use std::path::Path;
use tokio::process::Command;
use super::{SandboxPolicy, SandboxStrategy};
pub struct SeatbeltStrategy;
impl SandboxStrategy for SeatbeltStrategy {
fn name(&self) -> &'static str {
"seatbelt"
}
fn wrap_command(&self, cmd: Command, policy: &SandboxPolicy) -> Command {
let profile = build_profile(policy);
let std_cmd = cmd.as_std();
let program = std_cmd.get_program().to_os_string();
let args: Vec<_> = std_cmd.get_args().map(|a| a.to_os_string()).collect();
let current_dir = std_cmd.get_current_dir().map(Path::to_path_buf);
let envs: Vec<(std::ffi::OsString, Option<std::ffi::OsString>)> = std_cmd
.get_envs()
.map(|(k, v)| (k.to_os_string(), v.map(|v| v.to_os_string())))
.collect();
let mut wrapped = Command::new("sandbox-exec");
wrapped.arg("-p").arg(profile);
wrapped.arg(program);
wrapped.args(args);
if let Some(dir) = current_dir {
wrapped.current_dir(dir);
}
for (k, v) in envs {
match v {
Some(val) => {
wrapped.env(k, val);
}
None => {
wrapped.env_remove(k);
}
}
}
wrapped
}
}
pub(super) fn build_profile(policy: &SandboxPolicy) -> String {
let mut profile = String::new();
profile.push_str("(version 1)\n");
profile.push_str("(deny default)\n");
profile.push_str("(import \"system.sb\")\n");
profile.push_str("(allow process-fork)\n");
profile.push_str("(allow process-exec*)\n");
profile.push_str("(allow signal)\n");
profile.push_str("(allow sysctl-read)\n");
profile.push_str("(allow file-read*)\n");
push_subpath_allow(&mut profile, &policy.project_dir);
for p in &policy.allowed_write_paths {
push_subpath_allow(&mut profile, p);
}
for p in &policy.forbidden_paths {
push_subpath_deny_read(&mut profile, p);
}
if policy.allow_network {
profile.push_str("(allow network*)\n");
}
profile
}
fn push_subpath_allow(profile: &mut String, path: &Path) {
for variant in path_variants(path) {
let escaped = escape_sbpl(&variant.display().to_string());
profile.push_str(&format!("(allow file-write* (subpath \"{escaped}\"))\n"));
}
}
fn push_subpath_deny_read(profile: &mut String, path: &Path) {
for variant in path_variants(path) {
let escaped = escape_sbpl(&variant.display().to_string());
profile.push_str(&format!("(deny file-read* (subpath \"{escaped}\"))\n"));
}
}
fn path_variants(path: &Path) -> Vec<std::path::PathBuf> {
let mut out = vec![path.to_path_buf()];
if let Ok(canonical) = std::fs::canonicalize(path)
&& canonical != path
{
out.push(canonical);
}
out
}
fn escape_sbpl(s: &str) -> String {
s.replace('\\', "\\\\").replace('"', "\\\"")
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn test_policy() -> SandboxPolicy {
SandboxPolicy {
project_dir: PathBuf::from("/work/repo"),
allowed_write_paths: vec![
PathBuf::from("/tmp"),
PathBuf::from("/Users/test/.cache/agent-code"),
],
forbidden_paths: vec![PathBuf::from("/Users/test/.ssh")],
allow_network: false,
}
}
#[test]
fn profile_denies_by_default() {
let p = build_profile(&test_policy());
assert!(p.contains("(deny default)"));
}
#[test]
fn profile_allows_reads_broadly() {
let p = build_profile(&test_policy());
assert!(p.contains("(allow file-read*)"));
}
#[test]
fn profile_allows_project_writes() {
let p = build_profile(&test_policy());
assert!(p.contains("(allow file-write* (subpath \"/work/repo\"))"));
}
#[test]
fn profile_allows_extra_write_paths() {
let p = build_profile(&test_policy());
assert!(p.contains("(allow file-write* (subpath \"/tmp\"))"));
assert!(p.contains("(allow file-write* (subpath \"/Users/test/.cache/agent-code\"))"));
}
#[test]
fn profile_denies_forbidden_paths() {
let p = build_profile(&test_policy());
assert!(p.contains("(deny file-read* (subpath \"/Users/test/.ssh\"))"));
}
#[test]
fn profile_skips_network_when_disabled() {
let p = build_profile(&test_policy());
assert!(!p.contains("network*"));
}
#[test]
fn profile_allows_network_when_enabled() {
let mut policy = test_policy();
policy.allow_network = true;
let p = build_profile(&policy);
assert!(p.contains("(allow network*)"));
}
#[test]
fn profile_escapes_double_quotes_in_paths() {
let policy = SandboxPolicy {
project_dir: PathBuf::from("/weird\"path"),
allowed_write_paths: vec![],
forbidden_paths: vec![],
allow_network: false,
};
let p = build_profile(&policy);
assert!(p.contains("\"/weird\\\"path\""));
}
#[test]
fn profile_empty_allow_and_forbid_lists_still_builds() {
let policy = SandboxPolicy {
project_dir: PathBuf::from("/work/repo"),
allowed_write_paths: vec![],
forbidden_paths: vec![],
allow_network: false,
};
let p = build_profile(&policy);
assert!(p.contains("(allow file-write* (subpath \"/work/repo\"))"));
assert!(p.contains("(deny default)"));
}
#[test]
fn profile_multiple_forbidden_paths_all_appear() {
let policy = SandboxPolicy {
project_dir: PathBuf::from("/work/repo"),
allowed_write_paths: vec![],
forbidden_paths: vec![
PathBuf::from("/Users/test/.ssh"),
PathBuf::from("/Users/test/.aws"),
PathBuf::from("/Users/test/.gnupg"),
],
allow_network: false,
};
let p = build_profile(&policy);
assert!(p.contains("/Users/test/.ssh"));
assert!(p.contains("/Users/test/.aws"));
assert!(p.contains("/Users/test/.gnupg"));
}
#[test]
fn profile_contains_process_and_signal_allows() {
let p = build_profile(&test_policy());
assert!(p.contains("(allow process-fork)"));
assert!(p.contains("(allow process-exec*)"));
assert!(p.contains("(allow signal)"));
}
#[test]
fn profile_imports_system_sb() {
let p = build_profile(&test_policy());
assert!(p.contains("(import \"system.sb\")"));
}
#[test]
fn wrap_command_sets_sandbox_exec_as_program() {
let policy = test_policy();
let cmd = Command::new("bash");
let wrapped = SeatbeltStrategy.wrap_command(cmd, &policy);
assert_eq!(wrapped.as_std().get_program(), "sandbox-exec");
}
#[test]
fn wrap_command_prepends_profile_flag() {
let policy = test_policy();
let mut cmd = Command::new("bash");
cmd.arg("-c").arg("echo hi");
let wrapped = SeatbeltStrategy.wrap_command(cmd, &policy);
let args: Vec<_> = wrapped
.as_std()
.get_args()
.map(|a| a.to_os_string())
.collect();
assert_eq!(args[0], "-p");
assert!(args[1].to_str().unwrap().contains("(deny default)"));
assert_eq!(args[2], "bash");
assert_eq!(args[3], "-c");
assert_eq!(args[4], "echo hi");
}
#[test]
fn wrap_command_preserves_current_dir() {
let policy = test_policy();
let mut cmd = Command::new("bash");
cmd.current_dir("/work/repo");
let wrapped = SeatbeltStrategy.wrap_command(cmd, &policy);
assert_eq!(
wrapped.as_std().get_current_dir(),
Some(std::path::Path::new("/work/repo"))
);
}
#[test]
fn wrap_command_preserves_env_vars() {
let policy = test_policy();
let mut cmd = Command::new("bash");
cmd.env("MY_VAR", "hello").env("OTHER_VAR", "world");
let wrapped = SeatbeltStrategy.wrap_command(cmd, &policy);
let envs: std::collections::HashMap<_, _> = wrapped
.as_std()
.get_envs()
.map(|(k, v)| (k.to_os_string(), v.map(|v| v.to_os_string())))
.collect();
assert_eq!(
envs.get(&std::ffi::OsString::from("MY_VAR"))
.and_then(|v| v.clone()),
Some("hello".into())
);
assert_eq!(
envs.get(&std::ffi::OsString::from("OTHER_VAR"))
.and_then(|v| v.clone()),
Some("world".into())
);
}
#[test]
fn wrap_command_preserves_env_removals() {
let policy = test_policy();
let mut cmd = Command::new("bash");
cmd.env_remove("SECRET");
let wrapped = SeatbeltStrategy.wrap_command(cmd, &policy);
let envs: std::collections::HashMap<_, _> = wrapped
.as_std()
.get_envs()
.map(|(k, v)| (k.to_os_string(), v.map(|v| v.to_os_string())))
.collect();
assert_eq!(envs.get(&std::ffi::OsString::from("SECRET")), Some(&None));
}
}