Files
agent-sandbox/tests/integration.rs
Kristóf Tóth 25f0037aab Filter environment variables in both sandbox modes
Whitelist mode now clears the parent env and re-adds a small allowlist
(identity, terminal, locale, proxy, non-GUI XDG, vendor prefixes).
Blacklist mode strips cloud credentials, backup passphrases, dangling
socket pointers, and anything matching *_TOKEN, *_SECRET, *_PASSWORD,
*_PASSPHRASE, *_API_KEY, *_PRIVATE_KEY, *_CLIENT_SECRET; vendor prefix
carve-outs keep ANTHROPIC_API_KEY and friends.

Users can override via --setenv KEY=VALUE and --unsetenv KEY (and the
corresponding TOML keys), or opt out of the built-in policy entirely
with --no-env-filter.
2026-04-08 09:22:11 +02:00

1300 lines
36 KiB
Rust

use std::fs;
use std::process::Command;
use tempfile::TempDir;
fn sandbox_withconfig(extra_args: &[&str]) -> Command {
let mut cmd = Command::new(env!("CARGO_BIN_EXE_agent-sandbox"));
cmd.args(extra_args);
cmd
}
fn sandbox(extra_args: &[&str]) -> Command {
let mut cmd = sandbox_withconfig(&["--no-config"]);
cmd.args(extra_args);
cmd
}
struct ConfigFile {
_dir: TempDir,
path: String,
}
impl ConfigFile {
fn new(content: &str) -> Self {
let dir = TempDir::new().unwrap();
let path = dir.path().join("config.toml");
fs::write(&path, content).expect("failed to write config");
Self {
_dir: dir,
path: path.to_str().unwrap().to_string(),
}
}
}
impl std::ops::Deref for ConfigFile {
type Target = str;
fn deref(&self) -> &str {
&self.path
}
}
fn read_sid_from_stat(stat: &str) -> u32 {
stat.split_whitespace()
.nth(5)
.expect("missing field 6 in /proc/self/stat")
.parse()
.expect("failed to parse session ID")
}
fn read_sid_inside_sandbox(extra_args: &[&str]) -> u32 {
let output = sandbox(extra_args)
.args(["--", "bash", "-c", "cat /proc/self/stat"])
.output()
.expect("agent-sandbox binary failed to execute");
read_sid_from_stat(&String::from_utf8_lossy(&output.stdout))
}
fn read_sid_current_process() -> u32 {
let stat = fs::read_to_string("/proc/self/stat").expect("failed to read /proc/self/stat");
read_sid_from_stat(&stat)
}
struct CleanupFile(&'static str);
impl Drop for CleanupFile {
fn drop(&mut self) {
let _ = fs::remove_file(self.0);
}
}
#[test]
fn cwd_is_writable() {
let _cleanup = CleanupFile("./sandbox_canary");
let output = sandbox(&[])
.args(["--", "bash", "-c", "touch ./sandbox_canary && echo ok"])
.output()
.expect("agent-sandbox binary failed to execute");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("ok"),
"expected 'ok' in stdout, got: {stdout}"
);
}
#[test]
fn host_fs_is_readonly() {
let output = sandbox(&[])
.args(["--", "bash", "-c", "touch /etc/pwned 2>&1 || echo readonly"])
.output()
.expect("agent-sandbox binary failed to execute");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("readonly"),
"expected 'readonly' in stdout, got: {stdout}"
);
assert!(!std::path::Path::new("/etc/pwned").exists());
}
#[test]
fn ssh_dir_is_hidden() {
let output = sandbox(&[])
.args(["--", "bash", "-c", "ls ~/.ssh 2>/dev/null | wc -l"])
.output()
.expect("agent-sandbox binary failed to execute");
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
assert_eq!(stdout, "0", "expected empty ~/.ssh, got {stdout} entries");
}
#[test]
fn unshare_net_blocks_network() {
let output = sandbox(&["--unshare-net"])
.args([
"--",
"bash",
"-c",
"curl -s --max-time 2 http://1.1.1.1 2>&1; echo $?",
])
.output()
.expect("agent-sandbox binary failed to execute");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
!stdout.trim().ends_with("0"),
"expected curl to fail, got: {stdout}"
);
}
#[test]
fn hardened_pid_namespace() {
let output = sandbox(&["--hardened"])
.args(["--", "bash", "-c", "ls /proc | grep -cE '^[0-9]+$'"])
.output()
.expect("agent-sandbox binary failed to execute");
let count: u32 = String::from_utf8_lossy(&output.stdout)
.trim()
.parse()
.unwrap_or(999);
assert!(
count < 10,
"expected isolated PID namespace with few PIDs, got {count}"
);
}
#[test]
fn whitelist_hides_home_contents() {
let output = sandbox(&["--whitelist"])
.args(["--", "bash", "-c", "ls ~/Documents 2>&1 || echo hidden"])
.output()
.expect("agent-sandbox binary failed to execute");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("hidden"),
"expected ~/Documents to be hidden, got: {stdout}"
);
}
#[test]
fn extra_ro_mount() {
let dir = TempDir::new().expect("failed to create temp dir");
fs::write(dir.path().join("hello.txt"), "hi").expect("failed to write test file");
let dir_str = dir.path().to_str().unwrap();
let output = sandbox(&["--ro", dir_str])
.args([
"--",
"bash",
"-c",
&format!("cat {dir_str}/hello.txt && touch {dir_str}/new 2>&1 || echo readonly"),
])
.output()
.expect("agent-sandbox binary failed to execute");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("hi"),
"expected file content 'hi', got: {stdout}"
);
assert!(
stdout.contains("readonly"),
"expected ro mount to block writes, got: {stdout}"
);
}
#[test]
fn extra_rw_mount() {
let dir = TempDir::new().expect("failed to create temp dir");
let dir_str = dir.path().to_str().unwrap();
let output = sandbox(&["--rw", dir_str])
.args([
"--",
"bash",
"-c",
&format!("touch {dir_str}/canary && echo ok"),
])
.output()
.expect("agent-sandbox binary failed to execute");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("ok"), "expected 'ok', got: {stdout}");
assert!(
dir.path().join("canary").exists(),
"canary file should exist on host after rw mount"
);
}
#[test]
fn chdir_override() {
let dir = TempDir::new().expect("failed to create temp dir");
let dir_str = dir.path().to_str().unwrap();
let output = sandbox(&["--chdir", dir_str])
.args(["--", "bash", "-c", "pwd"])
.output()
.expect("agent-sandbox binary failed to execute");
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
assert_eq!(
stdout, dir_str,
"expected cwd to be {dir_str}, got: {stdout}"
);
}
#[test]
fn chdir_under_hardened_tmp() {
let dir = TempDir::new().expect("failed to create temp dir");
let dir_str = dir.path().to_str().unwrap();
let output = sandbox(&["--hardened", "--chdir", dir_str])
.args(["--", "bash", "-c", "pwd && touch ./ok && echo done"])
.output()
.expect("agent-sandbox binary failed to execute");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("done"),
"expected chdir under /tmp to work with --hardened, got: {stdout}"
);
}
#[test]
fn dry_run_prints_and_exits() {
let output = sandbox(&["--dry-run"])
.args(["--", "bash", "-c", "exit 42"])
.output()
.expect("agent-sandbox binary failed to execute");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("bwrap"),
"expected bwrap command in dry-run output, got: {stdout}"
);
assert!(
output.status.success(),
"dry-run should exit 0, not 42 from the inner command"
);
}
#[test]
fn dry_run_output_is_copy_pasteable_shell() {
let dry = sandbox(&["--dry-run"])
.args(["--", "bash", "-c", "echo $HOME"])
.output()
.expect("agent-sandbox binary failed to execute");
let dry_cmd = String::from_utf8_lossy(&dry.stdout).trim().to_string();
let args = shlex::split(&dry_cmd).expect("dry-run output is not valid shell");
assert_eq!(args[0], "bwrap");
assert_eq!(args[args.len() - 1], "echo $HOME");
assert_eq!(args[args.len() - 2], "-c");
}
#[test]
fn blacklist_overlays_survive_tmp_bind() {
fs::write("/tmp/ssh-sandbox-test", "secret").expect("failed to write sentinel");
let _cleanup = CleanupFile("/tmp/ssh-sandbox-test");
let output = sandbox(&[])
.args([
"--",
"bash",
"-c",
"cat /tmp/ssh-sandbox-test 2>/dev/null && echo LEAKED || echo HIDDEN",
])
.output()
.expect("agent-sandbox binary failed to execute");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("HIDDEN"),
"expected /tmp/ssh-* to be hidden in blacklist mode, got: {stdout}"
);
assert!(
!stdout.contains("LEAKED"),
"/tmp/ssh-sandbox-test was readable inside the sandbox"
);
}
#[test]
fn relative_chdir_works() {
let output = sandbox(&["--chdir", "src"])
.args(["--", "bash", "-c", "pwd"])
.output()
.expect("agent-sandbox binary failed to execute");
assert!(
output.status.success(),
"relative --chdir should work, stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
assert!(
stdout.ends_with("/src"),
"expected cwd ending in /src, got: {stdout}"
);
}
#[test]
fn relative_rw_path_works() {
let output = sandbox(&["--rw", "src"])
.args(["--", "bash", "-c", "echo ok"])
.output()
.expect("agent-sandbox binary failed to execute");
assert!(
output.status.success(),
"relative --rw should work, stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
}
#[test]
fn relative_ro_path_works() {
let output = sandbox(&["--ro", "src"])
.args(["--", "bash", "-c", "echo ok"])
.output()
.expect("agent-sandbox binary failed to execute");
assert!(
output.status.success(),
"relative --ro should work, stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
}
#[test]
fn empty_home_rejected() {
let output = sandbox(&[])
.env("HOME", "")
.args(["--", "true"])
.output()
.expect("agent-sandbox binary failed to execute");
assert!(
!output.status.success(),
"expected failure with empty HOME, but got success"
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.to_lowercase().contains("home"),
"expected error mentioning HOME, got: {stderr}"
);
}
#[test]
fn whitelist_sys_is_readable() {
let output = sandbox(&["--whitelist"])
.args(["--", "bash", "-c", "cat /sys/class/net/lo/address"])
.output()
.expect("agent-sandbox binary failed to execute");
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
assert_eq!(
stdout, "00:00:00:00:00:00",
"expected loopback address from /sys, got: {stdout}"
);
}
#[test]
fn new_session_isolates_sid() {
let inner_sid = read_sid_inside_sandbox(&[]);
let outer_sid = read_sid_current_process();
assert_ne!(
inner_sid, outer_sid,
"sandboxed process should have a different session ID (got {inner_sid} == {outer_sid})"
);
}
#[test]
fn blacklist_run_is_tmpfs() {
let output = sandbox(&[])
.args([
"--",
"bash",
"-c",
"touch /run/test_canary 2>&1 && echo WRITABLE || echo BLOCKED",
])
.output()
.expect("agent-sandbox binary failed to execute");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("WRITABLE"),
"expected /run to be a writable tmpfs in blacklist mode, got: {stdout}"
);
}
#[test]
fn blacklist_run_dbus_socket_accessible() {
let output = sandbox(&[])
.args([
"--",
"bash",
"-c",
"test -e /run/dbus/system_bus_socket && echo EXISTS || echo MISSING",
])
.output()
.expect("agent-sandbox binary failed to execute");
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
assert_eq!(
stdout, "EXISTS",
"expected /run/dbus/system_bus_socket to be accessible in blacklist mode"
);
}
#[test]
fn blacklist_runuser_is_tmpfs() {
let run_user = agent_sandbox::require_run_user().expect("failed to determine XDG_RUNTIME_DIR");
let script = format!("ls -A {} | grep -v '^bus$'", run_user);
let output = sandbox(&[])
.args(["--", "bash", "-c", &script])
.output()
.expect("agent-sandbox binary failed to execute");
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
assert!(
stdout.is_empty(),
"expected only 'bus' (or empty) in {}, got unexpected entries: {stdout}",
run_user
);
}
#[test]
fn blacklist_dev_input_hidden() {
let output = sandbox(&[])
.args(["--", "bash", "-c", "ls /dev/input/ 2>/dev/null | wc -l"])
.output()
.expect("agent-sandbox binary failed to execute");
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
assert_eq!(
stdout, "0",
"expected /dev/input/ to be empty in blacklist mode, got {stdout} entries"
);
}
#[test]
fn blacklist_root_is_readonly() {
let output = sandbox(&[])
.args([
"--",
"bash",
"-c",
"touch /rootfile 2>&1 && echo WRITABLE || echo READONLY; \
mkdir /newdir 2>&1 && echo MKDIR_OK || echo MKDIR_FAIL",
])
.output()
.expect("agent-sandbox binary failed to execute");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("READONLY"),
"expected root to be read-only in blacklist mode, got: {stdout}"
);
assert!(
stdout.contains("MKDIR_FAIL"),
"expected mkdir at root to fail in blacklist mode, got: {stdout}"
);
}
#[test]
fn whitelist_root_is_readonly() {
let output = sandbox(&["--whitelist"])
.args([
"--",
"bash",
"-c",
"touch /rootfile 2>&1 && echo WRITABLE || echo READONLY; \
mkdir /newdir 2>&1 && echo MKDIR_OK || echo MKDIR_FAIL",
])
.output()
.expect("agent-sandbox binary failed to execute");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("READONLY"),
"expected root to be read-only in whitelist mode, got: {stdout}"
);
assert!(
stdout.contains("MKDIR_FAIL"),
"expected mkdir at root to fail in whitelist mode, got: {stdout}"
);
}
#[test]
fn whitelist_mountpoint_parents_are_readonly() {
let output = sandbox(&["--whitelist"])
.args([
"--",
"bash",
"-c",
"echo pwned > /home/testfile 2>&1 && echo HOME_WRITABLE || echo HOME_READONLY; \
touch /etc/newfile 2>&1 && echo ETC_WRITABLE || echo ETC_READONLY; \
touch /var/newfile 2>&1 && echo VAR_WRITABLE || echo VAR_READONLY",
])
.output()
.expect("agent-sandbox binary failed to execute");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("HOME_READONLY"),
"expected /home to be read-only in whitelist mode, got: {stdout}"
);
assert!(
stdout.contains("ETC_READONLY"),
"expected /etc to be read-only in whitelist mode, got: {stdout}"
);
assert!(
stdout.contains("VAR_READONLY"),
"expected /var to be read-only in whitelist mode, got: {stdout}"
);
}
#[test]
fn whitelist_tmp_still_writable() {
let output = sandbox(&["--whitelist"])
.args([
"--",
"bash",
"-c",
"touch /tmp/ok 2>&1 && echo TMP_WRITABLE || echo TMP_READONLY; \
touch /var/tmp/ok 2>&1 && echo VARTMP_WRITABLE || echo VARTMP_READONLY",
])
.output()
.expect("agent-sandbox binary failed to execute");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("TMP_WRITABLE"),
"expected /tmp to remain writable in whitelist mode, got: {stdout}"
);
assert!(
stdout.contains("VARTMP_WRITABLE"),
"expected /var/tmp to remain writable in whitelist mode, got: {stdout}"
);
}
#[test]
fn rw_missing_path_errors() {
let output = sandbox(&["--rw", "/nonexistent/xyz"])
.args(["--", "true"])
.output()
.expect("agent-sandbox binary failed to execute");
assert!(
!output.status.success(),
"expected non-zero exit for missing --rw path"
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("/nonexistent/xyz"),
"expected path in error message, got: {stderr}"
);
}
#[test]
fn config_missing_file_errors() {
let output = sandbox_withconfig(&["--config", "/nonexistent/config.toml"])
.args(["--", "true"])
.output()
.expect("failed to execute");
assert!(!output.status.success());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("/nonexistent/config.toml"),
"expected config path in error, got: {stderr}"
);
}
#[test]
fn config_invalid_toml_errors() {
let cfg = ConfigFile::new("not valid {{{{ toml");
let output = sandbox_withconfig(&["--config", &cfg])
.args(["--", "true"])
.output()
.expect("failed to execute");
assert!(!output.status.success());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("cannot parse"),
"expected parse error, got: {stderr}"
);
}
#[test]
fn config_unknown_key_errors() {
let cfg = ConfigFile::new("hardened = true\nbogus = \"nope\"\n");
let output = sandbox_withconfig(&["--config", &cfg])
.args(["--", "true"])
.output()
.expect("failed to execute");
assert!(!output.status.success());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("unknown config key"),
"expected unknown key error, got: {stderr}"
);
}
#[test]
fn mask_hides_directory() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("secret.txt"), "sensitive").expect("failed to write");
let dir_str = dir.path().canonicalize().unwrap();
let output = sandbox(&["--mask", dir_str.to_str().unwrap()])
.args([
"--",
"bash",
"-c",
&format!("ls {} 2>/dev/null | wc -l", dir_str.display()),
])
.output()
.expect("failed to execute");
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
assert_eq!(
stdout, "0",
"expected masked directory to be empty, got {stdout} entries"
);
}
#[test]
fn mask_hides_file() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("secret.txt");
fs::write(&file, "sensitive").expect("failed to write");
let file_str = file.canonicalize().unwrap();
let output = sandbox(&["--mask", file_str.to_str().unwrap()])
.args([
"--",
"bash",
"-c",
&format!("cat {} 2>/dev/null || echo HIDDEN", file_str.display()),
])
.output()
.expect("failed to execute");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
!stdout.contains("sensitive"),
"expected masked file contents to be hidden, got: {stdout}"
);
}
#[test]
fn bwrap_arg_setenv_passes_through() {
let output = sandbox(&["--bwrap-arg", "--setenv MYVAR hello"])
.args(["--", "bash", "-c", "echo $MYVAR"])
.output()
.expect("agent-sandbox binary failed to execute");
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
assert_eq!(
stdout, "hello",
"expected --bwrap-arg to pass --setenv through to bwrap, got: {stdout}"
);
}
#[test]
fn config_entrypoint_appends_passthrough_args() {
let cfg = ConfigFile::new(
r#"
[profile.test]
entrypoint = ["bash", "-c"]
"#,
);
let output = sandbox_withconfig(&["--config", &cfg, "--profile", "test"])
.args(["--", "echo entrypoint-works"])
.output()
.expect("failed to execute");
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
assert_eq!(
stdout, "entrypoint-works",
"expected passthrough args appended to entrypoint, got: {stdout}"
);
}
#[test]
fn config_entrypoint_falls_back_to_command_defaults() {
let cfg = ConfigFile::new(
r#"
[profile.test]
entrypoint = ["bash", "-c"]
command = ["echo default-args"]
"#,
);
let output = sandbox_withconfig(&["--config", &cfg, "--profile", "test"])
.output()
.expect("failed to execute");
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
assert_eq!(
stdout, "default-args",
"expected command defaults when no passthrough args, got: {stdout}"
);
}
#[test]
fn config_entrypoint_alone_without_command_or_passthrough() {
let cfg = ConfigFile::new(
r#"
[profile.test]
entrypoint = ["bash", "-c", "echo entrypoint-only"]
"#,
);
let output = sandbox_withconfig(&["--config", &cfg, "--profile", "test"])
.output()
.expect("failed to execute");
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
assert_eq!(
stdout, "entrypoint-only",
"expected entrypoint to run on its own, got: {stdout}"
);
}
#[test]
fn cli_entrypoint_appends_passthrough_args() {
let output = sandbox(&["--entrypoint", "bash"])
.args(["--", "-c", "echo cli-entrypoint-works"])
.output()
.expect("failed to execute");
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
assert_eq!(
stdout, "cli-entrypoint-works",
"expected --entrypoint to receive trailing args, got: {stdout}"
);
}
#[test]
fn cli_entrypoint_overrides_config_entrypoint() {
let cfg = ConfigFile::new(
r#"
entrypoint = ["/bin/false"]
"#,
);
let output = sandbox_withconfig(&["--config", &cfg, "--entrypoint", "bash"])
.args(["--", "-c", "echo override-works"])
.output()
.expect("failed to execute");
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
assert_eq!(
stdout, "override-works",
"expected CLI --entrypoint to override config entrypoint, got: {stdout}"
);
}
#[test]
fn config_command_alone_without_passthrough() {
let cfg = ConfigFile::new(
r#"
[profile.test]
command = ["bash", "-c", "echo command-only"]
"#,
);
let output = sandbox_withconfig(&["--config", &cfg, "--profile", "test"])
.output()
.expect("failed to execute");
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
assert_eq!(
stdout, "command-only",
"expected config command to run on its own, got: {stdout}"
);
}
#[test]
fn config_command_replaced_by_passthrough() {
let cfg = ConfigFile::new(
r#"
[profile.test]
command = ["bash", "-c", "echo should-not-see-this"]
"#,
);
let output = sandbox_withconfig(&["--config", &cfg, "--profile", "test"])
.args(["--", "bash", "-c", "echo replaced"])
.output()
.expect("failed to execute");
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
assert_eq!(
stdout, "replaced",
"expected passthrough to replace config command, got: {stdout}"
);
}
#[test]
fn whitelist_ro_symlink_visible_at_link_path() {
let dir = TempDir::new().unwrap();
let target = dir.path().join("target.txt");
let link = dir.path().join("link.txt");
fs::write(&target, "hello from target").expect("failed to write target");
std::os::unix::fs::symlink(&target, &link).expect("failed to create symlink");
let link_str = link.to_str().unwrap();
let output = sandbox(&["--whitelist", "--ro", link_str])
.args(["--", "cat", link_str])
.output()
.expect("agent-sandbox binary failed to execute");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("hello from target"),
"expected symlink path to be readable inside sandbox, got stdout: {stdout}, stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
}
#[test]
fn mask_nonexistent_path_becomes_tmpfs() {
let dir = TempDir::new().unwrap();
let fake = dir.path().join("does_not_exist");
let fake_str = fake.to_str().unwrap();
let output = sandbox(&["--mask", fake_str])
.args([
"--",
"bash",
"-c",
&format!(
"test -d {fake_str} && touch {fake_str}/canary && echo WRITABLE || echo MISSING"
),
])
.output()
.expect("failed to execute");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("WRITABLE"),
"expected nonexistent mask to create a writable tmpfs, got: {stdout}"
);
assert!(
!fake.join("canary").exists(),
"tmpfs writes should not leak to host"
);
}
#[test]
fn seccomp_on_by_default_blocks_unshare() {
let output = sandbox(&[])
.args(["--", "unshare", "--user", "--map-root-user", "/bin/true"])
.output()
.expect("agent-sandbox binary failed to execute");
assert!(
!output.status.success(),
"expected unshare(2) to be blocked by default seccomp filter, but it succeeded"
);
}
#[test]
fn seccomp_off_allows_blocked_syscall() {
let output = sandbox(&["--no-seccomp"])
.args(["--", "unshare", "--user", "--map-root-user", "/bin/true"])
.output()
.expect("agent-sandbox binary failed to execute");
assert!(
output.status.success(),
"expected unshare(2) to succeed without seccomp, stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
}
#[test]
fn seccomp_dry_run_emits_seccomp_arg() {
let output = sandbox(&["--dry-run"])
.args(["--", "/bin/true"])
.output()
.expect("agent-sandbox binary failed to execute");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains("--seccomp"),
"expected --seccomp in dry-run output, got: {stdout}"
);
}
#[test]
fn seccomp_dry_run_no_seccomp_omits_arg() {
let output = sandbox(&["--dry-run", "--no-seccomp"])
.args(["--", "/bin/true"])
.output()
.expect("agent-sandbox binary failed to execute");
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
!stdout.contains("--seccomp"),
"expected no --seccomp in dry-run output with --no-seccomp, got: {stdout}"
);
}
#[test]
fn seccomp_normal_workload_succeeds() {
let output = sandbox(&[])
.args(["--", "bash", "-c", "ls /etc > /dev/null && date"])
.output()
.expect("agent-sandbox binary failed to execute");
assert!(
output.status.success(),
"expected normal workload to succeed under default seccomp, stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
}
fn printenv_inside(args: &[&str], vars: &[(&str, &str)], query: &[&str]) -> String {
let script = query
.iter()
.map(|v| format!("printenv {v} || echo MISSING:{v}"))
.collect::<Vec<_>>()
.join("; ");
let mut cmd = sandbox(args);
for (k, v) in vars {
cmd.env(k, v);
}
let output = cmd
.args(["--", "bash", "-c", &script])
.output()
.expect("agent-sandbox binary failed to execute");
String::from_utf8_lossy(&output.stdout).into_owned()
}
#[test]
fn whitelist_keeps_identity_and_terminal_vars() {
let stdout = printenv_inside(
&["--whitelist"],
&[("TERM", "xterm-test"), ("LANG", "C.UTF-8")],
&["HOME", "PATH", "TERM", "LANG"],
);
assert!(!stdout.contains("MISSING:HOME"), "HOME stripped: {stdout}");
assert!(!stdout.contains("MISSING:PATH"), "PATH stripped: {stdout}");
assert!(stdout.contains("xterm-test"), "TERM stripped: {stdout}");
assert!(stdout.contains("C.UTF-8"), "LANG stripped: {stdout}");
}
#[test]
fn whitelist_strips_arbitrary_host_var() {
let stdout = printenv_inside(
&["--whitelist"],
&[("SOME_RANDOM_NOISE_VAR", "leak")],
&["SOME_RANDOM_NOISE_VAR"],
);
assert!(
stdout.contains("MISSING:SOME_RANDOM_NOISE_VAR"),
"expected arbitrary host var to be stripped, got: {stdout}"
);
assert!(!stdout.contains("leak"));
}
#[test]
fn whitelist_keeps_vendor_prefixes() {
let stdout = printenv_inside(
&["--whitelist"],
&[
("CLAUDE_FOO", "claude-val"),
("ANTHROPIC_MODEL", "anthropic-val"),
("OPENAI_API_KEY", "openai-val"),
("CODEX_FOO", "codex-val"),
("GEMINI_API_KEY", "gemini-val"),
("OTEL_SERVICE_NAME", "otel-val"),
],
&[
"CLAUDE_FOO",
"ANTHROPIC_MODEL",
"OPENAI_API_KEY",
"CODEX_FOO",
"GEMINI_API_KEY",
"OTEL_SERVICE_NAME",
],
);
for expected in [
"claude-val",
"anthropic-val",
"openai-val",
"codex-val",
"gemini-val",
"otel-val",
] {
assert!(
stdout.contains(expected),
"expected {expected} in output, got: {stdout}"
);
}
assert!(!stdout.contains("MISSING:"), "unexpected strip: {stdout}");
}
#[test]
fn whitelist_keeps_lc_prefix() {
let stdout = printenv_inside(
&["--whitelist"],
&[("LC_TIME", "en_US.UTF-8")],
&["LC_TIME"],
);
assert!(stdout.contains("en_US.UTF-8"), "LC_TIME missing: {stdout}");
}
#[test]
fn whitelist_keeps_non_gui_xdg_vars() {
let stdout = printenv_inside(
&["--whitelist"],
&[
("XDG_CONFIG_HOME", "/cfg"),
("XDG_DATA_HOME", "/data"),
("XDG_CACHE_HOME", "/cache"),
("XDG_STATE_HOME", "/state"),
("XDG_CONFIG_DIRS", "/etc/xdg"),
("XDG_DATA_DIRS", "/usr/share"),
],
&[
"XDG_CONFIG_HOME",
"XDG_DATA_HOME",
"XDG_CACHE_HOME",
"XDG_STATE_HOME",
"XDG_CONFIG_DIRS",
"XDG_DATA_DIRS",
],
);
assert!(
!stdout.contains("MISSING:"),
"XDG non-GUI stripped: {stdout}"
);
}
#[test]
fn whitelist_strips_gui_xdg_vars() {
let stdout = printenv_inside(
&["--whitelist"],
&[
("XDG_RUNTIME_DIR", "/run/user/1000"),
("XDG_SESSION_ID", "1"),
("XDG_CURRENT_DESKTOP", "KDE"),
("XDG_SEAT", "seat0"),
],
&[
"XDG_RUNTIME_DIR",
"XDG_SESSION_ID",
"XDG_CURRENT_DESKTOP",
"XDG_SEAT",
],
);
for var in [
"XDG_RUNTIME_DIR",
"XDG_SESSION_ID",
"XDG_CURRENT_DESKTOP",
"XDG_SEAT",
] {
assert!(
stdout.contains(&format!("MISSING:{var}")),
"expected {var} stripped, got: {stdout}"
);
}
}
#[test]
fn whitelist_strips_dbus_vars() {
let stdout = printenv_inside(
&["--whitelist"],
&[
("DBUS_SESSION_BUS_ADDRESS", "unix:path=/foo"),
("DBUS_SYSTEM_BUS_ADDRESS", "unix:path=/bar"),
],
&["DBUS_SESSION_BUS_ADDRESS", "DBUS_SYSTEM_BUS_ADDRESS"],
);
assert!(
stdout.contains("MISSING:DBUS_SESSION_BUS_ADDRESS"),
"expected DBUS_SESSION stripped: {stdout}"
);
assert!(
stdout.contains("MISSING:DBUS_SYSTEM_BUS_ADDRESS"),
"expected DBUS_SYSTEM stripped: {stdout}"
);
}
#[test]
fn whitelist_setenv_injects_user_var() {
let stdout = printenv_inside(
&["--whitelist", "--setenv", "USER_INJECTED=forced"],
&[],
&["USER_INJECTED"],
);
assert!(stdout.contains("forced"), "setenv not applied: {stdout}");
}
#[test]
fn whitelist_unsetenv_overrides_kept_var() {
let stdout = printenv_inside(
&["--whitelist", "--unsetenv", "TERM"],
&[("TERM", "xterm-test")],
&["TERM"],
);
assert!(
stdout.contains("MISSING:TERM"),
"expected --unsetenv to strip kept var: {stdout}"
);
}
#[test]
fn blacklist_drops_token_and_secret_vars() {
let stdout = printenv_inside(
&[],
&[
("GH_TOKEN", "gh-secret"),
("AWS_SECRET_ACCESS_KEY", "aws-secret"),
("MY_PASSWORD", "pw"),
("FOO_API_KEY", "fookey"),
],
&[
"GH_TOKEN",
"AWS_SECRET_ACCESS_KEY",
"MY_PASSWORD",
"FOO_API_KEY",
],
);
for var in [
"GH_TOKEN",
"AWS_SECRET_ACCESS_KEY",
"MY_PASSWORD",
"FOO_API_KEY",
] {
assert!(
stdout.contains(&format!("MISSING:{var}")),
"expected {var} stripped in blacklist mode, got: {stdout}"
);
}
for leaked in ["gh-secret", "aws-secret", "pw", "fookey"] {
assert!(!stdout.contains(leaked), "{leaked} leaked: {stdout}");
}
}
#[test]
fn blacklist_carves_out_vendor_api_keys() {
let stdout = printenv_inside(
&[],
&[
("ANTHROPIC_API_KEY", "anthropic-key"),
("OPENAI_API_KEY", "openai-key"),
("GEMINI_API_KEY", "gemini-key"),
],
&["ANTHROPIC_API_KEY", "OPENAI_API_KEY", "GEMINI_API_KEY"],
);
for expected in ["anthropic-key", "openai-key", "gemini-key"] {
assert!(
stdout.contains(expected),
"expected {expected} to survive carve-out, got: {stdout}"
);
}
assert!(!stdout.contains("MISSING:"), "carve-out failed: {stdout}");
}
#[test]
fn blacklist_suffix_match_does_not_catch_substring() {
let stdout = printenv_inside(
&[],
&[
("TOKENIZER_PATH", "/opt/tok"),
("MY_TOKEN_HOLDER", "holder"),
],
&["TOKENIZER_PATH", "MY_TOKEN_HOLDER"],
);
assert!(
stdout.contains("/opt/tok"),
"TOKENIZER_PATH stripped: {stdout}"
);
assert!(
stdout.contains("holder"),
"MY_TOKEN_HOLDER stripped: {stdout}"
);
}
#[test]
fn blacklist_keeps_unrelated_host_var() {
let stdout = printenv_inside(&[], &[("MY_NICE_VAR", "hello")], &["MY_NICE_VAR"]);
assert!(stdout.contains("hello"), "MY_NICE_VAR stripped: {stdout}");
}
#[test]
fn blacklist_keeps_dbus_vars() {
let stdout = printenv_inside(
&[],
&[
("DBUS_SESSION_BUS_ADDRESS", "unix:path=/tmp/fake"),
("DBUS_SYSTEM_BUS_ADDRESS", "unix:path=/tmp/fake-system"),
],
&["DBUS_SESSION_BUS_ADDRESS", "DBUS_SYSTEM_BUS_ADDRESS"],
);
assert!(stdout.contains("unix:path=/tmp/fake"));
assert!(stdout.contains("unix:path=/tmp/fake-system"));
}
#[test]
fn no_env_filter_whitelist_keeps_arbitrary_host_var() {
let stdout = printenv_inside(
&["--whitelist", "--no-env-filter"],
&[("SOME_RANDOM_NOISE_VAR", "kept")],
&["SOME_RANDOM_NOISE_VAR"],
);
assert!(
stdout.contains("kept"),
"expected --no-env-filter to pass host var through, got: {stdout}"
);
}
#[test]
fn no_env_filter_blacklist_keeps_secrets() {
let stdout = printenv_inside(&["--no-env-filter"], &[("GH_TOKEN", "kept")], &["GH_TOKEN"]);
assert!(
stdout.contains("kept"),
"expected --no-env-filter to pass secrets through, got: {stdout}"
);
}
#[test]
fn no_env_filter_still_honors_user_setenv() {
let stdout = printenv_inside(
&["--no-env-filter", "--setenv", "FORCED=yes"],
&[],
&["FORCED"],
);
assert!(
stdout.contains("yes"),
"expected user --setenv to still work with --no-env-filter, got: {stdout}"
);
}
#[test]
fn blacklist_setenv_overrides_builtin_deny() {
let stdout = printenv_inside(
&["--setenv", "GH_TOKEN=overridden"],
&[("GH_TOKEN", "original")],
&["GH_TOKEN"],
);
assert!(
stdout.contains("overridden"),
"expected --setenv to override deny, got: {stdout}"
);
assert!(!stdout.contains("original"));
}
#[test]
fn seccomp_bash_pthread_fallback_works() {
// Verifies the ENOSYS-not-EPERM choice for clone3 doesn't break libc's
// clone3 -> clone fallback path that bash uses internally.
let output = sandbox(&[])
.args(["--", "bash", "-c", "true"])
.output()
.expect("agent-sandbox binary failed to execute");
assert!(
output.status.success(),
"expected bash to succeed under default seccomp (clone3 fallback), stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
}