Skip to main content

ljos_cli/
proc_tree.rs

1//! Processes above this one, each named by its pid and its start time, so
2//! a pid the kernel has since handed to another process is not taken for
3//! the one that was recorded. Read from `/proc`; elsewhere every list is
4//! empty and nothing is alive.
5
6use std::path::{Path, PathBuf};
7
8/// One process: its pid, when it started (clock ticks after boot), and its
9/// name at the time it was read.
10#[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    /// The same process: the name may change at exec, the start does not.
20    #[must_use]
21    pub fn same(&self, other: &Self) -> bool {
22        self.pid == other.pid && self.start == other.start
23    }
24}
25
26/// How far up a chain is read.
27const 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    // After the name: state, ppid, ... and starttime, field 22 of stat(5).
42    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/// The process `pid` names now.
52#[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/// The processes above `pid`, nearest first, up to and including pid 1:
62/// in a container that may be the harness itself.
63#[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/// The processes above this one, nearest first.
82#[must_use]
83pub fn ancestors() -> Vec<Proc> {
84    above(std::process::id())
85}
86
87/// `pid`'s parent and its own name.
88#[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    // SAFETY: the buffer is a zeroed proc_bsdinfo of the size passed.
98    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/// The environment `pid` was started with. A program it runs can set its
130/// own variables, but cannot change what its parent was started with.
131#[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    // SAFETY: a null buffer asks sysctl for the size alone.
149    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    // SAFETY: buf holds the size sysctl reported.
164    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    // argc, the executable path and its padding, argc words, then the
179    // environment up to an empty string.
180    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/// The process the nearest runner on this process's chain started: the
196/// shell it opened for a tool call, or this process when the runner ran
197/// it directly. `None` when the chain reaches the person's session or the
198/// top without a runner.
199#[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/// Whether `p` still runs: the same pid with the same start, and not a
220/// zombie waiting to be reaped.
221#[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/// This boot, so a process recorded before a reboot or on another machine
227/// never matches one here.
228#[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/// `p` as one word that names it on this machine and boot.
247#[must_use]
248pub fn token(p: &Proc) -> String {
249    format!("{}:{}:{}", boot(), p.pid, p.start)
250}
251
252/// Whether the process a [`token`] names still runs here.
253#[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
274/// Programs that run a command and stand between it and the shell that
275/// asked for it.
276const 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/// The shell that ran the process whose ancestors are `chain`: past any
294/// wrappers, the outermost of the shells nested directly above it, so a
295/// script that starts a fresh `sh -c` per line is still one shell. `None`
296/// when a program other than a shell ran it.
297#[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
311/// Where a chain stops being the hook's own: the person's session, a
312/// terminal or the init system ([`crate::is_session`]), or pid 1 unless
313/// it is a harness. Every sitting in that session shares it, and it
314/// outlives each of them.
315fn ends_the_walk(p: &Proc) -> bool {
316    crate::is_session(&p.comm) || (p.pid == 1 && !is_harness(p))
317}
318
319/// The program that ran the process whose ancestors are `chain`, read up
320/// to the session ([`ends_the_walk`]), which is never the program: the
321/// nearest harness ([`crate::is_harness_process`]), so a `uv`, `npx` or
322/// `node` that only started the hook and exits with it does not outrank
323/// the harness above it; else the nearest one that is neither a shell
324/// nor a wrapper, which keeps a harness that runs as plain `node`; else
325/// the outermost of the shells and wrappers below the session. `None`
326/// when the session ran the process itself.
327#[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
336/// The runners that spawn `ljos hook`, by the name of the program they
337/// are: `claude-code` is Claude Code's npm package.
338const 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
351/// Runtimes a runner may ship as: the program is the directory or script
352/// above them.
353const RUNTIMES: &[&str] = &["node", "nodejs", "bun", "deno"];
354
355/// The stem of a path component, so `cli.js` is the entry `cli`.
356fn 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
364/// A path component that names where a runner is packed, not the runner.
365fn 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
378/// The runner a component names, after an architecture suffix is dropped.
379fn 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/// The home this user's passwd entry names. A tool call sets `HOME` for
390/// whatever it starts, but cannot change this.
391#[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        // SAFETY: passwd is plain data, so all zeroes is a valid value.
397        let mut entry: libc::passwd = unsafe { std::mem::zeroed() };
398        let mut found: *mut libc::passwd = std::ptr::null_mut();
399        // SAFETY: getpwuid_r writes at most buf.len() bytes into buf and
400        // points entry's fields into it; buf outlives every read below.
401        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        // SAFETY: pw_dir is a NUL-terminated string inside buf.
418        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
424/// Where a runner is installed under the person's home ([`own_home`]):
425/// a user `bin`, and the prefix npm's own guide gives a user for global
426/// packages. These are trusted because the person owns them, which
427/// also lets any process of the person's uid, a tool call among them,
428/// replace what they hold: the check keeps out other users and copies
429/// placed elsewhere, not the person's own account.
430const HOME_ROOTS: &[&str] = &[".local/bin", ".cargo/bin", ".npm-global"];
431
432/// Where a runner is installed for every user, and where npm keeps a
433/// global package.
434const 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
444/// The directories a runner is installed below, for a person whose
445/// passwd home is `home`: the roots above, and an app directory of a
446/// runner's name under `~/.local/share` or `/usr/share`, where a native
447/// installer keeps the versions `~/.local/bin` links to.
448fn 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
461/// Where Nix keeps what it builds.
462const NIX_STORE: &str = "/nix/store";
463
464/// The `STORE/HASH-NAME` entry `path` sits in, STORE being Nix's store
465/// ([`NIX_STORE`]) and HASH 32 characters of Nix's base 32.
466fn 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
475/// The runner `path` names when it sits below a root ([`root_below`]),
476/// and that root: the nearest component that is not packing layout
477/// ([`layout_component`]), read from the file up to the root's own name,
478/// so `share/claude/versions/2.1.3` is Claude Code.
479fn 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
491/// The one of `roots`, or the entry of the Nix store `store`, that
492/// `path` sits strictly below. The path must be absolute and plain,
493/// since a `..` after the root would leave it. A path the kernel marks
494/// ` (deleted)` is not the file that was installed.
495fn 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
513/// A group as the group database lists it.
514struct GroupEntry {
515    name: std::ffi::OsString,
516    members: Vec<std::ffi::OsString>,
517}
518
519/// The account lookups [`group_is_private`] makes. `None` is a lookup
520/// that failed or that cannot be shown to be complete, which is never
521/// read as an empty answer.
522trait Accounts {
523    fn user_name(&self, uid: u32) -> Option<std::ffi::OsString>;
524    fn group(&self, gid: u32) -> Option<GroupEntry>;
525    /// The uid of every passwd entry whose primary group is `gid`.
526    fn primary_members(&self, gid: u32) -> Option<Vec<u32>>;
527}
528
529/// The passwd and group databases: a user's name through the C
530/// library, and a group and the accounts in it from `/etc/group` and
531/// `/etc/passwd` themselves ([`group_in_files`], [`primary_in_files`]),
532/// since a lookup by gid returns only the first line and enumerating
533/// NSS can stop short.
534struct SystemAccounts;
535
536/// Runs a `get*_r` lookup with a buffer that grows while it is too small.
537fn 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
548/// # Safety
549/// `ptr` is a NUL-terminated string.
550unsafe fn os_string(ptr: *const libc::c_char) -> std::ffi::OsString {
551    use std::os::unix::ffi::OsStrExt as _;
552    // SAFETY: the caller's contract.
553    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            // SAFETY: passwd is plain data, so all zeroes is a valid value.
561            let mut entry: libc::passwd = unsafe { std::mem::zeroed() };
562            let mut found: *mut libc::passwd = std::ptr::null_mut();
563            // SAFETY: getpwuid_r writes at most buf.len() bytes into buf
564            // and points entry's fields into it.
565            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            // SAFETY: a found entry's pw_name is a string inside buf.
578            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
592/// Where the account files and `nsswitch.conf` are.
593const ETC: &str = "/etc";
594
595/// Whether `etc/nsswitch.conf` sends database `db` to `files` and
596/// nothing else, so the file under `etc` is all of it. A missing
597/// file, a missing or repeated line, or any other source, such as
598/// `systemd`, `sss`, `ldap` or `compat`, may add accounts that file
599/// does not list.
600fn 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
615/// The one line of `etc/group` for `gid`, read only when group is
616/// that file alone ([`files_only`]). `None` when no line or more than
617/// one has that gid, since a lookup returns only the first and the
618/// members of the others still hold the group, and when the file
619/// cannot be read whole, as for `passwd` ([`primary_in_files`]).
620fn 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
655/// The uid of every line of `etc/passwd` whose primary group is `gid`,
656/// read only when passwd is that file alone ([`files_only`]). `None`
657/// when the file cannot be read whole: a line that is not seven fields
658/// with numeric ids, or a `+` or `-` line, which names accounts kept
659/// elsewhere.
660fn 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
685/// Whether group `gid` holds no one but user `uid`: it bears the user's
686/// name, lists no other member, and is no other account's primary group.
687fn 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
700/// Who [`person_keeps`] judges a file for: this process's user,
701/// primary group and supplementary groups (`None` when they could not
702/// be read), and whether the primary group is the user's alone, asked
703/// only when a file is group-writable.
704struct 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        // SAFETY: getuid and getgid take no arguments and only read the
715        // process identity.
716        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    /// Whether the person writes what group `gid` may write. Groups that
727    /// could not be read count as every group.
728    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
743/// This process's supplementary groups.
744fn supplementary_groups() -> Option<Vec<u32>> {
745    // SAFETY: a size of zero asks only for the count and writes nothing.
746    let count = unsafe { libc::getgroups(0, std::ptr::null_mut()) };
747    let mut groups = vec![0; usize::try_from(count).ok()?];
748    // SAFETY: getgroups writes at most `count` ids into groups.
749    let got = unsafe { libc::getgroups(count, groups.as_mut_ptr()) };
750    groups.truncate(usize::try_from(got).ok()?);
751    Some(groups)
752}
753
754/// A file's owner, group and mode.
755#[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/// The owner of `/` and of each directory below it down to `file`, and
774/// of `file`, each read from a descriptor opened beneath the one before
775/// without following a link, so a directory swapped for a link after
776/// `file` was resolved is not walked through. One swapped for another
777/// directory leads to another file than `expect`, the (device, inode)
778/// the caller holds for `file`. `None` unless every component above
779/// `file` is a directory and the file the walk ends on is a regular
780/// file that is `expect`.
781#[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        // SAFETY: name is NUL-terminated, and openat only reads it.
788        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        // SAFETY: a descriptor openat returned is open and owned here.
796        (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
827/// Whether a Nix store and an entry in it are as Nix leaves them, so
828/// that only Nix can change what the entry holds. The store is root's,
829/// the person cannot write it, and it is sticky if its group or anyone
830/// may write it; the entry is then root's, as the daemon builds it. On
831/// a single-user install the store is the person's and no one else may
832/// write it, and the entry is the person's or root's. Such a store, like
833/// a home root ([`HOME_ROOTS`]), is writable by a tool call of the
834/// person's uid, and is trusted only because the person owns it.
835fn 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
848/// Whether root or `person` owns a file, and no one else can write it
849/// but `person`'s primary group when that group is theirs alone.
850fn 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
856/// The runner a program file is, when the file is an installed copy for
857/// the person whose passwd home is `home` ([`runner_below`]).
858/// `~/.local/share/claude/versions/2.1.3` is Claude Code, and so is
859/// `/usr/lib/node_modules/@anthropic-ai/claude-code/cli.js`. A bundled
860/// runtime (`cursor-agent/versions/V/node`) is the runner it ships in.
861/// The file and the roots are compared as the kernel resolves them, so a
862/// link from `~/.local/bin` into `/tmp` is read in `/tmp`. A copy whose
863/// name is the runner, in a temp directory, a project tree or a `.local`
864/// of its own making, is not: the name alone is what a tool call can
865/// choose. Nor is a file that someone other than the person could have
866/// written, or swapped through a directory between it and its root, or
867/// an entry of a Nix store `store` that Nix does not keep
868/// ([`store_keeps`]). The directories and the file are judged as they
869/// were opened ([`pinned_walk`]) from `path` resolved once, and the
870/// file the walk ends on must be `expect`, the (device, inode) the
871/// caller holds for the file it means.
872fn 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
888/// [`runner_of_file`] for `file`, a path already resolved.
889fn 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/// Whether `path` is a runtime ([`RUNTIMES`]) by its exact name, and
902/// an installed copy kept as a runner file is ([`runner_of_file`]):
903/// below an install root, left to the person, and the file `expect`.
904#[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
925/// The install roots for `home` ([`install_roots`]) and the Nix store
926/// `store`, resolved.
927fn 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
936/// Whether `file`, a resolved path below install root `root`, is the
937/// file `expect` and left to the person ([`walk_kept`]) as the walk
938/// reads it ([`pinned_walk`]). A file in a temp directory never is.
939fn 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
952/// Whether the owners [`pinned_walk`] read leave a file to the person,
953/// its install root being at `at`. The directory holding the root, the
954/// root, and all below it are kept by the person ([`person_keeps`]),
955/// since whoever may write the root's directory may rename the root.
956/// For an entry of a Nix store, the store and the entry are as Nix keeps
957/// them ([`store_keeps`]) instead.
958fn 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/// The runner `pid` is, from the file the kernel runs it from, never
974/// from the name it gives itself. An installed runtime such as `node`
975/// ([`runtime_of_file`]) is the runner whose script it runs, that
976/// script's path resolved on disk and judged as a runner file is. The
977/// script's path comes from the process's own arguments, so only a
978/// runtime the person installed is believed about it.
979#[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/// [`exe_runner`] for a process run from `exe`, whose (device, inode)
1010/// the kernel reports as `running`, and that names `script` as the
1011/// script a runtime there runs. `None` when `running` could not be
1012/// read, since the path alone may name another file than the one the
1013/// process runs.
1014#[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/// Off Linux there is no `/proc/<pid>/exe` to read nor a descriptor
1037/// walk ([`pinned_walk`]), so no process is an installed runner.
1038#[cfg(not(target_os = "linux"))]
1039#[must_use]
1040pub fn exe_runner(_pid: u32) -> Option<&'static str> {
1041    None
1042}
1043
1044/// Editors and agents. A server whose chain still holds one of these is
1045/// not an orphan. Matched on the file name, without regard to case.
1046const 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/// Whether `name` is an editor or an agent. A path uses its file name.
1093#[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    // SAFETY: a null buffer asks sysctl for the size alone.
1116    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    // SAFETY: buf holds the size sysctl reported.
1131    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/// Whether an editor or an agent is still above this process. Linux also
1158/// accepts the runner named by the file the process is running, so a
1159/// `node` that is only hosting an editor still counts.
1160#[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    // SAFETY: getppid has no preconditions.
1174    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
1199/// Whether `cmd`, a shell's `-c` line, runs `ljos hook` and nothing else:
1200/// the program (`ljos`, a path to it, or the plugin launcher with `ljos`),
1201/// `hook`, and flags. `${NAME}` and double quotes are the runner's own
1202/// spelling of the launcher; a redirection, pipe or second command,
1203/// which a tool call needs to hand the hook a payload, is not.
1204fn 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/// Whether `pid` is the shell a runner opens to run its configured hook
1240/// command: a shell by the file it runs from, started as `SHELL -c CMD`
1241/// with [`bare_hook_command`] for CMD. `dash` forks that command rather
1242/// than replacing itself with it.
1243#[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/// The nearest process above this one that is a runner by the file the
1267/// kernel runs it from ([`exe_runner`]), and which runner it is.
1268#[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
1275/// Variables a runner gives the shell of a tool call. A runner started
1276/// with one runs inside another runner's tool call.
1277fn 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
1288/// One tool call the guard's `PreToolUse` saw: the session and cgroup it
1289/// could not shed, the start time of that hook (a later process is part
1290/// of the call), and the runner that spawned the hook.
1291struct 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
1301/// The session and cgroup of this process. A child inherits both until
1302/// it calls `setsid` or migrates cgroup.
1303struct 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
1316/// The calling process's session id.
1317fn current_sid() -> Result<u32, String> {
1318    // SAFETY: getsid(0) reads the calling process's session id and writes
1319    // nothing the caller owns.
1320    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
1346/// Remember that a tool-call record could not be written. A later read
1347/// then fails closed: a missing record means no tool call, and a failed
1348/// write must not look like that.
1349/// A tool call may have happened and its evidence could not be taken.
1350/// Later reads fail closed instead of looking like a quiet runtime.
1351pub 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
1422/// Record the tool call whose guard hook this is. The runner is `runner`,
1423/// the process that spawned the hook. The hook's own start time is the
1424/// watermark: a process started later, in this session or cgroup, is the
1425/// tool call even after it detaches.
1426///
1427/// # Errors
1428///
1429/// The session, cgroup or start time cannot be read, or the record cannot
1430/// be written. A failed write leaves a sentinel so later reads fail closed.
1431pub fn note_tool_call(runner: &Proc) -> Result<(), String> {
1432    // A tool call whose session, cgroup or start time cannot be read must
1433    // not look like a runtime that has seen no tool call.
1434    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    // sid, watermark, runner pid and start, tool shell pid and start
1461    // (unset when the shell does not exist yet), cgroup text as hex.
1462    // One line stays under PIPE_BUF so the append is one write.
1463    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
1497/// Whether `runner` is the runner a person started, rather than a process
1498/// a recorded tool call still owns. The same runner process that recorded
1499/// a mark is that opener. Any other process that shares the mark's cgroup
1500/// or session and is not older than the mark is the tool call. No marks
1501/// means the person has not made a tool call in this runtime yet.
1502fn 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
1531/// The installed runner above this process, checked against the tool-call
1532/// record ([`confirm_runner`]). Shells and wrappers are stepped over. The
1533/// first other program that is not an installed runner ends the walk, so
1534/// a copy named like a runner cannot hide behind a real one further up.
1535///
1536/// # Errors
1537///
1538/// No installed runner is above this process, or the one that is still
1539/// belongs to a recorded tool call.
1540pub 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
1559/// The runner that spawned this `ljos hook` itself, or why none did: the
1560/// parent must be an installed runner ([`exe_runner`]), with no shell or
1561/// interpreter between them but the `sh -c` the runner runs its hook
1562/// command in ([`hook_shell`]). That runner must not run inside a tool
1563/// call, which a runner above it, a tool call's variables in the
1564/// environment it was started with, or the guard's tool-call record
1565/// ([`confirm_runner`]) would show. Only such a hook's prompt is the
1566/// person's. An environment that cannot be read hides the stamp, so it
1567/// is refused.
1568///
1569/// # Errors
1570///
1571/// Why the parent is not a runner the person started.
1572pub 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    // The Cursor app also hosts the person's own terminals; its agent's
1598    // shells carry `CURSOR_AGENT`, which the stamp above reads.
1599    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    /// [`runner_of_file`] for the file `path` names now.
1618    #[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    /// A runner is read where the kernel resolves it. A link from the
1715    /// person's own `~/.local/bin` into `/tmp`, a path that climbs out of
1716    /// that home into another, and a file anyone may rewrite or reach
1717    /// through a directory anyone may write are not installed copies; the
1718    /// same file kept by the person is.
1719    #[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    /// Whoever may write the directory an install root sits in may swap
1769    /// the root, so that directory is judged as the root is: each root
1770    /// under the person's home is refused once its own directory is
1771    /// writable by anyone or by a group the person shares.
1772    #[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    /// The file judged is the one opened. A directory swapped after the
1837    /// path resolved, for a link or for another directory holding a file
1838    /// of the same name, is not walked through, and a file that is not
1839    /// the one the process runs is not its runner.
1840    #[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    /// A process is its runner only through the (device, inode) the
1877    /// kernel reports for its executable. When that cannot be read, the
1878    /// path the link names is not trusted in its place.
1879    #[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    /// A runtime's script names the runner only when the runtime is an
1902    /// installed copy of its exact name, kept as a runner file is and the
1903    /// file the process runs. A `node` of a tool call's own making, or a
1904    /// program whose name merely starts like one, may name any script.
1905    #[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    /// A Nix store entry is a runner only while Nix alone keeps it. A
1963    /// store the person owns counts as a single-user install only while
1964    /// no one else may write it; a root's store must be closed to the
1965    /// person, sticky when its group may write it, and hold root's
1966    /// entries.
1967    #[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    /// One user `u`, the name and members of whatever group is asked
2044    /// for, and the uids whose primary group it is.
2045    #[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    /// A runner file the person's primary group may write is theirs only
2072    /// when that group is: named for them, listing no one else, and no
2073    /// other account's primary group. An account lookup that fails is
2074    /// not such a group.
2075    #[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        // SAFETY: getuid takes no arguments and only reads the process
2080        // identity.
2081        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    /// The accounts in a group are read from `passwd` only when
2138    /// `nsswitch.conf` names that file alone, and a file that cannot be
2139    /// read whole lists no one rather than fewer.
2140    #[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    /// A group is read from `group` only when `nsswitch.conf` names that
2183    /// file alone, and only when one line has its gid: a second line's
2184    /// members hold the group as well, though a lookup returns the first.
2185    #[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    /// A process younger than a recorded tool call, in that call's cgroup
2220    /// or session, is still the tool call. The runner that recorded the
2221    /// mark is not. A missing record is not a tool call; a corrupt one
2222    /// refuses everything.
2223    #[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    /// The test runner is no runner's program, so a hook it spawned is
2292    /// not the person's.
2293    #[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        // Until the child has exec'd, its environment is the fork's.
2348        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    /// A process no kernel runs, so no `/proc` entry names it: pids stop
2420    /// below 2^22.
2421    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        // The walk stops at the session: a harness above it is not read.
2464        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}