From 6430f395a8c4d3784bc03d9cf050a70e9e9e318a Mon Sep 17 00:00:00 2001 From: RRFZ <247457381+Plastic-time@users.noreply.github.com> Date: Fri, 6 Nov 2026 01:52:53 -0500 Subject: [PATCH] fix(cli): reject non-decimal pyc and BEAM limit options (#9060) inspect-python-pyc and inspect-erlang-beam took their max-* limits through Number(), so 0x10 meant 16 and 1e2 meant 100. Admit decimal text only through cliDecimalField, which now keeps the shared field's description for help. --- src/cli/erlangCommands.ts | 10 ++-- src/cli/options.ts | 8 +++- src/cli/pythonPycCommands.ts | 17 +++++-- .../boundary/cli/cliIntegerArguments.test.ts | 46 +++++++++++++++++++ 4 files changed, 72 insertions(+), 9 deletions(-) diff --git a/src/cli/erlangCommands.ts b/src/cli/erlangCommands.ts index 9ec3113c8b..096dc9eac4 100644 --- a/src/cli/erlangCommands.ts +++ b/src/cli/erlangCommands.ts @@ -15,6 +15,7 @@ import { } from "./analysisEvidenceDelivery.js"; import { erlangBeamDeliverySchema } from "../domain/analysisView/erlangBeamView.js"; import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; +import { cliDecimalField } from "./options.js"; /** Inspect operator-selected BEAM files through the same shared workflow as MCP. */ export const registerErlangCommands = ( @@ -28,9 +29,12 @@ export const registerErlangCommands = ( path: localPathStringSchema.describe("Local BEAM file"), }), options: z.object({ - maxFileBytes: inspectErlangBeamInputSchema.shape.max_file_bytes, - maxChunks: inspectErlangBeamInputSchema.shape.max_chunks, - maxAtoms: inspectErlangBeamInputSchema.shape.max_atoms, + // Decimal tokens only, as for inspect-python-pyc. + maxFileBytes: cliDecimalField( + inspectErlangBeamInputSchema.shape.max_file_bytes, + ), + maxChunks: cliDecimalField(inspectErlangBeamInputSchema.shape.max_chunks), + maxAtoms: cliDecimalField(inspectErlangBeamInputSchema.shape.max_atoms), ...analysisEvidenceDeliveryOptions, view: z .string() diff --git a/src/cli/options.ts b/src/cli/options.ts index 4431b8cb5f..071bdc23c3 100644 --- a/src/cli/options.ts +++ b/src/cli/options.ts @@ -20,7 +20,7 @@ export const cliIntegerArgument = (min: number) => .regex(/^[0-9]+$/u, "Expected a decimal integer") .pipe(z.coerce.number().safe().min(min)); -/** Parse a decimal token, then let the canonical numeric field own bounds and defaults. */ +/** Parse a decimal token, then let the canonical numeric field own bounds, defaults and help. */ export function cliDecimalField( field: z.ZodDefault, ): z.ZodType; @@ -36,7 +36,11 @@ export function cliDecimalField( .pipe(z.coerce.number()) .pipe(field.unwrap()); const omitted = field.parse(undefined); - return omitted === undefined ? parsed.optional() : parsed.default(omitted); + const admitted = + omitted === undefined ? parsed.optional() : parsed.default(omitted); + return field.description === undefined + ? admitted + : admitted.describe(field.description); } export const providerSelectionOption = analysisProviderSelectorSchema diff --git a/src/cli/pythonPycCommands.ts b/src/cli/pythonPycCommands.ts index 4caaabd6b8..d9c4a7659a 100644 --- a/src/cli/pythonPycCommands.ts +++ b/src/cli/pythonPycCommands.ts @@ -15,6 +15,7 @@ import { } from "./analysisEvidenceDelivery.js"; import { pythonPycDeliverySchema } from "../domain/analysisView/pythonPycView.js"; import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; +import { cliDecimalField } from "./options.js"; /** Inspect operator-selected pyc files through the same shared workflow as MCP. */ export const registerPythonPycCommands = ( @@ -28,10 +29,18 @@ export const registerPythonPycCommands = ( path: localPathStringSchema.describe("Local CPython .pyc file"), }), options: z.object({ - maxFileBytes: inspectPythonPycInputSchema.shape.max_file_bytes, - maxObjects: inspectPythonPycInputSchema.shape.max_objects, - maxDepth: inspectPythonPycInputSchema.shape.max_depth, - maxStringBytes: inspectPythonPycInputSchema.shape.max_string_bytes, + // Decimal tokens only: numeric coercion would read "0x10" as 16 and + // "1e2" as 100. The shared fields keep their bounds and defaults. + maxFileBytes: cliDecimalField( + inspectPythonPycInputSchema.shape.max_file_bytes, + ), + maxObjects: cliDecimalField( + inspectPythonPycInputSchema.shape.max_objects, + ), + maxDepth: cliDecimalField(inspectPythonPycInputSchema.shape.max_depth), + maxStringBytes: cliDecimalField( + inspectPythonPycInputSchema.shape.max_string_bytes, + ), ...analysisEvidenceDeliveryOptions, view: z .string() diff --git a/tests/boundary/cli/cliIntegerArguments.test.ts b/tests/boundary/cli/cliIntegerArguments.test.ts index 9d15641520..d489ae71d1 100644 --- a/tests/boundary/cli/cliIntegerArguments.test.ts +++ b/tests/boundary/cli/cliIntegerArguments.test.ts @@ -250,6 +250,52 @@ describe("byte-entropy window options", () => { ); }); +describe("pyc and BEAM limit options", () => { + // One option takes every malformed spelling. Each sibling takes one + // spelling that numeric coercion admits within the field's bounds. + for (const [command, flag, field, values] of [ + ["inspect-python-pyc", "--max-depth", "maxDepth", NON_INTEGER_OPTION_TEXT], + ["inspect-python-pyc", "--max-file-bytes", "maxFileBytes", ["0x10"]], + ["inspect-python-pyc", "--max-objects", "maxObjects", ["1e2"]], + ["inspect-python-pyc", "--max-string-bytes", "maxStringBytes", ["0x1"]], + ["inspect-erlang-beam", "--max-file-bytes", "maxFileBytes", ["0x10"]], + ["inspect-erlang-beam", "--max-chunks", "maxChunks", ["1e1"]], + ["inspect-erlang-beam", "--max-atoms", "maxAtoms", ["0b1"]], + ] as const) { + cliTest.for(values)( + `rejects %j as ${command} ${flag}`, + async (value, { cli }) => { + const result = await runFileCli(cli, { + arguments: [ + command, + "/nonexistent/missing-artifact", + `${flag}=${value}`, + "--json", + ], + }); + + expectCliValidationError(result, field); + }, + ); + } + + cliTest.for([ + ["inspect-python-pyc", "--max-depth=016"], + ["inspect-erlang-beam", "--max-chunks=016"], + ] as const)("keeps a decimal %s limit", async ([command, flag], { cli }) => { + const result = await runFileCli(cli, { + arguments: [command, "/nonexistent/missing-artifact", flag, "--json"], + }); + + // The limit is admitted; the missing file is refused afterwards. + expect(result.exitCode).toBe(1); + expect(JSON.parse(result.stdout)).toMatchObject({ + code: "invalid_request", + details: { issues: [expect.objectContaining({ path: ["path"] })] }, + }); + }); +}); + describe("artifact decimal options", () => { // Same admission as byte-entropy windows. One option takes every malformed // spelling; siblings in artifactCommands.ts take one coercion each.