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