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
|
2026-07-19 23:57:19 +00:00
|
|
|
|
# BOX_YES is this family's documented automation switch, so an operator's CI
|
|
|
|
|
|
# wrapper may well export it. Checks that drive a destructive script for real
|
|
|
|
|
|
# would then take the CONSENT arm instead of the refusal they are asserting —
|
|
|
|
|
|
# turning this suite into `box uninstall --purge-host` on the host it runs on.
|
|
|
|
|
|
# Individual call sites use `env -u BOX_YES`; this is the belt to that braces,
|
|
|
|
|
|
# so the header's "runnable anywhere" promise cannot be broken by one export.
|
|
|
|
|
|
unset BOX_YES
|
2026-07-18 00:01:15 +00:00
|
|
|
|
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)"
|
feat: a minted box records how it was minted, and box info reads it back
cmd_new knew a great deal at the moment it launched and wrote three user.*
keys, dropping the rest: the box version that minted it, the base image (an
unpinned alias on a moving remote), the rig role, which rig repo and ref
converged it, the mint time, and whether a container was chosen or fallen
back into for want of /dev/kvm. There is no host-side per-box store — the
Incus instance config IS the database — so every one of those facts was gone
the moment the mint returned.
The same single write point now carries them as user.box.*, plus
user.box.schema=1 naming the stamp's shape. The alias's resolved fingerprint
is pinned in a second call after the launch, read from volatile.base_image,
best-effort by construction: a box that exists and boots must never be failed
over a provenance field.
A clone re-stamps rather than inheriting. 'incus copy' carries every user.*
key forward (audit B2), so an inherited stamp would not go stale, it would go
false. --from now re-stamps schema/version/created/origin/origin.from on the
copied instance before it starts, and leaves the lineage keys (template,
user, image, role, rig pin) alone — the clone's disk genuinely came from
them. origin.from records one hop.
cmd_info grows a provenance block, tolerating absence everywhere: boxes
minted before this stamp existed render as a box with blanks, and a schema
this box does not recognise is treated as newer than it, not as broken.
Closes #103.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:12:47 +00:00
|
|
|
|
# The pin's DEFAULTS live in rig_repo/rig_ref, one definition shared with the
|
|
|
|
|
|
# mint stamp (#103) — extract them alongside the function that reads them, or
|
|
|
|
|
|
# the extracted copy silently renders an empty repo and every assertion below
|
|
|
|
|
|
# goes green against nothing.
|
|
|
|
|
|
{ grep -cE '^rig_(repo|ref)\(\)' "$ROOT/bin/box" | grep -qx 2 || echo 'die "the rig pin helpers moved — this extraction is stale"'
|
|
|
|
|
|
grep -E '^rig_(repo|ref)\(\)' "$ROOT/bin/box"
|
|
|
|
|
|
awk '/^render_userdata\(\) \{/,/^\}/' "$ROOT/bin/box"; } > "$RUFN"
|
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
|
|
|
|
check "render_userdata: extracted from bin/box (guards the awk)" 0 "RIG_REPO" cat "$RUFN"
|
feat: a minted box records how it was minted, and box info reads it back
cmd_new knew a great deal at the moment it launched and wrote three user.*
keys, dropping the rest: the box version that minted it, the base image (an
unpinned alias on a moving remote), the rig role, which rig repo and ref
converged it, the mint time, and whether a container was chosen or fallen
back into for want of /dev/kvm. There is no host-side per-box store — the
Incus instance config IS the database — so every one of those facts was gone
the moment the mint returned.
The same single write point now carries them as user.box.*, plus
user.box.schema=1 naming the stamp's shape. The alias's resolved fingerprint
is pinned in a second call after the launch, read from volatile.base_image,
best-effort by construction: a box that exists and boots must never be failed
over a provenance field.
A clone re-stamps rather than inheriting. 'incus copy' carries every user.*
key forward (audit B2), so an inherited stamp would not go stale, it would go
false. --from now re-stamps schema/version/created/origin/origin.from on the
copied instance before it starts, and leaves the lineage keys (template,
user, image, role, rig pin) alone — the clone's disk genuinely came from
them. origin.from records one hop.
cmd_info grows a provenance block, tolerating absence everywhere: boxes
minted before this stamp existed render as a box with blanks, and a schema
this box does not recognise is treated as newer than it, not as broken.
Closes #103.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:12:47 +00:00
|
|
|
|
check "render_userdata: the pin's defaults came with it (guards the grep)" 0 "heavy-duty/rig" cat "$RUFN"
|
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
|
|
|
|
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.
|
refactor(templates): the tenant seeds carry rig's -box family suffix
rig is growing a second family of roles, and once a 'staging' role can mean
either a fleet machine or a box tenant, the bare name stops naming anything.
rig's answer is a suffix on the role (heavy-duty/rig#76): '-server' for fleet
machines, '-box' for box tenants. box's answer is that a template keeps being
named for the role it converges, so the tenant templates move with it:
claude -> claude-box codex -> codex-box
grok -> grok-box staging -> staging-box
Templates are the only surface that spells a rig role out loud
(BOX_BOOTSTRAP_ROLE, auto-run at mint since #81), so a directory that says one
thing and a role key that says another is a trap with a 15-minute fuse: it
mints clean and dies at convergence. Renamed with 'git mv' so the history of
each seed follows it.
'blank' keeps its name. It seeds no tenant role and sets no
BOX_BOOTSTRAP_ROLE, so it has nothing to agree with — renaming it would only
churn the default template's name for symmetry's sake.
Two namespaces move apart here and only one of them moved: the template name
and the role are now claude-box, while the seed USER stays 'claude' — that is
the user rig's role converges and the one 'box shell' lands in. test/cli.sh
pins the pair per tenant rather than each half alone, because a later rename
that moves one and forgets the other mints a box whose role dies looking for a
user nobody created. drill.sh keeps its bare box NAMES ('codex', 'grok' — what
the pre-flight banner announces and what teardown deletes) and only moves the
--template it passes.
The mint-time hints in cmd_new match both spellings of user.box.template, and
that is not an alias for the role: 'rig bootstrap claude' is gone and nothing
here softens the cut. The stamp is a fact about an INSTANCE, written at its own
mint time and carried forward by every clone; refusing the old spelling would
cut nothing over and only drop the login hint on boxes that predate today —
the same reason user.claudebox is honored everywhere else. migrate-host.sh
stamps re-homed legacy boxes claude-box, the name the template has today, so a
re-homed box looks like a fresh mint rather than a fossil.
Ordered AFTER rig's rename, and that is not a preference. The seeds install rig
from RIG_REPO/RIG_REF, defaulting to heavy-duty/rig@main and unpinned until
rig#32's releases, so these templates ask whatever main happens to be for
'rig bootstrap claude-box'. Against a pre-rename rig that role does not exist
and cmd_new refuses to call the box ready. Merged in the other order the window
closes instead of opening: rig's cut is hard, with no aliases, so the day it
lands every unmerged box seed naming a bare role is the broken one.
Closes #123
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 00:01:29 +00:00
|
|
|
|
check "staging-box: demands VM mode (BOX_REQUIRE_VM=1)" 0 "" \
|
|
|
|
|
|
grep -qx 'BOX_REQUIRE_VM="1"' "$ROOT/templates/staging-box/box.env"
|
|
|
|
|
|
check "staging-box: demands autostart (BOX_AUTOSTART=1)" 0 "" \
|
|
|
|
|
|
grep -qx 'BOX_AUTOSTART="1"' "$ROOT/templates/staging-box/box.env"
|
|
|
|
|
|
check "staging-box: the tenant role is 'staging-box'" 0 "ROLE=staging-box" tpl "$ROOT" staging-box
|
|
|
|
|
|
check "staging-box: the seed user is rig's default for the role ('ops')" 0 "USER=ops" tpl "$ROOT" staging-box
|
|
|
|
|
|
# The agent tenants. Two names, not one: the TEMPLATE is named for the rig role
|
|
|
|
|
|
# it converges — suffix and all, since rig's roles carry a family suffix
|
|
|
|
|
|
# ('-box' for box tenants, '-server' for fleet machines, rig#76) — while the
|
|
|
|
|
|
# seed USER stays the bare agent name, because that is the user rig's role
|
|
|
|
|
|
# converges and the one 'box shell' lands in. The pairing is the whole point of
|
|
|
|
|
|
# pinning it here: a rename that moves one and forgets the other mints a box
|
|
|
|
|
|
# whose role dies looking for a user that was never created.
|
|
|
|
|
|
for u in claude codex grok; do
|
|
|
|
|
|
check "$u-box: role is '$u-box', seed user is '$u' (rig's tenant mapping)" \
|
|
|
|
|
|
0 "USER=$u REQUIRE_VM= AUTOSTART= ROLE=$u-box" tpl "$ROOT" "$u-box"
|
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
|
|
|
|
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"'
|
fix: narrate and time-box the incus launch — a wedge fails loudly, not forever (#93)
Twice in the 2026-07-19 release drill (Debian 13, Incus 6.x, /dev/kvm
present, images cached), the child 'incus launch' under 'box new' wedged
with no server-side operation: 'incus operation list' empty, the instance
never created, the daemon journal quiet — one wedge ran 56 minutes before
being killed by hand, the other was killed by a 540s wrapper. An immediate
retry of the identical command succeeded in ~2-3 minutes, both times. box
inherited that as an indefinite silent hang, indistinguishable from a cold
mint working.
The mint now prints "launching instance ..." before the call, and the call
rides 'timeout -k 5 $BOX_LAUNCH_TIMEOUT' (seconds, default 600 — generous:
the coldest measured mint is minutes, never an hour; overridable the same
way BOX_CPU/BOX_MEMORY are), with stdin pinned per drill/RUNS.md trap 13.
When the budget fires (124, or 137 when the KILL was needed) the failure
says exactly what was measured — the client wedged with no server-side
operation, an immediate retry has been observed to succeed — and points at
'box doctor' for host state. A non-timeout launch failure still surfaces
incus's own stderr. The --from clone path is untouched: 'incus copy' of a
local instance is a different operation and has never been observed to
wedge this way.
Proven the way the other mint-path guards are (a daemon-free run cannot
mint): test/cli.sh greps that the narration orders before the launch, that
the launch sits under 'timeout -k' with the BOX_LAUNCH_TIMEOUT budget and
pinned stdin, and that the wedge message carries the retry hint, the
doctor, and #93 — plus a live shim-incus drive of all three exits (wedge,
plain refusal, success) during development.
Fixes #93
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 12:28:22 +00:00
|
|
|
|
|
|
|
|
|
|
# The launch phase, narrated and time-boxed (#93) — grepped the way the other
|
|
|
|
|
|
# mint-path guards are (a daemon-free run cannot mint). Twice in the
|
|
|
|
|
|
# 2026-07-19 release drill the child 'incus launch' wedged silently before
|
|
|
|
|
|
# the create was even accepted, once for 56 minutes. The narration must order
|
|
|
|
|
|
# BEFORE the launch call (a wedge after the line is visible at a glance; a
|
|
|
|
|
|
# wedge before it is the old silent hang), the call itself must sit under
|
|
|
|
|
|
# 'timeout -k' with the BOX_LAUNCH_TIMEOUT override and pinned stdin (RUNS.md
|
|
|
|
|
|
# trap 13: bare 'timeout N' cannot kill an incus call that owns a TTY), and
|
|
|
|
|
|
# the budget's failure must be LOUD — no server-side operation, the measured
|
|
|
|
|
|
# retry-succeeds hint, and the doctor as the next move.
|
|
|
|
|
|
# shellcheck disable=SC2016 # the $-strings are literals in the target file
|
|
|
|
|
|
check "new: the launch narration orders before incus launch (#93)" 0 "" bash -c '
|
|
|
|
|
|
fn="$(awk "/^cmd_new\(\) \{/,/^\}/" "'"$ROOT"'/bin/box")"
|
|
|
|
|
|
say="$(printf "%s\n" "$fn" | grep -n "launching instance" | head -1 | cut -d: -f1)"
|
|
|
|
|
|
run="$(printf "%s\n" "$fn" | grep -n "timeout -k.*incus launch" | head -1 | cut -d: -f1)"
|
|
|
|
|
|
[ -n "$say" ] && [ -n "$run" ] && [ "$say" -lt "$run" ]'
|
|
|
|
|
|
check "new: incus launch is time-boxed (timeout -k on the budget)" 0 "" bash -c '
|
|
|
|
|
|
awk "/^cmd_new\(\) \{/,/^\}/" "'"$ROOT"'/bin/box" \
|
|
|
|
|
|
| grep "timeout -k" | grep "budget" | grep -q "incus launch"'
|
|
|
|
|
|
check "new: the budget is BOX_LAUNCH_TIMEOUT, default 600s (the BOX_CPU knob shape)" 0 "" bash -c '
|
|
|
|
|
|
awk "/^cmd_new\(\) \{/,/^\}/" "'"$ROOT"'/bin/box" \
|
|
|
|
|
|
| grep "budget=" | grep -q "BOX_LAUNCH_TIMEOUT:-600"'
|
|
|
|
|
|
check "new: the launch pins stdin (RUNS.md trap 13)" 0 "" bash -c '
|
|
|
|
|
|
awk "/^cmd_new\(\) \{/,/^\}/" "'"$ROOT"'/bin/box" \
|
|
|
|
|
|
| grep -F "extra[@]" | grep -qF "</dev/null"'
|
|
|
|
|
|
# shellcheck disable=SC2016 # the $-strings are literals in the target file
|
|
|
|
|
|
check "new: the wedge failure is loud — retry hint, the doctor, and #93" 0 "" bash -c '
|
|
|
|
|
|
fn="$(awk "/^cmd_new\(\) \{/,/^\}/" "'"$ROOT"'/bin/box")"
|
|
|
|
|
|
printf "%s\n" "$fn" | grep -A6 "WEDGED" | grep -q "observed to succeed" &&
|
fix: the timeout path probes the instance, tells the two stories apart, and cleans up
Round-1 consensus on #94: timeout only proves the CLIENT overran the
budget. incus launch is create-then-start, so a slow-but-progressing
launch may already have registered the instance — the old message claimed
'never created' unconditionally and the advised retry would collide with
'Instance already exists'. The 124/137 path now probes 'incus info',
narrates the branch it found (true #93 wedge vs slow-launch overrun),
best-effort 'incus delete --force's either way so the retry is clean in
both worlds, hedges 137 as possibly an outside kill, and BOX_LAUNCH_TIMEOUT
is documented in 'box help new' beside the other knobs. Both branches
driven live against a shim incus; four new grep-proof checks pin the probe,
the cleanup, the overrun story, and the help text.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 12:52:41 +00:00
|
|
|
|
printf "%s\n" "$fn" | grep -q "box doctor" &&
|
|
|
|
|
|
printf "%s\n" "$fn" | grep "did not finish inside" | grep -q "#93"'
|
|
|
|
|
|
# timeout proves only that the CLIENT overran the budget: launch is
|
|
|
|
|
|
# create-then-start, so a slow launch may have REGISTERED the instance and a
|
|
|
|
|
|
# blind "never created, retry" would send the operator into 'Instance already
|
|
|
|
|
|
# exists' (#94 round-1, all three reviewers). The timeout path must probe the
|
|
|
|
|
|
# instance, tell the two stories apart, and best-effort delete either way so
|
|
|
|
|
|
# the retry advice is safe in both worlds.
|
2026-07-19 12:59:58 +00:00
|
|
|
|
# shellcheck disable=SC2016 # the $-strings are literals in the target file
|
fix: the timeout path probes the instance, tells the two stories apart, and cleans up
Round-1 consensus on #94: timeout only proves the CLIENT overran the
budget. incus launch is create-then-start, so a slow-but-progressing
launch may already have registered the instance — the old message claimed
'never created' unconditionally and the advised retry would collide with
'Instance already exists'. The 124/137 path now probes 'incus info',
narrates the branch it found (true #93 wedge vs slow-launch overrun),
best-effort 'incus delete --force's either way so the retry is clean in
both worlds, hedges 137 as possibly an outside kill, and BOX_LAUNCH_TIMEOUT
is documented in 'box help new' beside the other knobs. Both branches
driven live against a shim incus; four new grep-proof checks pin the probe,
the cleanup, the overrun story, and the help text.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 12:52:41 +00:00
|
|
|
|
check "new: the timeout path probes before claiming never-created (#94 r1)" 0 "" bash -c '
|
|
|
|
|
|
awk "/^cmd_new\(\) \{/,/^\}/" "'"$ROOT"'/bin/box" \
|
|
|
|
|
|
| grep "incus info" | grep -q "\$instance"'
|
2026-07-19 12:59:58 +00:00
|
|
|
|
# shellcheck disable=SC2016 # the $-strings are literals in the target file
|
fix: the timeout path probes the instance, tells the two stories apart, and cleans up
Round-1 consensus on #94: timeout only proves the CLIENT overran the
budget. incus launch is create-then-start, so a slow-but-progressing
launch may already have registered the instance — the old message claimed
'never created' unconditionally and the advised retry would collide with
'Instance already exists'. The 124/137 path now probes 'incus info',
narrates the branch it found (true #93 wedge vs slow-launch overrun),
best-effort 'incus delete --force's either way so the retry is clean in
both worlds, hedges 137 as possibly an outside kill, and BOX_LAUNCH_TIMEOUT
is documented in 'box help new' beside the other knobs. Both branches
driven live against a shim incus; four new grep-proof checks pin the probe,
the cleanup, the overrun story, and the help text.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 12:52:41 +00:00
|
|
|
|
check "new: the timeout path best-effort deletes, so retry is always clean" 0 "" bash -c '
|
|
|
|
|
|
awk "/^cmd_new\(\) \{/,/^\}/" "'"$ROOT"'/bin/box" \
|
|
|
|
|
|
| grep "incus delete --force" | grep -q "|| true"'
|
2026-07-19 12:59:58 +00:00
|
|
|
|
# shellcheck disable=SC2016 # the $-strings are literals in the target file
|
fix: the timeout path probes the instance, tells the two stories apart, and cleans up
Round-1 consensus on #94: timeout only proves the CLIENT overran the
budget. incus launch is create-then-start, so a slow-but-progressing
launch may already have registered the instance — the old message claimed
'never created' unconditionally and the advised retry would collide with
'Instance already exists'. The 124/137 path now probes 'incus info',
narrates the branch it found (true #93 wedge vs slow-launch overrun),
best-effort 'incus delete --force's either way so the retry is clean in
both worlds, hedges 137 as possibly an outside kill, and BOX_LAUNCH_TIMEOUT
is documented in 'box help new' beside the other knobs. Both branches
driven live against a shim incus; four new grep-proof checks pin the probe,
the cleanup, the overrun story, and the help text.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 12:52:41 +00:00
|
|
|
|
check "new: the overran-but-registered branch says so (not the wedge story)" 0 "" bash -c '
|
|
|
|
|
|
awk "/^cmd_new\(\) \{/,/^\}/" "'"$ROOT"'/bin/box" \
|
|
|
|
|
|
| grep "OVERRAN" | grep -q "budget"'
|
2026-07-19 12:59:58 +00:00
|
|
|
|
# shellcheck disable=SC2016 # the $-strings are literals in the target file
|
fix: the timeout path probes the instance, tells the two stories apart, and cleans up
Round-1 consensus on #94: timeout only proves the CLIENT overran the
budget. incus launch is create-then-start, so a slow-but-progressing
launch may already have registered the instance — the old message claimed
'never created' unconditionally and the advised retry would collide with
'Instance already exists'. The 124/137 path now probes 'incus info',
narrates the branch it found (true #93 wedge vs slow-launch overrun),
best-effort 'incus delete --force's either way so the retry is clean in
both worlds, hedges 137 as possibly an outside kill, and BOX_LAUNCH_TIMEOUT
is documented in 'box help new' beside the other knobs. Both branches
driven live against a shim incus; four new grep-proof checks pin the probe,
the cleanup, the overrun story, and the help text.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 12:52:41 +00:00
|
|
|
|
check "new: BOX_LAUNCH_TIMEOUT is documented in box help new" 0 "" bash -c '
|
|
|
|
|
|
"'"$ROOT"'/bin/box" help new | grep "BOX_LAUNCH_TIMEOUT" | grep -q 600'
|
2026-07-20 00:07:00 +00:00
|
|
|
|
# staging-box'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-server" — the one absence that keeps box creds-free end to end.
|
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
|
|
|
|
check "new: the workload join is printed, never exec'd" 1 "" bash -c '
|
2026-07-20 00:07:00 +00:00
|
|
|
|
grep "rig bootstrap workload-server" "'"$ROOT"'/bin/box" | grep -q "incus exec"'
|
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
|
|
|
|
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
|
|
|
|
# ---------------------------------------------------------------------------
|
2026-07-20 10:04:49 +00:00
|
|
|
|
# The 'pristine' mark (#104, child of heavy-duty/rig#62). The whole feature is
|
|
|
|
|
|
# a MOMENT: the guest after cloud-init and before rig converges anything. Get
|
|
|
|
|
|
# the position wrong by one step and the mark is a lie — a 'pristine' taken
|
|
|
|
|
|
# after 'rig bootstrap' is a converged box wearing the wrong label, and
|
|
|
|
|
|
# nothing at runtime would ever say so. So the position is pinned by line
|
|
|
|
|
|
# order, the way the other mint-path guards are (a daemon-free run cannot
|
|
|
|
|
|
# mint), and the policy half is DRIVEN against a stubbed incus.
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# shellcheck disable=SC2016 # the $-strings are literals in the target file
|
|
|
|
|
|
check "pristine: the mark is taken in the fresh-mint branch" 0 "" bash -c '
|
|
|
|
|
|
awk "/^cmd_new\(\) \{/,/^\}/" "'"$ROOT"'/bin/box" | grep -q "snapshot_pristine \"\$instance\""'
|
|
|
|
|
|
# AFTER cloud-init: before it, the guest is mid-install and the mark is not
|
|
|
|
|
|
# pristine Debian, it is a half-provisioned one.
|
|
|
|
|
|
# shellcheck disable=SC2016 # the $-strings are literals in the target file
|
|
|
|
|
|
check "pristine: the mark 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)"
|
|
|
|
|
|
snap="$(printf "%s\n" "$fn" | grep -n "snapshot_pristine " | head -1 | cut -d: -f1)"
|
|
|
|
|
|
[ -n "$wait" ] && [ -n "$snap" ] && [ "$wait" -lt "$snap" ]'
|
|
|
|
|
|
# BEFORE the rig hook: this is the assertion the whole issue rests on.
|
|
|
|
|
|
# shellcheck disable=SC2016 # the $-strings are literals in the target file
|
|
|
|
|
|
check "pristine: the mark orders BEFORE the rig bootstrap hook (the moment)" 0 "" bash -c '
|
|
|
|
|
|
fn="$(awk "/^cmd_new\(\) \{/,/^\}/" "'"$ROOT"'/bin/box")"
|
|
|
|
|
|
snap="$(printf "%s\n" "$fn" | grep -n "snapshot_pristine " | head -1 | cut -d: -f1)"
|
|
|
|
|
|
run="$(printf "%s\n" "$fn" | grep -n "rig bootstrap \$T_BOOTSTRAP_ROLE" | head -1 | cut -d: -f1)"
|
|
|
|
|
|
[ -n "$snap" ] && [ -n "$run" ] && [ "$snap" -lt "$run" ]'
|
|
|
|
|
|
# NOT under the T_BOOTSTRAP_ROLE guard: a blank box has no rig hook but has
|
|
|
|
|
|
# the same pristine moment, and "box restore <box> pristine" must mean one
|
|
|
|
|
|
# thing on every box box mints.
|
|
|
|
|
|
# shellcheck disable=SC2016 # the $-strings are literals in the target file
|
|
|
|
|
|
check "pristine: the mark is unconditional, not gated on a tenant role" 0 "" bash -c '
|
|
|
|
|
|
fn="$(awk "/^cmd_new\(\) \{/,/^\}/" "'"$ROOT"'/bin/box")"
|
|
|
|
|
|
snap="$(printf "%s\n" "$fn" | grep -n "snapshot_pristine " | head -1 | cut -d: -f1)"
|
|
|
|
|
|
guard="$(printf "%s\n" "$fn" | grep -n "if \[ -n \"\$T_BOOTSTRAP_ROLE\" \]" | head -1 | cut -d: -f1)"
|
|
|
|
|
|
[ -n "$snap" ] && [ -n "$guard" ] && [ "$snap" -lt "$guard" ]'
|
|
|
|
|
|
|
|
|
|
|
|
# THE CLONE TRAP. --from skips cloud-init and the rig hook entirely, so the
|
|
|
|
|
|
# pristine moment never happens on that path. A mark taken there would be
|
|
|
|
|
|
# "whatever the source was" — converged, worked-in — wearing a label that
|
|
|
|
|
|
# promises pristine Debian, which is strictly worse than no mark. Pin the
|
|
|
|
|
|
# ABSENCE: extract the clone branch alone (up to its 'else') and assert
|
|
|
|
|
|
# nothing in it takes the mark.
|
|
|
|
|
|
CLONEBR="$(mktemp)"
|
|
|
|
|
|
awk '/if \[ -n "\$from" \]; then/,/^ else$/' "$ROOT/bin/box" > "$CLONEBR"
|
|
|
|
|
|
check "pristine: the clone branch extracted from bin/box (guards the awk)" 0 "incus copy" cat "$CLONEBR"
|
|
|
|
|
|
check "pristine: a --from clone takes NO mark of its own (the correctness trap)" 1 "" \
|
|
|
|
|
|
grep -q "snapshot_pristine" "$CLONEBR"
|
|
|
|
|
|
check "pristine: nothing on the clone path creates a snapshot at all" 1 "" \
|
|
|
|
|
|
grep -q "incus snapshot create" "$CLONEBR"
|
|
|
|
|
|
# Inheritance is the other half of the decision, and it must be SAID: a clone
|
|
|
|
|
|
# of a box carries the source's snapshots (a real pristine among them), a
|
|
|
|
|
|
# clone of a snapshot carries none. Silence there sends the operator to
|
|
|
|
|
|
# 'box info' to find out which world they are in.
|
|
|
|
|
|
check "pristine: the clone narrates whether a pristine rode along" 0 "" \
|
|
|
|
|
|
grep -q "no 'pristine' mark here" "$CLONEBR"
|
fix: make the 'pristine' path's never-fatal contract structural, not incidental
Both from claude-bot's review of #128.
storage_driver's two driver probes had no '|| true' while the pool probe one
line up did. They were safe only via a bash subtlety: command substitution
strips errexit, so a failing 'incus storage show' fell through to the fallback
instead of aborting. Add 'shopt -s inherit_errexit' to bin/box — the class of
robustness tweak #107 documents sailing through review — and under pipefail a
restricted tier's probe refusal becomes a fatal abort mid-mint, inside the
function whose contract is NEVER fatal. All three probes now read alike and
depend on nothing. Driven test: refuse both probes with inherit_errexit on,
assert the caller survives and the driver reads empty (the unreadable-pool
case, which already takes the mark anyway).
The clone's inheritance narration piped a multi-line 'incus snapshot list'
straight into 'grep -q' — #124's racing-reader class in a 'set -euo pipefail'
file. A 141 there reads as "no pristine" and narrates the WRONG inheritance
shape on a clone that does carry one. Capture first, then read. Pinned by
shape (grep|head|sed|awk|read), not by instance spelling.
bin/box has ~10 more sites of that class and no sweep covers the file; one of
them unpins an exposure's static address on a fail-open read. Filed as #134
rather than widened into this PR.
Refs #104, #107, #124, #134.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 11:29:18 +00:00
|
|
|
|
# ...and it reads the snapshot list CAPTURE-FIRST (#124's class). Piping a
|
|
|
|
|
|
# multi-line incus writer into an early-exit reader lets the reader close the
|
|
|
|
|
|
# pipe, SIGPIPE incus, and hand pipefail a 141 — which on THIS line reads as
|
|
|
|
|
|
# "no pristine" and narrates the wrong inheritance shape on a clone that has
|
|
|
|
|
|
# one. Pin the shape, not the instance spelling: no 'incus snapshot list'
|
|
|
|
|
|
# feeding grep/head/sed/awk/read directly.
|
|
|
|
|
|
check "pristine: the clone's inheritance read is capture-first, not a piped early-exit reader" 1 "" \
|
|
|
|
|
|
grep -Eq 'incus snapshot list[^|]*\| *(grep|head|sed|awk|read)' "$CLONEBR"
|
2026-07-20 10:04:49 +00:00
|
|
|
|
rm -f "$CLONEBR"
|
|
|
|
|
|
|
|
|
|
|
|
# The policy half, DRIVEN not grepped: extract storage_driver +
|
|
|
|
|
|
# snapshot_pristine and run them against a stubbed incus, so every branch is
|
|
|
|
|
|
# actually executed on a host with no daemon.
|
|
|
|
|
|
PRISFN="$(mktemp)"
|
|
|
|
|
|
awk '/^storage_driver\(\) \{/,/^\}/;/^snapshot_pristine\(\) \{/,/^\}/' "$ROOT/bin/box" > "$PRISFN"
|
|
|
|
|
|
check "pristine: the functions extracted from bin/box (guards the awk)" 0 "BOX_SNAPSHOT_PRISTINE" cat "$PRISFN"
|
|
|
|
|
|
check "pristine: the extracted functions are valid bash" 0 "" bash -n "$PRISFN"
|
|
|
|
|
|
|
fix: make the 'pristine' path's never-fatal contract structural, not incidental
Both from claude-bot's review of #128.
storage_driver's two driver probes had no '|| true' while the pool probe one
line up did. They were safe only via a bash subtlety: command substitution
strips errexit, so a failing 'incus storage show' fell through to the fallback
instead of aborting. Add 'shopt -s inherit_errexit' to bin/box — the class of
robustness tweak #107 documents sailing through review — and under pipefail a
restricted tier's probe refusal becomes a fatal abort mid-mint, inside the
function whose contract is NEVER fatal. All three probes now read alike and
depend on nothing. Driven test: refuse both probes with inherit_errexit on,
assert the caller survives and the driver reads empty (the unreadable-pool
case, which already takes the mark anyway).
The clone's inheritance narration piped a multi-line 'incus snapshot list'
straight into 'grep -q' — #124's racing-reader class in a 'set -euo pipefail'
file. A 141 there reads as "no pristine" and narrates the WRONG inheritance
shape on a clone that does carry one. Capture first, then read. Pinned by
shape (grep|head|sed|awk|read), not by instance spelling.
bin/box has ~10 more sites of that class and no sweep covers the file; one of
them unpins an exposure's static address on a fail-open read. Filed as #134
rather than widened into this PR.
Refs #104, #107, #124, #134.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 11:29:18 +00:00
|
|
|
|
# storage_driver's probes must survive a REFUSAL, and not by accident. Today
|
|
|
|
|
|
# command substitution strips errexit, so a failing probe falls through to the
|
|
|
|
|
|
# fallback; add 'shopt -s inherit_errexit' to bin/box — the robustness tweak
|
|
|
|
|
|
# #107 describes sailing through review — and under pipefail that same refusal
|
|
|
|
|
|
# becomes a fatal abort mid-mint, inside the function whose contract is NEVER
|
|
|
|
|
|
# fatal. Drive it with inherit_errexit ON and a tier that refuses both probes:
|
|
|
|
|
|
# the function must return empty (the unreadable-pool case) and the caller
|
|
|
|
|
|
# must still be alive afterwards.
|
|
|
|
|
|
driver_under_inherit_errexit() {
|
|
|
|
|
|
# shellcheck disable=SC2016 # the body is the stub's source, expanded by the
|
|
|
|
|
|
# inner bash, never by this shell.
|
|
|
|
|
|
env PRISFN="$PRISFN" bash -c '
|
|
|
|
|
|
set -euo pipefail
|
|
|
|
|
|
shopt -s inherit_errexit
|
|
|
|
|
|
incus() {
|
|
|
|
|
|
case "$*" in
|
|
|
|
|
|
"profile device get box-net root pool") printf "boxpool\n" ;;
|
|
|
|
|
|
*) printf "incus: not authorized\n" >&2; return 1 ;;
|
|
|
|
|
|
esac
|
|
|
|
|
|
}
|
|
|
|
|
|
. "$PRISFN"
|
|
|
|
|
|
d="$(storage_driver)"
|
|
|
|
|
|
printf "SURVIVED driver=[%s]\n" "$d"
|
|
|
|
|
|
' 2>&1
|
|
|
|
|
|
}
|
|
|
|
|
|
check "pristine: a refused storage probe is an answer, not a fatal (survives inherit_errexit)" 0 "SURVIVED driver=[]" \
|
|
|
|
|
|
driver_under_inherit_errexit
|
|
|
|
|
|
|
2026-07-20 10:04:49 +00:00
|
|
|
|
# pris <driver> [env...] — drive snapshot_pristine against a fake pool of
|
|
|
|
|
|
# <driver>. 'none' makes both probes answer nothing (the unreadable-pool
|
|
|
|
|
|
# case). Every incus call the function can make is stubbed and echoed, so the
|
|
|
|
|
|
# assertions read the real control flow, not a mock's opinion of it.
|
|
|
|
|
|
pris() { # pris <driver> <instance> [VAR=VAL...]
|
|
|
|
|
|
local driver="$1" instance="$2"; shift 2
|
|
|
|
|
|
# shellcheck disable=SC2016 # the body is the stub's source, expanded by the
|
|
|
|
|
|
# inner bash from the environment 'env' sets up — never by this shell.
|
|
|
|
|
|
env "$@" DRIVER="$driver" INSTANCE="$instance" PRISFN="$PRISFN" bash -c '
|
|
|
|
|
|
incus() {
|
|
|
|
|
|
case "$*" in
|
|
|
|
|
|
"profile device get box-net root pool") printf "boxpool\n" ;;
|
|
|
|
|
|
"storage show boxpool")
|
|
|
|
|
|
[ "$DRIVER" = none ] && return 1
|
|
|
|
|
|
printf "name: boxpool\ndriver: %s\n" "$DRIVER" ;;
|
|
|
|
|
|
"storage list --format csv")
|
|
|
|
|
|
[ "$DRIVER" = none ] && return 1
|
|
|
|
|
|
printf "boxpool,%s,,0,CREATED\n" "$DRIVER" ;;
|
|
|
|
|
|
"snapshot create inst-x pristine") printf "STUB: snapshot created\n" ;;
|
|
|
|
|
|
"snapshot create fail-x pristine") printf "STUB: incus refused\n" >&2; return 1 ;;
|
|
|
|
|
|
*) printf "STUB: unexpected incus call: %s\n" "$*" >&2; return 1 ;;
|
|
|
|
|
|
esac
|
|
|
|
|
|
}
|
|
|
|
|
|
. "$PRISFN"
|
|
|
|
|
|
snapshot_pristine "$INSTANCE" boxname
|
|
|
|
|
|
' 2>&1
|
|
|
|
|
|
}
|
|
|
|
|
|
# The absence assertions need a command 'check' can run, not a pipeline.
|
|
|
|
|
|
pris_took_mark() { pris "$@" | grep -q "STUB: snapshot created"; }
|
|
|
|
|
|
check "pristine: btrfs (the designed backend) takes the mark" 0 "STUB: snapshot created" \
|
|
|
|
|
|
pris btrfs inst-x
|
|
|
|
|
|
check "pristine: btrfs names the restore command for the operator" 0 "box restore boxname pristine" \
|
|
|
|
|
|
pris btrfs inst-x
|
|
|
|
|
|
# The 'dir' fallback (host/setup-host.sh:294) has no CoW: the mark would be a
|
|
|
|
|
|
# full multi-GB copy of the root on EVERY mint. Skip — and LOUDLY, naming the
|
|
|
|
|
|
# by-hand command, because a silent skip teaches an operator to expect a mark
|
|
|
|
|
|
# that is not there.
|
|
|
|
|
|
check "pristine: a 'dir' pool SKIPS the mark (no CoW — it would be a full copy)" 0 "NOT taking" \
|
|
|
|
|
|
pris dir inst-x
|
|
|
|
|
|
check "pristine: the dir skip is loud and names the by-hand command" 0 "box snapshot boxname pristine" \
|
|
|
|
|
|
pris dir inst-x
|
|
|
|
|
|
check "pristine: the dir skip never reaches incus snapshot create" 1 "" \
|
|
|
|
|
|
pris_took_mark dir inst-x
|
|
|
|
|
|
# Neither probe answers (an unusual host, or a tier that cannot read the
|
|
|
|
|
|
# pool). The two mistakes are not symmetric — a mark taken on 'dir' wastes
|
|
|
|
|
|
# disk the operator can see and delete, a mark NOT taken is the moment gone
|
|
|
|
|
|
# for good. So proceed, and say what was assumed.
|
|
|
|
|
|
check "pristine: an unreadable pool takes the mark anyway (the asymmetry)" 0 "STUB: snapshot created" \
|
|
|
|
|
|
pris none inst-x
|
|
|
|
|
|
check "pristine: ...and says what it assumed rather than pretending it knew" 0 "could not read the storage driver" \
|
|
|
|
|
|
pris none inst-x
|
|
|
|
|
|
# The escape hatch is an environment knob (the BOX_LAUNCH_TIMEOUT shape), not
|
|
|
|
|
|
# another flag on 'new'.
|
|
|
|
|
|
check "pristine: BOX_SNAPSHOT_PRISTINE=0 skips it anywhere" 0 "BOX_SNAPSHOT_PRISTINE=0" \
|
|
|
|
|
|
pris btrfs inst-x BOX_SNAPSHOT_PRISTINE=0
|
|
|
|
|
|
check "pristine: the opt-out never reaches incus snapshot create" 1 "" \
|
|
|
|
|
|
pris_took_mark btrfs inst-x BOX_SNAPSHOT_PRISTINE=0
|
|
|
|
|
|
# A failed snapshot must NOT fail the mint. The mark is an undo, not the
|
|
|
|
|
|
# mint's product: a mint that worked must not be failed by a checkpoint that
|
|
|
|
|
|
# didn't.
|
|
|
|
|
|
check "pristine: a failed snapshot warns and returns 0 (never fails a good mint)" 0 "WARNING" \
|
|
|
|
|
|
pris btrfs fail-x
|
|
|
|
|
|
rm -f "$PRISFN"
|
|
|
|
|
|
|
|
|
|
|
|
# The durability caveat, pinned in the help text: a snapshot dies with its
|
|
|
|
|
|
# box, so nothing box says may let anyone read 'pristine' as a backup (#104's
|
|
|
|
|
|
# closing note; 'box export' is the durable path).
|
|
|
|
|
|
check "pristine: 'box help snapshot' refuses to sell snapshots as backups" 0 "not a backup" \
|
|
|
|
|
|
bash -c '"'"$ROOT"'/bin/box" help snapshot'
|
|
|
|
|
|
check "pristine: 'box help restore' documents the mark and its off-box blind spot" 0 "off-box" \
|
|
|
|
|
|
bash -c '"'"$ROOT"'/bin/box" help restore'
|
|
|
|
|
|
check "pristine: 'box help new' documents the mark and the opt-out" 0 "BOX_SNAPSHOT_PRISTINE" \
|
|
|
|
|
|
bash -c '"'"$ROOT"'/bin/box" help new'
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
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
|
|
|
|
|
|
|
2026-07-19 17:13:24 +00:00
|
|
|
|
# The help is the PRE-RUN CONTRACT: an operator reads it to decide whether to
|
|
|
|
|
|
# run the command at all, so it must not promise a mutation that will not
|
|
|
|
|
|
# happen (or deny one that will). Round 1 of #101 changed what grant/revoke
|
|
|
|
|
|
# mutate for an incus-admin member and left this prose describing the
|
|
|
|
|
|
# superseded design — these pins are why that cannot happen silently again.
|
|
|
|
|
|
# Both directions: the current sentence must be present, and the superseded
|
|
|
|
|
|
# one must be gone.
|
|
|
|
|
|
check "help grant: the admin member's group step is a real add, not a no-op" \
|
|
|
|
|
|
0 "like anyone else" "$BOX" help grant
|
|
|
|
|
|
check "help revoke: a bare revoke of a granted admin member is 'partial:'" \
|
|
|
|
|
|
0 "partial:" "$BOX" help revoke
|
|
|
|
|
|
check "help grant no longer calls the admin group step a no-op" 0 "" \
|
|
|
|
|
|
bash -c '! "'"$BOX"'" help grant | grep -q "reported no-op"'
|
|
|
|
|
|
check "help revoke no longer claims there is no membership to drop" 0 "" \
|
|
|
|
|
|
bash -c '! "'"$BOX"'" help revoke | grep -q "no membership to drop"'
|
|
|
|
|
|
|
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
|
|
|
|
# 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"
|
fix: box grant provisions incus-admin members instead of refusing them
The refusal at host/grant-user.sh conflated permission with provisioning.
The 'incus' group is a strict subset of what incus-admin opens — true, and
the whole of what the refusal reasoned about. The user-<uid> project, the
boxnet narrowing, the snapshot and backup allowances and the box-net profile
installed into that project are not permissions, and an incus-admin member
had none of them: box_tier() resolves them to admin, so they worked in the
shared default project with no world of their own, and the one command that
provisions one refused to run for them.
box grant now converges them fully. The group step is a reported no-op —
adding 'incus' would grant nothing and leave a group list implying a
restriction that was never in force — and steps 2-5 run unchanged. The
incus-user touch is pinned at incus-user's socket, which this turns out to
require: the incus client picks its socket by writability, so for an
incus-admin member an unpinned client sails past incus-user entirely and the
project is never created. The user-side proof names their project for the
same reason.
On success it prints the caveat the hard exit was gesturing at: the
restrictions are a default placement, not a confinement, and their own
commands keep landing in the default project until incus-admin goes. The
backout learns the third case (nothing added, nothing rolled back, still
loud), and box revoke mirrors the whole thing rather than claiming a lockout
it did not perform.
Unblocks heavy-duty/rig#49.
Closes #99
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 16:18:46 +00:00
|
|
|
|
check "grant: an incus-admin member is provisioned, not refused (#99)" 1 "" \
|
|
|
|
|
|
grep -qF 'there is nothing tighter to grant' "$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
|
|
|
|
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"
|
fix: grant the 'incus' membership to incus-admin members too (#101 review)
The previous revision skipped `usermod -aG incus` for an incus-admin member,
reasoning that 'incus' is a strict subset of what incus-admin already opens.
That is true of the daemon API and false of the filesystem. On Debian 13 /
Incus 6.0.4 the two sockets are two files with two owning groups:
/var/lib/incus/unix.socket group incus-admin 0660
/var/lib/incus/unix.socket.user group incus 0660
incus-admin opens the first and not the second, and only the second
provisions a user-<uid> project. So for the incus-admin-ONLY user — the
canonical #99 case — the pinned provisioning touch took EACCES, the `|| true`
swallowed it, no project appeared, and the grant died blaming a healthy
incus-user. Both reviewers converged on this independently and were right.
The membership is now granted for everyone, with output carrying the concern
the old no-op was built around (it is the key to a file, not a privilege;
box_tier still reads them as admin). Everything downstream moves with it:
- the backout rolls that membership back and verified, while refusing to call
the rollback a lockout — incus-admin is untouched and still opens the host
- revoke's bare path takes the membership back and reports `partial:` instead
of "no-op, nothing was taken", still declining to call them "out"
- grant's closing "gpasswd -d <user> incus-admin (no re-grant needed)" is now
a true promise: they keep 'incus', so the drop lands them in their project
- the socket existence probe goes through $SUDO, matching revoke's measured
discipline about /var/lib/incus lying to a non-root admin
Tests: the cli.sh assertions that encoded the old no-op design are flipped and
the decision is pinned at the seam that broke; the sudo shim now runs `test`
for real in both directions. Because the shims model neither INCUS_SOCKET nor
permissions and so cannot reproduce the EACCES, drill/multiuser.sh gains
criterion (o): an incus-admin-only member granted on real Incus in CI, with
the membership, the project, a live connect() to unix.socket.user, and the
post-drop landing all measured.
Mutation-checked: 11 of the new/flipped assertions fail against the previous
implementation.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 16:50:42 +00:00
|
|
|
|
# Criterion o is the real-Incus half of #101: the shim cannot model an EACCES
|
|
|
|
|
|
# on the user socket, so the admin-only grant is measured where the socket has
|
|
|
|
|
|
# a real owning group. Pinned so it cannot quietly leave the rehearsal.
|
|
|
|
|
|
# shellcheck disable=SC2016 # the $-strings are literals in the target file
|
|
|
|
|
|
check "rehearsal: grants an incus-admin-ONLY member on real Incus (criterion o)" 0 "" \
|
|
|
|
|
|
grep -qF 'usermod -aG incus-admin "$U5"' "$ROOT/drill/multiuser.sh"
|
|
|
|
|
|
# shellcheck disable=SC2016 # ditto
|
|
|
|
|
|
check "rehearsal: ...and opens the user socket as them, not just the daemon" 0 "" \
|
|
|
|
|
|
grep -qF 'INCUS_SOCKET="$sockdir/unix.socket.user"' "$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"
|
fix: box grant provisions incus-admin members instead of refusing them
The refusal at host/grant-user.sh conflated permission with provisioning.
The 'incus' group is a strict subset of what incus-admin opens — true, and
the whole of what the refusal reasoned about. The user-<uid> project, the
boxnet narrowing, the snapshot and backup allowances and the box-net profile
installed into that project are not permissions, and an incus-admin member
had none of them: box_tier() resolves them to admin, so they worked in the
shared default project with no world of their own, and the one command that
provisions one refused to run for them.
box grant now converges them fully. The group step is a reported no-op —
adding 'incus' would grant nothing and leave a group list implying a
restriction that was never in force — and steps 2-5 run unchanged. The
incus-user touch is pinned at incus-user's socket, which this turns out to
require: the incus client picks its socket by writability, so for an
incus-admin member an unpinned client sails past incus-user entirely and the
project is never created. The user-side proof names their project for the
same reason.
On success it prints the caveat the hard exit was gesturing at: the
restrictions are a default placement, not a confinement, and their own
commands keep landing in the default project until incus-admin goes. The
backout learns the third case (nothing added, nothing rolled back, still
loud), and box revoke mirrors the whole thing rather than claiming a lockout
it did not perform.
Unblocks heavy-duty/rig#49.
Closes #99
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 16:18:46 +00:00
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# #99: an incus-admin member is PROVISIONED, not refused. The distinction the
|
|
|
|
|
|
# old refusal missed is permission (the 'incus' group — theirs already, and
|
|
|
|
|
|
# stronger) versus provisioning (the user-<uid> project, the boxnet narrowing,
|
|
|
|
|
|
# snapshots, backups, the box-net profile — theirs not at all). Grepping the
|
|
|
|
|
|
# new prose would prove only that the prose exists, so both tier scripts are
|
|
|
|
|
|
# DRIVEN end to end under shims, the same seam setup-host is driven through:
|
|
|
|
|
|
# every incus and sudo call is logged, and the assertions are made against
|
|
|
|
|
|
# those logs — what the run did, not what the source says it would do.
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
GSHIM="$(mktemp -d)"; W99="$(mktemp -d)"
|
|
|
|
|
|
cat > "$GSHIM/incus" <<'SHIM'
|
|
|
|
|
|
#!/usr/bin/env bash
|
|
|
|
|
|
# Fake incus for the driven grant/revoke: logs every call, answers the
|
|
|
|
|
|
# existence probes from FAKE_*, and models the two state changes the scripts
|
|
|
|
|
|
# depend on — the project appearing after the incus-user touch, and
|
|
|
|
|
|
# disappearing after a purge deletes it.
|
|
|
|
|
|
[ -n "${FAKE_INCUS_LOG:-}" ] && printf 'incus %s\n' "$*" >> "$FAKE_INCUS_LOG"
|
|
|
|
|
|
case "$*" in *"profile edit"*) cat >/dev/null ;; esac
|
|
|
|
|
|
case "$*" in
|
|
|
|
|
|
"network show boxnet") [ -n "${FAKE_HAVE_BOXNET:-}" ] || exit 1 ;;
|
|
|
|
|
|
"project show "*)
|
|
|
|
|
|
[ -e "$FAKE_STATE/deleted" ] && exit 1
|
|
|
|
|
|
if [ -n "${FAKE_PROJECT_LAZY:-}" ]; then
|
|
|
|
|
|
# Lazy creation: absent on the first look, present afterwards — i.e.
|
|
|
|
|
|
# the touch worked. n counts the looks this run has taken.
|
|
|
|
|
|
n=0; [ -e "$FAKE_STATE/looks" ] && n="$(cat "$FAKE_STATE/looks")"
|
|
|
|
|
|
printf '%s\n' "$((n + 1))" > "$FAKE_STATE/looks"
|
|
|
|
|
|
[ "$n" -ge 1 ] || exit 1
|
|
|
|
|
|
else
|
|
|
|
|
|
[ -n "${FAKE_HAVE_PROJECT:-}" ] || exit 1
|
|
|
|
|
|
fi ;;
|
|
|
|
|
|
"project delete "*) : > "$FAKE_STATE/deleted" ;;
|
|
|
|
|
|
*"restricted.networks.access"*)
|
|
|
|
|
|
[ -z "${FAKE_FAIL_NARROW:-}" ] || { echo 'Instance "old" is on incusbr-1000' >&2; exit 1; } ;;
|
|
|
|
|
|
*"network show "*) exit 1 ;; # the private bridge: never there in these runs
|
|
|
|
|
|
esac
|
|
|
|
|
|
exit 0
|
|
|
|
|
|
SHIM
|
|
|
|
|
|
cat > "$GSHIM/sudo" <<'SHIM'
|
|
|
|
|
|
#!/usr/bin/env bash
|
fix: grant the 'incus' membership to incus-admin members too (#101 review)
The previous revision skipped `usermod -aG incus` for an incus-admin member,
reasoning that 'incus' is a strict subset of what incus-admin already opens.
That is true of the daemon API and false of the filesystem. On Debian 13 /
Incus 6.0.4 the two sockets are two files with two owning groups:
/var/lib/incus/unix.socket group incus-admin 0660
/var/lib/incus/unix.socket.user group incus 0660
incus-admin opens the first and not the second, and only the second
provisions a user-<uid> project. So for the incus-admin-ONLY user — the
canonical #99 case — the pinned provisioning touch took EACCES, the `|| true`
swallowed it, no project appeared, and the grant died blaming a healthy
incus-user. Both reviewers converged on this independently and were right.
The membership is now granted for everyone, with output carrying the concern
the old no-op was built around (it is the key to a file, not a privilege;
box_tier still reads them as admin). Everything downstream moves with it:
- the backout rolls that membership back and verified, while refusing to call
the rollback a lockout — incus-admin is untouched and still opens the host
- revoke's bare path takes the membership back and reports `partial:` instead
of "no-op, nothing was taken", still declining to call them "out"
- grant's closing "gpasswd -d <user> incus-admin (no re-grant needed)" is now
a true promise: they keep 'incus', so the drop lands them in their project
- the socket existence probe goes through $SUDO, matching revoke's measured
discipline about /var/lib/incus lying to a non-root admin
Tests: the cli.sh assertions that encoded the old no-op design are flipped and
the decision is pinned at the seam that broke; the sudo shim now runs `test`
for real in both directions. Because the shims model neither INCUS_SOCKET nor
permissions and so cannot reproduce the EACCES, drill/multiuser.sh gains
criterion (o): an incus-admin-only member granted on real Incus in CI, with
the membership, the project, a live connect() to unix.socket.user, and the
post-drop landing all measured.
Mutation-checked: 11 of the new/flipped assertions fail against the previous
implementation.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 16:50:42 +00:00
|
|
|
|
# Fake sudo: logs and swallows — EXCEPT 'sudo test', which is run for real.
|
|
|
|
|
|
# Both scripts route filesystem probes through it on purpose (/var/lib/incus
|
|
|
|
|
|
# is not traversable by a non-root admin, so an unprivileged stat lies), and
|
|
|
|
|
|
# both directions matter here: revoke's absence assert must see incus-user's
|
|
|
|
|
|
# state directory as genuinely absent on a clean machine, and grant's socket
|
|
|
|
|
|
# check must see the shimmed unix.socket.user as genuinely present.
|
fix: box grant provisions incus-admin members instead of refusing them
The refusal at host/grant-user.sh conflated permission with provisioning.
The 'incus' group is a strict subset of what incus-admin opens — true, and
the whole of what the refusal reasoned about. The user-<uid> project, the
boxnet narrowing, the snapshot and backup allowances and the box-net profile
installed into that project are not permissions, and an incus-admin member
had none of them: box_tier() resolves them to admin, so they worked in the
shared default project with no world of their own, and the one command that
provisions one refused to run for them.
box grant now converges them fully. The group step is a reported no-op —
adding 'incus' would grant nothing and leave a group list implying a
restriction that was never in force — and steps 2-5 run unchanged. The
incus-user touch is pinned at incus-user's socket, which this turns out to
require: the incus client picks its socket by writability, so for an
incus-admin member an unpinned client sails past incus-user entirely and the
project is never created. The user-side proof names their project for the
same reason.
On success it prints the caveat the hard exit was gesturing at: the
restrictions are a default placement, not a confinement, and their own
commands keep landing in the default project until incus-admin goes. The
backout learns the third case (nothing added, nothing rolled back, still
loud), and box revoke mirrors the whole thing rather than claiming a lockout
it did not perform.
Unblocks heavy-duty/rig#49.
Closes #99
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 16:18:46 +00:00
|
|
|
|
[ -n "${FAKE_SUDO_LOG:-}" ] && printf 'sudo %s\n' "$*" >> "$FAKE_SUDO_LOG"
|
fix: grant the 'incus' membership to incus-admin members too (#101 review)
The previous revision skipped `usermod -aG incus` for an incus-admin member,
reasoning that 'incus' is a strict subset of what incus-admin already opens.
That is true of the daemon API and false of the filesystem. On Debian 13 /
Incus 6.0.4 the two sockets are two files with two owning groups:
/var/lib/incus/unix.socket group incus-admin 0660
/var/lib/incus/unix.socket.user group incus 0660
incus-admin opens the first and not the second, and only the second
provisions a user-<uid> project. So for the incus-admin-ONLY user — the
canonical #99 case — the pinned provisioning touch took EACCES, the `|| true`
swallowed it, no project appeared, and the grant died blaming a healthy
incus-user. Both reviewers converged on this independently and were right.
The membership is now granted for everyone, with output carrying the concern
the old no-op was built around (it is the key to a file, not a privilege;
box_tier still reads them as admin). Everything downstream moves with it:
- the backout rolls that membership back and verified, while refusing to call
the rollback a lockout — incus-admin is untouched and still opens the host
- revoke's bare path takes the membership back and reports `partial:` instead
of "no-op, nothing was taken", still declining to call them "out"
- grant's closing "gpasswd -d <user> incus-admin (no re-grant needed)" is now
a true promise: they keep 'incus', so the drop lands them in their project
- the socket existence probe goes through $SUDO, matching revoke's measured
discipline about /var/lib/incus lying to a non-root admin
Tests: the cli.sh assertions that encoded the old no-op design are flipped and
the decision is pinned at the seam that broke; the sudo shim now runs `test`
for real in both directions. Because the shims model neither INCUS_SOCKET nor
permissions and so cannot reproduce the EACCES, drill/multiuser.sh gains
criterion (o): an incus-admin-only member granted on real Incus in CI, with
the membership, the project, a live connect() to unix.socket.user, and the
post-drop landing all measured.
Mutation-checked: 11 of the new/flipped assertions fail against the previous
implementation.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 16:50:42 +00:00
|
|
|
|
case "${1:-}" in test) shift; test "$@"; exit $? ;; esac
|
fix: box grant provisions incus-admin members instead of refusing them
The refusal at host/grant-user.sh conflated permission with provisioning.
The 'incus' group is a strict subset of what incus-admin opens — true, and
the whole of what the refusal reasoned about. The user-<uid> project, the
boxnet narrowing, the snapshot and backup allowances and the box-net profile
installed into that project are not permissions, and an incus-admin member
had none of them: box_tier() resolves them to admin, so they worked in the
shared default project with no world of their own, and the one command that
provisions one refused to run for them.
box grant now converges them fully. The group step is a reported no-op —
adding 'incus' would grant nothing and leave a group list implying a
restriction that was never in force — and steps 2-5 run unchanged. The
incus-user touch is pinned at incus-user's socket, which this turns out to
require: the incus client picks its socket by writability, so for an
incus-admin member an unpinned client sails past incus-user entirely and the
project is never created. The user-side proof names their project for the
same reason.
On success it prints the caveat the hard exit was gesturing at: the
restrictions are a default placement, not a confinement, and their own
commands keep landing in the default project until incus-admin goes. The
backout learns the third case (nothing added, nothing rolled back, still
loud), and box revoke mirrors the whole thing rather than claiming a lockout
it did not perform.
Unblocks heavy-duty/rig#49.
Closes #99
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 16:18:46 +00:00
|
|
|
|
exit 0
|
|
|
|
|
|
SHIM
|
|
|
|
|
|
printf '#!/usr/bin/env bash\nexit 0\n' > "$GSHIM/getent"
|
|
|
|
|
|
printf '#!/usr/bin/env bash\nexit 0\n' > "$GSHIM/systemctl"
|
|
|
|
|
|
printf '#!/usr/bin/env bash\nexit 1\n' > "$GSHIM/pgrep"
|
|
|
|
|
|
chmod +x "$GSHIM/incus" "$GSHIM/sudo" "$GSHIM/getent" "$GSHIM/systemctl" "$GSHIM/pgrep"
|
|
|
|
|
|
|
|
|
|
|
|
# The pinned incus-user socket. box grant resolves it through INCUS_DIR (the
|
|
|
|
|
|
# client's own first choice), so a directory here is the whole seam.
|
|
|
|
|
|
mkdir -p "$W99/incusdir"; : > "$W99/incusdir/unix.socket.user"
|
|
|
|
|
|
|
|
|
|
|
|
rungrant() { # rungrant <groups> <state-dir> [VAR=val ...] — the real grant, shimmed
|
|
|
|
|
|
local groups="$1" state="$2"; shift 2
|
|
|
|
|
|
mkdir -p "$state"
|
|
|
|
|
|
env FAKE_UID=1000 FAKE_GROUPS="$groups" FAKE_STATE="$state" \
|
|
|
|
|
|
FAKE_HAVE_BOXNET=1 FAKE_PROJECT_LAZY=1 INCUS_DIR="$W99/incusdir" \
|
|
|
|
|
|
FAKE_INCUS_LOG="$state/incus.log" FAKE_SUDO_LOG="$state/sudo.log" \
|
|
|
|
|
|
PATH="$GSHIM:$SHIMDIR:$PATH" "$@" bash "$ROOT/host/grant-user.sh" dev1
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
# --- the admin member: full convergence, no group change, honest caveat -----
|
|
|
|
|
|
A="$W99/admin"
|
|
|
|
|
|
check "grant: an incus-admin member CONVERGES (exit 0, no refusal)" 0 "granted:" \
|
|
|
|
|
|
rungrant "users incus-admin" "$A"
|
fix: grant the 'incus' membership to incus-admin members too (#101 review)
The previous revision skipped `usermod -aG incus` for an incus-admin member,
reasoning that 'incus' is a strict subset of what incus-admin already opens.
That is true of the daemon API and false of the filesystem. On Debian 13 /
Incus 6.0.4 the two sockets are two files with two owning groups:
/var/lib/incus/unix.socket group incus-admin 0660
/var/lib/incus/unix.socket.user group incus 0660
incus-admin opens the first and not the second, and only the second
provisions a user-<uid> project. So for the incus-admin-ONLY user — the
canonical #99 case — the pinned provisioning touch took EACCES, the `|| true`
swallowed it, no project appeared, and the grant died blaming a healthy
incus-user. Both reviewers converged on this independently and were right.
The membership is now granted for everyone, with output carrying the concern
the old no-op was built around (it is the key to a file, not a privilege;
box_tier still reads them as admin). Everything downstream moves with it:
- the backout rolls that membership back and verified, while refusing to call
the rollback a lockout — incus-admin is untouched and still opens the host
- revoke's bare path takes the membership back and reports `partial:` instead
of "no-op, nothing was taken", still declining to call them "out"
- grant's closing "gpasswd -d <user> incus-admin (no re-grant needed)" is now
a true promise: they keep 'incus', so the drop lands them in their project
- the socket existence probe goes through $SUDO, matching revoke's measured
discipline about /var/lib/incus lying to a non-root admin
Tests: the cli.sh assertions that encoded the old no-op design are flipped and
the decision is pinned at the seam that broke; the sudo shim now runs `test`
for real in both directions. Because the shims model neither INCUS_SOCKET nor
permissions and so cannot reproduce the EACCES, drill/multiuser.sh gains
criterion (o): an incus-admin-only member granted on real Incus in CI, with
the membership, the project, a live connect() to unix.socket.user, and the
post-drop landing all measured.
Mutation-checked: 11 of the new/flipped assertions fail against the previous
implementation.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 16:50:42 +00:00
|
|
|
|
check "grant: ...and the group step is a real convergence, named as one" 0 "added dev1 to 'incus'" \
|
fix: box grant provisions incus-admin members instead of refusing them
The refusal at host/grant-user.sh conflated permission with provisioning.
The 'incus' group is a strict subset of what incus-admin opens — true, and
the whole of what the refusal reasoned about. The user-<uid> project, the
boxnet narrowing, the snapshot and backup allowances and the box-net profile
installed into that project are not permissions, and an incus-admin member
had none of them: box_tier() resolves them to admin, so they worked in the
shared default project with no world of their own, and the one command that
provisions one refused to run for them.
box grant now converges them fully. The group step is a reported no-op —
adding 'incus' would grant nothing and leave a group list implying a
restriction that was never in force — and steps 2-5 run unchanged. The
incus-user touch is pinned at incus-user's socket, which this turns out to
require: the incus client picks its socket by writability, so for an
incus-admin member an unpinned client sails past incus-user entirely and the
project is never created. The user-side proof names their project for the
same reason.
On success it prints the caveat the hard exit was gesturing at: the
restrictions are a default placement, not a confinement, and their own
commands keep landing in the default project until incus-admin goes. The
backout learns the third case (nothing added, nothing rolled back, still
loud), and box revoke mirrors the whole thing rather than claiming a lockout
it did not perform.
Unblocks heavy-duty/rig#49.
Closes #99
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 16:18:46 +00:00
|
|
|
|
rungrant "users incus-admin" "$W99/a2"
|
fix: grant the 'incus' membership to incus-admin members too (#101 review)
The previous revision skipped `usermod -aG incus` for an incus-admin member,
reasoning that 'incus' is a strict subset of what incus-admin already opens.
That is true of the daemon API and false of the filesystem. On Debian 13 /
Incus 6.0.4 the two sockets are two files with two owning groups:
/var/lib/incus/unix.socket group incus-admin 0660
/var/lib/incus/unix.socket.user group incus 0660
incus-admin opens the first and not the second, and only the second
provisions a user-<uid> project. So for the incus-admin-ONLY user — the
canonical #99 case — the pinned provisioning touch took EACCES, the `|| true`
swallowed it, no project appeared, and the grant died blaming a healthy
incus-user. Both reviewers converged on this independently and were right.
The membership is now granted for everyone, with output carrying the concern
the old no-op was built around (it is the key to a file, not a privilege;
box_tier still reads them as admin). Everything downstream moves with it:
- the backout rolls that membership back and verified, while refusing to call
the rollback a lockout — incus-admin is untouched and still opens the host
- revoke's bare path takes the membership back and reports `partial:` instead
of "no-op, nothing was taken", still declining to call them "out"
- grant's closing "gpasswd -d <user> incus-admin (no re-grant needed)" is now
a true promise: they keep 'incus', so the drop lands them in their project
- the socket existence probe goes through $SUDO, matching revoke's measured
discipline about /var/lib/incus lying to a non-root admin
Tests: the cli.sh assertions that encoded the old no-op design are flipped and
the decision is pinned at the seam that broke; the sudo shim now runs `test`
for real in both directions. Because the shims model neither INCUS_SOCKET nor
permissions and so cannot reproduce the EACCES, drill/multiuser.sh gains
criterion (o): an incus-admin-only member granted on real Incus in CI, with
the membership, the project, a live connect() to unix.socket.user, and the
post-drop landing all measured.
Mutation-checked: 11 of the new/flipped assertions fail against the previous
implementation.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 16:50:42 +00:00
|
|
|
|
check "grant: ...saying WHY (the socket is a file, group 'incus', not a privilege)" 0 "mode 0660" \
|
|
|
|
|
|
rungrant "users incus-admin" "$W99/a2b"
|
fix: box grant provisions incus-admin members instead of refusing them
The refusal at host/grant-user.sh conflated permission with provisioning.
The 'incus' group is a strict subset of what incus-admin opens — true, and
the whole of what the refusal reasoned about. The user-<uid> project, the
boxnet narrowing, the snapshot and backup allowances and the box-net profile
installed into that project are not permissions, and an incus-admin member
had none of them: box_tier() resolves them to admin, so they worked in the
shared default project with no world of their own, and the one command that
provisions one refused to run for them.
box grant now converges them fully. The group step is a reported no-op —
adding 'incus' would grant nothing and leave a group list implying a
restriction that was never in force — and steps 2-5 run unchanged. The
incus-user touch is pinned at incus-user's socket, which this turns out to
require: the incus client picks its socket by writability, so for an
incus-admin member an unpinned client sails past incus-user entirely and the
project is never created. The user-side proof names their project for the
same reason.
On success it prints the caveat the hard exit was gesturing at: the
restrictions are a default placement, not a confinement, and their own
commands keep landing in the default project until incus-admin goes. The
backout learns the third case (nothing added, nothing rolled back, still
loud), and box revoke mirrors the whole thing rather than claiming a lockout
it did not perform.
Unblocks heavy-duty/rig#49.
Closes #99
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 16:18:46 +00:00
|
|
|
|
check "grant: ...the caveat calls it a default placement, not a confinement" 0 "DEFAULT PLACEMENT" \
|
|
|
|
|
|
rungrant "users incus-admin" "$W99/a3"
|
|
|
|
|
|
check "grant: ...and names the group that has to go for it to bind" 0 "gpasswd -d dev1 incus-admin" \
|
|
|
|
|
|
rungrant "users incus-admin" "$W99/a4"
|
|
|
|
|
|
# The logs: what the run actually did to the machine.
|
fix: grant the 'incus' membership to incus-admin members too (#101 review)
The previous revision skipped `usermod -aG incus` for an incus-admin member,
reasoning that 'incus' is a strict subset of what incus-admin already opens.
That is true of the daemon API and false of the filesystem. On Debian 13 /
Incus 6.0.4 the two sockets are two files with two owning groups:
/var/lib/incus/unix.socket group incus-admin 0660
/var/lib/incus/unix.socket.user group incus 0660
incus-admin opens the first and not the second, and only the second
provisions a user-<uid> project. So for the incus-admin-ONLY user — the
canonical #99 case — the pinned provisioning touch took EACCES, the `|| true`
swallowed it, no project appeared, and the grant died blaming a healthy
incus-user. Both reviewers converged on this independently and were right.
The membership is now granted for everyone, with output carrying the concern
the old no-op was built around (it is the key to a file, not a privilege;
box_tier still reads them as admin). Everything downstream moves with it:
- the backout rolls that membership back and verified, while refusing to call
the rollback a lockout — incus-admin is untouched and still opens the host
- revoke's bare path takes the membership back and reports `partial:` instead
of "no-op, nothing was taken", still declining to call them "out"
- grant's closing "gpasswd -d <user> incus-admin (no re-grant needed)" is now
a true promise: they keep 'incus', so the drop lands them in their project
- the socket existence probe goes through $SUDO, matching revoke's measured
discipline about /var/lib/incus lying to a non-root admin
Tests: the cli.sh assertions that encoded the old no-op design are flipped and
the decision is pinned at the seam that broke; the sudo shim now runs `test`
for real in both directions. Because the shims model neither INCUS_SOCKET nor
permissions and so cannot reproduce the EACCES, drill/multiuser.sh gains
criterion (o): an incus-admin-only member granted on real Incus in CI, with
the membership, the project, a live connect() to unix.socket.user, and the
post-drop landing all measured.
Mutation-checked: 11 of the new/flipped assertions fail against the previous
implementation.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 16:50:42 +00:00
|
|
|
|
# #101's decision, pinned at the seam that broke: an incus-admin member IS
|
|
|
|
|
|
# usermod'ed into 'incus'. It buys them no API privilege they lack — but
|
|
|
|
|
|
# incus-user's socket is a FILE, group 'incus' mode 0660, and without the
|
|
|
|
|
|
# membership the pinned touch below takes EACCES, the '|| true' eats it, and
|
|
|
|
|
|
# the grant dies blaming a healthy incus-user. The shim cannot model that
|
|
|
|
|
|
# EACCES (it ignores INCUS_SOCKET and permissions entirely), so the decision
|
|
|
|
|
|
# is pinned here and MEASURED on real Incus in drill/multiuser.sh criterion o.
|
|
|
|
|
|
check "grant: the admin member IS added to 'incus' — the user socket's group (#101)" 0 "" \
|
|
|
|
|
|
grep -qF 'usermod -aG incus dev1' "$A/sudo.log"
|
fix: box grant provisions incus-admin members instead of refusing them
The refusal at host/grant-user.sh conflated permission with provisioning.
The 'incus' group is a strict subset of what incus-admin opens — true, and
the whole of what the refusal reasoned about. The user-<uid> project, the
boxnet narrowing, the snapshot and backup allowances and the box-net profile
installed into that project are not permissions, and an incus-admin member
had none of them: box_tier() resolves them to admin, so they worked in the
shared default project with no world of their own, and the one command that
provisions one refused to run for them.
box grant now converges them fully. The group step is a reported no-op —
adding 'incus' would grant nothing and leave a group list implying a
restriction that was never in force — and steps 2-5 run unchanged. The
incus-user touch is pinned at incus-user's socket, which this turns out to
require: the incus client picks its socket by writability, so for an
incus-admin member an unpinned client sails past incus-user entirely and the
project is never created. The user-side proof names their project for the
same reason.
On success it prints the caveat the hard exit was gesturing at: the
restrictions are a default placement, not a confinement, and their own
commands keep landing in the default project until incus-admin goes. The
backout learns the third case (nothing added, nothing rolled back, still
loud), and box revoke mirrors the whole thing rather than claiming a lockout
it did not perform.
Unblocks heavy-duty/rig#49.
Closes #99
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 16:18:46 +00:00
|
|
|
|
check "grant: their project is still narrowed to boxnet" 0 "" \
|
|
|
|
|
|
grep -qF 'project set user-1000 restricted.networks.access boxnet' "$A/incus.log"
|
|
|
|
|
|
check "grant: their project still gets snapshots" 0 "" \
|
|
|
|
|
|
grep -qF 'project set user-1000 restricted.snapshots allow' "$A/incus.log"
|
|
|
|
|
|
check "grant: their project still gets backups" 0 "" \
|
|
|
|
|
|
grep -qF 'project set user-1000 restricted.backups allow' "$A/incus.log"
|
|
|
|
|
|
check "grant: box-net is still installed INTO their project" 0 "" \
|
|
|
|
|
|
grep -qF -- '--project user-1000 profile edit box-net' "$A/incus.log"
|
|
|
|
|
|
# The socket pin (#99's teeth): incus's client takes the DAEMON socket when it
|
|
|
|
|
|
# is writable, and only falls back to unix.socket.user when it is not — so for
|
|
|
|
|
|
# an incus-admin member an unpinned touch never reaches incus-user at all, and
|
|
|
|
|
|
# the project it was supposed to create never appears.
|
|
|
|
|
|
check "grant: the touch is pinned at incus-user's socket (the admin socket would win)" 0 "" \
|
|
|
|
|
|
grep -qF "INCUS_SOCKET=$W99/incusdir/unix.socket.user" "$A/sudo.log"
|
|
|
|
|
|
check "grant: the user-side proof names their project (an unqualified show proves nothing)" 0 "" \
|
|
|
|
|
|
grep -qF -- '--project user-1000 profile show box-net' "$A/sudo.log"
|
fix: grant the 'incus' membership to incus-admin members too (#101 review)
The previous revision skipped `usermod -aG incus` for an incus-admin member,
reasoning that 'incus' is a strict subset of what incus-admin already opens.
That is true of the daemon API and false of the filesystem. On Debian 13 /
Incus 6.0.4 the two sockets are two files with two owning groups:
/var/lib/incus/unix.socket group incus-admin 0660
/var/lib/incus/unix.socket.user group incus 0660
incus-admin opens the first and not the second, and only the second
provisions a user-<uid> project. So for the incus-admin-ONLY user — the
canonical #99 case — the pinned provisioning touch took EACCES, the `|| true`
swallowed it, no project appeared, and the grant died blaming a healthy
incus-user. Both reviewers converged on this independently and were right.
The membership is now granted for everyone, with output carrying the concern
the old no-op was built around (it is the key to a file, not a privilege;
box_tier still reads them as admin). Everything downstream moves with it:
- the backout rolls that membership back and verified, while refusing to call
the rollback a lockout — incus-admin is untouched and still opens the host
- revoke's bare path takes the membership back and reports `partial:` instead
of "no-op, nothing was taken", still declining to call them "out"
- grant's closing "gpasswd -d <user> incus-admin (no re-grant needed)" is now
a true promise: they keep 'incus', so the drop lands them in their project
- the socket existence probe goes through $SUDO, matching revoke's measured
discipline about /var/lib/incus lying to a non-root admin
Tests: the cli.sh assertions that encoded the old no-op design are flipped and
the decision is pinned at the seam that broke; the sudo shim now runs `test`
for real in both directions. Because the shims model neither INCUS_SOCKET nor
permissions and so cannot reproduce the EACCES, drill/multiuser.sh gains
criterion (o): an incus-admin-only member granted on real Incus in CI, with
the membership, the project, a live connect() to unix.socket.user, and the
post-drop landing all measured.
Mutation-checked: 11 of the new/flipped assertions fail against the previous
implementation.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 16:50:42 +00:00
|
|
|
|
# The socket existence probe rides $SUDO, like revoke's: /var/lib/incus is not
|
|
|
|
|
|
# traversable by a non-root admin, and a bare [ -e ] there false-fails into an
|
|
|
|
|
|
# exit that blames incus-user for a socket that is present (#101 review).
|
|
|
|
|
|
check "grant: the socket probe goes through sudo, not a bare [ -e ]" 0 "" \
|
|
|
|
|
|
grep -qF "test -e $W99/incusdir/unix.socket.user" "$A/sudo.log"
|
fix: box grant provisions incus-admin members instead of refusing them
The refusal at host/grant-user.sh conflated permission with provisioning.
The 'incus' group is a strict subset of what incus-admin opens — true, and
the whole of what the refusal reasoned about. The user-<uid> project, the
boxnet narrowing, the snapshot and backup allowances and the box-net profile
installed into that project are not permissions, and an incus-admin member
had none of them: box_tier() resolves them to admin, so they worked in the
shared default project with no world of their own, and the one command that
provisions one refused to run for them.
box grant now converges them fully. The group step is a reported no-op —
adding 'incus' would grant nothing and leave a group list implying a
restriction that was never in force — and steps 2-5 run unchanged. The
incus-user touch is pinned at incus-user's socket, which this turns out to
require: the incus client picks its socket by writability, so for an
incus-admin member an unpinned client sails past incus-user entirely and the
project is never created. The user-side proof names their project for the
same reason.
On success it prints the caveat the hard exit was gesturing at: the
restrictions are a default placement, not a confinement, and their own
commands keep landing in the default project until incus-admin goes. The
backout learns the third case (nothing added, nothing rolled back, still
loud), and box revoke mirrors the whole thing rather than claiming a lockout
it did not perform.
Unblocks heavy-duty/rig#49.
Closes #99
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 16:18:46 +00:00
|
|
|
|
|
|
|
|
|
|
# --- the restricted user: unchanged, and unpinned ---------------------------
|
|
|
|
|
|
R="$W99/restricted"
|
|
|
|
|
|
check "grant: a plain user is still added to 'incus'" 0 "added dev1 to 'incus'" \
|
|
|
|
|
|
rungrant "users" "$R"
|
|
|
|
|
|
check "grant: ...via usermod (the log, not the prose)" 0 "" \
|
|
|
|
|
|
grep -qF 'usermod -aG incus dev1' "$R/sudo.log"
|
|
|
|
|
|
check "grant: ...and their client is left to its own socket fallback" 1 "" \
|
|
|
|
|
|
grep -qF 'INCUS_SOCKET' "$R/sudo.log"
|
|
|
|
|
|
|
fix: grant the 'incus' membership to incus-admin members too (#101 review)
The previous revision skipped `usermod -aG incus` for an incus-admin member,
reasoning that 'incus' is a strict subset of what incus-admin already opens.
That is true of the daemon API and false of the filesystem. On Debian 13 /
Incus 6.0.4 the two sockets are two files with two owning groups:
/var/lib/incus/unix.socket group incus-admin 0660
/var/lib/incus/unix.socket.user group incus 0660
incus-admin opens the first and not the second, and only the second
provisions a user-<uid> project. So for the incus-admin-ONLY user — the
canonical #99 case — the pinned provisioning touch took EACCES, the `|| true`
swallowed it, no project appeared, and the grant died blaming a healthy
incus-user. Both reviewers converged on this independently and were right.
The membership is now granted for everyone, with output carrying the concern
the old no-op was built around (it is the key to a file, not a privilege;
box_tier still reads them as admin). Everything downstream moves with it:
- the backout rolls that membership back and verified, while refusing to call
the rollback a lockout — incus-admin is untouched and still opens the host
- revoke's bare path takes the membership back and reports `partial:` instead
of "no-op, nothing was taken", still declining to call them "out"
- grant's closing "gpasswd -d <user> incus-admin (no re-grant needed)" is now
a true promise: they keep 'incus', so the drop lands them in their project
- the socket existence probe goes through $SUDO, matching revoke's measured
discipline about /var/lib/incus lying to a non-root admin
Tests: the cli.sh assertions that encoded the old no-op design are flipped and
the decision is pinned at the seam that broke; the sudo shim now runs `test`
for real in both directions. Because the shims model neither INCUS_SOCKET nor
permissions and so cannot reproduce the EACCES, drill/multiuser.sh gains
criterion (o): an incus-admin-only member granted on real Incus in CI, with
the membership, the project, a live connect() to unix.socket.user, and the
post-drop landing all measured.
Mutation-checked: 11 of the new/flipped assertions fail against the previous
implementation.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 16:50:42 +00:00
|
|
|
|
# --- the failure path: what this run added comes back, and says what didn't --
|
fix: box grant provisions incus-admin members instead of refusing them
The refusal at host/grant-user.sh conflated permission with provisioning.
The 'incus' group is a strict subset of what incus-admin opens — true, and
the whole of what the refusal reasoned about. The user-<uid> project, the
boxnet narrowing, the snapshot and backup allowances and the box-net profile
installed into that project are not permissions, and an incus-admin member
had none of them: box_tier() resolves them to admin, so they worked in the
shared default project with no world of their own, and the one command that
provisions one refused to run for them.
box grant now converges them fully. The group step is a reported no-op —
adding 'incus' would grant nothing and leave a group list implying a
restriction that was never in force — and steps 2-5 run unchanged. The
incus-user touch is pinned at incus-user's socket, which this turns out to
require: the incus client picks its socket by writability, so for an
incus-admin member an unpinned client sails past incus-user entirely and the
project is never created. The user-side proof names their project for the
same reason.
On success it prints the caveat the hard exit was gesturing at: the
restrictions are a default placement, not a confinement, and their own
commands keep landing in the default project until incus-admin goes. The
backout learns the third case (nothing added, nothing rolled back, still
loud), and box revoke mirrors the whole thing rather than claiming a lockout
it did not perform.
Unblocks heavy-duty/rig#49.
Closes #99
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 16:18:46 +00:00
|
|
|
|
F="$W99/failed"
|
|
|
|
|
|
check "grant: a failed grant for an admin member exits 1" 1 "FAILED" \
|
|
|
|
|
|
rungrant "users incus-admin" "$F" FAKE_FAIL_NARROW=1
|
|
|
|
|
|
check "grant: ...says their admin socket was neither granted nor removed here" 1 "neither granted nor removed" \
|
|
|
|
|
|
rungrant "users incus-admin" "$W99/f2" FAKE_FAIL_NARROW=1
|
fix: grant the 'incus' membership to incus-admin members too (#101 review)
The previous revision skipped `usermod -aG incus` for an incus-admin member,
reasoning that 'incus' is a strict subset of what incus-admin already opens.
That is true of the daemon API and false of the filesystem. On Debian 13 /
Incus 6.0.4 the two sockets are two files with two owning groups:
/var/lib/incus/unix.socket group incus-admin 0660
/var/lib/incus/unix.socket.user group incus 0660
incus-admin opens the first and not the second, and only the second
provisions a user-<uid> project. So for the incus-admin-ONLY user — the
canonical #99 case — the pinned provisioning touch took EACCES, the `|| true`
swallowed it, no project appeared, and the grant died blaming a healthy
incus-user. Both reviewers converged on this independently and were right.
The membership is now granted for everyone, with output carrying the concern
the old no-op was built around (it is the key to a file, not a privilege;
box_tier still reads them as admin). Everything downstream moves with it:
- the backout rolls that membership back and verified, while refusing to call
the rollback a lockout — incus-admin is untouched and still opens the host
- revoke's bare path takes the membership back and reports `partial:` instead
of "no-op, nothing was taken", still declining to call them "out"
- grant's closing "gpasswd -d <user> incus-admin (no re-grant needed)" is now
a true promise: they keep 'incus', so the drop lands them in their project
- the socket existence probe goes through $SUDO, matching revoke's measured
discipline about /var/lib/incus lying to a non-root admin
Tests: the cli.sh assertions that encoded the old no-op design are flipped and
the decision is pinned at the seam that broke; the sudo shim now runs `test`
for real in both directions. Because the shims model neither INCUS_SOCKET nor
permissions and so cannot reproduce the EACCES, drill/multiuser.sh gains
criterion (o): an incus-admin-only member granted on real Incus in CI, with
the membership, the project, a live connect() to unix.socket.user, and the
post-drop landing all measured.
Mutation-checked: 11 of the new/flipped assertions fail against the previous
implementation.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 16:50:42 +00:00
|
|
|
|
# The membership IS this run's now, so the backout IS its business (#101).
|
|
|
|
|
|
check "grant: ...and DOES roll the 'incus' membership back (this run added it)" 0 "" \
|
|
|
|
|
|
grep -qF 'gpasswd -d dev1 incus' "$F/sudo.log"
|
|
|
|
|
|
check "grant: ...while refusing to call that rollback a lockout" 1 "closed incus-user's socket, NOT" \
|
|
|
|
|
|
rungrant "users incus-admin" "$W99/f3" FAKE_FAIL_NARROW=1
|
fix: box grant provisions incus-admin members instead of refusing them
The refusal at host/grant-user.sh conflated permission with provisioning.
The 'incus' group is a strict subset of what incus-admin opens — true, and
the whole of what the refusal reasoned about. The user-<uid> project, the
boxnet narrowing, the snapshot and backup allowances and the box-net profile
installed into that project are not permissions, and an incus-admin member
had none of them: box_tier() resolves them to admin, so they worked in the
shared default project with no world of their own, and the one command that
provisions one refused to run for them.
box grant now converges them fully. The group step is a reported no-op —
adding 'incus' would grant nothing and leave a group list implying a
restriction that was never in force — and steps 2-5 run unchanged. The
incus-user touch is pinned at incus-user's socket, which this turns out to
require: the incus client picks its socket by writability, so for an
incus-admin member an unpinned client sails past incus-user entirely and the
project is never created. The user-side proof names their project for the
same reason.
On success it prints the caveat the hard exit was gesturing at: the
restrictions are a default placement, not a confinement, and their own
commands keep landing in the default project until incus-admin goes. The
backout learns the third case (nothing added, nothing rolled back, still
loud), and box revoke mirrors the whole thing rather than claiming a lockout
it did not perform.
Unblocks heavy-duty/rig#49.
Closes #99
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 16:18:46 +00:00
|
|
|
|
|
|
|
|
|
|
# --- revoke, the mirror: it cannot take what it never gave ------------------
|
|
|
|
|
|
# BOX_YES=1 throughout: --purge is destructive and refuses without a terminal
|
|
|
|
|
|
# to confirm on, and this suite has none. It changes nothing for a bare revoke.
|
fix: grant the 'incus' membership to incus-admin members too (#101 review)
The previous revision skipped `usermod -aG incus` for an incus-admin member,
reasoning that 'incus' is a strict subset of what incus-admin already opens.
That is true of the daemon API and false of the filesystem. On Debian 13 /
Incus 6.0.4 the two sockets are two files with two owning groups:
/var/lib/incus/unix.socket group incus-admin 0660
/var/lib/incus/unix.socket.user group incus 0660
incus-admin opens the first and not the second, and only the second
provisions a user-<uid> project. So for the incus-admin-ONLY user — the
canonical #99 case — the pinned provisioning touch took EACCES, the `|| true`
swallowed it, no project appeared, and the grant died blaming a healthy
incus-user. Both reviewers converged on this independently and were right.
The membership is now granted for everyone, with output carrying the concern
the old no-op was built around (it is the key to a file, not a privilege;
box_tier still reads them as admin). Everything downstream moves with it:
- the backout rolls that membership back and verified, while refusing to call
the rollback a lockout — incus-admin is untouched and still opens the host
- revoke's bare path takes the membership back and reports `partial:` instead
of "no-op, nothing was taken", still declining to call them "out"
- grant's closing "gpasswd -d <user> incus-admin (no re-grant needed)" is now
a true promise: they keep 'incus', so the drop lands them in their project
- the socket existence probe goes through $SUDO, matching revoke's measured
discipline about /var/lib/incus lying to a non-root admin
Tests: the cli.sh assertions that encoded the old no-op design are flipped and
the decision is pinned at the seam that broke; the sudo shim now runs `test`
for real in both directions. Because the shims model neither INCUS_SOCKET nor
permissions and so cannot reproduce the EACCES, drill/multiuser.sh gains
criterion (o): an incus-admin-only member granted on real Incus in CI, with
the membership, the project, a live connect() to unix.socket.user, and the
post-drop landing all measured.
Mutation-checked: 11 of the new/flipped assertions fail against the previous
implementation.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 16:50:42 +00:00
|
|
|
|
runrevoke() { # runrevoke <groups> <state-dir> [script args...]
|
|
|
|
|
|
local groups="$1" state="$2"; shift 2
|
fix: box grant provisions incus-admin members instead of refusing them
The refusal at host/grant-user.sh conflated permission with provisioning.
The 'incus' group is a strict subset of what incus-admin opens — true, and
the whole of what the refusal reasoned about. The user-<uid> project, the
boxnet narrowing, the snapshot and backup allowances and the box-net profile
installed into that project are not permissions, and an incus-admin member
had none of them: box_tier() resolves them to admin, so they worked in the
shared default project with no world of their own, and the one command that
provisions one refused to run for them.
box grant now converges them fully. The group step is a reported no-op —
adding 'incus' would grant nothing and leave a group list implying a
restriction that was never in force — and steps 2-5 run unchanged. The
incus-user touch is pinned at incus-user's socket, which this turns out to
require: the incus client picks its socket by writability, so for an
incus-admin member an unpinned client sails past incus-user entirely and the
project is never created. The user-side proof names their project for the
same reason.
On success it prints the caveat the hard exit was gesturing at: the
restrictions are a default placement, not a confinement, and their own
commands keep landing in the default project until incus-admin goes. The
backout learns the third case (nothing added, nothing rolled back, still
loud), and box revoke mirrors the whole thing rather than claiming a lockout
it did not perform.
Unblocks heavy-duty/rig#49.
Closes #99
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 16:18:46 +00:00
|
|
|
|
mkdir -p "$state"
|
fix: grant the 'incus' membership to incus-admin members too (#101 review)
The previous revision skipped `usermod -aG incus` for an incus-admin member,
reasoning that 'incus' is a strict subset of what incus-admin already opens.
That is true of the daemon API and false of the filesystem. On Debian 13 /
Incus 6.0.4 the two sockets are two files with two owning groups:
/var/lib/incus/unix.socket group incus-admin 0660
/var/lib/incus/unix.socket.user group incus 0660
incus-admin opens the first and not the second, and only the second
provisions a user-<uid> project. So for the incus-admin-ONLY user — the
canonical #99 case — the pinned provisioning touch took EACCES, the `|| true`
swallowed it, no project appeared, and the grant died blaming a healthy
incus-user. Both reviewers converged on this independently and were right.
The membership is now granted for everyone, with output carrying the concern
the old no-op was built around (it is the key to a file, not a privilege;
box_tier still reads them as admin). Everything downstream moves with it:
- the backout rolls that membership back and verified, while refusing to call
the rollback a lockout — incus-admin is untouched and still opens the host
- revoke's bare path takes the membership back and reports `partial:` instead
of "no-op, nothing was taken", still declining to call them "out"
- grant's closing "gpasswd -d <user> incus-admin (no re-grant needed)" is now
a true promise: they keep 'incus', so the drop lands them in their project
- the socket existence probe goes through $SUDO, matching revoke's measured
discipline about /var/lib/incus lying to a non-root admin
Tests: the cli.sh assertions that encoded the old no-op design are flipped and
the decision is pinned at the seam that broke; the sudo shim now runs `test`
for real in both directions. Because the shims model neither INCUS_SOCKET nor
permissions and so cannot reproduce the EACCES, drill/multiuser.sh gains
criterion (o): an incus-admin-only member granted on real Incus in CI, with
the membership, the project, a live connect() to unix.socket.user, and the
post-drop landing all measured.
Mutation-checked: 11 of the new/flipped assertions fail against the previous
implementation.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 16:50:42 +00:00
|
|
|
|
env FAKE_UID=1000 FAKE_GROUPS="$groups" FAKE_STATE="$state" BOX_YES=1 \
|
fix: box grant provisions incus-admin members instead of refusing them
The refusal at host/grant-user.sh conflated permission with provisioning.
The 'incus' group is a strict subset of what incus-admin opens — true, and
the whole of what the refusal reasoned about. The user-<uid> project, the
boxnet narrowing, the snapshot and backup allowances and the box-net profile
installed into that project are not permissions, and an incus-admin member
had none of them: box_tier() resolves them to admin, so they worked in the
shared default project with no world of their own, and the one command that
provisions one refused to run for them.
box grant now converges them fully. The group step is a reported no-op —
adding 'incus' would grant nothing and leave a group list implying a
restriction that was never in force — and steps 2-5 run unchanged. The
incus-user touch is pinned at incus-user's socket, which this turns out to
require: the incus client picks its socket by writability, so for an
incus-admin member an unpinned client sails past incus-user entirely and the
project is never created. The user-side proof names their project for the
same reason.
On success it prints the caveat the hard exit was gesturing at: the
restrictions are a default placement, not a confinement, and their own
commands keep landing in the default project until incus-admin goes. The
backout learns the third case (nothing added, nothing rolled back, still
loud), and box revoke mirrors the whole thing rather than claiming a lockout
it did not perform.
Unblocks heavy-duty/rig#49.
Closes #99
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 16:18:46 +00:00
|
|
|
|
FAKE_HAVE_PROJECT=1 FAKE_INCUS_LOG="$state/incus.log" FAKE_SUDO_LOG="$state/sudo.log" \
|
|
|
|
|
|
PATH="$GSHIM:$SHIMDIR:$PATH" bash "$ROOT/host/revoke-user.sh" dev1 "$@"
|
|
|
|
|
|
}
|
fix: grant the 'incus' membership to incus-admin members too (#101 review)
The previous revision skipped `usermod -aG incus` for an incus-admin member,
reasoning that 'incus' is a strict subset of what incus-admin already opens.
That is true of the daemon API and false of the filesystem. On Debian 13 /
Incus 6.0.4 the two sockets are two files with two owning groups:
/var/lib/incus/unix.socket group incus-admin 0660
/var/lib/incus/unix.socket.user group incus 0660
incus-admin opens the first and not the second, and only the second
provisions a user-<uid> project. So for the incus-admin-ONLY user — the
canonical #99 case — the pinned provisioning touch took EACCES, the `|| true`
swallowed it, no project appeared, and the grant died blaming a healthy
incus-user. Both reviewers converged on this independently and were right.
The membership is now granted for everyone, with output carrying the concern
the old no-op was built around (it is the key to a file, not a privilege;
box_tier still reads them as admin). Everything downstream moves with it:
- the backout rolls that membership back and verified, while refusing to call
the rollback a lockout — incus-admin is untouched and still opens the host
- revoke's bare path takes the membership back and reports `partial:` instead
of "no-op, nothing was taken", still declining to call them "out"
- grant's closing "gpasswd -d <user> incus-admin (no re-grant needed)" is now
a true promise: they keep 'incus', so the drop lands them in their project
- the socket existence probe goes through $SUDO, matching revoke's measured
discipline about /var/lib/incus lying to a non-root admin
Tests: the cli.sh assertions that encoded the old no-op design are flipped and
the decision is pinned at the seam that broke; the sudo shim now runs `test`
for real in both directions. Because the shims model neither INCUS_SOCKET nor
permissions and so cannot reproduce the EACCES, drill/multiuser.sh gains
criterion (o): an incus-admin-only member granted on real Incus in CI, with
the membership, the project, a live connect() to unix.socket.user, and the
post-drop landing all measured.
Mutation-checked: 11 of the new/flipped assertions fail against the previous
implementation.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 16:50:42 +00:00
|
|
|
|
# The granted admin member is in BOTH groups — that is what 'box grant' leaves
|
|
|
|
|
|
# behind now (#101) — so revoke has a real membership to take back. It takes
|
|
|
|
|
|
# it, and still refuses to call the result a lockout: 'incus-admin' holds the
|
|
|
|
|
|
# daemon and is not this script's to remove.
|
|
|
|
|
|
GRANTED="users incus incus-admin"
|
fix: box grant provisions incus-admin members instead of refusing them
The refusal at host/grant-user.sh conflated permission with provisioning.
The 'incus' group is a strict subset of what incus-admin opens — true, and
the whole of what the refusal reasoned about. The user-<uid> project, the
boxnet narrowing, the snapshot and backup allowances and the box-net profile
installed into that project are not permissions, and an incus-admin member
had none of them: box_tier() resolves them to admin, so they worked in the
shared default project with no world of their own, and the one command that
provisions one refused to run for them.
box grant now converges them fully. The group step is a reported no-op —
adding 'incus' would grant nothing and leave a group list implying a
restriction that was never in force — and steps 2-5 run unchanged. The
incus-user touch is pinned at incus-user's socket, which this turns out to
require: the incus client picks its socket by writability, so for an
incus-admin member an unpinned client sails past incus-user entirely and the
project is never created. The user-side proof names their project for the
same reason.
On success it prints the caveat the hard exit was gesturing at: the
restrictions are a default placement, not a confinement, and their own
commands keep landing in the default project until incus-admin goes. The
backout learns the third case (nothing added, nothing rolled back, still
loud), and box revoke mirrors the whole thing rather than claiming a lockout
it did not perform.
Unblocks heavy-duty/rig#49.
Closes #99
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 16:18:46 +00:00
|
|
|
|
V="$W99/revoke"
|
fix: grant the 'incus' membership to incus-admin members too (#101 review)
The previous revision skipped `usermod -aG incus` for an incus-admin member,
reasoning that 'incus' is a strict subset of what incus-admin already opens.
That is true of the daemon API and false of the filesystem. On Debian 13 /
Incus 6.0.4 the two sockets are two files with two owning groups:
/var/lib/incus/unix.socket group incus-admin 0660
/var/lib/incus/unix.socket.user group incus 0660
incus-admin opens the first and not the second, and only the second
provisions a user-<uid> project. So for the incus-admin-ONLY user — the
canonical #99 case — the pinned provisioning touch took EACCES, the `|| true`
swallowed it, no project appeared, and the grant died blaming a healthy
incus-user. Both reviewers converged on this independently and were right.
The membership is now granted for everyone, with output carrying the concern
the old no-op was built around (it is the key to a file, not a privilege;
box_tier still reads them as admin). Everything downstream moves with it:
- the backout rolls that membership back and verified, while refusing to call
the rollback a lockout — incus-admin is untouched and still opens the host
- revoke's bare path takes the membership back and reports `partial:` instead
of "no-op, nothing was taken", still declining to call them "out"
- grant's closing "gpasswd -d <user> incus-admin (no re-grant needed)" is now
a true promise: they keep 'incus', so the drop lands them in their project
- the socket existence probe goes through $SUDO, matching revoke's measured
discipline about /var/lib/incus lying to a non-root admin
Tests: the cli.sh assertions that encoded the old no-op design are flipped and
the decision is pinned at the seam that broke; the sudo shim now runs `test`
for real in both directions. Because the shims model neither INCUS_SOCKET nor
permissions and so cannot reproduce the EACCES, drill/multiuser.sh gains
criterion (o): an incus-admin-only member granted on real Incus in CI, with
the membership, the project, a live connect() to unix.socket.user, and the
post-drop landing all measured.
Mutation-checked: 11 of the new/flipped assertions fail against the previous
implementation.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 16:50:42 +00:00
|
|
|
|
check "revoke: a bare revoke of a granted admin member is 'partial', not 'revoked'" 0 "partial:" \
|
|
|
|
|
|
runrevoke "$GRANTED" "$V"
|
|
|
|
|
|
check "revoke: ...and refuses to call it a lockout" 0 "is NOT locked out" \
|
|
|
|
|
|
runrevoke "$GRANTED" "$W99/v2"
|
fix: box grant provisions incus-admin members instead of refusing them
The refusal at host/grant-user.sh conflated permission with provisioning.
The 'incus' group is a strict subset of what incus-admin opens — true, and
the whole of what the refusal reasoned about. The user-<uid> project, the
boxnet narrowing, the snapshot and backup allowances and the box-net profile
installed into that project are not permissions, and an incus-admin member
had none of them: box_tier() resolves them to admin, so they worked in the
shared default project with no world of their own, and the one command that
provisions one refused to run for them.
box grant now converges them fully. The group step is a reported no-op —
adding 'incus' would grant nothing and leave a group list implying a
restriction that was never in force — and steps 2-5 run unchanged. The
incus-user touch is pinned at incus-user's socket, which this turns out to
require: the incus client picks its socket by writability, so for an
incus-admin member an unpinned client sails past incus-user entirely and the
project is never created. The user-side proof names their project for the
same reason.
On success it prints the caveat the hard exit was gesturing at: the
restrictions are a default placement, not a confinement, and their own
commands keep landing in the default project until incus-admin goes. The
backout learns the third case (nothing added, nothing rolled back, still
loud), and box revoke mirrors the whole thing rather than claiming a lockout
it did not perform.
Unblocks heavy-duty/rig#49.
Closes #99
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 16:18:46 +00:00
|
|
|
|
check "revoke: ...naming the group that would actually lock them out" 0 "gpasswd -d dev1 incus-admin" \
|
fix: grant the 'incus' membership to incus-admin members too (#101 review)
The previous revision skipped `usermod -aG incus` for an incus-admin member,
reasoning that 'incus' is a strict subset of what incus-admin already opens.
That is true of the daemon API and false of the filesystem. On Debian 13 /
Incus 6.0.4 the two sockets are two files with two owning groups:
/var/lib/incus/unix.socket group incus-admin 0660
/var/lib/incus/unix.socket.user group incus 0660
incus-admin opens the first and not the second, and only the second
provisions a user-<uid> project. So for the incus-admin-ONLY user — the
canonical #99 case — the pinned provisioning touch took EACCES, the `|| true`
swallowed it, no project appeared, and the grant died blaming a healthy
incus-user. Both reviewers converged on this independently and were right.
The membership is now granted for everyone, with output carrying the concern
the old no-op was built around (it is the key to a file, not a privilege;
box_tier still reads them as admin). Everything downstream moves with it:
- the backout rolls that membership back and verified, while refusing to call
the rollback a lockout — incus-admin is untouched and still opens the host
- revoke's bare path takes the membership back and reports `partial:` instead
of "no-op, nothing was taken", still declining to call them "out"
- grant's closing "gpasswd -d <user> incus-admin (no re-grant needed)" is now
a true promise: they keep 'incus', so the drop lands them in their project
- the socket existence probe goes through $SUDO, matching revoke's measured
discipline about /var/lib/incus lying to a non-root admin
Tests: the cli.sh assertions that encoded the old no-op design are flipped and
the decision is pinned at the seam that broke; the sudo shim now runs `test`
for real in both directions. Because the shims model neither INCUS_SOCKET nor
permissions and so cannot reproduce the EACCES, drill/multiuser.sh gains
criterion (o): an incus-admin-only member granted on real Incus in CI, with
the membership, the project, a live connect() to unix.socket.user, and the
post-drop landing all measured.
Mutation-checked: 11 of the new/flipped assertions fail against the previous
implementation.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 16:50:42 +00:00
|
|
|
|
runrevoke "$GRANTED" "$W99/v3"
|
|
|
|
|
|
# The mirror of grant's flip: there IS a privileged call now, and it is the
|
|
|
|
|
|
# membership grant added — asserted against the log, not the prose.
|
|
|
|
|
|
check "revoke: ...having actually dropped the 'incus' membership (the log)" 0 "" \
|
|
|
|
|
|
grep -qF 'gpasswd -d dev1 incus' "$V/sudo.log"
|
|
|
|
|
|
check "revoke: ...calling that key incus-user's, not their daemon access" 0 "NOT their daemon access" \
|
|
|
|
|
|
runrevoke "$GRANTED" "$W99/v4"
|
|
|
|
|
|
# An admin member who was never granted: nothing to take, and it still says so
|
|
|
|
|
|
# rather than reporting a revocation it did not perform.
|
|
|
|
|
|
N="$W99/revoke-ungranted"
|
|
|
|
|
|
check "revoke: an UNgranted admin member is still a named no-op" 0 "no-op:" \
|
|
|
|
|
|
runrevoke "users incus-admin" "$N"
|
|
|
|
|
|
check "revoke: ...saying their access is incus-admin's, untouched here" 0 "which this does not touch" \
|
|
|
|
|
|
runrevoke "users incus-admin" "$W99/n2"
|
|
|
|
|
|
# Absence of the LOG, not of a line in it: an ungranted admin member's bare
|
|
|
|
|
|
# revoke makes no privileged call whatsoever, so the file is never created.
|
fix: box grant provisions incus-admin members instead of refusing them
The refusal at host/grant-user.sh conflated permission with provisioning.
The 'incus' group is a strict subset of what incus-admin opens — true, and
the whole of what the refusal reasoned about. The user-<uid> project, the
boxnet narrowing, the snapshot and backup allowances and the box-net profile
installed into that project are not permissions, and an incus-admin member
had none of them: box_tier() resolves them to admin, so they worked in the
shared default project with no world of their own, and the one command that
provisions one refused to run for them.
box grant now converges them fully. The group step is a reported no-op —
adding 'incus' would grant nothing and leave a group list implying a
restriction that was never in force — and steps 2-5 run unchanged. The
incus-user touch is pinned at incus-user's socket, which this turns out to
require: the incus client picks its socket by writability, so for an
incus-admin member an unpinned client sails past incus-user entirely and the
project is never created. The user-side proof names their project for the
same reason.
On success it prints the caveat the hard exit was gesturing at: the
restrictions are a default placement, not a confinement, and their own
commands keep landing in the default project until incus-admin goes. The
backout learns the third case (nothing added, nothing rolled back, still
loud), and box revoke mirrors the whole thing rather than claiming a lockout
it did not perform.
Unblocks heavy-duty/rig#49.
Closes #99
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 16:18:46 +00:00
|
|
|
|
check "revoke: ...having made NO privileged call at all (no membership to drop)" 1 "" \
|
fix: grant the 'incus' membership to incus-admin members too (#101 review)
The previous revision skipped `usermod -aG incus` for an incus-admin member,
reasoning that 'incus' is a strict subset of what incus-admin already opens.
That is true of the daemon API and false of the filesystem. On Debian 13 /
Incus 6.0.4 the two sockets are two files with two owning groups:
/var/lib/incus/unix.socket group incus-admin 0660
/var/lib/incus/unix.socket.user group incus 0660
incus-admin opens the first and not the second, and only the second
provisions a user-<uid> project. So for the incus-admin-ONLY user — the
canonical #99 case — the pinned provisioning touch took EACCES, the `|| true`
swallowed it, no project appeared, and the grant died blaming a healthy
incus-user. Both reviewers converged on this independently and were right.
The membership is now granted for everyone, with output carrying the concern
the old no-op was built around (it is the key to a file, not a privilege;
box_tier still reads them as admin). Everything downstream moves with it:
- the backout rolls that membership back and verified, while refusing to call
the rollback a lockout — incus-admin is untouched and still opens the host
- revoke's bare path takes the membership back and reports `partial:` instead
of "no-op, nothing was taken", still declining to call them "out"
- grant's closing "gpasswd -d <user> incus-admin (no re-grant needed)" is now
a true promise: they keep 'incus', so the drop lands them in their project
- the socket existence probe goes through $SUDO, matching revoke's measured
discipline about /var/lib/incus lying to a non-root admin
Tests: the cli.sh assertions that encoded the old no-op design are flipped and
the decision is pinned at the seam that broke; the sudo shim now runs `test`
for real in both directions. Because the shims model neither INCUS_SOCKET nor
permissions and so cannot reproduce the EACCES, drill/multiuser.sh gains
criterion (o): an incus-admin-only member granted on real Incus in CI, with
the membership, the project, a live connect() to unix.socket.user, and the
post-drop landing all measured.
Mutation-checked: 11 of the new/flipped assertions fail against the previous
implementation.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 16:50:42 +00:00
|
|
|
|
test -e "$N/sudo.log"
|
fix: box grant provisions incus-admin members instead of refusing them
The refusal at host/grant-user.sh conflated permission with provisioning.
The 'incus' group is a strict subset of what incus-admin opens — true, and
the whole of what the refusal reasoned about. The user-<uid> project, the
boxnet narrowing, the snapshot and backup allowances and the box-net profile
installed into that project are not permissions, and an incus-admin member
had none of them: box_tier() resolves them to admin, so they worked in the
shared default project with no world of their own, and the one command that
provisions one refused to run for them.
box grant now converges them fully. The group step is a reported no-op —
adding 'incus' would grant nothing and leave a group list implying a
restriction that was never in force — and steps 2-5 run unchanged. The
incus-user touch is pinned at incus-user's socket, which this turns out to
require: the incus client picks its socket by writability, so for an
incus-admin member an unpinned client sails past incus-user entirely and the
project is never created. The user-side proof names their project for the
same reason.
On success it prints the caveat the hard exit was gesturing at: the
restrictions are a default placement, not a confinement, and their own
commands keep landing in the default project until incus-admin goes. The
backout learns the third case (nothing added, nothing rolled back, still
loud), and box revoke mirrors the whole thing rather than claiming a lockout
it did not perform.
Unblocks heavy-duty/rig#49.
Closes #99
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 16:18:46 +00:00
|
|
|
|
P="$W99/purge"
|
|
|
|
|
|
check "revoke --purge: still unmakes the provisioning" 0 "purged:" \
|
fix: grant the 'incus' membership to incus-admin members too (#101 review)
The previous revision skipped `usermod -aG incus` for an incus-admin member,
reasoning that 'incus' is a strict subset of what incus-admin already opens.
That is true of the daemon API and false of the filesystem. On Debian 13 /
Incus 6.0.4 the two sockets are two files with two owning groups:
/var/lib/incus/unix.socket group incus-admin 0660
/var/lib/incus/unix.socket.user group incus 0660
incus-admin opens the first and not the second, and only the second
provisions a user-<uid> project. So for the incus-admin-ONLY user — the
canonical #99 case — the pinned provisioning touch took EACCES, the `|| true`
swallowed it, no project appeared, and the grant died blaming a healthy
incus-user. Both reviewers converged on this independently and were right.
The membership is now granted for everyone, with output carrying the concern
the old no-op was built around (it is the key to a file, not a privilege;
box_tier still reads them as admin). Everything downstream moves with it:
- the backout rolls that membership back and verified, while refusing to call
the rollback a lockout — incus-admin is untouched and still opens the host
- revoke's bare path takes the membership back and reports `partial:` instead
of "no-op, nothing was taken", still declining to call them "out"
- grant's closing "gpasswd -d <user> incus-admin (no re-grant needed)" is now
a true promise: they keep 'incus', so the drop lands them in their project
- the socket existence probe goes through $SUDO, matching revoke's measured
discipline about /var/lib/incus lying to a non-root admin
Tests: the cli.sh assertions that encoded the old no-op design are flipped and
the decision is pinned at the seam that broke; the sudo shim now runs `test`
for real in both directions. Because the shims model neither INCUS_SOCKET nor
permissions and so cannot reproduce the EACCES, drill/multiuser.sh gains
criterion (o): an incus-admin-only member granted on real Incus in CI, with
the membership, the project, a live connect() to unix.socket.user, and the
post-drop landing all measured.
Mutation-checked: 11 of the new/flipped assertions fail against the previous
implementation.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 16:50:42 +00:00
|
|
|
|
runrevoke "$GRANTED" "$P" --purge
|
fix: box grant provisions incus-admin members instead of refusing them
The refusal at host/grant-user.sh conflated permission with provisioning.
The 'incus' group is a strict subset of what incus-admin opens — true, and
the whole of what the refusal reasoned about. The user-<uid> project, the
boxnet narrowing, the snapshot and backup allowances and the box-net profile
installed into that project are not permissions, and an incus-admin member
had none of them: box_tier() resolves them to admin, so they worked in the
shared default project with no world of their own, and the one command that
provisions one refused to run for them.
box grant now converges them fully. The group step is a reported no-op —
adding 'incus' would grant nothing and leave a group list implying a
restriction that was never in force — and steps 2-5 run unchanged. The
incus-user touch is pinned at incus-user's socket, which this turns out to
require: the incus client picks its socket by writability, so for an
incus-admin member an unpinned client sails past incus-user entirely and the
project is never created. The user-side proof names their project for the
same reason.
On success it prints the caveat the hard exit was gesturing at: the
restrictions are a default placement, not a confinement, and their own
commands keep landing in the default project until incus-admin goes. The
backout learns the third case (nothing added, nothing rolled back, still
loud), and box revoke mirrors the whole thing rather than claiming a lockout
it did not perform.
Unblocks heavy-duty/rig#49.
Closes #99
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 16:18:46 +00:00
|
|
|
|
check "revoke --purge: ...and refuses to call an admin member 'out'" 0 "is NOT out" \
|
fix: grant the 'incus' membership to incus-admin members too (#101 review)
The previous revision skipped `usermod -aG incus` for an incus-admin member,
reasoning that 'incus' is a strict subset of what incus-admin already opens.
That is true of the daemon API and false of the filesystem. On Debian 13 /
Incus 6.0.4 the two sockets are two files with two owning groups:
/var/lib/incus/unix.socket group incus-admin 0660
/var/lib/incus/unix.socket.user group incus 0660
incus-admin opens the first and not the second, and only the second
provisions a user-<uid> project. So for the incus-admin-ONLY user — the
canonical #99 case — the pinned provisioning touch took EACCES, the `|| true`
swallowed it, no project appeared, and the grant died blaming a healthy
incus-user. Both reviewers converged on this independently and were right.
The membership is now granted for everyone, with output carrying the concern
the old no-op was built around (it is the key to a file, not a privilege;
box_tier still reads them as admin). Everything downstream moves with it:
- the backout rolls that membership back and verified, while refusing to call
the rollback a lockout — incus-admin is untouched and still opens the host
- revoke's bare path takes the membership back and reports `partial:` instead
of "no-op, nothing was taken", still declining to call them "out"
- grant's closing "gpasswd -d <user> incus-admin (no re-grant needed)" is now
a true promise: they keep 'incus', so the drop lands them in their project
- the socket existence probe goes through $SUDO, matching revoke's measured
discipline about /var/lib/incus lying to a non-root admin
Tests: the cli.sh assertions that encoded the old no-op design are flipped and
the decision is pinned at the seam that broke; the sudo shim now runs `test`
for real in both directions. Because the shims model neither INCUS_SOCKET nor
permissions and so cannot reproduce the EACCES, drill/multiuser.sh gains
criterion (o): an incus-admin-only member granted on real Incus in CI, with
the membership, the project, a live connect() to unix.socket.user, and the
post-drop landing all measured.
Mutation-checked: 11 of the new/flipped assertions fail against the previous
implementation.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 16:50:42 +00:00
|
|
|
|
runrevoke "$GRANTED" "$W99/p2" --purge
|
fix: box grant provisions incus-admin members instead of refusing them
The refusal at host/grant-user.sh conflated permission with provisioning.
The 'incus' group is a strict subset of what incus-admin opens — true, and
the whole of what the refusal reasoned about. The user-<uid> project, the
boxnet narrowing, the snapshot and backup allowances and the box-net profile
installed into that project are not permissions, and an incus-admin member
had none of them: box_tier() resolves them to admin, so they worked in the
shared default project with no world of their own, and the one command that
provisions one refused to run for them.
box grant now converges them fully. The group step is a reported no-op —
adding 'incus' would grant nothing and leave a group list implying a
restriction that was never in force — and steps 2-5 run unchanged. The
incus-user touch is pinned at incus-user's socket, which this turns out to
require: the incus client picks its socket by writability, so for an
incus-admin member an unpinned client sails past incus-user entirely and the
project is never created. The user-side proof names their project for the
same reason.
On success it prints the caveat the hard exit was gesturing at: the
restrictions are a default placement, not a confinement, and their own
commands keep landing in the default project until incus-admin goes. The
backout learns the third case (nothing added, nothing rolled back, still
loud), and box revoke mirrors the whole thing rather than claiming a lockout
it did not perform.
Unblocks heavy-duty/rig#49.
Closes #99
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 16:18:46 +00:00
|
|
|
|
check "revoke --purge: ...the project really was deleted (the log, not the summary)" 0 "" \
|
|
|
|
|
|
grep -qF 'project delete user-1000' "$P/incus.log"
|
|
|
|
|
|
rm -rf "$GSHIM" "$W99"
|
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 "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"
|
2026-07-19 19:40:39 +00:00
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# The confirm gate (#105) — DRIVEN, not grepped.
|
|
|
|
|
|
#
|
|
|
|
|
|
# Until #105 the only coverage restore had was the two argument-validation
|
|
|
|
|
|
# checks above: neither ever reached dispatch, so the verb spent four releases
|
|
|
|
|
|
# handing a running box straight to 'incus snapshot restore' with no prompt
|
|
|
|
|
|
# and no --force, and nothing in this suite could have noticed. Both halves of
|
|
|
|
|
|
# the gate are now exercised against a fake incus that logs what it was asked
|
|
|
|
|
|
# to do — refusing must leave the log EMPTY (an assertion about an absence is
|
|
|
|
|
|
# the only way to prove a gate held), and --force must produce the restore.
|
|
|
|
|
|
#
|
|
|
|
|
|
# Stdin is closed on every run on purpose: confirm() branches on '[ -t 0 ]',
|
|
|
|
|
|
# and a suite run from a terminal would otherwise inherit one and sit there
|
|
|
|
|
|
# waiting for a human to type 'y'.
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
CSHIM="$(mktemp -d)"; CWORK="$(mktemp -d)"
|
|
|
|
|
|
cat > "$CSHIM/incus" <<'SHIM'
|
|
|
|
|
|
#!/usr/bin/env bash
|
|
|
|
|
|
# Fake incus for the destructive-path drive. Logs every call, and answers the
|
|
|
|
|
|
# one probe resolve_box makes so a box called 'work' exists and is ours.
|
|
|
|
|
|
[ -n "${FAKE_INCUS_LOG:-}" ] && printf 'incus %s\n' "$*" >> "$FAKE_INCUS_LOG"
|
|
|
|
|
|
case "$*" in
|
|
|
|
|
|
"config get work user.box") echo 1 ;;
|
|
|
|
|
|
"config get "*) exit 1 ;;
|
|
|
|
|
|
esac
|
|
|
|
|
|
exit 0
|
|
|
|
|
|
SHIM
|
|
|
|
|
|
chmod +x "$CSHIM/incus"
|
|
|
|
|
|
|
|
|
|
|
|
runbox() { # runbox <logfile> <args...> — the real box, shimmed, no TTY
|
|
|
|
|
|
local log="$1" rc; shift
|
|
|
|
|
|
: > "$log"
|
|
|
|
|
|
# Output is kept in <log>.out as well as replayed, so a check can assert on
|
|
|
|
|
|
# what the run PRINTED after the fact — check() swallows the output of a run
|
|
|
|
|
|
# it passes, and the "the prompt does not say 'delete'" assertion is exactly
|
|
|
|
|
|
# that: a claim about text from a run that already passed on its exit code.
|
|
|
|
|
|
env FAKE_INCUS_LOG="$log" PATH="$CSHIM:$PATH" "$BOX" "$@" </dev/null >"$log.out" 2>&1
|
|
|
|
|
|
rc=$?
|
|
|
|
|
|
cat "$log.out"
|
|
|
|
|
|
return "$rc"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
# --- restore: the gate refuses, and nothing is destroyed --------------------
|
|
|
|
|
|
RLOG="$CWORK/restore.log"
|
|
|
|
|
|
check "restore: refuses without --force when there is no terminal (#105)" \
|
|
|
|
|
|
2 "refusing to roll work back to snapshot 'authed'" \
|
|
|
|
|
|
runbox "$RLOG" restore work authed
|
2026-07-19 19:47:05 +00:00
|
|
|
|
# The exact no-TTY wording, pinned. This is the regression test for the CI
|
|
|
|
|
|
# failure this PR produced: the multi-user rehearsal drives restore unattended
|
|
|
|
|
|
# on real Incus, took this refusal, and recorded '(b) restore failed' — a
|
|
|
|
|
|
# 40-minute job catching what a 15-second suite should have. box refuses
|
|
|
|
|
|
# rather than assuming consent, and it says which of the two ways out applies.
|
|
|
|
|
|
check "restore: ...and the refusal names the missing terminal, not a bad usage (#105)" \
|
|
|
|
|
|
2 "no terminal to confirm on" \
|
|
|
|
|
|
runbox "$CWORK/r-tty.log" restore work authed
|
2026-07-19 19:40:39 +00:00
|
|
|
|
# The load-bearing assertion: the refusal actually PREVENTED the rollback.
|
|
|
|
|
|
# 'grep -q' on an absence, so an empty log passes and a logged restore fails.
|
|
|
|
|
|
check "restore: ...and the refusal reached incus with no restore (#105)" 1 "" \
|
|
|
|
|
|
grep -qF 'snapshot restore' "$RLOG"
|
|
|
|
|
|
# The prompt must name the SNAPSHOT and the loss, not rm's wording. This is
|
|
|
|
|
|
# the entire point of making the prompt row-driven: adding the 'confirm' token
|
|
|
|
|
|
# alone would have asked the operator to confirm deleting the box.
|
|
|
|
|
|
check "restore: the prompt names what is lost, not a deletion (#105)" \
|
|
|
|
|
|
2 "discard everything in the box since it was taken" \
|
|
|
|
|
|
runbox "$CWORK/r2.log" restore work authed
|
|
|
|
|
|
check "restore: the prompt does NOT offer to delete the box (#105)" 1 "" \
|
|
|
|
|
|
grep -qF 'delete work' "$CWORK/r2.log.out"
|
|
|
|
|
|
|
|
|
|
|
|
# --- restore: --force is the way through, and it still restores -------------
|
|
|
|
|
|
FLOG="$CWORK/force.log"
|
|
|
|
|
|
check "restore --force: skips the prompt and restores (#105)" 0 "restored work to authed" \
|
|
|
|
|
|
runbox "$FLOG" restore work authed --force
|
|
|
|
|
|
check "restore --force: ...and incus was really asked for the rollback (#105)" 0 "" \
|
|
|
|
|
|
grep -qF 'incus snapshot restore work authed' "$FLOG"
|
|
|
|
|
|
|
|
|
|
|
|
# --- rm: its wording is unchanged, and its gate still holds -----------------
|
|
|
|
|
|
# #105 moved the prompt out of the dispatch line and into the rows. rm's text
|
|
|
|
|
|
# was the string that lived there, so it is pinned verbatim: a refactor that
|
|
|
|
|
|
# rewords the ONE verb that already asked correctly is a regression.
|
|
|
|
|
|
MLOG="$CWORK/rm.log"
|
|
|
|
|
|
check "rm: still refuses without --force, in its own words (#105 refactor)" \
|
|
|
|
|
|
2 "refusing to delete work and all its snapshots" \
|
|
|
|
|
|
runbox "$MLOG" rm work
|
|
|
|
|
|
check "rm: ...and nothing was deleted" 1 "" grep -qF 'delete' "$MLOG"
|
|
|
|
|
|
check "rm --force: still deletes" 0 "removed work" runbox "$CWORK/rmf.log" rm work --force
|
|
|
|
|
|
check "rm --force: ...via 'incus delete -f'" 0 "" \
|
|
|
|
|
|
grep -qF 'incus delete -f work' "$CWORK/rmf.log"
|
|
|
|
|
|
|
|
|
|
|
|
# --- the table invariant: a confirm row must carry its own words ------------
|
|
|
|
|
|
# Fail-closed on the shape itself, so a future 'confirm' row cannot ship with
|
|
|
|
|
|
# an empty prompt field and inherit whatever the dispatch happens to say.
|
|
|
|
|
|
# shellcheck disable=SC2016 # $3/$7/$1 are awk's fields, not the shell's
|
|
|
|
|
|
check "table: every 'confirm' row supplies a prompt (#105)" 0 "" \
|
|
|
|
|
|
awk -F'^' '
|
|
|
|
|
|
/^CMDS=\(/ { in_t = 1; next }
|
|
|
|
|
|
in_t && /^\)/ { exit }
|
|
|
|
|
|
in_t && /^ "/ && $3 ~ /(^|,)confirm(,|$)/ {
|
|
|
|
|
|
seen = 1
|
|
|
|
|
|
if (NF < 7) { print "row for " $1 " is marked confirm with no prompt field"; bad = 1; next }
|
|
|
|
|
|
p = $7; sub(/"$/, "", p)
|
|
|
|
|
|
if (p == "") { print "row for " $1 " has an empty confirm prompt"; bad = 1 }
|
|
|
|
|
|
}
|
|
|
|
|
|
END { if (!seen) { print "no confirm rows found — the pin is not reading the table"; bad = 1 }
|
|
|
|
|
|
exit (bad ? 1 : 0) }
|
|
|
|
|
|
' "$ROOT/bin/box"
|
|
|
|
|
|
# shellcheck disable=SC2016 # the $-strings are literals in the target file
|
|
|
|
|
|
check "dispatch: the confirm prompt comes from the row, not a constant (#105)" 0 "" \
|
|
|
|
|
|
grep -qF 'confirm "$(fill "$cnf" "$inst")"' "$ROOT/bin/box"
|
2026-07-19 19:46:06 +00:00
|
|
|
|
# The rehearsal drives restore unattended on real Incus, so it must consent
|
|
|
|
|
|
# EXPLICITLY — the gate is only real if the one automated caller had to change.
|
|
|
|
|
|
# Pinned here because the rehearsal itself needs a daemon and this suite has none.
|
|
|
|
|
|
check "rehearsal: the unattended restore passes --force (#105)" 0 "" \
|
|
|
|
|
|
grep -qF 'box restore mine s1 --force' "$ROOT/drill/multiuser.sh"
|
2026-07-19 20:33:06 +00:00
|
|
|
|
# --- the three answers a human can give — DRIVEN ON A REAL PTY (#111) -------
|
|
|
|
|
|
# Everything above stops at the no-TTY refusal, because confirm() branches on
|
|
|
|
|
|
# '[ -t 0 ]' and this suite has no terminal. So the interactive half — 'y',
|
|
|
|
|
|
# 'n', and Ctrl-D — had never been executed here at all, which is precisely
|
|
|
|
|
|
# how #111 survived: an unguarded 'read' returns non-zero on EOF, 'set -e'
|
|
|
|
|
|
# ends the run before the 'case', and the abort happens in total silence.
|
|
|
|
|
|
#
|
|
|
|
|
|
# 'script' from util-linux gives the child a pty, so box takes the interactive
|
|
|
|
|
|
# branch for real and reads the answer we write to the master side. This does
|
|
|
|
|
|
# NOT hang a suite run from a terminal: script's own stdin is a file or
|
|
|
|
|
|
# /dev/null on every run below, never the developer's tty, so the answer (or
|
|
|
|
|
|
# the EOF) is always already waiting.
|
|
|
|
|
|
if command -v script >/dev/null 2>&1 && script --version 2>/dev/null | grep -q util-linux; then
|
|
|
|
|
|
PWORK="$(mktemp -d)"; PLOG="$PWORK/pty.log"
|
|
|
|
|
|
printf 'y\n' > "$PWORK/yes"; printf 'n\n' > "$PWORK/no"
|
|
|
|
|
|
# Invoked through a file so 'script -c' needs no quoting of its own; the log
|
|
|
|
|
|
# path and the shim PATH ride the environment script hands to the child.
|
|
|
|
|
|
cat > "$PWORK/run" <<RUNNER
|
|
|
|
|
|
#!/usr/bin/env bash
|
|
|
|
|
|
exec env PATH="$CSHIM:\$PATH" "$BOX" rm work
|
|
|
|
|
|
RUNNER
|
|
|
|
|
|
chmod +x "$PWORK/run"
|
|
|
|
|
|
ptybox() { # ptybox <answers-file> — 'box rm work' on a pty, answered
|
|
|
|
|
|
: > "$PLOG"
|
|
|
|
|
|
FAKE_INCUS_LOG="$PLOG" script -qec "$PWORK/run" /dev/null < "$1"
|
|
|
|
|
|
}
|
|
|
|
|
|
# The load-bearing assertion is the MESSAGE, not the exit code: before the
|
|
|
|
|
|
# fix Ctrl-D also exited 1, just without ever saying why. Asserting on the
|
|
|
|
|
|
# code alone would pass against the bug.
|
|
|
|
|
|
check "rm: Ctrl-D at the prompt aborts OUT LOUD, not in silence (#111)" \
|
|
|
|
|
|
1 "aborted." ptybox /dev/null
|
|
|
|
|
|
check "rm: ...and the Ctrl-D abort really deleted nothing (#111)" 1 "" \
|
|
|
|
|
|
grep -qF 'incus delete' "$PLOG"
|
|
|
|
|
|
check "rm: 'n' at the prompt aborts (#111)" 1 "aborted." ptybox "$PWORK/no"
|
|
|
|
|
|
check "rm: ...and 'n' really deleted nothing (#111)" 1 "" \
|
|
|
|
|
|
grep -qF 'incus delete' "$PLOG"
|
|
|
|
|
|
# The accept path, so the pty rig is proven to be able to reach the work —
|
|
|
|
|
|
# three checks that can only ever refuse would pass against a box that
|
|
|
|
|
|
# refuses everything.
|
|
|
|
|
|
check "rm: 'y' at the prompt goes through (#111)" 0 "removed work" \
|
|
|
|
|
|
ptybox "$PWORK/yes"
|
|
|
|
|
|
check "rm: ...and 'y' really reached 'incus delete -f' (#111)" 0 "" \
|
|
|
|
|
|
grep -qF 'incus delete -f work' "$PLOG"
|
|
|
|
|
|
rm -rf "$PWORK"
|
|
|
|
|
|
else
|
|
|
|
|
|
echo "skip: the interactive confirm answers (no util-linux 'script' here; CI has it)"
|
|
|
|
|
|
fi
|
|
|
|
|
|
|
2026-07-19 21:06:44 +00:00
|
|
|
|
# --- the sweep: no prompt-shaped 'read' under 'set -e' may go unguarded (#111)
|
|
|
|
|
|
# The pty checks above prove the two 'bin/box' gates. This proves the CLASS,
|
|
|
|
|
|
# repo-wide, and it exists because the class is exactly what the first pass at
|
|
|
|
|
|
# #111 missed: 'host/revoke-user.sh' and 'host/teardown-host.sh' carried the
|
|
|
|
|
|
# identical defect and survived, because nothing here was looking for the shape.
|
|
|
|
|
|
#
|
|
|
|
|
|
# The shape: a 'read' at the start of a statement, fed from the script's own
|
|
|
|
|
|
# stdin (so a human, or an EOF), inside a file that turns on errexit. On EOF
|
|
|
|
|
|
# 'read' returns non-zero and 'set -e' ends the run BEFORE the 'case' that was
|
|
|
|
|
|
# going to name the abort — the tool goes mute at the moment it asked.
|
|
|
|
|
|
#
|
|
|
|
|
|
# What is deliberately NOT flagged, because it is not the shape:
|
|
|
|
|
|
# · 'while IFS= read -r' loops — fed by a redirect at 'done', and a non-zero
|
|
|
|
|
|
# read is how the loop is supposed to end;
|
|
|
|
|
|
# · '<<<' herestring reads — fed from a string, never from a human;
|
|
|
|
|
|
# · files without errexit ('drill/wipe.sh', 'drill/drill.sh',
|
|
|
|
|
|
# 'drill/multiuser.sh' run under 'set -u' only, wipe.sh documents why), where
|
|
|
|
|
|
# EOF simply falls through to the '*)' arm and aborts out loud on its own.
|
|
|
|
|
|
# A guard is any '||' on the read's own line: '|| die', '|| reply=""',
|
|
|
|
|
|
# '|| { echo …; exit 1; }' — the spelling is each script's to choose, the
|
|
|
|
|
|
# guard is not.
|
|
|
|
|
|
eof_guard_sweep() {
|
|
|
|
|
|
local f n line bad=0 files
|
fix: lint the release path — globstar does not descend into dot-directories
CI's shellcheck sweep set globstar and globbed `bin/* **/*.sh`. globstar
makes `**` descend into subdirectories, but a glob still does not MATCH a
dot-prefixed name, so `**/` never entered `.github/` and three scripts were
never linted: changelog-armed.sh (the #108/#110 guard that gates every PR),
release-notes.sh (which produces the published release body), and
labels-reconcile.sh (the label state machine). That is the entire release
path, while the step's own comment promised the opposite — that a script in
a new subdirectory is linted without anyone editing a list.
Latent, not broken: all three pass shellcheck as-is, so this is a no-op on
current code. What changes is that a regression in them would be caught.
dotglob alongside globstar closes it, measured rather than assumed: it adds
exactly those three and nothing else — a checkout's .git carries no *.sh,
its hooks shipping as *.sample, so `**/*.sh` does not wander into it.
The one-time fix is dotglob; what keeps the gap shut is the CLASS check, in
the same shape as the eof_guard_sweep of #112. The sweep now compares the
globbed set against `git ls-files '*.sh'` and fails naming any tracked
script it does not cover, so a future dot-directory or shopt subtlety
cannot silently lint a subset and pass.
eof_guard_sweep carried the identical blind spot — it rebuilds the same
glob — and is widened the same way. A no-op today: the three scripts set
errexit, so they are in that class by construction, but none of them reads.
Refs #116
2026-07-19 23:32:49 +00:00
|
|
|
|
# dotglob alongside globstar for the same reason CI's shellcheck step carries
|
|
|
|
|
|
# it (#116): globstar descends, but a glob does not MATCH a dot-prefixed name,
|
|
|
|
|
|
# so this sweep skipped '.github/scripts/*.sh' — the release path — exactly as
|
|
|
|
|
|
# the linter did. Those three set errexit, so they are in scope for this class
|
|
|
|
|
|
# by construction; today none of them reads at all, which is why widening the
|
|
|
|
|
|
# set is a no-op on current code rather than a bug fix.
|
|
|
|
|
|
files="$(cd "$ROOT" && shopt -s globstar dotglob && printf '%s\n' bin/* ./**/*.sh | sed 's|^\./||' | sort -u)"
|
2026-07-19 21:06:44 +00:00
|
|
|
|
while IFS= read -r f; do
|
|
|
|
|
|
[ -f "$ROOT/$f" ] || continue
|
|
|
|
|
|
grep -qE '^[[:space:]]*set[[:space:]]+-[a-zA-Z]*e' "$ROOT/$f" || continue
|
|
|
|
|
|
while IFS=: read -r n line; do
|
|
|
|
|
|
case "$line" in
|
|
|
|
|
|
*'<<<'*) continue ;; # herestring, not a prompt
|
|
|
|
|
|
*'||'*) continue ;; # guarded — the whole point
|
|
|
|
|
|
esac
|
|
|
|
|
|
echo "$f:$n: prompt-shaped 'read' under 'set -e' with no '||' guard:$line"
|
|
|
|
|
|
bad=1
|
|
|
|
|
|
done < <(grep -nE '^[[:space:]]*(IFS=[^[:space:]]+[[:space:]]+)?read([[:space:]]|$)' "$ROOT/$f")
|
|
|
|
|
|
done <<<"$files"
|
|
|
|
|
|
return "$bad"
|
|
|
|
|
|
}
|
|
|
|
|
|
check "no prompt-shaped 'read' under 'set -e' goes unguarded, repo-wide (#111)" \
|
|
|
|
|
|
0 "" eof_guard_sweep
|
|
|
|
|
|
|
2026-07-19 19:40:39 +00:00
|
|
|
|
rm -rf "$CSHIM" "$CWORK"
|
|
|
|
|
|
|
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"
|
|
|
|
|
|
|
feat: a minted box records how it was minted, and box info reads it back
cmd_new knew a great deal at the moment it launched and wrote three user.*
keys, dropping the rest: the box version that minted it, the base image (an
unpinned alias on a moving remote), the rig role, which rig repo and ref
converged it, the mint time, and whether a container was chosen or fallen
back into for want of /dev/kvm. There is no host-side per-box store — the
Incus instance config IS the database — so every one of those facts was gone
the moment the mint returned.
The same single write point now carries them as user.box.*, plus
user.box.schema=1 naming the stamp's shape. The alias's resolved fingerprint
is pinned in a second call after the launch, read from volatile.base_image,
best-effort by construction: a box that exists and boots must never be failed
over a provenance field.
A clone re-stamps rather than inheriting. 'incus copy' carries every user.*
key forward (audit B2), so an inherited stamp would not go stale, it would go
false. --from now re-stamps schema/version/created/origin/origin.from on the
copied instance before it starts, and leaves the lineage keys (template,
user, image, role, rig pin) alone — the clone's disk genuinely came from
them. origin.from records one hop.
cmd_info grows a provenance block, tolerating absence everywhere: boxes
minted before this stamp existed render as a box with blanks, and a schema
this box does not recognise is treated as newer than it, not as broken.
Closes #103.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:12:47 +00:00
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# The mint stamp (#103) — DRIVEN on both halves, write and read.
|
|
|
|
|
|
#
|
|
|
|
|
|
# There is no host-side per-box store: the Incus instance config IS the
|
|
|
|
|
|
# database, so the only proof that a fact survives the mint is the argument
|
|
|
|
|
|
# list box hands 'incus launch'. A fake incus logs every call verbatim and
|
|
|
|
|
|
# answers just enough for cmd_new and cmd_info to run to completion with no
|
|
|
|
|
|
# daemon anywhere — the same trick the confirm-gate drive uses above.
|
|
|
|
|
|
#
|
|
|
|
|
|
# The read half matters as much as the write half, and legacy boxes most of
|
|
|
|
|
|
# all: every box minted before this stamp existed carries none of these keys,
|
|
|
|
|
|
# and a box outlives the release that minted it. 'incus config get' on an
|
|
|
|
|
|
# unset key prints EMPTY and exits 0 (audit B4), so "no stamp" and "daemon
|
|
|
|
|
|
# said no" arrive identically — 'box info' must render both as a box with
|
|
|
|
|
|
# blanks, never as an error.
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
MSHIM="$(mktemp -d)"; MWORK="$(mktemp -d)"
|
|
|
|
|
|
cat > "$MSHIM/incus" <<'SHIM'
|
|
|
|
|
|
#!/usr/bin/env bash
|
|
|
|
|
|
# Fake incus for the mint-stamp drive. Knobs, all optional:
|
|
|
|
|
|
# FAKE_BASE_IMAGE what 'config get <i> volatile.base_image' resolves to;
|
|
|
|
|
|
# empty = incus does not know it (the degraded mint)
|
|
|
|
|
|
# FAKE_CFG a file of "<key> <value>" lines answering 'config get'
|
|
|
|
|
|
# FAKE_ROW the csv row 'list --columns nstS' returns
|
|
|
|
|
|
# The launch carries a whole cloud-init seed, so the call is logged with its
|
|
|
|
|
|
# newlines flattened — an assertion about "the launch line" must see one line.
|
|
|
|
|
|
printf 'incus %s\n' "$*" | tr '\n' ' ' >> "$FAKE_INCUS_LOG"
|
|
|
|
|
|
printf '\n' >> "$FAKE_INCUS_LOG"
|
|
|
|
|
|
case "$*" in
|
|
|
|
|
|
*volatile.base_image) printf '%s\n' "${FAKE_BASE_IMAGE-}" ;;
|
|
|
|
|
|
"config get "*)
|
|
|
|
|
|
[ -n "${FAKE_CFG:-}" ] || exit 0
|
|
|
|
|
|
key="$*"; key="${key##* }"
|
|
|
|
|
|
awk -v k="$key" '$1 == k { $1 = ""; sub(/^ /, ""); print }' "$FAKE_CFG" ;;
|
|
|
|
|
|
*"--columns nstS") printf '%s\n' "${FAKE_ROW-}" ;;
|
|
|
|
|
|
*"--columns 4") echo '10.1.2.3 (enp5s0)' ;;
|
|
|
|
|
|
esac
|
|
|
|
|
|
exit 0
|
|
|
|
|
|
SHIM
|
|
|
|
|
|
chmod +x "$MSHIM/incus"
|
|
|
|
|
|
|
|
|
|
|
|
export FAKE_BASE_IMAGE=deadbeefcafe0123456789 # what the alias resolves to
|
|
|
|
|
|
mintbox() { # mintbox <logfile> <args...> — the real box, shimmed, no TTY
|
|
|
|
|
|
local log="$1"; shift
|
|
|
|
|
|
: > "$log"
|
|
|
|
|
|
env FAKE_INCUS_LOG="$log" PATH="$MSHIM:$PATH" "$BOX" "$@" </dev/null >"$log.out" 2>&1
|
|
|
|
|
|
local rc=$?
|
|
|
|
|
|
cat "$log.out"
|
|
|
|
|
|
return "$rc"
|
|
|
|
|
|
}
|
|
|
|
|
|
# The launch line, isolated: every assertion below is about ONE incus call, and
|
|
|
|
|
|
# grepping the whole log would let a key stamped by some other call pass.
|
|
|
|
|
|
launchline() { grep -m1 '^incus launch ' "$1"; }
|
|
|
|
|
|
# launch_has/restamp_has <log> <ere> — a matcher per surface, so the ABSENCE
|
|
|
|
|
|
# assertions (a key that must not be stamped) are a plain non-zero exit rather
|
|
|
|
|
|
# than a nest of quoting.
|
|
|
|
|
|
launch_has() { launchline "$1" | grep -qE "$2"; }
|
|
|
|
|
|
restamp_has() { grep -F 'config set' "$1" | grep -qE "$2"; }
|
|
|
|
|
|
|
|
|
|
|
|
# --- the write half: a fresh mint ------------------------------------------
|
|
|
|
|
|
MLOG="$MWORK/mint.log"
|
|
|
|
|
|
check "mint: a shimmed 'box new' runs to completion" 0 "ready" \
|
2026-07-21 10:51:08 +00:00
|
|
|
|
mintbox "$MLOG" new --name w1 --template claude-box --container
|
feat: a minted box records how it was minted, and box info reads it back
cmd_new knew a great deal at the moment it launched and wrote three user.*
keys, dropping the rest: the box version that minted it, the base image (an
unpinned alias on a moving remote), the rig role, which rig repo and ref
converged it, the mint time, and whether a container was chosen or fallen
back into for want of /dev/kvm. There is no host-side per-box store — the
Incus instance config IS the database — so every one of those facts was gone
the moment the mint returned.
The same single write point now carries them as user.box.*, plus
user.box.schema=1 naming the stamp's shape. The alias's resolved fingerprint
is pinned in a second call after the launch, read from volatile.base_image,
best-effort by construction: a box that exists and boots must never be failed
over a provenance field.
A clone re-stamps rather than inheriting. 'incus copy' carries every user.*
key forward (audit B2), so an inherited stamp would not go stale, it would go
false. --from now re-stamps schema/version/created/origin/origin.from on the
copied instance before it starts, and leaves the lineage keys (template,
user, image, role, rig pin) alone — the clone's disk genuinely came from
them. origin.from records one hop.
cmd_info grows a provenance block, tolerating absence everywhere: boxes
minted before this stamp existed render as a box with blanks, and a schema
this box does not recognise is treated as newer than it, not as broken.
Closes #103.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:12:47 +00:00
|
|
|
|
# Each key on its own check: a single grep for the whole block would go green
|
|
|
|
|
|
# on a partial stamp, and "which fact was dropped" is the useful failure.
|
|
|
|
|
|
check "mint: stamps the schema — the stamp's SHAPE, not the box version (#103)" \
|
|
|
|
|
|
0 "user.box.schema=1" launchline "$MLOG"
|
|
|
|
|
|
check "mint: stamps the box version that minted it (#103)" \
|
|
|
|
|
|
0 "user.box.version=$(cat "$ROOT/VERSION")" launchline "$MLOG"
|
|
|
|
|
|
check "mint: stamps the base image ALIAS asked for (#103)" \
|
|
|
|
|
|
0 "user.box.image=images:debian/13/cloud" launchline "$MLOG"
|
|
|
|
|
|
check "mint: stamps the mode it minted as (#103)" \
|
|
|
|
|
|
0 "user.box.mode=container" launchline "$MLOG"
|
|
|
|
|
|
# The demand, not just the outcome: TYPE already says CT afterwards, but only
|
|
|
|
|
|
# the mint knew whether a container was ASKED for or fallen back into.
|
|
|
|
|
|
check "mint: stamps the mode that was ASKED, not only the outcome (#103)" \
|
|
|
|
|
|
0 "user.box.mode.asked=container" launchline "$MLOG"
|
|
|
|
|
|
check "mint: stamps the rig role box will auto-run (#103)" \
|
2026-07-21 10:51:08 +00:00
|
|
|
|
0 "user.box.role=claude-box" launchline "$MLOG"
|
feat: a minted box records how it was minted, and box info reads it back
cmd_new knew a great deal at the moment it launched and wrote three user.*
keys, dropping the rest: the box version that minted it, the base image (an
unpinned alias on a moving remote), the rig role, which rig repo and ref
converged it, the mint time, and whether a container was chosen or fallen
back into for want of /dev/kvm. There is no host-side per-box store — the
Incus instance config IS the database — so every one of those facts was gone
the moment the mint returned.
The same single write point now carries them as user.box.*, plus
user.box.schema=1 naming the stamp's shape. The alias's resolved fingerprint
is pinned in a second call after the launch, read from volatile.base_image,
best-effort by construction: a box that exists and boots must never be failed
over a provenance field.
A clone re-stamps rather than inheriting. 'incus copy' carries every user.*
key forward (audit B2), so an inherited stamp would not go stale, it would go
false. --from now re-stamps schema/version/created/origin/origin.from on the
copied instance before it starts, and leaves the lineage keys (template,
user, image, role, rig pin) alone — the clone's disk genuinely came from
them. origin.from records one hop.
cmd_info grows a provenance block, tolerating absence everywhere: boxes
minted before this stamp existed render as a box with blanks, and a schema
this box does not recognise is treated as newer than it, not as broken.
Closes #103.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:12:47 +00:00
|
|
|
|
check "mint: stamps which rig converged it — repo (#103)" \
|
|
|
|
|
|
0 "user.box.rig.repo=heavy-duty/rig" launchline "$MLOG"
|
|
|
|
|
|
check "mint: stamps which rig converged it — ref (#103)" \
|
|
|
|
|
|
0 "user.box.rig.ref=main" launchline "$MLOG"
|
|
|
|
|
|
check "mint: stamps the origin (#103)" 0 "user.box.origin=mint" launchline "$MLOG"
|
|
|
|
|
|
# The timestamp's SHAPE, so a local-time or seconds-since-epoch spelling fails
|
|
|
|
|
|
# here: UTC ISO 8601, which is the only form that sorts and travels.
|
|
|
|
|
|
check "mint: stamps the mint time as UTC ISO 8601 (#103)" 0 "" \
|
|
|
|
|
|
launch_has "$MLOG" 'user\.box\.created=[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z'
|
|
|
|
|
|
# The existing three keys are UNTOUCHED — the stamp extends the namespace, it
|
|
|
|
|
|
# does not rewrite it, and box_user()/the login hint read two of them.
|
|
|
|
|
|
check "mint: the pre-existing boundary tag still rides the same line" \
|
|
|
|
|
|
0 "user.box=1" launchline "$MLOG"
|
|
|
|
|
|
check "mint: the pre-existing template stamp is untouched" \
|
2026-07-21 10:51:08 +00:00
|
|
|
|
0 "user.box.template=claude-box" launchline "$MLOG"
|
feat: a minted box records how it was minted, and box info reads it back
cmd_new knew a great deal at the moment it launched and wrote three user.*
keys, dropping the rest: the box version that minted it, the base image (an
unpinned alias on a moving remote), the rig role, which rig repo and ref
converged it, the mint time, and whether a container was chosen or fallen
back into for want of /dev/kvm. There is no host-side per-box store — the
Incus instance config IS the database — so every one of those facts was gone
the moment the mint returned.
The same single write point now carries them as user.box.*, plus
user.box.schema=1 naming the stamp's shape. The alias's resolved fingerprint
is pinned in a second call after the launch, read from volatile.base_image,
best-effort by construction: a box that exists and boots must never be failed
over a provenance field.
A clone re-stamps rather than inheriting. 'incus copy' carries every user.*
key forward (audit B2), so an inherited stamp would not go stale, it would go
false. --from now re-stamps schema/version/created/origin/origin.from on the
copied instance before it starts, and leaves the lineage keys (template,
user, image, role, rig pin) alone — the clone's disk genuinely came from
them. origin.from records one hop.
cmd_info grows a provenance block, tolerating absence everywhere: boxes
minted before this stamp existed render as a box with blanks, and a schema
this box does not recognise is treated as newer than it, not as broken.
Closes #103.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:12:47 +00:00
|
|
|
|
check "mint: the pre-existing user stamp is untouched" \
|
|
|
|
|
|
0 "user.box.user=claude" launchline "$MLOG"
|
|
|
|
|
|
# Deliberately NOT stamped. limits.* already hold cpu/memory and a duplicate
|
|
|
|
|
|
# drifts the first time someone edits a limit by hand; a container's disk does
|
|
|
|
|
|
# not exist (its root rides the pool); and the tier is a fact about whoever is
|
|
|
|
|
|
# ASKING. Absence assertions, so a well-meant addition has to argue here first.
|
|
|
|
|
|
check "mint: does NOT duplicate cpu into the user.box namespace (#103)" 1 "" \
|
|
|
|
|
|
launch_has "$MLOG" 'user\.box\.cpu'
|
|
|
|
|
|
check "mint: does NOT duplicate memory into the user.box namespace (#103)" 1 "" \
|
|
|
|
|
|
launch_has "$MLOG" 'user\.box\.memory'
|
|
|
|
|
|
check "mint: does NOT stamp a disk — a container's does not exist (#103)" 1 "" \
|
|
|
|
|
|
launch_has "$MLOG" 'user\.box\.disk'
|
|
|
|
|
|
check "mint: does NOT stamp the tier — it describes the asker, not the box (#103)" 1 "" \
|
|
|
|
|
|
launch_has "$MLOG" 'user\.box\.tier'
|
|
|
|
|
|
|
|
|
|
|
|
# --- the resolved fingerprint: the alias is not a reproducible fact ---------
|
|
|
|
|
|
# $T_IMAGE is an unpinned alias on a moving remote. Incus resolves it during
|
|
|
|
|
|
# the launch and records what it landed on; box reads that back and pins it.
|
|
|
|
|
|
check "mint: pins the RESOLVED image fingerprint after the launch (#103)" 0 "" \
|
|
|
|
|
|
grep -qF "config set w1 user.box.image.fingerprint=$FAKE_BASE_IMAGE" "$MLOG"
|
|
|
|
|
|
check "mint: ...and it is a SECOND call, not something the launch could know" 1 "" \
|
|
|
|
|
|
launch_has "$MLOG" 'image\.fingerprint'
|
|
|
|
|
|
# The load-bearing half of that design: a box that exists and boots must never
|
|
|
|
|
|
# be failed over a provenance field. With no fingerprint to be had, the mint
|
|
|
|
|
|
# still succeeds and the alias stands alone as the honest partial answer.
|
|
|
|
|
|
NOFP="$MWORK/nofp.log"
|
|
|
|
|
|
FAKE_BASE_IMAGE="" # exported above; incus does not know what the alias resolved to
|
|
|
|
|
|
check "mint: an unknowable fingerprint does not fail the mint (#103)" 0 "ready" \
|
|
|
|
|
|
mintbox "$NOFP" new --name w4 --template blank --container
|
|
|
|
|
|
check "mint: ...and it stamped no empty fingerprint key either (#103)" 1 "" \
|
|
|
|
|
|
grep -q 'image.fingerprint' "$NOFP"
|
|
|
|
|
|
FAKE_BASE_IMAGE=deadbeefcafe0123456789
|
|
|
|
|
|
|
|
|
|
|
|
# --- a seed that installs no rig is stamped no rig pin ----------------------
|
|
|
|
|
|
# 'blank' carries no @RIG_REPO@ token, so there is no rig to name. A stamp
|
|
|
|
|
|
# that named one anyway would be fiction, which is worse than a missing key.
|
|
|
|
|
|
check "mint: the 'blank' seed installs no rig, so no rig pin is stamped (#103)" 1 "" \
|
|
|
|
|
|
launch_has "$NOFP" 'user\.box\.rig\.'
|
|
|
|
|
|
check "mint: ...and no role either — blank names none (#103)" 1 "" \
|
|
|
|
|
|
launch_has "$NOFP" 'user\.box\.role'
|
|
|
|
|
|
|
|
|
|
|
|
# --- the rig pin has ONE definition, and both readers get the same answer ---
|
|
|
|
|
|
# The seed substitutes it and the stamp records it. Two spellings of the same
|
|
|
|
|
|
# default would eventually disagree, and a stamp that disagrees with the seed
|
|
|
|
|
|
# it shipped alongside is worse than no stamp. Driven through the environment
|
|
|
|
|
|
# override, so the two are compared on a value neither can have hardcoded.
|
|
|
|
|
|
RIGLOG="$MWORK/rig.log"
|
|
|
|
|
|
export RIG_REPO=someone/rig RIG_REF=probe-ref
|
2026-07-21 10:51:08 +00:00
|
|
|
|
mintbox "$RIGLOG" new --name w5 --template claude-box --container >/dev/null 2>&1
|
feat: a minted box records how it was minted, and box info reads it back
cmd_new knew a great deal at the moment it launched and wrote three user.*
keys, dropping the rest: the box version that minted it, the base image (an
unpinned alias on a moving remote), the rig role, which rig repo and ref
converged it, the mint time, and whether a container was chosen or fallen
back into for want of /dev/kvm. There is no host-side per-box store — the
Incus instance config IS the database — so every one of those facts was gone
the moment the mint returned.
The same single write point now carries them as user.box.*, plus
user.box.schema=1 naming the stamp's shape. The alias's resolved fingerprint
is pinned in a second call after the launch, read from volatile.base_image,
best-effort by construction: a box that exists and boots must never be failed
over a provenance field.
A clone re-stamps rather than inheriting. 'incus copy' carries every user.*
key forward (audit B2), so an inherited stamp would not go stale, it would go
false. --from now re-stamps schema/version/created/origin/origin.from on the
copied instance before it starts, and leaves the lineage keys (template,
user, image, role, rig pin) alone — the clone's disk genuinely came from
them. origin.from records one hop.
cmd_info grows a provenance block, tolerating absence everywhere: boxes
minted before this stamp existed render as a box with blanks, and a schema
this box does not recognise is treated as newer than it, not as broken.
Closes #103.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:12:47 +00:00
|
|
|
|
unset RIG_REPO RIG_REF
|
|
|
|
|
|
check "mint: the rig pin override reaches the STAMP (#103)" 0 "" \
|
|
|
|
|
|
launch_has "$RIGLOG" 'user\.box\.rig\.repo=someone/rig'
|
|
|
|
|
|
check "mint: ...both halves of it (#103)" 0 "" \
|
|
|
|
|
|
launch_has "$RIGLOG" 'user\.box\.rig\.ref=probe-ref'
|
|
|
|
|
|
check "mint: ...and the SEED it shipped with carries the same pin (#103)" 0 "" \
|
|
|
|
|
|
launch_has "$RIGLOG" 'someone/rig/probe-ref'
|
|
|
|
|
|
|
|
|
|
|
|
# --- the clone: the sharpest edge of the whole stamp ------------------------
|
|
|
|
|
|
# 'incus copy' carries every user.* key forward (audit B2) — which is what
|
|
|
|
|
|
# makes a clone know its template for free, and is exactly why the stamp
|
|
|
|
|
|
# cannot ride along untouched. An inherited stamp does not go stale, it goes
|
|
|
|
|
|
# FALSE: the clone would claim a mint time it was not present for, by a box
|
|
|
|
|
|
# version that never saw it.
|
|
|
|
|
|
CLONELOG="$MWORK/clone.log"
|
|
|
|
|
|
check "clone: a shimmed 'box new --from' runs to completion" 0 "cloned" \
|
|
|
|
|
|
mintbox "$CLONELOG" new --name w2 --from work/authed
|
|
|
|
|
|
check "clone: re-stamps the origin as a clone, not a mint (#103)" 0 "" \
|
|
|
|
|
|
grep -qF 'user.box.origin=clone' "$CLONELOG"
|
|
|
|
|
|
check "clone: names the source it was taken from — one hop (#103)" 0 "" \
|
|
|
|
|
|
grep -qF 'user.box.origin.from=work/authed' "$CLONELOG"
|
|
|
|
|
|
check "clone: re-stamps the box version that made THIS instance (#103)" 0 "" \
|
|
|
|
|
|
grep -qF "user.box.version=$(cat "$ROOT/VERSION")" "$CLONELOG"
|
|
|
|
|
|
check "clone: re-stamps a fresh created time (#103)" 0 "" \
|
|
|
|
|
|
grep -qE 'user\.box\.created=[0-9]{4}-[0-9]{2}-[0-9]{2}T' "$CLONELOG"
|
|
|
|
|
|
# The other half of the decision, and the reason it is a decision at all: the
|
|
|
|
|
|
# LINEAGE keys are left alone on purpose. The clone's disk genuinely came from
|
|
|
|
|
|
# that image, that template, that user, that role — re-stamping them from the
|
|
|
|
|
|
# cloning process's own template lookup would be the actual lie, and would
|
|
|
|
|
|
# break the login hint that reads user.box.template off the instance.
|
|
|
|
|
|
check "clone: does NOT re-stamp the template — it is inherited lineage (#103)" 1 "" \
|
|
|
|
|
|
restamp_has "$CLONELOG" 'user\.box\.template'
|
|
|
|
|
|
check "clone: does NOT re-stamp the user — box_user() reads the source's (#103)" 1 "" \
|
|
|
|
|
|
restamp_has "$CLONELOG" 'user\.box\.user'
|
|
|
|
|
|
check "clone: does NOT re-stamp the image — the disk really came from it (#103)" 1 "" \
|
|
|
|
|
|
restamp_has "$CLONELOG" 'user\.box\.image'
|
|
|
|
|
|
check "clone: does NOT re-stamp the role — the source's rig converged it (#103)" 1 "" \
|
|
|
|
|
|
restamp_has "$CLONELOG" 'user\.box\.role'
|
fix: a clone clears mode.asked — nobody asked THIS box anything (#103)
The clone's re-stamp split provenance into two columns: event facts
re-stamped (schema/version/created/origin/origin.from), lineage facts left
alone (template/user/image/role/rig/mode). Review found one key that sits in
neither: user.box.mode.asked.
It is a mint-EVENT fact by the split's own criterion — only the mint knew
whether a container was asked for or fallen back into for want of /dev/kvm —
but the asker was the SOURCE's operator. A clone refuses --vm/--container
outright, so nobody was asked anything about this instance. Riding through
'incus copy' untouched, it made 'box info' on a clone print
MODE vm (asked: auto)
describing a demand never made of it, with nothing marking it ancestral.
There is no true value to re-stamp it with, so it is CLEARED — cleared and
not set-to-empty, because an empty value is still a key a reader would find.
The unset tolerates failure: a source that predates the stamp never carried
the key, and a clone must not die over a key that was already absent. It
lands with the re-stamp and before the start, the same rule and the same
reason as the rest: a clone is never observable wearing an 'asked' its
operator never gave.
The read side needs no special case. The MODE line was already gated on
'asked', so absence renders as silence — the same absence-is-silence rule the
whole provenance block uses — while TYPE still reports VM or CT off the
instance type 'incus copy' preserves.
+7 checks (529 -> 536), each proven to bite.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 11:31:37 +00:00
|
|
|
|
# The third column, and the one review found: 'mode.asked' is neither lineage
|
|
|
|
|
|
# nor re-stampable. It is a mint-event fact whose asker was the SOURCE's
|
|
|
|
|
|
# operator, and a clone refuses --vm/--container, so nobody was asked anything
|
|
|
|
|
|
# here. It is CLEARED — there is no true value to give it.
|
|
|
|
|
|
check "clone: clears the inherited mode.asked — nobody asked THIS box (#103)" 0 "" \
|
|
|
|
|
|
grep -qF 'config unset w2 user.box.mode.asked' "$CLONELOG"
|
|
|
|
|
|
check "clone: ...and does not re-stamp it with a fabricated answer (#103)" 1 "" \
|
|
|
|
|
|
restamp_has "$CLONELOG" 'user\.box\.mode\.asked'
|
|
|
|
|
|
# Cleared, never set-to-empty: an empty value is still a key on the instance.
|
|
|
|
|
|
check "clone: clears it rather than setting it empty (#103)" 1 "" \
|
|
|
|
|
|
grep -qE 'config set .*user\.box\.mode\.asked=($|[[:space:]])' "$CLONELOG"
|
feat: a minted box records how it was minted, and box info reads it back
cmd_new knew a great deal at the moment it launched and wrote three user.*
keys, dropping the rest: the box version that minted it, the base image (an
unpinned alias on a moving remote), the rig role, which rig repo and ref
converged it, the mint time, and whether a container was chosen or fallen
back into for want of /dev/kvm. There is no host-side per-box store — the
Incus instance config IS the database — so every one of those facts was gone
the moment the mint returned.
The same single write point now carries them as user.box.*, plus
user.box.schema=1 naming the stamp's shape. The alias's resolved fingerprint
is pinned in a second call after the launch, read from volatile.base_image,
best-effort by construction: a box that exists and boots must never be failed
over a provenance field.
A clone re-stamps rather than inheriting. 'incus copy' carries every user.*
key forward (audit B2), so an inherited stamp would not go stale, it would go
false. --from now re-stamps schema/version/created/origin/origin.from on the
copied instance before it starts, and leaves the lineage keys (template,
user, image, role, rig pin) alone — the clone's disk genuinely came from
them. origin.from records one hop.
cmd_info grows a provenance block, tolerating absence everywhere: boxes
minted before this stamp existed render as a box with blanks, and a schema
this box does not recognise is treated as newer than it, not as broken.
Closes #103.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:12:47 +00:00
|
|
|
|
# Order: the re-stamp lands on the copied instance BEFORE it is started, so a
|
|
|
|
|
|
# clone is never observable wearing its source's provenance. Fail-closed — an
|
|
|
|
|
|
# absent line makes the arithmetic fail, not pass.
|
|
|
|
|
|
restamp_precedes_start() {
|
|
|
|
|
|
local set start
|
|
|
|
|
|
set="$(grep -n 'config set .* user.box.origin=clone' "$1" | head -1 | cut -d: -f1)"
|
|
|
|
|
|
start="$(grep -n '^incus start ' "$1" | head -1 | cut -d: -f1)"
|
|
|
|
|
|
[ -n "$set" ] && [ -n "$start" ] && [ "$set" -lt "$start" ]
|
|
|
|
|
|
}
|
|
|
|
|
|
check "clone: the re-stamp precedes the start (#103)" 0 "" \
|
|
|
|
|
|
restamp_precedes_start "$CLONELOG"
|
fix: a clone clears mode.asked — nobody asked THIS box anything (#103)
The clone's re-stamp split provenance into two columns: event facts
re-stamped (schema/version/created/origin/origin.from), lineage facts left
alone (template/user/image/role/rig/mode). Review found one key that sits in
neither: user.box.mode.asked.
It is a mint-EVENT fact by the split's own criterion — only the mint knew
whether a container was asked for or fallen back into for want of /dev/kvm —
but the asker was the SOURCE's operator. A clone refuses --vm/--container
outright, so nobody was asked anything about this instance. Riding through
'incus copy' untouched, it made 'box info' on a clone print
MODE vm (asked: auto)
describing a demand never made of it, with nothing marking it ancestral.
There is no true value to re-stamp it with, so it is CLEARED — cleared and
not set-to-empty, because an empty value is still a key a reader would find.
The unset tolerates failure: a source that predates the stamp never carried
the key, and a clone must not die over a key that was already absent. It
lands with the re-stamp and before the start, the same rule and the same
reason as the rest: a clone is never observable wearing an 'asked' its
operator never gave.
The read side needs no special case. The MODE line was already gated on
'asked', so absence renders as silence — the same absence-is-silence rule the
whole provenance block uses — while TYPE still reports VM or CT off the
instance type 'incus copy' preserves.
+7 checks (529 -> 536), each proven to bite.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 11:31:37 +00:00
|
|
|
|
# The clear rides the same rule for the same reason: a clone must never be
|
|
|
|
|
|
# observable — not for one moment, not to 'box info' — wearing an 'asked' its
|
|
|
|
|
|
# operator never gave. Fail-closed the same way.
|
|
|
|
|
|
clear_precedes_start() {
|
|
|
|
|
|
local unset_ln start
|
|
|
|
|
|
unset_ln="$(grep -n 'config unset .* user.box.mode.asked' "$1" | head -1 | cut -d: -f1)"
|
|
|
|
|
|
start="$(grep -n '^incus start ' "$1" | head -1 | cut -d: -f1)"
|
|
|
|
|
|
[ -n "$unset_ln" ] && [ -n "$start" ] && [ "$unset_ln" -lt "$start" ]
|
|
|
|
|
|
}
|
|
|
|
|
|
check "clone: the mode.asked clear precedes the start too (#103)" 0 "" \
|
|
|
|
|
|
clear_precedes_start "$CLONELOG"
|
feat: a minted box records how it was minted, and box info reads it back
cmd_new knew a great deal at the moment it launched and wrote three user.*
keys, dropping the rest: the box version that minted it, the base image (an
unpinned alias on a moving remote), the rig role, which rig repo and ref
converged it, the mint time, and whether a container was chosen or fallen
back into for want of /dev/kvm. There is no host-side per-box store — the
Incus instance config IS the database — so every one of those facts was gone
the moment the mint returned.
The same single write point now carries them as user.box.*, plus
user.box.schema=1 naming the stamp's shape. The alias's resolved fingerprint
is pinned in a second call after the launch, read from volatile.base_image,
best-effort by construction: a box that exists and boots must never be failed
over a provenance field.
A clone re-stamps rather than inheriting. 'incus copy' carries every user.*
key forward (audit B2), so an inherited stamp would not go stale, it would go
false. --from now re-stamps schema/version/created/origin/origin.from on the
copied instance before it starts, and leaves the lineage keys (template,
user, image, role, rig pin) alone — the clone's disk genuinely came from
them. origin.from records one hop.
cmd_info grows a provenance block, tolerating absence everywhere: boxes
minted before this stamp existed render as a box with blanks, and a schema
this box does not recognise is treated as newer than it, not as broken.
Closes #103.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:12:47 +00:00
|
|
|
|
|
|
|
|
|
|
# --- the read half: 'box info' surfaces it ---------------------------------
|
|
|
|
|
|
# A stamp nothing can read is not done. cmd_info printed NAME/STATE/TYPE/IPV4
|
|
|
|
|
|
# and surfaced none of the keys that already existed.
|
|
|
|
|
|
STAMPED="$MWORK/stamped.cfg"
|
|
|
|
|
|
cat > "$STAMPED" <<'CFG'
|
|
|
|
|
|
user.box 1
|
|
|
|
|
|
user.box.schema 1
|
|
|
|
|
|
user.box.created 2026-07-19T14:22:07Z
|
|
|
|
|
|
user.box.version 0.8.0
|
|
|
|
|
|
user.box.image images:debian/13/cloud
|
|
|
|
|
|
user.box.image.fingerprint 8a2f1c9d4e5b6a7c8d9e
|
|
|
|
|
|
user.box.mode vm
|
|
|
|
|
|
user.box.mode.asked auto
|
|
|
|
|
|
user.box.template claude
|
|
|
|
|
|
user.box.user claude
|
|
|
|
|
|
user.box.role claude
|
|
|
|
|
|
user.box.rig.repo heavy-duty/rig
|
|
|
|
|
|
user.box.rig.ref main
|
|
|
|
|
|
user.box.origin mint
|
|
|
|
|
|
CFG
|
|
|
|
|
|
infobox() { # infobox <cfg-file> — 'box info work' against a canned config
|
|
|
|
|
|
env FAKE_INCUS_LOG=/dev/null FAKE_CFG="$1" FAKE_ROW='work,RUNNING,VIRTUAL-MACHINE,0' \
|
|
|
|
|
|
PATH="$MSHIM:$PATH" "$BOX" info work </dev/null 2>&1
|
|
|
|
|
|
}
|
|
|
|
|
|
check "info: surfaces the mint time and the box that minted it (#103)" \
|
|
|
|
|
|
0 "MINTED 2026-07-19T14:22:07Z by box 0.8.0" infobox "$STAMPED"
|
|
|
|
|
|
check "info: surfaces the image alias AND what it resolved to (#103)" \
|
|
|
|
|
|
0 "IMAGE images:debian/13/cloud @ 8a2f1c9d4e5b" infobox "$STAMPED"
|
|
|
|
|
|
check "info: surfaces the template with its user and role (#103)" \
|
|
|
|
|
|
0 "TEMPLATE claude (user claude, role claude)" infobox "$STAMPED"
|
|
|
|
|
|
check "info: surfaces which rig converged it (#103)" \
|
|
|
|
|
|
0 "RIG heavy-duty/rig@main" infobox "$STAMPED"
|
|
|
|
|
|
check "info: surfaces the origin (#103)" 0 "ORIGIN mint" infobox "$STAMPED"
|
|
|
|
|
|
# Still the box it always was: the new block is additive, above the snapshots.
|
|
|
|
|
|
check "info: still prints the state block it always did" 0 "IPV4 10.1.2.3" infobox "$STAMPED"
|
|
|
|
|
|
|
fix: a clone clears mode.asked — nobody asked THIS box anything (#103)
The clone's re-stamp split provenance into two columns: event facts
re-stamped (schema/version/created/origin/origin.from), lineage facts left
alone (template/user/image/role/rig/mode). Review found one key that sits in
neither: user.box.mode.asked.
It is a mint-EVENT fact by the split's own criterion — only the mint knew
whether a container was asked for or fallen back into for want of /dev/kvm —
but the asker was the SOURCE's operator. A clone refuses --vm/--container
outright, so nobody was asked anything about this instance. Riding through
'incus copy' untouched, it made 'box info' on a clone print
MODE vm (asked: auto)
describing a demand never made of it, with nothing marking it ancestral.
There is no true value to re-stamp it with, so it is CLEARED — cleared and
not set-to-empty, because an empty value is still a key a reader would find.
The unset tolerates failure: a source that predates the stamp never carried
the key, and a clone must not die over a key that was already absent. It
lands with the re-stamp and before the start, the same rule and the same
reason as the rest: a clone is never observable wearing an 'asked' its
operator never gave.
The read side needs no special case. The MODE line was already gated on
'asked', so absence renders as silence — the same absence-is-silence rule the
whole provenance block uses — while TYPE still reports VM or CT off the
instance type 'incus copy' preserves.
+7 checks (529 -> 536), each proven to bite.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 11:31:37 +00:00
|
|
|
|
# A clone reads back as a clone, naming its source. Modelled on what the
|
|
|
|
|
|
# --from branch actually leaves behind: origin re-stamped, mode.asked cleared.
|
feat: a minted box records how it was minted, and box info reads it back
cmd_new knew a great deal at the moment it launched and wrote three user.*
keys, dropping the rest: the box version that minted it, the base image (an
unpinned alias on a moving remote), the rig role, which rig repo and ref
converged it, the mint time, and whether a container was chosen or fallen
back into for want of /dev/kvm. There is no host-side per-box store — the
Incus instance config IS the database — so every one of those facts was gone
the moment the mint returned.
The same single write point now carries them as user.box.*, plus
user.box.schema=1 naming the stamp's shape. The alias's resolved fingerprint
is pinned in a second call after the launch, read from volatile.base_image,
best-effort by construction: a box that exists and boots must never be failed
over a provenance field.
A clone re-stamps rather than inheriting. 'incus copy' carries every user.*
key forward (audit B2), so an inherited stamp would not go stale, it would go
false. --from now re-stamps schema/version/created/origin/origin.from on the
copied instance before it starts, and leaves the lineage keys (template,
user, image, role, rig pin) alone — the clone's disk genuinely came from
them. origin.from records one hop.
cmd_info grows a provenance block, tolerating absence everywhere: boxes
minted before this stamp existed render as a box with blanks, and a schema
this box does not recognise is treated as newer than it, not as broken.
Closes #103.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:12:47 +00:00
|
|
|
|
CLONECFG="$MWORK/clone.cfg"
|
fix: a clone clears mode.asked — nobody asked THIS box anything (#103)
The clone's re-stamp split provenance into two columns: event facts
re-stamped (schema/version/created/origin/origin.from), lineage facts left
alone (template/user/image/role/rig/mode). Review found one key that sits in
neither: user.box.mode.asked.
It is a mint-EVENT fact by the split's own criterion — only the mint knew
whether a container was asked for or fallen back into for want of /dev/kvm —
but the asker was the SOURCE's operator. A clone refuses --vm/--container
outright, so nobody was asked anything about this instance. Riding through
'incus copy' untouched, it made 'box info' on a clone print
MODE vm (asked: auto)
describing a demand never made of it, with nothing marking it ancestral.
There is no true value to re-stamp it with, so it is CLEARED — cleared and
not set-to-empty, because an empty value is still a key a reader would find.
The unset tolerates failure: a source that predates the stamp never carried
the key, and a clone must not die over a key that was already absent. It
lands with the re-stamp and before the start, the same rule and the same
reason as the rest: a clone is never observable wearing an 'asked' its
operator never gave.
The read side needs no special case. The MODE line was already gated on
'asked', so absence renders as silence — the same absence-is-silence rule the
whole provenance block uses — while TYPE still reports VM or CT off the
instance type 'incus copy' preserves.
+7 checks (529 -> 536), each proven to bite.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 11:31:37 +00:00
|
|
|
|
{ grep -v -e '^user.box.origin ' -e '^user.box.mode.asked ' "$STAMPED"
|
feat: a minted box records how it was minted, and box info reads it back
cmd_new knew a great deal at the moment it launched and wrote three user.*
keys, dropping the rest: the box version that minted it, the base image (an
unpinned alias on a moving remote), the rig role, which rig repo and ref
converged it, the mint time, and whether a container was chosen or fallen
back into for want of /dev/kvm. There is no host-side per-box store — the
Incus instance config IS the database — so every one of those facts was gone
the moment the mint returned.
The same single write point now carries them as user.box.*, plus
user.box.schema=1 naming the stamp's shape. The alias's resolved fingerprint
is pinned in a second call after the launch, read from volatile.base_image,
best-effort by construction: a box that exists and boots must never be failed
over a provenance field.
A clone re-stamps rather than inheriting. 'incus copy' carries every user.*
key forward (audit B2), so an inherited stamp would not go stale, it would go
false. --from now re-stamps schema/version/created/origin/origin.from on the
copied instance before it starts, and leaves the lineage keys (template,
user, image, role, rig pin) alone — the clone's disk genuinely came from
them. origin.from records one hop.
cmd_info grows a provenance block, tolerating absence everywhere: boxes
minted before this stamp existed render as a box with blanks, and a schema
this box does not recognise is treated as newer than it, not as broken.
Closes #103.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:12:47 +00:00
|
|
|
|
echo 'user.box.origin clone'
|
|
|
|
|
|
echo 'user.box.origin.from work/authed'; } > "$CLONECFG"
|
|
|
|
|
|
check "info: a clone says so, and names the box it came from (#103)" \
|
|
|
|
|
|
0 "ORIGIN clone of work/authed" infobox "$CLONECFG"
|
fix: a clone clears mode.asked — nobody asked THIS box anything (#103)
The clone's re-stamp split provenance into two columns: event facts
re-stamped (schema/version/created/origin/origin.from), lineage facts left
alone (template/user/image/role/rig/mode). Review found one key that sits in
neither: user.box.mode.asked.
It is a mint-EVENT fact by the split's own criterion — only the mint knew
whether a container was asked for or fallen back into for want of /dev/kvm —
but the asker was the SOURCE's operator. A clone refuses --vm/--container
outright, so nobody was asked anything about this instance. Riding through
'incus copy' untouched, it made 'box info' on a clone print
MODE vm (asked: auto)
describing a demand never made of it, with nothing marking it ancestral.
There is no true value to re-stamp it with, so it is CLEARED — cleared and
not set-to-empty, because an empty value is still a key a reader would find.
The unset tolerates failure: a source that predates the stamp never carried
the key, and a clone must not die over a key that was already absent. It
lands with the re-stamp and before the start, the same rule and the same
reason as the rest: a clone is never observable wearing an 'asked' its
operator never gave.
The read side needs no special case. The MODE line was already gated on
'asked', so absence renders as silence — the same absence-is-silence rule the
whole provenance block uses — while TYPE still reports VM or CT off the
instance type 'incus copy' preserves.
+7 checks (529 -> 536), each proven to bite.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 11:31:37 +00:00
|
|
|
|
# ...and stays silent about a demand nobody made of it. The MODE line is gated
|
|
|
|
|
|
# on 'asked' precisely so absence renders as silence rather than as a guess;
|
|
|
|
|
|
# TYPE above still reports VM off the instance type, so nothing is lost.
|
|
|
|
|
|
info_has_mode() { infobox "$1" | grep -q '^MODE'; }
|
|
|
|
|
|
check "info: a clone prints no MODE line — nobody asked IT anything (#103)" 1 "" \
|
|
|
|
|
|
info_has_mode "$CLONECFG"
|
|
|
|
|
|
check "info: ...while TYPE still reports what it actually is (#103)" \
|
|
|
|
|
|
0 "TYPE VM" infobox "$CLONECFG"
|
|
|
|
|
|
# The mint keeps its MODE line — there, the operator really was asked.
|
|
|
|
|
|
check "info: a MINT still surfaces what was asked for (#103)" \
|
|
|
|
|
|
0 "MODE vm (asked: auto)" infobox "$STAMPED"
|
feat: a minted box records how it was minted, and box info reads it back
cmd_new knew a great deal at the moment it launched and wrote three user.*
keys, dropping the rest: the box version that minted it, the base image (an
unpinned alias on a moving remote), the rig role, which rig repo and ref
converged it, the mint time, and whether a container was chosen or fallen
back into for want of /dev/kvm. There is no host-side per-box store — the
Incus instance config IS the database — so every one of those facts was gone
the moment the mint returned.
The same single write point now carries them as user.box.*, plus
user.box.schema=1 naming the stamp's shape. The alias's resolved fingerprint
is pinned in a second call after the launch, read from volatile.base_image,
best-effort by construction: a box that exists and boots must never be failed
over a provenance field.
A clone re-stamps rather than inheriting. 'incus copy' carries every user.*
key forward (audit B2), so an inherited stamp would not go stale, it would go
false. --from now re-stamps schema/version/created/origin/origin.from on the
copied instance before it starts, and leaves the lineage keys (template,
user, image, role, rig pin) alone — the clone's disk genuinely came from
them. origin.from records one hop.
cmd_info grows a provenance block, tolerating absence everywhere: boxes
minted before this stamp existed render as a box with blanks, and a schema
this box does not recognise is treated as newer than it, not as broken.
Closes #103.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:12:47 +00:00
|
|
|
|
|
|
|
|
|
|
# --- legacy boxes: the promise that they keep working under every verb ------
|
|
|
|
|
|
# A box carrying the boundary tag and NOTHING else — every box minted before
|
|
|
|
|
|
# this stamp existed. It must still render, exit 0, and say plainly that the
|
|
|
|
|
|
# mint was not recorded rather than inventing one or erroring out.
|
|
|
|
|
|
LEGACY="$MWORK/legacy.cfg"; printf 'user.box 1\n' > "$LEGACY"
|
|
|
|
|
|
check "info: a box with NO stamp at all still renders, exit 0 (#103)" \
|
|
|
|
|
|
0 "NAME work" infobox "$LEGACY"
|
|
|
|
|
|
check "info: ...and says the mint was not recorded, rather than inventing one" \
|
|
|
|
|
|
0 "predates the mint stamp" infobox "$LEGACY"
|
|
|
|
|
|
info_has() { infobox "$1" | grep -qE "$2"; }
|
|
|
|
|
|
check "info: ...and prints no half-empty IMAGE/ORIGIN lines for keys it lacks" 1 "" \
|
|
|
|
|
|
info_has "$LEGACY" '^(IMAGE|ORIGIN|RIG|MODE) '
|
|
|
|
|
|
# A pre-rename box carries user.claudebox=1 and no metadata at all, and is
|
|
|
|
|
|
# always a Claude box — the same mapping box_user() makes, honored forever.
|
|
|
|
|
|
PRERENAME="$MWORK/prerename.cfg"; printf 'user.claudebox 1\n' > "$PRERENAME"
|
|
|
|
|
|
check "info: a pre-rename box still reads as the claude template (#103)" \
|
|
|
|
|
|
0 "TEMPLATE claude" infobox "$PRERENAME"
|
|
|
|
|
|
|
|
|
|
|
|
# --- a schema from the future is not a broken box --------------------------
|
|
|
|
|
|
# A box outlives the release that minted it, so an OLDER box will one day read
|
|
|
|
|
|
# a NEWER box's stamp. It shows what it understands and says so; refusing to
|
|
|
|
|
|
# describe a box a later release minted perfectly well is the wrong answer.
|
|
|
|
|
|
FUTURE="$MWORK/future.cfg"
|
|
|
|
|
|
{ grep -v '^user.box.schema ' "$STAMPED"; echo 'user.box.schema 99'; } > "$FUTURE"
|
|
|
|
|
|
check "info: an unrecognised (newer) schema is noted, not refused (#103)" \
|
|
|
|
|
|
0 "NOTE" infobox "$FUTURE"
|
|
|
|
|
|
check "info: ...and it still shows every key it DOES understand (#103)" \
|
|
|
|
|
|
0 "MINTED 2026-07-19T14:22:07Z" infobox "$FUTURE"
|
|
|
|
|
|
check "info: ...and it still exits 0 — a future box is not a broken box (#103)" \
|
|
|
|
|
|
0 "ORIGIN" infobox "$FUTURE"
|
|
|
|
|
|
# A non-integer schema lands on the same side: noted, never fatal under set -e.
|
|
|
|
|
|
GARBAGE="$MWORK/garbage.cfg"
|
|
|
|
|
|
{ grep -v '^user.box.schema ' "$STAMPED"; echo 'user.box.schema not-a-number'; } > "$GARBAGE"
|
|
|
|
|
|
check "info: a non-integer schema is noted, not fatal (#103)" 0 "NOTE" infobox "$GARBAGE"
|
|
|
|
|
|
|
|
|
|
|
|
# VERSION has ONE reader in bin/box — box_version() — and both 'box --version'
|
|
|
|
|
|
# and the mint stamp go through it. A second 'cat $root/VERSION' is how the two
|
|
|
|
|
|
# would eventually disagree about what minted a box.
|
|
|
|
|
|
# shellcheck disable=SC2016 # '$root' is bin/box's variable, matched literally
|
|
|
|
|
|
one_version_reader() {
|
|
|
|
|
|
[ "$(grep -cF 'cat "$root/VERSION"' "$ROOT/bin/box")" -eq 1 ]
|
|
|
|
|
|
}
|
|
|
|
|
|
check "the tree's VERSION has a single reader, box_version() (#103)" 0 "" \
|
|
|
|
|
|
one_version_reader
|
|
|
|
|
|
|
feat: box import records the trip, without rewriting who the box was
An imported box kept the artifact's mint stamp verbatim (#103) — correct, the
mint time, box version, image and origin belong to the originating host and
should survive the trip. But nothing recorded the import, so an imported box
was indistinguishable from one minted here at the artifact's mint time.
Not origin=import. 'origin' answers how the instance came into BEING — mint or
clone — and overwriting it would make an exported clone come back claiming to
be an import, with nothing left saying it was ever a clone and an origin.from
naming a lineage no key explains. The import is a third fact, orthogonal to
the first two, so it takes its own keys and leaves every other one alone.
Birth pair plus latest pair, the shape heavy-duty/rig#61 settled on for the
same repeated-event question: imported/imported.by pinned once and never
rewritten, imported.last/.last.by refreshed on every arrival, imported.count
for the trips in between. Last-wins alone would erase the evidence of the
earlier trips, which is the same mistake origin=import makes one level up.
box info prints IMPORTED directly under MINTED, because that adjacency is what
stops the artifact's mint time being misread as this host's. It states only
the ordering and never claims another host: box has no record of which host
minted a box, and a re-import onto the same host is the documented upgrade
flow. user.box.schema does not move — adding a key is not breaking — and is
not written by the import at all, so a legacy artifact still reads as
MINTED (not recorded) rather than acquiring a shape it does not have.
Closes #131.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 11:14:45 +00:00
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# The import event (#131) — DRIVEN, on both halves, like the mint stamp above.
|
|
|
|
|
|
#
|
|
|
|
|
|
# An imported box keeps the artifact's mint stamp verbatim: the mint time, the
|
|
|
|
|
|
# box version, the image and the origin belong to the originating host and
|
|
|
|
|
|
# survive the trip on purpose (#129). What was missing is any record that the
|
|
|
|
|
|
# trip HAPPENED — and the obvious fix, 'origin=import', is the wrong one: it
|
|
|
|
|
|
# would overwrite whether the thing was a mint or a clone before it was
|
|
|
|
|
|
# exported, and leave 'origin.from' naming a lineage nothing explains. So the
|
|
|
|
|
|
# import takes its own keys, and the assertions that matter most here are the
|
|
|
|
|
|
# ABSENCE ones: every key the artifact carried must come out the far side
|
|
|
|
|
|
# untouched.
|
|
|
|
|
|
#
|
|
|
|
|
|
# The write half needs its own shim, because cmd_import reads 'incus config
|
|
|
|
|
|
# show <target>' as the name-collision guard and must see the name FREE — the
|
|
|
|
|
|
# opposite answer from the one the mint shim gives.
|
|
|
|
|
|
ISHIM="$(mktemp -d)"; IWORK="$(mktemp -d)"
|
|
|
|
|
|
cat > "$ISHIM/incus" <<'SHIM'
|
|
|
|
|
|
#!/usr/bin/env bash
|
|
|
|
|
|
# Fake incus for the import drive. FAKE_CFG answers 'config get' with a file
|
|
|
|
|
|
# of "<key> <value>" lines — it stands in for the config that rode inside the
|
|
|
|
|
|
# artifact and that 'incus import' has just restored.
|
|
|
|
|
|
printf 'incus %s\n' "$*" | tr '\n' ' ' >> "$FAKE_INCUS_LOG"
|
|
|
|
|
|
printf '\n' >> "$FAKE_INCUS_LOG"
|
|
|
|
|
|
case "$*" in
|
|
|
|
|
|
# Nothing exists under that name: the collision guard passes. The same call
|
|
|
|
|
|
# enumerates volatile hwaddrs later, where an empty answer is also correct.
|
|
|
|
|
|
"config show "*) exit 1 ;;
|
|
|
|
|
|
"config get "*)
|
|
|
|
|
|
[ -n "${FAKE_CFG:-}" ] || exit 0
|
|
|
|
|
|
key="$*"; key="${key##* }"
|
|
|
|
|
|
awk -v k="$key" '$1 == k { $1 = ""; sub(/^ /, ""); print }' "$FAKE_CFG" ;;
|
|
|
|
|
|
*"--columns P") printf '"box-net"\n' ;; # already on the contract, no re-home
|
|
|
|
|
|
esac
|
|
|
|
|
|
exit 0
|
|
|
|
|
|
SHIM
|
|
|
|
|
|
chmod +x "$ISHIM/incus"
|
|
|
|
|
|
|
|
|
|
|
|
# A real tarball, because cmd_import reads the instance name out of the
|
|
|
|
|
|
# artifact with tar before incus is ever called — a stub cannot fake that.
|
|
|
|
|
|
ARTIFACT="$IWORK/work-20260718T120000Z.tar.gz"
|
|
|
|
|
|
mkdir -p "$IWORK/backup" && printf 'name: work\n' > "$IWORK/backup/index.yaml"
|
|
|
|
|
|
tar -czf "$ARTIFACT" -C "$IWORK" backup/index.yaml
|
|
|
|
|
|
|
|
|
|
|
|
importbox() { # importbox <logfile> <cfg-file|""> — the real box, shimmed
|
|
|
|
|
|
local log="$1" cfg="$2"
|
|
|
|
|
|
: > "$log"
|
|
|
|
|
|
env FAKE_INCUS_LOG="$log" FAKE_CFG="$cfg" PATH="$ISHIM:$PATH" \
|
|
|
|
|
|
"$BOX" import "$ARTIFACT" </dev/null >"$log.out" 2>&1
|
|
|
|
|
|
local rc=$?
|
|
|
|
|
|
cat "$log.out"
|
|
|
|
|
|
return "$rc"
|
|
|
|
|
|
}
|
|
|
|
|
|
# One matcher per surface, so an absence assertion is a plain non-zero exit.
|
|
|
|
|
|
# 'config set' is the ONLY call that can write a key, so grepping it is what
|
|
|
|
|
|
# separates "box stamped this" from "the artifact carried it".
|
|
|
|
|
|
import_set() { grep -F 'config set' "$1" | grep -qE "$2"; }
|
|
|
|
|
|
|
|
|
|
|
|
# --- the first trip ---------------------------------------------------------
|
|
|
|
|
|
# The artifact of a box that was MINTED elsewhere and never imported before.
|
|
|
|
|
|
MINTED_ART="$IWORK/minted.cfg"
|
|
|
|
|
|
cat > "$MINTED_ART" <<'CFG'
|
|
|
|
|
|
user.box 1
|
|
|
|
|
|
user.box.schema 1
|
|
|
|
|
|
user.box.created 2026-06-01T10:00:00Z
|
|
|
|
|
|
user.box.version 0.7.0
|
|
|
|
|
|
user.box.template claude
|
|
|
|
|
|
user.box.user claude
|
|
|
|
|
|
user.box.origin mint
|
|
|
|
|
|
CFG
|
|
|
|
|
|
ILOG="$IWORK/import.log"
|
|
|
|
|
|
check "import: a shimmed 'box import' runs to completion (#131)" 0 "imported work" \
|
|
|
|
|
|
importbox "$ILOG" "$MINTED_ART"
|
|
|
|
|
|
check "import: records WHEN the box landed here, UTC ISO 8601 (#131)" 0 "" \
|
|
|
|
|
|
import_set "$ILOG" 'user\.box\.imported\.last=[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z'
|
|
|
|
|
|
check "import: records WHICH box version performed the import (#131)" 0 "" \
|
|
|
|
|
|
import_set "$ILOG" "user\\.box\\.imported\\.last\\.by=$(cat "$ROOT/VERSION")"
|
|
|
|
|
|
check "import: pins the FIRST trip as its own key (birth pair, rig#61) (#131)" 0 "" \
|
|
|
|
|
|
import_set "$ILOG" 'user\.box\.imported=[0-9]{4}-'
|
|
|
|
|
|
check "import: ...with the box version that made it (#131)" 0 "" \
|
|
|
|
|
|
import_set "$ILOG" "user\\.box\\.imported\\.by=$(cat "$ROOT/VERSION")"
|
|
|
|
|
|
check "import: counts the trip — the first one is 1 (#131)" 0 "" \
|
|
|
|
|
|
import_set "$ILOG" 'user\.box\.imported\.count=1'
|
|
|
|
|
|
|
|
|
|
|
|
# --- the absence assertions: this is the whole point of the issue -----------
|
|
|
|
|
|
# 'origin=import' is the road not taken. 'origin' says how the instance came
|
|
|
|
|
|
# into BEING — mint or clone — and the import is a third, orthogonal fact.
|
|
|
|
|
|
check "import: does NOT overwrite origin — an import is not a coming-into-being (#131)" 1 "" \
|
|
|
|
|
|
import_set "$ILOG" 'user\.box\.origin='
|
|
|
|
|
|
check "import: does NOT restamp the mint time — it is the origin host's (#131)" 1 "" \
|
|
|
|
|
|
import_set "$ILOG" 'user\.box\.created='
|
|
|
|
|
|
check "import: does NOT restamp the box version that MINTED it (#131)" 1 "" \
|
|
|
|
|
|
import_set "$ILOG" 'user\.box\.version='
|
|
|
|
|
|
check "import: does NOT restamp the template — lineage rides in the artifact (#131)" 1 "" \
|
|
|
|
|
|
import_set "$ILOG" 'user\.box\.template='
|
|
|
|
|
|
check "import: does NOT restamp the image the artifact was built on (#131)" 1 "" \
|
|
|
|
|
|
import_set "$ILOG" 'user\.box\.image'
|
|
|
|
|
|
check "import: does NOT restamp the rig that converged it (#131)" 1 "" \
|
|
|
|
|
|
import_set "$ILOG" 'user\.box\.rig\.'
|
|
|
|
|
|
# Adding a key is not a breaking change (#103's schema contract), so the schema
|
|
|
|
|
|
# does not move — and is not written here at all: stamping schema=1 onto a
|
|
|
|
|
|
# legacy artifact would claim a mint stamp shape it does not have.
|
|
|
|
|
|
check "import: does NOT touch user.box.schema — adding a key is not breaking (#131)" 1 "" \
|
|
|
|
|
|
import_set "$ILOG" 'user\.box\.schema'
|
|
|
|
|
|
# Before the start, like the clone re-stamp: an imported box is never
|
|
|
|
|
|
# observable without the record of how it got here. Fail-closed — a missing
|
|
|
|
|
|
# line makes the arithmetic fail, not pass.
|
|
|
|
|
|
import_stamp_precedes_start() {
|
|
|
|
|
|
local set start
|
|
|
|
|
|
set="$(grep -n 'config set .* user.box.imported.last=' "$1" | head -1 | cut -d: -f1)"
|
|
|
|
|
|
start="$(grep -n '^incus start ' "$1" | head -1 | cut -d: -f1)"
|
|
|
|
|
|
[ -n "$set" ] && [ -n "$start" ] && [ "$set" -lt "$start" ]
|
|
|
|
|
|
}
|
|
|
|
|
|
check "import: the import stamp precedes the start (#131)" 0 "" \
|
|
|
|
|
|
import_stamp_precedes_start "$ILOG"
|
|
|
|
|
|
|
|
|
|
|
|
# --- a CLONE that was exported and re-imported ------------------------------
|
|
|
|
|
|
# The case that makes 'origin=import' indefensible: it would come back
|
|
|
|
|
|
# claiming to be an import, with nothing left saying it was ever a clone and
|
|
|
|
|
|
# an origin.from pointing at a lineage no key explains.
|
|
|
|
|
|
CLONE_ART="$IWORK/clone-artifact.cfg"
|
|
|
|
|
|
{ grep -v '^user.box.origin ' "$MINTED_ART"
|
|
|
|
|
|
echo 'user.box.origin clone'
|
|
|
|
|
|
echo 'user.box.origin.from work/authed'; } > "$CLONE_ART"
|
|
|
|
|
|
CLOG="$IWORK/clone-import.log"
|
|
|
|
|
|
check "import: an exported CLONE imports cleanly (#131)" 0 "imported work" \
|
|
|
|
|
|
importbox "$CLOG" "$CLONE_ART"
|
|
|
|
|
|
check "import: ...and is still a clone afterwards — origin untouched (#131)" 1 "" \
|
|
|
|
|
|
import_set "$CLOG" 'user\.box\.origin='
|
|
|
|
|
|
check "import: ...and still names the box it was cloned from (#131)" 1 "" \
|
|
|
|
|
|
import_set "$CLOG" 'user\.box\.origin\.from'
|
|
|
|
|
|
check "import: ...while still recording that the trip happened (#131)" 0 "" \
|
|
|
|
|
|
import_set "$CLOG" 'user\.box\.imported\.last='
|
|
|
|
|
|
|
|
|
|
|
|
# --- the second trip: first-wins, last-wins, and a count --------------------
|
|
|
|
|
|
# The artifact of a box that has already been imported twice. Last-wins alone
|
|
|
|
|
|
# would erase the evidence of the first trip, which is the same mistake
|
|
|
|
|
|
# 'origin=import' makes one level up.
|
|
|
|
|
|
TWICE="$IWORK/twice.cfg"
|
|
|
|
|
|
{ cat "$MINTED_ART"
|
|
|
|
|
|
echo 'user.box.imported 2026-06-15T08:00:00Z'
|
|
|
|
|
|
echo 'user.box.imported.by 0.7.0'
|
|
|
|
|
|
echo 'user.box.imported.last 2026-07-01T09:00:00Z'
|
|
|
|
|
|
echo 'user.box.imported.last.by 0.8.0'
|
|
|
|
|
|
echo 'user.box.imported.count 2'; } > "$TWICE"
|
|
|
|
|
|
RLOG="$IWORK/reimport.log"
|
|
|
|
|
|
check "re-import: an already-imported artifact imports again (#131)" 0 "imported work" \
|
|
|
|
|
|
importbox "$RLOG" "$TWICE"
|
|
|
|
|
|
check "re-import: the FIRST trip is pinned, never rewritten (#131)" 1 "" \
|
|
|
|
|
|
import_set "$RLOG" 'user\.box\.imported=[0-9]'
|
|
|
|
|
|
check "re-import: ...nor is the version that made it (#131)" 1 "" \
|
|
|
|
|
|
import_set "$RLOG" 'user\.box\.imported\.by='
|
|
|
|
|
|
check "re-import: the LATEST trip IS refreshed (#131)" 0 "" \
|
|
|
|
|
|
import_set "$RLOG" 'user\.box\.imported\.last=[0-9]{4}-'
|
|
|
|
|
|
check "re-import: ...by this box version (#131)" 0 "" \
|
|
|
|
|
|
import_set "$RLOG" "user\\.box\\.imported\\.last\\.by=$(cat "$ROOT/VERSION")"
|
|
|
|
|
|
check "re-import: the count advances 2 → 3 (#131)" 0 "" \
|
|
|
|
|
|
import_set "$RLOG" 'user\.box\.imported\.count=3'
|
|
|
|
|
|
# A count that is not an integer (hand-edited config, a foreign key) must not
|
|
|
|
|
|
# fail an import that has already happened — arithmetic under 'set -e' would.
|
|
|
|
|
|
BADN="$IWORK/badcount.cfg"
|
|
|
|
|
|
{ cat "$MINTED_ART"; echo 'user.box.imported.count not-a-number'; } > "$BADN"
|
|
|
|
|
|
BLOG="$IWORK/badcount.log"
|
|
|
|
|
|
check "re-import: a non-integer count does not fail the import (#131)" 0 "imported work" \
|
|
|
|
|
|
importbox "$BLOG" "$BADN"
|
|
|
|
|
|
check "re-import: ...it restarts the count rather than inventing a total (#131)" 0 "" \
|
|
|
|
|
|
import_set "$BLOG" 'user\.box\.imported\.count=1'
|
fix: the import count guard closes the octal hole, not just the garbage one
All three reviewers landed on the same line. Reproduced on this head under
`set -euo pipefail`:
prev_n=08
[ -n "$prev_n" ] && [ "$prev_n" -eq "$prev_n" ] 2>/dev/null || prev_n=0
# -> guard PASSES: test parses 08 as decimal
n=$((prev_n + 1))
# -> bash: 08: value too great for base (error token is "08")
Two holes that had to close together. `test -eq` reads decimal, arithmetic
reads a leading zero as octal, so a value can pass the guard and still abort
the arithmetic it was guarding. The abort lands after the physical
`incus import` and before the stamp, the placement correction and the start —
the exact window the degrade-never-die contract exists to protect, and the
same side box_provenance()'s schema check falls on.
case "$prev_n" in ''|*[!0-9]*) prev_n=0 ;; esac
n=$((10#$prev_n + 1))
Digits-only closes sign and garbage; 10# forces base ten. Checked across
08 -> 9, 007 -> 8, 5 -> 6, and '' / not-a-number / -3 / 3x / 00 -> 1.
The existing not-a-number fixture could never have caught this: that value
fails the guard and degrades, so it exercises the path that already worked.
Added a count=08 fixture beside it, asserting both halves — the import does
not fail, AND the count advances to 9 rather than degrading to 1, because 08
is a real previous total and reading it as anything else would be its own
small lie. A zero-padded count is not exotic; it is what any external tool
that formats numbers writes.
Verified by mutation: with the old guard restored both new assertions fail.
Also drops user.box.mode.asked from the IMPCLONE fixture (grok's nit). Since
#129 the clone path clears that key, so a fixture built from the mint shape
that kept it described a box the clone path cannot produce. Nothing asserts
it today — which is precisely why it would have rotted unnoticed.
2026-07-21 11:49:32 +00:00
|
|
|
|
# A leading zero is the hole the non-integer fixture CANNOT catch: '08' passes
|
|
|
|
|
|
# an -eq guard (test parses decimal) and then dies in arithmetic, which reads
|
|
|
|
|
|
# it as octal. That abort would land after the physical 'incus import' and
|
|
|
|
|
|
# before the stamp, the placement fix and the start — the exact window the
|
|
|
|
|
|
# degrade-never-die contract exists to protect. A zero-padded count is not
|
|
|
|
|
|
# exotic either: it is what any external tool that formats numbers writes.
|
|
|
|
|
|
ZEROPAD="$IWORK/zeropad.cfg"
|
|
|
|
|
|
{ cat "$MINTED_ART"; echo 'user.box.imported.count 08'; } > "$ZEROPAD"
|
|
|
|
|
|
ZLOG="$IWORK/zeropad.log"
|
|
|
|
|
|
check "re-import: a zero-padded count does not fail the import (#131)" 0 "imported work" \
|
|
|
|
|
|
importbox "$ZLOG" "$ZEROPAD"
|
|
|
|
|
|
# Counted as decimal 8, not degraded to 0 and not read as octal: '08' is a
|
|
|
|
|
|
# real previous total, so the honest next value is 9.
|
|
|
|
|
|
check "re-import: ...and counts it as decimal, so 08 advances to 9 (#131)" 0 "" \
|
|
|
|
|
|
import_set "$ZLOG" 'user\.box\.imported\.count=9'
|
feat: box import records the trip, without rewriting who the box was
An imported box kept the artifact's mint stamp verbatim (#103) — correct, the
mint time, box version, image and origin belong to the originating host and
should survive the trip. But nothing recorded the import, so an imported box
was indistinguishable from one minted here at the artifact's mint time.
Not origin=import. 'origin' answers how the instance came into BEING — mint or
clone — and overwriting it would make an exported clone come back claiming to
be an import, with nothing left saying it was ever a clone and an origin.from
naming a lineage no key explains. The import is a third fact, orthogonal to
the first two, so it takes its own keys and leaves every other one alone.
Birth pair plus latest pair, the shape heavy-duty/rig#61 settled on for the
same repeated-event question: imported/imported.by pinned once and never
rewritten, imported.last/.last.by refreshed on every arrival, imported.count
for the trips in between. Last-wins alone would erase the evidence of the
earlier trips, which is the same mistake origin=import makes one level up.
box info prints IMPORTED directly under MINTED, because that adjacency is what
stops the artifact's mint time being misread as this host's. It states only
the ordering and never claims another host: box has no record of which host
minted a box, and a re-import onto the same host is the documented upgrade
flow. user.box.schema does not move — adding a key is not breaking — and is
not written by the import at all, so a legacy artifact still reads as
MINTED (not recorded) rather than acquiring a shape it does not have.
Closes #131.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 11:14:45 +00:00
|
|
|
|
|
|
|
|
|
|
# --- a legacy artifact with no stamp at all ---------------------------------
|
|
|
|
|
|
# A pre-stamp box export, or a hand-rolled 'incus export' of an unmanaged VM.
|
|
|
|
|
|
# It must import cleanly, get the boundary tag, get the import record — and NOT
|
|
|
|
|
|
# acquire a fabricated mint, which is what 'not recorded' exists to say.
|
|
|
|
|
|
LLOG="$IWORK/legacy-import.log"
|
|
|
|
|
|
check "import: a legacy artifact with NO stamp imports cleanly (#131)" 0 "imported work" \
|
|
|
|
|
|
importbox "$LLOG" /dev/null
|
|
|
|
|
|
check "import: ...it still gets the boundary tag (importing is minting) (#131)" 0 "" \
|
|
|
|
|
|
grep -qF 'config set work user.box=1' "$LLOG"
|
|
|
|
|
|
check "import: ...and the import record (#131)" 0 "" \
|
|
|
|
|
|
import_set "$LLOG" 'user\.box\.imported\.last='
|
|
|
|
|
|
check "import: ...but NO invented mint time (#131)" 1 "" \
|
|
|
|
|
|
import_set "$LLOG" 'user\.box\.created='
|
|
|
|
|
|
check "import: ...and no invented mint version either (#131)" 1 "" \
|
|
|
|
|
|
import_set "$LLOG" 'user\.box\.version='
|
|
|
|
|
|
|
|
|
|
|
|
# --- the read half: 'box info' must not let the mint be misread -------------
|
|
|
|
|
|
# The whole hazard: MINTED carries a time that is deliberately NOT this host's,
|
|
|
|
|
|
# and a reader who meets it alone will take it for one. The IMPORTED line sits
|
|
|
|
|
|
# directly under it and states the only thing box actually knows — the
|
|
|
|
|
|
# ORDERING. It does not claim another host: a box can be exported and
|
|
|
|
|
|
# re-imported onto the SAME host (#66's upgrade advice), and nothing records
|
|
|
|
|
|
# which host minted it.
|
|
|
|
|
|
IMPCFG="$MWORK/imported.cfg"
|
|
|
|
|
|
{ cat "$STAMPED"
|
|
|
|
|
|
echo 'user.box.imported 2026-07-20T09:14:03Z'
|
|
|
|
|
|
echo 'user.box.imported.by 0.8.1'
|
|
|
|
|
|
echo 'user.box.imported.last 2026-07-20T09:14:03Z'
|
|
|
|
|
|
echo 'user.box.imported.last.by 0.8.1'
|
|
|
|
|
|
echo 'user.box.imported.count 1'; } > "$IMPCFG"
|
|
|
|
|
|
check "info: surfaces when the box arrived, and by which box (#131)" \
|
|
|
|
|
|
0 "IMPORTED 2026-07-20T09:14:03Z by box 0.8.1" infobox "$IMPCFG"
|
|
|
|
|
|
check "info: ...and says the mint above is NOT this arrival (#131)" \
|
|
|
|
|
|
0 "the mint above predates it" infobox "$IMPCFG"
|
|
|
|
|
|
check "info: ...while the artifact's mint time still reads unchanged (#131)" \
|
|
|
|
|
|
0 "MINTED 2026-07-19T14:22:07Z by box 0.8.0" infobox "$IMPCFG"
|
|
|
|
|
|
# It must not invent a location for the mint — box has no record of one.
|
|
|
|
|
|
check "info: ...and never claims the mint happened on another host (#131)" 1 "" \
|
|
|
|
|
|
info_has "$IMPCFG" 'another host|elsewhere|remote host'
|
|
|
|
|
|
# A single trip prints ONE line: both pairs hold the same values and a
|
|
|
|
|
|
# 'first was...' continuation would be noise.
|
|
|
|
|
|
check "info: a single import prints no redundant 'first was' line (#131)" 1 "" \
|
|
|
|
|
|
info_has "$IMPCFG" 'the first was'
|
|
|
|
|
|
|
|
|
|
|
|
# A box that made the trip more than once shows both ends and the count.
|
|
|
|
|
|
IMPCFG2="$MWORK/imported-twice.cfg"
|
|
|
|
|
|
{ cat "$STAMPED"
|
|
|
|
|
|
echo 'user.box.imported 2026-06-15T08:00:00Z'
|
|
|
|
|
|
echo 'user.box.imported.by 0.7.0'
|
|
|
|
|
|
echo 'user.box.imported.last 2026-07-20T09:14:03Z'
|
|
|
|
|
|
echo 'user.box.imported.last.by 0.8.1'
|
|
|
|
|
|
echo 'user.box.imported.count 3'; } > "$IMPCFG2"
|
|
|
|
|
|
check "info: a repeat traveller shows the latest trip (#131)" \
|
|
|
|
|
|
0 "IMPORTED 2026-07-20T09:14:03Z by box 0.8.1" infobox "$IMPCFG2"
|
|
|
|
|
|
check "info: ...and the first one, with the count (#131)" \
|
|
|
|
|
|
0 "import 3 — the first was 2026-06-15T08:00:00Z by box 0.7.0" infobox "$IMPCFG2"
|
|
|
|
|
|
|
|
|
|
|
|
# An imported CLONE reads as a clone that also travelled — the two facts sit
|
|
|
|
|
|
# side by side, neither having eaten the other.
|
|
|
|
|
|
IMPCLONE="$MWORK/imported-clone.cfg"
|
fix: the import count guard closes the octal hole, not just the garbage one
All three reviewers landed on the same line. Reproduced on this head under
`set -euo pipefail`:
prev_n=08
[ -n "$prev_n" ] && [ "$prev_n" -eq "$prev_n" ] 2>/dev/null || prev_n=0
# -> guard PASSES: test parses 08 as decimal
n=$((prev_n + 1))
# -> bash: 08: value too great for base (error token is "08")
Two holes that had to close together. `test -eq` reads decimal, arithmetic
reads a leading zero as octal, so a value can pass the guard and still abort
the arithmetic it was guarding. The abort lands after the physical
`incus import` and before the stamp, the placement correction and the start —
the exact window the degrade-never-die contract exists to protect, and the
same side box_provenance()'s schema check falls on.
case "$prev_n" in ''|*[!0-9]*) prev_n=0 ;; esac
n=$((10#$prev_n + 1))
Digits-only closes sign and garbage; 10# forces base ten. Checked across
08 -> 9, 007 -> 8, 5 -> 6, and '' / not-a-number / -3 / 3x / 00 -> 1.
The existing not-a-number fixture could never have caught this: that value
fails the guard and degrades, so it exercises the path that already worked.
Added a count=08 fixture beside it, asserting both halves — the import does
not fail, AND the count advances to 9 rather than degrading to 1, because 08
is a real previous total and reading it as anything else would be its own
small lie. A zero-padded count is not exotic; it is what any external tool
that formats numbers writes.
Verified by mutation: with the old guard restored both new assertions fail.
Also drops user.box.mode.asked from the IMPCLONE fixture (grok's nit). Since
#129 the clone path clears that key, so a fixture built from the mint shape
that kept it described a box the clone path cannot produce. Nothing asserts
it today — which is precisely why it would have rotted unnoticed.
2026-07-21 11:49:32 +00:00
|
|
|
|
# mode.asked is dropped, not merely unasserted: since #129 the clone path
|
|
|
|
|
|
# clears it (nobody asked THIS box anything), so a fixture built from the mint
|
|
|
|
|
|
# shape that kept the key would describe a box the clone path cannot produce.
|
|
|
|
|
|
# No assertion here reads it — which is exactly why it would rot unnoticed.
|
|
|
|
|
|
{ grep -v '^user.box.origin ' "$IMPCFG" | grep -v '^user.box.mode.asked '
|
feat: box import records the trip, without rewriting who the box was
An imported box kept the artifact's mint stamp verbatim (#103) — correct, the
mint time, box version, image and origin belong to the originating host and
should survive the trip. But nothing recorded the import, so an imported box
was indistinguishable from one minted here at the artifact's mint time.
Not origin=import. 'origin' answers how the instance came into BEING — mint or
clone — and overwriting it would make an exported clone come back claiming to
be an import, with nothing left saying it was ever a clone and an origin.from
naming a lineage no key explains. The import is a third fact, orthogonal to
the first two, so it takes its own keys and leaves every other one alone.
Birth pair plus latest pair, the shape heavy-duty/rig#61 settled on for the
same repeated-event question: imported/imported.by pinned once and never
rewritten, imported.last/.last.by refreshed on every arrival, imported.count
for the trips in between. Last-wins alone would erase the evidence of the
earlier trips, which is the same mistake origin=import makes one level up.
box info prints IMPORTED directly under MINTED, because that adjacency is what
stops the artifact's mint time being misread as this host's. It states only
the ordering and never claims another host: box has no record of which host
minted a box, and a re-import onto the same host is the documented upgrade
flow. user.box.schema does not move — adding a key is not breaking — and is
not written by the import at all, so a legacy artifact still reads as
MINTED (not recorded) rather than acquiring a shape it does not have.
Closes #131.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 11:14:45 +00:00
|
|
|
|
echo 'user.box.origin clone'
|
|
|
|
|
|
echo 'user.box.origin.from work/authed'; } > "$IMPCLONE"
|
|
|
|
|
|
check "info: an imported clone is still a clone (#131)" \
|
|
|
|
|
|
0 "ORIGIN clone of work/authed" infobox "$IMPCLONE"
|
|
|
|
|
|
check "info: ...and still says it was imported (#131)" 0 "IMPORTED" infobox "$IMPCLONE"
|
|
|
|
|
|
|
|
|
|
|
|
# A box that was never imported says nothing at all — no empty IMPORTED line,
|
|
|
|
|
|
# the same rule every other key in the block follows.
|
|
|
|
|
|
check "info: a never-imported box prints no IMPORTED line (#131)" 1 "" \
|
|
|
|
|
|
info_has "$STAMPED" '^IMPORTED'
|
|
|
|
|
|
# And a legacy box with no stamp whatsoever still reads as 'not recorded'.
|
|
|
|
|
|
check "info: a stampless box still says the mint was not recorded (#131)" \
|
|
|
|
|
|
0 "predates the mint stamp" infobox "$LEGACY"
|
|
|
|
|
|
check "info: ...and prints no IMPORTED line for a key it does not have (#131)" 1 "" \
|
|
|
|
|
|
info_has "$LEGACY" '^IMPORTED'
|
|
|
|
|
|
|
|
|
|
|
|
check "help import: names the import record it writes (#131)" 0 "import EVENT" \
|
|
|
|
|
|
"$BOX" help import
|
|
|
|
|
|
check "help import: says the mint stamp is NOT rewritten (#131)" 0 "does not overwrite" \
|
|
|
|
|
|
"$BOX" help import
|
|
|
|
|
|
|
|
|
|
|
|
rm -rf "$ISHIM" "$IWORK"
|
|
|
|
|
|
|
feat: a minted box records how it was minted, and box info reads it back
cmd_new knew a great deal at the moment it launched and wrote three user.*
keys, dropping the rest: the box version that minted it, the base image (an
unpinned alias on a moving remote), the rig role, which rig repo and ref
converged it, the mint time, and whether a container was chosen or fallen
back into for want of /dev/kvm. There is no host-side per-box store — the
Incus instance config IS the database — so every one of those facts was gone
the moment the mint returned.
The same single write point now carries them as user.box.*, plus
user.box.schema=1 naming the stamp's shape. The alias's resolved fingerprint
is pinned in a second call after the launch, read from volatile.base_image,
best-effort by construction: a box that exists and boots must never be failed
over a provenance field.
A clone re-stamps rather than inheriting. 'incus copy' carries every user.*
key forward (audit B2), so an inherited stamp would not go stale, it would go
false. --from now re-stamps schema/version/created/origin/origin.from on the
copied instance before it starts, and leaves the lineage keys (template,
user, image, role, rig pin) alone — the clone's disk genuinely came from
them. origin.from records one hop.
cmd_info grows a provenance block, tolerating absence everywhere: boxes
minted before this stamp existed render as a box with blanks, and a schema
this box does not recognise is treated as newer than it, not as broken.
Closes #103.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:12:47 +00:00
|
|
|
|
rm -rf "$MSHIM" "$MWORK"
|
|
|
|
|
|
|
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
|
fix: the fresh-UFW test block no longer flakes on a missing log
It was never a test bug. box-firewall.sh decided the host's entire
firewall stance with `ufw status | grep -q "Status: active"`, and
"Status: active" is the FIRST line ufw prints: grep -q matches it and
exits immediately, closing the pipe while ufw is still writing the rest
of the table, so ufw dies of SIGPIPE. grep returned 0, but under the
script's own `set -o pipefail` the PIPELINE returns 141 (PIPESTATUS =
"141 0") — the if reads false, and a host with UFW plainly active takes
the nft-fallback branch and never builds the DNS carve-out.
`ufw status` is now read once into a variable and matched with [[ ]]:
no reader means no early exit means no race. The stale-rule scan reads
the same snapshot, so the branch decision and the converge loop cannot
disagree.
Separately, test/cli.sh now asserts that each shimmed run logged ufw
mutations at all, before the content greps, and dumps $WFW, the log and
the run's stderr when it did not — so the next occurrence reports its
own cause instead of four content-free grep failures.
Closes #102
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 17:44:54 +00:00
|
|
|
|
local mode="$1" p rc=0; shift
|
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
|
|
|
|
p="$FWSHIM:$SHIMDIR:$PATH"
|
|
|
|
|
|
[ "$mode" = ufw ] && p="$UFWSHIM:$p"
|
fix: the fresh-UFW test block no longer flakes on a missing log
It was never a test bug. box-firewall.sh decided the host's entire
firewall stance with `ufw status | grep -q "Status: active"`, and
"Status: active" is the FIRST line ufw prints: grep -q matches it and
exits immediately, closing the pipe while ufw is still writing the rest
of the table, so ufw dies of SIGPIPE. grep returned 0, but under the
script's own `set -o pipefail` the PIPELINE returns 141 (PIPESTATUS =
"141 0") — the if reads false, and a host with UFW plainly active takes
the nft-fallback branch and never builds the DNS carve-out.
`ufw status` is now read once into a variable and matched with [[ ]]:
no reader means no early exit means no race. The stale-rule scan reads
the same snapshot, so the branch decision and the converge loop cannot
disagree.
Separately, test/cli.sh now asserts that each shimmed run logged ufw
mutations at all, before the content greps, and dumps $WFW, the log and
the run's stderr when it did not — so the next occurrence reports its
own cause instead of four content-free grep failures.
Closes #102
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 17:44:54 +00:00
|
|
|
|
# Stderr is captured to a file AND re-emitted, rather than only passed
|
|
|
|
|
|
# through. The driving `check` swallows the output of a run that passes, so
|
|
|
|
|
|
# when a later grep over the log fails there is nothing left to read — which
|
|
|
|
|
|
# is precisely the hole #102 fell into. Keeping a copy on disk lets
|
|
|
|
|
|
# fwlog_ready below show what the run actually said. Overwritten per call by
|
|
|
|
|
|
# design: every fwlog_ready sits immediately after its own runfw, so "the
|
|
|
|
|
|
# last run" is always the run being diagnosed.
|
|
|
|
|
|
env PATH="$p" "$@" bash "$ROOT/host/box-firewall.sh" 2>"$WFW/last-run.err" || rc=$?
|
|
|
|
|
|
cat "$WFW/last-run.err" >&2
|
|
|
|
|
|
return "$rc"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
# fwlog_ready <log> — the shimmed ufw actually logged mutations to <log>.
|
|
|
|
|
|
#
|
|
|
|
|
|
# Why this exists (#102): every grep in the blocks below reads a log written by
|
|
|
|
|
|
# the shimmed ufw during the driving `runfw` check. When something stops the
|
|
|
|
|
|
# UFW branch of box-firewall.sh from running at all, that log is missing — or,
|
|
|
|
|
|
# as it turned out, present but holding nothing except the `ufw status` probe.
|
|
|
|
|
|
# The greps then fail four-at-a-time with empty output: a signature that looks
|
|
|
|
|
|
# alarmingly specific and carries no information whatsoever. #102 was filed
|
|
|
|
|
|
# reading it as "the log is not written", which was a reasonable inference from
|
|
|
|
|
|
# four blank failures and was also wrong; the file was there, the mutations
|
|
|
|
|
|
# were not, and that distinction is the entire diagnosis. So assert the
|
|
|
|
|
|
# precondition explicitly, before the content greps, and on failure print what
|
|
|
|
|
|
# IS in $WFW, what the log itself holds, and what the run wrote to stderr. The
|
|
|
|
|
|
# fix below should mean this never fires — it is here for the next cause, not
|
|
|
|
|
|
# this one, and its whole job is to hand over the evidence instead of making
|
|
|
|
|
|
# the next person re-derive it from a re-run loop.
|
|
|
|
|
|
fwlog_ready() {
|
|
|
|
|
|
local log="$1" muts
|
|
|
|
|
|
if [ -f "$log" ]; then
|
|
|
|
|
|
muts="$(grep -vc "^ufw status" "$log")"
|
|
|
|
|
|
[ "$muts" -gt 0 ] && return 0
|
|
|
|
|
|
echo "DIAGNOSIS: $log exists but logs no ufw MUTATION (only 'ufw status')."
|
|
|
|
|
|
echo " => box-firewall.sh took its no-UFW branch; the UFW carve-out never ran."
|
|
|
|
|
|
else
|
|
|
|
|
|
echo "DIAGNOSIS: $log does not exist — the shimmed ufw was never invoked."
|
|
|
|
|
|
fi
|
|
|
|
|
|
echo " \$WFW ($WFW) holds:"
|
|
|
|
|
|
# shellcheck disable=SC2012 # a human-read diagnostic dump, not parsed: `ls -la`
|
|
|
|
|
|
# shows sizes and mtimes, which is the whole point here (a zero-byte log and a
|
|
|
|
|
|
# log that was never created are different failures). $WFW is our own mktemp -d.
|
|
|
|
|
|
ls -la "$WFW" 2>&1 | sed 's/^/ /'
|
|
|
|
|
|
echo " contents of $(basename "$log"):"
|
|
|
|
|
|
{ [ -f "$log" ] && cat "$log" || echo "(absent)"; } 2>&1 | sed 's/^/ /'
|
|
|
|
|
|
echo " stderr of the run that should have written it:"
|
|
|
|
|
|
{ [ -s "$WFW/last-run.err" ] && cat "$WFW/last-run.err" || echo "(empty)"; } 2>&1 | sed 's/^/ /'
|
|
|
|
|
|
return 1
|
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
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
# 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"
|
fix: the fresh-UFW test block no longer flakes on a missing log
It was never a test bug. box-firewall.sh decided the host's entire
firewall stance with `ufw status | grep -q "Status: active"`, and
"Status: active" is the FIRST line ufw prints: grep -q matches it and
exits immediately, closing the pipe while ufw is still writing the rest
of the table, so ufw dies of SIGPIPE. grep returned 0, but under the
script's own `set -o pipefail` the PIPELINE returns 141 (PIPESTATUS =
"141 0") — the if reads false, and a host with UFW plainly active takes
the nft-fallback branch and never builds the DNS carve-out.
`ufw status` is now read once into a variable and matched with [[ ]]:
no reader means no early exit means no race. The stale-rule scan reads
the same snapshot, so the branch decision and the converge loop cannot
disagree.
Separately, test/cli.sh now asserts that each shimmed run logged ufw
mutations at all, before the content greps, and dumps $WFW, the log and
the run's stderr when it did not — so the next occurrence reports its
own cause instead of four content-free grep failures.
Closes #102
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 17:44:54 +00:00
|
|
|
|
check "box-firewall: ...the run logged ufw mutations at all" 0 "" fwlog_ready "$WFW/remap.log"
|
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
|
|
|
|
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"
|
fix: the fresh-UFW test block no longer flakes on a missing log
It was never a test bug. box-firewall.sh decided the host's entire
firewall stance with `ufw status | grep -q "Status: active"`, and
"Status: active" is the FIRST line ufw prints: grep -q matches it and
exits immediately, closing the pipe while ufw is still writing the rest
of the table, so ufw dies of SIGPIPE. grep returned 0, but under the
script's own `set -o pipefail` the PIPELINE returns 141 (PIPESTATUS =
"141 0") — the if reads false, and a host with UFW plainly active takes
the nft-fallback branch and never builds the DNS carve-out.
`ufw status` is now read once into a variable and matched with [[ ]]:
no reader means no early exit means no race. The stale-rule scan reads
the same snapshot, so the branch decision and the converge loop cannot
disagree.
Separately, test/cli.sh now asserts that each shimmed run logged ufw
mutations at all, before the content greps, and dumps $WFW, the log and
the run's stderr when it did not — so the next occurrence reports its
own cause instead of four content-free grep failures.
Closes #102
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 17:44:54 +00:00
|
|
|
|
# This one matters more than it looks: "no delete was issued" is an ASSERT-ABSENT
|
|
|
|
|
|
# check, so a run that issued nothing at all passes it for the wrong reason.
|
|
|
|
|
|
# fwlog_ready is what keeps the absence meaningful.
|
|
|
|
|
|
check "box-firewall: ...the run logged ufw mutations at all" 0 "" fwlog_ready "$WFW/agree.log"
|
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
|
|
|
|
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"
|
fix: the fresh-UFW test block no longer flakes on a missing log
It was never a test bug. box-firewall.sh decided the host's entire
firewall stance with `ufw status | grep -q "Status: active"`, and
"Status: active" is the FIRST line ufw prints: grep -q matches it and
exits immediately, closing the pipe while ufw is still writing the rest
of the table, so ufw dies of SIGPIPE. grep returned 0, but under the
script's own `set -o pipefail` the PIPELINE returns 141 (PIPESTATUS =
"141 0") — the if reads false, and a host with UFW plainly active takes
the nft-fallback branch and never builds the DNS carve-out.
`ufw status` is now read once into a variable and matched with [[ ]]:
no reader means no early exit means no race. The stale-rule scan reads
the same snapshot, so the branch decision and the converge loop cannot
disagree.
Separately, test/cli.sh now asserts that each shimmed run logged ufw
mutations at all, before the content greps, and dumps $WFW, the log and
the run's stderr when it did not — so the next occurrence reports its
own cause instead of four content-free grep failures.
Closes #102
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 17:44:54 +00:00
|
|
|
|
check "box-firewall: ...the run logged ufw mutations at all" 0 "" fwlog_ready "$WFW/fresh.log"
|
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
|
|
|
|
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.
|
fix: run setup-host over a migrated flat tree, and name what the migration left
had_install was computed AFTER the pre-0.7.0 migration block, so it observed
a versions/ directory that the migration had just created one line earlier. A
flat /opt/box therefore read as "already installed", host/setup-host.sh was
skipped, and the host kept every artifact the old release left behind while
box --version reported the new one — silent, and self-concealing. Computing
it BEFORE the migration asks the honest question: a tree that needs migrating
has by definition never been converged by this version's setup-host.
The accepted consequence: an unattended (BOX_YES=1) upgrade on a flat-tree
host now runs setup-host, which the #66 note cautions about. setup-host
converges and is idempotent, and a release whose host half is silently
missing is the worse failure.
Once setup-host can run at all, a second defect in the same block becomes
reachable: it went through $DEST/current, but the #66 guard holds the default
under existing boxes, so on such a host current still names the OLD version —
converging the host with the previous release's host scripts. It now runs the
installed version's own tree.
Separately, the migration named itself but not the lifecycle: the old tree
becomes a first-class 'box versions' entry the operator never installed and
cannot tell apart from a deliberate rollback target. The migration line now
names both ways out, and the closing summary re-states it, since the original
line scrolls past ~250 lines before the install ends.
test/cli.sh gains an inst_setup helper (no BOX_SKIP_SETUP_HOST) and a stub
host/setup-host.sh on the fabricated upgrade source, so the flat-tree fixture
proves end to end and fully offline that host setup runs — and that a
genuinely versioned tree still skips it.
Refs #115, #117
2026-07-19 23:36:48 +00:00
|
|
|
|
SRC9="$WORK/src-9.9.9"; mkdir -p "$SRC9/bin" "$SRC9/host"
|
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
|
|
|
|
cp "$ROOT/bin/box" "$SRC9/bin/box"; chmod +x "$SRC9/bin/box"
|
|
|
|
|
|
echo "9.9.9-drill" > "$SRC9/VERSION"
|
fix: run setup-host over a migrated flat tree, and name what the migration left
had_install was computed AFTER the pre-0.7.0 migration block, so it observed
a versions/ directory that the migration had just created one line earlier. A
flat /opt/box therefore read as "already installed", host/setup-host.sh was
skipped, and the host kept every artifact the old release left behind while
box --version reported the new one — silent, and self-concealing. Computing
it BEFORE the migration asks the honest question: a tree that needs migrating
has by definition never been converged by this version's setup-host.
The accepted consequence: an unattended (BOX_YES=1) upgrade on a flat-tree
host now runs setup-host, which the #66 note cautions about. setup-host
converges and is idempotent, and a release whose host half is silently
missing is the worse failure.
Once setup-host can run at all, a second defect in the same block becomes
reachable: it went through $DEST/current, but the #66 guard holds the default
under existing boxes, so on such a host current still names the OLD version —
converging the host with the previous release's host scripts. It now runs the
installed version's own tree.
Separately, the migration named itself but not the lifecycle: the old tree
becomes a first-class 'box versions' entry the operator never installed and
cannot tell apart from a deliberate rollback target. The migration line now
names both ways out, and the closing summary re-states it, since the original
line scrolls past ~250 lines before the install ends.
test/cli.sh gains an inst_setup helper (no BOX_SKIP_SETUP_HOST) and a stub
host/setup-host.sh on the fabricated upgrade source, so the flat-tree fixture
proves end to end and fully offline that host setup runs — and that a
genuinely versioned tree still skips it.
Refs #115, #117
2026-07-19 23:36:48 +00:00
|
|
|
|
# A stub host/setup-host.sh that only announces itself: enough to prove WHETHER
|
|
|
|
|
|
# the installer ran host setup, and from WHICH version's tree, with no Incus and
|
|
|
|
|
|
# no root. The real script builds the isolation stack; this one echoes (#115).
|
|
|
|
|
|
cat > "$SRC9/host/setup-host.sh" <<'STUB'
|
|
|
|
|
|
#!/usr/bin/env bash
|
|
|
|
|
|
echo "SETUP-HOST-RAN-FROM 9.9.9-drill"
|
|
|
|
|
|
STUB
|
|
|
|
|
|
chmod +x "$SRC9/host/setup-host.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
|
|
|
|
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"
|
|
|
|
|
|
}
|
fix: run setup-host over a migrated flat tree, and name what the migration left
had_install was computed AFTER the pre-0.7.0 migration block, so it observed
a versions/ directory that the migration had just created one line earlier. A
flat /opt/box therefore read as "already installed", host/setup-host.sh was
skipped, and the host kept every artifact the old release left behind while
box --version reported the new one — silent, and self-concealing. Computing
it BEFORE the migration asks the honest question: a tree that needs migrating
has by definition never been converged by this version's setup-host.
The accepted consequence: an unattended (BOX_YES=1) upgrade on a flat-tree
host now runs setup-host, which the #66 note cautions about. setup-host
converges and is idempotent, and a release whose host half is silently
missing is the worse failure.
Once setup-host can run at all, a second defect in the same block becomes
reachable: it went through $DEST/current, but the #66 guard holds the default
under existing boxes, so on such a host current still names the OLD version —
converging the host with the previous release's host scripts. It now runs the
installed version's own tree.
Separately, the migration named itself but not the lifecycle: the old tree
becomes a first-class 'box versions' entry the operator never installed and
cannot tell apart from a deliberate rollback target. The migration line now
names both ways out, and the closing summary re-states it, since the original
line scrolls past ~250 lines before the install ends.
test/cli.sh gains an inst_setup helper (no BOX_SKIP_SETUP_HOST) and a stub
host/setup-host.sh on the fabricated upgrade source, so the flat-tree fixture
proves end to end and fully offline that host setup runs — and that a
genuinely versioned tree still skips it.
Refs #115, #117
2026-07-19 23:36:48 +00:00
|
|
|
|
inst_setup() { # like inst, but WITHOUT BOX_SKIP_SETUP_HOST — host setup is the
|
|
|
|
|
|
# thing under test, so the switch that suppresses it has to come off. Safe
|
|
|
|
|
|
# offline: the only setup-host on these fabricated sources is the echo stub
|
|
|
|
|
|
# above, and BOX_YES=1 answers its prompt.
|
|
|
|
|
|
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_INSTALL_SOURCE="$ROOT" "$@" bash "$ROOT/install.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
|
|
|
|
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
|
|
|
|
|
|
|
fix: run setup-host over a migrated flat tree, and name what the migration left
had_install was computed AFTER the pre-0.7.0 migration block, so it observed
a versions/ directory that the migration had just created one line earlier. A
flat /opt/box therefore read as "already installed", host/setup-host.sh was
skipped, and the host kept every artifact the old release left behind while
box --version reported the new one — silent, and self-concealing. Computing
it BEFORE the migration asks the honest question: a tree that needs migrating
has by definition never been converged by this version's setup-host.
The accepted consequence: an unattended (BOX_YES=1) upgrade on a flat-tree
host now runs setup-host, which the #66 note cautions about. setup-host
converges and is idempotent, and a release whose host half is silently
missing is the worse failure.
Once setup-host can run at all, a second defect in the same block becomes
reachable: it went through $DEST/current, but the #66 guard holds the default
under existing boxes, so on such a host current still names the OLD version —
converging the host with the previous release's host scripts. It now runs the
installed version's own tree.
Separately, the migration named itself but not the lifecycle: the old tree
becomes a first-class 'box versions' entry the operator never installed and
cannot tell apart from a deliberate rollback target. The migration line now
names both ways out, and the closing summary re-states it, since the original
line scrolls past ~250 lines before the install ends.
test/cli.sh gains an inst_setup helper (no BOX_SKIP_SETUP_HOST) and a stub
host/setup-host.sh on the fabricated upgrade source, so the flat-tree fixture
proves end to end and fully offline that host setup runs — and that a
genuinely versioned tree still skips it.
Refs #115, #117
2026-07-19 23:36:48 +00:00
|
|
|
|
# #117: the migration is not silent about the entry it manufactured. The old
|
|
|
|
|
|
# tree is now a first-class 'box versions' row the operator never installed —
|
|
|
|
|
|
# so the output has to name the way back out (uninstall) and the reason to
|
|
|
|
|
|
# keep it (rollback), at the migration AND again in the closing summary, which
|
|
|
|
|
|
# is the half an operator scrolling ~250 lines of install output actually sees.
|
|
|
|
|
|
H3B="$WORK/h3b"; B3B="$WORK/b3b"; mkdir -p "$H3B/bin" "$B3B"
|
|
|
|
|
|
cp "$ROOT/bin/box" "$H3B/bin/box"; chmod +x "$H3B/bin/box"
|
|
|
|
|
|
cp "$ROOT/VERSION" "$H3B/VERSION"
|
|
|
|
|
|
ln -s "$H3B/bin/box" "$B3B/box"
|
|
|
|
|
|
mig_out="$WORK/mig-out.txt"
|
|
|
|
|
|
inst "$H3B" "$B3B" BOX_INSTALL_SOURCE="$SRC9" >"$mig_out" 2>&1 || true
|
|
|
|
|
|
check "migrate: the output points at the reap command (#117)" 0 "box uninstall $VER" \
|
|
|
|
|
|
cat "$mig_out"
|
|
|
|
|
|
check "migrate: ...and names keeping it as a rollback target (#117)" 0 "keep it to roll back" \
|
|
|
|
|
|
cat "$mig_out"
|
|
|
|
|
|
check "migrate: ...and the closing summary re-states it (#117)" 0 "was migrated to versions/$VER" \
|
|
|
|
|
|
cat "$mig_out"
|
|
|
|
|
|
# ...and the note is conditional: an install with nothing to migrate must not
|
|
|
|
|
|
# mention a migration at all. grep exits 1 when the string is absent, which is
|
|
|
|
|
|
# the pass here.
|
|
|
|
|
|
nomig_out="$WORK/nomig-out.txt"
|
|
|
|
|
|
H3C="$WORK/h3c"; B3C="$WORK/b3c"
|
|
|
|
|
|
inst "$H3C" "$B3C" >"$nomig_out" 2>&1 || true
|
|
|
|
|
|
check "migrate: a NON-migrating install stays silent about migration (#117)" 1 "" \
|
|
|
|
|
|
grep -qF "was migrated to versions/" "$nomig_out"
|
|
|
|
|
|
|
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
|
|
|
|
# ...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: run setup-host over a migrated flat tree, and name what the migration left
had_install was computed AFTER the pre-0.7.0 migration block, so it observed
a versions/ directory that the migration had just created one line earlier. A
flat /opt/box therefore read as "already installed", host/setup-host.sh was
skipped, and the host kept every artifact the old release left behind while
box --version reported the new one — silent, and self-concealing. Computing
it BEFORE the migration asks the honest question: a tree that needs migrating
has by definition never been converged by this version's setup-host.
The accepted consequence: an unattended (BOX_YES=1) upgrade on a flat-tree
host now runs setup-host, which the #66 note cautions about. setup-host
converges and is idempotent, and a release whose host half is silently
missing is the worse failure.
Once setup-host can run at all, a second defect in the same block becomes
reachable: it went through $DEST/current, but the #66 guard holds the default
under existing boxes, so on such a host current still names the OLD version —
converging the host with the previous release's host scripts. It now runs the
installed version's own tree.
Separately, the migration named itself but not the lifecycle: the old tree
becomes a first-class 'box versions' entry the operator never installed and
cannot tell apart from a deliberate rollback target. The migration line now
names both ways out, and the closing summary re-states it, since the original
line scrolls past ~250 lines before the install ends.
test/cli.sh gains an inst_setup helper (no BOX_SKIP_SETUP_HOST) and a stub
host/setup-host.sh on the fabricated upgrade source, so the flat-tree fixture
proves end to end and fully offline that host setup runs — and that a
genuinely versioned tree still skips it.
Refs #115, #117
2026-07-19 23:36:48 +00:00
|
|
|
|
# #115, end to end and fully offline: a flat pre-0.7.0 tree must still count as
|
|
|
|
|
|
# "no install yet" and RUN host setup. The migration converts the flat tree into
|
|
|
|
|
|
# versions/<v>, which is precisely what used to make had_install read 1 — the
|
|
|
|
|
|
# host then skipped setup-host while 'box --version' reported the new release,
|
|
|
|
|
|
# leaving every host-side artifact (box-firewall, #102) at the old one. The stub
|
|
|
|
|
|
# setup-host echoes a marker, so the marker IS the proof it ran.
|
|
|
|
|
|
H4B="$WORK/h4b"; B4B="$WORK/b4b"; mkdir -p "$H4B/bin" "$B4B"
|
|
|
|
|
|
cp "$ROOT/bin/box" "$H4B/bin/box"; chmod +x "$H4B/bin/box"
|
|
|
|
|
|
cp "$ROOT/VERSION" "$H4B/VERSION"
|
|
|
|
|
|
ln -s "$H4B/bin/box" "$B4B/box"
|
|
|
|
|
|
check "flat upgrade: host setup RUNS over a migrated flat tree (#115)" 0 "SETUP-HOST-RAN-FROM 9.9.9-drill" \
|
|
|
|
|
|
inst_setup "$H4B" "$B4B" BOX_INSTALL_SOURCE="$SRC9"
|
|
|
|
|
|
|
|
|
|
|
|
# The converse, so the gate is proven to still GATE: H4B is now a genuinely
|
|
|
|
|
|
# versioned tree, which HAS already made the host-setup decision — a re-run must
|
|
|
|
|
|
# not redo it. Without this, "fix" and "run setup-host unconditionally" would be
|
|
|
|
|
|
# indistinguishable.
|
|
|
|
|
|
vers_out="$WORK/versioned-upgrade-out.txt"
|
|
|
|
|
|
inst_setup "$H4B" "$B4B" BOX_INSTALL_SOURCE="$SRC8" >"$vers_out" 2>&1 || true
|
|
|
|
|
|
check "versioned upgrade: an existing versioned install still SKIPS host setup" 0 "already had a box install" \
|
|
|
|
|
|
cat "$vers_out"
|
|
|
|
|
|
check "versioned upgrade: ...and the stub did NOT run" 1 "" \
|
|
|
|
|
|
grep -qF "SETUP-HOST-RAN-FROM" "$vers_out"
|
|
|
|
|
|
|
|
|
|
|
|
# 'current' does not always flip: the #66 guard holds the default under existing
|
|
|
|
|
|
# boxes. Host setup must still come from the version just installed, or the
|
|
|
|
|
|
# upgrade converges the host with the OLD release's host scripts — reinstating
|
|
|
|
|
|
# the very staleness #115 is about. The flat fixture carries no host/ dir at all,
|
|
|
|
|
|
# so going through 'current' could not even find a script to run.
|
|
|
|
|
|
H10="$WORK/h10"; B10="$WORK/b10"; mkdir -p "$H10/bin" "$B10"
|
|
|
|
|
|
cp "$ROOT/bin/box" "$H10/bin/box"; chmod +x "$H10/bin/box"
|
|
|
|
|
|
cp "$ROOT/VERSION" "$H10/VERSION"
|
|
|
|
|
|
ln -s "$H10/bin/box" "$B10/box"
|
|
|
|
|
|
check "flat upgrade under boxes: setup-host runs the NEW version's script" 0 "SETUP-HOST-RAN-FROM 9.9.9-drill" \
|
|
|
|
|
|
inst_setup "$H10" "$B10" BOX_INSTALL_SOURCE="$SRC9" FAKE_BOXES=work
|
|
|
|
|
|
check "flat upgrade under boxes: ...while the default correctly stayed put (#66)" 0 "box $VER" \
|
|
|
|
|
|
ibox "$B10/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"
|
fix: teardown-host refuses a terminal-less run instead of aborting mute
host/teardown-host.sh had no `[ -t 0 ]` check before its confirmation
prompt. Without --yes/BOX_YES and without a terminal — CI, a pipe, a
nohup — it fell into `read`, took the instant EOF and exited 1 saying
only "aborted": a refusal naming neither the cause nor the override, in
the most destructive script in the tree.
It now refuses with the override named, exit 2 — "you invoked this
wrong", matching host/revoke-user.sh --purge and install.sh's confirm(),
versus 1 for "you were asked and you said no".
The gate sits below the --yes/BOX_YES arm, so consent given
non-interactively still runs headless, and above the first incus call,
so the refusal needs no daemon — which is what lets test/cli.sh drive it
for real instead of grepping for it.
Refs #113
2026-07-19 23:36:32 +00:00
|
|
|
|
# ...and the other side of that contract (#113): consent NOT given and no
|
|
|
|
|
|
# terminal to ask on is a usage error, not a mute 'aborted'. Driven for real —
|
|
|
|
|
|
# the gate sits above the first 'incus' call, so a daemon-free run reaches it.
|
|
|
|
|
|
check "teardown-host: refuses without a TTY and names the override (#113)" 2 \
|
2026-07-19 23:57:19 +00:00
|
|
|
|
"--yes (or BOX_YES=1) means yes" env -u BOX_YES bash "$ROOT/host/teardown-host.sh" </dev/null
|
2026-07-19 18:03:46 +00:00
|
|
|
|
|
fix: drill/wipe.sh reads ufw into a capture, not into an early-exit reader
wipe.sh piped `ufw status` straight into `grep -q "Status: active"`. That is
closes the pipe, ufw takes SIGPIPE, and the pipeline yields 141.
It was correct today, and only by accident — the file is `set -u` with no
`pipefail`, so the 141 was discarded and grep's 0 carried the branch. It was
one line from wrong: adding `set -o pipefail` for unrelated robustness would
have silently skipped every UFW removal on a host the operator was told is
wiped, with no error and no red X. Measured on a shim: 5/5 runs took the
wrong branch under pipefail, 3/3 the right one without.
Transplant #106's pattern verbatim from host/teardown-host.sh: capture
ufw_status once and match with `[[ ]]`; rewrite the numbered-delete loop —
whose condition was itself an early-exit reader, plus an un-captured re-read
to get the number — as a `while :` that reads one capture per iteration and
breaks on absence. The re-scan stays per-delete, since numbers shift after
each removal; it just no longer races. Removals keep the file's
`cmd && say "did X"` idiom.
Generalize the test/cli.sh pin from the one site to the class: sweep every
host/*.sh and drill/*.sh for the racing shape and name the offenders, so a
new script in either directory inherits the pin instead of being one more
site to remember. Comment lines are stripped before matching — each fix's
own commentary quotes the racing shape to explain it, and a prose-blind pin
would fail on the comment documenting why it exists. The positive pins (the
capture, the break-on-absence) now run per file over both, so the sweep
cannot be satisfied by deleting a block instead of fixing it.
drill/doctor.sh was checked and needs nothing: it already reads into
`ufw_out` and is safe by construction, not by absent pipefail.
Refs #107
2026-07-19 23:37:03 +00:00
|
|
|
|
# #102's race, pinned as a CLASS rather than at the one site that had it
|
|
|
|
|
|
# (#107). A daemon-free run cannot exercise a UFW teardown, so the shape is
|
fix: the racing-reader sweep guards the class, and revoke-user captures the trust store
The sweep added in #107 matched `ufw status[^|]*\| *grep` — every historical
instance, and none of the equivalent spellings. `head -n1`, `sed -n '1p;q'`,
`awk '/x/ {print; exit}'` and `read` all close the pipe early, SIGPIPE the
writer, and yield the same 141 under pipefail. The pin guarded the instance
spelling of the very thing it existed to generalise.
Both halves of the matcher are alternations now.
Readers are deliberately not narrowed to the early-exit spellings: telling
`grep -q` from `grep -c` by regex is precision that rots, and all six
`ufw status` sites in the tree already capture first, so banning the pipe
outright costs nothing real.
Writers gain `incus config trust list`. host/revoke-user.sh:206 piped it into
`grep -q` as the --purge leftover assert, under `set -euo pipefail` — so
unlike drill/wipe.sh nothing but the writer's size was holding it, and left of
`&&` a 141 is set -e-exempt too. It would have read as "no leftover cert" on a
host that still trusts the revoked user's certificate and called the purge
complete: fail-open, on the path whose job is to prove access is gone. Now
captured into `trust_csv` and matched with `[[ ]]`, with a leading newline so
the first CSV row anchors like the `^` it replaces.
Writers are enumerated rather than generalised — ~150 legitimate `| grep`
sites exist under host/ and drill/, nearly all re-reading a captured string —
so the sweep claims only what it can check. The `id -nG | tr | grep -qx`
shapes in grant/revoke/setup are left alone: single tiny writes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:02:09 +00:00
|
|
|
|
# pinned instead: nowhere under host/ or drill/ may a known multi-line writer
|
|
|
|
|
|
# be piped into a line reader. `Status: active` is ufw's FIRST line, so the
|
|
|
|
|
|
# reader matches, closes the pipe, ufw takes SIGPIPE, and the pipeline
|
fix: drill/wipe.sh reads ufw into a capture, not into an early-exit reader
wipe.sh piped `ufw status` straight into `grep -q "Status: active"`. That is
closes the pipe, ufw takes SIGPIPE, and the pipeline yields 141.
It was correct today, and only by accident — the file is `set -u` with no
`pipefail`, so the 141 was discarded and grep's 0 carried the branch. It was
one line from wrong: adding `set -o pipefail` for unrelated robustness would
have silently skipped every UFW removal on a host the operator was told is
wiped, with no error and no red X. Measured on a shim: 5/5 runs took the
wrong branch under pipefail, 3/3 the right one without.
Transplant #106's pattern verbatim from host/teardown-host.sh: capture
ufw_status once and match with `[[ ]]`; rewrite the numbered-delete loop —
whose condition was itself an early-exit reader, plus an un-captured re-read
to get the number — as a `while :` that reads one capture per iteration and
breaks on absence. The re-scan stays per-delete, since numbers shift after
each removal; it just no longer races. Removals keep the file's
`cmd && say "did X"` idiom.
Generalize the test/cli.sh pin from the one site to the class: sweep every
host/*.sh and drill/*.sh for the racing shape and name the offenders, so a
new script in either directory inherits the pin instead of being one more
site to remember. Comment lines are stripped before matching — each fix's
own commentary quotes the racing shape to explain it, and a prose-blind pin
would fail on the comment documenting why it exists. The positive pins (the
capture, the break-on-absence) now run per file over both, so the sweep
cannot be satisfied by deleting a block instead of fixing it.
drill/doctor.sh was checked and needs nothing: it already reads into
`ufw_out` and is safe by construction, not by absent pipefail.
Refs #107
2026-07-19 23:37:03 +00:00
|
|
|
|
# yields 141 — under pipefail the branch silently reads false and the whole
|
|
|
|
|
|
# firewall block is skipped on a host the operator was told is clean.
|
|
|
|
|
|
#
|
|
|
|
|
|
# Swept, not per-file, because absence of pipefail is what made drill/wipe.sh
|
|
|
|
|
|
# survive the same shape: a file is only ever one `set -o pipefail` — the kind
|
|
|
|
|
|
# of robustness tweak that sails through review — from being #102 again. The
|
|
|
|
|
|
# sweep closes the class, so a new host/ or drill/ script inherits the pin for
|
|
|
|
|
|
# free instead of being one more site someone has to remember.
|
|
|
|
|
|
# Comment lines are stripped before matching: each fix's own commentary quotes
|
2026-07-19 18:03:46 +00:00
|
|
|
|
# the racing shape to explain it, and a pin that cannot tell prose from code
|
|
|
|
|
|
# would fail on the very comment documenting why it exists.
|
fix: the racing-reader sweep guards the class, and revoke-user captures the trust store
The sweep added in #107 matched `ufw status[^|]*\| *grep` — every historical
instance, and none of the equivalent spellings. `head -n1`, `sed -n '1p;q'`,
`awk '/x/ {print; exit}'` and `read` all close the pipe early, SIGPIPE the
writer, and yield the same 141 under pipefail. The pin guarded the instance
spelling of the very thing it existed to generalise.
Both halves of the matcher are alternations now.
Readers are deliberately not narrowed to the early-exit spellings: telling
`grep -q` from `grep -c` by regex is precision that rots, and all six
`ufw status` sites in the tree already capture first, so banning the pipe
outright costs nothing real.
Writers gain `incus config trust list`. host/revoke-user.sh:206 piped it into
`grep -q` as the --purge leftover assert, under `set -euo pipefail` — so
unlike drill/wipe.sh nothing but the writer's size was holding it, and left of
`&&` a 141 is set -e-exempt too. It would have read as "no leftover cert" on a
host that still trusts the revoked user's certificate and called the purge
complete: fail-open, on the path whose job is to prove access is gone. Now
captured into `trust_csv` and matched with `[[ ]]`, with a leading newline so
the first CSV row anchors like the `^` it replaces.
Writers are enumerated rather than generalised — ~150 legitimate `| grep`
sites exist under host/ and drill/, nearly all re-reading a captured string —
so the sweep claims only what it can check. The `id -nG | tr | grep -qx`
shapes in grant/revoke/setup are left alone: single tiny writes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:02:09 +00:00
|
|
|
|
#
|
|
|
|
|
|
# BOTH halves of the matcher are alternations, and both were widened in #124:
|
|
|
|
|
|
#
|
|
|
|
|
|
# · READERS. Pinning `| grep` guarded the instance spelling, not the class.
|
|
|
|
|
|
# `head -n1`, `sed -n '1p;q'` and `awk '/x/ {print; exit}'` all close the
|
|
|
|
|
|
# pipe early and produce the identical wrong answer under pipefail. The
|
|
|
|
|
|
# alternation is deliberately NOT restricted to the early-exit spellings
|
|
|
|
|
|
# (`grep -q` but not `grep -c`, `sed …q` but not `sed s///`): telling
|
|
|
|
|
|
# those apart by regex is exactly the kind of precision that rots, and
|
|
|
|
|
|
# the house idiom is to capture first anyway — all six `ufw status` sites
|
|
|
|
|
|
# in the tree already do. Banning the pipe outright costs nothing real
|
|
|
|
|
|
# and cannot be defeated by a spelling nobody enumerated.
|
|
|
|
|
|
#
|
|
|
|
|
|
# · WRITERS. Enumerated, not generalised. `incus config trust list` joins
|
|
|
|
|
|
# `ufw status` because host/revoke-user.sh used it as a leftover-detection
|
|
|
|
|
|
# condition under `set -euo pipefail` (#124). A generic "no multi-line
|
|
|
|
|
|
# writer feeds a reader" matcher is unwritable here: ~150 legitimate
|
|
|
|
|
|
# `| grep` sites exist across host/ and drill/, nearly all reading an
|
|
|
|
|
|
# already-captured string back out of `printf '%s\n' "$var"`. So the
|
|
|
|
|
|
# sweep claims exactly what it can check — THESE writers are never piped
|
|
|
|
|
|
# — and grows one named writer at a time.
|
2026-07-19 18:03:46 +00:00
|
|
|
|
# shellcheck disable=SC2016 # "$1" is the subshell's positional, passed below
|
fix: the racing-reader sweep guards the class, and revoke-user captures the trust store
The sweep added in #107 matched `ufw status[^|]*\| *grep` — every historical
instance, and none of the equivalent spellings. `head -n1`, `sed -n '1p;q'`,
`awk '/x/ {print; exit}'` and `read` all close the pipe early, SIGPIPE the
writer, and yield the same 141 under pipefail. The pin guarded the instance
spelling of the very thing it existed to generalise.
Both halves of the matcher are alternations now.
Readers are deliberately not narrowed to the early-exit spellings: telling
`grep -q` from `grep -c` by regex is precision that rots, and all six
`ufw status` sites in the tree already capture first, so banning the pipe
outright costs nothing real.
Writers gain `incus config trust list`. host/revoke-user.sh:206 piped it into
`grep -q` as the --purge leftover assert, under `set -euo pipefail` — so
unlike drill/wipe.sh nothing but the writer's size was holding it, and left of
`&&` a 141 is set -e-exempt too. It would have read as "no leftover cert" on a
host that still trusts the revoked user's certificate and called the purge
complete: fail-open, on the path whose job is to prove access is gone. Now
captured into `trust_csv` and matched with `[[ ]]`, with a leading newline so
the first CSV row anchors like the `^` it replaces.
Writers are enumerated rather than generalised — ~150 legitimate `| grep`
sites exist under host/ and drill/, nearly all re-reading a captured string —
so the sweep claims only what it can check. The `id -nG | tr | grep -qx`
shapes in grant/revoke/setup are left alone: single tiny writes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:02:09 +00:00
|
|
|
|
check "no multi-line writer is piped into a line reader under host/ or drill/" 0 "" \
|
fix: drill/wipe.sh reads ufw into a capture, not into an early-exit reader
wipe.sh piped `ufw status` straight into `grep -q "Status: active"`. That is
closes the pipe, ufw takes SIGPIPE, and the pipeline yields 141.
It was correct today, and only by accident — the file is `set -u` with no
`pipefail`, so the 141 was discarded and grep's 0 carried the branch. It was
one line from wrong: adding `set -o pipefail` for unrelated robustness would
have silently skipped every UFW removal on a host the operator was told is
wiped, with no error and no red X. Measured on a shim: 5/5 runs took the
wrong branch under pipefail, 3/3 the right one without.
Transplant #106's pattern verbatim from host/teardown-host.sh: capture
ufw_status once and match with `[[ ]]`; rewrite the numbered-delete loop —
whose condition was itself an early-exit reader, plus an un-captured re-read
to get the number — as a `while :` that reads one capture per iteration and
breaks on absence. The re-scan stays per-delete, since numbers shift after
each removal; it just no longer races. Removals keep the file's
`cmd && say "did X"` idiom.
Generalize the test/cli.sh pin from the one site to the class: sweep every
host/*.sh and drill/*.sh for the racing shape and name the offenders, so a
new script in either directory inherits the pin instead of being one more
site to remember. Comment lines are stripped before matching — each fix's
own commentary quotes the racing shape to explain it, and a prose-blind pin
would fail on the comment documenting why it exists. The positive pins (the
capture, the break-on-absence) now run per file over both, so the sweep
cannot be satisfied by deleting a block instead of fixing it.
drill/doctor.sh was checked and needs nothing: it already reads into
`ufw_out` and is safe by construction, not by absent pipefail.
Refs #107
2026-07-19 23:37:03 +00:00
|
|
|
|
bash -c 'bad=""
|
|
|
|
|
|
for f in "$1"/host/*.sh "$1"/drill/*.sh; do
|
fix: the racing-reader sweep guards the class, and revoke-user captures the trust store
The sweep added in #107 matched `ufw status[^|]*\| *grep` — every historical
instance, and none of the equivalent spellings. `head -n1`, `sed -n '1p;q'`,
`awk '/x/ {print; exit}'` and `read` all close the pipe early, SIGPIPE the
writer, and yield the same 141 under pipefail. The pin guarded the instance
spelling of the very thing it existed to generalise.
Both halves of the matcher are alternations now.
Readers are deliberately not narrowed to the early-exit spellings: telling
`grep -q` from `grep -c` by regex is precision that rots, and all six
`ufw status` sites in the tree already capture first, so banning the pipe
outright costs nothing real.
Writers gain `incus config trust list`. host/revoke-user.sh:206 piped it into
`grep -q` as the --purge leftover assert, under `set -euo pipefail` — so
unlike drill/wipe.sh nothing but the writer's size was holding it, and left of
`&&` a 141 is set -e-exempt too. It would have read as "no leftover cert" on a
host that still trusts the revoked user's certificate and called the purge
complete: fail-open, on the path whose job is to prove access is gone. Now
captured into `trust_csv` and matched with `[[ ]]`, with a leading newline so
the first CSV row anchors like the `^` it replaces.
Writers are enumerated rather than generalised — ~150 legitimate `| grep`
sites exist under host/ and drill/, nearly all re-reading a captured string —
so the sweep claims only what it can check. The `id -nG | tr | grep -qx`
shapes in grant/revoke/setup are left alone: single tiny writes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:02:09 +00:00
|
|
|
|
grep -vE "^[[:space:]]*#" "$f" \
|
|
|
|
|
|
| grep -qE "(ufw status|incus config trust list)[^|]*\| *(grep|head|sed|awk|read)" \
|
|
|
|
|
|
&& bad="$bad ${f#"$1"/}"
|
fix: drill/wipe.sh reads ufw into a capture, not into an early-exit reader
wipe.sh piped `ufw status` straight into `grep -q "Status: active"`. That is
closes the pipe, ufw takes SIGPIPE, and the pipeline yields 141.
It was correct today, and only by accident — the file is `set -u` with no
`pipefail`, so the 141 was discarded and grep's 0 carried the branch. It was
one line from wrong: adding `set -o pipefail` for unrelated robustness would
have silently skipped every UFW removal on a host the operator was told is
wiped, with no error and no red X. Measured on a shim: 5/5 runs took the
wrong branch under pipefail, 3/3 the right one without.
Transplant #106's pattern verbatim from host/teardown-host.sh: capture
ufw_status once and match with `[[ ]]`; rewrite the numbered-delete loop —
whose condition was itself an early-exit reader, plus an un-captured re-read
to get the number — as a `while :` that reads one capture per iteration and
breaks on absence. The re-scan stays per-delete, since numbers shift after
each removal; it just no longer races. Removals keep the file's
`cmd && say "did X"` idiom.
Generalize the test/cli.sh pin from the one site to the class: sweep every
host/*.sh and drill/*.sh for the racing shape and name the offenders, so a
new script in either directory inherits the pin instead of being one more
site to remember. Comment lines are stripped before matching — each fix's
own commentary quotes the racing shape to explain it, and a prose-blind pin
would fail on the comment documenting why it exists. The positive pins (the
capture, the break-on-absence) now run per file over both, so the sweep
cannot be satisfied by deleting a block instead of fixing it.
drill/doctor.sh was checked and needs nothing: it already reads into
`ufw_out` and is safe by construction, not by absent pipefail.
Refs #107
2026-07-19 23:37:03 +00:00
|
|
|
|
done
|
fix: the racing-reader sweep guards the class, and revoke-user captures the trust store
The sweep added in #107 matched `ufw status[^|]*\| *grep` — every historical
instance, and none of the equivalent spellings. `head -n1`, `sed -n '1p;q'`,
`awk '/x/ {print; exit}'` and `read` all close the pipe early, SIGPIPE the
writer, and yield the same 141 under pipefail. The pin guarded the instance
spelling of the very thing it existed to generalise.
Both halves of the matcher are alternations now.
Readers are deliberately not narrowed to the early-exit spellings: telling
`grep -q` from `grep -c` by regex is precision that rots, and all six
`ufw status` sites in the tree already capture first, so banning the pipe
outright costs nothing real.
Writers gain `incus config trust list`. host/revoke-user.sh:206 piped it into
`grep -q` as the --purge leftover assert, under `set -euo pipefail` — so
unlike drill/wipe.sh nothing but the writer's size was holding it, and left of
`&&` a 141 is set -e-exempt too. It would have read as "no leftover cert" on a
host that still trusts the revoked user's certificate and called the purge
complete: fail-open, on the path whose job is to prove access is gone. Now
captured into `trust_csv` and matched with `[[ ]]`, with a leading newline so
the first CSV row anchors like the `^` it replaces.
Writers are enumerated rather than generalised — ~150 legitimate `| grep`
sites exist under host/ and drill/, nearly all re-reading a captured string —
so the sweep claims only what it can check. The `id -nG | tr | grep -qx`
shapes in grant/revoke/setup are left alone: single tiny writes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:02:09 +00:00
|
|
|
|
[ -z "$bad" ] || { printf "racing reads in:%s\n" "$bad"; exit 1; }' \
|
fix: drill/wipe.sh reads ufw into a capture, not into an early-exit reader
wipe.sh piped `ufw status` straight into `grep -q "Status: active"`. That is
closes the pipe, ufw takes SIGPIPE, and the pipeline yields 141.
It was correct today, and only by accident — the file is `set -u` with no
`pipefail`, so the 141 was discarded and grep's 0 carried the branch. It was
one line from wrong: adding `set -o pipefail` for unrelated robustness would
have silently skipped every UFW removal on a host the operator was told is
wiped, with no error and no red X. Measured on a shim: 5/5 runs took the
wrong branch under pipefail, 3/3 the right one without.
Transplant #106's pattern verbatim from host/teardown-host.sh: capture
ufw_status once and match with `[[ ]]`; rewrite the numbered-delete loop —
whose condition was itself an early-exit reader, plus an un-captured re-read
to get the number — as a `while :` that reads one capture per iteration and
breaks on absence. The re-scan stays per-delete, since numbers shift after
each removal; it just no longer races. Removals keep the file's
`cmd && say "did X"` idiom.
Generalize the test/cli.sh pin from the one site to the class: sweep every
host/*.sh and drill/*.sh for the racing shape and name the offenders, so a
new script in either directory inherits the pin instead of being one more
site to remember. Comment lines are stripped before matching — each fix's
own commentary quotes the racing shape to explain it, and a prose-blind pin
would fail on the comment documenting why it exists. The positive pins (the
capture, the break-on-absence) now run per file over both, so the sweep
cannot be satisfied by deleting a block instead of fixing it.
drill/doctor.sh was checked and needs nothing: it already reads into
`ufw_out` and is safe by construction, not by absent pipefail.
Refs #107
2026-07-19 23:37:03 +00:00
|
|
|
|
_ "$ROOT"
|
|
|
|
|
|
|
|
|
|
|
|
# The other direction, per file that removes UFW rules: the capture present and
|
|
|
|
|
|
# the delete loop breaking on absence, so the sweep above cannot be satisfied by
|
|
|
|
|
|
# deleting the block instead of fixing it.
|
|
|
|
|
|
for f in host/teardown-host.sh drill/wipe.sh; do
|
|
|
|
|
|
# shellcheck disable=SC2016 # the $-strings are literals in the target files
|
|
|
|
|
|
check "$f: the UFW branch reads a captured snapshot" 0 "" \
|
|
|
|
|
|
grep -qF 'if [[ "$ufw_status" == *"Status: active"* ]]; then' "$ROOT/$f"
|
|
|
|
|
|
# shellcheck disable=SC2016 # ditto
|
|
|
|
|
|
check "$f: the numbered-delete loop breaks on absence, not on a pipe" 0 "" \
|
|
|
|
|
|
grep -qF '[ -n "$line" ] || break' "$ROOT/$f"
|
|
|
|
|
|
done
|
fix: the racing-reader sweep guards the class, and revoke-user captures the trust store
The sweep added in #107 matched `ufw status[^|]*\| *grep` — every historical
instance, and none of the equivalent spellings. `head -n1`, `sed -n '1p;q'`,
`awk '/x/ {print; exit}'` and `read` all close the pipe early, SIGPIPE the
writer, and yield the same 141 under pipefail. The pin guarded the instance
spelling of the very thing it existed to generalise.
Both halves of the matcher are alternations now.
Readers are deliberately not narrowed to the early-exit spellings: telling
`grep -q` from `grep -c` by regex is precision that rots, and all six
`ufw status` sites in the tree already capture first, so banning the pipe
outright costs nothing real.
Writers gain `incus config trust list`. host/revoke-user.sh:206 piped it into
`grep -q` as the --purge leftover assert, under `set -euo pipefail` — so
unlike drill/wipe.sh nothing but the writer's size was holding it, and left of
`&&` a 141 is set -e-exempt too. It would have read as "no leftover cert" on a
host that still trusts the revoked user's certificate and called the purge
complete: fail-open, on the path whose job is to prove access is gone. Now
captured into `trust_csv` and matched with `[[ ]]`, with a leading newline so
the first CSV row anchors like the `^` it replaces.
Writers are enumerated rather than generalised — ~150 legitimate `| grep`
sites exist under host/ and drill/, nearly all re-reading a captured string —
so the sweep claims only what it can check. The `id -nG | tr | grep -qx`
shapes in grant/revoke/setup are left alone: single tiny writes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:02:09 +00:00
|
|
|
|
|
|
|
|
|
|
# Same other-direction pin for the non-ufw writer the sweep now names: the
|
|
|
|
|
|
# --purge leftover assert must match a captured trust store, so the sweep
|
|
|
|
|
|
# cannot be satisfied by deleting the assert instead of fixing it. That assert
|
|
|
|
|
|
# is the last thing standing between "purge INCOMPLETE" and a silent claim of
|
|
|
|
|
|
# success on a host that still trusts the revoked user's certificate.
|
|
|
|
|
|
# shellcheck disable=SC2016 # the $-strings are literals in the target file
|
|
|
|
|
|
check "revoke-user: the purge leftover assert reads a captured trust store" 0 "" \
|
|
|
|
|
|
grep -qF 'trust_csv="$(incus config trust list' "$ROOT/host/revoke-user.sh"
|
|
|
|
|
|
# shellcheck disable=SC2016 # ditto
|
|
|
|
|
|
check "revoke-user: the cert leftover check matches the capture, not a pipe" 0 "" \
|
|
|
|
|
|
grep -qF '"$trust_csv" == *$' "$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 "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 ]
|