| 1 | //! macOS Seatbelt (sandbox-exec) profile generation. |
| 2 | //! |
| 3 | //! Seatbelt is Apple's mandatory access control framework that uses the |
| 4 | //! Scheme-based policy language to define what system resources a process |
| 5 | //! can access. This module generates sandbox profiles dynamically based |
| 6 | //! on the configured `SandboxPolicy`. |
| 7 | //! |
| 8 | //! # How it works |
| 9 | //! |
| 10 | //! 1. We generate a Seatbelt policy string in the SBPL format |
| 11 | //! 2. We invoke `/usr/bin/sandbox-exec -p <policy>` to run the command |
| 12 | //! 3. The kernel enforces the policy, blocking unauthorized operations |
| 13 | //! |
| 14 | //! # References |
| 15 | //! |
| 16 | //! - Apple's sandbox(7) man page |
| 17 | //! - <https://reverse.put.as/wp-content/uploads/2011/09/Apple-Sandbox-Guide-v1.0.pdf> |
| 18 | |
| 19 | // Note: cfg(target_os = "macos") is already applied at the module level in mod.rs |
| 20 | |
| 21 | use super::policy::SandboxPolicy; |
| 22 | use std::path::{Path, PathBuf}; |
| 23 | use std::process::Command; |
| 24 | use std::sync::OnceLock; |
| 25 | |
| 26 | /// Path to the sandbox-exec binary on macOS. |
| 27 | pub const SANDBOX_EXEC_PATH: &str = "/usr/bin/sandbox-exec"; |
| 28 | |
| 29 | /// Base seatbelt policy that provides minimal process functionality. |
| 30 | /// |
| 31 | /// This policy: |
| 32 | /// - Denies everything by default |
| 33 | /// - Allows process execution and forking |
| 34 | /// - Allows signals within the same sandbox |
| 35 | /// - Allows reading user preferences (needed by many tools) |
| 36 | /// - Allows basic process introspection |
| 37 | /// - Allows writing to /dev/null |
| 38 | /// - Allows reading sysctl values |
| 39 | /// - Allows POSIX semaphores and pseudo-TTY operations |
| 40 | const SEATBELT_BASE_POLICY: &str = r#" |
| 41 | (version 1) |
| 42 | (deny default) |
| 43 | |
| 44 | ; Core process operations |
| 45 | (allow process-exec) |
| 46 | (allow process-fork) |
| 47 | (allow signal (target same-sandbox)) |
| 48 | (allow process-info* (target same-sandbox)) |
| 49 | |
| 50 | ; User preferences (needed by many CLI tools) |
| 51 | (allow user-preference-read) |
| 52 | |
| 53 | ; Consume only filesystem access tokens already granted by macOS |
| 54 | (allow file-read* (extension "com.apple.app-sandbox.read")) |
| 55 | (allow file-read* (extension "com.apple.app-sandbox.read-write")) |
| 56 | |
| 57 | ; Basic I/O to /dev/null |
| 58 | (allow file-write-data |
| 59 | (require-all |
| 60 | (path "/dev/null") |
| 61 | (vnode-type CHARACTER-DEVICE))) |
| 62 | |
| 63 | ; System information |
| 64 | (allow sysctl-read) |
| 65 | |
| 66 | ; IPC primitives |
| 67 | (allow ipc-posix-sem) |
| 68 | (allow ipc-posix-shm-read*) |
| 69 | (allow ipc-posix-shm-write-create) |
| 70 | (allow ipc-posix-shm-write-data) |
| 71 | (allow ipc-posix-shm-write-unlink) |
| 72 | |
| 73 | ; Terminal support (essential for shell commands) |
| 74 | (allow pseudo-tty) |
| 75 | (allow file-read* file-write* file-ioctl (literal "/dev/ptmx")) |
| 76 | (allow file-read* file-write* file-ioctl (literal "/dev/tty")) |
| 77 | (allow file-read* file-write* file-ioctl (regex #"^/dev/ttys[0-9]+$")) |
| 78 | |
| 79 | ; macOS-specific device access |
| 80 | (allow file-read* (literal "/dev/urandom")) |
| 81 | (allow file-read* (literal "/dev/random")) |
| 82 | (allow file-ioctl (literal "/dev/dtracehelper")) |
| 83 | |
| 84 | ; Mach IPC (needed by many system services) |
| 85 | (allow mach-lookup) |
| 86 | "#; |
| 87 | |
| 88 | /// Network access policy additions. |
| 89 | const SEATBELT_NETWORK_POLICY: &str = r" |
| 90 | ; Network access |
| 91 | (allow network-outbound) |
| 92 | (allow network-inbound) |
| 93 | (allow system-socket) |
| 94 | (allow network-bind) |
| 95 | "; |
| 96 | |
| 97 | /// AppleEvents/LaunchServices allowances for the trusted (full-disk-write) |
| 98 | /// tier only (#4828). |
| 99 | /// |
| 100 | /// `open`, `osascript`, and `launchctl` send AppleEvents and drive |
| 101 | /// LaunchServices; under `(deny default)` those calls die with exit -54. |
| 102 | /// The base policy already allows `mach-lookup` broadly, so the missing |
| 103 | /// operations are `appleevent-send` and the LaunchServices `lsopen` |
| 104 | /// operation. The launchservicesd/appleevents mach names are listed |
| 105 | /// explicitly so a future narrowing of the blanket `mach-lookup` rule |
| 106 | /// cannot silently break this tier. |
| 107 | /// |
| 108 | /// Restrictive tiers (workspace-write, read-only) intentionally stay locked |
| 109 | /// down: AppleEvents automation can instruct other apps to act outside the |
| 110 | /// sandbox, which would defeat the write restrictions. |
| 111 | const SEATBELT_TRUSTED_AUTOMATION_POLICY: &str = r#" |
| 112 | ; AppleEvents + LaunchServices (trusted full-access tier only) |
| 113 | (allow appleevent-send) |
| 114 | (allow lsopen) |
| 115 | (allow mach-lookup |
| 116 | (global-name "com.apple.coreservices.launchservicesd") |
| 117 | (global-name "com.apple.coreservices.appleevents")) |
| 118 | "#; |
| 119 | |
| 120 | /// Check if sandbox-exec is available and permitted on this system. |
| 121 | pub fn is_available() -> bool { |
| 122 | static SEATBELT_AVAILABLE: OnceLock<bool> = OnceLock::new(); |
| 123 | |
| 124 | *SEATBELT_AVAILABLE.get_or_init(|| { |
| 125 | if !Path::new(SANDBOX_EXEC_PATH).exists() { |
| 126 | return false; |
| 127 | } |
| 128 | |
| 129 | let output = Command::new(SANDBOX_EXEC_PATH) |
| 130 | .args(["-p", "(version 1)(allow default)", "--", "/usr/bin/true"]) |
| 131 | .output(); |
| 132 | |
| 133 | match output { |
| 134 | Ok(result) => result.status.success(), |
| 135 | Err(_) => false, |
| 136 | } |
| 137 | }) |
| 138 | } |
| 139 | |
| 140 | /// Create the command-line arguments for sandbox-exec. |
| 141 | /// |
| 142 | /// Returns a Vec of arguments that should be prepended to the command. |
| 143 | /// The format is: `sandbox-exec -p <policy> -D KEY=VALUE ... -- <original command>` |
| 144 | pub fn create_seatbelt_args( |
| 145 | command: Vec<String>, |
| 146 | policy: &SandboxPolicy, |
| 147 | sandbox_cwd: &Path, |
| 148 | ) -> Vec<String> { |
| 149 | let full_policy = generate_policy(policy, sandbox_cwd); |
| 150 | let params = generate_params(policy, sandbox_cwd); |
| 151 | |
| 152 | let mut args = vec!["-p".to_string(), full_policy]; |
| 153 | |
| 154 | // Add parameter definitions for variable substitution |
| 155 | for (key, value) in params { |
| 156 | args.push(format!("-D{}={}", key, value.to_string_lossy())); |
| 157 | } |
| 158 | |
| 159 | // Separator between sandbox-exec args and the actual command |
| 160 | args.push("--".to_string()); |
| 161 | args.extend(command); |
| 162 | |
| 163 | args |
| 164 | } |
| 165 | |
| 166 | /// Generate the complete Seatbelt policy string for the given policy. |
| 167 | fn generate_policy(policy: &SandboxPolicy, cwd: &Path) -> String { |
| 168 | let mut full_policy = SEATBELT_BASE_POLICY.to_string(); |
| 169 | |
| 170 | // Add read access policy |
| 171 | if SandboxPolicy::has_full_disk_read_access() { |
| 172 | full_policy.push_str("\n; Full filesystem read access\n(allow file-read*)"); |
| 173 | } |
| 174 | |
| 175 | // Add write access policy |
| 176 | let file_write_policy = generate_write_policy(policy, cwd); |
| 177 | if !file_write_policy.is_empty() { |
| 178 | full_policy.push_str("\n\n; Write access policy\n"); |
| 179 | full_policy.push_str(&file_write_policy); |
| 180 | } |
| 181 | |
| 182 | // Add network policy if enabled |
| 183 | if policy.has_network_access() { |
| 184 | full_policy.push('\n'); |
| 185 | full_policy.push_str(SEATBELT_NETWORK_POLICY); |
| 186 | } |
| 187 | |
| 188 | // Trusted tier (#4828): full-disk-write policies also get AppleEvents + |
| 189 | // LaunchServices so `open`/`osascript`/`launchctl` work when a |
| 190 | // full-access policy is still routed through seatbelt (e.g. a forced |
| 191 | // sandbox); in the normal flow danger-full-access bypasses the wrap |
| 192 | // entirely via `should_sandbox()`. |
| 193 | if policy.has_full_disk_write_access() { |
| 194 | full_policy.push('\n'); |
| 195 | full_policy.push_str(SEATBELT_TRUSTED_AUTOMATION_POLICY); |
| 196 | } |
| 197 | |
| 198 | // Add Darwin user cache directory access (needed by many macOS tools) |
| 199 | full_policy.push_str("\n\n; Darwin user cache directory\n"); |
| 200 | full_policy |
| 201 | .push_str(r#"(allow file-read* file-write* (subpath (param "DARWIN_USER_CACHE_DIR")))"#); |
| 202 | |
| 203 | // Add common macOS directories that tools often need |
| 204 | full_policy.push_str("\n\n; Common macOS directories\n"); |
| 205 | full_policy.push_str(r#"(allow file-read* (subpath "/usr/lib"))"#); |
| 206 | full_policy.push('\n'); |
| 207 | full_policy.push_str(r#"(allow file-read* (subpath "/usr/share"))"#); |
| 208 | full_policy.push('\n'); |
| 209 | full_policy.push_str(r#"(allow file-read* (subpath "/System/Library"))"#); |
| 210 | full_policy.push('\n'); |
| 211 | full_policy.push_str(r#"(allow file-read* (subpath "/Library/Preferences"))"#); |
| 212 | full_policy.push('\n'); |
| 213 | full_policy.push_str(r#"(allow file-read* (subpath "/private/var/db"))"#); |
| 214 | |
| 215 | // Cargo home (#558): cargo build/test/publish reach into ~/.cargo/registry |
| 216 | // and ~/.cargo/git for crate metadata, downloaded tarballs, and unpacked |
| 217 | // sources. Sandboxed workspace-write was previously rejecting these, |
| 218 | // making `cargo publish` unrunnable from inside the TUI's shell tool. |
| 219 | // Read access is always allowed; write access is granted whenever the |
| 220 | // policy allows any write at all (the registry caches need to be |
| 221 | // mutable for `cargo build` to populate them on a cache miss). Skipped |
| 222 | // entirely when neither `CARGO_HOME` nor `HOME` is set — without one of |
| 223 | // those we have no path to plumb into the policy params. |
| 224 | if resolve_cargo_home().is_some() { |
| 225 | full_policy.push_str("\n\n; Cargo home (~/.cargo) — registry/index/git caches\n"); |
| 226 | full_policy.push_str(r#"(allow file-read* (subpath (param "CARGO_HOME")))"#); |
| 227 | if !matches!(policy, SandboxPolicy::ReadOnly) { |
| 228 | full_policy.push('\n'); |
| 229 | full_policy.push_str(r#"(allow file-write* (subpath (param "CARGO_HOME_REGISTRY")))"#); |
| 230 | full_policy.push('\n'); |
| 231 | full_policy.push_str(r#"(allow file-write* (subpath (param "CARGO_HOME_GIT")))"#); |
| 232 | } |
| 233 | } |
| 234 | |
| 235 | // npm cache (#1267): npx-based MCP servers write to ~/.npm when downloading |
| 236 | // packages on first run. Without write access the npx subprocess fails |
| 237 | // immediately with "Stdio transport closed", making all stdio MCP servers |
| 238 | // broken on macOS under the default workspace-write policy. |
| 239 | // Read access is always allowed; write access mirrors the cargo pattern — |
| 240 | // granted for all policies that allow any write, skipped for ReadOnly. |
| 241 | // Skipped entirely when neither `NPM_CONFIG_CACHE` nor `HOME` is set. |
| 242 | if resolve_npm_cache_dir().is_some() { |
| 243 | full_policy.push_str("\n\n; npm cache (~/.npm) — npx package downloads\n"); |
| 244 | full_policy.push_str(r#"(allow file-read* (subpath (param "NPM_CACHE_DIR")))"#); |
| 245 | if !matches!(policy, SandboxPolicy::ReadOnly) { |
| 246 | full_policy.push('\n'); |
| 247 | full_policy.push_str(r#"(allow file-write* (subpath (param "NPM_CACHE_DIR")))"#); |
| 248 | } |
| 249 | } |
| 250 | |
| 251 | full_policy |
| 252 | } |
| 253 | |
| 254 | /// Resolve the user's cargo home — `CARGO_HOME` if set, else `$HOME/.cargo`. |
| 255 | /// Returns `None` only on hosts where neither env var is set (essentially |
| 256 | /// never on a real macOS user account; can happen in CI containers without |
| 257 | /// `HOME` exported). |
| 258 | fn resolve_cargo_home() -> Option<PathBuf> { |
| 259 | if let Ok(explicit) = std::env::var("CARGO_HOME") |
| 260 | && !explicit.trim().is_empty() |
| 261 | { |
| 262 | return Some(PathBuf::from(explicit)); |
| 263 | } |
| 264 | let home = std::env::var("HOME").ok()?; |
| 265 | Some(PathBuf::from(home).join(".cargo")) |
| 266 | } |
| 267 | |
| 268 | /// Resolve the npm cache directory — `NPM_CONFIG_CACHE` if set, else `$HOME/.npm`. |
| 269 | /// Returns `None` only on hosts where neither env var is set. |
| 270 | fn resolve_npm_cache_dir() -> Option<PathBuf> { |
| 271 | if let Ok(explicit) = std::env::var("NPM_CONFIG_CACHE") |
| 272 | && !explicit.trim().is_empty() |
| 273 | { |
| 274 | return Some(PathBuf::from(explicit)); |
| 275 | } |
| 276 | let home = std::env::var("HOME").ok()?; |
| 277 | Some(PathBuf::from(home).join(".npm")) |
| 278 | } |
| 279 | |
| 280 | /// Generate the write access portion of the Seatbelt policy. |
| 281 | fn generate_write_policy(policy: &SandboxPolicy, cwd: &Path) -> String { |
| 282 | // Full disk write access |
| 283 | if policy.has_full_disk_write_access() { |
| 284 | return r#"(allow file-write* (regex #"^/"))"#.to_string(); |
| 285 | } |
| 286 | |
| 287 | // Read-only - no write policy needed |
| 288 | if matches!(policy, SandboxPolicy::ReadOnly) { |
| 289 | return String::new(); |
| 290 | } |
| 291 | |
| 292 | // Workspace write - enumerate allowed paths |
| 293 | let writable_roots = policy.get_writable_roots(cwd); |
| 294 | if writable_roots.is_empty() { |
| 295 | return String::new(); |
| 296 | } |
| 297 | |
| 298 | let mut policies = Vec::new(); |
| 299 | |
| 300 | for (index, root) in writable_roots.iter().enumerate() { |
| 301 | let root_param = format!("WRITABLE_ROOT_{index}"); |
| 302 | |
| 303 | let mut root_parts = vec![format!("(subpath (param \"{root_param}\"))")]; |
| 304 | for (subpath_index, _) in root.read_only_subpaths.iter().enumerate() { |
| 305 | let ro_param = format!("WRITABLE_ROOT_{index}_RO_{subpath_index}"); |
| 306 | root_parts.push(format!("(require-not (subpath (param \"{ro_param}\")))")); |
| 307 | } |
| 308 | |
| 309 | let root_policy = if root_parts.len() == 1 { |
| 310 | root_parts[0].clone() |
| 311 | } else { |
| 312 | format!("(require-all {})", root_parts.join(" ")) |
| 313 | }; |
| 314 | policies.push(root_policy); |
| 315 | |
| 316 | // File Provider paths can require an inherited macOS extension even |
| 317 | // when their logical path is already an approved root. Keep Codewhale's |
| 318 | // root and protected-subpath restrictions authoritative by requiring |
| 319 | // the extension and every root predicate in the same conjunction. |
| 320 | let mut extension_parts = |
| 321 | vec![r#"(extension "com.apple.app-sandbox.read-write")"#.to_string()]; |
| 322 | extension_parts.extend(root_parts); |
| 323 | policies.push(format!("(require-all {})", extension_parts.join(" "))); |
| 324 | } |
| 325 | |
| 326 | if policies.is_empty() { |
| 327 | return String::new(); |
| 328 | } |
| 329 | |
| 330 | // Combine all write policies with allow |
| 331 | format!("(allow file-write*\n {})", policies.join("\n ")) |
| 332 | } |
| 333 | |
| 334 | /// Generate parameter definitions for variable substitution in the policy. |
| 335 | /// |
| 336 | /// sandbox-exec allows -DKEY=VALUE to substitute `(param "KEY")` in the policy. |
| 337 | fn generate_params(policy: &SandboxPolicy, cwd: &Path) -> Vec<(String, PathBuf)> { |
| 338 | let mut params = Vec::new(); |
| 339 | |
| 340 | // Add writable root parameters |
| 341 | let writable_roots = policy.get_writable_roots(cwd); |
| 342 | |
| 343 | for (index, root) in writable_roots.iter().enumerate() { |
| 344 | let canonical = root |
| 345 | .root |
| 346 | .canonicalize() |
| 347 | .unwrap_or_else(|_| root.root.clone()); |
| 348 | params.push((format!("WRITABLE_ROOT_{index}"), canonical)); |
| 349 | |
| 350 | // Add parameters for read-only subpaths |
| 351 | for (subpath_index, subpath) in root.read_only_subpaths.iter().enumerate() { |
| 352 | let canonical_subpath = subpath.canonicalize().unwrap_or_else(|_| subpath.clone()); |
| 353 | params.push(( |
| 354 | format!("WRITABLE_ROOT_{index}_RO_{subpath_index}"), |
| 355 | canonical_subpath, |
| 356 | )); |
| 357 | } |
| 358 | } |
| 359 | |
| 360 | // Add Darwin user cache directory |
| 361 | if let Some(cache_dir) = get_darwin_user_cache_dir() { |
| 362 | params.push(("DARWIN_USER_CACHE_DIR".to_string(), cache_dir)); |
| 363 | } else { |
| 364 | // Fallback to a reasonable default |
| 365 | if let Ok(home) = std::env::var("HOME") { |
| 366 | params.push(( |
| 367 | "DARWIN_USER_CACHE_DIR".to_string(), |
| 368 | PathBuf::from(format!("{home}/Library/Caches")), |
| 369 | )); |
| 370 | } |
| 371 | } |
| 372 | |
| 373 | // Cargo home (#558): paired with the policy lines emitted by |
| 374 | // `generate_policy` when `resolve_cargo_home()` succeeds. Both helpers |
| 375 | // use the same fallback chain so the policy text and the -DKEY=VALUE |
| 376 | // params stay in sync — emit one without the other and sandbox-exec |
| 377 | // refuses to load the profile. |
| 378 | if let Some(home) = resolve_cargo_home() { |
| 379 | let canonical_home = home.canonicalize().unwrap_or_else(|_| home.clone()); |
| 380 | params.push(( |
| 381 | "CARGO_HOME_REGISTRY".to_string(), |
| 382 | canonical_home.join("registry"), |
| 383 | )); |
| 384 | params.push(("CARGO_HOME_GIT".to_string(), canonical_home.join("git"))); |
| 385 | params.push(("CARGO_HOME".to_string(), canonical_home)); |
| 386 | } |
| 387 | |
| 388 | // npm cache (#1267): paired with the policy lines emitted by |
| 389 | // `generate_policy` when `resolve_npm_cache_dir()` succeeds. Both helpers |
| 390 | // use the same fallback chain so the policy text and the -DKEY=VALUE |
| 391 | // params stay in sync. |
| 392 | if let Some(npm_cache) = resolve_npm_cache_dir() { |
| 393 | let canonical = npm_cache |
| 394 | .canonicalize() |
| 395 | .unwrap_or_else(|_| npm_cache.clone()); |
| 396 | params.push(("NPM_CACHE_DIR".to_string(), canonical)); |
| 397 | } |
| 398 | |
| 399 | params |
| 400 | } |
| 401 | |
| 402 | /// Get the Darwin user cache directory using confstr. |
| 403 | /// |
| 404 | /// This returns the per-user cache directory that macOS assigns, |
| 405 | /// typically something like /var/folders/xx/xxx.../C/ |
| 406 | fn get_darwin_user_cache_dir() -> Option<PathBuf> { |
| 407 | // Use libc to call confstr for _CS_DARWIN_USER_CACHE_DIR |
| 408 | let mut buf = vec![0i8; (libc::PATH_MAX as usize) + 1]; |
| 409 | |
| 410 | // Safety: `buf` is a writable buffer sized to PATH_MAX + 1 for confstr. |
| 411 | let len = |
| 412 | unsafe { libc::confstr(libc::_CS_DARWIN_USER_CACHE_DIR, buf.as_mut_ptr(), buf.len()) }; |
| 413 | |
| 414 | if len == 0 { |
| 415 | return None; |
| 416 | } |
| 417 | |
| 418 | // Convert the C string to a Rust PathBuf |
| 419 | // Safety: confstr guarantees a NUL-terminated string in `buf` when len > 0. |
| 420 | let cstr = unsafe { std::ffi::CStr::from_ptr(buf.as_ptr()) }; |
| 421 | let path_str = cstr.to_str().ok()?; |
| 422 | let path = PathBuf::from(path_str); |
| 423 | |
| 424 | // Try to canonicalize, but return the raw path if that fails |
| 425 | path.canonicalize().ok().or(Some(path)) |
| 426 | } |
| 427 | |
| 428 | /// Detect sandbox denial from command output. |
| 429 | /// |
| 430 | /// Returns true if the output suggests the sandbox blocked an operation. |
| 431 | pub fn detect_denial(exit_code: i32, stderr: &str) -> bool { |
| 432 | if exit_code == 0 { |
| 433 | return false; |
| 434 | } |
| 435 | |
| 436 | // Common sandbox denial messages |
| 437 | let denial_patterns = [ |
| 438 | "Operation not permitted", |
| 439 | "sandbox-exec", |
| 440 | "deny(", |
| 441 | "Sandbox: ", |
| 442 | ]; |
| 443 | |
| 444 | denial_patterns.iter().any(|p| stderr.contains(p)) |
| 445 | } |
| 446 | |
| 447 | #[cfg(test)] |
| 448 | mod tests; |
| 449 | |
| 450 | #[cfg(test)] |
| 451 | mod existing_tests { |
| 452 | use super::*; |
| 453 | |
| 454 | // Tests that mutate HOME/CARGO_HOME use crate::test_support::lock_test_env() |
| 455 | // so they don't race with sibling tests in this crate that read those vars. |
| 456 | #[test] |
| 457 | fn test_generate_policy_with_network() { |
| 458 | let policy = SandboxPolicy::workspace_with_network(); |
| 459 | let cwd = Path::new("/tmp/test"); |
| 460 | let result = generate_policy(&policy, cwd); |
| 461 | |
| 462 | assert!(result.contains("network-outbound")); |
| 463 | assert!(result.contains("network-inbound")); |
| 464 | } |
| 465 | |
| 466 | #[test] |
| 467 | fn test_generate_params() { |
| 468 | let policy = SandboxPolicy::default(); |
| 469 | let cwd = Path::new("/tmp/test"); |
| 470 | let params = generate_params(&policy, cwd); |
| 471 | |
| 472 | // Should have at least the cache dir param |
| 473 | assert!(params.iter().any(|(k, _)| k == "DARWIN_USER_CACHE_DIR")); |
| 474 | } |
| 475 | |
| 476 | /// #558: cargo publish reaches into ~/.cargo/registry; the seatbelt has |
| 477 | /// to allow read+write inside it. Both the policy text and the param |
| 478 | /// table must be in sync — emitting one without the other makes |
| 479 | /// sandbox-exec refuse to load the profile. |
| 480 | #[test] |
| 481 | fn test_cargo_home_paths_emitted_in_policy_and_params_when_home_set() { |
| 482 | let _guard = crate::test_support::lock_test_env(); |
| 483 | |
| 484 | // SAFETY: HOME / CARGO_HOME are process-global. lock_test_env |
| 485 | // serializes tests that mutate them, and we always restore the |
| 486 | // prior value before returning. |
| 487 | let saved_home = std::env::var_os("HOME"); |
| 488 | let saved_cargo = std::env::var_os("CARGO_HOME"); |
| 489 | unsafe { |
| 490 | std::env::set_var("HOME", "/tmp/seatbelt-cargo-test"); |
| 491 | std::env::remove_var("CARGO_HOME"); |
| 492 | } |
| 493 | |
| 494 | let policy = SandboxPolicy::default(); |
| 495 | let cwd = Path::new("/tmp/test"); |
| 496 | |
| 497 | let policy_text = generate_policy(&policy, cwd); |
| 498 | assert!(policy_text.contains(r#"(allow file-read* (subpath (param "CARGO_HOME")))"#)); |
| 499 | assert!(policy_text.contains("CARGO_HOME_REGISTRY")); |
| 500 | assert!(policy_text.contains("CARGO_HOME_GIT")); |
| 501 | |
| 502 | let params = generate_params(&policy, cwd); |
| 503 | assert!(params.iter().any(|(k, _)| k == "CARGO_HOME")); |
| 504 | assert!(params.iter().any(|(k, _)| k == "CARGO_HOME_REGISTRY")); |
| 505 | assert!(params.iter().any(|(k, _)| k == "CARGO_HOME_GIT")); |
| 506 | |
| 507 | // Read-only policy should still emit CARGO_HOME read rule but skip writes. |
| 508 | let read_only_text = generate_policy(&SandboxPolicy::ReadOnly, cwd); |
| 509 | assert!( |
| 510 | read_only_text.contains(r#"(allow file-read* (subpath (param "CARGO_HOME")))"#), |
| 511 | "read-only mode should still allow reading the cargo registry: {read_only_text}" |
| 512 | ); |
| 513 | assert!( |
| 514 | !read_only_text |
| 515 | .contains(r#"(allow file-write* (subpath (param "CARGO_HOME_REGISTRY")))"#), |
| 516 | "read-only mode must NOT grant write access to the cargo registry" |
| 517 | ); |
| 518 | |
| 519 | // Restore. |
| 520 | // SAFETY: restoring the prior value the test stashed at entry. |
| 521 | unsafe { |
| 522 | match saved_home { |
| 523 | Some(v) => std::env::set_var("HOME", v), |
| 524 | None => std::env::remove_var("HOME"), |
| 525 | } |
| 526 | match saved_cargo { |
| 527 | Some(v) => std::env::set_var("CARGO_HOME", v), |
| 528 | None => std::env::remove_var("CARGO_HOME"), |
| 529 | } |
| 530 | } |
| 531 | } |
| 532 | |
| 533 | /// #558: if neither `CARGO_HOME` nor `HOME` is set, the cargo lines and |
| 534 | /// their params must both be omitted — emitting one without the other |
| 535 | /// would crash sandbox-exec on profile load. |
| 536 | #[test] |
| 537 | fn test_cargo_home_skipped_when_no_env() { |
| 538 | let _guard = crate::test_support::lock_test_env(); |
| 539 | |
| 540 | let saved_home = std::env::var_os("HOME"); |
| 541 | let saved_cargo = std::env::var_os("CARGO_HOME"); |
| 542 | // SAFETY: HOME/CARGO_HOME are process-global; lock_test_env serializes |
| 543 | // mutations here and we restore the prior values before returning. |
| 544 | unsafe { |
| 545 | std::env::remove_var("HOME"); |
| 546 | std::env::remove_var("CARGO_HOME"); |
| 547 | } |
| 548 | |
| 549 | let policy = SandboxPolicy::default(); |
| 550 | let cwd = Path::new("/tmp/test"); |
| 551 | let policy_text = generate_policy(&policy, cwd); |
| 552 | let params = generate_params(&policy, cwd); |
| 553 | |
| 554 | assert!(!policy_text.contains("CARGO_HOME")); |
| 555 | assert!(!params.iter().any(|(k, _)| k.starts_with("CARGO_HOME"))); |
| 556 | |
| 557 | // Restore. |
| 558 | // SAFETY: restoring the prior values the test stashed at entry. |
| 559 | unsafe { |
| 560 | match saved_home { |
| 561 | Some(v) => std::env::set_var("HOME", v), |
| 562 | None => std::env::remove_var("HOME"), |
| 563 | } |
| 564 | match saved_cargo { |
| 565 | Some(v) => std::env::set_var("CARGO_HOME", v), |
| 566 | None => std::env::remove_var("CARGO_HOME"), |
| 567 | } |
| 568 | } |
| 569 | } |
| 570 | |
| 571 | /// #1267: npx MCP servers write to ~/.npm on first run; the seatbelt must |
| 572 | /// allow writes to the npm cache directory. Both the policy text and the |
| 573 | /// param table must be in sync — emitting one without the other makes |
| 574 | /// sandbox-exec refuse to load the profile. |
| 575 | #[test] |
| 576 | fn test_npm_cache_paths_emitted_in_policy_and_params_when_home_set() { |
| 577 | let _guard = crate::test_support::lock_test_env(); |
| 578 | |
| 579 | let saved_home = std::env::var_os("HOME"); |
| 580 | let saved_npm = std::env::var_os("NPM_CONFIG_CACHE"); |
| 581 | // SAFETY: HOME/NPM_CONFIG_CACHE are process-global; lock_test_env |
| 582 | // serializes mutations here, and we always restore the prior value. |
| 583 | unsafe { |
| 584 | std::env::set_var("HOME", "/tmp/seatbelt-npm-test"); |
| 585 | std::env::remove_var("NPM_CONFIG_CACHE"); |
| 586 | } |
| 587 | |
| 588 | let policy = SandboxPolicy::default(); |
| 589 | let cwd = Path::new("/tmp/test"); |
| 590 | |
| 591 | let policy_text = generate_policy(&policy, cwd); |
| 592 | assert!( |
| 593 | policy_text.contains(r#"(allow file-read* (subpath (param "NPM_CACHE_DIR")))"#), |
| 594 | "npm cache read rule missing from policy" |
| 595 | ); |
| 596 | assert!( |
| 597 | policy_text.contains(r#"(allow file-write* (subpath (param "NPM_CACHE_DIR")))"#), |
| 598 | "npm cache write rule missing from default policy" |
| 599 | ); |
| 600 | |
| 601 | let params = generate_params(&policy, cwd); |
| 602 | assert!( |
| 603 | params.iter().any(|(k, _)| k == "NPM_CACHE_DIR"), |
| 604 | "NPM_CACHE_DIR param missing" |
| 605 | ); |
| 606 | |
| 607 | // ReadOnly policy: read access allowed, write access must be absent. |
| 608 | let read_only_text = generate_policy(&SandboxPolicy::ReadOnly, cwd); |
| 609 | assert!( |
| 610 | read_only_text.contains(r#"(allow file-read* (subpath (param "NPM_CACHE_DIR")))"#), |
| 611 | "read-only mode should allow reading the npm cache" |
| 612 | ); |
| 613 | assert!( |
| 614 | !read_only_text.contains(r#"(allow file-write* (subpath (param "NPM_CACHE_DIR")))"#), |
| 615 | "read-only mode must NOT grant write access to the npm cache" |
| 616 | ); |
| 617 | |
| 618 | // Restore. |
| 619 | // SAFETY: restoring the prior values the test stashed at entry. |
| 620 | unsafe { |
| 621 | match saved_home { |
| 622 | Some(v) => std::env::set_var("HOME", v), |
| 623 | None => std::env::remove_var("HOME"), |
| 624 | } |
| 625 | match saved_npm { |
| 626 | Some(v) => std::env::set_var("NPM_CONFIG_CACHE", v), |
| 627 | None => std::env::remove_var("NPM_CONFIG_CACHE"), |
| 628 | } |
| 629 | } |
| 630 | } |
| 631 | |
| 632 | /// #1267: if neither `NPM_CONFIG_CACHE` nor `HOME` is set, the npm lines |
| 633 | /// and their param must both be omitted. |
| 634 | #[test] |
| 635 | fn test_npm_cache_skipped_when_no_env() { |
| 636 | let _guard = crate::test_support::lock_test_env(); |
| 637 | |
| 638 | let saved_home = std::env::var_os("HOME"); |
| 639 | let saved_npm = std::env::var_os("NPM_CONFIG_CACHE"); |
| 640 | // SAFETY: HOME/NPM_CONFIG_CACHE are process-global; lock_test_env |
| 641 | // serializes mutations here and we restore the prior values before returning. |
| 642 | unsafe { |
| 643 | std::env::remove_var("HOME"); |
| 644 | std::env::remove_var("NPM_CONFIG_CACHE"); |
| 645 | } |
| 646 | |
| 647 | let policy = SandboxPolicy::default(); |
| 648 | let cwd = Path::new("/tmp/test"); |
| 649 | let policy_text = generate_policy(&policy, cwd); |
| 650 | let params = generate_params(&policy, cwd); |
| 651 | |
| 652 | assert!(!policy_text.contains("NPM_CACHE_DIR")); |
| 653 | assert!(!params.iter().any(|(k, _)| k == "NPM_CACHE_DIR")); |
| 654 | |
| 655 | // Restore. |
| 656 | // SAFETY: restoring the prior values the test stashed at entry. |
| 657 | unsafe { |
| 658 | match saved_home { |
| 659 | Some(v) => std::env::set_var("HOME", v), |
| 660 | None => std::env::remove_var("HOME"), |
| 661 | } |
| 662 | match saved_npm { |
| 663 | Some(v) => std::env::set_var("NPM_CONFIG_CACHE", v), |
| 664 | None => std::env::remove_var("NPM_CONFIG_CACHE"), |
| 665 | } |
| 666 | } |
| 667 | } |
| 668 | |
| 669 | #[test] |
| 670 | fn test_generate_policy_allows_dev_tty() { |
| 671 | let policy = SandboxPolicy::default(); |
| 672 | let cwd = Path::new("/tmp/test"); |
| 673 | let policy_text = generate_policy(&policy, cwd); |
| 674 | |
| 675 | assert!( |
| 676 | policy_text |
| 677 | .contains(r#"(allow file-read* file-write* file-ioctl (literal "/dev/tty"))"#), |
| 678 | "TTY-mode shells need /dev/tty access for sshpass/sudo prompts" |
| 679 | ); |
| 680 | } |
| 681 | |
| 682 | #[test] |
| 683 | fn test_create_seatbelt_args() { |
| 684 | let policy = SandboxPolicy::default(); |
| 685 | let cwd = Path::new("/tmp/test"); |
| 686 | let command = vec!["echo".to_string(), "hello".to_string()]; |
| 687 | |
| 688 | let args = create_seatbelt_args(command, &policy, cwd); |
| 689 | |
| 690 | // Should start with -p and the policy |
| 691 | assert_eq!(args[0], "-p"); |
| 692 | assert!(args[1].contains("(version 1)")); |
| 693 | |
| 694 | // Should contain the separator |
| 695 | assert!(args.contains(&"--".to_string())); |
| 696 | |
| 697 | // Should end with the original command |
| 698 | assert!(args.contains(&"echo".to_string())); |
| 699 | assert!(args.contains(&"hello".to_string())); |
| 700 | } |
| 701 | |
| 702 | /// #4828: `open`/`osascript`/`launchctl` die with exit -54 under |
| 703 | /// `(deny default)` because AppleEvent sends and the LaunchServices |
| 704 | /// `lsopen` operation are blocked. Only the trusted (full-disk-write) |
| 705 | /// tier gains those allowances; the restrictive tiers must stay locked |
| 706 | /// down since AppleEvents automation can drive other apps to act |
| 707 | /// outside the sandbox. |
| 708 | #[test] |
| 709 | fn test_apple_events_allowed_only_in_trusted_tier() { |
| 710 | let cwd = Path::new("/tmp/test"); |
| 711 | |
| 712 | let trusted = generate_policy(&SandboxPolicy::DangerFullAccess, cwd); |
| 713 | assert!( |
| 714 | trusted.contains("(allow appleevent-send)"), |
| 715 | "trusted tier must allow AppleEvent sends: {trusted}" |
| 716 | ); |
| 717 | assert!( |
| 718 | trusted.contains("(allow lsopen)"), |
| 719 | "trusted tier must allow LaunchServices lsopen: {trusted}" |
| 720 | ); |
| 721 | assert!( |
| 722 | trusted.contains(r#"(global-name "com.apple.coreservices.launchservicesd")"#), |
| 723 | "trusted tier must pin the launchservicesd mach name: {trusted}" |
| 724 | ); |
| 725 | assert!( |
| 726 | trusted.contains(r#"(global-name "com.apple.coreservices.appleevents")"#), |
| 727 | "trusted tier must pin the appleevents mach name: {trusted}" |
| 728 | ); |
| 729 | |
| 730 | for (name, restrictive) in [ |
| 731 | ( |
| 732 | "workspace-write", |
| 733 | generate_policy(&SandboxPolicy::default(), cwd), |
| 734 | ), |
| 735 | ( |
| 736 | "workspace-write+network", |
| 737 | generate_policy(&SandboxPolicy::workspace_with_network(), cwd), |
| 738 | ), |
| 739 | ("read-only", generate_policy(&SandboxPolicy::ReadOnly, cwd)), |
| 740 | ] { |
| 741 | assert!( |
| 742 | !restrictive.contains("appleevent-send"), |
| 743 | "{name} tier must NOT allow AppleEvent sends: {restrictive}" |
| 744 | ); |
| 745 | assert!( |
| 746 | !restrictive.contains("lsopen"), |
| 747 | "{name} tier must NOT allow LaunchServices lsopen: {restrictive}" |
| 748 | ); |
| 749 | } |
| 750 | } |
| 751 | |
| 752 | /// #4828: the trusted-tier policy (with the AppleEvents/LaunchServices |
| 753 | /// additions) must still be a valid SBPL profile that sandbox-exec |
| 754 | /// accepts. |
| 755 | #[test] |
| 756 | fn test_trusted_tier_policy_parses_under_sandbox_exec() { |
| 757 | // generate_policy/generate_params both read HOME/CARGO_HOME; take the |
| 758 | // env lock so sibling tests mutating those vars can't desync the |
| 759 | // policy text from its -DKEY=VALUE params mid-call. |
| 760 | let _guard = crate::test_support::lock_test_env(); |
| 761 | |
| 762 | assert!( |
| 763 | is_available(), |
| 764 | "UNRUN: macOS sandbox-exec is unavailable; generated policy was not parsed" |
| 765 | ); |
| 766 | |
| 767 | let cwd = std::env::temp_dir(); |
| 768 | let args = create_seatbelt_args( |
| 769 | vec!["/usr/bin/true".to_string()], |
| 770 | &SandboxPolicy::DangerFullAccess, |
| 771 | &cwd, |
| 772 | ); |
| 773 | let output = Command::new(SANDBOX_EXEC_PATH) |
| 774 | .args(args) |
| 775 | .current_dir(&cwd) |
| 776 | .output() |
| 777 | .expect("run sandbox-exec with trusted-tier policy"); |
| 778 | assert!( |
| 779 | output.status.success(), |
| 780 | "sandbox-exec rejected the trusted-tier policy: {}", |
| 781 | String::from_utf8_lossy(&output.stderr) |
| 782 | ); |
| 783 | } |
| 784 | |
| 785 | #[test] |
| 786 | fn test_detect_denial() { |
| 787 | assert!(detect_denial(1, "Operation not permitted")); |
| 788 | assert!(detect_denial(1, "Sandbox: ls denied file-write*")); |
| 789 | assert!(!detect_denial(0, "Operation not permitted")); |
| 790 | assert!(!detect_denial(1, "File not found")); |
| 791 | } |
| 792 | } |
| 793 |