Skip to content
6 changes: 5 additions & 1 deletion docs/reference/contracts/workflow-yaml-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,10 @@ inputs:
default: 'team@example.com'
```

An input `name` must be a **referenceable identifier** — `[A-Za-z0-9_-]+` (letters, digits, `_` or `-`),
the same charset the `{{inputs.<name>}}` head accepts — so a name like `my name` or `a.b` that could never
be referenced is rejected at parse (ADR-0023).

`secret`-typed inputs are resolved through the secret store, never written into run logs or the workflow file. They are also **masked in event payloads**: a `secret` input's value is redacted from the `run:started.inputs` payload (and any other event that echoes inputs), so a secret never reaches a surface, an IPC channel, or a persisted run log — see the masking rule in [sse-event-schema.md](sse-event-schema.md). See also [../desktop/keychain-and-secrets.md](../desktop/keychain-and-secrets.md).

An input may carry an optional **`validation`** object the engine checks before a run starts; a violating input fails fast and the run never begins:
Expand Down Expand Up @@ -117,7 +121,7 @@ inputs:

## Context and interpolation

`context` declares named values available throughout the workflow as `{{ctx.key}}`. Interpolation uses `{{ ... }}` syntax everywhere (inputs, context, prompt templates, message templates, edge/condition expressions).
`context` declares named values available throughout the workflow as `{{ctx.key}}`. Interpolation uses `{{ ... }}` syntax everywhere (inputs, context, prompt templates, message templates, edge/condition expressions). A context `key`, like an input `name`, must be a referenceable identifier (`[A-Za-z0-9_-]+`).

```yaml
context:
Expand Down
10 changes: 6 additions & 4 deletions docs/roadmap/current.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,14 @@

> Status: Living

> Last updated: 2026-06-11
> Last updated: 2026-06-12

- **Related**: [README.md](README.md), [phases/phase-0-foundations.md](phases/phase-0-foundations.md), [phases/phase-1-engine-and-llm.md](phases/phase-1-engine-and-llm.md), [../project-structure.md](../project-structure.md), [../tech-stack.md](../tech-stack.md)

This page tracks what is active **right now** and the immediate next concrete
actions. The full phase plan and the global milestone spine are in
[README.md](README.md); the granular work breakdown for the active phase is in
[phases/phase-0-foundations.md](phases/phase-0-foundations.md).
[phases/phase-1-engine-and-llm.md](phases/phase-1-engine-and-llm.md).

## Where we are

Expand Down Expand Up @@ -145,8 +145,10 @@ now scaffolded with a pure-TypeScript `WorkflowYAMLParser` that parses and valid
> off the M3 critical path); the **`turn_limit` `ErrorCode`** (a hard session turn cap, distinct from
> the `[chat].max_messages` trim threshold); the **reserved `on_error` edge kind**
> (workflow-yaml-spec.md, not authorable in v1.0); and a CI **engine dependency-allowlist guard** + the
> pnpm install-script allowlist. No Phase-1 work changed; **1.K has since landed (PR #13)** and **1.L is
> the next workstream**.
> pnpm install-script allowlist. No Phase-1 work changed; **1.K has since landed (PR #13)**, and
> **1.L has since landed (PR #14, 2026-06-12)**; **1.L2 (the `{{ … }}` interpolation engine + the
> parse-time secret-taint gate) is in review (PR #15)** — once it merges, **1.M (DAG builder +
> `RunPlan`) is the next workstream**.

Carry-over hardening is tracked in [deferred-tasks.md](deferred-tasks.md) — pick items up as Phase 1
first touches each file.
Expand Down
29 changes: 21 additions & 8 deletions docs/roadmap/phases/phase-1-engine-and-llm.md
Original file line number Diff line number Diff line change
Expand Up @@ -470,7 +470,7 @@ schemas; the run-event count-test is green at the new total; `tsc` + the seam fe
today is the count-pinned unit test, **not** the DB-migration drift gate — `run_events.event_type` is
unconstrained text — so the test total must be updated deliberately.)

### 1.L — `WorkflowYAMLParser` (parse + validate) — *critical path*
### 1.L — `WorkflowYAMLParser` (parse + validate) — *critical path* · ✅ **Done (PR #14, 2026-06-12)**

The engine entry point: load a `.relavium.yaml` and validate it against the
`@relavium/shared` `WorkflowSchema` (post-1.L.0) before any LLM call.
Expand Down Expand Up @@ -504,16 +504,19 @@ of every node, so it is sequenced before 1.M/1.O/1.P.) It is distinct from the J
(1.AB): `{{ … }}` is string templating; `condition`/`transform`/`merge_fn` are JS evaluated in the sandbox.

**Tasks:**
- Evaluate `{{ … }}` against the run scope — `inputs`, `ctx`, `run.outputs` (keyed by node id), `secrets` —
with the pipe-filter registry (`| read_file`, `| json`, `| length`, `| default`, …) per
[workflow-yaml-spec.md](../../reference/contracts/workflow-yaml-spec.md).
- Evaluate `{{ … }}` against the run scope — the three authored namespaces `inputs`, `ctx`, and
`run.outputs` (keyed by node id); a `secrets.*` reference is recognized by the lexer only so the
resolver/taint gate can reject it — with the pipe-filter registry (`| read_file`, `| json`,
`| length`, `| default("…")` — the argument-taking fallback applied when the value is missing, …)
per [workflow-yaml-spec.md](../../reference/contracts/workflow-yaml-spec.md).
- **Eager-once, immutable cached context:** a node's inputs are resolved once into a frozen snapshot, so a
re-run/replay is deterministic (aligned with the checkpoint + idempotency model, 1.R).
- Enforce the **transitive parse-time secret taint** [ADR-0029(c)](../../decisions/0029-tool-policy-hardening.md)
mandates "by the parser": a `secret`-typed value (or anything derived from one) is rejected from
`prompt_template` / tool text, allowed only in credential/header fields. Raise a typed `InterpolationError`
(key + workspace-relative location + node id; no absolute paths, no secret values) per
[error-handling.md](../../standards/error-handling.md).
`prompt_template` / tool text, allowed only in credential/header fields. Raise a typed,
field-named parse error (`WorkflowSecretLeakError`) — names only, no absolute paths, no secret
values — per [error-handling.md](../../standards/error-handling.md). (A *runtime* resolver failure
is the separate `InterpolationError`.)

**Acceptance:** interpolation resolves refs + filters correctly; a secret routed into prompt/tool text is
rejected at parse with a field-named, secret-free error; re-resolving a node yields an identical frozen scope.
Expand Down Expand Up @@ -608,10 +611,20 @@ Executes a single agent node end-to-end against `@relavium/llm`.
- Tool results enter message assembly **as data through the typed untrusted boundary**
([security-review.md §Prompt-injection posture](../../standards/security-review.md#prompt-injection-posture),
binding): never into `system`, never string-concatenated into an instruction template.
**This also covers resolved-interpolation content**: 1.L2's `resolveTemplate` returns a flat
string, dropping provenance, so a field that drew on `read_file` / `run.outputs` (untrusted) must
be carried as untrusted into a `user`/`tool` position by this layer, never `system`.
- **Re-apply secret taint when populating `run.outputs`** (ADR-0029(c) follow-up): the 1.L2
parse-time gate covers only the authored `{{ … }}` template graph. Any node output derived from a
`secret` — e.g. a `secret`-typed input surfaced *verbatim* via an `input` node's output — **must be
marked tainted when it is placed into `run.outputs`**, so a later `{{run.outputs["…"]}}` reference
into agent/human text is rejected the same way an authored secret reference is. The parse-time gate
remains responsible only for the authored template graph.

Comment thread
coderabbitai[bot] marked this conversation as resolved.
**Acceptance:** an agent node with a tool streams tokens, performs a tool round-trip,
emits a correct `cost:updated`, and completes with `node:completed`; a forced
provider error drives the fallback chain before the node is considered failed.
provider error drives the fallback chain before the node is considered failed; a `secret`-typed
input cannot reach agent/human text through a node output (taint re-applied at `run.outputs`).

### 1.P — Node-type handlers (condition / fan-out / fan-in / transform / input / output)

Expand Down
8 changes: 5 additions & 3 deletions docs/standards/security-review.md
Original file line number Diff line number Diff line change
Expand Up @@ -291,8 +291,10 @@ redaction rules live in [logging-and-observability.md](logging-and-observability
Any change to: key handling or the keychain bridge, IPC commands, the desktop
Rust-delegated egress path (`llm_stream` / `Channel<StreamChunk>`), provider base-URL
handling, the `http_request` tool or MCP server-URL handling (the other two SSRF egress
paths), the `run_command` sandbox, node `tools:` narrowing or `secret`-typed input
handling, prompt/tool-call construction, **media byte delivery (`read_media` / Range / upload) and the
media `url` carrier**, the DB encryption path, or a new dependency. For
paths), the `run_command` sandbox, **the host file reader behind the `read_file` interpolation
filter (`ResolverCapabilities.readFile`) — which must jail to the workspace root and reject path
traversal, a duty the pure engine delegates to each host**, node `tools:` narrowing or
`secret`-typed input handling, prompt/tool-call construction, **media byte delivery (`read_media` /
Range / upload) and the media `url` carrier**, the DB encryption path, or a new dependency. For
**managed mode**, also: the gateway authn/z path, key-pool selection, the metering/billing
path, and the master-key vault. When in doubt, run the checklist.
66 changes: 65 additions & 1 deletion packages/core/src/errors.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import { describe, expect, it } from 'vitest';

import { WorkflowSyntaxError, WorkflowValidationError, type WorkflowIssue } from './errors.js';
import {
InterpolationError,
WorkflowSecretLeakError,
WorkflowSyntaxError,
WorkflowValidationError,
type SecretLeak,
type WorkflowIssue,
} from './errors.js';

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

Expand Down Expand Up @@ -46,3 +53,60 @@ describe('WorkflowSyntaxError', () => {
expect(err.column).toBeUndefined();
});
});

describe('WorkflowSecretLeakError', () => {
const leak = (over: Partial<SecretLeak> = {}): SecretLeak => ({
location: 'node `n`.prompt_template',
secret: 'inputs.api_key',
...over,
});

it('summarizes the first leak, names the field/symbol, and cites the ADR', () => {
const err = new WorkflowSecretLeakError([leak()]);
expect(err.code).toBe('secret_interpolation');
expect(err.message).toBe(
'node `n`.prompt_template interpolates the secret `inputs.api_key` — secrets are rejected from agent/human text (ADR-0029)',
);
});

it('includes a `via` hop and a plural "more" suffix', () => {
const err = new WorkflowSecretLeakError([
leak({ secret: 'ctx.creds', via: 'inputs.api_key' }),
leak(),
leak(),
]);
expect(err.message).toContain('the secret `ctx.creds` (via `inputs.api_key`)');
expect(err.message).toContain('(and 2 more leaks)');
});

it('uses a singular "more leak" suffix for exactly two leaks', () => {
expect(new WorkflowSecretLeakError([leak(), leak()]).message).toContain('(and 1 more leak)');
});

it('summarizes an empty leak list defensively', () => {
expect(new WorkflowSecretLeakError([]).message).toBe('secret interpolation rejected');
});

it('omits a `via` that equals the secret (a direct, un-laundered reference)', () => {
const err = new WorkflowSecretLeakError([leak({ via: 'inputs.api_key' })]);
expect(err.message).not.toContain('(via');
});
});

describe('InterpolationError', () => {
it('carries a typed code and the offending reference as location', () => {
const err = new InterpolationError('unknown_filter', 'unknown filter `nope`', {
location: '{{inputs.x | nope}}',
});
expect(err.code).toBe('unknown_filter');
expect(err.location).toBe('{{inputs.x | nope}}');
expect(err.cause).toBeUndefined();
});

it('keeps a host error on cause and omits location when not given', () => {
const cause = new Error('inner');
const err = new InterpolationError('read_file_failed', 'read failed', { cause });
expect(err.cause).toBe(cause);
expect(err.location).toBeUndefined();
});
});
108 changes: 99 additions & 9 deletions packages/core/src/errors.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,20 @@
/**
* Typed, discriminated errors thrown by the engine's `WorkflowYAMLParser` (1.L). They mirror the
* `@relavium/llm` `LlmConfigError` pattern — a base class with a stable `code` discriminant and
* structured, secret-free context, narrowed on `code` and never on `message`
* (docs/standards/error-handling.md). The user-facing fields — `message`, `issues`, `field`, the
* `source` label, and the line/column — name the offending field/node and never carry an authored
* value, a stack trace, or an absolute path. An internal `cause`, where attached, is a non-secret
* diagnostic (a YAML rule, never the source text) kept for logs per error-handling.md; the raw
* ZodError is deliberately NOT attached, as it can carry an authored `received` value.
* Typed, discriminated errors thrown by the engine's `WorkflowYAMLParser` (1.L) and the `{{ … }}`
* interpolation engine (1.L2). They mirror the `@relavium/llm` `LlmConfigError` pattern — a base
* class with a stable `code` discriminant and structured, secret-free context, narrowed on `code`
* and never on `message` (docs/standards/error-handling.md). The user-facing fields — `message`,
* `issues`, `field`, `leaks`, the `source` label, and the line/column — name the offending
* field/node/symbol and never carry an authored value, a stack trace, or an absolute path. An
* internal `cause`, where attached, is a non-secret diagnostic (a YAML rule, or a host `readFile`
* error) kept for logs per error-handling.md; the raw ZodError is deliberately NOT attached, as it
* can carry an authored `received` value.
*
* Two families: the parse-time {@link WorkflowParseError} (syntax / schema / secret-leak), thrown by
* `parseWorkflow`; and the runtime {@link InterpolationError}, thrown while resolving a template
* against a run scope.
*/

export type WorkflowParseErrorCode = 'invalid_yaml' | 'schema_validation';
export type WorkflowParseErrorCode = 'invalid_yaml' | 'schema_validation' | 'secret_interpolation';

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

/**
* One rejected secret interpolation (ADR-0029(c)). Every field is a *name* — an authored input name,
* context key, or field locator — never a resolved value, so the finding is safe to surface and log.
*/
export interface SecretLeak {
/** Where the secret was interpolated — e.g. ``node `scan`.prompt_template``. */
readonly location: string;
/** The tainted symbol referenced at that site — e.g. `inputs.api_key` or `ctx.creds` (a name). */
readonly secret: string;
/**
* The **immediate** deeper tainted symbol, when laundered through a `context` entry or `input`
* default — e.g. `inputs.api_key`. This is a single hop (the direct predecessor), not the full
* chain; v1.0 surfaces one hop for a concise message.
*/
readonly via?: string;
}

/** Base for every parser error — callers narrow on `code`, never on `message`. */
export abstract class WorkflowParseError extends Error {
abstract readonly code: WorkflowParseErrorCode;
Expand Down Expand Up @@ -78,3 +100,71 @@ function summarize(issues: readonly WorkflowIssue[]): string {
const more = rest > 0 ? ` (and ${rest} more issue${suffix})` : '';
return `${first.field}: ${first.message}${more}`;
}

/**
* A `secret`-typed value — or anything transitively derived from one through a `context` entry or an
* `input` default — reaches agent/human text (`prompt_template`, `system_prompt[_append]`,
* `message_template`, `assignee`). Rejected at parse so a run never starts on it (ADR-0029(c)). The
* message names the offending field and the tainted symbol; it never carries the secret's value.
*/
export class WorkflowSecretLeakError extends WorkflowParseError {
readonly code = 'secret_interpolation';
readonly leaks: readonly SecretLeak[];

constructor(leaks: readonly SecretLeak[], opts?: { source?: string; cause?: unknown }) {
super(summarizeLeaks(leaks), opts?.source, opts?.cause);
this.name = 'WorkflowSecretLeakError';
this.leaks = leaks;
}
}

function summarizeLeaks(leaks: readonly SecretLeak[]): string {
const first = leaks[0];
if (first === undefined) {
return 'secret interpolation rejected';
}
const via =
first.via !== undefined && first.via !== first.secret ? ` (via \`${first.via}\`)` : '';
const rest = leaks.length - 1;
const suffix = rest === 1 ? '' : 's';
const more = rest > 0 ? ` (and ${rest} more leak${suffix})` : '';
return `${first.location} interpolates the secret \`${first.secret}\`${via} — secrets are rejected from agent/human text (ADR-0029)${more}`;
}

/** Stable discriminant for a runtime interpolation failure — callers narrow on `code`, not `message`. */
export type InterpolationErrorCode =
| 'unresolved_reference' // a `{{ … }}` head/path resolved to no value (and no `| default(…)` rescued it)
| 'unknown_namespace' // a reference reads from a namespace the resolver does not serve (e.g. `secrets`)
| 'unknown_filter' // a pipe filter name is not in the registry
| 'filter_arity' // a filter was given the wrong number of arguments
| 'filter_type' // a filter cannot apply to the value's type (e.g. `length` on a number)
| 'unserializable' // a reference resolved to an object/array used as text without a `| json` filter
| 'invalid_path' // a malformed property/index access after the head
| 'read_file_unavailable' // the `read_file` filter ran without a host `readFile` capability
| 'read_file_failed' // the host `readFile` capability threw (cause kept for logs, off the message)
| 'aborted'; // the run's `AbortSignal` fired mid-resolution (cooperative cancellation)

/**
* A runtime interpolation failure raised while *resolving* `{{ … }}` against a run scope (1.L2) —
* distinct from the parse-time {@link WorkflowParseError} family. User-facing and secret-free: the
* message names the offending reference (its verbatim `{{ … }}`) and never a resolved value; an
* absolute path from a host `readFile` failure stays on the `cause` (for logs), never in the message.
*/
export class InterpolationError extends Error {
readonly code: InterpolationErrorCode;
/** The offending `{{ … }}` occurrence, verbatim — names the reference, never a resolved value. */
readonly location?: string;

constructor(
code: InterpolationErrorCode,
message: string,
opts?: { location?: string; cause?: unknown },
) {
super(message, opts?.cause === undefined ? undefined : { cause: opts.cause });
this.name = 'InterpolationError';
this.code = code;
if (opts?.location !== undefined) {
this.location = opts.location;
}
}
}
Loading
Loading