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