mirror of
https://github.com/morluto/rea.git
synced 2026-10-18 20:10:13 -05:00
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>
This commit is contained in:
24 files changed
+406
-52
No files matched your search
@@ -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();
|
||||
|
||||
@@ -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 <path> --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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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"]);
|
||||
},
|
||||
);
|
||||
@@ -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",
|
||||
|
||||
@@ -679,6 +679,11 @@ const composedAvailability = (
|
||||
name: string,
|
||||
descriptors: ReadonlyMap<string, ProviderDescriptor>,
|
||||
): 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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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) => [
|
||||
|
||||
@@ -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 <percent-encoded-space>:0x<hex>.",
|
||||
];
|
||||
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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -180,6 +180,20 @@ const makeProvider = (
|
||||
trace.calls.push(operation);
|
||||
const results: Readonly<Record<string, unknown>> = {
|
||||
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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -144,6 +144,15 @@ type GhidraResultBuilder = (
|
||||
) => JsonValue;
|
||||
|
||||
const resultBuilders = new Map<GhidraOperation, GhidraResultBuilder>([
|
||||
[
|
||||
"read_binary_inventory_summary",
|
||||
() => ({
|
||||
document: "fixture",
|
||||
segments: resultFor("list_segments", {}),
|
||||
procedure_count: 1,
|
||||
string_count: 2,
|
||||
}),
|
||||
],
|
||||
["list_documents", () => ["fixture"]],
|
||||
[
|
||||
"list_procedures",
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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,
|
||||
|
||||
Vendored
+8
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user