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
10 changes: 10 additions & 0 deletions packages/sdk/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/sdk/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
"ajv": "^8.17.1",
"ajv-draft-04": "^1.0.0",
"js-yaml": "^5.4.1",
"re2js": "^2.8.6",
"yaml": "^2.5.1"
},
"devDependencies": {
Expand Down
6 changes: 4 additions & 2 deletions packages/sdk/src/compile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
import { parse as parseYaml } from 'yaml';
import { parseBudget, toKernelBudget } from './budget.js';
import { bindingDependencies } from './input-binding.js';
import { namedGateFailure } from './named-gates.js';
import { lowerNamedGates } from './named-gate-lowering.js';
import type {
AgentStepSpec,
DeterministicStepSpec,
Expand Down Expand Up @@ -146,7 +148,7 @@ export function compileSpec(spec: unknown): CompiledFlowSpec {
}
}
const validation: ValidationResult = validateSpec(snapshot);
if (!validation.ok) throw new CompileError(validation.errors);
if (!validation.ok) throw new CompileError(validation.errors, namedGateFailure(validation.errors));

const input = snapshot as CompiledFlowSpec;
// Preserve named declarations and selectors through authoring normalization.
Expand Down Expand Up @@ -322,7 +324,7 @@ export function toKernelSpec(flow: FlowSpec): KernelRunSpec {
// reverts the other, and `validateSpec` and `flows check` would both still
// look correct. See ops/reviews/20260903-pr139-repair-0903.md section 10.
...(compiled.triggers?.length ? { triggers: compiled.triggers.map(toKernelTrigger) } : {}),
steps: compiled.steps.map((step) => toKernelStep(resolveNamedAgent(step, compiled.agents))),
steps: lowerNamedGates(compiled.steps).map((step) => toKernelStep(resolveNamedAgent(step, compiled.agents))),
...(compiled.budget !== undefined ? { budget: toKernelBudget(compiled.budget) } : {}),
};
}
Expand Down
2 changes: 2 additions & 0 deletions packages/sdk/src/failure-kinds.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { PLUGIN_FAILURE_KINDS } from './plugin-manifest.js';
import { NAMED_GATE_FAILURE_KINDS } from './named-gates.js';
const SHARED_SPEC_FAILURE_KINDS = ['invalid_spec'] as const;

/** Environment refusal kinds produced after spec validation succeeds. */
const PREFLIGHT_ENVIRONMENT_FAILURE_KINDS = [
...NAMED_GATE_FAILURE_KINDS,
...PLUGIN_FAILURE_KINDS,
'helper_slack.credential_missing',
'helper_slack.mount_required',
Expand Down
7 changes: 7 additions & 0 deletions packages/sdk/src/gate-contract.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { StepSpec } from './spec.js';
import { isNamedGate } from './named-gates.js';

export type JournalGateCheck =
| 'completion'
Expand Down Expand Up @@ -54,6 +55,12 @@ export function acceptsAnyOutput(schema: unknown): boolean {
/** Describe the exact named checks the existing kernel applies to a step. */
export function inspectStepGate(step: StepSpec): StepGateInspection {
const checks: JournalGateCheck[] = [];
if (isNamedGate(step.verification)) {
checks.push('exit_code');
if (step.verification.type === 'references_input') checks.push('output_contains');
if (step.verification.type === 'word_count_bounds') checks.push('json_schema');
return { stepId: step.id, kind: 'data', checks, evaluator: 'kernel', preflightable: true, replayable: true };
Comment thread
cursor[bot] marked this conversation as resolved.
}
if (step.type === 'deterministic') checks.push('exit_code');
if (step.verification?.type === 'output_contains') checks.push('output_contains');
if (step.verification?.type === 'json_schema') checks.push('json_schema');
Expand Down
5 changes: 5 additions & 0 deletions packages/sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@ export type {
KernelVerificationSpec,
LlmStepSpec,
NamedAgentSpec,
NamedDataGate,
ReferencesInputGate,
SubprocessGate,
WordCountBoundsGate,
RegexMatchGate,
OutputContainsGate,
OutputBinding,
OutputVerificationSpec,
Expand Down
147 changes: 147 additions & 0 deletions packages/sdk/src/named-gate-lowering.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import { readFileSync } from 'node:fs';
import { createRequire } from 'node:module';
import { deflateRawSync } from 'node:zlib';
import { bindingDependencies } from './input-binding.js';
import { isNamedGate, regexFlags } from './named-gates.js';
import type { NamedDataGate, StepSpec, VerificationSpec } from './spec.js';

const quote = (text: string): string => `'${text.replaceAll("'", "'\\''")}'`;
let re2Source: string | undefined;

/** Embed the pinned engine in the command: execution does not resolve npm or SDK paths. */
function embeddedRE2(): string {
re2Source ??= deflateRawSync(readFileSync(createRequire(import.meta.url).resolve('re2js'))).toString('base64');
return `const re2={};new Function('exports',require('node:zlib').inflateRawSync(Buffer.from(${JSON.stringify(re2Source)},'base64')).toString())(re2);`;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

RE2 embed exceeds exec argument limit

High Severity

regex_match lowering inlines a deflate+base64 copy of re2js into a single node -e argument. Linux rejects any one execve argument over 128KB (MAX_ARG_STRLEN), and a full RE2 JavaScript engine is typically large enough that the payload exceeds that after encoding. The kernel then fails to spawn the gate with E2BIG. Preflight only probes node, so flows check can pass and the run still dies at the gate. The new Function('exports', …) loader also assumes a single CJS file that never requires siblings or uses module.exports.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit f63aab1. Configure here.

}

/**
* A gate is a nested deterministic step in the same journal/DAG. Keep the
* producer's identity/output, and add a barrier to every dependent (including
* implicit input edges). Internal bindings select the whole envelope so no
* invented output-path schema is needed. Missing paths fail in the gate.
*/
export function lowerNamedGates(steps: readonly StepSpec[]): StepSpec[] {
const used = new Set(steps.map(step => step.id));
const barriers = new Map<string, string>();
for (const step of steps) {
if (!isNamedGate(step.verification)) continue;
let id = `${step.id}.gate`;
while (used.has(id)) id += '.gate';
used.add(id);
barriers.set(step.id, id);
}
if (barriers.size === 0) return [...steps];
const output: StepSpec[] = [];
for (const step of steps) {
const dependencies = [...new Set([...(step.dependsOn ?? []), ...bindingDependencies(step.input)])];
const dependsOn = [...new Set([...dependencies, ...dependencies.flatMap(id => barriers.get(id) ?? [])])];
const producer = dependsOn.length ? { ...step, dependsOn } : step;
const gate = step.verification;
if (!isNamedGate(gate)) {
output.push(producer);
continue;
}
// The original output is still checked for a successful process/worker
// completion. This schema permits an internal whole-output binding.
output.push({ ...producer, verification: { type: 'json_schema', schema: true } });
const input = {
output: { step: step.id },
...(gate.type === 'references_input' ? { reference: step.input![gate.input_key]! } : {}),
Comment thread
cursor[bot] marked this conversation as resolved.
};
output.push({
id: barriers.get(step.id)!, type: 'deterministic',
dependsOn: [...new Set([step.id, ...bindingDependencies(input)])], input,
command: gateCommand(gate, step.type === 'deterministic'),
verification: gateVerification(gate), maxIterations: step.maxIterations ?? 1,
...(step.requirements === undefined ? {} : { requirements: step.requirements }),
});
}
return output;
}

function gateCommand(gate: NamedDataGate, deterministic: boolean): string {
const path = gate.type === 'subprocess_gate' ? gate.from_output
: gate.type === 'word_count_bounds' ? undefined : gate.in_output_at;
// Only compiler-owned code is serialized. Author strings are JSON literals;
// upstream output travels exclusively through FLOWS_INPUT, never shell text.
const setup = `const cp=require('node:child_process');
const input=JSON.parse(process.env.FLOWS_INPUT);
let value=input.output;
const path=${JSON.stringify(path ?? null)};
if(path!==null){for(const key of path){
if(value===null||typeof value!=='object'||!Object.hasOwn(value,key)||
(typeof key==='number'?!Array.isArray(value):Array.isArray(value)))process.exit(1);
value=value[key];
}}else if(${deterministic})value=value.stdout_tail;
Comment thread
cursor[bot] marked this conversation as resolved.
const text=typeof value==='string'?value:JSON.stringify(value);
if(typeof text!=='string')process.exit(1);
`;
let body: string;
switch (gate.type) {
case 'references_input':
body = `const reference=input.reference;
if(typeof reference!=='string'||reference.length===0||!text.includes(reference))process.exit(1);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Reference gate rejects non-string inputs

Medium Severity

references_input validation only checks that input_key names a declared binding. The generated gate then requires input.reference to be a non-empty string and uses includes. A number, boolean, or object binding compiles and preflights, then the gate always exits 1 at runtime.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit f63aab1. Configure here.

process.stdout.write('references_input:pass');`;
break;
case 'subprocess_gate':
body = `if(text.includes('\\0'))process.exit(1);
const result=cp.spawnSync('/bin/sh',['-c',${JSON.stringify(gate.command)}],{
env:{...process.env,INPUT:text},stdio:'inherit'});
process.exit(result.status===0?0:1);`;
break;
case 'word_count_bounds':
body = `const result=cp.spawnSync('wc',['-w'],{input:text,encoding:'utf8',env:{...process.env,LC_ALL:'C'}});
if(result.status!==0)process.exit(1);
const count=result.stdout.trim();
if(!/^[0-9]+$/.test(count))process.exit(1);
process.stdout.write(BigInt(count).toString());`;
break;
case 'regex_match':
body = `${embeddedRE2()}
process.exit(re2.RE2JS.compile(${JSON.stringify(gate.pattern)},${regexFlags(gate.flags ?? '')}).matcher(text).find()?0:1);`;
break;
}
return `node -e ${quote(setup + body)}`;
}

function gateVerification(gate: NamedDataGate): VerificationSpec {
if (gate.type === 'references_input') {
// output_contains cannot carry a runtime binding. The command resolves and
// checks the literal substring, then emits this fixed, unforgeable receipt.
return { type: 'output_contains', value: 'references_input:pass' };
}
if (gate.type === 'word_count_bounds') {
// Deterministic output is an envelope with STRING stdout, not parsed JSON.
// A bounded decimal language enforces the same inclusive numeric range
// with the existing json_schema primitive and retains the actual count.
return { type: 'json_schema', schema: {
type: 'object', required: ['stdout_tail'], properties: {
stdout_tail: { type: 'string', pattern: decimalRange(gate.min ?? 0, gate.max ?? Number.MAX_SAFE_INTEGER) },
},
} };
}
return { type: 'exit_code' };
}

/** Compact decimal range, bounded by 16 digits rather than by range width. */
function decimalRange(min: number, max: number): string {
const patterns: string[] = [];
function between(low: string, high: string, prefix: string): void {
if (low === high) { patterns.push(prefix + low); return; }
if (/^0+$/.test(low) && /^9+$/.test(high)) {
patterns.push(`${prefix}[0-9]{${low.length}}`); return;
}
const a = Number(low[0]), b = Number(high[0]);
if (a === b) { between(low.slice(1), high.slice(1), prefix + a); return; }
const rest = low.length - 1;
between(low.slice(1), '9'.repeat(rest), prefix + a);
if (a + 1 <= b - 1) patterns.push(`${prefix}[${a + 1}-${b - 1}]${rest ? `[0-9]{${rest}}` : ''}`);
between('0'.repeat(rest), high.slice(1), prefix + b);
}
for (let digits = String(min).length; digits <= String(max).length; digits++) {
const low = digits === String(min).length ? String(min) : '1' + '0'.repeat(digits - 1);
const high = digits === String(max).length ? String(max) : '9'.repeat(digits);
between(low, high, '');
}
return `^(?:${patterns.join('|')})$`;
}
76 changes: 76 additions & 0 deletions packages/sdk/src/named-gates.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { RE2JS } from 're2js';
import type { NamedDataGate, VerificationSpec } from './spec.js';

export const NAMED_GATE_FAILURE_KINDS = [
'unknown_gate_kind', 'gate_pattern_invalid', 'gate_command_missing', 'gate_bound_invalid',
] as const;
export type NamedGateFailureKind = typeof NAMED_GATE_FAILURE_KINDS[number];

export const NAMED_GATE_KEYS: Record<NamedDataGate['type'], readonly string[]> = {
references_input: ['type', 'input_key', 'in_output_at'],
subprocess_gate: ['type', 'command', 'from_output'],
word_count_bounds: ['type', 'min', 'max'],
regex_match: ['type', 'pattern', 'in_output_at', 'flags'],
};

export function isNamedGate(gate: VerificationSpec | undefined): gate is NamedDataGate {
return gate !== undefined && Object.hasOwn(NAMED_GATE_KEYS, gate.type);
}

export function regexFlags(flags: string): number {
return (flags.includes('i') ? RE2JS.CASE_INSENSITIVE : 0)
| (flags.includes('m') ? RE2JS.MULTILINE : 0)
| (flags.includes('s') ? RE2JS.DOTALL : 0);
}

/** Called only on snapshotted data, including by the public validator. */
export function namedGateErrors(gate: Record<string, unknown>, input: unknown, at: string): string[] {
const errors: string[] = [];
for (const key of ['in_output_at', 'from_output']) {
const path = gate[key];
if (path !== undefined && (!Array.isArray(path) || !path.every(segment =>
typeof segment === 'string' || (Number.isSafeInteger(segment) && (segment as number) >= 0)))) {
errors.push(`${at}.${key}: expected an array of object keys or non-negative integer indices`);
}
}
switch (gate.type) {
case 'references_input':
if (typeof gate.input_key !== 'string' || !gate.input_key.trim()
|| input === null || typeof input !== 'object' || !Object.hasOwn(input, gate.input_key)) {
errors.push(`${at}.input_key: must name a declared input binding`);
}
break;
case 'subprocess_gate':
if (typeof gate.command !== 'string' || !gate.command.trim() || gate.command.includes('\0')) {
errors.push(`${at}.command: gate_command_missing: expected a non-empty shell command`);
}
break;
case 'word_count_bounds': {
const { min, max } = gate;
if ([min, max].some(n => n !== undefined && (!Number.isSafeInteger(n) || (n as number) < 0))
|| (typeof min === 'number' && typeof max === 'number' && min > max)) {
errors.push(`${at}: gate_bound_invalid: min and max must be non-negative safe integers with min <= max`);
}
break;
}
case 'regex_match':
try {
if (typeof gate.pattern !== 'string' || gate.pattern.length > 8192) {
throw new Error('pattern must be a string of at most 8192 characters');
}
const flags = gate.flags ?? '';
if (typeof flags !== 'string' || !/^(?!.*(.).*\1)[ims]*$/.test(flags)) {
throw new Error('flags must be a non-repeating subset of i, m, s');
}
RE2JS.compile(gate.pattern, regexFlags(flags));
} catch (error) {
errors.push(`${at}: gate_pattern_invalid: ${error instanceof Error ? error.message : 'invalid RE2 pattern'}`);
}
break;
}
return errors;
}

export function namedGateFailure(errors: readonly string[]): NamedGateFailureKind | undefined {
return NAMED_GATE_FAILURE_KINDS.find(kind => errors.some(error => error.includes(`${kind}:`)));
}
18 changes: 18 additions & 0 deletions packages/sdk/src/preflight.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type { TriggerSource } from '@relayflows/surface';
import { acceptsAnyOutput, inspectStepGate, type StepGateInspection } from './gate-contract.js';
import { compileSpec, CompileError } from './compile.js';
import { helperCall } from './yaml-helpers.js';
import { isNamedGate, NAMED_GATE_FAILURE_KINDS, type NamedGateFailureKind } from './named-gates.js';
import type {
PreflightFailureKind,
PreflightWarningKind,
Expand Down Expand Up @@ -187,6 +188,8 @@ function preflightSync(flow: unknown, options: PreflightOptions): PreflightResul
// constructs preflight input through a different path still classifies.
const kind: PreflightDiagnostic['kind'] = error instanceof CompileError && error.kind === 'budget_syntax_invalid'
? 'budget_syntax_invalid'
: error instanceof CompileError && NAMED_GATE_FAILURE_KINDS.includes(error.kind as NamedGateFailureKind)
? error.kind as NamedGateFailureKind
: error instanceof BudgetSyntaxError
? 'budget_syntax_invalid'
: 'invalid_spec';
Expand Down Expand Up @@ -238,6 +241,7 @@ function preflightSync(flow: unknown, options: PreflightOptions): PreflightResul
}

for (const step of compiled.steps) {
probeNamedGate(step, options.probes, diagnostics);
warnOnVacuousGate(step, diagnostics);
warnOnUnprovableEffects(step, options.probes, diagnostics);
if (step.type === 'deterministic') continue;
Expand Down Expand Up @@ -589,6 +593,20 @@ function firstCommandWord(command: string): string | undefined {
return match?.[1] ?? match?.[2] ?? match?.[3];
}

function probeNamedGate(step: StepSpec, probes: PreflightProbes, diagnostics: PreflightDiagnostic[]): void {
const gate = step.verification;
if (!isNamedGate(gate)) return;
const commands = ['node', ...(gate.type === 'word_count_bounds' ? ['wc'] : [])];
if (gate.type === 'subprocess_gate') commands.push(firstCommandWord(gate.command) ?? '');
for (const command of commands) {
let exists = false;
try { exists = command !== '' && probes.command(command); } catch { /* Fail closed on an unprovable gate. */ }
if (exists) continue;
diagnostics.push({ severity: 'refusal', kind: 'gate_command_missing', stepId: step.id,
message: `Step "${step.id}" ${gate.type} command "${command}" does not resolve as an executable.` });
}
}

/** Helper preflight never evaluates the authored body. Dynamic uses are checked at call time. */
export function preflightHelpers(
definition: { header?: { tools?: { slack?: boolean } }; body?: Function },
Expand Down
Loading
Loading