Sandbox That Agent
A modern coding agent loves to install dependencies, edit configuration files, hit external APIs, and run database migrations. Most of the time that is useful, but there are also times when things go wrong and when they do it usually pretty unrecoverable. This problem is exacerbated when you consider the trend towards long running, independent agents. More and more we feel that running agents in a sandbox (which protects the rest of your computer) is the right thing to do. For the last few weeks we have been running coding agents inside a small per-project sandbox of our own. The constraints are deliberate but tricky to get right. What you want is to lock down the environment sufficiently to protect your computer while allowing the agent sufficient freedom within the sandbox to do long stretches of work. This piece is a walk-through of how the sandbox works and what we tried first.
Ultimately, creating the sandbox is a balancing act between security and freedom. We're not sure this is the final version, but it has been good for the latest batch of web apps we've been developing.
The Architecture
The sandbox runs four containers on a per-project Docker network. A single just recipe
brings the whole thing up.
host (Mac)
│ just project <name> up
│
└─ docker network: proj-<name>
├─ agent-box firewalled worker: claude, edits /workspace, runs tests
├─ project supervised dev server, shares /workspace + node_modules
├─ browser Playwright MCP at :8931
└─ db backing services declared by the projectagent-box is where the agent lives. It can edit the project source, run the test suite,
and reach the other sidecars over the Docker network. It is the only container with
restricted egress.
project runs the project's dev server. It exists so the agent can see the effect of its
changes. It shares the workspace bind mount so the agent's edits hot-reload immediately.
It shares a named dependency volumes (like node_volumes and .venv) so the agent and
dev server run against the same dependencies. Drift between the two trees is impossible by
construction. One key benefit is that this container is mapped to the host so we can see
what the agent sees.
browser is a Playwright MCP server. It gives the agent a real headless browser inside
the network for screenshotting, querying the DOM, and reading the JavaScript console.
db is whatever the project needs. The compose file is the project's own, and it is
merged with the lab's shared one at startup.
The compose configuration is split in two. A shared compose.lab.yml, owned by the lab,
defines the agent harness: agent-box, the browser sidecar, and the named volumes. Each
project then carries its own docker-compose.yml with only what is unique to it: the dev
server command and whatever backing services it needs. The up recipe stacks the two with
-f flags and Compose merges them into a single stack.
Here is the shared file:
# Shared sidecars for the per-project dev environment. Brought up under one
# Compose project name together with the project's own file:
# PROJECT_PATH=$(pwd)/foo PROJECT_NAME=foo APP_PORT=3000 \
# docker compose -p proj-foo \
# -f compose.lab.yml -f foo/docker-compose.yml up -d
services:
# The worker: runs Claude, edits files, runs tests. The ONLY container granted
# NET_ADMIN/NET_RAW, so init-firewall.sh can lock outbound traffic to an
# allowlist at start-up. Sleeps forever; you exec in to work.
agent-box:
image: agent-box
cap_add:
# Needed by init-firewall.sh inside the container.
- NET_ADMIN
- NET_RAW
env_file:
- path: ${PROJECT_PATH:?PROJECT_PATH must point at the project repo}/.env
required: false
environment:
NODE_OPTIONS: "--max-old-space-size=4096"
CLAUDE_CONFIG_DIR: /home/node/.claude
CLAUDE_PROJECT: ${PROJECT_NAME:?PROJECT_NAME must be set}
POWERLEVEL9K_DISABLE_GITSTATUS: "true"
PNPM_STORE_DIR: /home/node/.pnpm-store
volumes:
- ${PROJECT_PATH}:/workspace:delegated
# Linux-native node_modules, shared with the `project` sidecar (named volume).
- node_modules:/workspace/node_modules
- pnpm-store:/home/node/.pnpm-store
- claude-config:/home/node/.claude
- venv:/workspace/.venv
# Reuse the host's Claude config, read-only.
- ${HOME}/.claude/skills:/home/node/.claude/skills:ro
- ${HOME}/.claude/CLAUDE.md:/home/node/.claude/CLAUDE.md:ro
- ${HOME}/.claude/statusline-command.sh:/home/node/.claude/statusline-command.sh:ro
- ${HOME}/.claude/settings.json:/home/node/.claude/settings.json:ro
user: node
command: ["sleep", "infinity"]
# Playwright MCP server (streamable HTTP on :8931) for browser-driven tests.
# --allowed-hosts is required: the server checks the Host header to defend
# against DNS rebinding.
browser:
image: mcr.microsoft.com/playwright/mcp:v0.0.75
command: ["--port", "8931", "--host", "0.0.0.0", "--allowed-hosts", "browser:8931"]
restart: unless-stopped
volumes:
claude-config:
venv:
pnpm-store:
node_modules:And a representative project's own docker-compose.yml:
# Per-project services. The `project` service runs the dev server on the same
# agent-box image, sharing /workspace and the `node_modules` named volume
# (declared in compose.lab.yml) with agent-box.
services:
# Dev server. Supervised by Compose (restart-on-crash) and profile-gated so
# `up` starts it only after the shared deps are installed. Adapt `command`
# and the healthcheck to your app.
project:
image: agent-box
profiles: ["project"]
working_dir: /workspace
user: node
env_file:
- .env
command: ["just", "dev"] # your dev recipe, bound to 0.0.0.0
volumes:
- .:/workspace:delegated
- node_modules:/workspace/node_modules # SHARED with agent-box
ports:
- "${APP_PORT:-3000}:${APP_PORT:-3000}"
depends_on:
db:
condition: service_healthy
restart: unless-stopped
healthcheck:
test:
["CMD-SHELL", "curl -fsS http://localhost:${APP_PORT:-3000} >/dev/null || exit 1"]
interval: 10s
timeout: 5s
retries: 5
start_period: 60s
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: dev
POSTGRES_PASSWORD: dev
POSTGRES_DB: dev
# Publish to the host only if you want to connect from the Mac too.
ports:
- "5432:5432"
volumes:
- db_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U dev -d dev"]
interval: 5s
timeout: 5s
retries: 5
volumes:
db_data:The split earns its keep in three ways. The harness is defined once: a firewall tweak, a
new read-only config mount, or a browser MCP version bump lands in every project the next
time it comes up, with no per-project drift. The project file stays small enough to read
in a minute, because it only says what makes this project different. And because Compose
merges service configurations across -f files, a project can extend agent-box itself,
say with a read-only credentials mount, without forking the shared file.
This may sound complex but remember this setup is purely for development. For production we have a separate build and deploy process that doesn't require any of the above.
What the Agent Can and Cannot Do
The interesting half of the design is what we deny. Inside agent-box the agent has:
- A normal Linux shell as a non-root user (
node), with Node, pnpm, Python, and uv preinstalled. This means the same base setup can be used for JS or Python projects. - Read-write access to
/workspace, the project's source code, bind-mounted from the host. - Network access to the other sidecars over the Docker network: the dev server at
http://project:3000, Postgres atdb:5432, the browser MCP athttp://browser:8931/mcp. - One
sudocommand. Nothing else escalates.
What it does not have:
- Egress to the open internet. The firewall allows GitHub, npm, the Anthropic API, and a handful of named hosts. Everything else is rejected with an ICMP unreachable so the agent learns quickly. The relevant fragment of the init script:
# Default policies to DROP first
iptables -P INPUT DROP
iptables -P FORWARD DROP
iptables -P OUTPUT DROP
# Allow established connections
iptables -A INPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
iptables -A OUTPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
# Allow only the named hosts (GitHub, npm, Anthropic, etc.)
iptables -A OUTPUT -m set --match-set allowed-domains dst -j ACCEPT
# Anything else: reject immediately
iptables -A OUTPUT -j REJECT --reject-with icmp-admin-prohibited- The Docker CLI. There is no
dockerbinary in the container, and the host's socket is not mounted. The agent cannot inspect, restart, or kill its sibling containers. - Root. The
sudoersentry is one line:
node ALL=(root) NOPASSWD: /usr/local/bin/init-firewall.sh
For the init script we started from the one Claude Code uses to create devcontainers, and customised it further. The original script can be found here.
If you're interested, these are the domains we allow access to:
- pypi.org
- files.pythonhosted.org
- registry.npmjs.org
- api.anthropic.com
- openrouter.ai
- eu.api.smith.langchain.com (LangSmith telemetry, EU endpoint)
- sentry.io (Claude Code telemetry)
- statsig.com (Claude Code telemetry)
GitHub is allowed too, but by IP range rather than by name: the init script fetches
GitHub's published ranges from api.github.com/meta at firewall start-up.
That is all the local agent has access to. If a new domain is required, this can be added to the init script manually, but the key is that we control this access.
The deliberate gap is in restarting the dev server. Sometimes a change (a next.config.ts
edit, an env var) needs a full process restart. The agent cannot do it. The contract we
wrote into the project's AGENTS.md is explicit:
You cannot restart it directly. For a change that needs a full restart, ask the user to run
just project <name> restart-dev. Do NOT try to launch your ownnext dev; it would clash on port 3000.
Admittedly, this does cause some friction in development as the developer needs to restart containers. As an alternative we considered a Docker socket proxy but decided against it. The additional complexity and dependencies didn't seem worthwhile for the time being.
Where the Firewall Stops
One thing to call out is worth answering directly, the project container has no egress
restrictions, so theoretically it could use it to get to the open internet.
The Agent cannot log in to project. There is no docker CLI in agent-box, no socket
mount, and no SSH server in the dev container. A direct shell is closed off. But the agent
does not need one. agent-box and project share the /workspace bind mount, and the
dev server hot-reloads on edit. The agent writes a file, the dev server reloads it, and
that code runs inside the unfirewalled container. By writing the application's own code,
the agent can make a network call from project to anywhere.
This is acceptable because of what that code actually is. It is the code you were going to ship. The agent's job is to write the application, and you review the diff before it merges. If the agent wrote something that called out to a host it should not, that line would run in production too, and you would catch it in review either way. The dev container is not a new hole. It is the same surface your application already has.
What the firewall on agent-box protects is narrower, and still worth having. It
constrains the agent's own runtime: Claude Code, its tooling, and anything it does outside
writing project code. A poisoned dependency pulled into agent-box, or a prompt-injected
instruction to fetch and run a script in the agent's shell, hits the firewall. The agent's
exploration (installs, scratch scripts, test runs) cannot reach arbitrary hosts. The dev
server's egress is open because the application legitimately needs it, to reach Postgres,
Google Drive, or Vercel Blob.
The residual risk is data exfiltration through the app itself. A route that reads the
database and posts it to an external host will run, and the agent-box firewall does
nothing about it, because the request originates in project. Three things bound this:
the dev server runs as node with no Docker access, so it cannot escape its container or
touch siblings; its secrets are scoped to the project's .env and a read-only credentials
mount, not the host; and the behaviour is visible in the diff. If a project handles data
sensitive enough that this matters during development, the move is to firewall project
too with a project-specific allowlist rather than leaving it open. That is a per-project
decision, and the compose overlay is the natural place for it.
What We Tried Before
The first version of this was trusting in blind faith that the agent would respect
boundaries. We ran the agent on the host with --dangerously-skip-permissions and trusted
ourselves to notice when it did something silly. That works until you stop paying
attention on long-running tasks. This is where maximum damage can happen. The sort of
risks this opens you up to are:
- Installing a dodgy dependency globally,
- Editing an
.envfile we did not expect, - Deleting the wrong file.
The second version worked but was overcomplicated. We had a Docker Compose network with the dev server permanently up. Access to this server was through the Docker CLI and another container for the socket proxy, i.e. 2 always-running containers. This was very useful, but it led to bloated container images that took some time to spin up and develop on.
The third version put the dev server inside agent-box itself. The agent started the dev
server, ran tests against it, and used the browser MCP to drive it. The firewall, which
exists to keep the agent from reaching the wider internet, also blocked the published port
the host needed to talk to the dev server. The dev server itself needed to reach external
services (Vercel Blob, in our case), and widening the firewall allowlist for the agent to
let those through opened the agent's egress too. We bolted on an iptables rule for the
published port, a sudoers env_keep for the port number, and an exec -d hack to
auto-start the server because Compose had no way to supervise a process we started by
hand.
The current version goes back to separate containers for agent-box and the dev server,
but with lighter tooling. agent-box is firewalled. The dev server is its own project
sidecar, supervised by Compose, free to reach whatever the app needs. The agent reaches it
over the Docker network like any other service.
Keeping It Lightweight
A few choices keep the whole thing small enough to read in one sitting.
Each container does one job
agent-box is the agent harness: containerised Claude, the code mounts, and the firewall.
Nothing in it is project-specific, so one Dockerfile serves every project. Node with pnpm,
Python via uv, Claude Code, and the firewall script:
# Base image for the project's dev container. Node + pnpm are pinned here to
# explicit versions (Node 24 LTS "Krypton") so the box is self-contained and
# reproducible, independent of whatever toolchain the host happens to have.
FROM node:24.16.0
ARG TZ
ENV TZ="$TZ"
ARG CLAUDE_CODE_VERSION=latest
# Install basic development tools and iptables/ipset
RUN apt-get update && apt-get install -y --no-install-recommends \
less \
git \
procps \
sudo \
fzf \
zsh \
man-db \
unzip \
gnupg2 \
gh \
iptables \
ipset \
iproute2 \
dnsutils \
aggregate \
jq \
vim \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
# Install uv (single static binary, manages Python + ruff + ty)
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /usr/local/bin/
# Install just (not packaged in bookworm apt)
RUN wget -qO- https://just.systems/install.sh \
| bash -s -- --to /usr/local/bin
# Ensure default node user has access to /usr/local/share
RUN mkdir -p /usr/local/share/npm-global && \
chown -R node:node /usr/local/share
ARG USERNAME=node
# Create workspace and config directories and set permissions
RUN mkdir -p /workspace /home/node/.claude && \
chown -R node:node /workspace /home/node/.claude
WORKDIR /workspace
ARG GIT_DELTA_VERSION=0.18.2
RUN ARCH=$(dpkg --print-architecture) && \
wget "https://github.com/dandavison/delta/releases/download/${GIT_DELTA_VERSION}/git-delta_${GIT_DELTA_VERSION}_${ARCH}.deb" && \
sudo dpkg -i "git-delta_${GIT_DELTA_VERSION}_${ARCH}.deb" && \
rm "git-delta_${GIT_DELTA_VERSION}_${ARCH}.deb"
# Set up non-root user
USER node
# Pre-warm Python so first project use is fast
ARG PYTHON_VERSION=3.14
RUN uv python install ${PYTHON_VERSION}
# Install global packages
ENV NPM_CONFIG_PREFIX=/usr/local/share/npm-global
ENV PATH=$PATH:/usr/local/share/npm-global/bin
# Set the default shell to zsh rather than sh
ENV SHELL=/bin/zsh
# Set the default editor and visual
ENV EDITOR=vim
ENV VISUAL=vim
# Default powerline10k theme
ARG ZSH_IN_DOCKER_VERSION=1.2.0
RUN sh -c "$(wget -O- https://github.com/deluan/zsh-in-docker/releases/download/v${ZSH_IN_DOCKER_VERSION}/zsh-in-docker.sh)" -- \
-p git \
-p fzf \
-a "source /usr/share/doc/fzf/examples/key-bindings.zsh" \
-a "source /usr/share/doc/fzf/examples/completion.zsh" \
-x
# Install Claude
RUN npm install -g @anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}
# Enable pnpm via corepack and pin the box's baseline version explicitly (latest
# pnpm). A project that declares its own `packageManager` in package.json still
# overrides this in-project; the prompt is disabled so that override resolves
# non-interactively (fetched from the npm registry on first use). Running as the
# `node` user, so install the shim into the node-owned global bin dir (already on
# PATH) instead of the default root-owned /usr/local/bin.
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0
RUN corepack enable --install-directory /usr/local/share/npm-global/bin pnpm && \
corepack prepare pnpm@11.5.1 --activate
# Copy and set up firewall script
COPY ./init-firewall.sh /usr/local/bin/
USER root
RUN chmod +x /usr/local/bin/init-firewall.sh && \
printf 'node ALL=(root) NOPASSWD: /usr/local/bin/init-firewall.sh\n' > /etc/sudoers.d/node-firewall && \
chmod 0440 /etc/sudoers.d/node-firewall
USER nodeThe project sidecar needs none of that. It is there so the agent can see the effect of
its changes, and all it has to contain is the project. The preference is for each project
to supply its own dev image from its own folder, sharing its base with the production
Dockerfile so dev and prod do not drift. One project does a version of this: its
Dockerfile.dev layers the OCR packages the app needs (tesseract, poppler) on top of the
shared image, mirroring exactly what the production image installs.
For a simple web app the shared image already covers the runtime, so the dev configuration just points at it and overrides the command:
project:
image: agent-box
profiles: ["project"]
command: ["just", "dev"]
volumes:
- .:/workspace:delegated
- node_modules:/workspace/node_modules # SHARED with agent-boxEither way the split holds: one shared harness image for the agent, and a dev server that carries only what the project needs.
Shared node_modules
A named Docker volume mounted by both containers, with one writer and many readers. The
up recipe runs pnpm install inside agent-box; the dev sidecar's startup never
installs. The dev server runs against the same tree the agent's tests do.
Profile gates for projects that do not need a dev server
A pure-CLI project does not declare a project service. The up recipe checks for the
project profile and skips phase 2 if it is not there:
if ! PROFILES="$(docker compose --project-directory "$PROJECT_PATH" -p proj-{{name}} \
"${files[@]}" config --profiles 2>&1)"; then
echo "ERROR: 'docker compose config' failed:" >&2
echo "$PROFILES" >&2
exit 1
fi
HAS_PROJECT_PROFILE=false
if echo "$PROFILES" | grep -qx project; then HAS_PROJECT_PROFILE=true; fiNo socket-proxy
A socket proxy would let the agent restart its sibling containers under a narrowed policy. It would also be another container, and so another config and process to manage and maintain. As with code, the more services there are, the greater the potential fragility in the overall system.
Justfiles for management
We use Justfile recipes for all container and environment setup and operation. This
allows a single, common entrypoint into every project and, combined with decent
README.md and AGENTS.md files, lets both human and agent get up to speed quickly on
whichever project we are currently working on.
Wrapping Up
A useful sandbox is a small one. The bigger it gets, the more the constraints leak. The
current design is four containers, one shared agent image, one named volume between two of
them, one firewall, and one allowed sudo command. It is small enough that a new project
takes minutes to onboard. And most importantly it is strict enough that we can leave the
agent to run long, complex tasks independently until it is finished. Currently it isn't
self-healing so if the project container fails, a developer needs to restart it. This
gives us valuable insight into how Agents fail and so keeps us pretty close to the
operation.
If you are working out how to put an agent into a real engineering workflow, the questions are the same ones we just walked through. What can it reach? What can it edit? Where does it have to ask? Those answers do not need a platform team. They need a few files you can read end to end. Get in touch if you want a hand drawing yours up.