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_wasi_env_allowlist`]). 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 a WASM guest may observe: only names the manifest declared in
772/// `capabilities.env.allow` and that the host actually has set, minus anything
773/// credential-shaped. A declared name absent from the host is simply omitted, not an error.
774///
775/// [`ARTIFACT_CONFIG_ENV`] is reserved and never resolved from the host, whatever a manifest
776/// allowlists: the name is runtime-owned, and its value comes from the declaring artifact's own
777/// `config:` block or from nowhere. Skipped here rather than relied on being overwritten later,
778/// so a host value cannot reach a guest whose entry declared no config at all.
779pub(crate) fn build_wasi_env_allowlist(policy: &CapabilityPolicy) -> BTreeMap<String, String> {
780    let mut env = BTreeMap::new();
781
782    for key in &policy.env_allow {
783        if key == ARTIFACT_CONFIG_ENV {
784            continue;
785        }
786        if let Ok(value) = std::env::var(key) {
787            env.insert(key.clone(), value);
788        }
789    }
790
791    strip_credential_shaped_vars(&mut env, &policy.shell_strip_env);
792
793    env
794}
795
796fn synthetic_home_dir(session_workdir: &Path) -> Result<std::path::PathBuf, String> {
797    let home = session_workdir.join(SYNTHETIC_HOME_DIR_NAME);
798    fs::create_dir_all(&home).map_err(|error| {
799        format!(
800            "failed to create synthetic home directory {}: {error}",
801            home.display()
802        )
803    })?;
804    Ok(home)
805}
806
807fn env_name_matches_pattern(pattern: &str, key: &str) -> bool {
808    if let Some(rest) = pattern.strip_prefix('*') {
809        if let Some(middle) = rest.strip_suffix('*') {
810            return key.contains(middle);
811        }
812        return key.ends_with(rest);
813    }
814
815    if let Some(prefix) = pattern.strip_suffix('*') {
816        return key.starts_with(prefix);
817    }
818
819    pattern == key
820}
821
822/// Write one command's full stdout and stderr to `logs/<file_stem>.log`, returning the
823/// workdir-relative path.
824///
825/// Two callers, one content format: a foreground command whose output exceeded
826/// [`MAX_SHELL_OUTPUT_BYTES`] passes a `shell-<ms>` stem, and a demoted command passes its work
827/// id. A reader that finds either file reads the same header, `Stdout:` and `Stderr:` sections.
828fn write_shell_output_log(
829    accessible_workdir: &Path,
830    file_stem: &str,
831    binary: &str,
832    args: &[&str],
833    exit_code: i32,
834    stdout: &str,
835    stderr: &str,
836) -> Result<String, String> {
837    let logs_dir = accessible_workdir.join("logs");
838    fs::create_dir_all(&logs_dir)
839        .map_err(|error| format!("failed to create shell logs directory: {error}"))?;
840
841    let filename = format!("{file_stem}.log");
842    let relative_path = format!("logs/{filename}");
843    let path = logs_dir.join(&filename);
844
845    let command = if args.is_empty() {
846        binary.to_string()
847    } else {
848        format!("{binary} {}", args.join(" "))
849    };
850
851    let content = format!(
852        "Command: {command}\nExit code: {exit_code}\n\nStdout:\n{stdout}\n\nStderr:\n{stderr}\n"
853    );
854
855    fs::write(&path, content)
856        .map_err(|error| format!("failed to write shell log {}: {error}", path.display()))?;
857
858    Ok(relative_path)
859}
860
861#[cfg(test)]
862mod tests {
863    use tempfile::tempdir;
864
865    use super::*;
866    use crate::sandbox::ShellEnforcement;
867
868    /// The `pre_exec` rlimit plumbing, end to end on whatever platform runs the suite: a
869    /// declared `capabilities.resources.max_open_files` must reach the child as its **hard**
870    /// ceiling, which is what `ulimit -Hn` reports. This asserts the mechanism (the value was
871    /// applied to `rlim_max`, not only `rlim_cur`), not a security outcome — the hostile-capsule
872    /// scenarios require real Linux hardware.
873    #[test]
874    fn execute_shell_applies_the_declared_limit_as_a_hard_ceiling() {
875        let temp = tempdir().unwrap();
876        let policy = CapabilityPolicy {
877            shell_allow: vec!["bash".to_string()],
878            resources: crate::resources::HostResourceLimits {
879                max_open_files: 64,
880                ..crate::resources::HostResourceLimits::default()
881            },
882            ..CapabilityPolicy::default()
883        };
884        let mut enforcement = ShellEnforcement::environment_only();
885        enforcement.resource_limits = policy.resources;
886
887        let result = execute_shell(
888            "bash",
889            &["-c", "ulimit -Hn"],
890            &[],
891            temp.path(),
892            temp.path(),
893            &policy,
894            &enforcement,
895        )
896        .expect("bash must run");
897
898        assert_eq!(result.stdout.trim(), "64", "stderr was: {}", result.stderr);
899        assert_eq!(result.resource_limit_hit, None);
900    }
901
902    /// Under `--workdir` the two workdirs are different directories, and each of the three things
903    /// `execute_shell` does with one has to pick the right one: the subprocess runs in the
904    /// accessible workdir, `$HOME` resolves into the session workdir, and the truncated-output log
905    /// stays where the tool result says it is.
906    ///
907    /// Asserted through a real subprocess rather than through `build_shell_env` alone, because
908    /// `$HOME` naming a directory that was never created is exactly the failure this split can
909    /// produce and a pure env check would not see.
910    #[test]
911    fn a_shell_command_runs_in_the_accessible_workdir_with_home_inside_the_session_one() {
912        let accessible = tempdir().unwrap();
913        let session = accessible.path().join(".murmur").join("ses_shell_test");
914        fs::create_dir_all(&session).unwrap();
915        let policy = CapabilityPolicy {
916            shell_allow: vec!["bash".to_string()],
917            ..CapabilityPolicy::default()
918        };
919
920        let result = execute_shell(
921            "bash",
922            &[
923                "-c",
924                "echo marker > \"$HOME/probe\" && cat \"$HOME/probe\" && pwd",
925            ],
926            &[],
927            accessible.path(),
928            &session,
929            &policy,
930            &ShellEnforcement::environment_only(),
931        )
932        .expect("bash must run");
933
934        assert_eq!(result.exit_code, 0, "stderr was: {}", result.stderr);
935        let mut lines = result.stdout.lines();
936        assert_eq!(lines.next(), Some("marker"));
937        assert_eq!(
938            std::fs::canonicalize(lines.next().unwrap()).unwrap(),
939            std::fs::canonicalize(accessible.path()).unwrap(),
940            "the subprocess cwd is the directory the capsule was asked to produce"
941        );
942
943        assert_eq!(
944            fs::read_to_string(session.join(SYNTHETIC_HOME_DIR_NAME).join("probe")).unwrap(),
945            "marker\n"
946        );
947        assert!(
948            !accessible.path().join(SYNTHETIC_HOME_DIR_NAME).exists(),
949            "the synthetic home must add nothing to the top level of the accessible workdir"
950        );
951    }
952
953    /// A subprocess that exits normally must never be reported as limit-killed — the negative
954    /// half of attribution, and the one that keeps `resource_limit_hit` meaningful in the trace.
955    #[test]
956    fn execute_shell_reports_no_resource_limit_for_an_ordinary_exit() {
957        let temp = tempdir().unwrap();
958        let policy = CapabilityPolicy {
959            shell_allow: vec!["bash".to_string()],
960            ..CapabilityPolicy::default()
961        };
962
963        let result = execute_shell(
964            "bash",
965            &["-c", "exit 3"],
966            &[],
967            temp.path(),
968            temp.path(),
969            &policy,
970            &ShellEnforcement::environment_only(),
971        )
972        .expect("bash must run");
973
974        assert_eq!(result.exit_code, 3);
975        assert_eq!(result.resource_limit_hit, None);
976    }
977
978    #[test]
979    fn execute_shell_blocks_binary_not_in_allowlist() {
980        let policy = CapabilityPolicy::default();
981        let error = execute_shell(
982            "bash",
983            &["-c", "echo hi"],
984            &[],
985            Path::new("."),
986            Path::new("."),
987            &policy,
988            &ShellEnforcement::environment_only(),
989        )
990        .unwrap_err();
991
992        assert!(error.to_string().contains("capabilities.shell.allow"));
993    }
994
995    #[test]
996    fn execute_shell_returns_nonzero_exit_code_without_error() {
997        let temp = tempdir().unwrap();
998        let policy = CapabilityPolicy {
999            shell_allow: vec!["bash".to_string()],
1000            ..CapabilityPolicy::default()
1001        };
1002
1003        let result = execute_shell(
1004            "bash",
1005            &["-c", "exit 42"],
1006            &[],
1007            temp.path(),
1008            temp.path(),
1009            &policy,
1010            &ShellEnforcement::environment_only(),
1011        )
1012        .unwrap();
1013        assert_eq!(result.exit_code, 42);
1014    }
1015
1016    /// A real subprocess run through the real `execute_shell`: the returned `binary` is the
1017    /// canonical absolute path of the interpreter that ran, not the bare invoked name — the
1018    /// value that reaches `shell-event.binary` and `trace.jsonl`'s `binary` key.
1019    #[test]
1020    fn execute_shell_reports_the_resolved_path_of_the_invoked_binary() {
1021        let temp = tempdir().unwrap();
1022        let policy = CapabilityPolicy {
1023            shell_allow: vec!["bash".to_string()],
1024            ..CapabilityPolicy::default()
1025        };
1026
1027        let result = execute_shell(
1028            "bash",
1029            &["-c", "exit 0"],
1030            &[],
1031            temp.path(),
1032            temp.path(),
1033            &policy,
1034            &ShellEnforcement::environment_only(),
1035        )
1036        .unwrap();
1037
1038        assert!(
1039            Path::new(&result.binary).is_absolute(),
1040            "a binary found on PATH must be reported as an absolute path, got {:?}",
1041            result.binary
1042        );
1043        assert!(
1044            result.binary.ends_with("bash"),
1045            "the reported path must still name bash, got {:?}",
1046            result.binary
1047        );
1048    }
1049
1050    /// The fallback, exercised end-to-end rather than only against the resolver: a workdir
1051    /// -relative program spawns fine (the child resolves it after `chdir`) but has no host
1052    /// `PATH` entry to canonicalize against, so `binary` degrades to the invoked name
1053    /// unchanged. Nothing else about the call changes — it still runs and still reports its
1054    /// exit code.
1055    #[test]
1056    fn execute_shell_falls_back_to_the_bare_name_when_nothing_resolves() {
1057        let temp = tempdir().unwrap();
1058        let tool = temp.path().join("local-tool");
1059        fs::write(&tool, "#!/bin/sh\nexit 7\n").unwrap();
1060        let mut perms = fs::metadata(&tool).unwrap().permissions();
1061        std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o755);
1062        fs::set_permissions(&tool, perms).unwrap();
1063
1064        let policy = CapabilityPolicy {
1065            shell_allow: vec!["./local-tool".to_string()],
1066            ..CapabilityPolicy::default()
1067        };
1068
1069        let result = execute_shell(
1070            "./local-tool",
1071            &[],
1072            &[],
1073            temp.path(),
1074            temp.path(),
1075            &policy,
1076            &ShellEnforcement::environment_only(),
1077        )
1078        .unwrap();
1079
1080        assert_eq!(result.exit_code, 7, "the subprocess must still have run");
1081        assert_eq!(
1082            result.binary, "./local-tool",
1083            "an unresolvable name degrades to the bare invoked name, never an error"
1084        );
1085    }
1086
1087    #[test]
1088    fn build_shell_env_sets_synthetic_home_under_workdir() {
1089        let temp = tempdir().unwrap();
1090        let policy = CapabilityPolicy::default();
1091
1092        let env = build_shell_env(&policy, &[], temp.path()).unwrap();
1093
1094        let expected_home = temp.path().join(".capsule-home");
1095        assert_eq!(
1096            env.get("HOME"),
1097            Some(&expected_home.to_string_lossy().into_owned())
1098        );
1099        assert_eq!(
1100            env.get("USERPROFILE"),
1101            Some(&expected_home.to_string_lossy().into_owned())
1102        );
1103        assert!(expected_home.is_dir());
1104    }
1105
1106    #[test]
1107    fn build_shell_env_reports_synthetic_home_creation_failure() {
1108        let temp = tempdir().unwrap();
1109        let blocking_file = temp.path().join("blocked");
1110        std::fs::write(&blocking_file, b"not a directory").unwrap();
1111        let workdir = blocking_file.join("subdir");
1112        let policy = CapabilityPolicy::default();
1113
1114        let error = build_shell_env(&policy, &[], &workdir).unwrap_err();
1115
1116        let expected_home = workdir.join(".capsule-home");
1117        assert!(error.contains(&expected_home.to_string_lossy().into_owned()));
1118    }
1119
1120    #[test]
1121    fn execute_shell_does_not_spawn_when_synthetic_home_creation_fails() {
1122        let temp = tempdir().unwrap();
1123        let blocking_file = temp.path().join("blocked");
1124        std::fs::write(&blocking_file, b"not a directory").unwrap();
1125        let workdir = blocking_file.join("subdir");
1126        let policy = CapabilityPolicy {
1127            shell_allow: vec!["bash".to_string()],
1128            ..CapabilityPolicy::default()
1129        };
1130
1131        let error = execute_shell(
1132            "bash",
1133            &["-c", "echo should-not-run"],
1134            &[],
1135            &workdir,
1136            &workdir,
1137            &policy,
1138            &ShellEnforcement::environment_only(),
1139        )
1140        .unwrap_err();
1141
1142        assert!(error.to_string().contains(".capsule-home"));
1143    }
1144
1145    #[test]
1146    fn execute_shell_reports_synthetic_home_not_real_host_home() {
1147        let temp = tempdir().unwrap();
1148        let policy = CapabilityPolicy {
1149            shell_allow: vec!["bash".to_string()],
1150            ..CapabilityPolicy::default()
1151        };
1152
1153        let result = execute_shell(
1154            "bash",
1155            &["-c", "echo -n $HOME"],
1156            &[],
1157            temp.path(),
1158            temp.path(),
1159            &policy,
1160            &ShellEnforcement::environment_only(),
1161        )
1162        .unwrap();
1163
1164        let expected_home = temp.path().join(".capsule-home");
1165        assert_eq!(result.stdout, expected_home.to_string_lossy());
1166        if let Ok(real_home) = std::env::var("HOME") {
1167            assert_ne!(result.stdout, real_home);
1168        }
1169    }
1170
1171    #[test]
1172    fn build_shell_env_strips_wildcard_credential_patterns_from_overrides() {
1173        let temp = tempdir().unwrap();
1174        let policy = CapabilityPolicy::default();
1175        let overrides = vec![
1176            ("AWS_ACCESS_KEY_ID".to_string(), "leaked".to_string()),
1177            ("DOCKER_AUTH_CONFIG".to_string(), "leaked".to_string()),
1178            ("STRIPE_API_KEY".to_string(), "leaked".to_string()),
1179            ("GITHUB_TOKEN".to_string(), "leaked".to_string()),
1180            ("SAFE_VAR".to_string(), "kept".to_string()),
1181        ];
1182
1183        let env = build_shell_env(&policy, &overrides, temp.path()).unwrap();
1184
1185        assert!(!env.contains_key("AWS_ACCESS_KEY_ID"));
1186        assert!(!env.contains_key("DOCKER_AUTH_CONFIG"));
1187        assert!(!env.contains_key("STRIPE_API_KEY"));
1188        assert!(!env.contains_key("GITHUB_TOKEN"));
1189        assert_eq!(env.get("SAFE_VAR"), Some(&"kept".to_string()));
1190    }
1191
1192    #[test]
1193    fn build_wasi_env_allowlist_is_empty_without_declarations() {
1194        std::env::set_var("MURMUR_TEST_WASI_UNDECLARED", "host-value");
1195        let policy = CapabilityPolicy::default();
1196
1197        let env = build_wasi_env_allowlist(&policy);
1198
1199        assert!(env.is_empty());
1200    }
1201
1202    #[test]
1203    fn build_wasi_env_allowlist_passes_through_declared_host_var() {
1204        std::env::set_var("MURMUR_TEST_WASI_ALLOWED", "host-value");
1205        let policy = CapabilityPolicy {
1206            env_allow: vec![
1207                "MURMUR_TEST_WASI_ALLOWED".to_string(),
1208                // Declared but unset on the host: omitted, not an error.
1209                "MURMUR_TEST_WASI_NEVER_SET".to_string(),
1210            ],
1211            ..CapabilityPolicy::default()
1212        };
1213
1214        let env = build_wasi_env_allowlist(&policy);
1215
1216        assert_eq!(
1217            env.get("MURMUR_TEST_WASI_ALLOWED"),
1218            Some(&"host-value".to_string())
1219        );
1220        assert!(!env.contains_key("MURMUR_TEST_WASI_NEVER_SET"));
1221    }
1222
1223    #[test]
1224    fn build_wasi_env_allowlist_strips_credential_shaped_declarations() {
1225        std::env::set_var("GITHUB_TOKEN", "leaked-token");
1226        std::env::set_var("STRIPE_API_KEY", "leaked-key");
1227        std::env::set_var("MURMUR_TEST_WASI_CUSTOM_SECRET", "leaked-secret");
1228        let policy = CapabilityPolicy {
1229            env_allow: vec![
1230                "GITHUB_TOKEN".to_string(),
1231                "STRIPE_API_KEY".to_string(),
1232                "MURMUR_TEST_WASI_CUSTOM_SECRET".to_string(),
1233            ],
1234            shell_strip_env: vec!["*_CUSTOM_SECRET".to_string()],
1235            ..CapabilityPolicy::default()
1236        };
1237
1238        let env = build_wasi_env_allowlist(&policy);
1239
1240        // Declaring a credential-shaped name does not bypass the backstop.
1241        assert!(env.is_empty(), "expected all names stripped, got {env:?}");
1242    }
1243
1244    /// A credential-shaped name outside every backstop pattern is delivered like any other
1245    /// declared name: `W-SEC-024` reports that grant, and nothing withholds it.
1246    #[test]
1247    fn build_wasi_env_allowlist_delivers_a_credential_shaped_name_the_backstop_keeps() {
1248        std::env::set_var("PRIVATE_KEY", "host-private-key");
1249        std::env::set_var("MURMUR_TEST_WASI_PLAIN", "host-plain");
1250        let policy = CapabilityPolicy {
1251            env_allow: vec![
1252                "PRIVATE_KEY".to_string(),
1253                "MURMUR_TEST_WASI_PLAIN".to_string(),
1254            ],
1255            ..CapabilityPolicy::default()
1256        };
1257
1258        let env = build_wasi_env_allowlist(&policy);
1259
1260        assert_eq!(
1261            env.get("PRIVATE_KEY"),
1262            Some(&"host-private-key".to_string())
1263        );
1264        assert_eq!(
1265            env.get("MURMUR_TEST_WASI_PLAIN"),
1266            Some(&"host-plain".to_string())
1267        );
1268    }
1269
1270    #[test]
1271    fn build_shell_env_home_override_cannot_survive() {
1272        let temp = tempdir().unwrap();
1273        let policy = CapabilityPolicy {
1274            shell_baseline_env: vec!["HOME".to_string()],
1275            ..CapabilityPolicy::default()
1276        };
1277        let overrides = vec![("HOME".to_string(), "/tmp/guest-controlled".to_string())];
1278
1279        let env = build_shell_env(&policy, &overrides, temp.path()).unwrap();
1280
1281        let expected_home = temp.path().join(".capsule-home");
1282        assert_eq!(
1283            env.get("HOME"),
1284            Some(&expected_home.to_string_lossy().into_owned())
1285        );
1286    }
1287
1288    #[test]
1289    fn build_shell_env_keeps_safe_baseline_vars() {
1290        let temp = tempdir().unwrap();
1291        let policy = CapabilityPolicy::default();
1292
1293        std::env::set_var("CARGO_HOME", "/fake/cargo/home");
1294        let env = build_shell_env(&policy, &[], temp.path()).unwrap();
1295        std::env::remove_var("CARGO_HOME");
1296
1297        assert_eq!(env.get("CARGO_HOME"), Some(&"/fake/cargo/home".to_string()));
1298    }
1299
1300    #[test]
1301    fn build_shell_env_developer_declared_strip_env_still_composes() {
1302        let temp = tempdir().unwrap();
1303        let policy = CapabilityPolicy {
1304            shell_strip_env: vec!["MYCOMPANY_*".to_string()],
1305            ..CapabilityPolicy::default()
1306        };
1307        let overrides = vec![("MYCOMPANY_SECRET".to_string(), "leaked".to_string())];
1308
1309        let env = build_shell_env(&policy, &overrides, temp.path()).unwrap();
1310
1311        assert!(!env.contains_key("MYCOMPANY_SECRET"));
1312    }
1313
1314    /// The prediction and the strip are one decision procedure, asserted over names that exercise
1315    /// every arm of the pattern grammar plus a manifest-supplied pattern. A disagreement here is a
1316    /// `W-SEC-024` line that tells an operator the opposite of what the guest env will hold.
1317    #[test]
1318    fn credential_backstop_drops_agrees_with_the_strip_it_predicts() {
1319        let extra = vec!["*_SERVICE_SECRET".to_string()];
1320        let names = [
1321            "ANTHROPIC_API_KEY",
1322            "GITHUB_TOKEN",
1323            "GITHUB_TOKEN_2",
1324            "AWS_ACCESS_KEY_ID",
1325            "MY_AWS_KEY",
1326            "STRIPE_API_KEY",
1327            "KUBECONFIG",
1328            "MY_SERVICE_SECRET",
1329            "DATABASE_PASSWORD",
1330            "SLACK_BOT_TOKEN",
1331            "HOME",
1332            "TZ",
1333        ];
1334
1335        let mut env: BTreeMap<String, String> = names
1336            .iter()
1337            .map(|name| ((*name).to_string(), "value".to_string()))
1338            .collect();
1339        strip_credential_shaped_vars(&mut env, &extra);
1340
1341        for name in names {
1342            assert_eq!(
1343                credential_backstop_drops(name, &extra),
1344                !env.contains_key(name),
1345                "prediction and strip disagree about {name}"
1346            );
1347            assert_eq!(
1348                credential_backstop_match(name, &extra).is_some(),
1349                !env.contains_key(name),
1350                "match and strip disagree about {name}"
1351            );
1352        }
1353    }
1354
1355    #[test]
1356    fn credential_backstop_match_attributes_a_builtin_pattern() {
1357        assert_eq!(
1358            credential_backstop_match("AWS_REGION", &[]),
1359            Some(BackstopMatch {
1360                pattern: "AWS_*".to_string(),
1361                source: BackstopPatternSource::Builtin,
1362            })
1363        );
1364        assert_eq!(
1365            credential_backstop_match("GITHUB_TOKEN", &[]),
1366            Some(BackstopMatch {
1367                pattern: "GITHUB_TOKEN".to_string(),
1368                source: BackstopPatternSource::Builtin,
1369            })
1370        );
1371    }
1372
1373    #[test]
1374    fn credential_backstop_match_attributes_a_strip_env_pattern() {
1375        assert_eq!(
1376            credential_backstop_match("MY_SERVICE_SECRET", &["*_SERVICE_SECRET".to_string()]),
1377            Some(BackstopMatch {
1378                pattern: "*_SERVICE_SECRET".to_string(),
1379                source: BackstopPatternSource::StripEnv,
1380            })
1381        );
1382    }
1383
1384    /// A name both lists cover is attributed to the built-in pattern, the one a manifest edit
1385    /// cannot remove.
1386    #[test]
1387    fn credential_backstop_match_prefers_the_builtin_pattern_when_both_match() {
1388        assert_eq!(
1389            credential_backstop_match("STRIPE_API_KEY", &["STRIPE_*".to_string()]),
1390            Some(BackstopMatch {
1391                pattern: "*_API_KEY".to_string(),
1392                source: BackstopPatternSource::Builtin,
1393            })
1394        );
1395    }
1396
1397    #[test]
1398    fn credential_backstop_match_is_none_for_a_name_the_backstop_keeps() {
1399        assert_eq!(credential_backstop_match("TZ", &[]), None);
1400        assert_eq!(
1401            credential_backstop_match("PRIVATE_KEY", &["*_SERVICE_SECRET".to_string()]),
1402            None
1403        );
1404    }
1405
1406    #[test]
1407    fn env_name_matches_pattern_trailing_wildcard() {
1408        assert!(env_name_matches_pattern("AWS_*", "AWS_ACCESS_KEY_ID"));
1409        assert!(!env_name_matches_pattern("AWS_*", "MY_AWS_KEY"));
1410    }
1411
1412    #[test]
1413    fn env_name_matches_pattern_leading_wildcard() {
1414        assert!(env_name_matches_pattern("*_API_KEY", "STRIPE_API_KEY"));
1415        assert!(!env_name_matches_pattern("*_API_KEY", "API_KEY_ID"));
1416    }
1417
1418    #[test]
1419    fn env_name_matches_pattern_exact_match() {
1420        assert!(env_name_matches_pattern("GITHUB_TOKEN", "GITHUB_TOKEN"));
1421        assert!(!env_name_matches_pattern("GITHUB_TOKEN", "GITHUB_TOKEN_2"));
1422    }
1423
1424    #[test]
1425    fn shell_tool_manifest_yaml_is_valid_yaml_for_interpreter() {
1426        let yaml = shell_tool_manifest_yaml("bash");
1427        serde_yaml::from_str::<serde_yaml::Value>(&yaml).expect("bash manifest must be valid YAML");
1428    }
1429
1430    #[test]
1431    fn shell_tool_manifest_yaml_is_valid_yaml_for_non_interpreter() {
1432        let yaml = shell_tool_manifest_yaml("curl");
1433        serde_yaml::from_str::<serde_yaml::Value>(&yaml)
1434            .expect("curl manifest must be valid YAML — embedded quotes break serde_yaml parsing");
1435    }
1436
1437    #[test]
1438    fn shell_tool_manifest_yaml_non_interpreter_has_tool_runtime() {
1439        let yaml = shell_tool_manifest_yaml("curl");
1440        let value: serde_yaml::Value = serde_yaml::from_str(&yaml).unwrap();
1441        assert_eq!(
1442            value.get("runtime").and_then(|v| v.as_str()),
1443            Some("tool"),
1444            "non-interpreter manifest must have runtime: tool so inventory picks it up"
1445        );
1446    }
1447}