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