Skip to content
Merged
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
99 changes: 99 additions & 0 deletions docs/interactive-host-security.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ import {
createTrustedInteractiveHost,
createAgentReceiptStatement,
encodeAgentReceiptStatement,
normalizeHostAgentReceipt,
} from '@devcodex-labs/flowkit/interactive';
import {sign} from 'node:crypto';

Expand Down Expand Up @@ -143,6 +144,104 @@ artifact identity, pinned policy identity, proof ID and issuance/expiry times.
Only Ed25519 is accepted, with canonical base64url signatures. The default
maximum proof lifetime is five minutes, with five seconds of clock skew.

## Oversized real model output: Host → Coordinator contract (P0-A S07)

**Two different failures must never be confused.**

1. **Unverified or raw oversized receipt**: \`host.accept()\` calls
\`normalizeHostAgentReceipt()\` **before** signature/provenance verification.
The signed v1 receipt's canonical JSON limit is **2 MiB**, including
request metadata and the result envelope. A body above this limit returns
\`FK_I_PROVENANCE_REJECTED\` with **no Checkpoint, Journal or receipt-index
mutation**. The Run may still be \`waiting\`: rejecting unverified input
**must not** give a model the power to terminate another Run.
2. **Trusted Host has independently observed provider output overflow**:
the Host's own authenticated provider transport / streaming limiter records
that the output cannot be represented within the v1 receipt limit. The Host
sends **only a small, bounded, Ed25519-attested**
\`{kind:'agent-call-failed',failure:{message:'...'}}\` for the *current
authoritative Pending*. This is **an accepted failure receipt**, not an
accepted oversize result. Existing Coordinator rules retry at most
\`retries+1\` attempts; when exhausted, the Run durably becomes
\`errored(FK_R004)\`. The failure receipt receives a normal immutable
\`acceptedRevision\`, and its duplicates return the same Ack.
3. **Validly signed, bounded Agent result that overflows the *combined*
active Checkpoint budget**: the S07 Store capacity reserve applies.
Coordinator may durably terminate as \`errored(FK_I_CHECKPOINT_CAPACITY)\`
**without accepting that result**. This is a separate failure mode.

A trusted Host integration should handle the provider output *before* building
or signing a receipt. Minimal Host-side decision logic (illustrative;
\`trustedModelTransport\` must be a **Host-owned provider/session transport**,
not model JSON and not a public self-declared status field):

~~~js
const task = await host.getPublicAgentTask({runId});
// The transport/Host must enforce an explicit response-byte budget, record
// genuine provider/session outcome, and prevent unbounded buffering.
// The observation is made outside the model channel.
const observed = await trustedModelTransport(task);
const identity = {
runId:task.runId,requestId:task.requestId,
nodeId:task.nodeId,attempt:task.attempt,
};

const receipt = observed.kind === 'verified-provider-output-limit'
? {
...identity,
kind:'agent-call-failed',
failure:{
// Constant-size Host-authored message. NEVER append model output.
message:'FK_I_AGENT_OUTPUT_TOO_LARGE: provider transport limit exceeded',
},
}
: {
...identity,
kind:'agent-result',
result:observed.result,
};

// The trusted signer must independently confirm observation.sessionId,
// observation.requestId and exact provider result before signing.
// Validate receipt size/shape with normalizeHostAgentReceipt(receipt).
// Do not convert ANY arbitrary normalization/provenance error to a failure.
const normalized = normalizeHostAgentReceipt(receipt);
const now = Date.now();
const statement = createAgentReceiptStatement({
run:store.load(task.runId),receipt:normalized,
keyId:'transport-key-v1',proofId:observed.verifiedEventId,
issuedAt:now,expiresAt:now+60_000,
});
const signature = sign(
null,encodeAgentReceiptStatement(statement),privateKey,
).toString('base64url');
const frame = await host.accept({
receipt:normalized,attestation:{statement,signature},
});
~~~

**Strict Host obligations:** the verified size event is bound to the exact
current Run/request/attempt and is observed through a trusted transport, not
invented by model text. Sign only after independently checking the provider
origin and event identity. If the Host cannot authenticate the size
observation, it **must not** forge a signed failure or make a state change; it
should retain the Pending and reconcile the provider request using its own
delivery/idempotency policy. A safe Host should configure streaming/body-size
limits up front, rather than first buffering arbitrarily large responses in
memory. Never treat \`FK_I_PROVENANCE_REJECTED\` as proof of oversize; that
generic code also covers forged, malformed and unsupported receipts.

The Host may store a large *business* output using its own configured storage
and return a **small reference** instead; FlowKit doesn't choose the storage
provider, manage the file, or expire it. The reference must itself satisfy the
declared Agent result schema and the regular disclosure/integrity rules.

**Scope of evidence:** \`tests/interactive/host-oversize.test.mjs\` exercises
the real compiler, Program, SQLite, Host Ed25519 attestation, Coordinator CAS,
bounded retry/terminal and historical Ack with a **scripted trusted transport
observation**. This does **not** prove that Codex/Claude/Grok's actual
provider/session origin is verified. M6 S13 remains a separate gate.

## Rejection and recovery

- **Unsigned/forged/replayed-across-run/expired/wrong-key/wrong-policy/wrong-
Expand Down
2 changes: 1 addition & 1 deletion docs/interactive-s07-capacity.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ Existing `CheckpointStore` adapters without the optional `getReceipt` capability

- No file I/O API or Context integration. The Host chooses where business outputs and temporary files live and when they expire.
- No automatic pruning of the SQLite receipt index, Journal or Program bytes. Run retention and schema migration are separate future work; an audit index must not disappear while an accepted Run may be queried.
- The M6 near-2MiB adversarial seed must now fail **before** creating an unacceptably large waiting Checkpoint, while the original valid 64-KiB Agent result proceeds from the prior safe Checkpoint. Oversized inline results must end in durable `errored`, preserving State, journal integrity and immutable Program identity.
- The M6 near-2MiB adversarial seed must now fail **before** creating an unacceptably large waiting Checkpoint, while the original valid 64-KiB Agent result proceeds from the prior safe Checkpoint. A **properly authenticated, bounded result** that would exceed the active Checkpoint budget causes durable `errored(FK_I_CHECKPOINT_CAPACITY)` without an Ack. An **oversized raw receipt (>2 MiB)** cannot be authenticated under v1 and is rejected **without any Run mutation**; the trusted Host must independently observe the provider's output-size failure and sign a **bounded `agent-call-failed` receipt** to invoke normal retry/exhaustion (`FK_R004`) semantics. See [Host oversized result contract](./interactive-host-security.md#oversized-real-model-output-host--coordinator-contract-p0-a-s07).
- Stage 1 cannot claim S13 real Codex/Claude/Grok model/tool integration or mark M6 complete.

The change keeps the Program's routing authority, the 2 MiB hard integrity limit, namespace and artifact pins, and the existing Standard API untouched.
217 changes: 217 additions & 0 deletions tests/interactive/host-oversize.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
/**
* P0-A S07: trusted Host transport oversize outcomes.
* The mocked transport observation below is deterministic test evidence,
* NOT an authenticated real-model provider session (S13 remains blocked).
* An oversized unverified model response never gains Coordinator authority.
*/
import test,{after} from 'node:test';
import assert from 'node:assert/strict';
import {generateKeyPairSync,sign} from 'node:crypto';
import {mkdtemp,rm} from 'node:fs/promises';
import path from 'node:path';
import {tmpdir} from 'node:os';
import {analyzeSource} from '../../src/compiler/analyze.mjs';
import {lowerInteractive} from '../../src/compiler/interactive/lower.mjs';
import {emitInteractive} from '../../src/compiler/interactive/emit.mjs';
import {createSqliteCheckpointStore} from '../../src/interactive/sqlite-store.mjs';
import {createTrustedInteractiveHost} from '../../src/interactive/trusted-host.mjs';
import {canonicalBytes} from '../../src/interactive/canonical.mjs';
import {
createAgentReceiptStatement,encodeAgentReceiptStatement,
MAX_AGENT_RECEIPT_BYTES,
} from '../../src/interactive/host-attestation.mjs';

const roots=[];
after(async()=>{for(const root of roots)await rm(root,{recursive:true,force:true})});
const NOW=1794400000000;
const runtime={
skillResolver:async()=> 'Return a small structured answer or a data reference',
validateResult:(_schema,result)=>typeof result?.answer==='string'
? []:['answer must be a string'],
};

async function fixture(t,retries=1){
const root=await mkdtemp(path.join(tmpdir(),'flowkit-host-oversize-'));
roots.push(root);
const filename=path.join(root,'run.sqlite');
const definition=[
"import {defineWorkflow,node,terminal} from '@devcodex-labs/flowkit/dsl';",
"export default defineWorkflow({id:'host-oversize',version:'1.0.0',name:'Host Oversize',nodes:[",
"node('worker',{name:'Worker',agent:'writer',inputs:[],",
"output:{type:'object',properties:{answer:{type:'string'}}},",
"retries:"+retries+",next:'done'}),",
"terminal('done',{name:'Done',status:'succeeded'})]});",
].join('\n');
const analysis=await analyzeSource(definition,{
filename:'host-oversize.workflow.js',projectRoot:root,skillCheck:false,
});
assert.equal(analysis.diagnostics.some(d=>d.severity==='error'),false,
JSON.stringify(analysis.diagnostics));
const lower=lowerInteractive(analysis);
assert.ok(lower.ir,JSON.stringify(lower.diagnostics));
const compiled=emitInteractive(lower.ir);
assert.ok(compiled.code,JSON.stringify(compiled.diagnostics));
const program={
programBytes:Buffer.from(compiled.code),
programIdentity:compiled.programIdentity,
};
const keys=generateKeyPairSync('ed25519');
const store=createSqliteCheckpointStore(filename,{namespace:'trusted-oversize'});
t.after(()=>store.close());
const options={
store,runtime,policy:{contract:'host-oversize-v1'},
trustedPublicKeys:{transport:keys.publicKey},
trustedArtifacts:[program.programIdentity.artifactHash],
allowedInputKeys:[],disclosurePolicyId:'host-private-disclosure-v1',
authorizeDisclosure:async()=>true,now:()=>NOW,
};
const host=createTrustedInteractiveHost(options);
const runId='oversize-run-'+retries;
const started=await host.start({...program,runId,startKey:runId,
state:{approved:true,privateField:'not sent to model'}});
assert.equal(started.status,'waiting');
const task=await host.getPublicAgentTask({runId});
assert.ok(task);
assert.equal(Object.hasOwn(task,'privateField'),false);
return {root,filename,store,keys,options,host,task,started};
}

function makeReceipt(task,kind,payload){
return {
runId:task.runId,requestId:task.requestId,nodeId:task.nodeId,
attempt:task.attempt,kind,
...(kind==='agent-result'?{result:payload}:{failure:{message:payload}}),
};
}

function attestation(store,receipt,privateKey,proofId){
const statement=createAgentReceiptStatement({
run:store.load(receipt.runId),
receipt,keyId:'transport',proofId,issuedAt:NOW-1000,expiresAt:NOW+10000,
});
return {
statement,signature:sign(null,encodeAgentReceiptStatement(statement),privateKey)
.toString('base64url'),
};
}

function unchanged(f,cp,journal){
assert.deepEqual(canonicalBytes(f.store.load(f.task.runId)),cp);
assert.deepEqual(f.store.listJournal(f.task.runId),journal);
}

test('S07 oversized raw model receipt cannot terminate the Run without authenticated transport evidence',async t=>{
const f=await fixture(t);
const before=canonicalBytes(f.store.load(f.task.runId));
const journal=f.store.listJournal(f.task.runId);
const huge=makeReceipt(f.task,'agent-result',{answer:'x'.repeat(MAX_AGENT_RECEIPT_BYTES+1024)});
await assert.rejects(f.host.accept({
receipt:huge,attestation:{statement:{},signature:'model-claimed-proof'},
}),{code:'FK_I_PROVENANCE_REJECTED'});
// The legacy signer API correctly refuses to manufacture proof for bytes
// that are not representable in the signed v1 receipt contract.
assert.throws(()=>createAgentReceiptStatement({
run:f.store.load(f.task.runId),receipt:huge,keyId:'transport',
proofId:'invalid-large',issuedAt:NOW-1000,expiresAt:NOW+10000,
}),{code:'FK_I_PROVENANCE_REJECTED'});
unchanged(f,before,journal);
assert.equal((await f.host.load({runId:f.task.runId})).status,'waiting');
assert.equal(f.store.getReceipt(f.task.runId,f.task.requestId),null);

// A malicious model also cannot turn its unsupported output into an
// authenticated failure by adding a small, fabricated failure body.
const pretendFailure=makeReceipt(f.task,'agent-call-failed',
'Model claims its own output exceeded a capacity bound');
await assert.rejects(f.host.accept({
receipt:pretendFailure,
attestation:{statement:{kind:'agent-call-failed'},signature:'fake'},
}),{code:'FK_I_PROVENANCE_REJECTED'});
unchanged(f,before,journal);
});

test('S07 trusted transport size failure signs an existing bounded failure receipt, retries once, then accepts a small reference',async t=>{
const f=await fixture(t,1);
const hugeRaw=makeReceipt(f.task,'agent-result',{
answer:'model-output'.repeat(200000),
});
assert.ok(hugeRaw.result.answer.length>MAX_AGENT_RECEIPT_BYTES);
const cp=canonicalBytes(f.store.load(f.task.runId));
await assert.rejects(f.host.accept({
receipt:hugeRaw,attestation:null,
}),{code:'FK_I_PROVENANCE_REJECTED'});
assert.deepEqual(canonicalBytes(f.store.load(f.task.runId)),cp);

// Simulated *trusted transport* observation, NOT model-authored instructions.
// The Host independently observed excess bytes from the bound provider call,
// so it deliberately creates a tiny protocol failure rather than signing
// an oversized result. This changes no FlowKit SDK or execution ABI.
const observedTransportEvent={
kind:'provider-output-size-limit',
sessionId:'host-verified-session-001',
requestId:f.task.requestId,
observedBytes:Buffer.byteLength(hugeRaw.result.answer,'utf8'),
};
assert.equal(observedTransportEvent.kind,'provider-output-size-limit');
assert.equal(observedTransportEvent.requestId,f.task.requestId);
assert.ok(observedTransportEvent.observedBytes>MAX_AGENT_RECEIPT_BYTES);
const failure=makeReceipt(f.task,'agent-call-failed',
'FK_I_AGENT_OUTPUT_TOO_LARGE: trusted provider response exceeded 2 MiB');
const signedFailure=attestation(f.store,failure,f.keys.privateKey,'observed-provider-limit-1');
const afterFailure=await f.host.accept({receipt:failure,attestation:signedFailure});
assert.equal(afterFailure.status,'waiting');
assert.equal(afterFailure.pending.attempt,2);
assert.notEqual(afterFailure.pending.requestId,f.task.requestId);
assert.deepEqual(f.store.load(f.task.runId).state,{
approved:true,privateField:'not sent to model',
});
assert.equal(f.store.getReceipt(f.task.runId,f.task.requestId).acceptedRevision,
afterFailure.acceptedRevision);
assert.equal(f.store.listJournal(f.task.runId).length,afterFailure.revision+1);
assert.equal(JSON.stringify(f.store.listJournal(f.task.runId))
.includes(hugeRaw.result.answer.slice(0,1000)),false);

// A lost response to the first signed failure gets its ORIGINAL Ack and
// cannot create a third attempt.
const duplicate=await f.host.accept({receipt:failure,attestation:signedFailure});
assert.equal(duplicate.duplicate,true);
assert.equal(duplicate.acceptedRevision,afterFailure.acceptedRevision);
assert.equal(duplicate.pending.requestId,afterFailure.pending.requestId);

// Recover through a new trusted Host object with same pinned policy, key,
// Program and SQLite Store; no original output bytes are needed.
const reopened=createTrustedInteractiveHost(f.options);
const second=await reopened.getPublicAgentTask({runId:f.task.runId});
assert.equal(second.attempt,2);
const small=makeReceipt(second,'agent-result',{answer:'context-ref:result-001'});
const signed=attestation(f.store,small,f.keys.privateKey,'observed-provider-result-2');
const completed=await reopened.accept({receipt:small,attestation:signed});
assert.equal(completed.status,'succeeded');
assert.equal(completed.state.answer,'context-ref:result-001');
assert.equal(completed.state.privateField,'not sent to model');
assert.equal(completed.revision,f.store.listJournal(f.task.runId).length-1);
const old=await reopened.accept({receipt:failure,attestation:signedFailure});
assert.equal(old.duplicate,true);
assert.equal(old.acceptedRevision,afterFailure.acceptedRevision);
assert.equal(old.status,'succeeded');
});

test('S07 trusted transport oversize failure with no retries exits FK_R004 durably, not by unverified payload mutation',async t=>{
const f=await fixture(t,0);
const failure=makeReceipt(f.task,'agent-call-failed',
'FK_I_AGENT_OUTPUT_TOO_LARGE: Host observed response-size limit');
const signed=attestation(f.store,failure,f.keys.privateKey,'observed-provider-limit-0');
const ended=await f.host.accept({receipt:failure,attestation:signed});
assert.equal(ended.status,'errored');
assert.equal(ended.terminal.causeCode,'FK_R004');
assert.deepEqual(ended.state,{
approved:true,privateField:'not sent to model',
});
assert.equal(f.store.load(f.task.runId).pending,null);
assert.equal(f.store.getReceipt(f.task.runId,f.task.requestId).acceptedRevision,
ended.acceptedRevision);
assert.equal(f.store.listJournal(f.task.runId).length,ended.revision+1);
assert.equal((await f.host.resume({runId:f.task.runId})).revision,ended.revision);
const old=await f.host.accept({receipt:failure,attestation:signed});
assert.equal(old.duplicate,true);
assert.equal(old.acceptedRevision,ended.acceptedRevision);
});