mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-21 10:16:03 +00:00
fix(codemode): reject oversized BigInt source
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`,
|
||||
]) {
|
||||
|
||||
Reference in New Issue
Block a user