393 lines
12 KiB
Rust
393 lines
12 KiB
Rust
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(&[])
|
|
.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 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 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(&["--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(&["--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() {
|
|
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 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 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 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 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 mut bwrap_args = build_bwrap_command(&["--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(&["--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<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);
|
|
}
|