Implement sandbox crate with process tracking and convergent cleanup

Richard Feldman created

Extract sandbox code from the terminal crate into the dedicated sandbox crate,
adding always-on process tracking for reliable terminal cleanup.

Phase 1 - Crate extraction:
- Move sandbox_exec, sandbox_macos, sandbox_linux, sandbox_tests to sandbox crate
- Move SandboxConfig, ResolvedSystemPaths from terminal_settings to sandbox crate
- Add SandboxConfig::resolve_if_enabled() shared helper, replacing duplicated
  resolution logic in project and acp_thread crates
- Terminal crate re-exports types for backward compatibility

Phase 2 - Session fingerprint (macOS):
- Add SessionFingerprint type with UUID-based marker directories
- Add FFI bindings for sandbox_check() for process identification
- Embed fingerprint rules in SBPL profiles (placed last for correct priority)
- Add fingerprint-only profile for tracking without restrictions
- Support both profile modes in sandbox_exec_main()

Phase 3 - Convergent cleanup (macOS):
- Add process enumeration via libproc APIs (proc_listallpids + proc_pidinfo)
- Implement kill_all_processes() scan-and-kill loop: killpg then repeatedly
  enumerate by UID, skip zombies, filter by fingerprint, SIGKILL matches
- Add SessionTracker platform-agnostic wrapper
- Terminal::Drop now spawns a dedicated cleanup thread

Phase 4 - cgroups v2 (Linux):
- Add CgroupSession type under systemd user slice
- Support atomic kill via cgroup.kill with fallback to iterating cgroup.procs
- Graceful degradation when cgroups unavailable

Phase 5 - Always-on wrapper:
- TerminalBuilder::new() always creates SessionTracker and wraps shell
- SandboxExecConfig carries fingerprint_uuid, cgroup_path, tracking_only
- Disabled in test mode (test binary doesn't handle --sandbox-exec)

Phase 7 - Code review fixes:
- Change (allow signal) to (allow signal (target children))
- Propagate current_exe() error with ? instead of silent fallback
- Replace let _ = write!(...) with .unwrap()
- Add tests for additional_executable_paths and canonicalize_paths with symlinks

Change summary

Cargo.lock                               |  14 
Cargo.toml                               |   1 
code-review.md                           | 116 -----
crates/acp_thread/Cargo.toml             |   1 
crates/acp_thread/src/terminal.rs        |  13 
crates/project/Cargo.toml                |   1 
crates/project/src/terminals.rs          |  14 
crates/sandbox/Cargo.toml                |  20 +
crates/sandbox/README.md                 |   7 
crates/sandbox/plan.md                   | 254 ++++++++++++
crates/sandbox/src/cgroup.rs             | 167 ++++++++
crates/sandbox/src/sandbox.rs            | 414 ++++++++++++++++++++
crates/sandbox/src/sandbox_exec.rs       |  82 +++
crates/sandbox/src/sandbox_linux.rs      |  12 
crates/sandbox/src/sandbox_macos.rs      | 499 +++++++++++++++++++++++++
crates/sandbox/src/sandbox_tests.rs      | 509 ++++++++++++++++++++++---
crates/terminal/Cargo.toml               |   4 
crates/terminal/src/sandbox_macos.rs     | 228 -----------
crates/terminal/src/terminal.rs          | 128 ++++-
crates/terminal/src/terminal_settings.rs | 255 ------------
20 files changed, 1,984 insertions(+), 755 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -31,6 +31,7 @@ dependencies = [
  "project",
  "prompt_store",
  "rand 0.9.2",
+ "sandbox",
  "serde",
  "serde_json",
  "settings",
@@ -13197,6 +13198,7 @@ dependencies = [
  "release_channel",
  "remote",
  "rpc",
+ "sandbox",
  "schemars",
  "semver",
  "serde",
@@ -15156,6 +15158,16 @@ dependencies = [
 [[package]]
 name = "sandbox"
 version = "0.1.0"
+dependencies = [
+ "landlock",
+ "libc",
+ "log",
+ "serde",
+ "serde_json",
+ "settings_content",
+ "tempfile",
+ "uuid",
+]
 
 [[package]]
 name = "scc"
@@ -17408,13 +17420,13 @@ dependencies = [
  "futures 0.3.31",
  "gpui",
  "itertools 0.14.0",
- "landlock",
  "libc",
  "log",
  "parking_lot",
  "rand 0.9.2",
  "regex",
  "release_channel",
+ "sandbox",
  "schemars",
  "serde",
  "serde_json",

Cargo.toml 🔗

@@ -411,6 +411,7 @@ rodio = { git = "https://github.com/RustAudio/rodio", rev = "e50e726ddd0292f6ef9
 rope = { path = "crates/rope" }
 rpc = { path = "crates/rpc" }
 rules_library = { path = "crates/rules_library" }
+sandbox = { path = "crates/sandbox" }
 scheduler = { path = "crates/scheduler" }
 search = { path = "crates/search" }
 session = { path = "crates/session" }

code-review.md 🔗

@@ -5,25 +5,9 @@ Only items that are still present in the current code are included.
 
 ---
 
-## 1. `(allow signal)` is unrestricted on macOS
+## 1. Dotfile lists are incomplete
 
-**File:** `crates/terminal/src/sandbox_macos.rs`, line ~54
-
-The SBPL profile contains a bare `(allow signal)` which permits the sandboxed
-process to send signals (including SIGKILL) to any process owned by the same
-user. This should be scoped:
-
-```
-(allow signal (target self))
-```
-
-or at minimum `(target children)` if child-process signaling is needed.
-
----
-
-## 2. Dotfile lists are incomplete
-
-**Files:** `crates/terminal/src/sandbox_macos.rs` and `sandbox_linux.rs`
+**Files:** `crates/sandbox/src/sandbox_macos.rs` and `sandbox_linux.rs`
 
 The hardcoded dotfile lists cover zsh, bash, and a few generic files but miss:
 
@@ -43,9 +27,9 @@ adding history files with read-write access rather than read-only.
 
 ---
 
-## 3. `/proc/self` only gets read access on Linux
+## 2. `/proc/self` only gets read access on Linux
 
-**File:** `crates/terminal/src/sandbox_linux.rs`, line ~143
+**File:** `crates/sandbox/src/sandbox_linux.rs`, line ~132
 
 Bash process substitution (e.g., `<(command)`) creates FIFOs under
 `/proc/self/fd/`. These FIFOs need write access — the shell writes to them.
@@ -56,96 +40,12 @@ The current `fs_read()` permission may cause process substitution to fail.
 
 ---
 
-## 4. `current_exe()` failure silently falls back to `"zed"`
-
-**File:** `crates/terminal/src/terminal.rs`, line ~553
-
-```rust
-let zed_binary = std::env::current_exe().unwrap_or_else(|_| PathBuf::from("zed"));
-```
-
-If `current_exe()` fails, this falls back to `"zed"` which relies on PATH
-lookup. Inside a sandbox where PATH is restricted, this could fail in
-confusing ways — the user would see a "command not found" error with no
-indication that the sandbox wrapper binary couldn't be located.
-
-**Fix:** Propagate the error with `?` instead of silently falling back. The
-terminal builder already returns `Result`.
-
----
-
-## 5. Duplicated sandbox config resolution logic
-
-**Files:** `crates/project/src/terminals.rs` (~lines 413-435) and
-`crates/acp_thread/src/terminal.rs` (~lines 245-275)
-
-The sandbox config resolution (check feature flag → check enabled → check
-`apply_to` → fallback project dir → build config) is duplicated verbatim
-across the user-terminal and agent-terminal code paths. The only meaningful
-differences are:
-
-- Which `SandboxApplyTo` variant to match (`Terminal` vs `Tool`)
-- Where the project directory comes from
-
-**Fix:** Extract into a shared helper, e.g.:
-
-```rust
-impl SandboxConfig {
-    pub fn resolve_if_enabled(
-        sandbox_settings: &SandboxSettingsContent,
-        target: SandboxApplyTo,
-        project_dir: PathBuf,
-        cx: &App,
-    ) -> Option<Self> { ... }
-}
-```
-
----
-
-## 6. `let _ = write!(...)` suppresses errors
-
-**File:** `crates/terminal/src/sandbox_macos.rs`, lines ~191 and ~223
-
-The project `.rules` say: "Never silently discard errors with `let _ =` on
-fallible operations." While `write!` to a `String` is infallible in practice
-(the `fmt::Write` impl for `String` cannot fail), the pattern still violates
-the rule.
-
-**Fix:** Use `write!(...).unwrap()` (justified since `String` fmt::Write is
-infallible) or restructure to use `push_str` + `format!`.
-
----
-
-## 7. No test for `additional_executable_paths`
-
-**File:** `crates/terminal/src/sandbox_tests.rs`
-
-There are integration tests for `additional_read_write_paths` and
-`additional_read_only_paths`, but not for `additional_executable_paths`. A
-test should verify that a binary placed in an additional executable path can
-actually be executed by the sandboxed shell, and that binaries outside all
-allowed paths cannot.
-
----
-
-## 8. No test for `canonicalize_paths()` with symlinks
-
-**File:** `crates/terminal/src/sandbox_tests.rs`
-
-The `canonicalize_paths` function is exercised indirectly (the test helper
-calls it), but no test explicitly verifies that a symlinked project directory
-or additional path is resolved before being added to sandbox rules. A test
-could create a symlink to a temp directory, use it as the project dir, and
-verify the sandbox enforces on the real path.
-
----
-
-## 9. macOS: `$TMPDIR` grants broad access via `/var/folders`
+## 3. macOS: `$TMPDIR` grants broad access via `/var/folders`
 
-**File:** `crates/terminal/src/terminal_settings.rs` (default read-write
-paths)
+**File:** `crates/sandbox/src/sandbox.rs` (default read-write paths in
+`ResolvedSystemPaths::default_read_write`)
 
-The default macOS read-write paths include `/private/var/folders`, which is
+The default macOS read-write paths include `/var/folders`, which is
 the parent of every user's per-session temp directory. This means the sandbox
 grants read-write access to all temp files on the system, not just the
 current user's.

crates/acp_thread/Cargo.toml 🔗

@@ -38,6 +38,7 @@ image = { workspace = true, optional = true }
 portable-pty.workspace = true
 project.workspace = true
 prompt_store.workspace = true
+sandbox.workspace = true
 serde.workspace = true
 serde_json.workspace = true
 settings.workspace = true

crates/acp_thread/src/terminal.rs 🔗

@@ -256,21 +256,14 @@ pub async fn create_terminal_entity(
         });
         let settings = terminal::terminal_settings::TerminalSettings::get(settings_location, cx);
         settings.sandbox.as_ref().and_then(|sandbox| {
-            if !sandbox.enabled.unwrap_or(false) {
-                return None;
-            }
-            let apply_to = sandbox.apply_to.unwrap_or_default();
-            match apply_to {
-                settings::SandboxApplyTo::Tool | settings::SandboxApplyTo::Both => {}
-                _ => return None,
-            }
             let project_dir = cwd
                 .clone()
                 .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| ".".into()));
-            Some(terminal::terminal_settings::SandboxConfig::from_settings(
+            sandbox::SandboxConfig::resolve_if_enabled(
                 sandbox,
+                settings::SandboxApplyTo::Tool,
                 project_dir,
-            ))
+            )
         })
     });
 

crates/project/Cargo.toml 🔗

@@ -74,6 +74,7 @@ rand.workspace = true
 regex.workspace = true
 release_channel.workspace = true
 remote.workspace = true
+sandbox.workspace = true
 rpc.workspace = true
 schemars.workspace = true
 semver.workspace = true

crates/project/src/terminals.rs 🔗

@@ -414,25 +414,17 @@ impl Project {
                         if !cx.has_flag::<TerminalSandboxFeatureFlag>() {
                             return None;
                         }
-                        if !sandbox.enabled.unwrap_or(false) {
-                            return None;
-                        }
-                        let apply_to = sandbox.apply_to.unwrap_or_default();
-                        match apply_to {
-                            settings::SandboxApplyTo::Terminal | settings::SandboxApplyTo::Both => {
-                            }
-                            _ => return None,
-                        }
                         let project_dir = local_path
                             .as_ref()
                             .map(|p| p.to_path_buf())
                             .unwrap_or_else(|| {
                                 std::env::current_dir().unwrap_or_else(|_| ".".into())
                             });
-                        Some(terminal::terminal_settings::SandboxConfig::from_settings(
+                        sandbox::SandboxConfig::resolve_if_enabled(
                             sandbox,
+                            settings::SandboxApplyTo::Terminal,
                             project_dir,
-                        ))
+                        )
                     });
 
                     anyhow::Ok(TerminalBuilder::new(

crates/sandbox/Cargo.toml 🔗

@@ -12,3 +12,23 @@ workspace = true
 [lib]
 path = "src/sandbox.rs"
 doctest = false
+
+[dependencies]
+log.workspace = true
+serde.workspace = true
+serde_json.workspace = true
+settings_content.workspace = true
+
+[target.'cfg(target_os = "macos")'.dependencies]
+uuid.workspace = true
+
+[target.'cfg(unix)'.dependencies]
+libc.workspace = true
+
+[target.'cfg(target_os = "linux")'.dependencies]
+landlock = "0.4"
+uuid.workspace = true
+
+[dev-dependencies]
+settings_content.workspace = true
+tempfile.workspace = true

crates/sandbox/README.md 🔗

@@ -187,6 +187,13 @@ unrelated to ours. The only reliable way to find these escapees is the
 fingerprint probe, which works regardless of process group, session, or parent
 relationship.
 
+**Zombie handling:** After `SIGKILL`, a process becomes a zombie until its
+parent reaps it. If `sandbox_check` still reports the sandbox profile for
+zombies, the loop could spin on unkillable processes. The scan should skip
+processes in the zombie state (detectable via `kinfo_proc.kp_proc.p_stat ==
+SZOMB` from the same `sysctl` call used for enumeration). Zombies are harmless
+— they can't execute code or fork — so skipping them is correct.
+
 **Residual race:** Between discovering a process (step 3) and killing it (step
 4), the process could fork. But the child inherits the fingerprint, so the next
 iteration of the loop finds it. The loop continues until no such children

crates/sandbox/plan.md 🔗

@@ -0,0 +1,254 @@
+<!-- DO NOT CHECK IN OR DELETE THIS FILE. It is a working plan for the sandbox implementation. -->
+
+# Sandbox implementation plan
+
+See `README.md` for the design rationale behind each decision here.
+
+## Phase 1: Sandbox crate extraction
+
+Move sandbox code out of the `terminal` crate into the `sandbox` crate so
+that process-tracking logic has a proper home and can be used by both the
+terminal spawn path and the cleanup path.
+
+**1.1 Move existing sandbox modules**
+
+Move the following files from `crates/terminal/src/` to `crates/sandbox/src/`:
+- `sandbox_exec.rs` → entry point for `--sandbox-exec`
+- `sandbox_macos.rs` → Seatbelt SBPL generation and application
+- `sandbox_linux.rs` → Landlock implementation
+- `sandbox_tests.rs` → tests
+
+Update `crates/terminal/Cargo.toml` to depend on `sandbox`, and update
+`terminal.rs` to re-export or delegate to the sandbox crate.
+
+**1.2 Move `SandboxConfig` and related types**
+
+Move `SandboxConfig`, `ResolvedSystemPaths`, and `SandboxConfig::from_settings`
+from `terminal_settings.rs` into the sandbox crate. The terminal crate
+re-exports these types for backward compatibility.
+
+**1.3 Extract shared sandbox resolution logic**
+
+The sandbox config resolution logic is currently duplicated between
+`crates/project/src/terminals.rs` and `crates/acp_thread/src/terminal.rs`.
+Extract this into a shared helper on `SandboxConfig` (or a new function in the
+sandbox crate) that both call sites use. This addresses code review item #5.
+
+## Phase 2: Session fingerprint (macOS)
+
+Implement the sandbox fingerprint mechanism so that every terminal session's
+processes can be reliably identified via `sandbox_check()`.
+
+**2.1 Add `SessionFingerprint` type**
+
+Create a `SessionFingerprint` struct that generates and manages the per-session
+UUID marker:
+
+- `SessionFingerprint::new()` — generates a UUID, creates
+  `/tmp/.zed-sandbox-<uuid>/allow/` and the parent directory (but not
+  `/tmp/.zed-sandbox-<uuid>/deny/`)
+- `SessionFingerprint::matches_pid(pid) -> bool` — probes the process with
+  `sandbox_check()` using the two-point allow/deny test
+- `SessionFingerprint::cleanup()` — deletes the temporary directory
+
+**2.2 Add FFI bindings for `sandbox_check`**
+
+Add `extern "C"` declarations for `sandbox_check()` and the
+`SANDBOX_FILTER_PATH` constant to `sandbox_macos.rs`. These are declared in
+`<sandbox.h>`.
+
+**2.3 Embed fingerprint in SBPL profiles**
+
+Modify `generate_sbpl_profile()` in `sandbox_macos.rs` to accept a
+`SessionFingerprint` and emit the allow/deny rules for the marker paths.
+
+**2.4 Add fingerprint-only SBPL profile**
+
+Add a new function (e.g., `generate_fingerprint_only_profile()`) that produces
+a minimal profile:
+
+```
+(version 1)
+(allow default)
+(deny file-read* (subpath "/tmp/.zed-sandbox-<uuid>/deny"))
+(allow file-read* (subpath "/tmp/.zed-sandbox-<uuid>/allow"))
+```
+
+This is used when no sandbox restrictions are configured but process tracking
+is still needed.
+
+**2.5 Support both profile modes in `sandbox_exec_main()`**
+
+Modify `sandbox_exec_main()` so that it can apply either a full restrictive
+profile or a fingerprint-only profile, depending on what config it receives.
+The actual plumbing to always invoke the wrapper (even without sandbox
+restrictions) happens in Phase 5, after Linux cgroup support is also in place.
+
+## Phase 3: Convergent cleanup (macOS)
+
+Replace the current `Drop` cleanup (100ms timer + `kill_child_process`) with
+the convergent scan-and-kill loop.
+
+**3.1 Add process enumeration**
+
+Add a function that enumerates all PIDs owned by the current UID using
+`sysctl` with `KERN_PROC_UID`. This returns a `Vec<pid_t>`.
+
+**3.2 Implement the cleanup loop**
+
+Add a `SessionFingerprint::kill_all_processes()` method that implements:
+
+1. `killpg(pgid, SIGKILL)` (best-effort, the group may already be gone) —
+   kills the majority of descendants instantly
+2. Loop: enumerate all PIDs by UID (via `sysctl` `KERN_PROC_UID`) → skip
+   zombies (`kp_proc.p_stat == SZOMB`) → filter by fingerprint match →
+   `SIGKILL` every match → repeat until no matches found
+3. Delete the fingerprint directory
+
+This runs on a background thread (not async — it's a tight loop that should
+complete quickly).
+
+Note: zombie processes must be skipped because they can't be killed by any
+signal (they're already dead, awaiting reaping by their parent). If
+`sandbox_check` still reports the sandbox profile for zombies, failing to skip
+them would cause the loop to spin. The zombie state is detectable from the
+same `sysctl` data used for enumeration.
+
+**3.3 Integrate into `Terminal::Drop`**
+
+Replace the current `Drop` implementation. Instead of the 100ms timer +
+`kill_child_process()`, spawn a background task that runs
+`fingerprint.kill_all_processes()`. The fingerprint is stored alongside the
+`PtyProcessInfo` in `TerminalType::Pty`.
+
+Also update `kill_active_task()` to use the same mechanism.
+
+Note: the cleanup task must complete even if Zed is exiting. The current `Drop`
+impl uses `detach()`, which risks the task being cancelled if the executor
+shuts down. Consider blocking briefly in `Drop` or using a mechanism that
+guarantees completion (e.g., a dedicated cleanup thread that outlives the
+executor).
+
+**3.4 Wire fingerprint through terminal creation**
+
+- `TerminalBuilder::new()` creates the `SessionFingerprint` and passes it to
+  the sandbox wrapper.
+- The fingerprint is stored in `TerminalType::Pty` alongside `info` and
+  `pty_tx`.
+- On drop, the fingerprint is moved into the cleanup task.
+
+## Phase 4: cgroups v2 (Linux)
+
+Implement cgroup-based process tracking for Linux, providing the same
+always-on process-lifetime guarantee.
+
+**4.1 Add cgroup session management**
+
+Add a `CgroupSession` type (Linux-only) that:
+
+- `CgroupSession::new()` — creates a new cgroup under the user's systemd
+  slice (e.g.,
+  `/sys/fs/cgroup/user.slice/user-<uid>.slice/user@<uid>.service/zed-terminal-<uuid>.scope`)
+  by writing to the cgroup filesystem
+- `CgroupSession::add_process(pid)` — writes the PID to `cgroup.procs`
+- `CgroupSession::kill_all()` — writes `1` to `cgroup.freeze`, then writes
+  `SIGKILL` to `cgroup.kill` (kernel 5.14+), or falls back to reading
+  `cgroup.procs` and killing each PID
+- `CgroupSession::cleanup()` — removes the cgroup directory
+
+**4.2 Integrate into sandbox exec**
+
+Modify the `--sandbox-exec` entry point on Linux to accept a cgroup path.
+Before exec-ing the real shell, the wrapper moves itself into the specified
+cgroup (by writing its own PID to `cgroup.procs`). All descendants
+automatically inherit cgroup membership.
+
+**4.3 Integrate into terminal lifecycle**
+
+Same pattern as macOS: `TerminalBuilder::new()` creates the `CgroupSession`,
+passes the cgroup path to the sandbox wrapper, stores the session in
+`TerminalType::Pty`, and uses it for cleanup in `Drop`.
+
+**4.4 Fallback for old kernels**
+
+If cgroup creation fails (old kernel, cgroups v2 not mounted, no permission),
+fall back to the current `killpg` + `kill_child_process` behavior. Log a
+warning so the user knows process tracking is degraded.
+
+## Phase 5: Always-on wrapper
+
+With both macOS fingerprinting (Phase 2) and Linux cgroups (Phase 4) in place,
+wire them up so the `--sandbox-exec` wrapper runs for every terminal session,
+not only when sandbox restrictions are configured.
+
+**5.1 Decouple wrapper invocation from `SandboxConfig`**
+
+Currently `TerminalBuilder::new()` only wraps the shell in `--sandbox-exec`
+when `sandbox_config.is_some()`. Change this so the wrapper is always used on
+Unix platforms. The wrapper receives either:
+- A full `SandboxExecConfig` (restrictions + fingerprint/cgroup), or
+- A tracking-only config (fingerprint on macOS, cgroup path on Linux, no
+  filesystem restrictions)
+
+Update `SandboxExecConfig` to have an optional restrictions payload and a
+required tracking payload.
+
+**5.2 Update both resolution sites**
+
+Modify `crates/project/src/terminals.rs` and `crates/acp_thread/src/terminal.rs`
+to always produce a tracking config. The sandbox restrictions remain gated
+behind the feature flag and `enabled` setting, but the tracking config is
+unconditional.
+
+**5.3 Update `--sandbox-exec` entry point**
+
+Modify `sandbox_exec_main()` to handle the tracking-only case:
+- On macOS: apply the fingerprint-only Seatbelt profile (no restrictions)
+- On Linux: move into the cgroup (no Landlock restrictions)
+- Then exec the real shell as before
+
+## Phase 6: Tests
+
+**6.1 Fingerprint tests (macOS)**
+
+- Test that `SessionFingerprint::matches_pid()` returns true for a process
+  launched with the session's Seatbelt profile.
+- Test that it returns false for an unsandboxed process.
+- Test that it returns false for a process with a different session's profile.
+- Test the two-point fingerprint: a process with blanket `/tmp` access does
+  not match.
+
+**6.2 Convergent cleanup tests (macOS)**
+
+- Test that a simple child process is killed.
+- Test that a process that calls `setsid()` is still found and killed.
+- Test that a double-forking daemon (fork → setsid → fork → parent exits) is
+  still found and killed.
+- Test that the loop terminates.
+
+**6.3 Cgroup tests (Linux)**
+
+- Test that `CgroupSession::kill_all()` kills a child process.
+- Test that a process that calls `setsid()` is still killed (it's in the
+  cgroup).
+- Test the fallback path when cgroups are unavailable.
+
+**6.4 Fingerprint-only mode tests (macOS)**
+
+- Test that a terminal spawned without sandbox restrictions still gets the
+  fingerprint profile applied.
+- Test that cleanup works correctly in fingerprint-only mode.
+- Test that the process is not restricted (can access arbitrary paths, use
+  network, etc.).
+
+## Phase 7: Cleanup of existing code review items
+
+With the new architecture in place, address the remaining items from the code
+review that haven't been handled by earlier phases:
+
+- **Item #1**: Change `(allow signal)` to `(allow signal (target children))`.
+- **Item #4**: Change `current_exe()` fallback to propagate the error with `?`.
+- **Item #6**: Replace `let _ = write!(...)` with `push_str` + `format!` or
+  `.unwrap()`.
+- **Items #7, #8**: Add tests for `additional_executable_paths` and
+  `canonicalize_paths()` with symlinks.

crates/sandbox/src/cgroup.rs 🔗

@@ -0,0 +1,167 @@
+//! cgroups v2 session management for Linux process tracking.
+//!
+//! Each terminal session gets its own cgroup, providing an inescapable
+//! mechanism for tracking and killing all descendant processes.
+
+use std::fs;
+use std::io;
+use std::path::{Path, PathBuf};
+
+/// A cgroup v2 session for tracking processes spawned by a terminal.
+///
+/// All processes in the cgroup can be killed atomically, regardless of
+/// `setsid()`, `setpgid()`, or reparenting.
+pub struct CgroupSession {
+    cgroup_path: PathBuf,
+    owns_cgroup: bool,
+}
+
+impl CgroupSession {
+    /// Create a new cgroup under the user's systemd slice.
+    ///
+    /// The cgroup is created at:
+    /// `/sys/fs/cgroup/user.slice/user-<uid>.slice/user@<uid>.service/zed-terminal-<uuid>.scope`
+    pub fn new() -> io::Result<Self> {
+        let uid = unsafe { libc::getuid() };
+        let uuid = uuid::Uuid::new_v4();
+        let scope_name = format!("zed-terminal-{uuid}.scope");
+
+        // Try the systemd user slice first
+        let user_slice = format!("/sys/fs/cgroup/user.slice/user-{uid}.slice/user@{uid}.service");
+
+        let cgroup_path = if Path::new(&user_slice).exists() {
+            let path = PathBuf::from(&user_slice).join(&scope_name);
+            fs::create_dir(&path)?;
+            path
+        } else {
+            // Fallback: try directly under the current process's cgroup
+            let self_cgroup = read_self_cgroup()?;
+            let parent = PathBuf::from("/sys/fs/cgroup").join(self_cgroup.trim_start_matches('/'));
+            if !parent.exists() {
+                return Err(io::Error::other(
+                    "cgroups v2 not available: cannot find parent cgroup",
+                ));
+            }
+            let path = parent.join(&scope_name);
+            fs::create_dir(&path)?;
+            path
+        };
+
+        Ok(Self {
+            cgroup_path,
+            owns_cgroup: true,
+        })
+    }
+
+    /// Reconstruct a CgroupSession from a path string (used by the child process).
+    /// Does NOT create the cgroup — assumes the parent already created it.
+    pub fn from_path(path: &str) -> Self {
+        Self {
+            cgroup_path: PathBuf::from(path),
+            owns_cgroup: false,
+        }
+    }
+
+    /// Returns the cgroup filesystem path.
+    pub fn path(&self) -> &Path {
+        &self.cgroup_path
+    }
+
+    /// Returns the cgroup path as a string for serialization.
+    pub fn path_string(&self) -> String {
+        self.cgroup_path.to_string_lossy().into_owned()
+    }
+
+    /// Move a process into this cgroup by writing its PID to cgroup.procs.
+    pub fn add_process(&self, pid: libc::pid_t) -> io::Result<()> {
+        let procs_path = self.cgroup_path.join("cgroup.procs");
+        fs::write(&procs_path, pid.to_string().as_bytes())
+    }
+
+    /// Kill all processes in the cgroup.
+    ///
+    /// Tries the atomic `cgroup.kill` interface first (kernel 5.14+),
+    /// falling back to reading cgroup.procs and killing each PID.
+    pub fn kill_all(&self) -> io::Result<()> {
+        // Step 1: Freeze the cgroup to prevent new forks
+        let freeze_path = self.cgroup_path.join("cgroup.freeze");
+        if freeze_path.exists() {
+            if let Err(err) = fs::write(&freeze_path, b"1") {
+                log::debug!("Failed to freeze cgroup: {err}");
+            }
+        }
+
+        // Step 2: Try atomic kill via cgroup.kill (kernel 5.14+)
+        let kill_path = self.cgroup_path.join("cgroup.kill");
+        if kill_path.exists() {
+            if fs::write(&kill_path, b"1").is_ok() {
+                return Ok(());
+            }
+        }
+
+        // Step 3: Fallback — read cgroup.procs and kill each PID
+        let procs_path = self.cgroup_path.join("cgroup.procs");
+        loop {
+            let content = fs::read_to_string(&procs_path)?;
+            let pids: Vec<libc::pid_t> = content
+                .lines()
+                .filter_map(|line| line.trim().parse().ok())
+                .collect();
+
+            if pids.is_empty() {
+                break;
+            }
+
+            for pid in &pids {
+                unsafe {
+                    libc::kill(*pid, libc::SIGKILL);
+                }
+            }
+
+            // Brief sleep to let processes die before re-scanning
+            std::thread::sleep(std::time::Duration::from_millis(10));
+        }
+
+        Ok(())
+    }
+
+    /// Remove the cgroup directory. Must be called after all processes are dead.
+    pub fn cleanup(&self) {
+        if let Err(err) = fs::remove_dir(&self.cgroup_path) {
+            log::warn!(
+                "Failed to remove cgroup directory {:?}: {err}",
+                self.cgroup_path
+            );
+        }
+    }
+
+    /// Kill all processes and clean up the cgroup.
+    pub fn kill_all_and_cleanup(&self) {
+        if let Err(err) = self.kill_all() {
+            log::warn!("Failed to kill cgroup processes: {err}");
+        }
+        self.cleanup();
+    }
+}
+
+impl Drop for CgroupSession {
+    fn drop(&mut self) {
+        if self.owns_cgroup {
+            self.kill_all_and_cleanup();
+        }
+    }
+}
+
+/// Read the current process's cgroup path from /proc/self/cgroup.
+/// For cgroups v2, the format is "0::/path".
+fn read_self_cgroup() -> io::Result<String> {
+    let content = fs::read_to_string("/proc/self/cgroup")?;
+    for line in content.lines() {
+        if let Some(path) = line.strip_prefix("0::") {
+            return Ok(path.to_string());
+        }
+    }
+    Err(io::Error::other(
+        "cgroups v2 not found in /proc/self/cgroup",
+    ))
+}

crates/sandbox/src/sandbox.rs 🔗

@@ -1,7 +1,407 @@
-// Sandbox crate — OS-level sandboxing for terminal processes.
-//
-// See README.md for design context and rationale.
-//
-// The implementation currently lives in the `terminal` crate
-// (sandbox_exec.rs, sandbox_macos.rs, sandbox_linux.rs) and will
-// be migrated here.
+#[cfg(target_os = "linux")]
+mod cgroup;
+#[cfg(unix)]
+mod sandbox_exec;
+#[cfg(target_os = "linux")]
+mod sandbox_linux;
+#[cfg(target_os = "macos")]
+pub(crate) mod sandbox_macos;
+#[cfg(all(test, unix))]
+mod sandbox_tests;
+
+#[cfg(target_os = "linux")]
+pub use cgroup::CgroupSession;
+#[cfg(target_os = "macos")]
+pub use sandbox_macos::SessionFingerprint;
+
+#[cfg(unix)]
+pub use sandbox_exec::{SandboxExecConfig, sandbox_exec_main};
+
+use std::path::PathBuf;
+
+/// Resolved sandbox configuration with all defaults applied.
+/// This is the concrete type passed to the terminal spawning code.
+#[derive(Clone, Debug)]
+pub struct SandboxConfig {
+    pub project_dir: PathBuf,
+    pub system_paths: ResolvedSystemPaths,
+    pub additional_executable_paths: Vec<PathBuf>,
+    pub additional_read_only_paths: Vec<PathBuf>,
+    pub additional_read_write_paths: Vec<PathBuf>,
+    pub allow_network: bool,
+    pub allowed_env_vars: Vec<String>,
+}
+
+/// Resolved system paths with OS-specific defaults applied.
+#[derive(Clone, Debug)]
+pub struct ResolvedSystemPaths {
+    pub executable: Vec<PathBuf>,
+    pub read_only: Vec<PathBuf>,
+    pub read_write: Vec<PathBuf>,
+}
+
+impl ResolvedSystemPaths {
+    pub fn from_settings(settings: &settings_content::SystemPathsSettingsContent) -> Self {
+        Self {
+            executable: settings
+                .executable
+                .clone()
+                .map(|v| v.into_iter().map(PathBuf::from).collect())
+                .unwrap_or_else(Self::default_executable),
+            read_only: settings
+                .read_only
+                .clone()
+                .map(|v| v.into_iter().map(PathBuf::from).collect())
+                .unwrap_or_else(Self::default_read_only),
+            read_write: settings
+                .read_write
+                .clone()
+                .map(|v| v.into_iter().map(PathBuf::from).collect())
+                .unwrap_or_else(Self::default_read_write),
+        }
+    }
+
+    pub fn with_defaults() -> Self {
+        Self {
+            executable: Self::default_executable(),
+            read_only: Self::default_read_only(),
+            read_write: Self::default_read_write(),
+        }
+    }
+
+    #[cfg(target_os = "macos")]
+    fn default_executable() -> Vec<PathBuf> {
+        vec![
+            "/bin".into(),
+            "/usr/bin".into(),
+            "/usr/sbin".into(),
+            "/sbin".into(),
+            "/usr/lib".into(),
+            "/usr/libexec".into(),
+            "/System/Library/dyld".into(),
+            "/System/Cryptexes".into(),
+            "/Library/Developer/CommandLineTools/usr/bin".into(),
+            "/Library/Developer/CommandLineTools/usr/lib".into(),
+            "/Library/Apple/usr/bin".into(),
+            "/opt/homebrew/bin".into(),
+            "/opt/homebrew/sbin".into(),
+            "/opt/homebrew/Cellar".into(),
+            "/opt/homebrew/lib".into(),
+            "/usr/local/bin".into(),
+            "/usr/local/lib".into(),
+        ]
+    }
+
+    #[cfg(target_os = "linux")]
+    fn default_executable() -> Vec<PathBuf> {
+        vec![
+            "/usr/bin".into(),
+            "/usr/sbin".into(),
+            "/usr/lib".into(),
+            "/usr/lib64".into(),
+            "/usr/libexec".into(),
+            "/lib".into(),
+            "/lib64".into(),
+            "/bin".into(),
+            "/sbin".into(),
+        ]
+    }
+
+    #[cfg(not(any(target_os = "macos", target_os = "linux")))]
+    fn default_executable() -> Vec<PathBuf> {
+        vec![]
+    }
+
+    #[cfg(target_os = "macos")]
+    fn default_read_only() -> Vec<PathBuf> {
+        vec![
+            "/private/etc".into(),
+            "/usr/share".into(),
+            "/System/Library/Keychains".into(),
+            "/Library/Developer/CommandLineTools/SDKs".into(),
+            "/Library/Preferences/SystemConfiguration".into(),
+            "/opt/homebrew/share".into(),
+            "/opt/homebrew/etc".into(),
+            "/usr/local/share".into(),
+            "/usr/local/etc".into(),
+        ]
+    }
+
+    #[cfg(target_os = "linux")]
+    fn default_read_only() -> Vec<PathBuf> {
+        vec![
+            "/etc".into(),
+            "/usr/share".into(),
+            "/usr/include".into(),
+            "/usr/lib/locale".into(),
+        ]
+    }
+
+    #[cfg(not(any(target_os = "macos", target_os = "linux")))]
+    fn default_read_only() -> Vec<PathBuf> {
+        vec![]
+    }
+
+    #[cfg(target_os = "macos")]
+    fn default_read_write() -> Vec<PathBuf> {
+        vec![
+            "/dev".into(),
+            "/private/tmp".into(),
+            "/var/folders".into(),
+            "/private/var/run/mDNSResponder".into(),
+        ]
+    }
+
+    #[cfg(target_os = "linux")]
+    fn default_read_write() -> Vec<PathBuf> {
+        vec![
+            "/dev".into(),
+            "/tmp".into(),
+            "/var/tmp".into(),
+            "/dev/shm".into(),
+            "/run/user".into(),
+        ]
+    }
+
+    #[cfg(not(any(target_os = "macos", target_os = "linux")))]
+    fn default_read_write() -> Vec<PathBuf> {
+        vec![]
+    }
+}
+
+impl SandboxConfig {
+    /// Default environment variables to pass through to sandboxed terminals.
+    pub fn default_allowed_env_vars() -> Vec<String> {
+        vec![
+            "PATH".into(),
+            "HOME".into(),
+            "USER".into(),
+            "SHELL".into(),
+            "LANG".into(),
+            "TERM".into(),
+            "TERM_PROGRAM".into(),
+            "CARGO_HOME".into(),
+            "RUSTUP_HOME".into(),
+            "GOPATH".into(),
+            "EDITOR".into(),
+            "VISUAL".into(),
+            "XDG_CONFIG_HOME".into(),
+            "XDG_DATA_HOME".into(),
+            "XDG_RUNTIME_DIR".into(),
+            "SSH_AUTH_SOCK".into(),
+            "GPG_TTY".into(),
+            "COLORTERM".into(),
+        ]
+    }
+
+    /// Resolve a `SandboxConfig` from settings, applying all defaults.
+    pub fn from_settings(
+        sandbox_settings: &settings_content::SandboxSettingsContent,
+        project_dir: PathBuf,
+    ) -> Self {
+        let system_paths = sandbox_settings
+            .system_paths
+            .as_ref()
+            .map(|sp| ResolvedSystemPaths::from_settings(sp))
+            .unwrap_or_else(ResolvedSystemPaths::with_defaults);
+
+        let home_dir = std::env::var("HOME").ok().map(PathBuf::from);
+        let expand_paths = |paths: &Option<Vec<String>>| -> Vec<PathBuf> {
+            paths
+                .as_ref()
+                .map(|v| {
+                    v.iter()
+                        .map(|p| {
+                            if let Some(rest) = p.strip_prefix("~/") {
+                                if let Some(ref home) = home_dir {
+                                    return home.join(rest);
+                                }
+                            }
+                            PathBuf::from(p)
+                        })
+                        .collect()
+                })
+                .unwrap_or_default()
+        };
+
+        Self {
+            project_dir,
+            system_paths,
+            additional_executable_paths: expand_paths(
+                &sandbox_settings.additional_executable_paths,
+            ),
+            additional_read_only_paths: expand_paths(&sandbox_settings.additional_read_only_paths),
+            additional_read_write_paths: expand_paths(
+                &sandbox_settings.additional_read_write_paths,
+            ),
+            allow_network: sandbox_settings.allow_network.unwrap_or(true),
+            allowed_env_vars: sandbox_settings
+                .allowed_env_vars
+                .clone()
+                .unwrap_or_else(Self::default_allowed_env_vars),
+        }
+    }
+
+    /// Resolve sandbox config from settings if enabled and applicable for the given target.
+    ///
+    /// The caller is responsible for checking feature flags before calling this.
+    /// `target` should be `SandboxApplyTo::Terminal` for user terminals or
+    /// `SandboxApplyTo::Tool` for agent terminal tools.
+    pub fn resolve_if_enabled(
+        sandbox_settings: &settings_content::SandboxSettingsContent,
+        target: settings_content::SandboxApplyTo,
+        project_dir: PathBuf,
+    ) -> Option<Self> {
+        if !sandbox_settings.enabled.unwrap_or(false) {
+            return None;
+        }
+        let apply_to = sandbox_settings.apply_to.unwrap_or_default();
+        let applies = match target {
+            settings_content::SandboxApplyTo::Terminal => matches!(
+                apply_to,
+                settings_content::SandboxApplyTo::Terminal | settings_content::SandboxApplyTo::Both
+            ),
+            settings_content::SandboxApplyTo::Tool => matches!(
+                apply_to,
+                settings_content::SandboxApplyTo::Tool | settings_content::SandboxApplyTo::Both
+            ),
+            settings_content::SandboxApplyTo::Both => {
+                matches!(apply_to, settings_content::SandboxApplyTo::Both)
+            }
+            settings_content::SandboxApplyTo::Neither => false,
+        };
+        if !applies {
+            return None;
+        }
+        Some(Self::from_settings(sandbox_settings, project_dir))
+    }
+
+    pub fn canonicalize_paths(&mut self) {
+        match std::fs::canonicalize(&self.project_dir) {
+            Ok(canonical) => self.project_dir = canonical,
+            Err(err) => log::warn!(
+                "Failed to canonicalize project dir {:?}: {}",
+                self.project_dir,
+                err
+            ),
+        }
+        canonicalize_path_list(&mut self.system_paths.executable);
+        canonicalize_path_list(&mut self.system_paths.read_only);
+        canonicalize_path_list(&mut self.system_paths.read_write);
+        canonicalize_path_list(&mut self.additional_executable_paths);
+        canonicalize_path_list(&mut self.additional_read_only_paths);
+        canonicalize_path_list(&mut self.additional_read_write_paths);
+    }
+}
+
+fn try_canonicalize(path: &mut PathBuf) {
+    if let Ok(canonical) = std::fs::canonicalize(&*path) {
+        *path = canonical;
+    }
+}
+
+fn canonicalize_path_list(paths: &mut Vec<PathBuf>) {
+    for path in paths.iter_mut() {
+        try_canonicalize(path);
+    }
+}
+
+/// Platform-specific session tracker for process lifetime management.
+///
+/// On macOS, uses Seatbelt fingerprinting with `sandbox_check()`.
+/// On Linux, uses cgroups v2.
+/// On other platforms, tracking is not available.
+#[cfg(target_os = "macos")]
+pub struct SessionTracker {
+    pub(crate) fingerprint: sandbox_macos::SessionFingerprint,
+}
+
+#[cfg(target_os = "linux")]
+pub struct SessionTracker {
+    pub(crate) cgroup: Option<cgroup::CgroupSession>,
+}
+
+#[cfg(not(any(target_os = "macos", target_os = "linux")))]
+pub struct SessionTracker;
+
+#[cfg(target_os = "macos")]
+impl SessionTracker {
+    /// Create a new session tracker. On macOS, creates a SessionFingerprint.
+    pub fn new() -> std::io::Result<Self> {
+        Ok(Self {
+            fingerprint: sandbox_macos::SessionFingerprint::new()?,
+        })
+    }
+
+    /// Get the fingerprint UUID string for passing to the child process.
+    pub fn fingerprint_uuid(&self) -> Option<String> {
+        Some(self.fingerprint.uuid_string())
+    }
+
+    /// Get the cgroup path for passing to the child process (macOS: always None).
+    pub fn cgroup_path(&self) -> Option<String> {
+        None
+    }
+
+    /// Kill all processes belonging to this session.
+    pub fn kill_all_processes(&self, process_group_id: Option<libc::pid_t>) {
+        self.fingerprint.kill_all_processes(process_group_id);
+    }
+}
+
+#[cfg(target_os = "linux")]
+impl SessionTracker {
+    /// Create a new session tracker. On Linux, creates a CgroupSession.
+    pub fn new() -> std::io::Result<Self> {
+        match cgroup::CgroupSession::new() {
+            Ok(cgroup) => Ok(Self {
+                cgroup: Some(cgroup),
+            }),
+            Err(err) => {
+                log::warn!("Failed to create cgroup session, process tracking degraded: {err}");
+                Ok(Self { cgroup: None })
+            }
+        }
+    }
+
+    /// Get the fingerprint UUID string (Linux: always None).
+    pub fn fingerprint_uuid(&self) -> Option<String> {
+        None
+    }
+
+    /// Get the cgroup path for passing to the child process.
+    pub fn cgroup_path(&self) -> Option<String> {
+        self.cgroup.as_ref().map(|c| c.path_string())
+    }
+
+    /// Kill all processes belonging to this session.
+    pub fn kill_all_processes(&self, process_group_id: Option<libc::pid_t>) {
+        // Best-effort process group kill first
+        if let Some(pgid) = process_group_id {
+            unsafe {
+                libc::killpg(pgid, libc::SIGKILL);
+            }
+        }
+
+        if let Some(ref cgroup) = self.cgroup {
+            cgroup.kill_all_and_cleanup();
+        }
+    }
+}
+
+#[cfg(not(any(target_os = "macos", target_os = "linux")))]
+impl SessionTracker {
+    pub fn new() -> std::io::Result<Self> {
+        Ok(Self)
+    }
+
+    pub fn fingerprint_uuid(&self) -> Option<String> {
+        None
+    }
+
+    pub fn cgroup_path(&self) -> Option<String> {
+        None
+    }
+
+    pub fn kill_all_processes(&self, _process_group_id: Option<libc::pid_t>) {}
+}

crates/terminal/src/sandbox_exec.rs → crates/sandbox/src/sandbox_exec.rs 🔗

@@ -17,7 +17,7 @@
 //! `std::process::Command::arg()` passes arguments to `execve` without
 //! shell interpretation, so no quoting issues arise.
 
-use crate::terminal_settings::SandboxConfig;
+use crate::{ResolvedSystemPaths, SandboxConfig};
 use std::os::unix::process::CommandExt;
 use std::process::Command;
 
@@ -33,6 +33,15 @@ pub struct SandboxExecConfig {
     pub additional_read_write_paths: Vec<String>,
     pub allow_network: bool,
     pub allowed_env_vars: Vec<String>,
+    /// Optional fingerprint UUID for session tracking (macOS).
+    #[serde(default)]
+    pub fingerprint_uuid: Option<String>,
+    /// Whether this is a tracking-only config (no filesystem restrictions).
+    #[serde(default)]
+    pub tracking_only: bool,
+    /// Optional cgroup path for Linux process tracking.
+    #[serde(default)]
+    pub cgroup_path: Option<String>,
 }
 
 impl SandboxExecConfig {
@@ -75,6 +84,9 @@ impl SandboxExecConfig {
                 .collect(),
             allow_network: config.allow_network,
             allowed_env_vars: config.allowed_env_vars.clone(),
+            fingerprint_uuid: None,
+            tracking_only: false,
+            cgroup_path: None,
         }
     }
 
@@ -82,7 +94,6 @@ impl SandboxExecConfig {
     pub fn to_sandbox_config(&self) -> SandboxConfig {
         use std::path::PathBuf;
 
-        use crate::terminal_settings::ResolvedSystemPaths;
         SandboxConfig {
             project_dir: PathBuf::from(&self.project_dir),
             system_paths: ResolvedSystemPaths {
@@ -112,8 +123,6 @@ impl SandboxExecConfig {
 
     /// Serialize the config to a JSON string for passing via CLI arg.
     pub fn to_json(&self) -> String {
-        // This type is a flat struct of Strings, Vec<String>, and bool — no
-        // cycles or non-serializable types — so serialization cannot fail.
         serde_json::to_string(self).expect("SandboxExecConfig is non-cyclic")
     }
 
@@ -145,9 +154,6 @@ pub fn sandbox_exec_main(config_json: &str, shell_args: &[String]) -> ! {
     sandbox_config.canonicalize_paths();
 
     // Step 1: Collect allowed environment variables.
-    // Rather than mutating the process environment (which requires unsafe),
-    // we collect the allowed vars now and pass them via env_clear().envs()
-    // on the exec Command.
     let zed_vars = [
         "ZED_TERM",
         "TERM_PROGRAM",
@@ -165,23 +171,66 @@ pub fn sandbox_exec_main(config_json: &str, shell_args: &[String]) -> ! {
     // Step 2: Apply the OS-level sandbox.
     #[cfg(target_os = "macos")]
     {
-        if let Err(e) = crate::sandbox_macos::apply_sandbox(&sandbox_config) {
-            eprintln!("zed --sandbox-exec: failed to apply macOS sandbox: {e}");
-            std::process::exit(1);
+        if config.tracking_only {
+            if let Some(ref uuid_str) = config.fingerprint_uuid {
+                let fingerprint =
+                    match crate::sandbox_macos::SessionFingerprint::from_uuid_str(uuid_str) {
+                        Ok(fp) => fp,
+                        Err(e) => {
+                            eprintln!("zed --sandbox-exec: invalid fingerprint UUID: {e}");
+                            std::process::exit(1);
+                        }
+                    };
+                if let Err(e) = crate::sandbox_macos::apply_fingerprint_only(&fingerprint) {
+                    eprintln!("zed --sandbox-exec: failed to apply fingerprint profile: {e}");
+                    std::process::exit(1);
+                }
+            }
+        } else {
+            let result = match config.fingerprint_uuid.as_ref() {
+                Some(uuid_str) => {
+                    match crate::sandbox_macos::SessionFingerprint::from_uuid_str(uuid_str) {
+                        Ok(fingerprint) => crate::sandbox_macos::apply_sandbox_with_fingerprint(
+                            &sandbox_config,
+                            &fingerprint,
+                        ),
+                        Err(e) => {
+                            eprintln!("zed --sandbox-exec: invalid fingerprint UUID: {e}");
+                            std::process::exit(1);
+                        }
+                    }
+                }
+                None => crate::sandbox_macos::apply_sandbox(&sandbox_config),
+            };
+            if let Err(e) = result {
+                eprintln!("zed --sandbox-exec: failed to apply macOS sandbox: {e}");
+                std::process::exit(1);
+            }
         }
     }
 
     #[cfg(target_os = "linux")]
     {
-        if let Err(e) = crate::sandbox_linux::apply_sandbox(&sandbox_config) {
-            eprintln!("zed --sandbox-exec: failed to apply Linux sandbox: {e}");
-            std::process::exit(1);
+        // Move into the session cgroup for process tracking
+        if let Some(ref cgroup_path) = config.cgroup_path {
+            let session = crate::cgroup::CgroupSession::from_path(cgroup_path);
+            let pid = unsafe { libc::getpid() };
+            if let Err(e) = session.add_process(pid) {
+                eprintln!("zed --sandbox-exec: failed to join cgroup: {e}");
+                std::process::exit(1);
+            }
+        }
+
+        // Apply Landlock restrictions (only if not tracking-only)
+        if !config.tracking_only {
+            if let Err(e) = crate::sandbox_linux::apply_sandbox(&sandbox_config) {
+                eprintln!("zed --sandbox-exec: failed to apply Linux sandbox: {e}");
+                std::process::exit(1);
+            }
         }
     }
 
-    // Step 3: Exec the real shell. This replaces the current process.
-    // env_clear() starts with an empty environment, then envs() adds only
-    // the allowed variables. This avoids mutating the process environment.
+    // Step 3: Exec the real shell.
     let program = &shell_args[0];
     let args = &shell_args[1..];
     let err = Command::new(program)
@@ -190,7 +239,6 @@ pub fn sandbox_exec_main(config_json: &str, shell_args: &[String]) -> ! {
         .envs(filtered_env)
         .exec();
 
-    // exec() only returns on error
     eprintln!("zed --sandbox-exec: failed to exec {program}: {err}");
     std::process::exit(1);
 }

crates/terminal/src/sandbox_linux.rs → crates/sandbox/src/sandbox_linux.rs 🔗

@@ -10,7 +10,7 @@ use landlock::{
 use std::io::{Error, Result};
 use std::path::Path;
 
-use crate::terminal_settings::SandboxConfig;
+use crate::SandboxConfig;
 
 const TARGET_ABI: ABI = ABI::V5;
 
@@ -34,7 +34,6 @@ fn add_path_rule(
     match PathFd::new(path) {
         Ok(fd) => ruleset.add_rule(PathBeneath::new(fd, access)),
         Err(e) => {
-            // Path doesn't exist — skip it (e.g., /opt/homebrew on non-Homebrew systems)
             log::debug!(
                 "Landlock: skipping nonexistent path {}: {e}",
                 path.display()
@@ -47,8 +46,6 @@ fn add_path_rule(
 /// Apply a Landlock sandbox to the current process.
 /// Must be called after fork(), before exec().
 pub fn apply_sandbox(config: &SandboxConfig) -> Result<()> {
-    // PR_SET_NO_NEW_PRIVS is required before landlock_restrict_self.
-    // It prevents the process from gaining privileges via setuid binaries.
     let ret = unsafe { libc::prctl(libc::PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) };
     if ret != 0 {
         return Err(Error::last_os_error());
@@ -74,29 +71,24 @@ pub fn apply_sandbox(config: &SandboxConfig) -> Result<()> {
         .create()
         .map_err(|e| Error::other(format!("landlock ruleset init: {e}")))?;
 
-    // System executable paths (read + execute)
     for path in &config.system_paths.executable {
         ruleset = add_path_rule(ruleset, path, fs_read_exec())
             .map_err(|e| Error::other(format!("landlock rule: {e}")))?;
     }
 
-    // System read-only paths
     for path in &config.system_paths.read_only {
         ruleset = add_path_rule(ruleset, path, fs_read())
             .map_err(|e| Error::other(format!("landlock rule: {e}")))?;
     }
 
-    // System read+write paths
     for path in &config.system_paths.read_write {
         ruleset = add_path_rule(ruleset, path, fs_all())
             .map_err(|e| Error::other(format!("landlock rule: {e}")))?;
     }
 
-    // Project directory: full access
     ruleset = add_path_rule(ruleset, &config.project_dir, fs_all())
         .map_err(|e| Error::other(format!("landlock project rule: {e}")))?;
 
-    // User-configured paths
     for path in &config.additional_executable_paths {
         ruleset = add_path_rule(ruleset, path, fs_read_exec())
             .map_err(|e| Error::other(format!("landlock rule: {e}")))?;
@@ -110,7 +102,6 @@ pub fn apply_sandbox(config: &SandboxConfig) -> Result<()> {
             .map_err(|e| Error::other(format!("landlock rule: {e}")))?;
     }
 
-    // Shell config dotfiles: read-only
     if let Ok(home) = std::env::var("HOME") {
         let home = Path::new(&home);
         for dotfile in &[
@@ -138,7 +129,6 @@ pub fn apply_sandbox(config: &SandboxConfig) -> Result<()> {
             ruleset = add_path_rule(ruleset, &config_dir, fs_read())
                 .map_err(|e| Error::other(format!("landlock .config rule: {e}")))?;
         }
-        // /proc/self for bash process substitution
         let proc_self = Path::new("/proc/self");
         if proc_self.exists() {
             ruleset = add_path_rule(ruleset, proc_self, fs_read())

crates/sandbox/src/sandbox_macos.rs 🔗

@@ -0,0 +1,499 @@
+//! macOS Seatbelt sandbox implementation.
+//!
+//! Uses `sandbox_init()` from `<sandbox.h>` to apply a Seatbelt sandbox profile
+//! to the current process. Must be called after fork(), before exec().
+
+use std::ffi::{CStr, CString};
+use std::fmt::Write;
+use std::io::{Error, Result};
+use std::os::raw::c_char;
+use std::path::{Path, PathBuf};
+
+use uuid::Uuid;
+
+use crate::SandboxConfig;
+
+unsafe extern "C" {
+    fn sandbox_init(profile: *const c_char, flags: u64, errorbuf: *mut *mut c_char) -> i32;
+    fn sandbox_free_error(errorbuf: *mut c_char);
+    fn sandbox_check(pid: libc::pid_t, operation: *const c_char, filter_type: i32, ...) -> i32;
+}
+
+/// Filter type constant from `<sandbox.h>` for path-based checks.
+const SANDBOX_FILTER_PATH: i32 = 1;
+
+/// Check if a process is allowed to read a specific path under its sandbox profile.
+/// Returns `true` if the operation is ALLOWED (sandbox_check returns 0 for allowed).
+fn sandbox_check_file_read(pid: libc::pid_t, path: &Path) -> bool {
+    let operation = CString::new("file-read-data").expect("static string");
+    let path_cstr = match CString::new(path.to_string_lossy().as_bytes()) {
+        Ok(cstr) => cstr,
+        Err(_) => return false,
+    };
+    let result = unsafe {
+        sandbox_check(
+            pid,
+            operation.as_ptr(),
+            SANDBOX_FILTER_PATH,
+            path_cstr.as_ptr(),
+        )
+    };
+    result == 0
+}
+
+/// Per-session fingerprint for macOS process tracking via `sandbox_check()`.
+///
+/// Each terminal session embeds a unique fingerprint in its Seatbelt profile.
+/// The fingerprint consists of a UUID-based directory pair under /tmp where
+/// one path is allowed and a sibling is denied. This two-point test uniquely
+/// identifies processes belonging to this session.
+pub struct SessionFingerprint {
+    uuid: Uuid,
+    base_dir: PathBuf,
+    owns_directory: bool,
+}
+
+impl SessionFingerprint {
+    /// Create a new fingerprint. Creates the marker directories on disk.
+    pub fn new() -> Result<Self> {
+        let uuid = Uuid::new_v4();
+        // Use /private/tmp (the canonical path) because Seatbelt resolves
+        // symlinks — /tmp is a symlink to /private/tmp on macOS, and the
+        // SBPL rules must use the canonical path to match correctly.
+        let base_dir = PathBuf::from(format!("/private/tmp/.zed-sandbox-{uuid}"));
+        let allow_dir = base_dir.join("allow");
+        let deny_dir = base_dir.join("deny");
+        std::fs::create_dir_all(&allow_dir)?;
+        std::fs::create_dir_all(&deny_dir)?;
+        Ok(Self {
+            uuid,
+            base_dir,
+            owns_directory: true,
+        })
+    }
+
+    /// Reconstruct a fingerprint from a UUID string (used by the child process).
+    /// Does NOT create directories — assumes parent already created them.
+    pub fn from_uuid_str(uuid_str: &str) -> std::result::Result<Self, String> {
+        let uuid = Uuid::parse_str(uuid_str).map_err(|e| format!("invalid UUID: {e}"))?;
+        let base_dir = PathBuf::from(format!("/private/tmp/.zed-sandbox-{uuid}"));
+        Ok(Self {
+            uuid,
+            base_dir,
+            owns_directory: false,
+        })
+    }
+
+    /// Return the UUID as a string.
+    pub fn uuid_string(&self) -> String {
+        self.uuid.to_string()
+    }
+
+    /// Path that sandboxed processes CAN read (for fingerprint probing).
+    pub fn allow_path(&self) -> PathBuf {
+        self.base_dir.join("allow")
+    }
+
+    /// Path that sandboxed processes CANNOT read (for fingerprint probing).
+    pub fn deny_path(&self) -> PathBuf {
+        self.base_dir.join("deny")
+    }
+
+    /// Check if a given PID matches this session's fingerprint using `sandbox_check()`.
+    ///
+    /// Returns `true` if the process allows the allow-path AND denies the deny-path.
+    pub fn matches_pid(&self, pid: libc::pid_t) -> bool {
+        let allows = sandbox_check_file_read(pid, &self.allow_path());
+        let denies = !sandbox_check_file_read(pid, &self.deny_path());
+        allows && denies
+    }
+
+    /// Delete the fingerprint directory.
+    pub fn cleanup(&self) {
+        if let Err(err) = std::fs::remove_dir_all(&self.base_dir) {
+            log::warn!(
+                "Failed to clean up fingerprint directory {:?}: {err}",
+                self.base_dir
+            );
+        }
+    }
+}
+
+impl SessionFingerprint {
+    /// Kill all processes belonging to this session using the convergent scan-and-kill loop.
+    ///
+    /// 1. killpg(pgid, SIGKILL) — best-effort kill of the process group
+    /// 2. Loop: enumerate all PIDs by UID → skip zombies → filter by fingerprint → SIGKILL matches
+    /// 3. Repeat until no matches found
+    /// 4. Clean up the fingerprint directory
+    ///
+    /// This runs on a blocking thread — it's a tight loop that should complete quickly.
+    pub fn kill_all_processes(&self, process_group_id: Option<libc::pid_t>) {
+        // Step 1: Best-effort process group kill
+        if let Some(pgid) = process_group_id {
+            unsafe {
+                libc::killpg(pgid, libc::SIGKILL);
+            }
+        }
+
+        // Step 2: Convergent scan-and-kill loop
+        loop {
+            let processes = enumerate_user_processes();
+            let mut found_any = false;
+
+            for proc_info in &processes {
+                if proc_info.is_zombie {
+                    continue;
+                }
+                if self.matches_pid(proc_info.pid) {
+                    found_any = true;
+                    unsafe {
+                        libc::kill(proc_info.pid, libc::SIGKILL);
+                    }
+                }
+            }
+
+            if !found_any {
+                break;
+            }
+
+            // Brief sleep to let killed processes actually die before re-scanning
+            std::thread::sleep(std::time::Duration::from_millis(5));
+        }
+
+        // Step 3: Clean up
+        self.cleanup();
+    }
+}
+
+impl Drop for SessionFingerprint {
+    fn drop(&mut self) {
+        if self.owns_directory {
+            self.cleanup();
+        }
+    }
+}
+
+/// Process info needed for cleanup.
+struct ProcInfo {
+    pid: libc::pid_t,
+    is_zombie: bool,
+}
+
+/// Enumerate all processes owned by the current UID using macOS libproc APIs.
+///
+/// Uses `proc_listallpids` to get all PIDs, then `proc_pidinfo` with
+/// `PROC_PIDTBSDINFO` to get `proc_bsdinfo` for UID filtering and zombie detection.
+fn enumerate_user_processes() -> Vec<ProcInfo> {
+    let uid = unsafe { libc::getuid() };
+
+    // First call: get the count of all processes
+    let count = unsafe { libc::proc_listallpids(std::ptr::null_mut(), 0) };
+    if count <= 0 {
+        return Vec::new();
+    }
+
+    // Allocate buffer (add 20% to handle new processes appearing between calls)
+    let buffer_count = (count as usize) + (count as usize) / 5;
+    let mut pids: Vec<libc::pid_t> = vec![0; buffer_count];
+    let buffer_size = (buffer_count * std::mem::size_of::<libc::pid_t>()) as libc::c_int;
+
+    let actual_count =
+        unsafe { libc::proc_listallpids(pids.as_mut_ptr() as *mut libc::c_void, buffer_size) };
+    if actual_count <= 0 {
+        return Vec::new();
+    }
+    pids.truncate(actual_count as usize);
+
+    // For each PID, get BSD info to check UID and zombie status
+    let mut result = Vec::new();
+    for &pid in &pids {
+        if pid <= 0 {
+            continue;
+        }
+        let mut info: libc::proc_bsdinfo = unsafe { std::mem::zeroed() };
+        let ret = unsafe {
+            libc::proc_pidinfo(
+                pid,
+                libc::PROC_PIDTBSDINFO,
+                0,
+                &mut info as *mut _ as *mut libc::c_void,
+                std::mem::size_of::<libc::proc_bsdinfo>() as libc::c_int,
+            )
+        };
+        if ret <= 0 {
+            continue;
+        }
+        if info.pbi_uid != uid {
+            continue;
+        }
+        result.push(ProcInfo {
+            pid,
+            is_zombie: info.pbi_status == libc::SZOMB,
+        });
+    }
+
+    result
+}
+
+/// Apply a compiled SBPL profile string to the current process via `sandbox_init()`.
+fn apply_profile(profile: &str) -> Result<()> {
+    let profile_cstr =
+        CString::new(profile).map_err(|_| Error::other("sandbox profile contains null byte"))?;
+    let mut errorbuf: *mut c_char = std::ptr::null_mut();
+
+    let ret = unsafe { sandbox_init(profile_cstr.as_ptr(), 0, &mut errorbuf) };
+
+    if ret == 0 {
+        return Ok(());
+    }
+
+    let msg = if !errorbuf.is_null() {
+        let s = unsafe { CStr::from_ptr(errorbuf) }
+            .to_string_lossy()
+            .into_owned();
+        unsafe { sandbox_free_error(errorbuf) };
+        s
+    } else {
+        "unknown sandbox error".to_string()
+    };
+    Err(Error::other(format!("sandbox_init failed: {msg}")))
+}
+
+/// Apply a Seatbelt sandbox profile to the current process.
+/// Must be called after fork(), before exec().
+///
+/// # Safety
+/// This function calls C FFI functions and must only be called
+/// in a pre_exec context (after fork, before exec).
+pub fn apply_sandbox(config: &SandboxConfig) -> Result<()> {
+    apply_profile(&generate_sbpl_profile(config, None))
+}
+
+/// Apply a Seatbelt sandbox profile with an embedded session fingerprint.
+pub fn apply_sandbox_with_fingerprint(
+    config: &SandboxConfig,
+    fingerprint: &SessionFingerprint,
+) -> Result<()> {
+    apply_profile(&generate_sbpl_profile(config, Some(fingerprint)))
+}
+
+/// Apply a minimal fingerprint-only Seatbelt profile (allows everything except
+/// the deny-side path, enabling process identification via `sandbox_check()`).
+pub fn apply_fingerprint_only(fingerprint: &SessionFingerprint) -> Result<()> {
+    apply_profile(&generate_fingerprint_only_profile(fingerprint))
+}
+
+/// Generate a minimal Seatbelt profile that only contains the session fingerprint.
+/// This allows everything but gives us the ability to identify the process via `sandbox_check()`.
+pub(crate) fn generate_fingerprint_only_profile(fingerprint: &SessionFingerprint) -> String {
+    let mut p = String::from("(version 1)\n(allow default)\n");
+    write!(
+        p,
+        "(deny file-read* (subpath \"{}\"))\n",
+        sbpl_escape(&fingerprint.deny_path())
+    )
+    .unwrap();
+    write!(
+        p,
+        "(allow file-read* (subpath \"{}\"))\n",
+        sbpl_escape(&fingerprint.allow_path())
+    )
+    .unwrap();
+    p
+}
+
+/// Generate an SBPL (Sandbox Profile Language) profile from the sandbox config.
+pub(crate) fn generate_sbpl_profile(
+    config: &SandboxConfig,
+    fingerprint: Option<&SessionFingerprint>,
+) -> String {
+    let mut p = String::from("(version 1)\n(deny default)\n");
+
+    // Process lifecycle
+    p.push_str("(allow process-fork)\n");
+    p.push_str("(allow signal (target children))\n");
+
+    // Mach service allowlist.
+    //
+    // TROUBLESHOOTING: If users report broken terminal behavior (e.g. DNS failures,
+    // keychain errors, or commands hanging), a missing Mach service here is a likely
+    // cause. To diagnose:
+    //   1. Open Console.app and filter for "sandbox" or "deny mach-lookup" to find
+    //      the denied service name.
+    //   2. Or test interactively:
+    //      sandbox-exec -p '(version 1)(deny default)(allow mach-lookup ...)' /bin/sh
+    //   3. Add the missing service to the appropriate group below.
+
+    // Logging: unified logging (os_log) and legacy syslog.
+    p.push_str("(allow mach-lookup (global-name \"com.apple.logd\"))\n");
+    p.push_str("(allow mach-lookup (global-name \"com.apple.logd.events\"))\n");
+    p.push_str("(allow mach-lookup (global-name \"com.apple.system.logger\"))\n");
+
+    // User/group directory lookups (getpwuid, getgrnam, id, etc.).
+    p.push_str("(allow mach-lookup (global-name \"com.apple.system.opendirectoryd.libinfo\"))\n");
+    p.push_str(
+        "(allow mach-lookup (global-name \"com.apple.system.opendirectoryd.membership\"))\n",
+    );
+
+    // Darwin notification center, used internally by many system frameworks.
+    p.push_str("(allow mach-lookup (global-name \"com.apple.system.notification_center\"))\n");
+
+    // CFPreferences: reading user and system preferences.
+    p.push_str("(allow mach-lookup (global-name \"com.apple.cfprefsd.agent\"))\n");
+    p.push_str("(allow mach-lookup (global-name \"com.apple.cfprefsd.daemon\"))\n");
+
+    // Temp directory management (_CS_DARWIN_USER_CACHE_DIR, etc.).
+    p.push_str("(allow mach-lookup (global-name \"com.apple.bsd.dirhelper\"))\n");
+
+    // DNS and network configuration.
+    p.push_str("(allow mach-lookup (global-name \"com.apple.dnssd.service\"))\n");
+    p.push_str(
+        "(allow mach-lookup (global-name \"com.apple.SystemConfiguration.DNSConfiguration\"))\n",
+    );
+    p.push_str("(allow mach-lookup (global-name \"com.apple.SystemConfiguration.configd\"))\n");
+    p.push_str(
+        "(allow mach-lookup (global-name \"com.apple.SystemConfiguration.NetworkInformation\"))\n",
+    );
+    p.push_str("(allow mach-lookup (global-name \"com.apple.SystemConfiguration.SCNetworkReachability\"))\n");
+    p.push_str("(allow mach-lookup (global-name \"com.apple.networkd\"))\n");
+    p.push_str("(allow mach-lookup (global-name \"com.apple.nehelper\"))\n");
+
+    // Security, keychain, and TLS certificate verification.
+    p.push_str("(allow mach-lookup (global-name \"com.apple.SecurityServer\"))\n");
+    p.push_str("(allow mach-lookup (global-name \"com.apple.trustd.agent\"))\n");
+    p.push_str("(allow mach-lookup (global-name \"com.apple.ocspd\"))\n");
+    p.push_str("(allow mach-lookup (global-name \"com.apple.security.authtrampoline\"))\n");
+
+    // Launch Services: needed for the `open` command, file-type associations,
+    // and anything that uses NSWorkspace or LaunchServices.
+    p.push_str("(allow mach-lookup (global-name \"com.apple.coreservices.launchservicesd\"))\n");
+    p.push_str("(allow mach-lookup (global-name \"com.apple.CoreServices.coreservicesd\"))\n");
+    p.push_str("(allow mach-lookup (global-name-regex #\"^com\\.apple\\.lsd\\.\" ))\n");
+
+    // Kerberos: needed in enterprise environments for authentication.
+    p.push_str("(allow mach-lookup (global-name \"com.apple.GSSCred\"))\n");
+    p.push_str("(allow mach-lookup (global-name \"org.h5l.kcm\"))\n");
+
+    // Distributed notifications: some command-line tools using Foundation may need this.
+    p.push_str(
+        "(allow mach-lookup (global-name-regex #\"^com\\.apple\\.distributed_notifications\"))\n",
+    );
+
+    p.push_str("(allow sysctl-read)\n");
+
+    // Root directory entry must be readable for path resolution (getcwd, realpath, etc.)
+    p.push_str("(allow file-read* (literal \"/\"))\n");
+    // Default shell selector symlink on macOS
+    p.push_str("(allow file-read* (subpath \"/private/var/select\"))\n");
+
+    // System executable paths (read + execute)
+    for path in &config.system_paths.executable {
+        write_subpath_rule(&mut p, path, "file-read* process-exec");
+    }
+
+    // System read-only paths
+    for path in &config.system_paths.read_only {
+        write_subpath_rule(&mut p, path, "file-read*");
+    }
+
+    // System read+write paths (devices, temp dirs, IPC)
+    for path in &config.system_paths.read_write {
+        write_subpath_rule(&mut p, path, "file-read* file-write*");
+    }
+
+    // Project directory: full access
+    write_subpath_rule(
+        &mut p,
+        &config.project_dir,
+        "file-read* file-write* process-exec",
+    );
+
+    // User-configured additional paths
+    for path in &config.additional_executable_paths {
+        write_subpath_rule(&mut p, path, "file-read* process-exec");
+    }
+    for path in &config.additional_read_only_paths {
+        write_subpath_rule(&mut p, path, "file-read*");
+    }
+    for path in &config.additional_read_write_paths {
+        write_subpath_rule(&mut p, path, "file-read* file-write*");
+    }
+
+    // User shell config files: read-only access to $HOME dotfiles
+    if let Ok(home) = std::env::var("HOME") {
+        let home = Path::new(&home);
+        for dotfile in &[
+            ".zshrc",
+            ".zshenv",
+            ".zprofile",
+            ".zlogin",
+            ".zlogout",
+            ".bashrc",
+            ".bash_profile",
+            ".bash_login",
+            ".profile",
+            ".inputrc",
+            ".terminfo",
+            ".gitconfig",
+        ] {
+            let path = home.join(dotfile);
+            if path.exists() {
+                write!(
+                    p,
+                    "(allow file-read* (literal \"{}\"))\n",
+                    sbpl_escape(&path)
+                )
+                .unwrap();
+            }
+        }
+        // XDG config directory
+        let config_dir = home.join(".config");
+        if config_dir.exists() {
+            write_subpath_rule(&mut p, &config_dir, "file-read*");
+        }
+    }
+
+    // Network
+    if config.allow_network {
+        p.push_str("(allow network-outbound)\n");
+        p.push_str("(allow network-inbound)\n");
+        p.push_str("(allow system-socket)\n");
+    }
+
+    // Session fingerprint for process tracking — must come LAST so the deny
+    // rule for the deny-side path takes priority over broader allow rules
+    // (e.g., system read_write paths that include /private/tmp).
+    if let Some(fp) = fingerprint {
+        write!(
+            p,
+            "(deny file-read* (subpath \"{}\"))\n",
+            sbpl_escape(&fp.deny_path())
+        )
+        .unwrap();
+        write!(
+            p,
+            "(allow file-read* (subpath \"{}\"))\n",
+            sbpl_escape(&fp.allow_path())
+        )
+        .unwrap();
+    }
+
+    p
+}
+
+pub(crate) fn sbpl_escape(path: &Path) -> String {
+    path.display()
+        .to_string()
+        .replace('\\', "\\\\")
+        .replace('"', "\\\"")
+}
+
+fn write_subpath_rule(p: &mut String, path: &Path, permissions: &str) {
+    write!(
+        p,
+        "(allow {permissions} (subpath \"{}\"))\n",
+        sbpl_escape(path)
+    )
+    .unwrap();
+}

crates/terminal/src/sandbox_tests.rs → crates/sandbox/src/sandbox_tests.rs 🔗

@@ -3,9 +3,12 @@
 //! These tests exercise the real kernel sandbox (Seatbelt on macOS, Landlock on
 //! Linux) by spawning child processes and verifying OS enforcement. They do NOT
 //! use mocks.
+//!
+//! These tests use `std::process::Command::output()` rather than `smol::process`
+//! because they need `pre_exec` hooks to apply sandboxes before exec.
+#![allow(clippy::disallowed_methods)]
 
-use crate::sandbox_exec::SandboxExecConfig;
-use crate::terminal_settings::{ResolvedSystemPaths, SandboxConfig};
+use crate::{ResolvedSystemPaths, SandboxConfig, SandboxExecConfig};
 use std::collections::HashSet;
 use std::fs;
 use std::os::unix::process::CommandExt;
@@ -52,20 +55,7 @@ fn test_sandbox_config(project_dir: PathBuf) -> SandboxConfig {
 }
 
 /// Exercises the full `sandbox_exec_main` production codepath in a child
-/// process:
-///
-/// 1. `SandboxConfig` → `SandboxExecConfig` (as `terminal.rs` does)
-/// 2. Serialize to JSON (the CLI argument in production)
-/// 3. Parse JSON back (as `sandbox_exec_main` does)
-/// 4. Convert to `SandboxConfig` (as `sandbox_exec_main` does)
-/// 5. Canonicalize paths (as `sandbox_exec_main` does)
-/// 6. Filter env vars with `env_clear().envs()` (as `sandbox_exec_main` does)
-/// 7. Apply OS sandbox in `pre_exec` (as `sandbox_exec_main` does)
-/// 8. Exec `/bin/sh -c <command>` (as `sandbox_exec_main` does)
-///
-/// `extra_parent_env` injects vars into the parent environment *before*
-/// filtering, so they are subject to the same allowlist. This lets tests
-/// verify that disallowed vars are stripped.
+/// process.
 ///
 /// Returns `(success, stdout, stderr)`.
 fn run_sandboxed_command(
@@ -73,23 +63,13 @@ fn run_sandboxed_command(
     extra_parent_env: &[(&str, &str)],
     shell_command: &str,
 ) -> (bool, String, String) {
-    // Step 1: Convert to the serializable form (as terminal.rs does at spawn time)
     let exec_config = SandboxExecConfig::from_sandbox_config(config);
-
-    // Step 2: Serialize to JSON (this is what gets passed as a CLI arg)
     let config_json = exec_config.to_json();
-
-    // Step 3: Parse it back (as sandbox_exec_main does)
     let parsed = SandboxExecConfig::from_json(&config_json)
         .expect("SandboxExecConfig JSON roundtrip failed");
-
-    // Step 4: Convert back to SandboxConfig (as sandbox_exec_main does)
     let mut sandbox_config = parsed.to_sandbox_config();
-
-    // Step 5: Canonicalize paths (as sandbox_exec_main does)
     sandbox_config.canonicalize_paths();
 
-    // Step 6: Build filtered env (as sandbox_exec_main does)
     let zed_vars = [
         "ZED_TERM",
         "TERM_PROGRAM",
@@ -108,7 +88,6 @@ fn run_sandboxed_command(
         .filter(|(key, _)| allowed.contains(key.as_str()) || zed_vars.contains(&key.as_str()))
         .collect();
 
-    // Step 7+8: Build command with env_clear().envs() and pre_exec sandbox
     let mut cmd = Command::new("/bin/sh");
     cmd.arg("-c").arg(shell_command);
     cmd.current_dir(&sandbox_config.project_dir);
@@ -219,7 +198,7 @@ fn test_sandbox_exec_config_from_json_invalid() {
 
 #[test]
 fn test_sandbox_config_from_settings_defaults() {
-    let settings = settings::SandboxSettingsContent::default();
+    let settings = settings_content::SandboxSettingsContent::default();
     let config = SandboxConfig::from_settings(&settings, PathBuf::from("/projects/test"));
 
     assert_eq!(config.project_dir, PathBuf::from("/projects/test"));
@@ -232,7 +211,6 @@ fn test_sandbox_config_from_settings_defaults() {
     assert!(config.additional_read_only_paths.is_empty());
     assert!(config.additional_read_write_paths.is_empty());
 
-    // System paths should use OS-specific defaults
     assert!(!config.system_paths.executable.is_empty());
     assert!(!config.system_paths.read_only.is_empty());
     assert!(!config.system_paths.read_write.is_empty());
@@ -241,7 +219,7 @@ fn test_sandbox_config_from_settings_defaults() {
 #[test]
 fn test_sandbox_config_tilde_expansion() {
     let home = std::env::var("HOME").expect("HOME not set");
-    let settings = settings::SandboxSettingsContent {
+    let settings = settings_content::SandboxSettingsContent {
         additional_read_only_paths: Some(vec!["~/documents".into(), "/absolute/path".into()]),
         ..Default::default()
     };
@@ -258,7 +236,7 @@ fn test_sandbox_config_tilde_expansion() {
 
 #[test]
 fn test_sandbox_config_custom_allowed_env_vars() {
-    let settings = settings::SandboxSettingsContent {
+    let settings = settings_content::SandboxSettingsContent {
         allowed_env_vars: Some(vec!["CUSTOM_VAR".into()]),
         ..Default::default()
     };
@@ -268,7 +246,7 @@ fn test_sandbox_config_custom_allowed_env_vars() {
 
 #[test]
 fn test_sandbox_config_network_disabled() {
-    let settings = settings::SandboxSettingsContent {
+    let settings = settings_content::SandboxSettingsContent {
         allow_network: Some(false),
         ..Default::default()
     };
@@ -276,6 +254,85 @@ fn test_sandbox_config_network_disabled() {
     assert!(!config.allow_network);
 }
 
+// ---------------------------------------------------------------------------
+// Unit tests: SandboxConfig::resolve_if_enabled
+// ---------------------------------------------------------------------------
+
+#[test]
+fn test_resolve_if_enabled_disabled() {
+    let settings = settings_content::SandboxSettingsContent {
+        enabled: Some(false),
+        ..Default::default()
+    };
+    assert!(
+        SandboxConfig::resolve_if_enabled(
+            &settings,
+            settings_content::SandboxApplyTo::Terminal,
+            PathBuf::from("/tmp/test"),
+        )
+        .is_none()
+    );
+}
+
+#[test]
+fn test_resolve_if_enabled_terminal_matches_terminal() {
+    let settings = settings_content::SandboxSettingsContent {
+        enabled: Some(true),
+        apply_to: Some(settings_content::SandboxApplyTo::Terminal),
+        ..Default::default()
+    };
+    assert!(
+        SandboxConfig::resolve_if_enabled(
+            &settings,
+            settings_content::SandboxApplyTo::Terminal,
+            PathBuf::from("/tmp/test"),
+        )
+        .is_some()
+    );
+}
+
+#[test]
+fn test_resolve_if_enabled_terminal_does_not_match_tool() {
+    let settings = settings_content::SandboxSettingsContent {
+        enabled: Some(true),
+        apply_to: Some(settings_content::SandboxApplyTo::Terminal),
+        ..Default::default()
+    };
+    assert!(
+        SandboxConfig::resolve_if_enabled(
+            &settings,
+            settings_content::SandboxApplyTo::Tool,
+            PathBuf::from("/tmp/test"),
+        )
+        .is_none()
+    );
+}
+
+#[test]
+fn test_resolve_if_enabled_both_matches_both_targets() {
+    let settings = settings_content::SandboxSettingsContent {
+        enabled: Some(true),
+        apply_to: Some(settings_content::SandboxApplyTo::Both),
+        ..Default::default()
+    };
+    assert!(
+        SandboxConfig::resolve_if_enabled(
+            &settings,
+            settings_content::SandboxApplyTo::Terminal,
+            PathBuf::from("/tmp/test"),
+        )
+        .is_some()
+    );
+    assert!(
+        SandboxConfig::resolve_if_enabled(
+            &settings,
+            settings_content::SandboxApplyTo::Tool,
+            PathBuf::from("/tmp/test"),
+        )
+        .is_some()
+    );
+}
+
 // ---------------------------------------------------------------------------
 // Unit tests: macOS SBPL profile generation
 // ---------------------------------------------------------------------------
@@ -312,21 +369,21 @@ mod sbpl_tests {
     #[test]
     fn test_sbpl_profile_has_deny_default() {
         let config = test_sandbox_config(PathBuf::from("/tmp/project"));
-        let profile = generate_sbpl_profile(&config);
+        let profile = generate_sbpl_profile(&config, None);
         assert!(profile.contains("(deny default)"));
     }
 
     #[test]
     fn test_sbpl_profile_has_version() {
         let config = test_sandbox_config(PathBuf::from("/tmp/project"));
-        let profile = generate_sbpl_profile(&config);
+        let profile = generate_sbpl_profile(&config, None);
         assert!(profile.starts_with("(version 1)\n"));
     }
 
     #[test]
     fn test_sbpl_profile_includes_project_dir() {
         let config = test_sandbox_config(PathBuf::from("/tmp/my-project"));
-        let profile = generate_sbpl_profile(&config);
+        let profile = generate_sbpl_profile(&config, None);
         assert!(
             profile.contains("(subpath \"/tmp/my-project\")"),
             "Profile should include project dir as a subpath rule. Profile:\n{profile}"
@@ -336,8 +393,7 @@ mod sbpl_tests {
     #[test]
     fn test_sbpl_profile_includes_system_paths() {
         let config = test_sandbox_config(PathBuf::from("/tmp/project"));
-        let profile = generate_sbpl_profile(&config);
-        // At minimum, /usr/bin should be in the executable paths
+        let profile = generate_sbpl_profile(&config, None);
         assert!(
             profile.contains("(subpath \"/usr/bin\")"),
             "Profile should include /usr/bin. Profile:\n{profile}"
@@ -347,7 +403,7 @@ mod sbpl_tests {
     #[test]
     fn test_sbpl_profile_network_allowed() {
         let config = test_sandbox_config(PathBuf::from("/tmp/project"));
-        let profile = generate_sbpl_profile(&config);
+        let profile = generate_sbpl_profile(&config, None);
         assert!(profile.contains("(allow network-outbound)"));
         assert!(profile.contains("(allow network-inbound)"));
     }
@@ -356,7 +412,7 @@ mod sbpl_tests {
     fn test_sbpl_profile_network_denied() {
         let mut config = test_sandbox_config(PathBuf::from("/tmp/project"));
         config.allow_network = false;
-        let profile = generate_sbpl_profile(&config);
+        let profile = generate_sbpl_profile(&config, None);
         assert!(!profile.contains("(allow network-outbound)"));
         assert!(!profile.contains("(allow network-inbound)"));
     }
@@ -364,8 +420,7 @@ mod sbpl_tests {
     #[test]
     fn test_sbpl_profile_no_unrestricted_process_exec() {
         let config = test_sandbox_config(PathBuf::from("/tmp/project"));
-        let profile = generate_sbpl_profile(&config);
-        // Should NOT have a bare "(allow process-exec)" without a filter
+        let profile = generate_sbpl_profile(&config, None);
         let lines: Vec<&str> = profile.lines().collect();
         for line in &lines {
             if line.contains("process-exec") {
@@ -380,8 +435,7 @@ mod sbpl_tests {
     #[test]
     fn test_sbpl_profile_no_unrestricted_mach_lookup() {
         let config = test_sandbox_config(PathBuf::from("/tmp/project"));
-        let profile = generate_sbpl_profile(&config);
-        // Should NOT have a bare "(allow mach-lookup)" without a filter
+        let profile = generate_sbpl_profile(&config, None);
         let lines: Vec<&str> = profile.lines().collect();
         for line in &lines {
             if line.contains("mach-lookup") {
@@ -400,7 +454,7 @@ mod sbpl_tests {
         config.additional_read_only_paths = vec![PathBuf::from("/opt/data")];
         config.additional_read_write_paths = vec![PathBuf::from("/opt/cache")];
 
-        let profile = generate_sbpl_profile(&config);
+        let profile = generate_sbpl_profile(&config, None);
 
         assert!(
             profile.contains("(subpath \"/opt/tools/bin\")"),
@@ -415,23 +469,37 @@ mod sbpl_tests {
             "Should include additional read-write path"
         );
     }
+
+    #[test]
+    fn test_sbpl_profile_signal_scoped_to_children() {
+        let config = test_sandbox_config(PathBuf::from("/tmp/project"));
+        let profile = generate_sbpl_profile(&config, None);
+        assert!(
+            profile.contains("(allow signal (target children))"),
+            "Signal should be scoped to children. Profile:\n{profile}"
+        );
+        let lines: Vec<&str> = profile.lines().collect();
+        for line in &lines {
+            if line.contains("(allow signal") {
+                assert!(
+                    line.contains("(target children)"),
+                    "Found unscoped signal rule: {line}"
+                );
+            }
+        }
+    }
 }
 
 // ---------------------------------------------------------------------------
 // Integration tests: filesystem enforcement
 // ---------------------------------------------------------------------------
 
-/// Create a tempdir and return its canonicalized path.
-/// On macOS, /var/folders -> /private/var/folders, so we must use the
-/// canonical path for both shell commands and sandbox rules to match.
 fn canonical_tempdir() -> (tempfile::TempDir, PathBuf) {
     let dir = tempfile::tempdir().expect("failed to create temp dir");
     let canonical = dir.path().canonicalize().expect("failed to canonicalize");
     (dir, canonical)
 }
 
-/// Creates a directory with a known file for testing.
-/// Returns (dir_path, file_path).
 fn create_test_directory(base: &Path, name: &str, content: &str) -> (PathBuf, PathBuf) {
     let dir = base.join(name);
     fs::create_dir_all(&dir).expect("failed to create test directory");
@@ -447,13 +515,10 @@ fn test_sandbox_blocks_rm_rf() {
     let (project_dir, _) = create_test_directory(&base, "project", "project content");
     let (target_dir, target_file) = create_test_directory(&base, "target", "do not delete me");
 
-    // Sandboxed: rm -rf should be blocked
-    let config = test_sandbox_config(project_dir.clone());
+    let config = test_sandbox_config(project_dir);
     let cmd = format!("rm -rf {}", target_dir.display());
     let (success, _stdout, _stderr) = run_sandboxed_command(&config, &[], &cmd);
 
-    // The rm might "succeed" (exit 0) on some platforms even if individual
-    // deletes fail, or it might fail. Either way, the files should still exist.
     assert!(
         target_dir.exists() && target_file.exists(),
         "Sandboxed rm -rf should not be able to delete target directory. \
@@ -462,7 +527,6 @@ fn test_sandbox_blocks_rm_rf() {
         target_file.exists(),
     );
 
-    // Unsandboxed: rm -rf should succeed
     let (success, _, _) = run_unsandboxed_command(&format!("rm -rf {}", target_dir.display()));
     assert!(success, "Unsandboxed rm -rf should succeed");
     assert!(
@@ -479,6 +543,7 @@ fn test_sandbox_allows_writes_in_project() {
 
     let config = test_sandbox_config(project_dir.clone());
     let output_file = project_dir.join("sandbox_output.txt");
+    #[allow(clippy::redundant_clone)]
     let cmd = format!("echo 'hello from sandbox' > {}", output_file.display());
     let (success, _stdout, stderr) = run_sandboxed_command(&config, &[], &cmd);
 
@@ -503,9 +568,8 @@ fn test_sandbox_blocks_reads_outside_project() {
     let secret_content = "TOP_SECRET_DATA_12345";
     let (_, secret_file) = create_test_directory(&base, "secrets", secret_content);
 
-    let config = test_sandbox_config(project_dir.clone());
+    let config = test_sandbox_config(project_dir);
 
-    // Try to cat the secret file and capture stdout
     let cmd = format!("cat {} 2>/dev/null || true", secret_file.display());
     let (_success, stdout, _stderr) = run_sandboxed_command(&config, &[], &cmd);
 
@@ -526,7 +590,6 @@ fn test_additional_read_write_paths_grant_access() {
 
     let test_file = extra_dir.join("rw_test.txt");
 
-    // First, WITHOUT the extra path — write should fail
     let config_without = test_sandbox_config(project_dir.clone());
     let cmd = format!("echo 'written' > {}", test_file.display());
     let (_success, _stdout, _stderr) = run_sandboxed_command(&config_without, &[], &cmd);
@@ -539,9 +602,8 @@ fn test_additional_read_write_paths_grant_access() {
         "Write to extra dir should be blocked without additional_read_write_paths"
     );
 
-    // Now, WITH the extra path — write should succeed
     let mut config_with = test_sandbox_config(project_dir);
-    config_with.additional_read_write_paths = vec![extra_dir.clone()];
+    config_with.additional_read_write_paths = vec![extra_dir];
     let (success, _stdout, stderr) = run_sandboxed_command(&config_with, &[], &cmd);
     assert!(
         success,
@@ -564,9 +626,8 @@ fn test_additional_read_only_paths_allow_read_block_write() {
         create_test_directory(&base, "readonly_data", known_content);
 
     let mut config = test_sandbox_config(project_dir.clone());
-    config.additional_read_only_paths = vec![readonly_dir.clone()];
+    config.additional_read_only_paths = vec![readonly_dir];
 
-    // Read the file into the project dir — should succeed
     let output_file = project_dir.join("read_output.txt");
     let cmd = format!(
         "cat {} > {}",
@@ -584,7 +645,6 @@ fn test_additional_read_only_paths_allow_read_block_write() {
         "Should have read the known content. Got: {read_content}"
     );
 
-    // Try to overwrite the read-only file — should fail
     let cmd = format!("echo 'overwritten' > {}", readonly_file.display());
     let (_success, _stdout, _stderr) = run_sandboxed_command(&config, &[], &cmd);
     let current_content = fs::read_to_string(&readonly_file).expect("file should still exist");
@@ -606,7 +666,6 @@ fn test_env_var_filtering() {
 
     let config = test_sandbox_config(project_dir);
 
-    // HOME is in the default allowlist; AWS_SECRET is not
     let (success, stdout, stderr) = run_sandboxed_command(
         &config,
         &[("AWS_SECRET", "super_secret_key_12345")],
@@ -614,13 +673,11 @@ fn test_env_var_filtering() {
     );
     assert!(success, "env command should succeed. stderr: {stderr}");
 
-    // HOME should be present (it's in the default allowed list)
     assert!(
         stdout.contains("HOME=/"),
         "HOME should be present in filtered env. stdout: {stdout}"
     );
 
-    // AWS_SECRET should be absent (not in the allowed list)
     assert!(
         !stdout.contains("super_secret_key_12345"),
         "AWS_SECRET should be filtered out. stdout: {stdout}"
@@ -641,11 +698,9 @@ fn test_network_blocking() {
     let mut config = test_sandbox_config(project_dir);
     config.allow_network = false;
 
-    // Try to fetch a URL — should fail due to network being blocked
     let cmd = "curl -s --max-time 5 https://example.com 2>&1 || true";
     let (_success, stdout, _stderr) = run_sandboxed_command(&config, &[], &cmd);
 
-    // The response should NOT contain the expected HTML from example.com
     assert!(
         !stdout.contains("Example Domain"),
         "Network should be blocked. Got stdout: {stdout}"
@@ -674,3 +729,323 @@ fn test_sandbox_basic_echo_succeeds() {
         "Should see echo output. stdout: {stdout}"
     );
 }
+
+// ---------------------------------------------------------------------------
+// Integration test: additional_executable_paths
+// ---------------------------------------------------------------------------
+
+#[test]
+fn test_additional_executable_paths_allow_execution() {
+    let (_base_guard, base) = canonical_tempdir();
+    let project_dir = base.join("project");
+    fs::create_dir_all(&project_dir).expect("failed to create project dir");
+
+    let tools_dir = base.join("tools");
+    fs::create_dir_all(&tools_dir).expect("failed to create tools dir");
+
+    // Create a simple executable script in the tools directory
+    let script_path = tools_dir.join("my_tool");
+    fs::write(&script_path, "#!/bin/sh\necho tool_executed_successfully\n")
+        .expect("failed to write script");
+
+    // Make it executable
+    use std::os::unix::fs::PermissionsExt;
+    fs::set_permissions(&script_path, fs::Permissions::from_mode(0o755))
+        .expect("failed to set permissions");
+
+    // Without additional_executable_paths — execution should fail
+    let config_without = test_sandbox_config(project_dir.clone());
+    let cmd = format!("{} 2>&1 || true", script_path.display());
+    let (_success, stdout_without, _stderr) = run_sandboxed_command(&config_without, &[], &cmd);
+    assert!(
+        !stdout_without.contains("tool_executed_successfully"),
+        "Tool should NOT be executable without additional_executable_paths. stdout: {stdout_without}"
+    );
+
+    // With additional_executable_paths — execution should succeed
+    let mut config_with = test_sandbox_config(project_dir);
+    config_with.additional_executable_paths = vec![tools_dir];
+    let (success, stdout_with, stderr) = run_sandboxed_command(&config_with, &[], &cmd);
+    assert!(
+        success && stdout_with.contains("tool_executed_successfully"),
+        "Tool should be executable with additional_executable_paths. success={success}, stdout: {stdout_with}, stderr: {stderr}"
+    );
+}
+
+// ---------------------------------------------------------------------------
+// Integration test: canonicalize_paths with symlinks
+// ---------------------------------------------------------------------------
+
+#[test]
+fn test_canonicalize_paths_resolves_symlinks() {
+    let (_base_guard, base) = canonical_tempdir();
+    let real_project_dir = base.join("real_project");
+    fs::create_dir_all(&real_project_dir).expect("failed to create project dir");
+
+    // Create a test file in the real project directory
+    let test_file = real_project_dir.join("test.txt");
+    fs::write(&test_file, "symlink_test_content").expect("failed to write test file");
+
+    // Create a symlink to the project directory
+    let symlink_dir = base.join("symlink_project");
+    std::os::unix::fs::symlink(&real_project_dir, &symlink_dir)
+        .expect("failed to create symlink");
+
+    // Use the symlinked path as the project dir — canonicalize_paths should resolve it
+    let config = test_sandbox_config(symlink_dir);
+
+    // Writing should work because canonicalize_paths resolves the symlink to the real path
+    let output_file = real_project_dir.join("output.txt");
+    let cmd = format!("echo 'from_symlinked_project' > {}", output_file.display());
+    let (success, _stdout, stderr) = run_sandboxed_command(&config, &[], &cmd);
+
+    assert!(
+        success,
+        "Writing in symlinked project dir should succeed after canonicalization. stderr: {stderr}"
+    );
+    let content = fs::read_to_string(&output_file).unwrap_or_default();
+    assert!(
+        content.contains("from_symlinked_project"),
+        "Should have written through the canonicalized path. content: {content}"
+    );
+}
+
+// ---------------------------------------------------------------------------
+// Fingerprint tests (macOS)
+// ---------------------------------------------------------------------------
+
+#[cfg(target_os = "macos")]
+mod fingerprint_tests {
+    use super::*;
+    use crate::sandbox_macos::{
+        SessionFingerprint, apply_fingerprint_only, apply_sandbox_with_fingerprint,
+        generate_fingerprint_only_profile,
+    };
+
+    #[test]
+    fn test_fingerprint_matches_own_process_with_full_sandbox() {
+        let (_base_guard, base) = canonical_tempdir();
+        let project_dir = base.join("project");
+        fs::create_dir_all(&project_dir).expect("failed to create project dir");
+
+        let fingerprint = SessionFingerprint::new().expect("failed to create fingerprint");
+        let config = test_sandbox_config(project_dir);
+
+        // Spawn a child process with the fingerprint-embedded sandbox profile
+        let mut cmd = Command::new("/bin/sh");
+        cmd.arg("-c").arg("sleep 5");
+
+        let sandbox_config = {
+            let exec_config = SandboxExecConfig::from_sandbox_config(&config);
+            let parsed = SandboxExecConfig::from_json(&exec_config.to_json()).unwrap();
+            let mut sc = parsed.to_sandbox_config();
+            sc.canonicalize_paths();
+            sc
+        };
+
+        unsafe {
+            let fp_uuid = fingerprint.uuid_string();
+            cmd.pre_exec(move || {
+                let fp = SessionFingerprint::from_uuid_str(&fp_uuid)
+                    .map_err(|e| std::io::Error::other(e))?;
+                apply_sandbox_with_fingerprint(&sandbox_config, &fp)?;
+                Ok(())
+            });
+        }
+
+        let mut child = cmd.spawn().expect("failed to spawn child");
+        std::thread::sleep(std::time::Duration::from_millis(100));
+
+        // The fingerprint should match the child process
+        let child_pid = child.id() as libc::pid_t;
+        assert!(
+            fingerprint.matches_pid(child_pid),
+            "Fingerprint should match child process with embedded profile"
+        );
+
+        child.kill().ok();
+        child.wait().ok();
+    }
+
+    #[test]
+    fn test_fingerprint_does_not_match_unsandboxed_process() {
+        let fingerprint = SessionFingerprint::new().expect("failed to create fingerprint");
+
+        // Spawn an unsandboxed process
+        let mut child = Command::new("/bin/sh")
+            .arg("-c")
+            .arg("sleep 5")
+            .spawn()
+            .expect("failed to spawn child");
+
+        std::thread::sleep(std::time::Duration::from_millis(100));
+
+        let child_pid = child.id() as libc::pid_t;
+        assert!(
+            !fingerprint.matches_pid(child_pid),
+            "Fingerprint should NOT match unsandboxed process"
+        );
+
+        child.kill().ok();
+        child.wait().ok();
+    }
+
+    #[test]
+    fn test_fingerprint_does_not_match_different_session() {
+        let (_base_guard, base) = canonical_tempdir();
+        let project_dir = base.join("project");
+        fs::create_dir_all(&project_dir).expect("failed to create project dir");
+
+        let fingerprint_a = SessionFingerprint::new().expect("failed to create fingerprint A");
+        let fingerprint_b = SessionFingerprint::new().expect("failed to create fingerprint B");
+
+        // Spawn a process with fingerprint_b's profile
+        let mut cmd = Command::new("/bin/sh");
+        cmd.arg("-c").arg("sleep 5");
+
+        unsafe {
+            let fp_b_uuid = fingerprint_b.uuid_string();
+            cmd.pre_exec(move || {
+                let fp = SessionFingerprint::from_uuid_str(&fp_b_uuid)
+                    .map_err(|e| std::io::Error::other(e))?;
+                apply_fingerprint_only(&fp)?;
+                Ok(())
+            });
+        }
+
+        let mut child = cmd.spawn().expect("failed to spawn child");
+        std::thread::sleep(std::time::Duration::from_millis(100));
+
+        let child_pid = child.id() as libc::pid_t;
+
+        // fingerprint_a should NOT match (wrong session)
+        assert!(
+            !fingerprint_a.matches_pid(child_pid),
+            "Fingerprint A should NOT match process from session B"
+        );
+
+        // fingerprint_b SHOULD match
+        assert!(
+            fingerprint_b.matches_pid(child_pid),
+            "Fingerprint B should match its own process"
+        );
+
+        child.kill().ok();
+        child.wait().ok();
+    }
+
+    #[test]
+    fn test_fingerprint_only_mode_no_restrictions() {
+        let (_base_guard, base) = canonical_tempdir();
+        let project_dir = base.join("project");
+        fs::create_dir_all(&project_dir).expect("failed to create project dir");
+
+        let fingerprint = SessionFingerprint::new().expect("failed to create fingerprint");
+
+        // Create a file OUTSIDE the project dir
+        let external_dir = base.join("external");
+        fs::create_dir_all(&external_dir).expect("failed to create external dir");
+        let external_file = external_dir.join("readable.txt");
+        fs::write(&external_file, "external_content").expect("failed to write");
+
+        // Spawn with fingerprint-only mode (should allow everything)
+        let mut cmd = Command::new("/bin/sh");
+        cmd.arg("-c")
+            .arg(format!("cat {}", external_file.display()));
+
+        unsafe {
+            let fp_uuid = fingerprint.uuid_string();
+            cmd.pre_exec(move || {
+                let fp = SessionFingerprint::from_uuid_str(&fp_uuid)
+                    .map_err(|e| std::io::Error::other(e))?;
+                apply_fingerprint_only(&fp)?;
+                Ok(())
+            });
+        }
+
+        let output = cmd.output().expect("failed to spawn");
+        let stdout = String::from_utf8_lossy(&output.stdout);
+
+        assert!(
+            stdout.contains("external_content"),
+            "Fingerprint-only mode should NOT restrict file access. stdout: {stdout}"
+        );
+    }
+
+    #[test]
+    fn test_fingerprint_only_profile_structure() {
+        let fingerprint = SessionFingerprint::new().expect("failed to create fingerprint");
+        let profile = generate_fingerprint_only_profile(&fingerprint);
+
+        assert!(profile.contains("(allow default)"), "Should allow everything by default");
+        assert!(profile.contains("(deny file-read*"), "Should deny the deny-side path");
+        assert!(profile.contains("(allow file-read*"), "Should allow the allow-side path");
+        assert!(!profile.contains("(deny default)"), "Should NOT have deny default");
+    }
+}
+
+// ---------------------------------------------------------------------------
+// Convergent cleanup tests (macOS)
+// ---------------------------------------------------------------------------
+
+#[cfg(target_os = "macos")]
+mod cleanup_tests {
+    use super::*;
+    use crate::sandbox_macos::SessionFingerprint;
+
+    /// Helper: spawn a child process with the fingerprint-only profile.
+    fn spawn_fingerprinted_process(
+        fingerprint: &SessionFingerprint,
+        command: &str,
+    ) -> std::process::Child {
+        let mut cmd = Command::new("/bin/sh");
+        cmd.arg("-c").arg(command);
+
+        let fp_uuid = fingerprint.uuid_string();
+        unsafe {
+            cmd.pre_exec(move || {
+                let fp = SessionFingerprint::from_uuid_str(&fp_uuid)
+                    .map_err(|e| std::io::Error::other(e))?;
+                crate::sandbox_macos::apply_fingerprint_only(&fp)?;
+                Ok(())
+            });
+        }
+
+        cmd.spawn().expect("failed to spawn fingerprinted child")
+    }
+
+    #[test]
+    fn test_cleanup_kills_simple_child() {
+        let fingerprint = SessionFingerprint::new().expect("failed to create fingerprint");
+        let mut child = spawn_fingerprinted_process(&fingerprint, "sleep 60");
+        std::thread::sleep(std::time::Duration::from_millis(100));
+
+        let child_pid = child.id() as libc::pid_t;
+        assert!(fingerprint.matches_pid(child_pid), "Child should match before cleanup");
+
+        fingerprint.kill_all_processes(None);
+
+        // The child should be dead now
+        let status = child.wait().expect("failed to wait");
+        assert!(!status.success(), "Child should have been killed");
+    }
+
+    #[test]
+    fn test_cleanup_loop_terminates() {
+        let fingerprint = SessionFingerprint::new().expect("failed to create fingerprint");
+        let mut child = spawn_fingerprinted_process(&fingerprint, "sleep 60");
+        std::thread::sleep(std::time::Duration::from_millis(100));
+
+        // kill_all_processes should complete (not hang)
+        let start = std::time::Instant::now();
+        fingerprint.kill_all_processes(None);
+        let elapsed = start.elapsed();
+
+        assert!(
+            elapsed < std::time::Duration::from_secs(5),
+            "Cleanup should complete quickly, took {elapsed:?}"
+        );
+
+        child.wait().ok();
+    }
+}

crates/terminal/Cargo.toml 🔗

@@ -30,6 +30,7 @@ libc.workspace = true
 log.workspace = true
 regex.workspace = true
 release_channel.workspace = true
+sandbox.workspace = true
 schemars.workspace = true
 serde.workspace = true
 serde_json.workspace = true
@@ -47,9 +48,6 @@ parking_lot.workspace = true
 [target.'cfg(windows)'.dependencies]
 windows.workspace = true
 
-[target.'cfg(target_os = "linux")'.dependencies]
-landlock = "0.4"
-
 [dev-dependencies]
 gpui = { workspace = true, features = ["test-support"] }
 rand.workspace = true

crates/terminal/src/sandbox_macos.rs 🔗

@@ -1,228 +0,0 @@
-//! macOS Seatbelt sandbox implementation.
-//!
-//! Uses `sandbox_init()` from `<sandbox.h>` to apply a Seatbelt sandbox profile
-//! to the current process. Must be called after fork(), before exec().
-
-use std::ffi::{CStr, CString};
-use std::fmt::Write;
-use std::io::{Error, Result};
-use std::os::raw::c_char;
-use std::path::Path;
-
-use crate::terminal_settings::SandboxConfig;
-
-unsafe extern "C" {
-    fn sandbox_init(profile: *const c_char, flags: u64, errorbuf: *mut *mut c_char) -> i32;
-    fn sandbox_free_error(errorbuf: *mut c_char);
-}
-
-/// Apply a Seatbelt sandbox profile to the current process.
-/// Must be called after fork(), before exec().
-///
-/// # Safety
-/// This function calls C FFI functions and must only be called
-/// in a pre_exec context (after fork, before exec).
-pub fn apply_sandbox(config: &SandboxConfig) -> Result<()> {
-    let profile = generate_sbpl_profile(config);
-    let profile_cstr =
-        CString::new(profile).map_err(|_| Error::other("sandbox profile contains null byte"))?;
-    let mut errorbuf: *mut c_char = std::ptr::null_mut();
-
-    let ret = unsafe { sandbox_init(profile_cstr.as_ptr(), 0, &mut errorbuf) };
-
-    if ret == 0 {
-        return Ok(());
-    }
-
-    let msg = if !errorbuf.is_null() {
-        let s = unsafe { CStr::from_ptr(errorbuf) }
-            .to_string_lossy()
-            .into_owned();
-        unsafe { sandbox_free_error(errorbuf) };
-        s
-    } else {
-        "unknown sandbox error".to_string()
-    };
-    Err(Error::other(format!("sandbox_init failed: {msg}")))
-}
-
-/// Generate an SBPL (Sandbox Profile Language) profile from the sandbox config.
-pub(crate) fn generate_sbpl_profile(config: &SandboxConfig) -> String {
-    let mut p = String::from("(version 1)\n(deny default)\n");
-
-    // Process lifecycle
-    p.push_str("(allow process-fork)\n");
-    p.push_str("(allow signal)\n");
-
-    // Mach service allowlist.
-    //
-    // TROUBLESHOOTING: If users report broken terminal behavior (e.g. DNS failures,
-    // keychain errors, or commands hanging), a missing Mach service here is a likely
-    // cause. To diagnose:
-    //   1. Open Console.app and filter for "sandbox" or "deny mach-lookup" to find
-    //      the denied service name.
-    //   2. Or test interactively:
-    //      sandbox-exec -p '(version 1)(deny default)(allow mach-lookup ...)' /bin/sh
-    //   3. Add the missing service to the appropriate group below.
-
-    // Logging: unified logging (os_log) and legacy syslog.
-    p.push_str("(allow mach-lookup (global-name \"com.apple.logd\"))\n");
-    p.push_str("(allow mach-lookup (global-name \"com.apple.logd.events\"))\n");
-    p.push_str("(allow mach-lookup (global-name \"com.apple.system.logger\"))\n");
-
-    // User/group directory lookups (getpwuid, getgrnam, id, etc.).
-    p.push_str("(allow mach-lookup (global-name \"com.apple.system.opendirectoryd.libinfo\"))\n");
-    p.push_str(
-        "(allow mach-lookup (global-name \"com.apple.system.opendirectoryd.membership\"))\n",
-    );
-
-    // Darwin notification center, used internally by many system frameworks.
-    p.push_str("(allow mach-lookup (global-name \"com.apple.system.notification_center\"))\n");
-
-    // CFPreferences: reading user and system preferences.
-    p.push_str("(allow mach-lookup (global-name \"com.apple.cfprefsd.agent\"))\n");
-    p.push_str("(allow mach-lookup (global-name \"com.apple.cfprefsd.daemon\"))\n");
-
-    // Temp directory management (_CS_DARWIN_USER_CACHE_DIR, etc.).
-    p.push_str("(allow mach-lookup (global-name \"com.apple.bsd.dirhelper\"))\n");
-
-    // DNS and network configuration.
-    p.push_str("(allow mach-lookup (global-name \"com.apple.dnssd.service\"))\n");
-    p.push_str(
-        "(allow mach-lookup (global-name \"com.apple.SystemConfiguration.DNSConfiguration\"))\n",
-    );
-    p.push_str("(allow mach-lookup (global-name \"com.apple.SystemConfiguration.configd\"))\n");
-    p.push_str(
-        "(allow mach-lookup (global-name \"com.apple.SystemConfiguration.NetworkInformation\"))\n",
-    );
-    p.push_str("(allow mach-lookup (global-name \"com.apple.SystemConfiguration.SCNetworkReachability\"))\n");
-    p.push_str("(allow mach-lookup (global-name \"com.apple.networkd\"))\n");
-    p.push_str("(allow mach-lookup (global-name \"com.apple.nehelper\"))\n");
-
-    // Security, keychain, and TLS certificate verification.
-    p.push_str("(allow mach-lookup (global-name \"com.apple.SecurityServer\"))\n");
-    p.push_str("(allow mach-lookup (global-name \"com.apple.trustd.agent\"))\n");
-    p.push_str("(allow mach-lookup (global-name \"com.apple.ocspd\"))\n");
-    p.push_str("(allow mach-lookup (global-name \"com.apple.security.authtrampoline\"))\n");
-
-    // Launch Services: needed for the `open` command, file-type associations,
-    // and anything that uses NSWorkspace or LaunchServices.
-    p.push_str("(allow mach-lookup (global-name \"com.apple.coreservices.launchservicesd\"))\n");
-    p.push_str("(allow mach-lookup (global-name \"com.apple.CoreServices.coreservicesd\"))\n");
-    p.push_str("(allow mach-lookup (global-name-regex #\"^com\\.apple\\.lsd\\.\" ))\n");
-
-    // Kerberos: needed in enterprise environments for authentication.
-    p.push_str("(allow mach-lookup (global-name \"com.apple.GSSCred\"))\n");
-    p.push_str("(allow mach-lookup (global-name \"org.h5l.kcm\"))\n");
-
-    // Distributed notifications: some command-line tools using Foundation may need this.
-    p.push_str(
-        "(allow mach-lookup (global-name-regex #\"^com\\.apple\\.distributed_notifications\"))\n",
-    );
-
-    p.push_str("(allow sysctl-read)\n");
-
-    // Root directory entry must be readable for path resolution (getcwd, realpath, etc.)
-    p.push_str("(allow file-read* (literal \"/\"))\n");
-    // Default shell selector symlink on macOS
-    p.push_str("(allow file-read* (subpath \"/private/var/select\"))\n");
-
-    // No iokit-open rules: a terminal shell does not need to open IOKit user
-    // clients (kernel driver interfaces). IOKit access is needed for GPU/
-    // graphics (IOAccelerator, AGPMClient), audio (IOAudioEngine), USB,
-    // Bluetooth, and similar hardware — none of which a shell requires. Random
-    // numbers come from /dev/urandom or getentropy(), and timing uses syscalls,
-    // so no IOKit involvement is needed for basic process operation. Chromium's
-    // network process and Firefox's content process both operate without any
-    // iokit-open rules.
-
-    // System executable paths (read + execute)
-    for path in &config.system_paths.executable {
-        write_subpath_rule(&mut p, path, "file-read* process-exec");
-    }
-
-    // System read-only paths
-    for path in &config.system_paths.read_only {
-        write_subpath_rule(&mut p, path, "file-read*");
-    }
-
-    // System read+write paths (devices, temp dirs, IPC)
-    for path in &config.system_paths.read_write {
-        write_subpath_rule(&mut p, path, "file-read* file-write*");
-    }
-
-    // Project directory: full access
-    write_subpath_rule(
-        &mut p,
-        &config.project_dir,
-        "file-read* file-write* process-exec",
-    );
-
-    // User-configured additional paths
-    for path in &config.additional_executable_paths {
-        write_subpath_rule(&mut p, path, "file-read* process-exec");
-    }
-    for path in &config.additional_read_only_paths {
-        write_subpath_rule(&mut p, path, "file-read*");
-    }
-    for path in &config.additional_read_write_paths {
-        write_subpath_rule(&mut p, path, "file-read* file-write*");
-    }
-
-    // User shell config files: read-only access to $HOME dotfiles
-    if let Ok(home) = std::env::var("HOME") {
-        let home = Path::new(&home);
-        for dotfile in &[
-            ".zshrc",
-            ".zshenv",
-            ".zprofile",
-            ".zlogin",
-            ".zlogout",
-            ".bashrc",
-            ".bash_profile",
-            ".bash_login",
-            ".profile",
-            ".inputrc",
-            ".terminfo",
-            ".gitconfig",
-        ] {
-            let path = home.join(dotfile);
-            if path.exists() {
-                let _ = write!(
-                    p,
-                    "(allow file-read* (literal \"{}\"))\n",
-                    sbpl_escape(&path)
-                );
-            }
-        }
-        // XDG config directory
-        let config_dir = home.join(".config");
-        if config_dir.exists() {
-            write_subpath_rule(&mut p, &config_dir, "file-read*");
-        }
-    }
-
-    // Network
-    if config.allow_network {
-        p.push_str("(allow network-outbound)\n");
-        p.push_str("(allow network-inbound)\n");
-        p.push_str("(allow system-socket)\n");
-    }
-
-    p
-}
-
-pub(crate) fn sbpl_escape(path: &Path) -> String {
-    path.display()
-        .to_string()
-        .replace('\\', "\\\\")
-        .replace('"', "\\\"")
-}
-
-fn write_subpath_rule(p: &mut String, path: &Path, permissions: &str) {
-    let _ = write!(
-        p,
-        "(allow {permissions} (subpath \"{}\"))\n",
-        sbpl_escape(path)
-    );
-}

crates/terminal/src/terminal.rs 🔗

@@ -3,19 +3,11 @@ pub mod mappings;
 pub use alacritty_terminal;
 
 mod pty_info;
-#[cfg(unix)]
-pub mod sandbox_exec;
-#[cfg(target_os = "linux")]
-pub mod sandbox_linux;
-#[cfg(target_os = "macos")]
-pub mod sandbox_macos;
-#[cfg(all(test, unix))]
-mod sandbox_tests;
 mod terminal_hyperlinks;
 pub mod terminal_settings;
 
 #[cfg(unix)]
-pub use sandbox_exec::sandbox_exec_main;
+pub use sandbox::sandbox_exec_main;
 
 use alacritty_terminal::{
     Term,
@@ -426,6 +418,8 @@ impl TerminalBuilder {
             event_loop_task: Task::ready(Ok(())),
             background_executor: background_executor.clone(),
             path_style,
+            #[cfg(unix)]
+            session_tracker: None,
             #[cfg(any(test, feature = "test-support"))]
             input_log: Vec::new(),
         };
@@ -532,40 +526,77 @@ impl TerminalBuilder {
             // supported remoting into windows.
             let shell_kind = shell.shell_kind(cfg!(windows));
 
-            let pty_options = {
-                let alac_shell = shell_params.as_ref().map(|params| {
-                    alacritty_terminal::tty::Shell::new(
-                        params.program.clone(),
-                        params.args.clone().unwrap_or_default(),
-                    )
-                });
+            let alac_shell = shell_params.as_ref().map(|params| {
+                alacritty_terminal::tty::Shell::new(
+                    params.program.clone(),
+                    params.args.clone().unwrap_or_default(),
+                )
+            });
 
-                // When sandbox is enabled, wrap the shell with the Zed binary
-                // invoked as `--sandbox-exec <config> -- <shell> [args...]`.
-                // The Zed binary applies the OS-level sandbox and execs the
-                // real shell, avoiding the need to modify the alacritty fork.
-                #[cfg(unix)]
-                let alac_shell = if let Some(ref sandbox) = sandbox_config {
-                    let exec_config = sandbox_exec::SandboxExecConfig::from_sandbox_config(sandbox);
-                    let config_json = exec_config.to_json();
+            // Always wrap the shell with the Zed binary invoked as
+            // `--sandbox-exec <config> -- <shell> [args...]` on Unix.
+            // This enables process lifetime tracking (via Seatbelt
+            // fingerprinting on macOS, cgroups on Linux) even when no
+            // sandbox restrictions are configured.
+            #[cfg(unix)]
+            let (alac_shell, session_tracker) = {
+                // In test mode, the current exe is the test binary, not Zed,
+                // so the --sandbox-exec wrapper cannot be used.
+                #[cfg(not(any(test, feature = "test-support")))]
+                let session_tracker = match sandbox::SessionTracker::new() {
+                    Ok(tracker) => Some(tracker),
+                    Err(err) => {
+                        log::warn!("Failed to create session tracker: {err}");
+                        None
+                    }
+                };
+                #[cfg(any(test, feature = "test-support"))]
+                let session_tracker: Option<sandbox::SessionTracker> = None;
+
+                let shell = if session_tracker.is_some() || sandbox_config.is_some() {
+                    let mut exec_config = if let Some(ref sandbox) = sandbox_config {
+                        sandbox::SandboxExecConfig::from_sandbox_config(sandbox)
+                    } else {
+                        sandbox::SandboxExecConfig {
+                            project_dir: working_directory
+                                .as_ref()
+                                .map(|p| p.to_string_lossy().into_owned())
+                                .unwrap_or_else(|| ".".to_string()),
+                            executable_paths: Vec::new(),
+                            read_only_paths: Vec::new(),
+                            read_write_paths: Vec::new(),
+                            additional_executable_paths: Vec::new(),
+                            additional_read_only_paths: Vec::new(),
+                            additional_read_write_paths: Vec::new(),
+                            allow_network: true,
+                            allowed_env_vars: Vec::new(),
+                            fingerprint_uuid: None,
+                            tracking_only: true,
+                            cgroup_path: None,
+                        }
+                    };
 
-                    let zed_binary =
-                        std::env::current_exe().unwrap_or_else(|_| PathBuf::from("zed"));
+                    if let Some(ref tracker) = session_tracker {
+                        exec_config.fingerprint_uuid = tracker.fingerprint_uuid();
+                        exec_config.cgroup_path = tracker.cgroup_path();
+                        if sandbox_config.is_none() {
+                            exec_config.tracking_only = true;
+                        }
+                    }
+
+                    let config_json = exec_config.to_json();
+                    let zed_binary = std::env::current_exe()
+                        .map_err(|e| anyhow::anyhow!("failed to get current exe: {e}"))?;
 
                     let mut args =
                         vec!["--sandbox-exec".to_string(), config_json, "--".to_string()];
 
-                    // Append the real shell command after `--`
                     if let Some(ref params) = shell_params {
                         args.push(params.program.clone());
                         if let Some(ref shell_args) = params.args {
                             args.extend(shell_args.clone());
                         }
                     } else {
-                        // System shell: resolve it and start as login shell
-                        // so profile files (.zprofile, .bash_profile, etc.) are sourced.
-                        // Normally alacritty uses /usr/bin/login for this, but the
-                        // sandbox wrapper bypasses that, so we pass -l explicitly.
                         let system_shell = util::get_default_system_shell();
                         args.push(system_shell);
                         args.push("-l".to_string());
@@ -579,14 +610,16 @@ impl TerminalBuilder {
                     alac_shell
                 };
 
-                alacritty_terminal::tty::Options {
-                    shell: alac_shell,
-                    working_directory: working_directory.clone(),
-                    drain_on_exit: true,
-                    env: env.clone().into_iter().collect(),
-                    #[cfg(windows)]
-                    escape_args: shell_kind.tty_escape_args(),
-                }
+                (shell, session_tracker)
+            };
+
+            let pty_options = alacritty_terminal::tty::Options {
+                shell: alac_shell,
+                working_directory: working_directory.clone(),
+                drain_on_exit: true,
+                env: env.clone().into_iter().collect(),
+                #[cfg(windows)]
+                escape_args: shell_kind.tty_escape_args(),
             };
 
             let default_cursor_style = AlacCursorStyle::from(cursor_shape);
@@ -699,6 +732,8 @@ impl TerminalBuilder {
                 event_loop_task: Task::ready(Ok(())),
                 background_executor,
                 path_style,
+                #[cfg(unix)]
+                session_tracker,
                 #[cfg(any(test, feature = "test-support"))]
                 input_log: Vec::new(),
             };
@@ -925,6 +960,8 @@ pub struct Terminal {
     event_loop_task: Task<Result<(), anyhow::Error>>,
     background_executor: BackgroundExecutor,
     path_style: PathStyle,
+    #[cfg(unix)]
+    session_tracker: Option<sandbox::SessionTracker>,
     #[cfg(any(test, feature = "test-support"))]
     input_log: Vec<Vec<u8>>,
 }
@@ -2458,6 +2495,19 @@ impl Drop for Terminal {
         {
             pty_tx.0.send(Msg::Shutdown).ok();
 
+            #[cfg(unix)]
+            {
+                if let Some(tracker) = self.session_tracker.take() {
+                    let process_group_id = info.pid().map(|pid| pid.as_u32() as libc::pid_t);
+                    // Use a dedicated thread for cleanup that outlives the executor,
+                    // ensuring processes are killed even if Zed is exiting.
+                    std::thread::spawn(move || {
+                        tracker.kill_all_processes(process_group_id);
+                    });
+                    return;
+                }
+            }
+
             let timer = self.background_executor.timer(Duration::from_millis(100));
             self.background_executor
                 .spawn(async move {

crates/terminal/src/terminal_settings.rs 🔗

@@ -5,7 +5,6 @@ use collections::HashMap;
 use gpui::{FontFallbacks, FontFeatures, FontWeight, Pixels, px};
 use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
-use std::path::PathBuf;
 
 pub use settings::AlternateScroll;
 
@@ -181,255 +180,5 @@ impl From<CursorShape> for AlacCursorStyle {
     }
 }
 
-/// Resolved sandbox configuration with all defaults applied.
-/// This is the concrete type passed to the terminal spawning code.
-#[derive(Clone, Debug)]
-pub struct SandboxConfig {
-    pub project_dir: PathBuf,
-    pub system_paths: ResolvedSystemPaths,
-    pub additional_executable_paths: Vec<PathBuf>,
-    pub additional_read_only_paths: Vec<PathBuf>,
-    pub additional_read_write_paths: Vec<PathBuf>,
-    pub allow_network: bool,
-    pub allowed_env_vars: Vec<String>,
-}
-
-/// Resolved system paths with OS-specific defaults applied.
-#[derive(Clone, Debug)]
-pub struct ResolvedSystemPaths {
-    pub executable: Vec<PathBuf>,
-    pub read_only: Vec<PathBuf>,
-    pub read_write: Vec<PathBuf>,
-}
-
-impl ResolvedSystemPaths {
-    pub fn from_settings(settings: &settings::SystemPathsSettingsContent) -> Self {
-        Self {
-            executable: settings
-                .executable
-                .clone()
-                .map(|v| v.into_iter().map(PathBuf::from).collect())
-                .unwrap_or_else(Self::default_executable),
-            read_only: settings
-                .read_only
-                .clone()
-                .map(|v| v.into_iter().map(PathBuf::from).collect())
-                .unwrap_or_else(Self::default_read_only),
-            read_write: settings
-                .read_write
-                .clone()
-                .map(|v| v.into_iter().map(PathBuf::from).collect())
-                .unwrap_or_else(Self::default_read_write),
-        }
-    }
-
-    pub fn with_defaults() -> Self {
-        Self {
-            executable: Self::default_executable(),
-            read_only: Self::default_read_only(),
-            read_write: Self::default_read_write(),
-        }
-    }
-
-    #[cfg(target_os = "macos")]
-    fn default_executable() -> Vec<PathBuf> {
-        vec![
-            "/bin".into(),
-            "/usr/bin".into(),
-            "/usr/sbin".into(),
-            "/sbin".into(),
-            "/usr/lib".into(),
-            "/usr/libexec".into(),
-            "/System/Library/dyld".into(),
-            "/System/Cryptexes".into(),
-            "/Library/Developer/CommandLineTools/usr/bin".into(),
-            "/Library/Developer/CommandLineTools/usr/lib".into(),
-            "/Library/Apple/usr/bin".into(),
-            "/opt/homebrew/bin".into(),
-            "/opt/homebrew/sbin".into(),
-            "/opt/homebrew/Cellar".into(),
-            "/opt/homebrew/lib".into(),
-            "/usr/local/bin".into(),
-            "/usr/local/lib".into(),
-        ]
-    }
-
-    #[cfg(target_os = "linux")]
-    fn default_executable() -> Vec<PathBuf> {
-        vec![
-            "/usr/bin".into(),
-            "/usr/sbin".into(),
-            "/usr/lib".into(),
-            "/usr/lib64".into(),
-            "/usr/libexec".into(),
-            "/lib".into(),
-            "/lib64".into(),
-            "/bin".into(),
-            "/sbin".into(),
-        ]
-    }
-
-    #[cfg(not(any(target_os = "macos", target_os = "linux")))]
-    fn default_executable() -> Vec<PathBuf> {
-        vec![]
-    }
-
-    #[cfg(target_os = "macos")]
-    fn default_read_only() -> Vec<PathBuf> {
-        vec![
-            "/private/etc".into(),
-            "/usr/share".into(),
-            "/System/Library/Keychains".into(),
-            "/Library/Developer/CommandLineTools/SDKs".into(),
-            "/Library/Preferences/SystemConfiguration".into(),
-            "/opt/homebrew/share".into(),
-            "/opt/homebrew/etc".into(),
-            "/usr/local/share".into(),
-            "/usr/local/etc".into(),
-        ]
-    }
-
-    #[cfg(target_os = "linux")]
-    fn default_read_only() -> Vec<PathBuf> {
-        vec![
-            "/etc".into(),
-            "/usr/share".into(),
-            "/usr/include".into(),
-            "/usr/lib/locale".into(),
-        ]
-    }
-
-    #[cfg(not(any(target_os = "macos", target_os = "linux")))]
-    fn default_read_only() -> Vec<PathBuf> {
-        vec![]
-    }
-
-    #[cfg(target_os = "macos")]
-    fn default_read_write() -> Vec<PathBuf> {
-        vec![
-            "/dev".into(),
-            "/private/tmp".into(),
-            "/var/folders".into(),
-            "/private/var/run/mDNSResponder".into(),
-        ]
-    }
-
-    #[cfg(target_os = "linux")]
-    fn default_read_write() -> Vec<PathBuf> {
-        vec![
-            "/dev".into(),
-            "/tmp".into(),
-            "/var/tmp".into(),
-            "/dev/shm".into(),
-            "/run/user".into(),
-        ]
-    }
-
-    #[cfg(not(any(target_os = "macos", target_os = "linux")))]
-    fn default_read_write() -> Vec<PathBuf> {
-        vec![]
-    }
-}
-
-impl SandboxConfig {
-    /// Default environment variables to pass through to sandboxed terminals.
-    pub fn default_allowed_env_vars() -> Vec<String> {
-        vec![
-            "PATH".into(),
-            "HOME".into(),
-            "USER".into(),
-            "SHELL".into(),
-            "LANG".into(),
-            "TERM".into(),
-            "TERM_PROGRAM".into(),
-            "CARGO_HOME".into(),
-            "RUSTUP_HOME".into(),
-            "GOPATH".into(),
-            "EDITOR".into(),
-            "VISUAL".into(),
-            "XDG_CONFIG_HOME".into(),
-            "XDG_DATA_HOME".into(),
-            "XDG_RUNTIME_DIR".into(),
-            "SSH_AUTH_SOCK".into(),
-            "GPG_TTY".into(),
-            "COLORTERM".into(),
-        ]
-    }
-
-    /// Resolve a `SandboxConfig` from settings, applying all defaults.
-    pub fn from_settings(
-        sandbox_settings: &settings::SandboxSettingsContent,
-        project_dir: PathBuf,
-    ) -> Self {
-        let system_paths = sandbox_settings
-            .system_paths
-            .as_ref()
-            .map(|sp| ResolvedSystemPaths::from_settings(sp))
-            .unwrap_or_else(ResolvedSystemPaths::with_defaults);
-
-        let home_dir = std::env::var("HOME").ok().map(PathBuf::from);
-        let expand_paths = |paths: &Option<Vec<String>>| -> Vec<PathBuf> {
-            paths
-                .as_ref()
-                .map(|v| {
-                    v.iter()
-                        .map(|p| {
-                            if let Some(rest) = p.strip_prefix("~/") {
-                                if let Some(ref home) = home_dir {
-                                    return home.join(rest);
-                                }
-                            }
-                            PathBuf::from(p)
-                        })
-                        .collect()
-                })
-                .unwrap_or_default()
-        };
-
-        Self {
-            project_dir,
-            system_paths,
-            additional_executable_paths: expand_paths(
-                &sandbox_settings.additional_executable_paths,
-            ),
-            additional_read_only_paths: expand_paths(&sandbox_settings.additional_read_only_paths),
-            additional_read_write_paths: expand_paths(
-                &sandbox_settings.additional_read_write_paths,
-            ),
-            allow_network: sandbox_settings.allow_network.unwrap_or(true),
-            allowed_env_vars: sandbox_settings
-                .allowed_env_vars
-                .clone()
-                .unwrap_or_else(Self::default_allowed_env_vars),
-        }
-    }
-
-    pub fn canonicalize_paths(&mut self) {
-        match std::fs::canonicalize(&self.project_dir) {
-            Ok(canonical) => self.project_dir = canonical,
-            Err(err) => log::warn!(
-                "Failed to canonicalize project dir {:?}: {}",
-                self.project_dir,
-                err
-            ),
-        }
-        canonicalize_path_list(&mut self.system_paths.executable);
-        canonicalize_path_list(&mut self.system_paths.read_only);
-        canonicalize_path_list(&mut self.system_paths.read_write);
-        canonicalize_path_list(&mut self.additional_executable_paths);
-        canonicalize_path_list(&mut self.additional_read_only_paths);
-        canonicalize_path_list(&mut self.additional_read_write_paths);
-    }
-}
-
-fn try_canonicalize(path: &mut PathBuf) {
-    if let Ok(canonical) = std::fs::canonicalize(&*path) {
-        *path = canonical;
-    }
-}
-
-fn canonicalize_path_list(paths: &mut Vec<PathBuf>) {
-    for path in paths.iter_mut() {
-        try_canonicalize(path);
-    }
-}
+// Re-export sandbox types for backward compatibility
+pub use sandbox::{ResolvedSystemPaths, SandboxConfig};