Audit the isolation stack: verify the box boundary behaves as designed #15
Labels
No labels
blocked
blocker:ci-red
blocker:conflict
blocker:drill-pending
blocker:unrequested
bug
claimed
documentation
enhancement
epic
merge-next
needs-triage
ready
release
scope:cli
scope:drill
scope:host
scope:installer
scope:templates
scope:tiers
stale
state:addressing
state:bots-reviewing
state:building
state:needs-human
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: heavy-duty/box#15
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Split from #12 (part 1 of 3: audit → #16 isolation hardening → #17 box + templates).
The isolation stack has never been verified live, end to end. Worse: the analysis in #12 found that box-to-box isolation exists by accident (the
10.0.0.0/8egress drop happens to contain the10.87.0.0/24subnet), and several behaviors the design leans on are asserted only from a code reading. Before hardening anything (the next issue), audit that the boundary actually behaves as designed — and record what is true, so the follow-up issues build on facts rather than assumptions.Everything below runs on a live Incus host with claudebox set up (any scratch machine; boxes minted here are throwaway). Record each result as a comment on this issue. Line refs @
0982a2d.A. The boundary, observed
Mint two boxes
aandband probe every edge of the stated contract ("a box reaches the public internet and nothing else"):claudebox exec a -- curl -sS --max-time 5 https://example.coma: connect to the host's LAN IP and gateway on:22,:8000host/setup-host.sh:31-35,host/claudebox-firewall.sh)a:ping -c1 -W2 <b's eth0 IP>a:getent hosts bandgetent hosts b.incusa: DNS via the gateway still resolves public names10.87.0.1carve-out (setup-host.sh:30) is load-bearing for A1incus network get claudenet ipv6.addressnoneasecurity.acls.default.ingress.action=drop,setup-host.sh:39)Extraction note for A3: a claude box runs Docker and reports several IPv4s quoted across CSV lines — select
b'seth0address exactly (incus list ^b$ --format csv --columns 4 | tr -d '"' | tr ',' '\n' | awk '/\(eth0\)$/ {print $1; exit}'), or the ping fails on a malformed argument and the probe passes vacuously.B. The mechanisms the follow-up issues lean on
@internalas an ACL rule destination on a bridge network:incus network acl rule add <scratch-acl> egress action=drop destination=@internal— accepted and effective?@internal(renumber-proof) vs. deriving the subnet insetup-host.sh— notingincus network get claudenet ipv4.addressreturns the gateway CIDR (10.87.0.1/24, host bits included), so deriving means masking, not pastingincus copypreservesuser.*config keys (setuser.test=1on a scratch box, copy,config geton the clone)--fromreuse must carryuser.box.user)dns.mode=noneon a scratch bridge: instance records stop resolving; upstream resolution through the gateway dnsmasq still worksincus config get <inst> user.unset-key; echo $?— empty output, exit 0?security.mac_filtering=true+security.ipv4_filtering=trueon a scratch box's NIC: box still gets DHCP, Docker inside still works, curl still passesOutcome
Cost: about an hour with Incus at hand. No repo changes; the follow-up issue turns these probes into
tests/isolation.sh.The audit now has a run-and-forget vehicle: PR #18's drill. One command on a throwaway host (
bash drill/drill.sh --yes, ~20 min) runs every section-A probe, both section-B mechanism checks that fit phase A, and a live rehearsal of #16's proposed hardening for the rest (B1/B3/B5). It ends with an answers block formatted for pasting here. Sections A/B of this issue remain the source of truth for what each probe means and what each outcome decides.Partial answers from the first live run (interrupted at C7 by a drill bug — fixes in PR #19; re-run pending). What the run did establish, on a real Debian 13 + KVM host, VM boxes:
getent hosts peerfrom a sibling returned10.87.0.8 peer(plus a link-local AAAA). #16'sdns.mode=noneearns its place.ipv6.address = none✓incus copypreservesuser.*config getunset key${var:-}, never||Also live-confirmed along the way: the tag read, list filter include/exclude, CSV shapes,
RUNNINGstate string, rename-refuses-running (Error: Renaming of running instance not allowed), snapshot-label CSV field — every phase-A Incus assumption from #13's stub era holds on the real thing.Two candidate real findings awaiting the re-run's self-diagnosis:
claude --versionfailed inside the box whileghpassed — either claudeboxexec's PATH misses~/.local/bin(repo bug: the help promises that exact invocation) or the cloud-init install step failed. The #19 drill distinguishes the two.cloud-init status --waitreturning before runcmd truly finished. The same diagnosis output will tell.Run 2 — 42/49, most of the audit answered
Real host, Debian 13 + KVM, VM boxes. Of the 7 FAILs: 5 were one drill defect (stale boxes from the interrupted run 1 — cleanup aborted on a missing name; fixed in #20) and 2 are real findings below. The boundary section is complete except A3 (sibling isolation), which the same stale-box cascade blocked from running — run 3 (post-#20) gets it.
Answers block (updated)
peerresolves from another boxnone✓incus copypreservesuser.*config getunset key@internalon bridge ACLUnsupported nftables subject "@internal"⇒ #16 derives the subnetdns.mode=noneThe two real findings
claudebox exec box -- claude …cannot work on a fresh box — the binary is in~/.local/bin, but cloud-init exported PATH only in.bashrc/.zshrc, which the non-interactive shell behindexecnever reads (box login shell is zsh, so evensudo -imisses it). This is the help's own advertised example. Fixed in #20 via a/usr/local/binsymlink.cloud-init status --waitreturns beforeruncmdfinishes — the latter would mean boxes are handed over before Claude/docker finish installing. #20 adds acloud-init statusreadout to settle it on run 3; worth its own issue if it's the race.The audit is complete — and its headline finding overturns the premise
Run 10, on a doctor-clean host: 48/49, every probe answered. Full output below.
🔴 A3 — sibling isolation DOES NOT HOLD
refusedis not "blocked". It means box A's SYN arrived at box B, and box B's kernel sent a RST back — bidirectional reachability between boxes, on a stock claudebox host, right now.#12 claimed the opposite, from a code reading: the ACL drops egress to
10.0.0.0/8,claudenetis10.87.0.0/24⊂10.0.0.0/8, therefore box→box is dropped "belt and braces". That reasoning is wrong in practice — two boxes on one bridge are on the same L2 segment, and their traffic is switched, not routed, so it never meets the rules that would drop it.This flips the whole framing of #16:
#16 is a fix, not a formalization. And the tool's stated contract — "a box reaches the public internet and nothing else" — is not true today: it reaches every other box on the host. Two agents in two boxes can talk to each other.
This is exactly the claim the audit existed to test, and the reason it was worth ten runs to get an answer to.
The complete answer block
none✓ (contract holds)@internalon a bridge ACLUnsupported nftables subject "@internal"⇒ #16 must derive the subnet (mask the gateway CIDR)incus copypreservesuser.*dns.mode=noneconfig geton unset key${var:-}, never||🔴 B5 —
security.ipv4_filteringbreaks the box. This one is real.This verdict is trustworthy, unlike the earlier one: it was measured on a baseline that had already passed A1 (the box reached the internet before phase D touched anything). The gate added after run 7 is what makes the difference — an identical-looking FAIL in run 7 was a lie told by a broken box.
So #16's proposal 2 cannot ship as written. Likely mechanism:
ipv4_filteringpermits only the instance's own assigned address as a source, and in-box Docker's172.17.xtraffic does not survive that. Needs splitting:mac_filteringalone may well be fine;ipv4_filteringneeds a way to accommodate the box's own container NAT, or it is out.B3 — the flip-flop, resolved
Run 2 said
dns.mode=nonebroke egress; run 3 said it was fine. Run 10, on a healthy baseline: it works, immediately, with no outage window. The run-2 reading was an artifact of probing during the dnsmasq restart. The design veto I posted to #16 on the strength of it was wrong, and is retracted.dns.mode=noneis viable for proposal 3.What #16 should now be
@internalis unsupported on bridge ACLs, so derive the subnet insetup-host.shby masking the gateway CIDR (incus network get claudenet ipv4.address→10.87.0.1/24→10.87.0.0/24). And verify it live — an ACL rule that the L2 path bypasses is exactly the bug we just found; if an egress ACL cannot see intra-bridge traffic, this needssecurity.aclsbehaviour that does, or an ebtables/bridge-family rule, or per-box networks after all. The test must proverefusedbecomestimeout.dns.mode=none— viable, confirmed, closes the enumeration leak with no egress cost.ipv4_filteringvetoed as written; investigatemac_filteringalone.tests/isolation.sh— the drill's phase C already is this; lift it out.Closing this issue
Everything section A and B asked has been answered on a live host. The audit's job is done; the work moves to #16 (now a fix), #17 (unblocked — B2/B4 confirm its design), and the bugs the audit found along the way.
What it cost, and what it found
Ten runs. Four of them were killed by bugs in the drill, and three by bugs in claudebox or the host — the audit spent more time being wrong than being right, and the record of exactly how is in
drill/RUNS.md(eleven traps, each one paid for).Bugs found in claudebox itself, none of which the audit was looking for:
setup-host.shdeadlocks the Incus daemon when re-run while boxes are running (#26)claudebox exec box -- claude …— the help's own example — could not work:~/.local/binis not on the non-interactive PATH (#20)setup-host.shdied on a stock Debian 13 cloud image — it callsnft, which is not installeddirstorage makes every clone a full multi-GB disk copy, when cloning is the entire product (#29)error, with no way to learn why (#34)Createdmay have nothing serving it — no dnsmasq, no leases, and every mint dies inscrutably (#35)A stubbed test suite would have found none of these.
Run 11 (2026-07-14, post #40/#41/#42/#43): 45 passed, 2 failed — both failures were shared-host artifacts in the drill itself (pre-existing operator boxes t1/t2), fixed in #44. The audit answers, as the drill emitted them:
Caveat carried forward: the host has 15GiB RAM, so the drill ran claude-dev at 3GiB/2cpu — the production profile (8GiB/4cpu) is what was NOT drilled.
🤖 Generated with Claude Code