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:
@@ -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)', () => {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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(`
|
||||
|
||||
Reference in New Issue
Block a user