fix(codemode): reject oversized BigInt source

This commit is contained in:
Aiden Cline
2026-07-20 17:28:59 -05:00
parent 07d8315430
commit b8cb95d6cf
4 changed files with 225 additions and 13 deletions
+2 -1
View File
@@ -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.
+137 -2
View File
@@ -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 Provided extends Record<string, unknown>
}
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<number>()
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.
+8 -1
View File
@@ -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
+78 -9
View File
@@ -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`,
]) {