Commit graph

36 commits

Author SHA1 Message Date
dan-claude-bot
be633aa5f8 feat: a minted box records how it was minted, and box info reads it back
cmd_new knew a great deal at the moment it launched and wrote three user.*
keys, dropping the rest: the box version that minted it, the base image (an
unpinned alias on a moving remote), the rig role, which rig repo and ref
converged it, the mint time, and whether a container was chosen or fallen
back into for want of /dev/kvm. There is no host-side per-box store — the
Incus instance config IS the database — so every one of those facts was gone
the moment the mint returned.

The same single write point now carries them as user.box.*, plus
user.box.schema=1 naming the stamp's shape. The alias's resolved fingerprint
is pinned in a second call after the launch, read from volatile.base_image,
best-effort by construction: a box that exists and boots must never be failed
over a provenance field.

A clone re-stamps rather than inheriting. 'incus copy' carries every user.*
key forward (audit B2), so an inherited stamp would not go stale, it would go
false. --from now re-stamps schema/version/created/origin/origin.from on the
copied instance before it starts, and leaves the lineage keys (template,
user, image, role, rig pin) alone — the clone's disk genuinely came from
them. origin.from records one hop.

cmd_info grows a provenance block, tolerating absence everywhere: boxes
minted before this stamp existed render as a box with blanks, and a schema
this box does not recognise is treated as newer than it, not as broken.

Closes #103.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 10:47:50 +00:00
dan-claude-bot
ecce72f025 test: the teardown check must not inherit BOX_YES
The new check drives host/teardown-host.sh for real — the first check in
the suite to execute a destructive script rather than grep it or run it
against shims. teardown-host honours an inherited BOX_YES, and test/cli.sh
never sanitised it, so under `BOX_YES=1 bash test/cli.sh` the invocation
took the consent arm instead of the gate: a real teardown of the host the
suite was running on, reported red only afterwards.

BOX_YES is the documented automation switch for this family, so an
operator's CI wrapper exporting it is the expected case, not a contrived
one. The file header promises the suite is runnable anywhere; before this
the promise held under any environment.

Fixed at the call site with `env -u BOX_YES`, plus a file-level `unset` so
the promise cannot be broken again by the next check that drives something
destructive.

Refs #113
2026-07-20 14:45:23 +00:00
dan-claude-bot
b3fa94a7d4 fix: teardown-host refuses a terminal-less run instead of aborting mute
host/teardown-host.sh had no `[ -t 0 ]` check before its confirmation
prompt. Without --yes/BOX_YES and without a terminal — CI, a pipe, a
nohup — it fell into `read`, took the instant EOF and exited 1 saying
only "aborted": a refusal naming neither the cause nor the override, in
the most destructive script in the tree.

It now refuses with the override named, exit 2 — "you invoked this
wrong", matching host/revoke-user.sh --purge and install.sh's confirm(),
versus 1 for "you were asked and you said no".

The gate sits below the --yes/BOX_YES arm, so consent given
non-interactively still runs headless, and above the first incus call,
so the refusal needs no daemon — which is what lets test/cli.sh drive it
for real instead of grepping for it.

Refs #113
2026-07-20 14:45:23 +00:00
dan-claude-bot
b1968dabb7 fix: run setup-host over a migrated flat tree, and name what the migration left
had_install was computed AFTER the pre-0.7.0 migration block, so it observed
a versions/ directory that the migration had just created one line earlier. A
flat /opt/box therefore read as "already installed", host/setup-host.sh was
skipped, and the host kept every artifact the old release left behind while
box --version reported the new one — silent, and self-concealing. Computing
it BEFORE the migration asks the honest question: a tree that needs migrating
has by definition never been converged by this version's setup-host.

The accepted consequence: an unattended (BOX_YES=1) upgrade on a flat-tree
host now runs setup-host, which the #66 note cautions about. setup-host
converges and is idempotent, and a release whose host half is silently
missing is the worse failure.

Once setup-host can run at all, a second defect in the same block becomes
reachable: it went through $DEST/current, but the #66 guard holds the default
under existing boxes, so on such a host current still names the OLD version —
converging the host with the previous release's host scripts. It now runs the
installed version's own tree.

Separately, the migration named itself but not the lifecycle: the old tree
becomes a first-class 'box versions' entry the operator never installed and
cannot tell apart from a deliberate rollback target. The migration line now
names both ways out, and the closing summary re-states it, since the original
line scrolls past ~250 lines before the install ends.

test/cli.sh gains an inst_setup helper (no BOX_SKIP_SETUP_HOST) and a stub
host/setup-host.sh on the fabricated upgrade source, so the flat-tree fixture
proves end to end and fully offline that host setup runs — and that a
genuinely versioned tree still skips it.

Refs #115, #117
2026-07-20 14:18:21 +00:00
dan-claude-bot
0b7ad21eca fix: lint the release path — globstar does not descend into dot-directories
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
2026-07-20 13:11:41 +00:00
dan-claude-bot
9c78911563 fix(templates): the printed workload join follows rig's machine-role rename
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>
2026-07-20 00:07:00 +00:00
dan-claude-bot
86dbb0449e refactor(templates): the tenant seeds carry rig's -box family suffix
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>
2026-07-20 00:01:29 +00:00
dan-claude-bot
bb51aeeca9 fix: close the silent-EOF class at the two host/ gates it also reached (#111)
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>
2026-07-19 21:08:25 +00:00
dan-claude-bot
e27ab239f1 fix: Ctrl-D at a confirm prompt aborts out loud, not in silence (#111)
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>
2026-07-19 21:08:25 +00:00
dan-claude-bot
531803a8c9 test: pin the no-TTY refusal wording the rehearsal hit
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 19:47:05 +00:00
dan-claude-bot
b38518ee54 fix: the unattended rehearsal consents explicitly to the restore gate
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>
2026-07-19 19:46:06 +00:00
dan-claude-bot
12713c5012 fix: box restore asks before it destroys, in its own words (#105)
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>
2026-07-19 19:40:39 +00:00
dan-claude-bot
57db9a56bb fix: teardown-host carried the same SIGPIPE race — it sets pipefail (#106 review)
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>
2026-07-19 18:18:36 +00:00
dan-claude-bot
dc09d54cbe fix: the fresh-UFW test block no longer flakes on a missing log
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>
2026-07-19 18:17:42 +00:00
dan-claude-bot
7067fb03b0 docs: box help matches what grant/revoke now mutate (#101 review)
Round 2 changed the incus-admin path from a skipped group step to a real
`usermod -aG incus`, but `bin/box` help still described the superseded
design — telling operators the group step is a no-op that will not happen
(it does) and that a bare revoke has no membership to drop (it drops one).

The help is the pre-run contract: it is what an operator reads to decide
whether to run the command at all, so prose that denies a mutation the
command performs is the same class of defect the rest of this PR exists to
remove. Both sections now describe the current behavior, and `help revoke`
carries the consequence the operator would otherwise discover: once `incus`
is gone, a later `gpasswd -d <user> incus-admin` lands them in NEITHER
group, so grant's "no re-grant needed" holds only while they hold `incus`.

Pinned in both directions — the current sentence must be present and the
superseded one absent — so the prose cannot drift from the scripts again.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 17:33:37 +00:00
dan-claude-bot
8598cf96fe fix: grant the 'incus' membership to incus-admin members too (#101 review)
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>
2026-07-19 17:33:37 +00:00
dan-claude-bot
4a06c3ac00 fix: box grant provisions incus-admin members instead of refusing them
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>
2026-07-19 17:33:37 +00:00
dan-claude-bot
12edb1966f chore: the four new grep-proof checks carry the SC2016 directive their siblings had
CI shellchecks every shell file; the round-2 checks embed $ROOT in
single-quoted bash -c strings exactly like the check above them, but the
disable directive only covers the next command — each new check gets its
own, same as the repo pattern.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 12:59:58 +00:00
dan-claude-bot
4da487e388 fix: the timeout path probes the instance, tells the two stories apart, and cleans up
Round-1 consensus on #94: timeout only proves the CLIENT overran the
budget. incus launch is create-then-start, so a slow-but-progressing
launch may already have registered the instance — the old message claimed
'never created' unconditionally and the advised retry would collide with
'Instance already exists'. The 124/137 path now probes 'incus info',
narrates the branch it found (true #93 wedge vs slow-launch overrun),
best-effort 'incus delete --force's either way so the retry is clean in
both worlds, hedges 137 as possibly an outside kill, and BOX_LAUNCH_TIMEOUT
is documented in 'box help new' beside the other knobs. Both branches
driven live against a shim incus; four new grep-proof checks pin the probe,
the cleanup, the overrun story, and the help text.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 12:52:41 +00:00
dan-claude-bot
8ab9b38ba2 fix: narrate and time-box the incus launch — a wedge fails loudly, not forever (#93)
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>
2026-07-19 12:28:22 +00:00
dan-claude-bot
5d893ba2e1 test(cli): drive the four-case subnet decision — pin, converge, default, scan (#80)
choose_subnet is extracted with its helpers (the same awk seam as
subnet_claimant) and driven case by case against canned tables under the
shim ip: every pinned refusal unchanged (gateway-claimed, foreign
interface, disagreeing bridge, garbage — and a clearing pin used verbatim,
silently); bridge-present convergence to the bridge's own subnet with no
scan (case 2 precedes case 4) and no announcement on the plain default
re-run; the poisoned state (bridge AND uplink on one subnet) refusing
rather than converging; a free default staying 10.88; the nested tables
auto-picking 10.89 loudly, naming the DEFAULT GATEWAY claimant and the
BOX_SUBNET pin; a doubly-claimed host skipping to 10.90; and all 40
candidates claimed falling back to the old refusal, naming the end of the
scan range and BOX_SUBNET.

The driven whole-script fixtures move with the semantics: the refusal
paths now pin BOX_SUBNET=10.88.0.0/24 explicitly (the unpinned nested run
is no longer a refusal — it is the auto-pick, proven end to end: Host
ready, the announcement, and the bridge + ACL carve-out following the
pick to 10.89). The decision-precedes-mutation line-order check now pins
the choose_subnet call site. 339 → 370 checks.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 21:22:00 +00:00
Daniel Marin
a1eb2054ca
Merge pull request #89 from dan-claude-bot/fix/ufw-subnet-converge
box-firewall: converge the UFW carve-out off the live bridge; fail closed at boot (#86 follow-up)
2026-07-18 22:14:17 +01:00
dan-claude-bot
fb1f5e6f98 test(cli): pin the HOME=/root scar so a seed rewrite cannot drop it silently
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 20:52:15 +00:00
dan-claude-bot
a8d0f64e48 test(cli): the seed contract, driven — and the absence, fail-closed (#81)
- render_userdata extracted and DRIVEN: defaults pin heavy-duty/rig@main on
  both the installer URL and the installer's env; RIG_REPO/RIG_REF override
  at mint; a shell-shaped repo, a spaced ref and a newline-smuggled repo all
  die on the host (bash =~ anchors the whole string — one clean line cannot
  sneak past it the way a line-oriented grep -q lets it).
- BOX_BOOTSTRAP_ROLE through the real parser: round-trips, and a
  shell-shaped value dies at the gate.
- Per-template sweep grows the #81 contract: BOX_USER matches the user the
  cloud-init actually creates; a role-bearing seed installs rig carrying
  BOTH pin tokens; and absence greps over EFFECTIVE cloud-init lines
  (comments may name what they refuse — #69's idiom) prove no agent CLI, no
  docker, no tailscale/authkey/ssh, no write_files heredocs, in ANY
  template. Want-exit 1: re-adding tenant content goes red.
- cmd_new pinned: user-data reaches Incus through render_userdata; the
  auto-run orders after the cloud-init wait and sits under the
  T_BOOTSTRAP_ROLE guard; the failure path names the re-run; the workload
  join is printed, never exec'd; no template names a creds-holding role.
- staging's boot demands pinned to the file; blank proven roleless and
  rig-less.

283 passed, 0 failed (main: 213); shellcheck -x clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 20:51:27 +00:00
dan-claude-bot
ce9ec684d1 test(cli): drive box-firewall under a shim ufw, and the doctor's UFW findings
The real script, driven end to end (the setup-host seam): a fake ufw
serves canned status tables and logs every mutation, fake
nft/sysctl/iptables swallow the rest, the shim ip answers the
live-bridge read. Proven: a remapped bridge converges (stale tcp+udp
allows deleted, live gateway's inserted, live rules never deleted), an
agreeing host deletes nothing, a fresh host gets exactly the five
historical mutations, the unaddressed-bridge boot window mutates
NOTHING and says so (the fallback's absence is pinned to non-comment
lines), and a no-UFW host keeps its interface-scoped nft path — boot
window included. ufw_dns_findings is extracted and driven against the
same tables: agreement silent, stale carve-out flagged and named,
deny-with-no-allow flagged, untouched UFW host clean, stale-beside-live
named, other interfaces ignored; wiring grep-guarded. 297 checks.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 20:45:21 +00:00
dan-claude-bot
7d48822cc0 test(cli): drive the #80 guard, the BOX_SUBNET plumb-through, and the signature
The two pure functions are extracted and driven, the same seam as box_tier
and load_template: a shim ip serves canned route tables (the poisoned
guest verbatim from #80's capture, the remapped-bridge workaround, a
healthy stack host), and each signature line is proven to fire alone and
to stay silent on the clean states.

Then the WHOLE setup-host is driven end to end under shims (fake incus and
sudo log every call; fake id keeps it unprivileged): the three refusals
(gateway-in-subnet, foreign interface, garbage/wrong-shape BOX_SUBNET)
exit 1 having made NO incus and NO sudo call — refuse-before-mutation is
asserted on the logs' absence, not assumed — the existing-bridge mismatch
refuses, the legitimate converge (boxnet's own prior claim) still reaches
'Host ready', and a fresh BOX_SUBNET=10.89.0.0/24 build lands
ipv4.address=10.89.0.1/24 on the bridge and destination: 10.89.0.1/32 in
the ACL, proving every derived value follows the one input.

Line-order guards pin the wiring: the subnet guard precedes the first
mutation in setup-host, the machine-local signature precedes the doctor's
daemon checks, probe_sig runs on both tiers, box-firewall reads the
gateway off the live bridge, and the drill/multiuser/migrate probes derive
their prefix from the network.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 19:47:20 +00:00
dan-claude-bot
b5aa21e733 test(cli): the green path and the effective operand, pinned
Grok's two blockers, both real test gaps in a keys-only carve: with no
in-tree template setting the keys, deleting the case arms left the
suite green while the feature died as 'unknown key' at first use — a
positive fixture now drives both keys through the real load_template
and asserts they surface. And order-after-pick_mode was necessary but
not sufficient: a regression to the raw $mode flag would still pass it
while refusing every auto mint on a valid VM host — the guard line is
now pinned to compare "$m", the effective mode.

Also codex's concurrence on both, and grok's help nit: the two keys
are independent, and the --template help now says so.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 19:02:34 +00:00
dan-claude-bot
8d1824551c test+docs: strip staging specifics for the slim carve; changelog for #81
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>
2026-07-18 19:02:34 +00:00
dan-claude-bot
dd1166e1ed test(cli): template suite — every templates/*/ dir proven, dynamically (#68)
The old tmux check hardcoded blank/claude/codex/grok, so a new template
could ship without CI ever reading it. The suite now discovers templates/*/
and, for each: drives the REAL load_template (extracted from bin/box, the
same trick box_tier and install.sh's DEST block get) so box.env must parse
against the actual allowlist with BOX_IMAGE + BOX_USER present; asserts
user-data.yaml exists, declares #cloud-config, and is well-formed YAML
(python3+pyyaml, skipped loudly where absent — CI has both); and keeps the
#65 tmux contract. Fixtures prove the dies a green parse cannot: an unknown
key (no template grows a network) and a missing required key.

Staging-specific: both boot demands proven through the parser, docker + rig
preinstalled, and a creds-free refusal grep — no tailscale/authkey/ssh in
effective cloud-init lines; rig installs those inside the guest. Plus the
cmd_new half, grepped the way the expose guard is: the REQUIRE_VM refusal
orders after pick_mode, and boot.autostart is stamped only under the
T_AUTOSTART guard.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 19:02:28 +00:00
dan-claude-bot
1ef3ae739a feat: box export / import — state that survives the box and the host (#70)
'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>
2026-07-18 18:55:23 +00:00
dan-claude-bot
68a4996f0b fix: version names die at one shared gate, and --purge-host hears --force
Round-1 convergence from all three reviewers, both findings real:

A version string used to be a path fragment: 'box uninstall
../../../.ssh' resolved below versions/ and rm -rf'd wherever it
landed, 'box use' could point current outside the root, and a hostile
flat-tree VERSION could steer the migration's mv the same way. One
strict validator now gates every caller — only [A-Za-z0-9._+-], no
leading '.' or '-' — byte-identical in install.sh and bin/box like
existing_boxes, diff-guarded in the tests, with traversal regressions
on use, uninstall and the migration (which now refuses BEFORE the tree
moves anywhere).

--force is uninstall's installer-family consent, and --purge-host now
forwards it: teardown-host.sh gets --yes under --force/BOX_YES, so the
combined non-interactive uninstall no longer dies at teardown's own
prompt. CI's drill now runs the combined verb with --force alone (no
BOX_YES, no TTY) — the exact invocation that used to abort.

Also grok's polish, taken: current flips by rename (ln to a side name,
mv -Tf over — no window with no current) in both install.sh and 'box
use'; BOX_REINSTALL swaps by two renames and deletes LAST; and the
single-version path refuses while current is dangling (readlink -f
resolves a missing last component, so the guard checks the DIRECTORY,
not just the string).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 18:11:26 +00:00
dan-claude-bot
82f7f9d51a test+ci: real installs driven offline, uninstall proven to zero residue
test/cli.sh drives REAL installer runs via BOX_INSTALL_SOURCE (temp
BOX_HOME/BOX_BIN, a fabricated second version for upgrades): fresh install
lands versioned, --version answers through the chain, side-by-side installs,
same-version no-op, BOX_REINSTALL, the 0.6.0 flat-tree migration, stale-
symlink healing, single-version and full uninstalls asserted to zero residue
(a planted survivor makes it scream INCOMPLETE), working-tree refusals, and
byte-identity of the existing_boxes copies. The rehearsal job now installs
via install.sh itself — CI proves the installer under review — and ends with
the uninstall drill: revoke --purge, teardown --yes, box uninstall, then
assert nothing is left (networks, profiles, nft, units, files, symlinks).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 16:02:37 +00:00
dan-claude-bot
8e6f3a4bb8 grant/rehearsal: the codex round — verified rollback, loud partial states, and the raw-attach guarantee measured (#75)
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>
2026-07-18 06:41:05 +00:00
dan-claude-bot
ec5e898094 revoke/grant: survive the live-session case — the review's one real hole (#74)
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>
2026-07-18 05:11:20 +00:00
dan-claude-bot
56eed6250e test+ci: the multi-user rehearsal, and CI that runs it on a real incus (#74)
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>
2026-07-18 04:09:48 +00:00
dan-claude-bot
437a3a8e35 test+ci: add CI workflow and a dependency-free test suite
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>
2026-07-18 00:01:15 +00:00