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
4.8 KiB
claudebox design
claudebox is a CLI that mints and manages trust-less, network-isolated VMs
with Claude Code installed. It is infrastructure, not a project provisioner.
See issue #3 for the full reframe and rationale. This doc captures the durable design decisions.
Principle: separate the tool from the agent
- The tool mints isolated boxes with Claude installed but unauthenticated. It knows nothing about projects, secrets, recipes, or memory.
- The agent (Claude Code, inside the box) reads an optional
.claudebox/runbook in a cloned repo and acts on it. The recipe's consumer is the reasoning agent, not host machinery.
Boxes are strictly creds-free
claudebox new --name <n> launches a blank box: everything installed, no
git credentials and no Claude credentials. The operator authenticates
interactively inside the box:
- Claude —
claude→/login(paste-a-code OAuth: copy the URL, open it in your own browser, paste the code back). Works because the box is outbound-only; the tool never handles a token. - Git — the operator adds their own PAT /
gh auth logininside 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:
claudebox snapshot <n> [label]— checkpoint after login + clone.claudebox new --name <n2> --from <src>[/<snapshot>]— clone an existing box or snapshot (authed state and all). Isolation is preserved: the clone keeps theclaude-devprofile +claudenet+ ACL.claudebox restore <n> <snapshot>— roll a box back to a checkpoint.
Log in once → snapshot → spin up authed boxes from it.
The box announces itself to the agent
cloud-init installs a global ~/.claude/CLAUDE.md in every box telling Claude it
is running in a claudebox (trust-less, ephemeral, creds-free) and to treat a
repo's .claudebox/ folder as its bootstrap runbook. No "tell it" step, no host
execution.
.claudebox/ is optional, agent-facing documentation
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.
What claudebox owns, and what it doesn't
Boxes are ordinary Incus instances, tagged user.claudebox=1. That makes every
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:
claudebox owns a command when it must enforce an invariant Incus cannot see: the
user.claudebox=1boundary (never touch an instance we didn't mint), the isolation stack (claude-devprofile +claudenet+ ACL), or the creds-free snapshot→clone workflow. Everything else is Incus's job.
The rule cuts both ways, and that's the point:
renameis ours — not because it adds logic toincus 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=falseis not ours. It dismantles the trust boundary; wrapping it would imply we bless it.
Two mechanisms keep this honest.
The command table (CMDS in bin/claudebox) is the single source of truth
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.
The escape hatch — claudebox incus <box> -- <args...> — resolves and
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:
you cannot aim it at an instance claudebox didn't mint. If the command can move
the box off the isolation stack (profile, network, device, security.*), it
warns and proceeds — from there the trust boundary is yours to keep.
Isolation (unchanged)
Dedicated NAT bridge claudenet + Incus claude-isolate ACL dropping all
RFC1918/CGNAT/link-local egress, plus host-firewall rules blocking instance →
host. The instance reaches the internet and nothing else. Entry is incus exec
over the local socket — no inbound path. The VM is the trust boundary.
Non-goals
- No unattended/CI bring-up — the flow is interactive.
- No credential storage or injection by the tool.