Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
66bfcf5
feat(interactive): canonical Host Agent receipt attestation contract,…
rockyshi1993 Nov 10, 2026
f101935
feat(interactive): fail-closed trusted Host facade with Ed25519 prove…
rockyshi1993 Nov 10, 2026
34f8b52
test(interactive): M5 signed receipts, host disclosure, immutable art…
rockyshi1993 Nov 10, 2026
eae9439
test(interactive): include artifact hash helper for unapproved Progra…
rockyshi1993 Nov 10, 2026
b3554fa
test(interactive): actual Linux unprivileged model process cannot rea…
rockyshi1993 Nov 10, 2026
6e6e19b
feat(interactive): expose verified M5 Host adapter and pure signing-b…
rockyshi1993 Nov 10, 2026
cac972d
build(interactive): ship signed Host proof and trusted facade in exac…
rockyshi1993 Nov 10, 2026
e9ed6f8
types(interactive): M5 signed Host receipt, public-task projection an…
rockyshi1993 Nov 10, 2026
bbff172
test(types): M5 Host attestation declarations reject model approval a…
rockyshi1993 Nov 10, 2026
407e16d
ci(interactive): require real Linux cross-UID Host key/SQLite isolati…
rockyshi1993 Nov 10, 2026
fd07b4e
test(package): consume M5 signed Host SDK end-to-end from real instal…
rockyshi1993 Nov 10, 2026
2c8035e
docs(interactive): M5 Ed25519 trusted Host bridge threat model, priva…
rockyshi1993 Nov 10, 2026
5e15ee0
fix(interactive): do not leak private disclosure-validator exceptions…
rockyshi1993 Nov 10, 2026
087ce6c
test(interactive): ensure private disclosure authorizer errors are sa…
rockyshi1993 Nov 10, 2026
efb4cca
docs(interactive): distinguish low-level M4 Coordinator from verified…
rockyshi1993 Nov 10, 2026
96db51f
fix(interactive): recheck Pending after async Host disclosure approva…
rockyshi1993 Nov 10, 2026
a7262b6
test(interactive): asynchronous disclosure race refuses superseded pu…
rockyshi1993 Nov 10, 2026
96e21b3
fix(interactive): disallow unapproved absent-input names in model-vis…
rockyshi1993 Nov 10, 2026
6cbf3a0
test(interactive): reject model disclosure of nonallowlisted absent i…
rockyshi1993 Nov 10, 2026
8da6cc9
fix(interactive): allow verified expired-proof Ack only for previousl…
rockyshi1993 Nov 10, 2026
df4b6c9
test(interactive): expired signature cannot authorize new receipt but…
rockyshi1993 Nov 10, 2026
0af2a8e
docs(interactive): clarify historical Ack relies on durable ledger pl…
rockyshi1993 Nov 10, 2026
2882f1a
docs(interactive): render Markdown correctly and distinguish expired …
rockyshi1993 Nov 10, 2026
1ae2dfa
test(interactive): reject validly signed but wrong-tenant/program/pol…
rockyshi1993 Nov 10, 2026
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
18 changes: 18 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
179 changes: 179 additions & 0 deletions docs/interactive-host-security.md
Original file line number Diff line number Diff line change
@@ -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.
15 changes: 11 additions & 4 deletions docs/interactive.md
Original file line number Diff line number Diff line change
@@ -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`.
Expand All @@ -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 {
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion scripts/package-files.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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`]),
Expand Down
137 changes: 137 additions & 0 deletions src/interactive/host-attestation.mjs
Original file line number Diff line number Diff line change
@@ -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);
}
4 changes: 4 additions & 0 deletions src/interactive/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Loading