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::>() .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) ); }