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
29struct Stat {
30 comm: String,
31 dead: bool,
32 ppid: u32,
33 start: u64,
34}
35
36#[cfg(target_os = "linux")]
37fn stat(pid: u32) -> Option<Stat> {
38 let raw = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
39 let open = raw.find('(')?;
40 let close = raw.rfind(')')?;
41 let comm = raw.get(open + 1..close)?.to_string();
42 let rest: Vec<&str> = raw.get(close + 1..)?.split_whitespace().collect();
44 Some(Stat {
45 comm,
46 dead: matches!(*rest.first()?, "Z" | "X" | "x"),
47 ppid: rest.get(1)?.parse().ok()?,
48 start: rest.get(19)?.parse().ok()?,
49 })
50}
51
52#[cfg(target_os = "macos")]
53fn stat(pid: u32) -> Option<Stat> {
54 let mut info: libc::proc_bsdinfo = unsafe { std::mem::zeroed() };
55 let size = libc::c_int::try_from(std::mem::size_of::<libc::proc_bsdinfo>()).ok()?;
56 let n = unsafe {
58 libc::proc_pidinfo(
59 libc::c_int::try_from(pid).ok()?,
60 libc::PROC_PIDTBSDINFO,
61 0,
62 std::ptr::addr_of_mut!(info).cast(),
63 size,
64 )
65 };
66 if n != size {
67 return None;
68 }
69 let comm: Vec<u8> = info
70 .pbi_comm
71 .iter()
72 .take_while(|c| **c != 0)
73 .map(|c| c.to_ne_bytes()[0])
74 .collect();
75 let start = info
76 .pbi_start_tvsec
77 .saturating_mul(1_000_000)
78 .saturating_add(info.pbi_start_tvusec);
79 Some(Stat {
80 comm: String::from_utf8_lossy(&comm).into_owned(),
81 dead: info.pbi_status == libc::SZOMB,
82 ppid: info.pbi_ppid,
83 start,
84 })
85}
86
87#[cfg(not(any(target_os = "linux", target_os = "macos")))]
88fn stat(_pid: u32) -> Option<Stat> {
89 None
90}
91
92#[must_use]
94pub fn of(pid: u32) -> Option<Proc> {
95 stat(pid).map(|s| Proc {
96 pid,
97 start: s.start,
98 comm: s.comm,
99 })
100}
101
102#[must_use]
105pub fn above(pid: u32) -> Vec<Proc> {
106 let mut out = Vec::new();
107 let mut next = stat(pid).map(|s| s.ppid);
108 while let Some(p) = next.filter(|&p| p > 0 && out.len() < DEPTH) {
109 let Some(s) = stat(p) else {
110 break;
111 };
112 next = Some(s.ppid);
113 out.push(Proc {
114 pid: p,
115 start: s.start,
116 comm: s.comm,
117 });
118 }
119 out
120}
121
122#[must_use]
124pub fn ancestors() -> Vec<Proc> {
125 above(std::process::id())
126}
127
128fn parent_and_name(pid: u32) -> Option<(u32, String)> {
130 stat(pid).map(|s| (s.ppid, s.comm))
131}
132
133fn split_environ(raw: &[u8]) -> Vec<(String, String)> {
134 raw.split(|b| *b == 0)
135 .filter_map(|kv| {
136 let kv = String::from_utf8_lossy(kv);
137 let (k, v) = kv.split_once('=')?;
138 Some((k.to_string(), v.to_string()))
139 })
140 .collect()
141}
142
143#[cfg(target_os = "linux")]
146#[must_use]
147pub fn environ(pid: u32) -> Option<Vec<(String, String)>> {
148 std::fs::read(format!("/proc/{pid}/environ"))
149 .ok()
150 .map(|raw| split_environ(&raw))
151}
152
153#[cfg(target_os = "macos")]
154fn procargs2_buf(pid: u32) -> Option<Vec<u8>> {
155 let mut mib = [
156 libc::CTL_KERN,
157 libc::KERN_PROCARGS2,
158 libc::c_int::try_from(pid).ok()?,
159 ];
160 let mut size: libc::size_t = 0;
161 let asked = unsafe {
163 libc::sysctl(
164 mib.as_mut_ptr(),
165 3,
166 std::ptr::null_mut(),
167 &mut size,
168 std::ptr::null_mut(),
169 0,
170 )
171 };
172 if asked != 0 || size < 4 {
173 return None;
174 }
175 let mut buf = vec![0u8; size];
176 let read = unsafe {
178 libc::sysctl(
179 mib.as_mut_ptr(),
180 3,
181 buf.as_mut_ptr().cast(),
182 &mut size,
183 std::ptr::null_mut(),
184 0,
185 )
186 };
187 if read != 0 {
188 return None;
189 }
190 buf.truncate(size);
191 Some(buf)
192}
193
194#[cfg(target_os = "macos")]
195#[must_use]
196pub fn environ(pid: u32) -> Option<Vec<(String, String)>> {
197 let buf = procargs2_buf(pid)?;
198 let argc = usize::try_from(i32::from_ne_bytes(buf.get(..4)?.try_into().ok()?)).ok()?;
201 let mut words = buf[4..]
202 .split(|b| *b == 0)
203 .skip(1)
204 .skip_while(|w| w.is_empty());
205 for _ in 0..argc {
206 words.next()?;
207 }
208 let env: Vec<u8> = words
209 .take_while(|w| !w.is_empty())
210 .flat_map(|w| w.iter().copied().chain([0]))
211 .collect();
212 Some(split_environ(&env))
213}
214
215#[cfg(not(any(target_os = "linux", target_os = "macos")))]
216#[must_use]
217pub fn environ(_pid: u32) -> Option<Vec<(String, String)>> {
218 None
219}
220
221#[cfg(target_os = "linux")]
223#[must_use]
224pub fn argv(pid: u32) -> Option<Vec<String>> {
225 let raw = std::fs::read(format!("/proc/{pid}/cmdline")).ok()?;
226 let words: Vec<String> = raw
227 .split(|b| *b == 0)
228 .filter(|w| !w.is_empty())
229 .map(|w| String::from_utf8_lossy(w).into_owned())
230 .collect();
231 (!words.is_empty()).then_some(words)
232}
233
234#[cfg(target_os = "macos")]
235#[must_use]
236pub fn argv(pid: u32) -> Option<Vec<String>> {
237 let buf = procargs2_buf(pid)?;
238 let argc = usize::try_from(i32::from_ne_bytes(buf.get(..4)?.try_into().ok()?)).ok()?;
239 if argc == 0 {
240 return None;
241 }
242 let words: Vec<String> = buf[4..]
243 .split(|b| *b == 0)
244 .skip(1)
245 .skip_while(|w| w.is_empty())
246 .take(argc)
247 .filter(|w| !w.is_empty())
248 .map(|w| String::from_utf8_lossy(w).into_owned())
249 .collect();
250 (!words.is_empty()).then_some(words)
251}
252
253#[cfg(not(any(target_os = "linux", target_os = "macos")))]
254#[must_use]
255pub fn argv(_pid: u32) -> Option<Vec<String>> {
256 None
257}
258
259#[cfg(target_os = "linux")]
261#[must_use]
262pub fn exe(pid: u32) -> Option<PathBuf> {
263 std::fs::read_link(format!("/proc/{pid}/exe")).ok()
264}
265
266#[cfg(target_os = "macos")]
267#[must_use]
268pub fn exe(pid: u32) -> Option<PathBuf> {
269 use std::os::unix::ffi::OsStrExt as _;
270 let mut buf = [0u8; 4096];
271 let len = unsafe {
273 libc::proc_pidpath(
274 libc::c_int::try_from(pid).ok()?,
275 buf.as_mut_ptr().cast(),
276 u32::try_from(buf.len()).ok()?,
277 )
278 };
279 let len = usize::try_from(len)
280 .ok()
281 .filter(|&n| n > 0 && n <= buf.len())?;
282 Some(PathBuf::from(std::ffi::OsStr::from_bytes(&buf[..len])))
283}
284
285#[cfg(not(any(target_os = "linux", target_os = "macos")))]
286#[must_use]
287pub fn exe(_pid: u32) -> Option<PathBuf> {
288 None
289}
290
291#[cfg(target_os = "linux")]
293#[must_use]
294pub fn cwd(pid: u32) -> Option<PathBuf> {
295 std::fs::read_link(format!("/proc/{pid}/cwd")).ok()
296}
297
298#[cfg(target_os = "macos")]
299#[must_use]
300pub fn cwd(pid: u32) -> Option<PathBuf> {
301 use std::os::unix::ffi::OsStrExt as _;
302 let mut info: libc::proc_vnodepathinfo = unsafe { std::mem::zeroed() };
303 let size = libc::c_int::try_from(std::mem::size_of::<libc::proc_vnodepathinfo>()).ok()?;
304 let n = unsafe {
306 libc::proc_pidinfo(
307 libc::c_int::try_from(pid).ok()?,
308 libc::PROC_PIDVNODEPATHINFO,
309 0,
310 std::ptr::addr_of_mut!(info).cast(),
311 size,
312 )
313 };
314 if n != size {
315 return None;
316 }
317 let bytes: Vec<u8> = info
318 .pvi_cdir
319 .vip_path
320 .iter()
321 .flatten()
322 .take_while(|c| **c != 0)
323 .map(|c| c.to_ne_bytes()[0])
324 .collect();
325 (!bytes.is_empty()).then(|| PathBuf::from(std::ffi::OsStr::from_bytes(&bytes)))
326}
327
328#[cfg(not(any(target_os = "linux", target_os = "macos")))]
329#[must_use]
330pub fn cwd(_pid: u32) -> Option<PathBuf> {
331 None
332}
333
334#[must_use]
339pub fn started_by_runner() -> Option<u32> {
340 let mut child = std::process::id();
341 for _ in 0..DEPTH {
342 let (ppid, _) = parent_and_name(child)?;
343 if ppid == 0 {
344 return None;
345 }
346 let (_, comm) = parent_and_name(ppid)?;
347 if crate::is_harness_process(ppid, &comm) {
348 return Some(child);
349 }
350 if crate::is_session(&comm) || ppid == 1 {
351 return None;
352 }
353 child = ppid;
354 }
355 None
356}
357
358#[must_use]
361pub fn alive(p: &Proc) -> bool {
362 stat(p.pid).is_some_and(|s| s.start == p.start && !s.dead)
363}
364
365#[must_use]
368pub fn boot() -> &'static str {
369 static BOOT: std::sync::OnceLock<String> = std::sync::OnceLock::new();
370 BOOT.get_or_init(|| {
371 raw_boot_id()
372 .map(|s| {
373 s.trim()
374 .chars()
375 .filter(char::is_ascii_hexdigit)
376 .take(12)
377 .collect()
378 })
379 .filter(|s: &String| !s.is_empty())
380 .unwrap_or_else(|| "-".into())
381 })
382}
383
384#[cfg(target_os = "linux")]
385fn raw_boot_id() -> Option<String> {
386 std::fs::read_to_string("/proc/sys/kernel/random/boot_id").ok()
387}
388
389#[cfg(target_os = "macos")]
390fn raw_boot_id() -> Option<String> {
391 let mut buf = [0u8; 64];
392 let mut size = buf.len();
393 let rc = unsafe {
395 libc::sysctlbyname(
396 c"kern.bootsessionuuid".as_ptr(),
397 buf.as_mut_ptr().cast(),
398 &mut size,
399 std::ptr::null_mut(),
400 0,
401 )
402 };
403 if rc != 0 || size == 0 {
404 return None;
405 }
406 let end = buf[..size].iter().position(|&b| b == 0).unwrap_or(size);
407 String::from_utf8(buf[..end].to_vec()).ok()
408}
409
410#[cfg(not(any(target_os = "linux", target_os = "macos")))]
411fn raw_boot_id() -> Option<String> {
412 None
413}
414
415#[must_use]
417pub fn token(p: &Proc) -> String {
418 format!("{}:{}:{}", boot(), p.pid, p.start)
419}
420
421#[must_use]
423pub fn token_alive(token: &str) -> bool {
424 let mut parts = token.splitn(3, ':');
425 let (Some(at), Some(pid), Some(start)) = (parts.next(), parts.next(), parts.next()) else {
426 return false;
427 };
428 let (Ok(pid), Ok(start)) = (pid.parse(), start.parse()) else {
429 return false;
430 };
431 at == boot()
432 && alive(&Proc {
433 pid,
434 start,
435 comm: String::new(),
436 })
437}
438
439const SHELLS: &[&str] = &[
440 "sh", "bash", "dash", "zsh", "ksh", "mksh", "ash", "yash", "fish", "tcsh", "csh", "busybox",
441];
442
443const WRAPPERS: &[&str] = &[
446 "env", "nice", "nohup", "timeout", "stdbuf", "setsid", "ionice", "chrt", "taskset", "time",
447 "flock", "sudo", "doas", "xargs",
448];
449
450fn is_shell(p: &Proc) -> bool {
451 SHELLS.contains(&p.comm.as_str())
452}
453
454fn is_wrapper(p: &Proc) -> bool {
455 WRAPPERS.contains(&p.comm.as_str())
456}
457
458fn is_harness(p: &Proc) -> bool {
459 crate::is_harness_process(p.pid, &p.comm)
460}
461
462#[must_use]
467pub fn calling_shell(chain: &[Proc]) -> Option<&Proc> {
468 let mut rest = chain.iter().skip_while(|p| is_wrapper(p));
469 let mut top = rest.next().filter(|p| is_shell(p))?;
470 for p in rest {
471 if is_shell(p) {
472 top = p;
473 } else if !is_wrapper(p) {
474 break;
475 }
476 }
477 Some(top)
478}
479
480fn exe_path_key(path: &Path) -> std::ffi::OsString {
481 let text = path.as_os_str().to_string_lossy();
482 match text.strip_suffix(" (deleted)") {
483 Some(plain) => std::ffi::OsString::from(plain),
484 None => path.as_os_str().to_os_string(),
485 }
486}
487
488fn same_resolved_exe(pid: u32, mine: &Path) -> bool {
489 exe(pid).is_some_and(|theirs| exe_path_key(&theirs) == exe_path_key(mine))
490}
491
492#[must_use]
495pub fn is_ljos_exe(path: &Path) -> bool {
496 let key = exe_path_key(path);
497 Path::new(&key)
498 .file_name()
499 .is_some_and(|name| name == "ljos")
500}
501
502fn is_python(p: &Proc) -> bool {
503 p.comm.starts_with("python")
504}
505
506#[must_use]
513pub fn cast_shell<'a>(chain: &'a [Proc], mine: Option<&Path>) -> Option<&'a Proc> {
514 let transparent =
515 |p: &Proc| is_wrapper(p) || mine.is_some_and(|mine| same_resolved_exe(p.pid, mine));
516 let mut i = 0;
517 while i < chain.len() {
518 let p = &chain[i];
519 if transparent(p) || is_python(p) {
520 i += 1;
521 continue;
522 }
523 break;
524 }
525 let mut top = chain.get(i).filter(|p| is_shell(p))?;
526 i += 1;
527 while i < chain.len() {
528 let p = &chain[i];
529 if is_shell(p) {
530 top = p;
531 } else if !transparent(p) {
532 break;
533 }
534 i += 1;
535 }
536 Some(top)
537}
538
539fn ends_the_walk(p: &Proc) -> bool {
544 crate::is_session(&p.comm) || (p.pid == 1 && !is_harness(p))
545}
546
547#[must_use]
556pub fn runner(chain: &[Proc]) -> Option<&Proc> {
557 let own = chain.iter().take_while(|p| !ends_the_walk(p));
558 own.clone()
559 .find(|p| is_harness(p))
560 .or_else(|| own.clone().find(|p| !is_shell(p) && !is_wrapper(p)))
561 .or_else(|| own.last())
562}
563
564const HOOK_RUNNERS: &[(&str, &str)] = &[
567 ("claude", "claude"),
568 ("claude-code", "claude"),
569 ("codex", "codex"),
570 ("cursor-agent", "cursor-agent"),
571 ("cursor", "cursor"),
572 ("grok", "grok"),
573 ("opencode", "opencode"),
574 ("gemini", "gemini"),
575 ("agy", "agy"),
576 ("antigravity", "agy"),
577];
578
579const RUNTIMES: &[&str] = &["node", "nodejs", "bun", "deno"];
582
583fn component_stem(component: &str) -> &str {
585 component
586 .strip_suffix(".js")
587 .or_else(|| component.strip_suffix(".mjs"))
588 .or_else(|| component.strip_suffix(".cjs"))
589 .unwrap_or(component)
590}
591
592fn layout_component(component: &str) -> bool {
594 let name = component_stem(component);
595 crate::version_like(name)
596 || crate::PLACES.contains(&name)
597 || crate::ENTRY_STEMS.contains(&name)
598 || RUNTIMES.contains(&name)
599 || matches!(name, "vendor" | "share")
600 || name.starts_with('@')
601 || name.contains("unknown-linux")
602 || matches!(name, "x86_64" | "aarch64" | "arm64")
603 || name.starts_with("armv")
604}
605
606fn hook_name(component: &str) -> Option<&'static str> {
608 let name = component_stem(component);
609 let name = name.split_once("-x86_64").map_or(name, |(head, _)| head);
610 let name = name.split_once("-aarch64").map_or(name, |(head, _)| head);
611 HOOK_RUNNERS
612 .iter()
613 .find(|(n, _)| *n == name)
614 .map(|(_, runner)| *runner)
615}
616
617#[must_use]
620pub fn own_home() -> Option<std::path::PathBuf> {
621 use std::os::unix::ffi::OsStrExt as _;
622 let mut buf = vec![0u8; 1024];
623 loop {
624 let mut entry: libc::passwd = unsafe { std::mem::zeroed() };
626 let mut found: *mut libc::passwd = std::ptr::null_mut();
627 let rc = unsafe {
630 libc::getpwuid_r(
631 libc::getuid(),
632 &mut entry,
633 buf.as_mut_ptr().cast(),
634 buf.len(),
635 &mut found,
636 )
637 };
638 if rc == libc::ERANGE && buf.len() < 1 << 20 {
639 buf.resize(buf.len() * 2, 0);
640 continue;
641 }
642 if rc != 0 || found.is_null() || entry.pw_dir.is_null() {
643 return None;
644 }
645 let dir = unsafe { std::ffi::CStr::from_ptr(entry.pw_dir) };
647 let home = std::path::PathBuf::from(std::ffi::OsStr::from_bytes(dir.to_bytes()));
648 return home.is_absolute().then_some(home);
649 }
650}
651
652const HOME_ROOTS: &[&str] = &[".local/bin", ".cargo/bin", ".npm-global"];
659
660const SYSTEM_ROOTS: &[&str] = &[
663 "/usr/bin",
664 "/bin",
665 "/usr/local/bin",
666 "/opt/homebrew/bin",
667 "/usr/lib/node_modules",
668 "/usr/local/lib/node_modules",
669 "/opt/homebrew/lib/node_modules",
670];
671
672fn install_roots(home: Option<&Path>) -> Vec<PathBuf> {
677 let mut roots: Vec<PathBuf> = SYSTEM_ROOTS.iter().map(PathBuf::from).collect();
678 let mut shares = vec![PathBuf::from("/usr/share")];
679 if let Some(home) = home {
680 roots.extend(HOME_ROOTS.iter().map(|root| home.join(root)));
681 shares.push(home.join(".local/share"));
682 }
683 for share in shares {
684 roots.extend(HOOK_RUNNERS.iter().map(|(name, _)| share.join(name)));
685 }
686 roots
687}
688
689const NIX_STORE: &str = "/nix/store";
691
692fn nix_entry(path: &Path, store: &Path) -> Option<PathBuf> {
695 let name = path.strip_prefix(store).ok()?.iter().next()?.to_str()?;
696 let (hash, rest) = name.split_at_checked(32)?;
697 let hashed = hash
698 .chars()
699 .all(|c| "0123456789abcdfghijklmnpqrsvwxyz".contains(c));
700 (hashed && rest.len() > 1 && rest.starts_with('-')).then(|| store.join(name))
701}
702
703fn runner_below(path: &Path, roots: &[PathBuf], store: &Path) -> Option<(&'static str, PathBuf)> {
708 let root = root_below(path, roots, store)?;
709 let parts = root
710 .file_name()
711 .into_iter()
712 .chain(path.strip_prefix(&root).ok()?.iter())
713 .map(std::ffi::OsStr::to_str)
714 .collect::<Option<Vec<_>>>()?;
715 let name = parts.iter().rev().find(|part| !layout_component(part))?;
716 Some((hook_name(name)?, root))
717}
718
719fn root_below(path: &Path, roots: &[PathBuf], store: &Path) -> Option<PathBuf> {
724 let plain = path.is_absolute()
725 && path.components().all(|c| {
726 matches!(
727 c,
728 std::path::Component::RootDir | std::path::Component::Normal(_)
729 )
730 });
731 if !plain || path.to_str()?.contains(" (deleted)") {
732 return None;
733 }
734 roots
735 .iter()
736 .cloned()
737 .chain(nix_entry(path, store))
738 .find(|root| path != root.as_path() && path.starts_with(root))
739}
740
741struct GroupEntry {
743 name: std::ffi::OsString,
744 members: Vec<std::ffi::OsString>,
745}
746
747trait Accounts {
751 fn user_name(&self, uid: u32) -> Option<std::ffi::OsString>;
752 fn group(&self, gid: u32) -> Option<GroupEntry>;
753 fn primary_members(&self, gid: u32) -> Option<Vec<u32>>;
755}
756
757struct SystemAccounts;
763
764fn with_buf<T>(mut lookup: impl FnMut(&mut [u8]) -> Result<Option<T>, i32>) -> Option<T> {
766 let mut buf = vec![0u8; 1024];
767 loop {
768 match lookup(&mut buf) {
769 Err(libc::ERANGE) if buf.len() < 1 << 20 => buf.resize(buf.len() * 2, 0),
770 Ok(found) => return found,
771 Err(_) => return None,
772 }
773 }
774}
775
776unsafe fn os_string(ptr: *const libc::c_char) -> std::ffi::OsString {
779 use std::os::unix::ffi::OsStrExt as _;
780 let bytes = unsafe { std::ffi::CStr::from_ptr(ptr) }.to_bytes();
782 std::ffi::OsStr::from_bytes(bytes).to_os_string()
783}
784
785impl Accounts for SystemAccounts {
786 fn user_name(&self, uid: u32) -> Option<std::ffi::OsString> {
787 with_buf(|buf| {
788 let mut entry: libc::passwd = unsafe { std::mem::zeroed() };
790 let mut found: *mut libc::passwd = std::ptr::null_mut();
791 let rc = unsafe {
794 libc::getpwuid_r(
795 uid,
796 &mut entry,
797 buf.as_mut_ptr().cast(),
798 buf.len(),
799 &mut found,
800 )
801 };
802 if rc != 0 {
803 return Err(rc);
804 }
805 Ok((!found.is_null() && !entry.pw_name.is_null())
807 .then(|| unsafe { os_string(entry.pw_name) }))
808 })
809 }
810
811 fn group(&self, gid: u32) -> Option<GroupEntry> {
812 group_in_files(Path::new(ETC), gid)
813 }
814
815 fn primary_members(&self, gid: u32) -> Option<Vec<u32>> {
816 primary_in_files(Path::new(ETC), gid)
817 }
818}
819
820const ETC: &str = "/etc";
822
823fn files_only(etc: &Path, db: &str) -> bool {
829 let Ok(conf) = std::fs::read_to_string(etc.join("nsswitch.conf")) else {
830 return false;
831 };
832 let mut lines = conf
833 .lines()
834 .map(|line| line.split('#').next().unwrap_or_default())
835 .filter_map(|line| line.split_once(':'))
836 .filter(|(name, _)| name.trim() == db);
837 let (Some((_, sources)), None) = (lines.next(), lines.next()) else {
838 return false;
839 };
840 sources.split_whitespace().eq(["files"])
841}
842
843fn group_in_files(etc: &Path, gid: u32) -> Option<GroupEntry> {
849 if !files_only(etc, "group") {
850 return None;
851 }
852 let text = std::fs::read_to_string(etc.join("group")).ok()?;
853 let mut found = None;
854 for line in text.lines() {
855 if line.is_empty() || line.starts_with('#') {
856 continue;
857 }
858 if line.starts_with(['+', '-']) {
859 return None;
860 }
861 let fields: Vec<&str> = line.split(':').collect();
862 let [name, _, id, members] = fields[..] else {
863 return None;
864 };
865 if id.parse::<u32>().ok()? != gid {
866 continue;
867 }
868 if found.is_some() {
869 return None;
870 }
871 found = Some(GroupEntry {
872 name: name.into(),
873 members: members
874 .split(',')
875 .filter(|m| !m.is_empty())
876 .map(Into::into)
877 .collect(),
878 });
879 }
880 found
881}
882
883fn primary_in_files(etc: &Path, gid: u32) -> Option<Vec<u32>> {
889 if !files_only(etc, "passwd") {
890 return None;
891 }
892 let text = std::fs::read_to_string(etc.join("passwd")).ok()?;
893 let mut uids = Vec::new();
894 for line in text.lines() {
895 if line.is_empty() || line.starts_with('#') {
896 continue;
897 }
898 if line.starts_with(['+', '-']) {
899 return None;
900 }
901 let fields: Vec<&str> = line.split(':').collect();
902 let [_, _, uid, primary, _, _, _] = fields[..] else {
903 return None;
904 };
905 let (uid, primary) = (uid.parse::<u32>().ok()?, primary.parse::<u32>().ok()?);
906 if primary == gid {
907 uids.push(uid);
908 }
909 }
910 Some(uids)
911}
912
913fn group_is_private(accounts: &dyn Accounts, uid: u32, gid: u32) -> bool {
916 let (Some(user), Some(group), Some(primary)) = (
917 accounts.user_name(uid),
918 accounts.group(gid),
919 accounts.primary_members(gid),
920 ) else {
921 return false;
922 };
923 group.name == user
924 && group.members.iter().all(|member| *member == user)
925 && primary.iter().all(|other| *other == uid)
926}
927
928struct Person<'a> {
933 uid: u32,
934 gid: u32,
935 groups: Option<Vec<u32>>,
936 accounts: &'a dyn Accounts,
937 private_group: std::cell::OnceCell<bool>,
938}
939
940impl<'a> Person<'a> {
941 fn new(accounts: &'a dyn Accounts) -> Self {
942 let (uid, gid) = unsafe { (libc::getuid(), libc::getgid()) };
945 Person {
946 uid,
947 gid,
948 groups: supplementary_groups(),
949 accounts,
950 private_group: std::cell::OnceCell::new(),
951 }
952 }
953
954 fn in_group(&self, gid: u32) -> bool {
957 gid == self.gid
958 || self
959 .groups
960 .as_ref()
961 .is_none_or(|groups| groups.contains(&gid))
962 }
963
964 fn private_group(&self) -> bool {
965 *self
966 .private_group
967 .get_or_init(|| group_is_private(self.accounts, self.uid, self.gid))
968 }
969}
970
971fn supplementary_groups() -> Option<Vec<u32>> {
973 let count = unsafe { libc::getgroups(0, std::ptr::null_mut()) };
975 let mut groups = vec![0; usize::try_from(count).ok()?];
976 let got = unsafe { libc::getgroups(count, groups.as_mut_ptr()) };
978 groups.truncate(usize::try_from(got).ok()?);
979 Some(groups)
980}
981
982#[derive(Clone, Copy)]
984struct Owner {
985 uid: u32,
986 gid: u32,
987 mode: u32,
988}
989
990impl From<&std::fs::Metadata> for Owner {
991 fn from(meta: &std::fs::Metadata) -> Self {
992 use std::os::unix::fs::MetadataExt as _;
993 Owner {
994 uid: meta.uid(),
995 gid: meta.gid(),
996 mode: meta.mode(),
997 }
998 }
999}
1000
1001fn pinned_walk(file: &Path, expect: (u64, u64)) -> Option<Vec<Owner>> {
1010 use std::os::fd::{AsRawFd as _, FromRawFd as _};
1011 use std::os::unix::ffi::OsStrExt as _;
1012 use std::os::unix::fs::MetadataExt as _;
1013 #[cfg(target_os = "linux")]
1014 let flags = libc::O_PATH | libc::O_NOFOLLOW | libc::O_CLOEXEC;
1015 #[cfg(not(target_os = "linux"))]
1016 let flags = libc::O_RDONLY | libc::O_NOFOLLOW | libc::O_CLOEXEC;
1017 let open = |dir: libc::c_int, name: &std::ffi::CStr| {
1018 let fd = unsafe { libc::openat(dir, name.as_ptr(), flags) };
1020 (fd >= 0).then(|| std::fs::File::from(unsafe { std::os::fd::OwnedFd::from_raw_fd(fd) }))
1022 };
1023 let mut parts = file.components();
1024 if parts.next() != Some(std::path::Component::RootDir) {
1025 return None;
1026 }
1027 let mut at = open(libc::AT_FDCWD, c"/")?;
1028 let mut meta = at.metadata().ok()?;
1029 let mut owners = vec![Owner::from(&meta)];
1030 for part in parts {
1031 let std::path::Component::Normal(name) = part else {
1032 return None;
1033 };
1034 if !meta.is_dir() {
1035 return None;
1036 }
1037 at = open(
1038 at.as_raw_fd(),
1039 &std::ffi::CString::new(name.as_bytes()).ok()?,
1040 )?;
1041 meta = at.metadata().ok()?;
1042 owners.push(Owner::from(&meta));
1043 }
1044 (meta.is_file() && expect == (meta.dev(), meta.ino())).then_some(owners)
1045}
1046
1047fn store_keeps(store: Owner, entry: Owner, person: &Person) -> bool {
1056 let group = store.mode & 0o020 != 0;
1057 let anyone = store.mode & 0o002 != 0;
1058 if store.uid == 0 {
1059 let person_writes = anyone
1060 || (group && person.in_group(store.gid))
1061 || (person.uid == 0 && store.mode & 0o200 != 0);
1062 !person_writes && (!group || store.mode & 0o1000 != 0) && entry.uid == 0
1063 } else {
1064 store.uid == person.uid && !group && !anyone && (entry.uid == 0 || entry.uid == person.uid)
1065 }
1066}
1067
1068fn person_keeps(file: Owner, person: &Person) -> bool {
1071 (file.uid == 0 || file.uid == person.uid)
1072 && file.mode & 0o002 == 0
1073 && (file.mode & 0o020 == 0 || (file.gid == person.gid && person.private_group()))
1074}
1075
1076fn runner_of_file(
1093 path: &Path,
1094 home: Option<&Path>,
1095 store: &Path,
1096 person: &Person,
1097 expect: (u64, u64),
1098) -> Option<&'static str> {
1099 runner_at(
1100 &std::fs::canonicalize(path).ok()?,
1101 home,
1102 store,
1103 person,
1104 expect,
1105 )
1106}
1107
1108fn runner_at(
1110 file: &Path,
1111 home: Option<&Path>,
1112 store: &Path,
1113 person: &Person,
1114 expect: (u64, u64),
1115) -> Option<&'static str> {
1116 let (roots, store) = resolved_roots(home, store);
1117 let (runner, root) = runner_below(file, &roots, &store)?;
1118 kept_below(file, &root, &store, person, expect).then_some(runner)
1119}
1120
1121fn runtime_of_file(
1125 path: &Path,
1126 home: Option<&Path>,
1127 store: &Path,
1128 person: &Person,
1129 expect: (u64, u64),
1130) -> bool {
1131 let Ok(file) = std::fs::canonicalize(path) else {
1132 return false;
1133 };
1134 let named = file
1135 .file_name()
1136 .and_then(std::ffi::OsStr::to_str)
1137 .is_some_and(|name| RUNTIMES.contains(&name));
1138 let (roots, store) = resolved_roots(home, store);
1139 named
1140 && root_below(&file, &roots, &store)
1141 .is_some_and(|root| kept_below(&file, &root, &store, person, expect))
1142}
1143
1144fn resolved_roots(home: Option<&Path>, store: &Path) -> (Vec<PathBuf>, PathBuf) {
1147 let roots = install_roots(home)
1148 .iter()
1149 .filter_map(|root| std::fs::canonicalize(root).ok())
1150 .collect();
1151 let store = std::fs::canonicalize(store).unwrap_or_else(|_| store.to_path_buf());
1152 (roots, store)
1153}
1154
1155fn kept_below(file: &Path, root: &Path, store: &Path, person: &Person, expect: (u64, u64)) -> bool {
1159 if crate::bookkeep::resolved_path_is_temp(file) {
1160 return false;
1161 }
1162 let (Some(owners), Some(at)) = (
1163 pinned_walk(file, expect),
1164 root.components().count().checked_sub(1),
1165 ) else {
1166 return false;
1167 };
1168 walk_kept(&owners, at, root.parent() == Some(store), person)
1169}
1170
1171fn walk_kept(owners: &[Owner], at: usize, in_store: bool, person: &Person) -> bool {
1178 let (Some(&above), Some(&root)) = (
1179 at.checked_sub(1).and_then(|above| owners.get(above)),
1180 owners.get(at),
1181 ) else {
1182 return false;
1183 };
1184 let top = if in_store {
1185 store_keeps(above, root, person)
1186 } else {
1187 person_keeps(above, person)
1188 };
1189 top && owners[at..].iter().all(|dir| person_keeps(*dir, person))
1190}
1191
1192#[cfg(target_os = "linux")]
1193fn running_exe(pid: u32) -> Option<(PathBuf, Option<(u64, u64)>)> {
1194 use std::os::unix::fs::MetadataExt as _;
1195 let path = format!("/proc/{pid}/exe");
1196 let exe = std::fs::read_link(&path).ok()?;
1197 let running = std::fs::metadata(&path)
1198 .ok()
1199 .map(|meta| (meta.dev(), meta.ino()));
1200 Some((exe, running))
1201}
1202
1203#[cfg(not(target_os = "linux"))]
1204fn running_exe(pid: u32) -> Option<(PathBuf, Option<(u64, u64)>)> {
1205 use std::os::unix::fs::MetadataExt as _;
1206 let exe = exe(pid)?;
1207 let running = std::fs::metadata(&exe)
1208 .ok()
1209 .map(|meta| (meta.dev(), meta.ino()));
1210 Some((exe, running))
1211}
1212
1213#[must_use]
1220pub fn exe_runner(pid: u32) -> Option<&'static str> {
1221 let (exe, running) = running_exe(pid)?;
1222 let script = || {
1223 let words = argv(pid)?;
1224 let script = words
1225 .into_iter()
1226 .skip(1)
1227 .find(|a| !a.is_empty() && !a.starts_with('-'))?;
1228 let cwd = cwd(pid)?;
1229 Some(cwd.join(script))
1230 };
1231 let home = own_home();
1232 let person = Person::new(&SystemAccounts);
1233 runner_from(
1234 &exe,
1235 running,
1236 script,
1237 home.as_deref(),
1238 Path::new(NIX_STORE),
1239 &person,
1240 )
1241}
1242
1243fn runner_from(
1249 exe: &Path,
1250 running: Option<(u64, u64)>,
1251 script: impl FnOnce() -> Option<PathBuf>,
1252 home: Option<&Path>,
1253 store: &Path,
1254 person: &Person,
1255) -> Option<&'static str> {
1256 use std::os::unix::fs::MetadataExt as _;
1257 let running = running?;
1258 if let Some(r) = runner_of_file(exe, home, store, person, running) {
1259 return Some(r);
1260 }
1261 if !runtime_of_file(exe, home, store, person, running) {
1262 return None;
1263 }
1264 let script = std::fs::canonicalize(script()?).ok()?;
1265 let meta = std::fs::metadata(&script).ok()?;
1266 runner_of_file(&script, home, store, person, (meta.dev(), meta.ino()))
1267}
1268
1269const EDITORS: &[&str] = &[
1272 "claude",
1273 "claude-code",
1274 "codex",
1275 "cursor",
1276 "cursor-agent",
1277 "opencode",
1278 "opencode-ai",
1279 "agy",
1280 "antigravity",
1281 "grok",
1282 "gemini",
1283 "copilot",
1284 "qwen",
1285 "droid",
1286 "crush",
1287 "kiro",
1288 "kiro-cli",
1289 "hermes",
1290 "omp",
1291 "cline",
1292 "aider",
1293 "code",
1294 "code-oss",
1295 "codium",
1296 "vscodium",
1297 "zed",
1298 "zed-editor",
1299 "windsurf",
1300 "goland",
1301 "idea",
1302 "pycharm",
1303 "webstorm",
1304 "rustrover",
1305 "sublime_text",
1306 "emacs",
1307 "nvim",
1308 "vim",
1309 "gvim",
1310 "helix",
1311 "lapce",
1312 "kate",
1313 "gedit",
1314 "cursor helper",
1315];
1316
1317#[must_use]
1319pub fn is_editor_name(name: &str) -> bool {
1320 let base = name.rsplit(['/', '\\']).next().unwrap_or(name).trim();
1321 EDITORS.iter().any(|item| base.eq_ignore_ascii_case(item))
1322}
1323
1324#[cfg(target_os = "linux")]
1325fn command_base(pid: u32) -> Option<String> {
1326 let raw = std::fs::read(format!("/proc/{pid}/cmdline")).ok()?;
1327 let first = raw.split(|b| *b == 0).find(|word| !word.is_empty())?;
1328 let text = String::from_utf8_lossy(first);
1329 Some(text.rsplit(['/', '\\']).next().unwrap_or(&text).to_string())
1330}
1331
1332#[cfg(target_os = "macos")]
1333fn command_base(pid: u32) -> Option<String> {
1334 let mut mib = [
1335 libc::CTL_KERN,
1336 libc::KERN_PROCARGS2,
1337 libc::c_int::try_from(pid).ok()?,
1338 ];
1339 let mut size: libc::size_t = 0;
1340 let asked = unsafe {
1342 libc::sysctl(
1343 mib.as_mut_ptr(),
1344 3,
1345 std::ptr::null_mut(),
1346 &mut size,
1347 std::ptr::null_mut(),
1348 0,
1349 )
1350 };
1351 if asked != 0 || size < 4 {
1352 return None;
1353 }
1354 let mut buf = vec![0u8; size];
1355 let read = unsafe {
1357 libc::sysctl(
1358 mib.as_mut_ptr(),
1359 3,
1360 buf.as_mut_ptr().cast(),
1361 &mut size,
1362 std::ptr::null_mut(),
1363 0,
1364 )
1365 };
1366 if read != 0 {
1367 return None;
1368 }
1369 buf.truncate(size);
1370 let argc = usize::try_from(i32::from_ne_bytes(buf.get(..4)?.try_into().ok()?)).ok()?;
1371 if argc == 0 {
1372 return None;
1373 }
1374 let word = buf[4..]
1375 .split(|b| *b == 0)
1376 .skip(1)
1377 .find(|word| !word.is_empty())?;
1378 let text = String::from_utf8_lossy(word);
1379 Some(text.rsplit(['/', '\\']).next().unwrap_or(&text).to_string())
1380}
1381
1382#[cfg(target_os = "linux")]
1386#[must_use]
1387pub fn editor_above() -> bool {
1388 ancestors().into_iter().any(|proc| {
1389 is_editor_name(&proc.comm)
1390 || command_base(proc.pid).is_some_and(|name| is_editor_name(&name))
1391 || exe_runner(proc.pid).is_some_and(is_editor_name)
1392 })
1393}
1394
1395#[cfg(target_os = "macos")]
1396#[must_use]
1397pub fn editor_above() -> bool {
1398 let mut pid = unsafe { libc::getppid() } as u32;
1400 for _ in 0..DEPTH {
1401 if pid <= 1 {
1402 return false;
1403 }
1404 let Some((ppid, comm)) = parent_and_name(pid) else {
1405 return false;
1406 };
1407 if is_editor_name(&comm) || command_base(pid).is_some_and(|name| is_editor_name(&name)) {
1408 return true;
1409 }
1410 if ppid == pid {
1411 return false;
1412 }
1413 pid = ppid;
1414 }
1415 false
1416}
1417
1418#[cfg(not(any(target_os = "linux", target_os = "macos")))]
1419#[must_use]
1420pub fn editor_above() -> bool {
1421 false
1422}
1423
1424fn bare_hook_command(cmd: &str) -> bool {
1430 let mut plain = String::new();
1431 let mut rest = cmd;
1432 while let Some(at) = rest.find("${") {
1433 plain.push_str(&rest[..at]);
1434 let Some(close) = rest[at..].find('}') else {
1435 return false;
1436 };
1437 let name = &rest[at + 2..at + close];
1438 if name.is_empty() || !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
1439 return false;
1440 }
1441 plain.push_str("VAR");
1442 rest = &rest[at + close + 1..];
1443 }
1444 plain.push_str(rest);
1445 let plain = plain.replace('"', "");
1446 if plain.contains(|c: char| "<>|;&$`(){}\\'\n\r*?[]~#!".contains(c)) {
1447 return false;
1448 }
1449 let words: Vec<&str> = plain.split_whitespace().collect();
1450 let base = |w: &str| w.rsplit('/').next().unwrap_or(w).to_string();
1451 let at = match words.first().map(|w| base(w)).as_deref() {
1452 Some("ljos-plugin") if words.get(1) == Some(&"ljos") => 2,
1453 Some("ljos") => 1,
1454 _ => return false,
1455 };
1456 words.get(at) == Some(&"hook")
1457 && words[at + 1..].iter().all(|w| {
1458 w.starts_with("--")
1459 || w.chars()
1460 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
1461 })
1462}
1463
1464fn hook_shell(pid: u32) -> bool {
1469 let Some(exe) = exe(pid) else {
1470 return false;
1471 };
1472 let shell = exe
1473 .file_name()
1474 .and_then(|f| f.to_str())
1475 .is_some_and(|f| SHELLS.contains(&f));
1476 let words = argv(pid).unwrap_or_default();
1477 shell && matches!(words.as_slice(), [_, c, cmd] if c == "-c" && bare_hook_command(cmd))
1478}
1479
1480#[must_use]
1483pub fn runner_above() -> Option<(Proc, &'static str)> {
1484 ancestors()
1485 .into_iter()
1486 .find_map(|p| exe_runner(p.pid).map(|r| (p, r)))
1487}
1488
1489fn tool_call_stamp(env: &[(String, String)]) -> Option<&str> {
1492 env.iter()
1493 .find(|(k, v)| {
1494 matches!(
1495 k.as_str(),
1496 "CLAUDECODE" | "CURSOR_AGENT" | "CODEX_THREAD_ID"
1497 ) || (crate::runner_session_var(k, v) && !k.starts_with("LJOS_"))
1498 })
1499 .map(|(k, _)| k.as_str())
1500}
1501
1502struct ToolMark {
1506 sid: u32,
1507 at: u64,
1508 runner_pid: u32,
1509 runner_start: u64,
1510 shell_pid: u32,
1511 shell_start: u64,
1512 cgroup: String,
1513}
1514
1515struct Here {
1518 sid: u32,
1519 cgroup: String,
1520}
1521
1522fn marks_path() -> std::path::PathBuf {
1523 crate::runtime_dir().join("tool-calls")
1524}
1525
1526fn broken_path() -> std::path::PathBuf {
1527 crate::runtime_dir().join("tool-calls.broken")
1528}
1529
1530fn current_sid() -> Result<u32, String> {
1532 let sid = unsafe { libc::getsid(0) };
1535 if sid < 0 {
1536 return Err(
1537 "the session id cannot be read, so a tool call cannot be told from the runner".into(),
1538 );
1539 }
1540 u32::try_from(sid).map_err(|_| "the session id does not fit".into())
1541}
1542
1543fn here_evidence() -> Result<Here, String> {
1544 let sid = current_sid()?;
1545 #[cfg(target_os = "linux")]
1546 let cgroup = std::fs::read_to_string("/proc/self/cgroup").map_err(|_| {
1547 "the cgroup cannot be read, so a tool call cannot be told from the runner".to_string()
1548 })?;
1549 #[cfg(not(target_os = "linux"))]
1550 let cgroup = format!("sid:{sid}");
1551 if cgroup.is_empty() {
1552 return Err("the cgroup is empty, so a tool call cannot be told from the runner".into());
1553 }
1554 Ok(Here { sid, cgroup })
1555}
1556
1557fn self_start() -> Result<u64, String> {
1558 stat(std::process::id())
1559 .map(|row| row.start)
1560 .ok_or_else(|| "this process's start time cannot be read".into())
1561}
1562
1563pub fn seal_unreadable_tool_call() {
1569 mark_broken();
1570}
1571
1572fn mark_broken() {
1573 let dir = crate::runtime_dir();
1574 let _ = std::fs::create_dir_all(&dir);
1575 let mut options = std::fs::OpenOptions::new();
1576 options.create(true).write(true).truncate(true);
1577 #[cfg(unix)]
1578 {
1579 use std::os::unix::fs::OpenOptionsExt as _;
1580 options.mode(0o600);
1581 }
1582 let _ = options.open(broken_path());
1583}
1584
1585fn tool_marks() -> Result<Vec<ToolMark>, String> {
1586 if broken_path().exists() {
1587 return Err(
1588 "a tool-call record could not be written, so nothing is kept as the person's".into(),
1589 );
1590 }
1591 let text = match std::fs::read_to_string(marks_path()) {
1592 Ok(text) => text,
1593 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
1594 Err(_) => {
1595 return Err(
1596 "the tool-call record cannot be read, so nothing is kept as the person's".into(),
1597 );
1598 }
1599 };
1600 let mut marks = Vec::new();
1601 for line in text.lines() {
1602 let fields: Vec<&str> = line.split('\t').collect();
1603 if fields.len() != 8 || fields[0] != "1" {
1604 return Err(
1605 "the tool-call record is corrupt, so nothing is kept as the person's".into(),
1606 );
1607 }
1608 let number = |index: usize, label: &str| -> Result<u64, String> {
1609 fields[index].parse().map_err(|_| {
1610 format!(
1611 "the tool-call record has a bad {label}, so nothing is kept as the person's"
1612 )
1613 })
1614 };
1615 let cgroup = hex::decode(fields[7])
1616 .ok()
1617 .and_then(|bytes| String::from_utf8(bytes).ok())
1618 .ok_or("the tool-call record has a bad cgroup, so nothing is kept as the person's")?;
1619 let narrow = |value: u64, label: &str| -> Result<u32, String> {
1620 u32::try_from(value).map_err(|_| {
1621 format!(
1622 "the tool-call record has a bad {label}, so nothing is kept as the person's"
1623 )
1624 })
1625 };
1626 marks.push(ToolMark {
1627 sid: narrow(number(1, "session")?, "session")?,
1628 at: number(2, "start")?,
1629 runner_pid: narrow(number(3, "runner")?, "runner")?,
1630 runner_start: number(4, "runner start")?,
1631 shell_pid: narrow(number(5, "shell")?, "shell")?,
1632 shell_start: number(6, "shell start")?,
1633 cgroup,
1634 });
1635 }
1636 Ok(marks)
1637}
1638
1639pub fn note_tool_call(runner: &Proc) -> Result<(), String> {
1649 let here = match here_evidence() {
1652 Ok(here) => here,
1653 Err(why) => {
1654 mark_broken();
1655 return Err(why);
1656 }
1657 };
1658 let at = match self_start() {
1659 Ok(at) => at,
1660 Err(why) => {
1661 mark_broken();
1662 return Err(why);
1663 }
1664 };
1665 let dir = crate::runtime_dir();
1666 let mut builder = std::fs::DirBuilder::new();
1667 builder.recursive(true);
1668 #[cfg(unix)]
1669 {
1670 use std::os::unix::fs::DirBuilderExt as _;
1671 builder.mode(0o700);
1672 }
1673 if builder.create(&dir).is_err() {
1674 mark_broken();
1675 return Err("the tool-call record directory cannot be created".into());
1676 }
1677 let line = format!(
1681 "1\t{}\t{at}\t{}\t{}\t0\t0\t{}\n",
1682 here.sid,
1683 runner.pid,
1684 runner.start,
1685 hex::encode(here.cgroup.as_bytes())
1686 );
1687 if line.len() > 4096 {
1688 mark_broken();
1689 return Err("the tool-call record does not fit one write".into());
1690 }
1691 let mut options = std::fs::OpenOptions::new();
1692 options.create(true).append(true);
1693 #[cfg(unix)]
1694 {
1695 use std::os::unix::fs::OpenOptionsExt as _;
1696 options.mode(0o600);
1697 }
1698 let opened = options.open(marks_path());
1699 let mut file = match opened {
1700 Ok(file) => file,
1701 Err(_) => {
1702 mark_broken();
1703 return Err("the tool-call record cannot be written".into());
1704 }
1705 };
1706 use std::io::Write as _;
1707 if file.write_all(line.as_bytes()).is_err() {
1708 mark_broken();
1709 return Err("the tool-call record cannot be written".into());
1710 }
1711 Ok(())
1712}
1713
1714fn confirm_runner(runner: &Proc) -> Result<(), String> {
1720 let here = here_evidence()?;
1721 let marks = tool_marks()?;
1722 let chain = ancestors();
1723 for mark in &marks {
1724 if mark.runner_pid == runner.pid && mark.runner_start == runner.start {
1725 continue;
1726 }
1727 if mark.shell_pid > 0
1728 && chain
1729 .iter()
1730 .any(|proc| proc.pid == mark.shell_pid && proc.start == mark.shell_start)
1731 {
1732 return Err(format!(
1733 "{} (pid {}) descends from a recorded tool call",
1734 runner.comm, runner.pid
1735 ));
1736 }
1737 let same_place = mark.sid == here.sid || mark.cgroup == here.cgroup;
1738 if same_place && runner.start >= mark.at {
1739 return Err(format!(
1740 "{} (pid {}) shares a recorded tool call's cgroup or session and is not older than that tool call",
1741 runner.comm, runner.pid
1742 ));
1743 }
1744 }
1745 Ok(())
1746}
1747
1748pub fn placed_runner() -> Result<(), String> {
1758 for proc in ancestors() {
1759 if is_shell(&proc) || is_wrapper(&proc) {
1760 continue;
1761 }
1762 if ends_the_walk(&proc) {
1763 break;
1764 }
1765 if exe_runner(proc.pid).is_some() {
1766 return confirm_runner(&proc);
1767 }
1768 return Err(format!(
1769 "{} (pid {}) is not an installed runner, so this is not the person's runner",
1770 proc.comm, proc.pid
1771 ));
1772 }
1773 Err("no installed runner is above this process, so this is not the person's runner".into())
1774}
1775
1776pub fn hook_runner() -> Result<(Proc, &'static str), String> {
1790 let mut chain = ancestors();
1791 let parent = chain.first().ok_or("it has no parent process")?;
1792 if exe_runner(parent.pid).is_none() && chain.len() > 1 && hook_shell(parent.pid) {
1793 chain.remove(0);
1794 }
1795 let parent = &chain[0];
1796 let Some(runner) = exe_runner(parent.pid) else {
1797 return Err(format!(
1798 "its parent, {} (pid {}), is not a runner's own program: a shell, an interpreter or ssh ran it, so a tool call may have",
1799 parent.comm, parent.pid
1800 ));
1801 };
1802 let env = environ(parent.pid).ok_or_else(|| {
1803 format!(
1804 "the environment {} (pid {}) was started with cannot be read, so a tool call's stamp would be hidden",
1805 parent.comm, parent.pid
1806 )
1807 })?;
1808 if let Some(key) = tool_call_stamp(&env) {
1809 return Err(format!(
1810 "{runner} (pid {}) was started with {key} set, inside another runner's tool call",
1811 parent.pid
1812 ));
1813 }
1814 let own = chain[1..].iter().take_while(|p| !ends_the_walk(p));
1817 if let Some((p, other)) = own
1818 .filter_map(|p| exe_runner(p.pid).map(|r| (p, r)))
1819 .find(|(_, r)| *r != "cursor")
1820 {
1821 return Err(format!(
1822 "{runner} (pid {}) runs under {other} (pid {}), inside a tool call",
1823 parent.pid, p.pid
1824 ));
1825 }
1826 confirm_runner(parent)?;
1827 Ok((parent.clone(), runner))
1828}
1829
1830#[cfg(test)]
1831mod tests {
1832 use super::*;
1833
1834 #[cfg(target_os = "linux")]
1836 fn runner_here(
1837 path: &Path,
1838 home: Option<&Path>,
1839 store: &Path,
1840 person: &Person,
1841 ) -> Option<&'static str> {
1842 use std::os::unix::fs::MetadataExt as _;
1843 let meta = std::fs::metadata(path).ok()?;
1844 runner_of_file(path, home, store, person, (meta.dev(), meta.ino()))
1845 }
1846
1847 #[test]
1848 fn an_editor_name_matches_the_file_and_not_a_shell() {
1849 assert!(is_editor_name("Cursor"));
1850 assert!(is_editor_name("/Applications/Cursor Helper"));
1851 assert!(is_editor_name("cursor-agent"));
1852 assert!(!is_editor_name("bash"));
1853 assert!(!is_editor_name("python3"));
1854 }
1855
1856 #[test]
1857 fn a_runner_is_read_from_the_file_it_runs_from() {
1858 let roots = install_roots(Some(Path::new("/home/u")));
1859 for (path, want) in [
1860 ("/home/u/.local/share/claude/versions/2.1.3", Some("claude")),
1861 ("/home/u/.local/bin/claude", Some("claude")),
1862 (
1863 "/usr/lib/node_modules/@anthropic-ai/claude-code/cli.js",
1864 Some("claude"),
1865 ),
1866 (
1867 "/usr/lib/node_modules/@openai/codex/vendor/x86_64-unknown-linux-musl/codex/codex",
1868 Some("codex"),
1869 ),
1870 (
1871 "/home/u/.local/share/cursor-agent/versions/2025.10.2-abc/node",
1872 Some("cursor-agent"),
1873 ),
1874 ("/usr/share/cursor/cursor", Some("cursor")),
1875 ("/usr/local/bin/codex", Some("codex")),
1876 ("/usr/bin/claude", Some("claude")),
1877 ("/bin/grok", Some("grok")),
1878 ("/home/u/.cargo/bin/codex", Some("codex")),
1879 (
1880 "/home/u/.npm-global/lib/node_modules/@anthropic-ai/claude-code/cli.js",
1881 Some("claude"),
1882 ),
1883 (
1884 "/nix/store/0c9yapqd0b8y4mj5xj7mrpmhy3l5ykh8-claude-code-2.0.1/bin/claude",
1885 Some("claude"),
1886 ),
1887 ("/usr/bin/node", None),
1888 ("/usr/bin/bash", None),
1889 ("/usr/bin/python3", None),
1890 ("/usr/bin/ssh", None),
1891 ("/home/u/.cargo/bin/ljos", None),
1892 ("/tmp/claude", None),
1893 ("/tmp/claude/versions/2.1.3", None),
1894 ("/home/u/runners/claude", None),
1895 ("/home/u/plant/grok", None),
1896 ("/home/u/plant/bin/claude", None),
1897 ("/home/u/.local/bin/claude (deleted)", None),
1898 ("/home/u/plant/.local/bin/claude", None),
1899 ("/tmp/.local/bin/claude", None),
1900 ("/tmp/project/node_modules/.bin/claude", None),
1901 ("/home/u/../other/.local/bin/claude", None),
1902 ("/home/uu/.local/bin/claude", None),
1903 ("/home/other/.local/bin/claude", None),
1904 ("/home/u/.local/binx/claude", None),
1905 ("/home/u/.local/bin/../../plant/claude", None),
1906 ("/home/u/.local/share/plant/claude", None),
1907 ("/home/u/.local/share/claude-plant/claude", None),
1908 ("/home/u/src/app/node_modules/.bin/claude", None),
1909 ("/usr/binx/claude", None),
1910 ("/usr/share/plant/claude", None),
1911 ("/usr/lib/node_modules/../../../tmp/claude", None),
1912 ("/opt/plant/claude", None),
1913 ("/var/tmp/.local/bin/claude", None),
1914 ("/dev/shm/node_modules/.bin/claude", None),
1915 ("/nix/store/claude/bin/claude", None),
1916 (
1917 "/nix/store/0c9yapqd0b8y4mj5xj7mrpmhy3l5ykh8/bin/claude",
1918 None,
1919 ),
1920 (
1921 "/nix/store/0c9yapqd0b8y4mj5xj7mrpmhy3l5ykhe-claude-code-2.0.1/bin/claude",
1922 None,
1923 ),
1924 ] {
1925 let got = runner_below(Path::new(path), &roots, Path::new(NIX_STORE))
1926 .map(|(runner, _)| runner);
1927 assert_eq!(got, want, "{path}");
1928 }
1929 }
1930
1931 #[cfg(target_os = "linux")]
1937 #[test]
1938 fn an_install_root_holds_only_what_resolves_inside_it() {
1939 use std::os::unix::fs::PermissionsExt as _;
1940 let base = tempfile::Builder::new()
1941 .prefix(".ljos-roots-")
1942 .tempdir_in(own_home().expect("this user's passwd entry names a home"))
1943 .unwrap();
1944 let home = base.path().join("u");
1945 let person = Person::new(&SystemAccounts);
1946 let store = PathBuf::from(NIX_STORE);
1947 let program = |dir: &Path, name: &str| {
1948 std::fs::create_dir_all(dir).unwrap();
1949 let path = dir.join(name);
1950 std::fs::write(&path, "").unwrap();
1951 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
1952 path
1953 };
1954 let bin = home.join(".local/bin");
1955 let claude = program(&bin, "claude");
1956 assert_eq!(
1957 runner_here(&claude, Some(&home), &store, &person),
1958 Some("claude")
1959 );
1960
1961 let away = tempfile::tempdir().unwrap();
1962 std::os::unix::fs::symlink(program(away.path(), "codex"), bin.join("codex")).unwrap();
1963 assert_eq!(
1964 runner_here(&bin.join("codex"), Some(&home), &store, &person),
1965 None
1966 );
1967
1968 program(&base.path().join("other/.local/bin"), "claude");
1969 let climbed = home.join("../other/.local/bin/claude");
1970 assert_eq!(runner_here(&climbed, Some(&home), &store, &person), None);
1971
1972 std::fs::set_permissions(&claude, std::fs::Permissions::from_mode(0o757)).unwrap();
1973 assert_eq!(runner_here(&claude, Some(&home), &store, &person), None);
1974
1975 let open = bin.join("open");
1976 let grok = program(&open, "grok");
1977 assert_eq!(
1978 runner_here(&grok, Some(&home), &store, &person),
1979 Some("grok")
1980 );
1981 std::fs::set_permissions(&open, std::fs::Permissions::from_mode(0o777)).unwrap();
1982 assert_eq!(runner_here(&grok, Some(&home), &store, &person), None);
1983 }
1984
1985 #[cfg(target_os = "linux")]
1990 #[test]
1991 fn a_root_in_a_directory_others_may_write_is_refused() {
1992 use std::os::unix::fs::PermissionsExt as _;
1993 let base = tempfile::Builder::new()
1994 .prefix(".ljos-above-")
1995 .tempdir_in(own_home().expect("this user's passwd entry names a home"))
1996 .unwrap();
1997 let home = base.path().join("u");
1998 let store = PathBuf::from(NIX_STORE);
1999 let person = Person::new(&SystemAccounts);
2000 let staff = FakeAccounts {
2001 user: Some("u"),
2002 group: Some("staff"),
2003 members: &["u", "v"],
2004 primary: Some(vec![person.uid]),
2005 };
2006 let sharing = Person::new(&staff);
2007 let mut files: Vec<PathBuf> = vec![
2008 home.join(".local/bin/claude"),
2009 home.join(".cargo/bin/codex"),
2010 home.join(".npm-global/lib/node_modules/@anthropic-ai/claude-code/cli.js"),
2011 ];
2012 files.extend(
2013 HOOK_RUNNERS
2014 .iter()
2015 .map(|(name, _)| home.join(".local/share").join(name).join(name)),
2016 );
2017 for file in &files {
2018 std::fs::create_dir_all(file.parent().unwrap()).unwrap();
2019 std::fs::write(file, "").unwrap();
2020 }
2021 let set = |dir: &Path, mode| {
2022 std::fs::set_permissions(dir, std::fs::Permissions::from_mode(mode)).unwrap();
2023 };
2024 for root in install_roots(Some(&home))
2025 .iter()
2026 .filter(|r| r.starts_with(&home))
2027 {
2028 let file = files.iter().find(|f| f.starts_with(root)).unwrap();
2029 let above = root.parent().unwrap();
2030 let runner = |who| runner_here(file, Some(&home), &store, who);
2031 assert!(runner(&person).is_some(), "{}", root.display());
2032 set(above, 0o757);
2033 assert_eq!(runner(&person), None, "{}", root.display());
2034 set(above, 0o775);
2035 assert_eq!(runner(&sharing), None, "{}", root.display());
2036 set(above, 0o755);
2037 }
2038
2039 let uid = person.uid;
2040 let owner = |uid, mode| Owner {
2041 uid,
2042 gid: person.gid,
2043 mode,
2044 };
2045 let walk = |above| [owner(0, 0o755), above, owner(uid, 0o755), owner(uid, 0o755)];
2046 assert!(walk_kept(&walk(owner(uid, 0o755)), 2, false, &person));
2047 assert!(walk_kept(&walk(owner(0, 0o755)), 2, false, &person));
2048 assert!(!walk_kept(&walk(owner(uid + 1, 0o755)), 2, false, &person));
2049 assert!(!walk_kept(&walk(owner(uid, 0o757)), 2, false, &person));
2050 assert!(!walk_kept(&walk(owner(uid, 0o775)), 2, false, &sharing));
2051 }
2052
2053 #[cfg(target_os = "linux")]
2058 #[test]
2059 fn a_parent_swapped_after_the_path_resolved_is_refused() {
2060 use std::os::unix::fs::MetadataExt as _;
2061 let base = tempfile::Builder::new()
2062 .prefix(".ljos-swap-")
2063 .tempdir_in(own_home().expect("this user's passwd entry names a home"))
2064 .unwrap();
2065 let home = std::fs::canonicalize(base.path()).unwrap().join("u");
2066 let person = Person::new(&SystemAccounts);
2067 let store = PathBuf::from(NIX_STORE);
2068 let versions = home.join(".local/share/claude/versions");
2069 let other = home.join("other");
2070 for dir in [&versions, &other] {
2071 std::fs::create_dir_all(dir).unwrap();
2072 std::fs::write(dir.join("2.1.3"), "").unwrap();
2073 }
2074 let id = |path: &Path| {
2075 let meta = std::fs::metadata(path).unwrap();
2076 (meta.dev(), meta.ino())
2077 };
2078 let file = versions.join("2.1.3");
2079 let (running, planted) = (id(&file), id(&other.join("2.1.3")));
2080 let runner = |expect| runner_at(&file, Some(&home), &store, &person, expect);
2081 assert_eq!(runner(running), Some("claude"));
2082
2083 std::fs::rename(&versions, home.join("held")).unwrap();
2084 std::os::unix::fs::symlink(&other, &versions).unwrap();
2085 assert_eq!(runner(running), None);
2086 assert_eq!(runner(planted), None);
2087
2088 std::fs::remove_file(&versions).unwrap();
2089 std::fs::rename(&other, &versions).unwrap();
2090 assert_eq!(runner(running), None);
2091 }
2092
2093 #[cfg(target_os = "linux")]
2097 #[test]
2098 fn an_executable_that_cannot_be_read_is_no_runner() {
2099 use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _};
2100 let base = tempfile::Builder::new()
2101 .prefix(".ljos-exe-")
2102 .tempdir_in(own_home().expect("this user's passwd entry names a home"))
2103 .unwrap();
2104 let home = base.path().join("u");
2105 let bin = home.join(".local/bin");
2106 std::fs::create_dir_all(&bin).unwrap();
2107 let claude = bin.join("claude");
2108 std::fs::write(&claude, "").unwrap();
2109 std::fs::set_permissions(&claude, std::fs::Permissions::from_mode(0o755)).unwrap();
2110 let meta = std::fs::metadata(&claude).unwrap();
2111 let person = Person::new(&SystemAccounts);
2112 let store = PathBuf::from(NIX_STORE);
2113 let runner = |running| runner_from(&claude, running, || None, Some(&home), &store, &person);
2114 assert_eq!(runner(Some((meta.dev(), meta.ino()))), Some("claude"));
2115 assert_eq!(runner(None), None);
2116 }
2117
2118 #[cfg(target_os = "linux")]
2123 #[test]
2124 fn a_script_names_the_runner_only_for_an_installed_runtime() {
2125 use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _};
2126 let base = tempfile::Builder::new()
2127 .prefix(".ljos-runtime-")
2128 .tempdir_in(own_home().expect("this user's passwd entry names a home"))
2129 .unwrap();
2130 let home = base.path().join("u");
2131 let away = tempfile::tempdir().unwrap();
2132 let program = |dir: &Path, name: &str| {
2133 std::fs::create_dir_all(dir).unwrap();
2134 let path = dir.join(name);
2135 std::fs::write(&path, "").unwrap();
2136 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
2137 path
2138 };
2139 let id = |path: &Path| {
2140 let meta = std::fs::metadata(path).unwrap();
2141 (meta.dev(), meta.ino())
2142 };
2143 let cli = program(
2144 &home.join(".npm-global/lib/node_modules/@anthropic-ai/claude-code"),
2145 "cli.js",
2146 );
2147 let bin = home.join(".local/bin");
2148 let node = program(&bin, "node");
2149 let person = Person::new(&SystemAccounts);
2150 let store = PathBuf::from(NIX_STORE);
2151 let runner = |exe: &Path, running| {
2152 runner_from(
2153 exe,
2154 Some(running),
2155 || Some(cli.clone()),
2156 Some(&home),
2157 &store,
2158 &person,
2159 )
2160 };
2161 assert_eq!(runner(&node, id(&node)), Some("claude"));
2162 assert_eq!(runner(&node, id(&cli)), None);
2163 for planted in [
2164 program(&bin, "node-v20"),
2165 program(&bin, "nodejs_modules"),
2166 program(away.path(), "node"),
2167 ] {
2168 assert_eq!(
2169 runner(&planted, id(&planted)),
2170 None,
2171 "{}",
2172 planted.display()
2173 );
2174 }
2175 std::fs::set_permissions(&node, std::fs::Permissions::from_mode(0o757)).unwrap();
2176 assert_eq!(runner(&node, id(&node)), None);
2177 }
2178
2179 #[cfg(target_os = "linux")]
2185 #[test]
2186 fn a_nix_entry_is_a_runner_only_in_a_store_nix_keeps() {
2187 use std::os::unix::fs::PermissionsExt as _;
2188 let base = tempfile::Builder::new()
2189 .prefix(".ljos-store-")
2190 .tempdir_in(own_home().expect("this user's passwd entry names a home"))
2191 .unwrap();
2192 let store = base.path().join("store");
2193 let bin = store.join("0c9yapqd0b8y4mj5xj7mrpmhy3l5ykh8-claude-code-2.0.1/bin");
2194 std::fs::create_dir_all(&bin).unwrap();
2195 let claude = bin.join("claude");
2196 std::fs::write(&claude, "").unwrap();
2197 std::fs::set_permissions(&claude, std::fs::Permissions::from_mode(0o755)).unwrap();
2198 let person = Person::new(&SystemAccounts);
2199 let set = |mode| {
2200 std::fs::set_permissions(&store, std::fs::Permissions::from_mode(mode)).unwrap();
2201 };
2202 let runner = || runner_here(&claude, None, &store, &person);
2203
2204 set(0o755);
2205 assert_eq!(runner(), Some("claude"));
2206 for mode in [0o775, 0o757, 0o777, 0o1775, 0o1777] {
2207 set(mode);
2208 assert_eq!(runner(), None, "{mode:o}");
2209 }
2210 set(0o755);
2211
2212 let uid = person.uid;
2213 let nixbld = person.gid + 1;
2214 let owner = |uid, gid, mode| Owner { uid, gid, mode };
2215 let outsider = Person {
2216 groups: Some(vec![]),
2217 ..Person::new(&SystemAccounts)
2218 };
2219 let builder = Person {
2220 groups: Some(vec![nixbld]),
2221 ..Person::new(&SystemAccounts)
2222 };
2223 let unread = Person {
2224 groups: None,
2225 ..Person::new(&SystemAccounts)
2226 };
2227 let root_entry = owner(0, 0, 0o555);
2228 let own_entry = owner(uid, uid, 0o555);
2229 for (store, entry, who, want) in [
2230 (owner(0, nixbld, 0o1775), root_entry, &outsider, true),
2231 (owner(0, 0, 0o755), root_entry, &outsider, true),
2232 (owner(0, nixbld, 0o1775), own_entry, &outsider, false),
2233 (owner(0, nixbld, 0o775), root_entry, &outsider, false),
2234 (owner(0, nixbld, 0o1775), root_entry, &builder, false),
2235 (owner(0, nixbld, 0o1775), root_entry, &unread, false),
2236 (owner(0, 0, 0o1777), root_entry, &outsider, false),
2237 (owner(uid, uid, 0o755), own_entry, &outsider, true),
2238 (owner(uid, uid, 0o755), root_entry, &outsider, true),
2239 (owner(uid, uid, 0o775), own_entry, &outsider, false),
2240 (owner(uid, uid, 0o1777), own_entry, &outsider, false),
2241 (
2242 owner(uid, uid, 0o755),
2243 owner(uid + 1, 0, 0o555),
2244 &outsider,
2245 false,
2246 ),
2247 (owner(uid + 1, 0, 0o755), own_entry, &outsider, false),
2248 ] {
2249 assert_eq!(
2250 store_keeps(store, entry, who),
2251 want,
2252 "{}:{} {:o}",
2253 store.uid,
2254 store.gid,
2255 store.mode
2256 );
2257 }
2258 }
2259
2260 #[cfg(target_os = "linux")]
2263 struct FakeAccounts {
2264 user: Option<&'static str>,
2265 group: Option<&'static str>,
2266 members: &'static [&'static str],
2267 primary: Option<Vec<u32>>,
2268 }
2269
2270 #[cfg(target_os = "linux")]
2271 impl Accounts for FakeAccounts {
2272 fn user_name(&self, _uid: u32) -> Option<std::ffi::OsString> {
2273 self.user.map(Into::into)
2274 }
2275
2276 fn group(&self, _gid: u32) -> Option<GroupEntry> {
2277 self.group.map(|name| GroupEntry {
2278 name: name.into(),
2279 members: self.members.iter().map(Into::into).collect(),
2280 })
2281 }
2282
2283 fn primary_members(&self, _gid: u32) -> Option<Vec<u32>> {
2284 self.primary.clone()
2285 }
2286 }
2287
2288 #[cfg(target_os = "linux")]
2293 #[test]
2294 fn a_group_write_counts_only_from_a_group_the_person_has_alone() {
2295 use std::os::unix::fs::PermissionsExt as _;
2296 let uid = unsafe { libc::getuid() };
2299 let alone = |members, primary| FakeAccounts {
2300 user: Some("u"),
2301 group: Some("u"),
2302 members,
2303 primary: Some(primary),
2304 };
2305 let shared = [
2306 FakeAccounts {
2307 group: Some("staff"),
2308 ..alone(&[], vec![uid])
2309 },
2310 FakeAccounts {
2311 group: None,
2312 ..alone(&[], vec![uid])
2313 },
2314 alone(&["u", "v"], vec![uid]),
2315 alone(&["v"], vec![uid]),
2316 alone(&[], vec![uid, uid + 1]),
2317 FakeAccounts {
2318 user: None,
2319 ..alone(&[], vec![uid])
2320 },
2321 FakeAccounts {
2322 primary: None,
2323 ..alone(&[], vec![uid])
2324 },
2325 ];
2326
2327 let base = tempfile::Builder::new()
2328 .prefix(".ljos-group-")
2329 .tempdir_in(own_home().expect("this user's passwd entry names a home"))
2330 .unwrap();
2331 let home = base.path().join("u");
2332 let bin = home.join(".local/bin");
2333 std::fs::create_dir_all(&bin).unwrap();
2334 let claude = bin.join("claude");
2335 std::fs::write(&claude, "").unwrap();
2336 std::fs::set_permissions(&claude, std::fs::Permissions::from_mode(0o775)).unwrap();
2337 let store = PathBuf::from(NIX_STORE);
2338
2339 for accounts in [alone(&[], vec![uid]), alone(&["u"], vec![uid, uid])] {
2340 assert!(group_is_private(&accounts, uid, 0));
2341 let person = Person::new(&accounts);
2342 assert_eq!(
2343 runner_here(&claude, Some(&home), &store, &person),
2344 Some("claude")
2345 );
2346 }
2347 for accounts in &shared {
2348 assert!(!group_is_private(accounts, uid, 0));
2349 let person = Person::new(accounts);
2350 assert_eq!(runner_here(&claude, Some(&home), &store, &person), None);
2351 }
2352 }
2353
2354 #[test]
2358 fn a_primary_group_is_listed_only_from_a_passwd_file_read_whole() {
2359 let etc = tempfile::tempdir().unwrap();
2360 let write = |name: &str, text: &str| std::fs::write(etc.path().join(name), text).unwrap();
2361 let passwd = "root:x:0:0::/root:/bin/sh\nu:x:1000:1000::/home/u:/bin/sh\n";
2362 write("passwd", passwd);
2363 assert_eq!(primary_in_files(etc.path(), 1000), None, "no nsswitch.conf");
2364 for conf in [
2365 "passwd: files systemd\n",
2366 "passwd: sss files\n",
2367 "passwd: compat\n",
2368 "passwd: files [NOTFOUND=return] ldap\n",
2369 "passwd: files\npasswd: files ldap\n",
2370 "group: files\n",
2371 ] {
2372 write("nsswitch.conf", conf);
2373 assert_eq!(primary_in_files(etc.path(), 1000), None, "{conf}");
2374 }
2375 write(
2376 "nsswitch.conf",
2377 "# local\npasswd: files # only\ngroup: files\n",
2378 );
2379 assert_eq!(primary_in_files(etc.path(), 1000), Some(vec![1000]));
2380 assert_eq!(primary_in_files(etc.path(), 7), Some(vec![]));
2381 for extra in [
2382 "+@admins\n",
2383 "-v\n",
2384 "v:x:1001\n",
2385 "v:x:one:1000::/home/v:/bin/sh\n",
2386 ] {
2387 write("passwd", &format!("{passwd}{extra}"));
2388 assert_eq!(primary_in_files(etc.path(), 1000), None, "{extra}");
2389 }
2390 write(
2391 "passwd",
2392 &format!("{passwd}\n# kept\nv:x:1001:1000::/home/v:/bin/sh\n"),
2393 );
2394 assert_eq!(primary_in_files(etc.path(), 1000), Some(vec![1000, 1001]));
2395 std::fs::remove_file(etc.path().join("passwd")).unwrap();
2396 assert_eq!(primary_in_files(etc.path(), 1000), None);
2397 }
2398
2399 #[test]
2403 fn a_group_is_read_only_from_one_line_of_a_group_file_read_whole() {
2404 let etc = tempfile::tempdir().unwrap();
2405 let write = |name: &str, text: &str| std::fs::write(etc.path().join(name), text).unwrap();
2406 let group = "root:x:0:\nu:x:1000:\nstaff:x:50:u,v\n";
2407 write("group", group);
2408 let read = |gid| {
2409 group_in_files(etc.path(), gid).map(|g| {
2410 let members: Vec<String> = g
2411 .members
2412 .iter()
2413 .map(|m| m.to_string_lossy().into_owned())
2414 .collect();
2415 (g.name.to_string_lossy().into_owned(), members)
2416 })
2417 };
2418 assert_eq!(read(1000), None, "no nsswitch.conf");
2419 for conf in ["group: files systemd\n", "group: sss\n", "passwd: files\n"] {
2420 write("nsswitch.conf", conf);
2421 assert_eq!(read(1000), None, "{conf}");
2422 }
2423 write("nsswitch.conf", "passwd: files\ngroup: files\n");
2424 assert_eq!(read(1000), Some(("u".into(), vec![])));
2425 assert_eq!(
2426 read(50),
2427 Some(("staff".into(), vec!["u".into(), "v".into()]))
2428 );
2429 assert_eq!(read(7), None);
2430 for extra in ["shadow:x:1000:v\n", "+:::\n", "w:x:1000\n", "w:x:ten:\n"] {
2431 write("group", &format!("{group}{extra}"));
2432 assert_eq!(read(1000), None, "{extra}");
2433 }
2434 }
2435
2436 #[test]
2441 fn a_young_runner_in_a_recorded_tool_call_is_refused() {
2442 let _guard = crate::test_env_lock();
2443 let opener = Proc {
2444 pid: 1,
2445 start: 1,
2446 comm: "claude".into(),
2447 };
2448 let young = Proc {
2449 pid: 999_999,
2450 start: u64::MAX,
2451 comm: "claude".into(),
2452 };
2453 let old = Proc {
2454 pid: 42,
2455 start: 0,
2456 comm: "claude".into(),
2457 };
2458 assert!(confirm_runner(&young).is_ok(), "no record yet");
2459 note_tool_call(&opener).unwrap();
2460 let why = confirm_runner(&young).unwrap_err();
2461 assert!(why.contains("tool call"), "{why}");
2462 assert!(confirm_runner(&opener).is_ok(), "the opener recorded it");
2463 assert!(confirm_runner(&old).is_ok(), "older than the record");
2464 std::fs::write(marks_path(), "nope\n").unwrap();
2465 let why = confirm_runner(&old).unwrap_err();
2466 assert!(why.contains("corrupt"), "{why}");
2467 }
2468
2469 #[test]
2470 fn a_tool_calls_variables_mark_a_runner_started_inside_one() {
2471 let env = |k: &str, v: &str| vec![(k.to_string(), v.to_string())];
2472 assert_eq!(tool_call_stamp(&env("CLAUDECODE", "1")), Some("CLAUDECODE"));
2473 assert_eq!(
2474 tool_call_stamp(&env("CURSOR_AGENT", "1")),
2475 Some("CURSOR_AGENT")
2476 );
2477 assert!(tool_call_stamp(&env("CURSOR_CONVERSATION_ID", "0199a1b2-c3d4")).is_some());
2478 assert_eq!(tool_call_stamp(&env("LJOS_SESSION_ID", "mine-0001")), None);
2479 assert_eq!(tool_call_stamp(&env("HOME", "/home/u")), None);
2480 }
2481
2482 #[test]
2483 fn only_a_bare_hook_command_is_the_runners_hook_shell() {
2484 for cmd in [
2485 "ljos hook",
2486 "/home/u/.cargo/bin/ljos hook --runner codex",
2487 "ljos hook --event SessionStart",
2488 "\"${CLAUDE_PLUGIN_ROOT}/bin/ljos-plugin\" ljos hook",
2489 "\"${CURSOR_PLUGIN_ROOT}/bin/ljos-plugin\" ljos hook",
2490 ] {
2491 assert!(bare_hook_command(cmd), "{cmd}");
2492 }
2493 for cmd in [
2494 "ljos hook < plant.json",
2495 "cat plant.json | ljos hook",
2496 "ljos hook <<'EOF'\n{}\nEOF",
2497 "ljos status; ljos hook",
2498 "ljos hook $(cat p)",
2499 "python3 plant.py",
2500 "ljos remember a b c d",
2501 "ljos-plugin hook",
2502 "bash -c 'ljos hook'",
2503 ] {
2504 assert!(!bare_hook_command(cmd), "{cmd}");
2505 }
2506 }
2507
2508 #[test]
2511 fn a_hook_this_test_runs_has_no_runner() {
2512 let why = hook_runner().unwrap_err();
2513 assert!(why.contains("not a runner's own program"), "{why}");
2514 }
2515
2516 fn named(pid: u32, comm: &str) -> Proc {
2517 Proc {
2518 pid,
2519 start: u64::from(pid) * 10,
2520 comm: comm.into(),
2521 }
2522 }
2523
2524 #[test]
2525 fn this_process_has_live_ancestors_and_a_dead_pid_does_not_match() {
2526 let chain = ancestors();
2527 assert!(!chain.is_empty(), "the test runner is above this process");
2528 assert!(chain.iter().all(alive));
2529 let parent = &chain[0];
2530 assert!(token_alive(&token(parent)));
2531 let reused = Proc {
2532 start: parent.start + 1,
2533 ..parent.clone()
2534 };
2535 assert!(!alive(&reused));
2536 assert!(!token_alive(&format!(
2537 "other-boot:{}:{}",
2538 parent.pid, parent.start
2539 )));
2540 }
2541
2542 #[test]
2543 fn a_reaped_child_is_not_alive() {
2544 let mut child = std::process::Command::new("sleep")
2545 .arg("30")
2546 .spawn()
2547 .unwrap();
2548 let pid = child.id();
2549 let p = of(pid).unwrap();
2550 assert!(alive(&p));
2551 assert_eq!(above(pid).first().map(|q| q.pid), Some(std::process::id()));
2552 child.kill().unwrap();
2553 child.wait().unwrap();
2554 assert!(!alive(&p));
2555 }
2556
2557 #[test]
2558 fn a_child_is_read_with_the_environment_it_was_started_with() {
2559 let mut child = std::process::Command::new("sleep")
2560 .arg("30")
2561 .env("LJOS_PROC_TREE_PROBE", "started-with")
2562 .spawn()
2563 .unwrap();
2564 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
2566 let env = loop {
2567 let env = environ(child.id()).unwrap_or_default();
2568 if env.iter().any(|(k, _)| k == "LJOS_PROC_TREE_PROBE")
2569 || std::time::Instant::now() > deadline
2570 {
2571 break env;
2572 }
2573 std::thread::sleep(std::time::Duration::from_millis(10));
2574 };
2575 child.kill().unwrap();
2576 child.wait().unwrap();
2577 assert!(
2578 env.iter()
2579 .any(|(k, v)| k == "LJOS_PROC_TREE_PROBE" && v == "started-with"),
2580 "{env:?}"
2581 );
2582 }
2583
2584 #[test]
2585 fn the_calling_shell_is_the_outermost_of_a_nested_run() {
2586 let harness = named(10, "claude");
2587 let chain = [
2588 named(3, "timeout"),
2589 named(4, "sh"),
2590 named(5, "bash"),
2591 harness.clone(),
2592 ];
2593 assert_eq!(calling_shell(&chain), Some(&chain[2]));
2594 let chain = [
2595 named(4, "sh"),
2596 named(6, "env"),
2597 named(5, "bash"),
2598 named(7, "node"),
2599 named(8, "bash"),
2600 ];
2601 assert_eq!(calling_shell(&chain), Some(&chain[2]));
2602 assert_eq!(calling_shell(&[harness.clone(), named(5, "bash")]), None);
2603 assert_eq!(calling_shell(&[named(3, "env"), harness.clone()]), None);
2604 assert_eq!(calling_shell(&[]), None);
2605 let launched = [named(3, "python3"), named(4, "sh"), named(5, "bash")];
2606 assert_eq!(calling_shell(&launched), None);
2607 assert_eq!(cast_shell(&launched, None), Some(&launched[2]));
2608 assert_eq!(cast_shell(&[harness, named(5, "bash")], None), None);
2609 assert_eq!(
2610 cast_shell(&[named(9, "cargo"), named(5, "bash")], None),
2611 None
2612 );
2613 assert_eq!(cast_shell(&[], None), None);
2614 }
2615
2616 #[test]
2617 fn the_runner_is_above_the_shells_and_wrappers_it_started() {
2618 let chain = [
2619 named(4, "sh"),
2620 named(3, "timeout"),
2621 named(5, "bash"),
2622 named(10, "claude"),
2623 named(2, "tmux"),
2624 ];
2625 assert_eq!(runner(&chain).map(|p| p.pid), Some(10));
2626 assert_eq!(runner(&chain[3..]).map(|p| p.pid), Some(10));
2627 let shells = [named(4, "sh"), named(5, "bash")];
2628 assert_eq!(runner(&shells).map(|p| p.pid), Some(5));
2629 assert_eq!(runner(&[]), None);
2630 }
2631
2632 #[test]
2633 fn a_session_above_the_hook_is_never_its_runner() {
2634 for session in ["tmux", "tmux: server", "gnome-terminal-", "systemd", "sshd"] {
2635 let chain = [named(4, "sh"), named(5, "bash"), named(2, session)];
2636 assert_eq!(runner(&chain).map(|p| p.pid), Some(5), "{session}");
2637 let chain = [named(4, "sh"), named(2, session), named(10, "claude")];
2638 assert_eq!(runner(&chain).map(|p| p.pid), Some(4), "{session}");
2639 assert_eq!(runner(&[named(2, session), named(10, "claude")]), None);
2640 }
2641 let chain = [named(4, "sh"), named(7, "git"), named(2, "kitty")];
2642 assert_eq!(runner(&chain).map(|p| p.pid), Some(7));
2643 }
2644
2645 fn fake(n: u32, comm: &str) -> Proc {
2648 named((1 << 22) + n, comm)
2649 }
2650
2651 #[test]
2652 fn a_harness_outranks_a_runtime_that_only_started_the_hook() {
2653 let chain = [
2654 fake(1, "sh"),
2655 fake(2, "uv"),
2656 fake(3, "uv"),
2657 fake(4, "claude"),
2658 ];
2659 assert_eq!(runner(&chain), Some(&chain[3]));
2660 let chain = [fake(1, "sh"), fake(2, "node"), fake(3, "claude")];
2661 assert_eq!(runner(&chain), Some(&chain[2]));
2662 let chain = [
2663 fake(1, "sh"),
2664 fake(2, "npx"),
2665 fake(3, "bash"),
2666 fake(4, "cursor-agent"),
2667 fake(5, "tmux"),
2668 ];
2669 assert_eq!(runner(&chain), Some(&chain[3]));
2670 let chain = [
2671 fake(1, "sh"),
2672 fake(2, "uv"),
2673 fake(3, "codex"),
2674 fake(4, "agy"),
2675 ];
2676 assert_eq!(runner(&chain), Some(&chain[2]), "the nearest harness");
2677 }
2678
2679 #[test]
2680 fn a_node_harness_with_no_known_harness_above_is_the_runner() {
2681 let chain = [
2682 fake(1, "sh"),
2683 fake(2, "node"),
2684 fake(3, "bash"),
2685 fake(4, "tmux"),
2686 ];
2687 assert_eq!(runner(&chain), Some(&chain[1]));
2688 assert_eq!(runner(&chain[..2]), Some(&chain[1]));
2689 let chain = [
2691 fake(1, "sh"),
2692 fake(2, "node"),
2693 fake(3, "tmux"),
2694 fake(4, "claude"),
2695 ];
2696 assert_eq!(runner(&chain), Some(&chain[1]));
2697 }
2698
2699 #[test]
2700 fn pid_1_is_the_runner_only_as_a_harness() {
2701 let chain = [fake(1, "sh"), fake(2, "uv"), named(1, "claude")];
2702 assert_eq!(runner(&chain), Some(&chain[2]), "a container's harness");
2703 let chain = [fake(1, "sh"), fake(2, "bash"), named(1, "tini")];
2704 assert_eq!(runner(&chain), Some(&chain[1]), "an init is no runner");
2705 let chain = [fake(1, "sh"), fake(2, "node"), named(1, "dumb-init")];
2706 assert_eq!(runner(&chain), Some(&chain[1]));
2707 assert_eq!(runner(&[named(1, "tini")]), None);
2708 }
2709
2710 #[test]
2711 fn the_walk_reads_up_to_a_process_with_no_parent() {
2712 let chain = ancestors();
2713 let top = chain.last().expect("the test runner is above this process");
2714 let top = stat(top.pid).expect("the top process was just read");
2715 let hidden_parent = top.ppid != 0 && stat(top.ppid).is_none();
2719 assert!(
2720 chain.len() == DEPTH || top.ppid == 0 || hidden_parent,
2721 "{chain:?}"
2722 );
2723 }
2724}