use std::collections::BTreeSet;
use std::fmt;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::process::Stdio;
use std::str::FromStr;
use std::sync::OnceLock;
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct WritableRoots(Vec<String>);
impl WritableRoots {
pub fn as_slice(&self) -> &[String] {
&self.0
}
pub fn as_str_slice(&self) -> Vec<&str> {
self.0.iter().map(String::as_str).collect()
}
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
pub fn push(&mut self, root: String) {
if !root.is_empty() {
self.0.push(root);
}
}
}
impl FromStr for WritableRoots {
type Err = std::convert::Infallible;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let roots = s
.split(':')
.filter(|s| !s.is_empty())
.map(String::from)
.collect();
Ok(WritableRoots(roots))
}
}
impl fmt::Display for WritableRoots {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let mut first = true;
for root in &self.0 {
if !first {
write!(f, ":")?;
}
write!(f, "{root}")?;
first = false;
}
Ok(())
}
}
const SANDBOX_EXEC: &str = "/usr/bin/sandbox-exec";
const HOME_READ_SUBPATHS: &[&str] = &[".cargo", ".config/git", ".rustup", "src"];
const HOME_READ_FILES: &[&str] = &[
".gitconfig",
".gitignore",
".gitignore_global",
".gitmessage",
];
const GLOBAL_READ_ROOTS: &[&str] = &["/Library/Developer/CommandLineTools"];
const POLICY_PREAMBLE: &str = r#"(version 1)
;; =======================================================================
;; 1. BASE POLICY — deny-default, process control, PTY, sysctls, IPC
;; =======================================================================
(deny default)
; Child processes inherit the sandbox.
(allow process-exec)
(allow process-fork)
(allow signal (target same-sandbox))
(allow process-info* (target same-sandbox))
; /dev/null write
(allow file-write-data
(require-all
(path "/dev/null")
(vnode-type CHARACTER-DEVICE)))
; Curated sysctl reads (CPU topology, kernel info, memory, load average).
(allow sysctl-read
(sysctl-name "hw.activecpu")
(sysctl-name "hw.busfrequency_compat")
(sysctl-name "hw.byteorder")
(sysctl-name "hw.cacheconfig")
(sysctl-name "hw.cachelinesize_compat")
(sysctl-name "hw.cpufamily")
(sysctl-name "hw.cpufrequency_compat")
(sysctl-name "hw.cputype")
(sysctl-name "hw.l1dcachesize_compat")
(sysctl-name "hw.l1icachesize_compat")
(sysctl-name "hw.l2cachesize_compat")
(sysctl-name "hw.l3cachesize_compat")
(sysctl-name "hw.logicalcpu_max")
(sysctl-name "hw.machine")
(sysctl-name "hw.model")
(sysctl-name "hw.memsize")
(sysctl-name "hw.ncpu")
(sysctl-name "hw.nperflevels")
(sysctl-name-prefix "hw.optional.arm.")
(sysctl-name-prefix "hw.optional.armv8_")
(sysctl-name "hw.packages")
(sysctl-name "hw.pagesize_compat")
(sysctl-name "hw.pagesize")
(sysctl-name "hw.physicalcpu")
(sysctl-name "hw.physicalcpu_max")
(sysctl-name "hw.logicalcpu")
(sysctl-name "hw.cpufrequency")
(sysctl-name "hw.tbfrequency_compat")
(sysctl-name "hw.vectorunit")
(sysctl-name "machdep.cpu.brand_string")
(sysctl-name "kern.argmax")
(sysctl-name "kern.hostname")
(sysctl-name "kern.maxfilesperproc")
(sysctl-name "kern.maxproc")
(sysctl-name "kern.osproductversion")
(sysctl-name "kern.osrelease")
(sysctl-name "kern.ostype")
(sysctl-name "kern.osvariant_status")
(sysctl-name "kern.osversion")
(sysctl-name "kern.secure_kernel")
(sysctl-name "kern.usrstack64")
(sysctl-name "kern.version")
(sysctl-name "sysctl.proc_cputype")
(sysctl-name "vm.loadavg")
(sysctl-name-prefix "hw.perflevel")
(sysctl-name-prefix "kern.proc.pgrp.")
(sysctl-name-prefix "kern.proc.pid.")
(sysctl-name-prefix "net.routetable."))
; Java CPU grading (misclassified as write, conceptually a read).
(allow sysctl-write
(sysctl-name "kern.grade_cputype"))
; IOKit root domain.
(allow iokit-open
(iokit-registry-entry-class "RootDomainUserClient"))
; User directory lookups.
(allow mach-lookup
(global-name "com.apple.system.opendirectoryd.libinfo"))
; Python multiprocessing SemLock.
(allow ipc-posix-sem)
; PyTorch/libomp shared memory.
(allow ipc-posix-shm-read-data
ipc-posix-shm-write-create
ipc-posix-shm-write-unlink
(ipc-posix-name-regex #"^/__KMP_REGISTERED_LIB_[0-9]+$"))
; Power management.
(allow mach-lookup
(global-name "com.apple.PowerManagement.control"))
; PTY support.
(allow pseudo-tty)
(allow file-read* file-write* file-ioctl (literal "/dev/ptmx"))
(allow file-read* file-write*
(require-all
(regex #"^/dev/ttys[0-9]+")
(extension "com.apple.sandbox.pty")))
(allow file-ioctl (regex #"^/dev/ttys[0-9]+"))
; Read-only user preferences.
(allow ipc-posix-shm-read* (ipc-posix-name-prefix "apple.cfprefs."))
(allow mach-lookup
(global-name "com.apple.cfprefsd.daemon")
(global-name "com.apple.cfprefsd.agent")
(local-name "com.apple.cfprefsd.agent"))
(allow user-preference-read)
;; =======================================================================
;; 2. READ POLICY — restricted allowlist
;; =======================================================================
"#;
const POLICY_BEFORE_WRITE_RULES: &str = r#"
;; =======================================================================
;; 3. WRITE POLICY — writable roots + /tmp
;; =======================================================================
"#;
const POLICY_AFTER_WRITE_RULES: &str = r#"
;; =======================================================================
;; 4. NETWORK POLICY — deny by default, localhost only + platform services
;; =======================================================================
; Explicit deny-all for network access (defense-in-depth over `deny default`).
(deny network*)
; Allow binding on localhost only (needed for dev servers).
(allow network-bind (local ip "localhost:*"))
; Restrict traffic to loopback.
(allow network-inbound (local ip "localhost:*"))
(allow network-outbound (remote ip "localhost:*"))
; AF_SYSTEM sockets for local platform services.
(allow system-socket
(require-all
(socket-domain AF_SYSTEM)
(socket-protocol 2)))
; Mach lookups for DNS resolution and TLS certificate validation.
(allow mach-lookup
(global-name "com.apple.bsd.dirhelper")
(global-name "com.apple.system.opendirectoryd.membership")
(global-name "com.apple.SecurityServer")
(global-name "com.apple.networkd")
(global-name "com.apple.ocspd")
(global-name "com.apple.trustd.agent")
(global-name "com.apple.SystemConfiguration.DNSConfiguration")
(global-name "com.apple.SystemConfiguration.configd"))
; Route-table reads.
(allow sysctl-read
(sysctl-name-regex #"^net.routetable"))
; TLS session cache and per-user temp writes.
(allow file-write*
(subpath (param "DARWIN_USER_CACHE_DIR")))
(allow file-read* file-test-existence file-write*
(subpath (param "DARWIN_USER_TEMP_DIR")))
;; =======================================================================
;; 5. PLATFORM DEFAULTS — system libs, frameworks, /dev, /tmp, binaries
;; =======================================================================
; Read access to standard system paths.
(allow file-read* file-test-existence
(subpath "/Library/Apple")
(subpath "/Library/Filesystems/NetFSPlugins")
(subpath "/Library/Preferences/Logging")
(subpath "/private/var/db/DarwinDirectory/local/recordStore.data")
(subpath "/private/var/db/timezone")
(subpath "/usr/lib")
(subpath "/usr/share")
(subpath "/Library/Preferences")
(subpath "/var/db")
(subpath "/private/var/db"))
; Map system frameworks and dylibs for the loader.
(allow file-map-executable
(subpath "/Library/Apple/System/Library/Frameworks")
(subpath "/Library/Apple/System/Library/PrivateFrameworks")
(subpath "/Library/Apple/usr/lib")
(subpath "/System/Library/Extensions")
(subpath "/System/Library/Frameworks")
(subpath "/System/Library/PrivateFrameworks")
(subpath "/System/Library/SubFrameworks")
(subpath "/System/iOSSupport/System/Library/Frameworks")
(subpath "/System/iOSSupport/System/Library/PrivateFrameworks")
(subpath "/System/iOSSupport/System/Library/SubFrameworks")
(subpath "/usr/lib"))
; System framework resources.
(allow file-read* file-test-existence
(subpath "/Library/Apple/System/Library/Frameworks")
(subpath "/Library/Apple/System/Library/PrivateFrameworks")
(subpath "/Library/Apple/usr/lib")
(subpath "/System/Library/Frameworks")
(subpath "/System/Library/PrivateFrameworks")
(subpath "/System/Library/SubFrameworks")
(subpath "/System/iOSSupport/System/Library/Frameworks")
(subpath "/System/iOSSupport/System/Library/PrivateFrameworks")
(subpath "/System/iOSSupport/System/Library/SubFrameworks")
(subpath "/usr/lib"))
; Guarded vnodes.
(allow system-mac-syscall (mac-policy-name "vnguard"))
; Container detection.
(allow system-mac-syscall
(require-all
(mac-policy-name "Sandbox")
(mac-syscall-number 67)))
; Symlink resolution for standard paths.
(allow file-read-metadata file-test-existence
(literal "/etc")
(literal "/tmp")
(literal "/var")
(literal "/private/etc/localtime"))
; Firmlink parent traversal.
(allow file-read-metadata file-test-existence
(path-ancestors "/System/Volumes/Data/private"))
; Root directory.
(allow file-read* file-test-existence
(literal "/"))
; Alternate chflags.
(allow system-fsctl (fsctl-command FSIOC_CAS_BSDFLAGS))
; Standard special files.
(allow file-read* file-test-existence
(literal "/dev/autofs_nowait")
(literal "/dev/random")
(literal "/dev/urandom")
(literal "/private/etc/master.passwd")
(literal "/private/etc/passwd")
(literal "/private/etc/protocols")
(literal "/private/etc/services"))
; /dev/null and /dev/zero read/write.
(allow file-read* file-test-existence file-write-data
(literal "/dev/null")
(literal "/dev/zero"))
; File descriptors.
(allow file-read-data file-test-existence file-write-data
(subpath "/dev/fd"))
; DTrace helper.
(allow file-read* file-test-existence file-write-data file-ioctl
(literal "/dev/dtracehelper"))
; Scratch space (tmp directories).
(allow file-read* file-test-existence file-write* (subpath "/tmp"))
(allow file-read* file-write* (subpath "/private/tmp"))
(allow file-read* file-write* (subpath "/var/tmp"))
(allow file-read* file-write* (subpath "/private/var/tmp"))
; Standard config directories.
(allow file-read* (subpath "/etc"))
(allow file-read* (subpath "/private/etc"))
; CoreServices version plists.
(allow file-read* file-test-existence
(literal "/System/Library/CoreServices")
(literal "/System/Library/CoreServices/.SystemVersionPlatform.plist")
(literal "/System/Library/CoreServices/SystemVersion.plist"))
; /var metadata.
(allow file-read-metadata (subpath "/var"))
(allow file-read-metadata (subpath "/private/var"))
; System agents and services.
(allow mach-lookup
(global-name "com.apple.analyticsd")
(global-name "com.apple.analyticsd.messagetracer")
(global-name "com.apple.appsleep")
(global-name "com.apple.bsd.dirhelper")
(global-name "com.apple.cfprefsd.agent")
(global-name "com.apple.cfprefsd.daemon")
(global-name "com.apple.diagnosticd")
(global-name "com.apple.dt.automationmode.reader")
(global-name "com.apple.espd")
(global-name "com.apple.logd")
(global-name "com.apple.logd.events")
(global-name "com.apple.runningboard")
(global-name "com.apple.secinitd")
(global-name "com.apple.system.DirectoryService.libinfo_v1")
(global-name "com.apple.system.logger")
(global-name "com.apple.system.notification_center")
(global-name "com.apple.system.opendirectoryd.membership")
(global-name "com.apple.trustd")
(global-name "com.apple.trustd.agent")
(global-name "com.apple.xpc.activity.unmanaged")
(local-name "com.apple.cfprefsd.agent"))
; Syslog socket.
(allow network-outbound (literal "/private/var/run/syslog"))
; macOS notifications shared memory.
(allow ipc-posix-shm-read*
(ipc-posix-name "apple.shm.notification_center"))
; Eligibility plist.
(allow file-read*
(literal "/private/var/db/eligibilityd/eligibility.plist"))
; Audio and power management.
(allow mach-lookup (global-name "com.apple.audio.audiohald"))
(allow mach-lookup (global-name "com.apple.audio.AudioComponentRegistrar"))
; System binaries.
(allow file-read-data (subpath "/bin"))
(allow file-read-metadata (subpath "/bin"))
(allow file-read-data (subpath "/sbin"))
(allow file-read-metadata (subpath "/sbin"))
(allow file-read-data (subpath "/usr/bin"))
(allow file-read-metadata (subpath "/usr/bin"))
(allow file-read-data (subpath "/usr/sbin"))
(allow file-read-metadata (subpath "/usr/sbin"))
(allow file-read-data (subpath "/usr/libexec"))
(allow file-read-metadata (subpath "/usr/libexec"))
; Library paths (Homebrew, system, Applications).
(allow file-read* (subpath "/Library/Preferences"))
(allow file-read* (subpath "/opt/homebrew/lib"))
(allow file-read* (subpath "/usr/local/lib"))
(allow file-read* (subpath "/Applications"))
; Terminal device handles.
(allow file-read* (regex "^/dev/fd/(0|1|2)$"))
(allow file-write* (regex "^/dev/fd/(1|2)$"))
(allow file-read* file-write* (literal "/dev/null"))
(allow file-read* file-write* (literal "/dev/tty"))
(allow file-read-metadata (literal "/dev"))
(allow file-read-metadata (regex "^/dev/.*$"))
(allow file-read-metadata (literal "/dev/stdin"))
(allow file-read-metadata (literal "/dev/stdout"))
(allow file-read-metadata (literal "/dev/stderr"))
(allow file-read-metadata (regex "^/dev/tty[^/]*$"))
(allow file-read-metadata (regex "^/dev/pty[^/]*$"))
(allow file-read* file-write* (regex "^/dev/ttys[0-9]+$"))
(allow file-read* file-write* (literal "/dev/ptmx"))
(allow file-ioctl (regex "^/dev/ttys[0-9]+$"))
; Firmlink volume metadata.
(allow file-read-metadata (literal "/System/Volumes") (vnode-type DIRECTORY))
(allow file-read-metadata (literal "/System/Volumes/Data") (vnode-type DIRECTORY))
(allow file-read-metadata (literal "/System/Volumes/Data/Users") (vnode-type DIRECTORY))
; App sandbox extensions.
(allow file-read* (extension "com.apple.app-sandbox.read"))
(allow file-read* file-write* (extension "com.apple.app-sandbox.read-write"))
"#;
pub fn sandbox_available() -> bool {
static AVAILABLE: OnceLock<bool> = OnceLock::new();
*AVAILABLE.get_or_init(sandbox_exec_accepts_policy)
}
fn sandbox_exec_accepts_policy() -> bool {
if !Path::new(SANDBOX_EXEC).is_file() {
return false;
}
Command::new(SANDBOX_EXEC)
.arg("-p")
.arg("(version 1)\n(allow default)\n")
.arg("/usr/bin/true")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.map(|status| status.success())
.unwrap_or(false)
}
pub fn darwin_user_cache_dir() -> String {
darwin_user_dir("DARWIN_USER_CACHE_DIR", || {
"/private/var/folders".to_string()
})
}
pub fn darwin_user_temp_dir() -> String {
darwin_user_dir("DARWIN_USER_TEMP_DIR", || {
std::env::temp_dir()
.to_string_lossy()
.trim_end_matches('/')
.to_string()
})
}
fn darwin_user_dir<F>(variable: &str, fallback: F) -> String
where
F: FnOnce() -> String,
{
let output = Command::new("/usr/bin/getconf").arg(variable).output();
match output {
Ok(out) if out.status.success() => {
let dir = String::from_utf8_lossy(&out.stdout).trim().to_string();
dir.trim_end_matches('/').to_string()
}
_ => fallback(),
}
}
fn escape_sbpl_string(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for ch in s.chars() {
match ch {
'\\' => out.push_str("\\\\"),
'"' => out.push_str("\\\""),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c if c.is_control() => {
}
c => out.push(c),
}
}
out
}
fn path_variants(path: &Path) -> Vec<PathBuf> {
let mut variants = vec![path.to_path_buf()];
if let Ok(canonical) = std::fs::canonicalize(path)
&& !variants.iter().any(|existing| existing == &canonical)
{
variants.push(canonical);
}
variants
}
fn append_path_variants(read_roots: &mut BTreeSet<String>, path: &Path) {
for variant in path_variants(path) {
if let Some(path) = variant.to_str() {
read_roots.insert(path.to_string());
}
}
}
fn collect_read_roots(
writable_roots: &WritableRoots,
extra_read_roots: &[String],
home_dir: Option<&Path>,
) -> BTreeSet<String> {
let mut read_roots = BTreeSet::new();
let mut home_variants = Vec::new();
let mut allowed_home_variants = Vec::new();
if let Some(home_dir) = home_dir {
home_variants = path_variants(home_dir);
for suffix in HOME_READ_SUBPATHS {
let suffix_path = home_dir.join(suffix);
for variant in path_variants(&suffix_path) {
if !allowed_home_variants
.iter()
.any(|existing| existing == &variant)
{
allowed_home_variants.push(variant);
}
}
append_path_variants(&mut read_roots, &suffix_path);
}
for file in HOME_READ_FILES {
append_path_variants(&mut read_roots, &home_dir.join(file));
}
}
for root in GLOBAL_READ_ROOTS {
append_path_variants(&mut read_roots, Path::new(root));
}
for root in writable_roots.as_slice() {
let root_path = Path::new(root);
let allow = !home_variants.iter().any(|home| root_path.starts_with(home))
|| allowed_home_variants
.iter()
.any(|allowed| root_path.starts_with(allowed));
if allow {
append_path_variants(&mut read_roots, root_path);
}
}
for root in extra_read_roots {
append_path_variants(&mut read_roots, Path::new(root));
}
read_roots
}
fn append_read_rules(
policy: &mut String,
writable_roots: &WritableRoots,
extra_read_roots: &[String],
home_dir: Option<&Path>,
) {
for root in collect_read_roots(writable_roots, extra_read_roots, home_dir) {
let escaped = escape_sbpl_string(&root);
policy.push_str(&format!(
"(allow file-read* file-test-existence (subpath \"{escaped}\"))\n"
));
policy.push_str(&format!(
"(allow file-read-metadata file-test-existence (path-ancestors \"{escaped}\"))\n"
));
}
}
fn home_dir() -> Option<PathBuf> {
std::env::var_os("HOME")
.filter(|home| !home.is_empty())
.map(PathBuf::from)
}
pub fn build_policy(writable_roots: &WritableRoots) -> String {
build_policy_with_home(writable_roots, home_dir().as_deref())
}
pub(crate) fn build_policy_with_read_roots(
writable_roots: &WritableRoots,
extra_read_roots: &[String],
) -> String {
build_policy_with_home_and_read_roots(writable_roots, extra_read_roots, home_dir().as_deref())
}
fn build_policy_with_home(writable_roots: &WritableRoots, home_dir: Option<&Path>) -> String {
build_policy_with_home_and_read_roots(writable_roots, &[], home_dir)
}
fn build_policy_with_home_and_read_roots(
writable_roots: &WritableRoots,
extra_read_roots: &[String],
home_dir: Option<&Path>,
) -> String {
let roots = writable_roots.as_slice();
let read_roots = collect_read_roots(writable_roots, extra_read_roots, home_dir);
let mut policy = String::with_capacity(
POLICY_PREAMBLE.len()
+ POLICY_BEFORE_WRITE_RULES.len()
+ POLICY_AFTER_WRITE_RULES.len()
+ read_roots.len() * 128
+ roots.len() * 64,
);
policy.push_str(POLICY_PREAMBLE);
append_read_rules(&mut policy, writable_roots, extra_read_roots, home_dir);
policy.push_str(POLICY_BEFORE_WRITE_RULES);
for root in roots {
let escaped = escape_sbpl_string(root);
policy.push_str(&format!("(allow file-write* (subpath \"{escaped}\"))\n"));
}
policy.push_str(POLICY_AFTER_WRITE_RULES);
policy
}
pub fn shell_wrapper(writable_roots: &WritableRoots) -> Option<Vec<String>> {
if !sandbox_available() {
return None;
}
let policy = build_policy(writable_roots);
let cache_dir = darwin_user_cache_dir();
let temp_dir = darwin_user_temp_dir();
Some(vec![
SANDBOX_EXEC.to_string(),
"-p".to_string(),
policy,
"-D".to_string(),
format!("DARWIN_USER_CACHE_DIR={cache_dir}"),
"-D".to_string(),
format!("DARWIN_USER_TEMP_DIR={temp_dir}"),
"--".to_string(),
])
}
pub fn sandboxed_command(
program: &str,
args: &[&str],
writable_roots: &WritableRoots,
) -> tokio::process::Command {
let extra_read_roots = [program.to_string()];
sandboxed_command_with_read_roots(program, args, writable_roots, &extra_read_roots)
}
pub(crate) fn sandboxed_command_with_read_roots(
program: &str,
args: &[&str],
writable_roots: &WritableRoots,
extra_read_roots: &[String],
) -> tokio::process::Command {
if sandbox_available() {
let policy = build_policy_with_read_roots(writable_roots, extra_read_roots);
let cache_dir = darwin_user_cache_dir();
let temp_dir = darwin_user_temp_dir();
let mut cmd = tokio::process::Command::new(SANDBOX_EXEC);
cmd.arg("-p").arg(policy);
cmd.arg("-D")
.arg(format!("DARWIN_USER_CACHE_DIR={cache_dir}"));
cmd.arg("-D")
.arg(format!("DARWIN_USER_TEMP_DIR={temp_dir}"));
cmd.arg("--");
cmd.arg(program);
cmd.args(args);
cmd
} else {
let mut cmd = tokio::process::Command::new(program);
cmd.args(args);
cmd
}
}
pub(crate) fn service_read_roots(config_root: &Path, executable_path: &Path) -> Vec<String> {
let mut roots = BTreeSet::new();
roots.insert(config_root.to_string_lossy().into_owned());
if let Some(parent) = executable_path.parent() {
roots.insert(parent.to_string_lossy().into_owned());
}
roots.insert(executable_path.to_string_lossy().into_owned());
roots.into_iter().collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn roots(paths: &[&str]) -> WritableRoots {
let mut wr = WritableRoots::default();
for p in paths {
wr.push(p.to_string());
}
wr
}
#[test]
fn writable_roots_from_str_splits_on_colon() {
let wr: WritableRoots = "/a:/b:/c".parse().unwrap();
assert_eq!(wr.as_slice(), &["/a", "/b", "/c"]);
}
#[test]
fn writable_roots_from_str_drops_empty_components() {
let wr: WritableRoots = ":/a::/b:".parse().unwrap();
assert_eq!(wr.as_slice(), &["/a", "/b"]);
}
#[test]
fn writable_roots_display_round_trips() {
let wr: WritableRoots = "/a:/b".parse().unwrap();
assert_eq!(wr.to_string(), "/a:/b");
}
#[test]
fn writable_roots_push_ignores_empty() {
let mut wr = WritableRoots::default();
wr.push(String::new());
assert!(wr.is_empty());
}
#[test]
fn build_policy_contains_writable_root_rules() {
let policy = build_policy(&roots(&["/home/user/src", "/tmp/scratch"]));
assert!(
policy.contains("(allow file-write* (subpath \"/home/user/src\"))"),
"policy should contain first writable root"
);
assert!(
policy.contains("(allow file-write* (subpath \"/tmp/scratch\"))"),
"policy should contain second writable root"
);
}
#[test]
fn build_policy_with_no_roots_has_no_writable_root_rules() {
let policy = build_policy_with_home(&WritableRoots::default(), None);
assert!(
!policy.contains("(allow file-write* (subpath \"/home"),
"policy should have no writable-root rules"
);
}
#[test]
fn build_policy_is_valid_sbpl_structure() {
let policy =
build_policy_with_home(&roots(&["/workspace"]), Some(Path::new("/Users/tester")));
assert!(policy.starts_with("(version 1)"));
assert!(policy.contains("(deny default)"));
assert!(!policy.contains("\n(allow file-read*)\n"));
assert!(policy.contains("(allow file-read* file-test-existence (subpath \"/workspace\"))"));
assert!(
policy
.contains("(allow file-read* file-test-existence (subpath \"/Users/tester/src\"))")
);
assert!(policy.contains("(param \"DARWIN_USER_CACHE_DIR\")"));
assert!(policy.contains("(param \"DARWIN_USER_TEMP_DIR\")"));
}
#[test]
fn build_policy_contains_minimal_home_read_allowlist() {
let policy =
build_policy_with_home(&WritableRoots::default(), Some(Path::new("/Users/tester")));
assert!(policy.contains("(subpath \"/Users/tester/.cargo\")"));
assert!(policy.contains("(subpath \"/Users/tester/.config/git\")"));
assert!(policy.contains("(subpath \"/Users/tester/.gitconfig\")"));
assert!(policy.contains("(subpath \"/Users/tester/.rustup\")"));
assert!(policy.contains("(subpath \"/Users/tester/src\")"));
}
#[test]
fn build_policy_contains_global_read_allowlist() {
let policy =
build_policy_with_home(&WritableRoots::default(), Some(Path::new("/Users/tester")));
assert!(policy.contains("(subpath \"/Library/Developer/CommandLineTools\")"));
}
#[test]
fn build_policy_does_not_make_library_readable() {
let policy = build_policy_with_home(
&roots(&["/Users/tester/Library"]),
Some(Path::new("/Users/tester")),
);
assert!(policy.contains("(allow file-write* (subpath \"/Users/tester/Library\"))"));
assert!(
!policy.contains(
"(allow file-read* file-test-existence (subpath \"/Users/tester/Library\"))"
),
"home writable roots outside the allowlist must stay unreadable"
);
}
#[test]
fn build_policy_allows_explicit_read_roots_outside_home_allowlist() {
let extra_read_roots = vec![
"/Users/tester/.sid".to_string(),
"/Users/tester/.sid/agents/skill-inject".to_string(),
];
let policy = build_policy_with_home_and_read_roots(
&WritableRoots::default(),
&extra_read_roots,
Some(Path::new("/Users/tester")),
);
assert!(
policy.contains(
"(allow file-read* file-test-existence (subpath \"/Users/tester/.sid\"))"
)
);
assert!(policy.contains(
"(allow file-read* file-test-existence (subpath \"/Users/tester/.sid/agents/skill-inject\"))"
));
}
#[test]
fn shell_wrapper_returns_sandbox_exec_args() {
if !sandbox_available() {
return;
}
let wrapper = shell_wrapper(&roots(&["/workspace"])).expect("sandbox should be available");
assert_eq!(wrapper[0], SANDBOX_EXEC);
assert_eq!(wrapper[1], "-p");
assert!(wrapper[2].contains("(version 1)"));
assert_eq!(wrapper[3], "-D");
assert!(wrapper[4].starts_with("DARWIN_USER_CACHE_DIR="));
assert_eq!(wrapper[5], "-D");
assert!(wrapper[6].starts_with("DARWIN_USER_TEMP_DIR="));
assert_eq!(wrapper[7], "--");
}
#[test]
fn escape_sbpl_string_handles_plain_path() {
assert_eq!(escape_sbpl_string("/home/user/src"), "/home/user/src");
}
#[test]
fn escape_sbpl_string_escapes_backslash() {
assert_eq!(escape_sbpl_string(r"/path\to"), r"/path\\to");
}
#[test]
fn escape_sbpl_string_escapes_double_quote() {
assert_eq!(escape_sbpl_string(r#"/path "evil""#), r#"/path \"evil\""#);
}
#[test]
fn escape_sbpl_string_escapes_newline_and_tab() {
assert_eq!(escape_sbpl_string("/path\n/inject"), "/path\\n/inject");
assert_eq!(escape_sbpl_string("/path\t/tab"), "/path\\t/tab");
}
#[test]
fn escape_sbpl_string_drops_control_chars() {
assert_eq!(escape_sbpl_string("/path\x01\x7f/ctl"), "/path/ctl");
}
#[test]
fn build_policy_escapes_quote_in_writable_root() {
let policy = build_policy(&roots(&["/home/user/O'Brien\"s"]));
assert!(
policy.contains(r#"(subpath "/home/user/O'Brien\"s")"#),
"double-quote in path must be escaped: {policy}"
);
}
#[test]
fn build_policy_escapes_newline_in_writable_root() {
let policy = build_policy(&roots(&["/home/user/evil\n(deny default)"]));
assert!(
!policy.contains("\n(deny default)\")"),
"newline in path must not produce raw SBPL: {policy}"
);
assert!(
policy.contains(r#"(subpath "/home/user/evil\n(deny default)")"#),
"newline should be escaped to literal \\n: {policy}"
);
}
}