fix: name host and tool requirements in optional adapter refusals

Host and tool-configuration refusals from the pwntools, EVMole, firmware,
mitmproxy, Wakaru, LLDB and xcrun adapters carried no userMessage, so CLI
and MCP callers saw the generic "This analysis is unavailable for the
current target. Choose another analysis or target." as both message and
remediation. Choosing another target cannot satisfy a Linux-only profile
or an unset REA_*_COMMAND. The actual requirement appeared only in
details.reason.

Pass each refusal's reason as its userMessage, as the adapters' other
configuration refusals (EVMole limits, JADX, pwndbg) already do.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Kaoru0822
2026-10-09 08:12:25 +00:00
co-authored by Claude Opus 5.5
parent e6905c18e0
commit 170d0bf9e2
16 changed files with 146 additions and 35 deletions
@@ -6,6 +6,8 @@ import { AnalysisCapabilityUnavailableError } from "../../domain/analysisErrorCo
import type { HistoricalCaptureFormatAdapter } from "./HistoricalCaptureFormatAdapter.js";
import { MITMPROXY_CAPTURE_PROVIDER_IDENTITY } from "./CaptureRelease.js";
const OPERATION = "inspect_web_network_capture";
const MITMDUMP_REQUIREMENT =
"Native capture decoding requires Linux and an absolute REA_MITMDUMP_COMMAND path to caller-supplied mitmdump 12.2.3; HAR needs no external engine.";
/** Native mitmproxy decoding is offline and uses only an explicitly configured Linux tool. */
export class MitmproxyCaptureAdapter implements HistoricalCaptureFormatAdapter {
@@ -25,16 +27,18 @@ export class MitmproxyCaptureAdapter implements HistoricalCaptureFormatAdapter {
throw new AnalysisCapabilityUnavailableError(
"mitmproxy",
OPERATION,
"Native capture decoding requires Linux and an absolute REA_MITMDUMP_COMMAND path to caller-supplied mitmdump 12.2.3; HAR needs no external engine.",
MITMDUMP_REQUIREMENT,
{ userMessage: MITMDUMP_REQUIREMENT },
);
try {
await access(command, constants.R_OK | constants.X_OK);
} catch (cause: unknown) {
const reason = `Configured mitmdump executable is unavailable: ${command}.`;
throw new AnalysisCapabilityUnavailableError(
"mitmproxy",
OPERATION,
`Configured mitmdump executable is unavailable: ${command}.`,
{ cause },
reason,
{ cause, userMessage: reason },
);
}
return {
+4 -1
View File
@@ -52,6 +52,8 @@ import {
} from "./EvmWorkerLimits.js";
const OPERATION = "inspect_evm_interface";
const HOST_REQUIREMENT =
"Initial real-verified offline EVM interface profile supports Linux x64 only.";
const replySchema = z.discriminatedUnion("ok", [
z.strictObject({
ok: z.literal(true),
@@ -99,7 +101,8 @@ export class EvmoleInterfaceProvider implements EvmInterfacePort {
throw new AnalysisCapabilityUnavailableError(
EVMOLE_PROVIDER_IDENTITY.id,
OPERATION,
"Initial real-verified offline EVM interface profile supports Linux x64 only.",
HOST_REQUIREMENT,
{ userMessage: HOST_REQUIREMENT },
);
try {
if (!isAbsolute(limiter))
+22
View File
@@ -4,10 +4,32 @@ import { join } from "node:path";
import { expect, it as test } from "vitest";
import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js";
import { AnalysisCapabilityUnavailableError } from "../domain/analysisErrorCore.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import { resolveFirmwareCommand } from "./FirmwareCommand.js";
const it = test.skipIf(process.platform !== "linux");
test("names the host and tool requirement of an unconfigured engine", async () => {
const refusal: unknown = await resolveFirmwareCommand(
{},
"binwalk",
"inspect_firmware_regions",
).catch((cause: unknown) => cause);
expect(refusal).toMatchObject({
_tag: "AnalysisCapabilityUnavailableError",
});
if (!(refusal instanceof AnalysisCapabilityUnavailableError))
throw new Error("Expected a capability refusal");
expect(projectAnalysisError(refusal)).toMatchObject({
code: "capability_unavailable",
message: refusal.reason,
remediation: { action: refusal.reason },
});
expect(refusal.reason).toContain("REA_BINWALK_COMMAND");
});
it("rejects a directory configured as the firmware engine executable", async () => {
const root = await createTestTempDirectory("rea-firmware-command-");
const command = join(root, "binwalk");
+15 -5
View File
@@ -29,6 +29,16 @@ export type FirmwareLauncher = (
options: OwnedProviderProcessSpawnOptions,
) => Promise<SpawnedOwnedProviderProcess>;
/** Refuse an unmet host or tool requirement, which another target cannot satisfy. */
export const firmwareToolUnavailable = (
engine: string,
operation: string,
reason: string,
): AnalysisCapabilityUnavailableError =>
new AnalysisCapabilityUnavailableError(engine, operation, reason, {
userMessage: reason,
});
/** Resolve explicitly supplied executable paths; never install or search for engines. */
export const resolveFirmwareCommand = async (
environment: Readonly<Record<string, string | undefined>>,
@@ -43,13 +53,13 @@ export const resolveFirmwareCommand = async (
configured === undefined ||
!isAbsolute(configured)
)
throw new AnalysisCapabilityUnavailableError(
throw firmwareToolUnavailable(
engine,
operation,
`This integration requires Linux and an absolute ${variable} path to the caller-supplied tool`,
);
const command = await realpath(configured).catch((cause: unknown) => {
throw new AnalysisCapabilityUnavailableError(
throw firmwareToolUnavailable(
engine,
operation,
`${variable} is unavailable: ${cause instanceof Error ? cause.message : String(cause)}`,
@@ -62,7 +72,7 @@ export const resolveFirmwareCommand = async (
throw new TypeError("Configured path is not a regular file");
sha256 = await hashFirmwareFile(command);
} catch (cause: unknown) {
throw new AnalysisCapabilityUnavailableError(
throw firmwareToolUnavailable(
engine,
operation,
`${variable} is not a readable executable regular file: ${command}: ${cause instanceof Error ? cause.message : String(cause)}`,
@@ -71,7 +81,7 @@ export const resolveFirmwareCommand = async (
const limiter =
environment.REA_FIRMWARE_PRLIMIT_COMMAND ?? "/usr/bin/prlimit";
if (!isAbsolute(limiter))
throw new AnalysisCapabilityUnavailableError(
throw firmwareToolUnavailable(
engine,
operation,
"REA_FIRMWARE_PRLIMIT_COMMAND must be absolute",
@@ -81,7 +91,7 @@ export const resolveFirmwareCommand = async (
if (!(await stat(limiter)).isFile())
throw new TypeError("Configured path is not a regular file");
} catch (cause: unknown) {
throw new AnalysisCapabilityUnavailableError(
throw firmwareToolUnavailable(
engine,
operation,
`Resource limiter is not an executable regular file: ${limiter}: ${cause instanceof Error ? cause.message : String(cause)}; provide util-linux prlimit`,
+3 -3
View File
@@ -12,7 +12,6 @@ import {
import { AnalysisError } from "../domain/analysisErrorBase.js";
import {
AnalysisCancelledError,
AnalysisCapabilityUnavailableError,
AnalysisInputError,
AnalysisOutputError,
AnalysisTimeoutError,
@@ -29,6 +28,7 @@ import {
} from "./FirmwareRelease.js";
import { admitFirmwareVersion } from "./FirmwareVersion.js";
import {
firmwareToolUnavailable,
resolveFirmwareCommand,
runFirmwareCommand,
readFirmwareCommandReport,
@@ -144,14 +144,14 @@ export class FirmwareProvider implements FirmwareAnalysisPort {
const versionRun = await run(["--version"]);
const version = versionRun.stdout.text.trim();
if (versionRun.stdout.bytes > 1024)
throw new AnalysisCapabilityUnavailableError(
throw firmwareToolUnavailable(
engineName,
request.operation,
`Tool version banner is ${String(versionRun.stdout.bytes)} bytes; expected a short ${engineName} version line.`,
);
const admitted = admitFirmwareVersion(engineName, version);
if (admitted.status === "unsupported" || admitted.status === "unresolved")
throw new AnalysisCapabilityUnavailableError(
throw firmwareToolUnavailable(
engineName,
request.operation,
admitted.message,
+22 -1
View File
@@ -1,9 +1,30 @@
import { EventEmitter } from "node:events";
import { PassThrough } from "node:stream";
import { expect, it } from "vitest";
import { runWakaruCommand, type WakaruLauncher } from "./WakaruCommand.js";
import { AnalysisCapabilityUnavailableError } from "../../domain/analysisErrorCore.js";
import { projectAnalysisError } from "../../domain/analysisErrorProjection.js";
import {
resolveWakaruCommand,
runWakaruCommand,
type WakaruLauncher,
} from "./WakaruCommand.js";
import { RECOVERY_LIMITS } from "./WakaruRelease.js";
it("names the Wakaru configuration requirement when it is not configured", async () => {
const refusal: unknown = await resolveWakaruCommand({}).catch(
(cause: unknown) => cause,
);
if (!(refusal instanceof AnalysisCapabilityUnavailableError))
throw new Error("Expected a capability refusal");
expect(refusal.reason).toContain("REA_WAKARU_COMMAND");
expect(projectAnalysisError(refusal)).toMatchObject({
code: "capability_unavailable",
message: refusal.reason,
remediation: { action: refusal.reason },
});
});
it("rejects an exhausted shared execution budget before launching the next command", async () => {
await expect(
runWakaruCommand({
+9 -7
View File
@@ -25,6 +25,8 @@ import {
import { RECOVERY_LIMITS } from "./WakaruRelease.js";
const OPERATION = "recover_javascript_sources";
const WAKARU_REQUIREMENT =
"Provide an absolute REA_WAKARU_COMMAND pointing to Wakaru 1.13.0 on Linux x64; no tool is installed by REA";
/** Inject the owned process launcher while retaining production protocol parsing. */
export type WakaruLauncher = (
@@ -45,7 +47,8 @@ export const resolveWakaruCommand = async (
throw new AnalysisCapabilityUnavailableError(
"wakaru",
OPERATION,
"Provide an absolute REA_WAKARU_COMMAND pointing to Wakaru 1.13.0 on Linux x64; no tool is installed by REA",
WAKARU_REQUIREMENT,
{ userMessage: WAKARU_REQUIREMENT },
);
try {
const command = await realpath(configured);
@@ -61,12 +64,11 @@ export const resolveWakaruCommand = async (
await access(limiter, constants.X_OK);
return { command, limiter, ...fingerprint };
} catch (cause: unknown) {
throw new AnalysisCapabilityUnavailableError(
"wakaru",
OPERATION,
`Configured Wakaru or util-linux prlimit is unavailable: ${recoveryFailureMessage(cause)}`,
{ cause },
);
const reason = `Configured Wakaru or util-linux prlimit is unavailable: ${recoveryFailureMessage(cause)}`;
throw new AnalysisCapabilityUnavailableError("wakaru", OPERATION, reason, {
cause,
userMessage: reason,
});
}
};
+6 -6
View File
@@ -69,12 +69,12 @@ const executeWakaruRecovery = async (context: RecoveryWorkspace) => {
if (
version.exit_code !== 0 ||
version.stdout.trim() !== `wakaru ${WAKARU_RELEASE.version}`
)
throw new AnalysisCapabilityUnavailableError(
"wakaru",
OPERATION,
`This adapter requires Wakaru ${WAKARU_RELEASE.version}; reported version: ${version.stdout.trim()}; exit: ${String(version.exit_code)}; stderr: ${version.stderr}`,
);
) {
const reason = `This adapter requires Wakaru ${WAKARU_RELEASE.version}; reported version: ${version.stdout.trim()}; exit: ${String(version.exit_code)}; stderr: ${version.stderr}`;
throw new AnalysisCapabilityUnavailableError("wakaru", OPERATION, reason, {
userMessage: reason,
});
}
const run = await runWakaruCommand({
...commandContext,
staging,
+4 -1
View File
@@ -50,6 +50,8 @@ import {
const OPERATION = "observe_native_calls";
const PROVIDER = "native-macos";
const LLDB_REQUIREMENT =
"LLDB from Xcode or the Command Line Tools is not available";
/** Captured target output kept per stream; the rest is counted, not stored. */
const MAX_OUTPUT_BYTES = 1024 * 1024;
/** LLDB startup, symbol loading and teardown beyond the observation window. */
@@ -180,7 +182,8 @@ export class LldbCallTracer implements NativeCallTracer {
: new AnalysisCapabilityUnavailableError(
PROVIDER,
OPERATION,
"LLDB from Xcode or the Command Line Tools is not available",
LLDB_REQUIREMENT,
{ userMessage: LLDB_REQUIREMENT },
),
);
const root = await PrivateRuntimeRoot.create({ prefix: "rea-lldb-" });
+5 -3
View File
@@ -668,13 +668,15 @@ const translateCommandFailure = (
...(capturedOutput === undefined ? {} : { capturedOutput }),
...(cleanup === undefined ? {} : { cleanup }),
};
if (failure.reason === "unavailable")
if (failure.reason === "unavailable") {
const reason = `${failure.tool} is unavailable through xcrun.`;
return new AnalysisCapabilityUnavailableError(
IDENTITY.id,
operation,
`${failure.tool} is unavailable through xcrun.`,
errorOptions,
reason,
{ ...errorOptions, userMessage: reason },
);
}
if (failure.reason === "cancelled")
return new AnalysisCancelledError(operation, errorOptions);
if (failure.reason === "timeout")
+5 -1
View File
@@ -38,6 +38,9 @@ import {
PWNTOOLS_LIMITS,
} from "./PwntoolsRelease.js";
const HOST_REQUIREMENT =
"The initial real-verified pwntools artifact profile supports Linux x64 only.";
/** Owned launcher seam shared by the offline pwntools artifact adapters. */
export type PwntoolsLauncher = NonNullable<
Parameters<typeof runOwnedCommand>[2]
@@ -122,7 +125,8 @@ export class PwntoolsDecoder<
throw new AnalysisCapabilityUnavailableError(
this.identity.id,
OPERATION,
"The initial real-verified pwntools artifact profile supports Linux x64 only.",
HOST_REQUIREMENT,
{ userMessage: HOST_REQUIREMENT },
);
if (!isAbsolute(selectedPath))
throw pwntoolsUnavailable(
@@ -591,12 +591,18 @@ it.runIf(!unsupportedHost)(
it.runIf(unsupportedHost)(
"reports unverified host coverage before acquiring a provider",
async () => {
expect(
await new PwntoolsLayoutProvider({}).inspect({ path: "/selected.elf" }),
).toMatchObject({
const result = await new PwntoolsLayoutProvider({}).inspect({
path: "/selected.elf",
});
expect(result).toMatchObject({
ok: false,
error: { _tag: "AnalysisCapabilityUnavailableError" },
});
// Another target cannot satisfy a host requirement, so name it.
if (result.ok) throw new Error("Expected a host refusal");
const projected = projectAnalysisError(result.error);
expect(projected.message).toContain("Linux x64");
expect(projected.remediation.action).toContain("Linux x64");
},
);
@@ -79,6 +79,7 @@ it("keeps malformed JSON separate from absent external mitmproxy capability", as
expect(missing.ok).toBe(false);
expect(JSON.parse(missing.stdout)).toMatchObject({
category: "unsupported_provider",
message: expect.stringContaining("REA_MITMDUMP_COMMAND"),
});
});
@@ -29,6 +29,21 @@ const fixture = async () => {
return { path, root: workspace.root };
};
it.runIf(unsupportedHost)(
"names the host requirement instead of asking for another target",
async () => {
const result = await new EvmoleInterfaceProvider({}).inspect({
path: "/selected.hex",
encoding: "hex",
});
if (result.ok) throw new Error("Expected a host refusal");
const projected = projectAnalysisError(result.error);
expect(projected).toMatchObject({ code: "capability_unavailable" });
expect(projected.message).toContain("Linux x64");
expect(projected.remediation.action).toContain("Linux x64");
},
);
it.runIf(!unsupportedHost).each(["SIGXCPU", "SIGXFSZ", "EFBIG"])(
"preserves %s with configured limits distinct from unknown effective limits",
async (failure) => {
@@ -10,7 +10,8 @@ import { projectAnalysisError } from "../../../../src/domain/analysisErrorProjec
import { nativeCallObservationInputSchema } from "../../../../src/domain/native/nativeCallObservation.js";
import type { NativeCallTracer } from "../../../../src/native/LldbCallTracer.js";
import { LldbCallTracer } from "../../../../src/native/LldbCallTracer.js";
import { ok } from "../../../../src/domain/result.js";
import { err, ok } from "../../../../src/domain/result.js";
import { NativeCommandFailure } from "../../../../src/native/CommandRunner.js";
const EVENT = {
sequence: 0,
@@ -106,6 +107,19 @@ const fixtureTracer = (
};
describe("LLDB failure retention through the production tracer", () => {
it("names the missing LLDB requirement instead of asking for another target", async () => {
const tracer = new LldbCallTracer(
() => Promise.reject(new Error("LLDB must not launch")),
async (tool) => err(new NativeCommandFailure(tool, "unavailable")),
);
const result = await tracer.trace(request);
if (result.ok) throw new Error("expected an unavailable LLDB");
expect(projectAnalysisError(result.error)).toMatchObject({
code: "capability_unavailable",
message: "LLDB from Xcode or the Command Line Tools is not available",
});
});
it("preserves cancellation and journal/output evidence after runtime cleanup", async () => {
const fixture = fixtureTracer("cancelled");
const result = await fixture.tracer.trace(request);
@@ -657,6 +657,10 @@ describe("native macOS provider failures and parsing", () => {
expect(!unavailableResult.ok && unavailableResult.error._tag).toBe(
"AnalysisCapabilityUnavailableError",
);
if (unavailableResult.ok) throw new Error("Expected an unavailable tool");
expect(projectAnalysisError(unavailableResult.error).message).toBe(
"lipo is unavailable through xcrun.",
);
const malformed = new NativeMacOSProvider(
new FixtureRunner({ lipo: "malformed" }),