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
36fn stat(pid: u32) -> Option<Stat> {
37 let raw = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
38 let open = raw.find('(')?;
39 let close = raw.rfind(')')?;
40 let comm = raw.get(open + 1..close)?.to_string();
41 let rest: Vec<&str> = raw.get(close + 1..)?.split_whitespace().collect();
43 Some(Stat {
44 comm,
45 dead: matches!(*rest.first()?, "Z" | "X" | "x"),
46 ppid: rest.get(1)?.parse().ok()?,
47 start: rest.get(19)?.parse().ok()?,
48 })
49}
50
51#[must_use]
53pub fn of(pid: u32) -> Option<Proc> {
54 stat(pid).map(|s| Proc {
55 pid,
56 start: s.start,
57 comm: s.comm,
58 })
59}
60
61#[must_use]
64pub fn above(pid: u32) -> Vec<Proc> {
65 let mut out = Vec::new();
66 let mut next = stat(pid).map(|s| s.ppid);
67 while let Some(p) = next.filter(|&p| p > 0 && out.len() < DEPTH) {
68 let Some(s) = stat(p) else {
69 break;
70 };
71 next = Some(s.ppid);
72 out.push(Proc {
73 pid: p,
74 start: s.start,
75 comm: s.comm,
76 });
77 }
78 out
79}
80
81#[must_use]
83pub fn ancestors() -> Vec<Proc> {
84 above(std::process::id())
85}
86
87#[cfg(not(target_os = "macos"))]
89fn parent_and_name(pid: u32) -> Option<(u32, String)> {
90 stat(pid).map(|s| (s.ppid, s.comm))
91}
92
93#[cfg(target_os = "macos")]
94fn parent_and_name(pid: u32) -> Option<(u32, String)> {
95 let mut info: libc::proc_bsdinfo = unsafe { std::mem::zeroed() };
96 let size = libc::c_int::try_from(std::mem::size_of::<libc::proc_bsdinfo>()).ok()?;
97 let n = unsafe {
99 libc::proc_pidinfo(
100 libc::c_int::try_from(pid).ok()?,
101 libc::PROC_PIDTBSDINFO,
102 0,
103 std::ptr::addr_of_mut!(info).cast(),
104 size,
105 )
106 };
107 if n != size {
108 return None;
109 }
110 let comm: Vec<u8> = info
111 .pbi_comm
112 .iter()
113 .take_while(|c| **c != 0)
114 .map(|c| c.to_ne_bytes()[0])
115 .collect();
116 Some((info.pbi_ppid, String::from_utf8_lossy(&comm).into_owned()))
117}
118
119fn split_environ(raw: &[u8]) -> Vec<(String, String)> {
120 raw.split(|b| *b == 0)
121 .filter_map(|kv| {
122 let kv = String::from_utf8_lossy(kv);
123 let (k, v) = kv.split_once('=')?;
124 Some((k.to_string(), v.to_string()))
125 })
126 .collect()
127}
128
129#[cfg(not(target_os = "macos"))]
132#[must_use]
133pub fn environ(pid: u32) -> Option<Vec<(String, String)>> {
134 std::fs::read(format!("/proc/{pid}/environ"))
135 .ok()
136 .map(|raw| split_environ(&raw))
137}
138
139#[cfg(target_os = "macos")]
140#[must_use]
141pub fn environ(pid: u32) -> Option<Vec<(String, String)>> {
142 let mut mib = [
143 libc::CTL_KERN,
144 libc::KERN_PROCARGS2,
145 libc::c_int::try_from(pid).ok()?,
146 ];
147 let mut size: libc::size_t = 0;
148 let asked = unsafe {
150 libc::sysctl(
151 mib.as_mut_ptr(),
152 3,
153 std::ptr::null_mut(),
154 &mut size,
155 std::ptr::null_mut(),
156 0,
157 )
158 };
159 if asked != 0 || size < 4 {
160 return None;
161 }
162 let mut buf = vec![0u8; size];
163 let read = unsafe {
165 libc::sysctl(
166 mib.as_mut_ptr(),
167 3,
168 buf.as_mut_ptr().cast(),
169 &mut size,
170 std::ptr::null_mut(),
171 0,
172 )
173 };
174 if read != 0 {
175 return None;
176 }
177 buf.truncate(size);
178 let argc = usize::try_from(i32::from_ne_bytes(buf.get(..4)?.try_into().ok()?)).ok()?;
181 let mut words = buf[4..]
182 .split(|b| *b == 0)
183 .skip(1)
184 .skip_while(|w| w.is_empty());
185 for _ in 0..argc {
186 words.next()?;
187 }
188 let env: Vec<u8> = words
189 .take_while(|w| !w.is_empty())
190 .flat_map(|w| w.iter().copied().chain([0]))
191 .collect();
192 Some(split_environ(&env))
193}
194
195#[must_use]
200pub fn started_by_runner() -> Option<u32> {
201 let mut child = std::process::id();
202 for _ in 0..DEPTH {
203 let (ppid, _) = parent_and_name(child)?;
204 if ppid == 0 {
205 return None;
206 }
207 let (_, comm) = parent_and_name(ppid)?;
208 if crate::is_harness_process(ppid, &comm) {
209 return Some(child);
210 }
211 if crate::is_session(&comm) || ppid == 1 {
212 return None;
213 }
214 child = ppid;
215 }
216 None
217}
218
219#[must_use]
222pub fn alive(p: &Proc) -> bool {
223 stat(p.pid).is_some_and(|s| s.start == p.start && !s.dead)
224}
225
226#[must_use]
229pub fn boot() -> &'static str {
230 static BOOT: std::sync::OnceLock<String> = std::sync::OnceLock::new();
231 BOOT.get_or_init(|| {
232 std::fs::read_to_string("/proc/sys/kernel/random/boot_id")
233 .ok()
234 .map(|s| {
235 s.trim()
236 .chars()
237 .filter(char::is_ascii_hexdigit)
238 .take(12)
239 .collect()
240 })
241 .filter(|s: &String| !s.is_empty())
242 .unwrap_or_else(|| "-".into())
243 })
244}
245
246#[must_use]
248pub fn token(p: &Proc) -> String {
249 format!("{}:{}:{}", boot(), p.pid, p.start)
250}
251
252#[must_use]
254pub fn token_alive(token: &str) -> bool {
255 let mut parts = token.splitn(3, ':');
256 let (Some(at), Some(pid), Some(start)) = (parts.next(), parts.next(), parts.next()) else {
257 return false;
258 };
259 let (Ok(pid), Ok(start)) = (pid.parse(), start.parse()) else {
260 return false;
261 };
262 at == boot()
263 && alive(&Proc {
264 pid,
265 start,
266 comm: String::new(),
267 })
268}
269
270const SHELLS: &[&str] = &[
271 "sh", "bash", "dash", "zsh", "ksh", "mksh", "ash", "yash", "fish", "tcsh", "csh", "busybox",
272];
273
274const WRAPPERS: &[&str] = &[
277 "env", "nice", "nohup", "timeout", "stdbuf", "setsid", "ionice", "chrt", "taskset", "time",
278 "flock", "sudo", "doas", "xargs",
279];
280
281fn is_shell(p: &Proc) -> bool {
282 SHELLS.contains(&p.comm.as_str())
283}
284
285fn is_wrapper(p: &Proc) -> bool {
286 WRAPPERS.contains(&p.comm.as_str())
287}
288
289fn is_harness(p: &Proc) -> bool {
290 crate::is_harness_process(p.pid, &p.comm)
291}
292
293#[must_use]
298pub fn calling_shell(chain: &[Proc]) -> Option<&Proc> {
299 let mut rest = chain.iter().skip_while(|p| is_wrapper(p));
300 let mut top = rest.next().filter(|p| is_shell(p))?;
301 for p in rest {
302 if is_shell(p) {
303 top = p;
304 } else if !is_wrapper(p) {
305 break;
306 }
307 }
308 Some(top)
309}
310
311fn ends_the_walk(p: &Proc) -> bool {
316 crate::is_session(&p.comm) || (p.pid == 1 && !is_harness(p))
317}
318
319#[must_use]
328pub fn runner(chain: &[Proc]) -> Option<&Proc> {
329 let own = chain.iter().take_while(|p| !ends_the_walk(p));
330 own.clone()
331 .find(|p| is_harness(p))
332 .or_else(|| own.clone().find(|p| !is_shell(p) && !is_wrapper(p)))
333 .or_else(|| own.last())
334}
335
336const HOOK_RUNNERS: &[(&str, &str)] = &[
339 ("claude", "claude"),
340 ("claude-code", "claude"),
341 ("codex", "codex"),
342 ("cursor-agent", "cursor-agent"),
343 ("cursor", "cursor"),
344 ("grok", "grok"),
345 ("opencode", "opencode"),
346 ("gemini", "gemini"),
347 ("agy", "agy"),
348 ("antigravity", "agy"),
349];
350
351const RUNTIMES: &[&str] = &["node", "nodejs", "bun", "deno"];
354
355fn component_stem(component: &str) -> &str {
357 component
358 .strip_suffix(".js")
359 .or_else(|| component.strip_suffix(".mjs"))
360 .or_else(|| component.strip_suffix(".cjs"))
361 .unwrap_or(component)
362}
363
364fn layout_component(component: &str) -> bool {
366 let name = component_stem(component);
367 crate::version_like(name)
368 || crate::PLACES.contains(&name)
369 || crate::ENTRY_STEMS.contains(&name)
370 || RUNTIMES.contains(&name)
371 || matches!(name, "vendor" | "share")
372 || name.starts_with('@')
373 || name.contains("unknown-linux")
374 || matches!(name, "x86_64" | "aarch64" | "arm64")
375 || name.starts_with("armv")
376}
377
378fn hook_name(component: &str) -> Option<&'static str> {
380 let name = component_stem(component);
381 let name = name.split_once("-x86_64").map_or(name, |(head, _)| head);
382 let name = name.split_once("-aarch64").map_or(name, |(head, _)| head);
383 HOOK_RUNNERS
384 .iter()
385 .find(|(n, _)| *n == name)
386 .map(|(_, runner)| *runner)
387}
388
389#[must_use]
392pub fn own_home() -> Option<std::path::PathBuf> {
393 use std::os::unix::ffi::OsStrExt as _;
394 let mut buf = vec![0u8; 1024];
395 loop {
396 let mut entry: libc::passwd = unsafe { std::mem::zeroed() };
398 let mut found: *mut libc::passwd = std::ptr::null_mut();
399 let rc = unsafe {
402 libc::getpwuid_r(
403 libc::getuid(),
404 &mut entry,
405 buf.as_mut_ptr().cast(),
406 buf.len(),
407 &mut found,
408 )
409 };
410 if rc == libc::ERANGE && buf.len() < 1 << 20 {
411 buf.resize(buf.len() * 2, 0);
412 continue;
413 }
414 if rc != 0 || found.is_null() || entry.pw_dir.is_null() {
415 return None;
416 }
417 let dir = unsafe { std::ffi::CStr::from_ptr(entry.pw_dir) };
419 let home = std::path::PathBuf::from(std::ffi::OsStr::from_bytes(dir.to_bytes()));
420 return home.is_absolute().then_some(home);
421 }
422}
423
424const HOME_ROOTS: &[&str] = &[".local/bin", ".cargo/bin", ".npm-global"];
431
432const SYSTEM_ROOTS: &[&str] = &[
435 "/usr/bin",
436 "/bin",
437 "/usr/local/bin",
438 "/opt/homebrew/bin",
439 "/usr/lib/node_modules",
440 "/usr/local/lib/node_modules",
441 "/opt/homebrew/lib/node_modules",
442];
443
444fn install_roots(home: Option<&Path>) -> Vec<PathBuf> {
449 let mut roots: Vec<PathBuf> = SYSTEM_ROOTS.iter().map(PathBuf::from).collect();
450 let mut shares = vec![PathBuf::from("/usr/share")];
451 if let Some(home) = home {
452 roots.extend(HOME_ROOTS.iter().map(|root| home.join(root)));
453 shares.push(home.join(".local/share"));
454 }
455 for share in shares {
456 roots.extend(HOOK_RUNNERS.iter().map(|(name, _)| share.join(name)));
457 }
458 roots
459}
460
461const NIX_STORE: &str = "/nix/store";
463
464fn nix_entry(path: &Path, store: &Path) -> Option<PathBuf> {
467 let name = path.strip_prefix(store).ok()?.iter().next()?.to_str()?;
468 let (hash, rest) = name.split_at_checked(32)?;
469 let hashed = hash
470 .chars()
471 .all(|c| "0123456789abcdfghijklmnpqrsvwxyz".contains(c));
472 (hashed && rest.len() > 1 && rest.starts_with('-')).then(|| store.join(name))
473}
474
475fn runner_below(path: &Path, roots: &[PathBuf], store: &Path) -> Option<(&'static str, PathBuf)> {
480 let root = root_below(path, roots, store)?;
481 let parts = root
482 .file_name()
483 .into_iter()
484 .chain(path.strip_prefix(&root).ok()?.iter())
485 .map(std::ffi::OsStr::to_str)
486 .collect::<Option<Vec<_>>>()?;
487 let name = parts.iter().rev().find(|part| !layout_component(part))?;
488 Some((hook_name(name)?, root))
489}
490
491fn root_below(path: &Path, roots: &[PathBuf], store: &Path) -> Option<PathBuf> {
496 let plain = path.is_absolute()
497 && path.components().all(|c| {
498 matches!(
499 c,
500 std::path::Component::RootDir | std::path::Component::Normal(_)
501 )
502 });
503 if !plain || path.to_str()?.contains(" (deleted)") {
504 return None;
505 }
506 roots
507 .iter()
508 .cloned()
509 .chain(nix_entry(path, store))
510 .find(|root| path != root.as_path() && path.starts_with(root))
511}
512
513struct GroupEntry {
515 name: std::ffi::OsString,
516 members: Vec<std::ffi::OsString>,
517}
518
519trait Accounts {
523 fn user_name(&self, uid: u32) -> Option<std::ffi::OsString>;
524 fn group(&self, gid: u32) -> Option<GroupEntry>;
525 fn primary_members(&self, gid: u32) -> Option<Vec<u32>>;
527}
528
529struct SystemAccounts;
535
536fn with_buf<T>(mut lookup: impl FnMut(&mut [u8]) -> Result<Option<T>, i32>) -> Option<T> {
538 let mut buf = vec![0u8; 1024];
539 loop {
540 match lookup(&mut buf) {
541 Err(libc::ERANGE) if buf.len() < 1 << 20 => buf.resize(buf.len() * 2, 0),
542 Ok(found) => return found,
543 Err(_) => return None,
544 }
545 }
546}
547
548unsafe fn os_string(ptr: *const libc::c_char) -> std::ffi::OsString {
551 use std::os::unix::ffi::OsStrExt as _;
552 let bytes = unsafe { std::ffi::CStr::from_ptr(ptr) }.to_bytes();
554 std::ffi::OsStr::from_bytes(bytes).to_os_string()
555}
556
557impl Accounts for SystemAccounts {
558 fn user_name(&self, uid: u32) -> Option<std::ffi::OsString> {
559 with_buf(|buf| {
560 let mut entry: libc::passwd = unsafe { std::mem::zeroed() };
562 let mut found: *mut libc::passwd = std::ptr::null_mut();
563 let rc = unsafe {
566 libc::getpwuid_r(
567 uid,
568 &mut entry,
569 buf.as_mut_ptr().cast(),
570 buf.len(),
571 &mut found,
572 )
573 };
574 if rc != 0 {
575 return Err(rc);
576 }
577 Ok((!found.is_null() && !entry.pw_name.is_null())
579 .then(|| unsafe { os_string(entry.pw_name) }))
580 })
581 }
582
583 fn group(&self, gid: u32) -> Option<GroupEntry> {
584 group_in_files(Path::new(ETC), gid)
585 }
586
587 fn primary_members(&self, gid: u32) -> Option<Vec<u32>> {
588 primary_in_files(Path::new(ETC), gid)
589 }
590}
591
592const ETC: &str = "/etc";
594
595fn files_only(etc: &Path, db: &str) -> bool {
601 let Ok(conf) = std::fs::read_to_string(etc.join("nsswitch.conf")) else {
602 return false;
603 };
604 let mut lines = conf
605 .lines()
606 .map(|line| line.split('#').next().unwrap_or_default())
607 .filter_map(|line| line.split_once(':'))
608 .filter(|(name, _)| name.trim() == db);
609 let (Some((_, sources)), None) = (lines.next(), lines.next()) else {
610 return false;
611 };
612 sources.split_whitespace().eq(["files"])
613}
614
615fn group_in_files(etc: &Path, gid: u32) -> Option<GroupEntry> {
621 if !files_only(etc, "group") {
622 return None;
623 }
624 let text = std::fs::read_to_string(etc.join("group")).ok()?;
625 let mut found = None;
626 for line in text.lines() {
627 if line.is_empty() || line.starts_with('#') {
628 continue;
629 }
630 if line.starts_with(['+', '-']) {
631 return None;
632 }
633 let fields: Vec<&str> = line.split(':').collect();
634 let [name, _, id, members] = fields[..] else {
635 return None;
636 };
637 if id.parse::<u32>().ok()? != gid {
638 continue;
639 }
640 if found.is_some() {
641 return None;
642 }
643 found = Some(GroupEntry {
644 name: name.into(),
645 members: members
646 .split(',')
647 .filter(|m| !m.is_empty())
648 .map(Into::into)
649 .collect(),
650 });
651 }
652 found
653}
654
655fn primary_in_files(etc: &Path, gid: u32) -> Option<Vec<u32>> {
661 if !files_only(etc, "passwd") {
662 return None;
663 }
664 let text = std::fs::read_to_string(etc.join("passwd")).ok()?;
665 let mut uids = Vec::new();
666 for line in text.lines() {
667 if line.is_empty() || line.starts_with('#') {
668 continue;
669 }
670 if line.starts_with(['+', '-']) {
671 return None;
672 }
673 let fields: Vec<&str> = line.split(':').collect();
674 let [_, _, uid, primary, _, _, _] = fields[..] else {
675 return None;
676 };
677 let (uid, primary) = (uid.parse::<u32>().ok()?, primary.parse::<u32>().ok()?);
678 if primary == gid {
679 uids.push(uid);
680 }
681 }
682 Some(uids)
683}
684
685fn group_is_private(accounts: &dyn Accounts, uid: u32, gid: u32) -> bool {
688 let (Some(user), Some(group), Some(primary)) = (
689 accounts.user_name(uid),
690 accounts.group(gid),
691 accounts.primary_members(gid),
692 ) else {
693 return false;
694 };
695 group.name == user
696 && group.members.iter().all(|member| *member == user)
697 && primary.iter().all(|other| *other == uid)
698}
699
700struct Person<'a> {
705 uid: u32,
706 gid: u32,
707 groups: Option<Vec<u32>>,
708 accounts: &'a dyn Accounts,
709 private_group: std::cell::OnceCell<bool>,
710}
711
712impl<'a> Person<'a> {
713 fn new(accounts: &'a dyn Accounts) -> Self {
714 let (uid, gid) = unsafe { (libc::getuid(), libc::getgid()) };
717 Person {
718 uid,
719 gid,
720 groups: supplementary_groups(),
721 accounts,
722 private_group: std::cell::OnceCell::new(),
723 }
724 }
725
726 fn in_group(&self, gid: u32) -> bool {
729 gid == self.gid
730 || self
731 .groups
732 .as_ref()
733 .is_none_or(|groups| groups.contains(&gid))
734 }
735
736 fn private_group(&self) -> bool {
737 *self
738 .private_group
739 .get_or_init(|| group_is_private(self.accounts, self.uid, self.gid))
740 }
741}
742
743fn supplementary_groups() -> Option<Vec<u32>> {
745 let count = unsafe { libc::getgroups(0, std::ptr::null_mut()) };
747 let mut groups = vec![0; usize::try_from(count).ok()?];
748 let got = unsafe { libc::getgroups(count, groups.as_mut_ptr()) };
750 groups.truncate(usize::try_from(got).ok()?);
751 Some(groups)
752}
753
754#[derive(Clone, Copy)]
756struct Owner {
757 uid: u32,
758 gid: u32,
759 mode: u32,
760}
761
762impl From<&std::fs::Metadata> for Owner {
763 fn from(meta: &std::fs::Metadata) -> Self {
764 use std::os::unix::fs::MetadataExt as _;
765 Owner {
766 uid: meta.uid(),
767 gid: meta.gid(),
768 mode: meta.mode(),
769 }
770 }
771}
772
773#[cfg(target_os = "linux")]
782fn pinned_walk(file: &Path, expect: (u64, u64)) -> Option<Vec<Owner>> {
783 use std::os::fd::{AsRawFd as _, FromRawFd as _};
784 use std::os::unix::ffi::OsStrExt as _;
785 use std::os::unix::fs::MetadataExt as _;
786 let open = |dir: libc::c_int, name: &std::ffi::CStr| {
787 let fd = unsafe {
789 libc::openat(
790 dir,
791 name.as_ptr(),
792 libc::O_PATH | libc::O_NOFOLLOW | libc::O_CLOEXEC,
793 )
794 };
795 (fd >= 0).then(|| std::fs::File::from(unsafe { std::os::fd::OwnedFd::from_raw_fd(fd) }))
797 };
798 let mut parts = file.components();
799 if parts.next() != Some(std::path::Component::RootDir) {
800 return None;
801 }
802 let mut at = open(libc::AT_FDCWD, c"/")?;
803 let mut meta = at.metadata().ok()?;
804 let mut owners = vec![Owner::from(&meta)];
805 for part in parts {
806 let std::path::Component::Normal(name) = part else {
807 return None;
808 };
809 if !meta.is_dir() {
810 return None;
811 }
812 at = open(
813 at.as_raw_fd(),
814 &std::ffi::CString::new(name.as_bytes()).ok()?,
815 )?;
816 meta = at.metadata().ok()?;
817 owners.push(Owner::from(&meta));
818 }
819 (meta.is_file() && expect == (meta.dev(), meta.ino())).then_some(owners)
820}
821
822#[cfg(not(target_os = "linux"))]
823fn pinned_walk(_file: &Path, _expect: (u64, u64)) -> Option<Vec<Owner>> {
824 None
825}
826
827fn store_keeps(store: Owner, entry: Owner, person: &Person) -> bool {
836 let group = store.mode & 0o020 != 0;
837 let anyone = store.mode & 0o002 != 0;
838 if store.uid == 0 {
839 let person_writes = anyone
840 || (group && person.in_group(store.gid))
841 || (person.uid == 0 && store.mode & 0o200 != 0);
842 !person_writes && (!group || store.mode & 0o1000 != 0) && entry.uid == 0
843 } else {
844 store.uid == person.uid && !group && !anyone && (entry.uid == 0 || entry.uid == person.uid)
845 }
846}
847
848fn person_keeps(file: Owner, person: &Person) -> bool {
851 (file.uid == 0 || file.uid == person.uid)
852 && file.mode & 0o002 == 0
853 && (file.mode & 0o020 == 0 || (file.gid == person.gid && person.private_group()))
854}
855
856fn runner_of_file(
873 path: &Path,
874 home: Option<&Path>,
875 store: &Path,
876 person: &Person,
877 expect: (u64, u64),
878) -> Option<&'static str> {
879 runner_at(
880 &std::fs::canonicalize(path).ok()?,
881 home,
882 store,
883 person,
884 expect,
885 )
886}
887
888fn runner_at(
890 file: &Path,
891 home: Option<&Path>,
892 store: &Path,
893 person: &Person,
894 expect: (u64, u64),
895) -> Option<&'static str> {
896 let (roots, store) = resolved_roots(home, store);
897 let (runner, root) = runner_below(file, &roots, &store)?;
898 kept_below(file, &root, &store, person, expect).then_some(runner)
899}
900
901#[cfg(target_os = "linux")]
905fn runtime_of_file(
906 path: &Path,
907 home: Option<&Path>,
908 store: &Path,
909 person: &Person,
910 expect: (u64, u64),
911) -> bool {
912 let Ok(file) = std::fs::canonicalize(path) else {
913 return false;
914 };
915 let named = file
916 .file_name()
917 .and_then(std::ffi::OsStr::to_str)
918 .is_some_and(|name| RUNTIMES.contains(&name));
919 let (roots, store) = resolved_roots(home, store);
920 named
921 && root_below(&file, &roots, &store)
922 .is_some_and(|root| kept_below(&file, &root, &store, person, expect))
923}
924
925fn resolved_roots(home: Option<&Path>, store: &Path) -> (Vec<PathBuf>, PathBuf) {
928 let roots = install_roots(home)
929 .iter()
930 .filter_map(|root| std::fs::canonicalize(root).ok())
931 .collect();
932 let store = std::fs::canonicalize(store).unwrap_or_else(|_| store.to_path_buf());
933 (roots, store)
934}
935
936fn kept_below(file: &Path, root: &Path, store: &Path, person: &Person, expect: (u64, u64)) -> bool {
940 if crate::bookkeep::resolved_path_is_temp(file) {
941 return false;
942 }
943 let (Some(owners), Some(at)) = (
944 pinned_walk(file, expect),
945 root.components().count().checked_sub(1),
946 ) else {
947 return false;
948 };
949 walk_kept(&owners, at, root.parent() == Some(store), person)
950}
951
952fn walk_kept(owners: &[Owner], at: usize, in_store: bool, person: &Person) -> bool {
959 let (Some(&above), Some(&root)) = (
960 at.checked_sub(1).and_then(|above| owners.get(above)),
961 owners.get(at),
962 ) else {
963 return false;
964 };
965 let top = if in_store {
966 store_keeps(above, root, person)
967 } else {
968 person_keeps(above, person)
969 };
970 top && owners[at..].iter().all(|dir| person_keeps(*dir, person))
971}
972
973#[cfg(target_os = "linux")]
980#[must_use]
981pub fn exe_runner(pid: u32) -> Option<&'static str> {
982 use std::os::unix::fs::MetadataExt as _;
983 let exe = std::fs::read_link(format!("/proc/{pid}/exe")).ok()?;
984 let running = std::fs::metadata(format!("/proc/{pid}/exe"))
985 .ok()
986 .map(|meta| (meta.dev(), meta.ino()));
987 let script = || {
988 let cmdline = std::fs::read(format!("/proc/{pid}/cmdline")).ok()?;
989 let script = cmdline
990 .split(|b| *b == 0)
991 .skip(1)
992 .map(String::from_utf8_lossy)
993 .find(|a| !a.is_empty() && !a.starts_with('-'))?;
994 let cwd = std::fs::read_link(format!("/proc/{pid}/cwd")).ok()?;
995 Some(cwd.join(script.as_ref()))
996 };
997 let home = own_home();
998 let person = Person::new(&SystemAccounts);
999 runner_from(
1000 &exe,
1001 running,
1002 script,
1003 home.as_deref(),
1004 Path::new(NIX_STORE),
1005 &person,
1006 )
1007}
1008
1009#[cfg(target_os = "linux")]
1015fn runner_from(
1016 exe: &Path,
1017 running: Option<(u64, u64)>,
1018 script: impl FnOnce() -> Option<PathBuf>,
1019 home: Option<&Path>,
1020 store: &Path,
1021 person: &Person,
1022) -> Option<&'static str> {
1023 use std::os::unix::fs::MetadataExt as _;
1024 let running = running?;
1025 if let Some(r) = runner_of_file(exe, home, store, person, running) {
1026 return Some(r);
1027 }
1028 if !runtime_of_file(exe, home, store, person, running) {
1029 return None;
1030 }
1031 let script = std::fs::canonicalize(script()?).ok()?;
1032 let meta = std::fs::metadata(&script).ok()?;
1033 runner_of_file(&script, home, store, person, (meta.dev(), meta.ino()))
1034}
1035
1036#[cfg(not(target_os = "linux"))]
1039#[must_use]
1040pub fn exe_runner(_pid: u32) -> Option<&'static str> {
1041 None
1042}
1043
1044const EDITORS: &[&str] = &[
1047 "claude",
1048 "claude-code",
1049 "codex",
1050 "cursor",
1051 "cursor-agent",
1052 "opencode",
1053 "opencode-ai",
1054 "agy",
1055 "antigravity",
1056 "grok",
1057 "gemini",
1058 "copilot",
1059 "qwen",
1060 "droid",
1061 "crush",
1062 "kiro",
1063 "kiro-cli",
1064 "hermes",
1065 "omp",
1066 "cline",
1067 "aider",
1068 "code",
1069 "code-oss",
1070 "codium",
1071 "vscodium",
1072 "zed",
1073 "zed-editor",
1074 "windsurf",
1075 "goland",
1076 "idea",
1077 "pycharm",
1078 "webstorm",
1079 "rustrover",
1080 "sublime_text",
1081 "emacs",
1082 "nvim",
1083 "vim",
1084 "gvim",
1085 "helix",
1086 "lapce",
1087 "kate",
1088 "gedit",
1089 "cursor helper",
1090];
1091
1092#[must_use]
1094pub fn is_editor_name(name: &str) -> bool {
1095 let base = name.rsplit(['/', '\\']).next().unwrap_or(name).trim();
1096 EDITORS.iter().any(|item| base.eq_ignore_ascii_case(item))
1097}
1098
1099#[cfg(target_os = "linux")]
1100fn command_base(pid: u32) -> Option<String> {
1101 let raw = std::fs::read(format!("/proc/{pid}/cmdline")).ok()?;
1102 let first = raw.split(|b| *b == 0).find(|word| !word.is_empty())?;
1103 let text = String::from_utf8_lossy(first);
1104 Some(text.rsplit(['/', '\\']).next().unwrap_or(&text).to_string())
1105}
1106
1107#[cfg(target_os = "macos")]
1108fn command_base(pid: u32) -> Option<String> {
1109 let mut mib = [
1110 libc::CTL_KERN,
1111 libc::KERN_PROCARGS2,
1112 libc::c_int::try_from(pid).ok()?,
1113 ];
1114 let mut size: libc::size_t = 0;
1115 let asked = unsafe {
1117 libc::sysctl(
1118 mib.as_mut_ptr(),
1119 3,
1120 std::ptr::null_mut(),
1121 &mut size,
1122 std::ptr::null_mut(),
1123 0,
1124 )
1125 };
1126 if asked != 0 || size < 4 {
1127 return None;
1128 }
1129 let mut buf = vec![0u8; size];
1130 let read = unsafe {
1132 libc::sysctl(
1133 mib.as_mut_ptr(),
1134 3,
1135 buf.as_mut_ptr().cast(),
1136 &mut size,
1137 std::ptr::null_mut(),
1138 0,
1139 )
1140 };
1141 if read != 0 {
1142 return None;
1143 }
1144 buf.truncate(size);
1145 let argc = usize::try_from(i32::from_ne_bytes(buf.get(..4)?.try_into().ok()?)).ok()?;
1146 if argc == 0 {
1147 return None;
1148 }
1149 let word = buf[4..]
1150 .split(|b| *b == 0)
1151 .skip(1)
1152 .find(|word| !word.is_empty())?;
1153 let text = String::from_utf8_lossy(word);
1154 Some(text.rsplit(['/', '\\']).next().unwrap_or(&text).to_string())
1155}
1156
1157#[cfg(target_os = "linux")]
1161#[must_use]
1162pub fn editor_above() -> bool {
1163 ancestors().into_iter().any(|proc| {
1164 is_editor_name(&proc.comm)
1165 || command_base(proc.pid).is_some_and(|name| is_editor_name(&name))
1166 || exe_runner(proc.pid).is_some_and(is_editor_name)
1167 })
1168}
1169
1170#[cfg(target_os = "macos")]
1171#[must_use]
1172pub fn editor_above() -> bool {
1173 let mut pid = unsafe { libc::getppid() } as u32;
1175 for _ in 0..DEPTH {
1176 if pid <= 1 {
1177 return false;
1178 }
1179 let Some((ppid, comm)) = parent_and_name(pid) else {
1180 return false;
1181 };
1182 if is_editor_name(&comm) || command_base(pid).is_some_and(|name| is_editor_name(&name)) {
1183 return true;
1184 }
1185 if ppid == pid {
1186 return false;
1187 }
1188 pid = ppid;
1189 }
1190 false
1191}
1192
1193#[cfg(not(any(target_os = "linux", target_os = "macos")))]
1194#[must_use]
1195pub fn editor_above() -> bool {
1196 false
1197}
1198
1199fn bare_hook_command(cmd: &str) -> bool {
1205 let mut plain = String::new();
1206 let mut rest = cmd;
1207 while let Some(at) = rest.find("${") {
1208 plain.push_str(&rest[..at]);
1209 let Some(close) = rest[at..].find('}') else {
1210 return false;
1211 };
1212 let name = &rest[at + 2..at + close];
1213 if name.is_empty() || !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
1214 return false;
1215 }
1216 plain.push_str("VAR");
1217 rest = &rest[at + close + 1..];
1218 }
1219 plain.push_str(rest);
1220 let plain = plain.replace('"', "");
1221 if plain.contains(|c: char| "<>|;&$`(){}\\'\n\r*?[]~#!".contains(c)) {
1222 return false;
1223 }
1224 let words: Vec<&str> = plain.split_whitespace().collect();
1225 let base = |w: &str| w.rsplit('/').next().unwrap_or(w).to_string();
1226 let at = match words.first().map(|w| base(w)).as_deref() {
1227 Some("ljos-plugin") if words.get(1) == Some(&"ljos") => 2,
1228 Some("ljos") => 1,
1229 _ => return false,
1230 };
1231 words.get(at) == Some(&"hook")
1232 && words[at + 1..].iter().all(|w| {
1233 w.starts_with("--")
1234 || w.chars()
1235 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
1236 })
1237}
1238
1239#[cfg(target_os = "linux")]
1244fn hook_shell(pid: u32) -> bool {
1245 let Ok(exe) = std::fs::read_link(format!("/proc/{pid}/exe")) else {
1246 return false;
1247 };
1248 let shell = exe
1249 .file_name()
1250 .and_then(|f| f.to_str())
1251 .is_some_and(|f| SHELLS.contains(&f));
1252 let argv: Vec<String> = std::fs::read(format!("/proc/{pid}/cmdline"))
1253 .unwrap_or_default()
1254 .split(|b| *b == 0)
1255 .map(|a| String::from_utf8_lossy(a).into_owned())
1256 .collect();
1257 shell
1258 && matches!(argv.as_slice(), [_, c, cmd, last] if c == "-c" && last.is_empty() && bare_hook_command(cmd))
1259}
1260
1261#[cfg(not(target_os = "linux"))]
1262fn hook_shell(_pid: u32) -> bool {
1263 false
1264}
1265
1266#[must_use]
1269pub fn runner_above() -> Option<(Proc, &'static str)> {
1270 ancestors()
1271 .into_iter()
1272 .find_map(|p| exe_runner(p.pid).map(|r| (p, r)))
1273}
1274
1275fn tool_call_stamp(env: &[(String, String)]) -> Option<&str> {
1278 env.iter()
1279 .find(|(k, v)| {
1280 matches!(
1281 k.as_str(),
1282 "CLAUDECODE" | "CURSOR_AGENT" | "CODEX_THREAD_ID"
1283 ) || (crate::runner_session_var(k, v) && !k.starts_with("LJOS_"))
1284 })
1285 .map(|(k, _)| k.as_str())
1286}
1287
1288struct ToolMark {
1292 sid: u32,
1293 at: u64,
1294 runner_pid: u32,
1295 runner_start: u64,
1296 shell_pid: u32,
1297 shell_start: u64,
1298 cgroup: String,
1299}
1300
1301struct Here {
1304 sid: u32,
1305 cgroup: String,
1306}
1307
1308fn marks_path() -> std::path::PathBuf {
1309 crate::runtime_dir().join("tool-calls")
1310}
1311
1312fn broken_path() -> std::path::PathBuf {
1313 crate::runtime_dir().join("tool-calls.broken")
1314}
1315
1316fn current_sid() -> Result<u32, String> {
1318 let sid = unsafe { libc::getsid(0) };
1321 if sid < 0 {
1322 return Err(
1323 "the session id cannot be read, so a tool call cannot be told from the runner".into(),
1324 );
1325 }
1326 u32::try_from(sid).map_err(|_| "the session id does not fit".into())
1327}
1328
1329fn here_evidence() -> Result<Here, String> {
1330 let sid = current_sid()?;
1331 let cgroup = std::fs::read_to_string("/proc/self/cgroup").map_err(|_| {
1332 "the cgroup cannot be read, so a tool call cannot be told from the runner".to_string()
1333 })?;
1334 if cgroup.is_empty() {
1335 return Err("the cgroup is empty, so a tool call cannot be told from the runner".into());
1336 }
1337 Ok(Here { sid, cgroup })
1338}
1339
1340fn self_start() -> Result<u64, String> {
1341 stat(std::process::id())
1342 .map(|row| row.start)
1343 .ok_or_else(|| "this process's start time cannot be read".into())
1344}
1345
1346pub fn seal_unreadable_tool_call() {
1352 mark_broken();
1353}
1354
1355fn mark_broken() {
1356 let dir = crate::runtime_dir();
1357 let _ = std::fs::create_dir_all(&dir);
1358 let mut options = std::fs::OpenOptions::new();
1359 options.create(true).write(true).truncate(true);
1360 #[cfg(unix)]
1361 {
1362 use std::os::unix::fs::OpenOptionsExt as _;
1363 options.mode(0o600);
1364 }
1365 let _ = options.open(broken_path());
1366}
1367
1368fn tool_marks() -> Result<Vec<ToolMark>, String> {
1369 if broken_path().exists() {
1370 return Err(
1371 "a tool-call record could not be written, so nothing is kept as the person's".into(),
1372 );
1373 }
1374 let text = match std::fs::read_to_string(marks_path()) {
1375 Ok(text) => text,
1376 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
1377 Err(_) => {
1378 return Err(
1379 "the tool-call record cannot be read, so nothing is kept as the person's".into(),
1380 );
1381 }
1382 };
1383 let mut marks = Vec::new();
1384 for line in text.lines() {
1385 let fields: Vec<&str> = line.split('\t').collect();
1386 if fields.len() != 8 || fields[0] != "1" {
1387 return Err(
1388 "the tool-call record is corrupt, so nothing is kept as the person's".into(),
1389 );
1390 }
1391 let number = |index: usize, label: &str| -> Result<u64, String> {
1392 fields[index].parse().map_err(|_| {
1393 format!(
1394 "the tool-call record has a bad {label}, so nothing is kept as the person's"
1395 )
1396 })
1397 };
1398 let cgroup = hex::decode(fields[7])
1399 .ok()
1400 .and_then(|bytes| String::from_utf8(bytes).ok())
1401 .ok_or("the tool-call record has a bad cgroup, so nothing is kept as the person's")?;
1402 let narrow = |value: u64, label: &str| -> Result<u32, String> {
1403 u32::try_from(value).map_err(|_| {
1404 format!(
1405 "the tool-call record has a bad {label}, so nothing is kept as the person's"
1406 )
1407 })
1408 };
1409 marks.push(ToolMark {
1410 sid: narrow(number(1, "session")?, "session")?,
1411 at: number(2, "start")?,
1412 runner_pid: narrow(number(3, "runner")?, "runner")?,
1413 runner_start: number(4, "runner start")?,
1414 shell_pid: narrow(number(5, "shell")?, "shell")?,
1415 shell_start: number(6, "shell start")?,
1416 cgroup,
1417 });
1418 }
1419 Ok(marks)
1420}
1421
1422pub fn note_tool_call(runner: &Proc) -> Result<(), String> {
1432 let here = match here_evidence() {
1435 Ok(here) => here,
1436 Err(why) => {
1437 mark_broken();
1438 return Err(why);
1439 }
1440 };
1441 let at = match self_start() {
1442 Ok(at) => at,
1443 Err(why) => {
1444 mark_broken();
1445 return Err(why);
1446 }
1447 };
1448 let dir = crate::runtime_dir();
1449 let mut builder = std::fs::DirBuilder::new();
1450 builder.recursive(true);
1451 #[cfg(unix)]
1452 {
1453 use std::os::unix::fs::DirBuilderExt as _;
1454 builder.mode(0o700);
1455 }
1456 if builder.create(&dir).is_err() {
1457 mark_broken();
1458 return Err("the tool-call record directory cannot be created".into());
1459 }
1460 let line = format!(
1464 "1\t{}\t{at}\t{}\t{}\t0\t0\t{}\n",
1465 here.sid,
1466 runner.pid,
1467 runner.start,
1468 hex::encode(here.cgroup.as_bytes())
1469 );
1470 if line.len() > 4096 {
1471 mark_broken();
1472 return Err("the tool-call record does not fit one write".into());
1473 }
1474 let mut options = std::fs::OpenOptions::new();
1475 options.create(true).append(true);
1476 #[cfg(unix)]
1477 {
1478 use std::os::unix::fs::OpenOptionsExt as _;
1479 options.mode(0o600);
1480 }
1481 let opened = options.open(marks_path());
1482 let mut file = match opened {
1483 Ok(file) => file,
1484 Err(_) => {
1485 mark_broken();
1486 return Err("the tool-call record cannot be written".into());
1487 }
1488 };
1489 use std::io::Write as _;
1490 if file.write_all(line.as_bytes()).is_err() {
1491 mark_broken();
1492 return Err("the tool-call record cannot be written".into());
1493 }
1494 Ok(())
1495}
1496
1497fn confirm_runner(runner: &Proc) -> Result<(), String> {
1503 let here = here_evidence()?;
1504 let marks = tool_marks()?;
1505 let chain = ancestors();
1506 for mark in &marks {
1507 if mark.runner_pid == runner.pid && mark.runner_start == runner.start {
1508 continue;
1509 }
1510 if mark.shell_pid > 0
1511 && chain
1512 .iter()
1513 .any(|proc| proc.pid == mark.shell_pid && proc.start == mark.shell_start)
1514 {
1515 return Err(format!(
1516 "{} (pid {}) descends from a recorded tool call",
1517 runner.comm, runner.pid
1518 ));
1519 }
1520 let same_place = mark.sid == here.sid || mark.cgroup == here.cgroup;
1521 if same_place && runner.start >= mark.at {
1522 return Err(format!(
1523 "{} (pid {}) shares a recorded tool call's cgroup or session and is not older than that tool call",
1524 runner.comm, runner.pid
1525 ));
1526 }
1527 }
1528 Ok(())
1529}
1530
1531pub fn placed_runner() -> Result<(), String> {
1541 for proc in ancestors() {
1542 if is_shell(&proc) || is_wrapper(&proc) {
1543 continue;
1544 }
1545 if ends_the_walk(&proc) {
1546 break;
1547 }
1548 if exe_runner(proc.pid).is_some() {
1549 return confirm_runner(&proc);
1550 }
1551 return Err(format!(
1552 "{} (pid {}) is not an installed runner, so this is not the person's runner",
1553 proc.comm, proc.pid
1554 ));
1555 }
1556 Err("no installed runner is above this process, so this is not the person's runner".into())
1557}
1558
1559pub fn hook_runner() -> Result<(Proc, &'static str), String> {
1573 let mut chain = ancestors();
1574 let parent = chain.first().ok_or("it has no parent process")?;
1575 if exe_runner(parent.pid).is_none() && chain.len() > 1 && hook_shell(parent.pid) {
1576 chain.remove(0);
1577 }
1578 let parent = &chain[0];
1579 let Some(runner) = exe_runner(parent.pid) else {
1580 return Err(format!(
1581 "its parent, {} (pid {}), is not a runner's own program: a shell, an interpreter or ssh ran it, so a tool call may have",
1582 parent.comm, parent.pid
1583 ));
1584 };
1585 let env = environ(parent.pid).ok_or_else(|| {
1586 format!(
1587 "the environment {} (pid {}) was started with cannot be read, so a tool call's stamp would be hidden",
1588 parent.comm, parent.pid
1589 )
1590 })?;
1591 if let Some(key) = tool_call_stamp(&env) {
1592 return Err(format!(
1593 "{runner} (pid {}) was started with {key} set, inside another runner's tool call",
1594 parent.pid
1595 ));
1596 }
1597 let own = chain[1..].iter().take_while(|p| !ends_the_walk(p));
1600 if let Some((p, other)) = own
1601 .filter_map(|p| exe_runner(p.pid).map(|r| (p, r)))
1602 .find(|(_, r)| *r != "cursor")
1603 {
1604 return Err(format!(
1605 "{runner} (pid {}) runs under {other} (pid {}), inside a tool call",
1606 parent.pid, p.pid
1607 ));
1608 }
1609 confirm_runner(parent)?;
1610 Ok((parent.clone(), runner))
1611}
1612
1613#[cfg(test)]
1614mod tests {
1615 use super::*;
1616
1617 #[cfg(target_os = "linux")]
1619 fn runner_here(
1620 path: &Path,
1621 home: Option<&Path>,
1622 store: &Path,
1623 person: &Person,
1624 ) -> Option<&'static str> {
1625 use std::os::unix::fs::MetadataExt as _;
1626 let meta = std::fs::metadata(path).ok()?;
1627 runner_of_file(path, home, store, person, (meta.dev(), meta.ino()))
1628 }
1629
1630 #[test]
1631 fn an_editor_name_matches_the_file_and_not_a_shell() {
1632 assert!(is_editor_name("Cursor"));
1633 assert!(is_editor_name("/Applications/Cursor Helper"));
1634 assert!(is_editor_name("cursor-agent"));
1635 assert!(!is_editor_name("bash"));
1636 assert!(!is_editor_name("python3"));
1637 }
1638
1639 #[test]
1640 fn a_runner_is_read_from_the_file_it_runs_from() {
1641 let roots = install_roots(Some(Path::new("/home/u")));
1642 for (path, want) in [
1643 ("/home/u/.local/share/claude/versions/2.1.3", Some("claude")),
1644 ("/home/u/.local/bin/claude", Some("claude")),
1645 (
1646 "/usr/lib/node_modules/@anthropic-ai/claude-code/cli.js",
1647 Some("claude"),
1648 ),
1649 (
1650 "/usr/lib/node_modules/@openai/codex/vendor/x86_64-unknown-linux-musl/codex/codex",
1651 Some("codex"),
1652 ),
1653 (
1654 "/home/u/.local/share/cursor-agent/versions/2025.10.2-abc/node",
1655 Some("cursor-agent"),
1656 ),
1657 ("/usr/share/cursor/cursor", Some("cursor")),
1658 ("/usr/local/bin/codex", Some("codex")),
1659 ("/usr/bin/claude", Some("claude")),
1660 ("/bin/grok", Some("grok")),
1661 ("/home/u/.cargo/bin/codex", Some("codex")),
1662 (
1663 "/home/u/.npm-global/lib/node_modules/@anthropic-ai/claude-code/cli.js",
1664 Some("claude"),
1665 ),
1666 (
1667 "/nix/store/0c9yapqd0b8y4mj5xj7mrpmhy3l5ykh8-claude-code-2.0.1/bin/claude",
1668 Some("claude"),
1669 ),
1670 ("/usr/bin/node", None),
1671 ("/usr/bin/bash", None),
1672 ("/usr/bin/python3", None),
1673 ("/usr/bin/ssh", None),
1674 ("/home/u/.cargo/bin/ljos", None),
1675 ("/tmp/claude", None),
1676 ("/tmp/claude/versions/2.1.3", None),
1677 ("/home/u/runners/claude", None),
1678 ("/home/u/plant/grok", None),
1679 ("/home/u/plant/bin/claude", None),
1680 ("/home/u/.local/bin/claude (deleted)", None),
1681 ("/home/u/plant/.local/bin/claude", None),
1682 ("/tmp/.local/bin/claude", None),
1683 ("/tmp/project/node_modules/.bin/claude", None),
1684 ("/home/u/../other/.local/bin/claude", None),
1685 ("/home/uu/.local/bin/claude", None),
1686 ("/home/other/.local/bin/claude", None),
1687 ("/home/u/.local/binx/claude", None),
1688 ("/home/u/.local/bin/../../plant/claude", None),
1689 ("/home/u/.local/share/plant/claude", None),
1690 ("/home/u/.local/share/claude-plant/claude", None),
1691 ("/home/u/src/app/node_modules/.bin/claude", None),
1692 ("/usr/binx/claude", None),
1693 ("/usr/share/plant/claude", None),
1694 ("/usr/lib/node_modules/../../../tmp/claude", None),
1695 ("/opt/plant/claude", None),
1696 ("/var/tmp/.local/bin/claude", None),
1697 ("/dev/shm/node_modules/.bin/claude", None),
1698 ("/nix/store/claude/bin/claude", None),
1699 (
1700 "/nix/store/0c9yapqd0b8y4mj5xj7mrpmhy3l5ykh8/bin/claude",
1701 None,
1702 ),
1703 (
1704 "/nix/store/0c9yapqd0b8y4mj5xj7mrpmhy3l5ykhe-claude-code-2.0.1/bin/claude",
1705 None,
1706 ),
1707 ] {
1708 let got = runner_below(Path::new(path), &roots, Path::new(NIX_STORE))
1709 .map(|(runner, _)| runner);
1710 assert_eq!(got, want, "{path}");
1711 }
1712 }
1713
1714 #[cfg(target_os = "linux")]
1720 #[test]
1721 fn an_install_root_holds_only_what_resolves_inside_it() {
1722 use std::os::unix::fs::PermissionsExt as _;
1723 let base = tempfile::Builder::new()
1724 .prefix(".ljos-roots-")
1725 .tempdir_in(own_home().expect("this user's passwd entry names a home"))
1726 .unwrap();
1727 let home = base.path().join("u");
1728 let person = Person::new(&SystemAccounts);
1729 let store = PathBuf::from(NIX_STORE);
1730 let program = |dir: &Path, name: &str| {
1731 std::fs::create_dir_all(dir).unwrap();
1732 let path = dir.join(name);
1733 std::fs::write(&path, "").unwrap();
1734 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
1735 path
1736 };
1737 let bin = home.join(".local/bin");
1738 let claude = program(&bin, "claude");
1739 assert_eq!(
1740 runner_here(&claude, Some(&home), &store, &person),
1741 Some("claude")
1742 );
1743
1744 let away = tempfile::tempdir().unwrap();
1745 std::os::unix::fs::symlink(program(away.path(), "codex"), bin.join("codex")).unwrap();
1746 assert_eq!(
1747 runner_here(&bin.join("codex"), Some(&home), &store, &person),
1748 None
1749 );
1750
1751 program(&base.path().join("other/.local/bin"), "claude");
1752 let climbed = home.join("../other/.local/bin/claude");
1753 assert_eq!(runner_here(&climbed, Some(&home), &store, &person), None);
1754
1755 std::fs::set_permissions(&claude, std::fs::Permissions::from_mode(0o757)).unwrap();
1756 assert_eq!(runner_here(&claude, Some(&home), &store, &person), None);
1757
1758 let open = bin.join("open");
1759 let grok = program(&open, "grok");
1760 assert_eq!(
1761 runner_here(&grok, Some(&home), &store, &person),
1762 Some("grok")
1763 );
1764 std::fs::set_permissions(&open, std::fs::Permissions::from_mode(0o777)).unwrap();
1765 assert_eq!(runner_here(&grok, Some(&home), &store, &person), None);
1766 }
1767
1768 #[cfg(target_os = "linux")]
1773 #[test]
1774 fn a_root_in_a_directory_others_may_write_is_refused() {
1775 use std::os::unix::fs::PermissionsExt as _;
1776 let base = tempfile::Builder::new()
1777 .prefix(".ljos-above-")
1778 .tempdir_in(own_home().expect("this user's passwd entry names a home"))
1779 .unwrap();
1780 let home = base.path().join("u");
1781 let store = PathBuf::from(NIX_STORE);
1782 let person = Person::new(&SystemAccounts);
1783 let staff = FakeAccounts {
1784 user: Some("u"),
1785 group: Some("staff"),
1786 members: &["u", "v"],
1787 primary: Some(vec![person.uid]),
1788 };
1789 let sharing = Person::new(&staff);
1790 let mut files: Vec<PathBuf> = vec![
1791 home.join(".local/bin/claude"),
1792 home.join(".cargo/bin/codex"),
1793 home.join(".npm-global/lib/node_modules/@anthropic-ai/claude-code/cli.js"),
1794 ];
1795 files.extend(
1796 HOOK_RUNNERS
1797 .iter()
1798 .map(|(name, _)| home.join(".local/share").join(name).join(name)),
1799 );
1800 for file in &files {
1801 std::fs::create_dir_all(file.parent().unwrap()).unwrap();
1802 std::fs::write(file, "").unwrap();
1803 }
1804 let set = |dir: &Path, mode| {
1805 std::fs::set_permissions(dir, std::fs::Permissions::from_mode(mode)).unwrap();
1806 };
1807 for root in install_roots(Some(&home))
1808 .iter()
1809 .filter(|r| r.starts_with(&home))
1810 {
1811 let file = files.iter().find(|f| f.starts_with(root)).unwrap();
1812 let above = root.parent().unwrap();
1813 let runner = |who| runner_here(file, Some(&home), &store, who);
1814 assert!(runner(&person).is_some(), "{}", root.display());
1815 set(above, 0o757);
1816 assert_eq!(runner(&person), None, "{}", root.display());
1817 set(above, 0o775);
1818 assert_eq!(runner(&sharing), None, "{}", root.display());
1819 set(above, 0o755);
1820 }
1821
1822 let uid = person.uid;
1823 let owner = |uid, mode| Owner {
1824 uid,
1825 gid: person.gid,
1826 mode,
1827 };
1828 let walk = |above| [owner(0, 0o755), above, owner(uid, 0o755), owner(uid, 0o755)];
1829 assert!(walk_kept(&walk(owner(uid, 0o755)), 2, false, &person));
1830 assert!(walk_kept(&walk(owner(0, 0o755)), 2, false, &person));
1831 assert!(!walk_kept(&walk(owner(uid + 1, 0o755)), 2, false, &person));
1832 assert!(!walk_kept(&walk(owner(uid, 0o757)), 2, false, &person));
1833 assert!(!walk_kept(&walk(owner(uid, 0o775)), 2, false, &sharing));
1834 }
1835
1836 #[cfg(target_os = "linux")]
1841 #[test]
1842 fn a_parent_swapped_after_the_path_resolved_is_refused() {
1843 use std::os::unix::fs::MetadataExt as _;
1844 let base = tempfile::Builder::new()
1845 .prefix(".ljos-swap-")
1846 .tempdir_in(own_home().expect("this user's passwd entry names a home"))
1847 .unwrap();
1848 let home = std::fs::canonicalize(base.path()).unwrap().join("u");
1849 let person = Person::new(&SystemAccounts);
1850 let store = PathBuf::from(NIX_STORE);
1851 let versions = home.join(".local/share/claude/versions");
1852 let other = home.join("other");
1853 for dir in [&versions, &other] {
1854 std::fs::create_dir_all(dir).unwrap();
1855 std::fs::write(dir.join("2.1.3"), "").unwrap();
1856 }
1857 let id = |path: &Path| {
1858 let meta = std::fs::metadata(path).unwrap();
1859 (meta.dev(), meta.ino())
1860 };
1861 let file = versions.join("2.1.3");
1862 let (running, planted) = (id(&file), id(&other.join("2.1.3")));
1863 let runner = |expect| runner_at(&file, Some(&home), &store, &person, expect);
1864 assert_eq!(runner(running), Some("claude"));
1865
1866 std::fs::rename(&versions, home.join("held")).unwrap();
1867 std::os::unix::fs::symlink(&other, &versions).unwrap();
1868 assert_eq!(runner(running), None);
1869 assert_eq!(runner(planted), None);
1870
1871 std::fs::remove_file(&versions).unwrap();
1872 std::fs::rename(&other, &versions).unwrap();
1873 assert_eq!(runner(running), None);
1874 }
1875
1876 #[cfg(target_os = "linux")]
1880 #[test]
1881 fn an_executable_that_cannot_be_read_is_no_runner() {
1882 use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _};
1883 let base = tempfile::Builder::new()
1884 .prefix(".ljos-exe-")
1885 .tempdir_in(own_home().expect("this user's passwd entry names a home"))
1886 .unwrap();
1887 let home = base.path().join("u");
1888 let bin = home.join(".local/bin");
1889 std::fs::create_dir_all(&bin).unwrap();
1890 let claude = bin.join("claude");
1891 std::fs::write(&claude, "").unwrap();
1892 std::fs::set_permissions(&claude, std::fs::Permissions::from_mode(0o755)).unwrap();
1893 let meta = std::fs::metadata(&claude).unwrap();
1894 let person = Person::new(&SystemAccounts);
1895 let store = PathBuf::from(NIX_STORE);
1896 let runner = |running| runner_from(&claude, running, || None, Some(&home), &store, &person);
1897 assert_eq!(runner(Some((meta.dev(), meta.ino()))), Some("claude"));
1898 assert_eq!(runner(None), None);
1899 }
1900
1901 #[cfg(target_os = "linux")]
1906 #[test]
1907 fn a_script_names_the_runner_only_for_an_installed_runtime() {
1908 use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _};
1909 let base = tempfile::Builder::new()
1910 .prefix(".ljos-runtime-")
1911 .tempdir_in(own_home().expect("this user's passwd entry names a home"))
1912 .unwrap();
1913 let home = base.path().join("u");
1914 let away = tempfile::tempdir().unwrap();
1915 let program = |dir: &Path, name: &str| {
1916 std::fs::create_dir_all(dir).unwrap();
1917 let path = dir.join(name);
1918 std::fs::write(&path, "").unwrap();
1919 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
1920 path
1921 };
1922 let id = |path: &Path| {
1923 let meta = std::fs::metadata(path).unwrap();
1924 (meta.dev(), meta.ino())
1925 };
1926 let cli = program(
1927 &home.join(".npm-global/lib/node_modules/@anthropic-ai/claude-code"),
1928 "cli.js",
1929 );
1930 let bin = home.join(".local/bin");
1931 let node = program(&bin, "node");
1932 let person = Person::new(&SystemAccounts);
1933 let store = PathBuf::from(NIX_STORE);
1934 let runner = |exe: &Path, running| {
1935 runner_from(
1936 exe,
1937 Some(running),
1938 || Some(cli.clone()),
1939 Some(&home),
1940 &store,
1941 &person,
1942 )
1943 };
1944 assert_eq!(runner(&node, id(&node)), Some("claude"));
1945 assert_eq!(runner(&node, id(&cli)), None);
1946 for planted in [
1947 program(&bin, "node-v20"),
1948 program(&bin, "nodejs_modules"),
1949 program(away.path(), "node"),
1950 ] {
1951 assert_eq!(
1952 runner(&planted, id(&planted)),
1953 None,
1954 "{}",
1955 planted.display()
1956 );
1957 }
1958 std::fs::set_permissions(&node, std::fs::Permissions::from_mode(0o757)).unwrap();
1959 assert_eq!(runner(&node, id(&node)), None);
1960 }
1961
1962 #[cfg(target_os = "linux")]
1968 #[test]
1969 fn a_nix_entry_is_a_runner_only_in_a_store_nix_keeps() {
1970 use std::os::unix::fs::PermissionsExt as _;
1971 let base = tempfile::Builder::new()
1972 .prefix(".ljos-store-")
1973 .tempdir_in(own_home().expect("this user's passwd entry names a home"))
1974 .unwrap();
1975 let store = base.path().join("store");
1976 let bin = store.join("0c9yapqd0b8y4mj5xj7mrpmhy3l5ykh8-claude-code-2.0.1/bin");
1977 std::fs::create_dir_all(&bin).unwrap();
1978 let claude = bin.join("claude");
1979 std::fs::write(&claude, "").unwrap();
1980 std::fs::set_permissions(&claude, std::fs::Permissions::from_mode(0o755)).unwrap();
1981 let person = Person::new(&SystemAccounts);
1982 let set = |mode| {
1983 std::fs::set_permissions(&store, std::fs::Permissions::from_mode(mode)).unwrap();
1984 };
1985 let runner = || runner_here(&claude, None, &store, &person);
1986
1987 set(0o755);
1988 assert_eq!(runner(), Some("claude"));
1989 for mode in [0o775, 0o757, 0o777, 0o1775, 0o1777] {
1990 set(mode);
1991 assert_eq!(runner(), None, "{mode:o}");
1992 }
1993 set(0o755);
1994
1995 let uid = person.uid;
1996 let nixbld = person.gid + 1;
1997 let owner = |uid, gid, mode| Owner { uid, gid, mode };
1998 let outsider = Person {
1999 groups: Some(vec![]),
2000 ..Person::new(&SystemAccounts)
2001 };
2002 let builder = Person {
2003 groups: Some(vec![nixbld]),
2004 ..Person::new(&SystemAccounts)
2005 };
2006 let unread = Person {
2007 groups: None,
2008 ..Person::new(&SystemAccounts)
2009 };
2010 let root_entry = owner(0, 0, 0o555);
2011 let own_entry = owner(uid, uid, 0o555);
2012 for (store, entry, who, want) in [
2013 (owner(0, nixbld, 0o1775), root_entry, &outsider, true),
2014 (owner(0, 0, 0o755), root_entry, &outsider, true),
2015 (owner(0, nixbld, 0o1775), own_entry, &outsider, false),
2016 (owner(0, nixbld, 0o775), root_entry, &outsider, false),
2017 (owner(0, nixbld, 0o1775), root_entry, &builder, false),
2018 (owner(0, nixbld, 0o1775), root_entry, &unread, false),
2019 (owner(0, 0, 0o1777), root_entry, &outsider, false),
2020 (owner(uid, uid, 0o755), own_entry, &outsider, true),
2021 (owner(uid, uid, 0o755), root_entry, &outsider, true),
2022 (owner(uid, uid, 0o775), own_entry, &outsider, false),
2023 (owner(uid, uid, 0o1777), own_entry, &outsider, false),
2024 (
2025 owner(uid, uid, 0o755),
2026 owner(uid + 1, 0, 0o555),
2027 &outsider,
2028 false,
2029 ),
2030 (owner(uid + 1, 0, 0o755), own_entry, &outsider, false),
2031 ] {
2032 assert_eq!(
2033 store_keeps(store, entry, who),
2034 want,
2035 "{}:{} {:o}",
2036 store.uid,
2037 store.gid,
2038 store.mode
2039 );
2040 }
2041 }
2042
2043 #[cfg(target_os = "linux")]
2046 struct FakeAccounts {
2047 user: Option<&'static str>,
2048 group: Option<&'static str>,
2049 members: &'static [&'static str],
2050 primary: Option<Vec<u32>>,
2051 }
2052
2053 #[cfg(target_os = "linux")]
2054 impl Accounts for FakeAccounts {
2055 fn user_name(&self, _uid: u32) -> Option<std::ffi::OsString> {
2056 self.user.map(Into::into)
2057 }
2058
2059 fn group(&self, _gid: u32) -> Option<GroupEntry> {
2060 self.group.map(|name| GroupEntry {
2061 name: name.into(),
2062 members: self.members.iter().map(Into::into).collect(),
2063 })
2064 }
2065
2066 fn primary_members(&self, _gid: u32) -> Option<Vec<u32>> {
2067 self.primary.clone()
2068 }
2069 }
2070
2071 #[cfg(target_os = "linux")]
2076 #[test]
2077 fn a_group_write_counts_only_from_a_group_the_person_has_alone() {
2078 use std::os::unix::fs::PermissionsExt as _;
2079 let uid = unsafe { libc::getuid() };
2082 let alone = |members, primary| FakeAccounts {
2083 user: Some("u"),
2084 group: Some("u"),
2085 members,
2086 primary: Some(primary),
2087 };
2088 let shared = [
2089 FakeAccounts {
2090 group: Some("staff"),
2091 ..alone(&[], vec![uid])
2092 },
2093 FakeAccounts {
2094 group: None,
2095 ..alone(&[], vec![uid])
2096 },
2097 alone(&["u", "v"], vec![uid]),
2098 alone(&["v"], vec![uid]),
2099 alone(&[], vec![uid, uid + 1]),
2100 FakeAccounts {
2101 user: None,
2102 ..alone(&[], vec![uid])
2103 },
2104 FakeAccounts {
2105 primary: None,
2106 ..alone(&[], vec![uid])
2107 },
2108 ];
2109
2110 let base = tempfile::Builder::new()
2111 .prefix(".ljos-group-")
2112 .tempdir_in(own_home().expect("this user's passwd entry names a home"))
2113 .unwrap();
2114 let home = base.path().join("u");
2115 let bin = home.join(".local/bin");
2116 std::fs::create_dir_all(&bin).unwrap();
2117 let claude = bin.join("claude");
2118 std::fs::write(&claude, "").unwrap();
2119 std::fs::set_permissions(&claude, std::fs::Permissions::from_mode(0o775)).unwrap();
2120 let store = PathBuf::from(NIX_STORE);
2121
2122 for accounts in [alone(&[], vec![uid]), alone(&["u"], vec![uid, uid])] {
2123 assert!(group_is_private(&accounts, uid, 0));
2124 let person = Person::new(&accounts);
2125 assert_eq!(
2126 runner_here(&claude, Some(&home), &store, &person),
2127 Some("claude")
2128 );
2129 }
2130 for accounts in &shared {
2131 assert!(!group_is_private(accounts, uid, 0));
2132 let person = Person::new(accounts);
2133 assert_eq!(runner_here(&claude, Some(&home), &store, &person), None);
2134 }
2135 }
2136
2137 #[test]
2141 fn a_primary_group_is_listed_only_from_a_passwd_file_read_whole() {
2142 let etc = tempfile::tempdir().unwrap();
2143 let write = |name: &str, text: &str| std::fs::write(etc.path().join(name), text).unwrap();
2144 let passwd = "root:x:0:0::/root:/bin/sh\nu:x:1000:1000::/home/u:/bin/sh\n";
2145 write("passwd", passwd);
2146 assert_eq!(primary_in_files(etc.path(), 1000), None, "no nsswitch.conf");
2147 for conf in [
2148 "passwd: files systemd\n",
2149 "passwd: sss files\n",
2150 "passwd: compat\n",
2151 "passwd: files [NOTFOUND=return] ldap\n",
2152 "passwd: files\npasswd: files ldap\n",
2153 "group: files\n",
2154 ] {
2155 write("nsswitch.conf", conf);
2156 assert_eq!(primary_in_files(etc.path(), 1000), None, "{conf}");
2157 }
2158 write(
2159 "nsswitch.conf",
2160 "# local\npasswd: files # only\ngroup: files\n",
2161 );
2162 assert_eq!(primary_in_files(etc.path(), 1000), Some(vec![1000]));
2163 assert_eq!(primary_in_files(etc.path(), 7), Some(vec![]));
2164 for extra in [
2165 "+@admins\n",
2166 "-v\n",
2167 "v:x:1001\n",
2168 "v:x:one:1000::/home/v:/bin/sh\n",
2169 ] {
2170 write("passwd", &format!("{passwd}{extra}"));
2171 assert_eq!(primary_in_files(etc.path(), 1000), None, "{extra}");
2172 }
2173 write(
2174 "passwd",
2175 &format!("{passwd}\n# kept\nv:x:1001:1000::/home/v:/bin/sh\n"),
2176 );
2177 assert_eq!(primary_in_files(etc.path(), 1000), Some(vec![1000, 1001]));
2178 std::fs::remove_file(etc.path().join("passwd")).unwrap();
2179 assert_eq!(primary_in_files(etc.path(), 1000), None);
2180 }
2181
2182 #[test]
2186 fn a_group_is_read_only_from_one_line_of_a_group_file_read_whole() {
2187 let etc = tempfile::tempdir().unwrap();
2188 let write = |name: &str, text: &str| std::fs::write(etc.path().join(name), text).unwrap();
2189 let group = "root:x:0:\nu:x:1000:\nstaff:x:50:u,v\n";
2190 write("group", group);
2191 let read = |gid| {
2192 group_in_files(etc.path(), gid).map(|g| {
2193 let members: Vec<String> = g
2194 .members
2195 .iter()
2196 .map(|m| m.to_string_lossy().into_owned())
2197 .collect();
2198 (g.name.to_string_lossy().into_owned(), members)
2199 })
2200 };
2201 assert_eq!(read(1000), None, "no nsswitch.conf");
2202 for conf in ["group: files systemd\n", "group: sss\n", "passwd: files\n"] {
2203 write("nsswitch.conf", conf);
2204 assert_eq!(read(1000), None, "{conf}");
2205 }
2206 write("nsswitch.conf", "passwd: files\ngroup: files\n");
2207 assert_eq!(read(1000), Some(("u".into(), vec![])));
2208 assert_eq!(
2209 read(50),
2210 Some(("staff".into(), vec!["u".into(), "v".into()]))
2211 );
2212 assert_eq!(read(7), None);
2213 for extra in ["shadow:x:1000:v\n", "+:::\n", "w:x:1000\n", "w:x:ten:\n"] {
2214 write("group", &format!("{group}{extra}"));
2215 assert_eq!(read(1000), None, "{extra}");
2216 }
2217 }
2218
2219 #[test]
2224 fn a_young_runner_in_a_recorded_tool_call_is_refused() {
2225 let _guard = crate::test_env_lock();
2226 let opener = Proc {
2227 pid: 1,
2228 start: 1,
2229 comm: "claude".into(),
2230 };
2231 let young = Proc {
2232 pid: 999_999,
2233 start: u64::MAX,
2234 comm: "claude".into(),
2235 };
2236 let old = Proc {
2237 pid: 42,
2238 start: 0,
2239 comm: "claude".into(),
2240 };
2241 assert!(confirm_runner(&young).is_ok(), "no record yet");
2242 note_tool_call(&opener).unwrap();
2243 let why = confirm_runner(&young).unwrap_err();
2244 assert!(why.contains("tool call"), "{why}");
2245 assert!(confirm_runner(&opener).is_ok(), "the opener recorded it");
2246 assert!(confirm_runner(&old).is_ok(), "older than the record");
2247 std::fs::write(marks_path(), "nope\n").unwrap();
2248 let why = confirm_runner(&old).unwrap_err();
2249 assert!(why.contains("corrupt"), "{why}");
2250 }
2251
2252 #[test]
2253 fn a_tool_calls_variables_mark_a_runner_started_inside_one() {
2254 let env = |k: &str, v: &str| vec![(k.to_string(), v.to_string())];
2255 assert_eq!(tool_call_stamp(&env("CLAUDECODE", "1")), Some("CLAUDECODE"));
2256 assert_eq!(
2257 tool_call_stamp(&env("CURSOR_AGENT", "1")),
2258 Some("CURSOR_AGENT")
2259 );
2260 assert!(tool_call_stamp(&env("CURSOR_CONVERSATION_ID", "0199a1b2-c3d4")).is_some());
2261 assert_eq!(tool_call_stamp(&env("LJOS_SESSION_ID", "mine-0001")), None);
2262 assert_eq!(tool_call_stamp(&env("HOME", "/home/u")), None);
2263 }
2264
2265 #[test]
2266 fn only_a_bare_hook_command_is_the_runners_hook_shell() {
2267 for cmd in [
2268 "ljos hook",
2269 "/home/u/.cargo/bin/ljos hook --runner codex",
2270 "ljos hook --event SessionStart",
2271 "\"${CLAUDE_PLUGIN_ROOT}/bin/ljos-plugin\" ljos hook",
2272 "\"${CURSOR_PLUGIN_ROOT}/bin/ljos-plugin\" ljos hook",
2273 ] {
2274 assert!(bare_hook_command(cmd), "{cmd}");
2275 }
2276 for cmd in [
2277 "ljos hook < plant.json",
2278 "cat plant.json | ljos hook",
2279 "ljos hook <<'EOF'\n{}\nEOF",
2280 "ljos status; ljos hook",
2281 "ljos hook $(cat p)",
2282 "python3 plant.py",
2283 "ljos remember a b c d",
2284 "ljos-plugin hook",
2285 "bash -c 'ljos hook'",
2286 ] {
2287 assert!(!bare_hook_command(cmd), "{cmd}");
2288 }
2289 }
2290
2291 #[test]
2294 fn a_hook_this_test_runs_has_no_runner() {
2295 let why = hook_runner().unwrap_err();
2296 assert!(why.contains("not a runner's own program"), "{why}");
2297 }
2298
2299 fn named(pid: u32, comm: &str) -> Proc {
2300 Proc {
2301 pid,
2302 start: u64::from(pid) * 10,
2303 comm: comm.into(),
2304 }
2305 }
2306
2307 #[test]
2308 fn this_process_has_live_ancestors_and_a_dead_pid_does_not_match() {
2309 let chain = ancestors();
2310 assert!(!chain.is_empty(), "the test runner is above this process");
2311 assert!(chain.iter().all(alive));
2312 let parent = &chain[0];
2313 assert!(token_alive(&token(parent)));
2314 let reused = Proc {
2315 start: parent.start + 1,
2316 ..parent.clone()
2317 };
2318 assert!(!alive(&reused));
2319 assert!(!token_alive(&format!(
2320 "other-boot:{}:{}",
2321 parent.pid, parent.start
2322 )));
2323 }
2324
2325 #[test]
2326 fn a_reaped_child_is_not_alive() {
2327 let mut child = std::process::Command::new("sleep")
2328 .arg("30")
2329 .spawn()
2330 .unwrap();
2331 let pid = child.id();
2332 let p = of(pid).unwrap();
2333 assert!(alive(&p));
2334 assert_eq!(above(pid).first().map(|q| q.pid), Some(std::process::id()));
2335 child.kill().unwrap();
2336 child.wait().unwrap();
2337 assert!(!alive(&p));
2338 }
2339
2340 #[test]
2341 fn a_child_is_read_with_the_environment_it_was_started_with() {
2342 let mut child = std::process::Command::new("sleep")
2343 .arg("30")
2344 .env("LJOS_PROC_TREE_PROBE", "started-with")
2345 .spawn()
2346 .unwrap();
2347 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
2349 let env = loop {
2350 let env = environ(child.id()).unwrap_or_default();
2351 if env.iter().any(|(k, _)| k == "LJOS_PROC_TREE_PROBE")
2352 || std::time::Instant::now() > deadline
2353 {
2354 break env;
2355 }
2356 std::thread::sleep(std::time::Duration::from_millis(10));
2357 };
2358 child.kill().unwrap();
2359 child.wait().unwrap();
2360 assert!(
2361 env.iter()
2362 .any(|(k, v)| k == "LJOS_PROC_TREE_PROBE" && v == "started-with"),
2363 "{env:?}"
2364 );
2365 }
2366
2367 #[test]
2368 fn the_calling_shell_is_the_outermost_of_a_nested_run() {
2369 let harness = named(10, "claude");
2370 let chain = [
2371 named(3, "timeout"),
2372 named(4, "sh"),
2373 named(5, "bash"),
2374 harness.clone(),
2375 ];
2376 assert_eq!(calling_shell(&chain), Some(&chain[2]));
2377 let chain = [
2378 named(4, "sh"),
2379 named(6, "env"),
2380 named(5, "bash"),
2381 named(7, "node"),
2382 named(8, "bash"),
2383 ];
2384 assert_eq!(calling_shell(&chain), Some(&chain[2]));
2385 assert_eq!(calling_shell(&[harness.clone(), named(5, "bash")]), None);
2386 assert_eq!(calling_shell(&[named(3, "env"), harness]), None);
2387 assert_eq!(calling_shell(&[]), None);
2388 }
2389
2390 #[test]
2391 fn the_runner_is_above_the_shells_and_wrappers_it_started() {
2392 let chain = [
2393 named(4, "sh"),
2394 named(3, "timeout"),
2395 named(5, "bash"),
2396 named(10, "claude"),
2397 named(2, "tmux"),
2398 ];
2399 assert_eq!(runner(&chain).map(|p| p.pid), Some(10));
2400 assert_eq!(runner(&chain[3..]).map(|p| p.pid), Some(10));
2401 let shells = [named(4, "sh"), named(5, "bash")];
2402 assert_eq!(runner(&shells).map(|p| p.pid), Some(5));
2403 assert_eq!(runner(&[]), None);
2404 }
2405
2406 #[test]
2407 fn a_session_above_the_hook_is_never_its_runner() {
2408 for session in ["tmux", "tmux: server", "gnome-terminal-", "systemd", "sshd"] {
2409 let chain = [named(4, "sh"), named(5, "bash"), named(2, session)];
2410 assert_eq!(runner(&chain).map(|p| p.pid), Some(5), "{session}");
2411 let chain = [named(4, "sh"), named(2, session), named(10, "claude")];
2412 assert_eq!(runner(&chain).map(|p| p.pid), Some(4), "{session}");
2413 assert_eq!(runner(&[named(2, session), named(10, "claude")]), None);
2414 }
2415 let chain = [named(4, "sh"), named(7, "git"), named(2, "kitty")];
2416 assert_eq!(runner(&chain).map(|p| p.pid), Some(7));
2417 }
2418
2419 fn fake(n: u32, comm: &str) -> Proc {
2422 named((1 << 22) + n, comm)
2423 }
2424
2425 #[test]
2426 fn a_harness_outranks_a_runtime_that_only_started_the_hook() {
2427 let chain = [
2428 fake(1, "sh"),
2429 fake(2, "uv"),
2430 fake(3, "uv"),
2431 fake(4, "claude"),
2432 ];
2433 assert_eq!(runner(&chain), Some(&chain[3]));
2434 let chain = [fake(1, "sh"), fake(2, "node"), fake(3, "claude")];
2435 assert_eq!(runner(&chain), Some(&chain[2]));
2436 let chain = [
2437 fake(1, "sh"),
2438 fake(2, "npx"),
2439 fake(3, "bash"),
2440 fake(4, "cursor-agent"),
2441 fake(5, "tmux"),
2442 ];
2443 assert_eq!(runner(&chain), Some(&chain[3]));
2444 let chain = [
2445 fake(1, "sh"),
2446 fake(2, "uv"),
2447 fake(3, "codex"),
2448 fake(4, "agy"),
2449 ];
2450 assert_eq!(runner(&chain), Some(&chain[2]), "the nearest harness");
2451 }
2452
2453 #[test]
2454 fn a_node_harness_with_no_known_harness_above_is_the_runner() {
2455 let chain = [
2456 fake(1, "sh"),
2457 fake(2, "node"),
2458 fake(3, "bash"),
2459 fake(4, "tmux"),
2460 ];
2461 assert_eq!(runner(&chain), Some(&chain[1]));
2462 assert_eq!(runner(&chain[..2]), Some(&chain[1]));
2463 let chain = [
2465 fake(1, "sh"),
2466 fake(2, "node"),
2467 fake(3, "tmux"),
2468 fake(4, "claude"),
2469 ];
2470 assert_eq!(runner(&chain), Some(&chain[1]));
2471 }
2472
2473 #[test]
2474 fn pid_1_is_the_runner_only_as_a_harness() {
2475 let chain = [fake(1, "sh"), fake(2, "uv"), named(1, "claude")];
2476 assert_eq!(runner(&chain), Some(&chain[2]), "a container's harness");
2477 let chain = [fake(1, "sh"), fake(2, "bash"), named(1, "tini")];
2478 assert_eq!(runner(&chain), Some(&chain[1]), "an init is no runner");
2479 let chain = [fake(1, "sh"), fake(2, "node"), named(1, "dumb-init")];
2480 assert_eq!(runner(&chain), Some(&chain[1]));
2481 assert_eq!(runner(&[named(1, "tini")]), None);
2482 }
2483
2484 #[test]
2485 fn the_walk_reads_up_to_a_process_with_no_parent() {
2486 let chain = ancestors();
2487 let top = chain.last().expect("the test runner is above this process");
2488 assert!(
2489 chain.len() == DEPTH || stat(top.pid).is_some_and(|s| s.ppid == 0),
2490 "{chain:?}"
2491 );
2492 }
2493}