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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lewis Wang
2026-10-23 05:14:26 +08:00
committed by GitHub
parent 7695466081
commit 5a311d8afe
18 changed files with 531 additions and 89 deletions
@@ -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",
});
},
);
});
+80 -51
View File
@@ -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<Record<HostRestrictedFeature, boolean>>
>;
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,
+5 -1
View File
@@ -78,6 +78,10 @@ export const TSHARK_PROVIDER_IDENTITY = {
} as const;
type Launcher = NonNullable<Parameters<typeof runOwnedCommand>[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.",
);
+3
View File
@@ -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());
+7 -1
View File
@@ -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<NodeJS.ProcessEnv> = process.env,
): PacketCaptureService =>
+2 -1
View File
@@ -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.
+6 -1
View File
@@ -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<Record<string, string | undefined>> = process.env,
+6 -1
View File
@@ -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 = (
+7 -1
View File
@@ -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<NodeJS.ProcessEnv> = process.env,
): SignalRecordingService =>
+8 -2
View File
@@ -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,
+5 -1
View File
@@ -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<Record<string, string | undefined>>,
@@ -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)
)
+7 -2
View File
@@ -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)
)
+7 -1
View File
@@ -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<typeof runOwnedCommand>[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,
+79 -14
View File
@@ -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) =>
+39 -9
View File
@@ -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<AvailabilityPolicy["hostUnsupported"]>,
): Partial<Record<HostRestrictedFeature, boolean>> => {
const combined: Partial<Record<HostRestrictedFeature, boolean>> = {};
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,
),
}),
};
};
};
+5 -1
View File
@@ -73,6 +73,10 @@ export const SIGROK_PROVIDER_IDENTITY = {
} as const;
type Launcher = NonNullable<Parameters<typeof runOwnedCommand>[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.",
);
@@ -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",
});
});
@@ -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<void>,
): Promise<void> => {
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<typeof createServer>[1] = {},
): Promise<{
readonly path: (name: string) => string;
readonly availability: () => Promise<
ReturnType<typeof expandToolAvailability>
>;
readonly call: (name: string, args: Record<string, unknown>) => unknown;
readonly close: () => Promise<void>;
}> => {
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<string, unknown>) => {
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();
}
}));