sandbox_macos.rs

  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}