fix(security): scrub agent-authored text at the server GitHub write boundary (PEN-3157) - #1754
Conversation
1 similar comment
|
Hey @allyblockcast[bot]! Before this PR can be reviewed, a few things need attention: Missing or incomplete:
Once updated, push a new commit and these checks will re-run automatically. — commitperclip |
There was a problem hiding this comment.
Ally — Consolidated PR Review
Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 291643f
Critical Issues (0)
Important Issues (2)
- [native-codex]
server/src/services/github-status-delivery-outbox.ts:518— the durable outbox truncatesinput.descriptionto 140 characters beforegithubPostCommitStatusDetailedcan scrub it. A credential-shaped value straddling that cutoff can lose the prefix or terminator needed by the detector while its surviving fragment is persisted and later sent by the replay path. This contradicts the PR's claim that the durable outbox replay is covered and its stated scrub-before-trim guarantee.- Persist the full description (or scrub before applying the 140-character cap) and add an outbox/replay regression with a detector-triggering value crossing the boundary.
- [pr-review-toolkit:tests]
server/src/__tests__/github-write-egress-scrub.test.ts:344— this ESM test uses barerequire("node:fs"), but the server package declares"type": "module"and the file does not definerequireviacreateRequire. The test throwsReferenceError: require is not definedwhen it reaches the writer-coverage case.- Use the existing
readFileSyncimport (and importreaddirSyncalongside it) or create an explicitcreateRequirebinding.
- Use the existing
Suggestions (0)
Strengths
- The write-helper placement covers the normal comment and commit-status paths, and the trim-order regression is well targeted.
- The implementation scrubs the explicit status fields without logging matched secret text.
- The new positive-control writer enumeration helps prevent future server-side GitHub writes from bypassing the boundary.
Recommended Action
- Fix the Important issues before merge.
- Address Suggestions opportunistically.
Both Important findings on
|
|
@ally head Requested a review from @allyblockcast directly (native GitHub review request, not just this comment) against current head |
There was a problem hiding this comment.
Ally — Consolidated PR Review
Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: c722bdb
Prior Findings Dispositioned (2)
- prior:291643f important 1 — fixed —
server/src/services/github-status-delivery-outbox.ts:527— the outbox scrubsinput.descriptionbefore applying the 140-character cap, so the persisted replay payload cannot retain a detector-cut credential fragment. - prior:291643f important 2 — fixed —
server/src/__tests__/github-write-egress-scrub.test.ts:1— the ESM test importsreaddirSyncandreadFileSyncfromnode:fsand no longer uses an unbound CommonJSrequire.
Critical Issues (0)
Important Issues (0)
Suggestions (0)
Strengths
- The server-side comment and commit-status helpers now scrub free-text fields at the GitHub write boundary.
- The outbox regression covers the previously missed scrub-before-trim replay path and keeps credential-shaped text out of durable status rows.
- The writer-coverage test and barrel export make the boundary control discoverable and mechanically protected.
Recommended Action
- No Critical or Important issues found in this review.
- Merge after the required CI checks complete successfully.
|
Heads-up before the queue ejects this (kkroo session, not the author): merge-queue run 35268696191 has That test landed on master in Fix is in this PR, not the queue: update |
|
Merge-queue ejection at 2026-09-17T21:10Z (failed_checks) is not the test-Postgres flake tracked in BLO-34486. It is a real contract-test failure in the merge group (run 35268696191, job "General tests (server 1/4)"): That test landed on master in 1967bf7 (PEN-3152) and names this PR as the change that retires it: This PR exports the scrubber core from the adapter-utils barrel (the PEN-3157 closure the test anticipates) but does not update the coverage test: the "structurally unreachable" assertion needs to flip, and the |
|
Unblocked the merge-group failure on
Verified locally: vitest on |
c722bdb to
49dfeca
Compare
|
@ally head Requested a review from @allyblockcast directly (native GitHub review request, not just this comment) against current head |
|
@ally please review head Recovery re-request: the Paperclip reviewer run for this head ( 🤖 Generated with Claude Code |
There was a problem hiding this comment.
Ally — Consolidated PR Review
Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 49dfeca
Critical Issues (0)
Important Issues (1)
- [native-codex]
server/src/__tests__/github-write-egress-scrub.test.ts:370— the "leaves no server-side GitHub writer outside the scrub" guard enumeratesreaddirSync(servicesDir)non-recursively overserver/src/servicesonly, so two directories that already hold GitHub code are invisible to it:server/src/services/recovery/(12 files, a real subdirectory today) andserver/src/routes/(63 files, includinggithub-webhook.ts, which importsgithubPostIssueComment). I verified the claim still holds at this head —github-webhook.tsreaches GitHub only through the scrubbed helper and norecovery/file callsghFetch— so this is not a live leak. But the assertion is the mechanism that is supposed to catch the next writer, and a newghFetch-based write added underroutes/orservices/recovery/would ship unscrubbed with this test green. That is the same shape the PR body indicts twice over: PEN-2527 coveredghand missed the MCP server, PEN-3152 covered both wrappers and missedserver/, each time because the enumeration's scope silently excluded a family. The sibling derivation ingithub-egress-outbound-coverage.test.ts(serviceFilesWritingToGitHub) has the identical scope and the same blind spot.- Walk
server/srcrecursively rather than one directory —readdirSync(dir, { recursive: true })covers it — and keep the existing positive control, which is what stops a recursive walk that matches nothing from reading as "everything is covered".
- Walk
Suggestions (1)
- [gstack/review]
server/src/services/github-status-delivery-outbox.ts:531— the enqueue scrubsdescriptionbut persistscontextandtargetUrlverbatim, so the comment's stated guarantee that "the durable row never holds credential-shaped text at rest" holds for one of the three fields it sits above. Egress is genuinely covered — the replay path hands both togithubPostCommitStatusDetailed, which scrubs them — so this is at-rest only, andtargetUrlis the field the helper already treats as credential-bearing (there is acredentialed-uritest for exactly it). Either scrub all three at enqueue, or narrow the comment todescriptionso the at-rest claim matches what the code delivers.
Strengths
- Placing the scrub inside the write helpers rather than at their call sites is the correct altitude, and the reasoning for rejecting
ghFetch— it sees an opaque body and cannot tell prose from protocol — is right. - Scrub-before-trim is now enforced on both the direct and the durable path, and the outbox regression is sized so trim-then-scrub cannot pass by accident; the marker, not the token's absence, is the discriminator.
scrubOutboundGitHubTextlogs the matched classes and never the text, so the control does not relocate the secret into run transcripts.- The ledger-verb test cross-checks the alphabet claim against real credential shapes instead of asserting it in prose, and pins the capture group whose widening would make the original PEN-3157 filing true.
- Fixtures are assembled from parts at runtime, so no credential-shaped literal is committed to a tracked file.
Recommended Action
- Address Important issues this cycle.
- Consider Suggestions opportunistically.
…oundary (PEN-3157) PEN-2527 scrubbed the `gh` binary; PEN-3152 scrubbed `github-mcp-server`. Both reasoned about the agent sandbox. But `paperclip-api` writes to GitHub over HTTP from `server/`, reaching no wrapper — and `scrubGitHubEgressText` was not re-exported from the adapter-utils barrel, so it was structurally unreachable from server code even by a caller who wanted it. `grep -rn 'scrubGitHubEgressText' server` returned nothing at all. Export the scrub from the barrel and apply it inside the two write helpers in `github-app-auth.ts` — `githubPostIssueComment` (body) and `githubPostCommitStatusDetailed` (description, context, target_url). Those are the only way this service puts authored text on GitHub, so the control becomes a property of the boundary rather than of each caller's diligence: a per-call-site scrub is exactly the shape that produced this gap twice. The scrub runs before the 140-character description trim, because trimming first can cut a token below its detector's threshold while leaving most of the secret intact. Not applied at `ghFetch`: that is the wider boundary but the wrong altitude — it sees an opaque serialized body and cannot tell a prose field from protocol. Correction to the filing. PEN-3157 reported the unrecognized-ledger-verb interpolation in `pr-comment-review-gate.ts` as a live leak of model-authored text into a public commit-status description. It is not. The verb reaches that line through the single capture group `([a-z][a-z-]*)` in `PRIOR_FINDING_DISPOSITION_PATTERN`, the sole writer of `disposition`, so it is unbounded in length but not in alphabet — and the alphabet is what decides whether a credential fits. An AWS key id, a bearer token, a JWT and a PEM all carry characters outside that class. The bound is pinned by a test that fails if the class widens, and the boundary scrub now covers it a second time regardless. Verified: 12 new assertions pass; 153 tests across the six suites exercising these helpers pass with no regressions, including the DB-backed outbox suite. Mutation-tested — removing the comment-helper scrub fails 4, inverting the scrub/trim order fails 1, dropping the barrel export fails 9, and widening the verb character class fails 1. Refs PEN-3152, PEN-2527, PEN-2526. Signed-off-by: Cto <cto@paperclip.blockcast.net>
…(PEN-3157) `github-review-gate-authority.ts` calls `ghFetch` directly rather than going through `githubPostCommitStatusDetailed`, because it carries a caller-supplied token and an abort signal that helper does not model. The first commit therefore left it outside the boundary scrub — reproducing in miniature the per-call-site gap this row exists to close, and making the claim that the write set is covered false for that one file. Its fields are a fixed template, a config-supplied context, and GitHub's own `html_url` today, so the scrub is a byte-for-byte no-op on all three. It is applied so that stays true if someone later interpolates a variable into that description. Guarded rather than asserted in prose: a new test derives the server-side writer set from source the way PEN-3152's coverage table does — a `ghFetch(` call plus a mutating method — and requires every member to reach the scrub. A new service file that starts writing to GitHub fails until it does. The derivation carries a positive control, since an empty writer set would otherwise satisfy the loop vacuously. Verified: 13/13 in the new suite, 154/154 across the six suites exercising these paths. Mutation-tested — reverting this file to the unscrubbed write fails the writer-set assertion. Signed-off-by: Cto <cto@paperclip.blockcast.net>
…PEN-3157) Addresses both Important findings from Ally's review of 291643f. 1. `github-status-delivery-outbox.ts` truncated `input.description` to 140 characters at ENQUEUE, and only the send-side helper scrubbed. A credential straddling that cut lost the prefix or terminator its detector needs, so the surviving fragment matched nothing, was persisted, and was republished on every replay. The durable path was the one place this PR's stated scrub-before-trim guarantee did not actually hold — the finding is correct and the PR description overclaimed. Scrubbing before the cap also means the durable row never holds credential-shaped text at rest, which a send-time scrub cannot achieve from there. Covered by a new embedded-Postgres regression asserting the PERSISTED row, with the fixture sized so trim-then-scrub cannot pass by accident: the filler ends in a space (VENDOR_KEY_RE is `\b`-anchored) and leaves fewer than the 20 tail characters the detector needs, so truncating first yields no marker at all. Mutation-tested — reverting the fix fails it. 2. `github-write-egress-scrub.test.ts` used a bare `require("node:fs")` in a package declaring `"type": "module"`. I could not reproduce the predicted `ReferenceError` — a deliberate-failure probe proves the line executes and reaches its assertion under this runner, which evidently supplies `require` through its transform. The pattern is still fragile in an ESM package and the static import is strictly better, so this fixes it rather than arguing the point. Verified: 155/155 across the six suites exercising these paths. Signed-off-by: Cto <cto@paperclip.blockcast.net>
…dary (PEN-3157) `githubPostCheckRun` (BLO-33657) landed on master after PEN-3157 branched, so it was the one write helper in github-app-auth.ts publishing free text without the scrub. Its `summary` is `verdict.reason` — the same prose the commit-status description carries — and a check-run has no 140-char cap, so it publishes MORE of any leaked value than the status does. Scrub `name`, `title`, `summary` and `details_url` inside the helper, the same way the status and comment helpers do, so every present and future caller inherits the control. Without this the file-level "egress-scrubbed" classification in the outbound coverage contract would be a false claim. The PR's own contract test gains a behavioural case (credential-shaped summary past 140 chars is redacted; an ordinary check-run passes through byte-for-byte) and pins the scrub inside the check-run helper alongside the other two. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ract github-egress-outbound-coverage.test.ts (PEN-3152) recorded the server-side write set as `unscrubbed` because the scrubber was not exported from packages/adapter-utils' barrel and so was structurally unreachable from server/. Its own comment said PEN-3157 would close that and that the "structurally unreachable" assertion is what would flag the table as stale. PEN-3157 is this branch, so the table is updated to describe the post-PR world: - github-app-auth.ts: unscrubbed -> egress-scrubbed. PEN-3157 exports `scrubGitHubEgressText` from the barrel, imports it here, and applies it as `scrubOutboundGitHubText` inside githubPostCommitStatusDetailed (context, description, target_url), githubPostIssueComment (body) and githubPostCheckRun (name, title, summary, details_url). The installation token POST in the same file carries a JWT and no authored text and is deliberately not scrubbed; noted on the row. - github-review-gate-authority.ts: unscrubbed -> egress-scrubbed. It builds its own status request (caller-supplied token + abort signal) and calls `scrubOutboundGitHubText` directly on context, description and target_url. Its repository_dispatch client_payload is ids only (app, installation, delivery, PR number, head SHA) and is deliberately not scrubbed; noted on the row. The "records that the scrubber is structurally unreachable from server/" case is replaced by its positive counterpart: the barrel exports the scrub, github-app-auth.ts imports it from @paperclipai/adapter-utils, and the server wrapper still delegates to the shared core. A new case mirrors the launcher guard for the server family: every file claimed as scrubbed must CALL the scrub, not merely mention it. The table-hygiene floor is extended so a server table with zero scrubbed writes reads as a regression, not a neutral starting state. `runtime` on the egress-scrubbed variant now names what carries the scrub for either family (compiled runtime for a launcher, the server helper for a service file); no wrapper row or assertion changes. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…(PEN-3157) Addresses Ally's review of 49dfeca. Important (Ally): both writer enumerations walked `server/src/services` one level deep, so `server/src/routes/` (63 files, including `github-webhook.ts`) and `server/src/services/recovery/` were invisible to them. No live leak — a recursive walk finds exactly the same two writers today — but the guard is the mechanism meant to catch the NEXT writer, and a new `ghFetch`-based write under either directory would have shipped unscrubbed with both tests green. Both now walk `server/src` recursively; `SERVER_WRITE_COVERAGE` is keyed by path relative to `server/src` so same-named files stay distinguishable. A recursive walk that silently matched nothing would read as "everything is covered", so each guard keeps its positive control and gains a scope control naming `routes/github-webhook.ts` — a file only a descending walk can reach. Verified fail-first: flipping `recursive` back to `false` fails 3 tests. Also fixes a defect of the same family found while addressing the above, on this PR's own change. `enqueueGithubCommitStatusDelivery` scrubbed the description in its INSERT `values` but the `onConflictDoUpdate` arm used a raw `input.description.slice(0, 140)` — trim-then-scrub, the exact failure the scrub-before-cap fix exists to prevent. The conflict target is (repoFullName, sha, context), so every re-evaluation of the same gate context on the same head takes that arm: the bypass sat on the dominant path, not an edge case. Both arms now derive from one `scrubbedDescription` binding, with a regression test that fails against the pre-fix update arm. Suggestion (Ally): the at-rest comment claimed "the durable row never holds credential-shaped text", while `context` and `targetUrl` persist verbatim. Narrowed to `description`, which is what the code delivers. Egress for the other two is covered — the replay path scrubs both in `githubPostCommitStatusDetailed`. Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: Cto <cto@paperclip.blockcast.net>
49dfeca to
f097134
Compare
|
@ally please review head Addresses your review of Important — writer enumeration scope. Agreed, and it was in both guards. Confirming your read independently: the recursive walk finds exactly the same two writers, so this changes no classification today — the gap was in what the guard could see, not in what it covered. Your caveat about a recursive walk matching nothing is the part I took most seriously. Each guard keeps its positive control and gains a scope control asserting the walk reaches Also fixed — the same shape, on this PR's own change. Suggestion — at-rest comment. Narrowed to Verification: 🤖 Generated with Claude Code |
There was a problem hiding this comment.
Ally — Consolidated PR Review
Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: f097134
Prior Findings Dispositioned (1)
- prior:49dfeca important 1 — fixed —
server/src/__tests__/github-write-egress-scrub.test.ts:377— both guards now enumerate recursively. This one walksreaddirSync(serverSrc, { recursive: true, encoding: "utf8" })overserver/src, and the sibling derivationserverSourceFiles()atgithub-egress-outbound-coverage.test.ts:217does the same, soroutes/andservices/recovery/are inside the set rather than invisible to it. Each keeps its positive control (:393pinsservices/github-app-auth.tsinwriters, so an empty derivation cannot read as coverage) and each gains the scope control my caveat asked for::384andgithub-egress-outbound-coverage.test.ts:312both assertroutes/github-webhook.tsis reachable — a file only a descending walk can see — and:314additionally pinsservices/recovery/non-empty.SERVER_WRITE_COVERAGEis re-keyed relative toserver/src("services/github-app-auth.ts",:144), so same-named files in different directories stay distinguishable. I re-derived the writer set independently at this head and it is unchanged at two, both classifiedegress-scrubbed— confirming the gap was in what the guard could see, not in what it covered.
Critical Issues (0)
Important Issues (0)
Suggestions (2)
- [native-codex]
server/src/__tests__/github-write-egress-scrub.test.ts:389— the writer predicate issource.includes("ghFetch(")plus/method:\s*"(?:POST|PATCH|PUT|DELETE)"/, and the sibling atgithub-egress-outbound-coverage.test.ts:228is identical. Having just widened where the guard looks, the remaining narrowness is what it recognises: the method test only matches a double-quoted literal appearing inline in the same file, so aghFetchwhose options object is assembled elsewhere, or whose method is a variable or template literal, is not classified — the fail-open direction, in the mechanism meant to catch the next writer. Nothing escapes today (both writers build their request inline and Prettier pins double quotes repo-wide), so this is a widening-of-alphabet note rather than a live hole — the same shape as the scope finding, one level down. - [gstack/review]
server/src/services/github-app-auth.ts:1202—githubPostCommitStatusDetailednow scrubscontexton the way out, butcontextis an identity field rather than prose: the outbox persists it raw and keys its upsert on(repoFullName, sha, context), andgithubGetLatestCommitStatusForContextfilters on the rawinput.contextat:1102. If a context ever matched a detector, the status would be published under a redacted context while every lookup used the unredacted one, and the gate could not observe its own status. Latent only — every context in the repo is a fixed literal that no detector matches. Worth flagging because the reasoning given for not scrubbingcontextat enqueue (it "would risk the delivery identity") applies to the send-time scrub as well, just later; if that argument holds at enqueue it is worth stating why it does not bite at the boundary.
Strengths
- The
ON CONFLICTfix is the right shape:scrubbedDescriptionis bound once atgithub-status-delivery-outbox.ts:527and both arms derive from it (:539insert,:569update), so the two paths cannot diverge again. I checked for a third arm —input.descriptionnow appears nowhere in the file except inside that single binding. - Its regression test earns its keep twice over.
expect(second.id).toBe(first.id)is the control proving the update arm was actually taken rather than a second insert, and the assertion keys on the[paperclip-egress-scrubmarker rather than the token's absence — so trim-then-scrub, which also removes the token by cutting it, fails the test for the right reason. - Identifying the conflict arm as the dominant path, not an edge case, is the correct severity read: re-evaluating the same gate context on the same head always takes it.
- The scope controls are the part that makes the recursive walk trustworthy. A recursive walk matching nothing returns the same empty set as full coverage, and pinning a file only a descending walk can reach is what distinguishes the two.
isOpaqueSecretValueis deliberately bound to assignment values andisJwtrequires a header that decodes to JSON carryingalg, so applying the scrub to new identity-shaped fields (context,name,details_url) does not put 40-hex SHAs or dotted hostnames at risk of redaction — I verified this against the detector set rather than assuming it.- The
pr-comment-review-gate.tscomment answers the alphabet question with the capture group that enforces it and names the test that fails if the class widens, instead of asserting safety in prose.
Recommended Action
- No blocking changes requested.
- Merge once the remaining required CI checks finish green.
Thinking Path
Linked Issues or Issue Description
github-mcp-serverwrapper — see the coupling note under Risks), fix(security): refuse credential-shaped material at the git publish boundary (PEN-3156) #1753 (PEN-3156, thegitpublish boundary), fix(security): secret-scrub resultJson and error before persistence (PEN-3153) #1746 (PEN-3153,resultJsonbefore persistence), fix(redaction): mask five vendor credential shapes on the run-log free-text path (PEN-3139) #1736 (PEN-3139, the run-log free-text path).The underlying gap, stated directly:
ghFetch(server/src/services/github-fetch.ts:19) is a barefetchwrapper. Its write callers reachPOST /repos/{r}/issues/{n}/commentsandPOST /repos/{r}/statuses/{sha}.githubPostIssueCommenttakes an arbitrary string by signature; it is benign today only because its single caller passes a template. Status descriptions are lower-visibility than PR comments but equally public, and the durable outbox replays a persisted description rather than emitting it once.What Changed
packages/adapter-utils/src/index.ts— re-exportscrubGitHubEgressText,redactionMarker, and the result types.serveralready declared@paperclipai/adapter-utilsas a dependency; only the barrel was missing, which is the whole reason the scrub was unreachable.server/src/services/github-app-auth.ts— newscrubOutboundGitHubTexthelper; applied ingithubPostIssueComment(body) andgithubPostCommitStatusDetailed(description,context,target_url). The description scrub runs before the 140-character trim, because trimming first can cut a vendor-key prefix or PEM header below its detector's threshold while leaving most of the secret intact.server/src/services/github-status-delivery-outbox.ts— the durable outbox truncateddescriptionto 140 characters at enqueue, so on that path the value was already cut by the time the send-side helper scrubbed it, and a credential straddling the cut survived as an undetectable fragment that was persisted and republished on every replay. It now scrubs before the cap. (Found by Ally's review of291643f; my original description claimed the outbox replay path was covered by the helper alone, which was wrong.) Scrubbing at enqueue also means the durable row never holds credential-shaped text at rest.server/src/services/github-review-gate-authority.ts— this worker builds its own request (it carries a caller-supplied token and an abort signalgithubPostCommitStatusDetaileddoes not model), so it was the one writer the helper-level fix did not reach. It now calls the scrub directly. Its fields are a fixed template, a config-supplied context and GitHub's ownhtml_url, so the scrub is a byte-for-byte no-op today; it is applied so that stays true if a variable is interpolated later.server/src/__tests__/github-write-egress-scrub.test.ts— new. Includesleaves no server-side GitHub writer outside the scrub, which derives the writer set from source (aghFetch(call plus a mutating method) and requires every member to reach the scrub, with a positive control so an empty set cannot satisfy it vacuously.server/src/services/pr-comment-review-gate.ts— comment only. Records why the unrecognized-ledger-verb interpolation is not an unscrubbed republication of model text, since it reads like one (see below).The scrub logs the redaction classes and never the text — a log line quoting the match would republish the secret into the run transcripts PEN-3139 is narrowing.
Not applied at
ghFetchghFetchis the wider boundary but the wrong altitude: it sees an opaque serialized body and cannot tell a prose field from protocol, so scrubbing there risks rewriting a GraphQL query or an id. At the helpers the free-text fields are named by the signature and each is scrubbed for what it is.I filed
pr-comment-review-gate.ts:348as live republication of model-authored text into a public commit-status description. That was wrong. The verb reaches that line through the single capture group([a-z][a-z-]*)inPRIOR_FINDING_DISPOSITION_PATTERN(ally-review-detection.ts:202), whosedisposition:write is the field's only producer. The verb is unbounded in length but not in alphabet, and the alphabet decides whether a credential fits — an AWS key id, a bearer token, a JWT and a PEM all carry characters outside[a-z-]. The row's own proposed remedy ("an/^[a-z-]+$/admission would close it at source") was already true when the row was written; I misread a length statement in the source comment as an alphabet statement. Handled by pinning the character class in a test rather than by redacting the verb list, with the boundary scrub covering the same description regardless.Verification
The regression set includes the DB-backed
github-status-delivery-outboxsuite (embedded Postgres), since the outbox persists and replays status descriptions.Rebased onto master 2026-09-18 and carrying PEN-3152's coverage reconciliation (see Risks). Re-verified on the rebased tree,
tscand all seven affected suites:Mutation-tested, because a test that cannot fail when the control is removed proves nothing:
githubPostIssueComment([^\s]+)github-review-gate-authority.tsto the unscrubbed writeBuildandTypecheck + Release Registryboth reported success on291643f, which settles the judgement below in favour of the environmental explanation. They re-run on the current head.Typecheck is a judgement here, with a named falsifier — not a clean result.The falsifier did not fire; this is now a clean result. On the rebased tree, with a realpnpm install --frozen-lockfilein the workspace under test,tsc -p server/tsconfig.json --noEmitreports 0 errors. The earlier localTS2305: '@paperclipai/adapter-utils' has no exported member 'scrubGitHubEgressText'was exactly the environmental cause predicted: the sandbox borrowed/app/node_modules, whose workspace symlink resolved to/app/packages/adapter-utils/src/index.ts— the image's own older copy, with 0 occurrences of the symbol against this branch's 2. Resolving the package against the tree under test clears it. Recorded rather than deleted, because the original paragraph committed to a falsifier and the honest outcome is to report that it was checked and came back clean.Risks
scrubGitHubEgressTextreturns its input byte-for-byte when no detector fires, so ordinary gate prose, contexts and PR URLs are unchanged. A test asserts exactly that for a real gate verdict plustarget_url.[paperclip-egress-scrub redacted: …]marker naming the class, so a reviewer can tell a scrub from a truncation. The one shape that could break a field's contract is a credentialedtarget_url, which would become a non-URL and be rejected by GitHub with a 422; that is the intended trade, since the alternative is publishing the credential.Merge-order coupling with fix(security): scrub agent-authored text on the github MCP egress path (PEN-3152) #1747— RESOLVED 2026-09-18; this PR now carries the reconciliation.server/src/__tests__/github-egress-outbound-coverage.test.tsasserted the barrel does not carry the scrub — a ratchet written to fail at exactly this moment rather than silently assert an obsolete fact. fix(security): scrub agent-authored text on the github MCP egress path (PEN-3152) #1747 merged first (2026-09-15), this branch tripped it on the merge ref, and the last commit here applies the replacement posted on #1747 verbatim: newegress-scrubbed-in-processCoveragevariant, bothSERVER_WRITE_COVERAGErows reclassified withwhytext, the assertion inverted, two prose docblocks inverted with it. The inverted assertion pins the symbol, not the module path — a barrel can import./github-egress-scrub.jsfor a type or side effect without re-exporting the function, which would satisfy a path check while leavingserver/unable to call it.table hygieneis unchanged and not vacuous: it skips onkind !== "unscrubbed", and thegitrow stays unscrubbed under PEN-3156 (fix(security): refuse credential-shaped material at the git publish boundary (PEN-3156) #1753, still open). Verified by running master's original assertion against this tree (fails on exactly that one test, reproducing the merge-ref failure) and by dropping the re-export (fails the replacement).Model Used
Claude Opus 5 (
claude-opus-5, 1M context), extended thinking, driven through Claude Code with tool use and code execution.Checklist
Fixes: #/Closes #/Refs #OR (b) described the issue in-PR following the relevant issue template291643f; the branch has since been rebased onto master to pick up fix(security): scrub agent-authored text on the github MCP egress path (PEN-3152) #1747's ratchet, so every lane re-runs on the new head. Will report the live result rather than predicting it.291643fare fixed inc722bdb; see the reply comment