- Status: Stable
- Canonical home: the contract for the deterministic, resource-capped JS sandbox in
packages/core(@relavium/core) that evaluatescondition/transform/merge_fnexpressions — workstream 1.AB - Related: ../../decisions/0027-expression-sandbox.md (the decision + its 2026-06-12 hardening addendum), node-types.md (the node config blocks that carry these expressions), ../contracts/workflow-yaml-spec.md (the authored YAML surface), ../contracts/sse-event-schema.md (the
sandbox_errorcode), ../../standards/security-review.md (the binding invariants), ../../standards/error-handling.md (the retryable/fatal classification), ../../decisions/0029-tool-policy-hardening.md (the secret-taint gate)
This page is the one canonical home for the expression-sandbox contract — the scope an expression sees, the language surface it may use, the determinism guarantee, the resource caps, the result rules, and the error taxonomy. The why (and the pinned engine decision) lives in ADR-0027; this file is the dry reference its consumers (the 1.AB sandbox, the 1.P node handlers, the security review, the language server) bind to. Where any other doc names a sandbox rule it links here and never restates it.
Two different
{{ … }}-vs-bare mechanisms — do not conflate them.{{ … }}string interpolation (templating, owned by the interpolation engine) is a separate mechanism. The values described here —condition.expression,transform.transform, and a custommerge_fn— are bare JavaScript expressions (not{{ … }}-wrapped), evaluated in this sandbox. A bare expression is never string-interpolated, and a{{ … }}template is never evaluated as JS.
| Node | Field | Expression role | Result |
|---|---|---|---|
condition |
expression (evaluated once) |
branch selector | a value matched by strict === against each branch's when (boolean | string | number); the default branch is taken when none matches |
transform |
transform (a single expression) |
pure state reshape, no LLM | the (JSON-serializable) result becomes the node's whole output |
merge (custom) |
merge_fn |
combine N parallel branch outputs | the merged result — any JSON-serializable value (only when merge_strategy: custom) |
In v1.0 the only expression_type is js. jmespath / jsonlogic are reserved (each would
add an undeclared runtime dependency) and are rejected at parse time (1.L) with a field-named,
actionable error — not silently treated as js and failed at eval. There is no Python evaluator
(ADR-0003).
Each expression receives a single frozen scope rebuilt from JSON-only transport; denied values become VM-owned accessors before authored execution. The bindings are exactly:
| Binding | Shape | Meaning |
|---|---|---|
inputs |
object | admitted run inputs; supplied declared secret slots are denied accessors, while omitted optional inputs remain absent |
ctx |
object | the eager-once context projection: clean strings and denied accessors in original key order; see secret context boundary |
run.outputs |
object keyed by node id | completed upstream node outputs, e.g. run.outputs["classify"].sentiment — never a bare output |
branches |
array | merge_fn only — the branch outputs to combine, in the stable FanInPlanConfig.branchNodeIds order (never arrival/completion order; the order itself is defined by run-plan.md §fan-in branch order). run.outputs is also available, so a branch may be referenced by node id. |
Rules:
- Newly projected declared secrets are never injected. The engine excludes raw declared
secret inputs and secret-derived context strings before scope serialization. A supplied secret
input or denied context read runs a VM-owned accessor; an observed read fails with
secret_context, including ordinary caught reads and serialization-time reads. Direct expression inputs no longer expose{ secret: true, ref }display markers. Omitted optional secret inputs remain absent. Key-only enumeration is allowed; see the finite denial contract. This is declared-source provenance, not value scanning: arbitrary literals and historicalrun.outputsare outside that guarantee. - The scope uses immutable lexical bindings. It crosses into the VM as JSON-only transport
(see Marshaling & isolation); the VM rebuilds frozen null-prototype
records, installs denied accessors and gives authored code
constbindings in a separate strict lexical function. Scope bindings are never VM-global properties. An expression cannot mutate the scope, and a mutation attempt has no cross-evaluation effect. - Determinism of
merge_fn. Becausebranchesis ordered by static declaration (not arrival), a parallel fan-in merges to the same result regardless of which branch finishes first.
The VM context is built with a minimal intrinsic set: the non-deterministic and I/O-bearing
capabilities are simply not created (the one exception BaseObjects ships, Math.random, is
removed before the expression runs). Available to an expression:
| Available | Notes |
|---|---|
Object, Array, String, Number, Boolean |
constructors + their pure prototype methods |
Math |
without Math.random — the sandbox deletes the Math.random own property and freezes Math before the expression runs (no intrinsic flag omits a single Math method); calling or re-adding random throws → sandbox_error |
JSON |
parse / stringify |
Map, Set, WeakMap, WeakSet |
Map/Set deterministic + insertion-ordered |
RegExp |
deterministic (catastrophic backtracking is bounded by the wall-clock cap) |
Reflect, Symbol |
pure, deterministic, and host-unreachable — present but harmless |
BigInt |
present, but a top-level BigInt result is rejected as non-serializable (JSON has no BigInt) |
parseInt, parseFloat, isNaN, isFinite |
global numeric helpers |
Forbidden — not present in the context: Date, Math.random, Promise / async / await /
queueMicrotask (evaluation is synchronous — pending jobs are never run), setTimeout /
setInterval, performance, crypto, Proxy, WeakRef / FinalizationRegistry, Intl,
import / require / process, and any ambient I/O (fetch, filesystem, network). (The pure
reflective globals BaseObjects ships — Reflect, Symbol, WeakMap, WeakSet — are present;
they are deterministic and reach nothing.) No custom host function is injected in v1.0 — only the
built-in pure objects above (an interpolation filter such as read_file is a templating-engine
concern, not part of this sandbox).
eval/Functionare present but contained — the wasm VM is the boundary, not their absence. quickjsevalCoderequires theEvalintrinsic to compile, so it stays enabled andeval/ theFunctionconstructor exist inside the VM. This is safe: the QuickJS VM runs on an isolated wasm heap with no host reference reachable (zero host functions are injected), and every capability above is absent — so code reached througheval,Function, or a re-acquired(…).constructorcan read no clock, no RNG, no host object, do no I/O, and is bounded by the same caps. Isolation and determinism rest on removing the capabilities, not on trying to delete every reflective handle toFunction(which would be fragile theater the wasm boundary already makes unnecessary).
For an expression that completes within its resource caps, the result is a pure function of the injected scope — the same scope always yields the same value (or the same deterministic language error). This is what keeps checkpoint/resume and retry-from-node (idempotency key the effect journal's identities — see effect-journal.md) reproducible. Non-determinism is removed at the language level (no clock, no RNG, no async, no I/O — see Language surface).
The resource caps themselves are not part of the deterministic result — see Resource caps. A wall-clock-timeout trip is a non-idempotent safety net that surfaces as the error path, never as a stable value.
Author guidance (determinism footguns):
- Object key iteration is insertion order (ES2015). For an order-independent merge, sort keys explicitly.
- The default
Array.prototype.sort(no comparator) is Unicode code-point order — deterministic. Locale-sensitive operations (toLocaleString,Intl, locale collation) are discouraged: avoid them in expressions whose result feeds a branch or persisted output. - Use
Number.isNaN(x)andObject.is(x, y)forNaN/-0checks;x === NaNis alwaysfalse. run.outputsiteration follows host insertion order (and integer-like keys reorder ascending, per ES). The sandbox is a pure function of the scope object, so reproducibility across checkpoint/resume depends on the engine (1.O) buildingrun.outputsin a canonical (node-id-sorted / declaration) order — the same obligationmerge_fn'sbranchesalready meets. Sort keys explicitly for an order-independent merge.
Cancellation (v1.0).
evaluateis synchronous and a real expression runs ~1 ms, so a runCANCELis bounded by the wall-clock cap rather than threaded as anAbortSignal. Threading a signal into the sandbox is deferred to when 1.P/1.N wire it into the run loop; at that point a cancel must be a distinct fatal reason checked before the timeout path (a deliberate cancel is never a retryable timeout).
Each evaluation runs under fixed caps. The wasm module is instantiated once per engine instance; each evaluation gets a fresh runtime + context, disposed afterward (full isolation; OOM-safe — a tripped runtime is discarded, never reused).
| Cap | Default (v1.0) | Enforced by | On trip |
|---|---|---|---|
| Wall-clock timeout | 1000 ms | setInterruptHandler(shouldInterruptAfterDeadline(…)), started after runtime/context construction (it bounds execution, not cold setup) |
sandbox_error — retryable (non-idempotent safety net) |
| Heap memory | 16 MB | module.newRuntime({ memoryLimitBytes }) |
sandbox_error — fatal |
| Stack size | 256 KB | module.newRuntime({ maxStackSizeBytes }) |
sandbox_error — fatal |
The caps are fixed engine constants in v1.0 — expressions are small and infrequent relative to LLM calls, so a trip signals a bug or a DoS attempt, not normal variation. The 1.AB perf spike measured a real expression at ~1 ms (cold-start ~35 ms), so the 1 s budget leaves ~1000× headroom; it is set that loose on purpose — a tighter wall-clock budget spuriously trips a trivial eval when the host deschedules the process mid-call, and since a timeout is the one retryable failure, a tighter cap would only convert scheduling jitter into needless node retries. Author-configurable caps are a future ADR. These numbers are the single source of truth; every surface uses them unchanged.
| Node | Required result | Violation |
|---|---|---|
condition |
a boolean | string | number, compared to each when by strict === (no coercion) |
a result outside that set → fatal sandbox_error (result_type). (No-when-match-and-no-default is the 1.P condition handler's concern when it applies the result — not the sandbox.) |
transform |
a single JSON-serializable result (the node's whole output) | a function, symbol, top-level undefined, top-level BigInt, a top-level boxed primitive (new String/new Number/new Boolean), or circular result → fatal sandbox_error (non_serializable) |
merge_fn |
the same result contract as transform |
as transform |
Lossy JSON coercion (author guidance). A
transform/merge_fnresult is taken as JSON-serializable, so nested standardJSON.stringifylossy cases apply:Map/Set→{},NaN/Infinity/-Infinity→null,-0→0. Top-level numeric values instead retain their exact value/sign atevaluate; later JSON persistence retains its existing normalization. A top-levelBigIntis rejected. See result parity.Boxed primitives are rejected (deliberate over-rejection). A top-level
new String("x")/new Number(1)/new Boolean(true)is rejected asnon_serializableeven though it round-trips throughJSON.stringifycleanly. The host validator keys on the VM-sidetypeof(which is'object'for a wrapper) while the marshaled value is a host primitive, and treats that object-vs-primitive mismatch as the tell of a non-marshalable result. An object whosetoJSONreturns a non-null scalar is refused for the same reason; the existing null-result exception remains. The contract is therefore slightly stricter than raw JSON-serializability: return a plain primitive, never a wrapper.Unsettled
run.outputsreads. A JS-expressionrun.outputs["x"]read is still not wired as a dependency edge by the DAG builder — only{{ … }}template references are. Since ADR-0093 two rules bound the consequence: the injectedrun.outputscontains only the reading node's transitive dependency closure, and the builder refuses at parse a literalrun.outputs["id"]/run.outputs.idaccess naming a node that is not an ancestor. The scan is conservative by design — literal accesses only, silent where it cannot tell, and it never synthesizes an edge.The residue, stated rather than implied closed: a read the scan cannot confidently resolve — a computed key, an aliased binding, an identifier escape, or any expression whose literals it cannot lex — is not caught by the scan, falls through to the narrowed scope, and still compares against
undefined. Order such producers with an explicit edge. Exhaustive detection needs VM-side observation, which ADR-0093 records as the follow-up. (This sentence added "together with the ADR-0027 §3/§6 amendments it would require" until 2026-09-04. ADR-0093's own correction records that the objection does not apply to an accessor defined VM-SIDE, afterJSON.parse: nothing live crosses the boundary, marshaling stays JSON-only andProxystays off. The real cost is that it can only fail AFTER the run has started.)
Every sandbox failure surfaces as the closed ErrorCode member sandbox_error
(sse-event-schema.md), carried on the canonical
node:failed / run:failed events with a user-safe message and an internal correlation id. The
retryable/fatal split (owned by error-handling.md) is:
| Cause | Classification evidence | reason |
Classification |
|---|---|---|---|
| Syntax error (invalid JS) | object { name: "SyntaxError" } |
syntax |
fatal — deterministic |
Runtime error (ReferenceError/TypeError/a thrown Error: undefined var, bad property, Math.random call) |
object { name, message } |
runtime |
fatal — deterministic |
Non-conforming result (bad condition type; non-serializable transform/merge_fn) |
host-side validation | result_type / non_serializable |
fatal — deterministic |
| Memory / resource limit (out of memory, string too long) | object { name: "InternalError" } |
memory |
fatal |
| Stack overflow | object { name: "InternalError", message: "stack overflow" } |
stack |
fatal |
| Injected scope not serializable or required provenance missing/invalid (engine/caller fault, before VM eval) | host-side validation / serialization / depth admission | scope |
fatal |
| Observed denied input/context accessor | private denied transport arm, never an authored error name/message | secret_context |
fatal, fixed diagnostic without detail/cause |
| Wall-clock timeout (the genuine interrupt) | { name: "InternalError", message: "interrupted" } and the host deadline passed |
timeout |
retryable — non-idempotent; bounded by the node retry budget (1.S) |
Private transport selects success, failure or observed denial before ordinary error classification. A VM-owned denied arm takes precedence over authored catches and error names; authored values cannot select that arm. Only ordinary failures use the existing error-name/engine-marker and host-deadline classifier. The retryable
timeoutrequires BOTH the genuineInternalError: interruptedmarker AND the host deadline having passed. An authoredError("…interrupted…")is a fatalruntime; merely outlasting the deadline does not make a deterministic failure retryable. The pinned VM's exact marker strings are implementation-dependent. See private transport and the finite denial limit.
Message scrubbing (binding). The user-facing sandbox_error message is the code plus a generic,
secret-free string — it never echoes the expression source, a variable name, a scope value, an
output, or a host stack trace. Ordinary SandboxError.detail can contain authored error text and
is private potentially sensitive data; do not render it or treat it as universally safe to log.
The secret_context arm contains no detail or cause. This mirrors the LlmError-message discipline in
security-review.md.
Syntax validity is checked at evaluation (1.AB), not at parse (1.L) — Zod validates the expression string, not its JS grammar. A typo'd expression parses fine and fails the first time its node runs; test workflows before production. (
expression_typeother thanjs, by contrast, is rejected at parse.)
- JSON-only injection. The host serializes the scope (
JSON.stringify) and the VM rebuilds it (JSON.parse). No live host object, getter, or function crosses the boundary, so an expression cannot reach a host reference.JSON.parseinstalls a__proto__key as an own data property (never the prototype setter), so an attacker-shaped{"__proto__":…}arriving via model/tool-derivedrun.outputscannot poison the prototype chain. - Immutable, lexically-bound scope. The VM rebuilds frozen null-prototype records from the
parsed JSON and installs denied accessors before authored execution. Scope bindings (
inputs,ctx,run, andbranchesformerge_fn) areconstin a separate strict lexical function, outside the private latch/serializer environment. They are never installed on the VM global, and a write to a binding throws. A pathologically deep scope is rejected (scope) before injection (it would overflow the host stack insideevalCode). - Fresh context per evaluation. A new runtime + context is created and disposed for each evaluation, so two expressions cannot observe or corrupt each other (no state bleed), and an OOM discards only that runtime.
- Handle hygiene. Every VM handle is released via explicit
dispose(); a leaked handle aborts the runtime on disposal, so the sandbox treats a leak as a bug — surfaced as asandbox_erroron the success path — not a silent warning.
@relavium/core imports only quickjs-emscripten-core (the pure-TypeScript bindings — zero
platform imports) plus a single-file, synchronous variant (starting candidate
@jitl/quickjs-singlefile-mjs-release-sync) whose wasm is embedded as bytes and instantiated
through the standard WebAssembly global. The meta-package quickjs-emscripten and its default
getQuickJS() loader are forbidden — they statically import node:fs/path, breaking
@relavium/core's zero-platform-imports invariant
(ADR-0003) at import time and failing to
load in the Tauri WebView. The exact variant + version are pinned in the catalog:
(tech-stack.md) by the 1.AB perf spike, and the engine-deps allowlist
(tools/engine-deps/check.mjs) is edited in the same commit as the first import.
The sandbox lives in packages/core/src/expression/ (1.AB) and is consumed by the 1.P node handlers.
Its public surface is exported from packages/core/src/index.ts. Adversarial accept/reject tests
(prototype-pollution escape, eval/Function present-but-VM-isolated — they exist inside the wasm
VM because the Eval intrinsic must stay on for evalCode to compile, yet reach no host reference or
forbidden capability — Date/Math.random/Promise absent, secret-in-scope yields a secret-free
error, cap-trip classification, determinism over shuffled run.outputs key order) are a binding part of 1.AB per
testing.md.
Accepted implementation — 2026-10-16. Step 3 implements ADR-0113, with two NEW complete cumulative clean reviews and a separate clean security sitting. The acceptance register records exact source, evidence and preserved limits; whole Wave 3 remains open.
WorkflowContextProjection contains values: Readonly<Record<string, string>> (clean keys
only), deniedKeys: readonly string[] and orderedKeys: readonly string[]. Its factory
copies/freezes all three and detaches the value record's prototype. Keys must be referenceable
identifiers, denied/ordered keys unique, and orderedKeys exactly the disjoint union of clean
and denied keys. The full order is the original Object.keys(resolved) order, preserving
context declaration order and ECMAScript integer-key enumeration rather than sorting.
Denied context values are absent from this projection.
createWorkflowProvenance computes one authority from the existing linear declared-source
taint graph per admitted workflow. It owns both a ReadonlySet-compatible secret-input
membership facade and an immutable ordered deniedInputNames array. The facade has no Set
mutation methods; its callbacks and iterators do not expose the mutable backing Set, including
forEach's third argument. ownSecretInputNames copies embedding-supplied membership;
Object.freeze(new Set()) or retaining a caller's live Set would not provide that ownership.
Shape validation cannot detect a dishonest trusted embedding's fabricated provenance.
The authority also snapshots/freezes the context definition array and its key/value entries
before analysis. Engine resolution consumes that same contextSource, including across
awaited capabilities and same-process resume. A caller mutation after start() cannot make
resolution consume a different context definition from the one whose taint was analysed.
The trusted raw resolver remains compatible with full workflows and accepts a minimal
read-only context source; only engine resolution chooses the owned invocation source.
Execution/preparation require this projection and owned membership. Missing or malformed
metadata at an engine/public-template boundary raises fixed, content-free
WorkflowContextError (validation, non-retryable). Direct ExpressionSandbox.evaluate
converts that metadata failure to fixed SandboxError.reason: scope, preserving its guarantee
to throw only SandboxError. Clean embeddings supply an explicit empty-denial projection
and explicit deniedInputNames, never an implicit clean default.
resolvePublicTemplate uses the existing reference lexer to inspect the entire template
before resolving any reference or invoking any filter/capability. It refuses denied ctx
references and direct declared secret-input references, including optional secrets absent
from the input map, before a default can replace them. The fixed InterpolationError
reason secret_reference maps to non-retryable validation. This applies to hand-constructed
workflow objects as well as parsed YAML. The runner contract
owns actual fresh/resume/preparation producers and text consumers; preparation has no
sandbox-only field. The exported trusted raw resolveContext contract stays unchanged.
ExpressionScope.ctx is the required projection, with explicit deniedInputNames. Scope
construction excludes raw secret inputs, preserving actual input own-key order/presence.
Present denied input keys use private null transport placeholders which become frozen VM-owned
accessors before authored code. Omitted optional secret inputs get no property/accessor:
undefined, hasOwn/in, own keys and descriptors retain baseline absence. VM denied input
keys are present admitted keys intersected with owned declaration membership. Placeholders are
never readable expression values or display markers. Required-input admission and the ban
on secret defaults remain unchanged; trusted NodeExecContext.inputs remains raw, while
run-start/input-node/display masking retains its existing marker contract.
QuickJS reconstructs frozen null-prototype scope records, using the full context key order
and only present denied input keys. Authored code runs in a separate strict lexical function
receiving only the frozen public pack, with const scope bindings. Trusted intrinsics are
captured before authored execution. A denied accessor sets the private sticky latch before
throwing; ordinary caught reads, returned getters and toJSON reads cannot clear it.
This is finite observed-access detection. A stack/resource exception before the accessor
body can be caught without setting the latch. The installed-VM regression's exhausted-stack
case returns its authored clean fallback string "SWALLOWED"; the ordinary caught-read case
instead produces secret_context. That successful fallback carries no raw denied value because
none was supplied; it is not a denial outcome. An uncaught
pre-accessor resource failure keeps its ordinary classification. Confidentiality rests on
raw-value exclusion, not universal detection of every attempted read. A serialization interrupt
has the separate baseline result disposition below.
When the private serialization closure completes with an observed denial, it produces
SandboxError.reason: secret_context, existing code: sandbox_error,
retryable: false, and the fixed message “secret-derived values are unavailable to expressions”,
without source/value detail or cause. Only this private denial arm is independent of authored
error names/messages; ordinary error classification remains unchanged.
ADR-0093's parse scan still protects before-money-spent dependency ordering. These VM-side accessors diagnose runtime privacy reads; they add no host function and do not reverse that scheduling decision.
One evaluation uses two native calls in the same VM under the original deadline. The first
evaluates authored code and returns only a private VM-owned serialization closure. The second
invokes that closure to serialize the captured authored result at most once, then performs the
final latch check. No successful authored object is dumped across the bridge. A completed closure's
observed denial wins over a caught serialization error. A serialization failure without denial,
or a native serialization interrupt that prevents completion of the closure, preserves baseline
disposition: result_type for condition, non_serializable for transform/merge_fn.
The native interrupt does not receive a new deadline or become a successful denial observation.
Primitive results follow their original-type conversion paths without diagnostic prototype
reads or authored JSON hooks. Thrown BigInt values also bypass toJSON. Boxed values and
objects whose toJSON returns a scalar still undergo object JSON conversion and retain
original-VM-type classification. Diagnostic hydration applies only when that converted JSON
is an object/array; scalar representations do not acquire diagnostic prototype reads.
Top-level result conversion retains the installed bridge's supplemental name, message,
stack, fileName and lineNumber fields, including inherited/non-enumerable fields when
the serialized result does not supply a value. The original result's JSON traversal completes
first, retaining its getter order and invoking its toJSON once. Missing diagnostic fields
are then read in the native fixed order; all supplemental getters finish before supplemental
values are serialized. These authored conversions occur before the final latch check.
Subsequent encoding uses detached JSON records/arrays without authored prototypes or callbacks.
Nested Error values retain ordinary JSON semantics. Stack strings
describe the current VM wrapper; their internal frame text is not a stable cross-version value.
Ordinary thrown values use the same VM-owned conversion and fallback-text semantics before
the unchanged host classifier. Their bounded JSON payload is private error transport, never
an authored control arm. A VM-owned immutable Boolean distinguishes failed evaluation from
successful-result serialization if the native closure cannot complete; it grants no extra time.
The versioned private transport has closed success, failure and denied arms. Success
contains the trusted original VM typeof, a primitive payloadJson string and, where needed,
a closed number tag. The null-prototype outer envelope contains only trusted primitive metadata
and that string, so its later serialization cannot execute an authored getter or toJSON.
The host checks VM type, calls getString, parses/strictly validates the private arm and decodes
the payload. Author values cannot select the outer arm. Malformed transport is a fixed sandbox
failure, never success or evidence of secrecy.
The full transport's UTF-8 byte count is bounded by the evaluation's active memoryBytes,
with a cheap character-length precheck before bounded byte counting and host parsing.
Oversize transport raises a fixed non-retryable memory failure without truncation. Default
resource limits remain unchanged; no unbounded host object dump/parse is introduced.
| Result | condition |
transform / merge_fn |
|---|---|---|
| Boolean/string/finite number | Preserve exact value | Preserve exact value |
| Top-level NaN, ±Infinity, -0 | Preserve numeric value/sign using private tags | Same; later JSON persistence keeps existing normalization |
| Object/array/null, including Map/Set | result_type |
Existing JSON object semantics; Map/Set → {}; cycles fail |
| Top-level Error / Error subclass | result_type |
Retain enumerable data plus supplemental diagnostic fields; nested Errors keep ordinary JSON semantics |
Boxed primitive or object whose toJSON returns a non-null scalar |
result_type |
non_serializable |
| Undefined/function/symbol/bigint | result_type |
non_serializable |
Nested omission/null/boxed behavior and the existing null-result exception remain unchanged.
Successful JSON serialization alone does not override original VM type checks. A stateful
toJSON runs once, with no second authored serialization after the final latch check.
The tool provenance boundary records the unsupported
mapping path separately. This projection does not rewrite history or certify replayed
run.outputs or private error detail as secret-free; the current
at-rest/recovery guidance owns that limit.