Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions sep-flow-policy-poc/README.md
Original file line number Diff line number Diff line change
@@ -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=<secret>` 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.
114 changes: 114 additions & 0 deletions sep-flow-policy-poc/demo.mjs
Original file line number Diff line number Diff line change
@@ -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');
154 changes: 154 additions & 0 deletions sep-flow-policy-poc/host.mjs
Original file line number Diff line number Diff line change
@@ -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));
}
}
Loading
Loading