1use std::path::{Path, PathBuf};
7
8#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
11pub struct Proc {
12 pub pid: u32,
13 pub start: u64,
14 #[serde(default, skip_serializing_if = "String::is_empty")]
15 pub comm: String,
16}
17
18impl Proc {
19 #[must_use]
21 pub fn same(&self, other: &Self) -> bool {
22 self.pid == other.pid && self.start == other.start
23 }
24}
25
26const DEPTH: usize = 32;
28
29#[derive(Clone)]
30struct Stat {
31 comm: String,
32 dead: bool,
33 ppid: u32,
34 start: u64,
35}
36
37fn stat(pid: u32) -> Option<Stat> {
43 if let Some(found) = ancestor_stats()
44 .lock()
45 .unwrap_or_else(std::sync::PoisonError::into_inner)
46 .get(&pid)
47 {
48 return found.clone();
49 }
50 stat_read(pid)
51}
52
53type AncestorStats = std::sync::Mutex<std::collections::HashMap<u32, Option<Stat>>>;
54
55fn ancestor_stats() -> &'static AncestorStats {
58 static STATS: std::sync::OnceLock<AncestorStats> = std::sync::OnceLock::new();
59 STATS.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()))
60}
61
62#[cfg(target_os = "linux")]
63fn stat_read(pid: u32) -> Option<Stat> {
64 let raw = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
65 let open = raw.find('(')?;
66 let close = raw.rfind(')')?;
67 let comm = raw.get(open + 1..close)?.to_string();
68 let rest: Vec<&str> = raw.get(close + 1..)?.split_whitespace().collect();
70 Some(Stat {
71 comm,
72 dead: matches!(*rest.first()?, "Z" | "X" | "x"),
73 ppid: rest.get(1)?.parse().ok()?,
74 start: rest.get(19)?.parse().ok()?,
75 })
76}
77
78#[cfg(target_os = "macos")]
79fn stat_read(pid: u32) -> Option<Stat> {
80 let mut info: libc::proc_bsdinfo = unsafe { std::mem::zeroed() };
81 let size = libc::c_int::try_from(std::mem::size_of::<libc::proc_bsdinfo>()).ok()?;
82 let n = unsafe {
84 libc::proc_pidinfo(
85 libc::c_int::try_from(pid).ok()?,
86 libc::PROC_PIDTBSDINFO,
87 0,
88 std::ptr::addr_of_mut!(info).cast(),
89 size,
90 )
91 };
92 if n != size {
93 return short_stat(pid);
97 }
98 let comm: Vec<u8> = info
99 .pbi_comm
100 .iter()
101 .take_while(|c| **c != 0)
102 .map(|c| c.to_ne_bytes()[0])
103 .collect();
104 let start = info
105 .pbi_start_tvsec
106 .saturating_mul(1_000_000)
107 .saturating_add(info.pbi_start_tvusec);
108 Some(Stat {
109 comm: String::from_utf8_lossy(&comm).into_owned(),
110 dead: info.pbi_status == libc::SZOMB,
111 ppid: info.pbi_ppid,
112 start,
113 })
114}
115
116#[cfg(target_os = "macos")]
119fn short_stat(pid: u32) -> Option<Stat> {
120 let mut info: libc::proc_bsdshortinfo = unsafe { std::mem::zeroed() };
121 let size = libc::c_int::try_from(std::mem::size_of::<libc::proc_bsdshortinfo>()).ok()?;
122 let n = unsafe {
124 libc::proc_pidinfo(
125 libc::c_int::try_from(pid).ok()?,
126 libc::PROC_PIDT_SHORTBSDINFO,
127 0,
128 std::ptr::addr_of_mut!(info).cast(),
129 size,
130 )
131 };
132 if n != size {
133 return None;
134 }
135 let comm: Vec<u8> = info
136 .pbsi_comm
137 .iter()
138 .take_while(|c| **c != 0)
139 .map(|c| c.to_ne_bytes()[0])
140 .collect();
141 Some(Stat {
142 comm: String::from_utf8_lossy(&comm).into_owned(),
143 dead: info.pbsi_status == libc::SZOMB,
144 ppid: info.pbsi_ppid,
145 start: kern_proc_start(pid)?,
146 })
147}
148
149#[cfg(target_os = "macos")]
153fn kern_proc_start(pid: u32) -> Option<u64> {
154 let mut mib = [
155 libc::CTL_KERN,
156 libc::KERN_PROC,
157 libc::KERN_PROC_PID,
158 libc::c_int::try_from(pid).ok()?,
159 ];
160 let mut buf = [0u8; 1024];
161 let mut len: libc::size_t = buf.len();
162 let rc = unsafe {
165 libc::sysctl(
166 mib.as_mut_ptr(),
167 4,
168 buf.as_mut_ptr().cast(),
169 &mut len,
170 std::ptr::null_mut(),
171 0,
172 )
173 };
174 if rc != 0 || len < 12 {
175 return None;
176 }
177 let sec = i64::from_ne_bytes(buf[0..8].try_into().ok()?);
178 let usec = i32::from_ne_bytes(buf[8..12].try_into().ok()?);
179 let sec = u64::try_from(sec).ok()?;
180 let usec = u64::try_from(usec).ok()?;
181 Some(sec.saturating_mul(1_000_000).saturating_add(usec))
182}
183
184#[cfg(not(any(target_os = "linux", target_os = "macos")))]
185fn stat_read(_pid: u32) -> Option<Stat> {
186 None
187}
188
189#[must_use]
191pub fn of(pid: u32) -> Option<Proc> {
192 stat(pid).map(|s| Proc {
193 pid,
194 start: s.start,
195 comm: s.comm,
196 })
197}
198
199#[must_use]
202pub fn above(pid: u32) -> Vec<Proc> {
203 let own = pid == std::process::id();
204 let mut out = Vec::new();
205 let mut next = stat(pid).map(|s| s.ppid);
206 while let Some(p) = next.filter(|&p| p > 0 && out.len() < DEPTH) {
207 let Some(s) = stat(p) else {
208 break;
209 };
210 if own && std::env::var_os("LJOS_IN_HOOK").is_some() {
211 ancestor_stats()
212 .lock()
213 .unwrap_or_else(std::sync::PoisonError::into_inner)
214 .entry(p)
215 .or_insert_with(|| Some(s.clone()));
216 }
217 next = Some(s.ppid);
218 out.push(Proc {
219 pid: p,
220 start: s.start,
221 comm: s.comm,
222 });
223 }
224 out
225}
226
227#[must_use]
231pub fn ancestors() -> Vec<Proc> {
232 static WALKED: std::sync::OnceLock<Vec<Proc>> = std::sync::OnceLock::new();
233 WALKED.get_or_init(|| above(std::process::id())).clone()
234}
235
236fn parent_and_name(pid: u32) -> Option<(u32, String)> {
238 stat(pid).map(|s| (s.ppid, s.comm))
239}
240
241fn split_environ(raw: &[u8]) -> Vec<(String, String)> {
242 raw.split(|b| *b == 0)
243 .filter_map(|kv| {
244 let kv = String::from_utf8_lossy(kv);
245 let (k, v) = kv.split_once('=')?;
246 Some((k.to_string(), v.to_string()))
247 })
248 .collect()
249}
250
251#[cfg(target_os = "linux")]
254#[must_use]
255pub fn environ(pid: u32) -> Option<Vec<(String, String)>> {
256 std::fs::read(format!("/proc/{pid}/environ"))
257 .ok()
258 .map(|raw| split_environ(&raw))
259}
260
261#[cfg(target_os = "macos")]
262fn procargs2_buf(pid: u32) -> Option<Vec<u8>> {
263 let mut mib = [
264 libc::CTL_KERN,
265 libc::KERN_PROCARGS2,
266 libc::c_int::try_from(pid).ok()?,
267 ];
268 let mut size: libc::size_t = 0;
269 let asked = unsafe {
271 libc::sysctl(
272 mib.as_mut_ptr(),
273 3,
274 std::ptr::null_mut(),
275 &mut size,
276 std::ptr::null_mut(),
277 0,
278 )
279 };
280 if asked != 0 || size < 4 {
281 return None;
282 }
283 let mut buf = vec![0u8; size];
284 let read = unsafe {
286 libc::sysctl(
287 mib.as_mut_ptr(),
288 3,
289 buf.as_mut_ptr().cast(),
290 &mut size,
291 std::ptr::null_mut(),
292 0,
293 )
294 };
295 if read != 0 {
296 return None;
297 }
298 buf.truncate(size);
299 Some(buf)
300}
301
302#[cfg(target_os = "macos")]
303#[must_use]
304pub fn environ(pid: u32) -> Option<Vec<(String, String)>> {
305 let buf = procargs2_buf(pid)?;
306 let argc = usize::try_from(i32::from_ne_bytes(buf.get(..4)?.try_into().ok()?)).ok()?;
309 let mut words = buf[4..]
310 .split(|b| *b == 0)
311 .skip(1)
312 .skip_while(|w| w.is_empty());
313 for _ in 0..argc {
314 words.next()?;
315 }
316 let env: Vec<u8> = words
317 .take_while(|w| !w.is_empty())
318 .flat_map(|w| w.iter().copied().chain([0]))
319 .collect();
320 Some(split_environ(&env))
321}
322
323#[cfg(not(any(target_os = "linux", target_os = "macos")))]
324#[must_use]
325pub fn environ(_pid: u32) -> Option<Vec<(String, String)>> {
326 None
327}
328
329#[cfg(target_os = "linux")]
331#[must_use]
332pub fn argv(pid: u32) -> Option<Vec<String>> {
333 let raw = std::fs::read(format!("/proc/{pid}/cmdline")).ok()?;
334 let words: Vec<String> = raw
335 .split(|b| *b == 0)
336 .filter(|w| !w.is_empty())
337 .map(|w| String::from_utf8_lossy(w).into_owned())
338 .collect();
339 (!words.is_empty()).then_some(words)
340}
341
342#[cfg(target_os = "macos")]
343#[must_use]
344pub fn argv(pid: u32) -> Option<Vec<String>> {
345 let buf = procargs2_buf(pid)?;
346 let argc = usize::try_from(i32::from_ne_bytes(buf.get(..4)?.try_into().ok()?)).ok()?;
347 if argc == 0 {
348 return None;
349 }
350 let words: Vec<String> = buf[4..]
351 .split(|b| *b == 0)
352 .skip(1)
353 .skip_while(|w| w.is_empty())
354 .take(argc)
355 .filter(|w| !w.is_empty())
356 .map(|w| String::from_utf8_lossy(w).into_owned())
357 .collect();
358 (!words.is_empty()).then_some(words)
359}
360
361#[cfg(not(any(target_os = "linux", target_os = "macos")))]
362#[must_use]
363pub fn argv(_pid: u32) -> Option<Vec<String>> {
364 None
365}
366
367#[cfg(target_os = "linux")]
369#[must_use]
370pub fn exe(pid: u32) -> Option<PathBuf> {
371 std::fs::read_link(format!("/proc/{pid}/exe")).ok()
372}
373
374#[cfg(target_os = "macos")]
375#[must_use]
376pub fn exe(pid: u32) -> Option<PathBuf> {
377 use std::os::unix::ffi::OsStrExt as _;
378 let mut buf = [0u8; 4096];
379 let len = unsafe {
381 libc::proc_pidpath(
382 libc::c_int::try_from(pid).ok()?,
383 buf.as_mut_ptr().cast(),
384 u32::try_from(buf.len()).ok()?,
385 )
386 };
387 let len = usize::try_from(len)
388 .ok()
389 .filter(|&n| n > 0 && n <= buf.len())?;
390 Some(PathBuf::from(std::ffi::OsStr::from_bytes(&buf[..len])))
391}
392
393#[cfg(not(any(target_os = "linux", target_os = "macos")))]
394#[must_use]
395pub fn exe(_pid: u32) -> Option<PathBuf> {
396 None
397}
398
399#[cfg(target_os = "linux")]
401#[must_use]
402pub fn cwd(pid: u32) -> Option<PathBuf> {
403 std::fs::read_link(format!("/proc/{pid}/cwd")).ok()
404}
405
406#[cfg(target_os = "macos")]
407#[must_use]
408pub fn cwd(pid: u32) -> Option<PathBuf> {
409 use std::os::unix::ffi::OsStrExt as _;
410 let mut info: libc::proc_vnodepathinfo = unsafe { std::mem::zeroed() };
411 let size = libc::c_int::try_from(std::mem::size_of::<libc::proc_vnodepathinfo>()).ok()?;
412 let n = unsafe {
414 libc::proc_pidinfo(
415 libc::c_int::try_from(pid).ok()?,
416 libc::PROC_PIDVNODEPATHINFO,
417 0,
418 std::ptr::addr_of_mut!(info).cast(),
419 size,
420 )
421 };
422 if n != size {
423 return None;
424 }
425 let bytes: Vec<u8> = info
426 .pvi_cdir
427 .vip_path
428 .iter()
429 .flatten()
430 .take_while(|c| **c != 0)
431 .map(|c| c.to_ne_bytes()[0])
432 .collect();
433 (!bytes.is_empty()).then(|| PathBuf::from(std::ffi::OsStr::from_bytes(&bytes)))
434}
435
436#[cfg(not(any(target_os = "linux", target_os = "macos")))]
437#[must_use]
438pub fn cwd(_pid: u32) -> Option<PathBuf> {
439 None
440}
441
442#[must_use]
447pub fn started_by_runner() -> Option<u32> {
448 let mut child = std::process::id();
449 for _ in 0..DEPTH {
450 let (ppid, _) = parent_and_name(child)?;
451 if ppid == 0 {
452 return None;
453 }
454 let (_, comm) = parent_and_name(ppid)?;
455 if crate::is_harness_process(ppid, &comm) {
456 return Some(child);
457 }
458 if crate::is_session(&comm) || ppid == 1 {
459 return None;
460 }
461 child = ppid;
462 }
463 None
464}
465
466#[must_use]
469pub fn alive(p: &Proc) -> bool {
470 stat(p.pid).is_some_and(|s| s.start == p.start && !s.dead)
471}
472
473#[must_use]
476pub fn boot() -> &'static str {
477 static BOOT: std::sync::OnceLock<String> = std::sync::OnceLock::new();
478 BOOT.get_or_init(|| {
479 raw_boot_id()
480 .map(|s| {
481 s.trim()
482 .chars()
483 .filter(char::is_ascii_hexdigit)
484 .take(12)
485 .collect()
486 })
487 .filter(|s: &String| !s.is_empty())
488 .unwrap_or_else(|| "-".into())
489 })
490}
491
492#[cfg(target_os = "linux")]
493fn raw_boot_id() -> Option<String> {
494 std::fs::read_to_string("/proc/sys/kernel/random/boot_id").ok()
495}
496
497#[cfg(target_os = "macos")]
498fn raw_boot_id() -> Option<String> {
499 let mut buf = [0u8; 64];
500 let mut size = buf.len();
501 let rc = unsafe {
503 libc::sysctlbyname(
504 c"kern.bootsessionuuid".as_ptr(),
505 buf.as_mut_ptr().cast(),
506 &mut size,
507 std::ptr::null_mut(),
508 0,
509 )
510 };
511 if rc != 0 || size == 0 {
512 return None;
513 }
514 let end = buf[..size].iter().position(|&b| b == 0).unwrap_or(size);
515 String::from_utf8(buf[..end].to_vec()).ok()
516}
517
518#[cfg(not(any(target_os = "linux", target_os = "macos")))]
519fn raw_boot_id() -> Option<String> {
520 None
521}
522
523#[must_use]
525pub fn token(p: &Proc) -> String {
526 format!("{}:{}:{}", boot(), p.pid, p.start)
527}
528
529#[must_use]
531pub fn token_alive(token: &str) -> bool {
532 let mut parts = token.splitn(3, ':');
533 let (Some(at), Some(pid), Some(start)) = (parts.next(), parts.next(), parts.next()) else {
534 return false;
535 };
536 let (Ok(pid), Ok(start)) = (pid.parse(), start.parse()) else {
537 return false;
538 };
539 at == boot()
540 && alive(&Proc {
541 pid,
542 start,
543 comm: String::new(),
544 })
545}
546
547const SHELLS: &[&str] = &[
548 "sh", "bash", "dash", "zsh", "ksh", "mksh", "ash", "yash", "fish", "tcsh", "csh", "busybox",
549];
550
551const WRAPPERS: &[&str] = &[
554 "env", "nice", "nohup", "timeout", "stdbuf", "setsid", "ionice", "chrt", "taskset", "time",
555 "flock", "sudo", "doas", "xargs",
556];
557
558fn is_shell(p: &Proc) -> bool {
559 SHELLS.contains(&p.comm.as_str())
560}
561
562fn is_wrapper(p: &Proc) -> bool {
563 WRAPPERS.contains(&p.comm.as_str())
564}
565
566fn is_harness(p: &Proc) -> bool {
567 crate::is_harness_process(p.pid, &p.comm)
568}
569
570#[must_use]
575pub fn calling_shell(chain: &[Proc]) -> Option<&Proc> {
576 let mut rest = chain.iter().skip_while(|p| is_wrapper(p));
577 let mut top = rest.next().filter(|p| is_shell(p))?;
578 for p in rest {
579 if is_shell(p) {
580 top = p;
581 } else if !is_wrapper(p) {
582 break;
583 }
584 }
585 Some(top)
586}
587
588fn exe_path_key(path: &Path) -> std::ffi::OsString {
589 let text = path.as_os_str().to_string_lossy();
590 match text.strip_suffix(" (deleted)") {
591 Some(plain) => std::ffi::OsString::from(plain),
592 None => path.as_os_str().to_os_string(),
593 }
594}
595
596fn same_resolved_exe(pid: u32, mine: &Path) -> bool {
597 exe(pid).is_some_and(|theirs| exe_path_key(&theirs) == exe_path_key(mine))
598}
599
600#[must_use]
603pub fn is_ljos_exe(path: &Path) -> bool {
604 let key = exe_path_key(path);
605 Path::new(&key)
606 .file_name()
607 .is_some_and(|name| name == "ljos")
608}
609
610fn is_python(p: &Proc) -> bool {
611 p.comm.starts_with("python")
612}
613
614#[must_use]
621pub fn cast_shell<'a>(chain: &'a [Proc], mine: Option<&Path>) -> Option<&'a Proc> {
622 let transparent =
623 |p: &Proc| is_wrapper(p) || mine.is_some_and(|mine| same_resolved_exe(p.pid, mine));
624 let mut i = 0;
625 while i < chain.len() {
626 let p = &chain[i];
627 if transparent(p) || is_python(p) {
628 i += 1;
629 continue;
630 }
631 break;
632 }
633 let mut top = chain.get(i).filter(|p| is_shell(p))?;
634 i += 1;
635 while i < chain.len() {
636 let p = &chain[i];
637 if is_shell(p) {
638 top = p;
639 } else if !transparent(p) {
640 break;
641 }
642 i += 1;
643 }
644 Some(top)
645}
646
647fn ends_the_walk(p: &Proc) -> bool {
652 crate::is_session(&p.comm) || (p.pid == 1 && !is_harness(p))
653}
654
655#[must_use]
664pub fn runner(chain: &[Proc]) -> Option<&Proc> {
665 let own = chain.iter().take_while(|p| !ends_the_walk(p));
666 own.clone()
667 .find(|p| is_harness(p))
668 .or_else(|| own.clone().find(|p| !is_shell(p) && !is_wrapper(p)))
669 .or_else(|| own.last())
670}
671
672const HOOK_RUNNERS: &[(&str, &str)] = &[
675 ("claude", "claude"),
676 ("claude-code", "claude"),
677 ("codex", "codex"),
678 ("cursor-agent", "cursor-agent"),
679 ("cursor", "cursor"),
680 ("grok", "grok"),
681 ("opencode", "opencode"),
682 ("gemini", "gemini"),
683 ("agy", "agy"),
684 ("antigravity", "agy"),
685];
686
687const RUNTIMES: &[&str] = &["node", "nodejs", "bun", "deno"];
690
691fn component_stem(component: &str) -> &str {
693 component
694 .strip_suffix(".js")
695 .or_else(|| component.strip_suffix(".mjs"))
696 .or_else(|| component.strip_suffix(".cjs"))
697 .unwrap_or(component)
698}
699
700fn layout_component(component: &str) -> bool {
702 let name = component_stem(component);
703 crate::version_like(name)
704 || crate::PLACES.contains(&name)
705 || crate::ENTRY_STEMS.contains(&name)
706 || RUNTIMES.contains(&name)
707 || matches!(name, "vendor" | "share")
708 || name.starts_with('@')
709 || name.contains("unknown-linux")
710 || matches!(name, "x86_64" | "aarch64" | "arm64")
711 || name.starts_with("armv")
712}
713
714fn hook_name(component: &str) -> Option<&'static str> {
716 let name = component_stem(component);
717 let name = name.split_once("-x86_64").map_or(name, |(head, _)| head);
718 let name = name.split_once("-aarch64").map_or(name, |(head, _)| head);
719 HOOK_RUNNERS
720 .iter()
721 .find(|(n, _)| *n == name)
722 .map(|(_, runner)| *runner)
723}
724
725#[must_use]
728pub fn own_home() -> Option<std::path::PathBuf> {
729 use std::os::unix::ffi::OsStrExt as _;
730 let mut buf = vec![0u8; 1024];
731 loop {
732 let mut entry: libc::passwd = unsafe { std::mem::zeroed() };
734 let mut found: *mut libc::passwd = std::ptr::null_mut();
735 let rc = unsafe {
738 libc::getpwuid_r(
739 libc::getuid(),
740 &mut entry,
741 buf.as_mut_ptr().cast(),
742 buf.len(),
743 &mut found,
744 )
745 };
746 if rc == libc::ERANGE && buf.len() < 1 << 20 {
747 buf.resize(buf.len() * 2, 0);
748 continue;
749 }
750 if rc != 0 || found.is_null() || entry.pw_dir.is_null() {
751 return None;
752 }
753 let dir = unsafe { std::ffi::CStr::from_ptr(entry.pw_dir) };
755 let home = std::path::PathBuf::from(std::ffi::OsStr::from_bytes(dir.to_bytes()));
756 return home.is_absolute().then_some(home);
757 }
758}
759
760const HOME_ROOTS: &[&str] = &[".local/bin", ".cargo/bin", ".npm-global"];
767
768const SYSTEM_ROOTS: &[&str] = &[
771 "/usr/bin",
772 "/bin",
773 "/usr/local/bin",
774 "/opt/homebrew/bin",
775 "/usr/lib/node_modules",
776 "/usr/local/lib/node_modules",
777 "/opt/homebrew/lib/node_modules",
778];
779
780fn install_roots(home: Option<&Path>) -> Vec<PathBuf> {
785 let mut roots: Vec<PathBuf> = SYSTEM_ROOTS.iter().map(PathBuf::from).collect();
786 let mut shares = vec![PathBuf::from("/usr/share")];
787 if let Some(home) = home {
788 roots.extend(HOME_ROOTS.iter().map(|root| home.join(root)));
789 shares.push(home.join(".local/share"));
790 }
791 for share in shares {
792 roots.extend(HOOK_RUNNERS.iter().map(|(name, _)| share.join(name)));
793 }
794 roots
795}
796
797const NIX_STORE: &str = "/nix/store";
799
800fn nix_entry(path: &Path, store: &Path) -> Option<PathBuf> {
803 let name = path.strip_prefix(store).ok()?.iter().next()?.to_str()?;
804 let (hash, rest) = name.split_at_checked(32)?;
805 let hashed = hash
806 .chars()
807 .all(|c| "0123456789abcdfghijklmnpqrsvwxyz".contains(c));
808 (hashed && rest.len() > 1 && rest.starts_with('-')).then(|| store.join(name))
809}
810
811fn runner_below(path: &Path, roots: &[PathBuf], store: &Path) -> Option<(&'static str, PathBuf)> {
816 let root = root_below(path, roots, store)?;
817 let parts = root
818 .file_name()
819 .into_iter()
820 .chain(path.strip_prefix(&root).ok()?.iter())
821 .map(std::ffi::OsStr::to_str)
822 .collect::<Option<Vec<_>>>()?;
823 let name = parts.iter().rev().find(|part| !layout_component(part))?;
824 Some((hook_name(name)?, root))
825}
826
827fn root_below(path: &Path, roots: &[PathBuf], store: &Path) -> Option<PathBuf> {
832 let plain = path.is_absolute()
833 && path.components().all(|c| {
834 matches!(
835 c,
836 std::path::Component::RootDir | std::path::Component::Normal(_)
837 )
838 });
839 if !plain || path.to_str()?.contains(" (deleted)") {
840 return None;
841 }
842 roots
843 .iter()
844 .cloned()
845 .chain(nix_entry(path, store))
846 .find(|root| path != root.as_path() && path.starts_with(root))
847}
848
849struct GroupEntry {
851 name: std::ffi::OsString,
852 members: Vec<std::ffi::OsString>,
853}
854
855trait Accounts {
859 fn user_name(&self, uid: u32) -> Option<std::ffi::OsString>;
860 fn group(&self, gid: u32) -> Option<GroupEntry>;
861 fn primary_members(&self, gid: u32) -> Option<Vec<u32>>;
863}
864
865struct SystemAccounts;
871
872fn with_buf<T>(mut lookup: impl FnMut(&mut [u8]) -> Result<Option<T>, i32>) -> Option<T> {
874 let mut buf = vec![0u8; 1024];
875 loop {
876 match lookup(&mut buf) {
877 Err(libc::ERANGE) if buf.len() < 1 << 20 => buf.resize(buf.len() * 2, 0),
878 Ok(found) => return found,
879 Err(_) => return None,
880 }
881 }
882}
883
884unsafe fn os_string(ptr: *const libc::c_char) -> std::ffi::OsString {
887 use std::os::unix::ffi::OsStrExt as _;
888 let bytes = unsafe { std::ffi::CStr::from_ptr(ptr) }.to_bytes();
890 std::ffi::OsStr::from_bytes(bytes).to_os_string()
891}
892
893impl Accounts for SystemAccounts {
894 fn user_name(&self, uid: u32) -> Option<std::ffi::OsString> {
895 with_buf(|buf| {
896 let mut entry: libc::passwd = unsafe { std::mem::zeroed() };
898 let mut found: *mut libc::passwd = std::ptr::null_mut();
899 let rc = unsafe {
902 libc::getpwuid_r(
903 uid,
904 &mut entry,
905 buf.as_mut_ptr().cast(),
906 buf.len(),
907 &mut found,
908 )
909 };
910 if rc != 0 {
911 return Err(rc);
912 }
913 Ok((!found.is_null() && !entry.pw_name.is_null())
915 .then(|| unsafe { os_string(entry.pw_name) }))
916 })
917 }
918
919 fn group(&self, gid: u32) -> Option<GroupEntry> {
920 group_in_files(Path::new(ETC), gid)
921 }
922
923 fn primary_members(&self, gid: u32) -> Option<Vec<u32>> {
924 primary_in_files(Path::new(ETC), gid)
925 }
926}
927
928const ETC: &str = "/etc";
930
931fn files_only(etc: &Path, db: &str) -> bool {
937 let Ok(conf) = std::fs::read_to_string(etc.join("nsswitch.conf")) else {
938 return false;
939 };
940 let mut lines = conf
941 .lines()
942 .map(|line| line.split('#').next().unwrap_or_default())
943 .filter_map(|line| line.split_once(':'))
944 .filter(|(name, _)| name.trim() == db);
945 let (Some((_, sources)), None) = (lines.next(), lines.next()) else {
946 return false;
947 };
948 sources.split_whitespace().eq(["files"])
949}
950
951fn group_in_files(etc: &Path, gid: u32) -> Option<GroupEntry> {
957 if !files_only(etc, "group") {
958 return None;
959 }
960 let text = std::fs::read_to_string(etc.join("group")).ok()?;
961 let mut found = None;
962 for line in text.lines() {
963 if line.is_empty() || line.starts_with('#') {
964 continue;
965 }
966 if line.starts_with(['+', '-']) {
967 return None;
968 }
969 let fields: Vec<&str> = line.split(':').collect();
970 let [name, _, id, members] = fields[..] else {
971 return None;
972 };
973 if id.parse::<u32>().ok()? != gid {
974 continue;
975 }
976 if found.is_some() {
977 return None;
978 }
979 found = Some(GroupEntry {
980 name: name.into(),
981 members: members
982 .split(',')
983 .filter(|m| !m.is_empty())
984 .map(Into::into)
985 .collect(),
986 });
987 }
988 found
989}
990
991fn primary_in_files(etc: &Path, gid: u32) -> Option<Vec<u32>> {
997 if !files_only(etc, "passwd") {
998 return None;
999 }
1000 let text = std::fs::read_to_string(etc.join("passwd")).ok()?;
1001 let mut uids = Vec::new();
1002 for line in text.lines() {
1003 if line.is_empty() || line.starts_with('#') {
1004 continue;
1005 }
1006 if line.starts_with(['+', '-']) {
1007 return None;
1008 }
1009 let fields: Vec<&str> = line.split(':').collect();
1010 let [_, _, uid, primary, _, _, _] = fields[..] else {
1011 return None;
1012 };
1013 let (uid, primary) = (uid.parse::<u32>().ok()?, primary.parse::<u32>().ok()?);
1014 if primary == gid {
1015 uids.push(uid);
1016 }
1017 }
1018 Some(uids)
1019}
1020
1021fn group_is_private(accounts: &dyn Accounts, uid: u32, gid: u32) -> bool {
1024 let (Some(user), Some(group), Some(primary)) = (
1025 accounts.user_name(uid),
1026 accounts.group(gid),
1027 accounts.primary_members(gid),
1028 ) else {
1029 return false;
1030 };
1031 group.name == user
1032 && group.members.iter().all(|member| *member == user)
1033 && primary.iter().all(|other| *other == uid)
1034}
1035
1036struct Person<'a> {
1041 uid: u32,
1042 gid: u32,
1043 groups: Option<Vec<u32>>,
1044 accounts: &'a dyn Accounts,
1045 private_group: std::cell::OnceCell<bool>,
1046}
1047
1048impl<'a> Person<'a> {
1049 fn new(accounts: &'a dyn Accounts) -> Self {
1050 let (uid, gid) = unsafe { (libc::getuid(), libc::getgid()) };
1053 Person {
1054 uid,
1055 gid,
1056 groups: supplementary_groups(),
1057 accounts,
1058 private_group: std::cell::OnceCell::new(),
1059 }
1060 }
1061
1062 fn in_group(&self, gid: u32) -> bool {
1065 gid == self.gid
1066 || self
1067 .groups
1068 .as_ref()
1069 .is_none_or(|groups| groups.contains(&gid))
1070 }
1071
1072 fn private_group(&self) -> bool {
1073 *self
1074 .private_group
1075 .get_or_init(|| group_is_private(self.accounts, self.uid, self.gid))
1076 }
1077}
1078
1079fn supplementary_groups() -> Option<Vec<u32>> {
1081 let count = unsafe { libc::getgroups(0, std::ptr::null_mut()) };
1083 let mut groups = vec![0; usize::try_from(count).ok()?];
1084 let got = unsafe { libc::getgroups(count, groups.as_mut_ptr()) };
1086 groups.truncate(usize::try_from(got).ok()?);
1087 Some(groups)
1088}
1089
1090#[derive(Clone, Copy)]
1092struct Owner {
1093 uid: u32,
1094 gid: u32,
1095 mode: u32,
1096}
1097
1098impl From<&std::fs::Metadata> for Owner {
1099 fn from(meta: &std::fs::Metadata) -> Self {
1100 use std::os::unix::fs::MetadataExt as _;
1101 Owner {
1102 uid: meta.uid(),
1103 gid: meta.gid(),
1104 mode: meta.mode(),
1105 }
1106 }
1107}
1108
1109fn pinned_walk(file: &Path, expect: (u64, u64)) -> Option<Vec<Owner>> {
1118 use std::os::fd::{AsRawFd as _, FromRawFd as _};
1119 use std::os::unix::ffi::OsStrExt as _;
1120 use std::os::unix::fs::MetadataExt as _;
1121 #[cfg(target_os = "linux")]
1122 let flags = libc::O_PATH | libc::O_NOFOLLOW | libc::O_CLOEXEC;
1123 #[cfg(not(target_os = "linux"))]
1124 let flags = libc::O_RDONLY | libc::O_NOFOLLOW | libc::O_CLOEXEC;
1125 let open = |dir: libc::c_int, name: &std::ffi::CStr| {
1126 let fd = unsafe { libc::openat(dir, name.as_ptr(), flags) };
1128 (fd >= 0).then(|| std::fs::File::from(unsafe { std::os::fd::OwnedFd::from_raw_fd(fd) }))
1130 };
1131 let mut parts = file.components();
1132 if parts.next() != Some(std::path::Component::RootDir) {
1133 return None;
1134 }
1135 let mut at = open(libc::AT_FDCWD, c"/")?;
1136 let mut meta = at.metadata().ok()?;
1137 let mut owners = vec![Owner::from(&meta)];
1138 for part in parts {
1139 let std::path::Component::Normal(name) = part else {
1140 return None;
1141 };
1142 if !meta.is_dir() {
1143 return None;
1144 }
1145 at = open(
1146 at.as_raw_fd(),
1147 &std::ffi::CString::new(name.as_bytes()).ok()?,
1148 )?;
1149 meta = at.metadata().ok()?;
1150 owners.push(Owner::from(&meta));
1151 }
1152 (meta.is_file() && expect == (meta.dev(), meta.ino())).then_some(owners)
1153}
1154
1155fn store_keeps(store: Owner, entry: Owner, person: &Person) -> bool {
1164 let group = store.mode & 0o020 != 0;
1165 let anyone = store.mode & 0o002 != 0;
1166 if store.uid == 0 {
1167 let person_writes = anyone
1168 || (group && person.in_group(store.gid))
1169 || (person.uid == 0 && store.mode & 0o200 != 0);
1170 !person_writes && (!group || store.mode & 0o1000 != 0) && entry.uid == 0
1171 } else {
1172 store.uid == person.uid && !group && !anyone && (entry.uid == 0 || entry.uid == person.uid)
1173 }
1174}
1175
1176fn person_keeps(file: Owner, person: &Person) -> bool {
1179 (file.uid == 0 || file.uid == person.uid)
1180 && file.mode & 0o002 == 0
1181 && (file.mode & 0o020 == 0 || (file.gid == person.gid && person.private_group()))
1182}
1183
1184fn runner_of_file(
1201 path: &Path,
1202 home: Option<&Path>,
1203 store: &Path,
1204 person: &Person,
1205 expect: (u64, u64),
1206) -> Option<&'static str> {
1207 runner_at(
1208 &std::fs::canonicalize(path).ok()?,
1209 home,
1210 store,
1211 person,
1212 expect,
1213 )
1214}
1215
1216fn runner_at(
1218 file: &Path,
1219 home: Option<&Path>,
1220 store: &Path,
1221 person: &Person,
1222 expect: (u64, u64),
1223) -> Option<&'static str> {
1224 let (roots, store) = resolved_roots(home, store);
1225 let (runner, root) = runner_below(file, &roots, &store)?;
1226 kept_below(file, &root, &store, person, expect).then_some(runner)
1227}
1228
1229fn runtime_of_file(
1233 path: &Path,
1234 home: Option<&Path>,
1235 store: &Path,
1236 person: &Person,
1237 expect: (u64, u64),
1238) -> bool {
1239 let Ok(file) = std::fs::canonicalize(path) else {
1240 return false;
1241 };
1242 let named = file
1243 .file_name()
1244 .and_then(std::ffi::OsStr::to_str)
1245 .is_some_and(|name| RUNTIMES.contains(&name));
1246 let (roots, store) = resolved_roots(home, store);
1247 named
1248 && root_below(&file, &roots, &store)
1249 .is_some_and(|root| kept_below(&file, &root, &store, person, expect))
1250}
1251
1252fn resolved_roots(home: Option<&Path>, store: &Path) -> (Vec<PathBuf>, PathBuf) {
1255 type Roots = (Vec<PathBuf>, PathBuf);
1259 type Resolved = (Option<PathBuf>, PathBuf, Roots);
1260 static RESOLVED: std::sync::LazyLock<std::sync::Mutex<Vec<Resolved>>> =
1261 std::sync::LazyLock::new(|| std::sync::Mutex::new(Vec::new()));
1262 let key_home = home.map(Path::to_path_buf);
1263 if let Some((_, _, roots)) = RESOLVED
1264 .lock()
1265 .unwrap_or_else(std::sync::PoisonError::into_inner)
1266 .iter()
1267 .find(|(h, s, _)| *h == key_home && s == store)
1268 {
1269 return roots.clone();
1270 }
1271 let roots: Vec<PathBuf> = install_roots(home)
1272 .iter()
1273 .filter_map(|root| std::fs::canonicalize(root).ok())
1274 .collect();
1275 let resolved_store = std::fs::canonicalize(store).unwrap_or_else(|_| store.to_path_buf());
1276 let out = (roots, resolved_store);
1277 RESOLVED
1278 .lock()
1279 .unwrap_or_else(std::sync::PoisonError::into_inner)
1280 .push((key_home, store.to_path_buf(), out.clone()));
1281 out
1282}
1283
1284fn kept_below(file: &Path, root: &Path, store: &Path, person: &Person, expect: (u64, u64)) -> bool {
1288 if crate::bookkeep::resolved_path_is_temp(file) {
1289 return false;
1290 }
1291 let (Some(owners), Some(at)) = (
1292 pinned_walk(file, expect),
1293 root.components().count().checked_sub(1),
1294 ) else {
1295 return false;
1296 };
1297 walk_kept(&owners, at, root.parent() == Some(store), person)
1298}
1299
1300fn walk_kept(owners: &[Owner], at: usize, in_store: bool, person: &Person) -> bool {
1307 let (Some(&above), Some(&root)) = (
1308 at.checked_sub(1).and_then(|above| owners.get(above)),
1309 owners.get(at),
1310 ) else {
1311 return false;
1312 };
1313 let top = if in_store {
1314 store_keeps(above, root, person)
1315 } else {
1316 person_keeps(above, person)
1317 };
1318 top && owners[at..].iter().all(|dir| person_keeps(*dir, person))
1319}
1320
1321#[cfg(target_os = "linux")]
1322fn running_exe(pid: u32) -> Option<(PathBuf, Option<(u64, u64)>)> {
1323 use std::os::unix::fs::MetadataExt as _;
1324 let path = format!("/proc/{pid}/exe");
1325 let exe = std::fs::read_link(&path).ok()?;
1326 let running = std::fs::metadata(&path)
1327 .ok()
1328 .map(|meta| (meta.dev(), meta.ino()));
1329 Some((exe, running))
1330}
1331
1332#[cfg(not(target_os = "linux"))]
1333fn running_exe(pid: u32) -> Option<(PathBuf, Option<(u64, u64)>)> {
1334 use std::os::unix::fs::MetadataExt as _;
1335 let exe = exe(pid)?;
1336 let running = std::fs::metadata(&exe)
1337 .ok()
1338 .map(|meta| (meta.dev(), meta.ino()));
1339 Some((exe, running))
1340}
1341
1342#[must_use]
1349pub fn exe_runner(pid: u32) -> Option<&'static str> {
1350 let (exe, running) = running_exe(pid)?;
1351 let script = || {
1352 let words = argv(pid)?;
1353 let script = words
1354 .into_iter()
1355 .skip(1)
1356 .find(|a| !a.is_empty() && !a.starts_with('-'))?;
1357 let cwd = cwd(pid)?;
1358 Some(cwd.join(script))
1359 };
1360 let home = own_home();
1361 let person = Person::new(&SystemAccounts);
1362 runner_from(
1363 &exe,
1364 running,
1365 script,
1366 home.as_deref(),
1367 Path::new(NIX_STORE),
1368 &person,
1369 )
1370}
1371
1372fn runner_from(
1378 exe: &Path,
1379 running: Option<(u64, u64)>,
1380 script: impl FnOnce() -> Option<PathBuf>,
1381 home: Option<&Path>,
1382 store: &Path,
1383 person: &Person,
1384) -> Option<&'static str> {
1385 use std::os::unix::fs::MetadataExt as _;
1386 let running = running?;
1387 if let Some(r) = runner_of_file(exe, home, store, person, running) {
1388 return Some(r);
1389 }
1390 if !runtime_of_file(exe, home, store, person, running) {
1391 return None;
1392 }
1393 let script = std::fs::canonicalize(script()?).ok()?;
1394 let meta = std::fs::metadata(&script).ok()?;
1395 runner_of_file(&script, home, store, person, (meta.dev(), meta.ino()))
1396}
1397
1398const EDITORS: &[&str] = &[
1401 "claude",
1402 "claude-code",
1403 "codex",
1404 "cursor",
1405 "cursor-agent",
1406 "opencode",
1407 "opencode-ai",
1408 "agy",
1409 "antigravity",
1410 "grok",
1411 "gemini",
1412 "copilot",
1413 "qwen",
1414 "droid",
1415 "crush",
1416 "kiro",
1417 "kiro-cli",
1418 "hermes",
1419 "omp",
1420 "cline",
1421 "aider",
1422 "code",
1423 "code-oss",
1424 "codium",
1425 "vscodium",
1426 "zed",
1427 "zed-editor",
1428 "windsurf",
1429 "goland",
1430 "idea",
1431 "pycharm",
1432 "webstorm",
1433 "rustrover",
1434 "sublime_text",
1435 "emacs",
1436 "nvim",
1437 "vim",
1438 "gvim",
1439 "helix",
1440 "lapce",
1441 "kate",
1442 "gedit",
1443 "cursor helper",
1444];
1445
1446#[must_use]
1448pub fn is_editor_name(name: &str) -> bool {
1449 let base = name.rsplit(['/', '\\']).next().unwrap_or(name).trim();
1450 EDITORS.iter().any(|item| base.eq_ignore_ascii_case(item))
1451}
1452
1453#[cfg(target_os = "linux")]
1454fn command_base(pid: u32) -> Option<String> {
1455 let raw = std::fs::read(format!("/proc/{pid}/cmdline")).ok()?;
1456 let first = raw.split(|b| *b == 0).find(|word| !word.is_empty())?;
1457 let text = String::from_utf8_lossy(first);
1458 Some(text.rsplit(['/', '\\']).next().unwrap_or(&text).to_string())
1459}
1460
1461#[cfg(target_os = "macos")]
1462fn command_base(pid: u32) -> Option<String> {
1463 let mut mib = [
1464 libc::CTL_KERN,
1465 libc::KERN_PROCARGS2,
1466 libc::c_int::try_from(pid).ok()?,
1467 ];
1468 let mut size: libc::size_t = 0;
1469 let asked = unsafe {
1471 libc::sysctl(
1472 mib.as_mut_ptr(),
1473 3,
1474 std::ptr::null_mut(),
1475 &mut size,
1476 std::ptr::null_mut(),
1477 0,
1478 )
1479 };
1480 if asked != 0 || size < 4 {
1481 return None;
1482 }
1483 let mut buf = vec![0u8; size];
1484 let read = unsafe {
1486 libc::sysctl(
1487 mib.as_mut_ptr(),
1488 3,
1489 buf.as_mut_ptr().cast(),
1490 &mut size,
1491 std::ptr::null_mut(),
1492 0,
1493 )
1494 };
1495 if read != 0 {
1496 return None;
1497 }
1498 buf.truncate(size);
1499 let argc = usize::try_from(i32::from_ne_bytes(buf.get(..4)?.try_into().ok()?)).ok()?;
1500 if argc == 0 {
1501 return None;
1502 }
1503 let word = buf[4..]
1504 .split(|b| *b == 0)
1505 .skip(1)
1506 .find(|word| !word.is_empty())?;
1507 let text = String::from_utf8_lossy(word);
1508 Some(text.rsplit(['/', '\\']).next().unwrap_or(&text).to_string())
1509}
1510
1511#[cfg(target_os = "linux")]
1515#[must_use]
1516pub fn editor_above() -> bool {
1517 ancestors().into_iter().any(|proc| {
1518 is_editor_name(&proc.comm)
1519 || command_base(proc.pid).is_some_and(|name| is_editor_name(&name))
1520 || exe_runner(proc.pid).is_some_and(is_editor_name)
1521 })
1522}
1523
1524#[cfg(target_os = "macos")]
1525#[must_use]
1526pub fn editor_above() -> bool {
1527 let mut pid = unsafe { libc::getppid() } as u32;
1529 for _ in 0..DEPTH {
1530 if pid <= 1 {
1531 return false;
1532 }
1533 let Some((ppid, comm)) = parent_and_name(pid) else {
1534 return false;
1535 };
1536 if is_editor_name(&comm) || command_base(pid).is_some_and(|name| is_editor_name(&name)) {
1537 return true;
1538 }
1539 if ppid == pid {
1540 return false;
1541 }
1542 pid = ppid;
1543 }
1544 false
1545}
1546
1547#[cfg(not(any(target_os = "linux", target_os = "macos")))]
1548#[must_use]
1549pub fn editor_above() -> bool {
1550 false
1551}
1552
1553fn bare_hook_command(cmd: &str) -> bool {
1559 let mut plain = String::new();
1560 let mut rest = cmd;
1561 while let Some(at) = rest.find("${") {
1562 plain.push_str(&rest[..at]);
1563 let Some(close) = rest[at..].find('}') else {
1564 return false;
1565 };
1566 let name = &rest[at + 2..at + close];
1567 if name.is_empty() || !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
1568 return false;
1569 }
1570 plain.push_str("VAR");
1571 rest = &rest[at + close + 1..];
1572 }
1573 plain.push_str(rest);
1574 let plain = plain.replace('"', "");
1575 if plain.contains(|c: char| "<>|;&$`(){}\\'\n\r*?[]~#!".contains(c)) {
1576 return false;
1577 }
1578 let words: Vec<&str> = plain.split_whitespace().collect();
1579 let base = |w: &str| w.rsplit('/').next().unwrap_or(w).to_string();
1580 let at = match words.first().map(|w| base(w)).as_deref() {
1581 Some("ljos-plugin") if words.get(1) == Some(&"ljos") => 2,
1582 Some("ljos") => 1,
1583 _ => return false,
1584 };
1585 words.get(at) == Some(&"hook")
1586 && words[at + 1..].iter().all(|w| {
1587 w.starts_with("--")
1588 || w.chars()
1589 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
1590 })
1591}
1592
1593fn hook_shell(pid: u32) -> bool {
1598 let Some(exe) = exe(pid) else {
1599 return false;
1600 };
1601 let shell = exe
1602 .file_name()
1603 .and_then(|f| f.to_str())
1604 .is_some_and(|f| SHELLS.contains(&f));
1605 let words = argv(pid).unwrap_or_default();
1606 shell && matches!(words.as_slice(), [_, c, cmd] if c == "-c" && bare_hook_command(cmd))
1607}
1608
1609#[must_use]
1612pub fn runner_above() -> Option<(Proc, &'static str)> {
1613 ancestors()
1614 .into_iter()
1615 .find_map(|p| exe_runner(p.pid).map(|r| (p, r)))
1616}
1617
1618fn tool_call_stamp(env: &[(String, String)]) -> Option<&str> {
1621 env.iter()
1622 .find(|(k, v)| {
1623 matches!(
1624 k.as_str(),
1625 "CLAUDECODE" | "CURSOR_AGENT" | "CODEX_THREAD_ID"
1626 ) || (crate::runner_session_var(k, v) && !k.starts_with("LJOS_"))
1627 })
1628 .map(|(k, _)| k.as_str())
1629}
1630
1631struct ToolMark {
1635 sid: u32,
1636 at: u64,
1637 runner_pid: u32,
1638 runner_start: u64,
1639 shell_pid: u32,
1640 shell_start: u64,
1641 cgroup: String,
1642}
1643
1644struct Here {
1647 sid: u32,
1648 cgroup: String,
1649}
1650
1651fn marks_path() -> std::path::PathBuf {
1652 crate::runtime_dir().join("tool-calls")
1653}
1654
1655fn broken_path() -> std::path::PathBuf {
1656 crate::runtime_dir().join("tool-calls.broken")
1657}
1658
1659fn current_sid() -> Result<u32, String> {
1661 let sid = unsafe { libc::getsid(0) };
1664 if sid < 0 {
1665 return Err(
1666 "the session id cannot be read, so a tool call cannot be told from the runner".into(),
1667 );
1668 }
1669 u32::try_from(sid).map_err(|_| "the session id does not fit".into())
1670}
1671
1672fn here_evidence() -> Result<Here, String> {
1673 let sid = current_sid()?;
1674 #[cfg(target_os = "linux")]
1675 let cgroup = std::fs::read_to_string("/proc/self/cgroup").map_err(|_| {
1676 "the cgroup cannot be read, so a tool call cannot be told from the runner".to_string()
1677 })?;
1678 #[cfg(not(target_os = "linux"))]
1679 let cgroup = format!("sid:{sid}");
1680 if cgroup.is_empty() {
1681 return Err("the cgroup is empty, so a tool call cannot be told from the runner".into());
1682 }
1683 Ok(Here { sid, cgroup })
1684}
1685
1686fn self_start() -> Result<u64, String> {
1687 stat(std::process::id())
1688 .map(|row| row.start)
1689 .ok_or_else(|| "this process's start time cannot be read".into())
1690}
1691
1692pub fn seal_unreadable_tool_call() {
1698 mark_broken();
1699}
1700
1701fn mark_broken() {
1702 let dir = crate::runtime_dir();
1703 let _ = std::fs::create_dir_all(&dir);
1704 let mut options = std::fs::OpenOptions::new();
1705 options.create(true).write(true).truncate(true);
1706 #[cfg(unix)]
1707 {
1708 use std::os::unix::fs::OpenOptionsExt as _;
1709 options.mode(0o600);
1710 }
1711 let _ = options.open(broken_path());
1712}
1713
1714fn tool_marks() -> Result<Vec<ToolMark>, String> {
1715 if broken_path().exists() {
1716 return Err(
1717 "a tool-call record could not be written, so nothing is kept as the person's".into(),
1718 );
1719 }
1720 let text = match std::fs::read_to_string(marks_path()) {
1721 Ok(text) => text,
1722 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
1723 Err(_) => {
1724 return Err(
1725 "the tool-call record cannot be read, so nothing is kept as the person's".into(),
1726 );
1727 }
1728 };
1729 let mut marks = Vec::new();
1730 for line in text.lines() {
1731 let fields: Vec<&str> = line.split('\t').collect();
1732 if fields.len() != 8 || fields[0] != "1" {
1733 return Err(
1734 "the tool-call record is corrupt, so nothing is kept as the person's".into(),
1735 );
1736 }
1737 let number = |index: usize, label: &str| -> Result<u64, String> {
1738 fields[index].parse().map_err(|_| {
1739 format!(
1740 "the tool-call record has a bad {label}, so nothing is kept as the person's"
1741 )
1742 })
1743 };
1744 let cgroup = hex::decode(fields[7])
1745 .ok()
1746 .and_then(|bytes| String::from_utf8(bytes).ok())
1747 .ok_or("the tool-call record has a bad cgroup, so nothing is kept as the person's")?;
1748 let narrow = |value: u64, label: &str| -> Result<u32, String> {
1749 u32::try_from(value).map_err(|_| {
1750 format!(
1751 "the tool-call record has a bad {label}, so nothing is kept as the person's"
1752 )
1753 })
1754 };
1755 marks.push(ToolMark {
1756 sid: narrow(number(1, "session")?, "session")?,
1757 at: number(2, "start")?,
1758 runner_pid: narrow(number(3, "runner")?, "runner")?,
1759 runner_start: number(4, "runner start")?,
1760 shell_pid: narrow(number(5, "shell")?, "shell")?,
1761 shell_start: number(6, "shell start")?,
1762 cgroup,
1763 });
1764 }
1765 Ok(marks)
1766}
1767
1768pub fn note_tool_call(runner: &Proc) -> Result<(), String> {
1778 let here = match here_evidence() {
1781 Ok(here) => here,
1782 Err(why) => {
1783 mark_broken();
1784 return Err(why);
1785 }
1786 };
1787 let at = match self_start() {
1788 Ok(at) => at,
1789 Err(why) => {
1790 mark_broken();
1791 return Err(why);
1792 }
1793 };
1794 let dir = crate::runtime_dir();
1795 let mut builder = std::fs::DirBuilder::new();
1796 builder.recursive(true);
1797 #[cfg(unix)]
1798 {
1799 use std::os::unix::fs::DirBuilderExt as _;
1800 builder.mode(0o700);
1801 }
1802 if builder.create(&dir).is_err() {
1803 mark_broken();
1804 return Err("the tool-call record directory cannot be created".into());
1805 }
1806 let line = format!(
1810 "1\t{}\t{at}\t{}\t{}\t0\t0\t{}\n",
1811 here.sid,
1812 runner.pid,
1813 runner.start,
1814 hex::encode(here.cgroup.as_bytes())
1815 );
1816 if line.len() > 4096 {
1817 mark_broken();
1818 return Err("the tool-call record does not fit one write".into());
1819 }
1820 let mut options = std::fs::OpenOptions::new();
1821 options.create(true).append(true);
1822 #[cfg(unix)]
1823 {
1824 use std::os::unix::fs::OpenOptionsExt as _;
1825 options.mode(0o600);
1826 }
1827 let opened = options.open(marks_path());
1828 let mut file = match opened {
1829 Ok(file) => file,
1830 Err(_) => {
1831 mark_broken();
1832 return Err("the tool-call record cannot be written".into());
1833 }
1834 };
1835 use std::io::Write as _;
1836 if file.write_all(line.as_bytes()).is_err() {
1837 mark_broken();
1838 return Err("the tool-call record cannot be written".into());
1839 }
1840 Ok(())
1841}
1842
1843fn confirm_runner(runner: &Proc) -> Result<(), String> {
1849 let here = here_evidence()?;
1850 let marks = tool_marks()?;
1851 let chain = ancestors();
1852 for mark in &marks {
1853 if mark.runner_pid == runner.pid && mark.runner_start == runner.start {
1854 continue;
1855 }
1856 if mark.shell_pid > 0
1857 && chain
1858 .iter()
1859 .any(|proc| proc.pid == mark.shell_pid && proc.start == mark.shell_start)
1860 {
1861 return Err(format!(
1862 "{} (pid {}) descends from a recorded tool call",
1863 runner.comm, runner.pid
1864 ));
1865 }
1866 let same_place = mark.sid == here.sid || mark.cgroup == here.cgroup;
1867 if same_place && runner.start >= mark.at {
1868 return Err(format!(
1869 "{} (pid {}) shares a recorded tool call's cgroup or session and is not older than that tool call",
1870 runner.comm, runner.pid
1871 ));
1872 }
1873 }
1874 Ok(())
1875}
1876
1877pub fn hook_runner() -> Result<(Proc, &'static str), String> {
1891 let mut chain = ancestors();
1892 let parent = chain.first().ok_or("it has no parent process")?;
1893 if exe_runner(parent.pid).is_none() && chain.len() > 1 && hook_shell(parent.pid) {
1894 chain.remove(0);
1895 }
1896 let parent = &chain[0];
1897 let Some(runner) = exe_runner(parent.pid) else {
1898 return Err(format!(
1899 "its parent, {} (pid {}), is not a runner's own program: a shell, an interpreter or ssh ran it, so a tool call may have",
1900 parent.comm, parent.pid
1901 ));
1902 };
1903 let env = environ(parent.pid).ok_or_else(|| {
1904 format!(
1905 "the environment {} (pid {}) was started with cannot be read, so a tool call's stamp would be hidden",
1906 parent.comm, parent.pid
1907 )
1908 })?;
1909 if let Some(key) = tool_call_stamp(&env) {
1910 return Err(format!(
1911 "{runner} (pid {}) was started with {key} set, inside another runner's tool call",
1912 parent.pid
1913 ));
1914 }
1915 let own = chain[1..].iter().take_while(|p| !ends_the_walk(p));
1918 if let Some((p, other)) = own
1919 .filter_map(|p| exe_runner(p.pid).map(|r| (p, r)))
1920 .find(|(_, r)| *r != "cursor")
1921 {
1922 return Err(format!(
1923 "{runner} (pid {}) runs under {other} (pid {}), inside a tool call",
1924 parent.pid, p.pid
1925 ));
1926 }
1927 confirm_runner(parent)?;
1928 Ok((parent.clone(), runner))
1929}
1930
1931#[must_use]
1934pub fn prompt_origin_label(why: &str) -> String {
1935 let named = |prefix: &str| -> Option<&str> {
1936 why.strip_prefix(prefix)
1937 .and_then(|rest| rest.split(" (pid ").next())
1938 .filter(|name| !name.is_empty())
1939 };
1940 if why.contains("not a runner's own program") {
1941 let name = named("its parent, ").unwrap_or("unknown");
1942 return format!(
1943 "label prompt-origin: not stored as the person's words (parent process is `{name}`, not a known runner)"
1944 );
1945 }
1946 if why.contains("cannot be read") {
1947 let name = named("the environment ").unwrap_or("the parent");
1948 return format!(
1949 "label prompt-origin: not stored as the person's words (the environment of `{name}` cannot be read)"
1950 );
1951 }
1952 if why.contains("was started with") {
1953 return "label prompt-origin: not stored as the person's words (the parent was started inside a tool call)".into();
1954 }
1955 if why.contains("runs under") {
1956 return "label prompt-origin: not stored as the person's words (the parent runs inside another runner)".into();
1957 }
1958 if why.contains("descends from a recorded tool call")
1959 || why.contains("shares a recorded tool call")
1960 {
1961 return "label prompt-origin: not stored as the person's words (the parent is inside a recorded tool call)".into();
1962 }
1963 if why.contains("no parent") {
1964 return "label prompt-origin: not stored as the person's words (there is no parent process)".into();
1965 }
1966 "label prompt-origin: not stored as the person's words (the parent is not a known runner)"
1967 .into()
1968}
1969
1970#[must_use]
1972pub fn prompt_not_person_label(why: &str) -> String {
1973 let reason = match why {
1974 "LJOS_UNATTENDED is set" => "this session is unattended",
1975 "a subagent's brief" => "this is a subagent brief",
1976 "another thread's prompt" => "this prompt is from another thread",
1977 other => other,
1978 };
1979 format!("label prompt-origin: not stored as the person's words ({reason})")
1980}
1981
1982#[cfg(test)]
1983mod tests {
1984 use super::*;
1985
1986 #[cfg(target_os = "linux")]
1988 fn runner_here(
1989 path: &Path,
1990 home: Option<&Path>,
1991 store: &Path,
1992 person: &Person,
1993 ) -> Option<&'static str> {
1994 use std::os::unix::fs::MetadataExt as _;
1995 let meta = std::fs::metadata(path).ok()?;
1996 runner_of_file(path, home, store, person, (meta.dev(), meta.ino()))
1997 }
1998
1999 #[test]
2000 fn an_editor_name_matches_the_file_and_not_a_shell() {
2001 assert!(is_editor_name("Cursor"));
2002 assert!(is_editor_name("/Applications/Cursor Helper"));
2003 assert!(is_editor_name("cursor-agent"));
2004 assert!(!is_editor_name("bash"));
2005 assert!(!is_editor_name("python3"));
2006 }
2007
2008 #[test]
2009 fn a_runner_is_read_from_the_file_it_runs_from() {
2010 let roots = install_roots(Some(Path::new("/home/u")));
2011 for (path, want) in [
2012 ("/home/u/.local/share/claude/versions/2.1.3", Some("claude")),
2013 ("/home/u/.local/bin/claude", Some("claude")),
2014 (
2015 "/usr/lib/node_modules/@anthropic-ai/claude-code/cli.js",
2016 Some("claude"),
2017 ),
2018 (
2019 "/usr/lib/node_modules/@openai/codex/vendor/x86_64-unknown-linux-musl/codex/codex",
2020 Some("codex"),
2021 ),
2022 (
2023 "/home/u/.local/share/cursor-agent/versions/2025.10.2-abc/node",
2024 Some("cursor-agent"),
2025 ),
2026 ("/usr/share/cursor/cursor", Some("cursor")),
2027 ("/usr/local/bin/codex", Some("codex")),
2028 ("/usr/bin/claude", Some("claude")),
2029 ("/bin/grok", Some("grok")),
2030 ("/home/u/.cargo/bin/codex", Some("codex")),
2031 (
2032 "/home/u/.npm-global/lib/node_modules/@anthropic-ai/claude-code/cli.js",
2033 Some("claude"),
2034 ),
2035 (
2036 "/nix/store/0c9yapqd0b8y4mj5xj7mrpmhy3l5ykh8-claude-code-2.0.1/bin/claude",
2037 Some("claude"),
2038 ),
2039 ("/usr/bin/node", None),
2040 ("/usr/bin/bash", None),
2041 ("/usr/bin/python3", None),
2042 ("/usr/bin/ssh", None),
2043 ("/home/u/.cargo/bin/ljos", None),
2044 ("/tmp/claude", None),
2045 ("/tmp/claude/versions/2.1.3", None),
2046 ("/home/u/runners/claude", None),
2047 ("/home/u/plant/grok", None),
2048 ("/home/u/plant/bin/claude", None),
2049 ("/home/u/.local/bin/claude (deleted)", None),
2050 ("/home/u/plant/.local/bin/claude", None),
2051 ("/tmp/.local/bin/claude", None),
2052 ("/tmp/project/node_modules/.bin/claude", None),
2053 ("/home/u/../other/.local/bin/claude", None),
2054 ("/home/uu/.local/bin/claude", None),
2055 ("/home/other/.local/bin/claude", None),
2056 ("/home/u/.local/binx/claude", None),
2057 ("/home/u/.local/bin/../../plant/claude", None),
2058 ("/home/u/.local/share/plant/claude", None),
2059 ("/home/u/.local/share/claude-plant/claude", None),
2060 ("/home/u/src/app/node_modules/.bin/claude", None),
2061 ("/usr/binx/claude", None),
2062 ("/usr/share/plant/claude", None),
2063 ("/usr/lib/node_modules/../../../tmp/claude", None),
2064 ("/opt/plant/claude", None),
2065 ("/var/tmp/.local/bin/claude", None),
2066 ("/dev/shm/node_modules/.bin/claude", None),
2067 ("/nix/store/claude/bin/claude", None),
2068 (
2069 "/nix/store/0c9yapqd0b8y4mj5xj7mrpmhy3l5ykh8/bin/claude",
2070 None,
2071 ),
2072 (
2073 "/nix/store/0c9yapqd0b8y4mj5xj7mrpmhy3l5ykhe-claude-code-2.0.1/bin/claude",
2074 None,
2075 ),
2076 ] {
2077 let got = runner_below(Path::new(path), &roots, Path::new(NIX_STORE))
2078 .map(|(runner, _)| runner);
2079 assert_eq!(got, want, "{path}");
2080 }
2081 }
2082
2083 #[cfg(target_os = "linux")]
2089 #[test]
2090 fn an_install_root_holds_only_what_resolves_inside_it() {
2091 use std::os::unix::fs::PermissionsExt as _;
2092 let base = tempfile::Builder::new()
2093 .prefix(".ljos-roots-")
2094 .tempdir_in(own_home().expect("this user's passwd entry names a home"))
2095 .unwrap();
2096 let home = base.path().join("u");
2097 let person = Person::new(&SystemAccounts);
2098 let store = PathBuf::from(NIX_STORE);
2099 let program = |dir: &Path, name: &str| {
2100 std::fs::create_dir_all(dir).unwrap();
2101 let path = dir.join(name);
2102 std::fs::write(&path, "").unwrap();
2103 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
2104 path
2105 };
2106 let bin = home.join(".local/bin");
2107 let claude = program(&bin, "claude");
2108 assert_eq!(
2109 runner_here(&claude, Some(&home), &store, &person),
2110 Some("claude")
2111 );
2112
2113 let away = tempfile::tempdir().unwrap();
2114 std::os::unix::fs::symlink(program(away.path(), "codex"), bin.join("codex")).unwrap();
2115 assert_eq!(
2116 runner_here(&bin.join("codex"), Some(&home), &store, &person),
2117 None
2118 );
2119
2120 program(&base.path().join("other/.local/bin"), "claude");
2121 let climbed = home.join("../other/.local/bin/claude");
2122 assert_eq!(runner_here(&climbed, Some(&home), &store, &person), None);
2123
2124 std::fs::set_permissions(&claude, std::fs::Permissions::from_mode(0o757)).unwrap();
2125 assert_eq!(runner_here(&claude, Some(&home), &store, &person), None);
2126
2127 let open = bin.join("open");
2128 let grok = program(&open, "grok");
2129 assert_eq!(
2130 runner_here(&grok, Some(&home), &store, &person),
2131 Some("grok")
2132 );
2133 std::fs::set_permissions(&open, std::fs::Permissions::from_mode(0o777)).unwrap();
2134 assert_eq!(runner_here(&grok, Some(&home), &store, &person), None);
2135 }
2136
2137 #[cfg(target_os = "linux")]
2142 #[test]
2143 fn a_root_in_a_directory_others_may_write_is_refused() {
2144 use std::os::unix::fs::PermissionsExt as _;
2145 let base = tempfile::Builder::new()
2146 .prefix(".ljos-above-")
2147 .tempdir_in(own_home().expect("this user's passwd entry names a home"))
2148 .unwrap();
2149 let home = base.path().join("u");
2150 let store = PathBuf::from(NIX_STORE);
2151 let person = Person::new(&SystemAccounts);
2152 let staff = FakeAccounts {
2153 user: Some("u"),
2154 group: Some("staff"),
2155 members: &["u", "v"],
2156 primary: Some(vec![person.uid]),
2157 };
2158 let sharing = Person::new(&staff);
2159 let mut files: Vec<PathBuf> = vec![
2160 home.join(".local/bin/claude"),
2161 home.join(".cargo/bin/codex"),
2162 home.join(".npm-global/lib/node_modules/@anthropic-ai/claude-code/cli.js"),
2163 ];
2164 files.extend(
2165 HOOK_RUNNERS
2166 .iter()
2167 .map(|(name, _)| home.join(".local/share").join(name).join(name)),
2168 );
2169 for file in &files {
2170 std::fs::create_dir_all(file.parent().unwrap()).unwrap();
2171 std::fs::write(file, "").unwrap();
2172 }
2173 let set = |dir: &Path, mode| {
2174 std::fs::set_permissions(dir, std::fs::Permissions::from_mode(mode)).unwrap();
2175 };
2176 for root in install_roots(Some(&home))
2177 .iter()
2178 .filter(|r| r.starts_with(&home))
2179 {
2180 let file = files.iter().find(|f| f.starts_with(root)).unwrap();
2181 let above = root.parent().unwrap();
2182 let runner = |who| runner_here(file, Some(&home), &store, who);
2183 assert!(runner(&person).is_some(), "{}", root.display());
2184 set(above, 0o757);
2185 assert_eq!(runner(&person), None, "{}", root.display());
2186 set(above, 0o775);
2187 assert_eq!(runner(&sharing), None, "{}", root.display());
2188 set(above, 0o755);
2189 }
2190
2191 let uid = person.uid;
2192 let owner = |uid, mode| Owner {
2193 uid,
2194 gid: person.gid,
2195 mode,
2196 };
2197 let walk = |above| [owner(0, 0o755), above, owner(uid, 0o755), owner(uid, 0o755)];
2198 assert!(walk_kept(&walk(owner(uid, 0o755)), 2, false, &person));
2199 assert!(walk_kept(&walk(owner(0, 0o755)), 2, false, &person));
2200 assert!(!walk_kept(&walk(owner(uid + 1, 0o755)), 2, false, &person));
2201 assert!(!walk_kept(&walk(owner(uid, 0o757)), 2, false, &person));
2202 assert!(!walk_kept(&walk(owner(uid, 0o775)), 2, false, &sharing));
2203 }
2204
2205 #[cfg(target_os = "linux")]
2210 #[test]
2211 fn a_parent_swapped_after_the_path_resolved_is_refused() {
2212 use std::os::unix::fs::MetadataExt as _;
2213 let base = tempfile::Builder::new()
2214 .prefix(".ljos-swap-")
2215 .tempdir_in(own_home().expect("this user's passwd entry names a home"))
2216 .unwrap();
2217 let home = std::fs::canonicalize(base.path()).unwrap().join("u");
2218 let person = Person::new(&SystemAccounts);
2219 let store = PathBuf::from(NIX_STORE);
2220 let versions = home.join(".local/share/claude/versions");
2221 let other = home.join("other");
2222 for dir in [&versions, &other] {
2223 std::fs::create_dir_all(dir).unwrap();
2224 std::fs::write(dir.join("2.1.3"), "").unwrap();
2225 }
2226 let id = |path: &Path| {
2227 let meta = std::fs::metadata(path).unwrap();
2228 (meta.dev(), meta.ino())
2229 };
2230 let file = versions.join("2.1.3");
2231 let (running, planted) = (id(&file), id(&other.join("2.1.3")));
2232 let runner = |expect| runner_at(&file, Some(&home), &store, &person, expect);
2233 assert_eq!(runner(running), Some("claude"));
2234
2235 std::fs::rename(&versions, home.join("held")).unwrap();
2236 std::os::unix::fs::symlink(&other, &versions).unwrap();
2237 assert_eq!(runner(running), None);
2238 assert_eq!(runner(planted), None);
2239
2240 std::fs::remove_file(&versions).unwrap();
2241 std::fs::rename(&other, &versions).unwrap();
2242 assert_eq!(runner(running), None);
2243 }
2244
2245 #[cfg(target_os = "linux")]
2249 #[test]
2250 fn an_executable_that_cannot_be_read_is_no_runner() {
2251 use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _};
2252 let base = tempfile::Builder::new()
2253 .prefix(".ljos-exe-")
2254 .tempdir_in(own_home().expect("this user's passwd entry names a home"))
2255 .unwrap();
2256 let home = base.path().join("u");
2257 let bin = home.join(".local/bin");
2258 std::fs::create_dir_all(&bin).unwrap();
2259 let claude = bin.join("claude");
2260 std::fs::write(&claude, "").unwrap();
2261 std::fs::set_permissions(&claude, std::fs::Permissions::from_mode(0o755)).unwrap();
2262 let meta = std::fs::metadata(&claude).unwrap();
2263 let person = Person::new(&SystemAccounts);
2264 let store = PathBuf::from(NIX_STORE);
2265 let runner = |running| runner_from(&claude, running, || None, Some(&home), &store, &person);
2266 assert_eq!(runner(Some((meta.dev(), meta.ino()))), Some("claude"));
2267 assert_eq!(runner(None), None);
2268 }
2269
2270 #[cfg(target_os = "linux")]
2275 #[test]
2276 fn a_script_names_the_runner_only_for_an_installed_runtime() {
2277 use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _};
2278 let base = tempfile::Builder::new()
2279 .prefix(".ljos-runtime-")
2280 .tempdir_in(own_home().expect("this user's passwd entry names a home"))
2281 .unwrap();
2282 let home = base.path().join("u");
2283 let away = tempfile::tempdir().unwrap();
2284 let program = |dir: &Path, name: &str| {
2285 std::fs::create_dir_all(dir).unwrap();
2286 let path = dir.join(name);
2287 std::fs::write(&path, "").unwrap();
2288 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
2289 path
2290 };
2291 let id = |path: &Path| {
2292 let meta = std::fs::metadata(path).unwrap();
2293 (meta.dev(), meta.ino())
2294 };
2295 let cli = program(
2296 &home.join(".npm-global/lib/node_modules/@anthropic-ai/claude-code"),
2297 "cli.js",
2298 );
2299 let bin = home.join(".local/bin");
2300 let node = program(&bin, "node");
2301 let person = Person::new(&SystemAccounts);
2302 let store = PathBuf::from(NIX_STORE);
2303 let runner = |exe: &Path, running| {
2304 runner_from(
2305 exe,
2306 Some(running),
2307 || Some(cli.clone()),
2308 Some(&home),
2309 &store,
2310 &person,
2311 )
2312 };
2313 assert_eq!(runner(&node, id(&node)), Some("claude"));
2314 assert_eq!(runner(&node, id(&cli)), None);
2315 for planted in [
2316 program(&bin, "node-v20"),
2317 program(&bin, "nodejs_modules"),
2318 program(away.path(), "node"),
2319 ] {
2320 assert_eq!(
2321 runner(&planted, id(&planted)),
2322 None,
2323 "{}",
2324 planted.display()
2325 );
2326 }
2327 std::fs::set_permissions(&node, std::fs::Permissions::from_mode(0o757)).unwrap();
2328 assert_eq!(runner(&node, id(&node)), None);
2329 }
2330
2331 #[cfg(target_os = "linux")]
2337 #[test]
2338 fn a_nix_entry_is_a_runner_only_in_a_store_nix_keeps() {
2339 use std::os::unix::fs::PermissionsExt as _;
2340 let base = tempfile::Builder::new()
2341 .prefix(".ljos-store-")
2342 .tempdir_in(own_home().expect("this user's passwd entry names a home"))
2343 .unwrap();
2344 let store = base.path().join("store");
2345 let bin = store.join("0c9yapqd0b8y4mj5xj7mrpmhy3l5ykh8-claude-code-2.0.1/bin");
2346 std::fs::create_dir_all(&bin).unwrap();
2347 let claude = bin.join("claude");
2348 std::fs::write(&claude, "").unwrap();
2349 std::fs::set_permissions(&claude, std::fs::Permissions::from_mode(0o755)).unwrap();
2350 let person = Person::new(&SystemAccounts);
2351 let set = |mode| {
2352 std::fs::set_permissions(&store, std::fs::Permissions::from_mode(mode)).unwrap();
2353 };
2354 let runner = || runner_here(&claude, None, &store, &person);
2355
2356 set(0o755);
2357 assert_eq!(runner(), Some("claude"));
2358 for mode in [0o775, 0o757, 0o777, 0o1775, 0o1777] {
2359 set(mode);
2360 assert_eq!(runner(), None, "{mode:o}");
2361 }
2362 set(0o755);
2363
2364 let uid = person.uid;
2365 let nixbld = person.gid + 1;
2366 let owner = |uid, gid, mode| Owner { uid, gid, mode };
2367 let outsider = Person {
2368 groups: Some(vec![]),
2369 ..Person::new(&SystemAccounts)
2370 };
2371 let builder = Person {
2372 groups: Some(vec![nixbld]),
2373 ..Person::new(&SystemAccounts)
2374 };
2375 let unread = Person {
2376 groups: None,
2377 ..Person::new(&SystemAccounts)
2378 };
2379 let root_entry = owner(0, 0, 0o555);
2380 let own_entry = owner(uid, uid, 0o555);
2381 for (store, entry, who, want) in [
2382 (owner(0, nixbld, 0o1775), root_entry, &outsider, true),
2383 (owner(0, 0, 0o755), root_entry, &outsider, true),
2384 (owner(0, nixbld, 0o1775), own_entry, &outsider, false),
2385 (owner(0, nixbld, 0o775), root_entry, &outsider, false),
2386 (owner(0, nixbld, 0o1775), root_entry, &builder, false),
2387 (owner(0, nixbld, 0o1775), root_entry, &unread, false),
2388 (owner(0, 0, 0o1777), root_entry, &outsider, false),
2389 (owner(uid, uid, 0o755), own_entry, &outsider, true),
2390 (owner(uid, uid, 0o755), root_entry, &outsider, true),
2391 (owner(uid, uid, 0o775), own_entry, &outsider, false),
2392 (owner(uid, uid, 0o1777), own_entry, &outsider, false),
2393 (
2394 owner(uid, uid, 0o755),
2395 owner(uid + 1, 0, 0o555),
2396 &outsider,
2397 false,
2398 ),
2399 (owner(uid + 1, 0, 0o755), own_entry, &outsider, false),
2400 ] {
2401 assert_eq!(
2402 store_keeps(store, entry, who),
2403 want,
2404 "{}:{} {:o}",
2405 store.uid,
2406 store.gid,
2407 store.mode
2408 );
2409 }
2410 }
2411
2412 #[cfg(target_os = "linux")]
2415 struct FakeAccounts {
2416 user: Option<&'static str>,
2417 group: Option<&'static str>,
2418 members: &'static [&'static str],
2419 primary: Option<Vec<u32>>,
2420 }
2421
2422 #[cfg(target_os = "linux")]
2423 impl Accounts for FakeAccounts {
2424 fn user_name(&self, _uid: u32) -> Option<std::ffi::OsString> {
2425 self.user.map(Into::into)
2426 }
2427
2428 fn group(&self, _gid: u32) -> Option<GroupEntry> {
2429 self.group.map(|name| GroupEntry {
2430 name: name.into(),
2431 members: self.members.iter().map(Into::into).collect(),
2432 })
2433 }
2434
2435 fn primary_members(&self, _gid: u32) -> Option<Vec<u32>> {
2436 self.primary.clone()
2437 }
2438 }
2439
2440 #[cfg(target_os = "linux")]
2445 #[test]
2446 fn a_group_write_counts_only_from_a_group_the_person_has_alone() {
2447 use std::os::unix::fs::PermissionsExt as _;
2448 let uid = unsafe { libc::getuid() };
2451 let alone = |members, primary| FakeAccounts {
2452 user: Some("u"),
2453 group: Some("u"),
2454 members,
2455 primary: Some(primary),
2456 };
2457 let shared = [
2458 FakeAccounts {
2459 group: Some("staff"),
2460 ..alone(&[], vec![uid])
2461 },
2462 FakeAccounts {
2463 group: None,
2464 ..alone(&[], vec![uid])
2465 },
2466 alone(&["u", "v"], vec![uid]),
2467 alone(&["v"], vec![uid]),
2468 alone(&[], vec![uid, uid + 1]),
2469 FakeAccounts {
2470 user: None,
2471 ..alone(&[], vec![uid])
2472 },
2473 FakeAccounts {
2474 primary: None,
2475 ..alone(&[], vec![uid])
2476 },
2477 ];
2478
2479 let base = tempfile::Builder::new()
2480 .prefix(".ljos-group-")
2481 .tempdir_in(own_home().expect("this user's passwd entry names a home"))
2482 .unwrap();
2483 let home = base.path().join("u");
2484 let bin = home.join(".local/bin");
2485 std::fs::create_dir_all(&bin).unwrap();
2486 let claude = bin.join("claude");
2487 std::fs::write(&claude, "").unwrap();
2488 std::fs::set_permissions(&claude, std::fs::Permissions::from_mode(0o775)).unwrap();
2489 let store = PathBuf::from(NIX_STORE);
2490
2491 for accounts in [alone(&[], vec![uid]), alone(&["u"], vec![uid, uid])] {
2492 assert!(group_is_private(&accounts, uid, 0));
2493 let person = Person::new(&accounts);
2494 assert_eq!(
2495 runner_here(&claude, Some(&home), &store, &person),
2496 Some("claude")
2497 );
2498 }
2499 for accounts in &shared {
2500 assert!(!group_is_private(accounts, uid, 0));
2501 let person = Person::new(accounts);
2502 assert_eq!(runner_here(&claude, Some(&home), &store, &person), None);
2503 }
2504 }
2505
2506 #[test]
2510 fn a_primary_group_is_listed_only_from_a_passwd_file_read_whole() {
2511 let etc = tempfile::tempdir().unwrap();
2512 let write = |name: &str, text: &str| std::fs::write(etc.path().join(name), text).unwrap();
2513 let passwd = "root:x:0:0::/root:/bin/sh\nu:x:1000:1000::/home/u:/bin/sh\n";
2514 write("passwd", passwd);
2515 assert_eq!(primary_in_files(etc.path(), 1000), None, "no nsswitch.conf");
2516 for conf in [
2517 "passwd: files systemd\n",
2518 "passwd: sss files\n",
2519 "passwd: compat\n",
2520 "passwd: files [NOTFOUND=return] ldap\n",
2521 "passwd: files\npasswd: files ldap\n",
2522 "group: files\n",
2523 ] {
2524 write("nsswitch.conf", conf);
2525 assert_eq!(primary_in_files(etc.path(), 1000), None, "{conf}");
2526 }
2527 write(
2528 "nsswitch.conf",
2529 "# local\npasswd: files # only\ngroup: files\n",
2530 );
2531 assert_eq!(primary_in_files(etc.path(), 1000), Some(vec![1000]));
2532 assert_eq!(primary_in_files(etc.path(), 7), Some(vec![]));
2533 for extra in [
2534 "+@admins\n",
2535 "-v\n",
2536 "v:x:1001\n",
2537 "v:x:one:1000::/home/v:/bin/sh\n",
2538 ] {
2539 write("passwd", &format!("{passwd}{extra}"));
2540 assert_eq!(primary_in_files(etc.path(), 1000), None, "{extra}");
2541 }
2542 write(
2543 "passwd",
2544 &format!("{passwd}\n# kept\nv:x:1001:1000::/home/v:/bin/sh\n"),
2545 );
2546 assert_eq!(primary_in_files(etc.path(), 1000), Some(vec![1000, 1001]));
2547 std::fs::remove_file(etc.path().join("passwd")).unwrap();
2548 assert_eq!(primary_in_files(etc.path(), 1000), None);
2549 }
2550
2551 #[test]
2555 fn a_group_is_read_only_from_one_line_of_a_group_file_read_whole() {
2556 let etc = tempfile::tempdir().unwrap();
2557 let write = |name: &str, text: &str| std::fs::write(etc.path().join(name), text).unwrap();
2558 let group = "root:x:0:\nu:x:1000:\nstaff:x:50:u,v\n";
2559 write("group", group);
2560 let read = |gid| {
2561 group_in_files(etc.path(), gid).map(|g| {
2562 let members: Vec<String> = g
2563 .members
2564 .iter()
2565 .map(|m| m.to_string_lossy().into_owned())
2566 .collect();
2567 (g.name.to_string_lossy().into_owned(), members)
2568 })
2569 };
2570 assert_eq!(read(1000), None, "no nsswitch.conf");
2571 for conf in ["group: files systemd\n", "group: sss\n", "passwd: files\n"] {
2572 write("nsswitch.conf", conf);
2573 assert_eq!(read(1000), None, "{conf}");
2574 }
2575 write("nsswitch.conf", "passwd: files\ngroup: files\n");
2576 assert_eq!(read(1000), Some(("u".into(), vec![])));
2577 assert_eq!(
2578 read(50),
2579 Some(("staff".into(), vec!["u".into(), "v".into()]))
2580 );
2581 assert_eq!(read(7), None);
2582 for extra in ["shadow:x:1000:v\n", "+:::\n", "w:x:1000\n", "w:x:ten:\n"] {
2583 write("group", &format!("{group}{extra}"));
2584 assert_eq!(read(1000), None, "{extra}");
2585 }
2586 }
2587
2588 #[test]
2593 fn a_young_runner_in_a_recorded_tool_call_is_refused() {
2594 let _guard = crate::test_env_lock();
2595 let opener = Proc {
2596 pid: 1,
2597 start: 1,
2598 comm: "claude".into(),
2599 };
2600 let young = Proc {
2601 pid: 999_999,
2602 start: u64::MAX,
2603 comm: "claude".into(),
2604 };
2605 let old = Proc {
2606 pid: 42,
2607 start: 0,
2608 comm: "claude".into(),
2609 };
2610 assert!(confirm_runner(&young).is_ok(), "no record yet");
2611 note_tool_call(&opener).unwrap();
2612 let why = confirm_runner(&young).unwrap_err();
2613 assert!(why.contains("tool call"), "{why}");
2614 assert!(confirm_runner(&opener).is_ok(), "the opener recorded it");
2615 assert!(confirm_runner(&old).is_ok(), "older than the record");
2616 std::fs::write(marks_path(), "nope\n").unwrap();
2617 let why = confirm_runner(&old).unwrap_err();
2618 assert!(why.contains("corrupt"), "{why}");
2619 }
2620
2621 #[test]
2622 fn a_tool_calls_variables_mark_a_runner_started_inside_one() {
2623 let env = |k: &str, v: &str| vec![(k.to_string(), v.to_string())];
2624 assert_eq!(tool_call_stamp(&env("CLAUDECODE", "1")), Some("CLAUDECODE"));
2625 assert_eq!(
2626 tool_call_stamp(&env("CURSOR_AGENT", "1")),
2627 Some("CURSOR_AGENT")
2628 );
2629 assert!(tool_call_stamp(&env("CURSOR_CONVERSATION_ID", "0199a1b2-c3d4")).is_some());
2630 assert_eq!(tool_call_stamp(&env("LJOS_SESSION_ID", "mine-0001")), None);
2631 assert_eq!(tool_call_stamp(&env("HOME", "/home/u")), None);
2632 }
2633
2634 #[test]
2635 fn only_a_bare_hook_command_is_the_runners_hook_shell() {
2636 for cmd in [
2637 "ljos hook",
2638 "/home/u/.cargo/bin/ljos hook --runner codex",
2639 "ljos hook --event SessionStart",
2640 "\"${CLAUDE_PLUGIN_ROOT}/bin/ljos-plugin\" ljos hook",
2641 "\"${CURSOR_PLUGIN_ROOT}/bin/ljos-plugin\" ljos hook",
2642 ] {
2643 assert!(bare_hook_command(cmd), "{cmd}");
2644 }
2645 for cmd in [
2646 "ljos hook < plant.json",
2647 "cat plant.json | ljos hook",
2648 "ljos hook <<'EOF'\n{}\nEOF",
2649 "ljos status; ljos hook",
2650 "ljos hook $(cat p)",
2651 "python3 plant.py",
2652 "ljos remember a b c d",
2653 "ljos-plugin hook",
2654 "bash -c 'ljos hook'",
2655 ] {
2656 assert!(!bare_hook_command(cmd), "{cmd}");
2657 }
2658 }
2659
2660 #[test]
2663 fn a_hook_this_test_runs_has_no_runner() {
2664 let why = hook_runner().unwrap_err();
2665 assert!(why.contains("not a runner's own program"), "{why}");
2666 }
2667
2668 #[test]
2669 fn a_prompt_origin_label_names_the_parent_and_says_nothing_is_wrong() {
2670 let why = "its parent, timeout (pid 9), is not a runner's own program: a shell, an interpreter or ssh ran it, so a tool call may have";
2671 let line = prompt_origin_label(why);
2672 assert_eq!(
2673 line,
2674 "label prompt-origin: not stored as the person's words (parent process is `timeout`, not a known runner)"
2675 );
2676 assert!(!line.contains("runner's own program"), "{line}");
2677 let unread = prompt_origin_label(
2678 "the environment timeout (pid 9) was started with cannot be read, so a tool call's stamp would be hidden",
2679 );
2680 assert!(unread.starts_with("label prompt-origin:"), "{unread}");
2681 assert!(unread.contains("cannot be read"), "{unread}");
2682 let brief = prompt_not_person_label("a subagent's brief");
2683 assert_eq!(
2684 brief,
2685 "label prompt-origin: not stored as the person's words (this is a subagent brief)"
2686 );
2687 }
2688
2689 fn named(pid: u32, comm: &str) -> Proc {
2690 Proc {
2691 pid,
2692 start: u64::from(pid) * 10,
2693 comm: comm.into(),
2694 }
2695 }
2696
2697 #[test]
2698 fn this_process_has_live_ancestors_and_a_dead_pid_does_not_match() {
2699 let chain = ancestors();
2700 assert!(!chain.is_empty(), "the test runner is above this process");
2701 assert!(chain.iter().all(alive));
2702 let parent = &chain[0];
2703 assert!(token_alive(&token(parent)));
2704 let reused = Proc {
2705 start: parent.start + 1,
2706 ..parent.clone()
2707 };
2708 assert!(!alive(&reused));
2709 assert!(!token_alive(&format!(
2710 "other-boot:{}:{}",
2711 parent.pid, parent.start
2712 )));
2713 }
2714
2715 #[test]
2716 fn a_reaped_child_is_not_alive() {
2717 let mut child = std::process::Command::new("sleep")
2718 .arg("30")
2719 .spawn()
2720 .unwrap();
2721 let pid = child.id();
2722 let p = of(pid).unwrap();
2723 assert!(alive(&p));
2724 assert_eq!(above(pid).first().map(|q| q.pid), Some(std::process::id()));
2725 child.kill().unwrap();
2726 child.wait().unwrap();
2727 assert!(!alive(&p));
2728 }
2729
2730 #[test]
2731 fn a_child_is_read_with_the_environment_it_was_started_with() {
2732 let mut child = std::process::Command::new("sleep")
2733 .arg("30")
2734 .env("LJOS_PROC_TREE_PROBE", "started-with")
2735 .spawn()
2736 .unwrap();
2737 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
2739 let env = loop {
2740 let env = environ(child.id()).unwrap_or_default();
2741 if env.iter().any(|(k, _)| k == "LJOS_PROC_TREE_PROBE")
2742 || std::time::Instant::now() > deadline
2743 {
2744 break env;
2745 }
2746 std::thread::sleep(std::time::Duration::from_millis(10));
2747 };
2748 child.kill().unwrap();
2749 child.wait().unwrap();
2750 assert!(
2751 env.iter()
2752 .any(|(k, v)| k == "LJOS_PROC_TREE_PROBE" && v == "started-with"),
2753 "{env:?}"
2754 );
2755 }
2756
2757 #[test]
2758 fn the_calling_shell_is_the_outermost_of_a_nested_run() {
2759 let harness = named(10, "claude");
2760 let chain = [
2761 named(3, "timeout"),
2762 named(4, "sh"),
2763 named(5, "bash"),
2764 harness.clone(),
2765 ];
2766 assert_eq!(calling_shell(&chain), Some(&chain[2]));
2767 let chain = [
2768 named(4, "sh"),
2769 named(6, "env"),
2770 named(5, "bash"),
2771 named(7, "node"),
2772 named(8, "bash"),
2773 ];
2774 assert_eq!(calling_shell(&chain), Some(&chain[2]));
2775 assert_eq!(calling_shell(&[harness.clone(), named(5, "bash")]), None);
2776 assert_eq!(calling_shell(&[named(3, "env"), harness.clone()]), None);
2777 assert_eq!(calling_shell(&[]), None);
2778 let launched = [named(3, "python3"), named(4, "sh"), named(5, "bash")];
2779 assert_eq!(calling_shell(&launched), None);
2780 assert_eq!(cast_shell(&launched, None), Some(&launched[2]));
2781 assert_eq!(cast_shell(&[harness, named(5, "bash")], None), None);
2782 assert_eq!(
2783 cast_shell(&[named(9, "cargo"), named(5, "bash")], None),
2784 None
2785 );
2786 assert_eq!(cast_shell(&[], None), None);
2787 }
2788
2789 #[test]
2790 fn the_runner_is_above_the_shells_and_wrappers_it_started() {
2791 let chain = [
2792 named(4, "sh"),
2793 named(3, "timeout"),
2794 named(5, "bash"),
2795 named(10, "claude"),
2796 named(2, "tmux"),
2797 ];
2798 assert_eq!(runner(&chain).map(|p| p.pid), Some(10));
2799 assert_eq!(runner(&chain[3..]).map(|p| p.pid), Some(10));
2800 let shells = [named(4, "sh"), named(5, "bash")];
2801 assert_eq!(runner(&shells).map(|p| p.pid), Some(5));
2802 assert_eq!(runner(&[]), None);
2803 }
2804
2805 #[test]
2806 fn a_session_above_the_hook_is_never_its_runner() {
2807 for session in ["tmux", "tmux: server", "gnome-terminal-", "systemd", "sshd"] {
2808 let chain = [named(4, "sh"), named(5, "bash"), named(2, session)];
2809 assert_eq!(runner(&chain).map(|p| p.pid), Some(5), "{session}");
2810 let chain = [named(4, "sh"), named(2, session), named(10, "claude")];
2811 assert_eq!(runner(&chain).map(|p| p.pid), Some(4), "{session}");
2812 assert_eq!(runner(&[named(2, session), named(10, "claude")]), None);
2813 }
2814 let chain = [named(4, "sh"), named(7, "git"), named(2, "kitty")];
2815 assert_eq!(runner(&chain).map(|p| p.pid), Some(7));
2816 }
2817
2818 fn fake(n: u32, comm: &str) -> Proc {
2821 named((1 << 22) + n, comm)
2822 }
2823
2824 #[test]
2825 fn a_harness_outranks_a_runtime_that_only_started_the_hook() {
2826 let chain = [
2827 fake(1, "sh"),
2828 fake(2, "uv"),
2829 fake(3, "uv"),
2830 fake(4, "claude"),
2831 ];
2832 assert_eq!(runner(&chain), Some(&chain[3]));
2833 let chain = [fake(1, "sh"), fake(2, "node"), fake(3, "claude")];
2834 assert_eq!(runner(&chain), Some(&chain[2]));
2835 let chain = [
2836 fake(1, "sh"),
2837 fake(2, "npx"),
2838 fake(3, "bash"),
2839 fake(4, "cursor-agent"),
2840 fake(5, "tmux"),
2841 ];
2842 assert_eq!(runner(&chain), Some(&chain[3]));
2843 let chain = [
2844 fake(1, "sh"),
2845 fake(2, "uv"),
2846 fake(3, "codex"),
2847 fake(4, "agy"),
2848 ];
2849 assert_eq!(runner(&chain), Some(&chain[2]), "the nearest harness");
2850 }
2851
2852 #[test]
2853 fn a_node_harness_with_no_known_harness_above_is_the_runner() {
2854 let chain = [
2855 fake(1, "sh"),
2856 fake(2, "node"),
2857 fake(3, "bash"),
2858 fake(4, "tmux"),
2859 ];
2860 assert_eq!(runner(&chain), Some(&chain[1]));
2861 assert_eq!(runner(&chain[..2]), Some(&chain[1]));
2862 let chain = [
2864 fake(1, "sh"),
2865 fake(2, "node"),
2866 fake(3, "tmux"),
2867 fake(4, "claude"),
2868 ];
2869 assert_eq!(runner(&chain), Some(&chain[1]));
2870 }
2871
2872 #[test]
2873 fn pid_1_is_the_runner_only_as_a_harness() {
2874 let chain = [fake(1, "sh"), fake(2, "uv"), named(1, "claude")];
2875 assert_eq!(runner(&chain), Some(&chain[2]), "a container's harness");
2876 let chain = [fake(1, "sh"), fake(2, "bash"), named(1, "tini")];
2877 assert_eq!(runner(&chain), Some(&chain[1]), "an init is no runner");
2878 let chain = [fake(1, "sh"), fake(2, "node"), named(1, "dumb-init")];
2879 assert_eq!(runner(&chain), Some(&chain[1]));
2880 assert_eq!(runner(&[named(1, "tini")]), None);
2881 }
2882
2883 #[test]
2884 fn the_walk_reads_up_to_a_process_with_no_parent() {
2885 let chain = ancestors();
2886 let top = chain.last().expect("the test runner is above this process");
2887 let top = stat(top.pid).expect("the top process was just read");
2888 let hidden_parent = top.ppid != 0 && stat(top.ppid).is_none();
2892 assert!(
2893 chain.len() == DEPTH || top.ppid == 0 || hidden_parent,
2894 "{chain:?}"
2895 );
2896 }
2897}