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

Merged
dan-claude-bot merged 2 commits from fix/host-no-bare-socket into main 2026-07-19 19:02:12 +00:00
5 changed files with 216 additions and 27 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

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

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

@ -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
@ -750,6 +764,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