diff --git a/src/application/CapabilityInventory.test.ts b/src/application/CapabilityInventory.test.ts index f5bba9843e..f8c6282bf3 100644 --- a/src/application/CapabilityInventory.test.ts +++ b/src/application/CapabilityInventory.test.ts @@ -762,3 +762,56 @@ describe("trace_feature requirements", () => { ).toMatchObject({ available: false, reason: "provider_missing" }); }); }); + +const HOST_RESTRICTED_TOOLS = [ + ["inspect_packet_capture", "packetCapture", "packetCaptureEnabled"], + ["inspect_packet_fields", "packetCapture", "packetCaptureEnabled"], + ["decode_signal_recording", "signalRecording", "signalRecordingEnabled"], + ["inspect_signal_decoders", "signalRecording", "signalRecordingEnabled"], + ["inspect_evm_interface", "evmInterface", "evmInterfaceEnabled"], + ["inspect_binary_layout", "binaryLayout", "binaryLayoutEnabled"], + ["inspect_recorded_crash", "recordedCrash", "recordedCrashEnabled"], + [ + "inspect_firmware_regions", + "firmwareInspection", + "firmwareInspectionEnabled", + ], + ["extract_firmware", "firmwareExtraction", "firmwareExtractionEnabled"], + [ + "recover_javascript_sources", + "javascriptRecovery", + "javascriptRecoveryEnabled", + ], +] as const; + +describe("host-restricted integrations", () => { + it.each(HOST_RESTRICTED_TOOLS)( + "reports %s on an unsupported host before its configuration", + (name, feature, enabledField) => { + const availability = (enabled: boolean, hostUnsupported: boolean) => + buildCapabilityInventory( + { open: false, capabilities: [] }, + { + processCaptureEnabled: true, + ...({ [enabledField]: enabled } as Record< + typeof enabledField, + boolean + >), + hostUnsupported: { [feature]: hostUnsupported }, + }, + ).find((tool) => tool.name === name); + expect(availability(true, true)).toMatchObject({ + available: false, + reason: "unsupported_host", + }); + expect(availability(false, false)).toMatchObject({ + available: false, + reason: "provider_missing", + }); + expect(availability(true, false)).toMatchObject({ + available: true, + reason: "available", + }); + }, + ); +}); diff --git a/src/application/CapabilityInventory.ts b/src/application/CapabilityInventory.ts index 2f9abadfbd..6c9735fe1d 100644 --- a/src/application/CapabilityInventory.ts +++ b/src/application/CapabilityInventory.ts @@ -51,7 +51,25 @@ const statusSchema = z.object({ type ToolAvailabilityReason = "available" | ToolUnavailabilityReason; type ProviderDescriptor = ProviderCapability; + +/** Optional integrations whose verified hosts exclude some platforms. */ +export const HOST_RESTRICTED_FEATURES = [ + "packetCapture", + "signalRecording", + "evmInterface", + "binaryLayout", + "recordedCrash", + "firmwareInspection", + "firmwareExtraction", + "javascriptRecovery", +] as const; +export type HostRestrictedFeature = (typeof HOST_RESTRICTED_FEATURES)[number]; + export type AvailabilityPolicy = { + /** Integrations this host cannot run, whatever their configuration. */ + readonly hostUnsupported?: Readonly< + Partial> + >; readonly packetCaptureEnabled?: boolean; readonly signalRecordingEnabled?: boolean; readonly wasmArtifactEnabled?: boolean; @@ -461,37 +479,33 @@ const workflowAvailabilityFor = ({ ], }; if (name === "recover_javascript_sources") - return policy.javascriptRecoveryEnabled === true - ? { reason: "available", remediation: null } - : { - reason: "provider_missing", - remediation: - "On Linux x64, provide an absolute REA_WAKARU_COMMAND for Wakaru ^1.13.0 (verified with 1.14.0) and util-linux prlimit; see docs/javascript-recovery.md#install-wakaru. No binary target is required.", - }; + return hostRestrictedAvailability( + policy, + "javascriptRecovery", + policy.javascriptRecoveryEnabled, + "On Linux x64, provide an absolute REA_WAKARU_COMMAND for Wakaru ^1.13.0 (verified with 1.14.0) and util-linux prlimit; see docs/javascript-recovery.md#install-wakaru. No binary target is required.", + ); if (name === "inspect_binary_layout") - return policy.binaryLayoutEnabled === true - ? { reason: "available", remediation: null } - : { - reason: "provider_missing", - remediation: - "On Linux x64, set absolute REA_PWNTOOLS_PYTHON to caller-supplied Python with pwntools 4.15.0. No active binary target is required.", - }; + return hostRestrictedAvailability( + policy, + "binaryLayout", + policy.binaryLayoutEnabled, + "On Linux x64, set absolute REA_PWNTOOLS_PYTHON to caller-supplied Python with pwntools 4.15.0. No active binary target is required.", + ); if (name === "inspect_packet_capture" || name === "inspect_packet_fields") - return policy.packetCaptureEnabled === true - ? { reason: "available", remediation: null } - : { - reason: "provider_missing", - remediation: - "On Linux, supply TShark 4.x on PATH or through absolute REA_TSHARK_COMMAND, and util-linux prlimit (REA_TSHARK_PRLIMIT_COMMAND, default /usr/bin/prlimit). No active binary target or hardware is required; the tool is checked at execution.", - }; + return hostRestrictedAvailability( + policy, + "packetCapture", + policy.packetCaptureEnabled, + "On Linux, supply TShark 4.x on PATH or through absolute REA_TSHARK_COMMAND, and util-linux prlimit (REA_TSHARK_PRLIMIT_COMMAND, default /usr/bin/prlimit). No active binary target or hardware is required; the tool is checked at execution.", + ); if (name === "decode_signal_recording" || name === "inspect_signal_decoders") - return policy.signalRecordingEnabled === true - ? { reason: "available", remediation: null } - : { - reason: "provider_missing", - remediation: - "On Linux, supply sigrok-cli 0.7.2 via absolute REA_SIGROK_COMMAND and util-linux prlimit (REA_SIGROK_PRLIMIT_COMMAND, default /usr/bin/prlimit). No active binary target or hardware is required; the tool is checked at execution.", - }; + return hostRestrictedAvailability( + policy, + "signalRecording", + policy.signalRecordingEnabled, + "On Linux, supply sigrok-cli 0.7.2 via absolute REA_SIGROK_COMMAND and util-linux prlimit (REA_SIGROK_PRLIMIT_COMMAND, default /usr/bin/prlimit). No active binary target or hardware is required; the tool is checked at execution.", + ); if (name === "inspect_wasm_artifact") return policy.wasmArtifactEnabled === true ? { reason: "available", remediation: null } @@ -507,37 +521,35 @@ const workflowAvailabilityFor = ({ if (name === "inspect_elf_notes") return { reason: "available", remediation: null }; if (name === "inspect_evm_interface") { - if (policy.evmInterfaceEnabled !== true) - return { - reason: "provider_missing", - remediation: - "The bundled offline EVM interface profile is currently real-verified on Linux x64. No active binary target or chain endpoint is required.", - }; + const profile = hostRestrictedAvailability( + policy, + "evmInterface", + policy.evmInterfaceEnabled, + "The bundled offline EVM interface profile is currently real-verified on Linux x64. No active binary target or chain endpoint is required.", + ); + if (profile.reason !== "available") return profile; const limiter = policy.evmLimiterAvailability; if (limiter?.status === "unavailable") return { reason: "provider_missing", remediation: limiter.reason }; return { reason: "available", remediation: null }; } if (name === "inspect_recorded_crash") - return policy.recordedCrashEnabled === true - ? { reason: "available", remediation: null } - : { - reason: "provider_missing", - remediation: - "On Linux x64, set absolute REA_PWNTOOLS_PYTHON to caller-supplied Python with pwntools 4.15.0. No active binary target is required.", - }; + return hostRestrictedAvailability( + policy, + "recordedCrash", + policy.recordedCrashEnabled, + "On Linux x64, set absolute REA_PWNTOOLS_PYTHON to caller-supplied Python with pwntools 4.15.0. No active binary target is required.", + ); if (kind === "firmware-provider") { - const enabled = - name === "inspect_firmware_regions" + const inspection = name === "inspect_firmware_regions"; + return hostRestrictedAvailability( + policy, + inspection ? "firmwareInspection" : "firmwareExtraction", + inspection ? policy.firmwareInspectionEnabled - : policy.firmwareExtractionEnabled; - return enabled === true - ? { reason: "available", remediation: null } - : { - reason: "provider_missing", - remediation: - "On Linux, provide an absolute REA_BINWALK_COMMAND (Binwalk 3.1.x, verified with 3.1.0) or REA_UNBLOB_COMMAND (Unblob 26.6.x, verified with 26.6.4) path and util-linux prlimit. Extraction also requires the selected format’s external extractor.", - }; + : policy.firmwareExtractionEnabled, + "On Linux, provide an absolute REA_BINWALK_COMMAND (Binwalk 3.1.x, verified with 3.1.0) or REA_UNBLOB_COMMAND (Unblob 26.6.x, verified with 26.6.4) path and util-linux prlimit. Extraction also requires the selected format’s external extractor.", + ); } if (kind === "android-provider") return policy.androidAnalysisAvailability?.status === "available" @@ -604,6 +616,23 @@ const workflowAvailabilityFor = ({ return null; }; +/** + * An unsupported host takes precedence over configuration: no environment + * setting lets these integrations pass their execution host checks here. + */ +const hostRestrictedAvailability = ( + policy: AvailabilityPolicy, + feature: HostRestrictedFeature, + enabled: boolean | undefined, + remediation: string, +): Availability => { + if (policy.hostUnsupported?.[feature] === true) + return { reason: "unsupported_host", remediation }; + return enabled === true + ? { reason: "available", remediation: null } + : { reason: "provider_missing", remediation }; +}; + const ownerProbeAvailability = ( observation: ProviderAvailability | undefined, missingRemediation: string, diff --git a/src/captures/TsharkCaptureProvider.ts b/src/captures/TsharkCaptureProvider.ts index 2aadaef049..2328f60d02 100644 --- a/src/captures/TsharkCaptureProvider.ts +++ b/src/captures/TsharkCaptureProvider.ts @@ -78,6 +78,10 @@ export const TSHARK_PROVIDER_IDENTITY = { } as const; type Launcher = NonNullable[2]>["launcher"]; +/** Packet inspection runs only on Linux; discovery and execution share this check. */ +export const tsharkHostSupported = (platform: NodeJS.Platform): boolean => + platform === "linux"; + /** Inspect selected capture files only, retaining cleanup ownership across failures. */ export class TsharkCaptureProvider implements PacketCapturePort { readonly #resources: ArtifactAnalysisResources; @@ -149,7 +153,7 @@ export class TsharkCaptureProvider implements PacketCapturePort { cause: options.signal.reason, }); await this.#resources.release(); - if (this.platform !== "linux") + if (!tsharkHostSupported(this.platform)) throw new Error( "Packet inspection requires Linux with util-linux prlimit and TShark 4.x (on PATH or through absolute REA_TSHARK_COMMAND) so expanded captures are processed under an enforced address-space limit.", ); diff --git a/src/composition/binaryDiagnostics.ts b/src/composition/binaryDiagnostics.ts index 81be8de875..b0b562bf41 100644 --- a/src/composition/binaryDiagnostics.ts +++ b/src/composition/binaryDiagnostics.ts @@ -3,6 +3,7 @@ import { RecordedCrashService } from "../application/binaryDiagnostics/RecordedC import { PwntoolsRecordedCrashProvider } from "../native/pwntools/PwntoolsRecordedCrashProvider.js"; import { BinaryLayoutService } from "../application/binaryDiagnostics/BinaryLayoutService.js"; import { PwntoolsLayoutProvider } from "../native/pwntools/PwntoolsLayoutProvider.js"; +import { pwntoolsHostSupported } from "../native/pwntools/PwntoolsDecoder.js"; import { PeResourcesService } from "../application/binaryDiagnostics/PeResourcesService.js"; import { PeResourcesProvider } from "../native/pe/PeResourcesProvider.js"; import { PeCertificatesService } from "../application/binaryDiagnostics/PeCertificatesService.js"; @@ -13,6 +14,8 @@ import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.j import { NativeExportsService } from "../application/binaryDiagnostics/NativeExportsService.js"; import { NativeExportsProvider } from "../native/exports/NativeExportsProvider.js"; +export { pwntoolsHostSupported }; + /** Portable PE inspection with no engine, installation or target acquisition. */ export const createPeResourcesService = (): PeResourcesService => new PeResourcesService(new PeResourcesProvider()); diff --git a/src/composition/captures.ts b/src/composition/captures.ts index 264cf6aa59..ab033cfa3d 100644 --- a/src/composition/captures.ts +++ b/src/composition/captures.ts @@ -1,5 +1,11 @@ import { PacketCaptureService } from "../application/captures/PacketCaptureService.js"; -import { TsharkCaptureProvider } from "../captures/TsharkCaptureProvider.js"; +import { + TsharkCaptureProvider, + tsharkHostSupported, +} from "../captures/TsharkCaptureProvider.js"; + +export { tsharkHostSupported }; + export const createPacketCaptureService = ( environment: Readonly = process.env, ): PacketCaptureService => diff --git a/src/composition/evm.ts b/src/composition/evm.ts index 17d5d120cc..853cd73b02 100644 --- a/src/composition/evm.ts +++ b/src/composition/evm.ts @@ -1,6 +1,7 @@ import { EvmInterfaceService } from "../application/evm/EvmInterfaceService.js"; import { EvmoleInterfaceProvider, + evmHostSupported, evmLimiterAvailability, } from "../evm/EvmoleInterfaceProvider.js"; import { CLI_COMMANDS } from "../cliCommandNames.js"; @@ -12,7 +13,7 @@ export const createEvmInterfaceService = ( ): EvmInterfaceService => new EvmInterfaceService(new EvmoleInterfaceProvider(environment)); -export { evmLimiterAvailability }; +export { evmHostSupported, evmLimiterAvailability }; /** * Static binding of the existing operation, command and fresh service factory. diff --git a/src/composition/firmware.ts b/src/composition/firmware.ts index f3bed02938..78e16d9176 100644 --- a/src/composition/firmware.ts +++ b/src/composition/firmware.ts @@ -1,7 +1,12 @@ import type { FirmwareAnalysisPort } from "../application/firmware/FirmwareAnalysisPort.js"; -import type { FirmwareLauncher } from "../firmware/FirmwareCommand.js"; +import { + firmwareHostSupported, + type FirmwareLauncher, +} from "../firmware/FirmwareCommand.js"; import { FirmwareProvider } from "../firmware/FirmwareProvider.js"; +export { firmwareHostSupported }; + /** Construct one firmware provider without launching or acquiring an engine. */ export const createFirmwareAnalysisProvider = ( environment: Readonly> = process.env, diff --git a/src/composition/javascriptRecovery.ts b/src/composition/javascriptRecovery.ts index f3f2ae49d4..f57c984d6f 100644 --- a/src/composition/javascriptRecovery.ts +++ b/src/composition/javascriptRecovery.ts @@ -1,6 +1,11 @@ import type { JavaScriptRecoveryPort } from "../application/javascript/JavaScriptRecoveryPort.js"; import { WakaruProvider } from "../javascript/recovery/WakaruProvider.js"; -import type { WakaruLauncher } from "../javascript/recovery/WakaruCommand.js"; +import { + wakaruHostSupported, + type WakaruLauncher, +} from "../javascript/recovery/WakaruCommand.js"; + +export { wakaruHostSupported }; /** Construct optional recovery without acquiring tools, processes or workspaces. */ export const createJavaScriptRecoveryProvider = ( diff --git a/src/composition/signals.ts b/src/composition/signals.ts index d00934e6fd..d21395389f 100644 --- a/src/composition/signals.ts +++ b/src/composition/signals.ts @@ -1,5 +1,11 @@ import { SignalRecordingService } from "../application/signals/SignalRecordingService.js"; -import { SigrokRecordingProvider } from "../signals/SigrokRecordingProvider.js"; +import { + SigrokRecordingProvider, + sigrokHostSupported, +} from "../signals/SigrokRecordingProvider.js"; + +export { sigrokHostSupported }; + export const createSignalRecordingService = ( environment: Readonly = process.env, ): SignalRecordingService => diff --git a/src/evm/EvmoleInterfaceProvider.ts b/src/evm/EvmoleInterfaceProvider.ts index 573c993c33..f9f979816b 100644 --- a/src/evm/EvmoleInterfaceProvider.ts +++ b/src/evm/EvmoleInterfaceProvider.ts @@ -74,6 +74,12 @@ import { const OPERATION = "inspect_evm_interface"; const HOST_REQUIREMENT = "Initial real-verified offline EVM interface profile supports Linux x64 only."; + +/** The EVM interface profile runs only on Linux x64; discovery and execution share this check. */ +export const evmHostSupported = ( + platform: NodeJS.Platform, + arch: string, +): boolean => platform === "linux" && arch === "x64"; export const EVM_PRLIMIT_COMMAND_DEFAULT = "/usr/bin/prlimit"; export interface EvmLimiterProbeFileSystem { @@ -115,7 +121,7 @@ const selectEvmLimiter = async ( throwIfAborted(); const platform = probes?.platform ?? process.platform; const arch = probes?.arch ?? process.arch; - if (platform !== "linux" || arch !== "x64") return undefined; + if (!evmHostSupported(platform, arch)) return undefined; const path = environment.REA_EVM_PRLIMIT_COMMAND ?? EVM_PRLIMIT_COMMAND_DEFAULT; @@ -424,7 +430,7 @@ export class EvmoleInterfaceProvider implements EvmInterfacePort { throw new AnalysisCancelledError(OPERATION, { cause: options.signal.reason, }); - if (process.platform !== "linux" || process.arch !== "x64") + if (!evmHostSupported(process.platform, process.arch)) throw new AnalysisCapabilityUnavailableError( EVMOLE_PROVIDER_IDENTITY.id, OPERATION, diff --git a/src/firmware/FirmwareCommand.ts b/src/firmware/FirmwareCommand.ts index 8e99f2d49f..a0df50b76b 100644 --- a/src/firmware/FirmwareCommand.ts +++ b/src/firmware/FirmwareCommand.ts @@ -66,6 +66,10 @@ export const firmwareToolUnavailable = ( userMessage: reason, }); +/** Firmware engines run only on Linux; discovery and execution share this check. */ +export const firmwareHostSupported = (platform: NodeJS.Platform): boolean => + platform === "linux"; + /** Resolve explicitly supplied executable paths; never install or search for engines. */ export const resolveFirmwareCommand = async ( environment: Readonly>, @@ -78,7 +82,7 @@ export const resolveFirmwareCommand = async ( engine === "binwalk" ? "REA_BINWALK_COMMAND" : "REA_UNBLOB_COMMAND"; const configured = environment[variable]; if ( - process.platform !== "linux" || + !firmwareHostSupported(process.platform) || configured === undefined || !isAbsolute(configured) ) diff --git a/src/javascript/recovery/WakaruCommand.ts b/src/javascript/recovery/WakaruCommand.ts index 2cfe86beba..69a050daa5 100644 --- a/src/javascript/recovery/WakaruCommand.ts +++ b/src/javascript/recovery/WakaruCommand.ts @@ -29,6 +29,12 @@ const OPERATION = "recover_javascript_sources"; const WAKARU_REQUIREMENT = "Provide an absolute REA_WAKARU_COMMAND pointing to a Wakaru ^1.13.0 Linux x64 binary (verified with 1.14.0); no tool is installed by REA. See docs/javascript-recovery.md#install-wakaru."; +/** Wakaru recovery runs only on Linux x64; discovery and execution share this check. */ +export const wakaruHostSupported = ( + platform: NodeJS.Platform, + arch: string, +): boolean => platform === "linux" && arch === "x64"; + /** Inject the owned process launcher while retaining production protocol parsing. */ export type WakaruLauncher = ( options: OwnedProviderProcessSpawnOptions, @@ -65,8 +71,7 @@ export const resolveWakaruCommand = async ( ) => { const configured = environment.REA_WAKARU_COMMAND; if ( - process.platform !== "linux" || - process.arch !== "x64" || + !wakaruHostSupported(process.platform, process.arch) || configured === undefined || !isAbsolute(configured) ) diff --git a/src/native/pwntools/PwntoolsDecoder.ts b/src/native/pwntools/PwntoolsDecoder.ts index c53423969f..15ed318c43 100644 --- a/src/native/pwntools/PwntoolsDecoder.ts +++ b/src/native/pwntools/PwntoolsDecoder.ts @@ -64,6 +64,12 @@ import { pwntoolsInputBytes } from "./PwntoolsInputLimit.js"; const HOST_REQUIREMENT = "The initial real-verified pwntools artifact profile supports Linux x64 only."; +/** The pwntools profile runs only on Linux x64; discovery and execution share this check. */ +export const pwntoolsHostSupported = ( + platform: NodeJS.Platform, + arch: string, +): boolean => platform === "linux" && arch === "x64"; + /** Owned launcher seam shared by the offline pwntools artifact adapters. */ export type PwntoolsLauncher = NonNullable< Parameters[2] @@ -263,7 +269,7 @@ export class PwntoolsDecoder< throw new AnalysisCancelledError(OPERATION, { cause: options.signal.reason, }); - if (process.platform !== "linux" || process.arch !== "x64") + if (!pwntoolsHostSupported(process.platform, process.arch)) throw new AnalysisCapabilityUnavailableError( this.identity.id, OPERATION, diff --git a/src/server/createServer.ts b/src/server/createServer.ts index 0e1c0a141d..a84b2e7fe2 100644 --- a/src/server/createServer.ts +++ b/src/server/createServer.ts @@ -2,10 +2,16 @@ import { createGpuCodeObjectsService } from "../composition/gpu.js"; import { registerGpuCodeObjectsTools } from "./registerGpuCodeObjectsTools.js"; import type { PacketCaptureService } from "../application/captures/PacketCaptureService.js"; import { UNTRUSTED_CONTENT_INSTRUCTIONS } from "./toolResultEncoding.js"; -import { createPacketCaptureService } from "../composition/captures.js"; +import { + createPacketCaptureService, + tsharkHostSupported, +} from "../composition/captures.js"; import { registerCaptureTools } from "./registerCaptureTools.js"; import type { SignalRecordingService } from "../application/signals/SignalRecordingService.js"; -import { createSignalRecordingService } from "../composition/signals.js"; +import { + createSignalRecordingService, + sigrokHostSupported, +} from "../composition/signals.js"; import { registerSignalTools } from "./registerSignalTools.js"; import type { WasmArtifactService } from "../application/wasm/WasmArtifactService.js"; import { wasmArtifactModule } from "../composition/wasm.js"; @@ -31,6 +37,7 @@ import { registerPeResourcesTool } from "./registerPeResourcesTool.js"; import { registerNativeExportsTool } from "./registerNativeExportsTool.js"; import type { EvmInterfaceService } from "../application/evm/EvmInterfaceService.js"; import { + evmHostSupported, evmInterfaceModule, evmLimiterAvailability, } from "../composition/evm.js"; @@ -69,6 +76,7 @@ import { registerFridaTools } from "./registerFridaTools.js"; import { createByteEntropyService, createBinaryLayoutService, + pwntoolsHostSupported, } from "../composition/binaryDiagnostics.js"; import type { BinaryLayoutService } from "../application/binaryDiagnostics/BinaryLayoutService.js"; import { createCatalogIdentity } from "../catalogIdentity.js"; @@ -118,7 +126,10 @@ import { registerWebNetworkCaptureTool } from "./registerWebNetworkCaptureTool.j import { registerJavaScriptRecoveryTool } from "./registerJavaScriptRecoveryTool.js"; import { JavaScriptRecoveryService } from "../application/javascript/JavaScriptRecoveryService.js"; import type { JavaScriptRecoveryPort } from "../application/javascript/JavaScriptRecoveryPort.js"; -import { createJavaScriptRecoveryProvider } from "../composition/javascriptRecovery.js"; +import { + createJavaScriptRecoveryProvider, + wakaruHostSupported, +} from "../composition/javascriptRecovery.js"; import { registerElectronTools } from "./registerElectronTools.js"; import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; import { registerEnhancedTools } from "./registerEnhancedTools.js"; @@ -127,7 +138,10 @@ import { registerManagedTools } from "./registerManagedTools.js"; import { registerFirmwareTools } from "./registerFirmwareTools.js"; import { FirmwareAnalysisService } from "../application/firmware/FirmwareAnalysisService.js"; import type { FirmwareAnalysisPort } from "../application/firmware/FirmwareAnalysisPort.js"; -import { createFirmwareAnalysisProvider } from "../composition/firmware.js"; +import { + createFirmwareAnalysisProvider, + firmwareHostSupported, +} from "../composition/firmware.js"; import { registerAndroidTools } from "./registerAndroidTools.js"; import { registerApktoolTools } from "./registerApktoolTools.js"; import { registerPcapTools } from "./registerPcapTools.js"; @@ -257,23 +271,71 @@ const installSessionToolAvailability = ( fridaInstrumentation: FridaInstrumentationPort, ) => { if (session === undefined) return undefined; - const linux = process.platform === "linux"; - const linuxX64 = linux && process.arch === "x64"; + const { platform, arch } = process; + // Each provider owns its verified host; discovery and execution share it. + const verifiedHost = { + packetCapture: tsharkHostSupported(platform), + signalRecording: sigrokHostSupported(platform), + evmInterface: evmHostSupported(platform, arch), + pwntools: pwntoolsHostSupported(platform, arch), + firmware: firmwareHostSupported(platform), + javascriptRecovery: wakaruHostSupported(platform, arch), + }; const pwntools = - linuxX64 && isAbsolute(environment.REA_PWNTOOLS_PYTHON ?? ""); + verifiedHost.pwntools && isAbsolute(environment.REA_PWNTOOLS_PYTHON ?? ""); + // Injected services own their availability on any host. Otherwise no + // configuration lets an integration pass its execution host check. + const outsideVerifiedHost = (injected: unknown, supported: boolean) => + injected === undefined && !supported; const basePolicy = sessionAvailabilityPolicy(options.availabilityPolicy, { optionalProviderLoadFailures: options.optionalProviderLoadFailures, + hostUnsupported: { + packetCapture: outsideVerifiedHost( + options.packetCapture, + verifiedHost.packetCapture, + ), + signalRecording: outsideVerifiedHost( + options.signalRecording, + verifiedHost.signalRecording, + ), + evmInterface: outsideVerifiedHost( + options.evmInterface, + verifiedHost.evmInterface, + ), + binaryLayout: outsideVerifiedHost( + options.binaryLayout, + verifiedHost.pwntools, + ), + recordedCrash: outsideVerifiedHost( + options.recordedCrash, + verifiedHost.pwntools, + ), + firmwareInspection: outsideVerifiedHost( + options.firmwareAnalysis, + verifiedHost.firmware, + ), + firmwareExtraction: outsideVerifiedHost( + options.firmwareAnalysis, + verifiedHost.firmware, + ), + javascriptRecovery: outsideVerifiedHost( + options.javascriptRecovery, + verifiedHost.javascriptRecovery, + ), + }, optionalFeatures: { packetCaptureEnabled: options.packetCapture !== undefined || - (linux && tsharkLocatable(environment)), + (verifiedHost.packetCapture && tsharkLocatable(environment)), signalRecordingEnabled: options.signalRecording !== undefined || - (linux && isAbsolute(environment.REA_SIGROK_COMMAND ?? "")), + (verifiedHost.signalRecording && + isAbsolute(environment.REA_SIGROK_COMMAND ?? "")), wasmArtifactEnabled: options.wasmArtifact !== undefined || isAbsolute(environment.REA_WABT_BIN_DIRECTORY ?? ""), - evmInterfaceEnabled: options.evmInterface !== undefined || linuxX64, + evmInterfaceEnabled: + options.evmInterface !== undefined || verifiedHost.evmInterface, webModuleResolutionEnabled: options.webModuleTrace !== undefined || isAbsolute(environment.REA_BROWSER_EXECUTABLE ?? ""), @@ -281,14 +343,17 @@ const installSessionToolAvailability = ( binaryLayoutEnabled: options.binaryLayout !== undefined || pwntools, firmwareInspectionEnabled: options.firmwareAnalysis !== undefined || - (linux && isAbsolute(environment.REA_BINWALK_COMMAND ?? "")), + (verifiedHost.firmware && + isAbsolute(environment.REA_BINWALK_COMMAND ?? "")), firmwareExtractionEnabled: options.firmwareAnalysis !== undefined || - (linux && isAbsolute(environment.REA_UNBLOB_COMMAND ?? "")), + (verifiedHost.firmware && + isAbsolute(environment.REA_UNBLOB_COMMAND ?? "")), browserObservationEnabled: options.browserObservation !== undefined, javascriptRecoveryEnabled: options.javascriptRecovery !== undefined || - (linuxX64 && isAbsolute(environment.REA_WAKARU_COMMAND ?? "")), + (verifiedHost.javascriptRecovery && + isAbsolute(environment.REA_WAKARU_COMMAND ?? "")), browserScenarioEnabled: options.browserScenarioCapture !== undefined, electronObservationEnabled: options.electronObservation !== undefined, electronAutomationEnabled: @@ -314,7 +379,7 @@ const installSessionToolAvailability = ( }; return { policy, - ...(options.evmInterface !== undefined || !linuxX64 + ...(options.evmInterface !== undefined || !verifiedHost.evmInterface ? {} : { evmLimiterAvailability: (signal: AbortSignal) => diff --git a/src/server/sessionAvailabilityPolicy.ts b/src/server/sessionAvailabilityPolicy.ts index 430f88d005..47867f78f9 100644 --- a/src/server/sessionAvailabilityPolicy.ts +++ b/src/server/sessionAvailabilityPolicy.ts @@ -1,4 +1,8 @@ -import type { AvailabilityPolicy } from "../application/CapabilityInventory.js"; +import { + HOST_RESTRICTED_FEATURES, + type AvailabilityPolicy, + type HostRestrictedFeature, +} from "../application/CapabilityInventory.js"; import type { OptionalProviderLoadFailures } from "../application/OptionalObservationProviders.js"; import { platform } from "node:process"; @@ -6,6 +10,7 @@ export interface SessionAvailabilityDefaults { readonly optionalProviderLoadFailures?: | OptionalProviderLoadFailures | undefined; + readonly hostUnsupported?: AvailabilityPolicy["hostUnsupported"]; readonly optionalFeatures?: Pick< AvailabilityPolicy, | "packetCaptureEnabled" @@ -26,6 +31,18 @@ export interface SessionAvailabilityDefaults { >; } +/** Either the caller or the observed host can mark an integration unsupported. */ +const combineHostUnsupported = ( + configured: AvailabilityPolicy["hostUnsupported"], + observed: NonNullable, +): Partial> => { + const combined: Partial> = {}; + for (const feature of HOST_RESTRICTED_FEATURES) + if (configured?.[feature] === true || observed[feature] === true) + combined[feature] = true; + return combined; +}; + /** Select configured availability reporting or the target-free defaults. */ export const sessionAvailabilityPolicy = ( configured: (() => AvailabilityPolicy) | undefined, @@ -66,12 +83,25 @@ export const sessionAvailabilityPolicy = ( v8InspectorObservationEnabled: defaults.optionalFeatures?.v8InspectorObservationEnabled ?? false, })); - return () => ({ - ...policy(), - ...(defaults.optionalProviderLoadFailures === undefined - ? {} - : { - optionalProviderLoadFailures: defaults.optionalProviderLoadFailures, - }), - }); + // Observed host support also bounds a configured policy: no policy lets the + // default providers pass their execution host checks. + return () => { + const value: AvailabilityPolicy = policy(); + return { + ...value, + ...(defaults.optionalProviderLoadFailures === undefined + ? {} + : { + optionalProviderLoadFailures: defaults.optionalProviderLoadFailures, + }), + ...(defaults.hostUnsupported === undefined + ? {} + : { + hostUnsupported: combineHostUnsupported( + value.hostUnsupported, + defaults.hostUnsupported, + ), + }), + }; + }; }; diff --git a/src/signals/SigrokRecordingProvider.ts b/src/signals/SigrokRecordingProvider.ts index 11576cd5a2..4d75acae69 100644 --- a/src/signals/SigrokRecordingProvider.ts +++ b/src/signals/SigrokRecordingProvider.ts @@ -73,6 +73,10 @@ export const SIGROK_PROVIDER_IDENTITY = { } as const; type Launcher = NonNullable[2]>["launcher"]; +/** Signal decoding runs only on Linux; discovery and execution share this check. */ +export const sigrokHostSupported = (platform: NodeJS.Platform): boolean => + platform === "linux"; + /** Decode selected recordings only, retaining cleanup ownership across failures. */ export class SigrokRecordingProvider implements SignalRecordingPort { readonly #resources: ArtifactAnalysisResources; @@ -141,7 +145,7 @@ export class SigrokRecordingProvider implements SignalRecordingPort { cause: options.signal.reason, }); await this.#resources.release(); - if (this.platform !== "linux") + if (!sigrokHostSupported(this.platform)) throw new Error( "Signal decoding requires Linux with util-linux prlimit and REA_SIGROK_COMMAND so expanded recordings are processed under an enforced address-space limit.", ); diff --git a/tests/boundary/mcp/packetCaptureAvailability.test.ts b/tests/boundary/mcp/packetCaptureAvailability.test.ts index 9bd9c26c57..6002dc57e1 100644 --- a/tests/boundary/mcp/packetCaptureAvailability.test.ts +++ b/tests/boundary/mcp/packetCaptureAvailability.test.ts @@ -97,8 +97,12 @@ it.runIf(linux)( }, ); -it("reports packet tools missing when no absolute PATH entry holds TShark", async () => { +it("reports packet tools unavailable when no absolute PATH entry holds TShark", async () => { + // Off Linux the host, not the missing tool, keeps packet tools unavailable. expect( await packetCaptureAvailability({ PATH: "relative-entry" }), - ).toMatchObject({ available: false, reason: "provider_missing" }); + ).toMatchObject({ + available: false, + reason: linux ? "provider_missing" : "unsupported_host", + }); }); diff --git a/tests/process-global/hostRestrictedAvailabilityMcp.test.ts b/tests/process-global/hostRestrictedAvailabilityMcp.test.ts new file mode 100644 index 0000000000..d0d6b829e7 --- /dev/null +++ b/tests/process-global/hostRestrictedAvailabilityMcp.test.ts @@ -0,0 +1,206 @@ +import { join } from "node:path"; + +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import { expect, it } from "vitest"; +import { z } from "zod"; + +import { + expandToolAvailability, + sessionProvider, +} from "../../src/contracts/toolOutputSchemaPrimitives.js"; +import { createServer } from "../../src/server/createServer.js"; +import { createTestBinarySession } from "../fixtures/binarySession.js"; +import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; + +/** Each tool with the host its execution check admits. */ +const HOST_RESTRICTED_TOOLS = [ + ["inspect_packet_capture", "linux"], + ["inspect_packet_fields", "linux"], + ["decode_signal_recording", "linux"], + ["inspect_signal_decoders", "linux"], + ["inspect_firmware_regions", "linux"], + ["extract_firmware", "linux"], + ["inspect_binary_layout", "linux-x64"], + ["inspect_recorded_crash", "linux-x64"], + ["recover_javascript_sources", "linux-x64"], + ["inspect_evm_interface", "linux-x64"], +] as const; + +/** Run with the host identity that discovery and execution checks read. */ +const emulateHost = async ( + platform: NodeJS.Platform, + arch: NodeJS.Architecture, + run: () => Promise, +): Promise => { + const original = { + platform: Object.getOwnPropertyDescriptor(process, "platform"), + arch: Object.getOwnPropertyDescriptor(process, "arch"), + }; + Object.defineProperty(process, "platform", { + ...original.platform, + value: platform, + }); + Object.defineProperty(process, "arch", { ...original.arch, value: arch }); + try { + await run(); + } finally { + if (original.platform !== undefined) + Object.defineProperty(process, "platform", original.platform); + if (original.arch !== undefined) + Object.defineProperty(process, "arch", original.arch); + } +}; + +const connect = async ( + options: Parameters[1] = {}, +): Promise<{ + readonly path: (name: string) => string; + readonly availability: () => Promise< + ReturnType + >; + readonly call: (name: string, args: Record) => unknown; + readonly close: () => Promise; +}> => { + const directory = await createTestTempDirectory("rea-host-restricted-mcp-"); + const path = (name: string) => join(directory, name); + const session = createTestBinarySession(() => { + throw new Error("binary analysis must not start during status discovery"); + }); + // Absolute paths configure every integration; discovery does not run them. + const server = createServer( + { kind: "session", session }, + { + environment: { + REA_TSHARK_COMMAND: path("tshark"), + REA_SIGROK_COMMAND: path("sigrok-cli"), + REA_BINWALK_COMMAND: path("binwalk"), + REA_UNBLOB_COMMAND: path("unblob"), + REA_PWNTOOLS_PYTHON: path("python3"), + REA_WAKARU_COMMAND: path("wakaru"), + REA_EVM_PRLIMIT_COMMAND: path("missing-prlimit"), + REA_MCP_OUTPUT_SCHEMA_PROFILE: "off", + }, + ...options, + }, + ); + const client = new Client({ + name: "host-restricted-availability", + version: "1", + }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + await client.connect(clientTransport); + const call = async (name: string, args: Record) => { + const response = await client.callTool({ name, arguments: args }); + const text = z + .array(z.object({ type: z.string(), text: z.string().optional() })) + .parse(response.content) + .find((item) => item.type === "text")?.text; + const body = z + .record(z.string(), z.unknown()) + .parse(response.structuredContent ?? JSON.parse(text ?? "null")); + return "result" in body ? body.result : body; + }; + return { + path, + availability: async () => + expandToolAvailability( + sessionProvider.parse(await call("binary_session", {})) + .tool_availability, + ), + call, + close: async () => { + await Promise.allSettled([ + client.close(), + server.close(), + session.close(), + ]); + }, + }; +}; + +it.each([ + ["darwin", "arm64"], + ["linux", "arm64"], + ["linux", "x64"], +] as const)( + "reports host-restricted integrations on %s %s by host before configuration", + async (platform, arch) => + emulateHost(platform, arch, async () => { + const harness = await connect(); + try { + const availability = await harness.availability(); + for (const [name, requirement] of HOST_RESTRICTED_TOOLS) { + const supported = + platform === "linux" && (requirement === "linux" || arch === "x64"); + const entry = availability.find((tool) => tool.name === name); + if (!supported) + expect(entry, name).toMatchObject({ + available: false, + reason: "unsupported_host", + }); + else if (name === "inspect_evm_interface") + // The configured limiter is absent; the host itself is supported. + expect(entry, name).toMatchObject({ + available: false, + reason: "provider_missing", + }); + else expect(entry, name).toMatchObject({ available: true }); + } + } finally { + await harness.close(); + } + }), +); + +it.each([ + ["the observed host", "darwin", "arm64", {}], + [ + "the caller", + "linux", + "x64", + { hostUnsupported: { signalRecording: true } }, + ], +] as const)( + "keeps a host restriction from %s under a configured availability policy", + async (_source, platform, arch, restriction) => + emulateHost(platform, arch, async () => { + const harness = await connect({ + availabilityPolicy: () => ({ + processCaptureEnabled: true, + signalRecordingEnabled: true, + ...restriction, + }), + }); + try { + expect( + (await harness.availability()).find( + (tool) => tool.name === "decode_signal_recording", + ), + ).toMatchObject({ available: false, reason: "unsupported_host" }); + } finally { + await harness.close(); + } + }), +); + +it("refuses execution on the hosts that discovery reports unsupported", async () => + emulateHost("darwin", "arm64", async () => { + const harness = await connect(); + try { + for (const [name, args] of [ + ["inspect_signal_decoders", {}], + ["inspect_binary_layout", { path: harness.path("runtime.hex") }], + [ + "inspect_evm_interface", + { path: harness.path("runtime.hex"), encoding: "hex" }, + ], + ] as const) + expect(await harness.call(name, args), name).toMatchObject({ + error: { code: "capability_unavailable", retryable: false }, + }); + } finally { + await harness.close(); + } + }));