diff --git a/README.md b/README.md index 821d798..4909cc7 100644 --- a/README.md +++ b/README.md @@ -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 [--template ] [--from [/]] [--vm|--container] [--remote r] +box new --name [--template ] [--from [/]] [--vm|--container] box templates # list the templates this install can mint box list # list your boxes -box info # one box: state, IP, snapshot labels +box info # one box: state, IP, exposures, snapshot labels box shell # enter as the template's user box exec -- # run a command in the box box tmux [session] # attach/create a tmux session — survives disconnects @@ -128,8 +165,14 @@ box rename # rename a box (stop it first) box down # stop (state kept; `start` resumes) box start # start a stopped box box rm [--force] # delete the box + its snapshots (asks first) +box expose [] | --list | --remove + # forward a box port to host loopback — see a dev server box incus -- # 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 | --all-boxes | --retire-legacy + # move a pre-0.4.0 host onto the box stack box status # deprecated alias for `list` box help [] # 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 diff --git a/VERSION b/VERSION index 1d0ba9e..8f0916f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.4.0 +0.5.0 diff --git a/bin/box b/bin/box index 5ce6ce7..b55fd68 100755 --- a/bin/box +++ b/bin/box @@ -50,8 +50,12 @@ CMDS=( "down^^box^Stop a box, keeping its state ('start' resumes it)^incus:stop^stopped {}" "start^^box^Start a stopped box^incus:start^started {}" "rm^ [--force]^box,confirm^Delete a box and its snapshots — irreversible, and it asks first^incus:delete -f^removed {}" + "expose^ [] | --list | --remove ^box^Forward a box port to the host's loopback — see a dev server^fn:cmd_expose^" "incus^ -- ^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 | --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^[]^^This help, or 'box help ' 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 [] # forward 127.0.0.1: → box: + box expose --list # what doors are open + box expose --remove # 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:, 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 # 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 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 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 "$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 # 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-), 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: to the box's :. 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 + if [ "$a2" = "--remove" ]; then + local port="$a3"; [ -n "$port" ] || usage_error "usage: box expose $box --remove " + 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 [] + 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" diff --git a/docs/claudebox-design.md b/docs/box-design.md similarity index 81% rename from docs/claudebox-design.md rename to docs/box-design.md index 0b0e925..efc41ad 100644 --- a/docs/claudebox-design.md +++ b/docs/box-design.md @@ -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 ` launches a blank box: everything installed, **no** +`box new --name ` 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 [label]` — checkpoint after login + clone. -- `claudebox new --name --from [/]` — clone an existing box +- `box snapshot [label]` — checkpoint after login + clone. +- `box new --name --from [/]` — clone an existing box or snapshot (authed state and all). Isolation is preserved: the clone keeps the `claude-dev` profile + `claudenet` + ACL. -- `claudebox restore ` — roll a box back to a checkpoint. +- `box restore ` — 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 -- ` — resolves and +**The escape hatch** — `box incus -- ` — 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 diff --git a/docs/claudebox-recipe.md b/docs/box-recipe.md similarity index 58% rename from docs/claudebox-recipe.md rename to docs/box-recipe.md index e79ee6a..6d4f345 100644 --- a/docs/claudebox-recipe.md +++ b/docs/box-recipe.md @@ -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 && cd -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`. diff --git a/drill/README.md b/drill/README.md index f5e06c6..cdb41e9 100644 --- a/drill/README.md +++ b/drill/README.md @@ -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 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 `). **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. diff --git a/drill/doctor.sh b/drill/doctor.sh index 7a1974d..df91ebc 100755 --- a/drill/doctor.sh +++ b/drill/doctor.sh @@ -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:-} — 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)" diff --git a/drill/drill.sh b/drill/drill.sh index 7a8c13e..77c8be5 100755 --- a/drill/drill.sh +++ b/drill/drill.sh @@ -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 '')" + 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 && 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 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)" + # 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 "$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 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 ] diff --git a/host/box-firewall.sh b/host/box-firewall.sh index 9bb2ca7..edd8374 100644 --- a/host/box-firewall.sh +++ b/host/box-firewall.sh @@ -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:` 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 diff --git a/host/migrate-host.sh b/host/migrate-host.sh new file mode 100644 index 0000000..688e920 --- /dev/null +++ b/host/migrate-host.sh @@ -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 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 | --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 \ + | awk '{for(i=1;i/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 diff --git a/host/teardown-host.sh b/host/teardown-host.sh index 091b94e..af6266f 100755 --- a/host/teardown-host.sh +++ b/host/teardown-host.sh @@ -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.)" diff --git a/install.sh b/install.sh index 9bf307c..04d33f4 100755 --- a/install.sh +++ b/install.sh @@ -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-/ +# GitHub names the archive's top dir after the REPO, which is still 'claudebox': +# it extracts to claudebox-/. 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" diff --git a/templates/claude/box.env b/templates/claude/box.env index 67d9697..3cbfdd4 100644 --- a/templates/claude/box.env +++ b/templates/claude/box.env @@ -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" diff --git a/templates/claude/user-data.yaml b/templates/claude/user-data.yaml index 737294a..970d898 100644 --- a/templates/claude/user-data.yaml +++ b/templates/claude/user-data.yaml @@ -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 -- 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 diff --git a/templates/codex/box.env b/templates/codex/box.env new file mode 100644 index 0000000..08d5e58 --- /dev/null +++ b/templates/codex/box.env @@ -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" diff --git a/templates/codex/user-data.yaml b/templates/codex/user-data.yaml new file mode 100644 index 0000000..550e28a --- /dev/null +++ b/templates/codex/user-data.yaml @@ -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 -- 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 diff --git a/templates/grok/box.env b/templates/grok/box.env new file mode 100644 index 0000000..52912d3 --- /dev/null +++ b/templates/grok/box.env @@ -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" diff --git a/templates/grok/user-data.yaml b/templates/grok/user-data.yaml new file mode 100644 index 0000000..26dad6f --- /dev/null +++ b/templates/grok/user-data.yaml @@ -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 -- 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