jcp-gateway: everything a gated request carries to the server is sealed and flow-checked #339

Merged
dcharlot merged 1 commit from fix/jcp-gateway-flow-checks-whole-request into main 2026-10-19 21:21:56 -04:00
Owner

What was open

A gated tools/call, resources/read or prompts/get was decided and flow-checked over its arguments, but forwarded whole. Three things reached the server without passing the flow ceiling, and a value from an untrusted read could leave by any of them:

  • a _meta key;
  • a params member the method does not define;
  • a member of the JSON-RPC message beside params.

#336 already rebuilt task requests from the decided fields. This closes the same gap for calls.

What this changes

  • Everything beside the arguments is checked. Those fields are sealed and flow-checked with the arguments (beside). A request that carries nothing beyond its arguments is decided over exactly the bytes it was before.
  • Two things are left out, and the code says why (protocol_meta_key):
    • The protocol's own _meta keys: progressToken, and any key under a prefix MCP reserves for itself, such as io.modelcontextprotocol/. The client's runtime writes these, and a server names them back in every reply. The flow store admits every payload it labels, so folding these keys would make the first tainted request taint every request after it. They are the one part of a request the server receives unchecked.
    • This gateway's own namespace and legacy witness key.
  • A latent false refusal is fixed. A resources/read was gated over its whole params, _meta included. Under MCP 2026-07-28 the protocol keys in every request matched the server's io.modelcontextprotocol/serverInfo in every reply. Once one reply was admitted, every later read under a ceiling was refused as tainted. A test now reproduces this.
  • Parts are joined without shared text. A request's parts (arguments, answers, what it carries beside them) are scanned joined by a separator unique to that request (scan_bytes), not wrapped in one JSON object. A wrapper's keys would be the same text in every request of that shape, which would carry one tainted request's label into the rest. This also replaces the wrapper #336 used for retries.
  • SPEC §4.10 now says what "the argument" covers for a forwarding enforcement point, and what it may leave out as long as it says so.

Verification (build host)

  • cargo test -p jcp-gateway --lib passes, with 6 new tests.
  • All 13 mutations of the new logic are killed. Each one disables a single piece: the beside-the-arguments set as a whole; the message, params and _meta parts each on their own; the protocol-key exemption; _meta stripping on reads; the unique separator; joined scanning; sealing the extras; the task _meta scan; the gateway-namespace exemption; the legacy-key exemption; and the call-path extras scan.
  • The jcp-rs workspace tests, the jcp conformance suite, and the macOS clippy gate (-D warnings on jcp-energy and jcp-gateway).
  • Repo checks: tests-in-ci, spec-refs, orphan-sources, vanity-metrics, comparative-prose.

Behaviour to know

The flow store admits every payload it labels. So after a tainted request, a later request that shares 16 bytes of its own text with it inherits that taint. This includes argument or _meta structure that repeats from request to request. It fails closed and predates this PR. This PR only stops the gateway's own joining from adding such shared text.

🤖 Generated with Claude Code

## What was open A gated `tools/call`, `resources/read` or `prompts/get` was decided and flow-checked over its arguments, but forwarded whole. Three things reached the server without passing the flow ceiling, and a value from an untrusted read could leave by any of them: - a `_meta` key; - a `params` member the method does not define; - a member of the JSON-RPC message beside `params`. #336 already rebuilt task requests from the decided fields. This closes the same gap for calls. ## What this changes - **Everything beside the arguments is checked.** Those fields are sealed and flow-checked with the arguments (`beside`). A request that carries nothing beyond its arguments is decided over exactly the bytes it was before. - **Two things are left out, and the code says why** (`protocol_meta_key`): - **The protocol's own `_meta` keys:** `progressToken`, and any key under a prefix MCP reserves for itself, such as `io.modelcontextprotocol/`. The client's runtime writes these, and a server names them back in every reply. The flow store admits every payload it labels, so folding these keys would make the first tainted request taint every request after it. They are the one part of a request the server receives unchecked. - **This gateway's own namespace and legacy witness key.** - **A latent false refusal is fixed.** A `resources/read` was gated over its whole `params`, `_meta` included. Under MCP 2026-07-28 the protocol keys in every request matched the server's `io.modelcontextprotocol/serverInfo` in every reply. Once one reply was admitted, every later read under a ceiling was refused as tainted. A test now reproduces this. - **Parts are joined without shared text.** A request's parts (arguments, answers, what it carries beside them) are scanned joined by a separator unique to that request (`scan_bytes`), not wrapped in one JSON object. A wrapper's keys would be the same text in every request of that shape, which would carry one tainted request's label into the rest. This also replaces the wrapper #336 used for retries. - **SPEC §4.10** now says what "the argument" covers for a forwarding enforcement point, and what it may leave out as long as it says so. ## Verification (build host) - `cargo test -p jcp-gateway --lib` passes, with 6 new tests. - All 13 mutations of the new logic are killed. Each one disables a single piece: the beside-the-arguments set as a whole; the message, params and `_meta` parts each on their own; the protocol-key exemption; `_meta` stripping on reads; the unique separator; joined scanning; sealing the extras; the task `_meta` scan; the gateway-namespace exemption; the legacy-key exemption; and the call-path extras scan. - The jcp-rs workspace tests, the jcp conformance suite, and the macOS clippy gate (`-D warnings` on jcp-energy and jcp-gateway). - Repo checks: tests-in-ci, spec-refs, orphan-sources, vanity-metrics, comparative-prose. ## Behaviour to know The flow store admits every payload it labels. So after a tainted request, a later request that shares 16 bytes of its own text with it inherits that taint. This includes argument or `_meta` structure that repeats from request to request. It fails closed and predates this PR. This PR only stops the gateway's own joining from adding such shared text. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
jcp-gateway: everything a gated request carries to the server is sealed and flow-checked
All checks were successful
standards workspaces build + test / which workspaces this change touches (pull_request) Successful in 7s
repository checks / lockfiles, tests in CI, workflow hygiene, spec and section references, public text (pull_request) Successful in 56s
standards workspaces build + test / jouleclaw-rs (-nodebug, jouleclaw/jouleclaw-rs, -p jouleclaw-energy --features rapl,nvml, jouleclaw-rs, linux-arm64, -C target-feature=+fp16) (pull_request) Successful in 5m36s
standards workspaces build + test / joule-code-rs (joule-code/joule-code-rs, joule-code-rs, linux-arm64, true) (pull_request) Successful in 6m57s
standards workspaces build + test / mesh-rs (mesh/mesh-rs, -p mesh-engine-gguf -p mesh-openai --features mesh-engine-gguf/gguf,mesh-openai/server, mesh-rs, linux-arm64) (pull_request) Successful in 1m39s
standards workspaces build + test / joule-ui-rs (joule-ui/joule-ui-rs, joule-ui-rs, linux) (pull_request) Successful in 1m14s
standards workspaces build + test / jcp-rs (jcp-conformance, jcp/jcp-rs, -p jcp-energy --features nvml, jcp-rs, linux) (pull_request) Successful in 1m40s
standards workspaces build + test / joulehook-rs (joulehook/joulehook-rs, joulehook-rs, linux) (pull_request) Successful in 1m16s
standards workspaces build + test / sandbox-rs (sandbox-conformance, sandbox/sandbox-rs, sandbox-rs, linux) (pull_request) Successful in 2m38s
standards workspaces build + test / macOS energy backends (pull_request) Successful in 7m3s
standards workspaces build + test / jouledesk-rs (jouledesk/jouledesk-rs, jouledesk-rs, macos) (pull_request) Successful in 2m35s
efb7ee647b
The gateway decided a gated request over its arguments alone, but it
forwarded the whole request. So a `_meta` key, a `params` member the
method does not define, or a member of the JSON-RPC message beside
`params` reached the server without passing the flow ceiling. A value
from an untrusted read could leave by any of them.

Each of those is now sealed and flow-checked along with the arguments.
Two things are left out, and the code documents why:

- The protocol's own `_meta` keys: `progressToken`, and keys under a
  prefix MCP reserves for itself, such as `io.modelcontextprotocol/`.
  These are machinery written by the client's runtime, and a server
  names them back in every reply. The flow store admits every payload it
  labels, so folding these keys would make the first tainted request
  taint every request after it.
- This gateway's own namespace and its legacy witness key.

A request that carries nothing else is decided over exactly the bytes
it was before.

This also fixes a latent false refusal. A `resources/read` was gated
over its whole `params`, `_meta` included. Under MCP 2026-07-28 the
protocol keys in every request matched the server's keys in every
reply, so once one reply had been admitted, every later read under a
flow ceiling was refused as tainted.

A request's parts (arguments, answers to an interim result, what it
carries beside them) are now scanned joined by a separator unique to
that request, not wrapped in one JSON object. A wrapper's keys would be
the same text in every request of that shape, and the stored admission
of one tainted request would carry its taint into all the rest.

SPEC §4.10 now says what "the argument" covers for a forwarding
enforcement point, and what it may leave out if it says so.

Tests: 6 new. All 13 mutations of the new logic are killed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
Transaction-Science/open-standards!339
No description provided.