diff --git a/src/cli/binaryDiagnosticsCommands.ts b/src/cli/binaryDiagnosticsCommands.ts index fe945485f8..755144cf3c 100644 --- a/src/cli/binaryDiagnosticsCommands.ts +++ b/src/cli/binaryDiagnosticsCommands.ts @@ -1,7 +1,3 @@ -import { z } from "incur"; -import { cliDecimalField } from "./options.js"; -import { producerLimitsOption } from "./producerLimitsOption.js"; -import { pwntoolsProducerLimitsSchema } from "../domain/native/pwntoolsResourceLimits.js"; import { createByteEntropyService, createRecordedCrashService, @@ -22,17 +18,11 @@ import { createNativeExportsService, } from "../composition/binaryDiagnostics.js"; import { CLI_COMMANDS } from "../cliCommandNames.js"; -import { logCliCommand } from "../cliLogging.js"; import type { Logger } from "pino"; import type { CliInstance } from "./types.js"; -import { withCommandCancellation } from "./commandCancellation.js"; -import { inspectBinaryLayoutInputSchema } from "../domain/native/binaryLayout.js"; -import { inspectRecordedCrashInputSchema } from "../domain/native/recordedCrash.js"; import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; import { withEvidenceCallerInputPath } from "../domain/evidence.js"; import { BYTE_ENTROPY_PROVIDER } from "../domain/investigationProviders.js"; -import { localPathStringSchema } from "../domain/localPath.js"; -import { absoluteCallerPath } from "../filesystem/callerPath.js"; import { BINARY_DIAGNOSTICS_TOOL_CONTRACTS } from "../contracts/native/binaryDiagnosticsToolContracts.js"; import { contractNamed } from "./contractCliDescription.js"; import { @@ -319,81 +309,64 @@ export const registerBinaryDiagnosticsCommands = ( }, deliverWithService(createNativeExportsService, "rea-native-exports"), ); - const service = createBinaryLayoutService(environment); - cli.command(CLI_COMMANDS.inspectBinaryLayout, { - description: - "Inspect binary file/linked layout without executing the selected object", - args: z.object({ - path: localPathStringSchema.describe( - "Local ELF binary or relocatable object", - ), - }), - options: z.object({ - producerLimits: producerLimitsOption(pwntoolsProducerLimitsSchema), - pwntoolsTimeoutMs: cliDecimalField( - inspectBinaryLayoutInputSchema.shape.pwntools_timeout_ms, - ), - }), - run: ({ args, options }) => - withCommandCancellation((signal) => - logCliCommand(logger, CLI_COMMANDS.inspectBinaryLayout, async () => { - const result = await service.inspectAndClose( - { - path: absoluteCallerPath(args.path), - ...(options.producerLimits === undefined - ? {} - : { producer_limits: options.producerLimits }), - ...(options.pwntoolsTimeoutMs === undefined - ? {} - : { pwntools_timeout_ms: options.pwntoolsTimeoutMs }), - }, - { signal }, - ); - return result.ok - ? withEvidenceCallerInputPath(result.value, args.path) - : projectAnalysisError(result.error); - }), - ), - }); + const producerLimitsHelp = + "JSON producer budget overrides. Keys and units follow this operation's producer_limits contract; changes apply on this call without restart and preserve tighter inherited OS limits."; + const layout = createBinaryLayoutService(environment); + registerContractCommand( + cli, + logger, + contract("inspect_binary_layout"), + { + command: CLI_COMMANDS.inspectBinaryLayout, + description: + "Inspect binary file/linked layout without executing the selected object", + positionals: ["path"], + callerPaths: ["path"], + jsonText: ["producer_limits"], + fields: { + path: { description: "Local ELF binary or relocatable object" }, + producer_limits: { description: producerLimitsHelp }, + }, + omit: { + detail: + "One-shot output is complete Evidence; this command has no summary delivery", + }, + }, + async (input, { signal, callerInputs }) => { + const result = await layout.inspectAndClose(input, { signal }); + return result.ok + ? withEvidenceCallerInputPath(result.value, callerInputs.path) + : projectAnalysisError(result.error); + }, + ); const recorded = createRecordedCrashService(environment); - cli.command(CLI_COMMANDS.inspectRecordedCrash, { - description: - "Inspect supplied core registers/signals and optional debugger mapping evidence", - args: z.object({ - path: localPathStringSchema.describe( - "Local Linux ELF64 amd64 or AArch64 little-endian core", - ), - }), - options: z.object({ - producerLimits: producerLimitsOption(pwntoolsProducerLimitsSchema), - debuggerContext: z - .boolean() - .default(false) - .describe("Add BYO core-only GDB/pwndbg mapping candidates"), - pwntoolsTimeoutMs: cliDecimalField( - inspectRecordedCrashInputSchema.shape.pwntools_timeout_ms, - ), - }), - run: ({ args, options }) => - withCommandCancellation((signal) => - logCliCommand(logger, CLI_COMMANDS.inspectRecordedCrash, async () => { - const result = await recorded.inspectAndClose( - { - path: absoluteCallerPath(args.path), - include_debugger_context: options.debuggerContext, - ...(options.producerLimits === undefined - ? {} - : { producer_limits: options.producerLimits }), - ...(options.pwntoolsTimeoutMs === undefined - ? {} - : { pwntools_timeout_ms: options.pwntoolsTimeoutMs }), - }, - { signal }, - ); - return result.ok - ? withEvidenceCallerInputPath(result.value, args.path) - : projectAnalysisError(result.error); - }), - ), - }); + registerContractCommand( + cli, + logger, + contract("inspect_recorded_crash"), + { + command: CLI_COMMANDS.inspectRecordedCrash, + description: + "Inspect supplied core registers/signals and optional debugger mapping evidence", + positionals: ["path"], + callerPaths: ["path"], + jsonText: ["producer_limits"], + fields: { + path: { + description: "Local Linux ELF64 amd64 or AArch64 little-endian core", + }, + producer_limits: { description: producerLimitsHelp }, + include_debugger_context: { + name: "debuggerContext", + description: "Add BYO core-only GDB/pwndbg mapping candidates", + }, + }, + }, + async (input, { signal, callerInputs }) => { + const result = await recorded.inspectAndClose(input, { signal }); + return result.ok + ? withEvidenceCallerInputPath(result.value, callerInputs.path) + : projectAnalysisError(result.error); + }, + ); }; diff --git a/src/cli/contractCliDescription.ts b/src/cli/contractCliDescription.ts index 853c53b7cd..48b6606e7d 100644 --- a/src/cli/contractCliDescription.ts +++ b/src/cli/contractCliDescription.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import { localPathStringSchema } from "../domain/localPath.js"; import { absoluteCallerPath } from "../filesystem/callerPath.js"; import { analysisEvidenceDeliveryOptions } from "./analysisEvidenceDelivery.js"; +import { safeParseJson } from "../domain/safeJson.js"; import { cliDecimalShape } from "./options.js"; /** @@ -50,6 +51,11 @@ export interface CliContractProjection { * resolves it with `absoluteCallerPath` before the contract sees it. */ readonly callerPaths?: readonly Field[]; + /** + * Structured fields taken as one JSON text argument. The text is decoded + * at argv parsing and then admitted by the contract field's own schema. + */ + readonly jsonText?: readonly Field[]; readonly fields?: { readonly [Key in Field]?: CliFieldProjection }; /** * Optional fields this one-shot command does not expose, each with the @@ -105,6 +111,8 @@ export interface ContractCliParameter { readonly decimal: boolean; /** Resolved with `absoluteCallerPath` before the contract sees it. */ readonly callerPath: boolean; + /** The value is JSON text decoded into the contract field. */ + readonly jsonText: boolean; /** Selects CLI Evidence delivery and is not analysis input. */ readonly delivery: boolean; } @@ -196,6 +204,39 @@ const core = (value: z.ZodType): z.ZodType => { * Accept only field forms argv can carry, so a new contract field cannot * reach the CLI with an unreviewed spelling. */ +const kebabCase = (key: string): string => + key.replaceAll(/[A-Z]/gu, (letter) => `-${letter.toLowerCase()}`); + +/** + * JSON text for a structured contract field. Argv carries text; the decoded + * value is admitted by the contract field itself, so `--schema` advertises + * the contract's structure. + */ +const jsonTextField = (declared: z.ZodType, flag: string): z.ZodType => { + const value = + declared instanceof z.ZodOptional || declared instanceof z.ZodExactOptional + ? (declared.unwrap() as z.ZodType) + : declared; + const expected = + core(value) instanceof z.ZodObject ? "a JSON object" : "valid JSON"; + const decoded = z + .string() + .transform((text, context) => { + const parsed = safeParseJson(text); + if (parsed.ok) return parsed.value; + context.addIssue({ + code: "custom", + message: `${flag} must be ${expected}`, + }); + return z.NEVER; + }) + .pipe(value); + const admitted = admitsOmission(declared) ? decoded.optional() : decoded; + return declared.description === undefined + ? admitted + : admitted.describe(declared.description); +}; + const assertArgvField = ( contract: string, field: string, @@ -312,6 +353,9 @@ export const describeContractCommand = ( const fieldProjections: Readonly< Record > = projection.fields ?? {}; + const jsonTextFields: ReadonlySet = new Set( + projection.jsonText ?? [], + ); const omitted: Readonly> = Object.fromEntries( Object.entries(projection.omit ?? {}).filter( (entry): entry is [string, string] => typeof entry[1] === "string", @@ -320,6 +364,7 @@ export const describeContractCommand = ( for (const field of [ ...positionals, ...callerPaths, + ...jsonTextFields, ...Object.keys(fieldProjections), ...Object.keys(omitted), ]) @@ -335,8 +380,11 @@ export const describeContractCommand = ( const projected = fieldProjections[field]; const callerPath = callerPaths.has(field); const delivery = evidenceDelivery && field === "detail"; + const jsonText = jsonTextFields.has(field); + const key = projected?.name ?? camelCase(field); let schema: z.ZodType; if (delivery) schema = analysisEvidenceDeliveryOptions.detail; + else if (jsonText) schema = jsonTextField(declared, kebabCase(key)); else if (callerPath) { schema = admitsOmission(declared) ? localPathStringSchema.optional() @@ -353,7 +401,7 @@ export const describeContractCommand = ( const value = core(declared); return { field, - key: projected?.name ?? camelCase(field), + key, positional: positionals.includes(field), alias: projected?.alias, description, @@ -363,6 +411,7 @@ export const describeContractCommand = ( decimal: value instanceof z.ZodNumber, callerPath, delivery, + jsonText, }; }; const parameters = [ diff --git a/tests/boundary/cli/cliContractCommandParity.test.ts b/tests/boundary/cli/cliContractCommandParity.test.ts index 4268456e5b..46be93a359 100644 --- a/tests/boundary/cli/cliContractCommandParity.test.ts +++ b/tests/boundary/cli/cliContractCommandParity.test.ts @@ -62,6 +62,13 @@ const probes = (field: z.ZodType): readonly unknown[] => { " 1", "-1", "1.5", + "{}", + "[]", + "null", + "{", + '{"input_bytes":1}', + '{"input_bytes":0}', + '{"unknown":1}', ]; for (const bound of ["minimum", "maximum", "minLength", "maxLength"]) { const value = json[bound]; @@ -92,6 +99,18 @@ const expectedAdmission = ( contractField: z.ZodType, value: unknown, ): boolean => { + if (parameter.jsonText) { + // JSON text is decoded once, then the contract field admits the value. + if (typeof value !== "string") + return admits(contractField, value) && value === undefined; + let decoded: unknown; + try { + decoded = JSON.parse(value); + } catch { + return false; + } + return admits(contractField, decoded) && decoded !== undefined; + } if (parameter.callerPath) { // A caller path is validated as typed: an empty value never becomes the // working directory. A nonempty value is resolved, then the contract rules.