Sandboxing Agent Environments for Reproducibility, Not Just Safety
An agent that installs a package to fix a build has changed your machine. Do that thirty times and your environment is somewhere nobody can reproduce.
Isolation you actually need
The security case for sandboxing agents is covered elsewhere in this series. This article is about the operational one, which is separate and often more persuasive to the people who have to approve the work: a sandboxed agent is easier to run, easier to reproduce and easier to clean up after.
Three practical problems it solves, none of them about attackers.
- Environment drift. An agent that installs a package to fix a build has changed your machine. Repeat this a few dozen times and your development environment is a place nobody can reproduce.
- Parallel work. Two sessions on one machine contend for ports, databases and global state.
- Cleanup. An abandoned session leaves processes, containers and temp files. A discarded sandbox takes them with it.
The objection you will hear
The first response to any sandboxing proposal is that it will slow people down. It is worth taking seriously rather than dismissing, because it is usually true of a badly built sandbox and it is the reason most attempts get abandoned.
The friction is concentrated in three places, and each has a fix: cold start (cache volumes and a pre-built image), missing tools (put them in the image rather than making people install them), and awkward invocation (one script, no arguments). Address those and the objection disappears, because the sandbox stops being slower than not using one.
What does not work is mandating it while leaving the friction in place. People will run agents outside the sandbox, quietly, and you will have a policy that describes something nobody does.
Four levels, and what each costs
| Level | Isolates | Startup | Good for |
|---|---|---|---|
| Same machine, separate user | Home directory, credentials | None | Solo work on your own code |
| Container | Filesystem, processes, network | Seconds | The default for most teams |
| Dev container / devbox | As above, plus a declared toolchain | Seconds, after a build | Teams wanting reproducibility |
| Disposable VM | Kernel too | Tens of seconds | Untrusted code, unreviewed servers |
Level two is the right default. Level four is for the specific case of running something you have not reviewed — an unknown dependency, a new MCP server — where a container escape is a realistic concern rather than a theoretical one.
A working container setup
FROM python:3.12-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
git ripgrep jq curl ca-certificates \
&& rm -rf /var/lib/apt/lists/*
RUN useradd -m -u 1000 agent
USER agent
WORKDIR /work
# Toolchain pinned in the image, not installed at runtime
COPY --chown=agent requirements-dev.txt /tmp/
RUN pip install --user --no-cache-dir -r /tmp/requirements-dev.txt
ENV PATH="/home/agent/.local/bin:${PATH}"
The pinned toolchain is the part that makes this reproducible. An agent that cannot install things is an agent whose environment matches everyone else's, which removes an entire category of "works for me".
#!/bin/bash
# One isolated session against the current worktree.
set -euo pipefail
NAME="agent-$(basename "$PWD")-$$"
docker run --rm -it \
--name "$NAME" \
--mount type=bind,src="$PWD",dst=/work \
--workdir /work \
--network agent-net \
--cap-drop ALL \
--security-opt no-new-privileges \
--memory 4g --cpus 2 --pids-limit 512 \
--tmpfs /tmp:rw,noexec,nosuid,size=512m \
agent-sandbox:latest "$@"
Resource limits are doing operational work here, not security work. An agent
that spawns an unbounded number of processes, or a test suite that consumes all
available memory, degrades the whole machine. --pids-limit and
--memory keep a runaway session from taking your laptop with it.
The mounts to argue about
Every mount is a decision, and the pressure is always toward adding more because each one fixes something real.
| Mount | Verdict |
|---|---|
| The working directory | Required |
| A named volume for package caches | Fine, and a large speed win |
~/.gitconfig, read-only | Fine — name and email only |
~/.ssh | No. Use a scoped deploy key as a file |
~/.aws, ~/.config/gcloud | No. Inject a short-lived token as an env var |
| The Docker socket | No. This is root on the host |
| The whole home directory | No, and it is worth asking why it was proposed |
The cache volume in row two deserves a mention because it removes the main argument against containers. Without it, every session re-downloads dependencies and people abandon the sandbox out of impatience. With it, startup is fast enough that nobody notices.
docker volume create agent-pip-cache
# then, in the run command:
# --mount type=volume,src=agent-pip-cache,dst=/home/agent/.cache/pip
What breaks when you containerise
Three things reliably stop working on the first attempt, and knowing them in advance saves an afternoon.
| Symptom | Cause | Fix |
|---|---|---|
| Files owned by root on the host | Container runs as root; bind mount preserves UID | --user "$(id -u):$(id -g)" |
| Git refuses to operate on the mount | Ownership mismatch triggers safe.directory |
Match the UID, or add the path to safe.directory in the image |
| Agent cannot reach the model API | Bridge network without DNS or egress | Allow the provider host explicitly on the network policy |
The first is the one that causes lasting annoyance, because you discover it later when something outside the container cannot write to files the agent created. Setting the UID at the start avoids the whole class.
Networking
A user-defined bridge network, never --network host. Host
networking exposes every service bound to localhost — frequently
unauthenticated, precisely because they are local-only.
If the agent needs a local service, put that service on the same bridge network and reach it by container name. Slightly more setup, and it keeps the boundary intact rather than dissolving it for convenience.
Devcontainers, briefly
If your team already uses the devcontainer standard, you have most of this and should reuse it rather than building a parallel setup.
{
"name": "agent",
"build": { "dockerfile": "../Dockerfile.agent" },
"remoteUser": "agent",
"runArgs": ["--cap-drop=ALL", "--security-opt", "no-new-privileges",
"--memory=4g", "--pids-limit=512"],
"mounts": [
"source=agent-pip-cache,target=/home/agent/.cache/pip,type=volume"
],
"containerEnv": { "AGENT_SANDBOX": "1" }
}
The advantage is not technical, it is organisational: one definition serves both human and agent sessions, so it stays maintained. A separate agent-only image drifts, because nobody uses it daily and nobody notices when it breaks.
AGENT_SANDBOX=1 is worth including. It gives scripts a way to
detect the environment — useful for refusing operations that should never
run inside a sandbox, or for adjusting output verbosity.
Making it the path of least resistance
A sandbox people bypass is not a sandbox. The determining factor is almost always startup time and friction, not policy.
- One command to start a session, no arguments to remember.
- Cache volumes, so the second run is fast.
- The image built in CI and pulled, not built locally.
- Everything the work needs already inside — if people must install tools at runtime, the image is wrong.
Get those right and the sandbox becomes the easy option, at which point nobody has to be persuaded to use it. That is the only version of this that survives contact with a deadline.
Sandbox for reproducibility and cleanup, not only for security — it is the argument that persuades. Pin the toolchain in the image, set resource limits so a runaway session cannot take the machine, mount caches so startup is fast, and refuse the four mounts that dissolve the boundary. A sandbox people bypass is not a sandbox.