diff --git a/src/application/CapabilityInventory.test.ts b/src/application/CapabilityInventory.test.ts index 4286ff1d0..94b9ac659 100644 --- a/src/application/CapabilityInventory.test.ts +++ b/src/application/CapabilityInventory.test.ts @@ -498,6 +498,106 @@ it("reports host denial before target mismatch but keeps composed workflow prere ).toBe("target_required"); }); +describe("binary_overview capability requirements", () => { + it("admits a provider with current_document and other mandatory capabilities", () => { + const capabilities = [ + "list_segments", + "current_document", + "list_procedures", + "list_strings", + ].map((operation) => ({ operation, available: true, reason: null })); + expect( + entry( + "binary_overview", + status({ open: true, kind: "executable", capabilities }), + ), + ).toMatchObject({ + available: true, + reason: "available", + remediation: null, + }); + }); + + it("admits a provider with list_documents and other mandatory capabilities", () => { + const capabilities = [ + "list_segments", + "list_documents", + "list_procedures", + "list_strings", + ].map((operation) => ({ operation, available: true, reason: null })); + expect( + entry( + "binary_overview", + status({ open: true, kind: "executable", capabilities }), + ), + ).toMatchObject({ + available: true, + reason: "available", + remediation: null, + }); + }); + + it("reports document selection remediation when neither current_document nor list_documents is declared", () => { + const capabilities = [ + "list_segments", + "list_procedures", + "list_strings", + ].map((operation) => ({ operation, available: true, reason: null })); + expect( + entry( + "binary_overview", + status({ open: true, kind: "executable", capabilities }), + ), + ).toMatchObject({ + available: false, + reason: "provider_missing", + remediation: + "Configure a provider for one of: current_document, list_documents.", + }); + }); + + it("reports missing segment enumeration independently when segments are missing", () => { + const capabilities = [ + "current_document", + "list_procedures", + "list_strings", + ].map((operation) => ({ operation, available: true, reason: null })); + expect( + entry( + "binary_overview", + status({ open: true, kind: "executable", capabilities }), + ), + ).toMatchObject({ + available: false, + reason: "provider_missing", + remediation: "Configure a provider for required operation list_segments.", + }); + }); + + it("reports provider_unavailable when declared document selection capability is unavailable", () => { + const capabilities = [ + { operation: "list_segments", available: true, reason: null }, + { + operation: "current_document", + available: false, + reason: "Document view is closed", + }, + { operation: "list_procedures", available: true, reason: null }, + { operation: "list_strings", available: true, reason: null }, + ]; + expect( + entry( + "binary_overview", + status({ open: true, kind: "executable", capabilities }), + ), + ).toMatchObject({ + available: false, + reason: "provider_unavailable", + remediation: "Document view is closed", + }); + }); +}); + describe("trace_feature requirements", () => { const capabilities = (operations: readonly string[]) => operations.map((operation) => ({ diff --git a/src/application/CapabilityInventory.ts b/src/application/CapabilityInventory.ts index 925ed9ee3..87734fbd1 100644 --- a/src/application/CapabilityInventory.ts +++ b/src/application/CapabilityInventory.ts @@ -98,7 +98,9 @@ type AvailabilityContext = { readonly policy: AvailabilityPolicy; }; -const ENHANCED_REQUIREMENTS: Readonly> = { +const ENHANCED_REQUIREMENTS: Readonly< + Record +> = { trace_native_values: ["analyze_function", "resolve_native_call_targets"], trace_native_ui_action: [ "decode_interface_builder", @@ -114,7 +116,7 @@ const ENHANCED_REQUIREMENTS: Readonly> = { find_xrefs_to_name: ["list_names", "xrefs"], binary_overview: [ "list_segments", - "list_documents", + ["current_document", "list_documents"], "list_procedures", "list_strings", ], @@ -642,27 +644,62 @@ const composedAvailability = ( }; const composedAvailabilityFor = ( - requirements: readonly string[], + requirements: readonly (string | readonly string[])[], descriptors: ReadonlyMap, ): Availability => { - const missing = requirements.find((operation) => !descriptors.has(operation)); - if (missing !== undefined) - return { - reason: "provider_missing", - remediation: `Configure a provider for required operation ${missing}.`, - }; - const unavailable = requirements - .map((operation) => descriptors.get(operation)) - .find((descriptor) => descriptor?.available === false); - if (unavailable !== undefined) - return { - reason: - unavailable.availability_code === "unsupported_host" - ? "unsupported_host" - : "provider_unavailable", - remediation: - unavailable.reason ?? "Choose another target or configured provider.", - }; + for (const requirement of requirements) { + if (typeof requirement === "string") { + if (!descriptors.has(requirement)) + return { + reason: "provider_missing", + remediation: `Configure a provider for required operation ${requirement}.`, + }; + } else { + const declared = requirement.some((operation) => + descriptors.has(operation), + ); + if (!declared) + return { + reason: "provider_missing", + remediation: `Configure a provider for one of: ${requirement.join(", ")}.`, + }; + } + } + + for (const requirement of requirements) { + if (typeof requirement === "string") { + const descriptor = descriptors.get(requirement); + if (descriptor?.available === false) + return { + reason: + descriptor.availability_code === "unsupported_host" + ? "unsupported_host" + : "provider_unavailable", + remediation: + descriptor.reason ?? + "Choose another target or configured provider.", + }; + } else { + const anyAvailable = requirement.some( + (operation) => descriptors.get(operation)?.available === true, + ); + if (!anyAvailable) { + const unavailable = requirement + .map((operation) => descriptors.get(operation)) + .find((descriptor) => descriptor?.available === false); + if (unavailable !== undefined) + return { + reason: + unavailable.availability_code === "unsupported_host" + ? "unsupported_host" + : "provider_unavailable", + remediation: + unavailable.reason ?? + "Choose another target or configured provider.", + }; + } + } + } return { reason: "available", remediation: null }; }; diff --git a/tests/boundary/mcp/enhancedTools.04-uses-complete-inventories-inline.test.ts b/tests/boundary/mcp/enhancedTools.04-uses-complete-inventories-inline.test.ts index e452d70c3..a9b6f1119 100644 --- a/tests/boundary/mcp/enhancedTools.04-uses-complete-inventories-inline.test.ts +++ b/tests/boundary/mcp/enhancedTools.04-uses-complete-inventories-inline.test.ts @@ -313,4 +313,40 @@ describe("overview document identity through MCP", () => { .isError, ).toBe(true); }); + + it("reports document selection unavailable when neither current-document nor list-documents is supported", async () => { + const client = await connect({ + execute: (name) => { + if (name === "current_document" || name === "list_documents") + return Promise.resolve( + err( + new AnalysisCapabilityUnavailableError( + "fixture", + name, + "provider lacks document selection", + ), + ), + ); + if (name === "list_segments") return Promise.resolve(ok([])); + if (name === "list_procedures" || name === "list_strings") + return Promise.resolve(ok([])); + throw new Error(`Unexpected analysis operation: ${name}`); + }, + }); + const result = await client.callTool({ + name: "binary_overview", + arguments: {}, + }); + expect(result.isError).toBe(true); + expect(parseMcpToolError(result).error).toMatchObject({ + code: "capability_unavailable", + details: { + provider_id: "fixture", + operation: "list_documents", + reason: expect.stringContaining( + "provider lacks document selection", + ), + }, + }); + }); }); diff --git a/tests/composition/idaProvider.test.ts b/tests/composition/idaProvider.test.ts index 1fd17171d..538c9d30e 100644 --- a/tests/composition/idaProvider.test.ts +++ b/tests/composition/idaProvider.test.ts @@ -14,6 +14,7 @@ import { import { createEvidence } from "../../src/domain/evidence.js"; import { createEvidenceBundle } from "../../src/domain/evidenceBundle.js"; import { createAnalysisExecution } from "../../src/application/AnalysisProvider.js"; +import { EnhancedTools } from "../../src/application/EnhancedTools.js"; import { IDA_PROVIDER_IDENTITY } from "../../src/ida/IdaProviderCapabilities.js"; const roots: string[] = []; @@ -183,3 +184,35 @@ describe("IDA provider composition", () => { expect(producer.closes).toBe(1); }); }); + +describe("IDA binary_overview execution", () => { + it("reports document selection unavailable when binary_overview executes against an IDA session", async () => { + const { root, target } = await createIdaTarget(); + roots.push(root); + const path = join(root, "registration.json"); + await writeFile(path, JSON.stringify({ command: "fixture" })); + const config = parseConfig({ REA_IDA_MCP_CONFIG: path }); + if (!config.ok) throw config.error; + const producer = new RecordingIdaMcp(target); + const session = createTestBinarySession( + new IdaProvider(config.value, {}, () => producer), + ); + expect((await session.open(target.path)).ok).toBe(true); + const tools = new EnhancedTools(session); + const overview = await tools.execute("binary_overview", {}); + expect(overview).toMatchObject({ + ok: false, + error: { + _tag: "AnalysisCapabilityUnavailableError", + providerId: "ida", + operation: "list_documents", + message: expect.stringContaining( + "operation is not declared by this provider", + ), + }, + }); + const procedures = await session.execute("list_procedures", {}); + expect(procedures.ok).toBe(true); + expect((await session.close()).ok).toBe(true); + }); +});