The moment you let an agent execute the code it just wrote, its mistakes stop being text and start touching your machine. An agent that can only produce a diff is polite; an agent that can run a test suite, install a dependency, or retry a failing script until it passes is actually useful. The difference between those two setups is a sandbox, and the sandbox has to hold when the model writes something dumb or hostile.
A plain docker run gives you a container, not a sandbox. By default the process runs as root inside the container, the container gets a network interface, and it keeps a default set of Linux capabilities. Docker's own security guide is blunt about what that means: only trusted users should be allowed to control the Docker daemon, because a container can be handed your host filesystem. Model output is not a trusted user.
What follows is the sandbox I use for this: a 6-line Dockerfile, one docker run command, and four tests that prove each wall actually holds. Every command and every line of output below ran on this machine (Docker 29.7.2, Linux, python:3.12-slim).
Prerequisites
- Docker Engine 24 or newer. Check with
docker version. - A Linux host. The resource limits use cgroup v2, which is standard on current distros.
- A directory to use as the shared work volume. It will be owned by the sandbox user, not by you.
- Comfort with Dockerfiles and with reading exit codes.
A container is not a sandbox yet
Docker isolates processes with namespaces and limits them with cgroups, and both are mature kernel features. Namespaces stop the container from seeing host processes; cgroups stop it from eating all your memory. Neither of them protects the host kernel itself: containers share the host kernel, so a kernel exploit is an escape route.
Docker's security page draws the line clearly. The default capability set is an allowlist rather than a denylist, running processes as a non-privileged user adds a layer, and AppArmor or SELinux profiles stack on top. That is enough for code you wrote. For code you did not write and cannot predict, you want a stronger runtime, and there is a section on that below.
The image
# Dockerfile
FROM python:3.12-slim
RUN useradd --create-home --uid 10001 sandbox
WORKDIR /work
USER sandbox
ENTRYPOINT ["python3", "-I", "-B"]
Two details matter. The image ends with USER sandbox, so the container never starts as root. And the entrypoint runs Python in isolated mode:
$ python3 -I -c "import sys; print(sys.flags.isolated, sys.flags.ignore_environment, sys.flags.no_user_site)"
1 1 1
Isolated mode ignores PYTHON* environment variables and the per-user site directory, so a generated script cannot inject a module through the environment, and -B stops it from littering __pycache__ into your work volume.
Build it:
docker build -t agent-sandbox:py312 .
Install whatever the agent is allowed to import at build time. This matters later, because the sandbox has no network.
The run command, flag by flag
docker run --rm \
--network none \
--read-only \
--tmpfs /tmp:rw,noexec,nosuid,size=64m \
--memory 256m --memory-swap 256m \
--cpus 1 \
--pids-limit 32 \
--cap-drop ALL \
--security-opt no-new-privileges \
--user 10001:10001 \
--ulimit nofile=64 \
--workdir /work \
-v /srv/agent-sandbox/work:/work:rw \
agent-sandbox:py312 /work/task.py
What each flag buys you:
--network noneremoves the network interface. No exfiltration, no package installs at runtime, no calling back to a controller.--read-onlymakes the container filesystem immutable, so the only writable places are/tmpand the mounted work volume.--tmpfs /tmp:rw,noexec,nosuid,size=64mgives scratch space that cannot hold a runnable binary and cannot grow.--memory 256m --memory-swap 256mcaps memory and forbids swap, so a runaway allocation dies instead of dragging the host down.--cpus 1and--pids-limit 32bound CPU and process count. Without the pids limit, a fork bomb takes out the host.--cap-drop ALLremoves every Linux capability, including the ability to mount, change uid, or load modules.--security-opt no-new-privilegesblocks setuid binaries from gaining anything.--user 10001:10001matches the in-image user, so file ownership is predictable on the host.--ulimit nofile=64stops a file descriptor flood.- The
-vmount exposes exactly one host directory. Do not mount anything else, and never mount the Docker socket.
Four tests that prove the walls hold
Write these as small scripts in the work volume and run them with the flags above. If a wall fails, the output says so in plain language.
Network isolation:
$ docker run ... /work/net.py
NETWORK: blocked ([Errno 101] Network is unreachable)
Read-only root filesystem, writable work volume:
$ docker run ... /work/rofs.py
/home/sandbox/out.txt: [Errno 30] Read-only file system: '/home/sandbox/out.txt'
/work/out2.txt: written
uid 10001 cwd /work
The agent's own home directory is read-only, and the work volume is not. That is the shape you want: the agent can write results, nothing else.
Memory ceiling, allocating 900 MB inside a 256 MB container:
$ docker run ... /work/mem.py
$ echo $?
137
No error message, no traceback. The kernel OOM killer took the process, and Docker reports 137. Your wrapper has to translate that into something the model can read, or the agent will retry the same allocation forever.
Process ceiling, forking in a loop:
$ docker run ... /work/forks.py
PIDS: stopped after 31 forks ([Errno 11] Resource temporarily unavailable)
Privilege ceiling, trying to become root:
$ docker run ... /work/priv.py
PRIV: cannot change uid ([Errno 1] Operation not permitted)
PRIV: running as uid 10001 gid 10001
One more wall you cannot test from inside the container: wall-clock time. Run the container detached, wait with a deadline, and kill it when the deadline passes.
cid=$(docker run -d --rm --name sbx ... -c "import time; time.sleep(600)")
timeout 5 docker wait sbx; echo "wait exit=$?"
docker kill sbx
wait exit=124 (124 = still running when the deadline hit)
137
The 137 comes from docker wait after the kill, which is the same code the OOM killer produces. Distinguish them by tracking which event fired, not by the code alone.
The wrapper your agent calls
The tool your agent calls should hide all of this behind a function that returns stdout, stderr, an exit code, and whether the deadline fired.
#!/usr/bin/env python3
"""run_sandbox.py: run one piece of model-generated code in a locked container."""
import dataclasses, pathlib, subprocess, time, uuid
IMAGE = "agent-sandbox:py312"
WORKDIR = pathlib.Path("/srv/agent-sandbox/work") # owned by uid 10001
@dataclasses.dataclass
class Result:
exit_code: int
stdout: str
stderr: str
seconds: float
timed_out: bool
def run(code: str, *, timeout: int = 30) -> Result:
name = f"sbx-{uuid.uuid4().hex[:8]}"
task = WORKDIR / f"{name}.py"
task.write_text(code)
task.chmod(0o644)
cmd = [
"docker", "run", "--name", name, "--rm",
"--network", "none",
"--read-only",
"--tmpfs", "/tmp:rw,noexec,nosuid,size=64m",
"--memory", "256m", "--memory-swap", "256m",
"--cpus", "1", "--pids-limit", "32",
"--cap-drop", "ALL",
"--security-opt", "no-new-privileges",
"--user", "10001:10001",
"--ulimit", "nofile=64",
"--workdir", "/work",
"-v", f"{WORKDIR}:/work:rw",
IMAGE, f"/work/{task.name}",
]
started = time.monotonic()
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, text=True)
timed_out = False
try:
out, err = proc.communicate(timeout=timeout)
except subprocess.TimeoutExpired:
timed_out = True
subprocess.run(["docker", "kill", name], capture_output=True)
out, err = proc.communicate()
finally:
task.unlink(missing_ok=True)
return Result(proc.returncode, out.strip(), err.strip(),
round(time.monotonic() - started, 1), timed_out)
Running it on three inputs, on this machine:
exit=0 0.4s out='3.12.14'
exit=137 0.9s stdout='' timed_out=False
exit=137 5.1s timed_out=True
Line two is the memory bomb: killed by the kernel after 0.9 seconds, and the wrapper knows it was not a timeout. Line three is the sleep that hit the 5-second deadline, and the wrapper knows it was. Pass both facts to the model when you report the failure, because "your process was killed for using too much memory" and "your process ran too long" lead to different fixes.
Operating notes
The work volume needs to be writable by uid 10001. I lost ten minutes to this: a work directory owned by another uid gives you Permission denied on the output write, which reads like a sandbox misconfiguration and is just Unix permissions. chown -R 10001:10001 /srv/agent-sandbox/work, and let the wrapper process own the parent directory so it can drop task files in.
Bake dependencies into the image. With --network none there is no pip install at runtime, and that is a feature: the set of importable packages becomes something you control and review, not something the agent picks.
Do not pass credentials into the sandbox. The container boundary protects your host and your network. It does not protect a value you put in an environment variable, and generated code that prints os.environ will find it.
Run one container per task and let --rm clean it up. A reused container lets one task leave state behind for the next one, which turns a bounded failure into a confusing one.
Report the exit code as a first-class part of the result. 137 means killed, 124 means your deadline fired, and 0 with empty output usually means the code passed but printed nothing. An agent that only sees stdout will misread all three.
When to escalate to a stronger runtime
This setup assumes the code is wrong more often than it is malicious. If the code is fully attacker-controlled, or your threat model includes kernel exploits and container escapes, you want a runtime that does not share the host kernel. gVisor is the usual next step: it ships an OCI runtime called runsc that plugs into Docker and Kubernetes, and it handles system calls in userspace instead of passing them through to the host kernel. Firecracker and Kata Containers go further with lightweight virtual machines. All three cost you compatibility or startup time, which is why the plain container is still worth having as the default.
WASM runtimes sit at the other end of the tradeoff: a much smaller syscall surface, no filesystem, and a narrower set of languages that work unmodified.
Checklist
- Build an image whose entrypoint is a non-root user, and pin the interpreter into isolated mode.
- Pass
--network none,--read-only, and a size-limitednoexectmpfs. - Cap memory, CPU, pids, and open files, and drop every capability.
- Mount one work directory, never the Docker socket, never a home directory.
- Run the four tests against your own image before you hand it to an agent.
- Wrap the container in a function that returns exit code, stdout, stderr, duration, and whether the deadline fired.
- Escalate to gVisor or a microVM when the code is hostile rather than merely unreliable.