Isolation tiers and runtimes¶
Every sandbox reports the isolation boundary it actually runs behind. A Charter can require a floor, and a sandbox that falls short is refused before anything runs in it.
[host]
type = "container" # or "k8s", or "microvm" (below)
runtime_class = "io.containerd.kata-qemu.v2" # the OCI runtime (Docker/Podman --runtime, k8s RuntimeClass)
[isolation]
minimum = "vm" # process < container < kernel < vm
Tiers¶
| Tier | Boundary |
|---|---|
process |
A host process with in-process confinement only. |
container |
Namespaces and cgroups on the host kernel (runc, crun). |
kernel |
A kernel that is not the host's: gVisor's user-space kernel, or a VM runtime's guest kernel whose hardware virtualisation the evidence could not confirm. |
vm |
A hardware-virtualised guest: Kata on QEMU, Cloud Hypervisor or Firecracker, or a native Firecracker or libkrun microVM. |
Runtimes¶
runtime_class |
What it is | Name claims | At most |
|---|---|---|---|
empty, runc, crun |
Engine default, shared kernel | container | container |
runsc (gvisor) |
gVisor | kernel | kernel |
kata, kata-qemu, kata-clh, io.containerd.kata[-<h>].v2 |
Kata Containers, QEMU or Cloud Hypervisor | vm | vm |
kata-fc (Kubernetes only) |
Kata Containers with Firecracker; the workspace persists only on a direct-volume StorageClass | vm | vm |
krun |
crun built with libkrun: refused (no exec into a running guest); use the native libkrun driver | vm | refused |
With host.type = "microvm" there is no OCI runtime: host.runtime names
the monitor Rune Box boots itself, firecracker or libkrun (see
Native microVMs). Both claim vm and
are confirmed from guest evidence like every other runtime.
runtime_class is passed as docker run --runtime <name> /
podman run --runtime <name>, or set as the Pod's runtimeClassName on
Kubernetes (the RuntimeClass's handler is what gets classified). An OCI
runtime written in host.runtime is used when runtime_class is empty
(host.runtime otherwise stays the docker/podman engine hint). An
unregistered runtime fails the create; Rune Box never falls back to the
default runtime.
Evidence, not names¶
A runtime name is only a claim: a krun entry in daemon.json can point
at runc. Right after a sandbox starts, the backend runs a one-line POSIX
probe inside it (/bin/sh and cat) and compares the result with the
engine host's kernel (docker info / podman info, or the Kubernetes
node's kernelVersion):
- gVisor's signatures (
/proc/version,dmesg,/proc/cmdline) →kernel; - a guest kernel release different from the host's and a hypervisor
marker (the x86
hypervisorCPU flag, a hypervisor DMI vendor,/sys/hypervisor/type, alinux,dummy-virtdevice tree on arm64, virtio-mmio devices on the kernel command line) →vm; - a different kernel without a hypervisor marker →
kernel; - the host's own kernel, or anything unconfirmed (no shell in the image,
probe failed, node kernel unreadable) →
container.
Observed on a KVM host (Debian 13, Kata 4.2.0): kata-qemu and kata-clh
guests report kernel 6.18.35 against host 6.12.100+deb13-cloud-amd64, with
the hypervisor CPU flag and an empty DMI vendor; runc guests report the
host's kernel.
The name does not identify the hypervisor either: Kata 4's runtime-rs shim
loads the first runtime-rs/configuration.toml it finds (under
/etc/kata-containers, /usr/share/defaults/kata-containers,
/opt/kata/share/defaults/kata-containers) whatever the shim is called, so a
runtime named kata-clh or kata-fc can silently run QEMU. For a vm
sandbox on a Docker/Podman engine running on the same machine, the backend
finds the qemu-system-*, cloud-hypervisor or firecracker process whose
command line carries the container id and records it (hypervisor, signal
host:hypervisor=qemu, plus host:hypervisor-mismatch=... and a warning
when it contradicts the name). When host processes are not visible (remote
engine, Kubernetes) it reports vm with hypervisor unknown.
The result is capped at the runtime's ceiling (the "At most" column), so a
sandbox's own output can never lift runc or gVisor to vm. When the name
claims more than the evidence shows, the weaker tier is reported and an
ISOLATION DOWNGRADE warning is logged.
The evidence is stored on the sandbox (IsolationEvidence: runtime,
guest_kernel, host_kernel, signals, hypervisor) and written to the
ledger on the sandbox create event (and on a refused create) as
isolation_runtime, isolation_guest_kernel, isolation_host_kernel,
isolation_signals and isolation_hypervisor, for example:
isolation_signals = probe:ok,kernel:separate,hypervisor:cpu-flag,claim:vm,host:hypervisor=qemu,verdict:vm
Every Kata runtime boots a hardware VM, so its name claims vm, like
kata-fc and krun; the claim is only what an unprobed sandbox reports
(RUNEWARD_ISOLATION_PROBE=off) and what a downgrade is measured against.
The verdict always comes from the evidence.
The probe detects misconfiguration, not a hostile image: it trusts the
image's /bin/sh. RUNEWARD_ISOLATION_PROBE=off skips it and trusts the
runtime name; the evidence then records probe:disabled.
On Kubernetes the server and controller need get on nodes and
runtimeclasses (granted by the Helm chart and install manifests);
without them the node kernel is unknown and VM runtimes report container.
Sizing VM sandboxes¶
A VM runtime turns the sandbox's container limits into a guest. Kata sizes
the guest as its configured default_memory and default_vcpus plus
the container's memory and CPU limits, so the default limits
(RUNEWARD_SANDBOX_MEMORY=4g, RUNEWARD_SANDBOX_CPUS=2) become a guest of
6144 MiB and 3 vCPUs. When that does not fit on the engine host the
hypervisor never starts, and the engine only reports a shim timeout.
Before it asks Docker or Podman for a sandbox under a VM runtime (Kata, krun, Firecracker), the backend computes
guest memory = sandbox memory limit + RUNEWARD_VM_OVERHEAD_MEMORY (default 2048m)
guest vCPUs = ceil(sandbox CPU limit) + ceil(RUNEWARD_VM_OVERHEAD_CPUS) (default 1)
where the sandbox limits are the Charter's [rationing] memory / cpus,
else RUNEWARD_SANDBOX_MEMORY / RUNEWARD_SANDBOX_CPUS. It compares them
with the engine host's total memory and CPUs (docker info MemTotal /
NCPU, podman info Host.MemTotal / Host.CPUs, read once when the
backend starts) and refuses the sandbox when either is larger, naming the
sizes and what to lower. The overhead defaults are Kata's shipped
default_memory = 2048 and default_vcpus = 1; set the two
RUNEWARD_VM_OVERHEAD_* variables to match your runtime's configuration
(0/off for none, for example for a runtime that sizes the guest from
the limit alone). When the engine does not report its capacity, nothing is
refused. The check passing does not guarantee a boot: the host also needs
memory for itself and other sandboxes. Kubernetes accounts for VM overhead
through the RuntimeClass's overhead.podFixed and the scheduler instead.
Kata with Firecracker sizes the guest differently. Firecracker cannot
hot-plug memory or CPUs, so Kata's Firecracker configuration sets
static_sandbox_resource_mgmt = true: the guest is sized once, at boot,
from the Pod's limits, and default_memory / default_vcpus are replaced
rather than added to:
guest memory = overhead_memory + the Pod's container memory limits
guest vCPUs = overhead_vcpus + the Pod's container CPU limits
On the test host a sandbox with a 512 MiB limit ran in a 512 MiB guest. The
guest kernel and Kata's agent run in that memory too, so the sandbox has
less than its limit. Set overhead_memory (for example 256) and
overhead_vcpus in Kata's Firecracker configuration, and the RuntimeClass's
overhead.podFixed to match so the scheduler counts them. Rune Box gives
the egress sidecar its own limits (500m CPU, 256 MiB), so a sandbox that
needs it gets a guest with room for the proxy.
When a VM sandbox still fails to start with a known VM start failure
(timed out waiting for QMP ready, start vm, failed to create shim
task), the error names the likely causes: the guest size against the host
memory, the runtime not being able to use /dev/kvm (permissions, or no
nested virtualisation), or a broken hypervisor configuration
(/etc/kata-containers/runtime-rs/configuration.toml). A failed create
never leaves the container, the egress sidecar or the workspace volume
behind (on Kubernetes: the Pod, ConfigMap, Secret, NetworkPolicy or PVC).
The in-sandbox supervisor and the runtime¶
runeward-supervisor ([filesystem], [process]) confines the agent with
whatever kernel the sandbox sees: the host's under runc, the guest kernel
under Kata (any hypervisor), gVisor's own kernel under runsc. It probes
Landlock when it starts: best-effort uses the best ABI that kernel offers
and reports unsupported when it has none; required refuses below ABI 3,
and the create fails with the supervisor's reason (on Kubernetes the
sandbox container exits 77, and the backend reports it without waiting out
the start timeout). The report records the ABI and the seccomp filter that
were installed, so a guest kernel built without Landlock, or a user-space
kernel that does not implement it, shows up as evidence rather than as a
silent gap. The isolation probe runs after the supervisor has started and
does not need to predict any of this.
Docker/Podman and Kubernetes give the same guarantees; on Kubernetes the
binary comes from the egress image through an init container (see the
Firecracker drive budget below). On kind (runc, kernel 7.2, Landlock ABI 9)
the opt-in test k8s_governance showed governed execs as uid 1500 with
empty capability sets, Landlock refusing writes and reads outside the
Charter's paths, ptrace and unshare -U refused, and the report
(landlock: enforced) on the Citadel, with its own init container and
under strict egress. The Kata and gVisor paths have not been run against a
cluster yet.
Endpoint binaries on Kubernetes¶
On kind 0.33.0 (Kubernetes 1.37.0, containerd 2.3.4, runc; host kernel
6.12.100+deb13-cloud-amd64, Debian 13, 2 vCPUs, 3.8 GiB RAM), the opt-in
test k8s_binaries created Citadels with strict egress, [process]
run_as_user = "1500" and one tcp endpoint with binaries =
["/usr/bin/curl"], the upstream published in the cluster as a Service:
curlreached the upstream;python3(attributed as/usr/bin/python3.11),wgetand a copy of curl at/tmp/curlwere refused with reason codebinarybefore the upstream saw a byte.- The supervisor reported binary attribution
enforced, and the perimeter list and the signed ledger recordedbinaryandbinary_enforced = truefor the allowed and the refused connections. - With the attribution helper killed by the agent (same uid),
curlwas refused too:binary unknown: attribution helper unreachable. - With
mode = "audit",python3was forwarded and recorded asaudit-would-denywithbinary_enforced = false.
The first run found that the Kubernetes backend never read the sidecar's
decision lines back: enforcement worked, but the perimeter list and the
Chronicle stayed empty. The backend now follows the egress container's
log, as Docker follows the sidecar's. Kata (kata-qemu) was not run.
Tested hosts¶
On a KVM host (Debian 13 cloud VM, 2 vCPUs, 3.8 GiB RAM, host kernel
6.12.100+deb13-cloud-amd64, Docker 29, Kata 4.2.0 runtime-rs):
io.containerd.kata-qemu.v2andio.containerd.kata-clh.v2both booted with evidence verdictvm, guest kernel6.18.35, and the host-side hypervisor process found (host:hypervisor=qemu,host:hypervisor=cloud-hypervisor).- Kata 4.2.0 runtime-rs picks the hypervisor from the first existing
runtime-rs/configuration.toml(/etc/kata-containersfirst), not from the shim name. With the shim namedkata-clhand the configuration on QEMU, the evidence recordedhost:hypervisor-mismatch=cloud-hypervisor->qemu. - With the default limits the VM never started (6144 MiB guest on a
3.8 GiB host; the engine reported
timed out waiting for QMP ready); withRUNEWARD_SANDBOX_MEMORY=512m RUNEWARD_SANDBOX_CPUS=1it booted. The backend now refuses the first case up front. - runc on the same host reports verdict
container: the host is itself a VM, so thehypervisorCPU flag is present even for runc, but the guest kernel is the host's. - The shipped Firecracker configuration needed
dial_timeout_ms = 10andreconnect_timeout_ms = 45000; Firecracker needs a block-device root filesystem, so it is refused on Docker/Podman (below).
kata-fc on Kubernetes, same host (k3s 1.37 in a privileged container,
containerd 2.3 with the devmapper snapshotter on loop-file thin pools,
Kata 4.2.0 runtime-rs, Firecracker 1.12.1, guest memory 512 MiB):
- The sandbox booted with verdict
vm, guest kernel6.18.35, guest root/dev/vdc(ext4 from devmapper); signalsprobe:ok,kernel:separate,hypervisor:cpu-flag,hypervisor:cmdline=virtio_mmio,claim:vm,host:hypervisor=unknown,verdict:vm. Exec, file operations and destroy (Pod and PVC removed) worked. /workspaceinside the guest was a 96 MB tmpfs, not the workspace PVC: Firecracker cannot share a filesystem volume into the guest. Files written there never reached the PVC and were gone after sleep and wake. The probe now recordsworkspace:ephemeral=tmpfs, and a Charter with[workspace] persist = trueis refused on such a runtime (by the lint forkata-fcon Kubernetes, and by the backend at create and wake).- Kata's stock
csi-kata-directvolume(4.2.0) deletes the backing disk in NodeUnstageVolume, so the first sleep wiped the workspace and the wake formatted a new one. Its slot handling also left no drive for an egress sidecar next to a direct-volume workspace and the init container that then made it writable. Both led to the bundled driver below. - With the bundled driver (
sandbox.directVolumeDriver.enabled=true, Kata 4.3.0 runtime-rs, Firecracker 1.17.0, k3s 1.37): verdictvm;/workspacewas/dev/vdd, ext4, root0:0 1777, written by the image's user (uid 1000); the Pod had no init container and used three drives. Files survived three sleep/wake cycles (the backing disk kept its inode; each wake took about 5 s), and a driver restart while the workspace slept. [network] default = "deny"with a persistent workspace (four drives) started in about 8 s and the proxy allowed and denied hosts as written; with strict egress (five drives) bypassing the proxy failed too. Strict egress, a broker and a persistent workspace (six drives) were refused in 25 ms with nothing created, andruneward validatereported it. In a plain Pod, the drives of exited init containers were never reused, so a sixth drive user fails under 4.3.0 as well.- Destroy, of a running or a sleeping workspace, removed the Pod, PVC, PV, backing disk, Kata mount info and driver state.
- A RuntimeClass whose handler containerd does not know used to wait out
the full 90 s start timeout; a repeated
FailedCreatePodSandBoxevent now fails the create at once with the kubelet's message. A failed create left no objects behind either way.
krun, same host (a privileged Fedora 42 container: Podman 5.8.2, crun
1.27.1, libkrun 1.17.4, libkrunfw 5.3.0):
- The guest is a real VM: guest kernel
6.12.76against the host's,hypervisorCPU flag,virtio_mmio.device=on the command line, root on virtio-fs; the VMM is the container process itself, so no separate hypervisor process shows on the host. A 512m limit gave a 516 MiB guest (no Kata-style overhead). podman execinto the running guest fails withthe handler does not support exec. Rune Box runs every command, file operation and the isolation probe through exec, so the create was refused (probe:failed=..., verdictcontainer); with the probe off the sandbox started but every command failed.krunis therefore refused (below).
Native microVMs (host.type = "microvm"), same host, as an unprivileged
user in the kvm group, no container engine involved: Firecracker 1.15.1
with the Firecracker CI kernel 6.1.155, and libkrun 1.19.6 (built with
BLK=1) with libkrunfw 5.6.2. Both reported verdict vm with the monitor
confirmed on the host, ran exec, TTY, files and workspace export, had no
route around the egress proxy, kept the workspace disk across sleep and
wake, and left nothing behind on destroy. A supervised, read-only sandbox
from the runeward-sandbox image (about 110 s to pull and build its disk the
first time, 2 s afterwards) ran its execs as the image user under the
supervisor's seccomp filter. Details in
Native microVMs.
Installing a runtime¶
runeward runtime install <gvisor|kata> [--engine docker|k8s] [--runtime-class NAME] [--version V] [--sha256 HEX] [--apply]
prints the exact plan; nothing changes without --apply. Without root it
verifies downloads into a temporary directory and prints the sudo commands
to finish. It does not check the host for VM support; confirm the
requirements below yourself.
Host requirements for VM runtimes: Linux host, /dev/kvm present and openable,
nested virtualisation when the host is itself a VM, and not a desktop engine
(Docker Desktop, OrbStack and Podman machine run containers inside a VM
without nested virtualisation, so kata* cannot run there;
gVisor can).
gVisor (runsc)¶
Docker/Podman: downloads runsc and containerd-shim-runsc-v1 from the
gVisor release bucket, verified against a --sha256 pin or the bucket's
.sha512 sidecar (fail closed), installs them into /usr/local/bin, and
for Docker merges into /etc/docker/daemon.json:
Restart Docker (systemctl restart docker). Podman finds runsc in
/usr/local/bin by name. No KVM needed.
Kubernetes: install runsc and its shim on the nodes, register the
runsc handler in containerd, and apply:
Kata Containers (kata, kata-qemu, kata-clh)¶
Install Kata from its static release (/opt/kata) or your distribution (not
automated). Docker needs no daemon.json change: it runs containerd shims by
runtime type. Put the shim on dockerd's PATH under the name containerd looks
up, and pass the runtime type:
ln -sf /opt/kata/runtime-rs/bin/containerd-shim-kata-v2 /usr/local/bin/containerd-shim-kata-qemu-v2
docker run --runtime io.containerd.kata-qemu.v2 ... # host.runtime_class = "io.containerd.kata-qemu.v2"
runeward runtime install kata takes the other route: it does not install
Kata, but with --apply it finds containerd-shim-kata-v2 (or the older
kata-runtime binary) and registers it in /etc/docker/daemon.json as a
runtime named kata, so runtime_class = "kata" works. The names differ
because they name different things: containerd-shim-kata-v2 and
kata-runtime are Kata binaries, kata is a Docker runtime name registered
in daemon.json, and io.containerd.kata-qemu.v2 is a containerd runtime
handler (type) that containerd resolves to the shim
containerd-shim-kata-qemu-v2.
Either way, because runtime-rs picks the hypervisor from runtime-rs/configuration.toml
(see above), also point /etc/kata-containers/runtime-rs/configuration.toml
at the configuration for the hypervisor you want; KATA_CONF_FILE is only
honoured when it resolves to one of the same three paths. The evidence shows
which hypervisor actually ran.
Kubernetes: install with kata-deploy and use a RuntimeClass whose handler is
kata (or kata-qemu / kata-clh). These report vm when the evidence
shows a hardware-virtualised guest.
Kata with Firecracker (kata-fc)¶
Not available on Docker or Podman; the Charter lint and the backend refuse it there. Firecracker has no shared filesystem (virtio-fs/9p), so Kata fails with "share fs is unavailable", and it has no device hot-plug, so networking fails with "add net device: After 500 attempts". Firecracker itself boots fine on a KVM host; what it needs is a block-device root filesystem. Supported routes:
- Kubernetes with containerd's devmapper snapshotter on the nodes and a
kata-fcRuntimeClass (host.type = "k8s"). Kata 4.2.0 ships an invalid runtime-rsconfiguration-rs-fc.toml:dial_timeout_ms = 45000withoutreconnect_timeout_msfails validation; setdial_timeout_ms = 10andreconnect_timeout_ms = 45000. RuntimeClass (kata-deploy's handler, pod overhead and node label):
{ "apiVersion": "node.k8s.io/v1", "kind": "RuntimeClass",
"metadata": { "name": "kata-fc" }, "handler": "kata-fc",
"overhead": { "podFixed": { "cpu": "250m", "memory": "130Mi" } },
"scheduling": { "nodeSelector": { "katacontainers.io/kata-runtime": "true" } } }
- The native Firecracker driver (
host.type = "microvm",host.runtime = "firecracker", see Native microVMs): no Kubernetes or Kata; the workspace is a per-sandbox disk image and persists.
Workspaces. Kata with Firecracker cannot share a filesystem volume
into the guest, so an ordinary workspace PVC appears inside it as a small
guest tmpfs: nothing written there reaches the volume, and a sleep, wake or
restart loses it. Without further setup use kata-fc for disposable
sandboxes; a Charter with [workspace] persist = true is refused.
To keep workspaces, give persistent workspaces a direct-assigned block
device. The simplest way is the Helm chart's build of Kata's
csi-kata-directvolume driver:
helm upgrade --install runeward deploy/helm/runeward \
--set sandbox.directVolumeDriver.enabled=true \
--set-json 'sandbox.directVolumeDriver.nodeSelector={"katacontainers.io/kata-runtime":"true"}'
It runs the driver as a DaemonSet on the Kata nodes, creates the
runeward-kata-directvolume StorageClass and points
RUNEWARD_K8S_DIRECT_VOLUME_CLASS at it. Persistent workspaces of runtimes
without a shared filesystem then use that class and the guest mounts a real
ext4 disk; disposable ones keep the guest tmpfs, which spares a drive slot.
The disks live in /var/lib/runeward/direct-volumes on the node they were
created on, so a woken workspace is scheduled back to that node.
The image (runeward-csi-directvolume, built from
deploy/Dockerfile.csi-directvolume) is Kata 4.3.0's driver with three
changes:
- It keeps the disk until the volume is deleted. Kata's driver deletes the backing disk in NodeUnstageVolume, which runs whenever the last Pod using the volume goes away. A sleep deletes the Pod, so the wake got a new, empty disk and the workspace was lost without an error. Ours deletes it in DeleteVolume, when the workspace is destroyed.
- It hands a new filesystem over world-writable. The disk is formatted
on the node, root-owned, and Kata applies no
fsGroupto direct volumes, so the sandbox user could not write to it. Ours makes a new filesystem's root1777(like/tmp, inside a VM only this sandbox uses); a workspace already in use is never changed. - It deletes a disk only after the node has unmounted it. Destroying a workspace removes the Pod and the claim together, and Kata's driver deleted the disk while the node still had it mounted. Ours refuses until the node has unstaged the volume (the provisioner retries), and also removes a disk whose volume record is already gone.
To bring your own driver instead, set sandbox.directVolumeStorageClass (or
RUNEWARD_K8S_DIRECT_VOLUME_CLASS) to its StorageClass; it needs the first
two changes above.
Firecracker has five drive slots per pod. Every container root
filesystem (the pause container and init containers included) and every
block volume takes one, and Kata 4.2.0 and 4.3.0 do not reuse the slot of an
exited init container (4.3.0 resets it to its placeholder but never hands it
out again; see
kata-containers#13565).
Rune Box counts the drives a sandbox's Pod needs and refuses it before
creating anything when they exceed five; runeward validate flags the
one Charter shape that does:
| Pod | Drives |
|---|---|
| pause, sandbox, persistent workspace | 3 |
+ egress sidecar ([network] default = "deny" or a broker) |
4 |
| + strict egress init container | 5 |
| + broker trust-bundle init container (strict egress, broker and a persistent workspace) | 6: refused |
The in-sandbox supervisor ([filesystem] or [process]) is installed by
an init container from the egress image, which takes a drive, except under
strict egress, where the iptables init container installs it too and the
Pod needs no extra drive. Its emptyDirs are memory-backed and stay in the
guest. With the supervisor:
| Pod | Drives |
|---|---|
| pause, supervisor, sandbox (+ persistent workspace) | 3 (4) |
+ egress sidecar, cooperative [network] default = "deny" |
4 (5) |
| + strict egress (its init container installs the supervisor) | 4 (5) |
| broker, cooperative: pause, trust-bundle, supervisor, sandbox, egress | 5 (6: see below) |
| broker and strict egress | 5 (6: refused, as without the supervisor) |
The supervisor never costs a Charter that does not require it its
sandbox. A cooperative broker with a persistent workspace (five drives
without the supervisor) and a best-effort [filesystem] starts without
the supervisor: the agent runs unconfined, the control plane records the
supervisor as absent with that reason, and runeward validate warns.
Only a Charter that requires the supervisor ([process] or
landlock = "required") is refused in that shape, and runeward validate
reports it as an error. Every other combination, including strict egress
with the supervisor and a persistent workspace, fits in five. Use
kata-qemu or kata-clh for the refused shapes.
Both limits are tracked in #79.
libkrun (krun): refused¶
crun's libkrun handler boots a real microVM but cannot execute a command in
a running guest (podman exec fails with "the handler does not support
exec"; Docker and Kubernetes reach the same handler). Rune Box needs exec
for every command, file operation and the isolation probe, so krun is
refused by the Charter lint, the Docker/Podman and Kubernetes backends (a
RuntimeClass whose handler is krun), and runeward runtime install.
Use kata-qemu or kata-clh for a VM boundary on Docker or Podman, or the
native libkrun driver (host.type = "microvm", host.runtime = "libkrun"),
which drives libkrun directly and has exec.
Verifying on a KVM host¶
The opt-in test creates a sandbox under a runtime and asserts that the
evidence proves vm:
RUNEWARD_TEST_VM_RUNTIME=io.containerd.kata-qemu.v2 \
RUNEWARD_TEST_VM_HYPERVISOR=qemu \
cargo test -p runeward-backend --test vm_runtime -- --nocapture
RUNEWARD_TEST_VM_ENGINE=docker|podman|k8s picks the engine,
RUNEWARD_TEST_VM_IMAGE overrides alpine, and RUNEWARD_TEST_DOCKER=1
also checks that the default runtime reports container.
Native microVMs (host.type = "microvm")¶
The microvm backend boots one microVM per sandbox itself: no container
engine, no Kata, no Kubernetes. Two monitors share one guest design:
host.runtime |
Monitor | Guest kernel | Hosts |
|---|---|---|---|
firecracker |
Firecracker (firecracker --no-api --config-file) |
the operator's (RUNEWARD_MICROVM_KERNEL) |
Linux with /dev/kvm |
libkrun (krun) |
libkrun, loaded by the runeward-krun helper |
libkrunfw's bundled kernel | Linux with /dev/kvm; macOS on Apple silicon (see macOS) |
An empty host.runtime takes RUNEWARD_MICROVM_VMM, else Firecracker when
it is configured, else libkrun.
[host]
type = "microvm"
runtime = "firecracker" # or "libkrun"
image = "curlimages/curl:8.11.1"
[network]
default = "deny"
[[network.rule]]
hostname = "example.com"
[workspace]
persist = true
[isolation]
minimum = "vm"
How a sandbox is built¶
- Image. The Charter's image is pulled straight from its registry
(anonymously, for
linux/<host arch>; layers are verified against their digests), its layers are applied with OCI whiteouts into a staging directory (every write confined to it, so a hostile layer cannot write outside), and the tree becomes an ext4 disk. The real owners and modes (setuid bits, root-only files) are written into the disk withdebugfs, so the build never needs root. The disk is cached by manifest digest underRUNEWARD_MICROVM_DIR/imagesand shared, read-only, by every VM of that image; a tag that cannot be resolved (registry unreachable) falls back to the disk it last resolved to. gzip and uncompressed layers are supported; zstd layers are refused. - Guest.
runeward-vmagent, a static binary, is the guest's PID 1: Firecracker boots it as the initramfs/init, libkrun serves it as/init.krunfrom host memory on an otherwise empty root (libkrun's own init is disabled), so the agent never sits on the image disk and any image boots unmodified. The agent mounts the image disk read-only under an overlay whose upper layer is guest memory, mounts/proc,/sys,/devand the workspace disk inside it, andchroots into it.host.read_only = trueremounts that root read-only once the sandbox is staged, with a 512 MiB tmpfs/tmp; the supervisor state and broker CA directories stay writable, as container engines keep volumes there. - Protocol. The host reaches the agent over vsock (Firecracker's Unix
socket with
CONNECT <port>; a libkrun port-to-socket mapping). One stream per operation: exec with streamed stdin/stdout/stderr, a TTY with resizes, signals and the exit status; file write/read/stat/list and workspace tar streams; the isolation probe; shutdown. Closing a stream kills what it started. Commands and file operations run in a helper process that drops to the sandbox user first, so a link planted in the workspace cannot redirect a write into a root-owned file. - Workspace. A per-sandbox ext4 disk image (
RUNEWARD_MICROVM_DISK_SIZE, default8g, sparse) at the workspace directory. A fresh disk takes the owner and mode of the image's directory there, or the sandbox user when the image has none. - Sleep and wake.
stopasks the agent to stop every process, flush and unmount the workspace and reboot (the monitor exits), then kills the monitor if it is still running after 15 s. The workspace disk and the sandbox id stay; guest memory (the overlay,/tmp, processes) is gone.startboots a new VM on the same disk with a fresh egress path.killremoves the VM directory: disk, sockets, logs. - Sizing. The guest gets the sandbox's memory limit (at least 256 MiB)
and its CPU limit rounded up to whole vCPUs; the operator defaults
(
RUNEWARD_SANDBOX_MEMORY,RUNEWARD_SANDBOX_CPUS) apply as on Docker.
Egress: no network device¶
The guest has no network device (Firecracker gets no
network-interfaces; libkrun gets no virtio-net and its vsock is created
with TSI disabled, so guest sockets are not proxied onto the host network;
libkrunfw's kernel shows a dummy0 device with nothing behind it). Its only
way out is vsock: the agent listens on 127.0.0.1:3128 inside the guest,
HTTP_PROXY/HTTPS_PROXY point there, and every connection is forwarded
over vsock to a per-sandbox Unix socket on the host, where the Charter's
egress proxy (with the request broker when the Charter has
[[endpoint]]s or [[credential]]s) decides exactly as on the container
backends. Firecracker delivers guest-initiated vsock connections only to
<vsock socket>_<port> in the VM's own directory, where the egress socket
is the only one; libkrun only to the ports it was given. Checked from inside
both guests: host vsock ports 22, 2375 and the agent's own port were reset
(Firecracker) or timed out (libkrun); only the egress port answered.
This is why deny-by-default cannot be bypassed from inside: there is no
route to bypass it with, whatever a root process in the guest does, and it
holds for enforce = "strict" and "cooperative" alike. The price is that
only proxy-aware traffic leaves at all: raw TCP, UDP and DNS lookups fail
even under an open policy (runeward validate warns about that), and the
in-cell IDE, which needs a sandbox address, is refused. Broker CAs are
installed in the guest trust store as on the other backends.
Isolation evidence¶
The agent collects the same probe sections as the container backends'
shell probe, natively, so an image without a shell still reports evidence.
The verdict uses the same rules: vm needs a guest kernel different from
the host's and a hypervisor marker, and the backend additionally
records host:hypervisor=<monitor> only while the monitor process it
launched is still running. A sandbox whose probe fails reports container.
Observed on the KVM test host (Debian 13, kernel 6.12.100+deb13-cloud-amd64,
2026-10-21):
| Monitor | Guest kernel | Signals |
|---|---|---|
| Firecracker 1.15.1 | 6.1.155+ (Firecracker CI kernel) |
kernel:separate, hypervisor:cpu-flag, hypervisor:cmdline=virtio_mmio, host:hypervisor=firecracker, verdict:vm |
| libkrun 1.19.6 + libkrunfw 5.6.2 | 6.12.109 |
kernel:separate, hypervisor:cpu-flag, hypervisor:cmdline=virtio_mmio, host:hypervisor=libkrun, verdict:vm |
Settings refused or changed¶
| Setting | On microvm |
|---|---|
host.seccomp, host.apparmor |
Refused: container-engine profiles. The monitor runs under its own seccomp filters (Firecracker's built-in ones); use [process] for in-guest seccomp. |
[ide] enabled |
Refused: no sandbox network address. |
[filesystem] landlock = "required" |
Works when the guest kernel has Landlock. libkrunfw is built without it, and the Firecracker CI 6.1 kernel too; runeward validate warns for libkrun. |
host.user, host.command, [process], broker, [[file]], copy_from, snapshots |
As on Docker. |
Setting up a host¶
- The static guest agent:
cargo build --release --target x86_64-unknown-linux-musl -p runeward-vmagent(aarch64-unknown-linux-muslon arm64), thenRUNEWARD_MICROVM_AGENT=/path/to/runeward-vmagent(or put it next to therunewardbinary). - e2fsprogs 1.43 or later (
mkfs.ext4anddebugfs; Debian'se2fsprogs, orRUNEWARD_MICROVM_MKFS). - Access to
/dev/kvmfor the user running Rune Box (thekvmgroup). - Firecracker: a release binary
(
https://github.com/firecracker-microvm/firecracker/releases, checked against its.sha256.txt) asfirecrackeronPATHorRUNEWARD_FIRECRACKER_BIN, and an uncompressed guest kernel with virtio MMIO block and vsock, ext4, overlayfs, devtmpfs and initramfs support asRUNEWARD_MICROVM_KERNEL(the Firecracker CI kernels qualify; tested:firecracker-ci/v1.15/x86_64/vmlinux-6.1.155, sha256e20e46d0c36c55c0d1014eb20576171b3f3d922260d9f792017aeff53af3d4f2). - libkrun: libkrun 1.19 or later built with block-device support
(
make BLK=1; distribution packages may lack it, and the helper refuses such a build), libkrunfw next to it, theruneward-krunhelper (cargo build --release -p runeward-krun) onPATHorRUNEWARD_KRUN_HELPER, andRUNEWARD_LIBKRUNpointing atlibkrun.so.1when it is not on the loader path.
Settings: RUNEWARD_MICROVM_DIR (state and cache, default
~/.runeward/microvm; keep it short, Unix socket paths are limited to about
100 bytes), RUNEWARD_MICROVM_VMM, RUNEWARD_MICROVM_DISK_SIZE, and the
paths above. Opening the backend fails with a message naming what is missing.
macOS¶
libkrun runs on Apple silicon through Hypervisor.framework with the same guest design (block devices, vsock-to-Unix-socket mappings, no TSI). Firecracker is Linux-only. A macOS host needs:
- libkrun built for macOS with block-device support
(
cargo build --release -p libkrun --no-default-features --features blkin the libkrun source; Rune Box disables libkrun's init, so the init blob and its Linux sysroot are not needed) and libkrunfw built from the release'slibkrunfw-prebuilt-aarch64.tgz(cc -fPIC -DABI_VERSION=5 -shared -o libkrunfw.5.dylib kernel.c), side by side;RUNEWARD_LIBKRUNnameslibkrun.1.dyliband the helper finds libkrunfw next to it; - the
runeward-krunhelper signed with the hypervisor entitlement:codesign --force -s - --entitlements hvf.plist runeward-krun, wherehvf.plistsetscom.apple.security.hypervisorto true; - an
aarch64-unknown-linux-muslbuild ofruneward-vmagent; - e2fsprogs (
mkfs.ext4anddebugfs, for example Homebrew'se2fsprogsviaRUNEWARD_MICROVM_MKFS); RUNEWARD_MICROVM_DIRon a case-sensitive volume (Linux root filesystems contain names that differ only in case), for example a case-sensitive APFS disk image.
Verified on an Apple silicon Mac (macOS 27, libkrun 1.19.6 built as above,
libkrunfw 5.6.2), with one substitution: e2fsprogs was not installed, so
mkfs.ext4 and debugfs ran in a Linux container through a wrapper script.
The end-to-end test passed: verdict vm from kernel:separate (guest
6.12.109 against Darwin 27.0.0) and the arm64 device tree
(hypervisor:devicetree=linux,dummy-virt; there is no hypervisor CPU flag
on arm64) with host:hypervisor=libkrun, exec and a TTY, files and export,
no route around the proxy, allowed and denied egress, the workspace across
sleep and wake, and nothing left on destroy. The supervised, read-only
runeward-sandbox sandbox passed too (its first disk build took about 11
minutes through the container stand-in on a busy machine).
Verifying on a KVM host¶
RUNEWARD_TEST_MICROVM=firecracker \
RUNEWARD_MICROVM_DIR=/var/lib/rw-microvm \
RUNEWARD_MICROVM_AGENT=/opt/runeward/runeward-vmagent \
RUNEWARD_MICROVM_KERNEL=/opt/runeward/vmlinux-6.1.155 \
cargo test -p runeward-backend --test microvm_e2e -- --nocapture
(RUNEWARD_TEST_MICROVM=libkrun with RUNEWARD_KRUN_HELPER and
RUNEWARD_LIBKRUN for libkrun.) It creates a sandbox from
curlimages/curl with a deny-by-default policy allowing example.com and a
persistent workspace, and checks the vm verdict, exec (status, streams,
environment, a TTY), projected files and the workspace export, that the guest
has no network interface and no route around the proxy, that the allowed
host answers through the proxy and a denied one gets a 403, that the
workspace survives sleep and wake while /tmp does not, and that destroying
the sandbox leaves no monitor process, socket or file.
RUNEWARD_TEST_MICROVM_SUPERVISED=1 adds a read-only sandbox from the
runeward-sandbox image under the in-sandbox supervisor: governed execs run
as the image's sandbox user with the supervisor's seccomp filter (Landlock
reports unsupported: neither guest kernel has it), the enforcement report is
read back, the root refuses writes while /tmp and the workspace accept
them, and no host vsock port but the egress one answers. Both monitors pass both
on the KVM test host (Firecracker 1.15.1; libkrun 1.19.6 with libkrunfw
5.6.2): a cached image boots to a running sandbox in about 3 seconds.
Limitations¶
- Images are pulled anonymously from their registry; private registries and local-only images are not supported yet.
- zstd-compressed layers are refused; images whose layers write through an absolute symlink to a directory fail to unpack (writes never follow links out of the staging root).
- A control-plane restart leaves running VMs running and reachable, but their egress path (served in process, like the cooperative proxy on Docker) is gone until the sandbox is woken again: egress fails closed.
- Firecracker runs as the Rune Box user without its jailer; the VM boundary and Firecracker's seccomp filters are the isolation, not a chroot around the monitor.