Give the agent full autonomy.
Give it nothing else.
sandbox-cli runs Claude Code, Codex, Gemini and twelve more coding agents inside a disposable Docker container. Only the project you point it at is mounted; HOME is a fake ephemeral path and your SSH keys, cloud credentials and browser cookies are not there to be read.
Needs Docker. Nothing else. macOS · Linux · WindowsMIT licensed · written in Go
$curl -fsSL https://raw.githubusercontent.com/Amitgb14/sandbox-cli/main/install.sh \| sh -s -- --version 0.0.1
Detects your OS and CPU, verifies the archive against the release checksums.txt, and installs to ~/.local/bin/sandbox-cli. No root, no package manager.
then
$sandbox-cli claudeClaude Code, contained$sandbox-cli run -- basha shell in the sandbox$sandbox-cli run --dry-run -- npm testprint the docker argv, run nothing
- awaiting a command…
- 1
- host path mounted
- your project, at /workspace
- 12
- agents wrapped
- one prefix, flags forwarded verbatim
- 0
- host creds forwarded
- default-deny env allowlist
- --rm
- every container
- nothing survives the run
“Allow All” is the mode that makes agents useful.
It is also the one that scares you.
Agents earn their keep the moment they stop asking permission for every edit. But the same flag that unblocks the work hands a non-deterministic process your entire home directory — and prompt injection turns text somebody else wrote into commands your shell runs.
- Reads ~/.ssh, ~/.aws, cloud tokens, browser cookies
- One hallucinated path and the blast radius is your whole disk
- A poisoned README turns into local execution
- The only thing enforcing trust is the prompt
- Those paths were never mounted — there is nothing to read
- The blast radius is /workspace: the repo it was editing anyway
- Injection lands in a container that dies on exit
- The kernel enforces trust, not a paragraph of instructions
This is the default when you run an agent with “Allow All” on your machine. Pick a path to read what is at stake.
One function decides everything
runtime.BuildArgs is pure and deterministic: config in, docker argv out. It is the single choke point for what the container can reach, and it is exhaustively unit-tested against a golden output so the boundary cannot drift silently.
Three refusals no flag can override
Never mount /, never mount your home directory, never mount an ancestor of it. ResolveWorkspace enforces those before anything else runs, and there is no configuration key that turns them off.
Nothing crosses that you did not name
Host environment variables are default-deny. Each agent wrapper ships a small suggested allowlist applied only if the value is set, and everything else needs an explicit --env or --mount.
Thirty-two capabilities, one prefix
Everything below ships today and is reachable from a flag or a .sandbox.yaml key. Filter by the question you came with.
- on by default
Two profiles, neither of them lax
--profiledev and prod are both secure — they differ in what they optimise, never in whether the boundary holds. The one difference of kind: a control the host cannot provide is a warning under dev, because a developer is watching, and a refusal under prod, because nobody is. A committed .sandbox.yaml may demand the stricter profile and may never ask for the weaker one.
sandbox-cli claude --profile prod
- on by default
Check the host before you trust it
doctorAsks whether this machine can actually deliver what the profile promises: docker reachable, a syscall filter really applied, a container able to program the egress firewall — tried, not queried, because rootless and userns-remapped daemons cannot — and which OCI runtimes are registered. Non-zero exit under prod, so a scheduler notices before an unattended run does.
sandbox-cli doctor --profile prod
- on by default
The agent never holds a long-lived credential in prod
Persisted agent login is off under prod, and that is the whole answer rather than a mitigation: with it on, the agent's HOME holds an OAuth refresh token it can read. prod does not mount it, so there is nothing to steal and no TLS-intercepting proxy is needed to hide it. Prod authenticates with scoped, revocable tokens through the secrets broker.
- opt-in
A fallback when a provider is down
--fallbackClaude's API having an outage should not mean the afternoon stops. A chain runs the next agent instead — the provider is probed before launching, so an outage skips that agent before a container exists, and a run that failed having changed no files is retried with the next one. A run that changed files is never retried: that is a failed attempt, not an outage, and handing the next agent half-finished edits is the thing this must never do. When it fires, the previous agent's briefing is carried across — what was asked, what it said it was doing, and a ledger of the files it touched, derived from git rather than from anything the agent claimed. It is a briefing, not a resume, and says so: session ids do not cross between vendors and neither do transcripts. Both halves work in Studio too: the daemon outlives the launch, so it watches the run and hands the work over when one fails quietly — a daemon restarted mid-run leaves that run alone rather than guessing. The same handover is something you can ask for: every conversation in Studio offers Continue with, where picking the agent that held it reopens it and picking another starts that one with the briefing instead. It is recorded as a handoff rather than as routing, because a provider going down and a person choosing are different answers to why this agent is doing that work.
sandbox-cli claude --fallback codex 'fix the flaky test'
- on by default
Sessions you can list, follow, attach to and stop
list / logs / attach / killA kill -9 on sandbox-cli leaves the container running — the daemon owns it, not the client — with an agent still writing to your project, and --detach means to. Four commands address one by id, container name or branch, and a reference is matched against sandbox-cli's own containers rather than handed to the engine, so kill postgres finds nothing instead of your database. attach cannot kill: Ctrl-C detaches and the agent keeps working.
sandbox-cli list --all
- on by default
Carry a conversation on, or hand it to another agent
Studio → Agents → ConversationsEvery conversation Studio lists offers Continue with: pick the agent that held it and it reopens, pick another and that one starts with a briefing about it. The second is deliberately not a resume — a session id is a primary key into one vendor's private store, so what crosses is HANDOFF.md, a vendor-neutral transcript and a file ledger derived from git, mounted read-only, with a prompt that tells the target it is reading a briefing rather than its own history. Two transcript formats are parsed against a confirmed shape, claude's and codex's; a conversation in any other is listed with its id and dates and marked unknown rather than guessed at, and cannot be handed over, because a briefing carrying nothing would claim a conversation crossed when it did not.
- on by default
Containers you left behind, found and reaped
cleanDetached and fleet containers are kept after they exit, unlike every other run: their exit code and their logs are the only record the work happened, so --rm would delete exactly what you came back for. clean reaps them once you have read what you needed, and stopping a still-running one takes --force.
sandbox-cli clean
- on by default
A run log that says what ran, under what policy
One line per run in ~/.config/sandbox/audit/sessions.jsonl: image, workspace, branch, agent, command, network posture, the resolved egress allowlist, exit code and duration. Environment variables are recorded by name only — never a value, because a log is a file and the broker exists to keep secrets out of those.
~/.config/sandbox/audit/sessions.jsonl
- on by default
And what it was refused
A non-interactive run under an allowlist — CI, a redirected shell, --no-tty — records how many egress refusals it reported and a sample of the names, so you can answer "did this go looking for something it was not allowed to?" without the scrollback. Read the coverage before relying on it: an interactive session records nothing, because with a pty docker returns one merged stream and reading it would cost the container its terminal size; and --detach and fleet tasks record nothing yet either, since nothing reads their output back from docker logs. The field is called egress_denied_reported rather than egress_denied on purpose: the proxy prints those lines on the container's stderr, which the agent can write to as well, so this is the container's report and not an attested fact.
"egress_denied_reported": 2, "egress_denied_hosts_reported": ["gist.github.com"]
- on by default
Agents install a version somebody chose
Eight of the twelve wrappers download their agent from a vendor host the first time you run it. Each one installs a version recorded in the tool, announced as it installs, rather than whatever the vendor published that morning — so a hijacked or typosquatted release does not reach a sandbox until someone bumps that line. It does not defend against a compromised registry serving different bytes for a version it already published; that needs integrity hashes a global install has no lockfile for. Self-updating agents are unaffected after the first run.
sandbox-cli: installing qwen 0.21.3 into the sandbox agent home (first run only)...
- on by default
One host path, mounted on purpose
The project you chose is bind-mounted at /workspace and nothing else is host-connected. HOME, /etc and / inside the container are ephemeral and destroyed on exit.
~/projects/app -> /workspace (the only host-connected path)
- on by default
Refusals you cannot configure away
sandbox-cli refuses to mount /, your home directory, or any ancestor of it as the workspace. That check lives in ResolveWorkspace and no flag, config file or env var overrides it.
- on by default
One pure function builds the argv
--dry-runEvery isolation decision funnels through runtime.BuildArgs — deterministic, no I/O, exhaustively unit-tested against a golden output. --dry-run prints the exact docker command and exits, so you can read the boundary before you trust it.
sandbox-cli run --dry-run -- npm test
- on by default
Hardened container by default
--no-hardeningEvery run drops all Linux capabilities, forbids privilege escalation, and caps the process count to blunt fork bombs. Non-root by default, which is also why agents accept --dangerously-skip-permissions in here.
--cap-drop ALL --security-opt no-new-privileges --pids-limit 1024
- opt-in
Stronger isolation on request
--runtimePoint a run at any OCI runtime the daemon knows: kata-fc or kata-clh for a microVM with its own kernel, runsc for gVisor's userspace kernel. Mounts, hardening, caches, secrets and the egress allowlist all work unchanged on top. gVisor takes two adjustments — it has no connection tracking, so the allowlist is built by uid and destination instead and inbound filtering is skipped (nothing inside can answer an unsolicited connection anyway), and it cannot reach docker's embedded resolver, so the host's own nameservers are supplied.
sandbox-cli claude --runtime kata-fc
- on by default
Default-deny environment
--env-allowNothing from your host environment crosses the boundary unless you name it. Each agent wrapper ships a small suggested allowlist — ANTHROPIC_API_KEY and friends — applied only if the value is actually set.
- opt-in
Credential broker
--secretResolve a secret at run time from a file, a host command, or a host env var, and forward it by name. The raw value never appears on the docker argv, in --dry-run output, in config, or in your shell history — and cmd: sources can be short-lived tokens fetched fresh each run.
--secret GITHUB_TOKEN=cmd:'gh auth token'
- on, --flag to disable
Logins that survive --rm
--no-persist-authEach agent gets its own sandbox-owned directory bind-mounted as the container's whole HOME, so you authenticate once. It is separate from your real ~/.claude — the sandbox never reads or writes your host agent config.
~/.config/sandbox/agents/claude -> /sandbox/home
- on by default
Egress allowlist, on by default
--allowOutbound traffic is default-deny, enforced by an in-container firewall and decided by hostname rather than by resolved address. DNS, established flows and a baseline of agent APIs and package registries stay open, so npm install and git keep working. Worth knowing the limit: the baseline includes github.com, a write endpoint — so this bounds and logs exfiltration rather than ending it. network.baseline: false with an explicit allow is the stronger setting, and is what prod uses.
sandbox-cli claude --allow internal.registry.example.com
- on by default
Fails closed
The firewall is programmed at startup with NET_ADMIN — added only in this mode — and the run then drops back to the non-root sandbox user. If setup errors, the run fails rather than silently continuing wide open.
- opt-in
Publish a port when you want to look
--publishNo container port is reachable from the host until you ask. When you do, a spec that names no address binds to 127.0.0.1 rather than every interface — the one place sandbox-cli deliberately differs from docker -p. Put your dev-server ports in .sandbox.yaml and stop typing them.
sandbox-cli run -P 3000 -- npm run dev
- opt-in
Reach host services deliberately
--host-gatewayAn agent can talk to an MCP server on your machine through host.docker.internal. That resolves automatically on Docker Desktop; on Linux this flag maps it, and --add-host handles anything else.
- opt-in
Parallel agents on real git worktrees
--worktreeRun several agents at once, each on its own branch, in its own container, with no collisions. The worktree lives in a sandbox-owned directory so your checkout stays clean, and the branch shows up in your repo immediately.
sandbox-cli claude --worktree feature-a -- -p 'implement A'
- opt-in
Addressable by branch, never by cd
worktree list, path, git, commit and rm all take the branch name. worktree git forwards everything after it straight to git — output and exit code included — so your config, hooks and commit signing still apply.
sandbox-cli worktree commit feature-a -m 'implement A'
- opt-in
Commits attributed to you
--gitForwards your host user.name and user.email and marks the workspace trusted, so git in the container stops complaining about dubious ownership and the agent's commits carry your name.
- opt-in
A channel between sandboxes
--shareTwo sandboxes cannot see each other — that is the point, but it leaves no way to hand over an API contract. --share mounts one host directory at /shared in every sandbox that asks for it, from a shell or from Studio's Launch screen. Then just say so in the prompt.
- opt-in
Pasted image paths that resolve
--pasteYour terminal pastes an image as an absolute host path, which names nothing inside a container. This mounts ~/Desktop, ~/Downloads and ~/Pictures read-only at their own host paths so the path resolves. Opt-in, because it widens what the agent can read.
- opt-in
Package caches that persist
--cacheContainers are --rm, so a cold npm install every run gets old fast. --cache keeps npm, pip, cargo and go caches in named Docker volumes across runs — no host directory involved.
- on by default
Layered project config
Built-in defaults, then ~/.config/sandbox/config.yaml, then the nearest .sandbox.yaml walking up from cwd, then flags. sandbox-cli config show prints what actually won.
sandbox-cli init # scaffold .sandbox.yaml
- on, --flag to disable
Live resource gauge
--no-metricsNon-interactive runs pin a memory/CPU/elapsed gauge to the bottom of the terminal with the workspace's git branch at the right — the thing that tells parallel worktree sandboxes apart at a glance. Measurement only; no limits are imposed.
sandbox-cli │ mem 512MiB/7.6GiB [▓░░░░░░] cpu 82% · 0m47s git:feature/login
- on, --flag to disable
A status line inside Claude
--no-statuslineClaude Code renders the container's live memory and CPU in its own UI, plus the model answering and how much of your 5-hour and weekly windows is left — both from the JSON Claude already pipes to the hook. Injected through a managed-settings file that never touches your own Claude settings. Deliberately limited to claude: no other agent has a status-line hook, and running them under tmux to fake one made their TUIs render badly.
⬢ sandbox · opus 5 · mem 412MiB · cpu 82% · 5h 23% (2h14m) · wk 49% git:feature/login
- opt-in
How much of the window is left
sandbox-cli usage prints the same two subscription windows from anywhere — a second terminal, or a run that already finished. Several sandboxes on several branches are separate containers but one account quota. The reading comes from the cache Claude Code keeps for its own /usage, so the command always prints how old it is, and a window that has since reset shows no percentage rather than a figure about the period before it. --refresh spends one throwaway turn to make it current; --json for scripts. And when the file is being written while the reading in it is not — the agent running, but no longer recording usage there — it says so instead of offering a refresh that cannot help, because an old reading and a dead one are fixed by opposite things.
5h 23% resets in 2h14m · week 49% resets in 4d
- on by default
Peak summary on every run
Interactive sessions own the screen, so instead of drawing over them sandbox-cli samples in the background and prints one line when the run exits. You still get the numbers for a twelve-minute Claude session.
sandbox-cli: peak mem 412MiB · cpu peak 138% · 12m04s · git:feature/login
- opt-in
Watch every sandbox at once
sandbox-cli stats is a refreshing table of all running sandbox containers in a second terminal — the answer for the agents that have no status line of their own. --once for a scriptable snapshot.
sandbox-cli stats --interval 1s
Build the command. Read the argv. Then decide.
Every flag resolves into a plain docker invocation, and --dry-run prints it before anything executes. Toggle real options and watch the boundary widen or tighten — the arrow beside each flag tells you which way it moves.
agent
sandbox-cli claude --dry-runHighlighted lines are the ones your toggles added. Hover any line for what it does. The only host paths in the whole command are the --mount sources — count them, and that is the blast radius.
Everything above has a .sandbox.yaml key
Flags are for one run; a .sandbox.yaml committed at the root of the repo is the boundary everyone on the project gets by default. Nothing here is required — sandbox-cli init scaffolds the file, and every key falls back to a working default.
precedence — later wins, key by key
- 1config.Default()
Built-in defaultsNon-root sandbox user, /workspace, a fake HOME, all capabilities dropped.
- 2you, everywhere
~/.config/sandbox/config.yamlPreferences that follow you across every project on this machine.
- 3the project
Nearest .sandbox.yamlFound by walking up from the current directory. Commit it; everyone on the repo gets the same boundary.
- 4this one run
Command-line flagsScalar flags override the file for one run; the list-shaped ones — --mount, --env-allow, --allow, --publish — add to what it declared rather than replacing it.
sandbox-cli config show prints the merged result for the directory you are standing in, and sandbox-cli config path says which files it read.
.sandbox.yaml# .sandbox.yaml — commit this with the project.# It travels with the repo, so it is treated as UNTRUSTED: it may describe# the project, and tighten the sandbox, but never loosen it.# hostname: sandbox # cosmetic# cache:# enabled: true # npm/pip/cargo/go caches survive the --rm container# network:# mode: none # tighter than the default allowlist. "default" is# # REFUSED here — a project may not widen egress.# profile: prod # a repo that handles untrusted input may demand the# # stricter profile. It may never ask for the weaker one.
Almost nothing belongs here, and that is the design. A .sandbox.yaml travels with the repository and the agent can rewrite it mid-run, so the keys that choose an image, mount a host path, forward a credential or relax confinement are refused outright — and any network.mode or profile that weakens what is already in force is refused too. Setting one makes every sandbox-cli command in that directory fail, on purpose.
Every key, and what you get without it
imagestringunset → built-in sandbox-baseyour config onlyThe base image tag is content-addressed (sandbox-base:<gen>-<hash>) so it rebuilds itself whenever the image definition changes. Pinning your own tag opts out of that.
workdirpathunset → /workspaceyour config onlyWhere the project is mounted and where the guest command starts.
usersandbox | rootunset → sandboxyour config onlyNon-root by default — which is also why agents accept --dangerously-skip-permissions in here; they refuse it as root. On macOS, bind-mount ownership is virtualized, so files are still written as you.
homepathunset → /sandbox/homeyour config onlyThe fake HOME. Nothing under it is host-connected unless an agent wrapper persists its login there.
profiledev | produnset → devproject may tighten onlydev warns when a control the host cannot provide is missing, because a developer is watching. prod refuses, because nobody is. prod also means allowlist egress with the baseline off, no persisted login, no host history mount, seccomp required and bounded resources. Both are secure — they differ in what they optimise, not in whether the boundary holds. A project may demand prod and may never ask for dev.
persist_authboolunset → true for agent wrappersproject may tighten onlyKeeps the agent login across runs by mounting a sandbox-owned host directory as the agent's whole HOME. Worth knowing what that directory holds: a long-lived OAuth refresh token the agent can read. prod turns this off, which is why prod needs no TLS-intercepting proxy to protect a credential — it simply never carries one.
syncboolunset → trueproject may tighten onlyMounts this project's host agent history so sessions resolve on both sides of the sandbox. The one default that reaches a host path outside the workspace, scoped to the single project bucket.
routingstring[]unset → no routing — the agent you asked for is the agent that runsyour config onlyAgents to fall through when the one you asked for is unavailable, primary first. sandbox-cli probes the provider before launching and skips an agent that is not answering; on the command line it also retries a run that failed having changed no files, which is what a provider dying mid-run looks like. A run that changed files is never retried — that is a failed attempt, not an outage. User-config only: choosing the agent chooses which persisted login and which forwarded variables are in reach.
providersmap[agent]hostunset → each agent's own provider, and no probe for the ones that have noneyour config onlyWhich host routing probes for an agent, e.g. opencode: api.groq.com. It is what makes a provider-agnostic agent probeable at all, and what points the check at your own endpoint when an agent runs behind a proxy. Blank means do not probe. User-config only: a probe decides which agent a chain skips, so a host that always answers keeps a dead agent in play and one that never answers forces a fall through to another agent's login.
hostnamestringunset → sandboxThe container hostname, which is what an agent's prompt shows.
runtimestringunset → docker default (runc)your config onlyAny OCI runtime the daemon has registered: kata-fc or kata-clh for a microVM with its own kernel, runsc for gVisor. Mounts, hardening, allowlist and caches all work unchanged on top. Only names that say which hypervisor is underneath are reported as a kernel of their own — a bare kata resolves to whatever configuration.toml picks.
mountslist of { host, container, mode }unset → just /workspaceyour config onlyhost may start with ~ and may be relative to the config file that declared it. mode is ro unless you say rw. Refusals no key overrides: never /, never your home directory, never an ancestor of it.
envmapunset → emptyyour config onlyLiteral values injected into the container. Merged key by key, so a project file can add one without wiping your user-level set.
env_allowlistunset → the agent's own suggestionyour config onlyA default-deny allowlist of host variables, forwarded only when actually set. The one list that appends across layers instead of replacing.
secretsmap of { file | command | env }unset → noneyour config onlyBrokered credentials, resolved at run time and forwarded by name. Exactly one source per secret; a command: source can fetch a short-lived token fresh each run.
network.modeallowlist | default | noneunset → allowlistproject may tighten onlyallowlist is the default: a default-deny egress firewall is programmed inside the container at startup, then privileges drop back to the non-root user. If it cannot be programmed, the run fails instead of running open. Pass --network default to decline it for one run, or none to reach nothing at all. A project file may tighten this and never loosen it.
network.baselineboolunset → true — the built-in domains are permittedproject may tighten onlyfalse drops the built-in domain set so allow is the WHOLE list. It exists because allow could only ever add, leaving no way to decline github.com — which is a write endpoint, and so a channel for any token the agent holds. Turning it off is deliberately awkward: npm, pip and git stop working unless you list their hosts.
network.allowlistunset → baseline onlyyour config onlyExtra domains on top of the built-in baseline — agent APIs plus the common package registries — so npm install and git keep working. Replaces rather than appends, so a project can fully redefine it.
portslistunset → nothing publishedyour config onlyA spec with no address of its own binds to 127.0.0.1, not every interface — the one place sandbox-cli deliberately differs from docker -p. Write 0.0.0.0:3000:3000 to expose it on purpose.
security.no_new_privilegesboolunset → trueyour config onlyBlocks setuid privilege escalation inside the container.
security.cap_drop / cap_addlistunset → [ALL] / noneyour config onlyAll Linux capabilities are dropped, which is essentially free for the non-root sandbox user. Add one back only when a tool genuinely needs it.
security.pids_limitintunset → 1024your config onlyA fork-bomb guard set well above real build and agent process counts. 0 disables it.
security.memory / cpusstringunset → unlimitedyour config onlyOpt-in resource caps, e.g. 2g and 1.5. Empty leaves the container unbounded — sandbox-cli measures usage rather than throttling it.
security.seccompstringunset → docker's default profileyour config onlyPoint at your own seccomp profile when the default one blocks something you need — or set "required" to refuse the run unless the daemon actually applies one. Some daemons apply none and say nothing; sandbox-cli doctor tells you which yours is.
cache.enabled / cache.pathsbool / listunset → false / built-in dirsKeeps npm, pip, cargo and go caches in named docker volumes so a cold install every run stops hurting. No host directory is involved. Extra paths are added to the built-in set.
snapshot.enabled / interval / retention / manual_retentionbool / duration / duration / durationunset → true / 2m / 336h / 168hyour config onlyThe crash safety net: the workspace is committed under refs/sandbox/snapshots/ while a run is in flight, never touching your index, HEAD, branches or working tree. sandbox-cli recover reads it back. Checkpoints you take on purpose keep for manual_retention instead.
snapshot.s3blockunset → unset — snapshots never leave the machineyour config onlyMirrors each snapshot to a bucket as a git bundle, so a lost laptop is not a lost snapshot: git alone opens the object, on a machine that has never seen the repository. AWS and anything S3-compatible through endpoint and path_style. The credential is named, never held — access_key_env is the name of a variable read at upload time. sandbox-cli recover fetch lists what is in there and pulls one back. Retention prunes the local copy only; the bucket's own lifecycle rules govern the objects.
Some lists append, the rest replace
mounts and env_allow accumulate across layers — a project adds a variable without restating yours — and env and secrets overlay key by key. network.allow, ports, cache.paths and the security lists replace instead, so a project can fully redefine a policy, and say “none” with an empty list.
Relative paths follow their file
A mount written as ./data resolves against the directory of the config file that declared it, not your current directory — so the same .sandbox.yaml means the same thing no matter where in the repo you run from.
Omitted is not false
enabled, no_new_privileges and pids_limit are tri-state: leaving a key out keeps the inherited value, while writing it out explicitly overrides it — which is how a project turns a default-on setting off.
A firewall that stops exfiltration without stopping npm install
Filesystem isolation does nothing about an agent that has been talked into POSTing your .env somewhere. Flip the allowlist and watch which requests still leave.
api.anthropic.comthe model the agent is running onBaselineregistry.npmjs.orgnpm install, still workingBaselinegithub.comgit fetch, git pushBaselinepypi.orgpip installBaselinefiles.pythonhosted.orgthe wheels themselvesBaselineraw.githubusercontent.cominstall scriptsBaselineinternal.registry.example.comyour private registry — added with --allow--allowapi.continue.devan agent's own config endpoint, added with --allow--allowpaste.example.netthe exfiltration a prompt-injected agent was talked intoDeniedwebhook.attacker.tldyour .env, POSTed somewhere elseDeniedcrypto-pool.examplea miner the dependency chain brought alongDeniedtelemetry.unknown-vendor.iophone-home nobody asked forDenied
iptables inside the container at startup and then dropped back to the non-root user. It fails closed. Domains resolve to IPs once at startup, so hosts behind rotating CDN addresses can still be refused — add them explicitly.Three agents, three branches, one repo, zero collisions
Isolation stops being a tax the moment it lets you do something you could not do before. --worktree runs each agent in a real git worktree for its own branch, in its own container, so you can start three and go and do something else.
~/projects/appYour checkout, untouched and still on whatever branch you had. The worktrees live in a sandbox-owned directory, so the project folder stays clean.
~/.config/sandbox/worktrees/app-9f95/<branch>- feature-a
-p "implement the API"sandbox-dk0gtrd15s2gmem 412MiBcpu 82% - feature-b
-p "port the tests"sandbox-9f2la8hq4vznmem 308MiBcpu 61% - docs/rewrite
-p "rewrite the guide"sandbox-m4x1pq7bd0csmem 196MiBcpu 24%
git diff main...feature-asandbox-cli worktree commit feature-a -m "…"sandbox-cli worktree rm feature-aA container outlives the terminal that started it
The daemon owns the container, not the client — so a kill -9 on sandbox-cli leaves the agent working in your project, and --detach does it on purpose. Four commands address one by id, name or branch.
What is running right now — and, with --all, what has finished.
KIND is doing real work rather than decorating the row: fleet stop --all does not reach an interactive session, fleet clean does not reap one, and max_parallel does not count one — and the listing was the one place that distinction was invisible, which is exactly where somebody decides what to kill. The ID is the same one stats prints, so a row from either can be pasted into any of the other three.
A kill -9 on sandbox-cli leaves the agent running and still writing to your project, because the daemon owns the container rather than the client that started it — and --detach means to. These four commands are how you get back to it.
You can see what it is doing
A sandbox you cannot watch is a sandbox you will not trust. sandbox-cli measures — it never throttles — and reports in three places.
Why only Claude gets a status line
Claude Code has a statusLine hook, so the gauge lives in its own UI, injected through a managed-settings file that never touches your Claude settings. Neither Gemini CLI nor OpenCode has such a hook. Running them inside tmux to fake one was tried and reverted — it made their TUIs render badly, which is a bad trade for a gauge.
For every other agent the answer is sandbox-cli stats in a second terminal, plus the peak-usage line every run prints when it exits.
shared history
~/.claude/projects/<project> -> /sandbox/home/.claude/projects/-workspacesandbox-cli claude mounts your host Claude history for the current project so --resume inside the sandbox lists the sessions you started on the host, and sessions you run inside show up outside afterwards. Only that one project's directory is mounted, it is read-write, and --no-sync opts out.
non-interactive run — the sticky footer gauge
inside a sandbox-cli claude session — Claude's own status line
after every run, interactive included — the peak summary
12 agents, one prefix, your flags forwarded verbatim
sandbox-cli claude --dangerously-skip-permissions just works: a leading run of sandbox flags is consumed by sandbox, and the first token that is not one ends it — everything after goes to the agent untouched. 4 agents are baked into the base image; the rest install themselves into their own persisted home on first use, so you only download what you actually run.
Claude Code
Anthropic
sandbox-cli claude --dangerously-skip-permissions- login
- Run it and follow the prompt — a Claude account or ANTHROPIC_API_KEY.
- persisted at
~/.config/sandbox/agents/claude -> /sandbox/home
forwarded only if set
ANTHROPIC_API_KEYANTHROPIC_AUTH_TOKENANTHROPIC_BASE_URLCLAUDE_CODE_USE_BEDROCKCLAUDE_CODE_USE_VERTEX
The only wrapper with a live memory/CPU status line in the agent's own UI, and the only one that shares your host conversation history for this project so --resume works on both sides.
Two clients, one daemon
Everything the CLI does happens on the machine running the daemon, so driving it from code is a client rather than a second implementation. Both speak the same endpoints and keep the same rules; they differ in the language they are read in.
@sandbox-cli/sdk
For an agent, a CI step, or a workflow that fans out across branches. npm install @sandbox-cli/sdk— typed against a contract generated from the daemon's own Go types, so a client that describes an endpoint the daemon does not have fails a build.
sandbox-cli-sdk
For a LangGraph node, a FastAPI handler, or untrusted code a model just wrote. pip install sandbox-cli-sdk, then import sandbox_cli— sync and async from one implementation, and no dependencies, because it is imported into somebody else's agent process.
Where this sits, including where it loses
Running an agent in a disposable container is a crowded space. sandbox-cli's edge is code quality, ergonomics and a focused feature set — not a hard security boundary. If you need one of those, the table says so.
| sandbox-clithis project | Built-in agent sandboxesClaude / Codex | Docker Sandboxessbx | Native OS toolsSeatbelt / Landlock | Cloud microVMsE2B, Daytona, … | |
|---|---|---|---|---|---|
| Isolation strengthHow hard the wall actually is | Good — Docker + hardening, optional gVisor/Kata | Medium — OS-level, shared kernel | Excellent — microVM / Firecracker | Good — kernel primitives | Excellent — microVMs |
| Local, no cloudYour code never leaves the machine | Yes | Yes | Yes | Yes | No |
| Persistent agent authLog in once, not every run | Excellent — dedicated persistent home | Varies | Good | Varies | Varies |
| Package cache persistenceNo cold npm install every run | Yes — --cache volumes | Limited | Good | Manual | Often built-in |
| Parallel agents (worktrees)Several branches at once, no collisions | Excellent — built-in --worktree | Poor | Good | Poor | Varies |
| Credential brokerSecrets off the argv and out of history | Basic — references resolved; prod mounts no token at all | Basic | Good — proxy | Varies | Good |
| Egress / network controlStop exfiltration, keep installs working | Strong — allowlist with baselines | Basic | Strong | Varies | Strong |
| Observability / metricsWhat is this thing actually doing | Good — live gauge, stats, per-run log; no per-command trace | Limited | Good | Poor | Varies |
| Project configPer-repo policy, checked in | Excellent — .sandbox.yaml | Limited | Good | Poor | API / config |
| Dry-run / previewRead the boundary before trusting it | Yes | No | Varies | No | Varies |
| Ease of use | High — CLI-focused, thorough docs | High | High | Medium | Medium — setup |
| Cross-platform | Good — macOS / Linux / Windows | Good | Excellent | Platform-specific | N/A |
| Docker dependency | Yes | No | Yes | No | No |
| Best for | Local multi-agent workflows, ergonomics | Quick minimal protection | Strongest local isolation | Lightweight, zero deps | Scale & long-running tasks |
This is the project’s own read of the landscape, and the ratings for other tools are a snapshot that will age — check their docs before choosing. sandbox-cli’s edge is code quality and a focused feature set, not a hard security boundary; for that, reach for microVM tooling (or run sandbox-cli on top of one with --runtime).
Two rows here used to be kinder. Credential brokersaid “Excellent” while the value still reaches the container’s environment, where the agent can read it with printenv — a broker that injects the credential so the agent never holds it is not built. Observabilitysaid “Excellent” with no per-command trace and no replay. Both were corrected against the code rather than left standing, which is the rule this table is held to: a row that cannot be defended gets changed here.
- sandbox-cli
- Built-in agent sandboxes
- Cloud microVMs
Only the emphasised series is labelled; hover any bar for its score, and the table above is the full, readable version of the same judgement. Isolation is the axis where sandbox-cli deliberately does not win — a shared-kernel container is not a microVM, which is why --runtime exists.
Everywhere Docker runs
Almost everything works identically across platforms; the differences are all about the boundary the host can provide.
| Capability | macOSDocker Desktop | Linuxnative Docker | WindowsDocker Desktop / WSL2 |
|---|---|---|---|
| run, agent wrappers, mounts, env, hardening, metrics | supported | supported | supported |
| --cache, --secret, --worktree, --git, --share | supported | supported | supported |
| Egress allowlist (--allow)The firewall runs iptables inside the Linux container, so it works wherever the container kernel is Linux. Verified in CI on native Linux; not yet independently verified on Docker Desktop. | partly verified | supported | partly verified |
| --host-gatewayhost.docker.internal resolves automatically on Docker Desktop; native Linux needs the flag. | auto | needed | auto |
| /workspace file ownershipOn native Linux, use --user "$(id -u):$(id -g)" if ownership matters. | virtualized to you | container uid | virtualized to you |
| --runtime kata-fc / runscDocker Desktop runs containers in its own managed Linux VM and won't let you register custom OCI runtimes — you already get a VM boundary from Docker Desktop itself. | not available | supported | not available |
Two deployments. Neither of them is the lax one.
The same tool runs on the machine where you work and on the machine nobody is watching, and those pull in different directions — but not towards a weaker boundary. Local development is where a prompt-injected agent has the most valuable thing in reach, so --profile changes what each optimises within a secure baseline, plus one thing of kind: a control this host cannot provide is a warning under dev and a refusal under prod.
You are watching, so it warns
Optimised for the loop you are actually in: log in once, resume yesterday's conversation, commit as yourself, fan out across branches. The host boundary is identical to prod — this is where your credentials and your other repositories live, so nothing about it is relaxed.
| Within the same host boundary | Local development | Production |
|---|---|---|
| EgressThe baseline contains github.com, a write endpoint and so an exfiltration channel for any token the agent holds. Prod drops it; an allowlist that resolves to nothing refuses the run rather than starting unfiltered. | allowlist + 9 baseline domains | allowlist, baseline off — you name the list |
| Persisted agent loginThe default auth path is an OAuth refresh token in the persisted HOME, which the agent can read. Prod does not mount it and authenticates through the secrets broker instead, which is the whole answer to the credential problem — no TLS-terminating proxy required. | on — log in once | off — nothing to steal |
| Host conversation history | this project's bucket, mounted rw | not mounted |
| Seccomp missing on the daemonThe one difference of kind. You are there to read a warning; a scheduler is not, and a production run that quietly degraded is the failure the profile exists to prevent. | warns | refuses, non-zero exit |
| memory · cpus · pids | unlimited · unlimited · 1024 | 2g · 2 · 512 |
| Ports publishedAn inbound port is the one thing that opens the boundary the other way, and prod is likelier to be multi-tenant. It was once asserted on the config alone, so the flag went straight past it and opened a matching hole in the ingress chain; it is checked on the run now. | whatever you ask for | none — `--publish` is refused too |
| Container user | sandbox (non-root) | sandbox (non-root), asserted |
- 1
Ask the host what it can deliver
sandbox-cli doctor
Reports whether the daemon applies a syscall filter, whether a container here can program the egress firewall — tried, not queried — and which OCI runtimes are registered. Under dev a control the host cannot provide is a warning, so you can read it and decide; the run still starts.
- 2
Run the agent where you already are
cd ~/your-project sandbox-cli claude
No profile flag needed — dev is the default. The first run builds the base image, which takes a few minutes once. Only this directory is mounted, at /workspace; HOME inside the container is a fake path that dies with it.
- 3
Log in once, not every run
# nothing to do — this is the default sandbox-cli claude --no-persist-auth # opt out for a throwaway session
The agent's whole HOME is a sandbox-owned host directory (~/.config/sandbox/agents/claude), separate from your real ~/.claude, so the login survives the disposable container. Your host conversation history for this one project is mounted too, so a session started on the host resumes inside the sandbox and vice versa — --no-sync opts out.
- 4
Let it commit as you
sandbox-cli claude --git
Forwards your git identity and marks /workspace as a trusted directory, so commits carry your name and email instead of failing on dubious ownership. Without it the agent can still edit files; it just cannot commit cleanly.
- 5
Widen egress only when a build needs it
sandbox-cli claude --allow deb.debian.org --allow proxy.golang.org
Dev already runs default-deny with a baseline covering the agent APIs, npm, PyPI and GitHub, so most projects need nothing. --allow adds to that list for one run. A proxy inside the container decides on the hostname it reads from the TLS SNI or the HTTP Host header, resolving fresh per connection, so an allowlisted domain does not let every host sharing its IP in with it.
- 6
Commit the project's own boundary
A .sandbox.yaml is untrusted input: it travels with the repo and the agent can rewrite it between runs.
sandbox-cli init # scaffolds .sandbox.yaml
So it may describe the project — ports, hostname, caches, snapshots — and never the security boundary. image, user, mounts, env, secrets, security.* and runtime are refused from it with an error naming the key, and network settings may only ever tighten what you already have in force. Put those in ~/.config/sandbox/config.yaml, or load a project file you have read deliberately with --config ./.sandbox.yaml.
- 7
Fan out across branches
sandbox-cli claude --worktree feat/api --detach -- -p "add pagination" sandbox-cli ps sandbox-cli worktree commit feat/api -m "wip"
--worktree runs the agent in a real git worktree for its own branch, in its own container, so three agents on three branches never collide. --detach puts it in the background and keeps the container after it exits, because the exit code and the logs are the whole supervision story.
- 8
Watch it, and pick up after it
sandbox-cli stats # live memory/CPU of running sandboxes sandbox-cli context list # conversations agents have had here sandbox-cli recover # what a crashed run left behind
Claude Code gets the gauge inside its own UI through its statusLine hook; every other agent gets stats in a second terminal, because faking a status line by wrapping their TUIs in tmux was tried and reverted. After a crash the files are usually already on disk — recover says so rather than pretending it saved you — and correlates the run with its transcript to print the resume command.
From a cold machine to a verified sandbox
Pick your platform. Every path ends the same way — with doctor, because installing the binary is the easy half and whether this host can actually deliver the isolation is a property of the machine.
- 1
Install Docker Desktop
brew install --cask docker
Then launch it once and let it finish starting — the daemon has to be running, not merely installed. Apple silicon and Intel are both fine.
- 2
Turn seccomp back on
Some Docker Desktop configurations leave the syscall filter off, so the container gets the whole syscall table. If Settings → Docker Engine sets "seccomp-profile": "unconfined", remove that line and apply. If it sets nothing — which happens — the containerd image store (Settings → General) has been reported to leave the filter off by itself; check `docker info` for "Profile: unconfined" either way. sandbox-cli warns when it finds this, and refuses under --profile prod.
- 3
Install sandbox-cli
curl -fsSL https://raw.githubusercontent.com/Amitgb14/sandbox-cli/main/install.sh | sh
Drops a single static binary on your PATH, and — on a machine that has none yet — writes ~/.config/sandbox/config.yaml with every default spelled out. `go install github.com/Amitgb14/sandbox-cli/cmd/sandbox-cli@latest` works too if you would rather build it; it writes no config, and the built-in defaults are the stricter ones.
- 4
Know what the default config chose for you
sandbox-cli config path # which files were consulted sandbox-cli config show # the resolved configuration # ~/.config/sandbox/config.yaml # profile: dev # network: # mode: default # <- change to allowlist, or none
The file is the trusted layer: everything in it is something you could have typed. It ships `network.mode: default`, so a fresh install reaches the whole internet and works with any agent, model provider or private registry without a domain list to maintain. The host boundary does not depend on that — your home, your keys and your other repositories are still unreachable — but an agent can post what it *can* read anywhere, so change the one word to `allowlist` when you want that bounded. An existing file is never overwritten by an upgrade, and `--no-config` skips writing one.
- 5
Check the host can actually deliver it
sandbox-cli doctor
Reports whether the daemon applies a syscall filter, whether a container here can program the egress firewall — tried, not queried — and which OCI runtimes are registered. Add --profile prod on a machine that will run unattended: it turns every warning into a refusal with a non-zero exit, so a scheduler finds out instead of you.
- 6
Run an agent
cd ~/your-project sandbox-cli claude
The first run is slow twice over and mostly silent: it builds the base image (a few minutes), and the claude wrapper then downloads a self-updating copy of Claude Code into the persisted agent home — a large binary, with no progress shown. Interrupting either throws that work away and the next run starts from scratch, so let the first one finish; later runs start immediately. To watch it instead of guessing, run the install with its output visible: `sandbox-cli run -- sh -c 'curl -fsSL https://claude.ai/install.sh | bash'`. Only this directory is mounted; HOME inside the container is fake and dies with it. Egress follows the config from two steps ago — unrestricted as written, default-deny the moment you set `mode: allowlist` or pass --allow.
Your first ten minutes, end to end
Everything above explains one idea at a time and can be read in any order. This is the part that is a sequence: install, check the host, read the argv, push on the boundary, then run the agent. Each step says what you should see, so you can tell whether to continue or stop.
curl -fsSL https://raw.githubusercontent.com/Amitgb14/sandbox-cli/main/install.sh | sh sandbox-cli version docker info >/dev/null && echo 'docker ok'
The installer detects your OS and CPU, verifies the archive against the release checksums.txt, and drops a single binary in ~/.local/bin. No root, no package manager, nothing to add to your shell profile beyond having that directory on PATH. Docker is the one real prerequisite — Docker Desktop on macOS and Windows. On a machine that has none, it also writes ~/.config/sandbox/config.yaml: every default spelled out and commented, with `profile: dev` and `network.mode: default` — unrestricted egress, so the first run works with any agent and provider. An upgrade never overwrites it, and --no-config skips it.
You should see A version line and the base image tag, then `docker ok`. If docker is not reachable, everything below fails at the same place with the same message. `sandbox-cli config show` prints what that file resolved to.
sandbox-cli doctor
Installing the binary is the easy half; whether this machine can provide the isolation is a property of the machine. doctor checks the daemon is reachable, whether a syscall filter is really applied, whether a container here can program the egress firewall — tried, not queried, because rootless and userns-remapped daemons cannot — and which OCI runtimes are registered. Under dev an unsatisfiable control is a warning and the run still starts, so read it and decide.
You should see A checklist. The common finding on Docker Desktop is that no seccomp profile is applied; it names the setting to change.
cd ~/your-project sandbox-cli run --dry-run -- bash
Every flag resolves into a plain docker invocation and --dry-run prints it without executing. This is worth doing once, properly: the argv is the entire security story, and reading it yourself beats trusting a paragraph on a landing page. Look for the single --mount that is your project, the fake HOME, --rm, --cap-drop ALL and the user it drops to.
You should see One bind mount for your project at /workspace. If you see a second host path you did not ask for, that is the thing to ask about.
sandbox-cli run -- bash # then, inside the container: ls /workspace # your project echo $HOME # /sandbox/home, not yours ls ~/.ssh # No such file or directory ls /host 2>&1 # nothing of the host is mounted
The fastest way to trust a boundary is to push on it. Your project is there because you named it; your keys are not there because nothing mounted them. Exit the shell and the container is gone — every run is --rm, so the only things that survive are the files in /workspace, which were on your disk the whole time.
You should see $HOME is /sandbox/home and ~/.ssh does not exist. Files you create in /workspace are on your host afterwards; files you create anywhere else are not.
sandbox-cli claude sandbox-cli claude --dangerously-skip-permissions
The wrapper consumes a leading run of sandbox flags and forwards everything else to the agent verbatim, so the agent's own flags never collide with sandbox-cli's. That second line is the point of the whole project: 'Allow All' is the mode that makes an agent useful, and it is safe here because the blast radius is a directory that dies on exit. The first run builds the base image, which takes a few minutes once.
You should see Claude Code starts, with a status line showing the container's memory and CPU. Twelve agents have a wrapper; four are baked into the image and the rest install themselves on first use.
# nothing to do; this is the default sandbox-cli claude --no-persist-auth # opt out for a throwaway session
The agent's whole HOME is a sandbox-owned host directory (~/.config/sandbox/agents/claude), separate from your real ~/.claude, so the login survives a disposable container. Your host conversation history for this one project is mounted too, so a session started on the host resumes inside the sandbox and the other way round. Know what this costs: that directory holds a long-lived OAuth refresh token the agent can read, which is exactly why the egress allowlist is on by default and why prod does not mount it at all.
You should see You authenticate on the first run and never again. --no-persist-auth means an ephemeral HOME and logging in every time.
In allowlist mode a build that reaches an unlisted host fails, and the refusal is printed with the hostname.
sandbox-cli claude --allow deb.debian.org --allow proxy.golang.org
The config the installer wrote says `network.mode: default`, so egress starts unrestricted. Passing --allow switches that run to default-deny — an allowlist covering the agent APIs, npm, PyPI and GitHub, plus the domains you name — and setting `mode: allowlist` in ~/.config/sandbox/config.yaml makes it the standing rule. Enforcement is by name, not address: a proxy inside the container reads the hostname from the TLS SNI or the HTTP Host header and resolves it fresh per connection, so allowing one domain does not quietly admit every other host sharing its IP.
You should see `sandbox-cli: egress DENY <host>:443 (not on the egress allowlist)` is the line to look for. It names exactly what to pass to --allow.
sandbox-cli claude --git
Forwards your git identity and marks /workspace as a trusted directory, so commits carry your name and email instead of failing on dubious ownership. Without it the agent still edits files perfectly well — it just cannot make a clean commit, which is a confusing way to discover a missing flag an hour in.
You should see git commit inside the sandbox succeeds and `git log` on the host shows your name on it.
sandbox-cli claude --worktree feat/api --detach -- -p "add pagination" sandbox-cli ps sandbox-cli stats sandbox-cli worktree commit feat/api -m "wip"
Isolation stops being a tax the moment it lets you do something you could not do before. --worktree runs the agent in a real git worktree for its own branch, in its own container, so three agents on three branches never collide. --detach backgrounds it and deliberately keeps the container after it exits, because the exit code and the logs become interesting exactly when the run is over.
You should see ps lists the container as sandbox-<repo>-<branch>. Starting a second agent on the same branch is refused by the engine's own duplicate-name check — one agent per branch, enforced rather than remembered.
sandbox-cli recover # what a crashed run left behind sandbox-cli context list # conversations agents have had here sandbox-cli clean # reap exited containers
A run is snapshotted into refs/sandbox/snapshots while it is in flight, using a private index so your own index, HEAD and working tree are never touched. After a crash the files are usually already on disk — recover says so plainly rather than pretending it saved you — and it correlates the run with its transcript to print the resume command. If it cannot identify the conversation with confidence it tells you how to look instead of guessing, because resuming the wrong one is worse than offering none.
You should see On a clean exit there is nothing to recover, and it says so. That is the answer you want to have seen before the day you need it.
Every option, grouped by the question you came with
The builder above is the interactive half — fifteen flags that move the boundary, so you can watch the argv change. This is the reference half: all of them, including the many that move nothing, with what happens when you leave each one out. Most of the time the answer is that you do not need it.
Which directory does the agent see, and as whom?
| Flag | What it does | Without it | Boundary |
|---|---|---|---|
--project DIR / -p | The one host directory mounted at /workspace. | The current working directory. | neutral |
--worktree BRANCH | Runs in a real git worktree for BRANCH, created if absent, so per-branch agents never collide. | The project directory itself, on whatever branch is checked out. | neutral |
--workdir DIR / -wworkdiryour config only | Working directory inside the container. | /workspace | neutral |
--user USERuseryour config only | Container user: a name, a uid, or uid:gid. | sandbox — non-root, and agents refuse --dangerously-skip-permissions as root anyway. | widens |
--image REF / -iimageyour config only | Use a different base image. | The embedded base image, built on first use and tagged by content. | widens |
--config PATH / -c | Load this config file explicitly. Typing the path is the deliberate act discovery never involves, so a file loaded this way is trusted. | Built-in defaults, then ~/.config/sandbox/config.yaml, then the nearest .sandbox.yaml. | neutral |
your config onlymeans the key is refused from a project’s .sandbox.yaml — that file travels with the repository and the agent can rewrite it between runs, so it may describe the project and never the security boundary. Set those in ~/.config/sandbox/config.yaml, or load a project file you have read with --config ./.sandbox.yaml.
What actually differs
Same tool, same host boundary, two dispositions. Nothing on this table is a security control being relaxed — the ones that would be are not on it, because no profile can move them.
| Within the same host boundary | Local development--profile dev · default | Production--profile prod |
|---|---|---|
| EgressThe baseline contains github.com, a write endpoint and so an exfiltration channel for any token the agent holds. Prod drops it; an allowlist that resolves to nothing refuses the run rather than starting unfiltered. | allowlist + 9 baseline domains | allowlist, baseline off — you name the list |
| Persisted agent loginThe default auth path is an OAuth refresh token in the persisted HOME, which the agent can read. Prod does not mount it and authenticates through the secrets broker instead, which is the whole answer to the credential problem — no TLS-terminating proxy required. | on — log in once | off — nothing to steal |
| Host conversation history | this project's bucket, mounted rw | not mounted |
| Seccomp missing on the daemonThe one difference of kind. You are there to read a warning; a scheduler is not, and a production run that quietly degraded is the failure the profile exists to prevent. | warns | refuses, non-zero exit |
| memory · cpus · pids | unlimited · unlimited · 1024 | 2g · 2 · 512 |
| Ports publishedAn inbound port is the one thing that opens the boundary the other way, and prod is likelier to be multi-tenant. It was once asserted on the config alone, so the flag went straight past it and opened a matching hole in the ingress chain; it is checked on the run now. | whatever you ask for | none — `--publish` is refused too |
| Container user | sandbox (non-root) | sandbox (non-root), asserted |
Neither of these is the insecure one. Nothing in the host boundary is on this table, because no profile moves it: the three mount refusals, the privileged keys a project file may not set, the reserved environment names and the hardened root phase hold identically in both. What differs is what each optimises within that, plus one thing of kind — a control this host cannot provide is a warning under dev and a refusal under prod. The deployment section walks each one through, step by step.
What goes wrong, and why it is usually working
Nearly everything below is a deliberate default doing its job in a way that reads like a failure the first time. Knowing which decision produced the symptom is what tells you whether to work around it or change it.
Every command in this directory fails after upgrading
A .sandbox.yaml with network.mode: default. It used to match the built-in default and do nothing; now that egress is default-denied it is a weakening, and a project file may only tighten. Older `sandbox-cli init` scaffolds wrote exactly that line, so any project scaffolded then is affected — including read-only commands.
Remove the key, or load the file deliberately with --config, which is trusted because typing the path is a decision.
sandbox-cli run --config ./.sandbox.yaml -- bash
Claude Code silently stops updating itself
The self-updating install lives in the persisted HOME and fetches from claude.ai and downloads.claude.ai. Neither is in the baseline, so as soon as you are in allowlist mode the download is refused and the run falls back to the copy baked into the image — which is root-owned and can never update.
Allow both hosts, or put them in your own config so every run has them.
sandbox-cli claude --allow claude.ai --allow downloads.claude.ai
A new agent's first run fails with exit 127
Eight of the twelve agents are not baked into the image — they install themselves into the persisted HOME on first use, which keeps hundreds of megabytes of adapters you will never run out of the image. That install needs the vendor's download host reachable at that moment.
Allow the agent's install host. docs/AGENTS.md has the per-agent list; cursor and devin each need one, and devin needs two — the script host alone is not enough.
sandbox-cli cursor --allow cursor.com --allow downloads.cursor.com
My model provider is refused, but Anthropic works
The baseline covers api.anthropic.com and api.openai.com and nothing else. Every other provider is a host you have to name — which is the allowlist working, not failing.
Add the provider's API host for the run, or once in ~/.config/sandbox/config.yaml.
sandbox-cli gemini --allow generativelanguage.googleapis.com
Runs refuse to start on a CI runner or rootless daemon
Enforcing an allowlist means the container starts as root with NET_ADMIN to program iptables and then drops privileges. Rootless Docker, userns-remapped daemons and some CI runners cannot grant that — and the firewall fails closed, so the run aborts rather than proceeding unfiltered. That is the intended direction; before egress was default-denied only people passing --allow ever reached this path.
Decline the allowlist there deliberately, or run doctor first to find out before you schedule anything.
sandbox-cli doctor sandbox-cli run --network default -- ...
"this docker daemon applies no seccomp profile"
A Docker Desktop that applies no syscall filter — from "seccomp-profile": "unconfined" in Settings → Docker Engine, or from the containerd image store, which has been reported to leave it off with nothing set. sandbox-cli ships no profile of its own — the daemon default is good and maintaining a custom one is a large ongoing cost — so an absent filter means the container has the full syscall table. It is reported rather than refused under dev, because that is a property of your installation and fixable in its settings.
If Settings → Docker Engine sets that line, remove it and apply. If it sets nothing, check the containerd image store in Settings → General, which has been reported to leave the filter off on its own — `docker info` shows "Profile: unconfined" either way. Under prod this is a hard failure, not a warning.
--profile prod refuses to start at all
productionProd turns the baseline off, so a bare --profile prod resolves to an allowlist permitting nothing — which would mean running with no egress firewall wired at all. The strictest-sounding request must not produce the weakest result, so it stops.
Name what the job actually needs, or say network.mode: none to reach nothing on purpose.
sandbox-cli claude --profile prod \ --allow api.anthropic.com \ -- -p "run the migration"
A prod run still mounts a host path I did not expect
productionThe prod profile asserts network, credentials, seccomp, resource bounds, ports, user and capabilities against the resolved config — but not mounts or caches. A --mount on the command line, or in your own trusted config, is honoured under prod exactly as it is under dev. The non-overridable refusals still apply: never /, never your home, never an ancestor of it.
Read the argv before scheduling. --dry-run is the only thing that shows you every mount that will exist.
sandbox-cli run --profile prod --allow api.anthropic.com --dry-run -- true
The agent exhausted host RAM, or the Docker disk image grew
local developmentDev leaves memory and CPU unbounded on purpose: agents legitimately spike during builds and test runs, and an OOM-kill mid-task destroys work in a way an unbounded-but-observed container does not. The pids cap is the one default guard, because a fork bomb has no legitimate version. Docker Desktop's disk image is a sparse file that does not shrink when a container is removed.
Ask for limits when the work is untrusted, and watch the disk separately.
sandbox-cli run --memory 4g --cpus 2 -- ... docker system df
The persisted agent home keeps growing
local developmentThe self-updating install keeps every version it has downloaded, and each is a few hundred megabytes. Nothing prunes it, because the directory belongs to the agent — sandbox-cli only provides it.
Remove the old versions by hand; the symlink names the one in use.
ls ~/.config/sandbox/agents/claude/.local/share/claude/versions/
Old base images pile up after every upgrade
The image tag is content-addressed over the Dockerfile and the embedded egress proxy, so a change to either produces a new tag and a rebuild. That is what stops a stale proxy silently enforcing your allowlist — but nothing removes the tag it replaced.
Prune periodically. The build cache is usually the larger half.
docker image prune -a docker builder prune
A pasted screenshot does not resolve
local developmentPasting gives the agent a host path, and that path does not exist inside the container.
--paste mounts ~/Desktop, ~/Downloads and ~/Pictures read-only at their host paths, so the path the agent was handed resolves.
sandbox-cli claude --paste
The agent changed this repository's git config
The workspace is the agent's to edit, and agents legitimately run git config. .git/hooks is mounted read-only — planting a hook has no honest use and now fails — but config stays writable, so it is watched instead of sealed.
Read the summary printed at exit before you next run git there. Lines marked ! name a program your own git would run.
An allowlisted domain stops resolving part-way through a run
The name-enforcing proxy runs inside the container and nothing restarts it. If it dies, every tcp/80 and tcp/443 connection fails closed — the right direction, but silently, and the symptom looks like a network outage rather than its cause.
Check the proxy is still alive in the container before suspecting the allowlist.
docker exec <container> pgrep -a sandbox-egress-proxy
Only Claude Code has a status line
local developmentClaude Code has a statusLine hook, so the gauge renders inside its own UI through a managed settings file that never touches yours. Neither Gemini CLI nor OpenCode has such a hook. Wrapping their TUIs in tmux to fake one was tried and reverted — it made them render badly, a bad trade for a gauge.
Use a second terminal for every other agent; every run also prints its peak usage on exit.
sandbox-cli stats
The same boundary, driven from a browser
Studio is a local control plane: every run, the boundary it ran inside, and what it changed. Setting it up is unlike everything above because it is two processes that have to agree — a daemon holding the docker socket and a Next app in your browser — so each step below says which side it belongs to. The full walkthrough covers docker compose as well, and the three refusals that stand between a web page and your containers.
make build-studio-api ./bin/sandbox-studio-api -h
Studio is driven by its own process, sandbox-studio-api, built from cmd/sandbox-studio-api. It is separate from sandbox-cli on purpose: the CLI is a thing you run and watch exit, and this is a thing that stays up holding the docker socket while a browser talks to it. Nothing about the sandbox boundary changes — a run launched from Studio goes through the same BuildSpec as one launched from your shell.
You should see A flag list including -addr, -project, -token, -cors-origin and -allow-host. If make is unavailable, `go build -o bin/sandbox-studio-api ./cmd/sandbox-studio-api` is the same thing.
Without a token the daemon still starts, and any process on your machine can then ask it to launch a container. It is one flag; set it before you leave this running.
export SANDBOX_STUDIO_TOKEN=$(openssl rand -hex 16) ./bin/sandbox-studio-api -project "$PWD" -cors-origin http://localhost:3100
-project is the checkout Studio manages; it becomes /workspace for runs launched without a worktree. The token is read from $SANDBOX_STUDIO_TOKEN when -token is not passed, which keeps it out of your shell history and out of the process list. -cors-origin is explained two steps down — pass it now so the browser step works first time.
You should see A line naming the address it is listening on. With no token set it says so explicitly: every request but /v1/health would be unauthenticated.
curl -s http://127.0.0.1:8787/v1/health
Health is exempt from the bearer token deliberately, so a client can find out whether the server is up before it has a credential to present. Everything else answers 401 without one. Note the /v1 prefix: it is on every route, and a UI pointed at an unprefixed path gets 404 rather than anything descriptive.
You should see JSON naming the engine, the project and the profile this instance manages. A 401 here means you are hitting something other than /v1/health.
A control plane on 127.0.0.1 is reachable by any web page you happen to have open, so the daemon answers three questions before it answers a request. The Host header must name a loopback address — that is what catches DNS rebinding, where a page on attacker.example resolves its own hostname to 127.0.0.1 and so satisfies the browser's same-origin policy. An unlisted Origin is refused outright rather than merely denied a CORS header, because a cross-origin POST can skip preflight entirely and would otherwise still start a container. And the bearer token governs everything that is not a browser.
You should see Nothing yet — this is the step that makes the next two failures legible rather than mysterious.
cd studio npm install npm run dev
Studio is a separate Next app from the landing page you are reading, and deliberately so: this site is a static export with no server, and Studio talks to a local daemon and is designed dark-first. It serves on :3100.
You should see http://localhost:3100 with the runs table, and a badge in the header telling you whether it is reading the live daemon or its bundled fixtures.
A 403 naming your origin is this check, not a crash. The same applies to the Host header: reach the daemon by a name other than localhost or 127.0.0.1 and you need -allow-host for that name.
./bin/sandbox-studio-api -project "$PWD" -cors-origin http://localhost:3100
The UI is served from :3100 and the daemon listens on :8787, so every call the browser makes is cross-origin. The daemon refuses an Origin it was not told about — this is the check that actually stops a malicious page driving your containers, since refusing to reflect an origin only stops that page reading the reply. Non-browser clients like curl send no Origin at all and are unaffected.
You should see The header badge flips from fixture to live. Until it does, you are looking at sample data — Studio never presents a fixture as a real reading.
NEXT_PUBLIC_SANDBOX_API=http://127.0.0.1:9000 npm run dev
The UI defaults to http://localhost:8787, which is where the daemon listens by default, so you only need this if you changed -addr. Keep the two in step — a UI pointed at a port nothing is serving looks identical to a daemon that failed to start, and the badge is the only thing that tells them apart.
You should see The settings page names the endpoint it is actually calling, so you can confirm it rather than infer it.
A run started from Studio takes the same path as one started from your shell: the same resolved config, the same profile, the same egress posture. The run detail shows what that resolved to rather than what was requested, which is the number worth reading — a launch that quietly could not apply a control is exactly what the dry run and the doctor exist to surface on the CLI side.
You should see The new run in the table within a second or two, and its container visible to `sandbox-cli list` in a terminal — one control plane, two front ends.
It is a boolean, not a path you name. An arbitrary host directory chosen in a browser is a different decision from one typed in your own terminal, so Studio offers the one well-known directory the daemon creates and checks — the wider --mount stays a CLI flag.
Two runs in different repositories cannot see each other — everything a sandbox reaches is scoped to its own project, which is the point and also leaves no way to pass an API contract across. Ticking **Share the handoff directory** mounts the daemon machine's ~/.config/sandbox/shared at /shared, the same thing --share arranges from a shell and through the same code. Then say so in both prompts: write the contract to /shared/openapi.yaml on one side, read it on the other. A namespace narrows it to /shared/NAME so two runs do not clobber the same filename — it prevents collisions, not access, since any run sharing the root reads every namespace in it.
You should see The file on your own machine under ~/.config/sandbox/shared, still there after both containers exit.
A console run cannot also carry a verify. Verify's exit code is the whole point of it — `fleet land` reads it — and an interactive session's exit code only says when somebody closed the window.
A run launched with a console keeps a terminal open on the container, so an agent that asks a question can be answered from the browser. This is the one endpoint that refuses to work without a token even when the rest of the server is unauthenticated: everything else is read-only or launches a container you could have launched anyway, and a keyboard on a session that is already running — holding a workspace, and under dev's defaults an OAuth refresh token in the agent's HOME — is a different kind of reach.
You should see Keystrokes landing in the agent's UI. A 403 mentioning -token means the daemon was started without one.
Install once. Prefix your agent. Done.
Needs Docker — Docker Desktop on macOS and Windows. Go 1.25+ only if you build from source. The first run builds the base image; every run after that starts immediately.
Uninstalling is symmetrical and cautious: --uninstall removes the binary and then reports what else is on disk without deleting it — because ~/.config/sandbox holds your agent logins, and silently deleting it would sign you out of everything with no warning. Add --purge when you mean it.
- 1
Remove the binary
curl -fsSL https://raw.githubusercontent.com/Amitgb14/sandbox-cli/main/install.sh | sh -s -- --uninstall
Deletes sandbox-cli from ~/.local/bin (it checks /usr/local/bin too), then *reports* what else is on disk without touching it. Your projects and their .sandbox.yaml files are never touched by either flag, and containers are --rm, so nothing lingers between runs.
- 2
Then decide about your logins
sh install.sh --uninstall --purge
--purge additionally deletes ~/.config/sandbox — your config.yaml and every agent login — plus the sandbox-base images and the sandbox-cache-* volumes. It is a separate flag because silently signing you out of Claude, Codex and the rest is not something an uninstaller should do on its own.
- 3
Or clean up by hand
rm -rf ~/.config/sandbox # config + agent logins docker rmi $(docker images -q sandbox-base) # base image(s) docker volume rm $(docker volume ls -q -f name=sandbox-cache-) # package caches
The same three things --purge removes, in case you want one and not the others — reclaiming the image without losing the logins, say. The plain --uninstall prints these exact commands for whatever it found.
$curl -fsSL https://raw.githubusercontent.com/Amitgb14/sandbox-cli/main/install.sh \| sh -s -- --version 0.0.1
Detects your OS and CPU, verifies the archive against the release checksums.txt, and installs to ~/.local/bin/sandbox-cli. No root, no package manager.
v0.0.1 · verified against the release checksums.txt · installs to ~/.local/bin · no root, no package manager.