From f2bd249044f2b17a7c743ed7a6266bc6495ab4d5 Mon Sep 17 00:00:00 2001 From: Paavo Pokkinen Date: Thu, 24 Sep 2026 08:50:50 +0300 Subject: [PATCH] fix(sandbox): let Claude Code's background daemon start under claude-relaxed `claude --bg` / `claude agents` start the daemon on demand. The client spawns it detached through `launchctl asuser `, with cwd set to $HOME. The child stays inside the Seatbelt sandbox, and Bun's startup getcwd opens "." to read the directory path. When that open is denied, the fallback walk reads /Users. Both reads were denied, so the daemon exited with "An unknown error occurred (Unexpected)" before the client could reach it. With the cwd fixed, the daemon then needs to bind its control socket under the hardcoded /tmp/cc-daemon-/. $TMPDIR does not cover that path. The relaxed profile now grants a file-read-data literal on $HOME (the directory listing only) and read/write on /tmp/cc-daemon-/. Verified by running `claude daemon run` from $HOME under `airlock run --profile claude-relaxed`: the socket binds and `claude daemon status` reaches it. A known gap remains. /bin/ps is setuid and Seatbelt forbids exec'ing it (forbidden-exec-sugid), so the daemon writes a lock without a start-time identity and `claude daemon stop` refuses to signal it. The daemon still exits when its last client disconnects. --- README.md | 2 +- SECURITY.md | 1 + src/sandbox.rs | 68 ++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 70 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 9f05c82..c040772 100644 --- a/README.md +++ b/README.md @@ -384,7 +384,7 @@ airlock run --no-daemon -- claude # sandbox only; airlock exec Profiles bundle sandbox rules for a known agent: - **`claude`** — read/write to `~/.claude/`, `~/.claude.json`, `~/.local/share/claude/`. The macOS keychain is unreachable, so Claude Code stores its OAuth token in `~/.claude/.credentials.json` (mode `0600`). Also disables Claude Code's own `sandbox-exec` wrapper, which cannot nest inside Airlock's profile. -- **`claude-relaxed`** — `claude` plus keychain access, clipboard, `open `, and read access to shell dotfiles. Each widens the data-leak surface; see [SECURITY.md](SECURITY.md#built-in-agent-profiles). +- **`claude-relaxed`** — `claude` plus keychain access, clipboard, `open `, read access to shell dotfiles, and what Claude Code's background daemon (`claude --bg`, `claude agents`) needs to start. Each widens the data-leak surface; see [SECURITY.md](SECURITY.md#built-in-agent-profiles). ## Troubleshooting diff --git a/SECURITY.md b/SECURITY.md index a3d5461..a4ead1b 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -183,6 +183,7 @@ The directory containing `airlock.toml` is always included as a read-write path - Adds Launch Services Mach services + the `lsopen` operation class (so `open ` works from inside the sandbox). - Adds read access to `~/Library/Preferences/.GlobalPreferences*.plist` (default browser lookup). - Adds read access to shell init dotfiles: `.bashrc`, `.bash_profile`, `.bash_login`, `.profile`, `.zshrc`, `.zprofile`, `.zshenv`, `.zlogin`, `.inputrc`. +- Adds what Claude Code's background daemon (`claude --bg`, `claude agents`) needs to start. The daemon runs with `$HOME` as its working directory, so the profile grants a `file-read-data` literal on `$HOME` (the directory listing only, nothing beneath it); without it, Bun's startup `getcwd` fails. The profile also grants read/write on `/tmp/cc-daemon-/`, where the daemon binds its control socket. The daemon stays inside the sandbox: `launchctl asuser` only execs it. Because `/bin/ps` is setuid, the daemon cannot probe its own start time and writes a lock without one. `claude daemon stop` then refuses to signal it, but the daemon still exits when its last client disconnects. Each `claude-relaxed` extension is a deliberate widening. The keychain DB at rest is encrypted (AES, master key derived from the user's login password and held only in `securityd`'s memory), so a sandboxed agent with this access *cannot* decrypt or forge keychain items. What it *can* do: diff --git a/src/sandbox.rs b/src/sandbox.rs index 76ca291..a5b39cc 100644 --- a/src/sandbox.rs +++ b/src/sandbox.rs @@ -1053,6 +1053,35 @@ pub mod macos { } } + emit_claude_daemon_rules(out) + } + + /// Rules for Claude Code's background daemon (`claude --bg`, + /// `claude agents`), which the client spawns detached via + /// `launchctl asuser ` with its working directory set to `$HOME`. + fn emit_claude_daemon_rules(out: &mut String) -> Result<(), SandboxError> { + // Bun resolves the cwd at startup. libc `getcwd` opens "." to ask the + // kernel for its path; when that open is denied it falls back to + // walking up the tree with `readdir`, which is denied too, and Bun + // aborts with "An unknown error occurred (Unexpected)". A literal + // rule lists the names directly under `$HOME` but grants nothing + // below it. + if let Ok(home) = std::env::var("HOME") { + let escaped = escape_path(std::path::Path::new(&home))?; + out.push_str(&format!("(allow file-read-data (literal \"{escaped}\"))\n")); + } + + // The daemon's control socket lives at + // `/tmp/cc-daemon-//control.sock`. This path is hardcoded, + // so `$TMPDIR` does not cover it. The subpath is per-uid, so the grant + // does not open the rest of `/tmp`. + let uid = unsafe { libc::getuid() }; + for tmp in ["/private/tmp", "/tmp"] { + out.push_str(&format!( + "(allow file-read* file-write* (subpath \"{tmp}/cc-daemon-{uid}\"))\n" + )); + } + Ok(()) } @@ -2309,6 +2338,45 @@ pub mod macos { } } + #[test] + fn agent_profile_claude_relaxed_allows_claude_daemon_startup() { + // The background daemon starts with cwd = $HOME and binds its + // control socket under the hardcoded `/tmp/cc-daemon-/`. + // It needs both rules or it exits before the socket is reachable. + let _guard = crate::test_support::ENV_MUTEX.lock().unwrap(); + let home = std::env::var("HOME").expect("HOME should be set in test env"); + let uid = unsafe { libc::getuid() }; + let expected = [ + format!("(allow file-read-data (literal \"{home}\"))"), + format!( + "(allow file-read* file-write* (subpath \"/private/tmp/cc-daemon-{uid}\"))" + ), + format!("(allow file-read* file-write* (subpath \"/tmp/cc-daemon-{uid}\"))"), + ]; + + let relaxed = sbpl_from_agent_profile_with_kind( + &agent_policy_empty(), + Some(super::super::AgentProfileKind::ClaudeRelaxed), + ); + for line in &expected { + assert!( + relaxed.contains(line), + "ClaudeRelaxed SBPL should contain {line}, got:\n{relaxed}" + ); + } + + let strict = sbpl_from_agent_profile_with_kind( + &agent_policy_empty(), + Some(super::super::AgentProfileKind::Claude), + ); + for line in &expected { + assert!( + !strict.contains(line), + "plain Claude profile must NOT contain {line}, got:\n{strict}" + ); + } + } + #[test] fn agent_profile_omits_relaxed_bundle_for_plain_claude_kind() { // The standard Claude profile must NOT emit any of the relaxed extras.