Files
reverse-engineering-anything/src/cli/reverseEngineeringCommands.ts
T
Kaoru0822andClaude Opus 5.5 fe677f6cf9 fix(rizin): keep debug targets off the debugger command stream (#8647)
* fix(rizin): keep debug targets off the debugger command stream

A Rizin debug target inherited Rizin's stdin, which is REA's command
stream. Once resumed, a target that reads stdin consumed REA's commands,
so close returned cleanup_incomplete and the one-shot CLI hung.

Route the target's stdin to the caller-selected TTY alongside stdout and
stderr. Before detaching, refuse a target whose stdin is still the command
stream, so a Rizin or backend that ignores the key gets a specific,
retained failure instead of a resumed target and a timeout.

Fixes #8642

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(rizin): keep the stdin test launcher's descriptors under dash

The launcher gated its target with `cat "$2" >/dev/null`. dash applies a
simple command's redirection to the shell itself until the command returns,
so on Ubuntu the waiting shell's fd 1 was /dev/null, and that shell is the
target REA reads in /proc. All 8 Linux-gated tests then failed the existing
output-descriptor check. macOS /bin/sh is bash, which redirects only in the
forked child, so this did not show locally.

Gate without any redirection. Production code is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(rizin): reject unmodeled commands and own cleanup at acquisition

Address review of the stdin regression tests.

The launcher accepted any unknown line, so the marker that followed could
complete it, and assertions only checked that commands were present. An
added `dc` or a repeated `olj` still passed. It now records an unknown line
and exits, and the tests pin the exact command sequence.

Cleanup hung off module state assigned after setup finished, so a failed
setup left its directory and terminal child behind. Each resource now
registers its own finish hook when acquired, using createTestTempDirectory.

Success checks throw the typed error instead of asserting `.ok`, and the
refusal probe no longer sits inside a loop and conditional.

Production code is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 18:38:12 +08:00

484 lines
17 KiB
TypeScript

import { z } from "incur";
import type { Logger } from "pino";
import { OBJDUMP_PROVIDER_IDENTITY } from "../objdump/ObjdumpProviderIdentity.js";
import { createReverseEngineeringService } from "../composition/reverseEngineering.js";
import { AnalysisError } from "../domain/analysisErrorBase.js";
import {
AnalysisCancelledError,
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,
} from "../domain/reverseEngineering.js";
import { logCliCommand } from "../cliLogging.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>>,
): Promise<Result<Evidence, AnalysisError>> => {
let result: Result<Evidence, AnalysisError>;
try {
result = await execute();
} catch (cause) {
await service.close().catch(() => undefined);
throw cause;
}
const hadIncompleteCleanup = !result.ok && result.error.cleanupIncomplete;
try {
await service.close();
} catch (cause) {
if (result.ok) return err(asCleanupError(cause));
const cleanup = cause instanceof AnalysisError ? cause.cleanup : undefined;
const error = new AnalysisOutputError(
"reverse_engineering_cleanup",
"Provider cleanup remains incomplete after the command failed.",
{
cause: result.error,
...(cleanup === undefined ? {} : { cleanup }),
...(result.error.capturedOutput === undefined
? {}
: { capturedOutput: result.error.capturedOutput }),
...(result.error.partialObservation === undefined
? {}
: { partialObservation: result.error.partialObservation }),
},
);
return err(error);
}
if (!result.ok && hadIncompleteCleanup && !result.error.cleanupIncomplete) {
const evidence = cleanupOnlyCompleteEvidence(result.error);
if (evidence !== undefined)
return cancelledAfterCleanup({ ok: true, value: evidence }, signal);
}
return cancelledAfterCleanup(result, signal);
};
/** Late cancellation observed during finalization keeps the completed Evidence as partial data. */
const cancelledAfterCleanup = (
result: Result<Evidence, AnalysisError>,
signal: AbortSignal,
): Result<Evidence, AnalysisError> =>
result.ok && signal.aborted
? err(
new AnalysisCancelledError(result.value.operation, {
cause: signal.reason,
partialObservation: result.value,
}),
)
: result;
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;
};
const asCleanupError = (cause: unknown): AnalysisError =>
cause instanceof AnalysisError
? cause
: new AnalysisOutputError(
"reverse_engineering_cleanup",
"Provider process cleanup failed.",
{ cause },
);
/** 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: z
.string()
.min(1)
.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: reverseCommandTimeoutSchema,
}),
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.commandTimeoutMs === undefined
? {}
: { command_timeout_ms: options.commandTimeoutMs }),
},
{ signal },
),
);
return result.ok
? publishCliAnalysisEvidence(
{ ok: true, value: result.value },
delivery,
)
: projectAnalysisError(result.error);
}),
),
});
cli.command(CLI_COMMANDS.listObjdumpArchitectures, {
description: "Capture the selected objdump command response to -i",
args: z.object({}),
options: z.object({
...analysisEvidenceDeliveryOptions,
commandTimeoutMs: reverseCommandTimeoutSchema,
}),
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.commandTimeoutMs === undefined
? {}
: { command_timeout_ms: options.commandTimeoutMs }),
},
{ signal },
),
);
return result.ok
? publishCliAnalysisEvidence(
{ ok: true, value: result.value },
delivery,
)
: projectAnalysisError(result.error);
},
),
),
});
cli.command(CLI_COMMANDS.executeRizinCommand, {
description: "Execute one Rizin command against a local artifact",
args: z.object({
path: z
.string()
.min(1)
.describe("Path to the local artifact to analyze."),
command: z.string().min(1).describe("One caller-selected Rizin command."),
}),
options: z.object({
commandTimeoutMs: reverseCommandTimeoutSchema,
}),
run: ({ args, options }) =>
withCommandCancellation((signal) =>
logCliCommand(logger, CLI_COMMANDS.executeRizinCommand, async () => {
const result = await executeWithCleanup(service, signal, () =>
service.executeRizinCommand(
{
...args,
...(options.commandTimeoutMs === undefined
? {}
: { command_timeout_ms: options.commandTimeoutMs }),
},
{ signal },
),
);
return result.ok ? result.value : projectAnalysisError(result.error);
}),
),
});
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: z.string().min(1).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);
}),
),
});
};