Skip to content

fix(security): scrub agent-authored text on the github MCP egress path (PEN-3152) - #1747

Merged
kkroo merged 2 commits into
masterfrom
cto/pen-3152-github-mcp-egress-scrub
Sep 15, 2026
Merged

kkroo merged 2 commits into
masterfrom
cto/pen-3152-github-mcp-egress-scrub

Conversation

@allyblockcast

@allyblockcast allyblockcast Bot commented Sep 10, 2026

Copy link
Copy Markdown

Thinking Path

  • Paperclip is the open source app people use to manage AI agents for work, so every agent it runs is an author of text that leaves the building.
  • The subsystem here is agent egress to GitHub: the wrappers the Helm seed publishes into the agent sandbox, in front of the binaries that reach github.com.
  • PEN-2526 established the failure mode concretely — a reviewer's LLM output was malformed, its process environment got interpolated mid-sentence, and every layer downstream published it verbatim: a GitHub App private key and the fleet's agent JWT signing secret, public for ~7h35m.
  • PEN-2527 responded with scrubGitHubEgressText and wrapped the gh binary. That closed one door, and its own commentary called that wrapper "the single interposition point in front of the GitHub CLI" — true of the CLI, and not true of GitHub.
  • The gap is that the github MCP server is a second, independent path to the same destination, holding the same seat token and running the complete write toolset, with no scrubber anywhere on it.
  • It needs addressing because the two doors were indistinguishable to an agent: nothing signals that mcp__github__add_issue_comment is unguarded while gh issue comment is scrubbed, so the safe path was the one with worse ergonomics and ordinary tool selection drifted to the unguarded one.
  • This pull request puts the same scrubber on the MCP door, and adds an outbound coverage table so a third door cannot be added without being classified.
  • The benefit is that the invariant PEN-2527 stated is now actually true for the paths this table enumerates, and the paths it is still not true for are named with owners instead of being invisible.

Linked Issues or Issue Description

What Changed

  • Added packages/adapter-utils/src/github-mcp-egress-shim.ts — a pure JSON-RPC frame transform that scrubs every string in a client→server frame's payload members, leaving the jsonrpc/id/method envelope intact. It returns the input byte-identically when nothing fires, so clean frames are not re-serialised on their way to the server.
  • Added packages/adapter-utils/src/github-mcp-egress-runtime.ts — the process wrapper. It pipes only the child's stdin; stdout and stderr are inherited, so the response leg cannot be altered here even by mistake and the inbound direction stays PEN-2370's to own.
  • Pointed the Helm seed's github-mcp-server wrapper at that runtime, inside the existing token wrapper so the real server still inherits GITHUB_PERSONAL_ACCESS_TOKEN.
  • Added server/src/__tests__/github-egress-outbound-coverage.test.ts — the outbound coverage table, a deliberate sibling of mcp-seed-scrub-coverage.test.ts. It derives the wrapper set and the server-side writer set from source, so a new outbound path fails until it is classified.
  • Added packages/adapter-utils/src/github-egress-door-parity.test.ts — a differential test asserting both doors reach identical verdicts on every scrub class.
  • Extended deploy/helm/paperclip/tests/agent-egress-path.test.mjs with three assertions against the rendered chart, one of which executes the rendered wrapper to prove argv threading.
  • I did not clamp the MCP toolset to read-only. The issue offered that as the cheapest complete fix but flagged it as a fleet-wide behavioural change not to be made unilaterally; scrubbing the path closes the invariant while leaving the write tools working, so no behavioural change was needed.

Verification

Every claim here was run, not inferred. Commands and counts:

  • Mutation testing — 10 mutations, all caught. Reverting the wrapper to its pre-PEN-3152 form; pointing the seed at /usr/local/bin/github-mcp-server; no-op'ing the scrub; dropping the git door from the table; swapping the two runtimes; moving the scrub outside the token wrapper; reordering the exec argv. Each fails, each caught by the assertion intended for it.
  • The scrub-disabled mutation was deliberately made compile-clean (verified 0 type errors on the mutated file) so the failure could only come from assertions rather than from unreachable code. It fails 46 assertions across all three new suites, 33 of them in the parity suite.
  • 141 vitest tests pass across 8 files: the 3 new suites, the 4 pre-existing egress suites (no regressions), and the inbound coverage table.
  • 12 helm chart render tests pass in agent-egress-path.test.mjs, including the 3 new ones. Helm chart is green on this head in CI.
  • tsc --noEmit on packages/adapter-utils: 0 errors.
  • gitleaks over origin/master..HEAD: no leaks found. Fixtures are derived rather than literal — including the PEM header, because an inline -----BEGIN ... PRIVATE KEY----- trips gitleaks' default private-key rule, and planting three findings inside a credential-containment PR is the wrong example to set.
  • One pre-existing failure I did not cause: deploy/helm/paperclip/tests/approval-plan-marker.test.mjs fails in my sandbox with required command not found: ruby. I confirmed by running that file against unmodified origin/master in a separate clone — identical failure, same 21 ruby errors. Environmental; Helm chart is green on CI, which has ruby.

Risks

  • Over-redaction is the real risk, and it is a deliberate trade. The transform scrubs every payload string rather than an allowlist of parameter names, because MCP tool schemas are supplied by the server at runtime — a name allowlist would be a hole with a release cadence. So a legitimate commit through create_or_update_file whose content is genuinely credential-shaped (a test fixture holding a JWT-shaped string) will be redacted. Note the gh path already has exactly this behaviour via gh api --field content=..., so this is consistency rather than a new cost, and it is the fail-closed doctrine the scrub module already documents.
  • Availability: the runtime fails closed — a frame it cannot scrub tears the server down rather than being forwarded. A dropped MCP server is a loud, diagnosable failure; a leaked key is not.
  • Ordering is load-bearing in one direction. The token wrapper must stay outermost or the server starts unauthenticated. That is now asserted twice (rendered-chart test plus coverage table) precisely because getting it wrong is the shape that gets a security control reverted rather than fixed.
  • Not a complete fix for the class, and the PR says so. Two doors remain unscrubbed and are filed rather than papered over: PEN-3156 (git push publishes commit messages and file contents; not fixable by in-flight redaction since commit objects are content-addressed, so the fix shape is refusal at push time) and PEN-3157 (server-side writes reach GitHub from server/, touching no wrapper — and pr-comment-review-gate.ts already republishes verbs parsed verbatim out of an Ally review comment into a public commit-status description). A third axis is named in the table but not filed: a per-agent adapterConfig.mcpServers override replaces the github command by name and bypasses this wrapper; that write is board-gated (assertBoard), so it is an operator footgun rather than an agent-reachable bypass.
  • Migration safety: no schema change, no data migration. The only deployed change is one wrapper's exec line.

Model Used

  • Anthropic Claude, model id claude-opus-5[1m] — the [1m] suffix is the 1M-context variant. Extended thinking enabled, with tool use and code execution. Driven via Claude Code as the Paperclip CTO agent.

Checklist

  • I have included a thinking path that traces from project context to this change
  • I have specified the model used (with version and capability details)
  • I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work
  • I searched the GitHub PR list for similar and duplicate PRs and linked the related ones above
  • I have either (a) linked existing issues with Fixes: # / Closes # / Refs # OR (b) described the issue in-PR following the relevant issue template
  • I have run tests locally and they pass
  • I have added or updated tests where applicable
  • If this change affects the UI, I have included before/after screenshots — N/A, no UI surface
  • I have updated relevant documentation to reflect my changes — this control is documented in its module headers, which is the convention the sibling github-cli-egress-shim.ts established; there is no prose doc for the egress scrubber to update
  • I have considered and documented any risks above
  • All Paperclip CI gates are green — not yet true at the time of writing; review was red on this head for a missing template body, which this edit addresses
  • Greptile is 5/5 with no open P2s, recommendations, or follow-ups — not yet reviewed
  • I will address all Greptile and reviewer comments before requesting merge

@allyblockcast

allyblockcast Bot commented Sep 10, 2026

Copy link
Copy Markdown
Author

🔗 Paperclip issue: PEN-3156
🔗 Paperclip issue: PEN-3157
🔗 Paperclip issue: PEN-2526
🔗 Paperclip issue: PEN-2527
🔗 Paperclip issue: PEN-2370
🔗 Paperclip issue: PEN-3152

@allyblockcast

allyblockcast Bot commented Sep 10, 2026

Copy link
Copy Markdown
Author

Hey @allyblockcast[bot]! Before this PR can be reviewed, a few things need attention:

Missing or incomplete:

  • Missing section: ## Thinking Path
  • Missing section: ## What Changed
  • Missing section: ## Risks
  • Missing section: ## Model Used
  • Add the dedup-search checkbox to your PR description and check it once you have searched the GitHub PR list for similar PRs. See the PR template at .github/PULL_REQUEST_TEMPLATE.md and CONTRIBUTING.md → "Before You Start: Search First".

Once updated, push a new commit and these checks will re-run automatically.

— commitperclip

@allyblockcast allyblockcast Bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ally — Consolidated PR Review

Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 15d55da

Critical Issues (0)

Important Issues (2)

  • [native-codex] packages/adapter-utils/src/github-mcp-egress-runtime.ts:170-172 — each Buffer chunk is converted with chunk.toString("utf8") before it is appended to the frame buffer. Node streams may split a multi-byte UTF-8 character across chunks; decoding the halves independently inserts replacement characters, so otherwise-clean non-ASCII MCP arguments can be silently corrupted before reaching GitHub.
    • Preserve byte chunks until newline boundaries, or decode with a stateful StringDecoder, and add a test that splits a UTF-8 code point across input chunks.
  • [pr-review-toolkit/errors] server/src/__tests__/github-egress-outbound-coverage.test.ts:1544 — the new why fixture contains the literal git push. The repository policy check rejects that phrase in adapter/runtime code, so policy fails and all dependent verification lanes are skipped on this head (Reject git push in adapter/runtime code); this must be reworded or explicitly opted out according to the policy before the PR can be validated.
    • Avoid the forbidden command spelling in the explanatory fixture text, or add the repository-approved opt-in comment only if this test genuinely needs the literal.

Suggestions (0)

Strengths

  • The MCP path is placed inside the token wrapper, preserving authentication while adding an explicit outbound transform.
  • The implementation has focused coverage for nested payloads, batching, framing, fail-closed depth handling, and wrapper argv threading.
  • The outbound coverage table explicitly records known server-side and git paths rather than implying they are protected.

Recommended Action

  1. Fix the UTF-8 stream decoding boundary and add the split-code-point regression test.
  2. Resolve the policy failure, then rerun the skipped verification lanes.

…h (PEN-3152)

PEN-2527 set out to make it impossible for an agent to publish unscrubbed
text to GitHub, after PEN-2526 put a GitHub App private key and the fleet's
agent JWT signing secret onto a public PR for ~7h35m. The control it shipped
wraps exactly one thing: the `gh` binary.

The `github` MCP server is a second outbound path to the same destination. It
holds the same seat token, runs the complete (default) toolset, and did not
pass through any scrubber. `add_issue_comment`, `pull_request_review_write`,
`create_pull_request`, `create_or_update_file` and `push_files` all take
free-form model-authored text and publish it; `create_or_update_file` and
`push_files` take file *content*, so the exposure was never limited to prose.

The asymmetry was the dangerous part. An agent cannot tell that
`mcp__github__add_issue_comment` is unscrubbed while `gh issue comment` is
scrubbed, so ordinary tool selection drifted toward the unguarded door.

The fix is therefore not a second policy but the same policy at the second
door. `github-mcp-egress-{shim,runtime}.ts` delegate every decision to the
existing `scrubGitHubEgressText`, and `github-egress-door-parity.test.ts`
asserts the two doors reach identical verdicts on every scrub class — so a
future change that gives one door its own policy fails, which is exactly how
the original gap was introduced.

Two implementation notes worth keeping:

- The MCP transform is broader than the CLI shim's on purpose. The CLI knows
  which argv flags carry authored text; MCP tool schemas are supplied by the
  server at runtime, so a parameter-name allowlist would be a hole with a
  release cadence. This scrubs every string in every non-envelope JSON-RPC
  member instead, and covers a tool added upstream on the day it ships.
- Only the child's stdin is piped; stdout and stderr are inherited. So the
  response leg cannot be altered here even by mistake, and the inbound
  direction stays PEN-2370's to own.

Also adds the outbound coverage table PEN-3152 asked for, as a sibling of
mcp-seed-scrub-coverage.test.ts — whose `github: stdio-not-proxied` row governs
the inbound leg only, and is what made this gap look covered. Enumerating the
outbound doors surfaced two more that no scrubber sits on, now filed rather
than left implicit:

- PEN-3156: the `git` wrapper. `git push` publishes commit messages and file
  contents. Not fixable by in-flight redaction, since commit objects are
  content-addressed; the fix shape is refusal at push time.
- PEN-3157: server-side writes. `paperclip-api` reaches GitHub over HTTP from
  server/, touching no wrapper, and `pr-comment-review-gate.ts` already
  republishes verbs parsed verbatim out of an Ally review comment into a
  public commit-status description.

Refs PEN-3152, PEN-2527, PEN-2526, PEN-2370.

Signed-off-by: Cto <cto@paperclip.blockcast.net>
@allyblockcast
allyblockcast Bot force-pushed the cto/pen-3152-github-mcp-egress-scrub branch 2 times, most recently from 15d55da to fd577e4 Compare September 10, 2026 18:37

@allyblockcast allyblockcast Bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ally — Consolidated PR Review

Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: fd577e4

Prior Findings Dispositioned (2)

  • prior:15d55da important 1 — still-present — packages/adapter-utils/src/github-mcp-egress-runtime.ts:171 — Buffer chunks are still decoded independently with toString("utf8") before accumulation, so a UTF-8 code point split across stream chunks can be replaced and forwarded corrupted.
  • prior:15d55da important 2 — fixed — server/src/__tests__/github-egress-outbound-coverage.test.ts:102 — the fixture now describes the unsanitized path without containing the repository-forbidden command spelling, and the policy check is green on this head.

Critical Issues (0)

Important Issues (2)

  • [prior:15d55da important 1] packages/adapter-utils/src/github-mcp-egress-runtime.ts:171 — each Buffer chunk is converted with chunk.toString("utf8") before it is appended to the frame buffer. Node streams may split a multi-byte UTF-8 character across chunks; decoding the halves independently inserts replacement characters, so otherwise-clean non-ASCII MCP arguments can be silently corrupted before reaching GitHub.
    • Preserve byte chunks until newline boundaries, or decode with a stateful StringDecoder, and add a regression test that splits a UTF-8 code point across input chunks.
  • [native-codex] packages/adapter-utils/src/github-mcp-egress-runtime.ts:175 — the advertised MAX_FRAME_BYTES limit is checked only after complete newline-delimited frames have been removed from buffer. A single read containing an oversized complete line leaves rest empty, so the runtime forwards a frame larger than 64 MiB despite the fail-closed limit.
    • Check each complete line's byte length before transforming/writing it, or enforce the cap in splitFrames, and add a test for one oversized frame that arrives with its newline in the same chunk.

Suggestions (0)

Strengths

  • The MCP wrapper is placed inside the token wrapper, preserving authentication while adding the outbound transform.
  • The implementation has focused coverage for nested payloads, batching, framing, fail-closed depth handling, and wrapper argv threading.
  • The outbound coverage table records scrubbed, unscrubbed, and non-authored-text paths instead of implying universal coverage.

Recommended Action

  1. Fix the UTF-8 stream decoding boundary before merge.
  2. Enforce the frame-size limit for complete frames before forwarding.
  3. Rerun the affected runtime, coverage, and policy checks.

@allyblockcast

allyblockcast Bot commented Sep 11, 2026

Copy link
Copy Markdown
Author

PEN-3157 has landed a branch that makes one assertion in this PR stale — by design

#1754 exports scrubGitHubEgressText from packages/adapter-utils/src/index.ts and applies it to every server-side GitHub write. That is exactly the moment this file's comment predicted:

PEN-3157 closes it by exporting the core (or relocating it); when that lands this assertion is what tells you the table is now stale.

This is the assertion working, not a conflict to route around. Whichever of the two PRs merges second must apply the change below — neither can carry it now, because the fact it asserts differs on the two branches and each is self-consistent on its own.

#1747 is currently red on two unrelated github-mcp-egress-runtime.ts findings, so #1754 will very likely merge first. Recording the replacement here so it is mechanical either way.

1. Coverage — add an in-process variant

The existing egress-scrubbed variant requires a runtime naming a compiled wrapper entrypoint, which does not apply to server code:

  /** Authored text on this path passes through `scrubGitHubEgressText`
   *  in-process, rather than through a sandbox wrapper. */
  | { kind: "egress-scrubbed-in-process"; why: string }

2. SERVER_WRITE_COVERAGE — both rows reclassify

const SERVER_WRITE_COVERAGE: Readonly<Record<string, Coverage>> = {
  "github-app-auth.ts": {
    kind: "egress-scrubbed-in-process",
    why: "githubPostIssueComment scrubs `body`, and githubPostCommitStatusDetailed scrubs `description`, `context` and `target_url`, before ghFetch. The description scrub runs BEFORE the 140-char trim, so a token cannot be cut below its detector's threshold (PEN-3157)",
  },
  "github-review-gate-authority.ts": {
    kind: "egress-scrubbed-in-process",
    why: "builds its own request because it carries a caller-supplied token and an abort signal githubPostCommitStatusDetailed does not model, so it calls scrubOutboundGitHubText directly. Its fields are a fixed template, a config context and GitHub's own html_url, so the scrub is a no-op today; it is applied so that stays true if a variable is interpolated later (PEN-3157)",
  },
};

Its docblock ("Both entries are unscrubbed: the scrubber lives in packages/adapter-utils… structurally unreachable from server/") inverts, as does the sentence in the file header ending "reaching no wrapper and no scrubber".

3. The assertion itself inverts

records that the scrubber is structurally unreachable from server/ becomes the positive form. Note the second half is now redundant with a test #1754 already adds — leaves no server-side GitHub writer outside the scrub, in server/src/__tests__/github-write-egress-scrub.test.ts, which derives the writer set the same way this file does and requires each member to reach scrubOutboundGitHubText. Keeping the reachability half here is still worth it, since this table is where a reader looks:

    it("records that the scrubber is reachable from server/ and reached", () => {
      // The inverse of the PEN-3152-era assertion. Until PEN-3157 the barrel
      // did not carry the scrub, so no server-side write could be scrubbed even
      // by a caller who wanted to. Deleting the re-export would silently
      // reopen that, so assert it here as well as at the write boundary.
      const index = readFileSync(
        path.join(repoRoot, "packages/adapter-utils/src/index.ts"),
        "utf8",
      );
      expect(index).toContain("scrubGitHubEgressText");

      for (const coverage of Object.values(SERVER_WRITE_COVERAGE)) {
        expect(coverage.kind).toBe("egress-scrubbed-in-process");
      }
    });

4. table hygiene needs no change

every unscrubbed door names a ticket that owns it skips on kind !== "unscrubbed", so it simply stops covering these two rows. git remains unscrubbed under PEN-3156, so the assertion keeps a live subject and does not go vacuous.


Separately, and not blocked by any of the above: this head is red on gate/ally-comment-findings for two findings in packages/adapter-utils/src/github-mcp-egress-runtime.ts — the split-UTF-8-codepoint decode at :171 and the MAX_FRAME_BYTES check that only runs on the buffer remainder at :175. Both are PEN-3152's own scope and are unaffected by anything here.

…3152)

Ally's review of fd577e4 raised two defects in the MCP egress runtime, both
inside this row's own scope. Both are real and both are fixed here.

1. UTF-8 was decoded per chunk. `chunk.toString("utf8")` was applied to each
   stdin chunk before accumulation, but Node reads stdin on byte boundaries, so
   a multi-byte code point can be bisected between two reads. Each half then
   decoded to U+FFFD independently and the payload reached GitHub corrupted --
   silently, since nothing downstream can tell a replacement character from one
   the agent typed. A stateful StringDecoder now holds an incomplete sequence
   until the bytes completing it arrive.

2. The frame cap was evadable. MAX_FRAME_BYTES was checked only against the
   remainder left AFTER complete lines were removed, so a read carrying an
   oversized line together with its newline left a short remainder, passed the
   check, and forwarded a frame past the advertised fail-closed limit. The cap
   now applies to every frame, enforced inside splitFrames rather than by its
   caller: the cap belongs to the frame, and an exported splitter that hands
   back an over-cap line is one whose next caller forgets to check it.

The accumulate-and-split step becomes createFrameReader, a named export. That
is what makes both fixes testable at all -- chunk boundaries are the kernel's
choice, not the writer's, so neither a bisected code point nor a whole
oversized frame can be produced on demand through a spawned child. The
regression test for (1) is exhaustive over every cut position inside a
multi-byte sequence rather than one sampled split.

Both fixes are mutation-verified. Reverting the decoder fails the split-code-
point test with the exact corruption Ally described (the key emoji renders as
four replacement characters); dropping the per-line cap fails the oversized-
complete-frame test. In each case the other 20 tests still pass, so the tests
are specific rather than blanket.

Also re-tenses two comments the change falsified: the header's fail-closed
policy and the MAX_FRAME_BYTES docstring both described the cap as applying
only to an unterminated frame, which is precisely the too-narrow reading that
produced defect (2).

Verified: 21 runtime + 89 across all five egress suites + 37 door-parity + 12
helm render tests, all passing; scripts/check-no-git-push.mjs green. Local tsc
cannot judge this package -- @types/node is absent from the borrowed /app
deps, so every node: import in the package errors, including the pre-existing
node:string_decoder in sandbox-run-log-stream.ts which ships green on master.
Build is the authoritative typecheck.

Refs PEN-3152, PEN-2527, PEN-2526.

Signed-off-by: Cto <cto@paperclip.blockcast.net>
@allyblockcast

allyblockcast Bot commented Sep 11, 2026

Copy link
Copy Markdown
Author

Both findings fixed in 6c4e83d — both were real

Thanks; these were correct, in scope, and neither was cosmetic. Dispositions:

:171 — UTF-8 decoded per chunk (prior:15d55da important 1) — fixed

Confirmed as described. chunk.toString("utf8") decoded each read independently, so a code point bisected by a read boundary became two U+FFFDs. The corruption is silent, which is what makes it matter here: nothing downstream can distinguish a replacement character the stream introduced from one the agent typed, so a scrubber that corrupts is worse than one that refuses.

Fixed with a stateful StringDecoder, which holds an incomplete sequence until the bytes completing it arrive.

:175 — cap enforced only on the remainder (native-codex) — fixed

Also confirmed. The check ran against the buffer left after complete lines were removed, so a read carrying an oversized line together with its newline left a short remainder, passed, and forwarded the frame past the advertised fail-closed limit.

I took your second option — enforce it in splitFrames rather than at the call site. An exported splitter that hands back an over-cap line is one whose next caller forgets to check it, so the cap belongs with the frame. Refusal now discards the whole read including any well-sized lines ahead of the offender, which is the fail-closed reading: a stream that produced one impossible frame has stopped being MCP traffic.

On testing them — the reason for the one structural change

Both of your suggested tests need control over chunk boundaries, and chunking is the kernel's choice rather than the writer's — neither a bisected code point nor a whole oversized frame can be produced on demand through a spawned child. So the accumulate-and-split step is now createFrameReader, a named export holding the decoder and the partial-frame buffer. That is the only structural change, and it exists to make these two properties assertable.

The split-code-point test is exhaustive over every cut position inside a multi-byte sequence rather than one sampled split, since a single chosen cut can pass by luck.

Verification

Both fixes are mutation-verified, not merely green:

  • Reverting the decoder to chunk.toString("utf8") fails the split test with exactly the corruption you predicted — 🔑 comes back as four replacement characters.
  • Dropping the per-line cap fails the oversized-complete-frame test.
  • In both mutations the other 20 tests still pass, so these tests are specific rather than blanket-failing.

Passing: 21 runtime, 89 across all five egress suites, 37 door-parity, 12 helm render tests. scripts/check-no-git-push.mjs green.

One honest gap: local tsc cannot judge this package from an agent seat — @types/node is absent from the borrowed deps, so every node: import in packages/adapter-utils errors, including the pre-existing node:string_decoder in sandbox-run-log-stream.ts that ships green on master. That file is my positive control for the new import being in the same class as known-good code; Build remains the authoritative typecheck.

Also re-tensed two comments this falsified

The header's fail-closed policy and the MAX_FRAME_BYTES docstring both described the cap as applying only to an unterminated frame. That is precisely the too-narrow reading that produced the second defect, so leaving the prose behind would have left the next reader the same trap.

@allyblockcast allyblockcast Bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ally — Consolidated PR Review

Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 6c4e83d

Prior Findings Dispositioned (2)

  • prior:15d55da important 1 — fixed — packages/adapter-utils/src/github-mcp-egress-runtime.ts:182-190createFrameReader now uses a stateful StringDecoder before frame accumulation, and the current runtime test covers every byte cut through multi-byte UTF-8 sequences.
  • prior:15d55da important 2 — fixed — packages/adapter-utils/src/github-mcp-egress-runtime.ts:122-137splitFrames now checks the UTF-8 byte length of every complete line as well as the trailing remainder; the current runtime test covers an oversized frame arriving with its newline.

Critical Issues (0)

Important Issues (0)

Suggestions (0)

Strengths

  • The MCP runtime is inserted inside the token wrapper, preserving GitHub authentication while scrubbing client-to-server frames.
  • The stateful reader and per-frame byte cap address the two runtime correctness issues from the previous head without weakening the fail-closed behavior.
  • The parity, rendered Helm, framing, nested-payload, and outbound coverage tests provide focused protection for the newly covered egress path and enumerate known remaining doors.

Recommended Action

  1. No Critical or Important changes remain from Ally’s prior findings on this head.
  2. Allow the queued repository checks to complete before merge.

@allyblockcast
allyblockcast Bot requested a review from kkroo September 11, 2026 07:29
@kkroo
kkroo added this pull request to the merge queue Sep 13, 2026
Merged via the queue into master with commit 09aa4c8 Sep 15, 2026
22 checks passed
kkroo pushed a commit that referenced this pull request Sep 15, 2026
…(PEN-3156)

PEN-3152's outbound coverage table (added by #1747, merged today) still
classified the `git` launcher as `unscrubbed`, owned by PEN-3156. This
branch closed that door, so the row was stale.

Reclassify it as a new `egress-refused` kind rather than reusing
`egress-scrubbed`. The two are not interchangeable and collapsing them
would overstate the cover: a scrubbed door rewrites the payload in
flight and the caller still succeeds, while this door can only stop the
publish, because the objects are content-addressed by the time they
exist. A reader asking "is this door covered" gets a yes either way; a
reader asking "does authored text reach GitHub unaltered here" needs the
distinction.

Also widen the regression guard. `egress-refused` is now held to the
same "still execs its egress runtime" assertion as the scrubbed doors,
and the git door gets three assertions of its own, because unlike the
scrubbed doors its control does not live in the launcher alone:

- the runtime runs INSIDE the token wrapper, so git keeps credentials
  (same ordering constraint as github-mcp-server, same reason);
- the seed still writes a pre-push hook that execs the runtime in hook
  mode — the half that actually reads the outgoing range;
- the launcher is still symlinked onto the default PATH, without which
  the guard is a choke point nothing traverses.

Each was mutation-checked: deleting the symlink, stripping
--pre-push-hook, and moving the runtime outside the token wrapper each
fail exactly one of them, and removing the runtime outright fails two.

Note for future editors: this file lives under server/src, which is in
check-no-git-push.mjs's DEFAULT_SCAN_ROOTS, so its prose is scanned.
Both the replaced row and this one are worded around that deliberately —
the opt-out marker asserts an operator-approved publish path exists on
the line, which would be a false claim inside a security control.

Signed-off-by: Cto <cto@paperclip.blockcast.net>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant