Files
agent-sandbox/tests/e2e/mounts.rs
T

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);
}