use std::{
collections::BTreeMap,
fs,
path::{Path, PathBuf},
process::{Command, Stdio},
time::{Duration, Instant},
};
use crate::{
artifact_config::ARTIFACT_CONFIG_ENV,
detached::{DetachPolicy, DetachedDispatchInfo},
types::CapabilityPolicy,
};
const MAX_SHELL_OUTPUT_BYTES: usize = 16 * 1024;
const SHELL_INTERPRETERS: &[&str] = &["bash", "sh", "zsh", "fish", "dash", "ksh"];
const DEFAULT_ENV_BASELINE: &[&str] = &[
"PATH",
"HOME",
"USER",
"LANG",
"LC_ALL",
"TMPDIR",
"TEMP",
"TMP",
"CARGO_HOME",
"RUSTUP_HOME",
"TERM",
];
pub(crate) const SYNTHETIC_HOME_DIR_NAME: &str = ".capsule-home";
const CREDENTIAL_ENV_PATTERNS: &[&str] = &[
"ANTHROPIC_API_KEY",
"OPENAI_API_KEY",
"GOOGLE_API_KEY",
"HUGGING_FACE_HUB_TOKEN",
"NEXUS_API_KEY",
"AWS_*",
"GITHUB_TOKEN",
"GH_TOKEN",
"DOCKER_*",
"KUBECONFIG",
"NPM_TOKEN",
"PYPI_TOKEN",
"CARGO_REGISTRY_TOKEN",
"*_API_KEY",
];
#[derive(Debug)]
pub(crate) struct ShellResult {
pub(crate) binary: String,
pub(crate) exit_code: i32,
pub(crate) stdout: String,
pub(crate) stderr: String,
pub(crate) duration_ms: u64,
pub(crate) truncated: bool,
pub(crate) full_output_path: Option<String>,
pub(crate) resource_limit_hit: Option<String>,
}
#[derive(Debug)]
pub(crate) enum ShellExecError {
Failed(String),
SealedRootConstructionFailed { detail: String },
}
impl ShellExecError {
pub(crate) fn session_fatal(&self) -> Option<crate::errors::RuntimeError> {
match self {
Self::Failed(_) => None,
Self::SealedRootConstructionFailed { detail } => {
Some(crate::errors::RuntimeError::SealedRootConstructionFailed {
detail: detail.clone(),
})
}
}
}
}
impl std::fmt::Display for ShellExecError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Failed(message) => f.write_str(message),
Self::SealedRootConstructionFailed { detail } => write!(
f,
"{}",
crate::errors::RuntimeError::SealedRootConstructionFailed {
detail: detail.clone(),
}
),
}
}
}
impl From<String> for ShellExecError {
fn from(message: String) -> Self {
Self::Failed(message)
}
}
pub(crate) fn is_shell_interpreter(binary: &str) -> bool {
SHELL_INTERPRETERS.contains(&binary)
}
pub(crate) fn split_shell_words(command: &str) -> Vec<String> {
let mut words = Vec::new();
let mut current = String::new();
let mut quote = None;
let mut escaped = false;
for ch in command.chars() {
if escaped {
current.push(ch);
escaped = false;
continue;
}
if ch == '\\' {
escaped = true;
continue;
}
match (ch, quote) {
('"' | '\'', None) => quote = Some(ch),
(q, Some(open)) if q == open => quote = None,
(c, None) if c.is_whitespace() => {
if !current.is_empty() {
words.push(std::mem::take(&mut current));
}
}
_ => current.push(ch),
}
}
if !current.is_empty() {
words.push(current);
}
words
}
pub(crate) fn shell_tool_manifest_yaml(binary: &str) -> String {
let description = if is_shell_interpreter(binary) {
format!("Run a shell command via {binary} in the capsule workdir. The `command` field is passed to {binary} via -c.")
} else {
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).")
};
format!(
"name: {binary}\nversion: 0.0.0\nruntime: tool\nimplementation: native\ndescription: \"{description}\"\ninput_schema: '{{\"type\":\"object\",\"properties\":{{\"command\":{{\"type\":\"string\"}}}},\"required\":[\"command\"]}}'\n"
)
}
#[derive(Debug)]
pub(crate) enum ShellOutcome {
Finished(ShellResult),
Detached(DetachedDispatchInfo),
}
const DETACH_POLL_INTERVAL: Duration = Duration::from_millis(20);
#[allow(clippy::too_many_arguments)]
pub(crate) fn run_shell(
binary: &str,
args: &[&str],
env_overrides: &[(String, String)],
accessible_workdir: &Path,
session_workdir: &Path,
policy: &CapabilityPolicy,
enforcement: &crate::sandbox::ShellEnforcement,
detach: Option<DetachPolicy>,
) -> Result<ShellOutcome, ShellExecError> {
if !policy.shell_allow.iter().any(|allowed| allowed == binary) {
return Err(ShellExecError::Failed(format!(
"binary '{binary}' is not in capabilities.shell.allow"
)));
}
enforcement.check_workdir_budget()?;
let env = build_shell_env(policy, env_overrides, session_workdir)?;
let resolved_binary = crate::sandbox::resolve_invoked_binary_path(binary);
let started = Instant::now();
let mut command = Command::new(binary);
command
.args(args)
.current_dir(accessible_workdir)
.env_clear()
.envs(env)
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let supervisor = crate::sandbox::prepare_enforcement(
&mut command,
enforcement,
accessible_workdir,
session_workdir,
)?;
let cgroup_counters_before = enforcement
.cgroup_scope
.as_ref()
.map(|scope| scope.event_counters());
let mut child = match command.spawn() {
Ok(child) => child,
Err(error) => {
drop(command);
return Err(match supervisor.read_diagnostic() {
Some(detail) if detail.contains(crate::sealed::SEALED_ROOT_FAILURE_PREFIX) => {
ShellExecError::SealedRootConstructionFailed { detail }
}
Some(detail) if !detail.is_empty() => ShellExecError::Failed(format!(
"sandbox: shell enforcement setup failed before exec: {detail}"
)),
_ => ShellExecError::Failed(error.to_string()),
});
}
};
let readers = OutputReaders::spawn(&mut child);
let status = match &detach {
None => child.wait().map_err(|error| error.to_string())?,
Some(policy) => {
let deadline = started + policy.grace;
loop {
match child.try_wait().map_err(|error| error.to_string())? {
Some(status) => break status,
None => {
let remaining = deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
return Ok(ShellOutcome::Detached(demote(
child,
readers,
supervisor,
DemotionContext {
policy: detach.expect("the detach policy was matched above"),
accessible_workdir: accessible_workdir.to_path_buf(),
invoked_binary: binary.to_string(),
resolved_binary,
args: args.iter().map(|arg| (*arg).to_string()).collect(),
started,
cgroup_scope: enforcement.cgroup_scope.clone(),
cgroup_counters_before,
},
)));
}
std::thread::sleep(remaining.min(DETACH_POLL_INTERVAL));
}
}
}
}
};
let (stdout_bytes, stderr_bytes) = readers.join();
supervisor.join_best_effort();
let duration_ms = started.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
let resource_limit_hit = classify_resource_limit(
&status,
enforcement.cgroup_scope.as_deref(),
cgroup_counters_before,
);
let exit_code = exit_code_of(&status);
let mut stdout = String::from_utf8_lossy(&stdout_bytes).to_string();
let mut stderr = String::from_utf8_lossy(&stderr_bytes).to_string();
let mut truncated = false;
let mut full_output_path = None;
if stdout_bytes.len() > MAX_SHELL_OUTPUT_BYTES || stderr_bytes.len() > MAX_SHELL_OUTPUT_BYTES {
truncated = true;
let stdout_cut = stdout_bytes.len().min(MAX_SHELL_OUTPUT_BYTES);
let stderr_cut = stderr_bytes.len().min(MAX_SHELL_OUTPUT_BYTES);
let full_stdout = std::mem::replace(
&mut stdout,
String::from_utf8_lossy(&stdout_bytes[..stdout_cut]).to_string(),
);
let full_stderr = std::mem::replace(
&mut stderr,
String::from_utf8_lossy(&stderr_bytes[..stderr_cut]).to_string(),
);
full_output_path = Some(write_shell_output_log(
accessible_workdir,
&format!("shell-{}", crate::trace::timestamp_ms()),
binary,
args,
exit_code,
&full_stdout,
&full_stderr,
)?);
}
Ok(ShellOutcome::Finished(ShellResult {
binary: resolved_binary,
exit_code,
stdout,
stderr,
duration_ms,
truncated,
full_output_path,
resource_limit_hit,
}))
}
pub(crate) fn execute_shell(
binary: &str,
args: &[&str],
env_overrides: &[(String, String)],
accessible_workdir: &Path,
session_workdir: &Path,
policy: &CapabilityPolicy,
enforcement: &crate::sandbox::ShellEnforcement,
) -> Result<ShellResult, ShellExecError> {
match run_shell(
binary,
args,
env_overrides,
accessible_workdir,
session_workdir,
policy,
enforcement,
None,
)? {
ShellOutcome::Finished(result) => Ok(result),
ShellOutcome::Detached(info) => Err(ShellExecError::Failed(format!(
"shell command was demoted to the background as {} on a call site that cannot receive a completion",
info.work_id
))),
}
}
struct OutputReaders {
stdout: Option<std::thread::JoinHandle<Vec<u8>>>,
stderr: Option<std::thread::JoinHandle<Vec<u8>>>,
}
impl OutputReaders {
fn spawn(child: &mut std::process::Child) -> Self {
fn drain(
pipe: Option<impl std::io::Read + Send + 'static>,
) -> Option<std::thread::JoinHandle<Vec<u8>>> {
let mut pipe = pipe?;
Some(std::thread::spawn(move || {
let mut buffer = Vec::new();
let _ = pipe.read_to_end(&mut buffer);
buffer
}))
}
Self {
stdout: drain(child.stdout.take()),
stderr: drain(child.stderr.take()),
}
}
fn join(self) -> (Vec<u8>, Vec<u8>) {
let take = |handle: Option<std::thread::JoinHandle<Vec<u8>>>| {
handle
.map(|handle| handle.join().unwrap_or_default())
.unwrap_or_default()
};
(take(self.stdout), take(self.stderr))
}
}
struct DemotionContext {
policy: DetachPolicy,
accessible_workdir: PathBuf,
invoked_binary: String,
resolved_binary: String,
args: Vec<String>,
started: Instant,
cgroup_scope: Option<std::sync::Arc<crate::cgroup::CgroupScope>>,
cgroup_counters_before: Option<crate::cgroup::CgroupEventCounters>,
}
fn demote(
mut child: std::process::Child,
readers: OutputReaders,
supervisor: crate::sandbox::SupervisorHandle,
context: DemotionContext,
) -> DetachedDispatchInfo {
let work_id = crate::detached::new_work_id();
let info = DetachedDispatchInfo {
work_id: work_id.clone(),
binary: context.resolved_binary.clone(),
command: context.policy.command.clone(),
grace_ms: context
.policy
.grace
.as_millis()
.try_into()
.unwrap_or(u64::MAX),
};
context
.policy
.registry
.register(crate::detached::DetachedWork {
work_id: work_id.clone(),
binary: context.resolved_binary.clone(),
command: context.policy.command.clone(),
started_at_ms: crate::trace::timestamp_ms().saturating_sub(
context
.started
.elapsed()
.as_millis()
.try_into()
.unwrap_or(u64::MAX),
),
});
let provenance = context.policy.completion_provenance();
let DemotionContext {
policy,
accessible_workdir,
invoked_binary,
resolved_binary,
args,
started,
cgroup_scope,
cgroup_counters_before,
} = context;
std::thread::spawn(move || {
let waited = child.wait();
let (stdout_bytes, stderr_bytes) = readers.join();
supervisor.join_best_effort();
let duration_ms = started.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
let (exit_code, resource_limit, mut error) = match waited {
Ok(status) => (
exit_code_of(&status),
classify_resource_limit(&status, cgroup_scope.as_deref(), cgroup_counters_before),
None,
),
Err(failure) => (
-1,
None,
Some(format!("waiting for the command failed: {failure}")),
),
};
let stdout = String::from_utf8_lossy(&stdout_bytes).into_owned();
let stderr = String::from_utf8_lossy(&stderr_bytes).into_owned();
let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
let output_path = crate::detached::output_path_for(&work_id);
if let Err(failure) = write_shell_output_log(
&accessible_workdir,
&work_id,
&invoked_binary,
&arg_refs,
exit_code,
&stdout,
&stderr,
) {
error = Some(match error {
Some(existing) => format!("{existing}; {failure}"),
None => failure,
});
}
let output_bytes = std::fs::metadata(accessible_workdir.join(&output_path))
.map(|metadata| metadata.len())
.unwrap_or(0);
policy
.registry
.complete(crate::detached::DetachedCompletion {
work_id,
binary: resolved_binary,
command: policy.command.clone(),
exit_code,
duration_ms,
output_path,
output_bytes,
resource_limit,
context_id: policy.context_id.clone(),
provenance,
error,
});
});
info
}
fn exit_code_of(status: &std::process::ExitStatus) -> i32 {
use std::os::unix::process::ExitStatusExt;
status
.code()
.or_else(|| status.signal().map(|signal| 128 + signal))
.unwrap_or(-1)
}
fn classify_resource_limit(
status: &std::process::ExitStatus,
cgroup_scope: Option<&crate::cgroup::CgroupScope>,
counters_before: Option<crate::cgroup::CgroupEventCounters>,
) -> Option<String> {
use std::os::unix::process::ExitStatusExt;
if let Some(limit) = status
.signal()
.and_then(crate::resources::limit_from_signal)
{
return Some(limit.to_string());
}
let (scope, before) = (cgroup_scope?, counters_before?);
scope
.event_counters()
.attribution_since(before)
.map(str::to_string)
}
pub(crate) fn build_shell_env(
policy: &CapabilityPolicy,
env_overrides: &[(String, String)],
session_workdir: &Path,
) -> Result<BTreeMap<String, String>, String> {
let mut env = BTreeMap::new();
for key in DEFAULT_ENV_BASELINE {
if let Ok(value) = std::env::var(key) {
env.insert((*key).to_string(), value);
}
}
for (key, value) in env_overrides {
env.insert(key.clone(), value.clone());
}
for key in &policy.shell_baseline_env {
if let Ok(value) = std::env::var(key) {
env.insert(key.clone(), value);
}
}
strip_credential_shaped_vars(&mut env, &policy.shell_strip_env);
let synthetic_home = synthetic_home_dir(session_workdir)?;
let synthetic_home = synthetic_home.to_string_lossy().into_owned();
env.insert("HOME".to_string(), synthetic_home.clone());
env.insert("USERPROFILE".to_string(), synthetic_home);
Ok(env)
}
pub(crate) fn strip_credential_shaped_vars(
env: &mut BTreeMap<String, String>,
extra_patterns: &[String],
) {
env.retain(|key, _| !credential_backstop_drops(key, extra_patterns));
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BackstopPatternSource {
Builtin,
StripEnv,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BackstopMatch {
pub pattern: String,
pub source: BackstopPatternSource,
}
pub fn credential_backstop_match(name: &str, extra_patterns: &[String]) -> Option<BackstopMatch> {
CREDENTIAL_ENV_PATTERNS
.iter()
.map(|pattern| (*pattern, BackstopPatternSource::Builtin))
.chain(
extra_patterns
.iter()
.map(|pattern| (pattern.as_str(), BackstopPatternSource::StripEnv)),
)
.find(|(pattern, _)| env_name_matches_pattern(pattern, name))
.map(|(pattern, source)| BackstopMatch {
pattern: pattern.to_string(),
source,
})
}
pub fn credential_backstop_drops(name: &str, extra_patterns: &[String]) -> bool {
credential_backstop_match(name, extra_patterns).is_some()
}
pub(crate) fn build_wasi_env_allowlist(policy: &CapabilityPolicy) -> BTreeMap<String, String> {
let mut env = BTreeMap::new();
for key in &policy.env_allow {
if key == ARTIFACT_CONFIG_ENV {
continue;
}
if let Ok(value) = std::env::var(key) {
env.insert(key.clone(), value);
}
}
strip_credential_shaped_vars(&mut env, &policy.shell_strip_env);
env
}
fn synthetic_home_dir(session_workdir: &Path) -> Result<std::path::PathBuf, String> {
let home = session_workdir.join(SYNTHETIC_HOME_DIR_NAME);
fs::create_dir_all(&home).map_err(|error| {
format!(
"failed to create synthetic home directory {}: {error}",
home.display()
)
})?;
Ok(home)
}
fn env_name_matches_pattern(pattern: &str, key: &str) -> bool {
if let Some(rest) = pattern.strip_prefix('*') {
if let Some(middle) = rest.strip_suffix('*') {
return key.contains(middle);
}
return key.ends_with(rest);
}
if let Some(prefix) = pattern.strip_suffix('*') {
return key.starts_with(prefix);
}
pattern == key
}
fn write_shell_output_log(
accessible_workdir: &Path,
file_stem: &str,
binary: &str,
args: &[&str],
exit_code: i32,
stdout: &str,
stderr: &str,
) -> Result<String, String> {
let logs_dir = accessible_workdir.join("logs");
fs::create_dir_all(&logs_dir)
.map_err(|error| format!("failed to create shell logs directory: {error}"))?;
let filename = format!("{file_stem}.log");
let relative_path = format!("logs/{filename}");
let path = logs_dir.join(&filename);
let command = if args.is_empty() {
binary.to_string()
} else {
format!("{binary} {}", args.join(" "))
};
let content = format!(
"Command: {command}\nExit code: {exit_code}\n\nStdout:\n{stdout}\n\nStderr:\n{stderr}\n"
);
fs::write(&path, content)
.map_err(|error| format!("failed to write shell log {}: {error}", path.display()))?;
Ok(relative_path)
}
#[cfg(test)]
mod tests {
use tempfile::tempdir;
use super::*;
use crate::sandbox::ShellEnforcement;
#[test]
fn execute_shell_applies_the_declared_limit_as_a_hard_ceiling() {
let temp = tempdir().unwrap();
let policy = CapabilityPolicy {
shell_allow: vec!["bash".to_string()],
resources: crate::resources::HostResourceLimits {
max_open_files: 64,
..crate::resources::HostResourceLimits::default()
},
..CapabilityPolicy::default()
};
let mut enforcement = ShellEnforcement::environment_only();
enforcement.resource_limits = policy.resources;
let result = execute_shell(
"bash",
&["-c", "ulimit -Hn"],
&[],
temp.path(),
temp.path(),
&policy,
&enforcement,
)
.expect("bash must run");
assert_eq!(result.stdout.trim(), "64", "stderr was: {}", result.stderr);
assert_eq!(result.resource_limit_hit, None);
}
#[test]
fn a_shell_command_runs_in_the_accessible_workdir_with_home_inside_the_session_one() {
let accessible = tempdir().unwrap();
let session = accessible.path().join(".murmur").join("ses_shell_test");
fs::create_dir_all(&session).unwrap();
let policy = CapabilityPolicy {
shell_allow: vec!["bash".to_string()],
..CapabilityPolicy::default()
};
let result = execute_shell(
"bash",
&[
"-c",
"echo marker > \"$HOME/probe\" && cat \"$HOME/probe\" && pwd",
],
&[],
accessible.path(),
&session,
&policy,
&ShellEnforcement::environment_only(),
)
.expect("bash must run");
assert_eq!(result.exit_code, 0, "stderr was: {}", result.stderr);
let mut lines = result.stdout.lines();
assert_eq!(lines.next(), Some("marker"));
assert_eq!(
std::fs::canonicalize(lines.next().unwrap()).unwrap(),
std::fs::canonicalize(accessible.path()).unwrap(),
"the subprocess cwd is the directory the capsule was asked to produce"
);
assert_eq!(
fs::read_to_string(session.join(SYNTHETIC_HOME_DIR_NAME).join("probe")).unwrap(),
"marker\n"
);
assert!(
!accessible.path().join(SYNTHETIC_HOME_DIR_NAME).exists(),
"the synthetic home must add nothing to the top level of the accessible workdir"
);
}
#[test]
fn execute_shell_reports_no_resource_limit_for_an_ordinary_exit() {
let temp = tempdir().unwrap();
let policy = CapabilityPolicy {
shell_allow: vec!["bash".to_string()],
..CapabilityPolicy::default()
};
let result = execute_shell(
"bash",
&["-c", "exit 3"],
&[],
temp.path(),
temp.path(),
&policy,
&ShellEnforcement::environment_only(),
)
.expect("bash must run");
assert_eq!(result.exit_code, 3);
assert_eq!(result.resource_limit_hit, None);
}
#[test]
fn execute_shell_blocks_binary_not_in_allowlist() {
let policy = CapabilityPolicy::default();
let error = execute_shell(
"bash",
&["-c", "echo hi"],
&[],
Path::new("."),
Path::new("."),
&policy,
&ShellEnforcement::environment_only(),
)
.unwrap_err();
assert!(error.to_string().contains("capabilities.shell.allow"));
}
#[test]
fn execute_shell_returns_nonzero_exit_code_without_error() {
let temp = tempdir().unwrap();
let policy = CapabilityPolicy {
shell_allow: vec!["bash".to_string()],
..CapabilityPolicy::default()
};
let result = execute_shell(
"bash",
&["-c", "exit 42"],
&[],
temp.path(),
temp.path(),
&policy,
&ShellEnforcement::environment_only(),
)
.unwrap();
assert_eq!(result.exit_code, 42);
}
#[test]
fn execute_shell_reports_the_resolved_path_of_the_invoked_binary() {
let temp = tempdir().unwrap();
let policy = CapabilityPolicy {
shell_allow: vec!["bash".to_string()],
..CapabilityPolicy::default()
};
let result = execute_shell(
"bash",
&["-c", "exit 0"],
&[],
temp.path(),
temp.path(),
&policy,
&ShellEnforcement::environment_only(),
)
.unwrap();
assert!(
Path::new(&result.binary).is_absolute(),
"a binary found on PATH must be reported as an absolute path, got {:?}",
result.binary
);
assert!(
result.binary.ends_with("bash"),
"the reported path must still name bash, got {:?}",
result.binary
);
}
#[test]
fn execute_shell_falls_back_to_the_bare_name_when_nothing_resolves() {
let temp = tempdir().unwrap();
let tool = temp.path().join("local-tool");
fs::write(&tool, "#!/bin/sh\nexit 7\n").unwrap();
let mut perms = fs::metadata(&tool).unwrap().permissions();
std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o755);
fs::set_permissions(&tool, perms).unwrap();
let policy = CapabilityPolicy {
shell_allow: vec!["./local-tool".to_string()],
..CapabilityPolicy::default()
};
let result = execute_shell(
"./local-tool",
&[],
&[],
temp.path(),
temp.path(),
&policy,
&ShellEnforcement::environment_only(),
)
.unwrap();
assert_eq!(result.exit_code, 7, "the subprocess must still have run");
assert_eq!(
result.binary, "./local-tool",
"an unresolvable name degrades to the bare invoked name, never an error"
);
}
#[test]
fn build_shell_env_sets_synthetic_home_under_workdir() {
let temp = tempdir().unwrap();
let policy = CapabilityPolicy::default();
let env = build_shell_env(&policy, &[], temp.path()).unwrap();
let expected_home = temp.path().join(".capsule-home");
assert_eq!(
env.get("HOME"),
Some(&expected_home.to_string_lossy().into_owned())
);
assert_eq!(
env.get("USERPROFILE"),
Some(&expected_home.to_string_lossy().into_owned())
);
assert!(expected_home.is_dir());
}
#[test]
fn build_shell_env_reports_synthetic_home_creation_failure() {
let temp = tempdir().unwrap();
let blocking_file = temp.path().join("blocked");
std::fs::write(&blocking_file, b"not a directory").unwrap();
let workdir = blocking_file.join("subdir");
let policy = CapabilityPolicy::default();
let error = build_shell_env(&policy, &[], &workdir).unwrap_err();
let expected_home = workdir.join(".capsule-home");
assert!(error.contains(&expected_home.to_string_lossy().into_owned()));
}
#[test]
fn execute_shell_does_not_spawn_when_synthetic_home_creation_fails() {
let temp = tempdir().unwrap();
let blocking_file = temp.path().join("blocked");
std::fs::write(&blocking_file, b"not a directory").unwrap();
let workdir = blocking_file.join("subdir");
let policy = CapabilityPolicy {
shell_allow: vec!["bash".to_string()],
..CapabilityPolicy::default()
};
let error = execute_shell(
"bash",
&["-c", "echo should-not-run"],
&[],
&workdir,
&workdir,
&policy,
&ShellEnforcement::environment_only(),
)
.unwrap_err();
assert!(error.to_string().contains(".capsule-home"));
}
#[test]
fn execute_shell_reports_synthetic_home_not_real_host_home() {
let temp = tempdir().unwrap();
let policy = CapabilityPolicy {
shell_allow: vec!["bash".to_string()],
..CapabilityPolicy::default()
};
let result = execute_shell(
"bash",
&["-c", "echo -n $HOME"],
&[],
temp.path(),
temp.path(),
&policy,
&ShellEnforcement::environment_only(),
)
.unwrap();
let expected_home = temp.path().join(".capsule-home");
assert_eq!(result.stdout, expected_home.to_string_lossy());
if let Ok(real_home) = std::env::var("HOME") {
assert_ne!(result.stdout, real_home);
}
}
#[test]
fn build_shell_env_strips_wildcard_credential_patterns_from_overrides() {
let temp = tempdir().unwrap();
let policy = CapabilityPolicy::default();
let overrides = vec![
("AWS_ACCESS_KEY_ID".to_string(), "leaked".to_string()),
("DOCKER_AUTH_CONFIG".to_string(), "leaked".to_string()),
("STRIPE_API_KEY".to_string(), "leaked".to_string()),
("GITHUB_TOKEN".to_string(), "leaked".to_string()),
("SAFE_VAR".to_string(), "kept".to_string()),
];
let env = build_shell_env(&policy, &overrides, temp.path()).unwrap();
assert!(!env.contains_key("AWS_ACCESS_KEY_ID"));
assert!(!env.contains_key("DOCKER_AUTH_CONFIG"));
assert!(!env.contains_key("STRIPE_API_KEY"));
assert!(!env.contains_key("GITHUB_TOKEN"));
assert_eq!(env.get("SAFE_VAR"), Some(&"kept".to_string()));
}
#[test]
fn build_wasi_env_allowlist_is_empty_without_declarations() {
std::env::set_var("MURMUR_TEST_WASI_UNDECLARED", "host-value");
let policy = CapabilityPolicy::default();
let env = build_wasi_env_allowlist(&policy);
assert!(env.is_empty());
}
#[test]
fn build_wasi_env_allowlist_passes_through_declared_host_var() {
std::env::set_var("MURMUR_TEST_WASI_ALLOWED", "host-value");
let policy = CapabilityPolicy {
env_allow: vec![
"MURMUR_TEST_WASI_ALLOWED".to_string(),
"MURMUR_TEST_WASI_NEVER_SET".to_string(),
],
..CapabilityPolicy::default()
};
let env = build_wasi_env_allowlist(&policy);
assert_eq!(
env.get("MURMUR_TEST_WASI_ALLOWED"),
Some(&"host-value".to_string())
);
assert!(!env.contains_key("MURMUR_TEST_WASI_NEVER_SET"));
}
#[test]
fn build_wasi_env_allowlist_strips_credential_shaped_declarations() {
std::env::set_var("GITHUB_TOKEN", "leaked-token");
std::env::set_var("STRIPE_API_KEY", "leaked-key");
std::env::set_var("MURMUR_TEST_WASI_CUSTOM_SECRET", "leaked-secret");
let policy = CapabilityPolicy {
env_allow: vec![
"GITHUB_TOKEN".to_string(),
"STRIPE_API_KEY".to_string(),
"MURMUR_TEST_WASI_CUSTOM_SECRET".to_string(),
],
shell_strip_env: vec!["*_CUSTOM_SECRET".to_string()],
..CapabilityPolicy::default()
};
let env = build_wasi_env_allowlist(&policy);
assert!(env.is_empty(), "expected all names stripped, got {env:?}");
}
#[test]
fn build_wasi_env_allowlist_delivers_a_credential_shaped_name_the_backstop_keeps() {
std::env::set_var("PRIVATE_KEY", "host-private-key");
std::env::set_var("MURMUR_TEST_WASI_PLAIN", "host-plain");
let policy = CapabilityPolicy {
env_allow: vec![
"PRIVATE_KEY".to_string(),
"MURMUR_TEST_WASI_PLAIN".to_string(),
],
..CapabilityPolicy::default()
};
let env = build_wasi_env_allowlist(&policy);
assert_eq!(
env.get("PRIVATE_KEY"),
Some(&"host-private-key".to_string())
);
assert_eq!(
env.get("MURMUR_TEST_WASI_PLAIN"),
Some(&"host-plain".to_string())
);
}
#[test]
fn build_shell_env_home_override_cannot_survive() {
let temp = tempdir().unwrap();
let policy = CapabilityPolicy {
shell_baseline_env: vec!["HOME".to_string()],
..CapabilityPolicy::default()
};
let overrides = vec![("HOME".to_string(), "/tmp/guest-controlled".to_string())];
let env = build_shell_env(&policy, &overrides, temp.path()).unwrap();
let expected_home = temp.path().join(".capsule-home");
assert_eq!(
env.get("HOME"),
Some(&expected_home.to_string_lossy().into_owned())
);
}
#[test]
fn build_shell_env_keeps_safe_baseline_vars() {
let temp = tempdir().unwrap();
let policy = CapabilityPolicy::default();
std::env::set_var("CARGO_HOME", "/fake/cargo/home");
let env = build_shell_env(&policy, &[], temp.path()).unwrap();
std::env::remove_var("CARGO_HOME");
assert_eq!(env.get("CARGO_HOME"), Some(&"/fake/cargo/home".to_string()));
}
#[test]
fn build_shell_env_developer_declared_strip_env_still_composes() {
let temp = tempdir().unwrap();
let policy = CapabilityPolicy {
shell_strip_env: vec!["MYCOMPANY_*".to_string()],
..CapabilityPolicy::default()
};
let overrides = vec![("MYCOMPANY_SECRET".to_string(), "leaked".to_string())];
let env = build_shell_env(&policy, &overrides, temp.path()).unwrap();
assert!(!env.contains_key("MYCOMPANY_SECRET"));
}
#[test]
fn credential_backstop_drops_agrees_with_the_strip_it_predicts() {
let extra = vec!["*_SERVICE_SECRET".to_string()];
let names = [
"ANTHROPIC_API_KEY",
"GITHUB_TOKEN",
"GITHUB_TOKEN_2",
"AWS_ACCESS_KEY_ID",
"MY_AWS_KEY",
"STRIPE_API_KEY",
"KUBECONFIG",
"MY_SERVICE_SECRET",
"DATABASE_PASSWORD",
"SLACK_BOT_TOKEN",
"HOME",
"TZ",
];
let mut env: BTreeMap<String, String> = names
.iter()
.map(|name| ((*name).to_string(), "value".to_string()))
.collect();
strip_credential_shaped_vars(&mut env, &extra);
for name in names {
assert_eq!(
credential_backstop_drops(name, &extra),
!env.contains_key(name),
"prediction and strip disagree about {name}"
);
assert_eq!(
credential_backstop_match(name, &extra).is_some(),
!env.contains_key(name),
"match and strip disagree about {name}"
);
}
}
#[test]
fn credential_backstop_match_attributes_a_builtin_pattern() {
assert_eq!(
credential_backstop_match("AWS_REGION", &[]),
Some(BackstopMatch {
pattern: "AWS_*".to_string(),
source: BackstopPatternSource::Builtin,
})
);
assert_eq!(
credential_backstop_match("GITHUB_TOKEN", &[]),
Some(BackstopMatch {
pattern: "GITHUB_TOKEN".to_string(),
source: BackstopPatternSource::Builtin,
})
);
}
#[test]
fn credential_backstop_match_attributes_a_strip_env_pattern() {
assert_eq!(
credential_backstop_match("MY_SERVICE_SECRET", &["*_SERVICE_SECRET".to_string()]),
Some(BackstopMatch {
pattern: "*_SERVICE_SECRET".to_string(),
source: BackstopPatternSource::StripEnv,
})
);
}
#[test]
fn credential_backstop_match_prefers_the_builtin_pattern_when_both_match() {
assert_eq!(
credential_backstop_match("STRIPE_API_KEY", &["STRIPE_*".to_string()]),
Some(BackstopMatch {
pattern: "*_API_KEY".to_string(),
source: BackstopPatternSource::Builtin,
})
);
}
#[test]
fn credential_backstop_match_is_none_for_a_name_the_backstop_keeps() {
assert_eq!(credential_backstop_match("TZ", &[]), None);
assert_eq!(
credential_backstop_match("PRIVATE_KEY", &["*_SERVICE_SECRET".to_string()]),
None
);
}
#[test]
fn env_name_matches_pattern_trailing_wildcard() {
assert!(env_name_matches_pattern("AWS_*", "AWS_ACCESS_KEY_ID"));
assert!(!env_name_matches_pattern("AWS_*", "MY_AWS_KEY"));
}
#[test]
fn env_name_matches_pattern_leading_wildcard() {
assert!(env_name_matches_pattern("*_API_KEY", "STRIPE_API_KEY"));
assert!(!env_name_matches_pattern("*_API_KEY", "API_KEY_ID"));
}
#[test]
fn env_name_matches_pattern_exact_match() {
assert!(env_name_matches_pattern("GITHUB_TOKEN", "GITHUB_TOKEN"));
assert!(!env_name_matches_pattern("GITHUB_TOKEN", "GITHUB_TOKEN_2"));
}
#[test]
fn shell_tool_manifest_yaml_is_valid_yaml_for_interpreter() {
let yaml = shell_tool_manifest_yaml("bash");
serde_yaml::from_str::<serde_yaml::Value>(&yaml).expect("bash manifest must be valid YAML");
}
#[test]
fn shell_tool_manifest_yaml_is_valid_yaml_for_non_interpreter() {
let yaml = shell_tool_manifest_yaml("curl");
serde_yaml::from_str::<serde_yaml::Value>(&yaml)
.expect("curl manifest must be valid YAML — embedded quotes break serde_yaml parsing");
}
#[test]
fn shell_tool_manifest_yaml_non_interpreter_has_tool_runtime() {
let yaml = shell_tool_manifest_yaml("curl");
let value: serde_yaml::Value = serde_yaml::from_str(&yaml).unwrap();
assert_eq!(
value.get("runtime").and_then(|v| v.as_str()),
Some("tool"),
"non-interpreter manifest must have runtime: tool so inventory picks it up"
);
}
}