use crate::capability::{AccessMode, CapabilitySet, NetworkMode};
use crate::error::{NonoError, Result};
use crate::sandbox::SupportInfo;
use std::ffi::{CStr, CString};
use std::os::raw::c_char;
use std::path::Path;
use std::ptr;
use tracing::{debug, info};
extern "C" {
fn sandbox_init(profile: *const c_char, flags: u64, errorbuf: *mut *mut c_char) -> i32;
fn sandbox_free_error(errorbuf: *mut c_char);
}
extern "C" {
fn sandbox_extension_issue_file(
extension_class: *const c_char,
path: *const c_char,
flags: u32,
) -> *mut c_char;
fn sandbox_extension_consume(token: *const c_char) -> i64;
fn sandbox_extension_release(handle: i64) -> i32;
}
const EXT_CLASS_READ: &str = "com.apple.app-sandbox.read";
const EXT_CLASS_READ_WRITE: &str = "com.apple.app-sandbox.read-write";
pub fn extension_issue_file(path: &Path, access: AccessMode) -> Result<String> {
let class = match access {
AccessMode::Read => EXT_CLASS_READ,
AccessMode::Write | AccessMode::ReadWrite => EXT_CLASS_READ_WRITE,
};
let class_c = CString::new(class)
.map_err(|_| NonoError::SandboxInit("Extension class contains null byte".to_string()))?;
let path_str = path.to_str().ok_or_else(|| {
NonoError::SandboxInit(format!("Path contains non-UTF-8 bytes: {}", path.display()))
})?;
let path_c = CString::new(path_str).map_err(|_| {
NonoError::SandboxInit(format!("Path contains null byte: {}", path.display()))
})?;
let token_ptr = unsafe { sandbox_extension_issue_file(class_c.as_ptr(), path_c.as_ptr(), 0) };
if token_ptr.is_null() {
return Err(NonoError::SandboxInit(format!(
"sandbox_extension_issue_file failed for path: {}",
path.display()
)));
}
let token = unsafe { CStr::from_ptr(token_ptr) }
.to_string_lossy()
.into_owned();
unsafe { libc::free(token_ptr.cast::<libc::c_void>()) };
debug!(
"Issued extension token for {} ({:?})",
path.display(),
access
);
Ok(token)
}
pub fn extension_consume(token: &str) -> Result<i64> {
let token_c = CString::new(token)
.map_err(|_| NonoError::SandboxInit("Extension token contains null byte".to_string()))?;
let handle = unsafe { sandbox_extension_consume(token_c.as_ptr()) };
if handle < 0 {
return Err(NonoError::SandboxInit(format!(
"sandbox_extension_consume failed (handle={})",
handle
)));
}
debug!("Consumed extension token (handle={})", handle);
Ok(handle)
}
pub fn extension_release(handle: i64) -> Result<()> {
let result = unsafe { sandbox_extension_release(handle) };
if result != 0 {
return Err(NonoError::SandboxInit(format!(
"sandbox_extension_release failed for handle {}",
handle
)));
}
debug!("Released extension (handle={})", handle);
Ok(())
}
pub fn is_supported() -> bool {
true
}
pub fn support_info() -> SupportInfo {
SupportInfo {
is_supported: true,
platform: "macos",
details: "macOS Seatbelt sandbox available".to_string(),
}
}
fn collect_parent_dirs(caps: &CapabilitySet) -> std::collections::HashSet<String> {
let mut parents = std::collections::HashSet::new();
for cap in caps.fs_capabilities() {
let paths_to_walk: Vec<&std::path::Path> = if cap.original != cap.resolved {
vec![cap.resolved.as_path(), cap.original.as_path()]
} else {
vec![cap.resolved.as_path()]
};
for path in paths_to_walk {
let mut current = path.parent();
while let Some(parent) = current {
let parent_str = parent.to_string_lossy().to_string();
if parent_str == "/" || parent_str.is_empty() {
break;
}
if !parents.insert(parent_str) {
break;
}
current = parent.parent();
}
}
}
parents
}
fn path_filters_for_cap(cap: &crate::capability::FsCapability) -> Result<Vec<String>> {
let mut filters = Vec::with_capacity(2);
let resolved_str = cap.resolved.to_str().ok_or_else(|| {
NonoError::SandboxInit(format!(
"path contains non-UTF-8 bytes: {}",
cap.resolved.display()
))
})?;
let escaped_resolved = escape_path(resolved_str)?;
let kind = if cap.is_file { "literal" } else { "subpath" };
filters.push(format!("{} \"{}\"", kind, escaped_resolved));
if cap.original != cap.resolved {
if let Some(original_str) = cap.original.to_str() {
let escaped_original = escape_path(original_str)?;
filters.push(format!("{} \"{}\"", kind, escaped_original));
}
}
Ok(filters)
}
fn has_explicit_keychain_db_access(caps: &CapabilitySet) -> bool {
let user_keychain_dbs = std::env::var("HOME").ok().map(|home| {
[
Path::new(&home).join("Library/Keychains/login.keychain-db"),
Path::new(&home).join("Library/Keychains/metadata.keychain-db"),
]
});
let system_keychain_dbs = [
Path::new("/Library/Keychains/login.keychain-db").to_path_buf(),
Path::new("/Library/Keychains/metadata.keychain-db").to_path_buf(),
];
let is_keychain_db = |path: &Path| -> bool {
if system_keychain_dbs
.iter()
.any(|candidate| path == candidate)
{
return true;
}
if let Some(ref user_keychain_dbs) = user_keychain_dbs {
if user_keychain_dbs.iter().any(|candidate| path == candidate) {
return true;
}
}
false
};
caps.fs_capabilities()
.iter()
.any(|cap| is_keychain_db(&cap.original) || is_keychain_db(&cap.resolved))
}
fn escape_path(path: &str) -> Result<String> {
let mut result = String::with_capacity(path.len());
for c in path.chars() {
match c {
'\\' => result.push_str("\\\\"),
'"' => result.push_str("\\\""),
c if c.is_control() => {
return Err(NonoError::SandboxInit(format!(
"path contains control character 0x{:02X}: {}",
c as u32, path
)));
}
_ => result.push(c),
}
}
Ok(result)
}
fn regex_escape_path_for_seatbelt(path: &str) -> Result<String> {
for c in path.chars() {
if c.is_control() {
return Err(NonoError::SandboxInit(format!(
"path contains control character 0x{:02X}: {}",
c as u32, path
)));
}
}
let mut result = String::with_capacity(path.len() * 2);
for c in path.chars() {
match c {
'.' | '+' | '*' | '?' | '(' | ')' | '{' | '}' | '|' | '^' | '$' => {
result.push('[');
result.push(c);
result.push(']');
}
'[' | ']' => {
result.push('\\');
result.push('\\');
result.push(c);
}
'\\' => result.push_str("\\\\\\\\"),
'"' => result.push_str("\\\""),
_ => result.push(c),
}
}
Ok(result)
}
fn emit_unix_socket_rules(profile: &mut String, caps: &CapabilitySet) -> Result<()> {
for cap in caps.unix_socket_capabilities() {
let resolved_str = cap.resolved.to_str().ok_or_else(|| {
NonoError::SandboxInit(format!(
"unix socket path contains non-UTF-8 bytes: {}",
cap.resolved.display()
))
})?;
let original_str = if cap.original != cap.resolved {
cap.original.to_str()
} else {
None
};
let operations: &[&str] = if cap.mode.permits_bind() {
&["network-outbound", "network-bind"]
} else {
&["network-outbound"]
};
if cap.is_directory {
let escaped = regex_escape_path_for_seatbelt(resolved_str)?;
let escaped_orig = original_str
.map(regex_escape_path_for_seatbelt)
.transpose()?;
for op in operations {
profile.push_str(&format!("(allow {} (regex \"^{}/[^/]+$\"))\n", op, escaped));
if let Some(ref e) = escaped_orig {
profile.push_str(&format!("(allow {} (regex \"^{}/[^/]+$\"))\n", op, e));
}
}
} else {
let escaped = escape_path(resolved_str)?;
let escaped_orig = original_str.map(escape_path).transpose()?;
for op in operations {
profile.push_str(&format!("(allow {} (path \"{}\"))\n", op, escaped));
if let Some(ref e) = escaped_orig {
profile.push_str(&format!("(allow {} (path \"{}\"))\n", op, e));
}
}
}
}
Ok(())
}
fn generate_profile(caps: &CapabilitySet) -> Result<String> {
let mut profile = String::new();
profile.push_str("(version 1)\n");
profile.push_str("(deny default)\n");
if caps.seatbelt_debug_deny() {
profile.push_str("(debug deny)\n");
}
profile.push_str("(allow process-exec*)\n");
profile.push_str("(allow process-fork)\n");
match caps.process_info_mode() {
crate::capability::ProcessInfoMode::Isolated
| crate::capability::ProcessInfoMode::AllowSameSandbox => {
profile.push_str("(allow process-info* (target self))\n");
profile.push_str("(allow process-info* (target same-sandbox))\n");
}
crate::capability::ProcessInfoMode::AllowAll => {
profile.push_str("(allow process-info*)\n");
}
}
profile.push_str("(allow sysctl-read)\n");
profile.push_str("(allow mach-lookup)\n");
if !has_explicit_keychain_db_access(caps) {
profile.push_str("(deny mach-lookup (global-name \"com.apple.SecurityServer\"))\n");
profile.push_str("(deny mach-lookup (global-name \"com.apple.securityd\"))\n");
profile.push_str("(deny mach-lookup (global-name \"com.apple.security.keychaind\"))\n");
profile.push_str("(deny mach-lookup (global-name \"com.apple.secd\"))\n");
profile.push_str("(deny mach-lookup (global-name \"com.apple.security.agent\"))\n");
}
profile.push_str("(allow mach-per-user-lookup)\n");
profile.push_str("(allow mach-task-name)\n");
profile.push_str("(deny mach-priv*)\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-write-create)\n");
if caps.ipc_mode() == crate::capability::IpcMode::Full {
profile.push_str("(allow ipc-posix-sem*)\n");
}
match caps.signal_mode() {
crate::capability::SignalMode::Isolated
| crate::capability::SignalMode::AllowSameSandbox => {
profile.push_str("(allow signal (target self))\n");
profile.push_str("(allow signal (target same-sandbox))\n");
}
crate::capability::SignalMode::AllowAll => {
profile.push_str("(allow signal)\n");
}
}
profile.push_str("(allow system-fsctl)\n");
profile.push_str("(allow system-info)\n");
profile.push_str("(allow file-read* (literal \"/\"))\n");
let parent_dirs = collect_parent_dirs(caps);
for parent in &parent_dirs {
let escaped = escape_path(parent)?;
profile.push_str(&format!(
"(allow file-read-metadata (literal \"{}\"))\n",
escaped
));
}
for cap in caps.fs_capabilities() {
if matches!(cap.access, AccessMode::Read | AccessMode::ReadWrite) {
for filter in path_filters_for_cap(cap)? {
profile.push_str(&format!("(allow file-map-executable ({}))\n", filter));
}
}
}
profile.push_str("(allow file-ioctl (literal \"/dev/tty\"))\n");
profile.push_str("(allow file-ioctl (regex #\"^/dev/ttys[0-9]+$\"))\n");
profile.push_str("(allow file-ioctl (regex #\"^/dev/pty[a-z][0-9a-f]+$\"))\n");
for cap in caps.fs_capabilities() {
for filter in path_filters_for_cap(cap)? {
profile.push_str(&format!("(allow file-ioctl ({}))\n", filter));
}
}
profile.push_str("(allow pseudo-tty)\n");
for cap in caps.fs_capabilities() {
match cap.access {
AccessMode::Read | AccessMode::ReadWrite => {
for filter in path_filters_for_cap(cap)? {
profile.push_str(&format!("(allow file-read* ({}))\n", filter));
}
}
AccessMode::Write => {
}
}
}
if caps.extensions_enabled() {
profile.push_str("(allow file-read* (extension \"com.apple.app-sandbox.read\"))\n");
profile.push_str("(allow file-read* (extension \"com.apple.app-sandbox.read-write\"))\n");
profile.push_str("(allow file-write* (extension \"com.apple.app-sandbox.read-write\"))\n");
}
for rule in caps.platform_rules() {
profile.push_str(rule);
profile.push('\n');
}
for cap in caps.fs_capabilities() {
match cap.access {
AccessMode::Write | AccessMode::ReadWrite => {
for filter in path_filters_for_cap(cap)? {
profile.push_str(&format!("(allow file-write* ({}))\n", filter));
}
}
AccessMode::Read => {
}
}
}
const MDNS_RULES: &str = "\
(allow system-socket (socket-domain AF_UNIX) (socket-type SOCK_STREAM))\n\
(allow network-outbound (path \"/private/var/run/mDNSResponder\"))\n\
(allow network-outbound (path \"/var/run/mDNSResponder\"))\n";
let localhost_ports = caps.localhost_ports();
match caps.network_mode() {
NetworkMode::Blocked => {
profile.push_str("(deny network*)\n");
profile.push_str(MDNS_RULES);
emit_unix_socket_rules(&mut profile, caps)?;
if !localhost_ports.is_empty() {
profile.push_str(
"(allow system-socket (socket-domain AF_INET) (socket-type SOCK_STREAM))\n",
);
profile.push_str(
"(allow system-socket (socket-domain AF_INET6) (socket-type SOCK_STREAM))\n",
);
for lp in localhost_ports {
profile.push_str(&format!(
"(allow network-outbound (remote tcp \"localhost:{}\"))\n",
lp
));
}
profile.push_str("(allow network-bind)\n");
profile.push_str("(allow network-inbound)\n");
}
}
NetworkMode::ProxyOnly { port, bind_ports } => {
profile.push_str("(deny network*)\n");
profile.push_str(MDNS_RULES);
emit_unix_socket_rules(&mut profile, caps)?;
profile.push_str(&format!(
"(allow network-outbound (remote tcp \"localhost:{}\"))\n",
port
));
for lp in localhost_ports {
profile.push_str(&format!(
"(allow network-outbound (remote tcp \"localhost:{}\"))\n",
lp
));
}
profile.push_str(
"(allow system-socket (socket-domain AF_INET) (socket-type SOCK_STREAM))\n",
);
profile.push_str(
"(allow system-socket (socket-domain AF_INET6) (socket-type SOCK_STREAM))\n",
);
if !bind_ports.is_empty() || !localhost_ports.is_empty() {
profile.push_str("(allow network-bind)\n");
profile.push_str("(allow network-inbound)\n");
}
}
NetworkMode::AllowAll => {
profile.push_str("(allow system-socket)\n");
profile.push_str("(allow network-outbound)\n");
profile.push_str("(allow network-inbound)\n");
profile.push_str("(allow network-bind)\n");
}
}
if !caps.tcp_connect_ports().is_empty() || !caps.tcp_bind_ports().is_empty() {
return Err(NonoError::NetworkFilterUnsupported {
platform: "macOS".to_string(),
reason: "Seatbelt cannot filter by TCP port. Use --allow-domain for host-level \
filtering (routed through the proxy) or ProxyOnly mode instead."
.to_string(),
});
}
Ok(profile)
}
pub fn apply(caps: &CapabilitySet) -> Result<()> {
let profile = generate_profile(caps)?;
debug!("Generated Seatbelt profile:\n{}", profile);
let profile_cstr = CString::new(profile)
.map_err(|e| NonoError::SandboxInit(format!("Invalid profile string: {}", e)))?;
let mut error_buf: *mut c_char = ptr::null_mut();
let result = unsafe {
sandbox_init(
profile_cstr.as_ptr(),
0, &mut error_buf,
)
};
if result != 0 {
let error_msg = if !error_buf.is_null() {
let msg = unsafe {
std::ffi::CStr::from_ptr(error_buf)
.to_string_lossy()
.into_owned()
};
unsafe { sandbox_free_error(error_buf) };
msg
} else {
format!("sandbox_init returned error code {}", result)
};
return Err(NonoError::SandboxInit(error_msg));
}
info!("Seatbelt sandbox applied successfully");
Ok(())
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use crate::capability::{CapabilitySource, FsCapability};
use std::path::PathBuf;
#[test]
fn test_generate_profile_empty() {
let caps = CapabilitySet::default();
let profile = generate_profile(&caps).unwrap();
assert!(profile.contains("(version 1)"));
assert!(profile.contains("(deny default)"));
assert!(profile.contains("(allow network-outbound)"));
}
#[test]
fn test_generate_profile_with_dir() {
let mut caps = CapabilitySet::new();
caps.add_fs(FsCapability {
original: PathBuf::from("/test"),
resolved: PathBuf::from("/test"),
access: AccessMode::ReadWrite,
is_file: false,
source: CapabilitySource::User,
});
let profile = generate_profile(&caps).unwrap();
assert!(profile.contains("(allow file-read* (subpath \"/test\"))"));
assert!(profile.contains("(allow file-write* (subpath \"/test\"))"));
assert!(profile.contains("(allow file-map-executable (subpath \"/test\"))"));
}
#[test]
fn test_generate_profile_with_file() {
let mut caps = CapabilitySet::new();
caps.add_fs(FsCapability {
original: PathBuf::from("/test.txt"),
resolved: PathBuf::from("/test.txt"),
access: AccessMode::Write,
is_file: true,
source: CapabilitySource::User,
});
let profile = generate_profile(&caps).unwrap();
assert!(profile.contains("file-write*"));
assert!(profile.contains("literal \"/test.txt\""));
assert!(!profile.contains("file-map-executable"));
}
#[test]
fn test_generate_profile_no_global_file_map_executable() {
let caps = CapabilitySet::default();
let profile = generate_profile(&caps).unwrap();
assert!(!profile.contains("(allow file-map-executable)\n"));
}
#[test]
fn test_generate_profile_network_blocked() {
let caps = CapabilitySet::new().block_network();
let profile = generate_profile(&caps).unwrap();
assert!(profile.contains("(deny network*)"));
assert!(!profile.contains("(allow network-outbound)\n"));
}
#[test]
fn test_support_info() {
let info = support_info();
assert!(info.is_supported);
assert_eq!(info.platform, "macos");
}
#[test]
fn test_collect_parent_dirs() {
let mut caps = CapabilitySet::new();
caps.add_fs(FsCapability {
original: PathBuf::from("/Users/test/.claude"),
resolved: PathBuf::from("/Users/test/.claude"),
access: AccessMode::ReadWrite,
is_file: false,
source: CapabilitySource::User,
});
let parents = collect_parent_dirs(&caps);
assert!(parents.contains("/Users"));
assert!(parents.contains("/Users/test"));
assert!(!parents.contains("/"));
}
#[test]
fn test_escape_path() {
assert_eq!(escape_path("/simple/path").unwrap(), "/simple/path");
assert_eq!(
escape_path("/path with\\slash").unwrap(),
"/path with\\\\slash"
);
assert_eq!(escape_path("/path\"quoted").unwrap(), "/path\\\"quoted");
}
#[test]
fn test_escape_path_rejects_control_characters() {
assert!(escape_path("/path\0with\0nulls").is_err());
assert!(escape_path("/path\nwith\nnewlines").is_err());
assert!(escape_path("/path\rwith\rreturns").is_err());
assert!(escape_path("/path\twith\ttabs").is_err());
assert!(escape_path("/path\x0bwith\x0cfeeds").is_err());
assert!(escape_path("/path\x1bwith\x1bescape").is_err());
assert!(escape_path("/path\x7fwith\x7fdel").is_err());
}
#[test]
fn test_generate_profile_with_platform_rules() {
let mut caps = CapabilitySet::new();
caps.add_platform_rule("(deny file-read-data (subpath \"/private/var/db\"))")
.unwrap();
caps.add_platform_rule("(deny file-write-unlink)").unwrap();
let profile = generate_profile(&caps).unwrap();
assert!(profile.contains("(deny file-read-data (subpath \"/private/var/db\"))"));
assert!(profile.contains("(deny file-write-unlink)"));
let platform_pos = profile
.find("(deny file-write-unlink)")
.expect("platform rule not found");
let network_pos = profile
.find("(allow network-outbound)")
.expect("network rule not found");
assert!(
platform_pos < network_pos,
"platform rules must appear before network rules"
);
}
#[test]
fn test_generate_profile_platform_rules_between_reads_and_writes() {
let mut caps = CapabilitySet::new();
caps.add_fs(FsCapability {
original: PathBuf::from("/test"),
resolved: PathBuf::from("/test"),
access: AccessMode::ReadWrite,
is_file: false,
source: CapabilitySource::User,
});
caps.add_platform_rule("(deny file-write-unlink)").unwrap();
let profile = generate_profile(&caps).unwrap();
let read_pos = profile
.find("(allow file-read* (subpath \"/test\"))")
.expect("read rule not found");
let deny_pos = profile
.find("(deny file-write-unlink)")
.expect("deny rule not found");
let write_pos = profile
.find("(allow file-write* (subpath \"/test\"))")
.expect("write rule not found");
assert!(
read_pos < deny_pos,
"read rules must come before platform deny rules"
);
assert!(
deny_pos < write_pos,
"platform deny rules must come before write rules"
);
}
#[test]
fn test_generate_profile_platform_rules_empty() {
let caps = CapabilitySet::new();
let profile = generate_profile(&caps).unwrap();
assert!(profile.contains("(version 1)"));
assert!(profile.contains("(deny default)"));
}
#[test]
fn test_generate_profile_with_gpu_iokit_rules() {
let mut caps = CapabilitySet::new();
caps.add_platform_rule(
"(allow iokit-open \
(iokit-user-client-class \
\"AGXDeviceUserClient\"))",
)
.unwrap();
caps.add_platform_rule("(allow iokit-get-properties)")
.unwrap();
let profile = generate_profile(&caps).unwrap();
assert!(profile.contains("(allow iokit-open"));
assert!(profile.contains("AGXDeviceUserClient"));
assert!(!profile.contains("IOGPU"));
assert!(!profile.contains("AGXSharedUserClient"));
assert!(!profile.contains("IOSurfaceRootUserClient"));
assert!(profile.contains("(allow iokit-get-properties)"));
}
#[test]
fn test_generate_profile_gpu_rules_ordering() {
let mut caps = CapabilitySet::new();
caps.add_fs(FsCapability {
original: PathBuf::from("/test"),
resolved: PathBuf::from("/test"),
access: AccessMode::ReadWrite,
is_file: false,
source: CapabilitySource::User,
});
caps.add_platform_rule("(allow iokit-get-properties)")
.unwrap();
let profile = generate_profile(&caps).unwrap();
let read_pos = profile
.find("(allow file-read* (subpath \"/test\"))")
.expect("read rule not found");
let iokit_pos = profile
.find("(allow iokit-get-properties)")
.expect("iokit rule not found");
let write_pos = profile
.find("(allow file-write* (subpath \"/test\"))")
.expect("write rule not found");
assert!(
read_pos < iokit_pos,
"read rules must come before GPU/IOKit platform rules"
);
assert!(
iokit_pos < write_pos,
"GPU/IOKit platform rules must come before write rules"
);
}
#[test]
fn test_escape_path_injection_via_newline() {
let malicious = "/tmp/evil\n(allow file-read* (subpath \"/\"))";
assert!(escape_path(malicious).is_err());
}
#[test]
fn test_escape_path_injection_via_quote() {
let malicious = "/tmp/evil\")(allow file-read* (subpath \"/\"))(\"";
let escaped = escape_path(malicious).unwrap();
let chars: Vec<char> = escaped.chars().collect();
for (i, &c) in chars.iter().enumerate() {
if c == '"' {
assert!(
i > 0 && chars[i - 1] == '\\',
"unescaped quote at position {}",
i
);
}
}
}
#[test]
fn test_generate_profile_rejects_malicious_path() {
let mut caps = CapabilitySet::new();
caps.add_fs(FsCapability {
original: PathBuf::from("/tmp/evil"),
resolved: PathBuf::from("/tmp/evil\n(allow file-read* (subpath \"/\"))"),
access: AccessMode::Read,
is_file: false,
source: CapabilitySource::User,
});
assert!(
generate_profile(&caps).is_err(),
"paths with control characters must be rejected"
);
}
#[test]
fn test_capability_source_tagging() {
let mut caps = CapabilitySet::new();
caps.add_fs(FsCapability {
original: PathBuf::from("/usr"),
resolved: PathBuf::from("/usr"),
access: AccessMode::Read,
is_file: false,
source: CapabilitySource::Group("system_read_macos".to_string()),
});
let profile = generate_profile(&caps).unwrap();
assert!(profile.contains("(allow file-read* (subpath \"/usr\"))"));
}
#[test]
fn test_generate_profile_extensions_disabled_by_default() {
let caps = CapabilitySet::default();
let profile = generate_profile(&caps).unwrap();
assert!(!profile.contains("extension"));
}
#[test]
fn test_generate_profile_extensions_enabled() {
let caps = CapabilitySet::new().enable_extensions();
let profile = generate_profile(&caps).unwrap();
assert!(profile.contains("(allow file-read* (extension \"com.apple.app-sandbox.read\"))"));
assert!(
profile.contains("(allow file-read* (extension \"com.apple.app-sandbox.read-write\"))")
);
assert!(profile
.contains("(allow file-write* (extension \"com.apple.app-sandbox.read-write\"))"));
}
#[test]
fn test_generate_profile_extensions_before_platform_deny_rules() {
let mut caps = CapabilitySet::new().enable_extensions();
caps.add_platform_rule("(deny file-write-unlink)").unwrap();
let profile = generate_profile(&caps).unwrap();
let ext_pos = profile
.find("(allow file-read* (extension \"com.apple.app-sandbox.read\"))")
.expect("extension rule not found");
let deny_pos = profile
.find("(deny file-write-unlink)")
.expect("deny rule not found");
assert!(
ext_pos < deny_pos,
"extension rules must appear before platform deny rules"
);
}
#[test]
fn test_generate_profile_denies_keychain_mach_by_default() {
let caps = CapabilitySet::new();
let profile = generate_profile(&caps).unwrap();
assert!(profile.contains("(deny mach-lookup (global-name \"com.apple.SecurityServer\"))"));
assert!(profile.contains("(deny mach-lookup (global-name \"com.apple.securityd\"))"));
assert!(
profile.contains("(deny mach-lookup (global-name \"com.apple.security.keychaind\"))")
);
assert!(profile.contains("(deny mach-lookup (global-name \"com.apple.secd\"))"));
assert!(profile.contains("(deny mach-lookup (global-name \"com.apple.security.agent\"))"));
}
#[test]
fn test_generate_profile_skips_keychain_mach_deny_when_explicitly_granted() {
let mut caps = CapabilitySet::new();
let home = std::env::var("HOME").unwrap_or_else(|_| "/Users/test".to_string());
let keychain = PathBuf::from(home).join("Library/Keychains/login.keychain-db");
caps.add_fs(FsCapability {
original: keychain.clone(),
resolved: keychain,
access: AccessMode::Read,
is_file: true,
source: CapabilitySource::Profile,
});
let profile = generate_profile(&caps).unwrap();
assert!(!profile.contains("(deny mach-lookup (global-name \"com.apple.SecurityServer\"))"));
assert!(!profile.contains("(deny mach-lookup (global-name \"com.apple.securityd\"))"));
assert!(
!profile.contains("(deny mach-lookup (global-name \"com.apple.security.keychaind\"))")
);
assert!(!profile.contains("(deny mach-lookup (global-name \"com.apple.secd\"))"));
assert!(!profile.contains("(deny mach-lookup (global-name \"com.apple.security.agent\"))"));
}
#[test]
fn test_generate_profile_skips_keychain_mach_deny_for_metadata_keychain_db() {
let mut caps = CapabilitySet::new();
let home = std::env::var("HOME").unwrap_or_else(|_| "/Users/test".to_string());
let metadata_keychain_db =
PathBuf::from(home).join("Library/Keychains/metadata.keychain-db");
caps.add_fs(FsCapability {
original: metadata_keychain_db.clone(),
resolved: metadata_keychain_db,
access: AccessMode::Read,
is_file: true,
source: CapabilitySource::Profile,
});
let profile = generate_profile(&caps).unwrap();
assert!(!profile.contains("(deny mach-lookup (global-name \"com.apple.SecurityServer\"))"));
assert!(!profile.contains("(deny mach-lookup (global-name \"com.apple.securityd\"))"));
assert!(
!profile.contains("(deny mach-lookup (global-name \"com.apple.security.keychaind\"))")
);
assert!(!profile.contains("(deny mach-lookup (global-name \"com.apple.secd\"))"));
assert!(!profile.contains("(deny mach-lookup (global-name \"com.apple.security.agent\"))"));
}
#[test]
fn test_generate_profile_proxy_only_mode() {
let caps = CapabilitySet::new().proxy_only(54321);
let profile = generate_profile(&caps).unwrap();
assert!(profile.contains("(deny network*)"));
assert!(profile.contains("(allow network-outbound (remote tcp \"localhost:54321\"))"));
assert!(profile.contains("(allow system-socket"));
assert!(
profile.contains("(allow network-outbound (path \"/private/var/run/mDNSResponder\"))")
);
assert!(profile.contains("(allow network-outbound (path \"/var/run/mDNSResponder\"))"));
assert!(profile
.contains("(allow system-socket (socket-domain AF_UNIX) (socket-type SOCK_STREAM))"));
assert!(!profile.contains("(allow network-outbound)\n"));
assert!(!profile.contains("(allow network-bind)"));
assert!(!profile.contains("(allow network-inbound)"));
}
#[test]
fn test_generate_profile_proxy_only_with_bind_ports() {
let caps = CapabilitySet::new().proxy_only_with_bind(54321, vec![18789, 3000]);
let profile = generate_profile(&caps).unwrap();
assert!(profile.contains("(deny network*)"));
assert!(profile.contains("(allow network-outbound (remote tcp \"localhost:54321\"))"));
assert!(profile.contains("(allow system-socket"));
assert!(
profile.contains("(allow network-outbound (path \"/private/var/run/mDNSResponder\"))")
);
assert!(profile.contains("(allow network-outbound (path \"/var/run/mDNSResponder\"))"));
assert!(profile.contains("(allow network-bind)"));
assert!(profile.contains("(allow network-inbound)"));
assert!(!profile.contains("(allow network-outbound)\n"));
}
#[test]
fn test_generate_profile_allow_all_network() {
let caps = CapabilitySet::new();
let profile = generate_profile(&caps).unwrap();
assert!(profile.contains("(allow network-outbound)"));
assert!(profile.contains("(allow network-inbound)"));
assert!(profile.contains("(allow network-bind)"));
assert!(!profile.contains("(deny network*)"));
}
#[test]
fn test_generate_profile_unix_socket_allowed_in_proxy_only_mode() {
let mut caps = CapabilitySet::new().proxy_only(54321);
caps.add_unix_socket(crate::UnixSocketCapability {
original: PathBuf::from("/tmp/test.sock"),
resolved: PathBuf::from("/private/tmp/test.sock"),
is_directory: false,
mode: crate::UnixSocketMode::Connect,
source: CapabilitySource::User,
});
let profile = generate_profile(&caps).unwrap();
assert!(profile.contains("(deny network*)"));
assert!(profile.contains("(allow network-outbound (remote tcp \"localhost:54321\"))"));
assert!(
profile.contains("(allow network-outbound (path \"/private/tmp/test.sock\"))"),
"must allow network-outbound to resolved socket path"
);
assert!(
profile.contains("(allow network-outbound (path \"/tmp/test.sock\"))"),
"must allow network-outbound to original (symlink) socket path"
);
}
#[test]
fn test_generate_profile_unix_socket_allowed_in_blocked_mode() {
let mut caps = CapabilitySet::new().block_network();
caps.add_unix_socket(crate::UnixSocketCapability {
original: PathBuf::from("/var/run/app.sock"),
resolved: PathBuf::from("/private/var/run/app.sock"),
is_directory: false,
mode: crate::UnixSocketMode::ConnectBind,
source: CapabilitySource::User,
});
let profile = generate_profile(&caps).unwrap();
assert!(profile.contains("(deny network*)"));
assert!(
profile.contains("(allow network-outbound (path \"/private/var/run/app.sock\"))"),
"must allow network-outbound to resolved socket path"
);
assert!(
profile.contains("(allow network-outbound (path \"/var/run/app.sock\"))"),
"must allow network-outbound to original socket path"
);
assert!(
profile.contains("(allow network-bind (path \"/private/var/run/app.sock\"))"),
"ConnectBind must also allow network-bind on resolved path"
);
assert!(
profile.contains("(allow network-bind (path \"/var/run/app.sock\"))"),
"ConnectBind must also allow network-bind on original path"
);
assert!(!profile.contains("(allow network-outbound)\n"));
}
#[test]
fn test_generate_profile_unix_socket_connect_only_does_not_emit_bind() {
let mut caps = CapabilitySet::new().block_network();
caps.add_unix_socket(crate::UnixSocketCapability {
original: PathBuf::from("/var/run/client.sock"),
resolved: PathBuf::from("/private/var/run/client.sock"),
is_directory: false,
mode: crate::UnixSocketMode::Connect,
source: CapabilitySource::User,
});
let profile = generate_profile(&caps).unwrap();
assert!(
profile.contains("(allow network-outbound (path \"/private/var/run/client.sock\"))"),
"Connect mode must emit network-outbound"
);
assert!(
!profile.contains("(allow network-bind"),
"Connect-only mode must NOT emit any network-bind rule: {profile}"
);
}
#[test]
fn test_generate_profile_unix_socket_dir_emits_non_recursive_regex() {
let mut caps = CapabilitySet::new().proxy_only(54321);
caps.add_unix_socket(crate::UnixSocketCapability {
original: PathBuf::from("/tmp/mydir"),
resolved: PathBuf::from("/private/tmp/mydir"),
is_directory: true,
mode: crate::UnixSocketMode::ConnectBind,
source: CapabilitySource::User,
});
let profile = generate_profile(&caps).unwrap();
assert!(
profile.contains("(allow network-outbound (regex \"^/private/tmp/mydir/[^/]+$\"))"),
"directory unix socket grants must emit non-recursive regex: {profile}"
);
assert!(
profile.contains("(allow network-outbound (regex \"^/tmp/mydir/[^/]+$\"))"),
"symlinked original must also emit regex form"
);
assert!(
profile.contains("(allow network-bind (regex \"^/private/tmp/mydir/[^/]+$\"))"),
"ConnectBind directory grant must emit network-bind regex"
);
assert!(
!profile.contains("(allow network-outbound (subpath"),
"directory unix socket grants must NOT use recursive subpath"
);
}
#[test]
fn test_generate_profile_fs_capability_does_not_grant_network_outbound() {
let mut caps = CapabilitySet::new().block_network();
caps.add_fs(FsCapability {
original: PathBuf::from("/var/run"),
resolved: PathBuf::from("/private/var/run"),
access: AccessMode::ReadWrite,
is_file: false,
source: CapabilitySource::User,
});
let profile = generate_profile(&caps).unwrap();
assert!(profile.contains("(deny network*)"));
assert!(
!profile.contains("(allow network-outbound (subpath \"/private/var/run\"))"),
"FsCapability must not implicitly grant network-outbound on its subpath"
);
assert!(
!profile.contains("(allow network-outbound (path \"/var/run\"))"),
"FsCapability must not implicitly grant network-outbound on its path"
);
assert!(
!profile.contains("(allow network-bind"),
"FsCapability must not implicitly grant any network-bind rule"
);
}
#[test]
fn test_regex_escape_metacharacters_wrapped_in_character_class() {
let escaped = regex_escape_path_for_seatbelt("/tmp/foo.bar-1000").unwrap();
assert_eq!(escaped, "/tmp/foo[.]bar-1000");
}
#[test]
fn test_regex_escape_handles_bracket_and_backslash_via_backslash_escape() {
assert_eq!(
regex_escape_path_for_seatbelt("/tmp/foo[bar").unwrap(),
"/tmp/foo\\\\[bar"
);
assert_eq!(
regex_escape_path_for_seatbelt("/tmp/foo]bar").unwrap(),
"/tmp/foo\\\\]bar"
);
assert_eq!(
regex_escape_path_for_seatbelt("/tmp/foo\\bar").unwrap(),
"/tmp/foo\\\\\\\\bar"
);
}
#[test]
fn test_regex_escape_rejects_control_characters() {
assert!(regex_escape_path_for_seatbelt("/tmp/foo\x00bar").is_err());
assert!(regex_escape_path_for_seatbelt("/tmp/foo\nbar").is_err());
}
#[test]
fn test_generate_profile_unix_socket_rules_not_emitted_in_allow_all() {
let mut caps = CapabilitySet::new(); caps.add_unix_socket(crate::UnixSocketCapability {
original: PathBuf::from("/tmp/test.sock"),
resolved: PathBuf::from("/private/tmp/test.sock"),
is_directory: false,
mode: crate::UnixSocketMode::Connect,
source: CapabilitySource::User,
});
let profile = generate_profile(&caps).unwrap();
assert!(
profile.contains("(allow network-outbound)\n"),
"AllowAll must still emit blanket rule"
);
assert!(
!profile.contains("(allow network-outbound (path \"/private/tmp/test.sock\"))"),
"AllowAll must not emit per-path socket rules"
);
}
#[test]
fn test_generate_profile_rejects_per_port_rules() {
let caps = CapabilitySet::new().allow_tcp_connect(443);
let result = generate_profile(&caps);
assert!(result.is_err());
let err = result.err().unwrap();
assert!(
err.to_string().contains("macOS"),
"error should mention macOS: {}",
err
);
}
#[test]
fn test_generate_profile_rejects_per_port_bind_rules() {
let caps = CapabilitySet::new().allow_tcp_bind(8080);
let result = generate_profile(&caps);
assert!(result.is_err());
}
#[test]
fn test_generate_profile_signal_isolated_allows_same_sandbox() {
let caps = CapabilitySet::new(); let profile = generate_profile(&caps).unwrap();
assert!(profile.contains("(allow signal (target self))"));
assert!(profile.contains("(allow signal (target same-sandbox))"));
assert!(!profile.contains("(allow signal)\n"));
}
#[test]
fn test_generate_profile_signal_allow_same_sandbox() {
use crate::capability::SignalMode;
let caps = CapabilitySet::new().set_signal_mode(SignalMode::AllowSameSandbox);
let profile = generate_profile(&caps).unwrap();
assert!(profile.contains("(allow signal (target self))"));
assert!(profile.contains("(allow signal (target same-sandbox))"));
assert!(!profile.contains("(allow signal)\n"));
}
#[test]
fn test_generate_profile_process_info_isolated() {
let caps = CapabilitySet::new(); let profile = generate_profile(&caps).unwrap();
assert!(profile.contains("(allow process-info* (target self))"));
assert!(profile.contains("(allow process-info* (target same-sandbox))"));
assert!(!profile.contains("(deny process-info* (target others))"));
}
#[test]
fn test_generate_profile_process_info_allow_same_sandbox() {
use crate::capability::ProcessInfoMode;
let caps = CapabilitySet::new().set_process_info_mode(ProcessInfoMode::AllowSameSandbox);
let profile = generate_profile(&caps).unwrap();
assert!(profile.contains("(allow process-info* (target self))"));
assert!(profile.contains("(allow process-info* (target same-sandbox))"));
assert!(!profile.contains("(deny process-info* (target others))"));
}
#[test]
fn test_generate_profile_process_info_allow_all() {
use crate::capability::ProcessInfoMode;
let caps = CapabilitySet::new().set_process_info_mode(ProcessInfoMode::AllowAll);
let profile = generate_profile(&caps).unwrap();
assert!(profile.contains("(allow process-info*)\n"));
assert!(!profile.contains("(allow process-info* (target self))"));
assert!(!profile.contains("(deny process-info* (target others))"));
}
#[test]
fn test_generate_profile_ipc_shared_memory_only_no_semaphores() {
let caps = CapabilitySet::new(); let profile = generate_profile(&caps).unwrap();
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-write-create)"));
assert!(!profile.contains("ipc-posix-sem"));
}
#[test]
fn test_generate_profile_ipc_full_includes_semaphores() {
use crate::capability::IpcMode;
let caps = CapabilitySet::new().set_ipc_mode(IpcMode::Full);
let profile = generate_profile(&caps).unwrap();
assert!(profile.contains("(allow ipc-posix-shm-read-data)"));
assert!(profile.contains("(allow ipc-posix-sem*)"));
}
#[test]
fn test_generate_profile_blocked_with_localhost_ports() {
let caps = CapabilitySet::new()
.block_network()
.allow_localhost_port(3000)
.allow_localhost_port(5000);
let profile = generate_profile(&caps).unwrap();
assert!(profile.contains("(deny network*)"));
assert!(profile.contains("(allow network-outbound (remote tcp \"localhost:3000\"))"));
assert!(profile.contains("(allow network-outbound (remote tcp \"localhost:5000\"))"));
assert!(profile.contains("(allow network-bind)"));
assert!(profile.contains("(allow network-inbound)"));
assert!(profile.contains("(allow system-socket"));
assert!(
profile.contains("(allow network-outbound (path \"/private/var/run/mDNSResponder\"))")
);
assert!(profile.contains("(allow network-outbound (path \"/var/run/mDNSResponder\"))"));
assert!(profile
.contains("(allow system-socket (socket-domain AF_UNIX) (socket-type SOCK_STREAM))"));
}
#[test]
fn test_generate_profile_proxy_with_localhost_ports() {
let caps = CapabilitySet::new()
.proxy_only(54321)
.allow_localhost_port(3000);
let profile = generate_profile(&caps).unwrap();
assert!(profile.contains("(deny network*)"));
assert!(profile.contains("(allow network-outbound (remote tcp \"localhost:54321\"))"));
assert!(profile.contains("(allow network-outbound (remote tcp \"localhost:3000\"))"));
assert!(profile.contains("(allow network-bind)"));
assert!(profile.contains("(allow network-inbound)"));
}
#[test]
fn test_generate_profile_allow_all_with_localhost_ports() {
let caps = CapabilitySet::new().allow_localhost_port(3000);
let profile = generate_profile(&caps).unwrap();
assert!(profile.contains("(allow network-outbound)"));
assert!(profile.contains("(allow network-inbound)"));
assert!(profile.contains("(allow network-bind)"));
assert!(!profile.contains("(deny network*)"));
}
#[test]
fn test_generate_profile_dns_allowed_in_proxy_mode() {
let caps = CapabilitySet::new().proxy_only(12345);
let profile = generate_profile(&caps).unwrap();
assert!(
profile.contains("(allow network-outbound (path \"/private/var/run/mDNSResponder\"))"),
"must allow mDNSResponder at canonical path"
);
assert!(
profile.contains("(allow network-outbound (path \"/var/run/mDNSResponder\"))"),
"must allow mDNSResponder at symlink path"
);
assert!(
profile.contains(
"(allow system-socket (socket-domain AF_UNIX) (socket-type SOCK_STREAM))"
),
"must allow AF_UNIX SOCK_STREAM for mDNSResponder"
);
}
#[test]
fn test_generate_profile_dns_allowed_in_blocked_mode() {
let caps = CapabilitySet::new().block_network();
let profile = generate_profile(&caps).unwrap();
assert!(profile.contains("(deny network*)"));
assert!(
profile.contains("(allow network-outbound (path \"/private/var/run/mDNSResponder\"))"),
"blocked mode must allow mDNSResponder at canonical path"
);
assert!(
profile.contains("(allow network-outbound (path \"/var/run/mDNSResponder\"))"),
"blocked mode must allow mDNSResponder at symlink path"
);
assert!(
profile.contains(
"(allow system-socket (socket-domain AF_UNIX) (socket-type SOCK_STREAM))"
),
"blocked mode must allow AF_UNIX SOCK_STREAM for mDNSResponder"
);
}
#[test]
fn test_generate_profile_dns_not_needed_in_allow_all() {
let caps = CapabilitySet::new();
let profile = generate_profile(&caps).unwrap();
assert!(!profile.contains("(deny network*)"));
assert!(!profile.contains("mDNSResponder"));
}
}