test: fold the #15 audit into the drill — sibling probes and a hardening rehearsal

The drill covered the CLI surface and basic egress but never probed the
one claim everything hinges on: it only ever had one box running, so
sibling isolation — #12's central, never-reproduced finding — went
untested. Phase C now clones a peer and probes box-to-box reachability
(with a listener, so "refused" cannot masquerade as "dropped"), DNS
enumeration, IPv6-off, and host-to-box inbound. A new phase D applies
the exact changes #16 proposes (dns.mode=none, NIC filtering, @internal)
and watches what breaks — a FAIL there is a design veto caught before
the code is written. The run ends with an answers block to paste into
issue #15.

Also: defaults now install heavy-duty/claudebox@main (the old default
pointed at a fork branch that stopped moving when #13 merged), the
version check compares against the installed VERSION file instead of a
hardcoded 0.3.0, and the blind sleep after start became an agent poll.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
claude-hdb 2026-07-13 21:30:47 +00:00
parent 69eff180d4
commit aba7015e53
2 changed files with 230 additions and 33 deletions

View file

@ -1,23 +1,29 @@
# The drill # The drill
An end-to-end rehearsal of claudebox against a **real** Incus: install the CLI, An end-to-end rehearsal of claudebox against a **real** Incus: install the CLI,
set up the host, mint a box, drive the whole surface, and check that the set up the host, mint boxes, drive the whole surface, check that the isolation
isolation actually holds. actually holds — and run the full
[#15 audit](https://github.com/heavy-duty/claudebox/issues/15), including a live
rehearsal of the hardening
[#16](https://github.com/heavy-duty/claudebox/issues/16) proposes. It ends with
a block of audit answers to paste into #15.
> ⚠ **It rearranges the host it runs on.** Incus, a systemd unit, a network, an > ⚠ **It rearranges the host it runs on.** Incus, a systemd unit, a network, an
> ACL, a profile, and rewritten firewall rules. **Run it on a machine you can > ACL, a profile, rewritten firewall rules — and, in the last phase, deliberate
> format** — a spare server, a cloud VM you'll destroy, a VM on your laptop. > mutations to the network and profile. **Run it on a machine you can format**
> a spare server, a cloud VM you'll destroy, a VM on your laptop.
> Not your workstation. > Not your workstation.
```sh ```sh
git clone https://github.com/heavy-duty/claudebox && cd claudebox git clone https://github.com/heavy-duty/claudebox && cd claudebox
bash drill/drill.sh # asks before it touches anything bash drill/drill.sh --yes # run and forget; omit --yes to be asked first
``` ```
Useful flags: `--yes` (skip the prompt), `--ref <branch>` (drill a branch rather Useful flags: `--ref <branch>` (drill a branch rather than `main`),
than the default), `--keep-boxes` (leave the boxes up to poke at). `--keep-boxes` (leave the boxes up to poke at — note the last phase's network
and profile mutations stay applied with them).
Exit 0 means every check passed. Roughly 15 minutes, most of it the cold box. Exit 0 means every check passed. Roughly 20 minutes, most of it the cold box.
## Why it exists ## Why it exists
@ -33,8 +39,10 @@ that `incus config get <inst> user.claudebox` returns `1` (this is on the path o
*every* box command — if it lies, everything fails closed); that the *every* box command — if it lies, everything fails closed); that the
`user.claudebox=1` list filter selects our instances and excludes an untagged `user.claudebox=1` list filter selects our instances and excludes an untagged
one; that `--columns nstS` gives four clean CSV fields; that the state column one; that `--columns nstS` gives four clean CSV fields; that the state column
reads `RUNNING`; that `incus rename` really does refuse a running instance; and reads `RUNNING`; that `incus rename` really does refuse a running instance; that
that snapshot-list's first CSV field is the label. snapshot-list's first CSV field is the label; that an **unset** config key reads
as empty with exit 0 (#15 B4); and that `incus copy` **preserves `user.*` keys**
(#15 B2 — the whole template-metadata design in #17 rests on it).
**B. The surface.** Mint, list, info, snapshot, clone-from-a-snapshot-of-a- **B. The surface.** Mint, list, info, snapshot, clone-from-a-snapshot-of-a-
renamed-box, rename (running must refuse, stopped must work), the escape hatch renamed-box, rename (running must refuse, stopped must work), the escape hatch
@ -45,8 +53,22 @@ and its isolation warning, the `rm` confirmation guard, and the CLI contract
claudebox did *not* mint, aims `down`, `rm` and the escape hatch at it, and claudebox did *not* mint, aims `down`, `rm` and the escape hatch at it, and
requires all three to refuse — and the instance to still be standing afterwards. requires all three to refuse — and the instance to still be standing afterwards.
**C. Isolation.** From inside a real box: public egress works; the box cannot **C. Isolation baseline (#15 section A).** From inside a real box: public egress
reach a listener on the host's claudenet gateway; RFC1918 is dropped. works; the box cannot reach a listener on the host's claudenet gateway; RFC1918
is dropped; a **sibling box is unreachable** (a listener runs on the peer so
"refused" — the packet arrived — cannot masquerade as "dropped"); whether DNS
**enumerates** the sibling is recorded (#12 predicts it leaks today; that is
audit data, not a failure); IPv6 is off; and the host cannot connect **into**
a box.
**D. Hardening rehearsal (#15 section B).** The host is disposable, so the drill
applies the exact changes #16 proposes and watches what breaks: `dns.mode=none`
(must kill sibling resolution, must not kill egress), `security.mac_filtering` +
`security.ipv4_filtering` on the NIC (the box and its in-box Docker must keep
working), and `@internal` as an ACL drop destination on a bridge network (if
accepted and egress survives, #16's sibling drop is renumber-proof by
construction; if not, #16 derives the subnet instead). A FAIL in this phase is
a **design veto** for #16, caught before the code is written.
## What it does not check ## What it does not check

View file

@ -11,21 +11,24 @@
# bash drill/drill.sh --ref main # drill a different branch of claudebox # bash drill/drill.sh --ref main # drill a different branch of claudebox
# bash drill/drill.sh --keep-boxes # leave the boxes up to poke at # bash drill/drill.sh --keep-boxes # leave the boxes up to poke at
# #
# Three phases: # Four phases:
# A. Incus semantics — the assumptions claudebox is built on, probed directly. # A. Incus semantics — the assumptions claudebox is built on, probed directly.
# These were only ever verified against a stub. # These were only ever verified against a stub.
# B. The claudebox surface — the whole CLI, end to end, including the boundary. # B. The claudebox surface — the whole CLI, end to end, including the boundary.
# C. Isolation — does the trust boundary actually hold? # C. Isolation baseline — does the trust boundary actually hold? (#15 section A)
# D. Hardening rehearsal — #16's proposed changes, applied live and re-probed
# (#15 section B). FAILs here are design vetoes, not code bugs.
# #
# Exit 0 = every check passed. # Exit 0 = every check passed. The summary ends with a block of audit answers
# to paste into heavy-duty/claudebox#15.
# #
# The file is one long 'probe && ok "..." || no "..."'. ok/no always return 0, so # The file is one long 'probe && ok "..." || no "..."'. ok/no always return 0, so
# the C-may-run-when-A-is-true trap SC2015 warns about cannot fire here. # the C-may-run-when-A-is-true trap SC2015 warns about cannot fire here.
# shellcheck disable=SC2015 # shellcheck disable=SC2015
set -uo pipefail # NOT -e: a failing check is data, not a crash set -uo pipefail # NOT -e: a failing check is data, not a crash
REPO="${CLAUDEBOX_REPO:-claude-hdb/claudebox}" REPO="${CLAUDEBOX_REPO:-heavy-duty/claudebox}"
REF="${CLAUDEBOX_REF:-refactor/command-table}" REF="${CLAUDEBOX_REF:-main}"
YES=0; KEEP=0 YES=0; KEEP=0
SELF="$(readlink -f "$0")" SELF="$(readlink -f "$0")"
@ -41,12 +44,28 @@ while [ $# -gt 0 ]; do
esac esac
done done
pass=0; fail=0; findings=() pass=0; fail=0; findings=(); audit=()
ok() { printf ' \033[32mPASS\033[0m %s\n' "$*"; pass=$((pass + 1)); } ok() { printf ' \033[32mPASS\033[0m %s\n' "$*"; pass=$((pass + 1)); }
no() { printf ' \033[31mFAIL\033[0m %s\n' "$*"; fail=$((fail + 1)); findings+=("FAIL: $*"); } no() { printf ' \033[31mFAIL\033[0m %s\n' "$*"; fail=$((fail + 1)); findings+=("FAIL: $*"); }
note() { printf ' \033[33mNOTE\033[0m %s\n' "$*"; findings+=("NOTE: $*"); } note() { printf ' \033[33mNOTE\033[0m %s\n' "$*"; findings+=("NOTE: $*"); }
inf() { printf ' %s\n' "$*"; } inf() { printf ' %s\n' "$*"; }
phase(){ printf '\n\033[1m══ %s\033[0m\n' "$*"; } phase(){ printf '\n\033[1m══ %s\033[0m\n' "$*"; }
aud() { audit+=("$*"); } # an answer for the #15 audit
wait_box() { # poll until exec answers (the VM agent can take a while), ~2 min
local b="$1" _i
for _i in $(seq 1 60); do
claudebox exec "$b" -- true >/dev/null 2>&1 && return 0
sleep 2
done
return 1
}
eth0_ip() { # the box's address on claudenet — eth0 exactly; a box running
# docker reports several addresses across quoted CSV lines
incus list "^$1\$" --format csv --columns 4 | tr -d '"' | tr ',' '\n' \
| awk '/\(eth0\)$/ { print $1; exit }'
}
# --- stage 1: consent, install, then re-enter inside the incus-admin group --- # --- stage 1: consent, install, then re-enter inside the incus-admin group ---
if [ "${IN_GROUP:-0}" != 1 ]; then if [ "${IN_GROUP:-0}" != 1 ]; then
@ -56,7 +75,8 @@ This will, ON THIS HOST ($(hostname)):
· install Incus and a systemd unit · install Incus and a systemd unit
· create a network (claudenet), an ACL, and a profile · create a network (claudenet), an ACL, and a profile
· rewrite firewall rules (nft or UFW, and Docker's DOCKER-USER chain) · rewrite firewall rules (nft or UFW, and Docker's DOCKER-USER chain)
· create and destroy instances named: drill, clone, archive, payroll, cbprobe · create and destroy instances named: drill, clone, archive, peer, payroll, cbprobe, cbcopy
· mutate the network and profile mid-run to rehearse the #16 hardening
Only do this on a machine you can format. Only do this on a machine you can format.
EOF EOF
[ -t 0 ] || { echo "drill: no TTY to confirm on — pass --yes if you mean it." >&2; exit 2; } [ -t 0 ] || { echo "drill: no TTY to confirm on — pass --yes if you mean it." >&2; exit 2; }
@ -72,11 +92,11 @@ EOF
export PATH="$HOME/.local/bin:$PATH" export PATH="$HOME/.local/bin:$PATH"
phase "Host setup (Incus, claudenet, ACL, profile, firewall)" phase "Host setup (Incus, claudenet, ACL, profile, firewall)"
# setup-host.sh drives nft directly, but a stock Debian cloud image has no # setup-host.sh installs nftables itself when neither nft nor UFW exists
# nftables. If that's the case here, it is a real gap in the repo: record it. # (fixed in this PR — a stock Debian 13 cloud image ships neither). This
# guard stays as a tripwire: if it fires, that fix regressed.
if ! command -v nft >/dev/null 2>&1 && ! command -v ufw >/dev/null 2>&1; then if ! command -v nft >/dev/null 2>&1 && ! command -v ufw >/dev/null 2>&1; then
note "setup-host.sh needs 'nft' (no UFW here either) but nftables is NOT installed — installing it by hand to proceed. Repo gap: setup-host should install its own dependency." note "neither nft nor ufw present pre-setup — setup-host.sh must install nftables itself (it fixed this once; watch that it still does)"
sudo apt-get install -y -qq nftables >/dev/null 2>&1
fi fi
sudo apt-get install -y -qq incus >/dev/null 2>&1 # so the group exists before we sg sudo apt-get install -y -qq incus >/dev/null 2>&1 # so the group exists before we sg
~/.local/share/claudebox/host/setup-host.sh || true # first run may only add the group ~/.local/share/claudebox/host/setup-host.sh || true # first run may only add the group
@ -89,7 +109,10 @@ KEEP="${KEEP:-0}"
~/.local/share/claudebox/host/setup-host.sh || { echo "setup-host failed inside the group"; exit 1; } ~/.local/share/claudebox/host/setup-host.sh || { echo "setup-host failed inside the group"; exit 1; }
# Re-runnable: clear anything a previous drill left behind. # Re-runnable: clear anything a previous drill left behind.
incus delete -f drill clone archive payroll cbprobe cbnotours >/dev/null 2>&1 incus delete -f drill clone archive peer payroll cbprobe cbcopy cbnotours >/dev/null 2>&1
incus network unset claudenet dns.mode 2>/dev/null
incus profile device unset claude-dev eth0 security.mac_filtering 2>/dev/null
incus profile device unset claude-dev eth0 security.ipv4_filtering 2>/dev/null
# A real server has room for the production profile (8GiB/4cpu), and drilling the # A real server has room for the production profile (8GiB/4cpu), and drilling the
# real profile is worth more than drilling a shrunken one. Only shrink if we must. # real profile is worth more than drilling a shrunken one. Only shrink if we must.
@ -150,13 +173,42 @@ s1="$(incus snapshot list cbprobe --format csv 2>&1 | head -1)"
# A7 — the IPv4 column. #9 assumes it can be quoted/multi-line, hence fetching it apart. # A7 — the IPv4 column. #9 assumes it can be quoted/multi-line, hence fetching it apart.
inf "ipv4 column raw: $(incus list cbprobe --format csv --columns 4 2>&1 | tr '\n' '|')" inf "ipv4 column raw: $(incus list cbprobe --format csv --columns 4 2>&1 | tr '\n' '|')"
# A8 — unset config keys read as EMPTY with exit 0 (#15 B4). The '|| echo root'
# fallback #12 first proposed could never fire if so; #17's lookup depends on this.
u="$(incus config get cbprobe user.never-set 2>&1)"; rc=$?
if [ "$rc" -eq 0 ] && [ -z "$u" ]; then
ok "config get on an unset key → empty string, exit 0"
aud "B4 config-get unset key: empty + exit 0 — #17 fallbacks must use \${var:-}, never ||"
else
note "config get on an unset key → rc=$rc out='$u' (not the documented empty+0 — #17's lookup adapts)"
aud "B4 config-get unset key: rc=$rc out='$u'"
fi
# A9 — 'incus copy' preserves user.* keys (#15 B2). #17's whole metadata design:
# a clone must still know what it is without consulting the template.
incus config set cbprobe user.box.user claude 2>/dev/null
incus stop -f cbprobe >/dev/null 2>&1
incus copy cbprobe cbcopy >/dev/null 2>&1
c="$(incus config get cbcopy user.box.user 2>/dev/null)"
if [ "$c" = claude ]; then
ok "incus copy preserves user.* keys (a clone knows what it is)"
aud "B2 copy preserves user.*: YES — #17's metadata-stamp design holds"
else
no "incus copy DROPPED user.* keys (got '$c') — #17's metadata design fails without them"
aud "B2 copy preserves user.*: NO — #17 blocked as designed"
fi
incus delete -f cbcopy >/dev/null 2>&1
incus delete -f cbprobe cbnotours >/dev/null 2>&1 incus delete -f cbprobe cbnotours >/dev/null 2>&1
# =========================================================================== # ===========================================================================
phase "B. The claudebox surface" phase "B. The claudebox surface"
# =========================================================================== # ===========================================================================
# Compare against the installed tree's VERSION file, not a hardcoded number —
# a pinned literal here would fail the drill on every release.
expected="$(cat "$HOME/.local/share/claudebox/VERSION" 2>/dev/null || echo '?')"
v="$(claudebox --version 2>&1)" v="$(claudebox --version 2>&1)"
case "$v" in *0.3.0*) ok "claudebox --version → $v" ;; *) no "unexpected version: $v" ;; esac case "$v" in *"$expected"*) ok "claudebox --version → $v" ;; *) no "version mismatch: CLI says '$v', VERSION file says '$expected'" ;; esac
claudebox list >/dev/null 2>&1 && claudebox list 2>&1 | grep -q 'no boxes yet' \ claudebox list >/dev/null 2>&1 && claudebox list 2>&1 | grep -q 'no boxes yet' \
&& ok "empty host: 'no boxes yet', exit 0" || no "empty-host message wrong" && ok "empty host: 'no boxes yet', exit 0" || no "empty-host message wrong"
@ -232,34 +284,150 @@ claudebox list archive 2>&1 | grep -q 'claudebox info archive' && ok "'list <box
claudebox snapshot archive --labl x 2>&1 | grep -q 'unknown option' && ok "typo'd flag rejected (not swallowed as a label)" || no "unknown flag was swallowed" claudebox snapshot archive --labl x 2>&1 | grep -q 'unknown option' && ok "typo'd flag rejected (not swallowed as a label)" || no "unknown flag was swallowed"
# =========================================================================== # ===========================================================================
phase "C. Isolation — does the boundary actually hold?" phase "C. Isolation baseline — does the boundary actually hold? (#15 section A)"
# =========================================================================== # ===========================================================================
claudebox start archive >/dev/null 2>&1 claudebox start archive >/dev/null 2>&1
sleep 10 wait_box archive && ok "archive is back up (agent answering)" \
|| no "archive did not come back within 2 min of start"
# Sibling isolation needs a sibling. Clone from the snapshot — fast, no cold mint.
printf '\n cloning a peer for the sibling probes…\n'
if claudebox new --name peer --from archive/authed >/tmp/peer.log 2>&1 && wait_box peer; then
ok "peer minted from archive/authed and answering"
else
no "peer clone failed or never answered — tail: $(tail -3 /tmp/peer.log | tr '\n' ' ')"
fi
# C1 — public egress (#15 A1; resolving the hostname also proves A5, gateway DNS)
claudebox exec archive -- curl -sS -m 20 -o /dev/null -w '%{http_code}' https://api.github.com 2>/dev/null | grep -q 200 \ claudebox exec archive -- curl -sS -m 20 -o /dev/null -w '%{http_code}' https://api.github.com 2>/dev/null | grep -q 200 \
&& ok "box reaches the public internet" || no "box cannot reach the internet (a box that can't is useless)" && { ok "box reaches the public internet (and gateway DNS resolves public names)"; aud "A1/A5 egress + public DNS: PASS"; } \
|| { no "box cannot reach the internet (a box that can't is useless)"; aud "A1/A5 egress: FAIL"; }
# The host listens on the claudenet gateway; the box must NOT be able to reach it. # C2 — box → host (#15 A2): the host listens on the claudenet gateway
python3 -m http.server 8099 --bind 10.87.0.1 >/dev/null 2>&1 & python3 -m http.server 8099 --bind 10.87.0.1 >/dev/null 2>&1 &
srv=$! srv=$!
sleep 2 sleep 2
if claudebox exec archive -- curl -sS -m 5 -o /dev/null http://10.87.0.1:8099 2>/dev/null; then if claudebox exec archive -- curl -sS -m 5 -o /dev/null http://10.87.0.1:8099 2>/dev/null; then
no "THE BOX REACHED THE HOST on 10.87.0.1:8099 — the firewall rules are not holding" no "THE BOX REACHED THE HOST on 10.87.0.1:8099 — the firewall rules are not holding"
aud "A2 box→host: FAIL — reached a gateway listener"
else else
ok "box → host is blocked (no inbound path to the machine)" ok "box → host is blocked (no path to the machine's sockets)"
aud "A2 box→host: blocked"
fi fi
kill $srv 2>/dev/null kill $srv 2>/dev/null
# C3 — RFC1918 (#15 A2)
claudebox exec archive -- curl -sS -m 5 -o /dev/null http://192.168.1.1 2>/dev/null \ claudebox exec archive -- curl -sS -m 5 -o /dev/null http://192.168.1.1 2>/dev/null \
&& no "box reached a private-range address — the ACL is not dropping RFC1918" \ && { no "box reached a private-range address — the ACL is not dropping RFC1918"; aud "A2 RFC1918: FAIL"; } \
|| ok "box → RFC1918 is dropped by the ACL" || { ok "box → RFC1918 is dropped by the ACL"; aud "A2 RFC1918: dropped"; }
# C4 — SIBLING isolation (#15 A3): the central claim of #12, never reproduced
# live. A listener runs on peer so the curl exit code is unambiguous:
# 0 = connected (isolation broken) · 7 = refused (the packet ARRIVED — the
# egress drop is not covering siblings) · timeout = dropped, as designed.
PEER_IP="$(eth0_ip peer)"
if [ -n "$PEER_IP" ]; then
claudebox exec peer -- bash -c 'command -v python3 >/dev/null && nohup python3 -m http.server 8088 --bind 0.0.0.0 >/dev/null 2>&1 & sleep 1' >/dev/null 2>&1
claudebox exec archive -- curl -sS -m 5 -o /dev/null "http://$PEER_IP:8088" 2>/dev/null
rc=$?
case "$rc" in
0) no "BOX A CONNECTED TO BOX B ($PEER_IP:8088) — sibling isolation does not hold"
aud "A3 sibling: FAIL — connected. #16 is a FIX, not a formalization" ;;
7) no "box A's packets ARRIVE at box B (connection refused, not dropped)"
aud "A3 sibling: FAIL — refused means the packet arrived. #16 is a FIX" ;;
*) ok "box A cannot reach box B (drop — curl exit $rc)"
aud "A3 sibling: blocked (the incidental 10.0.0.0/8 drop covers it, as #12 read)" ;;
esac
else
no "could not read peer's eth0 address — the sibling probe never ran"
aud "A3 sibling: NOT PROBED (no eth0 address on peer)"
fi
# C5 — DNS enumeration (#15 A4): #12 predicts this LEAKS today. Either way it
# is audit data, not a code failure — #16's dns.mode=none is the fix.
e1="$(claudebox exec archive -- getent hosts peer 2>/dev/null)"
e2="$(claudebox exec archive -- getent hosts peer.incus 2>/dev/null)"
if [ -n "$e1$e2" ]; then
note "DNS enumeration leaks, as #12 predicted: a box resolves its sibling ($(printf '%s' "$e1$e2" | head -1 | cut -c1-50))"
aud "A4 dns enumeration: LEAKS (bare='${e1:+yes}' fqdn='${e2:+yes}') — #16's dns.mode=none earns its place"
else
note "DNS enumeration did NOT leak — #16's dns.mode item shrinks to an assertion"
aud "A4 dns enumeration: no leak observed"
fi
# C6 — IPv6 off (#15 A6): every ACL rule is IPv4-only; off is the only cover.
[ "$(incus network get claudenet ipv6.address 2>/dev/null)" = none ] \
&& { ok "claudenet ipv6.address = none (the IPv4-only ACLs have no uncovered path)"; aud "A6 ipv6: none, as contract requires"; } \
|| { no "claudenet has IPv6 enabled — and not one ACL rule covers IPv6"; aud "A6 ipv6: ENABLED and uncovered"; }
# C7 — inbound, host → box (#15 A7): the ACL's default ingress drop
ARCH_IP="$(eth0_ip archive)"
claudebox exec archive -- bash -c 'command -v python3 >/dev/null && nohup python3 -m http.server 8087 --bind 0.0.0.0 >/dev/null 2>&1 & sleep 1' >/dev/null 2>&1
if [ -n "$ARCH_IP" ] && curl -sS -m 5 -o /dev/null "http://$ARCH_IP:8087" 2>/dev/null; then
no "the HOST connected to a listener inside the box — the default ingress drop is not holding"
aud "A7 inbound host→box: FAIL — reached a box listener"
else
ok "host → box is dropped (entry is 'incus exec' only, as designed)"
aud "A7 inbound host→box: dropped"
fi
# ===========================================================================
phase "D. Hardening rehearsal — #16's changes, applied live (#15 section B)"
# ===========================================================================
# The host is disposable, so rehearse the exact changes #16 proposes and watch
# what breaks. A FAIL here vetoes a piece of #16's design before it is written.
# D1 — dns.mode=none (#15 B3): must kill sibling resolution, must NOT kill egress
if incus network set claudenet dns.mode=none 2>/dev/null; then
sleep 2
claudebox exec archive -- getent hosts peer.incus >/dev/null 2>&1 \
&& { no "dns.mode=none did not stop sibling resolution"; aud "B3 dns.mode=none: does NOT close the leak"; } \
|| { ok "dns.mode=none: sibling names no longer resolve"; aud "B3 dns.mode=none: closes the enumeration leak"; }
claudebox exec archive -- curl -sS -m 20 -o /dev/null https://api.github.com 2>/dev/null \
&& { ok "dns.mode=none: public egress still works"; aud "B3 egress under dns.mode=none: intact — safe to ship"; } \
|| { no "dns.mode=none BROKE public DNS — #16 cannot ship it as-is"; aud "B3 egress under dns.mode=none: BROKEN — design veto"; }
else
no "incus rejected dns.mode=none on claudenet"
aud "B3 dns.mode=none: REJECTED by incus — #16 needs another mechanism"
fi
# D2 — L2 filtering (#15 B5): the box must keep working with it on.
# Filtering binds at NIC attach, so the boxes restart here.
if incus profile device set claude-dev eth0 security.mac_filtering=true security.ipv4_filtering=true 2>/dev/null; then
incus restart -f archive peer >/dev/null 2>&1
wait_box archive || note "archive slow to return after the filtering restart"
claudebox exec archive -- curl -sS -m 20 -o /dev/null https://api.github.com 2>/dev/null \
&& { ok "mac+ipv4 filtering on: the box still reaches the internet"; aud "B5 L2 filtering: box networking intact — safe for the claude workload"; } \
|| { no "mac+ipv4 filtering BROKE the box's networking"; aud "B5 L2 filtering: BREAKS the box — design veto"; }
claudebox exec archive -- docker info >/dev/null 2>&1 \
&& { ok "…and in-box Docker still works under ipv4_filtering"; aud "B5 in-box docker under filtering: fine (NAT hides behind eth0, as #12 argued)"; } \
|| { note "in-box docker not confirmed under filtering ('docker info' failed — may be container-mode)"; aud "B5 in-box docker under filtering: UNVERIFIED here"; }
else
no "incus rejected security filtering on the profile NIC"
aud "B5 L2 filtering: REJECTED on a profile NIC"
fi
# D3 — @internal as an ACL destination on a bridge network (#15 B1). If it
# holds AND egress survives (the gateway carve-out must win the ordering),
# #16's sibling drop is renumber-proof by construction.
if incus network acl rule add claude-isolate egress action=drop destination=@internal 2>/tmp/ai.err; then
ok "@internal accepted as an ACL destination on a bridge network"
claudebox exec archive -- curl -sS -m 20 -o /dev/null https://api.github.com 2>/dev/null \
&& { ok "…and public egress survives the @internal drop (the carve-out wins)"; aud "B1 @internal: accepted, egress survives ⇒ #16 uses @internal"; } \
|| { no "the @internal drop killed egress/DNS — ordering does not protect the carve-out"; aud "B1 @internal: accepted but kills DNS ⇒ #16 derives the subnet instead"; }
incus network acl rule remove claude-isolate egress action=drop destination=@internal >/dev/null 2>&1
else
note "@internal rejected on a bridge ACL ($(head -1 /tmp/ai.err 2>/dev/null | cut -c1-60))"
aud "B1 @internal: rejected ⇒ #16 derives the subnet in setup-host.sh (mask the gateway CIDR)"
fi
# =========================================================================== # ===========================================================================
if [ "$KEEP" = 1 ]; then if [ "$KEEP" = 1 ]; then
phase "Boxes left up (--keep-boxes)" phase "Boxes left up (--keep-boxes)"
claudebox list claudebox list
inf "note: the D-phase mutations (dns.mode=none, NIC filtering) are still applied"
else else
claudebox rm peer --force >/dev/null 2>&1
claudebox rm archive --force >/dev/null 2>&1 claudebox rm archive --force >/dev/null 2>&1
claudebox list 2>&1 | grep -q 'no boxes yet' && ok "teardown: no boxes left" || no "a box survived teardown" claudebox list 2>&1 | grep -q 'no boxes yet' && ok "teardown: no boxes left" || no "a box survived teardown"
fi fi
@ -270,7 +438,14 @@ if [ "${#findings[@]}" -gt 0 ]; then
echo echo
printf ' %s\n' "${findings[@]}" printf ' %s\n' "${findings[@]}"
fi fi
if [ "${#audit[@]}" -gt 0 ]; then
phase "#15 audit answers — paste this block into heavy-duty/claudebox#15"
printf ' %s\n' "${audit[@]}"
fi
echo echo
inf "this host still has Incus, claudenet, the ACL, the profile and the firewall rules." inf "this host still has Incus, claudenet, the ACL, the profile and the firewall rules"
inf "(plus, unless re-run: dns.mode=none and NIC filtering from the D phase)."
inf "to undo: ~/.local/share/claudebox/host/teardown-host.sh [--purge-incus]" inf "to undo: ~/.local/share/claudebox/host/teardown-host.sh [--purge-incus]"
[ "$fail" -eq 0 ] [ "$fail" -eq 0 ]