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
mints a fresh, network-isolated Incus box with Claude Code installed. The box is
the product — you log in and work; destroying it loses nothing you didn't push.
**Headless, trust-less, throwaway dev VMs.** One command mints a fresh,
network-isolated Incus box from a **template**; the flagship template is
`claude` — Debian 13 with Claude Code installed, the box this repo is named
for. The box is the product — you log in and work; destroying it loses
nothing you didn't push.
**Strictly creds-free.** A box ships with everything installed and **no**
credentials — no Claude token, no git PAT, nothing. You authenticate
@ -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
out of the box.
**Templates set what's in the box, never what it can reach.** A template is
image + user + resources + cloud-init; the network and every security flag
live in a shared profile no template can touch, so `blank` is a box with
nobody home — not a box with the safety off.
**The tool knows nothing about your projects.** You just `git clone` inside a
box. A repo can ship an optional [`.claudebox/`](docs/claudebox-recipe.md)
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
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
```sh
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
`PATH`. Re-run any time to upgrade. (No `git clone` needed.)
Installs the tree to `~/.local/share/claudebox` and links `box` onto your
`PATH`. Re-run any time to upgrade — upgrading from a pre-0.4.0 install also
retires the old `claudebox` symlink. (No `git clone` needed.)
## One-time host setup (Ubuntu 24.04 / Debian 13)
@ -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
bridge (sibling-name resolution off, resolver pinned to public upstreams —
`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
re-apply at boot via `claudebox-firewall.service` — no post-reboot ritual. If
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
```sh
claudebox new --name work # mint a fresh, creds-free box (~10 min cold)
claudebox shell work # enter as the claude user
box new --name work # mint a fresh, creds-free claude box (~10 min cold)
box shell work # enter as the template's user
```
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
```
## 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
Because every fresh box is creds-free, re-authenticating each time would be
toil. Snapshot an authenticated box and clone from it instead:
```sh
claudebox 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 snapshot work authed # checkpoint after you've logged in
box new --name feature --from work/authed # clone the authed state into a new box
```
`--from` copies the whole box (Claude login, git creds, clones and all) while
preserving isolation. You can also `claudebox new --name x --from work` to clone
a box's live state, or roll a box back with `claudebox restore work authed`.
preserving isolation. You can also `box new --name x --from work` to clone
a box's live state, or roll a box back with `box restore work authed`.
Forgotten what you called a checkpoint? `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.
## Commands
```
claudebox new --name <box> [--from <src>[/<snap>]] [--vm|--container] [--remote r]
claudebox list # list your boxes
claudebox info <box> # one box: state, IP, snapshot labels
claudebox shell <box> # enter as the claude user
claudebox exec <box> -- <cmd...> # run a command in the box
claudebox tmux <box> [session] # attach/create a tmux session — survives disconnects
claudebox snapshot <box> [label] # checkpoint (label defaults to manual-<epoch>)
claudebox restore <box> <snap> # roll back to a snapshot
claudebox rename <box> <new> # rename a box (stop it first)
claudebox down <box> # stop (state kept; `start` resumes)
claudebox start <box> # start a stopped box
claudebox rm <box> [--force] # delete the box + its snapshots (asks first)
claudebox incus <box> -- <args...> # escape hatch: any incus command, box resolved
claudebox doctor [--fix|--pin-dns] # is this host fit to mint boxes? diagnose from ground truth
claudebox status # deprecated alias for `list`
claudebox help [<command>] # full help, or one command's page
box new --name <box> [--template <t>] [--from <src>[/<snap>]] [--vm|--container] [--remote r]
box templates # list the templates this install can mint
box list # list your boxes
box info <box> # one box: state, IP, snapshot labels
box shell <box> # enter as the template's user
box exec <box> -- <cmd...> # run a command in the box
box tmux <box> [session] # attach/create a tmux session — survives disconnects
box snapshot <box> [label] # checkpoint (label defaults to manual-<epoch>)
box restore <box> <snap> # roll back to a snapshot
box rename <box> <new> # rename a box (stop it first)
box down <box> # stop (state kept; `start` resumes)
box start <box> # start a stopped box
box rm <box> [--force] # delete the box + its snapshots (asks first)
box incus <box> -- <args...> # escape hatch: any incus command, box resolved
box doctor [--fix|--pin-dns] # is this host fit to mint boxes? diagnose from ground truth
box status # deprecated alias for `list`
box help [<command>] # full help, or one command's page
```
Every command takes `--help`, and options come after the command
(`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.
`new` fresh-launches from cloud-init, or with `--from` clones an existing box or
snapshot. VM mode (`--vm`, the default where `/dev/kvm` exists) is the trust-less
target; container mode (auto-fallback, `security.nesting=true`) is for hosts
without nested virt — weaker isolation, dev/test only.
`new` fresh-launches from a template (default: `claude`), or with `--from`
clones an existing box or snapshot. VM mode (`--vm`, the default where
`/dev/kvm` exists) is the trust-less target; container mode (auto-fallback,
`security.nesting=true`) is for hosts without nested virt — weaker isolation,
dev/test only.
## Boxes are just Incus instances
A box is an ordinary Incus instance tagged `user.claudebox=1`. claudebox wraps
the box lifecycle and the isolation model — not all of Incus. It owns a command
A box is an ordinary Incus instance tagged `user.box=1` (pre-0.4.0 boxes
carry `user.claudebox=1`, honored forever). box wraps the box lifecycle and the isolation model — not all of Incus. It owns a command
when it must enforce something Incus can't see: that tag (it will not stop,
rename or delete an instance it didn't mint), the isolation stack, or the
creds-free snapshot workflow. For everything else, there's the door:
```sh
claudebox incus work -- config show # instance name appended
claudebox incus work -- file push x.tar {}/tmp/ # or placed with {}
box incus work -- config show # instance name appended
box incus work -- file push x.tar {}/tmp/ # or placed with {}
```
The box is resolved and tag-checked; the rest is passed to `incus` verbatim, and
the command is echoed before it runs. If it can move the box off the isolation
stack (profile, network, device, `security.*`), claudebox warns and proceeds —
stack (profile, network, device, `security.*`), box warns and proceeds —
the trust boundary is then yours to keep. See
[docs/claudebox-design.md](docs/claudebox-design.md) for the rule and why the
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 —
**no inbound path exists.**
The VM is the trust boundary: Claude can run arbitrary code inside and touch
nothing you care about.
The VM is the trust boundary: whatever runs inside — Claude, or anything a
template ships — can run arbitrary code and touch nothing you care about.
### Measured, not claimed
@ -184,7 +219,7 @@ host-executed script. See [docs/claudebox-recipe.md](docs/claudebox-recipe.md).
```sh
~/.local/share/claudebox/host/teardown-host.sh # boxes, network, ACL, profile, firewall
~/.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

View file

@ -1 +1 @@
0.3.0
0.4.0

View file

@ -1,17 +1,17 @@
#!/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
# 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.
set -euo pipefail
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
die() { echo "claudebox: $*" >&2; exit 1; } # 1 = it went wrong
usage_error() { echo "claudebox: $*" >&2; echo "try 'claudebox help'." >&2; exit 2; } # 2 = you asked wrong
version() { echo "claudebox $(cat "$root/VERSION" 2>/dev/null || echo unknown) ($root)"; }
die() { echo "box: $*" >&2; exit 1; } # 1 = it went wrong
usage_error() { echo "box: $*" >&2; echo "try 'box help'." >&2; exit 2; } # 2 = you asked wrong
version() { echo "box $(cat "$root/VERSION" 2>/dev/null || echo unknown) ($root)"; }
# ---------------------------------------------------------------------------
# The command table.
@ -22,7 +22,7 @@ version() { echo "claudebox $(cat "$root/VERSION" 2>/dev/null || echo unknown) (
#
# preconditions (comma-separated):
# 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
# stopped the box must not be running
# 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.
#
# 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
# what `claudebox incus` is for.
# doesn't enforce a box invariant, it is incus's job, not ours — that is
# what `box incus` is for.
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^"
"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^"
"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^"
@ -52,7 +53,7 @@ CMDS=(
"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^"
"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; }
@ -69,7 +70,7 @@ field() {
act) echo "$f_act" ;; ok) echo "$f_ok" ;;
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.
suggest() {
@ -97,20 +98,20 @@ suggest() {
unknown_command() {
local hint; hint="$(suggest "$1")"
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
echo "claudebox: unknown command: $1" >&2
echo "box: unknown command: $1" >&2
fi
echo "try 'claudebox help' for the command list." >&2
echo "try 'box help' for the command list." >&2
exit 2
}
usage() {
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
claudebox <command> [<args>] [options]
box <command> [<args>] [options]
COMMANDS
EOF
@ -123,6 +124,7 @@ EOF
OPTIONS
--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)
--vm Force VM mode: the trust-less target (new)
--container Force container mode: weaker isolation, (new)
@ -131,28 +133,32 @@ OPTIONS
--force, -f Delete without the confirmation prompt (rm)
--remote <r> Act on Incus remote <r> (any)
--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
# mint a box and log in inside it — the tool never handles your token
claudebox new --name work
claudebox shell work # then: run 'claude', then /login
box new --name work
box shell work # then: run 'claude', then /login
# log in once, reuse forever: checkpoint the authed box, clone from it
claudebox snapshot work authed
claudebox new --name feature --from work/authed
box snapshot 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?
claudebox list
claudebox info work
box list
box info work
# 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
claudebox incus work -- config show
# anything box doesn't wrap: boxes are plain Incus instances
box incus work -- config show
EXIT STATUS
0 ok
@ -161,15 +167,15 @@ EXIT STATUS
THE MODEL
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
inbound path. Destroying a box loses nothing you didn't push.
claudebox owns a command when it must enforce something Incus cannot see: the
user.claudebox=1 boundary, the isolation stack, or the creds-free snapshot
workflow. Everything else is Incus's job — and 'claudebox incus' is the door.
box owns a command when it must enforce something Incus cannot see: the
user.box=1 boundary, the isolation stack, or the creds-free snapshot
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
}
@ -180,29 +186,50 @@ help_cmd() {
echo
case "$1" in
new) cat <<'EOF'
Mint a box. Without --from, launches a fresh Debian 13 box from cloud-init
(~10 min cold) with Claude Code installed and NO credentials. With --from,
clones an existing box or one of its snapshots — Claude login, git creds and
clones carry over, isolation is preserved.
Mint a box. Without --from, launches a fresh box from a template (default:
claude — Debian 13 with Claude Code installed and NO credentials, ~10 min
cold). With --from, clones an existing box or one of its snapshots — login
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.
--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>.
--vm | --container Force the mode. VM is the trust boundary and the
default wherever /dev/kvm exists; container mode
(security.nesting=true) is the fallback for hosts
without nested virt — weaker isolation, dev/test only.
claudebox new --name work
claudebox new --name feature --from work/authed
Resources come from the template's box.env; BOX_CPU / BOX_MEMORY / BOX_DISK
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
;;
list) cat <<'EOF'
List the boxes claudebox minted on this host: name, state, type, snapshot count.
Takes no box — for one box, that's 'claudebox info <box>'.
List the boxes box minted on this host: name, state, type, snapshot count.
Takes no box — for one box, that's 'box info <box>'.
--json Incus's JSON, straight through, for scripting.
claudebox list
box list
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.
claudebox info work
box info work
EOF
;;
shell) cat <<'EOF'
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.
claudebox shell work
box shell work
EOF
;;
exec) cat <<'EOF'
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.
claudebox exec work -- git -C project pull
claudebox exec work -- claude --version
box exec work -- git -C project pull
box exec work -- claude --version
EOF
;;
tmux) cat <<'EOF'
@ -240,35 +267,35 @@ remember.
The session name (default: main) buys parallel streams in one box:
claudebox tmux work # attach or create 'main'
claudebox tmux work run-1 # a second, independent stream, same box
box tmux work # attach or create 'main'
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
tmux's semantics, 'claudebox shell' is unchanged.
tmux's semantics, 'box shell' is unchanged.
EOF
;;
snapshot) cat <<'EOF'
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
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.
claudebox snapshot work authed
box snapshot work authed
EOF
;;
restore) cat <<'EOF'
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
;;
rename) cat <<'EOF'
Rename a box. Incus cannot rename a running instance, so stop it first:
claudebox down work
claudebox rename work archive
claudebox start archive
box down work
box rename work archive
box start archive
Snapshots and Claude auth follow the box; anything referring to the old name by
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
(a script, a pipe), it refuses unless --force is given.
claudebox rm work
claudebox rm work --force
box rm work
box rm work --force
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
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
claudebox didn't mint.
checked against the user.box=1 tag (or its legacy spelling), so you cannot aim it at an instance
box didn't mint.
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 command that will run is echoed before it runs.
claudebox incus work -- config show
claudebox incus work -- config device add {} extra disk source=/data path=/data
box incus work -- config show
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
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.
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
(setup-host.sh now pins by default; this is the quick test)
claudebox doctor
claudebox doctor --fix
box doctor
box doctor --fix
Exit 0 = clean; 1 = problems found (each printed with its fix). Read-only
unless --fix or --pin-dns is given.
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
still works, and forwards to 'list'.
EOF
@ -330,8 +357,8 @@ EOF
help) cat <<'EOF'
Print the general help, or the help for one command.
claudebox help
claudebox help rename
box help
box help rename
EOF
;;
*) field "$1" sum ;; # no prose: the table's summary is the help
@ -349,7 +376,7 @@ cmd="${1:-help}"; shift || true
case "$cmd" in
-h|--help) usage; 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
args=()
@ -357,6 +384,7 @@ while [ $# -gt 0 ]; do
case "$1" in
--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 ;;
--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 ;;
--vm) mode=vm; 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
# --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" = exec ] || [ "$cmd" = incus ]; then
usage_error "unknown option: $1 — a command's own flags go after --, as in '$(synopsis_of "$cmd")'"
fi
usage_error "unknown option: $1 (see 'claudebox help $cmd')" ;;
usage_error "unknown option: $1 (see 'box help $cmd')" ;;
*) args+=("$1"); shift ;;
esac
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
# 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() {
local box="$1" i tag
i="$(iname_of "$box")"
tag="$(incus config get "$i" user.claudebox 2>/dev/null || true)"
[ "$tag" = "1" ] || die "no such box: $box (see 'claudebox list')"
tag="$(incus config get "$i" user.box 2>/dev/null || true)"
# 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"
}
@ -400,7 +432,7 @@ require_stopped() {
local i="$1" box="$2" st; st="$(box_state "$i")"
case "$st" in
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
}
@ -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
[ -t 0 ] || usage_error "refusing to $1 without --force (no terminal to confirm on)"
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
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() {
if [ "$mode" != auto ]; then echo "$mode"; return; fi
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
fi
}
wait_agent() {
local n="$1" i
echo "claudebox: waiting for instance agent..."
echo "box: waiting for instance agent..."
for i in $(seq 1 90); do
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)"
@ -449,14 +481,14 @@ wait_agent() {
# 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,
# 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).
#
# 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.
reset_identity() {
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
# 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
@ -475,10 +507,60 @@ reset_identity() {
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() {
[ -n "$name" ] || usage_error "usage: $(synopsis_of new)"
local instance; instance="$(iname_of "$name")"
if [ -n "$from" ]; then
[ -z "$template" ] || usage_error "--from clones an existing box; its template rides along (drop --template)"
local src="${from%%/*}" snap="" srcref
case "$from" in */*) snap="${from#*/}" ;; esac
srcref="$(iname_of "$src")"; [ -n "$snap" ] && srcref="$srcref/$snap"
@ -486,49 +568,67 @@ cmd_new() {
incus start "$instance"
wait_agent "$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
local m extra=(); m="$(pick_mode)"
# shellcheck disable=SC2054 # "root,size=60GiB" is a single incus argument
if [ "$m" = vm ]; then extra+=(--vm --device root,size=60GiB); else extra+=(--config security.nesting=true); fi
incus launch images:debian/13/cloud "$instance" --profile claude-dev \
--config user.claudebox=1 \
--config cloud-init.user-data="$(cat "$root/cloud-init/user-data.yaml")" \
local t="${template:-claude}" m extra=()
load_template "$t"
m="$(pick_mode)"
# shellcheck disable=SC2054 # "root,size=..." is a single incus argument
if [ "$m" = vm ]; then extra+=(--vm --device "root,size=$T_DISK"); else extra+=(--config security.nesting=true); fi
# 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[@]}"
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",
# 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.
if ! incus exec "$instance" -- cloud-init status --wait; then
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
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 \
"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
echo >&2
echo "claudebox: '$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 " claudebox rm $name" >&2
echo "claudebox: 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 "box: '$name' is still up — inspect it, then delete it:" >&2
echo " box incus $name -- exec {} -- tail -50 /var/log/cloud-init-output.log" >&2
echo " box rm $name" >&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: box doctor" >&2
die "cloud-init failed — the box is incomplete, so refusing to hand it over"
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
# only 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.
# Boxes are ordinary Incus instances tagged user.box=1 — that tag is the only
# thing that makes them ours, so every read below is filtered by it and we
# 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
# 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.)
boxes_csv() {
{
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.
@ -549,7 +649,7 @@ short_type() {
list_all() {
local rows; rows="$(boxes_csv)"
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
fi
{
@ -569,10 +669,10 @@ list_all() {
# guess we can answer, not a surprise: point at the command that does want one.
cmd_list() {
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
if [ "$json" -eq 1 ]; then
incus list ${remote:+"$remote"} "user.claudebox=1" --format json
incus list ${remote:+"$remote"} "user.box=1" --format json
else
list_all
fi
@ -583,7 +683,7 @@ cmd_info() {
if [ "$json" -eq 1 ]; then incus list "$inst" --format json; return; fi
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
IFS=, read -r _ state type snaps <<<"$row"
@ -595,7 +695,7 @@ cmd_info() {
''|0)
echo "SNAPSHOTS (none)"
echo
echo "Take one: claudebox snapshot $box authed"
echo "Take one: box snapshot $box authed"
return 0 ;;
esac
echo "SNAPSHOTS"
@ -606,11 +706,28 @@ cmd_info() {
printf ' %-14s%s\n' "$sname" "$taken"
done < <(incus snapshot list "$inst" --format csv 2>/dev/null)
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; }
cmd_exec() { incus exec "$inst" -- sudo -u claude -i "${args[@]:1}"; }
# Which user does a shell land in? The template stamped it on the instance at
# 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
# 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
*[!A-Za-z0-9_-]*) usage_error "session names are letters, digits, '-' and '_' — got '$session'" ;;
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() {
@ -632,7 +749,7 @@ cmd_snapshot() {
}
cmd_status() {
echo "claudebox: 'status' is deprecated — use 'claudebox list'." >&2
echo "box: 'status' is deprecated — use 'box list'." >&2
list_all
}
@ -655,8 +772,8 @@ cmd_help() { show_help "${args[0]:-}"; }
warn_isolation() {
case " $* " in
*" profile "*|*" network "*|*" device "*|*security.*|*" nic "*)
echo "claudebox: 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: warning: this can move the box off the isolation stack" >&2
echo "box: (profile / network / device / security.*). The trust boundary is yours from here." >&2 ;;
esac
}
@ -671,7 +788,7 @@ cmd_incus() {
done
[ "$replaced" -eq 1 ] || out+=("$inst")
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[@]}"
}
@ -696,7 +813,7 @@ case "$action" in
incus $sub "$inst" "${args[@]:1}"
if [ -n "$ok" ]; then
msg="${ok//\{\}/${args[0]}}"; msg="${msg//\{1\}/${args[1]:-}}"
echo "claudebox: $msg"
echo "box: $msg"
fi
;;
esac

View file

@ -1,6 +1,6 @@
#!/usr/bin/env bash
# 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 --fix # report, then revert what the drill left behind
@ -93,29 +93,38 @@ else
inf " (or: sudo systemctl restart claudebox-firewall.service)"
fi
head_ "Profile — claude-dev (the NIC is the isolation contract)"
if incus profile show claude-dev >/dev/null 2>&1; then
iso="$(incus profile device get claude-dev eth0 security.port_isolation 2>/dev/null)"
# box-net is the placement contract since the 0.4.0 rename; claude-dev is its
# pre-rename ancestor and may linger while legacy boxes still reference it.
# Check whichever exist — an unisolated NIC is a fault on either.
PROFILES=""
incus profile show box-net >/dev/null 2>&1 && PROFILES="box-net"
incus profile show claude-dev >/dev/null 2>&1 && PROFILES="$PROFILES claude-dev"
head_ "Profile — the NIC is the isolation contract"
if [ -n "$PROFILES" ]; then
for p in $PROFILES; do
[ "$p" = claude-dev ] && inf "claude-dev is legacy (pre-rename boxes still reference it)"
iso="$(incus profile device get "$p" eth0 security.port_isolation 2>/dev/null)"
if [ "$iso" = "true" ]; then
ok "security.port_isolation = true — boxes cannot reach each other at L2"
ok "$p: security.port_isolation = true — boxes cannot reach each other at L2"
else
no "security.port_isolation is NOT set — BOXES CAN REACH EACH OTHER"
no "$p: security.port_isolation is NOT set — BOXES CAN REACH EACH OTHER"
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
for k in security.mac_filtering security.ipv4_filtering; do
v="$(incus profile device get claude-dev eth0 "$k" 2>/dev/null)"
v="$(incus profile device get "$p" eth0 "$k" 2>/dev/null)"
if [ -z "$v" ]; then
ok "$k unset (as shipped)"
ok "$p: $k unset (as shipped)"
else
no "$k = $v ← phase D left this behind. A box can fail to get on the network at all."
[ "$FIX" = 1 ] && { incus profile device unset claude-dev eth0 "$k" && inf "reverted: $k unset"; }
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
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)"
done
inf "resources are per-box since 0.4.0 (stamped from the template at mint; BOX_CPU/BOX_MEMORY override)"
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
head_ "ACL — claude-isolate"
@ -203,8 +212,10 @@ if [ "$PIN" = 1 ]; then
fi
head_ "Can a box actually resolve DNS?"
# Any box will do — the drill's names are not the only boxes on a host.
probe="$(incus list "user.claudebox=1" --format csv --columns ns 2>/dev/null \
# Any box will do — the drill's names are not the only boxes on a host, and
# 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 }')"
if [ -n "$probe" ] && [ "$FIX" != 1 ]; then
# 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
# 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.
# 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 --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
#
# 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.
# 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)
# D. Hardening rehearsal — #16's proposed changes, applied live and re-probed
# (#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
local b="$1" _i
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
done
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.
#
# 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.
# · $( ) waits for stdout to CLOSE, not for the command to exit. A grandchild
# 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
fi
phase "Installing claudebox ($REPO@$REF)"
phase "Installing box ($REPO@$REF)"
CLAUDEBOX_REPO="$REPO" CLAUDEBOX_REF="$REF" \
bash -c "$(curl -fsSL "https://raw.githubusercontent.com/$REPO/$REF/install.sh")" \
|| { 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
# revert it. Only the vetoed NIC filtering counts as leftover.
dirty=""
[ -n "$(incus profile device get claude-dev eth0 security.ipv4_filtering 2>/dev/null)" ] && dirty="$dirty ipv4_filtering"
[ -n "$(incus profile device get claude-dev eth0 security.mac_filtering 2>/dev/null)" ] && dirty="$dirty mac_filtering"
for p in box-net claude-dev; do
[ -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
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
incus profile device unset claude-dev eth0 security.ipv4_filtering >/dev/null 2>&1
for p in box-net claude-dev; do
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
inf "clearing anything a previous run left behind…"
@ -231,10 +235,12 @@ done
if incus network show claudenet >/dev/null 2>&1; then
timeout -k 5 30 incus network unset claudenet dns.mode >/dev/null 2>&1
fi
if incus profile show claude-dev >/dev/null 2>&1; then
timeout -k 5 30 incus profile device unset claude-dev eth0 security.mac_filtering >/dev/null 2>&1
timeout -k 5 30 incus profile device unset claude-dev eth0 security.ipv4_filtering >/dev/null 2>&1
for p in box-net claude-dev; do
if incus profile show "$p" >/dev/null 2>&1; then
timeout -k 5 30 incus profile device unset "$p" eth0 security.mac_filtering >/dev/null 2>&1
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' ' ')"
[ -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
inf "host setup complete"
# A real server has room for the production profile (8GiB/4cpu), and drilling the
# real profile is worth more than drilling a shrunken one. Only shrink if we must.
# A real server has room for the claude template's resources (8GiB/4cpu), and
# 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)"
if [ "$ram" -lt 20 ]; then
incus profile set claude-dev limits.memory=3GiB limits.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)"
export BOX_MEMORY=3GiB BOX_CPU=2
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
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
KVM=0; [ -e /dev/kvm ] && KVM=1
[ "$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
sleep 3
# A1 — the tag read. #13 puts this on the path of EVERY box command.
t="$(incus config get cbprobe user.claudebox 2>&1)"
[ "$t" = "1" ] && ok "config get user.claudebox → '1'" \
|| no "config get user.claudebox → '$t' (expected '1'; every box command would fail closed)"
t="$(incus config get cbprobe user.box 2>&1)"
[ "$t" = "1" ] && ok "config get user.box → '1'" \
|| 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
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
ok "list filter user.claudebox=1 selects ours, excludes theirs"
ok "list filter user.box=1 selects ours, excludes theirs"
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
# 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
# ===========================================================================
phase "B. The claudebox surface"
phase "B. The box surface"
# ===========================================================================
# Compare against the installed tree's VERSION file, not a hardcoded number —
# a pinned literal here would fail the drill on every release.
expected="$(cat "$HOME/.local/share/claudebox/VERSION" 2>/dev/null || echo '?')"
v="$(claudebox --version 2>&1)"
case "$v" in *"$expected"*) ok "claudebox --version → $v" ;; *) no "version mismatch: CLI says '$v', VERSION file says '$expected'" ;; esac
v="$(box --version 2>&1)"
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
# 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
# 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
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
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"
fi
printf '\n minting a box (cold, ~10 min)…\n'
t0=$SECONDS
if claudebox new --name drill >/tmp/new.log 2>&1; then
ok "claudebox new --name drill ($((SECONDS - t0))s)"
if box new --name drill >/tmp/new.log 2>&1; then
ok "box new --name drill ($((SECONDS - t0))s)"
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
fi
typ="$(claudebox list | awk '$1 == "drill" { print $3 }')"
typ="$(box list | awk '$1 == "drill" { print $3 }')"
if [ "$KVM" = 1 ]; then
[ "$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"
@ -377,80 +387,80 @@ else
note "the box is '$typ' (no /dev/kvm on this host)"
fi
claudebox 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 '^IPV4' && ok "info shows an IPv4" || no "info has no IPV4 row"
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"
elif timeout 30 claudebox 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'"
inf "PATH as exec sees it: $(timeout 30 claudebox exec drill -- printenv PATH 2>/dev/null)"
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 'box exec work -- claude --version'"
inf "PATH as exec sees it: $(timeout 30 box exec drill -- printenv PATH 2>/dev/null)"
else
no "'claude --version' failed inside the box"
# diag output must skip the hatch's own 'claudebox: incus exec …' announce lines
hatch_out() { timeout 30 claudebox incus drill -- exec {} -- "$@" 2>&1 | grep -v '^claudebox:' | tail -1 | cut -c1-120; }
# diag output must skip the hatch's own 'box: incus exec …' announce lines
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 "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
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"
# --- 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"
claudebox 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 snapshot drill authed 2>&1 | grep -q authed && ok "snapshot drill authed" || no "snapshot failed"
box info drill | grep -q 'authed' && ok "info lists the snapshot label" || no "info does not show the label"
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
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!"
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!"
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 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!"
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!"
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 delete -f payroll >/dev/null 2>&1
# --- 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"
claudebox 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"
claudebox 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 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 down drill >/dev/null 2>&1 && ok "down drill" || no "down failed"
box rename drill archive 2>&1 | grep -q 'renamed drill to archive' && ok "rename drill → archive (stopped)" || no "rename failed on a stopped box"
box list | grep -q '^archive' && ok "list shows the new name" || no "list still shows the old name"
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 --------------------------------
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)"
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
no "clone FAILED — tail: $(tail -3 /tmp/clone.log | tr '\n' ' ')"
fi
# --- 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"
h="$(claudebox incus archive -- config device add {} scratch disk source=/tmp path=/mnt/scratch 2>&1)"
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="$(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"
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 --------------------------
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!"
claudebox rm clone --force 2>&1 | grep -q 'removed' && ok "rm --force removes the clone" || no "rm --force failed"
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!"
box rm clone --force 2>&1 | grep -q 'removed' && ok "rm --force removes the clone" || no "rm --force failed"
# --- 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"
claudebox list archive 2>&1 | grep -q 'claudebox 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 lst 2>&1 | grep -q "did you mean 'list'" && ok "typo → did-you-mean, exit 2" || no "unknown command not suggested"
box list archive 2>&1 | grep -q 'box info archive' && ok "'list <box>' points at info" || no "'list <box>' does not point at info"
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)"
# ===========================================================================
claudebox start archive >/dev/null 2>&1
box start archive >/dev/null 2>&1
wait_box archive && ok "archive is back up (agent answering)" \
|| no "archive did not come back within 2 min of start"
# Sibling isolation needs a sibling. Clone from the snapshot — fast, no cold mint.
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"
else
no "peer clone failed or never answered — tail: $(tail -3 /tmp/peer.log | tr '\n' ' ')"
@ -607,15 +617,15 @@ fi
# ===========================================================================
if [ "$KEEP" = 1 ]; then
phase "Boxes left up (--keep-boxes)"
claudebox list
box list
inf "note: the D-phase mutations (dns.mode=none, NIC filtering) are still applied"
else
# 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
# already embodies the discipline (only names the drill minted); demanding
# '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" \
|| no "a drill box survived teardown: $(printf '%s' "$leftover" | awk '{print $1}' | tr '\n' ' ')"
fi

View file

@ -1,6 +1,6 @@
#!/usr/bin/env bash
# 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
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.
sudo systemctl restart claudebox-firewall.service
# Profile
if ! incus profile show claude-dev >/dev/null 2>&1; then
incus profile create claude-dev
# Profile — box-net, the placement contract: the isolated NIC and the root
# disk, nothing a template controls (resources are stamped per-instance from
# 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
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
# 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
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"
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 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
#
# 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}"
REF="${CLAUDEBOX_REF:-main}"
@ -26,7 +26,7 @@ trap cleanup EXIT
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"
curl -fsSL "$URL" -o "$TMPDIR/claudebox.tar.gz" \
|| 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>/
EXTRACTED="$(find "$TMPDIR" -maxdepth 1 -type d -name 'claudebox-*' | head -n1)"
[ -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 ---------------------------------------------
log "installing into $DEST"
@ -46,12 +46,19 @@ rm -rf "$DEST"
mkdir -p "$(dirname "$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"
ln -sf "$DEST/bin/claudebox" "$BINDIR/claudebox"
log "linked $BINDIR/claudebox -> $DEST/bin/claudebox"
ln -sf "$DEST/bin/box" "$BINDIR/box"
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 ------------------------------------------------------------
case ":$PATH:" in
@ -65,8 +72,8 @@ esac
# --- environment check -----------------------------------------------------
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"
fi
log "done — try: claudebox new --name test"
log "done — try: box new --name test"

View file

@ -1,8 +1,11 @@
name: claude-dev
description: Trust-less claudebox (resources + isolated NIC)
config:
limits.cpu: "4"
limits.memory: 8GiB
# The placement contract. Every box, whatever its template, launches with
# exactly this profile: the isolated NIC and the root disk — and NOTHING a
# template controls. Resources (limits.*) are per-instance, stamped from the
# template's box.env at mint time; they do not belong here, because a profile
# 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:
eth0:
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
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
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
local network. There is no inbound path.
- **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
`.claudebox/` folder, read it as your setup runbook — how to install
dependencies, start services, template environment files, seed data, and