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

Merged
dan-claude-bot merged 7 commits from feat/box-rename into main 2026-07-14 15:44:10 +00:00
16 changed files with 898 additions and 424 deletions

138
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,34 @@ 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 is a clean cut**: the CLI is `box` (no `claudebox` shim), the host
> stack is `boxnet`/`box-isolate`/`box-firewall` on 10.88.0.0/24, and the
> default template is `blank`. Existing boxes minted by any earlier version
> keep working under every verb — their legacy tag is honored forever, and
> their old `claudenet` (10.87) is left standing beside the new bridge. To
> strip a host of both generations at once: `host/teardown-host.sh`, or
> `drill/wipe.sh` for the scorched-earth version.
## Install ## 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)
@ -31,20 +47,20 @@ Installs the tree to `~/.local/share/claudebox` and links `claudebox` onto your
~/.local/share/claudebox/host/setup-host.sh # run twice if it adds you to incus-admin (re-login between) ~/.local/share/claudebox/host/setup-host.sh # run twice if it adds you to incus-admin (re-login between)
``` ```
Idempotent. Installs Incus and creates the isolation stack: the `claudenet` NAT Idempotent. Installs Incus and creates the isolation stack: the `boxnet` 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 `box-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 `box-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):
`sudo apt-get install -y dnsmasq-base`. `sudo apt-get install -y dnsmasq-base`.
## Quick start ## Quick start
```sh ```sh
claudebox new --name work # mint a fresh, creds-free box (~10 min cold) box new --name work --template claude # a 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 +73,93 @@ 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 # the DEFAULT template is blank: bare Debian,
# same isolation, nobody home
```
A template **cannot** name a network, a profile, or a `security.*` flag —
there is no key for them. Every box launches with the shared `box-net`
profile (the isolated NIC + root disk), so every template gets the identical
trust boundary. Resources come from the template's `box.env`;
`BOX_CPU` / `BOX_MEMORY` / `BOX_DISK` environment variables override them at
mint time. The template's identity (name, user) is stamped onto the instance,
so `shell`, `exec` and `tmux` land in the right user — and a clone still
knows, because `incus copy` carries the metadata.
## Log in once, reuse via snapshots ## 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.
@ -130,10 +170,10 @@ The contract: **a box reaches the public internet and nothing else.** Not the
host, not your LAN, not another box, not even another box's *name*. What host, not your LAN, not another box, not even another box's *name*. What
enforces it, layer by layer: enforces it, layer by layer:
- **Dedicated NAT bridge** `claudenet`, IPv6 off. Every rule below is - **Dedicated NAT bridge** `boxnet`, IPv6 off. Every rule below is
IPv4-only, so IPv6 would be an uncovered path — off is part of the IPv4-only, so IPv6 would be an uncovered path — off is part of the
contract, not a default. contract, not a default.
- **`claude-isolate` ACL** — drops all egress to private space (RFC1918, - **`box-isolate` ACL** — drops all egress to private space (RFC1918,
CGNAT, link-local), with a single carve-out to the gateway so DNS works. CGNAT, link-local), with a single carve-out to the gateway so DNS works.
- **Sibling isolation, at L2** — two boxes on one bridge are *switched*, - **Sibling isolation, at L2** — two boxes on one bridge are *switched*,
never routed, so no L3 rule can separate them (learned the hard way; see never routed, so no L3 rule can separate them (learned the hard way; see
@ -147,8 +187,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
@ -164,6 +204,8 @@ history, including every trap that fooled a run into a wrong verdict.
```sh ```sh
bash drill/doctor.sh # read-only: is this host healthy and the stack live? bash drill/doctor.sh # read-only: is this host healthy and the stack live?
bash drill/drill.sh # FULL end-to-end — mutates the host; use a machine you own bash drill/drill.sh # FULL end-to-end — mutates the host; use a machine you own
bash drill/wipe.sh # scorched earth: strip BOTH name generations, images and
# (--purge-storage) the pool, so a run starts from bare
``` ```
The doctor reads ground truth, not config claims — the kernel's `isolated on` The doctor reads ground truth, not config claims — the kernel's `isolated on`
@ -184,7 +226,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: blank), 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: blank) (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 claude box and log in inside it — the tool never handles your token
claudebox new --name work box new --name work --template claude
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
# the default: a blank box — same isolation, nobody home
box new --name scratch
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,51 @@ 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, blank — bare Debian 13, nobody home; --template claude gets Claude Code
clones an existing box or one of its snapshots — Claude login, git creds and installed, creds-free, ~10 min cold). With --from, clones an existing box or
clones carry over, isolation is preserved. 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. --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 scratch # blank, the default
box new --name work --template claude
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 +239,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 +268,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,50 +307,50 @@ 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
;; ;;
doctor) cat <<'EOF' doctor) cat <<'EOF'
Answer "is this host fit to mint boxes?" from ground truth, not config claims: Answer "is this host fit to mint boxes?" from ground truth, not config claims:
is the Incus daemon answering, is a dnsmasq actually serving claudenet, does is the Incus daemon answering, is a dnsmasq actually serving boxnet, does
the kernel's bridge port say 'isolated on', is the resolver pinned or is a the kernel's bridge port say 'isolated on', is the resolver pinned or is a
host VPN's DNS leaking into boxes, can a box actually resolve names. Every host VPN's DNS leaking into boxes, can a box actually resolve names. Every
check exists because its fault has happened — most kill a cold mint with a check exists because its fault has happened — most kill a cold mint with a
cloud-init error that names none of them. cloud-init error that names none of them.
--fix also revert what a drill run may have left behind --fix also revert what a drill run may have left behind
--pin-dns pin claudenet's resolver to public upstreams and re-test --pin-dns pin boxnet'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 +358,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 +377,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 +385,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 +397,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 +414,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 +433,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 +453,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,17 +463,27 @@ 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
} }
# Five minutes, not three: the first VM launch on a fresh pool unpacks the
# image into a pool volume and takes the coldest possible boot — measured
# live, an agent can need past the 3-minute mark exactly once per pool while
# every later boot answers in seconds. And when it still fails, ship the
# forensics: the VM's console says why, and the box is torn down by whoever
# called us before anyone can read it.
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 150); do
if incus exec "$n" -- true >/dev/null 2>&1; then return; fi if incus exec "$n" -- true </dev/null >/dev/null 2>&1; then return; fi
[ "$i" -eq 90 ] && die "instance agent never came up (incus console $n to inspect)" if [ "$i" -eq 150 ]; then
echo "box: instance agent never came up. The VM's console log:" >&2
timeout -k 5 15 incus console "$n" --show-log 2>/dev/null | tail -15 | sed 's/^/ /' >&2
die "agent unreachable after 5 minutes (incus console $n to inspect live)"
fi
sleep 2 sleep 2
done done
} }
@ -449,14 +492,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
@ -467,7 +510,7 @@ reset_identity() {
systemd-machine-id-setup >/dev/null 2>&1 || dbus-uuidgen > /etc/machine-id systemd-machine-id-setup >/dev/null 2>&1 || dbus-uuidgen > /etc/machine-id
ln -sf /etc/machine-id /var/lib/dbus/machine-id ln -sf /etc/machine-id /var/lib/dbus/machine-id
test -s /etc/machine-id test -s /etc/machine-id
' || die "could not reset the clone's machine-id" ' </dev/null || die "could not reset the clone's machine-id"
# The new id only takes effect at boot. Ask nicely, then insist — a clone that # The new id only takes effect at boot. Ask nicely, then insist — a clone that
# keeps its source's DHCP lease is worse than an unclean stop of a box that # keeps its source's DHCP lease is worse than an unclean stop of a box that
# booted 30 seconds ago. # booted 30 seconds ago.
@ -475,10 +518,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 +579,82 @@ 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:-blank}" 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)..."
echo "box: (its full narration, live: incus exec $name -- tail -f /var/log/cloud-init-output.log)"
# 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 # Every non-interactive exec pins stdin. With a TTY on stdin, 'incus exec'
# goes interactive — and when box's own output is redirected (a script, the
# drill), the session can wedge open after the remote command has exited,
# blocking forever on a websocket that will never close. Caught live: a
# mint stuck at 'status: done'. Only shell/exec/tmux may own the terminal.
# PYTHONUNBUFFERED: cloud-init's progress dots are block-buffered the
# moment stdout is not a tty — a redirected mint (a script, the drill)
# shows NOTHING for the whole install and then one burst at the end,
# which reads exactly like a hang. Unbuffered, the dots arrive as dots.
if ! incus exec "$instance" -- env PYTHONUNBUFFERED=1 cloud-init status --wait </dev/null; 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 </dev/null 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 </dev/null 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." # The login hint belongs to the claude template — read the EFFECTIVE
# template off the instance, so a clone of a claude box gets it too and a
# blank box is not told to run a binary it doesn't have.
local eff; eff="$(incus config get "$instance" user.box.template 2>/dev/null || true)"
[ -z "$eff" ] && [ "$(incus config get "$instance" user.claudebox 2>/dev/null || true)" = 1 ] && eff=claude
if [ "$eff" = claude ]; then
echo "box: ready — 'box shell $name'. Log into Claude inside: run 'claude' then /login."
else
echo "box: ready — 'box shell $name'."
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 +675,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 +695,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 +709,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 +721,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 +732,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 +765,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 +775,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 +798,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 +814,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 +839,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
@ -40,19 +40,19 @@ timeout 10 incus list >/dev/null 2>&1 || {
exit 1 exit 1
} }
head_ "Network — claudenet" head_ "Network — boxnet"
if incus network show claudenet >/dev/null 2>&1; then if incus network show boxnet >/dev/null 2>&1; then
# dns.mode=none is SHIPPED — it is what stops a box enumerating its siblings # dns.mode=none is SHIPPED — it is what stops a box enumerating its siblings
# through the gateway's dnsmasq. Its ABSENCE is the problem, not its presence. # through the gateway's dnsmasq. Its ABSENCE is the problem, not its presence.
dns="$(incus network get claudenet dns.mode 2>/dev/null)" dns="$(incus network get boxnet dns.mode 2>/dev/null)"
if [ "$dns" = none ]; then if [ "$dns" = none ]; then
ok "dns.mode = none — a box cannot enumerate its siblings by name" ok "dns.mode = none — a box cannot enumerate its siblings by name"
else else
no "dns.mode = ${dns:-<unset>} — a box can RESOLVE its siblings' names and addresses" no "dns.mode = ${dns:-<unset>} — a box can RESOLVE its siblings' names and addresses"
inf "fix: re-run ~/.local/share/claudebox/host/setup-host.sh" inf "fix: re-run ~/.local/share/claudebox/host/setup-host.sh"
[ "$FIX" = 1 ] && { incus network set claudenet dns.mode=none && inf "set: dns.mode=none"; } [ "$FIX" = 1 ] && { incus network set boxnet dns.mode=none && inf "set: dns.mode=none"; }
fi fi
inf "ipv4.address = $(incus network get claudenet ipv4.address 2>/dev/null)" inf "ipv4.address = $(incus network get boxnet ipv4.address 2>/dev/null)"
# Incus reports the network as "Created" whether or not anything is actually # Incus reports the network as "Created" whether or not anything is actually
# SERVING it. Kill the daemon uncleanly (a wedge, an OOM, a SIGKILL) and it # SERVING it. Kill the daemon uncleanly (a wedge, an OOM, a SIGKILL) and it
# can come back without respawning this network's dnsmasq — the bridge is up, # can come back without respawning this network's dnsmasq — the bridge is up,
@ -60,75 +60,84 @@ if incus network show claudenet >/dev/null 2>&1; then
# so it dies deep inside cloud-init with "Temporary failure resolving # so it dies deep inside cloud-init with "Temporary failure resolving
# deb.debian.org". Two cold mints and an hour of hunting went into learning # deb.debian.org". Two cold mints and an hour of hunting went into learning
# that Incus's own status does not cover this. Ask the process table instead. # that Incus's own status does not cover this. Ask the process table instead.
if pgrep -af 'dnsmasq.*--interface=claudenet' >/dev/null 2>&1; then if pgrep -af 'dnsmasq.*--interface=boxnet' >/dev/null 2>&1; then
ok "a dnsmasq is serving claudenet (DHCP + DNS)" ok "a dnsmasq is serving boxnet (DHCP + DNS)"
else else
no "NO dnsmasq is serving claudenet — the bridge is up and incus says 'Created', but nothing hands out leases" no "NO dnsmasq is serving boxnet — the bridge is up and incus says 'Created', but nothing hands out leases"
inf "every box minted now gets no address, no DNS, and dies in cloud-init" inf "every box minted now gets no address, no DNS, and dies in cloud-init"
inf "fix: timeout 60 incus delete -f <any boxes>; sudo systemctl restart incus" inf "fix: timeout 60 incus delete -f <any boxes>; sudo systemctl restart incus"
inf " (if it does not come back: teardown-host.sh, then re-run the drill)" inf " (if it does not come back: teardown-host.sh, then re-run the drill)"
[ "$FIX" = 1 ] && { [ "$FIX" = 1 ] && {
inf "restarting incus to respawn it…" inf "restarting incus to respawn it…"
sudo systemctl restart incus && sleep 5 sudo systemctl restart incus && sleep 5
pgrep -af 'dnsmasq.*--interface=claudenet' >/dev/null 2>&1 \ pgrep -af 'dnsmasq.*--interface=boxnet' >/dev/null 2>&1 \
&& inf "reverted: dnsmasq is serving claudenet again" \ && inf "reverted: dnsmasq is serving boxnet again" \
|| inf "STILL missing — run teardown-host.sh and let the drill rebuild the network" || inf "STILL missing — run teardown-host.sh and let the drill rebuild the network"
} }
fi fi
ipv6="$(incus network get claudenet ipv6.address 2>/dev/null)" ipv6="$(incus network get boxnet ipv6.address 2>/dev/null)"
[ "$ipv6" = none ] && ok "ipv6.address = none (the isolation contract — every ACL rule is IPv4-only)" \ [ "$ipv6" = none ] && ok "ipv6.address = none (the isolation contract — every ACL rule is IPv4-only)" \
|| no "ipv6.address = $ipv6 — IPv6 is on and NOT covered by any ACL rule" || no "ipv6.address = $ipv6 — IPv6 is on and NOT covered by any ACL rule"
else else
inf "claudenet does not exist (a fresh host — setup-host.sh will create it)" inf "boxnet does not exist (a fresh host — setup-host.sh will create it)"
fi fi
head_ "Firewall — the box-to-box drop" head_ "Firewall — the box-to-box drop"
if sudo nft list table bridge claudebox >/dev/null 2>&1; then if sudo nft list table bridge box >/dev/null 2>&1; then
ok "nft bridge table 'claudebox' is present — boxes cannot reach each other" ok "nft bridge table 'box' is present — boxes cannot reach each other"
else else
no "the box-to-box drop is MISSING — boxes can reach each other" no "the box-to-box drop is MISSING — boxes can reach each other"
inf "an L3 ACL never sees frames switched between two ports of one bridge;" inf "an L3 ACL never sees frames switched between two ports of one bridge;"
inf "the drop is an nft BRIDGE-family rule, and without it siblings are wide open." inf "the drop is an nft BRIDGE-family rule, and without it siblings are wide open."
inf "fix: sudo /usr/local/sbin/claudebox-firewall" inf "fix: sudo /usr/local/sbin/box-firewall"
inf " (or: sudo systemctl restart claudebox-firewall.service)" inf " (or: sudo systemctl restart box-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 — box-isolate"
if incus network acl show claude-isolate >/dev/null 2>&1; then if incus network acl show box-isolate >/dev/null 2>&1; then
n="$(incus network acl show claude-isolate | grep -c 'action:' || true)" n="$(incus network acl show box-isolate | grep -c 'action:' || true)"
inf "$n rules" inf "$n rules"
incus network acl show claude-isolate | grep -E 'action:|destination:' | sed 's/^/ /' incus network acl show box-isolate | grep -E 'action:|destination:' | sed 's/^/ /'
if incus network acl show claude-isolate | grep -q '@internal'; then if incus network acl show box-isolate | grep -q '@internal'; then
no "an @internal rule survived phase D" no "an @internal rule survived phase D"
[ "$FIX" = 1 ] && { incus network acl rule remove claude-isolate egress action=drop destination=@internal && inf "reverted: @internal rule removed"; } [ "$FIX" = 1 ] && { incus network acl rule remove box-isolate egress action=drop destination=@internal && inf "reverted: @internal rule removed"; }
fi fi
else else
inf "claude-isolate does not exist (a fresh host)" inf "box-isolate does not exist (a fresh host)"
fi fi
# Config is a claim; the bridge port is the fact. Incus can accept # Config is a claim; the bridge port is the fact. Incus can accept
@ -141,9 +150,9 @@ for c in bridge /usr/sbin/bridge /sbin/bridge; do
sudo "$c" -V >/dev/null 2>&1 && { BRIDGE="$c"; break; } sudo "$c" -V >/dev/null 2>&1 && { BRIDGE="$c"; break; }
done done
if [ -n "$BRIDGE" ]; then if [ -n "$BRIDGE" ]; then
ports="$(sudo "$BRIDGE" -d link show 2>/dev/null | grep -A1 'master claudenet')" ports="$(sudo "$BRIDGE" -d link show 2>/dev/null | grep -A1 'master boxnet')"
if [ -z "$ports" ]; then if [ -z "$ports" ]; then
inf "no instance is attached to claudenet right now (mint a box to check the taps)" inf "no instance is attached to boxnet right now (mint a box to check the taps)"
else else
printf '%s\n' "$ports" | sed 's/^/ /' printf '%s\n' "$ports" | sed 's/^/ /'
if printf '%s' "$ports" | grep -q 'isolated on'; then if printf '%s' "$ports" | grep -q 'isolated on'; then
@ -173,9 +182,9 @@ done
head_ "Host resolver — a box's DNS is forwarded through this" head_ "Host resolver — a box's DNS is forwarded through this"
hostns="$(grep -E '^nameserver' /etc/resolv.conf 2>/dev/null | awk '{print $2}' | tr '\n' ' ')" hostns="$(grep -E '^nameserver' /etc/resolv.conf 2>/dev/null | awk '{print $2}' | tr '\n' ' ')"
inf "/etc/resolv.conf: ${hostns:-<none>}" inf "/etc/resolv.conf: ${hostns:-<none>}"
raw="$(incus network get claudenet raw.dnsmasq 2>/dev/null | tr '\n' ';')" raw="$(incus network get boxnet raw.dnsmasq 2>/dev/null | tr '\n' ';')"
if [ -n "$raw" ]; then if [ -n "$raw" ]; then
ok "claudenet has a pinned resolver (raw.dnsmasq: $raw)" ok "boxnet has a pinned resolver (raw.dnsmasq: $raw)"
inf "boxes do NOT inherit the host's resolver — good (issue #33)" inf "boxes do NOT inherit the host's resolver — good (issue #33)"
else else
# 100.64.0.0/10 is CGNAT — which is exactly Tailscale's range. # 100.64.0.0/10 is CGNAT — which is exactly Tailscale's range.
@ -191,8 +200,8 @@ else
fi fi
if [ "$PIN" = 1 ]; then if [ "$PIN" = 1 ]; then
head_ "Pinning claudenet's resolver (issue #33)" head_ "Pinning boxnet's resolver (issue #33)"
if incus network set claudenet raw.dnsmasq "$(printf 'no-resolv\nserver=1.1.1.1\nserver=8.8.8.8\n')" 2>/tmp/pin.err; then if incus network set boxnet raw.dnsmasq "$(printf 'no-resolv\nserver=1.1.1.1\nserver=8.8.8.8\n')" 2>/tmp/pin.err; then
ok "set raw.dnsmasq: no-resolv + 1.1.1.1 + 8.8.8.8 — dnsmasq now ignores /etc/resolv.conf" ok "set raw.dnsmasq: no-resolv + 1.1.1.1 + 8.8.8.8 — dnsmasq now ignores /etc/resolv.conf"
inf "a box's DNS no longer depends on the host's VPN state, and MagicDNS is out of the path" inf "a box's DNS no longer depends on the host's VPN state, and MagicDNS is out of the path"
inf "re-run the drill; if the cold mint now succeeds, issue #33 is confirmed and the fix belongs in setup-host.sh" inf "re-run the drill; if the cold mint now succeeds, issue #33 is confirmed and the fix belongs in setup-host.sh"
@ -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
@ -215,8 +226,8 @@ if [ -n "$probe" ] && [ "$FIX" != 1 ]; then
inf "its resolv.conf: $(timeout -k 5 20 incus exec "$probe" -- sh -c 'grep -m2 nameserver /etc/resolv.conf' </dev/null 2>/dev/null | tr '\n' ' ')" inf "its resolv.conf: $(timeout -k 5 20 incus exec "$probe" -- sh -c 'grep -m2 nameserver /etc/resolv.conf' </dev/null 2>/dev/null | tr '\n' ' ')"
# Routing is probed by ADDRESS against the public internet, NOT by pinging # Routing is probed by ADDRESS against the public internet, NOT by pinging
# the gateway: claudebox-firewall.sh drops everything from a box to the host # the gateway: box-firewall.sh drops everything from a box to the host
# except DNS/DHCP, so ICMP to 10.87.0.1 fails BY DESIGN on a healthy host. # except DNS/DHCP, so ICMP to 10.88.0.1 fails BY DESIGN on a healthy host.
# A gateway ping here is a check that can only ever lie. # A gateway ping here is a check that can only ever lie.
if timeout -k 5 25 incus exec "$probe" -- curl -sS -m 10 -o /dev/null https://1.1.1.1 </dev/null 2>/dev/null; then if timeout -k 5 25 incus exec "$probe" -- curl -sS -m 10 -o /dev/null https://1.1.1.1 </dev/null 2>/dev/null; then
routing=1; ok "reaches 1.1.1.1 by address — egress routing is fine" routing=1; ok "reaches 1.1.1.1 by address — egress routing is fine"

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,
@ -86,20 +86,20 @@ in_box() {
return "$rc" return "$rc"
} }
# The box's address ON CLAUDENET. Three ways to get this wrong, all of them hit: # The box's address ON BOXNET. Three ways to get this wrong, all of them hit:
# · 'incus list' name filters are NOT regexes ("^b$" silently matches nothing) # · 'incus list' name filters are NOT regexes ("^b$" silently matches nothing)
# · its CSV quotes a multi-address box across lines # · its CSV quotes a multi-address box across lines
# · and the interface is NOT called eth0. The PROFILE names the device eth0, # · and the interface is NOT called eth0. The PROFILE names the device eth0,
# but inside a VM guest predictable naming renames it enp5s0. Six runs of # but inside a VM guest predictable naming renames it enp5s0. Six runs of
# A3 "not probed" were this, not the network. # A3 "not probed" were this, not the network.
# So: read it from inside the box, and select by SUBNET (10.87.x, what claudenet # So: read it from inside the box, and select by SUBNET (10.88.x, what boxnet
# hands out) rather than by interface name — docker0 (172.17.x) is the decoy, # hands out) rather than by interface name — docker0 (172.17.x) is the decoy,
# and the NIC's name is the guest's business, not ours. # and the NIC's name is the guest's business, not ours.
claudenet_ip() { boxnet_ip() {
local b="$1" ip _i local b="$1" ip _i
for _i in $(seq 1 15); do for _i in $(seq 1 15); do
ip="$(in_box "$b" ip -4 -o addr show scope global \ ip="$(in_box "$b" ip -4 -o addr show scope global \
| awk '{ for (i = 1; i < NF; i++) if ($i == "inet" && $(i+1) ~ /^10\.87\./) { split($(i+1), a, "/"); print a[1]; exit } }')" | awk '{ for (i = 1; i < NF; i++) if ($i == "inet" && $(i+1) ~ /^10\.88\./) { split($(i+1), a, "/"); print a[1]; exit } }')"
[ -n "$ip" ] && { printf '%s\n' "$ip"; return 0; } [ -n "$ip" ] && { printf '%s\n' "$ip"; return 0; }
sleep 2 sleep 2
done done
@ -137,15 +137,29 @@ box_pings() { # box_pings <box> <ip> → 0 if it answers ICMP
timeout -k 5 20 incus exec "$1" -- ping -c1 -W2 "$2" >/dev/null 2>&1 </dev/null timeout -k 5 20 incus exec "$1" -- ping -c1 -W2 "$2" >/dev/null 2>&1 </dev/null
} }
# Mint with a heartbeat. box new's own narration lands in the log; a dot every
# 5s on the drill's terminal proves the run is ALIVE — a silent multi-minute
# mint is indistinguishable from a wedge, and that ambiguity has cost whole
# evenings. The log line says where to watch the real progress.
mint_box() { # mint_box <log> <box-new args...> → box new's exit code
local log="$1"; shift
inf "watch it live in another terminal: tail -f $log"
box new "$@" >"$log" 2>&1 </dev/null &
local pid=$!
while kill -0 "$pid" 2>/dev/null; do printf '.'; sleep 5; done
printf '\n'
wait "$pid"
}
# --- stage 1: consent, install, then re-enter inside the incus-admin group --- # --- stage 1: consent, install, then re-enter inside the incus-admin group ---
if [ "${IN_GROUP:-0}" != 1 ]; then if [ "${IN_GROUP:-0}" != 1 ]; then
if [ "$YES" -ne 1 ]; then if [ "$YES" -ne 1 ]; then
cat <<EOF cat <<EOF
This will, ON THIS HOST ($(hostname)): This will, ON THIS HOST ($(hostname)):
· install Incus and a systemd unit · install Incus and a systemd unit
· create a network (claudenet), an ACL, and a profile · create a network (boxnet), an ACL, and a profile
· rewrite firewall rules (nft or UFW, and Docker's DOCKER-USER chain) · rewrite firewall rules (nft or UFW, and Docker's DOCKER-USER chain)
· create and destroy instances named: drill, clone, archive, peer, payroll, cbprobe, cbcopy · create and destroy instances named: drill, clone, archive, peer, payroll, cbprobe, cbcopy, tpl
· mutate the network and profile mid-run to rehearse the #16 hardening · mutate the network and profile mid-run to rehearse the #16 hardening
Only do this on a machine you can format. Only do this on a machine you can format.
EOF EOF
@ -155,13 +169,13 @@ 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; }
export PATH="$HOME/.local/bin:$PATH" export PATH="$HOME/.local/bin:$PATH"
phase "Host setup (Incus, claudenet, ACL, profile, firewall)" phase "Host setup (Incus, boxnet, ACL, profile, firewall)"
# setup-host.sh installs nftables itself when neither nft nor UFW exists # setup-host.sh installs nftables itself when neither nft nor UFW exists
# (a stock Debian 13 cloud image ships neither). This guard is a tripwire: # (a stock Debian 13 cloud image ships neither). This guard is a tripwire:
# if it fires, that fix regressed. # if it fires, that fix regressed.
@ -214,27 +228,33 @@ 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…"
# One name at a time — 'incus delete -f a b c' aborts at the first MISSING name, # One name at a time — 'incus delete -f a b c' aborts at the first MISSING name,
# which is how run 2 inherited run 1's boxes and cascaded five false FAILs. # which is how run 2 inherited run 1's boxes and cascaded five false FAILs.
for n in drill clone archive peer payroll cbprobe cbcopy cbnotours; do for n in drill clone archive peer payroll cbprobe cbcopy cbnotours tpl; do
timeout -k 5 60 incus delete -f "$n" >/dev/null 2>&1 timeout -k 5 60 incus delete -f "$n" >/dev/null 2>&1
done done
if incus network show claudenet >/dev/null 2>&1; then if incus network show boxnet >/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 boxnet 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
fi fi
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' ' ')" 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"
@ -242,48 +262,51 @@ inf "running setup-host.sh (in-group pass: network, ACL, profile, firewall)…"
if ! timeout -k 10 300 ~/.local/share/claudebox/host/setup-host.sh; then if ! timeout -k 10 300 ~/.local/share/claudebox/host/setup-host.sh; then
echo "drill: setup-host.sh failed or timed out (>5 min)." >&2 echo "drill: setup-host.sh failed or timed out (>5 min)." >&2
echo " it should take seconds on a host that already has incus. usual causes:" >&2 echo " it should take seconds on a host that already has incus. usual causes:" >&2
echo " · instances still attached to claudenet while its ACLs are reconfigured" >&2 echo " · instances still attached to boxnet while its ACLs are reconfigured" >&2
echo " incus list" >&2 echo " incus list" >&2
echo " · the firewall unit not completing" >&2 echo " · the firewall unit not completing" >&2
echo " systemctl status claudebox-firewall.service --no-pager" >&2 echo " systemctl status box-firewall.service --no-pager" >&2
echo " · the incus daemon wedged by an earlier aborted run" >&2 echo " · the incus daemon wedged by an earlier aborted run" >&2
echo " systemctl status incus --no-pager; journalctl -u incus -n 30 --no-pager" >&2 echo " systemctl status incus --no-pager; journalctl -u incus -n 30 --no-pager" >&2
exit 1 exit 1
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 +363,80 @@ 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' # --- templates: the mint surface is itself a surface to test ----------------
box templates 2>/dev/null | grep -q '^ blank' && box templates 2>/dev/null | grep -q '^ claude' \
&& ok "templates: lists blank and claude" || no "templates listing is missing a shipped template"
box new --name tpl --template nosuch 2>&1 | grep -q 'no such template' \
&& ok "unknown template refused, points at 'box templates'" || no "an unknown template was not refused"
# The one rule that keeps templates honest: no key can name a network. Plant a
# bad template in the installed tree (the drill owns this host), expect the
# parser to reject it BY NAME, remove it.
badt="$HOME/.local/share/claudebox/templates/cbdrill-bad"
mkdir -p "$badt" && printf 'BOX_IMAGE="x"\nBOX_USER="y"\nBOX_NETWORK="lan"\n' >"$badt/box.env" && : >"$badt/user-data.yaml"
box new --name tpl --template cbdrill-bad 2>&1 | grep -q "unknown key 'BOX_NETWORK'" \
&& ok "a template cannot name a network — BOX_NETWORK rejected by name" \
|| no "a box.env key outside the allowlist was ACCEPTED — a template could weaken isolation"
rm -rf "$badt"
printf '\n minting a blank box (the DEFAULT template — no tooling, fast)…\n'
t0=$SECONDS t0=$SECONDS
if claudebox new --name drill >/tmp/new.log 2>&1; then if mint_box /tmp/mint-tpl.log --name tpl; then
ok "claudebox new --name drill ($((SECONDS - t0))s)" ok "box new --name tpl, no --template ($((SECONDS - t0))s)"
tt="$(incus config get tpl user.box.template 2>/dev/null)"
[ "$tt" = blank ] && ok "the default template is blank (user.box.template=blank)" \
|| no "default template is '${tt:-<unset>}' — expected blank"
[ "$(incus config get tpl user.box.user 2>/dev/null)" = dev ] \
&& ok "template user stamped on the instance (user.box.user=dev)" || no "user.box.user not stamped"
incus config show tpl 2>/dev/null | grep -q '^- box-net' \
&& ok "blank box launched with the box-net profile — same placement contract" \
|| no "blank box is NOT on box-net — a template picked its own placement?!"
u="$(timeout -k 5 30 box exec tpl -- whoami </dev/null 2>/dev/null | tr -d '[:space:]')"
[ "$u" = dev ] && ok "exec lands in the template's user ($u) — nothing hardcodes claude" \
|| no "exec landed in '${u:-<nothing>}', expected dev"
timeout -k 5 30 box exec tpl -- sh -lc 'command -v claude' </dev/null >/dev/null 2>&1 \
&& no "the blank box has claude installed — 'blank' is not blank" \
|| ok "blank box has no claude — nobody home, as designed"
box_pings tpl 1.1.1.1 && ok "blank box reaches the internet (same egress as any template)" \
|| no "blank box has NO egress — isolation parity broken"
in_box tpl getent hosts deb.debian.org >/dev/null 2>&1 \
&& ok "blank box resolves public names (pinned resolver serves every template)" \
|| no "blank box cannot resolve — DNS parity broken"
box rm tpl --force >/dev/null 2>&1 && ok "blank box removed" || no "could not remove the blank box"
else else
no "claudebox new FAILED — tail: $(tail -3 /tmp/new.log | tr '\n' ' ')" no "blank mint FAILED — tail: $(tail -3 /tmp/mint-tpl.log | tr '\n' ' ')"
fi
printf '\n minting a claude box (cold, ~10 min)…\n'
t0=$SECONDS
if mint_box /tmp/mint-drill.log --name drill --template claude; then
ok "box new --name drill --template claude ($((SECONDS - t0))s)"
else
no "box new FAILED — tail: $(tail -3 /tmp/mint-drill.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,83 +444,83 @@ 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 mint_box /tmp/mint-clone.log --name clone --from archive/authed; 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/mint-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 mint_box /tmp/mint-peer.log --name peer --from archive/authed && 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/mint-peer.log | tr '\n' ' ')"
fi fi
# C1 — public egress (#15 A1; resolving the hostname also proves A5, gateway DNS) # C1 — public egress (#15 A1; resolving the hostname also proves A5, gateway DNS)
@ -472,10 +539,10 @@ fi
# nothing serves and read refused-vs-dropped — refused would mean the box's # nothing serves and read refused-vs-dropped — refused would mean the box's
# packet reached the host's stack, which is the thing the firewall must prevent. # packet reached the host's stack, which is the thing the firewall must prevent.
# (No background listener: one less process to leak, one less way to wedge.) # (No background listener: one less process to leak, one less way to wedge.)
hv="$(box_probe archive http://10.87.0.1:8099)" hv="$(box_probe archive http://10.88.0.1:8099)"
case "$hv" in case "$hv" in
reachable|refused) reachable|refused)
no "THE BOX'S PACKETS REACH THE HOST on 10.87.0.1:8099 [$hv] — the firewall rules are not holding" no "THE BOX'S PACKETS REACH THE HOST on 10.88.0.1:8099 [$hv] — the firewall rules are not holding"
aud "A2 box→host: FAIL — $hv (the packet reached the host's stack)" ;; aud "A2 box→host: FAIL — $hv (the packet reached the host's stack)" ;;
dropped) dropped)
ok "box → host is blocked (no path to the machine's sockets)" ok "box → host is blocked (no path to the machine's sockets)"
@ -500,8 +567,8 @@ esac
# port answers the question just as well (refused = the packet arrived), and # port answers the question just as well (refused = the packet arrived), and
# the listener was what kept wedging the run. Ping corroborates: if the two # the listener was what kept wedging the run. Ping corroborates: if the two
# disagree, say so rather than pick one. # disagree, say so rather than pick one.
PEER_IP="$(claudenet_ip peer)" PEER_IP="$(boxnet_ip peer)"
ARCH_IP_PRE="$(claudenet_ip archive)" ARCH_IP_PRE="$(boxnet_ip archive)"
if [ -n "$PEER_IP" ] && [ "$PEER_IP" = "$ARCH_IP_PRE" ]; then if [ -n "$PEER_IP" ] && [ "$PEER_IP" = "$ARCH_IP_PRE" ]; then
# Guard, because this actually happened: a clone inherited its source's # Guard, because this actually happened: a clone inherited its source's
# machine-id, hence its DHCP lease, hence its ADDRESS. Probing "archive → # machine-id, hence its DHCP lease, hence its ADDRESS. Probing "archive →
@ -525,8 +592,8 @@ elif [ -n "$PEER_IP" ]; then
aud "A3 sibling: BLOCKED — tcp dropped + no icmp reply (security.port_isolation)" aud "A3 sibling: BLOCKED — tcp dropped + no icmp reply (security.port_isolation)"
fi fi
else else
no "could not read peer's claudenet address — the sibling probe never ran" no "could not read peer's boxnet address — the sibling probe never ran"
aud "A3 sibling: NOT PROBED (no 10.87.x address on peer)" aud "A3 sibling: NOT PROBED (no 10.88.x address on peer)"
fi fi
# C5 — DNS enumeration (#15 A4). Now a CONTRACT, not an observation: setup-host # C5 — DNS enumeration (#15 A4). Now a CONTRACT, not an observation: setup-host
@ -542,13 +609,13 @@ else
fi fi
# C6 — IPv6 off (#15 A6): every ACL rule is IPv4-only; off is the only cover. # C6 — IPv6 off (#15 A6): every ACL rule is IPv4-only; off is the only cover.
[ "$(incus network get claudenet ipv6.address 2>/dev/null)" = none ] \ [ "$(incus network get boxnet ipv6.address 2>/dev/null)" = none ] \
&& { ok "claudenet ipv6.address = none (the IPv4-only ACLs have no uncovered path)"; aud "A6 ipv6: none, as contract requires"; } \ && { ok "boxnet ipv6.address = none (the IPv4-only ACLs have no uncovered path)"; aud "A6 ipv6: none, as contract requires"; } \
|| { no "claudenet has IPv6 enabled — and not one ACL rule covers IPv6"; aud "A6 ipv6: ENABLED and uncovered"; } || { no "boxnet has IPv6 enabled — and not one ACL rule covers IPv6"; aud "A6 ipv6: ENABLED and uncovered"; }
# C7 — inbound, host → box (#15 A7): the ACL's default ingress drop. Same # C7 — inbound, host → box (#15 A7): the ACL's default ingress drop. Same
# listener-free logic, run from the host this time. # listener-free logic, run from the host this time.
ARCH_IP="$(claudenet_ip archive)" ARCH_IP="$(boxnet_ip archive)"
if [ -n "$ARCH_IP" ]; then if [ -n "$ARCH_IP" ]; then
hmsg="$(curl -sS -m 5 -o /dev/null "http://$ARCH_IP:8087" 2>&1)"; hrc=$? hmsg="$(curl -sS -m 5 -o /dev/null "http://$ARCH_IP:8087" 2>&1)"; hrc=$?
if [ "$hrc" -eq 0 ]; then hv=reachable if [ "$hrc" -eq 0 ]; then hv=reachable
@ -567,7 +634,7 @@ if [ -n "$ARCH_IP" ]; then
aud "A7 inbound host→box: INCONCLUSIVE ($hv)" ;; aud "A7 inbound host→box: INCONCLUSIVE ($hv)" ;;
esac esac
else else
no "could not read archive's claudenet address — the inbound probe never ran" no "could not read archive's boxnet address — the inbound probe never ran"
aud "A7 inbound host→box: NOT PROBED" aud "A7 inbound host→box: NOT PROBED"
fi fi
@ -576,7 +643,7 @@ phase "D. The isolation contract, stated"
# =========================================================================== # ===========================================================================
# Phase D used to REHEARSE the hardening on a throwaway host, because nobody # Phase D used to REHEARSE the hardening on a throwaway host, because nobody
# knew whether it would work. That question is settled: the hardening now ships # knew whether it would work. That question is settled: the hardening now ships
# in setup-host.sh and claudebox-firewall.sh, so phase C tests the real thing # in setup-host.sh and box-firewall.sh, so phase C tests the real thing
# and there is nothing left to rehearse. What the rehearsal established, kept # and there is nothing left to rehearse. What the rehearsal established, kept
# here so it is not re-litigated: # here so it is not re-litigated:
# #
@ -607,15 +674,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 tpl; 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|tpl)([[: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
@ -633,7 +700,7 @@ if [ "${#audit[@]}" -gt 0 ]; then
fi fi
echo echo
inf "this host still has Incus, claudenet, the ACL, the profile and the firewall rules" inf "this host still has Incus, boxnet, the ACL, the profile and the firewall rules"
inf "(plus, unless re-run: dns.mode=none and NIC filtering from the D phase)." inf "(plus, unless re-run: dns.mode=none and NIC filtering from the D phase)."
inf "to undo: ~/.local/share/claudebox/host/teardown-host.sh [--purge-incus]" inf "to undo: ~/.local/share/claudebox/host/teardown-host.sh [--purge-incus]"
[ "$fail" -eq 0 ] [ "$fail" -eq 0 ]

147
drill/wipe.sh Normal file
View file

@ -0,0 +1,147 @@
#!/usr/bin/env bash
# wipe.sh — scorched earth for drill hosts. Remove EVERY trace of box and of
# pre-0.4.0 claudebox, so the next drill run starts from a truly bare host and
# its verdict means something.
#
# bash drill/wipe.sh # asks first; KEEPS cached images (an
# # image is upstream's artifact — wiping
# # it buys nothing but a re-download)
# bash drill/wipe.sh --yes # no prompt
# bash drill/wipe.sh --purge-storage # also delete the 'default' storage
# # pool (and the images inside it), so
# # setup-host exercises its bootstrap (#29)
#
# What teardown-host.sh does NOT cover, this does: instances the drill names
# but never tagged, instances of either tag generation, cached images, and
# (opt-in) the storage pool. teardown is the polite uninstall; this is the
# reset button for the staging server.
#
# NOT 'set -e': on a wipe, a step that finds nothing to remove is success,
# not failure. Every removal states what it did; silence is never trusted
# (the exit-code lesson, again).
set -u
YES=0; PURGE_STORAGE=0
while [ $# -gt 0 ]; do
case "$1" in
--yes|-y) YES=1; shift ;;
--purge-storage) PURGE_STORAGE=1; shift ;;
-h|--help) sed -n '2,17p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) echo "wipe: unknown option: $1" >&2; exit 2 ;;
esac
done
say() { printf 'wipe: %s\n' "$*"; }
if [ "$YES" -ne 1 ]; then
cat <<EOF
This wipes EVERY trace of box/claudebox from this host ($(hostname)):
· every instance tagged user.box=1 or user.claudebox=1
· every instance the drill has ever named (drill, clone, archive, peer,
payroll, cbprobe, cbcopy, cbnotours, tpl)
· networks boxnet + claudenet, ACLs box-isolate + claude-isolate
· profiles box-net + claude-dev
· firewall units, scripts and nft tables of BOTH name generations
$( [ "$PURGE_STORAGE" = 1 ] && echo " · the 'default' storage pool AND its cached images (--purge-storage)" \
|| echo " · (cached images are KEPT — the next mint stays fast; --purge-storage removes them with the pool)" )
Uncommitted work inside any box is LOST. Only do this on a drill host.
EOF
[ -t 0 ] || { echo "wipe: no TTY to confirm on — pass --yes if you mean it." >&2; exit 2; }
printf 'Continue? [y/N] '
read -r reply
case "$reply" in y|Y|yes) ;; *) echo "stopped."; exit 1 ;; esac
fi
command -v incus >/dev/null || { say "incus is not installed — nothing box-shaped can exist; only firewall crumbs checked."; }
if command -v incus >/dev/null; then
# --- instances: both tags, then every name the drill has ever used --------
# One delete at a time — a multi-name 'incus delete' aborts at the first
# missing name (drill trap 5).
for tag in "user.box=1" "user.claudebox=1"; do
for i in $(incus list "$tag" -f csv -c n 2>/dev/null); do
timeout -k 5 60 incus delete -f "$i" >/dev/null 2>&1 \
&& say "deleted instance $i ($tag)" || say "instance $i: delete FAILED — look at it by hand"
done
done
for n in drill clone archive peer payroll cbprobe cbcopy cbnotours tpl; do
incus info "$n" >/dev/null 2>&1 || continue
timeout -k 5 60 incus delete -f "$n" >/dev/null 2>&1 \
&& say "deleted untagged drill instance $n" || say "instance $n: delete FAILED — look at it by hand"
done
# --- profiles, networks, ACLs — both generations ---------------------------
for p in box-net claude-dev; do
incus profile delete "$p" >/dev/null 2>&1 && say "deleted profile $p"
done
for net in boxnet claudenet; do
incus network delete "$net" >/dev/null 2>&1 && say "deleted network $net"
done
for acl in box-isolate claude-isolate; do
incus network acl delete "$acl" >/dev/null 2>&1 && say "deleted ACL $acl"
done
# --- cached images: NOT wiped by default -----------------------------------
# An image is upstream's artifact, content-addressed by fingerprint — not a
# drill artifact. Deleting it buys zero cleanliness and costs the next mint
# a full re-download. It only goes when the pool it lives in goes.
if [ "$PURGE_STORAGE" = 1 ]; then
# --- the pool (opt-in): lets setup-host's bootstrap run for real ---------
# Images live in the pool and block its deletion — they go first.
for f in $(incus image list -f csv -c f 2>/dev/null); do
incus image delete "$f" >/dev/null 2>&1 && say "deleted image $f"
done
incus profile device remove default root >/dev/null 2>&1 && say "removed default profile's root device"
if incus storage delete default >/dev/null 2>&1; then
say "deleted storage pool 'default' — setup-host will rebuild it (btrfs where it can)"
else
incus storage show default >/dev/null 2>&1 \
&& say "pool 'default' NOT deleted — something still uses it: incus storage volume list default" \
|| say "no 'default' pool existed"
fi
fi
fi
# --- firewall: units, scripts, nft tables, UFW and Docker crumbs -------------
for unit in box-firewall claudebox-firewall; do
sudo systemctl disable --now "$unit.service" >/dev/null 2>&1 && say "disabled $unit.service"
sudo rm -f "/etc/systemd/system/$unit.service" "/usr/local/sbin/$unit"
done
sudo systemctl daemon-reload
for t in "inet box" "bridge box" "inet claudebox" "bridge claudebox"; do
# shellcheck disable=SC2086 # the table spec is two words by design
sudo nft delete table $t >/dev/null 2>&1 && say "deleted nft table $t"
done
if command -v ufw >/dev/null && sudo ufw status 2>/dev/null | grep -q "Status: active"; then
for net in boxnet claudenet; do
while sudo ufw status numbered | grep -q "on $net"; do
n="$(sudo ufw status numbered | grep -m1 "on $net" | sed -E 's/^\[ *([0-9]+)\].*/\1/')"
sudo ufw --force delete "$n" >/dev/null && say "deleted UFW rule on $net"
done
done
fi
if command -v docker >/dev/null; then
for net in boxnet claudenet; do
sudo iptables -D DOCKER-USER -i "$net" -j ACCEPT 2>/dev/null && say "removed DOCKER-USER -i $net"
sudo iptables -D DOCKER-USER -o "$net" -j ACCEPT 2>/dev/null && say "removed DOCKER-USER -o $net"
done
fi
# --- verdict: assert the ABSENCE, don't trust the removals' exit codes -------
left=""
if command -v incus >/dev/null; then
for tag in "user.box=1" "user.claudebox=1"; do
[ -n "$(incus list "$tag" -f csv -c n 2>/dev/null)" ] && left="$left instances($tag)"
done
for net in boxnet claudenet; do incus network show "$net" >/dev/null 2>&1 && left="$left $net"; done
for p in box-net claude-dev; do incus profile show "$p" >/dev/null 2>&1 && left="$left $p"; done
fi
for t in "inet box" "bridge box" "inet claudebox" "bridge claudebox"; do
# shellcheck disable=SC2086
sudo nft list table $t >/dev/null 2>&1 && left="$left nft:${t// /-}"
done
if [ -n "$left" ]; then
say "NOT clean — still present:$left"
exit 1
fi
say "clean — no trace of box or claudebox remains. The drill will rebuild everything."

View file

@ -1,12 +1,12 @@
[Unit] [Unit]
Description=Re-apply claudebox host firewall rules Description=Re-apply box host firewall rules
# After docker so DOCKER-USER exists; still fine if docker is absent. # After docker so DOCKER-USER exists; still fine if docker is absent.
After=network.target incus.service docker.service After=network.target incus.service docker.service
Wants=network.target Wants=network.target
[Service] [Service]
Type=oneshot Type=oneshot
ExecStart=/usr/local/sbin/claudebox-firewall ExecStart=/usr/local/sbin/box-firewall
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target

View file

@ -1,12 +1,12 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Apply the claudebox host-firewall rules. Idempotent; runs as root. # Apply the box host-firewall rules. Idempotent; runs as root.
# Invoked by setup-host.sh at install time and by claudebox-firewall.service # Invoked by setup-host.sh at install time and by box-firewall.service
# at every boot (UFW rules persist on their own; the nft fallback table and # at every boot (UFW rules persist on their own; the nft fallback table and
# Docker's DOCKER-USER rules are runtime-only and need re-applying). # Docker's DOCKER-USER rules are runtime-only and need re-applying).
set -euo pipefail set -euo pipefail
GW=10.87.0.1 GW=10.88.0.1
NET=claudenet NET=boxnet
if command -v ufw >/dev/null && ufw status 2>/dev/null | grep -q "Status: active"; then if command -v ufw >/dev/null && ufw status 2>/dev/null | grep -q "Status: active"; then
if ! ufw status | grep "on $NET" | grep -q "DENY"; then if ! ufw status | grep "on $NET" | grep -q "DENY"; then
@ -18,12 +18,12 @@ if command -v ufw >/dev/null && ufw status 2>/dev/null | grep -q "Status: active
fi fi
else else
# No UFW: protect the host's own sockets with a dedicated nft table. # No UFW: protect the host's own sockets with a dedicated nft table.
if ! nft list table inet claudebox >/dev/null 2>&1; then if ! nft list table inet box >/dev/null 2>&1; then
nft add table inet claudebox nft add table inet box
nft 'add chain inet claudebox input { type filter hook input priority -5 ; }' nft 'add chain inet box input { type filter hook input priority -5 ; }'
nft add rule inet claudebox input iifname "$NET" udp dport '{ 53, 67 }' accept nft add rule inet box input iifname "$NET" udp dport '{ 53, 67 }' accept
nft add rule inet claudebox input iifname "$NET" tcp dport 53 accept nft add rule inet box input iifname "$NET" tcp dport 53 accept
nft add rule inet claudebox input iifname "$NET" drop nft add rule inet box input iifname "$NET" drop
fi fi
fi fi
@ -31,7 +31,7 @@ fi
# #
# This is the ONE rule that makes "isolated even from each other" true, and it # This is the ONE rule that makes "isolated even from each other" true, and it
# is not the one anyone expected. The Incus ACL drops egress to 10.0.0.0/8, and # is not the one anyone expected. The Incus ACL drops egress to 10.0.0.0/8, and
# claudenet's 10.87.0.0/24 sits inside it — so on paper box→box was already # boxnet's 10.88.0.0/24 sits inside it — so on paper box→box was already
# blocked twice over (the ingress default is drop as well). It was not: a live # blocked twice over (the ingress default is drop as well). It was not: a live
# probe found box A's SYN arriving at box B and B answering with a RST. # probe found box A's SYN arriving at box B and B answering with a RST.
# #
@ -41,16 +41,16 @@ fi
# sees this traffic. # sees this traffic.
# #
# The bridge family DOES see it. Its forward hook fires exactly when a frame is # The bridge family DOES see it. Its forward hook fires exactly when a frame is
# passed from one bridge port to another — which, on claudenet, means box→box # passed from one bridge port to another — which, on boxnet, means box→box
# and nothing else: frames addressed to the gateway are delivered locally (the # and nothing else: frames addressed to the gateway are delivered locally (the
# INPUT hook), and so is anything being routed out to the internet. So dropping # INPUT hook), and so is anything being routed out to the internet. So dropping
# every forwarded frame on this bridge isolates the boxes from one another and # every forwarded frame on this bridge isolates the boxes from one another and
# costs them nothing else. DHCP and ARP still work: they are broadcast, and the # costs them nothing else. DHCP and ARP still work: they are broadcast, and the
# local delivery to dnsmasq happens on INPUT, not FORWARD. # local delivery to dnsmasq happens on INPUT, not FORWARD.
if ! nft list table bridge claudebox >/dev/null 2>&1; then if ! nft list table bridge box >/dev/null 2>&1; then
nft add table bridge claudebox nft add table bridge box
nft "add chain bridge claudebox forward { type filter hook forward priority -200 ; policy accept ; }" nft "add chain bridge box forward { type filter hook forward priority -200 ; policy accept ; }"
nft add rule bridge claudebox forward meta ibrname "$NET" meta obrname "$NET" drop nft add rule bridge box forward meta ibrname "$NET" meta obrname "$NET" drop
fi fi
# Docker rewrites FORWARD policy to DROP; DOCKER-USER is its escape hatch. # Docker rewrites FORWARD policy to DROP; DOCKER-USER is its escape hatch.

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)"
@ -56,21 +56,23 @@ PRESEED
fi fi
# Isolated NAT network. IPv6 off: one less egress path to reason about. # Isolated NAT network. IPv6 off: one less egress path to reason about.
incus network show claudenet >/dev/null 2>&1 || incus network create claudenet \ # 10.88, not 10.87: a pre-rename host may still carry claudenet on 10.87 with
ipv4.address=10.87.0.1/24 ipv4.nat=true ipv6.address=none # legacy boxes attached — two bridges must not claim one subnet.
incus network show boxnet >/dev/null 2>&1 || incus network create boxnet \
ipv4.address=10.88.0.1/24 ipv4.nat=true ipv6.address=none
# ACL: default egress allow (internet), explicit drops for private space. # ACL: default egress allow (internet), explicit drops for private space.
# Gateway carve-out first so instance DNS (dnsmasq on 10.87.0.1) survives. # Gateway carve-out first so instance DNS (dnsmasq on 10.88.0.1) survives.
if ! incus network acl show claude-isolate >/dev/null 2>&1; then if ! incus network acl show box-isolate >/dev/null 2>&1; then
incus network acl create claude-isolate incus network acl create box-isolate
incus network acl rule add claude-isolate egress action=allow destination=10.87.0.1/32 incus network acl rule add box-isolate egress action=allow destination=10.88.0.1/32
incus network acl rule add claude-isolate egress action=drop destination=10.0.0.0/8 incus network acl rule add box-isolate egress action=drop destination=10.0.0.0/8
incus network acl rule add claude-isolate egress action=drop destination=172.16.0.0/12 incus network acl rule add box-isolate egress action=drop destination=172.16.0.0/12
incus network acl rule add claude-isolate egress action=drop destination=192.168.0.0/16 incus network acl rule add box-isolate egress action=drop destination=192.168.0.0/16
incus network acl rule add claude-isolate egress action=drop destination=169.254.0.0/16 incus network acl rule add box-isolate egress action=drop destination=169.254.0.0/16
incus network acl rule add claude-isolate egress action=drop destination=100.64.0.0/10 incus network acl rule add box-isolate egress action=drop destination=100.64.0.0/10
fi fi
incus network set claudenet security.acls=claude-isolate \ incus network set boxnet security.acls=box-isolate \
security.acls.default.egress.action=allow \ security.acls.default.egress.action=allow \
security.acls.default.ingress.action=drop security.acls.default.ingress.action=drop
@ -80,7 +82,7 @@ incus network set claudenet security.acls=claude-isolate \
# from inside one box resolved another's name and address. Connection blocked, # from inside one box resolved another's name and address. Connection blocked,
# reconnaissance wide open. dns.mode=none stops it registering instance records; # reconnaissance wide open. dns.mode=none stops it registering instance records;
# forwarding for public names is unaffected (verified live). # forwarding for public names is unaffected (verified live).
incus network set claudenet dns.mode=none incus network set boxnet dns.mode=none
# A box's resolver must not be a function of the host's VPN posture (#33). # A box's resolver must not be a function of the host's VPN posture (#33).
# The bridge's dnsmasq forwards to whatever sits in the HOST's /etc/resolv.conf # The bridge's dnsmasq forwards to whatever sits in the HOST's /etc/resolv.conf
@ -93,11 +95,11 @@ incus network set claudenet dns.mode=none
# lever — the bridge has no first-class upstream key. Verified live on the # lever — the bridge has no first-class upstream key. Verified live on the
# drill host: pin applied, box resolves, cold mint survives. # drill host: pin applied, box resolves, cold mint survives.
BOX_DNS="${BOX_DNS:-1.1.1.1 8.8.8.8}" BOX_DNS="${BOX_DNS:-1.1.1.1 8.8.8.8}"
incus network set claudenet raw.dnsmasq \ incus network set boxnet raw.dnsmasq \
"$(printf 'no-resolv\n'; for s in $BOX_DNS; do printf 'server=%s\n' "$s"; done)" "$(printf 'no-resolv\n'; for s in $BOX_DNS; do printf 'server=%s\n' "$s"; done)"
# Sibling isolation itself is NOT an ACL rule — an L3 ACL never sees frames # Sibling isolation itself is NOT an ACL rule — an L3 ACL never sees frames
# switched between two ports of one bridge. It lives in claudebox-firewall.sh # switched between two ports of one bridge. It lives in box-firewall.sh
# as an nftables bridge-family rule. See the comment there; it is the reason # as an nftables bridge-family rule. See the comment there; it is the reason
# boxes cannot reach each other. # boxes cannot reach each other.
@ -107,41 +109,45 @@ incus network set claudenet raw.dnsmasq \
# --- Firewall coexistence --------------------------------------------------- # --- Firewall coexistence ---------------------------------------------------
# Hosts running UFW (INPUT drop) and/or Docker (FORWARD drop) silently eat # Hosts running UFW (INPUT drop) and/or Docker (FORWARD drop) silently eat
# claudenet traffic. Punch minimal, ordered holes; the Incus ACL still layers # boxnet traffic. Punch minimal, ordered holes; the Incus ACL still layers
# on top. The trailing deny also blocks instance -> host's own (public) IPs, # on top. The trailing deny also blocks instance -> host's own (public) IPs,
# which the RFC1918-only ACL cannot express. Rules live in # which the RFC1918-only ACL cannot express. Rules live in
# claudebox-firewall.sh; a boot-time systemd unit re-applies the runtime-only # box-firewall.sh; a boot-time systemd unit re-applies the runtime-only
# parts (nft table, DOCKER-USER) after every reboot. # parts (nft table, DOCKER-USER) after every reboot.
# The no-UFW path drives nft directly, and a stock Debian 13 cloud image ships # The no-UFW path drives nft directly, and a stock Debian 13 cloud image ships
# neither nftables nor UFW — install the dependency we are about to use. # neither nftables nor UFW — install the dependency we are about to use.
if ! command -v ufw >/dev/null 2>&1 && ! command -v nft >/dev/null 2>&1; then if ! command -v ufw >/dev/null 2>&1 && ! command -v nft >/dev/null 2>&1; then
sudo apt-get install -y nftables sudo apt-get install -y nftables
fi fi
sudo install -m 755 "$here/host/claudebox-firewall.sh" /usr/local/sbin/claudebox-firewall sudo install -m 755 "$here/host/box-firewall.sh" /usr/local/sbin/box-firewall
sudo install -m 644 "$here/host/claudebox-firewall.service" /etc/systemd/system/ sudo install -m 644 "$here/host/box-firewall.service" /etc/systemd/system/
sudo systemctl daemon-reload sudo systemctl daemon-reload
sudo systemctl enable claudebox-firewall.service sudo systemctl enable box-firewall.service
# RESTART, not 'enable --now'. The unit is RemainAfterExit, so once it has run # RESTART, not 'enable --now'. The unit is RemainAfterExit, so once it has run
# it stays "active" forever — and 'enable --now' does nothing to an active unit. # it stays "active" forever — and 'enable --now' does nothing to an active unit.
# Re-running setup-host after upgrading claudebox therefore installed the new # Re-running setup-host after upgrading the tool therefore installed the new
# rules to /usr/local/sbin and never applied them: the host kept the old # rules to /usr/local/sbin and never applied them: the host kept the old
# firewall, silently, and the box→box hole stayed open through a release that # firewall, silently, and the box→box hole stayed open through a release that
# 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 box-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.
if sudo nft list table bridge claudebox >/dev/null 2>&1; then if sudo nft list table bridge box >/dev/null 2>&1; then
echo "Isolation: box-to-box drop is live (nft bridge table 'claudebox')." echo "Isolation: box-to-box drop is live (nft bridge table 'box')."
else else
echo "WARNING: the box-to-box drop is NOT active — boxes can reach each other." >&2 echo "WARNING: the box-to-box drop is NOT active — boxes can reach each other." >&2
echo " check: sudo /usr/local/sbin/claudebox-firewall ; sudo nft list table bridge claudebox" >&2 echo " check: sudo /usr/local/sbin/box-firewall ; sudo nft list table bridge box" >&2
fi fi
echo "Host ready. Launch with: claudebox new --name <box>" echo "Host ready. Launch with: box new --name <box>"

View file

@ -1,57 +1,75 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Reverse everything host/setup-host.sh created: all claudebox instances, the # Reverse everything host/setup-host.sh created — and everything its pre-0.4.0
# claudenet network + ACL, the claude-dev profile, and the firewall rules. # ancestor created, so one teardown cleans a host of any generation: all boxes
# (both tags), the boxnet/claudenet networks + ACLs, the box-net/claude-dev
# profiles, and both generations of firewall units and nft tables.
# Usage: ./host/teardown-host.sh [--purge-incus] # Usage: ./host/teardown-host.sh [--purge-incus]
# --purge-incus also apt-purge Incus itself (skipped if non-claudebox # --purge-incus also apt-purge Incus itself (skipped if non-box
# instances still exist on this host) # instances still exist on this host)
set -euo pipefail set -euo pipefail
purge=false purge=false
[ "${1:-}" = "--purge-incus" ] && purge=true [ "${1:-}" = "--purge-incus" ] && purge=true
echo "This removes ALL claudebox instances (uncommitted work in them is lost)," echo "This removes ALL boxes (uncommitted work in them is lost), the"
echo "the claudenet network/ACL/profile, and the claudebox firewall rules." echo "boxnet/claudenet networks, ACLs, profiles, and the box firewall rules"
echo "(both current and pre-0.4.0 names)."
$purge && echo "Incus itself will also be uninstalled (--purge-incus)." $purge && echo "Incus itself will also be uninstalled (--purge-incus)."
read -rp "Continue? [y/N] " a read -rp "Continue? [y/N] " a
case "$a" in y|Y) ;; *) echo "aborted"; exit 1 ;; esac case "$a" in y|Y) ;; *) echo "aborted"; exit 1 ;; esac
# Instances # Instances — both tag generations, one delete at a time (a multi-name
for i in $(incus list "user.claudebox=1" -f csv -c n || true); do # 'incus delete' aborts at the first missing name).
echo "deleting instance $i" for tag in "user.box=1" "user.claudebox=1"; do
incus delete -f "$i" for i in $(incus list "$tag" -f csv -c n 2>/dev/null || true); do
echo "deleting instance $i"
incus delete -f "$i"
done
done done
incus profile delete claude-dev 2>/dev/null || true incus profile delete box-net 2>/dev/null || true
incus network delete claudenet 2>/dev/null || true incus profile delete claude-dev 2>/dev/null || true # legacy, pre-0.4.0
incus network acl delete claude-isolate 2>/dev/null || true incus network delete boxnet 2>/dev/null || true
incus network delete claudenet 2>/dev/null || true # legacy, pre-0.4.0
incus network acl delete box-isolate 2>/dev/null || true
incus network acl delete claude-isolate 2>/dev/null || true # legacy
# Boot-persistence unit # Boot-persistence units — both generations
sudo systemctl disable --now box-firewall.service 2>/dev/null || true
sudo systemctl disable --now claudebox-firewall.service 2>/dev/null || true sudo systemctl disable --now claudebox-firewall.service 2>/dev/null || true
sudo rm -f /etc/systemd/system/claudebox-firewall.service /usr/local/sbin/claudebox-firewall sudo rm -f /etc/systemd/system/box-firewall.service /usr/local/sbin/box-firewall \
/etc/systemd/system/claudebox-firewall.service /usr/local/sbin/claudebox-firewall
sudo systemctl daemon-reload sudo systemctl daemon-reload
# Firewall crumbs — UFW rules mentioning claudenet (numbers shift after each # Firewall crumbs — UFW rules mentioning either network (numbers shift after
# delete, so re-scan and remove the first match until none remain) # each delete, so re-scan and remove the first match until none remain)
if command -v ufw >/dev/null && sudo ufw status 2>/dev/null | grep -q "Status: active"; then if command -v ufw >/dev/null && sudo ufw status 2>/dev/null | grep -q "Status: active"; then
while sudo ufw status numbered | grep -q "on claudenet"; do for net in boxnet claudenet; do
n="$(sudo ufw status numbered | grep -m1 "on claudenet" | sed -E 's/^\[ *([0-9]+)\].*/\1/')" while sudo ufw status numbered | grep -q "on $net"; do
sudo ufw --force delete "$n" n="$(sudo ufw status numbered | grep -m1 "on $net" | sed -E 's/^\[ *([0-9]+)\].*/\1/')"
sudo ufw --force delete "$n"
done
done done
fi fi
sudo nft delete table inet claudebox 2>/dev/null || true sudo nft delete table inet box 2>/dev/null || true
sudo nft delete table bridge box 2>/dev/null || true
sudo nft delete table inet claudebox 2>/dev/null || true # legacy
sudo nft delete table bridge claudebox 2>/dev/null || true # legacy
if command -v docker >/dev/null; then if command -v docker >/dev/null; then
sudo iptables -D DOCKER-USER -i claudenet -j ACCEPT 2>/dev/null || true for net in boxnet claudenet; do
sudo iptables -D DOCKER-USER -o claudenet -j ACCEPT 2>/dev/null || true sudo iptables -D DOCKER-USER -i "$net" -j ACCEPT 2>/dev/null || true
sudo iptables -D DOCKER-USER -o "$net" -j ACCEPT 2>/dev/null || true
done
fi fi
if $purge; then if $purge; then
remaining="$(incus list -f csv 2>/dev/null | wc -l)" remaining="$(incus list -f csv 2>/dev/null | wc -l)"
if [ "$remaining" -gt 0 ]; then if [ "$remaining" -gt 0 ]; then
echo "NOTE: $remaining non-claudebox instance(s) remain on this host — leaving Incus installed." echo "NOTE: $remaining non-box instance(s) remain on this host — leaving Incus installed."
else else
sudo apt-get purge -y incus sudo apt-get purge -y incus
sudo apt-get autoremove -y sudo apt-get autoremove -y
fi fi
fi fi
echo "Teardown complete. (Your ~/.local/bin/claudebox symlink and ~/.local/share/claudebox remain — remove by hand if wanted.)" echo "Teardown complete. (Your ~/.local/bin/box symlink and ~/.local/share/claudebox remain — remove by hand if wanted.)"

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,12 +1,15 @@
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
network: claudenet network: boxnet
name: eth0 name: eth0
# Boxes must not reach each other. This is the mechanism that actually does # Boxes must not reach each other. This is the mechanism that actually does
# it: the kernel bridge's port-isolation flag, which stops two isolated # it: the kernel bridge's port-isolation flag, which stops two isolated
@ -15,7 +18,7 @@ devices:
# It is not an ACL rule, and it cannot be. Incus ACLs are L3/L4, and two # It is not an ACL rule, and it cannot be. Incus ACLs are L3/L4, and two
# boxes on one bridge are on the same L2 segment — their frames are switched # boxes on one bridge are on the same L2 segment — their frames are switched
# between ports and never traverse the netfilter path an ACL lives on. That # between ports and never traverse the netfilter path an ACL lives on. That
# is why the ACL's drop on 10.0.0.0/8 (which contains claudenet) and its # is why the ACL's drop on 10.0.0.0/8 (which contains boxnet) and its
# default ingress drop BOTH looked airtight while box→box was wide open: a # default ingress drop BOTH looked airtight while box→box was wide open: a
# live probe found box A's SYN arriving at box B and B answering with a RST. # live probe found box A's SYN arriving at box B and B answering with a RST.
security.port_isolation: "true" security.port_isolation: "true"

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