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
|
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
|
|
|
|
with a coding agent installed** (`claude`, `codex`, `grok`, 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.
|
|
|
|
|
|
|
|
|
|
|
|
## The box announces itself to 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
|
|
|
|
cloud-init installs a global agent-context file in every coding-agent box
|
|
|
|
|
|
(`~/.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
|
2026-07-10 15:00:36 +00:00
|
|
|
|
execution.
|
|
|
|
|
|
|
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-17 15:53:14 +00:00
|
|
|
|
## Server-class boxes: joining an overlay from inside
|
|
|
|
|
|
|
|
|
|
|
|
"No inbound path" is a claim about the **host's network position**: box builds
|
|
|
|
|
|
no route in — no SSH, no forwarded port, no LAN listener beyond `expose`'s
|
|
|
|
|
|
loopback door. It was never a claim about what a guest may do with its own
|
|
|
|
|
|
outbound reach. A guest can deliberately join an overlay network (a tailnet)
|
|
|
|
|
|
from inside and invite management in over the tunnel it established — the
|
|
|
|
|
|
host-side stack only ever sees allowed outbound UDP, and every rule above
|
|
|
|
|
|
still holds: the box still cannot reach the host, the LAN, or a sibling box.
|
|
|
|
|
|
It is reachable *only* over the overlay. For a staging server that is not a
|
|
|
|
|
|
hole in the posture, it **is** the posture: tailnet-only, never LAN or public.
|
|
|
|
|
|
|
|
|
|
|
|
The `staging` template is the sanctioned server-class use of that, and the
|
|
|
|
|
|
layering keeps each tool honest:
|
|
|
|
|
|
|
|
|
|
|
|
- **box** mints the VM — docker + rig preinstalled, still strictly creds-free.
|
|
|
|
|
|
- **rig**, run *inside* the box (`box shell` → `sudo rig bootstrap workload`),
|
|
|
|
|
|
hardens sshd and joins the tailnet. rig holds the auth key in process
|
|
|
|
|
|
memory per its own contract; box never sees it.
|
|
|
|
|
|
- **cast** registers the box in the control plane by its tailnet address.
|
|
|
|
|
|
|
|
|
|
|
|
Two template keys carry the server posture, and only those: `BOX_REQUIRE_VM`
|
|
|
|
|
|
(no container fallback — the VM is the trust boundary, and the guest runs
|
|
|
|
|
|
docker) and `BOX_AUTOSTART` (`boot.autostart`, so the box returns from a host
|
|
|
|
|
|
reboot without an operator). There is still no key for a network or a
|
|
|
|
|
|
`security.*` flag.
|
|
|
|
|
|
|
|
|
|
|
|
**Snapshot before the join.** Clone from a snapshot taken *before*
|
|
|
|
|
|
`rig bootstrap` runs: a post-join clone carries its source's tailnet identity,
|
|
|
|
|
|
and two machines answering as one node is the overlay's version of the DHCP
|
|
|
|
|
|
collision `reset_identity` exists for. Mint → snapshot → bootstrap, in that
|
|
|
|
|
|
order.
|
|
|
|
|
|
|
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).
|