refactor(cli): project JSON-text options and pwntools inspectors from contracts (#10589)

Add a jsonText declaration to the contract command description: a
structured contract field is taken as one JSON text argument, decoded at
argv parsing, and admitted by the contract field's own schema, so
--schema advertises the contract's structure. This generalizes the
per-command producerLimitsOption wiring.

Migrate inspect-binary-layout and inspect-recorded-crash (producer_limits
as JSON text; recorded-crash keeps --debugger-context for
include_debugger_context) and delete their hand-written options and input
spreads.

The parity guard probes JSON-text fields with valid, malformed and
contract-refused JSON.
This commit is contained in:
morluto authored and GitHub committed 2026-11-15 12:34:26 +08:00
1 parent 441aa170d3
commit 79cc07ecf1
3 files changed
+128 -87

No files matched your search

+59 -86
View File
@@ -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);
},
);
};
+50 -1
View File
@@ -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<Field extends string> {
* 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 = <Contract extends ProjectedContract>(
const fieldProjections: Readonly<
Record<string, CliFieldProjection | undefined>
> = projection.fields ?? {};
const jsonTextFields: ReadonlySet<string> = new Set(
projection.jsonText ?? [],
);
const omitted: Readonly<Record<string, string>> = Object.fromEntries(
Object.entries(projection.omit ?? {}).filter(
(entry): entry is [string, string] => typeof entry[1] === "string",
@@ -320,6 +364,7 @@ export const describeContractCommand = <Contract extends ProjectedContract>(
for (const field of [
...positionals,
...callerPaths,
...jsonTextFields,
...Object.keys(fieldProjections),
...Object.keys(omitted),
])
@@ -335,8 +380,11 @@ export const describeContractCommand = <Contract extends ProjectedContract>(
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 = <Contract extends ProjectedContract>(
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 = <Contract extends ProjectedContract>(
decimal: value instanceof z.ZodNumber,
callerPath,
delivery,
jsonText,
};
};
const parameters = [
@@ -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.