Skip to main content

capsule_runtime/
shell.rs

1use std::{
2    collections::BTreeMap,
3    process::{Command, Stdio},
4    time::{Duration, Instant},
5};
6
7use crate::{
8    artifact_config::ARTIFACT_CONFIG_ENV,
9    detached::{DetachPolicy, DetachedDispatchInfo},
10    types::CapabilityPolicy,
11    workdir_fs::{WorkdirRoot, DEFAULT_FILE_MODE},
12};
13
14pub(crate) const MAX_SHELL_OUTPUT_BYTES: usize = 16 * 1024;
15
16const SHELL_INTERPRETERS: &[&str] = &["bash", "sh", "zsh", "fish", "dash", "ksh"];
17const DEFAULT_ENV_BASELINE: &[&str] = &[
18    "PATH",
19    "HOME",
20    "USER",
21    "LOGNAME",
22    "LANG",
23    "LC_ALL",
24    "CARGO_HOME",
25    "RUSTUP_HOME",
26    "TERM",
27];
28
29/// The two variables that name the account a process runs as, sorted. Neither is a credential:
30/// each is a login name. A harness may look up its stored login by it (the macOS login keychain
31/// is keyed by user name), so a harness started without them can fail to find a login it has.
32pub(crate) const ACCOUNT_ENV: [&str; 2] = ["LOGNAME", "USER"];
33
34/// Serializes the unit tests that set or remove an [`ACCOUNT_ENV`] name in this test binary's
35/// process environment, which every test thread shares.
36#[cfg(test)]
37pub(crate) static ACCOUNT_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
38
39/// The host's values of [`ACCOUNT_ENV`], read once.
40///
41/// Each name carries the host's value or is absent: the runtime never makes one up, never copies
42/// one from the other, and [`HostAccountEnv::apply`] lets nothing an allowlist or a launch plan
43/// put in an environment stand in for the host's. A value that is not valid UTF-8 counts as
44/// unset, since no environment the runtime builds can carry it.
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub(crate) struct HostAccountEnv {
47    set: Vec<(String, String)>,
48    unset: Vec<String>,
49}
50
51impl HostAccountEnv {
52    /// The account names this process holds.
53    pub(crate) fn from_host() -> Self {
54        Self::from_lookup(|name| std::env::var(name).ok())
55    }
56
57    /// The account names as `lookup` answers them, for a caller that is not reading this process.
58    pub(crate) fn from_lookup(lookup: impl Fn(&str) -> Option<String>) -> Self {
59        let mut set = Vec::new();
60        let mut unset = Vec::new();
61        for name in ACCOUNT_ENV {
62            match lookup(name) {
63                Some(value) => set.push((name.to_string(), value)),
64                None => unset.push(name.to_string()),
65            }
66        }
67        Self { set, unset }
68    }
69
70    /// An account holding exactly `pairs`, for a test standing in for the host.
71    #[cfg(test)]
72    pub(crate) fn from_pairs(pairs: &[(&str, &str)]) -> Self {
73        Self::from_lookup(|name| {
74            pairs
75                .iter()
76                .find(|(key, _)| *key == name)
77                .map(|(_, value)| value.to_string())
78        })
79    }
80
81    /// The names the host did not set, sorted. Empty when it set both.
82    pub(crate) fn unset(&self) -> &[String] {
83        &self.unset
84    }
85
86    /// The names the host set, with its values, sorted by name.
87    pub(crate) fn set(&self) -> &[(String, String)] {
88        &self.set
89    }
90
91    /// Make `env`'s account names the host's: both are removed, then each the host set is
92    /// inserted with its value. Whatever `env` held under either name is gone afterwards.
93    pub(crate) fn apply(&self, env: &mut BTreeMap<String, String>) {
94        for name in ACCOUNT_ENV {
95            env.remove(name);
96        }
97        env.extend(self.set.iter().cloned());
98    }
99}
100
101/// What a shell command, plan shell step or native tool is refused with when its enforcement holds
102/// no home folder ([`crate::sandbox::ShellEnforcement::home_root`]).
103pub(crate) const NO_HOME_FOLDER_REFUSAL: &str =
104    "no home folder: this command's enforcement holds no session home folder (home/ in the \
105     session's records directory) to be its $HOME, so it is not run";
106
107/// Credential-shaped env var patterns stripped from every subprocess, regardless of
108/// whether they came from the host process env, `env_overrides`, or
109/// `policy.shell_baseline_env`. Supports exact match, trailing `*` (prefix) and
110/// leading `*` (suffix) via `env_name_matches_pattern`.
111const CREDENTIAL_ENV_PATTERNS: &[&str] = &[
112    "ANTHROPIC_API_KEY",
113    "OPENAI_API_KEY",
114    "GOOGLE_API_KEY",
115    "HUGGING_FACE_HUB_TOKEN",
116    "AWS_*",
117    "GITHUB_TOKEN",
118    "GH_TOKEN",
119    "DOCKER_*",
120    "KUBECONFIG",
121    "NPM_TOKEN",
122    "PYPI_TOKEN",
123    "CARGO_REGISTRY_TOKEN",
124    "*_API_KEY",
125];
126
127#[derive(Debug)]
128pub(crate) struct ShellResult {
129    /// The program that was actually invoked, resolved to its canonical absolute path
130    /// when the host `PATH` names it, else the bare invoked name — see
131    /// [`crate::sandbox::resolve_invoked_binary_path`]. Carried out of here because the
132    /// invoked name is otherwise lost the moment dispatch returns, leaving every
133    /// downstream observer (`shell-event.binary`, `trace.jsonl`) unable to say what ran.
134    pub(crate) binary: String,
135    pub(crate) exit_code: i32,
136    pub(crate) stdout: String,
137    pub(crate) stderr: String,
138    pub(crate) duration_ms: u64,
139    pub(crate) truncated: bool,
140    /// The absolute path of the log holding the full output when [`Self::truncated`] is set:
141    /// [`crate::detached::output_path_for`]'s path for a `shell-<ms>` stem in the session's
142    /// records directory. `None` for output that fit, and for a truncated command run with no
143    /// records directory, which writes no log.
144    pub(crate) full_output_path: Option<String>,
145    /// The `capabilities.resources` field this subprocess was killed for exceeding, when the
146    /// evidence names exactly one — see [`classify_resource_limit`]. `None` covers both "ran
147    /// normally" and "died for a reason no single limit can be pinned to", which are
148    /// deliberately not distinguished here: inventing an attribution is worse than declining one.
149    pub(crate) resource_limit_hit: Option<String>,
150    /// Whether anything the command started was still holding its output once it exited, and
151    /// what became of it.
152    pub(crate) output_holders: OutputHolders,
153}
154
155/// What held a shell command's stdout or stderr open after its own process exited.
156///
157/// A command's pipes close only when every process holding a copy has exited or closed it, so a
158/// process the command started in the background without redirecting its output keeps the call's
159/// output open for as long as it runs. [`finish_output`] waits for the pipes for a bounded time
160/// and then decides which of these it was.
161#[derive(Debug, Clone, Copy, PartialEq, Eq)]
162pub(crate) enum OutputHolders {
163    /// Both pipes reached EOF; nothing was killed.
164    Closed,
165    /// Processes in the command's process group were still holding its output after
166    /// [`PIPE_DRAIN_AFTER_EXIT`] and were killed with `SIGKILL`.
167    Killed,
168    /// Something outside the command's process group still held its output
169    /// [`LEFTOVER_KILL_WAIT`] after the kill. The result has what was read before the call
170    /// returned; the holder runs until the session ends and kills it.
171    Escaped,
172}
173
174impl OutputHolders {
175    /// The runtime note telling the model what happened to the holders, or `None` when there
176    /// were none. One line of fixed text, so it never grows with the command's output.
177    pub(crate) fn note(self) -> Option<String> {
178        const REDIRECT: &str = "A process meant to keep running in the background needs its \
179            output redirected away from the call, e.g. `server >server.log 2>&1 &`.";
180        match self {
181            Self::Closed => None,
182            Self::Killed => Some(format!(
183                "The command exited, but processes it started were still holding its output \
184                 after the wait and were killed. {REDIRECT}"
185            )),
186            Self::Escaped => Some(format!(
187                "The command exited, but processes it started outside its process group still \
188                 hold its output; the result has what was read before the call returned, and \
189                 those processes keep running until the session ends. {REDIRECT}"
190            )),
191        }
192    }
193}
194
195/// How long a shell call waits, once the command's own process has exited, for its stdout and
196/// stderr to close before it kills what is still holding them.
197///
198/// Background work that finishes and prints inside this window is returned as part of the
199/// call's output.
200const PIPE_DRAIN_AFTER_EXIT: Duration = Duration::from_secs(1);
201
202/// How long a shell call keeps killing its process group and waiting for its pipes to close
203/// after [`PIPE_DRAIN_AFTER_EXIT`], before it returns with the pipes still held.
204pub(crate) const LEFTOVER_KILL_WAIT: Duration = Duration::from_secs(2);
205
206/// How often [`OutputReaders`] is checked for EOF while a call waits on its pipes.
207const PIPE_POLL_INTERVAL: Duration = Duration::from_millis(10);
208
209/// Why [`execute_shell`] produced no result.
210///
211/// Two kinds, and the distinction is the whole point of the type: an ordinary failure is the
212/// capsule's business (it sees a failed tool call and can decide what to do next), while a
213/// [`Self::SealedRootConstructionFailed`] is the *session's* business — the containment boundary
214/// the session declared stopped being establishable, so there is nothing sensible for the capsule
215/// to retry and the run has to end with that named as the cause. Before this type existed both
216/// collapsed into a `String` and the second was indistinguishable from the first by the time it
217/// reached a caller that could act on it.
218#[derive(Debug)]
219pub(crate) enum ShellExecError {
220    /// Binary not in `capabilities.shell.allow`, env construction failure, workdir budget
221    /// breach, enforcement setup failure, spawn failure — everything that leaves the rest of
222    /// the session viable.
223    Failed(String),
224    /// A `sealed` session's composed root could not be built for this subprocess, *after* the
225    /// pre-launch probe reported the mechanism available. `detail` is what the child wrote to
226    /// the `pre_exec` diagnostic pipe: the exact step and its errno.
227    SealedRootConstructionFailed { detail: String },
228}
229
230impl ShellExecError {
231    /// The typed, session-fatal error this failure represents, or `None` when the failure is a
232    /// tool-level one the capsule can be told about and carry on from.
233    ///
234    /// Callers that own the session (the agent turn loop) return this; callers that only own a
235    /// single dispatch pass it upward alongside the tool result rather than deciding themselves.
236    pub(crate) fn session_fatal(&self) -> Option<crate::errors::RuntimeError> {
237        match self {
238            Self::Failed(_) => None,
239            Self::SealedRootConstructionFailed { detail } => {
240                Some(crate::errors::RuntimeError::SealedRootConstructionFailed {
241                    detail: detail.clone(),
242                })
243            }
244        }
245    }
246}
247
248/// What a failed `spawn()` of a `command` that went through
249/// [`crate::sandbox::prepare_enforcement`] means, for every subprocess that does: shell commands,
250/// plan shell steps and native tools.
251///
252/// A failure inside the `pre_exec` enforcement setup reaches the caller only as a bare errno (std
253/// collapses a `pre_exec` `io::Error` to its `raw_os_error()`, defaulting to EINVAL). `command` is
254/// dropped first so the parent's captured copy of the diagnostic pipe's write end closes, letting
255/// the read see EOF; then the detail the child wrote replaces the undifferentiated
256/// "Invalid argument (os error 22)". `subject` names the spawn in that message (`shell`,
257/// `native tool`), and `spawn_error` is the error returned when the child wrote nothing.
258pub(crate) fn enforced_spawn_failure(
259    command: Command,
260    supervisor: &crate::sandbox::SupervisorHandle,
261    subject: &str,
262    spawn_error: String,
263) -> ShellExecError {
264    drop(command);
265    match supervisor.read_diagnostic() {
266        // A composed-root failure keeps its own variant rather than being flattened into a
267        // message: it means a host that *did* clear the pre-launch sealed probe then failed to
268        // build the root, which is a different event from a Landlock or seccomp step failing,
269        // points somewhere else, and ends the session instead of the tool call. See
270        // [`ShellExecError::session_fatal`].
271        Some(detail) if detail.contains(crate::sealed::SEALED_ROOT_FAILURE_PREFIX) => {
272            ShellExecError::SealedRootConstructionFailed { detail }
273        }
274        Some(detail) if !detail.is_empty() => ShellExecError::Failed(format!(
275            "sandbox: {subject} enforcement setup failed before exec: {detail}"
276        )),
277        _ => ShellExecError::Failed(spawn_error),
278    }
279}
280
281impl std::fmt::Display for ShellExecError {
282    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
283        match self {
284            Self::Failed(message) => f.write_str(message),
285            // Delegate to the `RuntimeError` Display text so the message an operator reads cannot
286            // drift from the one `E-RUN-014` renders — the same reason the CLI's error mapping
287            // delegates rather than restating.
288            Self::SealedRootConstructionFailed { detail } => write!(
289                f,
290                "{}",
291                crate::errors::RuntimeError::SealedRootConstructionFailed {
292                    detail: detail.clone(),
293                }
294            ),
295        }
296    }
297}
298
299/// Lets the many `Result<_, String>` helpers `execute_shell` calls keep flowing through `?`.
300impl From<String> for ShellExecError {
301    fn from(message: String) -> Self {
302        Self::Failed(message)
303    }
304}
305
306pub(crate) fn is_shell_interpreter(binary: &str) -> bool {
307    SHELL_INTERPRETERS.contains(&binary)
308}
309
310pub(crate) use murmur_artifact::split_shell_words;
311
312pub(crate) fn shell_tool_manifest_yaml(binary: &str) -> String {
313    let description = if is_shell_interpreter(binary) {
314        format!("Run a shell command via {binary} in the capsule workdir. The `command` field is passed to {binary} via -c.")
315    } else {
316        format!("Run {binary} in the capsule workdir. The `command` field holds {binary}'s arguments and not the binary name. It is split into arguments at whitespace; single or double quotes group words, and a backslash escapes the next character. No shell runs it, so nothing in it is expanded.")
317    };
318
319    format!(
320        "name: {binary}\nversion: 0.0.0\nruntime: tool\nimplementation: native\ndescription: \"{description}\"\ninput_schema: '{{\"type\":\"object\",\"properties\":{{\"command\":{{\"type\":\"string\"}}}},\"required\":[\"command\"]}}'\n"
321    )
322}
323
324thread_local! {
325    /// Where [`run_shell`] reports the process group of a command it starts on this thread, for a
326    /// caller that kills the command from another thread. Set only by [`crate::session_check`].
327    static SPAWNED_GROUP: std::cell::RefCell<Option<std::sync::Arc<std::sync::atomic::AtomicI32>>> =
328        const { std::cell::RefCell::new(None) };
329}
330
331/// Has every later [`run_shell`] on this thread store the process group of the command it starts
332/// in `sink`.
333pub(crate) fn report_spawned_group_to(sink: std::sync::Arc<std::sync::atomic::AtomicI32>) {
334    SPAWNED_GROUP.with(|slot| *slot.borrow_mut() = Some(sink));
335}
336
337/// Whether a shell command finished inside its grace period or was demoted to the background.
338#[derive(Debug)]
339pub(crate) enum ShellOutcome {
340    Finished(ShellResult),
341    Detached(DetachedDispatchInfo),
342}
343
344/// How often the grace-period wait checks on a child it cannot block for.
345///
346/// `wait_with_output` has no pollable form, so a command that may be demoted is waited for by
347/// polling. Short enough that a command finishing just inside the grace period is not held past
348/// it by a noticeable margin, long enough that a one-minute grace costs a few thousand
349/// non-blocking syscalls rather than a busy loop.
350const DETACH_POLL_INTERVAL: Duration = Duration::from_millis(20);
351
352/// The tool error a shell call gets when its task was cancelled before the command was spawned.
353pub(crate) const CANCELED_BEFORE_SPAWN: &str =
354    "the task was canceled before this command started, so it was not run";
355
356/// Run a shell command, demoting it to the background if it outruns `detach`'s grace period.
357///
358/// The command starts as the leader of a new process group, with `/dev/null` as its stdin. Once
359/// its own process has exited, the call waits at most [`PIPE_DRAIN_AFTER_EXIT`] — never past the
360/// grace deadline — for its stdout and stderr to close, then kills every process still in that
361/// group and waits at most [`LEFTOVER_KILL_WAIT`] more; see [`finish_output`]. A background
362/// process whose output is redirected away from the call holds neither pipe, so it never triggers
363/// the kill, but the kill takes the whole group, it included, when anything else in it does.
364///
365/// With `detach: None` the child is waited for with a plain blocking wait and nothing is polled —
366/// the path `plan.rs` and the script-capsule store state take, because neither runs a task loop
367/// and so neither has anywhere to deliver a completion.
368///
369/// With `detach: Some`, the child is polled until it exits, the deadline passes, or the policy's
370/// abandon or cancel signal is raised; a cancel already raised refuses the call before the spawn.
371/// On demotion the child, its two pipe readers and the metadata needed to describe it all move to
372/// one plain OS thread — not a `spawn_blocking` task — so runtime teardown never waits on it. The
373/// session's end kills it with every other process the session started.
374///
375/// A detach policy is honoured only when `enforcement` holds a records directory, where a demoted
376/// command's log goes. Without one the command runs to completion in the foreground.
377///
378/// Every command runs in the session's one network namespace, so a server one call starts — in
379/// the background, or demoted — is reachable from the next call of the same session.
380///
381/// Everything else before the spawn is identical on both paths, including the whole spawn-failure
382/// arm: a spawn failure happens inside the grace window and stays a foreground error.
383#[allow(clippy::too_many_arguments)]
384pub(crate) fn run_shell(
385    binary: &str,
386    args: &[&str],
387    env_overrides: &[(String, String)],
388    accessible_workdir: &WorkdirRoot,
389    policy: &CapabilityPolicy,
390    enforcement: &crate::sandbox::ShellEnforcement,
391    detach: Option<DetachPolicy>,
392) -> Result<ShellOutcome, ShellExecError> {
393    let wait = detach.map_or(Wait::Blocking, Wait::Detach);
394    spawn_and_wait(
395        binary,
396        args,
397        env_overrides,
398        accessible_workdir,
399        policy,
400        enforcement,
401        wait,
402    )
403    .map(|(outcome, _)| outcome)
404}
405
406/// When [`run_shell_until_stopped`] stops a command before it exits on its own.
407pub(crate) struct ForegroundStop {
408    /// Raised by a person's cancel or the session deadline.
409    pub canceled: crate::cancel::CancelSignal,
410    /// How long the command may run from its spawn.
411    pub timeout: Duration,
412}
413
414/// Why [`run_shell_until_stopped`] killed a command.
415#[derive(Debug, Clone, Copy, PartialEq, Eq)]
416pub(crate) enum ForegroundStopReason {
417    /// [`ForegroundStop::canceled`] was raised while it ran.
418    Canceled,
419    /// It was still running at [`ForegroundStop::timeout`].
420    TimedOut,
421}
422
423/// A command [`run_shell_until_stopped`] ran: its result, partial when `stopped` is set.
424#[derive(Debug)]
425pub(crate) struct ForegroundRun {
426    pub result: ShellResult,
427    pub stopped: Option<ForegroundStopReason>,
428}
429
430/// Run a command in the foreground under the same containment, environment, resource limits and
431/// output cap as [`run_shell`], killing it when `stop`'s cancel is raised or its timeout passes.
432///
433/// The kill is `SIGKILL` to the command's whole process group, then the command is reaped and
434/// its output drained as [`finish_output`] drains any call's. The result keeps what it printed
435/// before the kill, its `exit_code` is the kill's, and no resource limit is attributed to it. A
436/// stopped command is never demoted and never registered as detached work.
437pub(crate) fn run_shell_until_stopped(
438    binary: &str,
439    args: &[&str],
440    env_overrides: &[(String, String)],
441    accessible_workdir: &WorkdirRoot,
442    policy: &CapabilityPolicy,
443    enforcement: &crate::sandbox::ShellEnforcement,
444    stop: ForegroundStop,
445) -> Result<ForegroundRun, ShellExecError> {
446    match spawn_and_wait(
447        binary,
448        args,
449        env_overrides,
450        accessible_workdir,
451        policy,
452        enforcement,
453        Wait::Stop(stop),
454    )? {
455        (ShellOutcome::Finished(result), stopped) => Ok(ForegroundRun { result, stopped }),
456        // Unreachable: only `Wait::Detach` demotes.
457        (ShellOutcome::Detached(info), _) => Err(ShellExecError::Failed(format!(
458            "a foreground command was demoted to the background as {}",
459            info.work_id
460        ))),
461    }
462}
463
464/// How [`spawn_and_wait`] waits for the command it started.
465enum Wait {
466    /// A plain blocking wait.
467    Blocking,
468    /// Polled, and demoted to the background past the policy's grace; see [`run_shell`].
469    Detach(DetachPolicy),
470    /// Polled, and killed when the stop's cancel is raised or its timeout passes; see
471    /// [`run_shell_until_stopped`].
472    Stop(ForegroundStop),
473}
474
475/// The one place a shell command is checked, contained, spawned and waited for: [`run_shell`]
476/// and [`run_shell_until_stopped`] differ only in `wait`. The second value is set when a
477/// [`Wait::Stop`] killed the command.
478fn spawn_and_wait(
479    binary: &str,
480    args: &[&str],
481    env_overrides: &[(String, String)],
482    accessible_workdir: &WorkdirRoot,
483    policy: &CapabilityPolicy,
484    enforcement: &crate::sandbox::ShellEnforcement,
485    wait: Wait,
486) -> Result<(ShellOutcome, Option<ForegroundStopReason>), ShellExecError> {
487    use std::os::unix::process::CommandExt as _;
488
489    if !policy.shell_allow.iter().any(|allowed| allowed == binary) {
490        return Err(ShellExecError::Failed(format!(
491            "binary '{binary}' is not in capabilities.shell.allow"
492        )));
493    }
494
495    // A call the task's tool loop reaches after its cancel starts nothing: the task is ending,
496    // and a command it started now would only be demoted at its first poll.
497    if let Wait::Detach(DetachPolicy {
498        canceled: Some(canceled),
499        ..
500    }) = &wait
501    {
502        if canceled.is_canceled() {
503            return Err(ShellExecError::Failed(CANCELED_BEFORE_SPAWN.to_string()));
504        }
505    }
506
507    // Refuse before spawning, not after: once the workdir ceiling is crossed, the cheapest way
508    // to stop it growing further is to not start the next process that would write to it.
509    enforcement.check_workdir_budget()?;
510
511    let env = build_shell_env(
512        policy,
513        env_overrides,
514        enforcement.home_dir_path(),
515        enforcement.runtime_dir_path(),
516        enforcement.records_dir_path(),
517        enforcement.tmpdir_value().as_deref(),
518    )?;
519
520    // Resolve before spawning: this is the identity of the binary this call is about to
521    // run, and it is the only point where the invoked name is still in hand.
522    let resolved_binary = crate::sandbox::resolve_invoked_binary_path(binary);
523
524    let started = Instant::now();
525    let mut command = Command::new(binary);
526    command
527        .args(args)
528        .current_dir(accessible_workdir.path())
529        .env_clear()
530        .envs(env)
531        // A command in its own process group that read `mur`'s terminal would be stopped by
532        // `SIGTTIN`, and under process transport `mur`'s stdin is a protocol pipe.
533        .stdin(Stdio::null())
534        .stdout(Stdio::piped())
535        .stderr(Stdio::piped())
536        // Its own group, so what it leaves holding its pipes can be killed without touching
537        // anything an earlier call started. std calls `setpgid` before any `pre_exec` closure.
538        .process_group(0);
539
540    // Fail-closed: if kernel enforcement setup fails unexpectedly, propagate the error and
541    // never call `.spawn()` at all — no code path here lets a Linux host silently run this
542    // subprocess with zero enforcement because setup failed. This also installs the hard
543    // rlimits and (where a scope exists) cgroup membership for the child.
544    let supervisor =
545        crate::sandbox::prepare_enforcement(&mut command, enforcement, accessible_workdir.path())?;
546
547    // Entered before the snapshot and held until the command exits — carried to the demoted
548    // thread if it gets that far — so a sibling's spawn anywhere inside this window is visible to
549    // attribution.
550    let cgroup_occupancy = enforcement.cgroup_scope.as_ref().map(|scope| scope.enter());
551    // Snapshotted before the child runs so attribution keys on this call's delta rather than on
552    // a session-cumulative total an earlier call could have moved.
553    let cgroup_counters_before = enforcement
554        .cgroup_scope
555        .as_ref()
556        .map(|scope| scope.event_counters());
557
558    let mut child = match command.spawn() {
559        Ok(child) => child,
560        Err(error) => {
561            return Err(enforced_spawn_failure(
562                command,
563                &supervisor,
564                "shell",
565                error.to_string(),
566            ))
567        }
568    };
569    // The child is its group's leader, so its pid names the group.
570    let pgid = child.id() as i32;
571    SPAWNED_GROUP.with(|slot| {
572        if let Some(sink) = slot.borrow().as_ref() {
573            sink.store(pgid, std::sync::atomic::Ordering::SeqCst);
574        }
575    });
576
577    // `wait_with_output` reads both pipes and waits in one uninterruptible step, so it cannot be
578    // given a deadline. Draining each pipe on its own thread separates reading from waiting,
579    // which is what lets the wait below be either blocking or polled.
580    let readers = OutputReaders::spawn(&mut child);
581
582    // Demotion keeps the output in the records directory and points the agent at it. With no
583    // records directory there is nowhere to keep it, so the command runs in the foreground.
584    let records_root = enforcement.records_root();
585    let wait = match wait {
586        Wait::Detach(_) if records_root.is_none() => Wait::Blocking,
587        wait => wait,
588    };
589
590    let mut stopped = None;
591    let (status, drain_deadline) = match wait {
592        Wait::Blocking => {
593            let status = child.wait().map_err(|error| error.to_string())?;
594            (status, Instant::now() + PIPE_DRAIN_AFTER_EXIT)
595        }
596        Wait::Stop(stop) => {
597            let deadline = started + stop.timeout;
598            loop {
599                if let Some(status) = child.try_wait().map_err(|error| error.to_string())? {
600                    break (status, Instant::now() + PIPE_DRAIN_AFTER_EXIT);
601                }
602                let remaining = deadline.saturating_duration_since(Instant::now());
603                let reason = if stop.canceled.is_canceled() {
604                    Some(ForegroundStopReason::Canceled)
605                } else if remaining.is_zero() {
606                    Some(ForegroundStopReason::TimedOut)
607                } else {
608                    None
609                };
610                if let Some(reason) = reason {
611                    // The leader is not reaped yet, so `pgid` still names this command's group.
612                    crate::formation_launch::signal_group(i64::from(pgid), libc::SIGKILL);
613                    let status = child.wait().map_err(|error| error.to_string())?;
614                    stopped = Some(reason);
615                    break (status, Instant::now() + PIPE_DRAIN_AFTER_EXIT);
616                }
617                std::thread::sleep(remaining.min(DETACH_POLL_INTERVAL));
618            }
619        }
620        Wait::Detach(policy) => {
621            let deadline = started + policy.grace;
622            loop {
623                match child.try_wait().map_err(|error| error.to_string())? {
624                    Some(status) => {
625                        break (
626                            status,
627                            (Instant::now() + PIPE_DRAIN_AFTER_EXIT).min(deadline),
628                        )
629                    }
630                    None => {
631                        let remaining = deadline.saturating_duration_since(Instant::now());
632                        // A caller that stopped waiting gets the same demotion the deadline
633                        // gives, at the next poll: nobody is left to read a foreground result.
634                        let abandoned = policy
635                            .abandoned
636                            .as_ref()
637                            .is_some_and(crate::detached::AbandonSignal::is_raised);
638                        // A cancelled task stops waiting the same way, and the session's end
639                        // kills the command it leaves running.
640                        let canceled = policy
641                            .canceled
642                            .as_ref()
643                            .filter(|signal| signal.is_canceled());
644                        if let Some(signal) = canceled {
645                            signal.note_phase(crate::cancel::PHASE_SHELL);
646                        }
647                        if remaining.is_zero() || abandoned || canceled.is_some() {
648                            // A zero grace demotes here, at the first poll after the spawn.
649                            let records_root = records_root
650                                .expect("a detach policy is kept only with a records root")
651                                .clone();
652                            let detached = demote(
653                                child,
654                                readers,
655                                DemotionContext {
656                                    policy,
657                                    records_root,
658                                    invoked_binary: binary.to_string(),
659                                    resolved_binary,
660                                    args: args.iter().map(|arg| (*arg).to_string()).collect(),
661                                    started,
662                                    pgid,
663                                    cgroup_scope: enforcement.cgroup_scope.clone(),
664                                    cgroup_counters_before,
665                                    cgroup_occupancy,
666                                },
667                            );
668                            return Ok((ShellOutcome::Detached(detached), None));
669                        }
670                        std::thread::sleep(remaining.min(DETACH_POLL_INTERVAL));
671                    }
672                }
673            }
674        }
675    };
676    let (stdout_bytes, stderr_bytes, output_holders) = finish_output(readers, pgid, drain_deadline);
677    let duration_ms = started.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
678
679    // After `finish_output`, so forks refused while the holders were still running are in the
680    // delta. A command this call killed hit no limit.
681    let resource_limit_hit = classify_resource_limit(
682        &status,
683        enforcement.cgroup_scope.as_deref(),
684        cgroup_counters_before,
685        cgroup_occupancy.as_ref(),
686    )
687    .filter(|_| stopped.is_none());
688    drop(cgroup_occupancy);
689    let exit_code = exit_code_of(&status);
690
691    let mut stdout = String::from_utf8_lossy(&stdout_bytes).to_string();
692    let mut stderr = String::from_utf8_lossy(&stderr_bytes).to_string();
693    let mut truncated = false;
694    let mut full_output_path = None;
695
696    if stdout_bytes.len() > MAX_SHELL_OUTPUT_BYTES || stderr_bytes.len() > MAX_SHELL_OUTPUT_BYTES {
697        truncated = true;
698        let stdout_cut = stdout_bytes.len().min(MAX_SHELL_OUTPUT_BYTES);
699        let stderr_cut = stderr_bytes.len().min(MAX_SHELL_OUTPUT_BYTES);
700        let full_stdout = std::mem::replace(
701            &mut stdout,
702            String::from_utf8_lossy(&stdout_bytes[..stdout_cut]).to_string(),
703        );
704        let full_stderr = std::mem::replace(
705            &mut stderr,
706            String::from_utf8_lossy(&stderr_bytes[..stderr_cut]).to_string(),
707        );
708        if let Some(records_root) = records_root {
709            full_output_path = Some(write_shell_output_log(
710                records_root,
711                &format!("shell-{}", crate::trace::timestamp_ms()),
712                binary,
713                args,
714                exit_code,
715                &full_stdout,
716                &full_stderr,
717            )?);
718        }
719    }
720
721    Ok((
722        ShellOutcome::Finished(ShellResult {
723            binary: resolved_binary,
724            exit_code,
725            stdout,
726            stderr,
727            duration_ms,
728            truncated,
729            full_output_path,
730            resource_limit_hit,
731            output_holders,
732        }),
733        stopped,
734    ))
735}
736
737/// [`run_shell`] with no demotion: the command runs to completion in the foreground.
738///
739/// Kept at its original signature and return type because most callers cannot take a completion
740/// and every one of them should keep reading exactly as it did.
741pub(crate) fn execute_shell(
742    binary: &str,
743    args: &[&str],
744    env_overrides: &[(String, String)],
745    accessible_workdir: &WorkdirRoot,
746    policy: &CapabilityPolicy,
747    enforcement: &crate::sandbox::ShellEnforcement,
748) -> Result<ShellResult, ShellExecError> {
749    match run_shell(
750        binary,
751        args,
752        env_overrides,
753        accessible_workdir,
754        policy,
755        enforcement,
756        None,
757    )? {
758        ShellOutcome::Finished(result) => Ok(result),
759        // Unreachable: demotion is reached only from the `Some(policy)` wait arm above. Reported
760        // rather than panicked, so a future edit that broke that invariant would fail one tool
761        // call instead of taking the session down.
762        ShellOutcome::Detached(info) => Err(ShellExecError::Failed(format!(
763            "shell command was demoted to the background as {} on a call site that cannot receive a completion",
764            info.work_id
765        ))),
766    }
767}
768
769/// The two pipe-draining threads for one child.
770///
771/// Both must be running before the child is waited for: a child writing more than a pipe buffer
772/// holds while nobody reads deadlocks, which is the reason `wait_with_output` reads and waits
773/// together in the first place.
774///
775/// Each thread appends what it reads to a buffer the call can take at any time, so the call can
776/// stop waiting for EOF without joining: a process the command left behind can hold a pipe open
777/// for as long as it runs. A demoted command's threads append to its log instead; see
778/// [`OutputReaders::go_live`].
779struct OutputReaders {
780    stdout: Option<PipeReader>,
781    stderr: Option<PipeReader>,
782}
783
784/// Where one pipe's reading thread puts what it reads.
785enum PipeSink {
786    /// Kept in memory until the call takes it.
787    Buffer(Vec<u8>),
788    /// Appended to a demoted command's log as it is read.
789    Live(std::sync::Arc<std::sync::Mutex<crate::detached::LiveOutput>>),
790    /// Read and dropped: the call has answered. A holder that writes forever neither costs memory
791    /// nor gets `SIGPIPE`.
792    Discard,
793}
794
795/// One pipe's reading thread and where it puts what it reads.
796struct PipeReader {
797    thread: std::thread::JoinHandle<()>,
798    sink: std::sync::Arc<std::sync::Mutex<PipeSink>>,
799}
800
801fn lock_sink(sink: &std::sync::Mutex<PipeSink>) -> std::sync::MutexGuard<'_, PipeSink> {
802    sink.lock()
803        .unwrap_or_else(std::sync::PoisonError::into_inner)
804}
805
806impl PipeReader {
807    fn spawn(mut pipe: impl std::io::Read + Send + 'static) -> Self {
808        let sink = std::sync::Arc::new(std::sync::Mutex::new(PipeSink::Buffer(Vec::new())));
809        let thread = {
810            let sink = std::sync::Arc::clone(&sink);
811            std::thread::spawn(move || {
812                let mut chunk = [0u8; 8192];
813                loop {
814                    match pipe.read(&mut chunk) {
815                        Ok(0) => break,
816                        Ok(read) => match &mut *lock_sink(&sink) {
817                            PipeSink::Buffer(buffer) => buffer.extend_from_slice(&chunk[..read]),
818                            PipeSink::Live(output) => output
819                                .lock()
820                                .unwrap_or_else(std::sync::PoisonError::into_inner)
821                                .append(&chunk[..read]),
822                            PipeSink::Discard => {}
823                        },
824                        Err(error) if error.kind() == std::io::ErrorKind::Interrupted => {}
825                        // A read error loses whatever was not yet read and keeps what was; there
826                        // is no better outcome available, and failing the whole call over it
827                        // would throw away the command's exit status too.
828                        Err(_) => break,
829                    }
830                }
831            })
832        };
833        Self { thread, sink }
834    }
835
836    /// The bytes buffered so far, leaving the thread to discard the rest. Empty for a reader
837    /// writing to a log, and for a thread that panicked.
838    fn take(self) -> Vec<u8> {
839        let taken = match std::mem::replace(&mut *lock_sink(&self.sink), PipeSink::Discard) {
840            PipeSink::Buffer(buffer) => buffer,
841            PipeSink::Live(_) | PipeSink::Discard => Vec::new(),
842        };
843        if self.thread.is_finished() && self.thread.join().is_err() {
844            return Vec::new();
845        }
846        taken
847    }
848}
849
850impl OutputReaders {
851    fn spawn(child: &mut std::process::Child) -> Self {
852        Self {
853            stdout: child.stdout.take().map(PipeReader::spawn),
854            stderr: child.stderr.take().map(PipeReader::spawn),
855        }
856    }
857
858    /// Moves both readers onto `output`: what each has buffered is appended, stdout's then
859    /// stderr's, and from then on each appends every chunk as it reads it.
860    ///
861    /// Both sinks are held across the switch, so no chunk read meanwhile lands ahead of the
862    /// buffered bytes.
863    fn go_live(&self, output: &std::sync::Arc<std::sync::Mutex<crate::detached::LiveOutput>>) {
864        let mut sinks: Vec<_> = [&self.stdout, &self.stderr]
865            .into_iter()
866            .flatten()
867            .map(|reader| lock_sink(&reader.sink))
868            .collect();
869        let mut live = output
870            .lock()
871            .unwrap_or_else(std::sync::PoisonError::into_inner);
872        for sink in &mut sinks {
873            if let PipeSink::Buffer(buffer) =
874                std::mem::replace(&mut **sink, PipeSink::Live(std::sync::Arc::clone(output)))
875            {
876                live.append(&buffer);
877            }
878        }
879    }
880
881    /// Whether both pipes have reached EOF (or failed to read).
882    fn at_eof(&self) -> bool {
883        [&self.stdout, &self.stderr]
884            .into_iter()
885            .flatten()
886            .all(|reader| reader.thread.is_finished())
887    }
888
889    /// Wait until both pipes reach EOF or `deadline` passes, whichever is first. `true` when they
890    /// reached EOF.
891    fn wait_for_eof(&self, deadline: Instant) -> bool {
892        loop {
893            if self.at_eof() {
894                return true;
895            }
896            let remaining = deadline.saturating_duration_since(Instant::now());
897            if remaining.is_zero() {
898                return false;
899            }
900            std::thread::sleep(remaining.min(PIPE_POLL_INTERVAL));
901        }
902    }
903
904    /// `(stdout, stderr)` as buffered so far, without waiting. A thread still reading keeps
905    /// reading to EOF and stores and writes nothing more.
906    fn take(self) -> (Vec<u8>, Vec<u8>) {
907        let take = |reader: Option<PipeReader>| reader.map(PipeReader::take).unwrap_or_default();
908        (take(self.stdout), take(self.stderr))
909    }
910}
911
912/// `(stdout, stderr)` of a command whose own process has exited, and what was left holding them.
913///
914/// Waits for both pipes until `drain_deadline`. If they are still open then, signals the
915/// command's process group `pgid` with `SIGKILL` until the group is gone, and waits for the pipes
916/// at most [`LEFTOVER_KILL_WAIT`] more. Signalling stops at the first `kill` that fails: the
917/// group's number stays reserved only while the group has members, so once it is gone the number
918/// may name an unrelated group. A holder that left the group (`setsid`, `setpgid`) is beyond this
919/// kill; its pipe is given up on and the session's end kills it.
920fn finish_output(
921    readers: OutputReaders,
922    pgid: i32,
923    drain_deadline: Instant,
924) -> (Vec<u8>, Vec<u8>, OutputHolders) {
925    if readers.wait_for_eof(drain_deadline) {
926        let (stdout, stderr) = readers.take();
927        return (stdout, stderr, OutputHolders::Closed);
928    }
929
930    let give_up = Instant::now() + LEFTOVER_KILL_WAIT;
931    let mut group_alive = true;
932    let mut killed = false;
933    let closed = loop {
934        if group_alive {
935            // Refuses a `pgid` of 0 or 1, which would name `mur`'s own group or every process.
936            if crate::formation_launch::signal_group(i64::from(pgid), libc::SIGKILL) {
937                killed = true;
938            } else {
939                group_alive = false;
940            }
941        }
942        if readers.at_eof() {
943            break true;
944        }
945        let remaining = give_up.saturating_duration_since(Instant::now());
946        if remaining.is_zero() {
947            break false;
948        }
949        std::thread::sleep(remaining.min(PIPE_POLL_INTERVAL));
950    };
951
952    let (stdout, stderr) = readers.take();
953    let holders = match (closed, killed) {
954        (true, true) => OutputHolders::Killed,
955        // The pipes closed on their own just after a zero-length window.
956        (true, false) => OutputHolders::Closed,
957        (false, _) => OutputHolders::Escaped,
958    };
959    (stdout, stderr, holders)
960}
961
962/// Everything the background thread needs to describe the command it is finishing.
963struct DemotionContext {
964    policy: DetachPolicy,
965    /// The session's records directory, where the command's log goes and which the completion
966    /// names it in.
967    records_root: WorkdirRoot,
968    /// The name as invoked, which is what the log header records.
969    invoked_binary: String,
970    /// The same name resolved to a path where the host `PATH` named one — what the trace and the
971    /// completion report, matching [`ShellResult::binary`].
972    resolved_binary: String,
973    args: Vec<String>,
974    started: Instant,
975    /// The command's process group, which [`finish_output`] kills if it is still holding the
976    /// command's output after it exits.
977    pgid: i32,
978    cgroup_scope: Option<std::sync::Arc<crate::cgroup::CgroupScope>>,
979    cgroup_counters_before: Option<crate::cgroup::CgroupEventCounters>,
980    /// The command's stay in the scope, held on the background thread until it exits.
981    cgroup_occupancy: Option<crate::cgroup::ScopeOccupancy>,
982}
983
984/// Hand a still-running command to a thread of its own and return the handle for it.
985///
986/// The command's log is opened in the records directory and its readers switched onto it before
987/// this returns, so the output read so far and everything after are on disk while it runs. The
988/// work is registered before this returns too, so the session-end sweep can never miss work that
989/// finished being registered after it looked.
990///
991/// The thread observes the command's own process exiting with `waitid(WNOWAIT)`, tells the
992/// registry, and only then reaps it: a group is signalled only while its leader is unreaped. It
993/// then finishes the output the way a foreground call does: [`finish_output`] waits at most
994/// [`PIPE_DRAIN_AFTER_EXIT`] for its pipes and then kills what is still holding them, so the
995/// completion arrives even when the command left background processes writing to its output.
996fn demote(
997    mut child: std::process::Child,
998    readers: OutputReaders,
999    context: DemotionContext,
1000) -> DetachedDispatchInfo {
1001    let work_id = crate::detached::new_work_id();
1002    let info = DetachedDispatchInfo {
1003        work_id: work_id.clone(),
1004        binary: context.resolved_binary.clone(),
1005        command: context.policy.command.clone(),
1006        grace_ms: context
1007            .policy
1008            .grace
1009            .as_millis()
1010            .try_into()
1011            .unwrap_or(u64::MAX),
1012    };
1013
1014    let arg_refs: Vec<&str> = context.args.iter().map(String::as_str).collect();
1015    let log = crate::detached::output_path_for(&context.records_root, &work_id);
1016    let output = std::sync::Arc::new(std::sync::Mutex::new(crate::detached::LiveOutput::open(
1017        &context.records_root,
1018        &log.relative,
1019        &log_command_line(&context.invoked_binary, &arg_refs),
1020    )));
1021    readers.go_live(&output);
1022
1023    context.policy.registry.register(
1024        crate::detached::DetachedWork {
1025            work_id: work_id.clone(),
1026            binary: context.resolved_binary.clone(),
1027            command: context.policy.command.clone(),
1028            // The spawn instant, not this one. Demotion happens a whole grace period after the
1029            // command started, and every reader of this field — the cancel residue, the
1030            // session-end sweep's `running_ms` — reports it as how long the command has been
1031            // running, which must include the foreground portion to match the `duration_ms` a
1032            // completion carries.
1033            started_at_ms: crate::trace::timestamp_ms().saturating_sub(
1034                context
1035                    .started
1036                    .elapsed()
1037                    .as_millis()
1038                    .try_into()
1039                    .unwrap_or(u64::MAX),
1040            ),
1041        },
1042        context.started,
1043        context.pgid,
1044        log.path.clone(),
1045        std::sync::Arc::clone(&output),
1046    );
1047
1048    let provenance = context.policy.completion_provenance();
1049    let DemotionContext {
1050        policy,
1051        records_root,
1052        resolved_binary,
1053        started,
1054        pgid,
1055        cgroup_scope,
1056        cgroup_counters_before,
1057        cgroup_occupancy,
1058        ..
1059    } = context;
1060
1061    std::thread::spawn(move || {
1062        let observed = wait_for_exit_unreaped(pgid);
1063        policy.registry.leader_exited(&work_id);
1064        let waited = child.wait();
1065        let (_, _, output_holders) =
1066            finish_output(readers, pgid, Instant::now() + PIPE_DRAIN_AFTER_EXIT);
1067        let duration_ms = started.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
1068
1069        let (exit_code, resource_limit, mut error) = match waited {
1070            Ok(status) => (
1071                exit_code_of(&status),
1072                classify_resource_limit(
1073                    &status,
1074                    cgroup_scope.as_deref(),
1075                    cgroup_counters_before,
1076                    cgroup_occupancy.as_ref(),
1077                ),
1078                None,
1079            ),
1080            Err(failure) => (
1081                -1,
1082                None,
1083                Some(format!("waiting for the command failed: {failure}")),
1084            ),
1085        };
1086        drop(cgroup_occupancy);
1087        if let (Err(failure), None) = (observed, &error) {
1088            error = Some(format!("observing the command's exit failed: {failure}"));
1089        }
1090
1091        let write_error = output
1092            .lock()
1093            .unwrap_or_else(std::sync::PoisonError::into_inner)
1094            .finish(exit_code);
1095        if let Some(failure) = write_error {
1096            error = Some(match error {
1097                Some(existing) => format!("{existing}; {failure}"),
1098                None => failure,
1099            });
1100        }
1101        let output_bytes = records_root
1102            .file_len(&log.relative)
1103            .ok()
1104            .flatten()
1105            .unwrap_or(0);
1106
1107        policy
1108            .registry
1109            .complete(crate::detached::DetachedCompletion {
1110                work_id,
1111                binary: resolved_binary,
1112                command: policy.command.clone(),
1113                exit_code,
1114                duration_ms,
1115                output_path: log.path,
1116                output_bytes,
1117                resource_limit,
1118                output_holders,
1119                context_id: policy.context_id.clone(),
1120                provenance,
1121                error,
1122            });
1123    });
1124
1125    info
1126}
1127
1128/// Blocks until the child `pid` has exited, leaving it unreaped.
1129///
1130/// Until it is reaped the pid stays the child's, so its process group, which it leads, cannot be
1131/// taken by an unrelated process while anything signals it. `NOWAIT` leaves the child waitable,
1132/// so `Child::wait` still reaps it afterwards.
1133fn wait_for_exit_unreaped(pid: i32) -> std::io::Result<()> {
1134    use rustix::process::{waitid, Pid, WaitId, WaitIdOptions};
1135
1136    let pid = (pid > 0)
1137        .then(|| Pid::from_raw(pid))
1138        .flatten()
1139        .ok_or_else(|| std::io::Error::from(std::io::ErrorKind::InvalidInput))?;
1140    loop {
1141        match waitid(
1142            WaitId::Pid(pid),
1143            WaitIdOptions::EXITED | WaitIdOptions::NOWAIT,
1144        ) {
1145            Ok(_) => return Ok(()),
1146            Err(rustix::io::Errno::INTR) => {}
1147            Err(errno) => return Err(errno.into()),
1148        }
1149    }
1150}
1151
1152/// Exit code for a finished subprocess, keeping the signal that killed it legible.
1153///
1154/// `ExitStatus::code()` returns `None` for every signal-killed process on Unix, so a bare
1155/// `.unwrap_or(-1)` collapses a `SIGXCPU` from `RLIMIT_CPU`, a cgroup OOM `SIGKILL` and an
1156/// unrelated crash into one indistinguishable `-1`. `128 + signal` is the long-standing shell
1157/// convention for exactly this case and keeps the cause readable in the trace even where
1158/// [`classify_resource_limit`] declines to name a limit.
1159pub(crate) fn exit_code_of(status: &std::process::ExitStatus) -> i32 {
1160    use std::os::unix::process::ExitStatusExt;
1161
1162    status
1163        .code()
1164        .or_else(|| status.signal().map(|signal| 128 + signal))
1165        .unwrap_or(-1)
1166}
1167
1168/// Name the `capabilities.resources` field a subprocess was killed for exceeding, or `None`.
1169///
1170/// Two independent sources of evidence, both kernel-maintained facts rather than inference:
1171///
1172///   * the kill signal, for the two rlimits that raise a signal unique to themselves
1173///     (`SIGXCPU` → `cpu_seconds`, `SIGXFSZ` → `max_file_size_bytes`);
1174///   * this call's delta on the cgroup scope's `memory.events`/`pids.events` counters
1175///     (`oom_kill` → `cgroup_memory_bytes`, `max` → `cgroup_pids_max`).
1176///
1177/// The signal is checked first: where both fire, it identifies the individual process that died,
1178/// which is the more specific claim. The counter delta is about the whole scope, so it names this
1179/// call's limit only when the call had the scope to itself — see [`attribute_resource_limit`].
1180///
1181/// The `pids.max` case is reported even when the process exited normally, because that is how it
1182/// presents — `pids.max` refuses a `fork()` rather than killing anything, so a fork bomb held by
1183/// the cgroup usually shows up as a shell exiting non-zero with `EAGAIN` on stderr. Without the
1184/// counter there would be nothing distinguishing it from any other failure.
1185///
1186/// Nothing else is attributed. See [`crate::resources::limit_from_signal`] for why
1187/// `RLIMIT_AS`/`RLIMIT_DATA`, `RLIMIT_NPROC` and `RLIMIT_NOFILE` are deliberately left
1188/// unattributed rather than guessed at.
1189fn classify_resource_limit(
1190    status: &std::process::ExitStatus,
1191    cgroup_scope: Option<&crate::cgroup::CgroupScope>,
1192    counters_before: Option<crate::cgroup::CgroupEventCounters>,
1193    occupancy: Option<&crate::cgroup::ScopeOccupancy>,
1194) -> Option<String> {
1195    use std::os::unix::process::ExitStatusExt;
1196
1197    let signal_limit = status
1198        .signal()
1199        .and_then(crate::resources::limit_from_signal);
1200    let counter_delta = match (cgroup_scope, counters_before) {
1201        (Some(scope), Some(before)) => scope.event_counters().attribution_since(before),
1202        _ => None,
1203    };
1204    let exclusive = occupancy.is_some_and(crate::cgroup::ScopeOccupancy::exclusive);
1205    attribute_resource_limit(signal_limit, counter_delta, exclusive)
1206}
1207
1208/// The limit a call is reported as having hit, from the two kinds of evidence there are.
1209///
1210/// The signal names the process that died, so it is this call's whenever there is one. The
1211/// cgroup counter delta is about the scope, and every process in the scope moves it: it is this
1212/// call's only when the call was `exclusive` — alone in the scope from entry to exit. Otherwise
1213/// the delta could be a sibling's, and nothing is attributed.
1214pub(crate) fn attribute_resource_limit(
1215    signal_limit: Option<&'static str>,
1216    counter_delta: Option<&'static str>,
1217    exclusive: bool,
1218) -> Option<String> {
1219    signal_limit
1220        .or(counter_delta.filter(|_| exclusive))
1221        .map(str::to_string)
1222}
1223
1224/// The environment of one shell command, plan shell step or native tool: [`DEFAULT_ENV_BASELINE`]
1225/// from the host, `env_overrides`, `capabilities.shell.baseline_env`, the credential backstop,
1226/// and then the runtime-owned names no entry before them can set — `HOME` and `USERPROFILE` from
1227/// `home_dir`, `MURMUR_RUNTIME_DIR` and `MURMUR_RECORDS_DIR` from `runtime_dir` and
1228/// `records_dir`, and `TMPDIR`, `TMP` and `TEMP`, all three `tmpdir` or all three absent.
1229///
1230/// `home_dir` is the session's home folder, `home/` in its records directory. `None` refuses the
1231/// command: no command runs without it, and none is handed a home anywhere else.
1232pub(crate) fn build_shell_env(
1233    policy: &CapabilityPolicy,
1234    env_overrides: &[(String, String)],
1235    home_dir: Option<&std::path::Path>,
1236    runtime_dir: Option<&std::path::Path>,
1237    records_dir: Option<&std::path::Path>,
1238    tmpdir: Option<&str>,
1239) -> Result<BTreeMap<String, String>, String> {
1240    let mut env = BTreeMap::new();
1241
1242    for key in DEFAULT_ENV_BASELINE {
1243        if let Ok(value) = std::env::var(key) {
1244            env.insert((*key).to_string(), value);
1245        }
1246    }
1247
1248    for (key, value) in env_overrides {
1249        env.insert(key.clone(), value.clone());
1250    }
1251
1252    for key in &policy.shell_baseline_env {
1253        // Formation names and the spawner lifeline are never a native process's: a baseline
1254        // entry naming one supplies nothing from the host.
1255        if crate::formation::is_member_grant_env(key)
1256            || key == crate::lifeline::SPAWNER_LIFELINE_ENV
1257        {
1258            continue;
1259        }
1260        if let Ok(value) = std::env::var(key) {
1261            env.insert(key.clone(), value);
1262        }
1263    }
1264
1265    strip_credential_shaped_vars(&mut env, &policy.shell_strip_env);
1266
1267    // Inserted last so neither a guest-supplied `env_overrides` entry nor a
1268    // manifest-declared `shell_baseline_env` entry can resurrect the real host
1269    // HOME/USERPROFILE, and so no strip_env pattern can remove the session's.
1270    let home = home_dir.ok_or_else(|| NO_HOME_FOLDER_REFUSAL.to_string())?;
1271    let home = home.to_string_lossy().into_owned();
1272    env.insert("HOME".to_string(), home.clone());
1273    env.insert("USERPROFILE".to_string(), home);
1274    // Runtime-owned on the same terms: whatever an override, a baseline entry or the host said,
1275    // a command reads the session's own runtime directory here, or no such variable at all.
1276    env.remove(crate::runtime_dir::RUNTIME_DIR_ENV);
1277    if let Some(runtime_dir) = runtime_dir {
1278        env.insert(
1279            crate::runtime_dir::RUNTIME_DIR_ENV.to_string(),
1280            runtime_dir.to_string_lossy().into_owned(),
1281        );
1282    }
1283    // And the session's records directory, on the same terms.
1284    env.remove(crate::records_dir::RECORDS_DIR_ENV);
1285    if let Some(records_dir) = records_dir {
1286        env.insert(
1287            crate::records_dir::RECORDS_DIR_ENV.to_string(),
1288            records_dir.to_string_lossy().into_owned(),
1289        );
1290    }
1291    // And the session's temporary folder, under all three names programs look for it by.
1292    for name in crate::temp_dir::TEMP_DIR_ENV {
1293        env.remove(name);
1294        if let Some(tmpdir) = tmpdir {
1295            env.insert(name.to_string(), tmpdir.to_string());
1296        }
1297    }
1298
1299    Ok(env)
1300}
1301
1302/// Drop every entry whose name matches [`CREDENTIAL_ENV_PATTERNS`] or one of
1303/// `extra_patterns` (a policy's `shell_strip_env`).
1304///
1305/// This is the single credential backstop for both environments the runtime builds: the
1306/// native subprocess env ([`build_shell_env`]) and the WASI guest env
1307/// ([`build_declared_env`]). It runs *after* whatever allowlist populated `env`, so a
1308/// declared name never bypasses the filter.
1309pub(crate) fn strip_credential_shaped_vars(
1310    env: &mut BTreeMap<String, String>,
1311    extra_patterns: &[String],
1312) {
1313    env.retain(|key, _| !credential_backstop_drops(key, extra_patterns));
1314}
1315
1316/// Which list a credential backstop pattern came from.
1317#[derive(Debug, Clone, PartialEq, Eq)]
1318pub enum BackstopPatternSource {
1319    /// [`CREDENTIAL_ENV_PATTERNS`], the list no manifest can change.
1320    Builtin,
1321    /// The manifest's own `capabilities.shell.strip_env`.
1322    StripEnv,
1323}
1324
1325/// The pattern that makes the credential backstop drop a name, verbatim, and where it came from.
1326#[derive(Debug, Clone, PartialEq, Eq)]
1327pub struct BackstopMatch {
1328    pub pattern: String,
1329    pub source: BackstopPatternSource,
1330}
1331
1332/// The first credential backstop pattern that matches `name`, or `None` when the backstop keeps
1333/// it — the one decision procedure behind [`strip_credential_shaped_vars`],
1334/// [`credential_backstop_drops`], `W-SEC-024` and the `E-CAP-016` staging refusal.
1335///
1336/// [`CREDENTIAL_ENV_PATTERNS`] is tried before `extra_patterns`, so a name both lists cover is
1337/// attributed to the built-in pattern: that is the one no manifest edit can remove.
1338/// `extra_patterns` is a policy's `shell_strip_env`. Judges a name alone and never reads the host
1339/// environment.
1340pub fn credential_backstop_match(name: &str, extra_patterns: &[String]) -> Option<BackstopMatch> {
1341    CREDENTIAL_ENV_PATTERNS
1342        .iter()
1343        .map(|pattern| (*pattern, BackstopPatternSource::Builtin))
1344        .chain(
1345            extra_patterns
1346                .iter()
1347                .map(|pattern| (pattern.as_str(), BackstopPatternSource::StripEnv)),
1348        )
1349        .find(|(pattern, _)| env_name_matches_pattern(pattern, name))
1350        .map(|(pattern, source)| BackstopMatch {
1351            pattern: pattern.to_string(),
1352            source,
1353        })
1354}
1355
1356/// Whether the credential backstop would drop a variable of this name — the same question
1357/// [`strip_credential_shaped_vars`] answers per entry, asked of a name alone. See
1358/// [`credential_backstop_match`] for which pattern decided it.
1359pub fn credential_backstop_drops(name: &str, extra_patterns: &[String]) -> bool {
1360    credential_backstop_match(name, extra_patterns).is_some()
1361}
1362
1363/// Resolve the host variables the manifest declared: only names in `capabilities.env.allow` that
1364/// the host actually has set, minus anything credential-shaped. A declared name absent from the
1365/// host is simply omitted, not an error.
1366///
1367/// This is what every WASM guest observes. A `transport: process` harness starts from it plus the
1368/// host's [`HostAccountEnv`], before its driver's `env-set` is applied. Native shell subprocesses
1369/// are the exception: they run on [`DEFAULT_ENV_BASELINE`] plus `capabilities.shell.baseline_env`,
1370/// built by [`build_shell_env`].
1371///
1372/// [`ARTIFACT_CONFIG_ENV`], a formation member's names
1373/// ([`crate::formation::is_member_grant_env`]) and [`crate::lifeline::SPAWNER_LIFELINE_ENV`] are
1374/// reserved and never resolved from the host, whatever a manifest allowlists: the names are
1375/// runtime-owned, and their values come from the declaring artifact's own `config:` block and from
1376/// the session, or from nowhere. Skipped here rather than relied on being overwritten later, so a
1377/// host value cannot reach a guest whose entry declared no config at all.
1378pub(crate) fn build_declared_env(policy: &CapabilityPolicy) -> BTreeMap<String, String> {
1379    let mut env = BTreeMap::new();
1380
1381    for key in &policy.env_allow {
1382        if key == ARTIFACT_CONFIG_ENV
1383            || crate::formation::is_member_grant_env(key)
1384            || key == crate::lifeline::SPAWNER_LIFELINE_ENV
1385            || key == crate::runtime_dir::RUNTIME_DIR_ENV
1386            || key == crate::records_dir::RECORDS_DIR_ENV
1387        {
1388            continue;
1389        }
1390        if let Ok(value) = std::env::var(key) {
1391            env.insert(key.clone(), value);
1392        }
1393    }
1394
1395    strip_credential_shaped_vars(&mut env, &policy.shell_strip_env);
1396
1397    env
1398}
1399
1400fn env_name_matches_pattern(pattern: &str, key: &str) -> bool {
1401    if let Some(rest) = pattern.strip_prefix('*') {
1402        if let Some(middle) = rest.strip_suffix('*') {
1403            return key.contains(middle);
1404        }
1405        return key.ends_with(rest);
1406    }
1407
1408    if let Some(prefix) = pattern.strip_suffix('*') {
1409        return key.starts_with(prefix);
1410    }
1411
1412    pattern == key
1413}
1414
1415/// The command as a log's `Command:` line names it: the invoked binary and its arguments.
1416fn log_command_line(binary: &str, args: &[&str]) -> String {
1417    if args.is_empty() {
1418        binary.to_string()
1419    } else {
1420        format!("{binary} {}", args.join(" "))
1421    }
1422}
1423
1424/// Write a foreground command's full stdout and stderr to its `shell-<ms>` log in the session's
1425/// records directory, returning the log's absolute path. [`crate::detached::output_path_for`]
1426/// places it.
1427///
1428/// The file holds a `Command:` and an `Exit code:` line, then `Stdout:` and `Stderr:` sections. A
1429/// demoted command's log has its own layout, written as it runs; see
1430/// [`crate::detached::LiveOutput`].
1431fn write_shell_output_log(
1432    records_root: &WorkdirRoot,
1433    file_stem: &str,
1434    binary: &str,
1435    args: &[&str],
1436    exit_code: i32,
1437    stdout: &str,
1438    stderr: &str,
1439) -> Result<String, String> {
1440    let log = crate::detached::output_path_for(records_root, file_stem);
1441    let command = log_command_line(binary, args);
1442
1443    let content = format!(
1444        "Command: {command}\nExit code: {exit_code}\n\nStdout:\n{stdout}\n\nStderr:\n{stderr}\n"
1445    );
1446
1447    records_root
1448        .write_file(&log.relative, content.as_bytes(), DEFAULT_FILE_MODE)
1449        .map_err(|error| format!("failed to write shell log: {error}"))?;
1450
1451    Ok(log.path)
1452}
1453
1454#[cfg(test)]
1455mod tests {
1456    use std::{fs, path::Path};
1457    use tempfile::tempdir;
1458
1459    use super::test_support::{process_dead_within, read_pid, KillPidFileOnDrop};
1460    use super::*;
1461    use crate::sandbox::ShellEnforcement;
1462
1463    /// The `pre_exec` rlimit plumbing, end to end on whatever platform runs the suite: a
1464    /// declared `capabilities.resources.max_open_files` must reach the child as its **hard**
1465    /// ceiling, which is what `ulimit -Hn` reports. This asserts the mechanism (the value was
1466    /// applied to `rlim_max`, not only `rlim_cur`), not a security outcome — the hostile-capsule
1467    /// scenarios require real Linux hardware.
1468    #[test]
1469    fn execute_shell_applies_the_declared_limit_as_a_hard_ceiling() {
1470        let temp = tempdir().unwrap();
1471        let policy = CapabilityPolicy {
1472            shell_allow: vec!["bash".to_string()],
1473            resources: crate::resources::HostResourceLimits {
1474                max_open_files: 64,
1475                ..crate::resources::HostResourceLimits::default()
1476            },
1477            ..CapabilityPolicy::default()
1478        };
1479        let mut enforcement = ShellEnforcement::environment_only().with_test_session_dirs();
1480        enforcement.resource_limits = policy.resources;
1481
1482        let result = execute_shell(
1483            "bash",
1484            &["-c", "ulimit -Hn"],
1485            &[],
1486            &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
1487            &policy,
1488            &enforcement,
1489        )
1490        .expect("bash must run");
1491
1492        assert_eq!(result.stdout.trim(), "64", "stderr was: {}", result.stderr);
1493        assert_eq!(result.resource_limit_hit, None);
1494    }
1495
1496    /// Runs `bash -c script` through `execute_shell` with `limits` both declared and applied.
1497    #[cfg(target_os = "linux")]
1498    fn run_bash_with_limits(
1499        limits: crate::resources::HostResourceLimits,
1500        script: &str,
1501    ) -> ShellResult {
1502        let temp = tempdir().unwrap();
1503        let policy = CapabilityPolicy {
1504            shell_allow: vec!["bash".to_string()],
1505            resources: limits,
1506            ..CapabilityPolicy::default()
1507        };
1508        let mut enforcement = ShellEnforcement::environment_only().with_test_session_dirs();
1509        enforcement.resource_limits = limits;
1510
1511        execute_shell(
1512            "bash",
1513            &["-c", script],
1514            &[],
1515            &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
1516            &policy,
1517            &enforcement,
1518        )
1519        .expect("bash must run")
1520    }
1521
1522    /// The bound behind `cpu_seconds` is the hard slot: a subshell that ignores `SIGXCPU` keeps
1523    /// running past `cpu_seconds` and is killed with `SIGKILL` one second later. That kill names
1524    /// no limit, and the subshell's CPU time, read from bash's `times` (whose second line is the
1525    /// reaped children's user and system time), stays within `cpu_seconds` plus one second, with
1526    /// slack for accounting granularity.
1527    #[cfg(target_os = "linux")]
1528    #[test]
1529    fn a_command_that_ignores_sigxcpu_is_killed_one_second_later() {
1530        let result = run_bash_with_limits(
1531            crate::resources::HostResourceLimits {
1532                cpu_seconds: 1,
1533                ..crate::resources::HostResourceLimits::default()
1534            },
1535            "( trap '' XCPU; while :; do :; done ); echo \"status=$?\"; times",
1536        );
1537
1538        assert_eq!(result.exit_code, 0, "stderr was: {}", result.stderr);
1539        assert!(
1540            result.stdout.contains("status=137"),
1541            "the subshell must die of SIGKILL; stdout was: {}",
1542            result.stdout
1543        );
1544        assert_eq!(result.resource_limit_hit, None);
1545
1546        let children = result
1547            .stdout
1548            .lines()
1549            .last()
1550            .unwrap_or_else(|| panic!("no children line from `times`: {}", result.stdout));
1551        let cpu: f64 = children
1552            .split_whitespace()
1553            .map(|field| {
1554                let (minutes, seconds) = field.trim_end_matches('s').split_once('m').unwrap();
1555                minutes.parse::<f64>().unwrap() * 60.0 + seconds.parse::<f64>().unwrap()
1556            })
1557            .sum();
1558        assert!(
1559            cpu <= 2.5,
1560            "the subshell ran {cpu} CPU-seconds, past cpu_seconds + 1"
1561        );
1562    }
1563
1564    /// `RLIMIT_CPU` reaches the child with its soft slot at `cpu_seconds` and its hard slot one
1565    /// second above, while every other rlimit keeps soft = hard, and an unprivileged child cannot
1566    /// raise the hard slot.
1567    #[cfg(target_os = "linux")]
1568    #[test]
1569    #[allow(unsafe_code)]
1570    fn the_cpu_limit_warns_one_second_before_its_hard_ceiling() {
1571        let result = run_bash_with_limits(
1572            crate::resources::HostResourceLimits {
1573                cpu_seconds: 5,
1574                max_open_files: 64,
1575                ..crate::resources::HostResourceLimits::default()
1576            },
1577            r#"echo "$(ulimit -St) $(ulimit -Ht) $(ulimit -Sn) $(ulimit -Hn)"; ulimit -Ht 10 2>/dev/null || echo "raise refused""#,
1578        );
1579
1580        let mut lines = result.stdout.lines();
1581        assert_eq!(
1582            lines.next(),
1583            Some("5 6 64 64"),
1584            "stderr was: {}",
1585            result.stderr
1586        );
1587        // SAFETY: `geteuid` takes no arguments, touches no memory of the caller and cannot fail.
1588        if unsafe { libc::geteuid() } != 0 {
1589            assert_eq!(lines.next(), Some("raise refused"));
1590        }
1591    }
1592
1593    /// The same ceiling through a kernel tier's `pre_exec` setup while this process holds more
1594    /// descriptors than the limit: the child inherits them until `execve`, so every descriptor the
1595    /// setup creates is numbered above 16, and the rlimit must not be in force until that setup
1596    /// is done.
1597    #[cfg(target_os = "linux")]
1598    #[test]
1599    fn a_kernel_tier_command_starts_under_a_limit_below_the_held_descriptors() {
1600        const TEST: &str = "a_kernel_tier_command_starts_under_a_limit_below_the_held_descriptors";
1601        if crate::network_namespace::skip_without_egress_namespace(TEST) {
1602            return;
1603        }
1604        let temp = tempdir().unwrap();
1605        let policy = CapabilityPolicy {
1606            shell_allow: vec!["bash".to_string()],
1607            resources: crate::resources::HostResourceLimits {
1608                max_open_files: 16,
1609                ..crate::resources::HostResourceLimits::default()
1610            },
1611            ..CapabilityPolicy::default()
1612        };
1613        let enforcement = ShellEnforcement::resolve_declared(
1614            &policy,
1615            murmur_artifact::ContainmentClass::Scoped,
1616            crate::sandbox::HostProbe::probe(),
1617        )
1618        .unwrap()
1619        .with_runtime_dir(crate::sandbox::test_runtime_dir(temp.path()), None);
1620        if enforcement.tier != crate::sandbox::EnforcementTier::KernelFull {
1621            crate::runtime_err!(
1622                "[SKIP-HOST] {TEST}: this host resolves {:?}, the test needs KernelFull",
1623                enforcement.tier
1624            );
1625            return;
1626        }
1627        let held: Vec<fs::File> = (0..128)
1628            .map(|_| fs::File::open("/dev/null").unwrap())
1629            .collect();
1630
1631        let result = execute_shell(
1632            "bash",
1633            &["-c", "ulimit -Hn"],
1634            &[],
1635            &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
1636            &policy,
1637            &enforcement,
1638        )
1639        .expect("bash must start under the limit");
1640        drop(held);
1641
1642        assert_eq!(result.stdout.trim(), "16", "stderr was: {}", result.stderr);
1643        assert_eq!(result.exit_code, 0, "stderr was: {}", result.stderr);
1644    }
1645
1646    /// What MURMUR.md's `## Running Shell Commands` states on a kernel tier: a program outside
1647    /// `shell.allow` has no Landlock `Execute` rule, so a shell that tries to start it gets
1648    /// `EACCES` and reports exit status 126. Listing the program is what lets it start.
1649    #[cfg(target_os = "linux")]
1650    #[test]
1651    fn a_program_outside_the_allowlist_is_refused_with_exit_126() {
1652        const TEST: &str = "a_program_outside_the_allowlist_is_refused_with_exit_126";
1653        if crate::network_namespace::skip_without_egress_namespace(TEST) {
1654            return;
1655        }
1656        let run = |allow: &[&str]| {
1657            let temp = tempdir().unwrap();
1658            let policy = CapabilityPolicy {
1659                shell_allow: allow.iter().map(|b| b.to_string()).collect(),
1660                ..CapabilityPolicy::default()
1661            };
1662            let enforcement = ShellEnforcement::resolve_declared(
1663                &policy,
1664                murmur_artifact::ContainmentClass::Scoped,
1665                crate::sandbox::HostProbe::probe(),
1666            )
1667            .unwrap()
1668            .with_runtime_dir(crate::sandbox::test_runtime_dir(temp.path()), None);
1669            if enforcement.tier != crate::sandbox::EnforcementTier::KernelFull {
1670                crate::runtime_err!(
1671                    "[SKIP-HOST] {TEST}: this host resolves {:?}, the test needs KernelFull",
1672                    enforcement.tier
1673                );
1674                return None;
1675            }
1676            let result = execute_shell(
1677                "bash",
1678                &["-c", "cat /dev/null"],
1679                &[],
1680                &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
1681                &policy,
1682                &enforcement,
1683            )
1684            .expect("bash must start");
1685            Some(result)
1686        };
1687
1688        let Some(refused) = run(&["bash"]) else {
1689            return;
1690        };
1691        assert_eq!(refused.exit_code, 126, "stderr was: {}", refused.stderr);
1692        assert!(
1693            refused.stderr.contains("Permission denied"),
1694            "stderr was: {}",
1695            refused.stderr
1696        );
1697
1698        let allowed = run(&["bash", "cat"]).expect("the tier did not change between runs");
1699        assert_eq!(allowed.exit_code, 0, "stderr was: {}", allowed.stderr);
1700        crate::runtime_err!("{TEST}: ran on KernelFull; [bash] exited 126, [bash, cat] exited 0");
1701    }
1702
1703    /// The CPU ceiling's two slots reach a program a kernel tier starts: soft `cpu_seconds`, hard
1704    /// one second above it, set by `apply_per_process_rlimits` last before `execve`.
1705    #[cfg(target_os = "linux")]
1706    #[test]
1707    #[allow(clippy::print_stdout)]
1708    fn a_kernel_tier_command_gets_the_cpu_slots() {
1709        const TEST: &str = "a_kernel_tier_command_gets_the_cpu_slots";
1710        if crate::network_namespace::skip_without_egress_namespace(TEST) {
1711            return;
1712        }
1713        let temp = tempdir().unwrap();
1714        let policy = CapabilityPolicy {
1715            shell_allow: vec!["bash".to_string()],
1716            resources: crate::resources::HostResourceLimits {
1717                cpu_seconds: 5,
1718                max_open_files: 64,
1719                ..crate::resources::HostResourceLimits::default()
1720            },
1721            ..CapabilityPolicy::default()
1722        };
1723        let enforcement = ShellEnforcement::resolve_declared(
1724            &policy,
1725            murmur_artifact::ContainmentClass::Scoped,
1726            crate::sandbox::HostProbe::probe(),
1727        )
1728        .unwrap()
1729        .with_runtime_dir(crate::sandbox::test_runtime_dir(temp.path()), None);
1730        if enforcement.tier != crate::sandbox::EnforcementTier::KernelFull {
1731            crate::runtime_err!(
1732                "[SKIP-HOST] {TEST}: this host resolves {:?}, the test needs KernelFull",
1733                enforcement.tier
1734            );
1735            return;
1736        }
1737
1738        let result = execute_shell(
1739            "bash",
1740            &[
1741                "-c",
1742                r#"echo "$(ulimit -St) $(ulimit -Ht) $(ulimit -Sn) $(ulimit -Hn)""#,
1743            ],
1744            &[],
1745            &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
1746            &policy,
1747            &enforcement,
1748        )
1749        .expect("bash must start under the limits");
1750        println!("{}", result.stdout.trim());
1751
1752        assert_eq!(
1753            result.stdout.trim(),
1754            "5 6 64 64",
1755            "stderr was: {}",
1756            result.stderr
1757        );
1758        assert_eq!(result.exit_code, 0, "stderr was: {}", result.stderr);
1759    }
1760
1761    /// With `RLIMIT_NPROC` left alone, `pids.max` is the only process bound on a scoped capsule,
1762    /// and it has to stop a fork bomb by itself and be named as what did. The children sleep
1763    /// rather than exit so every one of them holds a task in the scope until the loop gives up.
1764    #[cfg(target_os = "linux")]
1765    #[test]
1766    fn a_fork_bomb_in_a_scoped_capsule_is_stopped_by_pids_max() {
1767        const TEST: &str = "a_fork_bomb_in_a_scoped_capsule_is_stopped_by_pids_max";
1768        if !crate::cgroup::cgroup_delegation_available() {
1769            crate::runtime_err!("[SKIP-HOST] {TEST}: this host cannot delegate a cgroup v2 scope");
1770            return;
1771        }
1772
1773        let temp = tempdir().unwrap();
1774        let limits = crate::resources::HostResourceLimits {
1775            cgroup_pids_max: 16,
1776            ..crate::resources::HostResourceLimits::default()
1777        };
1778        let scope = crate::cgroup::prepare_scope(true, &limits, "ses_fork_bomb", temp.path())
1779            .expect("this host delegates a cgroup scope")
1780            .scope;
1781        let policy = CapabilityPolicy {
1782            shell_allow: vec!["python3".to_string()],
1783            resources: limits,
1784            ..CapabilityPolicy::default()
1785        };
1786        let mut enforcement = ShellEnforcement::environment_only().with_test_session_dirs();
1787        enforcement.resource_limits = limits;
1788        let enforcement = enforcement.with_host_bounding(scope, None);
1789
1790        let script = "import os, resource, signal, time\n\
1791            children = []\n\
1792            for _ in range(4096):\n\
1793            \x20   try:\n\
1794            \x20       pid = os.fork()\n\
1795            \x20   except OSError:\n\
1796            \x20       break\n\
1797            \x20   if pid == 0:\n\
1798            \x20       time.sleep(30)\n\
1799            \x20       os._exit(0)\n\
1800            \x20   children.append(pid)\n\
1801            for pid in children:\n\
1802            \x20   os.kill(pid, signal.SIGKILL)\n\
1803            for pid in children:\n\
1804            \x20   os.waitpid(pid, 0)\n\
1805            soft = resource.getrlimit(resource.RLIMIT_NPROC)[0]\n\
1806            print(len(children))\n\
1807            print('unlimited' if soft == resource.RLIM_INFINITY else soft)\n";
1808        let result = execute_shell(
1809            "python3",
1810            &["-c", script],
1811            &[],
1812            &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
1813            &policy,
1814            &enforcement,
1815        )
1816        .expect("python3 must run");
1817
1818        assert_eq!(result.exit_code, 0, "stderr was: {}", result.stderr);
1819        let mut lines = result.stdout.lines();
1820        let reached: u64 = lines.next().unwrap().parse().unwrap();
1821        let soft = lines.next().unwrap();
1822        assert!(
1823            reached < 16,
1824            "pids.max is 16 and the python process holds one task, so at most 15 children can \
1825             be live; reached {reached}"
1826        );
1827        assert_eq!(
1828            result.resource_limit_hit,
1829            Some("cgroup_pids_max".to_string())
1830        );
1831        let (own_soft, _) = crate::resources::test_support::own_rlimit_nproc();
1832        assert_eq!(
1833            soft,
1834            crate::resources::test_support::rendered_rlimit(own_soft),
1835            "a scoped spawn inherits RLIMIT_NPROC unchanged"
1836        );
1837    }
1838
1839    /// `$HOME` is the session's home folder, `home/` in its records directory, and the command
1840    /// runs in the work folder, which gains nothing.
1841    ///
1842    /// Asserted through a real subprocess rather than through `build_shell_env` alone, because
1843    /// `$HOME` naming a directory that does not exist is exactly the failure a wrong root would
1844    /// produce and a pure env check would not see.
1845    #[test]
1846    fn home_folder_is_home_and_the_work_folder_gains_nothing() {
1847        let accessible = tempdir().unwrap();
1848        let runtime = tempdir().unwrap();
1849        let enforcement = ShellEnforcement::environment_only()
1850            .with_runtime_dir(crate::sandbox::test_runtime_dir(runtime.path()), None);
1851        let home = enforcement.home_dir_path().unwrap().to_path_buf();
1852        let policy = CapabilityPolicy {
1853            shell_allow: vec!["bash".to_string()],
1854            ..CapabilityPolicy::default()
1855        };
1856
1857        let result = execute_shell(
1858            "bash",
1859            &[
1860                "-c",
1861                "echo marker > \"$HOME/probe\" && cat \"$HOME/probe\" && echo \"$HOME\" && pwd",
1862            ],
1863            &[],
1864            &crate::workdir_fs::WorkdirRoot::open(accessible.path()).unwrap(),
1865            &policy,
1866            &enforcement,
1867        )
1868        .expect("bash must run");
1869
1870        assert_eq!(result.exit_code, 0, "stderr was: {}", result.stderr);
1871        let mut lines = result.stdout.lines();
1872        assert_eq!(lines.next(), Some("marker"));
1873        assert_eq!(lines.next(), Some(home.to_str().unwrap()));
1874        assert_eq!(
1875            std::fs::canonicalize(lines.next().unwrap()).unwrap(),
1876            std::fs::canonicalize(accessible.path()).unwrap(),
1877            "the subprocess cwd is the work folder"
1878        );
1879        assert_eq!(fs::read_to_string(home.join("probe")).unwrap(), "marker\n");
1880        assert_eq!(
1881            fs::read_dir(accessible.path()).unwrap().count(),
1882            0,
1883            "the work folder gains nothing"
1884        );
1885    }
1886
1887    /// A command printing `seq 1 <last>`, past [`MAX_SHELL_OUTPUT_BYTES`] for any `last` over
1888    /// 4,000, run in `workdir`.
1889    fn print_seq(workdir: &Path, enforcement: &ShellEnforcement, last: u32) -> ShellResult {
1890        let policy = CapabilityPolicy {
1891            shell_allow: vec!["bash".to_string()],
1892            ..CapabilityPolicy::default()
1893        };
1894        let command = format!("seq 1 {last}");
1895        execute_shell(
1896            "bash",
1897            &["-c", &command],
1898            &[],
1899            &crate::workdir_fs::WorkdirRoot::open(workdir).unwrap(),
1900            &policy,
1901            enforcement,
1902        )
1903        .expect("bash must run")
1904    }
1905
1906    /// A truncated command's full output goes to `logs/shell-<ms>.log` in the session's records
1907    /// directory, and the result names it by its absolute path. The work folder gains nothing.
1908    #[test]
1909    fn a_truncated_output_log_lands_in_the_records_directory() {
1910        let workdir = tempdir().unwrap();
1911        let runtime = tempdir().unwrap();
1912        let enforcement = ShellEnforcement::environment_only()
1913            .with_runtime_dir(crate::sandbox::test_runtime_dir(runtime.path()), None);
1914        let records_root = enforcement.records_root().unwrap().clone();
1915
1916        let result = print_seq(workdir.path(), &enforcement, 20_000);
1917
1918        assert!(result.truncated);
1919        assert_eq!(result.stdout.len(), MAX_SHELL_OUTPUT_BYTES);
1920        let path = result
1921            .full_output_path
1922            .expect("a truncated command names its log");
1923        let file_name = Path::new(&path).file_name().unwrap().to_str().unwrap();
1924        let ms = file_name
1925            .strip_prefix("shell-")
1926            .and_then(|rest| rest.strip_suffix(".log"))
1927            .unwrap_or_else(|| panic!("{path} is not logs/shell-<ms>.log"));
1928        assert!(ms.chars().all(|c| c.is_ascii_digit()), "{path}");
1929        assert_eq!(
1930            Path::new(&path),
1931            records_root.host_path("logs").join(file_name),
1932        );
1933        assert!(Path::new(&path).is_absolute(), "{path}");
1934
1935        let log = fs::read_to_string(&path).unwrap();
1936        let expected: String = (1..=20_000).map(|n| format!("{n}\n")).collect();
1937        let stdout = log
1938            .split_once("\nStdout:\n")
1939            .and_then(|(_, rest)| rest.split_once("\n\nStderr:\n"))
1940            .map(|(stdout, _)| stdout)
1941            .expect("the log keeps its Stdout: and Stderr: sections");
1942        assert_eq!(stdout, expected, "the log holds the full stdout");
1943        assert!(log.starts_with("Command: bash -c seq 1 20000\nExit code: 0\n"));
1944
1945        assert!(
1946            !workdir.path().join("logs").exists(),
1947            "nothing is written into the accessible workdir"
1948        );
1949        assert_eq!(fs::read_dir(workdir.path()).unwrap().count(), 0);
1950    }
1951
1952    /// Roots built without a records directory, holding only a home folder, give a truncated
1953    /// command no log to write: it writes none in the work folder and names no path.
1954    #[test]
1955    fn with_no_records_directory_a_truncated_command_writes_no_log() {
1956        let runtime = tempdir().unwrap();
1957        let open = |sub: &str| {
1958            let path = runtime.path().join(sub);
1959            fs::create_dir_all(&path).unwrap();
1960            crate::workdir_fs::WorkdirRoot::open(&path).unwrap()
1961        };
1962        let roots_without_records = ShellEnforcement::environment_only().with_runtime_dir(
1963            crate::runtime_dir::RuntimeDirRoots::new(
1964                open(crate::runtime_dir::RUNTIME_SUBDIR),
1965                open(crate::runtime_dir::SEALED_ETC_SUBDIR),
1966                open(crate::runtime_dir::HARNESS_SUBDIR),
1967            )
1968            .with_home(open(crate::records_dir::HOME_SUBDIR)),
1969            None,
1970        );
1971
1972        let enforcement = &roots_without_records;
1973        let workdir = tempdir().unwrap();
1974        let result = print_seq(workdir.path(), enforcement, 20_000);
1975        assert!(result.truncated);
1976        assert_eq!(result.stdout.len(), MAX_SHELL_OUTPUT_BYTES);
1977        assert_eq!(result.full_output_path, None);
1978        assert_eq!(
1979            fs::read_dir(workdir.path()).unwrap().count(),
1980            0,
1981            "nothing is written into the workdir"
1982        );
1983        assert!(!runtime.path().join("logs").exists());
1984        assert!(!runtime
1985            .path()
1986            .join(crate::runtime_dir::RUNTIME_SUBDIR)
1987            .join("logs")
1988            .exists());
1989    }
1990
1991    /// A subprocess that exits normally must never be reported as limit-killed — the negative
1992    /// half of attribution, and the one that keeps `resource_limit_hit` meaningful in the trace.
1993    #[test]
1994    fn execute_shell_reports_no_resource_limit_for_an_ordinary_exit() {
1995        let temp = tempdir().unwrap();
1996        let policy = CapabilityPolicy {
1997            shell_allow: vec!["bash".to_string()],
1998            ..CapabilityPolicy::default()
1999        };
2000
2001        let result = execute_shell(
2002            "bash",
2003            &["-c", "exit 3"],
2004            &[],
2005            &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
2006            &policy,
2007            &ShellEnforcement::environment_only().with_test_session_dirs(),
2008        )
2009        .expect("bash must run");
2010
2011        assert_eq!(result.exit_code, 3);
2012        assert_eq!(result.resource_limit_hit, None);
2013    }
2014
2015    #[test]
2016    fn execute_shell_blocks_binary_not_in_allowlist() {
2017        let policy = CapabilityPolicy::default();
2018        let error = execute_shell(
2019            "bash",
2020            &["-c", "echo hi"],
2021            &[],
2022            &crate::workdir_fs::WorkdirRoot::open(Path::new(".")).unwrap(),
2023            &policy,
2024            &ShellEnforcement::environment_only().with_test_session_dirs(),
2025        )
2026        .unwrap_err();
2027
2028        assert!(error.to_string().contains("capabilities.shell.allow"));
2029    }
2030
2031    #[test]
2032    fn execute_shell_returns_nonzero_exit_code_without_error() {
2033        let temp = tempdir().unwrap();
2034        let policy = CapabilityPolicy {
2035            shell_allow: vec!["bash".to_string()],
2036            ..CapabilityPolicy::default()
2037        };
2038
2039        let result = execute_shell(
2040            "bash",
2041            &["-c", "exit 42"],
2042            &[],
2043            &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
2044            &policy,
2045            &ShellEnforcement::environment_only().with_test_session_dirs(),
2046        )
2047        .unwrap();
2048        assert_eq!(result.exit_code, 42);
2049    }
2050
2051    /// A real subprocess run through the real `execute_shell`: the returned `binary` is the
2052    /// canonical absolute path of the interpreter that ran, not the bare invoked name — the
2053    /// value that reaches `shell-event.binary` and `trace.jsonl`'s `binary` key.
2054    #[test]
2055    fn execute_shell_reports_the_resolved_path_of_the_invoked_binary() {
2056        let temp = tempdir().unwrap();
2057        let policy = CapabilityPolicy {
2058            shell_allow: vec!["bash".to_string()],
2059            ..CapabilityPolicy::default()
2060        };
2061
2062        let result = execute_shell(
2063            "bash",
2064            &["-c", "exit 0"],
2065            &[],
2066            &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
2067            &policy,
2068            &ShellEnforcement::environment_only().with_test_session_dirs(),
2069        )
2070        .unwrap();
2071
2072        assert!(
2073            Path::new(&result.binary).is_absolute(),
2074            "a binary found on PATH must be reported as an absolute path, got {:?}",
2075            result.binary
2076        );
2077        assert!(
2078            result.binary.ends_with("bash"),
2079            "the reported path must still name bash, got {:?}",
2080            result.binary
2081        );
2082    }
2083
2084    /// The fallback, exercised end-to-end rather than only against the resolver: a workdir
2085    /// -relative program spawns fine (the child resolves it after `chdir`) but has no host
2086    /// `PATH` entry to canonicalize against, so `binary` degrades to the invoked name
2087    /// unchanged. Nothing else about the call changes — it still runs and still reports its
2088    /// exit code.
2089    #[test]
2090    fn execute_shell_falls_back_to_the_bare_name_when_nothing_resolves() {
2091        let temp = tempdir().unwrap();
2092        let tool = temp.path().join("local-tool");
2093        fs::write(&tool, "#!/bin/sh\nexit 7\n").unwrap();
2094        let mut perms = fs::metadata(&tool).unwrap().permissions();
2095        std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o755);
2096        fs::set_permissions(&tool, perms).unwrap();
2097
2098        let policy = CapabilityPolicy {
2099            shell_allow: vec!["./local-tool".to_string()],
2100            ..CapabilityPolicy::default()
2101        };
2102
2103        let result = execute_shell(
2104            "./local-tool",
2105            &[],
2106            &[],
2107            &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
2108            &policy,
2109            &ShellEnforcement::environment_only().with_test_session_dirs(),
2110        )
2111        .unwrap();
2112
2113        assert_eq!(result.exit_code, 7, "the subprocess must still have run");
2114        assert_eq!(
2115            result.binary, "./local-tool",
2116            "an unresolvable name degrades to the bare invoked name, never an error"
2117        );
2118    }
2119
2120    #[test]
2121    fn build_shell_env_sets_home_to_the_home_folder() {
2122        let temp = tempdir().unwrap();
2123        let policy = CapabilityPolicy::default();
2124
2125        let env = build_shell_env(&policy, &[], Some(temp.path()), None, None, None).unwrap();
2126
2127        let expected_home = temp.path().to_string_lossy().into_owned();
2128        assert_eq!(env.get("HOME"), Some(&expected_home));
2129        assert_eq!(env.get("USERPROFILE"), Some(&expected_home));
2130    }
2131
2132    #[test]
2133    fn no_home_folder_build_shell_env_refuses() {
2134        let error =
2135            build_shell_env(&CapabilityPolicy::default(), &[], None, None, None, None).unwrap_err();
2136        assert_eq!(error, NO_HOME_FOLDER_REFUSAL);
2137        assert!(error.contains("home folder"), "{error}");
2138    }
2139
2140    /// A command whose enforcement holds no home folder — no runtime directory, or roots without
2141    /// one — is refused before anything is spawned, and is handed no home anywhere else: nothing
2142    /// is created in the work folder or beside it.
2143    #[test]
2144    fn no_home_folder_execute_shell_refuses_and_creates_nothing() {
2145        let parent = tempdir().unwrap();
2146        let workdir = parent.path().join("work");
2147        fs::create_dir(&workdir).unwrap();
2148        let runtime = tempdir().unwrap();
2149        let roots = crate::sandbox::test_runtime_dir(runtime.path());
2150        let without_home = crate::runtime_dir::RuntimeDirRoots {
2151            home: None,
2152            ..roots
2153        };
2154        let policy = CapabilityPolicy {
2155            shell_allow: vec!["bash".to_string()],
2156            ..CapabilityPolicy::default()
2157        };
2158        for enforcement in [
2159            ShellEnforcement::environment_only(),
2160            ShellEnforcement::environment_only().with_runtime_dir(without_home.clone(), None),
2161        ] {
2162            let error = execute_shell(
2163                "bash",
2164                &["-c", "touch ran"],
2165                &[],
2166                &crate::workdir_fs::WorkdirRoot::open(&workdir).unwrap(),
2167                &policy,
2168                &enforcement,
2169            )
2170            .unwrap_err();
2171            assert!(error.to_string().contains("no home folder"), "{error}");
2172        }
2173        assert_eq!(fs::read_dir(&workdir).unwrap().count(), 0);
2174        assert_eq!(fs::read_dir(parent.path()).unwrap().count(), 1);
2175    }
2176
2177    #[test]
2178    fn execute_shell_reports_the_home_folder_not_real_host_home() {
2179        let temp = tempdir().unwrap();
2180        let runtime = tempdir().unwrap();
2181        let enforcement = ShellEnforcement::environment_only()
2182            .with_runtime_dir(crate::sandbox::test_runtime_dir(runtime.path()), None);
2183        let policy = CapabilityPolicy {
2184            shell_allow: vec!["bash".to_string()],
2185            ..CapabilityPolicy::default()
2186        };
2187
2188        let result = execute_shell(
2189            "bash",
2190            &["-c", "echo -n $HOME"],
2191            &[],
2192            &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
2193            &policy,
2194            &enforcement,
2195        )
2196        .unwrap();
2197
2198        let expected_home = runtime.path().join("records/home");
2199        assert_eq!(result.stdout, expected_home.to_string_lossy());
2200        if let Ok(real_home) = std::env::var("HOME") {
2201            assert_ne!(result.stdout, real_home);
2202        }
2203    }
2204
2205    #[test]
2206    fn build_shell_env_strips_wildcard_credential_patterns_from_overrides() {
2207        let temp = tempdir().unwrap();
2208        let policy = CapabilityPolicy::default();
2209        let overrides = vec![
2210            ("AWS_ACCESS_KEY_ID".to_string(), "leaked".to_string()),
2211            ("DOCKER_AUTH_CONFIG".to_string(), "leaked".to_string()),
2212            ("STRIPE_API_KEY".to_string(), "leaked".to_string()),
2213            ("GITHUB_TOKEN".to_string(), "leaked".to_string()),
2214            ("SAFE_VAR".to_string(), "kept".to_string()),
2215        ];
2216
2217        let env =
2218            build_shell_env(&policy, &overrides, Some(temp.path()), None, None, None).unwrap();
2219
2220        assert!(!env.contains_key("AWS_ACCESS_KEY_ID"));
2221        assert!(!env.contains_key("DOCKER_AUTH_CONFIG"));
2222        assert!(!env.contains_key("STRIPE_API_KEY"));
2223        assert!(!env.contains_key("GITHUB_TOKEN"));
2224        assert_eq!(env.get("SAFE_VAR"), Some(&"kept".to_string()));
2225    }
2226
2227    #[test]
2228    fn declared_env_is_empty_without_declarations() {
2229        std::env::set_var("MURMUR_TEST_WASI_UNDECLARED", "host-value");
2230        let policy = CapabilityPolicy::default();
2231
2232        let env = build_declared_env(&policy);
2233
2234        assert!(env.is_empty());
2235    }
2236
2237    /// The lifeline variable names a descriptor of the member's own process: neither an
2238    /// `env.allow` nor a `shell.baseline_env` declaration resolves it from the host.
2239    #[test]
2240    fn declared_and_shell_env_never_carry_the_formation_lifeline() {
2241        use crate::lifeline::FORMATION_LIFELINE_ENV;
2242        let _guard = crate::formation::FORMATION_ENV_LOCK
2243            .lock()
2244            .unwrap_or_else(|poisoned| poisoned.into_inner());
2245        let temp = tempdir().unwrap();
2246        std::env::set_var(FORMATION_LIFELINE_ENV, "7");
2247        let policy = CapabilityPolicy {
2248            env_allow: vec![FORMATION_LIFELINE_ENV.to_string()],
2249            shell_baseline_env: vec![FORMATION_LIFELINE_ENV.to_string()],
2250            ..CapabilityPolicy::default()
2251        };
2252        let declared = build_declared_env(&policy);
2253        let shell = build_shell_env(&policy, &[], Some(temp.path()), None, None, None).unwrap();
2254        std::env::remove_var(FORMATION_LIFELINE_ENV);
2255        assert!(
2256            !declared.contains_key(FORMATION_LIFELINE_ENV),
2257            "{declared:?}"
2258        );
2259        assert!(!shell.contains_key(FORMATION_LIFELINE_ENV), "{shell:?}");
2260    }
2261
2262    /// The spawner lifeline names a descriptor of the child's own process, on the same terms.
2263    #[test]
2264    fn declared_and_shell_env_never_carry_the_spawner_lifeline() {
2265        use crate::lifeline::SPAWNER_LIFELINE_ENV;
2266        let _guard = crate::formation::FORMATION_ENV_LOCK
2267            .lock()
2268            .unwrap_or_else(|poisoned| poisoned.into_inner());
2269        let temp = tempdir().unwrap();
2270        std::env::set_var(SPAWNER_LIFELINE_ENV, "7");
2271        let policy = CapabilityPolicy {
2272            env_allow: vec![SPAWNER_LIFELINE_ENV.to_string()],
2273            shell_baseline_env: vec![SPAWNER_LIFELINE_ENV.to_string()],
2274            ..CapabilityPolicy::default()
2275        };
2276        let declared = build_declared_env(&policy);
2277        let shell = build_shell_env(&policy, &[], Some(temp.path()), None, None, None).unwrap();
2278        std::env::remove_var(SPAWNER_LIFELINE_ENV);
2279        assert!(!declared.contains_key(SPAWNER_LIFELINE_ENV), "{declared:?}");
2280        assert!(!shell.contains_key(SPAWNER_LIFELINE_ENV), "{shell:?}");
2281    }
2282
2283    #[test]
2284    fn declared_env_passes_through_declared_host_var() {
2285        std::env::set_var("MURMUR_TEST_WASI_ALLOWED", "host-value");
2286        let policy = CapabilityPolicy {
2287            env_allow: vec![
2288                "MURMUR_TEST_WASI_ALLOWED".to_string(),
2289                // Declared but unset on the host: omitted, not an error.
2290                "MURMUR_TEST_WASI_NEVER_SET".to_string(),
2291            ],
2292            ..CapabilityPolicy::default()
2293        };
2294
2295        let env = build_declared_env(&policy);
2296
2297        assert_eq!(
2298            env.get("MURMUR_TEST_WASI_ALLOWED"),
2299            Some(&"host-value".to_string())
2300        );
2301        assert!(!env.contains_key("MURMUR_TEST_WASI_NEVER_SET"));
2302    }
2303
2304    #[test]
2305    fn declared_env_strips_credential_shaped_declarations() {
2306        std::env::set_var("GITHUB_TOKEN", "leaked-token");
2307        std::env::set_var("STRIPE_API_KEY", "leaked-key");
2308        std::env::set_var("MURMUR_TEST_WASI_CUSTOM_SECRET", "leaked-secret");
2309        let policy = CapabilityPolicy {
2310            env_allow: vec![
2311                "GITHUB_TOKEN".to_string(),
2312                "STRIPE_API_KEY".to_string(),
2313                "MURMUR_TEST_WASI_CUSTOM_SECRET".to_string(),
2314            ],
2315            shell_strip_env: vec!["*_CUSTOM_SECRET".to_string()],
2316            ..CapabilityPolicy::default()
2317        };
2318
2319        let env = build_declared_env(&policy);
2320
2321        // Declaring a credential-shaped name does not bypass the backstop.
2322        assert!(env.is_empty(), "expected all names stripped, got {env:?}");
2323    }
2324
2325    /// A credential-shaped name outside every backstop pattern is delivered like any other
2326    /// declared name: `W-SEC-024` reports that grant, and nothing withholds it.
2327    #[test]
2328    fn declared_env_delivers_a_credential_shaped_name_the_backstop_keeps() {
2329        std::env::set_var("PRIVATE_KEY", "host-private-key");
2330        std::env::set_var("MURMUR_TEST_WASI_PLAIN", "host-plain");
2331        let policy = CapabilityPolicy {
2332            env_allow: vec![
2333                "PRIVATE_KEY".to_string(),
2334                "MURMUR_TEST_WASI_PLAIN".to_string(),
2335            ],
2336            ..CapabilityPolicy::default()
2337        };
2338
2339        let env = build_declared_env(&policy);
2340
2341        assert_eq!(
2342            env.get("PRIVATE_KEY"),
2343            Some(&"host-private-key".to_string())
2344        );
2345        assert_eq!(
2346            env.get("MURMUR_TEST_WASI_PLAIN"),
2347            Some(&"host-plain".to_string())
2348        );
2349    }
2350
2351    #[test]
2352    fn build_shell_env_home_override_cannot_survive() {
2353        let temp = tempdir().unwrap();
2354        let policy = CapabilityPolicy {
2355            shell_baseline_env: vec!["HOME".to_string()],
2356            ..CapabilityPolicy::default()
2357        };
2358        let overrides = vec![("HOME".to_string(), "/tmp/guest-controlled".to_string())];
2359
2360        let env =
2361            build_shell_env(&policy, &overrides, Some(temp.path()), None, None, None).unwrap();
2362
2363        assert_eq!(
2364            env.get("HOME"),
2365            Some(&temp.path().to_string_lossy().into_owned())
2366        );
2367    }
2368
2369    #[test]
2370    fn build_shell_env_keeps_safe_baseline_vars() {
2371        let temp = tempdir().unwrap();
2372        let policy = CapabilityPolicy::default();
2373
2374        std::env::set_var("CARGO_HOME", "/fake/cargo/home");
2375        let env = build_shell_env(&policy, &[], Some(temp.path()), None, None, None).unwrap();
2376        std::env::remove_var("CARGO_HOME");
2377
2378        assert_eq!(env.get("CARGO_HOME"), Some(&"/fake/cargo/home".to_string()));
2379    }
2380
2381    /// A command names the account it runs as with the host's own pair, beside the session's
2382    /// `HOME`, and `strip_env` governs the account names like any other baseline entry.
2383    #[test]
2384    fn build_shell_env_carries_the_hosts_account_names() {
2385        let temp = tempdir().unwrap();
2386        let root = crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap();
2387        let _guard = ACCOUNT_ENV_LOCK
2388            .lock()
2389            .unwrap_or_else(|poisoned| poisoned.into_inner());
2390        std::env::set_var("USER", "murmur-test-user");
2391        std::env::set_var("LOGNAME", "murmur-test-login");
2392
2393        let env = build_shell_env(
2394            &CapabilityPolicy::default(),
2395            &[],
2396            Some(root.path()),
2397            None,
2398            None,
2399            None,
2400        )
2401        .unwrap();
2402        let stripped = build_shell_env(
2403            &CapabilityPolicy {
2404                shell_strip_env: vec!["LOGNAME".to_string()],
2405                ..CapabilityPolicy::default()
2406            },
2407            &[],
2408            Some(root.path()),
2409            None,
2410            None,
2411            None,
2412        )
2413        .unwrap();
2414
2415        assert_eq!(
2416            env.get("USER").map(String::as_str),
2417            Some("murmur-test-user")
2418        );
2419        assert_eq!(
2420            env.get("LOGNAME").map(String::as_str),
2421            Some("murmur-test-login")
2422        );
2423        assert_eq!(
2424            env.get("HOME"),
2425            Some(&temp.path().to_string_lossy().into_owned())
2426        );
2427        assert!(!stripped.contains_key("LOGNAME"), "{stripped:?}");
2428        assert_eq!(
2429            stripped.get("USER").map(String::as_str),
2430            Some("murmur-test-user")
2431        );
2432        let guest = build_declared_env(&CapabilityPolicy::default());
2433        assert!(
2434            !guest.contains_key("USER") && !guest.contains_key("LOGNAME"),
2435            "a WASM guest is handed only what env.allow declares: {guest:?}"
2436        );
2437    }
2438
2439    #[test]
2440    fn host_account_with_both_names_set_reports_none_unset() {
2441        let account = HostAccountEnv::from_pairs(&[("USER", "alice"), ("LOGNAME", "alice-login")]);
2442        assert!(account.unset().is_empty());
2443        let mut env = BTreeMap::new();
2444        account.apply(&mut env);
2445        assert_eq!(env.get("USER").map(String::as_str), Some("alice"));
2446        assert_eq!(env.get("LOGNAME").map(String::as_str), Some("alice-login"));
2447    }
2448
2449    /// One name set never supplies the other.
2450    #[test]
2451    fn host_account_with_one_name_set_never_copies_it_to_the_other() {
2452        let account = HostAccountEnv::from_pairs(&[("USER", "alice")]);
2453        assert_eq!(account.unset(), ["LOGNAME".to_string()]);
2454        let mut env = BTreeMap::new();
2455        account.apply(&mut env);
2456        assert_eq!(env.get("USER").map(String::as_str), Some("alice"));
2457        assert!(!env.contains_key("LOGNAME"), "{env:?}");
2458    }
2459
2460    #[test]
2461    fn host_account_with_neither_name_set_reports_both_sorted() {
2462        let account = HostAccountEnv::from_pairs(&[]);
2463        assert_eq!(account.unset(), ["LOGNAME".to_string(), "USER".to_string()]);
2464        let mut env = BTreeMap::new();
2465        account.apply(&mut env);
2466        assert!(env.is_empty(), "{env:?}");
2467    }
2468
2469    /// Whatever an environment held under an account name is replaced by the host's value, or
2470    /// removed when the host has none.
2471    #[test]
2472    fn host_account_apply_replaces_a_stale_value() {
2473        let account = HostAccountEnv::from_pairs(&[("USER", "alice")]);
2474        let mut env = BTreeMap::from([
2475            ("USER".to_string(), "someone-else".to_string()),
2476            ("LOGNAME".to_string(), "someone-else".to_string()),
2477            ("PATH".to_string(), "/bin".to_string()),
2478        ]);
2479        account.apply(&mut env);
2480        assert_eq!(env.get("USER").map(String::as_str), Some("alice"));
2481        assert!(!env.contains_key("LOGNAME"), "{env:?}");
2482        assert_eq!(env.get("PATH").map(String::as_str), Some("/bin"));
2483    }
2484
2485    #[test]
2486    fn build_shell_env_developer_declared_strip_env_still_composes() {
2487        let temp = tempdir().unwrap();
2488        let policy = CapabilityPolicy {
2489            shell_strip_env: vec!["MYCOMPANY_*".to_string()],
2490            ..CapabilityPolicy::default()
2491        };
2492        let overrides = vec![("MYCOMPANY_SECRET".to_string(), "leaked".to_string())];
2493
2494        let env =
2495            build_shell_env(&policy, &overrides, Some(temp.path()), None, None, None).unwrap();
2496
2497        assert!(!env.contains_key("MYCOMPANY_SECRET"));
2498    }
2499
2500    /// The prediction and the strip are one decision procedure, asserted over names that exercise
2501    /// every arm of the pattern grammar plus a manifest-supplied pattern. A disagreement here is a
2502    /// `W-SEC-024` line that tells an operator the opposite of what the guest env will hold.
2503    #[test]
2504    fn credential_backstop_drops_agrees_with_the_strip_it_predicts() {
2505        let extra = vec!["*_SERVICE_SECRET".to_string()];
2506        let names = [
2507            "ANTHROPIC_API_KEY",
2508            "GITHUB_TOKEN",
2509            "GITHUB_TOKEN_2",
2510            "AWS_ACCESS_KEY_ID",
2511            "MY_AWS_KEY",
2512            "STRIPE_API_KEY",
2513            "KUBECONFIG",
2514            "MY_SERVICE_SECRET",
2515            "DATABASE_PASSWORD",
2516            "SLACK_BOT_TOKEN",
2517            "HOME",
2518            "TZ",
2519        ];
2520
2521        let mut env: BTreeMap<String, String> = names
2522            .iter()
2523            .map(|name| ((*name).to_string(), "value".to_string()))
2524            .collect();
2525        strip_credential_shaped_vars(&mut env, &extra);
2526
2527        for name in names {
2528            assert_eq!(
2529                credential_backstop_drops(name, &extra),
2530                !env.contains_key(name),
2531                "prediction and strip disagree about {name}"
2532            );
2533            assert_eq!(
2534                credential_backstop_match(name, &extra).is_some(),
2535                !env.contains_key(name),
2536                "match and strip disagree about {name}"
2537            );
2538        }
2539    }
2540
2541    #[test]
2542    fn credential_backstop_match_attributes_a_builtin_pattern() {
2543        assert_eq!(
2544            credential_backstop_match("AWS_REGION", &[]),
2545            Some(BackstopMatch {
2546                pattern: "AWS_*".to_string(),
2547                source: BackstopPatternSource::Builtin,
2548            })
2549        );
2550        assert_eq!(
2551            credential_backstop_match("GITHUB_TOKEN", &[]),
2552            Some(BackstopMatch {
2553                pattern: "GITHUB_TOKEN".to_string(),
2554                source: BackstopPatternSource::Builtin,
2555            })
2556        );
2557    }
2558
2559    #[test]
2560    fn credential_backstop_match_attributes_a_strip_env_pattern() {
2561        assert_eq!(
2562            credential_backstop_match("MY_SERVICE_SECRET", &["*_SERVICE_SECRET".to_string()]),
2563            Some(BackstopMatch {
2564                pattern: "*_SERVICE_SECRET".to_string(),
2565                source: BackstopPatternSource::StripEnv,
2566            })
2567        );
2568    }
2569
2570    /// A name both lists cover is attributed to the built-in pattern, the one a manifest edit
2571    /// cannot remove.
2572    #[test]
2573    fn credential_backstop_match_prefers_the_builtin_pattern_when_both_match() {
2574        assert_eq!(
2575            credential_backstop_match("STRIPE_API_KEY", &["STRIPE_*".to_string()]),
2576            Some(BackstopMatch {
2577                pattern: "*_API_KEY".to_string(),
2578                source: BackstopPatternSource::Builtin,
2579            })
2580        );
2581    }
2582
2583    #[test]
2584    fn credential_backstop_match_is_none_for_a_name_the_backstop_keeps() {
2585        assert_eq!(credential_backstop_match("TZ", &[]), None);
2586        assert_eq!(
2587            credential_backstop_match("PRIVATE_KEY", &["*_SERVICE_SECRET".to_string()]),
2588            None
2589        );
2590    }
2591
2592    #[test]
2593    fn env_name_matches_pattern_trailing_wildcard() {
2594        assert!(env_name_matches_pattern("AWS_*", "AWS_ACCESS_KEY_ID"));
2595        assert!(!env_name_matches_pattern("AWS_*", "MY_AWS_KEY"));
2596    }
2597
2598    #[test]
2599    fn env_name_matches_pattern_leading_wildcard() {
2600        assert!(env_name_matches_pattern("*_API_KEY", "STRIPE_API_KEY"));
2601        assert!(!env_name_matches_pattern("*_API_KEY", "API_KEY_ID"));
2602    }
2603
2604    #[test]
2605    fn env_name_matches_pattern_exact_match() {
2606        assert!(env_name_matches_pattern("GITHUB_TOKEN", "GITHUB_TOKEN"));
2607        assert!(!env_name_matches_pattern("GITHUB_TOKEN", "GITHUB_TOKEN_2"));
2608    }
2609
2610    #[test]
2611    fn shell_tool_manifest_yaml_is_valid_yaml_for_interpreter() {
2612        let yaml = shell_tool_manifest_yaml("bash");
2613        serde_norway::from_str::<serde_norway::Value>(&yaml)
2614            .expect("bash manifest must be valid YAML");
2615    }
2616
2617    #[test]
2618    fn shell_tool_manifest_yaml_is_valid_yaml_for_non_interpreter() {
2619        let yaml = shell_tool_manifest_yaml("curl");
2620        serde_norway::from_str::<serde_norway::Value>(&yaml)
2621            .expect("curl manifest must be valid YAML — embedded quotes break YAML parsing");
2622    }
2623
2624    #[test]
2625    fn shell_tool_manifest_yaml_non_interpreter_has_tool_runtime() {
2626        let yaml = shell_tool_manifest_yaml("curl");
2627        let value: serde_norway::Value = serde_norway::from_str(&yaml).unwrap();
2628        assert_eq!(
2629            value.get("runtime").and_then(|v| v.as_str()),
2630            Some("tool"),
2631            "non-interpreter manifest must have runtime: tool so inventory picks it up"
2632        );
2633    }
2634
2635    /// A direct binary's description is the rule alone, a pure function of the name: it sits in
2636    /// the tool array of every request, so it carries no example and no per-launch input.
2637    #[test]
2638    fn a_direct_binary_description_states_the_argument_rule_and_nothing_else() {
2639        for binary in ["curl", "git", "jq", "python3"] {
2640            let yaml = shell_tool_manifest_yaml(binary);
2641            let value: serde_norway::Value = serde_norway::from_str(&yaml)
2642                .unwrap_or_else(|e| panic!("{binary} manifest must be valid YAML: {e}"));
2643            let description = value
2644                .get("description")
2645                .and_then(|v| v.as_str())
2646                .expect("the manifest has a description");
2647            assert_eq!(
2648                description,
2649                format!(
2650                    "Run {binary} in the capsule workdir. The `command` field holds {binary}'s \
2651                     arguments and not the binary name. It is split into arguments at whitespace; \
2652                     single or double quotes group words, and a backslash escapes the next \
2653                     character. No shell runs it, so nothing in it is expanded."
2654                )
2655            );
2656            assert!(!description.contains("example.com"), "{description}");
2657            assert!(!description.contains(" -s "), "{description}");
2658            assert_eq!(yaml, shell_tool_manifest_yaml(binary));
2659        }
2660    }
2661
2662    /// A caller that stops waiting demotes its command at once, through the same path the grace
2663    /// period takes, rather than holding a blocking thread for the rest of the grace window.
2664    #[test]
2665    fn an_abandoned_command_demotes_before_its_grace() {
2666        use std::sync::Arc;
2667
2668        use crate::{
2669            detached::{AbandonSignal, DetachPolicy, DetachedRegistry},
2670            origin::{TaskOrigin, TaskProvenance},
2671        };
2672
2673        let temp = tempdir().unwrap();
2674        let runtime = tempdir().unwrap();
2675        let policy = CapabilityPolicy {
2676            shell_allow: vec!["bash".to_string()],
2677            ..CapabilityPolicy::default()
2678        };
2679        let (registry, _reports) = DetachedRegistry::new();
2680        let abandon = AbandonSignal::new();
2681        let raiser = {
2682            let abandon = abandon.clone();
2683            std::thread::spawn(move || {
2684                std::thread::sleep(Duration::from_millis(200));
2685                abandon.raise();
2686            })
2687        };
2688
2689        let started = Instant::now();
2690        let outcome = run_shell(
2691            "bash",
2692            // Outlives the abandonment by seconds and then ends on its own, so nothing has to
2693            // kill it; without the demotion the call would return only when it exited.
2694            &["-c", "sleep 5"],
2695            &[],
2696            &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
2697            &policy,
2698            &ShellEnforcement::environment_only()
2699                .with_runtime_dir(crate::sandbox::test_runtime_dir(runtime.path()), None),
2700            Some(DetachPolicy {
2701                grace: Duration::from_secs(60),
2702                delivered: true,
2703                registry: Arc::clone(&registry),
2704                command: "sleep 5".to_string(),
2705                context_id: "ctx_abandon_test".to_string(),
2706                provenance: Some(TaskProvenance::derive(TaskOrigin::User, None)),
2707                abandoned: Some(abandon),
2708                canceled: None,
2709            }),
2710        )
2711        .expect("a declared binary runs");
2712        let elapsed = started.elapsed();
2713        raiser.join().unwrap();
2714
2715        let ShellOutcome::Detached(info) = outcome else {
2716            panic!("an abandoned command must demote, got {outcome:?}");
2717        };
2718        assert!(
2719            elapsed < Duration::from_secs(2),
2720            "demotion waited {elapsed:?}, as if the 60 s grace still applied"
2721        );
2722        assert!(
2723            registry
2724                .outstanding()
2725                .iter()
2726                .any(|work| work.work_id == info.work_id),
2727            "the demoted command is registered like one that outran its grace"
2728        );
2729    }
2730
2731    /// A detach policy for a cancel test, with a grace no test outlasts.
2732    fn cancel_test_policy(
2733        registry: &std::sync::Arc<crate::detached::DetachedRegistry>,
2734        command: &str,
2735        canceled: crate::cancel::CancelSignal,
2736    ) -> crate::detached::DetachPolicy {
2737        use crate::origin::{TaskOrigin, TaskProvenance};
2738        crate::detached::DetachPolicy {
2739            grace: Duration::from_secs(60),
2740            delivered: true,
2741            registry: std::sync::Arc::clone(registry),
2742            command: command.to_string(),
2743            context_id: "ctx_cancel_test".to_string(),
2744            provenance: Some(TaskProvenance::derive(TaskOrigin::User, None)),
2745            abandoned: None,
2746            canceled: Some(canceled),
2747        }
2748    }
2749
2750    /// A task cancelled while its foreground command runs stops waiting on it at the next poll:
2751    /// the command is demoted and registered, and the cancel is claimed for the shell.
2752    #[test]
2753    fn a_canceled_task_demotes_its_foreground_command() {
2754        use crate::{
2755            cancel::{CancelSignal, PHASE_SHELL},
2756            detached::{DetachedRegistry, KillRunningOnDrop},
2757        };
2758
2759        let temp = tempdir().unwrap();
2760        let runtime = tempdir().unwrap();
2761        let policy = CapabilityPolicy {
2762            shell_allow: vec!["bash".to_string()],
2763            ..CapabilityPolicy::default()
2764        };
2765        let (registry, _reports) = DetachedRegistry::new();
2766        let _kill = KillRunningOnDrop(std::sync::Arc::clone(&registry));
2767        let cancel = CancelSignal::new();
2768        let raiser = {
2769            let cancel = cancel.clone();
2770            std::thread::spawn(move || {
2771                std::thread::sleep(Duration::from_millis(200));
2772                cancel.cancel();
2773            })
2774        };
2775
2776        let started = Instant::now();
2777        let outcome = run_shell(
2778            "bash",
2779            &["-c", "sleep 30"],
2780            &[],
2781            &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
2782            &policy,
2783            &ShellEnforcement::environment_only()
2784                .with_runtime_dir(crate::sandbox::test_runtime_dir(runtime.path()), None),
2785            Some(cancel_test_policy(&registry, "sleep 30", cancel.clone())),
2786        )
2787        .expect("a declared binary runs");
2788        let elapsed = started.elapsed();
2789        raiser.join().unwrap();
2790
2791        let ShellOutcome::Detached(info) = outcome else {
2792            panic!("a canceled task's command must demote, got {outcome:?}");
2793        };
2794        assert!(
2795            elapsed < Duration::from_secs(2),
2796            "demotion waited {elapsed:?}, as if the 60 s grace still applied"
2797        );
2798        assert!(
2799            registry
2800                .outstanding()
2801                .iter()
2802                .any(|work| work.work_id == info.work_id),
2803            "the demoted command is registered, so the cancel residue names it"
2804        );
2805        assert_eq!(cancel.phase(), PHASE_SHELL);
2806    }
2807
2808    /// The shell's claim is a first claim like any other: a wait that saw the cancel earlier
2809    /// keeps the phase.
2810    #[test]
2811    fn an_earlier_claim_keeps_the_phase_over_the_shell() {
2812        use crate::{
2813            cancel::{CancelSignal, PHASE_INFERENCE},
2814            detached::{DetachedRegistry, KillRunningOnDrop},
2815        };
2816
2817        let temp = tempdir().unwrap();
2818        let runtime = tempdir().unwrap();
2819        let policy = CapabilityPolicy {
2820            shell_allow: vec!["bash".to_string()],
2821            ..CapabilityPolicy::default()
2822        };
2823        let (registry, _reports) = DetachedRegistry::new();
2824        let _kill = KillRunningOnDrop(std::sync::Arc::clone(&registry));
2825        let cancel = CancelSignal::new();
2826        cancel.note_phase(PHASE_INFERENCE);
2827        let raiser = {
2828            let cancel = cancel.clone();
2829            std::thread::spawn(move || {
2830                std::thread::sleep(Duration::from_millis(200));
2831                cancel.cancel();
2832            })
2833        };
2834
2835        let outcome = run_shell(
2836            "bash",
2837            &["-c", "sleep 30"],
2838            &[],
2839            &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
2840            &policy,
2841            &ShellEnforcement::environment_only()
2842                .with_runtime_dir(crate::sandbox::test_runtime_dir(runtime.path()), None),
2843            Some(cancel_test_policy(&registry, "sleep 30", cancel.clone())),
2844        )
2845        .expect("a declared binary runs");
2846        raiser.join().unwrap();
2847
2848        assert!(matches!(outcome, ShellOutcome::Detached(_)), "{outcome:?}");
2849        assert_eq!(cancel.phase(), PHASE_INFERENCE);
2850    }
2851
2852    /// A shell call reached after the task's cancel is refused before anything is spawned.
2853    #[test]
2854    fn a_canceled_task_starts_no_command() {
2855        use crate::{cancel::CancelSignal, detached::DetachedRegistry};
2856
2857        let temp = tempdir().unwrap();
2858        let runtime = tempdir().unwrap();
2859        let policy = CapabilityPolicy {
2860            shell_allow: vec!["bash".to_string()],
2861            ..CapabilityPolicy::default()
2862        };
2863        let (registry, _reports) = DetachedRegistry::new();
2864        let cancel = CancelSignal::new();
2865        cancel.cancel();
2866
2867        let result = run_shell(
2868            "bash",
2869            &["-c", "touch started"],
2870            &[],
2871            &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
2872            &policy,
2873            &ShellEnforcement::environment_only()
2874                .with_runtime_dir(crate::sandbox::test_runtime_dir(runtime.path()), None),
2875            Some(cancel_test_policy(
2876                &registry,
2877                "touch started",
2878                cancel.clone(),
2879            )),
2880        );
2881
2882        match result {
2883            Err(ShellExecError::Failed(message)) => assert_eq!(message, CANCELED_BEFORE_SPAWN),
2884            other => panic!("a canceled task's call must be refused, got {other:?}"),
2885        }
2886        assert!(!temp.path().join("started").exists(), "nothing was spawned");
2887        assert!(registry.outstanding().is_empty(), "nothing was registered");
2888    }
2889
2890    /// [`run_shell_until_stopped`] on `script`, with a background `sleep` writing its pid to
2891    /// `sleeper.pid` so the test can check the whole group was killed.
2892    fn run_until_stopped(
2893        workdir: &std::path::Path,
2894        runtime: &std::path::Path,
2895        script: &str,
2896        stop: ForegroundStop,
2897    ) -> ForegroundRun {
2898        let mut enforcement = ShellEnforcement::environment_only()
2899            .with_runtime_dir(crate::sandbox::test_runtime_dir(runtime), None);
2900        // With no cgroup scope, `RLIMIT_NPROC` is headroom over the uid's thread count at the
2901        // spawn, and every test running beside this one spends it before `bash` forks. These
2902        // tests stop a command rather than bound it, so the headroom is out of their way.
2903        enforcement.resource_limits.max_processes = 1 << 20;
2904        run_shell_until_stopped(
2905            "bash",
2906            &["-c", script],
2907            &[],
2908            &crate::workdir_fs::WorkdirRoot::open(workdir).unwrap(),
2909            &CapabilityPolicy {
2910                shell_allow: vec!["bash".to_string()],
2911                ..CapabilityPolicy::default()
2912            },
2913            &enforcement,
2914            stop,
2915        )
2916        .expect("a declared binary runs")
2917    }
2918
2919    const STOPPABLE_SCRIPT: &str = "sleep 300 & echo $! > sleeper.pid; echo started; sleep 300";
2920
2921    /// A cancel raised while the command runs kills its whole process group within a second and
2922    /// returns what it printed, marked stopped.
2923    #[test]
2924    fn a_foreground_stop_kills_the_group_on_cancel() {
2925        let temp = tempdir().unwrap();
2926        let runtime = tempdir().unwrap();
2927        let _sleeper = KillPidFileOnDrop(temp.path().join("sleeper.pid"));
2928        let cancel = crate::cancel::CancelSignal::new();
2929        let raiser = {
2930            let cancel = cancel.clone();
2931            let pid_file = temp.path().join("sleeper.pid");
2932            std::thread::spawn(move || {
2933                while !pid_file.exists() {
2934                    std::thread::sleep(Duration::from_millis(10));
2935                }
2936                std::thread::sleep(Duration::from_millis(100));
2937                cancel.cancel();
2938                Instant::now()
2939            })
2940        };
2941
2942        let run = run_until_stopped(
2943            temp.path(),
2944            runtime.path(),
2945            STOPPABLE_SCRIPT,
2946            ForegroundStop {
2947                canceled: cancel,
2948                timeout: Duration::from_secs(60),
2949            },
2950        );
2951        let raised = raiser.join().unwrap();
2952
2953        assert!(
2954            raised.elapsed() < Duration::from_secs(1),
2955            "the call returned {:?} after the cancel",
2956            raised.elapsed()
2957        );
2958        assert_eq!(run.stopped, Some(ForegroundStopReason::Canceled));
2959        assert!(run.result.stdout.contains("started"), "{:?}", run.result);
2960        assert_eq!(run.result.resource_limit_hit, None);
2961        let sleeper = read_pid(&temp.path().join("sleeper.pid"));
2962        assert!(
2963            process_dead_within(sleeper, Duration::from_secs(1)),
2964            "the background sleeper {sleeper} outlived the stop"
2965        );
2966    }
2967
2968    /// A command still running at the timeout is killed with its group within a second of it.
2969    #[test]
2970    fn a_foreground_stop_kills_the_group_at_the_timeout() {
2971        let temp = tempdir().unwrap();
2972        let runtime = tempdir().unwrap();
2973        let _sleeper = KillPidFileOnDrop(temp.path().join("sleeper.pid"));
2974
2975        let started = Instant::now();
2976        let run = run_until_stopped(
2977            temp.path(),
2978            runtime.path(),
2979            STOPPABLE_SCRIPT,
2980            ForegroundStop {
2981                canceled: crate::cancel::CancelSignal::new(),
2982                timeout: Duration::from_secs(1),
2983            },
2984        );
2985        let elapsed = started.elapsed();
2986
2987        assert!(
2988            elapsed >= Duration::from_secs(1) && elapsed < Duration::from_secs(2),
2989            "the call returned after {elapsed:?} for a 1 s timeout"
2990        );
2991        assert_eq!(run.stopped, Some(ForegroundStopReason::TimedOut));
2992        assert!(run.result.stdout.contains("started"), "{:?}", run.result);
2993        let sleeper = read_pid(&temp.path().join("sleeper.pid"));
2994        assert!(
2995            process_dead_within(sleeper, Duration::from_secs(1)),
2996            "the background sleeper {sleeper} outlived the timeout"
2997        );
2998    }
2999
3000    /// A command that exits on its own is not marked stopped and keeps its exit code.
3001    #[test]
3002    fn a_foreground_stop_leaves_a_finished_command_alone() {
3003        let temp = tempdir().unwrap();
3004        let runtime = tempdir().unwrap();
3005        let run = run_until_stopped(
3006            temp.path(),
3007            runtime.path(),
3008            "echo out; echo err >&2; exit 3",
3009            ForegroundStop {
3010                canceled: crate::cancel::CancelSignal::new(),
3011                timeout: Duration::from_secs(60),
3012            },
3013        );
3014        assert_eq!(run.stopped, None);
3015        assert_eq!(run.result.exit_code, 3);
3016        assert_eq!(run.result.stdout, "out\n");
3017        assert_eq!(run.result.stderr, "err\n");
3018    }
3019
3020    /// `run` on a thread of its own, failing the test after 30 s instead of hanging it.
3021    fn returns_within_30_s<T: Send + 'static>(run: impl FnOnce() -> T + Send + 'static) -> T {
3022        let (sender, receiver) = std::sync::mpsc::channel();
3023        std::thread::spawn(move || {
3024            let _ = sender.send(run());
3025        });
3026        receiver
3027            .recv_timeout(Duration::from_secs(30))
3028            .unwrap_or_else(|_| panic!("the shell call did not return within 30 s"))
3029    }
3030
3031    /// A command that exits while a process it started in the background still holds its stdout
3032    /// returns well inside its grace period, and the holder is killed.
3033    #[test]
3034    fn a_background_holder_does_not_hold_a_graced_call() {
3035        use std::sync::Arc;
3036
3037        use crate::{
3038            detached::{DetachPolicy, DetachedRegistry},
3039            origin::{TaskOrigin, TaskProvenance},
3040        };
3041
3042        let temp = tempdir().unwrap();
3043        let runtime = tempdir().unwrap();
3044        let _sleeper = KillPidFileOnDrop(temp.path().join("sleeper.pid"));
3045        let workdir = temp.path().to_path_buf();
3046        let runtime_base = runtime.path().to_path_buf();
3047        let script = "sleep 300 & echo $! > sleeper.pid; echo started";
3048
3049        let started = Instant::now();
3050        let outcome = returns_within_30_s(move || {
3051            let (registry, _reports) = DetachedRegistry::new();
3052            run_shell(
3053                "bash",
3054                &["-c", script],
3055                &[],
3056                &crate::workdir_fs::WorkdirRoot::open(&workdir).unwrap(),
3057                &CapabilityPolicy {
3058                    shell_allow: vec!["bash".to_string()],
3059                    ..CapabilityPolicy::default()
3060                },
3061                &ShellEnforcement::environment_only()
3062                    .with_runtime_dir(crate::sandbox::test_runtime_dir(&runtime_base), None),
3063                Some(DetachPolicy {
3064                    grace: Duration::from_secs(5),
3065                    delivered: true,
3066                    registry: Arc::clone(&registry),
3067                    command: script.to_string(),
3068                    context_id: "ctx_holder_test".to_string(),
3069                    provenance: Some(TaskProvenance::derive(TaskOrigin::User, None)),
3070                    abandoned: None,
3071                    canceled: None,
3072                }),
3073            )
3074            .expect("a declared binary runs")
3075        });
3076        let elapsed = started.elapsed();
3077
3078        let ShellOutcome::Finished(result) = outcome else {
3079            panic!("a command that exits at once must finish, got {outcome:?}");
3080        };
3081        assert!(
3082            elapsed < Duration::from_secs(5),
3083            "the call took {elapsed:?}, past its 5 s grace"
3084        );
3085        assert!(result.stdout.contains("started"), "{result:?}");
3086        assert_eq!(result.exit_code, 0, "{result:?}");
3087        let sleeper = read_pid(&temp.path().join("sleeper.pid"));
3088        assert!(
3089            process_dead_within(sleeper, Duration::from_secs(2)),
3090            "the background sleeper {sleeper} is still running"
3091        );
3092    }
3093
3094    /// The same holder on the path with no grace period, which plan shell steps and the
3095    /// script-capsule store state take.
3096    #[test]
3097    fn a_background_holder_does_not_hold_an_ungraced_call() {
3098        let temp = tempdir().unwrap();
3099        let _sleeper = KillPidFileOnDrop(temp.path().join("sleeper.pid"));
3100        let workdir = temp.path().to_path_buf();
3101
3102        let started = Instant::now();
3103        let result = returns_within_30_s(move || {
3104            execute_shell(
3105                "bash",
3106                &["-c", "sleep 300 & echo $! > sleeper.pid; echo started"],
3107                &[],
3108                &crate::workdir_fs::WorkdirRoot::open(&workdir).unwrap(),
3109                &CapabilityPolicy {
3110                    shell_allow: vec!["bash".to_string()],
3111                    ..CapabilityPolicy::default()
3112                },
3113                &ShellEnforcement::environment_only().with_test_session_dirs(),
3114            )
3115            .expect("a declared binary runs")
3116        });
3117        let elapsed = started.elapsed();
3118
3119        assert!(
3120            elapsed < Duration::from_secs(10),
3121            "the call took {elapsed:?}"
3122        );
3123        assert!(result.stdout.contains("started"), "{result:?}");
3124        assert_eq!(result.exit_code, 0, "{result:?}");
3125        let sleeper = read_pid(&temp.path().join("sleeper.pid"));
3126        assert!(
3127            process_dead_within(sleeper, Duration::from_secs(2)),
3128            "the background sleeper {sleeper} is still running"
3129        );
3130        assert_eq!(result.output_holders, OutputHolders::Killed);
3131    }
3132
3133    /// Background work that prints and exits inside the drain window is part of the output, and
3134    /// nothing is killed or reported.
3135    #[test]
3136    fn output_written_just_after_exit_is_kept() {
3137        use std::sync::Arc;
3138
3139        use crate::{
3140            detached::{DetachPolicy, DetachedRegistry},
3141            origin::{TaskOrigin, TaskProvenance},
3142        };
3143
3144        let temp = tempdir().unwrap();
3145        let runtime = tempdir().unwrap();
3146        let (registry, _reports) = DetachedRegistry::new();
3147        let script = "(sleep 0.3; echo late) & echo early";
3148
3149        let outcome = run_shell(
3150            "bash",
3151            &["-c", script],
3152            &[],
3153            &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
3154            &CapabilityPolicy {
3155                shell_allow: vec!["bash".to_string()],
3156                ..CapabilityPolicy::default()
3157            },
3158            &ShellEnforcement::environment_only()
3159                .with_runtime_dir(crate::sandbox::test_runtime_dir(runtime.path()), None),
3160            Some(DetachPolicy {
3161                grace: Duration::from_secs(5),
3162                delivered: true,
3163                registry: Arc::clone(&registry),
3164                command: script.to_string(),
3165                context_id: "ctx_late_output_test".to_string(),
3166                provenance: Some(TaskProvenance::derive(TaskOrigin::User, None)),
3167                abandoned: None,
3168                canceled: None,
3169            }),
3170        )
3171        .expect("a declared binary runs");
3172
3173        let ShellOutcome::Finished(result) = outcome else {
3174            panic!("a command that exits at once must finish, got {outcome:?}");
3175        };
3176        assert!(result.stdout.contains("early"), "{result:?}");
3177        assert!(result.stdout.contains("late"), "{result:?}");
3178        assert_eq!(result.output_holders, OutputHolders::Closed);
3179        assert!(OutputHolders::Closed.note().is_none());
3180    }
3181
3182    /// A holder that left the command's process group cannot be killed by the call. The call
3183    /// returns anyway, with what it read, and says the holder is still running.
3184    #[cfg(target_os = "linux")]
3185    #[test]
3186    fn a_holder_outside_the_process_group_is_reported_not_waited_for() {
3187        use std::sync::Arc;
3188
3189        use crate::{
3190            detached::{DetachPolicy, DetachedRegistry},
3191            origin::{TaskOrigin, TaskProvenance},
3192        };
3193
3194        let temp = tempdir().unwrap();
3195        let runtime = tempdir().unwrap();
3196        let pid_file = temp.path().join("escaped.pid");
3197        let escaped_guard = KillPidFileOnDrop(pid_file.clone());
3198        let workdir = temp.path().to_path_buf();
3199        let runtime_base = runtime.path().to_path_buf();
3200        let script = "setsid sleep 300 & echo $! > escaped.pid; echo started";
3201
3202        let started = Instant::now();
3203        let outcome = returns_within_30_s(move || {
3204            let (registry, _reports) = DetachedRegistry::new();
3205            run_shell(
3206                "bash",
3207                &["-c", script],
3208                &[],
3209                &crate::workdir_fs::WorkdirRoot::open(&workdir).unwrap(),
3210                &CapabilityPolicy {
3211                    shell_allow: vec!["bash".to_string()],
3212                    ..CapabilityPolicy::default()
3213                },
3214                &ShellEnforcement::environment_only()
3215                    .with_runtime_dir(crate::sandbox::test_runtime_dir(&runtime_base), None),
3216                Some(DetachPolicy {
3217                    grace: Duration::from_secs(5),
3218                    delivered: true,
3219                    registry: Arc::clone(&registry),
3220                    command: script.to_string(),
3221                    context_id: "ctx_escaped_test".to_string(),
3222                    provenance: Some(TaskProvenance::derive(TaskOrigin::User, None)),
3223                    abandoned: None,
3224                    canceled: None,
3225                }),
3226            )
3227            .expect("a declared binary runs")
3228        });
3229        let elapsed = started.elapsed();
3230
3231        let ShellOutcome::Finished(result) = outcome else {
3232            panic!("a command that exits at once must finish, got {outcome:?}");
3233        };
3234        let bound = Duration::from_secs(5) + LEFTOVER_KILL_WAIT + Duration::from_secs(2);
3235        assert!(elapsed < bound, "the call took {elapsed:?}, past {bound:?}");
3236        assert_eq!(result.stdout.trim(), "started", "{result:?}");
3237        assert_eq!(result.output_holders, OutputHolders::Escaped);
3238        let escaped = read_pid(&pid_file);
3239        assert!(
3240            !process_dead_within(escaped, Duration::ZERO),
3241            "a holder outside the process group is not the call's to kill"
3242        );
3243        drop(escaped_guard);
3244        assert!(process_dead_within(escaped, Duration::from_secs(2)));
3245    }
3246
3247    #[test]
3248    fn a_shared_window_attributes_no_cgroup_delta() {
3249        assert_eq!(
3250            attribute_resource_limit(None, Some("cgroup_memory_bytes"), true).as_deref(),
3251            Some("cgroup_memory_bytes"),
3252            "a call alone in the scope owns its delta"
3253        );
3254        assert_eq!(
3255            attribute_resource_limit(None, Some("cgroup_memory_bytes"), false),
3256            None,
3257            "a delta from a shared window could be a sibling's"
3258        );
3259        assert_eq!(
3260            attribute_resource_limit(Some("cpu_seconds"), Some("cgroup_memory_bytes"), false)
3261                .as_deref(),
3262            Some("cpu_seconds"),
3263            "the signal is per-process evidence and needs no exclusive window"
3264        );
3265        assert_eq!(attribute_resource_limit(None, None, true), None);
3266    }
3267
3268    /// `MURMUR_RUNTIME_DIR` is runtime-owned like `HOME`: an override, a declared baseline name
3269    /// and a host value all lose to the session's own runtime directory, and with none there is
3270    /// no such variable.
3271    #[test]
3272    fn build_shell_env_sets_the_runtime_dir_last_whatever_else_says() {
3273        let workdir = tempfile::tempdir().unwrap();
3274        let root = WorkdirRoot::open(workdir.path()).unwrap();
3275        std::env::set_var(crate::runtime_dir::RUNTIME_DIR_ENV, "/host/value");
3276        let policy = CapabilityPolicy {
3277            shell_baseline_env: vec![crate::runtime_dir::RUNTIME_DIR_ENV.to_string()],
3278            env_allow: vec![crate::runtime_dir::RUNTIME_DIR_ENV.to_string()],
3279            ..CapabilityPolicy::default()
3280        };
3281        let overrides = [(
3282            crate::runtime_dir::RUNTIME_DIR_ENV.to_string(),
3283            "/override".to_string(),
3284        )];
3285
3286        let env = build_shell_env(
3287            &policy,
3288            &overrides,
3289            Some(root.path()),
3290            Some(std::path::Path::new("/runtime/dir")),
3291            None,
3292            None,
3293        )
3294        .unwrap();
3295        assert_eq!(
3296            env.get(crate::runtime_dir::RUNTIME_DIR_ENV)
3297                .map(String::as_str),
3298            Some("/runtime/dir")
3299        );
3300
3301        let without =
3302            build_shell_env(&policy, &overrides, Some(root.path()), None, None, None).unwrap();
3303        assert!(!without.contains_key(crate::runtime_dir::RUNTIME_DIR_ENV));
3304        assert!(!build_declared_env(&policy).contains_key(crate::runtime_dir::RUNTIME_DIR_ENV));
3305    }
3306
3307    /// `MURMUR_RECORDS_DIR` is runtime-owned on `MURMUR_RUNTIME_DIR`'s terms: an override, a
3308    /// baseline entry, `env.allow` and a host value all lose to the session's own records
3309    /// directory, and with none there is no such variable.
3310    #[test]
3311    fn build_shell_env_sets_the_records_dir_last_whatever_else_says() {
3312        let workdir = tempfile::tempdir().unwrap();
3313        let root = WorkdirRoot::open(workdir.path()).unwrap();
3314        std::env::set_var(crate::records_dir::RECORDS_DIR_ENV, "/host/records");
3315        let policy = CapabilityPolicy {
3316            shell_baseline_env: vec![crate::records_dir::RECORDS_DIR_ENV.to_string()],
3317            env_allow: vec![crate::records_dir::RECORDS_DIR_ENV.to_string()],
3318            ..CapabilityPolicy::default()
3319        };
3320        let overrides = [(
3321            crate::records_dir::RECORDS_DIR_ENV.to_string(),
3322            "/override".to_string(),
3323        )];
3324
3325        let env = build_shell_env(
3326            &policy,
3327            &overrides,
3328            Some(root.path()),
3329            None,
3330            Some(std::path::Path::new("/records/dir")),
3331            None,
3332        )
3333        .unwrap();
3334        assert_eq!(
3335            env.get(crate::records_dir::RECORDS_DIR_ENV)
3336                .map(String::as_str),
3337            Some("/records/dir")
3338        );
3339
3340        let without =
3341            build_shell_env(&policy, &overrides, Some(root.path()), None, None, None).unwrap();
3342        assert!(!without.contains_key(crate::records_dir::RECORDS_DIR_ENV));
3343        assert!(!build_declared_env(&policy).contains_key(crate::records_dir::RECORDS_DIR_ENV));
3344    }
3345
3346    /// `TMPDIR`, `TMP` and `TEMP` are runtime-owned: `/tmp` when the session maps it, the
3347    /// folder's absolute path when not, and absent when the enforcement holds no folder — whatever
3348    /// the host, an override or `shell.baseline_env` said.
3349    #[test]
3350    fn build_shell_env_owns_tmpdir_tmp_and_temp() {
3351        use crate::temp_dir::{NotMappedReason, TempMapping, TEMP_DIR_ENV};
3352
3353        let workdir = tempfile::tempdir().unwrap();
3354        let root = WorkdirRoot::open(workdir.path()).unwrap();
3355        let base = tempfile::tempdir().unwrap();
3356        let roots = crate::sandbox::test_runtime_dir(base.path());
3357        let folder = roots.temp.as_ref().unwrap().path().to_path_buf();
3358        // The host value is process-wide, and every test running beside this one creates its
3359        // temporary folders under it: it names a directory that exists and is never removed.
3360        let host_tmp = std::env::temp_dir().join("murmur-host-tmp");
3361        std::fs::create_dir_all(&host_tmp).unwrap();
3362        let saved: Vec<_> = TEMP_DIR_ENV
3363            .iter()
3364            .map(|name| (*name, std::env::var_os(name)))
3365            .collect();
3366        for name in TEMP_DIR_ENV {
3367            std::env::set_var(name, &host_tmp);
3368        }
3369        let policy = CapabilityPolicy {
3370            shell_baseline_env: TEMP_DIR_ENV.iter().map(|name| name.to_string()).collect(),
3371            ..CapabilityPolicy::default()
3372        };
3373        let overrides: Vec<(String, String)> = TEMP_DIR_ENV
3374            .iter()
3375            .map(|name| (name.to_string(), "/override".to_string()))
3376            .collect();
3377
3378        let held = |tier, mapping| {
3379            crate::sandbox::ShellEnforcement {
3380                tier,
3381                ..crate::sandbox::ShellEnforcement::environment_only()
3382            }
3383            .with_runtime_dir(roots.clone(), None)
3384            .with_temp_mapping(mapping)
3385        };
3386        let folder_text = folder.to_string_lossy().into_owned();
3387        let cases = [
3388            (
3389                held(
3390                    crate::sandbox::EnforcementTier::KernelFull,
3391                    TempMapping::MountNamespace,
3392                ),
3393                Some("/tmp".to_string()),
3394            ),
3395            (
3396                held(
3397                    crate::sandbox::EnforcementTier::KernelSealed,
3398                    TempMapping::ComposedRoot,
3399                ),
3400                Some("/tmp".to_string()),
3401            ),
3402            (
3403                held(
3404                    crate::sandbox::EnforcementTier::KernelFull,
3405                    TempMapping::NotMapped(NotMappedReason::NoMountNamespace),
3406                ),
3407                Some(folder_text.clone()),
3408            ),
3409            (
3410                held(
3411                    crate::sandbox::EnforcementTier::KernelSeccompOnly,
3412                    TempMapping::NotMapped(NotMappedReason::NoMountNamespaceNoLandlock),
3413                ),
3414                Some(folder_text.clone()),
3415            ),
3416            (
3417                held(
3418                    crate::sandbox::EnforcementTier::KernelFull,
3419                    TempMapping::NotMapped(NotMappedReason::WorkdirIsTmp),
3420                ),
3421                Some(folder_text.clone()),
3422            ),
3423            (
3424                held(
3425                    crate::sandbox::EnforcementTier::EnvironmentOnly,
3426                    TempMapping::NotMapped(NotMappedReason::NoKernelContainment),
3427                ),
3428                Some(folder_text.clone()),
3429            ),
3430            (crate::sandbox::ShellEnforcement::environment_only(), None),
3431        ];
3432        for (enforcement, expected) in cases {
3433            assert_eq!(
3434                enforcement.tmpdir_value(),
3435                expected,
3436                "{:?}",
3437                enforcement.tier
3438            );
3439            let env = build_shell_env(
3440                &policy,
3441                &overrides,
3442                Some(root.path()),
3443                None,
3444                None,
3445                enforcement.tmpdir_value().as_deref(),
3446            )
3447            .unwrap();
3448            for name in TEMP_DIR_ENV {
3449                assert_eq!(
3450                    env.get(name),
3451                    expected.as_ref(),
3452                    "{name} on {:?}",
3453                    enforcement.tier
3454                );
3455            }
3456        }
3457        for (name, value) in saved {
3458            match value {
3459                Some(value) => std::env::set_var(name, value),
3460                None => std::env::remove_var(name),
3461            }
3462        }
3463    }
3464
3465    /// The baseline passes no temporary-folder name through from the host.
3466    #[test]
3467    fn the_env_baseline_names_no_temp_variable() {
3468        for name in crate::temp_dir::TEMP_DIR_ENV {
3469            assert!(!DEFAULT_ENV_BASELINE.contains(&name), "{name}");
3470        }
3471    }
3472}
3473
3474/// Helpers for tests whose command leaves a process behind and writes its pid to a file.
3475#[cfg(test)]
3476pub(crate) mod test_support {
3477    use std::path::{Path, PathBuf};
3478    use std::time::{Duration, Instant};
3479
3480    /// The pid the test's command wrote to `pid_file`.
3481    pub(crate) fn read_pid(pid_file: &Path) -> u32 {
3482        std::fs::read_to_string(pid_file)
3483            .expect("the command wrote its pid file")
3484            .trim()
3485            .parse()
3486            .expect("the pid file holds a pid")
3487    }
3488
3489    /// Whether `pid` has exited within `timeout`: it is gone, or a zombie whichever process it
3490    /// was orphaned to has not reaped yet.
3491    pub(crate) fn process_dead_within(pid: u32, timeout: Duration) -> bool {
3492        let deadline = Instant::now() + timeout;
3493        loop {
3494            if !crate::running::pid_is_alive(pid) {
3495                return true;
3496            }
3497            if Instant::now() >= deadline {
3498                return false;
3499            }
3500            std::thread::sleep(Duration::from_millis(20));
3501        }
3502    }
3503
3504    /// Kills the process whose pid the test's command wrote to the file, if it is still running
3505    /// when the guard drops, so a failing test leaves nothing behind. It reads the file at drop,
3506    /// so it also covers a test that panicked before reading it.
3507    pub(crate) struct KillPidFileOnDrop(pub(crate) PathBuf);
3508
3509    impl Drop for KillPidFileOnDrop {
3510        fn drop(&mut self) {
3511            let Some(pid) = std::fs::read_to_string(&self.0)
3512                .ok()
3513                .and_then(|text| text.trim().parse::<libc::pid_t>().ok())
3514                .filter(|pid| *pid > 0)
3515            else {
3516                return;
3517            };
3518            if !process_dead_within(pid.unsigned_abs(), Duration::ZERO) {
3519                // SAFETY: `kill` takes two integers and dereferences nothing. `pid` is positive,
3520                // so it names one process, never a group or every process.
3521                #[allow(unsafe_code)]
3522                unsafe {
3523                    libc::kill(pid, libc::SIGKILL);
3524                }
3525            }
3526        }
3527    }
3528}