use std::path::{Path, PathBuf};
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct Proc {
pub pid: u32,
pub start: u64,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub comm: String,
}
impl Proc {
#[must_use]
pub fn same(&self, other: &Self) -> bool {
self.pid == other.pid && self.start == other.start
}
}
const DEPTH: usize = 32;
struct Stat {
comm: String,
dead: bool,
ppid: u32,
start: u64,
}
fn stat(pid: u32) -> Option<Stat> {
let raw = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
let open = raw.find('(')?;
let close = raw.rfind(')')?;
let comm = raw.get(open + 1..close)?.to_string();
let rest: Vec<&str> = raw.get(close + 1..)?.split_whitespace().collect();
Some(Stat {
comm,
dead: matches!(*rest.first()?, "Z" | "X" | "x"),
ppid: rest.get(1)?.parse().ok()?,
start: rest.get(19)?.parse().ok()?,
})
}
#[must_use]
pub fn of(pid: u32) -> Option<Proc> {
stat(pid).map(|s| Proc {
pid,
start: s.start,
comm: s.comm,
})
}
#[must_use]
pub fn above(pid: u32) -> Vec<Proc> {
let mut out = Vec::new();
let mut next = stat(pid).map(|s| s.ppid);
while let Some(p) = next.filter(|&p| p > 0 && out.len() < DEPTH) {
let Some(s) = stat(p) else {
break;
};
next = Some(s.ppid);
out.push(Proc {
pid: p,
start: s.start,
comm: s.comm,
});
}
out
}
#[must_use]
pub fn ancestors() -> Vec<Proc> {
above(std::process::id())
}
#[cfg(not(target_os = "macos"))]
fn parent_and_name(pid: u32) -> Option<(u32, String)> {
stat(pid).map(|s| (s.ppid, s.comm))
}
#[cfg(target_os = "macos")]
fn parent_and_name(pid: u32) -> Option<(u32, String)> {
let mut info: libc::proc_bsdinfo = unsafe { std::mem::zeroed() };
let size = libc::c_int::try_from(std::mem::size_of::<libc::proc_bsdinfo>()).ok()?;
let n = unsafe {
libc::proc_pidinfo(
libc::c_int::try_from(pid).ok()?,
libc::PROC_PIDTBSDINFO,
0,
std::ptr::addr_of_mut!(info).cast(),
size,
)
};
if n != size {
return None;
}
let comm: Vec<u8> = info
.pbi_comm
.iter()
.take_while(|c| **c != 0)
.map(|c| c.to_ne_bytes()[0])
.collect();
Some((info.pbi_ppid, String::from_utf8_lossy(&comm).into_owned()))
}
fn split_environ(raw: &[u8]) -> Vec<(String, String)> {
raw.split(|b| *b == 0)
.filter_map(|kv| {
let kv = String::from_utf8_lossy(kv);
let (k, v) = kv.split_once('=')?;
Some((k.to_string(), v.to_string()))
})
.collect()
}
#[cfg(not(target_os = "macos"))]
#[must_use]
pub fn environ(pid: u32) -> Option<Vec<(String, String)>> {
std::fs::read(format!("/proc/{pid}/environ"))
.ok()
.map(|raw| split_environ(&raw))
}
#[cfg(target_os = "macos")]
#[must_use]
pub fn environ(pid: u32) -> Option<Vec<(String, String)>> {
let mut mib = [
libc::CTL_KERN,
libc::KERN_PROCARGS2,
libc::c_int::try_from(pid).ok()?,
];
let mut size: libc::size_t = 0;
let asked = unsafe {
libc::sysctl(
mib.as_mut_ptr(),
3,
std::ptr::null_mut(),
&mut size,
std::ptr::null_mut(),
0,
)
};
if asked != 0 || size < 4 {
return None;
}
let mut buf = vec![0u8; size];
let read = unsafe {
libc::sysctl(
mib.as_mut_ptr(),
3,
buf.as_mut_ptr().cast(),
&mut size,
std::ptr::null_mut(),
0,
)
};
if read != 0 {
return None;
}
buf.truncate(size);
let argc = usize::try_from(i32::from_ne_bytes(buf.get(..4)?.try_into().ok()?)).ok()?;
let mut words = buf[4..]
.split(|b| *b == 0)
.skip(1)
.skip_while(|w| w.is_empty());
for _ in 0..argc {
words.next()?;
}
let env: Vec<u8> = words
.take_while(|w| !w.is_empty())
.flat_map(|w| w.iter().copied().chain([0]))
.collect();
Some(split_environ(&env))
}
#[must_use]
pub fn started_by_runner() -> Option<u32> {
let mut child = std::process::id();
for _ in 0..DEPTH {
let (ppid, _) = parent_and_name(child)?;
if ppid == 0 {
return None;
}
let (_, comm) = parent_and_name(ppid)?;
if crate::is_harness_process(ppid, &comm) {
return Some(child);
}
if crate::is_session(&comm) || ppid == 1 {
return None;
}
child = ppid;
}
None
}
#[must_use]
pub fn alive(p: &Proc) -> bool {
stat(p.pid).is_some_and(|s| s.start == p.start && !s.dead)
}
#[must_use]
pub fn boot() -> &'static str {
static BOOT: std::sync::OnceLock<String> = std::sync::OnceLock::new();
BOOT.get_or_init(|| {
std::fs::read_to_string("/proc/sys/kernel/random/boot_id")
.ok()
.map(|s| {
s.trim()
.chars()
.filter(char::is_ascii_hexdigit)
.take(12)
.collect()
})
.filter(|s: &String| !s.is_empty())
.unwrap_or_else(|| "-".into())
})
}
#[must_use]
pub fn token(p: &Proc) -> String {
format!("{}:{}:{}", boot(), p.pid, p.start)
}
#[must_use]
pub fn token_alive(token: &str) -> bool {
let mut parts = token.splitn(3, ':');
let (Some(at), Some(pid), Some(start)) = (parts.next(), parts.next(), parts.next()) else {
return false;
};
let (Ok(pid), Ok(start)) = (pid.parse(), start.parse()) else {
return false;
};
at == boot()
&& alive(&Proc {
pid,
start,
comm: String::new(),
})
}
const SHELLS: &[&str] = &[
"sh", "bash", "dash", "zsh", "ksh", "mksh", "ash", "yash", "fish", "tcsh", "csh", "busybox",
];
const WRAPPERS: &[&str] = &[
"env", "nice", "nohup", "timeout", "stdbuf", "setsid", "ionice", "chrt", "taskset", "time",
"flock", "sudo", "doas", "xargs",
];
fn is_shell(p: &Proc) -> bool {
SHELLS.contains(&p.comm.as_str())
}
fn is_wrapper(p: &Proc) -> bool {
WRAPPERS.contains(&p.comm.as_str())
}
fn is_harness(p: &Proc) -> bool {
crate::is_harness_process(p.pid, &p.comm)
}
#[must_use]
pub fn calling_shell(chain: &[Proc]) -> Option<&Proc> {
let mut rest = chain.iter().skip_while(|p| is_wrapper(p));
let mut top = rest.next().filter(|p| is_shell(p))?;
for p in rest {
if is_shell(p) {
top = p;
} else if !is_wrapper(p) {
break;
}
}
Some(top)
}
fn ends_the_walk(p: &Proc) -> bool {
crate::is_session(&p.comm) || (p.pid == 1 && !is_harness(p))
}
#[must_use]
pub fn runner(chain: &[Proc]) -> Option<&Proc> {
let own = chain.iter().take_while(|p| !ends_the_walk(p));
own.clone()
.find(|p| is_harness(p))
.or_else(|| own.clone().find(|p| !is_shell(p) && !is_wrapper(p)))
.or_else(|| own.last())
}
const HOOK_RUNNERS: &[(&str, &str)] = &[
("claude", "claude"),
("claude-code", "claude"),
("codex", "codex"),
("cursor-agent", "cursor-agent"),
("cursor", "cursor"),
("grok", "grok"),
("opencode", "opencode"),
("gemini", "gemini"),
("agy", "agy"),
("antigravity", "agy"),
];
const RUNTIMES: &[&str] = &["node", "nodejs", "bun", "deno"];
fn component_stem(component: &str) -> &str {
component
.strip_suffix(".js")
.or_else(|| component.strip_suffix(".mjs"))
.or_else(|| component.strip_suffix(".cjs"))
.unwrap_or(component)
}
fn layout_component(component: &str) -> bool {
let name = component_stem(component);
crate::version_like(name)
|| crate::PLACES.contains(&name)
|| crate::ENTRY_STEMS.contains(&name)
|| RUNTIMES.contains(&name)
|| matches!(name, "vendor" | "share")
|| name.starts_with('@')
|| name.contains("unknown-linux")
|| matches!(name, "x86_64" | "aarch64" | "arm64")
|| name.starts_with("armv")
}
fn hook_name(component: &str) -> Option<&'static str> {
let name = component_stem(component);
let name = name.split_once("-x86_64").map_or(name, |(head, _)| head);
let name = name.split_once("-aarch64").map_or(name, |(head, _)| head);
HOOK_RUNNERS
.iter()
.find(|(n, _)| *n == name)
.map(|(_, runner)| *runner)
}
#[must_use]
pub fn own_home() -> Option<std::path::PathBuf> {
use std::os::unix::ffi::OsStrExt as _;
let mut buf = vec![0u8; 1024];
loop {
let mut entry: libc::passwd = unsafe { std::mem::zeroed() };
let mut found: *mut libc::passwd = std::ptr::null_mut();
let rc = unsafe {
libc::getpwuid_r(
libc::getuid(),
&mut entry,
buf.as_mut_ptr().cast(),
buf.len(),
&mut found,
)
};
if rc == libc::ERANGE && buf.len() < 1 << 20 {
buf.resize(buf.len() * 2, 0);
continue;
}
if rc != 0 || found.is_null() || entry.pw_dir.is_null() {
return None;
}
let dir = unsafe { std::ffi::CStr::from_ptr(entry.pw_dir) };
let home = std::path::PathBuf::from(std::ffi::OsStr::from_bytes(dir.to_bytes()));
return home.is_absolute().then_some(home);
}
}
const HOME_ROOTS: &[&str] = &[".local/bin", ".cargo/bin", ".npm-global"];
const SYSTEM_ROOTS: &[&str] = &[
"/usr/bin",
"/bin",
"/usr/local/bin",
"/opt/homebrew/bin",
"/usr/lib/node_modules",
"/usr/local/lib/node_modules",
"/opt/homebrew/lib/node_modules",
];
fn install_roots(home: Option<&Path>) -> Vec<PathBuf> {
let mut roots: Vec<PathBuf> = SYSTEM_ROOTS.iter().map(PathBuf::from).collect();
let mut shares = vec![PathBuf::from("/usr/share")];
if let Some(home) = home {
roots.extend(HOME_ROOTS.iter().map(|root| home.join(root)));
shares.push(home.join(".local/share"));
}
for share in shares {
roots.extend(HOOK_RUNNERS.iter().map(|(name, _)| share.join(name)));
}
roots
}
const NIX_STORE: &str = "/nix/store";
fn nix_entry(path: &Path, store: &Path) -> Option<PathBuf> {
let name = path.strip_prefix(store).ok()?.iter().next()?.to_str()?;
let (hash, rest) = name.split_at_checked(32)?;
let hashed = hash
.chars()
.all(|c| "0123456789abcdfghijklmnpqrsvwxyz".contains(c));
(hashed && rest.len() > 1 && rest.starts_with('-')).then(|| store.join(name))
}
fn runner_below(path: &Path, roots: &[PathBuf], store: &Path) -> Option<(&'static str, PathBuf)> {
let plain = path.is_absolute()
&& path.components().all(|c| {
matches!(
c,
std::path::Component::RootDir | std::path::Component::Normal(_)
)
});
if !plain || path.to_str()?.contains(" (deleted)") {
return None;
}
let root = roots
.iter()
.cloned()
.chain(nix_entry(path, store))
.find(|root| path != root.as_path() && path.starts_with(root))?;
let parts = root
.file_name()
.into_iter()
.chain(path.strip_prefix(&root).ok()?.iter())
.map(std::ffi::OsStr::to_str)
.collect::<Option<Vec<_>>>()?;
let name = parts.iter().rev().find(|part| !layout_component(part))?;
Some((hook_name(name)?, root))
}
struct GroupEntry {
name: std::ffi::OsString,
members: Vec<std::ffi::OsString>,
}
trait Accounts {
fn user_name(&self, uid: u32) -> Option<std::ffi::OsString>;
fn group(&self, gid: u32) -> Option<GroupEntry>;
fn primary_members(&self, gid: u32) -> Option<Vec<u32>>;
}
struct SystemAccounts;
fn with_buf<T>(mut lookup: impl FnMut(&mut [u8]) -> Result<Option<T>, i32>) -> Option<T> {
let mut buf = vec![0u8; 1024];
loop {
match lookup(&mut buf) {
Err(libc::ERANGE) if buf.len() < 1 << 20 => buf.resize(buf.len() * 2, 0),
Ok(found) => return found,
Err(_) => return None,
}
}
}
unsafe fn os_string(ptr: *const libc::c_char) -> std::ffi::OsString {
use std::os::unix::ffi::OsStrExt as _;
let bytes = unsafe { std::ffi::CStr::from_ptr(ptr) }.to_bytes();
std::ffi::OsStr::from_bytes(bytes).to_os_string()
}
impl Accounts for SystemAccounts {
fn user_name(&self, uid: u32) -> Option<std::ffi::OsString> {
with_buf(|buf| {
let mut entry: libc::passwd = unsafe { std::mem::zeroed() };
let mut found: *mut libc::passwd = std::ptr::null_mut();
let rc = unsafe {
libc::getpwuid_r(
uid,
&mut entry,
buf.as_mut_ptr().cast(),
buf.len(),
&mut found,
)
};
if rc != 0 {
return Err(rc);
}
Ok((!found.is_null() && !entry.pw_name.is_null())
.then(|| unsafe { os_string(entry.pw_name) }))
})
}
fn group(&self, gid: u32) -> Option<GroupEntry> {
with_buf(|buf| {
let mut entry: libc::group = unsafe { std::mem::zeroed() };
let mut found: *mut libc::group = std::ptr::null_mut();
let rc = unsafe {
libc::getgrgid_r(
gid,
&mut entry,
buf.as_mut_ptr().cast(),
buf.len(),
&mut found,
)
};
if rc != 0 {
return Err(rc);
}
if found.is_null() || entry.gr_name.is_null() || entry.gr_mem.is_null() {
return Ok(None);
}
let mut members = Vec::new();
unsafe {
let mut at = entry.gr_mem;
while !(*at).is_null() {
members.push(os_string(*at));
at = at.add(1);
}
}
let name = unsafe { os_string(entry.gr_name) };
Ok(Some(GroupEntry { name, members }))
})
}
fn primary_members(&self, gid: u32) -> Option<Vec<u32>> {
static WALK: std::sync::Mutex<()> = std::sync::Mutex::new(());
let _walk = WALK.lock().ok()?;
let mut uids = Vec::new();
unsafe {
libc::setpwent();
loop {
let entry = libc::getpwent();
if entry.is_null() {
break;
}
if (*entry).pw_gid == gid {
uids.push((*entry).pw_uid);
}
}
libc::endpwent();
}
Some(uids)
}
}
fn group_is_private(accounts: &dyn Accounts, uid: u32, gid: u32) -> bool {
let (Some(user), Some(group), Some(primary)) = (
accounts.user_name(uid),
accounts.group(gid),
accounts.primary_members(gid),
) else {
return false;
};
group.name == user
&& group.members.iter().all(|member| *member == user)
&& primary.iter().all(|other| *other == uid)
}
struct Person<'a> {
uid: u32,
gid: u32,
groups: Option<Vec<u32>>,
accounts: &'a dyn Accounts,
private_group: std::cell::OnceCell<bool>,
}
impl<'a> Person<'a> {
fn new(accounts: &'a dyn Accounts) -> Self {
let (uid, gid) = unsafe { (libc::getuid(), libc::getgid()) };
Person {
uid,
gid,
groups: supplementary_groups(),
accounts,
private_group: std::cell::OnceCell::new(),
}
}
fn in_group(&self, gid: u32) -> bool {
gid == self.gid
|| self
.groups
.as_ref()
.is_none_or(|groups| groups.contains(&gid))
}
fn private_group(&self) -> bool {
*self
.private_group
.get_or_init(|| group_is_private(self.accounts, self.uid, self.gid))
}
}
fn supplementary_groups() -> Option<Vec<u32>> {
let count = unsafe { libc::getgroups(0, std::ptr::null_mut()) };
let mut groups = vec![0; usize::try_from(count).ok()?];
let got = unsafe { libc::getgroups(count, groups.as_mut_ptr()) };
groups.truncate(usize::try_from(got).ok()?);
Some(groups)
}
#[derive(Clone, Copy)]
struct Owner {
uid: u32,
gid: u32,
mode: u32,
}
impl From<&std::fs::Metadata> for Owner {
fn from(meta: &std::fs::Metadata) -> Self {
use std::os::unix::fs::MetadataExt as _;
Owner {
uid: meta.uid(),
gid: meta.gid(),
mode: meta.mode(),
}
}
}
#[cfg(target_os = "linux")]
fn pinned_walk(file: &Path, expect: Option<(u64, u64)>) -> Option<Vec<Owner>> {
use std::os::fd::{AsRawFd as _, FromRawFd as _};
use std::os::unix::ffi::OsStrExt as _;
use std::os::unix::fs::MetadataExt as _;
let open = |dir: libc::c_int, name: &std::ffi::CStr| {
let fd = unsafe {
libc::openat(
dir,
name.as_ptr(),
libc::O_PATH | libc::O_NOFOLLOW | libc::O_CLOEXEC,
)
};
(fd >= 0).then(|| std::fs::File::from(unsafe { std::os::fd::OwnedFd::from_raw_fd(fd) }))
};
let mut parts = file.components();
if parts.next() != Some(std::path::Component::RootDir) {
return None;
}
let mut at = open(libc::AT_FDCWD, c"/")?;
let mut meta = at.metadata().ok()?;
let mut owners = vec![Owner::from(&meta)];
for part in parts {
let std::path::Component::Normal(name) = part else {
return None;
};
if !meta.is_dir() {
return None;
}
at = open(
at.as_raw_fd(),
&std::ffi::CString::new(name.as_bytes()).ok()?,
)?;
meta = at.metadata().ok()?;
owners.push(Owner::from(&meta));
}
(meta.is_file() && expect.is_none_or(|id| id == (meta.dev(), meta.ino()))).then_some(owners)
}
#[cfg(not(target_os = "linux"))]
fn pinned_walk(_file: &Path, _expect: Option<(u64, u64)>) -> Option<Vec<Owner>> {
None
}
fn store_keeps(store: Owner, entry: Owner, person: &Person) -> bool {
let group = store.mode & 0o020 != 0;
let anyone = store.mode & 0o002 != 0;
if store.uid == 0 {
let person_writes = anyone
|| (group && person.in_group(store.gid))
|| (person.uid == 0 && store.mode & 0o200 != 0);
!person_writes && (!group || store.mode & 0o1000 != 0) && entry.uid == 0
} else {
store.uid == person.uid && !group && !anyone && (entry.uid == 0 || entry.uid == person.uid)
}
}
fn person_keeps(file: Owner, person: &Person) -> bool {
(file.uid == 0 || file.uid == person.uid)
&& file.mode & 0o002 == 0
&& (file.mode & 0o020 == 0 || (file.gid == person.gid && person.private_group()))
}
fn runner_of_file(
path: &Path,
home: Option<&Path>,
store: &Path,
person: &Person,
expect: Option<(u64, u64)>,
) -> Option<&'static str> {
runner_at(
&std::fs::canonicalize(path).ok()?,
home,
store,
person,
expect,
)
}
fn runner_at(
file: &Path,
home: Option<&Path>,
store: &Path,
person: &Person,
expect: Option<(u64, u64)>,
) -> Option<&'static str> {
if crate::bookkeep::path_is_temp(Some(file)) {
return None;
}
let roots: Vec<PathBuf> = install_roots(home)
.iter()
.filter_map(|root| std::fs::canonicalize(root).ok())
.collect();
let store = std::fs::canonicalize(store).unwrap_or_else(|_| store.to_path_buf());
let (runner, root) = runner_below(file, &roots, &store)?;
let owners = pinned_walk(file, expect)?;
let at = root.components().count().checked_sub(1)?;
let in_store = root.parent() == Some(store.as_path());
walk_kept(&owners, at, in_store, person).then_some(runner)
}
fn walk_kept(owners: &[Owner], at: usize, in_store: bool, person: &Person) -> bool {
let (Some(&above), Some(&root)) = (
at.checked_sub(1).and_then(|above| owners.get(above)),
owners.get(at),
) else {
return false;
};
let top = if in_store {
store_keeps(above, root, person)
} else {
person_keeps(above, person)
};
top && owners[at..].iter().all(|dir| person_keeps(*dir, person))
}
#[cfg(target_os = "linux")]
#[must_use]
pub fn exe_runner(pid: u32) -> Option<&'static str> {
use std::os::unix::fs::MetadataExt as _;
let home = own_home();
let person = Person::new(&SystemAccounts);
let exe = std::fs::read_link(format!("/proc/{pid}/exe")).ok()?;
let running = std::fs::metadata(format!("/proc/{pid}/exe"))
.ok()
.map(|meta| (meta.dev(), meta.ino()));
let store = Path::new(NIX_STORE);
if let Some(r) = runner_of_file(&exe, home.as_deref(), store, &person, running) {
return Some(r);
}
let base = exe.file_name()?.to_str()?;
if !RUNTIMES.iter().any(|r| base.starts_with(r)) {
return None;
}
let cmdline = std::fs::read(format!("/proc/{pid}/cmdline")).ok()?;
let script = cmdline
.split(|b| *b == 0)
.skip(1)
.map(String::from_utf8_lossy)
.find(|a| !a.is_empty() && !a.starts_with('-'))?;
let cwd = std::fs::read_link(format!("/proc/{pid}/cwd")).ok()?;
let script = std::fs::canonicalize(cwd.join(script.as_ref())).ok()?;
runner_of_file(&script, home.as_deref(), store, &person, None)
}
#[cfg(not(target_os = "linux"))]
#[must_use]
pub fn exe_runner(_pid: u32) -> Option<&'static str> {
None
}
fn bare_hook_command(cmd: &str) -> bool {
let mut plain = String::new();
let mut rest = cmd;
while let Some(at) = rest.find("${") {
plain.push_str(&rest[..at]);
let Some(close) = rest[at..].find('}') else {
return false;
};
let name = &rest[at + 2..at + close];
if name.is_empty() || !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
return false;
}
plain.push_str("VAR");
rest = &rest[at + close + 1..];
}
plain.push_str(rest);
let plain = plain.replace('"', "");
if plain.contains(|c: char| "<>|;&$`(){}\\'\n\r*?[]~#!".contains(c)) {
return false;
}
let words: Vec<&str> = plain.split_whitespace().collect();
let base = |w: &str| w.rsplit('/').next().unwrap_or(w).to_string();
let at = match words.first().map(|w| base(w)).as_deref() {
Some("ljos-plugin") if words.get(1) == Some(&"ljos") => 2,
Some("ljos") => 1,
_ => return false,
};
words.get(at) == Some(&"hook")
&& words[at + 1..].iter().all(|w| {
w.starts_with("--")
|| w.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
})
}
#[cfg(target_os = "linux")]
fn hook_shell(pid: u32) -> bool {
let Ok(exe) = std::fs::read_link(format!("/proc/{pid}/exe")) else {
return false;
};
let shell = exe
.file_name()
.and_then(|f| f.to_str())
.is_some_and(|f| SHELLS.contains(&f));
let argv: Vec<String> = std::fs::read(format!("/proc/{pid}/cmdline"))
.unwrap_or_default()
.split(|b| *b == 0)
.map(|a| String::from_utf8_lossy(a).into_owned())
.collect();
shell
&& matches!(argv.as_slice(), [_, c, cmd, last] if c == "-c" && last.is_empty() && bare_hook_command(cmd))
}
#[cfg(not(target_os = "linux"))]
fn hook_shell(_pid: u32) -> bool {
false
}
#[must_use]
pub fn runner_above() -> Option<(Proc, &'static str)> {
ancestors()
.into_iter()
.find_map(|p| exe_runner(p.pid).map(|r| (p, r)))
}
fn tool_call_stamp(env: &[(String, String)]) -> Option<&str> {
env.iter()
.find(|(k, v)| {
matches!(
k.as_str(),
"CLAUDECODE" | "CURSOR_AGENT" | "CODEX_THREAD_ID"
) || (crate::runner_session_var(k, v) && !k.starts_with("LJOS_"))
})
.map(|(k, _)| k.as_str())
}
struct ToolMark {
sid: u32,
at: u64,
runner_pid: u32,
runner_start: u64,
shell_pid: u32,
shell_start: u64,
cgroup: String,
}
struct Here {
sid: u32,
cgroup: String,
}
fn marks_path() -> std::path::PathBuf {
crate::runtime_dir().join("tool-calls")
}
fn broken_path() -> std::path::PathBuf {
crate::runtime_dir().join("tool-calls.broken")
}
fn current_sid() -> Result<u32, String> {
let sid = unsafe { libc::getsid(0) };
if sid < 0 {
return Err(
"the session id cannot be read, so a tool call cannot be told from the runner".into(),
);
}
u32::try_from(sid).map_err(|_| "the session id does not fit".into())
}
fn here_evidence() -> Result<Here, String> {
let sid = current_sid()?;
let cgroup = std::fs::read_to_string("/proc/self/cgroup").map_err(|_| {
"the cgroup cannot be read, so a tool call cannot be told from the runner".to_string()
})?;
if cgroup.is_empty() {
return Err("the cgroup is empty, so a tool call cannot be told from the runner".into());
}
Ok(Here { sid, cgroup })
}
fn self_start() -> Result<u64, String> {
stat(std::process::id())
.map(|row| row.start)
.ok_or_else(|| "this process's start time cannot be read".into())
}
pub fn seal_unreadable_tool_call() {
mark_broken();
}
fn mark_broken() {
let dir = crate::runtime_dir();
let _ = std::fs::create_dir_all(&dir);
let mut options = std::fs::OpenOptions::new();
options.create(true).write(true).truncate(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt as _;
options.mode(0o600);
}
let _ = options.open(broken_path());
}
fn tool_marks() -> Result<Vec<ToolMark>, String> {
if broken_path().exists() {
return Err(
"a tool-call record could not be written, so nothing is kept as the person's".into(),
);
}
let text = match std::fs::read_to_string(marks_path()) {
Ok(text) => text,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(_) => {
return Err(
"the tool-call record cannot be read, so nothing is kept as the person's".into(),
);
}
};
let mut marks = Vec::new();
for line in text.lines() {
let fields: Vec<&str> = line.split('\t').collect();
if fields.len() != 8 || fields[0] != "1" {
return Err(
"the tool-call record is corrupt, so nothing is kept as the person's".into(),
);
}
let number = |index: usize, label: &str| -> Result<u64, String> {
fields[index].parse().map_err(|_| {
format!(
"the tool-call record has a bad {label}, so nothing is kept as the person's"
)
})
};
let cgroup = hex::decode(fields[7])
.ok()
.and_then(|bytes| String::from_utf8(bytes).ok())
.ok_or("the tool-call record has a bad cgroup, so nothing is kept as the person's")?;
let narrow = |value: u64, label: &str| -> Result<u32, String> {
u32::try_from(value).map_err(|_| {
format!(
"the tool-call record has a bad {label}, so nothing is kept as the person's"
)
})
};
marks.push(ToolMark {
sid: narrow(number(1, "session")?, "session")?,
at: number(2, "start")?,
runner_pid: narrow(number(3, "runner")?, "runner")?,
runner_start: number(4, "runner start")?,
shell_pid: narrow(number(5, "shell")?, "shell")?,
shell_start: number(6, "shell start")?,
cgroup,
});
}
Ok(marks)
}
pub fn note_tool_call(runner: &Proc) -> Result<(), String> {
let here = match here_evidence() {
Ok(here) => here,
Err(why) => {
mark_broken();
return Err(why);
}
};
let at = match self_start() {
Ok(at) => at,
Err(why) => {
mark_broken();
return Err(why);
}
};
let dir = crate::runtime_dir();
let mut builder = std::fs::DirBuilder::new();
builder.recursive(true);
#[cfg(unix)]
{
use std::os::unix::fs::DirBuilderExt as _;
builder.mode(0o700);
}
if builder.create(&dir).is_err() {
mark_broken();
return Err("the tool-call record directory cannot be created".into());
}
let line = format!(
"1\t{}\t{at}\t{}\t{}\t0\t0\t{}\n",
here.sid,
runner.pid,
runner.start,
hex::encode(here.cgroup.as_bytes())
);
if line.len() > 4096 {
mark_broken();
return Err("the tool-call record does not fit one write".into());
}
let mut options = std::fs::OpenOptions::new();
options.create(true).append(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt as _;
options.mode(0o600);
}
let opened = options.open(marks_path());
let mut file = match opened {
Ok(file) => file,
Err(_) => {
mark_broken();
return Err("the tool-call record cannot be written".into());
}
};
use std::io::Write as _;
if file.write_all(line.as_bytes()).is_err() {
mark_broken();
return Err("the tool-call record cannot be written".into());
}
Ok(())
}
fn confirm_runner(runner: &Proc) -> Result<(), String> {
let here = here_evidence()?;
let marks = tool_marks()?;
let chain = ancestors();
for mark in &marks {
if mark.runner_pid == runner.pid && mark.runner_start == runner.start {
continue;
}
if mark.shell_pid > 0
&& chain
.iter()
.any(|proc| proc.pid == mark.shell_pid && proc.start == mark.shell_start)
{
return Err(format!(
"{} (pid {}) descends from a recorded tool call",
runner.comm, runner.pid
));
}
let same_place = mark.sid == here.sid || mark.cgroup == here.cgroup;
if same_place && runner.start >= mark.at {
return Err(format!(
"{} (pid {}) shares a recorded tool call's cgroup or session and is not older than that tool call",
runner.comm, runner.pid
));
}
}
Ok(())
}
pub fn placed_runner() -> Result<(), String> {
for proc in ancestors() {
if is_shell(&proc) || is_wrapper(&proc) {
continue;
}
if ends_the_walk(&proc) {
break;
}
if exe_runner(proc.pid).is_some() {
return confirm_runner(&proc);
}
return Err(format!(
"{} (pid {}) is not an installed runner, so this is not the person's runner",
proc.comm, proc.pid
));
}
Err("no installed runner is above this process, so this is not the person's runner".into())
}
pub fn hook_runner() -> Result<(Proc, &'static str), String> {
let mut chain = ancestors();
let parent = chain.first().ok_or("it has no parent process")?;
if exe_runner(parent.pid).is_none() && chain.len() > 1 && hook_shell(parent.pid) {
chain.remove(0);
}
let parent = &chain[0];
let Some(runner) = exe_runner(parent.pid) else {
return Err(format!(
"its parent, {} (pid {}), is not a runner's own program: a shell, an interpreter or ssh ran it, so a tool call may have",
parent.comm, parent.pid
));
};
let env = environ(parent.pid).ok_or_else(|| {
format!(
"the environment {} (pid {}) was started with cannot be read, so a tool call's stamp would be hidden",
parent.comm, parent.pid
)
})?;
if let Some(key) = tool_call_stamp(&env) {
return Err(format!(
"{runner} (pid {}) was started with {key} set, inside another runner's tool call",
parent.pid
));
}
let own = chain[1..].iter().take_while(|p| !ends_the_walk(p));
if let Some((p, other)) = own
.filter_map(|p| exe_runner(p.pid).map(|r| (p, r)))
.find(|(_, r)| *r != "cursor")
{
return Err(format!(
"{runner} (pid {}) runs under {other} (pid {}), inside a tool call",
parent.pid, p.pid
));
}
confirm_runner(parent)?;
Ok((parent.clone(), runner))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_runner_is_read_from_the_file_it_runs_from() {
let roots = install_roots(Some(Path::new("/home/u")));
for (path, want) in [
("/home/u/.local/share/claude/versions/2.1.3", Some("claude")),
("/home/u/.local/bin/claude", Some("claude")),
(
"/usr/lib/node_modules/@anthropic-ai/claude-code/cli.js",
Some("claude"),
),
(
"/usr/lib/node_modules/@openai/codex/vendor/x86_64-unknown-linux-musl/codex/codex",
Some("codex"),
),
(
"/home/u/.local/share/cursor-agent/versions/2025.10.2-abc/node",
Some("cursor-agent"),
),
("/usr/share/cursor/cursor", Some("cursor")),
("/usr/local/bin/codex", Some("codex")),
("/usr/bin/claude", Some("claude")),
("/bin/grok", Some("grok")),
("/home/u/.cargo/bin/codex", Some("codex")),
(
"/home/u/.npm-global/lib/node_modules/@anthropic-ai/claude-code/cli.js",
Some("claude"),
),
(
"/nix/store/0c9yapqd0b8y4mj5xj7mrpmhy3l5ykh8-claude-code-2.0.1/bin/claude",
Some("claude"),
),
("/usr/bin/node", None),
("/usr/bin/bash", None),
("/usr/bin/python3", None),
("/usr/bin/ssh", None),
("/home/u/.cargo/bin/ljos", None),
("/tmp/claude", None),
("/tmp/claude/versions/2.1.3", None),
("/home/u/runners/claude", None),
("/home/u/plant/grok", None),
("/home/u/plant/bin/claude", None),
("/home/u/.local/bin/claude (deleted)", None),
("/home/u/plant/.local/bin/claude", None),
("/tmp/.local/bin/claude", None),
("/tmp/project/node_modules/.bin/claude", None),
("/home/u/../other/.local/bin/claude", None),
("/home/uu/.local/bin/claude", None),
("/home/other/.local/bin/claude", None),
("/home/u/.local/binx/claude", None),
("/home/u/.local/bin/../../plant/claude", None),
("/home/u/.local/share/plant/claude", None),
("/home/u/.local/share/claude-plant/claude", None),
("/home/u/src/app/node_modules/.bin/claude", None),
("/usr/binx/claude", None),
("/usr/share/plant/claude", None),
("/usr/lib/node_modules/../../../tmp/claude", None),
("/opt/plant/claude", None),
("/var/tmp/.local/bin/claude", None),
("/dev/shm/node_modules/.bin/claude", None),
("/nix/store/claude/bin/claude", None),
(
"/nix/store/0c9yapqd0b8y4mj5xj7mrpmhy3l5ykh8/bin/claude",
None,
),
(
"/nix/store/0c9yapqd0b8y4mj5xj7mrpmhy3l5ykhe-claude-code-2.0.1/bin/claude",
None,
),
] {
let got = runner_below(Path::new(path), &roots, Path::new(NIX_STORE))
.map(|(runner, _)| runner);
assert_eq!(got, want, "{path}");
}
}
#[test]
fn an_install_root_holds_only_what_resolves_inside_it() {
use std::os::unix::fs::PermissionsExt as _;
let base = tempfile::Builder::new()
.prefix(".ljos-roots-")
.tempdir_in(own_home().expect("this user's passwd entry names a home"))
.unwrap();
let home = base.path().join("u");
let person = Person::new(&SystemAccounts);
let store = PathBuf::from(NIX_STORE);
let program = |dir: &Path, name: &str| {
std::fs::create_dir_all(dir).unwrap();
let path = dir.join(name);
std::fs::write(&path, "").unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
path
};
let bin = home.join(".local/bin");
let claude = program(&bin, "claude");
assert_eq!(
runner_of_file(&claude, Some(&home), &store, &person, None),
Some("claude")
);
let away = tempfile::tempdir().unwrap();
std::os::unix::fs::symlink(program(away.path(), "codex"), bin.join("codex")).unwrap();
assert_eq!(
runner_of_file(&bin.join("codex"), Some(&home), &store, &person, None),
None
);
program(&base.path().join("other/.local/bin"), "claude");
let climbed = home.join("../other/.local/bin/claude");
assert_eq!(
runner_of_file(&climbed, Some(&home), &store, &person, None),
None
);
std::fs::set_permissions(&claude, std::fs::Permissions::from_mode(0o757)).unwrap();
assert_eq!(
runner_of_file(&claude, Some(&home), &store, &person, None),
None
);
let open = bin.join("open");
let grok = program(&open, "grok");
assert_eq!(
runner_of_file(&grok, Some(&home), &store, &person, None),
Some("grok")
);
std::fs::set_permissions(&open, std::fs::Permissions::from_mode(0o777)).unwrap();
assert_eq!(
runner_of_file(&grok, Some(&home), &store, &person, None),
None
);
}
#[test]
fn a_root_in_a_directory_others_may_write_is_refused() {
use std::os::unix::fs::PermissionsExt as _;
let base = tempfile::Builder::new()
.prefix(".ljos-above-")
.tempdir_in(own_home().expect("this user's passwd entry names a home"))
.unwrap();
let home = base.path().join("u");
let store = PathBuf::from(NIX_STORE);
let person = Person::new(&SystemAccounts);
let staff = FakeAccounts {
user: Some("u"),
group: "staff",
members: &["u", "v"],
primary: vec![person.uid],
};
let sharing = Person::new(&staff);
let mut files: Vec<PathBuf> = vec![
home.join(".local/bin/claude"),
home.join(".cargo/bin/codex"),
home.join(".npm-global/lib/node_modules/@anthropic-ai/claude-code/cli.js"),
];
files.extend(
HOOK_RUNNERS
.iter()
.map(|(name, _)| home.join(".local/share").join(name).join(name)),
);
for file in &files {
std::fs::create_dir_all(file.parent().unwrap()).unwrap();
std::fs::write(file, "").unwrap();
}
let set = |dir: &Path, mode| {
std::fs::set_permissions(dir, std::fs::Permissions::from_mode(mode)).unwrap();
};
for root in install_roots(Some(&home))
.iter()
.filter(|r| r.starts_with(&home))
{
let file = files.iter().find(|f| f.starts_with(root)).unwrap();
let above = root.parent().unwrap();
let runner = |who| runner_of_file(file, Some(&home), &store, who, None);
assert!(runner(&person).is_some(), "{}", root.display());
set(above, 0o757);
assert_eq!(runner(&person), None, "{}", root.display());
set(above, 0o775);
assert_eq!(runner(&sharing), None, "{}", root.display());
set(above, 0o755);
}
let uid = person.uid;
let owner = |uid, mode| Owner {
uid,
gid: person.gid,
mode,
};
let walk = |above| [owner(0, 0o755), above, owner(uid, 0o755), owner(uid, 0o755)];
assert!(walk_kept(&walk(owner(uid, 0o755)), 2, false, &person));
assert!(walk_kept(&walk(owner(0, 0o755)), 2, false, &person));
assert!(!walk_kept(&walk(owner(uid + 1, 0o755)), 2, false, &person));
assert!(!walk_kept(&walk(owner(uid, 0o757)), 2, false, &person));
assert!(!walk_kept(&walk(owner(uid, 0o775)), 2, false, &sharing));
}
#[test]
fn a_parent_swapped_after_the_path_resolved_is_refused() {
use std::os::unix::fs::MetadataExt as _;
let base = tempfile::Builder::new()
.prefix(".ljos-swap-")
.tempdir_in(own_home().expect("this user's passwd entry names a home"))
.unwrap();
let home = std::fs::canonicalize(base.path()).unwrap().join("u");
let person = Person::new(&SystemAccounts);
let store = PathBuf::from(NIX_STORE);
let versions = home.join(".local/share/claude/versions");
let other = home.join("other");
for dir in [&versions, &other] {
std::fs::create_dir_all(dir).unwrap();
std::fs::write(dir.join("2.1.3"), "").unwrap();
}
let file = versions.join("2.1.3");
let meta = std::fs::metadata(&file).unwrap();
let running = Some((meta.dev(), meta.ino()));
let runner = |expect| runner_at(&file, Some(&home), &store, &person, expect);
assert_eq!(runner(running), Some("claude"));
std::fs::rename(&versions, home.join("held")).unwrap();
std::os::unix::fs::symlink(&other, &versions).unwrap();
assert_eq!(runner(None), None);
assert_eq!(runner(running), None);
std::fs::remove_file(&versions).unwrap();
std::fs::rename(&other, &versions).unwrap();
assert_eq!(runner(running), None);
assert_eq!(runner(None), Some("claude"));
}
#[test]
fn a_nix_entry_is_a_runner_only_in_a_store_nix_keeps() {
use std::os::unix::fs::PermissionsExt as _;
let base = tempfile::Builder::new()
.prefix(".ljos-store-")
.tempdir_in(own_home().expect("this user's passwd entry names a home"))
.unwrap();
let store = base.path().join("store");
let bin = store.join("0c9yapqd0b8y4mj5xj7mrpmhy3l5ykh8-claude-code-2.0.1/bin");
std::fs::create_dir_all(&bin).unwrap();
let claude = bin.join("claude");
std::fs::write(&claude, "").unwrap();
std::fs::set_permissions(&claude, std::fs::Permissions::from_mode(0o755)).unwrap();
let person = Person::new(&SystemAccounts);
let set = |mode| {
std::fs::set_permissions(&store, std::fs::Permissions::from_mode(mode)).unwrap();
};
let runner = || runner_of_file(&claude, None, &store, &person, None);
set(0o755);
assert_eq!(runner(), Some("claude"));
for mode in [0o775, 0o757, 0o777, 0o1775, 0o1777] {
set(mode);
assert_eq!(runner(), None, "{mode:o}");
}
set(0o755);
let uid = person.uid;
let nixbld = person.gid + 1;
let owner = |uid, gid, mode| Owner { uid, gid, mode };
let outsider = Person {
groups: Some(vec![]),
..Person::new(&SystemAccounts)
};
let builder = Person {
groups: Some(vec![nixbld]),
..Person::new(&SystemAccounts)
};
let unread = Person {
groups: None,
..Person::new(&SystemAccounts)
};
let root_entry = owner(0, 0, 0o555);
let own_entry = owner(uid, uid, 0o555);
for (store, entry, who, want) in [
(owner(0, nixbld, 0o1775), root_entry, &outsider, true),
(owner(0, 0, 0o755), root_entry, &outsider, true),
(owner(0, nixbld, 0o1775), own_entry, &outsider, false),
(owner(0, nixbld, 0o775), root_entry, &outsider, false),
(owner(0, nixbld, 0o1775), root_entry, &builder, false),
(owner(0, nixbld, 0o1775), root_entry, &unread, false),
(owner(0, 0, 0o1777), root_entry, &outsider, false),
(owner(uid, uid, 0o755), own_entry, &outsider, true),
(owner(uid, uid, 0o755), root_entry, &outsider, true),
(owner(uid, uid, 0o775), own_entry, &outsider, false),
(owner(uid, uid, 0o1777), own_entry, &outsider, false),
(
owner(uid, uid, 0o755),
owner(uid + 1, 0, 0o555),
&outsider,
false,
),
(owner(uid + 1, 0, 0o755), own_entry, &outsider, false),
] {
assert_eq!(
store_keeps(store, entry, who),
want,
"{}:{} {:o}",
store.uid,
store.gid,
store.mode
);
}
}
struct FakeAccounts {
user: Option<&'static str>,
group: &'static str,
members: &'static [&'static str],
primary: Vec<u32>,
}
impl Accounts for FakeAccounts {
fn user_name(&self, _uid: u32) -> Option<std::ffi::OsString> {
self.user.map(Into::into)
}
fn group(&self, _gid: u32) -> Option<GroupEntry> {
Some(GroupEntry {
name: self.group.into(),
members: self.members.iter().map(Into::into).collect(),
})
}
fn primary_members(&self, _gid: u32) -> Option<Vec<u32>> {
Some(self.primary.clone())
}
}
#[test]
fn a_group_write_counts_only_from_a_group_the_person_has_alone() {
use std::os::unix::fs::PermissionsExt as _;
let uid = unsafe { libc::getuid() };
let alone = |members, primary| FakeAccounts {
user: Some("u"),
group: "u",
members,
primary,
};
let shared = [
FakeAccounts {
group: "staff",
..alone(&[], vec![uid])
},
alone(&["u", "v"], vec![uid]),
alone(&["v"], vec![uid]),
alone(&[], vec![uid, uid + 1]),
FakeAccounts {
user: None,
..alone(&[], vec![uid])
},
];
let base = tempfile::Builder::new()
.prefix(".ljos-group-")
.tempdir_in(own_home().expect("this user's passwd entry names a home"))
.unwrap();
let home = base.path().join("u");
let bin = home.join(".local/bin");
std::fs::create_dir_all(&bin).unwrap();
let claude = bin.join("claude");
std::fs::write(&claude, "").unwrap();
std::fs::set_permissions(&claude, std::fs::Permissions::from_mode(0o775)).unwrap();
let store = PathBuf::from(NIX_STORE);
for accounts in [alone(&[], vec![uid]), alone(&["u"], vec![uid, uid])] {
assert!(group_is_private(&accounts, uid, 0));
let person = Person::new(&accounts);
assert_eq!(
runner_of_file(&claude, Some(&home), &store, &person, None),
Some("claude")
);
}
for accounts in &shared {
assert!(!group_is_private(accounts, uid, 0));
let person = Person::new(accounts);
assert_eq!(
runner_of_file(&claude, Some(&home), &store, &person, None),
None
);
}
}
#[test]
fn a_young_runner_in_a_recorded_tool_call_is_refused() {
let _guard = crate::test_env_lock();
let opener = Proc {
pid: 1,
start: 1,
comm: "claude".into(),
};
let young = Proc {
pid: 999_999,
start: u64::MAX,
comm: "claude".into(),
};
let old = Proc {
pid: 42,
start: 0,
comm: "claude".into(),
};
assert!(confirm_runner(&young).is_ok(), "no record yet");
note_tool_call(&opener).unwrap();
let why = confirm_runner(&young).unwrap_err();
assert!(why.contains("tool call"), "{why}");
assert!(confirm_runner(&opener).is_ok(), "the opener recorded it");
assert!(confirm_runner(&old).is_ok(), "older than the record");
std::fs::write(marks_path(), "nope\n").unwrap();
let why = confirm_runner(&old).unwrap_err();
assert!(why.contains("corrupt"), "{why}");
}
#[test]
fn a_tool_calls_variables_mark_a_runner_started_inside_one() {
let env = |k: &str, v: &str| vec![(k.to_string(), v.to_string())];
assert_eq!(tool_call_stamp(&env("CLAUDECODE", "1")), Some("CLAUDECODE"));
assert_eq!(
tool_call_stamp(&env("CURSOR_AGENT", "1")),
Some("CURSOR_AGENT")
);
assert!(tool_call_stamp(&env("CURSOR_CONVERSATION_ID", "0199a1b2-c3d4")).is_some());
assert_eq!(tool_call_stamp(&env("LJOS_SESSION_ID", "mine-0001")), None);
assert_eq!(tool_call_stamp(&env("HOME", "/home/u")), None);
}
#[test]
fn only_a_bare_hook_command_is_the_runners_hook_shell() {
for cmd in [
"ljos hook",
"/home/u/.cargo/bin/ljos hook --runner codex",
"ljos hook --event SessionStart",
"\"${CLAUDE_PLUGIN_ROOT}/bin/ljos-plugin\" ljos hook",
"\"${CURSOR_PLUGIN_ROOT}/bin/ljos-plugin\" ljos hook",
] {
assert!(bare_hook_command(cmd), "{cmd}");
}
for cmd in [
"ljos hook < plant.json",
"cat plant.json | ljos hook",
"ljos hook <<'EOF'\n{}\nEOF",
"ljos status; ljos hook",
"ljos hook $(cat p)",
"python3 plant.py",
"ljos remember a b c d",
"ljos-plugin hook",
"bash -c 'ljos hook'",
] {
assert!(!bare_hook_command(cmd), "{cmd}");
}
}
#[test]
fn a_hook_this_test_runs_has_no_runner() {
let why = hook_runner().unwrap_err();
assert!(why.contains("not a runner's own program"), "{why}");
}
fn named(pid: u32, comm: &str) -> Proc {
Proc {
pid,
start: u64::from(pid) * 10,
comm: comm.into(),
}
}
#[test]
fn this_process_has_live_ancestors_and_a_dead_pid_does_not_match() {
let chain = ancestors();
assert!(!chain.is_empty(), "the test runner is above this process");
assert!(chain.iter().all(alive));
let parent = &chain[0];
assert!(token_alive(&token(parent)));
let reused = Proc {
start: parent.start + 1,
..parent.clone()
};
assert!(!alive(&reused));
assert!(!token_alive(&format!(
"other-boot:{}:{}",
parent.pid, parent.start
)));
}
#[test]
fn a_reaped_child_is_not_alive() {
let mut child = std::process::Command::new("sleep")
.arg("30")
.spawn()
.unwrap();
let pid = child.id();
let p = of(pid).unwrap();
assert!(alive(&p));
assert_eq!(above(pid).first().map(|q| q.pid), Some(std::process::id()));
child.kill().unwrap();
child.wait().unwrap();
assert!(!alive(&p));
}
#[test]
fn a_child_is_read_with_the_environment_it_was_started_with() {
let mut child = std::process::Command::new("sleep")
.arg("30")
.env("LJOS_PROC_TREE_PROBE", "started-with")
.spawn()
.unwrap();
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
let env = loop {
let env = environ(child.id()).unwrap_or_default();
if env.iter().any(|(k, _)| k == "LJOS_PROC_TREE_PROBE")
|| std::time::Instant::now() > deadline
{
break env;
}
std::thread::sleep(std::time::Duration::from_millis(10));
};
child.kill().unwrap();
child.wait().unwrap();
assert!(
env.iter()
.any(|(k, v)| k == "LJOS_PROC_TREE_PROBE" && v == "started-with"),
"{env:?}"
);
}
#[test]
fn the_calling_shell_is_the_outermost_of_a_nested_run() {
let harness = named(10, "claude");
let chain = [
named(3, "timeout"),
named(4, "sh"),
named(5, "bash"),
harness.clone(),
];
assert_eq!(calling_shell(&chain), Some(&chain[2]));
let chain = [
named(4, "sh"),
named(6, "env"),
named(5, "bash"),
named(7, "node"),
named(8, "bash"),
];
assert_eq!(calling_shell(&chain), Some(&chain[2]));
assert_eq!(calling_shell(&[harness.clone(), named(5, "bash")]), None);
assert_eq!(calling_shell(&[named(3, "env"), harness]), None);
assert_eq!(calling_shell(&[]), None);
}
#[test]
fn the_runner_is_above_the_shells_and_wrappers_it_started() {
let chain = [
named(4, "sh"),
named(3, "timeout"),
named(5, "bash"),
named(10, "claude"),
named(2, "tmux"),
];
assert_eq!(runner(&chain).map(|p| p.pid), Some(10));
assert_eq!(runner(&chain[3..]).map(|p| p.pid), Some(10));
let shells = [named(4, "sh"), named(5, "bash")];
assert_eq!(runner(&shells).map(|p| p.pid), Some(5));
assert_eq!(runner(&[]), None);
}
#[test]
fn a_session_above_the_hook_is_never_its_runner() {
for session in ["tmux", "tmux: server", "gnome-terminal-", "systemd", "sshd"] {
let chain = [named(4, "sh"), named(5, "bash"), named(2, session)];
assert_eq!(runner(&chain).map(|p| p.pid), Some(5), "{session}");
let chain = [named(4, "sh"), named(2, session), named(10, "claude")];
assert_eq!(runner(&chain).map(|p| p.pid), Some(4), "{session}");
assert_eq!(runner(&[named(2, session), named(10, "claude")]), None);
}
let chain = [named(4, "sh"), named(7, "git"), named(2, "kitty")];
assert_eq!(runner(&chain).map(|p| p.pid), Some(7));
}
fn fake(n: u32, comm: &str) -> Proc {
named((1 << 22) + n, comm)
}
#[test]
fn a_harness_outranks_a_runtime_that_only_started_the_hook() {
let chain = [
fake(1, "sh"),
fake(2, "uv"),
fake(3, "uv"),
fake(4, "claude"),
];
assert_eq!(runner(&chain), Some(&chain[3]));
let chain = [fake(1, "sh"), fake(2, "node"), fake(3, "claude")];
assert_eq!(runner(&chain), Some(&chain[2]));
let chain = [
fake(1, "sh"),
fake(2, "npx"),
fake(3, "bash"),
fake(4, "cursor-agent"),
fake(5, "tmux"),
];
assert_eq!(runner(&chain), Some(&chain[3]));
let chain = [
fake(1, "sh"),
fake(2, "uv"),
fake(3, "codex"),
fake(4, "agy"),
];
assert_eq!(runner(&chain), Some(&chain[2]), "the nearest harness");
}
#[test]
fn a_node_harness_with_no_known_harness_above_is_the_runner() {
let chain = [
fake(1, "sh"),
fake(2, "node"),
fake(3, "bash"),
fake(4, "tmux"),
];
assert_eq!(runner(&chain), Some(&chain[1]));
assert_eq!(runner(&chain[..2]), Some(&chain[1]));
let chain = [
fake(1, "sh"),
fake(2, "node"),
fake(3, "tmux"),
fake(4, "claude"),
];
assert_eq!(runner(&chain), Some(&chain[1]));
}
#[test]
fn pid_1_is_the_runner_only_as_a_harness() {
let chain = [fake(1, "sh"), fake(2, "uv"), named(1, "claude")];
assert_eq!(runner(&chain), Some(&chain[2]), "a container's harness");
let chain = [fake(1, "sh"), fake(2, "bash"), named(1, "tini")];
assert_eq!(runner(&chain), Some(&chain[1]), "an init is no runner");
let chain = [fake(1, "sh"), fake(2, "node"), named(1, "dumb-init")];
assert_eq!(runner(&chain), Some(&chain[1]));
assert_eq!(runner(&[named(1, "tini")]), None);
}
#[test]
fn the_walk_reads_up_to_a_process_with_no_parent() {
let chain = ancestors();
let top = chain.last().expect("the test runner is above this process");
assert!(
chain.len() == DEPTH || stat(top.pid).is_some_and(|s| s.ppid == 0),
"{chain:?}"
);
}
}