578 lines
18 KiB
Rust
578 lines
18 KiB
Rust
use std::fs;
|
|
use std::process::Command;
|
|
|
|
use tempfile::TempDir;
|
|
|
|
use crate::common::*;
|
|
|
|
struct CleanupPath(String);
|
|
|
|
impl CleanupPath {
|
|
fn new(path: impl Into<String>) -> Self {
|
|
Self(path.into())
|
|
}
|
|
}
|
|
|
|
impl Drop for CleanupPath {
|
|
fn drop(&mut self) {
|
|
match std::path::Path::new(&self.0) {
|
|
p if p.is_dir() => {
|
|
let _ = fs::remove_dir_all(p);
|
|
}
|
|
p => {
|
|
let _ = fs::remove_file(p);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
#[test]
|
|
fn cwd_is_writable() {
|
|
let _cleanup = CleanupPath::new("./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 = CleanupPath::new("/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<String> {
|
|
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
|
|
}
|
|
|
|
#[test]
|
|
fn persistent_tmp_dry_run_uses_session_path_and_creates_no_dirs() {
|
|
let label = format!("e2e-dry-{}-{}", std::process::id(), rand_suffix());
|
|
let output = Sandbox::new(&["--persistent-tmp", "--persistent-key", &label, "--dry-run"])
|
|
.args(["--", "true"])
|
|
.output()
|
|
.expect("agent-sandbox binary failed to execute");
|
|
assert!(output.status.success());
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
assert!(
|
|
stdout.contains(&format!("/tmp/agent-sandbox-{label}")),
|
|
"dry-run output should embed session /tmp path, got: {stdout}"
|
|
);
|
|
assert!(
|
|
stdout.contains(&format!("/var/tmp/agent-sandbox-{label}")),
|
|
"dry-run output should embed session /var/tmp path, got: {stdout}"
|
|
);
|
|
assert!(
|
|
!std::path::Path::new(&format!("/tmp/agent-sandbox-{label}")).exists(),
|
|
"--dry-run must not create the host directory"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn persistent_tmp_survives_across_invocations() {
|
|
let label = format!("e2e-persist-{}-{}", std::process::id(), rand_suffix());
|
|
let _cleanup_tmp = CleanupPath::new(format!("/tmp/agent-sandbox-{label}"));
|
|
let _cleanup_var = CleanupPath::new(format!("/var/tmp/agent-sandbox-{label}"));
|
|
|
|
let writer = Sandbox::new(&["--persistent-tmp", "--persistent-key", &label])
|
|
.args([
|
|
"--",
|
|
"bash",
|
|
"-c",
|
|
"echo persisted > /tmp/canary && echo also-persisted > /var/tmp/canary",
|
|
])
|
|
.output()
|
|
.expect("write run failed");
|
|
assert!(
|
|
writer.status.success(),
|
|
"stderr: {}",
|
|
String::from_utf8_lossy(&writer.stderr)
|
|
);
|
|
|
|
let reader = Sandbox::new(&["--persistent-tmp", "--persistent-key", &label])
|
|
.args(["--", "bash", "-c", "cat /tmp/canary /var/tmp/canary"])
|
|
.output()
|
|
.expect("read run failed");
|
|
assert!(
|
|
reader.status.success(),
|
|
"stderr: {}",
|
|
String::from_utf8_lossy(&reader.stderr)
|
|
);
|
|
let out = String::from_utf8_lossy(&reader.stdout);
|
|
assert!(
|
|
out.contains("persisted"),
|
|
"expected /tmp canary, got: {out}"
|
|
);
|
|
assert!(
|
|
out.contains("also-persisted"),
|
|
"expected /var/tmp canary, got: {out}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn persistent_tmp_is_noop_in_blacklist_mode() {
|
|
let label = format!("e2e-bl-noop-{}-{}", std::process::id(), rand_suffix());
|
|
let canary_name = format!(
|
|
"agent-sandbox-bl-canary-{}-{}",
|
|
std::process::id(),
|
|
rand_suffix()
|
|
);
|
|
let canary_host = format!("/tmp/{canary_name}");
|
|
let _cleanup_canary = CleanupPath::new(&canary_host);
|
|
|
|
let output = Sandbox::new(&[
|
|
"--blacklist",
|
|
"--persistent-tmp",
|
|
"--persistent-key",
|
|
&label,
|
|
])
|
|
.args([
|
|
"--",
|
|
"bash",
|
|
"-c",
|
|
&format!("echo bl-shared > /tmp/{canary_name}"),
|
|
])
|
|
.output()
|
|
.expect("write run failed");
|
|
assert!(
|
|
output.status.success(),
|
|
"stderr: {}",
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
|
|
assert!(
|
|
std::path::Path::new(&canary_host).exists(),
|
|
"blacklist /tmp should be host /tmp; expected canary at {canary_host}"
|
|
);
|
|
assert!(
|
|
!std::path::Path::new(&format!("/tmp/agent-sandbox-{label}")).exists(),
|
|
"--persistent-tmp must be a no-op in blacklist mode, but session dir was created"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn persistent_tmp_resumes_with_explicit_key_matching_derived_hash() {
|
|
let chdir = TempDir::new().unwrap();
|
|
let chdir_str = chdir.path().to_str().unwrap();
|
|
|
|
let dry = Sandbox::new(&["--persistent-tmp", "--dry-run", "--chdir", chdir_str])
|
|
.args(["--", "true"])
|
|
.output()
|
|
.expect("dry-run failed");
|
|
assert!(dry.status.success());
|
|
let key = extract_persistent_key(&String::from_utf8_lossy(&dry.stdout));
|
|
let _cleanup_tmp = CleanupPath::new(format!("/tmp/agent-sandbox-{key}"));
|
|
let _cleanup_var = CleanupPath::new(format!("/var/tmp/agent-sandbox-{key}"));
|
|
|
|
let writer = Sandbox::new(&["--persistent-tmp", "--chdir", chdir_str])
|
|
.args(["--", "bash", "-c", "echo persisted > /tmp/canary"])
|
|
.output()
|
|
.expect("write run failed");
|
|
assert!(
|
|
writer.status.success(),
|
|
"stderr: {}",
|
|
String::from_utf8_lossy(&writer.stderr)
|
|
);
|
|
|
|
// Second run: different chdir (would have derived a different hash) plus an
|
|
// extra flag, but an explicit key pinning the same host directory.
|
|
let other_chdir = TempDir::new().unwrap();
|
|
let reader = Sandbox::new(&[
|
|
"--persistent-tmp",
|
|
"--persistent-key",
|
|
&key,
|
|
"--chdir",
|
|
other_chdir.path().to_str().unwrap(),
|
|
"--unshare-net",
|
|
])
|
|
.args(["--", "cat", "/tmp/canary"])
|
|
.output()
|
|
.expect("read run failed");
|
|
assert!(
|
|
reader.status.success(),
|
|
"stderr: {}",
|
|
String::from_utf8_lossy(&reader.stderr)
|
|
);
|
|
assert!(String::from_utf8_lossy(&reader.stdout).contains("persisted"));
|
|
}
|
|
|
|
fn extract_persistent_key(dryrun_stdout: &str) -> String {
|
|
let prefix = "/tmp/agent-sandbox-";
|
|
let idx = dryrun_stdout
|
|
.find(prefix)
|
|
.expect("dry-run output should mention /tmp/agent-sandbox-<key>");
|
|
dryrun_stdout[idx + prefix.len()..]
|
|
.chars()
|
|
.take_while(|c| c.is_ascii_hexdigit())
|
|
.collect()
|
|
}
|
|
|
|
fn rand_suffix() -> String {
|
|
let nanos = std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.unwrap()
|
|
.subsec_nanos();
|
|
format!("{nanos:08x}")
|
|
}
|
|
|
|
fn inject_absolute_var_run_symlink(bwrap_args: &mut Vec<String>) {
|
|
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);
|
|
}
|