Public: anyone can read this; the crew can change it.
Crew shell: deny the whole data directory but the agent's own; a temp directory per run #766
Check · Review · Land · Live done — Landed and live in production.
Closes #560
shipped production is serving it for issue #560 — its issue is already done deploy fb6f20c
Closes #560
What changed (src/crew/sandbox.ts, src/crew/runner.ts)
- The data directory is now an allow-list. Every entry in it is denied except
crew/; insidecrew/, every agent's directory is denied except this agent's. New top-level entries (such aschecks/andlooks/were) are denied with no list to edit. The fixed list and the socket scan are gone. - Each run has its own temp directory at
<data>/crew/<agent>/.t/<run>. It is given to Claude Code as bothCLAUDE_CODE_TMPDIRandTMPDIR, because with onlyCLAUDE_CODE_TMPDIRset the shell still binds the shared/tmp/claude-<uid>writable (checked on the live forge). The shared directory and the agent's other runs' temp directories are denied. The run's directory is removed when the run ends. ~/.claude.jsonis denied.- Race: Claude Code cannot deny by rule, so the policy is now built again for every Claude invocation of a run, the work and the wrap-up. An agent created during an invocation is denied from the next one; the code says so.
Tests (test/crew-sandbox.test.ts, already in the gate), on a data directory that has checks/, looks/, repos/, apps/, unknown files and two other agents:
- every entry is denied except
crew/ - every other agent is denied
- this agent's other runs' temp directories are denied
- this agent's working directory and its own run's temp directory stay open
- the shared temp directory and
~/.claude.jsonare denied - asking for the policy again denies an agent created since
Proof on the live forge. A real sandboxed Claude Code run, using this policy and the run environment, did the following:
caton a file in/data/checks: no such filels /data/checksandls /data/looks: emptyforge.db: permission denied- the shared
/tmp/claude-1000: an empty, throwaway tmpfs, with the real directory untouched - another agent's
.tdirectory: no such file git status,bun installandbun testin the agent's clone: all pass
Gate on macOS: passes except one test, crew.test.ts "assignment", which fails the same way on main (a /private path on macOS).
Seen but out of scope: Claude Code's sandbox creates empty .bashrc, .bash_profile and .claude/ mount points in the clone, so they show in git status.
🤖 Generated with Claude Code
Round two (fail-closed listing):
- A listing that fails refuses the run. If the data directory,
crew/or the agent's.t/cannot be listed (for example too many open files, or a permission error),sandboxPolicythrowsSandboxListingErrorand the run fails with that reason; it no longer gets a policy that denies nothing. A missing directory still counts as empty. - A named floor under the allow-list.
repos,forge.db*,secret.key,ssh,apps,runs,checks,looksandegressare denied by name as well as by the listing. - The machine's temp directory is denied whole. When the data directory lives inside it (a developer's forge, a test), denying the whole of it is impossible, and listing every entry of a busy temp directory overflows Claude Code's argument list. In that case the shared Claude Code directory and the cells'
fc-*socket directories are denied instead. - Leftover run temp directories are swept at crew start, but only once they are older than the run timeout plus an hour, because during a rollout the old forge is still finishing its runs.
- The doc comment now says that an entry created during an invocation stays readable until the next invocation rebuilds the policy.
- Names that stay visible: top-level data entries and agent names only, never their contents. No tenant or private repository name appears at that depth.
Tests: an unreadable data directory refuses the run; the named floor holds even with nothing to list; the temp directory is denied whole or around the data directory; sweeping removes old run temp directories and keeps fresh ones.
Probe, run from a real agent shell in a separate test pod (not the production pod). Stand-in forge secrets were set in the parent process's environment.
/procshows 6 processes, all the sandbox's own: its init and bash, the probe, and the probe's short-lived commands.- Reading
/proc/1/environis refused, and pid 1 is the sandbox's own init anyway. - No visible environment contains
S3_*,FORGE_EDGE_TOKENorSMTP*: 0 names. /tmplists 0 entries, and a canary file placed there from outside is not visible.lsof the data directory shows only names (crew, forge.db, repos); readingforge.dbis refused.- With
/tmpdenied whole, the network still works: the forge answers 200, and example.com is refused.
Full gate on macOS: passes except crew.test.ts "assignment", which fails the same way on main (a /private path on macOS). Main c19a368 is merged in.
Closes #560
First cut pushed early; proof on the live forge follows as a comment.