Two things: 1. The host scripts are first-class verbs now — 'box setup-host', 'box teardown-host [--purge-incus]', 'box migrate-host --box <n>'. Nobody should have to run ~/.local/share/box/host/<script>.sh; that read like an external script and exposed an install path. Each verb execs the installed script with its flags passed through (same pattern as 'box doctor'). README, doctor hints, and the uninstall section point at the verbs now. 2. The repo-runbook convention is '.box/', not '.claudebox/'. Renamed across docs and the templates' agent briefing; the briefing tells the agent to read either, and box-recipe.md notes the rename, so repos still shipping '.claudebox/' keep working through the transition. (Consuming repos rename their own folder — tracked separately.) Also widened the help command column for the longer verb names, and fixed one sed-casualty where a broad '.claudebox'→'.box' pass had turned the README's legacy user.claudebox tag into user.box.
251 lines
12 KiB
Markdown
251 lines
12 KiB
Markdown
# claudebox — ships the `box` CLI
|
|
|
|
**Headless, trust-less, throwaway dev VMs.** One command mints a fresh,
|
|
network-isolated Incus box from a **template**; the flagship template is
|
|
`claude` — Debian 13 with Claude Code installed, the box this repo is named
|
|
for. The box is the product — you log in and work; destroying it loses
|
|
nothing you didn't push.
|
|
|
|
**Strictly creds-free.** A box ships with everything installed and **no**
|
|
credentials — no Claude token, no git PAT, nothing. You authenticate
|
|
interactively *inside* the box. The tool never stores or injects a secret. That
|
|
means there's nothing shared or committed, so it's safe for multiple operators
|
|
out of the box.
|
|
|
|
**Templates set what's in the box, never what it can reach.** A template is
|
|
image + user + resources + cloud-init; the network and every security flag
|
|
live in a shared profile no template can touch, so `blank` is a box with
|
|
nobody home — not a box with the safety off.
|
|
|
|
**The tool knows nothing about your projects.** You just `git clone` inside a
|
|
box. A repo can ship an optional [`.box/`](docs/box-recipe.md)
|
|
runbook that Claude Code reads and acts on — there is no `install` step and no
|
|
host-run setup. See [docs/box-design.md](docs/box-design.md) for the
|
|
design rationale.
|
|
|
|
> **0.4.0 is a clean cut**: the CLI is `box` (no `claudebox` shim), the host
|
|
> stack is `boxnet`/`box-isolate`/`box-firewall` on 10.88.0.0/24, and the
|
|
> default template is `blank`. Existing boxes minted by any earlier version
|
|
> keep working under every verb — their legacy tag is honored forever, and
|
|
> their old `claudenet` (10.87) is left standing beside the new bridge. To
|
|
> strip a host of both generations at once: `host/teardown-host.sh`, or
|
|
> `drill/wipe.sh` for the scorched-earth version.
|
|
|
|
## Install
|
|
|
|
```sh
|
|
curl -fsSL https://raw.githubusercontent.com/heavy-duty/claudebox/main/install.sh | bash
|
|
```
|
|
|
|
Installs the tree to `~/.local/share/box` and links `box` onto your
|
|
`PATH`. Re-run any time to upgrade — upgrading from a pre-0.4.0 install also
|
|
retires the old `claudebox` symlink. (No `git clone` needed.)
|
|
|
|
## One-time host setup (Ubuntu 24.04 / Debian 13)
|
|
|
|
```sh
|
|
box setup-host # run twice if it adds you to incus-admin (re-login between)
|
|
```
|
|
|
|
Idempotent. Installs Incus and creates the isolation stack: the `boxnet` NAT
|
|
bridge (sibling-name resolution off, resolver pinned to public upstreams —
|
|
`BOX_DNS` overrides), the `box-isolate` ACL (drops all RFC1918/CGNAT/
|
|
link-local egress), the `box-net` profile (port-isolated NICs — boxes can't
|
|
reach each other), and firewall rules blocking instance → host. All rules
|
|
re-apply at boot via `box-firewall.service` — no post-reboot ritual. If
|
|
the host lacks `dnsmasq-base` (Debian cloud images skip Recommends):
|
|
`sudo apt-get install -y dnsmasq-base`.
|
|
|
|
## Quick start
|
|
|
|
```sh
|
|
box new --name work --template claude # a creds-free Claude box (~10 min cold)
|
|
box shell work # enter as the template's user
|
|
```
|
|
|
|
Inside the box, authenticate as needed:
|
|
|
|
```sh
|
|
claude # then run /login — copy the URL (press c), open it
|
|
# in YOUR browser, paste the code back. No host CLI needed.
|
|
gh auth login # or drop a PAT in — your git credentials, your call
|
|
git clone https://github.com/you/project && cd project
|
|
claude # if the repo has .box/, Claude reads it and sets up
|
|
```
|
|
|
|
## Templates
|
|
|
|
The claude box is one template among several. What ships today:
|
|
|
|
| Template | What's in it |
|
|
| --- | --- |
|
|
| `blank` | Bare Debian 13 — same isolation, no tooling. The default. |
|
|
| `claude` | Claude Code, creds-free (the original claudebox) |
|
|
| `codex` | OpenAI Codex CLI, creds-free |
|
|
| `grok` | xAI Grok Build CLI, creds-free |
|
|
|
|
A template is a directory under `templates/`: a `box.env` (image, user,
|
|
resources — parsed against a strict allowlist, never sourced) and a
|
|
`user-data.yaml` (cloud-init, passed to Incus verbatim). The coding-CLI
|
|
templates are all the same shape — install the CLI, put it on PATH, drop an
|
|
agent-context file; none of them carry credentials.
|
|
|
|
```sh
|
|
box templates # list what this install can mint
|
|
box new --name scratch # the DEFAULT template is blank: bare Debian,
|
|
# same isolation, nobody home
|
|
```
|
|
|
|
A template **cannot** name a network, a profile, or a `security.*` flag —
|
|
there is no key for them. Every box launches with the shared `box-net`
|
|
profile (the isolated NIC + root disk), so every template gets the identical
|
|
trust boundary. Resources come from the template's `box.env`;
|
|
`BOX_CPU` / `BOX_MEMORY` / `BOX_DISK` environment variables override them at
|
|
mint time. The template's identity (name, user) is stamped onto the instance,
|
|
so `shell`, `exec` and `tmux` land in the right user — and a clone still
|
|
knows, because `incus copy` carries the metadata.
|
|
|
|
## Log in once, reuse via snapshots
|
|
|
|
Because every fresh box is creds-free, re-authenticating each time would be
|
|
toil. Snapshot an authenticated box and clone from it instead:
|
|
|
|
```sh
|
|
box snapshot work authed # checkpoint after you've logged in
|
|
box new --name feature --from work/authed # clone the authed state into a new box
|
|
```
|
|
|
|
`--from` copies the whole box (Claude login, git creds, clones and all) while
|
|
preserving isolation. You can also `box new --name x --from work` to clone
|
|
a box's live state, or roll a box back with `box restore work authed`.
|
|
|
|
Forgotten what you called a checkpoint? `box info work` prints the box's
|
|
snapshot labels and the `--from` line to clone one.
|
|
|
|
## Commands
|
|
|
|
```
|
|
box new --name <box> [--template <t>] [--from <src>[/<snap>]] [--vm|--container] [--remote r]
|
|
box templates # list the templates this install can mint
|
|
box list # list your boxes
|
|
box info <box> # one box: state, IP, snapshot labels
|
|
box shell <box> # enter as the template's user
|
|
box exec <box> -- <cmd...> # run a command in the box
|
|
box tmux <box> [session] # attach/create a tmux session — survives disconnects
|
|
box snapshot <box> [label] # checkpoint (label defaults to manual-<epoch>)
|
|
box restore <box> <snap> # roll back to a snapshot
|
|
box rename <box> <new> # rename a box (stop it first)
|
|
box down <box> # stop (state kept; `start` resumes)
|
|
box start <box> # start a stopped box
|
|
box rm <box> [--force] # delete the box + its snapshots (asks first)
|
|
box expose <box> <port> # forward a box port to host loopback — see a dev server
|
|
box incus <box> -- <args...> # escape hatch: any incus command, box resolved
|
|
box doctor [--fix|--pin-dns] # is this host fit to mint boxes? diagnose from ground truth
|
|
box status # deprecated alias for `list`
|
|
box help [<command>] # full help, or one command's page
|
|
```
|
|
|
|
Every command takes `--help`, and options come after the command
|
|
(`box list --json`). Exit status: `0` ok, `1` it went wrong, `2` you asked
|
|
wrong.
|
|
|
|
`new` fresh-launches from a template (default: `claude`), or with `--from`
|
|
clones an existing box or snapshot. VM mode (`--vm`, the default where
|
|
`/dev/kvm` exists) is the trust-less target; container mode (auto-fallback,
|
|
`security.nesting=true`) is for hosts without nested virt — weaker isolation,
|
|
dev/test only.
|
|
|
|
## Boxes are just Incus instances
|
|
|
|
A box is an ordinary Incus instance tagged `user.box=1` (pre-0.4.0 boxes
|
|
carry `user.claudebox=1`, honored forever). box wraps the box lifecycle and
|
|
the isolation model — not all of Incus. It owns a command
|
|
when it must enforce something Incus can't see: that tag (it will not stop,
|
|
rename or delete an instance it didn't mint), the isolation stack, or the
|
|
creds-free snapshot workflow. For everything else, there's the door:
|
|
|
|
```sh
|
|
box incus work -- config show # instance name appended
|
|
box incus work -- file push x.tar {}/tmp/ # or placed with {}
|
|
```
|
|
|
|
The box is resolved and tag-checked; the rest is passed to `incus` verbatim, and
|
|
the command is echoed before it runs. If it can move the box off the isolation
|
|
stack (profile, network, device, `security.*`), box warns and proceeds —
|
|
the trust boundary is then yours to keep. See
|
|
[docs/box-design.md](docs/box-design.md) for the rule and why the
|
|
command surface is a table.
|
|
|
|
## Isolation
|
|
|
|
The contract: **a box reaches the public internet and nothing else.** Not the
|
|
host, not your LAN, not another box, not even another box's *name*. What
|
|
enforces it, layer by layer:
|
|
|
|
- **Dedicated NAT bridge** `boxnet`, IPv6 off. Every rule below is
|
|
IPv4-only, so IPv6 would be an uncovered path — off is part of the
|
|
contract, not a default.
|
|
- **`box-isolate` ACL** — drops all egress to private space (RFC1918,
|
|
CGNAT, link-local), with a single carve-out to the gateway so DNS works.
|
|
- **Sibling isolation, at L2** — two boxes on one bridge are *switched*,
|
|
never routed, so no L3 rule can separate them (learned the hard way; see
|
|
below). `security.port_isolation` on every box NIC plus an nft
|
|
bridge-family drop mean box A cannot exchange frames with box B at all.
|
|
- **No name-level reconnaissance** — `dns.mode=none` stops the gateway
|
|
resolving sibling names, and the bridge's resolver is pinned to public
|
|
upstreams (`no-resolv`), so tailnet names and split-DNS zones from a
|
|
host-level VPN don't resolve inside a box either.
|
|
- **Host firewall** — instance → host is dropped except DNS/DHCP, including
|
|
the host's public IPs. Entry is `incus exec` over the local socket only —
|
|
**no inbound path exists** — unless you punch one with `box expose`, and
|
|
that door only ever opens onto the host's own loopback (`127.0.0.1`), never
|
|
the network.
|
|
|
|
The VM is the trust boundary: whatever runs inside — Claude, or anything a
|
|
template ships — can run arbitrary code and touch nothing you care about.
|
|
|
|
### Measured, not claimed
|
|
|
|
Every clause above is probed live by an end-to-end drill, because the one time
|
|
this contract was reasoned about instead of measured, the reasoning was wrong:
|
|
box→box traffic was "covered" by an L3 drop that L2-switched frames never
|
|
meet — a hole found by probing, not by reading the rules. On a bare host the
|
|
drill installs the whole stack, mints a box cold, snapshots and clones it,
|
|
probes every boundary from inside the boxes, and removes what it minted —
|
|
currently **47 checks, 47 passing**. [drill/RUNS.md](drill/RUNS.md) is the full
|
|
history, including every trap that fooled a run into a wrong verdict.
|
|
|
|
```sh
|
|
bash drill/doctor.sh # read-only: is this host healthy and the stack live?
|
|
bash drill/drill.sh # FULL end-to-end — mutates the host; use a machine you own
|
|
bash drill/wipe.sh # scorched earth: strip BOTH name generations, images and
|
|
# (--purge-storage) the pool, so a run starts from bare
|
|
```
|
|
|
|
The doctor reads ground truth, not config claims — the kernel's `isolated on`
|
|
flag per bridge port, the process table, the resolver actually in use — and
|
|
diagnoses the host faults that have actually happened: a wedged Incus daemon,
|
|
a dnsmasq that silently isn't serving, a VPN resolver that boxes would
|
|
inherit.
|
|
|
|
## Recipes: the `.box/` convention
|
|
|
|
A repo that wants to be easy to stand up in a box ships an optional `.box/`
|
|
folder — a runbook Claude reads and follows (install deps, start services,
|
|
template env, seed data, smoke-test). It is agent-facing documentation, not a
|
|
host-executed script. See [docs/box-recipe.md](docs/box-recipe.md).
|
|
|
|
## Uninstall
|
|
|
|
```sh
|
|
box teardown-host # boxes, network, ACL, profile, firewall
|
|
box teardown-host --purge-incus # ...and Incus itself
|
|
rm -rf ~/.local/share/box ~/.local/bin/box # the CLI itself
|
|
```
|
|
|
|
## Non-goals
|
|
|
|
- **No unattended/CI bring-up.** The flow is interactive (log in, clone, ask
|
|
Claude). Reproducible-by-construction provisioning is out of scope.
|
|
- **No credential storage or injection by the tool.** Boxes are creds-free;
|
|
snapshots are the reuse mechanism, not a secrets store.
|