From b8cb95d6cfe49a1c2d8f359e2fe6b2f7170b51dd Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Mon, 20 Jul 2026 17:28:59 -0500 Subject: [PATCH] fix(codemode): reject oversized BigInt source --- packages/codemode/interpreter-support.md | 3 +- packages/codemode/src/interpreter/execute.ts | 139 +++++++++++++++++- packages/codemode/src/interpreter/runtime.ts | 9 +- packages/codemode/test/bigint-test262.test.ts | 87 +++++++++-- 4 files changed, 225 insertions(+), 13 deletions(-) diff --git a/packages/codemode/interpreter-support.md b/packages/codemode/interpreter-support.md index 3916396ae0..46603c15b7 100644 --- a/packages/codemode/interpreter-support.md +++ b/packages/codemode/interpreter-support.md @@ -40,7 +40,8 @@ ultimate source of truth. arithmetic throws. BigInt remains invalid in program results, `JSON.stringify`, and tool arguments/results; the `BigInt` constructor and other BigInt-specific standard-library APIs are not exposed. In-interpreter BigInts are limited to 4,096 bits; multiplication, exponentiation, and size-increasing shifts conservatively reject before - native evaluation when their result may exceed that limit. + native evaluation when their result may exceed that limit, and necessarily oversized literal tokens are rejected + by a bounded source scan before TypeScript or Acorn parses them. - [ ] Symbol primitive values and symbol-keyed properties. - [ ] Tagged-template calls. - [ ] Getter and setter definitions in object literals. diff --git a/packages/codemode/src/interpreter/execute.ts b/packages/codemode/src/interpreter/execute.ts index af2eccf9b8..ae53178c12 100644 --- a/packages/codemode/src/interpreter/execute.ts +++ b/packages/codemode/src/interpreter/execute.ts @@ -1,8 +1,19 @@ import { parse } from "acorn" import { Cause, Effect, Scope } from "effect" -import { DiagnosticCategory, ModuleKind, ScriptTarget, flattenDiagnosticMessageText, transpileModule } from "typescript" +import { + DiagnosticCategory, + ModuleKind, + ScriptKind, + ScriptTarget, + SyntaxKind, + createSourceFile, + flattenDiagnosticMessageText, + forEachChild, + transpileModule, + type Node, +} from "typescript" import type { DataValue, Diagnostic, ExecuteOptions, ResolvedExecutionLimits, Result } from "../codemode.js" -import { copyIn, copyOut, ToolRuntime, type Services } from "../tool-runtime.js" +import { copyIn, copyOut, MAX_BIGINT_BITS, ToolRuntime, type Services } from "../tool-runtime.js" import type { Tools } from "../tools.js" import { normalizeError } from "./errors.js" import { InterpreterRuntimeError, isRecord, type ProgramNode } from "./model.js" @@ -119,6 +130,7 @@ export const executeWithLimits = } const parseProgram = (code: string): ProgramNode => { + assertBigIntLiteralSourcesBounded(code) const transpiled = transpileModule(`async function __codemode__() {\n${code}\n}`, { reportDiagnostics: true, compilerOptions: { @@ -154,6 +166,129 @@ const parseProgram = (code: string): ProgramNode => { return parsed as ProgramNode } +const decimalBigIntLimit = ((1n << BigInt(MAX_BIGINT_BITS)) - 1n).toString() + +// TypeScript and Acorn materialize BigInt tokens before the interpreter can apply its value limit. +// Find necessarily oversized digit runs without constructing a BigInt, replace them with small +// sentinels, and parse only that shortened source to distinguish literals from identical text in +// strings, comments, templates, and regexps. Neither real parser sees an oversized BigInt token. +const assertBigIntLiteralSourcesBounded = (code: string): void => { + const candidates = oversizedBigIntSources(code) + if (candidates.length === 0) return + + let sourceEnd = 0 + let maskedEnd = 0 + const starts = new Set() + const chunks = candidates.map((candidate) => { + const chunk = `${code.slice(sourceEnd, candidate.start)}0n` + starts.add(maskedEnd + chunk.length - 2) + sourceEnd = candidate.end + maskedEnd += chunk.length + return chunk + }) + const masked = `${chunks.join("")}${code.slice(sourceEnd)}` + const prefix = "async function __codemode__() {\n" + const source = createSourceFile("codemode.ts", `${prefix}${masked}\n}`, ScriptTarget.ESNext, false, ScriptKind.TS) + let oversized = false + const visit = (node: Node): void => { + if (node.kind === SyntaxKind.BigIntLiteral && starts.has(node.getStart(source) - prefix.length)) { + oversized = true + return + } + if (!oversized) forEachChild(node, visit) + } + visit(source) + if (!oversized) return + throw new InterpreterRuntimeError( + `BigInt literal source exceeds CodeMode's ${MAX_BIGINT_BITS}-bit limit before parsing.`, + undefined, + "InvalidDataValue", + ) +} + +const oversizedBigIntSources = (code: string) => { + const candidates: Array<{ start: number; end: number }> = [] + for (let start = 0; start < code.length; start++) { + if (code[start] < "0" || code[start] > "9" || isIdentifierPart(code[start - 1])) continue + const prefix = code[start] === "0" ? code[start + 1]?.toLowerCase() : undefined + const radix = prefix === "b" ? 2 : prefix === "o" ? 8 : prefix === "x" ? 16 : 10 + const radixBits = radix === 2 ? 1 : radix === 8 ? 3 : radix === 16 ? 4 : 0 + const digitsStart = radix === 10 ? start : start + 2 + let end = digitsStart + let valid = true + let separator = false + let significantStart = -1 + let significantDigits = 0 + let firstSignificant = 0 + while (end < code.length) { + const digit = digitValue(code[end]) + if (digit >= 0 && digit < radix) { + if (digit !== 0 || significantStart !== -1) { + if (significantStart === -1) { + significantStart = end + firstSignificant = digit + } + significantDigits++ + } + separator = false + end++ + continue + } + if (code[end] !== "_") break + if (end === digitsStart || separator) valid = false + separator = true + end++ + } + if (separator || code[end] !== "n") valid = false + if (radix === 10 && end > start + 1 && code[start] === "0") valid = false + if (code[end] === "n") end++ + if ( + valid && + !isIdentifierPart(code[end]) && + sourceBigIntExceedsLimit(code, significantStart, end - 1, significantDigits, firstSignificant, radixBits) + ) { + candidates.push({ start, end }) + } + start = Math.max(start, end - 1) + } + return candidates +} + +const isIdentifierPart = (character: string | undefined): boolean => + character !== undefined && (/[A-Za-z0-9_$]/.test(character) || character.charCodeAt(0) > 127) + +const digitValue = (character: string | undefined): number => { + if (character === undefined) return -1 + const value = character.charCodeAt(0) + if (value >= 48 && value <= 57) return value - 48 + if (value >= 65 && value <= 70) return value - 55 + if (value >= 97 && value <= 102) return value - 87 + return -1 +} + +const sourceBigIntExceedsLimit = ( + code: string, + significantStart: number, + suffix: number, + significantDigits: number, + firstSignificant: number, + radixBits: number, +): boolean => { + if (radixBits !== 0) { + if (significantDigits === 0) return false + return (significantDigits - 1) * radixBits + firstSignificant.toString(2).length > MAX_BIGINT_BITS + } + if (significantDigits !== decimalBigIntLimit.length) return significantDigits > decimalBigIntLimit.length + let compared = 0 + for (let position = significantStart; position < suffix; position++) { + if (code[position] === "_") continue + const difference = code.charCodeAt(position) - decimalBigIntLimit.charCodeAt(compared) + if (difference !== 0) return difference > 0 + compared++ + } + return false +} + const utf8ByteLength = (value: string): number => new TextEncoder().encode(value).byteLength // Drop a replacement character produced by truncating inside a UTF-8 sequence. diff --git a/packages/codemode/src/interpreter/runtime.ts b/packages/codemode/src/interpreter/runtime.ts index 65e95ddf24..a484c50659 100644 --- a/packages/codemode/src/interpreter/runtime.ts +++ b/packages/codemode/src/interpreter/runtime.ts @@ -183,9 +183,16 @@ const assertSafeBigIntOperation = (operator: string, lhs: unknown, rhs: unknown, const leftBits = bigintBitLength(lhs) const rightBits = bigintBitLength(rhs) const exceedsLimit = (() => { - if (operator === "*") return lhs !== 0n && rhs !== 0n && leftBits + rightBits > MAX_BIGINT_BITS + if (operator === "*") { + if (lhs === 0n || lhs === 1n || lhs === -1n || rhs === 0n || rhs === 1n || rhs === -1n) return false + return leftBits + rightBits > MAX_BIGINT_BITS + } if (operator === "**") { if (rhs <= 0n || lhs === 0n || lhs === 1n || lhs === -1n) return false + const magnitude = lhs < 0n ? -lhs : lhs + if ((magnitude & (magnitude - 1n)) === 0n) { + return BigInt(leftBits - 1) * rhs + 1n > BigInt(MAX_BIGINT_BITS) + } return rhs > BigInt(Math.floor(MAX_BIGINT_BITS / leftBits)) } if (operator !== "<<" && operator !== ">>") return false diff --git a/packages/codemode/test/bigint-test262.test.ts b/packages/codemode/test/bigint-test262.test.ts index 5f42bbfac3..59a5028930 100644 --- a/packages/codemode/test/bigint-test262.test.ts +++ b/packages/codemode/test/bigint-test262.test.ts @@ -208,23 +208,92 @@ describe("BigInt JSON-like boundaries", () => { }) describe("BigInt resource bound", () => { - test("limits in-interpreter BigInts to 4,096 bits", async () => { - const maximum = await execute(`return ${`0b1${"0".repeat(4_095)}n`} === ${`0b1${"0".repeat(4_095)}n`}`) - expect(maximum.ok).toBe(true) - if (maximum.ok) expect(maximum.value).toBe(true) + const separate = (digits: string) => digits.match(/.{1,32}/g)!.join("_") + const decimalMaximum = ((1n << 4_096n) - 1n).toString() - const oversized = await execute(`return ${`0b1${"0".repeat(4_096)}n`} === 0n`) - expect(oversized.ok).toBe(false) - if (!oversized.ok) { - expect(oversized.error.kind).toBe("InvalidDataValue") - expect(oversized.error.message).toContain("4096-bit BigInt limit") + test("accepts 4,096-bit literal boundaries for every radix and separators", async () => { + for (const literal of [ + `${separate(decimalMaximum)}n`, + `0x${separate(`8${"0".repeat(1_023)}`)}n`, + `0o${separate(`1${"0".repeat(1_365)}`)}n`, + `0b${separate(`1${"0".repeat(4_095)}`)}n`, + ]) { + const result = await Effect.runPromise( + CodeMode.execute({ code: `return typeof ${literal} === "bigint"`, tools: {}, limits: { timeoutMs: 1 } }), + ) + expect(result.ok).toBe(true) + if (result.ok) expect(result.value).toBe(true) } }) + test("rejects necessarily oversized literal boundaries before parsing", async () => { + for (const literal of [ + `${1n << 4_096n}n`, + `0x1${"0".repeat(1_024)}n`, + `0o2${"0".repeat(1_365)}n`, + `0b${separate(`1${"0".repeat(4_096)}`)}n`, + ]) { + const result = await Effect.runPromise( + CodeMode.execute({ code: `return ${literal}`, tools: {}, limits: { timeoutMs: 1 } }), + ) + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.error.kind).toBe("InvalidDataValue") + expect(result.error.message).toContain("literal source") + expect(result.error.message).toContain("before parsing") + } + } + }) + + test("ignores BigInt-shaped text outside numeric literals", async () => { + const oversized = `0b1${"0".repeat(4_096)}n` + expect(await value(`const text = "${oversized}"; return text.length`)).toBe(4_100) + expect(await value(`/* ${oversized} */ return true`)).toBe(true) + expect(await value(`return \`${oversized}\`.length`)).toBe(4_100) + expect(await value(`return /${oversized}/.test("no")`)).toBe(false) + expect(await value(`if (true) /${oversized}/.test("no"); return true`)).toBe(true) + }) + + test("rejects very large literal source promptly despite a one-millisecond timeout", async () => { + const started = performance.now() + for (const digits of [500_000, 2_000_000]) { + const result = await Effect.runPromise( + CodeMode.execute({ + code: `return 0b1${"0".repeat(digits)}n`, + tools: {}, + limits: { timeoutMs: 1 }, + }), + ) + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.error.kind).toBe("InvalidDataValue") + expect(result.error.message).toContain("before parsing") + } + } + expect(performance.now() - started).toBeLessThan(2_000) + }) + + test("accepts precise safe multiplication and power-of-two exponentiation boundaries", async () => { + const maximumBit = `0b1${"0".repeat(4_095)}n` + expect( + await value(` + const value = ${maximumBit} + return [ + value * 1n === value, + value * -1n === -value, + 1n * value === value, + -1n * value === -value, + 2n ** 4095n === value, + ] + `), + ).toEqual(new Array(5).fill(true)) + }) + test("rejects expensive operations before native evaluation", async () => { for (const code of [ `const value = 1n << 2_048n; return value * value`, `return 16n ** 1_024n`, + `return 2n ** 4_096n`, `return 1n << 4_096n`, `return 1n >> -4_096n`, ]) {