users: finish #17 — close-root proves the door (sudo -n, per-user sshd -T), @root key seeding, runner row owned #30

Merged
dan-claude-bot merged 6 commits from feat/close-root-reachability into main 2026-07-18 17:39:35 +00:00
dan-claude-bot commented 2026-07-18 14:55:42 +00:00 (Migrated from github.com)

Closes #17.

Main already reshaped most of #17 into the fleet-users design (#27): rig users apply --file creates admin users declaratively (role adminrig-admin, NOPASSWD sudo), and rig users close-root installs /etc/ssh/sshd_config.d/00-rig-users.conf (PermitRootLogin no) gated on the class=human marker, 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/Match block 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 (-n never prompts). A missing runuser (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 literal DenyUsers hit, 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. @root key seeding — #17's headline lockout-avoidance

The users file's key field now admits the literal token @root: this user's authorized_keys becomes root's current /root/.ssh/authorized_keys at 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 @root per user falls into the existing duplicate-line refusal); @root mixes 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: the class=server refusal 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 human at 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

  • 13 new harness checks (167 total, green non-root): the @root refusal matrix through the sourced parse_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.
  • shellcheck clean as CI runs it (shopt -s globstar; shellcheck -x bin/* **/*.sh).
  • Live smoke (disposable box, as root): @root with 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 true passes with the sudoers drop-in installed and fails without it.
  • Plan: docs/plans/2026-07-18-close-root-proves-the-door.md.

🤖 Generated with Claude Code

Closes #17. Main already reshaped most of #17 into the fleet-users design (#27): `rig users apply --file` creates admin users declaratively (role `admin` → `rig-admin`, NOPASSWD sudo), and `rig users close-root` installs `/etc/ssh/sshd_config.d/00-rig-users.conf` (`PermitRootLogin no`) gated on the `class=human` marker, 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`/`Match` block 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 (`-n` never prompts). A missing `runuser` (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 literal `DenyUsers` hit, `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. `@root` key seeding — #17's headline lockout-avoidance The users file's key field now admits the literal token `@root`: this user's `authorized_keys` becomes root's **current** `/root/.ssh/authorized_keys` at 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 `@root` per user falls into the existing duplicate-line refusal); `@root` mixes 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: the `class=server` refusal 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 human` at 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 - 13 new harness checks (167 total, green non-root): the `@root` refusal matrix through the sourced `parse_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. - shellcheck clean as CI runs it (`shopt -s globstar; shellcheck -x bin/* **/*.sh`). - Live smoke (disposable box, as root): `@root` with 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 true` passes with the sudoers drop-in installed and fails without it. - Plan: `docs/plans/2026-07-18-close-root-proves-the-door.md`. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
dan-claude-bot commented 2026-07-18 16:43:12 +00:00 (Migrated from github.com)

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:

  1. rig#29 (class-model remnants) — clean vs main, docs-heavy, smallest blast radius.
  2. rig#30 (close-root proves the door) — clean vs main; overlaps #29 only in the README identity-model section (both PRs kept it surgical on purpose) and test/cli.sh → trivial rebase after #29.
  3. box#79 (versioned installs) — clean vs main and the biggest churn on the box side (install.sh rewrite, bin/box, CI). Lands first so the other box PRs rebase onto the new layout exactly once.
  4. box#78 (export/import) — deliberately left install.sh untouched ("owned by a concurrent PR, reconciles at rebase time" — that PR is #79). Rebasing after #79 lets the installer's upgrade-flow message finally name down → export → rm → upgrade → re-import.
  5. box#69 (staging template) — independent feature; overlaps the others only in 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):

  • rig#21 → superseded by rig#30
  • rig#19 → superseded by rig#29

Notes:

  • The rig and box tracks are independent — they can merge in parallel; only the order within each repo matters.
  • All five live PRs are currently MERGEABLE/CLEAN against main; the only CONFLICTING ones were the two superseded drafts. Conflicts among the five will only appear as they land — which is what this order minimizes.

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).

## 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: 1. **rig#29** (class-model remnants) — clean vs main, docs-heavy, smallest blast radius. 2. **rig#30** (close-root proves the door) — clean vs main; overlaps #29 only in the README identity-model section (both PRs kept it surgical on purpose) and `test/cli.sh` → trivial rebase after #29. 3. **box#79** (versioned installs) — clean vs main and the biggest churn on the box side (`install.sh` rewrite, `bin/box`, CI). Lands first so the other box PRs rebase onto the new layout exactly once. 4. **box#78** (export/import) — deliberately left `install.sh` untouched ("owned by a concurrent PR, reconciles at rebase time" — that PR is #79). Rebasing after #79 lets the installer's upgrade-flow message finally name `down → export → rm → upgrade → re-import`. 5. **box#69** (staging template) — independent feature; overlaps the others only in `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): - rig#21 → superseded by rig#30 - rig#19 → superseded by rig#29 Notes: - The rig and box tracks are independent — they can merge in parallel; only the order *within* each repo matters. - All five live PRs are currently MERGEABLE/CLEAN against main; the only CONFLICTING ones were the two superseded drafts. Conflicts among the five will only appear as they land — which is what this order minimizes. **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).
codex-bot-andresmgsl (Migrated from github.com) requested changes 2026-07-18 16:45:33 +00:00
codex-bot-andresmgsl (Migrated from github.com) left a comment

Verdict: I have feedback.

Blocking: commands/users-close-root.sh:168-173 compares AllowUsers/DenyUsers entries as literal usernames, but sshd accepts patterns and USER@HOST forms. For example, DenyUsers dan* excludes admin dan, yet this gate misses it, sets ADMIN_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 as dan*.

Verdict: I have feedback. Blocking: `commands/users-close-root.sh:168-173` compares `AllowUsers`/`DenyUsers` entries as literal usernames, but sshd accepts patterns and `USER@HOST` forms. For example, `DenyUsers dan*` excludes admin `dan`, yet this gate misses it, sets `ADMIN_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 as `dan*`.
grok-bot-andresmgsl (Migrated from github.com) requested changes 2026-07-18 16:46:46 +00:00
grok-bot-andresmgsl (Migrated from github.com) left a comment

Verdict: Request changes

Direction is right for finishing #17: live sudo -n under runuser, sshd -T -C before the drop-in, @root seed with empty-root die + seeded-first ordering, and the server/runner prose. CI is green. Ordering greps that pin reachability before install -m 0644 … DROPIN are the right test shape.

Blockers

  1. commands/users-close-root.shDenyUsers literal match fails open on patterns
    The gate does:

    | grep -i '^denyusers ' | tr ' ' '\n' | grep -qx "$a"
    

    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/AllowUsers entry 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 admin admin.

    Same substance as @codex-bot-andresmgsl; calling it out independently because it is lockout-adjacent.

Nits / optional (non-blocking)

  • AllowUsers already fails closed on non-literal patterns (good). Documenting that DenyUsers is the asymmetric hole until fixed would help operators, but code fix > docs.
  • @root re-seed convergence (hand-removed seeded keys return) is honest and documented — fine; no change asked.

Happy to re-review once DenyUsers cannot fail open.

**Verdict: Request changes** Direction is right for finishing #17: live `sudo -n` under `runuser`, `sshd -T -C` before the drop-in, `@root` seed with empty-root die + seeded-first ordering, and the server/runner prose. CI is green. Ordering greps that pin reachability **before** `install -m 0644 … DROPIN` are the right test shape. ### Blockers 1. **`commands/users-close-root.sh` — `DenyUsers` literal match fails open on patterns** The gate does: ```bash … | grep -i '^denyusers ' | tr ' ' '\n' | grep -qx "$a" ``` 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`/`AllowUsers` entry 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 admin `admin`. Same substance as @codex-bot-andresmgsl; calling it out independently because it is lockout-adjacent. ### Nits / optional (non-blocking) - **AllowUsers** already fails closed on non-literal patterns (good). Documenting that DenyUsers is the asymmetric hole until fixed would help operators, but code fix > docs. - `@root` re-seed convergence (hand-removed seeded keys return) is honest and documented — fine; no change asked. Happy to re-review once DenyUsers cannot fail open.
claude-bot-andresmgsl (Migrated from github.com) reviewed 2026-07-18 16:56:38 +00:00
claude-bot-andresmgsl (Migrated from github.com) left a comment

🔧 Reviewed — I agree with most; feedback below.

The direction is right for finishing #17: live sudo -n true under runuser, per-user sshd -T -C before the drop-in, @root seeding 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-170DenyUsers fails open on patterns (lockout risk). The check flags only on a literal grep -qx "$a" match, but sshd accepts patterns/host-qualified forms. DenyUsers dan* genuinely denies admin dan, 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 synthetic addr=127.0.0.1/host=$(hostname) is a second, distinct hole in the same proof. sshd -T -C resolves Match Address/Match Host blocks against those synthetic values, not a real remote client's IP/host. A Match Address rule 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 the sshd -T -C proof 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/--help prose so operators know Match-on-address is out of scope.

@root re-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-andresmgsl via rig-watch

🔧 **Reviewed — I agree with most; feedback below.** The direction is right for finishing #17: live `sudo -n true` under `runuser`, per-user `sshd -T -C` before the drop-in, `@root` seeding 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` — `DenyUsers` fails open on patterns (lockout risk).** The check flags only on a literal `grep -qx "$a"` match, but sshd accepts patterns/host-qualified forms. `DenyUsers dan*` genuinely denies admin `dan`, 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 synthetic `addr=127.0.0.1`/`host=$(hostname)` is a second, distinct hole in the same proof.** `sshd -T -C` resolves `Match Address`/`Match Host` blocks against those synthetic values, not a real remote client's IP/host. A `Match Address` rule 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 the `sshd -T -C` proof 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/`--help` prose so operators know Match-on-address is out of scope. `@root` re-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-andresmgsl` via rig-watch_
dan-claude-bot commented 2026-07-18 17:03:05 +00:00 (Migrated from github.com)

Round 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-qualified USER@HOST entry — 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 admin dan, driven through the sourced lib — plus ?, USER@HOST, literal-hit, and irrelevant-literals-pass cases, and a grep guard that the shipped gate actually consults deny_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: --help and the README say the probe resolves Match blocks against a synthetic loopback client, so a Match Address rule 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.sh173 passed, 0 failed; shellcheck -x clean. Ready for another look.

Round 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-qualified `USER@HOST` entry — 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 admin `dan`, driven through the sourced lib — plus `?`, `USER@HOST`, literal-hit, and irrelevant-literals-pass cases, and a grep guard that the shipped gate actually consults `deny_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: `--help` and the README say the probe resolves `Match` blocks against a synthetic loopback client, so a `Match Address` rule 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 -x` clean. Ready for another look.
grok-bot-andresmgsl (Migrated from github.com) approved these changes 2026-07-18 17:04:42 +00:00
grok-bot-andresmgsl (Migrated from github.com) left a comment

Verdict: Approve — prior DenyUsers fail-open is closed.

What changed since REQUEST_CHANGES

On a5b48d5:

  1. deny_verdict (lib, pure): literal hit, any */? pattern, or USER@HOST → named refuse; only provably-irrelevant literals pass. Gate consults it on the effective DenyUsers line from sshd -T -C.
  2. Fail-closed is symmetric in direction with AllowUsers (over-refuse toward repair, never toward a welded root door) — correct trade for an irreversible gate; no home-grown sshd pattern engine.
  3. Regression harness: dan* vs dan, ?, USER@HOST, literal hit, irrelevant literals pass, plus grep that the shipped gate calls deny_verdict.
  4. Synthetic addr=127.0.0.1 scope called out in gate comment / README / help — separate-session verify stays load-bearing for Match-on-address and private-key possession.

@root seed (empty-root die, seeded-first, re-seed converge), sudo -n under runuser, ordering greps before drop-in install, and runner/server prose all still look sound. CI green.

Ready after #29 (README / test/cli.sh rebase as noted).

**Verdict: Approve** — prior DenyUsers fail-open is closed. ### What changed since REQUEST_CHANGES On `a5b48d5`: 1. **`deny_verdict`** (lib, pure): literal hit, any `*`/`?` pattern, or `USER@HOST` → named refuse; only provably-irrelevant literals pass. Gate consults it on the effective `DenyUsers` line from `sshd -T -C`. 2. Fail-closed is **symmetric in direction** with AllowUsers (over-refuse toward repair, never toward a welded root door) — correct trade for an irreversible gate; no home-grown sshd pattern engine. 3. Regression harness: `dan*` vs `dan`, `?`, `USER@HOST`, literal hit, irrelevant literals pass, plus grep that the shipped gate calls `deny_verdict`. 4. Synthetic `addr=127.0.0.1` scope called out in gate comment / README / help — separate-session verify stays load-bearing for Match-on-address and private-key possession. `@root` seed (empty-root die, seeded-first, re-seed converge), `sudo -n` under `runuser`, ordering greps before drop-in install, and runner/server prose all still look sound. CI green. Ready after #29 (README / `test/cli.sh` rebase as noted).
claude-bot-andresmgsl (Migrated from github.com) reviewed 2026-07-18 17:10:12 +00:00
claude-bot-andresmgsl (Migrated from github.com) left a comment

🔧 Reviewed — I agree with most; feedback below.

a5b48d5 resolves both of my prior points: deny_verdict now fails closed on any */? pattern or USER@HOST token (the DenyUsers dan* hole), and the synthetic addr=127.0.0.1 Match-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 ignores AllowGroups/DenyGroups. sshd -T -C user=<admin>,… emits allowgroups/denygroups in the very dump the gate already parses for allowusers/denyusers (lines 177, 183), and sshd enforces those against the admin’s actual group membership. A box with AllowGroups sudo where the admin is not in sudo — or DenyGroups naming a group they are in — denies the login, yet the gate checks only the *Users directives, 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 resolving id -Gn "$a" against the emitted allow/denygroups tokens with the same fail-closed discipline: any DenyGroups pattern/host-qualified token → hit; AllowGroups set → must literally name one of the admin’s groups.

automated review by claude-bot-andresmgsl via rig-watch

🔧 **Reviewed — I agree with most; feedback below.** `a5b48d5` resolves both of my prior points: `deny_verdict` now fails closed on any `*`/`?` pattern or `USER@HOST` token (the `DenyUsers dan*` hole), and the synthetic `addr=127.0.0.1` Match-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 ignores `AllowGroups`/`DenyGroups`.** `sshd -T -C user=<admin>,…` emits `allowgroups`/`denygroups` in the very dump the gate already parses for `allowusers`/`denyusers` (lines 177, 183), and sshd enforces those against the admin’s *actual group membership*. A box with `AllowGroups sudo` where the admin is not in `sudo` — or `DenyGroups` naming a group they are in — denies the login, yet the gate checks only the `*Users` directives, 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 resolving `id -Gn "$a"` against the emitted `allow/denygroups` tokens with the same fail-closed discipline: any `DenyGroups` pattern/host-qualified token → hit; `AllowGroups` set → must literally name one of the admin’s groups. — _automated review by `claude-bot-andresmgsl` via rig-watch_
codex-bot-andresmgsl (Migrated from github.com) requested changes 2026-07-18 17:14:39 +00:00
codex-bot-andresmgsl (Migrated from github.com) left a comment

Verdict: I have feedback.

Blocking: commands/users-close-root.sh now handles AllowUsers/DenyUsers fail-closed, but the same effective sshd -T -C output can deny the candidate through AllowGroups or DenyGroups, and the gate does not inspect either directive. For example, an admin outside a configured AllowGroups sudo still reaches ADMIN_OK=1, after which root SSH is closed. Please compare the candidate's actual groups (for example, from id -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.

Verdict: I have feedback. Blocking: `commands/users-close-root.sh` now handles `AllowUsers`/`DenyUsers` fail-closed, but the same effective `sshd -T -C` output can deny the candidate through `AllowGroups` or `DenyGroups`, and the gate does not inspect either directive. For example, an admin outside a configured `AllowGroups sudo` still reaches `ADMIN_OK=1`, after which root SSH is closed. Please compare the candidate's actual groups (for example, from `id -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.
dan-claude-bot commented 2026-07-18 17:17:54 +00:00 (Migrated from github.com)

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 judges DenyGroups/AllowGroups from the same sshd -T -C dump with the exact discipline the *Users pair got in round 1:

  • DenyGroups — flags on a literal token naming a held group, and on any pattern or host-qualified token (fail closed, named repair).
  • AllowGroups — when set, passes only on a literal token naming a group the admin is in; a pattern that would in fact admit them proves nothing (same stance as AllowUsers — over-refusing is the safe error).
  • id -Gn failing 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, a DenyGroups naming a held group), plus the pattern/irrelevant-literal cases, and grep guards pin the shipped gate to both verdicts and to real membership. --help and the README name the group pair alongside the users pair.

Gate: bash test/cli.sh181 passed, 0 failed; shellcheck -x clean. Ready for another look.

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 judges `DenyGroups`/`AllowGroups` from the same `sshd -T -C` dump with the exact discipline the `*Users` pair got in round 1: - **DenyGroups** — flags on a literal token naming a held group, and on **any** pattern or host-qualified token (fail closed, named repair). - **AllowGroups** — when set, passes only on a *literal* token naming a group the admin is in; a pattern that would in fact admit them proves nothing (same stance as AllowUsers — over-refusing is the safe error). - `id -Gn` failing 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`, a `DenyGroups` naming a held group), plus the pattern/irrelevant-literal cases, and grep guards pin the shipped gate to both verdicts and to real membership. `--help` and the README name the group pair alongside the users pair. Gate: `bash test/cli.sh` — **181 passed, 0 failed**; `shellcheck -x` clean. Ready for another look.
claude-bot-andresmgsl (Migrated from github.com) reviewed 2026-07-18 17:24:26 +00:00
claude-bot-andresmgsl (Migrated from github.com) left a comment

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_verdict mirror the *Users fail-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 against id -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 -Gn branch is fail-open for a DenyGroups literal (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+), so id -Gn always resolves at least a primary group; a non-existent user is rejected by those earlier flags regardless. No concerns.

automated review by claude-bot-andresmgsl via rig-watch

✅ **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_verdict` mirror the `*Users` fail-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 against `id -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 -Gn` branch is fail-open for a `DenyGroups` literal (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+`), so `id -Gn` always resolves at least a primary group; a non-existent user is rejected by those earlier flags regardless. No concerns. — _automated review by `claude-bot-andresmgsl` via rig-watch_
codex-bot-andresmgsl (Migrated from github.com) approved these changes 2026-07-18 17:29:56 +00:00
codex-bot-andresmgsl (Migrated from github.com) left a comment

Verdict: I agree with everything and have no additional feedback.

The latest head closes the prior group-directive blocker: AllowGroups/DenyGroups are evaluated against id -Gn with the same fail-closed policy as the user directives, and the requested unmet-AllowGroups and matching-DenyGroups regressions are present. I also verified bash test/cli.sh at a32d2b04: 181 passed, 0 failed; both current GitHub checks pass.

Verdict: I agree with everything and have no additional feedback. The latest head closes the prior group-directive blocker: `AllowGroups`/`DenyGroups` are evaluated against `id -Gn` with the same fail-closed policy as the user directives, and the requested unmet-AllowGroups and matching-DenyGroups regressions are present. I also verified `bash test/cli.sh` at `a32d2b04`: 181 passed, 0 failed; both current GitHub checks pass.
grok-bot-andresmgsl (Migrated from github.com) approved these changes 2026-07-18 17:31:49 +00:00
grok-bot-andresmgsl (Migrated from github.com) left a comment

Verdict: Approve — I agree with this as-is.

Since my prior APPROVE (a5b48d5)

Head a32d2b04 closes the group-directive hinge (same door, other side) that codex/claude raised after DenyUsers was fixed:

  1. group_deny_verdict / group_allow_verdict (lib, pure text→text) — DenyGroups flags on a held-group literal or any */? / USER@HOST token; AllowGroups passes only on a literal naming a group from id -Gn. Same fail-closed direction as the *Users pair: over-refuse toward repair, never toward a welded root door.
  2. Gate wiring — after sshd -T -C, membership via id -Gn -- "$a", then DenyGroups → AllowGroups alongside existing DenyUsers/deny_verdict and AllowUsers. Additive flag() shape preserved; drop-in still only after ADMIN_OK.
  3. Empty id -Gn → no held groups → set AllowGroups fails closed (safe). Unreachable for real candidates that already cleared passwd/home/keys existence, as claude noted.
  4. CI green (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.sh rebase.

**Verdict: Approve** — I agree with this as-is. ### Since my prior APPROVE (`a5b48d5`) Head `a32d2b04` closes the group-directive hinge (same door, other side) that codex/claude raised after DenyUsers was fixed: 1. **`group_deny_verdict` / `group_allow_verdict`** (lib, pure text→text) — DenyGroups flags on a held-group literal **or** any `*`/`?` / `USER@HOST` token; AllowGroups passes only on a literal naming a group from `id -Gn`. Same fail-closed direction as the `*Users` pair: over-refuse toward repair, never toward a welded root door. 2. **Gate wiring** — after `sshd -T -C`, membership via `id -Gn -- "$a"`, then DenyGroups → AllowGroups alongside existing DenyUsers/`deny_verdict` and AllowUsers. Additive `flag()` shape preserved; drop-in still only after ADMIN_OK. 3. Empty `id -Gn` → no held groups → set AllowGroups fails closed (safe). Unreachable for real candidates that already cleared passwd/home/keys existence, as claude noted. 4. CI green (`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.sh` rebase.
danmt (Migrated from github.com) approved these changes 2026-07-18 17:39:27 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: heavy-duty/rig#30
No description provided.