From fd600166c59b4e666b4189abdb3d1e19eaa3da03 Mon Sep 17 00:00:00 2001 From: claude-hdb <278054522+claude-hdb@users.noreply.github.com> Date: Fri, 17 Jul 2026 15:52:16 +0000 Subject: [PATCH 1/4] feat(new): BOX_REQUIRE_VM and BOX_AUTOSTART template keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two optional, server-class keys in the box.env allowlist. BOX_REQUIRE_VM=1 refuses both the silent container fallback (no /dev/kvm, exit 1) and an explicit --container (exit 2): such a template's trust boundary is the VM, and its guest runs docker. BOX_AUTOSTART=1 stamps boot.autostart=true at launch, per-instance like limits.*, so the box comes back deterministically after a host reboot; a --from clone needs no code because 'incus copy' keeps every non-volatile config key — the same ride the user.* stamps take. Still no key for a network or a security flag, on purpose. Co-Authored-By: Claude Opus 4.8 Co-Authored-By: Claude Fable 5 --- bin/box | 55 +++++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 41 insertions(+), 14 deletions(-) diff --git a/bin/box b/bin/box index 0e13c94..6fbf3bb 100755 --- a/bin/box +++ b/bin/box @@ -235,8 +235,10 @@ being told. --name Required. The box's name. --template Template to mint from; 'box templates' lists them. - A template sets image, user and resources — never - the network: every template gets the same isolation. + A template sets image, user, resources and boot + demands (a server-class one insists on VM mode and + autostart) — never the network: every template gets + the same isolation. --from [/] Clone src's live state, or its snapshot . --cpu CPUs for this mint (limits.cpu, verbatim to Incus). --memory RAM for this mint, e.g. 3GiB (limits.memory). @@ -246,6 +248,8 @@ being told. default wherever /dev/kvm exists; container mode (security.nesting=true) is the fallback for hosts without nested virt — weaker isolation, dev/test only. + A template that requires VM mode refuses both the + fallback and --container. Resources resolve most-specific-first: these flags, then BOX_CPU / BOX_MEMORY / BOX_DISK environment variables (the scripting form), then the @@ -261,11 +265,11 @@ EOF ;; templates) cat <<'EOF' List the templates this install can mint, with their descriptions. A template -is a directory under templates/: a box.env (image, user, resources — parsed -against an allowlist, never sourced) and a user-data.yaml (cloud-init, passed -to Incus verbatim). Templates cannot touch the network or security flags — -the shared box-net profile is the placement contract, so every template gets -the same isolation. +is a directory under templates/: a box.env (image, user, resources, boot +demands — parsed against an allowlist, never sourced) and a user-data.yaml +(cloud-init, passed to Incus verbatim). Templates cannot touch the network +or security flags — the shared box-net profile is the placement contract, so +every template gets the same isolation. box templates box new --name scratch --template blank @@ -824,18 +828,23 @@ reset_identity() { wait_agent "$i" } -# Templates set image, user, resources and cloud-init — NOTHING else. The -# box.env file is parsed against this allowlist, never sourced: sourcing would -# hand every template arbitrary bash execution on the HOST at mint time. And -# there is deliberately no key for a network or a security flag — the shared -# box-net profile is the placement contract, so no template can weaken -# isolation. 'blank' is a box with nobody home, not a box with the safety off. +# Templates set image, user, resources, boot demands and cloud-init — NOTHING +# else. The box.env file is parsed against this allowlist, never sourced: +# sourcing would hand every template arbitrary bash execution on the HOST at +# mint time. And there is deliberately no key for a network or a security flag +# — the shared box-net profile is the placement contract, so no template can +# weaken isolation. 'blank' is a box with nobody home, not a box with the +# safety off. The two boot demands are for server-class templates (#68): +# BOX_REQUIRE_VM=1 refuses the container fallback (the VM is the trust +# boundary, and a server-class guest runs docker), and BOX_AUTOSTART=1 stamps +# boot.autostart so the box survives a host reboot without an operator. load_template() { local t="$1" dir line key val dir="$root/templates/$t" [ -d "$dir" ] || die "no such template: $t (see 'box templates')" [ -f "$dir/box.env" ] || die "template '$t' has no box.env" T_DESC=""; T_IMAGE=""; T_USER=""; T_CPU=""; T_MEMORY=""; T_DISK="" + T_REQUIRE_VM=""; T_AUTOSTART="" while IFS= read -r line || [ -n "$line" ]; do case "$line" in ''|\#*) continue ;; esac case "$line" in @@ -857,7 +866,9 @@ load_template() { BOX_CPU) T_CPU="$val" ;; BOX_MEMORY) T_MEMORY="$val" ;; BOX_DISK) T_DISK="$val" ;; - *) die "template '$t': unknown key '$key' — a template sets image, user and resources, nothing else (there is no key for a network, on purpose)" ;; + BOX_REQUIRE_VM) T_REQUIRE_VM="$val" ;; + BOX_AUTOSTART) T_AUTOSTART="$val" ;; + *) die "template '$t': unknown key '$key' — a template sets image, user, resources and boot demands, nothing else (there is no key for a network, on purpose)" ;; esac done <"$dir/box.env" # Not 'A && B || die': if T_IMAGE is set but T_USER is not, that idiom still @@ -907,6 +918,17 @@ cmd_new() { local t="${template:-blank}" m extra=() load_template "$t" m="$(pick_mode)" + # A server-class template (BOX_REQUIRE_VM, #68) has no weaker mode: the VM + # is its trust boundary, and its guest runs docker. Refuse the container + # fallback AND an explicit --container — never silently mint something + # lesser than what the template promises. The message holds for both + # tiers: /dev/kvm is a fact about the HOST, and an admin's default-project + # mint and a restricted user's incus-user mint go through the same daemon, + # so the fix is the same for both — a KVM-capable host, not a grant. + if [ "$T_REQUIRE_VM" = 1 ] && [ "$m" != vm ]; then + [ "$mode" != container ] || usage_error "template '$t' requires VM mode — it will not mint as a container (drop --container)" + die "template '$t' requires VM mode and this host has no /dev/kvm — mint it on a KVM-capable host (or via --remote)" + fi # shellcheck disable=SC2054 # "root,size=..." is a single incus argument # 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 @@ -916,6 +938,11 @@ cmd_new() { # 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 + # BOX_AUTOSTART (#68): a server-class box must come back after a host + # reboot without an operator. Stamped per-instance like limits.*; a --from clone + # needs no code — 'incus copy' keeps every non-volatile config key, the + # same ride the user.* stamps take (audit B2). + [ "$T_AUTOSTART" != 1 ] || extra+=(--config boot.autostart=true) # Root size is a VM launch concern; a container's root rides the pool. Say # so instead of silently dropping an explicit --disk. [ "$m" = vm ] || [ -z "$disk" ] || echo "box: note — --disk applies to VM mode only; this container's root rides the pool" >&2 -- 2.45.2 From dd1166e1ed1cab7747693fdbf9fcad98de6bf3ac Mon Sep 17 00:00:00 2001 From: dan-claude-bot Date: Sat, 18 Jul 2026 14:53:24 +0000 Subject: [PATCH 2/4] =?UTF-8?q?test(cli):=20template=20suite=20=E2=80=94?= =?UTF-8?q?=20every=20templates/*/=20dir=20proven,=20dynamically=20(#68)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The old tmux check hardcoded blank/claude/codex/grok, so a new template could ship without CI ever reading it. The suite now discovers templates/*/ and, for each: drives the REAL load_template (extracted from bin/box, the same trick box_tier and install.sh's DEST block get) so box.env must parse against the actual allowlist with BOX_IMAGE + BOX_USER present; asserts user-data.yaml exists, declares #cloud-config, and is well-formed YAML (python3+pyyaml, skipped loudly where absent — CI has both); and keeps the #65 tmux contract. Fixtures prove the dies a green parse cannot: an unknown key (no template grows a network) and a missing required key. Staging-specific: both boot demands proven through the parser, docker + rig preinstalled, and a creds-free refusal grep — no tailscale/authkey/ssh in effective cloud-init lines; rig installs those inside the guest. Plus the cmd_new half, grepped the way the expose guard is: the REQUIRE_VM refusal orders after pick_mode, and boot.autostart is stamped only under the T_AUTOSTART guard. Co-Authored-By: Claude Fable 5 --- test/cli.sh | 105 +++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 100 insertions(+), 5 deletions(-) diff --git a/test/cli.sh b/test/cli.sh index 9c838df..e569549 100644 --- a/test/cli.sh +++ b/test/cli.sh @@ -130,15 +130,110 @@ check "install.sh: still no-ops on an existing install (#66)" 0 "" \ grep -qF 'already installed' "$ROOT/install.sh" # --------------------------------------------------------------------------- -# Templates — #65 tmux. `box tmux` runs `tmux new-session` INSIDE the box, so a -# template that never installs tmux fails with "tmux: command not found". Every -# template must carry it in its cloud-init package list. +# Templates — DYNAMIC over templates/*/ (#68): the loop discovers every +# template directory, so a new template cannot ship without passing these (the +# old hardcoded blank/claude/codex/grok list let exactly that happen). The +# box.env parse is proven against the REAL allowlist: load_template is +# extracted from bin/box and DRIVEN against each template — the same +# source-the-pure-function trick install.sh's DEST block and box_tier get +# below — so an unknown key, a missing BOX_IMAGE/BOX_USER, or a line that is +# not KEY="value" fails HERE, not at mint time on a host. # --------------------------------------------------------------------------- -for t in blank claude codex grok; do +TPLFN="$(mktemp)" +awk '/^load_template\(\) \{/,/^\}/' "$ROOT/bin/box" > "$TPLFN" +check "load_template: extracted from bin/box (guards the awk)" 0 "unknown key" cat "$TPLFN" +check "load_template: the extracted function is valid bash" 0 "" bash -n "$TPLFN" + +# tpl