From 1c6e826ca2ad5284be5da1ddcbeb4dc1367d1190 Mon Sep 17 00:00:00 2001 From: Omkar Parkhe Date: Mon, 3 Aug 2026 19:07:24 +0530 Subject: [PATCH 1/2] Adding SEP for Cross-Server Data-Origin Labels and Flow Policy along with PoC --- sep-flow-policy-poc/README.md | 70 ++++ sep-flow-policy-poc/demo.mjs | 114 ++++++ sep-flow-policy-poc/host.mjs | 154 ++++++++ sep-flow-policy-poc/servers.mjs | 167 ++++++++ sep-flow-policy-poc/src/context.mjs | 77 ++++ sep-flow-policy-poc/src/floworigin.mjs | 49 +++ sep-flow-policy-poc/src/policy.mjs | 93 +++++ sep-flow-policy-poc/src/principals.mjs | 77 ++++ sep-flow-policy-poc/test/conformance.test.mjs | 361 ++++++++++++++++++ ...rver-data-origin-labels-and-flow-policy.md | 360 +++++++++++++++++ 10 files changed, 1522 insertions(+) create mode 100644 sep-flow-policy-poc/README.md create mode 100644 sep-flow-policy-poc/demo.mjs create mode 100644 sep-flow-policy-poc/host.mjs create mode 100644 sep-flow-policy-poc/servers.mjs create mode 100644 sep-flow-policy-poc/src/context.mjs create mode 100644 sep-flow-policy-poc/src/floworigin.mjs create mode 100644 sep-flow-policy-poc/src/policy.mjs create mode 100644 sep-flow-policy-poc/src/principals.mjs create mode 100644 sep-flow-policy-poc/test/conformance.test.mjs create mode 100644 seps/0000-cross-server-data-origin-labels-and-flow-policy.md diff --git a/sep-flow-policy-poc/README.md b/sep-flow-policy-poc/README.md new file mode 100644 index 000000000..6464e702f --- /dev/null +++ b/sep-flow-policy-poc/README.md @@ -0,0 +1,70 @@ +# Cross-server flow policy reference prototype + +A runnable prototype for [SEP-0000: Cross-Server Data-Origin Labels and Flow Policy](../seps/0000-cross-server-data-origin-labels-and-flow-policy.md). + +It implements the whole mechanism end to end: host-side contributor tracking, the `flowOrigin` request label, server-declared `flowPolicy`, evaluation on both peers, a host-side cross-principal ceiling, redaction of refusals before they could reach the model, and the restriction-only invariant enforced as a code path rather than a documented promise. + +## Running it + +```bash +node sep-flow-policy-poc/demo.mjs +node --test sep-flow-policy-poc/test/conformance.test.mjs +``` + +No dependencies and no install step. It runs on a stock Node install. + +## What the demo shows + +The demo runs the same two-server attack twice. A low-assurance web-fetch server returns a page carrying a hidden instruction, and that instruction steers the model into calling a high-assurance records server's export tool and then handing the result back to the low-assurance server. + +Under today's protocol the whole chain succeeds and the customer table leaves the trust boundary. Neither server is compromised and neither misbehaves by its own contract, which is precisely why no single server can prevent it. + +With flow policy enabled, the export call is never dispatched, because the web-fetch server has already contributed to the partition and `export_records` declares `acceptFrom: ["self", "user"]`. The remaining scenarios show that the same privileged call still succeeds when no low-assurance server has contributed, that the exfiltration leg is independently blocked by the host ceiling, that an ordinary `resources/read` is gated too because its URI is an egress channel, that a refusal reaches the model as a bare verdict rather than an explanation, and that a server refuses a request carrying no label at all. + +## Layout + +```text +sep-flow-policy-poc/ + src/principals.mjs principal identifiers, closed assurance vocabulary, token matching + src/context.mjs host-side contributor tracking over a context partition + src/floworigin.mjs building the label, and reading it back defensively + src/policy.mjs policy evaluation, the restriction-only invariant, redaction + servers.mjs two reference servers that declare and enforce a policy + host.mjs reference host: tracking, ceiling, pre-dispatch check, routing + demo.mjs the cross-server attack, with and without the control + test/conformance.test.mjs +``` + +## The invariant, in code + +The security of an unsigned label rests entirely on one property: it may only ever restrict. That is enforced structurally in `src/policy.mjs`: + +```text +combine(baseDecision, flowResult): + if baseDecision is not ALLOW -> DENY + otherwise -> ALLOW only if the flow result allows +``` + +The base authorization decision is computed first and without reference to the label. Flow evaluation is consulted afterwards and can move the result in one direction only. A property test asserts that no label, of any shape, can convert a denial into an approval. That is why forging a label is pointless: inflating assurance grants nothing, and deflating it only denies the forger's own request. + +## Design notes worth reviewing + +- The vocabulary is called assurance, not trust. SEP-1913 already uses "trust" for the risk of a tool and SEP-3140 for the integrity of a declaration; this labels how well the host knows who a principal is. Three overlapping trust vocabularies would be a hazard for implementers and reviewers alike. +- Contributors are deliberately over-approximated. Any principal whose content entered the partition is listed, whether or not it can be shown to have influenced the specific arguments. Precise taint tracking through model reasoning is not achievable, and under-reporting is a silent security failure, so the prototype chooses the loud failure mode. The cost is false rejections, which is an availability trade rather than a security one. +- Summarizing content does not clear its contributor. This is the most likely implementation mistake, since a summary looks like new host-authored text, and taking that shortcut would silently disable the whole mechanism. A test pins the behaviour. +- Everything fails closed. An absent label, a malformed label, an empty contributor list, a `complete` value that is not literally `true`, an unknown assurance state, and an unrecognized `acceptFrom` token all resolve to the most restrictive interpretation available. +- The assurance states are closed, but the identifier space is open. A vendor token such as `com.example.tier:gold` is inert on a host that does not understand it, because unknown tokens never match. Extension therefore makes an older evaluator more restrictive rather than less, which is why it needs no registry to stay safe. +- Both peers evaluate. The host pre-checks and refuses to dispatch, which is where containment actually happens because the request is never made. The server evaluates independently on receipt, which covers a host that is buggy, outdated, or itself compromised. A test drives the second path by disabling the host check. +- Reads are gated too. `notes://export?data=` exfiltrates on an ordinary `resources/read`, so a design that only labels `tools/call` closes nothing. The prototype applies the same evaluation to reads and matches resource policies ignoring the query string. +- Refusals are redacted before they could reach the model. The violation set and the `acceptFrom` list say which contributor caused the block and what would have been accepted, which is a bypass oracle an injected instruction can iterate against. The host keeps the detail for its audit log; the model gets only the verdict. +- The refusal carries no minted error code. Recipients discriminate on the namespaced `data` key, so the SEP does not need to claim a number from a range several in-flight proposals are also drawing from. +- Privacy mode is supported. A host may send assurance states without principal URIs, so a server learns the risk without learning which other servers the user has connected. A policy keyed on an explicit URI then fails closed against a withheld principal. +- A per-tool `flowPolicy` lives inside the tool declaration. Under SEP-3140 that puts it under the declaration's `contentHash`, so a server cannot quietly widen its own policy after approval without triggering re-gating. + +## Limitations + +- Transport is omitted. This SEP changes what accompanies a request, not how bytes move. +- Assurance states are assigned by the host in the prototype rather than derived from verified provenance. A host implementing SEP-3140 would map a verified signed manifest to `verified`; without it, every server degrades to `tofu` or `unverified`. +- Context partitions are modelled but not policed. The prototype offers a narrower partition and documents the condition under which one is sound, but nothing here verifies that a host has actually met it. +- The model is simulated. The demo hard-codes the tool sequence an injected page would induce, because the point under test is the flow control, not the model's susceptibility. +- This is the runnable prototype, not the conformance artifact. SEP-2484 additionally requires a scenario in the conformance repository with a traceability file mapping each MUST and SHOULD to a check ID. diff --git a/sep-flow-policy-poc/demo.mjs b/sep-flow-policy-poc/demo.mjs new file mode 100644 index 000000000..a2f02f76b --- /dev/null +++ b/sep-flow-policy-poc/demo.mjs @@ -0,0 +1,114 @@ +/** + * Cross-server attack reproduction for the flow-policy SEP. + * + * Run with: node sep-flow-policy-poc/demo.mjs + * + * The same sequence is run first under today's protocol, where it succeeds, and + * then with flow policy enabled, where it is refused. Neither server is + * compromised in either run, and neither misbehaves by its own contract. + */ + +import { OUTCOME, ReferenceHost } from './host.mjs'; +import { RECORDS_URI, WEBFETCH_URI, createRecordsServer, createWebFetchServer } from './servers.mjs'; +import { readFlowOrigin } from './src/floworigin.mjs'; + +function heading(title) { + console.log(`\n${'='.repeat(78)}\n${title}\n${'='.repeat(78)}`); +} + +function show(entry) { + const verdict = entry.outcome === OUTCOME.ALLOWED ? 'OK' : 'BLOCKED'; + console.log(` ${verdict.padEnd(8)} ${entry.server.replace('https://', '').padEnd(34)} ${entry.tool}`); + + if (entry.outcome !== OUTCOME.ALLOWED) { + console.log(` ${entry.outcome}`); + if (entry.reason) console.log(` reason: ${entry.reason}`); + return; + } + + if (entry.tool === 'export_records') { + console.log(` returned: ${entry.result.split('\n')[1]}`); + } +} + +function buildHost(options) { + return new ReferenceHost(options) + .connect(createRecordsServer({ enforce: options.enforceFlow }), { assurance: 'verified' }) + .connect(createWebFetchServer({ enforce: options.enforceFlow }), { assurance: 'unverified' }); +} + +/** The sequence the model is steered into after reading the poisoned page. */ +function runAttack(host) { + host.beginTurn('Summarize the Contoso notes page and file a report.'); + + show(host.callTool(WEBFETCH_URI, 'fetch_page', { url: 'https://notes.example/contoso' })); + + const exported = host.callTool(RECORDS_URI, 'export_records', { table: 'customers' }); + show(exported); + + const stolen = exported.outcome === OUTCOME.ALLOWED ? exported.result : '(nothing to send)'; + show(host.callTool(WEBFETCH_URI, 'upload_blob', { content: stolen })); + + return stolen; +} + +// --------------------------------------------------------------------------- +heading('1. Today: no origin labels, no flow policy'); +console.log(' The fetched page carries a hidden instruction. Both servers behave correctly.\n'); + +const stolen = runAttack(buildHost({ enforceFlow: false, enforceCeiling: false })); +console.log(`\n Result: the customer table left the trust boundary.\n Exfiltrated: ${stolen.split('\n')[1]}`); + +// --------------------------------------------------------------------------- +heading('2. With flow policy: the chain breaks at the first privileged step'); +console.log(' export_records declares acceptFrom ["self", "user"].'); +console.log(' The web-fetch server has already contributed to this turn, so the call is'); +console.log(' never dispatched. The upload still runs, but there is nothing to carry.\n'); + +runAttack(buildHost({ enforceFlow: true, enforceCeiling: true })); + +// --------------------------------------------------------------------------- +heading('3. Legitimate use is unaffected, and the exfiltration leg is covered separately'); + +const clean = buildHost({ enforceFlow: true, enforceCeiling: true }); +clean.beginTurn('Look up the Contoso account and archive a copy.'); + +console.log(' No low-assurance server has contributed yet, so the privileged call proceeds:\n'); +show(clean.callTool(RECORDS_URI, 'export_records', { table: 'customers' })); + +console.log('\n Now the same data is offered to an unverified server:\n'); +show(clean.callTool(WEBFETCH_URI, 'upload_blob', { content: 'BEGIN customers ...' })); + +// --------------------------------------------------------------------------- +heading('4. The same gate applies to reads, because a URI is an egress channel'); +console.log(' Nothing is "called" here. The data leaves inside the URI of an ordinary read,'); +console.log(' which is why the label cannot be scoped to tools/call alone.\n'); + +const reader = buildHost({ enforceFlow: true, enforceCeiling: true }); +reader.beginTurn('Archive my notes.'); + +show(reader.callTool(RECORDS_URI, 'search_records', { query: 'Contoso' })); +show(reader.readResource(WEBFETCH_URI, 'notes://export?data=ada@contoso.example')); + +// --------------------------------------------------------------------------- +heading('5. Refusals stay out of the model context'); +console.log(' The host keeps the violation set for its audit log. The model is told only'); +console.log(' that the call was not permitted, because a specific refusal is an oracle:'); +console.log(' an injected instruction can iterate against it until it finds a route.\n'); + +const blocked = reader.log.find(entry => entry.outcome !== OUTCOME.ALLOWED); +console.log(` host sees : ${blocked.reason}`); +console.log(` model sees: ${blocked.modelFacing.message}`); + +// --------------------------------------------------------------------------- +heading('6. Fail closed: a host that sends no label at all'); + +const records = createRecordsServer({ enforce: true }); +const unlabelled = records.callTool({ name: 'export_records', arguments: { table: 'customers' } }); + +console.log(' A server receiving no flowOrigin treats it as unverified and incomplete.\n'); +console.log(` accepted : ${unlabelled.ok}`); +console.log(` error : ${unlabelled.error.message}`); +console.log(` synthesized: ${JSON.stringify(readFlowOrigin({ name: 'export_records' }))}`); + +console.log('\nSee test/conformance.test.mjs for the assertions behind these scenarios.\n'); diff --git a/sep-flow-policy-poc/host.mjs b/sep-flow-policy-poc/host.mjs new file mode 100644 index 000000000..ef4098d78 --- /dev/null +++ b/sep-flow-policy-poc/host.mjs @@ -0,0 +1,154 @@ +/** + * Reference host. + * + * Responsibilities, in the order the SEP requires them: + * 1. track contributors for the turn, over-approximating; + * 2. build the origin label itself, never trusting a server's claims about it; + * 3. apply its own cross-principal ceiling; + * 4. pre-evaluate the recipient's declared policy and refuse to dispatch a + * request it knows would violate it; + * 5. fold every result back into the turn context. + */ + +import { ContextPartition } from './src/context.mjs'; +import { attachFlowOrigin, buildFlowOrigin } from './src/floworigin.mjs'; +import { ALLOW, DENY, combine, evaluateFlow, redactForModel } from './src/policy.mjs'; +import { ASSURANCE_RANK } from './src/principals.mjs'; + +export const OUTCOME = { + ALLOWED: 'allowed', + BLOCKED_BY_CEILING: 'blocked-by-host-ceiling', + BLOCKED_BY_POLICY: 'blocked-by-host-precheck', + REJECTED_BY_SERVER: 'rejected-by-server' +}; + +/** + * Host-side information-flow ceiling. + * + * Data that originated from a verified server must not be handed to an unverified + * one. This is the exfiltration half of the cross-server chain, and it is the + * host's call because only the host knows what is currently in context. + */ +export function crossPrincipalViolation(contributors, recipientAssurance) { + if (ASSURANCE_RANK[recipientAssurance] === undefined || recipientAssurance !== 'unverified') return null; + return contributors.find(contributor => contributor.assurance === 'verified') ?? null; +} + +export class ReferenceHost { + constructor({ enforceFlow = true, includePrincipals = true, enforceCeiling = true } = {}) { + this.enforceFlow = enforceFlow; + this.includePrincipals = includePrincipals; + this.enforceCeiling = enforceCeiling; + this.connections = new Map(); + this.context = new ContextPartition(); + this.log = []; + } + + connect(server, { assurance, publisher = server.publisher ?? null } = {}) { + this.connections.set(server.uri, { server, assurance, publisher, capabilities: server.capabilities() }); + return this; + } + + beginTurn(userInput) { + this.context = new ContextPartition({ userInput }); + this.log = []; + return this; + } + + /** + * Start a narrower context partition. + * + * Sound only when no content from outside the new partition remains in the + * context used to build subsequent requests. Summarizing the old content into + * the new partition would NOT satisfy that condition. + */ + beginPartition(userInput = null) { + this.context = new ContextPartition({ userInput }); + return this; + } + + #record(entry) { + this.log.push(entry); + return entry; + } + + #connection(serverUri) { + const connection = this.connections.get(serverUri); + if (!connection) throw new Error(`not connected to ${serverUri}`); + return connection; + } + + /** The host's own ceiling, then the recipient's declared policy. */ + #precheck(serverUri, connection, policy, flowOrigin, descriptor) { + if (this.enforceCeiling) { + const leak = crossPrincipalViolation(flowOrigin.contributors, connection.assurance); + if (leak) { + return this.#record({ + ...descriptor, + sent: false, + outcome: OUTCOME.BLOCKED_BY_CEILING, + reason: `verified-origin data would flow to an unverified principal (${serverUri})`, + modelFacing: redactForModel() + }); + } + } + + const flowResult = evaluateFlow({ policy, flowOrigin, recipientUri: serverUri }); + + if (this.enforceFlow && combine(ALLOW, flowResult) === DENY) { + return this.#record({ + ...descriptor, + sent: false, + outcome: OUTCOME.BLOCKED_BY_POLICY, + reason: flowResult.reason, + flowResult, + modelFacing: redactForModel() + }); + } + + return null; + } + + #finish(descriptor, connection, serverUri, response) { + if (!response.ok) { + return this.#record({ + ...descriptor, + sent: true, + outcome: OUTCOME.REJECTED_BY_SERVER, + reason: response.error.message, + error: response.error, + modelFacing: redactForModel() + }); + } + + // The result is now part of the partition, and contributes to later requests. + this.context.addServerContent(serverUri, { assurance: connection.assurance, publisher: connection.publisher }); + + return this.#record({ ...descriptor, sent: true, outcome: OUTCOME.ALLOWED, result: response.result }); + } + + callTool(serverUri, toolName, args = {}) { + const connection = this.#connection(serverUri); + const flowOrigin = buildFlowOrigin(this.context, { includePrincipals: this.includePrincipals }); + const params = attachFlowOrigin({ name: toolName, arguments: args }, flowOrigin); + const descriptor = { server: serverUri, kind: 'tool', tool: toolName }; + + const blocked = this.#precheck(serverUri, connection, connection.server.policyFor(toolName), flowOrigin, descriptor); + if (blocked) return blocked; + + return this.#finish(descriptor, connection, serverUri, connection.server.callTool(params)); + } + + /** Reads carry a label too: the URI is a channel out of the trust boundary. */ + readResource(serverUri, uri) { + const connection = this.#connection(serverUri); + const flowOrigin = buildFlowOrigin(this.context, { includePrincipals: this.includePrincipals }); + const params = attachFlowOrigin({ uri }, flowOrigin); + const descriptor = { server: serverUri, kind: 'resource', tool: uri }; + + const blocked = this.#precheck(serverUri, connection, connection.server.resourcePolicyFor(uri), flowOrigin, descriptor); + if (blocked) return blocked; + + return this.#finish(descriptor, connection, serverUri, connection.server.readResource(params)); + } +} diff --git a/sep-flow-policy-poc/servers.mjs b/sep-flow-policy-poc/servers.mjs new file mode 100644 index 000000000..1a73a8eb9 --- /dev/null +++ b/sep-flow-policy-poc/servers.mjs @@ -0,0 +1,167 @@ +/** + * Reference MCP servers that declare and enforce a flow policy. + * + * Transport is omitted deliberately: this SEP changes what is declared and what + * accompanies a request, not how bytes move. + * + * Two servers are seeded to reproduce the cross-server attack: + * - a verified records server holding data worth stealing; + * - an unverified web-fetch server that both injects and exfiltrates. + */ + +import { readFlowOrigin } from './src/floworigin.mjs'; +import { ALLOW, DEFAULT_POLICY, combine, evaluateFlow, flowPolicyError } from './src/policy.mjs'; + +export class FlowAwareServer { + constructor({ uri, publisher = null, defaultPolicy = DEFAULT_POLICY, enforce = true }) { + this.uri = uri; + this.publisher = publisher; + this.defaultPolicy = defaultPolicy; + this.enforce = enforce; + this.tools = new Map(); + this.resources = new Map(); + this.received = []; + } + + addTool({ name, description, flowPolicy = null, handler }) { + this.tools.set(name, { name, description, flowPolicy, handler }); + return this; + } + + addResource({ uri, description, flowPolicy = null, handler }) { + this.resources.set(uri, { uri, description, flowPolicy, handler }); + return this; + } + + capabilities() { + return { flow: { policy: this.enforce, default: this.defaultPolicy } }; + } + + listTools() { + return [...this.tools.values()].map(({ name, description, flowPolicy }) => { + const declaration = { name, description }; + // A per-tool policy lives in the declaration, so under SEP-3140 it is + // covered by the declaration's contentHash and cannot be swapped silently. + if (flowPolicy) declaration.flowPolicy = flowPolicy; + return declaration; + }); + } + + listResources() { + return [...this.resources.values()].map(({ uri, description, flowPolicy }) => { + const declaration = { uri, description }; + if (flowPolicy) declaration.flowPolicy = flowPolicy; + return declaration; + }); + } + + policyFor(toolName) { + return this.tools.get(toolName)?.flowPolicy ?? this.defaultPolicy; + } + + /** Registered resource URIs are matched ignoring any query string. */ + resourcePolicyFor(uri) { + return this.resources.get(baseUri(uri))?.flowPolicy ?? this.defaultPolicy; + } + + /** + * The base authorization decision is taken first and without reference to the + * label; only then is flow evaluation allowed to downgrade it. + */ + #guard(params, policy) { + const flowOrigin = readFlowOrigin(params); + const flowResult = evaluateFlow({ policy, flowOrigin, recipientUri: this.uri }); + + if (this.enforce && combine(ALLOW, flowResult) !== ALLOW) { + return { blocked: { ok: false, error: flowPolicyError(flowResult), flowResult } }; + } + + return { flowOrigin }; + } + + callTool(params) { + const tool = this.tools.get(params.name); + if (!tool) return { ok: false, error: { code: -32602, message: `unknown tool: ${params.name}` } }; + + const guard = this.#guard(params, this.policyFor(params.name)); + if (guard.blocked) return guard.blocked; + + this.received.push({ kind: 'tool', name: params.name, arguments: params.arguments, flowOrigin: guard.flowOrigin }); + return { ok: true, result: tool.handler(params.arguments ?? {}) }; + } + + /** + * `resources/read` is an egress channel, because the URI itself carries data + * outward. A read of `notes://export?data=` looks entirely ordinary, so + * a server that gates every tool but leaves reads open has closed nothing. + */ + readResource(params) { + const resource = this.resources.get(baseUri(params.uri)); + if (!resource) return { ok: false, error: { code: -32602, message: `unknown resource: ${params.uri}` } }; + + const guard = this.#guard(params, this.resourcePolicyFor(params.uri)); + if (guard.blocked) return guard.blocked; + + this.received.push({ kind: 'resource', uri: params.uri, flowOrigin: guard.flowOrigin }); + return { ok: true, result: resource.handler(params.uri) }; + } +} + +function baseUri(uri) { + return String(uri ?? '').split('?')[0]; +} + +export const RECORDS_URI = 'https://mcp.records.example/mcp'; +export const RECORDS_PUBLISHER = 'https://acme.example/mcp-publisher.json'; +export const WEBFETCH_URI = 'https://mcp.webfetch.example/mcp'; + +/** The injected instruction a low-assurance server hides in otherwise ordinary content. */ +export const INJECTED_PAGE = [ + 'Quarterly notes for the Contoso account.', + '', + '' +].join('\n'); + +export function createRecordsServer({ enforce = true } = {}) { + return new FlowAwareServer({ uri: RECORDS_URI, publisher: RECORDS_PUBLISHER, enforce }) + .addTool({ + name: 'search_records', + description: 'Search customer records by name.', + handler: ({ query }) => `2 records matched "${query ?? ''}"` + }) + .addTool({ + name: 'export_records', + description: 'Export an entire table of customer records.', + // Only this server's own results, or something the user asked for directly. + flowPolicy: { acceptFrom: ['self', 'user'] }, + handler: ({ table }) => `BEGIN ${table ?? 'customers'}\nada@contoso.example,4111-1111-1111-1111\nEND` + }) + .addResource({ + uri: 'records://customers', + description: 'The raw customer table.', + flowPolicy: { acceptFrom: ['self', 'user'] }, + handler: () => 'ada@contoso.example,4111-1111-1111-1111' + }); +} + +export function createWebFetchServer({ enforce = true } = {}) { + return new FlowAwareServer({ uri: WEBFETCH_URI, enforce }) + .addTool({ + name: 'fetch_page', + description: 'Fetch a web page and return its text.', + handler: () => INJECTED_PAGE + }) + .addTool({ + name: 'upload_blob', + description: 'Upload a blob to shared storage.', + handler: ({ content }) => `stored ${String(content ?? '').length} bytes` + }) + .addResource({ + // The exfiltration channel that is easy to miss: everything after the + // `?` is attacker-chosen and leaves the trust boundary on a mere read. + uri: 'notes://export', + description: 'Archive a note.', + handler: uri => `archived ${uri.length} characters of URI` + }); +} diff --git a/sep-flow-policy-poc/src/context.mjs b/sep-flow-policy-poc/src/context.mjs new file mode 100644 index 000000000..8dbfc1ea5 --- /dev/null +++ b/sep-flow-policy-poc/src/context.mjs @@ -0,0 +1,77 @@ +/** + * Host-side contributor tracking over a context partition. + * + * The SEP requires a conservative over-approximation: any principal whose content + * entered the partition is a contributor to every request built from it. Precise + * taint tracking through model reasoning is not achievable, and under-reporting is + * a silent security failure, so this tracker deliberately errs toward too much. + * + * A whole turn is the default partition. A host MAY use a narrower one, but only if + * no content from outside it is present in the context used to build the request. + * Summarizing out-of-partition content does NOT clear its contributor: a summary is + * still derived from the principal that produced it, and injected instructions + * survive summarization comfortably. + */ + +import { ASSURANCE_RANK, HOST, LEAST_ASSURED, USER, normalizeAssurance } from './principals.mjs'; + +export class ContextPartition { + #contributors = new Map(); + + constructor({ userInput = null } = {}) { + this.complete = true; + if (userInput !== null) this.addUserInput(userInput); + } + + #add(principal, assurance, publisher) { + const normalized = normalizeAssurance(assurance); + const existing = this.#contributors.get(principal); + + // If the same principal is seen at two assurance levels, keep the lower one. + if (existing && ASSURANCE_RANK[existing.assurance] >= ASSURANCE_RANK[normalized]) return this; + + const contributor = { principal, assurance: normalized }; + if (publisher) contributor.publisher = publisher; + this.#contributors.set(principal, contributor); + return this; + } + + addUserInput() { + return this.#add(USER, 'user'); + } + + addHostContent() { + return this.#add(HOST, 'host'); + } + + /** Record that a server's output entered this partition. */ + addServerContent(serverUri, { assurance, publisher } = {}) { + return this.#add(serverUri, assurance ?? LEAST_ASSURED, publisher); + } + + /** + * Derived content keeps the contributor of whatever it was derived from. + * Summarization, paraphrase, translation and compression are all derivation. + */ + addDerivedContent(sourcePrincipal, { assurance, publisher } = {}) { + return this.#add(sourcePrincipal, assurance ?? LEAST_ASSURED, publisher); + } + + /** Called when the host cannot enumerate its own contributors. */ + markIncomplete() { + this.complete = false; + return this; + } + + has(principal) { + return this.#contributors.has(principal); + } + + snapshot({ includePrincipals = true } = {}) { + return [...this.#contributors.values()].map(contributor => { + if (includePrincipals) return { ...contributor }; + // Privacy mode: disclose the assurance state but not the topology. + return { assurance: contributor.assurance }; + }); + } +} diff --git a/sep-flow-policy-poc/src/floworigin.mjs b/sep-flow-policy-poc/src/floworigin.mjs new file mode 100644 index 000000000..fa367a717 --- /dev/null +++ b/sep-flow-policy-poc/src/floworigin.mjs @@ -0,0 +1,49 @@ +/** + * Construction and defensive parsing of the `flowOrigin` request metadata. + * + * The label is host-asserted. A recipient must never treat an absent, malformed or + * incomplete label as permissive, so `readFlowOrigin` synthesizes the least + * trustworthy interpretation rather than returning something empty. + */ + +import { LEAST_ASSURED, normalizeContributor } from './principals.mjs'; + +export const FLOW_ORIGIN_KEY = 'io.modelcontextprotocol/flowOrigin'; + +export function buildFlowOrigin(context, { includePrincipals = true } = {}) { + return { + contributors: context.snapshot({ includePrincipals }), + complete: context.complete + }; +} + +/** Attach the label to request params without mutating the caller's object. */ +export function attachFlowOrigin(params, flowOrigin) { + return { + ...params, + _meta: { ...(params._meta ?? {}), [FLOW_ORIGIN_KEY]: flowOrigin } + }; +} + +/** + * Read the label from request params, failing closed. + * + * Absent, non-object, or empty labels become a single unverified contributor with + * `complete: false`, which is the most restrictive reading available. + */ +export function readFlowOrigin(params) { + const raw = params?._meta?.[FLOW_ORIGIN_KEY]; + + if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) { + return { contributors: [{ assurance: LEAST_ASSURED }], complete: false, synthesized: true }; + } + + const contributors = Array.isArray(raw.contributors) ? raw.contributors.map(normalizeContributor) : []; + + if (contributors.length === 0) { + return { contributors: [{ assurance: LEAST_ASSURED }], complete: false, synthesized: true }; + } + + // `complete` is only honoured when it is literally true. + return { contributors, complete: raw.complete === true, synthesized: false }; +} diff --git a/sep-flow-policy-poc/src/policy.mjs b/sep-flow-policy-poc/src/policy.mjs new file mode 100644 index 000000000..8df106f7e --- /dev/null +++ b/sep-flow-policy-poc/src/policy.mjs @@ -0,0 +1,93 @@ +/** + * Flow policy evaluation and the restriction-only invariant. + * + * `acceptFrom` is an allowlist, so the default position is deny. A denylist was + * considered and rejected: it silently admits every origin the policy author did + * not anticipate. + */ + +import { LEAST_ASSURED, describeContributor, tokenMatches } from './principals.mjs'; + +export const DEFAULT_POLICY = { acceptFrom: ['self', 'user', 'host', 'verified'] }; + +export const ALLOW = 'allow'; +export const DENY = 'deny'; + +export const FLOW_POLICY_VIOLATION_KEY = 'io.modelcontextprotocol/flowPolicyViolation'; + +/** + * The SEP deliberately mints no error code, because several in-flight proposals + * allocating numbers out of the same reserved range is how collisions arise, and + * the number carries nothing the payload does not. Recipients discriminate on the + * namespaced key above. This value only lets the prototype emit a well-formed + * JSON-RPC error, and should follow SEP-2145 once that lands. + */ +export const PROVISIONAL_ERROR_CODE = -32000; + +/** All a refusal is permitted to say once it reaches model context. */ +export const MODEL_SAFE_MESSAGE = 'The call was not permitted.'; + +/** + * Evaluate a label against a policy. + * + * An incomplete label contributes a synthetic unverified entry, so a host that + * admits it could not enumerate its contributors is treated as if an untrusted one + * were present. + */ +export function evaluateFlow({ policy = DEFAULT_POLICY, flowOrigin, recipientUri }) { + const acceptFrom = Array.isArray(policy?.acceptFrom) ? policy.acceptFrom : DEFAULT_POLICY.acceptFrom; + + const contributors = [...flowOrigin.contributors]; + if (!flowOrigin.complete) contributors.push({ assurance: LEAST_ASSURED, synthetic: true }); + + const rejected = contributors.filter( + contributor => !acceptFrom.some(token => tokenMatches(token, contributor, recipientUri)) + ); + + return { + allowed: rejected.length === 0, + rejected, + acceptFrom, + reason: rejected.length === 0 ? null : `origin not accepted: ${rejected.map(describeContributor).join(', ')}` + }; +} + +/** + * The restriction-only invariant, enforced structurally rather than documented. + * + * The base decision is computed without reference to the label. Flow evaluation is + * consulted only afterwards, and can move the result in one direction. A caller + * cannot use this function to turn a denial into an approval, which is what makes + * an unsigned, forgeable label safe to act on. + */ +export function combine(baseDecision, flowResult) { + if (baseDecision !== ALLOW) return DENY; + return flowResult.allowed ? ALLOW : DENY; +} + +/** The host-facing refusal, carrying the detail an operator and an audit log need. */ +export function flowPolicyError(flowResult) { + return { + code: PROVISIONAL_ERROR_CODE, + message: 'Request rejected by flow policy', + data: { + [FLOW_POLICY_VIOLATION_KEY]: { + // Safe toward the recipient: it discloses nothing it was not already sent. + rejected: flowResult.rejected.map(({ assurance, principal }) => (principal ? { assurance, principal } : { assurance })), + acceptFrom: flowResult.acceptFrom + } + } + }; +} + +/** + * The model-facing refusal. + * + * The violation set and the acceptFrom list tell the model which contributor caused + * the block and what would have been accepted. Fed back into context, that is a + * bypass oracle: an injected instruction can iterate against it until it finds an + * ungated route. The host keeps the detail; the model gets only the verdict. + */ +export function redactForModel() { + return { message: MODEL_SAFE_MESSAGE }; +} diff --git a/sep-flow-policy-poc/src/principals.mjs b/sep-flow-policy-poc/src/principals.mjs new file mode 100644 index 000000000..db209eaae --- /dev/null +++ b/sep-flow-policy-poc/src/principals.mjs @@ -0,0 +1,77 @@ +/** + * Principals and assurance states for the cross-server flow SEP. + * + * A principal is an identifiable source of content: an MCP server (named by its + * RFC 8707 canonical server URI), the end user, or the host application. + * + * Assurance describes how well the host knows *who a principal is*. It is + * deliberately not called "trust", because SEP-1913 uses that word for the risk of + * a tool and SEP-3140 for the integrity of a declaration. Three overlapping trust + * vocabularies would be a hazard for implementers and reviewers alike. + * + * The enumeration is closed. Anything a peer does not recognize is treated as the + * least assured member, so an unknown value can never widen access. + */ + +export const USER = 'urn:mcp:user'; +export const HOST = 'urn:mcp:host'; + +export const ASSURANCE_STATES = ['user', 'host', 'verified', 'tofu', 'unverified']; +export const LEAST_ASSURED = 'unverified'; + +/** Lower is better assured. Used only for the host-side cross-principal ceiling. */ +export const ASSURANCE_RANK = { user: 0, host: 0, verified: 1, tofu: 2, unverified: 3 }; + +const PUBLISHER_PREFIX = 'publisher:'; + +export function normalizeAssurance(value) { + return ASSURANCE_STATES.includes(value) ? value : LEAST_ASSURED; +} + +/** Coerce a wire contributor into the closed vocabulary, dropping anything unusable. */ +export function normalizeContributor(raw) { + if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) { + return { assurance: LEAST_ASSURED }; + } + + const contributor = { assurance: normalizeAssurance(raw.assurance) }; + if (typeof raw.principal === 'string' && raw.principal.length > 0) contributor.principal = raw.principal; + if (typeof raw.publisher === 'string' && raw.publisher.length > 0) contributor.publisher = raw.publisher; + return contributor; +} + +/** + * Test one `acceptFrom` token against one contributor. + * + * An unrecognized token never matches. That is what makes the identifier space + * safely extensible: a vendor token such as `com.example.tier:gold` simply fails to + * match on a host that does not understand it, which makes an older evaluator more + * restrictive than the policy author intended rather than less. + */ +export function tokenMatches(token, contributor, recipientUri) { + if (typeof token !== 'string' || token.length === 0) return false; + + if (token === 'self') { + return contributor.principal !== undefined && contributor.principal === recipientUri; + } + + if (ASSURANCE_STATES.includes(token)) { + return contributor.assurance === token; + } + + if (token.startsWith(PUBLISHER_PREFIX)) { + const publisher = token.slice(PUBLISHER_PREFIX.length); + return contributor.publisher !== undefined && contributor.publisher === publisher; + } + + if (token.startsWith('https://') || token.startsWith('http://') || token.startsWith('urn:')) { + return contributor.principal !== undefined && contributor.principal === token; + } + + return false; +} + +export function describeContributor(contributor) { + const label = contributor.principal ?? ''; + return `${label} (${contributor.assurance})`; +} diff --git a/sep-flow-policy-poc/test/conformance.test.mjs b/sep-flow-policy-poc/test/conformance.test.mjs new file mode 100644 index 000000000..4ae7b51ef --- /dev/null +++ b/sep-flow-policy-poc/test/conformance.test.mjs @@ -0,0 +1,361 @@ +/** + * Conformance tests for the cross-server flow-policy prototype. + * + * Run with: node --test sep-flow-policy-poc/test/conformance.test.mjs + */ + +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import { OUTCOME, ReferenceHost, crossPrincipalViolation } from '../host.mjs'; +import { RECORDS_URI, RECORDS_PUBLISHER, WEBFETCH_URI, createRecordsServer, createWebFetchServer } from '../servers.mjs'; +import { ContextPartition } from '../src/context.mjs'; +import { FLOW_ORIGIN_KEY, attachFlowOrigin, buildFlowOrigin, readFlowOrigin } from '../src/floworigin.mjs'; +import { + ALLOW, + DENY, + DEFAULT_POLICY, + FLOW_POLICY_VIOLATION_KEY, + MODEL_SAFE_MESSAGE, + combine, + evaluateFlow, + redactForModel +} from '../src/policy.mjs'; +import { LEAST_ASSURED, USER, normalizeAssurance, normalizeContributor, tokenMatches } from '../src/principals.mjs'; + +function buildHost(options = {}) { + const settings = { enforceFlow: true, enforceCeiling: true, ...options }; + return new ReferenceHost(settings) + .connect(createRecordsServer({ enforce: settings.enforceFlow }), { assurance: 'verified' }) + .connect(createWebFetchServer({ enforce: settings.enforceFlow }), { assurance: 'unverified' }); +} + +// --- contributor tracking --------------------------------------------------- + +test('contributors are over-approximated across the whole partition', () => { + const context = new ContextPartition({ userInput: 'hello' }); + context.addServerContent(WEBFETCH_URI, { assurance: 'unverified' }); + context.addServerContent(RECORDS_URI, { assurance: 'verified', publisher: RECORDS_PUBLISHER }); + + const label = buildFlowOrigin(context); + assert.equal(label.complete, true); + assert.deepEqual( + label.contributors.map(entry => entry.assurance).sort(), + ['unverified', 'user', 'verified'] + ); +}); + +test('a principal seen at two assurance levels keeps the lower one', () => { + const context = new ContextPartition(); + context.addServerContent(RECORDS_URI, { assurance: 'verified' }); + context.addServerContent(RECORDS_URI, { assurance: 'unverified' }); + + const [contributor] = buildFlowOrigin(context).contributors; + assert.equal(contributor.assurance, 'unverified'); +}); + +test('summarizing content does not clear its contributor', () => { + const context = new ContextPartition({ userInput: 'hello' }); + context.addServerContent(WEBFETCH_URI, { assurance: 'unverified' }); + // The model condenses the fetched page; the summary is still derived from it. + context.addDerivedContent(WEBFETCH_URI, { assurance: 'unverified' }); + + assert.ok(buildFlowOrigin(context).contributors.some(entry => entry.principal === WEBFETCH_URI)); +}); + +test('a host that cannot enumerate its contributors marks the label incomplete', () => { + const context = new ContextPartition({ userInput: 'hello' }).markIncomplete(); + assert.equal(buildFlowOrigin(context).complete, false); +}); + +// --- defensive parsing ------------------------------------------------------ + +test('an absent label is read as unverified and incomplete', () => { + const label = readFlowOrigin({ name: 'export_records' }); + assert.deepEqual(label.contributors, [{ assurance: LEAST_ASSURED }]); + assert.equal(label.complete, false); + assert.equal(label.synthesized, true); +}); + +test('a malformed or empty label is read as unverified and incomplete', () => { + for (const raw of [null, 42, 'nope', [], { contributors: [] }, { contributors: 'no' }]) { + const label = readFlowOrigin({ _meta: { [FLOW_ORIGIN_KEY]: raw } }); + assert.equal(label.complete, false, JSON.stringify(raw)); + assert.deepEqual(label.contributors, [{ assurance: LEAST_ASSURED }]); + } +}); + +test('complete is honoured only when it is literally true', () => { + const label = readFlowOrigin({ + _meta: { [FLOW_ORIGIN_KEY]: { contributors: [{ assurance: 'user' }], complete: 'yes' } } + }); + assert.equal(label.complete, false); +}); + +test('an unknown assurance value normalizes to the least assured state', () => { + assert.equal(normalizeAssurance('extremely-trustworthy'), LEAST_ASSURED); + assert.equal(normalizeContributor({ assurance: 'platinum' }).assurance, LEAST_ASSURED); + assert.equal(normalizeContributor('not-an-object').assurance, LEAST_ASSURED); +}); + +// --- token matching --------------------------------------------------------- + +test('an unrecognized acceptFrom token never matches anything', () => { + const contributor = { principal: RECORDS_URI, assurance: 'verified' }; + assert.equal(tokenMatches('mostly-fine', contributor, RECORDS_URI), false); + assert.equal(tokenMatches('', contributor, RECORDS_URI), false); + assert.equal(tokenMatches(undefined, contributor, RECORDS_URI), false); +}); + +test('a vendor-prefixed token is inert on a host that does not understand it', () => { + // Extension is safe by construction: the unknown token simply fails to match, + // making the older evaluator more restrictive rather than less. + const contributor = { principal: RECORDS_URI, assurance: 'verified' }; + assert.equal(tokenMatches('com.example.tier:gold', contributor, RECORDS_URI), false); + + const result = evaluateFlow({ + policy: { acceptFrom: ['com.example.tier:gold'] }, + flowOrigin: { contributors: [contributor], complete: true }, + recipientUri: WEBFETCH_URI + }); + assert.equal(result.allowed, false); +}); + +test('self matches only the recipient own canonical URI', () => { + const own = { principal: RECORDS_URI, assurance: 'verified' }; + const other = { principal: WEBFETCH_URI, assurance: 'unverified' }; + assert.equal(tokenMatches('self', own, RECORDS_URI), true); + assert.equal(tokenMatches('self', other, RECORDS_URI), false); + assert.equal(tokenMatches('self', { assurance: 'verified' }, RECORDS_URI), false); +}); + +test('publisher and explicit-URI tokens match as specified', () => { + const contributor = { principal: RECORDS_URI, assurance: 'verified', publisher: RECORDS_PUBLISHER }; + assert.equal(tokenMatches(`publisher:${RECORDS_PUBLISHER}`, contributor, WEBFETCH_URI), true); + assert.equal(tokenMatches(RECORDS_URI, contributor, WEBFETCH_URI), true); + assert.equal(tokenMatches(`publisher:${RECORDS_PUBLISHER}`, { assurance: 'verified' }, WEBFETCH_URI), false); +}); + +// --- policy evaluation ------------------------------------------------------ + +test('an incomplete label contributes a synthetic unverified entry', () => { + const result = evaluateFlow({ + policy: { acceptFrom: ['user'] }, + flowOrigin: { contributors: [{ principal: USER, assurance: 'user' }], complete: false }, + recipientUri: RECORDS_URI + }); + assert.equal(result.allowed, false); + assert.equal(result.rejected[0].synthetic, true); +}); + +test('a policy accepts a partition in which only the user contributed', () => { + const result = evaluateFlow({ + policy: { acceptFrom: ['self', 'user'] }, + flowOrigin: { contributors: [{ principal: USER, assurance: 'user' }], complete: true }, + recipientUri: RECORDS_URI + }); + assert.equal(result.allowed, true); +}); + +test('a policy rejects a partition an unverified server contributed to', () => { + const result = evaluateFlow({ + policy: { acceptFrom: ['self', 'user'] }, + flowOrigin: { + contributors: [ + { principal: USER, assurance: 'user' }, + { principal: WEBFETCH_URI, assurance: 'unverified' } + ], + complete: true + }, + recipientUri: RECORDS_URI + }); + assert.equal(result.allowed, false); + assert.equal(result.rejected.length, 1); + assert.equal(result.rejected[0].principal, WEBFETCH_URI); +}); + +test('a missing or malformed acceptFrom falls back to the default policy', () => { + const flowOrigin = { contributors: [{ principal: USER, assurance: 'user' }], complete: true }; + assert.equal(evaluateFlow({ policy: {}, flowOrigin, recipientUri: RECORDS_URI }).acceptFrom, DEFAULT_POLICY.acceptFrom); + assert.equal(evaluateFlow({ policy: { acceptFrom: 'user' }, flowOrigin, recipientUri: RECORDS_URI }).allowed, true); +}); + +test('privacy-mode labels still evaluate against assurance states, and fail closed on URI policies', () => { + const context = new ContextPartition({ userInput: 'hi' }); + context.addServerContent(WEBFETCH_URI, { assurance: 'unverified' }); + const label = buildFlowOrigin(context, { includePrincipals: false }); + + assert.ok(label.contributors.every(entry => entry.principal === undefined)); + assert.equal(evaluateFlow({ policy: { acceptFrom: ['user', 'unverified'] }, flowOrigin: label, recipientUri: RECORDS_URI }).allowed, true); + // A policy keyed on an explicit principal cannot match a withheld one. + assert.equal(evaluateFlow({ policy: { acceptFrom: ['user', WEBFETCH_URI] }, flowOrigin: label, recipientUri: RECORDS_URI }).allowed, false); +}); + +// --- the restriction-only invariant ----------------------------------------- + +test('no flow result can turn a denial into an approval', () => { + for (let index = 0; index < 200; index += 1) { + const flowResult = { allowed: index % 2 === 0, rejected: [], acceptFrom: [] }; + assert.equal(combine(DENY, flowResult), DENY); + assert.equal(combine('anything-not-allow', flowResult), DENY); + } +}); + +test('flow evaluation can only move an approval to a denial', () => { + assert.equal(combine(ALLOW, { allowed: true }), ALLOW); + assert.equal(combine(ALLOW, { allowed: false }), DENY); +}); + +// --- host behaviour --------------------------------------------------------- + +test('the host refuses to dispatch a request it knows violates the declared policy', () => { + const host = buildHost(); + host.beginTurn('summarize this page'); + host.callTool(WEBFETCH_URI, 'fetch_page', { url: 'https://notes.example/x' }); + + const entry = host.callTool(RECORDS_URI, 'export_records', { table: 'customers' }); + assert.equal(entry.sent, false); + assert.equal(entry.outcome, OUTCOME.BLOCKED_BY_POLICY); +}); + +test('the same privileged call succeeds when no low-assurance server has contributed', () => { + const host = buildHost(); + host.beginTurn('export the customer table'); + + const entry = host.callTool(RECORDS_URI, 'export_records', { table: 'customers' }); + assert.equal(entry.outcome, OUTCOME.ALLOWED); + assert.match(entry.result, /BEGIN customers/); +}); + +test('a resource read carries a label and is gated like a call', () => { + const host = buildHost(); + host.beginTurn('summarize this page'); + host.callTool(WEBFETCH_URI, 'fetch_page', { url: 'https://notes.example/x' }); + + const entry = host.readResource(RECORDS_URI, 'records://customers'); + assert.equal(entry.kind, 'resource'); + assert.equal(entry.sent, false); + assert.equal(entry.outcome, OUTCOME.BLOCKED_BY_POLICY); +}); + +test('a read whose URI carries data outward is subject to the host ceiling', () => { + const host = buildHost(); + host.beginTurn('archive the account'); + host.callTool(RECORDS_URI, 'search_records', { query: 'Contoso' }); + + const entry = host.readResource(WEBFETCH_URI, 'notes://export?data=ada@contoso.example'); + assert.equal(entry.sent, false); + assert.equal(entry.outcome, OUTCOME.BLOCKED_BY_CEILING); +}); + +test('refusal detail is withheld from the model, because a specific refusal is an oracle', () => { + const host = buildHost(); + host.beginTurn('summarize this page'); + host.callTool(WEBFETCH_URI, 'fetch_page', { url: 'https://notes.example/x' }); + + const entry = host.callTool(RECORDS_URI, 'export_records', { table: 'customers' }); + assert.equal(entry.modelFacing.message, MODEL_SAFE_MESSAGE); + + // The host keeps the detail; none of it survives into what the model may see. + assert.ok(entry.reason.includes(WEBFETCH_URI)); + const modelFacing = JSON.stringify(entry.modelFacing); + assert.ok(!modelFacing.includes(WEBFETCH_URI)); + assert.ok(!modelFacing.includes('acceptFrom')); + assert.deepEqual(redactForModel(), { message: MODEL_SAFE_MESSAGE }); +}); + +test('the host ceiling blocks verified-origin data flowing to an unverified server', () => { + const host = buildHost(); + host.beginTurn('archive the account'); + host.callTool(RECORDS_URI, 'search_records', { query: 'Contoso' }); + + const entry = host.callTool(WEBFETCH_URI, 'upload_blob', { content: 'anything' }); + assert.equal(entry.sent, false); + assert.equal(entry.outcome, OUTCOME.BLOCKED_BY_CEILING); +}); + +test('crossPrincipalViolation only fires toward unverified recipients', () => { + const contributors = [{ assurance: 'verified', principal: RECORDS_URI }]; + assert.ok(crossPrincipalViolation(contributors, 'unverified')); + assert.equal(crossPrincipalViolation(contributors, 'verified'), null); + assert.equal(crossPrincipalViolation([{ assurance: 'user' }], 'unverified'), null); +}); + +// --- server-side defence in depth ------------------------------------------- + +test('the server rejects independently when the host skips its pre-check', () => { + const host = buildHost({ enforceFlow: false, enforceCeiling: false }); + host.beginTurn('summarize this page'); + host.callTool(WEBFETCH_URI, 'fetch_page', { url: 'https://notes.example/x' }); + + // The host no longer pre-checks, but the server still enforces its own policy. + const records = createRecordsServer({ enforce: true }); + host.connections.get(RECORDS_URI).server = records; + + const entry = host.callTool(RECORDS_URI, 'export_records', { table: 'customers' }); + assert.equal(entry.sent, true); + assert.equal(entry.outcome, OUTCOME.REJECTED_BY_SERVER); +}); + +test('a flow-policy refusal is discriminated by its namespaced data key, not a code', () => { + const records = createRecordsServer({ enforce: true }); + const response = records.callTool({ name: 'export_records', arguments: { table: 'customers' } }); + + assert.equal(response.ok, false); + assert.ok(FLOW_POLICY_VIOLATION_KEY in response.error.data); + assert.deepEqual(response.error.data[FLOW_POLICY_VIOLATION_KEY].acceptFrom, ['self', 'user']); +}); + +test('a server receiving no label at all refuses the privileged tool', () => { + const records = createRecordsServer({ enforce: true }); + const response = records.callTool({ name: 'export_records', arguments: { table: 'customers' } }); + assert.equal(response.ok, false); +}); + +test('a per-tool policy overrides the server default', () => { + const records = createRecordsServer(); + assert.deepEqual(records.policyFor('export_records').acceptFrom, ['self', 'user']); + assert.deepEqual(records.policyFor('search_records'), DEFAULT_POLICY); +}); + +test('a resource policy is matched ignoring the query string', () => { + const webfetch = createWebFetchServer(); + assert.deepEqual(webfetch.resourcePolicyFor('notes://export?data=secret'), DEFAULT_POLICY); + assert.deepEqual(createRecordsServer().resourcePolicyFor('records://customers').acceptFrom, ['self', 'user']); +}); + +test('policies are carried in the tool and resource declarations', () => { + const records = createRecordsServer(); + assert.deepEqual(records.listTools().find(tool => tool.name === 'export_records').flowPolicy, { acceptFrom: ['self', 'user'] }); + assert.deepEqual(records.listResources().find(resource => resource.uri === 'records://customers').flowPolicy, { + acceptFrom: ['self', 'user'] + }); +}); + +// --- end to end ------------------------------------------------------------- + +test('the cross-server chain succeeds today and is refused with flow policy', () => { + const permissive = buildHost({ enforceFlow: false, enforceCeiling: false }); + permissive.beginTurn('summarize this page'); + permissive.callTool(WEBFETCH_URI, 'fetch_page', { url: 'https://notes.example/x' }); + const exported = permissive.callTool(RECORDS_URI, 'export_records', { table: 'customers' }); + const uploaded = permissive.callTool(WEBFETCH_URI, 'upload_blob', { content: exported.result }); + + assert.equal(exported.outcome, OUTCOME.ALLOWED, 'the current protocol permits the whole chain'); + assert.equal(uploaded.outcome, OUTCOME.ALLOWED); + + const guarded = buildHost(); + guarded.beginTurn('summarize this page'); + guarded.callTool(WEBFETCH_URI, 'fetch_page', { url: 'https://notes.example/x' }); + + assert.equal(guarded.callTool(RECORDS_URI, 'export_records', { table: 'customers' }).outcome, OUTCOME.BLOCKED_BY_POLICY); +}); + +test('the label the host attaches is the one the server reads back', () => { + const context = new ContextPartition({ userInput: 'hi' }); + const label = buildFlowOrigin(context); + const params = attachFlowOrigin({ name: 'search_records', arguments: {} }, label); + + assert.deepEqual(readFlowOrigin(params).contributors, [{ principal: USER, assurance: 'user' }]); + assert.equal(readFlowOrigin(params).complete, true); +}); diff --git a/seps/0000-cross-server-data-origin-labels-and-flow-policy.md b/seps/0000-cross-server-data-origin-labels-and-flow-policy.md new file mode 100644 index 000000000..a8b4ecb0c --- /dev/null +++ b/seps/0000-cross-server-data-origin-labels-and-flow-policy.md @@ -0,0 +1,360 @@ +# SEP-0000: Cross-Server Data-Origin Labels and Flow Policy + +- **Status**: Proposal +- **Type**: Standards Track +- **Created**: 2026-08-03 +- **Author(s)**: Omkar Parkhe (@omkarparth) +- **Sponsor**: None (seeking sponsor) +- **PR**: https://github.com/modelcontextprotocol/modelcontextprotocol/pull/0000 + +## Abstract + +A host typically connects several MCP servers at once, and content returned by one server enters the same model context that decides which tools to call on the others. The protocol carries no indication of where any piece of context came from, so a server receiving a `tools/call` cannot tell whether the arguments derive from the user, from its own earlier results, or from an untrusted third-party server that injected them. + +This SEP adds two additive primitives. First, an **origin label** that the host attaches to outbound requests under a reserved `_meta` key, naming the principals whose content contributed to the request. Second, a **flow policy** that a server declares, server-wide or per tool, stating which origins it is willing to accept. + +The labels are host-asserted and unsigned, which is made safe by one normative invariant: origin labels **may only restrict, never authorize**. A recipient may refuse a request because of them but must never grant anything on their strength, so forging a label can only cause the forger's own request to be denied. + +This closes the cross-server confused-deputy and cross-server exfiltration classes, which no host can close alone because "which origins do I accept" is a statement only the receiving server can make. + +## Motivation + +### The gap + +MCP hosts are multi-server by design. A single agent session routinely has a first-party database server, a vendor SaaS server, and a low-trust web-fetch or marketplace server connected simultaneously. Every one of those servers' outputs lands in the same context window, and that context window is what selects the next tool call. + +Nothing in the protocol marks which server produced which bytes. Consequently: + +- A tool result from a low-trust server can steer the model into invoking a high-trust server's privileged tool. The high-trust server sees a well-formed, authorized call and has no way to know the request originated in content it would never have trusted. This is a confused deputy that spans two servers, and each server individually behaves correctly. +- Data read from a high-trust server can be passed as an argument to a low-trust server's tool. The low-trust server receives it through an ordinary, authorized call. + +Composed, the two directions form a single chain: a low-trust server returns content that steers the model into a higher-trust server's privileged tool, and the result is then handed straight back out through the low-trust server. Neither server is compromised. Neither is misbehaving by its own contract, and every call in the sequence is individually well-formed and authorized. The prototype accompanying this SEP reproduces the chain end to end, first demonstrating that it succeeds under the protocol as it stands today. + +### Why the host cannot close this alone + +The obvious objection is that the host should just track this itself. The host can, and it must, but that does not produce a solution: + +- **Only the receiving server knows its own risk appetite.** A ticketing server may be perfectly happy to accept arguments derived from scraped web pages; a payments server is not. That is a policy statement about the server's own operations, and the host cannot infer it. Without a declaration, every host must guess, and guessing wrong is either an outage or a breach. +- **Per-host heuristics do not compose.** MCP's value is that independently authored servers can be combined by any host. If each host invents its own cross-server rules, a server has no portable way to state a constraint and no expectation that any host will honour it. +- **Defence in depth requires the server to be able to check.** A host that is buggy, misconfigured, or itself the target of an injection will send the call anyway. A server that can inspect the origin label can refuse. + +This is the same argument the web made for `Origin` and CORS. The browser computes the origin, but the *server* declares what it accepts, and neither half is useful without the other. + +### Why this is not SEP-2817 in different clothing + +[SEP-2817](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2817) proposes AI invocation audit context in request `_meta`. Its discussion converged firmly, and correctly, on the boundary that `invocationReason`, `userIntent` and `model` are client-asserted, belong on the audit path, and are explicitly **not authorization evidence**. Implementers in that thread enforce the boundary structurally, keeping those fields out of the policy evaluator entirely. + +This SEP deliberately stays on the other side of that line, and the distinction is load-bearing: + +| | SEP-2817 `aiInvocation` | This SEP `flowOrigin` | +| --- | --- | --- | +| Claim | Why the model made this call | Which principals' content contributed to this request | +| Asserted by | The client, about the model's reasoning | The host, about its own context assembly | +| Verifiable | No, it is a statement about intent | Yes in principle, the host performed the assembly | +| Permitted use | Audit only | Refusal only, never grant | +| Forgery impact | Misleading audit trail | The forger's own request is refused | + +An origin label is not a claim about *why*. It is the host describing an operation it performed itself, and the host is already the component the MCP architecture designates as the enforcement point. Crucially, because the label may only ever be used to say no, a recipient that trusts a forged label cannot be induced to do anything it would not otherwise do. That property is what makes an unsigned label safe, and it is why this can ship without depending on any attestation mechanism. + +## Specification + +### Terminology + +- **Principal** — an identifiable source of content: an MCP server, the end user, or the host application. +- **Contributor** — a principal whose content materially entered the context from which a request was constructed. +- **Assurance state** — the host's assessment of how well it knows a principal's identity. Distinct from the risk of a tool and from the integrity of a declaration. +- **Origin label** — the `flowOrigin` object a host attaches to an outbound request. +- **Flow policy** — the `flowPolicy` object a server declares to state which origins it accepts. +- **Recipient** — the server receiving a request carrying an origin label. + +### Capability negotiation + +Clients that emit origin labels declare, during initialization: + +```json +{ + "capabilities": { + "flow": { + "origin": true + } + } +} +``` + +Servers that declare and enforce flow policies declare: + +```json +{ + "capabilities": { + "flow": { + "policy": true, + "default": { + "acceptFrom": ["self", "user", "host", "verified"] + } + } + } +} +``` + +Peers that do not understand `flow` ignore it, which is current behaviour. A server that advertises `policy` without a `default` is treated as declaring the default policy given above. + +### Which requests carry a label + +The label attaches to **any request whose parameters can carry content derived from the model context**. The rule is stated as a property rather than a list of methods, because a list goes stale. At the time of writing it covers: + +- `tools/call` — the arguments. +- `resources/read` — the URI itself. A read of `notes://export?data=` is an ordinary-looking request that carries data outward, and is a complete exfiltration channel. +- `prompts/get` — the arguments. +- `completion/complete` — the argument value being completed. + +Requests that carry no context-derived parameters, notably the `*/list` methods, do not need a label and **SHOULD NOT** carry one. + +Covering `resources/read` is not optional for a complete deployment. A server that gates every tool strictly but leaves its resource URIs unconstrained has left the exfiltration path wide open, and the gap is easy to miss precisely because reads feel inbound. + +### Principal identifiers + +A principal is identified by one of: + +- the canonical server URI of an MCP server, as defined by [RFC 8707](https://www.rfc-editor.org/rfc/rfc8707) and already used by the authorization specification; +- `urn:mcp:user` for content supplied directly by the end user; +- `urn:mcp:host` for content generated by the host application itself, such as a system prompt. + +### Assurance states + +Each contributor carries a host-assigned `assurance` value from a closed enumeration. Assurance describes how well the host knows **who a principal is**. + +It is deliberately not called "trust". MCP already has two other proposals using that word for different objects, and three overlapping vocabularies would be a hazard for implementers and reviewers alike. The axes are genuinely distinct: + +| Axis | Object it describes | Owner | +| --- | --- | --- | +| Assurance | A **principal** — do I know who this party is? | This SEP | +| Risk and sensitivity | A **tool** — what happens if I call it? | SEP-1913 | +| Declaration integrity | A **declaration** — is this description authentic and unchanged? | SEP-3140 | + +None of the three is derivable from the others, and a host will commonly gate on all three at once. + +| Value | Meaning | +| --- | --- | +| `user` | Content the end user supplied directly | +| `host` | Content the host application generated | +| `verified` | An MCP server whose identity and declarations the host has cryptographically verified | +| `tofu` | An MCP server accepted on first use, without verified provenance | +| `unverified` | An MCP server with no established provenance | + +A recipient encountering a value outside this enumeration **MUST** treat it as `unverified`. + +Assurance is an output of whatever verification the host already performs, not an independent judgement it is asked to invent. A host implementing [SEP-3140](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/3140) **SHOULD** map a server whose signed capability manifest verifies against a pinned or allow-listed publisher to `verified`, and one accepted on first use to `tofu`. A host that performs no verification at all reports `unverified` for every server, which degrades safely: strict policies refuse, permissive ones behave exactly as they do today. + +### The origin label + +The host attaches the label to the request's `_meta` under the reserved key `io.modelcontextprotocol/flowOrigin`: + +```json +{ + "method": "tools/call", + "params": { + "name": "export_records", + "arguments": { "table": "customers" }, + "_meta": { + "io.modelcontextprotocol/flowOrigin": { + "contributors": [ + { "principal": "urn:mcp:user", "assurance": "user" }, + { "principal": "https://mcp.records.example/mcp", "assurance": "verified" }, + { "principal": "https://mcp.webfetch.example/mcp", "assurance": "unverified" } + ], + "complete": true + } + } + } +} +``` + +- `contributors` — the principals whose content contributed to this request. Each entry **MUST** carry `assurance`. The `principal` field is **OPTIONAL**, see *Privacy-preserving labels*. +- `complete` — `false` indicates the host could not fully enumerate the contributors. Recipients **MUST** evaluate a label with `complete: false` as though an additional contributor with `assurance: "unverified"` were present. + +### Host obligations + +- The host **MUST** compute `contributors` itself. It **MUST NOT** accept, copy, or merge contributor claims supplied by a server. +- The host **MUST** apply a conservative over-approximation: any principal whose content entered the context partition from which the request was built **MUST** be listed, whether or not the host can show that it influenced the specific arguments. Precise information-flow tracking through model reasoning is not achievable, so this specification requires soundness over precision. See *Rationale*. +- If the host cannot enumerate contributors for any reason, it **MUST** set `complete: false` rather than omitting the label or emitting a partial list as though it were complete. +- A host that has negotiated `flow.origin` **MUST** attach the label to every request it sends to a server that advertises `flow.policy`. + +#### Context partitions + +Scoping contributors to an entire session would make every contributor set maximal within a few turns, and every strict policy would refuse everything. Operators would switch the feature off, which is worse than not shipping it. Hosts therefore need a way to narrow the scope, and it needs a condition that keeps it sound. + +> A host **MAY** compute contributors over a partition of its context rather than over the whole session, if and only if no content originating outside that partition is present in the model context used to construct the request. + +The condition is structural and checkable rather than a matter of judgement. Sub-agents with freshly initialized contexts satisfy it. So does explicit compaction that discards the out-of-partition material outright. + +One trap must be stated plainly, because missing it defeats the entire mechanism: + +> Summarization does **not** clear a contributor. If a model reads content from a principal and then summarizes, paraphrases, translates, or compresses it, the result is still derived from that principal and the contributor **MUST** be retained. + +An implementation that treats a summary as clean has built a laundering step. Injected instructions survive summarization comfortably, and the summary is often the only thing that reaches the next turn. + +### The flow policy + +A server declares a policy at server scope in its capabilities, and **MAY** override it per tool in the tool declaration: + +```json +{ + "name": "export_records", + "description": "Export a table of customer records.", + "inputSchema": { "type": "object", "properties": { "table": { "type": "string" } } }, + "flowPolicy": { + "acceptFrom": ["self", "user"] + } +} +``` + +`acceptFrom` is an allowlist. Each entry is one of: + +- `self` — matches a contributor whose `principal` equals the recipient's own canonical server URI; +- one of the assurance states `user`, `host`, `verified`, `tofu`, `unverified` — matches a contributor with that assurance value; +- `publisher:` — matches a contributor whose publisher identity equals ``; +- an explicit canonical server URI — matches that principal exactly; +- a reverse-DNS prefixed token such as `com.example.tier:gold`, reserved for vendor extensions. + +The assurance states are a **closed** set and **MUST NOT** be extended, so that a third overlapping trust vocabulary does not emerge in the ecosystem. The identifier space, by contrast, is deliberately open. + +Extension there is safe by construction. Because an unrecognized token never matches, a token introduced later makes an older evaluator *more* restrictive than the policy author intended, never less. The failure mode of extension is a tool becoming uncallable from older hosts, which is loud and quickly diagnosed, rather than a policy silently widening, which is neither. + +A per-tool `flowPolicy` replaces the server default for that tool; the two are not merged. + +### Evaluation + +Given a policy and a label, evaluation proceeds as: + +1. If the label is absent, synthesize `{ contributors: [{ assurance: "unverified" }], complete: false }`. +2. If `complete` is `false`, append a synthetic contributor with `assurance: "unverified"`. +3. For each contributor, test it against every entry in `acceptFrom`. An entry the evaluator does not recognize **MUST NOT** match anything. +4. If every contributor matches at least one entry, the flow is **accepted**. Otherwise it is **rejected**, and the unmatched contributors are the violation set. + +Both peers evaluate, at different moments: + +- The host **SHOULD** evaluate before dispatch, and **MUST NOT** send a request it has determined violates the recipient's declared policy. This is where containment actually happens, because the request is never made. +- The recipient **SHOULD** evaluate on receipt, as defence in depth against a host that is buggy, outdated, or itself compromised. + +### The restriction-only invariant + +This is the central normative requirement of this SEP. + +> A recipient **MUST NOT** use `flowOrigin` to grant, widen, or unlock any capability, scope, authorization, or data access that it would otherwise withhold. A recipient **MAY** use `flowOrigin` only to refuse a request, to narrow a response, or to require additional authorization. + +Equivalently: the presence or content of an origin label may move a decision only in the restrictive direction. An implementation can enforce this structurally by computing its authorization decision first, without reference to the label, and then allowing flow evaluation only to downgrade the result. + +### Rejection + +A recipient that rejects a request on flow-policy grounds responds with a **JSON-RPC error**, not a tool-result error. The tool did not run and no side effect occurred, so reporting it as an execution failure would misdescribe what happened. + +This SEP deliberately does **not** mint a numeric error code. Parallel proposals each allocating codes out of the same reserved range is how collisions arise, and the number carries no information the payload does not already carry. Recipients **MUST** discriminate on the presence of the namespaced `data` key, and the numeric code **SHOULD** follow whatever general "not permitted" code emerges from [SEP-2145](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2145). + +The error payload: + +```json +{ + "message": "Request rejected by flow policy", + "data": { + "io.modelcontextprotocol/flowPolicyViolation": { + "rejected": [{ "assurance": "unverified" }], + "acceptFrom": ["self", "user"] + } + } +} +``` + +Returning the violation set to the **host** is safe. The recipient discloses nothing it was not already sent, and the host needs the detail to explain the refusal to an operator and to record it. + +Returning it to the **model** is not safe: + +> A host **MUST NOT** place the violation set, the `acceptFrom` list, or any equivalent explanation of why a flow was refused into model context. It **MAY** report only that the call was not permitted. + +A refusal that explains itself is an oracle. A model carrying an injected instruction, told which contributor triggered the block and which origins would have been accepted, can iterate against that feedback until it finds an ungated path. It is the same failure as a login form that distinguishes "no such user" from "wrong password", against an attacker that retries far faster than a human. + +### Privacy-preserving labels + +Listing principal URIs tells a server which other servers a user has connected, which is a topology disclosure the user may not intend. Hosts **MAY** therefore omit `principal` and send only `assurance`: + +```json +{ + "contributors": [{ "assurance": "user" }, { "assurance": "unverified" }], + "complete": true +} +``` + +Recipients **MUST** be able to evaluate a policy against assurance states alone. A policy using `self`, `publisher:` or explicit-URI entries will not match a contributor whose principal was withheld, which fails closed rather than open. + +## Rationale + +### Why restriction-only, rather than signing the label + +The alternative is to make labels authoritative through attestation, binding them to a signed request record along the lines of [SEP-2787](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2787) or [SEP-2828](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2828). That is a real and useful design, and it is complementary, but it is not what this SEP needs. + +If a label can only ever cause a refusal, forging it is pointless: an attacker who inflates trust achieves nothing, because a recipient is forbidden from granting anything on that basis, and an attacker who deflates trust only gets their own request denied. The security of the mechanism therefore does not rest on the integrity of the label at all. That is what lets this ship as a small additive change with no cryptographic dependency, and it is what keeps it compatible with the boundary the SEP-2817 discussion settled on. + +Where a recipient wants to *rely* on origin rather than merely restrict on it, for example to reduce friction for trusted flows or to write an authoritative audit record, it needs a signed assertion, and that belongs with the attestation work rather than here. + +### Why over-approximate contributors + +Tracking which specific bytes of a model's context influenced which specific token of a tool call is not solvable in general. Any attempt to be precise will under-report, and under-reporting is a silent security failure: the one contributor that gets dropped is exactly the injected one. + +Over-approximating, by listing everything that entered the partition, is sound but imprecise. The cost is false rejections, where a call is refused because an unrelated low-trust server happened to be read earlier. That is an availability cost, not a security one, and hosts reduce it by partitioning context rather than by guessing at influence. Choosing the failure mode that is loud rather than silent is deliberate. + +### Alternatives considered + +- **Host-only enforcement, no protocol change.** Rejected because the receiving server's risk appetite is not knowable by the host, and per-host rules do not compose across an open ecosystem. +- **A `rejectFrom` denylist alongside `acceptFrom`.** Rejected as a footgun. An allowlist is default-deny; a denylist silently admits anything the author did not think of. +- **Per-argument rather than per-request labels.** Rejected as premature. It multiplies wire size and implementation complexity for a precision the host cannot actually deliver, given the over-approximation above. +- **Carrying origin in a transport header rather than `_meta`.** Rejected because it would not survive stdio and would diverge from the established `_meta` extension pattern. +- **Minting a dedicated JSON-RPC error code.** Rejected. The namespaced `data` key is already an unambiguous discriminator, and several in-flight proposals allocating numbers from the same reserved range is a collision waiting to happen. +- **Reusing SEP-2817's `aiInvocation` block.** Rejected because that block is deliberately audit-only and client-asserted. Overloading it would erode a boundary its participants deliberately established. + +### Relationship to existing work + +- **[SEP-2817](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2817), AI Invocation Audit Context.** Complementary and deliberately disjoint, per the table in *Motivation*. Neither block is authorization evidence. A host implementing both sends `aiInvocation` for audit and `flowOrigin` for restriction. +- **[SEP-3140](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/3140), Signed Capability Declarations.** Supplies the verification whose result becomes the `verified` assurance state, along with the publisher identity that makes `publisher:` entries discriminating. This SEP degrades safely without it, reporting `unverified` for every server. Note also that a per-tool `flowPolicy` sits inside the tool declaration, so under SEP-3140 it is covered by the declaration's `contentHash` and a server cannot quietly widen its own policy after approval without triggering re-gating. +- **[SEP-1913](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/1913), Trust and Sensitivity Annotations.** A different axis, per the table in *Assurance states*. SEP-1913 labels what a **tool** will do; this labels who **data** came from. Neither is derivable from the other, and a host will commonly gate on both: a destructive tool called with data of unknown origin is a different proposition from the same tool called on the user's own input. +- **[SEP-2787](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2787) / [SEP-2828](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2828) / [SEP-3004](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/3004).** The attestation and audit family. `flowOrigin` is a natural field to carry into a signed decision or audit record, and SEP-3004's registered-extension mechanism is the right vehicle. +- **[SEP-2624](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2624) and the Interceptors WG.** Interceptors are the natural host-side enforcement point for the pre-dispatch check described above. +- **Security IG.** This addresses the discussion-agenda item "Tool identity across servers", which has been open without a champion since the group's charter. + +## Backward Compatibility + +Fully additive and capability-negotiated. + +- A client that does not send `flowOrigin` behaves exactly as today. Servers evaluating a policy treat the absent label as `unverified` and `complete: false`, so a server that adopts a strict policy before its clients adopt labelling will reject calls. Servers **SHOULD** therefore adopt a permissive default until labelling is widespread. +- A server that declares no `flowPolicy` behaves exactly as today, and hosts perform no pre-dispatch check for it. +- `flowPolicy` on a tool declaration and `flowOrigin` in `_meta` are both optional fields that older peers ignore. +- No existing method signature changes. + +## Security Implications + +- **Forged labels.** Neutralized by the restriction-only invariant. Inflating trust grants nothing; deflating it denies the forger's own call. This is the property that permits an unsigned label. +- **A compromised or malicious host.** Out of scope, and unsolvable at this layer. A host that lies about contributors is a host that could simply call the tool directly. Servers needing assurance against a hostile host require attestation, not labelling. +- **Under-reporting.** The genuine failure mode. If a host reports fewer contributors than really influenced a request, a policy check passes when it should not. The `complete` flag and the mandated over-approximation exist to make under-reporting an explicit, detectable choice rather than an accident. +- **Topology disclosure.** Principal URIs reveal which servers a user has connected. Mitigated by permitting assurance-only labels, which fail closed against URI-specific policy entries. +- **Refusal as an oracle.** A refusal that explains which contributor caused it, and what would have been accepted, is a bypass oracle. An injected instruction can iterate against that feedback until it finds an ungated route. This is why the violation set is host-facing only and **MUST NOT** reach model context: the structured data exists for policy engines, operators and audit records, not for the context window. +- **Laundering through summarization.** The most likely implementation mistake is treating a summary of low-assurance content as clean. The normative rule in *Context partitions* exists because this single shortcut would silently disable the whole mechanism. +- **False sense of coverage.** A flow policy constrains what a server accepts. It does not prevent the model from being manipulated, and it does not replace prompt-injection defences, approval gating, or per-tool authorization. It bounds the blast radius of a successful injection across a principal boundary, which is a narrower claim. +- **Denial of service through label inflation.** A low-assurance server that gets itself read early in a partition can cause later privileged calls to be refused. This is an availability consequence of over-approximation and is the intended trade. + +## Reference Implementation + +A runnable, dependency-free prototype accompanies this proposal at [`sep-flow-policy-poc/`](../sep-flow-policy-poc/README.md). + +```bash +node sep-flow-policy-poc/demo.mjs +node --test sep-flow-policy-poc/test/conformance.test.mjs +``` + +The prototype implements host-side contributor tracking, label construction, policy evaluation on both peers, and the restriction-only invariant as an enforced code path rather than a documented promise. The demo reproduces the two-server attack in full: a low-trust server returns content that steers the model into a high-trust server's export tool, and then attempts to exfiltrate the result back through the low-trust server. It runs the same scenario twice, once with flow policy disabled to show the current protocol's behaviour, and once with it enabled to show both legs refused. + +The conformance suite covers label normalization, fail-closed handling of absent and incomplete labels, unknown-value handling, `self` and `publisher:` matching, vendor-prefixed tokens on a host that does not understand them, host pre-dispatch blocking, server-side re-evaluation, `resources/read` gating, assurance-only privacy mode, the redaction of refusal detail before it could reach model context, and a property test asserting that no label can convert a denial into an approval. + +## Open Questions + +1. Should the assurance states be maintained by this SEP, or migrate into a shared vocabulary if the Security IG consolidates the three axes described under *Assurance states*? +2. Should vendor-prefixed `acceptFrom` tokens be registered anywhere, or left entirely to bilateral agreement between a server and the hosts that understand it? +3. Should a server be able to declare a flow policy per resource or per URI template, or only server-wide, for `resources/read`? + From 94a1e6c666050bd2cf1e583fd2ff4968cb08abbf Mon Sep 17 00:00:00 2001 From: Omkar Parkhe Date: Mon, 3 Aug 2026 19:13:03 +0530 Subject: [PATCH 2/2] Updating SEP # with PR # --- seps/0000-cross-server-data-origin-labels-and-flow-policy.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/seps/0000-cross-server-data-origin-labels-and-flow-policy.md b/seps/0000-cross-server-data-origin-labels-and-flow-policy.md index a8b4ecb0c..893c58236 100644 --- a/seps/0000-cross-server-data-origin-labels-and-flow-policy.md +++ b/seps/0000-cross-server-data-origin-labels-and-flow-policy.md @@ -1,11 +1,11 @@ -# SEP-0000: Cross-Server Data-Origin Labels and Flow Policy +# SEP-3193: Cross-Server Data-Origin Labels and Flow Policy - **Status**: Proposal - **Type**: Standards Track - **Created**: 2026-08-03 - **Author(s)**: Omkar Parkhe (@omkarparth) - **Sponsor**: None (seeking sponsor) -- **PR**: https://github.com/modelcontextprotocol/modelcontextprotocol/pull/0000 +- **PR**: https://github.com/modelcontextprotocol/modelcontextprotocol/pull/3193 ## Abstract