# Changelog History before 0.5.0 lives in git and in [drill/RUNS.md](drill/RUNS.md), which records not just what changed but what each drill run proved. ## Unreleased ### Added - **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 []` wraps `incus export` into one portable backup tarball (default `-.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 [--name ]` 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 ` 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 `/versions/` (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 ` (flips the default, converges the PATH symlinks, and *asserts the effective result* — `current` 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 uninstall** — `box uninstall [] [--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=`** — 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). ### Fixed - **`revoke --purge` re-checks the incus-user state** — the purge removed `/var/lib/incus/users/` 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/` 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 ` and that user gets their own boxes on the same hardened `boxnet`, seeing nobody else's; `box revoke ` 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 a–n: confinement, lifecycle, cross-user visibility, name collisions, the in-box isolation contract, escape hatches, re-sync survival, revoke incl. the live-session case) — 54/54 on the design host (container and VM mode), including the raw-attach scoped-guarantee measurement and both grant-failure injections demanded by #75's review. - **CI runs the multi-user rehearsal on a real Incus** — a second `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 []`** — 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 --memory --disk ` (#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 verbs** — `box setup-host`, `box teardown-host`, and `box migrate-host`, which re-homes pre-0.4.0 boxes onto the current stack (`--box ` / `--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 upgrade** — `box-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).