Skip to content

security(mcp-gateway): let a grant enumerate the tools it exposes (PEN-2735) - #1573

Merged
kkroo merged 2 commits into
masterfrom
security/pen-2735-gateway-tool-allowlist
Sep 1, 2026
Merged

security(mcp-gateway): let a grant enumerate the tools it exposes (PEN-2735)#1573
kkroo merged 2 commits into
masterfrom
security/pen-2735-gateway-tool-allowlist

Conversation

@allyblockcast

@allyblockcast allyblockcast Bot commented Aug 31, 2026

Copy link
Copy Markdown

Thinking Path

  • Paperclip is the open source app people use to manage AI agents for work
  • Agents reach their tools through paperclip-mcp-gateway, which reverse-proxies every upstream MCP server
  • PEN-2370 closed that gateway's response-content axis — what a permitted read hands back — and explicitly names a second axis it does not reach: which resources a grant exposes at all, and what each discloses as a side effect
  • PEN-2735 is that second axis with a name on it. prometheus's get_targets returns discoveredLabels: a verbatim copy of every annotation on every scraped object, including kubectl.kubernetes.io/last-applied-configuration, which embeds the same spec.containers[].env PEN-2370 exists to redact. Same material, by a route no response scrubber sits on
  • The right control for that shape is "do not offer this tool" — and the gateway could not say it. It forwarded whatever an upstream advertised, so its grant axis was entirely unimplemented, including for k8s-ro, which already sits behind it
  • The cheaper options were checked first and do not exist: pab1it0/prometheus-mcp-server registers all 6 tools with unconditional decorators and has no tool-selection config at any layer, and dropping the whole upstream would take execute_query/list_metrics away from every agent
  • This pull request adds a per-upstream tool allowlist to the gateway, enforced on both agent-facing endpoints and on the request side as well as the response side
  • The benefit is a control that closes a class rather than a spelling — get_targets-shaped doors are closed for any upstream, by enumeration rather than by denylist, which is PEN-2370's ask 3 (b2)

Linked Issues or Issue Description

Refs PEN-2735 — [Security] Door: prometheus MCP get_targets returns discoveredLabels
Refs PEN-2370 — the invariant card; this is its ask 3 (b2), on the (b) grant-scope axis

Stacked on #1567 (base is security/pen-2370-classifier-invariant, not master). #1567 rewrites the exact tools/list assembly and tools/call dispatch blocks this touches. Draft until #1567 merges, at which point this retargets master. Searched open PRs for a tool allowlist / disabled_tools / grant-scope change on this package — none exists; #1567 is the only other open mcp-gateway PR.

What Changed

  • upstreams.tsUpstreamConfig.tools?: string[], parsed and validated (non-array and non-string entries are rejected rather than ignored, since a silently-dropped allowlist is the fail-open). New isToolAllowed is the single authorization predicate.
  • server.ts — four enforcement points, all asking that one predicate on the upstream tool name: aggregate tools/list (before the prefix__ is added), aggregate tools/call, the prefixed route's request guard, and the prefixed route's tools/list reply.
  • server.tswriteResponse's new upstream parameter is required. A call site that forgets it is a compile error, not a route where filtering silently stops; gateway-built bodies pass an explicit null.
  • response-scrub.ts — the JSON/SSE framing walk now applies a composed document transform over a single parse. transformResponseBody is deliberately not exported and the redaction arm cannot be opted out of; scrubResponseBody remains the only entry point, so the writeResponse chokepoint guard from security(mcp-gateway): give response bodies exactly one exit, and enforce it (PEN-2370) #1567 is unchanged and still passing.
  • response-scrub.ts + server.tsstripLeadingBom is now shared with the request side. Without it a BOM-prefixed body reads as unparseable and forwards unexamined: a live bypass of the guard added here, and a pre-existing audit gap where such a request logged its HTTP verb instead of its JSON-RPC method.
  • Tests — 25 new assertions (tool-allowlist.e2e.test.ts, upstreams.test.ts).

Verification

cd packages/mcp-gateway
pnpm exec tsc --noEmit -p tsconfig.json   # clean
pnpm exec vitest run                      # 391 passed (7 files)

The new tests were run against the parent commit first. 9 of them fail there — the fix is load-bearing for every security assertion:

assertion vs 130dcd6 (unfixed) with this change
denied tool absent from prefixed tools/list (json + sse) ❌ ×2
denied tool absent from aggregate tools/list
empty allowlist denies all
denied tools/call refused — prefixed / aggregate ❌ ×2
…wrapped in a JSON-RPC batch array
…with a leading UTF-8 BOM ❌ (15s timeout)
…carrying no usable tool name

The other assertions are controls, and they pass in both states by design — without them the suite would be satisfied by a gateway that simply denies everything: no-allowlist stays fully unrestricted, an allowed call still reaches the upstream, and the scrubber still redacts env material in the tools it keeps.

Every "never reached the upstream" claim is asserted against a ledger the fake upstream keeps of what it was actually asked to run, not against the gateway's reply — a reply-only assertion passes just as happily if the call executed and its result were discarded.

Coverage is on both agent-facing endpoints and both framings, plus the established-session fast path (the branch every real agent call after the first takes), because a guard proven only on the cold path is a guard on the path nobody repeats.

Risks

  • Behavioural change is opt-in and currently inert. No seeded upstream declares tools, and absent means unrestricted, so this PR changes nothing in production until a registry entry opts in. Moving prometheus behind the gateway with an allowlist is deliberately a separate change.
  • An empty tools: [] denies everything. Chosen so the plausible typo (writing [] meaning "all") fails visibly and safely; reading it as "all" would make the same typo fail open, and rejecting it at parse would take the gateway down on a one-line registry edit.
  • The writeResponse signature change touches 20 call sites. Mechanical, and the required parameter is the point — but it is the largest surface here, and tsc is what backs it.
  • Scope, stated rather than implied — and pinned as an executable test. The allowlist governs tools and only tools. A non-tool primitive (resources/read) is not filtered by it, and is still covered by the scrubber. That residual is narrow but real, and the test is where it will surface if those two ever disagree.
  • A pre-existing limitation found while writing these tests, recorded not silently worked around: an SSE-framed upstream contributes no tools to the aggregate /mcp endpoint, because that assembly parses each reply with a bare JSON.parse. It reproduces on the parent commit, and it fails closed — tools go missing, nothing is disclosed — so it is an availability bug for its own ticket rather than a hole in this guard. It is a third place that decides where a document begins, next to the two this PR unified.

Model Used

Claude Opus 4.5 (claude-opus-4-5), extended thinking, via Claude Code with tool use and code execution.

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 have searched GitHub for duplicate or related PRs and linked them 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 — the rationale lives in the module doc comments, per this package's convention
  • I have considered and documented any risks above
  • All Paperclip CI gates are green — pending first run
  • Greptile is 5/5 with no open P2s, recommendations, or follow-ups — pending review
  • I will address all Greptile and reviewer comments before requesting merge

@allyblockcast

allyblockcast Bot commented Aug 31, 2026

Copy link
Copy Markdown
Author

🔗 Paperclip issue: PEN-2735
🔗 Paperclip issue: PEN-2370

1 similar comment
@allyblockcast

allyblockcast Bot commented Aug 31, 2026

Copy link
Copy Markdown
Author

🔗 Paperclip issue: PEN-2735
🔗 Paperclip issue: PEN-2370

@allyblockcast
allyblockcast Bot changed the base branch from security/pen-2370-classifier-invariant to master August 31, 2026 17:31
…N-2735)

PEN-2370 closed the response-content axis: what a permitted read hands
back. It names a second axis it does not reach — which resources a grant
exposes at all, and what each discloses as a side effect. `prometheus`'s
`get_targets` is that axis. It returns `discoveredLabels`, a verbatim copy
of every annotation on every scraped object, including
`kubectl.kubernetes.io/last-applied-configuration` — the same
`spec.containers[].env` PEN-2370 exists to redact, by a route no response
scrubber sits on. The right control is "do not offer this tool", and the
gateway had no way to say that: it forwarded whatever an upstream
advertised.

So `UpstreamConfig` gains an optional `tools` allowlist, enforced at four
points that all ask ONE predicate, `isToolAllowed`, on the upstream tool
name:

  - aggregate `tools/list`, before the `prefix__` is added
  - aggregate `tools/call` dispatch
  - the prefixed route's request guard, before any forward
  - the prefixed route's `tools/list` reply

Request-side enforcement is the half that matters: a response filter can
remove a tool from a listing but cannot un-execute a call, so filtering
`tools/list` alone would leave every denied tool advertised-but-absent and
still callable by name. Response-side matters too — without it a denied
tool is uncallable but still advertised. Both, or it is a partial fix
presented as a whole.

Three things are structural rather than remembered:

  - `writeResponse`'s upstream parameter is REQUIRED, not optional. A new
    call site that forgets it is a compile error instead of a route where
    filtering silently stops. Gateway-built bodies pass an explicit `null`,
    which a reviewer can check; an omitted argument is not reviewable.
  - The filter rides the scrubber's classifier instead of adding one.
    `response-scrub.ts` now applies a composed document transform over a
    single parse, and `transformResponseBody` is deliberately NOT exported:
    every fail-open that file records (BOM-prefixed JSON, CR-only event
    stream, a stream opening on `id:`) was a classifier that had drifted
    from its peer. A filter that re-sniffed the body would have rebuilt that
    shape one release after it was closed. Redaction cannot be opted out of
    on the way through.
  - `stripLeadingBom` is now shared with the REQUEST side. Without it a
    BOM-prefixed body reads as unparseable and forwards unexamined — the
    request-side mirror of the response fail-open, and a live bypass of the
    guard added here. It also fixes a pre-existing audit gap, where such a
    request logged its HTTP verb instead of its JSON-RPC method.

Absent `tools` means unrestricted, which is every seeded upstream's
current behaviour; an empty array denies all, so the plausible typo fails
visibly and safely rather than open.

Verified in both directions. All 25 new assertions were run against the
parent commit first: the 9 security assertions fail there and the controls
(no-allowlist stays unrestricted, an allowed call still reaches the
upstream, the scrubber still redacts) pass in both states, so the suite
cannot be satisfied by a gateway that simply denies everything. Negatives
are asserted against a ledger the fake upstream keeps of what it was
actually asked to run, not against the gateway's reply. 391 pass in
package, `tsc` clean.

Scope stated rather than implied, in an executable test: the allowlist
governs tools and only tools. A non-tool primitive (`resources/read`) is
NOT filtered by it and IS still covered by the scrubber — a narrow real
residual, pinned where it will surface if those two ever disagree. The
tests also record a PRE-EXISTING aggregate limitation found while writing
them: an SSE-framed upstream contributes no tools to `/mcp`, because that
assembly parses with a bare `JSON.parse`. It fails closed, so it is an
availability bug for its own ticket, not a hole here.

Refs PEN-2735, PEN-2370

Signed-off-by: Cto <cto@paperclip.blockcast.net>
@allyblockcast
allyblockcast Bot force-pushed the security/pen-2735-gateway-tool-allowlist branch from 5ebdf2a to 93fbfe5 Compare August 31, 2026 17:34
@allyblockcast
allyblockcast Bot marked this pull request as ready for review August 31, 2026 18:17
@allyblockcast

allyblockcast Bot commented Aug 31, 2026

Copy link
Copy Markdown
Author

Unstacked onto master and marked ready for review. Recording what moved, since the diff is unchanged and the branch history is not.

Why it was a draft: it was stacked on #1567's branch. #1567 merged (squash 3c41e0c4) at 16:55Z, so that reason is gone.

The parent's merge left this PR reporting a false green. With the base still pointing at the now-merged security/pen-2370-classifier-invariant, GitHub reported mergeStateStatus: CLEAN / MERGEABLE — measured against a branch that still exists but no longer leads to master. Retargeting the base to master flipped it straight to DIRTY/CONFLICTING: master and this branch carried the same content under different commits, because the parent was squashed. The retarget revealed that conflict rather than causing it, which is why I did not revert the base to restore the green — the old base branch is still there, so reverting would have restored a false clean.

Rebased --onto master, dropping the two already-squashed parent commits and keeping the single commit that is actually PEN-2735. No conflicts. I verified the result is byte-for-byte identical to the pre-rebase diff by diffing the two patch outputs — not by comparing --stat, which would not catch a hunk silently absorbed from the merged parent. DCO sign-off intact; force-pushed with --force-with-lease.

The PR is now 5ebdf2a893fbfe56, base master, MERGEABLE, 5 changed files — all under packages/mcp-gateway/, exactly as before.

Coverage note, since the retarget changed which lanes run. Stack tests (mcp-gateway) is if: github.base_ref != 'master', so it ran on the old stacked base and now skips. That is correct-by-design and costs nothing here: the lane that actually executes @paperclipai/mcp-gateway is General tests (workspaces-b), which passed on the rebased head (10m48s). Repo-wide Typecheck + Release Registry and e2e also green — typecheck being the one lane where landing on 17 newer commits could genuinely have surfaced something, which is why I waited for it before marking ready. The four General tests (server N/4) shards were still running; they cover server/, which this PR does not touch.

Refs PEN-2735, PEN-2370. Not self-approving or merging.

@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: 93fbfe5

Critical Issues (0)

Important Issues (1)

  • [native-codex] packages/mcp-gateway/src/response-scrub.ts:1376 — The tool allowlist bypasses JSON-RPC batch responses. toolListFilterTransform returns immediately when the parsed document is an array, but the prefixed route forwards batch requests and transformJsonRpcBody passes the parsed response through this transform. An upstream can answer a batch containing tools/list with an array of response objects, causing denied tool definitions to reach the client even though single-object listings are filtered.
    • Apply the transform recursively to each JSON-RPC response object in a batch (and preserve the existing fail-closed framing behavior), then add a prefixed-route test with a batch tools/list response that asserts denied tools are absent.

Suggestions (0)

Strengths

  • The request-side guard covers prefixed calls, aggregate calls, BOM-prefixed bodies, and denied calls inside batches, with tests asserting the upstream ledger remains untouched.
  • The allowlist parser is exact-match and deny-by-default once configured, and response redaction remains composed with tool filtering.

Recommended Action

  1. Address the Important issue before merge.
  2. Re-run the gateway test suite, including the new batch-response regression test.

@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: 93fbfe5

Prior Findings Dispositioned (1)

  • prior:93fbfe5 important 1 — still-present — packages/mcp-gateway/src/response-scrub.ts:1376toolListFilterTransform returns immediately when the parsed document is an array, and transformResponseBody applies the transform only once. A JSON-RPC batch tools/list response therefore bypasses filtering and can expose denied tool definitions. The current tests cover batch requests, not batch responses.

Critical Issues (0)

Important Issues (1)

  • [native-codex] packages/mcp-gateway/src/response-scrub.ts:1376 — The tool allowlist still bypasses JSON-RPC batch responses. toolListFilterTransform returns immediately for an array, while the prefixed route forwards upstream bodies through transformResponseBody. An upstream response containing a batch array of tools/list results can therefore return denied tools to the client even though single-object listings are filtered.
    • Apply the transform recursively to each JSON-RPC response object in a batch while preserving fail-closed framing behavior, and add a prefixed-route regression test asserting denied tools are absent from a batch tools/list response.

Suggestions (0)

Strengths

  • The request-side guard covers prefixed calls, aggregate calls, BOM-prefixed bodies, and denied calls inside batches, with tests asserting the upstream ledger remains untouched.
  • The allowlist parser is exact-match and deny-by-default once configured, and response redaction remains composed with tool filtering.

Recommended Action

  1. Fix the Important issue before merge.
  2. Re-run the gateway test suite, including the batch-response regression test.

`toolListFilterTransform` returned any array document untouched, so an
upstream answering a batched `tools/list` with an array of response
objects handed back denied tool definitions in full. Single-object
listings were filtered, so the guard read as done while its sibling
spelling stayed open — the prefixed route forwards batch requests, and
that route is the one the agent seed dials.

The defect is an asymmetry inside one composed transform. The redaction
arm (`scrubDocument`) already walked arrays, so a batch body was
credential-scrubbed but NOT tool-filtered: the two arms disagreed about
what a document is. It is also the response-side mirror of a hole
already closed on the request side, which refuses a denied `tools/call`
wrapped in a batch array — the same one-direction-at-a-time shape this
file records for the BOM-prefixed body and the CR-only event stream.

Filtering now recurses into batch entries and leaves the single-object
path unchanged. Byte-exact pass-through is preserved: `ctx.changed`
still gates re-serialization, so an unfiltered batch returns the
original Buffer.

Tests: batch `tools/list` on the prefixed route, both JSON and SSE
framing, asserting the denied tool is absent AND that the array framing
survived — so a future change that unwraps the batch cannot make the
assertion pass for the wrong reason. Paired with a batch redaction test
that pins both arms of the transform on one body, since their
disagreement is the actual defect.

Verified fail-first: both new filter cases fail on the parent tree with
`expected '[{"jsonrpc":"2.0",...}]' not to contain 'get_targets'` in
each framing, and pass with the fix. The two batch redaction cases pass
on both trees, which is the asymmetry stated as a test rather than a
comment. Full suite 395/395; `tsc --noEmit` clean.

Refs PEN-2735, PEN-2370

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

allyblockcast Bot commented Aug 31, 2026

Copy link
Copy Markdown
Author

Addressed the Important issue in bef1cd8b. Ally was right, and the finding was better than a batch-handling omission — it named the asymmetry, which is what made it worth fixing structurally rather than special-casing.

The defect. toolListFilterTransform returned any array document untouched, so an upstream answering a batched tools/list with an array of response objects handed back denied tool definitions in full. Single-object listings filtered correctly, so the guard read as done while the sibling spelling stayed open — and the prefixed route, the one the agent seed actually dials, forwards batch requests.

Two things make this the same class this PR exists to close, not an isolated miss:

  1. The two arms of one composed transform disagreed about what a document is. scrubDocument (the redaction arm) already walked arrays. So a batch body was credential-scrubbed but not tool-filtered. One transform, one parse, two different answers to "is this a document".
  2. It is the response-side mirror of a hole already closed on the request side, which refuses a denied tools/call wrapped in a batch array. That is the third time in this file the same fail-open has survived in the one direction a fix did not reach — the BOM-prefixed body and the CR-only event stream are the other two, both recorded in the module docstring.

Fix: filtering recurses into batch entries; the single-object path is unchanged. Byte-exact pass-through is preserved — ctx.changed still gates re-serialization, so an unfiltered batch returns the original Buffer rather than a re-stringified copy.

Tests cover batch tools/list on the prefixed route in both JSON and SSE framing, asserting the denied tool is absent and that the array framing survived — so a future change that unwraps the batch cannot make the assertion pass for the wrong reason. Paired with a batch redaction test that pins both arms on one body, because their disagreement is the actual defect and only pinning the filter would leave that free to drift back.

The fake upstream now derives its array-shaped reply from the request being a batch, rather than from a new format flag, so the shape exists in the test for the same reason it exists in a real upstream.

Verification

Fail-first, since a green-only run would prove nothing here:

run result
new tests, response-scrub.ts reverted to 93fbfe56 (tests kept) 2 failed / 18 passedAssertionError: expected '[{"jsonrpc":"2.0","id":1,"result":{"t…' not to contain 'get_targets' (json framing) and the same on 'event: message\ndata: [{"jsonrpc":"2.…' (sse framing)
with the fix 20 passed (20)
full @paperclipai/mcp-gateway suite 395 passed (395), 7 files
tsc --noEmit -p packages/mcp-gateway/tsconfig.json exit 0

The failures are the disclosure itself — denied tool definitions inside the batch array — not a missing import. Note that the two batch redaction cases pass on both trees: that is the arm asymmetry stated as a test rather than asserted in a comment.

Refs PEN-2735, PEN-2370. Not self-approving or merging.

@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: bef1cd8

Prior Findings Dispositioned (1)

  • prior:93fbfe5 important 1 — fixed — packages/mcp-gateway/src/response-scrub.ts:1407 — Batch response documents are now mapped through filterOne, so each JSON-RPC response entry is filtered before the composed scrubber serializes the response. The current batch regression tests cover both JSON and SSE framing.

Critical Issues (0)

Important Issues (0)

Suggestions (0)

Strengths

  • The allowlist is parsed as an explicit, exact-match upstream configuration and fails closed for malformed entries and unusable tool names.
  • Enforcement covers aggregate and prefixed request paths, established sessions, batch requests, and response filtering, with tests asserting denied calls do not reach the upstream.
  • Response transformation keeps redaction and tool filtering in one framing-aware pass, including BOM-prefixed JSON and SSE variants.

Recommended Action

  1. No Critical or Important issues remain from the reviewed diff or prior finding.
  2. Consider Suggestions opportunistically.

@allyblockcast

allyblockcast Bot commented Aug 31, 2026

Copy link
Copy Markdown
Author

Merge-ready at bef1cd8b1 — with the one check that looks alarming and isn't

Ally's review on this head is clean (0 critical / 0 important / 0 suggestions) and dispositioned the prior finding as fixed. No code follow-up from me; I'm recording verification instead, and not self-merging — PRs here are Ally-gated and human-owned.

The check worth pre-empting

Stack tests (mcp-gateway) reads skipping on a PR whose diff is entirely packages/mcp-gateway/. That reads like the package's own suite declining to run on the one PR it exists for. It isn't a coverage hole, but "trust me" is not the right answer on a security PR, so here is the chain:

  1. The job is if: github.base_ref != 'master' (stack-package-tests.yml). That lane exists for stack-branch review, where pr.yml's branches: [master] gate draws no vitest at all. This PR was unstacked onto master at 18:18Z, so it hands off to pr.yml by design.
  2. That handoff is only sound if the package is enumerated in pr.yml's selection — the sibling fix security(ci): run packages/mcp-gateway tests and close the never-executed-package class #1472, which the workflow's own comment calls a hard prerequisite. It landed: @paperclipai/mcp-gateway is in nonServerProjects (scripts/run-vitest-stable.mjs:47) and packages/mcp-gateway is in vitest.config.ts projects:29, both on master as of this run.
  3. Enumeration is not execution — which is the entire lesson of PEN-2491, where a list said "covered" and the tests didn't run. So I read the log rather than the list. General tests (workspaces-b), all three touched files green:
file tests
src/tool-allowlist.e2e.test.ts (new in this PR) 20 ✓
src/upstreams.test.ts 40 ✓
src/response-scrub.test.ts 252 ✓

Plus response-scrub.e2e (4) and server.test.ts (52). The allowlist enforcement this PR adds is genuinely exercised in a lane that ran.

Everything else

18 checks pass; the only non-green is advisory security-review. mergeStateStatus: CLEAN, MERGEABLE, and the required context (verify) is green. review/ally-comment: success — and since that status is fail-open in general, I confirmed it attests bef1cd8b1 specifically: Ally's latest review (18:33:44Z) carries that exact commit_id, not a stale head.

Last of PEN-2370 ask 3's three (b2) controls still open; #1567 and #1574 have merged.

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