Repository navigation
Governance: a GitHub PR webhook receiver that verifies first and only ever wakes (#704 S8) [PROTECTED: owner merges] - #842
Conversation
… ever wakes Governance mode (#704) needs GitHub's PR events to reach the gate faster than the 60 s poll, without letting a delivery decide anything. This adds the receiver as a standalone module; nothing mounts it yet (slice S9), so no install changes behaviour. It follows the automations hooks gateway's verify-first order: no secret (or one under 16 characters) refuses everything; a body over 1 MiB is refused unread; X-Hub-Signature-256 must be the HMAC-SHA256 of the raw body, compared in constant time, before the event, the delivery id or the JSON is looked at. Then only pull_request, pull_request_review, check_run, check_suite and status wake (ping answers; anything else is refused), the delivery id must be a GUID not seen in the last hour (a bounded window, recorded only after the signature passed), and a payload holding a Claude credential is refused (#711). A delivery's whole result is a PrWake: the configured repo and a PR number, or null to list the repo's open PRs again. It is frozen, made only by the receiver and recognised through a module-private WeakSet; no head, check, review or text from the payload leaves the module, so the gate always decides on what the API says. Test (fails without): test/governance-webhook.test.ts. On the tree without src/governance-webhook.ts the build reports TS2307 and the run fails with ERR_MODULE_NOT_FOUND (the module is new); the #711 case was written before the guard and failed against the first build of the module (11 of 12 passed). Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
Evaluator verdict: CHANGES NEEDED (round 1/2)Reviewed head Required changes
Checks
#704 acceptance criteria this slice touches
Protection
Scope, hygiene, CI
Named gaps (non-blocking; for S9 or the owner)
|
Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
…vent, never reject on a dropped client Review round 1 on #842 found three things, all measured. GitHub signs the body only, so anyone holding one captured delivery can set the headers: the unsigned X-GitHub-Event header was echoed into the log text (a setup token or 8,000 characters went through), and a captured pull_request body resent as status or a check event became a repo-wide wake. And handleGithubWebhook rejected with "aborted" when a client dropped the connection mid-body, which an unawaited caller would turn into an unhandled rejection. Every refusal is now a fixed text that names no header. The body must have its event's shape (GitHub's top-level keys), so a PR body sent as status, check_run or check_suite is refused; a PR's and a review's bodies may pass as each other, since both name the same one PR. The handler catches any error, destroys the request and response, and always resolves. The module comment now says the delivery-id window drops only GitHub's own redeliveries; a deliberate replay under fresh ids only wakes the same PR again, bounded by S9's rate limit and coalescing. Test (fails without): test/governance-webhook.test.ts. Against the round-1 module the three new tests fail (header echo, body shape, and the dropped client: 'rejected: aborted'); 12 of 15 pass. Signed-off-by: Jayden Vasquez <jaydenvasquez1711@gmail.com>
Round 2:
|
Evaluator verdict: PASSRound 2 at head Round-1 required changes
Shape matching (measured,
|
| Case | Client saw | Handler |
|---|---|---|
| a. Headers only, then abort | dropped | resolved, 2 ms |
| b. 100 of 1,000 bytes, then abort | dropped | resolved, 1 ms |
| c. Chunked, half a chunk, then abort | dropped | resolved |
| d. Full body, then abort before the reply | dropped | resolved |
| e. Chunked 1.1 MiB, then abort | dropped | resolved |
| f. Slow client: body in 10-byte pieces, 100 ms apart | 202 |
resolved after 804 ms |
g. Never finishes: 10 of 1,000 bytes, then silence (server requestTimeout 1,500) |
408 Request Timeout |
resolved once the server's timeout fired (after about 87 s, measured) |
| h. Chunked 2 MiB, no abort | 413 |
resolved, 3 ms |
i. content-length 2 MiB, 10 bytes sent |
413 |
resolved, before the read |
j–l. wake(), log() or secret() throws |
connection closed | resolved |
| m. GET, then abort | dropped | resolved |
- The cap while reading holds (measured): case h answered 413 within 3 ms while the client was still sending 2 MiB.
- How many bytes are read past the cap: at most 1 MiB plus one chunk. That's inferred from the per-chunk check at
readDelivery, because the socket was already gone when I tried to count.
Fail-without (method b, measured)
- Setup. A fresh worktree at the round-1 head
058de90d, with the round-2test/governance-webhook.test.tswritten in by Write.cmpwith the head's file: identical. - Build and run.
npx tsc -p tsconfig.jsonis clean. Thennode --import ./dist/test/isolate.js --test dist/test/governance-webhook.test.js:# tests 15,# pass 12,# fail 3.not ok 10 - no unsigned header is ever echoed: actual'sk-ant-oat01-e3a1… is not an event that wakes the gate'.not ok 11 - the event header must fit the body's shape: expected 400, actual 202.not ok 12 - a client that drops the connection mid-body: actual'rejected: aborted'.
- On the head (
0aa88658):governance-webhook+claude-credential-guard+brandgives# tests 20,# pass 20,# fail 0. The receiver and the guard account for 18 of those.
Other checks (measured)
- Typecheck and denylist.
npm run typecheckis clean.npm run check:denylistreportsdenylist: 1311 files clean. - Package size.
npm pack --dry-run --json --ignore-scriptsthrough the wrapper, withbuildandbuild:webrun in both trees, against main71316ebd:- Tarball: 1,903,282 to 1,906,340 (+3,058).
- Unpacked: 5,938,018 to 5,949,351 (+11,333).
- Files: 512 to 513.
- The only changed file is
dist/src/governance-webhook.js. - This matches the body exactly, and both sizes are under
PACKAGE_BUDGET(1,950,000 / 6,060,000).
- Merge with main.
git merge-tree --write-tree origin/main HEADgives a clean tree,9dc76c2e. Against main the diff is still only the 2 new files. The CHANGELOG,package.jsonand lock changes between rounds come from merging in main. - CI. Every test shard on Node 22 and 24, dco, denylist, push-gate, scope, scan, new-deps, agentshield and desktop pass. Run
39334202930hasheadSha0aa88658andsuccess. The real-systemd jobs are skipped as usual. - Commits. All 3, including the merge commit, have
Signed-off-by, and none has a Co-Authored-By or other attribution trailer. - Protected marking. The title has
[PROTECTED: owner merges], the body has**Risk class:** protected, theneeds-ownerlabel is set, and both sections are present. - Body. Claims are labelled. The round-2 adversarial rows match what I measured. "Part of Governance mode: the engine runs only its evaluator, merge policy and locks in front of PRs other agents or people open, with no agents of its own; external reviews and CI are policy inputs, never the decision #704" is right for a slice.
Protection and durability
- By-design protections (named in the body): the opaque
PrWake, verify-first in code order, the re-read through the API, and now a shape check that only refuses. No lint is added. - "Can never do" claims. "Store or log a credential" and "reject its promise" are now true (measured above).
- No regression for existing installs. Nothing imports the module, so no install's routes change (inferred).
Named gaps (non-blocking; for S9 or the owner)
- Captured-delivery replay under fresh GUIDs wakes the same PR again (50/50, measured). It's documented in the body, and S9's rate limit and coalescing bound it.
- An evicted id can be replayed after 10,000 signed ids. It's documented, and moot beside the fresh-GUID replay.
check_run.pull_requestsentries aren't checked against their base repo. It's documented and costs one re-read.- New in round 2: the module has no read deadline of its own. A client that never finishes holds the handler and up to 1 MiB of buffer until the server's
requestTimeoutfires.- Measured: Node 22's default
requestTimeoutis 300,000 ms. - Measured: with it set to 1,500 ms, the stalled request was settled only when the server's periodic check ran, after about 87 s.
- S9 should mount it on a server with a short request timeout, or behind the dashboard's existing limits (inferred).
- Measured: Node 22's default
Intent: Slice S8 of governance mode (#704). It adds the GitHub PR webhook receiver as a standalone module: it verifies each delivery first and only ever wakes the gate. A delivery never carries anything the gate decides on. The gate always re-reads the PR, its checks and its reviews through the API. Nothing mounts the receiver yet: S9 wires it next to the dashboard's other verify-first routes, and polling stays the fallback. So no install changes behaviour. This is independent of S1 (#833) and doesn't need its token.
Risk class: protected. It adds
src/governance-webhook.ts. src/** is engine (.worklane/review.yaml), so the owner merges.Part of #704 (slice S8; it does not close the issue).
Round 2 (
0aa88658, after round 1's CHANGES NEEDED). Main71316ebdis merged in, not rebased.71316ebdwith the UI built.What it does
receiveDelivery({secret, repo, headers, body, window})checks a delivery in this order. Nothing of a delivery is read before the step that needs it.No secret, or one shorter than 16 characters: 503 for every delivery (fail closed).
A body over 1 MiB: 413, unread.
X-Hub-Signature-256must besha256=<hex>of HMAC-SHA256 over the raw body, compared withtimingSafeEqual. Otherwise 401, with the same body every time.Only now is
X-GitHub-Eventread:pinggets 200;pull_request,pull_request_review,check_run,check_suiteandstatusgo on;X-GitHub-Deliverymust be a GUID that hasn't been seen in the last hour. Otherwise it gets 400, or 200 with no wake for a repeat. The window is bounded, and an id goes into it only after its signature passes.The JSON is parsed. Round 2: the body must have its event's shape (GitHub's top-level keys), or it gets 400:
pull_requestandpull_request_reviewneed apull_requestobject;check_runandcheck_suiteneed their own object;statusneedssha,stateandcontextstrings and no PR or check object.A PR's and a review's bodies may pass as each other, since both name the same one PR.
A payload that holds a Claude credential is refused (Auth docs: who can read agents' credentials and whether the engine stores a token match the code (coordinator sudo, dashboard token paste) #711,
holdsClaudeCredential).repository.full_namemust be the instance's repo, compared in any case. Otherwise it gets 202 with no wake.What it wakes:
pull_requestslist, with duplicates removed.statusevent, an empty list, or more than 20 PRs wakes the whole repo once (pr: null), so the gate lists open PRs again.Every
whyis a fixed text (round 2). None of them echoes a header, the payload or a name.handleGithubWebhook(req, res, ctx)does the same over HTTP:content-length, or once more than 1 MiB has been read. It then answers 413 and drops the connection.ctx.wake.wakeorlogthat throws, ends with the request and response destroyed, and the promise resolves.By-design protection
PrWake {repo, pr}:repois the instance's configured repo, never the payload's spelling.pris a positive safe integer, or null.isWakeaccepts only objects in a module-privateWeakSet.verify(),fits()andwakesOf()aren't exported, and the parsed payload is a local. So a signed delivery whose payload lies about a head, a check or a review can at most make the gate re-read one PR (measured: test "a wake carries the configured repo and a PR number only").statusor check wake (measured). Every refusal text is fixed, so no header text reachesctx.log(measured).test/claude-credential-guard.test.ts:32) passes with no new exemption.What this can never do
grep -rn "governance-webhook.js" src/finds no importer).prandrepo). The S9 acceptance test that one PR gets the same decision by webhook and by polling needs the gate, so it lands with S9.why(measured: the round-1 leak, where the unsigned event header reachedwhyuncapped, is fixed and tested).Compromised component
X-GitHub-EventorX-GitHub-Delivery. They need no secret:pull_requestbody sent asstatusorcheck_suiteturned a one-PR wake into a repo-wide one (measured by the reviewer). Round 2: refused with 400 (measured).why. Round 2: fixed texts only (measured).How it was verified
Fail-without, round 1 (measured). The module was new, so the main proof was that the tests fail on the tree without it. On unchanged
6e0d7d81:npm run build:error TS2307: Cannot find module '../src/governance-webhook.js';Error [ERR_MODULE_NOT_FOUND],# fail 1.The Auth docs: who can read agents' credentials and whether the engine stores a token match the code (coordinator sudo, dashboard token paste) #711 case was written before the guard and failed against the module's first build (
not ok 9, 11 of 12 passing).Fail-without, round 2 (measured). I wrote the three new tests first and ran them against the round-1 module (
058de90dmerged with71316ebd, module unchanged):# tests 15,# pass 12,# fail 3.not ok 10no unsigned header is ever echoed.not ok 11the event header must fit the body's shape.not ok 12a client that drops the connection mid-body: actual'rejected: aborted'.After (measured).
governance-webhookandclaude-credential-guardtogether:# tests 18,# pass 18,# fail 0.Neighbouring tests (measured). These scan all of
src/: package-files, review, feature-map, brand, isolate, push-check, change-files-renames, owner-terminal-removed, slot-dir-writes and slot-path. Result:# tests 228,# pass 228,# fail 0. CI runs the full suite.Other checks (measured).
npm run typecheck: no errors.npm run check:denylist:denylist: 1311 files clean.node scripts/check-dco.mjs origin/main HEAD:DCO: all commits signed off. That includes the merge commit, made with--signoff.2 commit(s) and 1 tip(s) clean.Adversarial pass
I ran these from a scratch script against the built
dist/src/governance-webhook.jsat0aa88658, with real local HTTP servers on 127.0.0.1. No repo file was edited.X-Hub-Signature-256headers (node joins them)__proto__key smugglingrepositoryrepository.full_nameacme/app/Pull_Requestcheck_runnaming PR"12"(string) and12.012, the JSON number, wakescheck_suitenaming 500 PRsPrWake.prototype.pr = 99pr(12) winsstatus/check_suite/check_runwhy= "not an event that wakes the gate". Round 1: the token was inwhywhy. Round 1: 8,036 characterscheck_runentry whose base is another repo (#99)abortedstructuredClone, Proxy,Object.create, swapped prototype,new PrWake(Symbol()))isWake; the constructor throws (test)merge without review, an approved review, green checks, another headWhat I fixed because of it.
Named gaps, for S9 (inferred):
check_run.pull_requestsentries aren't checked against their base repo. A mismatched entry wakes that number in this repo, which costs one re-read. GitHub's entries carrybase.repo.name, not the full name.webhook-secret(src/setup/app-manifest.ts:515,633), but the manifest's webhook is inactive today.On unlisted events. They get a 400. A GitHub App also receives
installationevents it didn't subscribe to, so those will show as failed deliveries in the App's log. That's cosmetic: nothing is lost, because nothing in them would wake the gate (inferred).The shape rule follows GitHub's documented top-level keys for each event (inferred from GitHub's webhook docs, not re-fetched here). If GitHub ever sent a body this rule doesn't fit, that delivery is refused and polling covers it, so it fails closed.
Package size
Measured with
npm pack --dry-run --json --ignore-scriptsthrough the npm wrapper. The baseline is a separate worktree at main71316ebd. Both trees rannpm run buildandnpm run build:web, sodist/webis included.dist/src/governance-webhook.js+11,333 (new). That's the only file that changed; the test isn't packed.6e0d7d81). The reviewer measured round 1's head with the UI against71316ebdat +2,551; that's the correct round-1 figure.PACKAGE_BUDGET(1,950,000 / 6,060,000) is unchanged and still has headroom.CHANGELOG
None. Nothing is mounted, so users see nothing (inferred). S9 adds the bullet when the endpoint exists.
New dependencies
None (
node:crypto,node:httponly).