fix(codemode): strip __proto__ keys at host object boundaries (#48926)

This commit is contained in:
Aiden Cline
2026-09-14 00:49:42 -05:00
committed by GitHub
Unverified
parent 0a3dc16961
commit 3045156fa6
4 changed files with 49 additions and 6 deletions
+3 -1
View File
@@ -20,7 +20,9 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
- [x] Top-level `await` and `return` through the program's implicit async-function scope.
- [x] Explicit `return`, final top-level expression as a REPL-style result, and `null` when no value is produced.
- [x] Program results use JSON-like boundaries, with `undefined` and non-finite numbers normalized to `null`. Tool
arguments follow JSON serialization semantics before their schema applies (see the tools section).
arguments follow JSON serialization semantics before their schema applies (see the tools section). Own
`__proto__` keys are dropped wherever a host object crosses to the host, so merging tool inputs or results
cannot replace a prototype; `JSON.stringify` still emits the key, like JS, since a string cannot pollute.
- [x] Live Date, RegExp, Map, Set, URL, and URLSearchParams values inside CodeMode.
- [x] Tool calls through the host-provided `tools` tree only.
- [x] The global `search(...)` built-in: synchronous tool discovery that counts as an admitted tool call and is
+10 -3
View File
@@ -59,8 +59,12 @@ export const fromData = (protos: Prototypes, value: unknown, label: string): unk
* dropped ("json") or become null ("result", for program results where the consumer must never see
* undefined); a bare `undefined` follows the same rule.
*/
export const toData = (value: unknown, label: string, undefinedAs: "json" | "result" = "json"): unknown =>
copy(value, label, undefinedAs, 0, new Set())
export const toData = (
value: unknown,
label: string,
undefinedAs: "json" | "result" = "json",
stripProto = true,
): unknown => copy(value, label, undefinedAs, 0, new Set(), undefined, stripProto)
// "program" and "data" build program objects; "json" and "result" build ordinary objects for the host.
type Mode = "program" | "data" | "json" | "result"
@@ -72,8 +76,9 @@ const copy = (
depth: number,
seen: Set<object>,
protos?: Prototypes,
stripProto = true,
): unknown => {
const next = (item: unknown) => copy(item, label, mode, depth + 1, seen, protos)
const next = (item: unknown) => copy(item, label, mode, depth + 1, seen, protos, stripProto)
if (depth > MAX_VALUE_DEPTH) {
throw new ToolRuntimeError("InvalidDataValue", `${label} exceeds the maximum value depth of ${MAX_VALUE_DEPTH}.`)
}
@@ -145,6 +150,7 @@ const copy = (
defineHost(copied, "message", next(get(value, "message")))
}
for (const [key, item] of entries(value)) {
if (stripProto && key === "__proto__") continue
const copiedItem = next(item)
if (copiedItem === undefined && mode === "json") continue
defineHost(copied, key, copiedItem)
@@ -180,6 +186,7 @@ const copy = (
}
const copied: Record<string, unknown> = {}
for (const [key, item] of Object.entries(value)) {
if (stripProto && key === "__proto__") continue
const copiedItem = next(item)
if (copiedItem === undefined && mode === "json") continue
defineHost(copied, key, copiedItem)
+2 -1
View File
@@ -56,7 +56,8 @@ const stringify = <R>(runner: Runner<R>, args: Array<unknown>): Effect.Effect<un
.filter((item): item is string | number => typeof item === "string" || typeof item === "number")
.map(String)
: null
return Effect.succeed(JSON.stringify(toData(args[0], "JSON.stringify value"), properties, indent))
// A string cannot pollute, so __proto__ stays: JSON.stringify includes own __proto__ keys, like JS.
return Effect.succeed(JSON.stringify(toData(args[0], "JSON.stringify value", "json", false), properties, indent))
}
// Validate up front; the replacer walk below reads the original value.
+34 -1
View File
@@ -198,7 +198,9 @@ describe("blocked member names on tool paths", () => {
const poisoned = await failure(runtime, `const o = {}; o.__proto__.constructor("return 1")`)
expect(poisoned.message).toContain("Cannot read properties of undefined")
// Prototype mutation is confined to one run: the next program starts from fresh intrinsics.
expect(await value(runtime, `Object.prototype.polluted = 1; Array.prototype.push = 2; return ({}).polluted`)).toBe(1)
expect(await value(runtime, `Object.prototype.polluted = 1; Array.prototype.push = 2; return ({}).polluted`)).toBe(
1,
)
expect(await value(runtime, `return [({}).polluted, typeof [].push]`)).toEqual([null, "function"])
expect(Object.keys(Object.prototype)).toEqual([])
expect(Object.keys(Array.prototype)).toEqual([])
@@ -279,3 +281,34 @@ describe("canonical path collisions", () => {
expect(await value(runtime, `return await tools.issues.close({})`)).toBe("closed")
})
})
describe("tool argument prototype safety", () => {
test("a __proto__ key never reaches tool code", async () => {
let seen: unknown
const runtime = CodeMode.make({
tools: {
inspect: Tool.make({
description: "Inspect",
input: Schema.Struct({ v: Schema.Unknown }),
output: Schema.Unknown,
execute: (input) =>
Effect.sync(() => {
seen = (input as { v: unknown }).v
return null
}),
}),
},
})
await value(runtime, `return await tools.inspect({ v: { __proto__: { polluted: true }, a: 1 } })`)
expect(seen).toEqual({ a: 1 })
const merged = Object.assign({}, seen as Record<string, unknown>)
expect(merged.polluted).toBeUndefined()
expect(Object.getPrototypeOf(merged)).toBe(Object.prototype)
})
test("program results drop __proto__ keys", async () => {
const runtime = CodeMode.make({ tools: {} })
expect(await value(runtime, `return { __proto__: { polluted: true }, a: 2 }`)).toEqual({ a: 2 })
expect(await value(runtime, `return [{ __proto__: 1 }]`)).toEqual([{}])
})
})