release: box 0.5.0 — codex+grok templates, migrate-host, box expose #56

Merged
dan-claude-bot merged 23 commits from integration/0.5.0 into main 2026-07-15 00:04:54 +00:00
18 changed files with 1023 additions and 137 deletions

120
README.md
View file

@ -1,10 +1,9 @@
# claudebox — ships the `box` CLI
# box
**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.
`claude` — Debian 13 with Claude Code installed. 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
@ -18,33 +17,36 @@ 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)
box. A repo can ship an optional [`.box/`](docs/box-recipe.md)
runbook that Claude Code reads and acts on — there is no `install` step and no
host-run setup. See [docs/claudebox-design.md](docs/claudebox-design.md) for the
host-run setup. See [docs/box-design.md](docs/box-design.md) for the
design rationale.
> **0.4.0 is a clean cut**: the CLI is `box` (no `claudebox` shim), the host
> stack is `boxnet`/`box-isolate`/`box-firewall` on 10.88.0.0/24, and the
> default template is `blank`. Existing boxes minted by any earlier version
> keep working under every verb — their legacy tag is honored forever, and
> their old `claudenet` (10.87) is left standing beside the new bridge. To
> strip a host of both generations at once: `host/teardown-host.sh`, or
> `drill/wipe.sh` for the scorched-earth version.
> **0.5.0**: two new templates (`codex`, `grok`), `box expose` — a
> loopback-only door to a box port, for seeing a dev server — and the host
> lifecycle as first-class verbs: `box setup-host`, `box teardown-host`, and
> `box migrate-host`, which re-homes pre-0.4.0 boxes onto the current stack
> and retires the legacy bridge.
>
> **0.4.0's clean cut stands**: the CLI is `box` (no legacy shim), the
> host stack is `boxnet`/`box-isolate`/`box-firewall` on 10.88.0.0/24, and
> the default template is `blank`. Boxes minted by any earlier version keep
> working under every verb — their legacy tag is honored forever.
## Install
```sh
curl -fsSL https://raw.githubusercontent.com/heavy-duty/claudebox/main/install.sh | bash
curl -fsSL https://raw.githubusercontent.com/heavy-duty/box/main/install.sh | bash
```
Installs the tree to `~/.local/share/claudebox` and links `box` onto your
Installs the tree to `~/.local/share/box` and links `box` onto your
`PATH`. Re-run any time to upgrade — upgrading from a pre-0.4.0 install also
retires the old `claudebox` symlink. (No `git clone` needed.)
## One-time host setup (Ubuntu 24.04 / Debian 13)
```sh
~/.local/share/claudebox/host/setup-host.sh # run twice if it adds you to incus-admin (re-login between)
box setup-host # run twice if it adds you to incus-admin (re-login between)
```
Idempotent. Installs Incus and creates the isolation stack: the `boxnet` NAT
@ -56,6 +58,11 @@ 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`.
A host still carrying the pre-0.4.0 stack: `box migrate-host --all-boxes`
re-homes each legacy box onto `boxnet` (authed state preserved), and
`box migrate-host --retire-legacy` removes the old bridge and profile once no
legacy box remains.
## Quick start
```sh
@ -70,15 +77,25 @@ claude # then run /login — copy the URL (press c), o
# in YOUR browser, paste the code back. No host CLI needed.
gh auth login # or drop a PAT in — your git credentials, your call
git clone https://github.com/you/project && cd project
claude # if the repo has .claudebox/, Claude reads it and sets up
claude # if the repo has .box/, 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).
The claude box is one template among several. What ships today:
| Template | What's in it |
| --- | --- |
| `blank` | Bare Debian 13 — same isolation, no tooling. The default. |
| `claude` | Claude Code, creds-free — where this project started |
| `codex` | OpenAI Codex CLI, creds-free |
| `grok` | xAI Grok CLI, creds-free |
A template is a directory under `templates/`: a `box.env` (image, user,
resources — parsed against a strict allowlist, never sourced) and a
`user-data.yaml` (cloud-init, passed to Incus verbatim). The coding-CLI
templates are all the same shape — install the CLI, put it on PATH, drop an
agent-context file; none of them carry credentials.
```sh
box templates # list what this install can mint
@ -112,13 +129,33 @@ a box's live state, or roll a box back with `box restore work authed`.
Forgotten what you called a checkpoint? `box info work` prints the box's
snapshot labels and the `--from` line to clone one.
## See a dev server: `box expose`
The isolation contract says no inbound path exists — which is one "no" too
many when you're coding in a box and want its dev server in your browser.
`box expose` is the deliberate exception:
```sh
box expose work 3000 # http://127.0.0.1:3000 → work:3000
box expose work 3000 8080 # or pick the host port: 127.0.0.1:8080 → work:3000
box expose work --list # what doors are open
box expose work --remove 3000 # close one
```
The listen side is **always the host's own loopback** — never the network, no
flag to widen it — so no other machine gains a path to the box. The in-box
server must listen on `0.0.0.0`, not its own loopback (safe inside the
isolation stack: only this door can reach it). A box with a hole says so:
`box info` lists open exposures. Everything else on the box stays dropped —
the door is per-port, punched and removable at runtime.
## Commands
```
box new --name <box> [--template <t>] [--from <src>[/<snap>]] [--vm|--container] [--remote r]
box new --name <box> [--template <t>] [--from <src>[/<snap>]] [--vm|--container]
box templates # list the templates this install can mint
box list # list your boxes
box info <box> # one box: state, IP, snapshot labels
box info <box> # one box: state, IP, exposures, 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
@ -128,8 +165,14 @@ box rename <box> <new> # rename a box (stop it first)
box down <box> # stop (state kept; `start` resumes)
box start <box> # start a stopped box
box rm <box> [--force] # delete the box + its snapshots (asks first)
box expose <box> <port> [<host-port>] | --list | --remove <port>
# forward a box port to host loopback — see a dev server
box incus <box> -- <args...> # escape hatch: any incus command, box resolved
box doctor [--fix|--pin-dns] # is this host fit to mint boxes? diagnose from ground truth
box setup-host # one-time host setup: Incus, the boxnet stack, the firewall
box teardown-host [--purge-incus] # remove the host stack (both name generations)
box migrate-host --box <n> | --all-boxes | --retire-legacy
# move a pre-0.4.0 host onto the box stack
box status # deprecated alias for `list`
box help [<command>] # full help, or one command's page
```
@ -138,7 +181,7 @@ Every command takes `--help`, and options come after the command
(`box list --json`). Exit status: `0` ok, `1` it went wrong, `2` you asked
wrong.
`new` fresh-launches from a template (default: `claude`), or with `--from`
`new` fresh-launches from a template (default: `blank`), 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,
@ -147,7 +190,8 @@ dev/test only.
## Boxes are just Incus instances
A box is an ordinary Incus instance tagged `user.box=1` (pre-0.4.0 boxes
carry `user.claudebox=1`, honored forever). box wraps the box lifecycle and the isolation model — not all of Incus. It owns a command
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:
@ -161,7 +205,7 @@ The box is resolved and tag-checked; the rest is passed to `incus` verbatim, and
the command is echoed before it runs. If it can move the box off the isolation
stack (profile, network, device, `security.*`), box warns and proceeds —
the trust boundary is then yours to keep. See
[docs/claudebox-design.md](docs/claudebox-design.md) for the rule and why the
[docs/box-design.md](docs/box-design.md) for the rule and why the
command surface is a table.
## Isolation
@ -185,7 +229,9 @@ enforces it, layer by layer:
host-level VPN don't resolve inside a box either.
- **Host firewall** — instance → host is dropped except DNS/DHCP, including
the host's public IPs. Entry is `incus exec` over the local socket only —
**no inbound path exists.**
**no inbound path exists** — unless you punch one with `box expose`, and
that door only ever opens onto the host's own loopback (`127.0.0.1`), never
the network.
The VM is the trust boundary: whatever runs inside — Claude, or anything a
template ships — can run arbitrary code and touch nothing you care about.
@ -196,9 +242,11 @@ Every clause above is probed live by an end-to-end drill, because the one time
this contract was reasoned about instead of measured, the reasoning was wrong:
box→box traffic was "covered" by an L3 drop that L2-switched frames never
meet — a hole found by probing, not by reading the rules. On a bare host the
drill installs the whole stack, mints a box cold, snapshots and clones it,
probes every boundary from inside the boxes, and removes what it minted —
currently **47 checks, 47 passing**. [drill/RUNS.md](drill/RUNS.md) is the full
drill installs the whole stack, mints every template cold, snapshots and
clones, probes every boundary from inside the boxes, opens and shuts the
`expose` door (and checks the contract survives it), re-homes a faithful
pre-0.4.0 box through `migrate-host`, and removes what it minted —
currently **81 checks, 81 passing**. [drill/RUNS.md](drill/RUNS.md) is the full
history, including every trap that fooled a run into a wrong verdict.
```sh
@ -214,19 +262,19 @@ diagnoses the host faults that have actually happened: a wedged Incus daemon,
a dnsmasq that silently isn't serving, a VPN resolver that boxes would
inherit.
## Recipes: the `.claudebox/` convention
## Recipes: the `.box/` convention
A repo that wants to be easy to stand up in a box ships an optional `.claudebox/`
A repo that wants to be easy to stand up in a box ships an optional `.box/`
folder — a runbook Claude reads and follows (install deps, start services,
template env, seed data, smoke-test). It is agent-facing documentation, not a
host-executed script. See [docs/claudebox-recipe.md](docs/claudebox-recipe.md).
host-executed script. See [docs/box-recipe.md](docs/box-recipe.md).
## Uninstall
```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/box # the CLI
box teardown-host # boxes, network, ACL, profile, firewall
box teardown-host --purge-incus # ...and Incus itself
rm -rf ~/.local/share/box ~/.local/bin/box # the CLI itself
```
## Non-goals

View file

@ -1 +1 @@
0.4.0
0.5.0

287
bin/box
View file

@ -50,8 +50,12 @@ CMDS=(
"down^<box>^box^Stop a box, keeping its state ('start' resumes it)^incus:stop^stopped {}"
"start^<box>^box^Start a stopped box^incus:start^started {}"
"rm^<box> [--force]^box,confirm^Delete a box and its snapshots — irreversible, and it asks first^incus:delete -f^removed {}"
"expose^<box> <port> [<host-port>] | --list | --remove <port>^box^Forward a box port to the host's loopback — see a dev server^fn:cmd_expose^"
"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^"
"setup-host^^^One-time host setup: Incus, the boxnet stack, the profile, the firewall^fn:cmd_setup_host^"
"teardown-host^[--purge-incus]^^Remove the box host stack (both name generations)^fn:cmd_teardown_host^"
"migrate-host^--box <n> | --all-boxes | --retire-legacy^^Move a host from the pre-0.4.0 stack onto box^fn:cmd_migrate_host^"
"status^^^Deprecated alias for 'list'^fn:cmd_status^"
"help^[<command>]^^This help, or 'box help <command>' for one command^fn:cmd_help^"
)
@ -118,7 +122,7 @@ EOF
local r v sum
for r in "${CMDS[@]}"; do
IFS='^' read -r v _ _ sum _ _ <<<"$r"
printf ' %-9s %s\n' "$v" "$sum"
printf ' %-13s %s\n' "$v" "$sum"
done
cat <<'EOF'
@ -310,6 +314,28 @@ confirmation first; --force (-f) skips the prompt. With no TTY to confirm on
box rm work
box rm work --force
EOF
;;
expose) cat <<'EOF'
Open a deliberate, loopback-only door to a port inside a box — for when you
are coding in a box and want to see its dev server in your browser.
box expose <box> <port> [<host-port>] # forward 127.0.0.1:<host-port> → box:<port>
box expose <box> --list # what doors are open
box expose <box> --remove <port> # close one
The host side ALWAYS listens on 127.0.0.1 — no other machine can reach the
box, only this host's loopback. There is no flag to widen that; if you need
LAN exposure you are leaving the tool's threat model, and 'box incus' is the
door (with its warning).
The in-box server must listen on 0.0.0.0:<port>, not only its own loopback —
a VM's forwarder connects to the box over the network. Inside an isolated box
that is safe: nothing but this proxy can reach the port.
box new --name web --template claude
box shell web # inside: run a dev server on 0.0.0.0:3000
box expose web 3000 # then open http://127.0.0.1:3000 in your browser
EOF
;;
incus) cat <<'EOF'
The door out. box wraps the box lifecycle and the isolation model, not
@ -348,6 +374,37 @@ cloud-init error that names none of them.
Exit 0 = clean; 1 = problems found (each printed with its fix). Read-only
unless --fix or --pin-dns is given.
EOF
;;
setup-host) cat <<'EOF'
Prepare this host to mint boxes — one time. Installs Incus and builds the
isolation stack: the boxnet NAT bridge (resolver pinned), the box-isolate
ACL, the box-net profile, and the firewall rules, all re-applied at boot.
Idempotent — safe to re-run after a box upgrade to pick up stack changes.
If it has to add you to the incus-admin group it will say so and exit; log
back in (or 'sg incus-admin') and run it again.
box setup-host
EOF
;;
teardown-host) cat <<'EOF'
Remove the box host stack — all boxes, the boxnet/claudenet networks, the
ACLs, the profiles, and the firewall rules of BOTH name generations (current
and pre-0.4.0). Asks first.
box teardown-host # the stack; leaves Incus installed
box teardown-host --purge-incus # ...and uninstall Incus too
EOF
;;
migrate-host) cat <<'EOF'
Move a host from the pre-0.4.0 'claudebox' stack onto 'box'. Re-homing
preserves a box's authed state (no re-login) — it only re-tags and reassigns
the profile, then verifies the box works on its new network leg.
box migrate-host --box <name> # re-home one legacy box
box migrate-host --all-boxes # re-home every legacy box
box migrate-host --retire-legacy # remove the old stack (once no legacy box remains)
EOF
;;
status) cat <<'EOF'
Deprecated alias for 'box list'. It ignored the <box> argument it
@ -399,6 +456,10 @@ while [ $# -gt 0 ]; do
-*)
# doctor's flags belong to the doctor script, not to box
if [ "$cmd" = doctor ]; then args+=("$1"); shift; continue; fi
# expose's own flags (--list, --remove) are positional to it, not box's
if [ "$cmd" = expose ]; then args+=("$1"); shift; continue; fi
# the host verbs delegate their flags to the scripts they wrap
case "$cmd" in setup-host|teardown-host|migrate-host) args+=("$1"); shift; continue ;; esac
if [ "$cmd" = exec ] || [ "$cmd" = incus ]; then
usage_error "unknown option: $1 — a command's own flags go after --, as in '$(synopsis_of "$cmd")'"
fi
@ -475,14 +536,45 @@ pick_mode() {
# 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
local n="$1" i clog
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)"
# The console log is FULL of terminal escape sequences (boot messages,
# a firmware menu). Dumping it raw scrambles the operator's terminal —
# and doubly so when it lands in a log someone is tail -f'ing. Capture
# it to a file, STRIP everything but printable ASCII + tab/newline, and
# print only a short sanitized tail. Nothing raw ever reaches a terminal.
clog="/tmp/box-console-$n.log"
timeout -k 5 15 incus console "$n" --show-log </dev/null >"$clog.raw" 2>/dev/null || true
# Strip whole escape sequences FIRST (while the ESC byte is present), then
# drop any residual control bytes — otherwise 'tr' alone leaves the visible
# '[1m[37m' halves behind. Result is clean, readable text.
sed -E $'s/\x1b\\[[0-9;:?]*[ -/]*[@-~]//g; s/\x1b[()#][0-9A-Za-z]//g; s/\x1b[=>PX^_].*?(\x1b\\\\|\x07)//g; s/\x1b.//g' \
"$clog.raw" 2>/dev/null | tr -cd '\11\12\40-\176' >"$clog"
rm -f "$clog.raw"
echo "box: instance agent never came up after 5 minutes." >&2
echo "box: sanitized console log → $clog (last non-blank lines:)" >&2
grep -v '^[[:space:]]*$' "$clog" 2>/dev/null | tail -6 | sed 's/^/ /' >&2
# A box that never boots is NOT a slow box, and the console says which
# failure it is. Each of these cost hours to diagnose by hand once; the
# box that hits them next should be told the answer, not the symptom.
if grep -qiE 'Failed to decompress kernel|efi_stub_entry\(\) failed' "$clog" 2>/dev/null; then
echo "box: THE KERNEL WOULD NOT DECOMPRESS — the cached image is corrupt." >&2
echo "box: (a truncated/bad image download does exactly this). Re-pull it:" >&2
echo "box: incus image list # find the fingerprint" >&2
echo "box: incus image delete <fingerprint> # the next mint re-downloads" >&2
elif grep -qiE 'bad shim signature|prohibited by secure boot' "$clog" 2>/dev/null; then
echo "box: SECURE BOOT rejected the kernel — but box mints VMs with" >&2
echo "box: security.secureboot=false, so this box predates that fix or was" >&2
echo "box: created by hand. Re-mint it with a current box." >&2
elif grep -qiE 'GNU GRUB|Press enter to boot|UEFI Firmware Settings' "$clog" 2>/dev/null; then
echo "box: the VM is stuck at the GRUB/firmware menu — it never booted." >&2
echo "box: this is the IMAGE, not box. Re-pull it (incus image delete …)," >&2
echo "box: or pin a known-good build in the template's BOX_IMAGE." >&2
fi
die "agent unreachable (inspect live: incus console $n)"
fi
sleep 2
done
@ -585,7 +677,14 @@ cmd_new() {
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
# security.secureboot=false: Incus defaults VMs to secureboot ON, and a
# Debian cloud image whose shim is signed with a key the host's OVMF does
# not trust dies with "bad shim signature / prohibited by secure boot
# policy" and drops to the GRUB menu forever — the kernel never loads. It
# is not part of a throwaway box's threat model (the VM boundary is), and
# turning it off boots reliably across image rebuilds. Container mode has
# no firmware, so it does not apply there.
if [ "$m" = vm ]; then extra+=(--vm --device "root,size=$T_DISK" --config security.secureboot=false); 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.
@ -663,6 +762,21 @@ box_ipv4() { # first address only; strips Incus's " (iface)" suffix. "-" if no
| grep . || echo "-"
}
# The box's address ON BOXNET — which is NOT the same as "its first address".
# A box running docker also carries 172.17.0.1 (docker0), and Incus happily
# lists that FIRST. box_ipv4() hands you the decoy, and pointing anything at it
# is pointing at the wrong interface: 'box expose' did exactly that until Incus
# refused with `Connect IP "172.17.0.1" must be one of the instance's static
# IPv4 addresses`. The drill has known this trap since run 4; the CLI had not.
# Derive the prefix from the network rather than hardcoding it.
box_net_ip() {
local pfx
pfx="$(incus network get boxnet ipv4.address 2>/dev/null | cut -d/ -f1 | cut -d. -f1-3)"
[ -n "$pfx" ] || return 1
incus list "$1" --format csv --columns 4 2>/dev/null \
| tr -d '"' | tr ' ,' '\n\n' | grep -E "^${pfx//./\\.}\.[0-9]+$" | head -n1 | grep .
}
# VIRTUAL-MACHINE is a mouthful in a table; anything unexpected passes through.
short_type() {
case "$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')" in
@ -716,6 +830,15 @@ cmd_info() {
printf '%-11s%s\n' NAME "$box" STATE "${state:--}" TYPE "$(short_type "$type")" \
IPV4 "$(box_ipv4 "$inst")"
# A box with a hole says so — an exposure visible only to --list is a hole
# info would deny. One line per open door.
local d listen
while IFS= read -r d; do
case "$d" in expose-*) : ;; *) continue ;; esac
listen="$(incus config device get "$inst" "$d" listen 2>/dev/null)"
printf '%-11s%s → port %s\n' EXPOSED "${listen#tcp:}" "${d#expose-}"
done < <(incus config device list "$inst" 2>/dev/null)
echo
case "${snaps:-0}" in
''|0)
@ -791,6 +914,19 @@ cmd_doctor() {
exec bash "$script" "${args[@]}"
}
# The host lifecycle scripts, as first-class verbs — nobody should have to know
# where the install tree keeps its scripts. Each execs the installed script
# with its flags passed through; the script owns its own behavior (setup-host's
# incus-admin re-login dance, teardown's confirmation, migrate's per-box work).
host_script() { # $1 = script basename under host/
local script="$root/host/$1"
[ -f "$script" ] || die "$1 not found at $script — re-run install.sh"
exec bash "$script" "${args[@]}"
}
cmd_setup_host() { host_script setup-host.sh; }
cmd_teardown_host() { host_script teardown-host.sh; }
cmd_migrate_host() { host_script migrate-host.sh; }
cmd_help() { show_help "${args[0]:-}"; }
# The escape hatch. The box is resolved and tag-checked; everything else is
@ -818,6 +954,145 @@ cmd_incus() {
incus "${out[@]}"
}
# A deliberate, loopback-only door to a box's port — for the one workflow the
# "no inbound path" contract is too absolute for: you are coding in a box and
# want to open its dev server in your browser.
#
# Two decisions, both load-bearing:
# · The listen side is ALWAYS 127.0.0.1. The network-facing contract stays
# true — no other machine can reach the box; only THIS host's loopback gets
# a door. There is no flag to widen it; that is the escape hatch's job, with
# its warning.
# · Each exposure is a named proxy device (expose-<port>), so 'box info' and
# --list can see it and --remove can undo it. A box with a hole says so.
#
# Mechanism (VMs): an Incus 'proxy' device in NAT mode DNATs host
# 127.0.0.1:<hostport> to the box's <ip>:<port>. The traffic rides the
# network into the guest, so the in-box server must listen on 0.0.0.0 (not
# just its own loopback) — inside an isolated box that is safe: boxnet +
# port-isolation + the ingress drop mean only this door can reach it. Three
# pieces beside the device itself, each one a drill-found absence:
# · a SCOPED ACL allow (this box's ip + this port only) — the ingress drop
# that makes A7 true would eat the DNAT'd packet;
# · route_localnet + a loopback masquerade on the host (box-firewall.sh) —
# Incus installs only the DNAT, and a loopback-sourced packet can neither
# leave the host nor be answered without them;
# · a static ipv4.address pin on the NIC — NAT mode refuses to start
# without one (see below).
exposure_dev() { echo "expose-$1"; } # device name for a port
cmd_expose() {
local box="${args[0]}" a2="${args[1]:-}" a3="${args[2]:-}"
# --list
if [ "$a2" = "--list" ]; then
local found=0 d listen connect
while IFS= read -r d; do
case "$d" in expose-*) : ;; *) continue ;; esac
listen="$(incus config device get "$inst" "$d" listen 2>/dev/null)"
connect="$(incus config device get "$inst" "$d" connect 2>/dev/null)"
[ "$found" = 0 ] && echo "EXPOSURES for $box"
found=1
printf ' %-14s %s → %s\n' "${d#expose-}" "$listen" "$connect"
done < <(incus config device list "$inst" 2>/dev/null)
[ "$found" = 0 ] && echo "box: $box has no exposed ports"
return 0
fi
# --remove <port>
if [ "$a2" = "--remove" ]; then
local port="$a3"; [ -n "$port" ] || usage_error "usage: box expose $box --remove <port>"
local dev; dev="$(exposure_dev "$port")"
incus config device get "$inst" "$dev" listen >/dev/null 2>&1 \
|| die "$box has no exposure on port $port (see 'box expose $box --list')"
incus config device remove "$inst" "$dev" >/dev/null \
&& echo "box: closed the door on port $port"
# Remove the scoped ACL allow, if one was added. Best-effort: its absence
# is not an error (the drill may show the allow was never needed).
local ip; ip="$(box_net_ip "$inst" || true)"
[ -n "$ip" ] && incus network acl rule remove box-isolate ingress \
action=allow "destination=$ip/32" "destination_port=$port" protocol=tcp >/dev/null 2>&1
# If that was the last door, unpin the static address it required. Only
# then — other exposures still lean on the pin. Best-effort, like the ACL.
if ! incus config device list "$inst" 2>/dev/null | grep -q '^expose-'; then
incus config device unset "$inst" eth0 ipv4.address >/dev/null 2>&1
fi
return 0
fi
# expose <port> [<host-port>]
local port="$a2" hport="${a3:-$a2}"
[ -n "$port" ] || usage_error "usage: $(synopsis_of expose)"
case "$port$hport" in *[!0-9]*) usage_error "ports must be numbers — got port='$port' host-port='$hport'" ;; esac
local ip; ip="$(box_net_ip "$inst")" \
|| die "$box has no boxnet address yet — is it running? (box info $box)"
local dev; dev="$(exposure_dev "$port")"
if incus config device get "$inst" "$dev" listen >/dev/null 2>&1; then
die "$box already exposes port $port (change or remove it: box expose $box --remove $port)"
fi
# The scoped ACL allow FIRST, so the door is open by the time the proxy uses
# it. Scoped to this box's IP and this port — it does not widen any other box.
incus network acl rule add box-isolate ingress action=allow \
"destination=$ip/32" "destination_port=$port" protocol=tcp >/dev/null 2>&1 || true
# A VM's proxy device must be NAT mode — Incus supports proxy on containers in
# both modes, but on VMs "NAT mode only" (the userspace forkproxy is a
# container thing). NAT mode DNATs host:port → instance:port in netfilter, and
# it needs the host to be the instance's gateway, which boxnet makes true.
#
# And NAT mode needs a STATIC address. Incus resolves connect=0.0.0.0 to the
# NIC's ipv4.address — the device config, not the lease — and refuses when it
# is unset: `Instance has no static IPv4 address assigned to be used as the
# connect IP` (the 0.5.0 drill). The first cut pinned an address but the
# WRONG one (box_ipv4's docker0 decoy); the second cut removed the pin
# instead of correcting it. Third cut: pin the box's current BOXNET lease.
# Same address the box already holds, so nothing about its networking moves —
# the lease just becomes official. Left in place across exposures; unpinned
# when the last door closes.
local err; err="$(mktemp)"
if [ -z "$(incus config device get "$inst" eth0 ipv4.address 2>/dev/null)" ]; then
# override copies the profile NIC into the instance with the key set; if a
# local eth0 already exists, override refuses and set is the right verb.
if ! incus config device override "$inst" eth0 "ipv4.address=$ip" >/dev/null 2>"$err" \
&& ! incus config device set "$inst" eth0 "ipv4.address=$ip" >/dev/null 2>"$err"; then
echo "box: could not pin $box's boxnet address ($ip) as static — the NAT proxy requires one:" >&2
sed 's/^/ /' "$err" >&2; rm -f "$err"
incus network acl rule remove box-isolate ingress action=allow \
"destination=$ip/32" "destination_port=$port" protocol=tcp >/dev/null 2>&1
die "expose failed"
fi
fi
# connect=0.0.0.0 is deliberate: Incus resolves it to the instance's static
# IPv4 (the pin above). Naming an address here would work too, but 0.0.0.0
# cannot repeat the docker0 mistake — there is nothing to get wrong.
if incus config device add "$inst" "$dev" proxy \
"listen=tcp:127.0.0.1:$hport" "connect=tcp:0.0.0.0:$port" \
bind=host nat=true >/dev/null 2>"$err"; then
rm -f "$err"
echo "box: 127.0.0.1:$hport → $box:$port"
echo "box: (the in-box server must listen on 0.0.0.0:$port, not only its own loopback)"
# The device alone is not the door: the DNAT'd loopback packet also needs
# route_localnet on the bridge (box-firewall.sh installs it, with the
# masquerade). Readable without root — warn instead of handing over a
# door that silently does not answer.
if [ "$(cat /proc/sys/net/ipv4/conf/boxnet/route_localnet 2>/dev/null)" != 1 ]; then
echo "box: WARNING — route_localnet is off on boxnet, so this door will NOT answer." >&2
echo "box: the host firewall predates expose — apply it: sudo /usr/local/sbin/box-firewall" >&2
fi
else
# NEVER swallow incus's reason — the first cut of this verb died with a bare
# "could not add the proxy device" and told the drill nothing.
echo "box: incus refused the proxy device:" >&2
sed 's/^/ /' "$err" >&2; rm -f "$err"
incus network acl rule remove box-isolate ingress action=allow \
"destination=$ip/32" "destination_port=$port" protocol=tcp >/dev/null 2>&1
die "expose failed"
fi
}
# --- dispatch: driven by the table, not by a hand-written case --------------
row="$(cmd_row "$cmd")" || unknown_command "$cmd"

View file

@ -1,6 +1,6 @@
# claudebox design
# box design
`claudebox` is a CLI that mints and manages **trust-less, network-isolated VMs
`box` is a CLI that mints and manages **trust-less, network-isolated VMs
with Claude Code installed**. It is infrastructure, not a project provisioner.
See issue #3 for the full reframe and rationale. This doc captures the durable
@ -10,13 +10,13 @@ design decisions.
- **The tool** mints isolated boxes with Claude installed but **unauthenticated**.
It knows nothing about projects, secrets, recipes, or memory.
- **The agent** (Claude Code, inside the box) reads an optional `.claudebox/`
- **The agent** (Claude Code, inside the box) reads an optional `.box/`
runbook in a cloned repo and acts on it. The recipe's consumer is the
reasoning agent, not host machinery.
## Boxes are strictly creds-free
`claudebox new --name <n>` launches a blank box: everything installed, **no**
`box new --name <n>` launches a blank box: everything installed, **no**
git credentials and **no** Claude credentials. The operator authenticates
interactively *inside* the box:
@ -33,35 +33,35 @@ multi-user problem: nothing shared, nothing committed.
Re-authing every fresh box would be toil, so authenticated state is reused via
snapshots, not a secrets store:
- `claudebox snapshot <n> [label]` — checkpoint after login + clone.
- `claudebox new --name <n2> --from <src>[/<snapshot>]` — clone an existing box
- `box snapshot <n> [label]` — checkpoint after login + clone.
- `box new --name <n2> --from <src>[/<snapshot>]` — clone an existing box
or snapshot (authed state and all). Isolation is preserved: the clone keeps
the `claude-dev` profile + `claudenet` + ACL.
- `claudebox restore <n> <snapshot>` — roll a box back to a checkpoint.
- `box restore <n> <snapshot>` — roll a box back to a checkpoint.
Log in once → snapshot → spin up authed boxes from it.
## The box announces itself to the agent
cloud-init installs a global `~/.claude/CLAUDE.md` in every box telling Claude it
is running in a claudebox (trust-less, ephemeral, creds-free) and to treat a
repo's `.claudebox/` folder as its bootstrap runbook. No "tell it" step, no host
is running in a box (trust-less, ephemeral, creds-free) and to treat a
repo's `.box/` folder as its bootstrap runbook. No "tell it" step, no host
execution.
## `.claudebox/` is optional, agent-facing documentation
## `.box/` is optional, agent-facing documentation
Not host-executed shell. A repo that wants to be easy to stand up in a sandbox
ships a runbook (prose + optional scripts the agent may run). A repo that does
not, you set up by hand. The tool enforces no contract; there is no `install`.
## What claudebox owns, and what it doesn't
## What box owns, and what it doesn't
Boxes are ordinary Incus instances, tagged `user.claudebox=1`. That makes every
Boxes are ordinary Incus instances, tagged `user.box=1`. That makes every
Incus verb a candidate feature request — `rename`, `info`, `file push`, on
forever — and wrapping them one at a time grows a worse `incus`. The rule:
> **claudebox owns a command when it must enforce an invariant Incus cannot see:**
> the `user.claudebox=1` boundary (never touch an instance we didn't mint), the
> **box owns a command when it must enforce an invariant Incus cannot see:**
> the `user.box=1` boundary (never touch an instance we didn't mint), the
> isolation stack (`claude-dev` profile + `claudenet` + ACL), or the creds-free
> snapshot→clone workflow. Everything else is Incus's job.
@ -76,17 +76,17 @@ The rule cuts both ways, and that's the point:
Two mechanisms keep this honest.
**The command table** (`CMDS` in `bin/claudebox`) is the single source of truth
**The command table** (`CMDS` in `bin/box`) is the single source of truth
for what exists, its synopsis, its help line, its preconditions and what runs.
Dispatch and help are both rendered from it, so the help cannot describe a
command that doesn't exist — the failure that produced #8. A thin verb is one
row; a verb that can't be expressed as a row and enforces no invariant of ours
doesn't belong in the tool.
**The escape hatch** — `claudebox incus <box> -- <args...>` — resolves and
**The escape hatch** — `box incus <box> -- <args...>` — resolves and
tag-checks the box, then hands the rest to Incus verbatim. It means "no" to a
proxy request is not "you can't do that", and it keeps the one rail that matters:
you cannot aim it at an instance claudebox didn't mint. If the command can move
you cannot aim it at an instance box didn't mint. If the command can move
the box off the isolation stack (profile, network, device, `security.*`), it
warns and proceeds — from there the trust boundary is yours to keep.
@ -102,7 +102,7 @@ That last clause is the one that was assumed and turned out to be false, so it
is spelled out here with the mechanism, and `drill/` tests it on every run.
- **Box → host, LAN, RFC1918, CGNAT, link-local:** the `claude-isolate` ACL.
- **Box → box: an nftables *bridge-family* rule** (`host/claudebox-firewall.sh`).
- **Box → box: an nftables *bridge-family* rule** (`host/box-firewall.sh`).
It cannot be an ACL rule. Two boxes on one bridge share an L2 segment, so
their frames are *switched* between bridge ports and never traverse the
netfilter path an L3 ACL lives on — the ACL looked airtight (it drops

View file

@ -1,18 +1,22 @@
# The `.claudebox/` convention
# The `.box/` convention
`claudebox` mints trust-less, creds-free, isolated VMs with Claude Code already
installed (`claudebox new/shell/snapshot/restore/exec/down/start/rm/status`). The
`box` mints trust-less, creds-free, isolated VMs with Claude Code already
installed (`box new/shell/snapshot/restore/exec/down/start/rm/status`). The
tool knows **nothing** about your project. There is no `install` step and no
host-run setup script.
A project makes itself easy to stand up inside a box by shipping an optional
`.claudebox/` folder. This folder is **agent-facing documentation** — read and
`.box/` folder. This folder is **agent-facing documentation** — read and
acted on by Claude Code (the reasoning agent) running inside the box. It is not
shell that the host executes.
> The folder was named `.claudebox/` before the 0.5.0 rename. Repos that still
> ship `.claudebox/` keep working — the agent is told to read either — but new
> projects should use `.box/`.
## What it is / what it is not
- **Optional.** No `.claudebox/` is a perfectly valid state.
- **Optional.** No `.box/` is a perfectly valid state.
- **Agent-facing.** You are writing instructions to a reasoning agent, not a
machine. Prose is fine; the agent adapts.
- **Not a host contract.** The host never parses, sources, or runs anything in
@ -23,34 +27,34 @@ shell that the host executes.
## How it's consumed
Every box ships a global `~/.claude/CLAUDE.md` telling Claude it is inside a
claudebox and to treat a repo's `.claudebox/` folder as its bootstrap runbook.
box and to treat a repo's `.box/` folder as its bootstrap runbook.
So the whole flow is:
```
claudebox new # get a box
claudebox shell # get in
box new # get a box
box shell # get in
git clone <repo> && cd <repo>
claude # Claude reads .claudebox/ and brings the project up
claude # Claude reads .box/ and brings the project up
```
The operator can also just say: *"set this project up per .claudebox"*.
The operator can also just say: *"set this project up per .box"*.
## Suggested contents (all optional)
Author everything here for a reasoning agent.
- **`.claudebox/SETUP.md`** — the prose runbook. Prerequisites, how to install
- **`.box/SETUP.md`** — the prose runbook. Prerequisites, how to install
deps, how to start services, how to template the env, how to seed data, and
how to smoke-test. Written as instructions to Claude.
- **Helper scripts** (e.g. `.claudebox/dev-up.sh`) that the runbook tells Claude
- **Helper scripts** (e.g. `.box/dev-up.sh`) that the runbook tells Claude
to run. Claude decides to run them; the host never does.
- **`.claudebox/env.template`** — example env the runbook explains how to fill.
- **`.box/env.template`** — example env the runbook explains how to fill.
Staging values the operator pastes in. **Never commit real secrets.**
- **`.claudebox/compose.yml`** — optional services the runbook starts.
- **`.box/compose.yml`** — optional services the runbook starts.
## Worked example
A minimal `.claudebox/SETUP.md` for a Node + Postgres app:
A minimal `.box/SETUP.md` for a Node + Postgres app:
```markdown
# Setup
@ -58,8 +62,8 @@ A minimal `.claudebox/SETUP.md` for a Node + Postgres app:
This is a Node service backed by Postgres.
1. Install deps: `npm ci`
2. Start Postgres: `docker compose -f .claudebox/compose.yml up -d`
3. Create the env file: copy `.claudebox/env.template` to `.env` and ask the
2. Start Postgres: `docker compose -f .box/compose.yml up -d`
3. Create the env file: copy `.box/env.template` to `.env` and ask the
operator to fill in `DATABASE_URL` and `API_KEY` (staging values).
4. Run migrations: `npm run migrate`
5. Start the app: `npm run dev`
@ -72,7 +76,7 @@ That's it — Claude reads it top to bottom and adapts if reality differs.
- **Keep it declarative and resilient.** State intent and steps; let the agent
adapt when the repo has drifted. Don't hard-code brittle assumptions.
- **Never put real credentials in `.claudebox/`.** Templates and staging
- **Never put real credentials in `.box/`.** Templates and staging
placeholders only. The operator pastes real values at runtime.
- **No `.claudebox/` is fine.** The operator can stand the project up by hand,
- **No `.box/` is fine.** The operator can stand the project up by hand,
or let Claude infer the steps from the repo's `README` / `CLAUDE.md`.

View file

@ -1,6 +1,6 @@
# The drill
An end-to-end rehearsal of claudebox against a **real** Incus: install the CLI,
An end-to-end rehearsal of box against a **real** Incus: install the CLI,
set up the host, mint boxes, drive the whole surface, check that the isolation
actually holds — and run the full
[#15 audit](https://github.com/heavy-duty/claudebox/issues/15), including a live
@ -32,7 +32,7 @@ The drill mutates the host in phase D; an aborted run can leave a network that
mints boxes with **no DNS**.
**Iterating on the drill?** Read [RUNS.md](RUNS.md) first — it is the run log:
what the audit has answered so far, the bugs the drill has found in claudebox,
what the audit has answered so far, the bugs the drill has found in box,
the traps this script has already fallen into (every one cost a run), how to
diagnose a stall, and how to run a single probe by hand instead of paying for a
whole run.
@ -46,7 +46,7 @@ wrong. The drill runs the real thing.
## What it checks
**A. Incus semantics.** The assumptions claudebox is built on, probed directly:
**A. Incus semantics.** The assumptions box is built on, probed directly:
that `incus config get <inst> user.claudebox` returns `1` (this is on the path of
*every* box command — if it lies, everything fails closed); that the
`user.claudebox=1` list filter selects our instances and excludes an untagged
@ -62,7 +62,7 @@ and its isolation warning, the `rm` confirmation guard, and the CLI contract
(typo'd command, typo'd flag, `list <box>`).
**The boundary** gets its own treatment: the drill launches an instance
claudebox did *not* mint, aims `down`, `rm` and the escape hatch at it, and
box did *not* mint, aims `down`, `rm` and the escape hatch at it, and
requires all three to refuse — and the instance to still be standing afterwards.
**C. Isolation baseline (#15 section A).** From inside a real box: public egress
@ -88,6 +88,6 @@ a **design veto** for #16, caught before the code is written.
design. The drill confirms Claude Code is installed and runnable; authenticating
is yours.
If the host has no `/dev/kvm`, claudebox falls back to container mode. The drill
If the host has no `/dev/kvm`, box falls back to container mode. The drill
still runs, but it says loudly that **the VM trust boundary was not validated**
rather than passing quietly on a weaker one.

View file

@ -49,7 +49,7 @@ if incus network show boxnet >/dev/null 2>&1; 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"
inf "fix: re-run: box setup-host"
[ "$FIX" = 1 ] && { incus network set boxnet dns.mode=none && inf "set: dns.mode=none"; }
fi
inf "ipv4.address = $(incus network get boxnet ipv4.address 2>/dev/null)"
@ -110,7 +110,7 @@ if [ -n "$PROFILES" ]; then
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"
inf "fix: re-run: box setup-host"
fi
for k in security.mac_filtering security.ipv4_filtering; do
v="$(incus profile device get "$p" eth0 "$k" 2>/dev/null)"
@ -192,7 +192,7 @@ else
no "the host resolves via a CGNAT/Tailscale resolver ($hostns), and boxes INHERIT it — see issue #33"
inf "· box DNS breaks whenever the tailnet's resolver does (this is what kills cold mints)"
inf "· and tailnet names RESOLVE from inside a box, though its ACL blocks connecting to them"
inf "fix: re-run ~/.local/share/claudebox/host/setup-host.sh (it pins the resolver)"
inf "fix: re-run: box setup-host (it pins the resolver)"
inf " or quick-test the pin alone: bash drill/doctor.sh --pin-dns"
else
inf "boxes inherit the host's resolver (unpinned — setup-host.sh pins this now; re-run it)"

View file

@ -1,5 +1,5 @@
#!/usr/bin/env bash
# drill.sh — end-to-end drill for box (the claudebox repo), against a real Incus.
# drill.sh — end-to-end drill for box, 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
@ -32,8 +32,8 @@
# false FAILs on the first live run. The pipeline verdict must be grep's alone.
set -u
REPO="${CLAUDEBOX_REPO:-heavy-duty/claudebox}"
REF="${CLAUDEBOX_REF:-main}"
REPO="${BOX_REPO:-heavy-duty/claudebox}"
REF="${BOX_REF:-main}"
YES=0; KEEP=0
SELF="$(readlink -f "$0")"
@ -57,9 +57,11 @@ inf() { printf ' %s\n' "$*"; }
phase(){ printf '\n\033[1m══ %s\033[0m\n' "$*"; }
aud() { audit+=("$*"); } # an answer for the #15 audit
wait_box() { # poll until exec answers (the VM agent can take a while), ~2 min
wait_box() { # poll until exec answers (the VM agent can take a while), ~4 min
# 2 min was too short: run 17's legacy box came up AFTER the window closed —
# the drill called it dead and then every migration check on it passed.
local b="$1" _i
for _i in $(seq 1 60); do
for _i in $(seq 1 120); do
box exec "$b" -- true >/dev/null 2>&1 && return 0
sleep 2
done
@ -159,8 +161,8 @@ This will, ON THIS HOST ($(hostname)):
· install Incus and a systemd unit
· 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, tpl
· mutate the network and profile mid-run to rehearse the #16 hardening
· create and destroy instances named: drill, clone, archive, peer, payroll, cbprobe, cbcopy, tpl, codex, grok, legacybox
· build a faithful legacy stack (claudenet/10.87, claude-dev) to drill migration
Only do this on a machine you can format.
EOF
[ -t 0 ] || { echo "drill: no TTY to confirm on — pass --yes if you mean it." >&2; exit 2; }
@ -170,11 +172,28 @@ EOF
fi
phase "Installing box ($REPO@$REF)"
CLAUDEBOX_REPO="$REPO" CLAUDEBOX_REF="$REF" \
BOX_REPO="$REPO" BOX_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"
# ASSERT WHAT LANDED — never trust that the install obeyed us.
# This has bitten twice: once on a lagged CDN tarball, once when a STALE local
# drill.sh passed the retired CLAUDEBOX_* env vars to a 0.5.0 install.sh that
# reads BOX_* — the vars were ignored, main was installed, and the run drilled
# the wrong tree while reporting success. A drill that silently drills the
# wrong code is worse than one that fails.
got="$(cat "$HOME/.local/share/box/INSTALLED_FROM" 2>/dev/null || echo '<unknown>')"
if [ "$got" != "$REPO@$REF" ]; then
echo "drill: FATAL — asked to install $REPO@$REF, but the tree says '$got'." >&2
echo " Your local drill.sh is probably STALE (pre-0.5.0 it passed CLAUDEBOX_*," >&2
echo " which today's install.sh ignores, so it fell back to main). Fix:" >&2
echo " git fetch origin && git checkout <the branch you mean> && git pull" >&2
echo " then re-run this drill." >&2
exit 1
fi
inf "installed tree confirms: $got"
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:
@ -206,10 +225,10 @@ EOF
fi
inf "running setup-host.sh (first pass: may only add you to incus-admin)…"
~/.local/share/claudebox/host/setup-host.sh || true
~/.local/share/box/host/setup-host.sh || true
# The group we were just added to isn't in this shell's credentials yet.
inf "re-entering inside the incus-admin group…"
exec sg incus-admin -c "IN_GROUP=1 CLAUDEBOX_REPO='$REPO' CLAUDEBOX_REF='$REF' KEEP=$KEEP bash '$SELF' --in-group"
exec sg incus-admin -c "IN_GROUP=1 BOX_REPO='$REPO' BOX_REF='$REF' KEEP=$KEEP bash '$SELF' --in-group"
fi
export PATH="$HOME/.local/bin:$PATH"
@ -243,7 +262,7 @@ 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 tpl; do
for n in drill clone archive peer payroll cbprobe cbcopy cbnotours tpl codex grok legacybox; do
timeout -k 5 60 incus delete -f "$n" >/dev/null 2>&1
done
if incus network show boxnet >/dev/null 2>&1; then
@ -259,7 +278,7 @@ 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"
inf "running setup-host.sh (in-group pass: network, ACL, profile, firewall)…"
if ! timeout -k 10 300 ~/.local/share/claudebox/host/setup-host.sh; then
if ! timeout -k 10 300 ~/.local/share/box/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 boxnet while its ACLs are reconfigured" >&2
@ -367,7 +386,7 @@ 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 '?')"
expected="$(cat "$HOME/.local/share/box/VERSION" 2>/dev/null || echo '?')"
v="$(box --version 2>&1)"
case "$v" in *"$expected"*) ok "box --version → $v" ;; *) no "version mismatch: CLI says '$v', VERSION file says '$expected'" ;; esac
@ -385,14 +404,18 @@ else
fi
# --- 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"
tpl_missing=""
for t in blank claude codex grok; do
box templates 2>/dev/null | grep -q "^ $t" || tpl_missing="$tpl_missing $t"
done
[ -z "$tpl_missing" ] && ok "templates: lists blank, claude, codex, grok" \
|| no "templates listing is missing:$tpl_missing"
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"
badt="$HOME/.local/share/box/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" \
@ -425,14 +448,50 @@ if mint_box /tmp/mint-tpl.log --name tpl; then
box rm tpl --force >/dev/null 2>&1 && ok "blank box removed" || no "could not remove the blank box"
else
no "blank mint FAILED — tail: $(tail -3 /tmp/mint-tpl.log | tr '\n' ' ')"
# Tear the stuck box down — a failed mint that lingers starves the next one.
timeout -k 5 60 incus delete -f tpl >/dev/null 2>&1
fi
# The generic mechanic (metadata, placement, user, isolation parity) is proven
# once by blank+claude and needs no per-template repeat. What a NEW template
# still has to prove is its own payload: the CLI installs, lands on the
# non-interactive exec PATH, and answers --version. One mint each.
for t in codex grok; do
case "$t" in codex) bin=codex; user=codex ;; grok) bin=grok; user=grok ;; esac
printf '\n minting a %s box (cold — validates the template install)…\n' "$t"
if mint_box "/tmp/mint-$t.log" --name "$t" --template "$t"; then
[ "$(incus config get "$t" user.box.user 2>/dev/null)" = "$user" ] \
&& ok "$t: template user stamped ($user)" || no "$t: user.box.user not $user"
if timeout -k 5 30 box exec "$t" -- "$bin" --version </dev/null >/dev/null 2>&1; then
ok "$t: '$bin --version' answers via box exec — installed and on the non-interactive PATH"
else
no "$t: '$bin --version' FAILED via exec — not installed, or not on exec's PATH (the claude template's #15 bug)"
inf "PATH as exec sees it: $(timeout -k 5 20 box exec "$t" -- printenv PATH </dev/null 2>/dev/null)"
# Do not throw the evidence away — say WHAT the installer actually left.
# Do NOT throw the evidence away — say what the installer actually left
# behind, and what its own log said. Guessing at an upstream installer's
# layout is how this FAILed in the first place.
inf "anything named '$t' on disk:"
in_box "$t" sh -c "find /home /opt /usr/local /usr/bin -maxdepth 4 \\( -type f -o -type l \\) -iname '*$t*' 2>/dev/null | head -8" \
| sed 's/^/ /'
inf "what its cloud-init said:"
in_box "$t" sh -c "grep -iE '$t|install' /var/log/cloud-init-output.log 2>/dev/null | tail -8" \
| sed 's/^/ /'
fi
box rm "$t" --force >/dev/null 2>&1 && ok "$t box removed" || no "$t: could not remove"
else
no "$t mint FAILED — tail: $(tail -3 "/tmp/mint-$t.log" | tr '\n' ' ')"
timeout -k 5 60 incus delete -f "$t" >/dev/null 2>&1
fi
done
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' ' ')"
timeout -k 5 60 incus delete -f drill >/dev/null 2>&1
echo; echo "── cannot continue without a box"; printf ' %s\n' "${findings[@]}"; exit 1
fi
@ -638,6 +697,63 @@ else
aud "A7 inbound host→box: NOT PROBED"
fi
# ===========================================================================
phase "E. box expose — a deliberate loopback door (#55)"
# ===========================================================================
# archive is a running claude box (node is installed). Start a DETACHED
# listener on 0.0.0.0 inside it, expose the port, and prove the door works
# from the HOST's loopback. Then prove removing it closes the door, and that a
# NON-exposed port still obeys the ingress drop — the feature must not
# globally weaken A7.
EP=8091; EHP=18091
srv="$(mktemp)"
printf 'require("http").createServer((q,r)=>r.end("box-expose-ok")).listen(%s,"0.0.0.0")\n' "$EP" >"$srv"
if incus file push "$srv" archive/tmp/srv.js >/dev/null 2>&1; then
rm -f "$srv"
# Detached: setsid + all fds redirected so 'incus exec' returns at once and
# nothing holds its stdout (trap 2/3). The listener outlives the exec.
timeout -k 5 20 incus exec archive -- sh -c 'setsid node /tmp/srv.js >/tmp/srv.log 2>&1 </dev/null &' </dev/null
sleep 3
xlog="$(mktemp)"
if box expose archive "$EP" "$EHP" >"$xlog" 2>&1; then
ok "box expose archive $EP $EHP — the device was added"
box expose archive --list 2>/dev/null | grep -q "$EP" \
&& ok "expose --list shows the open door" || no "expose --list does not show the exposure"
box info archive 2>/dev/null | grep -qi "$EP" \
&& ok "box info surfaces the exposure (a box with a hole says so)" || note "box info does not mention the exposure (nice-to-have)"
# THE test: does the host's loopback reach the box's server?
sleep 2
if curl -sS -m 6 "http://127.0.0.1:$EHP" 2>/dev/null | grep -q box-expose-ok; then
ok "127.0.0.1:$EHP reaches the box's server — the door WORKS"
else
no "127.0.0.1:$EHP does NOT reach the box — the proxy/ACL mechanism needs work (#55)"
inf "srv.log inside the box: $(in_box archive cat /tmp/srv.log 2>/dev/null | tail -2 | tr '\n' ' ')"
fi
# A NON-exposed port must still be dropped — the feature is per-port, not a
# global ingress opening.
nemsg="$(curl -sS -m 5 -o /dev/null "http://$ARCH_IP:9099" 2>&1)"
printf '%s' "$nemsg" | grep -q 'Connection refused' \
&& no "a non-exposed port answered on the box — expose opened ingress too wide" \
|| ok "a non-exposed port is still dropped — expose is per-port, A7 survives"
# Close it, and confirm the door shuts.
box expose archive --remove "$EP" >/dev/null 2>&1 && ok "box expose --remove closed the device" || no "expose --remove failed"
sleep 2
curl -sS -m 5 -o /dev/null "http://127.0.0.1:$EHP" 2>/dev/null \
&& no "the host still reaches the box after --remove — the door did not shut" \
|| ok "after --remove, 127.0.0.1:$EHP is dead — the door shut"
else
no "box expose failed to add the device"
inf "what box and incus actually said:"
sed 's/^/ /' "$xlog" 2>/dev/null
rm -f "$srv" 2>/dev/null
fi
rm -f "$xlog" 2>/dev/null
timeout -k 5 15 incus exec archive -- pkill -f srv.js </dev/null >/dev/null 2>&1
else
rm -f "$srv"
no "could not push the test server into archive — expose phase did not run"
fi
# ===========================================================================
phase "D. The isolation contract, stated"
# ===========================================================================
@ -671,6 +787,79 @@ if [ "$BASELINE_OK" -ne 1 ]; then
inf "start with: bash drill/doctor.sh"
fi
# ===========================================================================
phase "M. Migration — the pre-0.4.0 → box transition (host/migrate-host.sh)"
# ===========================================================================
# A fresh host has no legacy stack, so build a faithful one: claudenet on the
# OLD subnet, a claude-dev profile pinned to it, and a box tagged with the OLD
# tag on the OLD network — exactly what a pre-0.4.0 host carries. Then prove
# migrate-host.sh moves it onto the new stack with its identity intact, and
# retires the legacy stack only once it is empty.
MIG="$HOME/.local/share/box/host/migrate-host.sh"
if [ ! -f "$MIG" ]; then
no "migrate-host.sh not installed — cannot drill the transition"
else
inf "building a faithful legacy stack (claudenet/10.87 + claude-dev)…"
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 >/dev/null 2>&1
if ! incus profile show claude-dev >/dev/null 2>&1; then
incus profile create claude-dev >/dev/null 2>&1
incus profile device add claude-dev root disk pool=default path=/ >/dev/null 2>&1
incus profile device add claude-dev eth0 nic network=claudenet name=eth0 \
security.port_isolation=true >/dev/null 2>&1
fi
# A minimal legacy box: no template payload, just boots and networks on the
# old stack, wearing the old tag. This is what migrate has to move.
printf '\n minting a faithful legacy box on the old stack…\n'
# The legacy box must carry a 'claude' user, because that is what a real
# pre-0.4.0 box had — and box_user() maps the legacy tag to it. Without the
# user, 'box exec' (sudo -u claude) can never answer and wait_box fails
# forever on a box that is perfectly healthy. Run 17/18 lost a FAIL to this.
if mint_legacy=$(incus launch images:debian/13/cloud legacybox --profile claude-dev \
--config user.claudebox=1 --vm --device root,size=20GiB \
--config security.secureboot=false \
--config cloud-init.user-data="$(printf '#cloud-config\nusers:\n - name: claude\n shell: /bin/bash\n sudo: "ALL=(ALL) NOPASSWD:ALL"\n lock_passwd: true\n')" 2>&1); then
wait_box legacybox && ok "legacy box up on the old stack (claudenet, user.claudebox=1)" \
|| no "legacy box never came up — cannot drill migration"
box list 2>/dev/null | grep -q '^legacybox' \
&& ok "box list shows the legacy box (dual-tag matching)" || no "legacy box invisible to 'box list'"
# Retire must REFUSE while a legacy box exists.
bash "$MIG" --retire-legacy 2>&1 | grep -qi 'legacy boxes still exist' \
&& ok "retire-legacy refuses while a legacy box remains" \
|| no "retire-legacy did NOT refuse with a legacy box present — it would strip an in-use stack"
# Re-home it.
printf ' re-homing the legacy box…\n'
bash "$MIG" --box legacybox 2>&1 | sed 's/^/ /'
[ "$(incus config get legacybox user.box 2>/dev/null)" = 1 ] \
&& ok "migrate: legacy box now tagged user.box=1" || no "migrate: user.box tag not set"
[ "$(incus config get legacybox user.box.user 2>/dev/null)" = claude ] \
&& ok "migrate: legacy box mapped to the claude user" || no "migrate: user.box.user not claude"
incus config show legacybox 2>/dev/null | grep -q '^- box-net' \
&& ok "migrate: legacy box reassigned to box-net (the new placement contract)" \
|| no "migrate: legacy box is NOT on box-net"
lip="$(boxnet_ip legacybox)"
[ -n "$lip" ] && ok "migrate: legacy box got a boxnet address ($lip) — network move landed" \
|| no "migrate: legacy box has no 10.88 address — the move did not take"
in_box legacybox getent hosts deb.debian.org >/dev/null 2>&1 \
&& ok "migrate: re-homed box resolves + reaches the internet on its new leg" \
|| no "migrate: re-homed box cannot resolve on boxnet"
# No legacy boxes remain → retire must now SUCCEED and leave nothing.
printf ' retiring the (now empty) legacy stack…\n'
bash "$MIG" --retire-legacy 2>&1 | sed 's/^/ /'
incus network show claudenet >/dev/null 2>&1 \
&& no "retire-legacy left claudenet behind" || ok "retire-legacy removed claudenet"
incus profile show claude-dev >/dev/null 2>&1 \
&& no "retire-legacy left claude-dev behind" || ok "retire-legacy removed claude-dev"
box rm legacybox --force >/dev/null 2>&1
else
no "could not launch the legacy box: $(printf '%s' "$mint_legacy" | tail -1)"
fi
fi
# ===========================================================================
if [ "$KEEP" = 1 ]; then
phase "Boxes left up (--keep-boxes)"
@ -678,11 +867,11 @@ if [ "$KEEP" = 1 ]; then
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 tpl; do box rm "$n" --force >/dev/null 2>&1; done
for n in drill clone archive peer tpl codex grok legacybox; 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="$(box list 2>/dev/null | grep -E '^(drill|clone|archive|peer|tpl)([[:space:]]|$)' || true)"
leftover="$(box list 2>/dev/null | grep -E '^(drill|clone|archive|peer|tpl|codex|grok)([[: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
@ -702,5 +891,5 @@ fi
echo
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]"
inf "to undo: ~/.local/share/box/host/teardown-host.sh [--purge-incus]"
[ "$fail" -eq 0 ]

View file

@ -18,13 +18,25 @@ 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 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
# Flush + rebuild rather than skip-if-present: a guard that only checks
# existence pins every host to the rule set of the release that FIRST ran
# here, and an upgraded rule never lands. 'add chain' with the same spec is
# a no-op, so this converges.
#
# The established,related accept is load-bearing for 'box expose' (#55): the
# door's traffic reaches the box masqueraded as the gateway, so the box's
# REPLY arrives here as input on boxnet — a stateless drop eats it and the
# door times out (drill-found). Boxes still cannot INITIATE toward the host:
# a box-originated SYN is a NEW flow, and NEW is what the drop is for.
# (UFW hosts get the same semantics from ufw's built-in RELATED,ESTABLISHED
# accept in before.rules — this branch must match it.)
nft add table inet box
nft 'add chain inet box input { type filter hook input priority -5 ; }'
nft flush chain inet box input
nft add rule inet box input iifname "$NET" ct state established,related accept
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
# --- Sibling isolation: a box must not reach another box --------------------
@ -53,6 +65,38 @@ if ! nft list table bridge box >/dev/null 2>&1; then
nft add rule bridge box forward meta ibrname "$NET" meta obrname "$NET" drop
fi
# --- The loopback door's missing half (box expose, #55) ----------------------
#
# 'box expose' publishes a box port on the host's 127.0.0.1 via an Incus
# NAT-mode proxy device. Incus installs the DNAT (prerouting + output hooks)
# and NOTHING else — its only SNAT is a hairpin rule for the box reaching its
# own exposure. A host-local `curl 127.0.0.1:<hport>` is therefore DNAT'd
# toward the box and then dies twice:
# · the kernel refuses to route a loopback-SOURCED packet out a
# non-loopback interface (a martian) unless route_localnet is set on the
# egress bridge;
# · even then, the box would reply to 127.0.0.1 — its OWN loopback —
# unless the source is rewritten to something it can answer.
# This is exactly the plumbing Docker installs on docker0 to make
# `-p 127.0.0.1:x:y` work: route_localnet=1 on the bridge, plus a masquerade
# of loopback-sourced traffic leaving it (the box then sees the gateway and
# replies through it). Scoped to boxnet only, never 'all'.
#
# route_localnet's known risk — it makes 127/8 a routable DESTINATION on the
# interface, so a box could aim frames at the host's loopback services — is
# covered by the ingress stance above: everything arriving on boxnet at the
# host is dropped except DNS/DHCP (UFW 'deny in' or the inet-box input chain),
# and that drop fires regardless of the destination address.
if [ -e "/proc/sys/net/ipv4/conf/$NET/route_localnet" ]; then
sysctl -qw "net.ipv4.conf.$NET.route_localnet=1"
else
echo "box-firewall: $NET does not exist yet — route_localnet not set; expose's loopback door stays dead until this script runs again" >&2
fi
nft add table inet box
nft "add chain inet box expose-snat { type nat hook postrouting priority 110 ; }"
nft flush chain inet box expose-snat
nft add rule inet box expose-snat oifname "$NET" ip saddr 127.0.0.0/8 masquerade
# Docker rewrites FORWARD policy to DROP; DOCKER-USER is its escape hatch.
if command -v docker >/dev/null && iptables -L DOCKER-USER -n >/dev/null 2>&1; then
iptables -C DOCKER-USER -i "$NET" -j ACCEPT 2>/dev/null || iptables -I DOCKER-USER -i "$NET" -j ACCEPT

153
host/migrate-host.sh Normal file
View file

@ -0,0 +1,153 @@
#!/usr/bin/env bash
# migrate-host.sh — move a host from the pre-0.4.0 'claudebox' stack to 'box'.
#
# The zero-ceremony transition is just install.sh + setup-host.sh: that leaves
# a DUAL-STACK host where legacy boxes (tag user.claudebox=1, claudenet/10.87,
# claude-dev) keep working while new mints land on boxnet/10.88. This script is
# the two things that path does not do:
#
# migrate-host.sh --box <name> re-home ONE legacy box onto the new stack
# migrate-host.sh --all-boxes re-home every legacy box
# migrate-host.sh --retire-legacy remove the legacy stack (refuses while any
# legacy box still exists)
#
# One action per invocation, idempotent, loud about what it did. Re-homing
# PRESERVES the box's authed state (Claude login, git creds — the expensive
# thing); it does not re-mint. The order is load-bearing: tag first (additive,
# reversible), profile last, and verify the box works on its new leg BEFORE
# calling it migrated — a box must never end up tagless or profileless.
#
# NOT 'set -e' around the per-box work: a box that fails one step is reported
# and skipped, not a crash that abandons the rest mid-migration.
set -u
GW_NEW=10.88.0.1
say() { printf 'migrate: %s\n' "$*"; }
warn() { printf 'migrate: WARNING: %s\n' "$*" >&2; }
die() { printf 'migrate: ERROR: %s\n' "$*" >&2; exit 1; }
mode=""
target=""
while [ $# -gt 0 ]; do
case "$1" in
--box) mode=box; target="${2:-}"; shift 2 || die "--box needs a name" ;;
--all-boxes) mode=all; shift ;;
--retire-legacy) mode=retire; shift ;;
-h|--help) sed -n '2,20p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) die "unknown argument: $1 (see --help)" ;;
esac
done
[ -n "$mode" ] || die "pick one: --box <name> | --all-boxes | --retire-legacy"
command -v incus >/dev/null || die "incus is not installed on this host"
# The new stack must exist before any box can be re-homed onto it. setup-host
# creates it; refuse rather than move a box onto a network that isn't there.
require_new_stack() {
incus network show boxnet >/dev/null 2>&1 || die "boxnet does not exist — run host/setup-host.sh first"
incus profile show box-net >/dev/null 2>&1 || die "box-net profile does not exist — run host/setup-host.sh first"
}
# A box is LEGACY only if it still carries the old tag and has NOT been
# re-homed. Counting every user.claudebox=1 box was a bug: re-homing ADDS
# user.box=1 without removing the old tag, so --retire-legacy saw its own
# migrated boxes as un-migrated and refused forever.
legacy_boxes() {
local b
for b in $(incus list "user.claudebox=1" -f csv -c n 2>/dev/null); do
[ "$(incus config get "$b" user.box 2>/dev/null)" = 1 ] && continue # already re-homed
echo "$b"
done
}
# Re-home one box. Legacy boxes are all claude boxes (the only template the old
# tool minted), so the new metadata is the claude template's.
rehome_one() {
local b="$1" st
incus config get "$b" user.claudebox >/dev/null 2>&1 || { warn "$b is not a legacy box (no user.claudebox tag) — skipping"; return 1; }
if [ "$(incus config get "$b" user.box 2>/dev/null)" = 1 ]; then
say "$b already carries user.box=1 — already re-homed, skipping"; return 0
fi
say "re-homing $b"
# 1. TAG FIRST — additive and reversible. A box that stops here is still a
# valid legacy box (the old tag is untouched) AND now a new one.
incus config set "$b" user.box=1 user.box.template=claude user.box.user=claude \
|| { warn "$b: could not set new metadata — left untouched"; return 1; }
# 2. Stop, reassign the profile (this is the network move), restart. Incus
# won't reassign a profile on a running instance's NIC cleanly, and the
# box needs a fresh DHCP lease on boxnet anyway.
st="$(incus list "$b" -f csv -c s 2>/dev/null | head -1)"
case "$st" in RUNNING|Running|running) incus stop "$b" >/dev/null 2>&1 || warn "$b: stop was not clean" ;; esac
incus profile assign "$b" box-net \
|| { warn "$b: profile assign failed — it still has user.box=1 but is on the OLD network; fix by hand"; return 1; }
incus start "$b" >/dev/null 2>&1 || { warn "$b: did not restart — start it by hand"; return 1; }
# 3. VERIFY THE EFFECT, not the exit codes (the whole repo's lesson). The box
# must be on 10.88 and actually resolve+reach the internet on its new leg
# before we call it migrated.
local i ip
ip=""
for i in $(seq 1 30); do
ip="$(incus exec "$b" -- ip -4 -o addr show scope global </dev/null 2>/dev/null \
| awk '{for(i=1;i<NF;i++) if($i=="inet" && $(i+1)~/^10\.88\./){split($(i+1),a,"/"); print a[1]; exit}}')"
[ -n "$ip" ] && break
sleep 2
done
[ -n "$ip" ] || { warn "$b: never got a 10.88 address after restart — re-home INCOMPLETE, inspect: incus console $b"; return 1; }
if incus exec "$b" -- getent hosts deb.debian.org </dev/null >/dev/null 2>&1; then
# LAST, and only once the move is VERIFIED: drop the legacy tag. Until this
# point the box wears both tags, so a failure anywhere above leaves it a
# valid box under one name or the other — never orphaned. Now it is simply
# a box, and --retire-legacy can see the old stack is empty.
incus config unset "$b" user.claudebox >/dev/null 2>&1 \
|| warn "$b: migrated, but the legacy tag could not be removed — retire-legacy will still see it"
say "$b re-homed: on boxnet ($ip), resolves + reachable, authed state preserved"
return 0
fi
warn "$b is on boxnet ($ip) but cannot resolve — check the new stack's resolver (box doctor)"
return 1
}
case "$mode" in
box)
[ -n "$target" ] || die "--box needs a name"
require_new_stack
rehome_one "$target"
;;
all)
require_new_stack
boxes="$(legacy_boxes)"
[ -n "$boxes" ] || { say "no legacy boxes to re-home"; exit 0; }
rc=0
for b in $boxes; do rehome_one "$b" || rc=1; done
[ "$rc" = 0 ] && say "all legacy boxes re-homed" || warn "some boxes need attention (above)"
exit "$rc"
;;
retire)
# Refuse while any legacy box still references the old stack — removing an
# in-use profile/network fails anyway, and a half-removed stack is worse
# than an intact one.
remaining="$(legacy_boxes)"
if [ -n "$remaining" ]; then
die "legacy boxes still exist: $(echo "$remaining" | tr '\n' ' ')
re-home them first (--all-boxes), or delete them, then retire."
fi
say "no legacy boxes remain — removing the legacy stack"
incus profile delete claude-dev >/dev/null 2>&1 && say "deleted profile claude-dev"
incus network delete claudenet >/dev/null 2>&1 && say "deleted network claudenet"
incus network acl delete claude-isolate >/dev/null 2>&1 && say "deleted ACL claude-isolate"
sudo systemctl disable --now claudebox-firewall.service >/dev/null 2>&1 && say "disabled claudebox-firewall.service"
sudo rm -f /etc/systemd/system/claudebox-firewall.service /usr/local/sbin/claudebox-firewall
sudo systemctl daemon-reload
sudo nft delete table inet claudebox >/dev/null 2>&1 && say "deleted nft table inet claudebox"
sudo nft delete table bridge claudebox >/dev/null 2>&1 && say "deleted nft table bridge claudebox"
# Assert the absence — don't trust the removals' exit codes.
left=""
incus network show claudenet >/dev/null 2>&1 && left="$left claudenet"
incus profile show claude-dev >/dev/null 2>&1 && left="$left claude-dev"
sudo nft list table bridge claudebox >/dev/null 2>&1 && left="$left nft-bridge"
[ -z "$left" ] && say "legacy stack retired — this host is now single-stack (box only)" \
|| die "legacy stack NOT fully removed:$left"
;;
esac

View file

@ -72,4 +72,4 @@ if $purge; then
fi
fi
echo "Teardown complete. (Your ~/.local/bin/box symlink and ~/.local/share/claudebox remain — remove by hand if wanted.)"
echo "Teardown complete. (Your ~/.local/bin/box symlink and ~/.local/share/box remain — remove by hand if wanted.)"

View file

@ -1,19 +1,21 @@
#!/usr/bin/env bash
set -euo pipefail
# claudebox installer — intended for: curl -fsSL .../install.sh | bash
# box installer — intended for: curl -fsSL .../install.sh | bash
#
# Downloads the claudebox repo tarball, installs the whole tree under
# $DEST, and puts a `box` symlink on PATH via $BINDIR.
# Downloads the box source tarball from its GitHub repo (heavy-duty/box),
# installs the whole tree under $DEST, and puts a `box` symlink on PATH via
# $BINDIR. (GitHub redirects the repo's pre-rename URLs, so an old install
# script keeps working; BOX_REPO overrides.)
REPO="${CLAUDEBOX_REPO:-heavy-duty/claudebox}"
REF="${CLAUDEBOX_REF:-main}"
DEST="${CLAUDEBOX_HOME:-$HOME/.local/share/claudebox}"
BINDIR="${CLAUDEBOX_BIN:-$HOME/.local/bin}"
REPO="${BOX_REPO:-heavy-duty/box}"
REF="${BOX_REF:-main}"
DEST="${BOX_HOME:-$HOME/.local/share/box}"
BINDIR="${BOX_BIN:-$HOME/.local/bin}"
log() { printf 'claudebox-install: %s\n' "$*"; }
warn() { printf 'claudebox-install: WARNING: %s\n' "$*" >&2; }
die() { printf 'claudebox-install: ERROR: %s\n' "$*" >&2; exit 1; }
log() { printf 'box-install: %s\n' "$*"; }
warn() { printf 'box-install: WARNING: %s\n' "$*" >&2; }
die() { printf 'box-install: ERROR: %s\n' "$*" >&2; exit 1; }
# --- prerequisites ---------------------------------------------------------
command -v curl >/dev/null 2>&1 || die "curl is required but was not found. Please install curl and re-run."
@ -26,18 +28,19 @@ trap cleanup EXIT
URL="https://github.com/$REPO/archive/refs/heads/$REF.tar.gz"
log "installing box (the claudebox repo) ($REPO@$REF)"
log "installing box from $REPO@$REF"
log "downloading $URL"
curl -fsSL "$URL" -o "$TMPDIR/claudebox.tar.gz" \
curl -fsSL "$URL" -o "$TMPDIR/box.tar.gz" \
|| die "failed to download $URL"
log "extracting archive"
tar -xzf "$TMPDIR/claudebox.tar.gz" -C "$TMPDIR" \
tar -xzf "$TMPDIR/box.tar.gz" -C "$TMPDIR" \
|| die "failed to extract archive"
# GitHub archives extract to a single top-level dir like claudebox-<ref>/
# GitHub names the archive's top dir after the REPO, which is still 'claudebox':
# it extracts to claudebox-<ref>/. That is repo-derived, not a stray brand.
EXTRACTED="$(find "$TMPDIR" -maxdepth 1 -type d -name 'claudebox-*' | head -n1)"
[ -n "$EXTRACTED" ] || die "could not find extracted claudebox-* directory in archive"
[ -n "$EXTRACTED" ] || die "could not find the extracted source directory in archive"
[ -f "$EXTRACTED/bin/box" ] || die "archive does not contain bin/box — is $REPO@$REF correct?"
# --- atomically replace $DEST ---------------------------------------------
@ -59,6 +62,13 @@ 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
# 0.5.0 moved the install tree from ~/.local/share/claudebox to ~/.local/share/box.
# Sweep the old tree so an upgrade does not leave a stale copy behind.
OLD_DEST="$HOME/.local/share/claudebox"
if [ -d "$OLD_DEST" ] && [ "$OLD_DEST" != "$DEST" ]; then
rm -rf "$OLD_DEST"
log "removed the old install tree at $OLD_DEST (it now lives at $DEST)"
fi
# --- PATH check ------------------------------------------------------------
case ":$PATH:" in
@ -76,4 +86,10 @@ if ! command -v incus >/dev/null 2>&1; then
warn " run the one-time host setup: $DEST/host/setup-host.sh"
fi
log "done — try: box new --name test"
# Record WHAT was installed, so a caller can assert it got what it asked for.
# Without this, an installer invoked with stale env vars (the CLAUDEBOX_* names
# retired in 0.5.0) silently falls back to the defaults and installs main —
# and the caller drills the wrong tree, believing it drilled its branch.
printf '%s@%s\n' "$REPO" "$REF" > "$DEST/INSTALLED_FROM"
log "done ($REPO@$REF) — try: box new --name test"

View file

@ -4,7 +4,7 @@
# 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_DESCRIPTION="Claude Code on Debian 13, creds-free — where this project started"
BOX_IMAGE="images:debian/13/cloud"
BOX_USER="claude"
BOX_CPU="4"

View file

@ -10,10 +10,10 @@ write_files:
permissions: '0644'
defer: true
content: |
# You are running inside a claudebox
# You are running inside a box (template: claude)
A claudebox is a trust-less, network-isolated, ephemeral VM created by the
`box` CLI (template: claude). Keep this context in mind:
A box is a trust-less, network-isolated, ephemeral VM created by the
`box` CLI. 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`
@ -25,7 +25,7 @@ write_files:
- **Disposable.** Nothing here is backed up. State is discarded when the box is
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
`.box/` folder (older repos may use `.claudebox/`), read it as your setup runbook — how to install
dependencies, start services, template environment files, seed data, and
smoke-test — and follow it. It is documentation for you, not a script the
host runs.
@ -49,7 +49,7 @@ runcmd:
- curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
- apt-get install -y nodejs
- sudo -u claude bash -lc 'curl -fsSL https://claude.ai/install.sh | bash'
# 'claudebox exec box -- claude …' runs through a NON-interactive shell, which
# 'box exec <box> -- claude …' runs through a NON-interactive shell, which
# reads no .bashrc/.zshrc — a PATH export there is invisible to it. The
# symlink makes claude reachable from every shell, interactive or not.
- ln -sf /home/claude/.local/bin/claude /usr/local/bin/claude

11
templates/codex/box.env Normal file
View file

@ -0,0 +1,11 @@
# The codex template — Debian 13 + OpenAI Codex CLI, 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.
BOX_DESCRIPTION="OpenAI Codex CLI on Debian 13, creds-free"
BOX_IMAGE="images:debian/13/cloud"
BOX_USER="codex"
BOX_CPU="4"
BOX_MEMORY="8GiB"
BOX_DISK="60GiB"

View file

@ -0,0 +1,60 @@
#cloud-config
users:
- name: codex
shell: /bin/bash
sudo: "ALL=(ALL) NOPASSWD:ALL"
lock_passwd: true
write_files:
- path: /home/codex/.codex/AGENTS.md
owner: "codex:codex"
permissions: '0644'
defer: true
content: |
# You are running inside a box (template: codex)
A box is a trust-less, network-isolated, ephemeral VM created by the
`box` CLI. Keep this context in mind:
- **Creds-free by default.** The box starts with no OpenAI and no git
credentials. If you need to authenticate Codex, the operator runs the
login flow (`codex`) interactively. For git, the operator adds their own
credentials (a PAT or `gh auth login`). Never assume credentials are
present; never ask for or store secrets on disk beyond what the operator
sets up.
- **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 `box snapshot`.
- **Bootstrap runbook.** If the repository you are working in contains a
`.box/` folder (older repos may use `.claudebox/`), read it as your setup runbook — how to install
dependencies, start services, template environment files, seed data, and
smoke-test — and follow it. It is documentation for you, not a script the
host runs.
package_update: true
packages:
- git
- gh
- curl
- ca-certificates
- gnupg
- ripgrep
- jq
- tmux
- age
- unzip
- build-essential
runcmd:
- curl -fsSL https://get.docker.com | sh
- usermod -aG docker codex
# Codex CLI is an npm global and needs Node 22+ (verified upstream:
# npmjs.com/package/@openai/codex — the SCOPED @openai/codex, not the
# unrelated 2012 'codex' package).
- curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
- apt-get install -y nodejs
- npm install -g @openai/codex
# 'box exec <b> -- codex …' runs a NON-interactive shell that reads no
# rc files — npm's global bin must be reachable from every shell. Symlink
# the installed binary into /usr/local/bin (the same fix the claude
# template needed for its own CLI).
- ln -sf "$(npm prefix -g)/bin/codex" /usr/local/bin/codex
- echo 'export PATH="$(npm prefix -g)/bin:$PATH"' >> /home/codex/.bashrc

11
templates/grok/box.env Normal file
View file

@ -0,0 +1,11 @@
# The grok template — Debian 13 + xAI's Grok Build CLI, 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.
BOX_DESCRIPTION="xAI Grok CLI on Debian 13, creds-free"
BOX_IMAGE="images:debian/13/cloud"
BOX_USER="grok"
BOX_CPU="4"
BOX_MEMORY="8GiB"
BOX_DISK="60GiB"

View file

@ -0,0 +1,75 @@
#cloud-config
users:
- name: grok
shell: /bin/bash
sudo: "ALL=(ALL) NOPASSWD:ALL"
lock_passwd: true
write_files:
- path: /home/grok/.grok/AGENTS.md
owner: "grok:grok"
permissions: '0644'
defer: true
content: |
# You are running inside a box (template: grok)
A box is a trust-less, network-isolated, ephemeral VM created by the
`box` CLI. Keep this context in mind:
- **Creds-free by default.** The box starts with no xAI and no git
credentials. If you need to authenticate, the operator runs
`grok login` interactively (SuperGrok / X Premium+). For git, the
operator adds their own credentials (a PAT or `gh auth login`). Never
assume credentials are present; never ask for or store secrets on disk
beyond what the operator sets up.
- **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 `box snapshot`.
- **Bootstrap runbook.** If the repository you are working in contains a
`.box/` folder (older repos may use `.claudebox/`), read it as your setup runbook — how to install
dependencies, start services, template environment files, seed data, and
smoke-test — and follow it. It is documentation for you, not a script the
host runs.
package_update: true
packages:
- git
- gh
- curl
- ca-certificates
- gnupg
- ripgrep
- jq
- tmux
- age
- unzip
- build-essential
runcmd:
- curl -fsSL https://get.docker.com | sh
- usermod -aG docker grok
# The OFFICIAL installer, read at https://x.ai/cli/install.sh rather than
# guessed at. What it actually does:
# · installs the CLI as `grok` (with an `agent` alias) — NOT `grok-build`
# · BIN_DIR defaults to $HOME/.grok/bin, and what it puts there is a
# SYMLINK into its versioned download dir (so `find -type f` misses it)
# · GROK_BIN_DIR overrides that directory
#
# Run it AS grok, not root: the binary symlink points into the invoking
# user's download dir, and root's home is 0700 — a symlink into it would be
# unreadable to the grok user, giving a CLI that exists and cannot run.
- sudo -u grok bash -lc 'curl -fsSL https://x.ai/cli/install.sh | bash'
# 'box exec <b> -- grok …' is a NON-interactive shell: it reads no rc files,
# so ~/.grok/bin is never on its PATH. Symlink onto the system PATH — the
# same fix the claude template needed (#15). Assert the result: a CLI that
# silently is not on PATH is what cost the last drill run.
- |
if [ -e /home/grok/.grok/bin/grok ]; then
ln -sf /home/grok/.grok/bin/grok /usr/local/bin/grok
echo "grok: linked /usr/local/bin/grok -> /home/grok/.grok/bin/grok"
/usr/local/bin/grok --version >/dev/null 2>&1 \
&& echo "grok: 'grok --version' answers from the system PATH" \
|| echo "grok: WARNING - linked, but 'grok --version' does not answer" >&2
else
echo "grok: installer produced no ~/.grok/bin/grok - upstream layout changed?" >&2
find /home/grok -maxdepth 4 \( -type f -o -type l \) -perm -u+x 2>/dev/null | head -20 >&2
fi
- echo 'export PATH="$HOME/.grok/bin:$PATH"' >> /home/grok/.bashrc