# Sandboxing AI agents: Building a microVM

> Your AI agent is about to run rm -rf and curl | sh. Let's build a microVM for it to run in, directly in your browser.

_By Nikhil Unni · Published 2026-07-09 · Tags: engrams, software-factories, ai-agents, sandboxing, firecracker, microvms_
Source: https://builders.cortex.io/blog/sandboxing-agents-part-1/

---

## Why an AI agent needs a sandbox

An AI coding agent is, by design, a thing that runs commands you didn't write.

The security implications are horrifying! Exploits can be as "mild" as `rm -rf /` up to
hijacking your employee credentials. Despite strides in recent frontier models where
complete slipups are rarer, the agent is still a black box -- and impressionable to prompt injection
from the outside world.

Typically, coding agents deal with this in two ways:

1. Explicitly ask the user if the command or class of command is safe to run, *every time it wants to run something*
2. Have the user upfront allowlist commands or classes of commands.

Both approaches are non-starters for a true [software factory](../software-factories-are-operating-models/), where
we want to scale out to thousands of agents, with as little human intervention as possible.

There is a combinatorial explosion in the space of all possible commands (up to length `n`), and a similarly
massive space of all "good" and bad" commands (if that's even well defined) such that we cannot *possibly*
allow / deny a flat set of commands up front.

Newer approaches, like Claude Code's "auto mode" purport to solve this problem with a secondary classifier that decides
if a command is safe to run, before actually running it.

While this is a step in the right direction in terms of *user experience*, it is
still a black box. From a security standpoint this is no better posture than `--dangerously-skip-permissions`,
as it can still non-deterministically run "bad" commands.

The solution at scale is to give each agent its own sandbox: a real throwaway Linux box it can wreck, with
strict egress policies to prevent exfiltration. Treat each agent as potentially adversarial and eliminate
the blast radius completely by completely limiting what I/O it is permitted to do:

- If it wants to run `rm -rf /`, by all means go ahead
- If there's a supply chain attack in NPM, that's fine -- it literally cannot pull new packages

## Firecracker and MicroVMs

The agent runs untrusted commands, so the sandbox has one job: *contain the blast radius.* Your
options:

- A container shares the host kernel. A kernel bug or a `--privileged` slip and the agent
  is loose on the host — and on every other agent's files. Fine for code you trust; wrong
  threat model for code an LLM just wrote.
- A cloud VM isolates hard, but boots in tens of seconds and bills you for every idle
  minute. You can't spin one up per agent-task and stay solvent.
- **A microVM** is the middle path: a stripped-down VM with a minimal device model that boots
  in ~125 ms, isolated by the same hardware virtualization a full VM uses. It's what
  Firecracker — the engine under AWS Lambda — was built for.

Firecracker is a virtual machine monitor (VMM) that is extremely lightweight, and what many
platforms, including our own software factory, `engrams`, use to cheaply spin up microVM sandboxes
at scale.

It uses Linux KVM, an open source kernel module that exposes the hardware virtualization features of
modern CPUs to user space.

Where Firecracker shines over hypervisors like QEMU is that it has a minimal device model, and does not emulate a full PC.
It only exposes the devices that are necessary to run a Linux guest, such as a virtual CPU, memory, and a few essential devices like a serial console and network interface.

This allows Firecracker to run microVMs with minimal overhead and *very* fast boot times,
making it ideal for quickly spinning up and tearing down microVMs.

## Let's build a microVM

A major motivation for building `engrams` for us is understanding every moving part of software factories
deeply.

While `engrams` uses Firecracker directly, as it's production hardened and secure, I wanted to understand how it
actually worked under the hood, so that it feels less like magic, and wanted to share those learnings with you all.

This presented a bit of a pedagogical challenge for me, as viewers at home may not have machines with KVM enabled
machines to follow along with the code snippets.

So embedded in this article is a live playground that runs a Linux kernel on a software CPU, with toy KVM implementation
to illustrate the concepts. Each snippet is editable + runnable, so roll up your sleeves and let's get virtualizing!

Everything in this post runs live, right here. It's a real Linux kernel on a software CPU
(the v86 emulator) — you can boot it, type in
it, and compile C in it. It's genuinely Linux; it just isn't hardware-isolated the way the
real thing is.

By the end, the whole sandbox reduces to four moving parts you can hold in your head:

1. **Boot** a machine.
2. Give it a **wire** to talk to.
3. Give it a **world** to work in.
4. **Drive** a process inside it.

## Lesson 1: boot a sealed box

The unit of isolation is a microVM, and VMM like Firecracker is
what boots one. Underneath, a VMM is barely anything:

1. Open `/dev/kvm`
2. Create a VM
3. Give it some guest RAM
4. Create a virtual CPU
5. `KVM_RUN`

Our first guest is two instructions — it computes 2 + 2 and stops:

```asm
add al, bl   ; al = 2 + 2
hlt          ; freeze the CPU
```

The host loads those bytes, calls `KVM_RUN`, and waits.

**Before you run it, make a prediction: where does the `4` come out?**

…nothing useful. That's the point. A freshly-booted microVM is a sealed box: no screen,
no keyboard, no network. The guest computed `4` — but the only way to find out is by
freezing it after it halts and reading a register.

`KVM_RUN` runs the guest until it traps back to you; with no I/O, the only trap you ever get is
"it halted." To run an agent in here, you need a live wire to the outside.

## Lesson 2: give it a wire

So let the guest talk *while it runs*. The guest changes by
one idea: after computing the answer, it writes a byte to an I/O port.

```diff
  add al, bl        ; al = 2 + 2
+ add al, '0'       ; to ASCII — '4'
+ out dx, al        ; write it to port 0x3f8
  hlt
```

…and the host changes by one idea to match — instead of reading a register after the halt, it
loops, and catches the trap the `out` produces:

```diff
- ioctl(KVM_RUN);   // run once, then read a register after it halts
+ for (;;) {
+   ioctl(KVM_RUN);                  // runs until the guest traps back
+   if (exit_reason == KVM_EXIT_IO)  // it touched the port — take the byte
+     fwrite(io_data, 1, io_size, stdout);
+   if (exit_reason == KVM_EXIT_HLT) break;
+ }
```

Before you run it, predict two things: will the host see the byte *before* the guest halts,
and who decides what that byte *means*?

There's your `4`, live, the instant the guest produced it — a byte pushed through a hole in the
box.

And nothing more escaped! The guest never touched the host's terminal. It wrote to a
*virtual* port, the CPU trapped out to the host, and the host *chose* to print it. That
trap-and-emulate loop is the seed of every device a VMM has.

Here we are executing machine instructions directly to illustrate the high level concepts.
Real microVMs boot a Linux kernel, which runs a full userspace. The *kernel* traps to the VMM.

A full Linux guest turns that same port into a serial console — the terminal you booted up
top. But a console is a raw byte stream: fine for logs, awkward for a control plane that has to
send a task and read a structured answer back. To drive the machine at scale,
you want a real socket: [vsock](https://man7.org/linux/man-pages/man7/vsock.7.html).

vsock is a host↔guest socket with no networking stack in between — addressed by a context ID
and a port. It's like TCP but point-to-point between the VMM and its guest.

The socket affords the guest and host better ergonomics than a raw port: the guest can `accept` a connection, `read` a command, and `write` the output back.

In `engrams` the guest microVMs all run a small server called `agentd`, at PID 1, that communicates via `vsock` with its Firecracker hosts
over a wire protocol -- length-prefixed bincode RPCs over the configured transport, with verbs like: `Exec`, `Stat`, `StartShell`, `Ping`, `Shutdown`, and `SpawnHarness`.

In our toy example the guest server looks something like:

```c
int s = socket(AF_VSOCK, SOCK_STREAM, 0);        // not TCP — a host↔guest pipe
bind(s, { .svm_cid = VMADDR_CID_ANY, .svm_port = 9999 });
listen(s, 1);
int c = accept(s, 0, 0);                          // the orchestrator connects here
read(c, cmd, ...);                                // "run this"
/* ...run it... */
write(c, output, ...);                            // stream the result back
```

With both client and server, we have a toy control plane: a request in, a command run, output streamed back, all
over a real `AF_VSOCK` socket. Swap the `uname` for the agent, put the client out on the host,
and it's the spine of a sandboxing service.

v86 has no host-side vsock device, so this demo runs both ends inside the guest, over the
kernel's vsock *loopback* transport. The server is byte-for-byte the production agentd — the
only difference is that in production the client is your orchestrator, out on the host.

## Lesson 3: give the agent its world

A wire is no good if there's nothing on the other end. The rootfs is the agent's machine —
its userland, its tools, its environment. Whatever you put in it is what the agent can reach:
the language runtimes, `git`, the repo, the agent CLI itself.

In `engrams`, the user defines their rootfs via Docker images, and the orchestrator flattens that into a block device the microVM can boot:

```sh
# flatten the image's layers into a directory, then bake an ext4 straight from it —
# no mount, no root, just filesystem bytes written into a file:
skopeo copy docker://your/agent-image oci:img && oci-unpack img rootfs/
mke2fs -d ./rootfs -t ext4 rootfs.ext4 64M
```

Attach that as the boot disk and the VM comes up inside the agent's world. This program asks
the sandbox what world it woke up in — its kernel, its distro, the tools you baked in:

In production that toolbox is `node`, `python`, `git`, the agent binary. Here it's a shell and
a C compiler — which is how you've been compiling everything on this page.

## Lesson 4: drive it

The `agentd` in Lesson 2 ran your command with `popen`.

In `engrams` what our real `agentd` actually does is operate more like a real server: `fork` + `exec` + a pipe.

Putting it all together, this is roughly what is happening at a high level within `engrams`:

1. Firecracker spawns microVMs with KVM, creating a `vsock` connection
2. `agentd` spawns at PID 1 within the guest, accepting requests
3. Requests come in over `vsock`, and `agentd` `fork`s + `exec`s the requested command, streaming its output back over the wire.

For driving harnesses like Claude Code, there is a small wrapper that translates its JSONL output into standardized
harness messages that can be transferred over the `vsock` wire. Similarly, incoming wire messages are sent as `stdin` to the Claude Code
process to send messages.

A sandbox is four moving parts: a microVM you can **boot**, a **wire** to talk to it, a
rootfs that gives the agent its **world**, and an `exec` primitive to **drive** it.

## What's next

Two things we skipped, each its own post:

- **Density.** You can't keep thousands of idle agent-VMs resident in RAM. Snapshots, restore,
  and copy-on-write memory sharing are how you pause an idle agent and resurrect it in
  milliseconds.
- **Isolation hardening.** We booted on the happy path. `jailer`, seccomp, and an egress proxy
  are what make it actually safe to run code an LLM wrote against the open internet.
