From 7518a5cb6548563e6b970bf9f21ea9927590abfd Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Sun, 2 Aug 2026 15:51:23 +0800 Subject: [PATCH] fix(tools): snapshot const/enum/oneOf reads to close stateful-getter TOCTOU Address ds-review-bot v5/v6 review round 8. The prior guards re-read a stateful getter's value between the check and the spelling, so a getter returning different values across reads could still emit invalid Python: - renderConstrainedScalar reads node.const ONCE into a local, then checks and spells that snapshot; a third-read switch can no longer produce Literal[[object Object]]. - The enum path snapshots via [...raw] (reading each element exactly once, covering accessor-property elements) and requires the snapshot be a non-empty all-scalar array; an emptied re-read no longer spells Literal[], and a non-array re-read degrades. - The oneOf branch build guards a non-array or empty re-read to Any instead of joining to '' (a missing type). - pyScalar spells null as None; its JSDoc no longer claims null cannot reach it. Tests cover each re-read shape; py-types.ts stays at 100% coverage. --- packages/core/tools/src/py-types.ts | 41 +++++--- packages/core/tools/tests/py-types.spec.ts | 109 +++++++++++++++++++-- 2 files changed, 130 insertions(+), 20 deletions(-) diff --git a/packages/core/tools/src/py-types.ts b/packages/core/tools/src/py-types.ts index 272b5e7ac1..b989d7fc55 100644 --- a/packages/core/tools/src/py-types.ts +++ b/packages/core/tools/src/py-types.ts @@ -161,10 +161,11 @@ function allocateClassName(base: string, state: RenderState): string { } /** - * Render one validated scalar as Python literal text (`True`/`False`, - * JSON-quoted strings, bare numbers). `null` cannot reach here: the `null` - * type renders directly as `None`, and the unified validator rejects a null - * `const`/`enum` entry on every other scalar type. + * Render one validated scalar as Python literal text (`True`/`False`, `None`, + * JSON-quoted strings, bare numbers). A validated `const`/`enum` never carries + * a bare `null` on a non-`null` scalar type, but a post-validation stateful + * getter can re-read one as `null`, so `null` is spelled `None` rather than the + * JS `String(null)` = `"null"`. * * A beyond-safe-range integral number takes `BigInt` digits rather than * `String`: Python integers are arbitrary-precision, so the emitted digits ARE @@ -179,6 +180,7 @@ function allocateClassName(base: string, state: RenderState): string { function pyScalar(value: JsonSchemaScalar): string { if (value === true) return 'True' if (value === false) return 'False' + if (value === null) return 'None' if (typeof value === 'string') return JSON.stringify(value) if (typeof value === 'number' && Number.isInteger(value) && !Number.isSafeInteger(value)) { return BigInt(value).toString() @@ -201,18 +203,24 @@ function isPyScalar(value: unknown): value is JsonSchemaScalar { */ function renderConstrainedScalar(node: Record, broad: string, state: RenderState): string { if (Object.hasOwn(node, 'const')) { - // Re-read at render time: a stateful getter validated as a scalar can now - // return anything. A non-scalar would spell `Literal[[object Object]]` - // (invalid Python), so degrade to the broad type per the contract. - if (!isPyScalar(node.const)) return broad + // Snapshot the value with ONE read: a stateful getter can return different + // values across reads, so a separate check-read and spell-read could still + // pass the check and then spell a non-scalar (`Literal[[object Object]]`). + const value = node.const + if (!isPyScalar(value)) return broad state.typing.add('Literal') - return `Literal[${pyScalar(node.const)}]` + return `Literal[${pyScalar(value)}]` } if (Object.hasOwn(node, 'enum')) { const raw = node.enum - if (!Array.isArray(raw) || !raw.every(isPyScalar)) return broad + // `[...raw]` reads each element exactly once (elements may be accessor + // properties that change between reads); then check and spell that + // snapshot. Require non-empty: an emptied re-read would spell `Literal[]`, + // a Python SyntaxError that breaks the whole SDK. + const values: unknown[] | undefined = Array.isArray(raw) ? [...(raw as unknown[])] : undefined + if (values === undefined || values.length === 0 || !values.every(isPyScalar)) return broad state.typing.add('Literal') - return `Literal[${raw.map(pyScalar).join(', ')}]` + return `Literal[${values.map(pyScalar).join(', ')}]` } return broad } @@ -372,8 +380,17 @@ function renderType(schema: unknown, className: string, state: RenderState): str } const node = frame.schema as Record if (Object.hasOwn(node, 'oneOf')) { + // Snapshot the branches with ONE read (a getter can change them + // between reads). A re-read that is not a non-empty array would join to + // `''` (or drop branches), so degrade to `Any` instead. + const branches = node.oneOf + if (!Array.isArray(branches) || branches.length === 0) { + state.typing.add('Any') + finish('Any') + continue + } frame.kind = 'oneOf' - frame.children = (node.oneOf as unknown[]).map((branch, index) => ({ schema: branch, className: `${frame.className}${index + 1}` })) + frame.children = (branches as unknown[]).map((branch, index) => ({ schema: branch, className: `${frame.className}${index + 1}` })) continue } if (!Object.hasOwn(node, 'type')) { diff --git a/packages/core/tools/tests/py-types.spec.ts b/packages/core/tools/tests/py-types.spec.ts index 7c24ef9788..5bb10573be 100644 --- a/packages/core/tools/tests/py-types.spec.ts +++ b/packages/core/tools/tests/py-types.spec.ts @@ -200,10 +200,9 @@ describe('jsonSchemaToPy', () => { expect(out).toBe('list[Any]') }) - it('degrades to the broad type when a const getter re-reads as a non-scalar', () => { - // `const` validates as a string, then returns an object at render time. - // A naive spelling would emit Literal[[object Object]] (invalid Python); - // the render must fall back to the broad type instead. + it('degrades a const that snapshots as a non-scalar to the broad type', () => { + // The single snapshot read returns an object (validation read returned a + // scalar); the check must degrade rather than spell Literal[[object Object]]. let reads = 0 const schema: Record = { type: 'string' } Object.defineProperty(schema, 'const', { @@ -219,24 +218,118 @@ describe('jsonSchemaToPy', () => { expect(out).not.toContain('object Object') }) - it('degrades to the broad type when an enum getter re-reads as a non-scalar array', () => { - // `enum` validates as scalars, then returns an array containing an object - // at render time; the render must fall back to the broad type. + it('snapshots const with one read so a third-read switch cannot spell a non-scalar', () => { + // A getter returning 'fixed' on the validation AND check reads but an + // object on a third read would defeat a separate check-read/spell-read. + // The render snapshots once, so it either spells the checked value or + // degrades — never Literal[[object Object]]. + let reads = 0 + const schema: Record = { type: 'string' } + Object.defineProperty(schema, 'const', { + enumerable: true, + get() { + reads += 1 + return reads <= 2 ? 'fixed' : {} + }, + }) + let out: string | undefined + expect(() => { out = jsonSchemaToPy(schema) }).not.toThrow() + expect(out === 'str' || out === 'Literal["fixed"]').toBe(true) + expect(out).not.toContain('object Object') + }) + + it('degrades to the broad type when an enum getter re-reads as a non-array', () => { + // A validated enum array that re-reads as a non-array must degrade, not + // spread a non-iterable or spell a bad literal. let reads = 0 const schema: Record = { type: 'string' } Object.defineProperty(schema, 'enum', { enumerable: true, get() { reads += 1 - return reads <= 1 ? ['a', 'b'] : [{}] + return reads <= 1 ? ['a'] : 'not-an-array' }, }) let out: string | undefined expect(() => { out = jsonSchemaToPy(schema) }).not.toThrow() expect(out).toBe('str') + }) + + it('degrades to the broad type when an enum getter re-reads as an empty array', () => { + // A validated non-empty enum that re-reads as [] would spell Literal[] — a + // Python SyntaxError that breaks the whole SDK. Require non-empty at render. + let reads = 0 + const schema: Record = { type: 'string' } + Object.defineProperty(schema, 'enum', { + enumerable: true, + get() { + reads += 1 + return reads <= 1 ? ['a'] : [] + }, + }) + let out: string | undefined + expect(() => { out = jsonSchemaToPy(schema) }).not.toThrow() + expect(out).toBe('str') + expect(out).not.toContain('Literal[]') + }) + + it('degrades the broad type when an enum element is an accessor that re-reads as a non-scalar', () => { + // `[...raw]` reads each element exactly once; the validation read saw a + // scalar, the spread read returns an object. The snapshot's every(isPyScalar) + // check must degrade rather than spell Literal[[object Object]]. + let elemReads = 0 + const arr: unknown[] = [] + Object.defineProperty(arr, '0', { + enumerable: true, + configurable: true, + get() { + elemReads += 1 + return elemReads <= 1 ? 'a' : {} + }, + }) + arr.length = 1 + const schema = { type: 'string', enum: arr } + let out: string | undefined + expect(() => { out = jsonSchemaToPy(schema) }).not.toThrow() + expect(out).toBe('str') expect(out).not.toContain('object Object') }) + it('spells a const re-read as null with None, not the JS string "null"', () => { + let reads = 0 + const schema: Record = { type: 'string' } + Object.defineProperty(schema, 'const', { + enumerable: true, + get() { + reads += 1 + return reads <= 1 ? 'fixed' : null + }, + }) + let out: string | undefined + expect(() => { out = jsonSchemaToPy(schema) }).not.toThrow() + // Either the checked value spells, or a null re-read spells None — never "null". + expect(out === 'Literal["fixed"]' || out === 'Literal[None]').toBe(true) + expect(out).not.toContain('Literal[null]') + }) + + it('degrades a oneOf that re-reads as an empty array to Any, not an empty string', () => { + // oneOf validates as two branches, then returns [] at render; a naive join + // would produce '' (a missing type). Degrade to Any instead. + let reads = 0 + const schema: Record = {} + Object.defineProperty(schema, 'oneOf', { + enumerable: true, + get() { + reads += 1 + return reads <= 1 ? [{ type: 'string' }, { type: 'number' }] : [] + }, + }) + let out: string | undefined + expect(() => { out = jsonSchemaToPy(schema) }).not.toThrow() + expect(out).toBe('Any') + expect(out).not.toBe('') + }) + it('emits exact digits for a beyond-safe-range integer literal', () => { // Python integers are arbitrary-precision, so the emitted digits ARE the // value the model programs against. `String(2 ** 60)` prints the rounded