feat(analysis): make resource budgets recoverable across CLI and MCP (#9706)

* refactor(analysis): centralize resource budgets and recovery facts

* feat(analysis): expose ASAR and Ghidra runtime admission controls

* feat(artifacts): expose reader admission and preserve limited recognition

* feat(javascript): expose recoverable semantic precision budgets

* feat(web): add recoverable source-map budgets and compact retained views

* feat(web): make historical capture decoding budgets recoverable

* fix(desktop): enforce selected view policies across capture and export

* feat(runtime): make Frida and LLDB retention budgets selectable

* feat(native): expose diagnostic budgets and preserve exhaustion causes

* feat(managed): expose recoverable snapshot and representation budgets

* feat(metadata): make Go and Rust inspection budgets recoverable

* feat(wasm): expose representation and producer admissions

* feat(evm): expose per-call worker resource budgets

* feat(android): make resource and string-pool omissions recoverable

* feat(producers): expose command and recorded-capture budgets

* docs(analysis): record recovery contracts and integrated validation

* fix(docs): link calibration oracle from published verification pages

* fix(docs): restore desktop task anchors and receipt links

* test(analysis): align fixtures with selectable budget contracts

* fix(analysis): remove source-location projector dependency cycle

* test(analysis): refresh budget recovery and verifier regressions

* test(analysis): preserve format and publication recovery expectations

* test(windows): align conformance with owned runtime admission

* fix(desktop): preserve selected alias kinds in exported state

* test(analysis): align provider fixtures with recoverable budgets

* test(desktop): validate emitted alias metadata structurally

* fix(ghidra): classify bounded Windows endpoint refusals

* test(cli): assert selectable artifact admission recovery

* fix(cli): preserve capture provenance in environment builds
This commit is contained in:
morluto authored and GitHub committed 2026-11-10 05:11:20 -05:00
1 parent 55b6d2ca20
commit cb4e5cfbff
515 files changed
+21066 -3637

No files matched your search

@@ -28,6 +28,8 @@ import { jsonValueSchema, type JsonValue } from "../../domain/jsonValue.js";
import { err, ok, type Result } from "../../domain/result.js";
import {
DEFAULT_REVERSE_COMMAND_TIMEOUT_MS,
DEFAULT_REVERSE_COMMAND_OUTPUT_BYTES,
MAX_REVERSE_COMMAND_OUTPUT_BYTES,
MAX_REVERSE_COMMAND_TIMEOUT_MS,
objdumpInputSchema,
rizinInputSchema,
@@ -88,10 +90,16 @@ interface CommandLaunchOutcome {
succeeded: number;
}
const MAX_COMBINED_OUTPUT_BYTES = 16 * 1024 * 1024;
export const REVERSE_COMMAND_OUTPUT_LIMITATION = `REA stopped collecting output at its ${String(MAX_COMBINED_OUTPUT_BYTES)}-byte cap and requested termination of the provider process; the captured stdout and stderr are a prefix and command completion is unknown. Narrow the command or page its output.`;
// Eight concurrent captures bound retained provider output to 128 MiB.
const reverseCommandOutputLimitation = (
bytes: number,
source: "default" | "request",
): string =>
`REA stopped collecting output at max_output_bytes=${String(bytes)} bytes (source ${source}, default ${String(DEFAULT_REVERSE_COMMAND_OUTPUT_BYTES)}, range 1..${String(MAX_REVERSE_COMMAND_OUTPUT_BYTES)}) and requested termination of the provider process; captured stdout and stderr are a prefix and command completion is unknown. Narrow or page the provider command, or increase max_output_bytes (--max-output-bytes on the CLI) for the next call; no restart is required. Confirm command effects and cleanup before deciding whether it is safe to repeat. Client receive limits are separate.`;
// Bound simultaneous provider process families independently of output admission.
const MAX_CONCURRENT_REVERSE_ENGINEERING_OPERATIONS = 8;
const MAX_ACTIVE_CAPTURE_BYTES =
MAX_CONCURRENT_REVERSE_ENGINEERING_OPERATIONS *
DEFAULT_REVERSE_COMMAND_OUTPUT_BYTES;
export interface ReverseEngineeringServiceOptions {
readonly environment?: Readonly<NodeJS.ProcessEnv>;
@@ -140,6 +148,17 @@ export const resolveRizinCommandRequest = (
);
};
/** One accepted command, including the controls retained through admission and cleanup. */
interface ReverseCommandObservation {
readonly operation: ReverseEngineeringOperation;
readonly profile: ReverseEngineeringProviderCommand;
readonly path: string | undefined;
readonly parameters: Readonly<Record<string, JsonValue>>;
readonly signal: AbortSignal | undefined;
readonly timeoutMs: number;
readonly outputBudgetBytes: number;
}
/** Shared application workflow for caller-selected local analysis commands. */
export class ReverseEngineeringService {
readonly #environment: Readonly<NodeJS.ProcessEnv>;
@@ -159,6 +178,7 @@ export class ReverseEngineeringService {
AbortController,
Promise<Result<Evidence, AnalysisError>>
>();
#reservedOutputBytes = 0;
#closing = false;
#closePromise: Promise<void> | undefined;
#dispatched = 0;
@@ -297,6 +317,7 @@ export class ReverseEngineeringService {
const {
detail,
command_timeout_ms: commandTimeoutMs,
max_output_bytes: maxOutputBytes,
...input
} = parsed.value;
if (detail === "summary") {
@@ -314,14 +335,17 @@ export class ReverseEngineeringService {
} else parameters = input;
if (commandTimeoutMs !== undefined)
parameters = { ...parameters, command_timeout_ms: commandTimeoutMs };
const observed = await this.#observe(
"inspect_with_objdump",
command,
input.operation === "architectures" ? undefined : input.path,
if (maxOutputBytes !== undefined)
parameters = { ...parameters, max_output_bytes: maxOutputBytes };
const observed = await this.#observe({
operation: "inspect_with_objdump",
profile: command,
path: input.operation === "architectures" ? undefined : input.path,
parameters,
options.signal,
commandTimeoutMs ?? DEFAULT_REVERSE_COMMAND_TIMEOUT_MS,
);
signal: options.signal,
timeoutMs: commandTimeoutMs ?? DEFAULT_REVERSE_COMMAND_TIMEOUT_MS,
outputBudgetBytes: maxOutputBytes ?? DEFAULT_REVERSE_COMMAND_OUTPUT_BYTES,
});
return observed.ok && detail === "summary"
? summarizeRetainedAnalysis(observed.value, retention)
: observed;
@@ -342,26 +366,27 @@ export class ReverseEngineeringService {
request: RizinInput,
options: { readonly signal?: AbortSignal } = {},
): Promise<Result<Evidence, AnalysisError>> {
const { command_timeout_ms: commandTimeoutMs, ...input } = request;
const {
command_timeout_ms: commandTimeoutMs,
max_output_bytes: maxOutputBytes,
...input
} = request;
const command = this.#commands.rizin(input);
return this.#observe(
"execute_rizin_command",
command,
input.path,
commandTimeoutMs === undefined ? input : request,
options.signal,
commandTimeoutMs ?? DEFAULT_REVERSE_COMMAND_TIMEOUT_MS,
);
return this.#observe({
operation: "execute_rizin_command",
profile: command,
path: input.path,
parameters: request,
signal: options.signal,
timeoutMs: commandTimeoutMs ?? DEFAULT_REVERSE_COMMAND_TIMEOUT_MS,
outputBudgetBytes: maxOutputBytes ?? DEFAULT_REVERSE_COMMAND_OUTPUT_BYTES,
});
}
#observe(
operation: ReverseEngineeringOperation,
profile: ReverseEngineeringProviderCommand,
path: string | undefined,
parameters: Readonly<Record<string, JsonValue>>,
signal: AbortSignal | undefined,
timeoutMs: number,
request: ReverseCommandObservation,
): Promise<Result<Evidence, AnalysisError>> {
const { operation, signal, outputBudgetBytes } = request;
if (signal?.aborted)
return Promise.resolve(
err(new AnalysisCancelledError(operation, { cause: signal.reason })),
@@ -377,21 +402,26 @@ export class ReverseEngineeringService {
);
if (
this.#activeOperations.size >=
MAX_CONCURRENT_REVERSE_ENGINEERING_OPERATIONS
MAX_CONCURRENT_REVERSE_ENGINEERING_OPERATIONS ||
this.#reservedOutputBytes + outputBudgetBytes > MAX_ACTIVE_CAPTURE_BYTES
)
return Promise.resolve(
err(
new AnalysisResourceConstraintError(
operation,
"transport",
`Concurrent reverse-engineering operation capacity reached (${String(MAX_CONCURRENT_REVERSE_ENGINEERING_OPERATIONS)} operations). This release has no runtime override.`,
`Reverse-engineering capture capacity reached (${String(MAX_CONCURRENT_REVERSE_ENGINEERING_OPERATIONS)} operations or ${String(MAX_ACTIVE_CAPTURE_BYTES)} reserved output bytes).`,
{
max_concurrent_operations:
MAX_CONCURRENT_REVERSE_ENGINEERING_OPERATIONS,
active_operations: this.#activeOperations.size,
max_active_capture_bytes: MAX_ACTIVE_CAPTURE_BYTES,
reserved_output_bytes: this.#reservedOutputBytes,
requested_output_bytes: outputBudgetBytes,
},
{
remediationAction: `Wait for an active objdump or rizin command to finish, then retry. This capacity of ${String(MAX_CONCURRENT_REVERSE_ENGINEERING_OPERATIONS)} operations has no runtime override, and command_timeout_ms does not raise it.`,
retryable: true,
remediationAction: `Wait for active objdump or rizin work to finish, then retry, or lower max_output_bytes to fit the ${String(MAX_ACTIVE_CAPTURE_BYTES)}-byte aggregate capture admission. This live capacity has no runtime override; command_timeout_ms does not raise it. No restart is required.`,
},
),
),
@@ -406,30 +436,32 @@ export class ReverseEngineeringService {
signal === undefined || signal instanceof AbortSignal
? controller.signal
: signal;
const running = this.#observeProvider(
operation,
profile,
path,
parameters,
operationSignal,
timeoutMs,
);
this.#reservedOutputBytes += outputBudgetBytes;
const running = this.#observeProvider({
...request,
signal: operationSignal,
});
const tracked = running.finally(() => {
signal?.removeEventListener("abort", relayAbort);
this.#activeOperations.delete(controller);
this.#reservedOutputBytes -= outputBudgetBytes;
});
this.#activeOperations.set(controller, tracked);
return tracked;
}
async #observeProvider(
operation: ReverseEngineeringOperation,
profile: ReverseEngineeringProviderCommand,
path: string | undefined,
parameters: Readonly<Record<string, JsonValue>>,
signal: AbortSignal | undefined,
timeoutMs: number,
request: ReverseCommandObservation,
): Promise<Result<Evidence, AnalysisError>> {
const {
operation,
profile,
path,
parameters,
signal,
timeoutMs,
outputBudgetBytes,
} = request;
const { executable: command, arguments: args } = profile;
if (signal?.aborted)
return err(
@@ -540,7 +572,7 @@ export class ReverseEngineeringService {
signal,
timeout: timeoutMs,
stopSignal: "SIGTERM",
maxBuffer: MAX_COMBINED_OUTPUT_BYTES,
maxBuffer: outputBudgetBytes,
});
stdout = output.stdout;
stderr = output.stderr;
@@ -718,8 +750,6 @@ export class ReverseEngineeringService {
: { cleanupOwner: darwinFailure.cleanupOwner }),
});
} else {
const provider =
operation === "inspect_with_objdump" ? "objdump" : "rizin";
const setting =
operation === "inspect_with_objdump"
? "REA_OBJDUMP_COMMAND"
@@ -994,11 +1024,15 @@ export class ReverseEngineeringService {
: digestAfter === null || digest === undefined
? null
: digestAfter !== digest;
const outputBudgetSource =
parameters.max_output_bytes === undefined ? "default" : "request";
const result = {
stdout,
stderr,
exit_code: exitCode,
signal: signalName,
output_budget_bytes: outputBudgetBytes,
output_budget_source: outputBudgetSource,
output_truncated: outputTruncated,
completion_status: completionStatus,
artifact_sha256_after: absolutePath === undefined ? null : digestAfter,
@@ -1043,7 +1077,12 @@ export class ReverseEngineeringService {
]
: []),
...(outputLimitReached
? [REVERSE_COMMAND_OUTPUT_LIMITATION]
? [
reverseCommandOutputLimitation(
outputBudgetBytes,
outputBudgetSource,
),
]
: outputTruncated
? [
"Captured provider output is partial because bytes were omitted; process completion is reported separately.",
@@ -1092,6 +1131,9 @@ export class ReverseEngineeringService {
...(absolutePath === undefined
? {
operation: "architectures",
...(typeof parameters.max_output_bytes === "number"
? { max_output_bytes: parameters.max_output_bytes }
: {}),
...(typeof parameters.command_timeout_ms === "number"
? { command_timeout_ms: parameters.command_timeout_ms }
: {}),
@@ -1212,7 +1254,8 @@ const runOwnedCommandOutput =
},
{
timeoutMs: options.timeout ?? DEFAULT_REVERSE_COMMAND_TIMEOUT_MS,
diagnosticBytes: MAX_COMBINED_OUTPUT_BYTES,
diagnosticBytes:
options.maxBuffer ?? DEFAULT_REVERSE_COMMAND_OUTPUT_BYTES,
acceptNonZeroExit: true,
},
{ signal: options.signal },
@@ -1349,7 +1392,7 @@ const reverseCommandTimeoutGuidance = (
timeoutMs < MAX_REVERSE_COMMAND_TIMEOUT_MS
? `Increase command_timeout_ms above ${String(timeoutMs)} (integer milliseconds from 1 to ${String(MAX_REVERSE_COMMAND_TIMEOUT_MS)}; omitted means ${String(DEFAULT_REVERSE_COMMAND_TIMEOUT_MS)}), or use --command-timeout-ms on the CLI.`
: `command_timeout_ms is already at the maximum ${String(MAX_REVERSE_COMMAND_TIMEOUT_MS)} ms; no higher deadline is supported.`
} The new value applies on the next call; no restart is required. The ${String(MAX_COMBINED_OUTPUT_BYTES)} byte (16 MiB) combined output cap has no runtime override. Allow execution, cleanup and delivery headroom in the outer MCP client deadline. Confirm outstanding work and cleanup before retrying.`,
} The new value applies on the next call; no restart is required. The combined output allowance is separately selected by max_output_bytes (default ${String(DEFAULT_REVERSE_COMMAND_OUTPUT_BYTES)} bytes). Allow execution, cleanup and delivery headroom in the outer MCP client deadline. Confirm outstanding work and cleanup before retrying.`,
});
export const digestFile = async (