diff --git a/SECURITY.md b/SECURITY.md index 7bf5e1d..44384f3 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -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. diff --git a/src/sandbox.rs b/src/sandbox.rs index 82787db..50e2445 100644 --- a/src/sandbox.rs +++ b/src/sandbox.rs @@ -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"); @@ -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()); @@ -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());