580 lines
16 KiB
Rust
580 lines
16 KiB
Rust
use std::fs;
|
|
use std::process::Command;
|
|
|
|
use tempfile::TempDir;
|
|
|
|
fn sandbox(extra_args: &[&str]) -> Command {
|
|
let mut cmd = Command::new(env!("CARGO_BIN_EXE_agent-sandbox"));
|
|
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 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(&["--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(&["--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}"
|
|
);
|
|
}
|