Run inside a box, setup-host built a nested boxnet claiming the exact
subnet and gateway of the guest's own uplink: the guest then held
10.88.0.1 as a LOCAL address while its default route pointed at 10.88.0.1
as its GATEWAY, carried two connected routes for the subnet, and suffered
intermittent, self-recovering egress blackouts (~24-36 s, roughly hourly)
that no host-side check could attribute. The flagship use case funnels
agents toward exactly this — working on box, in a box — so the guard, not
the operator, has to catch it.
setup-host now scans the target subnet BEFORE any mutation: the default
route's gateway inside it (the smoking gun — that is this machine's own
uplink), or any non-boxnet interface holding an address in it, refuses
with the way out named (BOX_SUBNET). A prior boxnet owning the subnet is
the one claimant that is NOT a collision — the legitimate re-run — and an
existing bridge on a DIFFERENT subnet refuses too: setup-host converges a
bridge, it never re-addresses one under live leases.
BOX_SUBNET=<a.b.c.0/24> (validated at the gate, alongside BOX_DNS) moves
the whole stack from one place:
· the bridge address derives (ipv4.address=<gw>/24)
· the ACL's gateway carve-out derives — and the ruleset is now converged
via 'network acl edit', not created once: the #80 escape hatch (moving
the bridge) left the old /32 behind, stranding box DNS inside the
10.0.0.0/8 drop, which presents as a dead resolver, never as a stale
ACL
· box-firewall reads the gateway off the live bridge ('|| true' because
under pipefail an absent bridge would kill the script instead of
answering "no bridge yet")
· the drill, multiuser rehearsal and migrate-host probes derive the
prefix from 'incus network get boxnet ipv4.address' instead of
hardcoding 10.88 — a BOX_SUBNET host must not fail its own rehearsals
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three of the four round-1 nits, taken: --force's overwrite role on
export now sits in OPTIONS and 'help export' (it was only in the die
text); the restricted-tier rehearsal asserts restricted.backups=allow
beside the snapshots check it mirrors, so the grant key #70 depends on
is proven live, not only grepped; and reset_identity says "instance" —
it has two callers now, and only one of them clones. The install.sh
message stays #79's (the documented merge order).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
'box versions' lists what is installed (current + running marked); 'box use'
flips the default, refuses under existing boxes (#66), and asserts the
EFFECTIVE result — current resolves to the version asked for and the chain's
own --version answers it. 'box uninstall' is the real uninstall the old
two-rm-rf-lines of prose never were: one version, or everything in the safe
order (boxes first — refuse or --purge-host — then trees and symlinks), and
it ends by re-checking every promised path is gone, exiting 1 naming
leftovers (the revoke --purge discipline). teardown-host grows --yes/BOX_YES
for the unattended paths and points at 'box uninstall'; the drill reads the
installed tree through current/.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
incus 6.0.4 lets a restricted cert reference an allowed network with a raw
--network flag; 6.0.0 (ubuntu-latest) refuses it at the permission layer.
The first is the world criterion (m)'s measurements exist for; the second
is PREVENTION — the strongest of the review's three resolutions — and the
probe now recognizes it as such (with the refusal quoted) instead of dying
unmeasured. The (n) blocker is now staged with 'incus config device
override' — their own instance, their own stock profile NIC lifted
instance-local, permitted on both versions, and the exact shape that
actually blocks narrowing. Both staging failures now print their reasons;
swallowed narration is how this file's false FAILs happened.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review 4727756972 (A2): the backout no longer trusts gpasswd — it re-reads
the live group database after removal; verified-absent gets the safe
message, anything else screams ROLLBACK INCOMPLETE, exits nonzero, and
names the exact remediation. The concurrent-login window (a session begun
between usermod and backout keeps the group) is CLOSED to the extent the
database can't reach: the backout detects live processes and names
loginctl terminate-user, and the success wording claims only what was
verified.
Review 4727641752 (A1): a failed grant for a user whose membership predates
the run (the hand-added-user scenario) now fails LOUDLY — they retain
socket access on part-converged policy, and the message says so with both
remediations (box revoke now, or fix and re-run). Their membership is not
stripped: breaking a working user over a failed re-grant is its own hazard.
The default-profile eth0 removal is deliberately not restored on failure —
that mutation only reduces capability, and restoring it would move the
failure state AWAY from fail-closed. Injected-failure coverage is criterion
(n), both flavors: fresh-user backout (fault at the LAST mutation, so the
rollback runs after every earlier one) with the group's absence verified
and a converging re-run; blocked narrowing staged for real with an
instance-local NIC parked on the private bridge.
Review A3, resolution 3 with the measurement demanded: criterion (m)
launches exactly 'incus launch --network boxnet' as the restricted user and
probes the raw NIC from inside — egress works, RFC1918 dropped (the ACL is
the network's), sibling probes dropped BOTH directions (the nft drop is the
host's), name enumeration blocked. The scoped guarantee is now stated in
box-design.md and measured on every run: box-minted instances carry per-NIC
port_isolation; raw attachments keep every network- and host-owned control,
losing only that redundant L2 layer. Instrument lesson kept as MU-5: the
probe's first cut minted the non-cloud image — no DHCP client, no lease,
and a dead NIC passes every negative probe vacuously; it now requires the
lease before believing its own answers.
Rehearsal: 54/54 (containers). test/cli.sh: 82 checks.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
incus 6.0.4 refuses the private-bridge attach before 'Launching', 6.0.0
(ubuntu-latest) after it — with different words. Chasing message strings
across releases is a losing game: the probe now demands the fact (rc!=0 AND
nothing running on the bridge) and prints the refusal line as evidence in
every run log. First caught by the CI rehearsal doing exactly the job it
was added for.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Supplementary groups are read at LOGIN, so 'gpasswd -d' does nothing to a
session the user already holds — and after --purge, a stale-group process
could touch incus-user and lazily RECREATE the project with stock defaults:
the unhardened NAT bridge, un-narrowed, strictly worse than the granted
state. Adversarial review caught it; verified live, then closed:
- revoke --purge terminates the user's sessions first (loginctl, then
pkill), and refuses to purge under processes it cannot kill
- bare revoke says out loud that held sessions keep the socket until they
end, and names the loginctl command — instead of claiming a lockout it
did not deliver (help/README/design doc reworded to match)
- a failed grant backs out its own group-add on exit (trap, disarmed on
success): no half-granted user holding an un-narrowed socket while the
admin reads the error. Verified by injecting a bad profile YAML
- the rehearsal now holds a session open across the purge and demands it
dies with the tier (criterion l, 42nd check)
Smaller review findings, same pass: the escape-hatch probes assert the
refusal's REASON instead of any nonzero exit (an image hiccup must not read
as 'the escape is closed'); probe_from maps an outer-timeout kill to
dropped, not reachable; the rehearsal cleanup keeps the account when a purge
fails so doctor can name the leftovers; the purge asserts the trust
certificate's absence; cmd_new distinguishes a dead daemon from a missing
stack before prescribing setup-host; grant's success message names the
user-<uid> bridge variant correctly on big-uid hosts.
Rehearsal after: 42/42 (containers). test/cli.sh: 76 checks.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The plan doc records what was measured and why each decision fell where it
did: the private bridge is worse than unhardened (a live NAT bridge with
IPv6 on), incus-user blocks snapshots, no daemon-level template exists (read
in incus-user's source), widening survives re-sync (same source, then
measured live). box-design gains the access-tiers section — including why
narrowing to boxnet-only is the load-bearing decision and why the nft bridge
drop is the layer a restricted user cannot strip. RUNS.md logs MU-1..3.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
drill/multiuser.sh: two throwaway users, real grants, real boxes, and the
contract measured from INSIDE them — #74's criteria (a)-(f) plus what the
findings added: the cross-user sibling drop, the closed private-bridge
escape hatches, incus-user re-sync survival, scoped revoke --purge. Its own
first runs are in RUNS.md (MU-1..3): both false FAILs were the instrument,
and both lessons (keep the mint's narration; probe a SIBLING's name, never
your own — /etc/hosts answers for you) are now comments in the script.
test/cli.sh: box_tier() driven against a shim id for all five cases
(including both-groups → admin: membership wins at the socket), the two
copies diffed byte-identical, the tier scripts' usage contracts driven for
real, and grep-guards on every load-bearing line a daemon-free run cannot
exercise — the expose guard's position, the boxnet-and-only-boxnet
narrowing, the snapshot allow, 'incus:snapshot restore'.
ci.yml: a second job stands up the real stack on the runner (setup-host,
doctor, then the rehearsal in container mode) — every PR now proves the
tier's semantics against a live daemon. The tier's mechanics are
instance-type-independent (the drop, the ACL, dns.mode and port_isolation
bind to boxnet, not the instance); the VM trust boundary itself stays a
real-hardware ritual, like the full drill.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
incus-user confines an incus-group user to their own project, but its
defaults miss box's contract three measured ways (Debian 13 / Incus 6.0.4):
a private UNHARDENED NAT bridge per user (ipv6.nat=true, no ACL, no DNS
isolation), snapshots blocked, and the box-net profile invisible to their
project. So the tier is an admin-run idempotent convergence:
box grant <user> # incus group; touch incus-user (the project is lazy);
# drop the private-bridge eth0 from their default
# profile; restricted.networks.access=boxnet — and ONLY
# boxnet, or the unhardened bridge stays one --network
# flag away; restricted.snapshots=allow; install the
# shipped box-net profile into their project
box revoke <user> # group removal closes the socket, boxes keep running
--purge # ...or delete their world, and assert the absence
box_tier() (live credentials, argless id -nG; byte-identical copy in
setup-host.sh) drives the tier-aware surface: new pre-flights the profile
and names the right fix per tier, expose refuses before any daemon call
(without the guard the failure is a lie — restricted certs cannot read
boxnet's redacted config, so box_net_ip claims a running box has no
address), setup-host exits 0 with the honest note, doctor judges only what
the caller can see.
Also fixed while the rehearsal exercised the lifecycle: box restore
dispatched 'incus restore', which does not exist in Incus 6 (it is
'incus snapshot restore') — the verb had never worked. Fixed for every tier.
Convergence survives incus-user restarts by that tool's own design (it
configures a project only at creation) — read in its source, then measured.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
box had no CI and no unit tests — only the live-host drill. Mirror rig's CI:
one `check` job = globstar `shellcheck -x` over bin/* and **/*.sh, then
`bash test/cli.sh`. The suite is dependency-free and runs non-root with no
Incus: the full CLI contract; install.sh's DEST/BINDIR branch driven
functionally against a shim `id` (both tiers + the BOX_HOME/BOX_BIN overrides);
the root-only a+rX and #66's confirm/no-op flow grep-guarded; tmux asserted in
every template. Pre-existing repo shellcheck findings (bin/box SC2034/SC2015/
SC2020, and file-level SC2015 idioms in doctor.sh/wipe.sh/migrate-host.sh) were
resolved — real fixes where behaviour allows, reasoned disables otherwise — so
the new CI is green over the whole repo.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Implements the flow @danmt specified on #66. The installer now asks before it
acts, and never overwrites itself.
1. "Install box?" — prompted before anything is downloaded.
2. If box is already installed: say so and stop. A re-run changes nothing, so
curl|bash can no longer clobber a working tree or rebuild the host stack
under live boxes. Upgrading is explicit — uninstall, then install fresh.
3. On a fresh host: download, install, link onto PATH.
4. "Set up this machine as a box host now?" — a separate decision, because the
CLI and the host are not the same choice (you may host boxes elsewhere).
This replaces the version-diff refusal from the previous round with the simpler
rule Dan asked for: installed at all => no-op. It dissolves the same class of
"the upgrade ate my boxes" errors without the installer having to reason about
versions or enumerate boxes at all — you cannot lose boxes to an install that
refuses to touch an existing one.
Prompts read /dev/tty, because under curl|bash the script itself is stdin and a
plain read would eat the installer's own remaining lines. With no terminal
(CI, a pipe) BOX_YES=1 assumes yes and is required to proceed unattended;
without it we refuse rather than invent consent. BOX_SKIP_SETUP_HOST=1 declines
the second prompt.
The drill uninstalls before installing (the no-op rule would otherwise refuse
to re-lay the tree it re-proves each run) and sets BOX_YES=1 for the prompts;
BOX_FORCE_UPGRADE is gone with the refusal it drove.
Verified on a real host: cancel, fresh install, no-op re-run, and both prompts
driven through a pty (y/n and y-then-n), plus the no-tty refusal.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per @danmt on #66: hatch first, the version-aware migration as its own issue.
Building the host stack from the installer means an upgrade is no longer a tree
swap — it reaches under every box attached to that stack. So the installer now
declines to guess. Same version and ref: it says so and changes nothing.
Version or ref change with boxes on the host: it refuses, lists them, and does
so BEFORE $DEST is touched, so a refusal leaves the working install intact. No
boxes: nothing to lose, proceed. BOX_FORCE_UPGRADE=1 overrides, and the drill
sets it, because arriving on a dirty host and wiping it is the drill's job.
Ref, not just VERSION: a branch and main carry the same VERSION string, so
VERSION alone would call an install of this very branch "unchanged" and skip the
hatch. Both tag generations count as boxes — a pre-rename user.claudebox=1 box
is just as much someone's work as a current one.
The box query runs unprivileged first and escalates only if the socket refuses:
anyone who owns boxes is already in incus-admin, and an installer should not
demand a sudo password merely to look.
The error deliberately does not suggest snapshot -> rm -> restore --from: 'box
rm' deletes a box AND every snapshot it has, so that path loses the data at the
rm. It says to copy anything needed out of the box first. Raised on #67.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Review found two real problems, both confirmed by reproducing them.
setup-host hardcoded 'sudo' for every privileged call, so install.sh's
deliberate root branch — the one that proceeds when id -u is 0 even with no
sudo installed — handed off to a script that died on 'sudo: command not found'
before doing anything (exit 127, reproduced with env -i and a minimal PATH).
The root path was nominal, not real. Privilege is now resolved once: nothing at
UID 0, sudo otherwise, a clear error if neither is possible.
Two things fell out of that. Root does not need incus-admin at all (UID 0 opens
the socket regardless), so adding root to the group was a no-op that also missed
the human — under 'sudo install.sh' that is SUDO_USER, who is now the one
granted the group. And apt must not hang: install.sh runs setup-host with nobody
watching, while a fresh cloud image holds the dpkg lock in apt-daily for its
first minutes, so the calls are now bounded and non-interactive.
The drill did not exercise any of this. It ran setup-host immediately after
install.sh, so the stack existed by the drill's own hand and a run passed
identically whether or not install.sh had done a thing — a fresh run converged
three times while its messages still described the pre-#63 "first pass may only
add you to the group" behaviour. It now asserts the post-install stack in-group,
before the clean or anything else mutates the host, which is the assertion that
actually proves #64. setup-host then runs exactly once more, after the clean —
that one is load-bearing, since the clean deliberately unsets dns.mode and
something has to converge it back. DRILL_OWNS_SETUP=1 hands sequencing back to
the drill. Pre-setup tripwires now read before install.sh, because install.sh is
what triggers setup now; read afterwards they said nothing.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A bare host got two DIRTYs (the box-to-box drop 'MISSING', the host's
Tailscale resolver) and a 'NOT fit to mint (or to drill)' verdict — and
the very next drill run went 84/84 green from that exact state. Missing
from a stack and never set up are different findings: the network,
profile and ACL sections already knew this; the firewall and resolver
sections now do too. FRESH (no boxnet) downgrades both to information —
the VPN resolver is still named, as a fact about the host that setup-host
pins around, not a fault in a stack that does not exist. The clean
verdict on a fresh host now says what to actually do: run setup-host, or
the drill, which sets the host up itself.
Verified both paths live: standing stack → 'clean', post-teardown →
'fresh' with no DIRTYs.
Also: the measured drill count is 84 (README said 83 — the box-info
exposure check was a NOTE when last counted and is a PASS now).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The drill section showed the commands but not the step that actually
bites: making sure the checkout you run is the code you mean to judge.
Two versions are in play — the drill script itself, and the (repo, ref)
the drill installs from GitHub and asserts before any verdict. Spell both
out, plus --repo/--ref for drilling a release or a PR branch.
Also catch drill.sh's REPO default up with the rename — it still said
heavy-duty/claudebox (GitHub redirects it, but the default should name
the repo that exists).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Same treatment as the README, applied to the prose that stood in "Claude" for
"the coding agent": box-recipe.md and box-design.md now describe the `.box/`
runbook and creds-free flow around whichever agent the box was minted with, and
name the per-template context file (`~/.claude/CLAUDE.md`, `~/.codex/AGENTS.md`,
`~/.grok/AGENTS.md`) instead of hardcoding Claude's. drill/README.md's
"does not check" note says the drill confirms each template's CLI, not just
Claude Code. `claude` stays as the concrete login example throughout.
Left untouched (out of scope, literal identifiers): the legacy isolation-stack
names (claudenet/claude-dev/claude-isolate, user.claudebox), the claude template
files themselves, and drill/RUNS.md history.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Resolution is most-specific-first: flag > BOX_* env (kept — it is the
scripting form and how the drill shrinks boxes on small hosts) > the
template's box.env > defaults. Values pass to Incus verbatim (limits.cpu,
limits.memory, root size=) — its units, its validation; box adds no
parser. Resources are all a flag can touch: there is still no flag for a
network or a security.* key, on purpose.
Flags shape a fresh mint only — --from refuses them, a clone carries its
source's resources. An explicit --disk on a container mint gets a note
instead of a silent drop (a container's root rides the pool).
The drill's blank mint now carries --cpu 1 --memory 1GiB and asserts the
limits landed — which is also the precedence proof, since the drill
exports BOX_CPU/BOX_MEMORY on small hosts — plus a negative check that
--from refuses resource flags.
Verified live (container mint, image cached): BOX_CPU=3 + --cpu 1
--memory 1GiB → limits.cpu=1, limits.memory=1GiB; --from + --cpu exits 2
before touching anything; container --disk prints the note.
Closes#57
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Run 18: 74/2. grok passes (reading the installer worked), migrate's
retire passes. Two left, both mine:
1. box expose — read the Incus proxy docs instead of guessing again.
Proxy IS supported on VMs, but NAT mode only (correct already), and
crucially it does NOT need a static address: with no static IP, Incus
reads the box's address off the bridge's neighbour table and keeps
the NAT rules in step. My first cut pinned the lease with a device
override anyway — unnecessary, and almost certainly the step that was
failing before the proxy was ever added. Dropped it. Ask for less.
The drill also stopped throwing incus's reason away: it swallowed
stderr, then RE-RAN the command and printed only the last line ('box:
expose failed') — box's own words, never incus's. It now captures the
first attempt and prints all of it.
2. 'legacy box never came up' was unfixable by any timeout. wait_box
uses 'box exec', which for a legacy-tagged box resolves the user to
'claude' — and the drill's synthetic legacy box was a BARE image with
no claude user, so sudo -u claude could never answer, on a box that
was perfectly healthy (every migration check against it passed). The
fake legacy box now creates a claude user, like a real pre-0.4.0 box
had.
Fetched https://x.ai/cli/install.sh and read it, rather than inferring
the layout from docs. Three facts, every one of which the template had
wrong:
· the CLI installs as 'grok' (with an 'agent' alias) — NOT 'grok-build'.
So the drill was checking a command that never existed.
· BIN_DIR defaults to $HOME/.grok/bin, and what lands there is a
SYMLINK into the versioned download dir — which is exactly why the
template's 'find -type f' found nothing.
· GROK_BIN_DIR can override the directory.
The install was almost certainly succeeding the whole time; the template
was hunting for the wrong name, as the wrong file type, in the wrong
place. Now it links the known path onto the system PATH, asserts
'grok --version' answers, and dumps what the installer actually left if
the upstream layout ever moves.
Also corrected: the drill's version check (grok, not grok-build), the
agent briefing ('grok login'), the template description, and the README
row. The lesson is the repo's oldest one — read the thing, don't reason
about it.
The first drill run where every failure was the RELEASE CODE, not the
environment. 71 passed, 5 failed; all five traced to four bugs:
1. migrate-host --retire-legacy could NEVER succeed. Re-homing ADDS
user.box=1 but never removed user.claudebox=1, and legacy_boxes()
counted the old tag — so retire saw its own freshly-migrated box as
un-migrated and refused forever ('legacy boxes still exist:
legacybox'), leaving claudenet + claude-dev behind. Now: a verified
re-home drops the legacy tag LAST (after the move is proven, so a
failure anywhere above still leaves the box valid under one tag or
the other), and legacy_boxes() ignores boxes already carrying
user.box=1.
2. box expose died with a bare 'could not add the proxy device' — it
swallowed incus's reason, exactly the sin this repo keeps punishing.
Now it prints incus's error. And the mechanism is corrected: a VM's
proxy needs NAT mode, which requires a static NIC address, so expose
pins the box's current lease first (which also fixes the restart
caveat — the exposure no longer points at a lease the box may lose).
3. wait_box's 2-minute window was too short: the legacy box was declared
dead and then every migration check against it passed. 4 minutes.
4. The grok template hunted for a regular file named exactly
'grok-build' under /home/grok and found nothing — an installer's drop
may be a SYMLINK, and its binary name is upstream's to choose. Now it
tries the plausible names and paths, falls back to any executable
grok*, links both names, and SAYS what it found — or dumps what the
installer actually left when it finds nothing. The drill likewise
dumps the on-disk evidence and the cloud-init log on a --version
failure instead of discarding the box.
The 0.5.0 env-var rename (CLAUDEBOX_* -> BOX_*) created a silent trap:
a STALE local drill.sh passes CLAUDEBOX_REPO/REF, today's install.sh
reads BOX_REPO/REF, the vars are ignored, main gets installed — and the
drill runs to a green summary having drilled the wrong tree entirely.
The same class already cost an hour once via a lagged CDN tarball.
install.sh now records what it installed (/INSTALLED_FROM), and the
drill ASSERTS it matches the requested repo@ref before touching the
host — failing loudly, and naming the stale-checkout cause, instead of
drilling a lie.
Two things:
1. The host scripts are first-class verbs now — 'box setup-host',
'box teardown-host [--purge-incus]', 'box migrate-host --box <n>'.
Nobody should have to run ~/.local/share/box/host/<script>.sh; that
read like an external script and exposed an install path. Each verb
execs the installed script with its flags passed through (same
pattern as 'box doctor'). README, doctor hints, and the uninstall
section point at the verbs now.
2. The repo-runbook convention is '.box/', not '.claudebox/'. Renamed
across docs and the templates' agent briefing; the briefing tells
the agent to read either, and box-recipe.md notes the rename, so
repos still shipping '.claudebox/' keep working through the
transition. (Consuming repos rename their own folder — tracked
separately.)
Also widened the help command column for the longer verb names, and
fixed one sed-casualty where a broad '.claudebox'→'.box' pass had
turned the README's legacy user.claudebox tag into user.box.
The 0.4.0 rename left surface leftovers the user hit through the
CLAUDEBOX_* env vars. Sweep them, drawing a clean line:
box = everything the user touches — env vars (BOX_REPO/REF/HOME/
BIN), installer messages (box-install:), the install tree
(~/.local/share/box, with the installer sweeping the old
~/.local/share/claudebox on upgrade), tool prose, and the
docs (docs/box-{design,recipe}.md).
claudebox = the GitHub repo name (URLs, the claudebox-<ref> tarball
dir, issue refs), the legacy user.claudebox=1 tag, the
old-stack cleanup code (claudenet/claude-dev/claude-isolate/
claudebox-firewall), and the .claudebox/ runbook convention
— a deliberate v1 hold, since renaming it breaks consuming
repos.
Renamed the two doc files and their links; updated drill.sh/doctor.sh
paths and BOX_REPO/BOX_REF; fixed the claude template's in-box briefing
to say 'box'. RUNS.md left as-is (append-only history). No behavior
change beyond the install-dir move, which the installer migrates.
The console log finally showed the real error behind the GRUB-menu hang:
error: prohibited by secure boot policy.
error: bad shim signature.
Failed to boot both default and fallback entries.
Incus defaults VMs to security.secureboot=true. A Debian cloud image
whose shim is signed with a key this host's OVMF does not trust then
fails signature verification, the kernel never loads, and the VM sits
at the GRUB menu forever — which is exactly the 5-min agent timeout on
every box. It worked in runs 11–15 on the old cached image and broke
the moment --purge-storage re-downloaded a build with a different shim.
security.secureboot=false on VM launch (cmd_new, and the drill's legacy
box). Secure Boot inside a throwaway box is not part of its threat
model — the VM boundary is — and off, it boots reliably across image
rebuilds. Container mode has no firmware and is unaffected.
Bare repro that isolated it: 'incus launch images:debian/13/cloud x --vm'
alone reproduced the hang, proving it was never the 0.5.0 code.
Two bugs surfaced running the 0.5.0 drill with --purge-storage on a
cold btrfs pool:
1. wait_agent dumped the VM's RAW console log on timeout — full of
terminal escape sequences and a firmware menu — which scrambled the
operator's terminal, and doubly so when it landed in a log they were
tail -f'ing ('*Debian GNU/Linux', 'ESC to return previous menu',
^[^[^[). Now: capture to /tmp/box-console-<n>.log, strip everything
but printable ASCII + tab/newline, print only a short sanitized tail.
Nothing raw reaches a terminal.
2. A failed mint left its stuck VM running, starving the NEXT box's boot
and cascading more 5-min timeouts (tpl failed → codex failed). Every
mint-failure branch now tears the box down before continuing.
The 'no inbound path' contract is one notch too absolute for the tool's
own flagship workflow: coding in a box, a dev server on :3000, and no
way to open it in your browser. expose is the deliberate un-screwing.
- Loopback only, always: the host side listens on 127.0.0.1, never
0.0.0.0 — no other machine can reach the box; only this host gets a
door. No flag widens it (that is the escape hatch's job).
- A verb, per-port, reversible, visible: each exposure is a named proxy
device (expose-<port>); --list and box info show it, --remove undoes
it. A box with a hole says so.
- Mechanism (VMs): an Incus proxy device forwards host loopback to the
box's ip:port, plus a SCOPED ingress ACL allow (this box's ip + this
port only) so the forkproxy's connection survives the default drop —
the drill decides whether that allow is needed or redundant. The
in-box server must listen on 0.0.0.0 (a VM's forwarder reaches it over
the network); inside an isolated box that is safe.
Drill phase E: start a detached listener in a box, expose it, prove the
HOST loopback reaches it, prove a NON-exposed port is still dropped (A7
survives), prove --remove shuts the door.
Closes#55
The 0.4.0 transition is zero-ceremony (install + setup-host = a
dual-stack host). This script is the two things that path does not do:
- --box <name> / --all-boxes: re-home a pre-rename box onto the new
stack, PRESERVING its authed state (no re-login). Order is
load-bearing — tag first (additive, reversible), profile-assign last
(the network move), then verify the box actually resolves + reaches
the internet on its 10.88 leg before declaring it migrated. A box
never ends up tagless or profileless.
- --retire-legacy: remove claudenet/claude-dev/claude-isolate and the
old firewall unit + nft tables, but REFUSE while any legacy box still
references them; assert their absence rather than trust exit codes.
Drill phase M builds a faithful legacy stack (claudenet on 10.87, a
claude-dev profile pinned to it, a box on the old tag), then proves:
retire refuses with a legacy box present, re-home flips the tag +
reassigns box-net + lands a 10.88 address + resolves, and retire then
succeeds and leaves nothing. The transition is measured, not asserted.
Closes#53
Two coding-CLI templates mirroring claude's shape: a box.env + verbatim
cloud-init, inheriting the box-net placement contract structurally, no
new design.
- codex: OpenAI Codex CLI via 'npm i -g @openai/codex' (the SCOPED
package; needs Node 22), symlinked onto the non-interactive exec PATH
via 'npm prefix -g' — the same PATH fix the claude template needed.
- grok: xAI Grok Build via the official 'curl x.ai/cli/install.sh',
run AS the grok user (the installer drops into $HOME); the binary is
found and symlinked to /usr/local/bin.
Install commands verified upstream at implementation time, per the
issue's rule (npmjs.com/package/@openai/codex, x.ai/cli). Each gets an
AGENTS.md-style context file telling the agent it lives in a
disposable, isolated, creds-free box.
Drill: templates listing now expects four; a compact per-template smoke
(mint, '<cli> --version' via box exec, remove) validates each payload
installs and lands on the exec PATH — the generic mechanic is already
proven by blank+claude and not repeated.
Closes#54
Operator watched /tmp/new.log through a claude mint and saw not one
message: cloud-init's progress dots are block-buffered the moment
stdout is not a tty, so a redirected mint shows nothing for the whole
install and then one burst — which reads exactly like a hang, on the
very night three real hangs happened.
PYTHONUNBUFFERED=1 on the cloud-init wait makes the dots arrive as
dots; box new also prints how to watch the box's own full narration
(incus exec <box> -- tail -f /var/log/cloud-init-output.log); and the
drill's logs are named for the box being minted (/tmp/mint-drill.log),
not for the verb that mints it.
All four mints (blank, claude, clone, peer) now run through mint_box:
box new's narration lands in the log as before, the drill prints where
to tail it, and a dot every 5s on the drill's own terminal proves the
run is alive. A silent multi-minute mint is indistinguishable from a
wedge, and that ambiguity has cost whole evenings — the operator said
so, verbatim.
Run 14's second catch: the blank mint's cloud-init finished, printed
'status: done' — and 'box new' hung for 15+ minutes on an exec session
that never closed ('incus operation list' showed it still RUNNING).
With a TTY on stdin (the drill redirects only stdout/stderr), incus
exec goes interactive, and the session can wedge open after the remote
command has exited. Same disease as drill trap 2 and doctor trap 13;
the CLI's own execs never got the cure.
Every non-interactive exec now pins stdin: wait_agent's probe, the
cloud-init wait, both failure-path reads, and the clone identity
reset. shell/exec/tmux keep the terminal — owning it is their job.
Also: wipe.sh keeps cached images on a plain wipe. An image is
upstream's artifact, content-addressed by fingerprint — deleting it
buys zero cleanliness and costs the next mint a full re-download. It
goes only with --purge-storage, where the pool it lives in goes too
(and it must go first: images block pool deletion).
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
Every fault the drill's doctor diagnoses is a user's fault first: a
wedged Incus daemon, a dnsmasq that silently isn't serving, a VPN
resolver boxes inherit, isolation claimed by config but off in the
kernel — each has killed a cold mint or weakened a boundary, with a
cloud-init error that names none of them. The CLI half-admitted it:
cmd_new's failure path hand-pointed at issue #33, doing one special
case of a doctor's job inline.
The verb delegates to the installed drill/doctor.sh (the tree ships
whole) — one hardened script, two audiences. Its flags pass through;
the verdict now reads 'fit to mint boxes (and to drill)'; the mint-
failure hint ends with 'claudebox doctor' instead of the hand-rolled
diagnosis.
Closes#46
The log stopped at run 10's headline (boxes reach each other). Three
runs later the arc is closed and RUNS.md should say so:
- run 11 (45/2): A3 re-answered with the fixed probe — BLOCKED,
security.port_isolation; both FAILs were the drill's own empty-host
assumption
- run 12 (46/0): first zero-failure run
- run 13 (47/0): full teardown → rebuild → drill from a bare host,
pinned resolver built from scratch on a Tailscale host
Answer table updated (A3/A4 show fixed + re-measured state), headline
flipped to match, traps 13–15 added (TTY stdin under timeout, the
gateway-ping false alarm, demanding an empty host), run history gains
the three rows and a closing note.
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
Run 11: 45 passed, 2 failed — and both failures were the same non-fact.
The host had two operator boxes (t1, t2) minted before the run, so
'claudebox list' could not say 'no boxes yet' at either end of the drill.
The empty-host message is only testable on an actually-empty host: skip
it (with a note) when tenants pre-exist, instead of failing it.
The teardown check contradicted its own rm loop: the loop deliberately
removes only the names the drill minted — the multi-tenant discipline —
and then the assertion demanded a globally empty host, flagging that
very restraint as a failure. Assert the drill's own names are gone;
name the survivor when one is.
claudebox-firewall.sh drops everything from a box to the host except
DNS (53) and DHCP (67); ICMP to 10.87.0.1 dies in that trailing drop on
every healthy host. The doctor used exactly that ping as its routing
probe, so it reported 'cannot even reach the gateway' — and a NOT-fit-
to-drill verdict — on a host whose very next line proved DNS working
through that same gateway.
Probe routing the way the contract states it: a box reaches the public
internet. curl to 1.1.1.1 by address, reusing the one probe for the
DNS-failure diagnosis instead of running it twice.
With a TTY on stdin, 'incus exec' goes interactive and puts the terminal
in raw mode. The DNS probes then hang forever: timeout's bare TERM never
lands (no -k escalation), and ^C is forwarded into the box as a keystroke
instead of killing the script. Observed live: doctor hung 15+ minutes at
'Can a box actually resolve DNS?' and survived Ctrl-C; the operator had
to kill the shell.
The drill already learned this exact lesson in #22 (exec_in pins stdin
and uses 'timeout -k'); the doctor's probe section was added later in
#34/#35 and never got the cure. All four probes now pin stdin to
/dev/null and escalate to SIGKILL.