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>
An imported box kept the artifact's mint stamp verbatim (#103) — correct, the
mint time, box version, image and origin belong to the originating host and
should survive the trip. But nothing recorded the import, so an imported box
was indistinguishable from one minted here at the artifact's mint time.
Not origin=import. 'origin' answers how the instance came into BEING — mint or
clone — and overwriting it would make an exported clone come back claiming to
be an import, with nothing left saying it was ever a clone and an origin.from
naming a lineage no key explains. The import is a third fact, orthogonal to
the first two, so it takes its own keys and leaves every other one alone.
Birth pair plus latest pair, the shape heavy-duty/rig#61 settled on for the
same repeated-event question: imported/imported.by pinned once and never
rewritten, imported.last/.last.by refreshed on every arrival, imported.count
for the trips in between. Last-wins alone would erase the evidence of the
earlier trips, which is the same mistake origin=import makes one level up.
box info prints IMPORTED directly under MINTED, because that adjacency is what
stops the artifact's mint time being misread as this host's. It states only
the ordering and never claims another host: box has no record of which host
minted a box, and a re-import onto the same host is the documented upgrade
flow. user.box.schema does not move — adding a key is not breaking — and is
not written by the import at all, so a legacy artifact still reads as
MINTED (not recorded) rather than acquiring a shape it does not have.
Closes#131.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
cmd_new's fresh-mint branch runs launch → wait_agent → cloud-init
status --wait → the rig bootstrap hook. In the gap between the last two
the guest IS pristine Debian plus box's thin seed — the state
heavy-duty/rig#62 calls "back to pristine Debian". It existed for a few
seconds on every mint box has ever taken and nothing captured it.
Take it there, unconditionally within a fresh mint and never fatally, so
'box restore <box> pristine' is a complete undo for every creds-free
tenant role. A --from clone takes none: it has no pristine moment, so it
inherits its source's snapshots or starts with none, and box will not
label a worked-in state as pristine. On a 'dir' pool (no CoW) the mark
would be a full multi-GB copy per mint, so the mint skips it loudly.
Closes#104
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>
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>
README: the templates section tells the split — thin seeds (user, tmux,
rig), the creds-free tenant role auto-run at mint, the operator-run
workload join, and the RIG_REPO/RIG_REF pin point with the honest unpinned
note (both directions of the rig<->box edge track main until rig#32/#83).
The #80 guard note is cross-referenced as living once, in rig's roles.
box-design.md: a layering section (why rig roles and not cloud-init:
convergent, re-runnable, effective-state-asserted vs a first-boot
one-shot), and the announce section now says who renders the context file.
CHANGELOG: staging template, BOX_BOOTSTRAP_ROLE + auto-run, the pin point
under Added; the tenant-content move under Changed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
'box rm' deletes a box and every snapshot it has; 'box new --from' clones,
but the clone still lives on the same host. Nothing a box held could outlive
a teardown — which made #66's upgrade refusal honest but lossy. This adds
the way out and the way back:
- box export <box> [<file>] [--instance-only]: wraps 'incus export' into one
portable backup tarball (default <box>-<UTC stamp>.tar.gz), snapshots
included by default. Requires the box stopped (require_stopped grew an
honest reason parameter: export is down by OUR decision, not incus's).
Credentials are SHOUTED, not scrubbed — the artifact carries the box's
whole disk, and scrubbing a disk image is a promise tarball surgery
cannot keep.
- box import <file> [--name <box>]: reads the artifact's name from
backup/index.yaml up front, refuses any name an existing instance holds
(the resolve_box boundary from the other side), pre-flights the stack
(require_stack, factored out of cmd_new), imports, then re-stamps the
HOST's truth onto the artifact's: user.box=1 (legacy tag honored), the
box-net placement (profile assign, the migrate-host move), fresh volatile
MACs (imports restore volatile.* verbatim — a re-import beside its
sibling collided at start with 'MAC address already defined on another
NIC', measured live on Incus 6.0.4), and reset_identity, exactly like a
clone.
- restricted tier: box grant now converges restricted.backups allow —
export rides the backup API, which incus-user's restricted projects block
by default exactly like snapshots (incus 6.0 permissions.go,
AllowBackupCreation). Import is plain instance creation and needs no key.
- tests: driven usage errors + fail-closed grep/line-order guards for every
daemon-gated invariant; CI's rehearsal job now runs a live round-trip
(mint, write, snapshot, down, export, rm, import, assert the file, the
snapshot, the tag, the agent, and the collision refusal).
The whole flow was verified against a live Incus 6.0.4 daemon: running-box
refusal, export, overwrite guard, rm, import with and without --name,
re-home onto box-net, sibling re-import with distinct MACs and machine-ids,
pre-export file and snapshot present in both.
Closes#70
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review 4727756972 (A2): the backout no longer trusts gpasswd — it re-reads
the live group database after removal; verified-absent gets the safe
message, anything else screams ROLLBACK INCOMPLETE, exits nonzero, and
names the exact remediation. The concurrent-login window (a session begun
between usermod and backout keeps the group) is CLOSED to the extent the
database can't reach: the backout detects live processes and names
loginctl terminate-user, and the success wording claims only what was
verified.
Review 4727641752 (A1): a failed grant for a user whose membership predates
the run (the hand-added-user scenario) now fails LOUDLY — they retain
socket access on part-converged policy, and the message says so with both
remediations (box revoke now, or fix and re-run). Their membership is not
stripped: breaking a working user over a failed re-grant is its own hazard.
The default-profile eth0 removal is deliberately not restored on failure —
that mutation only reduces capability, and restoring it would move the
failure state AWAY from fail-closed. Injected-failure coverage is criterion
(n), both flavors: fresh-user backout (fault at the LAST mutation, so the
rollback runs after every earlier one) with the group's absence verified
and a converging re-run; blocked narrowing staged for real with an
instance-local NIC parked on the private bridge.
Review A3, resolution 3 with the measurement demanded: criterion (m)
launches exactly 'incus launch --network boxnet' as the restricted user and
probes the raw NIC from inside — egress works, RFC1918 dropped (the ACL is
the network's), sibling probes dropped BOTH directions (the nft drop is the
host's), name enumeration blocked. The scoped guarantee is now stated in
box-design.md and measured on every run: box-minted instances carry per-NIC
port_isolation; raw attachments keep every network- and host-owned control,
losing only that redundant L2 layer. Instrument lesson kept as MU-5: the
probe's first cut minted the non-cloud image — no DHCP client, no lease,
and a dead NIC passes every negative probe vacuously; it now requires the
lease before believing its own answers.
Rehearsal: 54/54 (containers). test/cli.sh: 82 checks.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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>
The plan doc records what was measured and why each decision fell where it
did: the private bridge is worse than unhardened (a live NAT bridge with
IPv6 on), incus-user blocks snapshots, no daemon-level template exists (read
in incus-user's source), widening survives re-sync (same source, then
measured live). box-design gains the access-tiers section — including why
narrowing to boxnet-only is the load-bearing decision and why the nft bridge
drop is the layer a restricted user cannot strip. RUNS.md logs MU-1..3.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Snapshots, ownership-rule, and Isolation sections still named the
pre-0.4.0 stack (claudenet/claude-dev/claude-isolate) while the rest of the
repo — README, host/setup-host.sh, host/box-firewall.sh — uses boxnet/box-net/
box-isolate. Rename the doc references to match ground truth: claudenet→boxnet,
claude-dev→box-net (profile), claude-isolate→box-isolate (ACL). Stale wording
only; the mechanism described is unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Same treatment as the README, applied to the prose that stood in "Claude" for
"the coding agent": box-recipe.md and box-design.md now describe the `.box/`
runbook and creds-free flow around whichever agent the box was minted with, and
name the per-template context file (`~/.claude/CLAUDE.md`, `~/.codex/AGENTS.md`,
`~/.grok/AGENTS.md`) instead of hardcoding Claude's. drill/README.md's
"does not check" note says the drill confirms each template's CLI, not just
Claude Code. `claude` stays as the concrete login example throughout.
Left untouched (out of scope, literal identifiers): the legacy isolation-stack
names (claudenet/claude-dev/claude-isolate, user.claudebox), the claude template
files themselves, and drill/RUNS.md history.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two things:
1. The host scripts are first-class verbs now — 'box setup-host',
'box teardown-host [--purge-incus]', 'box migrate-host --box <n>'.
Nobody should have to run ~/.local/share/box/host/<script>.sh; that
read like an external script and exposed an install path. Each verb
execs the installed script with its flags passed through (same
pattern as 'box doctor'). README, doctor hints, and the uninstall
section point at the verbs now.
2. The repo-runbook convention is '.box/', not '.claudebox/'. Renamed
across docs and the templates' agent briefing; the briefing tells
the agent to read either, and box-recipe.md notes the rename, so
repos still shipping '.claudebox/' keep working through the
transition. (Consuming repos rename their own folder — tracked
separately.)
Also widened the help command column for the longer verb names, and
fixed one sed-casualty where a broad '.claudebox'→'.box' pass had
turned the README's legacy user.claudebox tag into user.box.
The 0.4.0 rename left surface leftovers the user hit through the
CLAUDEBOX_* env vars. Sweep them, drawing a clean line:
box = everything the user touches — env vars (BOX_REPO/REF/HOME/
BIN), installer messages (box-install:), the install tree
(~/.local/share/box, with the installer sweeping the old
~/.local/share/claudebox on upgrade), tool prose, and the
docs (docs/box-{design,recipe}.md).
claudebox = the GitHub repo name (URLs, the claudebox-<ref> tarball
dir, issue refs), the legacy user.claudebox=1 tag, the
old-stack cleanup code (claudenet/claude-dev/claude-isolate/
claudebox-firewall), and the .claudebox/ runbook convention
— a deliberate v1 hold, since renaming it breaks consuming
repos.
Renamed the two doc files and their links; updated drill.sh/doctor.sh
paths and BOX_REPO/BOX_REF; fixed the claude template's in-box briefing
to say 'box'. RUNS.md left as-is (append-only history). No behavior
change beyond the install-dir move, which the installer migrates.
A live probe (drill run 10) found box A's SYN arriving at box B, and B
answering with a RST. Boxes were not isolated from each other at all,
and the README's contract — "a box reaches the public internet and
nothing else" — was false.
The ACL was not wrong; it simply never saw the traffic. Two boxes on one
bridge share an L2 segment, so their frames are SWITCHED between bridge
ports and never traverse the netfilter path where an L3 rule lives. The
drop on 10.0.0.0/8 (which contains claudenet) and the default ingress
drop both looked airtight and neither ever fired. This is why the
original reasoning — "belt and braces" — was plausible and wrong.
The bridge family does see it. Its forward hook fires exactly when a
frame passes from one bridge port to another, which on claudenet means
box→box and nothing else: frames for the gateway are delivered locally,
and so is anything routed out to the internet. Dropping every forwarded
frame on the bridge isolates the boxes and costs them nothing — DHCP and
ARP are unaffected, being broadcast and delivered on INPUT.
Also: dns.mode=none, so a box can no longer ENUMERATE its siblings
through the gateway's dnsmasq. Blocked connections with open
reconnaissance is not isolation.
security.ipv4_filtering is deliberately NOT used: it breaks the box's
networking (dockerd comes up but cannot pull or run a container).
The drill now ASSERTS all of this in phase C against the real stack;
phase D's rehearsal is retired, its findings recorded.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Every incus verb is a candidate feature request, and wrapping them one at a
time grows a worse incus. This lands the rule instead: claudebox owns a command
when it must enforce an invariant incus cannot see — the user.claudebox=1
boundary, the isolation stack, or the creds-free snapshot workflow. Everything
else is incus's job, and now has a door.
- CMDS table: one row per command, carrying synopsis, preconditions, summary,
action and success message. Dispatch AND help are rendered from it, so the
help can no longer describe a command that doesn't exist — the drift that
produced #8 is now impossible, not merely fixed.
- rename, via a table row: it needs the box stopped (incus won't rename a
running instance), so it says so instead of leaking an incus error.
- `claudebox incus <box> -- <args...>`: the escape hatch. Box resolved and
tag-checked, rest passed to incus verbatim, {} substituted, command echoed.
Warns when it can move a box off the isolation stack.
- The boundary is now ENFORCED, not assumed: every box-taking command resolves
through the user.claudebox=1 tag, so claudebox will not stop, rename or delete
an instance it didn't mint.
- The rule, written into docs/claudebox-design.md.
Closes#11
A CLI that mints trust-less, network-isolated Incus VMs with Claude Code
installed. Boxes are strictly creds-free — the operator logs into Claude
interactively inside; authenticated state is reused via snapshots. The tool
knows nothing about projects; a repo ships an optional agent-facing .claudebox/
runbook that Claude reads.
- bin/claudebox: new/shell/exec/snapshot/restore/down/start/rm/status; creds-free
'new' (fresh launch or clone via --from <src>[/<snap>]).
- cloud-init: global ~/.claude/CLAUDE.md self-describing the box + .claudebox/ runbook.
- install.sh: curl-pipe-bash installer.
- host/: Incus isolation stack (claudenet + claude-isolate ACL + claude-dev
profile + firewall).
- docs/: design + .claudebox/ convention.
Initial canonical import (prototyped separately; re-homed onto the heavy-duty fork).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>