From b908241580c8f66bc98507cb58656944e5f469b3 Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Tue, 21 Jul 2026 19:32:12 -0700 Subject: [PATCH 1/3] feat(core): deterministic sandbox hardening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - crypto.subtle.digest computes synchronously via node:crypto: byte-identical values, promise settles on a deterministic microtask, full BufferSource validation (internal-slot view reads, SAB rejection) - Atomics.waitAsync (a wall-clock timer), async WebAssembly compilation, WeakRef, and FinalizationRegistry are removed from the sandbox — wall clock and GC observation are unreplayable; sync WebAssembly constructors remain - freezeSerializationIntrinsics pins the universal dispatch surfaces: Object.prototype/Array.prototype/Function.prototype are frozen (every missed property read and hasInstance lookup terminates there) and serialization-referenced global bindings are non-writable. Value-type prototypes and constructor statics stay patchable so polyfills (Temporal's Date.prototype.toTemporalInstant, core-js Set.prototype .union / Object.groupBy) keep working — the retained-input gate verifies the members serialization executes per boundary instead. Groundwork for retained-VM replay (#2990). --- .changeset/vm-determinism-hardening.md | 6 + .../v5/api-reference/workflow-globals.mdx | 6 +- packages/core/src/vm/index.test.ts | 221 +++++++++++++++++- packages/core/src/vm/index.ts | 199 +++++++++++++++- packages/core/src/workflow.ts | 6 +- 5 files changed, 433 insertions(+), 5 deletions(-) create mode 100644 .changeset/vm-determinism-hardening.md diff --git a/.changeset/vm-determinism-hardening.md b/.changeset/vm-determinism-hardening.md new file mode 100644 index 0000000000..d40a2cffd8 --- /dev/null +++ b/.changeset/vm-determinism-hardening.md @@ -0,0 +1,6 @@ +--- +'@workflow/core': patch +'workflow': patch +--- + +Deterministic sandbox hardening: `crypto.subtle.digest` in workflow functions now computes synchronously via `node:crypto` (byte-identical values, deterministic timing under replay); `Atomics.waitAsync`, async `WebAssembly` compilation, `WeakRef`, and `FinalizationRegistry` are no longer exposed (wall-clock timing and GC observation cannot be replayed); and the sandbox pins the surfaces serialization dispatch resolves through: `Object.prototype`/`Array.prototype`/`Function.prototype` are frozen and serialization-relevant global bindings are non-writable. Built-in prototypes and constructor statics remain patchable, so polyfills (Temporal, core-js) keep working. diff --git a/docs/content/docs/v5/api-reference/workflow-globals.mdx b/docs/content/docs/v5/api-reference/workflow-globals.mdx index 6ec32b6b0b..2ec290738f 100644 --- a/docs/content/docs/v5/api-reference/workflow-globals.mdx +++ b/docs/content/docs/v5/api-reference/workflow-globals.mdx @@ -26,7 +26,7 @@ These APIs are available but are **seeded or fixed** to ensure deterministic beh | [`Date`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date) / `Date.now()` / `new Date()` | Returns a fixed timestamp that advances with the workflow's logical clock | | [`crypto.getRandomValues()`](https://developer.mozilla.org/en-US/docs/Web/API/Crypto/getRandomValues) | Seeded — produces deterministic output for a given workflow run | | [`crypto.randomUUID()`](https://developer.mozilla.org/en-US/docs/Web/API/Crypto/randomUUID) | Seeded — produces deterministic UUIDs for a given workflow run | -| [`crypto.subtle.digest()`](https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/digest) | Passes through to the real implementation (SHA-256, etc. are deterministic by nature) | +| [`crypto.subtle.digest()`](https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/digest) | Computed synchronously via `node:crypto` (values are byte-identical to WebCrypto), so the promise settles at a deterministic point during replay | You can safely use `Math.random()`, `Date.now()`, and `crypto.randomUUID()` in workflow functions. The framework ensures these return the same values across replays. @@ -100,3 +100,7 @@ The following are **not available** in workflow functions. Move this logic to [s - **Global `fetch`**: Use [`import { fetch } from "workflow"`](/docs/api-reference/workflow/fetch) instead. See [fetch-in-workflow](/docs/errors/fetch-in-workflow). - **Timers**: `setTimeout`, `setInterval`, `setImmediate`, and their `clear*` counterparts. Use [`sleep()`](/docs/api-reference/workflow/sleep) instead. See [timeout-in-workflow](/docs/errors/timeout-in-workflow). - **`Buffer`**: Node.js-specific API. Use `Uint8Array` with `toBase64()` / `fromBase64()` / `toHex()` / `fromHex()` for binary data encoding, or `atob()` / `btoa()` for string-based base64. +- **`WeakRef` and `FinalizationRegistry`**: garbage-collection timing is not deterministic, so observing it would make workflow code impossible to replay. (`WeakMap` and `WeakSet` remain available — they do not expose GC state.) +- **`Atomics.waitAsync`**: a wall-clock timer, which cannot be replayed. Use [`sleep()`](/docs/api-reference/workflow/sleep) instead. +- **Async `WebAssembly` compilation** (`compile`, `instantiate`, `compileStreaming`, `instantiateStreaming`): resolves on compile-thread timing. The synchronous `new WebAssembly.Module()` and `new WebAssembly.Instance()` constructors remain available. +- **Mutating core intrinsics**: `Object.prototype`, `Array.prototype`, and `Function.prototype` are frozen, and the global bindings for serialization-relevant built-ins (`Map`, `Date`, `Headers`, and similar) cannot be reassigned — serialization dispatch resolves through these, so redefining them would make replay unfaithful. Adding methods to built-in prototypes (e.g. a Temporal polyfill's `Date.prototype.toTemporalInstant`) and polyfilling constructor statics (`Object.groupBy`) remain allowed. diff --git a/packages/core/src/vm/index.test.ts b/packages/core/src/vm/index.test.ts index 0381b60f63..f28f330cb4 100644 --- a/packages/core/src/vm/index.test.ts +++ b/packages/core/src/vm/index.test.ts @@ -1,6 +1,6 @@ import * as vm from 'node:vm'; import { describe, expect, it } from 'vitest'; -import { createContext } from './index.js'; +import { createContext, freezeSerializationIntrinsics } from './index.js'; const seed = 'entropy seed'; const fixedTimestamp = 1234567890000; @@ -257,3 +257,222 @@ describe('createContext', () => { expect(result).toBe('undefined'); }); }); + +describe('host-timed async', () => { + it('does not expose any API that settles on host timing', () => { + const { context } = createContext({ seed, fixedTimestamp }); + + expect(vm.runInContext('typeof Atomics.waitAsync', context)).toBe( + 'undefined' + ); + expect(vm.runInContext('typeof WebAssembly.compile', context)).toBe( + 'undefined' + ); + expect(vm.runInContext('typeof WebAssembly.instantiate', context)).toBe( + 'undefined' + ); + expect( + vm.runInContext('typeof WebAssembly.compileStreaming', context) + ).toBe('undefined'); + expect( + vm.runInContext('typeof WebAssembly.instantiateStreaming', context) + ).toBe('undefined'); + // Deterministic synchronous WebAssembly stays available. + expect(vm.runInContext('typeof WebAssembly.Module', context)).toBe( + 'function' + ); + }); +}); + +describe('crypto.subtle.digest', () => { + it.each([ + 'SHA-1', + 'SHA-256', + 'SHA-384', + 'SHA-512', + ])('matches WebCrypto for %s', async (algorithm) => { + const { context } = createContext({ seed, fixedTimestamp }); + + const result = await vm.runInContext( + `crypto.subtle.digest(${JSON.stringify(algorithm)}, new Uint8Array([1,2,3,4,5,255,0,128]))`, + context + ); + const expected = await globalThis.crypto.subtle.digest( + algorithm, + new Uint8Array([1, 2, 3, 4, 5, 255, 0, 128]) + ); + expect(result).toBeInstanceOf(ArrayBuffer); + expect(new Uint8Array(result)).toEqual(new Uint8Array(expected)); + }); + + it('accepts the { name } algorithm form and ArrayBuffer input', async () => { + const { context } = createContext({ seed, fixedTimestamp }); + + const result = await vm.runInContext( + 'crypto.subtle.digest({ name: "sha-256" }, new ArrayBuffer(4))', + context + ); + const expected = await globalThis.crypto.subtle.digest( + 'SHA-256', + new ArrayBuffer(4) + ); + expect(new Uint8Array(result)).toEqual(new Uint8Array(expected)); + }); + + it('respects typed-array subviews', async () => { + const { context } = createContext({ seed, fixedTimestamp }); + + const result = await vm.runInContext( + 'const bytes = new Uint8Array([9, 1, 2, 9]); crypto.subtle.digest("SHA-256", bytes.subarray(1, 3))', + context + ); + const expected = await globalThis.crypto.subtle.digest( + 'SHA-256', + new Uint8Array([1, 2]) + ); + expect(new Uint8Array(result)).toEqual(new Uint8Array(expected)); + }); + + it('rejects unsupported algorithms like WebCrypto does', async () => { + const { context } = createContext({ seed, fixedTimestamp }); + + await expect( + vm.runInContext('crypto.subtle.digest("MD5", new Uint8Array(1))', context) + ).rejects.toMatchObject({ name: 'NotSupportedError' }); + }); +}); + +describe('GC observation', () => { + it('does not expose WeakRef or FinalizationRegistry', () => { + const { context } = createContext({ seed, fixedTimestamp }); + + expect(vm.runInContext('typeof WeakRef', context)).toBe('undefined'); + expect(vm.runInContext('typeof FinalizationRegistry', context)).toBe( + 'undefined' + ); + // WeakMap/WeakSet do not expose GC state and stay available. + expect(vm.runInContext('typeof WeakMap', context)).toBe('function'); + expect(vm.runInContext('typeof WeakSet', context)).toBe('function'); + }); +}); + +describe('crypto.subtle.digest input validation', () => { + it.each([ + ['a number', '2000000000'], + ['a plain object', '({})'], + ['a string', '"data"'], + ['null', 'null'], + ])('rejects %s with TypeError', async (_label, expression) => { + const { context } = createContext({ seed, fixedTimestamp }); + + await expect( + vm.runInContext(`crypto.subtle.digest("SHA-256", ${expression})`, context) + ).rejects.toThrow(TypeError); + }); +}); + +describe('crypto.subtle.digest SharedArrayBuffer rejection', () => { + it('rejects SharedArrayBuffer-backed views like WebCrypto does', async () => { + const { context } = createContext({ seed, fixedTimestamp }); + + await expect( + vm.runInContext( + 'crypto.subtle.digest("SHA-256", new Uint8Array(new SharedArrayBuffer(4)))', + context + ) + ).rejects.toThrow(TypeError); + }); +}); + +describe('crypto.subtle.digest view metadata', () => { + it('reads view ranges from internal slots, ignoring shadowed properties', async () => { + const { context } = createContext({ seed, fixedTimestamp }); + + const result = await vm.runInContext( + `(() => { + const view = new Uint8Array([1, 2, 3, 4]); + Object.defineProperty(view, "byteLength", { value: 0 }); + Object.defineProperty(view, "byteOffset", { value: 2 }); + return crypto.subtle.digest("SHA-256", view); + })()`, + context + ); + const expected = await globalThis.crypto.subtle.digest( + 'SHA-256', + new Uint8Array([1, 2, 3, 4]) + ); + expect(new Uint8Array(result)).toEqual(new Uint8Array(expected)); + }); +}); + +describe('freezeSerializationIntrinsics', () => { + it('freezes the universal lookup backstops', () => { + const { context, globalThis: g } = createContext({ seed, fixedTimestamp }); + freezeSerializationIntrinsics(g); + + expect(() => + vm.runInContext(`"use strict"; Object.prototype.polluted = 1`, context) + ).toThrow(/not extensible/); + expect(() => + vm.runInContext( + `"use strict"; Object.defineProperty(Array.prototype, "signal", { get() { return 1; } })`, + context + ) + ).toThrow(/not extensible/); + expect(() => + vm.runInContext( + `"use strict"; Function.prototype[Symbol.hasInstance] = () => true`, + context + ) + ).toThrow(/read only|not extensible/); + }); + + it('pins global bindings, including intentionally absent ones', () => { + const { context, globalThis: g } = createContext({ seed, fixedTimestamp }); + freezeSerializationIntrinsics(g); + + vm.runInContext( + 'try { globalThis.Map = function () {} } catch {}', + context + ); + expect(vm.runInContext('typeof Map.prototype.get', context)).toBe( + 'function' + ); + vm.runInContext( + 'try { globalThis.Request = function () {} } catch {}', + context + ); + expect(vm.runInContext('typeof Request', context)).toBe('undefined'); + }); + + it('leaves value-type prototypes and constructor statics patchable (polyfills)', () => { + const { context, globalThis: g } = createContext({ seed, fixedTimestamp }); + freezeSerializationIntrinsics(g); + + // Temporal-style prototype method and modern static polyfills must work. + const result = vm.runInContext( + `"use strict"; + Date.prototype.toTemporalInstant = function () { return "instant:" + this.getTime(); }; + Object.groupBy = (items, fn) => items.reduce((acc, x) => ((acc[fn(x)] ??= []).push(x), acc), {}); + Set.prototype.union = function (other) { return new Set([...this, ...other]); }; + [ + new Date(5).toTemporalInstant(), + Object.groupBy([1, 2, 3], (x) => (x % 2 ? "odd" : "even")).odd.length, + new Set([1]).union(new Set([2])).size, + ]`, + context + ); + expect(result[0]).toBe('instant:5'); + expect(result[1]).toBe(2); + expect(result[2]).toBe(2); + }); + + it('leaves ordinary workflow globals writable', () => { + const { context, globalThis: g } = createContext({ seed, fixedTimestamp }); + freezeSerializationIntrinsics(g); + + expect( + vm.runInContext('globalThis.myState = { ok: true }; myState.ok', context) + ).toBe(true); + }); +}); diff --git a/packages/core/src/vm/index.ts b/packages/core/src/vm/index.ts index c0003bdc39..f23bb71488 100644 --- a/packages/core/src/vm/index.ts +++ b/packages/core/src/vm/index.ts @@ -1,3 +1,5 @@ +import { createHash } from 'node:crypto'; +import { types } from 'node:util'; import { runInContext, createContext as vmCreateContext } from 'node:vm'; import { WorkflowRuntimeError } from '@workflow/errors'; import seedrandom from 'seedrandom'; @@ -10,6 +12,153 @@ export interface CreateContextOptions { fixedTimestamp: number; } +// WebCrypto digest algorithm names → node:crypto (OpenSSL) names. +const DIGEST_ALGORITHMS: Record = { + 'SHA-1': 'sha1', + 'SHA-256': 'sha256', + 'SHA-384': 'sha384', + 'SHA-512': 'sha512', +}; + +// Intrinsic prototype getters, captured so view metadata is read from +// internal slots like WebCrypto's BufferSource conversion — own properties +// shadowing `buffer`/`byteOffset`/`byteLength` on a view must not change +// which bytes are hashed. +function intrinsicGetter(prototype: object, name: string) { + // biome-ignore lint/style/noNonNullAssertion: intrinsic accessors always exist + return Object.getOwnPropertyDescriptor(prototype, name)!.get!; +} +const arrayBufferByteLength = intrinsicGetter( + ArrayBuffer.prototype, + 'byteLength' +); +const typedArrayPrototype = Object.getPrototypeOf( + Uint8Array.prototype +) as object; +const viewGetters = { + typedArray: { + buffer: intrinsicGetter(typedArrayPrototype, 'buffer'), + byteOffset: intrinsicGetter(typedArrayPrototype, 'byteOffset'), + byteLength: intrinsicGetter(typedArrayPrototype, 'byteLength'), + }, + dataView: { + buffer: intrinsicGetter(DataView.prototype, 'buffer'), + byteOffset: intrinsicGetter(DataView.prototype, 'byteOffset'), + byteLength: intrinsicGetter(DataView.prototype, 'byteLength'), + }, +}; + +// WebCrypto BufferSource conversion: typed-array/DataView views are read via +// internal slots; anything else must be a real ArrayBuffer (the native +// byteLength getter is a brand check that works across vm realms) so that +// e.g. a plain number is rejected with TypeError instead of allocating a +// Uint8Array of that length. +function toDigestBytes(data: ArrayBuffer | ArrayBufferView): Uint8Array { + if (types.isTypedArray(data) || types.isDataView(data)) { + const getters = types.isDataView(data) + ? viewGetters.dataView + : viewGetters.typedArray; + const buffer = getters.buffer.call(data) as ArrayBuffer; + // WebCrypto's BufferSource excludes SharedArrayBuffer-backed views. + if (types.isSharedArrayBuffer(buffer)) { + throw new TypeError( + 'crypto.subtle.digest does not accept SharedArrayBuffer-backed views' + ); + } + return new Uint8Array( + buffer, + getters.byteOffset.call(data) as number, + getters.byteLength.call(data) as number + ); + } + arrayBufferByteLength.call(data); + return new Uint8Array(data as ArrayBuffer); +} + +// Global bindings the serialization reducers dispatch on (`value instanceof +// global.X` — e.g. the Map/Set/Headers reducers in +// packages/core/src/serialization/reducers/common.ts:312-329 and the +// Request/Response/stream reducers in packages/core/src/serialization.ts). +// Pinned (non-writable) so workflow code cannot swap in a constructor whose +// `Symbol.hasInstance` the serializer would then execute. Absent bindings are +// pinned to `undefined` so a spoofing global cannot be introduced either. +const SERIALIZATION_BINDINGS = [ + 'Object', + 'Array', + 'Function', + 'Map', + 'Set', + 'Date', + 'RegExp', + 'ArrayBuffer', + 'SharedArrayBuffer', + 'DataView', + 'Int8Array', + 'Uint8Array', + 'Uint8ClampedArray', + 'Int16Array', + 'Uint16Array', + 'Int32Array', + 'Uint32Array', + 'Float16Array', + 'Float32Array', + 'Float64Array', + 'BigInt64Array', + 'BigUint64Array', + 'Headers', + 'URL', + 'URLSearchParams', + 'DOMException', + 'AbortController', + 'AbortSignal', + 'Request', + 'Response', + 'ReadableStream', + 'WritableStream', + 'TransformStream', +] as const; + +/** + * Pin the sandbox surfaces that serialization dispatch resolves through. + * Called after ALL SDK globals are installed, immediately before the + * workflow bundle evaluates. + * + * Frozen — the universal lookup backstops only: + * - `Object.prototype` / `Array.prototype`: every missed property read on a + * plain object or array terminates here (reducer probes like `.signal`, + * `Symbol.toStringTag` lookups by devalue's type detection, `constructor` + * reads by the class reducer in + * packages/core/src/serialization/reducers/class.ts:26-43), so a planted + * accessor here would execute during serialization of ANY value. + * - `Function.prototype`: `x instanceof C` resolves `Symbol.hasInstance` + * through the constructor's prototype chain (tc39.es/ecma262/#sec-instanceofoperator), + * which ends here for every constructor. + * No real-world polyfill patches these three, so freezing them costs nothing. + * + * NOT frozen — constructors and the value-type prototypes (`Map.prototype`, + * `Date.prototype`, typed arrays, …): polyfills legitimately add methods + * there (Temporal's `Date.prototype.toTemporalInstant`, core-js + * `Set.prototype.union`, `Object.groupBy`, `Array.fromAsync`). New + * data-valued members are inert during serialization — only the specific + * members the serializer EXECUTES matter, and the retained-input gate + * verifies those per boundary against captured originals instead + * (see runtime/retained-step-input.ts). + */ +export function freezeSerializationIntrinsics(g: typeof globalThis): void { + Object.freeze(g.Object.prototype); + Object.freeze(g.Array.prototype); + Object.freeze(g.Function.prototype); + for (const name of SERIALIZATION_BINDINGS) { + const descriptor = Object.getOwnPropertyDescriptor(g, name); + Object.defineProperty(g, name, { + value: descriptor && 'value' in descriptor ? descriptor.value : undefined, + writable: false, + configurable: false, + enumerable: descriptor?.enumerable ?? false, + }); + } +} + /** * Creates a Node.js `vm.Context` configured to be usable for * executing workflow logic in a deterministic environment. @@ -58,7 +207,53 @@ export function createContext(options: CreateContextOptions) { const randomUUID = createRandomUUID(rng); - const boundDigest = originalSubtle.digest.bind(originalSubtle); + // The sandbox must not expose any way for workflow code to observe host + // timing or host state: after this block, every promise a workflow can + // create settles either from the event log or within its own microtask + // cascade, so a suspended VM is fully quiescent and can be retained across + // inline steps (see `canRetainWorkflowSession`). + // + // - `Atomics.waitAsync` is a wall-clock timer (via SharedArrayBuffer). + // - The async `WebAssembly` entry points resolve on compile-thread timing; + // the synchronous `new WebAssembly.Module()` / `Instance()` remain. + // - `WeakRef.deref()` and finalizer callbacks observe GC timing. + // - Dynamic `import()` settles within a microtask (rejected: no + // `importModuleDynamically`), so it needs no handling. + const intrinsics = g as unknown as Record>; + delete intrinsics.Atomics.waitAsync; + delete intrinsics.WebAssembly.compile; + delete intrinsics.WebAssembly.instantiate; + delete intrinsics.WebAssembly.compileStreaming; + delete intrinsics.WebAssembly.instantiateStreaming; + delete intrinsics.WeakRef; + delete intrinsics.FinalizationRegistry; + + // `crypto.subtle.digest` computes synchronously via node:crypto, so its + // promise settles on a deterministic microtask instead of host threadpool + // timing — a digest can never advance a suspended workflow, and + // digest-using VMs stay retainable. Values are byte-identical to WebCrypto. + const digest = ( + algorithm: string | { name: string }, + data: ArrayBuffer | ArrayBufferView + ): Promise => { + try { + const name = typeof algorithm === 'string' ? algorithm : algorithm.name; + const ossl = DIGEST_ALGORITHMS[name.toUpperCase()]; + if (!ossl) { + throw new DOMException( + `Unrecognized algorithm name: ${name}`, + 'NotSupportedError' + ); + } + const hash = createHash(ossl).update(toDigestBytes(data)).digest(); + // Copy out: small Buffers share the internal pool allocation. + const out = new ArrayBuffer(hash.byteLength); + new Uint8Array(out).set(hash); + return Promise.resolve(out); + } catch (error) { + return Promise.reject(error); + } + }; g.crypto = new Proxy(originalCrypto, { get(target, prop) { @@ -78,7 +273,7 @@ export function createContext(options: CreateContextOptions) { ); }; } else if (prop === 'digest') { - return boundDigest; + return digest; } return target[prop as keyof typeof originalSubtle]; }, diff --git a/packages/core/src/workflow.ts b/packages/core/src/workflow.ts index 06fe1d79e2..8ed18de3d9 100644 --- a/packages/core/src/workflow.ts +++ b/packages/core/src/workflow.ts @@ -38,7 +38,7 @@ import { import * as Attribute from './telemetry/semantic-conventions.js'; import { trace } from './telemetry.js'; import { getWorkflowRunStreamId } from './util.js'; -import { createContext } from './vm/index.js'; +import { createContext, freezeSerializationIntrinsics } from './vm/index.js'; import { runCachedWorkflowScript } from './vm/script-cache.js'; import { createAbortSignalStatics, @@ -847,6 +847,10 @@ export async function runWorkflow( // Script rather than the line just past the end of the bundle. That path // is rare (it requires the lookup `?.get(...)` expression to throw) and // does not affect the workflow function or replay determinism. + // All SDK globals are installed; pin the serialization-consulted + // intrinsics before any workflow code can run. + freezeSerializationIntrinsics(vmGlobalThis); + runCachedWorkflowScript(workflowCode, filename, context); const workflowFn = runCachedWorkflowScript( `globalThis.__private_workflows?.get(${JSON.stringify(workflowRun.workflowName)})`, From 9395b2d2ae9aa328bbdcc734f221c0c276f140aa Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Thu, 23 Jul 2026 15:17:48 -0700 Subject: [PATCH 2/3] Drop serialization intrinsic freezing from the sandbox The retained-VM passivity design moved from pinning/verifying the sandbox surfaces serialization dispatches on to injecting hardened operations into devalue itself (with taint-based de-opt), so freezing Object/Array/Function prototypes and pinning global bindings is no longer needed. Keep only the determinism hardening (sync digest, removal of wall-clock/GC-observing APIs). --- .changeset/vm-determinism-hardening.md | 2 +- .../v5/api-reference/workflow-globals.mdx | 1 - packages/core/src/vm/index.test.ts | 74 +--------------- packages/core/src/vm/index.ts | 84 ------------------- packages/core/src/workflow.ts | 6 +- 5 files changed, 3 insertions(+), 164 deletions(-) diff --git a/.changeset/vm-determinism-hardening.md b/.changeset/vm-determinism-hardening.md index d40a2cffd8..912375fc39 100644 --- a/.changeset/vm-determinism-hardening.md +++ b/.changeset/vm-determinism-hardening.md @@ -3,4 +3,4 @@ 'workflow': patch --- -Deterministic sandbox hardening: `crypto.subtle.digest` in workflow functions now computes synchronously via `node:crypto` (byte-identical values, deterministic timing under replay); `Atomics.waitAsync`, async `WebAssembly` compilation, `WeakRef`, and `FinalizationRegistry` are no longer exposed (wall-clock timing and GC observation cannot be replayed); and the sandbox pins the surfaces serialization dispatch resolves through: `Object.prototype`/`Array.prototype`/`Function.prototype` are frozen and serialization-relevant global bindings are non-writable. Built-in prototypes and constructor statics remain patchable, so polyfills (Temporal, core-js) keep working. +Deterministic sandbox hardening: `crypto.subtle.digest` in workflow functions now computes synchronously via `node:crypto` (byte-identical values, deterministic timing under replay); `Atomics.waitAsync`, async `WebAssembly` compilation, `WeakRef`, and `FinalizationRegistry` are no longer exposed (wall-clock timing and GC observation cannot be replayed). diff --git a/docs/content/docs/v5/api-reference/workflow-globals.mdx b/docs/content/docs/v5/api-reference/workflow-globals.mdx index 2ec290738f..6faa7e3df6 100644 --- a/docs/content/docs/v5/api-reference/workflow-globals.mdx +++ b/docs/content/docs/v5/api-reference/workflow-globals.mdx @@ -103,4 +103,3 @@ The following are **not available** in workflow functions. Move this logic to [s - **`WeakRef` and `FinalizationRegistry`**: garbage-collection timing is not deterministic, so observing it would make workflow code impossible to replay. (`WeakMap` and `WeakSet` remain available — they do not expose GC state.) - **`Atomics.waitAsync`**: a wall-clock timer, which cannot be replayed. Use [`sleep()`](/docs/api-reference/workflow/sleep) instead. - **Async `WebAssembly` compilation** (`compile`, `instantiate`, `compileStreaming`, `instantiateStreaming`): resolves on compile-thread timing. The synchronous `new WebAssembly.Module()` and `new WebAssembly.Instance()` constructors remain available. -- **Mutating core intrinsics**: `Object.prototype`, `Array.prototype`, and `Function.prototype` are frozen, and the global bindings for serialization-relevant built-ins (`Map`, `Date`, `Headers`, and similar) cannot be reassigned — serialization dispatch resolves through these, so redefining them would make replay unfaithful. Adding methods to built-in prototypes (e.g. a Temporal polyfill's `Date.prototype.toTemporalInstant`) and polyfilling constructor statics (`Object.groupBy`) remain allowed. diff --git a/packages/core/src/vm/index.test.ts b/packages/core/src/vm/index.test.ts index f28f330cb4..7e778e10e1 100644 --- a/packages/core/src/vm/index.test.ts +++ b/packages/core/src/vm/index.test.ts @@ -1,6 +1,6 @@ import * as vm from 'node:vm'; import { describe, expect, it } from 'vitest'; -import { createContext, freezeSerializationIntrinsics } from './index.js'; +import { createContext } from './index.js'; const seed = 'entropy seed'; const fixedTimestamp = 1234567890000; @@ -404,75 +404,3 @@ describe('crypto.subtle.digest view metadata', () => { expect(new Uint8Array(result)).toEqual(new Uint8Array(expected)); }); }); - -describe('freezeSerializationIntrinsics', () => { - it('freezes the universal lookup backstops', () => { - const { context, globalThis: g } = createContext({ seed, fixedTimestamp }); - freezeSerializationIntrinsics(g); - - expect(() => - vm.runInContext(`"use strict"; Object.prototype.polluted = 1`, context) - ).toThrow(/not extensible/); - expect(() => - vm.runInContext( - `"use strict"; Object.defineProperty(Array.prototype, "signal", { get() { return 1; } })`, - context - ) - ).toThrow(/not extensible/); - expect(() => - vm.runInContext( - `"use strict"; Function.prototype[Symbol.hasInstance] = () => true`, - context - ) - ).toThrow(/read only|not extensible/); - }); - - it('pins global bindings, including intentionally absent ones', () => { - const { context, globalThis: g } = createContext({ seed, fixedTimestamp }); - freezeSerializationIntrinsics(g); - - vm.runInContext( - 'try { globalThis.Map = function () {} } catch {}', - context - ); - expect(vm.runInContext('typeof Map.prototype.get', context)).toBe( - 'function' - ); - vm.runInContext( - 'try { globalThis.Request = function () {} } catch {}', - context - ); - expect(vm.runInContext('typeof Request', context)).toBe('undefined'); - }); - - it('leaves value-type prototypes and constructor statics patchable (polyfills)', () => { - const { context, globalThis: g } = createContext({ seed, fixedTimestamp }); - freezeSerializationIntrinsics(g); - - // Temporal-style prototype method and modern static polyfills must work. - const result = vm.runInContext( - `"use strict"; - Date.prototype.toTemporalInstant = function () { return "instant:" + this.getTime(); }; - Object.groupBy = (items, fn) => items.reduce((acc, x) => ((acc[fn(x)] ??= []).push(x), acc), {}); - Set.prototype.union = function (other) { return new Set([...this, ...other]); }; - [ - new Date(5).toTemporalInstant(), - Object.groupBy([1, 2, 3], (x) => (x % 2 ? "odd" : "even")).odd.length, - new Set([1]).union(new Set([2])).size, - ]`, - context - ); - expect(result[0]).toBe('instant:5'); - expect(result[1]).toBe(2); - expect(result[2]).toBe(2); - }); - - it('leaves ordinary workflow globals writable', () => { - const { context, globalThis: g } = createContext({ seed, fixedTimestamp }); - freezeSerializationIntrinsics(g); - - expect( - vm.runInContext('globalThis.myState = { ok: true }; myState.ok', context) - ).toBe(true); - }); -}); diff --git a/packages/core/src/vm/index.ts b/packages/core/src/vm/index.ts index f23bb71488..39386d0378 100644 --- a/packages/core/src/vm/index.ts +++ b/packages/core/src/vm/index.ts @@ -75,90 +75,6 @@ function toDigestBytes(data: ArrayBuffer | ArrayBufferView): Uint8Array { return new Uint8Array(data as ArrayBuffer); } -// Global bindings the serialization reducers dispatch on (`value instanceof -// global.X` — e.g. the Map/Set/Headers reducers in -// packages/core/src/serialization/reducers/common.ts:312-329 and the -// Request/Response/stream reducers in packages/core/src/serialization.ts). -// Pinned (non-writable) so workflow code cannot swap in a constructor whose -// `Symbol.hasInstance` the serializer would then execute. Absent bindings are -// pinned to `undefined` so a spoofing global cannot be introduced either. -const SERIALIZATION_BINDINGS = [ - 'Object', - 'Array', - 'Function', - 'Map', - 'Set', - 'Date', - 'RegExp', - 'ArrayBuffer', - 'SharedArrayBuffer', - 'DataView', - 'Int8Array', - 'Uint8Array', - 'Uint8ClampedArray', - 'Int16Array', - 'Uint16Array', - 'Int32Array', - 'Uint32Array', - 'Float16Array', - 'Float32Array', - 'Float64Array', - 'BigInt64Array', - 'BigUint64Array', - 'Headers', - 'URL', - 'URLSearchParams', - 'DOMException', - 'AbortController', - 'AbortSignal', - 'Request', - 'Response', - 'ReadableStream', - 'WritableStream', - 'TransformStream', -] as const; - -/** - * Pin the sandbox surfaces that serialization dispatch resolves through. - * Called after ALL SDK globals are installed, immediately before the - * workflow bundle evaluates. - * - * Frozen — the universal lookup backstops only: - * - `Object.prototype` / `Array.prototype`: every missed property read on a - * plain object or array terminates here (reducer probes like `.signal`, - * `Symbol.toStringTag` lookups by devalue's type detection, `constructor` - * reads by the class reducer in - * packages/core/src/serialization/reducers/class.ts:26-43), so a planted - * accessor here would execute during serialization of ANY value. - * - `Function.prototype`: `x instanceof C` resolves `Symbol.hasInstance` - * through the constructor's prototype chain (tc39.es/ecma262/#sec-instanceofoperator), - * which ends here for every constructor. - * No real-world polyfill patches these three, so freezing them costs nothing. - * - * NOT frozen — constructors and the value-type prototypes (`Map.prototype`, - * `Date.prototype`, typed arrays, …): polyfills legitimately add methods - * there (Temporal's `Date.prototype.toTemporalInstant`, core-js - * `Set.prototype.union`, `Object.groupBy`, `Array.fromAsync`). New - * data-valued members are inert during serialization — only the specific - * members the serializer EXECUTES matter, and the retained-input gate - * verifies those per boundary against captured originals instead - * (see runtime/retained-step-input.ts). - */ -export function freezeSerializationIntrinsics(g: typeof globalThis): void { - Object.freeze(g.Object.prototype); - Object.freeze(g.Array.prototype); - Object.freeze(g.Function.prototype); - for (const name of SERIALIZATION_BINDINGS) { - const descriptor = Object.getOwnPropertyDescriptor(g, name); - Object.defineProperty(g, name, { - value: descriptor && 'value' in descriptor ? descriptor.value : undefined, - writable: false, - configurable: false, - enumerable: descriptor?.enumerable ?? false, - }); - } -} - /** * Creates a Node.js `vm.Context` configured to be usable for * executing workflow logic in a deterministic environment. diff --git a/packages/core/src/workflow.ts b/packages/core/src/workflow.ts index 8ed18de3d9..06fe1d79e2 100644 --- a/packages/core/src/workflow.ts +++ b/packages/core/src/workflow.ts @@ -38,7 +38,7 @@ import { import * as Attribute from './telemetry/semantic-conventions.js'; import { trace } from './telemetry.js'; import { getWorkflowRunStreamId } from './util.js'; -import { createContext, freezeSerializationIntrinsics } from './vm/index.js'; +import { createContext } from './vm/index.js'; import { runCachedWorkflowScript } from './vm/script-cache.js'; import { createAbortSignalStatics, @@ -847,10 +847,6 @@ export async function runWorkflow( // Script rather than the line just past the end of the bundle. That path // is rare (it requires the lookup `?.get(...)` expression to throw) and // does not affect the workflow function or replay determinism. - // All SDK globals are installed; pin the serialization-consulted - // intrinsics before any workflow code can run. - freezeSerializationIntrinsics(vmGlobalThis); - runCachedWorkflowScript(workflowCode, filename, context); const workflowFn = runCachedWorkflowScript( `globalThis.__private_workflows?.get(${JSON.stringify(workflowRun.workflowName)})`, From 8e23cfafbe008250aa202f51ad2c5b02f361d1a9 Mon Sep 17 00:00:00 2001 From: Nathan Colosimo <110621881+NathanColosimo@users.noreply.github.com> Date: Thu, 23 Jul 2026 16:25:55 -0700 Subject: [PATCH 3/3] Document and lock in why async crypto.subtle methods cannot break quiescence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The remaining async subtle methods reject immediately through the crypto proxy (brand check — the receiver is not a real SubtleCrypto), so they can never mint a host-timing promise. Narrow the quiescence comment to what the code actually enforces and add a test so the unreachability is not silently "fixed" later. --- packages/core/src/vm/index.test.ts | 18 ++++++++++++++++++ packages/core/src/vm/index.ts | 12 +++++++++++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/packages/core/src/vm/index.test.ts b/packages/core/src/vm/index.test.ts index 7e778e10e1..a2a9d5627d 100644 --- a/packages/core/src/vm/index.test.ts +++ b/packages/core/src/vm/index.test.ts @@ -171,6 +171,24 @@ describe('createContext', () => { expect(err?.message).toContain('not available inside a workflow function'); }); + it('rejects the remaining async `crypto.subtle` methods immediately', async () => { + // These would settle on host threadpool timing (breaking the quiescence + // invariant a retained VM relies on), so they must stay unreachable: + // through the crypto proxy the receiver is not a real SubtleCrypto and + // the brand check rejects before any crypto work is scheduled. + const { context } = createContext({ seed, fixedTimestamp }); + for (const call of [ + 'crypto.subtle.importKey("raw", new Uint8Array(16), { name: "AES-GCM" }, false, ["encrypt"])', + 'crypto.subtle.encrypt({ name: "AES-GCM" }, {}, new Uint8Array(4))', + 'crypto.subtle.sign("HMAC", {}, new Uint8Array(4))', + 'crypto.subtle.deriveBits({ name: "PBKDF2" }, {}, 128)', + ]) { + await expect(vm.runInContext(call, context)).rejects.toThrow( + /must be of type SubtleCrypto/ + ); + } + }); + it('should call `onWorkflowError` when a workflow error occurs', async () => { const { context } = createContext({ seed, fixedTimestamp }); diff --git a/packages/core/src/vm/index.ts b/packages/core/src/vm/index.ts index 39386d0378..59a6bda589 100644 --- a/packages/core/src/vm/index.ts +++ b/packages/core/src/vm/index.ts @@ -127,7 +127,7 @@ export function createContext(options: CreateContextOptions) { // timing or host state: after this block, every promise a workflow can // create settles either from the event log or within its own microtask // cascade, so a suspended VM is fully quiescent and can be retained across - // inline steps (see `canRetainWorkflowSession`). + // inline steps. // // - `Atomics.waitAsync` is a wall-clock timer (via SharedArrayBuffer). // - The async `WebAssembly` entry points resolve on compile-thread timing; @@ -135,6 +135,16 @@ export function createContext(options: CreateContextOptions) { // - `WeakRef.deref()` and finalizer callbacks observe GC timing. // - Dynamic `import()` settles within a microtask (rejected: no // `importModuleDynamically`), so it needs no handling. + // - The remaining async `crypto.subtle` methods (`encrypt`, `sign`, + // `importKey`, …) would settle on host threadpool timing, but none of + // them are usable: invoked through the crypto proxy below the receiver + // is not a real SubtleCrypto, so the brand check rejects immediately + // ("Value of 'this' must be of type SubtleCrypto") before any crypto + // work is scheduled — a deterministic microtask rejection. Only the + // deterministic overrides (`digest`, `getRandomValues`, `randomUUID`) + // and the explicit `generateKey` error do real work — locked in by a + // test. Do not "fix" those methods by binding them to the host subtle: + // that would hand workflows a host-timing promise. const intrinsics = g as unknown as Record>; delete intrinsics.Atomics.waitAsync; delete intrinsics.WebAssembly.compile;