diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5dae377..8c892d1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,6 +63,24 @@ jobs: if-no-files-found: ignore retention-days: 14 + host-security-isolation: + name: Host OS-user isolation / Node ${{ matrix.node }} + runs-on: ubuntu-24.04 + timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + node: ['22.13.0', '24.22.1'] + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + persist-credentials: false + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: ${{ matrix.node }} + - name: Prove cross-UID model worker cannot read Host key or SQLite DB + run: node tests/security/host-isolation.mjs + website: runs-on: ubuntu-24.04 timeout-minutes: 15 diff --git a/docs/interactive-host-security.md b/docs/interactive-host-security.md new file mode 100644 index 0000000..eab3fc8 --- /dev/null +++ b/docs/interactive-host-security.md @@ -0,0 +1,179 @@ +# FlowKit Interactive P0-A — M5 trusted Host boundary + +This is a **Host-owned security adapter** for the P0-A Agent workflow. It does +not create a sandbox, grant a model access to Host credentials, implement Human +approval, or change Standard's six root SDK functions. + +## Trust topology + +~~~text +PRIVATE TRUSTED HOST PROCESS / OS USER + - approved compiler artifacts (hash allowlist from trusted CI) + - pinned Ed25519 public keys and approved policy + - locked-down SQLite Checkpoint Store + - private Agent transport evidence (outside the model channel) + - Host signer / KMS / isolated key material (outside the model channel) + - createTrustedInteractiveHost(...) + | + | getPublicAgentTask() after explicit input allowlist + Host disclosure approval + v +UNTRUSTED MODEL PROCESS / OS USER + - receives only the public Agent Task (no Store, private key, full State or Coordinator) + - produces model result data; CANNOT sign trusted Host evidence + | + | model result to a separate, trusted provider/Host transport + v +TRUSTED HOST / TRANSPORT + - validates observed result and constructs signed receipt evidence + - accept({receipt,attestation}) verifies Ed25519 before any receipt CAS + | + v +P0-A Coordinator -> SQLite CAS -> accepted receipt -> local validation -> commit +~~~ + +**Hard requirement:** the model process must not share the Host UID, signing +key, private database path, or unrestricted filesystem/tool permissions. +A public JavaScript factory is **not** a security boundary by itself. +Any model with direct access to the Host-owned SQLite Store, private signing +material or raw Coordinator can bypass this adapter. In that environment, +secure Host approval is **BLOCKED** until isolation is available. + +The signed record is for **Agent results only**. The P0-A graph still rejects +Human, Action, Verification, Loop, Parallel, Agent verify and external +checkStep; constructing a string `provenanceRef` or JSON +`{kind:"human",approved:true}` grants no approval. + +## Host provisioning + +1. Compile using the trusted FlowKit compiler and approve the **exact** + `artifactHash` in a private build/deployment manifest. A SHA-256 hash only + proves identity, **not compiler authenticity**; never automatically add + model-supplied hashes to the allowlist. +2. Generate an Ed25519 key pair in the **Host/transport trust domain**. + Store the private key in a restricted OS account, secret manager or KMS, not + in a model-visible folder, prompt, shell argument, environment, or callback. + Distribute only the public key to the Host verifier. +3. Configure an explicit `allowedInputKeys` allowlist and an affirmative + `authorizeDisclosure` callback. The default allowlist is empty, so any + undeclared Agent input blocks publication to the model. +4. Place the SQLite Store in a Host-only directory; keep WAL mode and FULL + synchronization enabled and do not give model tools access to its files. +5. Use separate OS users/process isolation for the untrusted model worker. + A shared-UID shell agent must not be treated as a trusted approval origin. + +## Minimal Host-side integration + +~~~js +import { + createTrustedInteractiveHost, + createAgentReceiptStatement, + encodeAgentReceiptStatement, +} from '@devcodex-labs/flowkit/interactive'; +import {sign} from 'node:crypto'; + +// All variables below belong exclusively to the trusted Host. +// store, runtime and compiled Program are created in the Host process. +// trustedPublicKey comes from the Host's secure key registry. +// privateKey is illustrative; in production use a protected signer/KMS. +const host = createTrustedInteractiveHost({ + store, + runtime: { + skillResolver: trustedSkillRead, + validateResult: pureSynchronousValidator, + }, + policy: {deploymentId:'approved-v1'}, + trustedArtifacts: [approvedProgramIdentity.artifactHash], + trustedPublicKeys: {'transport-key-v1': trustedPublicKey}, + allowedInputKeys: ['documentId','summary'], + disclosurePolicyId: 'model-input-allowlist-v1', + authorizeDisclosure: async ({task}) => { + // Must be a genuine, Host-owned authorization source, not the model. + return trustedDlpAllows(task); + }, +}); + +const frame = await host.start({ + programBytes: approvedProgramBytes, + programIdentity: approvedProgramIdentity, + startKey: 'trusted-run-1', + state: {documentId:'doc-123',summary:'safe-to-share',privateKey:'NOT_DISCLOSED'}, +}); + +const task = await host.getPublicAgentTask({runId:frame.runId}); +// The model sees task ONLY, never frame, store, privateKey or the Host process. +const result = await trustedModelTransport(task); // host-observed result +const receipt = { + runId: task.runId, + requestId: task.requestId, + nodeId: task.nodeId, + attempt: task.attempt, + kind: 'agent-result', + result, +}; +const now = Date.now(); +const statement = createAgentReceiptStatement({ + run: store.load(task.runId), + receipt, + keyId: 'transport-key-v1', + proofId: trustedEventIdFromTransport, + issuedAt: now, + expiresAt: now + 60_000, +}); +// Sign inside the separate Host/transport trust domain. +// NEVER send privateKey or this signing capability to a model process. +const signature = sign( + null, encodeAgentReceiptStatement(statement), privateKey, +).toString('base64url'); +const verified = await host.accept({ + receipt, attestation:{statement,signature}, +}); +console.log(verified.status); // waiting / paused / succeeded / failed / errored +~~~ + +In this example, `trustedModelTransport` and `trustedDlpAllows` are +**Host-provided integrations**, not FlowKit-owned network clients or +model-controlled callbacks. A model-submitted `receipt` does not become +trusted just because the Host signed it: the Host must independently verify +the actual provider/session origin before issuing the signature. + +The signed statement uses canonical JSON bytes, versioned domain +`flowkit.host-agent-receipt.v1`, and binds the exact namespace, run ID, +request ID, node ID, attempt, submission kind, canonical result digest, +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. + +## Rejection and recovery + +- **Unsigned/forged/replayed-across-run/expired/wrong-key/wrong-policy/wrong- + artifact** proof: reject with `FK_I_PROVENANCE_REJECTED` before CAS. No + checkpoint or Journal mutation occurs. +- **Disallowed input or missing DLP approval**: reject + `FK_I_HOST_DENIED` without releasing a task or changing the run. +- **Same signed receipt retransmitted**: after a successful acceptance, the + Coordinator returns the original `acceptedRevision` and current run view. + A cryptographically valid, previously accepted, byte-identical receipt may + re-ack even after proof expiry; this never authorizes a new receipt. + Different content for the same request is rejected. +- **Lost response or Host crash**: reopen the same Host-owned Store with the + same trust policy and approved key/Program allowlists, then `resume` or + resend the signed original receipt; the historical Ack rule above applies. + Do not sign a newly invented model result to evade expiry. +- **Key rotation or artifact/allowlist change** changes the policy hash for + new Host instances and blocks existing runs. Keep the original policy + accessible until the run drains, or design an explicit, audited migration. +- **Missing trustworthy OS separation**: do not call the environment secure. + Quarantine the Host-only endpoints or mark the real-host security gate + **BLOCKED**; never substitute a model-claimed `provenanceRef`. + +## Validation evidence and limits + +The P0-A CI uses Ubuntu/Windows × Node 22/24 for `npm run verify`, +includes tampered-signature, wrong-tenant, wrong-request, expired, malicious +Program and disclosure-negative cases, and adds a **separate Ubuntu +Node 22/24 OS isolation job**. That job launches a real unprivileged +`nobody` Node process: it must read a public task but receive access-denied +on the Host's private Ed25519 key and SQLite database. It fails if privilege +separation cannot be established. This does **not** prove Windows ACL policy, +Codex/Claude/Grok product integration, real provider billing idempotence or a +native Human approval UI; those require a separately validated Host runtime. diff --git a/docs/interactive.md b/docs/interactive.md index b344340..434c960 100644 --- a/docs/interactive.md +++ b/docs/interactive.md @@ -1,4 +1,4 @@ -# FlowKit Interactive — P0-A M4 Host Coordinator +# FlowKit Interactive — P0-A M4 Coordinator / M5 Trusted Host > **Internal/maintainer integration only.** The Standard root API remains exactly > `run / compile / compileSource / check / evalWhen / isControlSignal`. @@ -11,11 +11,15 @@ - **Not implemented in P0-A**: Human, Action, Verification, Loop, Parallel, Agent verify or external checkStep, automatic `runAgent` invocation, trusted Human provenance bridge, background service, P0-B/P1/P2 extensions. - The Coordinator and the Store are **owned and invoked by the Host**, not by the model. A model must not have direct filesystem/database access or direct `complete()` authority. -## Example: compile, start, resolve pending, resume +## Low-level Host-only example: compile, start, resolve pending, resume The source is a validated `*.workflow.js` or `*.workflow.ts` using FlowKit's existing DSL; it is **not imported or executed** while compiling. +**Do not expose this low-level Coordinator example to the model.** For an +actual model/Host boundary, use the M5 signed-provenance and public-task adapter +described in [Trusted Host security](./interactive-host-security.md). + ~~~js import {readFile} from 'node:fs/promises'; import { @@ -120,8 +124,11 @@ try { - Agent input contains only declared input keys plus a `missing` list; absent keys are not synthesized as null. The Host still owns disclosure rules for confidential state and instructions. -- `complete()` is **not a model-exposed tool**. M5 must enforce actual Host - provenance and identity outside the model-controlled process. +- The low-level `complete()` method remains **Host-only**. M5 adds + `createTrustedInteractiveHost`, Ed25519 receipt attestations and explicit + public-task disclosure checks. This protects only a Host separated from + model-controlled filesystem/database/credential access; see + [Trusted Host security](./interactive-host-security.md). - Store data is written with `node:sqlite` in WAL mode and `synchronous=FULL`, with every transition in one SQLite transaction. Do not place this reference Store on an unreliable/shared filesystem. diff --git a/scripts/package-files.mjs b/scripts/package-files.mjs index f33268a..c02a31f 100644 --- a/scripts/package-files.mjs +++ b/scripts/package-files.mjs @@ -10,7 +10,7 @@ export const packageFiles = [ ...['source', 'extract', 'validate', 'skill-check', 'analyze', 'graph-algorithms', 'graph', 'dataflow', 'values', 'paths', 'mermaid', 'emit-plan', 'helpers', 'emit', 'compile', 'api', 'output'].map(name => [`src/compiler/${name}.mjs`, `dist/compiler/${name}.mjs`]), ...['feature-gate', 'lower', 'validate-ir'].map(name => [`src/compiler/interactive/${name}.mjs`, `dist/compiler/interactive/${name}.mjs`]), ['src/compiler/interactive/emit.mjs', 'dist/compiler/interactive/emit.mjs'], - ...['compile', 'identity', 'load', 'canonical', 'sqlite-store', 'coordinator', 'index'].map(name => [`src/interactive/${name}.mjs`, `dist/interactive/${name}.mjs`]), + ...['compile', 'identity', 'load', 'canonical', 'sqlite-store', 'coordinator', 'index', 'host-attestation', 'trusted-host'].map(name => [`src/interactive/${name}.mjs`, `dist/interactive/${name}.mjs`]), ...['parser', 'evaluate'].map(name => [`src/when/${name}.mjs`, `dist/when/${name}.mjs`]), ...['context', 'execute', 'load', 'source', 'skills', 'skill-remote', 'run', 'aot', 'dump', 'dump-channel', 'dump-hook'].map(name => [`src/runtime/${name}.mjs`, `dist/runtime/${name}.mjs`]), ...['dsl', 'index', 'compiler-api', 'interactive'].map(name => [`types/${name}.d.ts`, `dist/${name}.d.ts`]), diff --git a/src/interactive/host-attestation.mjs b/src/interactive/host-attestation.mjs new file mode 100644 index 0000000..edc9058 --- /dev/null +++ b/src/interactive/host-attestation.mjs @@ -0,0 +1,137 @@ +import {canonicalBytes, safeJSONSnapshot, sha256Bytes, assertSHA256} from './canonical.mjs'; + +export const HOST_AGENT_ATTESTATION_DOMAIN = 'flowkit.host-agent-receipt.v1'; +export const MAX_AGENT_RECEIPT_BYTES = 2 * 1024 * 1024; +const SIGNED_FIELDS = Object.freeze([ + 'domain','keyId','proofId','namespace','runId','requestId','nodeId', + 'attempt','kind','artifactHash','policyHash','receiptHash','issuedAt','expiresAt', +]); + +function rejected(reason) { + // Do not echo untrusted body, task instructions, or signed secrets into errors. + return Object.assign(new Error('FK_I_PROVENANCE_REJECTED: ' + reason), { + code:'FK_I_PROVENANCE_REJECTED', + }); +} +function strictRecord(value, fields, required=fields) { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw rejected('invalid object'); + } + const keys=Object.keys(value); + if (keys.some(k=>!fields.includes(k)) || required.some(k=>!Object.hasOwn(value,k))) { + throw rejected('invalid fields'); + } +} +function identifier(value, name, limit=256) { + if (typeof value !== 'string' || value.length<1 || value.length>limit + || /[\u0000-\u001f\u007f]/.test(value)) { + throw rejected('invalid '+name); + } +} +function boundedTime(value) { + return Number.isSafeInteger(value) && value>0; +} + +/** + * A receipt is pure JSON data from a trusted Host transport. It never contains + * a caller-supplied cursor, transition, approval, provenance or policy override. + */ +export function normalizeHostAgentReceipt(supplied) { + let data; + try { data=safeJSONSnapshot(supplied,MAX_AGENT_RECEIPT_BYTES); } + catch { throw rejected('invalid receipt JSON'); } + strictRecord(data,['runId','requestId','nodeId','attempt','kind','result','failure'], + ['runId','requestId','nodeId','attempt','kind']); + identifier(data.runId,'runId'); + identifier(data.requestId,'requestId'); + identifier(data.nodeId,'nodeId'); + if (!Number.isSafeInteger(data.attempt) || data.attempt<1 || data.attempt>11) { + throw rejected('invalid Agent attempt'); + } + if (data.kind === 'agent-result') { + if (!Object.hasOwn(data,'result') || Object.hasOwn(data,'failure')) { + throw rejected('invalid Agent result fields'); + } + } else if (data.kind === 'agent-call-failed') { + if (Object.hasOwn(data,'result') || !Object.hasOwn(data,'failure')) { + throw rejected('invalid Agent failure fields'); + } + strictRecord(data.failure,['message']); + if (typeof data.failure.message !== 'string' || !data.failure.message.length + || data.failure.message.length>4096) { + throw rejected('invalid Agent failure message'); + } + } else { + // Includes Human, Action and Verification. P0-A never accepts them. + throw rejected('unsupported receipt kind'); + } + return data; +} + +/** + * Pure, signing-key-free canonical attestation statement builder. + * A trusted Host may sign encodeAgentReceiptStatement(statement) with its + * PRIVATE Ed25519 key, held outside model-visible filesystem/tool channels. + */ +export function createAgentReceiptStatement({ + run, receipt, keyId, proofId, issuedAt, expiresAt, +}={}) { + const normalized=normalizeHostAgentReceipt(receipt); + if (!run || typeof run !== 'object' || Array.isArray(run)) { + throw rejected('missing authoritative run'); + } + identifier(run.namespace,'namespace'); + assertSHA256(run.artifactHash); + assertSHA256(run.policyHash); + identifier(keyId,'keyId',64); + identifier(proofId,'proofId',128); + if (!/^[a-zA-Z0-9_.:-]+$/.test(keyId) + || !/^[a-zA-Z0-9_.:-]+$/.test(proofId)) { + throw rejected('invalid proof identifier'); + } + if (!boundedTime(issuedAt) || !boundedTime(expiresAt) || expiresAt<=issuedAt) { + throw rejected('invalid proof time range'); + } + return Object.freeze({ + domain: HOST_AGENT_ATTESTATION_DOMAIN, + keyId,proofId,namespace:run.namespace, + runId:normalized.runId,requestId:normalized.requestId, + nodeId:normalized.nodeId,attempt:normalized.attempt,kind:normalized.kind, + artifactHash:run.artifactHash,policyHash:run.policyHash, + receiptHash:sha256Bytes(canonicalBytes(normalized,MAX_AGENT_RECEIPT_BYTES)), + issuedAt,expiresAt, + }); +} + +/** Canonical bytes for both Host signing and Host verifier; no JSON.stringify drift. */ +export function encodeAgentReceiptStatement(input) { + let statement; + try { statement=safeJSONSnapshot(input,4096); } + catch { throw rejected('invalid statement JSON'); } + strictRecord(statement,SIGNED_FIELDS); + if (statement.domain!==HOST_AGENT_ATTESTATION_DOMAIN) { + throw rejected('incorrect proof domain'); + } + identifier(statement.keyId,'keyId',64); + identifier(statement.proofId,'proofId',128); + if (!/^[a-zA-Z0-9_.:-]+$/.test(statement.keyId) + || !/^[a-zA-Z0-9_.:-]+$/.test(statement.proofId)) { + throw rejected('invalid proof identifier'); + } + for (const k of ['namespace','runId','requestId','nodeId']) { + identifier(statement[k],k); + } + if (!Number.isSafeInteger(statement.attempt) || statement.attempt<1 + || statement.attempt>11 || !['agent-result','agent-call-failed'].includes(statement.kind)) { + throw rejected('invalid Agent identity'); + } + for (const k of ['artifactHash','policyHash','receiptHash']) { + try { assertSHA256(statement[k]); } + catch { throw rejected('invalid '+k); } + } + if (!boundedTime(statement.issuedAt) || !boundedTime(statement.expiresAt) + || statement.expiresAt<=statement.issuedAt) { + throw rejected('invalid time'); + } + return canonicalBytes(statement,4096); +} diff --git a/src/interactive/index.mjs b/src/interactive/index.mjs index e806ef1..ad23b25 100644 --- a/src/interactive/index.mjs +++ b/src/interactive/index.mjs @@ -3,3 +3,7 @@ export {compileInteractive} from './compile.mjs'; export {loadInteractiveProgram} from './load.mjs'; export {createSqliteCheckpointStore} from './sqlite-store.mjs'; export {createInteractiveCoordinator} from './coordinator.mjs'; + +// M5 Host-only provenance and disclosure facade. Does not expose a signer/private key. +export {createTrustedInteractiveHost} from './trusted-host.mjs'; +export {normalizeHostAgentReceipt,createAgentReceiptStatement,encodeAgentReceiptStatement} from './host-attestation.mjs'; diff --git a/src/interactive/trusted-host.mjs b/src/interactive/trusted-host.mjs new file mode 100644 index 0000000..1c27ae0 --- /dev/null +++ b/src/interactive/trusted-host.mjs @@ -0,0 +1,281 @@ +import { + createPublicKey, verify as verifySignature, timingSafeEqual, +} from 'node:crypto'; +import {MAX_CHECKPOINT_BYTES, canonicalBytes, safeJSONSnapshot, sha256Bytes, assertSHA256} from './canonical.mjs'; +import {createInteractiveCoordinator} from './coordinator.mjs'; +import { + createAgentReceiptStatement,encodeAgentReceiptStatement, + normalizeHostAgentReceipt, +} from './host-attestation.mjs'; + +function denied(detail='authentication or disclosure denied') { + return Object.assign(new Error('FK_I_HOST_DENIED: '+detail), {code:'FK_I_HOST_DENIED'}); +} +function invalidProof() { + // A deliberately uniform external error; no information about key, tenant, + // underlying state or expected signed content leaks to model-controlled input. + return Object.assign(new Error('FK_I_PROVENANCE_REJECTED'), {code:'FK_I_PROVENANCE_REJECTED'}); +} +function name(value) { + if (typeof value!=='string' || !/^[a-zA-Z0-9_.:-]{1,128}$/.test(value)) { + throw new TypeError('Invalid Host binding identifier'); + } + return value; +} +function hash(value) { + try { assertSHA256(value);return value; } + catch { throw new TypeError('Invalid trusted artifact hash'); } +} +function strictFields(object,fields) { + if (!object || typeof object!=='object' || Array.isArray(object) + || Object.keys(object).length!==fields.length + || fields.some(k=>!Object.hasOwn(object,k))) { + throw invalidProof(); + } +} +function normalizeKeys(input) { + if (!input || typeof input!=='object' || Array.isArray(input)) { + throw new TypeError('trustedPublicKeys must be a Host-owned key-id map'); + } + const entries=Object.entries(input); + if (!entries.length || entries.length>32) { + throw new TypeError('At least one trusted Host public key is required'); + } + const keys=new Map(); + const fingerprints=[]; + for (const [id,value] of entries) { + name(id); + let key; + try { + key=value?.type==='public' && value?.asymmetricKeyType + ? value : createPublicKey(value); + } catch { + throw new TypeError('Invalid Host public key'); + } + if (key.type!=='public' || key.asymmetricKeyType!=='ed25519') { + throw new TypeError('Host attestation requires Ed25519 public keys'); + } + const fingerprint=sha256Bytes(key.export({type:'spki',format:'der'})); + keys.set(id,key); + fingerprints.push({id,publicKeyHash:fingerprint}); + } + // Deterministic UTF-16 ordering across Node/OS locale configurations. + fingerprints.sort((a,b)=>a.idb.id?1:0); + return {keys,fingerprints}; +} +function keysArray(value,label,max) { + if (!Array.isArray(value) || value.length>max || !value.every(x=>typeof x==='string' + && x.length>0 && x.length<=256 && !/[\u0000-\u001f\u007f]/.test(x)) + || new Set(value).size!==value.length) { + throw new TypeError('Invalid '+label); + } + return [...value].sort(); +} +function numericPolicy(value,label,max) { + if (!Number.isSafeInteger(value) || value<0 || value>max) { + throw new TypeError('Invalid '+label); + } + return value; +} + +/** + * Trusted Host facade for the existing P0-A Coordinator. The Host keeps + * SQLite, runtime callbacks, and signing private key outside model access. + * + * Warning: this is NOT an in-process sandbox. A model with the same OS + * privileges as the Host, or direct Store/Coordinator access, bypasses it. + */ +export function createTrustedInteractiveHost({ + store,runtime,policy={}, + trustedPublicKeys,trustedArtifacts, + allowedInputKeys=[], + disclosurePolicyId,authorizeDisclosure, + maxProofAgeMs=5*60*1000,clockSkewMs=5000, + now=()=>Date.now(),maxStagesPerCall=128, +}={}) { + if (!store || typeof store.namespace!=='string' + || typeof store.load!=='function' || typeof store.getArtifact!=='function') { + throw new TypeError('A namespace-scoped Host-owned CheckpointStore is required'); + } + if (typeof now!=='function' || typeof authorizeDisclosure!=='function') { + throw new TypeError('Trusted Host clock and disclosure authorizer are required'); + } + name(disclosurePolicyId); + numericPolicy(maxProofAgeMs,'maxProofAgeMs',3600000); + if (maxProofAgeMs<1000) throw new TypeError('maxProofAgeMs must be >= 1000'); + numericPolicy(clockSkewMs,'clockSkewMs',30000); + if (!Array.isArray(trustedArtifacts) || trustedArtifacts.length<1 + || trustedArtifacts.length>1000) { + throw new TypeError('trustedArtifacts must be a nonempty Host-owned allowlist'); + } + const approvedArtifacts=keysArray(trustedArtifacts,'trustedArtifacts',1000).map(hash); + const approvalSet=new Set(approvedArtifacts); + const permittedInputs=keysArray(allowedInputKeys,'allowedInputKeys',256); + const allowed=new Set(permittedInputs); + const {keys:publicKeys,fingerprints}=normalizeKeys(trustedPublicKeys); + const policySnapshot=safeJSONSnapshot(policy); + if (!policySnapshot || typeof policySnapshot!=='object' + || Array.isArray(policySnapshot) || Object.hasOwn(policySnapshot,'hostSecurity')) { + throw new TypeError('Host policy must be a JSON object without reserved hostSecurity'); + } + const securityConfig=Object.freeze({ + schemaVersion:1,mode:'ed25519-agent-receipt', + keyFingerprints:fingerprints, + approvedArtifactHashes:approvedArtifacts, + publicAgentInputKeys:permittedInputs, + disclosurePolicyId,maxProofAgeMs,clockSkewMs, + }); + const effectivePolicy={ + ...policySnapshot, + hostSecurity:securityConfig, + }; + const coordinator=createInteractiveCoordinator({ + store,runtime,policy:effectivePolicy,maxStagesPerCall, + }); + + function assertPinned(runId) { + const cp=store.load(runId); + if (!cp || cp.namespace!==store.namespace + || cp.policyHash!==coordinator.policyHash + || !approvalSet.has(cp.artifactHash)) { + throw denied(); + } + return cp; + } + + /** No bytes are imported until the Host's compiler artifact allowlist passes. */ + async function start(options={}) { + if (!options || typeof options!=='object' + || !options.programIdentity || !approvalSet.has(options.programIdentity.artifactHash)) { + throw denied('unapproved Program artifact'); + } + if (!(options.programBytes instanceof Uint8Array) + || options.programBytes.byteLength>4*1024*1024) { + throw denied('unapproved Program bytes'); + } + return coordinator.start(options); + } + + async function resume({runId}={}) { + assertPinned(runId); + return coordinator.resume({runId}); + } + async function load({runId}={}) { + const cp=store.load(runId); + if (cp===null) return null; + assertPinned(runId); + return coordinator.load({runId}); + } + + /** + * This is the ONLY task structure to send to the model. It is derived from + * authoritative Store state (never a caller-supplied frame), enforces an + * explicit input allowlist, then requires an affirmative Host disclosure gate. + */ + async function getPublicAgentTask({runId}={}) { + const cp=assertPinned(runId); + if (cp.status!=='waiting') return null; + const frame=await coordinator.load({runId}); + const pending=frame?.pending; + if (!pending || pending.kind!=='agent' || pending.runId!==runId) { + throw denied('pending task cannot be certified'); + } + // Missing declared inputs reveal field names too; they require approval. + if ([...Object.keys(pending.input),...pending.missing].some(key=>!allowed.has(key))) { + throw denied('Agent input is not on the disclosure allowlist'); + } + const task=safeJSONSnapshot({ + schemaVersion:1,kind:'agent', + id:pending.id,nodeId:pending.nodeId,runId:pending.runId, + requestId:pending.requestId,attempt:pending.attempt, + name:pending.name,instruction:pending.instruction, + input:pending.input,missing:pending.missing, + resultSchema:pending.resultSchema,reasons:pending.reasons, + ...(pending.overview?{overview:pending.overview}:{}), + },MAX_CHECKPOINT_BYTES); + let decision; + try { + decision=await authorizeDisclosure( + Object.freeze(safeJSONSnapshot({ + namespace:cp.namespace,runId,requestId:pending.requestId,task, + disclosurePolicyId, + },MAX_CHECKPOINT_BYTES)), + ); + } catch { + // Host-private DLP details must not leak into the model-facing error. + throw denied('Host did not approve model disclosure'); + } + if (decision!==true) throw denied('Host did not approve model disclosure'); + // An async disclosure gate may overlap a successful receipt/commit from a + // separate Host process. Never hand the model a superseded Pending. + const current=store.load(runId); + if (!current || current.status!=='waiting' || current.revision!==cp.revision + || current.policyHash!==cp.policyHash + || current.pending?.requestId!==pending.requestId) { + throw denied('Pending changed during Host authorization'); + } + return task; + } + + /** Validate the independent Host signature before ANY receipt CAS. */ + async function accept({receipt,attestation}={}) { + const normalized=normalizeHostAgentReceipt(receipt); + let cp; + try { cp=assertPinned(normalized.runId); } + catch { throw invalidProof(); } + let proof; + try { proof=safeJSONSnapshot(attestation,8192); } + catch { throw invalidProof(); } + strictFields(proof,['statement','signature']); + let statement,encoded,expected,signature; + try { + statement=safeJSONSnapshot(proof.statement,4096); + encoded=encodeAgentReceiptStatement(statement); + const current=now(); + // Re-acknowledging an already accepted byte-identical receipt is safe + // after expiry: the durable ledger proves no new receipt is being + // introduced. NEW receipts still require fresh proofs and signatures + // are checked in both cases; raw Coordinator access stays Host-private. + const receiptDigest=sha256Bytes(canonicalBytes(normalized)); + const alreadyAccepted=Array.isArray(cp.receipts) && cp.receipts.some(r=> + r.requestId===normalized.requestId && r.nodeId===normalized.nodeId + && r.attempt===normalized.attempt && r.digest===receiptDigest); + if (!Number.isSafeInteger(current) || current<=0 + || statement.issuedAt>current+clockSkewMs + || (statement.expiresAtmaxProofAgeMs) { + throw invalidProof(); + } + const key=publicKeys.get(statement.keyId); + if (!key || typeof proof.signature!=='string' + || !/^[A-Za-z0-9_-]{86}$/.test(proof.signature)) { + throw invalidProof(); + } + signature=Buffer.from(proof.signature,'base64url'); + if (signature.byteLength!==64 + || signature.toString('base64url')!==proof.signature) throw invalidProof(); + expected=createAgentReceiptStatement({ + run:cp,receipt:normalized,keyId:statement.keyId, + proofId:statement.proofId, + issuedAt:statement.issuedAt,expiresAt:statement.expiresAt, + }); + const expectedBytes=encodeAgentReceiptStatement(expected); + if (encoded.byteLength!==expectedBytes.byteLength + || !timingSafeEqual(encoded,expectedBytes) + || !verifySignature(null,encoded,key,signature)) { + throw invalidProof(); + } + } catch { + throw invalidProof(); + } + // The underlying Coordinator retains all M4 identity/CAS/retry checks. + // The model receives neither this closure nor a raw complete() capability. + return coordinator.complete(normalized); + } + + return Object.freeze({ + start,resume,load,getPublicAgentTask,accept, + policyHash:coordinator.policyHash, + securityMode:'ed25519-agent-receipt-v1', + }); +} diff --git a/tests/interactive/trusted-host.test.mjs b/tests/interactive/trusted-host.test.mjs new file mode 100644 index 0000000..af0d98a --- /dev/null +++ b/tests/interactive/trusted-host.test.mjs @@ -0,0 +1,375 @@ +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 {tmpdir} from 'node:os'; +import path from 'node:path'; +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 {canonicalBytes,sha256Bytes} from '../../src/interactive/canonical.mjs'; +import {createTrustedInteractiveHost} from '../../src/interactive/trusted-host.mjs'; +import { + createAgentReceiptStatement,encodeAgentReceiptStatement,normalizeHostAgentReceipt, +} from '../../src/interactive/host-attestation.mjs'; + +const dirs=[]; +after(async()=>{for(const p of dirs)await rm(p,{recursive:true,force:true})}); +const CLOCK=1794400000000; +const runtime={ + skillResolver:async descriptor=>'Validated instruction: '+descriptor.nodeId, + validateResult:(_schema,result)=>typeof result?.answer==='string'?[]:['answer required'], +}; +const dsl="import {defineWorkflow,condition,node,terminal} from '@devcodex-labs/flowkit/dsl';\n"; +const source=dsl+ + "export default defineWorkflow({id:'m5-agent',version:'1.0.0',name:'M5',nodes:["+ + "condition('gate',{name:'Gate',branches:[{when:'ready',next:'worker'},{when:'true',next:'fail'}]}),"+ + "node('worker',{name:'Worker',agent:'writer',inputs:['existing'],output:{type:'object',properties:{answer:{type:'string'}}},retries:1,next:'done'}),"+ + "terminal('done',{name:'Done',status:'succeeded'}),"+ + "terminal('fail',{name:'Fail',status:'failed'})]});"; + +async function compile(text=source) { + const analyzed=await analyzeSource(text,{ + filename:'m5.workflow.js',projectRoot:process.cwd(),skillCheck:false, + }); + assert.ok(!analyzed.diagnostics.some(d=>d.severity==='error'),JSON.stringify(analyzed.diagnostics)); + const lowered=lowerInteractive(analyzed); + assert.ok(lowered.ir,JSON.stringify(lowered.diagnostics)); + const emitted=emitInteractive(lowered.ir); + assert.ok(emitted.code); + return {programBytes:Buffer.from(emitted.code),programIdentity:emitted.programIdentity}; +} +async function fixture(t,options={}) { + const dir=await mkdtemp(path.join(tmpdir(),'flowkit-m5-')); + dirs.push(dir); + const store=createSqliteCheckpointStore(path.join(dir,'run.db'),{namespace:'tenant-m5'}); + t.after(()=>store.close()); + const program=await compile(); + const keys=generateKeyPairSync('ed25519'); + const hostOptions={ + store,runtime, + policy:{hostVersion:'1'}, + trustedPublicKeys:{'host-a':keys.publicKey}, + trustedArtifacts:[program.programIdentity.artifactHash], + allowedInputKeys:['existing'], + disclosurePolicyId:'disclosure-v1', + authorizeDisclosure:async()=>true, + now:()=>CLOCK, + ...options, + }; + const host=createTrustedInteractiveHost(hostOptions); + return {dir,store,program,keys,host,hostOptions}; +} +async function waiting(t,options={}) { + const f=await fixture(t,options); + const frame=await f.host.start({ + ...f.program, startKey:'run-'+f.dir, + state:{ready:true,existing:'public input',secret:'Host private data'}, + }); + assert.equal(frame.status,'waiting'); + return {...f,frame}; +} +function agentResult(frame,result={answer:'safe'}) { + return { + runId:frame.runId,requestId:frame.pending.requestId, + nodeId:frame.pending.nodeId,attempt:frame.pending.attempt, + kind:'agent-result',result, + }; +} +function proof(run,receipt,privateKey,{ + keyId='host-a',proofId='provider-event-1', + issuedAt=CLOCK-1000,expiresAt=CLOCK+10000, +}={}) { + const statement=createAgentReceiptStatement({ + run,receipt,keyId,proofId,issuedAt,expiresAt, + }); + const signature=sign(null,encodeAgentReceiptStatement(statement),privateKey).toString('base64url'); + return {statement,signature}; +} +async function rejectUnchanged(f,receipt,attestation) { + const cp=f.store.load(f.frame.runId); + const before=canonicalBytes(cp); + const count=f.store.listJournal(f.frame.runId).length; + await assert.rejects(f.host.accept({receipt,attestation}),{code:'FK_I_PROVENANCE_REJECTED'}); + assert.deepEqual(canonicalBytes(f.store.load(f.frame.runId)),before); + assert.equal(f.store.listJournal(f.frame.runId).length,count); +} + +test('M5 authorized public task contains only allowlisted declared input, not State or Host internals',async t=>{ + const f=await waiting(t); + const task=await f.host.getPublicAgentTask({runId:f.frame.runId}); + assert.equal(task.schemaVersion,1); + assert.equal(task.requestId,f.frame.pending.requestId); + assert.equal(task.id,'worker'); + assert.deepEqual(task.input,{existing:'public input'}); + assert.deepEqual(task.missing,[]); + assert.equal(task.instruction,'Validated instruction: worker'); + for(const forbidden of ['namespace','artifactHash','policyHash','secret','ready','state', + 'checkpoint','candidate','controlStack','receipts','signer','privateKey']) { + assert.equal(Object.hasOwn(task,forbidden),false,forbidden); + } + assert.equal(JSON.stringify(task).includes('Host private data'),false); + assert.equal((await f.host.getPublicAgentTask({runId:f.frame.runId})).requestId,task.requestId); + assert.equal(f.store.load(f.frame.runId).revision,f.frame.revision); +}); + +test('M5 strict disclosure allowlist and authorizer both fail closed with zero CAS writes',async t=>{ + for(const options of [ + {allowedInputKeys:[]}, + {authorizeDisclosure:async()=>false}, + {authorizeDisclosure:async()=>({approved:true})}, + {authorizeDisclosure:async()=>{throw Error('Private DLP details')}}, + ]) { + const f=await waiting(t,options); + const before=canonicalBytes(f.store.load(f.frame.runId)); + await assert.rejects(f.host.getPublicAgentTask({runId:f.frame.runId}),error=> + error.code==='FK_I_HOST_DENIED' && !error.message.includes('Private DLP details')); + assert.deepEqual(canonicalBytes(f.store.load(f.frame.runId)),before); + } +}); + +test('M5 trusted Ed25519 Agent receipt completes, and signed retransmission returns durable Ack',async t=>{ + const f=await waiting(t); + const receipt=agentResult(f.frame,{answer:'signed'}); + const attestation=proof(f.store.load(f.frame.runId),receipt,f.keys.privateKey); + const completed=await f.host.accept({receipt,attestation}); + assert.equal(completed.status,'succeeded'); + assert.equal(completed.state.secret,'Host private data'); + assert.equal(completed.state.answer,'signed'); + const retry=await f.host.accept({receipt,attestation}); + assert.equal(retry.duplicate,true); + assert.equal(retry.acceptedRevision,completed.acceptedRevision); + assert.equal(retry.revision,completed.revision); + assert.equal(await f.host.getPublicAgentTask({runId:f.frame.runId}),null); +}); + +test('M5 cannot forge provenance with arbitrary string, unknown key, other signature or modified body',async t=>{ + const f=await waiting(t); + const receipt=agentResult(f.frame); + const run=f.store.load(f.frame.runId); + const authentic=proof(run,receipt,f.keys.privateKey); + const attacker=generateKeyPairSync('ed25519'); + await rejectUnchanged(f,receipt,{statement:authentic.statement,signature:'not-a-signature'}); + await rejectUnchanged(f,receipt,{'provenanceRef':'host-approval-123'}); + await rejectUnchanged(f,receipt,{statement:authentic.statement, + signature:proof(run,receipt,attacker.privateKey).signature}); + await rejectUnchanged(f,receipt,{statement:{...authentic.statement,keyId:'fake-key'}, + signature:authentic.signature}); + await rejectUnchanged(f,{...receipt,result:{answer:'tampered'}},authentic); + await rejectUnchanged(f,{...receipt,attempt:receipt.attempt+1},authentic); + await rejectUnchanged(f,{...receipt,requestId:'different'},authentic); + await rejectUnchanged(f,receipt,{statement:{...authentic.statement,namespace:'other-tenant'}, + signature:authentic.signature}); + await rejectUnchanged(f,receipt,{statement:{...authentic.statement,policyHash:'sha256:'+'f'.repeat(64)}, + signature:authentic.signature}); + await rejectUnchanged(f,receipt,{statement:{...authentic.statement,artifactHash:'sha256:'+'f'.repeat(64)}, + signature:authentic.signature}); +}); + +test('M5 proof time checks reject expiry, future, excessive lifetime and bad signing context before CAS',async t=>{ + const f=await waiting(t); + const receipt=agentResult(f.frame); + const run=f.store.load(f.frame.runId); + for(const range of [ + {issuedAt:CLOCK-300000,expiresAt:CLOCK-30000}, + {issuedAt:CLOCK+60000,expiresAt:CLOCK+120000}, + {issuedAt:CLOCK-300000,expiresAt:CLOCK+100000}, + ]) { + await rejectUnchanged(f,receipt,proof(run,receipt,f.keys.privateKey,range)); + } + const wrong=proof(run,receipt,f.keys.privateKey,{keyId:'host-other'}); + await rejectUnchanged(f,receipt,wrong); +}); + +test('M5 cannot authorize Human/Action/Verification or model-picked fields by signed agent proof',async t=>{ + const f=await waiting(t); + const normal=agentResult(f.frame); + const signed=proof(f.store.load(f.frame.runId),normal,f.keys.privateKey); + for(const changed of [ + {...normal,kind:'human',approved:true}, + {...normal,kind:'action'}, + {...normal,kind:'verification'}, + {...normal,transitionId:'injected'}, + {...normal,provenanceRef:'model-approval'}, + {...normal,source:'fake'}, + ]) { + await rejectUnchanged(f,changed,signed); + } + const readOnly=f.store.load(f.frame.runId); + assert.equal(readOnly.pending.requestId,f.frame.pending.requestId); +}); + +test('M5 strictly rejects malformed Agent failure receipt prior to verifier and Store mutation',async t=>{ + const f=await waiting(t); + for(const body of [ + {kind:'agent-call-failed',failure:{message:7}}, + {kind:'agent-call-failed',failure:{message:'',unexpected:true}}, + {kind:'agent-call-failed',failure:{message:'x'},result:{}}, + {kind:'agent-result'}, + ]) { + const invalid={runId:f.frame.runId,requestId:f.frame.pending.requestId, + nodeId:'worker',attempt:1,...body}; + await rejectUnchanged(f,invalid,{}); + } +}); + +test('M5 signed Host-reported failure retries through the same Coordinator receipt CAS',async t=>{ + const f=await waiting(t); + const receipt={ + runId:f.frame.runId,requestId:f.frame.pending.requestId, + nodeId:'worker',attempt:1,kind:'agent-call-failed',failure:{message:'provider timeout'}, + }; + const attestation=proof(f.store.load(f.frame.runId),receipt,f.keys.privateKey); + const next=await f.host.accept({receipt,attestation}); + assert.equal(next.status,'waiting'); + assert.equal(next.pending.attempt,2); + assert.notEqual(next.pending.requestId,receipt.requestId); + assert.deepEqual(next.pending.reasons,['runAgent threw: provider timeout']); +}); + +test('M5 rejects unapproved Program bytes *before importing/executing* them',async t=>{ + const f=await fixture(t); + const alt=await compile(dsl+"export default defineWorkflow({id:'alt',version:'1.0.0',name:'Alternate',nodes:[terminal('done',{name:'Done',status:'succeeded'})]});"); + const hash=alt.programIdentity.artifactHash; + assert.notEqual(hash,f.program.programIdentity.artifactHash); + const modified=Buffer.from(alt.programBytes.toString('utf8')+'\nthrow Error("untrusted side effect");\n'); + const attempted={...alt,programBytes:modified,programIdentity:{ + ...alt.programIdentity, + artifactHash:sha256Bytes(modified), + }}; + await assert.rejects(f.host.start({...attempted,startKey:'malicious',state:{}}),{code:'FK_I_HOST_DENIED'}); + assert.equal(f.store.getArtifact(attempted.programIdentity.artifactHash),null); + await assert.rejects(f.host.start({...alt,startKey:'unapproved',state:{}}),{code:'FK_I_HOST_DENIED'}); + assert.equal(f.store.getArtifact(hash),null); +}); + +test('M5 trust-key, artifact and disclosure policy changes invalidate previously pinned policy',async t=>{ + const f=await waiting(t); + const next=generateKeyPairSync('ed25519'); + const changed=createTrustedInteractiveHost({ + ...f.hostOptions,trustedPublicKeys:{'host-b':next.publicKey}, + }); + await assert.rejects(changed.resume({runId:f.frame.runId}),{code:'FK_I_HOST_DENIED'}); + const rotatedAllowlist=createTrustedInteractiveHost({ + ...f.hostOptions,allowedInputKeys:['existing','secret'], + }); + await assert.rejects(rotatedAllowlist.load({runId:f.frame.runId}),{code:'FK_I_HOST_DENIED'}); + assert.deepEqual(await f.host.resume({runId:f.frame.runId}),f.frame); +}); + +test('M5 host constructor fails closed without a signer trust root or disclosure approval policy',async t=>{ + const f=await fixture(t); + for(const invalid of [ + {trustedPublicKeys:{}}, + {trustedPublicKeys:{'key':generateKeyPairSync('rsa',{modulusLength:2048}).publicKey}}, + {trustedArtifacts:[]}, + {allowedInputKeys:['existing','existing']}, + {authorizeDisclosure:undefined}, + {disclosurePolicyId:undefined}, + {maxProofAgeMs:3600001}, + ]) { + assert.throws(()=>createTrustedInteractiveHost({...f.hostOptions,...invalid})); + } +}); + +test('M5 object prototype, proxy, accessor and undefined cannot bypass signed receipt canonicalization',async t=>{ + const f=await waiting(t); + const valid=agentResult(f.frame); + const signed=proof(f.store.load(f.frame.runId),valid,f.keys.privateKey); + const getter={...valid}; + Object.defineProperty(getter,'result',{enumerable:true,get(){throw Error('getter triggered')}}); + const cycle={};cycle.self=cycle; + for(const receipt of [getter,new Proxy(valid,{}),{...valid,result:{answer:-0}}, + {...valid,result:cycle},{...valid,result:undefined}]) { + await rejectUnchanged(f,receipt,signed); + } + assert.throws(()=>normalizeHostAgentReceipt({...valid,extra:true}),/PROVENANCE_REJECTED/); +}); + + +test('M5 async disclosure approval never releases a Pending superseded by another Host',async t=>{ + let finishWhileApproving; + const f=await waiting(t,{ + authorizeDisclosure:async()=>{ + await finishWhileApproving(); + return true; + }, + }); + finishWhileApproving=async()=>{ + const receipt=agentResult(f.frame,{answer:'already-completed'}); + const attestation=proof(f.store.load(f.frame.runId),receipt,f.keys.privateKey); + const completed=await f.host.accept({receipt,attestation}); + assert.equal(completed.status,'succeeded'); + }; + await assert.rejects(f.host.getPublicAgentTask({runId:f.frame.runId}),{ + code:'FK_I_HOST_DENIED', + }); + assert.equal(f.store.load(f.frame.runId).status,'succeeded'); + assert.equal(f.store.load(f.frame.runId).state.answer,'already-completed'); + assert.equal(await f.host.getPublicAgentTask({runId:f.frame.runId}),null); +}); + + +test('M5 missing-input field names are also covered by explicit public allowlist',async t=>{ + const f=await fixture(t); + const alternative=await compile(source.replace( + "inputs:['existing']","inputs:['existing','secretKeyName']", + )); + const host=createTrustedInteractiveHost({ + ...f.hostOptions, + trustedArtifacts:[alternative.programIdentity.artifactHash], + allowedInputKeys:['existing'], + }); + const pending=await host.start({ + ...alternative,startKey:'missing-sensitive-field', + state:{ready:true,existing:'safe'}, + }); + assert.equal(pending.status,'waiting'); + assert.deepEqual(pending.pending.missing,['secretKeyName']); + const snapshot=canonicalBytes(f.store.load(pending.runId)); + await assert.rejects(host.getPublicAgentTask({runId:pending.runId}),{ + code:'FK_I_HOST_DENIED', + }); + assert.deepEqual(canonicalBytes(f.store.load(pending.runId)),snapshot); +}); + + +test('M5 expired authentic proof re-acks an already accepted identical receipt, never a new one',async t=>{ + let clock=CLOCK; + const f=await waiting(t,{now:()=>clock}); + const receipt=agentResult(f.frame,{answer:'finished'}); + const signed=proof(f.store.load(f.frame.runId),receipt,f.keys.privateKey,{ + issuedAt:CLOCK-1000,expiresAt:CLOCK+10000, + }); + const done=await f.host.accept({receipt,attestation:signed}); + assert.equal(done.status,'succeeded'); + clock=CLOCK+3600000; // Much later than the original proof validity. + const replay=await f.host.accept({receipt,attestation:signed}); + assert.equal(replay.duplicate,true); + assert.equal(replay.acceptedRevision,done.acceptedRevision); + assert.equal(replay.revision,done.revision); + const mutated={...receipt,result:{answer:'not previously accepted'}}; + await rejectUnchanged(f,mutated,signed); +}); + + +test('M5 even VALIDLY signed proofs for a wrong tenant, Program, policy, request or result are rejected',async t=>{ + const f=await waiting(t); + const receipt=agentResult(f.frame,{answer:'authoritative'}); + const run=f.store.load(f.frame.runId); + const cases=[ + proof({...run,namespace:'attacker-tenant'},receipt,f.keys.privateKey), + proof({...run,artifactHash:'sha256:'+'a'.repeat(64)},receipt,f.keys.privateKey), + proof({...run,policyHash:'sha256:'+'b'.repeat(64)},receipt,f.keys.privateKey), + proof(run,{...receipt,requestId:'other-request'},f.keys.privateKey), + proof(run,{...receipt,nodeId:'other-node'},f.keys.privateKey), + proof(run,{...receipt,attempt:receipt.attempt+1},f.keys.privateKey), + proof(run,{...receipt,result:{answer:'different-result'}},f.keys.privateKey), + ]; + for(const attestation of cases) { + // All attestations have actual, mathematically valid Host signatures. + // They must fail on the expected authoritative run identity/body. + await rejectUnchanged(f,receipt,attestation); + } +}); diff --git a/tests/package/consumer.mjs b/tests/package/consumer.mjs index b56db45..036def0 100644 --- a/tests/package/consumer.mjs +++ b/tests/package/consumer.mjs @@ -2,6 +2,7 @@ import assert from 'node:assert/strict'; import { readFile, writeFile, mkdir, stat } from 'node:fs/promises'; import { fileURLToPath } from 'node:url'; import path from 'node:path'; +import {generateKeyPairSync, sign} from 'node:crypto'; import * as api from '@devcodex-labs/flowkit'; import * as interactive from '@devcodex-labs/flowkit/interactive'; import * as dsl from '@devcodex-labs/flowkit/dsl'; @@ -30,7 +31,9 @@ assert.equal(await readFile('sample.compiled.mjs', 'utf8'), output.code); assert.deepEqual(Object.keys(interactive),[ - 'compileInteractive','createInteractiveCoordinator','createSqliteCheckpointStore','loadInteractiveProgram', + 'compileInteractive','createAgentReceiptStatement','createInteractiveCoordinator', + 'createSqliteCheckpointStore','createTrustedInteractiveHost','encodeAgentReceiptStatement', + 'loadInteractiveProgram','normalizeHostAgentReceipt', ]); await writeFile('simple.workflow.js', "import {defineWorkflow,terminal} from '@devcodex-labs/flowkit/dsl';\n"+ @@ -51,4 +54,48 @@ try { assert.equal(frame.status,'succeeded'); assert.deepEqual(frame.state,{installed:true}); assert.equal((await machine.resume({runId:frame.runId})).revision,frame.revision); + + await writeFile('signed.workflow.js', + "import {defineWorkflow,node,terminal} from '@devcodex-labs/flowkit/dsl';\n"+ + "export default defineWorkflow({id:'trusted-consumer',version:'1.0.0',name:'Trusted',nodes:["+ + "node('worker',{name:'Worker',skill:'skills/a.md',inputs:['message'],"+ + "output:{type:'object',properties:{answer:{type:'string'}}},retries:0,next:'done'}),"+ + "terminal('done',{name:'Done',status:'succeeded'})]});\n"); + const signed=await interactive.compileInteractive('signed.workflow.js',{ + output:'memory',featureStage:'P0-A', + }); + assert.equal(signed.diagnostics.some(d=>d.severity==='error'),false); + const {publicKey,privateKey}=generateKeyPairSync('ed25519'); + const now=1794400000000; + const trusted=interactive.createTrustedInteractiveHost({ + store,runtime:{skillResolver:async()=> 'Safe instruction', + validateResult:(_schema,result)=>typeof result?.answer==='string'?[]:['invalid']}, + trustedPublicKeys:{consumer:publicKey}, + trustedArtifacts:[signed.programIdentity.artifactHash], + allowedInputKeys:['message'], + disclosurePolicyId:'consumer-disclosure-v1', + authorizeDisclosure:async()=>true, + now:()=>now, + }); + const pending=await trusted.start({ + programBytes:Buffer.from(signed.code),programIdentity:signed.programIdentity, + startKey:'consumer-trusted',state:{message:'shareable',hidden:'private'}, + }); + assert.equal(pending.status,'waiting'); + const publicTask=await trusted.getPublicAgentTask({runId:pending.runId}); + assert.deepEqual(publicTask.input,{message:'shareable'}); + assert.equal(JSON.stringify(publicTask).includes('private'),false); + const receipt={kind:'agent-result',runId:pending.runId,requestId:pending.pending.requestId, + nodeId:pending.pending.nodeId,attempt:pending.pending.attempt,result:{answer:'verified'}}; + const statement=interactive.createAgentReceiptStatement({ + run:store.load(pending.runId),receipt,keyId:'consumer',proofId:'provider-result-1', + issuedAt:now-1000,expiresAt:now+10000, + }); + const signature=sign(null,interactive.encodeAgentReceiptStatement(statement),privateKey) + .toString('base64url'); + const final=await trusted.accept({receipt,attestation:{statement,signature}}); + assert.equal(final.status,'succeeded'); + assert.deepEqual(final.state,{message:'shareable',hidden:'private',answer:'verified'}); + assert.equal((await trusted.accept({receipt,attestation:{statement,signature}})).duplicate,true); + } finally {store.close();} diff --git a/tests/security/host-isolation.mjs b/tests/security/host-isolation.mjs new file mode 100644 index 0000000..5638de9 --- /dev/null +++ b/tests/security/host-isolation.mjs @@ -0,0 +1,76 @@ +/** + * M5 explicit Linux OS-user isolation proof. + * Run ONLY as a dedicated CI job with a privilege-boundary-capable runner. + * It is deliberately not a mocked promise or an in-process verifier test. + */ +import assert from 'node:assert/strict'; +import {generateKeyPairSync} from 'node:crypto'; +import {mkdtemp,mkdir,writeFile,readFile,chmod,rm} from 'node:fs/promises'; +import {spawn} from 'node:child_process'; +import {tmpdir} from 'node:os'; +import path from 'node:path'; +import {createSqliteCheckpointStore} from '../../src/interactive/sqlite-store.mjs'; + +if (process.platform!=='linux') throw new Error('M5 OS isolation proof requires Linux runner'); +const root=await mkdtemp(path.join(tmpdir(),'flowkit-m5-os-')); +let store; +try { + // mkdtemp defaults to 0700; the public task must actually be readable by + // the unprivileged child while Host-owned secrets remain inaccessible. + await chmod(root,0o755); + const publicDir=path.join(root,'public'); + const hostDir=path.join(root,'host-private'); + await mkdir(publicDir,{mode:0o755}); + await mkdir(hostDir,{mode:0o700}); + const publicTask=path.join(publicDir,'task.json'); + const privateKey=path.join(hostDir,'signing-key.pem'); + const sqliteFile=path.join(hostDir,'checkpoint.db'); + await writeFile(publicTask,JSON.stringify({kind:'agent',requestId:'req-public',instruction:'public'}),{mode:0o644}); + const pair=generateKeyPairSync('ed25519'); + await writeFile(privateKey,pair.privateKey.export({format:'pem',type:'pkcs8'}),{mode:0o600}); + store=createSqliteCheckpointStore(sqliteFile,{namespace:'host-owned'}); + await chmod(privateKey,0o600); + await chmod(hostDir,0o700); + + const childCode=String.raw` + import {readFile,writeFile} from 'node:fs/promises'; + const [publicTask,privateKey,sqliteFile]=process.argv.slice(1); + const task=JSON.parse(await readFile(publicTask,'utf8')); + if(task.requestId!=='req-public')throw Error('PUBLIC_TASK_UNREADABLE'); + for(const secret of [privateKey,sqliteFile]){ + let denied=false; + try{await readFile(secret); }catch(e){denied=e.code==='EACCES'||e.code==='EPERM';} + if(!denied)throw Error('HOST_SECRET_READABLE_BY_MODEL'); + } + let denied=false; + try{await writeFile(sqliteFile,'corrupt');}catch(e){denied=e.code==='EACCES'||e.code==='EPERM';} + if(!denied)throw Error('HOST_DATABASE_WRITABLE_BY_MODEL'); + process.stdout.write('ISOLATION_CONFIRMED'); + `; + const args=process.getuid?.()===0 + ? {command:process.execPath,args:['--input-type=module','-e',childCode, + publicTask,privateKey,sqliteFile],options:{uid:65534,gid:65534}} + : {command:'sudo',args:['-n','-u','nobody','--',process.execPath, + '--input-type=module','-e',childCode,publicTask,privateKey,sqliteFile], + options:{}}; + const child=await new Promise((resolve,reject)=>{ + const proc=spawn(args.command,args.args,{ + stdio:['ignore','pipe','pipe'],...args.options, + }); + let stdout='',stderr=''; + proc.stdout.on('data',buffer=>stdout+=buffer); + proc.stderr.on('data',buffer=>stderr+=buffer); + proc.on('error',reject); + proc.on('close',(code,signal)=>resolve({code,signal,stdout,stderr})); + }); + assert.equal(child.code,0, + 'Model OS user must read public task but cannot reach Host key/DB: '+child.stderr); + assert.match(child.stdout,/ISOLATION_CONFIRMED/); + assert.match(await readFile(publicTask,'utf8'),/req-public/); + assert.match(await readFile(privateKey,'utf8'),/BEGIN PRIVATE KEY/); + assert.equal(store.load('unrelated-run'),null); + console.log('M5 Linux OS-user boundary PASS: public task readable; Host key and SQLite file read/write denied'); +} finally { + store?.close(); + await rm(root,{recursive:true,force:true}); +} diff --git a/tests/types/interactive.ts b/tests/types/interactive.ts index 81efb91..4ee35ed 100644 --- a/tests/types/interactive.ts +++ b/tests/types/interactive.ts @@ -33,3 +33,46 @@ const unsupported: InteractiveRuntime={checkStep:()=>null}; coordinator.start({programBytes:bytes,programIdentity:artifact}); // @ts-expect-error correct attempt required on complete coordinator.complete({runId:'r',requestId:'q',nodeId:'n',result:{}}); + + +import { + createTrustedInteractiveHost,createAgentReceiptStatement,encodeAgentReceiptStatement, + normalizeHostAgentReceipt, +} from '../../types/interactive.js'; +import type { + HostAgentReceipt,HostAgentAttestation,PublicInteractiveAgentTask, + TrustedInteractiveHostOptions, +} from '../../types/interactive.js'; + +declare const signingPublicKey:unknown; +const trustedOptions:TrustedInteractiveHostOptions={ + store,runtime, + trustedPublicKeys:{'transport-v1':signingPublicKey}, + trustedArtifacts:[artifact.artifactHash], + disclosurePolicyId:'tenant-approved-fields-v1', + allowedInputKeys:['message'], + authorizeDisclosure:async evidence=>evidence.task.kind==='agent', +}; +const trusted=createTrustedInteractiveHost(trustedOptions); +const publicTask:Promise= + trusted.getPublicAgentTask({runId:'r'}); +const receipt:HostAgentReceipt={ + kind:'agent-result',runId:'r',requestId:'q',nodeId:'n',attempt:1,result:{answer:'ok'}, +}; +const statement=createAgentReceiptStatement({ + run:{namespace:'n',artifactHash:artifact.artifactHash,policyHash:'sha256:'+'a'.repeat(64)}, + receipt,keyId:'transport-v1',proofId:'receipt-1',issuedAt:1,expiresAt:2, +}); +const bytesToSign:Uint8Array=encodeAgentReceiptStatement(statement); +declare const attestation:HostAgentAttestation; +const verified=trusted.accept({receipt:normalizeHostAgentReceipt(receipt),attestation}); +void [trustedOptions,publicTask,bytesToSign,verified]; +// @ts-expect-error a model string cannot replace the signed Host attestation +trusted.accept({receipt,attestation:'approval-123'}); +// @ts-expect-error no low-level complete() method on the trusted Host facade +trusted.complete(receipt); +// @ts-expect-error Human is not in the M5 P0-A receipt union +const fakeHuman:HostAgentReceipt={kind:'human',runId:'r',requestId:'q',nodeId:'h',attempt:1,result:{approved:true}}; +// @ts-expect-error proof bytes require canonical attestation field set +encodeAgentReceiptStatement({domain:'flowkit.host-agent-receipt.v1'}); +void fakeHuman; diff --git a/types/interactive.d.ts b/types/interactive.d.ts index bbdf869..c50bfc5 100644 --- a/types/interactive.d.ts +++ b/types/interactive.d.ts @@ -162,3 +162,103 @@ export declare function createInteractiveCoordinator(input: { policy?: Record; maxStagesPerCall?: number; }): InteractiveCoordinator; + + +/** M5 signed Host-only Agent receipt: no Human/Action/Verification spoofing. */ +export type HostAgentReceipt = + | {runId:string;requestId:string;nodeId:string;attempt:number;kind:'agent-result';result:unknown} + | {runId:string;requestId:string;nodeId:string;attempt:number;kind:'agent-call-failed';failure:{message:string}}; + +export interface HostAgentReceiptStatement { + domain: 'flowkit.host-agent-receipt.v1'; + keyId: string; + proofId: string; + namespace: string; + runId: string; + requestId: string; + nodeId: string; + attempt: number; + kind: 'agent-result' | 'agent-call-failed'; + artifactHash: string; + policyHash: string; + receiptHash: string; + issuedAt: number; + expiresAt: number; +} +export interface HostAgentAttestation { + statement: HostAgentReceiptStatement; + /** Canonical base64url Ed25519 signature of encodeAgentReceiptStatement bytes. */ + signature: string; +} +export declare function normalizeHostAgentReceipt(input:unknown):HostAgentReceipt; +export declare function createAgentReceiptStatement(input: { + run: Pick & {policyHash:string}; + receipt:HostAgentReceipt; + keyId:string; + proofId:string; + issuedAt:number; + expiresAt:number; +}):Readonly; +/** No private key is used or exposed. Host must sign in an isolated trust boundary. */ +export declare function encodeAgentReceiptStatement( + statement:HostAgentReceiptStatement +):Uint8Array; + +export interface PublicInteractiveAgentTask { + schemaVersion:1; + kind:'agent'; + id:string; + nodeId:string; + runId:string; + requestId:string; + attempt:number; + name:string; + instruction:string; + input:Record; + missing:string[]; + resultSchema:Record; + reasons:string[]; + overview?:{step:number}; +} +export interface InteractiveDisclosureContext { + namespace:string; + runId:string; + requestId:string; + task:PublicInteractiveAgentTask; + disclosurePolicyId:string; +} +export interface TrustedInteractiveHost { + readonly policyHash:string; + readonly securityMode:'ed25519-agent-receipt-v1'; + /** Trusted Host only, not a model-facing method. */ + start(input: Parameters[0]):Promise; + resume(input:{runId:string}):Promise; + load(input:{runId:string}):Promise; + /** Only this task shape may be shared into a model worker. */ + getPublicAgentTask(input:{runId:string}):Promise; + /** A signature from an independently secured Host transport is mandatory. */ + accept(input:{ + receipt:HostAgentReceipt; + attestation:HostAgentAttestation; + }):Promise; +} +export interface TrustedInteractiveHostOptions { + store:CheckpointStore; + runtime:InteractiveRuntime; + policy?:Record; + /** Pinned Ed25519 public keys or PEM encoded public keys. No private material. */ + trustedPublicKeys:Record; + /** Pinned artifact hashes approved by a separate trusted build/deployment. */ + trustedArtifacts:string[]; + /** Explicit model-visible Agent State keys, default empty/fail closed. */ + allowedInputKeys?:string[]; + disclosurePolicyId:string; + authorizeDisclosure(context:Readonly):boolean|Promise; + maxProofAgeMs?:number; + clockSkewMs?:number; + now?:()=>number; + maxStagesPerCall?:number; +} +export declare function createTrustedInteractiveHost( + options:TrustedInteractiveHostOptions +):TrustedInteractiveHost;