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