Audit the isolation stack: verify the box boundary behaves as designed #15

Closed
opened 2026-07-13 21:12:33 +00:00 by dan-claude-bot · 5 comments
dan-claude-bot commented 2026-07-13 21:12:33 +00:00 (Migrated from github.com)

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/8 egress drop happens to contain the 10.87.0.0/24 subnet), 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 a and b and probe every edge of the stated contract ("a box reaches the public internet and nothing else"):

# Probe Expected Why it matters
A1 claudebox exec a -- curl -sS --max-time 5 https://example.com passes public egress is the one thing a box may do
A2 from a: connect to the host's LAN IP and gateway on :22, :8000 blocked host + LAN protection (the RFC1918 drops + host firewall, host/setup-host.sh:31-35, host/claudebox-firewall.sh)
A3 from a: ping -c1 -W2 <b's eth0 IP> blocked sibling isolation — currently incidental; this is #12's central claim, never reproduced live
A4 from a: getent hosts b and getent hosts b.incus expected to LEAK today dnsmasq on the gateway holds records for every instance — enumeration (names, existence, count) even where connection is blocked
A5 from a: DNS via the gateway still resolves public names passes the 10.87.0.1 carve-out (setup-host.sh:30) is load-bearing for A1
A6 incus network get claudenet ipv6.address none every ACL rule is IPv4-only; IPv6-off is the only thing covering that class
A7 inbound: from the host, connect to any port on a blocked (security.acls.default.ingress.action=drop, setup-host.sh:39) "no inbound path"

Extraction note for A3: a claude box runs Docker and reports several IPv4s quoted across CSV lines — select b's eth0 address 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

# Check Decides
B1 @internal as an ACL rule destination on a bridge network: incus network acl rule add <scratch-acl> egress action=drop destination=@internal — accepted and effective? Hardening uses @internal (renumber-proof) vs. deriving the subnet in setup-host.sh — noting incus network get claudenet ipv4.address returns the gateway CIDR (10.87.0.1/24, host bits included), so deriving means masking, not pasting
B2 incus copy preserves user.* config keys (set user.test=1 on a scratch box, copy, config get on the clone) The whole template-metadata design in box+templates (--from reuse must carry user.box.user)
B3 dns.mode=none on a scratch bridge: instance records stop resolving; upstream resolution through the gateway dnsmasq still works The DNS-enumeration fix in hardening
B4 incus config get <inst> user.unset-key; echo $? — empty output, exit 0? The user-lookup fallback shape in box+templates (`
B5 security.mac_filtering=true + security.ipv4_filtering=true on a scratch box's NIC: box still gets DHCP, Docker inside still works, curl still passes The L2 hardening is safe to apply to the claude template's workload

Outcome

  • Every A-row and B-row answered with observed output, as comments here.
  • If A3 fails to block: sibling isolation does not exist today — the hardening issue is a fix, not a formalization, and its priority changes accordingly.
  • If A4 does not leak: the DNS-enumeration item in the hardening issue shrinks to a no-op assertion.
  • The hardening issue's design points (drop-rule form, filtering flags, dns.mode) get pinned to these results before its PR is written.

Cost: about an hour with Incus at hand. No repo changes; the follow-up issue turns these probes into tests/isolation.sh.

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/8` egress drop happens to contain the `10.87.0.0/24` subnet), 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 `a` and `b` and probe every edge of the stated contract ("a box reaches the public internet and nothing else"): | # | Probe | Expected | Why it matters | | --- | --- | --- | --- | | A1 | `claudebox exec a -- curl -sS --max-time 5 https://example.com` | **passes** | public egress is the one thing a box may do | | A2 | from `a`: connect to the host's LAN IP and gateway on `:22`, `:8000` | **blocked** | host + LAN protection (the RFC1918 drops + host firewall, `host/setup-host.sh:31-35`, `host/claudebox-firewall.sh`) | | A3 | from `a`: `ping -c1 -W2 <b's eth0 IP>` | **blocked** | sibling isolation — currently incidental; this is #12's central claim, never reproduced live | | A4 | from `a`: `getent hosts b` and `getent hosts b.incus` | **expected to LEAK today** | dnsmasq on the gateway holds records for every instance — enumeration (names, existence, count) even where connection is blocked | | A5 | from `a`: DNS via the gateway still resolves public names | **passes** | the `10.87.0.1` carve-out (`setup-host.sh:30`) is load-bearing for A1 | | A6 | `incus network get claudenet ipv6.address` | `none` | every ACL rule is IPv4-only; IPv6-off is the only thing covering that class | | A7 | inbound: from the host, connect to any port on `a` | **blocked** (`security.acls.default.ingress.action=drop`, `setup-host.sh:39`) | "no inbound path" | Extraction note for A3: a claude box runs Docker and reports **several** IPv4s quoted across CSV lines — select `b`'s `eth0` address 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 | # | Check | Decides | | --- | --- | --- | | B1 | `@internal` as an ACL rule destination on a **bridge** network: `incus network acl rule add <scratch-acl> egress action=drop destination=@internal` — accepted *and effective*? | Hardening uses `@internal` (renumber-proof) vs. deriving the subnet in `setup-host.sh` — noting `incus network get claudenet ipv4.address` returns the **gateway CIDR** (`10.87.0.1/24`, host bits included), so deriving means masking, not pasting | | B2 | `incus copy` preserves `user.*` config keys (set `user.test=1` on a scratch box, copy, `config get` on the clone) | The whole template-metadata design in box+templates (`--from` reuse must carry `user.box.user`) | | B3 | `dns.mode=none` on a scratch bridge: instance records stop resolving; upstream resolution through the gateway dnsmasq still works | The DNS-enumeration fix in hardening | | B4 | `incus config get <inst> user.unset-key; echo $?` — empty output, exit 0? | The user-lookup fallback shape in box+templates (`|| echo root` can never fire if so) | | B5 | `security.mac_filtering=true` + `security.ipv4_filtering=true` on a scratch box's NIC: box still gets DHCP, Docker inside still works, curl still passes | The L2 hardening is safe to apply to the claude template's workload | ## Outcome - Every A-row and B-row answered with observed output, as comments here. - **If A3 fails to block**: sibling isolation does not exist today — the hardening issue is a fix, not a formalization, and its priority changes accordingly. - **If A4 does not leak**: the DNS-enumeration item in the hardening issue shrinks to a no-op assertion. - The hardening issue's design points (drop-rule form, filtering flags, dns.mode) get pinned to these results before its PR is written. Cost: about an hour with Incus at hand. No repo changes; the follow-up issue turns these probes into `tests/isolation.sh`.
dan-claude-bot commented 2026-07-13 21:31:43 +00:00 (Migrated from github.com)

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.

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.
dan-claude-bot commented 2026-07-13 22:05:57 +00:00 (Migrated from github.com)

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:

Probe Result
A1/A5 — public egress + gateway DNS PASS
A2 — box → host gateway listener blocked (curl timeout — dropped, not refused)
A2 — box → RFC1918 dropped
A3 — sibling isolation NOT YET PROBED — eth0_ip raced DHCP (drill bug, fixed in #19)
A4 — DNS enumeration LEAKS, as #12 predicted: getent hosts peer from a sibling returned 10.87.0.8 peer (plus a link-local AAAA). #16's dns.mode=none earns its place.
A6 — IPv6 ipv6.address = none
A7 — inbound host→box NOT YET PROBED (the run hung here — exec-pty bug, fixed in #19)
B2 — incus copy preserves user.* YES#17's metadata-stamp design holds
B4 — config get unset key empty + exit 0#17 fallbacks must use ${var:-}, never ||
B1 / B3 / B5 pending — phase D never ran

Also live-confirmed along the way: the tag read, list filter include/exclude, CSV shapes, RUNNING state 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:

  1. claude --version failed inside the box while gh passed — either claudebox exec'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.
  2. The cold mint completed in 111s — far under the ~10 min the docs expect for docker + node + Claude installs. Fast hardware, or cloud-init status --wait returning before runcmd truly finished. The same diagnosis output will tell.
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: | Probe | Result | | --- | --- | | A1/A5 — public egress + gateway DNS | **PASS** | | A2 — box → host gateway listener | **blocked** (curl timeout — dropped, not refused) | | A2 — box → RFC1918 | **dropped** | | A3 — sibling isolation | **NOT YET PROBED** — eth0_ip raced DHCP (drill bug, fixed in #19) | | A4 — DNS enumeration | **LEAKS, as #12 predicted**: `getent hosts peer` from a sibling returned `10.87.0.8 peer` (plus a link-local AAAA). #16's `dns.mode=none` earns its place. | | A6 — IPv6 | `ipv6.address = none` ✓ | | A7 — inbound host→box | **NOT YET PROBED** (the run hung here — exec-pty bug, fixed in #19) | | B2 — `incus copy` preserves `user.*` | **YES** — #17's metadata-stamp design holds | | B4 — `config get` unset key | **empty + exit 0** — #17 fallbacks must use `${var:-}`, never `\|\|` | | B1 / B3 / B5 | pending — phase D never ran | Also live-confirmed along the way: the tag read, list filter include/exclude, CSV shapes, `RUNNING` state 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: 1. `claude --version` failed inside the box while `gh` passed — either claudebox `exec`'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. 2. The cold mint completed in **111s** — far under the ~10 min the docs expect for docker + node + Claude installs. Fast hardware, or `cloud-init status --wait` returning before runcmd truly finished. The same diagnosis output will tell.
dan-claude-bot commented 2026-07-13 22:23:34 +00:00 (Migrated from github.com)

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)

Probe Result
A1/A5 egress + public DNS PASS
A2 box→host blocked
A2 RFC1918 dropped
A3 sibling still not probed — drill cascade (fixed #20)
A4 DNS enumeration LEAKS — sibling peer resolves from another box
A6 IPv6 none
A7 inbound host→box dropped ✓ (new this run)
B2 incus copy preserves user.* YES
B4 config get unset key empty + exit 0
B1 @internal on bridge ACL REJECTEDUnsupported nftables subject "@internal"#16 derives the subnet
B3 dns.mode=none stops enumeration, but BREAKS all box DNS — design veto
B5 L2 filtering box networking intact; in-box docker unverified (diag added in #20)

The two real findings

  1. 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 behind exec never reads (box login shell is zsh, so even sudo -i misses it). This is the help's own advertised example. Fixed in #20 via a /usr/local/bin symlink.
  2. Cold mint is ~95s, not ~10 min. Consistent across both runs. Either the host is fast or cloud-init status --wait returns before runcmd finishes — the latter would mean boxes are handed over before Claude/docker finish installing. #20 adds a cloud-init status readout to settle it on run 3; worth its own issue if it's the race.
## 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) | Probe | Result | | --- | --- | | A1/A5 egress + public DNS | **PASS** | | A2 box→host | **blocked** | | A2 RFC1918 | **dropped** | | A3 sibling | ⏳ **still not probed** — drill cascade (fixed #20) | | A4 DNS enumeration | **LEAKS** — sibling `peer` resolves from another box | | A6 IPv6 | `none` ✓ | | **A7 inbound host→box** | **dropped** ✓ (new this run) | | B2 `incus copy` preserves `user.*` | **YES** | | B4 `config get` unset key | **empty + exit 0** | | B1 `@internal` on bridge ACL | **REJECTED** — `Unsupported nftables subject "@internal"` ⇒ #16 derives the subnet | | B3 `dns.mode=none` | **stops enumeration, but BREAKS all box DNS** — design veto | | B5 L2 filtering | box networking **intact**; in-box docker unverified (diag added in #20) | ### The two real findings 1. **`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 behind `exec` never reads (box login shell is zsh, so even `sudo -i` misses it). This is the help's own advertised example. Fixed in #20 via a `/usr/local/bin` symlink. 2. **Cold mint is ~95s, not ~10 min.** Consistent across both runs. Either the host is fast or `cloud-init status --wait` returns before `runcmd` finishes — the latter would mean boxes are handed over before Claude/docker finish installing. #20 adds a `cloud-init status` readout to settle it on run 3; worth its own issue if it's the race.
dan-claude-bot commented 2026-07-14 01:24:33 +00:00 (Migrated from github.com)

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

probing archive (10.87.0.33) → peer (10.87.0.183), no listener:
  refused means it arrived, timeout means it was dropped
FAIL  BOX A REACHES BOX B (10.87.0.183) — sibling isolation does NOT hold [tcp: refused]

refused is 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, claudenet is 10.87.0.0/2410.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:

Boxes are already isolated from each other, but by accident
Boxes are not isolated from each other at all.

#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

Probe Answer
A1/A5 egress + public DNS PASS
A2 box → host dropped
A2 box → RFC1918 dropped
A3 sibling 🔴 FAIL — tcp refused (the packet arrived). #16 is a FIX
A4 DNS enumeration LEAKS — a box resolves its sibling by name and address
A6 IPv6 none ✓ (contract holds)
A7 inbound host → box dropped
B1 @internal on a bridge ACL REJECTEDUnsupported nftables subject "@internal"#16 must derive the subnet (mask the gateway CIDR)
B2 incus copy preserves user.* YES#17's metadata-stamp design holds
B3 dns.mode=none VIABLE — closes the enumeration leak and public egress survives, immediately, no outage window
B4 config get on unset key empty + exit 0#17 must use ${var:-}, never ||
B5 L2 filtering 🔴 BREAKS the box's networking — design veto (see below)

🔴 B5 — security.ipv4_filtering breaks the box. This one is real.

FAIL  mac+ipv4 filtering BROKE the box's networking
NOTE  dockerd is up under filtering but could not pull/run a container — check egress from docker0

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_filtering permits only the instance's own assigned address as a source, and in-box Docker's 172.17.x traffic does not survive that. Needs splitting: mac_filtering alone may well be fine; ipv4_filtering needs a way to accommodate the box's own container NAT, or it is out.

B3 — the flip-flop, resolved

Run 2 said dns.mode=none broke 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=none is viable for proposal 3.

What #16 should now be

  1. Sibling drop — mandatory, and it is a FIX. @internal is unsupported on bridge ACLs, so derive the subnet in setup-host.sh by masking the gateway CIDR (incus network get claudenet ipv4.address10.87.0.1/2410.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 needs security.acls behaviour that does, or an ebtables/bridge-family rule, or per-box networks after all. The test must prove refused becomes timeout.
  2. dns.mode=none — viable, confirmed, closes the enumeration leak with no egress cost.
  3. L2 filteringipv4_filtering vetoed as written; investigate mac_filtering alone.
  4. IPv6-off as contract — already true; state it.
  5. 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:

  • 🔴 boxes can reach each other (this issue — the contract is false)
  • 🔴 clones inherit their source's machine-id → same DHCP lease → two boxes, one IP (#27, #30)
  • 🔴 setup-host.sh deadlocks the Incus daemon when re-run while boxes are running (#26)
  • claudebox exec box -- claude … — the help's own example — could not work: ~/.local/bin is not on the non-interactive PATH (#20)
  • setup-host.sh died on a stock Debian 13 cloud image — it calls nft, which is not installed
  • dir storage makes every clone a full multi-GB disk copy, when cloning is the entire product (#29)
  • a failed cold mint printed 400 dots and the word error, with no way to learn why (#34)
  • tailnet names resolve from inside a box on a Tailscale host (#33)
  • a network Incus reports as Created may have nothing serving it — no dnsmasq, no leases, and every mint dies inscrutably (#35)

A stubbed test suite would have found none of these.

# 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 ``` probing archive (10.87.0.33) → peer (10.87.0.183), no listener: refused means it arrived, timeout means it was dropped FAIL BOX A REACHES BOX B (10.87.0.183) — sibling isolation does NOT hold [tcp: refused] ``` `refused` is 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](https://github.com/heavy-duty/claudebox/issues/12) claimed the opposite**, from a code reading: the ACL drops egress to `10.0.0.0/8`, `claudenet` is `10.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](https://github.com/heavy-duty/claudebox/issues/16): > ~~Boxes are already isolated from each other, but *by accident*~~ > **Boxes are not isolated from each other at all.** #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 | Probe | Answer | | --- | --- | | A1/A5 egress + public DNS | **PASS** | | A2 box → host | **dropped** ✓ | | A2 box → RFC1918 | **dropped** ✓ | | **A3 sibling** | 🔴 **FAIL — tcp refused (the packet arrived).** #16 is a FIX | | A4 DNS enumeration | **LEAKS** — a box resolves its sibling by name and address | | A6 IPv6 | `none` ✓ (contract holds) | | A7 inbound host → box | **dropped** ✓ | | B1 `@internal` on a bridge ACL | **REJECTED** — `Unsupported nftables subject "@internal"` ⇒ #16 must **derive the subnet** (mask the gateway CIDR) | | B2 `incus copy` preserves `user.*` | **YES** ⇒ #17's metadata-stamp design holds | | B3 `dns.mode=none` | **VIABLE** — closes the enumeration leak **and** public egress survives, immediately, no outage window | | B4 `config get` on unset key | **empty + exit 0** ⇒ #17 must use `${var:-}`, never `\|\|` | | B5 L2 filtering | 🔴 **BREAKS the box's networking — design veto** (see below) | ## 🔴 B5 — `security.ipv4_filtering` breaks the box. This one is real. ``` FAIL mac+ipv4 filtering BROKE the box's networking NOTE dockerd is up under filtering but could not pull/run a container — check egress from docker0 ``` **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_filtering` permits only the instance's own assigned address as a source, and in-box Docker's `172.17.x` traffic does not survive that. Needs splitting: `mac_filtering` alone may well be fine; `ipv4_filtering` needs a way to accommodate the box's own container NAT, or it is out. ## B3 — the flip-flop, resolved Run 2 said `dns.mode=none` broke 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=none` is viable for proposal 3. ## What #16 should now be 1. **Sibling drop — mandatory, and it is a FIX.** `@internal` is unsupported on bridge ACLs, so derive the subnet in `setup-host.sh` by 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 needs `security.acls` behaviour that does, or an ebtables/bridge-family rule, or per-box networks after all. **The test must prove `refused` becomes `timeout`.** 2. **`dns.mode=none`** — viable, confirmed, closes the enumeration leak with no egress cost. 3. **L2 filtering** — `ipv4_filtering` **vetoed as written**; investigate `mac_filtering` alone. 4. **IPv6-off as contract** — already true; state it. 5. **`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`](../blob/main/drill/RUNS.md) (eleven traps, each one paid for). Bugs found in **claudebox itself**, none of which the audit was looking for: - 🔴 **boxes can reach each other** (this issue — the contract is false) - 🔴 **clones inherit their source's machine-id** → same DHCP lease → **two boxes, one IP** (#27, #30) - 🔴 **`setup-host.sh` deadlocks the Incus daemon** when re-run while boxes are running (#26) - **`claudebox exec box -- claude …`** — the help's own example — could not work: `~/.local/bin` is not on the non-interactive PATH (#20) - **`setup-host.sh` died on a stock Debian 13 cloud image** — it calls `nft`, which is not installed - **`dir` storage** makes every clone a full multi-GB disk copy, when cloning is the entire product (#29) - **a failed cold mint printed 400 dots and the word `error`**, with no way to learn why (#34) - **tailnet names resolve from inside a box** on a Tailscale host (#33) - **a network Incus reports as `Created` may have nothing serving it** — no dnsmasq, no leases, and every mint dies inscrutably (#35) A stubbed test suite would have found **none** of these.
dan-claude-bot commented 2026-07-14 12:10:23 +00:00 (Migrated from github.com)

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:

B4 config-get unset key: empty + exit 0 — #17 fallbacks must use ${var:-}, never ||
B2 copy preserves user.*: YES — #17's metadata-stamp design holds
A1/A5 egress + public DNS: PASS
A2 box→host: dropped
A2 RFC1918: dropped
A3 sibling: BLOCKED — tcp dropped + no icmp reply (security.port_isolation)
A4 dns enumeration: blocked (dns.mode=none)
A6 ipv6: none, as contract requires
A7 inbound host→box: dropped

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

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: ``` B4 config-get unset key: empty + exit 0 — #17 fallbacks must use ${var:-}, never || B2 copy preserves user.*: YES — #17's metadata-stamp design holds A1/A5 egress + public DNS: PASS A2 box→host: dropped A2 RFC1918: dropped A3 sibling: BLOCKED — tcp dropped + no icmp reply (security.port_isolation) A4 dns enumeration: blocked (dns.mode=none) A6 ipv6: none, as contract requires A7 inbound host→box: dropped ``` 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](https://claude.com/claude-code)
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: heavy-duty/box#15
No description provided.