Skip to content
Closed
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <url>`, 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 <url>`, 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

Expand Down
1 change: 1 addition & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <url>` 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-<uid>/`, 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:

Expand Down
68 changes: 68 additions & 0 deletions src/sandbox.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <uid>` 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-<uid>/<hash>/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(())
}

Expand Down Expand Up @@ -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-<uid>/`.
// 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.
Expand Down
Loading