diff --git a/packages/code-runtime/code-runtime-worker/src/worker-json.ts b/packages/code-runtime/code-runtime-worker/src/worker-json.ts index 9151d07e7e..d49c26d7d1 100644 --- a/packages/code-runtime/code-runtime-worker/src/worker-json.ts +++ b/packages/code-runtime/code-runtime-worker/src/worker-json.ts @@ -2,6 +2,31 @@ import type { CodeJsonValue } from '@deepseek-ai/dsh-code-runtime' +/* jscpd:ignore-start -- the source worker mirrors session JSON helpers without workspace runtime imports */ +/** Whether an array uses one realm's intrinsic `Array.prototype`, not a subclass or forged prototype. */ +function hasPlainArrayPrototype(value: unknown[]): boolean { + const prototype: unknown = Object.getPrototypeOf(value) + if (!Array.isArray(prototype)) return false + const objectPrototype: unknown = Object.getPrototypeOf(prototype) + return objectPrototype !== null + && !Array.isArray(objectPrototype) + && Object.getPrototypeOf(objectPrototype) === null +} + +/** Whether an object is a plain or null-prototype record from any JavaScript realm. */ +function hasPlainObjectPrototype(value: object): boolean { + const prototype: unknown = Object.getPrototypeOf(value) + return prototype === null || Object.getPrototypeOf(prototype) === null +} + +/** Return every JSON-visible object key, or reject own data JSON would discard. */ +function enumerableStringKeys(value: object): string[] | undefined { + const keys = Reflect.ownKeys(value) + if (keys.some(key => typeof key !== 'string' || !Object.prototype.propertyIsEnumerable.call(value, key))) return undefined + return keys as string[] +} +/* jscpd:ignore-end */ + /** * Validate and detach one worker-boundary value without loading another * workspace package at runtime. This mirrors the session-owned canonical @@ -32,11 +57,12 @@ export function snapshotCodeJsonValue(value: unknown): CodeJsonValue | undefined if (typeof candidate !== 'object') return undefined if (Array.isArray(candidate)) { - if (Object.getPrototypeOf(candidate) !== Array.prototype) return undefined - if (Reflect.ownKeys(candidate).length !== candidate.length + 1) return undefined + if (!hasPlainArrayPrototype(candidate)) return undefined + const length = candidate.length + if (Reflect.ownKeys(candidate).length !== length + 1) return undefined return within(candidate, () => { const result: CodeJsonValue[] = [] - for (let index = 0; index < candidate.length; index++) { + for (let index = 0; index < length; index++) { if (!Object.hasOwn(candidate, index)) return undefined const item = copy(candidate[index]) if (item === undefined) return undefined @@ -46,11 +72,12 @@ export function snapshotCodeJsonValue(value: unknown): CodeJsonValue | undefined }) } - const prototype = Object.getPrototypeOf(candidate) as unknown - if (prototype !== Object.prototype && prototype !== null) return undefined + if (!hasPlainObjectPrototype(candidate)) return undefined + const keys = enumerableStringKeys(candidate) + if (keys === undefined) return undefined return within(candidate, () => { const result: Record = {} - for (const key of Object.keys(candidate)) { + for (const key of keys) { const item = copy((candidate as Record)[key]) if (item === undefined) return undefined Object.defineProperty(result, key, { diff --git a/packages/code-runtime/code-runtime-worker/tests/worker-json.spec.ts b/packages/code-runtime/code-runtime-worker/tests/worker-json.spec.ts index b574e80aff..029b5789ac 100644 --- a/packages/code-runtime/code-runtime-worker/tests/worker-json.spec.ts +++ b/packages/code-runtime/code-runtime-worker/tests/worker-json.spec.ts @@ -1,3 +1,4 @@ +import { runInNewContext } from 'node:vm' import { describe, expect, it } from 'vitest' import { snapshotJsonValue } from '@deepseek-ai/dsh-session' import { snapshotCodeJsonValue } from '../src/worker-json.ts' @@ -24,6 +25,16 @@ describe('snapshotCodeJsonValue', () => { expect(snapshot.alias).not.toBe(shared) }) + it('accepts intrinsic plain containers from another JavaScript realm', () => { + const foreign = runInNewContext('({ object: { nested: [1] }, array: [2, { ok: true }] })') as { + object: unknown + array: unknown + } + + expect(snapshotCodeJsonValue(foreign.object)).toEqual({ nested: [1] }) + expect(snapshotCodeJsonValue(foreign.array)).toEqual([2, { ok: true }]) + }) + it('reads each accepted slot once and preserves a literal __proto__ key', () => { let objectReads = 0 let arrayReads = 0 @@ -66,6 +77,12 @@ describe('snapshotCodeJsonValue', () => { Object.defineProperty(compensatedSparse, 'extra', { value: true }) const symbolDecorated = [1] Object.defineProperty(symbolDecorated, Symbol('extra'), { value: true }) + const hiddenObject = Object.defineProperty({}, 'hidden', { value: true }) + const symbolObject = { [Symbol('extra')]: true } + const forgedPrototype: unknown[] = [] + Object.setPrototypeOf(forgedPrototype, null) + const forgedArray = [1] + Object.setPrototypeOf(forgedArray, forgedPrototype) for (const value of [ new ExoticObject(), @@ -75,6 +92,9 @@ describe('snapshotCodeJsonValue', () => { decorated, compensatedSparse, symbolDecorated, + hiddenObject, + symbolObject, + forgedArray, cyclic, [undefined], { value: undefined }, @@ -83,6 +103,19 @@ describe('snapshotCodeJsonValue', () => { } }) + it('rejects an array whose getter mutates the validated length', () => { + const array = [0, 2] + Object.defineProperty(array, 0, { + enumerable: true, + get: () => { + array.length = 1 + return 1 + }, + }) + + expect(snapshotCodeJsonValue(array)).toBeUndefined() + }) + it('propagates a throwing getter and releases its recursion guard', () => { const failure = new Error('getter failed') const source = Object.defineProperty({}, 'value', {