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