[US-398] feat: solo-maintainer explicit-approval token for 🔴 PRs - #472
[US-398] feat: solo-maintainer explicit-approval token for 🔴 PRs#472rucka wants to merge 6 commits into
Conversation
A single-account repo cannot produce the non-author human review 🔴 asks
for, so D10 was unsatisfiable there. Adds an ALTERNATIVE satisfaction
path, never a replacement, and states exactly what it is worth.
- pr-state.sh: human_token_approval_{jq,actor_jq}_filter +
solo_approval_token_body, built from ONE predicate text so the gate's
count and its audit line cannot drift
- actor resolved server-side only: .user.type, performed_via_github_app,
author_association; the body gives the command and the head SHA and
nothing else - a body claiming another actor changes nothing
- head-bound: 40-hex guard, so an unset HEAD_SHA accepts nothing and a
force-push voids the token; fresh API read, so a withdrawn one stops
counting
- github-implementation.md: issue_comment trigger (payload carries
`issue`, not `pull_request` - head/base/author resolved via API, still
base-pinned checkout), token branch reached ONLY when the review query
found nothing, token section + ordering step 4
- pr-states.md / docs page / way-of-working: the honest claim -
confirmation, not independent review
- ADR-018 Amendment: guarantee (deliberateness, audit trail,
invalidation on change), the mechanism choice vs. a label, and the
grep-verifiable per-identity statement - forgery-resistance needs #218
- tests written first (15 red, then green): comments fixture with bot,
app-attributed, drive-by, stale, withdrawn and actor-claiming cases;
review-path regression kept
Refs: #398, T-1, T-2, T-3, T-4, T-5, T-6
Verdict
PR: #472 · Author: rucka · Reviewer: independent reviewer agent · Date: 2026-08-30 · Story: US-398 · Type: feature Classification matrix — per dimension
Tier = max(assessed) = red. Review value is a floor: confirmed or raised, never lowered. AssessmentsSecurity — Input validationVerdict: yellow — the comment body is parsed with a regex into which Details
Security — Output handlingVerdict: yellow — the audit line is interpolated into a GitHub commit-status Details
Security — AuthenticationVerdict: green — no credential handling is added; identity comes entirely from host-asserted comment fields, never from the payload the applier writes. Details
Security — AuthorizationVerdict: red — the token grants 🔴 satisfaction to actors the model does not intend: the PR author (on any repo shape) and read-only org members. Details
Security — Introduced vulnerabilitiesVerdict: red — 3 introduced, 0 pre-existing. Details
Any introduced red security finding drives CHANGES-REQUESTED. CostVerdict: Details
Architecture (Coupling)Verdict: green — the "one predicate text, two projections" shape ( Bug fix — Red test before fixVerdict: n/a — feature, not a bug fix. DetailsFindings by severityCritical (must fix before merge)
Major (must fix before merge)
Minor (must fix before merge — same bar as Major, just lower impact)
Questions (informational, never blocking)
Positive feedback
Functionality & requirements (AC coverage)
Testing & quality gates
Adoption compliance
Tech debt
Documentation
Performance & deployment
|
…ion read Review round 1 on #472 — the token branch was reachable by the PR author on any repo, and `author_association` was treated as authorization. - author exclusion + `PAIR_SOLO_APPROVAL_TOKEN` opt-in: the token is self-satisfiable only where the repo DECLARED it cannot produce a second human; unset (default) it excludes the author like the review path - stage 2 `token_approver_login`/`token_permission_sufficient`: the collaborators permission endpoint (admin|maintain|write) authorizes; MEMBER is only a pre-filter — corrected on all 6 surfaces that claimed it - command anchored to its own line: `> /approve <sha>` quote-replies, indented blocks and inline backticks no longer approve (nor get published as the approver) - HEAD_SHA validated `^[0-9a-f]{40}$`, not by length: it is concatenated into a regex - audit head abbreviated to 12 chars — the 140-char status `description` cap made a 24+ char login abort the POST and freeze the PR pending - `issue_comment` job body-filters `created` only; edited/deleted must still re-evaluate a withdrawal - ADR-018: #218 recovers forgery-resistance only as an App/Bot identity, not as a machine user account - conformance assertions scoped to the token predicate text (they passed on the review predicate and on prose) Fixtures + smoke cases: author (opt-in off/on), read-only org MEMBER, quote-reply with a real SHA, indented/backticked, 40 metachars, worst-case description length. Refs: #398
…ancel Review round 2 on #472. - de-fence the body before the line anchor: ```/~~~ regions are stripped, so "here is how you approve: <fence>/approve <sha></fence> do NOT run it yet" no longer approves and no longer names its author as the approver. The anchor covered `>`-quotes, indents and inline backticks — not a fence, which puts the command at column 0 of its own line - concurrency: cancel-in-progress only for non-issue_comment events. A job `if:` runs AFTER `concurrency`, so an ordinary comment cancelled the in-flight evaluation and was then skipped — required context stuck pending, cause visible nowhere - stage 2 stops collapsing every failure onto `none`: gh_permission maps HTTP 404 to `none` and anything else to $TOKEN_PERMISSION_UNKNOWN, token_approver_login exits 1/2/3, token_denied_desc gives each state its own description. The collaborators endpoint's scope prerequisite is stated by the permissions: block and checked in ordering step 4 - "unchanged, byte for byte, no additional API call" was false: the token branch runs on every 🔴 PR with no review yet, on any repo size. Corrected in the job comment, the bullet, ADR-018 and pr-states.md - ADR-018: why the LOOSE default (token stays enabled where independent review is possible) is deliberate Fixtures + smoke: fenced/tilde-fenced tokens rejected, a token beside a CLOSED fence still accepted, exit codes 1/2/3, the three descriptions pairwise distinct and under the 140-char status cap. Refs: #398
…e droppable Review round 3 on PR #472, all 6 actionable findings fixed in place. - Fourth denial state: with the opt-in unset, stage 1 drops the PR AUTHOR's own token, so the gate published the byte-identical "no token was posted" to the one person who did post one. `token_blocked_by_author_exclusion` re-runs stage 1 with the opt-in forced on; `token_denied_desc 4` names the variable. Opt-in compared case-insensitively (a `True` typed into Actions Variables declared nothing). - Concurrency: a comment run in the shared group is not merely non-cancelling, it is cancellABLE — the next ordinary comment drops a pending token run entirely. Keyed into its own group by comment id; the residual is now a fail-safe publication race. - Fence strip: HTML comments dropped, fence split line-anchored and rejoined with "" — an invisible token, an inline ```gh``` span and a token glued to a closing fence all counted and published their author in the audit line. - ADR § Consequences: the retracted "two-human repository pays nothing" claim deleted; the conformance guard now asserts the behaviour, not the phrasing. - The "ordinary comment spends no Actions minutes" claim scoped to `created`, with the edited/deleted merge-blocking window stated. - TOKEN_PREDICATE extraction asserted inside an `it()`, not at module scope. Refs: #398
Escalation — remediation rounds so far + still-open findingsThree remediation rounds have landed on this PR since the first review:
A further review pass over
Convention: further rework or re-review on this story — including manual, out-of-band rounds — should be appended to the working log at Note: the working log is an untracked file that lives only in the persistent authoring worktree Not merging. Awaiting human decision on the Questions item; the 6 Major/Minor findings above are open for the next fix round. |
Unblock full-history secret scanning.
7b55900 to
adb9627
Compare
PR Information
PR Title: [US-398] feat: solo-maintainer explicit-approval token for 🔴 PRs
Story/Epic: #398 (epic #208)
Type: Feature
Priority: High
Assignee: rucka
Labels: user story, risk:yellow
Summary
What Changed
An alternative satisfaction path for D10 ("🔴 needs a non-author human approving review"), for a repository that cannot produce a second human: a head-bound
/approve <head-sha>comment, authorized server-side in two stages that both have to pass.user.type=="User",performed_via_github_app==null,author_association ∈ {OWNER,MEMBER,COLLABORATOR}(pre-filter),.user.login != PR_AUTHORunless the repo opted in, and/approve <HEAD_SHA>owning its line, outside fenced regions and HTML commentshuman_token_approval_selectinassets/pr-state.shGET /repos/{owner}/{repo}/collaborators/{login}/permissionmust beadmin/maintain/write; a lookup that could not answer is kept distinct fromnonetoken_approver_login/token_permission_sufficient, same assetEvery refusal names itself. One published
descriptionper state, because telling a maintainer who just posted a valid token to post one is the failure the whole flow exists to remove:needs a non-author human approval, or /approve <sha> posted by a human maintainertoken posted by the PR author; set PAIR_SOLO_APPROVAL_TOKEN=true if this repo has one humantoken from <login> not authorized: repository permission is not write-levela token was posted but could not be authorized: permission lookup failed, see the run logPAIR_SOLO_APPROVAL_TOKEN=true(repo Actions variable, compared case-insensitively with whitespace ignored — it is typed into a free-text box) is the single-human declaration that suspends it. Nothing in a comment payload can say "this repository has one human", so the repository says it. Unset (every multi-human repo, and the safe default), a 🔴 PR can never be satisfied by its own author — and the fourth description above is what keeps that default from becoming an undiagnosable dead end for the solo maintainer who has not rungh variable setyet.author_associationis not authorization.MEMBERmeans "member of the owning organization", not "has push access here" — the permission read is what authorizes.(^|\n)/approve <sha>[ \t\r]*(\n|$)rejects>quote-replies, 4-space-indented blocks and inline backticks. It cannot see a fence (a fence puts the command at column 0 of its own line) nor an HTML comment (which renders as nothing at all), so both are stripped before it runs. The fence split is line-anchored ((^|\n) {0,3}```[^\n]*, CommonMark's three-space allowance, info string consumed) and rejoins with""— a bare backtick-parity split read an inline```gh```span as a fence, and joining with"\n"then manufactured the line boundary that made a mid-line mention approve.HEAD_SHAis validated^[0-9a-f]{40}$before being concatenated into that regex (a length check accepted 40 metacharacters).concurrencyis evaluated at RUN level, before any jobif:, and GitHub cancels a group's pending run whenever a newer one queues into it — so a shared group is unsafe in both directions and merely making comment runs non-cancelling fixes one: a/approvewaiting behind asynchronizeevaluation is dropped by the next ordinary "thanks!". Keying bygithub.event.comment.idmakes a token run neither cancel nor be cancelled;edited/deletedof the same comment share the key, so the newest state of that comment wins. Push/label/review runs stay in the sharedevalgroup (property 4).descriptionis capped at 140 characters, and the full SHA put any login of 24+ characters over it — a rejected POST underset -euo pipefailleaves the pending placeholder standing, i.e. the "permanently unmergeable 🔴 PR" this story exists to remove. The status is still posted on the full SHA.issue_commentjob body-filters thecreatedaction on/approve;edited/deletedstay unfiltered, because a withdrawal no longer contains the command and is exactly the event that must re-evaluate. Stated with its price: an edit or delete of any comment on any PR runs the job in full, pending-first flip included.Why This Change
A single-account repo cannot produce a non-author human review, so 🔴 was unusable there — every 🔴 PR permanently unmergeable regardless of quality. This gives that repo a named, weaker, explicitly-labelled alternative instead of silently downgrading the rule.
What the token is worth, stated plainly (and in the ADR): explicit human confirmation, not independent review — deliberateness, an audit trail, invalidation on change. It is not forgery-resistant while the agent runs on the maintainer's credentials, and #218 recovers that property only if the agent ships as a GitHub App or Bot identity: a machine user account holding a PAT is
user.type=="User"with no app attribution, so the predicate would accept it, and closing that shape needs an explicit deny-list of the agent login. Residual documented, not hidden.Story Context
User Story: solo-maintainer explicit-approval token for 🔴 PRs (verified human token instead of a second account).
Acceptance Criteria: AC1–AC7 implemented and tested (see Testing). AC4's business rule — "the token is a fallback for a configuration that cannot produce a second human, not a shortcut for one that can" — is executable as the self-approval exclusion (the opt-in), with the residual looseness argued rather than left implied. AC1's happy case and AC5 are verified OFFLINE only — see Follow-up Work; nothing in this diff has run against a live GitHub host.
Changes Made
Implementation Details
gh apiwrapper, the smoke test a fixture reader), so one code path decides in the recipe and in the test.token_approver_loginexits 1/2/3 (nothing posted / not write-level / lookup unanswerable) and the job adds a 4th by re-running stage 1 with the opt-in forced on (token_blocked_by_author_exclusion) when the candidate list is empty — the only way to tell "you posted nothing" apart from "you posted, and the exclusion dropped it".<!-- … -->(including an unclosed one, which swallows the rest of the body) then line-anchored```/~~~fences, keeping the even-indexed segments and rejoining with"". An unclosed fence swallows everything after it, which is both what GitHub renders and the fail-safe direction; a genuine token before or after a closed fence, or beside an HTML comment, still counts.PR_AUTHORwith the opt-in off ⇒ nothing counts; an unresolvedPR_AUTHORcan never match a candidate (so a failed author resolution is never reported as a self-approval); a non-hex/absentHEAD_SHA⇒ nothing counts; a permission lookup that cannot answer ⇒ rejected and said so. The audit line is drawn only for the actor stage 2 authorized.PAIR_SOLO_APPROVAL_TOKEN, unset by default. Leaving it unset is the safe configuration and requires no action. This repository must set it (its PR author and its maintainer are one person) — recorded inway-of-working.mdand ADR-018 § Adoption Impact.Files Changed
.pair/adoption/tech/adr/adr-018-…md,.pair/adoption/tech/way-of-working.md,.pair/knowledge/assets/pr-state.sh(+ dataset source),…/github-implementation.md(+ dataset source),…/pr-states.md(+ dataset source),apps/website/content/docs/concepts/pr-state-flow.mdx,packages/knowledge-hub/src/conformance/pr-state-flow.test.ts,scripts/smoke-tests/scenarios/pr-state-flow.shscripts/smoke-tests/fixtures/github-pr-comments.json,scripts/smoke-tests/fixtures/github-collaborator-permissions.json.pair/knowledge/**copies were regenerated through the production transform (buildDatasetSkillNameMap+buildSkillLinkPathMap+applyKnownMirrorTransforms— the same pairpair updateruns), never hand-ported;mirror-guardis green.Testing
Test Coverage
scripts/smoke-tests/scenarios/pr-state-flow.shsources the shippedpr-state.shand runs the realjqfilters and the real shell functions against fixtures — no reimplementation. Accept: human non-author on head; PR author with the opt-in on (true/True/TRUE/true); a genuine token beside a closed fence; a genuine token beside an HTML comment. Reject: PR author with the opt-in off, or set to1/yes/false; read-only orgMEMBER(clears stage 1, refused by stage 2); quote-reply carrying the real head SHA; indented block + inline backtick;```- and~~~-fenced tokens; a token inside an HTML comment; a token after an inline```gh```span; a token glued to a closing fence; Bot / app-attributed / actor-claiming body; drive-byNONEassociation; stale head after force-push; withdrawn/edited; 40 metacharacters and 40 non-hex chars asHEAD_SHA; emptyHEAD_SHA. Plus every permission value (admin/maintain/write/read/triage/none/empty/bogus),token_approver_login's three failure exit codes,token_blocked_by_author_exclusion's match / non-match / unresolved-author cases, the four denial descriptions asserted pairwise distinct and each under the 140-char cap, and the no-lookup / no-candidate fail-safes. Review-path regression kept.pr-state-flow.test.ts— the AC-named assertions are scoped to the extracted token predicate text, not the whole file (asserted against the file,/[Bb]ot/was satisfied by a prose comment and.user.type=="User"by the untouched review predicate, so both stayed green with the bot exclusion deleted). The extraction itself is asserted inside anit(), so a reformattedprintfis one named failure, never a collection abort taking the file down. Plus: the strip is asserted to happen before the anchor and to be line-anchored (a baresplit("```")and ajoin("\n")are asserted absent), the per-comment concurrency group asserted present and both shared-group forms absent, and every corrected wording asserted absent on the surfaces that carried it — including the "a two-human repository pays nothing" claim, whose guard now matches the behaviour rather than one draft's phrasing.Test Results
Testing Strategy
/approve <head-sha>, isOWNER, human, not via an App, holdsadmin→ the gate counts it and the audit line names the actor, the head and the timestamp.HEAD_SHA, absent permission lookup, permission API error (its own sentinel and its own published description) — all fail closed, and the closed state says which one it is.Existing tests modified (flagged for the reviewer)
ref: ${{ github.event.pull_request.base.sha }}assertion (conformance + smoke) now also accepts the|| steps.pr.outputs.basefallback theissue_commenttrigger forces (that payload has nopull_requestobject) — plus a new negative assertion that noref:ever resolves to the head.(env.HEAD_SHA|length)==40is now asserted absent.cancel-in-progress: truealone, andcancel-in-progress: ${{ github.event_name != 'issue_comment' }}with a shared group) absent.TOKEN_PREDICATE's extraction guard moved out of module scope into the describe's firstit().Known residual (documented, not hidden)
synchronizeevaluation can now execute concurrently on the same head, and the later POST wins: an evaluation that read the comments before the token was posted can overwrite the token'ssuccesswith afailure. Fail-safe direction (blocked, never merged) and self-healing — editing the token comment, or any later push/label/review event, re-evaluates. This is the traded-away half of the per-comment concurrency group: the ordering guarantee, in exchange for a token run that always executes.edited/deletedare deliberately unfiltered, so a typo fix on an unrelated comment — on any PR, 🟢 included — runs the job in full and flips the required context topendinguntil the evaluation completes. A run that dies in between leaves the PR blocked until the next event. Narrowing it would need per-comment state the payload does not carry.issue_commentevent the pending-first step resolves the head via one API read; if that read fails the step aborts and the previous status for the same head stands (a withdrawn token could stay satisfied until the next event). A tier raise arrives as alabeledevent carrying the head SHA and never depends on that read.administration: readis documented as a conditional remedy rather than added to the template: the collaborators endpoint was probed live here with a user token (non-collaborator ⇒read, admin ⇒admin, unknown login ⇒HTTP 404), which says nothing about an ActionsGITHUB_TOKEN— and this repo has nopair-explicit-approval.ymlto exercise it. Widening an authorization workflow's permissions on an unverified guess is the worse error; the gate now reports the 403 as itself and ordering step 4 sends the adopter to check it.Follow-up Work
.github/workflows/pair-explicit-approval.yml, so the predicate has been executed against fixtures and the recipe parsed andbash -n-checked, but the authorization path's happy case has never run against GitHub. Two unknowns are answerable only there: (1) whether an ActionsGITHUB_TOKENcarrying the four declaredpermissions:can callcollaborators/{login}/permissionat all — if it cannot, every token is refused with the lookup-failure description until an adopter addsadministration: read; (2) whether GitHub's queued-run semantics behave as the concurrency reasoning above describes. Same precedent as PR state flow (gate≠review) + pair review as required check #234 (recipe shipped as documentation, verified on a throwaway repo separately). Ordering step 4 ingithub-implementation.mdis the adopter-side verification and is written to be run before relying on the token path; the offline PASS in this PR is not live confirmation of AC1.