Skip to content

Commit 27438a0

Browse files
cemililikclaude
andcommitted
feat(core): the {{ … }} interpolation engine + static secret-taint gate (1.L2)
Implement workstream 1.L2 — the runtime `{{ … }}` resolver every node's input flows through, plus the parse-time secret-taint gate — on top of 1.L's lexer. Runtime resolver (`resolveTemplate` / `resolveContext`): - A pipe-filter registry — `json`, `length`, `default(…)`, and `read_file`. The engine stays pure (CLAUDE.md rule 5): `read_file` calls a host-injected `ResolverCapabilities.readFile` seam, never `node:fs`. An absent capability is a typed error, not a crash. - A safe `.prop` / `[n]` / `["k"]` path accessor — no `eval`/`new Function`, own-property only (no prototype reads), quote-aware bracket scanning. - Eager-once, `Object.freeze`d context: each `context` entry resolves a single time, in declared order, into a deterministic snapshot (re-resolve is identical — the property checkpoint/resume, 1.R, relies on). - Every failure is a typed, secret-free `InterpolationError` (code discriminant, the offending `{{ … }}` as location; a host path stays on `cause`, off the message). Static, parse-time gates wired into `parseWorkflow` (reads structure only, never a value): - `analyzeSecretTaint` (ADR-0029(c)): a `secret`-typed input — or anything transitively derived from one through a `context` entry OR an `input` default — is rejected from agent/human text via a field-named `WorkflowSecretLeakError`. The taint closes to a fixpoint over both laundering intermediates. - `analyzeContextReferences`: a `context` value that reads `{{run.outputs[…]}}` is a parse error (context resolves before any node runs). `ReferenceSite` now carries a `category` so the taint gate can target text sites. Scope (1.L2 only): node-existence / `$ref` / `agent_ref` / handle resolution and the cycle check remain 1.M; `condition`/`transform`/`merge_fn` JS remains the 1.AB sandbox. Findings from an adversarial review pass folded in: the input-default taint laundering path, a `json` filter that let a circular/BigInt `JSON.stringify` throw escape the typed-error contract, a `getByPath` prototype-member read and a quoted-key-with-`]` mis-parse, plus test-honesty and doc fixes. Refs: ADR-0029, ADR-0023, ADR-0035; docs/reference/contracts/workflow-yaml-spec.md §Context-and-interpolation. No new runtime dependency (engine-deps unchanged). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent e5d0973 commit 27438a0

15 files changed

Lines changed: 1495 additions & 53 deletions

File tree

packages/core/src/errors.test.ts

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
import { describe, expect, it } from 'vitest';
22

3-
import { WorkflowSyntaxError, WorkflowValidationError, type WorkflowIssue } from './errors.js';
3+
import {
4+
InterpolationError,
5+
WorkflowSecretLeakError,
6+
WorkflowSyntaxError,
7+
WorkflowValidationError,
8+
type SecretLeak,
9+
type WorkflowIssue,
10+
} from './errors.js';
411

512
const issue = (field: string): WorkflowIssue => ({ field, message: 'bad' });
613

@@ -46,3 +53,60 @@ describe('WorkflowSyntaxError', () => {
4653
expect(err.column).toBeUndefined();
4754
});
4855
});
56+
57+
describe('WorkflowSecretLeakError', () => {
58+
const leak = (over: Partial<SecretLeak> = {}): SecretLeak => ({
59+
location: 'node `n`.prompt_template',
60+
secret: 'inputs.api_key',
61+
...over,
62+
});
63+
64+
it('summarizes the first leak, names the field/symbol, and cites the ADR', () => {
65+
const err = new WorkflowSecretLeakError([leak()]);
66+
expect(err.code).toBe('secret_interpolation');
67+
expect(err.message).toBe(
68+
'node `n`.prompt_template interpolates the secret `inputs.api_key` — secrets are rejected from agent/human text (ADR-0029)',
69+
);
70+
});
71+
72+
it('includes a `via` hop and a plural "more" suffix', () => {
73+
const err = new WorkflowSecretLeakError([
74+
leak({ secret: 'ctx.creds', via: 'inputs.api_key' }),
75+
leak(),
76+
leak(),
77+
]);
78+
expect(err.message).toContain('the secret `ctx.creds` (via `inputs.api_key`)');
79+
expect(err.message).toContain('(and 2 more leaks)');
80+
});
81+
82+
it('uses a singular "more leak" suffix for exactly two leaks', () => {
83+
expect(new WorkflowSecretLeakError([leak(), leak()]).message).toContain('(and 1 more leak)');
84+
});
85+
86+
it('summarizes an empty leak list defensively', () => {
87+
expect(new WorkflowSecretLeakError([]).message).toBe('secret interpolation rejected');
88+
});
89+
90+
it('omits a `via` that equals the secret (a direct, un-laundered reference)', () => {
91+
const err = new WorkflowSecretLeakError([leak({ via: 'inputs.api_key' })]);
92+
expect(err.message).not.toContain('(via');
93+
});
94+
});
95+
96+
describe('InterpolationError', () => {
97+
it('carries a typed code and the offending reference as location', () => {
98+
const err = new InterpolationError('unknown_filter', 'unknown filter `nope`', {
99+
location: '{{inputs.x | nope}}',
100+
});
101+
expect(err.code).toBe('unknown_filter');
102+
expect(err.location).toBe('{{inputs.x | nope}}');
103+
expect(err.cause).toBeUndefined();
104+
});
105+
106+
it('keeps a host error on cause and omits location when not given', () => {
107+
const cause = new Error('inner');
108+
const err = new InterpolationError('read_file_failed', 'read failed', { cause });
109+
expect(err.cause).toBe(cause);
110+
expect(err.location).toBeUndefined();
111+
});
112+
});

packages/core/src/errors.ts

Lines changed: 94 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,20 @@
11
/**
2-
* Typed, discriminated errors thrown by the engine's `WorkflowYAMLParser` (1.L). They mirror the
3-
* `@relavium/llm` `LlmConfigError` pattern — a base class with a stable `code` discriminant and
4-
* structured, secret-free context, narrowed on `code` and never on `message`
5-
* (docs/standards/error-handling.md). The user-facing fields — `message`, `issues`, `field`, the
6-
* `source` label, and the line/column — name the offending field/node and never carry an authored
7-
* value, a stack trace, or an absolute path. An internal `cause`, where attached, is a non-secret
8-
* diagnostic (a YAML rule, never the source text) kept for logs per error-handling.md; the raw
9-
* ZodError is deliberately NOT attached, as it can carry an authored `received` value.
2+
* Typed, discriminated errors thrown by the engine's `WorkflowYAMLParser` (1.L) and the `{{ … }}`
3+
* interpolation engine (1.L2). They mirror the `@relavium/llm` `LlmConfigError` pattern — a base
4+
* class with a stable `code` discriminant and structured, secret-free context, narrowed on `code`
5+
* and never on `message` (docs/standards/error-handling.md). The user-facing fields — `message`,
6+
* `issues`, `field`, `leaks`, the `source` label, and the line/column — name the offending
7+
* field/node/symbol and never carry an authored value, a stack trace, or an absolute path. An
8+
* internal `cause`, where attached, is a non-secret diagnostic (a YAML rule, or a host `readFile`
9+
* error) kept for logs per error-handling.md; the raw ZodError is deliberately NOT attached, as it
10+
* can carry an authored `received` value.
11+
*
12+
* Two families: the parse-time {@link WorkflowParseError} (syntax / schema / secret-leak), thrown by
13+
* `parseWorkflow`; and the runtime {@link InterpolationError}, thrown while resolving a template
14+
* against a run scope.
1015
*/
1116

12-
export type WorkflowParseErrorCode = 'invalid_yaml' | 'schema_validation';
17+
export type WorkflowParseErrorCode = 'invalid_yaml' | 'schema_validation' | 'secret_interpolation';
1318

1419
/** One field-named validation problem — the unit the VS Code language server later renders. */
1520
export interface WorkflowIssue {
@@ -19,6 +24,19 @@ export interface WorkflowIssue {
1924
readonly message: string;
2025
}
2126

27+
/**
28+
* One rejected secret interpolation (ADR-0029(c)). Every field is a *name* — an authored input name,
29+
* context key, or field locator — never a resolved value, so the finding is safe to surface and log.
30+
*/
31+
export interface SecretLeak {
32+
/** Where the secret was interpolated — e.g. ``node `scan`.prompt_template``. */
33+
readonly location: string;
34+
/** The tainted symbol referenced at that site — e.g. `inputs.api_key` or `ctx.creds` (a name). */
35+
readonly secret: string;
36+
/** The deeper tainted symbol, when laundered through a `context` entry — e.g. `inputs.api_key`. */
37+
readonly via?: string;
38+
}
39+
2240
/** Base for every parser error — callers narrow on `code`, never on `message`. */
2341
export abstract class WorkflowParseError extends Error {
2442
abstract readonly code: WorkflowParseErrorCode;
@@ -78,3 +96,70 @@ function summarize(issues: readonly WorkflowIssue[]): string {
7896
const more = rest > 0 ? ` (and ${rest} more issue${suffix})` : '';
7997
return `${first.field}: ${first.message}${more}`;
8098
}
99+
100+
/**
101+
* A `secret`-typed value — or anything transitively derived from one through a `context` entry —
102+
* reaches agent/human text (`prompt_template`, `system_prompt[_append]`, `message_template`,
103+
* `assignee`). Rejected at parse so a run never starts on it (ADR-0029(c)). The message names the
104+
* offending field and the tainted symbol; it never carries the secret's value.
105+
*/
106+
export class WorkflowSecretLeakError extends WorkflowParseError {
107+
readonly code = 'secret_interpolation';
108+
readonly leaks: readonly SecretLeak[];
109+
110+
constructor(leaks: readonly SecretLeak[], opts?: { source?: string; cause?: unknown }) {
111+
super(summarizeLeaks(leaks), opts?.source, opts?.cause);
112+
this.name = 'WorkflowSecretLeakError';
113+
this.leaks = leaks;
114+
}
115+
}
116+
117+
function summarizeLeaks(leaks: readonly SecretLeak[]): string {
118+
const first = leaks[0];
119+
if (first === undefined) {
120+
return 'secret interpolation rejected';
121+
}
122+
const via =
123+
first.via !== undefined && first.via !== first.secret ? ` (via \`${first.via}\`)` : '';
124+
const rest = leaks.length - 1;
125+
const suffix = rest === 1 ? '' : 's';
126+
const more = rest > 0 ? ` (and ${rest} more leak${suffix})` : '';
127+
return `${first.location} interpolates the secret \`${first.secret}\`${via} — secrets are rejected from agent/human text (ADR-0029)${more}`;
128+
}
129+
130+
/** Stable discriminant for a runtime interpolation failure — callers narrow on `code`, not `message`. */
131+
export type InterpolationErrorCode =
132+
| 'unresolved_reference' // a `{{ … }}` head/path resolved to no value (and no `| default(…)` rescued it)
133+
| 'unknown_namespace' // a reference reads from a namespace the resolver does not serve (e.g. `secrets`)
134+
| 'unknown_filter' // a pipe filter name is not in the registry
135+
| 'filter_arity' // a filter was given the wrong number of arguments
136+
| 'filter_type' // a filter cannot apply to the value's type (e.g. `length` on a number)
137+
| 'unserializable' // a reference resolved to an object/array used as text without a `| json` filter
138+
| 'invalid_path' // a malformed property/index access after the head
139+
| 'read_file_unavailable' // the `read_file` filter ran without a host `readFile` capability
140+
| 'read_file_failed'; // the host `readFile` capability threw (cause kept for logs, off the message)
141+
142+
/**
143+
* A runtime interpolation failure raised while *resolving* `{{ … }}` against a run scope (1.L2) —
144+
* distinct from the parse-time {@link WorkflowParseError} family. User-facing and secret-free: the
145+
* message names the offending reference (its verbatim `{{ … }}`) and never a resolved value; an
146+
* absolute path from a host `readFile` failure stays on the `cause` (for logs), never in the message.
147+
*/
148+
export class InterpolationError extends Error {
149+
readonly code: InterpolationErrorCode;
150+
/** The offending `{{ … }}` occurrence, verbatim — names the reference, never a resolved value. */
151+
readonly location?: string;
152+
153+
constructor(
154+
code: InterpolationErrorCode,
155+
message: string,
156+
opts?: { location?: string; cause?: unknown },
157+
) {
158+
super(message, opts?.cause === undefined ? undefined : { cause: opts.cause });
159+
this.name = 'InterpolationError';
160+
this.code = code;
161+
if (opts?.location !== undefined) {
162+
this.location = opts.location;
163+
}
164+
}
165+
}

packages/core/src/index.ts

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,24 @@
66
* Code extension host, and Bun alike.
77
*/
88

9-
// WorkflowYAMLParser (1.L) — parse + validate a `.relavium.yaml` string into a typed definition.
9+
// WorkflowYAMLParser (1.L / 1.L2) — parse + validate + static interpolation gates into a typed def.
1010
export { parseWorkflow } from './parser.js';
1111
export type { WorkflowDefinition, ParseWorkflowOptions } from './parser.js';
1212

1313
// Typed, field-named, secret-free parse/validation errors — narrow on `code`, never on `message`.
14-
export { WorkflowParseError, WorkflowSyntaxError, WorkflowValidationError } from './errors.js';
15-
export type { WorkflowParseErrorCode, WorkflowIssue } from './errors.js';
14+
export {
15+
WorkflowParseError,
16+
WorkflowSyntaxError,
17+
WorkflowValidationError,
18+
WorkflowSecretLeakError,
19+
InterpolationError,
20+
} from './errors.js';
21+
export type {
22+
WorkflowParseErrorCode,
23+
WorkflowIssue,
24+
SecretLeak,
25+
InterpolationErrorCode,
26+
} from './errors.js';
1627

1728
// Structured, un-evaluated interpolation references — the view the DAG builder (1.M) consumes.
1829
export { parseTemplate, templateReferences } from './interpolation/references.js';
@@ -24,4 +35,11 @@ export type {
2435
FilterArg,
2536
} from './interpolation/references.js';
2637
export { collectReferences } from './interpolation/collect.js';
27-
export type { ReferenceSite } from './interpolation/collect.js';
38+
export type { ReferenceSite, ReferenceSiteCategory } from './interpolation/collect.js';
39+
40+
// Static interpolation analyses (1.L2) — also consumed by the future VS Code language server.
41+
export { analyzeSecretTaint, analyzeContextReferences } from './interpolation/analyze.js';
42+
43+
// The `{{ … }}` runtime resolver (1.L2) — evaluate templates against a run scope, eager-once context.
44+
export { resolveTemplate, resolveContext } from './interpolation/resolve.js';
45+
export type { RunScope, ResolverCapabilities } from './interpolation/scope.js';

0 commit comments

Comments
 (0)