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.
The tool underneath was already generic: a thin, honest wrapper over
Incus. What was Claude-specific was welded on — one image, one profile,
one cloud-init file, one hardcoded 'sudo -u claude'. The weld is now a
template.
The mechanic: 'box new' stamps the template's identity onto the
instance (user.box=1, user.box.template, user.box.user); shell/exec/
tmux read the user back off the instance, and 'incus copy' carries
user.* keys (audit B2), so a clone knows what it is without consulting
the template. Templates are box.env (parsed against a strict allowlist,
never sourced — no key for a network exists, on purpose) plus a
verbatim cloud-init. Every template launches with the shared box-net
profile: the isolated NIC and root disk, nothing template-controlled —
resources land per-instance from box.env, overridable via BOX_CPU/
BOX_MEMORY/BOX_DISK (which is also how the drill shrinks boxes on a
small host now that profile edits can't).
The three open calls, taken as recommended: clean cut at 0.4.0 (no
claudebox shim; the installer retires the old symlink); default
template = claude (muscle memory survives); repo stays heavy-duty/
claudebox, binary is box.
Compat is the tag, not the name: resolve_box and list honor the legacy
user.claudebox=1 forever, and the legacy tag maps to the claude user —
a pre-rename box lists, shells, clones, unchanged.
Deliberate divergence from #17's table: the host-stack resource names
(claudenet, claude-isolate, nft tables, claudebox-firewall.*) are NOT
renamed — they are host-internal, invisible to users, and renaming
them breaks every provisioned host for zero user-visible gain.
claude-dev is no longer created; setup-host creates box-net, teardown
removes both.
Closes#17
'incus admin init --minimal' picks the 'dir' backend, which has no
copy-on-write: every snapshot and clone is a full copy of the box's
root — several GB and minutes apiece once Docker, Node and Claude Code
are installed. Measured live: one clone took minutes, and a single
drill run pays that three times (snapshot + two clones). The workflow
the tool exists for — log in once, snapshot, clone forever — stops
being attractive at exactly that price.
setup-host.sh now bootstraps via a preseed that mirrors what --minimal
creates (default pool, incusbr0, default profile with root + eth0) with
only the driver deliberate: btrfs on a loop device (CoW — clones share
blocks and diverge on write), installing btrfs-progs if absent, falling
back to dir with a warning if btrfs cannot be had. Existing hosts are
untouched: the block is skipped whenever a 'default' pool exists.
Closes#29
The bridge's dnsmasq forwarded to whatever sat in the host's
/etc/resolv.conf at that moment. On a Tailscale host that is MagicDNS:
box DNS flapped with the tailnet (killing cold mints), and tailnet peer
names and split-DNS zones resolved from inside a box — name-level
reconnaissance of a private network, the same class as the sibling
enumeration dns.mode=none already closes.
setup-host.sh now sets raw.dnsmasq to no-resolv + pinned public
upstreams (BOX_DNS overrides the default 1.1.1.1 8.8.8.8), answering
the issue's three open questions from live measurement: raw.dnsmasq is
the lever (no first-class upstream key on the bridge; verified by
doctor --pin-dns followed by a box resolving), upstreams are a setting
with a sane default, and the pin is unconditional.
The doctor's unpinned-state messages now point at setup-host.sh as the
durable fix, keeping --pin-dns as the quick test.
Closes#33
Two bugs, one in each direction.
setup-host's new assertion ran 'nft list table bridge claudebox' without
sudo. nft needs root, so it failed with permission denied and printed
"the box-to-box drop is NOT active" about a rule that was demonstrably
there. A check that cries wolf is worse than no check.
And the deeper one: every check so far has asked the CONFIG whether
boxes are isolated. The config is a claim. Incus can accept
security.port_isolation and the kernel can still leave 'isolated off' on
the tap — and then boxes reach each other while every config in sight
says they cannot. That is precisely the shape of the original bug: the
ACL looked airtight and never saw the traffic.
So the doctor now reads the kernel's own view — 'bridge -d link show'
on claudenet's ports — and reports the isolated flag as the fact it is.
If the profile says true and the kernel says off, we learn that in a
second instead of after another ten-minute drill.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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 <noreply@anthropic.com>
A live probe (drill run 10) found box A's SYN arriving at box B, and B
answering with a RST. Boxes were not isolated from each other at all,
and the README's contract — "a box reaches the public internet and
nothing else" — was false.
The ACL was not wrong; it simply never saw the traffic. Two boxes on one
bridge share an L2 segment, so their frames are SWITCHED between bridge
ports and never traverse the netfilter path where an L3 rule lives. The
drop on 10.0.0.0/8 (which contains claudenet) and the default ingress
drop both looked airtight and neither ever fired. This is why the
original reasoning — "belt and braces" — was plausible and wrong.
The bridge family does see it. Its forward hook fires exactly when a
frame passes from one bridge port to another, which on claudenet means
box→box and nothing else: frames for the gateway are delivered locally,
and so is anything routed out to the internet. Dropping every forwarded
frame on the bridge isolates the boxes and costs them nothing — DHCP and
ARP are unaffected, being broadcast and delivered on INPUT.
Also: dns.mode=none, so a box can no longer ENUMERATE its siblings
through the gateway's dnsmasq. Blocked connections with open
reconnaissance is not isolation.
security.ipv4_filtering is deliberately NOT used: it breaks the box's
networking (dockerd comes up but cannot pull or run a container).
The drill now ASSERTS all of this in phase C against the real stack;
phase D's rehearsal is retired, its findings recorded.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A stock Debian 13 cloud image ships neither, so the no-UFW firewall path
died on its first nft call — on exactly the kind of fresh cloud host the
tool targets. Found by the drill; the drill's manual workaround becomes a
regression tripwire.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A CLI that mints trust-less, network-isolated Incus VMs with Claude Code
installed. Boxes are strictly creds-free — the operator logs into Claude
interactively inside; authenticated state is reused via snapshots. The tool
knows nothing about projects; a repo ships an optional agent-facing .claudebox/
runbook that Claude reads.
- bin/claudebox: new/shell/exec/snapshot/restore/down/start/rm/status; creds-free
'new' (fresh launch or clone via --from <src>[/<snap>]).
- cloud-init: global ~/.claude/CLAUDE.md self-describing the box + .claudebox/ runbook.
- install.sh: curl-pipe-bash installer.
- host/: Incus isolation stack (claudenet + claude-isolate ACL + claude-dev
profile + firewall).
- docs/: design + .claudebox/ convention.
Initial canonical import (prototyped separately; re-homed onto the heavy-duty fork).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>