box/CHANGELOG.md
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

34 KiB
Raw Blame History

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

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"), so release.yml now fires on pushes to main (fork-sourced ceremony PRs get a read-only token on pull_request events), reading the transition from the push itself: event.before to the pushed head. A decide step answers four states — release-flow work merged under the release label (-dev endstates, 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 to X.Y.(Z+1)-dev itself, direct push with a loud open-a-PR fallback, so no follow-up bump PR exists on the paved road. Same-job on purpose: a GITHUB_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.sh grep-pins the gate, every decide verdict, the single on.push key, and the same-job tag+publish+re-arm in the same daemon-free, fail-closed style.

Fixed

  • box grant provisions an incus-admin member 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: the incus group is indeed a strict subset of what incus-admin opens, but the user-<uid> project, the boxnet narrowing, the snapshot and backup allowances, and the box-net profile installed into that project are none of them permissions, and an incus-admin member had none of them — box_tier() resolves them to admin, 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 grant now runs the full convergence for them: the group step is a reported no-op (nothing is added — adding incus would grant nothing and leave a group list implying a restriction that was never in force), and steps 25 run unchanged. The touch that provokes the lazy project is pinned at incus-user's socket, which the fix turns out to require: the incus client picks its socket by writability (client/connection.go — the daemon socket when writable, unix.socket.user only otherwise), so for an incus-admin member 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 unqualified profile show would have answered from the shared default project and proved nothing. 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-admin goes their own box commands keep landing in the default project, at which point the provisioned project becomes their home with no re-grant needed. The failure path knows the new case too: nothing was added, so nothing is rolled back, and the backout says exactly that rather than going quiet. box revoke mirrors it — a bare revoke of an incus-admin member is a named no-op (it makes no privileged call at all: there is no membership to drop), and --purge unmakes the provisioning while refusing to call them "out", both naming gpasswd -d <user> incus-admin as the only thing that ends their access. Unblocks rig's users apply (heavy-duty/rig#49), which had to call box grant for a user who is both incus-admin by hand and role box in the fleet file. Driven end to end in test/cli.sh under logging incus/sudo shims — every assertion is made against what the run did, not what the source says it would.

0.7.0 — 2026-07-19

Added

  • The installer defaults to the latest release, and releases publish themselves (#83) — curl | bash used to hand out whatever main was 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.sh now resolves the latest release tag by following GitHub's releases/latest redirect (one HEAD request — no API, no token, no rate-limit pain) and downloads that tag's tarball; a failed resolution refuses loudly, naming BOX_REF as the way out — it never hangs and never silently falls back to main. A set BOX_REF is 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 new release.yml (on a bare X.Y.Z tag push — the 0.6.0 tag set the no-v precedent) asserts the tag names the tree's own VERSION (a mismatch fails loudly and creates nothing) and publishes the GitHub release with that version's CHANGELOG.md section 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). And main's VERSION now carries -dev between releases (this PR: 0.6.1-dev): the versioned layout names install trees after VERSION, so a main install without the bump would land in versions/0.6.0 and impersonate the released tree. test/release.sh drives all of it offline — the extraction against fixtures and the real changelog, the resolution and every channel against a shim curl.

  • setup-host auto-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 bare box setup-host now decides the subnet itself, in four deliberate cases: an explicit BOX_SUBNET is honored or refused, never silently overridden (scripted hosts keep exact semantics); an existing boxnet bridge 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 free 10.88.0.0/24 stays the default; and a claimed default — the nested case: a drill or rehearsal running inside a box, whose own uplink owns 10.88 — scans 10.89.0.0/2410.127.0.0/24 in 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-host refuses a claimed subnet, and BOX_SUBNET picks another (#80) — run inside a box, setup-host used to build a nested boxnet on 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: ~2436 s outages, roughly hourly, with the host clean throughout). setup-host now scans the target subnet before any mutation — the default route's gateway inside it, or any non-boxnet interface holding an address in it — and refuses, naming the way out. A prior boxnet owning the subnet is the legitimate converge path and does not trip it. BOX_SUBNET=<a.b.c.0/24> (validated, alongside the existing BOX_DNS) moves the whole stack: the bridge address, the ACL's gateway carve-out (now converged via network acl edit, so a bridge moved off a colliding subnet no longer strands box DNS behind a stale /32), the firewall (box-firewall reads the gateway off the live bridge), and every drill/migrate probe that used to hardcode 10.88.

  • box doctor knows the #80 signature — a default gateway held as a LOCAL address, and duplicate connected routes for the uplink subnet, judged from ip route/ip addr on 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 matches boxnet'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 staging template (#81, the re-cut of #69's layering) — a server-class, creds-free seed: Debian 13, user ops, 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), and BOX_BOOTSTRAP_ROLE="staging" — the server posture (docker, sshd hardening) converges via rig bootstrap staging after mint. The tailnet workload join holds a pre-auth key and therefore stays operator-run (box shellsudo rig bootstrap workload), printed as a next step — box never sees the key.

  • BOX_BOOTSTRAP_ROLE template 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). blank names 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 (default heavy-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.env allowlist keys. BOX_REQUIRE_VM=1 refuses 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=1 stamps boot.autostart=true at launch, per-instance like limits.*, so the box returns from a host reboot without an operator; clones inherit it via incus copy. Still no key for a network or a security.* flag, on purpose.

  • Dynamic template test suite (#81, carved from #69) — test/cli.sh discovers templates/*/ instead of hardcoding the list, so a new template cannot ship unseen. Per template: box.env is driven through the real, extracted load_template (unknown keys and missing BOX_IMAGE/BOX_USER fail, fixtures proving both dies); user-data.yaml exists, declares #cloud-config, parses as YAML, and installs tmux (#65). Grep guards pin the cmd_new half: the REQUIRE_VM refusal orders after pick_mode, and boot.autostart is stamped only under the T_AUTOSTART guard.

  • 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>] wraps incus export into one portable backup tarball (default <box>-<UTC stamp>.tar.gz), snapshots included by default (--instance-only opts 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: the user.box=1 boundary tag (legacy user.claudebox=1 honored), the box-net placement (re-assigned if the artifact's differs — the migrate-host move), and a fresh machine identity: the NIC's MAC (imports restore volatile.* verbatim, and a re-import beside its sibling collided at start with "MAC address already defined on another NIC" — measured live; incus copy regenerates it, incus import does not) plus reset_identity (the clone trust boundary: no DHCP collision with the box it was exported from). Import refuses any name an existing instance holds — the resolve_box boundary, seen from the other side. Works on both tiers: box grant now also converges restricted.backups allow (incus-user blocks backups by default exactly like snapshots, and an export is a backup create+download — measured against incus 6.0's permissions.go); re-run box grant <user> after upgrading, as documented. CI's rehearsal job 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 own VERSION + INSTALLED_FROM), with a current symlink tracking the default and $BINDIR/box riding 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 resultcurrent must resolve to the asked-for version and the chain's box --version must answer it). Re-running the installer with an installed version is a converging no-op (BOX_REINSTALL=1 replaces that version's tree); a new version installs side-by-side and flips current only 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 use keeps 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/box is 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 uninstallbox uninstall [<version>] [--all] [--purge-host] replaces the "rm -rf two paths" prose. One version: refuses the current one (box use off it first). Everything: runs in the safe order — refuses while boxes exist (naming them) unless --purge-host runs teardown-host first — then removes every version, the current and 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 as uninstall INCOMPLETE naming the leftovers (the revoke --purge discipline). teardown-host.sh gains --yes/BOX_YES=1 for automation and now points at box uninstall when 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 a cp -r mimic of it), and ends with an uninstall drill: grant + revoke --purge a 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 + throwaway BOX_HOME/BOX_BIN roots and a fake incus on 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 the INCOMPLETE scream 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 run box setup-host or 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, no write_files heredocs — so tenant content cannot quietly grow back.

Fixed

  • A wedged incus launch fails 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 child incus launch under box new hung 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, and an immediate retry of the identical command succeeded in minutes, both times. box new inherited that as an indefinite silent hang, indistinguishable from a cold mint working. It 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; the same scripting-knob shape as BOX_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 at box doctor for the host. 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.
  • UFW's gateway carve-out converges with the bridge, and the doctor can see it (the #86 review's blind spot) — box-firewall gated its whole UFW block behind "a DENY on boxnet rule 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 stale allow … to <old-gw> port 53 and 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), and box doctor reads UFW's own table wherever UFW is active, flagging a DNS allow that does not match boxnet'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.service ran, box-firewall guessed GW=10.88.0.1; on a BOX_SUBNET host 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 --purge re-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/incus is not traversable by a non-root admin, so a bare [ -d ] answered "absent" for a directory that was there).
  • A wedged $BINDIR/box no 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 from versions/<v> itself; symlinks are converged with ln -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 hardened boxnet, seeing nobody else's; box revoke <user> takes it back (--purge deletes 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, the box-net profile invisible — so grant is an idempotent convergence: project narrowed to boxnet and only boxnet (listing the private bridge too, the obvious fix, would keep an unhardened network one --network flag away), snapshots allowed, the shipped profile installed into their project. box_tier() (live credentials, argless id -nG) drives the tier-aware surface: expose refuses honestly before any daemon call, setup-host and doctor answer at the caller's tier. Rehearsed end-to-end by drill/multiuser.sh (criteria an: 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 rehearsal job stands up the full stack on the runner (setup-host, doctor, then multiuser.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 the box symlink 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_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 (read for files, +search on dirs), guarded on root. This unblocks "rig installs box" (rig#24's box role).
  • CI + a test suite.github/workflows/ci.yml (a check job: globstar shellcheck -x over bin/* **/*.sh, then bash test/cli.sh) and test/cli.sh, dependency-free and runnable by a non-root user with no Incus. It exercises the install.sh DEST/BINDIR branch functionally (both tiers + BOX_HOME/BOX_BIN overrides), 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 restore never worked against Incus 6 — the command table dispatched incus restore, a subcommand that does not exist (Incus 6 spells it incus 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 in test/cli.sh now hold it.

  • box tmux works on every template (#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 (blank/claude/codex/grok).

  • box setup-host finishes in one run (#63). When it had to add you to incus-admin it stopped there and told you to re-login and re-run — an exit 0 that reported success having built none of the stack: no boxnet, no ACL, no box-net profile, no firewall. It now re-execs itself under sg incus-admin and 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 moment usermod returns, 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 from incus. Argless id -nG asks the process what it actually holds.

  • setup-host works as root, with or without sudo — every privileged call was a hardcoded sudo, so on a minimal root image (no sudo package) it died on sudo: command not found before doing anything. Privilege is now resolved once: nothing at UID 0, sudo otherwise, and a clear error if neither is possible. This is what made install.sh's root path real rather than nominal.

  • setup-host grants incus-admin to the human, not to root — under sudo install.sh it would have added root to 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 from SUDO_USER.

  • box-firewall.service now reports its state honestly — the unit is Type=oneshot and was missing RemainAfterExit=yes, so it went inactive (dead) the instant it succeeded: a host whose isolation was perfectly live read as one whose firewall unit had died. drill.sh sends you to systemctl status box-firewall to 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 box showed the drop live while the unit read dead. restart was and remains correct either way.

  • setup-host's apt calls can no longer hang — a fresh cloud image has apt-daily/unattended-upgrades holding the dpkg lock, and a plain apt-get install waits on it silently and indefinitely. Now bounded (DPkg::Lock::Timeout=300) and non-interactive, which matters because install.sh runs it with nobody watching.

Changed

  • drill.sh proves the new contract instead of masking it — the drill ran setup-host itself right after installing, so the stack existed by its own hand and a run passed identically whether or not install.sh had 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 runs setup-host exactly once more — after the clean, which deliberately unsets dns.mode and so has to be converged back. DRILL_OWNS_SETUP=1 hands sequencing back to the drill. Pre-setup tripwires now read before install.sh, since that is what triggers setup now.
  • install.sh asks, 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 and box new died on a host with no Incus). Prompts read /dev/tty, since under curl | bash the script itself is stdin; BOX_YES=1 answers yes unattended (required where there is no terminal), BOX_SKIP_SETUP_HOST=1 declines the host-setup step.
  • install.sh never 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 portable box export so 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

  • codex and grok templates — OpenAI Codex CLI and xAI Grok CLI boxes, creds-free like every template. The template mechanic (image + user + resources, never a network or a security.* key) now has three tenants beside blank, 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's 127.0.0.1 (no flag to widen it), the door is per-port, --list/--remove manage it, and box info shows 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_DISK environment (the scripting form) > template box.env > defaults. Values pass to Incus verbatim; resources are all a flag can touch. --from refuses them — a clone carries its source's resources.
  • Host lifecycle as verbsbox setup-host, box teardown-host, and box 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 expose actually delivers packets — a trilogy of drill-found absences: the NAT proxy needs the box's boxnet lease pinned as a static ipv4.address (Incus resolves connect=0.0.0.0 against device config, not the lease); a loopback-sourced packet needs route_localnet plus a masquerade on the bridge to leave the host and be answerable; and the box's replies need a ct state established,related accept 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 upgradebox-firewall.sh rebuilds 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.
  • grok installs 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 is heavy-duty/box (GitHub redirects the old one, BOX_REPO overrides).
  • 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).