Letting an agent run its own generated code inside a normal Docker container means trusting a boundary that was built to organize reviewed workloads, not to contain adversarial input. There is a faster and stronger option than nesting containers inside containers, and it fits in about twenty lines of Python.

This is for anyone whose agent has a "run this code" tool. By the end you'll have a sandbox lifecycle script you can drop into that tool's execution path, plus a one-minute decision rule for which isolation boundary your workload actually needs.

Why a container isn't the isolation boundary you think it is

A plain docker run on a shared kernel host gets you Linux namespaces and control groups (cgroups): mechanisms that partition what a process can see and how much it can consume. Both were designed to keep trusted workloads from stepping on each other. Neither was designed to survive a guest that is actively trying to get out. Every container on that host talks to the same kernel, so one kernel bug is one boundary away from everything else you run.

Scope that fairly. "Docker" is no longer one fixed isolation level. Docker Desktop on macOS and Windows has always run containers inside a lightweight virtual machine, and recent versions extend that model further, which narrows the gap considerably. The weak case is the common one: raw docker run on a shared-kernel Linux host, which is what most self-hosted agent setups are doing.

Here is the nuance most vendor writeups skip. The production incidents everyone cites when arguing "Docker isn't enough" were not container escapes. Langflow's CVE-2025-3248 and n8n's CVE-2025-68613 were code execution reachable through the application's own input handling. The attacker never needed to break out of anything, because the application handed over exec() at the front door.

MicroVMs give you a real kernel boundary at container speed

A microVM is a stripped-down virtual machine: real hardware virtualization, but with the device model cut down to almost nothing (a disk, a network interface, a console) so it boots in milliseconds instead of tens of seconds. Each sandboxed execution gets its own kernel rather than sharing the host's. That's the whole pitch.

Firecracker, the virtual machine monitor AWS built for Lambda and Fargate, boots that kind of guest in roughly 125ms with single-digit-MiB memory overhead per instance, per AWS's own published figures. Fast enough to sit inline in a request path, not just in a batch queue. Treat 125ms as the conservative figure: numbers are implementation-specific, and newer sandbox runtimes claim sub-100ms boots (microsandbox's README reports under 100ms on an M1, self-reported and without a published benchmark). Measure on your own hardware before you design around a specific latency budget.

The catch is operational. Running Firecracker directly means the jailer wrapper, building and maintaining minimal kernel images, tap device networking, and a supervisor to reap dead VMs. That's a multi-week infrastructure project for a small team, and an ongoing one. The practical move is a microVM SDK on top of that machinery, self-hosted or managed, not hand-rolled Firecracker.

The sandbox lifecycle, in code

Below is the full pattern with microsandbox, an Apache-2.0 SDK backed by libkrun that gives you microVM isolation through a Python API. It's still beta software, so pin your version. The SDK is embeddable: no server to run, no daemon to manage. The first call downloads and caches the runtime automatically:

Create, execute, capture, destroy
import asyncio
from microsandbox import Sandbox

AGENT_CODE = """
import json, pathlib
data = {"files": len(list(pathlib.Path("/").glob("*")))}
print(json.dumps(data))
"""

async def run_untrusted(code: str, timeout_s: int = 30) -> str:
    sandbox = await Sandbox.create("agent-exec", image="python", memory=512)
    try:
        exec_result = await asyncio.wait_for(
            sandbox.exec("python", ["-c", code]), timeout=timeout_s
        )
        return exec_result.stdout_text
    finally:
        await sandbox.stop()

async def main() -> None:
    stdout = await run_untrusted(AGENT_CODE)
    print("stdout:", stdout)

asyncio.run(main())

The try/finally is the load-bearing part. Teardown is the setting people get wrong: they create the sandbox, run the code, and destroy it on the happy path only. Then a generated script hangs on a socket read, the request times out, and the microVM keeps running. Do that a few hundred times and you're out of memory on the host for reasons that look nothing like the cause.

Wrap the execution in asyncio.wait_for, not just the sandbox creation. On timeout, wait_for cancels the pending sandbox.exec() coroutine and raises TimeoutError, which the finally block catches on its way out to call sandbox.stop() regardless of how the try block exited. Skip the finally and a guest that never returns leaks the sandbox indefinitely. Note that cancelling the client-side coroutine is not the same as killing the process inside the guest: sandbox.stop() is what actually reclaims it, so verify the VM is gone with msb ls after a forced timeout rather than assuming cancellation alone was enough.

Where gVisor fits, and where it doesn't

gVisor is Google's user-space kernel: instead of giving each sandbox its own kernel, it intercepts syscalls in a userspace process and reimplements them, so the guest rarely touches the host kernel directly. Lower overhead than a VM, stronger than raw namespaces.

That's a legitimate choice for compute-bound work. Run a generated pandas transformation or a numeric simulation under gVisor and you get meaningful protection at a fraction of the cost.

It gets weaker exactly where agent code gets interesting. Filesystem access, outbound network calls, and subprocess exec are the paths with the largest reimplemented syscall surface, and they carry the most performance cost. Use gVisor for narrow, I/O-light workloads. Don't make it your only line of defense for arbitrary generated code.

Picking the right boundary for your agent

Four questions, in order. Stop at the first yes.

  1. Is the code internal, reviewed, and committed by a human? Containers are fine. You already trust the author.
  2. Does the code come from an LLM and touch the filesystem, the network, or subprocess exec? MicroVM. This is the default case for coding agents.
  3. Is it generated but compute-only, with no I/O? gVisor is an acceptable middle ground.
  4. Can an unauthenticated caller reach this execution path at all? Fix that first. No sandbox makes it acceptable.

Rough orders of magnitude, not benchmarks. Cold start and memory overhead vary enormously with image, host, and configuration, so treat this as a shape rather than a measurement:

Boundary Cold start Memory overhead Kernel
docker run (shared host) tens to hundreds of ms very low Shared
gVisor (runsc) hundreds of ms tens of MiB User-space reimplementation
Firecracker microVM ~125ms single-digit MiB Own guest kernel

The interesting row is the last one. A microVM is not meaningfully slower or heavier than the alternatives, which removes the usual reason people settle for a container: the assumption that real isolation costs real latency. It mostly doesn't anymore.

A microVM contains the damage from an exec()-on-user-input bug, it does not replace fixing that bug. Run the lifecycle script above against your own agent's execution tool this week, then grep that tool for every error branch and confirm teardown fires on all of them.