From 5a311d8afe41fd57566e05a9ea345d2be2bbac4b Mon Sep 17 00:00:00 2001 From: Lewis Wang Date: Fri, 23 Oct 2026 05:14:26 +0800 Subject: [PATCH] fix(mcp): report Linux-only integrations as unsupported_host off their hosts (#5793) * fix(mcp): report Linux-only integrations as unsupported_host off their hosts Session discovery folded the host check into each optional integration's enabled flag, so on macOS or Windows the Linux-only packet, signal, firmware, pwntools, EVM and Wakaru tools reported provider_missing. Their execution guards refuse those hosts whatever is configured, so installing a provider there could never make them available. The server now records, per integration, when the host is outside its verified host and no service is injected. Discovery checks that before configuration and reports unsupported_host with the existing remediation. Injected services and supported hosts behave as before. Fixes #5730 Co-Authored-By: Claude Opus 5.5 * refactor(mcp): share provider host checks with session discovery Discovery restated each Linux-only integration's host rule in createServer while its provider kept a separate execution check, so the two could drift apart again. Each provider now exports its host predicate, uses it in its execution guard, and composition hands the same predicate to createServer for both the host restriction and the enabled flag. Observed host restrictions now also apply when an embedder supplies its own availability policy, as provider load failures already do, because no policy lets the default providers pass their host checks. The MCP regression moves to tests/process-global and emulates darwin arm64, linux arm64 and linux x64 through the real createServer, so CI on Linux x64 exercises the unsupported-host branch. It also covers a configured policy and the matching execution refusals. Co-Authored-By: Claude Opus 5.5 * fix(mcp): keep caller host restrictions beside observed ones The observed host restriction replaced a configured policy's whole hostUnsupported map. For an injected service the observed value is false, so a caller's own restriction was lost and the tool was reported available. Combine both per integration: either the caller or the observed host can mark it unsupported. Co-Authored-By: Claude Opus 5.5 * test(pcap): expect unsupported_host for packet tools off Linux The host-independent PATH case asserted provider_missing on every host. Off Linux the packet tools are unavailable because of the host, so discovery now reports unsupported_host there; Linux still reports provider_missing for an unusable PATH. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- src/application/CapabilityInventory.test.ts | 53 +++++ src/application/CapabilityInventory.ts | 131 ++++++----- src/captures/TsharkCaptureProvider.ts | 6 +- src/composition/binaryDiagnostics.ts | 3 + src/composition/captures.ts | 8 +- src/composition/evm.ts | 3 +- src/composition/firmware.ts | 7 +- src/composition/javascriptRecovery.ts | 7 +- src/composition/signals.ts | 8 +- src/evm/EvmoleInterfaceProvider.ts | 10 +- src/firmware/FirmwareCommand.ts | 6 +- src/javascript/recovery/WakaruCommand.ts | 9 +- src/native/pwntools/PwntoolsDecoder.ts | 8 +- src/server/createServer.ts | 93 ++++++-- src/server/sessionAvailabilityPolicy.ts | 48 +++- src/signals/SigrokRecordingProvider.ts | 6 +- .../mcp/packetCaptureAvailability.test.ts | 8 +- .../hostRestrictedAvailabilityMcp.test.ts | 206 ++++++++++++++++++ 18 files changed, 531 insertions(+), 89 deletions(-) create mode 100644 tests/process-global/hostRestrictedAvailabilityMcp.test.ts 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(); + } + }));