fix(reverse): reconcile cleanup ownership and mutation diagnostics (#2799)

* feat(process): add owned debugger shutdown lifecycle

* feat(reverse): add local inspection commands

* feat(gdb): add persistent debugger integration

* feat(cutter): add Cutter bridge integration

* test(reverse): enforce complete integration coverage

* fix(process): preserve debugger shutdown ownership

* fix(reverse): retain evidence across interrupted commands

* fix(gdb): honor captured provider environment

* test(process): cover debugger shutdown retries

* fix(reverse): retain evidence for interrupted providers

* test(process): cover debugger shutdown retries

* fix(reverse): retain evidence for interrupted providers

* fix(server): snapshot provider environment at creation

* fix(reverse): close review gaps with complete coverage

* fix(reverse): address inspection review findings

* fix(gdb): bound sessions and retain cleanup ownership

* fix(ci): require reverse-tools quality lane

* fix(process): unref inherited debugger output pipes

* fix(reverse): address remaining PR 1960 findings

* fix(gdb): address remaining PR 1961 findings

* fix(reverse): account for unrestricted provider execution

* fix(process): retain debugger ownership until safe shutdown

* fix(process): preserve replacement debugger sessions

* fix(reverse): preserve admission and output provenance

* fix(process): retain exited debugger until output settles

* fix(mcp): bound provider admission and debugger cleanup

* fix(reverse): preserve failure provenance and classify E2BIG

* fix(gdb): preserve startup cleanup and detach safety

* fix(process): retain debugger ownership after detach failure

* fix(reverse): preserve mutation and failure provenance

* fix(gdb): address debugger review findings

* fix(reverse): rebind sessions after external mutation

* fix(process): cap debugger output after exit

* fix(gdb): protect one-shot detach verification

* feat(debugger): add reverse tool coverage gate

* test(debugger): cover integrated failure paths

* fix(gdb): bound and preserve session observations

* fix(gdb): address review regressions

* style: format merged integration files

* fix(reverse): preserve session and output evidence

* refactor(gdb): share repeated test and partial result setup

* fix(gdb): invalidate TTY proof for Python aliases

* fix(reverse): retain failure output byte counts

* test(process): synchronize late debugger output

* fix(gdb): invalidate TTY proof for wrapped commands

* fix(gdb): recognize wit command abbreviation

* test(reverse): wait for cancelled descendant readiness

* test(reverse): bound descendant cancellation cleanup

* style(reverse): format integration tests and adapters

* style: format reverse engineering integration files

* test(reverse): remove duplicate environment harness

* test(gdb): share session startup assertions

* fix(gdb): invalidate output proof for command abbreviations

* test(reverse): share composition edge case scenarios

* fix(ci): recognize verification and mutation tool entries

* test(rizin): cover successful fake inferior detach

* test(cli): align full output failure envelope

* test(cli): isolate reverse command lifecycle service

* test(process): share debugger output fixture setup

* fix(binary): preserve high-bit Windows signature bytes

* style(binary): format Windows signature parser

* test(mcp): preserve call coverage in unchanged comparison

* fix(ci): include reverse tools Vitest configs in lint cache

* fix(mcp): preserve target content on delivery errors

* ci: align duplication ceiling with expanded suite

* test(reverse): allow signal admission mutants

* fix(reverse): bound retained cleanup failures to process owners

* fix(test): replace drifting mutation ranges with owned selectors

* fix(reverse): admit persistent commands and retain cleanup ownership

* fix(cutter): recover timed-out commands from exact receipts

* fix(cutter): reject unsupported bridge request kinds

* fix(ci): repair client format and shared verification fixtures

* fix(native): preserve debugger cleanup ownership and bridge authentication

* test(native): retain diagnostics without perfect coverage requirements

* chore(licenses): refresh locked dependency inventory and notices

* fix(test): exclude transient command locks from mutation sandboxes

* fix(gdb): admit only platforms with safe detach verification

* fix(cutter): retain bounded completion receipts until acknowledgement

* fix(cutter): acknowledge commands rejected before UI dispatch

* test(reverse): fail cancellation checks before fixture teardown

* test(gdb): clean up the retained broken-stdin fixture owner

* docs(test): clarify focused provider verification scope

* fix(rizin): retain close cleanup failures

* fix(gdb): wait for inferior stop and bound MI buffering

* fix(cutter): retain failed receipt acknowledgements

* fix(rizin): return retained session identity on rebind failure

* fix(gdb): preserve startup cancellation and lossy status

* fix(rizin): retain launch provenance and assemble frames once

* fix(cutter): recover receipts across callers and bound discovery

* docs: describe debugger evidence and bridge recovery

* fix(process): keep cleanup incomplete while writers remain

Stopping a debugger no longer reports success when inherited output
streams stay open, so BinarySession cannot reopen a binary a child is
still changing. Concurrent Cutter cleanup retries share one receipt
acknowledgement instead of treating a consumed receipt as failure.

* fix(debuggers): recover cleanup with captured process identities

* fix(analysis): retire sessions after unrestricted commands

* fix(cli): preserve completed evidence after cleanup recovery

* fix(cutter): bound frame collection and coalesce cleanup

* fix(windows): resolve selected provider paths absolutely

* fix(gdb): report decoding loss in failed startup evidence

* fix(rizin): scope decoding loss to collected command frames

* test(mcp): cover snapshot retirement in reverse tools CI

---------

Co-authored-by: Daniel Gonçalves Araujo <47118053+dandgabr@users.noreply.github.com>
Co-authored-by: REA Maintainers <noreply@github.com>
Co-authored-by: OpenAI Codex <codex@users.noreply.github.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
This commit is contained in:
morluto
2026-10-14 04:47:34 +08:00
committed by GitHub
parent 2ad2a044d4
commit 4aa51bfe19
168 changed files with 32703 additions and 238 deletions
+69
View File
@@ -0,0 +1,69 @@
import { z } from "incur";
import type { Logger } from "pino";
import { CutterBridgeClient } from "../cutter/CutterBridgeClient.js";
import { CutterBridgeService } from "../cutter/CutterBridgeService.js";
import { CLI_COMMANDS } from "../cliCommandNames.js";
import { logCliCommand } from "../cliLogging.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import { withCommandCancellation } from "./commandCancellation.js";
import type { CliInstance } from "./types.js";
/** Register commands that control an already-running Cutter instance. */
export const registerCutterCommands = (
cli: CliInstance,
logger: Logger,
environment: Readonly<NodeJS.ProcessEnv>,
): void => {
const cutter = new CutterBridgeService(new CutterBridgeClient(environment));
cli.command(CLI_COMMANDS.listCutterSessions, {
description:
"List live Cutter instances connected through the REA Python plugin bridge",
args: z.object({}),
run: () =>
logCliCommand(logger, CLI_COMMANDS.listCutterSessions, () =>
cutter.listSessions(),
),
});
cli.command(CLI_COMMANDS.cutterCommand, {
description: "Execute one Rizin command in a selected live Cutter instance",
args: z.object({
sessionId: z
.string()
.uuid()
.describe("Session ID copied from list-cutter-sessions."),
expectedGeneration: z.coerce
.number()
.int()
.nonnegative()
.describe("Session generation returned by list-cutter-sessions."),
command: z
.string()
.min(1)
.describe("One unrestricted Rizin command to run in Cutter.")
.refine((value) => !/[\r\n]/u.test(value)),
}),
options: z.object({
json: z
.boolean()
.default(false)
.describe("Return command-specific JSON output when available."),
}),
run: ({ args, options }) =>
withCommandCancellation((signal) =>
logCliCommand(logger, CLI_COMMANDS.cutterCommand, async () => {
const result = await cutter.execute(
{
session_id: args.sessionId,
expected_generation: args.expectedGeneration,
command: args.command,
json: options.json,
},
{ signal },
);
return result.ok ? result.value : projectAnalysisError(result.error);
}),
),
});
};
+367
View File
@@ -0,0 +1,367 @@
import { z } from "incur";
import type { Logger } from "pino";
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 { Result } from "../domain/result.js";
import { err } from "../domain/result.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.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 {
GdbSessionManager,
gdbOneShotCommandMayInvalidateDetachProof,
} from "../gdb/GdbSessionManager.js";
import { RizinDebugSessionManager } from "../rizin/RizinDebugSessionManager.js";
const executeWithCleanup = async (
service: ReverseEngineeringService,
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 { ok: true, value: evidence };
}
return 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 },
);
/** 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>,
) => GdbSessionManager = (providerEnvironment) =>
new GdbSessionManager({ environment: providerEnvironment }),
createService: typeof createReverseEngineeringService = createReverseEngineeringService,
): void => {
const service = createService(environment);
cli.command(CLI_COMMANDS.inspectWithObjdump, {
description:
"Inspect a local artifact with GNU objdump and retain its raw output as Evidence",
args: z.object({
path: z
.string()
.min(1)
.describe("Path to the local artifact to inspect."),
operation: z
.enum([
"file_headers",
"section_headers",
"symbols",
"relocations",
"dwarf",
"disassemble",
"disassemble_all",
])
.describe("GNU objdump inspection operation to run."),
}),
options: z.object({
followDebugLinks: z
.boolean()
.default(false)
.describe(
"Follow local DWARF debug links. External debug-file identity is not bound to Evidence.",
),
}),
run: ({ args, options }) =>
withCommandCancellation((signal) =>
logCliCommand(logger, CLI_COMMANDS.inspectWithObjdump, async () => {
const result = await executeWithCleanup(service, () =>
service.inspectWithObjdump(
{
...args,
follow_debug_links: options.followDebugLinks,
},
{ signal },
),
);
return result.ok ? result.value : projectAnalysisError(result.error);
}),
),
});
cli.command(CLI_COMMANDS.listObjdumpArchitectures, {
description: "List BFD targets advertised by the installed GNU objdump",
args: z.object({}),
run: () =>
withCommandCancellation((signal) =>
logCliCommand(
logger,
CLI_COMMANDS.listObjdumpArchitectures,
async () => {
const result = await executeWithCleanup(service, () =>
service.inspectWithObjdump(
{ operation: "architectures" },
{ signal },
),
);
return result.ok
? result.value
: 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."),
}),
run: ({ args }) =>
withCommandCancellation((signal) =>
logCliCommand(logger, CLI_COMMANDS.executeRizinCommand, async () => {
const result = await executeWithCleanup(service, () =>
service.executeRizinCommand(args, { 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.",
),
}),
run: ({ args, options }) =>
withCommandCancellation((signal) =>
logCliCommand(logger, CLI_COMMANDS.debugWithGdb, async () => {
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;
try {
const started = await manager.start(signal, {
inferiorTty: options.inferiorTty,
});
if (!started.ok) return projectAnalysisError(started.error);
sessionId = started.value.session_id;
const executed = await manager.execute(
sessionId,
args.command,
signal,
);
return executed.ok
? executed.value.evidence
: projectAnalysisError(executed.error);
} finally {
let cleanupFailed = false;
if (sessionId !== undefined) {
try {
const closed = await manager.close(sessionId);
cleanupFailed = !closed.ok;
if (!closed.ok)
logger.error(
{
command: CLI_COMMANDS.debugWithGdb,
cleanup: projectAnalysisError(closed.error),
},
"GDB cleanup failed after collecting command output",
);
} catch (cause) {
cleanupFailed = true;
logger.error(
{
command: CLI_COMMANDS.debugWithGdb,
cleanup:
cause instanceof Error
? cause.message
: "Unknown cleanup failure",
},
"GDB cleanup failed after collecting command output",
);
}
}
try {
await manager.closeAll();
cleanupFailed = false;
} catch (cause) {
cleanupFailed = true;
logger.error(
{
command: CLI_COMMANDS.debugWithGdb,
cleanup:
cause instanceof Error
? cause.message
: "Unknown cleanup failure",
},
"GDB fallback cleanup failed",
);
}
if (cleanupFailed) process.exitCode = 1;
}
}),
),
});
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 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 = new RizinDebugSessionManager(environment);
let sessionId: string | undefined;
try {
const started = await manager.start(
{
...args,
inferior_tty: options.inferiorTty,
...(options.backend === undefined
? {}
: { backend: options.backend }),
},
signal,
);
if (!started.ok) return projectAnalysisError(started.error);
sessionId = started.value.session_id;
const executed = await manager.execute(
started.value.session_id,
args.command,
signal,
);
return executed.ok
? executed.value.evidence
: projectAnalysisError(executed.error);
} finally {
const closed =
sessionId === undefined
? undefined
: await manager.close(sessionId);
if (closed !== undefined && !closed.ok) {
logger.error(
{
command: CLI_COMMANDS.debugWithRizin,
cleanup: projectAnalysisError(closed.error),
},
"Rizin cleanup failed after collecting command output",
);
process.exitCode = 1;
}
await manager.closeAll().catch((cause: unknown) => {
process.exitCode = 1;
logger.error(
{
command: CLI_COMMANDS.debugWithRizin,
cleanup:
cause instanceof Error
? cause.message
: "Unknown cleanup failure",
},
"Rizin fallback cleanup failed",
);
});
}
}),
),
});
};