From 3045156fa6a4fa7efbedeca30f86f8f87602342f Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Mon, 14 Sep 2026 00:49:42 -0500 Subject: [PATCH] fix(codemode): strip __proto__ keys at host object boundaries (#48926) --- packages/codemode/interpreter-support.md | 4 ++- packages/codemode/src/data.ts | 13 +++++++-- packages/codemode/src/stdlib/json.ts | 3 +- packages/codemode/test/tool-paths.test.ts | 35 ++++++++++++++++++++++- 4 files changed, 49 insertions(+), 6 deletions(-) diff --git a/packages/codemode/interpreter-support.md b/packages/codemode/interpreter-support.md index edf12a80e97..559c3cf251a 100644 --- a/packages/codemode/interpreter-support.md +++ b/packages/codemode/interpreter-support.md @@ -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 diff --git a/packages/codemode/src/data.ts b/packages/codemode/src/data.ts index 4aa69154e7f..0c44697961a 100644 --- a/packages/codemode/src/data.ts +++ b/packages/codemode/src/data.ts @@ -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, 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 = {} 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) diff --git a/packages/codemode/src/stdlib/json.ts b/packages/codemode/src/stdlib/json.ts index eef9e68976c..16c4e647831 100644 --- a/packages/codemode/src/stdlib/json.ts +++ b/packages/codemode/src/stdlib/json.ts @@ -56,7 +56,8 @@ const stringify = (runner: Runner, args: Array): Effect.Effect 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. diff --git a/packages/codemode/test/tool-paths.test.ts b/packages/codemode/test/tool-paths.test.ts index 560b3c6acb6..2c9e90016f3 100644 --- a/packages/codemode/test/tool-paths.test.ts +++ b/packages/codemode/test/tool-paths.test.ts @@ -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) + 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([{}]) + }) +})