chore: finish the debrand — env vars, install dir, docs are 'box', not 'claudebox'
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.
2026-07-14 17:44:24 +00:00
|
|
|
|
# box design
|
2026-07-10 15:00:36 +00:00
|
|
|
|
|
chore: finish the debrand — env vars, install dir, docs are 'box', not 'claudebox'
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.
2026-07-14 17:44:24 +00:00
|
|
|
|
`box` is a CLI that mints and manages **trust-less, network-isolated VMs
|
2026-07-22 15:10:35 +00:00
|
|
|
|
with a coding agent installed** (`claude-box`, `codex-box`, `grok-box`,
|
|
|
|
|
|
`kimi-box`, or `blank` for none). It is infrastructure, not a project provisioner.
|
2026-07-10 15:00:36 +00:00
|
|
|
|
|
|
|
|
|
|
See issue #3 for the full reframe and rationale. This doc captures the durable
|
|
|
|
|
|
design decisions.
|
|
|
|
|
|
|
|
|
|
|
|
## Principle: separate the tool from the agent
|
|
|
|
|
|
|
docs: extend the agent-agnostic reframe to design, recipe, and drill docs
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>
2026-07-15 00:21:22 +00:00
|
|
|
|
- **The tool** mints isolated boxes with the agent installed but **unauthenticated**.
|
2026-07-10 15:00:36 +00:00
|
|
|
|
It knows nothing about projects, secrets, recipes, or memory.
|
docs: extend the agent-agnostic reframe to design, recipe, and drill docs
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>
2026-07-15 00:21:22 +00:00
|
|
|
|
- **The agent** (Claude Code, Codex, Grok — whichever template, inside the box)
|
|
|
|
|
|
reads an optional `.box/` runbook in a cloned repo and acts on it. The recipe's
|
|
|
|
|
|
consumer is the reasoning agent, not host machinery.
|
2026-07-10 15:00:36 +00:00
|
|
|
|
|
|
|
|
|
|
## Boxes are strictly creds-free
|
|
|
|
|
|
|
chore: finish the debrand — env vars, install dir, docs are 'box', not 'claudebox'
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.
2026-07-14 17:44:24 +00:00
|
|
|
|
`box new --name <n>` launches a blank box: everything installed, **no**
|
docs: extend the agent-agnostic reframe to design, recipe, and drill docs
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>
2026-07-15 00:21:22 +00:00
|
|
|
|
git credentials and **no** agent credentials. The operator authenticates
|
2026-07-10 15:00:36 +00:00
|
|
|
|
interactively *inside* the box:
|
|
|
|
|
|
|
docs: extend the agent-agnostic reframe to design, recipe, and drill docs
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>
2026-07-15 00:21:22 +00:00
|
|
|
|
- **The coding agent** — e.g. `claude` → `/login` (paste-a-code OAuth: copy the
|
|
|
|
|
|
URL, open it in your own browser, paste the code back); `codex` and `grok`
|
|
|
|
|
|
have their own login step. Works because the box is outbound-only; the tool
|
|
|
|
|
|
never handles a token.
|
2026-07-10 15:00:36 +00:00
|
|
|
|
- **Git** — the operator adds their own PAT / `gh auth login` inside the box.
|
|
|
|
|
|
|
|
|
|
|
|
The tool stores and injects **no** credentials, ever. This dissolves the
|
|
|
|
|
|
multi-user problem: nothing shared, nothing committed.
|
|
|
|
|
|
|
|
|
|
|
|
## Snapshots are the reuse mechanism
|
|
|
|
|
|
|
|
|
|
|
|
Re-authing every fresh box would be toil, so authenticated state is reused via
|
|
|
|
|
|
snapshots, not a secrets store:
|
|
|
|
|
|
|
chore: finish the debrand — env vars, install dir, docs are 'box', not 'claudebox'
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.
2026-07-14 17:44:24 +00:00
|
|
|
|
- `box snapshot <n> [label]` — checkpoint after login + clone.
|
|
|
|
|
|
- `box new --name <n2> --from <src>[/<snapshot>]` — clone an existing box
|
2026-07-10 15:00:36 +00:00
|
|
|
|
or snapshot (authed state and all). Isolation is preserved: the clone keeps
|
2026-07-15 00:23:58 +00:00
|
|
|
|
the `box-net` profile + `boxnet` + ACL.
|
chore: finish the debrand — env vars, install dir, docs are 'box', not 'claudebox'
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.
2026-07-14 17:44:24 +00:00
|
|
|
|
- `box restore <n> <snapshot>` — roll a box back to a checkpoint.
|
2026-07-10 15:00:36 +00:00
|
|
|
|
|
|
|
|
|
|
Log in once → snapshot → spin up authed boxes from it.
|
|
|
|
|
|
|
2026-07-20 10:04:49 +00:00
|
|
|
|
One checkpoint box takes itself: **`pristine`**, marked on every fresh mint
|
|
|
|
|
|
after cloud-init and before the `rig bootstrap` hook (#104, child of
|
|
|
|
|
|
heavy-duty/rig#62). That instant — pristine Debian plus box's thin seed, rig
|
|
|
|
|
|
installed but not yet run — is the state "back to pristine Debian" names, it
|
|
|
|
|
|
lasts a few seconds, and nobody is standing there to snapshot it. `box
|
|
|
|
|
|
restore <n> pristine` undoes a tenant role wholesale, because everything the
|
|
|
|
|
|
creds-free roles do is box-local and file-shaped. A `--from` clone takes no
|
|
|
|
|
|
`pristine`: it never has the moment, so it inherits its source's snapshots or
|
|
|
|
|
|
has none, and box refuses to label a worked-in state as pristine. On a `dir`
|
|
|
|
|
|
pool the mark would be a full copy rather than a CoW one, so the mint skips
|
|
|
|
|
|
it loudly.
|
|
|
|
|
|
|
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-20 11:09:23 +00:00
|
|
|
|
A second, **`bootstrapped`**, is marked one step later (#130) — after the rig
|
|
|
|
|
|
hook has run and box has *watched it succeed*. Same policy, literally the same
|
|
|
|
|
|
function: default on, never fatal, skipped on `dir`, opt-out via
|
|
|
|
|
|
`BOX_SNAPSHOT_BOOTSTRAPPED=0`. Where the two differ is deliberate.
|
|
|
|
|
|
`pristine` marks a **moment** every fresh mint has, so it is unconditional;
|
|
|
|
|
|
`bootstrapped` marks an **event**, so it is not. A blank box has no hook and
|
|
|
|
|
|
therefore no event — marking it anyway would duplicate `pristine` byte for
|
|
|
|
|
|
byte at twice the disk cost, or assert a convergence that never happened. A
|
|
|
|
|
|
failed hook gets no mark either: box tells the operator to re-run the role
|
|
|
|
|
|
through `box shell`, which is a run box does not watch, so it hands over `box
|
|
|
|
|
|
snapshot <n> bootstrapped` instead of guessing. The consequence is a label
|
|
|
|
|
|
that is only ever read one way — **presence** means "converged and untouched";
|
|
|
|
|
|
**absence** means nothing, and nothing in box may treat a missing
|
|
|
|
|
|
`bootstrapped` as evidence a box is unconverged.
|
|
|
|
|
|
|
feat: box export / import — state that survives the box and the host (#70)
'box rm' deletes a box and every snapshot it has; 'box new --from' clones,
but the clone still lives on the same host. Nothing a box held could outlive
a teardown — which made #66's upgrade refusal honest but lossy. This adds
the way out and the way back:
- box export <box> [<file>] [--instance-only]: wraps 'incus export' into one
portable backup tarball (default <box>-<UTC stamp>.tar.gz), snapshots
included by default. Requires the box stopped (require_stopped grew an
honest reason parameter: export is down by OUR decision, not incus's).
Credentials are SHOUTED, not scrubbed — the artifact carries the box's
whole disk, and scrubbing a disk image is a promise tarball surgery
cannot keep.
- box import <file> [--name <box>]: reads the artifact's name from
backup/index.yaml up front, refuses any name an existing instance holds
(the resolve_box boundary from the other side), pre-flights the stack
(require_stack, factored out of cmd_new), imports, then re-stamps the
HOST's truth onto the artifact's: user.box=1 (legacy tag honored), the
box-net placement (profile assign, the migrate-host move), fresh volatile
MACs (imports restore volatile.* verbatim — a re-import beside its
sibling collided at start with 'MAC address already defined on another
NIC', measured live on Incus 6.0.4), and reset_identity, exactly like a
clone.
- restricted tier: box grant now converges restricted.backups allow —
export rides the backup API, which incus-user's restricted projects block
by default exactly like snapshots (incus 6.0 permissions.go,
AllowBackupCreation). Import is plain instance creation and needs no key.
- tests: driven usage errors + fail-closed grep/line-order guards for every
daemon-gated invariant; CI's rehearsal job now runs a live round-trip
(mint, write, snapshot, down, export, rm, import, assert the file, the
snapshot, the tag, the agent, and the collision refusal).
The whole flow was verified against a live Incus 6.0.4 daemon: running-box
refusal, export, overwrite guard, rm, import with and without --name,
re-home onto box-net, sibling re-import with distinct MACs and machine-ids,
pre-export file and snapshot present in both.
Closes #70
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 15:10:12 +00:00
|
|
|
|
Snapshots are in-box state: `box rm` deletes a box *and* its snapshots, and a
|
|
|
|
|
|
clone still lives on the same host. The off-host mechanism is `box export` /
|
|
|
|
|
|
`box import` (#70) — one portable backup tarball, snapshots included by
|
|
|
|
|
|
default, that survives `rm`, a host teardown, an upgrade, a move. The split
|
|
|
|
|
|
of truths is the design: everything `incus import` restores is the artifact's
|
|
|
|
|
|
(disk, config, snapshots); everything box re-stamps on import is the current
|
|
|
|
|
|
host's (the `user.box=1` boundary tag, the `box-net` placement, a fresh
|
feat: box import records the trip, without rewriting who the box was
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>
2026-07-20 11:14:45 +00:00
|
|
|
|
machine identity via the same `reset_identity` a clone gets, and the record
|
|
|
|
|
|
that the trip happened). That last one is #131, and it is deliberately *not*
|
|
|
|
|
|
`origin=import`: `origin` says how the instance came into **being** — mint or
|
|
|
|
|
|
clone — and the import is a third, orthogonal fact. Overwriting `origin` would
|
|
|
|
|
|
make an exported clone come back claiming to be an import, with its
|
|
|
|
|
|
`origin.from` lineage left unreadable, so the import gets its own keys and the
|
|
|
|
|
|
artifact's mint stamp survives the trip untouched. Auth state
|
feat: box export / import — state that survives the box and the host (#70)
'box rm' deletes a box and every snapshot it has; 'box new --from' clones,
but the clone still lives on the same host. Nothing a box held could outlive
a teardown — which made #66's upgrade refusal honest but lossy. This adds
the way out and the way back:
- box export <box> [<file>] [--instance-only]: wraps 'incus export' into one
portable backup tarball (default <box>-<UTC stamp>.tar.gz), snapshots
included by default. Requires the box stopped (require_stopped grew an
honest reason parameter: export is down by OUR decision, not incus's).
Credentials are SHOUTED, not scrubbed — the artifact carries the box's
whole disk, and scrubbing a disk image is a promise tarball surgery
cannot keep.
- box import <file> [--name <box>]: reads the artifact's name from
backup/index.yaml up front, refuses any name an existing instance holds
(the resolve_box boundary from the other side), pre-flights the stack
(require_stack, factored out of cmd_new), imports, then re-stamps the
HOST's truth onto the artifact's: user.box=1 (legacy tag honored), the
box-net placement (profile assign, the migrate-host move), fresh volatile
MACs (imports restore volatile.* verbatim — a re-import beside its
sibling collided at start with 'MAC address already defined on another
NIC', measured live on Incus 6.0.4), and reset_identity, exactly like a
clone.
- restricted tier: box grant now converges restricted.backups allow —
export rides the backup API, which incus-user's restricted projects block
by default exactly like snapshots (incus 6.0 permissions.go,
AllowBackupCreation). Import is plain instance creation and needs no key.
- tests: driven usage errors + fail-closed grep/line-order guards for every
daemon-gated invariant; CI's rehearsal job now runs a live round-trip
(mint, write, snapshot, down, export, rm, import, assert the file, the
snapshot, the tag, the agent, and the collision refusal).
The whole flow was verified against a live Incus 6.0.4 daemon: running-box
refusal, export, overwrite guard, rm, import with and without --name,
re-home onto box-net, sibling re-import with distinct MACs and machine-ids,
pre-export file and snapshot present in both.
Closes #70
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 15:10:12 +00:00
|
|
|
|
rides along deliberately — and because scrubbing a disk image is a promise
|
|
|
|
|
|
tarball surgery cannot keep, export shouts that the file is a credential
|
|
|
|
|
|
instead of pretending to sanitize it.
|
|
|
|
|
|
|
docs: the thin-template story — box mints, rig converges (#81)
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>
2026-07-18 20:03:55 +00:00
|
|
|
|
## Thin templates: box mints, rig converges (#81)
|
|
|
|
|
|
|
|
|
|
|
|
A template is a **thin, creds-free seed** — base image, the tenant user,
|
|
|
|
|
|
tmux, and [rig](https://github.com/heavy-duty/rig) preinstalled — and what
|
|
|
|
|
|
the box *becomes* lives in rig's bootstrap roles (rig#31): box auto-runs the
|
refactor(templates): the tenant seeds carry rig's -box family suffix
rig is growing a second family of roles, and once a 'staging' role can mean
either a fleet machine or a box tenant, the bare name stops naming anything.
rig's answer is a suffix on the role (heavy-duty/rig#76): '-server' for fleet
machines, '-box' for box tenants. box's answer is that a template keeps being
named for the role it converges, so the tenant templates move with it:
claude -> claude-box codex -> codex-box
grok -> grok-box staging -> staging-box
Templates are the only surface that spells a rig role out loud
(BOX_BOOTSTRAP_ROLE, auto-run at mint since #81), so a directory that says one
thing and a role key that says another is a trap with a 15-minute fuse: it
mints clean and dies at convergence. Renamed with 'git mv' so the history of
each seed follows it.
'blank' keeps its name. It seeds no tenant role and sets no
BOX_BOOTSTRAP_ROLE, so it has nothing to agree with — renaming it would only
churn the default template's name for symmetry's sake.
Two namespaces move apart here and only one of them moved: the template name
and the role are now claude-box, while the seed USER stays 'claude' — that is
the user rig's role converges and the one 'box shell' lands in. test/cli.sh
pins the pair per tenant rather than each half alone, because a later rename
that moves one and forgets the other mints a box whose role dies looking for a
user nobody created. drill.sh keeps its bare box NAMES ('codex', 'grok' — what
the pre-flight banner announces and what teardown deletes) and only moves the
--template it passes.
The mint-time hints in cmd_new match both spellings of user.box.template, and
that is not an alias for the role: 'rig bootstrap claude' is gone and nothing
here softens the cut. The stamp is a fact about an INSTANCE, written at its own
mint time and carried forward by every clone; refusing the old spelling would
cut nothing over and only drop the login hint on boxes that predate today —
the same reason user.claudebox is honored everywhere else. migrate-host.sh
stamps re-homed legacy boxes claude-box, the name the template has today, so a
re-homed box looks like a fresh mint rather than a fossil.
Ordered AFTER rig's rename, and that is not a preference. The seeds install rig
from RIG_REPO/RIG_REF, defaulting to heavy-duty/rig@main and unpinned until
rig#32's releases, so these templates ask whatever main happens to be for
'rig bootstrap claude-box'. Against a pre-rename rig that role does not exist
and cmd_new refuses to call the box ready. Merged in the other order the window
closes instead of opening: rig's cut is hard, with no aliases, so the day it
lands every unmerged box seed naming a bare role is the broken one.
Closes #123
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 00:01:29 +00:00
|
|
|
|
template's creds-free tenant role after cloud-init (`rig bootstrap claude-box`
|
2026-07-22 15:10:35 +00:00
|
|
|
|
/ `codex-box` / `grok-box` / `kimi-box` / `staging-box` — the roles carry a family suffix,
|
refactor(templates): the tenant seeds carry rig's -box family suffix
rig is growing a second family of roles, and once a 'staging' role can mean
either a fleet machine or a box tenant, the bare name stops naming anything.
rig's answer is a suffix on the role (heavy-duty/rig#76): '-server' for fleet
machines, '-box' for box tenants. box's answer is that a template keeps being
named for the role it converges, so the tenant templates move with it:
claude -> claude-box codex -> codex-box
grok -> grok-box staging -> staging-box
Templates are the only surface that spells a rig role out loud
(BOX_BOOTSTRAP_ROLE, auto-run at mint since #81), so a directory that says one
thing and a role key that says another is a trap with a 15-minute fuse: it
mints clean and dies at convergence. Renamed with 'git mv' so the history of
each seed follows it.
'blank' keeps its name. It seeds no tenant role and sets no
BOX_BOOTSTRAP_ROLE, so it has nothing to agree with — renaming it would only
churn the default template's name for symmetry's sake.
Two namespaces move apart here and only one of them moved: the template name
and the role are now claude-box, while the seed USER stays 'claude' — that is
the user rig's role converges and the one 'box shell' lands in. test/cli.sh
pins the pair per tenant rather than each half alone, because a later rename
that moves one and forgets the other mints a box whose role dies looking for a
user nobody created. drill.sh keeps its bare box NAMES ('codex', 'grok' — what
the pre-flight banner announces and what teardown deletes) and only moves the
--template it passes.
The mint-time hints in cmd_new match both spellings of user.box.template, and
that is not an alias for the role: 'rig bootstrap claude' is gone and nothing
here softens the cut. The stamp is a fact about an INSTANCE, written at its own
mint time and carried forward by every clone; refusing the old spelling would
cut nothing over and only drop the login hint on boxes that predate today —
the same reason user.claudebox is honored everywhere else. migrate-host.sh
stamps re-homed legacy boxes claude-box, the name the template has today, so a
re-homed box looks like a fresh mint rather than a fossil.
Ordered AFTER rig's rename, and that is not a preference. The seeds install rig
from RIG_REPO/RIG_REF, defaulting to heavy-duty/rig@main and unpinned until
rig#32's releases, so these templates ask whatever main happens to be for
'rig bootstrap claude-box'. Against a pre-rename rig that role does not exist
and cmd_new refuses to call the box ready. Merged in the other order the window
closes instead of opening: rig's cut is hard, with no aliases, so the day it
lands every unmerged box seed naming a bare role is the broken one.
Closes #123
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 00:01:29 +00:00
|
|
|
|
`-box` for box tenants and `-server` for fleet machines, and the templates are
|
|
|
|
|
|
named for the roles they converge, rig#76), which installs the agent CLI or
|
|
|
|
|
|
server posture. The split is deliberate: cloud-init is a first-boot one-shot —
|
docs: the thin-template story — box mints, rig converges (#81)
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>
2026-07-18 20:03:55 +00:00
|
|
|
|
not convergent, not re-runnable, only parse-and-grep testable — while a rig
|
|
|
|
|
|
role is an idempotent script with effective-state asserts that can also
|
|
|
|
|
|
converge an *existing* box to a newer spec. Anything that joins a tailnet or
|
refactor(templates): the tenant seeds carry rig's -box family suffix
rig is growing a second family of roles, and once a 'staging' role can mean
either a fleet machine or a box tenant, the bare name stops naming anything.
rig's answer is a suffix on the role (heavy-duty/rig#76): '-server' for fleet
machines, '-box' for box tenants. box's answer is that a template keeps being
named for the role it converges, so the tenant templates move with it:
claude -> claude-box codex -> codex-box
grok -> grok-box staging -> staging-box
Templates are the only surface that spells a rig role out loud
(BOX_BOOTSTRAP_ROLE, auto-run at mint since #81), so a directory that says one
thing and a role key that says another is a trap with a 15-minute fuse: it
mints clean and dies at convergence. Renamed with 'git mv' so the history of
each seed follows it.
'blank' keeps its name. It seeds no tenant role and sets no
BOX_BOOTSTRAP_ROLE, so it has nothing to agree with — renaming it would only
churn the default template's name for symmetry's sake.
Two namespaces move apart here and only one of them moved: the template name
and the role are now claude-box, while the seed USER stays 'claude' — that is
the user rig's role converges and the one 'box shell' lands in. test/cli.sh
pins the pair per tenant rather than each half alone, because a later rename
that moves one and forgets the other mints a box whose role dies looking for a
user nobody created. drill.sh keeps its bare box NAMES ('codex', 'grok' — what
the pre-flight banner announces and what teardown deletes) and only moves the
--template it passes.
The mint-time hints in cmd_new match both spellings of user.box.template, and
that is not an alias for the role: 'rig bootstrap claude' is gone and nothing
here softens the cut. The stamp is a fact about an INSTANCE, written at its own
mint time and carried forward by every clone; refusing the old spelling would
cut nothing over and only drop the login hint on boxes that predate today —
the same reason user.claudebox is honored everywhere else. migrate-host.sh
stamps re-homed legacy boxes claude-box, the name the template has today, so a
re-homed box looks like a fresh mint rather than a fossil.
Ordered AFTER rig's rename, and that is not a preference. The seeds install rig
from RIG_REPO/RIG_REF, defaulting to heavy-duty/rig@main and unpinned until
rig#32's releases, so these templates ask whatever main happens to be for
'rig bootstrap claude-box'. Against a pre-rename rig that role does not exist
and cmd_new refuses to call the box ready. Merged in the other order the window
closes instead of opening: rig's cut is hard, with no aliases, so the day it
lands every unmerged box seed naming a bare role is the broken one.
Closes #123
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 00:01:29 +00:00
|
|
|
|
holds a key (the staging-box tenant's workload join) stays operator-run
|
|
|
|
|
|
through `box shell`; box prints it as a next step and never sees the key. The
|
|
|
|
|
|
seed's rig install is pinned by `RIG_REPO`/`RIG_REF` at mint (default
|
docs: the thin-template story — box mints, rig converges (#81)
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>
2026-07-18 20:03:55 +00:00
|
|
|
|
`heavy-duty/rig@main`, unpinned — the honest edge until rig#32's releases),
|
|
|
|
|
|
and box's template suite holds the line with fail-closed absence greps: no
|
|
|
|
|
|
agent CLI, no docker, no tailscale, no context-file heredocs in any
|
|
|
|
|
|
template, ever again.
|
|
|
|
|
|
|
2026-07-10 15:00:36 +00:00
|
|
|
|
## The box announces itself to the agent
|
|
|
|
|
|
|
docs: the thin-template story — box mints, rig converges (#81)
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>
2026-07-18 20:03:55 +00:00
|
|
|
|
Every coding-agent box gets a global agent-context file
|
docs: extend the agent-agnostic reframe to design, recipe, and drill docs
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>
2026-07-15 00:21:22 +00:00
|
|
|
|
(`~/.claude/CLAUDE.md`, `~/.codex/AGENTS.md`, `~/.grok/AGENTS.md`) telling the
|
|
|
|
|
|
agent it is running in a box (trust-less, ephemeral, creds-free) and to treat a
|
2026-07-14 18:01:34 +00:00
|
|
|
|
repo's `.box/` folder as its bootstrap runbook. No "tell it" step, no host
|
docs: the thin-template story — box mints, rig converges (#81)
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>
2026-07-18 20:03:55 +00:00
|
|
|
|
execution. The file is rendered by rig's tenant roles from one shared
|
|
|
|
|
|
template (#81) — including the #80 guard: never run `box setup-host`,
|
|
|
|
|
|
`box teardown-host` or the drill inside a box; a nested box stack claims the
|
|
|
|
|
|
guest's own uplink subnet and silently breaks its networking.
|
2026-07-10 15:00:36 +00:00
|
|
|
|
|
2026-07-14 18:01:34 +00:00
|
|
|
|
## `.box/` is optional, agent-facing documentation
|
2026-07-10 15:00:36 +00:00
|
|
|
|
|
|
|
|
|
|
Not host-executed shell. A repo that wants to be easy to stand up in a sandbox
|
|
|
|
|
|
ships a runbook (prose + optional scripts the agent may run). A repo that does
|
|
|
|
|
|
not, you set up by hand. The tool enforces no contract; there is no `install`.
|
|
|
|
|
|
|
chore: finish the debrand — env vars, install dir, docs are 'box', not 'claudebox'
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.
2026-07-14 17:44:24 +00:00
|
|
|
|
## What box owns, and what it doesn't
|
feat: make the command surface a table, add rename and an escape hatch
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
2026-07-13 20:49:46 +00:00
|
|
|
|
|
2026-07-14 18:01:34 +00:00
|
|
|
|
Boxes are ordinary Incus instances, tagged `user.box=1`. That makes every
|
feat: make the command surface a table, add rename and an escape hatch
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
2026-07-13 20:49:46 +00:00
|
|
|
|
Incus verb a candidate feature request — `rename`, `info`, `file push`, on
|
|
|
|
|
|
forever — and wrapping them one at a time grows a worse `incus`. The rule:
|
|
|
|
|
|
|
chore: finish the debrand — env vars, install dir, docs are 'box', not 'claudebox'
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.
2026-07-14 17:44:24 +00:00
|
|
|
|
> **box owns a command when it must enforce an invariant Incus cannot see:**
|
2026-07-14 18:01:34 +00:00
|
|
|
|
> the `user.box=1` boundary (never touch an instance we didn't mint), the
|
2026-07-15 00:23:58 +00:00
|
|
|
|
> isolation stack (`box-net` profile + `boxnet` + ACL), or the creds-free
|
feat: make the command surface a table, add rename and an escape hatch
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
2026-07-13 20:49:46 +00:00
|
|
|
|
> snapshot→clone workflow. Everything else is Incus's job.
|
|
|
|
|
|
|
|
|
|
|
|
The rule cuts both ways, and that's the point:
|
|
|
|
|
|
|
|
|
|
|
|
- `rename` **is** ours — not because it adds logic to `incus rename`, but because
|
|
|
|
|
|
resolving the name *is* the logic: check the tag, apply `--remote`, and notice
|
|
|
|
|
|
the box is running (Incus won't rename a running instance) so we can say "stop
|
|
|
|
|
|
it first" rather than leak an Incus error.
|
|
|
|
|
|
- `incus config set security.nesting=false` is **not** ours. It dismantles the
|
|
|
|
|
|
trust boundary; wrapping it would imply we bless it.
|
|
|
|
|
|
|
|
|
|
|
|
Two mechanisms keep this honest.
|
|
|
|
|
|
|
chore: finish the debrand — env vars, install dir, docs are 'box', not 'claudebox'
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.
2026-07-14 17:44:24 +00:00
|
|
|
|
**The command table** (`CMDS` in `bin/box`) is the single source of truth
|
feat: make the command surface a table, add rename and an escape hatch
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
2026-07-13 20:49:46 +00:00
|
|
|
|
for what exists, its synopsis, its help line, its preconditions and what runs.
|
|
|
|
|
|
Dispatch and help are both rendered from it, so the help cannot describe a
|
|
|
|
|
|
command that doesn't exist — the failure that produced #8. A thin verb is one
|
|
|
|
|
|
row; a verb that can't be expressed as a row and enforces no invariant of ours
|
|
|
|
|
|
doesn't belong in the tool.
|
|
|
|
|
|
|
chore: finish the debrand — env vars, install dir, docs are 'box', not 'claudebox'
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.
2026-07-14 17:44:24 +00:00
|
|
|
|
**The escape hatch** — `box incus <box> -- <args...>` — resolves and
|
feat: make the command surface a table, add rename and an escape hatch
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
2026-07-13 20:49:46 +00:00
|
|
|
|
tag-checks the box, then hands the rest to Incus verbatim. It means "no" to a
|
|
|
|
|
|
proxy request is not "you can't do that", and it keeps the one rail that matters:
|
chore: finish the debrand — env vars, install dir, docs are 'box', not 'claudebox'
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.
2026-07-14 17:44:24 +00:00
|
|
|
|
you cannot aim it at an instance box didn't mint. If the command can move
|
feat: make the command surface a table, add rename and an escape hatch
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
2026-07-13 20:49:46 +00:00
|
|
|
|
the box off the isolation stack (profile, network, device, `security.*`), it
|
|
|
|
|
|
warns and proceeds — from there the trust boundary is yours to keep.
|
|
|
|
|
|
|
2026-07-14 01:29:33 +00:00
|
|
|
|
## Isolation
|
2026-07-10 15:00:36 +00:00
|
|
|
|
|
2026-07-15 00:23:58 +00:00
|
|
|
|
Dedicated NAT bridge `boxnet` + Incus `box-isolate` ACL dropping all
|
2026-07-10 15:00:36 +00:00
|
|
|
|
RFC1918/CGNAT/link-local egress, plus host-firewall rules blocking instance →
|
2026-07-14 01:29:33 +00:00
|
|
|
|
host. Entry is `incus exec` over the local socket — no inbound path. The VM is
|
|
|
|
|
|
the trust boundary.
|
|
|
|
|
|
|
|
|
|
|
|
**A box reaches the public internet and nothing else — including no other box.**
|
|
|
|
|
|
That last clause is the one that was assumed and turned out to be false, so it
|
|
|
|
|
|
is spelled out here with the mechanism, and `drill/` tests it on every run.
|
|
|
|
|
|
|
2026-07-15 00:23:58 +00:00
|
|
|
|
- **Box → host, LAN, RFC1918, CGNAT, link-local:** the `box-isolate` ACL.
|
chore: finish the debrand — env vars, install dir, docs are 'box', not 'claudebox'
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.
2026-07-14 17:44:24 +00:00
|
|
|
|
- **Box → box: an nftables *bridge-family* rule** (`host/box-firewall.sh`).
|
2026-07-14 01:29:33 +00:00
|
|
|
|
It cannot be an ACL rule. Two boxes on one bridge share an L2 segment, so
|
|
|
|
|
|
their frames are *switched* between bridge ports and never traverse the
|
|
|
|
|
|
netfilter path an L3 ACL lives on — the ACL looked airtight (it drops
|
2026-07-15 00:23:58 +00:00
|
|
|
|
`10.0.0.0/8`, which contains `boxnet`) while box→box was in fact wide open.
|
2026-07-14 01:29:33 +00:00
|
|
|
|
A live probe found box A's SYN arriving at box B. The bridge family's forward
|
|
|
|
|
|
hook fires exactly on port-to-port frames, which on this bridge means box→box
|
|
|
|
|
|
and nothing else: gateway traffic and routed egress are delivered locally, not
|
|
|
|
|
|
forwarded. Dropping every forwarded frame therefore isolates the boxes and
|
|
|
|
|
|
costs them nothing.
|
|
|
|
|
|
- **Box → box by NAME:** `dns.mode=none`. dnsmasq on the gateway held a record
|
|
|
|
|
|
for every instance, so a box could enumerate its siblings even where it could
|
|
|
|
|
|
not reach them. Blocked connections with open reconnaissance is not isolation.
|
|
|
|
|
|
- **IPv6:** off (`ipv6.address=none`), and that is a *contract*, not a default —
|
|
|
|
|
|
every rule above is IPv4-only, so IPv6 would be an uncovered path.
|
|
|
|
|
|
- **`security.ipv4_filtering`: deliberately NOT used.** It breaks the box's
|
|
|
|
|
|
networking (in-box Docker cannot pull or run a container). Tested, vetoed.
|
|
|
|
|
|
|
|
|
|
|
|
The rule that keeps this honest: **isolation claims are tested, never reasoned
|
|
|
|
|
|
about.** The box→box hole existed because a plausible code reading said it could
|
|
|
|
|
|
not. See `drill/RUNS.md`.
|
2026-07-10 15:00:36 +00:00
|
|
|
|
|
2026-07-18 04:09:48 +00:00
|
|
|
|
## Multi-user hosts: access tiers
|
|
|
|
|
|
|
|
|
|
|
|
The daemon socket is binary — `incus-admin` holds everything on the machine —
|
|
|
|
|
|
so a shared host needs a second tier, and Incus ships one: **incus-user**
|
|
|
|
|
|
confines an `incus`-group member to an auto-created project `user-<uid>`,
|
|
|
|
|
|
behind a restricted certificate that cannot name any other project. The tier
|
|
|
|
|
|
is decided once, from the process's live credentials (`box_tier()`: UID 0 or
|
|
|
|
|
|
`incus-admin` → admin; `incus` alone → restricted; neither → none), and every
|
|
|
|
|
|
tier-aware verb reads that one function.
|
|
|
|
|
|
|
|
|
|
|
|
What incus-user does *not* do is honor box's contract — measured on Debian 13
|
|
|
|
|
|
/ Incus 6.0.4 (#74), after the design that assumed it (#72) was vetoed by its
|
|
|
|
|
|
own Task-0 rehearsal:
|
|
|
|
|
|
|
|
|
|
|
|
- it pins each user's project to a private auto-created bridge
|
|
|
|
|
|
(`incusbr-<uid>`) — a stock NAT bridge with **none** of the hardening: no
|
|
|
|
|
|
ACL, no `dns.mode=none`, no resolver pin, IPv6 on;
|
|
|
|
|
|
- it blocks snapshots — box's entire reuse workflow;
|
|
|
|
|
|
- the `box-net` profile lives in the default project, invisible to theirs.
|
|
|
|
|
|
|
|
|
|
|
|
So the tier is an **admin-run convergence** (`box grant <user>`), not a
|
|
|
|
|
|
group membership: put them in `incus`, touch incus-user once as them (the
|
|
|
|
|
|
project is created lazily; nothing exists to converge until it does), then
|
|
|
|
|
|
rewire the project — network access narrowed to `boxnet` **and only
|
|
|
|
|
|
`boxnet`**, snapshots allowed, the shipped profile installed. Narrowing is
|
|
|
|
|
|
the load-bearing decision: granting `boxnet,incusbr-<uid>` (the obvious fix)
|
|
|
|
|
|
would leave an unhardened NAT bridge one `--network` flag away from any box
|
|
|
|
|
|
they mint. With the private bridge unreferenced (its `eth0` is removed from
|
|
|
|
|
|
their default profile) and outside `restricted.networks.access`, the hardened
|
|
|
|
|
|
network is not their default placement — it is the only placement their
|
|
|
|
|
|
certificate can express. The grant survives incus-user restarts by that
|
|
|
|
|
|
tool's own design (it configures a project only at creation), and a restricted
|
|
|
|
|
|
certificate cannot widen its own project — both measured, not read.
|
|
|
|
|
|
|
|
|
|
|
|
Cross-USER isolation is the same mechanism as cross-box isolation, on
|
|
|
|
|
|
purpose: their instances share `boxnet` with everyone's, and the bridge-family
|
|
|
|
|
|
drop + port isolation + `dns.mode=none` already make any two boxes strangers.
|
|
|
|
|
|
A restricted user CAN strip `security.port_isolation` from the profile copy
|
grant/rehearsal: the codex round — verified rollback, loud partial states, and the raw-attach guarantee measured (#75)
Review 4727756972 (A2): the backout no longer trusts gpasswd — it re-reads
the live group database after removal; verified-absent gets the safe
message, anything else screams ROLLBACK INCOMPLETE, exits nonzero, and
names the exact remediation. The concurrent-login window (a session begun
between usermod and backout keeps the group) is CLOSED to the extent the
database can't reach: the backout detects live processes and names
loginctl terminate-user, and the success wording claims only what was
verified.
Review 4727641752 (A1): a failed grant for a user whose membership predates
the run (the hand-added-user scenario) now fails LOUDLY — they retain
socket access on part-converged policy, and the message says so with both
remediations (box revoke now, or fix and re-run). Their membership is not
stripped: breaking a working user over a failed re-grant is its own hazard.
The default-profile eth0 removal is deliberately not restored on failure —
that mutation only reduces capability, and restoring it would move the
failure state AWAY from fail-closed. Injected-failure coverage is criterion
(n), both flavors: fresh-user backout (fault at the LAST mutation, so the
rollback runs after every earlier one) with the group's absence verified
and a converging re-run; blocked narrowing staged for real with an
instance-local NIC parked on the private bridge.
Review A3, resolution 3 with the measurement demanded: criterion (m)
launches exactly 'incus launch --network boxnet' as the restricted user and
probes the raw NIC from inside — egress works, RFC1918 dropped (the ACL is
the network's), sibling probes dropped BOTH directions (the nft drop is the
host's), name enumeration blocked. The scoped guarantee is now stated in
box-design.md and measured on every run: box-minted instances carry per-NIC
port_isolation; raw attachments keep every network- and host-owned control,
losing only that redundant L2 layer. Instrument lesson kept as MU-5: the
probe's first cut minted the non-cloud image — no DHCP client, no lease,
and a dead NIC passes every negative probe vacuously; it now requires the
lease before believing its own answers.
Rehearsal: 54/54 (containers). test/cli.sh: 82 checks.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 06:41:05 +00:00
|
|
|
|
in their own project — or skip the profile entirely and attach `boxnet` raw
|
|
|
|
|
|
(`--network boxnet`); the network must be usable for the profile to work, and
|
|
|
|
|
|
Incus has no allow-via-profile-only lever. So the guarantee is scoped, and
|
|
|
|
|
|
said plainly: **per-NIC port isolation is guaranteed for box-minted
|
|
|
|
|
|
instances; a raw attachment keeps every network-owned control (the ACL,
|
|
|
|
|
|
`dns.mode=none`, the resolver pin) and every host-owned one (the nft bridge
|
|
|
|
|
|
drop) — losing only the redundant per-NIC L2 layer.** Scoped, and measured:
|
|
|
|
|
|
`drill/multiuser.sh` criterion (m) launches exactly that raw instance and
|
|
|
|
|
|
probes egress, RFC1918, both sibling directions and name enumeration from
|
|
|
|
|
|
inside it. Defense in depth, every layer measured (criteria a–n).
|
2026-07-18 04:09:48 +00:00
|
|
|
|
|
revoke/grant: survive the live-session case — the review's one real hole (#74)
Supplementary groups are read at LOGIN, so 'gpasswd -d' does nothing to a
session the user already holds — and after --purge, a stale-group process
could touch incus-user and lazily RECREATE the project with stock defaults:
the unhardened NAT bridge, un-narrowed, strictly worse than the granted
state. Adversarial review caught it; verified live, then closed:
- revoke --purge terminates the user's sessions first (loginctl, then
pkill), and refuses to purge under processes it cannot kill
- bare revoke says out loud that held sessions keep the socket until they
end, and names the loginctl command — instead of claiming a lockout it
did not deliver (help/README/design doc reworded to match)
- a failed grant backs out its own group-add on exit (trap, disarmed on
success): no half-granted user holding an un-narrowed socket while the
admin reads the error. Verified by injecting a bad profile YAML
- the rehearsal now holds a session open across the purge and demands it
dies with the tier (criterion l, 42nd check)
Smaller review findings, same pass: the escape-hatch probes assert the
refusal's REASON instead of any nonzero exit (an image hiccup must not read
as 'the escape is closed'); probe_from maps an outer-timeout kill to
dropped, not reachable; the rehearsal cleanup keeps the account when a purge
fails so doctor can name the leftovers; the purge asserts the trust
certificate's absence; cmd_new distinguishes a dead daemon from a missing
stack before prescribing setup-host; grant's success message names the
user-<uid> bridge variant correctly on big-uid hosts.
Rehearsal after: 42/42 (containers). test/cli.sh: 76 checks.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 05:11:20 +00:00
|
|
|
|
`box revoke` is two strengths: bare, it removes the group — their boxes keep
|
|
|
|
|
|
*running* (revoking a person does not kill their workloads), `grant` restores
|
|
|
|
|
|
everything, and because supplementary groups are read at login, revoke warns
|
|
|
|
|
|
when live sessions keep the socket until they end (and names the `loginctl`
|
|
|
|
|
|
command). `--purge` terminates those sessions *first* — a stale-group process
|
|
|
|
|
|
could otherwise touch incus-user after the purge and lazily recreate the
|
|
|
|
|
|
project with stock, unhardened defaults, undoing the grant's whole point —
|
|
|
|
|
|
then deletes their world (boxes, images, project, private bridge, trust-store
|
|
|
|
|
|
certificate) and asserts the absence afterwards. A failed `grant` backs its
|
|
|
|
|
|
own group-add out on exit for the same reason: no half-granted user holding
|
|
|
|
|
|
an un-narrowed socket.
|
2026-07-18 04:09:48 +00:00
|
|
|
|
|
2026-07-10 15:00:36 +00:00
|
|
|
|
## Non-goals
|
|
|
|
|
|
|
2026-07-18 04:09:48 +00:00
|
|
|
|
- Interactive-first: install and setup prompt by default (`BOX_YES=1` and the
|
|
|
|
|
|
CI rehearsal job are the sanctioned unattended paths).
|
2026-07-10 15:00:36 +00:00
|
|
|
|
- No credential storage or injection by the tool.
|
2026-07-18 04:09:48 +00:00
|
|
|
|
- No per-user resource quotas on the restricted tier (Incus's
|
|
|
|
|
|
`limits.*`/`restricted.*` project keys exist when someone needs them).
|