fix(users): the host= marker gates the box role, not the incus group

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 <noreply@anthropic.com>
This commit is contained in:
Claude 2026-07-19 17:29:20 +00:00
parent 5902f61da4
commit b8e8e79b87
4 changed files with 181 additions and 19 deletions

View file

@ -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-<uid>`, 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

View file

@ -146,6 +146,55 @@ assert_marker_human() {
esac
}
# assert_marker_hosts_vms <marker_path> — 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-<uid>, 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 <user> <denyusers token...>
#
# Judge sshd's effective DenyUsers list against ONE candidate, fail closed.

View file

@ -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 "*)

View file

@ -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 <marker_path>
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