Sandboxing a coding agent
Hooks are policy: they stop what you anticipated. A sandbox is containment: it bounds what you did not. You want both, and most people have neither.
There are two different questions people conflate when they talk about agent safety.
"Will it do something I told it not to?" That is policy, and the answer is hooks — code that runs at fixed points in the loop and can refuse an action.
"What is the worst outcome if something goes wrong in a way I did not anticipate?" That is containment, and no amount of policy answers it. A deny-list only blocks what you thought of.
This page is the second question. The useful framing: assume a turn goes badly wrong — a runaway loop, a misread path, an instruction injected through a web page the agent read. What is the blast radius, and what would make it smaller?
The layers, cheapest first#
| Layer | Stops | Costs you | Worth it |
|---|---|---|---|
| Commit before you start | losing uncommitted work | 5 seconds | always |
| Branch | polluting main | 5 seconds | always |
| Worktree | blocking your own work | one command | long runs |
| Container | filesystem outside the project, host tooling | some setup | most teams |
| Network egress control | exfiltration, unexpected downloads | real config work | anything touching untrusted content |
| Scoped credentials | the damage a leaked key can do | an afternoon | always, eventually |
| VM / remote machine | kernel-level escape, host compromise | latency, cost | high-risk or untrusted code |
The first three take under a minute and cover the overwhelming majority of real incidents. Start there.
Layer 1: git, used deliberately#
The single most common way agent work is destroyed is not the agent — it is an uncommitted working tree plus one bad command.
git add -A && git commit -m "wip: before agent run" # the whole safety net
git switch -c agent/rate-limitingEverything after that is recoverable with git reset, git reflog or git restore. Uncommitted work is the only thing in a repository that genuinely cannot be recovered.
Worktrees are the underused version, and they are the right tool for a long run:
git worktree add ../myrepo-agent -b agent/rate-limiting
cd ../myrepo-agent # a separate directory, same repository, own branchYou keep working in your main checkout while the agent works in another. Same object store, so branches and history are shared; separate working directories, so neither can disturb the other's files. When you are done:
git worktree remove ../myrepo-agentThis also removes an entire category of confusion — an agent and a human editing the same files simultaneously produces diffs neither of them understands.
Layer 2: containers#
The step that converts "it might touch something on my machine" into "it can only touch this directory".
A devcontainer is the least-friction version, because it is a standard your editor and your CI already understand:
{
"name": "myapp-agent",
"image": "mcr.microsoft.com/devcontainers/python:3.12",
"workspaceFolder": "/workspace",
"remoteUser": "vscode",
"mounts": [],
"runArgs": [
"--cap-drop=ALL",
"--security-opt=no-new-privileges",
"--pids-limit=512",
"--memory=4g"
],
"postCreateCommand": "uv sync",
"containerEnv": { "AGENT_SANDBOX": "1" }
}Or plainly, without any editor integration:
docker run --rm -it \
-v "$PWD:/workspace" -w /workspace \
--cap-drop=ALL --security-opt=no-new-privileges \
--memory=4g --pids-limit=512 \
--user "$(id -u):$(id -g)" \
myapp-dev bashWhat that actually buys you:
- The filesystem outside the mount does not exist. No
~/.ssh, no~/.aws, no other repositories, no browser profile. --pids-limitand--memorybound a runaway loop into a container failure rather than a wedged laptop.--user "$(id -u)"means files created in the mount belong to you, not to root — the detail that makes people give up on containers when they skip it.
What it does not buy you: the mounted directory is still fully writable, so a bad rm -rf inside /workspace deletes your actual files. Containers bound the outside, git protects the inside. That is why you want both.
Layer 3: the network#
The layer almost nobody configures, and the one that matters most once an agent is reading anything from outside.
The reasoning: an agent that can fetch web pages, read issues, or call MCP servers is consuming text written by other people. If that text can influence its actions, then network access is the exfiltration channel — the difference between "it read something malicious" and "it read something malicious and sent your source somewhere".
# no network at all — surprisingly usable for a lot of work
docker run --rm -it --network=none -v "$PWD:/workspace" -w /workspace myapp-dev
# or a network with an explicit egress proxy
docker run --rm -it \
--network=agent-net \
-e HTTPS_PROXY=http://proxy:3128 -e HTTP_PROXY=http://proxy:3128 \
-v "$PWD:/workspace" -w /workspace myapp-devAn allowlist proxy (Squid, or a small mitm-style proxy) with your package registry, your provider's API and nothing else is a few hours of work and it is the strongest single control on this page.
Two intermediate options that are much less work:
--network=nonefor the phases that do not need it. Test runs, refactors and reviews mostly do not need the internet. Dependency installation does.- Codex's
sandbox_mode = "workspace-write"denies network access from inside the sandbox by default, which is a genuinely good default and is enforced by the OS rather than by configuration you might get wrong.
Layer 4: credentials#
Assume every credential reachable from the agent's environment is one bad turn away from being used or leaked.
Deny the files, always:
{
"permissions": {
"deny": [
"Read(./.env)", "Read(./.env.*)", "Read(./secrets/**)",
"Read(~/.aws/**)", "Read(~/.ssh/**)", "Read(~/.config/gh/**)",
"Read(**/*.pem)", "Read(**/id_rsa*)"
]
}
}Then reduce what those credentials can do, because a deny-list is policy and policy has gaps:
- A separate development database, restored from an anonymised snapshot. Not a read replica of production — a copy.
- Read-only database credentials for anything the agent connects to directly, including MCP servers.
- Scoped, short-lived API tokens. A GitHub token limited to one repository with no admin scope. A cloud role that can read staging and nothing else.
- A separate provider key for agent work, with its own spend cap. That is a cost control as well — see token economics.
The test worth applying: if this credential were posted publicly right now, what would happen? If the answer is bad, it should not be in the environment.
Layer 5: VMs and remote machines#
For genuinely untrusted work — running code from a repository you have not read, reproducing a bug from an unknown reporter, letting an agent run for hours unattended — a container's shared kernel is a weaker boundary than people assume.
Options, roughly in order of effort:
- A cloud VM you destroy afterwards. The simplest strong isolation, and cheap at hourly rates.
- A local VM (Lima, UTM, a hypervisor) with a shared folder.
- A hosted agent environment, where the sandbox is someone else's problem.
The trade is latency and friction, which is why this is not the default for everyday work. Reserve it for the cases where the honest answer to "what if this goes wrong" is "I do not know".
What the harnesses give you natively#
Codex ships the stronger default containment. sandbox_mode is enforced by the operating system's sandboxing facilities rather than by the model's cooperation:
sandbox_mode = "workspace-write" # read-only | workspace-write | danger-full-access
approval_policy = "on-request"
[sandbox_workspace_write]
network_access = false # the important defaultread-only is genuinely useful and underused — it is the right mode for "explore this codebase and tell me how it works", and it makes the whole question of blast radius disappear.
Claude Code gives you the richer policy layer — a fine-grained permission allowlist plus hooks that can inspect and refuse each action — and expects you to bring your own containment (a container, a worktree, a VM).
Neither is complete on its own. The setup that actually works is policy from one and containment from the other's model: a permission allowlist and hooks, running inside a container with a bounded network.
A reasonable default for everyday work
git add -A && git commit -m wip
git worktree add ../proj-agent -b agent/task
cd ../proj-agentplus a permission allowlist with .env denied, and package installs behind a confirmation.
Escalate to a container when the agent will run unattended, or when it is reading anything from outside the repository. Escalate to no-network or an egress allowlist when it is reading untrusted content and also has tools that can write. That combination is the one that matters — see prompt injection.
Testing your containment#
Worth doing once, deliberately, rather than discovering the answer during an incident:
# inside your sandbox, check what is actually reachable
ls ~/.ssh 2>&1 # should fail
cat ~/.aws/credentials 2>&1 # should fail
curl -sS -m 5 https://example.com # should fail if egress is controlled
env | grep -iE 'key|token|secret' # what did you inherit?
psql "$DATABASE_URL" -c 'DROP TABLE IF EXISTS canary' # read-only creds?Five commands. If any of them succeeds and you expected it not to, you have found a real gap while it is still cheap.
Common questions#
Is a container overkill for solo work on my own project?#
For everyday work on a repository you own, a committed tree plus a branch covers the realistic failure modes and a container is friction you will stop paying. It stops being overkill the moment the agent runs unattended, reads content from outside the repo, or has credentials that reach anything shared.
Does a container actually stop a determined attacker?#
No — a shared kernel is a weaker boundary than a VM, and container escapes exist. But the realistic threat here is not a targeted kernel exploit; it is a mistaken command or an injected instruction, and against those a container with dropped capabilities is very effective. Use a VM when the code itself is untrusted.
Will network restrictions break my workflow?#
Some of it, and less than you expect. Test runs, refactors and code review need no network at all. What needs it is dependency installation, documentation lookups and the model API itself — which is exactly the short allowlist an egress proxy encodes. Start with --network=none for a session and see what actually breaks.
Should CI agents be sandboxed differently?#
They are already in a container, so the filesystem question is handled — but they usually have far more powerful credentials than your laptop does, and they run unattended. That inverts the priority: for CI, scoped credentials and network egress matter much more than filesystem isolation.
Get the updates
Agent tooling moves monthly. We send a short note when something on these sites changes materially — a new harness feature, a failure mode worth knowing, a config that stopped being right. Nothing else.
Unsubscribe in one click. We never sell the list.