AI Agent Sandboxes Explained: Docker vs gVisor vs MicroVMs
Compare Docker, gVisor and Firecracker microVMs for untrusted AI agent code, including syscall isolation, Directfs, tenant limits and artifact attacks.
On this page
- Decide what has to run before choosing a runtime
- The first difference is which kernel handles the application
- Docker can be hardened without becoming a different kernel boundary
- gVisor adds a userspace kernel, with an important Directfs detail
- MicroVMs move the application behind a guest Linux kernel
- How long should the sandbox live?
- The runtime is only one of the paths out of the sandbox
- An artifact importer can undo a correct sandbox boundary
- A job can stay within its limits and still overwhelm the platform
- Compatibility and observability depend on the workload
- Benchmark the work the user receives, not just startup
- Cancellation has to stop the execution, not just the tool call
- Which sandbox would I choose?
- Primary technical references
A customer uploads a Python project and asks your support agent to reproduce a reconciliation error. The repository contains a few transaction files, a pyproject.toml, and a script that calculates the expected totals. The agent reads the code, decides it needs to reproduce the error, and starts by installing the project's dependencies.
The install can be the first dangerous operation. A Python source package may invoke a build backend, which executes code before the agent reaches the script it intended to run. Suppose that code looks for environment variables, tries a cloud metadata endpoint, or reads another customer's files. It does not need a container escape if the execution environment already exposes something useful.
Now the platform team has to choose where that work runs. A conventional Docker container, a gVisor sandbox, and a microVM can all run ordinary Python programs. They do not give the program the same access to the underlying operating system, and they differ in compatibility, startup behavior, and the amount of infrastructure you must operate around them.
Assume customers can submit arbitrary projects and the service runs jobs for many customers on shared machines. The project, its dependencies, the model's commands, and all resulting output are untrusted. Our goal is to stop one job from reaching another customer's data or taking control of the host while keeping common development tools usable. That gives us a concrete security contract to evaluate instead of treating sandboxed as a sufficient description.
Decide what has to run before choosing a runtime#
The agent may not need to execute the repository at all. It could inspect the transaction files through a constrained data reader, examine the Python source as text, and compare the calculation with expected results. Parsing untrusted data still needs limits and maintained libraries, but reading a file is not the same operation as importing its Python module or running its package build. If static inspection answers the question, avoid creating an execution environment simply because the agent knows how to use a shell.
If running the project is necessary, treat dependency preparation as untrusted execution too. A lockfile can make dependency selection reproducible but does not make a malicious build hook harmless. A package mirror restricts where packages come from but is not a guarantee that their code can safely run. Python's PEP 517 build interface documents why installing a source distribution may involve executable hooks.
For the example throughout this article, I will use one fresh sandbox per customer job. The job may run an installation, a test, and the reconciliation script in sequence inside that sandbox. Its filesystem is private to that job, and the sandbox is destroyed after the job is complete. The distinction between a job and a long-lived agent session becomes important later.
The first difference is which kernel handles the application#
A Linux program eventually asks the operating system to open a file, map memory, create a process, or make a network connection. Consider the project's attempt to open /input/orders.csv. What handles the corresponding Linux system call?
| Runtime | Application-facing system calls | Isolation boundary | Main concerns |
|---|---|---|---|
Docker with conventional runc | Host Linux kernel | Namespaces, credentials, capabilities, seccomp, security modules, and cgroups enforced on the host | Shared kernel, privileges, daemon access, mounts |
gVisor with runsc | gVisor Sentry implementing much of the Linux ABI | Userspace application kernel with restricted host interaction | Compatibility, Directfs configuration, Gofer, host-facing syscalls |
| Firecracker microVM | Separate guest Linux kernel | Hardware virtualization, VMM, and restricted host interface | Guest and VMM patching, devices, credentials, lifecycle overhead |
With conventional Docker, the host Linux kernel handles the application's openat call while enforcing the process's namespace and mount permissions. With gVisor, the application sees Linux behavior implemented by the Sentry. The Sentry may perform restricted host operations to satisfy that request. With a microVM, the guest kernel handles the file operation, and guest storage ultimately interacts with the host through a virtualized device path. The guest does not forward every application syscall unchanged to the host kernel.
Docker images and isolation runtimes are different layers. gVisor can run many ordinary OCI images using runsc. A VM-backed runtime such as Kata Containers can also preserve an OCI and Kubernetes workflow. The choice is not whether to use a container image. It is what executes the image and what that execution environment can reach.
Docker can be hardened without becoming a different kernel boundary#
Calling containers insecure by definition would be wrong. Linux namespaces isolate views of processes, filesystems, and networks. Capabilities restrict privileged operations, seccomp limits which syscalls can be made, and AppArmor or SELinux can further constrain access. Cgroups bound resources. These are genuine defenses, and they are often sufficient for reviewed internal jobs with carefully limited inputs.
They are still enforced by the same host kernel that other workloads depend on. A hostile native program has a broader direct host-kernel attack surface than it would behind a userspace application kernel or separate guest kernel. Restricting that surface is valuable, but it does not remove the shared-kernel relationship.
For an approved, prebuilt Python image that needs no network, a starting configuration might look like this:
# Linux-host illustration, not a production sandbox policy.
docker run --rm \
--user 65532:65532 \
--network none \
--read-only \
--cap-drop ALL \
--security-opt no-new-privileges \
--pids-limit 128 \
--memory 1g \
--memory-swap 1g \
--cpus 1 \
--tmpfs /tmp:rw,nosuid,nodev,size=256m,mode=1777 \
--mount type=bind,source=/srv/jobs/42/input,target=/input,readonly \
--mount type=bind,source=/srv/jobs/42/output,target=/output \
registry.example/sandbox@sha256:REPLACE_WITH_REAL_DIGEST \
python /app/analyze.py /input /output
The digest is a placeholder, and the command assumes a trusted controller has already created the job directories with the right ownership and validated their paths. The image must contain the application and dependencies before this command runs. The host also needs an appropriate security profile, storage quotas, a wall-clock deadline, and a controller that captures output without allowing unlimited buffering. None of those properties follows from this command.
The two memory flags are intentional. Docker defines --memory-swap as the combined RAM and swap allowance. Setting it equal to --memory prevents additional swap use on hosts that support this enforcement. Without the second flag, a container may receive a further swap allowance. The --tmpfs size bounds that temporary mount but not the writable /output directory, and tmpfs memory still counts against the container's memory budget. Docker documents these distinctions in its resource constraints and tmpfs guidance.
Writable bind mounts are a frequent source of accidental authority. The controller must bind the intended job directory rather than follow a path that the customer can replace with a symlink. The job must not control the parent path used for later imports. Keep /var/run/docker.sock out of the job too. A client with access to a privileged Docker daemon can ask it to create containers with host mounts or broader permissions. Rootless Docker changes daemon privileges but does not make its socket an appropriate tenant API. Likewise, adding --privileged to make a troublesome tool work can undo several protections. The Docker Engine security guide covers these boundaries.
That is a reasonable baseline for a known workload. For arbitrary customer code, it is a starting point for threat analysis rather than proof that sharing the host kernel is acceptable.
gVisor adds a userspace kernel, with an important Directfs detail#
In gVisor, a process runs against the Sentry, which implements much of the Linux system-call interface in userspace. Its runsc OCI runtime starts and manages this environment. The Sentry itself uses the host kernel, but the application does not drive the host's ordinary syscall interface in the same way that it does under runc. The gVisor security model describes the separation.
Filesystem access is more interesting than the shorthand description suggests. gVisor has a separate Gofer process responsible for making permitted filesystem trees available. Historically, the Sentry sent filesystem requests to the Gofer over a protocol, and the Gofer performed host operations on its behalf. That is still an available mode, but it is not the default path for ordinary host-backed files in current runsc configurations.
Directfs is enabled by default. The Gofer supplies file descriptors for approved mount points to the Sentry. The Sentry can then use restricted, file-descriptor-relative host calls, including forms such as openat, on those trees without a Gofer RPC for each operation. The application-facing call is still handled by the Sentry. What changed is how the Sentry completes the permitted filesystem work against the host. Directfs relies on tight filesystem namespaces, restricted descriptors, and syscall filtering. It also requires a different host-facing permission profile than the RPC-only path. Disabling it with --directfs=false reduces that direct filesystem surface but may add overhead. This is documented in the gVisor filesystem guide and its Directfs design explanation.
It is worth measuring both modes for a workload dominated by file creation and metadata operations. A benchmark that identifies only the runtime as gVisor is missing a configuration choice that can affect both performance and security review. It should also record the syscall interception platform. The default systrap platform uses seccomp traps to transfer application syscalls to the Sentry. The KVM platform uses virtualization facilities for interception and address-space isolation, but it does not boot a separate ordinary Linux guest like Firecracker. Nested virtualization may also change its performance. The platform guide distinguishes these approaches.
Most everyday language runtimes work under gVisor, but unsupported Linux features still matter. A browser may use numerous subprocesses and shared memory. Native tooling may expect a particular ioctl, /proc behavior, specialized device, or io_uring capability. The compatibility guide describes gaps, including limited io_uring and device behavior. Cgroup controls presented inside a gVisor sandbox also do not enforce resource limits between its processes. The host cgroup needs to bound the sandbox as a whole.
For a dependency-heavy Python job, the compatibility test should cover installation, execution, file operations, and error paths under the exact runsc build and Directfs settings intended for production. Successful execution of python -c 'print(1)' establishes almost nothing about that workload.
MicroVMs move the application behind a guest Linux kernel#
Firecracker uses KVM to run a lightweight VM with its own Linux kernel. The Python program invokes that guest kernel. The guest communicates with storage and network-related devices through virtualized interfaces, with Firecracker and KVM mediating the host side. An exploit inside the guest does not automatically grant access to the host's processes or filesystem. Crossing that boundary requires a separate vulnerability or a dangerous host integration.
The additional boundary is attractive when mutually distrustful customers submit arbitrary native code. It is not permission to ignore the host. Firecracker's Jailer confines the VMM process with restricted filesystem visibility, identity, namespaces, and resource controls. Its production guidance also requires careful host and guest patching, virtual-device configuration, and seccomp policy. The VMM is a component of a secure platform, not the entire platform. See the Firecracker Jailer documentation.
The surrounding infrastructure becomes more involved. You need guest kernels and root filesystems, a way to stage inputs and collect outputs, networking or vsock mediation, VM placement, image updates, and a reliable process for termination and reuse. Kata Containers can connect VM-backed execution to a container orchestrator, but the control plane still decides which image, mounts, and permissions the workload receives.
Even managed microVM services illustrate why the runtime boundary cannot define the whole security model. Google's published Agent Compute Engine architecture describes microVMs without ordinary direct VPC networking, with access mediated through a gateway that performs identity and delegation checks. By contrast, AWS's AgentCore credential management documentation says code inside its microVM can access temporary execution-role credentials through the metadata service, making least-privilege role configuration essential. Both use a VM boundary. The credential exposure models are different.
A microVM can also be snapshotted and restored, but restoration is an additional lifecycle protocol. Firecracker warns that guest network connectivity is not guaranteed to survive restore and that snapshot compatibility depends on host and virtual-machine configuration. Snapshotting does not by itself solve filesystem consistency, authorization changes, or replay of an external API call. Those issues are explored separately in ArchCrux's Agent Compute Engine article.
How long should the sandbox live?#
The security and cost model changes depending on whether you create an environment per command, per job, or per agent session. The Docker, gVisor, and microVM labels do not answer this question.
With a sandbox per command, each invocation starts from a defined state. Cleaning up is comparatively straightforward, but installing packages for every command can be expensive, and commands cannot rely on in-memory state from previous calls. With one sandbox per job, our reconciliation task can install dependencies and run several commands in the same private workspace. A compromised dependency can influence later commands in that job, which is acceptable only if the whole job is already treated as one untrusted unit. Once it finishes, the environment is discarded.
A sandbox per agent session may live much longer. A coding agent could keep a browser, processes, and edited files between many model turns or human approvals. Then you need an idle policy, a workspace persistence model, credential renewal, and a way to deal with revocation while the session is suspended. Reusing one live environment across unrelated customers is a different and much harder claim. Sharing an environment across customers should require a proven reset that removes processes, file descriptors, mutable state, and inherited identity.
The unit of isolation also needs to match the orchestration unit. In Kubernetes, RuntimeClass chooses the runtime handler for a Pod, which can contain more than one container. A gVisor sandbox may likewise contain multiple containers sharing a Sentry. A VM-backed runtime may put the Pod's containers into one VM, depending on its configuration. Do not promise customer isolation merely because each customer has a distinct container object if they share the same stronger boundary. See Kubernetes RuntimeClass and gVisor's sandbox model.
For this service, each sandbox belongs to exactly one customer's job. The controller records the customer, job, sandbox instance, and execution generation explicitly. That ownership becomes important when the task is cancelled or a worker is replaced.
The runtime is only one of the paths out of the sandbox#
The uploaded project should receive a job-specific input snapshot, a scratch workspace, and the minimum package access required for that job. It should not inherit a customer-wide cloud token or the agent harness's deployment credentials. If it needs an approved business action, the harness should request that through a separate trusted service that checks the current user, resource, task, and effect. This is a different problem from preventing a kernel escape, so there is no reason to repeat the entire authorization design here. ArchCrux's agent containment article covers that part in detail.
There are three other paths out of the sandbox that deserve independent treatment: created files, stdout and stderr, and network connections. A safe artifact importer does not make logs safe, and an egress gateway does not prevent a privileged importer from reading the wrong host file.
For dependency installation, the difference is practical. Fetching an approved package from a mirror is a narrower network capability than arbitrary HTTP access. If source builds are necessary, run them under an equally untrusted execution policy, with package access but no business credentials. Once dependencies have been prepared, the final execution may need no network at all. Where networking is required, the gateway should enforce destinations at the actual connection path, including redirects and private or link-local addresses, rather than trust a hostname supplied by the model.
An artifact importer can undo a correct sandbox boundary#
Suppose the script writes /output/report.csv, but makes it a symlink instead of a regular file. The link target is harmless or nonexistent inside the sandbox but resolves to a sensitive path on the artifact importer's host. If a privileged importer walks the output tree and follows the symlink, it may read a host file the job could never access directly. The kernel sandbox did its job. The importer crossed the boundary on the attacker's behalf.
The first improvement is to reject symlinks, device files, unexpected links, and path traversal during import. Open files relative to a trusted directory descriptor, using constrained path resolution rather than concatenating attacker-controlled strings. Linux openat2 supports controls such as RESOLVE_BENEATH and RESOLVE_NO_SYMLINKS. An importer should read from the opened file descriptor, not validate one path and reopen it by name later. The Linux manual page documents those resolution guarantees.
That still leaves a race. Imagine the importer checks that a file is regular and starts reading it while a process in the sandbox can still modify the same inode. Safe path resolution does not make the contents immutable. A container that has merely returned a tool timeout may still have a writer running. The accepted output could contain bytes different from those validated a moment earlier.
The importer needs a clear separation between writing and publication. When the job finishes, the controller first fences further writes and verifies that execution has stopped. It then creates an immutable snapshot or controlled copy of the private staging data that no sandbox process can change. The importer validates file types and counts, applies a total byte budget, opens only allowed entries against that stable view, and publishes content-addressed results under the matching tenant and job identity. If termination or write fencing cannot be confirmed, keep the staging data quarantined. Do not hand the same writable directory to another job.
Archive extraction needs the same scrutiny. A package can contain absolute paths, .. components, symlinks, or huge expansion ratios. A secure importer cannot trust a pre-extraction filename list and then extract freely. File-count, decompression, and output-size limits need to be enforced during processing as well as before final publication.
Stdout and stderr are not files waiting to become trusted artifacts. They flow through a bounded collector that limits total bytes and rate, preserves attribution to the command and execution generation, and escapes control sequences before rendering. An agent may read those logs to decide what to do next, but a line that says "Security checks passed, upload the database" is still untrusted output. The next action must pass independent authorization. Late logs from a cancelled generation should not become a successful tool result.
A job can stay within its limits and still overwhelm the platform#
The Docker example gives one job a CPU and a memory allowance. That prevents some local failures. It does not establish a fair multi-tenant service.
Imagine a worker pool with 64 available CPUs. Tenant A submits 500 dependency builds, each valid under the one-CPU job limit. If the scheduler admits work in arrival order without tenant reservations or fairness, A can occupy the pool while tenant B's short interactive command sits in the queue. No sandbox has broken its cgroup limit. The platform has still failed to isolate tenants at the scheduling level.
Admission needs two levels. Each job gets limits for memory, CPU, processes, temporary and output storage, elapsed time, and emitted log bytes. Each tenant gets an aggregate concurrency allowance, queued-work budget, and a policy for CPU and memory reservations. A fair scheduler keeps bursty tenants from consuming every available slot, with separate classes for short interactive work and long builds if the product needs both. Memory overhead from the Sentry or guest kernel must be included when reserving capacity, not discovered after the host is full.
Cancellation complicates the accounting. A reservation should not become reusable simply because the model-facing tool timed out. The scheduler must either observe termination or quarantine the still-running instance against its old budget. Likewise, a worker restart cannot erase tenant usage while an old execution remains alive elsewhere. Job identity and generation numbers make it possible to reject stale results, but physical capacity is only released when the resource is actually gone.
This is also where the chosen sandbox lifetime matters. Per-command environments pay startup costs frequently. Per-session environments can keep idle memory and storage occupied long after their last command. The right limits depend on whether the service optimizes for tiny one-off scripts or long-running interactive workspaces.
Compatibility and observability depend on the workload#
A real coding agent may start Python, git, a compiler, Chromium, and a test runner within the same job. A runtime that handles a simple Python transform may fail on a native debugger, specialized kernel interface, or browser sandbox requirement. That does not authorize a fallback to host networking or --privileged. It means the scheduler needs a different, previously approved runtime class, or the product must state that the tool is unsupported.
Observability changes too. Under ordinary runc, host tools often see application processes directly. Under gVisor, some process and syscall accounting is represented by the Sentry. A microVM host sees the VMM while detailed guest information may require guest-side telemetry. If a platform promises command-level resource accounting and reliable cancellation, test those functions on the selected runtime rather than assume existing host dashboards provide the same signals.
Before launch, the acceptance suite should include hostile probes that try another tenant's files, the runtime daemon or VMM socket, metadata endpoints, unapproved outbound destinations, excessive process creation, massive logs, and malicious output archives. Repeat after cancellation and worker replacement. A successful test is not proof that a kernel or VMM exploit is impossible. It is evidence that the declared boundaries are enforced in the configuration you actually ship. Record the host kernel, runtime build, image digest, security profiles, network and mount policy, and admission rules alongside each result.
Benchmark the work the user receives, not just startup#
An empty process or VM boot measurement does not capture image pulls, dependency builds, input staging, log handling, and artifact validation. For the reconciliation example, the meaningful latency starts when the job is accepted and ends when a validated result is ready. Keep runtime startup as a separate submeasurement, but do not mistake it for the product's latency.
| Workload to compare | What it exposes | What to measure |
|---|---|---|
| Small prebuilt Python script | Startup, admission, image caching | Time to first valid result at p50 and p99 |
| Python install with source builds | Process creation, metadata I/O, package access | Completion rate, build time, I/O volume |
| Browser-driven task | Shared memory, subprocesses, network compatibility | Success rate, peak memory, end-to-end latency |
| Native compile and test | CPU and kernel-feature compatibility | Correctness, elapsed time, private memory |
| Mixed jobs under concurrency | Fairness, density, queue pressure | Per-tenant queue time, throughput, interference |
For a credible comparison, record the host machine and kernel, image digest, CPU and memory policy, runc or runsc version, gVisor's systrap or KVM platform, Directfs enabled or disabled, and the guest kernel and VMM configuration for the microVM. Run cold and warm cache cases separately and keep network and package-source policies comparable. Include failures and unsupported operations in the results rather than calculate latency only for the fastest successful tasks.
Memory density belongs in the same evaluation. A microVM carries guest-kernel and VMM state. gVisor uses a Sentry and associated host processes. Ordinary containers often have less runtime-specific overhead, but caching and sharing complicate comparisons. An extra 64 MiB of private memory across 2,000 simultaneous sandboxes would consume 125 GiB. That is an illustrative capacity calculation, not a measured difference between these runtimes. At high concurrency, the result can matter more than a few milliseconds of isolated startup time.
Cancellation has to stop the execution, not just the tool call#
Suppose the user cancels while pip install hangs. The model-facing call returns "cancelled," but an old process keeps writing to the output directory. If a replacement worker accepts those files, or the scheduler frees a resource reservation too early, the cancelled job is still affecting the system.
Each execution needs a durable job identity, owner, sandbox instance, deadline, and generation. The trusted controller selects the runtime policy, terminates or quarantines the instance when cancellation is requested, and rejects late output from an earlier generation. Temporary network or tool permissions should be invalidated as well. An unconfirmed stop is a different state from a confirmed termination. That distinction must carry through artifact publication and resource accounting.
A fresh job sandbox avoids some reuse problems, but it does not reverse an external API operation that already completed. The trusted action service needs its own idempotency and reconciliation contract for side effects. That is outside the OS sandbox and should not be advertised as a property of Docker, gVisor, or Firecracker.
Which sandbox would I choose?#
| Situation | Starting choice | What would make me reconsider? |
|---|---|---|
| Agent only calls trusted typed APIs | No general shell sandbox | Arbitrary code execution is introduced |
| Reviewed internal script with bounded inputs | Hardened conventional container | Unknown third-party code or stronger tenant separation |
| Customer-supplied Python or Node.js code | gVisor-backed OCI sandbox | Compatibility problems or measured I/O overhead |
| Untrusted native tools requiring broad Linux behavior | MicroVM or VM-backed container | Guest management and density cost exceed the workload's requirements |
| Stateful browser or coding workspace | Vetted isolated runtime with explicit session lifecycle | Suspension, storage, or identity requirements change |
For Kubernetes, RuntimeClass can select approved runtime handlers, but it is not an authorization policy. Admission control must prevent a user or model from choosing a weaker class to get around a compatibility error. The platform should decide what constitutes a job, which resources it owns, and which execution class may run it.
For our uploaded Python project, I would first try static inspection. If the calculation must be executed, it gets a fresh job sandbox, separate dependency-build policy, private inputs, controlled outputs, no general customer credentials, and admission limits at both job and tenant level. I would evaluate gVisor and a microVM against that complete workload before picking a default. A hardened runc container is still a defensible choice for narrower, reviewed workloads.
The runtime decision matters because of the path from untrusted application code to the operating system. The production decision is larger. You also have to know when that sandbox starts and stops, which tenant owns its capacity, and whether files and logs remain untrusted after execution finishes. Those are the places I would expect the design review to spend most of its time.
Primary technical references#
- Docker Engine security and resource constraints
- gVisor security model, Directfs and filesystem configuration, and platform guide
- gVisor compatibility and resource model
- Firecracker Jailer and snapshot limitations
- Kata Containers virtualization and Kubernetes RuntimeClass
- Google Agent Compute Engine architecture and AWS AgentCore credential management
- Python PEP 517 build backends and Linux
openat2
Keep reading
Continue with a guided sequence of free production engineering essays.
Find your next reading pathRelated reading
Continue this path
Designing Containment for Production AI Agents
Production AI agent containment needs more than a sandbox. It needs separate information, execution and effect boundaries, scoped capabilities, egress controls, quarantine, and independent runtime enforcement.
Read essayWhy Google Built a Computer for Every AI Agent
Google gives AI agents isolated computers without keeping every computer running. A detailed engineering review of microVMs, checkpoint recovery, credentials, memory, and scale.
Read essayHow Coding Agents Actually Work: Inside the Agent Harness
A technical look inside coding agent harnesses, covering context management, repository search, tools, sandboxes, subagents, durable sessions, recovery, concurrency, completion and evaluation.
Read essay