all posts

How AI Agent Sandboxes Work

Ajay Kumar··10 min read

An AI agent is a loop: a model looks at a goal, decides on an action, something executes that action, and the result goes back into the model so it can decide the next one. For a lot of agents, the interesting actions are "run this Python," "run this shell command," or "call this tool that runs code." Which raises the uncomfortable question at the center of every agent that does real work: where does that code actually run? If the answer is "in my process" or "on my host," you've handed a language model a shell on your infrastructure. An agent sandbox is the answer that makes the loop safe: the model's action runs inside a disposable, isolated machine that isn't yours, and only the result comes back.

I'm Ajay — I build PandaStack, an open-source Firecracker microVM platform, so "where does the untrusted code run" is more or less my entire job. This post is the mechanism explainer: the full request-to-result loop, then a level deeper into the isolation mechanics that make the loop safe. If you want the conceptual framing first — what an agent sandbox even is and why you'd want one — start with /blog/what-is-an-ai-agent-sandbox. This piece is about how it actually works under the hood.

The one-sentence version: an agent sandbox turns "the model wants to run this code" into "the code ran inside a throwaway VM with its own kernel, and here's the stdout," so a bad action's blast radius is one machine you were going to destroy anyway.

The loop, end to end

Start with the whole picture before we zoom in. Here's what happens, in order, from the moment the model decides to do something to the moment that something's result is back in the conversation:

  1. The agent (the LLM) decides on an action. Reasoning over the goal and the conversation so far, the model emits a concrete step: run this Python, execute this shell command, invoke this code-running tool. At this instant the action is just text the model produced — nothing has executed yet.
  2. Your app intercepts the action instead of running it locally. Your orchestration code takes that proposed command and, crucially, does not `exec()` it in your own process or shell it out on your host. It sends it to the sandbox API as a request.
  3. The sandbox platform provisions an isolated environment. On the other side, the platform hands you a machine with its own kernel, its own filesystem, and its own network namespace — here, a Firecracker microVM created via snapshot-restore in well under a second.
  4. The command runs inside the guest. The platform's guest agent executes your command inside the VM. Every syscall that command makes hits the GUEST kernel, not your host's — the untrusted code is talking to a kernel that isn't yours.
  5. The result comes back. stdout, stderr, the exit code, and any files the command wrote are collected inside the guest and returned to your app over the API as structured data.
  6. The result feeds back into the agent loop. Your app formats that result as a tool-call response and hands it to the model, which now reasons over what actually happened — the real output, the real error — and decides the next action.
  7. The VM is destroyed (or hibernated). When the task is done, the sandbox is torn down. Its memory, filesystem, and any state the code created vanish with it. Nothing persists into the next task or the next user.

That's the entire mechanism. Everything else in this post is a zoom-in on two of those steps: step 3 (what "an isolated environment" actually means at the hardware level) and step 4 (how a command gets inside the guest and how its result gets back out). Those two are where the safety lives.

Why not just run the action locally?

It's worth being blunt about what you're avoiding, because the whole architecture exists to avoid exactly one thing. Consider the two ways to execute a model-proposed command, side by side:

  • Run it locally (`exec` on your host) — the command runs as your process, on your kernel, with your filesystem, your environment variables, your network, and your credentials all in reach. If the model hallucinates `rm -rf`, pip-installs a typosquatted package, or the code was written by a prompt-injected instruction, the blast radius is your machine and everything it can touch. There is no boundary; there is only trust, and you're extending it to a probabilistic text generator.
  • Run it in a sandbox — the command runs inside a disposable microVM with its own kernel and no access to your host, your secrets, or your network unless you explicitly granted it. If the model does something catastrophic, the catastrophe is contained to a machine whose entire purpose was to be thrown away. You destroy the VM and move on. The blast radius is one throwaway box, not your infrastructure.
The action was written by a language model. Sometimes it was written by a language model that read a malicious web page mid-task. `exec()` in your own process trusts that text not to be hostile. A sandbox doesn't have to trust it — it just contains it.

This is the case for sandboxing agent tool calls in general, and it's covered in more depth in /blog/sandboxing-llm-tool-calls. For the rest of this post I'll take the "you want a sandbox" conclusion as given and focus on how one actually works.

Step 3: provisioning an isolated environment

When your app sends the action to the sandbox API, the platform has to hand back a running, isolated machine — fast, because it's sitting on the critical path of the agent loop and the user is waiting. On PandaStack that machine is a Firecracker microVM, and the create isn't a boot; it's a snapshot-restore of an already-booted machine. Here's the pipeline the agent runs for each create, roughly in order:

  1. Allocate a NATID network slot — grab a pre-built Linux network namespace (netns + veth pair + tap + iptables) from a warm pool, so the VM gets isolated networking without paying to build it cold.
  2. Configure the tap device in the namespace — patch the tap's MAC and routes to match the network identity the guest snapshot was baked with, so the restored guest wakes up on the network it remembers.
  3. Reflink the rootfs — make a copy-on-write clone of the template's disk (an O(metadata) reflink), so the guest gets a private, writable filesystem that shares blocks with the template until it writes.
  4. Fork+exec Firecracker under the jailer — start the VMM process in a dropped-privilege jail, ready to be handed a snapshot.
  5. Load the snapshot — memory-map the guest's frozen RAM copy-on-write and load its device state, so the machine comes back exactly as it was frozen.
  6. Resume — unpause the vCPUs. The guest is now running, mid-instruction, where the snapshot froze it. No boot, no init.
  7. Probe TCP :22 — confirm the guest's network stack is live and accepting connections before declaring the sandbox ready, so a create only returns once the box is actually usable.

The result lands at a p50 of about 179ms and a p99 around 203ms, with the snapshot restore itself around 49ms. The only time you pay a real cold boot — kernel init plus userspace coming up, roughly 3s — is the very first spawn of a template on an agent, after which the agent bakes a snapshot and every create afterward is the fast restore path. The deep mechanics of why restore is so much faster than boot are in /blog/snapshot-restore-boot-path; for the loop, the point is that per-action VMs are cheap enough to be the default, not a luxury.

Snapshot-restore is the trick that makes per-action sandboxes viable. Without it you'd cold-boot a VM (~3s) on every tool call and the agent loop would feel like wading through mud. With it, spinning up a fresh, isolated machine costs about as much as a warm container start — so you can afford a clean machine per task, or even per action.

One more thing worth stating plainly, because it's the property the whole loop rests on: each agent pre-allocates 16,384 /30 subnets, so every microVM gets its own network namespace and the practical ceiling on concurrent sandboxes is host memory and CPU, not networking. You're not sharing a network stack across tenants; each VM's egress is its own to control.

One level deeper: where the boundary actually is

"Isolated environment" is doing a lot of work in step 3, so let's make it concrete. The reason a microVM is a real boundary and not a hopeful one comes down to a few mechanics stacked on top of each other.

The KVM hardware boundary

Firecracker runs the guest inside a hardware-virtualized VM using KVM. This is the same CPU-level virtualization extension that isolates VMs in every serious cloud. The guest runs in a mode where the hardware itself enforces that the VM can't read or write the host's memory or the host's kernel. This is a fundamentally stronger boundary than a container, where isolation is a set of Linux kernel features (namespaces, cgroups, seccomp) all enforced by the one shared kernel the untrusted code is making syscalls against. With KVM, the boundary is enforced by the silicon, not by the same kernel you're trying to protect.

A whole guest kernel, per VM

This is the part that trips people up who are used to containers. Each microVM boots its own guest Linux kernel. When the untrusted code inside the sandbox makes a syscall — `open`, `socket`, `mmap`, whatever — that syscall is serviced by the guest's kernel, which belongs to that one VM. Your host kernel never sees it. So the entire Linux syscall surface, hundreds of calls plus the drivers and filesystems behind them, is not part of the attack surface between the guest and your host. A kernel privilege-escalation bug that the untrusted code triggers gets it root inside its own throwaway VM — which is a machine you were going to delete anyway.

The virtio device model is the only host-facing surface

So if the guest can't touch host memory and makes its syscalls against its own kernel, how does it do anything useful — disk, network? Through a tiny, deliberately minimal set of emulated virtio devices — block, net, vsock, a serial console — that the VMM presents to the guest. That handful of emulated devices, plus the KVM ioctl interface, is the entire surface the guest can reach toward the host. Compare that to a container, where the reachable surface is the full syscall ABI. To escape a microVM, an attacker has to find a bug in that small, heavily audited device-emulation code or in KVM itself — a far smaller target than "any bug in the Linux kernel reachable through a syscall." This is precisely why AWS Lambda runs untrusted, multi-tenant code on Firecracker microVMs.

Network egress is yours to control

Because each VM sits in its own network namespace with its own tap device and iptables rules, its network egress isn't ambient — it's configured. You decide whether a sandbox can reach the internet at all, only specific hosts, or nothing. For an agent running model-written code this matters a lot: it's the difference between "the code can exfiltrate whatever it can read to anywhere" and "the code has no route off the box except the results channel you control." The per-sandbox network namespace is what makes that enforceable rather than aspirational; the mechanics are in /blog/firecracker-networking-explained.

Step 4 and 5: getting the command in and the result out

There's a chicken-and-egg problem lurking in the loop. The VM is deliberately isolated — its own kernel, its own network namespace, no ambient access to or from the host. So how does your command get inside it, and how does the output get back out? The answer is a guest agent: a small process running inside the VM that the platform talks to over a controlled channel.

On PandaStack that guest agent is `pandastack-init`, and it bridges exec over vsock (with an SSH fallback). vsock — virtio socket — is a host-guest communication channel built into the virtio device model itself. It's not a network connection in the usual sense; it's a point-to-point socket between the host and that specific guest that doesn't traverse the guest's network namespace at all. That's exactly the property you want for a control channel: the host can send a command to the guest agent and read its output even when the guest has zero network egress to the outside world. Egress control and command delivery are independent surfaces. The vsock mechanics are covered in /blog/vsock-explained.

So step 4 is: your app calls the sandbox API, the platform sends the command over vsock to `pandastack-init` inside the guest, and the guest agent runs it — spawning a real process inside the VM whose syscalls hit the guest kernel. Step 5 is the reverse: the guest agent captures that process's stdout, stderr, and exit code, plus any files it wrote if you ask for them, and streams them back over the same vsock channel to the platform, which returns them to your app as structured data. The command went in through a controlled door and the result came back through it; the VM's isolation was never breached to make either happen.

The loop, in code

Here's the whole thing as an agent-loop sketch. The model proposes a shell command, your app runs it in a sandbox instead of on your host, and the result — stdout, exit code — gets fed straight back into the model for the next turn. This is the mechanism from the top of the post, made concrete:

from pandastack import Sandbox

# One disposable microVM for this agent's whole task. Created via
# snapshot-restore (~49ms restore; ~179ms p50 / ~203ms p99 end-to-end).
# Every command the model proposes runs in HERE, never on our host.
with Sandbox.create(template="agent", ttl_seconds=600) as sbx:

    messages = [{"role": "user", "content": goal}]

    for _ in range(MAX_STEPS):
        # 1. The LLM decides on an action given everything so far.
        action = model.next_action(messages)

        if action.type == "final":
            print(action.answer)
            break

        # 2 + 3 + 4. Instead of exec()-ing the model's command on OUR host,
        # we ship it to the sandbox. It runs inside the guest; its syscalls
        # hit the GUEST kernel. A hostile command's blast radius is this VM.
        result = sbx.exec(action.command, timeout_seconds=30)

        # 5 + 6. Feed the REAL stdout/stderr/exit back into the loop so the
        # model reasons over what actually happened, not what it imagined.
        messages.append({
            "role": "tool",
            "content": f"exit={result.exit_code}\n{result.stdout}\n{result.stderr}",
        })

# 7. Leaving the `with` block destroys the microVM. Its memory, filesystem,
#    and anything the model's code created are gone. The next task starts
#    from a clean machine — nothing leaked forward.

The load-bearing lines are the two comments: `sbx.exec(...)` instead of a local `exec()`, and the `with` block that guarantees teardown. Everything the model does happens inside that VM, and when the block exits the VM and all its state are gone. There is no worker to reset, no `/tmp` to sweep, no residual process — the machine that ran the untrusted code is destroyed, which is the cleanest possible way to guarantee nothing leaks into the next run.

Step 7: destroy, or hibernate for later

The default is total destruction, and that's the right default for isolation: teardown is the feature, not an afterthought. But not every agent wants a cold start on its next turn. If the same agent will be back — an interactive coding session, a long-running task with pauses — you can hibernate the VM instead of destroying it: snapshot its running state, pause it so it costs nothing while idle, and wake it back to exactly where it was when the next request arrives. The isolation guarantee is unchanged (it's still that agent's own VM), you just trade a clean slate for warmth. For one-shot tool calls, destroy; for a returning session, hibernate. Both are cheap because both are built on the same snapshot-restore primitive.

And if a task needs to branch — try three different fixes from the same starting state — that same primitive gives you fork: a copy-on-write clone of a running VM in roughly 400–750ms on the same host (1.2–3.5s cross-host), sharing memory and disk until each branch diverges. It's the same mechanism pointed at a live machine instead of a baked template, and it's what lets an agent explore several paths in parallel without paying a full create for each.

Putting it together

So that's how an AI agent sandbox works, end to end. The model decides on an action; your app ships that action to a sandbox API instead of running it on your host; the platform restores a Firecracker microVM in well under a second, with its own kernel, filesystem, and network namespace; a guest agent runs the command over vsock so its syscalls hit the guest kernel and never yours; the stdout, exit code, and file changes come back through that same controlled channel; the result feeds into the model for the next turn; and the VM is destroyed or hibernated so nothing persists. The safety comes from one architectural decision repeated at every layer — the untrusted code runs on a machine that isn't yours and that you're about to throw away — and snapshot-restore is what makes doing that on every action fast enough to be practical rather than a tax. If you want to see the substrate up close, the core of PandaStack is open source under Apache-2.0, so you can run the control plane and per-host agent yourself and watch the loop happen. For the conceptual grounding, start with /blog/what-is-an-ai-agent-sandbox; for the isolation mechanics, /blog/sandboxing-llm-tool-calls.

Frequently asked questions

What actually happens when an AI agent runs code in a sandbox?

Seven steps. The LLM decides on an action (run this code / command / tool). Your app intercepts it and sends it to a sandbox API instead of running it on your host. The platform provisions an isolated environment — on PandaStack, a Firecracker microVM restored from a snapshot in well under a second, with its own kernel, filesystem, and network namespace. A guest agent runs the command inside the VM, so its syscalls hit the guest kernel, not yours. The stdout, stderr, exit code, and any file changes are collected and returned to your app. That result feeds back into the model for its next decision. Finally the VM is destroyed (or hibernated), so nothing persists into the next task.

Why not just run the agent's command directly on my server with exec()?

Because the command was written by a language model — sometimes one that read a malicious web page mid-task — and running it with exec() in your own process gives that probabilistic text generator your kernel, filesystem, environment variables, network, and credentials. If it hallucinates a destructive command, installs a malicious package, or was steered by a prompt injection, the blast radius is your infrastructure. A sandbox runs the same command inside a disposable microVM with no access to your host unless you granted it, so a bad action is contained to a throwaway machine you destroy afterward. The sandbox doesn't have to trust the code; it just contains it.

How is a microVM sandbox stronger than a container for running agent code?

A container shares the host's one Linux kernel — its isolation (namespaces, cgroups, seccomp) is enforced by the same kernel the untrusted code makes syscalls against, so the entire syscall surface is the attack surface, and a kernel privilege-escalation bug can escape to the host. A Firecracker microVM boots its own guest kernel inside a KVM hardware-virtualized boundary: the untrusted code's syscalls hit the guest kernel, the hardware itself prevents the guest from touching host memory, and the only host-facing surface is a tiny virtio device model (block, net, vsock) plus the KVM interface. To escape, an attacker must find a bug in that small, heavily audited surface rather than anywhere in the full Linux syscall ABI. It's the same model AWS Lambda uses for untrusted multi-tenant code.

How does a command get into an isolated VM and the result get back out?

Through a guest agent and a control channel. A small process runs inside the VM — on PandaStack it's pandastack-init — and the platform talks to it over vsock (a virtio host-guest socket built into the device model), with SSH as a fallback. vsock doesn't traverse the guest's network namespace, so the host can send a command to the guest agent and read its output even when the VM has zero network egress to the outside world. The guest agent runs your command as a real process inside the VM, captures its stdout, stderr, and exit code, and streams them back over the same channel. Command delivery and network egress are independent surfaces, which is why you can lock a sandbox's egress to nothing and still run commands in it.

Doesn't spinning up a fresh VM for every action make the agent slow?

Not with snapshot-restore, which is the trick that makes per-action sandboxes practical. Instead of cold-booting a kernel (~3s) on every tool call, the platform restores a snapshot of an already-booted machine: the restore itself is around 49ms and an end-to-end create is about 179ms p50 (~203ms p99), roughly a warm-container start. The ~3s cold boot happens only on the very first spawn of a template, after which every create is the fast restore path. If the agent will return, you can hibernate the VM (snapshot and pause) and wake it later instead of destroying it, and if a task needs to branch you can fork a running VM copy-on-write in about 400–750ms same-host. All three are built on the same snapshot primitive.

Run code in a microVM in one API call.

49ms p50 cold start. Fork, snapshot, and scale to zero.

Start free
Written by Ajay Kumar, Founder, PandaStack.