Repository navigation
feat: vouched-ci — approve fork CI runs for verified commits from vouched users - #147
Merged
Merged
Conversation
…ched users
Adds an optional org-level `vouched-ci` list of `{ login, id }` entries to the
permissions config. When a vouched user pushes to a pull request from a fork,
Sheriff approves the GitHub Actions runs awaiting approval for that head SHA,
but only if every commit in the pull request was authored, committed and
verified-signed (`verification.verified && reason === 'valid'`) by that same
user, the head has not moved during verification, and the commit listing is
complete. Runs are approved individually and pinned to the verified head SHA;
each new push gets its own decision. Approvals are posted to Slack.
The decision logic is pure (`src/vouched-ci.ts`) and unit tested with node:test
(`yarn test`, now also run in CI). Approvals use a dedicated installation token
narrowed to `actions: write` + `pull_requests: read`, so a missing App
permission only breaks this feature.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wd5WZCENbhvKnJi2EMqrbi
In a dry run the handler now logs "would approve run <id>" and skips the approval (and the Slack notification), and the vouched-ci token is minted with actions:read instead of actions:write, matching getAuthNarrowing. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wd5WZCENbhvKnJi2EMqrbi
MarshallOfSound
marked this pull request as ready for review
September 10, 2026 17:36
MarshallOfSound
approved these changes
Sep 10, 2026
dsanders11
suggested changes
Sep 10, 2026
dsanders11
reviewed
Sep 10, 2026
VerteDinde
approved these changes
Sep 10, 2026
…ng window Address review feedback on #147: - Rename the org config key from `vouched-ci` to `vouched_ci` so it matches the snake_case used by the rest of the schema (type, Joi validator, handler, README, log tag). - Each workflow file produces its own run and GitHub creates them independently after the push, so stopping at the first listing that contained a run could approve a partial set. Verify the commits once up front, then `approvePendingRuns` lists `action_required` runs for the head SHA on every poll, approves each newly seen run at most once, re-fetches the pull request head before every approval batch so a racing push aborts, and keeps polling for up to ~60s, exiting early only once something has been approved and two consecutive polls found nothing new. The polling is a testable function with injected list/approve/sleep callbacks. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wd5WZCENbhvKnJi2EMqrbi
…f polling Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wd5WZCENbhvKnJi2EMqrbi
dsanders11
approved these changes
Sep 10, 2026
MarshallOfSound
pushed a commit
that referenced
this pull request
Oct 10, 2026
…match logins (#149) * fix: vouched_ci webhook handler must not treat the web process as a dry run `IS_DRY_RUN` is derived from the `--do-it-for-real-this-time` argv flag, which only the permissions cron is started with. The webhook server runs as `node lib/index.js`, so `IS_DRY_RUN` is always true there and the `workflow_run.requested` handler added in #147 could never get past its "would approve run" log line, and the vouched_ci installation token was minted with `actions: read` and could not have approved anything anyway. Drop the guard and always request `actions: write` for that token; no other webhook handler gates on `IS_DRY_RUN`. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wd5WZCENbhvKnJi2EMqrbi * feat: verify vouched_ci ids resolve to their logins during the permissions run The webhook handler only vouches for a user whose numeric id and login both match a `vouched_ci` entry, so an entry whose id no longer resolves to its login (typo, rename, deleted account) silently never approves anything, or worse was meant for a different account. Resolve each id through `GET /user/{account_id}` while processing the organization in the permissions run and fail the run, dry run included, when the login does not match case-insensitively or the id does not exist, so the .permissions CI check catches it before merge. The comparison lives in `verifyVouchedUsers` with an injected lookup so it is unit tested; the GitHub call stays in run.ts next to the other GitHub-backed checks rather than in the offline `validateConfigFast`. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wd5WZCENbhvKnJi2EMqrbi --------- Co-authored-by: Claude <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Requested by Samuel Attard · Slack thread
Before: Every GitHub Actions run on a pull request from a fork by an external contributor sits at "Awaiting approval" until a maintainer with write access clicks "Approve and run", even when the person who pushed is a trusted maintainer who works out of a fork. Sheriff had no notion of "trusted for CI".
After: The permissions config gains an optional org-level
vouched_cilist of users pinned by numeric GitHub id (- { login, id }). When a fork pull request run created by one of them is gated on approval, Sheriff approves that run, provided every commit in the pull request was authored, committed and verified-signed by that same user. Anything else is left alone with the reason logged, and every approval is posted to Slack.How: GitHub emits
workflow_runrequestedfor a fork pull request run the moment it is created and gated on "Approve and run" (the payload reports it asstatus: completed,conclusion: action_required; the org webhook already deliversworkflow_run). A newworkflow_run.requestedhandler insrc/index.tsruns its checks cheapest-first, since this event fires for every run in the org:isApprovableRun(src/vouched-ci.ts) rejects anything that is not apull_requestrun, notaction_required, not from a fork, or has no head repository/owner/branch, all from the payload alone; then the org must have avouched_cilist and the run'striggering_actor(the authenticated user whose push created the run) must be vouched by both id and login, as a pre-filter. Becauseworkflow_run.pull_requestsis always empty for fork runs, the handler then lists the open pull requests for the run'sowner:branchhead (GET /pulls?head=…) andmatchPullRequestForRunkeeps the one whosehead.shaandhead.repo.idequal the run'shead_shaandhead_repository.id, refusing on zero or several matches. It lists that PR's commits (GET /pulls/{n}/commits, paginated), re-fetches the PR to detect a racing push and cross-checks its head repository against the run, and runs the pure decision functionevaluateVouchedCIwith the triggering actor as the sender and the run'shead_shaas the tree to verify. Only once that passes does itPOST /actions/runs/{run_id}/approvefor that single run (inIS_DRY_RUNit logs "would approve run" instead); a 4xx from GitHub (run already approved by a maintainer or no longer pending) is logged as a no-op, anything else propagates. The API calls use a dedicated installation token narrowed toactions: write+pull_requests: read(getVouchedCIOctokit,actions: readin dry run) so a missing App permission only breaks this feature. The config key is validated with Joi invalidateConfigFast(positive integer id, non-empty login, no duplicates) so the.permissionsdry-run check catches mistakes. The decision, run-filter and PR-matching logic is covered by 36node:testcases (yarn test, wired into the Test workflow). There is no polling: each workflow file gets its own run and its own event, so every run is verified and approved individually.Safety / threat model
Analysed against GitHub's documented behaviour (webhook
triggering_actorsemantics, the RESTverificationobject and itsreasontable, signature persistence across the fork network, the fork-PR approval policy,pull_request_target). The premise as literally stated, "approve based on commit author", is not safe: git author/committer metadata is free-form text, GitHub's email-to-account attribution ofauthoris spoofable by writing someone's public email into a commit, and a verified signature by user X only proves X made that commit object, not that X pushed it, nor anything about the rest of the tree the run would execute (GitHub reuses verification records across the whole repository network, so X's signed commits can be replayed from any fork). What is safe is the stricter subset implemented here: the pusher is vouched, and every commit in the PR range is that pusher's own verified-signed commit.Trust anchors used:
workflow_run.triggering_actor.id(server-side attribution of the authenticated push that created the run, the same actor GitHub's own approval policy checks),commit.committer.idbound to a key on that account byverification.verified === true && reason === 'valid',commit.author.id === committer.id(the vigilant-mode condition, enforced ourselves since REST does not expose it), and the run'shead_sha. Web-UI commits are signed byweb-flow, not the user, so they are refused; so are unsigned commits and commits the vouched user committed but did not author. Merge commits are allowed only because every commit in the PR range is checked, so merging the PR base adds just the (signed) merge commit while merging anything else drags in foreign commits and is refused.Refusal conditions (any one refuses the run, nothing is approved, reason is logged):
run.event != 'pull_request'(sopull_request_targetetc. are never touched), or the run is notaction_required.vouched_cilist.triggering_actor.idis not a vouched id, or the pinned login does not matchtriggering_actor.login(case-insensitive) — an id typo can never vouch an unrelated account; a rename fails closed until the config is updated. The ghost user fails here too.head.sha == run.head_shaandhead.repo.id == run.head_repository.id(the run cannot be attributed to exactly one PR).commitscount, or there are zero commits.author/committerunresolved,author.id != triggering_actor.id,committer.id != triggering_actor.id,verification.verified != true, orverification.reason != 'valid'.Approvals are per run and pinned to the verified head SHA (the run's own
head_sha); a later push creates new runs, new events and new decisions. Residual risk: a compromised vouched account or signing key can run arbitrary code in fork-PR CI (read-onlyGITHUB_TOKEN, no repo secrets, but runner compute and anything a workflow exposes topull_requestruns), which is the same exposure as a write-access collaborator approving a run. Keep the list short; every approval is posted to Slack.Deploy notes
Actions: Read and writeandPull requests: Read-onlyto the Sheriff App (and accept the permission update on the installation) before adding avouched_cilist to the config. Without them thevouched_citoken cannot be minted; the rest of Sheriff is unaffected.workflow_run. No GitHub App event subscription is needed since Sheriff consumes the org webhook..permissionsPR addingvouched_ci(the dry-run check validates the new key), then merge. Numeric ids come fromhttps://api.github.com/users/<login>.vouched_ciid still resolves to the pinned login viaGET /user/{account_id}; (b) a push that triggers N workflows produces N events and N independent commit listings for the same head, which could share a short-lived per-head cache if API volume ever matters; (c) a fork branch opened as pull requests against two base branches at once is refused (two matches) rather than approved, which is deliberate but could be relaxed to "all matches are from the same vouched head".🤖 Generated with Claude Code
https://claude.ai/code/session_01Wd5WZCENbhvKnJi2EMqrbi
Generated by Claude Code