From b8e8e79b87cb7250336351ad25f44d4ade98d604 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Jul 2026 17:29:20 +0000 Subject: [PATCH 1/2] fix(users): the host= marker gates the box role, not the incus group MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit users apply consulted the host= trait only when group incus was ABSENT, so a host=no or marker-less box that nonetheless carried the group handed box-role users a bare `usermod -aG incus` — the socket with no tier, which incus-user answers by lazily building an unhardened project under whoever opens it. The marker now decides in both directions through one pure gate, assert_marker_hosts_vms, so the verdict is identical whether or not the group exists. The marker wins over the machine deliberately — it is the box's declared identity and every other host= decision already treats it as authoritative — but not silently: when the group exists and the trait disagrees, the skip names the contradiction and rig bootstrap as the fix. Closes #58 Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 20 +++++++++ commands/lib/users-config.sh | 49 ++++++++++++++++++++++ commands/users-apply.sh | 81 +++++++++++++++++++++++++++--------- test/cli.sh | 50 ++++++++++++++++++++++ 4 files changed, 181 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 27383db..50a1def 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,26 @@ on the way to cutting its first release, and this file starts there. ### Fixed +- **A `host=no` box with an `incus` group no longer hands out the bare + socket** (#58) — `users apply` consulted the `host=` trait only when group + `incus` was ABSENT (die on `host=yes`, skip on `host=no`). When the group + was PRESENT the trait was never asked, so a `host=no` or marker-less box + that nonetheless carried the group — `box setup-host` ran, then the box was + re-bootstrapped with other traits — gave every box-role user a bare + `usermod -aG incus`: the socket with no tier behind it, which `incus-user` + answers by lazily building an UNHARDENED project under whoever opens it + (`incusbr-`, NAT on v4 and v6, no ACL, no `dns.mode=none`, no port + isolation). The marker now decides in BOTH directions, through one new pure + gate (`assert_marker_hosts_vms`, testable against fixture markers non-root + like `assert_marker_human`): the box role applies only where the box CLAIMS + to host VMs, so the verdict is identical whether or not the group exists. + The machine deliberately does not overrule the marker — but the skip is not + silent either: when the group exists and the trait disagrees, the warning + names the contradiction and `rig bootstrap` as the repair. On such a box + exact-membership convergence now strips box-role users out of `incus`, on + the same reasoning: a membership inherited from a previous life is the same + half-grant as a freshly added one. + - **The release suite accepts the ceremony's own tree** (#44) — `test/release.sh` demanded a literal `## Unreleased` heading in the real `CHANGELOG.md`, extracting non-empty and containing `#32`. All three are diff --git a/commands/lib/users-config.sh b/commands/lib/users-config.sh index 81c9c15..ca62a17 100644 --- a/commands/lib/users-config.sh +++ b/commands/lib/users-config.sh @@ -146,6 +146,55 @@ assert_marker_human() { esac } +# assert_marker_hosts_vms — the box role's gate: return 0, +# silently, only when the marker says host=yes; otherwise print the reason on +# stdout and return 1 (the caller decides whether that is a warn or a die). +# Same shape and same reason as assert_marker_human above: the policy is a +# pure marker->verdict function so the harness can prove every arm against +# fixture markers, non-root, while the CLI path sits behind the root check. +# +# The MARKER decides, not the machine (#58). Apply used to consult host= only +# when group incus was ABSENT — so a box whose marker said host=no but which +# happened to carry the group (box's setup-host ran, then the box was +# re-bootstrapped with different traits, or given --host no) handed box-role +# users a bare `usermod -aG incus`: the socket with no tier behind it. That is +# the worst of the three states, because incus-user answers a socket it is +# given by lazily creating an UNHARDENED project for whoever opens it — +# incusbr-, NAT on v4 and v6, no ACL, no dns.mode=none, no port +# isolation. The alternative considered was to let the group's presence win +# and converge anyway with a warning, on the theory that a real incus install +# is evidence the machine really does host VMs. It was rejected: the marker is +# what this box CLAIMS to be, and every other host= decision in the family +# already treats it as authoritative rather than as a hint to be second- +# guessed by probing the machine. A box that lies about itself gets its lie +# taken seriously and gets told, loudly, to re-run bootstrap — which is a +# cheap repair — instead of rig quietly provisioning a VM-host tier on a box +# that does not claim to be one. Deciding from the marker alone also means the +# verdict is the SAME whether or not the group exists, which is the property +# that was missing. +# +# No marker, and a marker with no host= trait, both land here as "not a VM +# host" for the same fail-closed reason: rig cannot tell an unbootstrapped box +# from a repurposed one, and the safe error is withholding VM access that can +# be granted by a re-run, not granting VM access that cannot be un-granted +# once a project exists under it. +assert_marker_hosts_vms() { + local marker + marker="$(read_role_marker "$1")" + case "$marker" in + *host=yes*) return 0 ;; + *host=no*) + printf '%s\n' "this box does not host VMs (host=no)" + return 1 ;; + "") + printf '%s\n' "no /etc/rig/role marker, so this box names no host= trait — re-run rig bootstrap so it knows whether it hosts VMs" + return 1 ;; + *) + printf '%s\n' "the role marker names no host= trait (${marker}) — re-run rig bootstrap so this box knows whether it hosts VMs" + return 1 ;; + esac +} + # deny_verdict # # Judge sshd's effective DenyUsers list against ONE candidate, fail closed. diff --git a/commands/users-apply.sh b/commands/users-apply.sh index 97f0539..d0a1838 100755 --- a/commands/users-apply.sh +++ b/commands/users-apply.sh @@ -165,23 +165,59 @@ fi # --- groups ------------------------------------------------------------------ groupadd -f rig-admin groupadd -f rig -# rig NEVER installs Incus: box's setup-host owns the daemon and its group. An -# absent incus group means that never ran — but what that MEANS is the host= -# trait's call. The box role binds where VMs live; a users file is fleet-wide, -# its box grants are not. So on host=yes an absent group is a broken VM host -# (refuse, point at setup-host), while on host=no it is simply not this box's -# role to converge — skip it, never abort the admins the file also carries. +# rig NEVER installs Incus: box's setup-host owns the daemon and its group. +# +# Whether the box role applies AT ALL is the host= trait's call, decided here, +# once, from the marker alone — never from what groups happen to exist on the +# machine (#58). The box role binds where VMs live; a users file is fleet-wide, +# its box grants are not. Apply used to ask the trait only when group incus was +# ABSENT, which meant a host=no or marker-less box that nonetheless carried the +# group (setup-host ran, then the box was re-bootstrapped with other traits) +# handed box-role users a bare `usermod -aG incus` — the socket with no tier +# behind it, and incus-user answers a socket it is given by lazily creating an +# UNHARDENED project under whoever opens it. The trait now decides the same way +# in both directions: BOX_ROLE_OK is the single gate, and the group's presence +# only ever answers the narrower question of whether a box that DOES claim to +# host VMs is ready to. +# +# The rejected alternative was letting the machine overrule the marker — treat +# a real incus group as evidence the box hosts VMs and converge anyway, warning +# that the marker disagrees. It reads reasonable, but it inverts what the rest +# of this family does with host=: the marker is the box's declared identity, and +# bootstrap is the one thing that writes it. Provisioning a VM-host tier onto a +# box that does not claim to be a VM host is rig deciding it knows better than +# the declaration, on evidence (a leftover group) that survives exactly the +# repurposing that makes the marker right and the group stale. The cost of +# choosing the marker is a genuine VM host mislabelled host=no that stops +# provisioning — so this does not do it SILENTLY: the skip warning below names +# the contradiction and names `rig bootstrap` as the one-line repair. +# +# Consequence worth stating plainly: on such a box the exact-membership +# convergence below will now STRIP box-role users out of incus rather than +# leave them there. That is the same call, not a second one. A membership +# inherited from a previous life is the identical half-grant state as one +# freshly added — socket, no tier — and rig's promise for its three managed +# groups is exactness, not "exact except where drift got there first". INCUS_OK=0 if getent group incus >/dev/null; then INCUS_OK=1; fi -if [ "$NEED_INCUS" -eq 1 ] && [ "$INCUS_OK" -eq 0 ]; then - case "$(read_role_marker "${RIG_ROLE_MARKER:-/etc/rig/role}")" in - *host=yes*) - die "a user carries role box and this box hosts VMs (host=yes) but group incus is absent — install the box CLI and run 'box setup-host' first; rig never installs Incus" ;; - *host=no*) - warn "box role skipped for ${BOX_USERS[*]}: this box does not host VMs (host=no); everything else converges" ;; - *) - warn "box role skipped for ${BOX_USERS[*]}: the role marker names no host= trait — re-run rig bootstrap so this box knows whether it hosts VMs" ;; - esac +BOX_ROLE_OK=0 +BOX_ROLE_WHY="" +if BOX_ROLE_WHY="$(assert_marker_hosts_vms "${RIG_ROLE_MARKER:-/etc/rig/role}")"; then + BOX_ROLE_OK=1 +fi +if [ "$NEED_INCUS" -eq 1 ] && [ "$BOX_ROLE_OK" -eq 0 ]; then + # The group being present while the trait says otherwise is the marker/reality + # mismatch — the case that used to slip through — so it gets its own sentence + # rather than the generic skip. Never a die: a fleet-wide users file naming a + # box-role user somewhere must not abort the admins it also carries here. + if [ "$INCUS_OK" -eq 1 ]; then + warn "box role skipped for ${BOX_USERS[*]}: $BOX_ROLE_WHY — yet group incus EXISTS here, so this box's marker and this box's reality disagree. rig believes the marker and grants nothing (the group alone is only the socket; without the tier behind it incus-user would lazily build an unhardened project under whoever opens it). If this machine really does host VMs, re-run rig bootstrap with --host yes and apply again; everything else converges" + else + warn "box role skipped for ${BOX_USERS[*]}: $BOX_ROLE_WHY; everything else converges" + fi +fi +if [ "$NEED_INCUS" -eq 1 ] && [ "$BOX_ROLE_OK" -eq 1 ] && [ "$INCUS_OK" -eq 0 ]; then + die "a user carries role box and this box hosts VMs (host=yes) but group incus is absent — install the box CLI and run 'box setup-host' first; rig never installs Incus" fi in_group() { id -nG "$1" 2>/dev/null | tr ' ' '\n' | grep -qx "$2"; } @@ -206,10 +242,17 @@ for u in "${USERS[@]}"; do want="" case ",$roles," in *,admin,*) want="$want rig-admin" ;; esac case ",$roles," in *,rig,*) want="$want rig" ;; esac - # incus joins the wanted set only when the group exists (host=no boxes - # skipped it above): converging membership in a conjured group would hand - # the daemon's arrival an audience it never granted. - case ",$roles," in *,box,*) if [ "$INCUS_OK" -eq 1 ]; then want="$want incus"; fi ;; esac + # incus joins the wanted set only when the box role APPLIES to this box — + # the host= trait's call, made once above — and only when the group is + # actually there. Deliberately a gate on whether the role applies, not on + # the add: it is the same question no matter who performs the add, so it + # keeps answering correctly if the add itself later moves elsewhere (#53 + # defers it to `box grant`). BOX_ROLE_OK=1 already implies the group exists + # (the arm above dies otherwise), but INCUS_OK is kept in the condition + # because converging membership in a conjured group would hand the daemon's + # arrival an audience it never granted, and that must not depend on a + # neighbouring branch staying fatal. + case ",$roles," in *,box,*) if [ "$BOX_ROLE_OK" -eq 1 ] && [ "$INCUS_OK" -eq 1 ]; then want="$want incus"; fi ;; esac for g in rig-admin rig incus; do case " $want " in *" $g "*) diff --git a/test/cli.sh b/test/cli.sh index 07d18ab..1184f9c 100644 --- a/test/cli.sh +++ b/test/cli.sh @@ -750,6 +750,56 @@ check "users apply: revoked keys are renamed, never deleted" 0 "" \ check "users apply: box role skips on a host=no box" 0 "" \ grep -q "box role skipped" "$ROOT/commands/users-apply.sh" +# --- the box role's host= gate (#58) ----------------------------------------- +# The gate is a pure marker->verdict lib function for the same reason +# assert_marker_human is: apply's box arm sits behind the root check, so every +# arm is proven HERE against fixture markers, non-root. +# +# What #58 fixed: the trait used to be consulted only when group incus was +# ABSENT, so a host=no (or marker-less) box that happened to CARRY the group +# handed box-role users a bare `usermod -aG incus` — the socket with no tier, +# which incus-user answers by lazily building an unhardened project under +# whoever opens it. The load-bearing property below is that the verdict comes +# from the marker ALONE and is therefore identical whether or not the group +# exists; the group only decides whether a box that does claim host=yes is +# ready to serve the role. +hostvm_gate() { # hostvm_gate + bash -c 'set -euo pipefail + . "$1/commands/lib/users-config.sh" + assert_marker_hosts_vms "$2"' _ "$ROOT" "$1" +} +HOSTVM_FIX="$(mktemp -d)" +printf 'role=dev class=human host=yes join=authkey\n' > "$HOSTVM_FIX/yes" +printf 'role=workload class=server host=no join=authkey\n' > "$HOSTVM_FIX/no" +# A marker that predates the host= trait (or was hand-edited): present, but it +# names no host=. Distinct from an ABSENT marker and it must not read as yes. +printf 'role=workload class=server join=authkey\n' > "$HOSTVM_FIX/traitless" +check "users apply: host=yes passes the box-role gate" \ + 0 "" hostvm_gate "$HOSTVM_FIX/yes" +check "users apply: host=no fails the box-role gate" \ + 1 "does not host VMs" hostvm_gate "$HOSTVM_FIX/no" +# The marker-less case gets its own answer rather than falling through to +# either yes or no: rig cannot tell an unbootstrapped box from a repurposed +# one, so it withholds (recoverable by a re-run) and names the repair. +check "users apply: an absent marker fails the box-role gate, names bootstrap" \ + 1 "re-run rig bootstrap" hostvm_gate "$HOSTVM_FIX/absent" +check "users apply: a marker with no host= trait fails the gate, names bootstrap" \ + 1 "re-run rig bootstrap" hostvm_gate "$HOSTVM_FIX/traitless" +rm -rf "$HOSTVM_FIX" +# The gate must actually be WIRED to the wanted-groups decision, not merely +# exist: this is the line #58 reported, where the box arm used to test group +# presence alone. Pin both operands on that arm — a revert to the INCUS_OK-only +# test must not ship green. It is a gate on whether the ROLE APPLIES, kept +# separate from the mechanism of the add on purpose, so it survives #53 moving +# the add itself into `box grant`. +check "users apply: the incus want is gated on the host= verdict, not just the group" 0 "" \ + grep -qE '\*,box,\*\).*BOX_ROLE_OK.*INCUS_OK.*want incus' "$ROOT/commands/users-apply.sh" +# Marker says no, machine says yes: the skip must NAME the contradiction and +# the one-line repair. The cost of believing the marker is a genuine VM host +# that stops provisioning, and that is only acceptable while it is loud. +check "users apply: a marker/reality mismatch warns and names the repair" 0 "" \ + grep -q "marker and this box's reality disagree" "$ROOT/commands/users-apply.sh" + # --- users close-root: the human-class root-door shutter --------------------- check "users close-root: --help exits 0" 0 "usage:" "$ROOT/commands/users-close-root.sh" --help check "users close-root: unknown flag exits 2" 2 "unknown flag" "$ROOT/commands/users-close-root.sh" --nope From 49471bde4f346ae34542260d41fb881cf04d60e9 Mon Sep 17 00:00:00 2001 From: dan-claude-bot Date: Sun, 19 Jul 2026 17:50:24 +0000 Subject: [PATCH 2/2] docs: README's users-apply section matches the new gate (#60 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #58 inverted the section's central claim — the host= trait now decides in both directions and the incus group never overrides it — but README still read "when the `incus` group is absent, the `host=` trait decides". That qualifier is precisely the bypass the change removes, so the operator reference asserted the bug as the contract. It also omitted the behavior an operator most needs to know before running apply on a repurposed box: on a host=no box carrying a stray incus group, apply warns about the marker/reality mismatch and STRIPS box-role users out of the group. Discovering that from a diff of your own fleet is the wrong way to learn it. Rewritten so the trait gates the role, the group only distinguishes ready-vs-die once the trait already said yes, and the mismatch names both its hazard and `rig bootstrap --host yes` as the repair. Pinned in both directions — current sentences present, superseded one absent — following the same grep-the-prose-stays-honest discipline the file already uses. Co-Authored-By: Claude Opus 4.8 --- README.md | 29 +++++++++++++++++++++-------- test/cli.sh | 14 ++++++++++++++ 2 files changed, 35 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index ea16bdd..31919d3 100644 --- a/README.md +++ b/README.md @@ -761,14 +761,27 @@ silently root-equivalent through the very tool it scopes. Direct root — a bring-up shell, before any admin exists — proceeds. `box` binds where VMs live, and a users file is fleet-wide — its box grants -are not. rig never installs Incus — box's `setup-host` owns the daemon — so -when the `incus` group is absent, the `host=` trait decides: on `host=yes` -apply dies pointing at `box setup-host` (a VM host missing Incus is a real -problem) rather than conjure a group the (nonexistent) daemon would never -consult; on `host=no` the box role is **skipped with a warning** and -everything else — admins included — still converges, because one box-role -user somewhere in the fleet must not stop apply everywhere VMs don't live. -`incus-admin` is deliberately **not** a role: that group is +are not. **The `host=` trait decides whether the box role applies here, and +the `incus` group never overrides it.** On `host=no` — or a marker that names +no `host=` at all, or no marker — the role is **skipped with a warning** and +everything else, admins included, still converges: one box-role user +somewhere in the fleet must not stop apply everywhere VMs don't live. The +verdict is the same whether or not the group happens to exist. + +That last part is the point. rig never installs Incus — box's `setup-host` +owns the daemon — so a `host=no` box can still carry a leftover `incus` +group from a previous life. Adding someone to it there would hand out the +socket with no tier behind it, and incus-user would lazily build them an +**unhardened** project on first contact: `incusbr-`, NAT on v4 *and* +v6, no ACL, no `dns.mode=none`, no port isolation. So on that mismatch apply +warns — naming the contradiction and `rig bootstrap --host yes` as the +repair — and **strips** box-role users out of `incus`, because an inherited +half-grant is the same defect as a fresh one. + +The group's presence matters only once the trait already said yes: on +`host=yes` an absent `incus` group means the daemon was never set up, so +apply dies pointing at `box setup-host` rather than conjure a group nothing +would consult. `incus-admin` is deliberately **not** a role: that group is host-root-equivalent, break-glass by hand only. **All passwords stay locked, always** — created or found. The SSH key at the diff --git a/test/cli.sh b/test/cli.sh index 1184f9c..a6483c3 100644 --- a/test/cli.sh +++ b/test/cli.sh @@ -177,6 +177,20 @@ check "README: no stale heavy-duty/claudebox links" 1 "" \ grep -n "heavy-duty/claudebox" "$ROOT/README.md" check "README: points at heavy-duty/box" 0 "" \ grep -q "github.com/heavy-duty/box" "$ROOT/README.md" + +# The users-apply section is the operator's reference for what the box role +# does, and #58 inverted its central claim: the trait decides in BOTH +# directions now, and the group's presence never overrides it. A reference +# that still says "when the incus group is absent, the host= trait decides" +# asserts the very bypass that was the bug. Pinned in both directions — the +# current sentence present, the superseded one gone — so the prose cannot +# drift back to describing a semantics the code no longer has. +check "README: the trait gates the box role regardless of the group" 0 "" \ + grep -q "the \`incus\` group never overrides it" "$ROOT/README.md" +check "README: documents the mismatch strip on host=no" 0 "" \ + grep -q "half-grant is the same defect as a fresh one" "$ROOT/README.md" +check "README: no stale 'group absent decides' semantics" 1 "" \ + grep -n "when the \`incus\` group is absent, the \`host=\` trait decides" "$ROOT/README.md" if [ "$(id -u)" -ne 0 ]; then check "bootstrap: refuses non-root" 1 "must run as root" env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" workload check "bootstrap: runner role parses, refuses non-root" 1 "must run as root" env TS_AUTHKEY=x "$ROOT/commands/bootstrap.sh" runner