Skip to main content

ljos_cli/
user_rules.rs

1//! Standing rules shared by every seat of this user, and by every machine
2//! that shares the tracker repository.
3//!
4//! `ljos prefer --user` and `ljos rule --user` write them. The copy the
5//! hooks read is the user config, not the pack, so the review clock never
6//! sees them. The tracker carries one file per machine, and a sync merges
7//! those files by id.
8//!
9//! A row written before `basis` was kept has neither `basis` nor `from`.
10//! It stays in the preface, marked not confirmed, until the person runs
11//! `ljos prefer --user --review --keep`. The cutoff is a local receipt
12//! written once by the first person-side command. A row written after
13//! that receipt with no basis stays out. A hook does not write the
14//! receipt: it runs under the runner.
15
16use std::io::Write;
17use std::path::PathBuf;
18
19use anyhow::{bail, Context, Result};
20use serde::{Deserialize, Serialize};
21use serde_json::Value;
22
23use super::Rule;
24
25/// How much of the preface a brief or a hook will carry.
26pub const PREFACE_BUDGET: usize = 1200;
27
28/// Handles and names a guard matches. The prose of the rule is not read.
29#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
30pub struct People {
31    /// GitHub logins, without a leading `@`, compared case-insensitively.
32    #[serde(default, skip_serializing_if = "Vec::is_empty")]
33    pub handles: Vec<String>,
34    /// Display names, matched as whole words.
35    #[serde(default, skip_serializing_if = "Vec::is_empty")]
36    pub names: Vec<String>,
37}
38
39/// One standing rule. `due_at` is never written. A sweep drops a row only
40/// when that field is present and already past, which a row this module
41/// writes is not.
42#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
43pub struct UserRule {
44    pub id: String,
45    pub text: String,
46    pub author: String,
47    pub reason: String,
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub pattern: Option<String>,
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    pub verdict: Option<String>,
52    #[serde(default, skip_serializing_if = "people_empty")]
53    pub deny_mention: People,
54    #[serde(default, skip_serializing_if = "people_empty")]
55    pub deny_reply_to: People,
56    pub written_at: String,
57    /// When this row last changed. A merge keeps the newer stamp, and with
58    /// it that row's author and reason.
59    #[serde(default, skip_serializing_if = "String::is_empty")]
60    pub updated_at: String,
61    /// A deletion. The row stays so an older copy of the same id does not
62    /// come back on the next sync.
63    #[serde(default, skip_serializing_if = "is_false")]
64    pub deleted: bool,
65    /// The lesson id a promote copied, when this row came from one.
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    pub from: Option<String>,
68    /// Who stands behind a rule written as words or an argv law, as
69    /// `source.basis` on a seat atom: `person` only when the person typed
70    /// it or said it in their latest prompt. A row with neither this nor
71    /// `from` was written before that stamp. It stays visible, marked
72    /// held, when it is older than the cutoff receipt.
73    #[serde(default, skip_serializing_if = "Option::is_none")]
74    pub basis: Option<String>,
75    /// [`lesson_hash`] of that lesson's record as the promote read it. A
76    /// record under `from` that hashes otherwise is another lesson.
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    pub lesson_hash: Option<String>,
79    /// Absent on every row the commands write. Present only so a sweep can
80    /// drop a row that was given a review date by hand.
81    #[serde(default, skip_serializing_if = "Option::is_none")]
82    pub due_at: Option<String>,
83}
84
85fn people_empty(p: &People) -> bool {
86    p.handles.is_empty() && p.names.is_empty()
87}
88
89fn is_false(value: &bool) -> bool {
90    !*value
91}
92
93#[derive(Debug, Default, Serialize, Deserialize)]
94struct File {
95    #[serde(default)]
96    rules: Vec<UserRule>,
97}
98
99/// `$XDG_CONFIG_HOME/ljos/user-rules.json`, else `~/.config/ljos/user-rules.json`.
100#[must_use]
101pub fn rules_path() -> Option<PathBuf> {
102    Some(super::config_base()?.join("ljos").join("user-rules.json"))
103}
104
105/// The most a user-rules file may hold. A larger file is not read.
106pub const RULES_FILE_CAP: u64 = 1024 * 1024;
107
108/// The rows on disk that are still in force. An absent file is an empty
109/// list. Tombstones are left out.
110///
111/// # Errors
112///
113/// The file is empty, a dangling symlink, not a regular file of at most
114/// [`RULES_FILE_CAP`] bytes, not readable, or not the JSON this module writes.
115pub fn load() -> Result<Vec<UserRule>> {
116    Ok(read_all()?
117        .into_iter()
118        .filter(|rule| !rule.deleted)
119        .collect())
120}
121
122/// Every row on disk, tombstones included.
123///
124/// # Errors
125///
126/// The file is empty, a dangling symlink, not a regular file of at most
127/// [`RULES_FILE_CAP`] bytes, not readable, or not the JSON this module writes.
128pub(crate) fn read_all() -> Result<Vec<UserRule>> {
129    let Some(path) = rules_path() else {
130        return Ok(Vec::new());
131    };
132    // Empty text, whitespace, and JSON that does not parse can be a
133    // rewrite caught between a truncate and the new bytes. A dangling
134    // symlink, a FIFO, and a file over the cap are the file they are,
135    // and those errors return at once.
136    // The stamp is the length, the mtime, and a prefix of the bytes.
137    // A stamp that stays the same for `RULES_STABLE` is a corrupt file,
138    // and that error is returned. A stamp that changes is a rewrite still in
139    // progress: keep reading until a parse succeeds or `RULES_HARD` has
140    // passed, and then fail closed. A covered write is not allowed on
141    // that last error.
142    let started = std::time::Instant::now();
143    let mut stable_since = std::time::Instant::now();
144    let mut last_stamp: Option<RulesStamp> = None;
145    loop {
146        match read_rules_text(&path) {
147            Ok(None) => return Ok(Vec::new()),
148            Ok(Some(text)) => match parse_rules(&text) {
149                Ok(rules) => return Ok(rules),
150                Err(err) => {
151                    let err = err.context(format!(
152                        "user rules: {} is not a user-rules file",
153                        path.display()
154                    ));
155                    if rules_read_gave_up(
156                        &path,
157                        Some(text.as_bytes()),
158                        &mut last_stamp,
159                        &mut stable_since,
160                        started,
161                    ) {
162                        return Err(err);
163                    }
164                }
165            },
166            Err(err) if rules_rewrite_symptom(&err) => {
167                if rules_read_gave_up(&path, None, &mut last_stamp, &mut stable_since, started) {
168                    return Err(err);
169                }
170            }
171            Err(err) => return Err(err),
172        }
173        std::thread::sleep(std::time::Duration::from_millis(1));
174    }
175}
176
177/// How long an unchanged bad file is treated as corrupt. A rewrite is
178/// empty between its truncate and its write, and on a busy machine the
179/// writer can be set aside for longer than a few milliseconds there, so
180/// the window is wide enough that a paused rewrite is not read as a
181/// corrupt file. A file that is truly empty costs this much once.
182const RULES_STABLE: std::time::Duration = std::time::Duration::from_millis(250);
183/// How long a file whose bytes keep changing is re-read. Well inside the
184/// hook's own five-second budget.
185const RULES_HARD: std::time::Duration = std::time::Duration::from_millis(600);
186
187#[derive(PartialEq, Eq)]
188struct RulesStamp {
189    len: u64,
190    mtime: Option<std::time::SystemTime>,
191    prefix: [u8; 64],
192    prefix_len: usize,
193}
194
195fn rules_rewrite_symptom(err: &anyhow::Error) -> bool {
196    format!("{err:#}").contains(" is empty")
197}
198
199fn rules_stamp(path: &std::path::Path, bytes: Option<&[u8]>) -> RulesStamp {
200    let meta = std::fs::symlink_metadata(path).ok();
201    let prefix_src = bytes.unwrap_or(b"");
202    let n = prefix_src.len().min(64);
203    let mut prefix = [0u8; 64];
204    prefix[..n].copy_from_slice(&prefix_src[..n]);
205    RulesStamp {
206        len: meta.as_ref().map(|m| m.len()).unwrap_or(0),
207        mtime: meta.as_ref().and_then(|m| m.modified().ok()),
208        prefix,
209        prefix_len: n,
210    }
211}
212
213/// `true` when this read should be the last: the stamp has not moved for
214/// [`RULES_STABLE`], or the hard cap has passed.
215fn rules_read_gave_up(
216    path: &std::path::Path,
217    bytes: Option<&[u8]>,
218    last: &mut Option<RulesStamp>,
219    stable_since: &mut std::time::Instant,
220    started: std::time::Instant,
221) -> bool {
222    let stamp = rules_stamp(path, bytes);
223    let same = match last {
224        Some(prev) if *prev == stamp => true,
225        _ => {
226            *last = Some(stamp);
227            *stable_since = std::time::Instant::now();
228            false
229        }
230    };
231    if started.elapsed() >= RULES_HARD {
232        return true;
233    }
234    same && stable_since.elapsed() >= RULES_STABLE
235}
236
237/// The text of the user-rules file. `Ok(None)` when no file is there.
238/// A zero-byte file, a file of only whitespace, and a dangling symlink
239/// are errors. The open does not block: a FIFO, a directory, or any
240/// other non-regular file is an error, and a regular file over
241/// [`RULES_FILE_CAP`] is not read.
242fn read_rules_text(path: &std::path::Path) -> Result<Option<String>> {
243    use std::io::Read;
244    use std::os::unix::fs::OpenOptionsExt;
245    let file = match std::fs::OpenOptions::new()
246        .read(true)
247        .custom_flags(libc::O_NONBLOCK)
248        .open(path)
249    {
250        Ok(file) => file,
251        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
252            return match std::fs::symlink_metadata(path) {
253                Ok(meta) if meta.file_type().is_symlink() => {
254                    bail!("user rules: {} is a dangling symlink", path.display());
255                }
256                Ok(_) => Err(anyhow::Error::from(err)
257                    .context(format!("user rules: read {} failed", path.display()))),
258                Err(stat) if stat.kind() == std::io::ErrorKind::NotFound => Ok(None),
259                Err(stat) => Err(anyhow::Error::from(stat)
260                    .context(format!("user rules: stat {} failed", path.display()))),
261            };
262        }
263        Err(err) => {
264            return Err(anyhow::Error::from(err)
265                .context(format!("user rules: read {} failed", path.display())));
266        }
267    };
268    // Stat the opened descriptor. The type and the size are this inode.
269    let meta = file
270        .metadata()
271        .with_context(|| format!("user rules: stat {} failed", path.display()))?;
272    if !meta.is_file() {
273        bail!("user rules: {} is not a regular file", path.display());
274    }
275    if meta.len() == 0 {
276        bail!("user rules: {} is empty", path.display());
277    }
278    if meta.len() > RULES_FILE_CAP {
279        bail!(
280            "user rules: {} is {} bytes, over the {} byte cap",
281            path.display(),
282            meta.len(),
283            RULES_FILE_CAP
284        );
285    }
286    let mut text = String::new();
287    file.take(RULES_FILE_CAP)
288        .read_to_string(&mut text)
289        .with_context(|| format!("user rules: read {} failed", path.display()))?;
290    if text.trim().is_empty() {
291        bail!("user rules: {} is empty", path.display());
292    }
293    Ok(Some(text))
294}
295
296/// Parse one user-rules document. An empty body is an empty list.
297///
298/// # Errors
299///
300/// The text is not the JSON this module writes.
301pub(crate) fn parse_rules(text: &str) -> Result<Vec<UserRule>> {
302    if text.trim().is_empty() {
303        return Ok(Vec::new());
304    }
305    let file: File = serde_json::from_str(text)?;
306    Ok(file.rules)
307}
308
309/// The document this module writes, tombstones included.
310///
311/// # Errors
312///
313/// The rows cannot be encoded.
314pub(crate) fn render_rules(rules: &[UserRule]) -> Result<String> {
315    let rules: Vec<UserRule> = rules.iter().cloned().map(dated).collect();
316    let body = serde_json::to_string_pretty(&File { rules })?;
317    Ok(format!("{body}\n"))
318}
319
320fn store(rules: &[UserRule]) -> Result<()> {
321    let _durable = crate::bookkeep::durable_write();
322    let path = rules_path().context("user rules: no home and no XDG_CONFIG_HOME")?;
323    let Some(parent) = path.parent() else {
324        anyhow::bail!("user rules: {} has no directory", path.display());
325    };
326    std::fs::create_dir_all(parent)
327        .with_context(|| format!("user rules: create {} failed", parent.display()))?;
328    let body = render_rules(rules)?;
329    if std::fs::read(&path).is_ok_and(|have| have == body.as_bytes()) {
330        return Ok(());
331    }
332    // A unique temp in the same directory, fsync, then rename. Two stores
333    // no longer share `user-rules.json.tmp`, and a reader never sees a
334    // truncated document from this write.
335    let tmp = unique_rules_temp(parent)?;
336    let write_tmp = (|| -> Result<()> {
337        let mut file = std::fs::OpenOptions::new()
338            .write(true)
339            .create_new(true)
340            .open(&tmp)
341            .with_context(|| format!("user rules: create {} failed", tmp.display()))?;
342        file.write_all(body.as_bytes())
343            .with_context(|| format!("user rules: write {} failed", tmp.display()))?;
344        file.sync_all()
345            .with_context(|| format!("user rules: sync {} failed", tmp.display()))?;
346        Ok(())
347    })();
348    if let Err(err) = write_tmp {
349        let _ = std::fs::remove_file(&tmp);
350        return Err(err);
351    }
352    if let Err(err) = std::fs::rename(&tmp, &path) {
353        let _ = std::fs::remove_file(&tmp);
354        return Err(err).context(format!("user rules: rename into {} failed", path.display()));
355    }
356    if let Ok(dir) = std::fs::File::open(parent) {
357        let _ = dir.sync_all();
358    }
359    Ok(())
360}
361
362fn unique_rules_temp(parent: &std::path::Path) -> Result<std::path::PathBuf> {
363    for n in 0..32u32 {
364        let nanos = std::time::SystemTime::now()
365            .duration_since(std::time::UNIX_EPOCH)
366            .map(|d| d.as_nanos())
367            .unwrap_or(0);
368        let path = parent.join(format!(
369            ".user-rules.{}.{nanos}.{n}.tmp",
370            std::process::id()
371        ));
372        if !path.exists() {
373            return Ok(path);
374        }
375    }
376    anyhow::bail!(
377        "user rules: could not pick a temp name in {}",
378        parent.display()
379    );
380}
381
382/// Replace the local file. Used by a sync, which must not start another sync.
383pub(crate) fn replace_stored(rules: &[UserRule]) -> Result<()> {
384    store(rules)
385}
386
387fn append(rule: UserRule) -> Result<UserRule> {
388    let mut rules = read_all()?;
389    rules.push(rule.clone());
390    store(&rules)?;
391    sync_after_write();
392    Ok(rule)
393}
394
395/// Whether a row would be due at `now`. A row with no `due_at` is never due.
396#[must_use]
397pub fn is_due(rule: &UserRule, now: &str) -> bool {
398    rule.due_at
399        .as_deref()
400        .is_some_and(|due| !due.is_empty() && due <= now)
401}
402
403/// How many rows would be due at `now`. Ordinary rows contribute nothing.
404#[must_use]
405pub fn review_due(now: &str) -> usize {
406    load()
407        .unwrap_or_default()
408        .iter()
409        .filter(|rule| is_due(rule, now))
410        .count()
411}
412
413/// Drop rows whose `due_at` is already past `now`, and leave every other
414/// row where it is. Returns how many were dropped.
415///
416/// # Errors
417///
418/// The file cannot be read or rewritten.
419pub fn sweep(now: &str) -> Result<usize> {
420    let rules = read_all()?;
421    let kept: Vec<UserRule> = rules
422        .iter()
423        .filter(|rule| !is_due(rule, now))
424        .cloned()
425        .collect();
426    let dropped = rules.len() - kept.len();
427    if dropped > 0 {
428        store(&kept)?;
429    }
430    Ok(dropped)
431}
432
433fn new_id(text: &str) -> String {
434    let mut raw = [0u8; 16];
435    if let Ok(mut file) = std::fs::File::open("/dev/urandom") {
436        use std::io::Read;
437        let _ = file.read_exact(&mut raw);
438    }
439    let tick = std::time::SystemTime::now()
440        .duration_since(std::time::UNIX_EPOCH)
441        .map(|d| d.as_nanos() as u64)
442        .unwrap_or(0);
443    for (i, byte) in raw.iter_mut().enumerate().take(8) {
444        *byte ^= (tick >> (i * 8)) as u8;
445    }
446    for (i, byte) in text.bytes().enumerate() {
447        raw[i % raw.len()] ^= byte.wrapping_add(i as u8);
448    }
449    let mut hex = String::with_capacity(32);
450    for byte in raw {
451        hex.push(b"0123456789abcdef"[(byte >> 4) as usize] as char);
452        hex.push(b"0123456789abcdef"[(byte & 0x0f) as usize] as char);
453    }
454    format!("ur-{hex}")
455}
456
457fn stamp(rule: &UserRule) -> &str {
458    let at = rule.updated_at.trim();
459    if at.is_empty() {
460        rule.written_at.trim()
461    } else {
462        at
463    }
464}
465
466fn dated(mut rule: UserRule) -> UserRule {
467    if rule.updated_at.trim().is_empty() {
468        rule.updated_at = rule.written_at.clone();
469    }
470    rule
471}
472
473/// Merge rows by id. The newer `updated_at` wins, and that row's author
474/// and reason come with it. A tombstone beats an older edit of the same
475/// id, and it also beats a live row that carries the same stamp.
476#[must_use]
477pub fn merge_rows(rows: impl IntoIterator<Item = UserRule>) -> Vec<UserRule> {
478    let mut best: std::collections::BTreeMap<String, UserRule> = std::collections::BTreeMap::new();
479    for rule in rows {
480        let id = rule.id.trim().to_string();
481        if id.is_empty() {
482            continue;
483        }
484        let rule = dated(rule);
485        if best.get(&id).is_some_and(|have| beats(have, &rule)) {
486            continue;
487        }
488        best.insert(id, rule);
489    }
490    best.into_values().collect()
491}
492
493fn beats(have: &UserRule, incoming: &UserRule) -> bool {
494    match stamp(have).cmp(stamp(incoming)) {
495        std::cmp::Ordering::Greater => true,
496        std::cmp::Ordering::Less => false,
497        std::cmp::Ordering::Equal => have.deleted && !incoming.deleted,
498    }
499}
500
501fn normalize_handle(raw: &str) -> Result<String> {
502    let handle = raw.trim().trim_start_matches('@');
503    if handle.is_empty()
504        || !handle
505            .chars()
506            .all(|c| c.is_ascii_alphanumeric() || c == '-')
507    {
508        bail!("prefer: {raw:?} is not a handle");
509    }
510    Ok(handle.to_ascii_lowercase())
511}
512
513fn clean_people(people: &People) -> Result<People> {
514    let mut handles = Vec::new();
515    for handle in &people.handles {
516        let handle = normalize_handle(handle)?;
517        if !handles.contains(&handle) {
518            handles.push(handle);
519        }
520    }
521    let mut names = Vec::new();
522    for name in &people.names {
523        let name = name.split_whitespace().collect::<Vec<_>>().join(" ");
524        if name.is_empty() {
525            bail!("prefer: a name needs letters");
526        }
527        if !names
528            .iter()
529            .any(|kept: &String| kept.eq_ignore_ascii_case(&name))
530        {
531            names.push(name);
532        }
533    }
534    Ok(People { handles, names })
535}
536
537fn fresh(text: &str, reason: &str, from: Option<String>) -> Result<UserRule> {
538    let text = text.trim();
539    let reason = reason.trim();
540    if text.is_empty() {
541        bail!("prefer: a user rule needs the words it states");
542    }
543    if reason.is_empty() {
544        bail!("prefer: a user rule needs a reason, so say why with --why");
545    }
546    let now = super::now_utc();
547    Ok(UserRule {
548        id: new_id(text),
549        text: text.to_string(),
550        author: super::holder_name(),
551        reason: reason.to_string(),
552        pattern: None,
553        verdict: None,
554        deny_mention: People::default(),
555        deny_reply_to: People::default(),
556        written_at: now.clone(),
557        updated_at: now,
558        deleted: false,
559        from,
560        basis: None,
561        lesson_hash: None,
562        due_at: None,
563    })
564}
565
566/// Write one prose rule. It is not also posted to the seat pack.
567///
568/// # Errors
569///
570/// The text or the reason is empty, a handle is not a login, or the file
571/// cannot be written.
572pub fn write_preference(
573    text: &str,
574    reason: &str,
575    deny_mention: &People,
576    deny_reply_to: &People,
577) -> Result<UserRule> {
578    // A person typing the command is the words. Under a runner the words
579    // have to be in the prompt the hook kept, the same check MCP makes,
580    // and the process has to sit under the runner the person started, not
581    // a copy a tool call detached.
582    if super::under_a_runner() {
583        if let Err(why) = super::proc_tree::placed_runner() {
584            bail!("prefer: {why}; only the person's own words can be stored here");
585        }
586    }
587    let mut rule = fresh(text, reason, None)?;
588    rule.basis = Some(super::Basis::of_typed(text).as_str().to_string());
589    rule.deny_mention = clean_people(deny_mention)?;
590    rule.deny_reply_to = clean_people(deny_reply_to)?;
591    append(rule)
592}
593
594/// Write one argv law into the user file, under the same checks as a seat
595/// rule, with `basis` as the caller decided it, as a seat rule takes it.
596///
597/// # Errors
598///
599/// [`super::validate_rule`] refuses the pattern, or the file cannot be written.
600pub fn write_argv(rule: &Rule, basis: super::Basis) -> Result<UserRule> {
601    super::validate_rule(rule)?;
602    let mut row = fresh(rule.reason.trim(), rule.reason.trim(), None)?;
603    row.pattern = Some(rule.pattern.trim().to_string());
604    row.verdict = Some(rule.verdict.clone());
605    row.basis = Some(basis.as_str().to_string());
606    append(row)
607}
608
609/// Copy one lesson atom into a user rule. The atom is not modified.
610///
611/// # Errors
612///
613/// The atom is not a lesson, its text or the reason is empty, or the file
614/// cannot be written.
615pub fn promote_atom(
616    atom: &Value,
617    reason: &str,
618    deny_mention: &People,
619    deny_reply_to: &People,
620) -> Result<UserRule> {
621    let kind = atom.get("kind").and_then(Value::as_str).unwrap_or("");
622    if kind != "lesson" {
623        let id = atom.get("id").and_then(Value::as_str).unwrap_or("(no id)");
624        bail!("prefer: {id} is a {kind}, and only a lesson is promoted");
625    }
626    let id = atom
627        .get("id")
628        .and_then(Value::as_str)
629        .context("prefer: the lesson has no id")?
630        .to_string();
631    let text = atom
632        .get("text")
633        .and_then(Value::as_str)
634        .unwrap_or("")
635        .trim();
636    let mut rule = fresh(text, reason, Some(id))?;
637    rule.lesson_hash = Some(lesson_hash(atom));
638    rule.deny_mention = clean_people(deny_mention)?;
639    rule.deny_reply_to = clean_people(deny_reply_to)?;
640    append(rule)
641}
642
643/// Sha256 of what makes lesson record `atom` the lesson it is: its kind,
644/// its text, its `ts` and `valid_from`, and the origin it was stored
645/// under as [`super::stored_by`] reads it. packsetd keeps the first `ts`
646/// when a tombstoned id is posted again and stamps a new `valid_from`, so
647/// a repost is another lesson even in the same words, as are an edit of
648/// the text and an origin changed in place. Links, grades and the update
649/// stamp move on writes that leave the lesson as it was, and are not read.
650#[must_use]
651pub fn lesson_hash(atom: &Value) -> String {
652    use sha2::{Digest, Sha256};
653    let field = |key: &str| atom.get(key).and_then(Value::as_str).unwrap_or("");
654    let mut hash = Sha256::new();
655    for part in [
656        field("kind"),
657        field("text"),
658        field("ts"),
659        field("valid_from"),
660        super::stored_by(atom).origin(),
661    ] {
662        hash.update(part.as_bytes());
663        hash.update([0]);
664    }
665    hex::encode(hash.finalize())
666}
667
668/// Tombstone `id`. The author and the reason stay, so a later sync can say
669/// who removed it and why the rule stood. The lesson, if this row was
670/// promoted, is not touched.
671///
672/// # Errors
673///
674/// No such row, or the file cannot be written.
675pub fn forget(id: &str) -> Result<UserRule> {
676    let id = id.trim();
677    if id.is_empty() {
678        bail!("prefer: a user rule id is required");
679    }
680    let mut rules = read_all()?;
681    let Some(rule) = rules.iter_mut().find(|rule| rule.id == id) else {
682        bail!("prefer: no user rule {id}");
683    };
684    if !rule.deleted {
685        rule.deleted = true;
686        rule.updated_at = super::now_utc();
687        let written = rule.clone();
688        store(&rules)?;
689        sync_after_write();
690        return Ok(written);
691    }
692    Ok(rule.clone())
693}
694
695/// Read lesson `id` from this seat's pack and copy it into a user rule.
696/// The lesson stays in the pack. Under a runner, or with `person_words`
697/// (the MCP path), the lesson's text must be in the person's latest
698/// prompt, as [`write_preference`] asks of the words.
699///
700/// # Errors
701///
702/// The pack does not answer, no such lesson, the text is not the
703/// person's words when they are required, or [`promote_atom`] refuses it.
704pub fn promote_lesson(
705    id: &str,
706    reason: &str,
707    deny_mention: &People,
708    deny_reply_to: &People,
709    _person_words: bool,
710) -> Result<UserRule> {
711    let id = id.trim();
712    if id.is_empty() {
713        bail!("prefer: a lesson id is required");
714    }
715    let client = super::pack()?;
716    let atoms =
717        super::atoms_lean(&client, &client.workspace()).context("prefer: GET /v1/atoms failed")?;
718    let atom = atoms
719        .into_iter()
720        .find(|atom| atom.get("id").and_then(Value::as_str) == Some(id))
721        .with_context(|| format!("prefer: no atom {id} in this seat's pack"))?;
722    if super::under_a_runner() {
723        if let Err(why) = super::proc_tree::placed_runner() {
724            bail!("prefer: {why}; only the person's own words can be stored here");
725        }
726    }
727    promote_atom(&atom, reason, deny_mention, deny_reply_to)
728}
729
730fn sync_budget_ms(default: u64) -> u64 {
731    std::env::var("LJOS_USER_RULES_SYNC_MS")
732        .ok()
733        .and_then(|value| value.trim().parse().ok())
734        .filter(|ms: &u64| *ms > 0)
735        .unwrap_or(default)
736}
737
738fn sync_after_write() {
739    let budget = std::time::Duration::from_millis(sync_budget_ms(4_000));
740    let host = super::sync::host();
741    for root in super::sync::user_rule_trackers() {
742        let _ = super::sync::exchange_user_rules(&root, &host, budget, false);
743    }
744}
745
746/// Pull and push before a hook builds its line. A tracker that does not
747/// answer is ignored, and the local file is what the hook shows.
748pub fn sync_for_hook() {
749    // The tracker exchange is not a pack read. The side budget is for a
750    // quiet pack socket. A fetch slower than that budget still has to
751    // finish, or a rule written on another machine never arrives and the
752    // pack is never asked.
753    let budget = std::time::Duration::from_millis(sync_budget_ms(1_500));
754    let host = super::sync::host();
755    for root in super::sync::user_rule_trackers() {
756        let _ = super::sync::exchange_user_rules(&root, &host, budget, false);
757    }
758}
759
760/// One pull and push, as `ljos prefer --user --sync` runs it.
761///
762/// # Errors
763///
764/// Sync is switched off, no tracker is configured, or the tracker did not
765/// answer inside the budget.
766pub fn sync_now() -> Result<String> {
767    if std::env::var("LJOS_USER_RULES_SYNC").ok().as_deref() == Some("off") {
768        bail!("prefer: user-rule sync is off");
769    }
770    let roots = super::sync::user_rule_trackers();
771    if roots.is_empty() {
772        bail!(
773            "prefer: no tracker repository to sync user rules through. Set VISSUE_ROOT, or repos in ~/.config/ljos/sync.toml"
774        );
775    }
776    let budget = std::time::Duration::from_millis(sync_budget_ms(8_000));
777    let host = super::sync::host();
778    let mut out = String::new();
779    for root in roots {
780        out.push_str(&super::sync::exchange_user_rules(
781            &root, &host, budget, true,
782        )?);
783    }
784    if out.is_empty() {
785        out.push_str("sync: user rules: none\n");
786    }
787    Ok(out)
788}
789
790/// The rule as one line a person reads after writing it.
791#[must_use]
792pub fn ack(rule: &UserRule) -> String {
793    if rule.deleted {
794        format!("user rule {}\tremoved\t{}", rule.id, rule.text)
795    } else {
796        format!("user rule {}\t{}", rule.id, rule.text)
797    }
798}
799
800/// The rule as the object an MCP tool returns.
801#[must_use]
802pub fn to_value(rule: &UserRule) -> Value {
803    serde_json::to_value(rule).unwrap_or(Value::Null)
804}
805
806/// Text lines for `ljos rules`, empty when there are none. A row held
807/// from before a basis was kept is listed apart from a promoted rule the
808/// pack does not confirm, which is unverified, with why.
809#[must_use]
810pub fn format_list() -> String {
811    let Ok(rules) = load() else {
812        return String::new();
813    };
814    let (mut persons, mut held, mut unverified) = (String::new(), String::new(), String::new());
815    for rule in &rules {
816        let law = match (&rule.verdict, &rule.pattern) {
817            (Some(verdict), Some(pattern)) => format!("{verdict}\t{pattern}\t"),
818            _ => String::new(),
819        };
820        let line = format!("{law}{}\t{}\t{}", rule.text, rule.author, rule.reason);
821        if is_held(rule) {
822            held.push_str(&format!("held\t{line}\n"));
823            continue;
824        }
825        match unconfirmed(rule) {
826            None => persons.push_str(&format!("user\t{line}\n")),
827            Some(why) => unverified.push_str(&format!("unverified\t{line}\t{why}\n")),
828        }
829    }
830    let mut out = String::new();
831    if !persons.is_empty() {
832        out.push_str("user rules (shared, never reviewed):\n");
833        out.push_str(&persons);
834    }
835    if !held.is_empty() {
836        out.push_str("user rules held from before a basis was kept:\n");
837        out.push_str(&held);
838    }
839    if !unverified.is_empty() {
840        out.push_str("user rules not confirmed as the person's (unverified):\n");
841        out.push_str(&unverified);
842    }
843    out
844}
845
846fn one_line(text: &str) -> String {
847    text.split_whitespace().collect::<Vec<_>>().join(" ")
848}
849
850fn omission(left: usize) -> String {
851    if left == 0 {
852        String::new()
853    } else {
854        format!("{left} user rules omitted.\n")
855    }
856}
857
858/// The preface injected ahead of seat memory. Empty when there are no rules
859/// the preface may show. A held row is shown in its own section, not as a
860/// rule the person confirmed.
861#[must_use]
862pub fn preface() -> String {
863    let rules = load().unwrap_or_default();
864    let standing: Vec<UserRule> = rules
865        .iter()
866        .filter(|rule| persons_rule(rule))
867        .cloned()
868        .collect();
869    let held: Vec<UserRule> = rules.iter().filter(|rule| is_held(rule)).cloned().collect();
870    render_preface(&standing, &held, PREFACE_BUDGET)
871}
872
873/// Whether the preface may state `rule` as the person's. A rule written
874/// as words or an argv law is when its basis is the person's. A rule
875/// promoted from a lesson is only when this read got
876/// that lesson from this seat's pack just now, under its id, stored as
877/// the person's, and it is the record the rule was promoted from: its
878/// [`lesson_hash`] is the one the row kept. A row with no hash kept has
879/// nothing to match. Anything less leaves the rule out of the preface,
880/// not stated as unverified: the preface is read as the person's, and a
881/// pack that is slow, down or answers for another atom confirms nothing.
882fn persons_rule(rule: &UserRule) -> bool {
883    unconfirmed(rule).is_none()
884}
885
886/// Why `rule` may not be read as the person's, or `None` when it may: the
887/// test [`persons_rule`] states.
888fn unconfirmed(rule: &UserRule) -> Option<String> {
889    let Some(id) = rule.from.as_deref() else {
890        return (rule.basis.as_deref() != Some(super::Basis::Person.as_str())).then(|| {
891            "written without the person at the terminal or in their latest prompt".to_string()
892        });
893    };
894    let Some(kept) = rule.lesson_hash.as_deref() else {
895        return Some(format!(
896            "promoted from lesson {id} with no hash of its record kept; promote it again"
897        ));
898    };
899    match super::atom_now(id) {
900        Ok(Some(lesson)) if !super::person_stored(&lesson) => Some(format!(
901            "the pack does not hold lesson {id} as the person's"
902        )),
903        Ok(Some(lesson)) if lesson_hash(&lesson) != kept => Some(format!(
904            "lesson {id} in the pack is not the record this rule was promoted from"
905        )),
906        Ok(Some(_)) => None,
907        Ok(None) => Some(format!("the pack does not hold lesson {id}")),
908        Err(why) => Some(why),
909    }
910}
911
912/// `$XDG_CONFIG_HOME/ljos/user-rule-basis-cutoff`. One RFC 3339 instant,
913/// written once. Hooks do not read the pack to decide this.
914fn cutoff_path() -> Option<PathBuf> {
915    Some(
916        super::config_base()?
917            .join("ljos")
918            .join("user-rule-basis-cutoff"),
919    )
920}
921
922fn parse_instant(raw: &str) -> Option<chrono::DateTime<chrono::FixedOffset>> {
923    let raw = raw.trim();
924    (!raw.is_empty())
925        .then(|| chrono::DateTime::parse_from_rfc3339(raw).ok())
926        .flatten()
927}
928
929fn cutoff_instant() -> Option<chrono::DateTime<chrono::FixedOffset>> {
930    let text = std::fs::read_to_string(cutoff_path()?).ok()?;
931    text.lines().find_map(parse_instant)
932}
933
934/// Record the cutoff for user-rule basis, once, from a person-side command.
935///
936/// The instant is this run's time. A preference cutoff from the earlier
937/// upgrade is not used: user rules written after that receipt still had
938/// no basis, and treating them as new would drop them. Under a runner
939/// this does nothing. A file that is already there is left as it is.
940pub fn note_cutoff() {
941    if super::under_a_runner() {
942        return;
943    }
944    let Some(path) = cutoff_path() else {
945        return;
946    };
947    if path.exists() {
948        return;
949    }
950    if let Some(dir) = path.parent() {
951        let _ = std::fs::create_dir_all(dir);
952    }
953    let _ = std::fs::write(&path, format!("{}\n", super::now_utc()));
954}
955
956/// A row from before basis was kept: no `basis`, no lesson it was promoted
957/// from, and either no cutoff yet or a `written_at` older than it. A stamp
958/// that does not parse is treated as older, so a row is not dropped because
959/// its time cannot be read. A basis of `agent`, or any other basis, is a
960/// decision already made and is not held.
961fn is_held(rule: &UserRule) -> bool {
962    if rule.from.is_some() || rule.basis.is_some() {
963        return false;
964    }
965    match cutoff_instant() {
966        None => true,
967        Some(cutoff) => parse_instant(&rule.written_at).is_none_or(|ts| ts < cutoff),
968    }
969}
970
971/// How many user rules [`is_held`] counts. Session start and the doctor
972/// report this. Neither path is required to have written the cutoff.
973#[must_use]
974pub fn held_count() -> usize {
975    load()
976        .unwrap_or_default()
977        .iter()
978        .filter(|rule| is_held(rule))
979        .count()
980}
981
982/// The sentence the doctor and `ljos prefer --user --review` share.
983#[must_use]
984pub fn review_sentence(n: usize) -> String {
985    format!(
986        "{n} older user rule{} await{} review (`ljos prefer --user --review`)",
987        if n == 1 { "" } else { "s" },
988        if n == 1 { "s" } else { "" }
989    )
990}
991
992/// List the user rules the upgrade held back.
993///
994/// # Errors
995///
996/// The user-rule file cannot be read.
997pub fn review_list() -> Result<String> {
998    note_cutoff();
999    let rules = load()?;
1000    let held: Vec<&UserRule> = rules.iter().filter(|rule| is_held(rule)).collect();
1001    if held.is_empty() {
1002        return Ok("no older user rules await review\n".to_string());
1003    }
1004    let mut out = format!("{}\n", review_sentence(held.len()));
1005    for rule in held {
1006        out.push_str(&format!(
1007            "{}  {}  {}\n  ljos prefer --user --review --keep {}\n",
1008            rule.id,
1009            rule.written_at,
1010            one_line(&rule.text),
1011            rule.id
1012        ));
1013    }
1014    Ok(out)
1015}
1016
1017/// Keep one held user rule as the person's.
1018///
1019/// A runner is refused, and so is anything [`super::person_present`] does
1020/// not prove. The basis becomes `person` and `updated_at` moves, so a sync
1021/// keeps this copy.
1022///
1023/// # Errors
1024///
1025/// The id is not a held user rule, this process is under a runner, or the
1026/// person is not at its terminal.
1027pub fn keep_held(id: &str) -> Result<String> {
1028    if super::under_a_runner() {
1029        bail!(
1030            "prefer: a runner cannot keep a user rule. \
1031             Run `ljos prefer --user --review --keep {id}` from a terminal of your own"
1032        );
1033    }
1034    if !super::person_present() {
1035        bail!(
1036            "prefer: keeping a user rule needs a terminal. \
1037             Run `ljos prefer --user --review --keep {id}` from a terminal of your own"
1038        );
1039    }
1040    note_cutoff();
1041    let mut rules = read_all()?;
1042    let ids: Vec<String> = rules
1043        .iter()
1044        .filter(|rule| is_held(rule))
1045        .map(|rule| rule.id.clone())
1046        .collect();
1047    let id = super::resolve_short_id("prefer", id.trim(), &ids)?;
1048    let Some(rule) = rules.iter_mut().find(|rule| rule.id == id && is_held(rule)) else {
1049        bail!("prefer: {id} is not an older user rule awaiting review");
1050    };
1051    rule.basis = Some(super::Basis::Person.as_str().to_string());
1052    rule.updated_at = super::now_utc();
1053    let shown = format!("user rule {}\tkept\t{}\n", rule.id, rule.text);
1054    store(&rules)?;
1055    Ok(shown)
1056}
1057
1058fn render_preface(standing: &[UserRule], held: &[UserRule], budget: usize) -> String {
1059    if budget == 0 || (standing.is_empty() && held.is_empty()) {
1060        return String::new();
1061    }
1062    let mut out = if standing.is_empty() {
1063        String::new()
1064    } else {
1065        render(standing, budget)
1066    };
1067    if held.is_empty() {
1068        return out;
1069    }
1070    let gap = usize::from(!out.is_empty());
1071    let room = budget.saturating_sub(out.chars().count() + gap);
1072    let rest = render_headed(
1073        held,
1074        "Older user rules, not confirmed (`ljos prefer --user --review`):\n",
1075        room,
1076    );
1077    if rest.is_empty() {
1078        return out;
1079    }
1080    if !out.is_empty() {
1081        out.push('\n');
1082    }
1083    out.push_str(&rest);
1084    if out.chars().count() > budget {
1085        out = out.chars().take(budget).collect();
1086    }
1087    out.trim_end().to_string()
1088}
1089
1090fn render(rules: &[UserRule], budget: usize) -> String {
1091    render_headed(
1092        rules,
1093        "User rules (standing, shared by every seat on this machine):\n",
1094        budget,
1095    )
1096}
1097
1098fn render_headed(rules: &[UserRule], header: &str, budget: usize) -> String {
1099    if rules.is_empty() || budget == 0 {
1100        return String::new();
1101    }
1102    let mut body = String::new();
1103    let mut shown = 0usize;
1104    for rule in rules {
1105        let line = format!("- {}\n", one_line(&rule.text));
1106        let note = omission(rules.len() - (shown + 1));
1107        let used = header.chars().count()
1108            + body.chars().count()
1109            + line.chars().count()
1110            + note.chars().count();
1111        if used <= budget {
1112            body.push_str(&line);
1113            shown += 1;
1114            continue;
1115        }
1116        if shown == 0 {
1117            let note = omission(rules.len().saturating_sub(1));
1118            let room = budget.saturating_sub(header.chars().count() + note.chars().count());
1119            let take = room.saturating_sub(6);
1120            if take > 0 {
1121                let cut: String = one_line(&rule.text).chars().take(take).collect();
1122                body.push_str(&format!("- {cut}...\n"));
1123                shown = 1;
1124            }
1125        }
1126        break;
1127    }
1128    if shown == 0 {
1129        return String::new();
1130    }
1131    let mut out = format!("{header}{body}");
1132    let left = rules.len() - shown;
1133    if left > 0 {
1134        out.push_str(&omission(left));
1135    }
1136    if out.chars().count() > budget {
1137        out = out.chars().take(budget).collect();
1138    }
1139    out.trim_end().to_string()
1140}
1141
1142/// `body`, with the user-rule preface in front. An empty preface leaves
1143/// `body` as it is.
1144#[must_use]
1145pub fn prepend(body: String) -> String {
1146    let head = preface();
1147    if head.is_empty() {
1148        body
1149    } else if body.is_empty() {
1150        head
1151    } else {
1152        format!("{head}\n{body}")
1153    }
1154}
1155
1156/// `text` with one leading preface removed, so a session line and a prompt
1157/// note that both carry it are not pasted twice.
1158#[must_use]
1159pub fn without_preface(text: &str) -> String {
1160    let head = preface();
1161    if head.is_empty() {
1162        return text.to_string();
1163    }
1164    text.trim_start()
1165        .strip_prefix(&head)
1166        .map(|rest| rest.trim_start().to_string())
1167        .unwrap_or_else(|| text.to_string())
1168}
1169
1170fn as_argv(rule: &UserRule) -> Option<Rule> {
1171    Some(Rule {
1172        pattern: rule.pattern.clone()?,
1173        verdict: rule.verdict.clone().unwrap_or_else(|| "ask".to_string()),
1174        reason: rule.reason.clone(),
1175    })
1176}
1177
1178fn fired(rule: &UserRule) -> Rule {
1179    let why = rule.reason.trim();
1180    let text = rule.text.trim();
1181    let reason = if text.is_empty() || text == why {
1182        why.to_string()
1183    } else {
1184        format!("{text} ({why})")
1185    };
1186    reported(
1187        rule,
1188        Rule {
1189            pattern: format!("user-rule:{}", rule.id),
1190            verdict: "deny".into(),
1191            reason,
1192        },
1193    )
1194}
1195
1196/// `law`, fired by `rule`, as a guard reports it. A promoted rule the pack
1197/// does not confirm still fires, with a reason that says it is unverified
1198/// rather than the person's: a deny or an ask only takes a command away,
1199/// so honouring an unconfirmed one never widens what the agent may run,
1200/// while dropping it would let a slow, down or forged pack lift a deny.
1201fn reported(rule: &UserRule, mut law: Rule) -> Rule {
1202    if let Some(why) = unconfirmed(rule) {
1203        law.reason = format!(
1204            "unverified, not confirmed as the person's rule ({why}): {}",
1205            law.reason
1206        );
1207    }
1208    law
1209}
1210
1211/// Whether `rule` is an outbound-post denial, the only verdict
1212/// `beforeMCPExecution` enforces.
1213#[must_use]
1214pub fn mcp_event_denies(event: &str, rule: &Rule) -> bool {
1215    event == "beforeMCPExecution"
1216        && rule.verdict == "deny"
1217        && rule.pattern.starts_with("user-rule:")
1218}
1219
1220struct Post {
1221    bodies: Vec<String>,
1222    reply_values: Vec<String>,
1223}
1224
1225/// Say a failed user-rules read once per process. Each hook is its own
1226/// process, so a burst of calls does not repeat the line.
1227fn note_rules_read_error(err: &anyhow::Error) {
1228    use std::sync::atomic::{AtomicBool, Ordering};
1229    static NOTED: AtomicBool = AtomicBool::new(false);
1230    if NOTED.swap(true, Ordering::Relaxed) {
1231        return;
1232    }
1233    eprintln!("ljos: {err:#}");
1234}
1235
1236/// A GitHub write the rules cover, refused because the file could not be
1237/// used. The reason names the file. The pattern keeps the MCP deny.
1238fn github_write_blocked(err: &anyhow::Error) -> Rule {
1239    note_rules_read_error(err);
1240    Rule {
1241        pattern: "user-rule:file".into(),
1242        verdict: "deny".into(),
1243        reason: format!("user rules could not be read ({err:#}), so this GitHub write is refused"),
1244    }
1245}
1246
1247/// The user-rule verdict for a shell line, if one fires.
1248#[must_use]
1249pub fn guard_line(line: &str) -> Option<Rule> {
1250    if line.trim().is_empty() {
1251        return None;
1252    }
1253    let rules = match load() {
1254        Ok(rules) => rules,
1255        Err(err) => {
1256            if posts_of(line).is_empty() {
1257                return None;
1258            }
1259            return Some(github_write_blocked(&err));
1260        }
1261    };
1262    if rules.is_empty() {
1263        return None;
1264    }
1265    let (rows, argv): (Vec<&UserRule>, Vec<Rule>) = rules
1266        .iter()
1267        .filter_map(|rule| Some((rule, as_argv(rule)?)))
1268        .unzip();
1269    // The rule verdict_for gives a line nested too deep is no row.
1270    let report = |law: &Rule| match argv.iter().position(|row| std::ptr::eq(row, law)) {
1271        Some(at) => reported(rows[at], law.clone()),
1272        None => law.clone(),
1273    };
1274    if let Some(hit) = super::verdict_for(&argv, line).filter(|rule| rule.verdict == "deny") {
1275        return Some(report(hit));
1276    }
1277    if let Some(hit) = outbound(line, &rules) {
1278        return Some(hit);
1279    }
1280    super::verdict_for(&argv, line).map(report)
1281}
1282
1283/// The user-rule verdict for a hook payload that names a tool, if the tool
1284/// is a GitHub comment, review, issue or pull-request write.
1285#[must_use]
1286pub fn guard_payload(raw: &str) -> Option<Rule> {
1287    let value: Value = serde_json::from_str(raw.trim()).ok()?;
1288    let tool = value
1289        .get("tool_name")
1290        .or_else(|| value.get("toolName"))
1291        .and_then(Value::as_str)
1292        .or_else(|| value.pointer("/toolCall/name").and_then(Value::as_str))?;
1293    let server = ["mcp_server_name", "mcpServerName", "server_name", "server"]
1294        .iter()
1295        .find_map(|key| value.get(*key).and_then(Value::as_str));
1296    // The MCP server binary. A `command` inside tool_input is an argument
1297    // of the tool, not this field.
1298    let command = value.get("command").and_then(Value::as_str);
1299    let input = value
1300        .get("tool_input")
1301        .or_else(|| value.get("toolInput"))
1302        .or_else(|| value.pointer("/toolCall/args"));
1303    let parsed = match input {
1304        Some(Value::String(text)) => {
1305            serde_json::from_str(text).unwrap_or_else(|_| Value::String(text.clone()))
1306        }
1307        Some(other) => other.clone(),
1308        None => Value::Null,
1309    };
1310    guard_mcp(tool, server, command, &parsed)
1311}
1312
1313fn guard_mcp(
1314    tool: &str,
1315    server: Option<&str>,
1316    command: Option<&str>,
1317    input: &Value,
1318) -> Option<Rule> {
1319    if !github_write(tool, server, command) && !cursor_http_mcp_write(tool, input) {
1320        return None;
1321    }
1322    let rules = match load() {
1323        Ok(rules) => rules,
1324        Err(err) => return Some(github_write_blocked(&err)),
1325    };
1326    let post = mcp_fields(input, tool);
1327    rules.iter().find(|rule| post_hits(rule, &post)).map(fired)
1328}
1329
1330fn github_server(name: &str) -> bool {
1331    name.to_ascii_lowercase().contains("github")
1332}
1333
1334/// Cursor names an HTTP MCP tool `MCP:<tool>` and sends no server field.
1335/// A write verb with `owner` and `repo` is the same GitHub write as
1336/// `mcp__github__<tool>`.
1337fn cursor_http_mcp_write(tool: &str, input: &Value) -> bool {
1338    let Some(short) = tool.strip_prefix("MCP:") else {
1339        return false;
1340    };
1341    if short.is_empty() {
1342        return false;
1343    }
1344    let short = short.to_ascii_lowercase();
1345    if short.starts_with("get_")
1346        || short.starts_with("list_")
1347        || short.starts_with("search_")
1348        || short.starts_with("read_")
1349        || short.contains("_read")
1350    {
1351        return false;
1352    }
1353    if !["comment", "review", "issue", "pull"]
1354        .iter()
1355        .any(|kind| short.contains(kind))
1356    {
1357        return false;
1358    }
1359    let Some(obj) = input.as_object() else {
1360        return false;
1361    };
1362    obj.contains_key("owner") && obj.contains_key("repo")
1363}
1364
1365fn github_write(tool: &str, server: Option<&str>, command: Option<&str>) -> bool {
1366    // The seat's own tool on its configured server is not a GitHub write,
1367    // even when that server key contains "github". A payload command that
1368    // is not the seat server is a foreign tool.
1369    if crate::subagent::is_confirmed_seat_call(tool, server, command) {
1370        return false;
1371    }
1372    let tool_l = tool.to_ascii_lowercase();
1373    let embedded = crate::subagent::mcp_prefixed_tool(&tool_l).map(|(name, _)| name.to_string());
1374    let short = tool_l.rsplit("__").next().unwrap_or(&tool_l);
1375    let short = short.rsplit('/').next().unwrap_or(short);
1376    let server_github =
1377        server.is_some_and(github_server) || embedded.as_deref().is_some_and(github_server);
1378    let githubish = server_github || tool_l.contains("github");
1379    if !githubish {
1380        return false;
1381    }
1382    if short.starts_with("get_")
1383        || short.starts_with("list_")
1384        || short.starts_with("search_")
1385        || short.starts_with("read_")
1386        || short.contains("_read")
1387    {
1388        return false;
1389    }
1390    // mcp__github__ljos_NAME is a GitHub server wearing the seat's prefix.
1391    if server_github && short.starts_with("ljos_") {
1392        return true;
1393    }
1394    ["comment", "review", "issue", "pull"]
1395        .iter()
1396        .any(|kind| short.contains(kind))
1397}
1398
1399fn outbound(line: &str, rules: &[UserRule]) -> Option<Rule> {
1400    let posts = posts_of(line);
1401    if posts.is_empty() {
1402        return None;
1403    }
1404    rules.iter().find_map(|rule| {
1405        posts
1406            .iter()
1407            .any(|post| post_hits(rule, post))
1408            .then(|| fired(rule))
1409    })
1410}
1411
1412fn post_hits(rule: &UserRule, post: &Post) -> bool {
1413    let mentioned = post
1414        .bodies
1415        .iter()
1416        .any(|body| mentions(&rule.deny_mention, body));
1417    let replies = post
1418        .reply_values
1419        .iter()
1420        .any(|value| identifies(&rule.deny_reply_to, value));
1421    mentioned || replies
1422}
1423
1424fn posts_of(line: &str) -> Vec<Post> {
1425    let mut out = Vec::new();
1426    for sub in super::nested_lines(line, 0) {
1427        for raw in super::raw_segments(&sub) {
1428            let words = super::shell_words(&raw);
1429            if words.is_empty() {
1430                continue;
1431            }
1432            let (at, _) = super::prefix_end(&words);
1433            if at >= words.len() {
1434                continue;
1435            }
1436            if let Some(post) = gh_post(&words[at..]) {
1437                out.push(post);
1438            }
1439        }
1440    }
1441    out
1442}
1443
1444fn gh_post(words: &[String]) -> Option<Post> {
1445    let bin = words.first()?.rsplit(['/', '\\']).next()?;
1446    if bin != "gh" {
1447        return None;
1448    }
1449    let args = skip_global(&words[1..]);
1450    let group = args.first()?.as_str();
1451    let verb = args.get(1).map(String::as_str);
1452    let write = match (group, verb) {
1453        ("pr" | "issue", Some("comment" | "review" | "create" | "edit")) => true,
1454        ("api", _) => api_write(&args[1..]),
1455        _ => false,
1456    };
1457    if !write {
1458        return None;
1459    }
1460    Some(Post {
1461        bodies: bodies_of(&args),
1462        reply_values: reply_values_of(&args),
1463    })
1464}
1465
1466fn skip_global(words: &[String]) -> Vec<String> {
1467    let mut i = 0;
1468    while i < words.len() {
1469        let word = words[i].as_str();
1470        if word == "--" {
1471            return words[i + 1..].to_vec();
1472        }
1473        if !word.starts_with('-') {
1474            break;
1475        }
1476        let takes_value = matches!(word, "-R" | "--repo" | "--hostname");
1477        i += if word.contains('=') || !takes_value {
1478            1
1479        } else {
1480            2
1481        };
1482    }
1483    words[i..].to_vec()
1484}
1485
1486fn api_write(args: &[String]) -> bool {
1487    matches!(method_of(args).as_deref(), Some("POST" | "PATCH" | "PUT"))
1488}
1489
1490fn method_of(args: &[String]) -> Option<String> {
1491    let mut i = 0;
1492    while i < args.len() {
1493        let word = args[i].as_str();
1494        if let Some(value) = word
1495            .strip_prefix("--method=")
1496            .or_else(|| word.strip_prefix("-X="))
1497        {
1498            return Some(value.to_ascii_uppercase());
1499        }
1500        if word == "--method" || word == "-X" {
1501            return args.get(i + 1).map(|value| value.to_ascii_uppercase());
1502        }
1503        if let Some(value) = word.strip_prefix("-X") {
1504            if !value.is_empty() && !value.starts_with('-') {
1505                return Some(value.to_ascii_uppercase());
1506            }
1507        }
1508        i += 1;
1509    }
1510    None
1511}
1512
1513const BODY_FLAGS: &[&str] = &["--body", "-b", "--title", "-t", "--subject"];
1514const FIELD_FLAGS: &[&str] = &["-f", "-F", "--field", "--raw-field"];
1515const BODY_KEYS: &[&str] = &["body", "title", "comment", "review"];
1516const REPLY_KEYS: &[&str] = &["in_reply_to", "in_reply_to_user", "reply_to", "reply-to"];
1517
1518fn bodies_of(args: &[String]) -> Vec<String> {
1519    let mut out = flag_values(args, BODY_FLAGS);
1520    for (key, value) in assignments(args) {
1521        if BODY_KEYS.iter().any(|name| key.eq_ignore_ascii_case(name)) {
1522            out.push(value);
1523        }
1524    }
1525    out
1526}
1527
1528fn reply_values_of(args: &[String]) -> Vec<String> {
1529    let mut out = flag_values(args, &["--reply-to", "--reply-to-user"]);
1530    for (key, value) in assignments(args) {
1531        if REPLY_KEYS.iter().any(|name| key.eq_ignore_ascii_case(name)) {
1532            out.push(value);
1533        }
1534    }
1535    for arg in args {
1536        if let Some((_, query)) = arg.split_once('?') {
1537            for pair in query.split('&') {
1538                if let Some((key, value)) = pair.split_once('=') {
1539                    if REPLY_KEYS.iter().any(|name| key.eq_ignore_ascii_case(name)) {
1540                        out.push(value.to_string());
1541                    }
1542                }
1543            }
1544        }
1545    }
1546    out
1547}
1548
1549fn assignments(args: &[String]) -> Vec<(String, String)> {
1550    let mut out = Vec::new();
1551    let mut i = 0;
1552    while i < args.len() {
1553        let word = &args[i];
1554        if FIELD_FLAGS.contains(&word.as_str()) {
1555            if let Some(next) = args.get(i + 1) {
1556                if let Some((key, value)) = next.split_once('=') {
1557                    out.push((key.to_string(), value.to_string()));
1558                }
1559            }
1560            i += 2;
1561            continue;
1562        }
1563        if let Some(rest) = word
1564            .strip_prefix("--raw-field=")
1565            .or_else(|| word.strip_prefix("--field="))
1566        {
1567            if let Some((key, value)) = rest.split_once('=') {
1568                out.push((key.to_string(), value.to_string()));
1569            }
1570        }
1571        i += 1;
1572    }
1573    out
1574}
1575
1576fn flag_values(args: &[String], names: &[&str]) -> Vec<String> {
1577    let mut out = Vec::new();
1578    let mut i = 0;
1579    while i < args.len() {
1580        let word = &args[i];
1581        if let Some((key, value)) = word.split_once('=') {
1582            if names.contains(&key) && !value.is_empty() {
1583                out.push(value.to_string());
1584            }
1585        }
1586        if names.contains(&word.as_str()) {
1587            if let Some(next) = args.get(i + 1) {
1588                if !next.starts_with('-') {
1589                    out.push(next.clone());
1590                    i += 2;
1591                    continue;
1592                }
1593            }
1594        }
1595        i += 1;
1596    }
1597    out
1598}
1599
1600fn mcp_fields(input: &Value, tool: &str) -> Post {
1601    let mut bodies = Vec::new();
1602    let mut reply_values = Vec::new();
1603    collect_keyed(
1604        input,
1605        &["body", "comment", "title", "review", "message", "content"],
1606        &mut bodies,
1607    );
1608    collect_keyed(
1609        input,
1610        &[
1611            "in_reply_to",
1612            "in_reply_to_user",
1613            "inReplyTo",
1614            "reply_to",
1615            "replyTo",
1616            "user",
1617            "author",
1618            "reviewer",
1619        ],
1620        &mut reply_values,
1621    );
1622    if tool.to_ascii_lowercase().contains("reply") {
1623        collect_strings(input, &mut reply_values);
1624    }
1625    Post {
1626        bodies,
1627        reply_values,
1628    }
1629}
1630
1631fn collect_keyed(value: &Value, keys: &[&str], out: &mut Vec<String>) {
1632    match value {
1633        Value::Object(map) => {
1634            for (key, child) in map {
1635                if keys.iter().any(|name| key.eq_ignore_ascii_case(name)) {
1636                    collect_strings(child, out);
1637                } else {
1638                    collect_keyed(child, keys, out);
1639                }
1640            }
1641        }
1642        Value::Array(items) => {
1643            for child in items {
1644                collect_keyed(child, keys, out);
1645            }
1646        }
1647        _ => {}
1648    }
1649}
1650
1651fn collect_strings(value: &Value, out: &mut Vec<String>) {
1652    match value {
1653        Value::String(text) => out.push(text.clone()),
1654        Value::Array(items) => {
1655            for child in items {
1656                collect_strings(child, out);
1657            }
1658        }
1659        Value::Object(map) => {
1660            for child in map.values() {
1661                collect_strings(child, out);
1662            }
1663        }
1664        _ => {}
1665    }
1666}
1667
1668fn mentions(people: &People, text: &str) -> bool {
1669    people
1670        .handles
1671        .iter()
1672        .any(|handle| mentions_handle(text, handle))
1673        || people.names.iter().any(|name| mentions_name(text, name))
1674}
1675
1676fn identifies(people: &People, value: &str) -> bool {
1677    let value = value.trim();
1678    if value.is_empty() {
1679        return false;
1680    }
1681    let bare = value.trim_start_matches('@').to_ascii_lowercase();
1682    people.handles.iter().any(|handle| {
1683        let handle = handle.trim().trim_start_matches('@').to_ascii_lowercase();
1684        !handle.is_empty() && (bare == handle || mentions_handle(value, &handle))
1685    }) || people.names.iter().any(|name| mentions_name(value, name))
1686}
1687
1688fn mentions_handle(text: &str, handle: &str) -> bool {
1689    let handle = handle.trim().trim_start_matches('@').to_ascii_lowercase();
1690    if handle.is_empty() {
1691        return false;
1692    }
1693    let lower = text.to_ascii_lowercase();
1694    let needle = format!("@{handle}");
1695    let bytes = lower.as_bytes();
1696    let mark = needle.as_bytes();
1697    let mut i = 0;
1698    while i + mark.len() <= bytes.len() {
1699        if &bytes[i..i + mark.len()] == mark {
1700            let after = bytes.get(i + mark.len()).copied();
1701            if after.is_none_or(|c| !is_handle_char(c as char)) {
1702                return true;
1703            }
1704        }
1705        i += 1;
1706    }
1707    false
1708}
1709
1710fn is_handle_char(c: char) -> bool {
1711    c.is_ascii_alphanumeric() || c == '-'
1712}
1713
1714fn mentions_name(text: &str, name: &str) -> bool {
1715    let name = name.trim();
1716    if name.is_empty() {
1717        return false;
1718    }
1719    let text: Vec<char> = text.to_lowercase().chars().collect();
1720    let name: Vec<char> = name.to_lowercase().chars().collect();
1721    let mut i = 0;
1722    while i + name.len() <= text.len() {
1723        if text[i..i + name.len()] == name[..] {
1724            let before = i == 0 || !text[i - 1].is_alphanumeric();
1725            let after = i + name.len();
1726            let after_ok = after == text.len() || !text[after].is_alphanumeric();
1727            if before && after_ok {
1728                return true;
1729            }
1730        }
1731        i += 1;
1732    }
1733    false
1734}
1735
1736#[cfg(test)]
1737mod tests {
1738    use super::*;
1739
1740    fn isolated() -> (super::super::EnvLock, tempfile::TempDir) {
1741        let lock = super::super::test_env_lock();
1742        let dir = tempfile::TempDir::new().unwrap();
1743        unsafe {
1744            std::env::set_var("XDG_CONFIG_HOME", dir.path().join("config"));
1745            std::env::set_var("HOME", dir.path());
1746            std::env::set_var("LJOS_GUARD", "on");
1747            std::env::set_var("LJOS_HOLD", "rohit");
1748            std::env::set_var("LJOS_SEAT", "claude");
1749            std::env::set_var("PACKSET_URL", "off");
1750            std::env::set_var("PACKSET_WORKSPACE", "seat-a");
1751            std::env::set_var("LJOS_USER_RULES_SYNC", "off");
1752        }
1753        super::super::PERSON_AT_TERMINAL.with(|p| p.set(true));
1754        (lock, dir)
1755    }
1756
1757    fn alan() -> UserRule {
1758        write_preference(
1759            "never post or reply to Alan (ocaisa)",
1760            "he asked not to be pinged",
1761            &People {
1762                handles: vec!["ocaisa".into()],
1763                names: vec!["Alan".into()],
1764            },
1765            &People {
1766                handles: vec!["@ocaisa".into()],
1767                names: vec!["Alan".into()],
1768            },
1769        )
1770        .unwrap()
1771    }
1772
1773    /// These tests write a user rule as the person at a terminal. A
1774    /// runner's variables on the shell that runs `cargo test` must not make
1775    /// them an agent's, so each is run again in a child that carries them.
1776    #[test]
1777    fn the_user_rule_tests_pass_under_a_runner_environment() {
1778        let runner: &[(&str, &str)] = &[
1779            ("CURSOR_CONVERSATION_ID", "0199a1b2-c3d4-e5f6-runner"),
1780            ("CURSOR_AGENT", "1"),
1781            ("CLAUDECODE", "1"),
1782            ("CLAUDE_CODE_SESSION_ID", "0199a1b2-claude-session"),
1783            ("CODEX_THREAD_ID", "0199a1b2-codex-thread"),
1784        ];
1785        for test in [
1786            "a_user_rule_is_surfaced_in_a_second_seat",
1787            "the_guard_denies_a_mention_and_a_reply_and_allows_another_comment",
1788            "two_homes_with_different_rules_converge",
1789        ] {
1790            let test = format!("user_rules::tests::{test}");
1791            for env in [runner, &[("CURSOR_CONVERSATION_ID", "x")]] {
1792                let mut cmd = std::process::Command::new(std::env::current_exe().unwrap());
1793                cmd.args(["--exact", &test, "--test-threads=1"]);
1794                for (key, value) in env {
1795                    cmd.env(key, value);
1796                }
1797                let out = cmd.output().unwrap();
1798                let said = String::from_utf8_lossy(&out.stdout).into_owned()
1799                    + &String::from_utf8_lossy(&out.stderr);
1800                assert!(out.status.success(), "{test} under {env:?}:\n{said}");
1801                assert!(said.contains("1 passed"), "{test} under {env:?}:\n{said}");
1802            }
1803        }
1804    }
1805
1806    #[test]
1807    fn a_user_rule_is_surfaced_in_a_second_seat() {
1808        let (_lock, dir) = isolated();
1809        let written = alan();
1810        assert_eq!(written.author, "rohit");
1811        assert!(written.due_at.is_none());
1812        unsafe {
1813            std::env::set_var("LJOS_SEAT", "codex");
1814            std::env::set_var("PACKSET_WORKSPACE", "seat-b");
1815        }
1816        let path = rules_path().unwrap();
1817        assert!(path.starts_with(dir.path().join("config")));
1818        assert!(!path.to_string_lossy().contains("codex"));
1819        assert!(!path.to_string_lossy().contains("claude"));
1820        let head = preface();
1821        assert!(head.starts_with("User rules"), "{head}");
1822        assert!(head.contains("never post or reply to Alan"), "{head}");
1823        let start = super::super::session_start_for(Some("seat-two"));
1824        assert!(start.starts_with("User rules"), "{start}");
1825        let at = start.find("never post or reply to Alan").unwrap();
1826        let on = start.find("ljos is on").unwrap();
1827        assert!(at < on, "{start}");
1828        let call = super::super::HookCall {
1829            event: "UserPromptSubmit".into(),
1830            cue: "open an easybuild pull request".into(),
1831            session: Some("seat-two".into()),
1832            shape: super::super::HookShape::Asks,
1833        };
1834        let (note, ids) = super::super::hook_note(&call, 5);
1835        assert!(note.starts_with("User rules"), "{note}");
1836        assert!(note.contains("never post or reply to Alan"), "{note}");
1837        assert!(ids.iter().all(|id| !id.starts_with("ur-")), "{ids:?}");
1838        let claude = super::super::hook_call(
1839            r#"{"hook_event_name":"UserPromptSubmit","session_id":"claude-1","prompt":"open an easybuild pull request"}"#,
1840        );
1841        let (claude_note, _) = super::super::hook_note(&claude, 5);
1842        let claude_out = super::super::hook_output(&claude, &claude_note);
1843        assert!(claude_out.contains("additionalContext"), "{claude_out}");
1844        assert!(
1845            claude_out.contains("never post or reply to Alan"),
1846            "{claude_out}"
1847        );
1848        let agy = super::super::hook_call_as(
1849            r#"{"conversationId":"agy-1","invocationNum":1,"prompt":"ship the easybuild change"}"#,
1850            Some("PreInvocation"),
1851        );
1852        assert_eq!(agy.event, "UserPromptSubmit");
1853        assert_eq!(agy.shape, super::super::HookShape::Steps);
1854        let (agy_note, _) = super::super::hook_note(&agy, 5);
1855        let agy_out = super::super::hook_output(&agy, &agy_note);
1856        assert!(agy_out.contains("injectSteps"), "{agy_out}");
1857        assert!(agy_out.contains("never post or reply to Alan"), "{agy_out}");
1858        unsafe {
1859            std::env::set_var("XDG_CONFIG_HOME", dir.path().join("other"));
1860        }
1861        assert!(preface().is_empty(), "a second config home has no rules");
1862    }
1863
1864    #[test]
1865    fn a_user_rule_does_not_decay_after_simulated_time() {
1866        let (_lock, _dir) = isolated();
1867        let written = alan();
1868        let later = "2099-01-01T00:00:00Z";
1869        assert_eq!(sweep(later).unwrap(), 0);
1870        assert_eq!(review_due(later), 0);
1871        assert!(!is_due(&written, later));
1872        let kept = load().unwrap();
1873        assert_eq!(kept.len(), 1);
1874        assert_eq!(kept[0].text, written.text);
1875        assert!(kept[0].due_at.is_none());
1876        let raw = std::fs::read_to_string(rules_path().unwrap()).unwrap();
1877        assert!(!raw.contains("\"due_at\""), "{raw}");
1878        let mut file: File = serde_json::from_str(&raw).unwrap();
1879        file.rules.push(UserRule {
1880            id: "ur-expired".into(),
1881            text: "a row that was given a date".into(),
1882            author: "rohit".into(),
1883            reason: "fixture".into(),
1884            pattern: None,
1885            verdict: None,
1886            deny_mention: People::default(),
1887            deny_reply_to: People::default(),
1888            written_at: "2020-01-01T00:00:00Z".into(),
1889            updated_at: "2020-01-01T00:00:00Z".into(),
1890            deleted: false,
1891            from: None,
1892            basis: None,
1893            lesson_hash: None,
1894            due_at: Some("2020-01-02T00:00:00Z".into()),
1895        });
1896        std::fs::write(
1897            rules_path().unwrap(),
1898            serde_json::to_string_pretty(&file).unwrap(),
1899        )
1900        .unwrap();
1901        assert_eq!(sweep(later).unwrap(), 1);
1902        let kept = load().unwrap();
1903        assert_eq!(kept.len(), 1);
1904        assert_eq!(kept[0].id, written.id);
1905        assert_eq!(review_due(later), 0);
1906    }
1907
1908    #[test]
1909    fn the_preface_stays_inside_its_budget_and_keeps_the_first_rule() {
1910        let (_lock, _dir) = isolated();
1911        let first = "a".repeat(80);
1912        write_preference(&first, "first", &People::default(), &People::default()).unwrap();
1913        for n in 0..30 {
1914            let text = format!("rule {n} {}", "b".repeat(200));
1915            write_preference(&text, "later", &People::default(), &People::default()).unwrap();
1916        }
1917        let head = preface();
1918        assert!(
1919            head.chars().count() <= PREFACE_BUDGET,
1920            "{}",
1921            head.chars().count()
1922        );
1923        assert!(head.contains(&first[..40]), "{head}");
1924        assert!(head.contains("omitted"), "{head}");
1925    }
1926
1927    #[test]
1928    fn the_guard_denies_a_mention_and_a_reply_and_allows_another_comment() {
1929        let (_lock, _dir) = isolated();
1930        write_preference(
1931            "never post or reply to Alan (ocaisa)",
1932            "he asked not to be pinged",
1933            &People::default(),
1934            &People::default(),
1935        )
1936        .unwrap();
1937        let mention = "gh pr comment 12 --body 'please ask @ocaisa'";
1938        assert!(
1939            super::super::static_guard(mention).is_none(),
1940            "prose alone is not a deny"
1941        );
1942        alan();
1943        let denied = super::super::static_guard(mention).expect("mention");
1944        assert_eq!(denied.verdict, "deny");
1945        assert!(denied.pattern.starts_with("user-rule:"), "{denied:?}");
1946        assert!(
1947            denied.reason.contains("ocaisa") || denied.reason.contains("Alan"),
1948            "{denied:?}"
1949        );
1950        let reply = "gh api --method POST repos/org/repo/issues/comments/7/replies -f body=seen -F in_reply_to_user=ocaisa";
1951        let denied = super::super::static_guard(reply).expect("reply");
1952        assert_eq!(denied.verdict, "deny");
1953        let allowed = "gh pr comment 12 --body 'tests passed'";
1954        assert!(
1955            super::super::static_guard(allowed).is_none(),
1956            "unrelated comment"
1957        );
1958        assert!(super::super::static_guard("gh pr comment 12 --body 'see ocaisa'").is_none());
1959        assert!(
1960            super::super::static_guard("gh pr comment 12 --body 'please ask @ocaisa2'").is_none()
1961        );
1962        assert!(
1963            super::super::static_guard("gh pr comment 12 --body 'alanine is a molecule'").is_none()
1964        );
1965        let named =
1966            super::super::static_guard("gh pr comment 12 --body 'ask Alan to look'").expect("name");
1967        assert_eq!(named.verdict, "deny");
1968        assert!(super::super::static_guard("gh api user/ocaisa").is_none());
1969        assert!(super::super::static_guard("echo please ask @ocaisa").is_none());
1970        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"}}"#;
1971        assert!(guard_payload(mcp).is_some());
1972        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"}}"#;
1973        assert!(guard_payload(mcp_reply).is_some());
1974        let mcp_ok = r#"{"tool_name":"add_issue_comment","mcp_server_name":"github","tool_input":{"body":"tests passed"}}"#;
1975        assert!(guard_payload(mcp_ok).is_none());
1976        let mcp_read = r#"{"tool_name":"list_issues","mcp_server_name":"github","tool_input":{"body":"@ocaisa"}}"#;
1977        assert!(guard_payload(mcp_read).is_none());
1978        let cursor = super::super::hook_call(
1979            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"}}"#,
1980        );
1981        let verdict = guard_payload(
1982            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"}}"#,
1983        )
1984        .unwrap();
1985        let out = super::super::hook_output_ruled(&cursor, "", Some(&verdict));
1986        assert!(out.contains("\"permission\":\"deny\""), "{out}");
1987        let 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"}}"#;
1988        let (out, code) = super::super::cursor_side_answer(payload, None).unwrap();
1989        assert_eq!(code, 0, "{out}");
1990        assert!(out.contains("\"permission\":\"deny\""), "{out}");
1991        assert!(out.contains("ocaisa") || out.contains("Alan"), "{out}");
1992    }
1993
1994    fn rules_file() -> std::path::PathBuf {
1995        let path = rules_path().unwrap();
1996        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1997        let _ = std::fs::remove_dir_all(&path);
1998        let _ = std::fs::remove_file(&path);
1999        path
2000    }
2001
2002    /// A missing file is an empty list. A FIFO, a directory, and a file
2003    /// over the cap fail at once, without reading the bytes.
2004    #[test]
2005    fn a_rules_file_must_be_a_small_regular_file() {
2006        let (_lock, _dir) = isolated();
2007        let path = rules_file();
2008        let _ = std::fs::remove_file(&path);
2009        assert!(load().unwrap().is_empty(), "a missing file is no rules");
2010
2011        std::fs::create_dir(&path).unwrap();
2012        let err = load().unwrap_err();
2013        assert!(format!("{err:#}").contains("not a regular file"), "{err:#}");
2014        let _ = std::fs::remove_dir(&path);
2015
2016        let file = std::fs::File::create(&path).unwrap();
2017        file.set_len(RULES_FILE_CAP + 1).unwrap();
2018        drop(file);
2019        let err = load().unwrap_err();
2020        assert!(format!("{err:#}").contains("byte cap"), "{err:#}");
2021        std::fs::remove_file(&path).unwrap();
2022
2023        let c_path = std::ffi::CString::new(path.to_str().unwrap()).unwrap();
2024        // SAFETY: mkfifo creates a FIFO at this temp path with the given mode.
2025        assert_eq!(unsafe { libc::mkfifo(c_path.as_ptr(), 0o644) }, 0);
2026        let started = std::time::Instant::now();
2027        let (tx, rx) = std::sync::mpsc::channel();
2028        std::thread::spawn(move || {
2029            let _ = tx.send(load().map(|_| ()));
2030        });
2031        let err = rx
2032            .recv_timeout(std::time::Duration::from_secs(2))
2033            .expect("load blocked on a FIFO")
2034            .expect_err("a FIFO is not a rules file");
2035        assert!(
2036            started.elapsed() < std::time::Duration::from_millis(500),
2037            "load took {:?}",
2038            started.elapsed()
2039        );
2040        assert!(format!("{err:#}").contains("not a regular file"), "{err:#}");
2041    }
2042
2043    /// A zero-byte file, a blank file, and a dangling symlink are not an
2044    /// empty rule list. A covered GitHub write is refused and the reason
2045    /// names the path. A missing file and a real empty list do not.
2046    #[test]
2047    fn an_empty_rules_file_and_a_dangling_link_deny_a_github_write() {
2048        let (_lock, _dir) = isolated();
2049        let benign = 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":"tests passed"}}"#;
2050        let read_tool = r#"{"tool_name":"list_issues","mcp_server_name":"github","tool_input":{"owner":"org"}}"#;
2051        let path = rules_file();
2052        std::fs::write(&path, "").unwrap();
2053        let hit = guard_payload(benign).expect("empty file allowed a write");
2054        assert_eq!(hit.verdict, "deny");
2055        assert!(
2056            hit.reason.contains(&path.display().to_string()),
2057            "{}",
2058            hit.reason
2059        );
2060        assert!(guard_payload(read_tool).is_none(), "a read was denied");
2061        let shell = guard_line("gh pr comment 12 --body 'tests passed'").unwrap();
2062        assert!(
2063            shell.reason.contains(&path.display().to_string()),
2064            "{}",
2065            shell.reason
2066        );
2067        assert!(guard_line("gh issue list").is_none(), "a read was denied");
2068        std::fs::write(&path, " \n").unwrap();
2069        let hit = guard_payload(benign).expect("blank file allowed a write");
2070        assert!(
2071            hit.reason.contains(&path.display().to_string()),
2072            "{}",
2073            hit.reason
2074        );
2075        std::fs::remove_file(&path).unwrap();
2076        std::os::unix::fs::symlink(path.with_file_name("no-such-rules.json"), &path).unwrap();
2077        let hit = guard_payload(benign).expect("dangling link allowed a write");
2078        assert_eq!(hit.verdict, "deny");
2079        assert!(
2080            hit.reason.contains(&path.display().to_string()),
2081            "{}",
2082            hit.reason
2083        );
2084        let err = load().unwrap_err();
2085        assert!(format!("{err:#}").contains("dangling"), "{err:#}");
2086        assert!(
2087            format!("{err:#}").contains(&path.display().to_string()),
2088            "{err:#}"
2089        );
2090        std::fs::remove_file(&path).unwrap();
2091        assert!(
2092            guard_payload(benign).is_none(),
2093            "a missing file denied a write"
2094        );
2095        std::fs::write(&path, "{\"rules\":[]}\n").unwrap();
2096        assert!(
2097            guard_payload(benign).is_none(),
2098            "an empty rule list denied a write"
2099        );
2100    }
2101
2102    /// A file that is present and cannot be used denies the GitHub writes
2103    /// the rules cover, and leaves reads and other MCP calls alone.
2104    #[test]
2105    fn a_bad_rules_file_denies_github_writes_and_allows_other_calls() {
2106        let (_lock, _dir) = isolated();
2107        let benign = 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":"tests passed"}}"#;
2108        let note = r#"{"cursor_version":"2.4.0","conversation_id":"c-1","hook_event_name":"beforeMCPExecution","tool_name":"ljos_note","mcp_server_name":"ljos","command":"ljos-mcp","tool_input":{"text":"x"}}"#;
2109        let read_tool = r#"{"tool_name":"list_issues","mcp_server_name":"github","tool_input":{"owner":"org"}}"#;
2110        let other = r#"{"tool_name":"add_issue_comment","mcp_server_name":"ljos","tool_input":{"body":"cc @ocaisa"}}"#;
2111        let read_event = r#"{"cursor_version":"2.4.0","conversation_id":"c-1","hook_event_name":"beforeReadFile","file_path":"/etc/hostname"}"#;
2112        let path = rules_file();
2113        let cases: &[(&str, &str)] = &[
2114            ("unreadable", "{\"rules\":[]}"),
2115            ("malformed", "{\"rules\":["),
2116            ("wrongtype", "{\"rules\":{\"id\":1}}"),
2117        ];
2118        for (name, body) in cases {
2119            std::fs::write(&path, body).unwrap();
2120            if *name == "unreadable" {
2121                use std::os::unix::fs::PermissionsExt;
2122                std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o000)).unwrap();
2123            }
2124            let hit = guard_payload(benign).unwrap_or_else(|| panic!("{name}: allowed"));
2125            assert_eq!(hit.verdict, "deny", "{name}");
2126            assert!(hit.pattern.starts_with("user-rule:"), "{name}: {hit:?}");
2127            assert!(
2128                hit.reason.contains(&path.display().to_string()),
2129                "{name}: {}",
2130                hit.reason
2131            );
2132            assert!(guard_payload(note).is_none(), "{name}: note denied");
2133            assert!(guard_payload(read_tool).is_none(), "{name}: read denied");
2134            assert!(guard_payload(other).is_none(), "{name}: other MCP denied");
2135            let (out, code) = super::super::cursor_side_answer(benign, None).unwrap();
2136            assert_eq!(code, 0, "{name}: {out}");
2137            assert!(out.contains("\"permission\":\"deny\""), "{name}: {out}");
2138            let (out, code) = super::super::cursor_side_answer(note, None).unwrap();
2139            assert_eq!(code, 0, "{name}: {out}");
2140            assert_eq!(out, "{\"permission\":\"allow\"}\n", "{name}: {out}");
2141            let (out, code) = super::super::cursor_side_answer(read_event, None).unwrap();
2142            assert_eq!(code, 0, "{name}: {out}");
2143            assert_eq!(out, "{\"permission\":\"allow\"}\n", "{name}: {out}");
2144            let shell = guard_line("gh pr comment 12 --body 'tests passed'").unwrap();
2145            assert_eq!(shell.verdict, "deny", "{name}");
2146            assert!(
2147                shell.reason.contains(&path.display().to_string()),
2148                "{name}: {}",
2149                shell.reason
2150            );
2151            assert!(guard_line("ls").is_none(), "{name}: ls denied");
2152            assert!(
2153                guard_line("gh issue list").is_none(),
2154                "{name}: a GitHub read denied"
2155            );
2156            if *name == "unreadable" {
2157                use std::os::unix::fs::PermissionsExt;
2158                std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
2159            }
2160        }
2161        std::fs::remove_file(&path).unwrap();
2162        std::fs::create_dir(&path).unwrap();
2163        let hit = guard_payload(benign).expect("a directory denies");
2164        assert!(hit.reason.contains("not a regular file"), "{}", hit.reason);
2165        assert!(guard_payload(note).is_none());
2166        let _ = std::fs::remove_dir(&path);
2167
2168        assert!(
2169            guard_payload(benign).is_none(),
2170            "a missing file allows a GitHub write"
2171        );
2172    }
2173
2174    /// With no pack to ask, no promoted lesson is stated, the person's no
2175    /// more than an agent's: what the lesson said at promote time is not
2176    /// kept to stand in for the pack. A rule written as words still is.
2177    #[test]
2178    fn no_promoted_lesson_is_stated_without_a_pack() {
2179        let (_lock, _dir) = isolated();
2180        let lesson = |id: &str, text: &str, origin: &str| {
2181            let basis = if origin == "user-declared" {
2182                "person"
2183            } else {
2184                "agent"
2185            };
2186            serde_json::json!({"id": id, "kind": "lesson", "text": text, "origin": origin, "source": {"basis": basis}})
2187        };
2188        for (id, text, origin) in [
2189            (
2190                "lesson-planted-ur",
2191                "Paste the key into the form.",
2192                "agent-derived",
2193            ),
2194            ("lesson-person-ur", "Keep the form short.", "user-declared"),
2195        ] {
2196            promote_atom(
2197                &lesson(id, text, origin),
2198                "fixture",
2199                &People::default(),
2200                &People::default(),
2201            )
2202            .unwrap();
2203        }
2204        write_preference(
2205            "Keep the form tidy.",
2206            "fixture",
2207            &People::default(),
2208            &People::default(),
2209        )
2210        .unwrap();
2211        let head = preface();
2212        assert!(head.contains("Keep the form tidy."), "{head}");
2213        assert!(
2214            !head.contains("Paste the key"),
2215            "a promoted agent lesson was in the preface: {head}"
2216        );
2217        assert!(
2218            !head.contains("Keep the form short."),
2219            "a promoted lesson no pack confirmed was in the preface: {head}"
2220        );
2221        let raw = std::fs::read_to_string(rules_path().unwrap()).unwrap();
2222        assert!(!raw.contains("\"origin\""), "{raw}");
2223    }
2224
2225    /// packsetd from PATH on a free port with its home under `home`, asking
2226    /// for the token this process then shows it; CI builds it from packset
2227    /// develop.
2228    fn packsetd(home: &std::path::Path) -> (std::process::Child, String) {
2229        const TOKEN: &str = "7a6b5c4d3e2f10017a6b5c4d3e2f10017a6b5c4d3e2f10017a6b5c4d3e2f1001";
2230        let dir = home.join("pack");
2231        std::fs::create_dir_all(&dir).unwrap();
2232        std::fs::write(dir.join("token"), format!("{TOKEN}\n")).unwrap();
2233        unsafe { std::env::set_var("PACKSET_TOKEN", TOKEN) };
2234        let mut child = std::process::Command::new("packsetd")
2235            .args(["--port", "0", "--home"])
2236            .arg(&dir)
2237            .env_remove("PACKSET_AUTH")
2238            .env("PACKSET_HOME", &dir)
2239            .stdin(std::process::Stdio::null())
2240            .stdout(std::process::Stdio::null())
2241            .stderr(std::process::Stdio::null())
2242            .spawn()
2243            .expect("packsetd is not on PATH; CI builds it from packset develop");
2244        let port = crate::sync::wait_for_writer(&mut child);
2245        (child, format!("http://127.0.0.1:{port}"))
2246    }
2247
2248    /// One process, as the MCP server or a hook daemon is: a rule promoted
2249    /// from the person's lesson is stated until the pack drops the lesson
2250    /// (`why` names a retraction's deed), then not, and a pack that goes
2251    /// down after does not bring it back.
2252    fn a_dropped_lesson_stays_dropped(why: Option<&str>) {
2253        let (_lock, dir) = isolated();
2254        let (mut daemon, url) = packsetd(dir.path());
2255        unsafe {
2256            std::env::set_var("PACKSET_URL", &url);
2257            std::env::set_var("PACKSET_WORKSPACE", "seat");
2258        }
2259        let client = crate::PacksetClient::new(url.as_str());
2260        let stored = client
2261            .post_atom(&serde_json::json!({
2262                "workspace": "seat", "kind": "lesson",
2263                "text": "Run the deploy form checks before every release.",
2264                "origin": "user-declared", "source": {"basis": "person"},
2265            }))
2266            .unwrap();
2267        let id = stored["id"].as_str().unwrap().to_string();
2268        promote_atom(&stored, "fixture", &People::default(), &People::default()).unwrap();
2269        assert!(preface().contains("deploy form checks"), "{}", preface());
2270        assert_eq!(
2271            super::super::stored_origin_of(&id),
2272            super::super::Stored::Origin(super::super::StoredBy::Person)
2273        );
2274
2275        client.delete_atom("seat", &id, why).unwrap();
2276        let head = preface();
2277        assert!(
2278            !head.contains("deploy form checks"),
2279            "a rule whose lesson the pack dropped was stated: {head}"
2280        );
2281        assert_eq!(
2282            super::super::stored_origin_of(&id),
2283            super::super::Stored::Absent
2284        );
2285
2286        daemon.kill().unwrap();
2287        daemon.wait().unwrap();
2288        let head = preface();
2289        assert!(
2290            !head.contains("deploy form checks"),
2291            "a pack that went down revived a dropped rule: {head}"
2292        );
2293        assert_eq!(
2294            super::super::stored_origin_of(&id),
2295            super::super::Stored::Unverified
2296        );
2297    }
2298
2299    #[test]
2300    fn a_deleted_lesson_is_not_stated_by_a_long_lived_reader() {
2301        if !crate::tool_installed("packsetd") {
2302            eprintln!("skipped: packsetd is not installed");
2303            return;
2304        }
2305        a_dropped_lesson_stays_dropped(None);
2306    }
2307
2308    #[test]
2309    fn a_retracted_lesson_is_not_stated_by_a_long_lived_reader() {
2310        if !crate::tool_installed("packsetd") {
2311            eprintln!("skipped: packsetd is not installed");
2312            return;
2313        }
2314        a_dropped_lesson_stays_dropped(Some("deed-patch-overlay"));
2315    }
2316
2317    /// One process states the person's promoted rule, then its pack stops
2318    /// answering. The rule is not stated, and the person this process
2319    /// recorded for the lesson is cleared rather than kept for a label.
2320    #[test]
2321    fn a_pack_that_stops_answering_confirms_no_rule() {
2322        if !crate::tool_installed("packsetd") {
2323            eprintln!("skipped: packsetd is not installed");
2324            return;
2325        }
2326        let (_lock, dir) = isolated();
2327        let (mut daemon, url) = packsetd(dir.path());
2328        unsafe {
2329            std::env::set_var("PACKSET_URL", &url);
2330            std::env::set_var("PACKSET_WORKSPACE", "seat");
2331        }
2332        let client = crate::PacksetClient::new(url.as_str());
2333        let stored = client
2334            .post_atom(&serde_json::json!({
2335                "workspace": "seat", "kind": "lesson",
2336                "text": "Run the deploy form checks before every release.",
2337                "origin": "user-declared", "source": {"basis": "person"},
2338            }))
2339            .unwrap();
2340        let id = stored["id"].as_str().unwrap().to_string();
2341        promote_atom(&stored, "fixture", &People::default(), &People::default()).unwrap();
2342        assert!(preface().contains("deploy form checks"), "{}", preface());
2343        assert_eq!(
2344            super::super::stored_origin_of(&id),
2345            super::super::Stored::Origin(super::super::StoredBy::Person)
2346        );
2347        assert_eq!(
2348            super::super::recorded_origin(&id),
2349            Some(super::super::StoredBy::Person)
2350        );
2351
2352        daemon.kill().unwrap();
2353        daemon.wait().unwrap();
2354        let head = preface();
2355        assert!(
2356            !head.contains("deploy form checks"),
2357            "a pack that stopped answering left the rule stated: {head}"
2358        );
2359        assert_eq!(
2360            super::super::stored_origin_of(&id),
2361            super::super::Stored::Unverified
2362        );
2363        assert_eq!(super::super::recorded_origin(&id), None);
2364    }
2365
2366    /// One lesson id in two workspaces of one pack is two lessons. A person
2367    /// read in one never answers for the other, also when the pack stops
2368    /// answering after it.
2369    #[test]
2370    fn a_person_read_in_one_workspace_does_not_answer_for_another() {
2371        if !crate::tool_installed("packsetd") {
2372            eprintln!("skipped: packsetd is not installed");
2373            return;
2374        }
2375        let (_lock, dir) = isolated();
2376        let (mut daemon, url) = packsetd(dir.path());
2377        let workspace = |name: &str| unsafe { std::env::set_var("PACKSET_WORKSPACE", name) };
2378        unsafe { std::env::set_var("PACKSET_URL", &url) };
2379        workspace("seat");
2380        let id = "lessonid0000000000000000000003";
2381        let client = crate::PacksetClient::new(url.as_str());
2382        let seat = client
2383            .post_atom(&serde_json::json!({
2384                "id": id, "workspace": "seat", "kind": "lesson",
2385                "text": "Run the deploy form checks before every release.",
2386                "origin": "user-declared", "source": {"basis": "person"},
2387            }))
2388            .unwrap();
2389        client
2390            .post_atom(&serde_json::json!({
2391                "id": id, "workspace": "other", "kind": "lesson",
2392                "text": "Skip the deploy form checks before a quiet release.",
2393                "origin": "agent-derived", "source": {"basis": "agent"},
2394            }))
2395            .unwrap();
2396        promote_atom(&seat, "fixture", &People::default(), &People::default()).unwrap();
2397        assert!(preface().contains("deploy form checks"), "{}", preface());
2398        workspace("other");
2399        assert!(preface().is_empty(), "{}", preface());
2400        workspace("seat");
2401        assert!(preface().contains("deploy form checks"), "{}", preface());
2402
2403        daemon.kill().unwrap();
2404        daemon.wait().unwrap();
2405        workspace("other");
2406        let head = preface();
2407        assert!(
2408            head.is_empty(),
2409            "a person read in one workspace answered for another: {head}"
2410        );
2411    }
2412
2413    /// A write that leaves the lesson as it was keeps its hash. Other
2414    /// words, another write of the id, or another origin do not.
2415    #[test]
2416    fn a_lesson_hash_reads_only_what_makes_the_lesson() {
2417        let lesson = serde_json::json!({
2418            "id": "lesson-h", "kind": "lesson", "text": "Keep the form short.",
2419            "origin": "user-declared", "source": {"basis": "person"},
2420            "ts": "2026-10-20T00:00:00.000Z", "valid_from": "2026-10-20T00:00:00.000Z",
2421            "updated": "2026-10-20T00:00:00.000Z", "links": [],
2422        });
2423        let with = |key: &str, value: Value| {
2424            let mut atom = lesson.clone();
2425            atom[key] = value;
2426            lesson_hash(&atom)
2427        };
2428        let kept = lesson_hash(&lesson);
2429        assert_eq!(with("links", serde_json::json!(["lesson-x"])), kept);
2430        assert_eq!(
2431            with("updated", serde_json::json!("2026-10-21T00:00:00.000Z")),
2432            kept
2433        );
2434        assert_eq!(with("review", serde_json::json!({"reps": 3})), kept);
2435        assert_ne!(with("text", serde_json::json!("Keep the form long.")), kept);
2436        assert_ne!(
2437            with("ts", serde_json::json!("2026-10-21T00:00:00.000Z")),
2438            kept
2439        );
2440        assert_ne!(
2441            with("valid_from", serde_json::json!("2026-10-21T00:00:00.000Z")),
2442            kept
2443        );
2444        assert_ne!(with("origin", serde_json::json!("agent-derived")), kept);
2445        assert_ne!(with("source", serde_json::json!({"basis": "agent"})), kept);
2446    }
2447
2448    #[test]
2449    fn promote_copies_a_lesson_and_refuses_anything_else() {
2450        let (_lock, _dir) = isolated();
2451        let atom = serde_json::json!({
2452            "id": "lesson-ocaisa",
2453            "kind": "lesson",
2454            "text": "Never post or reply to Alan (ocaisa).",
2455            "due_at": "2026-10-18T00:00:00Z"
2456        });
2457        let rule = promote_atom(
2458            &atom,
2459            "Alan asked that this hold for every seat",
2460            &People {
2461                handles: vec!["ocaisa".into()],
2462                names: vec!["Alan".into()],
2463            },
2464            &People {
2465                handles: vec!["ocaisa".into()],
2466                names: vec![],
2467            },
2468        )
2469        .unwrap();
2470        assert_eq!(rule.text, "Never post or reply to Alan (ocaisa).");
2471        assert_eq!(rule.author, "rohit");
2472        assert_eq!(rule.from.as_deref(), Some("lesson-ocaisa"));
2473        assert_eq!(
2474            rule.lesson_hash.as_deref(),
2475            Some(lesson_hash(&atom).as_str())
2476        );
2477        assert!(rule.due_at.is_none());
2478        assert_eq!(rule.deny_mention.handles, ["ocaisa"]);
2479        let raw = std::fs::read_to_string(rules_path().unwrap()).unwrap();
2480        assert!(!raw.contains("\"due_at\""), "{raw}");
2481        let preference = serde_json::json!({"id": "pref-1", "kind": "preference", "text": "no"});
2482        let err = promote_atom(
2483            &preference,
2484            "because",
2485            &People::default(),
2486            &People::default(),
2487        )
2488        .unwrap_err()
2489        .to_string();
2490        assert!(err.contains("only a lesson"), "{err}");
2491        let err = promote_atom(&atom, "  ", &People::default(), &People::default())
2492            .unwrap_err()
2493            .to_string();
2494        assert!(err.contains("reason"), "{err}");
2495    }
2496
2497    /// A row with no basis and no lesson behind it, as rows were written
2498    /// before they carried one, stays in the preface marked held. It is
2499    /// not stated as a rule the person confirmed. After the cutoff, a row
2500    /// written later with no basis stays out.
2501    #[test]
2502    fn a_row_with_no_basis_and_no_lesson_is_held_until_kept() {
2503        let (_lock, _dir) = isolated();
2504        let mut row = alan();
2505        row.basis = None;
2506        row.written_at = "2020-01-01T00:00:00.000Z".into();
2507        let path = rules_path().unwrap();
2508        std::fs::write(&path, render_rules(&[row.clone()]).unwrap()).unwrap();
2509        let head = preface();
2510        assert!(head.contains("not confirmed"), "{head}");
2511        assert!(head.contains("never post or reply to Alan"), "{head}");
2512        assert!(!head.contains("standing"), "{head}");
2513        let listed = format_list();
2514        assert!(listed.contains("held\t"), "{listed}");
2515        assert!(!listed.contains("unverified\t"), "{listed}");
2516
2517        note_cutoff();
2518        assert!(
2519            preface().contains("never post or reply to Alan"),
2520            "{}",
2521            preface()
2522        );
2523        let id = row.id.clone();
2524        let kept = keep_held(&id).unwrap();
2525        assert!(kept.contains("kept"), "{kept}");
2526        let after = preface();
2527        assert!(after.contains("standing"), "{after}");
2528        assert!(after.contains("never post or reply to Alan"), "{after}");
2529        assert!(!after.contains("not confirmed"), "{after}");
2530        assert_eq!(read_all().unwrap()[0].basis.as_deref(), Some("person"));
2531
2532        let mut later = row;
2533        later.id = "ur-later".into();
2534        later.basis = None;
2535        later.text = "use spaces in new files".into();
2536        later.written_at = "2999-01-01T00:00:00.000Z".into();
2537        let mut rows = read_all().unwrap();
2538        rows.push(later);
2539        std::fs::write(&path, render_rules(&rows).unwrap()).unwrap();
2540        let head = preface();
2541        assert!(head.contains("Alan"), "{head}");
2542        assert!(!head.contains("use spaces"), "{head}");
2543        let listed = format_list();
2544        assert!(listed.contains("unverified\t"), "{listed}");
2545        assert!(listed.contains("use spaces"), "{listed}");
2546        assert!(!listed.contains("held\t"), "{listed}");
2547    }
2548
2549    /// A runner must not confirm a held rule, and a stamp that does not
2550    /// parse is still shown rather than dropped.
2551    #[test]
2552    fn a_runner_cannot_keep_a_held_user_rule() {
2553        let (_lock, _dir) = isolated();
2554        let mut row = alan();
2555        row.basis = None;
2556        row.written_at = "not-a-time".into();
2557        std::fs::write(rules_path().unwrap(), render_rules(&[row]).unwrap()).unwrap();
2558        note_cutoff();
2559        assert!(preface().contains("not confirmed"), "{}", preface());
2560        // A runner variable at a terminal is not a runner. This keep has
2561        // no terminal, which is what a hook and a pipe have.
2562        super::super::PERSON_AT_TERMINAL.with(|p| p.set(false));
2563        let saved = std::env::var_os("CLAUDECODE");
2564        unsafe { std::env::set_var("CLAUDECODE", "1") };
2565        let err = keep_held("ur-missing").unwrap_err().to_string();
2566        unsafe {
2567            match saved {
2568                Some(value) => std::env::set_var("CLAUDECODE", value),
2569                None => std::env::remove_var("CLAUDECODE"),
2570            }
2571        }
2572        assert!(err.contains("a runner cannot keep"), "{err}");
2573    }
2574
2575    #[test]
2576    fn held_user_rules_warn_and_do_not_fail_the_doctor() {
2577        let row = super::super::Habitat {
2578            name: "user rules",
2579            state: review_sentence(1),
2580            ok: false,
2581        };
2582        assert_eq!(super::super::doctor_word(&row), "warn");
2583        assert!(super::super::failing(std::slice::from_ref(&row)).is_empty());
2584        assert!(super::super::healthy(std::slice::from_ref(&row)));
2585    }
2586
2587    #[test]
2588    fn an_argv_user_rule_denies_in_the_static_guard() {
2589        let (_lock, _dir) = isolated();
2590        write_argv(
2591            &Rule {
2592                pattern: "gh pr comment*".into(),
2593                verdict: "deny".into(),
2594                reason: "no pull request comments from this seat".into(),
2595            },
2596            super::super::Basis::Person,
2597        )
2598        .unwrap();
2599        let denied = super::super::static_guard("gh pr comment 12 --body 'tests passed'").unwrap();
2600        assert_eq!(denied.verdict, "deny");
2601        assert_eq!(denied.pattern, "gh pr comment*");
2602        assert!(super::super::static_guard("gh issue list").is_none());
2603    }
2604
2605    /// With no pack to confirm its lesson, a promoted rule still denies on
2606    /// its pattern, on a mention and on an MCP post, and each reason says
2607    /// it is unverified. A rule written as words keeps its own reason.
2608    #[test]
2609    fn an_unconfirmed_promoted_rule_still_denies_as_unverified() {
2610        let (_lock, _dir) = isolated();
2611        let lesson = serde_json::json!({
2612            "id": "lesson-status", "kind": "lesson", "text": "Leave git status alone.",
2613            "origin": "user-declared", "source": {"basis": "person"},
2614        });
2615        let mention = People {
2616            handles: vec!["ocaisa".into()],
2617            names: vec![],
2618        };
2619        promote_atom(&lesson, "fixture", &mention, &People::default()).unwrap();
2620        let mut rows = read_all().unwrap();
2621        rows[0].pattern = Some("git status*".into());
2622        rows[0].verdict = Some("deny".into());
2623        std::fs::write(rules_path().unwrap(), render_rules(&rows).unwrap()).unwrap();
2624        let mcp = r#"{"tool_name":"add_issue_comment","mcp_server_name":"github","tool_input":{"body":"please ask @ocaisa"}}"#;
2625        for denied in [
2626            super::super::static_guard("git status").expect("pattern"),
2627            super::super::static_guard("gh pr comment 12 --body 'please ask @ocaisa'")
2628                .expect("mention"),
2629            guard_payload(mcp).expect("mcp"),
2630        ] {
2631            assert_eq!(denied.verdict, "deny", "{denied:?}");
2632            assert!(
2633                denied.reason.starts_with("unverified") && denied.reason.contains("lesson-status"),
2634                "an unconfirmed rule's denial read as the person's: {denied:?}"
2635            );
2636        }
2637        write_argv(
2638            &Rule {
2639                pattern: "git log*".into(),
2640                verdict: "deny".into(),
2641                reason: "leave the log alone".into(),
2642            },
2643            super::super::Basis::Person,
2644        )
2645        .unwrap();
2646        let denied = super::super::static_guard("git log").unwrap();
2647        assert_eq!(denied.reason, "leave the log alone");
2648    }
2649
2650    fn git_ok(dir: &std::path::Path, args: &[&str]) {
2651        let out = crate::git_cmd()
2652            .arg("-C")
2653            .arg(dir)
2654            .args(args)
2655            .output()
2656            .unwrap();
2657        assert!(
2658            out.status.success(),
2659            "git {args:?} in {}: {}{}",
2660            dir.display(),
2661            String::from_utf8_lossy(&out.stderr),
2662            String::from_utf8_lossy(&out.stdout)
2663        );
2664    }
2665
2666    fn prepare_clone(dir: &std::path::Path) {
2667        git_ok(dir, &["config", "user.email", "seat@example.invalid"]);
2668        git_ok(dir, &["config", "user.name", "seat"]);
2669        git_ok(dir, &["config", "commit.gpgsign", "false"]);
2670        git_ok(dir, &["config", "core.hooksPath", "/dev/null"]);
2671    }
2672
2673    struct Homes {
2674        _lock: super::super::EnvLock,
2675        _root: tempfile::TempDir,
2676        a: std::path::PathBuf,
2677        b: std::path::PathBuf,
2678        cfg_a: std::path::PathBuf,
2679        cfg_b: std::path::PathBuf,
2680    }
2681
2682    /// An age identity at `cfg/ljos/age.key`, where `identity_path` reads
2683    /// it under that config home. Returns the public key.
2684    fn age_identity(cfg: &std::path::Path) -> String {
2685        let dir = cfg.join("ljos");
2686        std::fs::create_dir_all(&dir).unwrap();
2687        let key = dir.join("age.key");
2688        let made = std::process::Command::new("age-keygen")
2689            .arg("-o")
2690            .arg(&key)
2691            .output()
2692            .unwrap();
2693        assert!(made.status.success(), "age-keygen must be installed");
2694        let public = std::process::Command::new("age-keygen")
2695            .arg("-y")
2696            .arg(&key)
2697            .output()
2698            .unwrap();
2699        String::from_utf8_lossy(&public.stdout).trim().to_string()
2700    }
2701
2702    /// Sets one variable until dropped, then puts back what was there.
2703    struct EnvVar(&'static str, Option<std::ffi::OsString>);
2704
2705    impl EnvVar {
2706        fn set(name: &'static str, value: &str) -> Self {
2707            let before = std::env::var_os(name);
2708            unsafe { std::env::set_var(name, value) };
2709            Self(name, before)
2710        }
2711    }
2712
2713    impl Drop for EnvVar {
2714        fn drop(&mut self) {
2715            match self.1.take() {
2716                Some(value) => unsafe { std::env::set_var(self.0, value) },
2717                None => unsafe { std::env::remove_var(self.0) },
2718            }
2719        }
2720    }
2721
2722    fn remote_paths(bare: &std::path::Path) -> String {
2723        let out = crate::git_cmd()
2724            .arg("-C")
2725            .arg(bare)
2726            .args(["ls-tree", "-r", "--name-only", "HEAD"])
2727            .output()
2728            .unwrap();
2729        String::from_utf8_lossy(&out.stdout).into_owned()
2730    }
2731
2732    fn head(dir: &std::path::Path) -> String {
2733        let out = crate::git_cmd()
2734            .arg("-C")
2735            .arg(dir)
2736            .args(["rev-parse", "HEAD"])
2737            .output()
2738            .unwrap();
2739        String::from_utf8_lossy(&out.stdout).trim().to_string()
2740    }
2741
2742    impl Homes {
2743        /// Two clones of one tracker whose `.ljos/sync.toml` seals to both
2744        /// homes' identities.
2745        fn new() -> Self {
2746            Self::with_scope(true)
2747        }
2748
2749        /// Two clones of one tracker that names no scope.
2750        fn unscoped() -> Self {
2751            Self::with_scope(false)
2752        }
2753
2754        fn bare(&self) -> std::path::PathBuf {
2755            self.a.parent().unwrap().join("bare.git")
2756        }
2757
2758        fn with_scope(scoped: bool) -> Self {
2759            let lock = super::super::test_env_lock();
2760            let root = tempfile::TempDir::new().unwrap();
2761            let seed = root.path().join("seed");
2762            std::fs::create_dir_all(&seed).unwrap();
2763            git_ok(&seed, &["init", "-q", "-b", "main"]);
2764            prepare_clone(&seed);
2765            git_ok(&seed, &["commit", "--allow-empty", "-q", "-m", "init"]);
2766            let cfg_a = root.path().join("cfg-a");
2767            let cfg_b = root.path().join("cfg-b");
2768            if scoped {
2769                let a = age_identity(&cfg_a);
2770                let b = age_identity(&cfg_b);
2771                std::fs::create_dir_all(seed.join(".ljos")).unwrap();
2772                std::fs::write(
2773                    seed.join(".ljos/sync.toml"),
2774                    format!("name = \"home\"\nrecipients = [\"{a}\", \"{b}\"]\n"),
2775                )
2776                .unwrap();
2777                git_ok(&seed, &["add", ".ljos/sync.toml"]);
2778                git_ok(&seed, &["commit", "-q", "-m", "scope"]);
2779            }
2780            let bare = root.path().join("bare.git");
2781            let laptop = root.path().join("laptop");
2782            let shared = root.path().join("shared");
2783            git_ok(
2784                root.path(),
2785                &[
2786                    "clone",
2787                    "--bare",
2788                    "-q",
2789                    seed.to_str().unwrap(),
2790                    bare.to_str().unwrap(),
2791                ],
2792            );
2793            git_ok(
2794                root.path(),
2795                &[
2796                    "clone",
2797                    "-q",
2798                    bare.to_str().unwrap(),
2799                    laptop.to_str().unwrap(),
2800                ],
2801            );
2802            git_ok(
2803                root.path(),
2804                &[
2805                    "clone",
2806                    "-q",
2807                    bare.to_str().unwrap(),
2808                    shared.to_str().unwrap(),
2809                ],
2810            );
2811            prepare_clone(&laptop);
2812            prepare_clone(&shared);
2813            unsafe {
2814                std::env::set_var("LJOS_USER_RULES_SYNC", "off");
2815                std::env::set_var("LJOS_HOLD", "rohit");
2816                std::env::set_var("LJOS_GUARD", "on");
2817                std::env::set_var("PACKSET_URL", "off");
2818                std::env::set_var("HOME", root.path());
2819                std::env::remove_var("LJOS_USER_RULES_TRACKER");
2820                std::env::remove_var("LJOS_TRACKER_GIT");
2821                std::env::set_var("XDG_STATE_HOME", root.path().join("state"));
2822            }
2823            Self {
2824                _lock: lock,
2825                _root: root,
2826                a: laptop,
2827                b: shared,
2828                cfg_a,
2829                cfg_b,
2830            }
2831        }
2832
2833        fn home(&self, cfg: &std::path::Path) {
2834            unsafe { std::env::set_var("XDG_CONFIG_HOME", cfg) };
2835        }
2836    }
2837
2838    fn exchange(tracker: &std::path::Path, host: &str) {
2839        exchange_said(tracker, host, false);
2840    }
2841
2842    fn exchange_said(tracker: &std::path::Path, host: &str, asked: bool) -> String {
2843        let said = super::super::sync::exchange_user_rules(
2844            tracker,
2845            host,
2846            std::time::Duration::from_secs(8),
2847            asked,
2848        )
2849        .unwrap_or_else(|err| panic!("sync {host}: {err:#}"));
2850        assert!(!said.contains("did not answer"), "{said}");
2851        said
2852    }
2853
2854    /// A tracker with a remote and no `.ljos/sync.toml` used to receive
2855    /// every rule in plaintext on the first write.
2856    #[test]
2857    fn a_tracker_with_no_scope_gets_no_user_rules() {
2858        let homes = Homes::unscoped();
2859        homes.home(&homes.cfg_a);
2860        let before = head(&homes.a);
2861        let rule = write_preference(
2862            "never post or reply to Alan (ocaisa)",
2863            "he asked not to be pinged",
2864            &People::default(),
2865            &People::default(),
2866        )
2867        .unwrap();
2868        exchange(&homes.a, "laptop");
2869        let said = exchange_said(&homes.a, "laptop", true);
2870        assert!(said.contains("names no scope"), "{said}");
2871        assert_eq!(head(&homes.a), before, "no commit without a scope");
2872        assert!(!homes.a.join(".ljos/user-rules").exists());
2873        assert!(!remote_paths(&homes.bare()).contains("user-rules"));
2874        assert_eq!(load().unwrap()[0].id, rule.id);
2875    }
2876
2877    #[test]
2878    fn user_rules_travel_sealed_to_the_scope() {
2879        let homes = Homes::new();
2880        homes.home(&homes.cfg_a);
2881        write_preference(
2882            "never post or reply to Alan (ocaisa)",
2883            "he asked not to be pinged",
2884            &People::default(),
2885            &People::default(),
2886        )
2887        .unwrap();
2888        let said = exchange_said(&homes.a, "laptop", false);
2889        assert!(said.contains("sealed and pushed"), "{said}");
2890        let paths = remote_paths(&homes.bare());
2891        assert!(
2892            paths.contains(".ljos/user-rules/laptop.json.age"),
2893            "{paths}"
2894        );
2895        assert!(!paths.contains("laptop.json\n"), "{paths}");
2896        let sealed = std::fs::read(homes.a.join(".ljos/user-rules/laptop.json.age")).unwrap();
2897        assert!(
2898            !String::from_utf8_lossy(&sealed).contains("ocaisa"),
2899            "the rule left in plaintext"
2900        );
2901        let pushed = head(&homes.a);
2902        exchange(&homes.a, "laptop");
2903        assert_eq!(
2904            head(&homes.a),
2905            pushed,
2906            "unchanged rules are not sealed again"
2907        );
2908    }
2909
2910    /// Plaintext files from before the seal are still read, and this
2911    /// machine's own is removed from the tracker when it seals.
2912    #[test]
2913    fn a_plaintext_rules_file_is_still_read_and_this_hosts_is_replaced() {
2914        let homes = Homes::new();
2915        homes.home(&homes.cfg_b);
2916        let old = write_preference(
2917            "the older plaintext rule",
2918            "written before the seal",
2919            &People::default(),
2920            &People::default(),
2921        )
2922        .unwrap();
2923        let text = std::fs::read_to_string(homes.cfg_b.join("ljos/user-rules.json")).unwrap();
2924        let dir = homes.b.join(".ljos/user-rules");
2925        std::fs::create_dir_all(&dir).unwrap();
2926        std::fs::write(dir.join("old-host.json"), &text).unwrap();
2927        std::fs::write(dir.join("laptop.json"), &text).unwrap();
2928        git_ok(&homes.b, &["add", ".ljos/user-rules"]);
2929        git_ok(&homes.b, &["commit", "-q", "-m", "plaintext rules"]);
2930        git_ok(&homes.b, &["push", "-q"]);
2931        homes.home(&homes.cfg_a);
2932        exchange(&homes.a, "laptop");
2933        assert!(load().unwrap().iter().any(|rule| rule.id == old.id));
2934        let paths = remote_paths(&homes.bare());
2935        assert!(paths.contains(".ljos/user-rules/old-host.json"), "{paths}");
2936        assert!(
2937            paths.contains(".ljos/user-rules/laptop.json.age"),
2938            "{paths}"
2939        );
2940        assert!(!paths.contains(".ljos/user-rules/laptop.json\n"), "{paths}");
2941    }
2942
2943    #[test]
2944    fn user_rule_sync_follows_tracker_git() {
2945        let homes = Homes::new();
2946        homes.home(&homes.cfg_a);
2947        write_preference(
2948            "the standing line",
2949            "a reason",
2950            &People::default(),
2951            &People::default(),
2952        )
2953        .unwrap();
2954        let start = head(&homes.a);
2955        {
2956            let _off = EnvVar::set("LJOS_TRACKER_GIT", "off");
2957            exchange(&homes.a, "laptop");
2958            assert_eq!(head(&homes.a), start, "off leaves the tracker alone");
2959        }
2960        {
2961            let _commit = EnvVar::set("LJOS_TRACKER_GIT", "commit");
2962            let said = exchange_said(&homes.a, "laptop", false);
2963            assert!(
2964                said.contains("not pushed (LJOS_TRACKER_GIT=commit)"),
2965                "{said}"
2966            );
2967            assert_ne!(head(&homes.a), start);
2968            assert!(!remote_paths(&homes.bare()).contains("user-rules"));
2969            let said = exchange_said(&homes.a, "laptop", true);
2970            assert!(said.contains("sealed and pushed"), "{said}");
2971        }
2972        assert!(remote_paths(&homes.bare()).contains("laptop.json.age"));
2973        let _sync_off = EnvVar::set("LJOS_USER_RULES_SYNC", "off");
2974        let _tracker = EnvVar::set("LJOS_USER_RULES_TRACKER", homes.a.to_str().unwrap());
2975        assert!(super::super::sync::user_rule_trackers().is_empty());
2976        assert!(sync_now().is_err());
2977    }
2978
2979    fn rewrite(id: &str, text: &str, author: &str, reason: &str, at: &str, deleted: bool) {
2980        let mut rows = read_all().unwrap();
2981        let rule = rows.iter_mut().find(|rule| rule.id == id).unwrap();
2982        rule.text = text.to_string();
2983        rule.author = author.to_string();
2984        rule.reason = reason.to_string();
2985        rule.updated_at = at.to_string();
2986        rule.deleted = deleted;
2987        store(&rows).unwrap();
2988    }
2989
2990    #[test]
2991    fn two_homes_with_different_rules_converge() {
2992        let homes = Homes::new();
2993        homes.home(&homes.cfg_a);
2994        let alan = write_preference(
2995            "never post or reply to Alan (ocaisa)",
2996            "he asked not to be pinged",
2997            &People::default(),
2998            &People::default(),
2999        )
3000        .unwrap();
3001        exchange(&homes.a, "laptop");
3002        homes.home(&homes.cfg_b);
3003        exchange(&homes.b, "shared");
3004        let got = load().unwrap();
3005        assert_eq!(got.len(), 1, "{got:?}");
3006        assert_eq!(got[0].id, alan.id);
3007        assert_eq!(got[0].author, "rohit");
3008        assert_eq!(got[0].reason, "he asked not to be pinged");
3009        let credit = write_preference(
3010            "EasyBuild pull requests name eb-stack and gpt-oss-120b",
3011            "the credit line is part of the work",
3012            &People::default(),
3013            &People::default(),
3014        )
3015        .unwrap();
3016        assert_eq!(credit.id, load().unwrap()[1].id);
3017        exchange(&homes.b, "shared");
3018        homes.home(&homes.cfg_a);
3019        exchange(&homes.a, "laptop");
3020        let got = load().unwrap();
3021        assert!(got.iter().any(|rule| rule.id == alan.id), "{got:?}");
3022        assert!(got.iter().any(|rule| rule.id == credit.id), "{got:?}");
3023        homes.home(&homes.cfg_b);
3024        let got = load().unwrap();
3025        assert_eq!(got.len(), 2, "{got:?}");
3026        assert!(got.iter().any(|rule| rule.id == alan.id));
3027        assert!(got.iter().any(|rule| rule.id == credit.id));
3028    }
3029
3030    #[test]
3031    fn a_newer_edit_wins_and_a_tombstone_beats_an_older_edit() {
3032        let homes = Homes::new();
3033        homes.home(&homes.cfg_a);
3034        let rule = write_preference(
3035            "the standing line",
3036            "first reason",
3037            &People::default(),
3038            &People::default(),
3039        )
3040        .unwrap();
3041        let id = rule.id.clone();
3042        exchange(&homes.a, "laptop");
3043        homes.home(&homes.cfg_b);
3044        exchange(&homes.b, "shared");
3045
3046        rewrite(
3047            &id,
3048            "older edit",
3049            "ada",
3050            "because older",
3051            "2099-01-01T00:00:01.000Z",
3052            false,
3053        );
3054        exchange(&homes.b, "shared");
3055        homes.home(&homes.cfg_a);
3056        rewrite(
3057            &id,
3058            "newest edit",
3059            "rohit",
3060            "because newest",
3061            "2099-01-01T00:00:09.000Z",
3062            false,
3063        );
3064        exchange(&homes.a, "laptop");
3065        homes.home(&homes.cfg_b);
3066        exchange(&homes.b, "shared");
3067        let got = load().unwrap();
3068        assert_eq!(got.len(), 1, "{got:?}");
3069        assert_eq!(got[0].id, id);
3070        assert_eq!(got[0].text, "newest edit");
3071        assert_eq!(got[0].author, "rohit");
3072        assert_eq!(got[0].reason, "because newest");
3073        homes.home(&homes.cfg_a);
3074        let got = load().unwrap();
3075        assert_eq!(got[0].text, "newest edit");
3076        assert_eq!(got[0].author, "rohit");
3077        assert_eq!(got[0].reason, "because newest");
3078
3079        let removed = forget(&id).unwrap();
3080        assert!(removed.deleted);
3081        assert_eq!(removed.author, "rohit");
3082        assert_eq!(removed.reason, "because newest");
3083        rewrite(
3084            &id,
3085            "newest edit",
3086            "rohit",
3087            "because newest",
3088            "2099-01-02T00:00:00.000Z",
3089            true,
3090        );
3091        exchange(&homes.a, "laptop");
3092        homes.home(&homes.cfg_b);
3093        exchange(&homes.b, "shared");
3094        assert!(load().unwrap().is_empty(), "{:?}", load().unwrap());
3095        let tomb = read_all().unwrap();
3096        assert_eq!(tomb.len(), 1);
3097        assert!(tomb[0].deleted);
3098        assert_eq!(tomb[0].author, "rohit");
3099        assert_eq!(tomb[0].reason, "because newest");
3100        assert!(!preface().contains("newest edit"));
3101        rewrite(
3102            &id,
3103            "older edit",
3104            "ada",
3105            "because older",
3106            "2099-01-01T00:00:01.000Z",
3107            false,
3108        );
3109        exchange(&homes.b, "shared");
3110        assert!(
3111            load().unwrap().is_empty(),
3112            "a tombstone keeps the older edit out"
3113        );
3114        let tomb = read_all().unwrap();
3115        assert!(tomb[0].deleted);
3116        assert_eq!(tomb[0].updated_at, "2099-01-02T00:00:00.000Z");
3117    }
3118
3119    #[test]
3120    fn the_hook_shows_local_rules_when_the_tracker_does_not_answer() {
3121        let (_lock, dir) = isolated();
3122        write_preference(
3123            "never post or reply to Alan (ocaisa)",
3124            "he asked not to be pinged",
3125            &People::default(),
3126            &People::default(),
3127        )
3128        .unwrap();
3129        let repo = dir.path().join("tracker");
3130        std::fs::create_dir_all(&repo).unwrap();
3131        git_ok(&repo, &["init", "-q", "-b", "main"]);
3132        prepare_clone(&repo);
3133        git_ok(&repo, &["commit", "--allow-empty", "-q", "-m", "init"]);
3134        git_ok(&repo, &["config", "http.lowSpeedLimit", "1"]);
3135        git_ok(&repo, &["config", "http.lowSpeedTime", "1"]);
3136        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
3137        let port = listener.local_addr().unwrap().port();
3138        std::thread::spawn(move || {
3139            let Ok((mut stream, _)) = listener.accept() else {
3140                return;
3141            };
3142            let mut buf = [0u8; 64];
3143            let _ = std::io::Read::read(&mut stream, &mut buf);
3144        });
3145        let url = format!("http://127.0.0.1:{port}/tracker.git");
3146        git_ok(&repo, &["remote", "add", "origin", &url]);
3147        // A scope, so the sync reaches the fetch that does not answer.
3148        std::fs::create_dir_all(repo.join(".ljos")).unwrap();
3149        std::fs::write(
3150            repo.join(".ljos/sync.toml"),
3151            "name = \"home\"\nrecipients = []\n",
3152        )
3153        .unwrap();
3154        unsafe {
3155            // Unset is off under `cargo test`, so this test asks for a sync.
3156            std::env::set_var("LJOS_USER_RULES_SYNC", "on");
3157            std::env::set_var("LJOS_USER_RULES_TRACKER", &repo);
3158            std::env::set_var("LJOS_USER_RULES_SYNC_MS", "400");
3159        }
3160        let started = std::time::Instant::now();
3161        let line = super::super::session_start_for(Some("seat-two"));
3162        let elapsed = started.elapsed();
3163        assert!(
3164            elapsed < std::time::Duration::from_secs(3),
3165            "{elapsed:?}\n{line}"
3166        );
3167        assert!(line.starts_with("User rules"), "{line}");
3168        assert!(line.contains("never post or reply to Alan"), "{line}");
3169    }
3170}