From 3536ea2f49e0c78cac8f4e5bb6a40ce8a8a929bf Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Thu, 29 Oct 2026 09:20:33 +0800 Subject: [PATCH] fix(discovery): report debugger start host restrictions (#7965) --- src/application/CapabilityInventory.test.ts | 27 ++++++++ src/application/CapabilityInventory.ts | 8 +++ src/gdb/GdbSessionManager.ts | 3 +- src/process/PersistentDebuggerHost.ts | 4 ++ src/rizin/RizinDebugSessionManager.ts | 3 +- src/server/createServer.ts | 2 + .../mcp/debuggerHostAvailability.test.ts | 64 +++++++++++++++++++ 7 files changed, 109 insertions(+), 2 deletions(-) create mode 100644 src/process/PersistentDebuggerHost.ts create mode 100644 tests/boundary/mcp/debuggerHostAvailability.test.ts diff --git a/src/application/CapabilityInventory.test.ts b/src/application/CapabilityInventory.test.ts index 2817af7dc7..1af42fe6b5 100644 --- a/src/application/CapabilityInventory.test.ts +++ b/src/application/CapabilityInventory.test.ts @@ -1,3 +1,4 @@ +import { persistentDebuggerHostSupported } from "../process/PersistentDebuggerHost.js"; import { pcapCaptureHostSupported } from "../domain/pcap/pcapAnalysis.js"; import { describe, expect, it } from "vitest"; @@ -887,6 +888,32 @@ it("keeps independent native sessions discoverable without an unrelated binary t ); }); +it.each(["darwin", "linux", "win32"] as const)( + "reports the persistent debugger start host restriction on %s", + (platform) => { + const entries = buildCapabilityInventory(status(), { + ...enabledPolicy, + hostUnsupported: { + persistentDebugger: !persistentDebuggerHostSupported(platform), + }, + }); + for (const name of ["start_gdb_session", "start_rizin_debug_session"]) + expect(entries.find((entry) => entry.name === name)).toMatchObject( + platform === "linux" + ? { available: true, reason: "available" } + : { + available: false, + reason: "unsupported_host", + remediation: expect.stringContaining("Linux"), + }, + ); + for (const name of ["gdb_session_status", "close_rizin_debug_session"]) + expect(entries.find((entry) => entry.name === name)).toMatchObject({ + available: true, + }); + }, +); + describe("summary tool availability", () => { it("summarizes every tool's current availability without static catalog flags", () => { const full = buildCapabilityInventory(status(), enabledPolicy); diff --git a/src/application/CapabilityInventory.ts b/src/application/CapabilityInventory.ts index c7cbbcb7e9..649361ba62 100644 --- a/src/application/CapabilityInventory.ts +++ b/src/application/CapabilityInventory.ts @@ -71,6 +71,7 @@ export const HOST_RESTRICTED_FEATURES = [ "firmwareInspection", "firmwareExtraction", "javascriptRecovery", + "persistentDebugger", ] as const; export type HostRestrictedFeature = (typeof HOST_RESTRICTED_FEATURES)[number]; @@ -444,6 +445,13 @@ const workflowAvailabilityFor = ({ kind, policy, }: AvailabilityContext): Availability | null => { + if (name === "start_gdb_session" || name === "start_rizin_debug_session") + return hostRestrictedAvailability( + policy, + "persistentDebugger", + true, + "Run persistent debugger sessions on Linux with /proc descriptor readback.", + ); if ( name === "inspect_desktop_application" || name === "inspect_go_binary" || diff --git a/src/gdb/GdbSessionManager.ts b/src/gdb/GdbSessionManager.ts index e7b59e07d1..02be67de68 100644 --- a/src/gdb/GdbSessionManager.ts +++ b/src/gdb/GdbSessionManager.ts @@ -1,3 +1,4 @@ +import { persistentDebuggerHostSupported } from "../process/PersistentDebuggerHost.js"; import { ABORTED, waitForAbortable } from "../process/AbortablePromise.js"; import { snapshotGdbCommandRecords } from "./GdbCommandRecords.js"; import { randomUUID } from "node:crypto"; @@ -639,7 +640,7 @@ export class GdbSessionManager { ? cancelledWithCallerReason("start_gdb_session", signal) : new AnalysisCancelledError("start_gdb_session"), ); - if (this.#platform !== "linux") + if (!persistentDebuggerHostSupported(this.#platform)) return err( new AnalysisCapabilityUnavailableError( "gdb", diff --git a/src/process/PersistentDebuggerHost.ts b/src/process/PersistentDebuggerHost.ts new file mode 100644 index 0000000000..eb217dde68 --- /dev/null +++ b/src/process/PersistentDebuggerHost.ts @@ -0,0 +1,4 @@ +/** Both persistent debugger managers require Linux procfs descriptor readback. */ +export const persistentDebuggerHostSupported = ( + platform: NodeJS.Platform, +): boolean => platform === "linux"; diff --git a/src/rizin/RizinDebugSessionManager.ts b/src/rizin/RizinDebugSessionManager.ts index e26454afeb..cb3fc70941 100644 --- a/src/rizin/RizinDebugSessionManager.ts +++ b/src/rizin/RizinDebugSessionManager.ts @@ -1,3 +1,4 @@ +import { persistentDebuggerHostSupported } from "../process/PersistentDebuggerHost.js"; import { randomUUID } from "node:crypto"; import { isUtf8 } from "node:buffer"; import { open, readlink, realpath, stat } from "node:fs/promises"; @@ -361,7 +362,7 @@ export class RizinDebugSessionManager { > { if (signal?.aborted) return err(rizinCancellation("start_rizin_debug_session", signal)); - if (this.#platform !== "linux") + if (!persistentDebuggerHostSupported(this.#platform)) return err( new AnalysisCapabilityUnavailableError( "rizin", diff --git a/src/server/createServer.ts b/src/server/createServer.ts index 1c521b91ad..88095ecc63 100644 --- a/src/server/createServer.ts +++ b/src/server/createServer.ts @@ -205,6 +205,7 @@ import { CutterBridgeClient } from "../cutter/CutterBridgeClient.js"; import { CutterBridgeService } from "../cutter/CutterBridgeService.js"; import { registerCutterTools } from "./registerCutterTools.js"; import { GdbSessionManager } from "../gdb/GdbSessionManager.js"; +import { persistentDebuggerHostSupported } from "../process/PersistentDebuggerHost.js"; import { registerGdbTools } from "./registerGdbTools.js"; import { RizinDebugSessionManager } from "../rizin/RizinDebugSessionManager.js"; import { registerRizinDebugTools } from "./registerRizinDebugTools.js"; @@ -299,6 +300,7 @@ const installSessionToolAvailability = ( const basePolicy = sessionAvailabilityPolicy(options.availabilityPolicy, { optionalProviderLoadFailures: options.optionalProviderLoadFailures, hostUnsupported: { + persistentDebugger: !persistentDebuggerHostSupported(platform), pcapCapture: outsideVerifiedHost( options.pcapAnalysis, verifiedHost.pcapCapture, diff --git a/tests/boundary/mcp/debuggerHostAvailability.test.ts b/tests/boundary/mcp/debuggerHostAvailability.test.ts new file mode 100644 index 0000000000..9717019a87 --- /dev/null +++ b/tests/boundary/mcp/debuggerHostAvailability.test.ts @@ -0,0 +1,64 @@ +import { expect, it } from "vitest"; + +import { expandToolAvailability } from "../../../src/contracts/toolOutputSchemaPrimitives.js"; +import { sessionOutputSchemas } from "../../../src/contracts/toolOutputSchemaGroups.js"; +import { createApplicationMcpHarness } from "../../fixtures/applicationMcpHarness.js"; +import { createTestBinarySession } from "../../fixtures/binarySession.js"; +import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; + +it("reports debugger start host restrictions without requiring an analysis target", async () => { + const session = createTestBinarySession(() => { + throw new Error("discovery must not start binary analysis"); + }); + const { client } = await createApplicationMcpHarness({ session }); + const entries = async (detail: "summary" | "complete") => { + const response = await client.callTool({ + name: "binary_session", + arguments: { detail }, + }); + expect(response.isError).not.toBe(true); + const parsed = sessionOutputSchemas.binary_session.parse( + response.structuredContent, + ).result; + expect(parsed.open).toBe(false); + return expandToolAvailability(parsed.tool_availability); + }; + for (const detail of ["summary", "complete"] as const) { + const tools = await entries(detail); + for (const name of ["start_gdb_session", "start_rizin_debug_session"]) { + expect(tools.find((tool) => tool.name === name)).toMatchObject( + process.platform === "linux" + ? { available: true, reason: "available" } + : { + available: false, + reason: "unsupported_host", + remediation: expect.stringContaining("Linux"), + }, + ); + } + expect( + tools.find((tool) => tool.name === "gdb_session_status"), + ).toMatchObject({ available: true }); + } + if (process.platform !== "linux") { + for (const [name, args] of [ + ["start_gdb_session", {}], + [ + "start_rizin_debug_session", + { path: "./absent.bin", inferior_tty: "/dev/null" }, + ], + ] as const) { + const response = await client.callTool({ name, arguments: args }); + expect(response.isError).toBe(true); + expect(parseMcpToolError(response)).toMatchObject({ + error: { code: "capability_unavailable" }, + }); + } + const after = await entries("complete"); + for (const name of ["start_gdb_session", "start_rizin_debug_session"]) + expect(after.find((tool) => tool.name === name)).toMatchObject({ + available: false, + reason: "unsupported_host", + }); + } +});