diff --git a/src/commands/artifacts-command.ts b/src/commands/artifacts-command.ts index dfbd24af..d60c29cd 100644 --- a/src/commands/artifacts-command.ts +++ b/src/commands/artifacts-command.ts @@ -30,6 +30,7 @@ import { parseCommanderProgram, } from "./commander-utils.js"; import { setStderrDevLogStorageRootDirectory } from "../runtime/stderr-dev-log.js"; +import { parseNonNegativeInt } from "./option-parsers.js"; interface WriteTarget { write(chunk: string): unknown; @@ -391,14 +392,6 @@ function parseHarnessKindOption(value: string): AgentHarnessKind { ); } -function parseNonNegativeInt(value: string): number { - const parsed = Number.parseInt(value, 10); - if (!Number.isFinite(parsed) || parsed < 0) { - throw new InvalidArgumentError("value must be a non-negative integer"); - } - return parsed; -} - function writeJson(stdout: WriteTarget, value: unknown): void { stdout.write(`${JSON.stringify(value, null, 2)}\n`); } diff --git a/src/commands/option-parsers.test.ts b/src/commands/option-parsers.test.ts index fec3f910..117b5bf3 100644 --- a/src/commands/option-parsers.test.ts +++ b/src/commands/option-parsers.test.ts @@ -35,6 +35,22 @@ describe("parsePositiveInt", () => { it("throws for non-numeric strings", () => { expect(() => parsePositiveInt("abc")).toThrow("positive integer"); }); + + it("rejects partially parsed numeric prefixes instead of truncating them", () => { + for (const value of ["12px", "12abc", "3.14xyz", "1.5", "1e3", "0x10"]) { + expect(() => parsePositiveInt(value)).toThrow("positive integer"); + } + }); + + it("rejects integers beyond the safe integer range", () => { + for (const value of ["9007199254740992", "9007199254740993", "1e21"]) { + expect(() => parsePositiveInt(value)).toThrow("positive integer"); + } + }); + + it("accepts the largest safe integer", () => { + expect(parsePositiveInt("9007199254740991")).toBe(9007199254740991); + }); }); describe("parseNonNegativeInt", () => { @@ -45,6 +61,22 @@ describe("parseNonNegativeInt", () => { it("throws for negative", () => { expect(() => parseNonNegativeInt("-1")).toThrow("non-negative"); }); + + it("rejects partially parsed values", () => { + for (const value of ["0items", "3.14xyz", "1e3"]) { + expect(() => parseNonNegativeInt(value)).toThrow("non-negative"); + } + }); + + it("rejects values beyond the safe integer range", () => { + expect(() => parseNonNegativeInt("9007199254740992")).toThrow( + "non-negative", + ); + }); + + it("accepts the largest safe integer", () => { + expect(parseNonNegativeInt("9007199254740991")).toBe(9007199254740991); + }); }); describe("parseNumber", () => { @@ -55,6 +87,25 @@ describe("parseNumber", () => { it("throws for non-numeric", () => { expect(() => parseNumber("xyz")).toThrow("Expected number"); }); + + it("rejects malformed numeric values", () => { + for (const value of [ + "3.14ms", + "3.14xyz", + "1.2.3", + "Infinity", + "-Infinity", + "NaN", + ]) { + expect(() => parseNumber(value)).toThrow("Expected number"); + } + }); + + it("still accepts scientific notation and sign prefixes", () => { + expect(parseNumber("1e3")).toBeCloseTo(1000); + expect(parseNumber("-2.5e-3")).toBeCloseTo(-0.0025); + expect(parseNumber("+7")).toBe(7); + }); }); describe("parseOperatingMode", () => { diff --git a/src/commands/option-parsers.ts b/src/commands/option-parsers.ts index 120f78eb..041a97be 100644 --- a/src/commands/option-parsers.ts +++ b/src/commands/option-parsers.ts @@ -14,9 +14,28 @@ import { MIN_ANTHROPIC_THINKING_BUDGET_TOKENS } from "../bootstrap/config/defaul export class InvalidArgumentError extends Error {} +const DECIMAL_INTEGER_PATTERN = /^[+-]?\d+$/u; +const DECIMAL_NUMBER_PATTERN = /^[+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?$/iu; + +function parseDecimalInteger(value: string): number { + const normalized = value.trim(); + if (!DECIMAL_INTEGER_PATTERN.test(normalized)) { + return Number.NaN; + } + return Number(normalized); +} + +function parseFiniteNumber(value: string): number { + const normalized = value.trim(); + if (!DECIMAL_NUMBER_PATTERN.test(normalized)) { + return Number.NaN; + } + return Number(normalized); +} + export function parsePositiveInt(value: string): number { - const parsed = Number.parseInt(value, 10); - if (!Number.isInteger(parsed) || parsed <= 0) { + const parsed = parseDecimalInteger(value); + if (!Number.isSafeInteger(parsed) || parsed <= 0) { throw new Error(`Expected positive integer, got: ${value}`); } return parsed; @@ -32,8 +51,8 @@ export function parseOperatingMode(value: string): AgentOperatingMode { } export function parseNonNegativeInt(value: string): number { - const parsed = Number.parseInt(value, 10); - if (!Number.isInteger(parsed) || parsed < 0) { + const parsed = parseDecimalInteger(value); + if (!Number.isSafeInteger(parsed) || parsed < 0) { throw new Error(`Expected non-negative integer, got: ${value}`); } return parsed; @@ -107,7 +126,7 @@ export function parseToolSearchIndexProfile( } export function parseNumber(value: string): number { - const parsed = Number.parseFloat(value); + const parsed = parseFiniteNumber(value); if (!Number.isFinite(parsed)) { throw new Error(`Expected number, got: ${value}`); }