---
title: "Firecracker on WSL2, without root"
description: "How I run each coding agent in its own Firecracker microVM nested in WSL2, with no root at run time: the stack, what it costs, and ten stumbling blocks."
url: "https://mko.run/firecracker-wsl2-rootless/"
language: "en"
published: 2026-11-10
---
# Firecracker on WSL2, without root

My coding agents run code I did not write: dependencies, test suites, scripts in repositories I cloned an hour ago. The
usual sandbox shares the host kernel. That holds as long as the kernel holds.

I wanted each agent to get a kernel of its own: a small VM per run, gone when the run ends. On the machine I actually
work on, a Windows laptop whose Linux is WSL2. And without root while it runs.

Firecracker, nested inside WSL2, does it. A minimal VM is ready in under 0.2 seconds; a full agent VM in about one. In
the first two weeks, my agents ran nearly a thousand times, each run in a VM of its own. Getting there took ten
surprises.

Two caveats up front: Firecracker does not support WSL2, and so far I have attacked the boundary only from a guest
without root. Nothing here is specific to WSL2 but the nesting and the missing seat; on Debian or Ubuntu it should run
the same, though I have not tested it there, and Ubuntu 24.04 and later restrict the unprivileged user namespaces it
needs.

## The stack

<m data-svg=inline>![The layers around each agent's VM, from Windows down to the guest, and its one way out](https://mko.run/firecracker-wsl2-rootless/stack.svg)\
***Figure 1.** Each layer is a separate boundary. The guest's only way out is vsock, to a broker outside the scope.*</m>

WSL2 exposes `/dev/kvm`, so every Firecracker VM is nested. A systemd user scope in a slice of its own caps memory,
swap, tasks and CPU. A bubblewrap jail gives Firecracker every namespace, no capability, a cleared environment and a
read-only root with little more than its binary, the kernel and the image. It runs as a uid from my `/etc/subuid`
range, as rootless Podman does, with `kvm` as its only group: it can open `/dev/kvm`, and none of my files. The guest has
no network interface. Its one cable is vsock, and on the host end a broker dials the one host each port stands for.

Root is needed for two commands, once, at setup:

```sh
sudo usermod -aG kvm "$USER"    # then log in again
echo "$USER:$(getent group kvm | cut -d: -f3):1" | sudo tee -a /etc/subgid
```

## How fast

The smallest guest I could write: a 16 MiB image holding only an `/init` that says `ready` over vsock. One vCPU,
128 MiB, twenty boots per configuration:

<m data-svg=inline>![Time to ready for twenty boots each: Firecracker's CI kernel near 670 ms with boot args that drop Firecracker's i8042 parameters, near 180 ms with them, jailed or not; my own kernel near 170 ms](https://mko.run/firecracker-wsl2-rootless/boots.svg)\
***Figure 2.** Measured on 10 November 2026, one boot at a time, each in its capped scope.*</m>

The jail costs nothing measurable, and Firecracker v1.18.0 boots as fast as v1.17.0. The half second in the first row
is a keyboard. Firecracker's CI kernels have the AT keyboard driver, and Firecracker's default kernel command line keeps
it from probing for a keyboard that is not there. Any `boot_args` replace that line whole: mine did, and so does the
example in Firecracker's getting-started guide, which then waits about half a second on every boot. **Keep
`i8042.noaux i8042.nomux i8042.dumbkbd`** in your own. My kernel, built from the configuration in Firecracker's
repository, has no keyboard driver at all, so it never waited.

## Ten stumbling blocks

### Access

**1. An ACL on `/dev/kvm` vanishes.** Firecracker's getting-started guide suggests `setfacl -m u:${USER}:rw /dev/kvm`.
My first VM booted, the next nineteen got "Permission denied". Debian's udev rules hand `kvm` to whoever sits at the
seat, and each new VM makes udev reapply that rule. WSL has no seat, so my entry was simply erased. **Use the `kvm`
group.** ([My note on the cause](https://github.com/superradcompany/microsandbox/issues/920#issuecomment-6448133464).)

**2. `sg kvm` bites.** It makes `kvm` the primary group, which hides the process's `/proc` entries from processes of
other groups and breaks `newuidmap`. **Log in again instead**; then `kvm` is a supplementary group.

### Limits

**3. A memory cap that was not there.** `systemd-run --user -p MemoryMax=64M` in the default `app.slice` once let a
process take 200 MiB. No error: the scope simply had no `memory.max`. After a fresh WSL start it worked, and I have not
been able to reproduce it. **Read the limit back** before starting anything, and stop if it is not what you asked for.
The process in the scope can do it itself:

```sh
systemd-run --user --scope --quiet --slice=agents.slice \
  -p MemoryMax=256M -p MemorySwapMax=0 -- \
  sh -c 'cat "/sys/fs/cgroup$(cut -d: -f3 /proc/self/cgroup)/memory.max"'
```

It prints `268435456` when the cap is in force, and `max` or an error when it is not.

**4. CPU is unbounded.** The emulated serial port costs a VM exit per byte; a chatty guest can keep two cores busy.
**Set `CPUQuota=`** on the scope. (A pull request to batch serial I/O, firecracker#6327, is open.)

**5. An OOM kill takes the whole scope.** When the guest blew its cap, systemd stopped everything in the scope, my
watchdog included. **Only the scope's `Result=oom-kill`** tells what happened.

### The jail

**6. bwrap creates mount points as 0700.** Under a subordinate uid, Firecracker could not enter the directory holding
its own binary. **Make it 0755** explicitly.

**7. No rule needed against nested namespaces.** I was about to write one, then read Firecracker's seccomp filters: none
of its threads may call `unshare`, `clone`, `clone3` or `setns`. **Firecracker already forbids it.**

### The cable

**8. A message can die with the guest.** My first `/init` wrote `ready` and restarted the VM; the host never got the
line. **Wait for an answer** before treating a write as delivered.

**9. Half-close does not cross vsock.** A guest's `shutdown(SHUT_WR)` never reaches the host, and the host's ends the
whole connection for the guest. Protocols that rely on half-close wait for a timeout. **End the whole connection** when
either side ends. (A fix is proposed in firecracker#6349; the docs say nothing about it.)

### Trust

**10. Checksums, no signatures.** Releases ship SHA-256 sums, not signatures. A maintainer confirmed that the team signs
them internally but leaves the signature out (firecracker#6117, open since August). **Pin the hashes**, so that every
update is a deliberate change.

## What it became

The spike took a day. Since then it has grown into d0, short for DeckZero, the tool I run my coding agents with every
day. It is not public yet; these parts of it came from the ten blocks:

- A helper inside each scope reads back the memory, swap, task and CPU limits and refuses to start Firecracker if one
  is missing (blocks 3 and 4).
- The kernel is built from source; the build stops if an option on my list is unknown or not in force.
- One vsock port per allowed host; the broker never dials a private, loopback or link-local address, and connections
  end whole (block 9).
- Every live VM gets its own subordinate uid, and the host never reads the agent's disk: work comes back as a git bundle.

## Attacking it

Before writing this, I turned the guest against each boundary: code with the agent's rights, no root, in a real VM. It
tried the internet, the metadata address and my WSL's own IP; all 65,535 vsock ports of the host; the broker with a
foreign TLS name, no name, a plain `CONNECT`, a silent connection and a hundred at once; a memory bomb, a fork bomb, a
full disk, busy loops, a command that ignores every signal, a process left behind, and terminal escapes in its output.
Each boundary held; one only after a fix.

My tool kept a vsock port open from the start for the agent's work to come back on, and took the first connection to it.
A process in the guest could connect early and hand the host a bundle of its own, past the point where everything else
in the VM is killed. Now the work comes back on the connection the guest's init opened at boot, which nothing else in
the guest can reach, and the port is gone.

Not attacked: a guest with root, as after a kernel exploit. One machine, nested KVM, this WSL version. And not
Firecracker's own way: its supported confinement, the jailer, needs root.

## Others on the same road

The parts are not new. [Kastellan](https://github.com/hherb/kastellan) jails Firecracker with a user scope and
bubblewrap, [izba](https://github.com/Lupus/izba) runs Cloud Hypervisor VMs without a network interface, with vsock
egress, and names WSL2, and [Tracekit Box](https://github.com/Cygnux-Labs/Tracekit-box) gives each agent job a
Firecracker VM with brokers over vsock. The combination, on WSL2 under a subordinate uid, I did not find.

## Try it

The reduced version, with nothing of my tool in it:
[firecracker-wsl2-rootless](https://github.com/mkorun/firecracker-wsl2-rootless). It fetches Firecracker and a CI kernel
against pinned hashes, boots twenty jailed VMs, and checks the running jail from the host: seven namespaces, no
capability, the subordinate uid, seccomp on every thread, the read-only root, loopback only, and that the jail cannot
read my files. Run from a fresh clone exactly as its README says: twenty of twenty, each ready in under a quarter of a second.

::: nocount
The reference code is MIT-licensed. The measurements are from 10 November 2026, on one laptop: a Lenovo Legion S7i (2022)
with an Intel Core i7-12700H and 24 GB, Windows 11 (build 26200.9711), WSL 2.7.11 with kernel 6.18.33.2, Debian 13.
This text was drafted with Claude from my notes and measurements.
:::
