From ae029fe2f80b171a73df52e57b4a355f32f7c456 Mon Sep 17 00:00:00 2001 From: Ysopwnable <161784452+N0zoM1z0@users.noreply.github.com> Date: Sat, 17 Oct 2026 18:44:24 +0800 Subject: [PATCH] perf(ghidra): aggregate binary overview at the provider boundary (#4754) Compute overview counts at the Ghidra inventory owner, preserve complete inventory access and coverage, and share CLI/MCP projection. Keep native aggregate Evidence separate from workflow snapshot identities. Fixes #4746 Co-authored-by: gensokyo-dev <342580529+gensokyo-dev@users.noreply.github.com> Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> --- bridge/ghidra/ReaGhidraBridge.java | 18 ++++++ docs/ghidra-provider.md | 8 +++ scripts/verify-packaged-ghidra-windows.mjs | 19 +++--- .../verify-real-ghidra-address-context.mjs | 14 +++++ scripts/verify-real-ghidra-windows.mjs | 10 +++ src/application/AnalysisProvider.ts | 3 + src/application/BinaryOverview.test.ts | 61 +++++++++++++++++++ src/application/CapabilityInventory.test.ts | 18 ++++++ src/application/CapabilityInventory.ts | 5 ++ src/application/EnhancedTools.ts | 49 ++++++--------- src/domain/hopperValues.ts | 33 ++++++++++ src/ghidra/GhidraInventoryValues.test.ts | 51 +++++++++++++++- src/ghidra/GhidraInventoryValues.ts | 11 ++++ src/ghidra/GhidraProviderCapabilities.ts | 22 ++++++- src/ghidra/GhidraSnapshotCache.test.ts | 28 ++++++++- .../directAnalysisSnapshotReplay.test.ts | 14 +++++ ....01-lists-the-complete-target-open.test.ts | 10 +++ ...4-uses-complete-inventories-inline.test.ts | 19 +++++- .../mcp/enhancedTools.limitations.test.ts | 11 +++- tests/boundary/mcp/ghidraEvidenceMcp.test.ts | 4 ++ tests/boundary/mcp/ghidraMcpHarness.ts | 9 +++ .../mcp/nativeAddressContextMcp.test.ts | 12 ++++ .../providers/ghidra/ghidraClient.test.ts | 21 +++++-- tests/fixtures/fakeGhidra.mjs | 8 +++ 24 files changed, 406 insertions(+), 52 deletions(-) create mode 100644 src/application/BinaryOverview.test.ts diff --git a/bridge/ghidra/ReaGhidraBridge.java b/bridge/ghidra/ReaGhidraBridge.java index c9057fc6e..2438471c6 100644 --- a/bridge/ghidra/ReaGhidraBridge.java +++ b/bridge/ghidra/ReaGhidraBridge.java @@ -140,6 +140,7 @@ public final class ReaGhidraBridge extends HeadlessScript { "params" ); private static final String[] CAPABILITIES = { + "read_binary_inventory_summary", "annotate_native_function", "set_address_name", "set_addresses_names", @@ -427,6 +428,7 @@ public final class ReaGhidraBridge extends HeadlessScript { ); } return switch (request.method) { + case "read_binary_inventory_summary" -> binaryInventorySummary(request.params); case "inspect_native_load_image" -> inspectNativeLoadImage(request.params); case "read_bytes" -> readMemoryBytes(request.params); case "search_bytes" -> searchMemoryBytes(request.params, descriptor); @@ -1974,6 +1976,18 @@ public final class ReaGhidraBridge extends HeadlessScript { return result; } + private JsonObject binaryInventorySummary(JsonObject params) throws Exception { + requireKeys(params, Set.of()); + JsonObject result = new JsonObject(); + result.addProperty("document", currentProgram.getName()); + result.add("segments", segments()); + // Count the owning inventories, preserving their exact discovery rules + // without serializing function identities or string values for counts. + result.addProperty("procedure_count", procedures().size()); + result.addProperty("string_count", strings().size()); + return result; + } + private JsonArray listNames(JsonObject params) throws Exception { requireKeys(params, Set.of("document", "address")); requireDocument(params); @@ -2000,6 +2014,10 @@ public final class ReaGhidraBridge extends HeadlessScript { private JsonArray listSegments(JsonObject params) { requireKeys(params, Set.of("document")); requireDocument(params); + return segments(); + } + + private JsonArray segments() { JsonArray result = new JsonArray(); String imageBase = canonicalAddress(currentProgram.getImageBase()); MemoryBlock[] blocks = currentProgram.getMemory().getBlocks(); diff --git a/docs/ghidra-provider.md b/docs/ghidra-provider.md index 4d1090ea0..febb26465 100644 --- a/docs/ghidra-provider.md +++ b/docs/ghidra-provider.md @@ -65,6 +65,14 @@ PDB, or change analyzer settings. ## Loaded addresses and file offsets +Ghidra computes overview procedure and string counts from the same inventories +used by `list_procedures` and `list_strings`, without transporting those rows +solely to count them. Counts include external functions and only Ghidra-defined +strings; they do not establish discovery of unidentified functions or undefined +string bytes. Complete inventories remain available through the list tools. +Overview reuse follows the target/profile snapshot lifetime and is invalidated +by session metadata edits. + `rea analyze --provider ghidra --json` and MCP `binary_overview` report the observed loaded `image_base` in `normalized_result`. They retain each segment's reported `image_base` and `address_space`. A provider that does not diff --git a/scripts/verify-packaged-ghidra-windows.mjs b/scripts/verify-packaged-ghidra-windows.mjs index ed4ce5fe4..68f774602 100644 --- a/scripts/verify-packaged-ghidra-windows.mjs +++ b/scripts/verify-packaged-ghidra-windows.mjs @@ -442,15 +442,16 @@ try { return evidence.normalized_result; }, ); - const { GHIDRA_MUTATING_OPERATIONS, GHIDRA_SESSION_CAPABILITIES } = - await import( - pathToFileURL(join(packageRoot, "dist/ghidra/GhidraSessionValues.js")) - ); - const expected = GHIDRA_SESSION_CAPABILITIES.filter( - (name) => - name !== "ping" && - name !== "shutdown" && - !GHIDRA_MUTATING_OPERATIONS.has(name), + const { GHIDRA_MUTATING_OPERATIONS } = await import( + pathToFileURL(join(packageRoot, "dist/ghidra/GhidraSessionValues.js")) + ); + const { GHIDRA_OPERATIONS } = await import( + pathToFileURL( + join(packageRoot, "dist/ghidra/GhidraProviderCapabilities.js"), + ) + ); + const expected = GHIDRA_OPERATIONS.filter( + (name) => !GHIDRA_MUTATING_OPERATIONS.has(name), ); const expectedOperations = new Set([ ...expected, diff --git a/scripts/verify-real-ghidra-address-context.mjs b/scripts/verify-real-ghidra-address-context.mjs index 2b650df93..432680b29 100644 --- a/scripts/verify-real-ghidra-address-context.mjs +++ b/scripts/verify-real-ghidra-address-context.mjs @@ -90,6 +90,20 @@ try { ), ); const procedures = parseEvidence(await call("list_procedures")); + const strings = parseEvidence(await call("list_strings")); + assert.equal( + overview.normalized_result.procedure_count, + procedures.normalized_result.length, + ); + assert.equal( + overview.normalized_result.string_count, + strings.normalized_result.length, + ); + assert.ok( + overview.limitations.some((text) => + text.includes("Ghidra-defined string Data"), + ), + ); const entry = procedures.normalized_result.find( (item) => item.value === "rea_pie_entry", ); diff --git a/scripts/verify-real-ghidra-windows.mjs b/scripts/verify-real-ghidra-windows.mjs index a69cb502a..f34f32bfa 100644 --- a/scripts/verify-real-ghidra-windows.mjs +++ b/scripts/verify-real-ghidra-windows.mjs @@ -166,6 +166,16 @@ try { !Array.isArray(strings) ) throw new Error("Windows Ghidra inventory proof was incomplete"); + const overview = await inventory("read_binary_inventory_summary", {}); + if ( + overview.document !== documents[0] || + overview.procedure_count !== procedures.length || + overview.string_count !== strings.length || + overview.segment_count !== segments.length + ) + throw new Error( + "Windows Ghidra overview aggregates disagree with inventories", + ); const procedure = procedures.find( (item) => item.procedure?.external === false, ); diff --git a/src/application/AnalysisProvider.ts b/src/application/AnalysisProvider.ts index 7157e7a24..7d316cb79 100644 --- a/src/application/AnalysisProvider.ts +++ b/src/application/AnalysisProvider.ts @@ -40,6 +40,9 @@ export type AnalysisOperation = | NativeToolName | ArtifactAnalysisOperation | ManagedToolName + // Provider aggregate and composed public overview have distinct Evidence and + // snapshot identities. This capability does not add a CLI/MCP tool. + | "read_binary_inventory_summary" | "health"; /** Atomic successful provider observation with exact producing provenance. */ diff --git a/src/application/BinaryOverview.test.ts b/src/application/BinaryOverview.test.ts new file mode 100644 index 000000000..6c38350b3 --- /dev/null +++ b/src/application/BinaryOverview.test.ts @@ -0,0 +1,61 @@ +import { expect, it } from "vitest"; +import { EnhancedTools } from "./EnhancedTools.js"; +import { createAnalysisExecution } from "./AnalysisProvider.js"; +import { + AnalysisCancelledError, + AnalysisCapabilityUnavailableError, + AnalysisOutputError, +} from "../domain/analysisErrorCore.js"; +import { err, ok } from "../domain/result.js"; + +it("uses the provider-owned overview without requesting complete inventories", async () => { + const calls: string[] = []; + const result = { + document: "selected", + procedure_count: 12, + string_count: 34, + }; + const tools = new EnhancedTools({ + execute: async (operation) => { + calls.push(operation); + return ok( + createAnalysisExecution(result, { + id: "fixture", + name: "Fixture", + version: "1", + }), + ); + }, + }); + expect(await tools.execute("binary_overview", {})).toEqual(ok(result)); + expect(calls).toEqual(["read_binary_inventory_summary"]); +}); + +it.each([ + new AnalysisOutputError("binary_overview", "Malformed aggregate"), + new AnalysisCancelledError("binary_overview"), + new AnalysisCapabilityUnavailableError( + "fixture", + "binary_overview", + "Cleanup pending", + { + cleanup: { + reason: "Owned process is still closing", + resources: ["session"], + }, + }, + ), +])( + "preserves an aggregate failure rather than silently repeating analysis: %s", + async (failure) => { + const calls: string[] = []; + const tools = new EnhancedTools({ + execute: async (operation) => { + calls.push(operation); + return err(failure); + }, + }); + expect(await tools.execute("binary_overview", {})).toEqual(err(failure)); + expect(calls).toEqual(["read_binary_inventory_summary"]); + }, +); diff --git a/src/application/CapabilityInventory.test.ts b/src/application/CapabilityInventory.test.ts index 7fcd237bb..231340a46 100644 --- a/src/application/CapabilityInventory.test.ts +++ b/src/application/CapabilityInventory.test.ts @@ -561,6 +561,24 @@ it("reports host denial before target mismatch but keeps composed workflow prere }); describe("binary_overview capability requirements", () => { + it("admits a native overview without requiring full inventory operations", () => { + expect( + entry( + "binary_overview", + status({ + open: true, + kind: "executable", + capabilities: [ + { + operation: "read_binary_inventory_summary", + available: true, + reason: null, + }, + ], + }), + ), + ).toMatchObject({ available: true, reason: "available" }); + }); it("admits a provider with current_document and other mandatory capabilities", () => { const capabilities = [ "current_document", diff --git a/src/application/CapabilityInventory.ts b/src/application/CapabilityInventory.ts index f1748e5a6..ca7088f74 100644 --- a/src/application/CapabilityInventory.ts +++ b/src/application/CapabilityInventory.ts @@ -679,6 +679,11 @@ const composedAvailability = ( name: string, descriptors: ReadonlyMap, ): Availability => { + if ( + name === "binary_overview" && + descriptors.get("read_binary_inventory_summary")?.available === true + ) + return { reason: "available", remediation: null }; const requirements = ENHANCED_REQUIREMENTS[name]; if (requirements === undefined) return { diff --git a/src/application/EnhancedTools.ts b/src/application/EnhancedTools.ts index 71d1637d4..8ff202f03 100644 --- a/src/application/EnhancedTools.ts +++ b/src/application/EnhancedTools.ts @@ -16,13 +16,12 @@ import { import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; import type { AnalysisError } from "../domain/analysisErrorBase.js"; import { - addressDistance, functionDossierSchema, parseFunctionDossier, parseListCount, parseRelatedAddresses, parseSegments, - parseSegmentImageBase, + projectBinaryOverview, } from "../domain/hopperValues.js"; import { err, ok, type Result } from "../domain/result.js"; import { @@ -617,6 +616,17 @@ export class EnhancedTools { } async #binaryOverview(signal?: AbortSignal): EnhancedResult { + const direct = await this.#call( + "read_binary_inventory_summary", + {}, + signal, + ); + if ( + direct.ok || + !(direct.error instanceof AnalysisCapabilityUnavailableError) || + direct.error.cleanupIncomplete + ) + return direct; const document = await resolveAnalysisDocument( this.analysis, undefined, @@ -647,33 +657,14 @@ export class EnhancedTools { const stringCount = parseListCount(stringsResult.value, "strings"); if (!stringCount.ok) return stringCount; - const observedSegments = segments?.ok ? segments.value : undefined; - const imageBase = - observedSegments === undefined - ? undefined - : parseSegmentImageBase(observedSegments); - - return ok({ - document: document.value, - ...(imageBase === undefined ? {} : { image_base: imageBase }), - ...(observedSegments === undefined - ? {} - : { - segments: observedSegments.map( - ({ name, start, end, image_base, address_space }) => ({ - name, - start, - end, - length: addressDistance(start, end), - ...(image_base === undefined ? {} : { image_base }), - ...(address_space === undefined ? {} : { address_space }), - }), - ), - segment_count: observedSegments.length, - }), - procedure_count: procedureCount.value, - string_count: stringCount.value, - }); + return ok( + projectBinaryOverview({ + document: document.value, + ...(segments?.ok ? { segments: segments.value } : {}), + procedure_count: procedureCount.value, + string_count: stringCount.value, + }), + ); } async #allAddressed( diff --git a/src/domain/hopperValues.ts b/src/domain/hopperValues.ts index 0f6c42ef4..4fbdb72db 100644 --- a/src/domain/hopperValues.ts +++ b/src/domain/hopperValues.ts @@ -527,6 +527,39 @@ export const parseSegmentImageBase = ( : undefined; }; +/** Project the same overview from complete lists or provider-owned aggregates. */ +export const projectBinaryOverview = (input: { + readonly document: string; + readonly procedure_count: number; + readonly string_count: number; + readonly segments?: readonly SegmentSummary[]; +}): JsonValue => { + const { document, procedure_count, string_count, segments } = input; + const imageBase = + segments === undefined ? undefined : parseSegmentImageBase(segments); + return { + document, + ...(imageBase === undefined ? {} : { image_base: imageBase }), + ...(segments === undefined + ? {} + : { + segments: segments.map( + ({ name, start, end, image_base, address_space }) => ({ + name, + start, + end, + length: addressDistance(start, end), + ...(image_base === undefined ? {} : { image_base }), + ...(address_space === undefined ? {} : { address_space }), + }), + ), + segment_count: segments.length, + }), + procedure_count, + string_count, + }; +}; + /** Parse direct or wrapped provider document names. */ export const parseDocuments = ( value: JsonValue, diff --git a/src/ghidra/GhidraInventoryValues.test.ts b/src/ghidra/GhidraInventoryValues.test.ts index 4d441ec39..42d0c262d 100644 --- a/src/ghidra/GhidraInventoryValues.test.ts +++ b/src/ghidra/GhidraInventoryValues.test.ts @@ -1,6 +1,55 @@ import { describe, expect, it } from "vitest"; -import { parseGhidraInventoryResult } from "./GhidraInventoryValues.js"; +import { + parseGhidraInventoryInput, + parseGhidraInventoryResult, +} from "./GhidraInventoryValues.js"; + +describe("provider-owned overview boundary", () => { + const overview = { + document: "fixture", + segments: [], + procedure_count: 0, + string_count: 0, + }; + + it("keeps exact empty counts and an observed empty segment set", () => { + expect( + parseGhidraInventoryInput("read_binary_inventory_summary", {}), + ).toEqual({ + ok: true, + value: {}, + }); + expect( + parseGhidraInventoryInput("read_binary_inventory_summary", { + document: "other", + }).ok, + ).toBe(false); + expect( + parseGhidraInventoryResult("read_binary_inventory_summary", overview), + ).toEqual({ + ok: true, + value: { ...overview, segment_count: 0 }, + }); + }); + + it.each([ + { procedure_count: -1 }, + { string_count: 1.5 }, + { procedure_count: Number.MAX_SAFE_INTEGER + 1 }, + { string_count: null }, + { document: "" }, + { segments: [{ name: "incomplete" }] }, + { extra: "unexpected" }, + ])("rejects malformed aggregates before creating Evidence: %j", (change) => { + expect( + parseGhidraInventoryResult("read_binary_inventory_summary", { + ...overview, + ...change, + }).ok, + ).toBe(false); + }); +}); // Wire-boundary fixture only; real-provider acceptance belongs to the DOS verifier. describe("containing-procedure complete body evidence", () => { diff --git a/src/ghidra/GhidraInventoryValues.ts b/src/ghidra/GhidraInventoryValues.ts index 85a8b8fbe..f4faa1a12 100644 --- a/src/ghidra/GhidraInventoryValues.ts +++ b/src/ghidra/GhidraInventoryValues.ts @@ -13,6 +13,7 @@ import { import { functionBodySchema, functionBodyEntryAgrees, + projectBinaryOverview, } from "../domain/hopperValues.js"; import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js"; import { err, ok, type Result } from "../domain/result.js"; @@ -23,6 +24,7 @@ import { analysisInputErrorFromIssues } from "../domain/inputIssueProjection.js" * except the naming operations, which edit the ephemeral session database. */ export const GHIDRA_INVENTORY_OPERATIONS = [ + "read_binary_inventory_summary", "inspect_native_load_image", "read_bytes", "search_bytes", @@ -78,6 +80,7 @@ const searchInput = { }; const inputSchemas = { + read_binary_inventory_summary: z.strictObject({}), search_bytes: z .strictObject({ ...bytePatternFields, @@ -287,6 +290,14 @@ const containingProcedure = z.discriminatedUnion("found", [ ]); const resultSchemas = { + read_binary_inventory_summary: z + .strictObject({ + document: identifier, + segments: z.array(segment), + procedure_count: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER), + string_count: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER), + }) + .transform(projectBinaryOverview), search_bytes: byteSearchResultSchema.superRefine((value, context) => { const addresses = [ ...value.matches.flatMap((match) => [ diff --git a/src/ghidra/GhidraProviderCapabilities.ts b/src/ghidra/GhidraProviderCapabilities.ts index 33a876c78..cc21d1396 100644 --- a/src/ghidra/GhidraProviderCapabilities.ts +++ b/src/ghidra/GhidraProviderCapabilities.ts @@ -21,12 +21,21 @@ export const GHIDRA_PROVIDER_IDENTITY: ProviderIdentity = Object.freeze({ version: null, }); -/** Analyst operations implemented by the Ghidra inventory and function adapters. */ -export const GHIDRA_OPERATIONS = Object.freeze([ +/** Provider observations, including internal aggregates consumed by workflows. */ +const providerOperations = Object.freeze([ ...GHIDRA_INVENTORY_OPERATIONS, ...GHIDRA_FUNCTION_OPERATIONS, ]); +/** Public analyst tools backed by these observations; aggregates stay internal. */ +export const GHIDRA_OPERATIONS = Object.freeze( + providerOperations.map((operation) => + operation === "read_binary_inventory_summary" + ? "binary_overview" + : operation, + ), +); + /** Health limitations shared by every Ghidra-backed capability. */ export const healthLimitations = Object.freeze([ "The session serves operations only after Ghidra reports default auto-analysis complete; incomplete analysis does not expose partial results.", @@ -54,6 +63,13 @@ export const limitationsFor = (operation: string): readonly string[] => { "Default-space addresses use lowercase 0x-prefixed hexadecimal; other address spaces use :0x.", ]; switch (operation) { + case "read_binary_inventory_summary": + return [ + ...common, + stringInventoryLimitation, + "Counts use the complete defined-string inventory and the same local/external function inventory as list_strings and list_procedures. They do not establish discovery of undefined strings or unidentified functions.", + "Memory-block end addresses are exclusive; segment lengths use those observed address coordinates.", + ]; case "annotate_native_function": return [ ...common, @@ -174,7 +190,7 @@ export const limitationsFor = (operation: string): readonly string[] => { /** Provider-neutral capabilities advertised by every non-Windows Ghidra session. */ export const CAPABILITIES: readonly CapabilityDescriptor[] = Object.freeze( - GHIDRA_OPERATIONS.map((operation) => { + providerOperations.map((operation) => { return Object.freeze({ provider: GHIDRA_PROVIDER_IDENTITY, operation, diff --git a/src/ghidra/GhidraSnapshotCache.test.ts b/src/ghidra/GhidraSnapshotCache.test.ts index 08957b4d6..0f0159385 100644 --- a/src/ghidra/GhidraSnapshotCache.test.ts +++ b/src/ghidra/GhidraSnapshotCache.test.ts @@ -94,6 +94,13 @@ const fixture = (version = "12.1.4", coverage?: RelationshipCoverage) => { throw new Error("Expected an annotation name"); name = parameters.name; } + if (operation === "read_binary_inventory_summary") + return ok({ + document: "fixture", + segments: [], + procedure_count: 0, + string_count: 0, + }); const dossier = functionDossierSchema.parse(ghidraFunctionDossier()); dossier.procedure.name = name; dossier.pseudocode = `void ${name}() {}`; @@ -267,6 +274,7 @@ describe("Ghidra snapshot profile admission", () => { describe("Ghidra snapshot cache", () => { it.each([ + { operation: "read_binary_inventory_summary", parameters: {} }, { operation: "analyze_function", parameters: { procedure: "0x1000" } }, { operation: "procedure_pseudo_code", @@ -327,6 +335,9 @@ describe("Ghidra snapshot cache", () => { it("keeps annotation-dependent reads live after cancellation reimports the original artifact", async () => { const { session, calls, cancellationReady } = fixture(); await open(session); + expect( + (await session.execute("read_binary_inventory_summary", {})).ok, + ).toBe(true); const parameters = { procedure: "0x1000", document: "fixture" }; expect( await session.execute("procedure_pseudo_code", parameters), @@ -342,13 +353,17 @@ describe("Ghidra snapshot cache", () => { }) ).ok, ).toBe(true); - for (let index = 0; index < 2; index += 1) + for (let index = 0; index < 2; index += 1) { + expect( + (await session.execute("read_binary_inventory_summary", {})).ok, + ).toBe(true); expect( await session.execute("procedure_pseudo_code", parameters), ).toMatchObject({ ok: true, value: { result: "void renamed() {}" }, }); + } expect(session.exportAnalysisSnapshot().ok).toBe(false); const controller = new AbortController(); const cancelled = session.execute( @@ -362,13 +377,22 @@ describe("Ghidra snapshot cache", () => { ok: false, error: { _tag: "AnalysisCancelledError" }, }); - for (let index = 0; index < 2; index += 1) + for (let index = 0; index < 2; index += 1) { + expect( + (await session.execute("read_binary_inventory_summary", {})).ok, + ).toBe(true); expect( await session.execute("procedure_pseudo_code", parameters), ).toMatchObject({ ok: true, value: { result: "void original() {}" }, }); + } + expect( + calls.filter( + (operation) => operation === "read_binary_inventory_summary", + ), + ).toHaveLength(5); expect( calls.filter((operation) => operation === "procedure_pseudo_code"), ).toHaveLength(5); diff --git a/tests/boundary/filesystem/directAnalysisSnapshotReplay.test.ts b/tests/boundary/filesystem/directAnalysisSnapshotReplay.test.ts index 75e4910fe..c856c757f 100644 --- a/tests/boundary/filesystem/directAnalysisSnapshotReplay.test.ts +++ b/tests/boundary/filesystem/directAnalysisSnapshotReplay.test.ts @@ -180,6 +180,20 @@ const makeProvider = ( trace.calls.push(operation); const results: Readonly> = { health: null, + read_binary_inventory_summary: { + document: "fixture", + procedure_count: 1, + string_count: 1, + segment_count: 1, + segments: [ + { + name: "__TEXT", + start: "0x1000", + end: "0x2000", + length: 4096, + }, + ], + }, list_segments: [ { name: "__TEXT", diff --git a/tests/boundary/mcp/enhancedTools.01-lists-the-complete-target-open.test.ts b/tests/boundary/mcp/enhancedTools.01-lists-the-complete-target-open.test.ts index 22ace43d7..7a14f1a1a 100644 --- a/tests/boundary/mcp/enhancedTools.01-lists-the-complete-target-open.test.ts +++ b/tests/boundary/mcp/enhancedTools.01-lists-the-complete-target-open.test.ts @@ -66,6 +66,16 @@ const fixturePort = (): AnalysisOperationPort => ({ // oxlint-disable-next-line complexity -- this fixture exhaustively serves the registered MCP surface. execute: (name, arguments_) => { switch (name) { + case "read_binary_inventory_summary": + return Promise.resolve( + err( + new AnalysisCapabilityUnavailableError( + "fixture", + name, + "This fixture exercises composed overview", + ), + ), + ); case "inspect_native_dispatch_metadata": return Promise.resolve( err( 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 ab4760ca3..7b51f9435 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 @@ -3,7 +3,7 @@ import { afterEach, describe, expect, it } from "vitest"; import { closeEnhancedToolResources, - connect, + connect as connectTool, jsonResult, } from "./enhancedToolsHarness.js"; import type { @@ -20,6 +20,23 @@ import { observed as ok } from "../../fixtures/analysisExecution.js"; afterEach(closeEnhancedToolResources); +// These fixtures exercise composition for providers without a native overview. +const connect = (analysis: AnalysisOperationPort) => + connectTool({ + execute: (operation, parameters, options) => + operation === "read_binary_inventory_summary" + ? Promise.resolve( + err( + new AnalysisCapabilityUnavailableError( + "fixture", + operation, + "No native overview", + ), + ), + ) + : analysis.execute(operation, parameters, options), + }); + it("discovers declarations without counting duplicates or compiler bookkeeping", async () => { const client = await connect({ execute: (name) => { diff --git a/tests/boundary/mcp/enhancedTools.limitations.test.ts b/tests/boundary/mcp/enhancedTools.limitations.test.ts index cc9a21c80..871e6f573 100644 --- a/tests/boundary/mcp/enhancedTools.limitations.test.ts +++ b/tests/boundary/mcp/enhancedTools.limitations.test.ts @@ -1,6 +1,7 @@ import { afterEach, expect, it } from "vitest"; import { createAnalysisExecution } from "../../../src/application/AnalysisProvider.js"; -import { ok } from "../../../src/domain/result.js"; +import { err, ok } from "../../../src/domain/result.js"; +import { AnalysisCapabilityUnavailableError } from "../../../src/domain/analysisErrorCore.js"; import { parseEvidence } from "../../../src/domain/evidence.js"; import { connect, closeEnhancedToolResources } from "./enhancedToolsHarness.js"; @@ -11,6 +12,14 @@ it("retains provider coverage caveats in a composed MCP overview without repeate const limitation = "Companion PDB symbol coverage is unverified."; const client = await connect({ execute: async (operation) => { + if (operation === "read_binary_inventory_summary") + return err( + new AnalysisCapabilityUnavailableError( + "fixture", + operation, + "No native overview", + ), + ); calls.push(operation); const value = operation === "current_document" ? "fixture" : []; return ok( diff --git a/tests/boundary/mcp/ghidraEvidenceMcp.test.ts b/tests/boundary/mcp/ghidraEvidenceMcp.test.ts index 2a7b54efc..bb8ed8ce7 100644 --- a/tests/boundary/mcp/ghidraEvidenceMcp.test.ts +++ b/tests/boundary/mcp/ghidraEvidenceMcp.test.ts @@ -413,6 +413,7 @@ describe("Ghidra MCP evidence parity", () => { ], }); + const beforeOverview = harness.calls.length; const mcpOverview = sessionEvidence( session, ( @@ -427,6 +428,9 @@ describe("Ghidra MCP evidence parity", () => { {}, ); if (!directOverview.ok) throw directOverview.error; + expect(harness.calls.slice(beforeOverview)).toEqual([ + "read_binary_inventory_summary", + ]); expect(mcpOverview.normalized_result).toEqual(directOverview.value); expect(mcpOverview).toMatchObject({ provider: { id: "rea-workflow" }, diff --git a/tests/boundary/mcp/ghidraMcpHarness.ts b/tests/boundary/mcp/ghidraMcpHarness.ts index 84b1a88e2..bdf9df226 100644 --- a/tests/boundary/mcp/ghidraMcpHarness.ts +++ b/tests/boundary/mcp/ghidraMcpHarness.ts @@ -144,6 +144,15 @@ type GhidraResultBuilder = ( ) => JsonValue; const resultBuilders = new Map([ + [ + "read_binary_inventory_summary", + () => ({ + document: "fixture", + segments: resultFor("list_segments", {}), + procedure_count: 1, + string_count: 2, + }), + ], ["list_documents", () => ["fixture"]], [ "list_procedures", diff --git a/tests/boundary/mcp/nativeAddressContextMcp.test.ts b/tests/boundary/mcp/nativeAddressContextMcp.test.ts index 5b855fc26..76a766681 100644 --- a/tests/boundary/mcp/nativeAddressContextMcp.test.ts +++ b/tests/boundary/mcp/nativeAddressContextMcp.test.ts @@ -7,6 +7,8 @@ import { jsonResult, } from "./enhancedToolsHarness.js"; import { observed as ok } from "../../fixtures/analysisExecution.js"; +import { err } from "../../../src/domain/result.js"; +import { AnalysisCapabilityUnavailableError } from "../../../src/domain/analysisErrorCore.js"; import type { SegmentSummary } from "../../../src/domain/hopperValues.js"; afterEach(closeEnhancedToolResources); @@ -25,6 +27,16 @@ const overviewClient = (segments: SegmentSummary[]) => connect({ execute: (name) => { switch (name) { + case "read_binary_inventory_summary": + return Promise.resolve( + err( + new AnalysisCapabilityUnavailableError( + "fixture", + name, + "No native overview", + ), + ), + ); case "current_document": return Promise.resolve(ok("fixture")); case "list_documents": diff --git a/tests/boundary/providers/ghidra/ghidraClient.test.ts b/tests/boundary/providers/ghidra/ghidraClient.test.ts index 89c9ad3f3..60fc89899 100644 --- a/tests/boundary/providers/ghidra/ghidraClient.test.ts +++ b/tests/boundary/providers/ghidra/ghidraClient.test.ts @@ -716,6 +716,11 @@ describe("GhidraClient", () => { const client = clientFor(launcher); const started = await client.start(); + const expectedCapabilities = GHIDRA_SESSION_CAPABILITIES.filter( + (value) => + HOST_TRANSPORT === "unix-socket" || + !GHIDRA_MUTATING_OPERATIONS.has(value), + ); expect(started).toMatchObject({ ok: true, value: { @@ -724,18 +729,22 @@ describe("GhidraClient", () => { read_only: HOST_TRANSPORT !== "unix-socket", analysis_complete: true, analysis_timed_out: false, - capabilities: GHIDRA_SESSION_CAPABILITIES.filter( - (value) => - HOST_TRANSPORT === "unix-socket" || - !GHIDRA_MUTATING_OPERATIONS.has(value), - ), target: { image_base: "0x1000", default_address_space: "ram", }, }, }); - if (started.ok) expect(started.value).not.toHaveProperty("bridge_version"); + if (started.ok) { + // Capability identity is an exact set; bridge declaration order is not a contract. + expect(new Set(started.value.capabilities)).toEqual( + new Set(expectedCapabilities), + ); + expect(started.value.capabilities).toHaveLength( + expectedCapabilities.length, + ); + expect(started.value).not.toHaveProperty("bridge_version"); + } if (HOST_TRANSPORT === "unix-socket") expect(Buffer.byteLength(launcher.endpointPaths[0] ?? "")).toBeLessThan( 108, diff --git a/tests/fixtures/fakeGhidra.mjs b/tests/fixtures/fakeGhidra.mjs index fc7b78700..2756040a8 100644 --- a/tests/fixtures/fakeGhidra.mjs +++ b/tests/fixtures/fakeGhidra.mjs @@ -255,6 +255,7 @@ const sessionInfo = ({ analysis_complete: !timedOut, analysis_timed_out: timedOut, capabilities: [ + "read_binary_inventory_summary", "ping", "shutdown", "inspect_native_load_image", @@ -307,6 +308,13 @@ const sessionInfo = ({ const inventoryResultFor = (method, params) => { switch (method) { + case "read_binary_inventory_summary": + return { + document: "fixture", + segments: inventoryResultFor("list_segments", {}), + procedure_count: inventoryResultFor("list_procedures", {}).length, + string_count: inventoryResultFor("list_strings", {}).length, + }; case "search_bytes": { const continued = params.cursor === "fixture-second-page"; const paged = params.limit === 1 && !continued;