2026-07-18 00:01:15 +00:00
|
|
|
|
#!/usr/bin/env bash
|
|
|
|
|
|
# Dependency-free CLI assertions for box. Run: bash test/cli.sh
|
|
|
|
|
|
#
|
|
|
|
|
|
# Runnable by a NON-root user with NO Incus installed — that is the whole point.
|
|
|
|
|
|
# Anything that needs a real incus daemon (every lifecycle command) is proven the
|
|
|
|
|
|
# way rig proves its root-only paths: source the pure function and drive it against
|
|
|
|
|
|
# a fixture, or grep the load-bearing line so a deleted guard cannot ship green.
|
|
|
|
|
|
# Deliberately no `set -e` — the harness asserts on failing commands.
|
|
|
|
|
|
set -u
|
|
|
|
|
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
|
|
|
|
PASS=0 FAIL=0
|
|
|
|
|
|
|
|
|
|
|
|
# check <desc> <want_exit> <want_substr> <cmd...>
|
|
|
|
|
|
# Runs cmd, asserts exit code and (if non-empty) that combined output
|
|
|
|
|
|
# contains want_substr.
|
|
|
|
|
|
check() {
|
|
|
|
|
|
local desc="$1" want="$2" substr="$3"; shift 3
|
|
|
|
|
|
local out rc
|
|
|
|
|
|
out="$("$@" 2>&1)"; rc=$?
|
|
|
|
|
|
if [ "$rc" -ne "$want" ]; then
|
|
|
|
|
|
echo "FAIL: $desc — exit $rc, wanted $want"
|
|
|
|
|
|
printf '%s\n' "$out" | sed 's/^/ /'
|
|
|
|
|
|
FAIL=$((FAIL + 1)); return
|
|
|
|
|
|
fi
|
|
|
|
|
|
if [ -n "$substr" ] && ! printf '%s' "$out" | grep -qF -e "$substr"; then
|
|
|
|
|
|
echo "FAIL: $desc — output missing '$substr'"
|
|
|
|
|
|
printf '%s\n' "$out" | sed 's/^/ /'
|
|
|
|
|
|
FAIL=$((FAIL + 1)); return
|
|
|
|
|
|
fi
|
|
|
|
|
|
echo "ok: $desc"; PASS=$((PASS + 1))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
BOX="$ROOT/bin/box"
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# The CLI contract: dispatch, help, usage errors. No incus needed — these all
|
|
|
|
|
|
# resolve before any daemon call. Exit codes are box's own (0 ok / 1 wrong /
|
|
|
|
|
|
# 2 you-asked-wrong), read straight from bin/box and confirmed by running it.
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# box with no args is 'help' (cmd="${1:-help}"), which prints the general usage
|
|
|
|
|
|
# and exits 0 — NOT rig's exit-2 bare-usage. Assert box's actual contract.
|
|
|
|
|
|
check "no args → general help, exit 0" 0 "USAGE" "$BOX"
|
|
|
|
|
|
check "no args help names the command form" 0 "box <command>" "$BOX"
|
|
|
|
|
|
check "--help exits 0" 0 "USAGE" "$BOX" --help
|
|
|
|
|
|
check "-h exits 0" 0 "USAGE" "$BOX" -h
|
|
|
|
|
|
check "help exits 0" 0 "USAGE" "$BOX" help
|
|
|
|
|
|
check "help <command> → that command's usage" 0 "usage: box new" "$BOX" help new
|
|
|
|
|
|
check "--version exits 0" 0 "box" "$BOX" --version
|
|
|
|
|
|
# Unknown command is a usage error (2), and it says so — the suggester may add a
|
|
|
|
|
|
# 'did you mean', but the stem is stable.
|
|
|
|
|
|
check "unknown command exits 2" 2 "unknown command" "$BOX" frobnicate
|
|
|
|
|
|
check "unknown command points at help" 2 "box help" "$BOX" zzzzzz
|
|
|
|
|
|
# Options before the command are the classic mistake; box names the fix.
|
|
|
|
|
|
check "option before command exits 2" 2 "options come after the command" "$BOX" --json list
|
|
|
|
|
|
# A missing required positional is a usage error carrying that command's synopsis.
|
|
|
|
|
|
check "new without --name exits 2" 2 "usage: box new" "$BOX" new
|
|
|
|
|
|
check "shell without a box exits 2" 2 "usage: box shell" "$BOX" shell
|
|
|
|
|
|
check "restore without arg2 needs a box first" 2 "usage: box restore" "$BOX" restore
|
|
|
|
|
|
# An unknown flag is refused, not swallowed as a positional (the --labl bug).
|
|
|
|
|
|
check "unknown flag on list exits 2" 2 "unknown option" "$BOX" list --nope
|
|
|
|
|
|
# A flag that needs a value and gets none.
|
|
|
|
|
|
check "--name with no value exits 2" 2 "--name needs a value" "$BOX" new --name
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# A shim `id` on PATH: lets us drive install.sh's DEST branch with a canned uid +
|
|
|
|
|
|
# group output, exactly the way rig drives assert_runner_repo against fixtures.
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
SHIMDIR="$(mktemp -d)"
|
|
|
|
|
|
cat > "$SHIMDIR/id" <<'SHIM'
|
|
|
|
|
|
#!/usr/bin/env bash
|
|
|
|
|
|
# Fake `id`: -u prints $FAKE_UID, -nG prints $FAKE_GROUPS. Just enough for
|
|
|
|
|
|
# install.sh's DEST branch, which only ever asks these two.
|
|
|
|
|
|
case "${1:-}" in
|
|
|
|
|
|
-u) printf '%s\n' "${FAKE_UID:-1000}" ;;
|
|
|
|
|
|
-nG) printf '%s\n' "${FAKE_GROUPS:-}" ;;
|
|
|
|
|
|
*) exit 0 ;;
|
|
|
|
|
|
esac
|
|
|
|
|
|
SHIM
|
|
|
|
|
|
chmod +x "$SHIMDIR/id"
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# install.sh — #71 global/root install. bash -n first, then drive the actual
|
|
|
|
|
|
# DEST/BINDIR branch with the shim id (the functional proof the contract asks
|
|
|
|
|
|
# for), then grep the root-only pieces that a daemon-free run cannot exercise.
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
check "install.sh is valid bash" 0 "" bash -n "$ROOT/install.sh"
|
|
|
|
|
|
# Extract EXACTLY the DEST/BINDIR if/else/fi (the first `id -u -eq 0` block) and
|
|
|
|
|
|
# print what it resolved — the same "run the pure block in isolation" trick rig
|
|
|
|
|
|
# uses for its embedded dump script. Fail closed: a mangled extraction is caught
|
|
|
|
|
|
# by the /opt/box grep below before any resolution is trusted.
|
|
|
|
|
|
DBLOCK="$(mktemp)"
|
|
|
|
|
|
awk '/id -u.*-eq 0/{f=1} f{print} f&&/^fi$/{exit}' "$ROOT/install.sh" > "$DBLOCK"
|
|
|
|
|
|
# The $DEST/$BINDIR here are LITERAL text appended into the extracted block — they
|
|
|
|
|
|
# must expand when that block RUNS, not when this printf writes it. Hence single
|
|
|
|
|
|
# quotes; SC2016 is the intent.
|
|
|
|
|
|
# shellcheck disable=SC2016
|
|
|
|
|
|
printf '\nprintf "DEST=%%s BINDIR=%%s\\n" "$DEST" "$BINDIR"\n' >> "$DBLOCK"
|
|
|
|
|
|
check "install.sh: DEST block extracted (guards the awk)" 0 "/opt/box" cat "$DBLOCK"
|
|
|
|
|
|
check "install.sh: the extracted DEST block is valid bash" 0 "" bash -n "$DBLOCK"
|
|
|
|
|
|
|
|
|
|
|
|
dest() { # dest <uid> [extra env assignments...] — resolve DEST/BINDIR
|
|
|
|
|
|
local uid="$1"; shift
|
|
|
|
|
|
FAKE_UID="$uid" HOME=/home/tester PATH="$SHIMDIR:$PATH" env "$@" bash "$DBLOCK"
|
|
|
|
|
|
}
|
|
|
|
|
|
# Root: the global path — a system tree other users can read (#71).
|
|
|
|
|
|
check "install.sh: root → DEST=/opt/box" 0 "DEST=/opt/box" dest 0
|
|
|
|
|
|
check "install.sh: root → BINDIR=/usr/local/bin" 0 "BINDIR=/usr/local/bin" dest 0
|
|
|
|
|
|
# Non-root: unchanged, the solo path.
|
|
|
|
|
|
check "install.sh: non-root → DEST=\$HOME/.local" 0 "DEST=/home/tester/.local/share/box" dest 1000
|
|
|
|
|
|
check "install.sh: non-root → BINDIR=\$HOME/.local" 0 "BINDIR=/home/tester/.local/bin" dest 1000
|
|
|
|
|
|
# BOX_HOME / BOX_BIN still win on BOTH branches — the scripting override.
|
|
|
|
|
|
check "install.sh: BOX_HOME overrides the root default" 0 "DEST=/srv/box" dest 0 BOX_HOME=/srv/box
|
|
|
|
|
|
check "install.sh: BOX_BIN overrides the root default" 0 "BINDIR=/srv/bin" dest 0 BOX_BIN=/srv/bin
|
|
|
|
|
|
check "install.sh: BOX_HOME overrides the non-root default" 0 "DEST=/srv/box" dest 1000 BOX_HOME=/srv/box
|
|
|
|
|
|
rm -f "$DBLOCK"
|
|
|
|
|
|
# The root-only world-readable chmod (#71): the tree is EXECUTED by other users,
|
|
|
|
|
|
# so root must open read+traverse. Grep it, and that it is root-guarded so the
|
|
|
|
|
|
# per-user install stays byte-identical to before.
|
|
|
|
|
|
# $DEST is a LITERAL in the grep pattern (install.sh's own variable) — single
|
|
|
|
|
|
# quotes intended.
|
|
|
|
|
|
# shellcheck disable=SC2016
|
|
|
|
|
|
check "install.sh: root makes the tree world-readable (a+rX)" 0 "" \
|
|
|
|
|
|
grep -qF 'chmod -R a+rX "$DEST"' "$ROOT/install.sh"
|
|
|
|
|
|
check "install.sh: the a+rX is root-guarded" 0 "" \
|
|
|
|
|
|
bash -c 'grep -B2 "chmod -R a+rX" "'"$ROOT"'/install.sh" | grep -q "id -u.*-eq 0"'
|
|
|
|
|
|
# #66's flow, preserved: confirm-before-download, and no-op if already installed.
|
|
|
|
|
|
check "install.sh: still confirms before downloading (#66)" 0 "" \
|
|
|
|
|
|
grep -qF 'confirm "Install box from' "$ROOT/install.sh"
|
|
|
|
|
|
check "install.sh: still no-ops on an existing install (#66)" 0 "" \
|
|
|
|
|
|
grep -qF 'already installed' "$ROOT/install.sh"
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
test(cli): template suite — every templates/*/ dir proven, dynamically (#68)
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 <noreply@anthropic.com>
2026-07-18 14:53:24 +00:00
|
|
|
|
# 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.
|
2026-07-18 00:01:15 +00:00
|
|
|
|
# ---------------------------------------------------------------------------
|
test(cli): template suite — every templates/*/ dir proven, dynamically (#68)
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 <noreply@anthropic.com>
2026-07-18 14:53:24 +00:00
|
|
|
|
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 <root> <template> — run the real parser against <root>/templates/, print
|
|
|
|
|
|
# what it resolved. $0 carries the extracted-function file into the subshell.
|
|
|
|
|
|
tpl() {
|
|
|
|
|
|
root="$1" bash -c '
|
|
|
|
|
|
die() { echo "box: $*" >&2; exit 1; }
|
|
|
|
|
|
. "$0"; load_template "$1"
|
test(cli): the seed contract, driven — and the absence, fail-closed (#81)
- render_userdata extracted and DRIVEN: defaults pin heavy-duty/rig@main on
both the installer URL and the installer's env; RIG_REPO/RIG_REF override
at mint; a shell-shaped repo, a spaced ref and a newline-smuggled repo all
die on the host (bash =~ anchors the whole string — one clean line cannot
sneak past it the way a line-oriented grep -q lets it).
- BOX_BOOTSTRAP_ROLE through the real parser: round-trips, and a
shell-shaped value dies at the gate.
- Per-template sweep grows the #81 contract: BOX_USER matches the user the
cloud-init actually creates; a role-bearing seed installs rig carrying
BOTH pin tokens; and absence greps over EFFECTIVE cloud-init lines
(comments may name what they refuse — #69's idiom) prove no agent CLI, no
docker, no tailscale/authkey/ssh, no write_files heredocs, in ANY
template. Want-exit 1: re-adding tenant content goes red.
- cmd_new pinned: user-data reaches Incus through render_userdata; the
auto-run orders after the cloud-init wait and sits under the
T_BOOTSTRAP_ROLE guard; the failure path names the re-run; the workload
join is printed, never exec'd; no template names a creds-holding role.
- staging's boot demands pinned to the file; blank proven roleless and
rig-less.
283 passed, 0 failed (main: 213); shellcheck -x clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 20:03:55 +00:00
|
|
|
|
printf "IMAGE=%s USER=%s REQUIRE_VM=%s AUTOSTART=%s ROLE=%s\n" \
|
|
|
|
|
|
"$T_IMAGE" "$T_USER" "$T_REQUIRE_VM" "$T_AUTOSTART" "$T_BOOTSTRAP_ROLE"
|
test(cli): template suite — every templates/*/ dir proven, dynamically (#68)
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 <noreply@anthropic.com>
2026-07-18 14:53:24 +00:00
|
|
|
|
' "$TPLFN" "$2"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
# The allowlist itself is load-bearing: a template must not be able to grow a
|
|
|
|
|
|
# network key, and the required keys must still be required. Fixture-driven,
|
|
|
|
|
|
# against a throwaway root — exactly the dies a green parse cannot prove.
|
|
|
|
|
|
EVILROOT="$(mktemp -d)"; mkdir -p "$EVILROOT/templates/evil"
|
|
|
|
|
|
printf 'BOX_IMAGE="images:debian/13/cloud"\nBOX_USER="dev"\nBOX_NETWORK="lan"\n' \
|
|
|
|
|
|
> "$EVILROOT/templates/evil/box.env"
|
|
|
|
|
|
check "load_template: an unknown key dies (no template grows a network)" 1 "unknown key" \
|
|
|
|
|
|
tpl "$EVILROOT" evil
|
|
|
|
|
|
printf 'BOX_USER="dev"\n' > "$EVILROOT/templates/evil/box.env"
|
|
|
|
|
|
check "load_template: a missing BOX_IMAGE dies" 1 "required" tpl "$EVILROOT" evil
|
test(cli): the seed contract, driven — and the absence, fail-closed (#81)
- render_userdata extracted and DRIVEN: defaults pin heavy-duty/rig@main on
both the installer URL and the installer's env; RIG_REPO/RIG_REF override
at mint; a shell-shaped repo, a spaced ref and a newline-smuggled repo all
die on the host (bash =~ anchors the whole string — one clean line cannot
sneak past it the way a line-oriented grep -q lets it).
- BOX_BOOTSTRAP_ROLE through the real parser: round-trips, and a
shell-shaped value dies at the gate.
- Per-template sweep grows the #81 contract: BOX_USER matches the user the
cloud-init actually creates; a role-bearing seed installs rig carrying
BOTH pin tokens; and absence greps over EFFECTIVE cloud-init lines
(comments may name what they refuse — #69's idiom) prove no agent CLI, no
docker, no tailscale/authkey/ssh, no write_files heredocs, in ANY
template. Want-exit 1: re-adding tenant content goes red.
- cmd_new pinned: user-data reaches Incus through render_userdata; the
auto-run orders after the cloud-init wait and sits under the
T_BOOTSTRAP_ROLE guard; the failure path names the re-run; the workload
join is printed, never exec'd; no template names a creds-holding role.
- staging's boot demands pinned to the file; blank proven roleless and
rig-less.
283 passed, 0 failed (main: 213); shellcheck -x clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 20:03:55 +00:00
|
|
|
|
# The boot demands' green path, kept as a fixture even now that staging sets
|
|
|
|
|
|
# them in-tree: fixtures survive a template rename, and a deleted case arm
|
|
|
|
|
|
# must fail HERE, through the real parser, not at first use on a host.
|
2026-07-18 18:05:49 +00:00
|
|
|
|
mkdir -p "$EVILROOT/templates/server"
|
|
|
|
|
|
printf 'BOX_IMAGE="images:debian/13/cloud"\nBOX_USER="ops"\nBOX_REQUIRE_VM="1"\nBOX_AUTOSTART="1"\n' \
|
|
|
|
|
|
> "$EVILROOT/templates/server/box.env"
|
|
|
|
|
|
check "load_template: REQUIRE_VM and AUTOSTART round-trip (accepted + surfaced)" \
|
|
|
|
|
|
0 "REQUIRE_VM=1 AUTOSTART=1" tpl "$EVILROOT" server
|
test(cli): the seed contract, driven — and the absence, fail-closed (#81)
- render_userdata extracted and DRIVEN: defaults pin heavy-duty/rig@main on
both the installer URL and the installer's env; RIG_REPO/RIG_REF override
at mint; a shell-shaped repo, a spaced ref and a newline-smuggled repo all
die on the host (bash =~ anchors the whole string — one clean line cannot
sneak past it the way a line-oriented grep -q lets it).
- BOX_BOOTSTRAP_ROLE through the real parser: round-trips, and a
shell-shaped value dies at the gate.
- Per-template sweep grows the #81 contract: BOX_USER matches the user the
cloud-init actually creates; a role-bearing seed installs rig carrying
BOTH pin tokens; and absence greps over EFFECTIVE cloud-init lines
(comments may name what they refuse — #69's idiom) prove no agent CLI, no
docker, no tailscale/authkey/ssh, no write_files heredocs, in ANY
template. Want-exit 1: re-adding tenant content goes red.
- cmd_new pinned: user-data reaches Incus through render_userdata; the
auto-run orders after the cloud-init wait and sits under the
T_BOOTSTRAP_ROLE guard; the failure path names the re-run; the workload
join is printed, never exec'd; no template names a creds-holding role.
- staging's boot demands pinned to the file; blank proven roleless and
rig-less.
283 passed, 0 failed (main: 213); shellcheck -x clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 20:03:55 +00:00
|
|
|
|
# BOX_BOOTSTRAP_ROLE (#81): accepted and surfaced through the real parser —
|
|
|
|
|
|
# and the value is a rig role NAME, nothing more. It is handed to
|
|
|
|
|
|
# 'incus exec … rig bootstrap <role>' at mint, so anything shell-shaped in
|
|
|
|
|
|
# it must die at parse time, on the host, before a guest exists.
|
|
|
|
|
|
mkdir -p "$EVILROOT/templates/tenant"
|
|
|
|
|
|
printf 'BOX_IMAGE="images:debian/13/cloud"\nBOX_USER="claude"\nBOX_BOOTSTRAP_ROLE="claude"\n' \
|
|
|
|
|
|
> "$EVILROOT/templates/tenant/box.env"
|
|
|
|
|
|
check "load_template: BOX_BOOTSTRAP_ROLE round-trips (accepted + surfaced)" \
|
|
|
|
|
|
0 "ROLE=claude" tpl "$EVILROOT" tenant
|
|
|
|
|
|
printf 'BOX_IMAGE="images:debian/13/cloud"\nBOX_USER="claude"\nBOX_BOOTSTRAP_ROLE="claude; rm -rf /"\n' \
|
|
|
|
|
|
> "$EVILROOT/templates/tenant/box.env"
|
|
|
|
|
|
check "load_template: a shell-shaped BOX_BOOTSTRAP_ROLE dies at the gate" \
|
|
|
|
|
|
1 "not a sane role name" tpl "$EVILROOT" tenant
|
test(cli): template suite — every templates/*/ dir proven, dynamically (#68)
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 <noreply@anthropic.com>
2026-07-18 14:53:24 +00:00
|
|
|
|
rm -rf "$EVILROOT"
|
|
|
|
|
|
|
test(cli): the seed contract, driven — and the absence, fail-closed (#81)
- render_userdata extracted and DRIVEN: defaults pin heavy-duty/rig@main on
both the installer URL and the installer's env; RIG_REPO/RIG_REF override
at mint; a shell-shaped repo, a spaced ref and a newline-smuggled repo all
die on the host (bash =~ anchors the whole string — one clean line cannot
sneak past it the way a line-oriented grep -q lets it).
- BOX_BOOTSTRAP_ROLE through the real parser: round-trips, and a
shell-shaped value dies at the gate.
- Per-template sweep grows the #81 contract: BOX_USER matches the user the
cloud-init actually creates; a role-bearing seed installs rig carrying
BOTH pin tokens; and absence greps over EFFECTIVE cloud-init lines
(comments may name what they refuse — #69's idiom) prove no agent CLI, no
docker, no tailscale/authkey/ssh, no write_files heredocs, in ANY
template. Want-exit 1: re-adding tenant content goes red.
- cmd_new pinned: user-data reaches Incus through render_userdata; the
auto-run orders after the cloud-init wait and sits under the
T_BOOTSTRAP_ROLE guard; the failure path names the re-run; the workload
join is printed, never exec'd; no template names a creds-holding role.
- staging's boot demands pinned to the file; blank proven roleless and
rig-less.
283 passed, 0 failed (main: 213); shellcheck -x clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 20:03:55 +00:00
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# render_userdata (#81) — the seed's ONE substitution, driven for real: the
|
|
|
|
|
|
# rig pin point. Defaults resolve to heavy-duty/rig@main; RIG_REPO/RIG_REF
|
|
|
|
|
|
# override at mint (how a rig branch under review reaches a guest); and a
|
|
|
|
|
|
# hostile value — the tokens land inside a runcmd shell line — dies on the
|
|
|
|
|
|
# host before touching the YAML. bash's =~ anchors the WHOLE string, so a
|
|
|
|
|
|
# multi-line value cannot sneak one clean line past it (the line-oriented
|
|
|
|
|
|
# grep -q failure mode).
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
RUFN="$(mktemp)"
|
|
|
|
|
|
awk '/^render_userdata\(\) \{/,/^\}/' "$ROOT/bin/box" > "$RUFN"
|
|
|
|
|
|
check "render_userdata: extracted from bin/box (guards the awk)" 0 "RIG_REPO" cat "$RUFN"
|
|
|
|
|
|
check "render_userdata: the extracted function is valid bash" 0 "" bash -n "$RUFN"
|
|
|
|
|
|
|
|
|
|
|
|
SEED="$(mktemp)"
|
|
|
|
|
|
printf '#cloud-config\nruncmd:\n - curl -fsSL https://raw.githubusercontent.com/@RIG_REPO@/@RIG_REF@/install.sh | RIG_REPO="@RIG_REPO@" RIG_REF="@RIG_REF@" bash\n' > "$SEED"
|
|
|
|
|
|
# shellcheck disable=SC2016 # $0/$1 expand in the child shell, by design
|
|
|
|
|
|
rud() { # rud [VAR=val ...] — render the fixture seed through the real function
|
|
|
|
|
|
env "$@" bash -c 'die() { echo "box: $*" >&2; exit 1; }; . "$0"; render_userdata "$1"' "$RUFN" "$SEED"
|
|
|
|
|
|
}
|
|
|
|
|
|
check "render_userdata: defaults pin heavy-duty/rig" 0 "githubusercontent.com/heavy-duty/rig/main/install.sh" rud
|
|
|
|
|
|
check "render_userdata: defaults feed the installer's own env too" 0 'RIG_REPO="heavy-duty/rig" RIG_REF="main"' rud
|
|
|
|
|
|
check "render_userdata: RIG_REPO/RIG_REF override at mint" 0 "dan-claude-bot/rig/feat/bootstrap-roles/install.sh" \
|
|
|
|
|
|
rud RIG_REPO=dan-claude-bot/rig RIG_REF=feat/bootstrap-roles
|
|
|
|
|
|
# shellcheck disable=SC2016 # $0/$1 expand in the child shells, by design
|
|
|
|
|
|
check "render_userdata: no token survives the render" 1 "" \
|
|
|
|
|
|
bash -c 'env bash -c "die() { echo box: \$*; exit 1; }; . \"\$0\"; render_userdata \"\$1\"" "$1" "$2" | grep -q @RIG_' _ "$RUFN" "$SEED"
|
|
|
|
|
|
check "render_userdata: a shell-shaped RIG_REPO dies on the host" 1 "RIG_REPO" \
|
|
|
|
|
|
rud 'RIG_REPO=evil"; rm -rf /; "/rig'
|
|
|
|
|
|
check "render_userdata: a spaced RIG_REF dies on the host" 1 "RIG_REF" \
|
|
|
|
|
|
rud 'RIG_REF=main plus junk'
|
|
|
|
|
|
check "render_userdata: a newline-smuggled RIG_REPO dies (whole-string anchor)" 1 "RIG_REPO" \
|
|
|
|
|
|
rud "RIG_REPO=$(printf 'a/b\nevil')"
|
|
|
|
|
|
rm -f "$RUFN" "$SEED"
|
|
|
|
|
|
|
test(cli): template suite — every templates/*/ dir proven, dynamically (#68)
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 <noreply@anthropic.com>
2026-07-18 14:53:24 +00:00
|
|
|
|
# YAML well-formedness needs python3 + pyyaml; the CI runner has both. Skip
|
|
|
|
|
|
# gracefully (never silently) where they are missing.
|
|
|
|
|
|
HAVE_YAML=0
|
|
|
|
|
|
command -v python3 >/dev/null 2>&1 && python3 -c 'import yaml' 2>/dev/null && HAVE_YAML=1
|
|
|
|
|
|
|
|
|
|
|
|
for d in "$ROOT"/templates/*/; do
|
|
|
|
|
|
t="$(basename "$d")"
|
|
|
|
|
|
# The parse itself asserts the allowlist AND the required keys (the driven
|
|
|
|
|
|
# function dies without BOX_IMAGE/BOX_USER); the greps pin both keys to the
|
|
|
|
|
|
# FILE, so neither can quietly become an inherited default.
|
|
|
|
|
|
check "template '$t': box.env parses against the real allowlist" 0 "USER=" tpl "$ROOT" "$t"
|
|
|
|
|
|
check "template '$t': box.env sets BOX_IMAGE" 0 "" grep -q '^BOX_IMAGE=' "$d/box.env"
|
|
|
|
|
|
check "template '$t': box.env sets BOX_USER" 0 "" grep -q '^BOX_USER=' "$d/box.env"
|
test(cli): the seed contract, driven — and the absence, fail-closed (#81)
- render_userdata extracted and DRIVEN: defaults pin heavy-duty/rig@main on
both the installer URL and the installer's env; RIG_REPO/RIG_REF override
at mint; a shell-shaped repo, a spaced ref and a newline-smuggled repo all
die on the host (bash =~ anchors the whole string — one clean line cannot
sneak past it the way a line-oriented grep -q lets it).
- BOX_BOOTSTRAP_ROLE through the real parser: round-trips, and a
shell-shaped value dies at the gate.
- Per-template sweep grows the #81 contract: BOX_USER matches the user the
cloud-init actually creates; a role-bearing seed installs rig carrying
BOTH pin tokens; and absence greps over EFFECTIVE cloud-init lines
(comments may name what they refuse — #69's idiom) prove no agent CLI, no
docker, no tailscale/authkey/ssh, no write_files heredocs, in ANY
template. Want-exit 1: re-adding tenant content goes red.
- cmd_new pinned: user-data reaches Incus through render_userdata; the
auto-run orders after the cloud-init wait and sits under the
T_BOOTSTRAP_ROLE guard; the failure path names the re-run; the workload
join is printed, never exec'd; no template names a creds-holding role.
- staging's boot demands pinned to the file; blank proven roleless and
rig-less.
283 passed, 0 failed (main: 213); shellcheck -x clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 20:03:55 +00:00
|
|
|
|
# cloud-init is passed to Incus verbatim (modulo the two rig pin tokens),
|
|
|
|
|
|
# so it must exist, declare itself, and be well-formed — a mint is far too
|
|
|
|
|
|
# late to learn about a typo.
|
test(cli): template suite — every templates/*/ dir proven, dynamically (#68)
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 <noreply@anthropic.com>
2026-07-18 14:53:24 +00:00
|
|
|
|
check "template '$t': user-data.yaml exists" 0 "" test -f "$d/user-data.yaml"
|
|
|
|
|
|
# shellcheck disable=SC2016 # $1 expands in the child shell, by design
|
|
|
|
|
|
check "template '$t': user-data.yaml begins with #cloud-config" 0 "" \
|
|
|
|
|
|
bash -c 'head -1 "$1" | grep -qx "#cloud-config"' _ "$d/user-data.yaml"
|
|
|
|
|
|
if [ "$HAVE_YAML" = 1 ]; then
|
|
|
|
|
|
check "template '$t': user-data.yaml is well-formed YAML" 0 "" \
|
|
|
|
|
|
python3 -c 'import sys, yaml; yaml.safe_load(open(sys.argv[1]))' "$d/user-data.yaml"
|
|
|
|
|
|
else
|
|
|
|
|
|
echo "skip: template '$t' YAML well-formedness (no python3+pyyaml here; CI has both)"
|
|
|
|
|
|
fi
|
|
|
|
|
|
# #65: 'box tmux' runs 'tmux new-session' INSIDE the box, so every
|
|
|
|
|
|
# template's package list must carry tmux or the verb dies inside.
|
2026-07-18 00:01:15 +00:00
|
|
|
|
check "template '$t': installs tmux (#65)" 0 "" \
|
test(cli): template suite — every templates/*/ dir proven, dynamically (#68)
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 <noreply@anthropic.com>
2026-07-18 14:53:24 +00:00
|
|
|
|
grep -qE '^[[:space:]]*-[[:space:]]+tmux$' "$d/user-data.yaml"
|
test(cli): the seed contract, driven — and the absence, fail-closed (#81)
- render_userdata extracted and DRIVEN: defaults pin heavy-duty/rig@main on
both the installer URL and the installer's env; RIG_REPO/RIG_REF override
at mint; a shell-shaped repo, a spaced ref and a newline-smuggled repo all
die on the host (bash =~ anchors the whole string — one clean line cannot
sneak past it the way a line-oriented grep -q lets it).
- BOX_BOOTSTRAP_ROLE through the real parser: round-trips, and a
shell-shaped value dies at the gate.
- Per-template sweep grows the #81 contract: BOX_USER matches the user the
cloud-init actually creates; a role-bearing seed installs rig carrying
BOTH pin tokens; and absence greps over EFFECTIVE cloud-init lines
(comments may name what they refuse — #69's idiom) prove no agent CLI, no
docker, no tailscale/authkey/ssh, no write_files heredocs, in ANY
template. Want-exit 1: re-adding tenant content goes red.
- cmd_new pinned: user-data reaches Incus through render_userdata; the
auto-run orders after the cloud-init wait and sits under the
T_BOOTSTRAP_ROLE guard; the failure path names the re-run; the workload
join is printed, never exec'd; no template names a creds-holding role.
- staging's boot demands pinned to the file; blank proven roleless and
rig-less.
283 passed, 0 failed (main: 213); shellcheck -x clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 20:03:55 +00:00
|
|
|
|
# BOX_USER is duplicated into the cloud-init by hand (the file reaches
|
|
|
|
|
|
# Incus verbatim) — assert the two halves actually agree, per template.
|
|
|
|
|
|
tuser="$(tpl "$ROOT" "$t" | sed -n 's/.*USER=\([^ ]*\).*/\1/p')"
|
|
|
|
|
|
check "template '$t': user-data.yaml creates BOX_USER ('$tuser')" 0 "" \
|
|
|
|
|
|
grep -qE "^[[:space:]]*-[[:space:]]+name:[[:space:]]+$tuser\$" "$d/user-data.yaml"
|
|
|
|
|
|
|
|
|
|
|
|
# ------------------------------------------------------------------------
|
|
|
|
|
|
# The thin-template contract (#81), both halves per template:
|
|
|
|
|
|
#
|
|
|
|
|
|
# THE SEED — a template that names a tenant role (BOX_BOOTSTRAP_ROLE) must
|
|
|
|
|
|
# preinstall rig carrying BOTH pin tokens, on the installer URL and on the
|
|
|
|
|
|
# installer's own env, or the pin is a half-truth: a mint would fetch one
|
|
|
|
|
|
# ref's installer and install another ref's tree.
|
|
|
|
|
|
# ------------------------------------------------------------------------
|
|
|
|
|
|
trole="$(tpl "$ROOT" "$t" | sed -n 's/.*ROLE=\([^ ]*\).*/\1/p')"
|
|
|
|
|
|
if [ -n "$trole" ]; then
|
|
|
|
|
|
check "template '$t': the seed installs rig (role '$trole')" 0 "" \
|
|
|
|
|
|
grep -q 'install.sh' "$d/user-data.yaml"
|
|
|
|
|
|
# shellcheck disable=SC2016 # $1 expands in the child shell, by design
|
|
|
|
|
|
check "template '$t': the rig install carries the @RIG_REPO@ pin token" 0 "" \
|
|
|
|
|
|
bash -c 'grep "install.sh" "$1" | grep -q "@RIG_REPO@/@RIG_REF@"' _ "$d/user-data.yaml"
|
|
|
|
|
|
# shellcheck disable=SC2016
|
|
|
|
|
|
check "template '$t': the pin reaches the installer's env too" 0 "" \
|
|
|
|
|
|
bash -c 'grep "install.sh" "$1" | grep -q "RIG_REPO=\"@RIG_REPO@\" RIG_REF=\"@RIG_REF@\""' _ "$d/user-data.yaml"
|
2026-07-18 20:52:15 +00:00
|
|
|
|
# HOME=/root: a scar found live — cloud-init's runcmd has no $HOME and
|
|
|
|
|
|
# rig's installer (set -u) dies on it (rig#39). The pin must survive
|
|
|
|
|
|
# every seed rewrite.
|
|
|
|
|
|
# shellcheck disable=SC2016
|
|
|
|
|
|
check "template '$t': the rig install pins HOME=/root (runcmd has no \$HOME)" 0 "" \
|
|
|
|
|
|
bash -c 'grep "install.sh" "$1" | grep -q "HOME=/root "' _ "$d/user-data.yaml"
|
test(cli): the seed contract, driven — and the absence, fail-closed (#81)
- render_userdata extracted and DRIVEN: defaults pin heavy-duty/rig@main on
both the installer URL and the installer's env; RIG_REPO/RIG_REF override
at mint; a shell-shaped repo, a spaced ref and a newline-smuggled repo all
die on the host (bash =~ anchors the whole string — one clean line cannot
sneak past it the way a line-oriented grep -q lets it).
- BOX_BOOTSTRAP_ROLE through the real parser: round-trips, and a
shell-shaped value dies at the gate.
- Per-template sweep grows the #81 contract: BOX_USER matches the user the
cloud-init actually creates; a role-bearing seed installs rig carrying
BOTH pin tokens; and absence greps over EFFECTIVE cloud-init lines
(comments may name what they refuse — #69's idiom) prove no agent CLI, no
docker, no tailscale/authkey/ssh, no write_files heredocs, in ANY
template. Want-exit 1: re-adding tenant content goes red.
- cmd_new pinned: user-data reaches Incus through render_userdata; the
auto-run orders after the cloud-init wait and sits under the
T_BOOTSTRAP_ROLE guard; the failure path names the re-run; the workload
join is printed, never exec'd; no template names a creds-holding role.
- staging's boot demands pinned to the file; blank proven roleless and
rig-less.
283 passed, 0 failed (main: 213); shellcheck -x clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 20:03:55 +00:00
|
|
|
|
fi
|
|
|
|
|
|
# ------------------------------------------------------------------------
|
|
|
|
|
|
# THE ABSENCE — no tenant content in ANY template, ever again. Everything a
|
|
|
|
|
|
# box becomes lives in rig's roles (rig#31); a template that grows an agent
|
|
|
|
|
|
# CLI, docker, node, a tailnet join or a context-file heredoc is the
|
|
|
|
|
|
# regression this suite exists to refuse. Greps run over EFFECTIVE
|
|
|
|
|
|
# cloud-init lines (comments may name what they refuse — #69's idiom), and
|
|
|
|
|
|
# they fail CLOSED: the want-exit is 1, so re-adding any of it goes red.
|
|
|
|
|
|
# ------------------------------------------------------------------------
|
|
|
|
|
|
# shellcheck disable=SC2016 # $1 expands in the child shell, by design
|
|
|
|
|
|
check "template '$t': no agent CLI install (rig's job, rig#31)" 1 "" \
|
|
|
|
|
|
bash -c 'grep -v "^[[:space:]]*#" "$1" | grep -qiE "claude\.ai|x\.ai|@openai|npm|nodesource|nodejs"' _ "$d/user-data.yaml"
|
|
|
|
|
|
# shellcheck disable=SC2016
|
|
|
|
|
|
check "template '$t': no docker (rig's job, rig#31)" 1 "" \
|
|
|
|
|
|
bash -c 'grep -v "^[[:space:]]*#" "$1" | grep -qi docker' _ "$d/user-data.yaml"
|
|
|
|
|
|
# shellcheck disable=SC2016
|
|
|
|
|
|
check "template '$t': nothing that joins or admits (no tailscale/authkey/ssh)" 1 "" \
|
|
|
|
|
|
bash -c 'grep -v "^[[:space:]]*#" "$1" | grep -qiE "tailscale|authkey|ssh"' _ "$d/user-data.yaml"
|
|
|
|
|
|
# shellcheck disable=SC2016
|
|
|
|
|
|
check "template '$t': no context-file heredoc (the #80 guard lives in rig's roles)" 1 "" \
|
|
|
|
|
|
bash -c 'grep -v "^[[:space:]]*#" "$1" | grep -qiE "write_files|CLAUDE\.md|AGENTS\.md"' _ "$d/user-data.yaml"
|
2026-07-18 00:01:15 +00:00
|
|
|
|
done
|
|
|
|
|
|
|
test(cli): the seed contract, driven — and the absence, fail-closed (#81)
- render_userdata extracted and DRIVEN: defaults pin heavy-duty/rig@main on
both the installer URL and the installer's env; RIG_REPO/RIG_REF override
at mint; a shell-shaped repo, a spaced ref and a newline-smuggled repo all
die on the host (bash =~ anchors the whole string — one clean line cannot
sneak past it the way a line-oriented grep -q lets it).
- BOX_BOOTSTRAP_ROLE through the real parser: round-trips, and a
shell-shaped value dies at the gate.
- Per-template sweep grows the #81 contract: BOX_USER matches the user the
cloud-init actually creates; a role-bearing seed installs rig carrying
BOTH pin tokens; and absence greps over EFFECTIVE cloud-init lines
(comments may name what they refuse — #69's idiom) prove no agent CLI, no
docker, no tailscale/authkey/ssh, no write_files heredocs, in ANY
template. Want-exit 1: re-adding tenant content goes red.
- cmd_new pinned: user-data reaches Incus through render_userdata; the
auto-run orders after the cloud-init wait and sits under the
T_BOOTSTRAP_ROLE guard; the failure path names the re-run; the workload
join is printed, never exec'd; no template names a creds-holding role.
- staging's boot demands pinned to the file; blank proven roleless and
rig-less.
283 passed, 0 failed (main: 213); shellcheck -x clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 20:03:55 +00:00
|
|
|
|
# The staging seed's boot demands are part of its contract (#68/#69): the VM
|
|
|
|
|
|
# is its trust boundary (its guest runs docker, via rig) and a server returns
|
|
|
|
|
|
# from a host reboot without an operator. Pinned to the FILE so neither can
|
|
|
|
|
|
# quietly vanish in a rewrite.
|
|
|
|
|
|
check "staging: demands VM mode (BOX_REQUIRE_VM=1)" 0 "" \
|
|
|
|
|
|
grep -qx 'BOX_REQUIRE_VM="1"' "$ROOT/templates/staging/box.env"
|
|
|
|
|
|
check "staging: demands autostart (BOX_AUTOSTART=1)" 0 "" \
|
|
|
|
|
|
grep -qx 'BOX_AUTOSTART="1"' "$ROOT/templates/staging/box.env"
|
|
|
|
|
|
check "staging: the tenant role is 'staging'" 0 "ROLE=staging" tpl "$ROOT" staging
|
|
|
|
|
|
check "staging: the seed user is rig's default for the role ('ops')" 0 "USER=ops" tpl "$ROOT" staging
|
|
|
|
|
|
# The agent tenants: role = user = template name, rig's default mapping.
|
|
|
|
|
|
for t in claude codex grok; do
|
|
|
|
|
|
check "$t: role and user are '$t' (rig's default tenant mapping)" 0 "USER=$t REQUIRE_VM= AUTOSTART= ROLE=$t" \
|
|
|
|
|
|
tpl "$ROOT" "$t"
|
|
|
|
|
|
done
|
|
|
|
|
|
# blank stays a box with NOBODY home: no rig, no role — same isolation, no
|
|
|
|
|
|
# tooling, and nothing auto-runs in it.
|
|
|
|
|
|
check "blank: names no bootstrap role" 1 "" \
|
|
|
|
|
|
grep -q '^BOX_BOOTSTRAP_ROLE=' "$ROOT/templates/blank/box.env"
|
|
|
|
|
|
check "blank: does not preinstall rig" 1 "" grep -q 'install.sh' "$ROOT/templates/blank/user-data.yaml"
|
|
|
|
|
|
|
test(cli): template suite — every templates/*/ dir proven, dynamically (#68)
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 <noreply@anthropic.com>
2026-07-18 14:53:24 +00:00
|
|
|
|
rm -f "$TPLFN"
|
|
|
|
|
|
|
|
|
|
|
|
# The keys' cmd_new half, grepped the way the expose guard is (line order —
|
|
|
|
|
|
# a daemon-free run cannot mint). The REQUIRE_VM refusal must read the
|
|
|
|
|
|
# EFFECTIVE mode, i.e. come after pick_mode: refusing on the template key
|
|
|
|
|
|
# alone would refuse valid VM mints, and a guard deleted in a refactor must
|
|
|
|
|
|
# not ship green.
|
|
|
|
|
|
# shellcheck disable=SC2016 # the $-strings are literals in the target file
|
|
|
|
|
|
check "new: the REQUIRE_VM refusal orders after pick_mode" 0 "" bash -c '
|
|
|
|
|
|
fn="$(awk "/^cmd_new\(\) \{/,/^\}/" "'"$ROOT"'/bin/box")"
|
|
|
|
|
|
pick="$(printf "%s\n" "$fn" | grep -n "pick_mode" | head -1 | cut -d: -f1)"
|
|
|
|
|
|
guard="$(printf "%s\n" "$fn" | grep -n "T_REQUIRE_VM" | head -1 | cut -d: -f1)"
|
|
|
|
|
|
[ -n "$pick" ] && [ -n "$guard" ] && [ "$pick" -lt "$guard" ]'
|
2026-07-18 18:05:49 +00:00
|
|
|
|
# Order is necessary, not sufficient: a regression to the RAW flag
|
|
|
|
|
|
# ([ "$mode" != vm ]) would still sit after pick_mode — and would refuse every
|
|
|
|
|
|
# auto mint on a valid VM host. Pin the guard to the EFFECTIVE operand: the
|
|
|
|
|
|
# T_REQUIRE_VM line itself must compare $m, the pick_mode result.
|
|
|
|
|
|
# shellcheck disable=SC2016 # the $-strings are literals in the target file
|
|
|
|
|
|
check "new: the REQUIRE_VM guard compares the effective mode (\$m)" 0 "" bash -c '
|
|
|
|
|
|
awk "/^cmd_new\(\) \{/,/^\}/" "'"$ROOT"'/bin/box" \
|
|
|
|
|
|
| grep "T_REQUIRE_VM" | grep -qF "\"\$m\" != vm"'
|
test(cli): template suite — every templates/*/ dir proven, dynamically (#68)
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 <noreply@anthropic.com>
2026-07-18 14:53:24 +00:00
|
|
|
|
check "new: boot.autostart is stamped under the T_AUTOSTART guard" 0 "" bash -c '
|
|
|
|
|
|
awk "/^cmd_new\(\) \{/,/^\}/" "'"$ROOT"'/bin/box" \
|
|
|
|
|
|
| grep -F "boot.autostart=true" | grep -q "T_AUTOSTART"'
|
|
|
|
|
|
|
test(cli): the seed contract, driven — and the absence, fail-closed (#81)
- render_userdata extracted and DRIVEN: defaults pin heavy-duty/rig@main on
both the installer URL and the installer's env; RIG_REPO/RIG_REF override
at mint; a shell-shaped repo, a spaced ref and a newline-smuggled repo all
die on the host (bash =~ anchors the whole string — one clean line cannot
sneak past it the way a line-oriented grep -q lets it).
- BOX_BOOTSTRAP_ROLE through the real parser: round-trips, and a
shell-shaped value dies at the gate.
- Per-template sweep grows the #81 contract: BOX_USER matches the user the
cloud-init actually creates; a role-bearing seed installs rig carrying
BOTH pin tokens; and absence greps over EFFECTIVE cloud-init lines
(comments may name what they refuse — #69's idiom) prove no agent CLI, no
docker, no tailscale/authkey/ssh, no write_files heredocs, in ANY
template. Want-exit 1: re-adding tenant content goes red.
- cmd_new pinned: user-data reaches Incus through render_userdata; the
auto-run orders after the cloud-init wait and sits under the
T_BOOTSTRAP_ROLE guard; the failure path names the re-run; the workload
join is printed, never exec'd; no template names a creds-holding role.
- staging's boot demands pinned to the file; blank proven roleless and
rig-less.
283 passed, 0 failed (main: 213); shellcheck -x clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 20:03:55 +00:00
|
|
|
|
# The auto-run half of #81, grepped the same way (a daemon-free run cannot
|
|
|
|
|
|
# mint). The seed reaches Incus through render_userdata — the pin point — not
|
|
|
|
|
|
# through a raw cat; and the tenant convergence must order AFTER the
|
|
|
|
|
|
# cloud-init wait (rig is installed by the seed's runcmd, so exec'ing the
|
|
|
|
|
|
# role before cloud-init settles would race its own installer) and sit under
|
|
|
|
|
|
# the T_BOOTSTRAP_ROLE guard (blank must never auto-run anything).
|
|
|
|
|
|
check "new: cloud-init user-data goes through render_userdata (the rig pin)" 0 "" bash -c '
|
|
|
|
|
|
awk "/^cmd_new\(\) \{/,/^\}/" "'"$ROOT"'/bin/box" \
|
|
|
|
|
|
| grep -F "cloud-init.user-data" | grep -q "render_userdata"'
|
|
|
|
|
|
# shellcheck disable=SC2016 # the $-strings are literals in the target file
|
|
|
|
|
|
check "new: the tenant auto-run orders after the cloud-init wait" 0 "" bash -c '
|
|
|
|
|
|
fn="$(awk "/^cmd_new\(\) \{/,/^\}/" "'"$ROOT"'/bin/box")"
|
|
|
|
|
|
wait="$(printf "%s\n" "$fn" | grep -n "cloud-init status --wait" | head -1 | cut -d: -f1)"
|
|
|
|
|
|
run="$(printf "%s\n" "$fn" | grep -n "rig bootstrap" | head -1 | cut -d: -f1)"
|
|
|
|
|
|
[ -n "$wait" ] && [ -n "$run" ] && [ "$wait" -lt "$run" ]'
|
|
|
|
|
|
check "new: the auto-run sits under the T_BOOTSTRAP_ROLE guard" 0 "" bash -c '
|
|
|
|
|
|
awk "/^cmd_new\(\) \{/,/^\}/" "'"$ROOT"'/bin/box" \
|
|
|
|
|
|
| grep -B2 "incus exec .* rig bootstrap" | grep -q "T_BOOTSTRAP_ROLE"'
|
|
|
|
|
|
check "new: a failed tenant role names the re-run (the role converges)" 0 "" bash -c '
|
|
|
|
|
|
awk "/^cmd_new\(\) \{/,/^\}/" "'"$ROOT"'/bin/box" \
|
|
|
|
|
|
| grep -q "sudo rig bootstrap"'
|
|
|
|
|
|
# staging's creds-holding join stays OPERATOR-run: cmd_new may print it as a
|
|
|
|
|
|
# next step, but no template and no code path auto-runs "rig bootstrap
|
|
|
|
|
|
# workload" — the one absence that keeps box creds-free end to end.
|
|
|
|
|
|
check "new: the workload join is printed, never exec'd" 1 "" bash -c '
|
|
|
|
|
|
grep "rig bootstrap workload" "'"$ROOT"'/bin/box" | grep -q "incus exec"'
|
|
|
|
|
|
check "templates: no template names a creds-holding role" 1 "" bash -c '
|
|
|
|
|
|
grep -h "^BOX_BOOTSTRAP_ROLE=" "'"$ROOT"'"/templates/*/box.env | grep -qE "workload|host|custom"'
|
|
|
|
|
|
|
test+ci: the multi-user rehearsal, and CI that runs it on a real incus (#74)
drill/multiuser.sh: two throwaway users, real grants, real boxes, and the
contract measured from INSIDE them — #74's criteria (a)-(f) plus what the
findings added: the cross-user sibling drop, the closed private-bridge
escape hatches, incus-user re-sync survival, scoped revoke --purge. Its own
first runs are in RUNS.md (MU-1..3): both false FAILs were the instrument,
and both lessons (keep the mint's narration; probe a SIBLING's name, never
your own — /etc/hosts answers for you) are now comments in the script.
test/cli.sh: box_tier() driven against a shim id for all five cases
(including both-groups → admin: membership wins at the socket), the two
copies diffed byte-identical, the tier scripts' usage contracts driven for
real, and grep-guards on every load-bearing line a daemon-free run cannot
exercise — the expose guard's position, the boxnet-and-only-boxnet
narrowing, the snapshot allow, 'incus:snapshot restore'.
ci.yml: a second job stands up the real stack on the runner (setup-host,
doctor, then the rehearsal in container mode) — every PR now proves the
tier's semantics against a live daemon. The tier's mechanics are
instance-type-independent (the drop, the ACL, dns.mode and port_isolation
bind to boxnet, not the instance); the VM trust boundary itself stays a
real-hardware ritual, like the full drill.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 04:09:48 +00:00
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# The restricted tier (#74). box_tier() is the decision the whole tier hangs
|
|
|
|
|
|
# on, so it is DRIVEN, not grepped: extracted from bin/box, sourced, and run
|
|
|
|
|
|
# against a shim id for every case — including the one that bites (a user in
|
|
|
|
|
|
# BOTH groups is admin: membership wins at the socket, and the function must
|
|
|
|
|
|
# not substring-match 'incus' inside 'incus-admin').
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
TIERFN="$(mktemp)"
|
|
|
|
|
|
awk '/^box_tier\(\) \{/,/^\}/' "$ROOT/bin/box" > "$TIERFN"
|
|
|
|
|
|
check "box_tier: extracted from bin/box (guards the awk)" 0 "incus-admin" cat "$TIERFN"
|
|
|
|
|
|
check "box_tier: the extracted function is valid bash" 0 "" bash -n "$TIERFN"
|
|
|
|
|
|
|
|
|
|
|
|
tier() { # tier <uid> <groups...>
|
|
|
|
|
|
local uid="$1"; shift
|
|
|
|
|
|
FAKE_UID="$uid" FAKE_GROUPS="$*" PATH="$SHIMDIR:$PATH" \
|
|
|
|
|
|
bash -c ". '$TIERFN'; box_tier"
|
|
|
|
|
|
}
|
|
|
|
|
|
check "box_tier: uid 0 → admin" 0 "admin" tier 0
|
|
|
|
|
|
check "box_tier: incus-admin → admin" 0 "admin" tier 1000 "users incus-admin"
|
|
|
|
|
|
check "box_tier: incus only → restricted" 0 "restricted" tier 1000 "users incus"
|
|
|
|
|
|
check "box_tier: both groups → admin (membership wins at the socket)" \
|
|
|
|
|
|
0 "admin" tier 1000 "users incus incus-admin"
|
|
|
|
|
|
check "box_tier: neither → none" 0 "none" tier 1000 "users dialout"
|
|
|
|
|
|
rm -f "$TIERFN"
|
|
|
|
|
|
|
|
|
|
|
|
# setup-host.sh must decide the tier BEFORE any install tree exists, so it
|
|
|
|
|
|
# carries its own copy — and a drifted copy is two tiers pretending to be one.
|
|
|
|
|
|
# Byte-identical, asserted.
|
|
|
|
|
|
BINFN="$(mktemp)"; HOSTFN="$(mktemp)"
|
|
|
|
|
|
awk '/^box_tier\(\) \{/,/^\}/' "$ROOT/bin/box" > "$BINFN"
|
|
|
|
|
|
awk '/^box_tier\(\) \{/,/^\}/' "$ROOT/host/setup-host.sh" > "$HOSTFN"
|
|
|
|
|
|
check "box_tier: bin/box and setup-host.sh copies are byte-identical" 0 "" \
|
|
|
|
|
|
diff "$BINFN" "$HOSTFN"
|
|
|
|
|
|
rm -f "$BINFN" "$HOSTFN"
|
|
|
|
|
|
|
|
|
|
|
|
# The tier scripts parse and refuse bad usage without a daemon — drive them.
|
|
|
|
|
|
check "grant: no argument is a usage error" 2 "usage: box grant" bash "$ROOT/host/grant-user.sh"
|
|
|
|
|
|
check "grant: a flag is not a user" 2 "usage: box grant" bash "$ROOT/host/grant-user.sh" --frob
|
|
|
|
|
|
check "revoke: no argument is a usage error" 2 "usage: box revoke" bash "$ROOT/host/revoke-user.sh"
|
|
|
|
|
|
check "revoke: two users is a usage error" 2 "usage: box revoke" bash "$ROOT/host/revoke-user.sh" a b
|
|
|
|
|
|
check "box grant with no user exits 2 (via the CLI table)" 2 "usage: box grant" "$BOX" grant
|
|
|
|
|
|
check "box revoke with no user exits 2 (via the CLI table)" 2 "usage: box revoke" "$BOX" revoke
|
|
|
|
|
|
check "help grant names the hardened network" 0 "boxnet" "$BOX" help grant
|
|
|
|
|
|
check "help revoke names --purge" 0 "purge" "$BOX" help revoke
|
|
|
|
|
|
|
|
|
|
|
|
# Load-bearing lines a daemon-free run cannot exercise — grepped so a deleted
|
|
|
|
|
|
# guard cannot ship green (the house test discipline).
|
|
|
|
|
|
# The expose guard must fire before ANY incus call in cmd_expose: line order.
|
|
|
|
|
|
# shellcheck disable=SC2016 # the $-strings are literals in the target file
|
|
|
|
|
|
check "expose: the restricted guard precedes the first incus call" 0 "" bash -c '
|
|
|
|
|
|
fn="$(awk "/^cmd_expose\(\) \{/,/^\}/" "'"$ROOT"'/bin/box")"
|
|
|
|
|
|
guard="$(printf "%s\n" "$fn" | grep -n "box_tier" | head -1 | cut -d: -f1)"
|
|
|
|
|
|
first="$(printf "%s\n" "$fn" | grep -n "incus config" | head -1 | cut -d: -f1)"
|
|
|
|
|
|
[ -n "$guard" ] && [ -n "$first" ] && [ "$guard" -lt "$first" ]'
|
|
|
|
|
|
# cmd_new refuses before minting when the placement contract is absent, and
|
|
|
|
|
|
# the message is tier-aware (a restricted user is sent to 'box grant', not
|
feat: box export / import — state that survives the box and the host (#70)
'box rm' deletes a box and every snapshot it has; 'box new --from' clones,
but the clone still lives on the same host. Nothing a box held could outlive
a teardown — which made #66's upgrade refusal honest but lossy. This adds
the way out and the way back:
- box export <box> [<file>] [--instance-only]: wraps 'incus export' into one
portable backup tarball (default <box>-<UTC stamp>.tar.gz), snapshots
included by default. Requires the box stopped (require_stopped grew an
honest reason parameter: export is down by OUR decision, not incus's).
Credentials are SHOUTED, not scrubbed — the artifact carries the box's
whole disk, and scrubbing a disk image is a promise tarball surgery
cannot keep.
- box import <file> [--name <box>]: reads the artifact's name from
backup/index.yaml up front, refuses any name an existing instance holds
(the resolve_box boundary from the other side), pre-flights the stack
(require_stack, factored out of cmd_new), imports, then re-stamps the
HOST's truth onto the artifact's: user.box=1 (legacy tag honored), the
box-net placement (profile assign, the migrate-host move), fresh volatile
MACs (imports restore volatile.* verbatim — a re-import beside its
sibling collided at start with 'MAC address already defined on another
NIC', measured live on Incus 6.0.4), and reset_identity, exactly like a
clone.
- restricted tier: box grant now converges restricted.backups allow —
export rides the backup API, which incus-user's restricted projects block
by default exactly like snapshots (incus 6.0 permissions.go,
AllowBackupCreation). Import is plain instance creation and needs no key.
- tests: driven usage errors + fail-closed grep/line-order guards for every
daemon-gated invariant; CI's rehearsal job now runs a live round-trip
(mint, write, snapshot, down, export, rm, import, assert the file, the
snapshot, the tag, the agent, and the collision refusal).
The whole flow was verified against a live Incus 6.0.4 daemon: running-box
refusal, export, overwrite guard, rm, import with and without --name,
re-home onto box-net, sibling re-import with distinct MACs and machine-ids,
pre-export file and snapshot present in both.
Closes #70
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 15:10:12 +00:00
|
|
|
|
# to setup-host they cannot run). The pre-flight lives in require_stack()
|
|
|
|
|
|
# since #70 gave it a second caller (import lands on the same contract), so
|
|
|
|
|
|
# assert both halves: the helper holds the probe, and cmd_new calls it.
|
|
|
|
|
|
check "require_stack: probes the box-net profile" 0 "" bash -c '
|
|
|
|
|
|
awk "/^require_stack\(\) \{/,/^\}/" "'"$ROOT"'/bin/box" | grep -q "incus profile show box-net"'
|
|
|
|
|
|
check "require_stack: the restricted fix names box grant" 0 "" bash -c '
|
|
|
|
|
|
awk "/^require_stack\(\) \{/,/^\}/" "'"$ROOT"'/bin/box" | grep -q "box grant"'
|
|
|
|
|
|
check "new: pre-flights the stack (require_stack)" 0 "" bash -c '
|
|
|
|
|
|
awk "/^cmd_new\(\) \{/,/^\}/" "'"$ROOT"'/bin/box" | grep -q "require_stack"'
|
test+ci: the multi-user rehearsal, and CI that runs it on a real incus (#74)
drill/multiuser.sh: two throwaway users, real grants, real boxes, and the
contract measured from INSIDE them — #74's criteria (a)-(f) plus what the
findings added: the cross-user sibling drop, the closed private-bridge
escape hatches, incus-user re-sync survival, scoped revoke --purge. Its own
first runs are in RUNS.md (MU-1..3): both false FAILs were the instrument,
and both lessons (keep the mint's narration; probe a SIBLING's name, never
your own — /etc/hosts answers for you) are now comments in the script.
test/cli.sh: box_tier() driven against a shim id for all five cases
(including both-groups → admin: membership wins at the socket), the two
copies diffed byte-identical, the tier scripts' usage contracts driven for
real, and grep-guards on every load-bearing line a daemon-free run cannot
exercise — the expose guard's position, the boxnet-and-only-boxnet
narrowing, the snapshot allow, 'incus:snapshot restore'.
ci.yml: a second job stands up the real stack on the runner (setup-host,
doctor, then the rehearsal in container mode) — every PR now proves the
tier's semantics against a live daemon. The tier's mechanics are
instance-type-independent (the drop, the ACL, dns.mode and port_isolation
bind to boxnet, not the instance); the VM trust boundary itself stays a
real-hardware ritual, like the full drill.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 04:09:48 +00:00
|
|
|
|
# grant converges to boxnet and ONLY boxnet — "boxnet,incusbr" would keep the
|
|
|
|
|
|
# unhardened private bridge one --network flag away (the #74 measured hole).
|
|
|
|
|
|
check "grant: narrows access to boxnet alone" 0 "" \
|
|
|
|
|
|
grep -qE 'restricted\.networks\.access boxnet($| )' "$ROOT/host/grant-user.sh"
|
|
|
|
|
|
check "grant: never grants the private bridge" 1 "" \
|
|
|
|
|
|
grep -qE 'networks\.access[^#]*incusbr' "$ROOT/host/grant-user.sh"
|
|
|
|
|
|
check "grant: allows snapshots (the clone workflow)" 0 "" \
|
|
|
|
|
|
grep -qF 'restricted.snapshots allow' "$ROOT/host/grant-user.sh"
|
|
|
|
|
|
# shellcheck disable=SC2016 # the $-strings are literals in the target file
|
|
|
|
|
|
check "grant: installs the SHIPPED profile into the project" 0 "" \
|
|
|
|
|
|
grep -qF 'profile edit box-net < "$here/profiles/box-net.yaml"' "$ROOT/host/grant-user.sh"
|
|
|
|
|
|
check "grant: unpins the private-bridge eth0 from the default profile" 0 "" \
|
|
|
|
|
|
grep -qF 'profile device remove default eth0' "$ROOT/host/grant-user.sh"
|
|
|
|
|
|
check "grant: refuses an incus-admin member (nothing tighter to grant)" 0 "" \
|
|
|
|
|
|
grep -qF 'incus-admin' "$ROOT/host/grant-user.sh"
|
|
|
|
|
|
check "revoke: group removal is the lockout" 0 "" \
|
|
|
|
|
|
grep -qF 'gpasswd -d' "$ROOT/host/revoke-user.sh"
|
revoke/grant: survive the live-session case — the review's one real hole (#74)
Supplementary groups are read at LOGIN, so 'gpasswd -d' does nothing to a
session the user already holds — and after --purge, a stale-group process
could touch incus-user and lazily RECREATE the project with stock defaults:
the unhardened NAT bridge, un-narrowed, strictly worse than the granted
state. Adversarial review caught it; verified live, then closed:
- revoke --purge terminates the user's sessions first (loginctl, then
pkill), and refuses to purge under processes it cannot kill
- bare revoke says out loud that held sessions keep the socket until they
end, and names the loginctl command — instead of claiming a lockout it
did not deliver (help/README/design doc reworded to match)
- a failed grant backs out its own group-add on exit (trap, disarmed on
success): no half-granted user holding an un-narrowed socket while the
admin reads the error. Verified by injecting a bad profile YAML
- the rehearsal now holds a session open across the purge and demands it
dies with the tier (criterion l, 42nd check)
Smaller review findings, same pass: the escape-hatch probes assert the
refusal's REASON instead of any nonzero exit (an image hiccup must not read
as 'the escape is closed'); probe_from maps an outer-timeout kill to
dropped, not reachable; the rehearsal cleanup keeps the account when a purge
fails so doctor can name the leftovers; the purge asserts the trust
certificate's absence; cmd_new distinguishes a dead daemon from a missing
stack before prescribing setup-host; grant's success message names the
user-<uid> bridge variant correctly on big-uid hosts.
Rehearsal after: 42/42 (containers). test/cli.sh: 76 checks.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 05:11:20 +00:00
|
|
|
|
# Group membership is read at login: purge must terminate live sessions (a
|
|
|
|
|
|
# stale-group process could recreate the project unhardened AFTER the purge),
|
|
|
|
|
|
# and a bare revoke must say the socket survives in held sessions.
|
|
|
|
|
|
check "revoke: purge terminates live sessions first" 0 "" \
|
|
|
|
|
|
grep -qF 'loginctl terminate-user' "$ROOT/host/revoke-user.sh"
|
|
|
|
|
|
check "revoke: purge refuses under unkillable sessions" 0 "" \
|
|
|
|
|
|
grep -qF 'refusing to purge under them' "$ROOT/host/revoke-user.sh"
|
|
|
|
|
|
check "revoke: bare revoke warns about held sessions" 0 "" \
|
|
|
|
|
|
grep -qF 'live sessions' "$ROOT/host/revoke-user.sh"
|
|
|
|
|
|
check "revoke: the purge asserts the certificate's absence too" 0 "" \
|
test+ci: real installs driven offline, uninstall proven to zero residue
test/cli.sh drives REAL installer runs via BOX_INSTALL_SOURCE (temp
BOX_HOME/BOX_BIN, a fabricated second version for upgrades): fresh install
lands versioned, --version answers through the chain, side-by-side installs,
same-version no-op, BOX_REINSTALL, the 0.6.0 flat-tree migration, stale-
symlink healing, single-version and full uninstalls asserted to zero residue
(a planted survivor makes it scream INCOMPLETE), working-tree refusals, and
byte-identity of the existing_boxes copies. The rehearsal job now installs
via install.sh itself — CI proves the installer under review — and ends with
the uninstall drill: revoke --purge, teardown --yes, box uninstall, then
assert nothing is left (networks, profiles, nft, units, files, symlinks).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 16:02:37 +00:00
|
|
|
|
bash -c 'awk "/Assert absence/,0" "'"$ROOT"'/host/revoke-user.sh" | grep -q "config trust list"'
|
revoke/grant: survive the live-session case — the review's one real hole (#74)
Supplementary groups are read at LOGIN, so 'gpasswd -d' does nothing to a
session the user already holds — and after --purge, a stale-group process
could touch incus-user and lazily RECREATE the project with stock defaults:
the unhardened NAT bridge, un-narrowed, strictly worse than the granted
state. Adversarial review caught it; verified live, then closed:
- revoke --purge terminates the user's sessions first (loginctl, then
pkill), and refuses to purge under processes it cannot kill
- bare revoke says out loud that held sessions keep the socket until they
end, and names the loginctl command — instead of claiming a lockout it
did not deliver (help/README/design doc reworded to match)
- a failed grant backs out its own group-add on exit (trap, disarmed on
success): no half-granted user holding an un-narrowed socket while the
admin reads the error. Verified by injecting a bad profile YAML
- the rehearsal now holds a session open across the purge and demands it
dies with the tier (criterion l, 42nd check)
Smaller review findings, same pass: the escape-hatch probes assert the
refusal's REASON instead of any nonzero exit (an image hiccup must not read
as 'the escape is closed'); probe_from maps an outer-timeout kill to
dropped, not reachable; the rehearsal cleanup keeps the account when a purge
fails so doctor can name the leftovers; the purge asserts the trust
certificate's absence; cmd_new distinguishes a dead daemon from a missing
stack before prescribing setup-host; grant's success message names the
user-<uid> bridge variant correctly on big-uid hosts.
Rehearsal after: 42/42 (containers). test/cli.sh: 76 checks.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 05:11:20 +00:00
|
|
|
|
# A failed grant must not leave a half-granted user: if THIS run added the
|
|
|
|
|
|
# group, the exit path takes it back (and the trap disarms only on success).
|
|
|
|
|
|
check "grant: backs out its own group-add on failure" 0 "" \
|
|
|
|
|
|
grep -qF 'trap backout EXIT' "$ROOT/host/grant-user.sh"
|
|
|
|
|
|
check "grant: the back-out disarms on success" 0 "" \
|
|
|
|
|
|
grep -qF 'trap - EXIT' "$ROOT/host/grant-user.sh"
|
grant/rehearsal: the codex round — verified rollback, loud partial states, and the raw-attach guarantee measured (#75)
Review 4727756972 (A2): the backout no longer trusts gpasswd — it re-reads
the live group database after removal; verified-absent gets the safe
message, anything else screams ROLLBACK INCOMPLETE, exits nonzero, and
names the exact remediation. The concurrent-login window (a session begun
between usermod and backout keeps the group) is CLOSED to the extent the
database can't reach: the backout detects live processes and names
loginctl terminate-user, and the success wording claims only what was
verified.
Review 4727641752 (A1): a failed grant for a user whose membership predates
the run (the hand-added-user scenario) now fails LOUDLY — they retain
socket access on part-converged policy, and the message says so with both
remediations (box revoke now, or fix and re-run). Their membership is not
stripped: breaking a working user over a failed re-grant is its own hazard.
The default-profile eth0 removal is deliberately not restored on failure —
that mutation only reduces capability, and restoring it would move the
failure state AWAY from fail-closed. Injected-failure coverage is criterion
(n), both flavors: fresh-user backout (fault at the LAST mutation, so the
rollback runs after every earlier one) with the group's absence verified
and a converging re-run; blocked narrowing staged for real with an
instance-local NIC parked on the private bridge.
Review A3, resolution 3 with the measurement demanded: criterion (m)
launches exactly 'incus launch --network boxnet' as the restricted user and
probes the raw NIC from inside — egress works, RFC1918 dropped (the ACL is
the network's), sibling probes dropped BOTH directions (the nft drop is the
host's), name enumeration blocked. The scoped guarantee is now stated in
box-design.md and measured on every run: box-minted instances carry per-NIC
port_isolation; raw attachments keep every network- and host-owned control,
losing only that redundant L2 layer. Instrument lesson kept as MU-5: the
probe's first cut minted the non-cloud image — no DHCP client, no lease,
and a dead NIC passes every negative probe vacuously; it now requires the
lease before believing its own answers.
Rehearsal: 54/54 (containers). test/cli.sh: 82 checks.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 06:41:05 +00:00
|
|
|
|
# The backout must VERIFY the removal and scream when it cannot — an
|
|
|
|
|
|
# unverified rollback printing a security guarantee is the review's A2.
|
|
|
|
|
|
check "grant: the backout verifies against the group database" 0 "" \
|
|
|
|
|
|
bash -c 'awk "/^backout\(\) \{/,/^\}/" "'"$ROOT"'/host/grant-user.sh" | grep -q "id -nG"'
|
|
|
|
|
|
check "grant: an unverifiable rollback screams" 0 "" \
|
|
|
|
|
|
grep -qF 'ROLLBACK INCOMPLETE' "$ROOT/host/grant-user.sh"
|
|
|
|
|
|
check "grant: a failed re-grant warns the pre-existing member is untouched" 0 "" \
|
|
|
|
|
|
grep -qF 'still holding socket access' "$ROOT/host/grant-user.sh"
|
|
|
|
|
|
check "grant: the mid-grant login window is named" 0 "" \
|
|
|
|
|
|
bash -c 'awk "/^backout\(\) \{/,/^\}/" "'"$ROOT"'/host/grant-user.sh" | grep -q "loginctl terminate-user"'
|
|
|
|
|
|
# The scoped guarantee (raw --network boxnet) is measured, not prose:
|
|
|
|
|
|
check "rehearsal: measures the raw boxnet attach (criterion m)" 0 "" \
|
|
|
|
|
|
grep -qF -- '--network boxnet' "$ROOT/drill/multiuser.sh"
|
|
|
|
|
|
# shellcheck disable=SC2016 # the $-string is a literal in the target file
|
|
|
|
|
|
check "rehearsal: injects grant failures (criterion n)" 0 "" \
|
|
|
|
|
|
grep -qF 'grant-user.sh" "$U3"' "$ROOT/drill/multiuser.sh"
|
test+ci: the multi-user rehearsal, and CI that runs it on a real incus (#74)
drill/multiuser.sh: two throwaway users, real grants, real boxes, and the
contract measured from INSIDE them — #74's criteria (a)-(f) plus what the
findings added: the cross-user sibling drop, the closed private-bridge
escape hatches, incus-user re-sync survival, scoped revoke --purge. Its own
first runs are in RUNS.md (MU-1..3): both false FAILs were the instrument,
and both lessons (keep the mint's narration; probe a SIBLING's name, never
your own — /etc/hosts answers for you) are now comments in the script.
test/cli.sh: box_tier() driven against a shim id for all five cases
(including both-groups → admin: membership wins at the socket), the two
copies diffed byte-identical, the tier scripts' usage contracts driven for
real, and grep-guards on every load-bearing line a daemon-free run cannot
exercise — the expose guard's position, the boxnet-and-only-boxnet
narrowing, the snapshot allow, 'incus:snapshot restore'.
ci.yml: a second job stands up the real stack on the runner (setup-host,
doctor, then the rehearsal in container mode) — every PR now proves the
tier's semantics against a live daemon. The tier's mechanics are
instance-type-independent (the drop, the ACL, dns.mode and port_isolation
bind to boxnet, not the instance); the VM trust boundary itself stays a
real-hardware ritual, like the full drill.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 04:09:48 +00:00
|
|
|
|
# shellcheck disable=SC2016 # the $-strings are literals in the target file
|
|
|
|
|
|
check "revoke: purge deletes instances one at a time" 0 "" \
|
|
|
|
|
|
grep -qF 'delete -f "$inst"' "$ROOT/host/revoke-user.sh"
|
|
|
|
|
|
check "revoke: purge removes the trust-store certificate" 0 "" \
|
|
|
|
|
|
grep -qF 'config trust remove' "$ROOT/host/revoke-user.sh"
|
|
|
|
|
|
# shellcheck disable=SC2016 # the $-strings are literals in the target file
|
|
|
|
|
|
check "setup-host: the restricted gate precedes the sudo resolution" 0 "" bash -c '
|
|
|
|
|
|
gate="$(grep -n "restricted tier" "'"$ROOT"'/host/setup-host.sh" | head -1 | cut -d: -f1)"
|
|
|
|
|
|
sudo="$(grep -n "^elif command -v sudo" "'"$ROOT"'/host/setup-host.sh" | head -1 | cut -d: -f1)"
|
|
|
|
|
|
[ -n "$gate" ] && [ -n "$sudo" ] && [ "$gate" -lt "$sudo" ]'
|
|
|
|
|
|
check "setup-host: enables incus-user.socket for the tier" 0 "" \
|
|
|
|
|
|
grep -qF 'incus-user.socket' "$ROOT/host/setup-host.sh"
|
|
|
|
|
|
check "doctor: honors BOX_TIER" 0 "" \
|
|
|
|
|
|
grep -qF 'BOX_TIER' "$ROOT/drill/doctor.sh"
|
|
|
|
|
|
check "box exports BOX_TIER to the doctor" 0 "" \
|
|
|
|
|
|
grep -qF 'export BOX_TIER' "$ROOT/bin/box"
|
|
|
|
|
|
# 'box restore' must speak incus 6 ('snapshot restore'); bare 'incus restore'
|
|
|
|
|
|
# does not exist and the verb was broken for everyone until #74's rehearsal hit it.
|
|
|
|
|
|
check "restore: dispatches 'incus snapshot restore'" 0 "" \
|
|
|
|
|
|
grep -qF '^incus:snapshot restore^' "$ROOT/bin/box"
|
feat: box export / import — state that survives the box and the host (#70)
'box rm' deletes a box and every snapshot it has; 'box new --from' clones,
but the clone still lives on the same host. Nothing a box held could outlive
a teardown — which made #66's upgrade refusal honest but lossy. This adds
the way out and the way back:
- box export <box> [<file>] [--instance-only]: wraps 'incus export' into one
portable backup tarball (default <box>-<UTC stamp>.tar.gz), snapshots
included by default. Requires the box stopped (require_stopped grew an
honest reason parameter: export is down by OUR decision, not incus's).
Credentials are SHOUTED, not scrubbed — the artifact carries the box's
whole disk, and scrubbing a disk image is a promise tarball surgery
cannot keep.
- box import <file> [--name <box>]: reads the artifact's name from
backup/index.yaml up front, refuses any name an existing instance holds
(the resolve_box boundary from the other side), pre-flights the stack
(require_stack, factored out of cmd_new), imports, then re-stamps the
HOST's truth onto the artifact's: user.box=1 (legacy tag honored), the
box-net placement (profile assign, the migrate-host move), fresh volatile
MACs (imports restore volatile.* verbatim — a re-import beside its
sibling collided at start with 'MAC address already defined on another
NIC', measured live on Incus 6.0.4), and reset_identity, exactly like a
clone.
- restricted tier: box grant now converges restricted.backups allow —
export rides the backup API, which incus-user's restricted projects block
by default exactly like snapshots (incus 6.0 permissions.go,
AllowBackupCreation). Import is plain instance creation and needs no key.
- tests: driven usage errors + fail-closed grep/line-order guards for every
daemon-gated invariant; CI's rehearsal job now runs a live round-trip
(mint, write, snapshot, down, export, rm, import, assert the file, the
snapshot, the tag, the agent, and the collision refusal).
The whole flow was verified against a live Incus 6.0.4 daemon: running-box
refusal, export, overwrite guard, rm, import with and without --name,
re-home onto box-net, sibling re-import with distinct MACs and machine-ids,
pre-export file and snapshot present in both.
Closes #70
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 15:10:12 +00:00
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# export / import (#70) — a box's state that survives the box and the host.
|
|
|
|
|
|
# Usage errors and the pure pre-incus refusals are DRIVEN; every daemon-gated
|
|
|
|
|
|
# invariant is grep-guarded or line-order-asserted (fail-closed: an empty
|
|
|
|
|
|
# grep is a FAIL, so a deleted guard cannot ship green).
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
check "export without a box exits 2" 2 "usage: box export" "$BOX" export
|
|
|
|
|
|
check "export of an unknown box exits 1" 1 "no such box" "$BOX" export nosuchbox
|
|
|
|
|
|
check "import without a file exits 2" 2 "usage: box import" "$BOX" import
|
|
|
|
|
|
check "import of a missing file exits 1" 1 "no such file" "$BOX" import /nope/nothing.tar.gz
|
|
|
|
|
|
check "import --name with no value exits 2" 2 "--name needs a value" "$BOX" import x.tar.gz --name
|
|
|
|
|
|
# A file that is not an export artifact is named as such, before any incus
|
|
|
|
|
|
# call — pure (tar + awk), so it is driven, not grepped.
|
|
|
|
|
|
NOTATARBALL="$(mktemp)"; echo "not a tarball" > "$NOTATARBALL"
|
|
|
|
|
|
check "import: a non-artifact file is refused" 1 "not an incus/box export" "$BOX" import "$NOTATARBALL"
|
|
|
|
|
|
rm -f "$NOTATARBALL"
|
|
|
|
|
|
check "help export names the credential risk" 0 "CREDENTIAL" "$BOX" help export
|
|
|
|
|
|
check "help import names the re-stamping" 0 "user.box=1" "$BOX" help import
|
|
|
|
|
|
# Export refuses a running box — require_stopped fires BEFORE incus export
|
|
|
|
|
|
# (line order inside cmd_export, fail-closed on either grep missing).
|
|
|
|
|
|
# shellcheck disable=SC2016 # the $-strings are literals inside bash -c
|
|
|
|
|
|
check "export: requires the box stopped, before exporting" 0 "" bash -c '
|
|
|
|
|
|
fn="$(awk "/^cmd_export\(\) \{/,/^\}/" "'"$ROOT"'/bin/box")"
|
|
|
|
|
|
guard="$(printf "%s\n" "$fn" | grep -n "require_stopped" | head -1 | cut -d: -f1)"
|
|
|
|
|
|
run="$(printf "%s\n" "$fn" | grep -n "incus export" | head -1 | cut -d: -f1)"
|
|
|
|
|
|
[ -n "$guard" ] && [ -n "$run" ] && [ "$guard" -lt "$run" ]'
|
|
|
|
|
|
# Snapshots ride along by default; --instance-only is the explicit opt-out.
|
|
|
|
|
|
check "export: snapshots included unless --instance-only" 0 "" bash -c '
|
|
|
|
|
|
awk "/^cmd_export\(\) \{/,/^\}/" "'"$ROOT"'/bin/box" | grep -q -- "--instance-only"'
|
|
|
|
|
|
# The credential SHOUT (#70's scrub-or-shout decision: box shouts).
|
|
|
|
|
|
check "export: shouts that the file is a credential" 0 "" bash -c '
|
|
|
|
|
|
awk "/^cmd_export\(\) \{/,/^\}/" "'"$ROOT"'/bin/box" | grep -q "treat the file itself as a credential"'
|
|
|
|
|
|
# Import re-stamps the boundary tag onto the current stack.
|
|
|
|
|
|
check "import: re-stamps user.box=1" 0 "" bash -c '
|
|
|
|
|
|
awk "/^cmd_import\(\) \{/,/^\}/" "'"$ROOT"'/bin/box" | grep -q "user.box=1"'
|
|
|
|
|
|
# The name-collision guard fires BEFORE incus import — the resolve_box
|
|
|
|
|
|
# boundary from the other side: never occupy an existing instance's name.
|
|
|
|
|
|
# shellcheck disable=SC2016 # the $-strings are literals inside bash -c
|
|
|
|
|
|
check "import: the collision guard precedes the import" 0 "" bash -c '
|
|
|
|
|
|
fn="$(awk "/^cmd_import\(\) \{/,/^\}/" "'"$ROOT"'/bin/box")"
|
|
|
|
|
|
guard="$(printf "%s\n" "$fn" | grep -n "already exists" | head -1 | cut -d: -f1)"
|
|
|
|
|
|
run="$(printf "%s\n" "$fn" | grep -n "incus import" | head -1 | cut -d: -f1)"
|
|
|
|
|
|
[ -n "$guard" ] && [ -n "$run" ] && [ "$guard" -lt "$run" ]'
|
|
|
|
|
|
# Import lands on the placement contract: same pre-flight as a mint.
|
|
|
|
|
|
check "import: pre-flights the stack (require_stack)" 0 "" bash -c '
|
|
|
|
|
|
awk "/^cmd_import\(\) \{/,/^\}/" "'"$ROOT"'/bin/box" | grep -q "require_stack"'
|
|
|
|
|
|
# The artifact's MAC comes back verbatim, and a re-import beside a sibling
|
|
|
|
|
|
# collides at start (measured live: "MAC address already defined on another
|
|
|
|
|
|
# NIC") — the hwaddr unset must precede the start. Line order, fail-closed.
|
|
|
|
|
|
# shellcheck disable=SC2016 # the $-strings are literals inside bash -c
|
|
|
|
|
|
check "import: regenerates the NIC MAC before the start" 0 "" bash -c '
|
|
|
|
|
|
fn="$(awk "/^cmd_import\(\) \{/,/^\}/" "'"$ROOT"'/bin/box")"
|
|
|
|
|
|
mac="$(printf "%s\n" "$fn" | grep -n "hwaddr" | head -1 | cut -d: -f1)"
|
|
|
|
|
|
start="$(printf "%s\n" "$fn" | grep -n "incus start" | head -1 | cut -d: -f1)"
|
|
|
|
|
|
[ -n "$mac" ] && [ -n "$start" ] && [ "$mac" -lt "$start" ]'
|
|
|
|
|
|
# reset_identity runs AFTER the imported box is started — the clone trust
|
|
|
|
|
|
# boundary (machine-id → DHCP lease), line-order-asserted, fail-closed.
|
|
|
|
|
|
# shellcheck disable=SC2016 # the $-strings are literals inside bash -c
|
|
|
|
|
|
check "import: reset_identity follows the start" 0 "" bash -c '
|
|
|
|
|
|
fn="$(awk "/^cmd_import\(\) \{/,/^\}/" "'"$ROOT"'/bin/box")"
|
|
|
|
|
|
start="$(printf "%s\n" "$fn" | grep -n "incus start" | head -1 | cut -d: -f1)"
|
|
|
|
|
|
reset="$(printf "%s\n" "$fn" | grep -n "reset_identity" | head -1 | cut -d: -f1)"
|
|
|
|
|
|
[ -n "$start" ] && [ -n "$reset" ] && [ "$start" -lt "$reset" ]'
|
|
|
|
|
|
# The restricted tier can export: grant converges restricted.backups (the
|
|
|
|
|
|
# backup API is what 'incus export' rides; blocked by default — #70).
|
|
|
|
|
|
check "grant: allows backups (the export workflow)" 0 "" \
|
|
|
|
|
|
grep -qF 'restricted.backups allow' "$ROOT/host/grant-user.sh"
|
|
|
|
|
|
|
test+ci: the multi-user rehearsal, and CI that runs it on a real incus (#74)
drill/multiuser.sh: two throwaway users, real grants, real boxes, and the
contract measured from INSIDE them — #74's criteria (a)-(f) plus what the
findings added: the cross-user sibling drop, the closed private-bridge
escape hatches, incus-user re-sync survival, scoped revoke --purge. Its own
first runs are in RUNS.md (MU-1..3): both false FAILs were the instrument,
and both lessons (keep the mint's narration; probe a SIBLING's name, never
your own — /etc/hosts answers for you) are now comments in the script.
test/cli.sh: box_tier() driven against a shim id for all five cases
(including both-groups → admin: membership wins at the socket), the two
copies diffed byte-identical, the tier scripts' usage contracts driven for
real, and grep-guards on every load-bearing line a daemon-free run cannot
exercise — the expose guard's position, the boxnet-and-only-boxnet
narrowing, the snapshot allow, 'incus:snapshot restore'.
ci.yml: a second job stands up the real stack on the runner (setup-host,
doctor, then the rehearsal in container mode) — every PR now proves the
tier's semantics against a live daemon. The tier's mechanics are
instance-type-independent (the drop, the ACL, dns.mode and port_isolation
bind to boxnet, not the instance); the VM trust boundary itself stays a
real-hardware ritual, like the full drill.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 04:09:48 +00:00
|
|
|
|
# The rehearsal itself stays runnable: syntax-checked here, run on real hosts.
|
|
|
|
|
|
check "multiuser.sh is valid bash" 0 "" bash -n "$ROOT/drill/multiuser.sh"
|
|
|
|
|
|
check "multiuser.sh refuses without the env gate" 2 "opt in" \
|
|
|
|
|
|
bash "$ROOT/drill/multiuser.sh" --yes
|
|
|
|
|
|
check "grant-user.sh is valid bash" 0 "" bash -n "$ROOT/host/grant-user.sh"
|
|
|
|
|
|
check "revoke-user.sh is valid bash" 0 "" bash -n "$ROOT/host/revoke-user.sh"
|
test+ci: real installs driven offline, uninstall proven to zero residue
test/cli.sh drives REAL installer runs via BOX_INSTALL_SOURCE (temp
BOX_HOME/BOX_BIN, a fabricated second version for upgrades): fresh install
lands versioned, --version answers through the chain, side-by-side installs,
same-version no-op, BOX_REINSTALL, the 0.6.0 flat-tree migration, stale-
symlink healing, single-version and full uninstalls asserted to zero residue
(a planted survivor makes it scream INCOMPLETE), working-tree refusals, and
byte-identity of the existing_boxes copies. The rehearsal job now installs
via install.sh itself — CI proves the installer under review — and ends with
the uninstall drill: revoke --purge, teardown --yes, box uninstall, then
assert nothing is left (networks, profiles, nft, units, files, symlinks).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 16:02:37 +00:00
|
|
|
|
check "teardown-host.sh is valid bash" 0 "" bash -n "$ROOT/host/teardown-host.sh"
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# Revoke leaves NOTHING (the grant/revoke cleanliness pass). The gap this
|
|
|
|
|
|
# closes: --purge removed /var/lib/incus/users/<uid> but never RE-CHECKED it —
|
|
|
|
|
|
# the one path its own absence assert did not cover. And the stat must ride
|
|
|
|
|
|
# $SUDO: /var/lib/incus is not traversable by a non-root admin, so a bare
|
|
|
|
|
|
# [ -d ] answers "absent" for a directory that is very much there.
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
check "revoke: purge removes the incus-user state directory" 0 "" \
|
|
|
|
|
|
grep -qF '/var/lib/incus/users/' "$ROOT/host/revoke-user.sh"
|
|
|
|
|
|
check "revoke: the absence assert covers the incus-user state too" 0 "" \
|
|
|
|
|
|
bash -c 'awk "/Assert absence/,0" "'"$ROOT"'/host/revoke-user.sh" | grep -q "/var/lib/incus/users/"'
|
|
|
|
|
|
# shellcheck disable=SC2016 # the $-strings are literals in the target file
|
|
|
|
|
|
check "revoke: the state checks go through \$SUDO test (an unprivileged stat lies)" 0 "" \
|
|
|
|
|
|
grep -qF '$SUDO test -d "/var/lib/incus/users/$uid"' "$ROOT/host/revoke-user.sh"
|
|
|
|
|
|
|
test(cli): drive the #80 guard, the BOX_SUBNET plumb-through, and the signature
The two pure functions are extracted and driven, the same seam as box_tier
and load_template: a shim ip serves canned route tables (the poisoned
guest verbatim from #80's capture, the remapped-bridge workaround, a
healthy stack host), and each signature line is proven to fire alone and
to stay silent on the clean states.
Then the WHOLE setup-host is driven end to end under shims (fake incus and
sudo log every call; fake id keeps it unprivileged): the three refusals
(gateway-in-subnet, foreign interface, garbage/wrong-shape BOX_SUBNET)
exit 1 having made NO incus and NO sudo call — refuse-before-mutation is
asserted on the logs' absence, not assumed — the existing-bridge mismatch
refuses, the legitimate converge (boxnet's own prior claim) still reaches
'Host ready', and a fresh BOX_SUBNET=10.89.0.0/24 build lands
ipv4.address=10.89.0.1/24 on the bridge and destination: 10.89.0.1/32 in
the ACL, proving every derived value follows the one input.
Line-order guards pin the wiring: the subnet guard precedes the first
mutation in setup-host, the machine-local signature precedes the doctor's
daemon checks, probe_sig runs on both tiers, box-firewall reads the
gateway off the live bridge, and the drill/multiuser/migrate probes derive
their prefix from the network.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 19:47:20 +00:00
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# The #80 guard and BOX_SUBNET. setup-host run inside a box used to build a
|
|
|
|
|
|
# nested boxnet on the guest's own uplink subnet — captured gateway, duplicate
|
|
|
|
|
|
# routes, intermittent egress blackouts. The guard's two pure functions are
|
|
|
|
|
|
# extracted and DRIVEN (a shim ip serves canned route tables, the same seam as
|
|
|
|
|
|
# the shim id), and then the WHOLE script is driven end to end under shims:
|
|
|
|
|
|
# the refusal paths must exit 1 having touched nothing (the incus/sudo shims
|
|
|
|
|
|
# log every call, and the log must not exist), the converge path must still
|
|
|
|
|
|
# run, and BOX_SUBNET must plumb through to every derived value.
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
cat > "$SHIMDIR/ip" <<'SHIM'
|
|
|
|
|
|
#!/usr/bin/env bash
|
|
|
|
|
|
# Fake `ip`: canned tables for the #80 guard and signature — just the reads
|
|
|
|
|
|
# setup-host and doctor make. Specific patterns first: case takes the first hit.
|
|
|
|
|
|
case "$*" in
|
|
|
|
|
|
"-4 -o addr show dev boxnet") printf '%s\n' "${FAKE_IP4_BOXNET:-}" ;;
|
|
|
|
|
|
"-4 route show default") printf '%s\n' "${FAKE_IP4_DEFAULT:-}" ;;
|
|
|
|
|
|
"-4 route show") printf '%s\n' "${FAKE_IP4_ROUTES:-}" ;;
|
|
|
|
|
|
"-4 -o addr show") printf '%s\n' "${FAKE_IP4_ADDRS:-}" ;;
|
|
|
|
|
|
esac
|
|
|
|
|
|
exit 0
|
|
|
|
|
|
SHIM
|
|
|
|
|
|
chmod +x "$SHIMDIR/ip"
|
|
|
|
|
|
|
|
|
|
|
|
# The route tables, verbatim from issue #80's capture (the poisoned guest) and
|
|
|
|
|
|
# from the states around it.
|
|
|
|
|
|
D_INBOX='default via 10.88.0.1 dev enp5s0 proto dhcp src 10.88.0.202 metric 1024'
|
|
|
|
|
|
D_LAN='default via 192.168.1.1 dev eno1 proto dhcp metric 100'
|
|
|
|
|
|
A_GUEST='2: enp5s0 inet 10.88.0.202/24 metric 1024 brd 10.88.0.255 scope global dynamic enp5s0'
|
|
|
|
|
|
A_HOSTSTACK='2: eno1 inet 192.168.1.50/24 brd 192.168.1.255 scope global dynamic eno1
|
|
|
|
|
|
5: boxnet inet 10.88.0.1/24 scope global boxnet'
|
|
|
|
|
|
A_FOREIGN='2: eno1 inet 192.168.1.50/24 brd 192.168.1.255 scope global dynamic eno1
|
|
|
|
|
|
3: virbr7 inet 10.88.0.7/24 brd 10.88.0.255 scope global virbr7'
|
|
|
|
|
|
|
|
|
|
|
|
SUBFN="$(mktemp)"
|
|
|
|
|
|
awk '/^valid_subnet\(\) \{/,/^\}/' "$ROOT/host/setup-host.sh" > "$SUBFN"
|
|
|
|
|
|
check "valid_subnet: extracted from setup-host.sh (guards the awk)" 0 "return 1" cat "$SUBFN"
|
|
|
|
|
|
check "valid_subnet: the extracted function is valid bash" 0 "" bash -n "$SUBFN"
|
|
|
|
|
|
vsub() { bash -c ". '$SUBFN'; valid_subnet \"\$1\"" _ "$1"; }
|
|
|
|
|
|
check "valid_subnet: the default is valid" 0 "" vsub 10.88.0.0/24
|
|
|
|
|
|
check "valid_subnet: the documented escape hatch is valid" 0 "" vsub 10.89.0.0/24
|
|
|
|
|
|
check "valid_subnet: any a.b.c.0/24 is valid" 0 "" vsub 192.168.7.0/24
|
|
|
|
|
|
check "valid_subnet: not-a-/24 is refused" 1 "" vsub 10.88.0.0/16
|
|
|
|
|
|
check "valid_subnet: a nonzero host octet is refused" 1 "" vsub 10.88.0.5/24
|
|
|
|
|
|
check "valid_subnet: an octet past 255 is refused" 1 "" vsub 300.88.0.0/24
|
|
|
|
|
|
check "valid_subnet: a bare address is refused" 1 "" vsub 10.88.0.0
|
|
|
|
|
|
check "valid_subnet: garbage is refused" 1 "" vsub banana
|
|
|
|
|
|
check "valid_subnet: an empty value is refused" 1 "" vsub ""
|
|
|
|
|
|
rm -f "$SUBFN"
|
|
|
|
|
|
|
|
|
|
|
|
CLMFN="$(mktemp)"
|
|
|
|
|
|
awk '/^subnet_claimant\(\) \{/,/^\}/' "$ROOT/host/setup-host.sh" > "$CLMFN"
|
|
|
|
|
|
check "subnet_claimant: extracted from setup-host.sh (guards the awk)" 0 "DEFAULT GATEWAY" cat "$CLMFN"
|
|
|
|
|
|
check "subnet_claimant: the extracted function is valid bash" 0 "" bash -n "$CLMFN"
|
|
|
|
|
|
claim() { # claim <subnet> <default-route> <addrs>
|
|
|
|
|
|
FAKE_IP4_DEFAULT="$2" FAKE_IP4_ADDRS="$3" PATH="$SHIMDIR:$PATH" \
|
|
|
|
|
|
bash -c ". '$CLMFN'; subnet_claimant \"\$1\"" _ "$1"
|
|
|
|
|
|
}
|
|
|
|
|
|
check "claimant: the default gateway inside the target is the smoking gun" \
|
|
|
|
|
|
0 "DEFAULT GATEWAY" claim 10.88.0.0/24 "$D_INBOX" "$A_GUEST"
|
|
|
|
|
|
check "claimant: a foreign interface inside the target is named" \
|
|
|
|
|
|
0 "virbr7" claim 10.88.0.0/24 "$D_LAN" "$A_FOREIGN"
|
|
|
|
|
|
check "claimant: boxnet's own prior claim is the converge path — CLEAN" \
|
|
|
|
|
|
1 "" claim 10.88.0.0/24 "$D_LAN" "$A_HOSTSTACK"
|
|
|
|
|
|
check "claimant: a free subnet is clean" \
|
|
|
|
|
|
1 "" claim 10.89.0.0/24 "$D_LAN" "$A_HOSTSTACK"
|
|
|
|
|
|
check "claimant: 10.8.0.0/24 does not prefix-match 10.88.x (the dot terminates)" \
|
|
|
|
|
|
1 "" claim 10.8.0.0/24 "$D_INBOX" "$A_GUEST"
|
|
|
|
|
|
rm -f "$CLMFN"
|
|
|
|
|
|
|
test(cli): drive the four-case subnet decision — pin, converge, default, scan (#80)
choose_subnet is extracted with its helpers (the same awk seam as
subnet_claimant) and driven case by case against canned tables under the
shim ip: every pinned refusal unchanged (gateway-claimed, foreign
interface, disagreeing bridge, garbage — and a clearing pin used verbatim,
silently); bridge-present convergence to the bridge's own subnet with no
scan (case 2 precedes case 4) and no announcement on the plain default
re-run; the poisoned state (bridge AND uplink on one subnet) refusing
rather than converging; a free default staying 10.88; the nested tables
auto-picking 10.89 loudly, naming the DEFAULT GATEWAY claimant and the
BOX_SUBNET pin; a doubly-claimed host skipping to 10.90; and all 40
candidates claimed falling back to the old refusal, naming the end of the
scan range and BOX_SUBNET.
The driven whole-script fixtures move with the semantics: the refusal
paths now pin BOX_SUBNET=10.88.0.0/24 explicitly (the unpinned nested run
is no longer a refusal — it is the auto-pick, proven end to end: Host
ready, the announcement, and the bridge + ACL carve-out following the
pick to 10.89). The decision-precedes-mutation line-order check now pins
the choose_subnet call site. 339 → 370 checks.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 21:20:51 +00:00
|
|
|
|
# --- choose_subnet: the four-case decision, driven case by case -------------
|
|
|
|
|
|
# 1 explicit pin: honored or refused, never overridden. 2 no pin + bridge:
|
|
|
|
|
|
# converge to the bridge (the bridge IS the pin) — the scan never runs with a
|
|
|
|
|
|
# bridge present. 3 no pin, no bridge, default free: default. 4 default
|
|
|
|
|
|
# claimed: scan 10.89…10.127, first free wins, loudly; refuse when all claimed.
|
|
|
|
|
|
PICKFN="$(mktemp)"
|
|
|
|
|
|
awk '/^(valid_subnet|subnet_claimant|choose_subnet)\(\) \{/,/^\}/' \
|
|
|
|
|
|
"$ROOT/host/setup-host.sh" > "$PICKFN"
|
|
|
|
|
|
check "choose_subnet: extracted with its helpers (guards the awk)" 0 "auto-picked" cat "$PICKFN"
|
|
|
|
|
|
check "choose_subnet: subnet_claimant came along" 0 "DEFAULT GATEWAY" cat "$PICKFN"
|
|
|
|
|
|
check "choose_subnet: the extracted functions are valid bash" 0 "" bash -n "$PICKFN"
|
|
|
|
|
|
pick() { # pick <pin> <default-route> <addrs> [boxnet-addr]
|
|
|
|
|
|
FAKE_IP4_DEFAULT="$2" FAKE_IP4_ADDRS="$3" FAKE_IP4_BOXNET="${4:-}" PATH="$SHIMDIR:$PATH" \
|
|
|
|
|
|
bash -c ". '$PICKFN'; choose_subnet \"\$1\"" _ "$1"
|
|
|
|
|
|
}
|
|
|
|
|
|
pickout() { pick "$@" 2>/dev/null; } # stdout only: the choice itself
|
|
|
|
|
|
pickquiet() { [ -z "$(pick "$@" 2>&1 >/dev/null)" ]; } # stderr must be EMPTY
|
|
|
|
|
|
picknoscan(){ ! pick "$@" 2>&1 | grep -qF auto-picked; }
|
|
|
|
|
|
|
|
|
|
|
|
# The bridge lines and the both-claimed / all-claimed address tables.
|
|
|
|
|
|
B_88='5: boxnet inet 10.88.0.1/24 scope global boxnet'
|
|
|
|
|
|
B_89='5: boxnet inet 10.89.0.1/24 scope global boxnet'
|
|
|
|
|
|
A_TWOCLAIM="$A_GUEST
|
|
|
|
|
|
3: virbr7 inet 10.89.0.7/24 brd 10.89.0.255 scope global virbr7"
|
|
|
|
|
|
A_ALLCLAIM="$(for b in $(seq 88 127); do
|
|
|
|
|
|
printf '%d: virbr%d inet 10.%d.0.7/24 brd 10.%d.0.255 scope global virbr%d\n' \
|
|
|
|
|
|
"$((b - 85))" "$((b - 87))" "$b" "$b" "$((b - 87))"
|
|
|
|
|
|
done)"
|
|
|
|
|
|
|
|
|
|
|
|
# Case 1 — the pin. Refusals identical in spirit to the pre-autopick gate.
|
|
|
|
|
|
check "pick: pinned + gw-in-subnet REFUSES, names issue #80" \
|
|
|
|
|
|
1 "issue #80" pick 10.88.0.0/24 "$D_INBOX" "$A_GUEST"
|
|
|
|
|
|
check "pick: pinned + foreign interface REFUSES, names it" \
|
|
|
|
|
|
1 "virbr7" pick 10.88.0.0/24 "$D_LAN" "$A_FOREIGN"
|
|
|
|
|
|
check "pick: a pinned refusal still names BOX_SUBNET" \
|
|
|
|
|
|
1 "BOX_SUBNET" pick 10.88.0.0/24 "$D_INBOX" "$A_GUEST"
|
|
|
|
|
|
check "pick: pinned against a disagreeing bridge REFUSES (never re-addresses)" \
|
|
|
|
|
|
1 "never re-addresses" pick 10.88.0.0/24 "$D_LAN" "$A_HOSTSTACK" "$B_89"
|
|
|
|
|
|
check "pick: a garbage pin is refused by name" \
|
|
|
|
|
|
1 "not a sane subnet" pick banana "$D_LAN" "$A_HOSTSTACK"
|
|
|
|
|
|
check "pick: a pin that clears the gate is used verbatim" \
|
|
|
|
|
|
0 "10.89.0.0/24" pickout 10.89.0.0/24 "$D_INBOX" "$A_GUEST"
|
|
|
|
|
|
check "pick: ...silently — a pin is the operator talking, not us" \
|
|
|
|
|
|
0 "" pickquiet 10.89.0.0/24 "$D_INBOX" "$A_GUEST"
|
|
|
|
|
|
|
|
|
|
|
|
# Case 2 — no pin, a bridge: converge to ITS subnet. No refusal, no scan —
|
|
|
|
|
|
# even when the default is claimed (THIS machine: nested stack, uplink on
|
|
|
|
|
|
# 10.88, bridge remapped to 10.89 — the #80 workaround host, bare re-run).
|
|
|
|
|
|
check "pick: bridge present converges to the bridge's own subnet" \
|
|
|
|
|
|
0 "10.89.0.0/24" pickout "" "$D_INBOX" "$A_GUEST
|
|
|
|
|
|
$B_89" "$B_89"
|
|
|
|
|
|
check "pick: ...announcing the convergence (an off-default bridge is worth a line)" \
|
|
|
|
|
|
0 "converging" pick "" "$D_INBOX" "$A_GUEST
|
|
|
|
|
|
$B_89" "$B_89"
|
|
|
|
|
|
check "pick: ...and the scan never ran (case 2 precedes case 4)" \
|
|
|
|
|
|
0 "" picknoscan "" "$D_INBOX" "$A_GUEST
|
|
|
|
|
|
$B_89" "$B_89"
|
|
|
|
|
|
check "pick: bridge on the DEFAULT subnet converges silently (plain re-run)" \
|
|
|
|
|
|
0 "" pickquiet "" "$D_LAN" "$A_HOSTSTACK" "$B_88"
|
|
|
|
|
|
check "pick: ...to the default" \
|
|
|
|
|
|
0 "10.88.0.0/24" pickout "" "$D_LAN" "$A_HOSTSTACK" "$B_88"
|
|
|
|
|
|
# The poisoned state (#80 verbatim: bridge AND uplink both on 10.88) must not
|
|
|
|
|
|
# converge — rebuilding there re-arms the blackouts. Refuse, name the fix.
|
|
|
|
|
|
check "pick: a bridge on a FOREIGN-claimed subnet refuses (the poisoned state)" \
|
|
|
|
|
|
1 "poisoned" pick "" "$D_INBOX" "$A_GUEST
|
|
|
|
|
|
$B_88" "$B_88"
|
|
|
|
|
|
check "pick: ...naming the bridge move as the fix" \
|
|
|
|
|
|
1 "ipv4.address" pick "" "$D_INBOX" "$A_GUEST
|
|
|
|
|
|
$B_88" "$B_88"
|
|
|
|
|
|
|
|
|
|
|
|
# Case 3 — no pin, no bridge, default free: the default, silently.
|
|
|
|
|
|
check "pick: a free default host gets 10.88.0.0/24" \
|
|
|
|
|
|
0 "10.88.0.0/24" pickout "" "$D_LAN" ""
|
|
|
|
|
|
check "pick: ...with no announcement" 0 "" pickquiet "" "$D_LAN" ""
|
|
|
|
|
|
|
|
|
|
|
|
# Case 4 — no pin, no bridge, default claimed: the nested case. First free
|
|
|
|
|
|
# candidate wins, the announcement names the claimant and the pin.
|
|
|
|
|
|
check "pick: default claimed by the gateway auto-picks 10.89.0.0/24" \
|
|
|
|
|
|
0 "10.89.0.0/24" pickout "" "$D_INBOX" "$A_GUEST"
|
|
|
|
|
|
check "pick: ...saying so loudly" \
|
|
|
|
|
|
0 "auto-picked 10.89.0.0/24" pick "" "$D_INBOX" "$A_GUEST"
|
|
|
|
|
|
check "pick: ...naming WHY (the machine's own gateway = inside a box)" \
|
|
|
|
|
|
0 "DEFAULT GATEWAY" pick "" "$D_INBOX" "$A_GUEST"
|
|
|
|
|
|
check "pick: ...and how to pin it for scripts" \
|
|
|
|
|
|
0 "BOX_SUBNET=10.89.0.0/24" pick "" "$D_INBOX" "$A_GUEST"
|
|
|
|
|
|
check "pick: default AND 10.89 claimed skips to 10.90.0.0/24" \
|
|
|
|
|
|
0 "10.90.0.0/24" pickout "" "$D_INBOX" "$A_TWOCLAIM"
|
|
|
|
|
|
check "pick: every candidate claimed → the old refusal" \
|
|
|
|
|
|
1 "refusing to build boxnet" pick "" "$D_LAN" "$A_ALLCLAIM"
|
|
|
|
|
|
check "pick: ...naming the end of the scan range" \
|
|
|
|
|
|
1 "10.127.0.0/24" pick "" "$D_LAN" "$A_ALLCLAIM"
|
|
|
|
|
|
check "pick: ...and BOX_SUBNET as the way out" \
|
|
|
|
|
|
1 "BOX_SUBNET" pick "" "$D_LAN" "$A_ALLCLAIM"
|
|
|
|
|
|
rm -f "$PICKFN"
|
|
|
|
|
|
|
test(cli): drive the #80 guard, the BOX_SUBNET plumb-through, and the signature
The two pure functions are extracted and driven, the same seam as box_tier
and load_template: a shim ip serves canned route tables (the poisoned
guest verbatim from #80's capture, the remapped-bridge workaround, a
healthy stack host), and each signature line is proven to fire alone and
to stay silent on the clean states.
Then the WHOLE setup-host is driven end to end under shims (fake incus and
sudo log every call; fake id keeps it unprivileged): the three refusals
(gateway-in-subnet, foreign interface, garbage/wrong-shape BOX_SUBNET)
exit 1 having made NO incus and NO sudo call — refuse-before-mutation is
asserted on the logs' absence, not assumed — the existing-bridge mismatch
refuses, the legitimate converge (boxnet's own prior claim) still reaches
'Host ready', and a fresh BOX_SUBNET=10.89.0.0/24 build lands
ipv4.address=10.89.0.1/24 on the bridge and destination: 10.89.0.1/32 in
the ACL, proving every derived value follows the one input.
Line-order guards pin the wiring: the subnet guard precedes the first
mutation in setup-host, the machine-local signature precedes the doctor's
daemon checks, probe_sig runs on both tiers, box-firewall reads the
gateway off the live bridge, and the drill/multiuser/migrate probes derive
their prefix from the network.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 19:47:20 +00:00
|
|
|
|
# --- the whole script, driven: refuse-before-mutation, converge, plumb-through
|
|
|
|
|
|
SETUPSHIM="$(mktemp -d)"
|
|
|
|
|
|
cat > "$SETUPSHIM/incus" <<'SHIM'
|
|
|
|
|
|
#!/usr/bin/env bash
|
|
|
|
|
|
# Fake incus for the driven setup-host: records every call (and, for the
|
|
|
|
|
|
# stdin verbs, the stdin) to $FAKE_INCUS_LOG, answers the existence probes
|
|
|
|
|
|
# from FAKE_HAVE_*, and never goes near a daemon.
|
|
|
|
|
|
[ -n "${FAKE_INCUS_LOG:-}" ] && printf 'incus %s\n' "$*" >> "$FAKE_INCUS_LOG"
|
|
|
|
|
|
case "$*" in
|
|
|
|
|
|
*"admin init --preseed"*|*"acl edit"*|*"profile edit"*)
|
|
|
|
|
|
if [ -n "${FAKE_INCUS_LOG:-}" ]; then sed 's/^/ | /' >> "$FAKE_INCUS_LOG"; else cat >/dev/null; fi ;;
|
|
|
|
|
|
esac
|
|
|
|
|
|
case "$*" in
|
|
|
|
|
|
"storage show default") [ -n "${FAKE_HAVE_STORAGE:-}" ] || exit 1 ;;
|
|
|
|
|
|
"network show boxnet") [ -n "${FAKE_HAVE_BOXNET:-}" ] || exit 1 ;;
|
|
|
|
|
|
"network acl show box-isolate") [ -n "${FAKE_HAVE_ACL:-}" ] || exit 1 ;;
|
|
|
|
|
|
"profile show box-net") [ -n "${FAKE_HAVE_PROFILE:-}" ] || exit 1 ;;
|
|
|
|
|
|
esac
|
|
|
|
|
|
exit 0
|
|
|
|
|
|
SHIM
|
|
|
|
|
|
cat > "$SETUPSHIM/sudo" <<'SHIM'
|
|
|
|
|
|
#!/usr/bin/env bash
|
|
|
|
|
|
# Fake sudo: logs to $FAKE_SUDO_LOG and swallows everything — the driven
|
|
|
|
|
|
# setup-host must never mutate the machine running this suite.
|
|
|
|
|
|
[ -n "${FAKE_SUDO_LOG:-}" ] && printf 'sudo %s\n' "$*" >> "$FAKE_SUDO_LOG"
|
|
|
|
|
|
exit 0
|
|
|
|
|
|
SHIM
|
|
|
|
|
|
chmod +x "$SETUPSHIM/incus" "$SETUPSHIM/sudo"
|
|
|
|
|
|
|
|
|
|
|
|
runsetup() { # runsetup [VAR=val ...] — the real setup-host, under shims
|
|
|
|
|
|
env FAKE_UID=1000 FAKE_GROUPS="users incus-admin" \
|
|
|
|
|
|
PATH="$SETUPSHIM:$SHIMDIR:$PATH" "$@" bash "$ROOT/host/setup-host.sh"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
W80="$(mktemp -d)"
|
test(cli): drive the four-case subnet decision — pin, converge, default, scan (#80)
choose_subnet is extracted with its helpers (the same awk seam as
subnet_claimant) and driven case by case against canned tables under the
shim ip: every pinned refusal unchanged (gateway-claimed, foreign
interface, disagreeing bridge, garbage — and a clearing pin used verbatim,
silently); bridge-present convergence to the bridge's own subnet with no
scan (case 2 precedes case 4) and no announcement on the plain default
re-run; the poisoned state (bridge AND uplink on one subnet) refusing
rather than converging; a free default staying 10.88; the nested tables
auto-picking 10.89 loudly, naming the DEFAULT GATEWAY claimant and the
BOX_SUBNET pin; a doubly-claimed host skipping to 10.90; and all 40
candidates claimed falling back to the old refusal, naming the end of the
scan range and BOX_SUBNET.
The driven whole-script fixtures move with the semantics: the refusal
paths now pin BOX_SUBNET=10.88.0.0/24 explicitly (the unpinned nested run
is no longer a refusal — it is the auto-pick, proven end to end: Host
ready, the announcement, and the bridge + ACL carve-out following the
pick to 10.89). The decision-precedes-mutation line-order check now pins
the choose_subnet call site. 339 → 370 checks.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 21:20:51 +00:00
|
|
|
|
# Refusal 1: an EXPLICIT pin on the subnet the default gateway sits inside —
|
|
|
|
|
|
# the inside of a box, and the operator said 10.88 out loud. A pin is never
|
|
|
|
|
|
# silently overridden, so this refuses exactly as it did pre-autopick.
|
|
|
|
|
|
check "setup-host: a pinned gw-claimed subnet REFUSES and names issue #80" 1 "issue #80" \
|
|
|
|
|
|
runsetup BOX_SUBNET=10.88.0.0/24 FAKE_IP4_DEFAULT="$D_INBOX" FAKE_IP4_ADDRS="$A_GUEST" \
|
test(cli): drive the #80 guard, the BOX_SUBNET plumb-through, and the signature
The two pure functions are extracted and driven, the same seam as box_tier
and load_template: a shim ip serves canned route tables (the poisoned
guest verbatim from #80's capture, the remapped-bridge workaround, a
healthy stack host), and each signature line is proven to fire alone and
to stay silent on the clean states.
Then the WHOLE setup-host is driven end to end under shims (fake incus and
sudo log every call; fake id keeps it unprivileged): the three refusals
(gateway-in-subnet, foreign interface, garbage/wrong-shape BOX_SUBNET)
exit 1 having made NO incus and NO sudo call — refuse-before-mutation is
asserted on the logs' absence, not assumed — the existing-bridge mismatch
refuses, the legitimate converge (boxnet's own prior claim) still reaches
'Host ready', and a fresh BOX_SUBNET=10.89.0.0/24 build lands
ipv4.address=10.89.0.1/24 on the bridge and destination: 10.89.0.1/32 in
the ACL, proving every derived value follows the one input.
Line-order guards pin the wiring: the subnet guard precedes the first
mutation in setup-host, the machine-local signature precedes the doctor's
daemon checks, probe_sig runs on both tiers, box-firewall reads the
gateway off the live bridge, and the drill/multiuser/migrate probes derive
their prefix from the network.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 19:47:20 +00:00
|
|
|
|
FAKE_INCUS_LOG="$W80/g1.log" FAKE_SUDO_LOG="$W80/s1.log"
|
|
|
|
|
|
check "setup-host: ...naming BOX_SUBNET as the way out" 1 "BOX_SUBNET" \
|
test(cli): drive the four-case subnet decision — pin, converge, default, scan (#80)
choose_subnet is extracted with its helpers (the same awk seam as
subnet_claimant) and driven case by case against canned tables under the
shim ip: every pinned refusal unchanged (gateway-claimed, foreign
interface, disagreeing bridge, garbage — and a clearing pin used verbatim,
silently); bridge-present convergence to the bridge's own subnet with no
scan (case 2 precedes case 4) and no announcement on the plain default
re-run; the poisoned state (bridge AND uplink on one subnet) refusing
rather than converging; a free default staying 10.88; the nested tables
auto-picking 10.89 loudly, naming the DEFAULT GATEWAY claimant and the
BOX_SUBNET pin; a doubly-claimed host skipping to 10.90; and all 40
candidates claimed falling back to the old refusal, naming the end of the
scan range and BOX_SUBNET.
The driven whole-script fixtures move with the semantics: the refusal
paths now pin BOX_SUBNET=10.88.0.0/24 explicitly (the unpinned nested run
is no longer a refusal — it is the auto-pick, proven end to end: Host
ready, the announcement, and the bridge + ACL carve-out following the
pick to 10.89). The decision-precedes-mutation line-order check now pins
the choose_subnet call site. 339 → 370 checks.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 21:20:51 +00:00
|
|
|
|
runsetup BOX_SUBNET=10.88.0.0/24 FAKE_IP4_DEFAULT="$D_INBOX" FAKE_IP4_ADDRS="$A_GUEST"
|
test(cli): drive the #80 guard, the BOX_SUBNET plumb-through, and the signature
The two pure functions are extracted and driven, the same seam as box_tier
and load_template: a shim ip serves canned route tables (the poisoned
guest verbatim from #80's capture, the remapped-bridge workaround, a
healthy stack host), and each signature line is proven to fire alone and
to stay silent on the clean states.
Then the WHOLE setup-host is driven end to end under shims (fake incus and
sudo log every call; fake id keeps it unprivileged): the three refusals
(gateway-in-subnet, foreign interface, garbage/wrong-shape BOX_SUBNET)
exit 1 having made NO incus and NO sudo call — refuse-before-mutation is
asserted on the logs' absence, not assumed — the existing-bridge mismatch
refuses, the legitimate converge (boxnet's own prior claim) still reaches
'Host ready', and a fresh BOX_SUBNET=10.89.0.0/24 build lands
ipv4.address=10.89.0.1/24 on the bridge and destination: 10.89.0.1/32 in
the ACL, proving every derived value follows the one input.
Line-order guards pin the wiring: the subnet guard precedes the first
mutation in setup-host, the machine-local signature precedes the doctor's
daemon checks, probe_sig runs on both tiers, box-firewall reads the
gateway off the live bridge, and the drill/multiuser/migrate probes derive
their prefix from the network.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 19:47:20 +00:00
|
|
|
|
check "setup-host: the refusal made NO incus call (refuse precedes mutation)" 1 "" \
|
|
|
|
|
|
test -e "$W80/g1.log"
|
|
|
|
|
|
check "setup-host: the refusal made NO sudo call either" 1 "" \
|
|
|
|
|
|
test -e "$W80/s1.log"
|
test(cli): drive the four-case subnet decision — pin, converge, default, scan (#80)
choose_subnet is extracted with its helpers (the same awk seam as
subnet_claimant) and driven case by case against canned tables under the
shim ip: every pinned refusal unchanged (gateway-claimed, foreign
interface, disagreeing bridge, garbage — and a clearing pin used verbatim,
silently); bridge-present convergence to the bridge's own subnet with no
scan (case 2 precedes case 4) and no announcement on the plain default
re-run; the poisoned state (bridge AND uplink on one subnet) refusing
rather than converging; a free default staying 10.88; the nested tables
auto-picking 10.89 loudly, naming the DEFAULT GATEWAY claimant and the
BOX_SUBNET pin; a doubly-claimed host skipping to 10.90; and all 40
candidates claimed falling back to the old refusal, naming the end of the
scan range and BOX_SUBNET.
The driven whole-script fixtures move with the semantics: the refusal
paths now pin BOX_SUBNET=10.88.0.0/24 explicitly (the unpinned nested run
is no longer a refusal — it is the auto-pick, proven end to end: Host
ready, the announcement, and the bridge + ACL carve-out following the
pick to 10.89). The decision-precedes-mutation line-order check now pins
the choose_subnet call site. 339 → 370 checks.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 21:20:51 +00:00
|
|
|
|
# Refusal 2: a pin on a subnet a foreign interface owns an address inside.
|
|
|
|
|
|
check "setup-host: a pinned foreign-claimed subnet REFUSES" 1 "virbr7" \
|
|
|
|
|
|
runsetup BOX_SUBNET=10.88.0.0/24 FAKE_IP4_DEFAULT="$D_LAN" FAKE_IP4_ADDRS="$A_FOREIGN"
|
test(cli): drive the #80 guard, the BOX_SUBNET plumb-through, and the signature
The two pure functions are extracted and driven, the same seam as box_tier
and load_template: a shim ip serves canned route tables (the poisoned
guest verbatim from #80's capture, the remapped-bridge workaround, a
healthy stack host), and each signature line is proven to fire alone and
to stay silent on the clean states.
Then the WHOLE setup-host is driven end to end under shims (fake incus and
sudo log every call; fake id keeps it unprivileged): the three refusals
(gateway-in-subnet, foreign interface, garbage/wrong-shape BOX_SUBNET)
exit 1 having made NO incus and NO sudo call — refuse-before-mutation is
asserted on the logs' absence, not assumed — the existing-bridge mismatch
refuses, the legitimate converge (boxnet's own prior claim) still reaches
'Host ready', and a fresh BOX_SUBNET=10.89.0.0/24 build lands
ipv4.address=10.89.0.1/24 on the bridge and destination: 10.89.0.1/32 in
the ACL, proving every derived value follows the one input.
Line-order guards pin the wiring: the subnet guard precedes the first
mutation in setup-host, the machine-local signature precedes the doctor's
daemon checks, probe_sig runs on both tiers, box-firewall reads the
gateway off the live bridge, and the drill/multiuser/migrate probes derive
their prefix from the network.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 19:47:20 +00:00
|
|
|
|
# Refusal 3: garbage BOX_SUBNET dies at the gate.
|
|
|
|
|
|
check "setup-host: a garbage BOX_SUBNET is refused by name" 1 "not a sane subnet" \
|
|
|
|
|
|
runsetup BOX_SUBNET=banana
|
|
|
|
|
|
check "setup-host: a /16 BOX_SUBNET is refused" 1 "not a sane subnet" \
|
|
|
|
|
|
runsetup BOX_SUBNET=10.88.0.0/16
|
|
|
|
|
|
# Refusal 4: an existing bridge on ANOTHER subnet is never re-addressed.
|
|
|
|
|
|
check "setup-host: a bridge on another subnet refuses (converge, don't re-address)" \
|
|
|
|
|
|
1 "never re-addresses" \
|
|
|
|
|
|
runsetup FAKE_IP4_DEFAULT="$D_LAN" FAKE_IP4_ADDRS="$A_HOSTSTACK" \
|
|
|
|
|
|
FAKE_IP4_BOXNET='5: boxnet inet 10.89.0.1/24 scope global boxnet' \
|
|
|
|
|
|
BOX_SUBNET=10.88.0.0/24
|
|
|
|
|
|
# The legitimate re-run: boxnet itself owns the subnet — setup-host converges.
|
|
|
|
|
|
check "setup-host: a prior boxnet claiming the subnet CONVERGES (no false positive)" \
|
|
|
|
|
|
0 "Host ready" \
|
|
|
|
|
|
runsetup FAKE_IP4_DEFAULT="$D_LAN" FAKE_IP4_ADDRS="$A_HOSTSTACK" \
|
|
|
|
|
|
FAKE_IP4_BOXNET='5: boxnet inet 10.88.0.1/24 scope global boxnet' \
|
|
|
|
|
|
FAKE_HAVE_STORAGE=1 FAKE_HAVE_BOXNET=1 FAKE_HAVE_ACL=1 FAKE_HAVE_PROFILE=1
|
|
|
|
|
|
# BOX_SUBNET plumbs through: a fresh build on 10.89.0.0/24 must derive EVERY
|
|
|
|
|
|
# value from it — the bridge address and the ACL's gateway carve-out.
|
|
|
|
|
|
check "setup-host: BOX_SUBNET drives a fresh build to completion" 0 "Host ready" \
|
|
|
|
|
|
runsetup BOX_SUBNET=10.89.0.0/24 FAKE_IP4_DEFAULT="$D_INBOX" FAKE_IP4_ADDRS="$A_GUEST" \
|
|
|
|
|
|
FAKE_INCUS_LOG="$W80/g2.log" FAKE_SUDO_LOG="$W80/s2.log"
|
|
|
|
|
|
check "setup-host: ...the bridge derives from BOX_SUBNET" 0 "" \
|
|
|
|
|
|
grep -qF 'network create boxnet ipv4.address=10.89.0.1/24' "$W80/g2.log"
|
|
|
|
|
|
check "setup-host: ...and so does the ACL's gateway carve-out" 0 "" \
|
|
|
|
|
|
grep -qF 'destination: 10.89.0.1/32' "$W80/g2.log"
|
test(cli): drive the four-case subnet decision — pin, converge, default, scan (#80)
choose_subnet is extracted with its helpers (the same awk seam as
subnet_claimant) and driven case by case against canned tables under the
shim ip: every pinned refusal unchanged (gateway-claimed, foreign
interface, disagreeing bridge, garbage — and a clearing pin used verbatim,
silently); bridge-present convergence to the bridge's own subnet with no
scan (case 2 precedes case 4) and no announcement on the plain default
re-run; the poisoned state (bridge AND uplink on one subnet) refusing
rather than converging; a free default staying 10.88; the nested tables
auto-picking 10.89 loudly, naming the DEFAULT GATEWAY claimant and the
BOX_SUBNET pin; a doubly-claimed host skipping to 10.90; and all 40
candidates claimed falling back to the old refusal, naming the end of the
scan range and BOX_SUBNET.
The driven whole-script fixtures move with the semantics: the refusal
paths now pin BOX_SUBNET=10.88.0.0/24 explicitly (the unpinned nested run
is no longer a refusal — it is the auto-pick, proven end to end: Host
ready, the announcement, and the bridge + ACL carve-out following the
pick to 10.89). The decision-precedes-mutation line-order check now pins
the choose_subnet call site. 339 → 370 checks.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 21:20:51 +00:00
|
|
|
|
# The nested case with ZERO flags — #80's tables, no pin, no bridge: the
|
|
|
|
|
|
# auto-pick must land the whole build on 10.89, announced, and every derived
|
|
|
|
|
|
# value must follow the pick, not the default.
|
|
|
|
|
|
check "setup-host: nested with no flags auto-picks and completes" 0 "Host ready" \
|
|
|
|
|
|
runsetup FAKE_IP4_DEFAULT="$D_INBOX" FAKE_IP4_ADDRS="$A_GUEST" \
|
|
|
|
|
|
FAKE_INCUS_LOG="$W80/g3.log" FAKE_SUDO_LOG="$W80/s3.log"
|
|
|
|
|
|
check "setup-host: ...announcing the auto-pick" 0 "auto-picked 10.89.0.0/24" \
|
|
|
|
|
|
runsetup FAKE_IP4_DEFAULT="$D_INBOX" FAKE_IP4_ADDRS="$A_GUEST"
|
|
|
|
|
|
check "setup-host: ...the bridge follows the pick" 0 "" \
|
|
|
|
|
|
grep -qF 'network create boxnet ipv4.address=10.89.0.1/24' "$W80/g3.log"
|
|
|
|
|
|
check "setup-host: ...the ACL carve-out follows the pick" 0 "" \
|
|
|
|
|
|
grep -qF 'destination: 10.89.0.1/32' "$W80/g3.log"
|
test(cli): drive the #80 guard, the BOX_SUBNET plumb-through, and the signature
The two pure functions are extracted and driven, the same seam as box_tier
and load_template: a shim ip serves canned route tables (the poisoned
guest verbatim from #80's capture, the remapped-bridge workaround, a
healthy stack host), and each signature line is proven to fire alone and
to stay silent on the clean states.
Then the WHOLE setup-host is driven end to end under shims (fake incus and
sudo log every call; fake id keeps it unprivileged): the three refusals
(gateway-in-subnet, foreign interface, garbage/wrong-shape BOX_SUBNET)
exit 1 having made NO incus and NO sudo call — refuse-before-mutation is
asserted on the logs' absence, not assumed — the existing-bridge mismatch
refuses, the legitimate converge (boxnet's own prior claim) still reaches
'Host ready', and a fresh BOX_SUBNET=10.89.0.0/24 build lands
ipv4.address=10.89.0.1/24 on the bridge and destination: 10.89.0.1/32 in
the ACL, proving every derived value follows the one input.
Line-order guards pin the wiring: the subnet guard precedes the first
mutation in setup-host, the machine-local signature precedes the doctor's
daemon checks, probe_sig runs on both tiers, box-firewall reads the
gateway off the live bridge, and the drill/multiuser/migrate probes derive
their prefix from the network.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 19:47:20 +00:00
|
|
|
|
rm -rf "$W80" "$SETUPSHIM"
|
|
|
|
|
|
|
test(cli): drive the four-case subnet decision — pin, converge, default, scan (#80)
choose_subnet is extracted with its helpers (the same awk seam as
subnet_claimant) and driven case by case against canned tables under the
shim ip: every pinned refusal unchanged (gateway-claimed, foreign
interface, disagreeing bridge, garbage — and a clearing pin used verbatim,
silently); bridge-present convergence to the bridge's own subnet with no
scan (case 2 precedes case 4) and no announcement on the plain default
re-run; the poisoned state (bridge AND uplink on one subnet) refusing
rather than converging; a free default staying 10.88; the nested tables
auto-picking 10.89 loudly, naming the DEFAULT GATEWAY claimant and the
BOX_SUBNET pin; a doubly-claimed host skipping to 10.90; and all 40
candidates claimed falling back to the old refusal, naming the end of the
scan range and BOX_SUBNET.
The driven whole-script fixtures move with the semantics: the refusal
paths now pin BOX_SUBNET=10.88.0.0/24 explicitly (the unpinned nested run
is no longer a refusal — it is the auto-pick, proven end to end: Host
ready, the announcement, and the bridge + ACL carve-out following the
pick to 10.89). The decision-precedes-mutation line-order check now pins
the choose_subnet call site. 339 → 370 checks.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 21:20:51 +00:00
|
|
|
|
# The decision must be the FIRST effective act — before the incus install, the
|
test(cli): drive the #80 guard, the BOX_SUBNET plumb-through, and the signature
The two pure functions are extracted and driven, the same seam as box_tier
and load_template: a shim ip serves canned route tables (the poisoned
guest verbatim from #80's capture, the remapped-bridge workaround, a
healthy stack host), and each signature line is proven to fire alone and
to stay silent on the clean states.
Then the WHOLE setup-host is driven end to end under shims (fake incus and
sudo log every call; fake id keeps it unprivileged): the three refusals
(gateway-in-subnet, foreign interface, garbage/wrong-shape BOX_SUBNET)
exit 1 having made NO incus and NO sudo call — refuse-before-mutation is
asserted on the logs' absence, not assumed — the existing-bridge mismatch
refuses, the legitimate converge (boxnet's own prior claim) still reaches
'Host ready', and a fresh BOX_SUBNET=10.89.0.0/24 build lands
ipv4.address=10.89.0.1/24 on the bridge and destination: 10.89.0.1/32 in
the ACL, proving every derived value follows the one input.
Line-order guards pin the wiring: the subnet guard precedes the first
mutation in setup-host, the machine-local signature precedes the doctor's
daemon checks, probe_sig runs on both tiers, box-firewall reads the
gateway off the live bridge, and the drill/multiuser/migrate probes derive
their prefix from the network.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 19:47:20 +00:00
|
|
|
|
# usermod, every apt call. Line order, fail-closed on either grep missing.
|
|
|
|
|
|
# shellcheck disable=SC2016 # the $-strings are literals in the target file
|
test(cli): drive the four-case subnet decision — pin, converge, default, scan (#80)
choose_subnet is extracted with its helpers (the same awk seam as
subnet_claimant) and driven case by case against canned tables under the
shim ip: every pinned refusal unchanged (gateway-claimed, foreign
interface, disagreeing bridge, garbage — and a clearing pin used verbatim,
silently); bridge-present convergence to the bridge's own subnet with no
scan (case 2 precedes case 4) and no announcement on the plain default
re-run; the poisoned state (bridge AND uplink on one subnet) refusing
rather than converging; a free default staying 10.88; the nested tables
auto-picking 10.89 loudly, naming the DEFAULT GATEWAY claimant and the
BOX_SUBNET pin; a doubly-claimed host skipping to 10.90; and all 40
candidates claimed falling back to the old refusal, naming the end of the
scan range and BOX_SUBNET.
The driven whole-script fixtures move with the semantics: the refusal
paths now pin BOX_SUBNET=10.88.0.0/24 explicitly (the unpinned nested run
is no longer a refusal — it is the auto-pick, proven end to end: Host
ready, the announcement, and the bridge + ACL carve-out following the
pick to 10.89). The decision-precedes-mutation line-order check now pins
the choose_subnet call site. 339 → 370 checks.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 21:20:51 +00:00
|
|
|
|
check "setup-host: the subnet decision precedes the first mutation" 0 "" bash -c '
|
|
|
|
|
|
guard="$(grep -n "^BOX_SUBNET=\"\$(choose_subnet " "'"$ROOT"'/host/setup-host.sh" | head -1 | cut -d: -f1)"
|
test(cli): drive the #80 guard, the BOX_SUBNET plumb-through, and the signature
The two pure functions are extracted and driven, the same seam as box_tier
and load_template: a shim ip serves canned route tables (the poisoned
guest verbatim from #80's capture, the remapped-bridge workaround, a
healthy stack host), and each signature line is proven to fire alone and
to stay silent on the clean states.
Then the WHOLE setup-host is driven end to end under shims (fake incus and
sudo log every call; fake id keeps it unprivileged): the three refusals
(gateway-in-subnet, foreign interface, garbage/wrong-shape BOX_SUBNET)
exit 1 having made NO incus and NO sudo call — refuse-before-mutation is
asserted on the logs' absence, not assumed — the existing-bridge mismatch
refuses, the legitimate converge (boxnet's own prior claim) still reaches
'Host ready', and a fresh BOX_SUBNET=10.89.0.0/24 build lands
ipv4.address=10.89.0.1/24 on the bridge and destination: 10.89.0.1/32 in
the ACL, proving every derived value follows the one input.
Line-order guards pin the wiring: the subnet guard precedes the first
mutation in setup-host, the machine-local signature precedes the doctor's
daemon checks, probe_sig runs on both tiers, box-firewall reads the
gateway off the live bridge, and the drill/multiuser/migrate probes derive
their prefix from the network.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 19:47:20 +00:00
|
|
|
|
mut="$(grep -n "^if ! command -v incus" "'"$ROOT"'/host/setup-host.sh" | head -1 | cut -d: -f1)"
|
|
|
|
|
|
[ -n "$guard" ] && [ -n "$mut" ] && [ "$guard" -lt "$mut" ]'
|
|
|
|
|
|
# box-firewall follows the bridge, wherever BOX_SUBNET put it.
|
|
|
|
|
|
# shellcheck disable=SC2016 # the $-string is a literal in the target file
|
|
|
|
|
|
check "box-firewall: the gateway is read off the live bridge, not hardcoded" 0 "" \
|
|
|
|
|
|
grep -qF 'addr show dev "$NET"' "$ROOT/host/box-firewall.sh"
|
|
|
|
|
|
# The drill and migrate probes derive the prefix from the network — a
|
|
|
|
|
|
# BOX_SUBNET host must not fail its own rehearsals.
|
|
|
|
|
|
check "drill: derives the boxnet prefix from the network" 0 "" \
|
|
|
|
|
|
grep -qF 'network get boxnet ipv4.address' "$ROOT/drill/drill.sh"
|
|
|
|
|
|
check "multiuser: derives the boxnet prefix from the network" 0 "" \
|
|
|
|
|
|
grep -qF 'network get boxnet ipv4.address' "$ROOT/drill/multiuser.sh"
|
|
|
|
|
|
check "migrate-host: derives the boxnet prefix from the network" 0 "" \
|
|
|
|
|
|
grep -qF 'network get boxnet ipv4.address' "$ROOT/host/migrate-host.sh"
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# The doctor's #80 signature. gw_squat_signature is pure text → findings, so
|
|
|
|
|
|
# it is extracted and driven against synthetic route tables — including the
|
|
|
|
|
|
# EXACT poisoned state from the issue, the workaround state (bridge remapped:
|
|
|
|
|
|
# clean), and a healthy host running the stack (clean).
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
SIGFN="$(mktemp)"
|
|
|
|
|
|
awk '/^gw_squat_signature\(\) \{/,/^\}/' "$ROOT/drill/doctor.sh" > "$SIGFN"
|
|
|
|
|
|
check "gw_squat_signature: extracted from doctor.sh (guards the awk)" 0 "default" cat "$SIGFN"
|
|
|
|
|
|
check "gw_squat_signature: the extracted function is valid bash" 0 "" bash -n "$SIGFN"
|
|
|
|
|
|
sig() { bash -c ". '$SIGFN'; gw_squat_signature \"\$1\" \"\$2\"" _ "$1" "$2"; }
|
|
|
|
|
|
nosig() { [ -z "$(sig "$1" "$2")" ]; }
|
|
|
|
|
|
|
|
|
|
|
|
# The poisoned guest, verbatim from #80: gateway held locally AND duplicated
|
|
|
|
|
|
# connected routes for the uplink subnet.
|
|
|
|
|
|
R_POISON="$D_INBOX
|
|
|
|
|
|
10.88.0.0/24 dev boxnet proto kernel scope link src 10.88.0.1 linkdown
|
|
|
|
|
|
10.88.0.0/24 dev enp5s0 proto kernel scope link src 10.88.0.202 metric 1024
|
|
|
|
|
|
10.88.0.1 dev enp5s0 proto dhcp scope link src 10.88.0.202 metric 1024"
|
|
|
|
|
|
A_POISON="$A_GUEST
|
|
|
|
|
|
17: boxnet inet 10.88.0.1/24 scope global boxnet"
|
|
|
|
|
|
check "signature: poisoned guest — the gateway is held as a LOCAL address" \
|
|
|
|
|
|
0 "held as a LOCAL address" sig "$R_POISON" "$A_POISON"
|
|
|
|
|
|
check "signature: poisoned guest — duplicate connected routes for the uplink" \
|
|
|
|
|
|
0 "duplicate connected routes" sig "$R_POISON" "$A_POISON"
|
|
|
|
|
|
# The workaround state (#80's fix: bridge remapped off the uplink subnet) —
|
|
|
|
|
|
# both signature lines must be ABSENT.
|
|
|
|
|
|
R_REMAP="$D_INBOX
|
|
|
|
|
|
10.88.0.0/24 dev enp5s0 proto kernel scope link src 10.88.0.202 metric 1024
|
|
|
|
|
|
10.88.0.1 dev enp5s0 proto dhcp scope link src 10.88.0.202 metric 1024
|
|
|
|
|
|
10.89.0.0/24 dev boxnet proto kernel scope link src 10.89.0.1 linkdown"
|
|
|
|
|
|
A_REMAP="$A_GUEST
|
|
|
|
|
|
17: boxnet inet 10.89.0.1/24 scope global boxnet"
|
|
|
|
|
|
check "signature: the remapped-bridge workaround is CLEAN" 0 "" nosig "$R_REMAP" "$A_REMAP"
|
|
|
|
|
|
# A healthy HOST running the stack: boxnet legitimately owns its subnet, and
|
|
|
|
|
|
# the uplink is elsewhere — clean, or every host would cry wolf.
|
|
|
|
|
|
R_HOST="$D_LAN
|
|
|
|
|
|
192.168.1.0/24 dev eno1 proto kernel scope link src 192.168.1.50
|
|
|
|
|
|
10.88.0.0/24 dev boxnet proto kernel scope link src 10.88.0.1"
|
|
|
|
|
|
check "signature: a healthy host running the stack is CLEAN" 0 "" nosig "$R_HOST" "$A_HOSTSTACK"
|
|
|
|
|
|
check "signature: no default route → nothing to judge (clean)" 0 "" \
|
|
|
|
|
|
nosig "10.88.0.0/24 dev boxnet proto kernel scope link src 10.88.0.1" "$A_HOSTSTACK"
|
|
|
|
|
|
# Each line fires on its own: a captured gateway without duplicate routes...
|
|
|
|
|
|
R_GWONLY="$D_INBOX
|
|
|
|
|
|
10.88.0.0/24 dev enp5s0 proto kernel scope link src 10.88.0.202 metric 1024"
|
|
|
|
|
|
check "signature: a captured gateway alone still fires" \
|
|
|
|
|
|
0 "held as a LOCAL address" sig "$R_GWONLY" "$A_POISON"
|
|
|
|
|
|
# ...and duplicate routes without the gateway captured (nested bridge on .5).
|
|
|
|
|
|
A_DUPONLY="$A_GUEST
|
|
|
|
|
|
17: boxnet inet 10.88.0.5/24 scope global boxnet"
|
|
|
|
|
|
check "signature: duplicate routes alone still fire" \
|
|
|
|
|
|
0 "duplicate connected routes" sig "$R_POISON" "$A_DUPONLY"
|
|
|
|
|
|
rm -f "$SIGFN"
|
|
|
|
|
|
|
|
|
|
|
|
# The wiring: the signature is judged on THIS machine before any daemon call
|
|
|
|
|
|
# (the daemon answering could be the nested impostor), probed INSIDE boxes on
|
|
|
|
|
|
# both tiers, and the egress-broken-DNS-fine split names the fingerprint.
|
|
|
|
|
|
# shellcheck disable=SC2016 # the $-strings are literals in the target file
|
|
|
|
|
|
check "doctor: this machine's signature precedes the daemon checks" 0 "" bash -c '
|
|
|
|
|
|
sig="$(grep -n "is a nested box stack squatting" "'"$ROOT"'/drill/doctor.sh" | head -1 | cut -d: -f1)"
|
|
|
|
|
|
daemon="$(grep -n "timeout 10 incus list" "'"$ROOT"'/drill/doctor.sh" | head -1 | cut -d: -f1)"
|
|
|
|
|
|
[ -n "$sig" ] && [ -n "$daemon" ] && [ "$sig" -lt "$daemon" ]'
|
|
|
|
|
|
# shellcheck disable=SC2016 # the $-string is a literal in the target file
|
|
|
|
|
|
check "doctor: the signature is probed inside boxes on BOTH tiers" 0 "" bash -c '
|
|
|
|
|
|
[ "$(grep -c "probe_sig \"\$probe\"" "'"$ROOT"'/drill/doctor.sh")" -eq 2 ]'
|
|
|
|
|
|
# shellcheck disable=SC2016 # the $-string is a literal in the target file
|
|
|
|
|
|
check "doctor: the egress-broken-DNS-fine fingerprint is named on both tiers" 0 "" bash -c '
|
|
|
|
|
|
[ "$(grep -c "fingerprint" "'"$ROOT"'/drill/doctor.sh")" -ge 2 ]'
|
|
|
|
|
|
check "doctor: the ACL carve-out is checked against the live gateway" 0 "" \
|
|
|
|
|
|
grep -qF "does NOT match boxnet's gateway" "$ROOT/drill/doctor.sh"
|
|
|
|
|
|
|
test(cli): drive box-firewall under a shim ufw, and the doctor's UFW findings
The real script, driven end to end (the setup-host seam): a fake ufw
serves canned status tables and logs every mutation, fake
nft/sysctl/iptables swallow the rest, the shim ip answers the
live-bridge read. Proven: a remapped bridge converges (stale tcp+udp
allows deleted, live gateway's inserted, live rules never deleted), an
agreeing host deletes nothing, a fresh host gets exactly the five
historical mutations, the unaddressed-bridge boot window mutates
NOTHING and says so (the fallback's absence is pinned to non-comment
lines), and a no-UFW host keeps its interface-scoped nft path — boot
window included. ufw_dns_findings is extracted and driven against the
same tables: agreement silent, stale carve-out flagged and named,
deny-with-no-allow flagged, untouched UFW host clean, stale-beside-live
named, other interfaces ignored; wiring grep-guarded. 297 checks.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 20:45:21 +00:00
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# box-firewall's UFW converge and the fail-closed boot window (#86 review,
|
|
|
|
|
|
# items 1–2). The whole script is DRIVEN under shims (the setup-host seam):
|
|
|
|
|
|
# a fake ufw serves canned `ufw status` tables and logs every mutation, fake
|
|
|
|
|
|
# nft/sysctl/iptables swallow the rest, and the shim ip answers the
|
|
|
|
|
|
# live-bridge read. Stale gateway allows must converge to the live gateway,
|
|
|
|
|
|
# a fresh UFW host must get exactly the rule set it always did, a no-UFW
|
|
|
|
|
|
# host must keep its nft path, and the no-bridge-address boot window must
|
|
|
|
|
|
# mutate NOTHING — the old GW=10.88.0.1 fallback built the carve-out for
|
|
|
|
|
|
# the wrong gateway on every BOX_SUBNET host that hit it.
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
FWSHIM="$(mktemp -d)"; UFWSHIM="$(mktemp -d)"; WFW="$(mktemp -d)"
|
|
|
|
|
|
cat > "$UFWSHIM/ufw" <<'SHIM'
|
|
|
|
|
|
#!/usr/bin/env bash
|
|
|
|
|
|
# Fake ufw: 'status' prints $FAKE_UFW_STATUS; every call is logged to
|
|
|
|
|
|
# $FAKE_UFW_LOG. Mutations mutate nothing, of course.
|
|
|
|
|
|
[ -n "${FAKE_UFW_LOG:-}" ] && printf 'ufw %s\n' "$*" >> "$FAKE_UFW_LOG"
|
|
|
|
|
|
case "${1:-}" in status) printf '%s\n' "${FAKE_UFW_STATUS:-Status: inactive}" ;; esac
|
|
|
|
|
|
exit 0
|
|
|
|
|
|
SHIM
|
|
|
|
|
|
cat > "$FWSHIM/nft" <<'SHIM'
|
|
|
|
|
|
#!/usr/bin/env bash
|
|
|
|
|
|
# Fake nft: logs to $FAKE_NFT_LOG. The bridge-table probe answers "absent"
|
|
|
|
|
|
# so the creation path runs (and is logged) instead of being skipped.
|
|
|
|
|
|
[ -n "${FAKE_NFT_LOG:-}" ] && printf 'nft %s\n' "$*" >> "$FAKE_NFT_LOG"
|
|
|
|
|
|
case "$*" in "list table bridge box") exit 1 ;; esac
|
|
|
|
|
|
exit 0
|
|
|
|
|
|
SHIM
|
|
|
|
|
|
cat > "$FWSHIM/sysctl" <<'SHIM'
|
|
|
|
|
|
#!/usr/bin/env bash
|
|
|
|
|
|
exit 0
|
|
|
|
|
|
SHIM
|
|
|
|
|
|
cat > "$FWSHIM/iptables" <<'SHIM'
|
|
|
|
|
|
#!/usr/bin/env bash
|
|
|
|
|
|
# Fake iptables: the DOCKER-USER probe answers "no such chain", so the
|
|
|
|
|
|
# docker block is deterministically skipped whether or not this runner
|
|
|
|
|
|
# happens to have docker.
|
|
|
|
|
|
exit 1
|
|
|
|
|
|
SHIM
|
|
|
|
|
|
chmod +x "$UFWSHIM/ufw" "$FWSHIM/nft" "$FWSHIM/sysctl" "$FWSHIM/iptables"
|
|
|
|
|
|
|
|
|
|
|
|
runfw() { # runfw <ufw|noufw> [VAR=val ...] — the real box-firewall, under shims
|
|
|
|
|
|
local mode="$1" p; shift
|
|
|
|
|
|
p="$FWSHIM:$SHIMDIR:$PATH"
|
|
|
|
|
|
[ "$mode" = ufw ] && p="$UFWSHIM:$p"
|
|
|
|
|
|
env PATH="$p" "$@" bash "$ROOT/host/box-firewall.sh"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
# Canned `ufw status` tables, modeled on the real output shape.
|
|
|
|
|
|
U_HDR='Status: active
|
|
|
|
|
|
|
|
|
|
|
|
To Action From
|
|
|
|
|
|
-- ------ ----
|
|
|
|
|
|
22/tcp ALLOW Anywhere'
|
|
|
|
|
|
U_FRESH="$U_HDR"
|
|
|
|
|
|
U_OLDGW="$U_HDR
|
|
|
|
|
|
Anywhere on boxnet DENY Anywhere
|
|
|
|
|
|
10.88.0.1 53/tcp on boxnet ALLOW Anywhere
|
|
|
|
|
|
10.88.0.1 53/udp on boxnet ALLOW Anywhere
|
|
|
|
|
|
67/udp on boxnet ALLOW Anywhere
|
|
|
|
|
|
Anywhere on boxnet ALLOW FWD Anywhere"
|
|
|
|
|
|
U_LIVEGW="$U_HDR
|
|
|
|
|
|
Anywhere on boxnet DENY Anywhere
|
|
|
|
|
|
10.89.0.1 53/tcp on boxnet ALLOW Anywhere
|
|
|
|
|
|
10.89.0.1 53/udp on boxnet ALLOW Anywhere
|
|
|
|
|
|
67/udp on boxnet ALLOW Anywhere
|
|
|
|
|
|
Anywhere on boxnet ALLOW FWD Anywhere"
|
|
|
|
|
|
BX88='5: boxnet inet 10.88.0.1/24 scope global boxnet'
|
|
|
|
|
|
BX89='5: boxnet inet 10.89.0.1/24 scope global boxnet'
|
|
|
|
|
|
|
|
|
|
|
|
# The remapped host (#80's escape hatch): bridge on 10.89, UFW still carrying
|
|
|
|
|
|
# 10.88's carve-out — the stale allows go, the live gateway's land.
|
|
|
|
|
|
check "box-firewall: a remapped bridge CONVERGES the UFW carve-out" 0 "" \
|
|
|
|
|
|
runfw ufw FAKE_IP4_BOXNET="$BX89" FAKE_UFW_STATUS="$U_OLDGW" FAKE_UFW_LOG="$WFW/remap.log"
|
|
|
|
|
|
check "box-firewall: ...the stale tcp allow is deleted" 0 "" \
|
|
|
|
|
|
grep -qF 'ufw delete allow in on boxnet to 10.88.0.1 port 53 proto tcp' "$WFW/remap.log"
|
|
|
|
|
|
check "box-firewall: ...and the stale udp allow" 0 "" \
|
|
|
|
|
|
grep -qF 'ufw delete allow in on boxnet to 10.88.0.1 port 53 proto udp' "$WFW/remap.log"
|
|
|
|
|
|
check "box-firewall: ...the live gateway gains its tcp allow" 0 "" \
|
|
|
|
|
|
grep -qF 'ufw insert 1 allow in on boxnet to 10.89.0.1 port 53 proto tcp' "$WFW/remap.log"
|
|
|
|
|
|
check "box-firewall: ...and its udp allow" 0 "" \
|
|
|
|
|
|
grep -qF 'ufw insert 1 allow in on boxnet to 10.89.0.1 port 53 proto udp' "$WFW/remap.log"
|
|
|
|
|
|
check "box-firewall: ...the live gateway's rules are never deleted" 1 "" \
|
|
|
|
|
|
grep -qF 'delete allow in on boxnet to 10.89.0.1' "$WFW/remap.log"
|
|
|
|
|
|
|
|
|
|
|
|
# The agreeing host: rules already match the live gateway — nothing deleted
|
|
|
|
|
|
# (ufw itself skips the re-adds as existing rules).
|
|
|
|
|
|
check "box-firewall: an agreeing UFW host deletes nothing" 0 "" \
|
|
|
|
|
|
runfw ufw FAKE_IP4_BOXNET="$BX89" FAKE_UFW_STATUS="$U_LIVEGW" FAKE_UFW_LOG="$WFW/agree.log"
|
|
|
|
|
|
check "box-firewall: ...no delete was issued" 1 "" grep -qF ' delete ' "$WFW/agree.log"
|
|
|
|
|
|
|
|
|
|
|
|
# The fresh host: no boxnet rules yet — exactly the five historical commands,
|
|
|
|
|
|
# aimed at the live gateway, and nothing else (unchanged behavior).
|
|
|
|
|
|
check "box-firewall: a fresh UFW host runs clean" 0 "" \
|
|
|
|
|
|
runfw ufw FAKE_IP4_BOXNET="$BX88" FAKE_UFW_STATUS="$U_FRESH" FAKE_UFW_LOG="$WFW/fresh.log"
|
|
|
|
|
|
check "box-firewall: ...the deny lands" 0 "" \
|
|
|
|
|
|
grep -qF 'ufw insert 1 deny in on boxnet' "$WFW/fresh.log"
|
|
|
|
|
|
check "box-firewall: ...the DNS allows aim at the live gateway" 0 "" \
|
|
|
|
|
|
grep -qF 'ufw insert 1 allow in on boxnet to 10.88.0.1 port 53 proto tcp' "$WFW/fresh.log"
|
|
|
|
|
|
# shellcheck disable=SC2016 # $1 expands in the child shell, by design
|
|
|
|
|
|
check "box-firewall: ...DHCP and the route allow land too" 0 "" bash -c '
|
|
|
|
|
|
grep -qF "ufw insert 1 allow in on boxnet to any port 67 proto udp" "$1" &&
|
|
|
|
|
|
grep -qF "ufw route allow in on boxnet" "$1"' _ "$WFW/fresh.log"
|
|
|
|
|
|
# shellcheck disable=SC2016 # $1 expands in the child shell, by design
|
|
|
|
|
|
check "box-firewall: ...exactly the five historical mutations, no more" 0 "" \
|
|
|
|
|
|
bash -c '[ "$(grep -vc "^ufw status" "$1")" -eq 5 ]' _ "$WFW/fresh.log"
|
|
|
|
|
|
|
|
|
|
|
|
# The boot window (#86 review item 2): bridge not yet addressed → NO guessed
|
|
|
|
|
|
# gateway, NO mutation at all — the persisted rules are left exactly as they
|
|
|
|
|
|
# are, and the skip says so. (The old fallback built 10.88.0.1 rules on a
|
|
|
|
|
|
# BOX_SUBNET host here — a latent DNS drop.)
|
|
|
|
|
|
check "box-firewall: an unaddressed bridge FAILS CLOSED on a UFW host" 0 "left as-is" \
|
|
|
|
|
|
runfw ufw FAKE_IP4_BOXNET= FAKE_UFW_STATUS="$U_OLDGW" FAKE_UFW_LOG="$WFW/boot.log"
|
|
|
|
|
|
# shellcheck disable=SC2016 # $1 expands in the child shell, by design
|
|
|
|
|
|
check "box-firewall: ...not one ufw mutation was issued" 0 "" \
|
|
|
|
|
|
bash -c '[ "$(grep -vc "^ufw status" "$1")" -eq 0 ]' _ "$WFW/boot.log"
|
|
|
|
|
|
check "box-firewall: the hardcoded gateway fallback is GONE (comments aside)" 1 "" \
|
|
|
|
|
|
grep -qE '^[^#]*GW=10' "$ROOT/host/box-firewall.sh"
|
|
|
|
|
|
|
|
|
|
|
|
# The no-UFW host: untouched semantics — the nft input carve-out is
|
|
|
|
|
|
# interface-scoped, so it needs no gateway and applies even in the boot
|
|
|
|
|
|
# window where the UFW path now declines to guess.
|
|
|
|
|
|
check "box-firewall: a no-UFW host keeps its nft path" 0 "" \
|
|
|
|
|
|
runfw noufw FAKE_IP4_BOXNET="$BX89" FAKE_NFT_LOG="$WFW/nft.log"
|
|
|
|
|
|
check "box-firewall: ...the DNS/DHCP accept is interface-scoped" 0 "" \
|
|
|
|
|
|
grep -qF 'add rule inet box input iifname boxnet udp dport { 53, 67 } accept' "$WFW/nft.log"
|
|
|
|
|
|
check "box-firewall: ...and the input drop lands" 0 "" \
|
|
|
|
|
|
grep -qF 'add rule inet box input iifname boxnet drop' "$WFW/nft.log"
|
|
|
|
|
|
check "box-firewall: the nft path survives the boot window too" 0 "" \
|
|
|
|
|
|
runfw noufw FAKE_IP4_BOXNET= FAKE_NFT_LOG="$WFW/nftboot.log"
|
|
|
|
|
|
check "box-firewall: ...with the same interface-scoped carve-out" 0 "" \
|
|
|
|
|
|
grep -qF 'add rule inet box input iifname boxnet udp dport { 53, 67 } accept' "$WFW/nftboot.log"
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# The doctor's UFW blind spot (#86 review item 1, second half): the ACL
|
|
|
|
|
|
# check alone gave a remapped UFW host a clean bill while the stale UFW
|
|
|
|
|
|
# allow dropped box DNS. ufw_dns_findings is pure text → findings, the
|
|
|
|
|
|
# gw_squat_signature seam: extracted and driven against canned tables.
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
UFWFN="$(mktemp)"
|
|
|
|
|
|
awk '/^ufw_dns_findings\(\) \{/,/^\}/' "$ROOT/drill/doctor.sh" > "$UFWFN"
|
|
|
|
|
|
check "ufw_dns_findings: extracted from doctor.sh (guards the awk)" 0 "DNS allow" cat "$UFWFN"
|
|
|
|
|
|
check "ufw_dns_findings: the extracted function is valid bash" 0 "" bash -n "$UFWFN"
|
|
|
|
|
|
ufwsig() { bash -c ". '$UFWFN'; ufw_dns_findings \"\$1\" \"\$2\" \"\$3\"" _ "$1" "$2" "$3"; }
|
|
|
|
|
|
noufwsig() { [ -z "$(ufwsig "$1" "$2" "$3")" ]; }
|
|
|
|
|
|
|
|
|
|
|
|
check "ufw findings: agreement is SILENT" 0 "" noufwsig "$U_LIVEGW" boxnet 10.89.0.1
|
|
|
|
|
|
check "ufw findings: a stale carve-out is flagged as NOT the live gateway" \
|
|
|
|
|
|
0 "NOT boxnet's live gateway" ufwsig "$U_OLDGW" boxnet 10.89.0.1
|
|
|
|
|
|
check "ufw findings: ...naming the address it points at" \
|
|
|
|
|
|
0 "10.88.0.1" ufwsig "$U_OLDGW" boxnet 10.89.0.1
|
|
|
|
|
|
# Our deny with no DNS allow at all is a drop — say so.
|
|
|
|
|
|
U_DENYONLY="$U_HDR
|
|
|
|
|
|
Anywhere on boxnet DENY Anywhere"
|
|
|
|
|
|
check "ufw findings: a deny with NO DNS allow is a drop" \
|
|
|
|
|
|
0 "NO DNS allow" ufwsig "$U_DENYONLY" boxnet 10.89.0.1
|
|
|
|
|
|
# A UFW host box-firewall never touched has nothing to judge — clean.
|
|
|
|
|
|
check "ufw findings: an untouched UFW host is CLEAN" 0 "" noufwsig "$U_FRESH" boxnet 10.89.0.1
|
|
|
|
|
|
# A stale allow left BESIDE the live one still gets named (residue, not a drop).
|
|
|
|
|
|
U_BOTH="$U_LIVEGW
|
|
|
|
|
|
10.88.0.1 53/tcp on boxnet ALLOW Anywhere"
|
|
|
|
|
|
check "ufw findings: a stale allow beside the live one is named" \
|
|
|
|
|
|
0 "stale UFW DNS allow" ufwsig "$U_BOTH" boxnet 10.89.0.1
|
|
|
|
|
|
# Rules on OTHER interfaces are not boxnet's problem.
|
|
|
|
|
|
U_OTHERIF="$U_LIVEGW
|
|
|
|
|
|
10.88.0.1 53/tcp on eth0 ALLOW Anywhere"
|
|
|
|
|
|
check "ufw findings: another interface's DNS allow is ignored" 0 "" \
|
|
|
|
|
|
noufwsig "$U_OTHERIF" boxnet 10.89.0.1
|
|
|
|
|
|
|
|
|
|
|
|
# The wiring: doctor judges UFW's own table where UFW is active, and the fix
|
|
|
|
|
|
# points at the converging box-firewall.
|
|
|
|
|
|
# shellcheck disable=SC2016 # the $-string is a literal in the target file
|
|
|
|
|
|
check "doctor: reads UFW's table through ufw_dns_findings" 0 "" \
|
|
|
|
|
|
grep -qF 'ufw_dns_findings "$ufw_out"' "$ROOT/drill/doctor.sh"
|
|
|
|
|
|
check "doctor: the UFW fix names the converge" 0 "" \
|
|
|
|
|
|
grep -qF 'converges the UFW allows' "$ROOT/drill/doctor.sh"
|
|
|
|
|
|
rm -f "$UFWFN"; rm -rf "$FWSHIM" "$UFWSHIM" "$WFW"
|
|
|
|
|
|
|
test(cli): drive the #80 guard, the BOX_SUBNET plumb-through, and the signature
The two pure functions are extracted and driven, the same seam as box_tier
and load_template: a shim ip serves canned route tables (the poisoned
guest verbatim from #80's capture, the remapped-bridge workaround, a
healthy stack host), and each signature line is proven to fire alone and
to stay silent on the clean states.
Then the WHOLE setup-host is driven end to end under shims (fake incus and
sudo log every call; fake id keeps it unprivileged): the three refusals
(gateway-in-subnet, foreign interface, garbage/wrong-shape BOX_SUBNET)
exit 1 having made NO incus and NO sudo call — refuse-before-mutation is
asserted on the logs' absence, not assumed — the existing-bridge mismatch
refuses, the legitimate converge (boxnet's own prior claim) still reaches
'Host ready', and a fresh BOX_SUBNET=10.89.0.0/24 build lands
ipv4.address=10.89.0.1/24 on the bridge and destination: 10.89.0.1/32 in
the ACL, proving every derived value follows the one input.
Line-order guards pin the wiring: the subnet guard precedes the first
mutation in setup-host, the machine-local signature precedes the doctor's
daemon checks, probe_sig runs on both tiers, box-firewall reads the
gateway off the live bridge, and the drill/multiuser/migrate probes derive
their prefix from the network.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 19:47:20 +00:00
|
|
|
|
# The docs keep the new promises.
|
|
|
|
|
|
check "help setup-host names BOX_SUBNET" 0 "BOX_SUBNET" "$BOX" help setup-host
|
|
|
|
|
|
check "help setup-host names the refusal" 0 "REFUSES" "$BOX" help setup-host
|
|
|
|
|
|
check "help doctor names the #80 signature" 0 "#80" "$BOX" help doctor
|
|
|
|
|
|
check "README documents BOX_SUBNET" 0 "" grep -qF 'BOX_SUBNET' "$ROOT/README.md"
|
|
|
|
|
|
|
test+ci: real installs driven offline, uninstall proven to zero residue
test/cli.sh drives REAL installer runs via BOX_INSTALL_SOURCE (temp
BOX_HOME/BOX_BIN, a fabricated second version for upgrades): fresh install
lands versioned, --version answers through the chain, side-by-side installs,
same-version no-op, BOX_REINSTALL, the 0.6.0 flat-tree migration, stale-
symlink healing, single-version and full uninstalls asserted to zero residue
(a planted survivor makes it scream INCOMPLETE), working-tree refusals, and
byte-identity of the existing_boxes copies. The rehearsal job now installs
via install.sh itself — CI proves the installer under review — and ends with
the uninstall drill: revoke --purge, teardown --yes, box uninstall, then
assert nothing is left (networks, profiles, nft, units, files, symlinks).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 16:02:37 +00:00
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# The versioned install (#66 → 0.7.0). BOX_INSTALL_SOURCE bypasses the network,
|
|
|
|
|
|
# so these are REAL runs of install.sh against throwaway BOX_HOME/BOX_BIN
|
|
|
|
|
|
# roots — layout, symlink chain, flat-tree migration, symlink healing, use and
|
|
|
|
|
|
# uninstall are all DRIVEN, not grepped. A fake `incus` on PATH answers the
|
|
|
|
|
|
# existing-boxes gate ($FAKE_BOXES names them), so the #66 refusals — refuse
|
|
|
|
|
|
# to flip, refuse to switch, refuse to uninstall under boxes — run for real
|
|
|
|
|
|
# too, with no daemon anywhere near this suite.
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
VER="$(cat "$ROOT/VERSION")"
|
|
|
|
|
|
WORK="$(mktemp -d)"
|
|
|
|
|
|
FAKEHOME="$WORK/home"; mkdir -p "$FAKEHOME"
|
|
|
|
|
|
|
|
|
|
|
|
ISHIM="$WORK/ishim"; mkdir -p "$ISHIM"
|
|
|
|
|
|
cat > "$ISHIM/incus" <<'SHIM'
|
|
|
|
|
|
#!/usr/bin/env bash
|
|
|
|
|
|
# Fake incus: 'list' prints $FAKE_BOXES (whitespace-separated names, one per
|
|
|
|
|
|
# line); everything else succeeds silently. Just enough for the existing-boxes
|
|
|
|
|
|
# gate that guards version flips.
|
|
|
|
|
|
case " $* " in
|
|
|
|
|
|
*" list "*) for b in ${FAKE_BOXES:-}; do printf '%s\n' "$b"; done ;;
|
|
|
|
|
|
esac
|
|
|
|
|
|
exit 0
|
|
|
|
|
|
SHIM
|
|
|
|
|
|
chmod +x "$ISHIM/incus"
|
|
|
|
|
|
|
|
|
|
|
|
# A fabricated "newer release": the same CLI, a different VERSION — what an
|
|
|
|
|
|
# upgrade actually is, from the installer's point of view.
|
|
|
|
|
|
SRC9="$WORK/src-9.9.9"; mkdir -p "$SRC9/bin"
|
|
|
|
|
|
cp "$ROOT/bin/box" "$SRC9/bin/box"; chmod +x "$SRC9/bin/box"
|
|
|
|
|
|
echo "9.9.9-drill" > "$SRC9/VERSION"
|
|
|
|
|
|
SRC8="$WORK/src-8.8.8"; mkdir -p "$SRC8/bin"
|
|
|
|
|
|
cp "$ROOT/bin/box" "$SRC8/bin/box"; chmod +x "$SRC8/bin/box"
|
|
|
|
|
|
echo "8.8.8-drill" > "$SRC8/VERSION"
|
|
|
|
|
|
|
|
|
|
|
|
inst() { # inst <box_home> <box_bin> [VAR=val ...] — run install.sh for real
|
|
|
|
|
|
local h="$1" b="$2"; shift 2
|
|
|
|
|
|
env HOME="$FAKEHOME" PATH="$ISHIM:$PATH" FAKE_BOXES= \
|
|
|
|
|
|
BOX_HOME="$h" BOX_BIN="$b" BOX_YES=1 BOX_SKIP_SETUP_HOST=1 \
|
|
|
|
|
|
BOX_INSTALL_SOURCE="$ROOT" "$@" bash "$ROOT/install.sh"
|
|
|
|
|
|
}
|
|
|
|
|
|
ibox() { # ibox [VAR=val ...] <cmd...> — run an installed box under the shim
|
|
|
|
|
|
env HOME="$FAKEHOME" PATH="$ISHIM:$PATH" FAKE_BOXES= "$@"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
# --- fresh install: the layout and the chain --------------------------------
|
|
|
|
|
|
H1="$WORK/h1"; B1="$WORK/b1"
|
|
|
|
|
|
check "install: a fresh install runs clean" 0 "done" inst "$H1" "$B1"
|
|
|
|
|
|
check "install: the tree lands in versions/<v>" 0 "" test -x "$H1/versions/$VER/bin/box"
|
|
|
|
|
|
check "install: 'current' points at versions/<v>" 0 "versions/$VER" readlink "$H1/current"
|
|
|
|
|
|
check "install: the PATH symlink rides the chain" 0 "$H1/current/bin/box" readlink "$B1/box"
|
|
|
|
|
|
check "install: box --version answers through the whole chain" 0 "box $VER" ibox "$B1/box" --version
|
|
|
|
|
|
check "install: INSTALLED_FROM records the local source" 0 "local:" cat "$H1/versions/$VER/INSTALLED_FROM"
|
|
|
|
|
|
|
|
|
|
|
|
# --- converge, don't clobber ------------------------------------------------
|
|
|
|
|
|
touch "$H1/versions/$VER/CANARY"
|
|
|
|
|
|
check "install: a same-version re-run is a no-op that says so (#66)" 0 "already installed" inst "$H1" "$B1"
|
|
|
|
|
|
check "install: the no-op left the tree untouched" 0 "" test -e "$H1/versions/$VER/CANARY"
|
|
|
|
|
|
check "install: BOX_REINSTALL=1 replaces that version's tree" 0 "reinstalled" inst "$H1" "$B1" BOX_REINSTALL=1
|
|
|
|
|
|
check "install: the reinstall really replaced it (canary gone)" 1 "" test -e "$H1/versions/$VER/CANARY"
|
|
|
|
|
|
|
|
|
|
|
|
# --- a second version: side-by-side, and the no-boxes flip ------------------
|
|
|
|
|
|
check "install: a second version installs side-by-side" 0 "" inst "$H1" "$B1" BOX_INSTALL_SOURCE="$SRC9"
|
|
|
|
|
|
check "install: ...into its own versions dir" 0 "" test -x "$H1/versions/9.9.9-drill/bin/box"
|
|
|
|
|
|
check "install: ...and the old version stays" 0 "" test -d "$H1/versions/$VER"
|
|
|
|
|
|
check "install: with no boxes, the default flips to the new version" 0 "box 9.9.9-drill" ibox "$B1/box" --version
|
|
|
|
|
|
|
|
|
|
|
|
# --- box versions -----------------------------------------------------------
|
|
|
|
|
|
check "versions: lists the installed versions" 0 "$VER" ibox "$B1/box" versions
|
|
|
|
|
|
check "versions: marks the current default" 0 "(current)" ibox "$B1/box" versions
|
|
|
|
|
|
check "versions: marks the running one" 0 "(running)" ibox "$B1/box" versions
|
|
|
|
|
|
|
|
|
|
|
|
# --- box use ----------------------------------------------------------------
|
|
|
|
|
|
check "use: no argument is a usage error" 2 "usage: box use" ibox "$B1/box" use
|
|
|
|
|
|
check "use: an unknown version is refused by name" 1 "no such version" ibox "$B1/box" use 1.2.3
|
fix: version names die at one shared gate, and --purge-host hears --force
Round-1 convergence from all three reviewers, both findings real:
A version string used to be a path fragment: 'box uninstall
../../../.ssh' resolved below versions/ and rm -rf'd wherever it
landed, 'box use' could point current outside the root, and a hostile
flat-tree VERSION could steer the migration's mv the same way. One
strict validator now gates every caller — only [A-Za-z0-9._+-], no
leading '.' or '-' — byte-identical in install.sh and bin/box like
existing_boxes, diff-guarded in the tests, with traversal regressions
on use, uninstall and the migration (which now refuses BEFORE the tree
moves anywhere).
--force is uninstall's installer-family consent, and --purge-host now
forwards it: teardown-host.sh gets --yes under --force/BOX_YES, so the
combined non-interactive uninstall no longer dies at teardown's own
prompt. CI's drill now runs the combined verb with --force alone (no
BOX_YES, no TTY) — the exact invocation that used to abort.
Also grok's polish, taken: current flips by rename (ln to a side name,
mv -Tf over — no window with no current) in both install.sh and 'box
use'; BOX_REINSTALL swaps by two renames and deletes LAST; and the
single-version path refuses while current is dangling (readlink -f
resolves a missing last component, so the guard checks the DIRECTORY,
not just the string).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 18:11:26 +00:00
|
|
|
|
# A version is a directory NAME — a crafted one must die at the gate, never
|
|
|
|
|
|
# reach the ln (current pointing outside the root) or an rm -rf.
|
|
|
|
|
|
check "use: a path-traversal version dies at the gate" 1 "not a sane version name" \
|
|
|
|
|
|
ibox "$B1/box" use '../../tmp/evil'
|
test+ci: real installs driven offline, uninstall proven to zero residue
test/cli.sh drives REAL installer runs via BOX_INSTALL_SOURCE (temp
BOX_HOME/BOX_BIN, a fabricated second version for upgrades): fresh install
lands versioned, --version answers through the chain, side-by-side installs,
same-version no-op, BOX_REINSTALL, the 0.6.0 flat-tree migration, stale-
symlink healing, single-version and full uninstalls asserted to zero residue
(a planted survivor makes it scream INCOMPLETE), working-tree refusals, and
byte-identity of the existing_boxes copies. The rehearsal job now installs
via install.sh itself — CI proves the installer under review — and ends with
the uninstall drill: revoke --purge, teardown --yes, box uninstall, then
assert nothing is left (networks, profiles, nft, units, files, symlinks).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 16:02:37 +00:00
|
|
|
|
check "use: refuses under existing boxes, naming them (#66)" 1 "wedged" \
|
|
|
|
|
|
ibox FAKE_BOXES="wedged stuck" "$B1/box" use "$VER"
|
|
|
|
|
|
check "use: the refusal points at the remedy (box rm, then re-run)" 1 "box rm" \
|
|
|
|
|
|
ibox FAKE_BOXES=wedged "$B1/box" use "$VER"
|
|
|
|
|
|
check "use: with no boxes, flips the default" 0 "switched to $VER" ibox "$B1/box" use "$VER"
|
|
|
|
|
|
check "use: the flip is effective through the PATH chain" 0 "box $VER" ibox "$B1/box" --version
|
|
|
|
|
|
check "install: an installed-but-not-current version is a no-op too" 0 "already installed" \
|
|
|
|
|
|
inst "$H1" "$B1" BOX_INSTALL_SOURCE="$SRC9"
|
|
|
|
|
|
check "install: ...and does not move the default" 0 "box $VER" ibox "$B1/box" --version
|
|
|
|
|
|
|
|
|
|
|
|
# --- the upgrade-under-boxes refusal, driven end to end ---------------------
|
|
|
|
|
|
H2="$WORK/h2"; B2="$WORK/b2"
|
|
|
|
|
|
check "refusal drill: baseline install" 0 "done" inst "$H2" "$B2"
|
|
|
|
|
|
check "upgrade under boxes: REFUSES the default flip (#66)" 0 "refusing to change the default box version" \
|
|
|
|
|
|
inst "$H2" "$B2" BOX_INSTALL_SOURCE="$SRC9" FAKE_BOXES=work
|
|
|
|
|
|
check "upgrade under boxes: the new version IS installed side-by-side" 0 "" \
|
|
|
|
|
|
test -d "$H2/versions/9.9.9-drill"
|
|
|
|
|
|
check "upgrade under boxes: the default stayed put" 0 "box $VER" ibox "$B2/box" --version
|
|
|
|
|
|
check "upgrade under boxes: the blocking boxes are NAMED" 0 "· work" \
|
|
|
|
|
|
inst "$H2" "$B2" BOX_INSTALL_SOURCE="$SRC8" FAKE_BOXES=work
|
|
|
|
|
|
check "upgrade under boxes: the refusal names the deliberate flip" 0 "" \
|
|
|
|
|
|
bash -c 'grep -q "then flip the default: box use" "'"$ROOT"'/install.sh"'
|
|
|
|
|
|
|
|
|
|
|
|
# --- migration: a 0.6.0 flat tree becomes a versioned one -------------------
|
|
|
|
|
|
H3="$WORK/h3"; B3="$WORK/b3"; mkdir -p "$H3/bin" "$B3"
|
|
|
|
|
|
cp "$ROOT/bin/box" "$H3/bin/box"; chmod +x "$H3/bin/box"
|
|
|
|
|
|
cp "$ROOT/VERSION" "$H3/VERSION"
|
|
|
|
|
|
echo "test@flat" > "$H3/INSTALLED_FROM"
|
|
|
|
|
|
ln -s "$H3/bin/box" "$B3/box"
|
|
|
|
|
|
check "migrate: a pre-0.7.0 flat tree is moved into versions/" 0 "migrating" inst "$H3" "$B3"
|
|
|
|
|
|
check "migrate: the OPERATOR'S tree moved (not a fresh copy)" 0 "test@flat" \
|
|
|
|
|
|
cat "$H3/versions/$VER/INSTALLED_FROM"
|
|
|
|
|
|
check "migrate: nothing flat remains at the root" 1 "" test -e "$H3/bin"
|
|
|
|
|
|
check "migrate: current points at the migrated version" 0 "versions/$VER" readlink "$H3/current"
|
|
|
|
|
|
check "migrate: the PATH symlink was re-pointed through current" 0 "$H3/current/bin/box" readlink "$B3/box"
|
|
|
|
|
|
check "migrate: the migrated install answers --version" 0 "box $VER" ibox "$B3/box" --version
|
|
|
|
|
|
|
|
|
|
|
|
# ...and the seamless 0.6.0 → 0.7.0 upgrade: flat tree in, new version beside it.
|
|
|
|
|
|
H4="$WORK/h4"; B4="$WORK/b4"; mkdir -p "$H4/bin" "$B4"
|
|
|
|
|
|
cp "$ROOT/bin/box" "$H4/bin/box"; chmod +x "$H4/bin/box"
|
|
|
|
|
|
cp "$ROOT/VERSION" "$H4/VERSION"
|
|
|
|
|
|
ln -s "$H4/bin/box" "$B4/box"
|
|
|
|
|
|
check "migrate+upgrade: flat 0.6.0 in, new version installed beside it" 0 "" \
|
|
|
|
|
|
inst "$H4" "$B4" BOX_INSTALL_SOURCE="$SRC9"
|
|
|
|
|
|
check "migrate+upgrade: both versions present" 0 "" \
|
|
|
|
|
|
bash -c "[ -d '$H4/versions/$VER' ] && [ -d '$H4/versions/9.9.9-drill' ]"
|
|
|
|
|
|
check "migrate+upgrade: no boxes → the new version is the default" 0 "box 9.9.9-drill" \
|
|
|
|
|
|
ibox "$B4/box" --version
|
|
|
|
|
|
|
fix: version names die at one shared gate, and --purge-host hears --force
Round-1 convergence from all three reviewers, both findings real:
A version string used to be a path fragment: 'box uninstall
../../../.ssh' resolved below versions/ and rm -rf'd wherever it
landed, 'box use' could point current outside the root, and a hostile
flat-tree VERSION could steer the migration's mv the same way. One
strict validator now gates every caller — only [A-Za-z0-9._+-], no
leading '.' or '-' — byte-identical in install.sh and bin/box like
existing_boxes, diff-guarded in the tests, with traversal regressions
on use, uninstall and the migration (which now refuses BEFORE the tree
moves anywhere).
--force is uninstall's installer-family consent, and --purge-host now
forwards it: teardown-host.sh gets --yes under --force/BOX_YES, so the
combined non-interactive uninstall no longer dies at teardown's own
prompt. CI's drill now runs the combined verb with --force alone (no
BOX_YES, no TTY) — the exact invocation that used to abort.
Also grok's polish, taken: current flips by rename (ln to a side name,
mv -Tf over — no window with no current) in both install.sh and 'box
use'; BOX_REINSTALL swaps by two renames and deletes LAST; and the
single-version path refuses while current is dangling (readlink -f
resolves a missing last component, so the guard checks the DIRECTORY,
not just the string).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 18:11:26 +00:00
|
|
|
|
# A broken current must halt the single-version path BEFORE any decision: the
|
|
|
|
|
|
# CURRENT guard keys off what current resolves to, and a dangling link makes
|
|
|
|
|
|
# that answer a lie. Drive the version tree's own binary — the current chain
|
|
|
|
|
|
# is exactly what is broken. H4 has two versions; heal current afterwards.
|
|
|
|
|
|
ln -sfn "versions/gone" "$H4/current"
|
|
|
|
|
|
check "uninstall: refuses while current is dangling (heal before delete)" 1 "dangling" \
|
|
|
|
|
|
ibox "$H4/versions/$VER/bin/box" uninstall 9.9.9-drill --force
|
|
|
|
|
|
check "uninstall: ...and both version trees survived the refusal" 0 "" \
|
|
|
|
|
|
bash -c "[ -d '$H4/versions/$VER' ] && [ -d '$H4/versions/9.9.9-drill' ]"
|
|
|
|
|
|
ln -sfn "versions/9.9.9-drill" "$H4/current"
|
|
|
|
|
|
|
|
|
|
|
|
# The migration reads VERSION off the old tree — disk data, not installer
|
|
|
|
|
|
# data. A hostile value must refuse BEFORE the tree moves anywhere.
|
|
|
|
|
|
H9="$WORK/h9"; B9="$WORK/b9"; mkdir -p "$H9/bin" "$B9"
|
|
|
|
|
|
cp "$ROOT/bin/box" "$H9/bin/box"; chmod +x "$H9/bin/box"
|
|
|
|
|
|
printf '%s\n' '../pwn' > "$H9/VERSION"
|
|
|
|
|
|
check "migrate: a hostile flat VERSION refuses to migrate" 1 "not a sane directory name" \
|
|
|
|
|
|
inst "$H9" "$B9"
|
|
|
|
|
|
check "migrate: ...with the flat tree untouched where it was" 0 "" test -x "$H9/bin/box"
|
|
|
|
|
|
|
test+ci: real installs driven offline, uninstall proven to zero residue
test/cli.sh drives REAL installer runs via BOX_INSTALL_SOURCE (temp
BOX_HOME/BOX_BIN, a fabricated second version for upgrades): fresh install
lands versioned, --version answers through the chain, side-by-side installs,
same-version no-op, BOX_REINSTALL, the 0.6.0 flat-tree migration, stale-
symlink healing, single-version and full uninstalls asserted to zero residue
(a planted survivor makes it scream INCOMPLETE), working-tree refusals, and
byte-identity of the existing_boxes copies. The rehearsal job now installs
via install.sh itself — CI proves the installer under review — and ends with
the uninstall drill: revoke --purge, teardown --yes, box uninstall, then
assert nothing is left (networks, profiles, nft, units, files, symlinks).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 16:02:37 +00:00
|
|
|
|
# --- healing: a wedged \$BINDIR/box must never block an install -------------
|
|
|
|
|
|
H5="$WORK/h5"; B5="$WORK/b5"; mkdir -p "$B5"
|
|
|
|
|
|
ln -s "$WORK/nowhere/box" "$B5/box" # dangling
|
|
|
|
|
|
check "heal: a DANGLING \$BINDIR/box does not wedge the install" 0 "done" inst "$H5" "$B5"
|
|
|
|
|
|
check "heal: ...and got repointed" 0 "box $VER" ibox "$B5/box" --version
|
|
|
|
|
|
H6="$WORK/h6"; B6="$WORK/b6"; mkdir -p "$B6"
|
|
|
|
|
|
ln -s /bin/true "$B6/box" # stale, but resolvable
|
|
|
|
|
|
check "heal: a STALE \$BINDIR/box with no tree does not fake 'installed'" 0 "installing $VER" \
|
|
|
|
|
|
inst "$H6" "$B6"
|
|
|
|
|
|
check "heal: ...the install is real and answers" 0 "box $VER" ibox "$B6/box" --version
|
|
|
|
|
|
|
|
|
|
|
|
# --- box uninstall: one version ---------------------------------------------
|
|
|
|
|
|
check "uninstall: refuses to remove the CURRENT version" 1 "CURRENT" \
|
|
|
|
|
|
ibox "$B1/box" uninstall "$VER" --force
|
|
|
|
|
|
check "uninstall: an unknown version is refused by name" 1 "no such version" \
|
|
|
|
|
|
ibox "$B1/box" uninstall 5.5.5 --force
|
fix: version names die at one shared gate, and --purge-host hears --force
Round-1 convergence from all three reviewers, both findings real:
A version string used to be a path fragment: 'box uninstall
../../../.ssh' resolved below versions/ and rm -rf'd wherever it
landed, 'box use' could point current outside the root, and a hostile
flat-tree VERSION could steer the migration's mv the same way. One
strict validator now gates every caller — only [A-Za-z0-9._+-], no
leading '.' or '-' — byte-identical in install.sh and bin/box like
existing_boxes, diff-guarded in the tests, with traversal regressions
on use, uninstall and the migration (which now refuses BEFORE the tree
moves anywhere).
--force is uninstall's installer-family consent, and --purge-host now
forwards it: teardown-host.sh gets --yes under --force/BOX_YES, so the
combined non-interactive uninstall no longer dies at teardown's own
prompt. CI's drill now runs the combined verb with --force alone (no
BOX_YES, no TTY) — the exact invocation that used to abort.
Also grok's polish, taken: current flips by rename (ln to a side name,
mv -Tf over — no window with no current) in both install.sh and 'box
use'; BOX_REINSTALL swaps by two renames and deletes LAST; and the
single-version path refuses while current is dangling (readlink -f
resolves a missing last component, so the guard checks the DIRECTORY,
not just the string).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 18:11:26 +00:00
|
|
|
|
check "uninstall: a path-traversal version dies at the gate (never an rm -rf)" 1 "not a sane version name" \
|
|
|
|
|
|
ibox "$B1/box" uninstall '../../../../etc' --force
|
test+ci: real installs driven offline, uninstall proven to zero residue
test/cli.sh drives REAL installer runs via BOX_INSTALL_SOURCE (temp
BOX_HOME/BOX_BIN, a fabricated second version for upgrades): fresh install
lands versioned, --version answers through the chain, side-by-side installs,
same-version no-op, BOX_REINSTALL, the 0.6.0 flat-tree migration, stale-
symlink healing, single-version and full uninstalls asserted to zero residue
(a planted survivor makes it scream INCOMPLETE), working-tree refusals, and
byte-identity of the existing_boxes copies. The rehearsal job now installs
via install.sh itself — CI proves the installer under review — and ends with
the uninstall drill: revoke --purge, teardown --yes, box uninstall, then
assert nothing is left (networks, profiles, nft, units, files, symlinks).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 16:02:37 +00:00
|
|
|
|
check "uninstall: a version plus --all is ambiguous (usage error)" 2 "" \
|
|
|
|
|
|
ibox "$B1/box" uninstall 9.9.9-drill --all --force
|
|
|
|
|
|
check "uninstall: removes a non-current version" 0 "removed version" \
|
|
|
|
|
|
ibox "$B1/box" uninstall 9.9.9-drill --force
|
|
|
|
|
|
check "uninstall: that version dir is gone" 1 "" test -e "$H1/versions/9.9.9-drill"
|
|
|
|
|
|
check "uninstall: the current version still answers" 0 "box $VER" ibox "$B1/box" --version
|
|
|
|
|
|
|
|
|
|
|
|
# --- box uninstall: everything, in the safe order ---------------------------
|
|
|
|
|
|
check "uninstall: refuses while boxes exist, naming them" 1 "wedged" \
|
|
|
|
|
|
ibox FAKE_BOXES=wedged "$B1/box" uninstall --all --force
|
|
|
|
|
|
check "uninstall: the refusal offers --purge-host" 1 "purge-host" \
|
|
|
|
|
|
ibox FAKE_BOXES=wedged "$B1/box" uninstall --all --force
|
|
|
|
|
|
check "uninstall: refuses without --force when no terminal" 2 "refusing" \
|
|
|
|
|
|
ibox bash -c "'$B1/box' uninstall --all </dev/null"
|
|
|
|
|
|
# Plant legacy crumbs: a real uninstall leaves neither name generation behind.
|
|
|
|
|
|
mkdir -p "$FAKEHOME/.local/share/claudebox"
|
|
|
|
|
|
ln -s "$WORK/gone" "$B1/claudebox"
|
|
|
|
|
|
check "uninstall --all: removes the whole install" 0 "uninstalled" \
|
|
|
|
|
|
ibox "$B1/box" uninstall --all --force
|
|
|
|
|
|
check "uninstall --all: ZERO residue — root, symlinks, legacy names" 0 "" bash -c "
|
|
|
|
|
|
[ ! -e '$H1' ] && [ ! -L '$H1' ] &&
|
|
|
|
|
|
[ ! -e '$B1/box' ] && [ ! -L '$B1/box' ] &&
|
|
|
|
|
|
[ ! -e '$B1/claudebox' ] && [ ! -L '$B1/claudebox' ] &&
|
|
|
|
|
|
[ ! -e '$FAKEHOME/.local/share/claudebox' ]"
|
|
|
|
|
|
# The last word is a re-check: a survivor must turn into a loud INCOMPLETE,
|
|
|
|
|
|
# never a cheerful "uninstalled". (Root ignores file modes, so this drill is
|
|
|
|
|
|
# meaningful — and runnable — for a non-root runner only.)
|
|
|
|
|
|
if [ "$(id -u)" -ne 0 ]; then
|
|
|
|
|
|
H7="$WORK/h7"; B7="$WORK/b7"
|
|
|
|
|
|
inst "$H7" "$B7" >/dev/null 2>&1
|
|
|
|
|
|
mkdir -p "$H7/versions/$VER/stuck"; touch "$H7/versions/$VER/stuck/pin"
|
|
|
|
|
|
chmod 555 "$H7/versions/$VER/stuck"
|
|
|
|
|
|
check "uninstall: a survivor makes it scream INCOMPLETE (exit 1)" 1 "INCOMPLETE" \
|
|
|
|
|
|
ibox "$B7/box" uninstall --all --force
|
|
|
|
|
|
chmod -R u+w "$H7" 2>/dev/null
|
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
|
|
# --- the versioned verbs from a working tree: refuse, don't guess -----------
|
|
|
|
|
|
check "uninstall: refuses from a working tree" 1 "not a versioned install" "$BOX" uninstall --all --force
|
|
|
|
|
|
check "versions: refuses from a working tree" 1 "not a versioned install" "$BOX" versions
|
|
|
|
|
|
check "use: refuses from a working tree" 1 "not a versioned install" "$BOX" use 1.0.0
|
|
|
|
|
|
|
|
|
|
|
|
# The existing-boxes gate must be ONE decision: install.sh and bin/box carry
|
|
|
|
|
|
# byte-identical copies (the installer runs before any tree exists), and a
|
|
|
|
|
|
# drifted copy is two #66 stances pretending to be one.
|
|
|
|
|
|
EBBIN="$(mktemp)"; EBINST="$(mktemp)"
|
|
|
|
|
|
awk '/^existing_boxes\(\) \{/,/^\}/' "$ROOT/bin/box" > "$EBBIN"
|
|
|
|
|
|
awk '/^existing_boxes\(\) \{/,/^\}/' "$ROOT/install.sh" > "$EBINST"
|
|
|
|
|
|
check "existing_boxes: extracted from bin/box (guards the awk)" 0 "user.box=1" cat "$EBBIN"
|
|
|
|
|
|
check "existing_boxes: bin/box and install.sh copies are byte-identical" 0 "" diff "$EBBIN" "$EBINST"
|
|
|
|
|
|
rm -f "$EBBIN" "$EBINST"
|
|
|
|
|
|
|
fix: version names die at one shared gate, and --purge-host hears --force
Round-1 convergence from all three reviewers, both findings real:
A version string used to be a path fragment: 'box uninstall
../../../.ssh' resolved below versions/ and rm -rf'd wherever it
landed, 'box use' could point current outside the root, and a hostile
flat-tree VERSION could steer the migration's mv the same way. One
strict validator now gates every caller — only [A-Za-z0-9._+-], no
leading '.' or '-' — byte-identical in install.sh and bin/box like
existing_boxes, diff-guarded in the tests, with traversal regressions
on use, uninstall and the migration (which now refuses BEFORE the tree
moves anywhere).
--force is uninstall's installer-family consent, and --purge-host now
forwards it: teardown-host.sh gets --yes under --force/BOX_YES, so the
combined non-interactive uninstall no longer dies at teardown's own
prompt. CI's drill now runs the combined verb with --force alone (no
BOX_YES, no TTY) — the exact invocation that used to abort.
Also grok's polish, taken: current flips by rename (ln to a side name,
mv -Tf over — no window with no current) in both install.sh and 'box
use'; BOX_REINSTALL swaps by two renames and deletes LAST; and the
single-version path refuses while current is dangling (readlink -f
resolves a missing last component, so the guard checks the DIRECTORY,
not just the string).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 18:11:26 +00:00
|
|
|
|
# Same discipline for the version-name gate: one policy, two copies, no drift
|
|
|
|
|
|
# — a version that install.sh would refuse must not be one 'box use' accepts.
|
|
|
|
|
|
VVBIN="$(mktemp)"; VVINST="$(mktemp)"
|
|
|
|
|
|
awk '/^valid_version\(\) \{/,/^\}/' "$ROOT/bin/box" > "$VVBIN"
|
|
|
|
|
|
awk '/^valid_version\(\) \{/,/^\}/' "$ROOT/install.sh" > "$VVINST"
|
|
|
|
|
|
check "valid_version: extracted from bin/box (guards the awk)" 0 "A-Za-z0-9" cat "$VVBIN"
|
|
|
|
|
|
check "valid_version: bin/box and install.sh copies are byte-identical" 0 "" diff "$VVBIN" "$VVINST"
|
|
|
|
|
|
rm -f "$VVBIN" "$VVINST"
|
|
|
|
|
|
|
|
|
|
|
|
# --purge-host must FORWARD installer-family consent: under --force/BOX_YES
|
|
|
|
|
|
# the teardown call carries --yes, or a non-interactive combined uninstall
|
|
|
|
|
|
# dies at teardown's own prompt with the flag's promise broken.
|
|
|
|
|
|
# shellcheck disable=SC2016 # the $-string is a literal in the target file
|
|
|
|
|
|
check "uninstall: --purge-host forwards consent to teardown-host (--yes)" 0 "" \
|
|
|
|
|
|
grep -qF -- 'bash "$root/host/teardown-host.sh" --yes' "$ROOT/bin/box"
|
|
|
|
|
|
|
test+ci: real installs driven offline, uninstall proven to zero residue
test/cli.sh drives REAL installer runs via BOX_INSTALL_SOURCE (temp
BOX_HOME/BOX_BIN, a fabricated second version for upgrades): fresh install
lands versioned, --version answers through the chain, side-by-side installs,
same-version no-op, BOX_REINSTALL, the 0.6.0 flat-tree migration, stale-
symlink healing, single-version and full uninstalls asserted to zero residue
(a planted survivor makes it scream INCOMPLETE), working-tree refusals, and
byte-identity of the existing_boxes copies. The rehearsal job now installs
via install.sh itself — CI proves the installer under review — and ends with
the uninstall drill: revoke --purge, teardown --yes, box uninstall, then
assert nothing is left (networks, profiles, nft, units, files, symlinks).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 16:02:37 +00:00
|
|
|
|
# --- the help keeps its promises --------------------------------------------
|
|
|
|
|
|
check "help: the table lists 'versions'" 0 "versions" "$BOX" help
|
|
|
|
|
|
check "help use: names the #66 stance" 0 "boxes" "$BOX" help use
|
|
|
|
|
|
check "help uninstall: names --purge-host" 0 "purge-host" "$BOX" help uninstall
|
|
|
|
|
|
check "help uninstall: promises the absence re-check" 0 "absence" "$BOX" help uninstall
|
|
|
|
|
|
|
|
|
|
|
|
# --- automation hooks the CI uninstall drill rides ---------------------------
|
|
|
|
|
|
check "teardown-host: honors --yes/BOX_YES (CI runs it unattended)" 0 "" \
|
|
|
|
|
|
grep -qF 'BOX_YES' "$ROOT/host/teardown-host.sh"
|
|
|
|
|
|
check "teardown-host: points at box uninstall when done" 0 "" \
|
|
|
|
|
|
grep -qF "box uninstall" "$ROOT/host/teardown-host.sh"
|
|
|
|
|
|
check "drill: reads the installed tree through current/" 0 "" \
|
|
|
|
|
|
grep -qF '.local/share/box/current/VERSION' "$ROOT/drill/drill.sh"
|
2026-07-18 00:01:15 +00:00
|
|
|
|
|
|
|
|
|
|
echo "---"
|
|
|
|
|
|
echo "$PASS passed, $FAIL failed"
|
test+ci: real installs driven offline, uninstall proven to zero residue
test/cli.sh drives REAL installer runs via BOX_INSTALL_SOURCE (temp
BOX_HOME/BOX_BIN, a fabricated second version for upgrades): fresh install
lands versioned, --version answers through the chain, side-by-side installs,
same-version no-op, BOX_REINSTALL, the 0.6.0 flat-tree migration, stale-
symlink healing, single-version and full uninstalls asserted to zero residue
(a planted survivor makes it scream INCOMPLETE), working-tree refusals, and
byte-identity of the existing_boxes copies. The rehearsal job now installs
via install.sh itself — CI proves the installer under review — and ends with
the uninstall drill: revoke --purge, teardown --yes, box uninstall, then
assert nothing is left (networks, profiles, nft, units, files, symlinks).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 16:02:37 +00:00
|
|
|
|
rm -rf "$SHIMDIR" "$WORK"
|
2026-07-18 00:01:15 +00:00
|
|
|
|
[ "$FAIL" -eq 0 ]
|