use super::SandboxPolicy;
pub const SANDBOX_EXEC_PATH: &str = "/usr/bin/sandbox-exec";
pub fn seatbelt_profile(policy: &SandboxPolicy) -> Option<String> {
if policy
.writable_roots
.iter()
.chain(&policy.read_deny_subpaths)
.chain(&policy.read_allow_subpaths)
.chain(&policy.write_protect_subpaths)
.any(|path| path.to_string_lossy().contains(|c: char| c.is_control()))
{
return None;
}
let mut profile = String::from("(version 1)\n(allow default)\n");
if !policy.allow_network {
profile.push_str("(deny network*)\n");
}
profile.push_str("(deny file-write*)\n");
profile.push_str("(allow file-write*\n");
for root in &policy.writable_roots {
profile.push_str(" (subpath ");
profile.push_str("e(&root.to_string_lossy()));
profile.push_str(")\n");
}
for device in [
"/dev/null",
"/dev/stdout",
"/dev/stderr",
"/dev/tty",
"/dev/ptmx",
] {
profile.push_str(" (literal ");
profile.push_str("e(device));
profile.push_str(")\n");
}
profile.push_str(
" (require-all (regex #\"^/dev/ttys[0-9]+$\") (extension \"com.apple.sandbox.pty\"))\n",
);
profile.push_str(")\n");
for path in &policy.write_protect_subpaths {
profile.push_str("(deny file-write* (subpath ");
profile.push_str("e(&path.to_string_lossy()));
profile.push_str("))\n");
}
for path in &policy.read_deny_subpaths {
push_file_read_rule(&mut profile, "deny", path);
}
for glob in &policy.read_deny_globs {
let regex = glob_to_seatbelt_regex(glob)?;
profile.push_str("(deny file-read* (regex #\"");
profile.push_str(®ex);
profile.push_str("\"))\n");
}
for path in &policy.read_allow_subpaths {
push_file_read_rule(&mut profile, "allow", path);
}
Some(profile)
}
fn glob_to_seatbelt_regex(glob: &str) -> Option<String> {
if glob.contains(|c: char| c.is_control() || matches!(c, '"' | '[' | ']' | '{' | '}')) {
return None;
}
let mut regex = String::from("^");
let mut chars = glob.chars().peekable();
while let Some(c) = chars.next() {
match c {
'*' => {
if chars.peek() == Some(&'*') {
chars.next();
regex.push_str(".*");
if chars.peek() == Some(&'/') {
chars.next();
}
} else {
regex.push_str("[^/]*");
}
}
'?' => regex.push_str("[^/]"),
'.' | '\\' | '+' | '(' | ')' | '^' | '$' | '|' => {
regex.push('\\');
regex.push(c);
}
other => regex.push(other),
}
}
regex.push('$');
Some(regex)
}
fn push_file_read_rule(profile: &mut String, verb: &str, path: &std::path::Path) {
profile.push('(');
profile.push_str(verb);
profile.push_str(" file-read* (subpath ");
profile.push_str("e(&path.to_string_lossy()));
profile.push_str("))\n");
}
fn quote(value: &str) -> String {
let escaped = value.replace('\\', "\\\\").replace('"', "\\\"");
format!("\"{escaped}\"")
}
#[cfg(test)]
mod tests {
use super::super::SandboxPolicy;
use super::*;
use std::ffi::OsStr;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
#[test]
fn seatbelt_profile_denies_writes_and_network_then_reopens_roots() {
let dir = tempfile::tempdir().unwrap();
let policy = SandboxPolicy::for_workspace(dir.path());
let profile = seatbelt_profile(&policy).expect("clean policy paths build a profile");
assert!(profile.contains("(deny file-write*)"));
assert!(profile.contains("(deny network*)"));
assert!(profile.contains("(allow file-write*"));
}
#[test]
fn seatbelt_profile_refuses_paths_with_control_characters() {
let mut policy = SandboxPolicy {
writable_roots: vec![PathBuf::from("/workspace")],
allow_network: false,
read_deny_subpaths: Vec::new(),
read_deny_globs: Vec::new(),
read_allow_subpaths: Vec::new(),
write_protect_subpaths: Vec::new(),
};
assert!(
seatbelt_profile(&policy).is_some(),
"an ordinary path builds a profile"
);
policy
.writable_roots
.push(PathBuf::from("/workspace/with\nnewline"));
assert!(
seatbelt_profile(&policy).is_none(),
"a writable root containing a newline refuses confinement"
);
let denied = SandboxPolicy {
writable_roots: vec![PathBuf::from("/workspace")],
allow_network: false,
read_deny_subpaths: vec![PathBuf::from("/workspace/secret\u{1}name")],
read_deny_globs: Vec::new(),
read_allow_subpaths: Vec::new(),
write_protect_subpaths: Vec::new(),
};
assert!(
seatbelt_profile(&denied).is_none(),
"a denied read path containing a control character refuses confinement"
);
}
#[test]
fn seatbelt_blocks_writes_outside_the_writable_root() {
let workspace_dir = tempfile::tempdir().unwrap();
let outside_dir = tempfile::tempdir().unwrap();
let workspace = std::fs::canonicalize(workspace_dir.path()).unwrap();
let outside = std::fs::canonicalize(outside_dir.path()).unwrap();
let policy = SandboxPolicy {
writable_roots: vec![workspace.clone()],
allow_network: false,
read_deny_subpaths: Vec::new(),
read_deny_globs: Vec::new(),
read_allow_subpaths: Vec::new(),
write_protect_subpaths: Vec::new(),
};
let run = |target: &Path| {
let command = format!("echo confined > {}", target.display());
let (program, args) =
super::super::confined_invocation(OsStr::new("/bin/sh"), &command, &policy)
.unwrap();
Command::new(program)
.args(args)
.output()
.expect("spawn sandbox-exec");
};
let inside_file = workspace.join("inside.txt");
run(&inside_file);
assert!(
inside_file.is_file(),
"a write inside the writable root should succeed"
);
let outside_file = outside.join("outside.txt");
run(&outside_file);
assert!(
!outside_file.exists(),
"a write outside the writable root must be blocked by the sandbox"
);
}
#[test]
fn seatbelt_profile_reopens_pty_devices() {
let dir = tempfile::tempdir().unwrap();
let policy = SandboxPolicy::for_workspace(dir.path());
let profile = seatbelt_profile(&policy).expect("clean policy paths build a profile");
assert!(profile.contains("(literal \"/dev/ptmx\")"));
assert!(profile.contains("com.apple.sandbox.pty"));
assert!(
profile.contains("#\"^/dev/ttys[0-9]+$\""),
"the pseudo-terminal slave pattern must be end-anchored"
);
}
#[test]
fn seatbelt_allows_pty_allocation() {
let dir = tempfile::tempdir().unwrap();
let policy = SandboxPolicy::for_workspace(dir.path());
let command = "/usr/bin/script -q /dev/null /usr/bin/true";
let (program, args) =
super::super::confined_invocation(OsStr::new("/bin/sh"), command, &policy).unwrap();
let output = Command::new(program)
.args(args)
.stdin(Stdio::null())
.output()
.expect("spawn sandbox-exec");
assert!(
output.status.success(),
"a confined command must be able to allocate a PTY; stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
}
#[test]
fn seatbelt_profile_denies_configured_read_subpaths() {
let dir = tempfile::tempdir().unwrap();
let policy = SandboxPolicy::for_workspace(dir.path()).with_read_rules(
dir.path(),
&["./secret/**".to_string()],
&["./secret/ok.txt".to_string()],
);
let profile = seatbelt_profile(&policy).expect("clean policy paths build a profile");
assert!(profile.contains("(deny file-read* (subpath"));
assert!(profile.contains("(allow file-read* (subpath"));
}
#[test]
fn seatbelt_blocks_reads_of_denied_subpaths() {
let workspace_dir = tempfile::tempdir().unwrap();
let workspace = std::fs::canonicalize(workspace_dir.path()).unwrap();
let secret_dir = workspace.join("secret");
std::fs::create_dir(&secret_dir).unwrap();
let secret = secret_dir.join("key.txt");
std::fs::write(&secret, "top secret").unwrap();
let public = secret_dir.join("public.txt");
std::fs::write(&public, "fine to read").unwrap();
let policy = SandboxPolicy {
writable_roots: vec![workspace.clone()],
allow_network: false,
read_deny_subpaths: vec![secret_dir.clone()],
read_deny_globs: Vec::new(),
read_allow_subpaths: vec![public.clone()],
write_protect_subpaths: Vec::new(),
};
let read = |target: &Path| {
let command = format!("cat {}", target.display());
let (program, args) =
super::super::confined_invocation(OsStr::new("/bin/sh"), &command, &policy)
.unwrap();
Command::new(program)
.args(args)
.output()
.expect("spawn sandbox-exec")
};
let denied = read(&secret);
assert!(
!denied.status.success(),
"a read of a denied subpath must be blocked"
);
let allowed = read(&public);
assert!(
allowed.status.success(),
"a read of an allow exception must succeed; stderr: {}",
String::from_utf8_lossy(&allowed.stderr)
);
assert_eq!(
String::from_utf8_lossy(&allowed.stdout).trim(),
"fine to read"
);
}
#[test]
fn glob_to_seatbelt_regex_translates_globs_and_refuses_unsafe() {
assert_eq!(
glob_to_seatbelt_regex("/etc/ssl/private/*.key").unwrap(),
r"^/etc/ssl/private/[^/]*\.key$"
);
assert_eq!(glob_to_seatbelt_regex("/a/**/b").unwrap(), r"^/a/.*b$");
assert_eq!(
glob_to_seatbelt_regex("/etc/**/passwd").unwrap(),
r"^/etc/.*passwd$"
);
assert_eq!(glob_to_seatbelt_regex("/a/?.c").unwrap(), r"^/a/[^/]\.c$");
assert!(glob_to_seatbelt_regex("/a/[ab].key").is_none());
assert!(glob_to_seatbelt_regex("/a/{x,y}").is_none());
assert!(glob_to_seatbelt_regex("/a/\"q").is_none());
assert!(glob_to_seatbelt_regex("/a/\nb").is_none());
}
#[test]
fn seatbelt_profile_emits_regex_deny_for_glob_and_refuses_unconvertible() {
let dir = tempfile::tempdir().unwrap();
let mut policy = SandboxPolicy::for_workspace(dir.path());
policy.read_deny_globs = vec!["/etc/ssl/private/*.key".to_string()];
let profile = seatbelt_profile(&policy).expect("a convertible glob builds a profile");
assert!(profile.contains("(deny file-read* (regex"));
assert!(profile.contains(r"^/etc/ssl/private/[^/]*\.key$"));
policy.read_deny_globs = vec!["/etc/[ab].key".to_string()];
assert!(
seatbelt_profile(&policy).is_none(),
"an unconvertible glob refuses confinement"
);
}
#[test]
fn seatbelt_blocks_reads_matching_a_glob_deny_including_indirect() {
let workspace_dir = tempfile::tempdir().unwrap();
let workspace = std::fs::canonicalize(workspace_dir.path()).unwrap();
let outside_dir = tempfile::tempdir().unwrap();
let keys = std::fs::canonicalize(outside_dir.path())
.unwrap()
.join("keys");
std::fs::create_dir(&keys).unwrap();
let secret = keys.join("id.key");
std::fs::write(&secret, "top secret").unwrap();
let public = keys.join("note.txt");
std::fs::write(&public, "fine to read").unwrap();
let policy = SandboxPolicy {
writable_roots: vec![workspace],
allow_network: false,
read_deny_subpaths: Vec::new(),
read_deny_globs: vec![format!("{}/*.key", keys.display())],
read_allow_subpaths: Vec::new(),
write_protect_subpaths: Vec::new(),
};
let run = |command: String| {
let (program, args) =
super::super::confined_invocation(OsStr::new("/bin/sh"), &command, &policy)
.unwrap();
Command::new(program)
.args(args)
.output()
.expect("spawn sandbox-exec")
};
assert!(
!run(format!("cat {}", secret.display())).status.success(),
"a direct read of a glob-denied file must be blocked"
);
assert!(
!run(format!("f={}; cat \"$f\"", secret.display()))
.status
.success(),
"an indirect read of a glob-denied file must be blocked"
);
let allowed = run(format!("cat {}", public.display()));
assert!(
allowed.status.success(),
"a non-matching sibling must stay readable; stderr: {}",
String::from_utf8_lossy(&allowed.stderr)
);
}
#[test]
fn seatbelt_double_star_glob_blocks_the_zero_directory_case() {
let workspace_dir = tempfile::tempdir().unwrap();
let workspace = std::fs::canonicalize(workspace_dir.path()).unwrap();
let base_dir = tempfile::tempdir().unwrap();
let base = std::fs::canonicalize(base_dir.path()).unwrap();
let direct = base.join("secret");
std::fs::write(&direct, "top secret").unwrap();
let nested_dir = base.join("sub");
std::fs::create_dir(&nested_dir).unwrap();
let nested = nested_dir.join("secret");
std::fs::write(&nested, "top secret").unwrap();
let policy = SandboxPolicy {
writable_roots: vec![workspace],
allow_network: false,
read_deny_subpaths: Vec::new(),
read_deny_globs: vec![format!("{}/**/secret", base.display())],
read_allow_subpaths: Vec::new(),
write_protect_subpaths: Vec::new(),
};
let read = |target: &Path| {
let command = format!("cat {}", target.display());
let (program, args) =
super::super::confined_invocation(OsStr::new("/bin/sh"), &command, &policy)
.unwrap();
Command::new(program)
.args(args)
.output()
.expect("spawn sandbox-exec")
};
assert!(
!read(&direct).status.success(),
"the zero-directory match must be blocked"
);
assert!(
!read(&nested).status.success(),
"a nested match must be blocked"
);
}
#[test]
fn seatbelt_broad_read_allow_does_not_reopen_denied_subpath() {
let workspace_dir = tempfile::tempdir().unwrap();
let workspace = std::fs::canonicalize(workspace_dir.path()).unwrap();
let secret_dir = workspace.join("secret");
std::fs::create_dir(&secret_dir).unwrap();
let secret = secret_dir.join("key.txt");
std::fs::write(&secret, "top secret").unwrap();
let policy = SandboxPolicy::for_workspace(&workspace).with_read_rules(
&workspace,
&[format!("{}/**", secret_dir.display())],
&[format!("{}/**", workspace.display())],
);
let command = format!("cat {}", secret.display());
let (program, args) =
super::super::confined_invocation(OsStr::new("/bin/sh"), &command, &policy).unwrap();
let output = Command::new(program)
.args(args)
.output()
.expect("spawn sandbox-exec");
assert!(
!output.status.success(),
"a broad allow must not re-open the denied secret"
);
}
#[test]
fn seatbelt_blocks_outbound_network() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
std::thread::spawn(move || {
for stream in listener.incoming() {
drop(stream);
}
});
let command = format!("/usr/bin/nc -z -w 1 127.0.0.1 {port}");
let unconfined = Command::new("/bin/sh")
.args(["-c", &command])
.output()
.expect("spawn nc");
assert!(
unconfined.status.success(),
"the unconfined control connection must succeed; stderr: {}",
String::from_utf8_lossy(&unconfined.stderr)
);
let dir = tempfile::tempdir().unwrap();
let run_confined = |allow_network: bool| {
let policy = SandboxPolicy {
allow_network,
..SandboxPolicy::for_workspace(dir.path())
};
let (program, args) =
super::super::confined_invocation(OsStr::new("/bin/sh"), &command, &policy)
.unwrap();
Command::new(program)
.args(args)
.output()
.expect("spawn sandbox-exec")
};
let allowed = run_confined(true);
assert!(
allowed.status.success(),
"a confined command must connect when the policy allows the network; stderr: {}",
String::from_utf8_lossy(&allowed.stderr)
);
assert!(
!run_confined(false).status.success(),
"a confined command must not be able to open an outbound socket"
);
}
#[test]
fn seatbelt_profile_write_protects_metadata() {
let dir = tempfile::tempdir().unwrap();
let policy = SandboxPolicy::for_workspace(dir.path());
let profile = seatbelt_profile(&policy).expect("clean policy paths build a profile");
let git = dir.path().join(".git");
let deny = format!("(deny file-write* (subpath \"{}\"))", git.to_string_lossy());
assert!(profile.contains(&deny), "missing metadata write-deny");
assert!(
profile.find(&deny).unwrap() > profile.find("(allow file-write*").unwrap(),
"metadata deny must follow the writable-root allow"
);
}
#[test]
fn seatbelt_write_protects_metadata_end_to_end() {
let workspace_dir = tempfile::tempdir().unwrap();
let workspace = std::fs::canonicalize(workspace_dir.path()).unwrap();
let git = workspace.join(".git");
std::fs::create_dir(&git).unwrap();
std::fs::write(git.join("config"), "[core]\n").unwrap();
let sofos = workspace.join(".sofos");
std::fs::create_dir(&sofos).unwrap();
let policy = SandboxPolicy::for_workspace(&workspace);
let run = |command: String| {
let (program, args) =
super::super::confined_invocation(OsStr::new("/bin/sh"), &command, &policy)
.unwrap();
Command::new(program)
.args(args)
.output()
.expect("spawn sandbox-exec")
};
run(format!(
"echo ok > {}",
workspace.join("file.txt").display()
));
assert!(
workspace.join("file.txt").is_file(),
"workspace write blocked"
);
run(format!("echo hacked >> {}", git.join("config").display()));
assert_eq!(
std::fs::read_to_string(git.join("config")).unwrap(),
"[core]\n",
".git must stay read-only"
);
run(format!(
"printf x > {}",
sofos.join("config.local.toml").display()
));
assert!(
!sofos.join("config.local.toml").exists(),
".sofos must stay read-only"
);
let read = run(format!("cat {}", git.join("config").display()));
assert!(
read.status.success() && String::from_utf8_lossy(&read.stdout).contains("[core]"),
".git must stay readable"
);
}
}