box/README.md
dan-claude-bot 050c90966e feat: mark 'bootstrapped' after a rig hook box watched succeed
converged anything. This takes the checkpoint one step later: after the rig
bootstrap hook has run and box has WATCHED IT SUCCEED, the box is converged
and untouched. 'box restore <box> bootstrapped' keeps the tenant role and
undoes only what happened after it, which is the undo an operator reaches
for far more often and which otherwise costs a ~10-minute re-mint.

The policy is #128's, shared rather than copied: snapshot_pristine and the
new snapshot_bootstrapped are thin wrappers over one snapshot_mark, so the
never-fatal contract and the 'dir' skip exist in exactly one place. What
does not generalise is the prose — the two marks name different moments —
so each wrapper owns its own narration.

Deliberately conditional where 'pristine' is unconditional. 'pristine' marks
a MOMENT every fresh mint has; 'bootstrapped' marks an EVENT, and a blank
box has no such event, so it gets no mark rather than a byte-identical
duplicate of 'pristine' at twice the disk cost. A FAILED hook gets none
either: the failure message already sends the operator to 'box shell', which
is a run box does not watch, so box hands over 'box snapshot <box>
bootstrapped' instead of labelling a convergence it never saw.

The label is therefore documented as one-directional on every surface: its
presence means the hook converged untouched, its absence means nothing at
all. Same durability caveats restated everywhere it appears — it dies with
the box on 'box rm', and no filesystem rollback reaches off-box state
(heavy-duty/rig#62).

Closes #130

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 11:57:01 +00:00

689 lines
36 KiB
Markdown

# box
**Headless, trust-less, throwaway dev VMs.** One command mints a fresh,
network-isolated Incus box from a **template**; the coding-agent templates
hand you a CLI agent on Debian 13 — `claude-box` (Claude Code), `codex-box`
(OpenAI Codex), `grok-box` (xAI Grok) — **box mints, [rig](https://github.com/heavy-duty/rig)
converges**: the template is a thin seed, and the agent tooling lands via a
creds-free `rig bootstrap` role auto-run at mint
([#81](https://github.com/heavy-duty/box/issues/81)). The box is the product
— you log in and work; destroying it loses nothing you didn't push.
**Strictly creds-free.** A box ships with everything installed and **no**
credentials — no agent token, no git PAT, nothing. You authenticate
interactively _inside_ the box. The tool never stores or injects a secret. That
means there's nothing shared or committed, so it's safe for multiple operators
out of the box.
**Templates set what's in the box, never what it can reach.** A template is
image + user + resources + cloud-init; the network and every security flag
live in a shared profile no template can touch, so `blank` is a box with
nobody home — not a box with the safety off.
**The tool knows nothing about your projects.** You just `git clone` inside a
box. A repo can ship an optional [`.box/`](docs/box-recipe.md)
runbook that the box's coding agent reads and acts on — there is no `install`
step and no host-run setup. See [docs/box-design.md](docs/box-design.md) for the
design rationale.
> **0.6.0**: multi-user support.
> **0.5.0**: two new templates (`codex`, `grok`), `box expose` — a
> loopback-only door to a box port, for seeing a dev server — and the host
> lifecycle as first-class verbs: `box setup-host`, `box teardown-host`, and
> `box migrate-host`, which re-homes pre-0.4.0 boxes onto the current stack
> and retires the legacy bridge.
>
> **0.4.0's clean cut stands**: the CLI is `box` (no legacy shim), the
> host stack is `boxnet`/`box-isolate`/`box-firewall` on 10.88.0.0/24, and
> the default template is `blank`. Boxes minted by any earlier version keep
> working under every verb — their legacy tag is honored forever.
## Install
```sh
curl -fsSL https://raw.githubusercontent.com/heavy-duty/box/main/install.sh | bash
```
By default that installs the **latest release** — the installer resolves the
release tag off GitHub's `releases/latest` redirect (no API, no token) and
downloads exactly that tree, so two operators running it get the same box.
If the resolution fails it says so and stops — it never silently hands out
`main`. `BOX_REF` picks another channel (a set ref is tried as a tag first,
then as a branch — [#83](https://github.com/heavy-duty/box/issues/83)):
```sh
curl -fsSL .../install.sh | bash # the latest release (default)
curl -fsSL .../install.sh | BOX_REF=0.6.0 bash # pin a release
curl -fsSL .../install.sh | BOX_REF=main bash # the development tip
```
(A dev tree's `VERSION` carries a `-dev` suffix, so it lands beside your
releases under `versions/`, never on top of one.)
It asks first — **"Install box?"** — then downloads the tree into a
**versioned** install (the way plenty of CLIs manage theirs), links `box` onto
your `PATH`, and on a fresh host asks a second question: **"Set up this
machine as a box host now?"** Say yes and it builds the whole isolation stack
for you (it may ask for `sudo`); say no and you can run `box setup-host`
later. (No `git clone` needed.)
The layout, under the install root (`~/.local/share/box`, or `/opt/box` for a
root install):
```
versions/<version>/ one full tree per installed version
current -> versions/<v> the tracked default
$BINDIR/box -> current/bin/box the PATH entry, riding the chain
```
**Re-running is a safe converge.** Installing a version you already have
changes nothing and says so (`BOX_REINSTALL=1` replaces that version's tree);
a stray re-run can never clobber your install or rebuild the stack under your
boxes. Installing a **new** version lands it side by side and flips `current`
only when you have **no boxes** — under existing boxes the flip is refused
(never change versions under a user's boxes,
[#66](https://github.com/heavy-duty/box/issues/66)) and switching stays a
deliberate act: preserve what you care about — `box down <box>`, then
`box export <box>` (one portable file per box, snapshots included —
[#70](https://github.com/heavy-duty/box/issues/70)), then `box rm <box>`
(which deletes the box _and_ its snapshots) — then:
```sh
box versions # what is installed, which is current, which is running
box use <version> # flip the default (same refusal while boxes exist)
```
A pre-0.7.0 flat install is migrated into `versions/` automatically on the
next installer run — the tree is moved, not re-downloaded, and your boxes are
untouched. After switching versions (and `box setup-host`, if the stack was
torn down), `box import <file>` brings each exported box back — snapshots,
logins and all. A version-aware upgrade that migrates boxes instead of asking
you to is [#67](https://github.com/heavy-duty/box/issues/67). For unattended
installs (CI, images), `BOX_YES=1` answers every prompt yes,
`BOX_SKIP_SETUP_HOST=1` declines the host-setup step, and
`BOX_INSTALL_SOURCE=<dir-or-tarball>` installs from a local tree instead of
downloading (how CI proves the installer under review, and how the drill can
install an unpushed branch).
### Global vs per-user install
Where box lands depends on **who runs the installer**, because on a shared host
box's tree is _executed by other users_ — so it cannot hide in one user's home:
- **As root → global.** The tree goes to `/opt/box` (world-readable) and the
`box` symlink to `/usr/local/bin` (already on every login `PATH`). One
install, every operator on the host runs the same `box`. This is the fleet
path: [rig](https://github.com/heavy-duty/rig)'s `box` role
([rig#24](https://github.com/heavy-duty/rig/issues/24)) installs box once at
host bootstrap ([#71](https://github.com/heavy-duty/box/issues/71)).
- **As a normal user → per-user.** The tree goes to `~/.local/share/box` and
the symlink to `~/.local/bin` — the solo path, unchanged. Nobody else needs
to run your box.
`BOX_HOME` / `BOX_BIN` override the destination on either path. A per-user
install under `/root` would be `0700` and unreadable to everyone else — which
is exactly the bug the root branch fixes. When both tiers are installed, PATH
order decides which `box` wins — the installer warns when it sees the other
tier's tree.
## One-time host setup (Ubuntu 24.04 / Debian 13)
The installer already does this. Run it directly to set up a host you
installed with `BOX_SKIP_SETUP_HOST=1`, or to re-apply the stack by hand:
```sh
box setup-host # one run is enough
```
Idempotent. Installs Incus and creates the isolation stack: the `boxnet` NAT
bridge (sibling-name resolution off, resolver pinned to public upstreams —
`BOX_DNS` overrides), the `box-isolate` ACL (drops all RFC1918/CGNAT/
link-local egress), the `box-net` profile (port-isolated NICs — boxes can't
reach each other), and firewall rules blocking instance → host. All rules
re-apply at boot via `box-firewall.service` — no post-reboot ritual. If
the host lacks `dnsmasq-base` (Debian cloud images skip Recommends):
`sudo apt-get install -y dnsmasq-base`.
The stack's subnet is `10.88.0.0/24` when free. setup-host **never builds on
a subnet something else already claims** — most tellingly when this machine's
own default gateway sits inside it, which means it is being run *inside a
box*: a nested `boxnet` on the guest's own uplink subnet captures its gateway
address and blackholes the guest's egress in intermittent,
maddening-to-attribute blackouts
([#80](https://github.com/heavy-duty/box/issues/80)). Instead of refusing, a
bare `box setup-host` decides for itself: an existing `boxnet` bridge is
converged on as-is (the bridge is the pin — it is never re-addressed), and a
claimed default triggers an auto-pick of the first free `/24` from
`10.89.0.0/24` through `10.127.0.0/24`, announced loudly — so drills and
rehearsals *inside a box* work with zero flags. `BOX_SUBNET=<a.b.c.0/24>`
pins the subnet explicitly for scripted hosts (the bridge address, the ACL's
gateway carve-out and the firewall all derive from it); a pin is honored or
refused, never silently overridden. `box doctor` recognizes the poisoned
state (a gateway held as a local address, duplicate uplink routes) on the
machine it runs on and inside every box it probes.
A host still carrying the pre-0.4.0 stack: `box migrate-host --all-boxes`
re-homes each legacy box onto `boxnet` (authed state preserved), and
`box migrate-host --retire-legacy` removes the old bridge and profile once no
legacy box remains.
## Multi-user hosts: the restricted tier
One host, several people, and not everyone should hold the daemon. Incus's
socket is all-or-nothing — `incus-admin` group members own every instance on
the machine — so box layers a second tier on
[incus-user](https://linuxcontainers.org/incus/docs/main/projects/):
| tier | who | what they hold |
| -------------- | -------------------------------- | ----------------------------------------------------------------- |
| **admin** | root, or the `incus-admin` group | everything: all boxes, the stack, `setup-host`, `expose`, `grant` |
| **restricted** | the `incus` group | their **own** boxes only, on the same hardened network |
| none | everyone else | no socket, nothing |
An admin hands the tier out per user, and takes it back:
```sh
box grant dev1 # dev1 can now: box new / list / shell / snapshot / rm — their boxes only
box revoke dev1 # tier removed; their boxes survive (grant again restores).
# a session they already hold keeps the socket until it
# ends — revoke warns and names the loginctl command
box revoke dev1 --purge # ...or end their sessions and delete everything they had
```
`grant` is an idempotent convergence, not a flag flip, because incus-user's
defaults miss box's contract three ways (measured on Debian 13 / Incus 6.0.4,
see [the plan doc](docs/plans/2026-07-18-restricted-tier.md)): it pins each
user to a private _unhardened_ NAT bridge, it blocks snapshots, and it cannot
see the `box-net` profile. Granting rewires all three: the user's project is
restricted to `boxnet` **and only boxnet** — the hardened network is not their
default placement but the only one their certificate can express — snapshots
and backups are allowed (the clone and `box export` workflows), and the
shipped profile is installed into their project. Re-run
`box grant <user>` after upgrading box to refresh the profile, like
`setup-host` for the stack.
What a restricted user gets is the full contract: same ACL, same DNS
isolation, same pinned resolver, same port isolation, same box↔box drop —
and their boxes cannot reach another user's box, which is the same
box↔box drop doing its one job. What they can't do stays honest: `box
expose` (daemon-global state) says to ask an admin, `box setup-host` and
`box doctor` answer at their tier instead of failing at it.
`drill/multiuser.sh` rehearses all of it live — two users, real grants, real
boxes, probes from inside — and CI runs it on every PR (container mode; the
VM boundary itself is proven on real hardware, like the rest of the drill).
## Quick start
```sh
box new --name work --template claude-box # a creds-free coding-agent box (~10 min cold)
box shell work # enter as the template's user
```
Pick whichever coding-agent template you like — `claude-box`, `codex-box`,
`grok-box` — or `blank` for none. Inside the box, authenticate as needed. The
`claude-box` template looks like this; the others follow the same shape with
their own login step:
```sh
claude # then run /login — copy the URL (press c), open it
# in YOUR browser, paste the code back. No host CLI needed.
gh auth login # or drop a PAT in — your git credentials, your call
git clone https://github.com/you/project && cd project
claude # if the repo has .box/, the agent reads it and sets up
```
## Templates
No coding agent is special — each is one template among several, and adding
another is just another directory. What ships today:
| Template | What it becomes |
| ------------- | ------------------------------------------------------------------ |
| `blank` | Bare Debian 13 — same isolation, no tooling. The default. |
| `claude-box` | Claude Code, creds-free — where this project started |
| `codex-box` | OpenAI Codex CLI, creds-free |
| `grok-box` | xAI Grok CLI, creds-free |
| `staging-box` | Server-class: docker + sshd hardening via rig; VM-only, autostarts |
**Templates are thin seeds; rig does the becoming**
([#81](https://github.com/heavy-duty/box/issues/81)). A template is a
directory under `templates/`: a `box.env` (image, user, resources, boot
demands, tenant role — parsed against a strict allowlist, never sourced) and
a `user-data.yaml` (cloud-init, passed to Incus verbatim except the two rig
pin tokens below). The seed is deliberately small — the tenant user, tmux,
and [rig](https://github.com/heavy-duty/rig) preinstalled, nothing that
joins a tailnet or admits credentials — and after cloud-init settles, box
auto-runs the template's **creds-free** tenant role inside the guest
(`rig bootstrap claude-box` / `codex-box` / `grok-box` / `staging-box`,
[rig#31](https://github.com/heavy-duty/rig/issues/31); the roles carry a
family suffix — `-box` for box tenants, `-server` for fleet machines — and a
template is named for the role it converges,
[rig#76](https://github.com/heavy-duty/rig/issues/76)). The agent CLI,
docker, the server posture and the agent-context file all come from that
role — convergent and idempotent, so the same command re-run later converges
an *existing* box to a newer spec (`box shell <box>` →
`sudo rig bootstrap <role>`). The agent-context file carries the
[#80](https://github.com/heavy-duty/box/issues/80) guard — never run
`box setup-host`, `box teardown-host` or the drill *inside* a box — once,
from rig's roles, instead of copy-pasted per template.
**Anything that joins or admits stays operator-run.** The `staging-box`
tenant's tailnet workload join holds a pre-auth key, so box only prints it as the
next step — `box shell <name>`, then `sudo rig bootstrap workload-server` — and
never sees the key ([#69](https://github.com/heavy-duty/box/issues/69)'s
split, kept).
**The rig pin point** (`RIG_REPO` / `RIG_REF`). The seeds preinstall rig,
which inverts the rig→box install edge
([rig#28](https://github.com/heavy-duty/rig/issues/28): rig installs box on
host-class machines; box guests now install rig). The seed's install line
carries `@RIG_REPO@`/`@RIG_REF@` tokens that box resolves at mint from the
environment:
```sh
box new --name work --template claude-box # heavy-duty/rig @ main
RIG_REPO=you/rig RIG_REF=my-branch \
box new --name trial --template claude-box # a rig branch under review
```
Both directions of that edge track `main` unpinned today — said honestly,
the same way rig documents box's unpinned install
([rig#29](https://github.com/heavy-duty/rig/issues/29)) — until the release
flow lands ([rig#32](https://github.com/heavy-duty/rig/issues/32),
[#83](https://github.com/heavy-duty/box/issues/83)). The pin covers both the
installer fetched and the tree it installs, and the values are
allowlist-validated on the host before they touch the YAML.
```sh
box templates # list what this install can mint
box new --name scratch # the DEFAULT template is blank: bare Debian,
# same isolation, nobody home — no rig, no role
```
A template **cannot** name a network, a profile, or a `security.*` flag —
there is no key for them. Every box launches with the shared `box-net`
profile (the isolated NIC + root disk), so every template gets the identical
trust boundary. Resources come from the template's `box.env`, overridable at
mint time — inline (`--cpu 2 --memory 3GiB --disk 20GiB`) or via
`BOX_CPU` / `BOX_MEMORY` / `BOX_DISK` environment variables (the scripting
form; flags win). The template's identity (name, user) is stamped onto the instance,
so `shell`, `exec` and `tmux` land in the right user — and a clone still
knows, because `incus copy` carries the metadata.
## Log in once, reuse via snapshots
Because every fresh box is creds-free, re-authenticating each time would be
toil. Snapshot an authenticated box and clone from it instead:
```sh
box snapshot work authed # checkpoint after you've logged in
box new --name feature --from work/authed # clone the authed state into a new box
```
`--from` copies the whole box (agent login, git creds, clones and all) while
preserving isolation. You can also `box new --name x --from work` to clone
a box's live state, or roll a box back with `box restore work authed` — which
asks first, since a rollback discards everything since the snapshot (`--force`
skips the prompt, and scripts must pass it: with no terminal to ask on, box
refuses rather than assuming yes).
Forgotten what you called a checkpoint? `box info work` prints the box's
snapshot labels and the `--from` line to clone one.
### `pristine` — the one checkpoint box takes for you
Every fresh mint marks a snapshot called `pristine`
([#104](https://github.com/heavy-duty/box/issues/104)) at the one moment it
is true: **after cloud-init, before `rig bootstrap` converges the tenant
role.** At that instant the guest is pristine Debian plus box's thin seed
(the user, tmux, rig) and nothing else — the state
[heavy-duty/rig#62](https://github.com/heavy-duty/rig/issues/62) calls "back
to pristine Debian". It exists for a few seconds on every mint, so box
captures it rather than asking you to be quick.
```sh
box restore work pristine # undo the tenant role and everything since
```
That is a complete undo for every tenant role: everything `rig bootstrap
claude|codex|grok|staging` does — docker, node, the agent CLI, the
agent-context file, the role marker — is box-local and file-shaped, so a
filesystem rollback reaches all of it, without paying a ~10-minute re-mint.
Three things it deliberately does not do:
- **It is an undo, not a backup.** Snapshots die with their box: `box rm`
deletes a box _and_ every snapshot it has. `box export` is the only state
that outlives the box — see below.
- **It cannot reach off-box state.** A tailnet join, a GitHub runner
registration, a pushed commit: those are records held somewhere else, and
no filesystem rollback undoes them (rig#62 covers those separately).
- **A `--from` clone gets no `pristine` of its own.** A clone skips
cloud-init and rig entirely, so it has no pristine moment to capture, and
box will not label a source's worked-in state as one. Cloning a _box_
inherits the source's snapshots (a real `pristine` among them, if the
source had one); cloning a _snapshot_ starts with none. `box new` says
which of the two you got.
On a host whose storage pool uses the `dir` driver, a snapshot is a full
multi-GB copy rather than a near-free copy-on-write mark, so the mint
**skips** `pristine` and says so loudly — take it by hand with `box snapshot
<box> pristine` if you want it anyway. btrfs is what `box setup-host`
installs by default precisely so snapshots are cheap. `BOX_SNAPSHOT_PRISTINE=0`
skips the mark on any host.
### `bootstrapped` — the same undo, one step later
A mint whose template names a bootstrap role marks a second snapshot,
`bootstrapped` ([#130](https://github.com/heavy-duty/box/issues/130)), once
`rig bootstrap` has run and box has **watched it succeed**. That is the box
converged and not yet touched — the state you actually wanted back most of
the time.
```sh
box restore work bootstrapped # keep the tenant role, undo what you did to it
box restore work pristine # throw the tenant role away too
```
Same rules as `pristine`: default on, never fatal, skipped loudly on a `dir`
pool (with two marks that disk cost is twice the size, so a CoW-less host is
not asked to pay it), and `BOX_SNAPSHOT_BOOTSTRAPPED=0` skips it anywhere.
The same two caveats apply, unchanged: it **dies with the box** on `box rm`
(`box export` is the durable path), and it **cannot reach off-box state**
a tailnet device record, a runner registration (rig#62).
**Read the label in one direction only.** Its _presence_ means the mint-time
hook converged and nothing has touched the box since. Its _absence_ means
nothing at all, because box only marks a hook it watched:
- A **blank** box runs no hook, so there is no convergence to mark. It gets
none — `pristine` and `bootstrapped` would be the same disk state at twice
the cost, and a label claiming a convergence that never happened is worse
than no label.
- A box whose hook **failed** gets none either. box tells you to re-run the
role by hand through `box shell`, and a by-hand run happens in a shell box
does not watch — so box hands you `box snapshot <box> bootstrapped` to take
at the moment it is true, rather than inventing a fact.
- A `dir`-pool host and `BOX_SNAPSHOT_BOOTSTRAPPED=0` both skip it.
`box info <box>` is what actually tells you which labels a box has.
## Survive the host: `box export` / `box import`
Snapshots live _inside_ a box, and `box rm` deletes the box **and** its
snapshots. `box new --from` clones — but the clone still lives on the same
host, under the same stack. `box export` is the way out
([#70](https://github.com/heavy-duty/box/issues/70)): one portable file that
outlives the box, the host stack, and the machine.
```sh
box down work # export wants a settled disk
box export work # → work-<UTC stamp>.tar.gz, snapshots included
box rm work # nothing is lost anymore
# ...upgrade box / rebuild the host / carry the file to another machine...
box import work-<stamp>.tar.gz # the box is back — snapshots, logins and all
box import work-<stamp>.tar.gz --name work2 # or under a new name
```
This is what makes the upgrade flow humane
([#66](https://github.com/heavy-duty/box/issues/66)): stop, export, remove
every box, upgrade, re-import. Everything `incus import` restores is the
artifact's truth (disk, config, snapshots); what box re-stamps on import is
_this_ host's truth — the `user.box=1` boundary tag, the `box-net` placement
(re-assigned if the artifact's differs), and a fresh machine identity, the
same move a clone gets, so an imported box can never collide with the box it
was exported from. Import refuses a name any existing instance already holds.
`--instance-only` exports the live state without the snapshots.
**The file is a credential.** A box's disk carries everything inside it —
agent logins, git PATs, SSH keys, shell history. Export scrubs nothing (a
"scrubbed" disk image would be a lie) and shouts instead, every time. Store
and move the file like the secret it is.
## See a dev server: `box expose`
The isolation contract says no inbound path exists — which is one "no" too
many when you're coding in a box and want its dev server in your browser.
`box expose` is the deliberate exception:
```sh
box expose work 3000 # http://127.0.0.1:3000 → work:3000
box expose work 3000 8080 # or pick the host port: 127.0.0.1:8080 → work:3000
box expose work --list # what doors are open
box expose work --remove 3000 # close one
```
The listen side is **always the host's own loopback** — never the network, no
flag to widen it — so no other machine gains a path to the box. The in-box
server must listen on `0.0.0.0`, not its own loopback (safe inside the
isolation stack: only this door can reach it). A box with a hole says so:
`box info` lists open exposures. Everything else on the box stays dropped —
the door is per-port, punched and removable at runtime.
## Commands
```
box new --name <box> [--template <t>] [--from <src>[/<snap>]] [--cpu <n>] [--memory <size>] [--disk <size>] [--vm|--container]
box templates # list the templates this install can mint
box list # list your boxes
box info <box> # one box: state, IP, exposures, provenance, snapshots
box shell <box> # enter as the template's user
box exec <box> -- <cmd...> # run a command in the box
box tmux <box> [session] # attach/create a tmux session — survives disconnects
box snapshot <box> [label] # checkpoint (label defaults to manual-<epoch>)
box restore <box> <snap> [--force]
# roll back to a snapshot — destructive, asks first
# 'pristine' is auto-marked at mint: back to
# pristine Debian + box's seed, before rig ran
# 'bootstrapped' too, if a rig hook converged:
# the role kept, everything since undone
box export <box> [<file>] [--instance-only]
# one portable file (snapshots incl.) — survives rm & host
box import <file> [--name <box>]
# mint a box back from an exported file, re-stamped
box rename <box> <new> # rename a box (stop it first)
box down <box> # stop (state kept; `start` resumes)
box start <box> # start a stopped box
box rm <box> [--force] # delete the box + its snapshots (asks first)
box expose <box> <port> [<host-port>] | --list | --remove <port>
# forward a box port to host loopback — see a dev server
box incus <box> -- <args...> # escape hatch: any incus command, box resolved
box doctor [--fix|--pin-dns] # is this host fit to mint boxes? diagnose from ground truth
box setup-host # one-time host setup: Incus, the boxnet stack, the firewall
box teardown-host [--purge-incus] # remove the host stack (both name generations)
box migrate-host --box <n> | --all-boxes | --retire-legacy
# move a pre-0.4.0 host onto the box stack
box status # deprecated alias for `list`
box help [<command>] # full help, or one command's page
```
Every command takes `--help`, and options come after the command
(`box list --json`). Exit status: `0` ok, `1` it went wrong, `2` you asked
wrong.
`new` fresh-launches from a template (default: `blank`), or with `--from`
clones an existing box or snapshot. VM mode (`--vm`, the default where
`/dev/kvm` exists) is the trust-less target; container mode (auto-fallback,
`security.nesting=true`) is for hosts without nested virt — weaker isolation,
dev/test only.
## What minted this box: `box info`
A box outlives the release that minted it, the template that shaped it and the
image build it came from — and until
[#103](https://github.com/heavy-duty/box/issues/103) it recorded none of them.
There is no host-side per-box store; the Incus instance config _is_ the
database, so a fact not written at mint time is simply gone. `box new` now
stamps what it knew, and `box info` reads it back:
```
NAME work
STATE RUNNING
TYPE VM
IPV4 10.x.x.x
MINTED 2026-07-19T14:22:07Z by box 0.8.1
TEMPLATE claude (user claude, role claude)
IMAGE images:debian/13/cloud @ 8a2f1c9d4e5b…
MODE vm (asked: auto)
RIG heavy-duty/rig@main
ORIGIN mint
```
The image line carries both halves on purpose: the template names an
_unpinned alias on a moving remote_, so what it resolved to at that mint is the
only reproducible fact. `box info --json` carries every key verbatim — they
ride `incus list --format json` in `config`.
**A clone re-stamps.** `incus copy` preserves `user.*` keys, so a clone inherits
its source's template and user for free — but inheriting the mint stamp would
not make it stale, it would make it **false**: the clone was not present at that
mint. `box new --from` therefore re-stamps the four keys that describe _this_
instance's coming into being (`ORIGIN clone of work/authed`, a fresh time, the
box version that cloned it) and leaves the lineage keys alone, because the
clone's disk genuinely did come from that image, template and role. `origin.from`
records one hop: a clone of a clone names its parent, not its grandparent.
**Boxes minted before this stamp existed keep working**, under this verb and
every other — they render as a box with blanks and say `MINTED (not recorded)`
rather than erroring. `user.box.schema` names the stamp's _shape_ (an integer,
not the box version) so a box minted by a later release reads back on an older
box as "here is what I understand, and there is more I don't".
## Boxes are just Incus instances
A box is an ordinary Incus instance tagged `user.box=1` (pre-0.4.0 boxes
carry `user.claudebox=1`, honored forever). box wraps the box lifecycle and
the isolation model — not all of Incus. It owns a command
when it must enforce something Incus can't see: that tag (it will not stop,
rename or delete an instance it didn't mint), the isolation stack, or the
creds-free snapshot workflow. For everything else, there's the door:
```sh
box incus work -- config show # instance name appended
box incus work -- file push x.tar {}/tmp/ # or placed with {}
```
The box is resolved and tag-checked; the rest is passed to `incus` verbatim, and
the command is echoed before it runs. If it can move the box off the isolation
stack (profile, network, device, `security.*`), box warns and proceeds —
the trust boundary is then yours to keep. See
[docs/box-design.md](docs/box-design.md) for the rule and why the
command surface is a table.
## Isolation
The contract: **a box reaches the public internet and nothing else.** Not the
host, not your LAN, not another box, not even another box's _name_. What
enforces it, layer by layer:
- **Dedicated NAT bridge** `boxnet`, IPv6 off. Every rule below is
IPv4-only, so IPv6 would be an uncovered path — off is part of the
contract, not a default.
- **`box-isolate` ACL** — drops all egress to private space (RFC1918,
CGNAT, link-local), with a single carve-out to the gateway so DNS works.
- **Sibling isolation, at L2** — two boxes on one bridge are _switched_,
never routed, so no L3 rule can separate them (learned the hard way; see
below). `security.port_isolation` on every box NIC plus an nft
bridge-family drop mean box A cannot exchange frames with box B at all.
- **No name-level reconnaissance** — `dns.mode=none` stops the gateway
resolving sibling names, and the bridge's resolver is pinned to public
upstreams (`no-resolv`), so tailnet names and split-DNS zones from a
host-level VPN don't resolve inside a box either.
- **Host firewall** — instance → host is dropped except DNS/DHCP, including
the host's public IPs. Entry is `incus exec` over the local socket only —
**no inbound path exists** — unless you punch one with `box expose`, and
that door only ever opens onto the host's own loopback (`127.0.0.1`), never
the network.
The VM is the trust boundary: whatever runs inside — the coding agent, or
anything a template ships — can run arbitrary code and touch nothing you care
about.
### Measured, not claimed
Every clause above is probed live by an end-to-end drill, because the one time
this contract was reasoned about instead of measured, the reasoning was wrong:
box→box traffic was "covered" by an L3 drop that L2-switched frames never
meet — a hole found by probing, not by reading the rules. On a bare host the
drill installs the whole stack, mints every template cold, snapshots and
clones, probes every boundary from inside the boxes, opens and shuts the
`expose` door (and checks the contract survives it), re-homes a faithful
pre-0.4.0 box through `migrate-host`, and removes what it minted —
currently **84 checks, 84 passing**. [drill/RUNS.md](drill/RUNS.md) is the full
history, including every trap that fooled a run into a wrong verdict.
### Run the drill yourself
The drill ships in the repo, not the installed tree — run it from a checkout.
Two versions are in play and both must be current: **the drill script you
run** (a stale checkout judges the past), and **the code under test** — the
drill does not test your working tree; it installs box from GitHub
(default: `heavy-duty/box@main`) and asserts the installed tree is exactly
the ref it asked for before issuing any verdict.
```sh
git clone https://github.com/heavy-duty/box && cd box # or refresh an existing
git log --oneline -1 # checkout — this commit is
# the drill that will judge
bash drill/doctor.sh # read-only: is this host healthy and the stack live?
bash drill/drill.sh # FULL end-to-end — mutates the host; use a machine you own
bash drill/wipe.sh # scorched earth: strip BOTH name generations, images and
# (--purge-storage) the pool, so a run starts from bare
```
To drill something other than latest `main` — a release ref, or a PR branch
on a fork:
```sh
bash drill/drill.sh --ref <branch-or-tag>
bash drill/drill.sh --repo <owner>/<repo> --ref <branch> # a PR under review
```
The doctor reads ground truth, not config claims — the kernel's `isolated on`
flag per bridge port, the process table, the resolver actually in use — and
diagnoses the host faults that have actually happened: a wedged Incus daemon,
a dnsmasq that silently isn't serving, a VPN resolver that boxes would
inherit.
## Recipes: the `.box/` convention
A repo that wants to be easy to stand up in a box ships an optional `.box/`
folder — a runbook the box's coding agent reads and follows (install deps,
start services, template env, seed data, smoke-test). It is agent-facing
documentation, not a host-executed script. See
[docs/box-recipe.md](docs/box-recipe.md).
## Uninstall
`box uninstall` is the real uninstall, and it runs in the safe order — boxes
first, then the stack, then the tree — and **ends with an absence assert**:
every path it removed is re-checked, and any survivor makes it exit 1 naming
the leftovers instead of reporting a clean uninstall that wasn't (the same
discipline as `box revoke --purge`).
```sh
box uninstall <version> # one non-current version (side-by-side cleanup)
box uninstall --all --purge-host # everything: teardown-host (all boxes, the
# boxnet stack, the firewall), then every
# version, the symlinks, legacy claudebox crumbs
box uninstall # just the install — refuses while boxes exist
# (and names them); run teardown-host first,
# or use --purge-host
```
The full-removal order on a multi-user host: `box revoke <user> --purge` each
granted user (it asserts its own zero-residue, including the incus-user state
under `/var/lib/incus/users/`), then `box teardown-host` (add `--purge-incus`
to drop Incus itself, `--yes`/`BOX_YES=1` for automation), then
`box uninstall`. CI drills exactly this sequence and asserts zero residue —
no networks, profiles, nft tables, systemd units, files or symlinks.
## Non-goals
- **No unattended/CI bring-up.** The flow is interactive (log in, clone, ask
the agent). Reproducible-by-construction provisioning is out of scope.
- **No credential storage or injection by the tool.** Boxes are creds-free;
snapshots are the reuse mechanism, not a secrets store.