From 56eed6250ecaef69d232b143f52f33d334a6b802 Mon Sep 17 00:00:00 2001 From: dan-claude-bot Date: Sat, 18 Jul 2026 04:09:48 +0000 Subject: [PATCH] test+ci: the multi-user rehearsal, and CI that runs it on a real incus (#74) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .github/workflows/ci.yml | 46 ++++-- drill/multiuser.sh | 302 +++++++++++++++++++++++++++++++++++++++ test/cli.sh | 104 ++++++++++++++ 3 files changed, 444 insertions(+), 8 deletions(-) create mode 100644 drill/multiuser.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 845da81..b36599d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,11 +22,41 @@ jobs: - name: cli tests run: bash test/cli.sh - # NOT run here: the drill (drill/drill.sh) and the multi-user rehearsal - # (drill/multiuser.sh). Both stand up a real Incus, rearrange the host's - # network/firewall, and — for the rehearsal — create throwaway system users. - # None of that belongs in CI: it needs a disposable host and root, and it is - # the answer to #72 Task 0's substrate question, which a container runner - # cannot answer. CI stays static + dependency-free (shellcheck + cli.sh), - # exactly as this repo's design demands ("isolation claims are tested on a - # real host, never reasoned about" — docs/box-design.md). + # The multi-user rehearsal, on a REAL incus — a GitHub runner is root on a + # disposable VM, which is exactly the substrate the rehearsal needs. It runs + # in container mode: the tier's mechanics (grant, confinement, the network + # contract, revoke) are identical for containers and VMs — the nft bridge + # drop, the ACL, dns.mode=none and port_isolation all bind to boxnet, not + # to the instance type. What container mode canNOT validate is the VM trust + # boundary itself; that stays a real-hardware ritual (drill/RUNS.md), same + # as the full drill. So: every PR proves the tier's semantics, and a + # release still proves the boundary. + rehearsal: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + - name: install incus + run: | + sudo apt-get update + sudo DEBIAN_FRONTEND=noninteractive apt-get install -y incus + - name: global install, from this checkout (the #71 layout) + # Not install.sh: it installs a RELEASE (REPO@REF), and CI must prove + # the code under review. Same tree, same layout, same a+rX. + run: | + sudo cp -r . /opt/box + sudo rm -rf /opt/box/.git + sudo chmod -R a+rX /opt/box + sudo ln -sf /opt/box/bin/box /usr/local/bin/box + - name: setup-host + run: sudo bash /opt/box/host/setup-host.sh + - name: doctor — the baseline is provable before anything is judged + run: sudo BOX_TIER=admin bash /opt/box/drill/doctor.sh + - name: multi-user rehearsal (criteria a-l, container mode) + run: sudo BOX_MULTIUSER_REHEARSAL=1 bash /opt/box/drill/multiuser.sh --yes --container + + # NOT run here: the full drill (drill/drill.sh). It rehearses the whole + # surface — cold template mints, expose, migration — and wants a real host + # and the better part of an hour. The rehearsal job above is the CI-shaped + # slice of the same discipline: isolation claims are still tested on a real + # daemon, never reasoned about (docs/box-design.md). diff --git a/drill/multiuser.sh b/drill/multiuser.sh new file mode 100644 index 0000000..acc9b37 --- /dev/null +++ b/drill/multiuser.sh @@ -0,0 +1,302 @@ +#!/usr/bin/env bash +# multiuser.sh — the restricted-tier rehearsal (#74): does 'box grant' give a +# plain incus-group user their own boxes, on the hardened boxnet, with no view +# of anyone else's — measured live, from both sides of the socket? +# +# sudo BOX_MULTIUSER_REHEARSAL=1 bash drill/multiuser.sh --yes +# +# It creates two throwaway system users, grants them the tier through the real +# 'box grant', mints real boxes as them, probes the isolation contract from +# INSIDE those boxes, revokes one user, and deletes everything it made. Root +# only, and opt-in twice (the env gate and the prompt): it edits the group +# database and the daemon's project list, which is nothing to do casually. +# +# Criteria (a)-(f) are issue #74's acceptance gate, kept under their letters; +# (g)-(l) are what the Task-0 findings added: the network CONTRACT is the +# tier's whole point, so it is measured, never assumed. +# +# a. an incus-group user is auto-confined to their own project +# b. they can box new / list / exec / snapshot / restore / clone their own box +# c. they cannot see another user's boxes +# d. the same box name in two projects does not collide +# e. box expose refuses for them (daemon-global state) +# f. box setup-host / box doctor answer with the honest restricted note +# g. their boxes ride boxnet and carry the full isolation contract +# h. the private incusbr- escape hatch is closed (boxnet is the ONLY network) +# i. (folded into b: snapshot / restore / clone) +# k. the grant survives an incus-user restart +# l. box revoke --purge removes the user's world and touches nobody else's +# +# ok/no/note return 0 by design — the 'A && ok || no' idiom below is the +# same one drill.sh is built on (and the reason for the SC2015 disable). +# shellcheck disable=SC2015 +set -u + +YES=0; KEEP=0; MODE=() +for a in "$@"; do + case "$a" in + --yes) YES=1 ;; + --keep) KEEP=1 ;; + --container) MODE=(--container) ;; # CI / kvm-less hosts: don't wait on a VM + *) echo "usage: sudo BOX_MULTIUSER_REHEARSAL=1 bash drill/multiuser.sh [--yes] [--container] [--keep]" >&2; exit 2 ;; + esac +done + +[ "${BOX_MULTIUSER_REHEARSAL:-}" = 1 ] || { + echo "multiuser.sh: this rehearsal creates system users and edits the group database." >&2 + echo "opt in explicitly: sudo BOX_MULTIUSER_REHEARSAL=1 bash drill/multiuser.sh --yes" >&2 + exit 2 +} +[ "$(id -u)" -eq 0 ] || { echo "multiuser.sh: root only (it creates users and grants tiers)." >&2; exit 1; } +command -v box >/dev/null || { echo "multiuser.sh: no 'box' on PATH — the tier needs the global install (#71): sudo bash install.sh" >&2; exit 1; } +command -v incus >/dev/null || { echo "multiuser.sh: incus is not installed — box setup-host first." >&2; exit 1; } +incus network show boxnet >/dev/null 2>&1 || { echo "multiuser.sh: no boxnet — box setup-host first." >&2; exit 1; } + +if [ "$YES" -ne 1 ]; then + [ -t 0 ] || { echo "multiuser.sh: no terminal to confirm on — pass --yes." >&2; exit 2; } + printf 'multiuser.sh: create users %s/%s, grant them the tier, mint boxes as them, then delete it all? [y/N] ' boxdrill1 boxdrill2 + read -r reply + case "$reply" in y|Y|yes|YES|Yes) : ;; *) echo "aborted."; exit 1 ;; esac +fi + +pass=0; fail=0; findings=(); audit=() +ok() { printf ' \033[32mPASS\033[0m %s\n' "$*"; pass=$((pass+1)); } +no() { printf ' \033[31mFAIL\033[0m %s\n' "$*"; fail=$((fail+1)); findings+=("FAIL: $*"); } +note() { printf ' \033[33mNOTE\033[0m %s\n' "$*"; findings+=("NOTE: $*"); } +inf() { printf ' %s\n' "$*"; } +phase(){ printf '\n\033[1m══ %s\033[0m\n' "$*"; } +aud() { audit+=("$*"); } + +U1=boxdrill1; U2=boxdrill2 + +# Run as a rehearsal user. runuser resets HOME/USER to the target (we are +# root), and stdin is pinned: an incus with a terminal can go interactive and +# wedge the run forever — the drill's oldest trap, honored here. +as_u() { local u="$1"; shift; runuser -u "$u" -- "$@" + local u="$1" b="$2" url="$3" out + out="$(as_u "$u" timeout -k 5 30 incus exec "$b" -- curl -sS -m 15 -o /dev/null "$url" 2>&1)" + case "$out" in + "") echo reachable ;; + *Connection\ refused*) echo refused ;; + *) echo dropped ;; + esac +} + +# For probes whose PASS is "reachable": one retry. A nested-virt host can +# blow a first TLS handshake on timing alone, and a rehearsal that cries +# broken-egress over that teaches people to ignore it. Isolation probes never +# retry — for them silence is the pass, and silence is not flaky. +probe_up() { # probe_up + local r; r="$(probe_from "$@")" + [ "$r" = reachable ] || r="$(probe_from "$@")" + echo "$r" +} + +cleanup() { + [ "$KEEP" = 1 ] && { echo "(--keep: users and boxes left for inspection)"; return; } + echo + echo "── cleanup" + for u in "$U1" "$U2"; do + id "$u" >/dev/null 2>&1 || continue + BOX_YES=1 box revoke "$u" --purge >/dev/null 2>&1 + userdel -r "$u" >/dev/null 2>&1 + id "$u" >/dev/null 2>&1 && echo " WARNING: user $u still exists" || echo " removed $u (tier, boxes, account)" + done +} +trap cleanup EXIT + +phase "G. the grant — box grant is the convergence, and it converges" +for u in "$U1" "$U2"; do + id "$u" >/dev/null 2>&1 || useradd -m -s /bin/bash "$u" +done +# Before any grant: a bare user has NO tier — the socket refuses them. This is +# the baseline that makes everything after it meaningful. +as_u "$U1" incus list >/dev/null 2>&1 \ + && no "(a) $U1 reached the daemon BEFORE any grant — the socket is not confining" \ + || ok "(a) before the grant, $U1 cannot reach the daemon at all" + +box grant "$U1" >/dev/null 2>&1 && ok "box grant $U1 exits 0" || no "box grant $U1 failed" +box grant "$U2" >/dev/null 2>&1 && ok "box grant $U2 exits 0" || no "box grant $U2 failed" +box grant "$U1" >/dev/null 2>&1 && ok "box grant is idempotent (re-run exits 0)" || no "box grant re-run failed" + +uid1="$(id -u "$U1")"; uid2="$(id -u "$U2")" +p1="user-$uid1"; p2="user-$uid2" + +acc="$(incus project get "$p1" restricted.networks.access 2>/dev/null)" +[ "$acc" = boxnet ] \ + && ok "(h) $p1 is restricted to boxnet and ONLY boxnet" \ + || no "(h) $p1 restricted.networks.access = '$acc' — the unhardened private bridge is still reachable" +aud "h. restricted.networks.access after grant: '$acc' (the private incusbr-$uid1 is unreferenced)" + +snaps="$(incus project get "$p1" restricted.snapshots 2>/dev/null)" +[ "$snaps" = allow ] && ok "snapshots allowed in $p1 (the clone workflow exists)" \ + || no "restricted.snapshots = '$snaps' — box snapshot will refuse" + +incus --project "$p1" profile device get default eth0 type >/dev/null 2>&1 \ + && no "(h) $p1's default profile still carries the private-bridge eth0" \ + || ok "(h) $p1's default profile places no network — box-net is the only door" + +iso="$(incus --project "$p1" profile device get box-net eth0 security.port_isolation 2>/dev/null)" +[ "$iso" = true ] && ok "box-net profile is in $p1, port_isolation true" \ + || no "box-net profile in $p1 is wrong (port_isolation='$iso')" + +phase "a. confinement — each user lands in their own project, and only theirs" +projects="$(as_u "$U1" incus project list --format csv 2>/dev/null | cut -d, -f1)" +if [ "$(printf '%s\n' "$projects" | grep -c .)" = 1 ] && printf '%s' "$projects" | grep -q "$p1"; then + ok "(a) $U1 sees exactly one project: their own ($p1)" +else + no "(a) $U1 sees: $(printf '%s' "$projects" | tr '\n' ' ') — confinement LEAKED, which vetoes the tier" +fi +as_u "$U1" incus list --project default >/dev/null 2>&1 \ + && no "(a) $U1 can list the DEFAULT project — admin boxes are visible" \ + || ok "(a) the default project refuses $U1" +aud "a. incus-user confines: project list as $U1 = '$(printf '%s' "$projects" | tr '\n' ' ')'" + +phase "b. the lifecycle, as a restricted user — new/list/exec/snapshot/restore/clone/rm" +# The mint's narration is kept and shown on failure — a FAIL that names +# nothing is the drill's oldest sin. +mintlog="$(mktemp)" +# 1GiB / 2 cpus: a blank Debian needs no more, and the rehearsal runs TWO +# boxes at once — on a small (or nested) rehearsal host, 2GiB apiece is the +# difference between measuring isolation and measuring swap. +if as_u "$U1" box new --name mine --template blank --cpu 2 --memory 1GiB "${MODE[@]}" >"$mintlog" 2>&1; then + ok "(b) box new mine — minted" +else + no "(b) box new failed for $U1 (rc≠0) — its last words:" + grep -v '^\.*$' "$mintlog" | tail -6 | sed 's/^/ /' +fi +rm -f "$mintlog" +as_u "$U1" box list 2>/dev/null | grep -q '^mine ' && ok "(b) box list shows mine" || no "(b) box list does not show mine" +as_u "$U1" box exec mine -- true >/dev/null 2>&1 && ok "(b) box exec mine -- true" || no "(b) box exec failed" +as_u "$U1" box info mine 2>/dev/null | grep -q '10\.88\.' \ + && ok "(g) box info shows a boxnet (10.88.x) address — placed on the hardened network" \ + || no "(g) mine has no boxnet address in box info" +as_u "$U1" box snapshot mine s1 >/dev/null 2>&1 && ok "(b) box snapshot mine s1" || no "(b) snapshot refused" +as_u "$U1" box restore mine s1 >/dev/null 2>&1 && ok "(b) box restore mine s1 (the incus 6 'snapshot restore' spelling)" || no "(b) restore failed" +as_u "$U1" box new --name c1 --from mine/s1 >/dev/null 2>&1 && ok "(b) box new --from mine/s1 — the clone workflow" || no "(b) clone failed" +# c1 stays alive through phase g: it is the distinctly-NAMED sibling the +# enumeration probe needs (both users' primaries are 'mine' by design of d). +aud "b. full lifecycle exercised as $U1 through the real CLI" + +phase "d. same name, two users — projects mean no collision" +as_u "$U2" box new --name mine --template blank --cpu 2 --memory 1GiB "${MODE[@]}" >/dev/null 2>&1 \ + && ok "(d) $U2 minted their own 'mine' beside $U1's" \ + || no "(d) $U2 could not mint 'mine' — names collide across users" + +phase "c. cross-visibility — each sees exactly their own" +n1="$(as_u "$U1" box list 2>/dev/null | grep -c '^mine ')" +n2="$(as_u "$U2" box list 2>/dev/null | grep -c '^mine ')" +[ "$n1" = 1 ] && [ "$n2" = 1 ] \ + && ok "(c) both users see exactly one 'mine' — their own" \ + || no "(c) visibility leaked: $U1 sees $n1, $U2 sees $n2 — vetoes the tier" +as_u "$U1" incus list --project "$p2" >/dev/null 2>&1 \ + && no "(c) $U1 can list $U2's project" \ + || ok "(c) $U2's project refuses $U1" +aud "c. cross-user visibility: $U1=$n1 'mine', $U2=$n2 'mine', foreign project listing refused" + +phase "g. the isolation contract, measured from INSIDE the boxes" +ip1="$(incus --project "$p1" list mine --format csv --columns 4 2>/dev/null | tr -d '"' | sed 's/ (.*//' | head -n1)" +ip2="$(incus --project "$p2" list mine --format csv --columns 4 2>/dev/null | tr -d '"' | sed 's/ (.*//' | head -n1)" +inf "$U1's mine: ${ip1:-} $U2's mine: ${ip2:-}" +case "$ip1" in 10.88.*) ok "(g) $U1's box holds a boxnet lease" ;; *) no "(g) $U1's box is NOT on boxnet: '$ip1'" ;; esac + +r="$(probe_up "$U1" mine https://1.1.1.1)" +[ "$r" = reachable ] && ok "(g) egress to the public internet works (curl 1.1.1.1: $r)" || no "(g) public egress broken: $r" +as_u "$U1" timeout -k 5 20 incus exec mine -- getent hosts deb.debian.org >/dev/null 2>&1 \ + && ok "(g) public DNS resolves (via the pinned resolver)" || no "(g) DNS broken inside the box" + +r="$(probe_from "$U1" mine "http://10.88.0.1:22")" +[ "$r" = dropped ] && ok "(g) box → host is dropped (gateway :22: $r)" || no "(g) box can reach the HOST: $r" +r="$(probe_from "$U1" mine "http://192.168.0.1")" +[ "$r" = dropped ] && ok "(g) box → RFC1918 is dropped ($r)" || no "(g) box reaches private space: $r" + +if [ -n "$ip2" ]; then + r="$(probe_from "$U1" mine "http://$ip2:9")" + [ "$r" = dropped ] && ok "(g) $U1's box → $U2's box is DROPPED (cross-user sibling isolation)" \ + || no "(g) cross-user box→box answered ($r) — a packet crossed the user boundary" + aud "g. cross-user sibling probe $ip1 → $ip2: $r (silence is the pass; 'refused' would mean arrival)" +else + note "(g) no ip for $U2's box — sibling probe skipped" +fi + +# Enumeration is probed with a SIBLING's name, never the box's own — a box +# always resolves itself from /etc/hosts (cloud-init writes it), and reading +# that as a gateway leak was this rehearsal's first false FAIL. c1 (U1's +# clone, still alive) is the distinctly-named instance; U2's box asks. +as_u "$U2" timeout -k 5 20 incus exec mine -- getent hosts c1 >/dev/null 2>&1 \ + && no "(g) a box can resolve a sibling's name through the gateway (dns.mode leak)" \ + || ok "(g) a sibling's name does not resolve (dns.mode=none holds for the tier)" +as_u "$U1" box rm c1 --force >/dev/null 2>&1 && ok "(b) box rm c1" || no "(b) rm failed" +v6="$(as_u "$U1" timeout -k 5 20 incus exec mine -- sh -c 'ip -6 addr show dev eth0 scope global 2>/dev/null' 2>/dev/null)" +[ -z "$v6" ] && ok "(g) no global IPv6 inside the box (the IPv4-only contract)" \ + || no "(g) the box holds a global IPv6 address — an uncovered egress path" + +phase "h. the escape hatches, tried and refused" +as_u "$U1" incus launch images:debian/13 esc --network "incusbr-$uid1" >/dev/null 2>&1 \ + && { no "(h) $U1 attached the unhardened private bridge"; as_u "$U1" incus delete -f esc >/dev/null 2>&1; } \ + || ok "(h) attaching the private incusbr-$uid1 is refused (not in restricted.networks.access)" +as_u "$U1" incus project set "$p1" restricted.networks.access "boxnet,incusbr-$uid1" >/dev/null 2>&1 \ + && no "(h) $U1 widened their OWN project's network access" \ + || ok "(h) a restricted certificate cannot widen its own project" +as_u "$U1" incus network set boxnet dns.mode=managed >/dev/null 2>&1 \ + && no "(h) $U1 edited boxnet itself" \ + || ok "(h) boxnet's config refuses a restricted certificate" + +phase "e/f. the honest refusals — expose, setup-host, doctor" +out="$(as_u "$U1" box expose mine 3000 2>&1)"; rc=$? +[ "$rc" -ne 0 ] && printf '%s' "$out" | grep -qi restricted \ + && ok "(e) box expose refuses with the restricted message (rc=$rc)" \ + || no "(e) box expose: rc=$rc, said: $(printf '%s' "$out" | head -1)" +out="$(as_u "$U1" box setup-host 2>&1)"; rc=$? +[ "$rc" -eq 0 ] && printf '%s' "$out" | grep -q "restricted tier" \ + && ok "(f) box setup-host: the honest note, exit 0" \ + || no "(f) box setup-host as $U1: rc=$rc" +out="$(as_u "$U1" box doctor 2>&1)"; rc=$? +printf '%s' "$out" | grep -q "Access tier — restricted" \ + && ok "(f) box doctor answers at the restricted tier (rc=$rc)" \ + || no "(f) box doctor did not honor the tier (rc=$rc)" +aud "e/f. expose refused; setup-host and doctor answer honestly at the tier" + +phase "k. the grant survives an incus-user restart (the re-sync question, #74)" +systemctl restart incus-user.socket 2>/dev/null +sleep 1 +as_u "$U1" box list 2>/dev/null | grep -q '^mine ' \ + && ok "(k) after restarting incus-user, $U1 still reaches their boxes" \ + || no "(k) the restart broke $U1's access" +acc="$(incus project get "$p1" restricted.networks.access 2>/dev/null)" +[ "$acc" = boxnet ] && ok "(k) restricted.networks.access is still boxnet — nothing re-widened" \ + || no "(k) the restart rewrote network access to '$acc'" +aud "k. incus-user re-sync: convergence intact (matches its source: setup runs only at project creation)" + +phase "l. revoke — one user out, the other untouched" +BOX_YES=1 box revoke "$U2" --purge >/dev/null 2>&1 && ok "(l) box revoke $U2 --purge exits 0" || no "(l) revoke failed" +as_u "$U2" incus list >/dev/null 2>&1 \ + && no "(l) $U2 still reaches the daemon after revoke" \ + || ok "(l) $U2 is locked out" +incus project show "$p2" >/dev/null 2>&1 \ + && no "(l) $U2's project survived the purge" \ + || ok "(l) $U2's project, boxes and bridge are gone" +st="$(incus --project "$p1" list mine --format csv --columns s 2>/dev/null | head -n1)" +[ "$st" = RUNNING ] && ok "(l) $U1's box is untouched and RUNNING through it all" \ + || no "(l) $U1's box state after $U2's purge: '$st'" +aud "l. revoke --purge is scoped: $U2 erased, $U1 unmoved" + +echo +echo "════════════════════════════════════════════" +echo " $pass passed, $fail failed" +if [ "${#findings[@]}" -gt 0 ]; then + echo; printf ' %s\n' "${findings[@]}" +fi +echo +echo " #74 audit answers:" +printf ' · %s\n' "${audit[@]}" +echo +[ "$fail" -eq 0 ] && echo " VERDICT: the restricted tier HOLDS — grant/confine/isolate/revoke, measured." \ + || echo " VERDICT: the tier does NOT hold — see the findings." +[ "$fail" -eq 0 ] diff --git a/test/cli.sh b/test/cli.sh index f2242d8..d657c8c 100644 --- a/test/cli.sh +++ b/test/cli.sh @@ -139,6 +139,110 @@ for t in blank claude codex grok; do grep -qE '^[[:space:]]*-[[:space:]]+tmux$' "$ROOT/templates/$t/user-data.yaml" done +# --------------------------------------------------------------------------- +# 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 + local uid="$1"; shift + FAKE_UID="$uid" FAKE_GROUPS="$*" PATH="$SHIMDIR:$PATH" \ + bash -c ". '$TIERFN'; box_tier" +} +check "box_tier: uid 0 → admin" 0 "admin" tier 0 +check "box_tier: incus-admin → admin" 0 "admin" tier 1000 "users incus-admin" +check "box_tier: incus only → restricted" 0 "restricted" tier 1000 "users incus" +check "box_tier: both groups → admin (membership wins at the socket)" \ + 0 "admin" tier 1000 "users incus incus-admin" +check "box_tier: neither → none" 0 "none" tier 1000 "users dialout" +rm -f "$TIERFN" + +# setup-host.sh must decide the tier BEFORE any install tree exists, so it +# carries its own copy — and a drifted copy is two tiers pretending to be one. +# Byte-identical, asserted. +BINFN="$(mktemp)"; HOSTFN="$(mktemp)" +awk '/^box_tier\(\) \{/,/^\}/' "$ROOT/bin/box" > "$BINFN" +awk '/^box_tier\(\) \{/,/^\}/' "$ROOT/host/setup-host.sh" > "$HOSTFN" +check "box_tier: bin/box and setup-host.sh copies are byte-identical" 0 "" \ + diff "$BINFN" "$HOSTFN" +rm -f "$BINFN" "$HOSTFN" + +# The tier scripts parse and refuse bad usage without a daemon — drive them. +check "grant: no argument is a usage error" 2 "usage: box grant" bash "$ROOT/host/grant-user.sh" +check "grant: a flag is not a user" 2 "usage: box grant" bash "$ROOT/host/grant-user.sh" --frob +check "revoke: no argument is a usage error" 2 "usage: box revoke" bash "$ROOT/host/revoke-user.sh" +check "revoke: two users is a usage error" 2 "usage: box revoke" bash "$ROOT/host/revoke-user.sh" a b +check "box grant with no user exits 2 (via the CLI table)" 2 "usage: box grant" "$BOX" grant +check "box revoke with no user exits 2 (via the CLI table)" 2 "usage: box revoke" "$BOX" revoke +check "help grant names the hardened network" 0 "boxnet" "$BOX" help grant +check "help revoke names --purge" 0 "purge" "$BOX" help revoke + +# Load-bearing lines a daemon-free run cannot exercise — grepped so a deleted +# guard cannot ship green (the house test discipline). +# The expose guard must fire before ANY incus call in cmd_expose: line order. +# shellcheck disable=SC2016 # the $-strings are literals in the target file +check "expose: the restricted guard precedes the first incus call" 0 "" bash -c ' + fn="$(awk "/^cmd_expose\(\) \{/,/^\}/" "'"$ROOT"'/bin/box")" + guard="$(printf "%s\n" "$fn" | grep -n "box_tier" | head -1 | cut -d: -f1)" + first="$(printf "%s\n" "$fn" | grep -n "incus config" | head -1 | cut -d: -f1)" + [ -n "$guard" ] && [ -n "$first" ] && [ "$guard" -lt "$first" ]' +# cmd_new refuses before minting when the placement contract is absent, and +# the message is tier-aware (a restricted user is sent to 'box grant', not +# to setup-host they cannot run). +check "new: pre-flights the box-net profile" 0 "" bash -c ' + awk "/^cmd_new\(\) \{/,/^\}/" "'"$ROOT"'/bin/box" | grep -q "incus profile show box-net"' +check "new: the restricted fix names box grant" 0 "" bash -c ' + awk "/^cmd_new\(\) \{/,/^\}/" "'"$ROOT"'/bin/box" | grep -q "box grant"' +# grant converges to boxnet and ONLY boxnet — "boxnet,incusbr" would keep the +# unhardened private bridge one --network flag away (the #74 measured hole). +check "grant: narrows access to boxnet alone" 0 "" \ + grep -qE 'restricted\.networks\.access boxnet($| )' "$ROOT/host/grant-user.sh" +check "grant: never grants the private bridge" 1 "" \ + grep -qE 'networks\.access[^#]*incusbr' "$ROOT/host/grant-user.sh" +check "grant: allows snapshots (the clone workflow)" 0 "" \ + grep -qF 'restricted.snapshots allow' "$ROOT/host/grant-user.sh" +# shellcheck disable=SC2016 # the $-strings are literals in the target file +check "grant: installs the SHIPPED profile into the project" 0 "" \ + grep -qF 'profile edit box-net < "$here/profiles/box-net.yaml"' "$ROOT/host/grant-user.sh" +check "grant: unpins the private-bridge eth0 from the default profile" 0 "" \ + grep -qF 'profile device remove default eth0' "$ROOT/host/grant-user.sh" +check "grant: refuses an incus-admin member (nothing tighter to grant)" 0 "" \ + grep -qF 'incus-admin' "$ROOT/host/grant-user.sh" +check "revoke: group removal is the lockout" 0 "" \ + grep -qF 'gpasswd -d' "$ROOT/host/revoke-user.sh" +# shellcheck disable=SC2016 # the $-strings are literals in the target file +check "revoke: purge deletes instances one at a time" 0 "" \ + grep -qF 'delete -f "$inst"' "$ROOT/host/revoke-user.sh" +check "revoke: purge removes the trust-store certificate" 0 "" \ + grep -qF 'config trust remove' "$ROOT/host/revoke-user.sh" +# shellcheck disable=SC2016 # the $-strings are literals in the target file +check "setup-host: the restricted gate precedes the sudo resolution" 0 "" bash -c ' + gate="$(grep -n "restricted tier" "'"$ROOT"'/host/setup-host.sh" | head -1 | cut -d: -f1)" + sudo="$(grep -n "^elif command -v sudo" "'"$ROOT"'/host/setup-host.sh" | head -1 | cut -d: -f1)" + [ -n "$gate" ] && [ -n "$sudo" ] && [ "$gate" -lt "$sudo" ]' +check "setup-host: enables incus-user.socket for the tier" 0 "" \ + grep -qF 'incus-user.socket' "$ROOT/host/setup-host.sh" +check "doctor: honors BOX_TIER" 0 "" \ + grep -qF 'BOX_TIER' "$ROOT/drill/doctor.sh" +check "box exports BOX_TIER to the doctor" 0 "" \ + grep -qF 'export BOX_TIER' "$ROOT/bin/box" +# 'box restore' must speak incus 6 ('snapshot restore'); bare 'incus restore' +# does not exist and the verb was broken for everyone until #74's rehearsal hit it. +check "restore: dispatches 'incus snapshot restore'" 0 "" \ + grep -qF '^incus:snapshot restore^' "$ROOT/bin/box" +# 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" echo "---" echo "$PASS passed, $FAIL failed"