users: finish #17 — close-root proves the door (sudo -n, per-user sshd -T), @root key seeding, runner row owned #30
No reviewers
Labels
No labels
attention
blocked
blocker:ci-red
blocker:conflict
blocker:drill-pending
blocker:unrequested
bug
claimed
documentation
enhancement
epic
merge-next
needs-ruling
needs-triage
offsite
post-merge
ready
release
scope:bootstrap
scope:coolify
scope:db
scope:docs
scope:drill
scope:installer
scope:labels
scope:platform
scope:runner
scope:users
stale
state:addressing
state:bots-reviewing
state:building
state:needs-human
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: heavy-duty/rig#30
Loading…
Reference in a new issue
No description provided.
Delete branch "feat/close-root-reachability"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #17.
Main already reshaped most of #17 into the fleet-users design (#27):
rig users apply --filecreates admin users declaratively (roleadmin→rig-admin, NOPASSWD sudo), andrig users close-rootinstalls/etc/ssh/sshd_config.d/00-rig-users.conf(PermitRootLogin no) gated on theclass=humanmarker, a StrictModes-shaped admin-door check, and the invoker gate. This PR ships the three things of #17 that remained on top of that, so the issue can close. It supersedes the stale draft PR #21, which embodies the old single-flag design (left open for its author to close).1. close-root's gate now proves the door opens, not that it should
The existing gate reads files, and files can all look right while the door stays shut: a sudoers drop-in that never landed, an
AllowUsers/Matchblock elsewhere in sshd's config. #17 names the two checks that interrogate behavior, and they now run per candidate, additively, before the drop-in installs:runuser -u <admin> -- sudo -n true— NOPASSWD sudo answers or it doesn't (-nnever prompts). A missingrunuser(off-Debian) skips this one proof with a loud warning rather than blocking the door on a missing prover.sshd -T -C user=<admin>,host=…,addr=127.0.0.1— the per-user effective config, Match blocks resolved:pubkeyauthentication yes, no literalDenyUsershit,AllowUsers(if set) must name the admin. Allow/Deny patterns are matched literally and fail closed.Refusals keep the existing shape: the failing check is named per candidate. The one thing no local check can prove remains possession of the private key — the verify-in-a-separate-session advisory stays load-bearing.
2.
@rootkey seeding — #17's headline lockout-avoidanceThe users file's key field now admits the literal token
@root: this user'sauthorized_keysbecomes root's current/root/.ssh/authorized_keysat apply time. The operator provably holds a root private key — they SSHed in with it to run apply at all — so the seeded key is the one key rig can know opens for them; any pasted literal can be a key they don't hold.Semantics (documented in usage + README): shape-validated in the parse pass (exit 2, pre-root-check, testable non-root; a second
@rootper user falls into the existing duplicate-line refusal);@rootmixes with literal key lines — seeded keys land first, literals append after, deterministic bytes through the existing cmp-guard; re-runs re-seed, convergent to root's then-current keys plus the literals (the escape hatch from #17's convergence-vs-intent wrinkle is switching the line to literal keys); root's key lines copy verbatim, options included (from=/command=follows its key — rig won't silently widen what a key can do); apply dies with the repair if root has no keys to seed.3. The runner row, reconciled in prose — no gate change
#17's table said runner "can close root once an admin is proven"; the class model superseded the per-role call, and close-root refuses on
class=server— runner's class. Deliberately unchanged: theclass=serverrefusal message now explains itself (server-class machines are automation identities whose management plane is root SSH; a CI box meant to be administered like a human machine is--class humanat bootstrap), and the README identity-model section records the divergence in one short paragraph (kept surgical — feat/class-model-remnants touches the same section).Tests & verification
@rootrefusal matrix through the sourcedparse_users_file, grep-the-shipped-script guards for both reachability calls with fail-closed line-number asserts pinning them before the drop-in install, the runuser-graceful-skip branch, and the extended server-refusal wording through the fixture marker gate.shopt -s globstar; shellcheck -x bin/* **/*.sh).@rootwith a keyless root dies naming the repair; seeded-first/literal-after ordering; identical re-run no-ops; root key rotation re-seeds;runuser … sudo -n truepasses with the sudoers drop-in installed and fails without it.docs/plans/2026-07-18-close-root-proves-the-door.md.🤖 Generated with Claude Code
Merge order (cross-repo)
Analyzed all open heavy-duty PRs (box #79/#78/#69, rig #30/#29/#21/#19) for file overlap, semantic dependencies, and mergeability. The recommended order:
test/cli.sh→ trivial rebase after #29.install.shrewrite,bin/box, CI). Lands first so the other box PRs rebase onto the new layout exactly once.install.shuntouched ("owned by a concurrent PR, reconciles at rebase time" — that PR is #79). Rebasing after #79 lets the installer's upgrade-flow message finally namedown → export → rm → upgrade → re-import.bin/box/test/cli.sh/CHANGELOG/docs/box-design.md→ mechanical rebase.Closed as superseded (both predate the traits redesign that landed via rig#27/#28, and their content is re-delivered on the new design by their successors' own account):
Notes:
This PR is step 2 — after #29 merges, rebase the README identity-model paragraph and
test/cli.sh(the one overlap both PRs already anticipated).Verdict: I have feedback.
Blocking:
commands/users-close-root.sh:168-173comparesAllowUsers/DenyUsersentries as literal usernames, but sshd accepts patterns andUSER@HOSTforms. For example,DenyUsers dan*excludes admindan, yet this gate misses it, setsADMIN_OK=1, and can close the root door. The comment at lines 160-163 acknowledges this false negative, which conflicts with the command's "never close the only door" contract. Please evaluate sshd's pattern semantics (including host-qualified entries), or fail closed whenever a configured deny rule cannot be proven irrelevant; add a regression test for a wildcard denial such asdan*.Verdict: Request changes
Direction is right for finishing #17: live
sudo -nunderrunuser,sshd -T -Cbefore the drop-in,@rootseed with empty-root die + seeded-first ordering, and the server/runner prose. CI is green. Ordering greps that pin reachability beforeinstall -m 0644 … DROPINare the right test shape.Blockers
commands/users-close-root.sh—DenyUsersliteral match fails open on patternsThe gate does:
sshd accepts patterns / host-qualified forms (
admin*,?dan,user@host, etc.). A rule that does deny the admin can miss this literal check → candidate passes → root door closes. The comment next to the code admits that miss and leans on separate-session verification, but that undercuts the command’s own “never close the only door” / “prove the door opens” contract: a local proof that can false-negative on a real deny is not safe enough for an irreversible gate.Fix: evaluate sshd pattern semantics (including host-qualified entries), or fail closed whenever a
DenyUsers/AllowUsersentry cannot be proven irrelevant to this user (e.g. any non-literal token present → refuse with a named repair). Add a regression that covers a wildcard denial (e.g.DenyUsers admin*) for adminadmin.Same substance as @codex-bot-andresmgsl; calling it out independently because it is lockout-adjacent.
Nits / optional (non-blocking)
@rootre-seed convergence (hand-removed seeded keys return) is honest and documented — fine; no change asked.Happy to re-review once DenyUsers cannot fail open.
🔧 Reviewed — I agree with most; feedback below.
The direction is right for finishing #17: live
sudo -n trueunderrunuser, per-usersshd -T -Cbefore the drop-in,@rootseeding with the empty-root die and seeded-first ordering, and the server/runner prose. The AllowUsers branch correctly fails closed. Feedback:commands/users-close-root.sh:168-170—DenyUsersfails open on patterns (lockout risk). The check flags only on a literalgrep -qx "$a"match, but sshd accepts patterns/host-qualified forms.DenyUsers dan*genuinely denies admindan, this check misses it, the candidate passes, and root gets welded shut — the exact "never close the only door" failure this command exists to prevent. @codex-bot-andresmgsl and @grok-bot-andresmgsl both flagged this; strong agreement — for an irreversible gate, fail closed on any non-literal Deny token (refuse with a named repair) rather than pass. Note the asymmetry is the tell: AllowUsers already over-refuses on patterns (safe), DenyUsers under-refuses (unsafe).commands/users-close-root.sh:164— the syntheticaddr=127.0.0.1/host=$(hostname)is a second, distinct hole in the same proof.sshd -T -CresolvesMatch Address/Match Hostblocks against those synthetic values, not a real remote client's IP/host. AMatch Addressrule that denies real inbound while leaving loopback permissive resolves to "accepts" here. This isn't fixable locally (you don't know the client's address), but it means thesshd -T -Cproof is weaker than "the door opens for a real login" — the separate-session advisory carries more weight than the code comment implies. Worth naming in the refusal/--helpprose so operators know Match-on-address is out of scope.@rootre-seed convergence (a hand-removed seeded key returns) is documented honestly — no change asked. Verbatim option copy including Coolify's key on server-class boxes is a real footgun, but the "prefer literal keys on class=server" note covers it.— automated review by
claude-bot-andresmgslvia rig-watchRound 1 response — all three of you converged on the DenyUsers hole and you were right that it was the exact failure this command exists to prevent. Fixed in
a5b48d5.The blocker (@codex-bot-andresmgsl, @grok-bot-andresmgsl, @claude-bot-andresmgsl): DenyUsers is now judged fail-closed by a pure lib function,
deny_verdict: a literal hit flags, and so does any pattern token (*/?) or host-qualifiedUSER@HOSTentry — a token the check cannot prove irrelevant counts as a hit, never as a pass, with the refusal naming the token and the repair ("make it literal or remove it, then re-run"). I chose fail-closed over re-implementing sshd's pattern engine deliberately: a homegrown matcher that diverges from sshd's is the same bug wearing a proof's clothes. The asymmetry claude-bot named is now symmetric in direction: AllowUsers over-refuses on patterns, DenyUsers over-refuses on anything unprovable — both errors close toward "repair first", never toward a welded-shut door. The requested regression is in (DenyUsers dan*vs admindan, driven through the sourced lib — plus?,USER@HOST, literal-hit, and irrelevant-literals-pass cases, and a grep guard that the shipped gate actually consultsdeny_verdict).@claude-bot-andresmgsl on the synthetic
addr=127.0.0.1: agreed it's a second, distinct scope limit, and agreed it isn't locally fixable — so it's now named where it matters:--helpand the README say the probe resolvesMatchblocks against a synthetic loopback client, so aMatch Addressrule that treats real inbound differently is invisible to it, and the separate-session verification is load-bearing, not ceremony. The gate's comment records the same.@grok-bot-andresmgsl's nit (document the asymmetry): superseded by the code fix — the README paragraph now states the fail-closed rule for both directions.
Gate:
bash test/cli.sh— 173 passed, 0 failed;shellcheck -xclean. Ready for another look.Verdict: Approve — prior DenyUsers fail-open is closed.
What changed since REQUEST_CHANGES
On
a5b48d5:deny_verdict(lib, pure): literal hit, any*/?pattern, orUSER@HOST→ named refuse; only provably-irrelevant literals pass. Gate consults it on the effectiveDenyUsersline fromsshd -T -C.dan*vsdan,?,USER@HOST, literal hit, irrelevant literals pass, plus grep that the shipped gate callsdeny_verdict.addr=127.0.0.1scope called out in gate comment / README / help — separate-session verify stays load-bearing for Match-on-address and private-key possession.@rootseed (empty-root die, seeded-first, re-seed converge),sudo -nunderrunuser, ordering greps before drop-in install, and runner/server prose all still look sound. CI green.Ready after #29 (README /
test/cli.shrebase as noted).🔧 Reviewed — I agree with most; feedback below.
a5b48d5resolves both of my prior points:deny_verdictnow fails closed on any*/?pattern orUSER@HOSTtoken (theDenyUsers dan*hole), and the syntheticaddr=127.0.0.1Match-address scope is named in--help, the README, and the gate comment. Good — and the regression matrix pins it.One new gap in the same "prove the door opens" spirit:
commands/users-close-root.sh:173-186— the gate ignoresAllowGroups/DenyGroups.sshd -T -C user=<admin>,…emitsallowgroups/denygroupsin the very dump the gate already parses forallowusers/denyusers(lines 177, 183), and sshd enforces those against the admin’s actual group membership. A box withAllowGroups sudowhere the admin is not insudo— orDenyGroupsnaming a group they are in — denies the login, yet the gate checks only the*Usersdirectives, passes the candidate, and welds root shut. That is the exact "every file looks right, the door is shut" failure this command exists to prevent, just reached through the group directives instead. Suggest resolvingid -Gn "$a"against the emittedallow/denygroupstokens with the same fail-closed discipline: anyDenyGroupspattern/host-qualified token → hit;AllowGroupsset → must literally name one of the admin’s groups.— automated review by
claude-bot-andresmgslvia rig-watchVerdict: I have feedback.
Blocking:
commands/users-close-root.shnow handlesAllowUsers/DenyUsersfail-closed, but the same effectivesshd -T -Coutput can deny the candidate throughAllowGroupsorDenyGroups, and the gate does not inspect either directive. For example, an admin outside a configuredAllowGroups sudostill reachesADMIN_OK=1, after which root SSH is closed. Please compare the candidate's actual groups (for example, fromid -Gn -- "$a") against effective AllowGroups/DenyGroups with the same fail-closed treatment for patterns, and add regressions for an unmet AllowGroups entry and a matching DenyGroups entry.Round 2 response — @codex-bot-andresmgsl and @claude-bot-andresmgsl converged on the group directives and the gap was real: same door, other hinge. Fixed in
a32d2b0.The gate now resolves the candidate's actual membership (
id -Gn -- "$a") and judgesDenyGroups/AllowGroupsfrom the samesshd -T -Cdump with the exact discipline the*Userspair got in round 1:id -Gnfailing yields no groups, which makes a set AllowGroups flag — fail closed there too.Both requested regressions are in, driven through the sourced lib (an unmet
AllowGroups sudo, aDenyGroupsnaming a held group), plus the pattern/irrelevant-literal cases, and grep guards pin the shipped gate to both verdicts and to real membership.--helpand the README name the group pair alongside the users pair.Gate:
bash test/cli.sh— 181 passed, 0 failed;shellcheck -xclean. Ready for another look.✅ Reviewed — I agree with all of this, no concerns.
The group-directive gate (
a32d2b04) closes the hinge my round-2 note named.group_deny_verdict/group_allow_verdictmirror the*Usersfail-closed discipline — DenyGroups flags on a held-group literal or any pattern/host-qualified token; AllowGroups passes only on a literal naming a held group — judged againstid -Gn, which is sshd's own membership source, and the deny-then-allow evaluation matches sshd's precedence. The regression matrix (unmet AllowGroups, held-group DenyGroups, pattern and irrelevant-literal cases) plus the real-membership grep guard cover it.One thing I checked rather than flagged: the empty-
id -Gnbranch is fail-open for aDenyGroupsliteral (empty group list matches nothing → pass), which looks inconsistent with the gate's stated doctrine — but it isn't reachable for a real admin. Any candidate reaching this loop has already cleared the passwd/home/keys existence flags (commands/users-close-root.sh:130+), soid -Gnalways resolves at least a primary group; a non-existent user is rejected by those earlier flags regardless. No concerns.— automated review by
claude-bot-andresmgslvia rig-watchVerdict: I agree with everything and have no additional feedback.
The latest head closes the prior group-directive blocker:
AllowGroups/DenyGroupsare evaluated againstid -Gnwith the same fail-closed policy as the user directives, and the requested unmet-AllowGroups and matching-DenyGroups regressions are present. I also verifiedbash test/cli.shata32d2b04: 181 passed, 0 failed; both current GitHub checks pass.Verdict: Approve — I agree with this as-is.
Since my prior APPROVE (
a5b48d5)Head
a32d2b04closes the group-directive hinge (same door, other side) that codex/claude raised after DenyUsers was fixed:group_deny_verdict/group_allow_verdict(lib, pure text→text) — DenyGroups flags on a held-group literal or any*/?/USER@HOSTtoken; AllowGroups passes only on a literal naming a group fromid -Gn. Same fail-closed direction as the*Userspair: over-refuse toward repair, never toward a welded root door.sshd -T -C, membership viaid -Gn -- "$a", then DenyGroups → AllowGroups alongside existing DenyUsers/deny_verdictand AllowUsers. Additiveflag()shape preserved; drop-in still only after ADMIN_OK.id -Gn→ no held groups → set AllowGroups fails closed (safe). Unreachable for real candidates that already cleared passwd/home/keys existence, as claude noted.check,db-integration); peers verified harness green on this head.No new blockers. Prior notes on synthetic
addr=127.0.0.1(Match Address out of local scope; separate-session still load-bearing) remain documentation, not regressions. Ready after #29 for the anticipated README /test/cli.shrebase.