feat!: rename the host stack too, default to blank, drill the templates, add wipe

Follow-up to the rename, per operator direction — the divergence is
reversed and the cut is complete:

- Host stack: boxnet (10.88.0.0/24 — a pre-rename host may still carry
  claudenet on 10.87, two bridges must not claim one subnet),
  box-isolate, nft tables 'inet box'/'bridge box', box-firewall.{sh,
  service}. teardown-host now strips BOTH name generations, so one
  script uninstalls a host of any age.

- Default template is blank: 'box new --name x' mints bare Debian;
  the claude box is '--template claude'. The login hint follows the
  EFFECTIVE template read off the instance, so clones of claude boxes
  still get it and blank boxes are not told to run a binary they lack.

- The drill validates templates: listing, unknown-template refusal,
  the allowlist rejecting BOX_NETWORK by name, and a full blank mint —
  default resolves to blank, metadata stamped, box-net placement, exec
  lands in 'dev', no claude binary, and isolation parity (egress +
  pinned DNS) on the same contract as every template.

- drill/wipe.sh: scorched earth for drill hosts. Both tag generations,
  every drill-named instance, networks/ACLs/profiles/firewall of both
  generations, cached images, and (--purge-storage) the default pool.
  Ends by asserting the ABSENCE of every artifact rather than trusting
  the removals' exit codes.
This commit is contained in:
claude-hdb 2026-07-14 14:36:39 +00:00
parent c11f3d7552
commit 5defd40bca
9 changed files with 374 additions and 157 deletions

View file

@ -23,9 +23,13 @@ runbook that Claude Code reads and acts on — there is no `install` step and no
host-run setup. See [docs/claudebox-design.md](docs/claudebox-design.md) for the
design rationale.
> **0.4.0 renamed the CLI** from `claudebox` to `box` — a clean cut, no shim.
> Existing boxes minted by any earlier version keep working under every verb
> (their legacy tag is honored forever); only the old command name retired.
> **0.4.0 is a clean cut**: the CLI is `box` (no `claudebox` shim), the host
> stack is `boxnet`/`box-isolate`/`box-firewall` on 10.88.0.0/24, and the
> default template is `blank`. Existing boxes minted by any earlier version
> keep working under every verb — their legacy tag is honored forever, and
> their old `claudenet` (10.87) is left standing beside the new bridge. To
> strip a host of both generations at once: `host/teardown-host.sh`, or
> `drill/wipe.sh` for the scorched-earth version.
## Install
@ -43,19 +47,19 @@ retires the old `claudebox` symlink. (No `git clone` needed.)
~/.local/share/claudebox/host/setup-host.sh # run twice if it adds you to incus-admin (re-login between)
```
Idempotent. Installs Incus and creates the isolation stack: the `claudenet` NAT
Idempotent. Installs Incus and creates the isolation stack: the `boxnet` NAT
bridge (sibling-name resolution off, resolver pinned to public upstreams —
`BOX_DNS` overrides), the `claude-isolate` ACL (drops all RFC1918/CGNAT/
`BOX_DNS` overrides), the `box-isolate` ACL (drops all RFC1918/CGNAT/
link-local egress), the `box-net` profile (port-isolated NICs — boxes can't
reach each other), and firewall rules blocking instance → host. All rules
re-apply at boot via `claudebox-firewall.service` — no post-reboot ritual. If
re-apply at boot via `box-firewall.service` — no post-reboot ritual. If
the host lacks `dnsmasq-base` (Debian cloud images skip Recommends):
`sudo apt-get install -y dnsmasq-base`.
## Quick start
```sh
box new --name work # mint a fresh, creds-free claude box (~10 min cold)
box new --name work --template claude # a creds-free Claude box (~10 min cold)
box shell work # enter as the template's user
```
@ -77,8 +81,9 @@ allowlist, never sourced) and a `user-data.yaml` (cloud-init, passed to Incus
verbatim).
```sh
box templates # list what this install can mint
box new --name scratch --template blank # bare Debian: same isolation, no tooling
box templates # list what this install can mint
box new --name scratch # the DEFAULT template is blank: bare Debian,
# same isolation, nobody home
```
A template **cannot** name a network, a profile, or a `security.*` flag —
@ -165,10 +170,10 @@ The contract: **a box reaches the public internet and nothing else.** Not the
host, not your LAN, not another box, not even another box's *name*. What
enforces it, layer by layer:
- **Dedicated NAT bridge** `claudenet`, IPv6 off. Every rule below is
- **Dedicated NAT bridge** `boxnet`, IPv6 off. Every rule below is
IPv4-only, so IPv6 would be an uncovered path — off is part of the
contract, not a default.
- **`claude-isolate` ACL** — drops all egress to private space (RFC1918,
- **`box-isolate` ACL** — drops all egress to private space (RFC1918,
CGNAT, link-local), with a single carve-out to the gateway so DNS works.
- **Sibling isolation, at L2** — two boxes on one bridge are *switched*,
never routed, so no L3 rule can separate them (learned the hard way; see
@ -199,6 +204,8 @@ history, including every trap that fooled a run into a wrong verdict.
```sh
bash drill/doctor.sh # read-only: is this host healthy and the stack live?
bash drill/drill.sh # FULL end-to-end — mutates the host; use a machine you own
bash drill/wipe.sh # scorched earth: strip BOTH name generations, images and
# (--purge-storage) the pool, so a run starts from bare
```
The doctor reads ground truth, not config claims — the kernel's `isolated on`

42
bin/box
View file

@ -37,7 +37,7 @@ version() { echo "box $(cat "$root/VERSION" 2>/dev/null || echo unknown) ($root)
# doesn't enforce a box invariant, it is incus's job, not ours — that is
# what `box incus` is for.
CMDS=(
"new^--name <box> [--template <t>] [--from <src>[/<snap>]] [--vm|--container]^^Mint a box from a template (default: claude), or --from an existing box/snapshot^fn:cmd_new^"
"new^--name <box> [--template <t>] [--from <src>[/<snap>]] [--vm|--container]^^Mint a box from a template (default: blank), or --from an existing box/snapshot^fn:cmd_new^"
"templates^^^List the templates this install can mint^fn:cmd_templates^"
"list^[--json]^^List your boxes^fn:cmd_list^"
"info^<box> [--json]^box^One box: state, type, IP, and its snapshot labels^fn:cmd_info^"
@ -124,7 +124,7 @@ EOF
OPTIONS
--name <box> Name for the new box (new)
--template <t> Template to mint from (default: claude) (new)
--template <t> Template to mint from (default: blank) (new)
--from <src>[/<snap>] Clone from box <src>, or from its snapshot (new)
--vm Force VM mode: the trust-less target (new)
--container Force container mode: weaker isolation, (new)
@ -138,16 +138,16 @@ OPTIONS
Options come after the command: 'box list --json', not 'box --json list'.
EXAMPLES
# mint a box and log in inside it — the tool never handles your token
box new --name work
# mint a claude box and log in inside it — the tool never handles your token
box new --name work --template claude
box shell work # then: run 'claude', then /login
# log in once, reuse forever: checkpoint the authed box, clone from it
box snapshot work authed
box new --name feature --from work/authed
# a box with nobody home — same isolation, no Claude
box new --name scratch --template blank
# the default: a blank box — same isolation, nobody home
box new --name scratch
box templates
# what have I got, and what can I clone?
@ -187,10 +187,11 @@ help_cmd() {
case "$1" in
new) cat <<'EOF'
Mint a box. Without --from, launches a fresh box from a template (default:
claude — Debian 13 with Claude Code installed and NO credentials, ~10 min
cold). With --from, clones an existing box or one of its snapshots — login
state, git creds and clones carry over, isolation is preserved, and the
clone knows its template's user without being told.
blank — bare Debian 13, nobody home; --template claude gets Claude Code
installed, creds-free, ~10 min cold). With --from, clones an existing box or
one of its snapshots — login state, git creds and clones carry over,
isolation is preserved, and the clone knows its template's user without
being told.
--name <box> Required. The box's name.
--template <t> Template to mint from; 'box templates' lists them.
@ -206,8 +207,8 @@ Resources come from the template's box.env; BOX_CPU / BOX_MEMORY / BOX_DISK
environment variables override them at mint time (a small host shrinks a box
without editing a template it doesn't own).
box new --name work
box new --name scratch --template blank
box new --name scratch # blank, the default
box new --name work --template claude
box new --name feature --from work/authed
EOF
;;
@ -331,14 +332,14 @@ EOF
;;
doctor) cat <<'EOF'
Answer "is this host fit to mint boxes?" from ground truth, not config claims:
is the Incus daemon answering, is a dnsmasq actually serving claudenet, does
is the Incus daemon answering, is a dnsmasq actually serving boxnet, does
the kernel's bridge port say 'isolated on', is the resolver pinned or is a
host VPN's DNS leaking into boxes, can a box actually resolve names. Every
check exists because its fault has happened — most kill a cold mint with a
cloud-init error that names none of them.
--fix also revert what a drill run may have left behind
--pin-dns pin claudenet's resolver to public upstreams and re-test
--pin-dns pin boxnet's resolver to public upstreams and re-test
(setup-host.sh now pins by default; this is the quick test)
box doctor
@ -570,7 +571,7 @@ cmd_new() {
reset_identity "$instance"
echo "box: cloned $srcref — isolation and auth state carry over from the source."
else
local t="${template:-claude}" m extra=()
local t="${template:-blank}" m extra=()
load_template "$t"
m="$(pick_mode)"
# shellcheck disable=SC2054 # "root,size=..." is a single incus argument
@ -609,10 +610,15 @@ cmd_new() {
die "cloud-init failed — the box is incomplete, so refusing to hand it over"
fi
fi
if [ -z "$from" ] && [ "${template:-claude}" != claude ]; then
echo "box: ready — 'box shell $name'."
else
# The login hint belongs to the claude template — read the EFFECTIVE
# template off the instance, so a clone of a claude box gets it too and a
# blank box is not told to run a binary it doesn't have.
local eff; eff="$(incus config get "$instance" user.box.template 2>/dev/null || true)"
[ -z "$eff" ] && [ "$(incus config get "$instance" user.claudebox 2>/dev/null || true)" = 1 ] && eff=claude
if [ "$eff" = claude ]; then
echo "box: ready — 'box shell $name'. Log into Claude inside: run 'claude' then /login."
else
echo "box: ready — 'box shell $name'."
fi
}

View file

@ -40,19 +40,19 @@ timeout 10 incus list >/dev/null 2>&1 || {
exit 1
}
head_ "Network — claudenet"
if incus network show claudenet >/dev/null 2>&1; then
head_ "Network — boxnet"
if incus network show boxnet >/dev/null 2>&1; then
# dns.mode=none is SHIPPED — it is what stops a box enumerating its siblings
# through the gateway's dnsmasq. Its ABSENCE is the problem, not its presence.
dns="$(incus network get claudenet dns.mode 2>/dev/null)"
dns="$(incus network get boxnet dns.mode 2>/dev/null)"
if [ "$dns" = none ]; then
ok "dns.mode = none — a box cannot enumerate its siblings by name"
else
no "dns.mode = ${dns:-<unset>} — a box can RESOLVE its siblings' names and addresses"
inf "fix: re-run ~/.local/share/claudebox/host/setup-host.sh"
[ "$FIX" = 1 ] && { incus network set claudenet dns.mode=none && inf "set: dns.mode=none"; }
[ "$FIX" = 1 ] && { incus network set boxnet dns.mode=none && inf "set: dns.mode=none"; }
fi
inf "ipv4.address = $(incus network get claudenet ipv4.address 2>/dev/null)"
inf "ipv4.address = $(incus network get boxnet ipv4.address 2>/dev/null)"
# Incus reports the network as "Created" whether or not anything is actually
# SERVING it. Kill the daemon uncleanly (a wedge, an OOM, a SIGKILL) and it
# can come back without respawning this network's dnsmasq — the bridge is up,
@ -60,37 +60,37 @@ if incus network show claudenet >/dev/null 2>&1; then
# so it dies deep inside cloud-init with "Temporary failure resolving
# deb.debian.org". Two cold mints and an hour of hunting went into learning
# that Incus's own status does not cover this. Ask the process table instead.
if pgrep -af 'dnsmasq.*--interface=claudenet' >/dev/null 2>&1; then
ok "a dnsmasq is serving claudenet (DHCP + DNS)"
if pgrep -af 'dnsmasq.*--interface=boxnet' >/dev/null 2>&1; then
ok "a dnsmasq is serving boxnet (DHCP + DNS)"
else
no "NO dnsmasq is serving claudenet — the bridge is up and incus says 'Created', but nothing hands out leases"
no "NO dnsmasq is serving boxnet — the bridge is up and incus says 'Created', but nothing hands out leases"
inf "every box minted now gets no address, no DNS, and dies in cloud-init"
inf "fix: timeout 60 incus delete -f <any boxes>; sudo systemctl restart incus"
inf " (if it does not come back: teardown-host.sh, then re-run the drill)"
[ "$FIX" = 1 ] && {
inf "restarting incus to respawn it…"
sudo systemctl restart incus && sleep 5
pgrep -af 'dnsmasq.*--interface=claudenet' >/dev/null 2>&1 \
&& inf "reverted: dnsmasq is serving claudenet again" \
pgrep -af 'dnsmasq.*--interface=boxnet' >/dev/null 2>&1 \
&& inf "reverted: dnsmasq is serving boxnet again" \
|| inf "STILL missing — run teardown-host.sh and let the drill rebuild the network"
}
fi
ipv6="$(incus network get claudenet ipv6.address 2>/dev/null)"
ipv6="$(incus network get boxnet ipv6.address 2>/dev/null)"
[ "$ipv6" = none ] && ok "ipv6.address = none (the isolation contract — every ACL rule is IPv4-only)" \
|| no "ipv6.address = $ipv6 — IPv6 is on and NOT covered by any ACL rule"
else
inf "claudenet does not exist (a fresh host — setup-host.sh will create it)"
inf "boxnet does not exist (a fresh host — setup-host.sh will create it)"
fi
head_ "Firewall — the box-to-box drop"
if sudo nft list table bridge claudebox >/dev/null 2>&1; then
ok "nft bridge table 'claudebox' is present — boxes cannot reach each other"
if sudo nft list table bridge box >/dev/null 2>&1; then
ok "nft bridge table 'box' 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)"
inf "fix: sudo /usr/local/sbin/box-firewall"
inf " (or: sudo systemctl restart box-firewall.service)"
fi
# box-net is the placement contract since the 0.4.0 rename; claude-dev is its
@ -127,17 +127,17 @@ else
inf "box-net does not exist (a fresh host — setup-host.sh will create it)"
fi
head_ "ACL — claude-isolate"
if incus network acl show claude-isolate >/dev/null 2>&1; then
n="$(incus network acl show claude-isolate | grep -c 'action:' || true)"
head_ "ACL — box-isolate"
if incus network acl show box-isolate >/dev/null 2>&1; then
n="$(incus network acl show box-isolate | grep -c 'action:' || true)"
inf "$n rules"
incus network acl show claude-isolate | grep -E 'action:|destination:' | sed 's/^/ /'
if incus network acl show claude-isolate | grep -q '@internal'; then
incus network acl show box-isolate | grep -E 'action:|destination:' | sed 's/^/ /'
if incus network acl show box-isolate | grep -q '@internal'; then
no "an @internal rule survived phase D"
[ "$FIX" = 1 ] && { incus network acl rule remove claude-isolate egress action=drop destination=@internal && inf "reverted: @internal rule removed"; }
[ "$FIX" = 1 ] && { incus network acl rule remove box-isolate egress action=drop destination=@internal && inf "reverted: @internal rule removed"; }
fi
else
inf "claude-isolate does not exist (a fresh host)"
inf "box-isolate does not exist (a fresh host)"
fi
# Config is a claim; the bridge port is the fact. Incus can accept
@ -150,9 +150,9 @@ for c in bridge /usr/sbin/bridge /sbin/bridge; do
sudo "$c" -V >/dev/null 2>&1 && { BRIDGE="$c"; break; }
done
if [ -n "$BRIDGE" ]; then
ports="$(sudo "$BRIDGE" -d link show 2>/dev/null | grep -A1 'master claudenet')"
ports="$(sudo "$BRIDGE" -d link show 2>/dev/null | grep -A1 'master boxnet')"
if [ -z "$ports" ]; then
inf "no instance is attached to claudenet right now (mint a box to check the taps)"
inf "no instance is attached to boxnet right now (mint a box to check the taps)"
else
printf '%s\n' "$ports" | sed 's/^/ /'
if printf '%s' "$ports" | grep -q 'isolated on'; then
@ -182,9 +182,9 @@ done
head_ "Host resolver — a box's DNS is forwarded through this"
hostns="$(grep -E '^nameserver' /etc/resolv.conf 2>/dev/null | awk '{print $2}' | tr '\n' ' ')"
inf "/etc/resolv.conf: ${hostns:-<none>}"
raw="$(incus network get claudenet raw.dnsmasq 2>/dev/null | tr '\n' ';')"
raw="$(incus network get boxnet raw.dnsmasq 2>/dev/null | tr '\n' ';')"
if [ -n "$raw" ]; then
ok "claudenet has a pinned resolver (raw.dnsmasq: $raw)"
ok "boxnet has a pinned resolver (raw.dnsmasq: $raw)"
inf "boxes do NOT inherit the host's resolver — good (issue #33)"
else
# 100.64.0.0/10 is CGNAT — which is exactly Tailscale's range.
@ -200,8 +200,8 @@ else
fi
if [ "$PIN" = 1 ]; then
head_ "Pinning claudenet's resolver (issue #33)"
if incus network set claudenet raw.dnsmasq "$(printf 'no-resolv\nserver=1.1.1.1\nserver=8.8.8.8\n')" 2>/tmp/pin.err; then
head_ "Pinning boxnet's resolver (issue #33)"
if incus network set boxnet raw.dnsmasq "$(printf 'no-resolv\nserver=1.1.1.1\nserver=8.8.8.8\n')" 2>/tmp/pin.err; then
ok "set raw.dnsmasq: no-resolv + 1.1.1.1 + 8.8.8.8 — dnsmasq now ignores /etc/resolv.conf"
inf "a box's DNS no longer depends on the host's VPN state, and MagicDNS is out of the path"
inf "re-run the drill; if the cold mint now succeeds, issue #33 is confirmed and the fix belongs in setup-host.sh"
@ -226,8 +226,8 @@ if [ -n "$probe" ] && [ "$FIX" != 1 ]; then
inf "its resolv.conf: $(timeout -k 5 20 incus exec "$probe" -- sh -c 'grep -m2 nameserver /etc/resolv.conf' </dev/null 2>/dev/null | tr '\n' ' ')"
# Routing is probed by ADDRESS against the public internet, NOT by pinging
# the gateway: claudebox-firewall.sh drops everything from a box to the host
# except DNS/DHCP, so ICMP to 10.87.0.1 fails BY DESIGN on a healthy host.
# the gateway: box-firewall.sh drops everything from a box to the host
# except DNS/DHCP, so ICMP to 10.88.0.1 fails BY DESIGN on a healthy host.
# A gateway ping here is a check that can only ever lie.
if timeout -k 5 25 incus exec "$probe" -- curl -sS -m 10 -o /dev/null https://1.1.1.1 </dev/null 2>/dev/null; then
routing=1; ok "reaches 1.1.1.1 by address — egress routing is fine"

View file

@ -86,20 +86,20 @@ in_box() {
return "$rc"
}
# The box's address ON CLAUDENET. Three ways to get this wrong, all of them hit:
# The box's address ON BOXNET. Three ways to get this wrong, all of them hit:
# · 'incus list' name filters are NOT regexes ("^b$" silently matches nothing)
# · its CSV quotes a multi-address box across lines
# · and the interface is NOT called eth0. The PROFILE names the device eth0,
# but inside a VM guest predictable naming renames it enp5s0. Six runs of
# A3 "not probed" were this, not the network.
# So: read it from inside the box, and select by SUBNET (10.87.x, what claudenet
# So: read it from inside the box, and select by SUBNET (10.88.x, what boxnet
# hands out) rather than by interface name — docker0 (172.17.x) is the decoy,
# and the NIC's name is the guest's business, not ours.
claudenet_ip() {
boxnet_ip() {
local b="$1" ip _i
for _i in $(seq 1 15); do
ip="$(in_box "$b" ip -4 -o addr show scope global \
| awk '{ for (i = 1; i < NF; i++) if ($i == "inet" && $(i+1) ~ /^10\.87\./) { split($(i+1), a, "/"); print a[1]; exit } }')"
| awk '{ for (i = 1; i < NF; i++) if ($i == "inet" && $(i+1) ~ /^10\.88\./) { split($(i+1), a, "/"); print a[1]; exit } }')"
[ -n "$ip" ] && { printf '%s\n' "$ip"; return 0; }
sleep 2
done
@ -143,9 +143,9 @@ if [ "${IN_GROUP:-0}" != 1 ]; then
cat <<EOF
This will, ON THIS HOST ($(hostname)):
· install Incus and a systemd unit
· create a network (claudenet), an ACL, and a profile
· create a network (boxnet), an ACL, and a profile
· rewrite firewall rules (nft or UFW, and Docker's DOCKER-USER chain)
· create and destroy instances named: drill, clone, archive, peer, payroll, cbprobe, cbcopy
· create and destroy instances named: drill, clone, archive, peer, payroll, cbprobe, cbcopy, tpl
· mutate the network and profile mid-run to rehearse the #16 hardening
Only do this on a machine you can format.
EOF
@ -161,7 +161,7 @@ EOF
|| { echo "install failed"; exit 1; }
export PATH="$HOME/.local/bin:$PATH"
phase "Host setup (Incus, claudenet, ACL, profile, firewall)"
phase "Host setup (Incus, boxnet, ACL, profile, firewall)"
# setup-host.sh installs nftables itself when neither nft nor UFW exists
# (a stock Debian 13 cloud image ships neither). This guard is a tripwire:
# if it fires, that fix regressed.
@ -229,11 +229,11 @@ fi
inf "clearing anything a previous run left behind…"
# One name at a time — 'incus delete -f a b c' aborts at the first MISSING name,
# which is how run 2 inherited run 1's boxes and cascaded five false FAILs.
for n in drill clone archive peer payroll cbprobe cbcopy cbnotours; do
for n in drill clone archive peer payroll cbprobe cbcopy cbnotours tpl; do
timeout -k 5 60 incus delete -f "$n" >/dev/null 2>&1
done
if incus network show claudenet >/dev/null 2>&1; then
timeout -k 5 30 incus network unset claudenet dns.mode >/dev/null 2>&1
if incus network show boxnet >/dev/null 2>&1; then
timeout -k 5 30 incus network unset boxnet dns.mode >/dev/null 2>&1
fi
for p in box-net claude-dev; do
if incus profile show "$p" >/dev/null 2>&1; then
@ -248,10 +248,10 @@ inf "running setup-host.sh (in-group pass: network, ACL, profile, firewall)…"
if ! timeout -k 10 300 ~/.local/share/claudebox/host/setup-host.sh; then
echo "drill: setup-host.sh failed or timed out (>5 min)." >&2
echo " it should take seconds on a host that already has incus. usual causes:" >&2
echo " · instances still attached to claudenet while its ACLs are reconfigured" >&2
echo " · instances still attached to boxnet while its ACLs are reconfigured" >&2
echo " incus list" >&2
echo " · the firewall unit not completing" >&2
echo " systemctl status claudebox-firewall.service --no-pager" >&2
echo " systemctl status box-firewall.service --no-pager" >&2
echo " · the incus daemon wedged by an earlier aborted run" >&2
echo " systemctl status incus --no-pager; journalctl -u incus -n 30 --no-pager" >&2
exit 1
@ -370,10 +370,53 @@ else
&& ok "empty host: 'no boxes yet', exit 0" || no "empty-host message wrong"
fi
printf '\n minting a box (cold, ~10 min)…\n'
# --- templates: the mint surface is itself a surface to test ----------------
box templates 2>/dev/null | grep -q '^ blank' && box templates 2>/dev/null | grep -q '^ claude' \
&& ok "templates: lists blank and claude" || no "templates listing is missing a shipped template"
box new --name tpl --template nosuch 2>&1 | grep -q 'no such template' \
&& ok "unknown template refused, points at 'box templates'" || no "an unknown template was not refused"
# The one rule that keeps templates honest: no key can name a network. Plant a
# bad template in the installed tree (the drill owns this host), expect the
# parser to reject it BY NAME, remove it.
badt="$HOME/.local/share/claudebox/templates/cbdrill-bad"
mkdir -p "$badt" && printf 'BOX_IMAGE="x"\nBOX_USER="y"\nBOX_NETWORK="lan"\n' >"$badt/box.env" && : >"$badt/user-data.yaml"
box new --name tpl --template cbdrill-bad 2>&1 | grep -q "unknown key 'BOX_NETWORK'" \
&& ok "a template cannot name a network — BOX_NETWORK rejected by name" \
|| no "a box.env key outside the allowlist was ACCEPTED — a template could weaken isolation"
rm -rf "$badt"
printf '\n minting a blank box (the DEFAULT template — no tooling, fast)…\n'
t0=$SECONDS
if box new --name drill >/tmp/new.log 2>&1; then
ok "box new --name drill ($((SECONDS - t0))s)"
if box new --name tpl >/tmp/tpl.log 2>&1; then
ok "box new --name tpl, no --template ($((SECONDS - t0))s)"
tt="$(incus config get tpl user.box.template 2>/dev/null)"
[ "$tt" = blank ] && ok "the default template is blank (user.box.template=blank)" \
|| no "default template is '${tt:-<unset>}' — expected blank"
[ "$(incus config get tpl user.box.user 2>/dev/null)" = dev ] \
&& ok "template user stamped on the instance (user.box.user=dev)" || no "user.box.user not stamped"
incus config show tpl 2>/dev/null | grep -q '^- box-net' \
&& ok "blank box launched with the box-net profile — same placement contract" \
|| no "blank box is NOT on box-net — a template picked its own placement?!"
u="$(timeout -k 5 30 box exec tpl -- whoami </dev/null 2>/dev/null | tr -d '[:space:]')"
[ "$u" = dev ] && ok "exec lands in the template's user ($u) — nothing hardcodes claude" \
|| no "exec landed in '${u:-<nothing>}', expected dev"
timeout -k 5 30 box exec tpl -- sh -lc 'command -v claude' </dev/null >/dev/null 2>&1 \
&& no "the blank box has claude installed — 'blank' is not blank" \
|| ok "blank box has no claude — nobody home, as designed"
box_pings tpl 1.1.1.1 && ok "blank box reaches the internet (same egress as any template)" \
|| no "blank box has NO egress — isolation parity broken"
in_box tpl getent hosts deb.debian.org >/dev/null 2>&1 \
&& ok "blank box resolves public names (pinned resolver serves every template)" \
|| no "blank box cannot resolve — DNS parity broken"
box rm tpl --force >/dev/null 2>&1 && ok "blank box removed" || no "could not remove the blank box"
else
no "blank mint FAILED — tail: $(tail -3 /tmp/tpl.log | tr '\n' ' ')"
fi
printf '\n minting a claude box (cold, ~10 min)…\n'
t0=$SECONDS
if box new --name drill --template claude >/tmp/new.log 2>&1; then
ok "box new --name drill --template claude ($((SECONDS - t0))s)"
else
no "box new FAILED — tail: $(tail -3 /tmp/new.log | tr '\n' ' ')"
echo; echo "── cannot continue without a box"; printf ' %s\n' "${findings[@]}"; exit 1
@ -482,10 +525,10 @@ fi
# nothing serves and read refused-vs-dropped — refused would mean the box's
# packet reached the host's stack, which is the thing the firewall must prevent.
# (No background listener: one less process to leak, one less way to wedge.)
hv="$(box_probe archive http://10.87.0.1:8099)"
hv="$(box_probe archive http://10.88.0.1:8099)"
case "$hv" in
reachable|refused)
no "THE BOX'S PACKETS REACH THE HOST on 10.87.0.1:8099 [$hv] — the firewall rules are not holding"
no "THE BOX'S PACKETS REACH THE HOST on 10.88.0.1:8099 [$hv] — the firewall rules are not holding"
aud "A2 box→host: FAIL — $hv (the packet reached the host's stack)" ;;
dropped)
ok "box → host is blocked (no path to the machine's sockets)"
@ -510,8 +553,8 @@ esac
# port answers the question just as well (refused = the packet arrived), and
# the listener was what kept wedging the run. Ping corroborates: if the two
# disagree, say so rather than pick one.
PEER_IP="$(claudenet_ip peer)"
ARCH_IP_PRE="$(claudenet_ip archive)"
PEER_IP="$(boxnet_ip peer)"
ARCH_IP_PRE="$(boxnet_ip archive)"
if [ -n "$PEER_IP" ] && [ "$PEER_IP" = "$ARCH_IP_PRE" ]; then
# Guard, because this actually happened: a clone inherited its source's
# machine-id, hence its DHCP lease, hence its ADDRESS. Probing "archive →
@ -535,8 +578,8 @@ elif [ -n "$PEER_IP" ]; then
aud "A3 sibling: BLOCKED — tcp dropped + no icmp reply (security.port_isolation)"
fi
else
no "could not read peer's claudenet address — the sibling probe never ran"
aud "A3 sibling: NOT PROBED (no 10.87.x address on peer)"
no "could not read peer's boxnet address — the sibling probe never ran"
aud "A3 sibling: NOT PROBED (no 10.88.x address on peer)"
fi
# C5 — DNS enumeration (#15 A4). Now a CONTRACT, not an observation: setup-host
@ -552,13 +595,13 @@ else
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"; }
[ "$(incus network get boxnet ipv6.address 2>/dev/null)" = none ] \
&& { ok "boxnet ipv6.address = none (the IPv4-only ACLs have no uncovered path)"; aud "A6 ipv6: none, as contract requires"; } \
|| { no "boxnet 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. Same
# listener-free logic, run from the host this time.
ARCH_IP="$(claudenet_ip archive)"
ARCH_IP="$(boxnet_ip archive)"
if [ -n "$ARCH_IP" ]; then
hmsg="$(curl -sS -m 5 -o /dev/null "http://$ARCH_IP:8087" 2>&1)"; hrc=$?
if [ "$hrc" -eq 0 ]; then hv=reachable
@ -577,7 +620,7 @@ if [ -n "$ARCH_IP" ]; then
aud "A7 inbound host→box: INCONCLUSIVE ($hv)" ;;
esac
else
no "could not read archive's claudenet address — the inbound probe never ran"
no "could not read archive's boxnet address — the inbound probe never ran"
aud "A7 inbound host→box: NOT PROBED"
fi
@ -586,7 +629,7 @@ phase "D. The isolation contract, stated"
# ===========================================================================
# Phase D used to REHEARSE the hardening on a throwaway host, because nobody
# knew whether it would work. That question is settled: the hardening now ships
# in setup-host.sh and claudebox-firewall.sh, so phase C tests the real thing
# in setup-host.sh and box-firewall.sh, so phase C tests the real thing
# and there is nothing left to rehearse. What the rehearsal established, kept
# here so it is not re-litigated:
#
@ -621,11 +664,11 @@ if [ "$KEEP" = 1 ]; then
inf "note: the D-phase mutations (dns.mode=none, NIC filtering) are still applied"
else
# every name the drill can have left, whatever branch a partial run took
for n in drill clone archive peer; do box rm "$n" --force >/dev/null 2>&1; done
for n in drill clone archive peer tpl; do box rm "$n" --force >/dev/null 2>&1; done
# Assert OUR boxes are gone — not that the host is empty. The rm loop above
# already embodies the discipline (only names the drill minted); demanding
# 'no boxes yet' here would flag any pre-existing operator box as a failure.
leftover="$(box list 2>/dev/null | grep -E '^(drill|clone|archive|peer)([[:space:]]|$)' || true)"
leftover="$(box list 2>/dev/null | grep -E '^(drill|clone|archive|peer|tpl)([[:space:]]|$)' || true)"
[ -z "$leftover" ] && ok "teardown: every box the drill minted is gone" \
|| no "a drill box survived teardown: $(printf '%s' "$leftover" | awk '{print $1}' | tr '\n' ' ')"
fi
@ -643,7 +686,7 @@ if [ "${#audit[@]}" -gt 0 ]; then
fi
echo
inf "this host still has Incus, claudenet, the ACL, the profile and the firewall rules"
inf "this host still has Incus, boxnet, 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]"
[ "$fail" -eq 0 ]

142
drill/wipe.sh Normal file
View file

@ -0,0 +1,142 @@
#!/usr/bin/env bash
# wipe.sh — scorched earth for drill hosts. Remove EVERY trace of box and of
# pre-0.4.0 claudebox, so the next drill run starts from a truly bare host and
# its verdict means something.
#
# bash drill/wipe.sh # asks first
# bash drill/wipe.sh --yes # no prompt
# bash drill/wipe.sh --purge-storage # also delete cached images AND the
# # 'default' storage pool, so setup-host
# # exercises its pool bootstrap (#29)
#
# What teardown-host.sh does NOT cover, this does: instances the drill names
# but never tagged, instances of either tag generation, cached images, and
# (opt-in) the storage pool. teardown is the polite uninstall; this is the
# reset button for the staging server.
#
# NOT 'set -e': on a wipe, a step that finds nothing to remove is success,
# not failure. Every removal states what it did; silence is never trusted
# (the exit-code lesson, again).
set -u
YES=0; PURGE_STORAGE=0
while [ $# -gt 0 ]; do
case "$1" in
--yes|-y) YES=1; shift ;;
--purge-storage) PURGE_STORAGE=1; shift ;;
-h|--help) sed -n '2,17p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) echo "wipe: unknown option: $1" >&2; exit 2 ;;
esac
done
say() { printf 'wipe: %s\n' "$*"; }
if [ "$YES" -ne 1 ]; then
cat <<EOF
This wipes EVERY trace of box/claudebox from this host ($(hostname)):
· every instance tagged user.box=1 or user.claudebox=1
· every instance the drill has ever named (drill, clone, archive, peer,
payroll, cbprobe, cbcopy, cbnotours, tpl)
· networks boxnet + claudenet, ACLs box-isolate + claude-isolate
· profiles box-net + claude-dev
· firewall units, scripts and nft tables of BOTH name generations
· every cached Incus image (the next mint re-downloads)
$( [ "$PURGE_STORAGE" = 1 ] && echo " · the 'default' storage pool (--purge-storage)" )
Uncommitted work inside any box is LOST. Only do this on a drill host.
EOF
[ -t 0 ] || { echo "wipe: no TTY to confirm on — pass --yes if you mean it." >&2; exit 2; }
printf 'Continue? [y/N] '
read -r reply
case "$reply" in y|Y|yes) ;; *) echo "stopped."; exit 1 ;; esac
fi
command -v incus >/dev/null || { say "incus is not installed — nothing box-shaped can exist; only firewall crumbs checked."; }
if command -v incus >/dev/null; then
# --- instances: both tags, then every name the drill has ever used --------
# One delete at a time — a multi-name 'incus delete' aborts at the first
# missing name (drill trap 5).
for tag in "user.box=1" "user.claudebox=1"; do
for i in $(incus list "$tag" -f csv -c n 2>/dev/null); do
timeout -k 5 60 incus delete -f "$i" >/dev/null 2>&1 \
&& say "deleted instance $i ($tag)" || say "instance $i: delete FAILED — look at it by hand"
done
done
for n in drill clone archive peer payroll cbprobe cbcopy cbnotours tpl; do
incus info "$n" >/dev/null 2>&1 || continue
timeout -k 5 60 incus delete -f "$n" >/dev/null 2>&1 \
&& say "deleted untagged drill instance $n" || say "instance $n: delete FAILED — look at it by hand"
done
# --- profiles, networks, ACLs — both generations ---------------------------
for p in box-net claude-dev; do
incus profile delete "$p" >/dev/null 2>&1 && say "deleted profile $p"
done
for net in boxnet claudenet; do
incus network delete "$net" >/dev/null 2>&1 && say "deleted network $net"
done
for acl in box-isolate claude-isolate; do
incus network acl delete "$acl" >/dev/null 2>&1 && say "deleted ACL $acl"
done
# --- cached images: the pool's other tenants -------------------------------
for f in $(incus image list -f csv -c f 2>/dev/null); do
incus image delete "$f" >/dev/null 2>&1 && say "deleted image $f"
done
# --- the pool itself (opt-in): lets setup-host's bootstrap run for real ----
if [ "$PURGE_STORAGE" = 1 ]; then
incus profile device remove default root >/dev/null 2>&1 && say "removed default profile's root device"
if incus storage delete default >/dev/null 2>&1; then
say "deleted storage pool 'default' — setup-host will rebuild it (btrfs where it can)"
else
incus storage show default >/dev/null 2>&1 \
&& say "pool 'default' NOT deleted — something still uses it: incus storage volume list default" \
|| say "no 'default' pool existed"
fi
fi
fi
# --- firewall: units, scripts, nft tables, UFW and Docker crumbs -------------
for unit in box-firewall claudebox-firewall; do
sudo systemctl disable --now "$unit.service" >/dev/null 2>&1 && say "disabled $unit.service"
sudo rm -f "/etc/systemd/system/$unit.service" "/usr/local/sbin/$unit"
done
sudo systemctl daemon-reload
for t in "inet box" "bridge box" "inet claudebox" "bridge claudebox"; do
# shellcheck disable=SC2086 # the table spec is two words by design
sudo nft delete table $t >/dev/null 2>&1 && say "deleted nft table $t"
done
if command -v ufw >/dev/null && sudo ufw status 2>/dev/null | grep -q "Status: active"; then
for net in boxnet claudenet; do
while sudo ufw status numbered | grep -q "on $net"; do
n="$(sudo ufw status numbered | grep -m1 "on $net" | sed -E 's/^\[ *([0-9]+)\].*/\1/')"
sudo ufw --force delete "$n" >/dev/null && say "deleted UFW rule on $net"
done
done
fi
if command -v docker >/dev/null; then
for net in boxnet claudenet; do
sudo iptables -D DOCKER-USER -i "$net" -j ACCEPT 2>/dev/null && say "removed DOCKER-USER -i $net"
sudo iptables -D DOCKER-USER -o "$net" -j ACCEPT 2>/dev/null && say "removed DOCKER-USER -o $net"
done
fi
# --- verdict: assert the ABSENCE, don't trust the removals' exit codes -------
left=""
if command -v incus >/dev/null; then
for tag in "user.box=1" "user.claudebox=1"; do
[ -n "$(incus list "$tag" -f csv -c n 2>/dev/null)" ] && left="$left instances($tag)"
done
for net in boxnet claudenet; do incus network show "$net" >/dev/null 2>&1 && left="$left $net"; done
for p in box-net claude-dev; do incus profile show "$p" >/dev/null 2>&1 && left="$left $p"; done
fi
for t in "inet box" "bridge box" "inet claudebox" "bridge claudebox"; do
# shellcheck disable=SC2086
sudo nft list table $t >/dev/null 2>&1 && left="$left nft:${t// /-}"
done
if [ -n "$left" ]; then
say "NOT clean — still present:$left"
exit 1
fi
say "clean — no trace of box or claudebox remains. The drill will rebuild everything."

View file

@ -1,12 +1,12 @@
[Unit]
Description=Re-apply claudebox host firewall rules
Description=Re-apply box host firewall rules
# After docker so DOCKER-USER exists; still fine if docker is absent.
After=network.target incus.service docker.service
Wants=network.target
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/claudebox-firewall
ExecStart=/usr/local/sbin/box-firewall
[Install]
WantedBy=multi-user.target

View file

@ -1,12 +1,12 @@
#!/usr/bin/env bash
# Apply the claudebox host-firewall rules. Idempotent; runs as root.
# Invoked by setup-host.sh at install time and by claudebox-firewall.service
# Apply the box host-firewall rules. Idempotent; runs as root.
# Invoked by setup-host.sh at install time and by box-firewall.service
# at every boot (UFW rules persist on their own; the nft fallback table and
# Docker's DOCKER-USER rules are runtime-only and need re-applying).
set -euo pipefail
GW=10.87.0.1
NET=claudenet
GW=10.88.0.1
NET=boxnet
if command -v ufw >/dev/null && ufw status 2>/dev/null | grep -q "Status: active"; then
if ! ufw status | grep "on $NET" | grep -q "DENY"; then
@ -18,12 +18,12 @@ if command -v ufw >/dev/null && ufw status 2>/dev/null | grep -q "Status: active
fi
else
# No UFW: protect the host's own sockets with a dedicated nft table.
if ! nft list table inet claudebox >/dev/null 2>&1; then
nft add table inet claudebox
nft 'add chain inet claudebox input { type filter hook input priority -5 ; }'
nft add rule inet claudebox input iifname "$NET" udp dport '{ 53, 67 }' accept
nft add rule inet claudebox input iifname "$NET" tcp dport 53 accept
nft add rule inet claudebox input iifname "$NET" drop
if ! nft list table inet box >/dev/null 2>&1; then
nft add table inet box
nft 'add chain inet box input { type filter hook input priority -5 ; }'
nft add rule inet box input iifname "$NET" udp dport '{ 53, 67 }' accept
nft add rule inet box input iifname "$NET" tcp dport 53 accept
nft add rule inet box input iifname "$NET" drop
fi
fi
@ -31,7 +31,7 @@ fi
#
# This is the ONE rule that makes "isolated even from each other" true, and it
# is not the one anyone expected. The Incus ACL drops egress to 10.0.0.0/8, and
# claudenet's 10.87.0.0/24 sits inside it — so on paper box→box was already
# boxnet's 10.88.0.0/24 sits inside it — so on paper box→box was already
# blocked twice over (the ingress default is drop as well). It was not: a live
# probe found box A's SYN arriving at box B and B answering with a RST.
#
@ -41,16 +41,16 @@ fi
# sees this traffic.
#
# The bridge family DOES see it. Its forward hook fires exactly when a frame is
# passed from one bridge port to another — which, on claudenet, means box→box
# passed from one bridge port to another — which, on boxnet, means box→box
# and nothing else: frames addressed to the gateway are delivered locally (the
# INPUT hook), and so is anything being routed out to the internet. So dropping
# every forwarded frame on this bridge isolates the boxes from one another and
# costs them nothing else. DHCP and ARP still work: they are broadcast, and the
# local delivery to dnsmasq happens on INPUT, not FORWARD.
if ! nft list table bridge claudebox >/dev/null 2>&1; then
nft add table bridge claudebox
nft "add chain bridge claudebox forward { type filter hook forward priority -200 ; policy accept ; }"
nft add rule bridge claudebox forward meta ibrname "$NET" meta obrname "$NET" drop
if ! nft list table bridge box >/dev/null 2>&1; then
nft add table bridge box
nft "add chain bridge box forward { type filter hook forward priority -200 ; policy accept ; }"
nft add rule bridge box forward meta ibrname "$NET" meta obrname "$NET" drop
fi
# Docker rewrites FORWARD policy to DROP; DOCKER-USER is its escape hatch.

View file

@ -56,21 +56,23 @@ PRESEED
fi
# Isolated NAT network. IPv6 off: one less egress path to reason about.
incus network show claudenet >/dev/null 2>&1 || incus network create claudenet \
ipv4.address=10.87.0.1/24 ipv4.nat=true ipv6.address=none
# 10.88, not 10.87: a pre-rename host may still carry claudenet on 10.87 with
# legacy boxes attached — two bridges must not claim one subnet.
incus network show boxnet >/dev/null 2>&1 || incus network create boxnet \
ipv4.address=10.88.0.1/24 ipv4.nat=true ipv6.address=none
# ACL: default egress allow (internet), explicit drops for private space.
# Gateway carve-out first so instance DNS (dnsmasq on 10.87.0.1) survives.
if ! incus network acl show claude-isolate >/dev/null 2>&1; then
incus network acl create claude-isolate
incus network acl rule add claude-isolate egress action=allow destination=10.87.0.1/32
incus network acl rule add claude-isolate egress action=drop destination=10.0.0.0/8
incus network acl rule add claude-isolate egress action=drop destination=172.16.0.0/12
incus network acl rule add claude-isolate egress action=drop destination=192.168.0.0/16
incus network acl rule add claude-isolate egress action=drop destination=169.254.0.0/16
incus network acl rule add claude-isolate egress action=drop destination=100.64.0.0/10
# Gateway carve-out first so instance DNS (dnsmasq on 10.88.0.1) survives.
if ! incus network acl show box-isolate >/dev/null 2>&1; then
incus network acl create box-isolate
incus network acl rule add box-isolate egress action=allow destination=10.88.0.1/32
incus network acl rule add box-isolate egress action=drop destination=10.0.0.0/8
incus network acl rule add box-isolate egress action=drop destination=172.16.0.0/12
incus network acl rule add box-isolate egress action=drop destination=192.168.0.0/16
incus network acl rule add box-isolate egress action=drop destination=169.254.0.0/16
incus network acl rule add box-isolate egress action=drop destination=100.64.0.0/10
fi
incus network set claudenet security.acls=claude-isolate \
incus network set boxnet security.acls=box-isolate \
security.acls.default.egress.action=allow \
security.acls.default.ingress.action=drop
@ -80,7 +82,7 @@ incus network set claudenet security.acls=claude-isolate \
# from inside one box resolved another's name and address. Connection blocked,
# reconnaissance wide open. dns.mode=none stops it registering instance records;
# forwarding for public names is unaffected (verified live).
incus network set claudenet dns.mode=none
incus network set boxnet dns.mode=none
# A box's resolver must not be a function of the host's VPN posture (#33).
# The bridge's dnsmasq forwards to whatever sits in the HOST's /etc/resolv.conf
@ -93,11 +95,11 @@ incus network set claudenet dns.mode=none
# lever — the bridge has no first-class upstream key. Verified live on the
# drill host: pin applied, box resolves, cold mint survives.
BOX_DNS="${BOX_DNS:-1.1.1.1 8.8.8.8}"
incus network set claudenet raw.dnsmasq \
incus network set boxnet raw.dnsmasq \
"$(printf 'no-resolv\n'; for s in $BOX_DNS; do printf 'server=%s\n' "$s"; done)"
# Sibling isolation itself is NOT an ACL rule — an L3 ACL never sees frames
# switched between two ports of one bridge. It lives in claudebox-firewall.sh
# switched between two ports of one bridge. It lives in box-firewall.sh
# as an nftables bridge-family rule. See the comment there; it is the reason
# boxes cannot reach each other.
@ -107,27 +109,27 @@ incus network set claudenet raw.dnsmasq \
# --- Firewall coexistence ---------------------------------------------------
# Hosts running UFW (INPUT drop) and/or Docker (FORWARD drop) silently eat
# claudenet traffic. Punch minimal, ordered holes; the Incus ACL still layers
# boxnet traffic. Punch minimal, ordered holes; the Incus ACL still layers
# on top. The trailing deny also blocks instance -> host's own (public) IPs,
# which the RFC1918-only ACL cannot express. Rules live in
# claudebox-firewall.sh; a boot-time systemd unit re-applies the runtime-only
# box-firewall.sh; a boot-time systemd unit re-applies the runtime-only
# parts (nft table, DOCKER-USER) after every reboot.
# The no-UFW path drives nft directly, and a stock Debian 13 cloud image ships
# neither nftables nor UFW — install the dependency we are about to use.
if ! command -v ufw >/dev/null 2>&1 && ! command -v nft >/dev/null 2>&1; then
sudo apt-get install -y nftables
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 install -m 755 "$here/host/box-firewall.sh" /usr/local/sbin/box-firewall
sudo install -m 644 "$here/host/box-firewall.service" /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable claudebox-firewall.service
sudo systemctl enable box-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
# Re-running setup-host after upgrading the tool 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
sudo systemctl restart box-firewall.service
# Profile — box-net, the placement contract: the isolated NIC and the root
# disk, nothing a template controls (resources are stamped per-instance from
@ -141,11 +143,11 @@ incus profile edit box-net < "$here/profiles/box-net.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 sudo nft list table bridge claudebox >/dev/null 2>&1; then
echo "Isolation: box-to-box drop is live (nft bridge table 'claudebox')."
if sudo nft list table bridge box >/dev/null 2>&1; then
echo "Isolation: box-to-box drop is live (nft bridge table 'box')."
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
echo " check: sudo /usr/local/sbin/box-firewall ; sudo nft list table bridge box" >&2
fi
echo "Host ready. Launch with: box new --name <box>"

View file

@ -1,58 +1,75 @@
#!/usr/bin/env bash
# Reverse everything host/setup-host.sh created: all claudebox instances, the
# claudenet network + ACL, the claude-dev profile, and the firewall rules.
# Reverse everything host/setup-host.sh created — and everything its pre-0.4.0
# ancestor created, so one teardown cleans a host of any generation: all boxes
# (both tags), the boxnet/claudenet networks + ACLs, the box-net/claude-dev
# profiles, and both generations of firewall units and nft tables.
# Usage: ./host/teardown-host.sh [--purge-incus]
# --purge-incus also apt-purge Incus itself (skipped if non-claudebox
# --purge-incus also apt-purge Incus itself (skipped if non-box
# instances still exist on this host)
set -euo pipefail
purge=false
[ "${1:-}" = "--purge-incus" ] && purge=true
echo "This removes ALL claudebox instances (uncommitted work in them is lost),"
echo "the claudenet network/ACL/profile, and the claudebox firewall rules."
echo "This removes ALL boxes (uncommitted work in them is lost), the"
echo "boxnet/claudenet networks, ACLs, profiles, and the box firewall rules"
echo "(both current and pre-0.4.0 names)."
$purge && echo "Incus itself will also be uninstalled (--purge-incus)."
read -rp "Continue? [y/N] " a
case "$a" in y|Y) ;; *) echo "aborted"; exit 1 ;; esac
# Instances
for i in $(incus list "user.claudebox=1" -f csv -c n || true); do
echo "deleting instance $i"
incus delete -f "$i"
# Instances — both tag generations, one delete at a time (a multi-name
# 'incus delete' aborts at the first missing name).
for tag in "user.box=1" "user.claudebox=1"; do
for i in $(incus list "$tag" -f csv -c n 2>/dev/null || true); do
echo "deleting instance $i"
incus delete -f "$i"
done
done
incus profile delete box-net 2>/dev/null || true
incus profile delete claude-dev 2>/dev/null || true # legacy, pre-rename
incus network delete claudenet 2>/dev/null || true
incus network acl delete claude-isolate 2>/dev/null || true
incus profile delete claude-dev 2>/dev/null || true # legacy, pre-0.4.0
incus network delete boxnet 2>/dev/null || true
incus network delete claudenet 2>/dev/null || true # legacy, pre-0.4.0
incus network acl delete box-isolate 2>/dev/null || true
incus network acl delete claude-isolate 2>/dev/null || true # legacy
# Boot-persistence unit
# Boot-persistence units — both generations
sudo systemctl disable --now box-firewall.service 2>/dev/null || true
sudo systemctl disable --now claudebox-firewall.service 2>/dev/null || true
sudo rm -f /etc/systemd/system/claudebox-firewall.service /usr/local/sbin/claudebox-firewall
sudo rm -f /etc/systemd/system/box-firewall.service /usr/local/sbin/box-firewall \
/etc/systemd/system/claudebox-firewall.service /usr/local/sbin/claudebox-firewall
sudo systemctl daemon-reload
# Firewall crumbs — UFW rules mentioning claudenet (numbers shift after each
# delete, so re-scan and remove the first match until none remain)
# Firewall crumbs — UFW rules mentioning either network (numbers shift after
# each delete, so re-scan and remove the first match until none remain)
if command -v ufw >/dev/null && sudo ufw status 2>/dev/null | grep -q "Status: active"; then
while sudo ufw status numbered | grep -q "on claudenet"; do
n="$(sudo ufw status numbered | grep -m1 "on claudenet" | sed -E 's/^\[ *([0-9]+)\].*/\1/')"
sudo ufw --force delete "$n"
for net in boxnet claudenet; do
while sudo ufw status numbered | grep -q "on $net"; do
n="$(sudo ufw status numbered | grep -m1 "on $net" | sed -E 's/^\[ *([0-9]+)\].*/\1/')"
sudo ufw --force delete "$n"
done
done
fi
sudo nft delete table inet claudebox 2>/dev/null || true
sudo nft delete table inet box 2>/dev/null || true
sudo nft delete table bridge box 2>/dev/null || true
sudo nft delete table inet claudebox 2>/dev/null || true # legacy
sudo nft delete table bridge claudebox 2>/dev/null || true # legacy
if command -v docker >/dev/null; then
sudo iptables -D DOCKER-USER -i claudenet -j ACCEPT 2>/dev/null || true
sudo iptables -D DOCKER-USER -o claudenet -j ACCEPT 2>/dev/null || true
for net in boxnet claudenet; do
sudo iptables -D DOCKER-USER -i "$net" -j ACCEPT 2>/dev/null || true
sudo iptables -D DOCKER-USER -o "$net" -j ACCEPT 2>/dev/null || true
done
fi
if $purge; then
remaining="$(incus list -f csv 2>/dev/null | wc -l)"
if [ "$remaining" -gt 0 ]; then
echo "NOTE: $remaining non-claudebox instance(s) remain on this host — leaving Incus installed."
echo "NOTE: $remaining non-box instance(s) remain on this host — leaving Incus installed."
else
sudo apt-get purge -y incus
sudo apt-get autoremove -y
fi
fi
echo "Teardown complete. (Your ~/.local/bin/claudebox symlink and ~/.local/share/claudebox remain — remove by hand if wanted.)"
echo "Teardown complete. (Your ~/.local/bin/box symlink and ~/.local/share/claudebox remain — remove by hand if wanted.)"