Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ The daemon generates an SBPL (Scheme-based) sandbox profile for each tool execut
- **System reads**: `sysctl-read` (needed by Go/Rust runtimes before `main()`).
- **Mach IPC**: `mach-lookup` is an explicit allowlist (no blanket allow). `(deny mach-priv*)` blocks privileged operations.
- **Keychain is out of the baseline.** `com.apple.SecurityServer`, `com.apple.securityd.xpc`, and every other Mach endpoint that fronts Keychain Services are intentionally absent from the allowlist. A sandboxed process running under the baseline (or under the strict `claude` profile) cannot read or write any keychain item. TLS trust evaluation (`SecTrustEvaluate`, `SecPolicyCreateSSL`) reaches the network through `com.apple.trustd.agent` and does not depend on `securityd` — verified empirically — so dropping the keychain services does not affect HTTPS. Profiles that need keychain access opt back in: see `claude-relaxed` under "Built-in agent profiles" below.
- **File-change notification is in the baseline.** `com.apple.FSEvents` is on the allowlist because every macOS file watcher goes through it — without it `node --watch`, nodemon, vite, and `cargo watch` fail, and they fail unrecognisably: libuv surfaces a failed `FSEventStreamStart` as `EMFILE: too many open files, watch` even with a 1M descriptor limit, and Bun reports `error: Error starting FSEvents stream`. The capability is notification-only: reading a changed file still goes through the filesystem rules. It does widen metadata disclosure — an event stream rooted outside the sandbox reports the *paths* of files the process cannot open — which is the accepted cost of working dev servers.
- **Baseline filesystem reads**: `/usr/lib`, `/usr/share`, `/System`, `/Library`, `/private/etc`, `/etc`, `/dev/null`, `/dev/random`, `/dev/urandom`, and the tool binary itself (needed for TLS code signature verification).
- **Config-declared paths**: `(allow file-read* (subpath ...))` for read paths; `(allow file-write* (subpath ...))` for write paths.
- **Network**: `network-outbound`, `system-socket`, plus DNS via `/private/var/run/mDNSResponder` (when `requires_network` is set, currently always true). `network-bind` is scoped to `(local unix-socket)` only — tools can bind Unix domain sockets for local IPC (argocd SSO, language servers, loopback IPC) but cannot `listen()` on TCP/UDP and therefore cannot become network-reachable services.
Expand Down
32 changes: 32 additions & 0 deletions src/sandbox.rs
Original file line number Diff line number Diff line change
Expand Up @@ -481,10 +481,21 @@ pub mod macos {
// HTTPS. Profiles that genuinely need keychain access (e.g.
// `claude-relaxed`) opt in by re-adding the services in their own
// profile rules. Privileged Mach operations remain denied by default.
//
// `com.apple.FSEvents` is the fseventsd endpoint behind
// `FSEventStreamCreate`. Without it every file watcher on macOS
// breaks, and the failures are unrecognisable: libuv reports the
// failed `FSEventStreamStart` as `EMFILE` (`node --watch`, nodemon,
// vite), Bun as "Error starting FSEvents stream". It grants change
// notifications only — reading a changed file still goes through the
// filesystem rules — but the event stream itself carries paths, so a
// watcher rooted outside the sandbox learns names of files it cannot
// open. That metadata channel is the price of working dev servers.
out.push_str("(allow mach-lookup\n");
out.push_str(" (global-name \"com.apple.audio.systemsoundserver\")\n");
out.push_str(" (global-name \"com.apple.distributed_notifications@Uv3\")\n");
out.push_str(" (global-name \"com.apple.FontObjectsServer\")\n");
out.push_str(" (global-name \"com.apple.FSEvents\")\n");
out.push_str(" (global-name \"com.apple.fonts\")\n");
out.push_str(" (global-name \"com.apple.logd\")\n");
out.push_str(" (global-name \"com.apple.lsd.mapdb\")\n");
Expand Down Expand Up @@ -1258,6 +1269,18 @@ pub mod macos {
}
}

#[test]
fn profile_allows_fsevents_mach_service() {
// File watchers (node --watch, vite, bun, cargo-watch) fail with
// misleading errors — EMFILE from libuv — when fseventsd is
// unreachable. Both profiles must reach it.
let sbpl = sbpl_from_profile(&empty_policy());
assert!(
sbpl.contains("(global-name \"com.apple.FSEvents\")"),
"SBPL should allow the FSEvents Mach service, got:\n{sbpl}"
);
}

#[test]
fn profile_mach_lookup_excludes_keychain_services() {
let sbpl = sbpl_from_profile(&empty_policy());
Expand Down Expand Up @@ -1952,6 +1975,15 @@ pub mod macos {
);
}

#[test]
fn agent_profile_allows_fsevents_mach_service() {
let sbpl = sbpl_from_agent_profile(&agent_policy_empty());
assert!(
sbpl.contains("(global-name \"com.apple.FSEvents\")"),
"agent SBPL should allow the FSEvents Mach service, got:\n{sbpl}"
);
}

#[test]
fn tool_profile_excludes_agent_only_permissions() {
let sbpl = sbpl_from_profile(&empty_policy());
Expand Down
Loading