Files
reverse-engineering-anything/src/cli/reverseEngineeringCommands.ts
T
morlutoandCursor Agent 5c2917266d fix(inputs): validate command admission and preserve valid MCP requests (#9901)
* fix(cli): validate named inputs before target admission

* fix(mcp): preserve valid inputs when advertised schema references widen

* test(inputs): reach session behavior with admitted inputs

One-shot admission now rejects incomplete requests before a session exists. Lifecycle tests supply the required fields, and unknown arguments are rejected before provider issues are retained.

* fix(cli): preserve Electron fuse path spelling

Anchor relative fuse paths without stripping dot segments, and let an empty path reach the operation so it still reports invalid_request.

* fix(cli): reject outline-depth unless detail is summary

The advertised schema already required detail summary whenever outline-depth
was present. The parser still applied the full default and accepted that
combination, so its output failed the same schema. Parsing now rejects it
before the command runs.

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-11-11 00:43:30 -05:00

481 lines
17 KiB
TypeScript

import { z } from "incur";
import { localPathStringSchema } from "../domain/localPath.js";
import type { Logger } from "pino";
import { OBJDUMP_PROVIDER_IDENTITY } from "../objdump/ObjdumpProviderIdentity.js";
import { RIZIN_PROVIDER_IDENTITY } from "../rizin/RizinProviderIdentity.js";
import { createReverseEngineeringService } from "../composition/reverseEngineering.js";
import { AnalysisError } from "../domain/analysisErrorBase.js";
import {
AnalysisInputError,
AnalysisOutputError,
} from "../domain/analysisErrorCore.js";
import { evidenceSchema, type Evidence } from "../domain/evidence.js";
import type { JsonValue } from "../domain/jsonValue.js";
import type { Result } from "../domain/result.js";
import { err, ok } from "../domain/result.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import {
OBJDUMP_ARTIFACT_OPERATIONS,
reverseCommandTimeoutSchema,
reverseCommandOutputBytesSchema,
rizinInputSchema,
} from "../domain/reverseEngineering.js";
import { logCliCommand } from "../cliLogging.js";
import { cliDecimalField } from "./options.js";
import type { CliInstance } from "./types.js";
import { withCommandCancellation } from "./commandCancellation.js";
import { CLI_COMMANDS } from "../cliCommandNames.js";
import type { ReverseEngineeringService } from "../application/reverse/ReverseEngineeringService.js";
import { gdbOneShotCommandMayInvalidateDetachProof } from "../gdb/GdbSessionManager.js";
import {
createGdbSessionManager,
createRizinDebugSessionManager,
} from "../composition/debuggerSessions.js";
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
import {
retainedDebuggerDiagnostics,
retainedDebuggerProcessIds,
retainedDebuggerRemediation,
} from "../process/ownedDebuggerCleanupError.js";
import {
analysisEvidenceDeliveryOptions,
cliEvidenceDeliveryInputError,
deliverCliAnalysisEvidence,
publishCliAnalysisEvidence,
} from "./analysisEvidenceDelivery.js";
import { finishAnalysisCommand } from "./finishAnalysisCommand.js";
import { projectCliAnalysisResult } from "./projectCliAnalysisResult.js";
const executeWithCleanup = async (
service: ReverseEngineeringService,
signal: AbortSignal,
execute: () => Promise<Result<Evidence, AnalysisError>>,
operation: "inspect_with_objdump" | "execute_rizin_command",
) =>
finishAnalysisCommand(
execute,
service,
operation === "inspect_with_objdump"
? OBJDUMP_PROVIDER_IDENTITY.id
: RIZIN_PROVIDER_IDENTITY.id,
operation,
signal,
undefined,
{ recoverResolvedCleanup: cleanupOnlyCompleteEvidence },
);
const cleanupOnlyCompleteEvidence = (
error: AnalysisError,
): Evidence | undefined => {
if (
!(error instanceof AnalysisOutputError) ||
!error.cleanupOnly ||
error.partialObservation === undefined
)
return undefined;
const parsed = evidenceSchema.safeParse(error.partialObservation);
if (!parsed.success) return undefined;
const normalized = parsed.data.normalized_result;
return typeof normalized === "object" &&
normalized !== null &&
!Array.isArray(normalized) &&
normalized.completion_status === "complete"
? parsed.data
: undefined;
};
/** One-shot debugger cleanup: the retained owner's closeAll decides whether cleanup remains incomplete. */
const closeDebuggerCommandSession = async (
manager: {
close(sessionId: string): Promise<Result<unknown, AnalysisError>>;
closeAll(): Promise<void>;
},
sessionId: string | undefined,
providerId: string,
operation: string,
log: (cleanup: JsonValue | string) => void,
): Promise<void> => {
if (sessionId !== undefined) {
try {
const closed = await manager.close(sessionId);
if (!closed.ok) log(projectAnalysisError(closed.error));
} catch (cause) {
log(cause instanceof Error ? cause.message : "Unknown cleanup failure");
}
}
try {
await manager.closeAll();
} catch (cause) {
// This process is the only owner, so a caller gets the debugger to check
// rather than a retry that no later command could perform.
const retainedPids = retainedDebuggerProcessIds(cause);
throw new ProviderCleanupError(
providerId,
sessionId === undefined ? [] : [sessionId],
{
reason:
cause instanceof AggregateError
? cause.errors
.map((error: unknown) =>
error instanceof Error ? error.message : String(error),
)
.join("; ")
: cause instanceof Error
? cause.message
: String(cause),
...retainedDebuggerDiagnostics(cause),
},
{
cause,
operation,
...(retainedPids.length === 0
? {}
: { remediationAction: retainedDebuggerRemediation(retainedPids) }),
},
);
}
};
/** Add one-shot reverse-engineering commands using the shared service layer. */
export const registerReverseEngineeringCommands = (
cli: CliInstance,
logger: Logger,
environment: Readonly<NodeJS.ProcessEnv>,
createGdbManager: (
environment: Readonly<NodeJS.ProcessEnv>,
) => ReturnType<typeof createGdbSessionManager> = createGdbSessionManager,
createService: typeof createReverseEngineeringService = createReverseEngineeringService,
): void => {
const service = createService(environment);
cli.command(CLI_COMMANDS.inspectWithObjdump, {
description:
"Inspect a local artifact using GNU-style objdump arguments and retain the selected command output as Evidence",
args: z.object({
path: localPathStringSchema.describe(
"Path to the local artifact to inspect.",
),
operation: z
.enum(OBJDUMP_ARTIFACT_OPERATIONS)
.describe("GNU-style objdump operation to run."),
}),
options: z.object({
...analysisEvidenceDeliveryOptions,
symbol: z
.string()
.optional()
.describe(
"Native symbol to select with the disassemble operation; the selected executable controls matching and boundaries.",
),
followDebugLinks: z
.boolean()
.default(false)
.describe(
"Follow local DWARF debug links. External debug-file identity is not bound to Evidence.",
),
commandTimeoutMs: cliDecimalField(reverseCommandTimeoutSchema),
maxOutputBytes: cliDecimalField(reverseCommandOutputBytesSchema),
}),
run: ({ args, options }) =>
withCommandCancellation((signal) =>
logCliCommand(logger, CLI_COMMANDS.inspectWithObjdump, async () => {
const delivery = {
providerId: OBJDUMP_PROVIDER_IDENTITY.id,
operation: "inspect_with_objdump",
signal,
detail: options.detail,
evidenceOutput: options.evidence_output,
};
const inputError = cliEvidenceDeliveryInputError(delivery);
if (inputError !== undefined) return projectAnalysisError(inputError);
const result = await executeWithCleanup(
service,
signal,
() =>
service.inspectWithObjdump(
{
...args,
follow_debug_links: options.followDebugLinks,
...(options.symbol === undefined
? {}
: { symbol: options.symbol }),
...(options.maxOutputBytes === undefined
? {}
: { max_output_bytes: options.maxOutputBytes }),
...(options.commandTimeoutMs === undefined
? {}
: { command_timeout_ms: options.commandTimeoutMs }),
},
{ signal },
),
"inspect_with_objdump",
);
return result.ok
? publishCliAnalysisEvidence(
{ ok: true, value: result.value },
delivery,
)
: result.error;
}),
),
});
cli.command(CLI_COMMANDS.listObjdumpArchitectures, {
description: "Capture the selected objdump command response to -i",
args: z.object({}),
options: z.object({
...analysisEvidenceDeliveryOptions,
commandTimeoutMs: cliDecimalField(reverseCommandTimeoutSchema),
maxOutputBytes: cliDecimalField(reverseCommandOutputBytesSchema),
}),
run: ({ options }) =>
withCommandCancellation((signal) =>
logCliCommand(
logger,
CLI_COMMANDS.listObjdumpArchitectures,
async () => {
const delivery = {
providerId: OBJDUMP_PROVIDER_IDENTITY.id,
operation: "inspect_with_objdump",
signal,
detail: options.detail,
evidenceOutput: options.evidence_output,
};
const inputError = cliEvidenceDeliveryInputError(delivery);
if (inputError !== undefined)
return projectAnalysisError(inputError);
const result = await executeWithCleanup(
service,
signal,
() =>
service.inspectWithObjdump(
{
operation: "architectures",
...(options.maxOutputBytes === undefined
? {}
: { max_output_bytes: options.maxOutputBytes }),
...(options.commandTimeoutMs === undefined
? {}
: { command_timeout_ms: options.commandTimeoutMs }),
},
{ signal },
),
"inspect_with_objdump",
);
return result.ok
? publishCliAnalysisEvidence(
{ ok: true, value: result.value },
delivery,
)
: result.error;
},
),
),
});
cli.command(CLI_COMMANDS.executeRizinCommand, {
description: "Execute one Rizin command against a local artifact",
args: z.object({
path: localPathStringSchema.describe(
"Path to the local artifact to analyze.",
),
command: z.string().min(1).describe("One caller-selected Rizin command."),
}),
options: z.object({
asmArch: rizinInputSchema.shape.asm_arch,
asmBits: cliDecimalField(rizinInputSchema.shape.asm_bits),
asmCpu: rizinInputSchema.shape.asm_cpu,
baseAddress: rizinInputSchema.shape.base_address,
commandTimeoutMs: cliDecimalField(reverseCommandTimeoutSchema),
maxOutputBytes: cliDecimalField(reverseCommandOutputBytesSchema),
}),
run: ({ args, options }) =>
withCommandCancellation((signal) =>
logCliCommand(logger, CLI_COMMANDS.executeRizinCommand, async () => {
const result = await executeWithCleanup(
service,
signal,
() =>
service.executeRizinCommand(
{
...args,
...(options.asmArch === undefined
? {}
: { asm_arch: options.asmArch }),
...(options.asmBits === undefined
? {}
: { asm_bits: options.asmBits }),
...(options.asmCpu === undefined
? {}
: { asm_cpu: options.asmCpu }),
...(options.baseAddress === undefined
? {}
: { base_address: options.baseAddress }),
...(options.maxOutputBytes === undefined
? {}
: { max_output_bytes: options.maxOutputBytes }),
...(options.commandTimeoutMs === undefined
? {}
: { command_timeout_ms: options.commandTimeoutMs }),
},
{ signal },
),
"execute_rizin_command",
);
return projectCliAnalysisResult(result);
}),
),
});
cli.command(CLI_COMMANDS.debugWithGdb, {
description:
"Run one unrestricted GDB console command in an ephemeral MI session; provide an inferior TTY so a live target can be safely detached",
args: z.object({
command: z
.string()
.min(1)
.describe("One unrestricted GDB console command."),
}),
options: z.object({
inferiorTty: z
.string()
.min(1)
.describe(
"Caller-selected durable TTY for output from a live GDB inferior.",
),
...analysisEvidenceDeliveryOptions,
}),
run: ({ args, options }) =>
withCommandCancellation((signal) =>
logCliCommand(logger, CLI_COMMANDS.debugWithGdb, async () => {
const delivery = {
providerId: "gdb",
operation: "gdb_console",
signal,
detail: options.detail,
evidenceOutput: options.evidence_output,
};
const inputError = cliEvidenceDeliveryInputError(delivery);
if (inputError !== undefined) return projectAnalysisError(inputError);
if (gdbOneShotCommandMayInvalidateDetachProof(args.command))
return projectAnalysisError(
new AnalysisInputError("gdb_console", undefined, [
{
path: ["command"],
reason: "invalid_value",
message:
"This command can invalidate the verified inferior output route; use the persistent GDB session workflow.",
},
]),
);
const manager = createGdbManager(environment);
let sessionId: string | undefined;
return deliverCliAnalysisEvidence({
...delivery,
run: async () => {
const started = await manager.start(signal, {
inferiorTty: options.inferiorTty,
});
if (!started.ok) return err(started.error);
sessionId = started.value.session_id;
const executed = await manager.execute(
sessionId,
args.command,
signal,
);
return executed.ok
? ok(executed.value.evidence)
: err(executed.error);
},
service: {
close: () =>
closeDebuggerCommandSession(
manager,
sessionId,
"gdb",
"gdb_console",
(cleanup) =>
logger.error(
{ command: CLI_COMMANDS.debugWithGdb, cleanup },
"GDB cleanup failed after collecting command output",
),
),
},
});
}),
),
});
cli.command(CLI_COMMANDS.debugWithRizin, {
description:
"Run one unrestricted Rizin debugger command in an ephemeral session",
args: z.object({
path: localPathStringSchema.describe(
"Path to the local artifact to debug.",
),
command: z
.string()
.min(1)
.describe("One unrestricted Rizin debugger command.")
.refine((value) => !/[\r\n]/u.test(value)),
}),
options: z.object({
inferiorTty: z
.string()
.min(1)
.describe(
"Caller-selected Linux TTY for inferior stdin, stdout and stderr.",
),
backend: z
.string()
.min(1)
.optional()
.describe("Optional Rizin IO debugger backend name."),
}),
run: ({ args, options }) =>
withCommandCancellation((signal) =>
logCliCommand(logger, CLI_COMMANDS.debugWithRizin, async () => {
const manager = createRizinDebugSessionManager(environment);
let sessionId: string | undefined;
return finishAnalysisCommand(
async () => {
const started = await manager.start(
{
...args,
inferior_tty: options.inferiorTty,
...(options.backend === undefined
? {}
: { backend: options.backend }),
},
signal,
);
if (!started.ok) return err(started.error);
sessionId = started.value.session_id;
const executed = await manager.execute(
sessionId,
args.command,
signal,
);
return executed.ok
? ok(executed.value.evidence)
: err(executed.error);
},
{
close: () =>
closeDebuggerCommandSession(
manager,
sessionId,
"rizin",
"rizin_debug_command",
(cleanup) =>
logger.error(
{ command: CLI_COMMANDS.debugWithRizin, cleanup },
"Rizin cleanup failed after collecting command output",
),
),
},
"rizin",
"rizin_debug_command",
signal,
).then(projectCliAnalysisResult);
}),
),
});
};