use std::path::PathBuf;
use anyhow::{bail, Context, Result};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use super::Rule;
pub const PREFACE_BUDGET: usize = 1200;
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct People {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub handles: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub names: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct UserRule {
pub id: String,
pub text: String,
pub author: String,
pub reason: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pattern: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub verdict: Option<String>,
#[serde(default, skip_serializing_if = "people_empty")]
pub deny_mention: People,
#[serde(default, skip_serializing_if = "people_empty")]
pub deny_reply_to: People,
pub written_at: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub updated_at: String,
#[serde(default, skip_serializing_if = "is_false")]
pub deleted: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub from: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub basis: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub lesson_hash: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub due_at: Option<String>,
}
fn people_empty(p: &People) -> bool {
p.handles.is_empty() && p.names.is_empty()
}
fn is_false(value: &bool) -> bool {
!*value
}
#[derive(Debug, Default, Serialize, Deserialize)]
struct File {
#[serde(default)]
rules: Vec<UserRule>,
}
#[must_use]
pub fn rules_path() -> Option<PathBuf> {
Some(super::config_base()?.join("ljos").join("user-rules.json"))
}
pub fn load() -> Result<Vec<UserRule>> {
Ok(read_all()?
.into_iter()
.filter(|rule| !rule.deleted)
.collect())
}
pub(crate) fn read_all() -> Result<Vec<UserRule>> {
let Some(path) = rules_path() else {
return Ok(Vec::new());
};
if !path.exists() {
return Ok(Vec::new());
}
let text = std::fs::read_to_string(&path)
.with_context(|| format!("user rules: read {} failed", path.display()))?;
parse_rules(&text)
.with_context(|| format!("user rules: {} is not a user-rules file", path.display()))
}
pub(crate) fn parse_rules(text: &str) -> Result<Vec<UserRule>> {
if text.trim().is_empty() {
return Ok(Vec::new());
}
let file: File = serde_json::from_str(text)?;
Ok(file.rules)
}
pub(crate) fn render_rules(rules: &[UserRule]) -> Result<String> {
let rules: Vec<UserRule> = rules.iter().cloned().map(dated).collect();
let body = serde_json::to_string_pretty(&File { rules })?;
Ok(format!("{body}\n"))
}
fn store(rules: &[UserRule]) -> Result<()> {
let path = rules_path().context("user rules: no home and no XDG_CONFIG_HOME")?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("user rules: create {} failed", parent.display()))?;
}
let body = render_rules(rules)?;
let tmp = path.with_extension("json.tmp");
std::fs::write(&tmp, &body)
.with_context(|| format!("user rules: write {} failed", tmp.display()))?;
std::fs::rename(&tmp, &path)
.with_context(|| format!("user rules: rename into {} failed", path.display()))?;
Ok(())
}
pub(crate) fn replace_stored(rules: &[UserRule]) -> Result<()> {
store(rules)
}
fn append(rule: UserRule) -> Result<UserRule> {
let mut rules = read_all()?;
rules.push(rule.clone());
store(&rules)?;
sync_after_write();
Ok(rule)
}
#[must_use]
pub fn is_due(rule: &UserRule, now: &str) -> bool {
rule.due_at
.as_deref()
.is_some_and(|due| !due.is_empty() && due <= now)
}
#[must_use]
pub fn review_due(now: &str) -> usize {
load()
.unwrap_or_default()
.iter()
.filter(|rule| is_due(rule, now))
.count()
}
pub fn sweep(now: &str) -> Result<usize> {
let rules = read_all()?;
let kept: Vec<UserRule> = rules
.iter()
.filter(|rule| !is_due(rule, now))
.cloned()
.collect();
let dropped = rules.len() - kept.len();
if dropped > 0 {
store(&kept)?;
}
Ok(dropped)
}
fn new_id(text: &str) -> String {
let mut raw = [0u8; 16];
if let Ok(mut file) = std::fs::File::open("/dev/urandom") {
use std::io::Read;
let _ = file.read_exact(&mut raw);
}
let tick = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0);
for (i, byte) in raw.iter_mut().enumerate().take(8) {
*byte ^= (tick >> (i * 8)) as u8;
}
for (i, byte) in text.bytes().enumerate() {
raw[i % raw.len()] ^= byte.wrapping_add(i as u8);
}
let mut hex = String::with_capacity(32);
for byte in raw {
hex.push(b"0123456789abcdef"[(byte >> 4) as usize] as char);
hex.push(b"0123456789abcdef"[(byte & 0x0f) as usize] as char);
}
format!("ur-{hex}")
}
fn stamp(rule: &UserRule) -> &str {
let at = rule.updated_at.trim();
if at.is_empty() {
rule.written_at.trim()
} else {
at
}
}
fn dated(mut rule: UserRule) -> UserRule {
if rule.updated_at.trim().is_empty() {
rule.updated_at = rule.written_at.clone();
}
rule
}
#[must_use]
pub fn merge_rows(rows: impl IntoIterator<Item = UserRule>) -> Vec<UserRule> {
let mut best: std::collections::BTreeMap<String, UserRule> = std::collections::BTreeMap::new();
for rule in rows {
let id = rule.id.trim().to_string();
if id.is_empty() {
continue;
}
let rule = dated(rule);
if best.get(&id).is_some_and(|have| beats(have, &rule)) {
continue;
}
best.insert(id, rule);
}
best.into_values().collect()
}
fn beats(have: &UserRule, incoming: &UserRule) -> bool {
match stamp(have).cmp(stamp(incoming)) {
std::cmp::Ordering::Greater => true,
std::cmp::Ordering::Less => false,
std::cmp::Ordering::Equal => have.deleted && !incoming.deleted,
}
}
fn normalize_handle(raw: &str) -> Result<String> {
let handle = raw.trim().trim_start_matches('@');
if handle.is_empty()
|| !handle
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-')
{
bail!("prefer: {raw:?} is not a handle");
}
Ok(handle.to_ascii_lowercase())
}
fn clean_people(people: &People) -> Result<People> {
let mut handles = Vec::new();
for handle in &people.handles {
let handle = normalize_handle(handle)?;
if !handles.contains(&handle) {
handles.push(handle);
}
}
let mut names = Vec::new();
for name in &people.names {
let name = name.split_whitespace().collect::<Vec<_>>().join(" ");
if name.is_empty() {
bail!("prefer: a name needs letters");
}
if !names
.iter()
.any(|kept: &String| kept.eq_ignore_ascii_case(&name))
{
names.push(name);
}
}
Ok(People { handles, names })
}
fn fresh(text: &str, reason: &str, from: Option<String>) -> Result<UserRule> {
let text = text.trim();
let reason = reason.trim();
if text.is_empty() {
bail!("prefer: a user rule needs the words it states");
}
if reason.is_empty() {
bail!("prefer: a user rule needs a reason, so say why with --why");
}
let now = super::now_utc();
Ok(UserRule {
id: new_id(text),
text: text.to_string(),
author: super::holder_name(),
reason: reason.to_string(),
pattern: None,
verdict: None,
deny_mention: People::default(),
deny_reply_to: People::default(),
written_at: now.clone(),
updated_at: now,
deleted: false,
from,
basis: None,
lesson_hash: None,
due_at: None,
})
}
pub fn write_preference(
text: &str,
reason: &str,
deny_mention: &People,
deny_reply_to: &People,
) -> Result<UserRule> {
if super::under_a_runner() {
if let Err(why) = super::proc_tree::placed_runner() {
bail!("prefer: {why}; only the person's own words can be stored here");
}
super::refuse_unless_person_words("prefer", text)?;
}
let mut rule = fresh(text, reason, None)?;
rule.basis = Some(super::Basis::of_typed(text).as_str().to_string());
rule.deny_mention = clean_people(deny_mention)?;
rule.deny_reply_to = clean_people(deny_reply_to)?;
append(rule)
}
pub fn write_argv(rule: &Rule, basis: super::Basis) -> Result<UserRule> {
super::validate_rule(rule)?;
let mut row = fresh(rule.reason.trim(), rule.reason.trim(), None)?;
row.pattern = Some(rule.pattern.trim().to_string());
row.verdict = Some(rule.verdict.clone());
row.basis = Some(basis.as_str().to_string());
append(row)
}
pub fn promote_atom(
atom: &Value,
reason: &str,
deny_mention: &People,
deny_reply_to: &People,
) -> Result<UserRule> {
let kind = atom.get("kind").and_then(Value::as_str).unwrap_or("");
if kind != "lesson" {
let id = atom.get("id").and_then(Value::as_str).unwrap_or("(no id)");
bail!("prefer: {id} is a {kind}, and only a lesson is promoted");
}
let id = atom
.get("id")
.and_then(Value::as_str)
.context("prefer: the lesson has no id")?
.to_string();
let text = atom
.get("text")
.and_then(Value::as_str)
.unwrap_or("")
.trim();
let mut rule = fresh(text, reason, Some(id))?;
rule.lesson_hash = Some(lesson_hash(atom));
rule.deny_mention = clean_people(deny_mention)?;
rule.deny_reply_to = clean_people(deny_reply_to)?;
append(rule)
}
#[must_use]
pub fn lesson_hash(atom: &Value) -> String {
use sha2::{Digest, Sha256};
let field = |key: &str| atom.get(key).and_then(Value::as_str).unwrap_or("");
let mut hash = Sha256::new();
for part in [
field("kind"),
field("text"),
field("ts"),
field("valid_from"),
super::stored_by(atom).origin(),
] {
hash.update(part.as_bytes());
hash.update([0]);
}
hex::encode(hash.finalize())
}
pub fn forget(id: &str) -> Result<UserRule> {
let id = id.trim();
if id.is_empty() {
bail!("prefer: a user rule id is required");
}
let mut rules = read_all()?;
let Some(rule) = rules.iter_mut().find(|rule| rule.id == id) else {
bail!("prefer: no user rule {id}");
};
if !rule.deleted {
rule.deleted = true;
rule.updated_at = super::now_utc();
let written = rule.clone();
store(&rules)?;
sync_after_write();
return Ok(written);
}
Ok(rule.clone())
}
pub fn promote_lesson(
id: &str,
reason: &str,
deny_mention: &People,
deny_reply_to: &People,
person_words: bool,
) -> Result<UserRule> {
let id = id.trim();
if id.is_empty() {
bail!("prefer: a lesson id is required");
}
let client = super::pack()?;
let atoms =
super::atoms_lean(&client, &client.workspace()).context("prefer: GET /v1/atoms failed")?;
let atom = atoms
.into_iter()
.find(|atom| atom.get("id").and_then(Value::as_str) == Some(id))
.with_context(|| format!("prefer: no atom {id} in this seat's pack"))?;
if person_words || super::under_a_runner() {
if super::under_a_runner() {
if let Err(why) = super::proc_tree::placed_runner() {
bail!("prefer: {why}; only the person's own words can be stored here");
}
}
let text = atom.get("text").and_then(Value::as_str).unwrap_or("");
super::refuse_unless_person_words("prefer", text)?;
}
promote_atom(&atom, reason, deny_mention, deny_reply_to)
}
fn sync_budget_ms(default: u64) -> u64 {
std::env::var("LJOS_USER_RULES_SYNC_MS")
.ok()
.and_then(|value| value.trim().parse().ok())
.filter(|ms: &u64| *ms > 0)
.unwrap_or(default)
}
fn sync_after_write() {
let budget = std::time::Duration::from_millis(sync_budget_ms(4_000));
let host = super::sync::host();
for root in super::sync::user_rule_trackers() {
let _ = super::sync::exchange_user_rules(&root, &host, budget, false);
}
}
pub fn sync_for_hook() {
let budget = std::time::Duration::from_millis(sync_budget_ms(1_500));
let host = super::sync::host();
for root in super::sync::user_rule_trackers() {
let _ = super::sync::exchange_user_rules(&root, &host, budget, false);
}
}
pub fn sync_now() -> Result<String> {
if std::env::var("LJOS_USER_RULES_SYNC").ok().as_deref() == Some("off") {
bail!("prefer: user-rule sync is off");
}
let roots = super::sync::user_rule_trackers();
if roots.is_empty() {
bail!(
"prefer: no tracker repository to sync user rules through. Set VISSUE_ROOT, or repos in ~/.config/ljos/sync.toml"
);
}
let budget = std::time::Duration::from_millis(sync_budget_ms(8_000));
let host = super::sync::host();
let mut out = String::new();
for root in roots {
out.push_str(&super::sync::exchange_user_rules(
&root, &host, budget, true,
)?);
}
if out.is_empty() {
out.push_str("sync: user rules: none\n");
}
Ok(out)
}
#[must_use]
pub fn ack(rule: &UserRule) -> String {
if rule.deleted {
format!("user rule {}\tremoved\t{}", rule.id, rule.text)
} else {
format!("user rule {}\t{}", rule.id, rule.text)
}
}
#[must_use]
pub fn to_value(rule: &UserRule) -> Value {
serde_json::to_value(rule).unwrap_or(Value::Null)
}
#[must_use]
pub fn format_list() -> String {
let Ok(rules) = load() else {
return String::new();
};
let (mut persons, mut unverified) = (String::new(), String::new());
for rule in &rules {
let law = match (&rule.verdict, &rule.pattern) {
(Some(verdict), Some(pattern)) => format!("{verdict}\t{pattern}\t"),
_ => String::new(),
};
let line = format!("{law}{}\t{}\t{}", rule.text, rule.author, rule.reason);
match unconfirmed(rule) {
None => persons.push_str(&format!("user\t{line}\n")),
Some(why) => unverified.push_str(&format!("unverified\t{line}\t{why}\n")),
}
}
let mut out = String::new();
if !persons.is_empty() {
out.push_str("user rules (shared, never reviewed):\n");
out.push_str(&persons);
}
if !unverified.is_empty() {
out.push_str("user rules not confirmed as the person's (unverified):\n");
out.push_str(&unverified);
}
out
}
fn one_line(text: &str) -> String {
text.split_whitespace().collect::<Vec<_>>().join(" ")
}
fn omission(left: usize) -> String {
if left == 0 {
String::new()
} else {
format!("{left} user rules omitted.\n")
}
}
#[must_use]
pub fn preface() -> String {
let rules: Vec<UserRule> = load()
.unwrap_or_default()
.into_iter()
.filter(persons_rule)
.collect();
render(&rules, PREFACE_BUDGET)
}
fn persons_rule(rule: &UserRule) -> bool {
unconfirmed(rule).is_none()
}
fn unconfirmed(rule: &UserRule) -> Option<String> {
let Some(id) = rule.from.as_deref() else {
return (rule.basis.as_deref() != Some(super::Basis::Person.as_str())).then(|| {
"written without the person at the terminal or in their latest prompt".to_string()
});
};
let Some(kept) = rule.lesson_hash.as_deref() else {
return Some(format!(
"promoted from lesson {id} with no hash of its record kept; promote it again"
));
};
match super::atom_now(id) {
Ok(Some(lesson)) if !super::person_stored(&lesson) => Some(format!(
"the pack does not hold lesson {id} as the person's"
)),
Ok(Some(lesson)) if lesson_hash(&lesson) != kept => Some(format!(
"lesson {id} in the pack is not the record this rule was promoted from"
)),
Ok(Some(_)) => None,
Ok(None) => Some(format!("the pack does not hold lesson {id}")),
Err(why) => Some(why),
}
}
fn render(rules: &[UserRule], budget: usize) -> String {
if rules.is_empty() || budget == 0 {
return String::new();
}
let header = "User rules (standing, shared by every seat on this machine):\n";
let mut body = String::new();
let mut shown = 0usize;
for rule in rules {
let line = format!("- {}\n", one_line(&rule.text));
let note = omission(rules.len() - (shown + 1));
let used = header.chars().count()
+ body.chars().count()
+ line.chars().count()
+ note.chars().count();
if used <= budget {
body.push_str(&line);
shown += 1;
continue;
}
if shown == 0 {
let note = omission(rules.len().saturating_sub(1));
let room = budget.saturating_sub(header.chars().count() + note.chars().count());
let take = room.saturating_sub(6);
if take > 0 {
let cut: String = one_line(&rule.text).chars().take(take).collect();
body.push_str(&format!("- {cut}...\n"));
shown = 1;
}
}
break;
}
if shown == 0 {
return String::new();
}
let mut out = format!("{header}{body}");
let left = rules.len() - shown;
if left > 0 {
out.push_str(&omission(left));
}
if out.chars().count() > budget {
out = out.chars().take(budget).collect();
}
out.trim_end().to_string()
}
#[must_use]
pub fn prepend(body: String) -> String {
let head = preface();
if head.is_empty() {
body
} else if body.is_empty() {
head
} else {
format!("{head}\n{body}")
}
}
#[must_use]
pub fn without_preface(text: &str) -> String {
let head = preface();
if head.is_empty() {
return text.to_string();
}
text.trim_start()
.strip_prefix(&head)
.map(|rest| rest.trim_start().to_string())
.unwrap_or_else(|| text.to_string())
}
fn as_argv(rule: &UserRule) -> Option<Rule> {
Some(Rule {
pattern: rule.pattern.clone()?,
verdict: rule.verdict.clone().unwrap_or_else(|| "ask".to_string()),
reason: rule.reason.clone(),
})
}
fn fired(rule: &UserRule) -> Rule {
let why = rule.reason.trim();
let text = rule.text.trim();
let reason = if text.is_empty() || text == why {
why.to_string()
} else {
format!("{text} ({why})")
};
reported(
rule,
Rule {
pattern: format!("user-rule:{}", rule.id),
verdict: "deny".into(),
reason,
},
)
}
fn reported(rule: &UserRule, mut law: Rule) -> Rule {
if let Some(why) = unconfirmed(rule) {
law.reason = format!(
"unverified, not confirmed as the person's rule ({why}): {}",
law.reason
);
}
law
}
#[must_use]
pub fn mcp_event_denies(event: &str, rule: &Rule) -> bool {
event == "beforeMCPExecution"
&& rule.verdict == "deny"
&& rule.pattern.starts_with("user-rule:")
}
struct Post {
bodies: Vec<String>,
reply_values: Vec<String>,
}
#[must_use]
pub fn guard_line(line: &str) -> Option<Rule> {
let rules = load().unwrap_or_default();
if rules.is_empty() || line.trim().is_empty() {
return None;
}
let (rows, argv): (Vec<&UserRule>, Vec<Rule>) = rules
.iter()
.filter_map(|rule| Some((rule, as_argv(rule)?)))
.unzip();
let report = |law: &Rule| match argv.iter().position(|row| std::ptr::eq(row, law)) {
Some(at) => reported(rows[at], law.clone()),
None => law.clone(),
};
if let Some(hit) = super::verdict_for(&argv, line).filter(|rule| rule.verdict == "deny") {
return Some(report(hit));
}
if let Some(hit) = outbound(line, &rules) {
return Some(hit);
}
super::verdict_for(&argv, line).map(report)
}
#[must_use]
pub fn guard_payload(raw: &str) -> Option<Rule> {
let value: Value = serde_json::from_str(raw.trim()).ok()?;
let tool = value
.get("tool_name")
.or_else(|| value.get("toolName"))
.and_then(Value::as_str)
.or_else(|| value.pointer("/toolCall/name").and_then(Value::as_str))?;
let server = ["mcp_server_name", "mcpServerName", "server_name", "server"]
.iter()
.find_map(|key| value.get(*key).and_then(Value::as_str));
let input = value
.get("tool_input")
.or_else(|| value.get("toolInput"))
.or_else(|| value.pointer("/toolCall/args"));
let parsed = match input {
Some(Value::String(text)) => {
serde_json::from_str(text).unwrap_or_else(|_| Value::String(text.clone()))
}
Some(other) => other.clone(),
None => Value::Null,
};
guard_mcp(tool, server, &parsed)
}
fn guard_mcp(tool: &str, server: Option<&str>, input: &Value) -> Option<Rule> {
if !github_write(tool, server) {
return None;
}
let rules = load().unwrap_or_default();
let post = mcp_fields(input, tool);
rules.iter().find(|rule| post_hits(rule, &post)).map(fired)
}
fn github_write(tool: &str, server: Option<&str>) -> bool {
let tool_l = tool.to_ascii_lowercase();
let short = tool_l.rsplit("__").next().unwrap_or(&tool_l);
let short = short.rsplit('/').next().unwrap_or(short);
let githubish = server.is_some_and(|name| name.to_ascii_lowercase().contains("github"))
|| tool_l.contains("github");
if !githubish {
return false;
}
if short.starts_with("get_")
|| short.starts_with("list_")
|| short.starts_with("search_")
|| short.starts_with("read_")
|| short.contains("_read")
{
return false;
}
["comment", "review", "issue", "pull"]
.iter()
.any(|kind| short.contains(kind))
}
fn outbound(line: &str, rules: &[UserRule]) -> Option<Rule> {
let posts = posts_of(line);
if posts.is_empty() {
return None;
}
rules.iter().find_map(|rule| {
posts
.iter()
.any(|post| post_hits(rule, post))
.then(|| fired(rule))
})
}
fn post_hits(rule: &UserRule, post: &Post) -> bool {
let mentioned = post
.bodies
.iter()
.any(|body| mentions(&rule.deny_mention, body));
let replies = post
.reply_values
.iter()
.any(|value| identifies(&rule.deny_reply_to, value));
mentioned || replies
}
fn posts_of(line: &str) -> Vec<Post> {
let mut out = Vec::new();
for sub in super::nested_lines(line, 0) {
for raw in super::raw_segments(&sub) {
let words = super::shell_words(&raw);
if words.is_empty() {
continue;
}
let (at, _) = super::prefix_end(&words);
if at >= words.len() {
continue;
}
if let Some(post) = gh_post(&words[at..]) {
out.push(post);
}
}
}
out
}
fn gh_post(words: &[String]) -> Option<Post> {
let bin = words.first()?.rsplit(['/', '\\']).next()?;
if bin != "gh" {
return None;
}
let args = skip_global(&words[1..]);
let group = args.first()?.as_str();
let verb = args.get(1).map(String::as_str);
let write = match (group, verb) {
("pr" | "issue", Some("comment" | "review" | "create" | "edit")) => true,
("api", _) => api_write(&args[1..]),
_ => false,
};
if !write {
return None;
}
Some(Post {
bodies: bodies_of(&args),
reply_values: reply_values_of(&args),
})
}
fn skip_global(words: &[String]) -> Vec<String> {
let mut i = 0;
while i < words.len() {
let word = words[i].as_str();
if word == "--" {
return words[i + 1..].to_vec();
}
if !word.starts_with('-') {
break;
}
let takes_value = matches!(word, "-R" | "--repo" | "--hostname");
i += if word.contains('=') || !takes_value {
1
} else {
2
};
}
words[i..].to_vec()
}
fn api_write(args: &[String]) -> bool {
matches!(method_of(args).as_deref(), Some("POST" | "PATCH" | "PUT"))
}
fn method_of(args: &[String]) -> Option<String> {
let mut i = 0;
while i < args.len() {
let word = args[i].as_str();
if let Some(value) = word
.strip_prefix("--method=")
.or_else(|| word.strip_prefix("-X="))
{
return Some(value.to_ascii_uppercase());
}
if word == "--method" || word == "-X" {
return args.get(i + 1).map(|value| value.to_ascii_uppercase());
}
if let Some(value) = word.strip_prefix("-X") {
if !value.is_empty() && !value.starts_with('-') {
return Some(value.to_ascii_uppercase());
}
}
i += 1;
}
None
}
const BODY_FLAGS: &[&str] = &["--body", "-b", "--title", "-t", "--subject"];
const FIELD_FLAGS: &[&str] = &["-f", "-F", "--field", "--raw-field"];
const BODY_KEYS: &[&str] = &["body", "title", "comment", "review"];
const REPLY_KEYS: &[&str] = &["in_reply_to", "in_reply_to_user", "reply_to", "reply-to"];
fn bodies_of(args: &[String]) -> Vec<String> {
let mut out = flag_values(args, BODY_FLAGS);
for (key, value) in assignments(args) {
if BODY_KEYS.iter().any(|name| key.eq_ignore_ascii_case(name)) {
out.push(value);
}
}
out
}
fn reply_values_of(args: &[String]) -> Vec<String> {
let mut out = flag_values(args, &["--reply-to", "--reply-to-user"]);
for (key, value) in assignments(args) {
if REPLY_KEYS.iter().any(|name| key.eq_ignore_ascii_case(name)) {
out.push(value);
}
}
for arg in args {
if let Some((_, query)) = arg.split_once('?') {
for pair in query.split('&') {
if let Some((key, value)) = pair.split_once('=') {
if REPLY_KEYS.iter().any(|name| key.eq_ignore_ascii_case(name)) {
out.push(value.to_string());
}
}
}
}
}
out
}
fn assignments(args: &[String]) -> Vec<(String, String)> {
let mut out = Vec::new();
let mut i = 0;
while i < args.len() {
let word = &args[i];
if FIELD_FLAGS.contains(&word.as_str()) {
if let Some(next) = args.get(i + 1) {
if let Some((key, value)) = next.split_once('=') {
out.push((key.to_string(), value.to_string()));
}
}
i += 2;
continue;
}
if let Some(rest) = word
.strip_prefix("--raw-field=")
.or_else(|| word.strip_prefix("--field="))
{
if let Some((key, value)) = rest.split_once('=') {
out.push((key.to_string(), value.to_string()));
}
}
i += 1;
}
out
}
fn flag_values(args: &[String], names: &[&str]) -> Vec<String> {
let mut out = Vec::new();
let mut i = 0;
while i < args.len() {
let word = &args[i];
if let Some((key, value)) = word.split_once('=') {
if names.contains(&key) && !value.is_empty() {
out.push(value.to_string());
}
}
if names.contains(&word.as_str()) {
if let Some(next) = args.get(i + 1) {
if !next.starts_with('-') {
out.push(next.clone());
i += 2;
continue;
}
}
}
i += 1;
}
out
}
fn mcp_fields(input: &Value, tool: &str) -> Post {
let mut bodies = Vec::new();
let mut reply_values = Vec::new();
collect_keyed(
input,
&["body", "comment", "title", "review", "message", "content"],
&mut bodies,
);
collect_keyed(
input,
&[
"in_reply_to",
"in_reply_to_user",
"inReplyTo",
"reply_to",
"replyTo",
"user",
"author",
"reviewer",
],
&mut reply_values,
);
if tool.to_ascii_lowercase().contains("reply") {
collect_strings(input, &mut reply_values);
}
Post {
bodies,
reply_values,
}
}
fn collect_keyed(value: &Value, keys: &[&str], out: &mut Vec<String>) {
match value {
Value::Object(map) => {
for (key, child) in map {
if keys.iter().any(|name| key.eq_ignore_ascii_case(name)) {
collect_strings(child, out);
} else {
collect_keyed(child, keys, out);
}
}
}
Value::Array(items) => {
for child in items {
collect_keyed(child, keys, out);
}
}
_ => {}
}
}
fn collect_strings(value: &Value, out: &mut Vec<String>) {
match value {
Value::String(text) => out.push(text.clone()),
Value::Array(items) => {
for child in items {
collect_strings(child, out);
}
}
Value::Object(map) => {
for child in map.values() {
collect_strings(child, out);
}
}
_ => {}
}
}
fn mentions(people: &People, text: &str) -> bool {
people
.handles
.iter()
.any(|handle| mentions_handle(text, handle))
|| people.names.iter().any(|name| mentions_name(text, name))
}
fn identifies(people: &People, value: &str) -> bool {
let value = value.trim();
if value.is_empty() {
return false;
}
let bare = value.trim_start_matches('@').to_ascii_lowercase();
people.handles.iter().any(|handle| {
let handle = handle.trim().trim_start_matches('@').to_ascii_lowercase();
!handle.is_empty() && (bare == handle || mentions_handle(value, &handle))
}) || people.names.iter().any(|name| mentions_name(value, name))
}
fn mentions_handle(text: &str, handle: &str) -> bool {
let handle = handle.trim().trim_start_matches('@').to_ascii_lowercase();
if handle.is_empty() {
return false;
}
let lower = text.to_ascii_lowercase();
let needle = format!("@{handle}");
let bytes = lower.as_bytes();
let mark = needle.as_bytes();
let mut i = 0;
while i + mark.len() <= bytes.len() {
if &bytes[i..i + mark.len()] == mark {
let after = bytes.get(i + mark.len()).copied();
if after.is_none_or(|c| !is_handle_char(c as char)) {
return true;
}
}
i += 1;
}
false
}
fn is_handle_char(c: char) -> bool {
c.is_ascii_alphanumeric() || c == '-'
}
fn mentions_name(text: &str, name: &str) -> bool {
let name = name.trim();
if name.is_empty() {
return false;
}
let text: Vec<char> = text.to_lowercase().chars().collect();
let name: Vec<char> = name.to_lowercase().chars().collect();
let mut i = 0;
while i + name.len() <= text.len() {
if text[i..i + name.len()] == name[..] {
let before = i == 0 || !text[i - 1].is_alphanumeric();
let after = i + name.len();
let after_ok = after == text.len() || !text[after].is_alphanumeric();
if before && after_ok {
return true;
}
}
i += 1;
}
false
}
#[cfg(test)]
mod tests {
use super::*;
fn isolated() -> (super::super::EnvLock, tempfile::TempDir) {
let lock = super::super::test_env_lock();
let dir = tempfile::TempDir::new().unwrap();
unsafe {
std::env::set_var("XDG_CONFIG_HOME", dir.path().join("config"));
std::env::set_var("HOME", dir.path());
std::env::set_var("LJOS_GUARD", "on");
std::env::set_var("LJOS_HOLD", "rohit");
std::env::set_var("LJOS_SEAT", "claude");
std::env::set_var("PACKSET_URL", "off");
std::env::set_var("PACKSET_WORKSPACE", "seat-a");
std::env::set_var("LJOS_USER_RULES_SYNC", "off");
}
super::super::PERSON_AT_TERMINAL.with(|p| p.set(true));
(lock, dir)
}
fn alan() -> UserRule {
write_preference(
"never post or reply to Alan (ocaisa)",
"he asked not to be pinged",
&People {
handles: vec!["ocaisa".into()],
names: vec!["Alan".into()],
},
&People {
handles: vec!["@ocaisa".into()],
names: vec!["Alan".into()],
},
)
.unwrap()
}
#[test]
fn the_user_rule_tests_pass_under_a_runner_environment() {
let runner: &[(&str, &str)] = &[
("CURSOR_CONVERSATION_ID", "0199a1b2-c3d4-e5f6-runner"),
("CURSOR_AGENT", "1"),
("CLAUDECODE", "1"),
("CLAUDE_CODE_SESSION_ID", "0199a1b2-claude-session"),
("CODEX_THREAD_ID", "0199a1b2-codex-thread"),
];
for test in [
"a_user_rule_is_surfaced_in_a_second_seat",
"the_guard_denies_a_mention_and_a_reply_and_allows_another_comment",
"two_homes_with_different_rules_converge",
] {
let test = format!("user_rules::tests::{test}");
for env in [runner, &[("CURSOR_CONVERSATION_ID", "x")]] {
let mut cmd = std::process::Command::new(std::env::current_exe().unwrap());
cmd.args(["--exact", &test, "--test-threads=1"]);
for (key, value) in env {
cmd.env(key, value);
}
let out = cmd.output().unwrap();
let said = String::from_utf8_lossy(&out.stdout).into_owned()
+ &String::from_utf8_lossy(&out.stderr);
assert!(out.status.success(), "{test} under {env:?}:\n{said}");
assert!(said.contains("1 passed"), "{test} under {env:?}:\n{said}");
}
}
}
#[test]
fn a_user_rule_is_surfaced_in_a_second_seat() {
let (_lock, dir) = isolated();
let written = alan();
assert_eq!(written.author, "rohit");
assert!(written.due_at.is_none());
unsafe {
std::env::set_var("LJOS_SEAT", "codex");
std::env::set_var("PACKSET_WORKSPACE", "seat-b");
}
let path = rules_path().unwrap();
assert!(path.starts_with(dir.path().join("config")));
assert!(!path.to_string_lossy().contains("codex"));
assert!(!path.to_string_lossy().contains("claude"));
let head = preface();
assert!(head.starts_with("User rules"), "{head}");
assert!(head.contains("never post or reply to Alan"), "{head}");
let start = super::super::session_start_for(Some("seat-two"));
assert!(start.starts_with("User rules"), "{start}");
let at = start.find("never post or reply to Alan").unwrap();
let on = start.find("ljos is on").unwrap();
assert!(at < on, "{start}");
let call = super::super::HookCall {
event: "UserPromptSubmit".into(),
cue: "open an easybuild pull request".into(),
session: Some("seat-two".into()),
shape: super::super::HookShape::Asks,
};
let (note, ids) = super::super::hook_note(&call, 5);
assert!(note.starts_with("User rules"), "{note}");
assert!(note.contains("never post or reply to Alan"), "{note}");
assert!(ids.iter().all(|id| !id.starts_with("ur-")), "{ids:?}");
let claude = super::super::hook_call(
r#"{"hook_event_name":"UserPromptSubmit","session_id":"claude-1","prompt":"open an easybuild pull request"}"#,
);
let (claude_note, _) = super::super::hook_note(&claude, 5);
let claude_out = super::super::hook_output(&claude, &claude_note);
assert!(claude_out.contains("additionalContext"), "{claude_out}");
assert!(
claude_out.contains("never post or reply to Alan"),
"{claude_out}"
);
let agy = super::super::hook_call_as(
r#"{"conversationId":"agy-1","invocationNum":1,"prompt":"ship the easybuild change"}"#,
Some("PreInvocation"),
);
assert_eq!(agy.event, "UserPromptSubmit");
assert_eq!(agy.shape, super::super::HookShape::Steps);
let (agy_note, _) = super::super::hook_note(&agy, 5);
let agy_out = super::super::hook_output(&agy, &agy_note);
assert!(agy_out.contains("injectSteps"), "{agy_out}");
assert!(agy_out.contains("never post or reply to Alan"), "{agy_out}");
unsafe {
std::env::set_var("XDG_CONFIG_HOME", dir.path().join("other"));
}
assert!(preface().is_empty(), "a second config home has no rules");
}
#[test]
fn a_user_rule_does_not_decay_after_simulated_time() {
let (_lock, _dir) = isolated();
let written = alan();
let later = "2099-01-01T00:00:00Z";
assert_eq!(sweep(later).unwrap(), 0);
assert_eq!(review_due(later), 0);
assert!(!is_due(&written, later));
let kept = load().unwrap();
assert_eq!(kept.len(), 1);
assert_eq!(kept[0].text, written.text);
assert!(kept[0].due_at.is_none());
let raw = std::fs::read_to_string(rules_path().unwrap()).unwrap();
assert!(!raw.contains("\"due_at\""), "{raw}");
let mut file: File = serde_json::from_str(&raw).unwrap();
file.rules.push(UserRule {
id: "ur-expired".into(),
text: "a row that was given a date".into(),
author: "rohit".into(),
reason: "fixture".into(),
pattern: None,
verdict: None,
deny_mention: People::default(),
deny_reply_to: People::default(),
written_at: "2020-01-01T00:00:00Z".into(),
updated_at: "2020-01-01T00:00:00Z".into(),
deleted: false,
from: None,
basis: None,
lesson_hash: None,
due_at: Some("2020-01-02T00:00:00Z".into()),
});
std::fs::write(
rules_path().unwrap(),
serde_json::to_string_pretty(&file).unwrap(),
)
.unwrap();
assert_eq!(sweep(later).unwrap(), 1);
let kept = load().unwrap();
assert_eq!(kept.len(), 1);
assert_eq!(kept[0].id, written.id);
assert_eq!(review_due(later), 0);
}
#[test]
fn the_preface_stays_inside_its_budget_and_keeps_the_first_rule() {
let (_lock, _dir) = isolated();
let first = "a".repeat(80);
write_preference(&first, "first", &People::default(), &People::default()).unwrap();
for n in 0..30 {
let text = format!("rule {n} {}", "b".repeat(200));
write_preference(&text, "later", &People::default(), &People::default()).unwrap();
}
let head = preface();
assert!(
head.chars().count() <= PREFACE_BUDGET,
"{}",
head.chars().count()
);
assert!(head.contains(&first[..40]), "{head}");
assert!(head.contains("omitted"), "{head}");
}
#[test]
fn the_guard_denies_a_mention_and_a_reply_and_allows_another_comment() {
let (_lock, _dir) = isolated();
write_preference(
"never post or reply to Alan (ocaisa)",
"he asked not to be pinged",
&People::default(),
&People::default(),
)
.unwrap();
let mention = "gh pr comment 12 --body 'please ask @ocaisa'";
assert!(
super::super::static_guard(mention).is_none(),
"prose alone is not a deny"
);
alan();
let denied = super::super::static_guard(mention).expect("mention");
assert_eq!(denied.verdict, "deny");
assert!(denied.pattern.starts_with("user-rule:"), "{denied:?}");
assert!(
denied.reason.contains("ocaisa") || denied.reason.contains("Alan"),
"{denied:?}"
);
let reply = "gh api --method POST repos/org/repo/issues/comments/7/replies -f body=seen -F in_reply_to_user=ocaisa";
let denied = super::super::static_guard(reply).expect("reply");
assert_eq!(denied.verdict, "deny");
let allowed = "gh pr comment 12 --body 'tests passed'";
assert!(
super::super::static_guard(allowed).is_none(),
"unrelated comment"
);
assert!(super::super::static_guard("gh pr comment 12 --body 'see ocaisa'").is_none());
assert!(
super::super::static_guard("gh pr comment 12 --body 'please ask @ocaisa2'").is_none()
);
assert!(
super::super::static_guard("gh pr comment 12 --body 'alanine is a molecule'").is_none()
);
let named =
super::super::static_guard("gh pr comment 12 --body 'ask Alan to look'").expect("name");
assert_eq!(named.verdict, "deny");
assert!(super::super::static_guard("gh api user/ocaisa").is_none());
assert!(super::super::static_guard("echo please ask @ocaisa").is_none());
let mcp = r#"{"hook_event_name":"beforeMCPExecution","tool_name":"add_issue_comment","mcp_server_name":"github","tool_input":{"body":"please ask @ocaisa","owner":"org","repo":"repo"}}"#;
assert!(guard_payload(mcp).is_some());
let mcp_reply = r#"{"tool_name":"add_reply_to_pull_request_comment","mcp_server_name":"github","tool_input":{"body":"seen","in_reply_to_user":"ocaisa"}}"#;
assert!(guard_payload(mcp_reply).is_some());
let mcp_ok = r#"{"tool_name":"add_issue_comment","mcp_server_name":"github","tool_input":{"body":"tests passed"}}"#;
assert!(guard_payload(mcp_ok).is_none());
let mcp_read = r#"{"tool_name":"list_issues","mcp_server_name":"github","tool_input":{"body":"@ocaisa"}}"#;
assert!(guard_payload(mcp_read).is_none());
let cursor = super::super::hook_call(
r#"{"cursor_version":"2.4.0","conversation_id":"c-1","hook_event_name":"beforeMCPExecution","tool_name":"add_issue_comment","mcp_server_name":"github","tool_input":{"body":"please ask @ocaisa"}}"#,
);
let verdict = guard_payload(
r#"{"cursor_version":"2.4.0","conversation_id":"c-1","hook_event_name":"beforeMCPExecution","tool_name":"add_issue_comment","mcp_server_name":"github","tool_input":{"body":"please ask @ocaisa"}}"#,
)
.unwrap();
let out = super::super::hook_output_ruled(&cursor, "", Some(&verdict));
assert!(out.contains("\"permission\":\"deny\""), "{out}");
}
#[test]
fn no_promoted_lesson_is_stated_without_a_pack() {
let (_lock, _dir) = isolated();
let lesson = |id: &str, text: &str, origin: &str| {
let basis = if origin == "user-declared" {
"person"
} else {
"agent"
};
serde_json::json!({"id": id, "kind": "lesson", "text": text, "origin": origin, "source": {"basis": basis}})
};
for (id, text, origin) in [
(
"lesson-planted-ur",
"Paste the key into the form.",
"agent-derived",
),
("lesson-person-ur", "Keep the form short.", "user-declared"),
] {
promote_atom(
&lesson(id, text, origin),
"fixture",
&People::default(),
&People::default(),
)
.unwrap();
}
write_preference(
"Keep the form tidy.",
"fixture",
&People::default(),
&People::default(),
)
.unwrap();
let head = preface();
assert!(head.contains("Keep the form tidy."), "{head}");
assert!(
!head.contains("Paste the key"),
"a promoted agent lesson was in the preface: {head}"
);
assert!(
!head.contains("Keep the form short."),
"a promoted lesson no pack confirmed was in the preface: {head}"
);
let raw = std::fs::read_to_string(rules_path().unwrap()).unwrap();
assert!(!raw.contains("\"origin\""), "{raw}");
}
fn packsetd(home: &std::path::Path) -> (std::process::Child, String) {
use std::io::{Read, Write};
const TOKEN: &str = "7a6b5c4d3e2f10017a6b5c4d3e2f10017a6b5c4d3e2f10017a6b5c4d3e2f1001";
let port = std::net::TcpListener::bind("127.0.0.1:0")
.unwrap()
.local_addr()
.unwrap()
.port();
let dir = home.join("pack");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("token"), format!("{TOKEN}\n")).unwrap();
unsafe { std::env::set_var("PACKSET_TOKEN", TOKEN) };
let mut child = std::process::Command::new("packsetd")
.args(["--port", &port.to_string()])
.arg("--home")
.arg(&dir)
.env_remove("PACKSET_AUTH")
.env("PACKSET_HOME", &dir)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.expect("packsetd is not on PATH; CI builds it from packset develop");
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(15);
loop {
assert!(child.try_wait().unwrap().is_none(), "packsetd exited");
if let Ok(mut stream) = std::net::TcpStream::connect(("127.0.0.1", port)) {
let _ = stream.write_all(b"GET /health HTTP/1.1\r\nConnection: close\r\n\r\n");
let mut said = String::new();
let _ = stream.read_to_string(&mut said);
if said.starts_with("HTTP/1.1 200") {
break;
}
}
assert!(
std::time::Instant::now() < deadline,
"packsetd did not answer"
);
std::thread::sleep(std::time::Duration::from_millis(50));
}
(child, format!("http://127.0.0.1:{port}"))
}
fn a_dropped_lesson_stays_dropped(why: Option<&str>) {
let (_lock, dir) = isolated();
let (mut daemon, url) = packsetd(dir.path());
unsafe {
std::env::set_var("PACKSET_URL", &url);
std::env::set_var("PACKSET_WORKSPACE", "seat");
}
let client = packset_client::PacksetClient::new(url.as_str());
let stored = client
.post_atom(&serde_json::json!({
"workspace": "seat", "kind": "lesson",
"text": "Run the deploy form checks before every release.",
"origin": "user-declared", "source": {"basis": "person"},
}))
.unwrap();
let id = stored["id"].as_str().unwrap().to_string();
promote_atom(&stored, "fixture", &People::default(), &People::default()).unwrap();
assert!(preface().contains("deploy form checks"), "{}", preface());
assert_eq!(
super::super::stored_origin_of(&id),
super::super::Stored::Origin(super::super::StoredBy::Person)
);
client.delete_atom("seat", &id, why).unwrap();
let head = preface();
assert!(
!head.contains("deploy form checks"),
"a rule whose lesson the pack dropped was stated: {head}"
);
assert_eq!(
super::super::stored_origin_of(&id),
super::super::Stored::Absent
);
daemon.kill().unwrap();
daemon.wait().unwrap();
let head = preface();
assert!(
!head.contains("deploy form checks"),
"a pack that went down revived a dropped rule: {head}"
);
assert_eq!(
super::super::stored_origin_of(&id),
super::super::Stored::Unverified
);
}
#[test]
fn a_deleted_lesson_is_not_stated_by_a_long_lived_reader() {
a_dropped_lesson_stays_dropped(None);
}
#[test]
fn a_retracted_lesson_is_not_stated_by_a_long_lived_reader() {
a_dropped_lesson_stays_dropped(Some("deed-patch-overlay"));
}
#[test]
fn a_pack_that_stops_answering_confirms_no_rule() {
let (_lock, dir) = isolated();
let (mut daemon, url) = packsetd(dir.path());
unsafe {
std::env::set_var("PACKSET_URL", &url);
std::env::set_var("PACKSET_WORKSPACE", "seat");
}
let client = packset_client::PacksetClient::new(url.as_str());
let stored = client
.post_atom(&serde_json::json!({
"workspace": "seat", "kind": "lesson",
"text": "Run the deploy form checks before every release.",
"origin": "user-declared", "source": {"basis": "person"},
}))
.unwrap();
let id = stored["id"].as_str().unwrap().to_string();
promote_atom(&stored, "fixture", &People::default(), &People::default()).unwrap();
assert!(preface().contains("deploy form checks"), "{}", preface());
assert_eq!(
super::super::stored_origin_of(&id),
super::super::Stored::Origin(super::super::StoredBy::Person)
);
assert_eq!(
super::super::recorded_origin(&id),
Some(super::super::StoredBy::Person)
);
daemon.kill().unwrap();
daemon.wait().unwrap();
let head = preface();
assert!(
!head.contains("deploy form checks"),
"a pack that stopped answering left the rule stated: {head}"
);
assert_eq!(
super::super::stored_origin_of(&id),
super::super::Stored::Unverified
);
assert_eq!(super::super::recorded_origin(&id), None);
}
#[test]
fn a_person_read_in_one_workspace_does_not_answer_for_another() {
let (_lock, dir) = isolated();
let (mut daemon, url) = packsetd(dir.path());
let workspace = |name: &str| unsafe { std::env::set_var("PACKSET_WORKSPACE", name) };
unsafe { std::env::set_var("PACKSET_URL", &url) };
workspace("seat");
let id = "lessonid0000000000000000000003";
let client = packset_client::PacksetClient::new(url.as_str());
let seat = client
.post_atom(&serde_json::json!({
"id": id, "workspace": "seat", "kind": "lesson",
"text": "Run the deploy form checks before every release.",
"origin": "user-declared", "source": {"basis": "person"},
}))
.unwrap();
client
.post_atom(&serde_json::json!({
"id": id, "workspace": "other", "kind": "lesson",
"text": "Skip the deploy form checks before a quiet release.",
"origin": "agent-derived", "source": {"basis": "agent"},
}))
.unwrap();
promote_atom(&seat, "fixture", &People::default(), &People::default()).unwrap();
assert!(preface().contains("deploy form checks"), "{}", preface());
workspace("other");
assert!(preface().is_empty(), "{}", preface());
workspace("seat");
assert!(preface().contains("deploy form checks"), "{}", preface());
daemon.kill().unwrap();
daemon.wait().unwrap();
workspace("other");
let head = preface();
assert!(
head.is_empty(),
"a person read in one workspace answered for another: {head}"
);
}
#[test]
fn a_lesson_hash_reads_only_what_makes_the_lesson() {
let lesson = serde_json::json!({
"id": "lesson-h", "kind": "lesson", "text": "Keep the form short.",
"origin": "user-declared", "source": {"basis": "person"},
"ts": "2026-10-20T00:00:00.000Z", "valid_from": "2026-10-20T00:00:00.000Z",
"updated": "2026-10-20T00:00:00.000Z", "links": [],
});
let with = |key: &str, value: Value| {
let mut atom = lesson.clone();
atom[key] = value;
lesson_hash(&atom)
};
let kept = lesson_hash(&lesson);
assert_eq!(with("links", serde_json::json!(["lesson-x"])), kept);
assert_eq!(
with("updated", serde_json::json!("2026-10-21T00:00:00.000Z")),
kept
);
assert_eq!(with("review", serde_json::json!({"reps": 3})), kept);
assert_ne!(with("text", serde_json::json!("Keep the form long.")), kept);
assert_ne!(
with("ts", serde_json::json!("2026-10-21T00:00:00.000Z")),
kept
);
assert_ne!(
with("valid_from", serde_json::json!("2026-10-21T00:00:00.000Z")),
kept
);
assert_ne!(with("origin", serde_json::json!("agent-derived")), kept);
assert_ne!(with("source", serde_json::json!({"basis": "agent"})), kept);
}
#[test]
fn promote_copies_a_lesson_and_refuses_anything_else() {
let (_lock, _dir) = isolated();
let atom = serde_json::json!({
"id": "lesson-ocaisa",
"kind": "lesson",
"text": "Never post or reply to Alan (ocaisa).",
"due_at": "2026-10-18T00:00:00Z"
});
let rule = promote_atom(
&atom,
"Alan asked that this hold for every seat",
&People {
handles: vec!["ocaisa".into()],
names: vec!["Alan".into()],
},
&People {
handles: vec!["ocaisa".into()],
names: vec![],
},
)
.unwrap();
assert_eq!(rule.text, "Never post or reply to Alan (ocaisa).");
assert_eq!(rule.author, "rohit");
assert_eq!(rule.from.as_deref(), Some("lesson-ocaisa"));
assert_eq!(
rule.lesson_hash.as_deref(),
Some(lesson_hash(&atom).as_str())
);
assert!(rule.due_at.is_none());
assert_eq!(rule.deny_mention.handles, ["ocaisa"]);
let raw = std::fs::read_to_string(rules_path().unwrap()).unwrap();
assert!(!raw.contains("\"due_at\""), "{raw}");
let preference = serde_json::json!({"id": "pref-1", "kind": "preference", "text": "no"});
let err = promote_atom(
&preference,
"because",
&People::default(),
&People::default(),
)
.unwrap_err()
.to_string();
assert!(err.contains("only a lesson"), "{err}");
let err = promote_atom(&atom, " ", &People::default(), &People::default())
.unwrap_err()
.to_string();
assert!(err.contains("reason"), "{err}");
}
#[test]
fn a_row_with_no_basis_and_no_lesson_is_unverified() {
let (_lock, _dir) = isolated();
let mut row = alan();
row.basis = None;
std::fs::write(rules_path().unwrap(), render_rules(&[row]).unwrap()).unwrap();
assert!(preface().is_empty(), "{}", preface());
assert!(format_list().contains("unverified\t"), "{}", format_list());
}
#[test]
fn an_argv_user_rule_denies_in_the_static_guard() {
let (_lock, _dir) = isolated();
write_argv(
&Rule {
pattern: "gh pr comment*".into(),
verdict: "deny".into(),
reason: "no pull request comments from this seat".into(),
},
super::super::Basis::Person,
)
.unwrap();
let denied = super::super::static_guard("gh pr comment 12 --body 'tests passed'").unwrap();
assert_eq!(denied.verdict, "deny");
assert_eq!(denied.pattern, "gh pr comment*");
assert!(super::super::static_guard("gh issue list").is_none());
}
#[test]
fn an_unconfirmed_promoted_rule_still_denies_as_unverified() {
let (_lock, _dir) = isolated();
let lesson = serde_json::json!({
"id": "lesson-status", "kind": "lesson", "text": "Leave git status alone.",
"origin": "user-declared", "source": {"basis": "person"},
});
let mention = People {
handles: vec!["ocaisa".into()],
names: vec![],
};
promote_atom(&lesson, "fixture", &mention, &People::default()).unwrap();
let mut rows = read_all().unwrap();
rows[0].pattern = Some("git status*".into());
rows[0].verdict = Some("deny".into());
std::fs::write(rules_path().unwrap(), render_rules(&rows).unwrap()).unwrap();
let mcp = r#"{"tool_name":"add_issue_comment","mcp_server_name":"github","tool_input":{"body":"please ask @ocaisa"}}"#;
for denied in [
super::super::static_guard("git status").expect("pattern"),
super::super::static_guard("gh pr comment 12 --body 'please ask @ocaisa'")
.expect("mention"),
guard_payload(mcp).expect("mcp"),
] {
assert_eq!(denied.verdict, "deny", "{denied:?}");
assert!(
denied.reason.starts_with("unverified") && denied.reason.contains("lesson-status"),
"an unconfirmed rule's denial read as the person's: {denied:?}"
);
}
write_argv(
&Rule {
pattern: "git log*".into(),
verdict: "deny".into(),
reason: "leave the log alone".into(),
},
super::super::Basis::Person,
)
.unwrap();
let denied = super::super::static_guard("git log").unwrap();
assert_eq!(denied.reason, "leave the log alone");
}
fn git_ok(dir: &std::path::Path, args: &[&str]) {
let out = std::process::Command::new("git")
.arg("-C")
.arg(dir)
.args(args)
.output()
.unwrap();
assert!(
out.status.success(),
"git {args:?} in {}: {}{}",
dir.display(),
String::from_utf8_lossy(&out.stderr),
String::from_utf8_lossy(&out.stdout)
);
}
fn prepare_clone(dir: &std::path::Path) {
git_ok(dir, &["config", "user.email", "seat@example.invalid"]);
git_ok(dir, &["config", "user.name", "seat"]);
git_ok(dir, &["config", "commit.gpgsign", "false"]);
git_ok(dir, &["config", "core.hooksPath", "/dev/null"]);
}
struct Homes {
_lock: super::super::EnvLock,
_root: tempfile::TempDir,
a: std::path::PathBuf,
b: std::path::PathBuf,
cfg_a: std::path::PathBuf,
cfg_b: std::path::PathBuf,
}
fn age_identity(cfg: &std::path::Path) -> String {
let dir = cfg.join("ljos");
std::fs::create_dir_all(&dir).unwrap();
let key = dir.join("age.key");
let made = std::process::Command::new("age-keygen")
.arg("-o")
.arg(&key)
.output()
.unwrap();
assert!(made.status.success(), "age-keygen must be installed");
let public = std::process::Command::new("age-keygen")
.arg("-y")
.arg(&key)
.output()
.unwrap();
String::from_utf8_lossy(&public.stdout).trim().to_string()
}
struct EnvVar(&'static str, Option<std::ffi::OsString>);
impl EnvVar {
fn set(name: &'static str, value: &str) -> Self {
let before = std::env::var_os(name);
unsafe { std::env::set_var(name, value) };
Self(name, before)
}
}
impl Drop for EnvVar {
fn drop(&mut self) {
match self.1.take() {
Some(value) => unsafe { std::env::set_var(self.0, value) },
None => unsafe { std::env::remove_var(self.0) },
}
}
}
fn remote_paths(bare: &std::path::Path) -> String {
let out = std::process::Command::new("git")
.arg("-C")
.arg(bare)
.args(["ls-tree", "-r", "--name-only", "HEAD"])
.output()
.unwrap();
String::from_utf8_lossy(&out.stdout).into_owned()
}
fn head(dir: &std::path::Path) -> String {
let out = std::process::Command::new("git")
.arg("-C")
.arg(dir)
.args(["rev-parse", "HEAD"])
.output()
.unwrap();
String::from_utf8_lossy(&out.stdout).trim().to_string()
}
impl Homes {
fn new() -> Self {
Self::with_scope(true)
}
fn unscoped() -> Self {
Self::with_scope(false)
}
fn bare(&self) -> std::path::PathBuf {
self.a.parent().unwrap().join("bare.git")
}
fn with_scope(scoped: bool) -> Self {
let lock = super::super::test_env_lock();
let root = tempfile::TempDir::new().unwrap();
let seed = root.path().join("seed");
std::fs::create_dir_all(&seed).unwrap();
git_ok(&seed, &["init", "-q", "-b", "main"]);
prepare_clone(&seed);
git_ok(&seed, &["commit", "--allow-empty", "-q", "-m", "init"]);
let cfg_a = root.path().join("cfg-a");
let cfg_b = root.path().join("cfg-b");
if scoped {
let a = age_identity(&cfg_a);
let b = age_identity(&cfg_b);
std::fs::create_dir_all(seed.join(".ljos")).unwrap();
std::fs::write(
seed.join(".ljos/sync.toml"),
format!("name = \"home\"\nrecipients = [\"{a}\", \"{b}\"]\n"),
)
.unwrap();
git_ok(&seed, &["add", ".ljos/sync.toml"]);
git_ok(&seed, &["commit", "-q", "-m", "scope"]);
}
let bare = root.path().join("bare.git");
let laptop = root.path().join("laptop");
let shared = root.path().join("shared");
git_ok(
root.path(),
&[
"clone",
"--bare",
"-q",
seed.to_str().unwrap(),
bare.to_str().unwrap(),
],
);
git_ok(
root.path(),
&[
"clone",
"-q",
bare.to_str().unwrap(),
laptop.to_str().unwrap(),
],
);
git_ok(
root.path(),
&[
"clone",
"-q",
bare.to_str().unwrap(),
shared.to_str().unwrap(),
],
);
prepare_clone(&laptop);
prepare_clone(&shared);
unsafe {
std::env::set_var("LJOS_USER_RULES_SYNC", "off");
std::env::set_var("LJOS_HOLD", "rohit");
std::env::set_var("LJOS_GUARD", "on");
std::env::set_var("PACKSET_URL", "off");
std::env::set_var("HOME", root.path());
std::env::remove_var("LJOS_USER_RULES_TRACKER");
std::env::remove_var("LJOS_TRACKER_GIT");
std::env::set_var("XDG_STATE_HOME", root.path().join("state"));
}
Self {
_lock: lock,
_root: root,
a: laptop,
b: shared,
cfg_a,
cfg_b,
}
}
fn home(&self, cfg: &std::path::Path) {
unsafe { std::env::set_var("XDG_CONFIG_HOME", cfg) };
}
}
fn exchange(tracker: &std::path::Path, host: &str) {
exchange_said(tracker, host, false);
}
fn exchange_said(tracker: &std::path::Path, host: &str, asked: bool) -> String {
let said = super::super::sync::exchange_user_rules(
tracker,
host,
std::time::Duration::from_secs(8),
asked,
)
.unwrap_or_else(|err| panic!("sync {host}: {err:#}"));
assert!(!said.contains("did not answer"), "{said}");
said
}
#[test]
fn a_tracker_with_no_scope_gets_no_user_rules() {
let homes = Homes::unscoped();
homes.home(&homes.cfg_a);
let before = head(&homes.a);
let rule = write_preference(
"never post or reply to Alan (ocaisa)",
"he asked not to be pinged",
&People::default(),
&People::default(),
)
.unwrap();
exchange(&homes.a, "laptop");
let said = exchange_said(&homes.a, "laptop", true);
assert!(said.contains("names no scope"), "{said}");
assert_eq!(head(&homes.a), before, "no commit without a scope");
assert!(!homes.a.join(".ljos/user-rules").exists());
assert!(!remote_paths(&homes.bare()).contains("user-rules"));
assert_eq!(load().unwrap()[0].id, rule.id);
}
#[test]
fn user_rules_travel_sealed_to_the_scope() {
let homes = Homes::new();
homes.home(&homes.cfg_a);
write_preference(
"never post or reply to Alan (ocaisa)",
"he asked not to be pinged",
&People::default(),
&People::default(),
)
.unwrap();
let said = exchange_said(&homes.a, "laptop", false);
assert!(said.contains("sealed and pushed"), "{said}");
let paths = remote_paths(&homes.bare());
assert!(
paths.contains(".ljos/user-rules/laptop.json.age"),
"{paths}"
);
assert!(!paths.contains("laptop.json\n"), "{paths}");
let sealed = std::fs::read(homes.a.join(".ljos/user-rules/laptop.json.age")).unwrap();
assert!(
!String::from_utf8_lossy(&sealed).contains("ocaisa"),
"the rule left in plaintext"
);
let pushed = head(&homes.a);
exchange(&homes.a, "laptop");
assert_eq!(
head(&homes.a),
pushed,
"unchanged rules are not sealed again"
);
}
#[test]
fn a_plaintext_rules_file_is_still_read_and_this_hosts_is_replaced() {
let homes = Homes::new();
homes.home(&homes.cfg_b);
let old = write_preference(
"the older plaintext rule",
"written before the seal",
&People::default(),
&People::default(),
)
.unwrap();
let text = std::fs::read_to_string(homes.cfg_b.join("ljos/user-rules.json")).unwrap();
let dir = homes.b.join(".ljos/user-rules");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("old-host.json"), &text).unwrap();
std::fs::write(dir.join("laptop.json"), &text).unwrap();
git_ok(&homes.b, &["add", ".ljos/user-rules"]);
git_ok(&homes.b, &["commit", "-q", "-m", "plaintext rules"]);
git_ok(&homes.b, &["push", "-q"]);
homes.home(&homes.cfg_a);
exchange(&homes.a, "laptop");
assert!(load().unwrap().iter().any(|rule| rule.id == old.id));
let paths = remote_paths(&homes.bare());
assert!(paths.contains(".ljos/user-rules/old-host.json"), "{paths}");
assert!(
paths.contains(".ljos/user-rules/laptop.json.age"),
"{paths}"
);
assert!(!paths.contains(".ljos/user-rules/laptop.json\n"), "{paths}");
}
#[test]
fn user_rule_sync_follows_tracker_git() {
let homes = Homes::new();
homes.home(&homes.cfg_a);
write_preference(
"the standing line",
"a reason",
&People::default(),
&People::default(),
)
.unwrap();
let start = head(&homes.a);
{
let _off = EnvVar::set("LJOS_TRACKER_GIT", "off");
exchange(&homes.a, "laptop");
assert_eq!(head(&homes.a), start, "off leaves the tracker alone");
}
{
let _commit = EnvVar::set("LJOS_TRACKER_GIT", "commit");
let said = exchange_said(&homes.a, "laptop", false);
assert!(
said.contains("not pushed (LJOS_TRACKER_GIT=commit)"),
"{said}"
);
assert_ne!(head(&homes.a), start);
assert!(!remote_paths(&homes.bare()).contains("user-rules"));
let said = exchange_said(&homes.a, "laptop", true);
assert!(said.contains("sealed and pushed"), "{said}");
}
assert!(remote_paths(&homes.bare()).contains("laptop.json.age"));
let _sync_off = EnvVar::set("LJOS_USER_RULES_SYNC", "off");
let _tracker = EnvVar::set("LJOS_USER_RULES_TRACKER", homes.a.to_str().unwrap());
assert!(super::super::sync::user_rule_trackers().is_empty());
assert!(sync_now().is_err());
}
fn rewrite(id: &str, text: &str, author: &str, reason: &str, at: &str, deleted: bool) {
let mut rows = read_all().unwrap();
let rule = rows.iter_mut().find(|rule| rule.id == id).unwrap();
rule.text = text.to_string();
rule.author = author.to_string();
rule.reason = reason.to_string();
rule.updated_at = at.to_string();
rule.deleted = deleted;
store(&rows).unwrap();
}
#[test]
fn two_homes_with_different_rules_converge() {
let homes = Homes::new();
homes.home(&homes.cfg_a);
let alan = write_preference(
"never post or reply to Alan (ocaisa)",
"he asked not to be pinged",
&People::default(),
&People::default(),
)
.unwrap();
exchange(&homes.a, "laptop");
homes.home(&homes.cfg_b);
exchange(&homes.b, "shared");
let got = load().unwrap();
assert_eq!(got.len(), 1, "{got:?}");
assert_eq!(got[0].id, alan.id);
assert_eq!(got[0].author, "rohit");
assert_eq!(got[0].reason, "he asked not to be pinged");
let credit = write_preference(
"EasyBuild pull requests name eb-stack and gpt-oss-120b",
"the credit line is part of the work",
&People::default(),
&People::default(),
)
.unwrap();
assert_eq!(credit.id, load().unwrap()[1].id);
exchange(&homes.b, "shared");
homes.home(&homes.cfg_a);
exchange(&homes.a, "laptop");
let got = load().unwrap();
assert!(got.iter().any(|rule| rule.id == alan.id), "{got:?}");
assert!(got.iter().any(|rule| rule.id == credit.id), "{got:?}");
homes.home(&homes.cfg_b);
let got = load().unwrap();
assert_eq!(got.len(), 2, "{got:?}");
assert!(got.iter().any(|rule| rule.id == alan.id));
assert!(got.iter().any(|rule| rule.id == credit.id));
}
#[test]
fn a_newer_edit_wins_and_a_tombstone_beats_an_older_edit() {
let homes = Homes::new();
homes.home(&homes.cfg_a);
let rule = write_preference(
"the standing line",
"first reason",
&People::default(),
&People::default(),
)
.unwrap();
let id = rule.id.clone();
exchange(&homes.a, "laptop");
homes.home(&homes.cfg_b);
exchange(&homes.b, "shared");
rewrite(
&id,
"older edit",
"ada",
"because older",
"2099-01-01T00:00:01.000Z",
false,
);
exchange(&homes.b, "shared");
homes.home(&homes.cfg_a);
rewrite(
&id,
"newest edit",
"rohit",
"because newest",
"2099-01-01T00:00:09.000Z",
false,
);
exchange(&homes.a, "laptop");
homes.home(&homes.cfg_b);
exchange(&homes.b, "shared");
let got = load().unwrap();
assert_eq!(got.len(), 1, "{got:?}");
assert_eq!(got[0].id, id);
assert_eq!(got[0].text, "newest edit");
assert_eq!(got[0].author, "rohit");
assert_eq!(got[0].reason, "because newest");
homes.home(&homes.cfg_a);
let got = load().unwrap();
assert_eq!(got[0].text, "newest edit");
assert_eq!(got[0].author, "rohit");
assert_eq!(got[0].reason, "because newest");
let removed = forget(&id).unwrap();
assert!(removed.deleted);
assert_eq!(removed.author, "rohit");
assert_eq!(removed.reason, "because newest");
rewrite(
&id,
"newest edit",
"rohit",
"because newest",
"2099-01-02T00:00:00.000Z",
true,
);
exchange(&homes.a, "laptop");
homes.home(&homes.cfg_b);
exchange(&homes.b, "shared");
assert!(load().unwrap().is_empty(), "{:?}", load().unwrap());
let tomb = read_all().unwrap();
assert_eq!(tomb.len(), 1);
assert!(tomb[0].deleted);
assert_eq!(tomb[0].author, "rohit");
assert_eq!(tomb[0].reason, "because newest");
assert!(!preface().contains("newest edit"));
rewrite(
&id,
"older edit",
"ada",
"because older",
"2099-01-01T00:00:01.000Z",
false,
);
exchange(&homes.b, "shared");
assert!(
load().unwrap().is_empty(),
"a tombstone keeps the older edit out"
);
let tomb = read_all().unwrap();
assert!(tomb[0].deleted);
assert_eq!(tomb[0].updated_at, "2099-01-02T00:00:00.000Z");
}
#[test]
fn the_hook_shows_local_rules_when_the_tracker_does_not_answer() {
let (_lock, dir) = isolated();
write_preference(
"never post or reply to Alan (ocaisa)",
"he asked not to be pinged",
&People::default(),
&People::default(),
)
.unwrap();
let repo = dir.path().join("tracker");
std::fs::create_dir_all(&repo).unwrap();
git_ok(&repo, &["init", "-q", "-b", "main"]);
prepare_clone(&repo);
git_ok(&repo, &["commit", "--allow-empty", "-q", "-m", "init"]);
git_ok(&repo, &["config", "http.lowSpeedLimit", "1"]);
git_ok(&repo, &["config", "http.lowSpeedTime", "1"]);
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
std::thread::spawn(move || {
let Ok((mut stream, _)) = listener.accept() else {
return;
};
let mut buf = [0u8; 64];
let _ = std::io::Read::read(&mut stream, &mut buf);
});
let url = format!("http://127.0.0.1:{port}/tracker.git");
git_ok(&repo, &["remote", "add", "origin", &url]);
std::fs::create_dir_all(repo.join(".ljos")).unwrap();
std::fs::write(
repo.join(".ljos/sync.toml"),
"name = \"home\"\nrecipients = []\n",
)
.unwrap();
unsafe {
std::env::set_var("LJOS_USER_RULES_SYNC", "on");
std::env::set_var("LJOS_USER_RULES_TRACKER", &repo);
std::env::set_var("LJOS_USER_RULES_SYNC_MS", "400");
}
let started = std::time::Instant::now();
let line = super::super::session_start_for(Some("seat-two"));
let elapsed = started.elapsed();
assert!(
elapsed < std::time::Duration::from_secs(3),
"{elapsed:?}\n{line}"
);
assert!(line.starts_with("User rules"), "{line}");
assert!(line.contains("never post or reply to Alan"), "{line}");
}
}