docker

Running coding agents in Docker: when a write sandbox isn't enough

A write sandbox stops an agent from trashing your repo but still lets it read and reach the network. Here's what a container buys you, what it costs, and the credentials gotcha nobody warns you about.

An OS-level write sandbox does one job well: it stops a coding agent from writing where it shouldn’t. No rm -rf in your home directory, no rewrite of your dotfiles, no history surgery on your repo. If you read the earlier post on running agents with permissions off, that is the boundary it argued for, and it is the right default for most work.

But a write sandbox is exactly that, and no more. The agent still runs as you, on your filesystem. It can read anything your user can read: other projects, ~/.ssh, that .env you forgot about. It can open network connections to anywhere. “Can’t wreck your machine” is a genuine guarantee. “Isolated” it is not.

Isolation is a spectrum, not a switch. A container sits one notch up. It moves the agent’s process, filesystem, and build environment off your host and into a separate world that sees only what you hand it. That buys real things, and it costs real things, and the costs are the part people discover the hard way. This post is about both, and about the one that actually trips everyone up: credentials.

What a container moves that a write sandbox doesn’t

A write sandbox draws a line inside your machine. A container draws a line around a different machine. The agent runs on Linux, in its own root filesystem, with its own process tree. Your host home directory is not mounted, so there is nothing to read there. The network can be constrained. Builds and tests execute in that Linux environment rather than against your local toolchains.

Three of those are worth naming concretely.

You get a genuinely separate environment. The agent can install packages, mangle its own filesystem, and run an unfamiliar dependency’s install script without any of it touching your machine. When the container exits, that world is gone. This is the difference that matters for riskier work: a write sandbox contains the writes, a container contains the whole process.

You get Linux-native builds. If you ship to Linux, running the agent’s builds and tests on Linux means the results are the ones that matter, not the ones your Mac happens to produce. If your project needs Xcode, this cuts the other way and a container is the wrong tool. Know which one you are.

You get a smaller read surface. Your host filesystem simply is not present, so the “it can read anything you can read” caveat shrinks to the handful of paths you mounted.

Now the honest part. A container running as root, which is what most setups (including this one) give you today, is containment for a live process, not a hardened trust boundary. Container escapes exist. A determined, malicious payload is not what root-in-a-container is designed to stop. What it is good at is keeping a confused agent’s blast radius inside a box instead of on your disk. That is a real and useful property. It is not a sandbox in the security-research sense, and treating it as one will burn you.

Which leads to the thing worth saying plainly: the container is not a substitute for reading the diff before it merges. Isolation limits blast radius. It does nothing to verify that the code the agent wrote is correct, safe, or what you asked for. An agent in a perfect container can still write a subtly wrong migration and open a PR for it. The container protects your machine on the way there. Review is what keeps bad code out of your project. Those are two different jobs and no amount of isolation collapses them into one.

The credentials problem

Here is the gotcha. On macOS, an agent authenticates against your Keychain and your already-logged-in CLIs. Run Claude Code on the host and it just works, because your login is right there. Move that same agent into a container and it breaks, because a container has no access to your Keychain. The environment that made auth free is gone.

The tempting fix is to inject all your credentials into the container: mount the Keychain, pass every token as an environment variable, copy your credentials files in. Do not do this. The entire reason you reached for a container was to shrink what the agent can reach. Flooding it with your secrets hands back the read surface you just paid to remove, and now those secrets live in a place with weaker guarantees than your Keychain. The right default is that a container starts with nothing and you grant exactly what the task needs.

So the agent needs its own credential, resolved deliberately. A sane resolution order, first hit wins:

  1. An existing OAuth token you can safely surface to the container.
  2. A dedicated setup token minted for this purpose (Claude’s claude setup-token produces exactly this: a long-lived token you generate once for headless use).
  3. An ambient API key or OAuth value from the environment (ANTHROPIC_API_KEY and friends), if you have chosen to provide one.
  4. A usable credentials file on a read-only mount.
  5. Otherwise, fail loudly and ask you to connect, rather than silently running unauthenticated or reaching for something it shouldn’t.

The shape of that chain is the point. It prefers a purpose-built, scoped credential over your general-purpose login, and it never invents access you didn’t grant. A setup token is the sweet spot: it authenticates the agent without dragging your whole Keychain across the boundary.

When to use which

Reach for a write sandbox for everyday work on code you mostly trust. It is zero setup, has effectively no overhead, runs your native toolchains, and its write-deny profile already stops the failure that actually happens, which is an agent writing where it shouldn’t. For most of your fleet, a container would just be friction.

Reach for a container when you want Linux-native builds and tests, when you want a cleaner environment than your host, or when you want an extra boundary for riskier or fully unattended runs. The sketchy dependency upgrade, the unfamiliar codebase, the overnight batch you won’t be watching: those are container jobs. You trade convenience and a credential setup step for a harder line around what the agent can touch.

You do not have to pick globally. The useful stance is to mix: the bulk of your agents on the fast write sandbox, and a container for the one task that earns it.

How Fletch does it

Fletch runs every agent under a macOS write sandbox by default, and offers Docker as an optional stronger engine. You choose the engine in Settings > General > Sandbox, and the choice is stamped onto each agent when it spawns, so switching the setting never re-engines an agent that is already running. Mixing is the intended workflow.

Each Docker agent runs as its own docker run --rm --init container, with builds and tests executing on Linux. Fletch live-probes the daemon, and a Docker-stamped agent whose daemon is down fails loudly rather than quietly downgrading to the write sandbox. If you asked for a container, you get a container or an error, never a silent weaker boundary. Docker mode is supported for Claude Code, Codex, Cursor, OpenCode, and Pi; Antigravity stays on the write sandbox because its CLI has no non-interactive login.

The mounts are deliberate. The container gets the writable workspace, the RPC mailbox, and ~/.claude read-only except for credentials. The source object store is mounted read-only, and the real .git never enters the container at all. Resource limits default to 4 GB of memory and 2 CPUs, both configurable.

For credentials, Fletch injects nothing by default and resolves Claude’s auth per spawn through the first-hit-wins chain described above: Keychain OAuth token, a stored claude setup-token, ambient env, a usable credentials file on the read-only mount, else fail with a prompt. The “Connect Claude” flow automates claude setup-token for you, which is the clean way to give a container its own scoped login without handing it your Keychain.

And the threat model stays honest. These containers run as root in this version. They are live-process containment, not a hardened trust boundary. The real gate on what reaches your repository is still structural: every agent works in its own disposable clone, and nothing lands until you review the diff and merge the pull request. A container raises the boundary around the agent while it works. It does not replace the review that decides what ships.