8.6 KiB
close-root proves the door + @root key seeding — Implementation Plan (finishes #17)
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Finish what remains of issue #17 on top of the merged fleet-users
design (#27). Main already ships the admin user (rig users apply — role
admin → rig-admin group, NOPASSWD sudo) and the role-aware root door
(rig users close-root — PermitRootLogin no gated on class=human, a
StrictModes-shaped admin check, and the invoker gate). Three gaps remain,
all named in #17: (1) close-root's gate proves the admin door should open,
not that it does — add the two reachability proofs #17 specifies
(sudo -n true under runuser, sshd -T -C user=<admin>); (2) #17's
headline lockout-avoidance — seed the admin's authorized_keys from root's
own, via a literal @root key-field token in the users file; (3) #17's
table said runner "can close root once an admin is proven", but main's class
model refuses close-root on class=server (runner) — reconcile in prose,
not code.
Why this shape: the admin-door gate exists so close-root never welds
shut the only door. But every check it makes today reads files: a sudoers
drop-in that failed to land, or an AllowUsers/Match block elsewhere in
sshd's config, leaves every file looking right while the door stays shut.
#17 names the two checks that interrogate behavior instead: sudo -n true
run as the admin (NOPASSWD sudo answers, or it doesn't — -n never
prompts), and sshd -T -C user=<admin>,host=...,addr=... (the per-user
EFFECTIVE config sshd would apply to exactly that login, Match blocks
resolved). And the one thing no local check can prove — that the operator
holds the admin's private key — is what @root answers at apply time: the
operator is connected as root right now using one of root's keys, so
seeding those keys is live evidence the private key is in their hands,
strictly better than any check rig could invent over a pasted literal.
The measured lock-root table (from #17, verbatim constraints)
| Measure | Key-based root SSH after | Verdict |
|---|---|---|
passwd -l root |
works | harmless |
PermitRootLogin prohibit-password |
works | safe — bootstrap's state |
usermod --expiredate 1 root |
breaks | never |
| root shell → nologin | breaks | never |
PermitRootLogin no |
breaks root only | close-root's move, gated |
Nothing here may introduce usermod --expiredate or a nologin shell for
root — both break key SSH and rig's own root-run convergence path.
Architecture
commands/users-close-root.sh: the reachability proofs join the existing per-candidate gate loop as additiveflag()checks — same refusal shape, naming the failing check per candidate.runusermay be absent off-Debian: precomputed once; absence skips the sudo proof with a loudwarn, never a die (a missing prover must not block the door — but the operator is told what to verify by hand).sshd -T -Cfailing is a flag: fail closed. Allow/Deny entries are matched literally — a pattern that would admit the admin still refuses (fail closed; the operator proves patterns by hand). Both proofs sit before the drop-in install, pinned by line-number ordering asserts.commands/lib/users-config.sh(parse_users_file): the key field admits the literal token@root— shape-validated in the parse pass (exit 2, pre-root-check, testable non-root): exactly@root, no trailing material; a second@rootfor one user falls into the existing duplicate-line refusal for free;rootas username stays refused. Mixing semantics (the simplest sound call):@rootmixes with literal key lines — seeded keys land FIRST, literals append after, fixed order so the cmp-guard sees deterministic bytes.commands/users-apply.sh: resolves@rootONCE, after the root check (/root/.ssh needs root): root's currentauthorized_keys, comments and blanks dropped, key lines copied verbatim (options included — afrom=/command=restriction follows its key; rig will not silently widen what a key can do). Empty/absent root keys die with the repair. Convergence: every run re-seeds from root's then-current file — the honest exception #17 weighs (a hand-removed seeded key returns) is resolved toward convergence, with "switch the line to literal keys" as the escape hatch, documented in usage and README.- The runner row: no gate change. The
class=serverrefusal inassert_marker_humangrows the explanation (server-class machines are automation identities; root is the management plane; runner stays server-class deliberately;--class humanat bootstrap is the path for a humanly-administered CI box), and the README identity-model section gets one short divergence paragraph.
Tech Stack: bash only, shellcheck, existing ci.yml (globstar
shellcheck + bash test/cli.sh) — no workflow change.
Non-Goals
- No change to the class gate:
class=serverstill refuses close-root, no--force, runner included. - No
--admin-keyflag (#17's option (b)) — the users file already carries literal keys;@rootcovers the lockout case. - No filtering/rewriting of root's key options on seed (no stripping
from=/command=) — verbatim copy, documented caveat. - No root
authorized_keysmanagement (Coolify owns its key material). - No pattern-matching engine for
AllowUsers/DenyUsers— literal match, fail closed, documented.
Global Constraints
set -euo pipefail;rig-users:log/warn/die prefixes; exit 2 usage (pre-root-check), exit 1 runtime refusal; cmp-guarded convergent writes.- Never
usermod --expiredate 1 root, never a nologin shell for root. - shellcheck-clean as CI runs it (
shopt -s globstar; shellcheck -x bin/* **/*.sh);bash test/cli.shgreen as non-root.
Task 1: reachability proofs in close-root's gate
Files: commands/users-close-root.sh, test/cli.sh
- Tests: grep the two calls (
runuser -u "$a" -- sudo -n true,sshd -T -C "user=) and pin both before the drop-in install line (install -m 0644 "$TMP" "$DROPIN") with fail-closed line-number asserts; grep the graceful runuser-absent branch. - Implement: per-candidate flags after the shape checks;
HAVE_RUNUSERprecomputed with a single warn;sshd -T -Cparsed forpubkeyauthentication yes, literal DenyUsers hit, AllowUsers-without- the-admin; resolve failure flags. bash test/cli.shgreen; shellcheck clean.
Task 2: @root seeding
Files: commands/lib/users-config.sh, commands/users-apply.sh,
test/cli.sh
- Tests through the sourced
parse()harness: exact token parses and emitsuser|roles|@root; trailing material refused; mixes with literal lines; second@rootis a duplicate; root cannot seed itself; an@rootfixture reaches the non-root refusal (proving parse-pass validation); grep apply's keyless-root die. - Implement: parser token cases; apply resolves post-root-check, dies on empty; seeded-first/literals-after write through the existing cmp-guard; usage documents the semantics.
bash test/cli.shgreen; shellcheck clean.
Task 3: the runner row, reconciled in prose
Files: commands/lib/users-config.sh (refusal message), README.md,
test/cli.sh
- Extend the
class=serverrefusal to own the divergence ("runner included"); fixture test asserts the new wording alongside the existing "control plane" assert. - README: one divergence paragraph in the identity-model section; the
@rootparagraph in the apply section; the reachability sentence in the close-root section. Surgical — a concurrent PR adds a class table to the same README area.
Test Plan
- Harness: the
@rootrefusal matrix through the sourced parser (non-root, network-free); grep-the-shipped-script guards for both reachability calls with before-the-drop-in ordering asserts; the extended server-refusal wording through the fixture marker gate. - Rehearsal (manual, out of harness — the #17 shape): throwaway cloud
box: bootstrap
--class human;users applya file whose admin line is@root; from a second terminal, SSH in as the admin with the SAME key used for root (the seeded one) — the inbound connection is the proof rig cannot self-assert;close-root; confirm root refused, admin accepted; re-run apply and close-root — both no-op; add a literal key line, re-run, confirm seeded-first ordering and convergence.