Skip to main content

capsule_runtime/
shell.rs

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