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 } fn write_config(dir: &TempDir, content: &str) -> String { let path = dir.path().join("config.toml"); fs::write(&path, content).expect("failed to write config"); path.to_str().unwrap().to_string() } fn read_sid_from_stat(stat: &str) -> u32 { stat.trim() .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 no_net_blocks_network() { let output = sandbox(&["--no-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 dir = TempDir::new().unwrap(); let cfg = write_config(&dir, "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 dir = TempDir::new().unwrap(); let cfg = write_config(&dir, "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 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" ); }