← Back to Blog

Sandboxing AI Agent Code with Docker: Four Tests That Prove the Walls Hold

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 none removes the network interface. No exfiltration, no package installs at runtime, no calling back to a controller.
  • --read-only makes the container filesystem immutable, so the only writable places are /tmp and the mounted work volume.
  • --tmpfs /tmp:rw,noexec,nosuid,size=64m gives scratch space that cannot hold a runnable binary and cannot grow.
  • --memory 256m --memory-swap 256m caps memory and forbids swap, so a runaway allocation dies instead of dragging the host down.
  • --cpus 1 and --pids-limit 32 bound CPU and process count. Without the pids limit, a fork bomb takes out the host.
  • --cap-drop ALL removes every Linux capability, including the ability to mount, change uid, or load modules.
  • --security-opt no-new-privileges blocks setuid binaries from gaining anything.
  • --user 10001:10001 matches the in-image user, so file ownership is predictable on the host.
  • --ulimit nofile=64 stops a file descriptor flood.
  • The -v mount 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

  1. Build an image whose entrypoint is a non-root user, and pin the interpreter into isolated mode.
  2. Pass --network none, --read-only, and a size-limited noexec tmpfs.
  3. Cap memory, CPU, pids, and open files, and drop every capability.
  4. Mount one work directory, never the Docker socket, never a home directory.
  5. Run the four tests against your own image before you hand it to an agent.
  6. Wrap the container in a function that returns exit code, stdout, stderr, duration, and whether the deadline fired.
  7. Escalate to gVisor or a microVM when the code is hostile rather than merely unreliable.

Referensi

Need Help Implementing This?

I help teams design and build scalable cloud infrastructure, DevOps pipelines, and production-grade systems.

Book a Free Consultation