Skip to content

2026-07-26: The Airgap That Forgot Itself

A pencil-sketched stone wall with a small gate set into it. The gate hangs slightly ajar and a teal glow spills through the gap at its base; a brass bell wired down to the threshold is caught mid-ring, having just noticed. Tommy the Abyssinian sits in the foreground, keeping watch on the gap the way he keeps watch on everything.

The OpenClaw VM has no internet. That was the design, it was written down, and a NAT window had been explicitly considered and rejected. On 26 July we discovered it had been reachable on the open internet for roughly eight days.

Nobody opened a hole. The VM was migrated from UTM to Lima, and Lima’s default user-mode networking helpfully attached an eth0 with a NAT gateway. The airgap was not defeated; it was forgotten — a property that existed because of how the box happened to be built, not because anything enforced it.

That distinction is the entire lesson.

Every probe the doctrine cited as proof of the airgap came back inverted:

Doctrine saidReality
ping 1.1.1.1 failsreachable
DNS for registry.npmjs.org failsresolves
no internet by designcurl registry.npmjs.org → 200

The default route told the rest of the story: via 192.168.5.2 dev eth0, the signature of Lima’s slirp gateway. limactl list confirmed a vz VM named sanctum-vm, and there were no UTM bundles left on the machine at all.

This is the regression doctrine doing its job. When something that worked stops working — or in this case, when something that was true stops being true — the answer is in what changed, not in the physics. The break was in the diff: a hypervisor swap.

Cutting it back, without cutting the agents

Section titled “Cutting it back, without cutting the agents”

The obvious fix is to down eth0. The obvious fix is wrong.

The VM’s LAN path runs through that NAT: ip route get 10.0.0.1 returns via 192.168.5.2 dev eth0. Downing the interface would have severed the Firewalla bridge, the mDNS listener, and the health ingester along with the internet — a fix that trades one outage for three.

So the guard filters on destination, not interface. Everything in private space stays reachable; everything else is dropped.

Terminal window
table inet airgap {
chain output {
type filter hook output priority filter; policy accept;
ct state established,related accept
oif "lo" accept
ip daddr { 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16,
127.0.0.0/8, 169.254.0.0/16, 224.0.0.0/4, <CGNAT/tailnet range> } accept
meta nfproto ipv4 counter name egress_blocked_v4 drop
meta nfproto ipv6 counter name egress_blocked_v6 drop
}
}

Two details worth stealing. It lives in its own table — Docker installs its own nftables rules, and a stray flush ruleset would take container networking down with it. And the persisted /etc/nftables.conf deliberately omits the flush ruleset line that ships in Ubuntu’s default template, for exactly that reason.

The part that matters: it announces itself now

Section titled “The part that matters: it announces itself now”

Windu’s ruling was blunt. The image model was not the finding. The finding was that a platform change re-granted egress and nothing noticed for eight days.

Airgaps must be tested, not assumed. Config that survives a hypervisor swap by luck is not a control.

So the boundary now reports on itself, two ways.

The drop rules count and log, rate-limited so a retry loop can’t flood the journal. sudo nft list counters table inet airgap is now a number you can watch, and journalctl -k | grep airgap-drop shows what tried to leave.

And a systemd unit asserts the airgap at boot and hourly. It is an inverted health check: it fails when the public internet is reachable. A failed unit is the alarm — it sits in systemctl --user --failed until a human looks at it, and it writes a status file the Mini can scrape.

The assertion was tested in both directions, because a guard nobody has watched fail is not a guard:

  1. Airgap intact → exit 0, leaks: [].
  2. Guard deliberately removed → exit 1, catching all five leak vectors plus the missing table, and surfacing as a failed unit.
  3. Guard restored → exit 0, unit result success.

While brokering the one real internet dependency, proxyd was asked for an image model. No seat matched, so the fallback ladder walked down to the local text model — which answered confidently and invented an image URL that does not exist.

That is worse than an outage. An outage reports itself; a plausible, well-formed, false artifact does not. The seat was reverted rather than shipped.

The ruling that followed: seats should declare a capability, fallback should only traverse the same capability class, and a cross-class request should fail closed with an error that cannot be mistaken for a result. That work belongs in proxyd’s fallback path, which was under active surgery by another session at the time — so it was handed over rather than duplicated. Two sessions editing one fallback loop is its own kind of incident.

The dead provider held a plaintext API key. Windu’s instruction was to revoke rather than rotate — “inert behind the airgap is not dead; it is one hypervisor migration away from live.” The key and the reopened airgap were the same incident.

The sweep that followed found ten more plaintext credentials: five Google keys sitting in per-agent models.json, and five Anthropic keys in stale .bak files left behind by a database import. All eleven are now purged or quarantined, and the live config surface greps clean. A live key’s only correct home is the secrets trifecta; not one of these eleven had ever been near it.

One found by accident implies others nobody has looked for. That turned out to be exactly right.

Nothing, in the end. The VM lost no capability it was actually using, the agents never noticed, and the boundary went from an assumption to a control that files a complaint when it’s violated.

The uncomfortable part is the eight days. Not that the airgap broke — platforms change, that’s their job — but that a security property everyone believed in had no one checking it. The airgap had no such habit. For eight days it guarded nothing and told no one.

The fix isn’t the firewall rule. The fix is the thing that fails loudly on a Tuesday when Lima ships a new default — a boundary that keeps watch even when nobody’s watching the boundary.