changelog-armed.sh asks only whether the TOP section agrees with VERSION.
A PR that replaces '## X.Y.Z — DATE' with its own '## Unreleased' block —
git merges the edit cleanly, and a shipped section is silently absorbed into
Unreleased. The damage only surfaces at the next release, when
release-notes.sh cannot find the section it extracts by heading.
Add changelog-monotonic.sh: the set of '^## X.Y.Z' headings on a branch must
be a superset of the set at the merge base. Release headings are append-only,
so the rule has no legitimate violation — and the ceremony's stamp passes by
construction, adding X.Y.Z and removing none.
Its own script, not a clause in changelog-armed.sh: the input is a git
history rather than two files, no base ref is a SKIP rather than a failure,
and changelog-armed.sh is driven by test/release.sh against constructed
non-git trees that cannot express the failure at all.
CI checks out with fetch-depth: 0 and sets CHANGELOG_MONOTONIC_STRICT=1, so
an unreachable base ref goes red there instead of degrading to the skip a
local run is allowed.
Closes#122
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Unreleased entry was written over `## 0.8.0 — 2026-07-19` instead of
above it, folding the shipped 0.8.0 section into Unreleased: the release
lost its extractable anchor, and the next cut would have republished its
notes as new work.
changelog-armed.sh passed on that tree — correctly, since it asks only
whether the top section agrees with VERSION, and Unreleased was still on
top. The gap it leaves is filed as #122.
Also quote the class check's failure list instead of relying on unquoted
word-splitting to print one path per line.
Refs #116
CI's shellcheck sweep set globstar and globbed `bin/* **/*.sh`. globstar
makes `**` descend into subdirectories, but a glob still does not MATCH a
dot-prefixed name, so `**/` never entered `.github/` and three scripts were
never linted: changelog-armed.sh (the #108/#110 guard that gates every PR),
release-notes.sh (which produces the published release body), and
labels-reconcile.sh (the label state machine). That is the entire release
path, while the step's own comment promised the opposite — that a script in
a new subdirectory is linted without anyone editing a list.
Latent, not broken: all three pass shellcheck as-is, so this is a no-op on
current code. What changes is that a regression in them would be caught.
dotglob alongside globstar closes it, measured rather than assumed: it adds
exactly those three and nothing else — a checkout's .git carries no *.sh,
its hooks shipping as *.sample, so `**/*.sh` does not wander into it.
The one-time fix is dotglob; what keeps the gap shut is the CLASS check, in
the same shape as the eof_guard_sweep of #112. The sweep now compares the
globbed set against `git ls-files '*.sh'` and fails naming any tracked
script it does not cover, so a future dot-directory or shopt subtlety
cannot silently lint a subset and pass.
eof_guard_sweep carried the identical blind spot — it rebuilds the same
glob — and is widened the same way. A no-op today: the three scripts set
errexit, so they are in that class by construction, but none of them reads.
Refs #116
The tenant half of rig#76 is what #123 tracked, but the machine half reaches
box in one place: the tailnet workload join box prints as the next step for a
staging-box guest is `rig bootstrap workload`, and that role is now
`workload-server`.
box never runs it -- it holds a pre-auth key, and that it stays operator-run
is the absence that keeps box creds-free end to end -- but box does PRINT it,
in three places that all had to move together: cmd_new's hint, the staging-box
seed'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.
The suite's assertion moved with it, so it still pins what it was written to
pin: that the join is printed and never exec'd.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
rig is growing a second family of roles, and once a 'staging' role 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 (heavy-duty/rig#76): '-server' for fleet
machines, '-box' for box tenants. box's answer is that a template keeps being
named for the role it converges, so the tenant templates move with it:
claude -> claude-box codex -> codex-box
grok -> grok-box staging -> staging-box
Templates are the only surface that spells a rig role out loud
(BOX_BOOTSTRAP_ROLE, auto-run at mint since #81), so a directory that says one
thing and a role key that says another is a trap with a 15-minute fuse: it
mints clean and dies at convergence. Renamed with 'git mv' so the history of
each seed follows it.
'blank' keeps its name. It seeds no tenant role and sets no
BOX_BOOTSTRAP_ROLE, so it has nothing to agree with — renaming it would only
churn the default template's name for symmetry's sake.
Two namespaces move apart here and only one of them moved: the template name
and the role are now claude-box, while the seed USER stays 'claude' — that is
the user rig's role converges and the one 'box shell' lands in. test/cli.sh
pins the pair per tenant rather than each half alone, because a later rename
that moves one and forgets the other mints a box whose role dies looking for a
user nobody created. drill.sh keeps its bare box NAMES ('codex', 'grok' — what
the pre-flight banner announces and what teardown deletes) and only moves the
--template it passes.
The mint-time hints in cmd_new match both spellings of user.box.template, and
that is not an alias for the role: 'rig bootstrap claude' is gone and nothing
here softens the cut. The stamp is a fact about an INSTANCE, written at its own
mint time and carried forward by every clone; refusing the old spelling would
cut nothing over and only drop the login hint on boxes that predate today —
the same reason user.claudebox is honored everywhere else. migrate-host.sh
stamps re-homed legacy boxes claude-box, the name the template has today, so a
re-homed box looks like a fresh mint rather than a fossil.
Ordered AFTER rig's rename, and that is not a preference. The seeds install rig
from RIG_REPO/RIG_REF, defaulting to heavy-duty/rig@main and unpinned until
rig#32's releases, so these templates ask whatever main happens to be for
'rig bootstrap claude-box'. Against a pre-rename rig that role does not exist
and cmd_new refuses to call the box ready. Merged in the other order the window
closes instead of opening: rig's cut is hard, with no aliases, so the day it
lands every unmerged box seed naming a bare role is the broken one.
Closes#123
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Stamp the ceremony: VERSION 0.7.1-dev -> 0.8.0 (bare), and rewrite
'## Unreleased' into '## 0.8.0 — 2026-07-19' with an empty '## Unreleased'
put back above it — the re-arm half of the two-edit stamp (#108), so main is
never disarmed between this merge and the automatic -dev bump.
0.8.0 rather than 0.7.1: the section carries an '### Added' (#96, the merge
door), and #105 changes 'box restore' to ask before it destroys — which
breaks non-interactive callers that did not pass --force.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The first pass fixed#111 where it was reported — confirm() and
uninstall_confirm() in bin/box — and stopped there, while the same defect
sat at two more destructive gates in this repo:
· host/revoke-user.sh:50 guards 'box revoke --purge', the prompt whose
own text says "this cannot be undone";
· host/teardown-host.sh:31 guards a full host teardown.
Both run under 'set -euo pipefail', so an unguarded 'read' returning
non-zero on EOF ends the run before the 'case' that names the abort —
exit 1, in silence, at the moment the script asked. Each now carries the
guard in its own script's wording rather than importing bin/box's die().
The three drill/ prompts are deliberately untouched: they run under
'set -u' only, so EOF falls through to the '*)' arm and already aborts
out loud. install.sh:65 was already guarded.
What keeps the class closed is a repo-wide sweep in test/cli.sh: every
statement-initial 'read' fed from stdin, in any file that turns on
errexit, must carry a '||' guard — 'while read' loops and '<<<'
herestrings excluded, since neither is a prompt. Removing any of the four
guards makes it fail and name the file:line; it flags nothing else across
the tree's fifteen shell files. The absence of exactly this check is why
the host/ pair was missed the first time.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
confirm() and uninstall_confirm() read the operator's answer with a bare
'read -r reply'. Ctrl-D makes read return non-zero, set -euo pipefail ends
the run on that line, and the case below — the only thing that ever says
"aborted." — is never reached. box exits 1 having printed nothing after
the question it just asked.
The cure is one token in each, 'read -r reply || die "aborted."', the same
one heavy-duty/rig#43 applied to rig's credential prompts.
The three answers a human can give (y, n, Ctrl-D) are now driven on a real
pty via util-linux 'script'. They were structurally untested before —
'[ -t 0 ]' sends a terminal-less suite to the refusal branch, so every
existing check stopped there, which is how this survived four releases.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The bare-VERSION branch left the top heading unconstrained on purpose — both
ceremony shapes have to stay legal, which is the rig#44 / cast#108 lesson. A
review round on the sibling fix found the gap that asymmetry leaves: a tree
with VERSION bumped, '## Unreleased' still populated on top, and no stamped
section for that version makes the wrong-number test false on its first
clause, short-circuits, and passes. release.yml then refuses at publish time
— after the merge, on main, with the release already half-shipped.
So the bare branch now also requires the section it is about to publish to
exist and be non-empty, asserted by running release-notes.sh itself so the
guard and the publisher cannot drift over what a section is. The message is
distinct from the wrong-number case: a missing stamp is not a misnumbered
one. Matches heavy-duty/rig#67.
test/release.sh constructs the half-ceremony tree and the stamped-but-empty
tree and drives the real script at both — all three new assertions fail
against the previous guard — while the re-armed and un-re-armed ceremony
trees stay green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Stamping '## Unreleased' away left main with no heading for a PR
authored before the release to land in, so its entry merged cleanly
into the section that just shipped. The ceremony now re-arms, and
changelog-armed.sh enforces it keyed on VERSION -- so the ceremony
PR's own bare-VERSION tree stays legal (rig#44 / cast#108).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
CI's multi-user rehearsal drives 'box restore' with no terminal, so the
new gate correctly refused it. --force there is the drill proving the
gate is real. README says restore asks, and that scripts must pass it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The 'confirm' precondition existed but the dispatch hardcoded rm's
wording, so giving restore the token would have asked the operator to
confirm deleting the box they were rescuing. The prompt is now a
per-row field; restore names the snapshot and the loss.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The original diff claimed all three sibling `ufw status | grep -q` sites were
safe because none set pipefail. That is true of drill/wipe.sh and
drill/doctor.sh (both `set -u`) and FALSE of host/teardown-host.sh, whose
line 12 is `set -euo pipefail`. Its line 60 was the identical pipeline, so
the same race could read a plainly-active UFW as inactive and skip the whole
crumb-removal block — leaving stale boxnet/claudenet rules on a host the
operator was told is clean. Its numbered-delete loop had the same early-exit
reader as its condition, so it could also end while rules remained.
Both now read captures, matching box-firewall.sh's fix. The changelog claim
is corrected rather than deleted: this repo's changelog is the record of what
was proven, and shipping a disproven safety claim about a live defect is
worse than the defect, because it tells the next reader not to look.
Pinned in both directions, with comment lines stripped before matching — the
fix's own commentary quotes the racing shape to explain it, and a pin that
cannot tell prose from code fails on the comment documenting why it exists.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
It was never a test bug. box-firewall.sh decided the host's entire
firewall stance with `ufw status | grep -q "Status: active"`, and
"Status: active" is the FIRST line ufw prints: grep -q matches it and
exits immediately, closing the pipe while ufw is still writing the rest
of the table, so ufw dies of SIGPIPE. grep returned 0, but under the
script's own `set -o pipefail` the PIPELINE returns 141 (PIPESTATUS =
"141 0") — the if reads false, and a host with UFW plainly active takes
the nft-fallback branch and never builds the DNS carve-out.
`ufw status` is now read once into a variable and matched with [[ ]]:
no reader means no early exit means no race. The stale-rule scan reads
the same snapshot, so the branch decision and the converge loop cannot
disagree.
Separately, test/cli.sh now asserts that each shimmed run logged ufw
mutations at all, before the content greps, and dumps $WFW, the log and
the run's stderr when it did not — so the next occurrence reports its
own cause instead of four content-free grep failures.
Closes#102
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The previous revision skipped `usermod -aG incus` for an incus-admin member,
reasoning that 'incus' is a strict subset of what incus-admin already opens.
That is true of the daemon API and false of the filesystem. On Debian 13 /
Incus 6.0.4 the two sockets are two files with two owning groups:
/var/lib/incus/unix.socket group incus-admin 0660
/var/lib/incus/unix.socket.user group incus 0660
incus-admin opens the first and not the second, and only the second
provisions a user-<uid> project. So for the incus-admin-ONLY user — the
canonical #99 case — the pinned provisioning touch took EACCES, the `|| true`
swallowed it, no project appeared, and the grant died blaming a healthy
incus-user. Both reviewers converged on this independently and were right.
The membership is now granted for everyone, with output carrying the concern
the old no-op was built around (it is the key to a file, not a privilege;
box_tier still reads them as admin). Everything downstream moves with it:
- the backout rolls that membership back and verified, while refusing to call
the rollback a lockout — incus-admin is untouched and still opens the host
- revoke's bare path takes the membership back and reports `partial:` instead
of "no-op, nothing was taken", still declining to call them "out"
- grant's closing "gpasswd -d <user> incus-admin (no re-grant needed)" is now
a true promise: they keep 'incus', so the drop lands them in their project
- the socket existence probe goes through $SUDO, matching revoke's measured
discipline about /var/lib/incus lying to a non-root admin
Tests: the cli.sh assertions that encoded the old no-op design are flipped and
the decision is pinned at the seam that broke; the sudo shim now runs `test`
for real in both directions. Because the shims model neither INCUS_SOCKET nor
permissions and so cannot reproduce the EACCES, drill/multiuser.sh gains
criterion (o): an incus-admin-only member granted on real Incus in CI, with
the membership, the project, a live connect() to unix.socket.user, and the
post-drop landing all measured.
Mutation-checked: 11 of the new/flipped assertions fail against the previous
implementation.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The refusal at host/grant-user.sh conflated permission with provisioning.
The 'incus' group is a strict subset of what incus-admin opens — true, and
the whole of what the refusal reasoned about. The user-<uid> project, the
boxnet narrowing, the snapshot and backup allowances and the box-net profile
installed into that project are not permissions, and an incus-admin member
had none of them: box_tier() resolves them to admin, so they worked in the
shared default project with no world of their own, and the one command that
provisions one refused to run for them.
box grant now converges them fully. The group step is a reported no-op —
adding 'incus' would grant nothing and leave a group list implying a
restriction that was never in force — and steps 2-5 run unchanged. The
incus-user touch is pinned at incus-user's socket, which this turns out to
require: the incus client picks its socket by writability, so for an
incus-admin member an unpinned client sails past incus-user entirely and the
project is never created. The user-side proof names their project for the
same reason.
On success it prints the caveat the hard exit was gesturing at: the
restrictions are a default placement, not a confinement, and their own
commands keep landing in the default project until incus-admin goes. The
backout learns the third case (nothing added, nothing rolled back, still
loud), and box revoke mirrors the whole thing rather than claiming a lockout
it did not perform.
Unblocks heavy-duty/rig#49.
Closes#99
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The permission-starvation blocker found on the cast twin (claude-bot,
cast#112 round 4) is identical here: a declared permissions: block zeroes
every unspecified scope, so the decide step's label read and the bump
fallback's gh pr create could only 403 — every genuine ceremony would end
red at the label check. pull-requests: write added with the consumers
named. CONTRIBUTING step 3 and the changelog entry now tell the shipped
story: push-to-main door, event.before interlock, self-re-arm with the
manual path's bump staying the operator's. Re-runs wording nit taken.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The 0.7.0 ceremony exposed the gap: the release PR merged with four
approvals and nothing happened, correctly, because publishing hung off a
separate, manual, silent-when-forgotten tag push — the worst failure
shape, no error and no red X. The ship decision already lives in the
release PR, so the merge now IS the release.
release.yml grows a second door: pull_request closed on main, gated on
merged == true AND the hand-set release label (read from the event
payload — no extra permission). Four asserts, in order, each fail-loud
and creating nothing: VERSION at the merge commit is non--dev; VERSION
changed in this PR (merge vs first parent — the -dev interlock that
kills a mislabeled ordinary PR); the version's CHANGELOG.md section
extracts non-empty via the existing release-notes.sh; and no tag or
release exists yet. Then, in the same job, it creates the tag ref at
the merge commit via the API and publishes with gh release create
--verify-tag. Same-job on purpose: a GITHUB_TOKEN-created tag triggers
no workflows (GitHub's anti-recursion), so the tag door can never fire
off it and double-publish, and the no-existing assert covers a manual
tag racing the merge. The tag-push path stays step-for-step identical
as the documented manual fallback and backfill, gated to the push event
so a closed PR never runs it against a branch ref.
CONTRIBUTING.md's Releases section now reads "the maintainer's merge IS
the release", with the manual tag ritual kept as the fallback.
test/release.sh grep-pins the merged+labeled gate, all four asserts,
the same-job tag+publish, and that the tag-push trigger survives — in
the same daemon-free, fail-closed style.
Fixes#96
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The first release cut by the flow itself (#83): VERSION leaves 0.6.1-dev
for 0.7.0 and the Unreleased section is stamped with today's date. The
whole surface was drilled twice on 2026-07-19 — before and after the #92/
#93/#94 round — 85/0 on real incus both times, all three install channels
live, and the ceremony itself rehearsed end to end on a scratch fork
(mismatch and missing-section guards firing, the real path publishing).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The entry ended at round 1's unconditional wedge message; the shipped
behavior probes the instance, narrates wedge vs overrun, and best-effort
deletes so the retry is clean either way.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Twice in the 2026-07-19 release drill (Debian 13, Incus 6.x, /dev/kvm
present, images cached), the child 'incus launch' under 'box new' wedged
with no server-side operation: 'incus operation list' empty, the instance
never created, the daemon journal quiet — one wedge ran 56 minutes before
being killed by hand, the other was killed by a 540s wrapper. An immediate
retry of the identical command succeeded in ~2-3 minutes, both times. box
inherited that as an indefinite silent hang, indistinguishable from a cold
mint working.
The mint now prints "launching instance ..." before the call, and the call
rides 'timeout -k 5 $BOX_LAUNCH_TIMEOUT' (seconds, default 600 — generous:
the coldest measured mint is minutes, never an hour; overridable the same
way BOX_CPU/BOX_MEMORY are), with stdin pinned per drill/RUNS.md trap 13.
When the budget fires (124, or 137 when the KILL was needed) the failure
says exactly what was measured — the client wedged with no server-side
operation, an immediate retry has been observed to succeed — and points at
'box doctor' for host state. A non-timeout launch failure still surfaces
incus's own stderr. The --from clone path is untouched: 'incus copy' of a
local instance is a different operation and has never been observed to
wedge this way.
Proven the way the other mint-path guards are (a daemon-free run cannot
mint): test/cli.sh greps that the narration orders before the launch, that
the launch sits under 'timeout -k' with the BOX_LAUNCH_TIMEOUT budget and
pinned stdin, and that the wedge message carries the retry hint, the
doctor, and #93 — plus a live shim-incus drive of all three exits (wedge,
plain refusal, success) during development.
Fixes#93
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
README: the installer's default is the latest release — reproducible,
resolved off the releases/latest redirect, failing loudly rather than
silently handing out main — with the pinned (BOX_REF=<tag>) and dev
(BOX_REF=main) channels beside it. CONTRIBUTING: a release is a PR,
then a tag — the release PR bumps VERSION and stamps the changelog,
the bare X.Y.Z tag on the merge commit triggers release.yml, and
main's VERSION bumps to X.Y.(Z+1)-dev immediately after, because the
versioned layout names install trees after VERSION. Plus the
CHANGELOG entry.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
help setup-host and the README now lead with what a bare run does (free
default → 10.88; existing bridge → converge; claimed default → auto-pick
10.89…10.127, announced) and demote BOX_SUBNET to what it is: the pin for
scripted hosts, honored or refused, never overridden. The changelog names
the drill/rehearsal payoff — nested box-in-box with zero flags. The
doctor's this-machine #80 verdict stops saying "setup-host now refuses
this" (it no longer does, it picks around it): a poisoned stack predates
the fix or was pinned onto the uplink.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
README: the templates section tells the split — thin seeds (user, tmux,
rig), the creds-free tenant role auto-run at mint, the operator-run
workload join, and the RIG_REPO/RIG_REF pin point with the honest unpinned
note (both directions of the rig<->box edge track main until rig#32/#83).
The #80 guard note is cross-referenced as living once, in rig's roles.
box-design.md: a layering section (why rig roles and not cloud-init:
convergent, re-runnable, effective-state-asserted vs a first-boot
one-shot), and the announce section now says who renders the context file.
CHANGELOG: staging template, BOX_BOOTSTRAP_ROLE + auto-run, the pin point
under Added; the tenant-content move under Changed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
README's setup-host section documents the subnet input and the refusal;
'box help setup-host' names BOX_SUBNET and the nested-box trap; 'box help
doctor' names the #80 signature it now judges; the changelog carries the
full story, including that suggested fix 4 (the agent-context guard note
for the templates) lands in heavy-duty/rig#31's bootstrap roles per the
thin-templates split (#81).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The staging template itself returns as a thin seed once rig#31's staging
role exists; what ships here is only the mint-time box surface — the two
box.env keys and the dynamic template suite.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
'box rm' deletes a box and every snapshot it has; 'box new --from' clones,
but the clone still lives on the same host. Nothing a box held could outlive
a teardown — which made #66's upgrade refusal honest but lossy. This adds
the way out and the way back:
- box export <box> [<file>] [--instance-only]: wraps 'incus export' into one
portable backup tarball (default <box>-<UTC stamp>.tar.gz), snapshots
included by default. Requires the box stopped (require_stopped grew an
honest reason parameter: export is down by OUR decision, not incus's).
Credentials are SHOUTED, not scrubbed — the artifact carries the box's
whole disk, and scrubbing a disk image is a promise tarball surgery
cannot keep.
- box import <file> [--name <box>]: reads the artifact's name from
backup/index.yaml up front, refuses any name an existing instance holds
(the resolve_box boundary from the other side), pre-flights the stack
(require_stack, factored out of cmd_new), imports, then re-stamps the
HOST's truth onto the artifact's: user.box=1 (legacy tag honored), the
box-net placement (profile assign, the migrate-host move), fresh volatile
MACs (imports restore volatile.* verbatim — a re-import beside its
sibling collided at start with 'MAC address already defined on another
NIC', measured live on Incus 6.0.4), and reset_identity, exactly like a
clone.
- restricted tier: box grant now converges restricted.backups allow —
export rides the backup API, which incus-user's restricted projects block
by default exactly like snapshots (incus 6.0 permissions.go,
AllowBackupCreation). Import is plain instance creation and needs no key.
- tests: driven usage errors + fail-closed grep/line-order guards for every
daemon-gated invariant; CI's rehearsal job now runs a live round-trip
(mint, write, snapshot, down, export, rm, import, assert the file, the
snapshot, the tag, the agent, and the collision refusal).
The whole flow was verified against a live Incus 6.0.4 daemon: running-box
refusal, export, overwrite guard, rm, import with and without --name,
re-home onto box-net, sibling re-import with distinct MACs and machine-ids,
pre-export file and snapshot present in both.
Closes#70
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>
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>
#71 — run as root, box installs once to /opt/box (world-readable) with a
/usr/local/bin shim, so every operator on a shared host has `box` on PATH
instead of `command not found`. Per-user ($HOME/.local) install is
byte-identical to before; BOX_HOME/BOX_BIN still override. A per-user tree
under /root is 0700 and unreadable to everyone else — the whole fleet got
`command not found` — so the root branch lands in a system location and
chmod -R a+rX's it, guarded on root. This is the path rig installs box through
(rig#24's `box` role).
#65 — `box tmux` runs `tmux new-session` inside the box, but the templates did
not install tmux, so it failed with `tmux: command not found`. tmux is now in
each template's cloud-init package list.
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>
Found by running the drill on a real host, which I could not do before.
The unit is Type=oneshot with no RemainAfterExit, so systemd marks it
'inactive (dead)' the moment ExecStart succeeds. The rules are applied and the
box-to-box drop is live, and the unit still reads as though it died. That is
precisely the question people ask this unit: drill.sh's own failure hint sends
you to 'systemctl status box-firewall.service' to find out whether the firewall
came up, and today the honest answer and the alarming one look identical.
setup-host.sh already believed this was set — 'The unit is RemainAfterExit, so
once it has run it stays "active" forever' — and reasoned from it to explain why
it uses restart instead of 'enable --now'. The reasoning is right and the
restart is right; only the unit was missing the line the comment assumed.
Verified live: before, 'nft list table bridge box' showed the drop present while
is-active said inactive. After, is-active says active (exited) with the drop
still present, and restart still re-applies.
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>
box setup-host stopped halfway when it had to add you to incus-admin: it
usermod'd, printed a NOTE telling you to re-login and re-run, and exited 0 —
a success-shaped no-op with no boxnet, no ACL, no box-net profile and no
firewall behind it. It now re-execs itself under 'sg incus-admin' and
finishes in that same invocation.
The membership check was also asking the wrong question. 'id -nG "$USER"'
names a user, so it reads the group database — which lists incus-admin the
instant usermod returns, while the shell's own credentials still lack it
(supplementary groups are fixed at login). A same-session re-run therefore
passed the check and died further down on a bare permission error from incus
that mentioned neither the group nor the re-login. Argless 'id -nG' asks the
process what it actually holds, which is what incus checks when it opens
/var/lib/incus/unix.socket.
With one run now sufficient, install.sh runs the setup itself instead of
printing a warning and leaving the user a command: the install reported
success and 'box new' then failed on a host with no Incus. setup-host is
idempotent, so doing this on every install is also how an upgraded host picks
up stack changes. BOX_SKIP_SETUP_HOST=1 opts out, and a failed setup leaves
the install standing and says what to re-run.
Fixes#63Fixes#64
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>
One entry for now — 0.5.0 as merged plus the inline resource flags that
fold into it. Pre-0.5.0 history stays in git and drill/RUNS.md, which
this points at.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>