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