feat!: claudebox becomes box — the Claude box is one template among several

The tool underneath was already generic: a thin, honest wrapper over
Incus. What was Claude-specific was welded on — one image, one profile,
one cloud-init file, one hardcoded 'sudo -u claude'. The weld is now a
template.

The mechanic: 'box new' stamps the template's identity onto the
instance (user.box=1, user.box.template, user.box.user); shell/exec/
tmux read the user back off the instance, and 'incus copy' carries
user.* keys (audit B2), so a clone knows what it is without consulting
the template. Templates are box.env (parsed against a strict allowlist,
never sourced — no key for a network exists, on purpose) plus a
verbatim cloud-init. Every template launches with the shared box-net
profile: the isolated NIC and root disk, nothing template-controlled —
resources land per-instance from box.env, overridable via BOX_CPU/
BOX_MEMORY/BOX_DISK (which is also how the drill shrinks boxes on a
small host now that profile edits can't).

The three open calls, taken as recommended: clean cut at 0.4.0 (no
claudebox shim; the installer retires the old symlink); default
template = claude (muscle memory survives); repo stays heavy-duty/
claudebox, binary is box.

Compat is the tag, not the name: resolve_box and list honor the legacy
user.claudebox=1 forever, and the legacy tag maps to the claude user —
a pre-rename box lists, shells, clones, unchanged.

Deliberate divergence from #17's table: the host-stack resource names
(claudenet, claude-isolate, nft tables, claudebox-firewall.*) are NOT
renamed — they are host-internal, invisible to users, and renaming
them breaks every provisioned host for zero user-visible gain.
claude-dev is no longer created; setup-host creates box-net, teardown
removes both.

Closes #17
This commit is contained in:
claude-hdb 2026-07-14 14:22:50 +00:00
parent ffdfb52512
commit c11f3d7552
13 changed files with 500 additions and 282 deletions

121
README.md
View file

@ -1,8 +1,10 @@
# claudebox # claudebox — ships the `box` CLI
A CLI to run **headless, trust-less Claude Code in throwaway VMs**. One command **Headless, trust-less, throwaway dev VMs.** One command mints a fresh,
mints a fresh, network-isolated Incus box with Claude Code installed. The box is network-isolated Incus box from a **template**; the flagship template is
the product — you log in and work; destroying it loses nothing you didn't push. `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** **Strictly creds-free.** A box ships with everything installed and **no**
credentials — no Claude token, no git PAT, nothing. You authenticate credentials — no Claude token, no git PAT, nothing. You authenticate
@ -10,20 +12,30 @@ 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 means there's nothing shared or committed, so it's safe for multiple operators
out of the box. 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 **The tool knows nothing about your projects.** You just `git clone` inside a
box. A repo can ship an optional [`.claudebox/`](docs/claudebox-recipe.md) box. A repo can ship an optional [`.claudebox/`](docs/claudebox-recipe.md)
runbook that Claude Code reads and acts on — there is no `install` step and no runbook that Claude Code reads and acts on — there is no `install` step and no
host-run setup. See [docs/claudebox-design.md](docs/claudebox-design.md) for the host-run setup. See [docs/claudebox-design.md](docs/claudebox-design.md) for the
design rationale. design rationale.
> **0.4.0 renamed the CLI** from `claudebox` to `box` — a clean cut, no shim.
> Existing boxes minted by any earlier version keep working under every verb
> (their legacy tag is honored forever); only the old command name retired.
## Install ## Install
```sh ```sh
curl -fsSL https://raw.githubusercontent.com/heavy-duty/claudebox/main/install.sh | bash curl -fsSL https://raw.githubusercontent.com/heavy-duty/claudebox/main/install.sh | bash
``` ```
Installs the tree to `~/.local/share/claudebox` and links `claudebox` onto your Installs the tree to `~/.local/share/claudebox` and links `box` onto your
`PATH`. Re-run any time to upgrade. (No `git clone` needed.) `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) ## One-time host setup (Ubuntu 24.04 / Debian 13)
@ -34,7 +46,7 @@ Installs the tree to `~/.local/share/claudebox` and links `claudebox` onto your
Idempotent. Installs Incus and creates the isolation stack: the `claudenet` NAT Idempotent. Installs Incus and creates the isolation stack: the `claudenet` NAT
bridge (sibling-name resolution off, resolver pinned to public upstreams — bridge (sibling-name resolution off, resolver pinned to public upstreams —
`BOX_DNS` overrides), the `claude-isolate` ACL (drops all RFC1918/CGNAT/ `BOX_DNS` overrides), the `claude-isolate` ACL (drops all RFC1918/CGNAT/
link-local egress), the `claude-dev` profile (port-isolated NICs — boxes can't link-local egress), the `box-net` profile (port-isolated NICs — boxes can't
reach each other), and firewall rules blocking instance → host. All rules reach each other), and firewall rules blocking instance → host. All rules
re-apply at boot via `claudebox-firewall.service` — no post-reboot ritual. If re-apply at boot via `claudebox-firewall.service` — no post-reboot ritual. If
the host lacks `dnsmasq-base` (Debian cloud images skip Recommends): the host lacks `dnsmasq-base` (Debian cloud images skip Recommends):
@ -43,8 +55,8 @@ the host lacks `dnsmasq-base` (Debian cloud images skip Recommends):
## Quick start ## Quick start
```sh ```sh
claudebox new --name work # mint a fresh, creds-free box (~10 min cold) box new --name work # mint a fresh, creds-free claude box (~10 min cold)
claudebox shell work # enter as the claude user box shell work # enter as the template's user
``` ```
Inside the box, authenticate as needed: Inside the box, authenticate as needed:
@ -57,69 +69,92 @@ git clone https://github.com/you/project && cd project
claude # if the repo has .claudebox/, Claude reads it and sets up claude # if the repo has .claudebox/, Claude reads it and sets up
``` ```
## Templates
The claude box is one template among several. 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).
```sh
box templates # list what this install can mint
box new --name scratch --template blank # bare Debian: same isolation, no tooling
```
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 ## Log in once, reuse via snapshots
Because every fresh box is creds-free, re-authenticating each time would be Because every fresh box is creds-free, re-authenticating each time would be
toil. Snapshot an authenticated box and clone from it instead: toil. Snapshot an authenticated box and clone from it instead:
```sh ```sh
claudebox snapshot work authed # checkpoint after you've logged in box snapshot work authed # checkpoint after you've logged in
claudebox new --name feature --from work/authed # clone the authed state into a new box 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 `--from` copies the whole box (Claude login, git creds, clones and all) while
preserving isolation. You can also `claudebox new --name x --from work` to clone preserving isolation. You can also `box new --name x --from work` to clone
a box's live state, or roll a box back with `claudebox restore work authed`. a box's live state, or roll a box back with `box restore work authed`.
Forgotten what you called a checkpoint? `claudebox info work` prints the box's Forgotten what you called a checkpoint? `box info work` prints the box's
snapshot labels and the `--from` line to clone one. snapshot labels and the `--from` line to clone one.
## Commands ## Commands
``` ```
claudebox new --name <box> [--from <src>[/<snap>]] [--vm|--container] [--remote r] box new --name <box> [--template <t>] [--from <src>[/<snap>]] [--vm|--container] [--remote r]
claudebox list # list your boxes box templates # list the templates this install can mint
claudebox info <box> # one box: state, IP, snapshot labels box list # list your boxes
claudebox shell <box> # enter as the claude user box info <box> # one box: state, IP, snapshot labels
claudebox exec <box> -- <cmd...> # run a command in the box box shell <box> # enter as the template's user
claudebox tmux <box> [session] # attach/create a tmux session — survives disconnects box exec <box> -- <cmd...> # run a command in the box
claudebox snapshot <box> [label] # checkpoint (label defaults to manual-<epoch>) box tmux <box> [session] # attach/create a tmux session — survives disconnects
claudebox restore <box> <snap> # roll back to a snapshot box snapshot <box> [label] # checkpoint (label defaults to manual-<epoch>)
claudebox rename <box> <new> # rename a box (stop it first) box restore <box> <snap> # roll back to a snapshot
claudebox down <box> # stop (state kept; `start` resumes) box rename <box> <new> # rename a box (stop it first)
claudebox start <box> # start a stopped box box down <box> # stop (state kept; `start` resumes)
claudebox rm <box> [--force] # delete the box + its snapshots (asks first) box start <box> # start a stopped box
claudebox incus <box> -- <args...> # escape hatch: any incus command, box resolved box rm <box> [--force] # delete the box + its snapshots (asks first)
claudebox doctor [--fix|--pin-dns] # is this host fit to mint boxes? diagnose from ground truth box incus <box> -- <args...> # escape hatch: any incus command, box resolved
claudebox status # deprecated alias for `list` box doctor [--fix|--pin-dns] # is this host fit to mint boxes? diagnose from ground truth
claudebox help [<command>] # full help, or one command's page 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 Every command takes `--help`, and options come after the command
(`claudebox list --json`). Exit status: `0` ok, `1` it went wrong, `2` you asked (`box list --json`). Exit status: `0` ok, `1` it went wrong, `2` you asked
wrong. wrong.
`new` fresh-launches from cloud-init, or with `--from` clones an existing box or `new` fresh-launches from a template (default: `claude`), or with `--from`
snapshot. VM mode (`--vm`, the default where `/dev/kvm` exists) is the trust-less clones an existing box or snapshot. VM mode (`--vm`, the default where
target; container mode (auto-fallback, `security.nesting=true`) is for hosts `/dev/kvm` exists) is the trust-less target; container mode (auto-fallback,
without nested virt — weaker isolation, dev/test only. `security.nesting=true`) is for hosts without nested virt — weaker isolation,
dev/test only.
## Boxes are just Incus instances ## Boxes are just Incus instances
A box is an ordinary Incus instance tagged `user.claudebox=1`. claudebox wraps A box is an ordinary Incus instance tagged `user.box=1` (pre-0.4.0 boxes
the box lifecycle and the isolation model — not all of Incus. It owns a command 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, 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 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: creds-free snapshot workflow. For everything else, there's the door:
```sh ```sh
claudebox incus work -- config show # instance name appended box incus work -- config show # instance name appended
claudebox incus work -- file push x.tar {}/tmp/ # or placed with {} 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 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 the command is echoed before it runs. If it can move the box off the isolation
stack (profile, network, device, `security.*`), claudebox warns and proceeds — stack (profile, network, device, `security.*`), box warns and proceeds —
the trust boundary is then yours to keep. See the trust boundary is then yours to keep. See
[docs/claudebox-design.md](docs/claudebox-design.md) for the rule and why the [docs/claudebox-design.md](docs/claudebox-design.md) for the rule and why the
command surface is a table. command surface is a table.
@ -147,8 +182,8 @@ enforces it, layer by layer:
the host's public IPs. Entry is `incus exec` over the local socket only — the host's public IPs. Entry is `incus exec` over the local socket only —
**no inbound path exists.** **no inbound path exists.**
The VM is the trust boundary: Claude can run arbitrary code inside and touch The VM is the trust boundary: whatever runs inside — Claude, or anything a
nothing you care about. template ships — can run arbitrary code and touch nothing you care about.
### Measured, not claimed ### Measured, not claimed
@ -184,7 +219,7 @@ host-executed script. See [docs/claudebox-recipe.md](docs/claudebox-recipe.md).
```sh ```sh
~/.local/share/claudebox/host/teardown-host.sh # boxes, network, ACL, profile, firewall ~/.local/share/claudebox/host/teardown-host.sh # boxes, network, ACL, profile, firewall
~/.local/share/claudebox/host/teardown-host.sh --purge-incus # ...and Incus itself ~/.local/share/claudebox/host/teardown-host.sh --purge-incus # ...and Incus itself
rm -rf ~/.local/share/claudebox ~/.local/bin/claudebox # the CLI rm -rf ~/.local/share/claudebox ~/.local/bin/box # the CLI
``` ```
## Non-goals ## Non-goals

View file

@ -1 +1 @@
0.3.0 0.4.0

View file

@ -1,17 +1,17 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# claudebox — trust-less, isolated Incus VMs with Claude Code, creds-free. # box — trust-less, isolated Incus VMs with Claude Code, creds-free.
# The command surface is the CMDS table below: it is the single source of truth # The command surface is the CMDS table below: it is the single source of truth
# for what exists, what it looks like, what the help says, and what runs. The # for what exists, what it looks like, what the help says, and what runs. The
# help cannot drift from the code, because it is rendered from the same rows. # help cannot drift from the code, because it is rendered from the same rows.
set -euo pipefail set -euo pipefail
root="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/.." && pwd)" root="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/.." && pwd)"
remote=""; mode="auto"; name=""; from=""; force=0; json=0; want_help=0 remote=""; mode="auto"; name=""; from=""; template=""; force=0; json=0; want_help=0
inst="" # the resolved Incus instance, set by the 'box' precondition inst="" # the resolved Incus instance, set by the 'box' precondition
die() { echo "claudebox: $*" >&2; exit 1; } # 1 = it went wrong die() { echo "box: $*" >&2; exit 1; } # 1 = it went wrong
usage_error() { echo "claudebox: $*" >&2; echo "try 'claudebox help'." >&2; exit 2; } # 2 = you asked wrong usage_error() { echo "box: $*" >&2; echo "try 'box help'." >&2; exit 2; } # 2 = you asked wrong
version() { echo "claudebox $(cat "$root/VERSION" 2>/dev/null || echo unknown) ($root)"; } version() { echo "box $(cat "$root/VERSION" 2>/dev/null || echo unknown) ($root)"; }
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# The command table. # The command table.
@ -22,7 +22,7 @@ version() { echo "claudebox $(cat "$root/VERSION" 2>/dev/null || echo unknown) (
# #
# preconditions (comma-separated): # preconditions (comma-separated):
# box first positional is a box: resolve it, and REFUSE if the instance # box first positional is a box: resolve it, and REFUSE if the instance
# isn't tagged user.claudebox=1 — the boundary, enforced, not assumed # isn't tagged user.box=1 (or the legacy user.claudebox=1) — the boundary, enforced, not assumed
# arg2 a second positional is required # arg2 a second positional is required
# stopped the box must not be running # stopped the box must not be running
# confirm destructive: prompt unless --force # confirm destructive: prompt unless --force
@ -34,13 +34,14 @@ version() { echo "claudebox $(cat "$root/VERSION" 2>/dev/null || echo unknown) (
# ok message: printed on success; {} = the box, {1} = the second positional. # ok message: printed on success; {} = the box, {1} = the second positional.
# #
# Adding a thin verb is one row. If a request can't be expressed as a row and # Adding a thin verb is one row. If a request can't be expressed as a row and
# doesn't enforce a claudebox invariant, it is incus's job, not ours — that is # doesn't enforce a box invariant, it is incus's job, not ours — that is
# what `claudebox incus` is for. # what `box incus` is for.
CMDS=( CMDS=(
"new^--name <box> [--from <src>[/<snap>]] [--vm|--container]^^Mint a box: fresh from cloud-init, or --from an existing box/snapshot^fn:cmd_new^" "new^--name <box> [--template <t>] [--from <src>[/<snap>]] [--vm|--container]^^Mint a box from a template (default: claude), or --from an existing box/snapshot^fn:cmd_new^"
"templates^^^List the templates this install can mint^fn:cmd_templates^"
"list^[--json]^^List your boxes^fn:cmd_list^" "list^[--json]^^List your boxes^fn:cmd_list^"
"info^<box> [--json]^box^One box: state, type, IP, and its snapshot labels^fn:cmd_info^" "info^<box> [--json]^box^One box: state, type, IP, and its snapshot labels^fn:cmd_info^"
"shell^<box>^box^Open a shell in a box, as the claude user^fn:cmd_shell^" "shell^<box>^box^Open a shell in a box, as its template's user^fn:cmd_shell^"
"exec^<box> -- <cmd...>^box^Run a command inside a box^fn:cmd_exec^" "exec^<box> -- <cmd...>^box^Run a command inside a box^fn:cmd_exec^"
"tmux^<box> [<session>]^box^Attach or create a tmux session in a box — survives disconnects^fn:cmd_tmux^" "tmux^<box> [<session>]^box^Attach or create a tmux session in a box — survives disconnects^fn:cmd_tmux^"
"snapshot^<box> [<label>]^box^Checkpoint a box (label defaults to manual-<epoch>)^fn:cmd_snapshot^" "snapshot^<box> [<label>]^box^Checkpoint a box (label defaults to manual-<epoch>)^fn:cmd_snapshot^"
@ -52,7 +53,7 @@ CMDS=(
"incus^<box> -- <args...>^box^Escape hatch: run any incus command against a box^fn:cmd_incus^" "incus^<box> -- <args...>^box^Escape hatch: run any incus command against a box^fn:cmd_incus^"
"doctor^[--fix | --pin-dns]^^Is this host fit to mint boxes? Diagnose the daemon, network, DNS, isolation^fn:cmd_doctor^" "doctor^[--fix | --pin-dns]^^Is this host fit to mint boxes? Diagnose the daemon, network, DNS, isolation^fn:cmd_doctor^"
"status^^^Deprecated alias for 'list'^fn:cmd_status^" "status^^^Deprecated alias for 'list'^fn:cmd_status^"
"help^[<command>]^^This help, or 'claudebox help <command>' for one command^fn:cmd_help^" "help^[<command>]^^This help, or 'box help <command>' for one command^fn:cmd_help^"
) )
cmd_row() { local r; for r in "${CMDS[@]}"; do case "$r" in "$1^"*) echo "$r"; return 0 ;; esac; done; return 1; } cmd_row() { local r; for r in "${CMDS[@]}"; do case "$r" in "$1^"*) echo "$r"; return 0 ;; esac; done; return 1; }
@ -69,7 +70,7 @@ field() {
act) echo "$f_act" ;; ok) echo "$f_ok" ;; act) echo "$f_act" ;; ok) echo "$f_ok" ;;
esac esac
} }
synopsis_of() { local s; s="$(field "$1" syn)"; echo "claudebox $1${s:+ $s}"; } synopsis_of() { local s; s="$(field "$1" syn)"; echo "box $1${s:+ $s}"; }
# Nearest command by edit distance — a typo should point somewhere, not just fail. # Nearest command by edit distance — a typo should point somewhere, not just fail.
suggest() { suggest() {
@ -97,20 +98,20 @@ suggest() {
unknown_command() { unknown_command() {
local hint; hint="$(suggest "$1")" local hint; hint="$(suggest "$1")"
if [ -n "$hint" ]; then if [ -n "$hint" ]; then
echo "claudebox: unknown command: $1 — did you mean '$hint'?" >&2 echo "box: unknown command: $1 — did you mean '$hint'?" >&2
else else
echo "claudebox: unknown command: $1" >&2 echo "box: unknown command: $1" >&2
fi fi
echo "try 'claudebox help' for the command list." >&2 echo "try 'box help' for the command list." >&2
exit 2 exit 2
} }
usage() { usage() {
cat <<'EOF' cat <<'EOF'
claudebox — trust-less, network-isolated Incus VMs with Claude Code, creds-free. box — trust-less, network-isolated Incus VMs with Claude Code, creds-free.
USAGE USAGE
claudebox <command> [<args>] [options] box <command> [<args>] [options]
COMMANDS COMMANDS
EOF EOF
@ -123,6 +124,7 @@ EOF
OPTIONS OPTIONS
--name <box> Name for the new box (new) --name <box> Name for the new box (new)
--template <t> Template to mint from (default: claude) (new)
--from <src>[/<snap>] Clone from box <src>, or from its snapshot (new) --from <src>[/<snap>] Clone from box <src>, or from its snapshot (new)
--vm Force VM mode: the trust-less target (new) --vm Force VM mode: the trust-less target (new)
--container Force container mode: weaker isolation, (new) --container Force container mode: weaker isolation, (new)
@ -131,28 +133,32 @@ OPTIONS
--force, -f Delete without the confirmation prompt (rm) --force, -f Delete without the confirmation prompt (rm)
--remote <r> Act on Incus remote <r> (any) --remote <r> Act on Incus remote <r> (any)
--help, -h Help; after a command, help for that command --help, -h Help; after a command, help for that command
--version, -V Print the claudebox version --version, -V Print the box version
Options come after the command: 'claudebox list --json', not 'claudebox --json list'. Options come after the command: 'box list --json', not 'box --json list'.
EXAMPLES EXAMPLES
# mint a box and log in inside it — the tool never handles your token # mint a box and log in inside it — the tool never handles your token
claudebox new --name work box new --name work
claudebox shell work # then: run 'claude', then /login box shell work # then: run 'claude', then /login
# log in once, reuse forever: checkpoint the authed box, clone from it # log in once, reuse forever: checkpoint the authed box, clone from it
claudebox snapshot work authed box snapshot work authed
claudebox new --name feature --from work/authed box new --name feature --from work/authed
# a box with nobody home — same isolation, no Claude
box new --name scratch --template blank
box templates
# what have I got, and what can I clone? # what have I got, and what can I clone?
claudebox list box list
claudebox info work box info work
# run something without opening a shell # run something without opening a shell
claudebox exec work -- git -C project pull box exec work -- git -C project pull
# anything claudebox doesn't wrap: boxes are plain Incus instances # anything box doesn't wrap: boxes are plain Incus instances
claudebox incus work -- config show box incus work -- config show
EXIT STATUS EXIT STATUS
0 ok 0 ok
@ -161,15 +167,15 @@ EXIT STATUS
THE MODEL THE MODEL
A box carries NO credentials. You authenticate interactively inside it A box carries NO credentials. You authenticate interactively inside it
('claude' then /login; 'gh auth login'); claudebox never stores or injects a ('claude' then /login; 'gh auth login'); box never stores or injects a
secret. A box reaches the public internet and nothing else — there is no secret. A box reaches the public internet and nothing else — there is no
inbound path. Destroying a box loses nothing you didn't push. inbound path. Destroying a box loses nothing you didn't push.
claudebox owns a command when it must enforce something Incus cannot see: the box owns a command when it must enforce something Incus cannot see: the
user.claudebox=1 boundary, the isolation stack, or the creds-free snapshot user.box=1 boundary, the isolation stack, or the creds-free snapshot
workflow. Everything else is Incus's job — and 'claudebox incus' is the door. workflow. Everything else is Incus's job — and 'box incus' is the door.
Docs: https://github.com/heavy-duty/claudebox Docs: https://github.com/heavy-duty/box
EOF EOF
} }
@ -180,29 +186,50 @@ help_cmd() {
echo echo
case "$1" in case "$1" in
new) cat <<'EOF' new) cat <<'EOF'
Mint a box. Without --from, launches a fresh Debian 13 box from cloud-init Mint a box. Without --from, launches a fresh box from a template (default:
(~10 min cold) with Claude Code installed and NO credentials. With --from, claude — Debian 13 with Claude Code installed and NO credentials, ~10 min
clones an existing box or one of its snapshots — Claude login, git creds and cold). With --from, clones an existing box or one of its snapshots — login
clones carry over, isolation is preserved. state, git creds and clones carry over, isolation is preserved, and the
clone knows its template's user without being told.
--name <box> Required. The box's name. --name <box> Required. The box's name.
--template <t> Template to mint from; 'box templates' lists them.
A template sets image, user and resources — never
the network: every template gets the same isolation.
--from <src>[/<snap>] Clone src's live state, or its snapshot <snap>. --from <src>[/<snap>] Clone src's live state, or its snapshot <snap>.
--vm | --container Force the mode. VM is the trust boundary and the --vm | --container Force the mode. VM is the trust boundary and the
default wherever /dev/kvm exists; container mode default wherever /dev/kvm exists; container mode
(security.nesting=true) is the fallback for hosts (security.nesting=true) is the fallback for hosts
without nested virt — weaker isolation, dev/test only. without nested virt — weaker isolation, dev/test only.
claudebox new --name work Resources come from the template's box.env; BOX_CPU / BOX_MEMORY / BOX_DISK
claudebox new --name feature --from work/authed environment variables override them at mint time (a small host shrinks a box
without editing a template it doesn't own).
box new --name work
box new --name scratch --template blank
box new --name feature --from work/authed
EOF
;;
templates) cat <<'EOF'
List the templates this install can mint, with their descriptions. A template
is a directory under templates/: a box.env (image, user, resources — parsed
against an allowlist, never sourced) and a user-data.yaml (cloud-init, passed
to Incus verbatim). Templates cannot touch the network or security flags —
the shared box-net profile is the placement contract, so every template gets
the same isolation.
box templates
box new --name scratch --template blank
EOF EOF
;; ;;
list) cat <<'EOF' list) cat <<'EOF'
List the boxes claudebox minted on this host: name, state, type, snapshot count. List the boxes box minted on this host: name, state, type, snapshot count.
Takes no box — for one box, that's 'claudebox info <box>'. Takes no box — for one box, that's 'box info <box>'.
--json Incus's JSON, straight through, for scripting. --json Incus's JSON, straight through, for scripting.
claudebox list box list
EOF EOF
;; ;;
info) cat <<'EOF' info) cat <<'EOF'
@ -211,23 +238,23 @@ labels of its snapshots, with the --from line to clone one.
--json Incus's JSON, straight through, for scripting. --json Incus's JSON, straight through, for scripting.
claudebox info work box info work
EOF EOF
;; ;;
shell) cat <<'EOF' shell) cat <<'EOF'
Open an interactive shell in a running box as the 'claude' user. This is the Open an interactive shell in a running box as the 'claude' user. This is the
only entry path — there is no SSH and no inbound route to a box. only entry path — there is no SSH and no inbound route to a box.
claudebox shell work box shell work
EOF EOF
;; ;;
exec) cat <<'EOF' exec) cat <<'EOF'
Run a command inside a box as the 'claude' user. Everything after -- is passed Run a command inside a box as the 'claude' user. Everything after -- is passed
through untouched; the -- is required, or claudebox will read your command's through untouched; the -- is required, or box will read your command's
flags as its own. flags as its own.
claudebox exec work -- git -C project pull box exec work -- git -C project pull
claudebox exec work -- claude --version box exec work -- claude --version
EOF EOF
;; ;;
tmux) cat <<'EOF' tmux) cat <<'EOF'
@ -240,35 +267,35 @@ remember.
The session name (default: main) buys parallel streams in one box: The session name (default: main) buys parallel streams in one box:
claudebox tmux work # attach or create 'main' box tmux work # attach or create 'main'
claudebox tmux work run-1 # a second, independent stream, same box box tmux work run-1 # a second, independent stream, same box
Detach with Ctrl-b d; 'exit' ends the session. For a plain shell with none of Detach with Ctrl-b d; 'exit' ends the session. For a plain shell with none of
tmux's semantics, 'claudebox shell' is unchanged. tmux's semantics, 'box shell' is unchanged.
EOF EOF
;; ;;
snapshot) cat <<'EOF' snapshot) cat <<'EOF'
Checkpoint a box. Snapshots are how an authenticated box is reused: log in Checkpoint a box. Snapshots are how an authenticated box is reused: log in
once, snapshot, then 'new --from <box>/<label>' as often as you like. The once, snapshot, then 'new --from <box>/<label>' as often as you like. The
label defaults to manual-<epoch>; 'claudebox info <box>' shows the labels you label defaults to manual-<epoch>; 'box info <box>' shows the labels you
have. have.
claudebox snapshot work authed box snapshot work authed
EOF EOF
;; ;;
restore) cat <<'EOF' restore) cat <<'EOF'
Roll a box back to one of its snapshots, in place. Anything in the box since Roll a box back to one of its snapshots, in place. Anything in the box since
that snapshot is lost. 'claudebox info <box>' lists the labels. that snapshot is lost. 'box info <box>' lists the labels.
claudebox restore work authed box restore work authed
EOF EOF
;; ;;
rename) cat <<'EOF' rename) cat <<'EOF'
Rename a box. Incus cannot rename a running instance, so stop it first: Rename a box. Incus cannot rename a running instance, so stop it first:
claudebox down work box down work
claudebox rename work archive box rename work archive
claudebox start archive box start archive
Snapshots and Claude auth follow the box; anything referring to the old name by Snapshots and Claude auth follow the box; anything referring to the old name by
hand (a --from line, a script) does not. hand (a --from line, a script) does not.
@ -279,26 +306,26 @@ Delete a box and every snapshot it has. This cannot be undone, so it asks for
confirmation first; --force (-f) skips the prompt. With no TTY to confirm on confirmation first; --force (-f) skips the prompt. With no TTY to confirm on
(a script, a pipe), it refuses unless --force is given. (a script, a pipe), it refuses unless --force is given.
claudebox rm work box rm work
claudebox rm work --force box rm work --force
EOF EOF
;; ;;
incus) cat <<'EOF' incus) cat <<'EOF'
The door out. claudebox wraps the box lifecycle and the isolation model, not The door out. box wraps the box lifecycle and the isolation model, not
all of Incus — so when you need something it doesn't wrap, run Incus through all of Incus — so when you need something it doesn't wrap, run Incus through
here and keep the safety rail that matters: the box name is resolved and here and keep the safety rail that matters: the box name is resolved and
checked against the user.claudebox=1 tag, so you cannot aim it at an instance checked against the user.box=1 tag (or its legacy spelling), so you cannot aim it at an instance
claudebox didn't mint. box didn't mint.
Everything after -- is passed to incus verbatim. A literal {} is replaced with Everything after -- is passed to incus verbatim. A literal {} is replaced with
the resolved instance name; with no {}, the instance is appended at the end. the resolved instance name; with no {}, the instance is appended at the end.
The command that will run is echoed before it runs. The command that will run is echoed before it runs.
claudebox incus work -- config show box incus work -- config show
claudebox incus work -- config device add {} extra disk source=/data path=/data box incus work -- config device add {} extra disk source=/data path=/data
Changing the profile, the network, a device or a security.* key can take a box Changing the profile, the network, a device or a security.* key can take a box
outside the isolation stack. claudebox warns and then does as you asked — from outside the isolation stack. box warns and then does as you asked — from
there, the trust boundary is yours to keep. there, the trust boundary is yours to keep.
EOF EOF
;; ;;
@ -314,15 +341,15 @@ cloud-init error that names none of them.
--pin-dns pin claudenet's resolver to public upstreams and re-test --pin-dns pin claudenet's resolver to public upstreams and re-test
(setup-host.sh now pins by default; this is the quick test) (setup-host.sh now pins by default; this is the quick test)
claudebox doctor box doctor
claudebox doctor --fix box doctor --fix
Exit 0 = clean; 1 = problems found (each printed with its fix). Read-only Exit 0 = clean; 1 = problems found (each printed with its fix). Read-only
unless --fix or --pin-dns is given. unless --fix or --pin-dns is given.
EOF EOF
;; ;;
status) cat <<'EOF' status) cat <<'EOF'
Deprecated alias for 'claudebox list'. It ignored the <box> argument it Deprecated alias for 'box list'. It ignored the <box> argument it
advertised, so it was split into 'list' (all boxes) and 'info <box>' (one). It advertised, so it was split into 'list' (all boxes) and 'info <box>' (one). It
still works, and forwards to 'list'. still works, and forwards to 'list'.
EOF EOF
@ -330,8 +357,8 @@ EOF
help) cat <<'EOF' help) cat <<'EOF'
Print the general help, or the help for one command. Print the general help, or the help for one command.
claudebox help box help
claudebox help rename box help rename
EOF EOF
;; ;;
*) field "$1" sum ;; # no prose: the table's summary is the help *) field "$1" sum ;; # no prose: the table's summary is the help
@ -349,7 +376,7 @@ cmd="${1:-help}"; shift || true
case "$cmd" in case "$cmd" in
-h|--help) usage; exit 0 ;; -h|--help) usage; exit 0 ;;
-V|--version) version; exit 0 ;; -V|--version) version; exit 0 ;;
-*) usage_error "options come after the command — try 'claudebox <command> $cmd ...'" ;; -*) usage_error "options come after the command — try 'box <command> $cmd ...'" ;;
esac esac
args=() args=()
@ -357,6 +384,7 @@ while [ $# -gt 0 ]; do
case "$1" in case "$1" in
--name) [ $# -ge 2 ] || usage_error "--name needs a value"; name="$2"; shift 2 ;; --name) [ $# -ge 2 ] || usage_error "--name needs a value"; name="$2"; shift 2 ;;
--from) [ $# -ge 2 ] || usage_error "--from needs a value"; from="$2"; shift 2 ;; --from) [ $# -ge 2 ] || usage_error "--from needs a value"; from="$2"; shift 2 ;;
--template) [ $# -ge 2 ] || usage_error "--template needs a value"; template="$2"; shift 2 ;;
--remote) [ $# -ge 2 ] || usage_error "--remote needs a value"; remote="$2:"; shift 2 ;; --remote) [ $# -ge 2 ] || usage_error "--remote needs a value"; remote="$2:"; shift 2 ;;
--vm) mode=vm; shift ;; --vm) mode=vm; shift ;;
--container) mode=container; shift ;; --container) mode=container; shift ;;
@ -368,12 +396,12 @@ while [ $# -gt 0 ]; do
# An unrecognized flag used to be swallowed as a positional — so a typo'd # An unrecognized flag used to be swallowed as a positional — so a typo'd
# --labl silently became a snapshot's label. Say so instead. # --labl silently became a snapshot's label. Say so instead.
-*) -*)
# doctor's flags belong to the doctor script, not to claudebox # doctor's flags belong to the doctor script, not to box
if [ "$cmd" = doctor ]; then args+=("$1"); shift; continue; fi if [ "$cmd" = doctor ]; then args+=("$1"); shift; continue; fi
if [ "$cmd" = exec ] || [ "$cmd" = incus ]; then if [ "$cmd" = exec ] || [ "$cmd" = incus ]; then
usage_error "unknown option: $1 — a command's own flags go after --, as in '$(synopsis_of "$cmd")'" usage_error "unknown option: $1 — a command's own flags go after --, as in '$(synopsis_of "$cmd")'"
fi fi
usage_error "unknown option: $1 (see 'claudebox help $cmd')" ;; usage_error "unknown option: $1 (see 'box help $cmd')" ;;
*) args+=("$1"); shift ;; *) args+=("$1"); shift ;;
esac esac
done done
@ -385,12 +413,16 @@ if [ "$want_help" -eq 1 ]; then show_help "$cmd"; exit 0; fi
iname_of() { echo "$remote$1"; } # instance name = box name iname_of() { echo "$remote$1"; } # instance name = box name
# The boundary, enforced: a box is an Incus instance WE tagged. Anything else is # The boundary, enforced: a box is an Incus instance WE tagged. Anything else is
# somebody's VM, and claudebox will not stop, rename or delete it by accident. # somebody's VM, and box will not stop, rename or delete it by accident.
resolve_box() { resolve_box() {
local box="$1" i tag local box="$1" i tag
i="$(iname_of "$box")" i="$(iname_of "$box")"
tag="$(incus config get "$i" user.claudebox 2>/dev/null || true)" tag="$(incus config get "$i" user.box 2>/dev/null || true)"
[ "$tag" = "1" ] || die "no such box: $box (see 'claudebox list')" # A pre-rename box carries user.claudebox=1 and nothing else. Snapshots of
# old boxes outlive the release that minted them — the legacy tag is honored
# forever, or an old box stops being a box at all.
[ "$tag" = "1" ] || tag="$(incus config get "$i" user.claudebox 2>/dev/null || true)"
[ "$tag" = "1" ] || die "no such box: $box (see 'box list')"
echo "$i" echo "$i"
} }
@ -400,7 +432,7 @@ require_stopped() {
local i="$1" box="$2" st; st="$(box_state "$i")" local i="$1" box="$2" st; st="$(box_state "$i")"
case "$st" in case "$st" in
STOPPED|Stopped|stopped) return 0 ;; STOPPED|Stopped|stopped) return 0 ;;
*) die "box '$box' is ${st:-not stopped} — Incus needs it stopped for this. Stop it: claudebox down $box" ;; *) die "box '$box' is ${st:-not stopped} — Incus needs it stopped for this. Stop it: box down $box" ;;
esac esac
} }
@ -420,7 +452,7 @@ confirm() { # $1 = prompt. --force, or a TTY to ask on, or we refuse.
if [ "$force" -eq 1 ]; then return 0; fi if [ "$force" -eq 1 ]; then return 0; fi
[ -t 0 ] || usage_error "refusing to $1 without --force (no terminal to confirm on)" [ -t 0 ] || usage_error "refusing to $1 without --force (no terminal to confirm on)"
local reply local reply
printf 'claudebox: %s? this cannot be undone. [y/N] ' "$1" printf 'box: %s? this cannot be undone. [y/N] ' "$1"
read -r reply read -r reply
case "$reply" in y|Y|yes|YES|Yes) return 0 ;; *) die "aborted." ;; esac case "$reply" in y|Y|yes|YES|Yes) return 0 ;; *) die "aborted." ;; esac
} }
@ -430,14 +462,14 @@ confirm() { # $1 = prompt. --force, or a TTY to ask on, or we refuse.
pick_mode() { pick_mode() {
if [ "$mode" != auto ]; then echo "$mode"; return; fi if [ "$mode" != auto ]; then echo "$mode"; return; fi
if [ -n "$remote" ] || [ -e /dev/kvm ]; then echo vm; else if [ -n "$remote" ] || [ -e /dev/kvm ]; then echo vm; else
echo "claudebox: no /dev/kvm — using container mode (weaker isolation, dev/test only)" >&2 echo "box: no /dev/kvm — using container mode (weaker isolation, dev/test only)" >&2
echo container echo container
fi fi
} }
wait_agent() { wait_agent() {
local n="$1" i local n="$1" i
echo "claudebox: waiting for instance agent..." echo "box: waiting for instance agent..."
for i in $(seq 1 90); do for i in $(seq 1 90); do
if incus exec "$n" -- true >/dev/null 2>&1; then return; fi if incus exec "$n" -- true >/dev/null 2>&1; then return; fi
[ "$i" -eq 90 ] && die "instance agent never came up (incus console $n to inspect)" [ "$i" -eq 90 ] && die "instance agent never came up (incus console $n to inspect)"
@ -449,14 +481,14 @@ wait_agent() {
# rides along inside the disk — and systemd derives its DHCP client identifier # rides along inside the disk — and systemd derives its DHCP client identifier
# (DUID) from it. Same client-id, same dnsmasq lease: two boxes, one IP address, # (DUID) from it. Same client-id, same dnsmasq lease: two boxes, one IP address,
# to the second on the lease timer. Every box cloned from one snapshot collided # to the second on the lease timer. Every box cloned from one snapshot collided
# on the network, which is exactly the workflow claudebox exists for (log in # on the network, which is exactly the workflow box exists for (log in
# once, snapshot, clone forever). # once, snapshot, clone forever).
# #
# Truncating /etc/machine-id makes systemd mint a fresh one on the next boot, so # Truncating /etc/machine-id makes systemd mint a fresh one on the next boot, so
# the reset costs one reboot. Do it before handing the box over, never after. # the reset costs one reboot. Do it before handing the box over, never after.
reset_identity() { reset_identity() {
local i="$1" local i="$1"
echo "claudebox: giving the clone its own identity (machine-id, DHCP lease)..." echo "box: giving the clone its own identity (machine-id, DHCP lease)..."
# Do NOT truncate machine-id and reboot: systemd needs a valid one to shut # Do NOT truncate machine-id and reboot: systemd needs a valid one to shut
# down cleanly, so the graceful stop hangs and the reboot never happens — # down cleanly, so the graceful stop hangs and the reboot never happens —
# leaving the clone on its source's identity, which is the bug we are here to # leaving the clone on its source's identity, which is the bug we are here to
@ -475,10 +507,60 @@ reset_identity() {
wait_agent "$i" wait_agent "$i"
} }
# Templates set image, user, resources and cloud-init — NOTHING else. The
# box.env file is parsed against this allowlist, never sourced: sourcing would
# hand every template arbitrary bash execution on the HOST at mint time. And
# there is deliberately no key for a network or a security flag — the shared
# box-net profile is the placement contract, so no template can weaken
# isolation. 'blank' is a box with nobody home, not a box with the safety off.
load_template() {
local t="$1" dir line key val
dir="$root/templates/$t"
[ -d "$dir" ] || die "no such template: $t (see 'box templates')"
[ -f "$dir/box.env" ] || die "template '$t' has no box.env"
T_DESC=""; T_IMAGE=""; T_USER=""; T_CPU=""; T_MEMORY=""; T_DISK=""
while IFS= read -r line || [ -n "$line" ]; do
case "$line" in ''|\#*) continue ;; esac
case "$line" in
*=*) key="${line%%=*}"; val="${line#*=}" ;;
*) die "template '$t': not a KEY=\"value\" line: $line" ;;
esac
val="${val#\"}"; val="${val%\"}"
case "$key" in
BOX_DESCRIPTION) T_DESC="$val" ;;
BOX_IMAGE) T_IMAGE="$val" ;;
BOX_USER) T_USER="$val" ;;
BOX_CPU) T_CPU="$val" ;;
BOX_MEMORY) T_MEMORY="$val" ;;
BOX_DISK) T_DISK="$val" ;;
*) die "template '$t': unknown key '$key' — a template sets image, user and resources, nothing else (there is no key for a network, on purpose)" ;;
esac
done <"$dir/box.env"
[ -n "$T_IMAGE" ] && [ -n "$T_USER" ] || die "template '$t': BOX_IMAGE and BOX_USER are required"
# Environment overrides beat the file — this is how a small host (or the
# drill) shrinks a box without editing a template it doesn't own.
T_CPU="${BOX_CPU:-${T_CPU:-4}}"
T_MEMORY="${BOX_MEMORY:-${T_MEMORY:-8GiB}}"
T_DISK="${BOX_DISK:-${T_DISK:-60GiB}}"
}
cmd_templates() {
local d t desc
echo "TEMPLATES"
for d in "$root/templates"/*/; do
t="$(basename "$d")"
desc="$(grep -m1 '^BOX_DESCRIPTION=' "$d/box.env" 2>/dev/null | cut -d= -f2- | tr -d '"')"
printf ' %-10s %s\n' "$t" "$desc"
done
echo
echo "mint one: box new --name <box> --template <template>"
}
cmd_new() { cmd_new() {
[ -n "$name" ] || usage_error "usage: $(synopsis_of new)" [ -n "$name" ] || usage_error "usage: $(synopsis_of new)"
local instance; instance="$(iname_of "$name")" local instance; instance="$(iname_of "$name")"
if [ -n "$from" ]; then if [ -n "$from" ]; then
[ -z "$template" ] || usage_error "--from clones an existing box; its template rides along (drop --template)"
local src="${from%%/*}" snap="" srcref local src="${from%%/*}" snap="" srcref
case "$from" in */*) snap="${from#*/}" ;; esac case "$from" in */*) snap="${from#*/}" ;; esac
srcref="$(iname_of "$src")"; [ -n "$snap" ] && srcref="$srcref/$snap" srcref="$(iname_of "$src")"; [ -n "$snap" ] && srcref="$srcref/$snap"
@ -486,49 +568,67 @@ cmd_new() {
incus start "$instance" incus start "$instance"
wait_agent "$instance" wait_agent "$instance"
reset_identity "$instance" reset_identity "$instance"
echo "claudebox: cloned $srcref — isolation and Claude auth carry over from the source." echo "box: cloned $srcref — isolation and auth state carry over from the source."
else else
local m extra=(); m="$(pick_mode)" local t="${template:-claude}" m extra=()
# shellcheck disable=SC2054 # "root,size=60GiB" is a single incus argument load_template "$t"
if [ "$m" = vm ]; then extra+=(--vm --device root,size=60GiB); else extra+=(--config security.nesting=true); fi m="$(pick_mode)"
incus launch images:debian/13/cloud "$instance" --profile claude-dev \ # shellcheck disable=SC2054 # "root,size=..." is a single incus argument
--config user.claudebox=1 \ if [ "$m" = vm ]; then extra+=(--vm --device "root,size=$T_DISK"); else extra+=(--config security.nesting=true); fi
--config cloud-init.user-data="$(cat "$root/cloud-init/user-data.yaml")" \ # The template's identity is stamped ONTO the instance: which template,
# which user. 'incus copy' preserves user.* keys (audit B2), so a clone
# knows what it is without ever consulting the template again.
incus launch "$T_IMAGE" "$instance" --profile box-net \
--config user.box=1 \
--config user.box.template="$t" \
--config user.box.user="$T_USER" \
--config limits.cpu="$T_CPU" \
--config limits.memory="$T_MEMORY" \
--config cloud-init.user-data="$(cat "$root/templates/$t/user-data.yaml")" \
"${extra[@]}" "${extra[@]}"
wait_agent "$instance" wait_agent "$instance"
echo "claudebox: waiting for phase-1 (cloud-init)..." echo "box: waiting for phase-1 (cloud-init)..."
# A failed cloud-init used to print a screen of dots and the word "error", # A failed cloud-init used to print a screen of dots and the word "error",
# with nothing to act on — the box's own log holds the reason, and nobody # with nothing to act on — the box's own log holds the reason, and nobody
# was told it existed. Show it, and leave the box up to inspect. # was told it existed. Show it, and leave the box up to inspect.
if ! incus exec "$instance" -- cloud-init status --wait; then if ! incus exec "$instance" -- cloud-init status --wait; then
echo >&2 echo >&2
echo "claudebox: cloud-init FAILED in $name. What it says:" >&2 echo "box: cloud-init FAILED in $name. What it says:" >&2
incus exec "$instance" -- cloud-init status --long 2>&1 | sed 's/^/ /' >&2 incus exec "$instance" -- cloud-init status --long 2>&1 | sed 's/^/ /' >&2
echo >&2 echo >&2
echo "claudebox: the errors, from the box's log:" >&2 echo "box: the errors, from the box's log:" >&2
incus exec "$instance" -- sh -c \ incus exec "$instance" -- sh -c \
"grep -iE '^(E:|Err:)|Temporary failure|Could not resolve|Unable to fetch' /var/log/cloud-init-output.log | tail -8" \ "grep -iE '^(E:|Err:)|Temporary failure|Could not resolve|Unable to fetch' /var/log/cloud-init-output.log | tail -8" \
2>/dev/null | sed 's/^/ /' >&2 2>/dev/null | sed 's/^/ /' >&2
echo >&2 echo >&2
echo "claudebox: '$name' is still up — inspect it, then delete it:" >&2 echo "box: '$name' is still up — inspect it, then delete it:" >&2
echo " claudebox incus $name -- exec {} -- tail -50 /var/log/cloud-init-output.log" >&2 echo " box incus $name -- exec {} -- tail -50 /var/log/cloud-init-output.log" >&2
echo " claudebox rm $name" >&2 echo " box rm $name" >&2
echo "claudebox: a failed mint is usually the HOST's fault (a wedged daemon, a dnsmasq" >&2 echo "box: a failed mint is usually the HOST's fault (a wedged daemon, a dnsmasq" >&2
echo " not serving, a VPN resolver the box inherits). Diagnose it: claudebox doctor" >&2 echo " not serving, a VPN resolver the box inherits). Diagnose it: box doctor" >&2
die "cloud-init failed — the box is incomplete, so refusing to hand it over" die "cloud-init failed — the box is incomplete, so refusing to hand it over"
fi fi
fi fi
echo "claudebox: ready — 'claudebox shell $name'. Log into Claude inside: run 'claude' then /login." if [ -z "$from" ] && [ "${template:-claude}" != claude ]; then
echo "box: ready — 'box shell $name'."
else
echo "box: ready — 'box shell $name'. Log into Claude inside: run 'claude' then /login."
fi
} }
# Boxes are ordinary Incus instances tagged user.claudebox=1 — that tag is the # Boxes are ordinary Incus instances tagged user.box=1 — that tag is the only
# only thing that makes them ours, so every read below is filtered by it and we # thing that makes them ours, so every read below is filtered by it and we
# never report on (or touch) an instance claudebox didn't mint. # never report on (or touch) an instance box didn't mint. Pre-rename boxes
# carry user.claudebox=1 instead and are ours forever; a box can't hold both
# tags via any path we mint, but the dedupe costs nothing.
# Emits: name,state,type,snapshot-count — none of which can contain a comma or a # Emits: name,state,type,snapshot-count — none of which can contain a comma or a
# newline, so a plain -F, split is safe. (IPv4 can: a box running docker has # newline, so a plain -F, split is safe. (IPv4 can: a box running docker has
# several addresses and Incus quotes them across lines. It's fetched separately.) # several addresses and Incus quotes them across lines. It's fetched separately.)
boxes_csv() { boxes_csv() {
incus list ${remote:+"$remote"} "user.claudebox=1" --format csv --columns nstS {
incus list ${remote:+"$remote"} "user.box=1" --format csv --columns nstS
incus list ${remote:+"$remote"} "user.claudebox=1" --format csv --columns nstS
} 2>/dev/null | awk -F, '!seen[$1]++'
} }
box_ipv4() { # first address only; strips Incus's " (iface)" suffix. "-" if none. box_ipv4() { # first address only; strips Incus's " (iface)" suffix. "-" if none.
@ -549,7 +649,7 @@ short_type() {
list_all() { list_all() {
local rows; rows="$(boxes_csv)" local rows; rows="$(boxes_csv)"
if [ -z "$rows" ]; then if [ -z "$rows" ]; then
echo "claudebox: no boxes yet — create one with: claudebox new --name work" >&2 echo "box: no boxes yet — create one with: box new --name work" >&2
return 0 return 0
fi fi
{ {
@ -569,10 +669,10 @@ list_all() {
# guess we can answer, not a surprise: point at the command that does want one. # guess we can answer, not a surprise: point at the command that does want one.
cmd_list() { cmd_list() {
if [ "${#args[@]}" -ge 1 ] && [ -n "${args[0]}" ]; then if [ "${#args[@]}" -ge 1 ] && [ -n "${args[0]}" ]; then
die "list takes no box — for one box, use: claudebox info ${args[0]}" die "list takes no box — for one box, use: box info ${args[0]}"
fi fi
if [ "$json" -eq 1 ]; then if [ "$json" -eq 1 ]; then
incus list ${remote:+"$remote"} "user.claudebox=1" --format json incus list ${remote:+"$remote"} "user.box=1" --format json
else else
list_all list_all
fi fi
@ -583,7 +683,7 @@ cmd_info() {
if [ "$json" -eq 1 ]; then incus list "$inst" --format json; return; fi if [ "$json" -eq 1 ]; then incus list "$inst" --format json; return; fi
row="$(boxes_csv | awk -F, -v b="$box" '$1 == b { print; exit }')" row="$(boxes_csv | awk -F, -v b="$box" '$1 == b { print; exit }')"
[ -n "$row" ] || die "no such box: $box (see 'claudebox list')" [ -n "$row" ] || die "no such box: $box (see 'box list')"
local state type snaps local state type snaps
IFS=, read -r _ state type snaps <<<"$row" IFS=, read -r _ state type snaps <<<"$row"
@ -595,7 +695,7 @@ cmd_info() {
''|0) ''|0)
echo "SNAPSHOTS (none)" echo "SNAPSHOTS (none)"
echo echo
echo "Take one: claudebox snapshot $box authed" echo "Take one: box snapshot $box authed"
return 0 ;; return 0 ;;
esac esac
echo "SNAPSHOTS" echo "SNAPSHOTS"
@ -606,11 +706,28 @@ cmd_info() {
printf ' %-14s%s\n' "$sname" "$taken" printf ' %-14s%s\n' "$sname" "$taken"
done < <(incus snapshot list "$inst" --format csv 2>/dev/null) done < <(incus snapshot list "$inst" --format csv 2>/dev/null)
echo echo
echo "Clone one: claudebox new --name <new> --from $box/${first:-<snapshot>}" echo "Clone one: box new --name <new> --from $box/${first:-<snapshot>}"
} }
cmd_shell() { incus exec "$inst" -- sudo -u claude -i; } # Which user does a shell land in? The template stamped it on the instance at
cmd_exec() { incus exec "$inst" -- sudo -u claude -i "${args[@]:1}"; } # mint time (user.box.user), and 'incus copy' carries user.* keys — so a clone
# knows without consulting the template. Two subtleties, both from the audit:
# 'incus config get' prints EMPTY + exit 0 for an unset key (B4), hence ${u:-},
# never '||'; and a pre-rename box has no metadata but is always a Claude box,
# so the legacy tag maps to 'claude'. The root fallback is effectively
# unreachable (every template sets a user) — anything that truly needs root
# goes through the 'box incus' escape hatch.
box_user() {
local u
u="$(incus config get "$1" user.box.user 2>/dev/null || true)"
if [ -z "$u" ] && [ "$(incus config get "$1" user.claudebox 2>/dev/null || true)" = 1 ]; then
u=claude
fi
echo "${u:-root}"
}
cmd_shell() { incus exec "$inst" -- sudo -u "$(box_user "$inst")" -i; }
cmd_exec() { incus exec "$inst" -- sudo -u "$(box_user "$inst")" -i "${args[@]:1}"; }
# A shell is a child of the exec connection: drop the terminal and everything # A shell is a child of the exec connection: drop the terminal and everything
# in it is SIGHUP'd — a long Claude run dies with it. tmux 'new-session -A' # in it is SIGHUP'd — a long Claude run dies with it. tmux 'new-session -A'
@ -622,7 +739,7 @@ cmd_tmux() {
case "$session" in case "$session" in
*[!A-Za-z0-9_-]*) usage_error "session names are letters, digits, '-' and '_' — got '$session'" ;; *[!A-Za-z0-9_-]*) usage_error "session names are letters, digits, '-' and '_' — got '$session'" ;;
esac esac
incus exec "$inst" -- sudo -u claude -i tmux new-session -A -s "$session" incus exec "$inst" -- sudo -u "$(box_user "$inst")" -i tmux new-session -A -s "$session"
} }
cmd_snapshot() { cmd_snapshot() {
@ -632,7 +749,7 @@ cmd_snapshot() {
} }
cmd_status() { cmd_status() {
echo "claudebox: 'status' is deprecated — use 'claudebox list'." >&2 echo "box: 'status' is deprecated — use 'box list'." >&2
list_all list_all
} }
@ -655,8 +772,8 @@ cmd_help() { show_help "${args[0]:-}"; }
warn_isolation() { warn_isolation() {
case " $* " in case " $* " in
*" profile "*|*" network "*|*" device "*|*security.*|*" nic "*) *" profile "*|*" network "*|*" device "*|*security.*|*" nic "*)
echo "claudebox: warning: this can move the box off the isolation stack" >&2 echo "box: warning: this can move the box off the isolation stack" >&2
echo "claudebox: (profile / network / device / security.*). The trust boundary is yours from here." >&2 ;; echo "box: (profile / network / device / security.*). The trust boundary is yours from here." >&2 ;;
esac esac
} }
@ -671,7 +788,7 @@ cmd_incus() {
done done
[ "$replaced" -eq 1 ] || out+=("$inst") [ "$replaced" -eq 1 ] || out+=("$inst")
warn_isolation "${out[@]}" warn_isolation "${out[@]}"
echo "claudebox: incus ${out[*]}" >&2 # no magic: show what runs echo "box: incus ${out[*]}" >&2 # no magic: show what runs
incus "${out[@]}" incus "${out[@]}"
} }
@ -696,7 +813,7 @@ case "$action" in
incus $sub "$inst" "${args[@]:1}" incus $sub "$inst" "${args[@]:1}"
if [ -n "$ok" ]; then if [ -n "$ok" ]; then
msg="${ok//\{\}/${args[0]}}"; msg="${msg//\{1\}/${args[1]:-}}" msg="${ok//\{\}/${args[0]}}"; msg="${msg//\{1\}/${args[1]:-}}"
echo "claudebox: $msg" echo "box: $msg"
fi fi
;; ;;
esac esac

View file

@ -1,6 +1,6 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# doctor.sh — is this host fit to mint boxes (and to drill), and if not, what # doctor.sh — is this host fit to mint boxes (and to drill), and if not, what
# is wrong? Users reach it as 'claudebox doctor'; the drill runs it directly. # is wrong? Users reach it as 'box doctor'; the drill runs it directly.
# #
# bash drill/doctor.sh # report # bash drill/doctor.sh # report
# bash drill/doctor.sh --fix # report, then revert what the drill left behind # bash drill/doctor.sh --fix # report, then revert what the drill left behind
@ -93,29 +93,38 @@ else
inf " (or: sudo systemctl restart claudebox-firewall.service)" inf " (or: sudo systemctl restart claudebox-firewall.service)"
fi fi
head_ "Profile — claude-dev (the NIC is the isolation contract)" # box-net is the placement contract since the 0.4.0 rename; claude-dev is its
if incus profile show claude-dev >/dev/null 2>&1; then # pre-rename ancestor and may linger while legacy boxes still reference it.
iso="$(incus profile device get claude-dev eth0 security.port_isolation 2>/dev/null)" # Check whichever exist — an unisolated NIC is a fault on either.
if [ "$iso" = "true" ]; then PROFILES=""
ok "security.port_isolation = true — boxes cannot reach each other at L2" incus profile show box-net >/dev/null 2>&1 && PROFILES="box-net"
else incus profile show claude-dev >/dev/null 2>&1 && PROFILES="$PROFILES claude-dev"
no "security.port_isolation is NOT set — BOXES CAN REACH EACH OTHER" head_ "Profile — the NIC is the isolation contract"
inf "an L3 ACL cannot do this: two boxes on one bridge are on the same L2" if [ -n "$PROFILES" ]; then
inf "segment, so their frames are switched, never routed past the ACL." for p in $PROFILES; do
inf "fix: re-run ~/.local/share/claudebox/host/setup-host.sh" [ "$p" = claude-dev ] && inf "claude-dev is legacy (pre-rename boxes still reference it)"
fi iso="$(incus profile device get "$p" eth0 security.port_isolation 2>/dev/null)"
for k in security.mac_filtering security.ipv4_filtering; do if [ "$iso" = "true" ]; then
v="$(incus profile device get claude-dev eth0 "$k" 2>/dev/null)" ok "$p: security.port_isolation = true — boxes cannot reach each other at L2"
if [ -z "$v" ]; then
ok "$k unset (as shipped)"
else else
no "$k = $v ← phase D left this behind. A box can fail to get on the network at all." no "$p: security.port_isolation is NOT set — BOXES CAN REACH EACH OTHER"
[ "$FIX" = 1 ] && { incus profile device unset claude-dev eth0 "$k" && inf "reverted: $k unset"; } inf "an L3 ACL cannot do this: two boxes on one bridge are on the same L2"
inf "segment, so their frames are switched, never routed past the ACL."
inf "fix: re-run ~/.local/share/claudebox/host/setup-host.sh"
fi fi
for k in security.mac_filtering security.ipv4_filtering; do
v="$(incus profile device get "$p" eth0 "$k" 2>/dev/null)"
if [ -z "$v" ]; then
ok "$p: $k unset (as shipped)"
else
no "$p: $k = $v ← phase D left this behind. A box can fail to get on the network at all."
[ "$FIX" = 1 ] && { incus profile device unset "$p" eth0 "$k" && inf "reverted: $k unset"; }
fi
done
done done
inf "cpu/mem: $(incus profile get claude-dev limits.cpu 2>/dev/null)/$(incus profile get claude-dev limits.memory 2>/dev/null) (the drill lowers these on a small host)" inf "resources are per-box since 0.4.0 (stamped from the template at mint; BOX_CPU/BOX_MEMORY override)"
else else
inf "claude-dev does not exist (a fresh host)" inf "box-net does not exist (a fresh host — setup-host.sh will create it)"
fi fi
head_ "ACL — claude-isolate" head_ "ACL — claude-isolate"
@ -203,8 +212,10 @@ if [ "$PIN" = 1 ]; then
fi fi
head_ "Can a box actually resolve DNS?" head_ "Can a box actually resolve DNS?"
# Any box will do — the drill's names are not the only boxes on a host. # Any box will do — the drill's names are not the only boxes on a host, and
probe="$(incus list "user.claudebox=1" --format csv --columns ns 2>/dev/null \ # a pre-rename box (legacy tag) is as good a probe as a new one.
probe="$({ incus list "user.box=1" --format csv --columns ns 2>/dev/null
incus list "user.claudebox=1" --format csv --columns ns 2>/dev/null; } \
| awk -F, '$2 == "RUNNING" { print $1; exit }')" | awk -F, '$2 == "RUNNING" { print $1; exit }')"
if [ -n "$probe" ] && [ "$FIX" != 1 ]; then if [ -n "$probe" ] && [ "$FIX" != 1 ]; then
# Stdin MUST be pinned to /dev/null: with a TTY on stdin, 'incus exec' goes # Stdin MUST be pinned to /dev/null: with a TTY on stdin, 'incus exec' goes

View file

@ -1,5 +1,5 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# drill.sh — end-to-end drill for claudebox, against a real Incus. # drill.sh — end-to-end drill for box (the claudebox repo), against a real Incus.
# #
# ⚠ DESTRUCTIVE, AND MEANT TO BE. Run it on a THROWAWAY host you can format. # ⚠ DESTRUCTIVE, AND MEANT TO BE. Run it on a THROWAWAY host you can format.
# It installs Incus, rewrites the host's firewall rules, installs a systemd # It installs Incus, rewrites the host's firewall rules, installs a systemd
@ -8,13 +8,13 @@
# #
# bash drill/drill.sh # asks first # bash drill/drill.sh # asks first
# bash drill/drill.sh --yes # no prompt (CI, or you've read it) # bash drill/drill.sh --yes # no prompt (CI, or you've read it)
# bash drill/drill.sh --ref main # drill a different branch of claudebox # bash drill/drill.sh --ref main # drill a different branch of the repo
# bash drill/drill.sh --keep-boxes # leave the boxes up to poke at # bash drill/drill.sh --keep-boxes # leave the boxes up to poke at
# #
# Four phases: # Four phases:
# A. Incus semantics — the assumptions claudebox is built on, probed directly. # A. Incus semantics — the assumptions box is built on, probed directly.
# These were only ever verified against a stub. # These were only ever verified against a stub.
# B. The claudebox surface — the whole CLI, end to end, including the boundary. # B. The box surface — the whole CLI, end to end, including the boundary.
# C. Isolation baseline — does the trust boundary actually hold? (#15 section A) # C. Isolation baseline — does the trust boundary actually hold? (#15 section A)
# D. Hardening rehearsal — #16's proposed changes, applied live and re-probed # D. Hardening rehearsal — #16's proposed changes, applied live and re-probed
# (#15 section B). FAILs here are design vetoes, not code bugs. # (#15 section B). FAILs here are design vetoes, not code bugs.
@ -60,7 +60,7 @@ aud() { audit+=("$*"); } # an answer for the #15 audit
wait_box() { # poll until exec answers (the VM agent can take a while), ~2 min wait_box() { # poll until exec answers (the VM agent can take a while), ~2 min
local b="$1" _i local b="$1" _i
for _i in $(seq 1 60); do for _i in $(seq 1 60); do
claudebox exec "$b" -- true >/dev/null 2>&1 && return 0 box exec "$b" -- true >/dev/null 2>&1 && return 0
sleep 2 sleep 2
done done
return 1 return 1
@ -69,7 +69,7 @@ wait_box() { # poll until exec answers (the VM agent can take a while), ~2 min
# Read from inside a box WITHOUT ever hanging the drill. # Read from inside a box WITHOUT ever hanging the drill.
# #
# Two traps, both hit for real: # Two traps, both hit for real:
# · 'claudebox exec' becomes 'sudo -u claude -i' — a LOGIN zsh (oh-my-zsh and # · 'box exec' becomes 'sudo -u <template user> -i' — a LOGIN zsh (oh-my-zsh and
# all). Fine for a person, needless machinery for a probe. # all). Fine for a person, needless machinery for a probe.
# · $( ) waits for stdout to CLOSE, not for the command to exit. A grandchild # · $( ) waits for stdout to CLOSE, not for the command to exit. A grandchild
# inheriting the exec session's stdout keeps the substitution open forever, # inheriting the exec session's stdout keeps the substitution open forever,
@ -155,7 +155,7 @@ EOF
case "$reply" in y|Y|yes) ;; *) echo "stopped."; exit 1 ;; esac case "$reply" in y|Y|yes) ;; *) echo "stopped."; exit 1 ;; esac
fi fi
phase "Installing claudebox ($REPO@$REF)" phase "Installing box ($REPO@$REF)"
CLAUDEBOX_REPO="$REPO" CLAUDEBOX_REF="$REF" \ CLAUDEBOX_REPO="$REPO" CLAUDEBOX_REF="$REF" \
bash -c "$(curl -fsSL "https://raw.githubusercontent.com/$REPO/$REF/install.sh")" \ bash -c "$(curl -fsSL "https://raw.githubusercontent.com/$REPO/$REF/install.sh")" \
|| { echo "install failed"; exit 1; } || { echo "install failed"; exit 1; }
@ -214,12 +214,16 @@ KEEP="${KEEP:-0}"
# DNS-enumeration leak), so it is no longer "dirt" from a rehearsal — do not # DNS-enumeration leak), so it is no longer "dirt" from a rehearsal — do not
# revert it. Only the vetoed NIC filtering counts as leftover. # revert it. Only the vetoed NIC filtering counts as leftover.
dirty="" dirty=""
[ -n "$(incus profile device get claude-dev eth0 security.ipv4_filtering 2>/dev/null)" ] && dirty="$dirty ipv4_filtering" for p in box-net claude-dev; do
[ -n "$(incus profile device get claude-dev eth0 security.mac_filtering 2>/dev/null)" ] && dirty="$dirty mac_filtering" [ -n "$(incus profile device get "$p" eth0 security.ipv4_filtering 2>/dev/null)" ] && dirty="$dirty $p:ipv4_filtering"
[ -n "$(incus profile device get "$p" eth0 security.mac_filtering 2>/dev/null)" ] && dirty="$dirty $p:mac_filtering"
done
if [ -n "$dirty" ]; then if [ -n "$dirty" ]; then
note "this host carries the VETOED NIC filtering from an old rehearsal:$dirty — reverting" note "this host carries the VETOED NIC filtering from an old rehearsal:$dirty — reverting"
incus profile device unset claude-dev eth0 security.mac_filtering >/dev/null 2>&1 for p in box-net claude-dev; do
incus profile device unset claude-dev eth0 security.ipv4_filtering >/dev/null 2>&1 incus profile device unset "$p" eth0 security.mac_filtering >/dev/null 2>&1
incus profile device unset "$p" eth0 security.ipv4_filtering >/dev/null 2>&1
done
fi fi
inf "clearing anything a previous run left behind…" inf "clearing anything a previous run left behind…"
@ -231,10 +235,12 @@ done
if incus network show claudenet >/dev/null 2>&1; then if incus network show claudenet >/dev/null 2>&1; then
timeout -k 5 30 incus network unset claudenet dns.mode >/dev/null 2>&1 timeout -k 5 30 incus network unset claudenet dns.mode >/dev/null 2>&1
fi fi
if incus profile show claude-dev >/dev/null 2>&1; then for p in box-net claude-dev; do
timeout -k 5 30 incus profile device unset claude-dev eth0 security.mac_filtering >/dev/null 2>&1 if incus profile show "$p" >/dev/null 2>&1; then
timeout -k 5 30 incus profile device unset claude-dev eth0 security.ipv4_filtering >/dev/null 2>&1 timeout -k 5 30 incus profile device unset "$p" eth0 security.mac_filtering >/dev/null 2>&1
fi timeout -k 5 30 incus profile device unset "$p" eth0 security.ipv4_filtering >/dev/null 2>&1
fi
done
left="$(incus list --format csv --columns n 2>/dev/null | tr '\n' ' ')" left="$(incus list --format csv --columns n 2>/dev/null | tr '\n' ' ')"
[ -n "$left" ] && inf "instances still on this host (not ours, left alone): $left" [ -n "$left" ] && inf "instances still on this host (not ours, left alone): $left"
@ -252,38 +258,41 @@ if ! timeout -k 10 300 ~/.local/share/claudebox/host/setup-host.sh; then
fi fi
inf "host setup complete" inf "host setup complete"
# A real server has room for the production profile (8GiB/4cpu), and drilling the # A real server has room for the claude template's resources (8GiB/4cpu), and
# real profile is worth more than drilling a shrunken one. Only shrink if we must. # drilling the real numbers is worth more than drilling shrunken ones. Only
# shrink if we must. Since 0.4.0 resources are per-box, stamped from the
# template at mint — a profile edit no longer reaches them; the supported
# override is the BOX_* environment, which every 'box new' below inherits.
ram="$(awk '/MemTotal/{print int($2/1024/1024)}' /proc/meminfo)" ram="$(awk '/MemTotal/{print int($2/1024/1024)}' /proc/meminfo)"
if [ "$ram" -lt 20 ]; then if [ "$ram" -lt 20 ]; then
incus profile set claude-dev limits.memory=3GiB limits.cpu=2 export BOX_MEMORY=3GiB BOX_CPU=2
note "host has ${ram}GiB RAM — lowered claude-dev to 3GiB/2cpu for the drill (production profile is 8GiB/4cpu, and that is what was NOT drilled)" note "host has ${ram}GiB RAM — minting at 3GiB/2cpu via BOX_MEMORY/BOX_CPU (the claude template's 8GiB/4cpu is what was NOT drilled)"
else else
inf "host has ${ram}GiB RAM — drilling the production profile (8GiB/4cpu) unchanged" inf "host has ${ram}GiB RAM — drilling the claude template's resources (8GiB/4cpu) unchanged"
fi fi
KVM=0; [ -e /dev/kvm ] && KVM=1 KVM=0; [ -e /dev/kvm ] && KVM=1
[ "$KVM" = 1 ] && inf "/dev/kvm present — boxes will be VMs (the real trust boundary)" \ [ "$KVM" = 1 ] && inf "/dev/kvm present — boxes will be VMs (the real trust boundary)" \
|| note "NO /dev/kvm on this host — claudebox will fall back to CONTAINER mode, so this run does NOT validate the VM trust boundary" || note "NO /dev/kvm on this host — box will fall back to CONTAINER mode, so this run does NOT validate the VM trust boundary"
# =========================================================================== # ===========================================================================
phase "A. Incus semantics — the assumptions claudebox is built on" phase "A. Incus semantics — the assumptions box is built on"
# =========================================================================== # ===========================================================================
incus launch images:debian/13 cbprobe --config user.claudebox=1 >/dev/null 2>&1 incus launch images:debian/13 cbprobe --config user.box=1 >/dev/null 2>&1
incus launch images:debian/13 cbnotours >/dev/null 2>&1 # untagged: not ours incus launch images:debian/13 cbnotours >/dev/null 2>&1 # untagged: not ours
sleep 3 sleep 3
# A1 — the tag read. #13 puts this on the path of EVERY box command. # A1 — the tag read. #13 puts this on the path of EVERY box command.
t="$(incus config get cbprobe user.claudebox 2>&1)" t="$(incus config get cbprobe user.box 2>&1)"
[ "$t" = "1" ] && ok "config get user.claudebox → '1'" \ [ "$t" = "1" ] && ok "config get user.box → '1'" \
|| no "config get user.claudebox → '$t' (expected '1'; every box command would fail closed)" || no "config get user.box → '$t' (expected '1'; every box command would fail closed)"
# A2 — the list filter, and that it EXCLUDES an instance we didn't mint # A2 — the list filter, and that it EXCLUDES an instance we didn't mint
f="$(incus list user.claudebox=1 --format csv --columns nstS 2>&1)" f="$(incus list user.box=1 --format csv --columns nstS 2>&1)"
if echo "$f" | grep -q '^cbprobe,' && ! echo "$f" | grep -q '^cbnotours,'; then if echo "$f" | grep -q '^cbprobe,' && ! echo "$f" | grep -q '^cbnotours,'; then
ok "list filter user.claudebox=1 selects ours, excludes theirs" ok "list filter user.box=1 selects ours, excludes theirs"
else else
no "list filter user.claudebox=1 is wrong — got: $(echo "$f" | tr '\n' ' ')" no "list filter user.box=1 is wrong — got: $(echo "$f" | tr '\n' ' ')"
fi fi
# A3 — four fields, no commas/newlines to mangle the awk table # A3 — four fields, no commas/newlines to mangle the awk table
@ -340,36 +349,37 @@ incus delete -f cbcopy >/dev/null 2>&1
incus delete -f cbprobe cbnotours >/dev/null 2>&1 incus delete -f cbprobe cbnotours >/dev/null 2>&1
# =========================================================================== # ===========================================================================
phase "B. The claudebox surface" phase "B. The box surface"
# =========================================================================== # ===========================================================================
# Compare against the installed tree's VERSION file, not a hardcoded number — # Compare against the installed tree's VERSION file, not a hardcoded number —
# a pinned literal here would fail the drill on every release. # a pinned literal here would fail the drill on every release.
expected="$(cat "$HOME/.local/share/claudebox/VERSION" 2>/dev/null || echo '?')" expected="$(cat "$HOME/.local/share/claudebox/VERSION" 2>/dev/null || echo '?')"
v="$(claudebox --version 2>&1)" v="$(box --version 2>&1)"
case "$v" in *"$expected"*) ok "claudebox --version → $v" ;; *) no "version mismatch: CLI says '$v', VERSION file says '$expected'" ;; esac case "$v" in *"$expected"*) ok "box --version → $v" ;; *) no "version mismatch: CLI says '$v', VERSION file says '$expected'" ;; esac
# The drill must not require an empty host: operator boxes tagged # The drill must not require an empty host: operator boxes tagged
# user.claudebox=1 are legitimate tenants, and the teardown below deliberately # user.box=1 (or the legacy tag) are legitimate tenants, and the teardown below deliberately
# refuses to touch them. The empty-host message is only TESTABLE when the host # refuses to touch them. The empty-host message is only TESTABLE when the host
# is actually empty — on a shared host, skip it instead of failing it. # is actually empty — on a shared host, skip it instead of failing it.
tenants="$(incus list user.claudebox=1 --format csv --columns n 2>/dev/null | tr '\n' ' ')" tenants="$({ incus list user.box=1 --format csv --columns n 2>/dev/null
incus list user.claudebox=1 --format csv --columns n 2>/dev/null; } | sort -u | tr '\n' ' ')"
if [ -n "${tenants% }" ]; then if [ -n "${tenants% }" ]; then
inf "host already has claudebox boxes (${tenants% }) — the empty-host message cannot be tested this run" inf "host already has boxes (${tenants% }) — the empty-host message cannot be tested this run"
else else
claudebox list >/dev/null 2>&1 && claudebox list 2>&1 | grep -q 'no boxes yet' \ box list >/dev/null 2>&1 && box list 2>&1 | grep -q 'no boxes yet' \
&& ok "empty host: 'no boxes yet', exit 0" || no "empty-host message wrong" && ok "empty host: 'no boxes yet', exit 0" || no "empty-host message wrong"
fi fi
printf '\n minting a box (cold, ~10 min)…\n' printf '\n minting a box (cold, ~10 min)…\n'
t0=$SECONDS t0=$SECONDS
if claudebox new --name drill >/tmp/new.log 2>&1; then if box new --name drill >/tmp/new.log 2>&1; then
ok "claudebox new --name drill ($((SECONDS - t0))s)" ok "box new --name drill ($((SECONDS - t0))s)"
else else
no "claudebox new FAILED — tail: $(tail -3 /tmp/new.log | tr '\n' ' ')" no "box new FAILED — tail: $(tail -3 /tmp/new.log | tr '\n' ' ')"
echo; echo "── cannot continue without a box"; printf ' %s\n' "${findings[@]}"; exit 1 echo; echo "── cannot continue without a box"; printf ' %s\n' "${findings[@]}"; exit 1
fi fi
typ="$(claudebox list | awk '$1 == "drill" { print $3 }')" typ="$(box list | awk '$1 == "drill" { print $3 }')"
if [ "$KVM" = 1 ]; then if [ "$KVM" = 1 ]; then
[ "$typ" = VM ] && ok "the box is a VM — the trust boundary is real" \ [ "$typ" = VM ] && ok "the box is a VM — the trust boundary is real" \
|| no "the box is '$typ' but /dev/kvm exists — it should have been a VM" || no "the box is '$typ' but /dev/kvm exists — it should have been a VM"
@ -377,80 +387,80 @@ else
note "the box is '$typ' (no /dev/kvm on this host)" note "the box is '$typ' (no /dev/kvm on this host)"
fi fi
claudebox info drill | grep -q '^IPV4' && ok "info shows an IPv4" || no "info has no IPV4 row" box info drill | grep -q '^IPV4' && ok "info shows an IPv4" || no "info has no IPV4 row"
claudebox info drill | grep -q 'SNAPSHOTS (none)' && ok "info: no snapshots yet, offers to take one" || no "info snapshot-empty state wrong" box info drill | grep -q 'SNAPSHOTS (none)' && ok "info: no snapshots yet, offers to take one" || no "info snapshot-empty state wrong"
if claudebox exec drill -- claude --version >/dev/null 2>&1; then if box exec drill -- claude --version >/dev/null 2>&1; then
ok "Claude Code is installed in the box" ok "Claude Code is installed in the box"
elif timeout 30 claudebox exec drill -- bash -lc 'claude --version' >/dev/null 2>&1; then elif timeout 30 box exec drill -- bash -lc 'claude --version' >/dev/null 2>&1; then
no "'claude' is installed but NOT on exec's PATH — repo bug: the help promises 'claudebox exec work -- claude --version'" no "'claude' is installed but NOT on exec's PATH — repo bug: the help promises 'box exec work -- claude --version'"
inf "PATH as exec sees it: $(timeout 30 claudebox exec drill -- printenv PATH 2>/dev/null)" inf "PATH as exec sees it: $(timeout 30 box exec drill -- printenv PATH 2>/dev/null)"
else else
no "'claude --version' failed inside the box" no "'claude --version' failed inside the box"
# diag output must skip the hatch's own 'claudebox: incus exec …' announce lines # diag output must skip the hatch's own 'box: incus exec …' announce lines
hatch_out() { timeout 30 claudebox incus drill -- exec {} -- "$@" 2>&1 | grep -v '^claudebox:' | tail -1 | cut -c1-120; } hatch_out() { timeout 30 box incus drill -- exec {} -- "$@" 2>&1 | grep -v '^box:' | tail -1 | cut -c1-120; }
inf "cloud-init: $(hatch_out cloud-init status)" inf "cloud-init: $(hatch_out cloud-init status)"
inf "binary runs? $(hatch_out sudo -u claude /home/claude/.local/bin/claude --version)" inf "binary runs? $(hatch_out sudo -u claude /home/claude/.local/bin/claude --version)"
inf "exec PATH: $(timeout 30 claudebox exec drill -- printenv PATH 2>/dev/null | tail -1)" inf "exec PATH: $(timeout 30 box exec drill -- printenv PATH 2>/dev/null | tail -1)"
fi fi
claudebox exec drill -- gh --version >/dev/null 2>&1 \ box exec drill -- gh --version >/dev/null 2>&1 \
&& ok "the GitHub CLI is installed in the box (PR #5)" || no "'gh --version' failed inside the box" && ok "the GitHub CLI is installed in the box (PR #5)" || no "'gh --version' failed inside the box"
# --- the snapshot → clone workflow, which is the whole point of the tool --- # --- the snapshot → clone workflow, which is the whole point of the tool ---
claudebox snapshot drill authed 2>&1 | grep -q authed && ok "snapshot drill authed" || no "snapshot failed" box snapshot drill authed 2>&1 | grep -q authed && ok "snapshot drill authed" || no "snapshot failed"
claudebox info drill | grep -q 'authed' && ok "info lists the snapshot label" || no "info does not show the label" box info drill | grep -q 'authed' && ok "info lists the snapshot label" || no "info does not show the label"
claudebox info drill | grep -q -- '--from drill/authed' && ok "info prints the --from line to clone it" || no "info lacks the --from hint" box info drill | grep -q -- '--from drill/authed' && ok "info prints the --from line to clone it" || no "info lacks the --from hint"
# --- the boundary: an instance claudebox did NOT mint ---------------------- # --- the boundary: an instance box did NOT mint ----------------------
incus launch images:debian/13 payroll >/dev/null 2>&1 # somebody else's instance incus launch images:debian/13 payroll >/dev/null 2>&1 # somebody else's instance
sleep 2 sleep 2
claudebox down payroll 2>&1 | grep -q 'no such box' && ok "boundary: 'down' refuses an untagged instance" || no "boundary: 'down' touched an instance claudebox didn't mint!" box down payroll 2>&1 | grep -q 'no such box' && ok "boundary: 'down' refuses an untagged instance" || no "boundary: 'down' touched an instance box didn't mint!"
claudebox rm payroll --force 2>&1 | grep -q 'no such box' && ok "boundary: 'rm' refuses an untagged instance" || no "boundary: 'rm' would DELETE a foreign instance!" box rm payroll --force 2>&1 | grep -q 'no such box' && ok "boundary: 'rm' refuses an untagged instance" || no "boundary: 'rm' would DELETE a foreign instance!"
claudebox incus payroll -- config show 2>&1 | grep -q 'no such box' && ok "boundary: the escape hatch refuses it too" || no "boundary: the hatch reached a foreign instance!" box incus payroll -- config show 2>&1 | grep -q 'no such box' && ok "boundary: the escape hatch refuses it too" || no "boundary: the hatch reached a foreign instance!"
incus list payroll --format csv --columns ns | grep -q '^payroll,RUNNING' && ok "…and payroll is still running, untouched" || no "payroll was harmed — the boundary leaked" incus list payroll --format csv --columns ns | grep -q '^payroll,RUNNING' && ok "…and payroll is still running, untouched" || no "payroll was harmed — the boundary leaked"
incus delete -f payroll >/dev/null 2>&1 incus delete -f payroll >/dev/null 2>&1
# --- rename, and its precondition ----------------------------------------- # --- rename, and its precondition -----------------------------------------
claudebox rename drill archive 2>&1 | grep -qi 'RUNNING' && ok "rename refuses a running box, and says how to fix it" || no "rename did not refuse a running box" box rename drill archive 2>&1 | grep -qi 'RUNNING' && ok "rename refuses a running box, and says how to fix it" || no "rename did not refuse a running box"
claudebox down drill >/dev/null 2>&1 && ok "down drill" || no "down failed" box down drill >/dev/null 2>&1 && ok "down drill" || no "down failed"
claudebox rename drill archive 2>&1 | grep -q 'renamed drill to archive' && ok "rename drill → archive (stopped)" || no "rename failed on a stopped box" box rename drill archive 2>&1 | grep -q 'renamed drill to archive' && ok "rename drill → archive (stopped)" || no "rename failed on a stopped box"
claudebox list | grep -q '^archive' && ok "list shows the new name" || no "list still shows the old name" box list | grep -q '^archive' && ok "list shows the new name" || no "list still shows the old name"
claudebox info archive | grep -q authed && ok "the snapshot followed the rename" || no "snapshot lost across the rename" box info archive | grep -q authed && ok "the snapshot followed the rename" || no "snapshot lost across the rename"
# --- clone from a snapshot of a renamed box -------------------------------- # --- clone from a snapshot of a renamed box --------------------------------
printf '\n cloning from the snapshot…\n' printf '\n cloning from the snapshot…\n'
if claudebox new --name clone --from archive/authed >/tmp/clone.log 2>&1; then if box new --name clone --from archive/authed >/tmp/clone.log 2>&1; then
ok "new --from archive/authed (clone of a snapshot of a renamed box)" ok "new --from archive/authed (clone of a snapshot of a renamed box)"
claudebox exec clone -- true >/dev/null 2>&1 && ok "the clone is alive and enterable" || no "the clone is not enterable" box exec clone -- true >/dev/null 2>&1 && ok "the clone is alive and enterable" || no "the clone is not enterable"
else else
no "clone FAILED — tail: $(tail -3 /tmp/clone.log | tr '\n' ' ')" no "clone FAILED — tail: $(tail -3 /tmp/clone.log | tr '\n' ' ')"
fi fi
# --- the escape hatch ------------------------------------------------------ # --- the escape hatch ------------------------------------------------------
claudebox incus archive -- config show 2>/dev/null | grep -q 'user.claudebox' && ok "hatch: 'incus archive -- config show', instance appended" || no "hatch passthrough failed" box incus archive -- config show 2>/dev/null | grep -q 'user.box' && ok "hatch: 'incus archive -- config show', instance appended" || no "hatch passthrough failed"
h="$(claudebox incus archive -- config device add {} scratch disk source=/tmp path=/mnt/scratch 2>&1)" h="$(box incus archive -- config device add {} scratch disk source=/tmp path=/mnt/scratch 2>&1)"
echo "$h" | grep -q 'isolation stack' && ok "hatch warns when a command can break isolation" || no "hatch did not warn on a device add" echo "$h" | grep -q 'isolation stack' && ok "hatch warns when a command can break isolation" || no "hatch did not warn on a device add"
claudebox incus archive -- config device remove {} scratch >/dev/null 2>&1 box incus archive -- config device remove {} scratch >/dev/null 2>&1
# --- rm, and the guard that did not used to exist -------------------------- # --- rm, and the guard that did not used to exist --------------------------
claudebox rm clone </dev/null 2>&1 | grep -q 'refusing' && ok "rm with no TTY and no --force refuses (exit 2)" || no "rm destroyed a box with no confirmation!" box rm clone </dev/null 2>&1 | grep -q 'refusing' && ok "rm with no TTY and no --force refuses (exit 2)" || no "rm destroyed a box with no confirmation!"
claudebox rm clone --force 2>&1 | grep -q 'removed' && ok "rm --force removes the clone" || no "rm --force failed" box rm clone --force 2>&1 | grep -q 'removed' && ok "rm --force removes the clone" || no "rm --force failed"
# --- the CLI contract ------------------------------------------------------ # --- the CLI contract ------------------------------------------------------
claudebox lst 2>&1 | grep -q "did you mean 'list'" && ok "typo → did-you-mean, exit 2" || no "unknown command not suggested" box lst 2>&1 | grep -q "did you mean 'list'" && ok "typo → did-you-mean, exit 2" || no "unknown command not suggested"
claudebox list archive 2>&1 | grep -q 'claudebox info archive' && ok "'list <box>' points at info" || no "'list <box>' does not point at info" box list archive 2>&1 | grep -q 'box info archive' && ok "'list <box>' points at info" || no "'list <box>' does not point at info"
claudebox snapshot archive --labl x 2>&1 | grep -q 'unknown option' && ok "typo'd flag rejected (not swallowed as a label)" || no "unknown flag was swallowed" box snapshot archive --labl x 2>&1 | grep -q 'unknown option' && ok "typo'd flag rejected (not swallowed as a label)" || no "unknown flag was swallowed"
# =========================================================================== # ===========================================================================
phase "C. Isolation baseline — does the boundary actually hold? (#15 section A)" phase "C. Isolation baseline — does the boundary actually hold? (#15 section A)"
# =========================================================================== # ===========================================================================
claudebox start archive >/dev/null 2>&1 box start archive >/dev/null 2>&1
wait_box archive && ok "archive is back up (agent answering)" \ wait_box archive && ok "archive is back up (agent answering)" \
|| no "archive did not come back within 2 min of start" || no "archive did not come back within 2 min of start"
# Sibling isolation needs a sibling. Clone from the snapshot — fast, no cold mint. # Sibling isolation needs a sibling. Clone from the snapshot — fast, no cold mint.
printf '\n cloning a peer for the sibling probes…\n' printf '\n cloning a peer for the sibling probes…\n'
if claudebox new --name peer --from archive/authed >/tmp/peer.log 2>&1 && wait_box peer; then if box new --name peer --from archive/authed >/tmp/peer.log 2>&1 && wait_box peer; then
ok "peer minted from archive/authed and answering" ok "peer minted from archive/authed and answering"
else else
no "peer clone failed or never answered — tail: $(tail -3 /tmp/peer.log | tr '\n' ' ')" no "peer clone failed or never answered — tail: $(tail -3 /tmp/peer.log | tr '\n' ' ')"
@ -607,15 +617,15 @@ fi
# =========================================================================== # ===========================================================================
if [ "$KEEP" = 1 ]; then if [ "$KEEP" = 1 ]; then
phase "Boxes left up (--keep-boxes)" phase "Boxes left up (--keep-boxes)"
claudebox list box list
inf "note: the D-phase mutations (dns.mode=none, NIC filtering) are still applied" inf "note: the D-phase mutations (dns.mode=none, NIC filtering) are still applied"
else else
# every name the drill can have left, whatever branch a partial run took # every name the drill can have left, whatever branch a partial run took
for n in drill clone archive peer; do claudebox rm "$n" --force >/dev/null 2>&1; done for n in drill clone archive peer; do box rm "$n" --force >/dev/null 2>&1; done
# Assert OUR boxes are gone — not that the host is empty. The rm loop above # Assert OUR boxes are gone — not that the host is empty. The rm loop above
# already embodies the discipline (only names the drill minted); demanding # already embodies the discipline (only names the drill minted); demanding
# 'no boxes yet' here would flag any pre-existing operator box as a failure. # 'no boxes yet' here would flag any pre-existing operator box as a failure.
leftover="$(claudebox list 2>/dev/null | grep -E '^(drill|clone|archive|peer)([[:space:]]|$)' || true)" leftover="$(box list 2>/dev/null | grep -E '^(drill|clone|archive|peer)([[:space:]]|$)' || true)"
[ -z "$leftover" ] && ok "teardown: every box the drill minted is gone" \ [ -z "$leftover" ] && ok "teardown: every box the drill minted is gone" \
|| no "a drill box survived teardown: $(printf '%s' "$leftover" | awk '{print $1}' | tr '\n' ' ')" || no "a drill box survived teardown: $(printf '%s' "$leftover" | awk '{print $1}' | tr '\n' ' ')"
fi fi

View file

@ -1,6 +1,6 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# One-time host setup: install Incus, create the isolated network + ACL and # One-time host setup: install Incus, create the isolated network + ACL and
# the claude-dev profile. Idempotent. Ubuntu 24.04 / Debian 13. # the box-net profile. Idempotent. Ubuntu 24.04 / Debian 13.
set -euo pipefail set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" here="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
@ -129,11 +129,15 @@ sudo systemctl enable claudebox-firewall.service
# claimed to close it. Restart re-runs the script, which is idempotent by design. # claimed to close it. Restart re-runs the script, which is idempotent by design.
sudo systemctl restart claudebox-firewall.service sudo systemctl restart claudebox-firewall.service
# Profile # Profile — box-net, the placement contract: the isolated NIC and the root
if ! incus profile show claude-dev >/dev/null 2>&1; then # disk, nothing a template controls (resources are stamped per-instance from
incus profile create claude-dev # the template at mint time). A legacy claude-dev profile is left alone:
# Incus refuses to delete an in-use profile, and pre-rename boxes reference
# it until their last one is gone — teardown-host removes it then.
if ! incus profile show box-net >/dev/null 2>&1; then
incus profile create box-net
fi fi
incus profile edit claude-dev < "$here/profiles/claude-dev.yaml" incus profile edit box-net < "$here/profiles/box-net.yaml"
# The sibling drop is the one rule whose absence is invisible: everything keeps # The sibling drop is the one rule whose absence is invisible: everything keeps
# working, and boxes can simply reach each other. Assert it landed. # working, and boxes can simply reach each other. Assert it landed.
@ -144,4 +148,4 @@ else
echo " check: sudo /usr/local/sbin/claudebox-firewall ; sudo nft list table bridge claudebox" >&2 echo " check: sudo /usr/local/sbin/claudebox-firewall ; sudo nft list table bridge claudebox" >&2
fi fi
echo "Host ready. Launch with: claudebox new --name <box>" echo "Host ready. Launch with: box new --name <box>"

View file

@ -21,7 +21,8 @@ for i in $(incus list "user.claudebox=1" -f csv -c n || true); do
incus delete -f "$i" incus delete -f "$i"
done done
incus profile delete claude-dev 2>/dev/null || true incus profile delete box-net 2>/dev/null || true
incus profile delete claude-dev 2>/dev/null || true # legacy, pre-rename
incus network delete claudenet 2>/dev/null || true incus network delete claudenet 2>/dev/null || true
incus network acl delete claude-isolate 2>/dev/null || true incus network acl delete claude-isolate 2>/dev/null || true

View file

@ -4,7 +4,7 @@ set -euo pipefail
# claudebox installer — intended for: curl -fsSL .../install.sh | bash # claudebox installer — intended for: curl -fsSL .../install.sh | bash
# #
# Downloads the claudebox repo tarball, installs the whole tree under # Downloads the claudebox repo tarball, installs the whole tree under
# $DEST, and puts a `claudebox` symlink on PATH via $BINDIR. # $DEST, and puts a `box` symlink on PATH via $BINDIR.
REPO="${CLAUDEBOX_REPO:-heavy-duty/claudebox}" REPO="${CLAUDEBOX_REPO:-heavy-duty/claudebox}"
REF="${CLAUDEBOX_REF:-main}" REF="${CLAUDEBOX_REF:-main}"
@ -26,7 +26,7 @@ trap cleanup EXIT
URL="https://github.com/$REPO/archive/refs/heads/$REF.tar.gz" URL="https://github.com/$REPO/archive/refs/heads/$REF.tar.gz"
log "installing claudebox ($REPO@$REF)" log "installing box (the claudebox repo) ($REPO@$REF)"
log "downloading $URL" log "downloading $URL"
curl -fsSL "$URL" -o "$TMPDIR/claudebox.tar.gz" \ curl -fsSL "$URL" -o "$TMPDIR/claudebox.tar.gz" \
|| die "failed to download $URL" || die "failed to download $URL"
@ -38,7 +38,7 @@ tar -xzf "$TMPDIR/claudebox.tar.gz" -C "$TMPDIR" \
# GitHub archives extract to a single top-level dir like claudebox-<ref>/ # GitHub archives extract to a single top-level dir like claudebox-<ref>/
EXTRACTED="$(find "$TMPDIR" -maxdepth 1 -type d -name 'claudebox-*' | head -n1)" EXTRACTED="$(find "$TMPDIR" -maxdepth 1 -type d -name 'claudebox-*' | head -n1)"
[ -n "$EXTRACTED" ] || die "could not find extracted claudebox-* directory in archive" [ -n "$EXTRACTED" ] || die "could not find extracted claudebox-* directory in archive"
[ -f "$EXTRACTED/bin/claudebox" ] || die "archive does not contain bin/claudebox — is $REPO@$REF correct?" [ -f "$EXTRACTED/bin/box" ] || die "archive does not contain bin/box — is $REPO@$REF correct?"
# --- atomically replace $DEST --------------------------------------------- # --- atomically replace $DEST ---------------------------------------------
log "installing into $DEST" log "installing into $DEST"
@ -46,12 +46,19 @@ rm -rf "$DEST"
mkdir -p "$(dirname "$DEST")" mkdir -p "$(dirname "$DEST")"
mv "$EXTRACTED" "$DEST" mv "$EXTRACTED" "$DEST"
chmod +x "$DEST/bin/claudebox" chmod +x "$DEST/bin/box"
# --- put claudebox on PATH ------------------------------------------------- # --- put box on PATH -------------------------------------------------------
mkdir -p "$BINDIR" mkdir -p "$BINDIR"
ln -sf "$DEST/bin/claudebox" "$BINDIR/claudebox" ln -sf "$DEST/bin/box" "$BINDIR/box"
log "linked $BINDIR/claudebox -> $DEST/bin/claudebox" log "linked $BINDIR/box -> $DEST/bin/box"
# 0.4.0 renamed the binary (clean cut): clear a stale claudebox symlink so it
# cannot dangle at the old bin path forever. Old BOXES keep working — the CLI
# honors their legacy tag — it is only the old command name that retires.
if [ -L "$BINDIR/claudebox" ]; then
rm -f "$BINDIR/claudebox"
log "removed the old claudebox symlink — the command is 'box' now (your existing boxes keep working)"
fi
# --- PATH check ------------------------------------------------------------ # --- PATH check ------------------------------------------------------------
case ":$PATH:" in case ":$PATH:" in
@ -65,8 +72,8 @@ esac
# --- environment check ----------------------------------------------------- # --- environment check -----------------------------------------------------
if ! command -v incus >/dev/null 2>&1; then if ! command -v incus >/dev/null 2>&1; then
warn "incus was not found. claudebox needs Incus on the host." warn "incus was not found. box needs Incus on the host."
warn " run the one-time host setup: $DEST/host/setup-host.sh" warn " run the one-time host setup: $DEST/host/setup-host.sh"
fi fi
log "done — try: claudebox new --name test" log "done — try: box new --name test"

View file

@ -1,8 +1,11 @@
name: claude-dev # The placement contract. Every box, whatever its template, launches with
description: Trust-less claudebox (resources + isolated NIC) # exactly this profile: the isolated NIC and the root disk — and NOTHING a
config: # template controls. Resources (limits.*) are per-instance, stamped from the
limits.cpu: "4" # template's box.env at mint time; they do not belong here, because a profile
limits.memory: 8GiB # a template could point away from is a network a template could escape.
name: box-net
description: The box placement contract — isolated NIC + root disk, nothing else
config: {}
devices: devices:
eth0: eth0:
type: nic type: nic

9
templates/blank/box.env Normal file
View file

@ -0,0 +1,9 @@
# The blank template — a box with nobody home, not a box with the safety off.
# Same image, same isolation, same entry path as every other template; the
# only thing missing is the tooling. Start here to build a new template.
BOX_DESCRIPTION="Bare Debian 13 — same isolation, no tooling"
BOX_IMAGE="images:debian/13/cloud"
BOX_USER="dev"
BOX_CPU="2"
BOX_MEMORY="2GiB"
BOX_DISK="20GiB"

View file

@ -0,0 +1,9 @@
#cloud-config
# The whole template: create the user and stop. BOX_USER in box.env must
# match the name here — the file is passed to Incus verbatim.
users:
- name: dev
shell: /bin/bash
sudo: "ALL=(ALL) NOPASSWD:ALL"
lock_passwd: true
package_update: false

12
templates/claude/box.env Normal file
View file

@ -0,0 +1,12 @@
# The claude template — Debian 13 + Claude Code, creds-free.
# KEY="value" only. Parsed against an allowlist, never sourced; there is no
# key for a network or a security flag, on purpose — the shared box-net
# profile is the placement contract and no template can weaken it.
# BOX_USER must match the user user-data.yaml creates (the cloud-init file is
# passed to Incus verbatim, so the duplication is deliberate and by hand).
BOX_DESCRIPTION="Claude Code on Debian 13, creds-free (the original claudebox)"
BOX_IMAGE="images:debian/13/cloud"
BOX_USER="claude"
BOX_CPU="4"
BOX_MEMORY="8GiB"
BOX_DISK="60GiB"

View file

@ -13,7 +13,7 @@ write_files:
# You are running inside a claudebox # You are running inside a claudebox
A claudebox is a trust-less, network-isolated, ephemeral VM created by the A claudebox is a trust-less, network-isolated, ephemeral VM created by the
`claudebox` CLI. Keep this context in mind: `box` CLI (template: claude). Keep this context in mind:
- **Creds-free by default.** The box starts with no Claude and no git - **Creds-free by default.** The box starts with no Claude and no git
credentials. If you need to authenticate Claude, the operator runs `/login` credentials. If you need to authenticate Claude, the operator runs `/login`
@ -23,7 +23,7 @@ write_files:
- **Isolated.** The box reaches the public internet but nothing on the host or - **Isolated.** The box reaches the public internet but nothing on the host or
local network. There is no inbound path. local network. There is no inbound path.
- **Disposable.** Nothing here is backed up. State is discarded when the box is - **Disposable.** Nothing here is backed up. State is discarded when the box is
removed; the operator persists work via git push and via `claudebox snapshot`. removed; the operator persists work via git push and via `box snapshot`.
- **Bootstrap runbook.** If the repository you are working in contains a - **Bootstrap runbook.** If the repository you are working in contains a
`.claudebox/` folder, read it as your setup runbook — how to install `.claudebox/` folder, read it as your setup runbook — how to install
dependencies, start services, template environment files, seed data, and dependencies, start services, template environment files, seed data, and