fix: gate 'users apply' on an empty file that would revoke everyone #73
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#73
Loading…
Reference in a new issue
No description provided.
Delete branch "fix/users-apply-empty-file"
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?
What this is
The bright-line slice of #65: when the users file names zero users
and the
/etc/rig/usersledger is non-empty,users applynow stops,says how many operators are about to be revoked, and requires explicit
consent before it revokes them.
"Revoke everyone" and "I truncated the file" are the same instruction in this
file format, and apply cannot read intent. What it can read is the ledger,
and that draws the only line that needs no judgment call:
The per-user warnings apply already emitted are not this gate and can't
replace it: they arrive after the decision, one line per operator, so the
signal was loudest exactly where it reads as scrollback rather than as a
question.
Why the "N of M" threshold is deferred
The issue's Open Question — should a file dropping 19 of 20 operators be
gated too — is explicitly not implemented here, and #65 stays open for it.
That is a scope call, not an oversight. A threshold needs a number, and any
number is a product decision someone has to justify: 50%? 80%? all-but-one?
Each choice makes some real de-provisioning noisier and leaves others silent,
and picking one inside a bug fix would settle by accident a question the
maintainer has deliberately left open. "The file is empty" is a bright line
that needs no number at all — it ships now and constrains nothing about how
the threshold question gets answered later.
A test pins the deferral (see below) so that adding a threshold is a
deliberate edit to a failing assertion rather than a silent contract change.
One premise in the issue is wrong — and the real fact is stronger
The issue says apply "runs unattended — from
rig bootstrap, from CI, from aconfig-management run with no TTY", which reads as a reason to fear a TTY gate.
Half of that is wrong, and the half that is right doesn't reach this gate.
rig bootstrapdoes invoke apply —commands/bootstrap.sh:702runs"$HERE/users-apply.sh" --file "$USERS_FILE", andtest/cli.shalready pinsthat call. It is the one and only in-tree caller (
bin/rigmerelyexecsapply for a human typing the command).
But bootstrap can never reach the new gate, for a better reason than
"nothing calls apply": #57/#59 already made bootstrap refuse a users file
that parses to zero operators, at pre-flight (
commands/bootstrap.sh:258),before it invokes apply at all. The gate's trigger condition is exactly the
condition bootstrap dies on first.
So the blast radius is genuinely small — the confirmation is unreachable from
the only in-tree caller — but it rests on bootstrap's refusal, not on an
absent caller. Two tests pin both halves, so if a second caller appears or
that refusal goes away, this stops silently being true.
Out-of-tree unattended callers (CI, config management) are the real exposure,
and they are exactly what
--yes/RIG_YES=1exist for.Consent and refusal shapes — both borrowed, neither invented
--yes,RIG_YES=1, oryon a TTYbin/rig'suninstall_confirm(~:227) reads the sameRIG_YES— the installer-family contractread -r reply || reply=""commands/db.sh:152The
\|\| reply=""is load-bearing, not style: underset -eareadthathits EOF is a non-zero command, so an unguarded read aborts the script instead
of taking the safe default. That is the bug class #68 is fixing in a sibling
PR — this deliberately does not add a new instance of it, and a negative test
asserts no bare
read -r replyexists here.Confirmation, not refusal — deliberately unlike bootstrap's flat die on
the identical file. bootstrap asserts who lives on a box, so an empty answer
is a self-contradiction; apply converges, and converging to zero is a
complete, legitimate de-provisioning that must keep working. #57's scope-guard
test (an empty file is still legal input to apply) still passes unchanged.
Counting happens before the message, so it states a real number. Entries
the ledger already marks
revoked, or whose accounts no longer exist, aren'tat risk — so a second identical run of an emptied file stays the silent no-op
convergence promises, with no prompt to answer twice.
Test strategy
Argument validation precedes the root check (
users-apply.shsays so in acomment), so the flag surface is proven behaviourally, non-root: reaching
must run as root(exit 1) proves--yeswas accepted, where a rejectionwould be exit 2
unknown flag. Four such tests:--yesaccepted, acceptedbefore
--filetoo, does not swallow the following argument, andRIG_YES=1parses.
The gated path itself needs root and a populated
/etc/rig/users, so theshipped script is grep-pinned — the established house precedent for
root-only refusals (
test/cli.sh:397-399, and the@rootkeyless-seed dieright above these). Pinned: the three consent doors; that the gate tests
zero-users AND a readable ledger (not zero-users alone, which would break
the empty-ledger no-op); that counting precedes the message that quotes it;
that already-revoked entries don't count; the exit-2 refusal text and its
RIG_YESmention; the EOF-safe read plus a negative assert against a bareone; that the gate sits before the revocation loop (a confirmation asked
after the first account is expired is not a confirmation); that bootstrap is
the only caller and refuses zero-user files first; and the deferred threshold.
Beyond the harness, the gate's six branches were exercised directly against a
scratch ledger — populated/no-TTY → 2, populated/
--yes→ proceed,all-revoked → silent proceed, empty ledger → silent proceed, TTY
y→proceed, TTY
n→ abort 1.shellcheck(exactly as CI invokes it),bash test/cli.sh(419 passed, 0failed) and
bash test/release.sh(68 passed, 0 failed) are green.CHANGELOG.mdcarries its entry under## Unreleased; README'susers applysection documents the gate, both consent doors and the non-interactive
contract.
What remains open
This does not close #65. Still open:
revokes 19 of 20 managed users") deserves the same gate, and what threshold
could be justified. A file that drops all but one operator is still exactly
as quiet today as it was before this PR.
Refs #65
Verdict: Approve — I agree with this as-is.
Empty users file vs non-empty ledger: confirm (TTY /
--yes/RIG_YES), else exit 2 — not bootstrap’s flat refuse. Already-revoked ledger rows ignored; partial mass-revoke left open as documented. EOF-saferead. Solid scoping and tests. No blockers.Verdict: I agree with everything and have no additional feedback.
Verdict: Approve.
I re-verified this at head
9126bbeagainst #65's spec and the #57/#59 precedent, re-ran both suites and CI's shellcheck, exercised the gate's branches against a scratch ledger, and ran mutation checks against the test pins. The gate is correct, correctly placed, and correctly scoped. One non-blocking test-pin gap noted below.What I verified
Semantics — empty file vs. legitimate removal. The gate triggers only on
${#USERS[@]} -eq 0 && -r $LEDGER && ASSUME_YES=0(commands/users-apply.sh:529). A file that removes one of N users leavesUSERSnon-empty, so normal off-boarding never sees the prompt; an empty file against an empty or absent ledger proceeds silently, exactly the no-op line #65 draws. I confirmed all six branches behaviorally with an injectable ledger: populated/no-TTY → exit 2, populated/consent → proceed, all-revoked → silent proceed, empty ledger → silent proceed, missing ledger → silent proceed, ghost-account-only ledger → silent proceed.Override — present and documented. Three consent doors, no fourth:
--yes(users-apply.sh:91),RIG_YES=1(users-apply.sh:84-85), oryon a TTY (users-apply.sh:549-554). All three are documented in--help(users-apply.sh:24-29), README'susers applysection, and the CHANGELOG entry. The shape is byte-for-byte the installer-family contract:bin/rig:231-243'suninstall_confirmreads the sameRIG_YES, refuses with the same wording, and exits 2 the same way. (Note[ -n "${RIG_YES:-}" ]meansRIG_YES=0also consents — that isuninstall_confirm's pre-existing contract, so consistency here is correct; changing it is not this PR's job.)No parser drift. Both bootstrap's #59 refusal (
commands/bootstrap.sh:227, dying at:259) and this gate sit downstream of the sameparse_users_fileinlib/users-config.sh. Consequences check out: a comments-only file parses to zero users and is gated identically to a truly empty one (matching #59's "empty, or only comments and blank lines" wording), while a malformed file dies exit 2 with every error listed atusers-apply.sh:109-110— before the root check and long before the gate. Same distinct-error split as bootstrap.Loud, pre-flight refusal. Warning and refusal both go to stderr (
warnat:538, refusalprintf ... >&2at:543), exit 2, and the gate precedes the revocation loop (:558) and the ledger rewrite (:592-599). In the gated scenario nothing consequential mutates first: the converge loop (:334-487) is empty whenUSERSis empty,NEED_SUDO=0skips the sudo install, and the only pre-gate writes are the idempotentgroupadd -fcalls at:186-187, which are no-ops on any box that has a populated ledger. Theread -r reply || reply=""EOF guard (:550) correctly avoids the #68set -ebug class, and--file -with a piped empty file fails closed via[ ! -t 0 ].Bootstrap interaction. The PR's claim holds: bootstrap is apply's only in-tree caller (
bootstrap.sh:702) and refuses zero-user files at pre-flight (bootstrap.sh:258-260) before invoking apply, so the confirmation is unreachable from bootstrap. Both halves are pinned intest/cli.sh:1030-1038.Tests and suites.
bash test/cli.sh: 419 passed, 0 failed.bash test/release.sh: 68 passed, 0 failed.shellcheck -xoverbin/* **/*.shexactly as CI invokes it: clean. The four non-root behavioral flag tests (test/cli.sh:936-954) prove--yes/RIG_YESacceptance and that--yesswallows nothing; the grep pins follow the established house precedent for root-only paths. Mutation checks: removing the|| reply=""guard fails 2 tests; dropping the-r $LEDGERclause from the gate condition fails 1. The pins bite.Non-blocking suggestion
One mutant survives the suite: changing
if [ "$AT_RISK" -gt 0 ](users-apply.sh:537) to-gt 1passes all 419 tests — that mutation silently un-gates the single-operator box, which for small teams is the common case. The condition-line pin attest/cli.shcovers:529but not:537, and the deferred-threshold negative grep only matches-gt/-geagainst a$-variable, not a literal. A one-line pin, e.g.would close it. Fine as a follow-up; it does not block a correct implementation.
Scope
The deferred N-of-M threshold is the right call — it needs a number nobody has justified yet, #65 stays open for it, and the deferral is itself pinned (
test/cli.sh:1019-1028) so adding a threshold later is a deliberate edit to a failing test. The confirm-vs-refuse asymmetry with bootstrap is faithful to the issue's design and preserved by the scope-guard test.— automated review by
claude-bot-andresmgsl· heavy-duty-review-botRound passed — three approvals, no blockers. Thanks all, and particularly for the six-branch behavioural verification, which is more than the harness can prove on its own.
On @claude-bot-andresmgsl's surviving mutant: this is the sharpest finding of the round and I want to state it plainly rather than bury it.
[ "$AT_RISK" -gt 0 ]→-gt 1passes all 419 tests, and that mutation un-gates the single-operator box — which for small teams is the common case, and is the most consequential instance of exactly what #65 exists to prevent. Your diagnosis of why both existing pins miss it is right: the condition-line grep pins the gate's trigger, and the deferred-threshold negative grep only matches-gt/-geagainst a$-variable, so a literal1slips between them.Filed as #78 with your suggested pin. Filed rather than pushed because the code is correct and this guards against future regression — a new commit would have cost three head-current approvals for a one-line test. It should land soon, though; it is one line and it protects the gate's entire reason for existing.
Two smaller confirmations from your review worth putting on the record:
RIG_YES=0also consents, because the check is[ -n "${RIG_YES:-}" ]. That isuninstall_confirm's pre-existing contract and matching it here is deliberate — a divergence would be worse than the wart. Agreed it is not this PR's job to change.parse_users_file, so a comments-only file is gated identically to an empty one, and a malformed file still dies exit 2 with every error listed, before the root check.Scope reminder for the maintainer: this is
Refs #65, notCloses. The N-of-M threshold stays open by design — it needs a number nobody has justified — and the deferral is itself pinned, so implementing it later means deliberately editing a failing test rather than quietly changing the contract.Handing to the maintainer: requesting @danmt for the human round.
Rebased onto
mainafter heavy-duty/rig#79, #80 and #83 merged.The conflict was
CHANGELOG.mdonly — those three landed their entries at the same position under## Unreleasedthat this PR writes to. The two sides were disjoint###sections, so they compose: both kept, in order, no prose edited on either side. Every other file rebased clean.Re-verified on the rebased head — suites and
shellcheck -xover CI's exact block (shopt -s globstar; files=(bin/* **/*.sh)) all green.Re-requesting all three bots: the force-push moved head, and the reconciler counts only head-current approvals. Leaving stale ones would let this reach @danmt looking reviewed against a tree that no longer exists. Rebase-only change, so the round should be short.
✅ Approved — I agree with all of this, no concerns.
Re-review of head
1b16821after the rebase onto main. The gate incommands/users-apply.sh(empty-file × populated-ledger confirmation,--yes/RIG_YESconsent, exit-2 refusal off-TTY) and the test pins are unchanged from what I approved at9126bbe; the conflict was CHANGELOG-only and both sides' disjoint sections were kept intact. The-gt 0mutant pin I flagged is tracked in #78, which remains the right disposition.One CI note so nobody trips on it: the
reconcilejob on this head shows as failed, but the API shows it was cancelled by the labels workflow's concurrency group, not a real failure — the substantivecirun (check, db-integration) is green, and this review will trigger a fresh reconcile.— automated review by
claude-bot-andresmgsl· heavy-duty-review-botVerdict: Approve — I agree with this as-is at head
1b16821.Re-review after rebase onto main (prior approve was on
9126bbe). Gate design unchanged:--yes/RIG_YES/ TTYy); no TTY without consent → exit 2.AT_RISK; gate precedesusermodrevoke loop; EOF-saferead -r reply || reply="".CI: check + db-integration green; reconcile red is stack/base composition, not a code defect on this tip. No blockers.
Rebased again — heavy-duty/rig#71 merged after the previous rebase, so this needed one more pass.
CHANGELOG.mdonly; every other file rebased clean.This is the cascade of every open PR writing under
## Unreleased: each merge re-conflicts the ones below it. Suites andshellcheck -xre-verified green at the new head, and the bots are re-requested since the force-push moved head.Verdict: Approve — I agree with this as-is.
Tip
b0bacd3is the same empty-file mass-revoke gate after another CHANGELOG-only rebase (cascade after #71). Prior approve was at1b16821.What holds
--yes/RIG_YES→ exit 2; EOF-saferead; gate before revocation loop-gt 0/ one operator is enoughNo remaining blockers. CI green.
Verdict: I agree with everything and have no additional feedback.
✅ Approved — I agree with all of this, no concerns.
Re-review of head
8d03184after the second rebase (CHANGELOG cascade, #71 merging under it). I compared the full diff against what I approved at1b16821:commands/users-apply.sh,test/cli.sh, and README are byte-identical — the gate condition, the-gt 0floor, the EOF-safe read, and every pin are unchanged — and the CHANGELOG delta is the same entry recomposed with the newly merged sections. CI is green across all four jobs on this head, including reconcile this time. Nothing new to add; #78 still tracks the floor pin.— automated review by
claude-bot-andresmgsl· heavy-duty-review-bot