use std::fs; use std::process::Command; use tempfile::TempDir; use crate::common::*; 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::new(&[]) .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::new(&[]) .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::new(&[]) .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 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::new(&["--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::new(&["--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 ro_mount_with_remapped_target() { 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 src_str = dir.path().to_str().unwrap(); let target = "/tmp/agent-sandbox-remap-test"; let output = Sandbox::new(&["--ro", &format!("{src_str}:{target}")]) .args([ "--", "bash", "-c", &format!("cat {target}/hello.txt && ls {src_str} 2>&1 | head -1 || echo src_hidden"), ]) .output() .expect("agent-sandbox binary failed to execute"); let stdout = String::from_utf8_lossy(&output.stdout); assert!( stdout.contains("hi"), "expected file content 'hi' at remapped target, got: {stdout}" ); } #[test] fn rw_refines_ro_parent() { let parent = TempDir::new().expect("failed to create temp dir"); let child = parent.path().join("sub"); fs::create_dir(&child).expect("failed to create sub dir"); fs::write(parent.path().join("top.txt"), "top").expect("write"); fs::write(child.join("inner.txt"), "inner").expect("write"); let parent_str = parent.path().to_str().unwrap(); let child_str = child.to_str().unwrap(); let output = Sandbox::new(&["--ro", parent_str, "--rw", child_str]) .args([ "--", "bash", "-c", &format!( "touch {parent_str}/top_new 2>&1 || echo parent_ro; \ touch {child_str}/child_new && echo child_rw" ), ]) .output() .expect("agent-sandbox binary failed to execute"); let stdout = String::from_utf8_lossy(&output.stdout); assert!( stdout.contains("parent_ro"), "parent should be read-only, got: {stdout}" ); assert!( stdout.contains("child_rw"), "child should be writable, got: {stdout}" ); } #[test] fn blacklist_overlays_survive_tmp_bind() { let mut sandbox = Sandbox::new_for_host_mutation(&["--blacklist"]); 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::new(&["--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::new(&["--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::new(&["--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 rw_missing_path_errors() { let output = Sandbox::new(&["--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 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::new(&["--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::new(&["--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 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::new(&["--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::new(&["--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 blacklist_overlays_survive_absolute_var_run_symlink() { // On Debian/Ubuntu, /var/run -> /run is an absolute symlink; overlays // like --tmpfs /var/run/dbus trip bwrap's re-rooted symlink resolution. // Arch ships /var/run -> ../run (relative) so we synthesize the absolute // layout inside the sandbox to reproduce on any host. let _guard = HostGlobsLock::for_scan(); let mut bwrap_args = build_bwrap_command(&["--blacklist", "--no-seccomp", "--", "true"]); inject_absolute_var_run_symlink(&mut bwrap_args); let output = Command::new(&bwrap_args[0]) .args(&bwrap_args[1..]) .output() .expect("failed to invoke bwrap directly"); assert!( output.status.success(), "bwrap failed — an overlay target traverses an absolute /var/run symlink.\n\ stderr: {}", String::from_utf8_lossy(&output.stderr), ); } fn build_bwrap_command(sandbox_args: &[&str]) -> Vec { let output = Sandbox::new(&["--dry-run"]) .args(sandbox_args) .output() .expect("agent-sandbox binary failed to execute"); let cmd = String::from_utf8_lossy(&output.stdout); let parsed = shlex::split(cmd.trim()).expect("dry-run output is not valid shell"); assert_eq!(parsed[0], "bwrap"); parsed } fn inject_absolute_var_run_symlink(bwrap_args: &mut Vec) { assert_eq!(bwrap_args[1], "--ro-bind"); assert_eq!(bwrap_args[2], "/"); assert_eq!(bwrap_args[3], "/"); let flags = ["--tmpfs", "/var", "--symlink", "/run", "/var/run"].map(String::from); bwrap_args.splice(4..4, flags); }