Просмотр исходного кода

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.
Tianyi Cui 3 месяцев назад
Родитель
Сommit
fff2e1f33d

+ 1 - 1
docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md

@@ -30,7 +30,7 @@ One deliberate strictness DIVERGENCE from CC: hook misuse — unknown or deferre
 
 **Realm boundary**: values entering the host (meta, hook options, schemas, the return value) go through `materializeFromRealm` — a descriptor walk that NEVER invokes accessors (the repo's `isJsonValue` is prototype-strict and getter-invoking, so it cannot run first; it would reject every cross-realm object and let realm code run outside the timed window) and rejects loud everything JSON cannot carry, proxies included (the trap-free `util.types.isProxy`, checked before any inspection, so realm-side traps never run on the host stack), copying via `Object.defineProperty` so a `"__proto__"` key becomes a data property, never a prototype mutation. Values entering the realm (`args`, `agent()` results) are rebuilt INSIDE the realm via the context's own `JSON.parse`, and `parallel`/`pipeline` resolve to realm-built arrays, so the script never holds a live host-prototype object. Realm functions (stages, thunks) are called, never materialized.
 
-**Containment**: every hook-returned promise carries a no-op rejection consumer, so a script that drops a promise cannot surface an unhandled rejection when cancellation rejects it — `dsh-app-boot` exits the process on unhandled rejections. Thrown script values (and meta-evaluation throws) are rendered by the total `describeThrown` — fixed labels for proxies/functions, own-descriptor reads, `String()` only on primitives, and a CONTAINED stack-getter call (modern V8 makes `stack` an own accessor on real Errors; a hostile getter's throw is swallowed) — so a hostile thrown value can neither escape the catch path raw nor make `result` reject. Caps (`maxConcurrentAgents` auto = `min(16, cores - 2)`, `maxTotalAgents` 1000, `maxItemsPerCall` 4096) and timeouts are validated Config, not literals.
+**Containment**: every hook-returned promise carries a no-op rejection consumer, so a script that drops a promise cannot surface an unhandled rejection when cancellation rejects it — `dsh-app-boot` exits the process on unhandled rejections. Thrown script/meta values are pre-rendered to a string by a realm-side catch compiled into the wrapper (rendering runs inside the realm's own execution window, so a hostile `stack` getter dies by the vm sync-slice timeout like any other script code — host-side formatting of realm errors is unfixable in general, since V8 stack formatting invokes script-controllable `name`/`prepareStackTrace` hooks); the host catch descriptor-reads that string or falls back to `describeThrown` (fixed labels, own-data reads, an identity-verified host-native stack getter), so `result` cannot reject. Caps (`maxConcurrentAgents` auto = `min(16, cores - 2)`, `maxTotalAgents` 1000, `maxItemsPerCall` 4096) and timeouts are validated Config, not literals.
 
 ### The consumer (dsh-tool-workflow)
 

+ 1 - 1
packages/workflow/workflow-vm/README.md

@@ -14,7 +14,7 @@ Values ENTERING the host (the meta literal, hook options/schemas, the script's r
 
 ## Limits, cancellation, disposal
 
-Per-run: a concurrency semaphore (`maxConcurrentAgents`), a total-`agent()` cap (`maxTotalAgents`), and a per-call item cap (`maxItemsPerCall`), all config. `cancel()` aborts every child (a shared `AbortSignal`), rejects waiting `agent()` slots, and makes every future hook call throw `CANCELLED` — the script dies at its next await and the run settles `cancelled`; a cancellation that lands before the body runs (or before it settles) reports `cancelled` even if the script itself needed no hooks. Once a run settles, stray children a script fired without awaiting are aborted too, and `dispose()` waits for those children to finish disposing (bounded by the grace) before returning. Every hook-returned promise carries a no-op rejection consumer, so a dropped promise cannot surface an unhandled rejection (the app boot layer exits the process on those), and thrown script values are rendered by the total `describeThrown` (proxy-labelling, own-descriptor reads, a contained stack-getter call), so a hostile throw (`{ get stack() { throw ... } }`) cannot make `result` reject.
+Per-run: a concurrency semaphore (`maxConcurrentAgents`), a total-`agent()` cap (`maxTotalAgents`), and a per-call item cap (`maxItemsPerCall`), all config. `cancel()` aborts every child (a shared `AbortSignal`), rejects waiting `agent()` slots, and makes every future hook call throw `CANCELLED` — the script dies at its next await and the run settles `cancelled`; a cancellation that lands before the body runs (or before it settles) reports `cancelled` even if the script itself needed no hooks. Once a run settles, stray children a script fired without awaiting are aborted too, and `dispose()` waits for those children to finish disposing (bounded by the grace) before returning. Every hook-returned promise carries a no-op rejection consumer, so a dropped promise cannot surface an unhandled rejection (the app boot layer exits the process on those). Thrown script values are pre-rendered to a string INSIDE the realm's execution window (the body is compiled into a realm-side catch), so a hostile `stack` getter is subject to the vm sync-slice timeout like any other script code; the host catch only descriptor-reads that string, falling back to `describeThrown` (fixed labels, own-data reads, an identity-verified host-native stack getter) — `result` cannot reject.
 
 **Documented limitations** (the accepted cost of the in-process mechanism; the seam exists so a worker-thread/isolated-vm engine can swap in): vm is NOT a security boundary — scripts are model-written, the same trust level as the model's bash access — and the vm `timeout` covers only the initial synchronous slice, so a pathological synchronous spin after the first await cannot be killed; `dispose()` waits `disposeGraceMs` then ABANDONS such a script (its settlement stays contained, but an abandoned spin would still occupy the event loop).
 

+ 15 - 7
packages/workflow/workflow-vm/src/meta.ts

@@ -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
packages/workflow/workflow-vm/src/realm.ts

@@ -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
packages/workflow/workflow-vm/src/runtime.ts

@@ -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

+ 15 - 2
packages/workflow/workflow-vm/tests/meta.spec.ts

@@ -116,11 +116,24 @@ return 2`
     expect(error.message).toContain('proxies cannot cross')
   })
 
-  it('a meta expression THROWING a hostile value maps to META_INVALID — rendering runs no realm code', () => {
+  it('a meta expression THROWING a hostile value maps to META_INVALID — rendering stays realm-side', () => {
+    // bad() rethrows anything that is not a WorkflowError, so a hostile value
+    // escaping the realm-side renderer raw would fail this test.
     const error = bad('export const meta = { name: (() => { throw { get stack() { throw new Error("boom") }, toString() { throw new Error("boom") } } })(), description: "d" }\nreturn 1')
     expect(error.code).toBe('META_INVALID')
     expect(error.message).toContain('pure literal')
-    expect(error.message).toContain('[object Object]')
+    expect(error.message).toContain('[unrenderable thrown value]')
+  })
+
+  it('a spinning meta expression (even inside a thrown stack getter) dies by the eval timeout', () => {
+    try {
+      extractMeta('export const meta = { name: (() => { while (true) {} })(), description: "d" }', 50)
+      throw new Error('expected the extraction to time out')
+    } catch (error: unknown) {
+      expect(error).toBeInstanceOf(WorkflowError)
+      expect((error as WorkflowError).code).toBe('META_INVALID')
+      expect((error as WorkflowError).message.toLowerCase()).toContain('timed out')
+    }
   })
 
   it('rejects shape violations with EVERY violation listed (META_INVALID)', () => {

+ 44 - 1
packages/workflow/workflow-vm/tests/realm.spec.ts

@@ -1,6 +1,6 @@
 import { describe, expect, it } from 'vitest'
 import * as vm from 'node:vm'
-import { materializeFromRealm, MaterializeError } from '../src/realm.ts'
+import { materializeFromRealm, MaterializeError, describeThrown, thrownRendering } from '../src/realm.ts'
 
 /** Evaluate an expression inside a fresh vm realm and hand back the raw realm value. */
 function inRealm(expression: string): unknown {
@@ -133,3 +133,46 @@ describe('materializeFromRealm', () => {
     expect(materializeFromRealm(null)).toBeNull()
   })
 })
+
+describe('describeThrown (host-side thrown-value rendering)', () => {
+  it('renders a HOST Error via its identity-verified native stack getter', () => {
+    const error = new Error('host failure')
+    const rendered = describeThrown(error)
+    expect(rendered).toContain('host failure')
+    expect(rendered).toContain('at ') // a real stack, not just the message
+  })
+
+  it('never invokes a REALM error stack getter (identity mismatch) — message renders instead', () => {
+    const realmError: unknown = vm.runInNewContext('(() => { try { throw new Error("realm failure") } catch (e) { return e } })()')
+    expect(describeThrown(realmError)).toBe('realm failure')
+  })
+
+  it('reads a data-property stack directly and falls through a setter-only accessor', () => {
+    expect(describeThrown({ stack: 'data stack' })).toBe('data stack')
+    const setterOnly = { message: 'via message' }
+    Object.defineProperty(setterOnly, 'stack', { set() { /* swallow */ } })
+    expect(describeThrown(setterOnly)).toBe('via message')
+  })
+
+  it('labels proxies and functions without touching them; primitives stringify', () => {
+    expect(describeThrown(new Proxy({}, { getOwnPropertyDescriptor() { throw new Error('trap ran') } }))).toBe('[thrown proxy]')
+    expect(describeThrown(() => 1)).toBe('[thrown function]')
+    expect(describeThrown('plain')).toBe('plain')
+    expect(describeThrown(42)).toBe('42')
+    expect(describeThrown(undefined)).toBe('undefined')
+    expect(describeThrown(null)).toBe('null')
+    expect(describeThrown({ code: 42 })).toBe('[object Object]')
+  })
+})
+
+describe('thrownRendering (the realm-catch wrapper reader)', () => {
+  it('extracts the pre-rendered string from a wrapper and nothing else', () => {
+    expect(thrownRendering({ __wfThrown: 'rendered text' })).toBe('rendered text')
+    expect(thrownRendering({ __wfThrown: 42 })).toBeUndefined()
+    expect(thrownRendering({ other: 'x' })).toBeUndefined()
+    expect(thrownRendering(new Error('plain'))).toBeUndefined()
+    expect(thrownRendering('string')).toBeUndefined()
+    expect(thrownRendering(null)).toBeUndefined()
+    expect(thrownRendering(new Proxy({ __wfThrown: 'forged' }, { getOwnPropertyDescriptor() { throw new Error('trap ran') } }))).toBeUndefined()
+  })
+})

+ 29 - 8
packages/workflow/workflow-vm/tests/workflow-vm.spec.ts

@@ -589,24 +589,24 @@ describe('dsh-workflow-vm', () => {
       expect(result.error).toBe('[object Object]')
     })
 
-    it('hostile thrown values render contained: result NEVER rejects, no unhandled rejection', async () => {
+    it('hostile thrown values render realm-side: result NEVER rejects, no unhandled rejection', async () => {
       const unhandled: unknown[] = []
       const onUnhandled = (reason: unknown): void => { unhandled.push(reason) }
       process.on('unhandledRejection', onUnhandled)
       try {
         const { ctx, parent } = await setup()
-        // Each thrown value would run realm code (or throw) under a plain
-        // property read or String(); rendering must stay total — the only
-        // permitted realm call is the CONTAINED stack getter.
+        // Each thrown value runs code (or throws) when rendered — the realm
+        // wrapper renders it INSIDE script execution, and the host catch only
+        // ever descriptor-reads the pre-rendered string.
         const cases: [string, string][] = [
           ["throw { get stack() { throw new Error('stack getter threw') } }", '[object Object]'],
           ["throw { get stack() { throw new Error('x') }, message: 'getter threw, message renders' }", 'getter threw, message renders'],
-          ["throw { get message() { throw new Error('message getter ran') } }", '[object Object]'],
+          ["throw { get message() { throw new Error('message getter threw') } }", '[object Object]'],
           ["throw { stack: 'custom data stack' }", 'custom data stack'],
           ["throw (() => { const o = { message: 'setter-only stack' }; Object.defineProperty(o, 'stack', { set() {} }); return o })()", 'setter-only stack'],
-          ["throw new Proxy({}, { getOwnPropertyDescriptor() { throw new Error('trap ran') } })", '[thrown proxy]'],
-          ["throw { [Symbol.toPrimitive]() { throw new Error('toPrimitive ran') } }", '[object Object]'],
-          ['throw () => 1', '[thrown function]'],
+          ["throw new Proxy({}, { getOwnPropertyDescriptor() { throw new Error('gopd trap threw') } })", '[object Object]'],
+          ["throw { [Symbol.toPrimitive]() { throw new Error('toPrimitive threw') } }", '[unrenderable thrown value]'],
+          ['throw () => 1', '() => 1'],
           ['throw null', 'null'],
         ]
         for (const [body, rendered] of cases) {
@@ -622,6 +622,27 @@ describe('dsh-workflow-vm', () => {
       }
     })
 
+    it('a synchronous spin hidden in a thrown stack getter dies by the vm timeout, not on the host', async () => {
+      const { ctx, parent } = await setup({ config: { provider: 'stub', syncTimeoutMs: 50 } })
+      // The realm-side renderer reads e.stack INSIDE the timed sync slice, so
+      // the spin is killed exactly like a plain `while (true) {}` body.
+      const result = await run(ctx, parent, script('throw { get stack() { while (true) {} } }'))
+      expect(result.stopReason).toBe('error')
+      expect(result.error?.toLowerCase()).toContain('timed out')
+    })
+
+    it('a hostile thenable rejection that bypasses the realm wrapper renders host-side, data-only', async () => {
+      const { ctx, parent } = await setup()
+      // Returning a thenable makes the host unwrap it AFTER the script
+      // settled — its rejection value skips the realm catch entirely and hits
+      // drive()'s catch raw. The proxy must be labelled, its traps never run.
+      const result = await run(ctx, parent, script(`
+        return { then(_resolve, reject) { reject(new Proxy({}, { getOwnPropertyDescriptor() { throw new Error('trap ran') } })) } }
+      `))
+      expect(result.stopReason).toBe('error')
+      expect(result.error).toBe('[thrown proxy]')
+    })
+
     it('falls back to the message for an Error whose stack was stripped', async () => {
       const { ctx, parent } = await setup()
       const result = await run(ctx, parent, script(`