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    // Snapshotted before the child runs so attribution keys on this call's delta rather than on
279    // a session-cumulative total an earlier call could have moved.
280    let cgroup_counters_before = enforcement
281        .cgroup_scope
282        .as_ref()
283        .map(|scope| scope.event_counters());
284
285    let mut child = match command.spawn() {
286        Ok(child) => child,
287        Err(error) => {
288            // A failure inside the `pre_exec` enforcement setup reaches us here only as a bare
289            // errno (std collapses a `pre_exec` `io::Error` to its `raw_os_error()`, defaulting to
290            // EINVAL). Drop `command` first so the parent's captured copy of the diagnostic pipe's
291            // write end closes (letting the read below see EOF), then fold any legible detail the
292            // child wrote into the returned error instead of the undifferentiated
293            // "Invalid argument (os error 22)".
294            drop(command);
295            return Err(match supervisor.read_diagnostic() {
296                // A composed-root failure keeps its own variant all the way out of here rather
297                // than being flattened into a message: it means a host that *did* clear the
298                // pre-launch sealed probe then failed to build the root, which is a different
299                // event from a Landlock or seccomp step failing, points somewhere else, and ends
300                // the session instead of the tool call. See [`ShellExecError::session_fatal`].
301                Some(detail) if detail.contains(crate::sealed::SEALED_ROOT_FAILURE_PREFIX) => {
302                    ShellExecError::SealedRootConstructionFailed { detail }
303                }
304                Some(detail) if !detail.is_empty() => ShellExecError::Failed(format!(
305                    "sandbox: shell enforcement setup failed before exec: {detail}"
306                )),
307                _ => ShellExecError::Failed(error.to_string()),
308            });
309        }
310    };
311
312    // `wait_with_output` reads both pipes and waits in one uninterruptible step, so it cannot be
313    // given a deadline. Draining each pipe on its own thread separates reading from waiting,
314    // which is what lets the wait below be either blocking or polled.
315    let readers = OutputReaders::spawn(&mut child);
316
317    let status = match &detach {
318        None => child.wait().map_err(|error| error.to_string())?,
319        Some(policy) => {
320            let deadline = started + policy.grace;
321            loop {
322                match child.try_wait().map_err(|error| error.to_string())? {
323                    Some(status) => break status,
324                    None => {
325                        let remaining = deadline.saturating_duration_since(Instant::now());
326                        if remaining.is_zero() {
327                            // A zero grace demotes here, at the first poll after the spawn.
328                            return Ok(ShellOutcome::Detached(demote(
329                                child,
330                                readers,
331                                supervisor,
332                                DemotionContext {
333                                    policy: detach.expect("the detach policy was matched above"),
334                                    accessible_workdir: accessible_workdir.to_path_buf(),
335                                    invoked_binary: binary.to_string(),
336                                    resolved_binary,
337                                    args: args.iter().map(|arg| (*arg).to_string()).collect(),
338                                    started,
339                                    cgroup_scope: enforcement.cgroup_scope.clone(),
340                                    cgroup_counters_before,
341                                },
342                            )));
343                        }
344                        std::thread::sleep(remaining.min(DETACH_POLL_INTERVAL));
345                    }
346                }
347            }
348        }
349    };
350    let (stdout_bytes, stderr_bytes) = readers.join();
351    supervisor.join_best_effort();
352    let duration_ms = started.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
353
354    let resource_limit_hit = classify_resource_limit(
355        &status,
356        enforcement.cgroup_scope.as_deref(),
357        cgroup_counters_before,
358    );
359    let exit_code = exit_code_of(&status);
360
361    let mut stdout = String::from_utf8_lossy(&stdout_bytes).to_string();
362    let mut stderr = String::from_utf8_lossy(&stderr_bytes).to_string();
363    let mut truncated = false;
364    let mut full_output_path = None;
365
366    if stdout_bytes.len() > MAX_SHELL_OUTPUT_BYTES || stderr_bytes.len() > MAX_SHELL_OUTPUT_BYTES {
367        truncated = true;
368        let stdout_cut = stdout_bytes.len().min(MAX_SHELL_OUTPUT_BYTES);
369        let stderr_cut = stderr_bytes.len().min(MAX_SHELL_OUTPUT_BYTES);
370        let full_stdout = std::mem::replace(
371            &mut stdout,
372            String::from_utf8_lossy(&stdout_bytes[..stdout_cut]).to_string(),
373        );
374        let full_stderr = std::mem::replace(
375            &mut stderr,
376            String::from_utf8_lossy(&stderr_bytes[..stderr_cut]).to_string(),
377        );
378        full_output_path = Some(write_shell_output_log(
379            accessible_workdir,
380            &format!("shell-{}", crate::trace::timestamp_ms()),
381            binary,
382            args,
383            exit_code,
384            &full_stdout,
385            &full_stderr,
386        )?);
387    }
388
389    Ok(ShellOutcome::Finished(ShellResult {
390        binary: resolved_binary,
391        exit_code,
392        stdout,
393        stderr,
394        duration_ms,
395        truncated,
396        full_output_path,
397        resource_limit_hit,
398    }))
399}
400
401/// [`run_shell`] with no demotion: the command runs to completion in the foreground.
402///
403/// Kept at its original signature and return type because most callers cannot take a completion
404/// and every one of them should keep reading exactly as it did.
405pub(crate) fn execute_shell(
406    binary: &str,
407    args: &[&str],
408    env_overrides: &[(String, String)],
409    accessible_workdir: &Path,
410    session_workdir: &Path,
411    policy: &CapabilityPolicy,
412    enforcement: &crate::sandbox::ShellEnforcement,
413) -> Result<ShellResult, ShellExecError> {
414    match run_shell(
415        binary,
416        args,
417        env_overrides,
418        accessible_workdir,
419        session_workdir,
420        policy,
421        enforcement,
422        None,
423    )? {
424        ShellOutcome::Finished(result) => Ok(result),
425        // Unreachable: demotion is reached only from the `Some(policy)` wait arm above. Reported
426        // rather than panicked, so a future edit that broke that invariant would fail one tool
427        // call instead of taking the session down.
428        ShellOutcome::Detached(info) => Err(ShellExecError::Failed(format!(
429            "shell command was demoted to the background as {} on a call site that cannot receive a completion",
430            info.work_id
431        ))),
432    }
433}
434
435/// The two pipe-draining threads for one child.
436///
437/// Both must be running before the child is waited for: a child writing more than a pipe buffer
438/// holds while nobody reads deadlocks, which is the reason `wait_with_output` reads and waits
439/// together in the first place.
440struct OutputReaders {
441    stdout: Option<std::thread::JoinHandle<Vec<u8>>>,
442    stderr: Option<std::thread::JoinHandle<Vec<u8>>>,
443}
444
445impl OutputReaders {
446    fn spawn(child: &mut std::process::Child) -> Self {
447        fn drain(
448            pipe: Option<impl std::io::Read + Send + 'static>,
449        ) -> Option<std::thread::JoinHandle<Vec<u8>>> {
450            let mut pipe = pipe?;
451            Some(std::thread::spawn(move || {
452                let mut buffer = Vec::new();
453                // A read error loses whatever was not yet read and keeps what was; there is no
454                // better outcome available, and failing the whole call over it would throw away
455                // the command's exit status too.
456                let _ = pipe.read_to_end(&mut buffer);
457                buffer
458            }))
459        }
460
461        Self {
462            stdout: drain(child.stdout.take()),
463            stderr: drain(child.stderr.take()),
464        }
465    }
466
467    /// `(stdout, stderr)`, waiting for both pipes to reach EOF. A reader thread that panicked
468    /// yields empty bytes rather than propagating.
469    fn join(self) -> (Vec<u8>, Vec<u8>) {
470        let take = |handle: Option<std::thread::JoinHandle<Vec<u8>>>| {
471            handle
472                .map(|handle| handle.join().unwrap_or_default())
473                .unwrap_or_default()
474        };
475        (take(self.stdout), take(self.stderr))
476    }
477}
478
479/// Everything the background thread needs to describe the command it is finishing.
480struct DemotionContext {
481    policy: DetachPolicy,
482    /// Where the command ran, and where its `logs/<work-id>.log` goes — the path the completion
483    /// reports back, workdir-relative.
484    accessible_workdir: PathBuf,
485    /// The name as invoked, which is what the log header records.
486    invoked_binary: String,
487    /// The same name resolved to a path where the host `PATH` named one — what the trace and the
488    /// completion report, matching [`ShellResult::binary`].
489    resolved_binary: String,
490    args: Vec<String>,
491    started: Instant,
492    cgroup_scope: Option<std::sync::Arc<crate::cgroup::CgroupScope>>,
493    cgroup_counters_before: Option<crate::cgroup::CgroupEventCounters>,
494}
495
496/// Hand a still-running command to a thread of its own and return the handle for it.
497///
498/// The work is registered before this returns, so the session-end sweep can never miss work that
499/// finished being registered after it looked.
500fn demote(
501    mut child: std::process::Child,
502    readers: OutputReaders,
503    supervisor: crate::sandbox::SupervisorHandle,
504    context: DemotionContext,
505) -> DetachedDispatchInfo {
506    let work_id = crate::detached::new_work_id();
507    let info = DetachedDispatchInfo {
508        work_id: work_id.clone(),
509        binary: context.resolved_binary.clone(),
510        command: context.policy.command.clone(),
511        grace_ms: context
512            .policy
513            .grace
514            .as_millis()
515            .try_into()
516            .unwrap_or(u64::MAX),
517    };
518
519    context
520        .policy
521        .registry
522        .register(crate::detached::DetachedWork {
523            work_id: work_id.clone(),
524            binary: context.resolved_binary.clone(),
525            command: context.policy.command.clone(),
526            // The spawn instant, not this one. Demotion happens a whole grace period after the
527            // command started, and every reader of this field — the cancel residue, the
528            // session-end sweep's `running_ms` — reports it as how long the command has been
529            // running, which must include the foreground portion to match the `duration_ms` a
530            // completion carries.
531            started_at_ms: crate::trace::timestamp_ms().saturating_sub(
532                context
533                    .started
534                    .elapsed()
535                    .as_millis()
536                    .try_into()
537                    .unwrap_or(u64::MAX),
538            ),
539        });
540
541    let provenance = context.policy.completion_provenance();
542    let DemotionContext {
543        policy,
544        accessible_workdir,
545        invoked_binary,
546        resolved_binary,
547        args,
548        started,
549        cgroup_scope,
550        cgroup_counters_before,
551    } = context;
552
553    std::thread::spawn(move || {
554        let waited = child.wait();
555        let (stdout_bytes, stderr_bytes) = readers.join();
556        // This command's own egress proxy, torn down with the command it served. Each
557        // `prepare_enforcement` call starts its own, so a foreground command finishing in the
558        // meantime cannot have taken this one down.
559        supervisor.join_best_effort();
560        let duration_ms = started.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
561
562        let (exit_code, resource_limit, mut error) = match waited {
563            Ok(status) => (
564                exit_code_of(&status),
565                classify_resource_limit(&status, cgroup_scope.as_deref(), cgroup_counters_before),
566                None,
567            ),
568            Err(failure) => (
569                -1,
570                None,
571                Some(format!("waiting for the command failed: {failure}")),
572            ),
573        };
574
575        let stdout = String::from_utf8_lossy(&stdout_bytes).into_owned();
576        let stderr = String::from_utf8_lossy(&stderr_bytes).into_owned();
577        let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
578        let output_path = crate::detached::output_path_for(&work_id);
579        if let Err(failure) = write_shell_output_log(
580            &accessible_workdir,
581            &work_id,
582            &invoked_binary,
583            &arg_refs,
584            exit_code,
585            &stdout,
586            &stderr,
587        ) {
588            error = Some(match error {
589                Some(existing) => format!("{existing}; {failure}"),
590                None => failure,
591            });
592        }
593        let output_bytes = std::fs::metadata(accessible_workdir.join(&output_path))
594            .map(|metadata| metadata.len())
595            .unwrap_or(0);
596
597        policy
598            .registry
599            .complete(crate::detached::DetachedCompletion {
600                work_id,
601                binary: resolved_binary,
602                command: policy.command.clone(),
603                exit_code,
604                duration_ms,
605                output_path,
606                output_bytes,
607                resource_limit,
608                context_id: policy.context_id.clone(),
609                provenance,
610                error,
611            });
612    });
613
614    info
615}
616
617/// Exit code for a finished subprocess, keeping the signal that killed it legible.
618///
619/// `ExitStatus::code()` returns `None` for every signal-killed process on Unix, so a bare
620/// `.unwrap_or(-1)` collapses a `SIGXCPU` from `RLIMIT_CPU`, a cgroup OOM `SIGKILL` and an
621/// unrelated crash into one indistinguishable `-1`. `128 + signal` is the long-standing shell
622/// convention for exactly this case and keeps the cause readable in the trace even where
623/// [`classify_resource_limit`] declines to name a limit.
624fn exit_code_of(status: &std::process::ExitStatus) -> i32 {
625    use std::os::unix::process::ExitStatusExt;
626
627    status
628        .code()
629        .or_else(|| status.signal().map(|signal| 128 + signal))
630        .unwrap_or(-1)
631}
632
633/// Name the `capabilities.resources` field a subprocess was killed for exceeding, or `None`.
634///
635/// Two independent sources of evidence, both kernel-maintained facts rather than inference:
636///
637///   * the kill signal, for the two rlimits that raise a signal unique to themselves
638///     (`SIGXCPU` → `cpu_seconds`, `SIGXFSZ` → `max_file_size_bytes`);
639///   * this call's delta on the cgroup scope's `memory.events`/`pids.events` counters
640///     (`oom_kill` → `cgroup_memory_bytes`, `max` → `cgroup_pids_max`).
641///
642/// The signal is checked first: where both fire, it identifies the individual process that died,
643/// which is the more specific claim.
644///
645/// The `pids.max` case is reported even when the process exited normally, because that is how it
646/// presents — `pids.max` refuses a `fork()` rather than killing anything, so a fork bomb held by
647/// the cgroup usually shows up as a shell exiting non-zero with `EAGAIN` on stderr. Without the
648/// counter there would be nothing distinguishing it from any other failure.
649///
650/// Nothing else is attributed. See [`crate::resources::limit_from_signal`] for why
651/// `RLIMIT_AS`/`RLIMIT_DATA`, `RLIMIT_NPROC` and `RLIMIT_NOFILE` are deliberately left
652/// unattributed rather than guessed at.
653fn classify_resource_limit(
654    status: &std::process::ExitStatus,
655    cgroup_scope: Option<&crate::cgroup::CgroupScope>,
656    counters_before: Option<crate::cgroup::CgroupEventCounters>,
657) -> Option<String> {
658    use std::os::unix::process::ExitStatusExt;
659
660    if let Some(limit) = status
661        .signal()
662        .and_then(crate::resources::limit_from_signal)
663    {
664        return Some(limit.to_string());
665    }
666
667    let (scope, before) = (cgroup_scope?, counters_before?);
668    scope
669        .event_counters()
670        .attribution_since(before)
671        .map(str::to_string)
672}
673
674pub(crate) fn build_shell_env(
675    policy: &CapabilityPolicy,
676    env_overrides: &[(String, String)],
677    session_workdir: &Path,
678) -> Result<BTreeMap<String, String>, String> {
679    let mut env = BTreeMap::new();
680
681    for key in DEFAULT_ENV_BASELINE {
682        if let Ok(value) = std::env::var(key) {
683            env.insert((*key).to_string(), value);
684        }
685    }
686
687    for (key, value) in env_overrides {
688        env.insert(key.clone(), value.clone());
689    }
690
691    for key in &policy.shell_baseline_env {
692        if let Ok(value) = std::env::var(key) {
693            env.insert(key.clone(), value);
694        }
695    }
696
697    strip_credential_shaped_vars(&mut env, &policy.shell_strip_env);
698
699    // Inserted last so neither a guest-supplied `env_overrides` entry nor a
700    // manifest-declared `shell_baseline_env` entry can resurrect the real host
701    // HOME/USERPROFILE, and so no strip_env pattern can remove the synthetic one.
702    let synthetic_home = synthetic_home_dir(session_workdir)?;
703    let synthetic_home = synthetic_home.to_string_lossy().into_owned();
704    env.insert("HOME".to_string(), synthetic_home.clone());
705    env.insert("USERPROFILE".to_string(), synthetic_home);
706
707    Ok(env)
708}
709
710/// Drop every entry whose name matches [`CREDENTIAL_ENV_PATTERNS`] or one of
711/// `extra_patterns` (a policy's `shell_strip_env`).
712///
713/// This is the single credential backstop for both environments the runtime builds: the
714/// native subprocess env ([`build_shell_env`]) and the WASI guest env
715/// ([`build_declared_env`]). It runs *after* whatever allowlist populated `env`, so a
716/// declared name never bypasses the filter.
717pub(crate) fn strip_credential_shaped_vars(
718    env: &mut BTreeMap<String, String>,
719    extra_patterns: &[String],
720) {
721    env.retain(|key, _| !credential_backstop_drops(key, extra_patterns));
722}
723
724/// Which list a credential backstop pattern came from.
725#[derive(Debug, Clone, PartialEq, Eq)]
726pub enum BackstopPatternSource {
727    /// [`CREDENTIAL_ENV_PATTERNS`], the list no manifest can change.
728    Builtin,
729    /// The manifest's own `capabilities.shell.strip_env`.
730    StripEnv,
731}
732
733/// The pattern that makes the credential backstop drop a name, verbatim, and where it came from.
734#[derive(Debug, Clone, PartialEq, Eq)]
735pub struct BackstopMatch {
736    pub pattern: String,
737    pub source: BackstopPatternSource,
738}
739
740/// The first credential backstop pattern that matches `name`, or `None` when the backstop keeps
741/// it — the one decision procedure behind [`strip_credential_shaped_vars`],
742/// [`credential_backstop_drops`], `W-SEC-024` and the `E-CAP-016` staging refusal.
743///
744/// [`CREDENTIAL_ENV_PATTERNS`] is tried before `extra_patterns`, so a name both lists cover is
745/// attributed to the built-in pattern: that is the one no manifest edit can remove.
746/// `extra_patterns` is a policy's `shell_strip_env`. Judges a name alone and never reads the host
747/// environment.
748pub fn credential_backstop_match(name: &str, extra_patterns: &[String]) -> Option<BackstopMatch> {
749    CREDENTIAL_ENV_PATTERNS
750        .iter()
751        .map(|pattern| (*pattern, BackstopPatternSource::Builtin))
752        .chain(
753            extra_patterns
754                .iter()
755                .map(|pattern| (pattern.as_str(), BackstopPatternSource::StripEnv)),
756        )
757        .find(|(pattern, _)| env_name_matches_pattern(pattern, name))
758        .map(|(pattern, source)| BackstopMatch {
759            pattern: pattern.to_string(),
760            source,
761        })
762}
763
764/// Whether the credential backstop would drop a variable of this name — the same question
765/// [`strip_credential_shaped_vars`] answers per entry, asked of a name alone. See
766/// [`credential_backstop_match`] for which pattern decided it.
767pub fn credential_backstop_drops(name: &str, extra_patterns: &[String]) -> bool {
768    credential_backstop_match(name, extra_patterns).is_some()
769}
770
771/// Resolve the host variables the manifest declared: only names in `capabilities.env.allow` that
772/// the host actually has set, minus anything credential-shaped. A declared name absent from the
773/// host is simply omitted, not an error.
774///
775/// This is what every WASM guest observes, and what a `transport: process` harness starts from
776/// before its driver's `env-set` is applied. Native shell subprocesses are the exception: they
777/// run on [`DEFAULT_ENV_BASELINE`] plus `capabilities.shell.baseline_env`, built by
778/// [`build_shell_env`].
779///
780/// [`ARTIFACT_CONFIG_ENV`] is reserved and never resolved from the host, whatever a manifest
781/// allowlists: the name is runtime-owned, and its value comes from the declaring artifact's own
782/// `config:` block or from nowhere. Skipped here rather than relied on being overwritten later,
783/// so a host value cannot reach a guest whose entry declared no config at all.
784pub(crate) fn build_declared_env(policy: &CapabilityPolicy) -> BTreeMap<String, String> {
785    let mut env = BTreeMap::new();
786
787    for key in &policy.env_allow {
788        if key == ARTIFACT_CONFIG_ENV {
789            continue;
790        }
791        if let Ok(value) = std::env::var(key) {
792            env.insert(key.clone(), value);
793        }
794    }
795
796    strip_credential_shaped_vars(&mut env, &policy.shell_strip_env);
797
798    env
799}
800
801fn synthetic_home_dir(session_workdir: &Path) -> Result<std::path::PathBuf, String> {
802    let home = session_workdir.join(SYNTHETIC_HOME_DIR_NAME);
803    fs::create_dir_all(&home).map_err(|error| {
804        format!(
805            "failed to create synthetic home directory {}: {error}",
806            home.display()
807        )
808    })?;
809    Ok(home)
810}
811
812fn env_name_matches_pattern(pattern: &str, key: &str) -> bool {
813    if let Some(rest) = pattern.strip_prefix('*') {
814        if let Some(middle) = rest.strip_suffix('*') {
815            return key.contains(middle);
816        }
817        return key.ends_with(rest);
818    }
819
820    if let Some(prefix) = pattern.strip_suffix('*') {
821        return key.starts_with(prefix);
822    }
823
824    pattern == key
825}
826
827/// Write one command's full stdout and stderr to `logs/<file_stem>.log`, returning the
828/// workdir-relative path.
829///
830/// Two callers, one content format: a foreground command whose output exceeded
831/// [`MAX_SHELL_OUTPUT_BYTES`] passes a `shell-<ms>` stem, and a demoted command passes its work
832/// id. A reader that finds either file reads the same header, `Stdout:` and `Stderr:` sections.
833fn write_shell_output_log(
834    accessible_workdir: &Path,
835    file_stem: &str,
836    binary: &str,
837    args: &[&str],
838    exit_code: i32,
839    stdout: &str,
840    stderr: &str,
841) -> Result<String, String> {
842    let logs_dir = accessible_workdir.join("logs");
843    fs::create_dir_all(&logs_dir)
844        .map_err(|error| format!("failed to create shell logs directory: {error}"))?;
845
846    let filename = format!("{file_stem}.log");
847    let relative_path = format!("logs/{filename}");
848    let path = logs_dir.join(&filename);
849
850    let command = if args.is_empty() {
851        binary.to_string()
852    } else {
853        format!("{binary} {}", args.join(" "))
854    };
855
856    let content = format!(
857        "Command: {command}\nExit code: {exit_code}\n\nStdout:\n{stdout}\n\nStderr:\n{stderr}\n"
858    );
859
860    fs::write(&path, content)
861        .map_err(|error| format!("failed to write shell log {}: {error}", path.display()))?;
862
863    Ok(relative_path)
864}
865
866#[cfg(test)]
867mod tests {
868    use tempfile::tempdir;
869
870    use super::*;
871    use crate::sandbox::ShellEnforcement;
872
873    /// The `pre_exec` rlimit plumbing, end to end on whatever platform runs the suite: a
874    /// declared `capabilities.resources.max_open_files` must reach the child as its **hard**
875    /// ceiling, which is what `ulimit -Hn` reports. This asserts the mechanism (the value was
876    /// applied to `rlim_max`, not only `rlim_cur`), not a security outcome — the hostile-capsule
877    /// scenarios require real Linux hardware.
878    #[test]
879    fn execute_shell_applies_the_declared_limit_as_a_hard_ceiling() {
880        let temp = tempdir().unwrap();
881        let policy = CapabilityPolicy {
882            shell_allow: vec!["bash".to_string()],
883            resources: crate::resources::HostResourceLimits {
884                max_open_files: 64,
885                ..crate::resources::HostResourceLimits::default()
886            },
887            ..CapabilityPolicy::default()
888        };
889        let mut enforcement = ShellEnforcement::environment_only();
890        enforcement.resource_limits = policy.resources;
891
892        let result = execute_shell(
893            "bash",
894            &["-c", "ulimit -Hn"],
895            &[],
896            temp.path(),
897            temp.path(),
898            &policy,
899            &enforcement,
900        )
901        .expect("bash must run");
902
903        assert_eq!(result.stdout.trim(), "64", "stderr was: {}", result.stderr);
904        assert_eq!(result.resource_limit_hit, None);
905    }
906
907    /// Under `--workdir` the two workdirs are different directories, and each of the three things
908    /// `execute_shell` does with one has to pick the right one: the subprocess runs in the
909    /// accessible workdir, `$HOME` resolves into the session workdir, and the truncated-output log
910    /// stays where the tool result says it is.
911    ///
912    /// Asserted through a real subprocess rather than through `build_shell_env` alone, because
913    /// `$HOME` naming a directory that was never created is exactly the failure this split can
914    /// produce and a pure env check would not see.
915    #[test]
916    fn a_shell_command_runs_in_the_accessible_workdir_with_home_inside_the_session_one() {
917        let accessible = tempdir().unwrap();
918        let session = accessible.path().join(".murmur").join("ses_shell_test");
919        fs::create_dir_all(&session).unwrap();
920        let policy = CapabilityPolicy {
921            shell_allow: vec!["bash".to_string()],
922            ..CapabilityPolicy::default()
923        };
924
925        let result = execute_shell(
926            "bash",
927            &[
928                "-c",
929                "echo marker > \"$HOME/probe\" && cat \"$HOME/probe\" && pwd",
930            ],
931            &[],
932            accessible.path(),
933            &session,
934            &policy,
935            &ShellEnforcement::environment_only(),
936        )
937        .expect("bash must run");
938
939        assert_eq!(result.exit_code, 0, "stderr was: {}", result.stderr);
940        let mut lines = result.stdout.lines();
941        assert_eq!(lines.next(), Some("marker"));
942        assert_eq!(
943            std::fs::canonicalize(lines.next().unwrap()).unwrap(),
944            std::fs::canonicalize(accessible.path()).unwrap(),
945            "the subprocess cwd is the directory the capsule was asked to produce"
946        );
947
948        assert_eq!(
949            fs::read_to_string(session.join(SYNTHETIC_HOME_DIR_NAME).join("probe")).unwrap(),
950            "marker\n"
951        );
952        assert!(
953            !accessible.path().join(SYNTHETIC_HOME_DIR_NAME).exists(),
954            "the synthetic home must add nothing to the top level of the accessible workdir"
955        );
956    }
957
958    /// A subprocess that exits normally must never be reported as limit-killed — the negative
959    /// half of attribution, and the one that keeps `resource_limit_hit` meaningful in the trace.
960    #[test]
961    fn execute_shell_reports_no_resource_limit_for_an_ordinary_exit() {
962        let temp = tempdir().unwrap();
963        let policy = CapabilityPolicy {
964            shell_allow: vec!["bash".to_string()],
965            ..CapabilityPolicy::default()
966        };
967
968        let result = execute_shell(
969            "bash",
970            &["-c", "exit 3"],
971            &[],
972            temp.path(),
973            temp.path(),
974            &policy,
975            &ShellEnforcement::environment_only(),
976        )
977        .expect("bash must run");
978
979        assert_eq!(result.exit_code, 3);
980        assert_eq!(result.resource_limit_hit, None);
981    }
982
983    #[test]
984    fn execute_shell_blocks_binary_not_in_allowlist() {
985        let policy = CapabilityPolicy::default();
986        let error = execute_shell(
987            "bash",
988            &["-c", "echo hi"],
989            &[],
990            Path::new("."),
991            Path::new("."),
992            &policy,
993            &ShellEnforcement::environment_only(),
994        )
995        .unwrap_err();
996
997        assert!(error.to_string().contains("capabilities.shell.allow"));
998    }
999
1000    #[test]
1001    fn execute_shell_returns_nonzero_exit_code_without_error() {
1002        let temp = tempdir().unwrap();
1003        let policy = CapabilityPolicy {
1004            shell_allow: vec!["bash".to_string()],
1005            ..CapabilityPolicy::default()
1006        };
1007
1008        let result = execute_shell(
1009            "bash",
1010            &["-c", "exit 42"],
1011            &[],
1012            temp.path(),
1013            temp.path(),
1014            &policy,
1015            &ShellEnforcement::environment_only(),
1016        )
1017        .unwrap();
1018        assert_eq!(result.exit_code, 42);
1019    }
1020
1021    /// A real subprocess run through the real `execute_shell`: the returned `binary` is the
1022    /// canonical absolute path of the interpreter that ran, not the bare invoked name — the
1023    /// value that reaches `shell-event.binary` and `trace.jsonl`'s `binary` key.
1024    #[test]
1025    fn execute_shell_reports_the_resolved_path_of_the_invoked_binary() {
1026        let temp = tempdir().unwrap();
1027        let policy = CapabilityPolicy {
1028            shell_allow: vec!["bash".to_string()],
1029            ..CapabilityPolicy::default()
1030        };
1031
1032        let result = execute_shell(
1033            "bash",
1034            &["-c", "exit 0"],
1035            &[],
1036            temp.path(),
1037            temp.path(),
1038            &policy,
1039            &ShellEnforcement::environment_only(),
1040        )
1041        .unwrap();
1042
1043        assert!(
1044            Path::new(&result.binary).is_absolute(),
1045            "a binary found on PATH must be reported as an absolute path, got {:?}",
1046            result.binary
1047        );
1048        assert!(
1049            result.binary.ends_with("bash"),
1050            "the reported path must still name bash, got {:?}",
1051            result.binary
1052        );
1053    }
1054
1055    /// The fallback, exercised end-to-end rather than only against the resolver: a workdir
1056    /// -relative program spawns fine (the child resolves it after `chdir`) but has no host
1057    /// `PATH` entry to canonicalize against, so `binary` degrades to the invoked name
1058    /// unchanged. Nothing else about the call changes — it still runs and still reports its
1059    /// exit code.
1060    #[test]
1061    fn execute_shell_falls_back_to_the_bare_name_when_nothing_resolves() {
1062        let temp = tempdir().unwrap();
1063        let tool = temp.path().join("local-tool");
1064        fs::write(&tool, "#!/bin/sh\nexit 7\n").unwrap();
1065        let mut perms = fs::metadata(&tool).unwrap().permissions();
1066        std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o755);
1067        fs::set_permissions(&tool, perms).unwrap();
1068
1069        let policy = CapabilityPolicy {
1070            shell_allow: vec!["./local-tool".to_string()],
1071            ..CapabilityPolicy::default()
1072        };
1073
1074        let result = execute_shell(
1075            "./local-tool",
1076            &[],
1077            &[],
1078            temp.path(),
1079            temp.path(),
1080            &policy,
1081            &ShellEnforcement::environment_only(),
1082        )
1083        .unwrap();
1084
1085        assert_eq!(result.exit_code, 7, "the subprocess must still have run");
1086        assert_eq!(
1087            result.binary, "./local-tool",
1088            "an unresolvable name degrades to the bare invoked name, never an error"
1089        );
1090    }
1091
1092    #[test]
1093    fn build_shell_env_sets_synthetic_home_under_workdir() {
1094        let temp = tempdir().unwrap();
1095        let policy = CapabilityPolicy::default();
1096
1097        let env = build_shell_env(&policy, &[], temp.path()).unwrap();
1098
1099        let expected_home = temp.path().join(".capsule-home");
1100        assert_eq!(
1101            env.get("HOME"),
1102            Some(&expected_home.to_string_lossy().into_owned())
1103        );
1104        assert_eq!(
1105            env.get("USERPROFILE"),
1106            Some(&expected_home.to_string_lossy().into_owned())
1107        );
1108        assert!(expected_home.is_dir());
1109    }
1110
1111    #[test]
1112    fn build_shell_env_reports_synthetic_home_creation_failure() {
1113        let temp = tempdir().unwrap();
1114        let blocking_file = temp.path().join("blocked");
1115        std::fs::write(&blocking_file, b"not a directory").unwrap();
1116        let workdir = blocking_file.join("subdir");
1117        let policy = CapabilityPolicy::default();
1118
1119        let error = build_shell_env(&policy, &[], &workdir).unwrap_err();
1120
1121        let expected_home = workdir.join(".capsule-home");
1122        assert!(error.contains(&expected_home.to_string_lossy().into_owned()));
1123    }
1124
1125    #[test]
1126    fn execute_shell_does_not_spawn_when_synthetic_home_creation_fails() {
1127        let temp = tempdir().unwrap();
1128        let blocking_file = temp.path().join("blocked");
1129        std::fs::write(&blocking_file, b"not a directory").unwrap();
1130        let workdir = blocking_file.join("subdir");
1131        let policy = CapabilityPolicy {
1132            shell_allow: vec!["bash".to_string()],
1133            ..CapabilityPolicy::default()
1134        };
1135
1136        let error = execute_shell(
1137            "bash",
1138            &["-c", "echo should-not-run"],
1139            &[],
1140            &workdir,
1141            &workdir,
1142            &policy,
1143            &ShellEnforcement::environment_only(),
1144        )
1145        .unwrap_err();
1146
1147        assert!(error.to_string().contains(".capsule-home"));
1148    }
1149
1150    #[test]
1151    fn execute_shell_reports_synthetic_home_not_real_host_home() {
1152        let temp = tempdir().unwrap();
1153        let policy = CapabilityPolicy {
1154            shell_allow: vec!["bash".to_string()],
1155            ..CapabilityPolicy::default()
1156        };
1157
1158        let result = execute_shell(
1159            "bash",
1160            &["-c", "echo -n $HOME"],
1161            &[],
1162            temp.path(),
1163            temp.path(),
1164            &policy,
1165            &ShellEnforcement::environment_only(),
1166        )
1167        .unwrap();
1168
1169        let expected_home = temp.path().join(".capsule-home");
1170        assert_eq!(result.stdout, expected_home.to_string_lossy());
1171        if let Ok(real_home) = std::env::var("HOME") {
1172            assert_ne!(result.stdout, real_home);
1173        }
1174    }
1175
1176    #[test]
1177    fn build_shell_env_strips_wildcard_credential_patterns_from_overrides() {
1178        let temp = tempdir().unwrap();
1179        let policy = CapabilityPolicy::default();
1180        let overrides = vec![
1181            ("AWS_ACCESS_KEY_ID".to_string(), "leaked".to_string()),
1182            ("DOCKER_AUTH_CONFIG".to_string(), "leaked".to_string()),
1183            ("STRIPE_API_KEY".to_string(), "leaked".to_string()),
1184            ("GITHUB_TOKEN".to_string(), "leaked".to_string()),
1185            ("SAFE_VAR".to_string(), "kept".to_string()),
1186        ];
1187
1188        let env = build_shell_env(&policy, &overrides, temp.path()).unwrap();
1189
1190        assert!(!env.contains_key("AWS_ACCESS_KEY_ID"));
1191        assert!(!env.contains_key("DOCKER_AUTH_CONFIG"));
1192        assert!(!env.contains_key("STRIPE_API_KEY"));
1193        assert!(!env.contains_key("GITHUB_TOKEN"));
1194        assert_eq!(env.get("SAFE_VAR"), Some(&"kept".to_string()));
1195    }
1196
1197    #[test]
1198    fn declared_env_is_empty_without_declarations() {
1199        std::env::set_var("MURMUR_TEST_WASI_UNDECLARED", "host-value");
1200        let policy = CapabilityPolicy::default();
1201
1202        let env = build_declared_env(&policy);
1203
1204        assert!(env.is_empty());
1205    }
1206
1207    #[test]
1208    fn declared_env_passes_through_declared_host_var() {
1209        std::env::set_var("MURMUR_TEST_WASI_ALLOWED", "host-value");
1210        let policy = CapabilityPolicy {
1211            env_allow: vec![
1212                "MURMUR_TEST_WASI_ALLOWED".to_string(),
1213                // Declared but unset on the host: omitted, not an error.
1214                "MURMUR_TEST_WASI_NEVER_SET".to_string(),
1215            ],
1216            ..CapabilityPolicy::default()
1217        };
1218
1219        let env = build_declared_env(&policy);
1220
1221        assert_eq!(
1222            env.get("MURMUR_TEST_WASI_ALLOWED"),
1223            Some(&"host-value".to_string())
1224        );
1225        assert!(!env.contains_key("MURMUR_TEST_WASI_NEVER_SET"));
1226    }
1227
1228    #[test]
1229    fn declared_env_strips_credential_shaped_declarations() {
1230        std::env::set_var("GITHUB_TOKEN", "leaked-token");
1231        std::env::set_var("STRIPE_API_KEY", "leaked-key");
1232        std::env::set_var("MURMUR_TEST_WASI_CUSTOM_SECRET", "leaked-secret");
1233        let policy = CapabilityPolicy {
1234            env_allow: vec![
1235                "GITHUB_TOKEN".to_string(),
1236                "STRIPE_API_KEY".to_string(),
1237                "MURMUR_TEST_WASI_CUSTOM_SECRET".to_string(),
1238            ],
1239            shell_strip_env: vec!["*_CUSTOM_SECRET".to_string()],
1240            ..CapabilityPolicy::default()
1241        };
1242
1243        let env = build_declared_env(&policy);
1244
1245        // Declaring a credential-shaped name does not bypass the backstop.
1246        assert!(env.is_empty(), "expected all names stripped, got {env:?}");
1247    }
1248
1249    /// A credential-shaped name outside every backstop pattern is delivered like any other
1250    /// declared name: `W-SEC-024` reports that grant, and nothing withholds it.
1251    #[test]
1252    fn declared_env_delivers_a_credential_shaped_name_the_backstop_keeps() {
1253        std::env::set_var("PRIVATE_KEY", "host-private-key");
1254        std::env::set_var("MURMUR_TEST_WASI_PLAIN", "host-plain");
1255        let policy = CapabilityPolicy {
1256            env_allow: vec![
1257                "PRIVATE_KEY".to_string(),
1258                "MURMUR_TEST_WASI_PLAIN".to_string(),
1259            ],
1260            ..CapabilityPolicy::default()
1261        };
1262
1263        let env = build_declared_env(&policy);
1264
1265        assert_eq!(
1266            env.get("PRIVATE_KEY"),
1267            Some(&"host-private-key".to_string())
1268        );
1269        assert_eq!(
1270            env.get("MURMUR_TEST_WASI_PLAIN"),
1271            Some(&"host-plain".to_string())
1272        );
1273    }
1274
1275    #[test]
1276    fn build_shell_env_home_override_cannot_survive() {
1277        let temp = tempdir().unwrap();
1278        let policy = CapabilityPolicy {
1279            shell_baseline_env: vec!["HOME".to_string()],
1280            ..CapabilityPolicy::default()
1281        };
1282        let overrides = vec![("HOME".to_string(), "/tmp/guest-controlled".to_string())];
1283
1284        let env = build_shell_env(&policy, &overrides, temp.path()).unwrap();
1285
1286        let expected_home = temp.path().join(".capsule-home");
1287        assert_eq!(
1288            env.get("HOME"),
1289            Some(&expected_home.to_string_lossy().into_owned())
1290        );
1291    }
1292
1293    #[test]
1294    fn build_shell_env_keeps_safe_baseline_vars() {
1295        let temp = tempdir().unwrap();
1296        let policy = CapabilityPolicy::default();
1297
1298        std::env::set_var("CARGO_HOME", "/fake/cargo/home");
1299        let env = build_shell_env(&policy, &[], temp.path()).unwrap();
1300        std::env::remove_var("CARGO_HOME");
1301
1302        assert_eq!(env.get("CARGO_HOME"), Some(&"/fake/cargo/home".to_string()));
1303    }
1304
1305    #[test]
1306    fn build_shell_env_developer_declared_strip_env_still_composes() {
1307        let temp = tempdir().unwrap();
1308        let policy = CapabilityPolicy {
1309            shell_strip_env: vec!["MYCOMPANY_*".to_string()],
1310            ..CapabilityPolicy::default()
1311        };
1312        let overrides = vec![("MYCOMPANY_SECRET".to_string(), "leaked".to_string())];
1313
1314        let env = build_shell_env(&policy, &overrides, temp.path()).unwrap();
1315
1316        assert!(!env.contains_key("MYCOMPANY_SECRET"));
1317    }
1318
1319    /// The prediction and the strip are one decision procedure, asserted over names that exercise
1320    /// every arm of the pattern grammar plus a manifest-supplied pattern. A disagreement here is a
1321    /// `W-SEC-024` line that tells an operator the opposite of what the guest env will hold.
1322    #[test]
1323    fn credential_backstop_drops_agrees_with_the_strip_it_predicts() {
1324        let extra = vec!["*_SERVICE_SECRET".to_string()];
1325        let names = [
1326            "ANTHROPIC_API_KEY",
1327            "GITHUB_TOKEN",
1328            "GITHUB_TOKEN_2",
1329            "AWS_ACCESS_KEY_ID",
1330            "MY_AWS_KEY",
1331            "STRIPE_API_KEY",
1332            "KUBECONFIG",
1333            "MY_SERVICE_SECRET",
1334            "DATABASE_PASSWORD",
1335            "SLACK_BOT_TOKEN",
1336            "HOME",
1337            "TZ",
1338        ];
1339
1340        let mut env: BTreeMap<String, String> = names
1341            .iter()
1342            .map(|name| ((*name).to_string(), "value".to_string()))
1343            .collect();
1344        strip_credential_shaped_vars(&mut env, &extra);
1345
1346        for name in names {
1347            assert_eq!(
1348                credential_backstop_drops(name, &extra),
1349                !env.contains_key(name),
1350                "prediction and strip disagree about {name}"
1351            );
1352            assert_eq!(
1353                credential_backstop_match(name, &extra).is_some(),
1354                !env.contains_key(name),
1355                "match and strip disagree about {name}"
1356            );
1357        }
1358    }
1359
1360    #[test]
1361    fn credential_backstop_match_attributes_a_builtin_pattern() {
1362        assert_eq!(
1363            credential_backstop_match("AWS_REGION", &[]),
1364            Some(BackstopMatch {
1365                pattern: "AWS_*".to_string(),
1366                source: BackstopPatternSource::Builtin,
1367            })
1368        );
1369        assert_eq!(
1370            credential_backstop_match("GITHUB_TOKEN", &[]),
1371            Some(BackstopMatch {
1372                pattern: "GITHUB_TOKEN".to_string(),
1373                source: BackstopPatternSource::Builtin,
1374            })
1375        );
1376    }
1377
1378    #[test]
1379    fn credential_backstop_match_attributes_a_strip_env_pattern() {
1380        assert_eq!(
1381            credential_backstop_match("MY_SERVICE_SECRET", &["*_SERVICE_SECRET".to_string()]),
1382            Some(BackstopMatch {
1383                pattern: "*_SERVICE_SECRET".to_string(),
1384                source: BackstopPatternSource::StripEnv,
1385            })
1386        );
1387    }
1388
1389    /// A name both lists cover is attributed to the built-in pattern, the one a manifest edit
1390    /// cannot remove.
1391    #[test]
1392    fn credential_backstop_match_prefers_the_builtin_pattern_when_both_match() {
1393        assert_eq!(
1394            credential_backstop_match("STRIPE_API_KEY", &["STRIPE_*".to_string()]),
1395            Some(BackstopMatch {
1396                pattern: "*_API_KEY".to_string(),
1397                source: BackstopPatternSource::Builtin,
1398            })
1399        );
1400    }
1401
1402    #[test]
1403    fn credential_backstop_match_is_none_for_a_name_the_backstop_keeps() {
1404        assert_eq!(credential_backstop_match("TZ", &[]), None);
1405        assert_eq!(
1406            credential_backstop_match("PRIVATE_KEY", &["*_SERVICE_SECRET".to_string()]),
1407            None
1408        );
1409    }
1410
1411    #[test]
1412    fn env_name_matches_pattern_trailing_wildcard() {
1413        assert!(env_name_matches_pattern("AWS_*", "AWS_ACCESS_KEY_ID"));
1414        assert!(!env_name_matches_pattern("AWS_*", "MY_AWS_KEY"));
1415    }
1416
1417    #[test]
1418    fn env_name_matches_pattern_leading_wildcard() {
1419        assert!(env_name_matches_pattern("*_API_KEY", "STRIPE_API_KEY"));
1420        assert!(!env_name_matches_pattern("*_API_KEY", "API_KEY_ID"));
1421    }
1422
1423    #[test]
1424    fn env_name_matches_pattern_exact_match() {
1425        assert!(env_name_matches_pattern("GITHUB_TOKEN", "GITHUB_TOKEN"));
1426        assert!(!env_name_matches_pattern("GITHUB_TOKEN", "GITHUB_TOKEN_2"));
1427    }
1428
1429    #[test]
1430    fn shell_tool_manifest_yaml_is_valid_yaml_for_interpreter() {
1431        let yaml = shell_tool_manifest_yaml("bash");
1432        serde_yaml::from_str::<serde_yaml::Value>(&yaml).expect("bash manifest must be valid YAML");
1433    }
1434
1435    #[test]
1436    fn shell_tool_manifest_yaml_is_valid_yaml_for_non_interpreter() {
1437        let yaml = shell_tool_manifest_yaml("curl");
1438        serde_yaml::from_str::<serde_yaml::Value>(&yaml)
1439            .expect("curl manifest must be valid YAML — embedded quotes break serde_yaml parsing");
1440    }
1441
1442    #[test]
1443    fn shell_tool_manifest_yaml_non_interpreter_has_tool_runtime() {
1444        let yaml = shell_tool_manifest_yaml("curl");
1445        let value: serde_yaml::Value = serde_yaml::from_str(&yaml).unwrap();
1446        assert_eq!(
1447            value.get("runtime").and_then(|v| v.as_str()),
1448            Some("tool"),
1449            "non-interpreter manifest must have runtime: tool so inventory picks it up"
1450        );
1451    }
1452}