Interceptor conformance suite + attested-validation signature reference (SEP-2624) - #31
Draft
wi-ski wants to merge 19 commits into
Draft
Interceptor conformance suite + attested-validation signature reference (SEP-2624)#31wi-ski wants to merge 19 commits into
wi-ski wants to merge 19 commits into
Conversation
…t.signature Ed25519-signed, offline-verifiable attested interceptor decisions: - signs the canonicalized result (signature field excluded) so every field is bound - verifies against a PINNED issuer key, ignoring the embedded key (forgery fails) - why-not provenance on failure (tagged union, never a bare false) - offline verifier CLI; portable Web Crypto Ed25519 (Node 20+/Deno/browser) - 5/5 node:test suites green
- const-object-derived unions for every finite set (RULE 1); no bare string-literal unions. - readonly throughout + null-not-optional (RULE 3/6). - single wire boundary normalizer with a Record<type,handler> dispatch (RULE 2/7), replacing PR modelcontextprotocol#13's dual hand-parser + zod layer. - resolve-priority primitive; 20/20 vitest, tsc clean. - spec-faithful: two types (validation|mutation), `enforce` mode value (issue modelcontextprotocol#15), no non-spec `sink` (issue modelcontextprotocol#16). - ~39% smaller than the equivalent PR modelcontextprotocol#13 protocol layer (423 vs 693 loc).
…, invoke params, rpc handshake Adds the outbound half of the wire boundary (serialize.ts emits the SEP's optional shape, omitting interior defaults), normalizeInvokeParams for the server-inbound direction, shared hook-event matching, MCP error helpers, and the thin zod handshake schemas the MCP SDK needs to route the two extension methods (real validation stays in wire.ts — no dual parser). Round-trip tested: normalize(serialize(x)) === x for descriptors, both result-union members, and invoke params.
Request phase runs mutations→validations, response phase validations→ mutations (per-phase dispatch table). Mutations fold sequentially by resolvePriority with alphabetical tie-break; validations run in parallel. Audit never blocks (validators log, mutators shadow-compute); failOpen governs crash/timeout, default fail-closed; mutators receive structuredClone snapshots. Unknown wire result types surface as interceptor failures subject to failOpen — never a chain crash. 55 tests including the exhaustive mode×failOpen×outcome matrices (Condition 1).
…ation defineValidator/defineMutator normalize consumer specs once into interior descriptor+handler pairs (verdict vocabulary: pass/block/apply/keep). createRegistry is a closure-based pure core: unique-name fail-fast, event filtering, per-invoke timeout, duration stamping, MCP invalid-params for unknown names. registerInterceptorsOnServer wires interceptors/list and interceptor/invoke into an MCP Server and advertises the capability under extensions["io.modelcontextprotocol/interceptors"].
…epting client listInterceptors/invokeInterceptor push every response through the wire boundary. discoverInterceptors merges hosts under the SEP chain-global unique-name rule (policy dispatch: error | first-wins) and routes invokes to the advertising host. createChainRunner caches discovery; the intercepting client wraps a backend Client so ONE generic intercepted() primitive carries every MCP operation — request chain before the backend, response chain before the caller, ChainBlockedError carrying the full ChainResult.
createGateway fronts a backend server: one route table binds MCP request schemas to SEP lifecycle events and backend forwards; handlers register only for capabilities the backend advertises, and its list-changed notifications are re-emitted to front clients. Blocked chains surface as MCP errors with the abort attached. exposeInterceptorProtocol makes the gateway itself an interceptor host (aggregated list, routed invoke).
Real JSON-RPC through InMemoryTransport: capability advertisement, list filtering, invoke round-trips, multi-host merge policies, intercepting-client mutation/blocking/audit/event-filter behavior, and gateway capability mirroring, chain-blocked MCP errors, interceptor-protocol passthrough, and notification forwarding. src/index.ts now exports every layer.
cross-boundary-guard (validator): causal cross-boundary taint over verbatim secrets — a secret read from server A is blocked from requests to server B, per-session, first-origin-wins. secretless-redactor (mutator): deterministic opaque handle tokens for outbound secrets. Secret-format catalog is clean-room public credential shapes (RULE 8, sourced). src/attest wires @formalcore/mcp-attested-validation into the SDK as an opt-in: withAttestation(validator, keys) returns SEP-wire ValidationResults with the reserved signature field populated; verification is offline against a pinned issuer key. Consumed as source via tsconfig paths + vitest alias; excluded from the publishable dist (tsconfig.build.json). 47 vitest tests incl. exhaustive per-format calibration (RULE 15) and forbidden-output assertions (RULE 18): denials never echo the secret, redacted payloads never contain one.
…ssue modelcontextprotocol#20) One typed catalog (src/catalog.ts) is the oracle; a generator derives 24 pure-JSON fixtures + a traceability manifest, canonicalized with the JCS canonicalize shared with attested-validation so cross-language comparison is scrub → canonicalize → one string compare. - fixtures/protocol: exact interceptors/list, interceptor/invoke, and chain semantics (trust-boundary order, enforce vs audit, fail-open/closed, unknown-name error) - fixtures/behavior: declarative security requirements incl. the canonical relaybleed-* fixtures (filesystem read → sqlite write MUST deny) and the false-positive boundary (same-origin, causality, session isolation) + redaction - schema/fixture.schema.json + manifest.json (fixture → SEP requirement) - runner replays fixtures against a 4-method adapter (ADAPTER.md) and emits a compliance percentage; reference TypeScript adapter proves 100% - meta-tests: fixture count == catalog size, deterministic generation, disk byte-parity, strawman scores 0% Also: attested-validation typecheck green (@types/node devDep, one exactOptionalPropertyTypes fix in tests; npm set the bin executable bit). 34 conformance tests green; SDK 160 tests green; attested 5 tests green.
… the shared fixtures Proves the language-neutral suite is language-neutral in fact: the 24 TypeScript-generated golden fixtures run UNMODIFIED under Python and certify (or reject) a Python implementation. - ADAPTER.md contract (createSession/list/invoke/chain) + the eight-entry behavior vocabulary bound to the WG feature/python-sdk over a real in-memory MCP connection; raw wire bytes compared, not model objects. - Clean-room Python port of the open reference security interceptors (cross-boundary-guard, secretless-redactor): same seven public credential formats, causal per-session cross-boundary taint, deterministic FNV-1a handles pinned byte-equal to the TS implementation. - JCS canonicalization byte-identical to the TS canonicalizer (UTF-16 key order, JSON.stringify escapes, ECMAScript number formatting), pinned by golden tests plus an optional differential fuzz against Node. - Results: conformant posture 24/24 (100%, incl. every relaybleed-*); permissive strawman 0% on relaybleed-*; raw WG-SDK posture 19/24 (79.2%) failing exactly the fixtures that encode three documented SEP interop findings (mode spelled 'active', defaults emitted on list, chain direction derived server-side) - the bugs conformance exists to catch. - Upstream feature/python-sdk is vendored READ-ONLY at a pinned SHA by scripts/vendor.sh (gitignored, never modified, never committed). 69 pytest tests green; TS suite untouched and still 34/34.
Fixtures regenerated deterministically from the updated catalog; all suites re-verified green (TS SDK 160/160, conformance 34/34, Python adapter 69/69).
…rceptors
Adds @formalcore/mcp-interceptors-langgraph, a thin binding that makes any
LangGraph agent enforce the reference stack in one line.
- InterceptingToolNode: drop-in StateGraph tools node (invoke({messages}) ->
{messages}); per-call redaction + causal cross-boundary guard + signed
decision receipt; a deny becomes a status:"error" ToolMessage. Session taint
is keyed by config.configurable.thread_id.
- withInterceptors(tools, options): wraps StructuredTools in place for
createReactAgent / existing ToolNode; a deny throws InterceptorDenied so a
standard ToolNode maps it to an error message.
- Binds, does not reimplement: reuses the SDK cross-boundary-guard, secretless
redactor, and chain executor, plus the attested-validation Ed25519 signer and
offline verifier. A receipt IS the guard's own SEP-2624 ValidationResult,
signed; durationMs is dropped so receipts are reproducible.
- Hermetic demo (no network, no key, no LLM): baseline leaks; guard-only DENIES
the cross-server write and verifies the receipt offline (PASS pinned, FAIL
wrong key); guard+redactor allows the write but defuses the secret to a handle.
- 12 vitest tests (pure engine + LangGraph surfaces), typecheck clean. The SDK
(160), conformance (34), attested (5), and Python adapter (69) are untouched
and still green.
…e reference interceptors Keystone adoption artifact: a named agent framework (LangChain / LangGraph) emits our provenance labels and enforces our cross-boundary guard. - @formalcore/langgraph-interceptors: `createInterceptorShield().wrap(tools)` returns ordinary StructuredToolInterfaces for a ToolNode / createReactAgent. - Reuses the reference interceptors unchanged: cross-boundary-guard (validator), secretless-redactor (mutator), executeChain (SEP-2624 trust-boundary order), and attested-validation (Ed25519 over RFC 8785). No security logic is reimplemented. - One guard instance threads causal per-session taint across calls: request phase guards the outbound args, the tool runs, response phase ingests output and emits an mcp-provenance-label envelope on ToolMessage.artifact. - A denied cross-boundary send carries an attested ValidationResult receipt that verifies offline against a pinned issuer key (and is rejected against a wrong key). - Hermetic deterministic demo (no API key, no network): vanilla ToolNode leaks the secret to sqlite; the shield denies with a verifiable receipt; redaction defuses the send to an opaque handle. Optional live model behind LANGGRAPH_DEMO_LIVE=1. - 14 vitest tests pinned to the conformance behavior fixtures (relaybleed-*, redaction-defuses-relaybleed, same-origin-writeback-allowed, no-prior-read-allowed, session-isolation). Typecheck clean.
… lint config - conformance.yml: run the suite, assert fixture determinism (regenerate + git diff --exit-code), and assert the suite discriminates a permissive strawman; plus a Python-adapter job proving cross-language certification. - integrations.yml: typecheck/test the LangGraph binding on Node 20/22 and run the relaybleed demo as a smoke assertion. - typescript.yml: add an Attested Validation leg (typecheck + node --test). - status-check.yml + comment-trigger.yml: wire the new workflows into required checks and /test all. - scripts/demo.sh: one command, three self-verifying beats (deterministic oracle, cross-language matrix, offline-verifiable denials). - README: document the conformance/attested/integrations components + walkthrough. - eslint: ignore _-prefixed vars and rest siblings (destructure-omit idiom). All packages green: SDK 160, attested 5, conformance 34, langgraph 12, python adapter 69. Fixtures reproduce byte-identical.
Two LangGraph integrations existed: integrations/langgraph (@formalcore/mcp-interceptors-langgraph, wired into CI, the demo script, and the README, with attested receipts) and an earlier typescript/integrations/langgraph (@formalcore/langgraph-interceptors) that nothing referenced and no workflow covered. Keeping both is a diligence smell and an interop hazard. Remove the orphan; the canonical package stays green (typecheck + 12 tests). The removed package's one distinct idea, a provenance-label envelope on tool results, is preserved in history (commit 2a21f08) if we later choose to add source-labeling to the canonical binding.
…ename fixture op chain->apply
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Opening as a draft for discussion in today's Interceptors WG call (Jul 23).
ValidationResult.signature(context in Sending-side chain direction: validators should gate final wire bytes, but SDKs disagree #29).The TypeScript SDK in this diff is scaffolding so the fixture generator had something to target; it is not a proposal competing with #13. Happy to split however the group prefers.