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
mints a fresh, network-isolated Incus box with Claude Code installed. The box is
the product — you log in and work; destroying it loses nothing you didn't push.
**Headless, trust-less, throwaway dev VMs.** One command mints a fresh,
network-isolated Incus box from a **template**; the flagship template is
`claude` — Debian 13 with Claude Code installed, the box this repo is named
for. The box is the product — you log in and work; destroying it loses
nothing you didn't push.
**Strictly creds-free.** A box ships with everything installed and **no**
credentials — no Claude token, no git PAT, nothing. You authenticate
@ -10,20 +12,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
out of the box.
**Templates set what's in the box, never what it can reach.** A template is
image + user + resources + cloud-init; the network and every security flag
live in a shared profile no template can touch, so `blank` is a box with
nobody home — not a box with the safety off.
**The tool knows nothing about your projects.** You just `git clone` inside a
box. A repo can ship an optional [`.claudebox/`](docs/claudebox-recipe.md)
runbook that Claude Code reads and acts on — there is no `install` step and no
host-run setup. See [docs/claudebox-design.md](docs/claudebox-design.md) for the
design rationale.
> **0.4.0 is a clean cut**: the CLI is `box` (no `claudebox` shim), the host
> stack is `boxnet`/`box-isolate`/`box-firewall` on 10.88.0.0/24, and the
> default template is `blank`. Existing boxes minted by any earlier version
> keep working under every verb — their legacy tag is honored forever, and
> their old `claudenet` (10.87) is left standing beside the new bridge. To
> strip a host of both generations at once: `host/teardown-host.sh`, or
> `drill/wipe.sh` for the scorched-earth version.
## Install
```sh
curl -fsSL https://raw.githubusercontent.com/heavy-duty/claudebox/main/install.sh | bash
```
Installs the tree to `~/.local/share/claudebox` and links `claudebox` onto your
`PATH`. Re-run any time to upgrade. (No `git clone` needed.)
Installs the tree to `~/.local/share/claudebox` and links `box` onto your
`PATH`. Re-run any time to upgrade — upgrading from a pre-0.4.0 install also
retires the old `claudebox` symlink. (No `git clone` needed.)
## One-time host setup (Ubuntu 24.04 / Debian 13)
@ -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)
```
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 —
`BOX_DNS` overrides), the `claude-isolate` ACL (drops all RFC1918/CGNAT/
link-local egress), the `claude-dev` profile (port-isolated NICs — boxes can't
`BOX_DNS` overrides), the `box-isolate` ACL (drops all RFC1918/CGNAT/
link-local egress), the `box-net` profile (port-isolated NICs — boxes can't
reach each other), and firewall rules blocking instance → host. All rules
re-apply at boot via `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):
`sudo apt-get install -y dnsmasq-base`.
## Quick start
```sh
claudebox new --name work # mint a fresh, creds-free box (~10 min cold)
claudebox shell work # enter as the claude user
box new --name work --template claude # a creds-free Claude box (~10 min cold)
box shell work # enter as the template's user
```
Inside the box, authenticate as needed:
@ -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
```
## 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
Because every fresh box is creds-free, re-authenticating each time would be
toil. Snapshot an authenticated box and clone from it instead:
```sh
claudebox snapshot work authed # checkpoint after you've logged in
claudebox new --name feature --from work/authed # clone the authed state into a new box
box snapshot work authed # checkpoint after you've logged in
box new --name feature --from work/authed # clone the authed state into a new box
```
`--from` copies the whole box (Claude login, git creds, clones and all) while
preserving isolation. You can also `claudebox new --name x --from work` to clone
a box's live state, or roll a box back with `claudebox restore work authed`.
preserving isolation. You can also `box new --name x --from work` to clone
a box's live state, or roll a box back with `box restore work authed`.
Forgotten what you called a checkpoint? `claudebox info work` prints the box's
Forgotten what you called a checkpoint? `box info work` prints the box's
snapshot labels and the `--from` line to clone one.
## Commands
```
claudebox new --name <box> [--from <src>[/<snap>]] [--vm|--container] [--remote r]
claudebox list # list your boxes
claudebox info <box> # one box: state, IP, snapshot labels
claudebox shell <box> # enter as the claude user
claudebox exec <box> -- <cmd...> # run a command in the box
claudebox tmux <box> [session] # attach/create a tmux session — survives disconnects
claudebox snapshot <box> [label] # checkpoint (label defaults to manual-<epoch>)
claudebox restore <box> <snap> # roll back to a snapshot
claudebox rename <box> <new> # rename a box (stop it first)
claudebox down <box> # stop (state kept; `start` resumes)
claudebox start <box> # start a stopped box
claudebox rm <box> [--force] # delete the box + its snapshots (asks first)
claudebox incus <box> -- <args...> # escape hatch: any incus command, box resolved
claudebox doctor [--fix|--pin-dns] # is this host fit to mint boxes? diagnose from ground truth
claudebox status # deprecated alias for `list`
claudebox help [<command>] # full help, or one command's page
box new --name <box> [--template <t>] [--from <src>[/<snap>]] [--vm|--container] [--remote r]
box templates # list the templates this install can mint
box list # list your boxes
box info <box> # one box: state, IP, snapshot labels
box shell <box> # enter as the template's user
box exec <box> -- <cmd...> # run a command in the box
box tmux <box> [session] # attach/create a tmux session — survives disconnects
box snapshot <box> [label] # checkpoint (label defaults to manual-<epoch>)
box restore <box> <snap> # roll back to a snapshot
box rename <box> <new> # rename a box (stop it first)
box down <box> # stop (state kept; `start` resumes)
box start <box> # start a stopped box
box rm <box> [--force] # delete the box + its snapshots (asks first)
box incus <box> -- <args...> # escape hatch: any incus command, box resolved
box doctor [--fix|--pin-dns] # is this host fit to mint boxes? diagnose from ground truth
box status # deprecated alias for `list`
box help [<command>] # full help, or one command's page
```
Every command takes `--help`, and options come after the command
(`claudebox list --json`). Exit status: `0` ok, `1` it went wrong, `2` you asked
(`box list --json`). Exit status: `0` ok, `1` it went wrong, `2` you asked
wrong.
`new` fresh-launches from cloud-init, or with `--from` clones an existing box or
snapshot. VM mode (`--vm`, the default where `/dev/kvm` exists) is the trust-less
target; container mode (auto-fallback, `security.nesting=true`) is for hosts
without nested virt — weaker isolation, dev/test only.
`new` fresh-launches from a template (default: `claude`), or with `--from`
clones an existing box or snapshot. VM mode (`--vm`, the default where
`/dev/kvm` exists) is the trust-less target; container mode (auto-fallback,
`security.nesting=true`) is for hosts without nested virt — weaker isolation,
dev/test only.
## Boxes are just Incus instances
A box is an ordinary Incus instance tagged `user.claudebox=1`. claudebox wraps
the box lifecycle and the isolation model — not all of Incus. It owns a command
A box is an ordinary Incus instance tagged `user.box=1` (pre-0.4.0 boxes
carry `user.claudebox=1`, honored forever). box wraps the box lifecycle and the isolation model — not all of Incus. It owns a command
when it must enforce something Incus can't see: that tag (it will not stop,
rename or delete an instance it didn't mint), the isolation stack, or the
creds-free snapshot workflow. For everything else, there's the door:
```sh
claudebox incus work -- config show # instance name appended
claudebox incus work -- file push x.tar {}/tmp/ # or placed with {}
box incus work -- config show # instance name appended
box incus work -- file push x.tar {}/tmp/ # or placed with {}
```
The box is resolved and tag-checked; the rest is passed to `incus` verbatim, and
the command is echoed before it runs. If it can move the box off the isolation
stack (profile, network, device, `security.*`), claudebox warns and proceeds —
stack (profile, network, device, `security.*`), box warns and proceeds —
the trust boundary is then yours to keep. See
[docs/claudebox-design.md](docs/claudebox-design.md) for the rule and why the
command surface is a table.
@ -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
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
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.
- **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
@ -147,8 +187,8 @@ enforces it, layer by layer:
the host's public IPs. Entry is `incus exec` over the local socket only —
**no inbound path exists.**
The VM is the trust boundary: Claude can run arbitrary code inside and touch
nothing you care about.
The VM is the trust boundary: whatever runs inside — Claude, or anything a
template ships — can run arbitrary code and touch nothing you care about.
### Measured, not claimed
@ -164,6 +204,8 @@ history, including every trap that fooled a run into a wrong verdict.
```sh
bash drill/doctor.sh # read-only: is this host healthy and the stack live?
bash drill/drill.sh # FULL end-to-end — mutates the host; use a machine you own
bash drill/wipe.sh # scorched earth: strip BOTH name generations, images and
# (--purge-storage) the pool, so a run starts from bare
```
The doctor reads ground truth, not config claims — the kernel's `isolated on`
@ -184,7 +226,7 @@ host-executed script. See [docs/claudebox-recipe.md](docs/claudebox-recipe.md).
```sh
~/.local/share/claudebox/host/teardown-host.sh # boxes, network, ACL, profile, firewall
~/.local/share/claudebox/host/teardown-host.sh --purge-incus # ...and Incus itself
rm -rf ~/.local/share/claudebox ~/.local/bin/claudebox # the CLI
rm -rf ~/.local/share/claudebox ~/.local/bin/box # the CLI
```
## Non-goals

View file

@ -1 +1 @@
0.3.0
0.4.0

View file

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

View file

@ -1,6 +1,6 @@
#!/usr/bin/env bash
# doctor.sh — is this host fit to mint boxes (and to drill), and if not, what
# is wrong? Users reach it as 'claudebox doctor'; the drill runs it directly.
# is wrong? Users reach it as 'box doctor'; the drill runs it directly.
#
# bash drill/doctor.sh # report
# bash drill/doctor.sh --fix # report, then revert what the drill left behind
@ -40,19 +40,19 @@ timeout 10 incus list >/dev/null 2>&1 || {
exit 1
}
head_ "Network — claudenet"
if incus network show claudenet >/dev/null 2>&1; then
head_ "Network — boxnet"
if incus network show boxnet >/dev/null 2>&1; then
# 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.
dns="$(incus network get claudenet dns.mode 2>/dev/null)"
dns="$(incus network get boxnet dns.mode 2>/dev/null)"
if [ "$dns" = none ]; then
ok "dns.mode = none — a box cannot enumerate its siblings by name"
else
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"
[ "$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
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
# 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,
@ -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
# 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.
if pgrep -af 'dnsmasq.*--interface=claudenet' >/dev/null 2>&1; then
ok "a dnsmasq is serving claudenet (DHCP + DNS)"
if pgrep -af 'dnsmasq.*--interface=boxnet' >/dev/null 2>&1; then
ok "a dnsmasq is serving boxnet (DHCP + DNS)"
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 "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)"
[ "$FIX" = 1 ] && {
inf "restarting incus to respawn it…"
sudo systemctl restart incus && sleep 5
pgrep -af 'dnsmasq.*--interface=claudenet' >/dev/null 2>&1 \
&& inf "reverted: dnsmasq is serving claudenet again" \
pgrep -af 'dnsmasq.*--interface=boxnet' >/dev/null 2>&1 \
&& inf "reverted: dnsmasq is serving boxnet again" \
|| inf "STILL missing — run teardown-host.sh and let the drill rebuild the network"
}
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)" \
|| no "ipv6.address = $ipv6 — IPv6 is on and NOT covered by any ACL rule"
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
head_ "Firewall — the box-to-box drop"
if sudo nft list table bridge claudebox >/dev/null 2>&1; then
ok "nft bridge table 'claudebox' is present — boxes cannot reach each other"
if sudo nft list table bridge box >/dev/null 2>&1; then
ok "nft bridge table 'box' is present — boxes cannot reach each other"
else
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 "the drop is an nft BRIDGE-family rule, and without it siblings are wide open."
inf "fix: sudo /usr/local/sbin/claudebox-firewall"
inf " (or: sudo systemctl restart claudebox-firewall.service)"
inf "fix: sudo /usr/local/sbin/box-firewall"
inf " (or: sudo systemctl restart box-firewall.service)"
fi
head_ "Profile — claude-dev (the NIC is the isolation contract)"
if incus profile show claude-dev >/dev/null 2>&1; then
iso="$(incus profile device get claude-dev eth0 security.port_isolation 2>/dev/null)"
if [ "$iso" = "true" ]; then
ok "security.port_isolation = true — boxes cannot reach each other at L2"
else
no "security.port_isolation is NOT set — BOXES CAN REACH EACH OTHER"
inf "an L3 ACL cannot do this: two boxes on one bridge are on the same L2"
inf "segment, so their frames are switched, never routed past the ACL."
inf "fix: re-run ~/.local/share/claudebox/host/setup-host.sh"
fi
for k in security.mac_filtering security.ipv4_filtering; do
v="$(incus profile device get claude-dev eth0 "$k" 2>/dev/null)"
if [ -z "$v" ]; then
ok "$k unset (as shipped)"
# box-net is the placement contract since the 0.4.0 rename; claude-dev is its
# pre-rename ancestor and may linger while legacy boxes still reference it.
# Check whichever exist — an unisolated NIC is a fault on either.
PROFILES=""
incus profile show box-net >/dev/null 2>&1 && PROFILES="box-net"
incus profile show claude-dev >/dev/null 2>&1 && PROFILES="$PROFILES claude-dev"
head_ "Profile — the NIC is the isolation contract"
if [ -n "$PROFILES" ]; then
for p in $PROFILES; do
[ "$p" = claude-dev ] && inf "claude-dev is legacy (pre-rename boxes still reference it)"
iso="$(incus profile device get "$p" eth0 security.port_isolation 2>/dev/null)"
if [ "$iso" = "true" ]; then
ok "$p: security.port_isolation = true — boxes cannot reach each other at L2"
else
no "$k = $v ← phase D left this behind. A box can fail to get on the network at all."
[ "$FIX" = 1 ] && { incus profile device unset claude-dev eth0 "$k" && inf "reverted: $k unset"; }
no "$p: security.port_isolation is NOT set — BOXES CAN REACH EACH OTHER"
inf "an L3 ACL cannot do this: two boxes on one bridge are on the same L2"
inf "segment, so their frames are switched, never routed past the ACL."
inf "fix: re-run ~/.local/share/claudebox/host/setup-host.sh"
fi
for k in security.mac_filtering security.ipv4_filtering; do
v="$(incus profile device get "$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
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
inf "claude-dev does not exist (a fresh host)"
inf "box-net does not exist (a fresh host — setup-host.sh will create it)"
fi
head_ "ACL — claude-isolate"
if incus network acl show claude-isolate >/dev/null 2>&1; then
n="$(incus network acl show claude-isolate | grep -c 'action:' || true)"
head_ "ACL — box-isolate"
if incus network acl show box-isolate >/dev/null 2>&1; then
n="$(incus network acl show box-isolate | grep -c 'action:' || true)"
inf "$n rules"
incus network acl show claude-isolate | grep -E 'action:|destination:' | sed 's/^/ /'
if incus network acl show claude-isolate | grep -q '@internal'; then
incus network acl show box-isolate | grep -E 'action:|destination:' | sed 's/^/ /'
if incus network acl show box-isolate | grep -q '@internal'; then
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
else
inf "claude-isolate does not exist (a fresh host)"
inf "box-isolate does not exist (a fresh host)"
fi
# 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; }
done
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
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
printf '%s\n' "$ports" | sed 's/^/ /'
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"
hostns="$(grep -E '^nameserver' /etc/resolv.conf 2>/dev/null | awk '{print $2}' | tr '\n' ' ')"
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
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)"
else
# 100.64.0.0/10 is CGNAT — which is exactly Tailscale's range.
@ -191,8 +200,8 @@ else
fi
if [ "$PIN" = 1 ]; then
head_ "Pinning claudenet'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
head_ "Pinning boxnet's resolver (issue #33)"
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"
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"
@ -203,8 +212,10 @@ if [ "$PIN" = 1 ]; then
fi
head_ "Can a box actually resolve DNS?"
# Any box will do — the drill's names are not the only boxes on a host.
probe="$(incus list "user.claudebox=1" --format csv --columns ns 2>/dev/null \
# Any box will do — the drill's names are not the only boxes on a host, and
# a pre-rename box (legacy tag) is as good a probe as a new one.
probe="$({ incus list "user.box=1" --format csv --columns ns 2>/dev/null
incus list "user.claudebox=1" --format csv --columns ns 2>/dev/null; } \
| awk -F, '$2 == "RUNNING" { print $1; exit }')"
if [ -n "$probe" ] && [ "$FIX" != 1 ]; then
# Stdin MUST be pinned to /dev/null: with a TTY on stdin, 'incus exec' goes
@ -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' ' ')"
# 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
# except DNS/DHCP, so ICMP to 10.87.0.1 fails BY DESIGN on a healthy host.
# the gateway: box-firewall.sh drops everything from a box to the 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.
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"

View file

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

View file

@ -1,12 +1,12 @@
#!/usr/bin/env bash
# Apply the claudebox host-firewall rules. Idempotent; runs as root.
# Invoked by setup-host.sh at install time and by claudebox-firewall.service
# Apply the box host-firewall rules. Idempotent; runs as root.
# 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
# Docker's DOCKER-USER rules are runtime-only and need re-applying).
set -euo pipefail
GW=10.87.0.1
NET=claudenet
GW=10.88.0.1
NET=boxnet
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
@ -18,12 +18,12 @@ if command -v ufw >/dev/null && ufw status 2>/dev/null | grep -q "Status: active
fi
else
# No UFW: protect the host's own sockets with a dedicated nft table.
if ! nft list table inet claudebox >/dev/null 2>&1; then
nft add table inet claudebox
nft 'add chain inet claudebox input { type filter hook input priority -5 ; }'
nft add rule inet claudebox input iifname "$NET" udp dport '{ 53, 67 }' accept
nft add rule inet claudebox input iifname "$NET" tcp dport 53 accept
nft add rule inet claudebox input iifname "$NET" drop
if ! nft list table inet box >/dev/null 2>&1; then
nft add table inet box
nft 'add chain inet box input { type filter hook input priority -5 ; }'
nft add rule inet box input iifname "$NET" udp dport '{ 53, 67 }' accept
nft add rule inet box input iifname "$NET" tcp dport 53 accept
nft add rule inet box input iifname "$NET" drop
fi
fi
@ -31,7 +31,7 @@ fi
#
# 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
# 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
# probe found box A's SYN arriving at box B and B answering with a RST.
#
@ -41,16 +41,16 @@ fi
# sees this traffic.
#
# 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
# 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
# costs them nothing else. DHCP and ARP still work: they are broadcast, and the
# local delivery to dnsmasq happens on INPUT, not FORWARD.
if ! nft list table bridge claudebox >/dev/null 2>&1; then
nft add table bridge claudebox
nft "add chain bridge claudebox forward { type filter hook forward priority -200 ; policy accept ; }"
nft add rule bridge claudebox forward meta ibrname "$NET" meta obrname "$NET" drop
if ! nft list table bridge box >/dev/null 2>&1; then
nft add table bridge box
nft "add chain bridge box forward { type filter hook forward priority -200 ; policy accept ; }"
nft add rule bridge box forward meta ibrname "$NET" meta obrname "$NET" drop
fi
# Docker rewrites FORWARD policy to DROP; DOCKER-USER is its escape hatch.

View file

@ -1,6 +1,6 @@
#!/usr/bin/env bash
# One-time host setup: install Incus, create the isolated network + ACL and
# the claude-dev profile. Idempotent. Ubuntu 24.04 / Debian 13.
# the box-net profile. Idempotent. Ubuntu 24.04 / Debian 13.
set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
@ -56,21 +56,23 @@ PRESEED
fi
# Isolated NAT network. IPv6 off: one less egress path to reason about.
incus network show claudenet >/dev/null 2>&1 || incus network create claudenet \
ipv4.address=10.87.0.1/24 ipv4.nat=true ipv6.address=none
# 10.88, not 10.87: a pre-rename host may still carry claudenet on 10.87 with
# 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.
# Gateway carve-out first so instance DNS (dnsmasq on 10.87.0.1) survives.
if ! incus network acl show claude-isolate >/dev/null 2>&1; then
incus network acl create claude-isolate
incus network acl rule add claude-isolate egress action=allow destination=10.87.0.1/32
incus network acl rule add claude-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 claude-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 claude-isolate egress action=drop destination=100.64.0.0/10
# Gateway carve-out first so instance DNS (dnsmasq on 10.88.0.1) survives.
if ! incus network acl show box-isolate >/dev/null 2>&1; then
incus network acl create box-isolate
incus network acl rule add box-isolate egress action=allow destination=10.88.0.1/32
incus network acl rule add box-isolate egress action=drop destination=10.0.0.0/8
incus network acl rule add box-isolate egress action=drop destination=172.16.0.0/12
incus network acl rule add box-isolate egress action=drop destination=192.168.0.0/16
incus network acl rule add box-isolate egress action=drop destination=169.254.0.0/16
incus network acl rule add box-isolate egress action=drop destination=100.64.0.0/10
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.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,
# reconnaissance wide open. dns.mode=none stops it registering instance records;
# 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).
# 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
# drill host: pin applied, box resolves, cold mint survives.
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)"
# 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
# boxes cannot reach each other.
@ -107,41 +109,45 @@ incus network set claudenet raw.dnsmasq \
# --- Firewall coexistence ---------------------------------------------------
# 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,
# 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.
# 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.
if ! command -v ufw >/dev/null 2>&1 && ! command -v nft >/dev/null 2>&1; then
sudo apt-get install -y nftables
fi
sudo install -m 755 "$here/host/claudebox-firewall.sh" /usr/local/sbin/claudebox-firewall
sudo install -m 644 "$here/host/claudebox-firewall.service" /etc/systemd/system/
sudo install -m 755 "$here/host/box-firewall.sh" /usr/local/sbin/box-firewall
sudo install -m 644 "$here/host/box-firewall.service" /etc/systemd/system/
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
# 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
# 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.
sudo systemctl restart claudebox-firewall.service
sudo systemctl restart box-firewall.service
# Profile
if ! incus profile show claude-dev >/dev/null 2>&1; then
incus profile create claude-dev
# Profile — box-net, the placement contract: the isolated NIC and the root
# disk, nothing a template controls (resources are stamped per-instance from
# the template at mint time). A legacy claude-dev profile is left alone:
# Incus refuses to delete an in-use profile, and pre-rename boxes reference
# it until their last one is gone — teardown-host removes it then.
if ! incus profile show box-net >/dev/null 2>&1; then
incus profile create box-net
fi
incus profile edit claude-dev < "$here/profiles/claude-dev.yaml"
incus profile edit box-net < "$here/profiles/box-net.yaml"
# The sibling drop is the one rule whose absence is invisible: everything keeps
# working, and boxes can simply reach each other. Assert it landed.
if sudo nft list table bridge claudebox >/dev/null 2>&1; then
echo "Isolation: box-to-box drop is live (nft bridge table 'claudebox')."
if sudo nft list table bridge box >/dev/null 2>&1; then
echo "Isolation: box-to-box drop is live (nft bridge table 'box')."
else
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
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
# Reverse everything host/setup-host.sh created: all claudebox instances, the
# claudenet network + ACL, the claude-dev profile, and the firewall rules.
# Reverse everything host/setup-host.sh created — and everything its pre-0.4.0
# 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]
# --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)
set -euo pipefail
purge=false
[ "${1:-}" = "--purge-incus" ] && purge=true
echo "This removes ALL claudebox instances (uncommitted work in them is lost),"
echo "the claudenet network/ACL/profile, and the claudebox firewall rules."
echo "This removes ALL boxes (uncommitted work in them is lost), the"
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)."
read -rp "Continue? [y/N] " a
case "$a" in y|Y) ;; *) echo "aborted"; exit 1 ;; esac
# Instances
for i in $(incus list "user.claudebox=1" -f csv -c n || true); do
echo "deleting instance $i"
incus delete -f "$i"
# Instances — both tag generations, one delete at a time (a multi-name
# 'incus delete' aborts at the first missing name).
for tag in "user.box=1" "user.claudebox=1"; do
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
incus profile delete claude-dev 2>/dev/null || true
incus network delete claudenet 2>/dev/null || true
incus network acl delete claude-isolate 2>/dev/null || true
incus profile delete box-net 2>/dev/null || true
incus profile delete claude-dev 2>/dev/null || true # legacy, pre-0.4.0
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 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
# Firewall crumbs — UFW rules mentioning claudenet (numbers shift after each
# delete, so re-scan and remove the first match until none remain)
# Firewall crumbs — UFW rules mentioning either network (numbers shift after
# 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
while sudo ufw status numbered | grep -q "on claudenet"; do
n="$(sudo ufw status numbered | grep -m1 "on claudenet" | sed -E 's/^\[ *([0-9]+)\].*/\1/')"
sudo ufw --force delete "$n"
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"
done
done
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
sudo iptables -D DOCKER-USER -i claudenet -j ACCEPT 2>/dev/null || true
sudo iptables -D DOCKER-USER -o claudenet -j ACCEPT 2>/dev/null || true
for net in boxnet claudenet; do
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
if $purge; then
remaining="$(incus list -f csv 2>/dev/null | wc -l)"
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
sudo apt-get purge -y incus
sudo apt-get autoremove -y
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
#
# Downloads the claudebox repo tarball, installs the whole tree under
# $DEST, and puts a `claudebox` symlink on PATH via $BINDIR.
# $DEST, and puts a `box` symlink on PATH via $BINDIR.
REPO="${CLAUDEBOX_REPO:-heavy-duty/claudebox}"
REF="${CLAUDEBOX_REF:-main}"
@ -26,7 +26,7 @@ trap cleanup EXIT
URL="https://github.com/$REPO/archive/refs/heads/$REF.tar.gz"
log "installing claudebox ($REPO@$REF)"
log "installing box (the claudebox repo) ($REPO@$REF)"
log "downloading $URL"
curl -fsSL "$URL" -o "$TMPDIR/claudebox.tar.gz" \
|| die "failed to download $URL"
@ -38,7 +38,7 @@ tar -xzf "$TMPDIR/claudebox.tar.gz" -C "$TMPDIR" \
# GitHub archives extract to a single top-level dir like claudebox-<ref>/
EXTRACTED="$(find "$TMPDIR" -maxdepth 1 -type d -name 'claudebox-*' | head -n1)"
[ -n "$EXTRACTED" ] || die "could not find extracted claudebox-* directory in archive"
[ -f "$EXTRACTED/bin/claudebox" ] || die "archive does not contain bin/claudebox — is $REPO@$REF correct?"
[ -f "$EXTRACTED/bin/box" ] || die "archive does not contain bin/box — is $REPO@$REF correct?"
# --- atomically replace $DEST ---------------------------------------------
log "installing into $DEST"
@ -46,12 +46,19 @@ rm -rf "$DEST"
mkdir -p "$(dirname "$DEST")"
mv "$EXTRACTED" "$DEST"
chmod +x "$DEST/bin/claudebox"
chmod +x "$DEST/bin/box"
# --- put claudebox on PATH -------------------------------------------------
# --- put box on PATH -------------------------------------------------------
mkdir -p "$BINDIR"
ln -sf "$DEST/bin/claudebox" "$BINDIR/claudebox"
log "linked $BINDIR/claudebox -> $DEST/bin/claudebox"
ln -sf "$DEST/bin/box" "$BINDIR/box"
log "linked $BINDIR/box -> $DEST/bin/box"
# 0.4.0 renamed the binary (clean cut): clear a stale claudebox symlink so it
# cannot dangle at the old bin path forever. Old BOXES keep working — the CLI
# honors their legacy tag — it is only the old command name that retires.
if [ -L "$BINDIR/claudebox" ]; then
rm -f "$BINDIR/claudebox"
log "removed the old claudebox symlink — the command is 'box' now (your existing boxes keep working)"
fi
# --- PATH check ------------------------------------------------------------
case ":$PATH:" in
@ -65,8 +72,8 @@ esac
# --- environment check -----------------------------------------------------
if ! command -v incus >/dev/null 2>&1; then
warn "incus was not found. claudebox needs Incus on the host."
warn "incus was not found. box needs Incus on the host."
warn " run the one-time host setup: $DEST/host/setup-host.sh"
fi
log "done — try: claudebox new --name test"
log "done — try: box new --name test"

View file

@ -1,12 +1,15 @@
name: claude-dev
description: Trust-less claudebox (resources + isolated NIC)
config:
limits.cpu: "4"
limits.memory: 8GiB
# The placement contract. Every box, whatever its template, launches with
# exactly this profile: the isolated NIC and the root disk — and NOTHING a
# template controls. Resources (limits.*) are per-instance, stamped from the
# template's box.env at mint time; they do not belong here, because a profile
# a template could point away from is a network a template could escape.
name: box-net
description: The box placement contract — isolated NIC + root disk, nothing else
config: {}
devices:
eth0:
type: nic
network: claudenet
network: boxnet
name: eth0
# 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
@ -15,7 +18,7 @@ devices:
# 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
# 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
# live probe found box A's SYN arriving at box B and B answering with a RST.
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
A claudebox is a trust-less, network-isolated, ephemeral VM created by the
`claudebox` CLI. Keep this context in mind:
`box` CLI (template: claude). Keep this context in mind:
- **Creds-free by default.** The box starts with no Claude and no git
credentials. If you need to authenticate Claude, the operator runs `/login`
@ -23,7 +23,7 @@ write_files:
- **Isolated.** The box reaches the public internet but nothing on the host or
local network. There is no inbound path.
- **Disposable.** Nothing here is backed up. State is discarded when the box is
removed; the operator persists work via git push and via `claudebox snapshot`.
removed; the operator persists work via git push and via `box snapshot`.
- **Bootstrap runbook.** If the repository you are working in contains a
`.claudebox/` folder, read it as your setup runbook — how to install
dependencies, start services, template environment files, seed data, and