Review found the first cut incomplete, and the gap is the shape the #118 incident actually had. Containment catches a heading that VANISHED. It cannot catch one that was DUPLICATED: the duplicate is head-side surplus, and `comm -23` (base minus head) is blind to extras on the head side -- with or without `sort -u`. Multiset comparison does not close it either, for the same reason: base {0.8.0} minus head {0.8.0, 0.8.0} is still empty. The assert that does close it is uniqueness of version headings ON HEAD, kept ALONGSIDE containment rather than replacing it, since containment remains the right check for deletions. That matters because the duplicate is not hypothetical. It is what a bad rebase of #116 produced an hour before this commit: two `## 0.8.0 - 2026-07-19` headings with the incoming entry stranded between them. Every other guard was green on that tree -- no conflict markers, changelog-armed.sh happy because the top section was still right for the VERSION, tests and shellcheck clean -- while release-notes.sh re-armed its grab on the second heading and folded post-cut prose into the shipped 0.8.0 body, and the stranded entry would have been dropped from the next release's notes as well. Uniqueness fires on nothing legitimate: the ceremony stamps a NEW version, and 'Unreleased' fails the version shape before it reaches here. Both trees are now pinned in test/release.sh side by side -- the deletion near-miss and the real duplicate -- each with changelog-armed.sh asserted green on it, which is the whole reason this script exists separately. The duplicate case also pins that the containment arm stays silent on it, so a future simplification cannot collapse the two asserts into one. Proven non-vacuous: stubbing the uniqueness check to empty turns the suite red on exactly the three new assertions (117/3); restoring it returns 120/0. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
54 KiB
Changelog
History before 0.5.0 lives in git and in drill/RUNS.md, which records not just what changed but what each drill run proved.
Unreleased
Changed
-
The tenant templates carry rig's family suffix:
claude→claude-box,codex→codex-box,grok→grok-box,staging→staging-box(#123, following heavy-duty/rig#76) — rig is growing a second family of roles, and once astagingrole can mean either a fleet machine or a box tenant, the bare name stops naming anything. rig's answer is a suffix on the role itself —-serverfor fleet machines,-boxfor box tenants — and box's answer is that a template keeps being named for the role it converges. Templates are the only surface that spells a rig role out loud (BOX_BOOTSTRAP_ROLE, auto-run at mint since #81), so a template whose directory says one thing and whose role key says another is a trap with a 15-minute fuse: it mints clean and dies at convergence.blankkeeps its name — it seeds no tenant role, sets noBOX_BOOTSTRAP_ROLE, and therefore has nothing to agree with. Two namespaces move apart here and only one of them moved: the template name and the role are nowclaude-box, while the seed USER staysclaude, because that is the user the rig role converges and the onebox shelllands in.test/cli.shpins the pair per tenant rather than each half alone — a future rename that moves one and forgets the other mints a box whose role dies looking for a user nobody created.This lands after rig's rename, not before, and the ordering is not a preference. The seeds install rig from
RIG_REPO/RIG_REF, which default toheavy-duty/rig@mainand are unpinned until rig#32's releases — so a box minted from these templates asks whatevermainhappens to be forrig bootstrap claude-box. Against a pre-rename rig that role does not exist,cmd_newrefuses to call the box ready, and the operator is handed a failed mint for a change neither repo has finished making. Merged in the other order the window closes instead of opening: rig's roles are a hard cut with no aliases, so the day rig's rename lands, every unmerged box seed naming a bare role is the broken one.One deliberate asymmetry: the mint-time hints in
cmd_newmatch both the new and the old spelling ofuser.box.template. That is not an alias for the role — nothing here softens the cut, andrig bootstrap claudeis gone. It reads a stamp left on an instance at its own mint time, which every box minted before today carries forever and every clone carries forward; refusing the old spelling would cut nothing over and only drop the login hint on boxes that predate the rename, the same reasonuser.claudeboxis honored everywhere else.migrate-host.shstamps re-homed legacy boxesclaude-box, the name the template has today, so a re-homed box looks like a fresh mint rather than a fossil.The machine-role half of rig's rename reaches box too, in one place: the tailnet workload join box prints as a next step for a
staging-boxguest is nowsudo rig bootstrap workload-server. box never runs it — it holds a pre-auth key, and that it stays operator-run is the absence keeping box creds-free end to end — but box does print it, in three places that all had to move together (cmd_new's hint, thestaging-boxseed's own comment, and the README). A next step an operator copy-pastes is as wrong as a role box executes, and it fails later and further from the cause.
Fixed
-
CI's shellcheck sweep never lints
.github/scripts/*.sh(#116) —globstarmakes**descend into subdirectories, but a glob still does not match a dot-prefixed name, so**/never entered.github/. The three scripts that escaped are the release path:changelog-armed.sh(the #108/#110 guard that gates every PR, and had never been linted),release-notes.sh(which produces the published release body), andlabels-reconcile.sh(the label state machine) — while the step's own comment promised that "a script in a new subdirectory is linted without anyone remembering to edit this list". Latent, not broken: all three pass shellcheck as-is, so this lands as a no-op on current code and the fix is that a regression in them would now be caught.dotglobalongsideglobstarcloses it, measured rather than assumed — it adds exactly those three and nothing else, a checkout's.gitcarrying no*.sh(its hooks ship as*.sample). Paired with a CLASS check in the same shape as theeof_guard_sweepof #112: the sweep now compares the globbed set againstgit ls-files '*.sh'and fails naming any tracked script it does not cover, so the gap cannot reopen silently the next time a dot-directory or a shopt subtlety hides one.eof_guard_sweepitself carried the identical blind spot — it rebuilds the same glob — and is widened the same way. -
A PR can no longer delete a shipped changelog section and stay green (#122) — caught in review of #118, where an entry added under
## Unreleasedreplaced the line## 0.8.0 — 2026-07-19instead of being inserted above it. The whole shipped 0.8.0 record was absorbed into## Unreleased, git merged it cleanly — a one-line edit, no conflict, no signal — andchangelog-armed.shwas green on that exact tree, correctly: it asks only whether the TOP section agrees withVERSION, and## Unreleasedwas still on top. The damage would have surfaced at the next release, whenrelease-notes.shcould no longer find the section it extracts by heading, or worse, republished the absorbed prose as new..github/scripts/changelog-monotonic.shasserts the complementary invariant on every PR: release headings are append-only, so the set of## X.Y.Zheadings on a branch must be a superset of the set at its merge base. A separate script rather than a clause inchangelog-armed.shbecause "a heading disappeared" is a property of a DIFF, not of a tree — and becausechangelog-armed.shis driven against constructed non-git fixtures that could not express it. The ceremony's stamp passes by construction (it addsX.Y.Z, removes none), and no base ref to compare against is a loud SKIP locally but a hard failure in CI, which setsCHANGELOG_MONOTONIC_STRICT=1and checks out withfetch-depth: 0so the guard can never quietly stop guarding.Review of this PR found the guard's first cut incomplete, and the gap is the shape the incident actually had. Containment catches a deleted heading; it cannot catch a duplicated one, because the duplicate is head-side surplus and
comm -23(base minus head) is blind to extras on the head side — with or withoutsort -u, and multiset comparison does not close it for the same reason. So the guard now also asserts that version headings are unique on HEAD, alongside containment rather than instead of it. Nothing legitimate repeats one: the ceremony stamps a new version, andUnreleasedfails the version shape. Both trees are pinned intest/release.sh— the deletion near-miss and the real duplicate — each withchangelog-armed.shasserted green on it, which is the whole reason this script exists.
0.8.0 — 2026-07-19
Added
- Merging the release PR IS the release — and the release re-arms main
itself (#96) — the 0.7.0 ceremony ended in an absence: the release PR
merged with four approvals and nothing happened, correctly, because
publishing hung off a separate, manual, silent-when-forgotten tag push —
a failure shape with no error and no red X. The ship decision already
lives in the release PR (the one PR whose whole diff is "the version
leaves
-dev"), sorelease.ymlnow fires on pushes to main (fork-sourced ceremony PRs get a read-only token onpull_requestevents), reading the transition from the push itself:event.beforeto the pushed head. A decide step answers four states — release-flow work merged under thereleaselabel (-devendstates, the post-release window) no-ops green with a NOTICE; the two genuinely ambiguous bare states refuse loudly; a true transition then requires a merged,release-labeled PR behind the commit (read via the API — the label is the operator's declared intent) before anything is created. Then, in the same job, it tags the merge commit via the API, publishes — and bumps main toX.Y.(Z+1)-devitself, direct push with a loud open-a-PR fallback, so no follow-up bump PR exists on the paved road. Same-job on purpose: aGITHUB_TOKEN-created tag triggers no workflows, which is also what makes double-publish impossible. The tag-push path stays unchanged as the documented manual fallback and backfill (it shipped 0.7.0 itself).test/release.shgrep-pins the gate, every decide verdict, the singleon.pushkey, and the same-job tag+publish+re-arm in the same daemon-free, fail-closed style.
Fixed
-
The release ceremony re-arms
CHANGELOG.md, and CI refuses to letmainsit disarmed (#108) — the ceremony stamps## Unreleasedinto## X.Y.Z — DATEby hand, and nothing put the heading back, somainsat with no## Unreleasedfrom the release until the next PR that happened to re-create one. A PR authored before the release wrote its entry under## Unreleased; with that heading gone, git lands the entry under whatever now occupies the position — the section that just shipped — and it merges cleanly. No conflict, no error, no red X: the one signal an author would trust is absent exactly when the outcome is wrong, and the changelog credits a released version with a change it does not contain until a human reads the file. Confirmed in the sibling repo (heavy-duty/rig#66); box has not drifted yet, and the reason is luck rather than design — 0.6.0's ceremony (77599ab) added its heading without removing## Unreleased, so main was never disarmed, while 0.7.0 did disarm it and left a window that nothing happened to cross. Two halves land together. The ceremony step inCONTRIBUTING.mdis now explicitly two edits: stamp, then put an empty## Unreleasedback above the section just stamped — it belongs there and not inrelease.yml, which only ever touchesVERSION. And.github/scripts/changelog-armed.shenforces it in CI, keyed onVERSIONbecause the two states are genuinely different: a-devtree must carry## Unreleasedon top, a bare-VERSIONtree (the ceremony PR, and the merge that publishes it) may carry either that or its own stamped section. The keying is the whole design and not an over-complication — box previously had no top-section guard at all, and the obvious one, an unconditional## Unreleasedrequirement, is false by construction on the ceremony PR's own tree, which is why rig#44 and heavy-duty/cast#108 both had to revert it. So a forgotten re-arm does not block the release; it turnsmainred on the very next push, the automatic-devbump the release itself makes. Leaving the bare branch's top heading unconstrained is what keeps both ceremony shapes legal, and a review round on the sibling fix (heavy-duty/cast#114) found the gap that asymmetry leaves: a half-ceremony tree —VERSIONbumped,## Unreleasedstill populated on top, and the section for that version never stamped — makes the wrong-number test false on its first clause, short-circuits, and passes. Nothing then refuses untilrelease.ymlextracts the notes, which is after the merge, onmain, with the release already half-shipped. So the bare branch now also requires that the section it is about to publish exists and is non-empty, and it asserts that by runningrelease-notes.sh— the very scriptrelease.ymlruns — so the guard and the publisher cannot drift apart over what a section is. The message is its own: a missing stamp is not a misnumbered one, and an operator sent to correct a version number that is already right will not find the real problem. Matches heavy-duty/rig#67, so the three repos agree. -
Ctrl-D at a confirmation prompt aborts out loud, instead of exiting in silence (#111) —
confirm()anduninstall_confirm()both took the operator's answer with a bareread -r reply. Every answer a human can type routes through thecasebelow it and ends at areturnor atdie "aborted."— every answer except EOF. Ctrl-D makesreadreturn non-zero,set -euo pipefailends the run on that line, and thecaseis never reached: box exits 1 having printed nothing at all after the question it just asked. It fails closed, which is why this is a small fix and not an incident — nothing is destroyed, the abort is real. The damage is that the tool goes mute at the one moment it had the operator's full attention, and someone who Ctrl-Ds out ofbox rm workcannot tell from the output whether the box is still there. The cure is one token in each function,read -r reply || die "aborted.", the same one heavy-duty/rig#43 applied to rig's credential prompts so the two repos read alike. The bug predates everything it touches —rmhas carried a confirm gate for as long as the verb has existed — but #105 took the number of verbs reaching that line from one to two, and both are irreversible, which is the argument for closing it now rather than the next time someone notices. The three answers a human can actually give (y,n, and Ctrl-D) are now driven for real on a pty via util-linuxscript: they were structurally untested before, because[ -t 0 ]sends a terminal-less suite to the refusal branch and every existing check stopped there — which is exactly how this survived four releases. Review caught that the first pass fixed the bug where it was reported and stopped there, while the same defect sat at two more destructive gates in this repo:host/revoke-user.sh:50, the prompt guardingbox revoke --purge— the one whose own text says "this cannot be undone" — andhost/teardown-host.sh:31, guarding a full host teardown. Both run underset -euo pipefail, both died mute on EOF with theirabortedline never reached; both now carry the guard in their own script's wording. The threedrill/prompts are deliberately left alone — they run underset -uonly, so EOF falls through to the*)arm and already aborts out loud — andinstall.sh:65was already guarded. What keeps the class closed is a repo-wide sweep intest/cli.sh: every statement-initialreadfed from stdin, in any file that turns on errexit, must carry a||guard, withwhile readloops and<<<herestrings excluded because neither is a prompt. The sweep flags all four sites when their guards are removed and nothing else across the tree's fifteen shell files — the absence of exactly this check is why thehost/pair was missed in the first place. -
box restoreasks before it destroys — and the confirmation prompt is now the row's, not rm's (#105) —restoreandrmboth irreversibly discard user state, and only one of them asked. The table gaverestorethe preconditionsbox,arg2: the instance is ours, a snapshot name is present, go. Sobox restore work stale-labelsilently threw away everything done in the box since that snapshot, with no prompt, no--force, and no way to take it back — a warning in--helpis not a gate. It has been that way since the verb shipped, and it is about to become routine rather than rare (heavy-duty/rig#62's pristine snapshot), which is the wrong time to still be relying on the operator typing the right label. The reason it stayed ungated is worth recording, because it is the actual bug:confirmwas already a precondition token, but the dispatch line hardcoded the words —confirm "delete $inst and all its snapshots"— so the one-token fix would have gated restore behind a prompt offering to DELETE the box the operator was trying to rescue. A gate that names the wrong act is worse than no gate; it is how people learn to answerywithout reading. So the prompt moved into the table as a seventh field, each row saying what it is about to do in its own words, andrestorenow asks to "roll<box>back to snapshot<label>and discard everything in the box since it was taken" — naming the label, because picking the wrong one is the whole risk.rm's wording is unchanged and pinned verbatim by a test, since rewording the one verb that already worked would be a regression shipped as a refactor. A row markedconfirmwith no words is now a hard internal error rather than a blank question.--forceand the no-TTY refusal come free —confirm()already had both. The one automated caller had to consent explicitly:drill/multiuser.shdrives restore unattended on real Incus and now passes--force, which is the rehearsal proving the gate rather than working around it — the CI run of this very PR failed there first, which is the shape a gate is supposed to have. Coverage went from two argument-validation checks that never reached dispatch to the destructive path itself, driven against a fake incus: refusing leaves the call log empty,--forceproduces exactly oneincus snapshot restore. Not changed, deliberately:restorestill does not require the box stopped (#105 makes that case separately and it deserves its own call), and--helpnow says plainly that a rollback of a running box is crash-consistent, because these snapshots are stateless. -
box-firewallcould hand a UFW host the no-UFW firewall, ~2% of the time (#102) — filed as an intermittent test flake (test/cli.sh's fresh-UFW block going four-assertions-red on an unmodifiedmain, measured here at 5 failing runs in 40), it was not one. The branch that decides the host's entire firewall stance readufw status | grep -q "Status: active", andStatus: activeis the FIRST line ufw prints:grep -qmatches it and exits immediately, closing the pipe while ufw is still writing the rest of the table, so ufw dies of SIGPIPE.grepreturned 0, but under this script'sset -o pipefailthe PIPELINE returns 141 — theifreads false and a host with UFW plainly active takes the nft-fallback branch, never building the DNS carve-out its persisted rules depend on. A pure scheduling race, isolated at ~2% per invocation (PIPESTATUS=141 0; a draining reader flakes 0/2000, a reader whose match is on the last line flakes 0/2000). Real ufw is a slower, longer writer than the test shim, so production had no reason to be safer.ufw statusis now read ONCE into a variable and matched with[[ ]]— no reader, no race — and the stale-rule scan reads that same snapshot, so the branch decision and the converge loop can no longer disagree.host/teardown-host.shcarried the same live defect and is fixed with it: that file does setpipefail(line 12), so its UFW crumb-removal branch could read a plainly-active UFW as inactive and skip silently, leaving staleboxnet/claudenetrules on a host the operator was told is clean — and its numbered-delete loop had the same early-exit reader as its condition, so it could end while rules remained. Both now read captures. The sibling calls indrill/wipe.shanddrill/doctor.share the same shape but set onlyset -u, so the SIGPIPE is discarded there and the branch holds — latent, not live, until either gainspipefail. -
A missing firewall log now diagnoses itself (#102) — the four greps reading
$WFW/*.logused to fail together with empty output when the driving run took the wrong branch, a signature that looks specific and says nothing (#102 was filed reading it as "the log is not written"; the log existed, the mutations did not, and that distinction was the diagnosis).test/cli.shnow asserts the precondition explicitly before the content greps and, on failure, prints the contents of$WFW, the log itself, and the stderr of the run that should have written it. It also keepsan agreeing UFW host deletes nothinghonest: that check asserts an absence, which a run that did nothing at all passes for the wrong reason. -
box grantprovisions anincus-adminmember instead of refusing them (#99) — the refusal read "they already have the admin tier; there is nothing tighter to grant", which is true about permission and silent about provisioning: theincusgroup is indeed a strict subset of whatincus-adminopens at the daemon API, but theuser-<uid>project, the boxnet narrowing, the snapshot and backup allowances, and thebox-netprofile installed into that project are none of them permissions, and anincus-adminmember had none of them —box_tier()resolves them toadmin, so they worked in the shared default project next to root and every other admin, with no world of their own and no supported way to get one.box grantnow runs the full convergence for them.The group step is part of that convergence, not an exception to it: an
incus-adminmember is added toincuslike anyone else. The subset argument holds for the API and fails at the filesystem, which is where it matters here — the two sockets are two files with two owning groups (Debian 13 / Incus 6.0.4, measured):socket group mode /var/lib/incus/unix.socketincus-admin0660 /var/lib/incus/unix.socket.userincus0660 incus-adminopens the first and not the second, and only the second provisions auser-<uid>project. Without the membership the provisioning touch takesEACCES, the swallowing|| truehides it, no project appears, and the grant dies blaming a perfectly healthy incus-user — the exact incus-admin-only user #99 is about, left no better off. So the membership is granted, and the grant says out loud why: it is the key to a file, not a new privilege (box_tier()still reads them asadmin, both-groups →admin).The touch itself is pinned at incus-user's socket: the incus client picks by writability (
client/connection.go— the daemon socket when writable,unix.socket.useronly otherwise), so for anincus-adminmember an unpinned touch sails past incus-user and provisions nothing. The user-side proof that closes the grant names their project for the same reason, since an unqualifiedprofile showwould have answered from the shared default project and proved nothing. The socket's existence is probed through$SUDO, not a bare[ -e ]—/var/lib/incusis not traversable by a non-root admin, so an unprivileged stat reports a present socket as absent, and this probe exits on absent (the disciplinebox revokealready documents).On success the grant prints the caveat the hard exit was gesturing at, in the two forms it actually takes: the restrictions are a default placement, not a confinement (admin membership still wins at the socket — the default project and other users' instances stay one flag away), and until
incus-admingoes their ownboxcommands keep landing in the default project. Droppingincus-adminthen lands them in their ready project with no re-grant — a promise that is only true because they keepincus; without it that drop would leave them in neither group,box_tier()none, and a converged project they could not open. The failure path follows: the membership this run added is rolled back and verified, while the backout refuses to call that a lockout —incus-adminis untouched and still opens every project.box revokemirrors it. A bare revoke of a grantedincus-adminmember now takes theincusmembership back and reportspartial:— the socket keybox grantadded is gone, their project is kept, and they are explicitly not locked out. Anincus-adminmember who was never granted is still a named no-op that makes no privileged call at all.--purgeunmakes the provisioning while refusing to call them "out". Every path namesgpasswd -d <user> incus-adminas the only thing that ends their access.Unblocks rig's
users apply(heavy-duty/rig#49), which had to callbox grantfor a user who is bothincus-adminby hand and roleboxin the fleet file. Driven end to end intest/cli.shunder logging incus/sudo shims — every assertion is made against what the run did, not what the source says it would — and, because those shims model neitherINCUS_SOCKETnor file permissions and so cannot reproduce theEACCES, measured on real Incus in CI by a newdrill/multiuser.shcriterion (o): anincus-admin-only member is granted, the membership lands, the project appears,unix.socket.useropens as them, and droppingincus-adminleaves them in their own project with no re-grant.
0.7.0 — 2026-07-19
Added
-
The installer defaults to the latest release, and releases publish themselves (#83) —
curl | bashused to hand out whatevermainwas at that second: the 0.6.0 release was a bookmark, not a package, and two operators "on 0.6.0" could be running different trees.install.shnow resolves the latest release tag by following GitHub'sreleases/latestredirect (one HEAD request — no API, no token, no rate-limit pain) and downloads that tag's tarball; a failed resolution refuses loudly, namingBOX_REFas the way out — it never hangs and never silently falls back tomain. A setBOX_REFis tried as a tag first, then as a branch, so one knob yields three channels: default = latest release,BOX_REF=0.6.0= pinned,BOX_REF=main= dev. A newrelease.yml(on a bareX.Y.Ztag push — the0.6.0tag set the no-vprecedent) asserts the tag names the tree's ownVERSION(a mismatch fails loudly and creates nothing) and publishes the GitHub release with that version'sCHANGELOG.mdsection as the body (.github/scripts/release-notes.sh— the curated prose, not the generated PR list; no assets, the source tarball for the tag IS the package). Andmain'sVERSIONnow carries-devbetween releases (this PR:0.6.1-dev): the versioned layout names install trees afterVERSION, so amaininstall without the bump would land inversions/0.6.0and impersonate the released tree.test/release.shdrives all of it offline — the extraction against fixtures and the real changelog, the resolution and every channel against a shim curl. -
setup-hostauto-picks a free subnet — nested box-in-box with zero flags (#80, completing its fix #1: "refuse … or automatically select a non-colliding subnet"). A barebox setup-hostnow decides the subnet itself, in four deliberate cases: an explicitBOX_SUBNETis honored or refused, never silently overridden (scripted hosts keep exact semantics); an existingboxnetbridge is converged on as-is — the bridge IS the pin — turning the old bare-re-run agree-gate refusal into plain convergence (unless a foreigner also claims the bridge's subnet: that is #80's poisoned state, and converging would rebuild on it, so it still refuses and names the bridge move); a free10.88.0.0/24stays the default; and a claimed default — the nested case: a drill or rehearsal running inside a box, whose own uplink owns 10.88 — scans10.89.0.0/24…10.127.0.0/24in order, takes the first free candidate, announces the pick and the claimant loudly, and only refuses when every candidate is claimed. The decision happens before any mutation, and everything downstream (the bridge,BOX_GW, the ACL's gateway carve-out, the firewall, the doctor's expectations) derives from it. -
setup-hostrefuses a claimed subnet, andBOX_SUBNETpicks another (#80) — run inside a box,setup-hostused to build a nestedboxneton the exact subnet and gateway of the guest's own uplink: the guest then held its gateway's address as a local address, carried duplicate connected routes for its uplink subnet, and suffered intermittent, self-recovering egress blackouts that looked like flaky internet (measured live: ~24–36 s outages, roughly hourly, with the host clean throughout).setup-hostnow scans the target subnet before any mutation — the default route's gateway inside it, or any non-boxnetinterface holding an address in it — and refuses, naming the way out. A priorboxnetowning the subnet is the legitimate converge path and does not trip it.BOX_SUBNET=<a.b.c.0/24>(validated, alongside the existingBOX_DNS) moves the whole stack: the bridge address, the ACL's gateway carve-out (now converged vianetwork acl edit, so a bridge moved off a colliding subnet no longer strands box DNS behind a stale/32), the firewall (box-firewallreads the gateway off the live bridge), and every drill/migrate probe that used to hardcode10.88. -
box doctorknows the #80 signature — a default gateway held as a LOCAL address, and duplicate connected routes for the uplink subnet, judged fromip route/ip addron the machine doctor runs on (both tiers, before any daemon check — the nested daemon answering could be the impostor) and probed inside every box it examines. The existing "egress broken but DNS fine" split now names itself as #80's fingerprint (the impostor dnsmasq on a captured gateway keeps resolving while IP egress dies), and the admin ACL section verifies the gateway carve-out matchesboxnet's actual gateway. The agent-context guard for the templates (suggested fix 4) lands in heavy-duty/rig#31's bootstrap roles per the thin-templates split (#81). -
The
stagingtemplate (#81, the re-cut of #69's layering) — a server-class, creds-free seed: Debian 13, userops, tmux, rig,BOX_REQUIRE_VM=1(the VM is its trust boundary),BOX_AUTOSTART=1(a server returns from a host reboot without an operator), andBOX_BOOTSTRAP_ROLE="staging"— the server posture (docker, sshd hardening) converges viarig bootstrap stagingafter mint. The tailnet workload join holds a pre-auth key and therefore stays operator-run (box shell→sudo rig bootstrap workload), printed as a next step — box never sees the key. -
BOX_BOOTSTRAP_ROLEtemplate key + mint-time auto-run (#81) — a template names the creds-free rig tenant role box runs inside the guest after cloud-init settles (incus exec … rig bootstrap <role>); the value is a role name by allowlist (anything shell-shaped dies at parse time, on the host). A failed role leaves the box up and names the re-run — the roles are convergent by contract (rig#31).blanknames no role and auto-runs nothing. -
The rig pin point:
RIG_REPO/RIG_REF(#81) — the tenant seeds preinstall rig, inverting the rig→box install edge (rig#28), and the new edge gets the same honest treatment rig#29 gave box's unpinned install:@RIG_REPO@/@RIG_REF@tokens in the seed resolve at mint from the environment (defaultheavy-duty/rig@main— unpinned, tracking main, until a release flow exists, rig#32/#83). The pin covers both the installer fetched and the tree it installs, so a rig branch under review is testable end to end; values are allowlist-validated before touching the YAML. -
Server-posture template keys (#81, carved from #69) — two optional
box.envallowlist keys.BOX_REQUIRE_VM=1refuses both the silent container fallback (no/dev/kvm, exit 1) and an explicit--container(exit 2): such a template's trust boundary is the VM.BOX_AUTOSTART=1stampsboot.autostart=trueat launch, per-instance likelimits.*, so the box returns from a host reboot without an operator; clones inherit it viaincus copy. Still no key for a network or asecurity.*flag, on purpose. -
Dynamic template test suite (#81, carved from #69) —
test/cli.shdiscoverstemplates/*/instead of hardcoding the list, so a new template cannot ship unseen. Per template:box.envis driven through the real, extractedload_template(unknown keys and missingBOX_IMAGE/BOX_USERfail, fixtures proving both dies);user-data.yamlexists, declares#cloud-config, parses as YAML, and installs tmux (#65). Grep guards pin thecmd_newhalf: theREQUIRE_VMrefusal orders afterpick_mode, andboot.autostartis stamped only under theT_AUTOSTARTguard. -
box export/box import(#70) — a box's state that survives the box and the host, unblocking #66's humane upgrade flow (down, export, rm, upgrade, re-import).box export <box> [<file>]wrapsincus exportinto one portable backup tarball (default<box>-<UTC stamp>.tar.gz), snapshots included by default (--instance-onlyopts out); the box must be stopped first (box down) so the artifact is a settled disk, not a moving one. The file is shouted about, not scrubbed — it carries the box's whole disk (agent logins, git credentials, SSH keys), and scrubbing a disk image is a promise tarball surgery cannot keep, so box says what is inside instead, every time.box import <file> [--name <box>]mints the box back and re-stamps what is the current host's truth, not the artifact's: theuser.box=1boundary tag (legacyuser.claudebox=1honored), thebox-netplacement (re-assigned if the artifact's differs — the migrate-host move), and a fresh machine identity: the NIC's MAC (imports restorevolatile.*verbatim, and a re-import beside its sibling collided at start with "MAC address already defined on another NIC" — measured live;incus copyregenerates it,incus importdoes not) plusreset_identity(the clone trust boundary: no DHCP collision with the box it was exported from). Import refuses any name an existing instance holds — theresolve_boxboundary, seen from the other side. Works on both tiers:box grantnow also convergesrestricted.backups allow(incus-user blocks backups by default exactly like snapshots, and an export is a backup create+download — measured against incus 6.0'spermissions.go); re-runbox grant <user>after upgrading, as documented. CI'srehearsaljob now proves the round-trip on a live Incus: mint → write a file → snapshot → down → export →rm→ import → the file and the snapshot survived, the agent answers, the tag is present, and a colliding re-import is refused. -
Versioned installs (#66's stance, made livable) — install.sh now lands each version side by side at
<root>/versions/<v>(its ownVERSION+INSTALLED_FROM), with acurrentsymlink tracking the default and$BINDIR/boxriding the chain, the way plenty of CLIs manage theirs. New verbs:box versions(lists installs, marks the current default and the running tree),box use <version>(flips the default, converges the PATH symlinks, and asserts the effective result —currentmust resolve to the asked-for version and the chain'sbox --versionmust answer it). Re-running the installer with an installed version is a converging no-op (BOX_REINSTALL=1replaces that version's tree); a new version installs side-by-side and flipscurrentonly when no boxes exist — under existing boxes the flip is refused loudly, naming the boxes (#66: never change versions under a user's boxes;box usekeeps the same refusal). A pre-0.7.0 flat tree is migrated in place (two renames, the operator's tree preserved bit for bit), so upgrading from 0.6.0 is seamless; a stale or dangling$BINDIR/boxis healed instead of wedging the install; and the installer warns when the other tier's install (/opt/box vs ~/.local) coexists, since PATH order decides which wins. -
A real uninstall —
box uninstall [<version>] [--all] [--purge-host]replaces the "rm -rf two paths" prose. One version: refuses the current one (box useoff it first). Everything: runs in the safe order — refuses while boxes exist (naming them) unless--purge-hostruns teardown-host first — then removes every version, thecurrentand PATH symlinks, and the legacy claudebox crumbs (both name generations), and ends with an absence assert: every removed path is re-checked, and any survivor makes it exit 1 asuninstall INCOMPLETEnaming the leftovers (therevoke --purgediscipline).teardown-host.shgains--yes/BOX_YES=1for automation and now points atbox uninstallwhen done. -
BOX_INSTALL_SOURCE=<dir-or-tarball>— installs from a local tree, bypassing the download. CI's rehearsal job now installs via install.sh itself (proving the installer under review, not acp -rmimic of it), and ends with an uninstall drill: grant +revoke --purgea throwaway user,teardown-host,box uninstall --all, then assert zero residue — no networks, profiles, ACLs, nft tables, systemd units, files or symlinks. -
test/cli.sh drives real installs — still dependency-free, non-root, no daemon:
BOX_INSTALL_SOURCE+ throwawayBOX_HOME/BOX_BINroots and a fakeincuson PATH ($FAKE_BOXES) turn layout, chain, no-op/converge, reinstall, side-by-side upgrade, the three #66 refusals (install flip,use,uninstall— boxes named), flat-tree migration, symlink healing, single-version and zero-residue uninstalls, and theINCOMPLETEscream into driven tests instead of greps (154 checks).
Changed
- Thin templates — box mints, rig converges (#81, companion rig#31) —
the tenant content that lived in
claude/codex/grok's cloud-init (the agent CLI installs, docker, node, the per-template agent-context heredocs) moves to rig's bootstrap roles, where it is convergent, idempotent and testable end to end instead of parse-only YAML. What remains per template is a thin, creds-free seed: the tenant user, tmux (#65), and rig preinstalled — nothing that joins a tailnet or admits credentials. The #80 agent-context guard ("never runbox setup-hostor the drill inside a box") now lives once, in rig's roles, not copy-pasted per template. The template test sweep grew the contract's teeth: per-template seed asserts (user matches, rig pinned via both tokens) and fail-closed absence greps over effective cloud-init lines — no agent CLI, no docker, no tailscale/authkey/ssh, nowrite_filesheredocs — so tenant content cannot quietly grow back.
Fixed
- A wedged
incus launchfails loudly, not forever — the mint's launch phase is narrated and time-boxed (#93) — twice in the 2026-07-19 release drill (Debian 13, Incus 6.x, /dev/kvm present, images cached), the childincus launchunderbox newhung with no server-side operation:incus operation listempty, the instance never created, the daemon journal quiet — one wedge ran 56 minutes before being killed by hand, and an immediate retry of the identical command succeeded in minutes, both times.box newinherited that as an indefinite silent hang, indistinguishable from a cold mint working. It now printslaunching instance …before the call, and the call ridestimeout -k 5 $BOX_LAUNCH_TIMEOUT(seconds, default 600 — generous: the coldest measured mint is minutes, never an hour; the same scripting-knob shape asBOX_CPU/BOX_MEMORY), with stdin pinned per the drill's own trap list. On the budget firing it probes whether the instance was ever registered and tells the two stories apart — the measured #93 wedge (no server-side operation; an immediate retry has been observed to succeed) vs a slow launch that overran the budget with the instance already created — then best-effort deletes either way, so the retry advice is clean in both worlds, and points atbox doctorfor the host. The--fromclone path is untouched:incus copyof a local instance is a different operation and has never been observed to wedge this way. - UFW's gateway carve-out converges with the bridge, and the doctor can
see it (the #86 review's blind spot) —
box-firewallgated its whole UFW block behind "aDENY on boxnetrule exists", pinning every UFW host to the gateway of the first run: a bridge remapped off a colliding subnet (#80's escape hatch) kept its staleallow … to <old-gw> port 53and never gained the live gateway's, so box→gateway DNS died at box's own deny — while the doctor's carve-out check read only the incus ACL (which setup-host converges) and called the host clean. The UFW allows now converge off the live bridge address on every run (stale DNS allows deleted, the live set ensured — ufw skips existing rules, so a fresh host gets the identical rule set and a re-run is a no-op), andbox doctorreads UFW's own table wherever UFW is active, flagging a DNS allow that does not matchboxnet's gateway (and stale allows left beside a live one). The no-UFW nft carve-out never had this failure mode: it is interface-scoped, no gateway address to go stale. - The boot-time gateway fallback is gone — no rule beats a wrong one —
with the bridge not yet addressed when
box-firewall.serviceran,box-firewallguessedGW=10.88.0.1; on aBOX_SUBNEThost that hit that window the UFW carve-out was built for the wrong gateway, a latent DNS drop (#86 review). It now fails closed: an unaddressed bridge leaves the persisted UFW rules exactly as they are (they survive boots on their own, and nothing else in the script needs the gateway) and says so on stderr; the next setup-host run or service restart converges them once the bridge is addressed. revoke --purgere-checks the incus-user state — the purge removed/var/lib/incus/users/<uid>without ever asserting its absence, the one path its own absence block did not cover; and the stat now rides$SUDO test -d(/var/lib/incusis not traversable by a non-root admin, so a bare[ -d ]answered "absent" for a directory that was there).- A wedged
$BINDIR/boxno longer blocks installing — the old no-op-if-installed check keyed off the symlink's existence OR the tree's, so a stale symlink (or a half-removed tree) could fake "already installed" forever. Installed-ness is now judged fromversions/<v>itself; symlinks are converged withln -sfn, never trusted as the signal.
0.6.0 — 2026-07-18
Added
- The restricted tier: multi-user hosts (#74, redesigning #72) — an admin
runs
box grant <user>and that user gets their own boxes on the same hardenedboxnet, seeing nobody else's;box revoke <user>takes it back (--purgedeletes their world, and asserts the absence). The tier rides incus-user, whose defaults miss box's contract three measured ways (Debian 13 / Incus 6.0.4): a private unhardened NAT bridge per user, snapshots blocked, thebox-netprofile invisible — so grant is an idempotent convergence: project narrowed toboxnetand only boxnet (listing the private bridge too, the obvious fix, would keep an unhardened network one--networkflag away), snapshots allowed, the shipped profile installed into their project.box_tier()(live credentials, arglessid -nG) drives the tier-aware surface:exposerefuses honestly before any daemon call,setup-hostanddoctoranswer at the caller's tier. Rehearsed end-to-end bydrill/multiuser.sh(criteria a–n: confinement, lifecycle, cross-user visibility, name collisions, the in-box isolation contract, escape hatches, re-sync survival, revoke incl. the live-session case) — 54/54 on the design host (container and VM mode), including the raw-attach scoped-guarantee measurement and both grant-failure injections demanded by #75's review. - CI runs the multi-user rehearsal on a real Incus — a second
rehearsaljob stands up the full stack on the runner (setup-host, doctor, thenmultiuser.sh --container), so every PR proves the tier's semantics against a live daemon, not a mock. The VM trust boundary itself remains a real-hardware ritual, like the full drill. - Global / root install (#71) — run as root, box installs once to
/opt/box(world-readable) with theboxsymlink on/usr/local/bin, so every operator on a shared host runs the same tree. Per-user installs are unchanged ($HOME/.local);BOX_HOME/BOX_BINstill override. A per-user tree under/rootis0700and unreadable to everyone else — the whole fleet gotcommand not found— so the root branch lands in a system location andchmod -R a+rX's it (read for files, +search on dirs), guarded on root. This unblocks "rig installs box" (rig#24'sboxrole). - CI + a test suite —
.github/workflows/ci.yml(acheckjob: globstarshellcheck -xoverbin/* **/*.sh, thenbash test/cli.sh) andtest/cli.sh, dependency-free and runnable by a non-root user with no Incus. It exercises theinstall.shDEST/BINDIR branch functionally (both tiers +BOX_HOME/BOX_BINoverrides), the CLI contract, and grep-guards the daemon-gated invariants and tmux in every template — the box was the repo with "no tests and no CI".
Fixed
-
box restorenever worked against Incus 6 — the command table dispatchedincus restore, a subcommand that does not exist (Incus 6 spells itincus snapshot restore), so every restore died on "unknown command". Found by #74's rehearsal exercising the full lifecycle as a restricted user; fixed for every tier, and the rehearsal + a grep-guard intest/cli.shnow hold it. -
box tmuxworks on every template (#65) —box tmuxrunstmux new-sessioninside the box, but the templates did not install tmux, so it failed withtmux: command not found.tmuxis now in each template's cloud-init package list (blank/claude/codex/grok). -
box setup-hostfinishes in one run (#63). When it had to add you toincus-adminit stopped there and told you to re-login and re-run — anexit 0that reported success having built none of the stack: noboxnet, no ACL, nobox-netprofile, no firewall. It now re-execs itself undersg incus-adminand completes in that one invocation. The membership check was also asking the wrong question:id -nG "$USER"reads the group database, which lists the group the momentusermodreturns, so a same-session re-run passed the check with credentials that still lacked the group and died further down on a bare permission error fromincus. Arglessid -nGasks the process what it actually holds. -
setup-hostworks as root, with or withoutsudo— every privileged call was a hardcodedsudo, so on a minimal root image (nosudopackage) it died onsudo: command not foundbefore doing anything. Privilege is now resolved once: nothing at UID 0,sudootherwise, and a clear error if neither is possible. This is what madeinstall.sh's root path real rather than nominal. -
setup-hostgrantsincus-adminto the human, not to root — undersudo install.shit would have addedrootto the group: a no-op (UID 0 opens the socket regardless) that also left the actual user locked out of their own boxes. It now derives the login user fromSUDO_USER. -
box-firewall.servicenow reports its state honestly — the unit isType=oneshotand was missingRemainAfterExit=yes, so it wentinactive (dead)the instant it succeeded: a host whose isolation was perfectly live read as one whose firewall unit had died. drill.sh sends you tosystemctl status box-firewallto diagnose exactly that, and setup-host.sh's own comment already asserted the unit "is RemainAfterExit" — it was not. Found by running the drill on a real host and mistrusting the green:nft list table bridge boxshowed the drop live while the unit read dead.restartwas and remains correct either way. -
setup-host's apt calls can no longer hang — a fresh cloud image hasapt-daily/unattended-upgradesholding the dpkg lock, and a plainapt-get installwaits on it silently and indefinitely. Now bounded (DPkg::Lock::Timeout=300) and non-interactive, which matters becauseinstall.shruns it with nobody watching.
Changed
drill.shproves the new contract instead of masking it — the drill ransetup-hostitself right after installing, so the stack existed by its own hand and a run passed identically whether or notinstall.shhad done a thing; a fresh run converged the stack three times, while the 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 touching the host, and runssetup-hostexactly once more — after the clean, which deliberately unsetsdns.modeand so has to be converged back.DRILL_OWNS_SETUP=1hands sequencing back to the drill. Pre-setup tripwires now read beforeinstall.sh, since that is what triggers setup now.install.shasks, sets up the host, and no-ops on re-run (#64) — it now prompts "Install box?", then on a fresh host installs the tree and asks a second question, "Set up this machine as a box host now?", running the whole isolation stack if you say yes (previously it only printed a warning and left you a command, so the install reported success andbox newdied on a host with no Incus). Prompts read/dev/tty, since undercurl | bashthe script itself is stdin;BOX_YES=1answers yes unattended (required where there is no terminal),BOX_SKIP_SETUP_HOST=1declines the host-setup step.install.shnever overwrites an existing install — if box is already installed it says so and changes nothing, so a stray re-run can no longer clobber a working tree or rebuild the host stack under live boxes. Upgrading is explicit: uninstall (rm -rf ~/.local/share/box ~/.local/bin/box, boxes preserved first) and install fresh. This replaces the earlier version-diff refusal with a simpler rule that dissolves the same class of errors. The version-aware upgrade that migrates boxes instead is #67; a portablebox exportso a box survives its own deletion is #70.
0.5.0 — 2026-07-15
The release the project was renamed in: the repo is heavy-duty/box, matching
the CLI it ships. Everything legacy-facing is honored forever — the
user.claudebox=1 tag, the .claudebox/ runbook folder, the old symlink the
installer retires — but nothing current carries the old name.
Added
codexandgroktemplates — OpenAI Codex CLI and xAI Grok CLI boxes, creds-free like every template. The template mechanic (image + user + resources, never a network or asecurity.*key) now has three tenants besideblank, and the drill mints all of them cold.box expose <box> <port> [<host-port>]— a deliberate, loopback-only door to a port inside a box, for seeing a dev server in your browser. The listen side is always the host's127.0.0.1(no flag to widen it), the door is per-port,--list/--removemanage it, andbox infoshows open exposures — a box with a hole says so.- Inline resource overrides on
new—--cpu <n> --memory <size> --disk <size>(#57). Resolution most-specific-first: flag >BOX_CPU/BOX_MEMORY/BOX_DISKenvironment (the scripting form) > templatebox.env> defaults. Values pass to Incus verbatim; resources are all a flag can touch.--fromrefuses them — a clone carries its source's resources. - Host lifecycle as verbs —
box setup-host,box teardown-host, andbox migrate-host, which re-homes pre-0.4.0 boxes onto the current stack (--box <n>/--all-boxes, authed state preserved) and retires the legacy bridge once empty (--retire-legacy). - The
.box/recipe convention — the agent-facing runbook folder a repo can ship, renamed from.claudebox/(both spellings read).
Fixed
- VM mints no longer hang at GRUB — Incus defaults VMs to Secure Boot on,
and a cloud image whose shim the host's OVMF doesn't trust dies with "bad
shim signature" forever. Boxes now launch with
security.secureboot=false; the VM boundary, not boot attestation, is the box threat model. box exposeactually delivers packets — a trilogy of drill-found absences: the NAT proxy needs the box's boxnet lease pinned as a staticipv4.address(Incus resolvesconnect=0.0.0.0against device config, not the lease); a loopback-sourced packet needsroute_localnetplus a masquerade on the bridge to leave the host and be answerable; and the box's replies need act state established,relatedaccept ahead of the host firewall's input drop, which was eating them statelessly. Boxes still cannot initiate toward the host — a box-originated SYN is a NEW flow.- Firewall rules now converge on upgrade —
box-firewall.shrebuilds its chains every run (add + flush + re-add) instead of skipping when they exist, which had pinned every host to the rule set of the release that first ran there. - Failed mints tell you why — cloud-init failures print the box's own log excerpts and leave the box up to inspect; a mint that never boots names the likely cause (corrupt image, Secure Boot, GRUB hang) and ships a sanitized console dump; the installer asserts it landed the ref it was asked for.
grokinstalls the binary it actually ships — the installer was read, not guessed at, and the CLI lands on the non-interactive PATH (same fix class as codex).
Changed
- Debrand complete — env vars, install dir, docs, template descriptions
and the README all say
box; the install URL isheavy-duty/box(GitHub redirects the old one,BOX_REPOoverrides). - The drill grew from 47 to 84 checks — the expose door opened, exercised
and shut (with the contract re-probed around it), every template minted
cold, a faithful pre-0.4.0 box re-homed through
migrate-host, and the inline resource flags asserted (including their precedence over the environment).