Skip to main content

capsule_runtime/
shell.rs

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