返回 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 denied_read_subpaths: &[std::path::PathBuf],
149 ) -> Vec<String> {
150 let full_policy = generate_policy(policy, sandbox_cwd, denied_read_subpaths);
151 let params = generate_params(policy, sandbox_cwd);
152
153 let mut args = vec!["-p".to_string(), full_policy];
154
155 // Add parameter definitions for variable substitution
156 for (key, value) in params {
157 args.push(format!("-D{}={}", key, value.to_string_lossy()));
158 }
159
160 // Separator between sandbox-exec args and the actual command
161 args.push("--".to_string());
162 args.extend(command);
163
164 args
165 }
166
167 /// Generate the complete Seatbelt policy string for the given policy.
168 fn generate_policy(
169 policy: &SandboxPolicy,
170 cwd: &Path,
171 denied_read_subpaths: &[std::path::PathBuf],
172 ) -> String {
173 let mut full_policy = SEATBELT_BASE_POLICY.to_string();
174
175 // Base read grant. Emitted unconditionally, including when a deny-list is
176 // in force: SBPL is last-match-wins, so the `(deny file-read* …)` rules
177 // appended at the end of this profile override it for exactly the denied
178 // subpaths and nothing else. Dropping the broad allow instead would make
179 // every posture unable to read `/usr/bin`, and nothing would run.
180 full_policy.push_str("\n; Full filesystem read access\n(allow file-read*)");
181
182 // Add write access policy
183 let file_write_policy = generate_write_policy(policy, cwd);
184 if !file_write_policy.is_empty() {
185 full_policy.push_str("\n\n; Write access policy\n");
186 full_policy.push_str(&file_write_policy);
187 }
188
189 // Add network policy if enabled
190 if policy.has_network_access() {
191 full_policy.push('\n');
192 full_policy.push_str(SEATBELT_NETWORK_POLICY);
193 }
194
195 // Trusted tier (#4828): full-disk-write policies also get AppleEvents +
196 // LaunchServices so `open`/`osascript`/`launchctl` work when a
197 // full-access policy is still routed through seatbelt (e.g. a forced
198 // sandbox); in the normal flow danger-full-access bypasses the wrap
199 // entirely via `should_sandbox()`.
200 if policy.has_full_disk_write_access() {
201 full_policy.push('\n');
202 full_policy.push_str(SEATBELT_TRUSTED_AUTOMATION_POLICY);
203 }
204
205 // Darwin user cache: read always; write only when the policy allows any
206 // write (same gate as cargo/npm). ReadOnly must not get a cache escape.
207 full_policy.push_str("\n\n; Darwin user cache directory\n");
208 full_policy.push_str(r#"(allow file-read* (subpath (param "DARWIN_USER_CACHE_DIR")))"#);
209 if !matches!(policy, SandboxPolicy::ReadOnly) {
210 full_policy.push('\n');
211 full_policy.push_str(r#"(allow file-write* (subpath (param "DARWIN_USER_CACHE_DIR")))"#);
212 }
213
214 // Add common macOS directories that tools often need
215 full_policy.push_str("\n\n; Common macOS directories\n");
216 full_policy.push_str(r#"(allow file-read* (subpath "/usr/lib"))"#);
217 full_policy.push('\n');
218 full_policy.push_str(r#"(allow file-read* (subpath "/usr/share"))"#);
219 full_policy.push('\n');
220 full_policy.push_str(r#"(allow file-read* (subpath "/System/Library"))"#);
221 full_policy.push('\n');
222 full_policy.push_str(r#"(allow file-read* (subpath "/Library/Preferences"))"#);
223 full_policy.push('\n');
224 full_policy.push_str(r#"(allow file-read* (subpath "/private/var/db"))"#);
225
226 // Cargo home (#558): cargo build/test/publish reach into ~/.cargo/registry
227 // and ~/.cargo/git for crate metadata, downloaded tarballs, and unpacked
228 // sources. Sandboxed workspace-write was previously rejecting these,
229 // making `cargo publish` unrunnable from inside the TUI's shell tool.
230 // Read access is always allowed; write access is granted whenever the
231 // policy allows any write at all (the registry caches need to be
232 // mutable for `cargo build` to populate them on a cache miss). Skipped
233 // entirely when neither `CARGO_HOME` nor `HOME` is set — without one of
234 // those we have no path to plumb into the policy params.
235 if resolve_cargo_home().is_some() {
236 full_policy.push_str("\n\n; Cargo home (~/.cargo) — registry/index/git caches\n");
237 full_policy.push_str(r#"(allow file-read* (subpath (param "CARGO_HOME")))"#);
238 if !matches!(policy, SandboxPolicy::ReadOnly) {
239 full_policy.push('\n');
240 full_policy.push_str(r#"(allow file-write* (subpath (param "CARGO_HOME_REGISTRY")))"#);
241 full_policy.push('\n');
242 full_policy.push_str(r#"(allow file-write* (subpath (param "CARGO_HOME_GIT")))"#);
243 }
244 }
245
246 // npm cache (#1267): npx-based MCP servers write to ~/.npm when downloading
247 // packages on first run. Without write access the npx subprocess fails
248 // immediately with "Stdio transport closed", making all stdio MCP servers
249 // broken on macOS under the default workspace-write policy.
250 // Read access is always allowed; write access mirrors the cargo pattern —
251 // granted for all policies that allow any write, skipped for ReadOnly.
252 // Skipped entirely when neither `NPM_CONFIG_CACHE` nor `HOME` is set.
253 if resolve_npm_cache_dir().is_some() {
254 full_policy.push_str("\n\n; npm cache (~/.npm) — npx package downloads\n");
255 full_policy.push_str(r#"(allow file-read* (subpath (param "NPM_CACHE_DIR")))"#);
256 if !matches!(policy, SandboxPolicy::ReadOnly) {
257 full_policy.push('\n');
258 full_policy.push_str(r#"(allow file-write* (subpath (param "NPM_CACHE_DIR")))"#);
259 }
260 }
261
262 // Opt-in read deny-list (S1, #5568). Appended LAST deliberately: SBPL is
263 // last-match-wins, so these rules override every broad read allowance
264 // above — including the full-disk `(allow file-read*)` — for the listed
265 // subpaths. Metadata reads are denied too so the paths do not enumerate.
266 if !denied_read_subpaths.is_empty() {
267 full_policy.push_str("\n\n; Opt-in read deny-list (user-configured)\n");
268 for path in denied_read_subpaths {
269 let escaped = path
270 .to_string_lossy()
271 .replace('\\', "\\\\")
272 .replace('"', "\\\"");
273 full_policy.push_str(&format!("(deny file-read* (subpath \"{escaped}\"))\n"));
274 }
275 }
276
277 full_policy
278 }
279
280 /// Resolve the user's cargo home — `CARGO_HOME` if set, else `$HOME/.cargo`.
281 /// Returns `None` only on hosts where neither env var is set (essentially
282 /// never on a real macOS user account; can happen in CI containers without
283 /// `HOME` exported).
284 fn resolve_cargo_home() -> Option<PathBuf> {
285 if let Ok(explicit) = std::env::var("CARGO_HOME")
286 && !explicit.trim().is_empty()
287 {
288 return Some(PathBuf::from(explicit));
289 }
290 let home = std::env::var("HOME").ok()?;
291 Some(PathBuf::from(home).join(".cargo"))
292 }
293
294 /// Resolve the npm cache directory — `NPM_CONFIG_CACHE` if set, else `$HOME/.npm`.
295 /// Returns `None` only on hosts where neither env var is set.
296 fn resolve_npm_cache_dir() -> Option<PathBuf> {
297 if let Ok(explicit) = std::env::var("NPM_CONFIG_CACHE")
298 && !explicit.trim().is_empty()
299 {
300 return Some(PathBuf::from(explicit));
301 }
302 let home = std::env::var("HOME").ok()?;
303 Some(PathBuf::from(home).join(".npm"))
304 }
305
306 /// Generate the write access portion of the Seatbelt policy.
307 fn generate_write_policy(policy: &SandboxPolicy, cwd: &Path) -> String {
308 // Full disk write access
309 if policy.has_full_disk_write_access() {
310 return r#"(allow file-write* (regex #"^/"))"#.to_string();
311 }
312
313 // Read-only - no write policy needed
314 if matches!(policy, SandboxPolicy::ReadOnly) {
315 return String::new();
316 }
317
318 // Workspace write - enumerate allowed paths
319 let writable_roots = policy.get_writable_roots(cwd);
320 if writable_roots.is_empty() {
321 return String::new();
322 }
323
324 let mut policies = Vec::new();
325
326 for (index, root) in writable_roots.iter().enumerate() {
327 let root_param = format!("WRITABLE_ROOT_{index}");
328
329 let mut root_parts = vec![format!("(subpath (param \"{root_param}\"))")];
330 for (subpath_index, _) in root.read_only_subpaths.iter().enumerate() {
331 let ro_param = format!("WRITABLE_ROOT_{index}_RO_{subpath_index}");
332 root_parts.push(format!("(require-not (subpath (param \"{ro_param}\")))"));
333 }
334
335 let root_policy = if root_parts.len() == 1 {
336 root_parts[0].clone()
337 } else {
338 format!("(require-all {})", root_parts.join(" "))
339 };
340 policies.push(root_policy);
341
342 // File Provider paths can require an inherited macOS extension even
343 // when their logical path is already an approved root. Keep Codewhale's
344 // root and protected-subpath restrictions authoritative by requiring
345 // the extension and every root predicate in the same conjunction.
346 let mut extension_parts =
347 vec![r#"(extension "com.apple.app-sandbox.read-write")"#.to_string()];
348 extension_parts.extend(root_parts);
349 policies.push(format!("(require-all {})", extension_parts.join(" ")));
350 }
351
352 if policies.is_empty() {
353 return String::new();
354 }
355
356 // Combine all write policies with allow
357 format!("(allow file-write*\n {})", policies.join("\n "))
358 }
359
360 /// Generate parameter definitions for variable substitution in the policy.
361 ///
362 /// sandbox-exec allows -DKEY=VALUE to substitute `(param "KEY")` in the policy.
363 fn generate_params(policy: &SandboxPolicy, cwd: &Path) -> Vec<(String, PathBuf)> {
364 let mut params = Vec::new();
365
366 // Add writable root parameters
367 let writable_roots = policy.get_writable_roots(cwd);
368
369 for (index, root) in writable_roots.iter().enumerate() {
370 let canonical = root
371 .root
372 .canonicalize()
373 .unwrap_or_else(|_| root.root.clone());
374 params.push((format!("WRITABLE_ROOT_{index}"), canonical));
375
376 // Add parameters for read-only subpaths
377 for (subpath_index, subpath) in root.read_only_subpaths.iter().enumerate() {
378 let canonical_subpath = subpath.canonicalize().unwrap_or_else(|_| subpath.clone());
379 params.push((
380 format!("WRITABLE_ROOT_{index}_RO_{subpath_index}"),
381 canonical_subpath,
382 ));
383 }
384 }
385
386 // Add Darwin user cache directory
387 if let Some(cache_dir) = get_darwin_user_cache_dir() {
388 params.push(("DARWIN_USER_CACHE_DIR".to_string(), cache_dir));
389 } else {
390 // Fallback to a reasonable default
391 if let Ok(home) = std::env::var("HOME") {
392 params.push((
393 "DARWIN_USER_CACHE_DIR".to_string(),
394 PathBuf::from(format!("{home}/Library/Caches")),
395 ));
396 }
397 }
398
399 // Cargo home (#558): paired with the policy lines emitted by
400 // `generate_policy` when `resolve_cargo_home()` succeeds. Both helpers
401 // use the same fallback chain so the policy text and the -DKEY=VALUE
402 // params stay in sync — emit one without the other and sandbox-exec
403 // refuses to load the profile.
404 if let Some(home) = resolve_cargo_home() {
405 let canonical_home = home.canonicalize().unwrap_or_else(|_| home.clone());
406 params.push((
407 "CARGO_HOME_REGISTRY".to_string(),
408 canonical_home.join("registry"),
409 ));
410 params.push(("CARGO_HOME_GIT".to_string(), canonical_home.join("git")));
411 params.push(("CARGO_HOME".to_string(), canonical_home));
412 }
413
414 // npm cache (#1267): paired with the policy lines emitted by
415 // `generate_policy` when `resolve_npm_cache_dir()` succeeds. Both helpers
416 // use the same fallback chain so the policy text and the -DKEY=VALUE
417 // params stay in sync.
418 if let Some(npm_cache) = resolve_npm_cache_dir() {
419 let canonical = npm_cache
420 .canonicalize()
421 .unwrap_or_else(|_| npm_cache.clone());
422 params.push(("NPM_CACHE_DIR".to_string(), canonical));
423 }
424
425 params
426 }
427
428 /// Get the Darwin user cache directory using confstr.
429 ///
430 /// This returns the per-user cache directory that macOS assigns,
431 /// typically something like /var/folders/xx/xxx.../C/
432 fn get_darwin_user_cache_dir() -> Option<PathBuf> {
433 // Use libc to call confstr for _CS_DARWIN_USER_CACHE_DIR
434 let mut buf = vec![0i8; (libc::PATH_MAX as usize) + 1];
435
436 // Safety: `buf` is a writable buffer sized to PATH_MAX + 1 for confstr.
437 let len =
438 unsafe { libc::confstr(libc::_CS_DARWIN_USER_CACHE_DIR, buf.as_mut_ptr(), buf.len()) };
439
440 if len == 0 {
441 return None;
442 }
443
444 // Convert the C string to a Rust PathBuf
445 // Safety: confstr guarantees a NUL-terminated string in `buf` when len > 0.
446 let cstr = unsafe { std::ffi::CStr::from_ptr(buf.as_ptr()) };
447 let path_str = cstr.to_str().ok()?;
448 let path = PathBuf::from(path_str);
449
450 // Try to canonicalize, but return the raw path if that fails
451 path.canonicalize().ok().or(Some(path))
452 }
453
454 /// Detect sandbox denial from command output.
455 ///
456 /// Returns true if the output suggests the sandbox blocked an operation.
457 pub fn detect_denial(exit_code: i32, stderr: &str) -> bool {
458 if exit_code == 0 {
459 return false;
460 }
461
462 // Common sandbox denial messages
463 let denial_patterns = [
464 "Operation not permitted",
465 "sandbox-exec",
466 "deny(",
467 "Sandbox: ",
468 ];
469
470 denial_patterns.iter().any(|p| stderr.contains(p))
471 }
472
473 #[cfg(test)]
474 mod tests;
475
476 #[cfg(test)]
477 mod existing_tests {
478 use super::*;
479
480 // Tests that mutate HOME/CARGO_HOME use crate::test_support::lock_test_env()
481 // so they don't race with sibling tests in this crate that read those vars.
482 #[test]
483 fn test_generate_policy_with_network() {
484 let policy = SandboxPolicy::workspace_with_network();
485 let cwd = Path::new("/tmp/test");
486 let result = generate_policy(&policy, cwd, &[]);
487
488 assert!(result.contains("network-outbound"));
489 assert!(result.contains("network-inbound"));
490 }
491
492 #[test]
493 fn test_generate_params() {
494 let policy = SandboxPolicy::default();
495 let cwd = Path::new("/tmp/test");
496 let params = generate_params(&policy, cwd);
497
498 // Should have at least the cache dir param
499 assert!(params.iter().any(|(k, _)| k == "DARWIN_USER_CACHE_DIR"));
500 }
501
502 #[test]
503 fn test_darwin_user_cache_write_skipped_for_read_only() {
504 let cwd = Path::new("/tmp/test");
505
506 let default_text = generate_policy(&SandboxPolicy::default(), cwd, &[]);
507 assert!(
508 default_text
509 .contains(r#"(allow file-read* (subpath (param "DARWIN_USER_CACHE_DIR")))"#),
510 "default policy should allow reading the Darwin user cache"
511 );
512 assert!(
513 default_text
514 .contains(r#"(allow file-write* (subpath (param "DARWIN_USER_CACHE_DIR")))"#),
515 "default policy should allow writing the Darwin user cache"
516 );
517
518 let read_only_text = generate_policy(&SandboxPolicy::ReadOnly, cwd, &[]);
519 assert!(
520 read_only_text
521 .contains(r#"(allow file-read* (subpath (param "DARWIN_USER_CACHE_DIR")))"#),
522 "read-only mode should still allow reading the Darwin user cache"
523 );
524 assert!(
525 !read_only_text
526 .contains(r#"(allow file-write* (subpath (param "DARWIN_USER_CACHE_DIR")))"#),
527 "read-only mode must NOT grant write access to the Darwin user cache"
528 );
529 assert!(
530 !read_only_text.contains(
531 r#"(allow file-read* file-write* (subpath (param "DARWIN_USER_CACHE_DIR")))"#
532 ),
533 "read-only mode must not combine Darwin cache write into the read rule"
534 );
535 }
536
537 /// #558: cargo publish reaches into ~/.cargo/registry; the seatbelt has
538 /// to allow read+write inside it. Both the policy text and the param
539 /// table must be in sync — emitting one without the other makes
540 /// sandbox-exec refuse to load the profile.
541 #[test]
542 fn test_cargo_home_paths_emitted_in_policy_and_params_when_home_set() {
543 let _guard = crate::test_support::lock_test_env();
544
545 // SAFETY: HOME / CARGO_HOME are process-global. lock_test_env
546 // serializes tests that mutate them, and we always restore the
547 // prior value before returning.
548 let saved_home = std::env::var_os("HOME");
549 let saved_cargo = std::env::var_os("CARGO_HOME");
550 unsafe {
551 std::env::set_var("HOME", "/tmp/seatbelt-cargo-test");
552 std::env::remove_var("CARGO_HOME");
553 }
554
555 let policy = SandboxPolicy::default();
556 let cwd = Path::new("/tmp/test");
557
558 let policy_text = generate_policy(&policy, cwd, &[]);
559 assert!(policy_text.contains(r#"(allow file-read* (subpath (param "CARGO_HOME")))"#));
560 assert!(policy_text.contains("CARGO_HOME_REGISTRY"));
561 assert!(policy_text.contains("CARGO_HOME_GIT"));
562
563 let params = generate_params(&policy, cwd);
564 assert!(params.iter().any(|(k, _)| k == "CARGO_HOME"));
565 assert!(params.iter().any(|(k, _)| k == "CARGO_HOME_REGISTRY"));
566 assert!(params.iter().any(|(k, _)| k == "CARGO_HOME_GIT"));
567
568 // Read-only policy should still emit CARGO_HOME read rule but skip writes.
569 let read_only_text = generate_policy(&SandboxPolicy::ReadOnly, cwd, &[]);
570 assert!(
571 read_only_text.contains(r#"(allow file-read* (subpath (param "CARGO_HOME")))"#),
572 "read-only mode should still allow reading the cargo registry: {read_only_text}"
573 );
574 assert!(
575 !read_only_text
576 .contains(r#"(allow file-write* (subpath (param "CARGO_HOME_REGISTRY")))"#),
577 "read-only mode must NOT grant write access to the cargo registry"
578 );
579
580 // Restore.
581 // SAFETY: restoring the prior value the test stashed at entry.
582 unsafe {
583 match saved_home {
584 Some(v) => std::env::set_var("HOME", v),
585 None => std::env::remove_var("HOME"),
586 }
587 match saved_cargo {
588 Some(v) => std::env::set_var("CARGO_HOME", v),
589 None => std::env::remove_var("CARGO_HOME"),
590 }
591 }
592 }
593
594 /// #558: if neither `CARGO_HOME` nor `HOME` is set, the cargo lines and
595 /// their params must both be omitted — emitting one without the other
596 /// would crash sandbox-exec on profile load.
597 #[test]
598 fn test_cargo_home_skipped_when_no_env() {
599 let _guard = crate::test_support::lock_test_env();
600
601 let saved_home = std::env::var_os("HOME");
602 let saved_cargo = std::env::var_os("CARGO_HOME");
603 // SAFETY: HOME/CARGO_HOME are process-global; lock_test_env serializes
604 // mutations here and we restore the prior values before returning.
605 unsafe {
606 std::env::remove_var("HOME");
607 std::env::remove_var("CARGO_HOME");
608 }
609
610 let policy = SandboxPolicy::default();
611 let cwd = Path::new("/tmp/test");
612 let policy_text = generate_policy(&policy, cwd, &[]);
613 let params = generate_params(&policy, cwd);
614
615 assert!(!policy_text.contains("CARGO_HOME"));
616 assert!(!params.iter().any(|(k, _)| k.starts_with("CARGO_HOME")));
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_cargo {
626 Some(v) => std::env::set_var("CARGO_HOME", v),
627 None => std::env::remove_var("CARGO_HOME"),
628 }
629 }
630 }
631
632 /// #1267: npx MCP servers write to ~/.npm on first run; the seatbelt must
633 /// allow writes to the npm cache directory. Both the policy text and the
634 /// param table must be in sync — emitting one without the other makes
635 /// sandbox-exec refuse to load the profile.
636 #[test]
637 fn test_npm_cache_paths_emitted_in_policy_and_params_when_home_set() {
638 let _guard = crate::test_support::lock_test_env();
639
640 let saved_home = std::env::var_os("HOME");
641 let saved_npm = std::env::var_os("NPM_CONFIG_CACHE");
642 // SAFETY: HOME/NPM_CONFIG_CACHE are process-global; lock_test_env
643 // serializes mutations here, and we always restore the prior value.
644 unsafe {
645 std::env::set_var("HOME", "/tmp/seatbelt-npm-test");
646 std::env::remove_var("NPM_CONFIG_CACHE");
647 }
648
649 let policy = SandboxPolicy::default();
650 let cwd = Path::new("/tmp/test");
651
652 let policy_text = generate_policy(&policy, cwd, &[]);
653 assert!(
654 policy_text.contains(r#"(allow file-read* (subpath (param "NPM_CACHE_DIR")))"#),
655 "npm cache read rule missing from policy"
656 );
657 assert!(
658 policy_text.contains(r#"(allow file-write* (subpath (param "NPM_CACHE_DIR")))"#),
659 "npm cache write rule missing from default policy"
660 );
661
662 let params = generate_params(&policy, cwd);
663 assert!(
664 params.iter().any(|(k, _)| k == "NPM_CACHE_DIR"),
665 "NPM_CACHE_DIR param missing"
666 );
667
668 // ReadOnly policy: read access allowed, write access must be absent.
669 let read_only_text = generate_policy(&SandboxPolicy::ReadOnly, cwd, &[]);
670 assert!(
671 read_only_text.contains(r#"(allow file-read* (subpath (param "NPM_CACHE_DIR")))"#),
672 "read-only mode should allow reading the npm cache"
673 );
674 assert!(
675 !read_only_text.contains(r#"(allow file-write* (subpath (param "NPM_CACHE_DIR")))"#),
676 "read-only mode must NOT grant write access to the npm cache"
677 );
678
679 // Restore.
680 // SAFETY: restoring the prior values the test stashed at entry.
681 unsafe {
682 match saved_home {
683 Some(v) => std::env::set_var("HOME", v),
684 None => std::env::remove_var("HOME"),
685 }
686 match saved_npm {
687 Some(v) => std::env::set_var("NPM_CONFIG_CACHE", v),
688 None => std::env::remove_var("NPM_CONFIG_CACHE"),
689 }
690 }
691 }
692
693 /// #1267: if neither `NPM_CONFIG_CACHE` nor `HOME` is set, the npm lines
694 /// and their param must both be omitted.
695 #[test]
696 fn test_npm_cache_skipped_when_no_env() {
697 let _guard = crate::test_support::lock_test_env();
698
699 let saved_home = std::env::var_os("HOME");
700 let saved_npm = std::env::var_os("NPM_CONFIG_CACHE");
701 // SAFETY: HOME/NPM_CONFIG_CACHE are process-global; lock_test_env
702 // serializes mutations here and we restore the prior values before returning.
703 unsafe {
704 std::env::remove_var("HOME");
705 std::env::remove_var("NPM_CONFIG_CACHE");
706 }
707
708 let policy = SandboxPolicy::default();
709 let cwd = Path::new("/tmp/test");
710 let policy_text = generate_policy(&policy, cwd, &[]);
711 let params = generate_params(&policy, cwd);
712
713 assert!(!policy_text.contains("NPM_CACHE_DIR"));
714 assert!(!params.iter().any(|(k, _)| k == "NPM_CACHE_DIR"));
715
716 // Restore.
717 // SAFETY: restoring the prior values the test stashed at entry.
718 unsafe {
719 match saved_home {
720 Some(v) => std::env::set_var("HOME", v),
721 None => std::env::remove_var("HOME"),
722 }
723 match saved_npm {
724 Some(v) => std::env::set_var("NPM_CONFIG_CACHE", v),
725 None => std::env::remove_var("NPM_CONFIG_CACHE"),
726 }
727 }
728 }
729
730 #[test]
731 fn test_generate_policy_allows_dev_tty() {
732 let policy = SandboxPolicy::default();
733 let cwd = Path::new("/tmp/test");
734 let policy_text = generate_policy(&policy, cwd, &[]);
735
736 assert!(
737 policy_text
738 .contains(r#"(allow file-read* file-write* file-ioctl (literal "/dev/tty"))"#),
739 "TTY-mode shells need /dev/tty access for sshpass/sudo prompts"
740 );
741 }
742
743 #[test]
744 fn seatbelt_profile_grants_network_only_when_the_policy_does() {
745 // The OS layer and the application-level policy must agree. The
746 // seatbelt base profile is `(deny default)` with no network rules, so
747 // absence of SEATBELT_NETWORK_POLICY is a real denial, not a gap.
748 let cwd = Path::new("/tmp/test");
749
750 let restricted = SandboxPolicy::WorkspaceWrite {
751 writable_roots: vec![cwd.to_path_buf()],
752 network_access: false,
753 exclude_tmpdir: false,
754 exclude_slash_tmp: false,
755 };
756 let text = generate_policy(&restricted, cwd, &[]);
757 assert!(
758 !text.contains("network-outbound"),
759 "a network-restricted policy must not emit outbound rules:\n{text}"
760 );
761 assert!(!text.contains("network-inbound"));
762 assert!(!text.contains("network-bind"));
763
764 let allowed = SandboxPolicy::WorkspaceWrite {
765 writable_roots: vec![cwd.to_path_buf()],
766 network_access: true,
767 exclude_tmpdir: false,
768 exclude_slash_tmp: false,
769 };
770 let text = generate_policy(&allowed, cwd, &[]);
771 assert!(
772 text.contains("network-outbound"),
773 "an explicitly granted policy must emit outbound rules:\n{text}"
774 );
775
776 // Default construction is restricted, so the shipped default profile
777 // carries no network rules.
778 assert!(!generate_policy(&SandboxPolicy::default(), cwd, &[]).contains("network-outbound"));
779 }
780
781 #[test]
782 fn test_create_seatbelt_args() {
783 let policy = SandboxPolicy::default();
784 let cwd = Path::new("/tmp/test");
785 let command = vec!["echo".to_string(), "hello".to_string()];
786
787 let args = create_seatbelt_args(command, &policy, cwd, &[]);
788
789 // Should start with -p and the policy
790 assert_eq!(args[0], "-p");
791 assert!(args[1].contains("(version 1)"));
792
793 // Should contain the separator
794 assert!(args.contains(&"--".to_string()));
795
796 // Should end with the original command
797 assert!(args.contains(&"echo".to_string()));
798 assert!(args.contains(&"hello".to_string()));
799 }
800
801 /// #4828: `open`/`osascript`/`launchctl` die with exit -54 under
802 /// `(deny default)` because AppleEvent sends and the LaunchServices
803 /// `lsopen` operation are blocked. Only the trusted (full-disk-write)
804 /// tier gains those allowances; the restrictive tiers must stay locked
805 /// down since AppleEvents automation can drive other apps to act
806 /// outside the sandbox.
807 #[test]
808 fn test_apple_events_allowed_only_in_trusted_tier() {
809 let cwd = Path::new("/tmp/test");
810
811 let trusted = generate_policy(&SandboxPolicy::DangerFullAccess, cwd, &[]);
812 assert!(
813 trusted.contains("(allow appleevent-send)"),
814 "trusted tier must allow AppleEvent sends: {trusted}"
815 );
816 assert!(
817 trusted.contains("(allow lsopen)"),
818 "trusted tier must allow LaunchServices lsopen: {trusted}"
819 );
820 assert!(
821 trusted.contains(r#"(global-name "com.apple.coreservices.launchservicesd")"#),
822 "trusted tier must pin the launchservicesd mach name: {trusted}"
823 );
824 assert!(
825 trusted.contains(r#"(global-name "com.apple.coreservices.appleevents")"#),
826 "trusted tier must pin the appleevents mach name: {trusted}"
827 );
828
829 for (name, restrictive) in [
830 (
831 "workspace-write",
832 generate_policy(&SandboxPolicy::default(), cwd, &[]),
833 ),
834 (
835 "workspace-write+network",
836 generate_policy(&SandboxPolicy::workspace_with_network(), cwd, &[]),
837 ),
838 (
839 "read-only",
840 generate_policy(&SandboxPolicy::ReadOnly, cwd, &[]),
841 ),
842 ] {
843 assert!(
844 !restrictive.contains("appleevent-send"),
845 "{name} tier must NOT allow AppleEvent sends: {restrictive}"
846 );
847 assert!(
848 !restrictive.contains("lsopen"),
849 "{name} tier must NOT allow LaunchServices lsopen: {restrictive}"
850 );
851 }
852 }
853
854 /// #4828: the trusted-tier policy (with the AppleEvents/LaunchServices
855 /// additions) must still be a valid SBPL profile that sandbox-exec
856 /// accepts.
857 #[test]
858 fn test_trusted_tier_policy_parses_under_sandbox_exec() {
859 // generate_policy/generate_params both read HOME/CARGO_HOME; take the
860 // env lock so sibling tests mutating those vars can't desync the
861 // policy text from its -DKEY=VALUE params mid-call.
862 let _guard = crate::test_support::lock_test_env();
863
864 assert!(
865 is_available(),
866 "UNRUN: macOS sandbox-exec is unavailable; generated policy was not parsed"
867 );
868
869 let cwd = std::env::temp_dir();
870 let args = create_seatbelt_args(
871 vec!["/usr/bin/true".to_string()],
872 &SandboxPolicy::DangerFullAccess,
873 &cwd,
874 &[],
875 );
876 let output = Command::new(SANDBOX_EXEC_PATH)
877 .args(args)
878 .current_dir(&cwd)
879 .output()
880 .expect("run sandbox-exec with trusted-tier policy");
881 assert!(
882 output.status.success(),
883 "sandbox-exec rejected the trusted-tier policy: {}",
884 String::from_utf8_lossy(&output.stderr)
885 );
886 }
887
888 #[test]
889 fn test_detect_denial() {
890 assert!(detect_denial(1, "Operation not permitted"));
891 assert!(detect_denial(1, "Sandbox: ls denied file-write*"));
892 assert!(!detect_denial(0, "Operation not permitted"));
893 assert!(!detect_denial(1, "File not found"));
894 }
895 }
896
896 lines RUST