use crate::config::Config;
use crate::output;
use std::path::{Path, PathBuf};
use std::process::Command;
pub struct SandboxGuard;
pub fn check() -> Result<(), String> {
let path = Path::new("/usr/bin/sandbox-exec");
if path.is_file() {
Ok(())
} else {
Err("sandbox-exec not found at /usr/bin/sandbox-exec. \
This tool is required for sandboxing on macOS."
.into())
}
}
pub fn platform_notes(config: &Config) {
output::warn(
"macOS backend uses deprecated sandbox-exec; treat this as legacy containment.",
);
if !config.gpu_enabled() {
output::info("--no-gpu has no effect on macOS (Metal is system-level)");
}
if !config.display_enabled() {
output::info(
"--no-display has no effect on macOS (Cocoa is system-level)",
);
}
if config.systemd_user_enabled() {
output::warn("--systemd-user has no effect on macOS");
}
if config.tailscale_enabled() {
output::warn(
"--tailscale has no effect on macOS (no socket bind; \
tailscaled is reachable only if seatbelt network rules \
already allow it)",
);
}
if !config.allow_tcp_ports().is_empty() && config.lockdown_enabled() {
output::warn(
"--allow-tcp-port has no effect on macOS \
lockdown (seatbelt blocks all network)",
);
}
if !config.overlay_maps.is_empty() {
output::warn(
"overlay maps are read-only on macOS (no overlayfs); \
writes to those paths will be denied",
);
}
}
pub fn build(config: &Config, project_dir: &Path, verbose: bool) -> Command {
let lockdown = config.lockdown_enabled();
let profile = build_profile(config, project_dir, verbose);
let launch = super::build_launch_command(config);
let mut cmd = Command::new("/usr/bin/sandbox-exec");
cmd.arg("-p").arg(&profile);
cmd.arg("--");
cmd.arg(&launch.program);
cmd.args(&launch.args);
cmd.current_dir(project_dir);
apply_child_env(&mut cmd, config);
if !lockdown && let Some(tmpdir) = macos_session_tmpdir() {
cmd.env("TMPDIR", tmpdir);
}
cmd
}
fn apply_child_env(cmd: &mut Command, config: &Config) {
cmd.env_clear();
let host_env: Vec<(String, String)> = std::env::vars().collect();
let env = if config.inherit_env_enabled() {
let mut env = Vec::new();
crate::config::apply_env_pass(&mut env, config.env_pass(), &host_env);
env
} else {
crate::config::filtered_child_env(config.env_pass(), &host_env)
};
for (key, value) in env {
cmd.env(key, value);
}
if config.lockdown_enabled() {
cmd.env("PATH", super::LOCKDOWN_PATH);
}
cmd.env("PS1", super::JAIL_PS1);
cmd.env("_ZO_DOCTOR", "0");
if let Some(dir) = &config.claude_dir {
cmd.env("CLAUDE_CONFIG_DIR", dir);
}
}
pub fn dry_run(config: &Config, project_dir: &Path, verbose: bool) -> String {
let profile = build_profile(config, project_dir, verbose);
let launch = super::build_launch_command(config);
let mut command_line = String::from("sandbox-exec -p '<profile>' -- ");
command_line.push_str(&super::quote_shell_arg(&launch.program));
for arg in &launch.args {
command_line.push(' ');
command_line.push_str(&super::quote_shell_arg(arg));
}
format_dry_run_macos(&command_line, &profile)
}
fn build_profile(config: &Config, project_dir: &Path, verbose: bool) -> String {
let profile = generate_sbpl_profile(config, project_dir);
if verbose {
output::verbose("SBPL profile:");
for line in profile.lines() {
output::verbose(&format!(" {line}"));
}
}
profile
}
fn canonicalize_or_keep(p: &Path) -> PathBuf {
std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf())
}
fn push_sbpl_escaped(c: char, out: &mut String) {
match c {
'\\' => out.push_str("\\\\"),
'"' => out.push_str("\\\""),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
_ => out.push(c),
}
}
fn sbpl_escape(input: &str) -> String {
let mut out = String::with_capacity(input.len());
for c in input.chars() {
push_sbpl_escaped(c, &mut out);
}
out
}
fn sbpl_regex_escape(input: &str) -> String {
let mut out = String::with_capacity(input.len() * 2);
for c in input.chars() {
match c {
'.' | '*' | '+' | '?' | '(' | ')' | '[' | ']' | '{' | '}' | '^'
| '$' | '|' => {
out.push('\\');
out.push(c);
}
_ => push_sbpl_escaped(c, &mut out),
}
}
out
}
fn sbpl_path(p: &Path) -> String {
sbpl_escape(canonicalize_or_keep(p).to_string_lossy().as_ref())
}
fn generate_sbpl_profile(config: &Config, project_dir: &Path) -> String {
let lockdown = config.lockdown_enabled();
let exempt = super::dotdir_exemptions(config);
let agent_state = agent_state_paths(config);
let mut deny_paths = macos_read_deny_paths(&config.hide_dotdirs, &exempt);
if !agent_state.is_empty() {
deny_paths.retain(|p| !agent_state.contains(p));
}
deny_paths.extend(super::effective_mask_patterns(config, project_dir));
let explicit_deny_paths = super::expand_mask_patterns(
&config.deny_paths,
&config.deny_path_exceptions,
project_dir,
);
deny_paths.extend(explicit_deny_paths.clone());
let writable_paths = macos_writable_paths(project_dir, config, lockdown);
let atomic_paths = macos_atomic_write_paths(config);
let mut write_deny_paths = explicit_deny_paths.clone();
write_deny_paths.extend(
config
.ro_maps
.iter()
.chain(config.overlay_maps.iter())
.filter(|p| super::path_exists(p))
.cloned(),
);
let mut profile = String::new();
profile.push_str("(version 1)\n");
profile.push_str("(deny default)\n\n");
push_static_sections(&mut profile, config.macos_host_ipc_enabled());
push_network_section(&mut profile, config);
push_file_read_section(&mut profile, config, project_dir, &deny_paths);
push_file_write_section(
&mut profile,
lockdown,
&writable_paths,
&atomic_paths,
&write_deny_paths,
);
push_docker_section(&mut profile, docker_active(config, lockdown));
profile
}
fn push_path_rule(profile: &mut String, verb: &str, action: &str, path: &Path) {
let canonical = canonicalize_or_keep(path);
let escaped = sbpl_escape(canonical.to_string_lossy().as_ref());
let pattern = if canonical.is_dir() || !canonical.exists() {
"subpath"
} else {
"literal"
};
profile.push_str(&format!("({verb} {action} ({pattern} \"{escaped}\"))\n"));
}
fn push_static_sections(profile: &mut String, allow_host_ipc: bool) {
profile.push_str("; Process operations\n");
profile.push_str("(allow process-exec)\n");
profile.push_str("(allow process-fork)\n");
profile.push_str("(allow process-info* (target same-sandbox))\n");
profile.push_str("(allow sysctl-read)\n\n");
profile.push_str("; IPC and Mach\n");
for service in ["com.apple.cfprefsd.daemon", "com.apple.system.logger"] {
profile.push_str(&format!(
"(allow mach-lookup (global-name \"{service}\"))\n"
));
}
profile.push('\n');
if allow_host_ipc {
profile.push_str("; Explicit macOS host IPC compatibility opt-in\n");
profile.push_str("(allow signal)\n");
profile.push_str("(allow ipc-posix-shm-read-data)\n");
profile.push_str("(allow ipc-posix-shm-write-data)\n");
profile.push_str("(allow ipc-posix-shm-read-metadata)\n");
profile.push_str("(allow ipc-posix-shm-write-create)\n");
profile.push_str("(allow ipc-posix-sem)\n");
profile.push_str("(allow mach-lookup)\n");
profile.push_str("(allow mach-host*)\n");
profile.push_str("(allow file-ioctl)\n");
profile.push_str("(allow iokit-open)\n");
profile.push('\n');
}
profile.push_str("; Pseudo-terminal\n");
profile.push_str("(allow pseudo-tty)\n");
profile
.push_str("(allow file-read* file-write* (literal \"/dev/ptmx\"))\n");
profile.push_str(
"(allow file-read* file-write* (regex #\"^/dev/ttys[0-9]+\"))\n\n",
);
profile.push_str("; Standard devices\n");
profile.push_str("(allow file-write* (literal \"/dev/null\"))\n");
profile.push_str("(allow file-write* (literal \"/dev/zero\"))\n");
profile.push_str("(allow file-write* (literal \"/dev/random\"))\n");
profile.push_str("(allow file-write* (literal \"/dev/urandom\"))\n\n");
profile.push('\n');
}
fn push_network_section(profile: &mut String, config: &Config) {
if !config.network_enabled() || config.lockdown_enabled() {
return;
}
profile.push_str("; Network\n");
profile.push_str("(allow network-outbound)\n");
profile.push_str("(allow network-inbound)\n");
profile.push_str("(allow network-bind)\n");
profile.push_str("(allow system-socket)\n\n");
}
fn push_file_read_section(
profile: &mut String,
config: &Config,
project_dir: &Path,
deny_paths: &[PathBuf],
) {
profile.push_str("; File reads: explicit allow-list\n");
profile.push_str("(allow file-read* (literal \"/\"))\n");
for rd_path in macos_read_paths(config, project_dir) {
push_path_rule(profile, "allow", "file-read*", &rd_path);
}
profile.push('\n');
for deny_path in deny_paths {
push_path_rule(profile, "deny", "file-read*", deny_path);
}
profile.push('\n');
}
fn push_file_write_section(
profile: &mut String,
lockdown: bool,
writable_paths: &[PathBuf],
atomic_paths: &[PathBuf],
deny_paths: &[PathBuf],
) {
if lockdown {
for deny_path in deny_paths {
push_path_rule(profile, "deny", "file-write*", deny_path);
}
profile.push_str("; Lockdown: no host file-write allowances\n\n");
return;
}
profile.push_str("; File writes: allow specific paths\n");
for wr_path in writable_paths {
push_path_rule(profile, "allow", "file-write*", wr_path);
}
if !atomic_paths.is_empty() {
profile.push('\n');
profile.push_str(
"; Atomic-write paths (literal + bounded temp siblings)\n",
);
for ap in atomic_paths {
let canonical = canonicalize_or_keep(ap);
let path_str = canonical.to_string_lossy();
let escaped = sbpl_escape(&path_str);
profile.push_str(&format!(
"(allow file-write* (literal \"{escaped}\"))\n"
));
let regex_escaped = sbpl_regex_escape(&path_str);
let siblings = format!(
"^{regex_escaped}(\\.tmp\\.[0-9]+\\.[0-9a-f]+|\\.lock)$"
);
profile.push_str(&format!(
"(allow file-write* (regex #\"{siblings}\"))\n"
));
}
}
for deny_path in deny_paths {
push_path_rule(profile, "deny", "file-write*", deny_path);
}
profile.push('\n');
}
fn docker_active(config: &Config, lockdown: bool) -> bool {
config.docker_enabled() && config.browser_profile().is_none() && !lockdown
}
fn push_docker_section(profile: &mut String, active: bool) {
if !active {
return;
}
let Some(sock) = macos_docker_socket() else {
return;
};
let escaped = sbpl_path(&sock);
profile.push_str("; Docker socket\n");
profile.push_str(&format!("(allow file-write* (literal \"{escaped}\"))\n"));
profile.push('\n');
}
fn validated_macos_tmpdir() -> Option<PathBuf> {
use std::os::unix::ffi::OsStrExt;
use std::os::unix::fs::MetadataExt;
let length = unsafe {
nix::libc::confstr(
nix::libc::_CS_DARWIN_USER_TEMP_DIR,
std::ptr::null_mut(),
0,
)
};
if length == 0 {
return None;
}
let mut bytes = vec![0_u8; length as usize];
if unsafe {
nix::libc::confstr(
nix::libc::_CS_DARWIN_USER_TEMP_DIR,
bytes.as_mut_ptr().cast(),
bytes.len(),
)
} == 0
{
return None;
}
let trusted = PathBuf::from(std::ffi::OsStr::from_bytes(
&bytes[..bytes.len().saturating_sub(1)],
));
let trusted = std::fs::canonicalize(trusted).ok()?;
let metadata = trusted.metadata().ok()?;
let mode = metadata.mode();
let sticky = u32::from(nix::libc::S_ISVTX);
let unsafe_writable = mode & 0o022 != 0 && mode & sticky == 0;
if !metadata.is_dir()
|| metadata.uid() != unsafe { nix::libc::geteuid() }
|| unsafe_writable
{
return None;
}
if let Some(tmpdir) = std::env::var_os("TMPDIR")
&& std::fs::canonicalize(tmpdir).ok().as_ref() != Some(&trusted)
{
output::warn(
"ignoring TMPDIR that differs from Darwin's trusted per-user temporary directory",
);
}
Some(trusted)
}
fn macos_session_tmpdir() -> Option<PathBuf> {
use std::os::unix::fs::PermissionsExt;
let root = validated_macos_tmpdir()?;
let session = root.join(format!("ai-jail-{}", std::process::id()));
if std::fs::create_dir(&session).is_err() && !session.is_dir() {
output::warn(&format!(
"cannot create session temp dir {}; temp access disabled",
session.display()
));
return None;
}
let _ = std::fs::set_permissions(
&session,
std::fs::Permissions::from_mode(0o700),
);
Some(session)
}
fn format_dry_run_macos(command_line: &str, profile: &str) -> String {
let mut out = String::new();
out.push_str("# sandbox-exec command:\n");
out.push_str(command_line);
out.push('\n');
out.push_str("\n# SBPL profile:\n");
out.push_str(profile);
out
}
fn macos_read_deny_paths(
hide_dotdirs: &[String],
exempt: &[&str],
) -> Vec<PathBuf> {
let home = super::home_dir();
let mut candidates: Vec<PathBuf> =
super::denied_dotdirs(hide_dotdirs, exempt)
.map(|name| home.join(format!(".{}", name)))
.collect();
candidates.extend([
home.join("Library/Mail"),
home.join("Library/Messages"),
home.join("Library/Safari"),
home.join("Library/Cookies"),
]);
candidates
.into_iter()
.filter(|p| super::path_exists(p))
.collect()
}
fn agent_state_paths(config: &Config) -> Vec<PathBuf> {
if !config.agent_state_enabled() {
return Vec::new();
}
let home = super::home_dir();
let mut paths: Vec<PathBuf> = Vec::new();
let mut push = |rel: &str| {
let path = home.join(rel);
if super::path_exists(&path) && !paths.contains(&path) {
paths.push(path);
}
};
match crate::command::effective_name(&config.command) {
Some("claude") => {
push(".claude");
push(".claude.json");
}
Some("codex") => push(".codex"),
Some("opencode") => {
push(".config/opencode");
push(".local/share/opencode");
}
Some("crush") => push(".crush"),
Some(name) if name.starts_with("kimi") => push(".kimi-code"),
Some("gemini") => push(".gemini"),
Some("grok") => push(".grok"),
Some("pi") => {
push(".pi");
push(".pi-lens");
}
Some("aider") => push(".aider"),
Some("soulforge") => push(".soulforge"),
Some("omp") => push(".omp"),
_ => {}
}
paths
}
fn macos_writable_paths(
project_dir: &Path,
config: &Config,
lockdown: bool,
) -> Vec<PathBuf> {
if lockdown {
return Vec::new();
}
let home = super::home_dir();
let mut paths = Vec::new();
let agent_state = agent_state_paths(config);
let browser_mode = config.browser_profile().is_some();
let private_home = config.private_home_enabled();
if browser_mode {
for p in &agent_state {
paths.push(p.clone());
}
if let Some(state) = super::browser_state_dir(config) {
let _ = std::fs::create_dir_all(&state);
paths.push(state);
}
if let Some(tmpdir) = macos_session_tmpdir() {
paths.push(tmpdir);
}
return paths;
}
paths.push(project_dir.to_path_buf());
if let Some(worktree) =
super::discover_git_worktree_paths(config, project_dir, false)
{
paths.push(worktree.git_dir.clone());
}
for p in agent_state {
if !paths.contains(&p) {
paths.push(p);
}
}
if !private_home {
for name in super::DOTDIR_RW {
let p = home.join(name);
if super::path_exists(&p) {
paths.push(p);
}
}
let local = home.join(".local");
if super::path_exists(&local) {
paths.push(local);
}
}
if let Some(dir) = &config.claude_dir
&& super::path_exists(dir)
{
paths.push(dir.clone());
}
if let Some(tmpdir) = macos_session_tmpdir() {
paths.push(tmpdir);
}
if !private_home {
let lib_caches = home.join("Library/Caches");
if super::path_exists(&lib_caches) {
paths.push(lib_caches);
}
}
for p in &config.rw_maps {
if super::path_exists(p) {
paths.push(p.clone());
}
}
paths
}
fn macos_atomic_write_paths(config: &Config) -> Vec<PathBuf> {
if config.lockdown_enabled() || !config.agent_state_enabled() {
return Vec::new();
}
if crate::command::effective_name(&config.command) != Some("claude") {
return Vec::new();
}
let claude_json = super::home_dir().join(".claude.json");
if claude_json.is_file() {
vec![claude_json]
} else {
Vec::new()
}
}
fn macos_docker_socket() -> Option<PathBuf> {
super::docker_socket()
}
fn macos_read_paths(config: &Config, project_dir: &Path) -> Vec<PathBuf> {
let mut paths = Vec::new();
let mut push_unique = |p: PathBuf| {
if !paths.contains(&p) {
paths.push(p);
}
};
push_unique(canonicalize_or_keep(project_dir));
if let Some(worktree) =
super::discover_git_worktree_paths(config, project_dir, false)
{
for path in worktree.unique_paths() {
push_unique(canonicalize_or_keep(&path));
}
}
for p in &config.overlay_maps {
if super::path_exists(p) {
push_unique(canonicalize_or_keep(p));
}
}
for p in [
"/System",
"/usr",
"/bin",
"/sbin",
"/etc",
"/private/etc",
"/Library",
"/dev",
] {
let pb = PathBuf::from(p);
if super::path_exists(&pb) {
push_unique(pb);
}
}
let browser_mode = config.browser_profile().is_some();
let private_home = config.private_home_enabled();
if browser_mode && let Some(state) = super::browser_state_dir(config) {
push_unique(canonicalize_or_keep(&state));
}
for path in agent_state_paths(config) {
push_unique(canonicalize_or_keep(&path));
}
if !private_home {
for filename in [".gitconfig", ".gitignore"] {
let git_file = super::home_dir().join(filename);
if git_file.is_file() {
push_unique(canonicalize_or_keep(&git_file));
}
}
let xdg_git = super::xdg_config_home().join("git");
if xdg_git.is_dir() {
push_unique(canonicalize_or_keep(&xdg_git));
}
}
if !private_home && !browser_mode {
for name in [
".gemini",
".claude",
".crush",
".codex",
".aider",
".kiro",
".soulforge",
".grok",
".agents",
".omp",
".pi",
".pi-lens",
".config",
".cargo",
".cache",
".bundle",
".gem",
".rustup",
".npm",
".bun",
".deno",
".yarn",
".pnpm",
".m2",
".gradle",
".dotnet",
".nuget",
".pub-cache",
".mix",
".hex",
".local",
] {
let path = super::home_dir().join(name);
if path.is_dir() {
push_unique(canonicalize_or_keep(&path));
}
}
}
if !browser_mode && !config.lockdown_enabled() {
for p in config.rw_maps.iter().chain(config.ro_maps.iter()) {
if super::path_exists(p) {
push_unique(canonicalize_or_keep(p));
}
}
for p in super::command_home_paths(config) {
push_unique(canonicalize_or_keep(&p));
}
if config.ssh_enabled() {
let ssh_dir = super::home_dir().join(".ssh");
if ssh_dir.is_dir() {
push_unique(canonicalize_or_keep(&ssh_dir));
}
}
if config.pictures_enabled() {
let pictures = super::home_dir().join("Pictures");
if pictures.is_dir() {
push_unique(canonicalize_or_keep(&pictures));
}
}
}
for command in crate::command::executable_candidates(&config.command) {
let path = PathBuf::from(command);
if path.starts_with("/Applications") && path.exists() {
push_unique(canonicalize_or_keep(&path));
}
}
for p in config.rw_maps.iter().chain(config.ro_maps.iter()) {
let source = crate::config::map_source(p);
if super::path_exists(&source) {
push_unique(canonicalize_or_keep(&source));
}
}
if !config.lockdown_enabled()
&& let Some(tmpdir) = macos_session_tmpdir()
{
push_unique(tmpdir);
}
paths
}
#[cfg(test)]
mod tests {
use super::*;
use crate::sandbox::test_support::linked_worktree_fixture;
use crate::test_utils::{ENV_LOCK, EnvVarGuard};
fn create_linked_worktree_fixture()
-> crate::sandbox::test_support::LinkedWorktreeFixture {
linked_worktree_fixture("seatbelt-worktree")
}
#[test]
fn sbpl_profile_has_deny_default() {
let config = Config {
command: vec!["bash".into()],
no_mise: Some(true),
..Config::default()
};
let project = PathBuf::from("/tmp/test-project");
let profile = generate_sbpl_profile(&config, &project);
assert!(profile.contains("(deny default)"));
}
#[test]
fn docker_is_disabled_in_browser_and_lockdown_modes() {
let enabled = Config {
no_docker: Some(false),
..Config::default()
};
assert!(docker_active(&enabled, false));
let browser = Config {
browser_profile: Some("soft".into()),
..enabled.clone()
};
assert!(!docker_active(&browser, false));
assert!(!docker_active(&enabled, true));
}
#[test]
fn sbpl_profile_defaults_to_no_network_or_global_reads() {
let _env = ENV_LOCK.lock().unwrap();
let home = std::env::temp_dir().join(format!(
"ai-jail-seatbelt-defaults-home-{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&home);
std::fs::create_dir_all(&home).unwrap();
let _home = EnvVarGuard::set("HOME", home.as_os_str());
let project = PathBuf::from("/tmp/test-project");
let modes = [
Config::default(),
Config {
private_home: Some(false),
..Config::default()
},
Config {
lockdown: Some(true),
..Config::default()
},
Config {
browser_profile: Some("soft".into()),
..Config::default()
},
];
for config in modes {
let profile = generate_sbpl_profile(&config, &project);
assert!(!profile.contains("(allow network-outbound)"));
assert!(!profile.contains("(allow file-read*)\n"));
assert!(!profile.contains("(subpath \"/Users/"));
assert!(!profile.contains("(allow mach-lookup)\n"));
assert!(!profile.contains("(allow mach-host*)"));
assert!(!profile.contains("(allow mach-register)"));
assert!(!profile.contains("(allow file-ioctl)"));
assert!(!profile.contains("(allow iokit-open)"));
assert!(!profile.contains("(allow signal)"));
assert!(!profile.contains("ipc-posix-shm"));
assert!(!profile.contains("(allow ipc-posix-sem)"));
assert_eq!(
profile.matches("(allow mach-lookup").count(),
profile
.matches("(allow mach-lookup (global-name \"")
.count(),
"default profile must not allow non-literal mach-lookup"
);
}
let _ = std::fs::remove_dir_all(&home);
}
#[test]
fn sbpl_network_and_host_ipc_require_explicit_opt_in() {
let config = Config {
network: Some(true),
macos_host_ipc: Some(true),
..Config::default()
};
let profile =
generate_sbpl_profile(&config, Path::new("/tmp/test-project"));
assert!(profile.contains("(allow network-outbound)"));
assert!(profile.contains("(allow mach-lookup)"));
assert!(profile.contains("(allow mach-host*)"));
assert!(profile.contains("(allow file-ioctl)"));
assert!(profile.contains("(allow iokit-open)"));
assert!(profile.contains("(allow signal)"));
assert!(profile.contains("(allow ipc-posix-shm-read-data)"));
assert!(profile.contains("(allow ipc-posix-shm-write-data)"));
assert!(profile.contains("(allow ipc-posix-shm-read-metadata)"));
assert!(profile.contains("(allow ipc-posix-shm-write-create)"));
assert!(profile.contains("(allow ipc-posix-sem)"));
assert!(!profile.contains("mach-register"));
}
#[test]
fn sbpl_profile_denies_deny_paths_for_read_and_write() {
let config = Config {
deny_paths: vec![PathBuf::from(".env")],
..Config::default()
};
let project = PathBuf::from("/tmp/test-project");
let profile = generate_sbpl_profile(&config, &project);
assert!(profile.contains(
"(deny file-read* (subpath \"/tmp/test-project/.env\"))"
));
assert!(profile.contains(
"(deny file-write* (subpath \"/tmp/test-project/.env\"))"
));
}
#[test]
fn sbpl_profile_honors_exceptions_but_keeps_policy_hidden() {
let project = std::env::temp_dir().join("ai-jail-seatbelt-exceptions");
std::fs::create_dir_all(&project).unwrap();
std::fs::write(project.join(".ai-jail"), "mask = []").unwrap();
let config = Config {
mask: vec![PathBuf::from(".env")],
mask_exceptions: vec![PathBuf::from(".env")],
deny_paths: vec![PathBuf::from("secret")],
deny_path_exceptions: vec![PathBuf::from("secret")],
..Config::default()
};
let profile = generate_sbpl_profile(&config, &project);
assert!(!profile.contains("/.env\"))"));
assert!(!profile.contains("/secret\"))"));
assert!(profile.contains(&format!("{}/.ai-jail", project.display())));
let visible = Config {
no_hide_config: Some(true),
..Config::default()
};
let visible_profile = generate_sbpl_profile(&visible, &project);
assert!(
!visible_profile
.contains(&format!("{}/.ai-jail", project.display()))
);
let _ = std::fs::remove_dir_all(project);
}
#[test]
fn sbpl_profile_lockdown_disables_network_and_writes() {
let config = Config {
lockdown: Some(true),
..Config::default()
};
let project = PathBuf::from("/tmp/test-project");
let profile = generate_sbpl_profile(&config, &project);
assert!(!profile.contains("(allow network-outbound)"));
assert!(!profile.contains("(allow file-read*)\n"));
assert!(
profile
.contains("(allow file-read* (subpath \"/tmp/test-project\"))")
);
assert!(profile.contains("no host file-write allowances"));
assert!(!profile.contains("(allow file-write* (subpath"));
}
#[test]
fn sbpl_profile_escapes_quotes_in_paths() {
let escaped = sbpl_escape("/tmp/with\"quote");
assert_eq!(escaped, "/tmp/with\\\"quote");
}
#[test]
fn regression_sbpl_escape_controls() {
let escaped = sbpl_escape("line1\nline2\t\\");
assert_eq!(escaped, "line1\\nline2\\t\\\\");
}
#[test]
fn sbpl_regex_escape_dots_and_specials() {
let escaped = sbpl_regex_escape("/Users/user/.claude.json");
assert_eq!(escaped, "/Users/user/\\.claude\\.json");
}
#[test]
fn sbpl_regex_escape_handles_all_metacharacters() {
let escaped = sbpl_regex_escape("/a.b*c+d?e(f)g[h]i{j}k^l$m|n");
assert_eq!(
escaped,
"/a\\.b\\*c\\+d\\?e\\(f\\)g\\[h\\]i\\{j\\}k\\^l\\$m\\|n"
);
}
#[test]
fn atomic_paths_gets_bounded_atomic_write_rules() {
let _env = ENV_LOCK.lock().unwrap();
use std::fs;
let fake_home = std::env::temp_dir()
.join(format!("ai-jail-seatbelt-atomic-{}", std::process::id()));
fs::create_dir_all(&fake_home).unwrap();
let claude_json = fake_home.join(".claude.json");
fs::write(&claude_json, "{}").unwrap();
let _home = EnvVarGuard::set("HOME", fake_home.as_os_str());
let config = Config {
command: vec!["claude".into()],
no_mise: Some(true),
agent_state: Some(true),
..Config::default()
};
let profile = generate_sbpl_profile(&config, Path::new("/tmp/proj"));
let canonical = canonicalize_or_keep(&claude_json);
let path_str = canonical.to_string_lossy();
let regex_escaped = sbpl_regex_escape(&path_str);
let _ = fs::remove_dir_all(&fake_home);
assert!(
profile.contains(&format!(
"(allow file-write* (literal \"{path_str}\"))"
)),
".claude.json must have a literal write rule"
);
let bounded = format!(
"(allow file-write* (regex #\"^{regex_escaped}\
(\\.tmp\\.[0-9]+\\.[0-9a-f]+|\\.lock)$\"))"
);
assert!(
profile.contains(&bounded),
".claude.json must have a bounded temp-sibling regex rule"
);
assert!(
!profile.contains(&format!(
"(allow file-write* (regex #\"^{regex_escaped}\"))"
)),
"regex must not be an unbounded prefix"
);
}
#[test]
fn dry_run_macos_output() {
let config = Config {
command: vec!["bash".into()],
no_mise: Some(true),
..Config::default()
};
let project = PathBuf::from("/tmp/test-project");
let output = dry_run(&config, &project, false);
assert!(output.contains("sandbox-exec"));
assert!(output.contains("SBPL profile"));
}
#[test]
fn macos_writable_paths_empty_in_lockdown() {
let config = Config {
lockdown: Some(true),
..Config::default()
};
let project = PathBuf::from("/tmp/test-project");
let paths = macos_writable_paths(&project, &config, true);
assert!(paths.is_empty());
}
#[test]
fn private_home_writable_paths_skip_host_home_state() {
let _env = ENV_LOCK.lock().unwrap();
let home = std::env::temp_dir().join(format!(
"ai-jail-seatbelt-private-home-{}",
std::process::id()
));
let project = home.join("project");
let extra = home.join("extra");
std::fs::create_dir_all(home.join(".config")).unwrap();
std::fs::create_dir_all(home.join(".local")).unwrap();
std::fs::create_dir_all(home.join("Library/Caches")).unwrap();
std::fs::create_dir_all(&project).unwrap();
std::fs::create_dir_all(&extra).unwrap();
let _home = EnvVarGuard::set("HOME", home.as_os_str());
let config = Config {
private_home: Some(true),
rw_maps: vec![extra.clone()],
..Config::default()
};
let paths = macos_writable_paths(&project, &config, false);
assert!(paths.contains(&project));
assert!(paths.contains(&extra));
assert!(!paths.contains(&home.join(".config")));
assert!(!paths.contains(&home.join(".local")));
assert!(!paths.contains(&home.join("Library/Caches")));
let _ = std::fs::remove_dir_all(&home);
}
#[test]
fn private_home_profile_uses_restricted_reads() {
let config = Config {
private_home: Some(true),
..Config::default()
};
let project = PathBuf::from("/tmp/test-project");
let profile = generate_sbpl_profile(&config, &project);
assert!(profile.contains("; File reads: explicit allow-list"));
assert!(!profile.contains("(allow file-read*)\n"));
}
#[test]
fn ro_and_overlay_maps_get_write_denies_after_project_allow() {
let _lock = ENV_LOCK.lock().unwrap();
let home = std::env::temp_dir()
.join(format!("ai-jail-seatbelt-ro-map-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&home);
let project = home.join("project");
std::fs::create_dir_all(project.join(".git")).unwrap();
std::fs::create_dir_all(project.join("vendor")).unwrap();
let _home = EnvVarGuard::set("HOME", &home);
let config = Config {
ro_maps: vec![project.join(".git")],
overlay_maps: vec![project.join("vendor")],
..Config::default()
};
let profile = generate_sbpl_profile(&config, &project);
let allow_project = format!(
"(allow file-write* (subpath \"{}\"))",
sbpl_path(&project)
);
let deny_ro_map = format!(
"(deny file-write* (subpath \"{}\"))",
sbpl_path(&project.join(".git"))
);
let deny_overlay = format!(
"(deny file-write* (subpath \"{}\"))",
sbpl_path(&project.join("vendor"))
);
let allow_at = profile
.find(&allow_project)
.expect("project write allowance present");
let deny_ro_at = profile
.find(&deny_ro_map)
.expect("ro map write deny present");
assert!(
profile.contains(&deny_overlay),
"overlay map write deny present (read-only fallback on macOS)"
);
assert!(
deny_ro_at > allow_at,
"write deny must come after the project allow (last match wins)"
);
let _ = std::fs::remove_dir_all(&home);
}
#[test]
fn private_home_reads_allow_home_installed_command() {
let _lock = ENV_LOCK.lock().unwrap();
let home = std::env::temp_dir()
.join(format!("ai-jail-seatbelt-cmd-home-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&home);
let versions = home.join(".local/share/agent/versions");
std::fs::create_dir_all(home.join(".local/bin")).unwrap();
std::fs::create_dir_all(&versions).unwrap();
let target = versions.join("1.0");
std::fs::write(&target, "#!/bin/sh\n").unwrap();
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(
&target,
std::fs::Permissions::from_mode(0o755),
)
.unwrap();
std::os::unix::fs::symlink(&target, home.join(".local/bin/agent"))
.unwrap();
let _home = EnvVarGuard::set("HOME", &home);
let _path =
EnvVarGuard::set("PATH", home.join(".local/bin").as_os_str());
let config = Config {
command: vec!["agent".into()],
private_home: Some(true),
..Config::default()
};
let project = home.join("project");
let profile = generate_sbpl_profile(&config, &project);
assert!(
profile
.contains(&format!("(subpath \"{}\")", sbpl_path(&versions)))
);
assert!(
profile.contains(&format!("(literal \"{}\")", sbpl_path(&target)))
);
let _ = std::fs::remove_dir_all(&home);
}
#[test]
fn restricted_reads_allow_root_node() {
let private_home = Config {
private_home: Some(true),
..Config::default()
};
let lockdown = Config {
lockdown: Some(true),
..Config::default()
};
let browser = Config {
browser_profile: Some("soft".into()),
..Config::default()
};
let cases = [
("private-home", private_home),
("lockdown", lockdown),
("browser", browser),
];
for (mode, config) in cases {
let project = PathBuf::from("/tmp/test-project");
let profile = generate_sbpl_profile(&config, &project);
assert!(
profile.contains("(allow file-read* (literal \"/\"))"),
"restricted read profile must grant the root node ({mode})"
);
assert!(
!profile.contains("(allow file-read* (subpath \"/\"))"),
"must not grant subpath / (would expose everything, {mode})"
);
}
}
#[test]
fn read_paths_include_project() {
let project = PathBuf::from("/tmp/test-project");
let paths = macos_read_paths(&Config::default(), &project);
assert!(paths.contains(&project));
}
#[test]
fn lockdown_read_paths_include_home_gitignore() {
let _env = ENV_LOCK.lock().unwrap();
let home = std::env::temp_dir().join(format!(
"ai-jail-seatbelt-gitignore-home-{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&home);
std::fs::create_dir_all(&home).unwrap();
let gitignore = home.join(".gitignore");
std::fs::write(&gitignore, b"target\n").unwrap();
let _home = EnvVarGuard::set("HOME", home.as_os_str());
let paths = macos_read_paths(
&Config {
lockdown: Some(true),
private_home: Some(false),
..Config::default()
},
Path::new("/tmp/test-project"),
);
assert!(paths.contains(&canonicalize_or_keep(&gitignore)));
let _ = std::fs::remove_dir_all(&home);
}
#[test]
fn lockdown_read_paths_include_xdg_git_dir() {
let _env = ENV_LOCK.lock().unwrap();
let home = std::env::temp_dir().join(format!(
"ai-jail-seatbelt-xdg-git-home-{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&home);
let xdg_git = home.join(".config").join("git");
std::fs::create_dir_all(&xdg_git).unwrap();
std::fs::write(xdg_git.join("ignore"), b"target\n").unwrap();
let _home = EnvVarGuard::set("HOME", home.as_os_str());
let _xdg = EnvVarGuard::remove("XDG_CONFIG_HOME");
let paths = macos_read_paths(
&Config {
lockdown: Some(true),
private_home: Some(false),
..Config::default()
},
Path::new("/tmp/test-project"),
);
assert!(paths.contains(&canonicalize_or_keep(&xdg_git)));
let _ = std::fs::remove_dir_all(&home);
}
#[test]
fn writable_paths_grant_worktree_git_dir_not_common_dir() {
let fixture = create_linked_worktree_fixture();
let config = Config {
no_mise: Some(true),
..Config::default()
};
let paths = macos_writable_paths(&fixture.project_dir, &config, false);
let same = |a: &Path, b: &Path| {
std::fs::canonicalize(a).ok() == std::fs::canonicalize(b).ok()
};
assert!(paths.iter().any(|path| same(path, &fixture.git_dir)));
assert!(
!paths.iter().any(|path| same(path, &fixture.common_dir)),
"common git dir must not be writable"
);
}
#[test]
fn lockdown_read_paths_include_linked_worktree_git_dirs() {
let fixture = create_linked_worktree_fixture();
let config = Config {
lockdown: Some(true),
..Config::default()
};
let paths = macos_read_paths(&config, &fixture.project_dir);
assert!(
paths
.iter()
.any(|path| path == &canonicalize_or_keep(&fixture.git_dir))
);
assert!(
paths
.iter()
.any(|path| path == &canonicalize_or_keep(&fixture.common_dir))
);
}
struct AgentStateFixture {
home: PathBuf,
_home: EnvVarGuard,
_env: std::sync::MutexGuard<'static, ()>,
}
fn agent_state_fixture_home(prefix: &str) -> AgentStateFixture {
let _env = ENV_LOCK.lock().unwrap();
let home = std::env::temp_dir()
.join(format!("ai-jail-seatbelt-{prefix}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&home);
for dir in [
".claude",
".codex",
".config/opencode",
".local/share/opencode",
".crush",
".kimi-code",
".gemini",
".grok",
".pi",
".pi-lens",
".aider",
".soulforge",
".omp",
] {
std::fs::create_dir_all(home.join(dir)).unwrap();
}
std::fs::write(home.join(".claude.json"), "{}").unwrap();
let _home = EnvVarGuard::set("HOME", home.as_os_str());
AgentStateFixture { home, _home, _env }
}
#[test]
fn agent_state_is_gated_off_by_default() {
let fixture = agent_state_fixture_home("state-default");
let home = &fixture.home;
let config = Config {
command: vec!["claude".into()],
no_mise: Some(true),
..Config::default()
};
assert!(agent_state_paths(&config).is_empty());
let project = PathBuf::from("/tmp/test-project");
let writable = macos_writable_paths(&project, &config, false);
assert!(!writable.contains(&home.join(".claude")));
assert!(!writable.contains(&home.join(".claude.json")));
let profile = generate_sbpl_profile(&config, &project);
assert!(!profile.contains(".claude.json"));
let _ = std::fs::remove_dir_all(home);
}
#[test]
fn agent_state_mounts_claude_state_read_write() {
let fixture = agent_state_fixture_home("state-claude");
let home = &fixture.home;
let config = Config {
command: vec!["claude".into()],
no_mise: Some(true),
agent_state: Some(true),
..Config::default()
};
let expected = vec![home.join(".claude"), home.join(".claude.json")];
assert_eq!(agent_state_paths(&config), expected);
let project = PathBuf::from("/tmp/test-project");
let writable = macos_writable_paths(&project, &config, false);
assert!(writable.contains(&home.join(".claude")));
assert!(writable.contains(&home.join(".claude.json")));
let reads = macos_read_paths(&config, &project);
assert!(reads.contains(&canonicalize_or_keep(&home.join(".claude"))));
let _ = std::fs::remove_dir_all(home);
}
#[test]
fn agent_state_covers_full_command_list() {
let fixture = agent_state_fixture_home("state-list");
let home = &fixture.home;
for (command, expected) in [
(vec!["claude"], vec![".claude", ".claude.json"]),
(vec!["codex"], vec![".codex"]),
(
vec!["opencode"],
vec![".config/opencode", ".local/share/opencode"],
),
(vec!["crush"], vec![".crush"]),
(vec!["kimi"], vec![".kimi-code"]),
(vec!["gemini"], vec![".gemini"]),
(vec!["grok"], vec![".grok"]),
(vec!["pi"], vec![".pi", ".pi-lens"]),
(vec!["aider"], vec![".aider"]),
(vec!["soulforge"], vec![".soulforge"]),
(vec!["omp"], vec![".omp"]),
] {
let config = Config {
command: command
.clone()
.into_iter()
.map(String::from)
.collect(),
no_mise: Some(true),
agent_state: Some(true),
..Config::default()
};
let expected: Vec<PathBuf> =
expected.iter().map(|rel| home.join(rel)).collect();
assert_eq!(
agent_state_paths(&config),
expected,
"state mapping mismatch for {}",
command[0]
);
}
let _ = std::fs::remove_dir_all(home);
}
#[test]
fn agent_state_opt_in_outranks_builtin_dotdir_deny() {
let fixture = agent_state_fixture_home("state-kimi");
let home = &fixture.home;
let config = Config {
command: vec!["kimi".into()],
no_mise: Some(true),
agent_state: Some(true),
..Config::default()
};
let project = PathBuf::from("/tmp/test-project");
let profile = generate_sbpl_profile(&config, &project);
let kimi = home.join(".kimi-code");
assert!(
!profile.contains(&format!(
"(deny file-read* (subpath \"{}\"))",
sbpl_path(&kimi)
)),
".kimi-code is a built-in hide, but the explicit state \
opt-in must outrank it"
);
assert!(profile.contains(&format!(
"(allow file-write* (subpath \"{}\"))",
sbpl_path(&kimi)
)));
let _ = std::fs::remove_dir_all(home);
}
#[test]
fn writable_paths_use_dedicated_session_tmpdir() {
let Some(root) = validated_macos_tmpdir() else {
assert!(macos_session_tmpdir().is_none());
return;
};
let session = root.join(format!("ai-jail-{}", std::process::id()));
let config = Config::default();
let project = PathBuf::from("/tmp/test-project");
let writable = macos_writable_paths(&project, &config, false);
assert!(writable.contains(&session));
assert!(
!writable.contains(&root),
"the whole per-user temp root must not be writable"
);
use std::os::unix::fs::PermissionsExt;
let mode = session.metadata().unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o700, "session tmpdir must be mode 0700");
}
#[test]
fn build_sets_child_tmpdir_to_session_dir() {
let Some(root) = validated_macos_tmpdir() else {
return;
};
let config = Config {
command: vec!["bash".into()],
no_mise: Some(true),
..Config::default()
};
let cmd = build(&config, Path::new("/tmp/test-project"), false);
let session = root.join(format!("ai-jail-{}", std::process::id()));
let tmpdir = cmd
.get_envs()
.find(|(key, _)| key == &std::ffi::OsStr::new("TMPDIR"))
.and_then(|(_, value)| value);
assert_eq!(tmpdir, Some(session.as_os_str()));
}
#[test]
fn build_child_env_is_allowlisted_with_env_pass() {
let _env = ENV_LOCK.lock().unwrap();
let _dropped = EnvVarGuard::set("AI_JAIL_HOST_STATE", "secret");
let _locale = EnvVarGuard::set("LC_CTYPE", "UTF-8");
let _xdg = EnvVarGuard::set("XDG_DATA_HOME", "/tmp/xdg-data");
let _passed = EnvVarGuard::set("AI_JAIL_TEST_SECRET", "hunter2");
let config = Config {
command: vec!["bash".into()],
no_mise: Some(true),
env_pass: vec![
"AI_JAIL_TEST_SECRET".into(),
"AI_JAIL_LITERAL=xyz".into(),
],
..Config::default()
};
let cmd = build(&config, Path::new("/tmp/test-project"), false);
let env: std::collections::HashMap<_, _> = cmd.get_envs().collect();
let get = |name: &str| {
env.get(&std::ffi::OsStr::new(name)).copied().flatten()
};
assert!(
get("AI_JAIL_HOST_STATE").is_none(),
"non-allowlisted host state must be dropped"
);
assert!(get("PATH").is_some());
assert!(get("HOME").is_some());
assert_eq!(get("LC_CTYPE"), Some(std::ffi::OsStr::new("UTF-8")));
assert_eq!(
get("XDG_DATA_HOME"),
Some(std::ffi::OsStr::new("/tmp/xdg-data"))
);
assert_eq!(
get("AI_JAIL_TEST_SECRET"),
Some(std::ffi::OsStr::new("hunter2"))
);
assert_eq!(get("AI_JAIL_LITERAL"), Some(std::ffi::OsStr::new("xyz")));
assert_eq!(
get("PS1"),
Some(std::ffi::OsStr::new(crate::sandbox::JAIL_PS1))
);
}
#[test]
fn inherit_env_opt_in_passes_host_environment() {
let _env = ENV_LOCK.lock().unwrap();
let _state = EnvVarGuard::set("AI_JAIL_HOST_STATE", "secret");
let config = Config {
command: vec!["bash".into()],
no_mise: Some(true),
inherit_env: Some(true),
..Config::default()
};
let cmd = build(&config, Path::new("/tmp/test-project"), false);
let env: std::collections::HashMap<_, _> = cmd.get_envs().collect();
assert_eq!(
env.get(&std::ffi::OsStr::new("AI_JAIL_HOST_STATE"))
.copied()
.flatten(),
Some(std::ffi::OsStr::new("secret"))
);
}
}