From 2396880f09dbb7f323881df7f97ff7778f6c728c Mon Sep 17 00:00:00 2001 From: dan-claude-bot Date: Sat, 18 Jul 2026 14:54:03 +0000 Subject: [PATCH] =?UTF-8?q?docs:=20plan=20=E2=80=94=20close-root=20proves?= =?UTF-8?q?=20the=20door=20+=20@root=20seeding,=20what=20remained=20of=20#?= =?UTF-8?q?17?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .../2026-07-18-close-root-proves-the-door.md | 161 ++++++++++++++++++ 1 file changed, 161 insertions(+) create mode 100644 docs/plans/2026-07-18-close-root-proves-the-door.md diff --git a/docs/plans/2026-07-18-close-root-proves-the-door.md b/docs/plans/2026-07-18-close-root-proves-the-door.md new file mode 100644 index 0000000..93040ae --- /dev/null +++ b/docs/plans/2026-07-18-close-root-proves-the-door.md @@ -0,0 +1,161 @@ +# 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=`); (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=,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 **additive** `flag()` checks — same refusal + shape, naming the failing check per candidate. `runuser` may be absent + off-Debian: precomputed once; absence skips the sudo proof with a loud + `warn`, never a die (a missing prover must not block the door — but the + operator is told what to verify by hand). `sshd -T -C` failing *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 `@root` for one user falls into the existing duplicate-line + refusal for free; `root` as username stays refused. Mixing semantics + (the simplest sound call): `@root` mixes 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 `@root` ONCE, after the root check + (/root/.ssh needs root): root's current `authorized_keys`, comments and + blanks dropped, key lines copied verbatim (options included — a + `from=`/`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=server` refusal in + `assert_marker_human` grows the explanation (server-class machines are + automation identities; root is the management plane; runner stays + server-class deliberately; `--class human` at 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=server` still refuses close-root, no + `--force`, runner included. +- No `--admin-key` flag (#17's option (b)) — the users file already carries + literal keys; `@root` covers the lockout case. +- No filtering/rewriting of root's key options on seed (no stripping + `from=`/`command=`) — verbatim copy, documented caveat. +- No root `authorized_keys` management (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.sh` green as non-root. + +--- + +### Task 1: reachability proofs in close-root's gate + +**Files:** `commands/users-close-root.sh`, `test/cli.sh` + +- [x] 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. +- [x] Implement: per-candidate flags after the shape checks; `HAVE_RUNUSER` + precomputed with a single warn; `sshd -T -C` parsed for + `pubkeyauthentication yes`, literal DenyUsers hit, AllowUsers-without- + the-admin; resolve failure flags. +- [x] `bash test/cli.sh` green; shellcheck clean. + +### Task 2: `@root` seeding + +**Files:** `commands/lib/users-config.sh`, `commands/users-apply.sh`, +`test/cli.sh` + +- [x] Tests through the sourced `parse()` harness: exact token parses and + emits `user|roles|@root`; trailing material refused; mixes with literal + lines; second `@root` is a duplicate; root cannot seed itself; an + `@root` fixture reaches the non-root refusal (proving parse-pass + validation); grep apply's keyless-root die. +- [x] 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. +- [x] `bash test/cli.sh` green; shellcheck clean. + +### Task 3: the runner row, reconciled in prose + +**Files:** `commands/lib/users-config.sh` (refusal message), `README.md`, +`test/cli.sh` + +- [x] Extend the `class=server` refusal to own the divergence ("runner + included"); fixture test asserts the new wording alongside the existing + "control plane" assert. +- [x] README: one divergence paragraph in the identity-model section; the + `@root` paragraph 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 `@root` refusal 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 apply` a 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.