From bd849181bd459561d8bc8254851c36fd071c4a5b Mon Sep 17 00:00:00 2001 From: claude-hdb Date: Tue, 14 Jul 2026 01:40:02 +0000 Subject: [PATCH 1/2] fix: the firewall unit never re-ran, so new rules were never applied MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The box-to-box drop shipped, the drill still found boxes reaching each other, and the rule was simply not on the host. setup-host.sh ended with 'systemctl enable --now claudebox-firewall.service' — but the unit is RemainAfterExit, so once it has run it stays "active" forever, and '--now' does nothing to an active unit. Re-running setup-host after upgrading claudebox therefore installed the new script to /usr/local/sbin and never executed it. The host silently kept its old firewall, and the box-to-box hole stayed open through the release that claimed to close it. This is worse than the original bug: every future firewall change would have landed only on hosts that had never run setup-host before. Restart the unit instead — the script is idempotent by design. Then ASSERT the rule is live rather than assume it, because the absence of this particular rule is invisible: everything keeps working and boxes can simply reach each other. doctor.sh checks it too. Also: dns.mode=none is now part of the shipped stack, so the drill must stop treating it as leftover rehearsal dirt and reverting it. Co-Authored-By: Claude Fable 5 --- drill/doctor.sh | 11 +++++++++++ drill/drill.sh | 10 ++++------ host/setup-host.sh | 18 +++++++++++++++++- 3 files changed, 32 insertions(+), 7 deletions(-) diff --git a/drill/doctor.sh b/drill/doctor.sh index 34769cc..9f8ba33 100755 --- a/drill/doctor.sh +++ b/drill/doctor.sh @@ -78,6 +78,17 @@ else inf "claudenet does not exist (a fresh host — setup-host.sh will create it)" fi +head_ "Firewall — the box-to-box drop" +if nft list table bridge claudebox >/dev/null 2>&1 || sudo -n nft list table bridge claudebox >/dev/null 2>&1; then + ok "nft bridge table 'claudebox' is present — boxes cannot reach each other" +else + no "the box-to-box drop is MISSING — boxes can reach each other" + inf "an L3 ACL never sees frames switched between two ports of one bridge;" + inf "the drop is an nft BRIDGE-family rule, and without it siblings are wide open." + inf "fix: sudo /usr/local/sbin/claudebox-firewall" + inf " (or: sudo systemctl restart claudebox-firewall.service)" +fi + head_ "Profile — claude-dev (the NIC is the isolation contract)" if incus profile show claude-dev >/dev/null 2>&1; then for k in security.mac_filtering security.ipv4_filtering; do diff --git a/drill/drill.sh b/drill/drill.sh index 7a0e423..49f14e9 100755 --- a/drill/drill.sh +++ b/drill/drill.sh @@ -204,18 +204,16 @@ KEEP="${KEEP:-0}" # clean-ish slate and setup-host is the no-op it should be. # A host still carrying a previous run's phase-D mutations mints boxes with no # DNS, and then reports the resulting breakage as a finding. Refuse to run. +# NOTE: dns.mode=none is now part of the SHIPPED stack (it closes the sibling +# DNS-enumeration leak), so it is no longer "dirt" from a rehearsal — do not +# revert it. Only the vetoed NIC filtering counts as leftover. dirty="" -[ -n "$(incus network get claudenet dns.mode 2>/dev/null)" ] && dirty="dns.mode" [ -n "$(incus profile device get claude-dev eth0 security.ipv4_filtering 2>/dev/null)" ] && dirty="$dirty ipv4_filtering" [ -n "$(incus profile device get claude-dev eth0 security.mac_filtering 2>/dev/null)" ] && dirty="$dirty mac_filtering" if [ -n "$dirty" ]; then - note "this host still carries a previous run's phase-D mutations:$dirty — reverting them now" - incus network unset claudenet dns.mode >/dev/null 2>&1 + note "this host carries the VETOED NIC filtering from an old rehearsal:$dirty — reverting" incus profile device unset claude-dev eth0 security.mac_filtering >/dev/null 2>&1 incus profile device unset claude-dev eth0 security.ipv4_filtering >/dev/null 2>&1 - incus network acl rule remove claude-isolate egress action=drop destination=@internal >/dev/null 2>&1 - still="$(incus network get claudenet dns.mode 2>/dev/null)" - [ -n "$still" ] && { echo "drill: could not revert dns.mode ('$still'). run: bash drill/doctor.sh --fix" >&2; exit 1; } fi inf "clearing anything a previous run left behind…" diff --git a/host/setup-host.sh b/host/setup-host.sh index 4b26ccf..12a1721 100755 --- a/host/setup-host.sh +++ b/host/setup-host.sh @@ -70,7 +70,14 @@ fi sudo install -m 755 "$here/host/claudebox-firewall.sh" /usr/local/sbin/claudebox-firewall sudo install -m 644 "$here/host/claudebox-firewall.service" /etc/systemd/system/ sudo systemctl daemon-reload -sudo systemctl enable --now claudebox-firewall.service +sudo systemctl enable claudebox-firewall.service +# RESTART, not 'enable --now'. The unit is RemainAfterExit, so once it has run +# it stays "active" forever — and 'enable --now' does nothing to an active unit. +# Re-running setup-host after upgrading claudebox therefore installed the new +# rules to /usr/local/sbin and never applied them: the host kept the old +# firewall, silently, and the box→box hole stayed open through a release that +# claimed to close it. Restart re-runs the script, which is idempotent by design. +sudo systemctl restart claudebox-firewall.service # Profile if ! incus profile show claude-dev >/dev/null 2>&1; then @@ -78,4 +85,13 @@ if ! incus profile show claude-dev >/dev/null 2>&1; then fi incus profile edit claude-dev < "$here/profiles/claude-dev.yaml" +# The sibling drop is the one rule whose absence is invisible: everything keeps +# working, and boxes can simply reach each other. Assert it landed. +if nft list table bridge claudebox >/dev/null 2>&1; then + echo "Isolation: box-to-box drop is live (nft bridge table 'claudebox')." +else + echo "WARNING: the box-to-box drop is NOT active — boxes can reach each other." >&2 + echo " check: sudo /usr/local/sbin/claudebox-firewall ; sudo nft list table bridge claudebox" >&2 +fi + echo "Host ready. Launch with: claudebox new --name " -- 2.45.2 From 3bed7c2f2af858e2df29d576a7bed33f12f6f624 Mon Sep 17 00:00:00 2001 From: claude-hdb Date: Tue, 14 Jul 2026 01:41:18 +0000 Subject: [PATCH 2/2] fix: isolate boxes with the bridge's port-isolation flag, not an nft rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The nft bridge-family rule from the previous commit is LIVE on the host and boxes still reach each other: table bridge claudebox { chain forward { ... meta ibrname "claudenet" meta obrname "claudenet" drop } } FAIL BOX A REACHES BOX B — sibling isolation does NOT hold [tcp: refused] So the rule is not wrong about intent, it is wrong about mechanism — whatever path these frames take, that hook does not stop them. Rather than reason harder about netfilter (reasoning is what put the hole there in the first place), use the mechanism Incus provides for exactly this: security.port_isolation on the bridged NIC, which sets the kernel bridge port's isolated flag so two isolated ports cannot exchange frames at all. The nft rule stays as a second layer — it costs nothing — but the profile flag is what carries the guarantee. doctor.sh checks it, because the absence of this one is invisible: everything works and boxes can simply reach each other. Co-Authored-By: Claude Fable 5 --- drill/doctor.sh | 9 +++++++++ profiles/claude-dev.yaml | 11 +++++++++++ 2 files changed, 20 insertions(+) diff --git a/drill/doctor.sh b/drill/doctor.sh index 9f8ba33..464ef59 100755 --- a/drill/doctor.sh +++ b/drill/doctor.sh @@ -91,6 +91,15 @@ fi head_ "Profile — claude-dev (the NIC is the isolation contract)" if incus profile show claude-dev >/dev/null 2>&1; then + iso="$(incus profile device get claude-dev eth0 security.port_isolation 2>/dev/null)" + if [ "$iso" = "true" ]; then + ok "security.port_isolation = true — boxes cannot reach each other at L2" + else + no "security.port_isolation is NOT set — BOXES CAN REACH EACH OTHER" + inf "an L3 ACL cannot do this: two boxes on one bridge are on the same L2" + inf "segment, so their frames are switched, never routed past the ACL." + inf "fix: re-run ~/.local/share/claudebox/host/setup-host.sh" + fi for k in security.mac_filtering security.ipv4_filtering; do v="$(incus profile device get claude-dev eth0 "$k" 2>/dev/null)" if [ -z "$v" ]; then diff --git a/profiles/claude-dev.yaml b/profiles/claude-dev.yaml index 4b4850d..1273fdd 100644 --- a/profiles/claude-dev.yaml +++ b/profiles/claude-dev.yaml @@ -8,6 +8,17 @@ devices: type: nic network: claudenet name: eth0 + # Boxes must not reach each other. This is the mechanism that actually does + # it: the kernel bridge's port-isolation flag, which stops two isolated + # ports exchanging frames at L2. + # + # It is not an ACL rule, and it cannot be. Incus ACLs are L3/L4, and two + # boxes on one bridge are on the same L2 segment — their frames are switched + # between ports and never traverse the netfilter path an ACL lives on. That + # is why the ACL's drop on 10.0.0.0/8 (which contains claudenet) and its + # default ingress drop BOTH looked airtight while box→box was wide open: a + # live probe found box A's SYN arriving at box B and B answering with a RST. + security.port_isolation: "true" root: type: disk pool: default -- 2.45.2