workflow: render thrown script values inside the realm's execution window

Codex code-review round 3: the round-2 'contained stack getter' still let a
script escape the vm sync-slice timeout — throw { get stack() { while(true){} } }
put the spin on the HOST catch path, where no timeout applies (verified: a
direct sync-slice spin dies by the timeout; the getter-hidden one hung the
process). Identity-trusting the native getter is also insufficient: V8 stack
formatting reads script-controllable hooks at format time (Error.prepareStackTrace,
a subclass name getter — both empirically confirmed), so ANY host-side
formatting of a realm error can run realm code.

The fix moves rendering into the realm itself: the compiled body (and the meta
literal) is wrapped in a realm-side catch that pre-renders the thrown value to
a string (REALM_THROWN_RENDERER_SOURCE) — a hostile accessor/toString now runs
as ordinary script code, killed by the sync-slice timeout or falling under the
documented post-await spin limitation; host WorkflowErrors pass through for
the CANCELLED mapping. The host catch descriptor-reads the pre-rendered string
(thrownRendering) or falls back to describeThrown, which invokes no getter
whose identity is not the host realm's own native stack getter.

Tests: hostile-table expectations updated for realm-side rendering; new
regressions for the getter-hidden sync spin dying by the vm timeout (engine +
meta paths) and for a hostile thenable rejection that bypasses the realm
wrapper (renders host-side, proxy labelled, traps never run); describeThrown/
thrownRendering unit tables including the realm-error identity-mismatch case.
This commit is contained in:
Tianyi Cui
2026-07-05 20:32:35 +08:00
parent 57b9910339
commit fff2e1f33d
8 files changed
+199 -61

No files matched your search

+15 -7
View File
@@ -20,7 +20,7 @@
import * as vm from 'node:vm'
import { WorkflowError } from '@deepseek-ai/dsh-workflow'
import type { WorkflowMeta, WorkflowPhase } from '@deepseek-ai/dsh-workflow'
import { materializeFromRealm, MaterializeError, describeThrown } from './realm.ts'
import { materializeFromRealm, MaterializeError, describeThrown, thrownRendering, REALM_THROWN_RENDERER_SOURCE } from './realm.ts'
/** The result of {@link extractMeta}: the validated meta and the runnable body. */
export interface ExtractedScript {
@@ -171,13 +171,21 @@ export function extractMeta(script: string, evalTimeoutMs: number): ExtractedScr
// An EMPTY context: any non-literal reference (a variable, a call) throws
// here. The result — data only — is what the contract checks; a getter or
// IIFE can still run, which is why the timeout and the materialization
// below are part of the same boundary.
evaluated = vm.runInNewContext(`(${literal})`, undefined, { timeout: evalTimeoutMs })
// below are part of the same boundary. A thrown value is pre-rendered by
// the realm-side catch INSIDE the timed window, so a hostile
// stack/message/toString can neither run on the host catch path nor
// outlive the timeout.
evaluated = vm.runInNewContext(
`(() => { try { return (${literal}) } catch (e) { throw (${REALM_THROWN_RENDERER_SOURCE})(e) } })()`,
undefined,
{ timeout: evalTimeoutMs },
)
} catch (error: unknown) {
// describeThrown, not String(): an expression in the literal can THROW a
// hostile value (a throwing toString/accessor), and this catch must map
// it to META_INVALID rather than let realm code run or a raw error escape.
throw new WorkflowError(`meta block failed to evaluate as a pure literal: ${describeThrown(error)}`, 'META_INVALID', { cause: error })
throw new WorkflowError(
`meta block failed to evaluate as a pure literal: ${thrownRendering(error) ?? describeThrown(error)}`,
'META_INVALID',
{ cause: error },
)
}
let data: unknown
try {
+76 -32
View File
@@ -27,10 +27,16 @@
* chain, so the engine rebuilds inbound values INSIDE the realm via the
* context's own `JSON.parse` (see the runtime).
*
* {@link describeThrown} is the same discipline for the one place realm
* values reach the host WITHOUT materialization: rendering a thrown value
* for a failure report. It never throws; the only realm code it can invoke
* is a stack getter, contained (see its doc).
* {@link REALM_THROWN_RENDERER_SOURCE}, {@link thrownRendering}, and
* {@link describeThrown} are the same discipline for the one place realm
* values reach the host WITHOUT materialization: a thrown value crossing into
* a host catch block. The renderer runs INSIDE the realm's own execution
* window (compiled into the script wrapper), so reading a hostile
* accessor/`toString` there is subject to the vm sync-slice timeout exactly
* like any other script code; the host side only descriptor-reads the
* pre-rendered string, or falls back to {@link describeThrown}, which invokes
* no getter whose function identity is not the host realm's own native stack
* getter.
*
* @module @deepseek-ai/dsh-workflow-vm/realm
*/
@@ -46,22 +52,66 @@ export class MaterializeError extends Error {
}
/**
* Render a value THROWN by realm code (a script failure, a meta-literal
* evaluation failure) as text, without ever throwing itself — the callers sit
* in catch blocks whose totality is a seam contract (`WorkflowRun.result`
* never rejects). Plain property reads and `String(value)` are hostile-value
* hazards (`{ get stack() { throw ... } }`, a throwing
* `toString`/`Symbol.toPrimitive`), so: proxies render as a fixed label
* (trap-free `isProxy`, before any inspection); `message` is read as an OWN
* DATA descriptor only; everything else object-shaped renders as
* `[object Object]` without being touched; only primitives (which cannot
* carry code) reach `String()`. The one exception is the `stack` getter —
* modern V8 makes `stack` an own ACCESSOR on genuine `Error`s, so it is
* invoked (that is how real stacks, with the script's own line numbers via
* the compile lineOffset, are obtained) but CONTAINED: a hostile getter's
* throw is swallowed and rendering falls back to message. Detection is
* structural, not `instanceof` — a realm Error is not an instance of the host
* class.
* Realm-SOURCE text (an arrow-function expression) the engine compiles into
* its script wrappers: `throw (RENDERER)(e)` inside a catch around the whole
* body/literal. It renders the thrown value to a string INSIDE the realm's
* own execution window — a hostile `stack`/`message` accessor or `toString`
* invoked here is subject to the vm sync-slice timeout like any other script
* code (and post-await it is the engine's accepted spin limitation, identical
* to a script reading `e.stack` in its own catch). Host `WorkflowError`s
* thrown by hooks pass through unwrapped (duck-checked by name — a realm
* forgery fails the host's `instanceof` and merely renders data-only);
* everything else becomes `{ __wfThrown: <string> }`, whose only consumer is
* {@link thrownRendering}. Every read is individually contained, so the
* renderer itself never throws.
*/
export const REALM_THROWN_RENDERER_SOURCE = `(e) => {
try { if (e && e.name === 'WorkflowError') return e } catch { /* hostile name getter: fall through to rendering */ }
const rendered = (() => {
try { if (e && typeof e.stack === 'string' && e.stack.length > 0) return e.stack } catch { /* hostile stack getter */ }
try { if (e && typeof e.message === 'string') return e.message } catch { /* hostile message getter */ }
try { return String(e) } catch { /* hostile toString/Symbol.toPrimitive */ }
return '[unrenderable thrown value]'
})()
return { __wfThrown: rendered }
}`
/**
* The pre-rendered failure text carried by a realm-catch wrapper object
* (`{ __wfThrown: string }` from {@link REALM_THROWN_RENDERER_SOURCE}), or
* `undefined` when `error` is not such a wrapper. Descriptor-read and
* proxy-guarded: never invokes user code.
* @param error - the value a host catch received from script execution.
* @returns the realm-rendered string, or `undefined` to fall back to
* {@link describeThrown}.
*/
export function thrownRendering(error: unknown): string | undefined {
if (typeof error !== 'object' || error === null || types.isProxy(error)) return undefined
const value = ownDataProperty(error, '__wfThrown')
return typeof value === 'string' ? value : undefined
}
/**
* The host realm's own native `stack` getter (modern V8 makes `stack` an own
* ACCESSOR on Errors); `undefined` where it is a data property. Typed through
* a structural view of the descriptor — it is only ever identity-compared or
* `.call`ed on an explicit receiver, never invoked unbound.
*/
const HOST_STACK_GETTER: unknown = (Object.getOwnPropertyDescriptor(new Error(), 'stack') as { get?: unknown } | undefined)?.get
/**
* Render a thrown value HOST-SIDE without ever throwing and without running
* any code the host does not own: proxies become a fixed label (trap-free
* `isProxy` before any inspection); `stack` is read as an own data descriptor,
* or through its getter ONLY when that getter's function identity is the host
* realm's own native stack getter (an unforgeable check — realm code cannot
* hold that identity, and the host realm's `prepareStackTrace` is the host's
* own trust domain); `message` is an own-data read; anything else
* object-shaped renders as `[object Object]` untouched; only primitives
* (which cannot carry code) reach `String()`. Used for host-thrown errors
* (vm timeouts, `WorkflowError`s) and as the fallback for adversarial values
* that bypassed the realm-side renderer (e.g. a hostile thenable rejection);
* ordinary script failures arrive pre-rendered via {@link thrownRendering}.
* @param error - the thrown value, of any shape and any realm.
* @returns human-readable text for the failure report; prefers the stack.
*/
@@ -86,24 +136,18 @@ export function describeThrown(error: unknown): string {
}
/**
* Read `error.stack`, tolerating both descriptor shapes: an own DATA property
* (older V8, plain objects) and the modern own ACCESSOR pair (the Error Stack
* Accessor proposal). Invoking the getter is the only way to obtain a real
* stack; on a hostile object that getter is user code, so the call is
* contained — a throw yields `undefined` (the caller falls back to message),
* and a synchronous spin is the engine's already-accepted post-await
* limitation (a script can spin directly just the same).
* Read `error.stack` without running foreign code: an own DATA descriptor is
* read directly; an accessor is invoked only on function identity with
* {@link HOST_STACK_GETTER} (never a realm or user function). The native
* getter returns `undefined` on a non-Error receiver rather than throwing.
*/
function readStack(error: object): unknown {
const descriptor = Object.getOwnPropertyDescriptor(error, 'stack')
if (descriptor === undefined) return undefined
if ('value' in descriptor) return descriptor.value
if (typeof descriptor.get !== 'function') return undefined
try {
return descriptor.get.call(error)
} catch {
return undefined // a hostile stack getter threw; message/fallback renders instead
}
if (descriptor.get !== HOST_STACK_GETTER) return undefined
return descriptor.get.call(error)
}
/** An own DATA property's value (`undefined` for absent or accessor); never invokes user code on a non-proxy object. */
+18 -9
View File
@@ -41,7 +41,7 @@ import type {
WorkflowMeta,
WorkflowResult,
} from '@deepseek-ai/dsh-workflow'
import { materializeFromRealm, MaterializeError, describeThrown } from './realm.ts'
import { materializeFromRealm, MaterializeError, describeThrown, thrownRendering, REALM_THROWN_RENDERER_SOURCE } from './realm.ts'
/** The per-run knobs the engine resolves from its Config. */
export interface ExecutionLimits {
@@ -137,13 +137,19 @@ export class WorkflowExecution {
) {
// Compile FIRST: a body syntax error must throw out of the constructor
// (the engine maps it to SCRIPT_PARSE) before any realm state exists.
// The body is wrapped in a realm-side catch that pre-renders any thrown
// value to a string (see REALM_THROWN_RENDERER_SOURCE) — rendering happens
// inside the realm's own execution window, never on a host catch path.
// lineOffset compensates for the wrapper line, so stack traces carry the
// script's own line numbers (the meta statement was blanked, not removed).
try {
this.compiled = new vm.Script(`(async () => {\n${body}\n})()`, {
filename: `workflow:${meta.name}`,
lineOffset: -1,
})
this.compiled = new vm.Script(
`(async () => { try {\n${body}\n} catch (e) { throw (${REALM_THROWN_RENDERER_SOURCE})(e) } })()`,
{
filename: `workflow:${meta.name}`,
lineOffset: -1,
},
)
} catch (error: unknown) {
throw new WorkflowError(`workflow script does not parse: ${String(error)}`, 'SCRIPT_PARSE', { cause: error })
}
@@ -225,10 +231,13 @@ export class WorkflowExecution {
if (error instanceof WorkflowError && error.code === 'CANCELLED') {
return { value: null, stopReason: 'cancelled', error: error.message, agentsStarted: this.started }
}
// describeThrown is total and trap-free: a hostile thrown value (a
// throwing accessor, a proxy) cannot make this catch throw — drive()
// resolving is the `result` never-rejects seam contract.
return { value: null, stopReason: 'error', error: describeThrown(error), agentsStarted: this.started }
// Ordinary script failures arrive pre-rendered by the realm-side catch
// (thrownRendering); host-thrown errors (a vm timeout, a WorkflowError)
// and adversarial values that bypassed the wrapper (e.g. a hostile
// thenable rejection) render via the total, host-code-only
// describeThrown. Neither path can throw — drive() resolving is the
// `result` never-rejects seam contract.
return { value: null, stopReason: 'error', error: thrownRendering(error) ?? describeThrown(error), agentsStarted: this.started }
} finally {
// Reap strays: a script that fired agent() calls without awaiting them
// leaves live children behind after settlement — abort them all. (The