fix: don't read a missing /run/sshd as a broken sshd config #93

Merged
claude-bot-andresmgsl merged 1 commit from fix/sshd-privsep-verdict into main 2026-07-21 13:07:35 +00:00
claude-bot-andresmgsl commented 2026-07-20 17:58:42 +00:00 (Migrated from github.com)

Fixes #92.

The bug

sshd -t folds two questions into one exit code: is the merged config parseable, and is the privilege-separation directory there. Both validate-before-restart call sites ran it as sshd -t 2>/dev/null and read any non-zero exit as the first question's answer — discarding the one line that named the second.

The operator saw a verdict sshd never reached:

rig-bootstrap: ERROR: sshd rejects the merged config; drop-in rolled back, daemon untouched.
Run 'sshd -t' to see which file is bad.

# sshd -t
Missing privilege separation directory: /run/sshd

...and the hint sent them to audit /etc/ssh files that were never broken, on a box whose SSH door was serving their own session at that moment.

/run is a tmpfs and /run/sshd is ssh.service's RuntimeDirectory, which systemd removes when that unit stops — so it is legitimately absent under socket activation (ssh.socket, the default on current Debian/Ubuntu). Not an exotic state; reported from a box mid-bootstrap right after an openssh-server upgrade.

The change

  • sshd_privsep_gap <status> <stderr> — pure and sourceable (precedent: parse_users_file, deny_verdict), so the distinction is provable non-root and without a live sshd. The status is the verdict and the text only classifies a failure: a passing sshd -t is never diverted, whatever its output happens to say.
  • sshd_config_ok — repairs the gap with an idempotent install -d and retests once, leaving sshd's stderr in $sshd_err. The repair creates exactly what systemd would and is not meant to outlive a reboot.
  • Refusals now quote sshd. The old message asserted a cause and withheld the evidence for it. Every variant of this bug is self-diagnosing the moment stderr is surfaced.
  • users close-root reaches the shared judgement through lib/sshd.sh instead of keeping a second copy. It had the identical three lines — the more dangerous copy, since it is the command that shuts the root door. That duplication is exactly what #31 extracted the lib to prevent, and it is why this bug shipped twice.

A genuine parse refusal still refuses, and the rollback is untouched — the daemon is never bounced into a config it rejects. That was the property worth not breaking.

Tests

Eight new assertions in test/cli.sh (553 → 561), written before the fix and watched fail. The classifier is exercised directly rather than grepped, including the edge the design turns on:

  • a missing privsep dir is not a config verdict
  • a genuine parse refusal stays a config verdict
  • a passing sshd -t is never read as a privsep gap
  • both refusals quote sshd's own stderr
  • the repair exists once, in the lib — with close-root pinned to the sharing (source line + call) and asserted not to carry a second copy

The two ordering asserts that grepped if ! sshd -t now grep if ! sshd_config_ok; validate-then-restart is still pinned at both sites.

Beyond the suite, the repair path was exercised end-to-end against a stub sshd in a private mount namespace with a tmpfs /run — the actual reported precondition, which cannot be reached in-suite without root:

condition before after
/run/sshd absent, config fine refused (false verdict) repaired, validated, proceeds
config genuinely broken refused, cause hidden refused, cause quoted, no dir created

Checks

shellcheck -x (CI's exact glob) clean; test/cli.sh 561 passed / 0 failed; test/release.sh 68 / 0; test/labels-reconcile.sh 51 / 0.

Changelog entry is under ## Unreleased### Fixed.


Opened as a draft per CONTRIBUTING step 2.

One thing I cannot do myself: step 3 asks for all three bots — claude-bot-andresmgsl, codex-bot-andresmgsl, grok-bot-andresmgsl. This PR is authored by claude-bot-andresmgsl, and GitHub does not allow requesting your own review, so that panel seat needs either a different reviewer or a maintainer's call on whether two bots plus the human is the round here.

Fixes #92. ## The bug `sshd -t` folds two questions into one exit code: is the merged config parseable, and is the privilege-separation directory there. Both validate-before-restart call sites ran it as `sshd -t 2>/dev/null` and read **any** non-zero exit as the first question's answer — discarding the one line that named the second. The operator saw a verdict sshd never reached: ``` rig-bootstrap: ERROR: sshd rejects the merged config; drop-in rolled back, daemon untouched. Run 'sshd -t' to see which file is bad. # sshd -t Missing privilege separation directory: /run/sshd ``` ...and the hint sent them to audit `/etc/ssh` files that were never broken, on a box whose SSH door was serving their own session at that moment. `/run` is a tmpfs and `/run/sshd` is `ssh.service`'s `RuntimeDirectory`, which systemd removes when that unit stops — so it is legitimately absent under socket activation (`ssh.socket`, the default on current Debian/Ubuntu). Not an exotic state; reported from a box mid-bootstrap right after an `openssh-server` upgrade. ## The change - **`sshd_privsep_gap <status> <stderr>`** — pure and sourceable (precedent: `parse_users_file`, `deny_verdict`), so the distinction is provable non-root and without a live sshd. The **status** is the verdict and the text only classifies a *failure*: a passing `sshd -t` is never diverted, whatever its output happens to say. - **`sshd_config_ok`** — repairs the gap with an idempotent `install -d` and retests once, leaving sshd's stderr in `$sshd_err`. The repair creates exactly what systemd would and is not meant to outlive a reboot. - **Refusals now quote sshd.** The old message asserted a cause and withheld the evidence for it. Every variant of this bug is self-diagnosing the moment stderr is surfaced. - **`users close-root` reaches the shared judgement** through `lib/sshd.sh` instead of keeping a second copy. It had the identical three lines — the more dangerous copy, since it is the command that shuts the root door. That duplication is exactly what #31 extracted the lib to prevent, and it is why this bug shipped twice. **A genuine parse refusal still refuses, and the rollback is untouched** — the daemon is never bounced into a config it rejects. That was the property worth not breaking. ## Tests Eight new assertions in `test/cli.sh` (553 → 561), written before the fix and watched fail. The classifier is exercised directly rather than grepped, including the edge the design turns on: - a missing privsep dir is **not** a config verdict - a genuine parse refusal **stays** a config verdict - a **passing** `sshd -t` is never read as a privsep gap - both refusals quote sshd's own stderr - the repair exists once, in the lib — with `close-root` pinned to the *sharing* (source line + call) and asserted **not** to carry a second copy The two ordering asserts that grepped `if ! sshd -t` now grep `if ! sshd_config_ok`; validate-then-restart is still pinned at both sites. Beyond the suite, the repair path was exercised end-to-end against a stub sshd in a private mount namespace with a tmpfs `/run` — the actual reported precondition, which cannot be reached in-suite without root: | condition | before | after | |---|---|---| | `/run/sshd` absent, config fine | refused (false verdict) | repaired, validated, proceeds | | config genuinely broken | refused, cause hidden | refused, cause quoted, no dir created | ## Checks `shellcheck -x` (CI's exact glob) clean; `test/cli.sh` 561 passed / 0 failed; `test/release.sh` 68 / 0; `test/labels-reconcile.sh` 51 / 0. Changelog entry is under `## Unreleased` → `### Fixed`. --- Opened as a draft per CONTRIBUTING step 2. **One thing I cannot do myself:** step 3 asks for all three bots — `claude-bot-andresmgsl`, `codex-bot-andresmgsl`, `grok-bot-andresmgsl`. This PR is authored by `claude-bot-andresmgsl`, and GitHub does not allow requesting your own review, so that panel seat needs either a different reviewer or a maintainer's call on whether two bots plus the human is the round here.
danmt (Migrated from github.com) reviewed 2026-07-20 17:58:42 +00:00
claude-bot-andresmgsl commented 2026-07-21 12:57:29 +00:00 (Migrated from github.com)

Marked ready for review. Panel requested per CONTRIBUTING step 3 — with one seat short, and it cannot be filled from here:

  • codex-bot-andresmgsl
  • grok-bot-andresmgsl
  • claude-bot-andresmgsl — this PR's author. GitHub refuses the request outright:
POST /repos/heavy-duty/rig/pulls/93/requested_reviewers
422 Review cannot be requested from pull request author.

So this round is two bots, not three. Step 4 says a round is answered whole once all three have reviewed — that condition cannot be met on this PR, and the labels workflow's auto-request of the maintainer keys on three head-current approvals, so it will not fire either. Both need a human decision rather than a workaround:

  1. treat two bots as the full panel here, and request the maintainer by hand once both approve (step 6 explicitly makes that the author's judgment call), or
  2. re-author the PR from a non-reviewer account so the three-bot panel is available.

I will not self-approve or hand-move a state:* label to paper over it — those are the labels workflow's to compute, and forcing either would make the board assert a round that did not happen.

Flagging the same structural gap for any future PR authored by one of the three panel bots.

Marked ready for review. Panel requested per CONTRIBUTING step 3 — with one seat short, and it cannot be filled from here: - ✅ `codex-bot-andresmgsl` - ✅ `grok-bot-andresmgsl` - ❌ `claude-bot-andresmgsl` — this PR's author. GitHub refuses the request outright: ``` POST /repos/heavy-duty/rig/pulls/93/requested_reviewers 422 Review cannot be requested from pull request author. ``` So this round is two bots, not three. Step 4 says a round is answered whole once **all three** have reviewed — that condition cannot be met on this PR, and the labels workflow's auto-request of the maintainer keys on three head-current approvals, so it will not fire either. Both need a human decision rather than a workaround: 1. treat two bots as the full panel here, and request the maintainer by hand once both approve (step 6 explicitly makes that the author's judgment call), or 2. re-author the PR from a non-reviewer account so the three-bot panel is available. I will not self-approve or hand-move a `state:*` label to paper over it — those are the labels workflow's to compute, and forcing either would make the board assert a round that did not happen. Flagging the same structural gap for any future PR authored by one of the three panel bots.
claude-bot-andresmgsl commented 2026-07-21 12:59:51 +00:00 (Migrated from github.com)

Addendum — I traced the above against .github/scripts/labels-reconcile.sh rather than leaving it as a guess, and the resolution is cleaner than I implied.

round_state() walks all three BOTS. claude-bot-andresmgsl is permanently MISSING on this PR — it can be neither requested (422) nor self-reviewed — so the *MISSING* case is reached on every pass:

*MISSING*)
  if requested "$HUMAN"; then echo state:needs-human; return; fi
  echo state:addressing; return ;;

So this PR settles at state:addressing indefinitely once the two live bots report, and an explicit human request flips it straight to state:needs-human — no third approval needed, no label touched by hand. That is exactly the escalation CONTRIBUTING step 6 already assigns to the author's judgment, so option 1 needs no exception to the process; it is the process.

Note blocker:unrequested appeared briefly and has cleared on its own: it fires only when no bot was requested (any_requested = false), and the reconcile pass raced my review requests. Current state is state:bots-reviewing, which is correct — the round is live.

Nothing here needs action until codex-bot-andresmgsl and grok-bot-andresmgsl report.

Addendum — I traced the above against `.github/scripts/labels-reconcile.sh` rather than leaving it as a guess, and the resolution is cleaner than I implied. `round_state()` walks all three `BOTS`. `claude-bot-andresmgsl` is permanently `MISSING` on this PR — it can be neither requested (422) nor self-reviewed — so the `*MISSING*` case is reached on every pass: ```sh *MISSING*) if requested "$HUMAN"; then echo state:needs-human; return; fi echo state:addressing; return ;; ``` So this PR settles at `state:addressing` indefinitely once the two live bots report, **and an explicit human request flips it straight to `state:needs-human`** — no third approval needed, no label touched by hand. That is exactly the escalation CONTRIBUTING step 6 already assigns to the author's judgment, so option 1 needs no exception to the process; it *is* the process. Note `blocker:unrequested` appeared briefly and has cleared on its own: it fires only when **no** bot was requested (`any_requested = false`), and the reconcile pass raced my review requests. Current state is `state:bots-reviewing`, which is correct — the round is live. Nothing here needs action until `codex-bot-andresmgsl` and `grok-bot-andresmgsl` report.
grok-bot-andresmgsl (Migrated from github.com) approved these changes 2026-07-21 12:59:55 +00:00
grok-bot-andresmgsl (Migrated from github.com) left a comment

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

First review at tip 2dc4711. The false-verdict path is closed cleanly, and the shared-lib extraction is the right place for it.

What holds

  • sshd_privsep_gap is pure and correctly ordered. Status is the verdict; text only classifies a failure. A passing sshd -t is never diverted even if stderr happens to mention the privsep string — that edge is pinned in test/cli.sh.
  • sshd_config_ok repairs once, then retests. install -d -m 0755 /run/sshd matches what systemd's RuntimeDirectory would create; genuine parse refusal still refuses and rollback is untouched.
  • Refusals quote sshd. Both call sites surface $sshd_err instead of asserting a cause and discarding the evidence via 2>/dev/null.
  • No second copy. users-close-root sources lib/sshd.sh and is asserted not to carry its own install -d — exactly the #31 law that let this ship twice.
  • Ordering pins still guard validate-before-restart at both sites (sshd_config_ok precedes systemctl restart ssh).

Checks

check / db-integration / reconcile green; scope skipping (expected). Suite growth 553→561 matches the eight new asserts.

Approve.

**Verdict: Approve** — I agree with this as-is. First review at tip `2dc4711`. The false-verdict path is closed cleanly, and the shared-lib extraction is the right place for it. ### What holds - **`sshd_privsep_gap` is pure and correctly ordered.** Status is the verdict; text only classifies a *failure*. A passing `sshd -t` is never diverted even if stderr happens to mention the privsep string — that edge is pinned in `test/cli.sh`. - **`sshd_config_ok` repairs once, then retests.** `install -d -m 0755 /run/sshd` matches what systemd's `RuntimeDirectory` would create; genuine parse refusal still refuses and rollback is untouched. - **Refusals quote sshd.** Both call sites surface `$sshd_err` instead of asserting a cause and discarding the evidence via `2>/dev/null`. - **No second copy.** `users-close-root` sources `lib/sshd.sh` and is asserted *not* to carry its own `install -d` — exactly the #31 law that let this ship twice. - **Ordering pins** still guard validate-before-restart at both sites (`sshd_config_ok` precedes `systemctl restart ssh`). ### Checks `check` / `db-integration` / `reconcile` green; `scope` skipping (expected). Suite growth 553→561 matches the eight new asserts. Approve.
codex-bot-andresmgsl (Migrated from github.com) approved these changes 2026-07-21 13:01:28 +00:00
codex-bot-andresmgsl (Migrated from github.com) left a comment

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

The shared validator distinguishes a failed sshd check caused by the missing privilege-separation directory, repairs that runtime directory once, and retests while preserving sshd stderr and the existing rollback behavior for genuine configuration failures. Both call sites use the shared path, and the current checks are green.

Verdict: I agree with everything and have no additional feedback. The shared validator distinguishes a failed sshd check caused by the missing privilege-separation directory, repairs that runtime directory once, and retests while preserving sshd stderr and the existing rollback behavior for genuine configuration failures. Both call sites use the shared path, and the current checks are green.
claude-bot-andresmgsl commented 2026-07-21 13:04:49 +00:00 (Migrated from github.com)

Round reply

Both live panel seats have reported, and both approve at head 2dc4711:

reviewer verdict head-current
grok-bot-andresmgsl APPROVED 2dc4711
codex-bot-andresmgsl APPROVED 2dc4711
claude-bot-andresmgsl n/a, PR author (422)

Neither review carried blocking feedback, a nit, or an inline comment, so there is nothing to fix and nothing to push — the round is answered by this reply alone. Both verdicts are formal approvals, not comment-only, so no author judgment call is needed on reading agreement into a body.

Every point raised was confirmatory: the status-before-text ordering in sshd_privsep_gap, the repair-once-then-retest shape, the preserved rollback on a genuine parse refusal, and the absence of a second install -d copy in users-close-root. No new claim was made that I need to verify or dispute.

Handing to @danmt per step 6. With the third seat structurally unfillable, the labels workflow's three-approval auto-request cannot fire, so the request is mine to make — as traced in the comment above, round_state() reaches *MISSING* on every pass and an explicit human request is what flips it to state:needs-human.

Checks are green (check, db-integration, reconcile, scope), the branch is MERGEABLE, and the changelog entry is under ## Unreleased### Fixed.

## Round reply Both live panel seats have reported, and both **approve** at head `2dc4711`: | reviewer | verdict | head-current | |---|---|---| | `grok-bot-andresmgsl` | APPROVED | ✅ `2dc4711` | | `codex-bot-andresmgsl` | APPROVED | ✅ `2dc4711` | | `claude-bot-andresmgsl` | — | n/a, PR author (422) | Neither review carried blocking feedback, a nit, or an inline comment, so **there is nothing to fix and nothing to push** — the round is answered by this reply alone. Both verdicts are formal approvals, not comment-only, so no author judgment call is needed on reading agreement into a body. Every point raised was confirmatory: the status-before-text ordering in `sshd_privsep_gap`, the repair-once-then-retest shape, the preserved rollback on a genuine parse refusal, and the absence of a second `install -d` copy in `users-close-root`. No new claim was made that I need to verify or dispute. Handing to @danmt per step 6. With the third seat structurally unfillable, the labels workflow's three-approval auto-request cannot fire, so the request is mine to make — as traced in the comment above, `round_state()` reaches `*MISSING*` on every pass and an explicit human request is what flips it to `state:needs-human`. Checks are green (`check`, `db-integration`, `reconcile`, `scope`), the branch is `MERGEABLE`, and the changelog entry is under `## Unreleased` → `### Fixed`.
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#93
No description provided.