返回 CodeWhale
seatbelt.rs
根目录 / crates / tui / src / sandbox / seatbelt.rs
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
793 lines RUST