From 170d0bf9e2d45b2512ad556c02f31bdc81ea03ca Mon Sep 17 00:00:00 2001 From: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> Date: Fri, 9 Oct 2026 08:12:25 +0000 Subject: [PATCH] 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 --- .../history/MitmproxyCaptureAdapter.ts | 10 +++++--- src/evm/EvmoleInterfaceProvider.ts | 5 +++- src/firmware/FirmwareCommand.test.ts | 22 ++++++++++++++++++ src/firmware/FirmwareCommand.ts | 20 ++++++++++++---- src/firmware/FirmwareProvider.ts | 6 ++--- src/javascript/recovery/WakaruCommand.test.ts | 23 ++++++++++++++++++- src/javascript/recovery/WakaruCommand.ts | 16 +++++++------ src/javascript/recovery/WakaruExecution.ts | 12 +++++----- src/native/LldbCallTracer.ts | 5 +++- src/native/NativeMacOSProvider.ts | 8 ++++--- src/native/pwntools/PwntoolsDecoder.ts | 6 ++++- .../pwntoolsLayoutProvider.test.ts | 12 +++++++--- .../browser/historicalWebCapture.test.ts | 1 + .../evm/evmoleInterfaceProvider.test.ts | 15 ++++++++++++ .../native/lldbCallTracerFailures.test.ts | 16 ++++++++++++- .../providers/native/nativeProvider.test.ts | 4 ++++ 16 files changed, 146 insertions(+), 35 deletions(-) diff --git a/src/browser/history/MitmproxyCaptureAdapter.ts b/src/browser/history/MitmproxyCaptureAdapter.ts index a3ebd66f7..2e71720c7 100644 --- a/src/browser/history/MitmproxyCaptureAdapter.ts +++ b/src/browser/history/MitmproxyCaptureAdapter.ts @@ -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 { diff --git a/src/evm/EvmoleInterfaceProvider.ts b/src/evm/EvmoleInterfaceProvider.ts index 2316f44a7..c38aefa09 100644 --- a/src/evm/EvmoleInterfaceProvider.ts +++ b/src/evm/EvmoleInterfaceProvider.ts @@ -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)) diff --git a/src/firmware/FirmwareCommand.test.ts b/src/firmware/FirmwareCommand.test.ts index 1ca439b5f..cbfa0925a 100644 --- a/src/firmware/FirmwareCommand.test.ts +++ b/src/firmware/FirmwareCommand.test.ts @@ -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"); diff --git a/src/firmware/FirmwareCommand.ts b/src/firmware/FirmwareCommand.ts index 025596528..27bd57aa3 100644 --- a/src/firmware/FirmwareCommand.ts +++ b/src/firmware/FirmwareCommand.ts @@ -29,6 +29,16 @@ export type FirmwareLauncher = ( options: OwnedProviderProcessSpawnOptions, ) => Promise; +/** 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>, @@ -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`, diff --git a/src/firmware/FirmwareProvider.ts b/src/firmware/FirmwareProvider.ts index 610c91f1c..a0d7dfa50 100644 --- a/src/firmware/FirmwareProvider.ts +++ b/src/firmware/FirmwareProvider.ts @@ -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, diff --git a/src/javascript/recovery/WakaruCommand.test.ts b/src/javascript/recovery/WakaruCommand.test.ts index bdfe54dd9..08e43e44a 100644 --- a/src/javascript/recovery/WakaruCommand.test.ts +++ b/src/javascript/recovery/WakaruCommand.test.ts @@ -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({ diff --git a/src/javascript/recovery/WakaruCommand.ts b/src/javascript/recovery/WakaruCommand.ts index 42062feee..41e11d55f 100644 --- a/src/javascript/recovery/WakaruCommand.ts +++ b/src/javascript/recovery/WakaruCommand.ts @@ -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, + }); } }; diff --git a/src/javascript/recovery/WakaruExecution.ts b/src/javascript/recovery/WakaruExecution.ts index ff82b5c47..1a4c72067 100644 --- a/src/javascript/recovery/WakaruExecution.ts +++ b/src/javascript/recovery/WakaruExecution.ts @@ -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, diff --git a/src/native/LldbCallTracer.ts b/src/native/LldbCallTracer.ts index e08bb8fac..2f99d026e 100644 --- a/src/native/LldbCallTracer.ts +++ b/src/native/LldbCallTracer.ts @@ -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-" }); diff --git a/src/native/NativeMacOSProvider.ts b/src/native/NativeMacOSProvider.ts index d011509d6..c358f2d2c 100644 --- a/src/native/NativeMacOSProvider.ts +++ b/src/native/NativeMacOSProvider.ts @@ -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") diff --git a/src/native/pwntools/PwntoolsDecoder.ts b/src/native/pwntools/PwntoolsDecoder.ts index eacc2d63e..4c1add09c 100644 --- a/src/native/pwntools/PwntoolsDecoder.ts +++ b/src/native/pwntools/PwntoolsDecoder.ts @@ -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[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( diff --git a/tests/boundary/binaryDiagnostics/pwntoolsLayoutProvider.test.ts b/tests/boundary/binaryDiagnostics/pwntoolsLayoutProvider.test.ts index 702458224..71b7383ca 100644 --- a/tests/boundary/binaryDiagnostics/pwntoolsLayoutProvider.test.ts +++ b/tests/boundary/binaryDiagnostics/pwntoolsLayoutProvider.test.ts @@ -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"); }, ); diff --git a/tests/boundary/browser/historicalWebCapture.test.ts b/tests/boundary/browser/historicalWebCapture.test.ts index a6905bdb3..a0a9f10e2 100644 --- a/tests/boundary/browser/historicalWebCapture.test.ts +++ b/tests/boundary/browser/historicalWebCapture.test.ts @@ -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"), }); }); diff --git a/tests/boundary/evm/evmoleInterfaceProvider.test.ts b/tests/boundary/evm/evmoleInterfaceProvider.test.ts index dd0e0fd8e..5d1305d21 100644 --- a/tests/boundary/evm/evmoleInterfaceProvider.test.ts +++ b/tests/boundary/evm/evmoleInterfaceProvider.test.ts @@ -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) => { diff --git a/tests/boundary/providers/native/lldbCallTracerFailures.test.ts b/tests/boundary/providers/native/lldbCallTracerFailures.test.ts index aa8a932fd..d65b820e2 100644 --- a/tests/boundary/providers/native/lldbCallTracerFailures.test.ts +++ b/tests/boundary/providers/native/lldbCallTracerFailures.test.ts @@ -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); diff --git a/tests/boundary/providers/native/nativeProvider.test.ts b/tests/boundary/providers/native/nativeProvider.test.ts index 9260e7fd1..2b26733b0 100644 --- a/tests/boundary/providers/native/nativeProvider.test.ts +++ b/tests/boundary/providers/native/nativeProvider.test.ts @@ -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" }),