Skip to content

fix(sandbox): let Claude Code's background daemon start under claude-relaxed - #17

Closed
paveq wants to merge 1 commit into
mainfrom
fix/claude-relaxed-daemon
Closed

paveq wants to merge 1 commit into
mainfrom
fix/claude-relaxed-daemon

Conversation

@paveq

@paveq paveq commented Sep 24, 2026

Copy link
Copy Markdown
Contributor

Problem

Under airlock run --profile claude-relaxed, starting a Claude Code background session fails:

Couldn't reach the background service (background service exited before it became reachable (exit code 1): error: An unknown error occurred (Unexpected))

The client probes for a GUI session with launchctl asuser <uid> /usr/bin/true, then spawns the daemon with launchctl asuser <uid> claude daemon ... and cwd = $HOME. launchctl asuser only execs the command, so the daemon stays inside the Seatbelt sandbox. At startup, Bun's getcwd opens . to read the directory path. When that open is denied, the fallback walks up with readdir. The sandbox denied both reads (deny file-read-data /Users/paveq and /Users in the log), so Bun aborted.

After the cwd fix, the daemon still has to bind its control socket at /tmp/cc-daemon-<uid>/<hash>/control.sock. That path is hardcoded, and the agent profile denies /tmp contents.

Fix (claude-relaxed only)

  • (allow file-read-data (literal "$HOME")): lists the names directly under $HOME; nothing below it becomes readable.
  • (allow file-read* file-write* (subpath "/{private/,}tmp/cc-daemon-<uid>")): covers only the per-uid directory, not the rest of /tmp.

SECURITY.md and README.md are updated to match.

Verification

  • Ran cd $HOME && claude daemon run under airlock run --profile claude-relaxed. Before the fix it failed with the same Unexpected error. After the fix the socket binds, and claude daemon status from inside the sandbox reports the socket path.
  • cargo test --lib: 481 passed, including a new test that checks the rules are present in claude-relaxed and absent from claude.

Known gap

/bin/ps is setuid, and Seatbelt always forbids sandboxed processes from exec'ing setuid binaries (forbidden-exec-sugid). The daemon's start-time probe (ps -o lstart=) therefore fails, so it 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.

Log noise left as-is

These denials are harmless, and startup works without them:

  • kern.memorystatus_vm_pressure_level (Bun)
  • kern.singleuser (launchctl)
  • /run metadata
  • diagnosticd

🤖 Generated with Claude Code

…relaxed

`claude --bg` / `claude agents` start the daemon on demand. The client
spawns it detached through `launchctl asuser <uid>`, 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-<uid>/. $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-<uid>/.
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.
@paveq paveq closed this Sep 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant