mirror of
https://github.com/morluto/rea.git
synced 2026-11-10 00:36:35 +00:00
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:
168 files changed
+32703
-238
No files matched your search
@@ -158,7 +158,7 @@ const expandAddress = async (
|
||||
state.residual.add(`Call relationships were unavailable for ${address}.`);
|
||||
return ok(false);
|
||||
}
|
||||
const related = parseRelatedAddresses(result.value.result, relation);
|
||||
const related = parseRelatedAddresses(result.value.result, relation, tool);
|
||||
if (!related.ok) {
|
||||
state.failures.push({
|
||||
address,
|
||||
|
||||
@@ -5,7 +5,10 @@ import {
|
||||
analysisProfilesEqual,
|
||||
type AnalysisProfileCommitment,
|
||||
} from "../../domain/analysisProfile.js";
|
||||
import { AnalysisCancelledError } from "../../domain/analysisErrorCore.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisOutputError,
|
||||
} from "../../domain/analysisErrorCore.js";
|
||||
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import { err, ok, type Result } from "../../domain/result.js";
|
||||
import type { JsonValue } from "../../domain/jsonValue.js";
|
||||
@@ -73,6 +76,7 @@ export class BinarySession
|
||||
implements BinarySessionPort
|
||||
{
|
||||
#active: SessionBinding | undefined;
|
||||
#externalMutationCleanupFailure: AnalysisError | undefined;
|
||||
#pendingCleanup:
|
||||
| {
|
||||
readonly client: AnalysisClient;
|
||||
@@ -158,6 +162,10 @@ export class BinarySession
|
||||
);
|
||||
}
|
||||
|
||||
invalidateSnapshotForExternalMutation(): void {
|
||||
this.invalidateSnapshot();
|
||||
}
|
||||
|
||||
/** Return the selected immutable profile, optionally scoped to an operation. */
|
||||
analysisProfile(
|
||||
operation?: AnalysisOperation,
|
||||
@@ -245,110 +253,180 @@ export class BinarySession
|
||||
resolve: () => Promise<Result<ResolvedSessionOpen, AnalysisError>>,
|
||||
options: Pick<BinarySessionOpenOptions, "signal" | "snapshot">,
|
||||
): Promise<Result<BinaryTarget, AnalysisError>> {
|
||||
return this.#serialize(async (admittedThrough) => {
|
||||
if (isAborted(options.signal))
|
||||
return err(new AnalysisCancelledError("open_binary"));
|
||||
const resolved = await resolve();
|
||||
if (!resolved.ok) return resolved;
|
||||
const { target, route, sameTarget } = resolved.value;
|
||||
const { profile } = route;
|
||||
if (isAborted(options.signal))
|
||||
return err(new AnalysisCancelledError("open_binary"));
|
||||
const activeProfile = this.#active?.profile;
|
||||
const sameProfile =
|
||||
activeProfile === null || activeProfile === undefined
|
||||
? profile === null
|
||||
: profile !== null && analysisProfilesEqual(activeProfile, profile);
|
||||
await this.#drainCalls(admittedThrough);
|
||||
if (isAborted(options.signal))
|
||||
return err(new AnalysisCancelledError("open_binary"));
|
||||
const pendingClosed = await this.#retryPendingCleanup();
|
||||
if (!pendingClosed.ok) return pendingClosed;
|
||||
if (isAborted(options.signal))
|
||||
return err(new AnalysisCancelledError("open_binary"));
|
||||
const retained = sameTarget && sameProfile ? this.#active : undefined;
|
||||
if (retained !== undefined) {
|
||||
const fastPath = await this.#tryRetainedFastPath(
|
||||
retained,
|
||||
target,
|
||||
options,
|
||||
);
|
||||
if (fastPath !== undefined) return fastPath;
|
||||
}
|
||||
const previous = this.#active;
|
||||
this.#active = undefined;
|
||||
if (previous !== undefined) {
|
||||
const closed = await this.#retireClient(
|
||||
previous.client,
|
||||
previous.route.identity.id,
|
||||
);
|
||||
if (!closed.ok) {
|
||||
// A failed open retires the provider cache, not the investigation.
|
||||
// Preserve Evidence and Unknown history for export and recovery.
|
||||
this.clearSnapshot();
|
||||
this.#clearRuntimeAvailability();
|
||||
return closed;
|
||||
}
|
||||
}
|
||||
if (isAborted(options.signal))
|
||||
return err(
|
||||
await this.#restoreAfterFailedOpen(
|
||||
previous,
|
||||
new AnalysisCancelledError("open_binary"),
|
||||
),
|
||||
);
|
||||
const runId = randomUUID();
|
||||
const client = route.createClient(target, { runId });
|
||||
const started = await client.execute("health", {}, options);
|
||||
if (!started.ok) {
|
||||
return err(
|
||||
await this.#failedOpen(
|
||||
client,
|
||||
route.identity.id,
|
||||
previous,
|
||||
started.error,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (isAborted(options.signal)) {
|
||||
return err(
|
||||
await this.#failedOpen(
|
||||
client,
|
||||
route.identity.id,
|
||||
previous,
|
||||
new AnalysisCancelledError("open_binary"),
|
||||
),
|
||||
);
|
||||
}
|
||||
this.#active = {
|
||||
return this.#serialize((admittedThrough) =>
|
||||
this.#openWithinTransition(admittedThrough, resolve, options),
|
||||
);
|
||||
}
|
||||
|
||||
async #openWithinTransition(
|
||||
admittedThrough: number,
|
||||
resolve: () => Promise<Result<ResolvedSessionOpen, AnalysisError>>,
|
||||
options: Pick<BinarySessionOpenOptions, "signal" | "snapshot">,
|
||||
): Promise<Result<BinaryTarget, AnalysisError>> {
|
||||
if (this.#externalMutationCleanupFailure?.cleanupIncomplete === true)
|
||||
return err(this.#externalMutationCleanupFailure);
|
||||
if (isAborted(options.signal))
|
||||
return err(new AnalysisCancelledError("open_binary"));
|
||||
const resolved = await resolve();
|
||||
if (!resolved.ok) return resolved;
|
||||
const { target, route, sameTarget } = resolved.value;
|
||||
const { profile } = route;
|
||||
if (isAborted(options.signal))
|
||||
return err(new AnalysisCancelledError("open_binary"));
|
||||
const activeProfile = this.#active?.profile;
|
||||
const sameProfile =
|
||||
activeProfile === null || activeProfile === undefined
|
||||
? profile === null
|
||||
: profile !== null && analysisProfilesEqual(activeProfile, profile);
|
||||
await this.#drainCalls(admittedThrough);
|
||||
if (isAborted(options.signal))
|
||||
return err(new AnalysisCancelledError("open_binary"));
|
||||
const pendingClosed = await this.#retryPendingCleanup();
|
||||
if (!pendingClosed.ok) return pendingClosed;
|
||||
if (isAborted(options.signal))
|
||||
return err(new AnalysisCancelledError("open_binary"));
|
||||
const retained = sameTarget && sameProfile ? this.#active : undefined;
|
||||
if (retained !== undefined) {
|
||||
const fastPath = await this.#tryRetainedFastPath(
|
||||
retained,
|
||||
target,
|
||||
client,
|
||||
profile,
|
||||
route,
|
||||
runId,
|
||||
nativeSourceAdmitted: false,
|
||||
};
|
||||
this.#clearRuntimeAvailability();
|
||||
this.resetSnapshotInvalidation();
|
||||
if (options.snapshot === undefined) {
|
||||
if (profile === null) this.clearSnapshot();
|
||||
else this.selectSnapshot(target, profile);
|
||||
options,
|
||||
);
|
||||
if (fastPath !== undefined) return fastPath;
|
||||
}
|
||||
const previous = this.#active;
|
||||
this.#active = undefined;
|
||||
if (previous !== undefined) {
|
||||
const closed = await this.#retireClient(
|
||||
previous.client,
|
||||
previous.route.identity.id,
|
||||
);
|
||||
if (!closed.ok) {
|
||||
// A failed open retires the provider cache, not the investigation.
|
||||
// Preserve Evidence and Unknown history for export and recovery.
|
||||
this.clearSnapshot();
|
||||
this.#clearRuntimeAvailability();
|
||||
return closed;
|
||||
}
|
||||
if (options.snapshot !== undefined) {
|
||||
const imported = this.importAnalysisSnapshot(options.snapshot);
|
||||
if (!imported.ok) {
|
||||
this.#active = undefined;
|
||||
return err(
|
||||
await this.#failedOpen(
|
||||
client,
|
||||
route.identity.id,
|
||||
previous,
|
||||
imported.error,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (isAborted(options.signal))
|
||||
return err(
|
||||
await this.#restoreAfterFailedOpen(
|
||||
previous,
|
||||
new AnalysisCancelledError("open_binary"),
|
||||
),
|
||||
);
|
||||
const runId = randomUUID();
|
||||
const client = route.createClient(target, { runId });
|
||||
const started = await client.execute("health", {}, options);
|
||||
if (!started.ok) {
|
||||
return err(
|
||||
await this.#failedOpen(
|
||||
client,
|
||||
route.identity.id,
|
||||
previous,
|
||||
started.error,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (isAborted(options.signal)) {
|
||||
return err(
|
||||
await this.#failedOpen(
|
||||
client,
|
||||
route.identity.id,
|
||||
previous,
|
||||
new AnalysisCancelledError("open_binary"),
|
||||
),
|
||||
);
|
||||
}
|
||||
this.#active = {
|
||||
target,
|
||||
client,
|
||||
profile,
|
||||
route,
|
||||
runId,
|
||||
nativeSourceAdmitted: false,
|
||||
};
|
||||
this.#clearRuntimeAvailability();
|
||||
this.resetSnapshotInvalidation();
|
||||
if (options.snapshot === undefined) {
|
||||
if (profile === null) this.clearSnapshot();
|
||||
else this.selectSnapshot(target, profile);
|
||||
}
|
||||
if (options.snapshot !== undefined) {
|
||||
const imported = this.importAnalysisSnapshot(options.snapshot);
|
||||
if (!imported.ok) {
|
||||
this.#active = undefined;
|
||||
return err(
|
||||
await this.#failedOpen(
|
||||
client,
|
||||
route.identity.id,
|
||||
previous,
|
||||
imported.error,
|
||||
),
|
||||
);
|
||||
}
|
||||
return ok(target);
|
||||
}
|
||||
return ok(target);
|
||||
}
|
||||
|
||||
/** Serialize an external mutation and retire the potentially stale analysis. */
|
||||
withAdmittedExternalMutation<Value>(
|
||||
_path: string | undefined,
|
||||
signal: AbortSignal | undefined,
|
||||
operation: () => Promise<Result<Value, AnalysisError>>,
|
||||
retirementFailure: (
|
||||
value: Result<Value, AnalysisError>,
|
||||
error: AnalysisError,
|
||||
) => AnalysisError,
|
||||
operationName = "execute_rizin_command",
|
||||
cleanupRecovery?: (
|
||||
failure: AnalysisError | undefined,
|
||||
) => Promise<Result<null, AnalysisError>>,
|
||||
): Promise<Result<Result<Value, AnalysisError>, AnalysisError>> {
|
||||
return this.#serialize(async (admittedThrough) => {
|
||||
if (isAborted(signal))
|
||||
return err(new AnalysisCancelledError(operationName));
|
||||
await this.#drainCalls(admittedThrough);
|
||||
if (isAborted(signal))
|
||||
return err(new AnalysisCancelledError(operationName));
|
||||
|
||||
if (this.#externalMutationCleanupFailure?.cleanupIncomplete === true) {
|
||||
if (cleanupRecovery === undefined)
|
||||
return err(this.#externalMutationCleanupFailure);
|
||||
const recovered = await cleanupRecovery(
|
||||
this.#externalMutationCleanupFailure,
|
||||
);
|
||||
if (!recovered.ok) return recovered;
|
||||
if (this.#externalMutationCleanupFailure.cleanupIncomplete)
|
||||
return err(this.#externalMutationCleanupFailure);
|
||||
this.#externalMutationCleanupFailure = undefined;
|
||||
}
|
||||
|
||||
const pendingCleanup = await this.#retryPendingCleanup();
|
||||
if (!pendingCleanup.ok) return pendingCleanup;
|
||||
|
||||
const activeAtDispatch = this.#active;
|
||||
if (activeAtDispatch?.route.binding?.identity.id === "ghidra")
|
||||
return err(
|
||||
new AnalysisOutputError(
|
||||
operationName,
|
||||
"Close the active Ghidra session explicitly before an external mutation; rebinding would discard its ephemeral analysis edits.",
|
||||
),
|
||||
);
|
||||
// A protocol reply does not prove that background writers have stopped.
|
||||
// Retire the old analysis and require an explicit fresh open rather than
|
||||
// silently binding a snapshot while an unrestricted command can still write.
|
||||
const retireTarget = activeAtDispatch !== undefined;
|
||||
this.invalidateSnapshot();
|
||||
const value = await operation();
|
||||
this.#externalMutationCleanupFailure =
|
||||
!value.ok && value.error.cleanupIncomplete ? value.error : undefined;
|
||||
if (!retireTarget) return ok(value);
|
||||
|
||||
const retired = await this.#closeActive({}, false);
|
||||
if (!retired.ok) return err(retirementFailure(value, retired.error));
|
||||
return ok(value);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -396,6 +474,7 @@ export class BinarySession
|
||||
options: Pick<ExecutionOptions, "progress"> & {
|
||||
readonly retainProviderDocuments?: boolean;
|
||||
},
|
||||
clearRecords = true,
|
||||
): Promise<Result<null, AnalysisError>> {
|
||||
const previous = this.#active;
|
||||
this.#active = undefined;
|
||||
@@ -417,7 +496,7 @@ export class BinarySession
|
||||
: {}),
|
||||
},
|
||||
);
|
||||
this.clearSessionRecords();
|
||||
if (clearRecords) this.clearSessionRecords();
|
||||
this.#clearRuntimeAvailability();
|
||||
const fileResources = await this.closeWriteResources();
|
||||
if (!closed.ok && !fileResources.ok)
|
||||
|
||||
@@ -48,6 +48,20 @@ export interface BinarySessionPort
|
||||
signal: AbortSignal | undefined,
|
||||
operation: (analysis: AnalysisOperationPort) => Promise<Value>,
|
||||
): Promise<Result<Value, AnalysisError>>;
|
||||
/** Serialize an external mutation and retire any active target afterward. */
|
||||
withAdmittedExternalMutation<Value>(
|
||||
_path: string | undefined,
|
||||
signal: AbortSignal | undefined,
|
||||
operation: () => Promise<Result<Value, AnalysisError>>,
|
||||
retirementFailure: (
|
||||
value: Result<Value, AnalysisError>,
|
||||
error: AnalysisError,
|
||||
) => AnalysisError,
|
||||
operationName?: string,
|
||||
cleanupRecovery?: (
|
||||
failure: AnalysisError | undefined,
|
||||
) => Promise<Result<null, AnalysisError>>,
|
||||
): Promise<Result<Result<Value, AnalysisError>, AnalysisError>>;
|
||||
open(
|
||||
path: string,
|
||||
options?: {
|
||||
@@ -82,6 +96,8 @@ export interface BinarySessionPort
|
||||
operation?: AnalysisOperation,
|
||||
): AnalysisProfileCommitment | undefined;
|
||||
allowsSnapshotReplay(operation: AnalysisOperation): boolean;
|
||||
/** Invalidate cached Evidence before an external command may mutate the target. */
|
||||
invalidateSnapshotForExternalMutation(): void;
|
||||
recordWorkflowSnapshot(
|
||||
input: WorkflowSnapshotRecordInput,
|
||||
): Result<null, EvidenceIntegrityError>;
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { EvidenceProvider } from "../../domain/evidence.js";
|
||||
import type {
|
||||
ObjdumpInput,
|
||||
RizinInput,
|
||||
} from "../../domain/reverseEngineering.js";
|
||||
|
||||
/** Provider-neutral command profile consumed by the shared application workflow. */
|
||||
export interface ReverseEngineeringProviderCommand {
|
||||
readonly executable: string;
|
||||
readonly arguments: readonly string[];
|
||||
readonly identity: EvidenceProvider;
|
||||
readonly environmentOverrides?: Readonly<NodeJS.ProcessEnv>;
|
||||
}
|
||||
|
||||
/** Provider-specific command construction stays outside the application layer. */
|
||||
export interface ReverseEngineeringCommandPort {
|
||||
objdump(input: ObjdumpInput): ReverseEngineeringProviderCommand;
|
||||
rizin(input: RizinInput): ReverseEngineeringProviderCommand;
|
||||
}
|
||||
@@ -0,0 +1,948 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { createReadStream } from "node:fs";
|
||||
import { stat as statPath } from "node:fs/promises";
|
||||
import type { Stats } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import {
|
||||
AnalysisAccessDeniedError,
|
||||
AnalysisCancelledError,
|
||||
AnalysisCapabilityUnavailableError,
|
||||
AnalysisInputError,
|
||||
AnalysisResourceConstraintError,
|
||||
AnalysisTimeoutError,
|
||||
} from "../../domain/analysisErrorCore.js";
|
||||
import type {
|
||||
AnalysisCleanupObservation,
|
||||
AnalysisError,
|
||||
} from "../../domain/analysisErrorBase.js";
|
||||
import { AnalysisOutputError } from "../../domain/analysisErrorCore.js";
|
||||
import { createEvidence, type Evidence } from "../../domain/evidence.js";
|
||||
import type { JsonValue } from "../../domain/jsonValue.js";
|
||||
import { err, ok, type Result } from "../../domain/result.js";
|
||||
import {
|
||||
objdumpInputSchema,
|
||||
rizinInputSchema,
|
||||
} from "../../domain/reverseEngineering.js";
|
||||
import type {
|
||||
ReverseEngineeringCommandPort,
|
||||
ReverseEngineeringProviderCommand,
|
||||
} from "./ReverseEngineeringCommandPort.js";
|
||||
import {
|
||||
execFileOutput,
|
||||
execFileOutputFailure,
|
||||
} from "../../process/ExecFileOutput.js";
|
||||
import {
|
||||
OwnedCommandFailure,
|
||||
runOwnedCommand,
|
||||
} from "../../process/OwnedCommand.js";
|
||||
import type { ProviderProcessSupervisor } from "../../process/ProviderProcess.js";
|
||||
|
||||
export type ReverseEngineeringOperation =
|
||||
| "inspect_with_objdump"
|
||||
| "execute_rizin_command";
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 120_000;
|
||||
const MAX_COMBINED_OUTPUT_BYTES = 16 * 1024 * 1024;
|
||||
// Eight concurrent captures bound retained provider output to 128 MiB.
|
||||
const MAX_CONCURRENT_REVERSE_ENGINEERING_OPERATIONS = 8;
|
||||
|
||||
export interface ReverseEngineeringServiceOptions {
|
||||
readonly environment?: Readonly<NodeJS.ProcessEnv>;
|
||||
readonly commands: ReverseEngineeringCommandPort;
|
||||
readonly run?: ReverseEngineeringCommandRunner;
|
||||
readonly runOwned?: typeof runOwnedCommand;
|
||||
readonly digest?: typeof digestFile;
|
||||
readonly stat?: (path: string) => Promise<Stats>;
|
||||
}
|
||||
|
||||
interface ReverseEngineeringCommandRunner {
|
||||
(
|
||||
command: string,
|
||||
arguments_: readonly string[],
|
||||
options?: Parameters<typeof execFileOutput>[2],
|
||||
): Promise<{
|
||||
readonly stdout: string;
|
||||
readonly stderr: string;
|
||||
readonly stdoutInvalidUtf8?: boolean;
|
||||
readonly stderrInvalidUtf8?: boolean;
|
||||
readonly stdoutBytes?: number;
|
||||
readonly stderrBytes?: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export type ReverseEngineeringServiceRuntimeOptions = Omit<
|
||||
ReverseEngineeringServiceOptions,
|
||||
"commands" | "environment"
|
||||
>;
|
||||
|
||||
/** Shared application workflow for caller-selected local analysis commands. */
|
||||
export class ReverseEngineeringService {
|
||||
readonly #environment: Readonly<NodeJS.ProcessEnv>;
|
||||
readonly #commands: ReverseEngineeringCommandPort;
|
||||
readonly #run: ReverseEngineeringCommandRunner;
|
||||
readonly #digest: typeof digestFile;
|
||||
readonly #stat: (path: string) => Promise<Stats>;
|
||||
readonly #pendingCleanup = new Map<
|
||||
ProviderProcessSupervisor,
|
||||
{
|
||||
readonly observation: AnalysisCleanupObservation;
|
||||
readonly failures: Set<AnalysisError>;
|
||||
readonly retryFailure?: AnalysisError;
|
||||
}
|
||||
>();
|
||||
readonly #activeOperations = new Map<
|
||||
AbortController,
|
||||
Promise<Result<Evidence, AnalysisError>>
|
||||
>();
|
||||
#closing = false;
|
||||
#closePromise: Promise<void> | undefined;
|
||||
|
||||
constructor(options: ReverseEngineeringServiceOptions) {
|
||||
this.#environment = { ...(options.environment ?? process.env) };
|
||||
this.#commands = options.commands;
|
||||
this.#run =
|
||||
options.run ??
|
||||
runOwnedCommandOutput(
|
||||
options.runOwned ?? runOwnedCommand,
|
||||
(owner, cleanup) =>
|
||||
this.#pendingCleanup.set(owner, {
|
||||
observation: cleanup,
|
||||
failures: this.#pendingCleanup.get(owner)?.failures ?? new Set(),
|
||||
}),
|
||||
);
|
||||
this.#digest = options.digest ?? digestFile;
|
||||
this.#stat = options.stat ?? statPath;
|
||||
}
|
||||
|
||||
/** Ask retained process owners to settle cleanup before admitting more work. */
|
||||
async retryCleanup(
|
||||
operation = "reverse_engineering_cleanup",
|
||||
): Promise<Result<null, AnalysisError>> {
|
||||
let failure: AnalysisError | undefined;
|
||||
for (const [owner, retained] of this.#pendingCleanup) {
|
||||
let stopped: Awaited<ReturnType<ProviderProcessSupervisor["stop"]>>;
|
||||
try {
|
||||
stopped = await owner.stop();
|
||||
} catch (cause) {
|
||||
stopped = {
|
||||
status: "incomplete",
|
||||
reason: cause instanceof Error ? cause.message : String(cause),
|
||||
};
|
||||
}
|
||||
if (stopped.status === "incomplete") {
|
||||
const observation = {
|
||||
reason: stopped.reason,
|
||||
resources: retained.observation.resources,
|
||||
};
|
||||
let retryFailure = retained.retryFailure;
|
||||
if (retryFailure === undefined) {
|
||||
retryFailure = new AnalysisOutputError(
|
||||
operation,
|
||||
"A previous provider process still requires cleanup.",
|
||||
{ cleanup: observation },
|
||||
);
|
||||
retained.failures.add(retryFailure);
|
||||
}
|
||||
this.#pendingCleanup.set(owner, {
|
||||
observation,
|
||||
failures: retained.failures,
|
||||
retryFailure,
|
||||
});
|
||||
if (failure === undefined) failure = retryFailure;
|
||||
} else {
|
||||
for (const previous of retained.failures) previous.resolveCleanup();
|
||||
this.#pendingCleanup.delete(owner);
|
||||
}
|
||||
}
|
||||
return failure === undefined ? ok(null) : err(failure);
|
||||
}
|
||||
|
||||
/** Retry cleanup for provider process groups retained after an incomplete stop. */
|
||||
close(): Promise<void> {
|
||||
this.#closing = true;
|
||||
this.#closePromise ??= this.#closeActiveOperationsAndCleanup().catch(
|
||||
(cause: unknown) => {
|
||||
this.#closePromise = undefined;
|
||||
throw cause;
|
||||
},
|
||||
);
|
||||
return this.#closePromise;
|
||||
}
|
||||
|
||||
async #closeActiveOperationsAndCleanup(): Promise<void> {
|
||||
const active = [...this.#activeOperations.entries()];
|
||||
for (const [controller] of active) controller.abort();
|
||||
await Promise.allSettled(active.map(([, operation]) => operation));
|
||||
|
||||
const cleanup = await this.retryCleanup();
|
||||
if (!cleanup.ok) throw cleanup.error;
|
||||
}
|
||||
|
||||
inspectWithObjdump(
|
||||
rawInput: unknown,
|
||||
options: { readonly signal?: AbortSignal } = {},
|
||||
): Promise<Result<Evidence, AnalysisError>> {
|
||||
const parsed = objdumpInputSchema.safeParse(rawInput);
|
||||
if (!parsed.success)
|
||||
return Promise.resolve(
|
||||
err(new AnalysisInputError("inspect_with_objdump")),
|
||||
);
|
||||
const input = parsed.data;
|
||||
const command = this.#commands.objdump(input);
|
||||
return this.#observe(
|
||||
"inspect_with_objdump",
|
||||
command,
|
||||
input.operation === "architectures" ? undefined : input.path,
|
||||
input,
|
||||
options.signal,
|
||||
);
|
||||
}
|
||||
|
||||
executeRizinCommand(
|
||||
rawInput: unknown,
|
||||
options: { readonly signal?: AbortSignal } = {},
|
||||
): Promise<Result<Evidence, AnalysisError>> {
|
||||
const parsed = rizinInputSchema.safeParse(rawInput);
|
||||
if (!parsed.success)
|
||||
return Promise.resolve(
|
||||
err(new AnalysisInputError("execute_rizin_command")),
|
||||
);
|
||||
const input = parsed.data;
|
||||
const command = this.#commands.rizin(input);
|
||||
return this.#observe(
|
||||
"execute_rizin_command",
|
||||
command,
|
||||
input.path,
|
||||
input,
|
||||
options.signal,
|
||||
);
|
||||
}
|
||||
|
||||
#observe(
|
||||
operation: ReverseEngineeringOperation,
|
||||
profile: ReverseEngineeringProviderCommand,
|
||||
path: string | undefined,
|
||||
parameters: Readonly<Record<string, JsonValue>>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Result<Evidence, AnalysisError>> {
|
||||
if (signal?.aborted)
|
||||
return Promise.resolve(err(new AnalysisCancelledError(operation)));
|
||||
if (this.#closing)
|
||||
return Promise.resolve(
|
||||
err(
|
||||
new AnalysisOutputError(
|
||||
operation,
|
||||
"Reverse-engineering service is closed.",
|
||||
),
|
||||
),
|
||||
);
|
||||
if (
|
||||
this.#activeOperations.size >=
|
||||
MAX_CONCURRENT_REVERSE_ENGINEERING_OPERATIONS
|
||||
)
|
||||
return Promise.resolve(
|
||||
err(
|
||||
new AnalysisResourceConstraintError(
|
||||
operation,
|
||||
"transport",
|
||||
"Concurrent reverse-engineering operation capacity reached.",
|
||||
{
|
||||
max_concurrent_operations:
|
||||
MAX_CONCURRENT_REVERSE_ENGINEERING_OPERATIONS,
|
||||
active_operations: this.#activeOperations.size,
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
const controller = new AbortController();
|
||||
const relayAbort = (): void => controller.abort(signal?.reason);
|
||||
if (signal instanceof AbortSignal) {
|
||||
if (signal.aborted) relayAbort();
|
||||
else signal.addEventListener("abort", relayAbort, { once: true });
|
||||
}
|
||||
const operationSignal =
|
||||
signal === undefined || signal instanceof AbortSignal
|
||||
? controller.signal
|
||||
: signal;
|
||||
const running = this.#observeProvider(
|
||||
operation,
|
||||
profile,
|
||||
path,
|
||||
parameters,
|
||||
operationSignal,
|
||||
);
|
||||
const tracked = running.finally(() => {
|
||||
signal?.removeEventListener("abort", relayAbort);
|
||||
this.#activeOperations.delete(controller);
|
||||
});
|
||||
this.#activeOperations.set(controller, tracked);
|
||||
return tracked;
|
||||
}
|
||||
|
||||
async #observeProvider(
|
||||
operation: ReverseEngineeringOperation,
|
||||
profile: ReverseEngineeringProviderCommand,
|
||||
path: string | undefined,
|
||||
parameters: Readonly<Record<string, JsonValue>>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Result<Evidence, AnalysisError>> {
|
||||
const { executable: command, arguments: args } = profile;
|
||||
if (signal?.aborted) return err(new AnalysisCancelledError(operation));
|
||||
const cleanup = await this.retryCleanup(operation);
|
||||
if (!cleanup.ok) return cleanup;
|
||||
const absolutePath = path === undefined ? undefined : resolve(path);
|
||||
let digest: string | undefined;
|
||||
try {
|
||||
if (absolutePath !== undefined)
|
||||
digest = await this.#digest(absolutePath, signal, this.#stat);
|
||||
} catch (cause) {
|
||||
const code = (cause as NodeJS.ErrnoException).code;
|
||||
if (signal?.aborted)
|
||||
return err(new AnalysisCancelledError(operation, { cause }));
|
||||
if (code === "EACCES" || code === "EPERM") {
|
||||
const deniedPath = absolutePath === undefined ? command : absolutePath;
|
||||
return err(
|
||||
new AnalysisAccessDeniedError(operation, deniedPath, code, {
|
||||
cause,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (code === "ETIMEDOUT")
|
||||
return err(
|
||||
new AnalysisTimeoutError(operation, DEFAULT_TIMEOUT_MS, { cause }),
|
||||
);
|
||||
return err(new AnalysisInputError(operation, { cause }));
|
||||
}
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let stdoutBytes = 0;
|
||||
let stderrBytes = 0;
|
||||
let exitCode: number | string | null = 0;
|
||||
let signalName: string | null = null;
|
||||
let outputTruncated = false;
|
||||
let stdoutInvalidUtf8 = false;
|
||||
let stderrInvalidUtf8 = false;
|
||||
let cleanupIncomplete = false;
|
||||
let cleanupOnlyFailure = false;
|
||||
let completionStatus: "complete" | "unknown" = "complete";
|
||||
let failure: AnalysisError | undefined;
|
||||
let cleanupOwner: ProviderProcessSupervisor | undefined;
|
||||
let earlyFailure: AnalysisError | undefined;
|
||||
try {
|
||||
const output = await this.#run(command, args, {
|
||||
env: {
|
||||
...this.#environment,
|
||||
...profile.environmentOverrides,
|
||||
},
|
||||
signal,
|
||||
timeout: DEFAULT_TIMEOUT_MS,
|
||||
stopSignal: "SIGTERM",
|
||||
maxBuffer: MAX_COMBINED_OUTPUT_BYTES,
|
||||
});
|
||||
stdout = output.stdout;
|
||||
stderr = output.stderr;
|
||||
stdoutBytes = output.stdoutBytes ?? Buffer.byteLength(stdout);
|
||||
stderrBytes = output.stderrBytes ?? Buffer.byteLength(stderr);
|
||||
stdoutInvalidUtf8 = output.stdoutInvalidUtf8 === true;
|
||||
stderrInvalidUtf8 = output.stderrInvalidUtf8 === true;
|
||||
} catch (cause) {
|
||||
cleanupOwner =
|
||||
cause instanceof OwnedOutputFailure ? cause.cleanupOwner : undefined;
|
||||
if (
|
||||
cause instanceof OwnedOutputFailure &&
|
||||
cause.capabilityError !== undefined
|
||||
)
|
||||
earlyFailure = new AnalysisCapabilityUnavailableError(
|
||||
cause.capabilityError.providerId,
|
||||
cause.capabilityError.operation,
|
||||
cause.capabilityError.reason,
|
||||
{
|
||||
cause: cause.capabilityError,
|
||||
...(cause.capabilityError.diagnostics === undefined
|
||||
? {}
|
||||
: { diagnostics: cause.capabilityError.diagnostics }),
|
||||
...(cause.capabilityError.capturedOutput === undefined
|
||||
? {}
|
||||
: { capturedOutput: cause.capabilityError.capturedOutput }),
|
||||
...(cause.capabilityError.partialObservation === undefined
|
||||
? {}
|
||||
: {
|
||||
partialObservation: cause.capabilityError.partialObservation,
|
||||
}),
|
||||
...(cause.capabilityError.userMessage === undefined
|
||||
? {}
|
||||
: { userMessage: cause.capabilityError.userMessage }),
|
||||
retryable: cause.capabilityError.retryable,
|
||||
...ownedCleanupOptions(cause),
|
||||
},
|
||||
);
|
||||
const capture = execFileOutputFailure(cause);
|
||||
const code =
|
||||
capture?.code ?? (cause as NodeJS.ErrnoException).code ?? undefined;
|
||||
if (earlyFailure === undefined && code === "E2BIG")
|
||||
earlyFailure = new AnalysisResourceConstraintError(
|
||||
operation,
|
||||
"transport",
|
||||
"The host rejected the provider command because its argument list exceeds the process launch limit.",
|
||||
{
|
||||
argument_bytes: args.reduce(
|
||||
(bytes, argument, index) =>
|
||||
bytes + Buffer.byteLength(argument) + (index === 0 ? 0 : 1),
|
||||
0,
|
||||
),
|
||||
},
|
||||
{ cause, ...ownedCleanupOptions(cause) },
|
||||
);
|
||||
if (
|
||||
earlyFailure === undefined &&
|
||||
(capture === undefined ||
|
||||
code === "ENOENT" ||
|
||||
code === "ERR_PROVIDER_LAUNCH")
|
||||
) {
|
||||
if (signal?.aborted || code === "ABORT_ERR")
|
||||
earlyFailure = new AnalysisCancelledError(operation, {
|
||||
cause,
|
||||
...ownedCleanupOptions(cause),
|
||||
});
|
||||
else if (code === "ETIMEDOUT")
|
||||
earlyFailure = new AnalysisTimeoutError(
|
||||
operation,
|
||||
DEFAULT_TIMEOUT_MS,
|
||||
{
|
||||
cause,
|
||||
...ownedCleanupOptions(cause),
|
||||
},
|
||||
);
|
||||
else if (code === "EACCES" || code === "EPERM")
|
||||
earlyFailure = new AnalysisAccessDeniedError(
|
||||
operation,
|
||||
command,
|
||||
code,
|
||||
{
|
||||
cause,
|
||||
...ownedCleanupOptions(cause),
|
||||
},
|
||||
);
|
||||
else
|
||||
earlyFailure = new AnalysisCapabilityUnavailableError(
|
||||
operation === "inspect_with_objdump" ? "objdump" : "rizin",
|
||||
operation,
|
||||
`Unable to run configured command (${String(code ?? "unknown error")})`,
|
||||
{ cause, ...ownedCleanupOptions(cause) },
|
||||
);
|
||||
}
|
||||
if (earlyFailure === undefined && capture !== undefined) {
|
||||
stdout = capture.stdout;
|
||||
stderr = capture.stderr;
|
||||
stdoutBytes = capture.stdoutBytes ?? Buffer.byteLength(stdout);
|
||||
stderrBytes = capture.stderrBytes ?? Buffer.byteLength(stderr);
|
||||
exitCode = capture.code;
|
||||
signalName = capture.signal;
|
||||
outputTruncated = capture.outputTruncated;
|
||||
if (capture.collectionFailure) {
|
||||
exitCode = null;
|
||||
completionStatus = "unknown";
|
||||
}
|
||||
const capturedOutput = {
|
||||
stdout,
|
||||
stderr,
|
||||
truncated: outputTruncated,
|
||||
stdout_bytes: stdoutBytes,
|
||||
stderr_bytes: stderrBytes,
|
||||
exit_code:
|
||||
!capture.collectionFailure && typeof capture.code === "number"
|
||||
? capture.code
|
||||
: null,
|
||||
signal: capture.signal,
|
||||
};
|
||||
if (signal?.aborted || code === "ABORT_ERR")
|
||||
failure = new AnalysisCancelledError(operation, {
|
||||
cause,
|
||||
capturedOutput,
|
||||
...ownedCleanupOptions(cause),
|
||||
});
|
||||
else if (code === "ETIMEDOUT")
|
||||
failure = new AnalysisTimeoutError(operation, DEFAULT_TIMEOUT_MS, {
|
||||
cause,
|
||||
capturedOutput,
|
||||
...ownedCleanupOptions(cause),
|
||||
});
|
||||
else if (code === "EACCES" || code === "EPERM")
|
||||
failure = new AnalysisAccessDeniedError(operation, command, code, {
|
||||
cause,
|
||||
capturedOutput,
|
||||
...ownedCleanupOptions(cause),
|
||||
});
|
||||
if (
|
||||
cause instanceof OwnedOutputFailure &&
|
||||
cause.cleanup !== undefined
|
||||
) {
|
||||
cleanupIncomplete = true;
|
||||
if (failure === undefined) {
|
||||
cleanupOnlyFailure =
|
||||
!capture.collectionFailure &&
|
||||
!capture.outputTruncated &&
|
||||
!capture.killed &&
|
||||
capture.signal === null &&
|
||||
typeof capture.code === "number" &&
|
||||
!signal?.aborted;
|
||||
failure = new AnalysisOutputError(
|
||||
operation,
|
||||
"Provider process cleanup remains incomplete.",
|
||||
{
|
||||
cause,
|
||||
capturedOutput,
|
||||
cleanup: cause.cleanup,
|
||||
cleanupOnly: cleanupOnlyFailure,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
if (
|
||||
failure === undefined &&
|
||||
cause instanceof OwnedOutputFailure &&
|
||||
cause.collectionFailure
|
||||
)
|
||||
failure = new AnalysisOutputError(
|
||||
operation,
|
||||
"Provider output collection failed.",
|
||||
{ cause, capturedOutput },
|
||||
);
|
||||
if (
|
||||
capture.killed ||
|
||||
capture.signal !== null ||
|
||||
typeof capture.code !== "number"
|
||||
) {
|
||||
exitCode = null;
|
||||
completionStatus = "unknown";
|
||||
}
|
||||
if (cause instanceof OwnedOutputFailure) {
|
||||
stdoutInvalidUtf8 = cause.stdoutInvalidUtf8;
|
||||
stderrInvalidUtf8 = cause.stderrInvalidUtf8;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (earlyFailure !== undefined)
|
||||
return err(this.#registerCleanupFailure(earlyFailure, cleanupOwner));
|
||||
if (failure !== undefined && cleanupOwner !== undefined)
|
||||
failure = this.#registerCleanupFailure(failure, cleanupOwner);
|
||||
let digestAfter: string | null = null;
|
||||
if (absolutePath === undefined) {
|
||||
digestAfter = null;
|
||||
} else if (cleanupIncomplete) {
|
||||
if (!cleanupOnlyFailure) completionStatus = "unknown";
|
||||
} else {
|
||||
try {
|
||||
digestAfter = await this.#digest(absolutePath, signal, this.#stat);
|
||||
} catch {
|
||||
// Preserve the provider's collected observations if the artifact vanished or became unreadable.
|
||||
}
|
||||
}
|
||||
if (signal?.aborted && failure === undefined) {
|
||||
failure = new AnalysisCancelledError(operation, {
|
||||
capturedOutput: {
|
||||
stdout,
|
||||
stderr,
|
||||
truncated: outputTruncated,
|
||||
stdout_bytes: stdoutBytes,
|
||||
stderr_bytes: stderrBytes,
|
||||
exit_code: typeof exitCode === "number" ? exitCode : null,
|
||||
signal: signalName,
|
||||
},
|
||||
});
|
||||
exitCode = null;
|
||||
completionStatus = "unknown";
|
||||
}
|
||||
const artifactChanged =
|
||||
absolutePath === undefined
|
||||
? null
|
||||
: digestAfter === null || digest === undefined
|
||||
? null
|
||||
: digestAfter !== digest;
|
||||
const result = {
|
||||
stdout,
|
||||
stderr,
|
||||
exit_code: exitCode,
|
||||
signal: signalName,
|
||||
output_truncated: outputTruncated,
|
||||
completion_status: completionStatus,
|
||||
artifact_sha256_after: absolutePath === undefined ? null : digestAfter,
|
||||
artifact_changed: absolutePath === undefined ? null : artifactChanged,
|
||||
};
|
||||
const limitations = [
|
||||
...(absolutePath === undefined
|
||||
? [
|
||||
"The objdump version is not reported; the output lists BFD targets advertised by the installed tool and does not establish support for the selected artifact.",
|
||||
]
|
||||
: [
|
||||
"The executable version and supported formats are reported as unknown because no reliable version probe was performed.",
|
||||
]),
|
||||
"A non-zero process exit is preserved as an observed command result.",
|
||||
...(operation === "inspect_with_objdump" &&
|
||||
parameters.follow_debug_links === true
|
||||
? [
|
||||
"DWARF output may include local debug-link files whose identities are not bound to Evidence.",
|
||||
]
|
||||
: []),
|
||||
...(operation === "execute_rizin_command"
|
||||
? [
|
||||
"Rizin commands and retained plugins may read files or access network resources outside the selected artifact; those external inputs are not bound to Evidence.",
|
||||
]
|
||||
: []),
|
||||
...(stdoutInvalidUtf8 || stderrInvalidUtf8
|
||||
? [
|
||||
`Captured ${[
|
||||
...(stdoutInvalidUtf8 ? ["stdout"] : []),
|
||||
...(stderrInvalidUtf8 ? ["stderr"] : []),
|
||||
].join(
|
||||
" and ",
|
||||
)} bytes contain invalid or incomplete UTF-8; replacement characters in the text capture do not preserve the original bytes.`,
|
||||
]
|
||||
: []),
|
||||
...(outputTruncated
|
||||
? [
|
||||
"The provider command ended before completion could be confirmed; captured output is partial.",
|
||||
]
|
||||
: []),
|
||||
...(signalName === null
|
||||
? []
|
||||
: [
|
||||
`The provider process terminated by signal ${signalName}; captured output may be partial and command completion is unknown.`,
|
||||
]),
|
||||
...(failure === undefined ||
|
||||
(failure instanceof AnalysisOutputError && failure.cleanupOnly)
|
||||
? []
|
||||
: [
|
||||
`Command completion is unknown because the operation failed with ${failure._tag}: ${failure.message}`,
|
||||
]),
|
||||
...(absolutePath === undefined
|
||||
? [
|
||||
"This output lists BFD targets advertised by the installed objdump and is independent of the selected artifact.",
|
||||
]
|
||||
: [
|
||||
"When both are available, pre-run and post-run digests detect artifact changes across the provider call but cannot prove the exact bytes consumed during the call.",
|
||||
]),
|
||||
...(artifactChanged === true
|
||||
? [
|
||||
"The provider command may have changed the artifact while analysis was running; pre-run and post-run digests are both reported.",
|
||||
]
|
||||
: []),
|
||||
...(absolutePath !== undefined && digestAfter === null
|
||||
? [
|
||||
cleanupIncomplete
|
||||
? "The post-run artifact digest was not captured because owned provider cleanup was incomplete at observation time; artifact changes during the command remain unquantified."
|
||||
: signal?.aborted
|
||||
? "The post-run artifact digest is unknown because cancellation interrupted the digest."
|
||||
: "The post-run artifact digest is unknown because the artifact could not be read after the provider call.",
|
||||
]
|
||||
: []),
|
||||
];
|
||||
const evidence = createEvidence(
|
||||
absolutePath === undefined || digest === undefined
|
||||
? undefined
|
||||
: { path: absolutePath, sha256: digest, format: "unknown" },
|
||||
profile.identity,
|
||||
{
|
||||
predicateType: "rea.reverse-engineering.command-observation",
|
||||
operation,
|
||||
parameters: {
|
||||
...(absolutePath === undefined
|
||||
? { operation: "architectures" }
|
||||
: parameters),
|
||||
provider_executable: command,
|
||||
command_arguments: [...args],
|
||||
},
|
||||
rawResult: result,
|
||||
result,
|
||||
limitations,
|
||||
...(absolutePath === undefined
|
||||
? {}
|
||||
: {
|
||||
locations: [
|
||||
{ kind: "artifact-path" as const, path: absolutePath },
|
||||
],
|
||||
}),
|
||||
},
|
||||
);
|
||||
return failure === undefined
|
||||
? ok(evidence)
|
||||
: err(failure.retainPartialObservation(evidence));
|
||||
}
|
||||
|
||||
#registerCleanupFailure(
|
||||
failure: AnalysisError,
|
||||
cleanupOwner: ProviderProcessSupervisor | undefined,
|
||||
): AnalysisError {
|
||||
if (cleanupOwner !== undefined && failure.cleanupIncomplete)
|
||||
this.#pendingCleanup.get(cleanupOwner)?.failures.add(failure);
|
||||
return failure;
|
||||
}
|
||||
}
|
||||
|
||||
interface OwnedOutputFailureOptions {
|
||||
readonly stdout: string;
|
||||
readonly stderr: string;
|
||||
readonly code: number | string | null;
|
||||
readonly signal: string | null;
|
||||
readonly outputTruncated: boolean;
|
||||
readonly stdoutInvalidUtf8: boolean;
|
||||
readonly stderrInvalidUtf8: boolean;
|
||||
readonly stdoutBytes: number;
|
||||
readonly stderrBytes: number;
|
||||
readonly capabilityError?: AnalysisCapabilityUnavailableError;
|
||||
readonly collectionFailure?: boolean;
|
||||
readonly cleanup?: AnalysisCleanupObservation;
|
||||
readonly cleanupOwner?: ProviderProcessSupervisor;
|
||||
readonly cause?: unknown;
|
||||
}
|
||||
|
||||
class OwnedOutputFailure extends Error {
|
||||
readonly stdout: string;
|
||||
readonly stderr: string;
|
||||
readonly code: number | string | null;
|
||||
readonly signal: string | null;
|
||||
readonly outputTruncated: boolean;
|
||||
readonly stdoutInvalidUtf8: boolean;
|
||||
readonly stderrInvalidUtf8: boolean;
|
||||
readonly stdoutBytes: number;
|
||||
readonly stderrBytes: number;
|
||||
readonly capabilityError: AnalysisCapabilityUnavailableError | undefined;
|
||||
readonly collectionFailure: boolean;
|
||||
readonly cleanup: AnalysisCleanupObservation | undefined;
|
||||
readonly cleanupOwner: ProviderProcessSupervisor | undefined;
|
||||
|
||||
constructor(options: OwnedOutputFailureOptions) {
|
||||
super("Owned provider command did not produce a complete result.", {
|
||||
cause: options.cause,
|
||||
});
|
||||
this.stdout = options.stdout;
|
||||
this.stderr = options.stderr;
|
||||
this.code = options.code;
|
||||
this.signal = options.signal;
|
||||
this.outputTruncated = options.outputTruncated;
|
||||
this.stdoutInvalidUtf8 = options.stdoutInvalidUtf8;
|
||||
this.stderrInvalidUtf8 = options.stderrInvalidUtf8;
|
||||
this.stdoutBytes = options.stdoutBytes;
|
||||
this.stderrBytes = options.stderrBytes;
|
||||
this.capabilityError = options.capabilityError;
|
||||
this.collectionFailure = options.collectionFailure ?? false;
|
||||
this.cleanup = options.cleanup;
|
||||
this.cleanupOwner = options.cleanupOwner;
|
||||
}
|
||||
}
|
||||
|
||||
const runOwnedCommandOutput =
|
||||
(
|
||||
runOwned: typeof runOwnedCommand,
|
||||
onCleanupOwner: (
|
||||
owner: ProviderProcessSupervisor,
|
||||
cleanup: AnalysisCleanupObservation,
|
||||
) => void,
|
||||
): ReverseEngineeringCommandRunner =>
|
||||
async (command, args, options = {}) => {
|
||||
const spawnEnv = { ...options.env };
|
||||
try {
|
||||
const snapshot = await runOwned(
|
||||
{
|
||||
command,
|
||||
arguments: [...args],
|
||||
runId: randomUUID(),
|
||||
expectedCommand: null,
|
||||
env: spawnEnv,
|
||||
hostEnvironment: {},
|
||||
sweepTokenOwnedProcesses: true,
|
||||
},
|
||||
{
|
||||
timeoutMs: DEFAULT_TIMEOUT_MS,
|
||||
diagnosticBytes: MAX_COMBINED_OUTPUT_BYTES,
|
||||
acceptNonZeroExit: true,
|
||||
},
|
||||
{ signal: options.signal },
|
||||
);
|
||||
if (snapshot.signal !== null && snapshot.signal !== undefined)
|
||||
throw new OwnedOutputFailure({
|
||||
stdout: snapshot.stdout.text,
|
||||
stderr: snapshot.stderr.text,
|
||||
code: snapshot.exitCode ?? null,
|
||||
signal: snapshot.signal,
|
||||
outputTruncated: snapshot.diagnosticTruncated === true,
|
||||
stdoutInvalidUtf8: snapshot.stdout.invalidUtf8 === true,
|
||||
stderrInvalidUtf8: snapshot.stderr.invalidUtf8 === true,
|
||||
stdoutBytes: snapshot.stdout.bytes,
|
||||
stderrBytes: snapshot.stderr.bytes,
|
||||
});
|
||||
if (snapshot.exitCode !== 0)
|
||||
throw new OwnedOutputFailure({
|
||||
stdout: snapshot.stdout.text,
|
||||
stderr: snapshot.stderr.text,
|
||||
code: snapshot.exitCode ?? null,
|
||||
signal: null,
|
||||
outputTruncated: snapshot.diagnosticTruncated === true,
|
||||
stdoutInvalidUtf8: snapshot.stdout.invalidUtf8 === true,
|
||||
stderrInvalidUtf8: snapshot.stderr.invalidUtf8 === true,
|
||||
stdoutBytes: snapshot.stdout.bytes,
|
||||
stderrBytes: snapshot.stderr.bytes,
|
||||
});
|
||||
return {
|
||||
stdout: snapshot.stdout.text,
|
||||
stderr: snapshot.stderr.text,
|
||||
stdoutInvalidUtf8: snapshot.stdout.invalidUtf8 === true,
|
||||
stderrInvalidUtf8: snapshot.stderr.invalidUtf8 === true,
|
||||
stdoutBytes: snapshot.stdout.bytes,
|
||||
stderrBytes: snapshot.stderr.bytes,
|
||||
};
|
||||
} catch (cause) {
|
||||
if (!(cause instanceof OwnedCommandFailure)) throw cause;
|
||||
if (cause.cleanupOwner !== undefined && cause.cleanupFailure !== null)
|
||||
onCleanupOwner(cause.cleanupOwner, {
|
||||
reason: cause.cleanupFailure,
|
||||
resources: cause.resources,
|
||||
});
|
||||
const snapshot = cause.snapshot;
|
||||
const launchErrorCode =
|
||||
cause.cause instanceof Error
|
||||
? Reflect.get(cause.cause, "code")
|
||||
: undefined;
|
||||
const code =
|
||||
cause.reason === "cancelled"
|
||||
? "ABORT_ERR"
|
||||
: cause.reason === "timeout"
|
||||
? "ETIMEDOUT"
|
||||
: cause.reason === "output-limit"
|
||||
? "ERR_CHILD_PROCESS_STDIO_MAXBUFFER"
|
||||
: launchErrorCode === "E2BIG"
|
||||
? "E2BIG"
|
||||
: snapshot === null
|
||||
? "ERR_PROVIDER_LAUNCH"
|
||||
: (snapshot.exitCode ?? null);
|
||||
throw new OwnedOutputFailure({
|
||||
stdout: snapshot?.stdout.text ?? "",
|
||||
stderr: snapshot?.stderr.text ?? "",
|
||||
code,
|
||||
signal: snapshot?.signal ?? null,
|
||||
outputTruncated: snapshot?.diagnosticTruncated === true,
|
||||
stdoutInvalidUtf8: snapshot?.stdout.invalidUtf8 === true,
|
||||
stderrInvalidUtf8: snapshot?.stderr.invalidUtf8 === true,
|
||||
stdoutBytes: snapshot?.stdout.bytes ?? 0,
|
||||
stderrBytes: snapshot?.stderr.bytes ?? 0,
|
||||
...(cause.cause instanceof AnalysisCapabilityUnavailableError
|
||||
? { capabilityError: cause.cause }
|
||||
: {}),
|
||||
...(cause.collectionFailure ? { collectionFailure: true } : {}),
|
||||
...(cause.cleanupFailure === null
|
||||
? {}
|
||||
: {
|
||||
cleanup: {
|
||||
reason: cause.cleanupFailure,
|
||||
resources: cause.resources,
|
||||
},
|
||||
}),
|
||||
...(cause.cleanupOwner === undefined
|
||||
? {}
|
||||
: { cleanupOwner: cause.cleanupOwner }),
|
||||
cause,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const ownedCleanupOptions = (
|
||||
cause: unknown,
|
||||
): { readonly cleanup?: AnalysisCleanupObservation } =>
|
||||
cause instanceof OwnedOutputFailure && cause.cleanup !== undefined
|
||||
? { cleanup: cause.cleanup }
|
||||
: {};
|
||||
|
||||
const digestFile = async (
|
||||
path: string,
|
||||
signal?: AbortSignal,
|
||||
stat: (path: string) => Promise<Stats> = statPath,
|
||||
): Promise<string> => {
|
||||
const info = await statWithDeadline(path, signal, stat);
|
||||
if (!info.isFile())
|
||||
throw Object.assign(new Error("Artifact digest requires a regular file"), {
|
||||
code: "EINVAL",
|
||||
});
|
||||
return new Promise<string>((resolveDigest, rejectDigest) => {
|
||||
const hash = createHash("sha256");
|
||||
const stream = createReadStream(path);
|
||||
const timer = setTimeout(
|
||||
() =>
|
||||
stream.destroy(
|
||||
Object.assign(new Error("Artifact digest timed out"), {
|
||||
code: "ETIMEDOUT",
|
||||
}),
|
||||
),
|
||||
DEFAULT_TIMEOUT_MS,
|
||||
);
|
||||
const abort = (): void => {
|
||||
stream.destroy(new AnalysisCancelledError("artifact_digest"));
|
||||
};
|
||||
const cleanup = (): void => {
|
||||
clearTimeout(timer);
|
||||
signal?.removeEventListener("abort", abort);
|
||||
};
|
||||
if (signal?.aborted) {
|
||||
cleanup();
|
||||
stream.destroy();
|
||||
rejectDigest(new AnalysisCancelledError("artifact_digest"));
|
||||
return;
|
||||
}
|
||||
signal?.addEventListener("abort", abort, { once: true });
|
||||
stream.on("data", (chunk: Buffer | string) => hash.update(chunk));
|
||||
stream.once("error", (cause: Error) => {
|
||||
cleanup();
|
||||
rejectDigest(cause);
|
||||
});
|
||||
stream.once("end", () => {
|
||||
cleanup();
|
||||
resolveDigest(hash.digest("hex"));
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const statWithDeadline = (
|
||||
path: string,
|
||||
signal: AbortSignal | undefined,
|
||||
stat: (path: string) => Promise<Stats>,
|
||||
): Promise<Stats> => {
|
||||
if (signal?.aborted)
|
||||
return Promise.reject(new AnalysisCancelledError("artifact_digest"));
|
||||
return new Promise<Stats>((resolveStat, rejectStat) => {
|
||||
let settled = false;
|
||||
const finish = (cause?: unknown, value?: Stats): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
signal?.removeEventListener("abort", abort);
|
||||
if (cause !== undefined) rejectStat(cause);
|
||||
else resolveStat(value!);
|
||||
};
|
||||
const abort = (): void =>
|
||||
finish(new AnalysisCancelledError("artifact_digest"));
|
||||
const timer = setTimeout(
|
||||
() =>
|
||||
finish(
|
||||
Object.assign(new Error("Artifact stat timed out"), {
|
||||
code: "ETIMEDOUT",
|
||||
}),
|
||||
),
|
||||
DEFAULT_TIMEOUT_MS,
|
||||
);
|
||||
signal?.addEventListener("abort", abort, { once: true });
|
||||
if (signal?.aborted) {
|
||||
abort();
|
||||
return;
|
||||
}
|
||||
// Promise handlers stay attached after timeout/cancellation so a delayed
|
||||
// stat rejection is observed and cannot become an unhandled rejection.
|
||||
try {
|
||||
void stat(path).then(
|
||||
(info) => finish(undefined, info),
|
||||
(cause: unknown) => finish(cause),
|
||||
);
|
||||
} catch (cause) {
|
||||
finish(cause);
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -49,6 +49,8 @@ import {
|
||||
streamCliCommandResults,
|
||||
type CliResultOutput,
|
||||
} from "./cli/cliResultOutput.js";
|
||||
import { registerReverseEngineeringCommands } from "./cli/reverseEngineeringCommands.js";
|
||||
import { registerCutterCommands } from "./cli/cutterCommands.js";
|
||||
|
||||
/** Describe CLI settings that are invalid before any command runs, as MCP does. */
|
||||
export const cliConfigurationError = (
|
||||
@@ -95,6 +97,8 @@ export const createCli = (
|
||||
});
|
||||
|
||||
registerSetupCommands(cli, logger, environment);
|
||||
registerReverseEngineeringCommands(cli, logger, environment);
|
||||
registerCutterCommands(cli, logger, environment);
|
||||
registerCoreAnalysisCommands(cli, logger, analysis.runDirectAnalysis);
|
||||
registerUtilityCommands(cli, logger, environment, analysis);
|
||||
registerWasmCommands(cli, logger, environment);
|
||||
|
||||
@@ -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);
|
||||
}),
|
||||
),
|
||||
});
|
||||
};
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
}
|
||||
}),
|
||||
),
|
||||
});
|
||||
};
|
||||
@@ -49,6 +49,14 @@ export const CLI_COMMANDS = Object.freeze({
|
||||
inspectManagedArtifact: "inspect-managed-artifact",
|
||||
inspectRecordedCrash: "inspect-recorded-crash",
|
||||
inspectBinaryLayout: "inspect-binary-layout",
|
||||
listCutterSessions: "list-cutter-sessions",
|
||||
cutterCommand: "cutter-command",
|
||||
inspectWithObjdump: "inspect-with-objdump",
|
||||
listObjdumpArchitectures: "list-objdump-architectures",
|
||||
executeRizinCommand: "execute-rizin-command",
|
||||
debugWithGdb: "debug-with-gdb",
|
||||
debugWithRizin: "debug-with-rizin",
|
||||
|
||||
inspectGoBinary: "inspect-go-binary",
|
||||
inspectRustBinary: "inspect-rust-binary",
|
||||
inspectPeResources: "inspect-pe-resources",
|
||||
@@ -164,6 +172,13 @@ export const CLI_COMMAND_TOOL_ALIASES = Object.freeze({
|
||||
search: ["search_strings", "search_procedures"],
|
||||
"capture-process": ["capture_process_scenario"],
|
||||
"compare-bundles": ["compare_bundles"],
|
||||
"list-cutter-sessions": ["list_cutter_sessions"],
|
||||
"cutter-command": ["cutter_command"],
|
||||
"inspect-with-objdump": ["inspect_with_objdump"],
|
||||
"list-objdump-architectures": ["inspect_with_objdump"],
|
||||
"execute-rizin-command": ["execute_rizin_command"],
|
||||
"debug-with-gdb": ["gdb_console"],
|
||||
"debug-with-rizin": ["rizin_debug_command"],
|
||||
"list-frida-devices": ["list_frida_devices"],
|
||||
"list-frida-processes": ["list_frida_processes"],
|
||||
"instrument-with-frida": ["instrument_with_frida"],
|
||||
@@ -224,6 +239,12 @@ export const MCP_TOOLS_WITHOUT_DEDICATED_CLI = Object.freeze([
|
||||
"get_evidence_bundle",
|
||||
"get_navigation_context",
|
||||
"inspect_address_context",
|
||||
"start_gdb_session",
|
||||
"gdb_session_status",
|
||||
"close_gdb_session",
|
||||
"start_rizin_debug_session",
|
||||
"rizin_debug_session_status",
|
||||
"close_rizin_debug_session",
|
||||
"start_frida_session",
|
||||
"load_frida_script",
|
||||
"resume_frida_session",
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { existsSync, statSync } from "node:fs";
|
||||
import { win32 } from "node:path";
|
||||
|
||||
export interface WindowsExecutableLookup {
|
||||
/** Windows-formatted working directory; defaults to the process CWD. */
|
||||
readonly cwd?: string;
|
||||
/** Test seam for Windows paths when running on a non-Windows host. */
|
||||
readonly isFile?: (path: string) => boolean;
|
||||
}
|
||||
|
||||
const splitWindowsPath = (value: string): readonly string[] => {
|
||||
const entries: string[] = [];
|
||||
let start = 0;
|
||||
let quoted = false;
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
if (value[index] === '"') quoted = !quoted;
|
||||
else if (value[index] === ";" && !quoted) {
|
||||
entries.push(value.slice(start, index));
|
||||
start = index + 1;
|
||||
}
|
||||
}
|
||||
entries.push(value.slice(start));
|
||||
return entries.flatMap((entry) => {
|
||||
if (entry.startsWith('"') && entry.endsWith('"') && entry.length >= 2)
|
||||
return [entry.slice(1, -1)];
|
||||
return entry.includes('"') ? [] : [entry];
|
||||
});
|
||||
};
|
||||
|
||||
const isRegularFile = (path: string): boolean => {
|
||||
try {
|
||||
return existsSync(path) && statSync(path).isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/** Resolve Windows bare command names against the captured child environment. */
|
||||
export const resolveWindowsProviderExecutable = (
|
||||
executable: string,
|
||||
environment: Readonly<NodeJS.ProcessEnv>,
|
||||
lookup: WindowsExecutableLookup = {},
|
||||
): string => {
|
||||
const cwd = lookup.cwd ?? process.cwd();
|
||||
const absolute = (...paths: string[]): string => win32.resolve(cwd, ...paths);
|
||||
if (
|
||||
win32.isAbsolute(executable) ||
|
||||
executable.includes("/") ||
|
||||
executable.includes("\\") ||
|
||||
win32.parse(executable).root !== ""
|
||||
)
|
||||
return absolute(executable);
|
||||
|
||||
const pathValue = environment.PATH ?? environment.Path ?? environment.path;
|
||||
if (pathValue === undefined) return executable;
|
||||
const pathEntries = splitWindowsPath(pathValue);
|
||||
const explicitExtension = win32.extname(executable).toLowerCase();
|
||||
if (explicitExtension !== "" && ![".com", ".exe"].includes(explicitExtension))
|
||||
return executable;
|
||||
const extensions =
|
||||
explicitExtension !== ""
|
||||
? [""]
|
||||
: (environment.PATHEXT ?? environment.PathExt ?? ".COM;.EXE")
|
||||
.split(";")
|
||||
.filter((extension) =>
|
||||
[".com", ".exe"].includes(extension.toLowerCase()),
|
||||
);
|
||||
|
||||
for (const entry of pathEntries) {
|
||||
if (entry.length === 0) continue;
|
||||
for (const extension of extensions) {
|
||||
const candidate = absolute(entry, `${executable}${extension}`);
|
||||
if ((lookup.isFile ?? isRegularFile)(candidate)) return candidate;
|
||||
}
|
||||
}
|
||||
return executable;
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { ReverseEngineeringServiceRuntimeOptions } from "../application/reverse/ReverseEngineeringService.js";
|
||||
import type { ReverseEngineeringCommandPort } from "../application/reverse/ReverseEngineeringCommandPort.js";
|
||||
import { ReverseEngineeringService } from "../application/reverse/ReverseEngineeringService.js";
|
||||
import {
|
||||
objdumpCommand,
|
||||
OBJDUMP_PROVIDER_IDENTITY,
|
||||
} from "../objdump/ObjdumpCommand.js";
|
||||
import {
|
||||
rizinCommand,
|
||||
RIZIN_PROVIDER_IDENTITY,
|
||||
} from "../rizin/RizinCommand.js";
|
||||
import { resolveWindowsProviderExecutable } from "./resolveWindowsProviderExecutable.js";
|
||||
|
||||
export const createReverseEngineeringCommandPort = (
|
||||
environment: Readonly<NodeJS.ProcessEnv>,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): ReverseEngineeringCommandPort => ({
|
||||
objdump: (input) => ({
|
||||
executable: providerExecutable(
|
||||
environment.REA_OBJDUMP_COMMAND ?? "objdump",
|
||||
environment,
|
||||
platform,
|
||||
),
|
||||
arguments: objdumpCommand(input),
|
||||
identity: OBJDUMP_PROVIDER_IDENTITY,
|
||||
environmentOverrides: { DEBUGINFOD_URLS: "" },
|
||||
}),
|
||||
rizin: (input) => ({
|
||||
executable: providerExecutable(
|
||||
environment.REA_RIZIN_COMMAND ?? "rizin",
|
||||
environment,
|
||||
platform,
|
||||
),
|
||||
arguments: rizinCommand(input),
|
||||
identity: RIZIN_PROVIDER_IDENTITY,
|
||||
}),
|
||||
});
|
||||
|
||||
const providerExecutable = (
|
||||
executable: string,
|
||||
environment: Readonly<NodeJS.ProcessEnv>,
|
||||
platform: NodeJS.Platform,
|
||||
): string =>
|
||||
platform === "win32"
|
||||
? resolveWindowsProviderExecutable(executable, environment)
|
||||
: executable;
|
||||
|
||||
/** Compose BYO reverse-engineering commands without acquiring tools at import time. */
|
||||
export const createReverseEngineeringService = (
|
||||
environment: Readonly<NodeJS.ProcessEnv> = process.env,
|
||||
options: ReverseEngineeringServiceRuntimeOptions = {},
|
||||
): ReverseEngineeringService => {
|
||||
const environmentSnapshot = { ...environment };
|
||||
return new ReverseEngineeringService({
|
||||
...options,
|
||||
environment: environmentSnapshot,
|
||||
commands: createReverseEngineeringCommandPort(environmentSnapshot),
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,96 @@
|
||||
import { z } from "zod";
|
||||
import { jsonValueSchema } from "../domain/jsonValue.js";
|
||||
import type { ToolContract } from "./toolContractTypes.js";
|
||||
import { evidenceResultOf } from "./toolOutputSchemaPrimitives.js";
|
||||
import { toolContractMetadata } from "./toolEffects.js";
|
||||
|
||||
/** List live Cutter plugin bridge sessions. */
|
||||
export const cutterListInputSchema = z.strictObject({});
|
||||
|
||||
/** Execute a command in a selected live Cutter instance and observed document generation. */
|
||||
export const cutterCommandInputSchema = z.strictObject({
|
||||
session_id: z.string().uuid(),
|
||||
expected_generation: z.number().int().nonnegative(),
|
||||
command: z
|
||||
.string()
|
||||
.min(1)
|
||||
.refine(
|
||||
(value) => !/[\r\n]/u.test(value),
|
||||
"A single Rizin command line is required",
|
||||
),
|
||||
json: z.boolean().default(false),
|
||||
});
|
||||
|
||||
export const cutterSessionSchema = z.strictObject({
|
||||
session_id: z.string().uuid(),
|
||||
pid: z.number().int().positive(),
|
||||
document_generation: z.number().int().nonnegative(),
|
||||
current_file: z.string().nullable(),
|
||||
cutter_version: z.string().nullable(),
|
||||
identity_status: z.literal("partial"),
|
||||
});
|
||||
|
||||
export const cutterCommandResultSchema = z.strictObject({
|
||||
command: z.string(),
|
||||
output: jsonValueSchema.nullable(),
|
||||
execution_state: z.enum(["not_started", "complete", "failed", "unknown"]),
|
||||
error: z.string().nullable(),
|
||||
message: z.string().nullable(),
|
||||
output_truncated: z.boolean(),
|
||||
cutter_version: z.string().nullable(),
|
||||
current_file: z.string().nullable(),
|
||||
document_generation: z.number().int().nonnegative(),
|
||||
identity_status: z.literal("partial"),
|
||||
});
|
||||
|
||||
export const cutterDiscoverySchema = z.strictObject({
|
||||
sessions: z.array(cutterSessionSchema),
|
||||
bridge_directory: z.string(),
|
||||
discovery_status: z.enum([
|
||||
"sessions_found",
|
||||
"no_live_bridge_found",
|
||||
"bridge_directory_unavailable",
|
||||
"bridge_directory_insecure",
|
||||
"bridge_directory_capacity_exceeded",
|
||||
]),
|
||||
bridge_directory_security: z.enum([
|
||||
"private_verified",
|
||||
"not_private",
|
||||
"unverified_platform_acl",
|
||||
"not_checked",
|
||||
]),
|
||||
});
|
||||
|
||||
/** Live Cutter discovery and arbitrary Rizin command contracts. */
|
||||
export const CUTTER_TOOL_CONTRACTS = [
|
||||
{
|
||||
name: "list_cutter_sessions",
|
||||
...toolContractMetadata("list_cutter_sessions"),
|
||||
kind: "native-provider",
|
||||
description:
|
||||
"List live REA bridge sessions hosted by upstream Cutter instances, including observed document identity and bridge-directory security status. Discovery admits at most 256 descriptor candidates (16 MiB at the descriptor byte limit), reads and probes at most eight concurrently, and reports bridge_directory_capacity_exceeded rather than claiming no sessions when that budget is exceeded.",
|
||||
inputSchema: cutterListInputSchema,
|
||||
outputSchema: cutterDiscoverySchema,
|
||||
examples: [{ title: "List open Cutter instances", input: {} }],
|
||||
},
|
||||
{
|
||||
name: "cutter_command",
|
||||
...toolContractMetadata("cutter_command"),
|
||||
kind: "native-provider",
|
||||
description:
|
||||
"Run one arbitrary Rizin command in a selected live Cutter session. Commands may edit and persist analysis state or execute plugin-provided effects; completion uncertainty is returned as Evidence and must not be retried automatically. In a shared MCP server, this admitted command closes any active binary-analysis session; after its effects have settled, call open_binary before further analysis. Close an active Ghidra session explicitly first.",
|
||||
inputSchema: cutterCommandInputSchema,
|
||||
outputSchema: evidenceResultOf(cutterCommandResultSchema),
|
||||
examples: [
|
||||
{
|
||||
title: "Inspect active binary metadata",
|
||||
input: {
|
||||
session_id: "00000000-0000-4000-8000-000000000001",
|
||||
expected_generation: 0,
|
||||
command: "ij",
|
||||
json: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
] as const satisfies readonly ToolContract[];
|
||||
@@ -0,0 +1,113 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import type { ToolContract } from "./toolContractTypes.js";
|
||||
import { evidenceResultOf } from "./toolOutputSchemaPrimitives.js";
|
||||
import { toolContractMetadata } from "./toolEffects.js";
|
||||
|
||||
const sessionId = z.string().uuid();
|
||||
const gdbCommandResult = z.strictObject({
|
||||
command: z.string(),
|
||||
mi: z.string(),
|
||||
records: z.array(z.string()),
|
||||
console: z.string(),
|
||||
target: z.string(),
|
||||
log: z.string(),
|
||||
process_exit_observed: z.boolean().optional(),
|
||||
process_exit_code: z.number().int().nullable().optional(),
|
||||
process_exit_signal: z.string().nullable().optional(),
|
||||
output_truncated: z.boolean(),
|
||||
output_invalid_utf8: z.literal(true).optional(),
|
||||
completion_status: z.enum(["complete", "unknown"]),
|
||||
});
|
||||
|
||||
/** Persistent GDB/MI session contracts hosted by the existing REA MCP server. */
|
||||
export const GDB_TOOL_CONTRACTS = [
|
||||
{
|
||||
name: "start_gdb_session",
|
||||
...toolContractMetadata("start_gdb_session"),
|
||||
kind: "native-provider",
|
||||
description:
|
||||
"Start a BYO GDB process in MI3 mode when available, suppress startup files, and disable auto-loading. Persistent GDB sessions are available only on Linux, where REA can verify live inferior output descriptors through /proc before detaching; macOS descriptor verification is not implemented, and Windows Job Object cleanup can terminate debugger inferiors. Optionally set inferior_tty to a caller-selected terminal for future inferior runs; REA verifies the setting through GDB/MI before the session becomes ready. The selected terminal must remain available after GDB exits. A live inferior can be detached during close only while this durable output sink remains verified. The session persists in this REA MCP server process and receives an explicit session ID.",
|
||||
inputSchema: z.strictObject({
|
||||
inferior_tty: z
|
||||
.string()
|
||||
.min(1)
|
||||
.optional()
|
||||
.describe(
|
||||
"Caller-selected terminal path for future inferior runs. GDB must report the exact configured path before the session is ready.",
|
||||
),
|
||||
}),
|
||||
outputSchema: z.strictObject({
|
||||
session_id: sessionId,
|
||||
mi_version: z.enum(["mi3", "mi2"]),
|
||||
}),
|
||||
examples: [{ title: "Start GDB", input: {} }],
|
||||
},
|
||||
{
|
||||
name: "gdb_console",
|
||||
...toolContractMetadata("gdb_console"),
|
||||
kind: "native-provider",
|
||||
description:
|
||||
"Run one unrestricted GDB CLI command through MI interpreter-exec console and return raw MI, console, target, and log records inline with Evidence. A command timeout preserves collected records with completion_status unknown and stops the owned debugger session. Commands can execute shell, alter target state, access files, and use network. Closing the debugger never implies target termination. In a shared MCP server, this admitted command closes any active binary-analysis session; after its effects have settled, call open_binary before further analysis. Close an active Ghidra session explicitly first.",
|
||||
inputSchema: z.strictObject({
|
||||
session_id: sessionId,
|
||||
command: z.string().min(1),
|
||||
}),
|
||||
outputSchema: evidenceResultOf(gdbCommandResult),
|
||||
examples: [
|
||||
{
|
||||
title: "Inspect loaded symbols",
|
||||
input: {
|
||||
session_id: "00000000-0000-4000-8000-000000000001",
|
||||
command: "info functions",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "gdb_session_status",
|
||||
...toolContractMetadata("gdb_session_status"),
|
||||
kind: "native-provider",
|
||||
description:
|
||||
"Return the observed lifecycle state and bounded recent MI records and process diagnostics for one persistent REA GDB session; truncation and invalid-UTF-8 fields report discarded or lossy data.",
|
||||
inputSchema: z.strictObject({ session_id: sessionId }),
|
||||
outputSchema: z.strictObject({
|
||||
session_id: sessionId,
|
||||
mi_version: z.enum(["mi3", "mi2"]),
|
||||
state: z.enum(["ready", "closed"]),
|
||||
recent_mi_records: z.array(z.string()),
|
||||
recent_mi_records_truncated: z.boolean(),
|
||||
mi_invalid_utf8: z.boolean(),
|
||||
stderr: z.string(),
|
||||
stderr_observed_bytes: z.number().int().nonnegative(),
|
||||
stderr_truncated: z.boolean(),
|
||||
stderr_invalid_utf8: z.boolean(),
|
||||
diagnostics_truncated: z.boolean(),
|
||||
inferior_tty: z.string().nullable(),
|
||||
inferior_tty_verified: z.boolean(),
|
||||
}),
|
||||
examples: [
|
||||
{
|
||||
title: "Check GDB session",
|
||||
input: { session_id: "00000000-0000-4000-8000-000000000001" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "close_gdb_session",
|
||||
...toolContractMetadata("close_gdb_session"),
|
||||
kind: "native-provider",
|
||||
description:
|
||||
"Close the owned GDB process after verifying the inferior state. When one live thread group has a verified durable output TTY, REA automatically detaches it, which may resume target execution; otherwise close reports that a disposition is required. REA never kills attached or remote targets as part of debugger cleanup. In a shared MCP server, this admitted close retires any active binary-analysis session; after target effects have settled, call open_binary before further analysis. Close an active Ghidra session explicitly first.",
|
||||
inputSchema: z.strictObject({ session_id: sessionId }),
|
||||
outputSchema: z.strictObject({
|
||||
target_state: z.enum(["none_observed", "unknown"]),
|
||||
}),
|
||||
examples: [
|
||||
{
|
||||
title: "Close GDB session",
|
||||
input: { session_id: "00000000-0000-4000-8000-000000000001" },
|
||||
},
|
||||
],
|
||||
},
|
||||
] as const satisfies readonly ToolContract[];
|
||||
@@ -0,0 +1,61 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
objdumpInputSchema,
|
||||
rizinInputSchema,
|
||||
} from "../domain/reverseEngineering.js";
|
||||
import type { ToolContract } from "./toolContractTypes.js";
|
||||
import { evidenceResultOf } from "./toolOutputSchemaPrimitives.js";
|
||||
import { toolContractMetadata } from "./toolEffects.js";
|
||||
|
||||
const commandObservationSchema = z.strictObject({
|
||||
stdout: z.string(),
|
||||
stderr: z.string(),
|
||||
exit_code: z.union([z.string(), z.number(), z.null()]),
|
||||
signal: z.string().nullable(),
|
||||
output_truncated: z.boolean(),
|
||||
completion_status: z.enum(["complete", "unknown"]),
|
||||
artifact_sha256_after: z
|
||||
.string()
|
||||
.regex(/^[a-f0-9]{64}$/u)
|
||||
.nullable(),
|
||||
artifact_changed: z.boolean().nullable(),
|
||||
});
|
||||
|
||||
/** Caller-selected GNU objdump and Rizin observations. */
|
||||
export const REVERSE_ENGINEERING_TOOL_CONTRACTS = [
|
||||
{
|
||||
name: "inspect_with_objdump",
|
||||
...toolContractMetadata("inspect_with_objdump"),
|
||||
kind: "native-provider",
|
||||
description:
|
||||
"Run caller-selected GNU objdump inspection against a local artifact, or request operation=architectures without a path to list BFD targets advertised by the installed binary. Preserves decoded stdout and stderr inline with SHA-256 Evidence for artifact inspections, up to a shared 16 MiB combined limit; invalid UTF-8 bytes are represented by replacement characters and flagged in Evidence limitations. output_truncated reports output beyond that limit and completion_status is unknown if buffer enforcement terminated the process. The raw output is authoritative and REA does not claim a stable normalized format. DWARF does not follow local debug links by default. Set follow_debug_links to true to follow them; identities of external debug files are not bound to Evidence. REA clears DEBUGINFOD_URLS for objdump so DWARF processing does not make debuginfod network requests. Architecture output is subjectless and does not establish support for any artifact.",
|
||||
inputSchema: objdumpInputSchema,
|
||||
outputSchema: evidenceResultOf(commandObservationSchema),
|
||||
examples: [
|
||||
{
|
||||
title: "Disassemble code sections",
|
||||
input: { path: "/tmp/program", operation: "disassemble" },
|
||||
},
|
||||
{
|
||||
title: "List advertised BFD targets",
|
||||
input: { operation: "architectures" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "execute_rizin_command",
|
||||
...toolContractMetadata("execute_rizin_command"),
|
||||
kind: "native-provider",
|
||||
description:
|
||||
"Execute one caller-selected Rizin command against a local artifact using the Rizin CLI profile that suppresses user settings and scripts while retaining plugins. Preserves decoded stdout and stderr inline with SHA-256 Evidence, up to a shared 16 MiB combined limit; invalid UTF-8 bytes are represented by replacement characters and flagged in Evidence limitations. output_truncated reports output beyond that limit and completion_status is unknown if buffer enforcement terminated the process. Rizin commands and retained plugins can execute foreign code, mutate files, access the network, and read files outside the selected artifact; identities and contents of those external inputs are not bound to Evidence. Command effects depend on the Rizin command and loaded plugins; JSON is command-specific and raw output remains authoritative. In a shared MCP server, this admitted command closes any active binary-analysis session; after its effects have settled, call open_binary before further analysis. Close an active Ghidra session explicitly first.",
|
||||
inputSchema: rizinInputSchema,
|
||||
outputSchema: evidenceResultOf(commandObservationSchema),
|
||||
examples: [
|
||||
{
|
||||
title: "Inspect binary information",
|
||||
input: { path: "/tmp/program", command: "iI" },
|
||||
},
|
||||
],
|
||||
},
|
||||
] as const satisfies readonly ToolContract[];
|
||||
@@ -0,0 +1,107 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import type { ToolContract } from "./toolContractTypes.js";
|
||||
import { evidenceResultOf } from "./toolOutputSchemaPrimitives.js";
|
||||
import { toolContractMetadata } from "./toolEffects.js";
|
||||
|
||||
const sessionId = z.string().uuid();
|
||||
const commandResult = z.strictObject({
|
||||
command: z.string(),
|
||||
output: z.string(),
|
||||
output_scope: z.literal("command_and_interleaved_session_output"),
|
||||
output_truncated: z.boolean(),
|
||||
output_invalid_utf8: z.literal(true).optional(),
|
||||
completion_status: z.enum(["complete", "unknown"]),
|
||||
backend: z.string().nullable(),
|
||||
});
|
||||
|
||||
/** Persistent Rizin debugger session contracts. */
|
||||
export const RIZIN_DEBUG_TOOL_CONTRACTS = [
|
||||
{
|
||||
name: "start_rizin_debug_session",
|
||||
...toolContractMetadata("start_rizin_debug_session"),
|
||||
kind: "native-provider",
|
||||
description:
|
||||
"Start a persistent Rizin debugger session using `-N` to suppress user settings and scripts while retaining plugins, `-0` for NUL framing, and `-d` with an optional caller-selected IO debugger backend. Requires a caller-selected Linux TTY for inferior stdout and stderr; the target may write directly to that terminal while running and after detach. REA verifies both destinations and `dbg.exitkills=false` before detaching. Backend and target capabilities depend on the installed build. The session lives in this REA MCP process. Persistent sessions require Linux `/proc` descriptor readback. In a shared MCP server, starting this session retires any active binary-analysis session; after target effects have settled, call open_binary before further analysis. Close an active Ghidra session explicitly first.",
|
||||
inputSchema: z.strictObject({
|
||||
path: z.string().min(1),
|
||||
inferior_tty: z.string().min(1),
|
||||
backend: z.string().min(1).optional(),
|
||||
}),
|
||||
outputSchema: z.strictObject({
|
||||
session_id: sessionId,
|
||||
backend: z.string().nullable(),
|
||||
}),
|
||||
examples: [
|
||||
{
|
||||
title: "Start Rizin debugger",
|
||||
input: { path: "/tmp/program", inferior_tty: "/dev/pts/7" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "rizin_debug_command",
|
||||
...toolContractMetadata("rizin_debug_command"),
|
||||
kind: "native-provider",
|
||||
description:
|
||||
"Execute one unrestricted command in a persistent Rizin debugger session. Returns raw output inline with Evidence, including backend output interleaved before the completion marker. If the command exceeds the output or frame limit, `output_truncated` is true and `completion_status` is `unknown`; retained partial output is returned and the owned debugger session is stopped. Unsolicited frames already queued before the command make the session unavailable because correlation is ambiguous. Commands can access local files, shell, network, plugins, and target state; effects depend on the active backend and target. In a shared MCP server, this admitted command closes any active binary-analysis session; after its effects have settled, call open_binary before further analysis. Close an active Ghidra session explicitly first.",
|
||||
inputSchema: z.strictObject({
|
||||
session_id: sessionId,
|
||||
command: z
|
||||
.string()
|
||||
.min(1)
|
||||
.refine(
|
||||
(value) => !/[\r\n]/u.test(value),
|
||||
"A single command line is required",
|
||||
),
|
||||
}),
|
||||
outputSchema: evidenceResultOf(commandResult),
|
||||
examples: [
|
||||
{
|
||||
title: "Inspect debugger state",
|
||||
input: {
|
||||
session_id: "00000000-0000-4000-8000-000000000001",
|
||||
command: "dr",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "rizin_debug_session_status",
|
||||
...toolContractMetadata("rizin_debug_session_status"),
|
||||
kind: "native-provider",
|
||||
description:
|
||||
"Return observed lifecycle state and bounded recent NUL-framed output and process diagnostics for one Rizin debugger session; truncation fields report discarded data, and output_invalid_utf8 reports whether invalid UTF-8 has been observed in session frame bytes; recent output may contain replacement characters.",
|
||||
inputSchema: z.strictObject({ session_id: sessionId }),
|
||||
outputSchema: z.strictObject({
|
||||
session_id: sessionId,
|
||||
state: z.enum(["ready", "closed"]),
|
||||
inferior_tty: z.string(),
|
||||
recent_output: z.array(z.string()),
|
||||
recent_output_truncated: z.boolean(),
|
||||
diagnostics_truncated: z.boolean(),
|
||||
output_invalid_utf8: z.boolean(),
|
||||
}),
|
||||
examples: [
|
||||
{
|
||||
title: "Check Rizin session",
|
||||
input: { session_id: "00000000-0000-4000-8000-000000000001" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "close_rizin_debug_session",
|
||||
...toolContractMetadata("close_rizin_debug_session"),
|
||||
kind: "native-provider",
|
||||
description:
|
||||
"Close the REA-owned Rizin debugger only after verifying no inferior remains or detaching one with a verified caller-selected TTY output sink. A detached target may continue running and write to that TTY; target state is otherwise reported as unknown. REA never signals an inferior based on PID or path. In a shared MCP server, this admitted close retires any active binary-analysis session; after target effects have settled, call open_binary before further analysis. Close an active Ghidra session explicitly first.",
|
||||
inputSchema: z.strictObject({ session_id: sessionId }),
|
||||
outputSchema: z.strictObject({ target_state: z.literal("unknown") }),
|
||||
examples: [
|
||||
{
|
||||
title: "Close Rizin debugger",
|
||||
input: { session_id: "00000000-0000-4000-8000-000000000001" },
|
||||
},
|
||||
],
|
||||
},
|
||||
] as const satisfies readonly ToolContract[];
|
||||
@@ -82,6 +82,11 @@ export const TOOL_EXAMPLE_OVERRIDES: Readonly<
|
||||
left_bundle_path: "/tmp/left-evidence.json",
|
||||
right_bundle_path: "/tmp/right-evidence.json",
|
||||
},
|
||||
inspect_with_objdump: {
|
||||
path: "/tmp/program",
|
||||
operation: "disassemble",
|
||||
},
|
||||
execute_rizin_command: { path: "/tmp/program", command: "iI" },
|
||||
list_frida_devices: {},
|
||||
list_frida_processes: { device_id: "local" },
|
||||
start_frida_session: { mode: "attach", device_id: "local", pid: 1234 },
|
||||
|
||||
@@ -29,6 +29,10 @@ import { JAVASCRIPT_RECOVERY_TOOL_CONTRACTS } from "./javascript/javascriptRecov
|
||||
import { OFFICIAL_TOOL_CONTRACTS } from "./officialToolContracts.js";
|
||||
import { ENHANCED_TOOL_CONTRACTS } from "./enhancedToolContracts.js";
|
||||
import { SESSION_TOOL_CONTRACTS } from "./sessionToolContracts.js";
|
||||
import { CUTTER_TOOL_CONTRACTS } from "./cutterToolContracts.js";
|
||||
import { REVERSE_ENGINEERING_TOOL_CONTRACTS } from "./reverseEngineeringToolContracts.js";
|
||||
import { GDB_TOOL_CONTRACTS } from "./gdbToolContracts.js";
|
||||
import { RIZIN_DEBUG_TOOL_CONTRACTS } from "./rizinDebugToolContracts.js";
|
||||
import { FRIDA_TOOL_CONTRACTS } from "./fridaToolContracts.js";
|
||||
|
||||
/** Complete ordered public inventory used by registration and verification. */
|
||||
@@ -64,6 +68,10 @@ export const TOOL_CONTRACTS = [
|
||||
...WEB_NETWORK_CAPTURE_TOOL_CONTRACTS,
|
||||
...JAVASCRIPT_RECOVERY_TOOL_CONTRACTS,
|
||||
...SESSION_TOOL_CONTRACTS,
|
||||
...CUTTER_TOOL_CONTRACTS,
|
||||
...REVERSE_ENGINEERING_TOOL_CONTRACTS,
|
||||
...GDB_TOOL_CONTRACTS,
|
||||
...RIZIN_DEBUG_TOOL_CONTRACTS,
|
||||
...FRIDA_TOOL_CONTRACTS,
|
||||
] as const;
|
||||
|
||||
|
||||
@@ -13,6 +13,10 @@ const EXECUTION_WORDING =
|
||||
/\b(?:run|runs|running|launch|launches|launching|execute|executes|executing|execution|start|starts|install|installs|uninstall|uninstalls)\b/iu;
|
||||
|
||||
const EXECUTION_WORDING_EXCEPTIONS: Readonly<Record<string, string>> = {
|
||||
inspect_with_objdump:
|
||||
"runs a caller-selected inspection tool, not target code",
|
||||
start_gdb_session:
|
||||
"starts a debugger session; execution is attributed to gdb_console",
|
||||
start_frida_session:
|
||||
"spawned targets remain suspended until resume_frida_session",
|
||||
inspect_native_load_image: "states that it does not execute the target",
|
||||
@@ -129,14 +133,21 @@ describe("tool effects", () => {
|
||||
"capture_electron_scenario",
|
||||
"capture_native_ui_scenario",
|
||||
"capture_process_scenario",
|
||||
"close_gdb_session",
|
||||
"close_rizin_debug_session",
|
||||
"cutter_command",
|
||||
"execute_rizin_command",
|
||||
"gdb_console",
|
||||
"install_adb_package",
|
||||
"instrument_with_frida",
|
||||
"load_frida_script",
|
||||
"observe_native_calls",
|
||||
"push_adb_file",
|
||||
"resume_frida_session",
|
||||
"rizin_debug_command",
|
||||
"start_adb_activity",
|
||||
"start_adb_app",
|
||||
"start_rizin_debug_session",
|
||||
"stop_adb_app",
|
||||
"uninstall_adb_package",
|
||||
]);
|
||||
@@ -169,6 +180,17 @@ describe("tool effects", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("marks automatic GDB detach as destructive target execution", () => {
|
||||
const effects = TOOL_EFFECTS["close_gdb_session"];
|
||||
if (effects === undefined)
|
||||
throw new Error("close_gdb_session is unaudited");
|
||||
expect(effects).toMatchObject({
|
||||
mutatesTarget: true,
|
||||
executesForeignCode: true,
|
||||
});
|
||||
expect(annotationsFromEffects(effects).destructiveHint).toBe(true);
|
||||
});
|
||||
|
||||
it("publishes the advertised annotations derived from canonical effects", () => {
|
||||
for (const contract of TOOL_CONTRACTS)
|
||||
expect(contract.annotations, contract.name).toEqual(
|
||||
|
||||
@@ -53,6 +53,78 @@ const sessionEvidence = effects({ mutatesSession: true });
|
||||
|
||||
/** Explicit effect audit for every public tool. */
|
||||
export const TOOL_EFFECTS: Readonly<Record<string, ToolEffects>> = {
|
||||
list_cutter_sessions: effects({ accessesNetwork: true }),
|
||||
cutter_command: effects({
|
||||
mutatesTarget: true,
|
||||
executesForeignCode: true,
|
||||
mutatesSession: true,
|
||||
writesFilesystem: true,
|
||||
launchesProcess: true,
|
||||
accessesNetwork: true,
|
||||
changesUiState: true,
|
||||
idempotent: false,
|
||||
}),
|
||||
inspect_with_objdump: effects({
|
||||
mutatesSession: true,
|
||||
launchesProcess: true,
|
||||
}),
|
||||
start_gdb_session: effects({
|
||||
launchesProcess: true,
|
||||
mutatesSession: true,
|
||||
idempotent: false,
|
||||
}),
|
||||
gdb_console: effects({
|
||||
mutatesTarget: true,
|
||||
mutatesSession: true,
|
||||
writesFilesystem: true,
|
||||
launchesProcess: true,
|
||||
accessesNetwork: true,
|
||||
executesForeignCode: true,
|
||||
idempotent: false,
|
||||
}),
|
||||
gdb_session_status: effects(),
|
||||
start_rizin_debug_session: effects({
|
||||
mutatesTarget: true,
|
||||
mutatesSession: true,
|
||||
launchesProcess: true,
|
||||
executesForeignCode: true,
|
||||
idempotent: false,
|
||||
}),
|
||||
rizin_debug_command: effects({
|
||||
mutatesTarget: true,
|
||||
mutatesSession: true,
|
||||
executesForeignCode: true,
|
||||
writesFilesystem: true,
|
||||
launchesProcess: true,
|
||||
accessesNetwork: true,
|
||||
changesUiState: true,
|
||||
mayDiscardData: true,
|
||||
idempotent: false,
|
||||
}),
|
||||
rizin_debug_session_status: effects({ mutatesSession: true }),
|
||||
close_rizin_debug_session: effects({
|
||||
mutatesSession: true,
|
||||
launchesProcess: true,
|
||||
mutatesTarget: true,
|
||||
executesForeignCode: true,
|
||||
}),
|
||||
close_gdb_session: effects({
|
||||
launchesProcess: true,
|
||||
mutatesSession: true,
|
||||
mutatesTarget: true,
|
||||
executesForeignCode: true,
|
||||
idempotent: false,
|
||||
}),
|
||||
execute_rizin_command: effects({
|
||||
mutatesTarget: true,
|
||||
mutatesSession: true,
|
||||
writesFilesystem: true,
|
||||
launchesProcess: true,
|
||||
accessesNetwork: true,
|
||||
mayDiscardData: true,
|
||||
executesForeignCode: true,
|
||||
idempotent: false,
|
||||
}),
|
||||
inspect_go_binary: evidence,
|
||||
inspect_rust_binary: evidence,
|
||||
inspect_sqlite_database: effects({
|
||||
|
||||
@@ -0,0 +1,891 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { constants as fsConstants } from "node:fs";
|
||||
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
||||
import { createConnection } from "node:net";
|
||||
import { lstat, open, opendir } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
AnalysisCapabilityUnavailableError,
|
||||
AnalysisOutputError,
|
||||
AnalysisResourceConstraintError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import { requireWindowsNativeAuthority } from "../windows/WindowsNativeLoader.js";
|
||||
import { readBoundedFileBytes } from "../process/BoundedFileBytes.js";
|
||||
|
||||
const descriptorSchema = z.object({
|
||||
session_id: z.string().uuid(),
|
||||
pid: z.number().int().positive(),
|
||||
host: z.literal("127.0.0.1"),
|
||||
port: z.number().int().min(1).max(65535),
|
||||
token: z.string().min(20),
|
||||
document_generation: z.number().int().nonnegative(),
|
||||
current_file: z.string().nullable(),
|
||||
cutter_version: z.string().nullable(),
|
||||
identity_status: z.literal("partial"),
|
||||
});
|
||||
|
||||
export const CUTTER_PROVIDER_IDENTITY = {
|
||||
id: "cutter.python-plugin",
|
||||
name: "Cutter Python plugin",
|
||||
version: null,
|
||||
} as const;
|
||||
|
||||
const responseSchema = z.object({
|
||||
ok: z.boolean(),
|
||||
session_id: z.string().uuid().optional(),
|
||||
output: z.unknown().optional(),
|
||||
current_file: z.string().nullable().optional(),
|
||||
cutter_version: z.string().nullable().optional(),
|
||||
document_generation: z.number().int().nonnegative().optional(),
|
||||
identity_status: z.literal("partial").optional(),
|
||||
error: z.string().optional(),
|
||||
message: z.string().optional(),
|
||||
execution_state: z
|
||||
.enum(["not_started", "running", "complete", "failed", "unknown"])
|
||||
.optional(),
|
||||
output_truncated: z.boolean().optional(),
|
||||
completed_commands: z
|
||||
.array(
|
||||
z.object({
|
||||
request_id: z.string().uuid(),
|
||||
execution_state: z.enum(["not_started", "complete", "failed"]),
|
||||
}),
|
||||
)
|
||||
.max(64)
|
||||
.optional(),
|
||||
completed_connection: z
|
||||
.string()
|
||||
.regex(/^[0-9a-f]{16}$/)
|
||||
.optional(),
|
||||
});
|
||||
const acknowledgementSchema = z.object({
|
||||
ok: z.literal(true),
|
||||
acknowledged: z.boolean(),
|
||||
});
|
||||
const MAX_DESCRIPTOR_BYTES = 64 * 1024;
|
||||
const MAX_FRAME_BYTES = 16 * 1024 * 1024;
|
||||
const FRAME_PAGE_BYTES = 64 * 1024;
|
||||
// Bound admitted descriptor input bytes and concurrent file/socket handles.
|
||||
const MAX_DESCRIPTOR_CANDIDATES = MAX_FRAME_BYTES / MAX_DESCRIPTOR_BYTES;
|
||||
const DISCOVERY_CONCURRENCY = 8;
|
||||
type AuthenticatedRequestBytes = string & {
|
||||
readonly __authenticatedRequestBytes: unique symbol;
|
||||
};
|
||||
|
||||
const serializeRequest = (
|
||||
descriptor: z.infer<typeof descriptorSchema>,
|
||||
request: Readonly<Record<string, unknown>>,
|
||||
): AuthenticatedRequestBytes => {
|
||||
const requestBytes = JSON.stringify({
|
||||
...request,
|
||||
session_id: descriptor.session_id,
|
||||
});
|
||||
let frameBytes =
|
||||
Buffer.byteLength(JSON.stringify({ request: "", proof: "0".repeat(64) })) +
|
||||
Buffer.byteLength(requestBytes);
|
||||
for (const character of requestBytes)
|
||||
if (character === '"' || character === "\\") frameBytes += 1;
|
||||
if (frameBytes > MAX_FRAME_BYTES)
|
||||
throw new AnalysisResourceConstraintError(
|
||||
"cutter_command",
|
||||
"transport",
|
||||
"Cutter request exceeds its authenticated frame limit; no command was sent.",
|
||||
{ frame_bytes: frameBytes, max_frame_bytes: MAX_FRAME_BYTES },
|
||||
);
|
||||
return requestBytes as AuthenticatedRequestBytes;
|
||||
};
|
||||
|
||||
class CutterBridgeResponseFrames {
|
||||
#parts: Buffer[] = [];
|
||||
#page = Buffer.allocUnsafe(FRAME_PAGE_BYTES);
|
||||
#pageLength = 0;
|
||||
#contentBytes = 0;
|
||||
#framesRead = 0;
|
||||
|
||||
push(chunk: Buffer, onFrame: (line: Buffer) => boolean): void {
|
||||
let offset = 0;
|
||||
while (offset < chunk.length) {
|
||||
const delimiter = chunk.indexOf(0x0a, offset);
|
||||
const end = delimiter < 0 ? chunk.length : delimiter;
|
||||
const segment = chunk.subarray(offset, end);
|
||||
if (
|
||||
this.#contentBytes + segment.length + (delimiter < 0 ? 1 : 0) >
|
||||
MAX_FRAME_BYTES
|
||||
)
|
||||
throw new Error(
|
||||
"Cutter bridge response exceeded its framed transport limit",
|
||||
);
|
||||
this.#append(segment);
|
||||
if (delimiter < 0) return;
|
||||
if (++this.#contentBytes > MAX_FRAME_BYTES)
|
||||
throw new Error(
|
||||
"Cutter bridge response exceeded its framed transport limit",
|
||||
);
|
||||
if (this.#pageLength > 0)
|
||||
this.#parts.push(this.#page.subarray(0, this.#pageLength));
|
||||
const line = Buffer.concat(this.#parts, this.#contentBytes - 1);
|
||||
this.#reset();
|
||||
if (++this.#framesRead > 2)
|
||||
throw new Error("Cutter bridge sent more than two protocol frames");
|
||||
offset = delimiter + 1;
|
||||
if (!onFrame(line)) return;
|
||||
}
|
||||
}
|
||||
|
||||
#append(segment: Buffer): void {
|
||||
let copied = 0;
|
||||
while (copied < segment.length) {
|
||||
const length = Math.min(
|
||||
segment.length - copied,
|
||||
FRAME_PAGE_BYTES - this.#pageLength,
|
||||
);
|
||||
segment.copy(this.#page, this.#pageLength, copied, copied + length);
|
||||
this.#pageLength += length;
|
||||
this.#contentBytes += length;
|
||||
copied += length;
|
||||
if (this.#pageLength === FRAME_PAGE_BYTES) {
|
||||
this.#parts.push(this.#page);
|
||||
this.#page = Buffer.allocUnsafe(FRAME_PAGE_BYTES);
|
||||
this.#pageLength = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#reset(): void {
|
||||
this.#parts = [];
|
||||
this.#page = Buffer.allocUnsafe(FRAME_PAGE_BYTES);
|
||||
this.#pageLength = 0;
|
||||
this.#contentBytes = 0;
|
||||
}
|
||||
}
|
||||
|
||||
const mapDiscovery = async <Input, Output>(
|
||||
inputs: readonly Input[],
|
||||
operation: (input: Input) => Promise<Output>,
|
||||
): Promise<Output[]> => {
|
||||
const results = new Array<Output>(inputs.length);
|
||||
let next = 0;
|
||||
await Promise.all(
|
||||
Array.from(
|
||||
{ length: Math.min(inputs.length, DISCOVERY_CONCURRENCY) },
|
||||
async () => {
|
||||
while (next < inputs.length) {
|
||||
const index = next++;
|
||||
results[index] = await operation(inputs[index]!);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
return results;
|
||||
};
|
||||
export type CutterBridgeExecutionState =
|
||||
| "not_started"
|
||||
| "complete"
|
||||
| "failed"
|
||||
| "unknown";
|
||||
export interface CutterBridgeExecution {
|
||||
readonly requestId: string;
|
||||
readonly output: unknown;
|
||||
readonly currentFile: string | null;
|
||||
readonly documentGeneration: number;
|
||||
readonly identityStatus: "partial";
|
||||
readonly cutterVersion: string | null;
|
||||
readonly executionState: CutterBridgeExecutionState;
|
||||
readonly error: string | null;
|
||||
readonly message: string | null;
|
||||
readonly outputTruncated: boolean;
|
||||
readonly completionToken?: string;
|
||||
}
|
||||
|
||||
class CutterBridgeRequestFailure extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly requestSent: boolean,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
export interface WindowsCutterDescriptorReader {
|
||||
verifyDirectory(directory: string): boolean;
|
||||
readDescriptor(directory: string, name: string, maxBytes: number): Buffer;
|
||||
}
|
||||
interface CutterBridgeClientOptions {
|
||||
readonly platform?: NodeJS.Platform;
|
||||
readonly windowsPrivateReader?: WindowsCutterDescriptorReader;
|
||||
}
|
||||
|
||||
export type CutterBridgeSession = Omit<
|
||||
z.infer<typeof descriptorSchema>,
|
||||
"token" | "host" | "port"
|
||||
>;
|
||||
export interface CutterBridgeDiscovery {
|
||||
readonly sessions: readonly CutterBridgeSession[];
|
||||
readonly bridge_directory: string;
|
||||
readonly discovery_status:
|
||||
| "sessions_found"
|
||||
| "no_live_bridge_found"
|
||||
| "bridge_directory_unavailable"
|
||||
| "bridge_directory_insecure"
|
||||
| "bridge_directory_capacity_exceeded";
|
||||
readonly bridge_directory_security:
|
||||
| "private_verified"
|
||||
| "not_private"
|
||||
| "unverified_platform_acl"
|
||||
| "not_checked";
|
||||
}
|
||||
|
||||
/** Discovers and communicates with REA Python bridge plugins in live Cutter processes. */
|
||||
export class CutterBridgeClient {
|
||||
readonly #directory: string;
|
||||
readonly #platform: NodeJS.Platform;
|
||||
readonly #windowsPrivateReader: WindowsCutterDescriptorReader | undefined;
|
||||
readonly #pendingAcknowledgements = new Map<string, Set<string>>();
|
||||
|
||||
constructor(
|
||||
environment: Readonly<NodeJS.ProcessEnv> = process.env,
|
||||
options: CutterBridgeClientOptions = {},
|
||||
) {
|
||||
const configured = environment.REA_CUTTER_BRIDGE_DIR;
|
||||
const localAppData = environment.LOCALAPPDATA;
|
||||
this.#platform = options.platform ?? process.platform;
|
||||
this.#windowsPrivateReader =
|
||||
options.windowsPrivateReader ??
|
||||
(this.#platform === "win32"
|
||||
? {
|
||||
verifyDirectory: (directory) =>
|
||||
z
|
||||
.object({ privateDacl: z.literal(true) })
|
||||
.safeParse(
|
||||
requireWindowsNativeAuthority().call(
|
||||
"cutter_bridge_verify_directory",
|
||||
[directory],
|
||||
),
|
||||
).success,
|
||||
readDescriptor: (directory, name, maxBytes) =>
|
||||
z
|
||||
.instanceof(Buffer)
|
||||
.parse(
|
||||
requireWindowsNativeAuthority().call(
|
||||
"cutter_bridge_read_descriptor",
|
||||
[directory, name, maxBytes],
|
||||
),
|
||||
),
|
||||
}
|
||||
: undefined);
|
||||
this.#directory =
|
||||
configured ??
|
||||
(this.#platform === "win32" && localAppData
|
||||
? join(localAppData, "REA", "CutterBridge")
|
||||
: join(homedir(), ".cache", "rea", "cutter-bridge"));
|
||||
}
|
||||
|
||||
async listSessions(): Promise<CutterBridgeDiscovery> {
|
||||
const loaded = await this.#readDescriptors();
|
||||
if (loaded.error !== undefined)
|
||||
return {
|
||||
sessions: [],
|
||||
bridge_directory: this.#directory,
|
||||
discovery_status: loaded.error,
|
||||
bridge_directory_security: loaded.security,
|
||||
};
|
||||
const descriptors = await mapDiscovery(
|
||||
loaded.descriptors,
|
||||
async (descriptor) => {
|
||||
try {
|
||||
const status = responseSchema.safeParse(
|
||||
await this.#request(
|
||||
descriptor,
|
||||
serializeRequest(descriptor, {
|
||||
kind: "status",
|
||||
session_id: descriptor.session_id,
|
||||
}),
|
||||
),
|
||||
);
|
||||
if (
|
||||
!status.success ||
|
||||
!status.data.ok ||
|
||||
status.data.session_id !== descriptor.session_id
|
||||
)
|
||||
return undefined;
|
||||
return {
|
||||
session_id: descriptor.session_id,
|
||||
pid: descriptor.pid,
|
||||
document_generation:
|
||||
status.data.document_generation ?? descriptor.document_generation,
|
||||
current_file:
|
||||
status.data.current_file === undefined
|
||||
? descriptor.current_file
|
||||
: status.data.current_file,
|
||||
cutter_version:
|
||||
status.data.cutter_version ?? descriptor.cutter_version,
|
||||
identity_status: "partial" as const,
|
||||
};
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
},
|
||||
);
|
||||
const sessions = descriptors.filter(
|
||||
(value): value is CutterBridgeSession => value !== undefined,
|
||||
);
|
||||
return {
|
||||
sessions,
|
||||
bridge_directory: this.#directory,
|
||||
discovery_status:
|
||||
sessions.length > 0 ? "sessions_found" : "no_live_bridge_found",
|
||||
bridge_directory_security: loaded.security,
|
||||
};
|
||||
}
|
||||
|
||||
async execute(input: {
|
||||
readonly sessionId: string;
|
||||
readonly expectedGeneration: number;
|
||||
readonly command: string;
|
||||
readonly json: boolean;
|
||||
readonly signal?: AbortSignal;
|
||||
}): Promise<CutterBridgeExecution> {
|
||||
input.signal?.throwIfAborted();
|
||||
const descriptor = await this.#find(input.sessionId);
|
||||
input.signal?.throwIfAborted();
|
||||
const requestId = randomUUID();
|
||||
const commandRequest = serializeRequest(descriptor, {
|
||||
kind: "command",
|
||||
request_id: requestId,
|
||||
expected_generation: input.expectedGeneration,
|
||||
command: input.command,
|
||||
json: input.json,
|
||||
});
|
||||
await this.#recoverCompletedCommands(descriptor, input.signal);
|
||||
await this.#retryPendingAcknowledgements(descriptor);
|
||||
input.signal?.throwIfAborted();
|
||||
let completionToken: string | undefined;
|
||||
let rawResponse: unknown;
|
||||
try {
|
||||
rawResponse = await this.#request(
|
||||
descriptor,
|
||||
commandRequest,
|
||||
input.signal,
|
||||
(ordinal) => {
|
||||
completionToken = ordinal;
|
||||
},
|
||||
);
|
||||
} catch (cause) {
|
||||
if (!(cause instanceof CutterBridgeRequestFailure) || !cause.requestSent)
|
||||
throw cause;
|
||||
return {
|
||||
requestId,
|
||||
output: null,
|
||||
currentFile: descriptor.current_file,
|
||||
documentGeneration: input.expectedGeneration,
|
||||
identityStatus: "partial",
|
||||
cutterVersion: descriptor.cutter_version,
|
||||
executionState: "unknown",
|
||||
error: "transport-response-missing",
|
||||
message: `${cause.message}; the command may have completed; do not retry automatically`,
|
||||
outputTruncated: false,
|
||||
...(completionToken === undefined ? {} : { completionToken }),
|
||||
};
|
||||
}
|
||||
const parsed = responseSchema.safeParse(rawResponse);
|
||||
if (!parsed.success)
|
||||
return {
|
||||
requestId,
|
||||
output: null,
|
||||
currentFile: descriptor.current_file,
|
||||
documentGeneration: input.expectedGeneration,
|
||||
identityStatus: "partial",
|
||||
cutterVersion: descriptor.cutter_version,
|
||||
executionState: "unknown",
|
||||
error: "malformed-bridge-response",
|
||||
message:
|
||||
"Cutter may have completed the command, but its response was invalid; do not retry automatically",
|
||||
outputTruncated: false,
|
||||
...(completionToken === undefined ? {} : { completionToken }),
|
||||
};
|
||||
const response = parsed.data;
|
||||
const executionState: CutterBridgeExecutionState = response.ok
|
||||
? response.execution_state === undefined ||
|
||||
response.execution_state === "complete"
|
||||
? "complete"
|
||||
: response.execution_state === "not_started"
|
||||
? "not_started"
|
||||
: "unknown"
|
||||
: response.execution_state === "failed"
|
||||
? "failed"
|
||||
: response.execution_state === "not_started"
|
||||
? "not_started"
|
||||
: "unknown";
|
||||
if (executionState !== "unknown")
|
||||
await this.#acknowledgeCompletion(descriptor, requestId);
|
||||
if (response.error === "active-document-generation-changed")
|
||||
throw new Error(
|
||||
"Cutter's active document generation changed. List sessions again and select the current generation.",
|
||||
);
|
||||
return {
|
||||
requestId,
|
||||
output: response.output ?? null,
|
||||
currentFile:
|
||||
response.current_file === undefined
|
||||
? descriptor.current_file
|
||||
: response.current_file,
|
||||
documentGeneration:
|
||||
response.document_generation ?? input.expectedGeneration,
|
||||
identityStatus: "partial",
|
||||
cutterVersion: response.cutter_version ?? descriptor.cutter_version,
|
||||
executionState,
|
||||
error: response.ok ? null : (response.error ?? "cutter-command-failed"),
|
||||
message: response.message ?? null,
|
||||
outputTruncated: response.output_truncated ?? false,
|
||||
...(completionToken === undefined ? {} : { completionToken }),
|
||||
};
|
||||
}
|
||||
|
||||
/** Confirm completion by exact receipt or authenticated connection watermark. */
|
||||
async confirmCommandCompletion(
|
||||
sessionId: string,
|
||||
requestId: string,
|
||||
completionToken?: string,
|
||||
): Promise<Result<boolean, AnalysisError>> {
|
||||
let descriptor: z.infer<typeof descriptorSchema>;
|
||||
try {
|
||||
descriptor = await this.#find(sessionId);
|
||||
} catch (cause) {
|
||||
return err(
|
||||
new AnalysisCapabilityUnavailableError(
|
||||
"cutter",
|
||||
"cutter_command",
|
||||
cause instanceof Error ? cause.message : "Cutter session unavailable",
|
||||
{ cause },
|
||||
),
|
||||
);
|
||||
}
|
||||
try {
|
||||
const raw = await this.#request(
|
||||
descriptor,
|
||||
serializeRequest(descriptor, {
|
||||
kind: "status",
|
||||
session_id: sessionId,
|
||||
}),
|
||||
);
|
||||
const parsed = responseSchema.safeParse(raw);
|
||||
if (
|
||||
!parsed.success ||
|
||||
!parsed.data.ok ||
|
||||
parsed.data.session_id !== sessionId
|
||||
)
|
||||
return err(
|
||||
new AnalysisOutputError(
|
||||
"cutter_command_status",
|
||||
"The bridge status response did not contain a valid session identity.",
|
||||
{ cause: parsed.success ? undefined : parsed.error },
|
||||
),
|
||||
);
|
||||
const completed = (parsed.data.completed_commands ?? []).some(
|
||||
(receipt) => receipt.request_id === requestId,
|
||||
);
|
||||
if (completed) await this.#acknowledgeCompletion(descriptor, requestId);
|
||||
return ok(
|
||||
completed ||
|
||||
(completionToken !== undefined &&
|
||||
parsed.data.completed_connection !== undefined &&
|
||||
parsed.data.completed_connection >= completionToken),
|
||||
);
|
||||
} catch (cause) {
|
||||
return err(
|
||||
new AnalysisCapabilityUnavailableError(
|
||||
"cutter",
|
||||
"cutter_command_status",
|
||||
cause instanceof Error ? cause.message : "Cutter status failed",
|
||||
{ cause },
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async #recoverCompletedCommands(
|
||||
descriptor: z.infer<typeof descriptorSchema>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
const raw = await this.#request(
|
||||
descriptor,
|
||||
serializeRequest(descriptor, {
|
||||
kind: "status",
|
||||
session_id: descriptor.session_id,
|
||||
}),
|
||||
signal,
|
||||
);
|
||||
const status = responseSchema.safeParse(raw);
|
||||
if (
|
||||
!status.success ||
|
||||
!status.data.ok ||
|
||||
status.data.session_id !== descriptor.session_id
|
||||
)
|
||||
throw new AnalysisOutputError(
|
||||
"cutter_command_status",
|
||||
"The bridge status response did not contain a valid session identity.",
|
||||
{ cause: status.success ? undefined : status.error },
|
||||
);
|
||||
if (status.data.completed_connection === undefined)
|
||||
throw new AnalysisOutputError(
|
||||
"cutter_command_status",
|
||||
"The bridge did not provide authenticated proof that earlier connections reached a terminal state.",
|
||||
);
|
||||
const pending =
|
||||
this.#pendingAcknowledgements.get(descriptor.session_id) ?? new Set();
|
||||
for (const receipt of status.data.completed_commands ?? [])
|
||||
pending.add(receipt.request_id);
|
||||
if (pending.size > 0)
|
||||
this.#pendingAcknowledgements.set(descriptor.session_id, pending);
|
||||
}
|
||||
|
||||
async #acknowledgeCompletion(
|
||||
descriptor: z.infer<typeof descriptorSchema>,
|
||||
requestId: string,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const raw = await this.#request(
|
||||
descriptor,
|
||||
serializeRequest(descriptor, {
|
||||
kind: "ack",
|
||||
request_id: requestId,
|
||||
}),
|
||||
);
|
||||
const acknowledged = acknowledgementSchema.safeParse(raw);
|
||||
if (!acknowledged.success)
|
||||
throw new Error(
|
||||
"Cutter did not confirm the completion receipt acknowledgement.",
|
||||
);
|
||||
const pending = this.#pendingAcknowledgements.get(descriptor.session_id);
|
||||
pending?.delete(requestId);
|
||||
if (pending?.size === 0)
|
||||
this.#pendingAcknowledgements.delete(descriptor.session_id);
|
||||
return true;
|
||||
} catch {
|
||||
const pending =
|
||||
this.#pendingAcknowledgements.get(descriptor.session_id) ??
|
||||
new Set<string>();
|
||||
pending.add(requestId);
|
||||
this.#pendingAcknowledgements.set(descriptor.session_id, pending);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async #retryPendingAcknowledgements(
|
||||
descriptor: z.infer<typeof descriptorSchema>,
|
||||
): Promise<void> {
|
||||
const pending = this.#pendingAcknowledgements.get(descriptor.session_id);
|
||||
if (pending === undefined) return;
|
||||
for (const requestId of pending) {
|
||||
if (!(await this.#acknowledgeCompletion(descriptor, requestId)))
|
||||
throw new AnalysisCapabilityUnavailableError(
|
||||
"cutter",
|
||||
"cutter_command",
|
||||
"A retained Cutter completion receipt could not be acknowledged; no new command was sent.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async #find(sessionId: string): Promise<z.infer<typeof descriptorSchema>> {
|
||||
const loaded = await this.#readDescriptors();
|
||||
if (loaded.error === "bridge_directory_capacity_exceeded")
|
||||
throw new AnalysisResourceConstraintError(
|
||||
"cutter_command",
|
||||
"transport",
|
||||
"Cutter descriptor discovery exceeded its candidate budget; no command was sent.",
|
||||
{
|
||||
max_descriptor_candidates: MAX_DESCRIPTOR_CANDIDATES,
|
||||
bridge_directory: this.#directory,
|
||||
},
|
||||
);
|
||||
if (loaded.error !== undefined)
|
||||
throw new AnalysisCapabilityUnavailableError(
|
||||
"cutter",
|
||||
"cutter_command",
|
||||
`Cutter descriptor discovery failed: ${loaded.error}`,
|
||||
);
|
||||
const descriptor = loaded.descriptors.find(
|
||||
(candidate) => candidate.session_id === sessionId,
|
||||
);
|
||||
if (descriptor === undefined)
|
||||
throw new Error("Cutter bridge session is unavailable or stale");
|
||||
return descriptor;
|
||||
}
|
||||
|
||||
async listDescriptors(): Promise<
|
||||
readonly z.infer<typeof descriptorSchema>[]
|
||||
> {
|
||||
const loaded = await this.#readDescriptors();
|
||||
return loaded.descriptors;
|
||||
}
|
||||
|
||||
async #readDescriptors(): Promise<{
|
||||
readonly descriptors: readonly z.infer<typeof descriptorSchema>[];
|
||||
readonly security:
|
||||
| "private_verified"
|
||||
| "not_private"
|
||||
| "unverified_platform_acl"
|
||||
| "not_checked";
|
||||
readonly error?:
|
||||
| "bridge_directory_unavailable"
|
||||
| "bridge_directory_insecure"
|
||||
| "bridge_directory_capacity_exceeded";
|
||||
}> {
|
||||
let directoryInfo;
|
||||
try {
|
||||
directoryInfo = await lstat(this.#directory);
|
||||
} catch (cause) {
|
||||
const code = (cause as NodeJS.ErrnoException).code;
|
||||
if (code === "ENOENT")
|
||||
return { descriptors: [], security: "not_checked" };
|
||||
return {
|
||||
descriptors: [],
|
||||
security: "not_checked",
|
||||
error: "bridge_directory_unavailable",
|
||||
};
|
||||
}
|
||||
if (!directoryInfo.isDirectory() || directoryInfo.isSymbolicLink())
|
||||
return {
|
||||
descriptors: [],
|
||||
security: "not_private",
|
||||
error: "bridge_directory_insecure",
|
||||
};
|
||||
const currentUid =
|
||||
this.#platform !== "win32" ? process.getuid?.() : undefined;
|
||||
const posix = currentUid !== undefined;
|
||||
if (
|
||||
posix &&
|
||||
(directoryInfo.uid !== currentUid || (directoryInfo.mode & 0o077) !== 0)
|
||||
)
|
||||
return {
|
||||
descriptors: [],
|
||||
security: "not_private",
|
||||
error: "bridge_directory_insecure",
|
||||
};
|
||||
let security:
|
||||
| "private_verified"
|
||||
| "not_private"
|
||||
| "unverified_platform_acl" = posix
|
||||
? "private_verified"
|
||||
: "unverified_platform_acl";
|
||||
const windowsPrivateReader = this.#windowsPrivateReader;
|
||||
if (!posix) {
|
||||
if (this.#platform !== "win32" || windowsPrivateReader === undefined)
|
||||
return {
|
||||
descriptors: [],
|
||||
security,
|
||||
error: "bridge_directory_insecure",
|
||||
};
|
||||
try {
|
||||
if (!windowsPrivateReader.verifyDirectory(this.#directory))
|
||||
return {
|
||||
descriptors: [],
|
||||
security: "not_private",
|
||||
error: "bridge_directory_insecure",
|
||||
};
|
||||
security = "private_verified";
|
||||
} catch {
|
||||
return {
|
||||
descriptors: [],
|
||||
security,
|
||||
error: "bridge_directory_insecure",
|
||||
};
|
||||
}
|
||||
}
|
||||
const entries: string[] = [];
|
||||
try {
|
||||
const directory = await opendir(this.#directory);
|
||||
for await (const entry of directory) {
|
||||
if (!/^cutter-\d+-[0-9a-f-]{36}\.json$/iu.test(entry.name)) continue;
|
||||
if (entries.length === MAX_DESCRIPTOR_CANDIDATES)
|
||||
return {
|
||||
descriptors: [],
|
||||
security,
|
||||
error: "bridge_directory_capacity_exceeded",
|
||||
};
|
||||
entries.push(entry.name);
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
descriptors: [],
|
||||
security: "not_checked",
|
||||
error: "bridge_directory_unavailable",
|
||||
};
|
||||
}
|
||||
const loaded = await mapDiscovery(entries, async (entry) => {
|
||||
let file;
|
||||
try {
|
||||
let bytes: Buffer;
|
||||
if (this.#platform === "win32") {
|
||||
bytes = windowsPrivateReader!.readDescriptor(
|
||||
this.#directory,
|
||||
entry,
|
||||
MAX_DESCRIPTOR_BYTES,
|
||||
);
|
||||
} else {
|
||||
const flags = fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0);
|
||||
file = await open(join(this.#directory, entry), flags);
|
||||
const info = await file.stat();
|
||||
if (
|
||||
!info.isFile() ||
|
||||
info.size > MAX_DESCRIPTOR_BYTES ||
|
||||
info.uid !== currentUid ||
|
||||
(info.mode & 0o077) !== 0
|
||||
)
|
||||
return undefined;
|
||||
const boundedBytes = await readBoundedFileBytes(
|
||||
file,
|
||||
MAX_DESCRIPTOR_BYTES,
|
||||
);
|
||||
if (boundedBytes === undefined) return undefined;
|
||||
bytes = boundedBytes;
|
||||
}
|
||||
if (bytes.byteLength > MAX_DESCRIPTOR_BYTES) return undefined;
|
||||
const parsed = descriptorSchema.safeParse(
|
||||
JSON.parse(bytes.toString("utf8")) as unknown,
|
||||
);
|
||||
if (
|
||||
!parsed.success ||
|
||||
entry !== `cutter-${parsed.data.pid}-${parsed.data.session_id}.json`
|
||||
)
|
||||
return undefined;
|
||||
return parsed.data;
|
||||
} catch {
|
||||
return undefined;
|
||||
} finally {
|
||||
await file?.close().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
return {
|
||||
descriptors: loaded.filter(
|
||||
(value): value is z.infer<typeof descriptorSchema> =>
|
||||
value !== undefined,
|
||||
),
|
||||
security,
|
||||
};
|
||||
}
|
||||
|
||||
#request(
|
||||
descriptor: z.infer<typeof descriptorSchema>,
|
||||
requestBytes: AuthenticatedRequestBytes,
|
||||
signal?: AbortSignal,
|
||||
onAuthenticated?: (connectionOrdinal: string) => void,
|
||||
): Promise<unknown> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const connection = createConnection({
|
||||
host: descriptor.host,
|
||||
port: descriptor.port,
|
||||
});
|
||||
const frames = new CutterBridgeResponseFrames();
|
||||
let settled = false;
|
||||
let requestSent = false;
|
||||
let authenticated = false;
|
||||
const clientNonce = randomBytes(32).toString("hex");
|
||||
let serverNonce = "";
|
||||
let connectionOrdinal: string | undefined;
|
||||
const finish = (complete: () => void): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
complete();
|
||||
};
|
||||
const rejectRequest = (message: string): void =>
|
||||
finish(() =>
|
||||
reject(new CutterBridgeRequestFailure(message, requestSent)),
|
||||
);
|
||||
const timer = setTimeout(() => {
|
||||
rejectRequest("Cutter bridge request timed out");
|
||||
connection.destroy();
|
||||
}, 25_000);
|
||||
const onAbort = (): void => {
|
||||
rejectRequest("Cutter bridge request was cancelled");
|
||||
connection.destroy();
|
||||
};
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
if (signal?.aborted) onAbort();
|
||||
connection.once("connect", () => {
|
||||
connection.write(
|
||||
`${JSON.stringify({ kind: "rea-auth-v2", nonce: clientNonce })}\n`,
|
||||
);
|
||||
});
|
||||
connection.on("data", (chunk: Buffer) => {
|
||||
try {
|
||||
frames.push(chunk, (line) => {
|
||||
const frame: unknown = JSON.parse(line.toString("utf8"));
|
||||
if (!authenticated) {
|
||||
const challenge = z
|
||||
.object({
|
||||
session_id: z.string().uuid(),
|
||||
nonce: z.string().regex(/^[0-9a-f]{64}$/),
|
||||
proof: z.string().regex(/^[0-9a-f]{64}$/),
|
||||
})
|
||||
.parse(frame);
|
||||
if (challenge.session_id !== descriptor.session_id)
|
||||
throw new Error("unexpected peer session");
|
||||
serverNonce = challenge.nonce;
|
||||
connectionOrdinal = challenge.nonce.slice(0, 16);
|
||||
const expected = createHmac("sha256", descriptor.token)
|
||||
.update(
|
||||
`rea-cutter-server-v2:${descriptor.session_id}:${clientNonce}:${challenge.nonce}`,
|
||||
)
|
||||
.digest();
|
||||
if (
|
||||
!timingSafeEqual(expected, Buffer.from(challenge.proof, "hex"))
|
||||
)
|
||||
throw new Error("peer proof failed");
|
||||
onAuthenticated?.(connectionOrdinal);
|
||||
const proof = createHmac("sha256", descriptor.token)
|
||||
.update(
|
||||
`rea-cutter-client-v2:${descriptor.session_id}:${clientNonce}:${challenge.nonce}:${requestBytes}`,
|
||||
)
|
||||
.digest("hex");
|
||||
authenticated = true;
|
||||
requestSent = true;
|
||||
connection.write(
|
||||
`${JSON.stringify({ request: requestBytes, proof })}\n`,
|
||||
);
|
||||
return true;
|
||||
} else {
|
||||
const envelope = z
|
||||
.object({
|
||||
response: z.string(),
|
||||
proof: z.string().regex(/^[0-9a-f]{64}$/),
|
||||
})
|
||||
.parse(frame);
|
||||
const expected = createHmac("sha256", descriptor.token)
|
||||
.update(
|
||||
`rea-cutter-response-v2:${descriptor.session_id}:${clientNonce}:${serverNonce}:${envelope.response}`,
|
||||
)
|
||||
.digest();
|
||||
if (
|
||||
!timingSafeEqual(expected, Buffer.from(envelope.proof, "hex"))
|
||||
)
|
||||
throw new Error("response proof failed");
|
||||
const response: unknown = JSON.parse(envelope.response);
|
||||
connection.end();
|
||||
finish(() => resolve(response));
|
||||
return false;
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
rejectRequest("Cutter bridge peer authentication or response failed");
|
||||
connection.destroy();
|
||||
}
|
||||
});
|
||||
connection.once("error", (cause) =>
|
||||
rejectRequest(`Cutter bridge is unavailable: ${cause.message}`),
|
||||
);
|
||||
connection.once("close", () =>
|
||||
rejectRequest(
|
||||
"Cutter bridge closed before returning a complete response",
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import { jsonValueSchema } from "../domain/jsonValue.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisCapabilityUnavailableError,
|
||||
AnalysisInputError,
|
||||
AnalysisOutputError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import { createEvidence, type Evidence } from "../domain/evidence.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import { cutterCommandInputSchema } from "../contracts/cutterToolContracts.js";
|
||||
import {
|
||||
CutterBridgeClient,
|
||||
type CutterBridgeDiscovery,
|
||||
type CutterBridgeExecution,
|
||||
} from "./CutterBridgeClient.js";
|
||||
import { CUTTER_PROVIDER_IDENTITY } from "./CutterBridgeClient.js";
|
||||
|
||||
interface CutterBridgePort {
|
||||
listSessions(): Promise<CutterBridgeDiscovery>;
|
||||
confirmCommandCompletion?(
|
||||
sessionId: string,
|
||||
requestId: string,
|
||||
completionToken?: string,
|
||||
): Promise<Result<boolean, AnalysisError>>;
|
||||
execute(input: {
|
||||
readonly sessionId: string;
|
||||
readonly expectedGeneration: number;
|
||||
readonly command: string;
|
||||
readonly json: boolean;
|
||||
readonly signal?: AbortSignal;
|
||||
}): Promise<CutterBridgeExecution>;
|
||||
}
|
||||
|
||||
/** Shared Cutter bridge workflows used by the MCP adapter and CLI. */
|
||||
export class CutterBridgeService {
|
||||
#cleanupAttempt: Promise<Result<null, AnalysisError>> | undefined;
|
||||
readonly #pendingCleanup = new Map<
|
||||
string,
|
||||
{
|
||||
readonly sessionId: string;
|
||||
readonly completionToken?: string;
|
||||
readonly failure: AnalysisOutputError;
|
||||
}
|
||||
>();
|
||||
|
||||
constructor(readonly client: CutterBridgePort = new CutterBridgeClient()) {}
|
||||
|
||||
/** Release timed-out work only after producer-owned terminal proof. */
|
||||
retryCleanup(): Promise<Result<null, AnalysisError>> {
|
||||
if (this.#cleanupAttempt !== undefined) return this.#cleanupAttempt;
|
||||
const attempt = this.#retryPendingCleanup().finally(() => {
|
||||
if (this.#cleanupAttempt === attempt) this.#cleanupAttempt = undefined;
|
||||
});
|
||||
this.#cleanupAttempt = attempt;
|
||||
return attempt;
|
||||
}
|
||||
|
||||
async #retryPendingCleanup(): Promise<Result<null, AnalysisError>> {
|
||||
for (const [requestId, pending] of this.#pendingCleanup) {
|
||||
const confirmCommandCompletion = this.client.confirmCommandCompletion;
|
||||
if (confirmCommandCompletion === undefined) return err(pending.failure);
|
||||
const receipt = await confirmCommandCompletion.call(
|
||||
this.client,
|
||||
pending.sessionId,
|
||||
requestId,
|
||||
pending.completionToken,
|
||||
);
|
||||
if (!receipt.ok || !receipt.value) return err(pending.failure);
|
||||
const { failure } = pending;
|
||||
failure.resolveCleanup();
|
||||
this.#pendingCleanup.delete(requestId);
|
||||
}
|
||||
return ok(null);
|
||||
}
|
||||
|
||||
listSessions(): Promise<CutterBridgeDiscovery> {
|
||||
return this.client.listSessions();
|
||||
}
|
||||
|
||||
async execute(
|
||||
rawInput: unknown,
|
||||
options: { readonly signal?: AbortSignal } = {},
|
||||
): Promise<Result<Evidence, AnalysisError>> {
|
||||
const parsed = cutterCommandInputSchema.safeParse(rawInput);
|
||||
if (!parsed.success) return err(new AnalysisInputError("cutter_command"));
|
||||
if (options.signal?.aborted)
|
||||
return err(new AnalysisCancelledError("cutter_command"));
|
||||
const input = parsed.data;
|
||||
const cleanup = await this.retryCleanup();
|
||||
if (!cleanup.ok) return err(cleanup.error);
|
||||
try {
|
||||
const output = await this.client.execute({
|
||||
sessionId: input.session_id,
|
||||
expectedGeneration: input.expected_generation,
|
||||
command: input.command,
|
||||
json: input.json,
|
||||
...(options.signal === undefined ? {} : { signal: options.signal }),
|
||||
});
|
||||
const normalizedOutput = jsonValueSchema.parse(output.output);
|
||||
const evidence = createEvidence(
|
||||
undefined,
|
||||
{
|
||||
...CUTTER_PROVIDER_IDENTITY,
|
||||
version: output.cutterVersion,
|
||||
},
|
||||
{
|
||||
predicateType: "rea.cutter.command-observation",
|
||||
operation: "cutter_command",
|
||||
parameters: {
|
||||
session_id: input.session_id,
|
||||
expected_generation: input.expected_generation,
|
||||
command: input.command,
|
||||
json: input.json,
|
||||
},
|
||||
result: {
|
||||
command: input.command,
|
||||
output: normalizedOutput,
|
||||
execution_state: output.executionState,
|
||||
error: output.error,
|
||||
message: output.message,
|
||||
output_truncated: output.outputTruncated,
|
||||
cutter_version: output.cutterVersion,
|
||||
current_file: output.currentFile,
|
||||
document_generation: output.documentGeneration,
|
||||
identity_status: output.identityStatus,
|
||||
},
|
||||
rawResult: normalizedOutput,
|
||||
subjectUnavailableReason:
|
||||
"Cutter exposes no verified stable identity for the active unsaved document or tab.",
|
||||
limitations: [
|
||||
"Document identity is partial: the bridge uses a plugin instance UUID and observed generation; a same-path tab switch or reload may not be distinguishable.",
|
||||
"Cutter command effects depend on the Rizin command and active plugins.",
|
||||
"The local IPC protocol is implemented by REA and is not an upstream Cutter API.",
|
||||
...(output.executionState === "unknown"
|
||||
? [
|
||||
"Command completion or its output could not be confirmed; do not retry automatically because the command may have produced partial or persistent effects.",
|
||||
]
|
||||
: []),
|
||||
...(output.outputTruncated
|
||||
? [
|
||||
"The command completed or may have completed, but output exceeded the bridge transport limit and was omitted.",
|
||||
]
|
||||
: []),
|
||||
],
|
||||
},
|
||||
);
|
||||
if (output.executionState === "unknown") {
|
||||
const failure = new AnalysisOutputError(
|
||||
"cutter_command",
|
||||
"Cutter command completion is unknown; the bound binary remains unavailable until the UI dispatcher confirms this request reached a terminal state.",
|
||||
{
|
||||
cleanup: {
|
||||
reason:
|
||||
"The exact UI dispatcher completion receipt has not arrived; the dispatched command may still be running.",
|
||||
resources: [`cutter-session:${input.session_id}`],
|
||||
},
|
||||
partialObservation: evidence,
|
||||
},
|
||||
);
|
||||
this.#pendingCleanup.set(output.requestId, {
|
||||
sessionId: input.session_id,
|
||||
...(output.completionToken === undefined
|
||||
? {}
|
||||
: { completionToken: output.completionToken }),
|
||||
failure,
|
||||
});
|
||||
return err(failure);
|
||||
}
|
||||
return ok(evidence);
|
||||
} catch (cause) {
|
||||
if (cause instanceof AnalysisError) return err(cause);
|
||||
const message =
|
||||
cause instanceof Error
|
||||
? cause.message
|
||||
: "Unknown Cutter bridge failure";
|
||||
if (options.signal?.aborted && !message.includes("may have completed"))
|
||||
return err(new AnalysisCancelledError("cutter_command", { cause }));
|
||||
return err(
|
||||
message.includes("generation changed") ||
|
||||
message.includes("unavailable or stale")
|
||||
? new AnalysisInputError("cutter_command", { cause })
|
||||
: new AnalysisCapabilityUnavailableError(
|
||||
"cutter",
|
||||
"cutter_command",
|
||||
message,
|
||||
{ cause },
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -65,7 +65,7 @@ export abstract class AnalysisError extends Error {
|
||||
readonly diagnostics: Readonly<Record<string, JsonValue>> | undefined;
|
||||
readonly capturedOutput: AnalysisCapturedOutput | undefined;
|
||||
readonly cleanup: AnalysisCleanupObservation | undefined;
|
||||
readonly cleanupIncomplete: boolean;
|
||||
cleanupIncomplete: boolean;
|
||||
readonly cleanupResources: readonly string[];
|
||||
constructor(message: string, options?: AnalysisErrorOptions) {
|
||||
super(message, options);
|
||||
@@ -89,6 +89,30 @@ export abstract class AnalysisError extends Error {
|
||||
partialObservation: AnalysisPartialObservation | undefined;
|
||||
readonly cleanupReport: ProcessCaptureCleanupReport | undefined = undefined;
|
||||
|
||||
/** Retain a later lifecycle failure without replacing the original typed error. */
|
||||
retainSecondaryFailure(
|
||||
failure: AnalysisError,
|
||||
diagnostics: Readonly<Record<string, JsonValue>>,
|
||||
): this {
|
||||
// Secondary resources remain owned by their cleanup lifecycle; preserve
|
||||
// the primary error's live cleanup status and original output.
|
||||
Object.assign(this, {
|
||||
diagnostics: structuredClone({ ...this.diagnostics, ...diagnostics }),
|
||||
cause: new AggregateError(
|
||||
this.cause === undefined ? [failure] : [this.cause, failure],
|
||||
"Analysis failed and a later lifecycle operation also failed.",
|
||||
{ cause: this.cause },
|
||||
),
|
||||
});
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Clear live cleanup status only after its owning resource confirms retry success. */
|
||||
resolveCleanup(): this {
|
||||
this.cleanupIncomplete = false;
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Retain a completed observation when a later step fails. */
|
||||
retainPartialObservation(observation: AnalysisPartialObservation): this {
|
||||
this.partialObservation ??= observation;
|
||||
|
||||
@@ -86,13 +86,15 @@ export interface AnalysisInputIssue {
|
||||
/** Provider output failed provider-neutral application parsing. */
|
||||
export class AnalysisOutputError extends AnalysisError {
|
||||
readonly _tag = "AnalysisOutputError";
|
||||
readonly cleanupOnly: boolean;
|
||||
|
||||
constructor(
|
||||
readonly operation: string,
|
||||
readonly reason: string,
|
||||
options?: AnalysisErrorOptions,
|
||||
options?: AnalysisErrorOptions & { readonly cleanupOnly?: boolean },
|
||||
) {
|
||||
super(`Invalid analysis output for ${operation}: ${reason}`, options);
|
||||
this.cleanupOnly = options?.cleanupOnly ?? false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -796,3 +796,32 @@ describe("analysis error projection: evidence files", () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("retains the primary failure and both cleanup reports after a secondary failure", () => {
|
||||
const primary = new AnalysisCancelledError("execute_rizin_command", {
|
||||
capturedOutput: {
|
||||
stdout: "partial",
|
||||
stderr: "diagnostic",
|
||||
truncated: false,
|
||||
},
|
||||
cleanup: { reason: "writer remains", resources: ["writer"] },
|
||||
});
|
||||
const secondary = new AnalysisOutputError("open_binary", "cannot rebind", {
|
||||
cleanup: { reason: "provider remains", resources: ["provider", "writer"] },
|
||||
});
|
||||
const enriched = primary.retainSecondaryFailure(secondary, {
|
||||
rebind_error: projectAnalysisError(secondary),
|
||||
});
|
||||
expect(enriched).toBe(primary);
|
||||
expect(enriched._tag).toBe("AnalysisCancelledError");
|
||||
expect(projectAnalysisError(enriched)).toMatchObject({
|
||||
code: "cleanup_incomplete",
|
||||
details: {
|
||||
execution_failure: "cancelled",
|
||||
resources: ["writer"],
|
||||
cleanup_reason: "writer remains",
|
||||
captured_output: { stdout: "partial", stderr: "diagnostic" },
|
||||
diagnostics: { rebind_error: { code: "cleanup_incomplete" } },
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -447,8 +447,8 @@ const lifecycleErrorDetails = (
|
||||
if (error instanceof AnalysisCancelledError)
|
||||
return {
|
||||
operation: error.operation,
|
||||
cleanup: error.cleanup === undefined ? "complete" : "incomplete",
|
||||
...(error.cleanup === undefined
|
||||
cleanup: error.cleanupIncomplete ? "incomplete" : "complete",
|
||||
...(error.cleanup === undefined || !error.cleanupIncomplete
|
||||
? {}
|
||||
: {
|
||||
cleanup_reason: error.cleanup.reason,
|
||||
@@ -462,7 +462,7 @@ const lifecycleErrorDetails = (
|
||||
return {
|
||||
operation: error.operation,
|
||||
timeout_ms: error.timeoutMs,
|
||||
...(error.cleanup === undefined
|
||||
...(error.cleanup === undefined || !error.cleanupIncomplete
|
||||
? {}
|
||||
: {
|
||||
cleanup: "incomplete",
|
||||
|
||||
@@ -11,7 +11,7 @@ import { AnalysisCancelledError } from "./analysisErrorCore.js";
|
||||
/** A bounded passive browser observation failed at its CDP boundary. */
|
||||
export class BrowserObservationError extends AnalysisError {
|
||||
readonly _tag = "BrowserObservationError";
|
||||
override readonly cleanupIncomplete: boolean;
|
||||
override cleanupIncomplete: boolean;
|
||||
override readonly cleanupResources: readonly string[];
|
||||
override readonly userCategory: "cancelled" | undefined;
|
||||
override readonly userMessage: string | undefined;
|
||||
|
||||
+7
-6
@@ -72,13 +72,14 @@ const windowsHeaderSignature = (
|
||||
bytes: Buffer | undefined,
|
||||
): "PE" | "NE" | "LE" | "LX" | "other" | "unobserved" => {
|
||||
if (bytes === undefined || bytes.length < 2) return "unobserved";
|
||||
const prefix = bytes.toString("latin1", 0, 2);
|
||||
if (prefix === "NE" || prefix === "LE" || prefix === "LX") return prefix;
|
||||
if (prefix !== "PE") return "other";
|
||||
const first = bytes[0];
|
||||
const second = bytes[1];
|
||||
if (first === 0x4e && second === 0x45) return "NE";
|
||||
if (first === 0x4c && second === 0x45) return "LE";
|
||||
if (first === 0x4c && second === 0x58) return "LX";
|
||||
if (first !== 0x50 || second !== 0x45) return "other";
|
||||
if (bytes.length < 4) return "unobserved";
|
||||
return bytes.subarray(0, 4).equals(Buffer.from("PE\0\0", "binary"))
|
||||
? "PE"
|
||||
: "other";
|
||||
return bytes[2] === 0 && bytes[3] === 0 ? "PE" : "other";
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -78,9 +78,21 @@ describe("Hopper boundary values", () => {
|
||||
(_label, parse) => {
|
||||
const result = parse();
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.error._tag).toBe("HopperProtocolError");
|
||||
if (!result.ok) expect(result.error._tag).toBe("AnalysisOutputError");
|
||||
},
|
||||
);
|
||||
|
||||
it("uses provider-neutral wording for malformed shared values", () => {
|
||||
const result = parseSegments([{ name: 1 }]);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error._tag).toBe("AnalysisOutputError");
|
||||
expect(result.error.operation).toBe("list_segments");
|
||||
expect(result.error.reason).toBe(
|
||||
"Provider returned an invalid segment list",
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("provider-neutral inclusive function body evidence", () => {
|
||||
|
||||
+33
-21
@@ -7,7 +7,6 @@ import { err, ok, type Result } from "./result.js";
|
||||
import { nativeApiBoundarySchema } from "./native/nativeApiBoundary.js";
|
||||
import { nativeValueFlowSchema } from "./native/nativeValueFlow.js";
|
||||
import { AnalysisOutputError } from "./analysisErrorCore.js";
|
||||
import { HopperProtocolError } from "./hopperErrors.js";
|
||||
|
||||
export interface AddressedName {
|
||||
readonly address: string;
|
||||
@@ -460,10 +459,11 @@ export const parseFunctionDossier = (
|
||||
);
|
||||
};
|
||||
|
||||
/** Parse Hopper's direct or wrapped procedure map into stable entries. */
|
||||
/** Parse a provider's direct or wrapped procedure map into stable entries. */
|
||||
export const parseProcedures = (
|
||||
value: JsonValue,
|
||||
): Result<readonly AddressedName[], HopperProtocolError> => {
|
||||
operation = "list_procedures",
|
||||
): Result<readonly AddressedName[], AnalysisOutputError> => {
|
||||
const parsed = procedureMapSchema.safeParse(
|
||||
unwrapProperty(value, "procedures"),
|
||||
);
|
||||
@@ -474,43 +474,48 @@ export const parseProcedures = (
|
||||
name,
|
||||
})),
|
||||
)
|
||||
: invalid("procedure map", parsed.error);
|
||||
: invalid(operation, "procedure map", parsed.error);
|
||||
};
|
||||
|
||||
/** Parse Hopper's direct or wrapped list of address/name records. */
|
||||
/** Parse a provider's direct or wrapped list of address/name records. */
|
||||
export const parseNames = (
|
||||
value: JsonValue,
|
||||
): Result<readonly AddressedName[], HopperProtocolError> => {
|
||||
operation = "list_names",
|
||||
): Result<readonly AddressedName[], AnalysisOutputError> => {
|
||||
const unwrapped = unwrapProperty(value, "names");
|
||||
const records = addressedNamesSchema.safeParse(unwrapped);
|
||||
if (records.success) return ok(records.data);
|
||||
const map = addressedNameMapSchema.safeParse(unwrapped);
|
||||
return map.success
|
||||
? ok(Object.entries(map.data).map(([address, name]) => ({ address, name })))
|
||||
: invalid("name list", map.error);
|
||||
: invalid(operation, "name list", map.error);
|
||||
};
|
||||
|
||||
/** Parse callee/caller strings from direct or wrapped Hopper results. */
|
||||
/** Parse callee/caller strings from direct or wrapped provider results. */
|
||||
export const parseRelatedAddresses = (
|
||||
value: JsonValue,
|
||||
relation: "callees" | "callers",
|
||||
): Result<readonly string[], HopperProtocolError> => {
|
||||
operation = relation === "callees"
|
||||
? "procedure_callees"
|
||||
: "procedure_callers",
|
||||
): Result<readonly string[], AnalysisOutputError> => {
|
||||
const parsed = z.array(z.string()).safeParse(unwrapProperty(value, relation));
|
||||
return parsed.success
|
||||
? ok(parsed.data)
|
||||
: invalid(`${relation} list`, parsed.error);
|
||||
: invalid(operation, `${relation} list`, parsed.error);
|
||||
};
|
||||
|
||||
/** Preserve reported address context in direct or wrapped provider segments. */
|
||||
export const parseSegments = (
|
||||
value: JsonValue,
|
||||
): Result<readonly SegmentSummary[], HopperProtocolError> => {
|
||||
operation = "list_segments",
|
||||
): Result<readonly SegmentSummary[], AnalysisOutputError> => {
|
||||
const parsed = z
|
||||
.array(segmentSchema)
|
||||
.safeParse(unwrapProperty(value, "segments"));
|
||||
return parsed.success
|
||||
? ok(parsed.data)
|
||||
: invalid("segment list", parsed.error);
|
||||
: invalid(operation, "segment list", parsed.error);
|
||||
};
|
||||
|
||||
/** Return a reported overview base only when every parsed segment agrees. */
|
||||
@@ -524,30 +529,32 @@ export const parseSegmentImageBase = (
|
||||
: undefined;
|
||||
};
|
||||
|
||||
/** Parse direct or wrapped Hopper document names. */
|
||||
/** Parse direct or wrapped provider document names. */
|
||||
export const parseDocuments = (
|
||||
value: JsonValue,
|
||||
): Result<readonly string[], HopperProtocolError> => {
|
||||
operation = "list_documents",
|
||||
): Result<readonly string[], AnalysisOutputError> => {
|
||||
const parsed = z
|
||||
.array(z.string())
|
||||
.safeParse(unwrapProperty(value, "documents"));
|
||||
return parsed.success
|
||||
? ok(parsed.data)
|
||||
: invalid("document list", parsed.error);
|
||||
: invalid(operation, "document list", parsed.error);
|
||||
};
|
||||
|
||||
/** Parse a direct or wrapped list when only its cardinality is required. */
|
||||
export const parseListCount = (
|
||||
value: JsonValue,
|
||||
property: string,
|
||||
): Result<number, HopperProtocolError> => {
|
||||
operation = property === "procedures" ? "list_procedures" : "list_strings",
|
||||
): Result<number, AnalysisOutputError> => {
|
||||
const unwrapped = unwrapProperty(value, property);
|
||||
const list = z.array(z.unknown()).safeParse(unwrapped);
|
||||
if (list.success) return ok(list.data.length);
|
||||
const map = z.record(z.string(), z.unknown()).safeParse(unwrapped);
|
||||
return map.success
|
||||
? ok(Object.keys(map.data).length)
|
||||
: invalid(`${property} list`, map.error);
|
||||
: invalid(operation, `${property} list`, map.error);
|
||||
};
|
||||
|
||||
const unwrapProperty = (value: JsonValue, property: string): JsonValue => {
|
||||
@@ -563,11 +570,16 @@ const unwrapProperty = (value: JsonValue, property: string): JsonValue => {
|
||||
};
|
||||
|
||||
const invalid = <T>(
|
||||
operation: string,
|
||||
expected: string,
|
||||
cause: z.ZodError,
|
||||
): Result<T, HopperProtocolError> =>
|
||||
): Result<T, AnalysisOutputError> =>
|
||||
err(
|
||||
new HopperProtocolError(`Hopper returned an invalid ${expected}`, {
|
||||
cause,
|
||||
}),
|
||||
new AnalysisOutputError(
|
||||
operation,
|
||||
`Provider returned an invalid ${expected}`,
|
||||
{
|
||||
cause,
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -4,7 +4,7 @@ import type { AnalysisPartialObservation } from "./analysisErrorBase.js";
|
||||
|
||||
/** Provider resources could not be proven closed after bounded cleanup. */
|
||||
export class ProviderCleanupError extends ProviderAdapterError {
|
||||
override readonly cleanupIncomplete = true;
|
||||
override cleanupIncomplete = true;
|
||||
override readonly cleanupResources: readonly string[];
|
||||
override readonly userMessage =
|
||||
"Provider cleanup could not be fully confirmed. Review the reported local resources before opening another target.";
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const objdumpArtifactInputSchema = z
|
||||
.object({
|
||||
path: z.string().min(1),
|
||||
operation: z.enum([
|
||||
"file_headers",
|
||||
"section_headers",
|
||||
"symbols",
|
||||
"relocations",
|
||||
"dwarf",
|
||||
"disassemble",
|
||||
"disassemble_all",
|
||||
]),
|
||||
follow_debug_links: z.boolean().default(false),
|
||||
})
|
||||
.strict();
|
||||
|
||||
/** Caller-selected artifact facet or pathless provider capability query. */
|
||||
export const objdumpInputSchema = z.discriminatedUnion("operation", [
|
||||
objdumpArtifactInputSchema,
|
||||
z.strictObject({ operation: z.literal("architectures") }),
|
||||
]);
|
||||
|
||||
/** Caller-selected Rizin command against one local artifact. */
|
||||
export const rizinInputSchema = z
|
||||
.object({
|
||||
path: z.string().min(1),
|
||||
command: z
|
||||
.string()
|
||||
.min(1)
|
||||
.refine(
|
||||
(value) => !/[\r\n\u0000]/u.test(value),
|
||||
"A single command line is required",
|
||||
),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type ObjdumpInput = z.input<typeof objdumpInputSchema>;
|
||||
export type RizinInput = z.input<typeof rizinInputSchema>;
|
||||
@@ -4,7 +4,7 @@ import type { AnalysisErrorOptions } from "./analysisErrorBase.js";
|
||||
/** Publication failure retaining its input/output target and cleanup state. */
|
||||
export class WebScriptExportError extends ArtifactOperationError {
|
||||
override readonly userMessage: string;
|
||||
override readonly cleanupIncomplete: boolean;
|
||||
override cleanupIncomplete: boolean;
|
||||
override readonly cleanupResources: readonly string[];
|
||||
|
||||
constructor(
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,24 @@
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import type { ObjdumpInput } from "../domain/reverseEngineering.js";
|
||||
|
||||
export const OBJDUMP_PROVIDER_IDENTITY = {
|
||||
id: "gnu.objdump",
|
||||
name: "GNU objdump",
|
||||
version: null,
|
||||
} as const;
|
||||
|
||||
/** Build GNU objdump argv without invoking a shell or following DWARF links by default. */
|
||||
export const objdumpCommand = (input: ObjdumpInput): readonly string[] => {
|
||||
if (input.operation === "architectures") return ["-i"];
|
||||
const argumentsByOperation = {
|
||||
file_headers: ["-f"],
|
||||
section_headers: ["-h"],
|
||||
symbols: ["-t"],
|
||||
relocations: ["-r"],
|
||||
dwarf: ["-W", input.follow_debug_links ? "-WK" : "-WN"],
|
||||
disassemble: ["-d"],
|
||||
disassemble_all: ["-D"],
|
||||
} satisfies Record<typeof input.operation, readonly string[]>;
|
||||
return [...argumentsByOperation[input.operation], resolve(input.path)];
|
||||
};
|
||||
@@ -1,13 +1,49 @@
|
||||
import { access, readFile } from "node:fs/promises";
|
||||
import { access, readFile, rm } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js";
|
||||
|
||||
import { execFileBytes, execFileOutput } from "./ExecFileOutput.js";
|
||||
import {
|
||||
execFileBytes,
|
||||
execFileOutput,
|
||||
execFileOutputFailure,
|
||||
} from "./ExecFileOutput.js";
|
||||
|
||||
const waitForFile = async (path: string): Promise<boolean> => {
|
||||
for (let attempt = 0; attempt < 500; attempt += 1) {
|
||||
if (
|
||||
await access(path).then(
|
||||
() => true,
|
||||
() => false,
|
||||
)
|
||||
)
|
||||
return true;
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
return access(path).then(
|
||||
() => true,
|
||||
() => false,
|
||||
);
|
||||
};
|
||||
|
||||
describe("execFileOutput", () => {
|
||||
it("ignores non-Error and incomplete failure metadata", () => {
|
||||
expect(execFileOutputFailure("failure")).toBeUndefined();
|
||||
expect(execFileOutputFailure(new Error("failure"))).toBeUndefined();
|
||||
});
|
||||
|
||||
it("recognizes the execFile output limit code as truncated output", () => {
|
||||
const failure = Object.assign(new Error("output limit"), {
|
||||
stdout: "partial stdout",
|
||||
stderr: "partial stderr",
|
||||
code: "ERR_CHILD_PROCESS_STDIO_MAXBUFFER",
|
||||
});
|
||||
|
||||
expect(execFileOutputFailure(failure)?.outputTruncated).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves captured bytes for callers that own the output format", async () => {
|
||||
const result = await execFileBytes(process.execPath, [
|
||||
"-e",
|
||||
@@ -18,6 +54,20 @@ describe("execFileOutput", () => {
|
||||
expect(result.stderr).toEqual(Buffer.from([0xfe]));
|
||||
});
|
||||
|
||||
it("reports raw byte counts when UTF-8 decoding changes the captured text", async () => {
|
||||
const result = await execFileOutput(process.execPath, [
|
||||
"-e",
|
||||
"process.stdout.write(Buffer.from([0x61, 0xff])); process.stderr.write(Buffer.from([0xe2, 0x82, 0xac]));",
|
||||
]);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
stdout: "a�",
|
||||
stderr: "€",
|
||||
stdoutBytes: 2,
|
||||
stderrBytes: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it("captures output larger than Node's default execFile limit", async () => {
|
||||
const marker = "tail-marker";
|
||||
const result = await execFileOutput(process.execPath, [
|
||||
@@ -48,6 +98,14 @@ describe("execFileOutput", () => {
|
||||
).rejects.toMatchObject({ code: 7, stdout: "out", stderr: "err" });
|
||||
});
|
||||
|
||||
it("uses the built-in execFile timeout when no stop signal is requested", async () => {
|
||||
await expect(
|
||||
execFileOutput(process.execPath, ["-e", "setInterval(() => {}, 1000)"], {
|
||||
timeout: 100,
|
||||
}),
|
||||
).rejects.toMatchObject({ killed: true });
|
||||
});
|
||||
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"stops an aborted process with the caller's signal and settles after it exits",
|
||||
async () => {
|
||||
@@ -75,25 +133,48 @@ describe("execFileOutput", () => {
|
||||
),
|
||||
}),
|
||||
);
|
||||
for (let attempt = 0; attempt < 500; attempt += 1) {
|
||||
if (
|
||||
await access(ready).then(
|
||||
() => true,
|
||||
() => false,
|
||||
)
|
||||
)
|
||||
break;
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
|
||||
const readyObserved = await waitForFile(ready);
|
||||
controller.abort();
|
||||
const outcome = await execution;
|
||||
|
||||
expect(readyObserved).toBe(true);
|
||||
expect(outcome?.cause).toMatchObject({ name: "AbortError" });
|
||||
expect(outcome?.stoppedBeforeSettling).toBe("SIGINT");
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"keeps the first stop reason when timeout arrives during abort cleanup",
|
||||
async () => {
|
||||
const directory = await createTestTempDirectory("rea-exec-double-stop-");
|
||||
const ready = join(directory, "ready");
|
||||
const script = [
|
||||
'const fs = require("node:fs");',
|
||||
'for (const name of ["SIGINT", "SIGTERM"]) process.on(name, () => {});',
|
||||
`fs.writeFileSync(${JSON.stringify(ready)}, "");`,
|
||||
"setInterval(() => {}, 1000);",
|
||||
].join("\n");
|
||||
const controller = new AbortController();
|
||||
const execution = execFileOutput(process.execPath, ["-e", script], {
|
||||
signal: controller.signal,
|
||||
timeout: 10_000,
|
||||
stopSignal: "SIGINT",
|
||||
});
|
||||
const startedAt = Date.now();
|
||||
expect(await waitForFile(ready)).toBe(true);
|
||||
const remainingBeforeDeadline = Math.max(
|
||||
0,
|
||||
9_000 - (Date.now() - startedAt),
|
||||
);
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, remainingBeforeDeadline),
|
||||
);
|
||||
controller.abort();
|
||||
|
||||
await expect(execution).rejects.toMatchObject({ name: "AbortError" });
|
||||
},
|
||||
);
|
||||
|
||||
it("rejects an already aborted stoppable command without starting it", async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
@@ -149,6 +230,33 @@ describe.skipIf(process.platform === "win32")(
|
||||
},
|
||||
);
|
||||
|
||||
it("preserves a child failure's missing signal when reporting timeout", async () => {
|
||||
const directory = await createTestTempDirectory(
|
||||
"rea-exec-missing-signal-",
|
||||
);
|
||||
const ready = join(directory, "ready");
|
||||
const script = [
|
||||
'const fs = require("node:fs");',
|
||||
'for (const name of ["SIGINT", "SIGTERM"])',
|
||||
" process.on(name, () => process.exit(1));",
|
||||
`fs.writeFileSync(${JSON.stringify(ready)}, "");`,
|
||||
"setInterval(() => {}, 1000);",
|
||||
].join("\n");
|
||||
try {
|
||||
const execution = execFileOutput(process.execPath, ["-e", script], {
|
||||
timeout: 10_000,
|
||||
stopSignal: "SIGINT",
|
||||
});
|
||||
expect(await waitForFile(ready)).toBe(true);
|
||||
await expect(execution).rejects.toMatchObject({
|
||||
code: "ETIMEDOUT",
|
||||
signal: null,
|
||||
});
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("kills a timed-out process that ignores the stop signal", async () => {
|
||||
const script = [
|
||||
'for (const name of ["SIGINT", "SIGTERM"]) process.on(name, () => {});',
|
||||
@@ -168,3 +276,40 @@ describe.skipIf(process.platform === "win32")(
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
describe("execFileOutput failure metadata", () => {
|
||||
it("preserves raw byte counts on a failed command with invalid UTF-8", async () => {
|
||||
const failure: unknown = await execFileOutput(process.execPath, [
|
||||
"-e",
|
||||
"process.stdout.write(Buffer.from([0x61, 0xff])); process.stderr.write(Buffer.from([0xe2, 0x82, 0xac])); process.exitCode = 7;",
|
||||
]).then(
|
||||
() => undefined,
|
||||
(cause: unknown) => cause,
|
||||
);
|
||||
|
||||
expect(execFileOutputFailure(failure)).toMatchObject({
|
||||
stdout: "a�",
|
||||
stderr: "€",
|
||||
stdoutBytes: 2,
|
||||
stderrBytes: 3,
|
||||
code: 7,
|
||||
});
|
||||
});
|
||||
it("preserves explicit truncation metadata from an owned process failure", () => {
|
||||
const failure = Object.assign(new Error("owned output was truncated"), {
|
||||
stdout: "partial stdout",
|
||||
stderr: "partial stderr",
|
||||
code: 7,
|
||||
outputTruncated: true,
|
||||
});
|
||||
|
||||
expect(execFileOutputFailure(failure)).toEqual({
|
||||
stdout: "partial stdout",
|
||||
stderr: "partial stderr",
|
||||
code: 7,
|
||||
signal: null,
|
||||
killed: false,
|
||||
outputTruncated: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -76,6 +76,9 @@ export interface ExecFileOutputFailure {
|
||||
readonly signal: string | null;
|
||||
readonly killed: boolean;
|
||||
readonly outputTruncated: boolean;
|
||||
readonly stdoutBytes?: number;
|
||||
readonly stderrBytes?: number;
|
||||
readonly collectionFailure?: boolean;
|
||||
}
|
||||
|
||||
/** Read captured output metadata from a failed execFileOutput invocation. */
|
||||
@@ -88,15 +91,31 @@ export const execFileOutputFailure = (
|
||||
const code = Reflect.get(cause, "code");
|
||||
const signal = Reflect.get(cause, "signal");
|
||||
const killed = Reflect.get(cause, "killed");
|
||||
const outputTruncated = Reflect.get(cause, "outputTruncated");
|
||||
const stdoutBytes = Reflect.get(cause, "stdoutBytes");
|
||||
const stderrBytes = Reflect.get(cause, "stderrBytes");
|
||||
const collectionFailure = Reflect.get(cause, "collectionFailure");
|
||||
if (typeof stdout !== "string" || typeof stderr !== "string")
|
||||
return undefined;
|
||||
return {
|
||||
stdout,
|
||||
stderr,
|
||||
...(typeof stdoutBytes === "number" &&
|
||||
Number.isSafeInteger(stdoutBytes) &&
|
||||
stdoutBytes >= 0
|
||||
? { stdoutBytes }
|
||||
: {}),
|
||||
...(typeof stderrBytes === "number" &&
|
||||
Number.isSafeInteger(stderrBytes) &&
|
||||
stderrBytes >= 0
|
||||
? { stderrBytes }
|
||||
: {}),
|
||||
code: typeof code === "string" || typeof code === "number" ? code : null,
|
||||
signal: typeof signal === "string" ? signal : null,
|
||||
killed: killed === true,
|
||||
outputTruncated: code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER",
|
||||
outputTruncated:
|
||||
outputTruncated === true || code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER",
|
||||
...(collectionFailure === true ? { collectionFailure: true } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -150,6 +169,8 @@ export const execFileBytes = (
|
||||
if (failure !== null) {
|
||||
Reflect.set(failure, "stdout", stdout.toString("utf8"));
|
||||
Reflect.set(failure, "stderr", stderr.toString("utf8"));
|
||||
Reflect.set(failure, "stdoutBytes", stdout.byteLength);
|
||||
Reflect.set(failure, "stderrBytes", stderr.byteLength);
|
||||
reject(failure);
|
||||
return;
|
||||
}
|
||||
@@ -179,8 +200,15 @@ export const execFileOutput = (
|
||||
command: string,
|
||||
arguments_: readonly string[],
|
||||
options: ExecFileOutputOptions = {},
|
||||
): Promise<{ readonly stdout: string; readonly stderr: string }> =>
|
||||
): Promise<{
|
||||
readonly stdout: string;
|
||||
readonly stderr: string;
|
||||
readonly stdoutBytes: number;
|
||||
readonly stderrBytes: number;
|
||||
}> =>
|
||||
execFileBytes(command, arguments_, options).then(({ stdout, stderr }) => ({
|
||||
stdout: stdout.toString("utf8"),
|
||||
stderr: stderr.toString("utf8"),
|
||||
stdoutBytes: stdout.byteLength,
|
||||
stderrBytes: stderr.byteLength,
|
||||
}));
|
||||
@@ -20,18 +20,23 @@ import {
|
||||
/** A short-lived command failed its lifecycle or complete-output contract. */
|
||||
export class OwnedCommandFailure extends Error {
|
||||
readonly cleanupResult: ProcessCleanupFailure | undefined;
|
||||
readonly collectionFailure: boolean;
|
||||
|
||||
constructor(
|
||||
readonly reason: "cancelled" | "timeout" | "output-limit" | "process",
|
||||
message: string,
|
||||
readonly snapshot: ProviderProcessSnapshot | null = null,
|
||||
readonly cleanupFailure: string | null = null,
|
||||
options?: ErrorOptions & { readonly cleanupResult?: ProcessCleanupFailure },
|
||||
options?: ErrorOptions & {
|
||||
readonly cleanupResult?: ProcessCleanupFailure;
|
||||
readonly collectionFailure?: boolean;
|
||||
},
|
||||
readonly resources: readonly string[] = [],
|
||||
readonly cleanupOwner?: ProviderProcessSupervisor,
|
||||
) {
|
||||
super(message, options);
|
||||
this.cleanupResult = options?.cleanupResult;
|
||||
this.collectionFailure = options?.collectionFailure ?? false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,7 +61,7 @@ export const runOwnedCommand = async (
|
||||
readonly acceptNonZeroExit?: boolean;
|
||||
},
|
||||
options: {
|
||||
readonly signal?: AbortSignal;
|
||||
readonly signal?: AbortSignal | undefined;
|
||||
/** Synchronously accept unresolved cleanup ownership; execution still returns its original outcome. Must not throw. */
|
||||
readonly retainCleanup?: (owner: ProviderProcessSupervisor) => void;
|
||||
readonly launcher?: (
|
||||
@@ -156,7 +161,9 @@ const collectOwnedCommand = async (
|
||||
"Command diagnostic output exceeded its complete-output budget.",
|
||||
);
|
||||
if (processFailure !== undefined)
|
||||
throw new OwnedCommandFailure("process", processFailure);
|
||||
throw new OwnedCommandFailure("process", processFailure, null, null, {
|
||||
collectionFailure: true,
|
||||
});
|
||||
await waitForAbortableDelay(25, signal);
|
||||
}
|
||||
const snapshot = supervisor.snapshot();
|
||||
@@ -191,6 +198,8 @@ const collectOwnedCommand = async (
|
||||
processFailure ??
|
||||
"Command failed; inspect its exit status and diagnostics.",
|
||||
snapshot,
|
||||
null,
|
||||
{ collectionFailure: processFailure !== undefined },
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
failure =
|
||||
@@ -219,6 +228,9 @@ const collectOwnedCommand = async (
|
||||
...(stopped.cleanupResult === undefined
|
||||
? {}
|
||||
: { cleanupResult: stopped.cleanupResult }),
|
||||
...(failure?.collectionFailure === true
|
||||
? { collectionFailure: true }
|
||||
: {}),
|
||||
},
|
||||
ownedCommandResources(launched.ownership, spawn.cwd),
|
||||
supervisor,
|
||||
@@ -234,6 +246,7 @@ const collectOwnedCommand = async (
|
||||
...(failure.cleanupResult === undefined
|
||||
? {}
|
||||
: { cleanupResult: failure.cleanupResult }),
|
||||
...(failure.collectionFailure ? { collectionFailure: true } : {}),
|
||||
},
|
||||
failure.resources,
|
||||
);
|
||||
|
||||
@@ -6,10 +6,14 @@ import { WindowsOwnedProcess } from "../windows/WindowsOwnedProcess.js";
|
||||
|
||||
import type {
|
||||
OwnedProcessGroup,
|
||||
ProcessOwnershipBaseline,
|
||||
ProcessCleanupFailure,
|
||||
ProcessCleanupResult,
|
||||
} from "./ProcessOwnership.js";
|
||||
import { prepareProcessOwnershipInspection } from "./ProcessOwnershipObservation.js";
|
||||
import {
|
||||
prepareProcessOwnershipInspection,
|
||||
systemProcessOwnershipHost,
|
||||
} from "./ProcessOwnershipObservation.js";
|
||||
|
||||
const DEFAULT_TERMINATION_GRACE_MS = 250;
|
||||
const DEFAULT_KILL_GRACE_MS = 1_000;
|
||||
@@ -36,6 +40,8 @@ export interface OwnedProviderProcessSpawnOptions {
|
||||
* Windows Job Object. Other launchers cannot enforce it and refuse it.
|
||||
*/
|
||||
readonly memoryLimitBytes?: number;
|
||||
/** Sweep exact run-token descendants during POSIX cleanup. */
|
||||
readonly sweepTokenOwnedProcesses?: boolean;
|
||||
/** Cancels native ownership preparation before provider process creation. */
|
||||
readonly signal?: AbortSignal;
|
||||
}
|
||||
@@ -95,6 +101,8 @@ export interface ProviderProcessSnapshot {
|
||||
readonly diagnosticTruncated?: boolean;
|
||||
readonly stdout: {
|
||||
readonly text: string;
|
||||
/** True when retained bytes cannot be represented exactly as UTF-8 text. */
|
||||
readonly invalidUtf8?: true;
|
||||
/** Bytes retained in text, limited by maxDiagnosticBytes when configured. */
|
||||
readonly bytes: number;
|
||||
/** All bytes drained from this stream since construction or resetOutput. */
|
||||
@@ -102,6 +110,8 @@ export interface ProviderProcessSnapshot {
|
||||
};
|
||||
readonly stderr: {
|
||||
readonly text: string;
|
||||
/** True when retained bytes cannot be represented exactly as UTF-8 text. */
|
||||
readonly invalidUtf8?: true;
|
||||
/** Bytes retained in text, limited by maxDiagnosticBytes when configured. */
|
||||
readonly bytes: number;
|
||||
readonly observedBytes: number;
|
||||
@@ -174,6 +184,16 @@ export const spawnOwnedProviderProcess = async (
|
||||
await prepareProcessOwnershipInspection(options.signal);
|
||||
options.signal?.throwIfAborted();
|
||||
const platform = options.platform ?? process.platform;
|
||||
const sweepTokenOwnedProcesses =
|
||||
options.sweepTokenOwnedProcesses === true && platform !== "win32";
|
||||
let captureBaseline: ProcessOwnershipBaseline | undefined;
|
||||
if (sweepTokenOwnedProcesses) {
|
||||
const capture = systemProcessOwnershipHost.captureBaseline;
|
||||
if (capture === undefined)
|
||||
throw new Error("Process ownership baseline is unavailable on this host");
|
||||
captureBaseline = await capture(options.signal);
|
||||
options.signal?.throwIfAborted();
|
||||
}
|
||||
const hostEnvironment = options.hostEnvironment ?? process.env;
|
||||
const environment = {
|
||||
...hostEnvironment,
|
||||
@@ -226,6 +246,12 @@ export const spawnOwnedProviderProcess = async (
|
||||
leaderPid: pid,
|
||||
processGroupId: pid,
|
||||
expectedParentPid: process.pid,
|
||||
...(sweepTokenOwnedProcesses
|
||||
? {
|
||||
sweepTokenOwnedProcesses: true,
|
||||
captureBaseline: captureBaseline!,
|
||||
}
|
||||
: {}),
|
||||
...(options.expectedCommand === null
|
||||
? {}
|
||||
: { expectedCommand: options.expectedCommand ?? options.command }),
|
||||
@@ -282,12 +308,14 @@ export class ProviderProcessSupervisor {
|
||||
#exitObservation: ProviderProcessExitObservation | undefined;
|
||||
#stopPromise: Promise<ProviderProcessStopResult> | undefined;
|
||||
#disposed = false;
|
||||
#maxDiagnosticBytes: number | undefined;
|
||||
|
||||
constructor(
|
||||
readonly launch: ProviderProcessLaunch,
|
||||
options: ProviderProcessSupervisorOptions = {},
|
||||
) {
|
||||
this.#options = options;
|
||||
this.#maxDiagnosticBytes = options.maxDiagnosticBytes;
|
||||
if (options.captureStdout !== false)
|
||||
this.#attach(launch.process.stdout, "stdout");
|
||||
this.#attach(launch.process.stderr, "stderr");
|
||||
@@ -340,7 +368,7 @@ export class ProviderProcessSupervisor {
|
||||
);
|
||||
}
|
||||
|
||||
/** Wait until the producer has closed its output streams; exit alone does not prove complete output. */
|
||||
/** Wait until output streams close; WindowsOwnedProcess closes only after its whole Job Object settles. */
|
||||
async waitForOutputClose(timeoutMs: number): Promise<boolean> {
|
||||
return waitForProcessEvent(
|
||||
this.#outputClosed,
|
||||
@@ -349,6 +377,35 @@ export class ProviderProcessSupervisor {
|
||||
);
|
||||
}
|
||||
|
||||
/** Bound additional retained bytes while still draining all later output. */
|
||||
limitAdditionalOutputBytes(maxBytes: number): void {
|
||||
if (!Number.isSafeInteger(maxBytes) || maxBytes < 0)
|
||||
throw new RangeError(
|
||||
"Additional output limit must be a non-negative safe integer.",
|
||||
);
|
||||
const retained = this.#stdout.bytes + this.#stderr.bytes;
|
||||
const requestedLimit = Math.min(
|
||||
Number.MAX_SAFE_INTEGER,
|
||||
retained + maxBytes,
|
||||
);
|
||||
this.#maxDiagnosticBytes =
|
||||
this.#maxDiagnosticBytes === undefined
|
||||
? requestedLimit
|
||||
: Math.min(this.#maxDiagnosticBytes, requestedLimit);
|
||||
}
|
||||
|
||||
/** Unreference inherited output pipes without closing streams shared with descendants. */
|
||||
unrefOutputStreams(): void {
|
||||
for (const stream of [
|
||||
this.launch.process.stdout,
|
||||
this.launch.process.stderr,
|
||||
]) {
|
||||
const unref = (stream as (Readable & { unref?: () => void }) | null)
|
||||
?.unref;
|
||||
unref?.call(stream);
|
||||
}
|
||||
}
|
||||
|
||||
/** Share each stop attempt; incomplete cleanup retains ownership and permits retry. */
|
||||
stop(
|
||||
options: ProviderProcessStopOptions = {},
|
||||
@@ -509,13 +566,11 @@ export class ProviderProcessSupervisor {
|
||||
if (stream === "stdout") this.#stdoutObservedBytes += bytes.byteLength;
|
||||
else this.#stderrObservedBytes += bytes.byteLength;
|
||||
const remaining =
|
||||
this.#options.maxDiagnosticBytes === undefined
|
||||
this.#maxDiagnosticBytes === undefined
|
||||
? bytes.byteLength
|
||||
: Math.max(
|
||||
0,
|
||||
this.#options.maxDiagnosticBytes -
|
||||
this.#stdout.bytes -
|
||||
this.#stderr.bytes,
|
||||
this.#maxDiagnosticBytes - this.#stdout.bytes - this.#stderr.bytes,
|
||||
);
|
||||
const retained = Math.min(bytes.byteLength, remaining);
|
||||
if (retained < bytes.byteLength) this.#diagnosticTruncated = true;
|
||||
@@ -564,11 +619,19 @@ class ProcessOutputCapture {
|
||||
}
|
||||
|
||||
snapshot(observedBytes: number): ProviderProcessSnapshot["stdout"] {
|
||||
const text = Buffer.concat(this.#chunks, this.#bytes).toString("utf8");
|
||||
const buffer = Buffer.concat(this.#chunks, this.#bytes);
|
||||
const text = buffer.toString("utf8");
|
||||
let invalidUtf8 = false;
|
||||
try {
|
||||
new TextDecoder("utf-8", { fatal: true }).decode(buffer);
|
||||
} catch {
|
||||
invalidUtf8 = true;
|
||||
}
|
||||
return {
|
||||
text,
|
||||
bytes: this.#bytes,
|
||||
observedBytes,
|
||||
...(invalidUtf8 ? { invalidUtf8: true } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
import { parseLinuxProcessStartTime } from "./ProcessOwnershipObservation.js";
|
||||
|
||||
/** Result of terminating one debugger process owned by this application. */
|
||||
export type OwnedDebuggerStopResult =
|
||||
| { readonly status: "stopped" | "already_exited" }
|
||||
| { readonly status: "incomplete"; readonly reason: string };
|
||||
|
||||
const DETACH_TIMEOUT_MS = 1_000;
|
||||
const MAX_LATE_DEBUGGER_OUTPUT_BYTES = 64 * 1024;
|
||||
export type OwnedDebuggerDetachResult =
|
||||
| { readonly status: "no-inferior" }
|
||||
| { readonly status: "detached"; readonly outputSink: "durable" };
|
||||
export interface OwnedInferiorIdentity {
|
||||
readonly pid: string;
|
||||
readonly startTime: string;
|
||||
}
|
||||
|
||||
export type ProcessStartTimeReader = (pid: string) => Promise<string | null>;
|
||||
|
||||
const readLinuxProcessStartTime: ProcessStartTimeReader = async (pid) => {
|
||||
if (process.platform !== "linux")
|
||||
throw new Error(
|
||||
"Inferior process identity verification requires Linux /proc.",
|
||||
);
|
||||
let stat: string;
|
||||
try {
|
||||
stat = await readFile(`/proc/${pid}/stat`, "utf8");
|
||||
} catch (cause: unknown) {
|
||||
if (
|
||||
typeof cause === "object" &&
|
||||
cause !== null &&
|
||||
"code" in cause &&
|
||||
cause.code === "ENOENT"
|
||||
)
|
||||
return null;
|
||||
throw cause;
|
||||
}
|
||||
const startTime = parseLinuxProcessStartTime(stat);
|
||||
if (startTime === undefined)
|
||||
throw new Error(`Inferior PID ${pid} has an unreadable process identity.`);
|
||||
return startTime;
|
||||
};
|
||||
|
||||
/** Confirm only that every previously identified inferior process has ended. */
|
||||
export const verifyOwnedInferiorsEnded = async (
|
||||
identities: Iterable<OwnedInferiorIdentity>,
|
||||
readStartTime: ProcessStartTimeReader = readLinuxProcessStartTime,
|
||||
): Promise<boolean> => {
|
||||
let foundIdentity = false;
|
||||
for (const identity of identities) {
|
||||
foundIdentity = true;
|
||||
const observed = await readStartTime(identity.pid);
|
||||
if (observed === identity.startTime) return false;
|
||||
}
|
||||
return foundIdentity;
|
||||
};
|
||||
|
||||
const detachAttempts = new WeakMap<
|
||||
object,
|
||||
Promise<OwnedDebuggerDetachResult>
|
||||
>();
|
||||
const detachOutcomes = new WeakMap<
|
||||
object,
|
||||
"confirmed" | "unconfirmed" | "inferior-ended"
|
||||
>();
|
||||
|
||||
const isVerifiedDetachResult = (
|
||||
result: unknown,
|
||||
): result is OwnedDebuggerDetachResult =>
|
||||
typeof result === "object" &&
|
||||
result !== null &&
|
||||
(("status" in result && result.status === "no-inferior") ||
|
||||
("status" in result &&
|
||||
result.status === "detached" &&
|
||||
"outputSink" in result &&
|
||||
result.outputSink === "durable"));
|
||||
|
||||
const waitForInferiorDetach = async (
|
||||
session: object,
|
||||
detach: () => Promise<OwnedDebuggerDetachResult>,
|
||||
): Promise<OwnedDebuggerDetachResult | undefined> => {
|
||||
let attempt = detachAttempts.get(session);
|
||||
if (attempt === undefined) {
|
||||
// Providers may use `this` to access their command transport.
|
||||
const pending = Promise.resolve().then(() => detach.call(session));
|
||||
const tracked = pending.then(
|
||||
(result) => {
|
||||
detachAttempts.delete(session);
|
||||
detachOutcomes.set(
|
||||
session,
|
||||
isVerifiedDetachResult(result) ? "confirmed" : "unconfirmed",
|
||||
);
|
||||
return result;
|
||||
},
|
||||
(cause: unknown) => {
|
||||
detachAttempts.delete(session);
|
||||
detachOutcomes.set(session, "unconfirmed");
|
||||
throw cause;
|
||||
},
|
||||
);
|
||||
attempt = tracked;
|
||||
detachAttempts.set(session, tracked);
|
||||
}
|
||||
let timer!: NodeJS.Timeout;
|
||||
try {
|
||||
return await Promise.race([
|
||||
attempt,
|
||||
new Promise<undefined>((resolve) => {
|
||||
timer = setTimeout(() => resolve(undefined), DETACH_TIMEOUT_MS);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
};
|
||||
|
||||
interface OwnedDebuggerProcess {
|
||||
readonly id: string;
|
||||
readonly exited: boolean;
|
||||
readonly inferiorStateUnknown?: () => boolean;
|
||||
readonly verifyInferiorsEndedAfterExit?: () => Promise<boolean>;
|
||||
readonly launched: {
|
||||
readonly process: { kill(signal: NodeJS.Signals): boolean };
|
||||
};
|
||||
/** Must return only after the provider verifies detachment or no inferior. */
|
||||
/** Confirms no inferior, or detaches only after verifying a durable output sink. */
|
||||
readonly detachInferior?: () => Promise<OwnedDebuggerDetachResult>;
|
||||
readonly supervisor: {
|
||||
waitForExit(timeoutMs: number): Promise<boolean>;
|
||||
/** WindowsOwnedProcess closes output only after the whole Job Object settles. */
|
||||
waitForOutputClose(timeoutMs: number): Promise<boolean>;
|
||||
unrefOutputStreams(): void;
|
||||
/** Cap bytes retained after debugger shutdown while continuing to drain. */
|
||||
limitAdditionalOutputBytes(maxBytes: number): void;
|
||||
dispose(): void;
|
||||
};
|
||||
}
|
||||
|
||||
const finishExitedDebuggerSession = async (
|
||||
session: OwnedDebuggerProcess,
|
||||
debuggerName: string,
|
||||
platform: NodeJS.Platform,
|
||||
status: "stopped" | "already_exited",
|
||||
): Promise<OwnedDebuggerStopResult> => {
|
||||
try {
|
||||
session.supervisor.limitAdditionalOutputBytes(
|
||||
MAX_LATE_DEBUGGER_OUTPUT_BYTES,
|
||||
);
|
||||
if (!(await session.supervisor.waitForOutputClose(1_000))) {
|
||||
return {
|
||||
status: "incomplete",
|
||||
reason:
|
||||
platform === "win32"
|
||||
? `Owned ${debuggerName} process leader exited, but its Windows Job Object still has active descendants or has not reported settlement.`
|
||||
: `Owned ${debuggerName} process leader exited, but inherited output streams are still open or have not reported settlement.`,
|
||||
};
|
||||
}
|
||||
} catch (cause: unknown) {
|
||||
return {
|
||||
status: "incomplete",
|
||||
reason:
|
||||
platform === "win32"
|
||||
? `Owned ${debuggerName} Windows Job Object settlement could not be verified: ${cause instanceof Error ? cause.message : String(cause)}`
|
||||
: `Owned ${debuggerName} inherited output stream settlement could not be verified: ${cause instanceof Error ? cause.message : String(cause)}`,
|
||||
};
|
||||
}
|
||||
if (platform !== "win32") session.supervisor.unrefOutputStreams();
|
||||
session.supervisor.dispose();
|
||||
return { status };
|
||||
};
|
||||
|
||||
/** Stop only the directly owned debugger process, never its process group or target. */
|
||||
export const stopOwnedDebuggerProcess = async (
|
||||
session: OwnedDebuggerProcess,
|
||||
debuggerName: string,
|
||||
options: { readonly platform?: NodeJS.Platform } = {},
|
||||
): Promise<OwnedDebuggerStopResult> => {
|
||||
const platform = options.platform ?? process.platform;
|
||||
if (session.exited) {
|
||||
if (detachAttempts.has(session))
|
||||
return {
|
||||
status: "incomplete",
|
||||
reason: `Owned ${debuggerName} inferior detach is still pending after the process exited.`,
|
||||
};
|
||||
const detachOutcome = detachOutcomes.get(session);
|
||||
if (
|
||||
detachOutcome === "unconfirmed" ||
|
||||
(detachOutcome === undefined && session.inferiorStateUnknown?.() === true)
|
||||
) {
|
||||
try {
|
||||
if ((await session.verifyInferiorsEndedAfterExit?.()) !== true)
|
||||
return {
|
||||
status: "incomplete",
|
||||
reason: `Owned ${debuggerName} inferior disposition was not verified before the debugger exited, and no owner proof confirms the identified inferior processes have ended.`,
|
||||
};
|
||||
detachOutcomes.set(session, "inferior-ended");
|
||||
} catch (cause: unknown) {
|
||||
return {
|
||||
status: "incomplete",
|
||||
reason: `Owned ${debuggerName} inferior process identity could not be verified after debugger exit: ${cause instanceof Error ? cause.message : String(cause)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
return finishExitedDebuggerSession(
|
||||
session,
|
||||
debuggerName,
|
||||
platform,
|
||||
"already_exited",
|
||||
);
|
||||
}
|
||||
if (platform === "win32")
|
||||
return {
|
||||
status: "incomplete",
|
||||
reason: `Owned ${debuggerName} process cannot be stopped safely on Windows because job termination can also terminate its inferior processes.`,
|
||||
};
|
||||
try {
|
||||
if (session.detachInferior === undefined) {
|
||||
detachOutcomes.set(session, "unconfirmed");
|
||||
return {
|
||||
status: "incomplete",
|
||||
reason: `Owned ${debuggerName} inferior detach cannot be verified because the provider has no detach capability.`,
|
||||
};
|
||||
}
|
||||
const detached = await waitForInferiorDetach(
|
||||
session,
|
||||
session.detachInferior,
|
||||
);
|
||||
if (detached === undefined)
|
||||
return {
|
||||
status: "incomplete",
|
||||
reason: `Owned ${debuggerName} inferior detach was not verified within ${DETACH_TIMEOUT_MS} ms.`,
|
||||
};
|
||||
if (!isVerifiedDetachResult(detached)) {
|
||||
detachOutcomes.set(session, "unconfirmed");
|
||||
return {
|
||||
status: "incomplete",
|
||||
reason: `Owned ${debuggerName} provider did not confirm no inferior or detachment with a durable output sink.`,
|
||||
};
|
||||
}
|
||||
detachOutcomes.set(session, "confirmed");
|
||||
} catch (cause: unknown) {
|
||||
return {
|
||||
status: "incomplete",
|
||||
reason: `Owned ${debuggerName} inferior detach could not be verified: ${cause instanceof Error ? cause.message : String(cause)}`,
|
||||
};
|
||||
}
|
||||
session.launched.process.kill("SIGTERM");
|
||||
if (!(await session.supervisor.waitForExit(300))) {
|
||||
session.launched.process.kill("SIGKILL");
|
||||
const exitedAfterKill = await session.supervisor.waitForExit(1_000);
|
||||
if (exitedAfterKill)
|
||||
return finishExitedDebuggerSession(
|
||||
session,
|
||||
debuggerName,
|
||||
platform,
|
||||
"stopped",
|
||||
);
|
||||
return {
|
||||
status: "incomplete",
|
||||
reason: `Owned ${debuggerName} process did not exit after direct termination.`,
|
||||
};
|
||||
}
|
||||
return finishExitedDebuggerSession(
|
||||
session,
|
||||
debuggerName,
|
||||
platform,
|
||||
"stopped",
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Stop every owned session and retain failed sessions for possible recovery.
|
||||
* Callers must block replacement admission and drain in-flight starts until
|
||||
* this settles; incomplete cleanup leaves the old owner in the map for retry.
|
||||
*/
|
||||
export const closeOwnedDebuggerSessions = async <
|
||||
Session extends { id: string },
|
||||
>(
|
||||
sessions: Map<string, Session>,
|
||||
stop: (session: Session) => Promise<OwnedDebuggerStopResult>,
|
||||
debuggerName: string,
|
||||
): Promise<void> => {
|
||||
const results = await Promise.allSettled(
|
||||
[...sessions.entries()].map(async ([key, session]) => ({
|
||||
key,
|
||||
session,
|
||||
result: await stop(session),
|
||||
})),
|
||||
);
|
||||
const failures: Error[] = [];
|
||||
for (const settled of results) {
|
||||
if (settled.status === "rejected") {
|
||||
failures.push(
|
||||
settled.reason instanceof Error
|
||||
? settled.reason
|
||||
: new Error(String(settled.reason)),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const { key, session, result } = settled.value;
|
||||
if (result.status === "incomplete") failures.push(new Error(result.reason));
|
||||
else if (sessions.get(key) === session) sessions.delete(key);
|
||||
}
|
||||
if (failures.length > 0)
|
||||
throw new AggregateError(
|
||||
failures,
|
||||
`One or more owned ${debuggerName} processes did not stop cleanly`,
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import type { RizinInput } from "../domain/reverseEngineering.js";
|
||||
|
||||
export const RIZIN_PROVIDER_IDENTITY = {
|
||||
id: "rizin",
|
||||
name: "Rizin",
|
||||
version: null,
|
||||
} as const;
|
||||
|
||||
/** Build a one-shot rz-pipe compatible command while retaining plugin availability. */
|
||||
export const rizinCommand = (input: RizinInput): readonly string[] => [
|
||||
"-N",
|
||||
"-q",
|
||||
"-c",
|
||||
input.command,
|
||||
resolve(input.path),
|
||||
];
|
||||
File diff suppressed because it is too large.
Load diff
@@ -8,6 +8,7 @@ import type { WasmArtifactService } from "../application/wasm/WasmArtifactServic
|
||||
import { wasmArtifactModule } from "../composition/wasm.js";
|
||||
import { registerWasmTools } from "./registerWasmTools.js";
|
||||
import { snapshotEnvironment } from "../process/snapshotEnvironment.js";
|
||||
import { captureProviderEnvironment } from "./providerEnvironment.js";
|
||||
import { createPeResourcesService } from "../composition/binaryDiagnostics.js";
|
||||
import type { PeResourcesService } from "../application/binaryDiagnostics/PeResourcesService.js";
|
||||
import { registerPeResourcesTool } from "./registerPeResourcesTool.js";
|
||||
@@ -122,6 +123,15 @@ import {
|
||||
type ServerAnalysisSource,
|
||||
} from "./analysisAdmission.js";
|
||||
import type { WithAdmittedAnalysis } from "./analysisAdmission.js";
|
||||
import { createReverseEngineeringService } from "../composition/reverseEngineering.js";
|
||||
import { registerReverseEngineeringTools } from "./registerReverseEngineeringTools.js";
|
||||
import { CutterBridgeClient } from "../cutter/CutterBridgeClient.js";
|
||||
import { CutterBridgeService } from "../cutter/CutterBridgeService.js";
|
||||
import { registerCutterTools } from "./registerCutterTools.js";
|
||||
import { GdbSessionManager } from "../gdb/GdbSessionManager.js";
|
||||
import { registerGdbTools } from "./registerGdbTools.js";
|
||||
import { RizinDebugSessionManager } from "../rizin/RizinDebugSessionManager.js";
|
||||
import { registerRizinDebugTools } from "./registerRizinDebugTools.js";
|
||||
|
||||
const TARGET_FREE_INSTRUCTIONS =
|
||||
"REA provides reverse-engineering tools for local artifacts, native binaries, managed code, browser pages, and runtimes. Use the tool that directly answers the question; discover targets or inspect inventory only when needed. Tool results include inline Evidence and report their coverage and limitations.";
|
||||
@@ -139,6 +149,7 @@ export interface CreateServerOptions {
|
||||
readonly packetCapture?: PacketCaptureService;
|
||||
readonly signalRecording?: SignalRecordingService;
|
||||
readonly wasmArtifact?: WasmArtifactService;
|
||||
readonly providerEnvironment?: Readonly<NodeJS.ProcessEnv>;
|
||||
readonly evmInterface?: EvmInterfaceService;
|
||||
readonly sqliteDatabase?: SqliteDatabaseService;
|
||||
readonly logger?: Logger;
|
||||
@@ -272,6 +283,10 @@ export const createServer = (
|
||||
const session = source.kind === "session" ? source.session : undefined;
|
||||
const analysis = source.kind === "session" ? source.session : source.analysis;
|
||||
const environment = snapshotEnvironment(options.environment ?? process.env);
|
||||
const providerEnvironment = captureProviderEnvironment(
|
||||
options.providerEnvironment,
|
||||
environment,
|
||||
);
|
||||
const delivery = selectToolResultDelivery(environment, options.delivery);
|
||||
const evmInterface =
|
||||
options.evmInterface ?? evmInterfaceModule.create(environment);
|
||||
@@ -292,6 +307,8 @@ export const createServer = (
|
||||
const javascriptRecovery =
|
||||
options.javascriptRecovery ?? createJavaScriptRecoveryProvider(environment);
|
||||
const artifactResources = new ArtifactResourceScope();
|
||||
const reverseEngineering =
|
||||
createReverseEngineeringService(providerEnvironment);
|
||||
const firmwareAnalysis = new FirmwareAnalysisService(
|
||||
options.firmwareAnalysis ?? createFirmwareAnalysisProvider(environment),
|
||||
);
|
||||
@@ -314,6 +331,10 @@ export const createServer = (
|
||||
delivery,
|
||||
selectCompactInputSchemaPresentation(environment),
|
||||
);
|
||||
const gdbSessions = new GdbSessionManager({
|
||||
environment: providerEnvironment,
|
||||
});
|
||||
const rizinDebugSessions = new RizinDebugSessionManager(providerEnvironment);
|
||||
const android =
|
||||
options.androidAnalysis ?? createAndroidAnalysisProvider(environment);
|
||||
const pcap = options.pcapAnalysis ?? createPcapAnalysisProvider(environment);
|
||||
@@ -359,7 +380,10 @@ export const createServer = (
|
||||
webNetworkCapture.close(),
|
||||
javascriptRecovery.close?.() ?? Promise.resolve(),
|
||||
artifactResources.close(),
|
||||
reverseEngineering.close(),
|
||||
firmwareAnalysis.close(),
|
||||
gdbSessions.closeAll(),
|
||||
rizinDebugSessions.closeAll(),
|
||||
])
|
||||
.then((results) => {
|
||||
const failures = results.flatMap((result) =>
|
||||
@@ -405,6 +429,28 @@ export const createServer = (
|
||||
webNetworkCapture,
|
||||
};
|
||||
registerBinaryAnalysisTools(toolContext);
|
||||
registerReverseEngineeringTools(
|
||||
server,
|
||||
reverseEngineering,
|
||||
toolLogger,
|
||||
delivery,
|
||||
session === undefined
|
||||
? undefined
|
||||
: (evidence) => session.recordEvidence(evidence),
|
||||
analysisAdmission,
|
||||
session === undefined
|
||||
? undefined
|
||||
: () => session.invalidateSnapshotForExternalMutation(),
|
||||
session === undefined
|
||||
? undefined
|
||||
: (path, signal, operation, rebindFailure) =>
|
||||
session.withAdmittedExternalMutation(
|
||||
path,
|
||||
signal,
|
||||
operation,
|
||||
rebindFailure,
|
||||
),
|
||||
);
|
||||
registerFridaTools(
|
||||
server,
|
||||
fridaInstrumentation,
|
||||
@@ -412,7 +458,37 @@ export const createServer = (
|
||||
delivery,
|
||||
session === undefined
|
||||
? undefined
|
||||
: (input) => session.recordEvidence(input),
|
||||
: (evidence) => session.recordEvidence(evidence),
|
||||
);
|
||||
registerCutterTools(
|
||||
server,
|
||||
new CutterBridgeService(new CutterBridgeClient(providerEnvironment)),
|
||||
toolLogger,
|
||||
delivery,
|
||||
session === undefined
|
||||
? undefined
|
||||
: (evidence) => session.recordEvidence(evidence),
|
||||
session,
|
||||
);
|
||||
registerGdbTools(
|
||||
server,
|
||||
gdbSessions,
|
||||
toolLogger,
|
||||
delivery,
|
||||
session === undefined
|
||||
? undefined
|
||||
: (evidence) => session.recordEvidence(evidence),
|
||||
session,
|
||||
);
|
||||
registerRizinDebugTools(
|
||||
server,
|
||||
rizinDebugSessions,
|
||||
toolLogger,
|
||||
delivery,
|
||||
session === undefined
|
||||
? undefined
|
||||
: (evidence) => session.recordEvidence(evidence),
|
||||
session,
|
||||
);
|
||||
const previousOnclose = server.server.onclose;
|
||||
server.server.onclose = () => {
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import type { BinarySessionPort } from "../application/binary/BinarySessionPort.js";
|
||||
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import { AnalysisOutputError } from "../domain/analysisErrorCore.js";
|
||||
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
||||
import type { Evidence } from "../domain/evidence.js";
|
||||
import { err, type Result } from "../domain/result.js";
|
||||
|
||||
export type ExternalMutationAdmission = Pick<
|
||||
BinarySessionPort,
|
||||
"withAdmittedExternalMutation"
|
||||
>;
|
||||
|
||||
/** Shared MCP admission and failure preservation for unrestricted commands. */
|
||||
export const runWithExternalMutationAdmission = async <Value>(
|
||||
session: ExternalMutationAdmission | undefined,
|
||||
operationName: string,
|
||||
signal: AbortSignal | undefined,
|
||||
operation: () => Promise<Result<Value, AnalysisError>>,
|
||||
evidenceOf: ((value: Value) => Evidence) | undefined,
|
||||
cleanupRecovery?: (
|
||||
failure: AnalysisError | undefined,
|
||||
) => Promise<Result<null, AnalysisError>>,
|
||||
retainedDetails?: (value: Value) => Readonly<Record<string, string>>,
|
||||
): Promise<Result<Value, AnalysisError>> => {
|
||||
if (session === undefined) {
|
||||
const recovered = await cleanupRecovery?.(undefined);
|
||||
if (recovered !== undefined && !recovered.ok) return recovered;
|
||||
return operation();
|
||||
}
|
||||
const admitted = await session.withAdmittedExternalMutation(
|
||||
undefined,
|
||||
signal,
|
||||
operation,
|
||||
(value, cause) =>
|
||||
value.ok
|
||||
? new AnalysisOutputError(
|
||||
operationName,
|
||||
"The external command may have changed the bound artifact, but the active analysis session could not be retired.",
|
||||
{
|
||||
cause,
|
||||
...(evidenceOf === undefined
|
||||
? {}
|
||||
: { partialObservation: evidenceOf(value.value) }),
|
||||
...(cause.cleanup === undefined
|
||||
? {}
|
||||
: { cleanup: cause.cleanup }),
|
||||
...(cause.capturedOutput === undefined
|
||||
? {}
|
||||
: { capturedOutput: cause.capturedOutput }),
|
||||
...(retainedDetails === undefined
|
||||
? {}
|
||||
: { diagnostics: retainedDetails(value.value) }),
|
||||
},
|
||||
)
|
||||
: value.error.retainSecondaryFailure(cause, {
|
||||
retirement_error: projectAnalysisError(cause),
|
||||
}),
|
||||
operationName,
|
||||
cleanupRecovery,
|
||||
);
|
||||
return admitted.ok ? admitted.value : err(admitted.error);
|
||||
};
|
||||
|
||||
/** Admit an owned close once, including when it is recovering its own blocker. */
|
||||
export const runOwnedCleanupWithExternalMutationAdmission = async <Value>(
|
||||
session: ExternalMutationAdmission | undefined,
|
||||
operationName: string,
|
||||
signal: AbortSignal | undefined,
|
||||
resourceId: string,
|
||||
cleanup: () => Promise<Result<Value, AnalysisError>>,
|
||||
): Promise<Result<Value, AnalysisError>> => {
|
||||
let completed: Result<Value, AnalysisError> | undefined;
|
||||
const cleanupOnce = async (): Promise<Result<Value, AnalysisError>> => {
|
||||
completed ??= await cleanup();
|
||||
return completed;
|
||||
};
|
||||
const admitted = await runWithExternalMutationAdmission(
|
||||
session,
|
||||
operationName,
|
||||
signal,
|
||||
cleanupOnce,
|
||||
undefined,
|
||||
async (failure) => {
|
||||
if (
|
||||
failure === undefined ||
|
||||
!failure.cleanup?.resources.includes(resourceId)
|
||||
)
|
||||
return failure === undefined
|
||||
? { ok: true, value: null }
|
||||
: { ok: false, error: failure };
|
||||
const recovered = await cleanupOnce();
|
||||
return recovered.ok ? { ok: true, value: null } : recovered;
|
||||
},
|
||||
);
|
||||
return admitted;
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
import { snapshotEnvironment } from "../process/snapshotEnvironment.js";
|
||||
|
||||
/** Capture the selected provider environment at server construction time. */
|
||||
export const captureProviderEnvironment = (
|
||||
providerEnvironment: Readonly<NodeJS.ProcessEnv> | undefined,
|
||||
serverEnvironment: Readonly<NodeJS.ProcessEnv>,
|
||||
): Readonly<NodeJS.ProcessEnv> =>
|
||||
snapshotEnvironment(providerEnvironment ?? serverEnvironment);
|
||||
@@ -0,0 +1,79 @@
|
||||
import type { McpServer } from "@modelcontextprotocol/server";
|
||||
import type { Logger } from "pino";
|
||||
|
||||
import type { CutterBridgeService } from "../cutter/CutterBridgeService.js";
|
||||
import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js";
|
||||
import { toolContract } from "../contracts/toolContracts.js";
|
||||
import { ok } from "../domain/result.js";
|
||||
import { evidenceSchema } from "../domain/evidence.js";
|
||||
import {
|
||||
runWithExternalMutationAdmission,
|
||||
type ExternalMutationAdmission,
|
||||
} from "./externalMutationAdmission.js";
|
||||
import { logToolExecution } from "./toolLogging.js";
|
||||
import { toolRegistrationOptions } from "./toolRegistrationOptions.js";
|
||||
import type { ToolResultDelivery } from "./toolResult.js";
|
||||
|
||||
/** Register Cutter discovery and command tools against their named contracts. */
|
||||
export const registerCutterTools = (
|
||||
server: McpServer,
|
||||
service: CutterBridgeService,
|
||||
logger: Logger,
|
||||
delivery: ToolResultDelivery,
|
||||
recordEvidence?: EvidenceWriter["recordEvidence"],
|
||||
session?: ExternalMutationAdmission,
|
||||
): void => {
|
||||
const list = toolContract("list_cutter_sessions");
|
||||
const command = toolContract("cutter_command");
|
||||
server.registerTool(list.name, toolRegistrationOptions(list), async () => {
|
||||
const result = await logToolExecution(logger, list.name, async () =>
|
||||
ok(await service.listSessions()),
|
||||
);
|
||||
if (!result.ok) return delivery.toCallToolResult(result, list);
|
||||
return delivery.toCallToolResult(
|
||||
{
|
||||
ok: true,
|
||||
value: { ...result.value, sessions: [...result.value.sessions] },
|
||||
},
|
||||
list,
|
||||
);
|
||||
});
|
||||
server.registerTool(
|
||||
command.name,
|
||||
toolRegistrationOptions(command),
|
||||
async (input, context) => {
|
||||
const result = await runWithExternalMutationAdmission(
|
||||
session,
|
||||
command.name,
|
||||
context.mcpReq.signal,
|
||||
() =>
|
||||
logToolExecution(logger, command.name, () =>
|
||||
service.execute(input, { signal: context.mcpReq.signal }),
|
||||
),
|
||||
(value) => value,
|
||||
() => service.retryCleanup(),
|
||||
);
|
||||
if (!result.ok) {
|
||||
const partial = evidenceSchema.safeParse(
|
||||
result.error.partialObservation,
|
||||
);
|
||||
if (partial.success) {
|
||||
const recorded = recordEvidence?.(partial.data);
|
||||
if (recorded !== undefined)
|
||||
return delivery.toRecordedPartialErrorToolResult(
|
||||
result.error,
|
||||
partial.data.evidence_id,
|
||||
recorded,
|
||||
command.returnsTargetContent,
|
||||
);
|
||||
}
|
||||
return delivery.toCallToolResult(result, command);
|
||||
}
|
||||
return delivery.toEvidenceToolResult(
|
||||
result.value,
|
||||
command,
|
||||
recordEvidence?.(result.value),
|
||||
);
|
||||
},
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,156 @@
|
||||
import type { McpServer, ServerContext } from "@modelcontextprotocol/server";
|
||||
import type { Logger } from "pino";
|
||||
|
||||
import type { GdbSessionManager } from "../gdb/GdbSessionManager.js";
|
||||
import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js";
|
||||
import { toolContract } from "../contracts/toolContracts.js";
|
||||
import { evidenceSchema } from "../domain/evidence.js";
|
||||
import { err } from "../domain/result.js";
|
||||
import { AnalysisInputError } from "../domain/analysisErrorCore.js";
|
||||
import {
|
||||
runOwnedCleanupWithExternalMutationAdmission,
|
||||
runWithExternalMutationAdmission,
|
||||
type ExternalMutationAdmission,
|
||||
} from "./externalMutationAdmission.js";
|
||||
import { logToolExecution } from "./toolLogging.js";
|
||||
import { toolRegistrationOptions } from "./toolRegistrationOptions.js";
|
||||
import type { ToolResultDelivery } from "./toolResult.js";
|
||||
|
||||
export type GdbToolManager = Pick<
|
||||
GdbSessionManager,
|
||||
"start" | "execute" | "status" | "close" | "retryCleanupFailures"
|
||||
>;
|
||||
|
||||
/** Register persistent GDB session operations on the existing REA MCP server. */
|
||||
export const registerGdbTools = (
|
||||
server: McpServer,
|
||||
manager: GdbToolManager,
|
||||
logger: Logger,
|
||||
delivery: ToolResultDelivery,
|
||||
recordEvidence?: EvidenceWriter["recordEvidence"],
|
||||
session?: ExternalMutationAdmission,
|
||||
): void => {
|
||||
const start = toolContract("start_gdb_session");
|
||||
const consoleContract = toolContract("gdb_console");
|
||||
const status = toolContract("gdb_session_status");
|
||||
const close = toolContract("close_gdb_session");
|
||||
server.registerTool(
|
||||
start.name,
|
||||
toolRegistrationOptions(start),
|
||||
async (input, context: ServerContext) => {
|
||||
const result = await logToolExecution(logger, start.name, () =>
|
||||
manager.start(
|
||||
context.mcpReq.signal,
|
||||
input.inferior_tty === undefined
|
||||
? {}
|
||||
: { inferiorTty: input.inferior_tty },
|
||||
),
|
||||
);
|
||||
if (result.ok)
|
||||
return delivery.toCallToolResult(
|
||||
{ ok: true, value: result.value },
|
||||
start,
|
||||
);
|
||||
const partial = result.error.partialObservation;
|
||||
const parsed = evidenceSchema.safeParse(partial);
|
||||
if (parsed.success) {
|
||||
const recorded = recordEvidence?.(parsed.data);
|
||||
if (recorded !== undefined)
|
||||
return delivery.toRecordedPartialErrorToolResult(
|
||||
result.error,
|
||||
parsed.data.evidence_id,
|
||||
recorded,
|
||||
start.returnsTargetContent,
|
||||
);
|
||||
}
|
||||
return delivery.toCallToolResult(result, start);
|
||||
},
|
||||
);
|
||||
server.registerTool(
|
||||
consoleContract.name,
|
||||
toolRegistrationOptions(consoleContract),
|
||||
async (input, context: ServerContext) => {
|
||||
const execute = () =>
|
||||
logToolExecution(logger, consoleContract.name, () =>
|
||||
manager.execute(
|
||||
input.session_id,
|
||||
input.command,
|
||||
context.mcpReq.signal,
|
||||
),
|
||||
);
|
||||
const result = await runWithExternalMutationAdmission(
|
||||
session,
|
||||
consoleContract.name,
|
||||
context.mcpReq.signal,
|
||||
execute,
|
||||
(value) => value.evidence,
|
||||
() => manager.retryCleanupFailures(),
|
||||
);
|
||||
if (!result.ok) {
|
||||
const partial = result.error.partialObservation;
|
||||
const parsed = evidenceSchema.safeParse(partial);
|
||||
if (parsed.success) {
|
||||
const recorded = recordEvidence?.(parsed.data);
|
||||
if (recorded !== undefined)
|
||||
return delivery.toRecordedPartialErrorToolResult(
|
||||
result.error,
|
||||
parsed.data.evidence_id,
|
||||
recorded,
|
||||
consoleContract.returnsTargetContent,
|
||||
);
|
||||
}
|
||||
return delivery.toCallToolResult(result, consoleContract);
|
||||
}
|
||||
const recorded = recordEvidence?.(result.value.evidence);
|
||||
return delivery.toEvidenceToolResult(
|
||||
result.value.evidence,
|
||||
consoleContract,
|
||||
recorded,
|
||||
);
|
||||
},
|
||||
);
|
||||
server.registerTool(
|
||||
status.name,
|
||||
toolRegistrationOptions(status),
|
||||
async (input) => {
|
||||
const result = manager.status(input.session_id);
|
||||
if (result === undefined)
|
||||
return delivery.toCallToolResult(
|
||||
err(invalidSession("gdb_session_status")),
|
||||
status,
|
||||
);
|
||||
return delivery.toCallToolResult(
|
||||
{
|
||||
ok: true,
|
||||
value: {
|
||||
...result,
|
||||
recent_mi_records: [...result.recent_mi_records],
|
||||
},
|
||||
},
|
||||
status,
|
||||
);
|
||||
},
|
||||
);
|
||||
server.registerTool(
|
||||
close.name,
|
||||
toolRegistrationOptions(close),
|
||||
async (input, context: ServerContext) => {
|
||||
const result = await runOwnedCleanupWithExternalMutationAdmission(
|
||||
session,
|
||||
close.name,
|
||||
context.mcpReq.signal,
|
||||
input.session_id,
|
||||
() =>
|
||||
logToolExecution(logger, close.name, () =>
|
||||
manager.close(input.session_id),
|
||||
),
|
||||
);
|
||||
return result.ok
|
||||
? delivery.toCallToolResult({ ok: true, value: result.value }, close)
|
||||
: delivery.toCallToolResult(result, close);
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const invalidSession = (operation: string): AnalysisInputError =>
|
||||
new AnalysisInputError(operation);
|
||||
@@ -0,0 +1,207 @@
|
||||
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
||||
import type {
|
||||
CallToolResult,
|
||||
McpServer,
|
||||
ServerContext,
|
||||
} from "@modelcontextprotocol/server";
|
||||
import type { Logger } from "pino";
|
||||
|
||||
import type { ReverseEngineeringService } from "../application/reverse/ReverseEngineeringService.js";
|
||||
import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js";
|
||||
import type { Evidence } from "../domain/evidence.js";
|
||||
import { toolContract } from "../contracts/toolContracts.js";
|
||||
import type { ToolContract } from "../contracts/toolContractTypes.js";
|
||||
import { logToolExecution } from "./toolLogging.js";
|
||||
import { toolRegistrationOptions } from "./toolRegistrationOptions.js";
|
||||
import type { ToolResultDelivery } from "./toolResult.js";
|
||||
import type { WithAdmittedAnalysis } from "./analysisAdmission.js";
|
||||
import { rizinInputSchema } from "../domain/reverseEngineering.js";
|
||||
import { AnalysisOutputError } from "../domain/analysisErrorCore.js";
|
||||
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import type { Result } from "../domain/result.js";
|
||||
|
||||
/** Register objdump and Rizin operations against their exact named contracts. */
|
||||
export const registerReverseEngineeringTools = (
|
||||
server: McpServer,
|
||||
service: ReverseEngineeringService,
|
||||
logger: Logger,
|
||||
delivery: ToolResultDelivery,
|
||||
recordEvidence?: EvidenceWriter["recordEvidence"],
|
||||
withAdmittedAnalysis?: WithAdmittedAnalysis,
|
||||
invalidateSnapshotForExternalMutation?: () => void,
|
||||
withAdmittedExternalMutation?: <Value>(
|
||||
path: string | undefined,
|
||||
signal: AbortSignal | undefined,
|
||||
operation: () => Promise<Result<Value, AnalysisError>>,
|
||||
retirementFailure: (
|
||||
value: Result<Value, AnalysisError>,
|
||||
error: AnalysisError,
|
||||
) => AnalysisError,
|
||||
) => Promise<Result<Result<Value, AnalysisError>, AnalysisError>>,
|
||||
): void => {
|
||||
const objdump = toolContract("inspect_with_objdump");
|
||||
const rizin = toolContract("execute_rizin_command");
|
||||
server.registerTool(
|
||||
objdump.name,
|
||||
toolRegistrationOptions(objdump),
|
||||
(input, context: ServerContext) =>
|
||||
runAdmitted(
|
||||
objdump,
|
||||
context,
|
||||
withAdmittedAnalysis,
|
||||
delivery,
|
||||
async () => {
|
||||
const result = await logToolExecution(logger, objdump.name, () =>
|
||||
service.inspectWithObjdump(input, {
|
||||
signal: context.mcpReq.signal,
|
||||
}),
|
||||
);
|
||||
if (!result.ok)
|
||||
return deliverFailure(
|
||||
result.error,
|
||||
objdump,
|
||||
delivery,
|
||||
recordEvidence,
|
||||
);
|
||||
return delivery.toEvidenceToolResult(
|
||||
result.value,
|
||||
objdump,
|
||||
recordEvidence?.(result.value),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
server.registerTool(
|
||||
rizin.name,
|
||||
toolRegistrationOptions(rizin),
|
||||
async (input, context: ServerContext) => {
|
||||
const parsed = rizinInputSchema.safeParse(input);
|
||||
if (parsed.success && withAdmittedExternalMutation !== undefined) {
|
||||
const cleanup = await service.retryCleanup(rizin.name);
|
||||
if (!cleanup.ok)
|
||||
return deliverFailure(cleanup.error, rizin, delivery, recordEvidence);
|
||||
const admitted = await withAdmittedExternalMutation(
|
||||
parsed.data.path,
|
||||
context.mcpReq.signal,
|
||||
async () => {
|
||||
invalidateSnapshotForExternalMutation?.();
|
||||
return logToolExecution(logger, rizin.name, () =>
|
||||
service.executeRizinCommand(parsed.data, {
|
||||
signal: context.mcpReq.signal,
|
||||
}),
|
||||
);
|
||||
},
|
||||
(value, cause) => {
|
||||
if (!value.ok)
|
||||
return value.error.retainSecondaryFailure(cause, {
|
||||
retirement_error: projectAnalysisError(cause),
|
||||
});
|
||||
const observation = value.value;
|
||||
return new AnalysisOutputError(
|
||||
rizin.name,
|
||||
"Rizin may have changed the bound artifact, but the active analysis session could not be retired.",
|
||||
{
|
||||
cause,
|
||||
...(cause.cleanup === undefined
|
||||
? {}
|
||||
: { cleanup: cause.cleanup }),
|
||||
...(cause.capturedOutput === undefined
|
||||
? {}
|
||||
: { capturedOutput: cause.capturedOutput }),
|
||||
...(observation === undefined
|
||||
? {}
|
||||
: { partialObservation: observation }),
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
if (!admitted.ok)
|
||||
return deliverFailure(
|
||||
admitted.error,
|
||||
rizin,
|
||||
delivery,
|
||||
recordEvidence,
|
||||
);
|
||||
const result = admitted.value;
|
||||
if (!result.ok)
|
||||
return deliverFailure(result.error, rizin, delivery, recordEvidence);
|
||||
return delivery.toEvidenceToolResult(
|
||||
result.value,
|
||||
rizin,
|
||||
recordEvidence?.(result.value),
|
||||
);
|
||||
}
|
||||
return runAdmitted(
|
||||
rizin,
|
||||
context,
|
||||
withAdmittedAnalysis,
|
||||
delivery,
|
||||
async () => {
|
||||
if (
|
||||
invalidateSnapshotForExternalMutation !== undefined &&
|
||||
rizinInputSchema.safeParse(input).success
|
||||
)
|
||||
invalidateSnapshotForExternalMutation();
|
||||
const result = await logToolExecution(logger, rizin.name, () =>
|
||||
service.executeRizinCommand(input, {
|
||||
signal: context.mcpReq.signal,
|
||||
}),
|
||||
);
|
||||
if (!result.ok)
|
||||
return deliverFailure(
|
||||
result.error,
|
||||
rizin,
|
||||
delivery,
|
||||
recordEvidence,
|
||||
);
|
||||
return delivery.toEvidenceToolResult(
|
||||
result.value,
|
||||
rizin,
|
||||
recordEvidence?.(result.value),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const runAdmitted = async (
|
||||
contract: ToolContract,
|
||||
context: ServerContext,
|
||||
admission: WithAdmittedAnalysis | undefined,
|
||||
delivery: ToolResultDelivery,
|
||||
operation: () => Promise<CallToolResult>,
|
||||
): Promise<CallToolResult> => {
|
||||
if (admission === undefined) return operation();
|
||||
const admitted = await admission(
|
||||
contract.name,
|
||||
context.mcpReq.signal,
|
||||
async () => operation(),
|
||||
);
|
||||
return admitted.ok
|
||||
? admitted.value
|
||||
: delivery.toErrorToolResult(admitted.error, contract.returnsTargetContent);
|
||||
};
|
||||
|
||||
const deliverFailure = (
|
||||
error: import("../domain/analysisErrorBase.js").AnalysisError,
|
||||
contract: ToolContract,
|
||||
delivery: ToolResultDelivery,
|
||||
recordEvidence?: EvidenceWriter["recordEvidence"],
|
||||
) => {
|
||||
const partial = error.partialObservation;
|
||||
if (isEvidence(partial) && recordEvidence !== undefined)
|
||||
return delivery.toRecordedPartialErrorToolResult(
|
||||
error,
|
||||
partial.evidence_id,
|
||||
recordEvidence(partial),
|
||||
contract.returnsTargetContent,
|
||||
);
|
||||
return delivery.toErrorToolResult(error, contract.returnsTargetContent);
|
||||
};
|
||||
|
||||
const isEvidence = (value: unknown): value is Evidence =>
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
"evidence_id" in value &&
|
||||
typeof value.evidence_id === "string";
|
||||
@@ -0,0 +1,146 @@
|
||||
import type { McpServer, ServerContext } from "@modelcontextprotocol/server";
|
||||
import type { Logger } from "pino";
|
||||
|
||||
import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js";
|
||||
import { toolContract } from "../contracts/toolContracts.js";
|
||||
import { AnalysisInputError } from "../domain/analysisErrorCore.js";
|
||||
import type { RizinDebugSessionManager } from "../rizin/RizinDebugSessionManager.js";
|
||||
import { evidenceSchema } from "../domain/evidence.js";
|
||||
import {
|
||||
runOwnedCleanupWithExternalMutationAdmission,
|
||||
runWithExternalMutationAdmission,
|
||||
type ExternalMutationAdmission,
|
||||
} from "./externalMutationAdmission.js";
|
||||
import { logToolExecution } from "./toolLogging.js";
|
||||
import { toolRegistrationOptions } from "./toolRegistrationOptions.js";
|
||||
import type { ToolResultDelivery } from "./toolResult.js";
|
||||
|
||||
export type RizinDebugToolManager = Pick<
|
||||
RizinDebugSessionManager,
|
||||
"start" | "execute" | "status" | "close" | "retryCleanupFailures"
|
||||
>;
|
||||
|
||||
/** Register persistent Rizin debugger operations on the existing REA MCP server. */
|
||||
export const registerRizinDebugTools = (
|
||||
server: McpServer,
|
||||
manager: RizinDebugToolManager,
|
||||
logger: Logger,
|
||||
delivery: ToolResultDelivery,
|
||||
recordEvidence?: EvidenceWriter["recordEvidence"],
|
||||
session?: ExternalMutationAdmission,
|
||||
): void => {
|
||||
const start = toolContract("start_rizin_debug_session");
|
||||
const command = toolContract("rizin_debug_command");
|
||||
const status = toolContract("rizin_debug_session_status");
|
||||
const close = toolContract("close_rizin_debug_session");
|
||||
server.registerTool(
|
||||
start.name,
|
||||
toolRegistrationOptions(start),
|
||||
async (input, context: ServerContext) => {
|
||||
const result = await runWithExternalMutationAdmission(
|
||||
session,
|
||||
start.name,
|
||||
context.mcpReq.signal,
|
||||
() =>
|
||||
logToolExecution(logger, start.name, () =>
|
||||
manager.start(
|
||||
{
|
||||
path: input.path,
|
||||
inferior_tty: input.inferior_tty,
|
||||
...(input.backend === undefined
|
||||
? {}
|
||||
: { backend: input.backend }),
|
||||
},
|
||||
context.mcpReq.signal,
|
||||
),
|
||||
),
|
||||
undefined,
|
||||
() => manager.retryCleanupFailures(),
|
||||
(value) => ({ session_id: value.session_id }),
|
||||
);
|
||||
return result.ok
|
||||
? delivery.toCallToolResult({ ok: true, value: result.value }, start)
|
||||
: delivery.toCallToolResult(result, start);
|
||||
},
|
||||
);
|
||||
server.registerTool(
|
||||
command.name,
|
||||
toolRegistrationOptions(command),
|
||||
async (input, context: ServerContext) => {
|
||||
const result = await runWithExternalMutationAdmission(
|
||||
session,
|
||||
command.name,
|
||||
context.mcpReq.signal,
|
||||
() =>
|
||||
logToolExecution(logger, command.name, () =>
|
||||
manager.execute(
|
||||
input.session_id,
|
||||
input.command,
|
||||
context.mcpReq.signal,
|
||||
),
|
||||
),
|
||||
(value) => value.evidence,
|
||||
() => manager.retryCleanupFailures(),
|
||||
);
|
||||
if (!result.ok) {
|
||||
const partial = evidenceSchema.safeParse(
|
||||
result.error.partialObservation,
|
||||
);
|
||||
if (partial.success) {
|
||||
const recorded = recordEvidence?.(partial.data);
|
||||
if (recorded !== undefined)
|
||||
return delivery.toRecordedPartialErrorToolResult(
|
||||
result.error,
|
||||
partial.data.evidence_id,
|
||||
recorded,
|
||||
command.returnsTargetContent,
|
||||
);
|
||||
}
|
||||
return delivery.toCallToolResult(result, command);
|
||||
}
|
||||
return delivery.toEvidenceToolResult(
|
||||
result.value.evidence,
|
||||
command,
|
||||
recordEvidence?.(result.value.evidence),
|
||||
);
|
||||
},
|
||||
);
|
||||
server.registerTool(
|
||||
status.name,
|
||||
toolRegistrationOptions(status),
|
||||
async (input) => {
|
||||
const result = manager.status(input.session_id);
|
||||
if (result === undefined)
|
||||
return delivery.toCallToolResult(
|
||||
{ ok: false, error: new AnalysisInputError(status.name) },
|
||||
status,
|
||||
);
|
||||
return delivery.toCallToolResult(
|
||||
{
|
||||
ok: true,
|
||||
value: { ...result, recent_output: [...result.recent_output] },
|
||||
},
|
||||
status,
|
||||
);
|
||||
},
|
||||
);
|
||||
server.registerTool(
|
||||
close.name,
|
||||
toolRegistrationOptions(close),
|
||||
async (input, context: ServerContext) => {
|
||||
const result = await runOwnedCleanupWithExternalMutationAdmission(
|
||||
session,
|
||||
close.name,
|
||||
context.mcpReq.signal,
|
||||
input.session_id,
|
||||
() =>
|
||||
logToolExecution(logger, close.name, () =>
|
||||
manager.close(input.session_id),
|
||||
),
|
||||
);
|
||||
return result.ok
|
||||
? delivery.toCallToolResult({ ok: true, value: result.value }, close)
|
||||
: delivery.toCallToolResult(result, close);
|
||||
},
|
||||
);
|
||||
};
|
||||
@@ -8,7 +8,11 @@ import {
|
||||
} from "./WindowsNativeLoader.js";
|
||||
|
||||
const fixture = (
|
||||
capabilities: { protocolStdin?: boolean; jobMemoryLimit?: unknown } = {},
|
||||
capabilities: {
|
||||
protocolStdin?: boolean;
|
||||
jobMemoryLimit?: unknown;
|
||||
privateBridgeDescriptorAccess?: unknown;
|
||||
} = {},
|
||||
) => {
|
||||
const bytes = Buffer.alloc(128);
|
||||
bytes.write("MZ");
|
||||
@@ -51,6 +55,7 @@ const fixture = (
|
||||
architecture: "x64",
|
||||
filesystem: identity,
|
||||
privateDacl: true,
|
||||
privateBridgeDescriptorAccess: true,
|
||||
atomicJobAssignment: true,
|
||||
killOnOwnerClose: true,
|
||||
...capabilities,
|
||||
@@ -199,4 +204,12 @@ describe("packaged Windows native admission", () => {
|
||||
expect(loaded.available).toBe(false);
|
||||
expect(loaded).not.toHaveProperty("retryable");
|
||||
});
|
||||
|
||||
it("rejects Windows controls without handle-bound Cutter descriptor access", () => {
|
||||
const value = fixture({ privateBridgeDescriptorAccess: undefined });
|
||||
expect(loadWindowsNativeAuthority(value.host)).toMatchObject({
|
||||
available: false,
|
||||
reason: expect.stringContaining("privateBridgeDescriptorAccess"),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -41,6 +41,7 @@ const inspectionSchema = z.strictObject({
|
||||
architecture: z.literal("x64"),
|
||||
filesystem: windowsFileIdentitySchema,
|
||||
privateDacl: z.literal(true),
|
||||
privateBridgeDescriptorAccess: z.literal(true),
|
||||
atomicJobAssignment: z.literal(true),
|
||||
killOnOwnerClose: z.literal(true),
|
||||
protocolStdin: z.boolean().optional(),
|
||||
|
||||
@@ -59,6 +59,7 @@ const authority = (
|
||||
directory: true,
|
||||
},
|
||||
privateDacl: true,
|
||||
privateBridgeDescriptorAccess: true,
|
||||
atomicJobAssignment: true,
|
||||
killOnOwnerClose: true,
|
||||
protocolStdin: true,
|
||||
@@ -105,6 +106,32 @@ const launch = (
|
||||
);
|
||||
|
||||
describe("Windows Job Object memory limits", () => {
|
||||
it("emits close only after descendants leave the Job Object", async () => {
|
||||
const { native, calls } = authority({
|
||||
polls: [
|
||||
{
|
||||
...settledPoll,
|
||||
stdoutEnded: false,
|
||||
stderrEnded: false,
|
||||
activeProcesses: 1,
|
||||
},
|
||||
settledPoll,
|
||||
],
|
||||
});
|
||||
const child = launch(native);
|
||||
const events: string[] = [];
|
||||
child.once("exit", () => events.push("exit"));
|
||||
child.once("close", () => events.push("close"));
|
||||
|
||||
await once(child, "close");
|
||||
|
||||
expect(events).toEqual(["exit", "close"]);
|
||||
expect(
|
||||
calls.filter(({ operation }) => operation === "process_poll"),
|
||||
).toHaveLength(2);
|
||||
expect(calls.at(-1)?.operation).toBe("process_close");
|
||||
});
|
||||
|
||||
it("refuses a requested limit before launch when the artifact lacks the control", () => {
|
||||
const { native, calls } = authority();
|
||||
expect(() => launch(native, LIMIT)).toThrow(
|
||||
|
||||
Reference in new issue
Block a user