返回 DeepSeek-TUI-2026
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 ; Basic I/O to /dev/null
54 (allow file-write-data
55 (require-all
56 (path "/dev/null")
57 (vnode-type CHARACTER-DEVICE)))
58
59 ; System information
60 (allow sysctl-read)
61
62 ; IPC primitives
63 (allow ipc-posix-sem)
64 (allow ipc-posix-shm-read*)
65 (allow ipc-posix-shm-write-create)
66 (allow ipc-posix-shm-write-data)
67 (allow ipc-posix-shm-write-unlink)
68
69 ; Terminal support (essential for shell commands)
70 (allow pseudo-tty)
71 (allow file-read* file-write* file-ioctl (literal "/dev/ptmx"))
72 (allow file-read* file-write* file-ioctl (regex #"^/dev/ttys[0-9]+$"))
73
74 ; macOS-specific device access
75 (allow file-read* (literal "/dev/urandom"))
76 (allow file-read* (literal "/dev/random"))
77 (allow file-ioctl (literal "/dev/dtracehelper"))
78
79 ; Mach IPC (needed by many system services)
80 (allow mach-lookup)
81 "#;
82
83 /// Network access policy additions.
84 const SEATBELT_NETWORK_POLICY: &str = r"
85 ; Network access
86 (allow network-outbound)
87 (allow network-inbound)
88 (allow system-socket)
89 (allow network-bind)
90 ";
91
92 /// Check if sandbox-exec is available and permitted on this system.
93 pub fn is_available() -> bool {
94 static SEATBELT_AVAILABLE: OnceLock<bool> = OnceLock::new();
95
96 *SEATBELT_AVAILABLE.get_or_init(|| {
97 if !Path::new(SANDBOX_EXEC_PATH).exists() {
98 return false;
99 }
100
101 let output = Command::new(SANDBOX_EXEC_PATH)
102 .args(["-p", "(version 1)(allow default)", "--", "/usr/bin/true"])
103 .output();
104
105 match output {
106 Ok(result) => result.status.success(),
107 Err(_) => false,
108 }
109 })
110 }
111
112 /// Create the command-line arguments for sandbox-exec.
113 ///
114 /// Returns a Vec of arguments that should be prepended to the command.
115 /// The format is: `sandbox-exec -p <policy> -D KEY=VALUE ... -- <original command>`
116 pub fn create_seatbelt_args(
117 command: Vec<String>,
118 policy: &SandboxPolicy,
119 sandbox_cwd: &Path,
120 ) -> Vec<String> {
121 let full_policy = generate_policy(policy, sandbox_cwd);
122 let params = generate_params(policy, sandbox_cwd);
123
124 let mut args = vec!["-p".to_string(), full_policy];
125
126 // Add parameter definitions for variable substitution
127 for (key, value) in params {
128 args.push(format!("-D{}={}", key, value.to_string_lossy()));
129 }
130
131 // Separator between sandbox-exec args and the actual command
132 args.push("--".to_string());
133 args.extend(command);
134
135 args
136 }
137
138 /// Generate the complete Seatbelt policy string for the given policy.
139 fn generate_policy(policy: &SandboxPolicy, cwd: &Path) -> String {
140 let mut full_policy = SEATBELT_BASE_POLICY.to_string();
141
142 // Add read access policy
143 if SandboxPolicy::has_full_disk_read_access() {
144 full_policy.push_str("\n; Full filesystem read access\n(allow file-read*)");
145 }
146
147 // Add write access policy
148 let file_write_policy = generate_write_policy(policy, cwd);
149 if !file_write_policy.is_empty() {
150 full_policy.push_str("\n\n; Write access policy\n");
151 full_policy.push_str(&file_write_policy);
152 }
153
154 // Add network policy if enabled
155 if policy.has_network_access() {
156 full_policy.push('\n');
157 full_policy.push_str(SEATBELT_NETWORK_POLICY);
158 }
159
160 // Add Darwin user cache directory access (needed by many macOS tools)
161 full_policy.push_str("\n\n; Darwin user cache directory\n");
162 full_policy
163 .push_str(r#"(allow file-read* file-write* (subpath (param "DARWIN_USER_CACHE_DIR")))"#);
164
165 // Add common macOS directories that tools often need
166 full_policy.push_str("\n\n; Common macOS directories\n");
167 full_policy.push_str(r#"(allow file-read* (subpath "/usr/lib"))"#);
168 full_policy.push('\n');
169 full_policy.push_str(r#"(allow file-read* (subpath "/usr/share"))"#);
170 full_policy.push('\n');
171 full_policy.push_str(r#"(allow file-read* (subpath "/System/Library"))"#);
172 full_policy.push('\n');
173 full_policy.push_str(r#"(allow file-read* (subpath "/Library/Preferences"))"#);
174 full_policy.push('\n');
175 full_policy.push_str(r#"(allow file-read* (subpath "/private/var/db"))"#);
176
177 // Cargo home (#558): cargo build/test/publish reach into ~/.cargo/registry
178 // and ~/.cargo/git for crate metadata, downloaded tarballs, and unpacked
179 // sources. Sandboxed workspace-write was previously rejecting these,
180 // making `cargo publish` unrunnable from inside the TUI's shell tool.
181 // Read access is always allowed; write access is granted whenever the
182 // policy allows any write at all (the registry caches need to be
183 // mutable for `cargo build` to populate them on a cache miss). Skipped
184 // entirely when neither `CARGO_HOME` nor `HOME` is set — without one of
185 // those we have no path to plumb into the policy params.
186 if resolve_cargo_home().is_some() {
187 full_policy.push_str("\n\n; Cargo home (~/.cargo) — registry/index/git caches\n");
188 full_policy.push_str(r#"(allow file-read* (subpath (param "CARGO_HOME")))"#);
189 if !matches!(policy, SandboxPolicy::ReadOnly) {
190 full_policy.push('\n');
191 full_policy.push_str(r#"(allow file-write* (subpath (param "CARGO_HOME_REGISTRY")))"#);
192 full_policy.push('\n');
193 full_policy.push_str(r#"(allow file-write* (subpath (param "CARGO_HOME_GIT")))"#);
194 }
195 }
196
197 full_policy
198 }
199
200 /// Resolve the user's cargo home — `CARGO_HOME` if set, else `$HOME/.cargo`.
201 /// Returns `None` only on hosts where neither env var is set (essentially
202 /// never on a real macOS user account; can happen in CI containers without
203 /// `HOME` exported).
204 fn resolve_cargo_home() -> Option<PathBuf> {
205 if let Ok(explicit) = std::env::var("CARGO_HOME")
206 && !explicit.trim().is_empty()
207 {
208 return Some(PathBuf::from(explicit));
209 }
210 let home = std::env::var("HOME").ok()?;
211 Some(PathBuf::from(home).join(".cargo"))
212 }
213
214 /// Generate the write access portion of the Seatbelt policy.
215 fn generate_write_policy(policy: &SandboxPolicy, cwd: &Path) -> String {
216 // Full disk write access
217 if policy.has_full_disk_write_access() {
218 return r#"(allow file-write* (regex #"^/"))"#.to_string();
219 }
220
221 // Read-only - no write policy needed
222 if matches!(policy, SandboxPolicy::ReadOnly) {
223 return String::new();
224 }
225
226 // Workspace write - enumerate allowed paths
227 let writable_roots = policy.get_writable_roots(cwd);
228 if writable_roots.is_empty() {
229 return String::new();
230 }
231
232 let mut policies = Vec::new();
233
234 for (index, root) in writable_roots.iter().enumerate() {
235 let root_param = format!("WRITABLE_ROOT_{index}");
236
237 if root.read_only_subpaths.is_empty() {
238 // Simple case: entire subtree is writable
239 policies.push(format!("(subpath (param \"{root_param}\"))"));
240 } else {
241 // Complex case: writable with read-only exceptions
242 // Use require-all to combine subpath with require-not for each exception
243 let mut parts = vec![format!("(subpath (param \"{}\"))", root_param)];
244
245 for (subpath_index, _) in root.read_only_subpaths.iter().enumerate() {
246 let ro_param = format!("WRITABLE_ROOT_{index}_RO_{subpath_index}");
247 parts.push(format!("(require-not (subpath (param \"{ro_param}\")))"));
248 }
249
250 policies.push(format!("(require-all {})", parts.join(" ")));
251 }
252 }
253
254 if policies.is_empty() {
255 return String::new();
256 }
257
258 // Combine all write policies with allow
259 format!("(allow file-write*\n {})", policies.join("\n "))
260 }
261
262 /// Generate parameter definitions for variable substitution in the policy.
263 ///
264 /// sandbox-exec allows -DKEY=VALUE to substitute `(param "KEY")` in the policy.
265 fn generate_params(policy: &SandboxPolicy, cwd: &Path) -> Vec<(String, PathBuf)> {
266 let mut params = Vec::new();
267
268 // Add writable root parameters
269 let writable_roots = policy.get_writable_roots(cwd);
270
271 for (index, root) in writable_roots.iter().enumerate() {
272 let canonical = root
273 .root
274 .canonicalize()
275 .unwrap_or_else(|_| root.root.clone());
276 params.push((format!("WRITABLE_ROOT_{index}"), canonical));
277
278 // Add parameters for read-only subpaths
279 for (subpath_index, subpath) in root.read_only_subpaths.iter().enumerate() {
280 let canonical_subpath = subpath.canonicalize().unwrap_or_else(|_| subpath.clone());
281 params.push((
282 format!("WRITABLE_ROOT_{index}_RO_{subpath_index}"),
283 canonical_subpath,
284 ));
285 }
286 }
287
288 // Add Darwin user cache directory
289 if let Some(cache_dir) = get_darwin_user_cache_dir() {
290 params.push(("DARWIN_USER_CACHE_DIR".to_string(), cache_dir));
291 } else {
292 // Fallback to a reasonable default
293 if let Ok(home) = std::env::var("HOME") {
294 params.push((
295 "DARWIN_USER_CACHE_DIR".to_string(),
296 PathBuf::from(format!("{home}/Library/Caches")),
297 ));
298 }
299 }
300
301 // Cargo home (#558): paired with the policy lines emitted by
302 // `generate_policy` when `resolve_cargo_home()` succeeds. Both helpers
303 // use the same fallback chain so the policy text and the -DKEY=VALUE
304 // params stay in sync — emit one without the other and sandbox-exec
305 // refuses to load the profile.
306 if let Some(home) = resolve_cargo_home() {
307 let canonical_home = home.canonicalize().unwrap_or_else(|_| home.clone());
308 params.push((
309 "CARGO_HOME_REGISTRY".to_string(),
310 canonical_home.join("registry"),
311 ));
312 params.push(("CARGO_HOME_GIT".to_string(), canonical_home.join("git")));
313 params.push(("CARGO_HOME".to_string(), canonical_home));
314 }
315
316 params
317 }
318
319 /// Get the Darwin user cache directory using confstr.
320 ///
321 /// This returns the per-user cache directory that macOS assigns,
322 /// typically something like /var/folders/xx/xxx.../C/
323 fn get_darwin_user_cache_dir() -> Option<PathBuf> {
324 // Use libc to call confstr for _CS_DARWIN_USER_CACHE_DIR
325 let mut buf = vec![0i8; (libc::PATH_MAX as usize) + 1];
326
327 // Safety: `buf` is a writable buffer sized to PATH_MAX + 1 for confstr.
328 let len =
329 unsafe { libc::confstr(libc::_CS_DARWIN_USER_CACHE_DIR, buf.as_mut_ptr(), buf.len()) };
330
331 if len == 0 {
332 return None;
333 }
334
335 // Convert the C string to a Rust PathBuf
336 // Safety: confstr guarantees a NUL-terminated string in `buf` when len > 0.
337 let cstr = unsafe { std::ffi::CStr::from_ptr(buf.as_ptr()) };
338 let path_str = cstr.to_str().ok()?;
339 let path = PathBuf::from(path_str);
340
341 // Try to canonicalize, but return the raw path if that fails
342 path.canonicalize().ok().or(Some(path))
343 }
344
345 /// Detect sandbox denial from command output.
346 ///
347 /// Returns true if the output suggests the sandbox blocked an operation.
348 pub fn detect_denial(exit_code: i32, stderr: &str) -> bool {
349 if exit_code == 0 {
350 return false;
351 }
352
353 // Common sandbox denial messages
354 let denial_patterns = [
355 "Operation not permitted",
356 "sandbox-exec",
357 "deny(",
358 "Sandbox: ",
359 ];
360
361 denial_patterns.iter().any(|p| stderr.contains(p))
362 }
363
364 #[cfg(test)]
365 mod tests {
366 use super::*;
367
368 /// Serializes tests that mutate process-global env vars (HOME, CARGO_HOME)
369 /// so they don't race with each other or with sibling tests in this
370 /// crate that read those vars. Mirrors the pattern in main.rs::tests
371 /// (commit d06eaed0).
372 static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
373
374 #[test]
375 fn test_is_available() {
376 // This test just checks the function doesn't panic
377 // On macOS it should return true, on other platforms false
378 let _ = is_available();
379 }
380
381 #[test]
382 fn test_generate_policy_default() {
383 let policy = SandboxPolicy::default();
384 let cwd = Path::new("/tmp/test");
385 let result = generate_policy(&policy, cwd);
386
387 assert!(result.contains("(version 1)"));
388 assert!(result.contains("(deny default)"));
389 assert!(result.contains("(allow file-read*)"));
390 assert!(result.contains("file-write*"));
391 // Default policy has no network
392 assert!(!result.contains("network-outbound"));
393 }
394
395 #[test]
396 fn test_generate_policy_with_network() {
397 let policy = SandboxPolicy::workspace_with_network();
398 let cwd = Path::new("/tmp/test");
399 let result = generate_policy(&policy, cwd);
400
401 assert!(result.contains("network-outbound"));
402 assert!(result.contains("network-inbound"));
403 }
404
405 #[test]
406 fn test_generate_policy_read_only() {
407 let policy = SandboxPolicy::ReadOnly;
408 let cwd = Path::new("/tmp/test");
409 let result = generate_policy(&policy, cwd);
410
411 assert!(result.contains("(allow file-read*)"));
412 // Should not have workspace write rules
413 assert!(!result.contains("WRITABLE_ROOT"));
414 }
415
416 #[test]
417 fn test_generate_params() {
418 let policy = SandboxPolicy::default();
419 let cwd = Path::new("/tmp/test");
420 let params = generate_params(&policy, cwd);
421
422 // Should have at least the cache dir param
423 assert!(params.iter().any(|(k, _)| k == "DARWIN_USER_CACHE_DIR"));
424 }
425
426 /// #558: cargo publish reaches into ~/.cargo/registry; the seatbelt has
427 /// to allow read+write inside it. Both the policy text and the param
428 /// table must be in sync — emitting one without the other makes
429 /// sandbox-exec refuse to load the profile.
430 #[test]
431 fn test_cargo_home_paths_emitted_in_policy_and_params_when_home_set() {
432 let _guard = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
433
434 // SAFETY: HOME / CARGO_HOME are process-global. ENV_LOCK serializes
435 // all tests in this module that mutate them, and we always restore
436 // the prior value before returning.
437 let saved_home = std::env::var_os("HOME");
438 let saved_cargo = std::env::var_os("CARGO_HOME");
439 unsafe {
440 std::env::set_var("HOME", "/tmp/seatbelt-cargo-test");
441 std::env::remove_var("CARGO_HOME");
442 }
443
444 let policy = SandboxPolicy::default();
445 let cwd = Path::new("/tmp/test");
446
447 let policy_text = generate_policy(&policy, cwd);
448 assert!(policy_text.contains(r#"(allow file-read* (subpath (param "CARGO_HOME")))"#));
449 assert!(policy_text.contains("CARGO_HOME_REGISTRY"));
450 assert!(policy_text.contains("CARGO_HOME_GIT"));
451
452 let params = generate_params(&policy, cwd);
453 assert!(params.iter().any(|(k, _)| k == "CARGO_HOME"));
454 assert!(params.iter().any(|(k, _)| k == "CARGO_HOME_REGISTRY"));
455 assert!(params.iter().any(|(k, _)| k == "CARGO_HOME_GIT"));
456
457 // Read-only policy should still emit CARGO_HOME read rule but skip writes.
458 let read_only_text = generate_policy(&SandboxPolicy::ReadOnly, cwd);
459 assert!(
460 read_only_text.contains(r#"(allow file-read* (subpath (param "CARGO_HOME")))"#),
461 "read-only mode should still allow reading the cargo registry: {read_only_text}"
462 );
463 assert!(
464 !read_only_text
465 .contains(r#"(allow file-write* (subpath (param "CARGO_HOME_REGISTRY")))"#),
466 "read-only mode must NOT grant write access to the cargo registry"
467 );
468
469 // Restore.
470 // SAFETY: restoring the prior value the test stashed at entry.
471 unsafe {
472 match saved_home {
473 Some(v) => std::env::set_var("HOME", v),
474 None => std::env::remove_var("HOME"),
475 }
476 match saved_cargo {
477 Some(v) => std::env::set_var("CARGO_HOME", v),
478 None => std::env::remove_var("CARGO_HOME"),
479 }
480 }
481 }
482
483 /// #558: if neither `CARGO_HOME` nor `HOME` is set, the cargo lines and
484 /// their params must both be omitted — emitting one without the other
485 /// would crash sandbox-exec on profile load.
486 #[test]
487 fn test_cargo_home_skipped_when_no_env() {
488 let _guard = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
489
490 let saved_home = std::env::var_os("HOME");
491 let saved_cargo = std::env::var_os("CARGO_HOME");
492 // SAFETY: HOME/CARGO_HOME are process-global; ENV_LOCK serializes
493 // mutations here and we restore the prior values before returning.
494 unsafe {
495 std::env::remove_var("HOME");
496 std::env::remove_var("CARGO_HOME");
497 }
498
499 let policy = SandboxPolicy::default();
500 let cwd = Path::new("/tmp/test");
501 let policy_text = generate_policy(&policy, cwd);
502 let params = generate_params(&policy, cwd);
503
504 assert!(!policy_text.contains("CARGO_HOME"));
505 assert!(!params.iter().any(|(k, _)| k.starts_with("CARGO_HOME")));
506
507 // Restore.
508 // SAFETY: restoring the prior values the test stashed at entry.
509 unsafe {
510 match saved_home {
511 Some(v) => std::env::set_var("HOME", v),
512 None => std::env::remove_var("HOME"),
513 }
514 match saved_cargo {
515 Some(v) => std::env::set_var("CARGO_HOME", v),
516 None => std::env::remove_var("CARGO_HOME"),
517 }
518 }
519 }
520
521 #[test]
522 fn test_create_seatbelt_args() {
523 let policy = SandboxPolicy::default();
524 let cwd = Path::new("/tmp/test");
525 let command = vec!["echo".to_string(), "hello".to_string()];
526
527 let args = create_seatbelt_args(command, &policy, cwd);
528
529 // Should start with -p and the policy
530 assert_eq!(args[0], "-p");
531 assert!(args[1].contains("(version 1)"));
532
533 // Should contain the separator
534 assert!(args.contains(&"--".to_string()));
535
536 // Should end with the original command
537 assert!(args.contains(&"echo".to_string()));
538 assert!(args.contains(&"hello".to_string()));
539 }
540
541 #[test]
542 fn test_detect_denial() {
543 assert!(detect_denial(1, "Operation not permitted"));
544 assert!(detect_denial(1, "Sandbox: ls denied file-write*"));
545 assert!(!detect_denial(0, "Operation not permitted"));
546 assert!(!detect_denial(1, "File not found"));
547 }
548 }
549
549 lines RUST