From 424db4ffac4c013ecb5d7fbd466cd87d92b1e8a4 Mon Sep 17 00:00:00 2001 From: rocky Date: Wed, 11 Nov 2026 11:24:53 +0900 Subject: [PATCH 1/4] test(interactive): authenticated Host oversize result failure and bounded retry/terminal; reject forged raw overflow --- tests/interactive/host-oversize.test.mjs | 217 +++++++++++++++++++++++ 1 file changed, 217 insertions(+) create mode 100644 tests/interactive/host-oversize.test.mjs diff --git a/tests/interactive/host-oversize.test.mjs b/tests/interactive/host-oversize.test.mjs new file mode 100644 index 0000000..b25f1b3 --- /dev/null +++ b/tests/interactive/host-oversize.test.mjs @@ -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); +}); From e448aae5e45162ae43fbd8c4b8ff25a60fa22e00 Mon Sep 17 00:00:00 2001 From: rocky Date: Wed, 11 Nov 2026 11:26:05 +0900 Subject: [PATCH 2/4] docs(interactive): define trusted transport overflow proof, signed failure receipt and bounded retry semantics --- docs/interactive-host-security.md | 98 +++++++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) diff --git a/docs/interactive-host-security.md b/docs/interactive-host-security.md index eab3fc8..a821b37 100644 --- a/docs/interactive-host-security.md +++ b/docs/interactive-host-security.md @@ -143,6 +143,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- From 8fd64b715af81e6ef5e6b4a7daa04b15cd6fe071 Mon Sep 17 00:00:00 2001 From: rocky Date: Wed, 11 Nov 2026 11:26:31 +0900 Subject: [PATCH 3/4] docs(interactive): import receipt normalizer used in bounded Host transport example --- docs/interactive-host-security.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/interactive-host-security.md b/docs/interactive-host-security.md index a821b37..aee1bd5 100644 --- a/docs/interactive-host-security.md +++ b/docs/interactive-host-security.md @@ -68,6 +68,7 @@ import { createTrustedInteractiveHost, createAgentReceiptStatement, encodeAgentReceiptStatement, + normalizeHostAgentReceipt, } from '@devcodex-labs/flowkit/interactive'; import {sign} from 'node:crypto'; From 70394d4e981ae0eaae50e1314c951818830d7a9c Mon Sep 17 00:00:00 2001 From: rocky Date: Wed, 11 Nov 2026 11:26:47 +0900 Subject: [PATCH 4/4] docs(interactive): distinguish unverified oversized receipt from authenticated bounded result capacity exit --- docs/interactive-s07-capacity.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/interactive-s07-capacity.md b/docs/interactive-s07-capacity.md index 91c6606..3b5f987 100644 --- a/docs/interactive-s07-capacity.md +++ b/docs/interactive-s07-capacity.md @@ -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.