fix(cli): validate integer tokens before coercion

Validate decimal tokens at the CLI owner before safe-number parsing. Apply the same rule to positional indexes/generations and repeated record selectors, retaining leading-zero decimals and canonical MCP inputs.

Refs #4182

Co-authored-by: Arjun Prabhulal <arjun.prabhulal@gmail.com>
This commit is contained in:
morluto
2026-10-16 03:13:06 +08:00
parent 08a897b2cd
commit 8d8257928b
7 changed files with 123 additions and 23 deletions
+4 -5
View File
@@ -8,6 +8,7 @@ import { logCliCommand } from "../cliLogging.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import { withCommandCancellation } from "./commandCancellation.js";
import type { CliInstance } from "./types.js";
import { cliIntegerArgument } from "./options.js";
/** Register commands that control an already-running Cutter instance. */
export const registerCutterCommands = (
@@ -33,11 +34,9 @@ export const registerCutterCommands = (
.string()
.uuid()
.describe("Session ID copied from list-cutter-sessions."),
expectedGeneration: z.coerce
.number()
.int()
.nonnegative()
.describe("Session generation returned by list-cutter-sessions."),
expectedGeneration: cliIntegerArgument(0).describe(
"Session generation returned by list-cutter-sessions.",
),
command: z
.string()
.min(1)
+9
View File
@@ -1,3 +1,5 @@
import { z } from "incur";
import {
executableFormatHintSchema,
type ExecutableFormatHint,
@@ -11,6 +13,13 @@ import { analysisProviderSelectorSchema } from "../contracts/providerSelection.j
import type { AnalysisProviderSelector } from "../contracts/providerSelection.js";
import type { Logger } from "pino";
/** Parse decimal integer tokens before Number coercion can erase their syntax. */
export const cliIntegerArgument = (min: number) =>
z
.string()
.regex(/^[0-9]+$/u, "Expected a decimal integer")
.pipe(z.coerce.number<string>().safe().min(min));
export const providerSelectionOption = analysisProviderSelectorSchema
.optional()
.describe(
+4 -5
View File
@@ -6,6 +6,7 @@ import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import { webModuleTraceInputSchema } from "../domain/webModuleTrace.js";
import type { Logger } from "pino";
import type { CliInstance } from "./types.js";
import { cliIntegerArgument } from "./options.js";
import { withCommandCancellation } from "./commandCancellation.js";
const importMapInputSchema =
@@ -23,11 +24,9 @@ export const registerWebModuleCommands = (
"Trace one captured script's native imports to URLs and captured source candidates",
args: z.object({
manifestPath: webModuleTraceInputSchema.shape.manifest_path,
scriptIndex: z.coerce
.number()
.int()
.min(0)
.describe("Zero-based manifest script index"),
scriptIndex: cliIntegerArgument(0).describe(
"Zero-based manifest script index",
),
}),
options: z.object({
importerUrl: webModuleTraceInputSchema.shape.importer_url,
+2 -1
View File
@@ -5,6 +5,7 @@ import { CLI_COMMANDS } from "../cliCommandNames.js";
import { logCliCommand } from "../cliLogging.js";
import type { Logger } from "pino";
import type { CliInstance } from "./types.js";
import { cliIntegerArgument } from "./options.js";
import { withCommandCancellation } from "./commandCancellation.js";
import { finishAnalysisCommand } from "./finishAnalysisCommand.js";
@@ -26,7 +27,7 @@ export const registerWebNetworkCaptureCommands = (
}),
options: z.object({
record: z
.array(z.coerce.number().int().nonnegative())
.array(cliIntegerArgument(0))
.optional()
.describe("Zero-based producer ordinals; repeat to select several"),
sensitiveValue: z
+8 -11
View File
@@ -5,6 +5,7 @@ import { logCliCommand } from "../cliLogging.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import type { Logger } from "pino";
import type { CliInstance } from "./types.js";
import { cliIntegerArgument } from "./options.js";
import { withCommandCancellation } from "./commandCancellation.js";
/** Expose the same explicit selected map/point contract through the CLI. */
@@ -21,21 +22,17 @@ export const registerWebSourceLocationCommands = (
manifestPath: z
.string()
.describe("Absolute export_web_scripts manifest path"),
scriptIndex: z.coerce
.number()
.int()
.min(0)
.describe("Zero-based manifest script index"),
scriptIndex: cliIntegerArgument(0).describe(
"Zero-based manifest script index",
),
sourceMapPath: z.string().describe("Absolute local source-map path"),
sourceMapUrl: z
.string()
.describe("Absolute source-map URL context; no fetch"),
line: z.coerce.number().int().min(1).describe("One-based generated line"),
column: z.coerce
.number()
.int()
.min(0)
.describe("Zero-based UTF-16 generated column"),
line: cliIntegerArgument(1).describe("One-based generated line"),
column: cliIntegerArgument(0).describe(
"Zero-based UTF-16 generated column",
),
}),
run: ({ args }) =>
withCommandCancellation(async (signal) => {
@@ -0,0 +1,95 @@
import { describe, expect, it } from "vitest";
import { serveCli } from "./cliServeHarness.js";
const SESSION_ID = "27d3e3f1-f1e5-49ae-91ec-95af1f343a5a";
const NON_INTEGER_TEXT = [
"",
" ",
"\t",
"1\n",
"0x1",
"0b1",
"1e0",
"+1",
"1.0",
"9007199254740992",
];
const NON_INTEGER_OPTION_TEXT = [...NON_INTEGER_TEXT, "-0"];
const expectCliValidationError = (
result: Awaited<ReturnType<typeof serveCli>>,
field: string,
): void => {
expect(result.exitCode).toBe(1);
expect(JSON.parse(result.stdout)).toMatchObject({
code: "VALIDATION_ERROR",
fieldErrors: expect.arrayContaining([
expect.objectContaining({ path: expect.stringContaining(field) }),
]),
});
};
describe("CLI integer arguments", () => {
it.each(NON_INTEGER_TEXT)(
"rejects %j as a Cutter expected generation",
async (value) => {
const result = await serveCli([
"cutter-command",
SESSION_ID,
value,
"pdi",
"--json",
]);
expectCliValidationError(result, "expectedGeneration");
},
);
it.each(NON_INTEGER_TEXT)(
"rejects %j as a web module script index",
async (value) => {
const result = await serveCli([
"trace-web-module-imports",
"/nonexistent/manifest.json",
value,
"--json",
]);
expectCliValidationError(result, "scriptIndex");
},
);
it.each(NON_INTEGER_OPTION_TEXT)(
"rejects %j in a repeated web capture record selector",
async (value) => {
const result = await serveCli([
"inspect-web-network-capture",
"/nonexistent/capture.qlog",
"qlog",
"--record",
"0",
"--record",
value,
"--json",
]);
expectCliValidationError(result, "record");
},
);
it("keeps the one-based source line lower bound at the CLI boundary", async () => {
const result = await serveCli([
"trace-web-source-location",
"/nonexistent/manifest.json",
"0",
"/nonexistent/app.js.map",
"https://example.test/app.js.map",
"0",
"0",
"--json",
]);
expectCliValidationError(result, "line");
});
});
+1 -1
View File
@@ -169,7 +169,7 @@ describe("Cutter CLI command output flags", () => {
);
const result = await serveCli(
["cutter-command", SESSION_ID, "0", "pdi", "--json-output"],
["cutter-command", SESSION_ID, "000", "pdi", "--json-output"],
{ REA_CUTTER_BRIDGE_DIR: directory },
);