Run coding agents inside a Docker sandbox that feels like running them natively. The agent gets broad, permission-skipped authority over your project; the sandbox keeps that authority off the rest of your machine.
One shared isolation boundary, the cage, hosts four boxes: claude-box (Claude Code), codex-box (OpenAI Codex), deepseek-box (DeepSeek Harness), and pi-box (the Pi coding agent). Each box preserves exact project paths, git/SSH integration, and persistent per-harness state, with per-project overrides via .env.<box> files.
A coding agent earns its keep once you stop approving every action. Claude Code's --dangerously-skip-permissions, Codex's full-auto mode, and their equivalents let the agent read, write, and run commands without a prompt. Granted on the host, that is authority over the whole machine: every file your user account can touch, your shell, your saved credentials, your network.
The obvious fix, a plain container, takes the ergonomics with it. Inside a bare box the agent loses the ~/.claude skills you rely on (including ones symlinked into other repos), MCP servers stop resolving, git identity and SSH signing break, host credentials are gone, and a session started in the container can't be resumed on the host.
agent-box gives you both. Each agent runs in its own box on a shared cage. The cage keeps the agent off the host; the box wires through exactly the parts that make the agent feel native: skills resolved (symlinks included), MCP servers reachable, .gitconfig and the SSH agent forwarded, macOS Keychain credentials still valid, sessions resumable across container and host. .env.<box> lets each project layer its own forwarded env vars and extra mounts on top, so there are no global config edits and no guessing which API key belongs to which workspace.
Four boxes share one cage. Each adds a single agent and its own persistent state; nothing agent-specific leaks into the cage.
| Box | Agent | State dir | Status |
|---|---|---|---|
claude-box |
Claude Code | ~/.claude-box/state/ |
Proven |
codex-box |
OpenAI Codex CLI | ~/.codex-box/state/ |
Proven |
deepseek-box |
DeepSeek Harness (dsh) |
~/.deepseek-box/state/ |
Proven; upstream Harness is itself a developer preview |
pi-box |
Pi coding agent | ~/.pi-box/state/ |
Unverified, not yet exercised in anger |
The boundary is deliberately tight by default. The safety posture in one view:
| Concern | Default posture |
|---|---|
| Host filesystem | Only the project dir (at its exact host path) and any explicit extra mounts are visible. Nothing else. |
| Host docker socket | Never mounted. The box refuses to start if one is present, since a host socket is root-equivalent control of the host. |
~/.claude skills, plugins, hooks, .mcp.json |
Mounted read-only, so the container reflects your local setup but cannot alter it. |
~/.ssh |
Mounted read-only; the SSH agent is forwarded. Drop both with --no-ssh. |
| Published services | Bind host loopback (127.0.0.1) only, never the LAN. |
| Nested containers | Run on an engine inside the box whose authority is bounded by the cage. No host root. |
| Persistent state | Each box keeps its own state dir; delete it to reset that box. |
The rest of this document explains how each of those is wired, and how to widen the boundary deliberately when a project needs it.
- The project dir is bind-mounted at its exact host path, so skill symlinks resolve correctly.
~/.claude-box/state/persists Claude Code state (settings, conversation history) across runs. Delete it to reset.~/.claudeskills, plugins, hooks, and.mcp.jsonare bind-mounted read-only, so your local setup is always reflected.- Symlinked skills that point outside
~/.claude/have their parent dirs auto-mounted. - Sessions for the current project are written back to
~/.claude/projects/<slug>/on the host, so a conversation started in a box is resumable from host Claude, and the reverse. Session-keyed satellite state (todos, plan-mode drafts, file-edit history) is shared too. - macOS Keychain credentials are extracted at launch and written to the state dir.
settings.jsonis synced on first run (stripping sandbox/hooks, rewriting macOS-specific paths).- A nested container engine runs inside the box (rootless dockerd by default), so
dockeranddocker composework in-session without ever exposing the host's docker socket.
agent-box is four payloads on a shared cage. The cage is the isolation boundary: it owns the outer container, its security posture, the bounded nested engine, uid mapping, the ssh/colima relays, the image build, the exit-status contract, and the docker run assembly. It knows nothing about which agent runs inside it. A payload owns one thing: which agent command runs, and which host state it syncs.
┌────────────────────────────┐
│ cage │
│ libcage.sh │
│ Dockerfile.cage │
│ entrypoint-cage.sh │
│ isolation, nested engine, │
│ relays, docker run │
└─────────────┬──────────────┘
│ FROM cage-base
┌───────────┬─────────┴────────┬───────────┐
claude-box codex-box deepseek-box pi-box
Claude Code Codex CLI DeepSeek dsh Pi agent
(proven) (proven) (proven) (unverified)
libcage.shis the cage as a sourced shell library. The engine block, the userns probe ladder, env forwarding, and thedocker runinvocation live here exactly once.Dockerfile.cagebuildscage-base: git, gh, the nested docker engine, and the generic dev tooling, with no agent baked in.entrypoint-cage.shis its entrypoint (create the host user, start the engine, wire the relays, then exec whatever command the launcher hands it).- A payload is a thin wrapper plus a Dockerfile
FROM cage-base.claude-boxandDockerfile.claudeadd Claude Code;codex-boxandDockerfile.codexadd the OpenAI Codex CLI;deepseek-boxandDockerfile.deepseekadd the official DeepSeek Harness;pi-boxandDockerfile.piadd the Pi coding agent. Each wrapper sets a handful ofBOX_*variables and may define payload hooks for its own arguments, state, or published ports, then callscage_run "$@".
The four payloads keep the boundary honest: anything agent-specific that leaked into the cage would break another payload. To verify the boundary and the nested engine, run docs/cage-engine-acceptance.md.
- Docker Desktop, or Docker Engine on Linux
- Node.js (the settings sync script just needs
nodein PATH) - macOS or Linux
curl -fsSL https://raw.githubusercontent.com/shftwst/agent-box/main/install.sh | bashThe script clones agent-box to ~/.agent-box and symlinks the four box wrappers onto PATH (/usr/local/bin if writable, otherwise ~/.local/bin). Re-running it updates an existing checkout in place. Override the defaults with env vars: AGENT_BOX_DIR (install dir), AGENT_BOX_BIN (symlink target), AGENT_BOX_REF (branch or tag).
If you'd rather not pipe a script to your shell, do the same by hand:
git clone https://github.com/shftwst/agent-box ~/.agent-box
for box in claude-box codex-box deepseek-box pi-box; do
ln -sf "$HOME/.agent-box/$box" "/usr/local/bin/$box"
done
chmod +x ~/.agent-box/{claude-box,codex-box,deepseek-box,pi-box}The images build automatically on first run: a shared cage-base first, then the selected payload image on top of it. Each rebuilds when its own inputs change (see Architecture).
Every box runs in the current directory (the project root), so cd into your project first.
claude-box # interactive session
claude-box -c # continue most recent session
claude-box --resume # pick a session to resume (interactive picker)
claude-box -r <id> # resume a specific session by ID
claude-box -p "..." # non-interactive prompt (pipe-friendly)Every argument is passed directly to claude. Use -- to force everything after it to claude, so a claude flag that shares a name with a box flag can still be passed (e.g. claude-box -- --engine foo).
These box flags are consumed by the wrapper before claude sees them, and are position-free (they can appear anywhere on the command line):
--upgrade:git pullthe install dir and exit. See Updating.--no-ssh: skip mounting~/.sshand forwarding the SSH agent. Disables git-over-SSH and commit signing inside the container; useful for sessions that don't touch git remotes.--ollama <model>: point Claude Code at an Ollama server instead of the Anthropic API. See Ollama.--engine <mode>: nested container engine posture:auto(default),sysbox,rootless,privileged-dind,none. See Nested container engine.--name <name>: name the box's container (defaultclaude-box-<project>-<pid>), so a caller can address it withdocker stop/docker exec. Must match docker's charset[a-zA-Z0-9][a-zA-Z0-9_.-]*.--name-file <path>: write the resolved container name to<path>just before launch (removed on exit), so a headless supervisor can discover the box and stop it.
codex-box passes arguments directly to Codex and uses the same cage flags:
codex-box # interactive session
codex-box "fix the flaky test" # start with a prompt
codex-box resume --last # resume the latest sessionCodex state persists in ~/.codex-box/state/. A host codex login is seeded into the box, and refreshed account authentication is synced back on exit.
deepseek-box installs the official @deepseek-ai/dsh package. Because the Harness currently ships a browser UI rather than a terminal UI, a bare launch starts dsh web and publishes it on host loopback only:
deepseek-box # http://127.0.0.1:3080
deepseek-box --port 3081 # choose another local port
deepseek-box --profile headless "fix the tests" # one headless task
deepseek-box --version # any explicit dsh mode passes throughThe Harness stays bound to container loopback, as its safety policy requires. A cage-local bridge makes it reachable through Docker, which publishes only 127.0.0.1:<port> on the host; it is not exposed on your LAN. Set DEEPSEEK_BOX_PORT (also supported in .env.deepseek-box) to change the default. Open the tokenized loopback URL that dsh prints at startup; the token establishes the browser session before the Harness serves the UI.
DEEPSEEK_API_KEY, DEEPSEEK_BASE_URL, and DEEPSEEK_SEARCH_BASE_URL are forwarded when set. You can instead save the API key in the web UI; the Harness stores it under $DSH_HOME, which persists at ~/.deepseek-box/state/ on the host.
DeepSeek Harness is itself a developer preview upstream and may make breaking changes.
pi-box installs the official Pi coding agent (@earendil-works/pi-coding-agent) and passes arguments directly to its terminal UI or non-interactive modes:
pi-box # interactive TUI
pi-box -c # continue the latest session
pi-box -r # browse previous sessions
pi-box -p "review this repository" # one-shot print modePi deliberately relies on its runtime environment for isolation, so the shared cage supplies the filesystem and nested-engine boundary. Pi state persists in ~/.pi-box/state/, mounted as ~/.pi/agent; /login, settings, extensions, skills, packages, and sessions therefore survive container replacement.
The documented provider API keys and cloud-provider variables are forwarded when set, including ANTHROPIC_API_KEY, OPENAI_API_KEY, DEEPSEEK_API_KEY, and GEMINI_API_KEY. Use PI_BOX_EXTRA_VARS for a custom provider variable, or put project-specific values in .env.pi-box.
Note: pi-box is not yet verified to the standard of the other three. Treat it as experimental and check its behaviour before relying on it.
Run Claude Code against a local (or remote) Ollama server that exposes the Anthropic-compatible API, mirroring ollama launch claude --model <model>:
claude-box --ollama qwen3-coder:30b-a3b-q4_K_M
claude-box --ollama qwen3-coder:30b-a3b-q4_K_M -c # combine with other flagsThe flag is a command-line override (nothing is baked into .env.claude-box) and wires up, inside the container:
ANTHROPIC_BASE_URLfromOLLAMA_HOST(addinghttp://if the scheme is missing), falling back tohttp://localhost:11434ANTHROPIC_AUTH_TOKEN=ollama(a dummy token Ollama ignores but Claude Code requires)ANTHROPIC_MODELandANTHROPIC_SMALL_FAST_MODEL, both set to the model you pass
Because the base URL follows OLLAMA_HOST, pointing it at a Tailscale peer just works: set OLLAMA_HOST=100.x.y.z:11434 (as your ollama CLI already does) and the container reaches it over the normal bridge network. No --network host is needed, since a container can already route to any address the host can reach, including the tailnet.
The box ships its own container engine inside the cage: docker and docker compose work in-session, but their authority is bounded by the cage. The host's docker socket is never mounted; the entrypoint refuses to start if one is present, because a host socket is root-equivalent control of the host and would void the sandbox entirely.
Engine postures, selected with --engine (or CLAUDE_BOX_ENGINE_MODE in .env.claude-box):
auto(default):sysboxif the host docker has the sysbox-runc runtime, elserootless.sysbox: rootful dockerd inside a sysbox container. Strongest posture; requires sysbox installed on the host/VM.rootless: rootless dockerd (rootlesskit) running as the unprivileged in-box user. The cage stays non-privileged; the relaxations it needs areseccomp=unconfinedplussystempaths=unconfined(rootlesskit must create user namespaces and writenet.ipv4.ip_forwardin its own detached netns), the/dev/net/tunand/dev/fusedevices, and on apparmor hosts eitherapparmor=unconfinedor a tinyclaude-box-engineprofile granting theusernspermission.privileged-dind: rootful dockerd in a--privilegedcage. This is the weakest option, so it is explicit opt-in only, the launcher warns, and it should be a last resort.none: no engine.
On first launch (per docker host) the rootless posture probes for the weakest working relaxation set and caches it in ~/.claude-box/.userns-strategy-*:
plain: the relaxations above suffice (typical for Docker Desktop / OrbStack).profile/profile-cap: Ubuntu-lineage kernels (Colima VMs, Ubuntu 23.10+) restrict unprivileged user namespaces via apparmor. The launcher loads theclaude-box-engineapparmor profile into the VM kernel through a privileged one-shot helper (host-altitude setup by the human-run wrapper; the cage itself never gets that authority), andprofile-capadditionally addsCAP_SYS_ADMINto the cage's bounding set, which the kernel demands for the setuidnewuidmaphelpers there. The cap is latent: unprivileged in-box processes only reach it through those helpers.cap:CAP_SYS_ADMINalone (restricted kernel without apparmor mediation).
Delete ~/.claude-box/.userns-strategy-* to force a re-probe (e.g. after changing docker runtimes).
Engine storage is an anonymous volume, removed when the session ends, so nested images don't persist across sessions. To keep a warm image cache, mount a named volume at the engine's data root (one box at a time, since concurrent daemons on one data root won't start):
CLAUDE_BOX_EXTRA_MOUNTS=("claude-box-engine-cache:/var/lib/claude-box-engine") claude-boxVerification: run the four checks in docs/cage-engine-acceptance.md inside the box. Expect docker info --format '{{.SecurityOptions}}' to contain name=rootless under the default posture, and /var/run/docker.sock to be absent (rootless) or owned by the nested daemon (rootful).
The default forwarded vars are ANTHROPIC_API_KEY, GITHUB_TOKEN, GITHUB_PERSONAL_ACCESS_TOKEN, and LINEAR_API_KEY.
To forward additional vars without modifying the script:
CLAUDE_BOX_EXTRA_VARS=(TURSO_AUTH_TOKEN MY_API_KEY) claude-boxOr set it in your shell profile:
export CLAUDE_BOX_EXTRA_VARS=(TURSO_AUTH_TOKEN NETLIFY_AUTH_TOKEN)Drop a .env.claude-box file in a project root to supply project-specific values for any of the forwarded vars. It's sourced just before the env lookup, so values here override whatever's in your shell, which is useful when each project is backed by a different API key (e.g. a different LINEAR_API_KEY per workspace).
# .env.claude-box (in your project root, gitignored)
LINEAR_API_KEY=lin_proj_specific_xxx
TURSO_AUTH_TOKEN=ey...Plain KEY=value lines work, with no export needed. Add .env.claude-box to .gitignore (or rely on your global .env.* ignore) since it holds secrets. The other boxes read their own .env.<box> file the same way (.env.codex-box, .env.deepseek-box, .env.pi-box).
By default the box authenticates from your macOS Keychain subscription login, whose OAuth refresh token is single-use and rotating. That's fine for one box, but two concurrent boxes each refresh it independently: the auth server sees the same token spent twice, treats it as a leak, and revokes the whole lineage, silently logging every box (and often host Claude) out. Re-running /login in any one box heals them all, because the credential file is a shared mount, but the logout keeps recurring.
To run boxes concurrently, mint a long-lived OAuth token once and let every box share it. Claude Code uses this token as-is and never refreshes it, so there's nothing to race on:
# 1. Generate a long-lived token (opens the same browser flow as /login; ~1yr).
claude setup-token
# 2. Store it in the Keychain under this exact service name (one time).
security add-generic-password -U -s "Claude Code-oauth-token" -a "$USER" -w "<token-from-step-1>"From then on the launcher picks it up automatically and injects it as CLAUDE_CODE_OAUTH_TOKEN (you'll see using static OAuth token in the launch log). An already-exported CLAUDE_CODE_OAUTH_TOKEN takes priority over the Keychain item. If neither is present, the box falls back to the rotating subscription credential, so single-box use needs no setup and is unchanged. The shared credential mount stays in place either way, so /login still propagates across boxes. Re-mint and re-store when the token eventually expires.
The image bundles several deploy/cloud CLIs (aws, flyctl, netlify, wrangler, gh). Each authenticates non-interactively from a precise env var name, so forward the ones you need via CLAUDE_BOX_EXTRA_VARS (or set them per-project in .env.claude-box). Each CLI reads its own exact name, and the launcher forwards them verbatim, so don't rename them.
| CLI | Env var(s) | Notes |
|---|---|---|
aws (AWS CLI v2) |
AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_SESSION_TOKEN |
Session token only for temporary creds. AWS_DEFAULT_REGION (or AWS_REGION) sets the region; AWS_PROFILE selects a named profile. |
flyctl (Fly.io) |
FLY_API_TOKEN |
From flyctl auth token. FLY_ACCESS_TOKEN is also honoured. |
netlify |
NETLIFY_AUTH_TOKEN |
Personal access token. NETLIFY_SITE_ID targets a site without a linked repo. |
wrangler (Cloudflare) |
CLOUDFLARE_API_TOKEN |
Add CLOUDFLARE_ACCOUNT_ID when the token spans multiple accounts. Legacy CLOUDFLARE_API_KEY plus CLOUDFLARE_EMAIL global-key auth also works, but prefer a scoped token. |
gh (GitHub) |
GH_TOKEN or GITHUB_TOKEN |
GITHUB_TOKEN / GITHUB_PERSONAL_ACCESS_TOKEN are already in the default forwarded set. |
Example, forwarding AWS and Cloudflare creds for a project:
# .env.claude-box
CLAUDE_BOX_EXTRA_VARS=(AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_DEFAULT_REGION CLOUDFLARE_API_TOKEN CLOUDFLARE_ACCOUNT_ID)Note: flyctl and wrangler also fall back to on-disk config (~/.fly/, ~/.wrangler/ or ~/.config/.wrangler/) when the env var is absent. Those paths aren't in the default mount set, so nothing leaks in unless you mount them explicitly. Prefer forwarding a scoped token over mounting host credential dirs.
By default the container sees the project dir, ~/.ssh (read-only), and ~/.claude skills/plugins/hooks/.mcp.json (read-only), and never the host docker socket (a mount at /var/run/docker.sock makes the box refuse to start). To expose additional host paths inside the container, set CLAUDE_BOX_EXTRA_MOUNTS to an array of host:container[:opts] specs:
CLAUDE_BOX_EXTRA_MOUNTS=("$HOME/.aws:$HOME/.aws:ro" "/data:/data") claude-boxOr per-project in .env.claude-box:
# .env.claude-box
CLAUDE_BOX_EXTRA_MOUNTS=("$HOME/.config/gcloud:$HOME/.config/gcloud:ro")Each entry is passed straight to docker run -v, so the standard :ro / :rw / propagation suffixes all work. Use this when a project needs cloud credentials, a shared dataset, or any other host directory that isn't part of the default mount set.
Each payload owns a separate persistent state directory:
- Claude Code:
~/.claude-box/state/mounts at~/.claude. - Codex:
~/.codex-box/state/mounts at~/.codex. - DeepSeek Harness:
~/.deepseek-box/state/mounts at~/.dsh. - Pi:
~/.pi-box/state/mounts at~/.pi/agent.
Conversation history, project memories, settings, and harness-managed credentials survive container replacement. To reset one payload completely, remove its state directory. For example:
rm -rf ~/.claude-box/state/claude-box --upgrade # or codex-box / deepseek-box / pi-boxThis runs git pull --ff-only on the install dir and exits. The images rebuild automatically on the next regular run: cage-base when its inputs changed, and each payload image when its Dockerfile, payload inputs, or cage-base changed.
On startup, a box does a backgrounded git fetch against the install dir at most once every 24 hours. When the check finds you're behind upstream, the next launch prints a single hint line:
[claude-box] update available — run 'claude-box --upgrade' to pull latest
The check runs detached and adds no perceptible latency to launch; the hint disappears the next time --upgrade succeeds.
MIT. See LICENSE.