From b6a184ffe92af2fe8bbd533db7bedfdd9e9827c5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 13:45:49 +0000 Subject: [PATCH 1/2] fix(runtime): compare the entry snapshot as the VM saw it in the write-back key set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Leg 2 of `carriedInputKeys` decides "did the body write THROUGH this object-valued key?" by comparing the host entry snapshot against the VM's exit dump. The dump is JSON; the snapshot was not. A host `Date` therefore compared unequal to its own ISO projection, was carried back onto the engine's flat-input Proxy, recorded by the #14088 `set` trap as hook-written, and kept by `stripReadonlyFields` — a caller-supplied readonly field no hook ever touched landing on the row. Normalise the entry value through the same round-trip the VM saw before comparing. The #14758 fail-open is preserved per key: a value the round-trip cannot evaluate (cycle, bigint) is still reported as changed and carried. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N --- packages/runtime/src/sandbox/body-runner.ts | 87 ++++++++++++++++----- 1 file changed, 68 insertions(+), 19 deletions(-) diff --git a/packages/runtime/src/sandbox/body-runner.ts b/packages/runtime/src/sandbox/body-runner.ts index f9df19c874..b15bbd1ed3 100644 --- a/packages/runtime/src/sandbox/body-runner.ts +++ b/packages/runtime/src/sandbox/body-runner.ts @@ -504,6 +504,45 @@ function vmVisibleEntryKeys(entryInput: unknown): string[] { return out; } +/** + * [#14760] The entry value as the VM could actually have seen it, or `ok: + * false` for a host value the round-trip cannot evaluate at all. + * + * Leg 2 of {@link carriedInputKeys} compares an entry snapshot against the VM's + * exit dump. The dump has been through `JSON.stringify` on the way in and + * `JSON.parse` on the way out; the snapshot had not. Comparing them raw asks + * whether a HOST value equals its own JSON projection, which for a `Date` is + * always false — so an untouched caller-supplied `Date` was reported as written + * THROUGH, carried back onto the engine's flat-input Proxy, recorded by the + * #14088 `set` trap as hook-written, and therefore KEPT by + * `stripReadonlyFields`. Measured end to end before this fix: a readonly + * `datetime` field no body ever names lands the CALLER's value on the row, + * while the same run's readonly `text` field is correctly stripped. The class + * is wider than `Date` — it is every object-valued entry value a round-trip + * cannot prove equal, an object carrying an `undefined` member included. + * + * The write-back's second harm has the same one cause: a key carried by this + * leg is re-asserted FROM THE DUMP, so the host `Date` reached the driver as an + * ISO string and the object lost its `undefined` member even where nothing was + * readonly. Normalising the comparison closes both, because an untouched key is + * no longer carried at all and the host simply keeps its own value. + * + * ⛔ The fail-open is NOT reversed. #14758 chose "anything we cannot prove + * equal is reported as changed and therefore CARRIED" deliberately, and a value + * that throws here — a cycle, a bigint, a `toJSON` returning `undefined` — still + * takes exactly that path. What changes is that the fail-open stops firing on + * values the round-trip CAN evaluate, which is where it was never needed. The + * verdict is per KEY: one unrepresentable entry value must not decide the set. + */ +function jsonSeenByVm(value: unknown): { ok: true; value: unknown } | { ok: false } { + try { + return { ok: true, value: JSON.parse(JSON.stringify(value)) as unknown }; + } catch { + /* unrepresentable (cycle, bigint) — #14758's fail-open, for this key only */ + return { ok: false }; + } +} + /** * [#14758] Which keys of the exit dump the write-back should re-assert, or * `undefined` to assert all of them (the pre-#14758 behaviour). @@ -519,13 +558,17 @@ function vmVisibleEntryKeys(entryInput: unknown): string[] { * assigned and then deleted, or assigned `undefined`, is absent from the * dump and belongs to the deletion leg, not to this merge. * 2. Object-valued entry keys whose dumped value no longer matches the entry - * snapshot. A body that writes THROUGH a value it read (`ctx.input.meta.x = - * 1`) never trips a trap on `ctx.input`, so (1) cannot list it and dropping - * it would be exactly the silent loss this card exists to end. The - * comparison is confined to keys whose ENTRY value is an object because a - * primitive cannot be mutated in place — every change to one is an - * assignment (1) already saw — and confining it there is what keeps this - * leg from re-widening into the value diff #14099's ruling refused. + * snapshot **as the VM saw it** ({@link jsonSeenByVm}). A body that writes + * THROUGH a value it read (`ctx.input.meta.x = 1`) never trips a trap on + * `ctx.input`, so (1) cannot list it and dropping it would be exactly the + * silent loss this card exists to end. The comparison is confined to keys + * whose ENTRY value is an object because a primitive cannot be mutated in + * place — every change to one is an assignment (1) already saw — and + * confining it there is what keeps this leg from re-widening into the value + * diff #14099's ruling refused. [#14760] Normalising the entry side is what + * makes the comparison answer "did the body write through this?" instead of + * "is this host value already JSON?"; without it every `Date`-valued key + * answered the second question, in the wrong direction, forever. * * `undefined` (no narrowing) whenever the evidence is not there: no recorder, * or no usable entry snapshot to read leg 2 from. @@ -546,7 +589,8 @@ function carriedInputKeys( if (carried.has(key)) continue; if (!before || typeof before !== 'object') continue; if (!Object.prototype.hasOwnProperty.call(mutated, key)) continue; - if (!sameJsonValue(before, mutated[key])) carried.add(key); + const seen = jsonSeenByVm(before); + if (!seen.ok || !sameJsonValue(seen.value, mutated[key])) carried.add(key); } return [...carried]; } @@ -557,14 +601,18 @@ function carriedInputKeys( * * Key ORDER is deliberately not significant: a JSON round-trip through the VM * preserves insertion order for string keys but reorders integer-like ones, and - * a reorder is not a write. Every failure direction is the safe one — anything - * this cannot prove equal is reported as changed and therefore CARRIED, which - * is the pre-#14758 behaviour for that key. That covers the host values JSON - * cannot represent (a `Date` arrives back as a string and compares unequal, so - * it is carried exactly as it was before this card). - * - * Terminates on a cyclic `a`: `b` is always JSON-parsed and therefore finite, - * so the walk is bounded by `b`'s depth. + * a reorder is not a write. + * + * [#14760] BOTH sides are JSON values by the time they reach here: `b` is the + * VM's exit dump, and `a` is the entry snapshot already put through + * {@link jsonSeenByVm}. So this compares like for like, and it no longer stands + * in for the round-trip itself. It used to: an unequal verdict meant either + * "the body wrote this" or "JSON cannot represent this host value", and the + * caller could not tell the two apart — which is how an untouched `Date` was + * read as a write. Distinguishing them is now {@link jsonSeenByVm}'s job, and + * the fail-open direction lives there with it, unchanged. + * + * Terminates: both sides are JSON-parsed and therefore finite. */ function sameJsonValue(a: unknown, b: unknown): boolean { if (a === b) return true; @@ -661,9 +709,10 @@ function sameJsonValue(a: unknown, b: unknown): boolean { * itself ever fires, so the recorder cannot list `meta`. The dump is the only * witness for those, and {@link carriedInputKeys} reads it the narrowest way * available: an OBJECT-valued entry key whose dumped value no longer matches - * the entry snapshot was written through, and is carried. Primitives need no - * such leg — a primitive cannot be mutated in place, so every change to one - * is an assignment the recorder saw. + * the entry snapshot — [#14760] as {@link jsonSeenByVm} shows it to the VM — + * was written through, and is carried. Primitives need no such leg: a + * primitive cannot be mutated in place, so every change to one is an + * assignment the recorder saw. */ function applyMutationsToInput( engineCtx: any, From f89dba7e7c05fd6c3716a1ee406b110a32a7974c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 14:15:15 +0000 Subject: [PATCH 2/2] test(runtime): pin that an untouched readonly key is not laundered by the sandbox write-back MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eight cases over the same line. Five drive real ObjectQL + real SqlDriver + real QuickJSScriptRunner: the readonly `Date` on both strip sites, the wider `json`-with-an-undefined-member case, and two controls that stop a green from being vacuous — a body that DOES assign the readonly key still lands its write, and a body that mutates a readonly object in place is still carried by leg 2. Three read the same line at the write-back boundary, where the driver cannot normalise the evidence away: an untouched host `Date` and an untouched object keep their identity, and a cyclic entry value is still carried, which is #14758's fail-open unchanged. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N --- ...box-writeback-entry-snapshot-normalised.md | 30 ++ ...ck-readonly-provenance.integration.test.ts | 390 ++++++++++++++++++ 2 files changed, 420 insertions(+) create mode 100644 .changeset/sandbox-writeback-entry-snapshot-normalised.md create mode 100644 packages/runtime/src/sandbox/hook-input-writeback-readonly-provenance.integration.test.ts diff --git a/.changeset/sandbox-writeback-entry-snapshot-normalised.md b/.changeset/sandbox-writeback-entry-snapshot-normalised.md new file mode 100644 index 0000000000..247a4a7410 --- /dev/null +++ b/.changeset/sandbox-writeback-entry-snapshot-normalised.md @@ -0,0 +1,30 @@ +--- +'@objectstack/runtime': patch +--- + +fix(runtime): a sandboxed hook body no longer launders an untouched `readonly` field onto the row + +A `beforeUpdate`/`beforeInsert` body running in the sandbox made the engine believe it had +written payload keys it never named, and a `readonly` field the caller supplied then survived +the readonly strip and landed. Measured end to end: with `locked_at` declared +`{ type: 'datetime', readonly: true }` and seeded to `2020-01-01`, a caller sending +`locked_at: new Date('2099-12-31…')` alongside a body whose whole source is +`ctx.input.touched_by = 'hook'` stored the caller's 2099 value — while the same object's +readonly `text` field was correctly stripped in the same request. + +The cause was a comparison of unlike things. The write-back decides whether a body wrote +*through* an object-valued key by comparing the host payload value against the VM's exit dump, +and the dump has been through `JSON.stringify`/`JSON.parse` while the host value has not. A +`Date` therefore never compared equal to its own ISO projection, took the documented +"cannot prove equal ⇒ carry it back" path, and was re-asserted onto the proxy that records +which keys a hook wrote. The class was every object-valued value a JSON round-trip cannot +prove equal — an object carrying an `undefined` member included, a `Date` being only its most +reachable member. + +The entry value is now normalised through the same round-trip the VM saw before it is +compared. The same change ends a fidelity loss on non-readonly fields: an untouched key is no +longer carried at all, so a host `Date` is no longer replaced by an ISO string on its way to +the driver. + +Fail-open behaviour is unchanged for values the round-trip genuinely cannot evaluate: a cyclic +or bigint-bearing payload value is still reported as changed and still carried, per key. diff --git a/packages/runtime/src/sandbox/hook-input-writeback-readonly-provenance.integration.test.ts b/packages/runtime/src/sandbox/hook-input-writeback-readonly-provenance.integration.test.ts new file mode 100644 index 0000000000..0f74a9ee09 --- /dev/null +++ b/packages/runtime/src/sandbox/hook-input-writeback-readonly-provenance.integration.test.ts @@ -0,0 +1,390 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#14760] A caller-supplied `readonly` field that no hook body ever names must + * NOT survive `stripReadonlyFields` just because a sandboxed body ran. + * + * ## What was measured broken + * + * #14758 narrowed the sandbox write-back to the keys the body wrote. Its leg 2 + * — "did the body write THROUGH this object-valued key?" — compared the HOST + * entry snapshot against the VM's exit dump. The dump is JSON; the snapshot was + * not. So a host `Date` was compared against its own ISO projection, could not + * be proved equal, and took `sameJsonValue`'s documented fail-open: CARRIED. + * + * Carried means assigned onto `engineCtx.input`, which is the engine's + * flat-input Proxy — the one the #14088 provenance recorder watches. The key + * entered `hookWrittenKeys`, and `stripReadonlyFields` keeps what a hook wrote. + * Measured end to end on `main` before this fix, by-id path: + * + * ``` + * seeded locked_at = 2020-01-01T00:00:00.000Z locked_note = 'SEEDED' + * caller locked_at = new Date('2099-12-31…') locked_note = 'CALLER' + * body ctx.input.touched_by = 'hook' (names no readonly key) + * row locked_at = 2099-12-31T23:59:59.000Z ← the CALLER's value + * locked_note = 'SEEDED' ← correctly stripped + * ``` + * + * The readonly TEXT field stripped in the SAME run is what makes that + * conclusive rather than a probe that never worked, so it is asserted in every + * case here for the same reason. + * + * The class is wider than `Date`: it is every object-valued entry value a JSON + * round-trip cannot prove equal. A readonly `json` value carrying an + * `undefined` member survived too, while a plainly round-trippable object of + * the same shape was stripped. + * + * ## Why this harness, and why these controls + * + * The defect is a composition of three real components no unit mock exercises: + * QuickJS marshalling, objectql's flat-input proxy, and the #14088 recorder's + * `set` trap — followed by objectql's readonly strip reading that recording. So + * this drives REAL `ObjectQL` + REAL `SqlDriver` (better-sqlite3) + REAL + * `QuickJSScriptRunner` behind `hookBodyRunnerFactory`. + * + * A test that only asserts "the readonly value was stripped" passes just as + * well when the write-back has been broken into carrying nothing at all, so + * three controls carry the weight: + * + * - **the body's own write LANDS** (`touched_by`) and the caller's writable + * key lands, in every case — the body really ran; + * - **the FIRING control**: a body that DOES assign the readonly key still has + * its value kept, so provenance is still able to say KEEP; + * - **the write-THROUGH control**: a body that mutates a readonly `json` value + * in place trips no trap on `ctx.input`, so only leg 2 — the leg this card + * changes — can carry it. It must still be carried and still land. + * + * Seeding goes through `driver.create`, bypassing the insert-side readonly + * strip, or the columns under measurement would start empty and every case + * would pass vacuously. + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { ObjectQL, bindHooksToEngine } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { hookBodyRunnerFactory } from './body-runner.js'; +import { QuickJSScriptRunner } from './quickjs-runner.js'; +const GUARD_TASK = { + name: 'guard_task', + fields: { + title: { type: 'text' }, + bucket: { type: 'text' }, + status: { type: 'text' }, + touched_by: { type: 'text' }, + // The reachable member of the class: a host `Date` on the payload. + locked_at: { type: 'datetime', readonly: true }, + // The primitive control — leg 2 never looks at it, so it was always + // stripped. A run where THIS one survives is a broken strip, not this card. + locked_note: { type: 'text', readonly: true }, + // The widening: an object-valued readonly key. + locked_meta: { type: 'json', readonly: true }, + }, +}; + +const SEEDED_AT = new Date('2020-01-01T00:00:00.000Z'); +const CALLER_AT = new Date('2099-12-31T23:59:59.000Z'); +const HOOK_AT = '2031-03-03T03:03:03.000Z'; + +/** Names no readonly key at all — the case the card is about. */ +const UNTOUCHING_SOURCE = ` + ctx.log.info('row', { title: ctx.previous.title }); + ctx.input.touched_by = 'hook'; +`; + +/** FIRING control: the body really does assign the readonly key. */ +const ASSIGNS_READONLY_SOURCE = ` + ctx.log.info('row', { title: ctx.previous.title }); + ctx.input.touched_by = 'hook'; + ctx.input.locked_at = '${HOOK_AT}'; +`; + +/** + * Write-THROUGH control: mutates the object `locked_meta` holds instead of + * assigning the key, so no trap on `ctx.input` fires and leg 1 cannot see it. + * Only leg 2 — the leg this card changes — can carry this. + */ +const WRITES_THROUGH_SOURCE = ` + ctx.log.info('row', { title: ctx.previous.title }); + ctx.input.touched_by = 'hook'; + ctx.input.locked_meta.who = 'hook'; +`; + +/** + * ⛔ No `captureExpectedReadRefusals` here, and that is derived rather than + * skipped: this fixture seeds through `driver.create` and never calls + * `engine.insert`, so nothing on these paths probes `sys_organization` and no + * refusal envelope is emitted to withhold. Declaring the table anyway would + * make `silentChannels()` name a channel that was never going to fire — + * asserting a probe this file does not drive. Measured: the run below emits no + * `refused a read on` line and no `Find operation failed` frame at all. + */ +type Boot = { + engine: ObjectQL; + driver: SqlDriver; + seen: any[]; + dir: string; +}; + +/** `datetime` comes back as a `Date` or an ISO string depending on the driver. */ +const iso = (v: unknown): string | null => + v === null || v === undefined ? null : new Date(v as any).toISOString(); + +/** `json` comes back parsed or as text; normalise before asserting. */ +const asJson = (v: unknown): any => + typeof v === 'string' ? JSON.parse(v) : v; + +describe('#14760 — an untouched readonly key is not laundered by the sandbox write-back', () => { + let booted: Boot | null = null; + + afterEach(async () => { + try { await booted?.engine.destroy(); } catch { /* noop */ } + if (booted?.dir) rmSync(booted.dir, { recursive: true, force: true }); + booted = null; + }); + + async function boot(source: string): Promise { + const dir = mkdtempSync(join(tmpdir(), 'os-14760-')); + const driver = new SqlDriver({ + client: 'better-sqlite3', + connection: { filename: join(dir, 'data.sqlite') }, + useNullAsDefault: true, + }); + await driver.initObjects([GUARD_TASK]); + const engine = new ObjectQL(); + engine.registerDriver(driver, true); + await engine.init(); + engine.registry.registerObject(GUARD_TASK as any, 'guard'); + + const seen: any[] = []; + const logger = { + debug: () => {}, + info: (_m: string, meta?: any) => { seen.push(meta); }, + warn: () => {}, + error: () => {}, + }; + engine.setDefaultBodyRunner( + hookBodyRunnerFactory(new QuickJSScriptRunner(), { ql: engine, appId: 'guard', logger }), + ); + bindHooksToEngine(engine, [{ + name: 'guard_task_body', + object: 'guard_task', + events: ['beforeUpdate'], + body: { language: 'js', source, capabilities: ['log'] }, + } as any], { packageId: 'guard' }); + + booted = { engine, driver, seen, dir }; + return booted; + } + + /** + * Seeds THROUGH THE DRIVER on purpose: `engine.insert` would strip the very + * readonly columns this file measures, leaving them null and every assertion + * below vacuously true. + */ + async function seed(driver: SqlDriver, meta: unknown = { seeded: true }) { + await driver.create('guard_task', { + title: 'row', + bucket: 'b1', + status: 'open', + locked_at: SEEDED_AT, + locked_note: 'SEEDED', + locked_meta: JSON.stringify(meta), + } as any); + } + + const row = async (engine: ObjectQL) => + ((await engine.find('guard_task', { where: { title: 'row' } })) as any[])[0]; + + it('by-id: a caller-supplied readonly Date the body never names does NOT land', async () => { + const { engine, driver, seen } = await boot(UNTOUCHING_SOURCE); + await seed(driver); + const seeded = await row(engine); + seen.splice(0); + + await engine.update('guard_task', { + id: seeded.id, + status: 'done', + locked_at: CALLER_AT, + locked_note: 'CALLER', + } as any); + + // The body really ran, and on this row. + expect(seen.map((o) => o.title)).toEqual(['row']); + + const after = await row(engine); + // THE CARD: the seeded value survived; the caller's 2099 did not land. + expect(iso(after.locked_at)).toBe(SEEDED_AT.toISOString()); + // Control in the SAME run — the readonly primitive was stripped, so the + // strip is present and working rather than absent. + expect(after.locked_note).toBe('SEEDED'); + // Over-narrowing guards: the body's own write and the caller's writable key + // both landed, so nothing here is green because the write-back went silent. + expect(after.touched_by).toBe('hook'); + expect(after.status).toBe('done'); + }, 60000); + + it('predicate/multi: the same readonly Date does not land on the second strip site', async () => { + const { engine, driver, seen } = await boot(UNTOUCHING_SOURCE); + await seed(driver); + seen.splice(0); + + await engine.update( + 'guard_task', + { status: 'done', locked_at: CALLER_AT, locked_note: 'CALLER' }, + { multi: true, where: { bucket: 'b1' } } as any, + ); + + expect(seen.map((o) => o.title)).toEqual(['row']); + + const after = await row(engine); + expect(iso(after.locked_at)).toBe(SEEDED_AT.toISOString()); + expect(after.locked_note).toBe('SEEDED'); + expect(after.touched_by).toBe('hook'); + expect(after.status).toBe('done'); + }, 60000); + + it('the class is wider than Date: a readonly json value carrying an undefined member does not land', async () => { + const { engine, driver, seen } = await boot(UNTOUCHING_SOURCE); + await seed(driver); + const seeded = await row(engine); + seen.splice(0); + + await engine.update('guard_task', { + id: seeded.id, + status: 'done', + // Round-trips to `{ who: 'caller' }` — a JSON compare against the raw host + // object could never prove them equal, which is the whole defect. + locked_meta: { who: 'caller', dropped: undefined }, + locked_note: 'CALLER', + } as any); + + expect(seen.map((o) => o.title)).toEqual(['row']); + + const after = await row(engine); + expect(asJson(after.locked_meta)).toEqual({ seeded: true }); + expect(after.locked_note).toBe('SEEDED'); + expect(after.touched_by).toBe('hook'); + expect(after.status).toBe('done'); + }, 60000); + + it('FIRING control: a body that DOES assign the readonly key still lands its write', async () => { + const { engine, driver, seen } = await boot(ASSIGNS_READONLY_SOURCE); + await seed(driver); + const seeded = await row(engine); + seen.splice(0); + + await engine.update('guard_task', { + id: seeded.id, + status: 'done', + locked_at: CALLER_AT, + locked_note: 'CALLER', + } as any); + + expect(seen.map((o) => o.title)).toEqual(['row']); + + const after = await row(engine); + // Provenance still turns STRIP into KEEP for a key the body really wrote — + // without this, "nothing readonly ever lands" would pass a write-back that + // had simply stopped working. + expect(iso(after.locked_at)).toBe(new Date(HOOK_AT).toISOString()); + expect(after.locked_note).toBe('SEEDED'); + expect(after.touched_by).toBe('hook'); + }, 60000); + + it('write-THROUGH control: leg 2 still carries an object the body mutated in place', async () => { + const { engine, driver, seen } = await boot(WRITES_THROUGH_SOURCE); + await seed(driver); + const seeded = await row(engine); + seen.splice(0); + + await engine.update('guard_task', { + id: seeded.id, + status: 'done', + locked_meta: { who: 'caller' }, + locked_note: 'CALLER', + } as any); + + expect(seen.map((o) => o.title)).toEqual(['row']); + + const after = await row(engine); + // `ctx.input.locked_meta.who = 'hook'` trips no trap on `ctx.input`, so leg + // 1 cannot list it: only the normalised leg-2 comparison can carry it, and + // only a carried key is recorded as hook-written and kept by the strip. + expect(asJson(after.locked_meta)).toEqual({ who: 'hook' }); + expect(after.locked_note).toBe('SEEDED'); + expect(after.touched_by).toBe('hook'); + }, 60000); +}); + +/** + * The same line, read at the write-back boundary instead of at the row. + * + * These drive the REAL `QuickJSScriptRunner` through `hookBodyRunnerFactory` + * with a plain host `ctx`, because two properties of the fix are invisible once + * a driver has normalised everything into a column: + * + * - the SECOND harm — a carried key is re-asserted FROM THE DUMP, so an + * untouched host `Date` used to be replaced by its ISO string and an object + * used to lose its `undefined` member even where nothing was readonly; + * - the FAIL-OPEN, which #14758 chose deliberately and this card does not + * reverse. `safeJsonStringify` lets a cyclic or bigint-bearing value cross + * into the VM in degraded form, so such a key IS in the exit dump and DOES + * reach the comparison — where a plain round-trip of the host value throws. + * It must still be reported as changed and carried, exactly as before. + */ +describe('#14760 — write-back fidelity and the preserved fail-open', () => { + const runner = new QuickJSScriptRunner(); + + const bind = (source: string) => + hookBodyRunnerFactory(runner, { ql: {}, appId: 'guard' })({ + name: 'guard_body', + object: 'guard_task', + events: ['beforeUpdate'], + body: { language: 'js', source, capabilities: [] }, + } as any)!; + + it('an untouched host Date keeps its identity — it is not replaced by the dump string', async () => { + const fn = bind("ctx.input.touched_by = 'hook';"); + const locked = new Date('2099-12-31T23:59:59.000Z'); + const engineCtx = { input: { status: 'done', locked_at: locked } } as any; + + await fn(engineCtx); + + expect(engineCtx.input.touched_by).toBe('hook'); + // Same instance, not an equal one: nothing was carried back over it. + expect(engineCtx.input.locked_at).toBeInstanceOf(Date); + expect(Object.is(engineCtx.input.locked_at, locked)).toBe(true); + }); + + it('an untouched object keeps its host form, undefined members included', async () => { + const fn = bind("ctx.input.touched_by = 'hook';"); + const meta = { who: 'caller', dropped: undefined }; + const engineCtx = { input: { status: 'done', meta } } as any; + + await fn(engineCtx); + + expect(Object.is(engineCtx.input.meta, meta)).toBe(true); + expect('dropped' in engineCtx.input.meta).toBe(true); + }); + + it('FAIL-OPEN preserved: an entry value the round-trip cannot evaluate is still carried', async () => { + const fn = bind("ctx.input.touched_by = 'hook';"); + // `safeJsonStringify` drops the back-edge on the way in, so the VM sees + // `{ tag: 'cyclic' }` and dumps it — but a plain round-trip of the HOST + // value throws, which is exactly the case #14758's fail-open exists for. + const cyclic: Record = { tag: 'cyclic' }; + cyclic.self = cyclic; + const engineCtx = { input: { status: 'done', meta: cyclic } } as any; + + await fn(engineCtx); + + expect(engineCtx.input.touched_by).toBe('hook'); + // Carried: the host key now holds the dump's degraded copy, byte for byte + // the pre-#14760 behaviour for a value JSON cannot represent. + expect(Object.is(engineCtx.input.meta, cyclic)).toBe(false); + expect(engineCtx.input.meta).toEqual({ tag: 'cyclic' }); + }); +});