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.