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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions scripts/package-files.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ export const packageFiles = [
...['contracts', 'control', 'skill-ref'].map(name => [`src/shared/${name}.mjs`, `dist/shared/${name}.mjs`]),
...['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'].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'].map(name => [`types/${name}.d.ts`, `dist/${name}.d.ts`]),
Expand Down
153 changes: 153 additions & 0 deletions src/compiler/interactive/emit.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import { createHash } from 'node:crypto';
import { readFileSync } from 'node:fs';
import { evaluateWhenAst } from '../../when/evaluate.mjs';
import { validateInteractiveIR } from './validate-ir.mjs';

const compilerVersion = JSON.parse(readFileSync(new URL('../../../package.json', import.meta.url), 'utf8')).version;
const SHA = value => 'sha256:' + createHash('sha256').update(value).digest('hex');

// The generated program includes the original Standard evaluator *implementation*.
// Only requireBoolean is a tiny standalone wrapper for its existing free binding.
function requireBoolean(value) {
if (typeof value !== 'boolean') throw Object.assign(new Error('When operand and final result must be boolean'), { code: 'FK_R002' });
return value;
}

/** Emits executable, synchronous JavaScript. No application code is run while compiling. */
function advance(view, event) {
if (!view || !event || event.schemaVersion !== 1 || view.blockId !== event.forBlockId) {
throw new TypeError('Event does not target the current checkpoint block');
}
const blockId = view.blockId;
const phase = view.phase;
const block = programTable.blocks.find(item => item.id === blockId);
if (!block || block.phase !== phase || !block.eventKinds.includes(event.kind)) {
throw new TypeError('Uncertified event or invalid stage');
}
const nodeId = block.nodeId;
const record = programTable.nodes[nodeId];
const none = { kind: 'none' };
const proposal = (kind, toBlockId, stackEffect = none, mergeIntent = none) =>
({ kind, fromBlockId: blockId, nodeId, toBlockId, stackEffect, mergeIntent });
const fail = code => ({
kind: 'raise-error', fromBlockId: blockId, nodeId,
causeCode: code || 'FK_R002', stackEffect: none, mergeIntent: none,
});
if (event.kind === 'deterministic-failure') return fail(event.error?.code);
const to = (node, name) => node + '::' + name;
if (phase === 'enter') {
if (record.kind === 'agent') {
if (!Number.isSafeInteger(event.activation) || event.activation < 1) {
throw new TypeError('Missing certified Agent activation');
}
const frame = {
kind: 'agent', nodeId, activation: event.activation,
attempt: 1, phase: 'prepare', reasons: [], overviewStep: event.activation,
};
return proposal('move', to(nodeId, 'prepare'), {kind: 'push', frame});
}
return proposal('move', to(nodeId, 'route'));
}
if (record.kind === 'condition' && phase === 'route') {
try {
for (const branch of record.metadata.branches) {
if (evaluateWhenAst(branch.ast, view.state)) {
return proposal('move', to(branch.next, 'enter'));
}
}
} catch (error) {
if (error?.code === 'FK_R002') return fail('FK_R002');
throw error; // trusted Coordinator classifies unexpected program failures
}
return fail('FK_R002');
}
if (record.kind === 'terminal' && phase === 'route') {
return {
kind: 'terminal', fromBlockId: blockId, nodeId,
terminal: {status: record.metadata.status, nodeId},
stackEffect: none, mergeIntent: none,
};
}
if (record.kind !== 'agent') throw new TypeError('Unsupported P0-A block');
const frame = view.controlStack[view.controlStack.length - 1];
if (!frame || frame.kind !== 'agent' || frame.nodeId !== nodeId) {
throw new TypeError('Missing certified Agent control frame');
}
if (phase === 'prepare') return proposal('prepare-pending', to(nodeId, 'dispatch-ready'));
if (phase === 'dispatch-ready') return proposal('publish-pending', to(nodeId, 'awaiting'));
if (phase === 'validate') {
if (event.kind === 'validation-passed') {
if (view.candidate?.kind !== 'agent') throw new TypeError('Verified Agent candidate not committed');
return proposal('commit-candidate', to(nodeId, 'commit'));
}
if (event.kind === 'validation-rejected' || event.kind === 'agent-call-failed') {
const attempts = record.metadata.retries + 1;
if (frame.attempt >= attempts) return fail('FK_R004');
return proposal('retry-agent', to(nodeId, 'prepare'),
{kind: 'update', expectedKind: 'agent', expectedNodeId: nodeId,
field: 'attempt', value: frame.attempt + 1});
}
throw new TypeError('Agent validation event is not ready for Program advancement');
}
if (phase === 'commit') {
if (view.candidate?.kind !== 'agent') throw new TypeError('Missing committed Agent candidate');
return proposal('commit-state', to(nodeId, 'route'), none,
{kind:'commit-candidate', requestId:view.candidate.requestId});
}
if (phase === 'route') {
return proposal('move', to(record.metadata.next, 'enter'),
{kind:'pop', expectedKind:'agent', expectedNodeId:nodeId});
}
throw new TypeError('Illegal Program advancement at awaiting/unknown phase');
}
function freezeData(value) {
if (value && typeof value === 'object') {
for (const key of Object.keys(value)) freezeData(value[key]);
Object.freeze(value);
}
return value;
}

export function emitInteractive(ir, options = {}) {
const validation = validateInteractiveIR(ir);
if (!validation.ok) {
return Object.freeze({ code:null, programIdentity:null, diagnostics:validation.diagnostics });
}
if (options === null || typeof options !== 'object' || Array.isArray(options)) {
throw new TypeError('Emit options must be an object');
}
const manifest = {
artifactFormat:'FlowkitInteractiveProgramV1',
identity:{
workflowId: ir.workflowId, workflowVersion:ir.workflowVersion,
sourceHash: 'sha256:' + ir.sourceHash, compilerVersion,
programAbiVersion:1, semanticsVersion:'interactive-state-machine-v1',
checkpointSchemaVersion:1, canonicalizationVersion:'jcs-rfc8785-v1',
implementedStage:'P0-A',
},
};
const agents = Object.values(ir.nodes).some(node => node.kind === 'agent');
const bindings = agents ? [
{id:'runtime.validateResult',version:'1',mode:'pure-local',kind:'agent-validation'},
{id:'runtime.skillResolver',version:'1',mode:'trusted-read',kind:'skill'},
] : [];
const table = {
schemaVersion:1, entryBlockId:ir.entryBlockId, blocks:ir.blocks,
nodes:ir.nodes, requiredBindings:bindings,
};
const code = [
'// @generated by FlowKit Interactive — P0-A / Program ABI V1',
'// This trusted compiler output MUST be loaded from verified immutable bytes.',
requireBoolean.toString(),
evaluateWhenAst.toString(),
freezeData.toString(),
'export const interactiveProgram = freezeData(' + JSON.stringify(manifest) + ');',
'export const programTable = freezeData(' + JSON.stringify(table) + ');',

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Embedded JSON mishandles proto keys

High Severity

emitInteractive splices JSON.stringify(manifest) and JSON.stringify(table) into generated object literals. A quoted "__proto__" key is a prototype setter in JavaScript, so own __proto__ keys in Agent output schemas are dropped and the object's prototype is replaced. loadInteractiveProgram then rejects the table as untrusted metadata, so programs that M1 already preserves fail to load.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 7578cc7. Configure here.

'export ' + advance.toString(),
'',
].join('\n');
const programIdentity = Object.freeze({
...manifest.identity, artifactHash:SHA(Buffer.from(code, 'utf8')),
});
return Object.freeze({code, programIdentity, diagnostics:Object.freeze([])});
}
58 changes: 58 additions & 0 deletions src/interactive/compile.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import {readFile} from 'node:fs/promises';
import {mkdir,open,rename,unlink} from 'node:fs/promises';
import {randomUUID} from 'node:crypto';
import path from 'node:path';
import {analyzeSource} from '../compiler/analyze.mjs';
import {lowerInteractive} from '../compiler/interactive/lower.mjs';
import {emitInteractive} from '../compiler/interactive/emit.mjs';

/** Internal M2 compile API; the exported SDK subpath is introduced in M4. */
export async function compileInteractive(file, options) {
if (typeof file !== 'string' || !/\.workflow\.(js|ts)$/.test(file)) {
throw new TypeError('Expected .workflow.js or .workflow.ts file');
}
if (!options || typeof options !== 'object' || Array.isArray(options)) throw new TypeError('Compile options object required');
const known = new Set(['output','outDir','featureStage','targetVersion']);
for (const key of Reflect.ownKeys(options)) {
if (typeof key !== 'string' || !known.has(key)) throw new TypeError('Unknown Interactive compile option');
if (!Object.hasOwn(Object.getOwnPropertyDescriptor(options,key),'value')) throw new TypeError('Compile option accessors forbidden');
}
const snapshotOptions = Object.fromEntries(Object.keys(options).map(key=>[key,options[key]]));
if (!['memory','file'].includes(snapshotOptions.output)) throw new TypeError('Invalid compile output');
if (snapshotOptions.featureStage !== 'P0-A') throw new TypeError('Only P0-A is implemented');
if (snapshotOptions.targetVersion !== undefined && snapshotOptions.targetVersion !== 'interactive-state-machine-v1') {
throw new TypeError('Unsupported semantics version');
}
if (snapshotOptions.outDir !== undefined && typeof snapshotOptions.outDir !== 'string') throw new TypeError('outDir must be a string');
const cwd = process.cwd();
const filename = path.resolve(cwd,file);
const outDir = snapshotOptions.outDir === undefined ? path.dirname(filename) : path.resolve(cwd,snapshotOptions.outDir);
const snapshot = await readFile(filename); // original source bytes, never imported
const analysis = await analyzeSource(snapshot,{
filename, projectRoot:path.dirname(filename),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Compiler uses wrong project root

Medium Severity

compileInteractive passes projectRoot: path.dirname(filename) into analyzeSource, which skips resolveProjectRoot's walk to package.json. Skill checks then resolve skills/ next to the workflow file instead of the project root. Agent workflows in a subdirectory fail with missing-skill diagnostics, and callers cannot override projectRoot or skillsRoot.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 7578cc7. Configure here.

});
const lowered = lowerInteractive(analysis,{featureStage:options.featureStage});
if (!lowered.ir) return {code:'',diagnostics:[...lowered.diagnostics]};
const emitted = emitInteractive(lowered.ir);
if (emitted.diagnostics.some(d => d.severity === 'error')) {
return {code:'',diagnostics:[...lowered.diagnostics,...emitted.diagnostics]};
}
const result = {code:emitted.code,diagnostics:[...lowered.diagnostics],programIdentity:emitted.programIdentity};
if (snapshotOptions.output === 'file') {
const stem = path.basename(filename).replace(/\.workflow\.(js|ts)$/,'');
const artifactPath = path.join(outDir,stem + '.interactive.mjs');
await mkdir(outDir,{recursive:true});
const temporary = path.join(outDir,'.' + stem + '.' + randomUUID() + '.tmp');
const handle = await open(temporary,'wx');
try {
await handle.writeFile(emitted.code,'utf8'); await handle.sync(); await handle.close();
await rename(temporary,artifactPath);
} catch(e) {
await handle.close().catch(()=>{});
await unlink(temporary).catch(()=>{});
throw e;
}
result.artifactPath = artifactPath;
}
return result;
}
13 changes: 13 additions & 0 deletions src/interactive/identity.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import {createHash} from 'node:crypto';

export function sha256Bytes(bytes) {
if (!(bytes instanceof Uint8Array)) throw new TypeError('Expected immutable program byte snapshot');
return 'sha256:' + createHash('sha256').update(bytes).digest('hex');
}

export function assertSha256(hash) {
if (typeof hash !== 'string' || !/^sha256:[0-9a-f]{64}$/.test(hash)) {
throw new TypeError('Invalid SHA-256 identity');
}
return hash;
}
99 changes: 99 additions & 0 deletions src/interactive/load.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import {parse} from 'acorn';
import {sha256Bytes,assertSha256} from './identity.mjs';
import {validateInteractiveIR,agentFrameRule} from '../compiler/interactive/validate-ir.mjs';

/** A verified program is trusted compiler output, NOT an untrusted JavaScript sandbox. */
function ensureNoImports(ast) {
const pending = [ast];
while (pending.length) {
const node = pending.pop();
if (!node || typeof node !== 'object') continue;
if (['ImportDeclaration','ImportExpression','ExportAllDeclaration'].includes(node.type)) {
throw new TypeError('Interactive Program imports are forbidden');
}
if (node.type === 'ExportNamedDeclaration' && node.source) {
throw new TypeError('Re-exports are forbidden');
}
for (const value of Object.values(node)) {
if (Array.isArray(value)) {
for (const item of value) if (item?.type) pending.push(item);
} else if (value?.type) {
pending.push(value);
}
}
}
}
function ownJSON(value, depth = 0, seen = new Set()) {
if (depth > 64) throw new TypeError('Excessive Program metadata depth');
if (value === null || ['string','number','boolean'].includes(typeof value)) {
if (typeof value === 'number' && (!Number.isFinite(value) || Object.is(value,-0))) throw new TypeError('Invalid number');
return value;
}
if (!value || typeof value !== 'object' || seen.has(value)) throw new TypeError('Invalid Program metadata');
const plain = Array.isArray(value) || Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null;
if (!plain) throw new TypeError('Untrusted Program metadata prototype');
seen.add(value);
const result = Array.isArray(value) ? [] : Object.create(null);
const props = Reflect.ownKeys(value);
for (const key of props) {
if (typeof key !== 'string') throw new TypeError('Program metadata must have string keys');
const descriptor = Object.getOwnPropertyDescriptor(value,key);
if (!descriptor || !Object.hasOwn(descriptor,'value')) throw new TypeError('Program metadata has accessor');
if (Array.isArray(value) && key === 'length') continue;
if (Array.isArray(value) && !/^(0|[1-9]\d*)$/.test(key)) throw new TypeError('Invalid Program metadata array key');
if (!Array.isArray(value) && (key === 'constructor' || key === 'prototype')) throw new TypeError('Unsafe metadata key');

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Loader rejects valid schema keys

Medium Severity

ownJSON throws on constructor or prototype keys while copying programTable. Those names are valid JSON Schema property keys and are already preserved by the Standard analyzer, dataflow, and M1 IR. A compiled Program whose Agent output lists them cannot be loaded even though the copy target is a null-prototype object.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 7578cc7. Configure here.

const own = ownJSON(descriptor.value, depth+1,seen);
Object.defineProperty(result,key,{value:own,enumerable:true,writable:false,configurable:false});
}
if (Array.isArray(value) && Object.keys(value).length !== value.length) throw new TypeError('Sparse Program metadata array');
seen.delete(value);
return Object.freeze(result);
}

/** Loads exactly the verified byte snapshot, regardless of file-name swaps. */
export async function loadInteractiveProgram(bytes, expectedArtifactHash) {
if (!(bytes instanceof Uint8Array)) throw new TypeError('Program bytes must be Uint8Array');
assertSha256(expectedArtifactHash);
const immutable = Buffer.from(bytes);
if (immutable.byteLength > 4 * 1024 * 1024) throw new TypeError('Oversize Interactive Program');
if (sha256Bytes(immutable) !== expectedArtifactHash) throw new Error('FK_I_PROGRAM_MISMATCH: artifact hash differs');
const code = new TextDecoder('utf-8', {fatal:true,ignoreBOM:true}).decode(immutable);
const ast = parse(code,{ecmaVersion:'latest',sourceType:'module'});
ensureNoImports(ast);
const advances = ast.body.filter(n => n.type === 'ExportNamedDeclaration' && n.declaration?.type === 'FunctionDeclaration' && n.declaration.id.name === 'advance');
if (advances.length !== 1) throw new TypeError('Exactly one exported advance function required');
const originalBody = code.slice(advances[0].declaration.start,advances[0].declaration.end);
const module = await import('data:text/javascript;base64,' + immutable.toString('base64'));
if (typeof module.advance !== 'function' || module.advance.constructor.name === 'AsyncFunction'
|| Function.prototype.toString.call(module.advance) !== originalBody) {
throw new TypeError('Loaded advance function differs from verified bytes');
}
if (Object.keys(module).sort().join(',') !== 'advance,interactiveProgram,programTable') {
throw new TypeError('Unexpected Interactive Program exports');
}
const manifest = ownJSON(module.interactiveProgram);
const table = ownJSON(module.programTable);
if (manifest.artifactFormat !== 'FlowkitInteractiveProgramV1'
|| manifest.identity.programAbiVersion !== 1
|| manifest.identity.semanticsVersion !== 'interactive-state-machine-v1'
|| manifest.identity.implementedStage !== 'P0-A'
|| table.schemaVersion !== 1 || !Array.isArray(table.blocks)
|| !table.blocks.length || !table.nodes || !Array.isArray(table.requiredBindings)) {
throw new TypeError('Invalid Interactive Program ABI V1');
}
// Validate the ProgramTable as a self-contained compiler contract.
// No source files or runtime lowering are consulted.
if (!/^sha256:[0-9a-f]{64}$/.test(manifest.identity.sourceHash)) throw new TypeError('Invalid Program source identity');
const staticIR = {
schemaVersion:1,featureStage:'P0-A',sourceHash:manifest.identity.sourceHash.slice(7),
entryBlockId:table.entryBlockId,blocks:table.blocks,nodes:table.nodes,
frameRules:Object.keys(table.nodes).filter(id=>table.nodes[id].kind==='agent').map(id=>agentFrameRule(id)),
};
const validated = validateInteractiveIR(staticIR);
if (!validated.ok) throw new TypeError('FK_I_INVALID_TRANSITION: invalid ProgramTable');
return Object.freeze({
artifactHash:expectedArtifactHash,
interactiveProgram:manifest,programTable:table,
advance:module.advance,
});
}
Loading