Sandboxing an AI agent: containers, microVMs, and WASM

The demo that changed how I design this was not an attack. A colleague's coding agent, asked to clean up a test directory, decided the tidiest interpretation of the request included a parent directory it had no business touching. It was running as the developer's own user, on the developer's laptop, with the developer's credentials in the environment. Nothing malicious happened, nothing was unrecoverable, and the whole thing took four seconds.

That's the useful framing, and it survives contact with the security-minded version of the question. You do not sandbox an agent because a model is malicious. You sandbox it because a probabilistic system with real credentials will eventually take an action inside its permissions that you would not have authorized — and because prompt injection means an attacker can aim that behaviour. The design question is therefore not "is it sandboxed" but "what is the worst thing my tools permit, and what contains it?"

This is the isolation half of agent security. The input half — injection, exfiltration, guardrails — is a different article; this one is about what runs the tool call, what it can reach, and how much it costs to make that boundary real. Everything here is as of late 2025.

What you are actually containing

Three capabilities, and they need separate answers. Teams tend to solve the first, assume the second, and forget the third entirely.

CapabilityThe worst caseWhat contains it
Code execution — the agent runs somethingArbitrary code with your process's privilegesRuntime isolation: container, gVisor, microVM, WASM
Filesystem & secrets — the agent reads or writesReads a credential file, writes outside its scope, deletes a parent directoryA per-run writable scope, read-only everything else, no ambient credentials
Network egress — the agent talks outExfiltration of whatever it just read, or a call to an internal service it shouldn't know existsDeny-by-default egress with an allowlist — the control most often missing

I'd argue the ordering in that table is backwards from how most teams invest. Runtime isolation gets the attention because it's the part with interesting technology in it, but egress is where the damage becomes irreversible. A sandbox that stops nothing from leaving is a sandbox in which a compromised run can read a secret and post it somewhere, which is the whole game. A leaky runtime with strict egress is embarrassing; a tight runtime with open egress is a breach waiting for a prompt.

Four levels of runtime isolation

From weakest to strongest, with what each actually buys you.

Same process, no isolation

What it is: the tool is a function call in your application. What it stops: nothing. Every credential in the environment, every file the process can reach, every internal network destination is in scope. This is fine — genuinely fine — for tools that are narrow, typed, and don't execute anything: get_claim_status(claim_id) hitting your own API is not an execution risk, it's an authorization question. The mistake is running an interpreter here, which is what happens the first time someone adds a "run this Python" tool to a service that also holds a database password.

Containers

What it is: namespaces plus cgroups plus a seccomp profile. What it stops: ambient filesystem access, unbounded resource use, and casual mistakes — the parent-directory deletion above becomes a deletion inside a directory you were going to throw away. What it doesn't: a container shares the host kernel, so a kernel vulnerability is a host compromise, and a misconfigured container (privileged, host network, docker socket mounted) is no boundary at all. Containers are the right default for tool execution in a trusted-code, untrusted-input setting: your code, running the model's parameters. They are not sufficient when the thing being run is code the model wrote.

Kernel-interception sandboxes (gVisor) and microVMs (Firecracker)

What they are: two different answers to "don't share the host kernel." gVisor puts a user-space kernel in front of syscalls, so the host kernel sees a much smaller attack surface. Firecracker gives each run a minimal virtual machine with its own kernel, booting in tens of milliseconds — the model behind several public code-execution services, which is a strong signal about where the bar sits for running untrusted code at scale.

What they stop: host compromise from a kernel bug, which is exactly the risk that containers leave open. What they cost: some syscall-heavy performance for gVisor; a little startup latency and per-run memory for microVMs, plus real operational work to run either well. When to reach for them: the moment your agent executes code it generated, or code from an untrusted source, on infrastructure shared with anything you care about. If you're building a code interpreter, this is the level; a container is not the answer and the industry's own architecture choices say so.

WebAssembly

What it is: a capability-based sandbox at the language-runtime level — a WASM module can do nothing except what the host explicitly grants it, with no filesystem, no sockets and no clock unless you hand them over.

What it buys: the best startup time in this list (microseconds to milliseconds, so per-call sandboxing becomes practical), tiny memory footprint, and a deny-by-default posture that is much easier to reason about than a seccomp profile. It's a genuinely good fit for many-small-untrusted-things: user-supplied expressions, plugin-style tools, per-call transformations.

What it costs: the ecosystem. Native extensions, arbitrary system libraries and subprocesses are constrained or unavailable, and a Python-in-WASM runtime is not the Python your data-science tool expects. Choose it when the tools you're isolating are computational rather than integrative.

graph TB
  AGENT["Agent loop"] --> GATE{"Tool classifier"}
  GATE -->|"typed, read-only,
your own API"| INPROC["In-process call
authorization, not isolation"] GATE -->|"your code,
model-supplied args"| CONT["Container
+ seccomp + read-only rootfs"] GATE -->|"model-written code,
untrusted input"| MICRO["microVM / gVisor
own kernel boundary"] GATE -->|"small pure computation,
per-call"| WASM["WASM module
capability-scoped"] INPROC --> EGRESS{"Egress proxy
deny by default"} CONT --> EGRESS MICRO --> EGRESS WASM --> EGRESS EGRESS -->|"allowlisted hosts only"| NET["Internet / internal APIs"] EGRESS -->|"blocked + logged"| ALERT["Alert: the interesting signal"] SEC[("Secrets broker
short-lived, per-tool")] -.->|"scoped token, never ambient"| CONT SEC -.-> MICRO

One agent, four isolation levels, chosen per tool rather than per application — because uniform isolation means either paying microVM cost for a status lookup or running generated code in a bare process. Two things sit outside the choice and apply to all of it: every path exits through an egress proxy that denies by default, and credentials arrive as short-lived scoped tokens rather than environment variables. A blocked egress attempt is one of the highest-signal alerts you will ever get from an agent.

The controls that matter more than the runtime

Egress: deny by default, allowlist by name

If you take one thing: a sandbox with unrestricted outbound network access is not a sandbox. Route tool traffic through a proxy that permits named destinations and blocks everything else, log the blocks, and alert on them. This single control turns "the model read a file it shouldn't have" from an incident into a log line, because the read is only interesting if the data can leave. It also catches the case nobody models — an agent that discovers an internal service by guessing hostnames because the environment let it.

Credentials: scoped, short-lived, never ambient

The demo I opened with was dangerous mainly because the developer's credentials were sitting in the environment. Tools should receive a token minted for that tool, that tenant, that operation, expiring in minutes — not inherit whatever the process happens to hold. If a tool needs write access to one bucket prefix, that's what its token grants. This is ordinary least privilege, and agents make it urgent rather than aspirational, because the thing choosing which tool to call is a probability distribution.

Filesystem: one writable scope per run, read-only otherwise

Give each run a fresh working directory it can write to, mount everything else read-only, and destroy the directory when the run ends. Cheap, and it converts a whole class of "the agent wrote somewhere unexpected" outcomes into "the agent wrote in the box we were going to throw away."

Resource caps: the loop is the threat model

CPU, memory, disk and wall-clock limits per run, plus a cap on steps. Agents fail by looping, and a loop with an unbounded resource envelope is a self-inflicted denial of service — sometimes on the machine, more often on the bill. The step cap belongs in the harness, the resource caps belong in the sandbox, and both should exist.

# Per-tool isolation policy as data, not as scattered if-statements. The point
# is that a reviewer can read this file and know what each tool can reach.
TOOL_POLICY = {
    "get_claim_status": dict(
        isolation="in_process",        # typed read against our own API
        egress=["claims-api.internal"],
        secrets=["claims_api_read"],   # scoped token, 5 min TTL
        writable=None,
    ),
    "render_report": dict(
        isolation="container",         # our code, model-chosen arguments
        egress=[],                     # no network at all: it renders locally
        secrets=[],
        writable="/work",              # fresh per run, destroyed after
        limits=dict(cpu=1, memory_mb=512, seconds=30),
    ),
    "run_analysis": dict(
        isolation="microvm",           # model-written Python. Own kernel.
        egress=["pypi.org"],           # deliberate, logged, reviewed quarterly
        secrets=[],
        writable="/work",
        limits=dict(cpu=2, memory_mb=2048, seconds=120),
    ),
    "eval_expression": dict(
        isolation="wasm",              # thousands of tiny pure computations
        egress=[],
        secrets=[],
        writable=None,
        limits=dict(fuel=5_000_000),   # instruction budget, not a timer
    ),
}

def invoke(tool_name, args, run_id, tenant):
    policy = TOOL_POLICY[tool_name]            # no policy, no execution
    token = secrets_broker.mint(policy["secrets"], tenant=tenant, ttl=300)
    with sandbox(policy, run_id=run_id) as box:  # sets up egress, fs, limits
        return box.call(tool_name, args, token)

Three ways I have seen a "sandbox" turn out not to be one. The docker socket. A container with /var/run/docker.sock mounted so the agent can "manage its own containers" is a container that can start a privileged one. That is not an isolation boundary, it is a decorative one. The shared workspace. Multiple runs, or multiple tenants, mounted into the same directory because it was convenient for caching — now one run can read another tenant's intermediate files, and the isolation you paid for at the runtime level is defeated by a volume mount. Ambient cloud credentials. A sandboxed container on a VM whose instance metadata service is reachable inherits the VM's role, so the boundary you built around the filesystem has a hole in it the shape of your entire cloud account. Block the metadata endpoint from tool sandboxes, explicitly, and test that it's blocked. In all three cases the runtime was fine and the configuration around it was the vulnerability — which is the general shape of this problem.

How much isolation is enough?

The honest answer is that it depends on one question — who wrote the code that runs? — and the levels map onto it cleanly:

  • Your code, model-chosen arguments, no interpreter: in-process is fine. Spend your effort on authorization and on validating arguments, because that's the actual attack surface.
  • Your code, model-chosen arguments, side effects: container, with a writable scope, resource caps and an egress allowlist. This covers the large majority of enterprise agent tools.
  • Model-written code, or third-party code: microVM or gVisor. Don't negotiate this one down to a container because the container was easier to stand up.
  • Many small untrusted computations: WASM, for the startup cost and the capability model.

And two things regardless of level: egress denied by default, and credentials scoped and short-lived. Those two are cheaper than any runtime decision on this page and they contain more damage than any of them.

What to carry away

Sandbox because a probabilistic system with real permissions will eventually do something inside those permissions that you would not have approved — not because the model is hostile. Separate the three capabilities you're containing: code execution, filesystem and secrets, network egress. Then choose isolation per tool: in-process for typed reads, containers for your own code with side effects, microVMs or gVisor the moment generated code runs, WASM for many small pure computations.

Invest first in the two controls that aren't runtimes. Deny egress by default and allowlist by name, because that is what makes a read a log line instead of a breach, and because a blocked outbound attempt is the highest-signal alert an agent will ever generate. Mint scoped, short-lived credentials per tool instead of letting tools inherit the process's environment. Add a fresh writable directory per run, read-only everything else, and hard resource and step caps — agents fail by looping.

Finally, write the policy down as data, one entry per tool, so that "what can this tool reach?" is a file a reviewer can read rather than an archaeology exercise across a codebase. The isolation you can explain in a review is the isolation you actually have.