Whitelist mode now clears the parent env and re-adds a small allowlist (identity, terminal, locale, proxy, non-GUI XDG, vendor prefixes). Blacklist mode strips cloud credentials, backup passphrases, dangling socket pointers, and anything matching *_TOKEN, *_SECRET, *_PASSWORD, *_PASSPHRASE, *_API_KEY, *_PRIVATE_KEY, *_CLIENT_SECRET; vendor prefix carve-outs keep ANTHROPIC_API_KEY and friends. Users can override via --setenv KEY=VALUE and --unsetenv KEY (and the corresponding TOML keys), or opt out of the built-in policy entirely with --no-env-filter.
1300 lines
36 KiB
Rust
1300 lines
36 KiB
Rust
use std::fs;
|
|
use std::process::Command;
|
|
|
|
use tempfile::TempDir;
|
|
|
|
fn sandbox_withconfig(extra_args: &[&str]) -> Command {
|
|
let mut cmd = Command::new(env!("CARGO_BIN_EXE_agent-sandbox"));
|
|
cmd.args(extra_args);
|
|
cmd
|
|
}
|
|
|
|
fn sandbox(extra_args: &[&str]) -> Command {
|
|
let mut cmd = sandbox_withconfig(&["--no-config"]);
|
|
cmd.args(extra_args);
|
|
cmd
|
|
}
|
|
|
|
struct ConfigFile {
|
|
_dir: TempDir,
|
|
path: String,
|
|
}
|
|
|
|
impl ConfigFile {
|
|
fn new(content: &str) -> Self {
|
|
let dir = TempDir::new().unwrap();
|
|
let path = dir.path().join("config.toml");
|
|
fs::write(&path, content).expect("failed to write config");
|
|
Self {
|
|
_dir: dir,
|
|
path: path.to_str().unwrap().to_string(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl std::ops::Deref for ConfigFile {
|
|
type Target = str;
|
|
fn deref(&self) -> &str {
|
|
&self.path
|
|
}
|
|
}
|
|
|
|
fn read_sid_from_stat(stat: &str) -> u32 {
|
|
stat.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 unshare_net_blocks_network() {
|
|
let output = sandbox(&["--unshare-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 dry_run_output_is_copy_pasteable_shell() {
|
|
let dry = sandbox(&["--dry-run"])
|
|
.args(["--", "bash", "-c", "echo $HOME"])
|
|
.output()
|
|
.expect("agent-sandbox binary failed to execute");
|
|
|
|
let dry_cmd = String::from_utf8_lossy(&dry.stdout).trim().to_string();
|
|
let args = shlex::split(&dry_cmd).expect("dry-run output is not valid shell");
|
|
assert_eq!(args[0], "bwrap");
|
|
assert_eq!(args[args.len() - 1], "echo $HOME");
|
|
assert_eq!(args[args.len() - 2], "-c");
|
|
}
|
|
|
|
#[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_withconfig(&["--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 cfg = ConfigFile::new("not valid {{{{ toml");
|
|
|
|
let output = sandbox_withconfig(&["--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}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn config_unknown_key_errors() {
|
|
let cfg = ConfigFile::new("hardened = true\nbogus = \"nope\"\n");
|
|
|
|
let output = sandbox_withconfig(&["--config", &cfg])
|
|
.args(["--", "true"])
|
|
.output()
|
|
.expect("failed to execute");
|
|
|
|
assert!(!output.status.success());
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
assert!(
|
|
stderr.contains("unknown config key"),
|
|
"expected unknown key error, 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 bwrap_arg_setenv_passes_through() {
|
|
let output = sandbox(&["--bwrap-arg", "--setenv MYVAR hello"])
|
|
.args(["--", "bash", "-c", "echo $MYVAR"])
|
|
.output()
|
|
.expect("agent-sandbox binary failed to execute");
|
|
|
|
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
|
assert_eq!(
|
|
stdout, "hello",
|
|
"expected --bwrap-arg to pass --setenv through to bwrap, got: {stdout}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn config_entrypoint_appends_passthrough_args() {
|
|
let cfg = ConfigFile::new(
|
|
r#"
|
|
[profile.test]
|
|
entrypoint = ["bash", "-c"]
|
|
"#,
|
|
);
|
|
|
|
let output = sandbox_withconfig(&["--config", &cfg, "--profile", "test"])
|
|
.args(["--", "echo entrypoint-works"])
|
|
.output()
|
|
.expect("failed to execute");
|
|
|
|
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
|
assert_eq!(
|
|
stdout, "entrypoint-works",
|
|
"expected passthrough args appended to entrypoint, got: {stdout}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn config_entrypoint_falls_back_to_command_defaults() {
|
|
let cfg = ConfigFile::new(
|
|
r#"
|
|
[profile.test]
|
|
entrypoint = ["bash", "-c"]
|
|
command = ["echo default-args"]
|
|
"#,
|
|
);
|
|
|
|
let output = sandbox_withconfig(&["--config", &cfg, "--profile", "test"])
|
|
.output()
|
|
.expect("failed to execute");
|
|
|
|
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
|
assert_eq!(
|
|
stdout, "default-args",
|
|
"expected command defaults when no passthrough args, got: {stdout}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn config_entrypoint_alone_without_command_or_passthrough() {
|
|
let cfg = ConfigFile::new(
|
|
r#"
|
|
[profile.test]
|
|
entrypoint = ["bash", "-c", "echo entrypoint-only"]
|
|
"#,
|
|
);
|
|
|
|
let output = sandbox_withconfig(&["--config", &cfg, "--profile", "test"])
|
|
.output()
|
|
.expect("failed to execute");
|
|
|
|
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
|
assert_eq!(
|
|
stdout, "entrypoint-only",
|
|
"expected entrypoint to run on its own, got: {stdout}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn cli_entrypoint_appends_passthrough_args() {
|
|
let output = sandbox(&["--entrypoint", "bash"])
|
|
.args(["--", "-c", "echo cli-entrypoint-works"])
|
|
.output()
|
|
.expect("failed to execute");
|
|
|
|
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
|
assert_eq!(
|
|
stdout, "cli-entrypoint-works",
|
|
"expected --entrypoint to receive trailing args, got: {stdout}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn cli_entrypoint_overrides_config_entrypoint() {
|
|
let cfg = ConfigFile::new(
|
|
r#"
|
|
entrypoint = ["/bin/false"]
|
|
"#,
|
|
);
|
|
|
|
let output = sandbox_withconfig(&["--config", &cfg, "--entrypoint", "bash"])
|
|
.args(["--", "-c", "echo override-works"])
|
|
.output()
|
|
.expect("failed to execute");
|
|
|
|
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
|
assert_eq!(
|
|
stdout, "override-works",
|
|
"expected CLI --entrypoint to override config entrypoint, got: {stdout}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn config_command_alone_without_passthrough() {
|
|
let cfg = ConfigFile::new(
|
|
r#"
|
|
[profile.test]
|
|
command = ["bash", "-c", "echo command-only"]
|
|
"#,
|
|
);
|
|
|
|
let output = sandbox_withconfig(&["--config", &cfg, "--profile", "test"])
|
|
.output()
|
|
.expect("failed to execute");
|
|
|
|
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
|
assert_eq!(
|
|
stdout, "command-only",
|
|
"expected config command to run on its own, got: {stdout}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn config_command_replaced_by_passthrough() {
|
|
let cfg = ConfigFile::new(
|
|
r#"
|
|
[profile.test]
|
|
command = ["bash", "-c", "echo should-not-see-this"]
|
|
"#,
|
|
);
|
|
|
|
let output = sandbox_withconfig(&["--config", &cfg, "--profile", "test"])
|
|
.args(["--", "bash", "-c", "echo replaced"])
|
|
.output()
|
|
.expect("failed to execute");
|
|
|
|
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
|
assert_eq!(
|
|
stdout, "replaced",
|
|
"expected passthrough to replace config command, 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 seccomp_on_by_default_blocks_unshare() {
|
|
let output = sandbox(&[])
|
|
.args(["--", "unshare", "--user", "--map-root-user", "/bin/true"])
|
|
.output()
|
|
.expect("agent-sandbox binary failed to execute");
|
|
|
|
assert!(
|
|
!output.status.success(),
|
|
"expected unshare(2) to be blocked by default seccomp filter, but it succeeded"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn seccomp_off_allows_blocked_syscall() {
|
|
let output = sandbox(&["--no-seccomp"])
|
|
.args(["--", "unshare", "--user", "--map-root-user", "/bin/true"])
|
|
.output()
|
|
.expect("agent-sandbox binary failed to execute");
|
|
|
|
assert!(
|
|
output.status.success(),
|
|
"expected unshare(2) to succeed without seccomp, stderr: {}",
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn seccomp_dry_run_emits_seccomp_arg() {
|
|
let output = sandbox(&["--dry-run"])
|
|
.args(["--", "/bin/true"])
|
|
.output()
|
|
.expect("agent-sandbox binary failed to execute");
|
|
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
assert!(
|
|
stdout.contains("--seccomp"),
|
|
"expected --seccomp in dry-run output, got: {stdout}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn seccomp_dry_run_no_seccomp_omits_arg() {
|
|
let output = sandbox(&["--dry-run", "--no-seccomp"])
|
|
.args(["--", "/bin/true"])
|
|
.output()
|
|
.expect("agent-sandbox binary failed to execute");
|
|
|
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
|
assert!(
|
|
!stdout.contains("--seccomp"),
|
|
"expected no --seccomp in dry-run output with --no-seccomp, got: {stdout}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn seccomp_normal_workload_succeeds() {
|
|
let output = sandbox(&[])
|
|
.args(["--", "bash", "-c", "ls /etc > /dev/null && date"])
|
|
.output()
|
|
.expect("agent-sandbox binary failed to execute");
|
|
|
|
assert!(
|
|
output.status.success(),
|
|
"expected normal workload to succeed under default seccomp, stderr: {}",
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
}
|
|
|
|
fn printenv_inside(args: &[&str], vars: &[(&str, &str)], query: &[&str]) -> String {
|
|
let script = query
|
|
.iter()
|
|
.map(|v| format!("printenv {v} || echo MISSING:{v}"))
|
|
.collect::<Vec<_>>()
|
|
.join("; ");
|
|
let mut cmd = sandbox(args);
|
|
for (k, v) in vars {
|
|
cmd.env(k, v);
|
|
}
|
|
let output = cmd
|
|
.args(["--", "bash", "-c", &script])
|
|
.output()
|
|
.expect("agent-sandbox binary failed to execute");
|
|
String::from_utf8_lossy(&output.stdout).into_owned()
|
|
}
|
|
|
|
#[test]
|
|
fn whitelist_keeps_identity_and_terminal_vars() {
|
|
let stdout = printenv_inside(
|
|
&["--whitelist"],
|
|
&[("TERM", "xterm-test"), ("LANG", "C.UTF-8")],
|
|
&["HOME", "PATH", "TERM", "LANG"],
|
|
);
|
|
assert!(!stdout.contains("MISSING:HOME"), "HOME stripped: {stdout}");
|
|
assert!(!stdout.contains("MISSING:PATH"), "PATH stripped: {stdout}");
|
|
assert!(stdout.contains("xterm-test"), "TERM stripped: {stdout}");
|
|
assert!(stdout.contains("C.UTF-8"), "LANG stripped: {stdout}");
|
|
}
|
|
|
|
#[test]
|
|
fn whitelist_strips_arbitrary_host_var() {
|
|
let stdout = printenv_inside(
|
|
&["--whitelist"],
|
|
&[("SOME_RANDOM_NOISE_VAR", "leak")],
|
|
&["SOME_RANDOM_NOISE_VAR"],
|
|
);
|
|
assert!(
|
|
stdout.contains("MISSING:SOME_RANDOM_NOISE_VAR"),
|
|
"expected arbitrary host var to be stripped, got: {stdout}"
|
|
);
|
|
assert!(!stdout.contains("leak"));
|
|
}
|
|
|
|
#[test]
|
|
fn whitelist_keeps_vendor_prefixes() {
|
|
let stdout = printenv_inside(
|
|
&["--whitelist"],
|
|
&[
|
|
("CLAUDE_FOO", "claude-val"),
|
|
("ANTHROPIC_MODEL", "anthropic-val"),
|
|
("OPENAI_API_KEY", "openai-val"),
|
|
("CODEX_FOO", "codex-val"),
|
|
("GEMINI_API_KEY", "gemini-val"),
|
|
("OTEL_SERVICE_NAME", "otel-val"),
|
|
],
|
|
&[
|
|
"CLAUDE_FOO",
|
|
"ANTHROPIC_MODEL",
|
|
"OPENAI_API_KEY",
|
|
"CODEX_FOO",
|
|
"GEMINI_API_KEY",
|
|
"OTEL_SERVICE_NAME",
|
|
],
|
|
);
|
|
for expected in [
|
|
"claude-val",
|
|
"anthropic-val",
|
|
"openai-val",
|
|
"codex-val",
|
|
"gemini-val",
|
|
"otel-val",
|
|
] {
|
|
assert!(
|
|
stdout.contains(expected),
|
|
"expected {expected} in output, got: {stdout}"
|
|
);
|
|
}
|
|
assert!(!stdout.contains("MISSING:"), "unexpected strip: {stdout}");
|
|
}
|
|
|
|
#[test]
|
|
fn whitelist_keeps_lc_prefix() {
|
|
let stdout = printenv_inside(
|
|
&["--whitelist"],
|
|
&[("LC_TIME", "en_US.UTF-8")],
|
|
&["LC_TIME"],
|
|
);
|
|
assert!(stdout.contains("en_US.UTF-8"), "LC_TIME missing: {stdout}");
|
|
}
|
|
|
|
#[test]
|
|
fn whitelist_keeps_non_gui_xdg_vars() {
|
|
let stdout = printenv_inside(
|
|
&["--whitelist"],
|
|
&[
|
|
("XDG_CONFIG_HOME", "/cfg"),
|
|
("XDG_DATA_HOME", "/data"),
|
|
("XDG_CACHE_HOME", "/cache"),
|
|
("XDG_STATE_HOME", "/state"),
|
|
("XDG_CONFIG_DIRS", "/etc/xdg"),
|
|
("XDG_DATA_DIRS", "/usr/share"),
|
|
],
|
|
&[
|
|
"XDG_CONFIG_HOME",
|
|
"XDG_DATA_HOME",
|
|
"XDG_CACHE_HOME",
|
|
"XDG_STATE_HOME",
|
|
"XDG_CONFIG_DIRS",
|
|
"XDG_DATA_DIRS",
|
|
],
|
|
);
|
|
assert!(
|
|
!stdout.contains("MISSING:"),
|
|
"XDG non-GUI stripped: {stdout}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn whitelist_strips_gui_xdg_vars() {
|
|
let stdout = printenv_inside(
|
|
&["--whitelist"],
|
|
&[
|
|
("XDG_RUNTIME_DIR", "/run/user/1000"),
|
|
("XDG_SESSION_ID", "1"),
|
|
("XDG_CURRENT_DESKTOP", "KDE"),
|
|
("XDG_SEAT", "seat0"),
|
|
],
|
|
&[
|
|
"XDG_RUNTIME_DIR",
|
|
"XDG_SESSION_ID",
|
|
"XDG_CURRENT_DESKTOP",
|
|
"XDG_SEAT",
|
|
],
|
|
);
|
|
for var in [
|
|
"XDG_RUNTIME_DIR",
|
|
"XDG_SESSION_ID",
|
|
"XDG_CURRENT_DESKTOP",
|
|
"XDG_SEAT",
|
|
] {
|
|
assert!(
|
|
stdout.contains(&format!("MISSING:{var}")),
|
|
"expected {var} stripped, got: {stdout}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn whitelist_strips_dbus_vars() {
|
|
let stdout = printenv_inside(
|
|
&["--whitelist"],
|
|
&[
|
|
("DBUS_SESSION_BUS_ADDRESS", "unix:path=/foo"),
|
|
("DBUS_SYSTEM_BUS_ADDRESS", "unix:path=/bar"),
|
|
],
|
|
&["DBUS_SESSION_BUS_ADDRESS", "DBUS_SYSTEM_BUS_ADDRESS"],
|
|
);
|
|
assert!(
|
|
stdout.contains("MISSING:DBUS_SESSION_BUS_ADDRESS"),
|
|
"expected DBUS_SESSION stripped: {stdout}"
|
|
);
|
|
assert!(
|
|
stdout.contains("MISSING:DBUS_SYSTEM_BUS_ADDRESS"),
|
|
"expected DBUS_SYSTEM stripped: {stdout}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn whitelist_setenv_injects_user_var() {
|
|
let stdout = printenv_inside(
|
|
&["--whitelist", "--setenv", "USER_INJECTED=forced"],
|
|
&[],
|
|
&["USER_INJECTED"],
|
|
);
|
|
assert!(stdout.contains("forced"), "setenv not applied: {stdout}");
|
|
}
|
|
|
|
#[test]
|
|
fn whitelist_unsetenv_overrides_kept_var() {
|
|
let stdout = printenv_inside(
|
|
&["--whitelist", "--unsetenv", "TERM"],
|
|
&[("TERM", "xterm-test")],
|
|
&["TERM"],
|
|
);
|
|
assert!(
|
|
stdout.contains("MISSING:TERM"),
|
|
"expected --unsetenv to strip kept var: {stdout}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn blacklist_drops_token_and_secret_vars() {
|
|
let stdout = printenv_inside(
|
|
&[],
|
|
&[
|
|
("GH_TOKEN", "gh-secret"),
|
|
("AWS_SECRET_ACCESS_KEY", "aws-secret"),
|
|
("MY_PASSWORD", "pw"),
|
|
("FOO_API_KEY", "fookey"),
|
|
],
|
|
&[
|
|
"GH_TOKEN",
|
|
"AWS_SECRET_ACCESS_KEY",
|
|
"MY_PASSWORD",
|
|
"FOO_API_KEY",
|
|
],
|
|
);
|
|
for var in [
|
|
"GH_TOKEN",
|
|
"AWS_SECRET_ACCESS_KEY",
|
|
"MY_PASSWORD",
|
|
"FOO_API_KEY",
|
|
] {
|
|
assert!(
|
|
stdout.contains(&format!("MISSING:{var}")),
|
|
"expected {var} stripped in blacklist mode, got: {stdout}"
|
|
);
|
|
}
|
|
for leaked in ["gh-secret", "aws-secret", "pw", "fookey"] {
|
|
assert!(!stdout.contains(leaked), "{leaked} leaked: {stdout}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn blacklist_carves_out_vendor_api_keys() {
|
|
let stdout = printenv_inside(
|
|
&[],
|
|
&[
|
|
("ANTHROPIC_API_KEY", "anthropic-key"),
|
|
("OPENAI_API_KEY", "openai-key"),
|
|
("GEMINI_API_KEY", "gemini-key"),
|
|
],
|
|
&["ANTHROPIC_API_KEY", "OPENAI_API_KEY", "GEMINI_API_KEY"],
|
|
);
|
|
for expected in ["anthropic-key", "openai-key", "gemini-key"] {
|
|
assert!(
|
|
stdout.contains(expected),
|
|
"expected {expected} to survive carve-out, got: {stdout}"
|
|
);
|
|
}
|
|
assert!(!stdout.contains("MISSING:"), "carve-out failed: {stdout}");
|
|
}
|
|
|
|
#[test]
|
|
fn blacklist_suffix_match_does_not_catch_substring() {
|
|
let stdout = printenv_inside(
|
|
&[],
|
|
&[
|
|
("TOKENIZER_PATH", "/opt/tok"),
|
|
("MY_TOKEN_HOLDER", "holder"),
|
|
],
|
|
&["TOKENIZER_PATH", "MY_TOKEN_HOLDER"],
|
|
);
|
|
assert!(
|
|
stdout.contains("/opt/tok"),
|
|
"TOKENIZER_PATH stripped: {stdout}"
|
|
);
|
|
assert!(
|
|
stdout.contains("holder"),
|
|
"MY_TOKEN_HOLDER stripped: {stdout}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn blacklist_keeps_unrelated_host_var() {
|
|
let stdout = printenv_inside(&[], &[("MY_NICE_VAR", "hello")], &["MY_NICE_VAR"]);
|
|
assert!(stdout.contains("hello"), "MY_NICE_VAR stripped: {stdout}");
|
|
}
|
|
|
|
#[test]
|
|
fn blacklist_keeps_dbus_vars() {
|
|
let stdout = printenv_inside(
|
|
&[],
|
|
&[
|
|
("DBUS_SESSION_BUS_ADDRESS", "unix:path=/tmp/fake"),
|
|
("DBUS_SYSTEM_BUS_ADDRESS", "unix:path=/tmp/fake-system"),
|
|
],
|
|
&["DBUS_SESSION_BUS_ADDRESS", "DBUS_SYSTEM_BUS_ADDRESS"],
|
|
);
|
|
assert!(stdout.contains("unix:path=/tmp/fake"));
|
|
assert!(stdout.contains("unix:path=/tmp/fake-system"));
|
|
}
|
|
|
|
#[test]
|
|
fn no_env_filter_whitelist_keeps_arbitrary_host_var() {
|
|
let stdout = printenv_inside(
|
|
&["--whitelist", "--no-env-filter"],
|
|
&[("SOME_RANDOM_NOISE_VAR", "kept")],
|
|
&["SOME_RANDOM_NOISE_VAR"],
|
|
);
|
|
assert!(
|
|
stdout.contains("kept"),
|
|
"expected --no-env-filter to pass host var through, got: {stdout}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn no_env_filter_blacklist_keeps_secrets() {
|
|
let stdout = printenv_inside(&["--no-env-filter"], &[("GH_TOKEN", "kept")], &["GH_TOKEN"]);
|
|
assert!(
|
|
stdout.contains("kept"),
|
|
"expected --no-env-filter to pass secrets through, got: {stdout}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn no_env_filter_still_honors_user_setenv() {
|
|
let stdout = printenv_inside(
|
|
&["--no-env-filter", "--setenv", "FORCED=yes"],
|
|
&[],
|
|
&["FORCED"],
|
|
);
|
|
assert!(
|
|
stdout.contains("yes"),
|
|
"expected user --setenv to still work with --no-env-filter, got: {stdout}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn blacklist_setenv_overrides_builtin_deny() {
|
|
let stdout = printenv_inside(
|
|
&["--setenv", "GH_TOKEN=overridden"],
|
|
&[("GH_TOKEN", "original")],
|
|
&["GH_TOKEN"],
|
|
);
|
|
assert!(
|
|
stdout.contains("overridden"),
|
|
"expected --setenv to override deny, got: {stdout}"
|
|
);
|
|
assert!(!stdout.contains("original"));
|
|
}
|
|
|
|
#[test]
|
|
fn seccomp_bash_pthread_fallback_works() {
|
|
// Verifies the ENOSYS-not-EPERM choice for clone3 doesn't break libc's
|
|
// clone3 -> clone fallback path that bash uses internally.
|
|
let output = sandbox(&[])
|
|
.args(["--", "bash", "-c", "true"])
|
|
.output()
|
|
.expect("agent-sandbox binary failed to execute");
|
|
|
|
assert!(
|
|
output.status.success(),
|
|
"expected bash to succeed under default seccomp (clone3 fallback), stderr: {}",
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
}
|