Skip to main content

capsule_runtime/
shell.rs

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