refactor(codemode): treat prototype-named keys as ordinary data (#48218)

This commit is contained in:
Aiden Cline
2026-09-09 16:54:55 -05:00
committed by GitHub
parent 0bbf29fea6
commit 50ed7c41ef
14 changed files with 87 additions and 117 deletions
+7 -10
View File
@@ -47,7 +47,7 @@ ultimate source of truth.
- [x] `const`, `let`, and accepted `var` declarations.
- [x] Object and array destructuring in declarations, parameters, assignment expressions, and `for...of` bindings.
- [x] Nested patterns, defaults, elisions, and rest elements.
- [x] Assignment to identifiers, unblocked plain-object fields, non-negative integer array indexes, and writable URL
- [x] Assignment to identifiers, plain-object fields, non-negative integer array indexes, and writable URL
fields.
- [x] Direct function declarations are hoisted in program and block statement lists.
- [x] Parameter defaults observe a temporal dead zone for later parameters.
@@ -213,10 +213,10 @@ ultimate source of truth.
synchronous iterator support for `fromEntries`.
- [x] `Object.keys` over arrays and tool references.
- [x] Object identity is preserved by in-CodeMode Object helpers.
- [x] Prototype traversal and mutation through `__proto__`, `constructor`, and `prototype` are blocked.
- [x] `__proto__`, `constructor`, and `prototype` are ordinary own data keys. Prototype machinery is not observable:
data objects have no prototype, so `({}).constructor` and `[].__proto__` read as `undefined` and `o.__proto__ = x`
sets an own field.
- [x] Circular references are rejected when created (`o.self = o`, `array.push(array)`), not at serialization as in JS.
- [ ] Legal own data fields named `__proto__`, `constructor`, or `prototype` are rejected at JSON/tool boundaries and
cannot be created, read, or written in CodeMode; tool path segments with those names remain supported.
- [x] `Object.is` for supported data values.
- [x] `Object.groupBy` over finite collections and custom synchronous iterators/generators, with string-key coercion
and null-prototype results.
@@ -280,23 +280,20 @@ ultimate source of truth.
use their epoch time) and reject opaque runtime references as data errors.
- [x] Unknown static members on global namespaces and on `Number`/`String`/the coercion functions read as `undefined`
for feature detection. Calling any undefined value reports a native-style `TypeError` naming the callee, for
example `Math.sum is not a function.` Blocked members (`constructor`, `__proto__`, ...) still throw,
and unknown `Promise` statics keep their descriptive error.
example `Math.sum is not a function.` Unknown `Promise` statics keep their descriptive error.
- [x] `Math.sumPrecise` over finite collections and custom synchronous iterators/generators, rejecting non-number
elements without coercion.
- [x] Global coercing `isFinite` and `isNaN`; opaque runtime references reject as data errors, like `Number(...)`.
## JSON and console
- [x] `JSON.parse` and `JSON.stringify` for supported data objects; the blocked data-key gap listed above still applies.
- [x] `JSON.parse` and `JSON.stringify` for supported data objects.
- [x] Numeric/string indentation for `JSON.stringify`.
- [x] `JSON.parse` reviver callbacks, including postorder traversal, deletion through `undefined`, and root replacement.
Revivers receive `(key, value)` but no `this` holder because CodeMode functions intentionally have no `this`.
- [x] `JSON.stringify` function and array replacers. Function replacers receive `(key, value)` in preorder, including
the root, but no `this` holder. Array replacers preserve requested property order, deduplicate names, coerce
number primitives, and ignore non-string/non-number entries. Primitive wrapper entries remain unsupported.
- [x] JSON callbacks retain the blocked-key boundary: parsed or stringified data containing `__proto__`, `constructor`,
or `prototype` is rejected before callback traversal.
- [x] Captured `console.log`, `console.info`, `console.debug`, `console.warn`, and `console.error`.
- [x] Captured `console.dir` and `console.table`.
@@ -327,7 +324,7 @@ ultimate source of truth.
- [x] `test`, `exec`, and `toString`.
- [x] Readable `source`, `flags`, `lastIndex`, `hasIndices`, `global`, `ignoreCase`, `multiline`, `sticky`, `unicode`,
`unicodeSets`, and `dotAll`.
- [x] Captures, safe named groups (blocked member names are omitted), match `.index`, and stateful global matching.
- [x] Captures, named groups, match `.index`, and stateful global matching.
- [x] Integration with supported String methods, including function replacers.
- [x] Writable `lastIndex`.
- [x] Match `indices` metadata for the `d` flag, including named groups on `exec`, `match`, and `matchAll` results.
+8 -12
View File
@@ -22,10 +22,6 @@ export class ToolRuntimeError extends Error {
}
}
const blockedMemberNames = new Set(["__proto__", "constructor", "prototype"])
export const isBlockedMember = (name: string): boolean => blockedMemberNames.has(name)
/**
* Brings a host-produced runtime value into the program: runtime values pass through, their host
* counterparts (Date, RegExp, Map, Set, URL, URLSearchParams) are wrapped, and objects become
@@ -118,10 +114,7 @@ const copy = (value: unknown, label: string, mode: Mode, depth: number, seen: Se
if (mode === "program") {
for (const [key, item] of Object.entries(value)) {
if (Object.hasOwn(copied, key)) continue
if (isBlockedMember(key)) {
throw new ToolRuntimeError("InvalidDataValue", `${label} contains blocked property '${key}'.`)
}
Reflect.set(copied, key, copy(item, label, mode, depth + 1, seen))
define(copied, key, copy(item, label, mode, depth + 1, seen))
}
}
seen.delete(value)
@@ -135,13 +128,16 @@ const copy = (value: unknown, label: string, mode: Mode, depth: number, seen: Se
const copied: SafeObject = plain ? (Object.create(null) as SafeObject) : {}
for (const [key, item] of Object.entries(value)) {
if (isBlockedMember(key)) {
throw new ToolRuntimeError("InvalidDataValue", `${label} contains blocked property '${key}'.`)
}
const next = copy(item, label, mode, depth + 1, seen)
if (next === undefined && mode === "json") continue
copied[key] = next
define(copied, key, next)
}
seen.delete(value)
return copied
}
// Own data property regardless of the target's prototype, so a "__proto__" key on a host object or
// array never reaches the Object.prototype setter.
const define = (target: object, key: string, value: unknown): void => {
Object.defineProperty(target, key, { value, enumerable: true, writable: true, configurable: true })
}
+2 -2
View File
@@ -1,5 +1,5 @@
import { Effect } from "effect"
import { isBlockedMember, type SafeObject, toProgram } from "../data.js"
import { type SafeObject, toProgram } from "../data.js"
import { dateSetterArgumentCount, invokeDateMethod } from "../stdlib/date.js"
import { invokeNumberMethod } from "../stdlib/number.js"
import { invokeRegExpMethod, matchToValue, toHostRegex } from "../stdlib/regexp.js"
@@ -280,7 +280,7 @@ const invokeStringReplacer = <R>(
if (hasGroups) {
const safeGroups: SafeObject = Object.create(null) as SafeObject
for (const [key, group] of Object.entries(groups)) {
if (!isBlockedMember(key)) safeGroups[key] = group
safeGroups[key] = group
}
callbackArgs[callbackArgs.length - 1] = safeGroups
}
+4 -23
View File
@@ -41,7 +41,7 @@ import type {
YieldExpression,
} from "acorn"
import { Cause, Deferred, Effect, Exit } from "effect"
import { isBlockedMember, ToolRuntimeError, type SafeObject, toProgram } from "../data.js"
import { ToolRuntimeError, type SafeObject, toProgram } from "../data.js"
import { ToolReference } from "../tool-runtime.js"
import {
type AstNode,
@@ -1042,7 +1042,7 @@ class Frame<R> {
if (property.type === "RestElement") {
const rest: SafeObject = Object.create(null) as SafeObject
for (const [key, item] of Object.entries(value as SafeObject)) {
if (!consumed.has(key) && !isBlockedMember(key)) rest[key] = item
if (!consumed.has(key)) rest[key] = item
}
copyIteratorSymbols(value, rest, consumed)
yield* self.declarePattern(property.argument, rest, mutable, property, initialize)
@@ -1050,9 +1050,6 @@ class Frame<R> {
}
const key = yield* self.destructuringPropertyKey(property)
if (isBlockedMember(String(key))) {
throw new InterpreterRuntimeError(`Property '${String(key)}' is not available.`, property)
}
consumed.add(typeof key === "symbol" ? key : String(key))
yield* self.declarePattern(
property.value,
@@ -1109,16 +1106,13 @@ class Frame<R> {
if (property.type === "RestElement") {
const rest: SafeObject = Object.create(null) as SafeObject
for (const [key, item] of Object.entries(source)) {
if (!consumed.has(key) && !isBlockedMember(key)) rest[key] = item
if (!consumed.has(key)) rest[key] = item
}
copyIteratorSymbols(source, rest, consumed)
yield* self.assignPattern(property.argument, rest, property)
continue
}
const key = yield* self.destructuringPropertyKey(property)
if (isBlockedMember(String(key))) {
throw new InterpreterRuntimeError(`Property '${String(key)}' is not available.`, property)
}
consumed.add(typeof key === "symbol" ? key : String(key))
yield* self.assignPattern(property.value, self.destructuringPropertyValue(source, key), property)
}
@@ -1908,10 +1902,7 @@ class Frame<R> {
"InvalidDataValue",
)
}
for (const [key, value] of Object.entries(spread)) {
if (isBlockedMember(key)) throw new InterpreterRuntimeError(`Property '${key}' is not available.`, property)
objectValue[key] = value
}
for (const [key, value] of Object.entries(spread)) objectValue[key] = value
copyIteratorSymbols(spread, objectValue)
continue
}
@@ -1934,9 +1925,6 @@ class Frame<R> {
throw new InterpreterRuntimeError("Unsupported object property key shape.", keyNode)
}
if (isBlockedMember(String(key))) {
throw new InterpreterRuntimeError(`Property '${String(key)}' is not available.`, keyNode)
}
Reflect.set(objectValue, key, yield* self.evaluateExpression(property.value))
}
@@ -2051,9 +2039,6 @@ class Frame<R> {
}
if (objectValue instanceof HostFunction || objectValue instanceof HostNamespace) {
if (typeof key === "string" && isBlockedMember(key)) {
throw new InterpreterRuntimeError(`${objectValue.name}.${key} is not available.`, propertyNode)
}
// Unknown static members read as undefined so feature detection works like native JS.
return new ComputedValue(objectValue.member(key, propertyNode))
}
@@ -2146,10 +2131,6 @@ class Frame<R> {
throw new InterpreterRuntimeError("Cannot access a property on a non-object value.", objectNode)
}
if (typeof key === "string" && isBlockedMember(key)) {
throw new InterpreterRuntimeError(`Property '${key}' is not available.`, propertyNode)
}
if (Array.isArray(objectValue)) {
if (operation === "delete") return { target: objectValue, key }
const index = typeof key === "symbol" ? undefined : parseArrayIndex(key)
+3 -5
View File
@@ -1,6 +1,5 @@
import { fromSchemaOpenApi3_0, fromSchemaOpenApi3_1 } from "effect/JsonSchema"
import type { JsonSchema } from "../tool.js"
import { isBlockedMember } from "../data.js"
import type {
Body,
Document,
@@ -468,8 +467,7 @@ export const operationInput = (
ok: true,
value: {
fields: fields.map((field) => {
const visibleName = isBlockedMember(field.name) ? `${field.name}_2` : field.name
const base = conflicts.has(field.name) ? `${field.location}_${visibleName}` : visibleName
const base = conflicts.has(field.name) ? `${field.location}_${field.name}` : field.name
const next = (index: number): string => {
const candidate = index === 1 ? base : `${base}_${index}`
return used.has(candidate) ? next(index + 1) : candidate
@@ -567,12 +565,12 @@ export const operationOutput = (
}
const sanitizeOperationSegment = (raw: string): string => {
const base =
return (
raw
.replaceAll(/[^A-Za-z0-9_$]+/g, "_")
.replace(/^_+|_+$/g, "")
.replace(/^([0-9])/, "_$1") || "operation"
return isBlockedMember(base) ? `${base}_2` : base
)
}
const fallbackOperationId = (method: string, path: string): string =>
+1 -7
View File
@@ -1,5 +1,5 @@
import { Effect } from "effect"
import { isBlockedMember, type SafeObject } from "../data.js"
import type { SafeObject } from "../data.js"
import { HostFunction, requiresNew } from "../interpreter/host.js"
import { type AstNode, InterpreterRuntimeError, isRecord } from "../interpreter/model.js"
import { describeValue, isRuntimeReference } from "../interpreter/references.js"
@@ -124,12 +124,6 @@ export const groupBy = <R>(runner: Runner<R>, namespace: "Map" | "Object") =>
cursor,
Effect.flatMap(apply([item, index]), (value) => coerceGroupByPropertyKey(runner, value, node)),
)
if (isBlockedMember(key)) {
return yield* preserveConsumerError(
cursor,
Effect.fail(new InterpreterRuntimeError(`Property '${key}' is not available.`, node)),
)
}
const group = result[key]
if (group === undefined) result[key] = [item]
else (group as Array<unknown>).push(item)
-9
View File
@@ -6,15 +6,6 @@ import { typeofValue } from "../interpreter/references.js"
import { fromData, type SafeObject, toData, toProgram } from "../data.js"
import { Values } from "../values.js"
export const invokeJsonMethod = <R>(
runner: Runner<R>,
name: "parse" | "stringify",
args: Array<unknown>,
node: AstNode,
): Effect.Effect<unknown, unknown, R> => {
return name === "parse" ? parse(runner, args, node) : stringify(runner, args, node)
}
export const jsonGlobal = <R>(runner: Runner<R>) =>
new HostNamespace("JSON", {
parse: new HostFunction<R>({ name: "JSON.parse", call: (args, node) => parse(runner, args, node) }),
+2 -5
View File
@@ -1,5 +1,5 @@
import { Effect } from "effect"
import { isBlockedMember, toProgram } from "../data.js"
import { toProgram } from "../data.js"
import { HostFunction, sync, syncCall } from "../interpreter/host.js"
import { type AstNode, AsyncIteratorSymbol, InterpreterRuntimeError, IteratorSymbol } from "../interpreter/model.js"
import {
@@ -36,8 +36,6 @@ export const objectAssign = (args: Array<unknown>, node: AstNode): unknown => {
const out = target as Record<string, unknown>
const seen = new Set<object>()
const guardedSet = (key: PropertyKey, item: unknown): void => {
if (typeof key === "string" && isBlockedMember(key))
throw new InterpreterRuntimeError(`Property '${key}' is not available.`, node)
rejectCircularInsertion(out, item, "Object.assign result", node, seen)
if (!Reflect.set(out, key, item))
throw new InterpreterRuntimeError(`Object.assign could not assign property '${String(key)}'.`, node).as(
@@ -95,7 +93,6 @@ const objectFromEntries = <R>(
toProgram(entry[0], "Object.fromEntries key")
toProgram(entry[1], "Object.fromEntries value")
const key = coerceToString(entry[0])
if (isBlockedMember(key)) throw new InterpreterRuntimeError(`Property '${key}' is not available.`, node)
out[key] = entry[1]
}),
)
@@ -105,7 +102,7 @@ const objectFromEntries = <R>(
const constructObject = (args: Array<unknown>, node: AstNode): unknown => {
const first = args[0]
if (first === null || first === undefined) return {}
if (first === null || first === undefined) return Object.create(null)
if (typeof first === "object") return first
throw new InterpreterRuntimeError(
`Object(${typeof first}) wrapper objects are not supported; use the primitive value directly.`,
+3 -3
View File
@@ -1,6 +1,6 @@
import { sync, syncCall } from "../interpreter/host.js"
import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js"
import { isBlockedMember, type SafeObject } from "../data.js"
import type { SafeObject } from "../data.js"
import { Values } from "../values.js"
import { coerceToNumber, coerceToString } from "./value.js"
@@ -62,7 +62,7 @@ export const matchToValue = (match: RegExpMatchArray): Array<unknown> => {
if (match.groups) {
const groups: SafeObject = Object.create(null) as SafeObject
for (const [key, group] of Object.entries(match.groups)) {
if (!isBlockedMember(key)) groups[key] = group
groups[key] = group
}
result.groups = groups
}
@@ -148,7 +148,7 @@ const indicesToValue = (indices: RegExpIndicesArray): IndicesValue => {
if (indices.groups) {
const groups: SafeObject = Object.create(null) as SafeObject
for (const [key, range] of Object.entries(indices.groups)) {
if (!isBlockedMember(key)) groups[key] = range === undefined ? undefined : [...range]
groups[key] = range === undefined ? undefined : [...range]
}
result.groups = groups
return result
@@ -29,7 +29,6 @@
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { CodeMode } from "../src/index.js"
import { invokeJsonMethod } from "../src/stdlib/json.js"
const value = async (code: string) => {
const result = await Effect.runPromise(CodeMode.execute({ code, tools: {} }))
@@ -218,27 +217,19 @@ describe("CodeMode JSON callback boundaries", () => {
expect(result).toMatchObject({ ok: false, error: { kind: "UnsupportedSyntax" } })
})
test("blocked parse keys are rejected before reviver traversal", async () => {
test("prototype-named keys parse as own data and reach the reviver", async () => {
expect(
await value(
`try { JSON.parse('{"__proto__":1}', (key, item) => item) } catch (error) { return true } return false`,
),
).toBe(true)
await value(`
const seen = []
const parsed = JSON.parse('{"__proto__":{"polluted":1},"constructor":2}', (key, item) => { seen.push(key); return item })
return [seen, parsed.__proto__.polluted, parsed.constructor, ({}).polluted, Object.keys(parsed)]
`),
).toEqual([["polluted", "__proto__", "constructor", ""], 1, 2, null, ["__proto__", "constructor"]])
})
test("JSON.stringify directly rejects blocked input keys", () => {
expect(() =>
invokeJsonMethod(
{
invokeFunction: () => Effect.die("unused"),
invokeCallable: () => Effect.die("unused"),
settlePromise: () => Effect.die("unused"),
syncIterator: () => Effect.die("unused"),
},
"stringify",
[Object.fromEntries([["constructor", 1]])],
{ type: "CallExpression", start: 0, end: 0 },
),
).toThrow("blocked property 'constructor'")
test("JSON.stringify serializes prototype-named own keys", async () => {
expect(await value(`return JSON.stringify({ constructor: 1, __proto__: 2 })`)).toBe(
'{"constructor":1,"__proto__":2}',
)
})
})
+1 -1
View File
@@ -1095,7 +1095,7 @@ describe("OpenAPI.fromSpec", () => {
tags: ["x", "y"],
filter: { state: "open", page: 2 },
nullable: null,
constructor_2: "safe",
constructor: "safe",
meta: { a: "b", c: "d" },
})
.pipe(Effect.provide(client.layer)),
+12 -8
View File
@@ -265,9 +265,11 @@ describe("property deletion", () => {
expect((await error(`return delete tools.example`)).kind).toBe("InvalidDataValue")
})
test("keeps blocked property names unavailable", async () => {
expect((await error(`const object = {}; return delete object.__proto__`)).kind).toBe("ExecutionFailure")
expect((await error(`const values = []; return delete values["constructor"]`)).kind).toBe("ExecutionFailure")
test("prototype-named keys delete like any own data key", async () => {
expect(
await value(`const object = { __proto__: 1, a: 2 }; delete object.__proto__; return Object.keys(object)`),
).toEqual(["a"])
expect(await value(`const values = [1]; delete values["constructor"]; return values`)).toEqual([1])
})
})
@@ -906,10 +908,12 @@ describe("coercion parity: unknown static members read as undefined", () => {
)
})
test("blocked members still throw instead of reading as undefined", async () => {
const err = await error(`return Math.constructor`)
expect(err.message).toContain("not available")
const coercionErr = await error(`return Number.constructor`)
expect(coercionErr.message).toContain("Number.constructor is not available")
test("prototype-named members on globals read as undefined like other unknown statics", async () => {
expect(await value(`return [Math.constructor, Number.constructor, Object.prototype, Array.__proto__]`)).toEqual([
null,
null,
null,
null,
])
})
})
@@ -126,7 +126,7 @@ describe("RegExp Test262 parity", () => {
const match = /(?<a>a)(b)?/d.exec("a")
const stringMatch = "a".match(/a/d)
const all = "a a".matchAll(/a/dg)
const blocked = /(?<constructor>a)(?<safe>b)/d.exec("ab")
const named = /(?<constructor>a)(?<safe>b)/d.exec("ab")
return [
/./.hasIndices,
/./d.hasIndices,
@@ -138,10 +138,10 @@ describe("RegExp Test262 parity", () => {
stringMatch.indices[0],
all[0].indices[0],
all[1].indices[0],
Object.keys(blocked.indices.groups),
Object.keys(named.indices.groups),
]
`),
).toEqual([false, true, true, [0, 1], [0, 1], null, [0, 1], [0, 1], [0, 1], [2, 3], ["safe"]])
).toEqual([false, true, true, [0, 1], [0, 1], null, [0, 1], [0, 1], [0, 1], [2, 3], ["constructor", "safe"]])
})
test("match indices preserve captures, Unicode offsets, and groups properties", async () => {
@@ -177,13 +177,13 @@ describe("RegExp Test262 parity", () => {
])
})
test("match and matchAll preserve named, unmatched, and blocked index groups", async () => {
test("match and matchAll preserve named, unmatched, and prototype-named index groups", async () => {
expect(
await value(`
const matched = "a".match(/(?<a>a)|(?<x>x)/d).indices.groups
const all = "a x".matchAll(/(?<a>a)|(?<x>x)/dg)
const blockedMatch = "ab".match(/(?<constructor>a)(?<safe>b)/d).indices.groups
const blockedAll = "ab".matchAll(/(?<constructor>a)(?<safe>b)/dg)[0].indices.groups
const namedMatch = "ab".match(/(?<constructor>a)(?<safe>b)/d).indices.groups
const namedAll = "ab".matchAll(/(?<constructor>a)(?<safe>b)/dg)[0].indices.groups
return [
matched.a,
matched.x,
@@ -191,11 +191,11 @@ describe("RegExp Test262 parity", () => {
all[0].indices.groups.x,
all[1].indices.groups.a,
all[1].indices.groups.x,
Object.keys(blockedMatch),
Object.keys(blockedAll),
Object.keys(namedMatch),
Object.keys(namedAll),
]
`),
).toEqual([[0, 1], null, [0, 1], null, null, [2, 3], ["safe"], ["safe"]])
).toEqual([[0, 1], null, [0, 1], null, null, [2, 3], ["constructor", "safe"], ["constructor", "safe"]])
})
test("v flag exposes unicodeSets and remains exclusive with u", async () => {
+24 -3
View File
@@ -176,10 +176,31 @@ describe("blocked member names on tool paths", () => {
expect(await value(poisoned, `return await tools.ns.real({})`)).toBe("real")
})
test("blocked member access on data values stays blocked", async () => {
const diagnostic = await failure(runtime, `const x = {}; return x.constructor`)
expect(diagnostic.message).toContain("constructor")
test("prototype machinery is unreachable through data values", async () => {
expect(
await value(
runtime,
`
const object = {}
const array = []
object.__proto__ = { polluted: true }
return [
object.constructor, array.constructor, "".constructor, Math.constructor,
object.__proto__.polluted, ({}).polluted, array.__proto__, Object().__proto__, new Object().constructor,
typeof ({}).constructor, typeof [].__proto__,
]
`,
),
).toEqual([null, null, null, null, true, null, null, null, null, "undefined", "undefined"])
expect((await failure(runtime, `return (() => 1).constructor`)).message).toContain(
"Cannot read properties of a function",
)
const escape = await failure(runtime, `return ({}).constructor.constructor("return 1")()`)
expect(escape.message).toContain("Cannot access a property on a non-object value")
const poisoned = await failure(runtime, `const o = {}; o.__proto__.constructor("return 1")`)
expect(poisoned.message).toContain("Cannot access a property on a non-object value")
expect(Object.keys(Object.prototype)).toEqual([])
expect(Object.keys(Array.prototype)).toEqual([])
})
})