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

Merged opened this in answer to

Check · Review · Land · Live done — Landed and live in production.

Merged

Closes #560

shipped production is serving it for issue #560 — its issue is already done deploy fb6f20c

opened this

Closes #560

First cut pushed early; proof on the live forge follows as a comment.

commented

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/; inside crew/, every agent's directory is denied except this agent's. New top-level entries (such as checks/ and looks/ 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 both CLAUDE_CODE_TMPDIR and TMPDIR, because with only CLAUDE_CODE_TMPDIR set 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.json is 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.json are 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:

  • cat on a file in /data/checks: no such file
  • ls /data/checks and ls /data/looks: empty
  • forge.db: permission denied
  • the shared /tmp/claude-1000: an empty, throwaway tmpfs, with the real directory untouched
  • another agent's .t directory: no such file
  • git status, bun install and bun test in 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

commented

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), sandboxPolicy throws SandboxListingError and 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, looks and egress are 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.

  • /proc shows 6 processes, all the sandbox's own: its init and bash, the probe, and the probe's short-lived commands.
  • Reading /proc/1/environ is refused, and pid 1 is the sandbox's own init anyway.
  • No visible environment contains S3_*, FORGE_EDGE_TOKEN or SMTP*: 0 names.
  • /tmp lists 0 entries, and a canary file placed there from outside is not visible.
  • ls of the data directory shows only names (crew, forge.db, repos); reading forge.db is refused.
  • With /tmp denied 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.

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs