use super::policy::SandboxPolicy;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::OnceLock;
pub const SANDBOX_EXEC_PATH: &str = "/usr/bin/sandbox-exec";
const SEATBELT_BASE_POLICY: &str = r#"
(version 1)
(deny default)
; Core process operations
(allow process-exec)
(allow process-fork)
(allow signal (target same-sandbox))
(allow process-info* (target same-sandbox))
; User preferences (needed by many CLI tools)
(allow user-preference-read)
; Basic I/O to /dev/null
(allow file-write-data
(require-all
(path "/dev/null")
(vnode-type CHARACTER-DEVICE)))
; System information
(allow sysctl-read)
; IPC primitives
(allow ipc-posix-sem)
(allow ipc-posix-shm-read*)
(allow ipc-posix-shm-write-create)
(allow ipc-posix-shm-write-data)
(allow ipc-posix-shm-write-unlink)
; Terminal support (essential for shell commands)
(allow pseudo-tty)
(allow file-read* file-write* file-ioctl (literal "/dev/ptmx"))
(allow file-read* file-write* file-ioctl (regex #"^/dev/ttys[0-9]+$"))
; macOS-specific device access
(allow file-read* (literal "/dev/urandom"))
(allow file-read* (literal "/dev/random"))
(allow file-ioctl (literal "/dev/dtracehelper"))
; Mach IPC (needed by many system services)
(allow mach-lookup)
"#;
const SEATBELT_NETWORK_POLICY: &str = r"
; Network access
(allow network-outbound)
(allow network-inbound)
(allow system-socket)
(allow network-bind)
";
pub fn is_available() -> bool {
static SEATBELT_AVAILABLE: OnceLock<bool> = OnceLock::new();
*SEATBELT_AVAILABLE.get_or_init(|| {
if !Path::new(SANDBOX_EXEC_PATH).exists() {
return false;
}
let output = Command::new(SANDBOX_EXEC_PATH)
.args(["-p", "(version 1)(allow default)", "--", "/usr/bin/true"])
.output();
match output {
Ok(result) => result.status.success(),
Err(_) => false,
}
})
}
pub fn create_seatbelt_args(
command: Vec<String>,
policy: &SandboxPolicy,
sandbox_cwd: &Path,
) -> Vec<String> {
let full_policy = generate_policy(policy, sandbox_cwd);
let params = generate_params(policy, sandbox_cwd);
let mut args = vec!["-p".to_string(), full_policy];
for (key, value) in params {
args.push(format!("-D{}={}", key, value.to_string_lossy()));
}
args.push("--".to_string());
args.extend(command);
args
}
fn generate_policy(policy: &SandboxPolicy, cwd: &Path) -> String {
let mut full_policy = SEATBELT_BASE_POLICY.to_string();
if SandboxPolicy::has_full_disk_read_access() {
full_policy.push_str("\n; Full filesystem read access\n(allow file-read*)");
}
let file_write_policy = generate_write_policy(policy, cwd);
if !file_write_policy.is_empty() {
full_policy.push_str("\n\n; Write access policy\n");
full_policy.push_str(&file_write_policy);
}
if policy.has_network_access() {
full_policy.push('\n');
full_policy.push_str(SEATBELT_NETWORK_POLICY);
}
full_policy.push_str("\n\n; Darwin user cache directory\n");
full_policy
.push_str(r#"(allow file-read* file-write* (subpath (param "DARWIN_USER_CACHE_DIR")))"#);
full_policy.push_str("\n\n; Common macOS directories\n");
full_policy.push_str(r#"(allow file-read* (subpath "/usr/lib"))"#);
full_policy.push('\n');
full_policy.push_str(r#"(allow file-read* (subpath "/usr/share"))"#);
full_policy.push('\n');
full_policy.push_str(r#"(allow file-read* (subpath "/System/Library"))"#);
full_policy.push('\n');
full_policy.push_str(r#"(allow file-read* (subpath "/Library/Preferences"))"#);
full_policy.push('\n');
full_policy.push_str(r#"(allow file-read* (subpath "/private/var/db"))"#);
full_policy
}
fn generate_write_policy(policy: &SandboxPolicy, cwd: &Path) -> String {
if policy.has_full_disk_write_access() {
return r#"(allow file-write* (regex #"^/"))"#.to_string();
}
if matches!(policy, SandboxPolicy::ReadOnly) {
return String::new();
}
let writable_roots = policy.get_writable_roots(cwd);
if writable_roots.is_empty() {
return String::new();
}
let mut policies = Vec::new();
for (index, root) in writable_roots.iter().enumerate() {
let root_param = format!("WRITABLE_ROOT_{index}");
if root.read_only_subpaths.is_empty() {
policies.push(format!("(subpath (param \"{root_param}\"))"));
} else {
let mut parts = vec![format!("(subpath (param \"{}\"))", root_param)];
for (subpath_index, _) in root.read_only_subpaths.iter().enumerate() {
let ro_param = format!("WRITABLE_ROOT_{index}_RO_{subpath_index}");
parts.push(format!("(require-not (subpath (param \"{ro_param}\")))"));
}
policies.push(format!("(require-all {})", parts.join(" ")));
}
}
if policies.is_empty() {
return String::new();
}
format!("(allow file-write*\n {})", policies.join("\n "))
}
fn generate_params(policy: &SandboxPolicy, cwd: &Path) -> Vec<(String, PathBuf)> {
let mut params = Vec::new();
let writable_roots = policy.get_writable_roots(cwd);
for (index, root) in writable_roots.iter().enumerate() {
let canonical = root
.root
.canonicalize()
.unwrap_or_else(|_| root.root.clone());
params.push((format!("WRITABLE_ROOT_{index}"), canonical));
for (subpath_index, subpath) in root.read_only_subpaths.iter().enumerate() {
let canonical_subpath = subpath.canonicalize().unwrap_or_else(|_| subpath.clone());
params.push((
format!("WRITABLE_ROOT_{index}_RO_{subpath_index}"),
canonical_subpath,
));
}
}
if let Some(cache_dir) = get_darwin_user_cache_dir() {
params.push(("DARWIN_USER_CACHE_DIR".to_string(), cache_dir));
} else {
if let Ok(home) = std::env::var("HOME") {
params.push((
"DARWIN_USER_CACHE_DIR".to_string(),
PathBuf::from(format!("{home}/Library/Caches")),
));
}
}
params
}
fn get_darwin_user_cache_dir() -> Option<PathBuf> {
let mut buf = vec![0i8; (libc::PATH_MAX as usize) + 1];
let len =
unsafe { libc::confstr(libc::_CS_DARWIN_USER_CACHE_DIR, buf.as_mut_ptr(), buf.len()) };
if len == 0 {
return None;
}
let cstr = unsafe { std::ffi::CStr::from_ptr(buf.as_ptr()) };
let path_str = cstr.to_str().ok()?;
let path = PathBuf::from(path_str);
path.canonicalize().ok().or(Some(path))
}
pub fn detect_denial(exit_code: i32, stderr: &str) -> bool {
if exit_code == 0 {
return false;
}
let denial_patterns = [
"Operation not permitted",
"sandbox-exec",
"deny(",
"Sandbox: ",
];
denial_patterns.iter().any(|p| stderr.contains(p))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_is_available() {
let _ = is_available();
}
#[test]
fn test_generate_policy_default() {
let policy = SandboxPolicy::default();
let cwd = Path::new("/tmp/test");
let result = generate_policy(&policy, cwd);
assert!(result.contains("(version 1)"));
assert!(result.contains("(deny default)"));
assert!(result.contains("(allow file-read*)"));
assert!(result.contains("file-write*"));
assert!(!result.contains("network-outbound"));
}
#[test]
fn test_generate_policy_with_network() {
let policy = SandboxPolicy::workspace_with_network();
let cwd = Path::new("/tmp/test");
let result = generate_policy(&policy, cwd);
assert!(result.contains("network-outbound"));
assert!(result.contains("network-inbound"));
}
#[test]
fn test_generate_policy_read_only() {
let policy = SandboxPolicy::ReadOnly;
let cwd = Path::new("/tmp/test");
let result = generate_policy(&policy, cwd);
assert!(result.contains("(allow file-read*)"));
assert!(!result.contains("WRITABLE_ROOT"));
}
#[test]
fn test_generate_params() {
let policy = SandboxPolicy::default();
let cwd = Path::new("/tmp/test");
let params = generate_params(&policy, cwd);
assert!(params.iter().any(|(k, _)| k == "DARWIN_USER_CACHE_DIR"));
}
#[test]
fn test_create_seatbelt_args() {
let policy = SandboxPolicy::default();
let cwd = Path::new("/tmp/test");
let command = vec!["echo".to_string(), "hello".to_string()];
let args = create_seatbelt_args(command, &policy, cwd);
assert_eq!(args[0], "-p");
assert!(args[1].contains("(version 1)"));
assert!(args.contains(&"--".to_string()));
assert!(args.contains(&"echo".to_string()));
assert!(args.contains(&"hello".to_string()));
}
#[test]
fn test_detect_denial() {
assert!(detect_denial(1, "Operation not permitted"));
assert!(detect_denial(1, "Sandbox: ls denied file-write*"));
assert!(!detect_denial(0, "Operation not permitted"));
assert!(!detect_denial(1, "File not found"));
}
}