1//! macOS Seatbelt sandbox implementation.
2//!
3//! Uses `sandbox_init()` from `<sandbox.h>` to apply a Seatbelt sandbox profile
4//! to the current process. Must be called after fork(), before exec().
5
6use std::ffi::{CStr, CString};
7use std::fmt::Write;
8use std::io::{Error, Result};
9use std::os::raw::c_char;
10use std::path::{Path, PathBuf};
11
12use uuid::Uuid;
13
14use crate::SandboxConfig;
15
16unsafe extern "C" {
17 fn sandbox_init(profile: *const c_char, flags: u64, errorbuf: *mut *mut c_char) -> i32;
18 fn sandbox_free_error(errorbuf: *mut c_char);
19 fn sandbox_check(pid: libc::pid_t, operation: *const c_char, filter_type: i32, ...) -> i32;
20}
21
22/// Filter type constant from `<sandbox.h>` for path-based checks.
23const SANDBOX_FILTER_PATH: i32 = 1;
24
25/// Check if a process is allowed to read a specific path under its sandbox profile.
26/// Returns `true` if the operation is ALLOWED (sandbox_check returns 0 for allowed).
27fn sandbox_check_file_read(pid: libc::pid_t, path: &Path) -> bool {
28 let operation = CString::new("file-read-data").expect("static string");
29 let path_cstr = match CString::new(path.to_string_lossy().as_bytes()) {
30 Ok(cstr) => cstr,
31 Err(_) => return false,
32 };
33 let result = unsafe {
34 sandbox_check(
35 pid,
36 operation.as_ptr(),
37 SANDBOX_FILTER_PATH,
38 path_cstr.as_ptr(),
39 )
40 };
41 result == 0
42}
43
44/// Per-session fingerprint for macOS process tracking via `sandbox_check()`.
45///
46/// Each terminal session embeds a unique fingerprint in its Seatbelt profile.
47/// The fingerprint consists of a UUID-based directory pair under /tmp where
48/// one path is allowed and a sibling is denied. This two-point test uniquely
49/// identifies processes belonging to this session.
50pub struct SessionFingerprint {
51 uuid: Uuid,
52 base_dir: PathBuf,
53 owns_directory: bool,
54}
55
56impl SessionFingerprint {
57 /// Create a new fingerprint. Creates the marker directories on disk.
58 pub fn new() -> Result<Self> {
59 let uuid = Uuid::new_v4();
60 // Use /private/tmp (the canonical path) because Seatbelt resolves
61 // symlinks — /tmp is a symlink to /private/tmp on macOS, and the
62 // SBPL rules must use the canonical path to match correctly.
63 let base_dir = PathBuf::from(format!("/private/tmp/.zed-sandbox-{uuid}"));
64 let allow_dir = base_dir.join("allow");
65 let deny_dir = base_dir.join("deny");
66 std::fs::create_dir_all(&allow_dir)?;
67 std::fs::create_dir_all(&deny_dir)?;
68 Ok(Self {
69 uuid,
70 base_dir,
71 owns_directory: true,
72 })
73 }
74
75 /// Reconstruct a fingerprint from a UUID string (used by the child process).
76 /// Does NOT create directories — assumes parent already created them.
77 pub fn from_uuid_str(uuid_str: &str) -> std::result::Result<Self, String> {
78 let uuid = Uuid::parse_str(uuid_str).map_err(|e| format!("invalid UUID: {e}"))?;
79 let base_dir = PathBuf::from(format!("/private/tmp/.zed-sandbox-{uuid}"));
80 Ok(Self {
81 uuid,
82 base_dir,
83 owns_directory: false,
84 })
85 }
86
87 /// Return the UUID as a string.
88 pub fn uuid_string(&self) -> String {
89 self.uuid.to_string()
90 }
91
92 /// Path that sandboxed processes CAN read (for fingerprint probing).
93 pub fn allow_path(&self) -> PathBuf {
94 self.base_dir.join("allow")
95 }
96
97 /// Path that sandboxed processes CANNOT read (for fingerprint probing).
98 pub fn deny_path(&self) -> PathBuf {
99 self.base_dir.join("deny")
100 }
101
102 /// Check if a given PID matches this session's fingerprint using `sandbox_check()`.
103 ///
104 /// Returns `true` if the process allows the allow-path AND denies the deny-path.
105 pub fn matches_pid(&self, pid: libc::pid_t) -> bool {
106 let allows = sandbox_check_file_read(pid, &self.allow_path());
107 let denies = !sandbox_check_file_read(pid, &self.deny_path());
108 allows && denies
109 }
110
111 /// Delete the fingerprint directory.
112 pub fn cleanup(&self) {
113 if let Err(err) = std::fs::remove_dir_all(&self.base_dir) {
114 log::warn!(
115 "Failed to clean up fingerprint directory {:?}: {err}",
116 self.base_dir
117 );
118 }
119 }
120}
121
122impl SessionFingerprint {
123 /// Kill all processes belonging to this session using the convergent scan-and-kill loop.
124 ///
125 /// 1. killpg(pgid, SIGKILL) — best-effort kill of the process group
126 /// 2. Loop: enumerate all PIDs by UID → skip zombies → filter by fingerprint → SIGKILL matches
127 /// 3. Repeat until no matches found
128 /// 4. Clean up the fingerprint directory
129 ///
130 /// This runs on a blocking thread — it's a tight loop that should complete quickly.
131 pub fn kill_all_processes(&self, process_group_id: Option<libc::pid_t>) {
132 // Step 1: Best-effort process group kill
133 if let Some(pgid) = process_group_id {
134 unsafe {
135 libc::killpg(pgid, libc::SIGKILL);
136 }
137 }
138
139 // Step 2: Convergent scan-and-kill loop
140 loop {
141 let processes = enumerate_user_processes();
142 let mut found_any = false;
143
144 for proc_info in &processes {
145 if proc_info.is_zombie {
146 continue;
147 }
148 if self.matches_pid(proc_info.pid) {
149 found_any = true;
150 unsafe {
151 libc::kill(proc_info.pid, libc::SIGKILL);
152 }
153 }
154 }
155
156 if !found_any {
157 break;
158 }
159
160 // Brief sleep to let killed processes actually die before re-scanning
161 std::thread::sleep(std::time::Duration::from_millis(5));
162 }
163
164 // Step 3: Clean up
165 self.cleanup();
166 }
167}
168
169impl Drop for SessionFingerprint {
170 fn drop(&mut self) {
171 if self.owns_directory {
172 self.cleanup();
173 }
174 }
175}
176
177/// Process info needed for cleanup.
178struct ProcInfo {
179 pid: libc::pid_t,
180 is_zombie: bool,
181}
182
183/// Enumerate all processes owned by the current UID using macOS libproc APIs.
184///
185/// Uses `proc_listallpids` to get all PIDs, then `proc_pidinfo` with
186/// `PROC_PIDTBSDINFO` to get `proc_bsdinfo` for UID filtering and zombie detection.
187fn enumerate_user_processes() -> Vec<ProcInfo> {
188 let uid = unsafe { libc::getuid() };
189
190 // First call: get the count of all processes
191 let count = unsafe { libc::proc_listallpids(std::ptr::null_mut(), 0) };
192 if count <= 0 {
193 return Vec::new();
194 }
195
196 // Allocate buffer (add 20% to handle new processes appearing between calls)
197 let buffer_count = (count as usize) + (count as usize) / 5;
198 let mut pids: Vec<libc::pid_t> = vec![0; buffer_count];
199 let buffer_size = (buffer_count * std::mem::size_of::<libc::pid_t>()) as libc::c_int;
200
201 let actual_count =
202 unsafe { libc::proc_listallpids(pids.as_mut_ptr() as *mut libc::c_void, buffer_size) };
203 if actual_count <= 0 {
204 return Vec::new();
205 }
206 pids.truncate(actual_count as usize);
207
208 // For each PID, get BSD info to check UID and zombie status
209 let mut result = Vec::new();
210 for &pid in &pids {
211 if pid <= 0 {
212 continue;
213 }
214 let mut info: libc::proc_bsdinfo = unsafe { std::mem::zeroed() };
215 let ret = unsafe {
216 libc::proc_pidinfo(
217 pid,
218 libc::PROC_PIDTBSDINFO,
219 0,
220 &mut info as *mut _ as *mut libc::c_void,
221 std::mem::size_of::<libc::proc_bsdinfo>() as libc::c_int,
222 )
223 };
224 if ret <= 0 {
225 continue;
226 }
227 if info.pbi_uid != uid {
228 continue;
229 }
230 result.push(ProcInfo {
231 pid,
232 is_zombie: info.pbi_status == libc::SZOMB,
233 });
234 }
235
236 result
237}
238
239/// Apply a compiled SBPL profile string to the current process via `sandbox_init()`.
240fn apply_profile(profile: &str) -> Result<()> {
241 let profile_cstr =
242 CString::new(profile).map_err(|_| Error::other("sandbox profile contains null byte"))?;
243 let mut errorbuf: *mut c_char = std::ptr::null_mut();
244
245 let ret = unsafe { sandbox_init(profile_cstr.as_ptr(), 0, &mut errorbuf) };
246
247 if ret == 0 {
248 return Ok(());
249 }
250
251 let msg = if !errorbuf.is_null() {
252 let s = unsafe { CStr::from_ptr(errorbuf) }
253 .to_string_lossy()
254 .into_owned();
255 unsafe { sandbox_free_error(errorbuf) };
256 s
257 } else {
258 "unknown sandbox error".to_string()
259 };
260 Err(Error::other(format!("sandbox_init failed: {msg}")))
261}
262
263/// Apply a Seatbelt sandbox profile to the current process.
264/// Must be called after fork(), before exec().
265///
266/// # Safety
267/// This function calls C FFI functions and must only be called
268/// in a pre_exec context (after fork, before exec).
269pub fn apply_sandbox(config: &SandboxConfig) -> Result<()> {
270 apply_profile(&generate_sbpl_profile(config, None))
271}
272
273/// Apply a Seatbelt sandbox profile with an embedded session fingerprint.
274pub fn apply_sandbox_with_fingerprint(
275 config: &SandboxConfig,
276 fingerprint: &SessionFingerprint,
277) -> Result<()> {
278 apply_profile(&generate_sbpl_profile(config, Some(fingerprint)))
279}
280
281/// Apply a minimal fingerprint-only Seatbelt profile (allows everything except
282/// the deny-side path, enabling process identification via `sandbox_check()`).
283pub fn apply_fingerprint_only(fingerprint: &SessionFingerprint) -> Result<()> {
284 apply_profile(&generate_fingerprint_only_profile(fingerprint))
285}
286
287/// Generate a minimal Seatbelt profile that only contains the session fingerprint.
288/// This allows everything but gives us the ability to identify the process via `sandbox_check()`.
289pub(crate) fn generate_fingerprint_only_profile(fingerprint: &SessionFingerprint) -> String {
290 let mut p = String::from("(version 1)\n(allow default)\n");
291 write!(
292 p,
293 "(deny file-read* (subpath \"{}\"))\n",
294 sbpl_escape(&fingerprint.deny_path())
295 )
296 .unwrap();
297 write!(
298 p,
299 "(allow file-read* (subpath \"{}\"))\n",
300 sbpl_escape(&fingerprint.allow_path())
301 )
302 .unwrap();
303 p
304}
305
306/// Generate an SBPL (Sandbox Profile Language) profile from the sandbox config.
307pub(crate) fn generate_sbpl_profile(
308 config: &SandboxConfig,
309 fingerprint: Option<&SessionFingerprint>,
310) -> String {
311 let mut p = String::from("(version 1)\n(deny default)\n");
312
313 // Process lifecycle
314 p.push_str("(allow process-fork)\n");
315 p.push_str("(allow signal (target children))\n");
316
317 // Mach service allowlist.
318 //
319 // TROUBLESHOOTING: If users report broken terminal behavior (e.g. DNS failures,
320 // keychain errors, or commands hanging), a missing Mach service here is a likely
321 // cause. To diagnose:
322 // 1. Open Console.app and filter for "sandbox" or "deny mach-lookup" to find
323 // the denied service name.
324 // 2. Or test interactively:
325 // sandbox-exec -p '(version 1)(deny default)(allow mach-lookup ...)' /bin/sh
326 // 3. Add the missing service to the appropriate group below.
327
328 // Logging: unified logging (os_log) and legacy syslog.
329 p.push_str("(allow mach-lookup (global-name \"com.apple.logd\"))\n");
330 p.push_str("(allow mach-lookup (global-name \"com.apple.logd.events\"))\n");
331 p.push_str("(allow mach-lookup (global-name \"com.apple.system.logger\"))\n");
332
333 // User/group directory lookups (getpwuid, getgrnam, id, etc.).
334 p.push_str("(allow mach-lookup (global-name \"com.apple.system.opendirectoryd.libinfo\"))\n");
335 p.push_str(
336 "(allow mach-lookup (global-name \"com.apple.system.opendirectoryd.membership\"))\n",
337 );
338
339 // Darwin notification center, used internally by many system frameworks.
340 p.push_str("(allow mach-lookup (global-name \"com.apple.system.notification_center\"))\n");
341
342 // CFPreferences: reading user and system preferences.
343 p.push_str("(allow mach-lookup (global-name \"com.apple.cfprefsd.agent\"))\n");
344 p.push_str("(allow mach-lookup (global-name \"com.apple.cfprefsd.daemon\"))\n");
345
346 // Temp directory management (_CS_DARWIN_USER_CACHE_DIR, etc.).
347 p.push_str("(allow mach-lookup (global-name \"com.apple.bsd.dirhelper\"))\n");
348
349 // DNS and network configuration.
350 p.push_str("(allow mach-lookup (global-name \"com.apple.dnssd.service\"))\n");
351 p.push_str(
352 "(allow mach-lookup (global-name \"com.apple.SystemConfiguration.DNSConfiguration\"))\n",
353 );
354 p.push_str("(allow mach-lookup (global-name \"com.apple.SystemConfiguration.configd\"))\n");
355 p.push_str(
356 "(allow mach-lookup (global-name \"com.apple.SystemConfiguration.NetworkInformation\"))\n",
357 );
358 p.push_str("(allow mach-lookup (global-name \"com.apple.SystemConfiguration.SCNetworkReachability\"))\n");
359 p.push_str("(allow mach-lookup (global-name \"com.apple.networkd\"))\n");
360 p.push_str("(allow mach-lookup (global-name \"com.apple.nehelper\"))\n");
361
362 // Security, keychain, and TLS certificate verification.
363 p.push_str("(allow mach-lookup (global-name \"com.apple.SecurityServer\"))\n");
364 p.push_str("(allow mach-lookup (global-name \"com.apple.trustd.agent\"))\n");
365 p.push_str("(allow mach-lookup (global-name \"com.apple.ocspd\"))\n");
366 p.push_str("(allow mach-lookup (global-name \"com.apple.security.authtrampoline\"))\n");
367
368 // Launch Services: needed for the `open` command, file-type associations,
369 // and anything that uses NSWorkspace or LaunchServices.
370 p.push_str("(allow mach-lookup (global-name \"com.apple.coreservices.launchservicesd\"))\n");
371 p.push_str("(allow mach-lookup (global-name \"com.apple.CoreServices.coreservicesd\"))\n");
372 p.push_str("(allow mach-lookup (global-name-regex #\"^com\\.apple\\.lsd\\.\" ))\n");
373
374 // Kerberos: needed in enterprise environments for authentication.
375 p.push_str("(allow mach-lookup (global-name \"com.apple.GSSCred\"))\n");
376 p.push_str("(allow mach-lookup (global-name \"org.h5l.kcm\"))\n");
377
378 // Distributed notifications: some command-line tools using Foundation may need this.
379 p.push_str(
380 "(allow mach-lookup (global-name-regex #\"^com\\.apple\\.distributed_notifications\"))\n",
381 );
382
383 p.push_str("(allow sysctl-read)\n");
384
385 // Root directory entry must be readable for path resolution (getcwd, realpath, etc.)
386 p.push_str("(allow file-read* (literal \"/\"))\n");
387 // Default shell selector symlink on macOS
388 p.push_str("(allow file-read* (subpath \"/private/var/select\"))\n");
389
390 // System executable paths (read + execute)
391 for path in &config.system_paths.executable {
392 write_subpath_rule(&mut p, path, "file-read* process-exec");
393 }
394
395 // System read-only paths
396 for path in &config.system_paths.read_only {
397 write_subpath_rule(&mut p, path, "file-read*");
398 }
399
400 // System read+write paths (devices, temp dirs, IPC)
401 for path in &config.system_paths.read_write {
402 write_subpath_rule(&mut p, path, "file-read* file-write*");
403 }
404
405 // Project directory: full access
406 write_subpath_rule(
407 &mut p,
408 &config.project_dir,
409 "file-read* file-write* process-exec",
410 );
411
412 // User-configured additional paths
413 for path in &config.additional_executable_paths {
414 write_subpath_rule(&mut p, path, "file-read* process-exec");
415 }
416 for path in &config.additional_read_only_paths {
417 write_subpath_rule(&mut p, path, "file-read*");
418 }
419 for path in &config.additional_read_write_paths {
420 write_subpath_rule(&mut p, path, "file-read* file-write*");
421 }
422
423 // User shell config files
424 if let Ok(home) = std::env::var("HOME") {
425 let home = Path::new(&home);
426 for dotfile in SandboxConfig::READ_ONLY_DOTFILES {
427 let path = home.join(dotfile);
428 if path.exists() {
429 write!(
430 p,
431 "(allow file-read* (literal \"{}\"))\n",
432 sbpl_escape(&path)
433 )
434 .unwrap();
435 }
436 }
437 for dotfile in SandboxConfig::READ_WRITE_DOTFILES {
438 let path = home.join(dotfile);
439 if path.exists() {
440 write!(
441 p,
442 "(allow file-read* file-write* (literal \"{}\"))\n",
443 sbpl_escape(&path)
444 )
445 .unwrap();
446 }
447 }
448 // XDG config directory
449 let config_dir = home.join(".config");
450 if config_dir.exists() {
451 write_subpath_rule(&mut p, &config_dir, "file-read*");
452 }
453 }
454
455 // Network
456 if config.allow_network {
457 p.push_str("(allow network-outbound)\n");
458 p.push_str("(allow network-inbound)\n");
459 p.push_str("(allow system-socket)\n");
460 }
461
462 // Session fingerprint for process tracking — must come LAST so the deny
463 // rule for the deny-side path takes priority over broader allow rules
464 // (e.g., system read_write paths that include /private/tmp).
465 if let Some(fp) = fingerprint {
466 write!(
467 p,
468 "(deny file-read* (subpath \"{}\"))\n",
469 sbpl_escape(&fp.deny_path())
470 )
471 .unwrap();
472 write!(
473 p,
474 "(allow file-read* (subpath \"{}\"))\n",
475 sbpl_escape(&fp.allow_path())
476 )
477 .unwrap();
478 }
479
480 p
481}
482
483pub(crate) fn sbpl_escape(path: &Path) -> String {
484 path.display()
485 .to_string()
486 .replace('\\', "\\\\")
487 .replace('"', "\\\"")
488}
489
490fn write_subpath_rule(p: &mut String, path: &Path, permissions: &str) {
491 write!(
492 p,
493 "(allow {permissions} (subpath \"{}\"))\n",
494 sbpl_escape(path)
495 )
496 .unwrap();
497}