feat(core): widen retained boundaries to plain data and standard built-ins#3047
Draft
NathanColosimo wants to merge 14 commits into
Draft
feat(core): widen retained boundaries to plain data and standard built-ins#3047NathanColosimo wants to merge 14 commits into
NathanColosimo wants to merge 14 commits into
Conversation
🦋 Changeset detectedLatest commit: a272681 The changes in this PR will be included in the next version bump. This PR includes changesets to release 16 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
Contributor
Contributor
🧪 E2E Test Results✅ All tests passed Summary
Details by Category✅ ▲ Vercel Production
✅ 💻 Local Development
✅ 📦 Local Production
✅ 🐘 Local Postgres
✅ 🪟 Windows
✅ 📋 Other
✅ vercel-multi-region
|
NathanColosimo
marked this pull request as draft
July 22, 2026 05:15
NathanColosimo
force-pushed
the
nathanc/retained-vm-core
branch
from
July 22, 2026 17:47
fda3612 to
df5c2bc
Compare
NathanColosimo
force-pushed
the
nathanc/retained-vm-passivity
branch
from
July 22, 2026 18:10
6590434 to
1695747
Compare
Applies the runtime changes from sveltejs/devalue#172 (head 2e97724) via pnpm patch, adapted to 5.8.1's merged TypedArray/DataView case so encoded output stays byte-identical. Enables injecting hardened, side-effect-free introspection operations into stringify.
…ivity # Conflicts: # packages/core/src/workflow.ts
Pins the exact devalue strings the workflow→step boundary produces for a representative corpus, recorded from the serialization code before the hardened stringify operations. The follow-up hardening commit must keep every byte identical.
…lization Serialization now runs through devalue's pluggable stringify operations (patched in via devalue#172): reducers and operations read values with captured host intrinsics and passive property access, and report when they had to execute workflow code (getters, proxies, custom serializers, step-function closures). The suspension handler de-opts a boundary to ordinary replay only when that report is tainted, replacing the standalone pre-serialization walker and its pin registry.
This was referenced Jul 23, 2026
- RegExp flags: the spec'd `flags` getter does ordinary Gets of the per-flag properties, so an own flag getter could execute untainted. Compose the flags string from the individual internal-slot getters instead (byte-identical output). - Error stacks: V8's lazy stack getter executes `Error.prepareStackTrace` when a realm replaced it. Capture each realm's pristine Error intrinsics at VM-context creation (never constructing an untrusted `global.Error` later) and taint stack reads when the realm's prepareStackTrace changed. - Report scope: reducer construction resolves prototypes off the workflow global before stringify activates the passivity report; run it inside the report scope so a getter or proxy on a global constructor taints.
- Vendor devalue 5.8.1 + the pending operations interface (sveltejs/devalue#172) into @workflow/core, replacing the pnpm patch: patchedDependencies don't ship with published packages, so consumers would have silently received a stock devalue that ignores the hardened operations. - Stop capturing URLSearchParams.prototype.size (absent on older Node 18, crashed at module load); derive emptiness from the captured toString output, which is equivalent to size === 0. - Read all AbortController/AbortSignal serialization fields passively via captured native accessors (signal/aborted/reason), so native holders don't taint retention and patched accessors do.
…spoof fallbacks - Ship the vendored devalue MIT LICENSE (and provenance README) in the npm package by copying them into dist/vendor/devalue at build time. - Taint Proxies encountered while walking prototype chains: in isInstanceOfPrototype, in a new objectShape wrapper (the default reads the prototype of plain candidates), and in a new passiveHas used for the 'cause' / '__boundThis' presence checks that previously used `in`. - Brand-check the tag-dispatched operations (dateISO, regExp, setValues, mapEntries, viewInfo): tag-spoofed / polyfilled built-ins now taint and fall back to the stock devalue behavior instead of throwing a brand-check TypeError that stock devalue never threw.
…fer taints, test devalue imports - ops.get now reads through a Proxy's get trap (tainted), matching stock devalue bytes instead of silently serializing the target descriptor value. - objectShape skips the prototype probe for Proxy values so their getPrototypeOf trap is not run an extra time relative to stock devalue. - arrayLength / arrayBuffer taint tag-spoofed values before the serializer coerces them (valueOf on an object-valued length, base64 coercion). - serialization/serialization.test.ts no longer requires the removed 'devalue' npm package; it imports the vendored copy.
…Params emptiness Prototype methods captured at module load can already be patched by code that ran before @workflow/core was imported, and would then be invoked as "passive" forever. Capture ECMAScript intrinsics (and the statics the passive helpers dispatch) from a freshly created node:vm realm instead; host web APIs stay module-load captures with the residual trust documented. Also derive URLSearchParams emptiness from the captured native toString before any dynamic dispatch, so an overridden toString can neither run untainted on empty params nor change which values serialize as empty.
…rchParams conversion getters The engine resolves Error.prepareStackTrace with an ordinary Get on the realm's Error constructor, so a formatter inherited from the realm's Function.prototype must also read as "replaced" (walk own descriptors up the constructor chain; accessors/proxies resolve to a never-matching sentinel so stack reads taint). The URLSearchParams reducer now selects its native fast path with a descriptor-level peek (passivePeek) that performs no reads, so a stateful conversion getter fires exactly once, from the single String(value) dispatch, like it did pre-hardening.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Third of the 3-PR stack from #2990 — replaces #3046's primitives-only retention gate with taint-reporting hardened serialization, so real-world step arguments keep the fast path without breaking polyfills:
devalue@5.8.1viapnpm patch): every primitive readstringifyperforms —typeof,@@toStringTag, property gets,Map/Setiteration, typed-array introspection — routes through an overridable operations object. Default behavior is unchanged; a byte-stability corpus test proves it (see below).serialization/operations.ts): serialization reads values through host intrinsics captured at module load (Map.prototype.entries,Dategetters,RegExpflags,URL.href, typed-array internal-slot getters, the V8Error.stackaccessor, …), brand checks viautil.types/@@toStringTaginstead ofinstanceof, andpassiveGetfor property access. Patching a built-in prototype in the workflow realm — Temporal'sDate.prototype.toTemporalInstant, core-jsSet.prototype.union, even replacingDate.prototype.toISOString— never executes and never affects the serialized bytes.[WORKFLOW_SERIALIZE]class serializers, step-function closure capture, thenable probes — it records the reason on a per-boundarySerializationPassivityReport. The suspension handler de-opts only tainted boundaries to ordinary replay; everything else retains.This deletes the previous approach on this branch (the descriptor-walking
retained-step-input.tspre-verifier and its pin registry, ~1,400 lines) in favor of ~500 lines that instrument the single real serialization path.Wire-format guarantee
serialization/byte-stability.test.tslocks a 100+-value corpus (primitives, built-ins, errors, workflow types, getter/proxy values) to a committed snapshot recorded before this rewrite: the hardened operations produce byte-identical devalue output to stockstringifyfor every corpus value. Replays of pre-existing runs are unaffected.Review focus
serialization/operations.ts(the captured intrinsics and the taint points) and the reducer rewrite inserialization/reducers/common.ts— every read there answers "can this dispatch into workflow code?". The suspension-handler wiring is small; the loop and state machine were reviewed in #3046, sandbox semantics in #3045.Validation
Full core suite green in both modes (default and
WORKFLOW_RETAINED_VM=0): 75 files, 1,582 passed, 3 expected failures. Coverage: operations unit tests (taint matrices, byte-compat with stock devalue, patched-prototype immunity, report scoping), suspension-handler taint gating, and loop-level tests proving getter/proxy/custom-serializer args demote (vmBuilds > 1) while built-ins — including a patchedDate.prototype.toISOString— retain (vmBuilds === 1).Docs Preview
WORKFLOW_RETAINED_VMStack: #3045 → #3046 → this. #2990 stays open as the reference implementation.