Skip to main content

capsule_runtime/
shell.rs

1use std::{
2    collections::BTreeMap,
3    process::{Command, Stdio},
4    time::{Duration, Instant},
5};
6
7use crate::{
8    artifact_config::ARTIFACT_CONFIG_ENV,
9    detached::{DetachPolicy, DetachedDispatchInfo},
10    types::CapabilityPolicy,
11    workdir_fs::{WorkdirRoot, DEFAULT_DIR_MODE, DEFAULT_FILE_MODE},
12};
13
14const MAX_SHELL_OUTPUT_BYTES: usize = 16 * 1024;
15
16const SHELL_INTERPRETERS: &[&str] = &["bash", "sh", "zsh", "fish", "dash", "ksh"];
17const DEFAULT_ENV_BASELINE: &[&str] = &[
18    "PATH",
19    "HOME",
20    "USER",
21    "LOGNAME",
22    "LANG",
23    "LC_ALL",
24    "TMPDIR",
25    "TEMP",
26    "TMP",
27    "CARGO_HOME",
28    "RUSTUP_HOME",
29    "TERM",
30];
31
32/// The two variables that name the account a process runs as, sorted. Neither is a credential:
33/// each is a login name. A harness may look up its stored login by it (the macOS login keychain
34/// is keyed by user name), so a harness started without them can fail to find a login it has.
35pub(crate) const ACCOUNT_ENV: [&str; 2] = ["LOGNAME", "USER"];
36
37/// Serializes the unit tests that set or remove an [`ACCOUNT_ENV`] name in this test binary's
38/// process environment, which every test thread shares.
39#[cfg(test)]
40pub(crate) static ACCOUNT_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
41
42/// The host's values of [`ACCOUNT_ENV`], read once.
43///
44/// Each name carries the host's value or is absent: the runtime never makes one up, never copies
45/// one from the other, and [`HostAccountEnv::apply`] lets nothing an allowlist or a launch plan
46/// put in an environment stand in for the host's. A value that is not valid UTF-8 counts as
47/// unset, since no environment the runtime builds can carry it.
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub(crate) struct HostAccountEnv {
50    set: Vec<(String, String)>,
51    unset: Vec<String>,
52}
53
54impl HostAccountEnv {
55    /// The account names this process holds.
56    pub(crate) fn from_host() -> Self {
57        Self::from_lookup(|name| std::env::var(name).ok())
58    }
59
60    /// The account names as `lookup` answers them, for a caller that is not reading this process.
61    pub(crate) fn from_lookup(lookup: impl Fn(&str) -> Option<String>) -> Self {
62        let mut set = Vec::new();
63        let mut unset = Vec::new();
64        for name in ACCOUNT_ENV {
65            match lookup(name) {
66                Some(value) => set.push((name.to_string(), value)),
67                None => unset.push(name.to_string()),
68            }
69        }
70        Self { set, unset }
71    }
72
73    /// An account holding exactly `pairs`, for a test standing in for the host.
74    #[cfg(test)]
75    pub(crate) fn from_pairs(pairs: &[(&str, &str)]) -> Self {
76        Self::from_lookup(|name| {
77            pairs
78                .iter()
79                .find(|(key, _)| *key == name)
80                .map(|(_, value)| value.to_string())
81        })
82    }
83
84    /// The names the host did not set, sorted. Empty when it set both.
85    pub(crate) fn unset(&self) -> &[String] {
86        &self.unset
87    }
88
89    /// The names the host set, with its values, sorted by name.
90    pub(crate) fn set(&self) -> &[(String, String)] {
91        &self.set
92    }
93
94    /// Make `env`'s account names the host's: both are removed, then each the host set is
95    /// inserted with its value. Whatever `env` held under either name is gone afterwards.
96    pub(crate) fn apply(&self, env: &mut BTreeMap<String, String>) {
97        for name in ACCOUNT_ENV {
98            env.remove(name);
99        }
100        env.extend(self.set.iter().cloned());
101    }
102}
103
104/// Directory name for the per-session synthetic home, created under the capsule's
105/// session workdir — `<accessible workdir>/.murmur/<session id>/` under `--workdir`, so it is
106/// under the one prefix a consumer diffing the accessible workdir excludes. Planned /proc and
107/// subprocess key isolation work depend on this exact name/location convention.
108///
109/// Nothing outside the runtime names this path: the capsule reaches it only through `$HOME`.
110/// Declared as a runtime write by [`crate::workdir_writes`].
111///
112/// `pub(crate)` for one reader outside this file: `sandbox::ensure_sealed_identity_files`, which
113/// writes the `pw_dir` field of a sealed capsule's synthetic `/etc/passwd` entry. That field and
114/// `$HOME` must be the same string — a second hand-copied `".capsule-home"` there is exactly how
115/// they would stop being one.
116pub(crate) const SYNTHETIC_HOME_DIR_NAME: &str = ".capsule-home";
117
118/// Credential-shaped env var patterns stripped from every subprocess, regardless of
119/// whether they came from the host process env, `env_overrides`, or
120/// `policy.shell_baseline_env`. Supports exact match, trailing `*` (prefix) and
121/// leading `*` (suffix) via `env_name_matches_pattern`.
122const CREDENTIAL_ENV_PATTERNS: &[&str] = &[
123    "ANTHROPIC_API_KEY",
124    "OPENAI_API_KEY",
125    "GOOGLE_API_KEY",
126    "HUGGING_FACE_HUB_TOKEN",
127    "AWS_*",
128    "GITHUB_TOKEN",
129    "GH_TOKEN",
130    "DOCKER_*",
131    "KUBECONFIG",
132    "NPM_TOKEN",
133    "PYPI_TOKEN",
134    "CARGO_REGISTRY_TOKEN",
135    "*_API_KEY",
136];
137
138#[derive(Debug)]
139pub(crate) struct ShellResult {
140    /// The program that was actually invoked, resolved to its canonical absolute path
141    /// when the host `PATH` names it, else the bare invoked name — see
142    /// [`crate::sandbox::resolve_invoked_binary_path`]. Carried out of here because the
143    /// invoked name is otherwise lost the moment dispatch returns, leaving every
144    /// downstream observer (`shell-event.binary`, `trace.jsonl`) unable to say what ran.
145    pub(crate) binary: String,
146    pub(crate) exit_code: i32,
147    pub(crate) stdout: String,
148    pub(crate) stderr: String,
149    pub(crate) duration_ms: u64,
150    pub(crate) truncated: bool,
151    pub(crate) full_output_path: Option<String>,
152    /// The `capabilities.resources` field this subprocess was killed for exceeding, when the
153    /// evidence names exactly one — see [`classify_resource_limit`]. `None` covers both "ran
154    /// normally" and "died for a reason no single limit can be pinned to", which are
155    /// deliberately not distinguished here: inventing an attribution is worse than declining one.
156    pub(crate) resource_limit_hit: Option<String>,
157}
158
159/// Why [`execute_shell`] produced no result.
160///
161/// Two kinds, and the distinction is the whole point of the type: an ordinary failure is the
162/// capsule's business (it sees a failed tool call and can decide what to do next), while a
163/// [`Self::SealedRootConstructionFailed`] is the *session's* business — the containment boundary
164/// the session declared stopped being establishable, so there is nothing sensible for the capsule
165/// to retry and the run has to end with that named as the cause. Before this type existed both
166/// collapsed into a `String` and the second was indistinguishable from the first by the time it
167/// reached a caller that could act on it.
168#[derive(Debug)]
169pub(crate) enum ShellExecError {
170    /// Binary not in `capabilities.shell.allow`, env construction failure, workdir budget
171    /// breach, enforcement setup failure, spawn failure — everything that leaves the rest of
172    /// the session viable.
173    Failed(String),
174    /// A `sealed` session's composed root could not be built for this subprocess, *after* the
175    /// pre-launch probe reported the mechanism available. `detail` is what the child wrote to
176    /// the `pre_exec` diagnostic pipe: the exact step and its errno.
177    SealedRootConstructionFailed { detail: String },
178}
179
180impl ShellExecError {
181    /// The typed, session-fatal error this failure represents, or `None` when the failure is a
182    /// tool-level one the capsule can be told about and carry on from.
183    ///
184    /// Callers that own the session (the agent turn loop) return this; callers that only own a
185    /// single dispatch pass it upward alongside the tool result rather than deciding themselves.
186    pub(crate) fn session_fatal(&self) -> Option<crate::errors::RuntimeError> {
187        match self {
188            Self::Failed(_) => None,
189            Self::SealedRootConstructionFailed { detail } => {
190                Some(crate::errors::RuntimeError::SealedRootConstructionFailed {
191                    detail: detail.clone(),
192                })
193            }
194        }
195    }
196}
197
198/// What a failed `spawn()` of a `command` that went through
199/// [`crate::sandbox::prepare_enforcement`] means, for every subprocess that does: shell commands,
200/// plan shell steps and native tools.
201///
202/// A failure inside the `pre_exec` enforcement setup reaches the caller only as a bare errno (std
203/// collapses a `pre_exec` `io::Error` to its `raw_os_error()`, defaulting to EINVAL). `command` is
204/// dropped first so the parent's captured copy of the diagnostic pipe's write end closes, letting
205/// the read see EOF; then the detail the child wrote replaces the undifferentiated
206/// "Invalid argument (os error 22)". `subject` names the spawn in that message (`shell`,
207/// `native tool`), and `spawn_error` is the error returned when the child wrote nothing.
208pub(crate) fn enforced_spawn_failure(
209    command: Command,
210    supervisor: &crate::sandbox::SupervisorHandle,
211    subject: &str,
212    spawn_error: String,
213) -> ShellExecError {
214    drop(command);
215    match supervisor.read_diagnostic() {
216        // A composed-root failure keeps its own variant rather than being flattened into a
217        // message: it means a host that *did* clear the pre-launch sealed probe then failed to
218        // build the root, which is a different event from a Landlock or seccomp step failing,
219        // points somewhere else, and ends the session instead of the tool call. See
220        // [`ShellExecError::session_fatal`].
221        Some(detail) if detail.contains(crate::sealed::SEALED_ROOT_FAILURE_PREFIX) => {
222            ShellExecError::SealedRootConstructionFailed { detail }
223        }
224        Some(detail) if !detail.is_empty() => ShellExecError::Failed(format!(
225            "sandbox: {subject} enforcement setup failed before exec: {detail}"
226        )),
227        _ => ShellExecError::Failed(spawn_error),
228    }
229}
230
231impl std::fmt::Display for ShellExecError {
232    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
233        match self {
234            Self::Failed(message) => f.write_str(message),
235            // Delegate to the `RuntimeError` Display text so the message an operator reads cannot
236            // drift from the one `E-RUN-014` renders — the same reason the CLI's error mapping
237            // delegates rather than restating.
238            Self::SealedRootConstructionFailed { detail } => write!(
239                f,
240                "{}",
241                crate::errors::RuntimeError::SealedRootConstructionFailed {
242                    detail: detail.clone(),
243                }
244            ),
245        }
246    }
247}
248
249/// Lets the many `Result<_, String>` helpers `execute_shell` calls keep flowing through `?`.
250impl From<String> for ShellExecError {
251    fn from(message: String) -> Self {
252        Self::Failed(message)
253    }
254}
255
256pub(crate) fn is_shell_interpreter(binary: &str) -> bool {
257    SHELL_INTERPRETERS.contains(&binary)
258}
259
260pub(crate) fn split_shell_words(command: &str) -> Vec<String> {
261    let mut words = Vec::new();
262    let mut current = String::new();
263    let mut quote = None;
264    let mut escaped = false;
265
266    for ch in command.chars() {
267        if escaped {
268            current.push(ch);
269            escaped = false;
270            continue;
271        }
272        if ch == '\\' {
273            escaped = true;
274            continue;
275        }
276        match (ch, quote) {
277            ('"' | '\'', None) => quote = Some(ch),
278            (q, Some(open)) if q == open => quote = None,
279            (c, None) if c.is_whitespace() => {
280                if !current.is_empty() {
281                    words.push(std::mem::take(&mut current));
282                }
283            }
284            _ => current.push(ch),
285        }
286    }
287
288    if !current.is_empty() {
289        words.push(current);
290    }
291    words
292}
293
294pub(crate) fn shell_tool_manifest_yaml(binary: &str) -> String {
295    let description = if is_shell_interpreter(binary) {
296        format!("Run a shell command via {binary} in the capsule workdir. The `command` field is passed to {binary} via -c.")
297    } else {
298        format!("Run {binary} in the capsule workdir. The `command` field is the argument list — omit the binary name itself (pass -s http://example.com, not {binary} -s http://example.com).")
299    };
300
301    format!(
302        "name: {binary}\nversion: 0.0.0\nruntime: tool\nimplementation: native\ndescription: \"{description}\"\ninput_schema: '{{\"type\":\"object\",\"properties\":{{\"command\":{{\"type\":\"string\"}}}},\"required\":[\"command\"]}}'\n"
303    )
304}
305
306/// Whether a shell command finished inside its grace period or was demoted to the background.
307#[derive(Debug)]
308pub(crate) enum ShellOutcome {
309    Finished(ShellResult),
310    Detached(DetachedDispatchInfo),
311}
312
313/// How often the grace-period wait checks on a child it cannot block for.
314///
315/// `wait_with_output` has no pollable form, so a command that may be demoted is waited for by
316/// polling. Short enough that a command finishing just inside the grace period is not held past
317/// it by a noticeable margin, long enough that a one-minute grace costs a few thousand
318/// non-blocking syscalls rather than a busy loop.
319const DETACH_POLL_INTERVAL: Duration = Duration::from_millis(20);
320
321/// Run a shell command, demoting it to the background if it outruns `detach`'s grace period.
322///
323/// With `detach: None` the child is waited for with a plain blocking wait and nothing is polled —
324/// the path `plan.rs` and the script-capsule store state take, because neither runs a task loop
325/// and so neither has anywhere to deliver a completion.
326///
327/// With `detach: Some`, the child is polled until it exits or the deadline passes. On the
328/// deadline the child, its two pipe readers and the metadata needed to describe it all move to
329/// one plain OS thread — not a `spawn_blocking` task — so runtime teardown never waits on it. The
330/// session's end kills it with every other process the session started.
331///
332/// Every command runs in the session's one network namespace, so a server one call starts — in
333/// the background, or demoted — is reachable from the next call of the same session.
334///
335/// Everything before the spawn is identical on both paths, including the whole spawn-failure
336/// arm: a spawn failure happens inside the grace window and stays a foreground error.
337#[allow(clippy::too_many_arguments)]
338pub(crate) fn run_shell(
339    binary: &str,
340    args: &[&str],
341    env_overrides: &[(String, String)],
342    accessible_workdir: &WorkdirRoot,
343    session_workdir: &WorkdirRoot,
344    policy: &CapabilityPolicy,
345    enforcement: &crate::sandbox::ShellEnforcement,
346    detach: Option<DetachPolicy>,
347) -> Result<ShellOutcome, ShellExecError> {
348    if !policy.shell_allow.iter().any(|allowed| allowed == binary) {
349        return Err(ShellExecError::Failed(format!(
350            "binary '{binary}' is not in capabilities.shell.allow"
351        )));
352    }
353
354    // Refuse before spawning, not after: once the workdir ceiling is crossed, the cheapest way
355    // to stop it growing further is to not start the next process that would write to it.
356    enforcement.check_workdir_budget()?;
357
358    let env = build_shell_env(
359        policy,
360        env_overrides,
361        session_workdir,
362        enforcement.runtime_dir_path(),
363        enforcement.records_dir_path(),
364    )?;
365
366    // Resolve before spawning: this is the identity of the binary this call is about to
367    // run, and it is the only point where the invoked name is still in hand.
368    let resolved_binary = crate::sandbox::resolve_invoked_binary_path(binary);
369
370    let started = Instant::now();
371    let mut command = Command::new(binary);
372    command
373        .args(args)
374        .current_dir(accessible_workdir.path())
375        .env_clear()
376        .envs(env)
377        .stdout(Stdio::piped())
378        .stderr(Stdio::piped());
379
380    // Fail-closed: if kernel enforcement setup fails unexpectedly, propagate the error and
381    // never call `.spawn()` at all — no code path here lets a Linux host silently run this
382    // subprocess with zero enforcement because setup failed. This also installs the hard
383    // rlimits and (where a scope exists) cgroup membership for the child.
384    let supervisor = crate::sandbox::prepare_enforcement(
385        &mut command,
386        enforcement,
387        accessible_workdir.path(),
388        session_workdir,
389    )?;
390
391    // Entered before the snapshot and held until the command exits — carried to the demoted
392    // thread if it gets that far — so a sibling's spawn anywhere inside this window is visible to
393    // attribution.
394    let cgroup_occupancy = enforcement.cgroup_scope.as_ref().map(|scope| scope.enter());
395    // Snapshotted before the child runs so attribution keys on this call's delta rather than on
396    // a session-cumulative total an earlier call could have moved.
397    let cgroup_counters_before = enforcement
398        .cgroup_scope
399        .as_ref()
400        .map(|scope| scope.event_counters());
401
402    let mut child = match command.spawn() {
403        Ok(child) => child,
404        Err(error) => {
405            return Err(enforced_spawn_failure(
406                command,
407                &supervisor,
408                "shell",
409                error.to_string(),
410            ))
411        }
412    };
413
414    // `wait_with_output` reads both pipes and waits in one uninterruptible step, so it cannot be
415    // given a deadline. Draining each pipe on its own thread separates reading from waiting,
416    // which is what lets the wait below be either blocking or polled.
417    let readers = OutputReaders::spawn(&mut child);
418
419    let status = match &detach {
420        None => child.wait().map_err(|error| error.to_string())?,
421        Some(policy) => {
422            let deadline = started + policy.grace;
423            loop {
424                match child.try_wait().map_err(|error| error.to_string())? {
425                    Some(status) => break status,
426                    None => {
427                        let remaining = deadline.saturating_duration_since(Instant::now());
428                        // A caller that stopped waiting gets the same demotion the deadline
429                        // gives, at the next poll: nobody is left to read a foreground result.
430                        let abandoned = policy
431                            .abandoned
432                            .as_ref()
433                            .is_some_and(crate::detached::AbandonSignal::is_raised);
434                        if remaining.is_zero() || abandoned {
435                            // A zero grace demotes here, at the first poll after the spawn.
436                            return Ok(ShellOutcome::Detached(demote(
437                                child,
438                                readers,
439                                DemotionContext {
440                                    policy: detach.expect("the detach policy was matched above"),
441                                    accessible_workdir: accessible_workdir.clone(),
442                                    invoked_binary: binary.to_string(),
443                                    resolved_binary,
444                                    args: args.iter().map(|arg| (*arg).to_string()).collect(),
445                                    started,
446                                    cgroup_scope: enforcement.cgroup_scope.clone(),
447                                    cgroup_counters_before,
448                                    cgroup_occupancy,
449                                },
450                            )));
451                        }
452                        std::thread::sleep(remaining.min(DETACH_POLL_INTERVAL));
453                    }
454                }
455            }
456        }
457    };
458    let (stdout_bytes, stderr_bytes) = readers.join();
459    let duration_ms = started.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
460
461    let resource_limit_hit = classify_resource_limit(
462        &status,
463        enforcement.cgroup_scope.as_deref(),
464        cgroup_counters_before,
465        cgroup_occupancy.as_ref(),
466    );
467    drop(cgroup_occupancy);
468    let exit_code = exit_code_of(&status);
469
470    let mut stdout = String::from_utf8_lossy(&stdout_bytes).to_string();
471    let mut stderr = String::from_utf8_lossy(&stderr_bytes).to_string();
472    let mut truncated = false;
473    let mut full_output_path = None;
474
475    if stdout_bytes.len() > MAX_SHELL_OUTPUT_BYTES || stderr_bytes.len() > MAX_SHELL_OUTPUT_BYTES {
476        truncated = true;
477        let stdout_cut = stdout_bytes.len().min(MAX_SHELL_OUTPUT_BYTES);
478        let stderr_cut = stderr_bytes.len().min(MAX_SHELL_OUTPUT_BYTES);
479        let full_stdout = std::mem::replace(
480            &mut stdout,
481            String::from_utf8_lossy(&stdout_bytes[..stdout_cut]).to_string(),
482        );
483        let full_stderr = std::mem::replace(
484            &mut stderr,
485            String::from_utf8_lossy(&stderr_bytes[..stderr_cut]).to_string(),
486        );
487        full_output_path = Some(write_shell_output_log(
488            accessible_workdir,
489            &format!("shell-{}", crate::trace::timestamp_ms()),
490            binary,
491            args,
492            exit_code,
493            &full_stdout,
494            &full_stderr,
495        )?);
496    }
497
498    Ok(ShellOutcome::Finished(ShellResult {
499        binary: resolved_binary,
500        exit_code,
501        stdout,
502        stderr,
503        duration_ms,
504        truncated,
505        full_output_path,
506        resource_limit_hit,
507    }))
508}
509
510/// [`run_shell`] with no demotion: the command runs to completion in the foreground.
511///
512/// Kept at its original signature and return type because most callers cannot take a completion
513/// and every one of them should keep reading exactly as it did.
514pub(crate) fn execute_shell(
515    binary: &str,
516    args: &[&str],
517    env_overrides: &[(String, String)],
518    accessible_workdir: &WorkdirRoot,
519    session_workdir: &WorkdirRoot,
520    policy: &CapabilityPolicy,
521    enforcement: &crate::sandbox::ShellEnforcement,
522) -> Result<ShellResult, ShellExecError> {
523    match run_shell(
524        binary,
525        args,
526        env_overrides,
527        accessible_workdir,
528        session_workdir,
529        policy,
530        enforcement,
531        None,
532    )? {
533        ShellOutcome::Finished(result) => Ok(result),
534        // Unreachable: demotion is reached only from the `Some(policy)` wait arm above. Reported
535        // rather than panicked, so a future edit that broke that invariant would fail one tool
536        // call instead of taking the session down.
537        ShellOutcome::Detached(info) => Err(ShellExecError::Failed(format!(
538            "shell command was demoted to the background as {} on a call site that cannot receive a completion",
539            info.work_id
540        ))),
541    }
542}
543
544/// The two pipe-draining threads for one child.
545///
546/// Both must be running before the child is waited for: a child writing more than a pipe buffer
547/// holds while nobody reads deadlocks, which is the reason `wait_with_output` reads and waits
548/// together in the first place.
549struct OutputReaders {
550    stdout: Option<std::thread::JoinHandle<Vec<u8>>>,
551    stderr: Option<std::thread::JoinHandle<Vec<u8>>>,
552}
553
554impl OutputReaders {
555    fn spawn(child: &mut std::process::Child) -> Self {
556        fn drain(
557            pipe: Option<impl std::io::Read + Send + 'static>,
558        ) -> Option<std::thread::JoinHandle<Vec<u8>>> {
559            let mut pipe = pipe?;
560            Some(std::thread::spawn(move || {
561                let mut buffer = Vec::new();
562                // A read error loses whatever was not yet read and keeps what was; there is no
563                // better outcome available, and failing the whole call over it would throw away
564                // the command's exit status too.
565                let _ = pipe.read_to_end(&mut buffer);
566                buffer
567            }))
568        }
569
570        Self {
571            stdout: drain(child.stdout.take()),
572            stderr: drain(child.stderr.take()),
573        }
574    }
575
576    /// `(stdout, stderr)`, waiting for both pipes to reach EOF. A reader thread that panicked
577    /// yields empty bytes rather than propagating.
578    fn join(self) -> (Vec<u8>, Vec<u8>) {
579        let take = |handle: Option<std::thread::JoinHandle<Vec<u8>>>| {
580            handle
581                .map(|handle| handle.join().unwrap_or_default())
582                .unwrap_or_default()
583        };
584        (take(self.stdout), take(self.stderr))
585    }
586}
587
588/// Everything the background thread needs to describe the command it is finishing.
589struct DemotionContext {
590    policy: DetachPolicy,
591    /// Where the command ran, and where its `logs/<work-id>.log` goes — the path the completion
592    /// reports back, workdir-relative.
593    accessible_workdir: WorkdirRoot,
594    /// The name as invoked, which is what the log header records.
595    invoked_binary: String,
596    /// The same name resolved to a path where the host `PATH` named one — what the trace and the
597    /// completion report, matching [`ShellResult::binary`].
598    resolved_binary: String,
599    args: Vec<String>,
600    started: Instant,
601    cgroup_scope: Option<std::sync::Arc<crate::cgroup::CgroupScope>>,
602    cgroup_counters_before: Option<crate::cgroup::CgroupEventCounters>,
603    /// The command's stay in the scope, held on the background thread until it exits.
604    cgroup_occupancy: Option<crate::cgroup::ScopeOccupancy>,
605}
606
607/// Hand a still-running command to a thread of its own and return the handle for it.
608///
609/// The work is registered before this returns, so the session-end sweep can never miss work that
610/// finished being registered after it looked.
611fn demote(
612    mut child: std::process::Child,
613    readers: OutputReaders,
614    context: DemotionContext,
615) -> DetachedDispatchInfo {
616    let work_id = crate::detached::new_work_id();
617    let info = DetachedDispatchInfo {
618        work_id: work_id.clone(),
619        binary: context.resolved_binary.clone(),
620        command: context.policy.command.clone(),
621        grace_ms: context
622            .policy
623            .grace
624            .as_millis()
625            .try_into()
626            .unwrap_or(u64::MAX),
627    };
628
629    context
630        .policy
631        .registry
632        .register(crate::detached::DetachedWork {
633            work_id: work_id.clone(),
634            binary: context.resolved_binary.clone(),
635            command: context.policy.command.clone(),
636            // The spawn instant, not this one. Demotion happens a whole grace period after the
637            // command started, and every reader of this field — the cancel residue, the
638            // session-end sweep's `running_ms` — reports it as how long the command has been
639            // running, which must include the foreground portion to match the `duration_ms` a
640            // completion carries.
641            started_at_ms: crate::trace::timestamp_ms().saturating_sub(
642                context
643                    .started
644                    .elapsed()
645                    .as_millis()
646                    .try_into()
647                    .unwrap_or(u64::MAX),
648            ),
649        });
650
651    let provenance = context.policy.completion_provenance();
652    let DemotionContext {
653        policy,
654        accessible_workdir,
655        invoked_binary,
656        resolved_binary,
657        args,
658        started,
659        cgroup_scope,
660        cgroup_counters_before,
661        cgroup_occupancy,
662    } = context;
663
664    std::thread::spawn(move || {
665        let waited = child.wait();
666        let (stdout_bytes, stderr_bytes) = readers.join();
667        let duration_ms = started.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
668
669        let (exit_code, resource_limit, mut error) = match waited {
670            Ok(status) => (
671                exit_code_of(&status),
672                classify_resource_limit(
673                    &status,
674                    cgroup_scope.as_deref(),
675                    cgroup_counters_before,
676                    cgroup_occupancy.as_ref(),
677                ),
678                None,
679            ),
680            Err(failure) => (
681                -1,
682                None,
683                Some(format!("waiting for the command failed: {failure}")),
684            ),
685        };
686        drop(cgroup_occupancy);
687
688        let stdout = String::from_utf8_lossy(&stdout_bytes).into_owned();
689        let stderr = String::from_utf8_lossy(&stderr_bytes).into_owned();
690        let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
691        let output_path = crate::detached::output_path_for(&work_id);
692        if let Err(failure) = write_shell_output_log(
693            &accessible_workdir,
694            &work_id,
695            &invoked_binary,
696            &arg_refs,
697            exit_code,
698            &stdout,
699            &stderr,
700        ) {
701            error = Some(match error {
702                Some(existing) => format!("{existing}; {failure}"),
703                None => failure,
704            });
705        }
706        let output_bytes = accessible_workdir
707            .file_len(&output_path)
708            .ok()
709            .flatten()
710            .unwrap_or(0);
711
712        policy
713            .registry
714            .complete(crate::detached::DetachedCompletion {
715                work_id,
716                binary: resolved_binary,
717                command: policy.command.clone(),
718                exit_code,
719                duration_ms,
720                output_path,
721                output_bytes,
722                resource_limit,
723                context_id: policy.context_id.clone(),
724                provenance,
725                error,
726            });
727    });
728
729    info
730}
731
732/// Exit code for a finished subprocess, keeping the signal that killed it legible.
733///
734/// `ExitStatus::code()` returns `None` for every signal-killed process on Unix, so a bare
735/// `.unwrap_or(-1)` collapses a `SIGXCPU` from `RLIMIT_CPU`, a cgroup OOM `SIGKILL` and an
736/// unrelated crash into one indistinguishable `-1`. `128 + signal` is the long-standing shell
737/// convention for exactly this case and keeps the cause readable in the trace even where
738/// [`classify_resource_limit`] declines to name a limit.
739pub(crate) fn exit_code_of(status: &std::process::ExitStatus) -> i32 {
740    use std::os::unix::process::ExitStatusExt;
741
742    status
743        .code()
744        .or_else(|| status.signal().map(|signal| 128 + signal))
745        .unwrap_or(-1)
746}
747
748/// Name the `capabilities.resources` field a subprocess was killed for exceeding, or `None`.
749///
750/// Two independent sources of evidence, both kernel-maintained facts rather than inference:
751///
752///   * the kill signal, for the two rlimits that raise a signal unique to themselves
753///     (`SIGXCPU` → `cpu_seconds`, `SIGXFSZ` → `max_file_size_bytes`);
754///   * this call's delta on the cgroup scope's `memory.events`/`pids.events` counters
755///     (`oom_kill` → `cgroup_memory_bytes`, `max` → `cgroup_pids_max`).
756///
757/// The signal is checked first: where both fire, it identifies the individual process that died,
758/// which is the more specific claim. The counter delta is about the whole scope, so it names this
759/// call's limit only when the call had the scope to itself — see [`attribute_resource_limit`].
760///
761/// The `pids.max` case is reported even when the process exited normally, because that is how it
762/// presents — `pids.max` refuses a `fork()` rather than killing anything, so a fork bomb held by
763/// the cgroup usually shows up as a shell exiting non-zero with `EAGAIN` on stderr. Without the
764/// counter there would be nothing distinguishing it from any other failure.
765///
766/// Nothing else is attributed. See [`crate::resources::limit_from_signal`] for why
767/// `RLIMIT_AS`/`RLIMIT_DATA`, `RLIMIT_NPROC` and `RLIMIT_NOFILE` are deliberately left
768/// unattributed rather than guessed at.
769fn classify_resource_limit(
770    status: &std::process::ExitStatus,
771    cgroup_scope: Option<&crate::cgroup::CgroupScope>,
772    counters_before: Option<crate::cgroup::CgroupEventCounters>,
773    occupancy: Option<&crate::cgroup::ScopeOccupancy>,
774) -> Option<String> {
775    use std::os::unix::process::ExitStatusExt;
776
777    let signal_limit = status
778        .signal()
779        .and_then(crate::resources::limit_from_signal);
780    let counter_delta = match (cgroup_scope, counters_before) {
781        (Some(scope), Some(before)) => scope.event_counters().attribution_since(before),
782        _ => None,
783    };
784    let exclusive = occupancy.is_some_and(crate::cgroup::ScopeOccupancy::exclusive);
785    attribute_resource_limit(signal_limit, counter_delta, exclusive)
786}
787
788/// The limit a call is reported as having hit, from the two kinds of evidence there are.
789///
790/// The signal names the process that died, so it is this call's whenever there is one. The
791/// cgroup counter delta is about the scope, and every process in the scope moves it: it is this
792/// call's only when the call was `exclusive` — alone in the scope from entry to exit. Otherwise
793/// the delta could be a sibling's, and nothing is attributed.
794pub(crate) fn attribute_resource_limit(
795    signal_limit: Option<&'static str>,
796    counter_delta: Option<&'static str>,
797    exclusive: bool,
798) -> Option<String> {
799    signal_limit
800        .or(counter_delta.filter(|_| exclusive))
801        .map(str::to_string)
802}
803
804pub(crate) fn build_shell_env(
805    policy: &CapabilityPolicy,
806    env_overrides: &[(String, String)],
807    session_workdir: &WorkdirRoot,
808    runtime_dir: Option<&std::path::Path>,
809    records_dir: Option<&std::path::Path>,
810) -> Result<BTreeMap<String, String>, String> {
811    let mut env = BTreeMap::new();
812
813    for key in DEFAULT_ENV_BASELINE {
814        if let Ok(value) = std::env::var(key) {
815            env.insert((*key).to_string(), value);
816        }
817    }
818
819    for (key, value) in env_overrides {
820        env.insert(key.clone(), value.clone());
821    }
822
823    for key in &policy.shell_baseline_env {
824        // Formation names and the spawner lifeline are never a native process's: a baseline
825        // entry naming one supplies nothing from the host.
826        if crate::formation::is_member_grant_env(key)
827            || key == crate::lifeline::SPAWNER_LIFELINE_ENV
828        {
829            continue;
830        }
831        if let Ok(value) = std::env::var(key) {
832            env.insert(key.clone(), value);
833        }
834    }
835
836    strip_credential_shaped_vars(&mut env, &policy.shell_strip_env);
837
838    // Inserted last so neither a guest-supplied `env_overrides` entry nor a
839    // manifest-declared `shell_baseline_env` entry can resurrect the real host
840    // HOME/USERPROFILE, and so no strip_env pattern can remove the synthetic one.
841    let synthetic_home = synthetic_home_dir(session_workdir)?;
842    let synthetic_home = synthetic_home.to_string_lossy().into_owned();
843    env.insert("HOME".to_string(), synthetic_home.clone());
844    env.insert("USERPROFILE".to_string(), synthetic_home);
845    // Runtime-owned on the same terms: whatever an override, a baseline entry or the host said,
846    // a command reads the session's own runtime directory here, or no such variable at all.
847    env.remove(crate::runtime_dir::RUNTIME_DIR_ENV);
848    if let Some(runtime_dir) = runtime_dir {
849        env.insert(
850            crate::runtime_dir::RUNTIME_DIR_ENV.to_string(),
851            runtime_dir.to_string_lossy().into_owned(),
852        );
853    }
854    // And the session's records directory, on the same terms.
855    env.remove(crate::records_dir::RECORDS_DIR_ENV);
856    if let Some(records_dir) = records_dir {
857        env.insert(
858            crate::records_dir::RECORDS_DIR_ENV.to_string(),
859            records_dir.to_string_lossy().into_owned(),
860        );
861    }
862
863    Ok(env)
864}
865
866/// Drop every entry whose name matches [`CREDENTIAL_ENV_PATTERNS`] or one of
867/// `extra_patterns` (a policy's `shell_strip_env`).
868///
869/// This is the single credential backstop for both environments the runtime builds: the
870/// native subprocess env ([`build_shell_env`]) and the WASI guest env
871/// ([`build_declared_env`]). It runs *after* whatever allowlist populated `env`, so a
872/// declared name never bypasses the filter.
873pub(crate) fn strip_credential_shaped_vars(
874    env: &mut BTreeMap<String, String>,
875    extra_patterns: &[String],
876) {
877    env.retain(|key, _| !credential_backstop_drops(key, extra_patterns));
878}
879
880/// Which list a credential backstop pattern came from.
881#[derive(Debug, Clone, PartialEq, Eq)]
882pub enum BackstopPatternSource {
883    /// [`CREDENTIAL_ENV_PATTERNS`], the list no manifest can change.
884    Builtin,
885    /// The manifest's own `capabilities.shell.strip_env`.
886    StripEnv,
887}
888
889/// The pattern that makes the credential backstop drop a name, verbatim, and where it came from.
890#[derive(Debug, Clone, PartialEq, Eq)]
891pub struct BackstopMatch {
892    pub pattern: String,
893    pub source: BackstopPatternSource,
894}
895
896/// The first credential backstop pattern that matches `name`, or `None` when the backstop keeps
897/// it — the one decision procedure behind [`strip_credential_shaped_vars`],
898/// [`credential_backstop_drops`], `W-SEC-024` and the `E-CAP-016` staging refusal.
899///
900/// [`CREDENTIAL_ENV_PATTERNS`] is tried before `extra_patterns`, so a name both lists cover is
901/// attributed to the built-in pattern: that is the one no manifest edit can remove.
902/// `extra_patterns` is a policy's `shell_strip_env`. Judges a name alone and never reads the host
903/// environment.
904pub fn credential_backstop_match(name: &str, extra_patterns: &[String]) -> Option<BackstopMatch> {
905    CREDENTIAL_ENV_PATTERNS
906        .iter()
907        .map(|pattern| (*pattern, BackstopPatternSource::Builtin))
908        .chain(
909            extra_patterns
910                .iter()
911                .map(|pattern| (pattern.as_str(), BackstopPatternSource::StripEnv)),
912        )
913        .find(|(pattern, _)| env_name_matches_pattern(pattern, name))
914        .map(|(pattern, source)| BackstopMatch {
915            pattern: pattern.to_string(),
916            source,
917        })
918}
919
920/// Whether the credential backstop would drop a variable of this name — the same question
921/// [`strip_credential_shaped_vars`] answers per entry, asked of a name alone. See
922/// [`credential_backstop_match`] for which pattern decided it.
923pub fn credential_backstop_drops(name: &str, extra_patterns: &[String]) -> bool {
924    credential_backstop_match(name, extra_patterns).is_some()
925}
926
927/// Resolve the host variables the manifest declared: only names in `capabilities.env.allow` that
928/// the host actually has set, minus anything credential-shaped. A declared name absent from the
929/// host is simply omitted, not an error.
930///
931/// This is what every WASM guest observes. A `transport: process` harness starts from it plus the
932/// host's [`HostAccountEnv`], before its driver's `env-set` is applied. Native shell subprocesses
933/// are the exception: they run on [`DEFAULT_ENV_BASELINE`] plus `capabilities.shell.baseline_env`,
934/// built by [`build_shell_env`].
935///
936/// [`ARTIFACT_CONFIG_ENV`], a formation member's names
937/// ([`crate::formation::is_member_grant_env`]) and [`crate::lifeline::SPAWNER_LIFELINE_ENV`] are
938/// reserved and never resolved from the host, whatever a manifest allowlists: the names are
939/// runtime-owned, and their values come from the declaring artifact's own `config:` block and from
940/// the session, or from nowhere. Skipped here rather than relied on being overwritten later, so a
941/// host value cannot reach a guest whose entry declared no config at all.
942pub(crate) fn build_declared_env(policy: &CapabilityPolicy) -> BTreeMap<String, String> {
943    let mut env = BTreeMap::new();
944
945    for key in &policy.env_allow {
946        if key == ARTIFACT_CONFIG_ENV
947            || crate::formation::is_member_grant_env(key)
948            || key == crate::lifeline::SPAWNER_LIFELINE_ENV
949            || key == crate::runtime_dir::RUNTIME_DIR_ENV
950            || key == crate::records_dir::RECORDS_DIR_ENV
951        {
952            continue;
953        }
954        if let Ok(value) = std::env::var(key) {
955            env.insert(key.clone(), value);
956        }
957    }
958
959    strip_credential_shaped_vars(&mut env, &policy.shell_strip_env);
960
961    env
962}
963
964fn synthetic_home_dir(session_workdir: &WorkdirRoot) -> Result<std::path::PathBuf, String> {
965    session_workdir
966        .create_dir_all(SYNTHETIC_HOME_DIR_NAME, DEFAULT_DIR_MODE)
967        .map_err(|error| format!("failed to create synthetic home directory: {error}"))?;
968    Ok(session_workdir.host_path(SYNTHETIC_HOME_DIR_NAME))
969}
970
971fn env_name_matches_pattern(pattern: &str, key: &str) -> bool {
972    if let Some(rest) = pattern.strip_prefix('*') {
973        if let Some(middle) = rest.strip_suffix('*') {
974            return key.contains(middle);
975        }
976        return key.ends_with(rest);
977    }
978
979    if let Some(prefix) = pattern.strip_suffix('*') {
980        return key.starts_with(prefix);
981    }
982
983    pattern == key
984}
985
986/// Write one command's full stdout and stderr to `logs/<file_stem>.log`, returning the
987/// workdir-relative path.
988///
989/// Two callers, one content format: a foreground command whose output exceeded
990/// [`MAX_SHELL_OUTPUT_BYTES`] passes a `shell-<ms>` stem, and a demoted command passes its work
991/// id. A reader that finds either file reads the same header, `Stdout:` and `Stderr:` sections.
992fn write_shell_output_log(
993    accessible_workdir: &WorkdirRoot,
994    file_stem: &str,
995    binary: &str,
996    args: &[&str],
997    exit_code: i32,
998    stdout: &str,
999    stderr: &str,
1000) -> Result<String, String> {
1001    let relative_path = format!("logs/{file_stem}.log");
1002
1003    let command = if args.is_empty() {
1004        binary.to_string()
1005    } else {
1006        format!("{binary} {}", args.join(" "))
1007    };
1008
1009    let content = format!(
1010        "Command: {command}\nExit code: {exit_code}\n\nStdout:\n{stdout}\n\nStderr:\n{stderr}\n"
1011    );
1012
1013    accessible_workdir
1014        .write_file(&relative_path, content.as_bytes(), DEFAULT_FILE_MODE)
1015        .map_err(|error| format!("failed to write shell log: {error}"))?;
1016
1017    Ok(relative_path)
1018}
1019
1020#[cfg(test)]
1021mod tests {
1022    use std::{fs, path::Path};
1023    use tempfile::tempdir;
1024
1025    use super::*;
1026    use crate::sandbox::ShellEnforcement;
1027
1028    /// The `pre_exec` rlimit plumbing, end to end on whatever platform runs the suite: a
1029    /// declared `capabilities.resources.max_open_files` must reach the child as its **hard**
1030    /// ceiling, which is what `ulimit -Hn` reports. This asserts the mechanism (the value was
1031    /// applied to `rlim_max`, not only `rlim_cur`), not a security outcome — the hostile-capsule
1032    /// scenarios require real Linux hardware.
1033    #[test]
1034    fn execute_shell_applies_the_declared_limit_as_a_hard_ceiling() {
1035        let temp = tempdir().unwrap();
1036        let policy = CapabilityPolicy {
1037            shell_allow: vec!["bash".to_string()],
1038            resources: crate::resources::HostResourceLimits {
1039                max_open_files: 64,
1040                ..crate::resources::HostResourceLimits::default()
1041            },
1042            ..CapabilityPolicy::default()
1043        };
1044        let mut enforcement = ShellEnforcement::environment_only();
1045        enforcement.resource_limits = policy.resources;
1046
1047        let result = execute_shell(
1048            "bash",
1049            &["-c", "ulimit -Hn"],
1050            &[],
1051            &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
1052            &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
1053            &policy,
1054            &enforcement,
1055        )
1056        .expect("bash must run");
1057
1058        assert_eq!(result.stdout.trim(), "64", "stderr was: {}", result.stderr);
1059        assert_eq!(result.resource_limit_hit, None);
1060    }
1061
1062    /// With `RLIMIT_NPROC` left alone, `pids.max` is the only process bound on a scoped capsule,
1063    /// and it has to stop a fork bomb by itself and be named as what did. The children sleep
1064    /// rather than exit so every one of them holds a task in the scope until the loop gives up.
1065    #[cfg(target_os = "linux")]
1066    #[test]
1067    fn a_fork_bomb_in_a_scoped_capsule_is_stopped_by_pids_max() {
1068        const TEST: &str = "a_fork_bomb_in_a_scoped_capsule_is_stopped_by_pids_max";
1069        if !crate::cgroup::cgroup_delegation_available() {
1070            crate::runtime_err!("[SKIP-HOST] {TEST}: this host cannot delegate a cgroup v2 scope");
1071            return;
1072        }
1073
1074        let temp = tempdir().unwrap();
1075        let limits = crate::resources::HostResourceLimits {
1076            cgroup_pids_max: 16,
1077            ..crate::resources::HostResourceLimits::default()
1078        };
1079        let scope = crate::cgroup::prepare_scope(true, &limits, "ses_fork_bomb", temp.path())
1080            .expect("this host delegates a cgroup scope")
1081            .scope;
1082        let policy = CapabilityPolicy {
1083            shell_allow: vec!["python3".to_string()],
1084            resources: limits,
1085            ..CapabilityPolicy::default()
1086        };
1087        let mut enforcement = ShellEnforcement::environment_only();
1088        enforcement.resource_limits = limits;
1089        let enforcement = enforcement.with_host_bounding(scope, None);
1090
1091        let script = "import os, resource, signal, time\n\
1092            children = []\n\
1093            for _ in range(4096):\n\
1094            \x20   try:\n\
1095            \x20       pid = os.fork()\n\
1096            \x20   except OSError:\n\
1097            \x20       break\n\
1098            \x20   if pid == 0:\n\
1099            \x20       time.sleep(30)\n\
1100            \x20       os._exit(0)\n\
1101            \x20   children.append(pid)\n\
1102            for pid in children:\n\
1103            \x20   os.kill(pid, signal.SIGKILL)\n\
1104            for pid in children:\n\
1105            \x20   os.waitpid(pid, 0)\n\
1106            soft = resource.getrlimit(resource.RLIMIT_NPROC)[0]\n\
1107            print(len(children))\n\
1108            print('unlimited' if soft == resource.RLIM_INFINITY else soft)\n";
1109        let result = execute_shell(
1110            "python3",
1111            &["-c", script],
1112            &[],
1113            &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
1114            &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
1115            &policy,
1116            &enforcement,
1117        )
1118        .expect("python3 must run");
1119
1120        assert_eq!(result.exit_code, 0, "stderr was: {}", result.stderr);
1121        let mut lines = result.stdout.lines();
1122        let reached: u64 = lines.next().unwrap().parse().unwrap();
1123        let soft = lines.next().unwrap();
1124        assert!(
1125            reached < 16,
1126            "pids.max is 16 and the python process holds one task, so at most 15 children can \
1127             be live; reached {reached}"
1128        );
1129        assert_eq!(
1130            result.resource_limit_hit,
1131            Some("cgroup_pids_max".to_string())
1132        );
1133        let (own_soft, _) = crate::resources::test_support::own_rlimit_nproc();
1134        assert_eq!(
1135            soft,
1136            crate::resources::test_support::rendered_rlimit(own_soft),
1137            "a scoped spawn inherits RLIMIT_NPROC unchanged"
1138        );
1139    }
1140
1141    /// Under `--workdir` the two workdirs are different directories, and each of the three things
1142    /// `execute_shell` does with one has to pick the right one: the subprocess runs in the
1143    /// accessible workdir, `$HOME` resolves into the session workdir, and the truncated-output log
1144    /// stays where the tool result says it is.
1145    ///
1146    /// Asserted through a real subprocess rather than through `build_shell_env` alone, because
1147    /// `$HOME` naming a directory that was never created is exactly the failure this split can
1148    /// produce and a pure env check would not see.
1149    #[test]
1150    fn a_shell_command_runs_in_the_accessible_workdir_with_home_inside_the_session_one() {
1151        let accessible = tempdir().unwrap();
1152        let session = accessible.path().join(".murmur").join("ses_shell_test");
1153        fs::create_dir_all(&session).unwrap();
1154        let policy = CapabilityPolicy {
1155            shell_allow: vec!["bash".to_string()],
1156            ..CapabilityPolicy::default()
1157        };
1158
1159        let result = execute_shell(
1160            "bash",
1161            &[
1162                "-c",
1163                "echo marker > \"$HOME/probe\" && cat \"$HOME/probe\" && pwd",
1164            ],
1165            &[],
1166            &crate::workdir_fs::WorkdirRoot::open(accessible.path()).unwrap(),
1167            &crate::workdir_fs::WorkdirRoot::open(&session).unwrap(),
1168            &policy,
1169            &ShellEnforcement::environment_only(),
1170        )
1171        .expect("bash must run");
1172
1173        assert_eq!(result.exit_code, 0, "stderr was: {}", result.stderr);
1174        let mut lines = result.stdout.lines();
1175        assert_eq!(lines.next(), Some("marker"));
1176        assert_eq!(
1177            std::fs::canonicalize(lines.next().unwrap()).unwrap(),
1178            std::fs::canonicalize(accessible.path()).unwrap(),
1179            "the subprocess cwd is the directory the capsule was asked to produce"
1180        );
1181
1182        assert_eq!(
1183            fs::read_to_string(session.join(SYNTHETIC_HOME_DIR_NAME).join("probe")).unwrap(),
1184            "marker\n"
1185        );
1186        assert!(
1187            !accessible.path().join(SYNTHETIC_HOME_DIR_NAME).exists(),
1188            "the synthetic home must add nothing to the top level of the accessible workdir"
1189        );
1190    }
1191
1192    /// A subprocess that exits normally must never be reported as limit-killed — the negative
1193    /// half of attribution, and the one that keeps `resource_limit_hit` meaningful in the trace.
1194    #[test]
1195    fn execute_shell_reports_no_resource_limit_for_an_ordinary_exit() {
1196        let temp = tempdir().unwrap();
1197        let policy = CapabilityPolicy {
1198            shell_allow: vec!["bash".to_string()],
1199            ..CapabilityPolicy::default()
1200        };
1201
1202        let result = execute_shell(
1203            "bash",
1204            &["-c", "exit 3"],
1205            &[],
1206            &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
1207            &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
1208            &policy,
1209            &ShellEnforcement::environment_only(),
1210        )
1211        .expect("bash must run");
1212
1213        assert_eq!(result.exit_code, 3);
1214        assert_eq!(result.resource_limit_hit, None);
1215    }
1216
1217    #[test]
1218    fn execute_shell_blocks_binary_not_in_allowlist() {
1219        let policy = CapabilityPolicy::default();
1220        let error = execute_shell(
1221            "bash",
1222            &["-c", "echo hi"],
1223            &[],
1224            &crate::workdir_fs::WorkdirRoot::open(Path::new(".")).unwrap(),
1225            &crate::workdir_fs::WorkdirRoot::open(Path::new(".")).unwrap(),
1226            &policy,
1227            &ShellEnforcement::environment_only(),
1228        )
1229        .unwrap_err();
1230
1231        assert!(error.to_string().contains("capabilities.shell.allow"));
1232    }
1233
1234    #[test]
1235    fn execute_shell_returns_nonzero_exit_code_without_error() {
1236        let temp = tempdir().unwrap();
1237        let policy = CapabilityPolicy {
1238            shell_allow: vec!["bash".to_string()],
1239            ..CapabilityPolicy::default()
1240        };
1241
1242        let result = execute_shell(
1243            "bash",
1244            &["-c", "exit 42"],
1245            &[],
1246            &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
1247            &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
1248            &policy,
1249            &ShellEnforcement::environment_only(),
1250        )
1251        .unwrap();
1252        assert_eq!(result.exit_code, 42);
1253    }
1254
1255    /// A real subprocess run through the real `execute_shell`: the returned `binary` is the
1256    /// canonical absolute path of the interpreter that ran, not the bare invoked name — the
1257    /// value that reaches `shell-event.binary` and `trace.jsonl`'s `binary` key.
1258    #[test]
1259    fn execute_shell_reports_the_resolved_path_of_the_invoked_binary() {
1260        let temp = tempdir().unwrap();
1261        let policy = CapabilityPolicy {
1262            shell_allow: vec!["bash".to_string()],
1263            ..CapabilityPolicy::default()
1264        };
1265
1266        let result = execute_shell(
1267            "bash",
1268            &["-c", "exit 0"],
1269            &[],
1270            &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
1271            &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
1272            &policy,
1273            &ShellEnforcement::environment_only(),
1274        )
1275        .unwrap();
1276
1277        assert!(
1278            Path::new(&result.binary).is_absolute(),
1279            "a binary found on PATH must be reported as an absolute path, got {:?}",
1280            result.binary
1281        );
1282        assert!(
1283            result.binary.ends_with("bash"),
1284            "the reported path must still name bash, got {:?}",
1285            result.binary
1286        );
1287    }
1288
1289    /// The fallback, exercised end-to-end rather than only against the resolver: a workdir
1290    /// -relative program spawns fine (the child resolves it after `chdir`) but has no host
1291    /// `PATH` entry to canonicalize against, so `binary` degrades to the invoked name
1292    /// unchanged. Nothing else about the call changes — it still runs and still reports its
1293    /// exit code.
1294    #[test]
1295    fn execute_shell_falls_back_to_the_bare_name_when_nothing_resolves() {
1296        let temp = tempdir().unwrap();
1297        let tool = temp.path().join("local-tool");
1298        fs::write(&tool, "#!/bin/sh\nexit 7\n").unwrap();
1299        let mut perms = fs::metadata(&tool).unwrap().permissions();
1300        std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o755);
1301        fs::set_permissions(&tool, perms).unwrap();
1302
1303        let policy = CapabilityPolicy {
1304            shell_allow: vec!["./local-tool".to_string()],
1305            ..CapabilityPolicy::default()
1306        };
1307
1308        let result = execute_shell(
1309            "./local-tool",
1310            &[],
1311            &[],
1312            &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
1313            &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
1314            &policy,
1315            &ShellEnforcement::environment_only(),
1316        )
1317        .unwrap();
1318
1319        assert_eq!(result.exit_code, 7, "the subprocess must still have run");
1320        assert_eq!(
1321            result.binary, "./local-tool",
1322            "an unresolvable name degrades to the bare invoked name, never an error"
1323        );
1324    }
1325
1326    #[test]
1327    fn build_shell_env_sets_synthetic_home_under_workdir() {
1328        let temp = tempdir().unwrap();
1329        let policy = CapabilityPolicy::default();
1330
1331        let env = build_shell_env(
1332            &policy,
1333            &[],
1334            &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
1335            None,
1336            None,
1337        )
1338        .unwrap();
1339
1340        let expected_home = temp.path().join(".capsule-home");
1341        assert_eq!(
1342            env.get("HOME"),
1343            Some(&expected_home.to_string_lossy().into_owned())
1344        );
1345        assert_eq!(
1346            env.get("USERPROFILE"),
1347            Some(&expected_home.to_string_lossy().into_owned())
1348        );
1349        assert!(expected_home.is_dir());
1350    }
1351
1352    #[test]
1353    fn build_shell_env_reports_synthetic_home_creation_failure() {
1354        let temp = tempdir().unwrap();
1355        // A file where the synthetic home belongs, so it cannot be created.
1356        std::fs::write(
1357            temp.path().join(SYNTHETIC_HOME_DIR_NAME),
1358            b"not a directory",
1359        )
1360        .unwrap();
1361        let policy = CapabilityPolicy::default();
1362
1363        let error = build_shell_env(
1364            &policy,
1365            &[],
1366            &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
1367            None,
1368            None,
1369        )
1370        .unwrap_err();
1371
1372        assert!(error.contains(SYNTHETIC_HOME_DIR_NAME), "{error}");
1373    }
1374
1375    #[test]
1376    fn execute_shell_does_not_spawn_when_synthetic_home_creation_fails() {
1377        let temp = tempdir().unwrap();
1378        std::fs::write(
1379            temp.path().join(SYNTHETIC_HOME_DIR_NAME),
1380            b"not a directory",
1381        )
1382        .unwrap();
1383        let workdir = temp.path().to_path_buf();
1384        let policy = CapabilityPolicy {
1385            shell_allow: vec!["bash".to_string()],
1386            ..CapabilityPolicy::default()
1387        };
1388
1389        let error = execute_shell(
1390            "bash",
1391            &["-c", "echo should-not-run"],
1392            &[],
1393            &crate::workdir_fs::WorkdirRoot::open(&workdir).unwrap(),
1394            &crate::workdir_fs::WorkdirRoot::open(&workdir).unwrap(),
1395            &policy,
1396            &ShellEnforcement::environment_only(),
1397        )
1398        .unwrap_err();
1399
1400        assert!(error.to_string().contains(".capsule-home"));
1401    }
1402
1403    #[test]
1404    fn execute_shell_reports_synthetic_home_not_real_host_home() {
1405        let temp = tempdir().unwrap();
1406        let policy = CapabilityPolicy {
1407            shell_allow: vec!["bash".to_string()],
1408            ..CapabilityPolicy::default()
1409        };
1410
1411        let result = execute_shell(
1412            "bash",
1413            &["-c", "echo -n $HOME"],
1414            &[],
1415            &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
1416            &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
1417            &policy,
1418            &ShellEnforcement::environment_only(),
1419        )
1420        .unwrap();
1421
1422        let expected_home = temp.path().join(".capsule-home");
1423        assert_eq!(result.stdout, expected_home.to_string_lossy());
1424        if let Ok(real_home) = std::env::var("HOME") {
1425            assert_ne!(result.stdout, real_home);
1426        }
1427    }
1428
1429    #[test]
1430    fn build_shell_env_strips_wildcard_credential_patterns_from_overrides() {
1431        let temp = tempdir().unwrap();
1432        let policy = CapabilityPolicy::default();
1433        let overrides = vec![
1434            ("AWS_ACCESS_KEY_ID".to_string(), "leaked".to_string()),
1435            ("DOCKER_AUTH_CONFIG".to_string(), "leaked".to_string()),
1436            ("STRIPE_API_KEY".to_string(), "leaked".to_string()),
1437            ("GITHUB_TOKEN".to_string(), "leaked".to_string()),
1438            ("SAFE_VAR".to_string(), "kept".to_string()),
1439        ];
1440
1441        let env = build_shell_env(
1442            &policy,
1443            &overrides,
1444            &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
1445            None,
1446            None,
1447        )
1448        .unwrap();
1449
1450        assert!(!env.contains_key("AWS_ACCESS_KEY_ID"));
1451        assert!(!env.contains_key("DOCKER_AUTH_CONFIG"));
1452        assert!(!env.contains_key("STRIPE_API_KEY"));
1453        assert!(!env.contains_key("GITHUB_TOKEN"));
1454        assert_eq!(env.get("SAFE_VAR"), Some(&"kept".to_string()));
1455    }
1456
1457    #[test]
1458    fn declared_env_is_empty_without_declarations() {
1459        std::env::set_var("MURMUR_TEST_WASI_UNDECLARED", "host-value");
1460        let policy = CapabilityPolicy::default();
1461
1462        let env = build_declared_env(&policy);
1463
1464        assert!(env.is_empty());
1465    }
1466
1467    /// The lifeline variable names a descriptor of the member's own process: neither an
1468    /// `env.allow` nor a `shell.baseline_env` declaration resolves it from the host.
1469    #[test]
1470    fn declared_and_shell_env_never_carry_the_formation_lifeline() {
1471        use crate::lifeline::FORMATION_LIFELINE_ENV;
1472        let _guard = crate::formation::FORMATION_ENV_LOCK
1473            .lock()
1474            .unwrap_or_else(|poisoned| poisoned.into_inner());
1475        let temp = tempdir().unwrap();
1476        std::env::set_var(FORMATION_LIFELINE_ENV, "7");
1477        let policy = CapabilityPolicy {
1478            env_allow: vec![FORMATION_LIFELINE_ENV.to_string()],
1479            shell_baseline_env: vec![FORMATION_LIFELINE_ENV.to_string()],
1480            ..CapabilityPolicy::default()
1481        };
1482        let declared = build_declared_env(&policy);
1483        let shell = build_shell_env(
1484            &policy,
1485            &[],
1486            &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
1487            None,
1488            None,
1489        )
1490        .unwrap();
1491        std::env::remove_var(FORMATION_LIFELINE_ENV);
1492        assert!(
1493            !declared.contains_key(FORMATION_LIFELINE_ENV),
1494            "{declared:?}"
1495        );
1496        assert!(!shell.contains_key(FORMATION_LIFELINE_ENV), "{shell:?}");
1497    }
1498
1499    /// The spawner lifeline names a descriptor of the child's own process, on the same terms.
1500    #[test]
1501    fn declared_and_shell_env_never_carry_the_spawner_lifeline() {
1502        use crate::lifeline::SPAWNER_LIFELINE_ENV;
1503        let _guard = crate::formation::FORMATION_ENV_LOCK
1504            .lock()
1505            .unwrap_or_else(|poisoned| poisoned.into_inner());
1506        let temp = tempdir().unwrap();
1507        std::env::set_var(SPAWNER_LIFELINE_ENV, "7");
1508        let policy = CapabilityPolicy {
1509            env_allow: vec![SPAWNER_LIFELINE_ENV.to_string()],
1510            shell_baseline_env: vec![SPAWNER_LIFELINE_ENV.to_string()],
1511            ..CapabilityPolicy::default()
1512        };
1513        let declared = build_declared_env(&policy);
1514        let shell = build_shell_env(
1515            &policy,
1516            &[],
1517            &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
1518            None,
1519            None,
1520        )
1521        .unwrap();
1522        std::env::remove_var(SPAWNER_LIFELINE_ENV);
1523        assert!(!declared.contains_key(SPAWNER_LIFELINE_ENV), "{declared:?}");
1524        assert!(!shell.contains_key(SPAWNER_LIFELINE_ENV), "{shell:?}");
1525    }
1526
1527    #[test]
1528    fn declared_env_passes_through_declared_host_var() {
1529        std::env::set_var("MURMUR_TEST_WASI_ALLOWED", "host-value");
1530        let policy = CapabilityPolicy {
1531            env_allow: vec![
1532                "MURMUR_TEST_WASI_ALLOWED".to_string(),
1533                // Declared but unset on the host: omitted, not an error.
1534                "MURMUR_TEST_WASI_NEVER_SET".to_string(),
1535            ],
1536            ..CapabilityPolicy::default()
1537        };
1538
1539        let env = build_declared_env(&policy);
1540
1541        assert_eq!(
1542            env.get("MURMUR_TEST_WASI_ALLOWED"),
1543            Some(&"host-value".to_string())
1544        );
1545        assert!(!env.contains_key("MURMUR_TEST_WASI_NEVER_SET"));
1546    }
1547
1548    #[test]
1549    fn declared_env_strips_credential_shaped_declarations() {
1550        std::env::set_var("GITHUB_TOKEN", "leaked-token");
1551        std::env::set_var("STRIPE_API_KEY", "leaked-key");
1552        std::env::set_var("MURMUR_TEST_WASI_CUSTOM_SECRET", "leaked-secret");
1553        let policy = CapabilityPolicy {
1554            env_allow: vec![
1555                "GITHUB_TOKEN".to_string(),
1556                "STRIPE_API_KEY".to_string(),
1557                "MURMUR_TEST_WASI_CUSTOM_SECRET".to_string(),
1558            ],
1559            shell_strip_env: vec!["*_CUSTOM_SECRET".to_string()],
1560            ..CapabilityPolicy::default()
1561        };
1562
1563        let env = build_declared_env(&policy);
1564
1565        // Declaring a credential-shaped name does not bypass the backstop.
1566        assert!(env.is_empty(), "expected all names stripped, got {env:?}");
1567    }
1568
1569    /// A credential-shaped name outside every backstop pattern is delivered like any other
1570    /// declared name: `W-SEC-024` reports that grant, and nothing withholds it.
1571    #[test]
1572    fn declared_env_delivers_a_credential_shaped_name_the_backstop_keeps() {
1573        std::env::set_var("PRIVATE_KEY", "host-private-key");
1574        std::env::set_var("MURMUR_TEST_WASI_PLAIN", "host-plain");
1575        let policy = CapabilityPolicy {
1576            env_allow: vec![
1577                "PRIVATE_KEY".to_string(),
1578                "MURMUR_TEST_WASI_PLAIN".to_string(),
1579            ],
1580            ..CapabilityPolicy::default()
1581        };
1582
1583        let env = build_declared_env(&policy);
1584
1585        assert_eq!(
1586            env.get("PRIVATE_KEY"),
1587            Some(&"host-private-key".to_string())
1588        );
1589        assert_eq!(
1590            env.get("MURMUR_TEST_WASI_PLAIN"),
1591            Some(&"host-plain".to_string())
1592        );
1593    }
1594
1595    #[test]
1596    fn build_shell_env_home_override_cannot_survive() {
1597        let temp = tempdir().unwrap();
1598        let policy = CapabilityPolicy {
1599            shell_baseline_env: vec!["HOME".to_string()],
1600            ..CapabilityPolicy::default()
1601        };
1602        let overrides = vec![("HOME".to_string(), "/tmp/guest-controlled".to_string())];
1603
1604        let env = build_shell_env(
1605            &policy,
1606            &overrides,
1607            &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
1608            None,
1609            None,
1610        )
1611        .unwrap();
1612
1613        let expected_home = temp.path().join(".capsule-home");
1614        assert_eq!(
1615            env.get("HOME"),
1616            Some(&expected_home.to_string_lossy().into_owned())
1617        );
1618    }
1619
1620    #[test]
1621    fn build_shell_env_keeps_safe_baseline_vars() {
1622        let temp = tempdir().unwrap();
1623        let policy = CapabilityPolicy::default();
1624
1625        std::env::set_var("CARGO_HOME", "/fake/cargo/home");
1626        let env = build_shell_env(
1627            &policy,
1628            &[],
1629            &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
1630            None,
1631            None,
1632        )
1633        .unwrap();
1634        std::env::remove_var("CARGO_HOME");
1635
1636        assert_eq!(env.get("CARGO_HOME"), Some(&"/fake/cargo/home".to_string()));
1637    }
1638
1639    /// A command names the account it runs as with the host's own pair, beside a synthetic
1640    /// `HOME`, and `strip_env` governs the account names like any other baseline entry.
1641    #[test]
1642    fn build_shell_env_carries_the_hosts_account_names() {
1643        let temp = tempdir().unwrap();
1644        let root = crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap();
1645        let _guard = ACCOUNT_ENV_LOCK
1646            .lock()
1647            .unwrap_or_else(|poisoned| poisoned.into_inner());
1648        std::env::set_var("USER", "murmur-test-user");
1649        std::env::set_var("LOGNAME", "murmur-test-login");
1650
1651        let env = build_shell_env(&CapabilityPolicy::default(), &[], &root, None, None).unwrap();
1652        let stripped = build_shell_env(
1653            &CapabilityPolicy {
1654                shell_strip_env: vec!["LOGNAME".to_string()],
1655                ..CapabilityPolicy::default()
1656            },
1657            &[],
1658            &root,
1659            None,
1660            None,
1661        )
1662        .unwrap();
1663
1664        assert_eq!(
1665            env.get("USER").map(String::as_str),
1666            Some("murmur-test-user")
1667        );
1668        assert_eq!(
1669            env.get("LOGNAME").map(String::as_str),
1670            Some("murmur-test-login")
1671        );
1672        let expected_home = temp.path().join(SYNTHETIC_HOME_DIR_NAME);
1673        assert_eq!(
1674            env.get("HOME"),
1675            Some(&expected_home.to_string_lossy().into_owned())
1676        );
1677        assert!(!stripped.contains_key("LOGNAME"), "{stripped:?}");
1678        assert_eq!(
1679            stripped.get("USER").map(String::as_str),
1680            Some("murmur-test-user")
1681        );
1682        let guest = build_declared_env(&CapabilityPolicy::default());
1683        assert!(
1684            !guest.contains_key("USER") && !guest.contains_key("LOGNAME"),
1685            "a WASM guest is handed only what env.allow declares: {guest:?}"
1686        );
1687    }
1688
1689    #[test]
1690    fn host_account_with_both_names_set_reports_none_unset() {
1691        let account = HostAccountEnv::from_pairs(&[("USER", "alice"), ("LOGNAME", "alice-login")]);
1692        assert!(account.unset().is_empty());
1693        let mut env = BTreeMap::new();
1694        account.apply(&mut env);
1695        assert_eq!(env.get("USER").map(String::as_str), Some("alice"));
1696        assert_eq!(env.get("LOGNAME").map(String::as_str), Some("alice-login"));
1697    }
1698
1699    /// One name set never supplies the other.
1700    #[test]
1701    fn host_account_with_one_name_set_never_copies_it_to_the_other() {
1702        let account = HostAccountEnv::from_pairs(&[("USER", "alice")]);
1703        assert_eq!(account.unset(), ["LOGNAME".to_string()]);
1704        let mut env = BTreeMap::new();
1705        account.apply(&mut env);
1706        assert_eq!(env.get("USER").map(String::as_str), Some("alice"));
1707        assert!(!env.contains_key("LOGNAME"), "{env:?}");
1708    }
1709
1710    #[test]
1711    fn host_account_with_neither_name_set_reports_both_sorted() {
1712        let account = HostAccountEnv::from_pairs(&[]);
1713        assert_eq!(account.unset(), ["LOGNAME".to_string(), "USER".to_string()]);
1714        let mut env = BTreeMap::new();
1715        account.apply(&mut env);
1716        assert!(env.is_empty(), "{env:?}");
1717    }
1718
1719    /// Whatever an environment held under an account name is replaced by the host's value, or
1720    /// removed when the host has none.
1721    #[test]
1722    fn host_account_apply_replaces_a_stale_value() {
1723        let account = HostAccountEnv::from_pairs(&[("USER", "alice")]);
1724        let mut env = BTreeMap::from([
1725            ("USER".to_string(), "someone-else".to_string()),
1726            ("LOGNAME".to_string(), "someone-else".to_string()),
1727            ("PATH".to_string(), "/bin".to_string()),
1728        ]);
1729        account.apply(&mut env);
1730        assert_eq!(env.get("USER").map(String::as_str), Some("alice"));
1731        assert!(!env.contains_key("LOGNAME"), "{env:?}");
1732        assert_eq!(env.get("PATH").map(String::as_str), Some("/bin"));
1733    }
1734
1735    #[test]
1736    fn build_shell_env_developer_declared_strip_env_still_composes() {
1737        let temp = tempdir().unwrap();
1738        let policy = CapabilityPolicy {
1739            shell_strip_env: vec!["MYCOMPANY_*".to_string()],
1740            ..CapabilityPolicy::default()
1741        };
1742        let overrides = vec![("MYCOMPANY_SECRET".to_string(), "leaked".to_string())];
1743
1744        let env = build_shell_env(
1745            &policy,
1746            &overrides,
1747            &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
1748            None,
1749            None,
1750        )
1751        .unwrap();
1752
1753        assert!(!env.contains_key("MYCOMPANY_SECRET"));
1754    }
1755
1756    /// The prediction and the strip are one decision procedure, asserted over names that exercise
1757    /// every arm of the pattern grammar plus a manifest-supplied pattern. A disagreement here is a
1758    /// `W-SEC-024` line that tells an operator the opposite of what the guest env will hold.
1759    #[test]
1760    fn credential_backstop_drops_agrees_with_the_strip_it_predicts() {
1761        let extra = vec!["*_SERVICE_SECRET".to_string()];
1762        let names = [
1763            "ANTHROPIC_API_KEY",
1764            "GITHUB_TOKEN",
1765            "GITHUB_TOKEN_2",
1766            "AWS_ACCESS_KEY_ID",
1767            "MY_AWS_KEY",
1768            "STRIPE_API_KEY",
1769            "KUBECONFIG",
1770            "MY_SERVICE_SECRET",
1771            "DATABASE_PASSWORD",
1772            "SLACK_BOT_TOKEN",
1773            "HOME",
1774            "TZ",
1775        ];
1776
1777        let mut env: BTreeMap<String, String> = names
1778            .iter()
1779            .map(|name| ((*name).to_string(), "value".to_string()))
1780            .collect();
1781        strip_credential_shaped_vars(&mut env, &extra);
1782
1783        for name in names {
1784            assert_eq!(
1785                credential_backstop_drops(name, &extra),
1786                !env.contains_key(name),
1787                "prediction and strip disagree about {name}"
1788            );
1789            assert_eq!(
1790                credential_backstop_match(name, &extra).is_some(),
1791                !env.contains_key(name),
1792                "match and strip disagree about {name}"
1793            );
1794        }
1795    }
1796
1797    #[test]
1798    fn credential_backstop_match_attributes_a_builtin_pattern() {
1799        assert_eq!(
1800            credential_backstop_match("AWS_REGION", &[]),
1801            Some(BackstopMatch {
1802                pattern: "AWS_*".to_string(),
1803                source: BackstopPatternSource::Builtin,
1804            })
1805        );
1806        assert_eq!(
1807            credential_backstop_match("GITHUB_TOKEN", &[]),
1808            Some(BackstopMatch {
1809                pattern: "GITHUB_TOKEN".to_string(),
1810                source: BackstopPatternSource::Builtin,
1811            })
1812        );
1813    }
1814
1815    #[test]
1816    fn credential_backstop_match_attributes_a_strip_env_pattern() {
1817        assert_eq!(
1818            credential_backstop_match("MY_SERVICE_SECRET", &["*_SERVICE_SECRET".to_string()]),
1819            Some(BackstopMatch {
1820                pattern: "*_SERVICE_SECRET".to_string(),
1821                source: BackstopPatternSource::StripEnv,
1822            })
1823        );
1824    }
1825
1826    /// A name both lists cover is attributed to the built-in pattern, the one a manifest edit
1827    /// cannot remove.
1828    #[test]
1829    fn credential_backstop_match_prefers_the_builtin_pattern_when_both_match() {
1830        assert_eq!(
1831            credential_backstop_match("STRIPE_API_KEY", &["STRIPE_*".to_string()]),
1832            Some(BackstopMatch {
1833                pattern: "*_API_KEY".to_string(),
1834                source: BackstopPatternSource::Builtin,
1835            })
1836        );
1837    }
1838
1839    #[test]
1840    fn credential_backstop_match_is_none_for_a_name_the_backstop_keeps() {
1841        assert_eq!(credential_backstop_match("TZ", &[]), None);
1842        assert_eq!(
1843            credential_backstop_match("PRIVATE_KEY", &["*_SERVICE_SECRET".to_string()]),
1844            None
1845        );
1846    }
1847
1848    #[test]
1849    fn env_name_matches_pattern_trailing_wildcard() {
1850        assert!(env_name_matches_pattern("AWS_*", "AWS_ACCESS_KEY_ID"));
1851        assert!(!env_name_matches_pattern("AWS_*", "MY_AWS_KEY"));
1852    }
1853
1854    #[test]
1855    fn env_name_matches_pattern_leading_wildcard() {
1856        assert!(env_name_matches_pattern("*_API_KEY", "STRIPE_API_KEY"));
1857        assert!(!env_name_matches_pattern("*_API_KEY", "API_KEY_ID"));
1858    }
1859
1860    #[test]
1861    fn env_name_matches_pattern_exact_match() {
1862        assert!(env_name_matches_pattern("GITHUB_TOKEN", "GITHUB_TOKEN"));
1863        assert!(!env_name_matches_pattern("GITHUB_TOKEN", "GITHUB_TOKEN_2"));
1864    }
1865
1866    #[test]
1867    fn shell_tool_manifest_yaml_is_valid_yaml_for_interpreter() {
1868        let yaml = shell_tool_manifest_yaml("bash");
1869        serde_yaml::from_str::<serde_yaml::Value>(&yaml).expect("bash manifest must be valid YAML");
1870    }
1871
1872    #[test]
1873    fn shell_tool_manifest_yaml_is_valid_yaml_for_non_interpreter() {
1874        let yaml = shell_tool_manifest_yaml("curl");
1875        serde_yaml::from_str::<serde_yaml::Value>(&yaml)
1876            .expect("curl manifest must be valid YAML — embedded quotes break serde_yaml parsing");
1877    }
1878
1879    #[test]
1880    fn shell_tool_manifest_yaml_non_interpreter_has_tool_runtime() {
1881        let yaml = shell_tool_manifest_yaml("curl");
1882        let value: serde_yaml::Value = serde_yaml::from_str(&yaml).unwrap();
1883        assert_eq!(
1884            value.get("runtime").and_then(|v| v.as_str()),
1885            Some("tool"),
1886            "non-interpreter manifest must have runtime: tool so inventory picks it up"
1887        );
1888    }
1889
1890    /// A caller that stops waiting demotes its command at once, through the same path the grace
1891    /// period takes, rather than holding a blocking thread for the rest of the grace window.
1892    #[test]
1893    fn an_abandoned_command_demotes_before_its_grace() {
1894        use std::sync::Arc;
1895
1896        use crate::{
1897            detached::{AbandonSignal, DetachPolicy, DetachedRegistry},
1898            origin::{TaskOrigin, TaskProvenance},
1899        };
1900
1901        let temp = tempdir().unwrap();
1902        let policy = CapabilityPolicy {
1903            shell_allow: vec!["bash".to_string()],
1904            ..CapabilityPolicy::default()
1905        };
1906        let (registry, _reports) = DetachedRegistry::new();
1907        let abandon = AbandonSignal::new();
1908        let raiser = {
1909            let abandon = abandon.clone();
1910            std::thread::spawn(move || {
1911                std::thread::sleep(Duration::from_millis(200));
1912                abandon.raise();
1913            })
1914        };
1915
1916        let started = Instant::now();
1917        let outcome = run_shell(
1918            "bash",
1919            // Outlives the abandonment by seconds and then ends on its own, so nothing has to
1920            // kill it; without the demotion the call would return only when it exited.
1921            &["-c", "sleep 5"],
1922            &[],
1923            &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
1924            &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
1925            &policy,
1926            &ShellEnforcement::environment_only(),
1927            Some(DetachPolicy {
1928                grace: Duration::from_secs(60),
1929                registry: Arc::clone(&registry),
1930                command: "sleep 5".to_string(),
1931                context_id: "ctx_abandon_test".to_string(),
1932                provenance: Some(TaskProvenance::derive(TaskOrigin::User, None)),
1933                abandoned: Some(abandon),
1934            }),
1935        )
1936        .expect("a declared binary runs");
1937        let elapsed = started.elapsed();
1938        raiser.join().unwrap();
1939
1940        let ShellOutcome::Detached(info) = outcome else {
1941            panic!("an abandoned command must demote, got {outcome:?}");
1942        };
1943        assert!(
1944            elapsed < Duration::from_secs(2),
1945            "demotion waited {elapsed:?}, as if the 60 s grace still applied"
1946        );
1947        assert!(
1948            registry
1949                .outstanding()
1950                .iter()
1951                .any(|work| work.work_id == info.work_id),
1952            "the demoted command is registered like one that outran its grace"
1953        );
1954    }
1955
1956    #[test]
1957    fn a_shared_window_attributes_no_cgroup_delta() {
1958        assert_eq!(
1959            attribute_resource_limit(None, Some("cgroup_memory_bytes"), true).as_deref(),
1960            Some("cgroup_memory_bytes"),
1961            "a call alone in the scope owns its delta"
1962        );
1963        assert_eq!(
1964            attribute_resource_limit(None, Some("cgroup_memory_bytes"), false),
1965            None,
1966            "a delta from a shared window could be a sibling's"
1967        );
1968        assert_eq!(
1969            attribute_resource_limit(Some("cpu_seconds"), Some("cgroup_memory_bytes"), false)
1970                .as_deref(),
1971            Some("cpu_seconds"),
1972            "the signal is per-process evidence and needs no exclusive window"
1973        );
1974        assert_eq!(attribute_resource_limit(None, None, true), None);
1975    }
1976
1977    /// `MURMUR_RUNTIME_DIR` is runtime-owned like `HOME`: an override, a declared baseline name
1978    /// and a host value all lose to the session's own runtime directory, and with none there is
1979    /// no such variable.
1980    #[test]
1981    fn build_shell_env_sets_the_runtime_dir_last_whatever_else_says() {
1982        let workdir = tempfile::tempdir().unwrap();
1983        let root = WorkdirRoot::open(workdir.path()).unwrap();
1984        std::env::set_var(crate::runtime_dir::RUNTIME_DIR_ENV, "/host/value");
1985        let policy = CapabilityPolicy {
1986            shell_baseline_env: vec![crate::runtime_dir::RUNTIME_DIR_ENV.to_string()],
1987            env_allow: vec![crate::runtime_dir::RUNTIME_DIR_ENV.to_string()],
1988            ..CapabilityPolicy::default()
1989        };
1990        let overrides = [(
1991            crate::runtime_dir::RUNTIME_DIR_ENV.to_string(),
1992            "/override".to_string(),
1993        )];
1994
1995        let env = build_shell_env(
1996            &policy,
1997            &overrides,
1998            &root,
1999            Some(std::path::Path::new("/runtime/dir")),
2000            None,
2001        )
2002        .unwrap();
2003        assert_eq!(
2004            env.get(crate::runtime_dir::RUNTIME_DIR_ENV)
2005                .map(String::as_str),
2006            Some("/runtime/dir")
2007        );
2008
2009        let without = build_shell_env(&policy, &overrides, &root, None, None).unwrap();
2010        assert!(!without.contains_key(crate::runtime_dir::RUNTIME_DIR_ENV));
2011        assert!(!build_declared_env(&policy).contains_key(crate::runtime_dir::RUNTIME_DIR_ENV));
2012    }
2013
2014    /// `MURMUR_RECORDS_DIR` is runtime-owned on `MURMUR_RUNTIME_DIR`'s terms: an override, a
2015    /// baseline entry, `env.allow` and a host value all lose to the session's own records
2016    /// directory, and with none there is no such variable.
2017    #[test]
2018    fn build_shell_env_sets_the_records_dir_last_whatever_else_says() {
2019        let workdir = tempfile::tempdir().unwrap();
2020        let root = WorkdirRoot::open(workdir.path()).unwrap();
2021        std::env::set_var(crate::records_dir::RECORDS_DIR_ENV, "/host/records");
2022        let policy = CapabilityPolicy {
2023            shell_baseline_env: vec![crate::records_dir::RECORDS_DIR_ENV.to_string()],
2024            env_allow: vec![crate::records_dir::RECORDS_DIR_ENV.to_string()],
2025            ..CapabilityPolicy::default()
2026        };
2027        let overrides = [(
2028            crate::records_dir::RECORDS_DIR_ENV.to_string(),
2029            "/override".to_string(),
2030        )];
2031
2032        let env = build_shell_env(
2033            &policy,
2034            &overrides,
2035            &root,
2036            None,
2037            Some(std::path::Path::new("/records/dir")),
2038        )
2039        .unwrap();
2040        assert_eq!(
2041            env.get(crate::records_dir::RECORDS_DIR_ENV)
2042                .map(String::as_str),
2043            Some("/records/dir")
2044        );
2045
2046        let without = build_shell_env(&policy, &overrides, &root, None, None).unwrap();
2047        assert!(!without.contains_key(crate::records_dir::RECORDS_DIR_ENV));
2048        assert!(!build_declared_env(&policy).contains_key(crate::records_dir::RECORDS_DIR_ENV));
2049    }
2050}