Running Agent-Generated Code: What a Container Does and Does Not Isolate
Once an agent can write code and run it (data analysis, test execution, a coding assistant working in a repo), you’re running untrusted code. It’s untrusted not because the model is malicious, but because its input can be: a file it read, a web page it fetched, a dependency it installed. The standard answer is “run it in a container”, and that’s the right start. But a default container shares the host kernel and needs a threat-model-specific security configuration, and the gap between the two matters here.
What a container shares with the host
A container is a set of Linux namespaces and cgroups around a process. It gets its own view of the filesystem, process table and network, but it runs on the host’s kernel. Every system call the code makes goes to the same kernel that runs everything else on the machine. A kernel vulnerability reachable from inside the container is a way out of it, and container escapes of exactly that kind are found regularly.
That’s acceptable for your own code. For code an LLM wrote after reading an untrusted document, look harder at the options.
Three levels of isolation
Hardened container. Same shared kernel, but with the attack surface cut down: no capabilities, a restrictive seccomp profile, non-root, read-only filesystem, no network. It’s cheap and fast to start, and reasonable when the code comes from your own users in a single-tenant system and the host holds nothing sensitive.
User-space kernel (gVisor). The container’s system calls are handled by gVisor’s kernel, written in Go and running in user space, which exposes only a small surface to the host kernel. Install it as the runsc runtime and select it per container. It costs some performance, especially on syscall-heavy and I/O-heavy workloads. It’s a good middle option for multi-tenant code execution on hosts you control.
MicroVM (Firecracker, Kata Containers). Each workload gets its own kernel inside a lightweight virtual machine, with hardware virtualisation as the boundary. Firecracker is what AWS built Lambda and Fargate on, and it starts VMs in a fraction of a second. Choose it when the code comes from anyone on the internet, or when the sandbox runs next to anything valuable.
If you run on managed platforms, you may already have the stronger boundary: Lambda and Fargate tasks run in Firecracker microVMs. There, the remaining risk is less about the kernel and more about what the sandbox can reach: its network access and its credentials.
A hardened docker run
For the first level, or as the inside layer of the other two. Set SANDBOX_IMAGE to your reviewed repository@sha256:... digest, with Python and timeout installed. This smoke command does not mount or execute an external task file; a /work tmpfs would hide any task baked into that path:
docker run --rm \
--network none \
--read-only \
--tmpfs /tmp:rw,noexec,nosuid,size=256m \
--tmpfs /work:rw,nosuid,size=512m,mode=1777 \
--cap-drop ALL \
--security-opt no-new-privileges \
--user 65534:65534 \
--pids-limit 128 \
--memory 1g --memory-swap 1g \
--cpus 1 \
--stop-timeout 5 \
"$SANDBOX_IMAGE" \
timeout 60 python -c 'print("sandbox smoke check")'What each line prevents:
--network none: removes the container’s ordinary external network path. Output files, stdout, mounted sockets and host integrations can still carry data outward. If the code genuinely needs network, route it through an egress proxy with an allowlist of domains rather than opening everything.--read-onlywith smalltmpfsmounts: code can’t modify the image, and writable space is bounded.noexecon/tmpblocks direct executable-file launch there; it does not stop an interpreter reading and executing a script./workstays executable because it holds the task.--cap-drop ALLandno-new-privileges: no raw sockets, no mounting, no setuid escalation.--user 65534: runs asnobody, not root inside the container.--pids-limit,--memory,--cpusandtimeout: constrain resource use. Also enforce lifetime from the host or scheduler; code inside the container must not control the only timeout.
Docker applies its default seccomp profile unless you turn it off. Don’t run sandboxes with --privileged or seccomp=unconfined. Pin the image by digest so what runs is exactly what you reviewed.
Credentials are the real prize
The most damaging thing sandboxed code usually does isn’t escaping the container. It’s using credentials that were reachable from inside it:
- Environment variables. Don’t pass API keys or cloud credentials into the sandbox. If the code needs to call an API, give it a narrowly scoped, short-lived token for that one call, or have the agent host make the call on its behalf.
- Instance metadata. On EC2, a container on the default bridge network can reach
169.254.169.254and get the instance role’s credentials. Require IMDSv2 and set the hop limit to 1 so bridged containers can’t reach it, or block the address with a network policy. - Task and pod credentials. On ECS the task role is served from
169.254.170.2. On Kubernetes, service account tokens are mounted by default. The sandbox should run as a separate task or pod with no role, or with a role that can do nothing. - Mounted volumes. Mounting the host’s Docker socket or home directory hands the sandbox the host. Mount only a scratch directory.
Where this stops
Isolation contains what the code can do. It doesn’t make the code correct, and it doesn’t stop harm done through channels you deliberately left open. If the sandbox can write to the shared output directory that the agent reads next, a malicious script can plant instructions there. Treat everything that comes out of the sandbox (files, stdout, exit messages) as untrusted input to the next step, just like what went in.