Files
rea/tests/boundary/mcp/ghidraEvidenceMcp.test.ts
T
242e2b0001 fix(ghidra): preserve function facet and call coverage (#3002)
* fix(ghidra): preserve observed function facet and call coverage

Declare successful query availability in the Java producer, including explicit unavailable pseudocode reasons. Carry observed static call-set coverage through the parsed provider result and Evidence without inventing facts for legacy omissions. Use Ghidra indexed comment addresses over the function body and entry, retaining cancellation checks and sparse collection.

Closes #2951

Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com>

* fix(ghidra): retain unresolved call coverage in observations

Share decoded unresolved-call facts between dossiers, callee coverage and reference observations. Combine terminal and unresolved observations in one instruction traversal and retain site records only when requested. Keep missing procedure identities incomplete and validate unresolved locations at the provider boundary.

Bind snapshots to the corrected call coverage profile so older complete claims cannot replay under the new semantics. Add real public MCP callback trace/comparison regressions.

Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com>

* fix(ghidra): separate reference targets from callee coverage

Reuse containing-function ownership for resolved reference identities and independently compare those identities with the actual returned callee set. Keep known interior routes out of unresolved site records while retaining partial inventory coverage.

Advance the coverage profile to v3 and reject interim v2 cache bindings. Add a real Ghidra producer regression with restored reference metadata; the previous bridge fails the known-route assertion.

Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com>

* fix(ghidra): measure incoming call coverage across function bodies

Compare incoming reference source identities across the owned function body and entry with the actual returned caller set. Stream caller-only checks and reuse dossier reference observations. Combine caller/callee coverage from observed counts without reinterpreting serialized metadata, and share the body scope owner with comments and references.

Bind snapshots to function-body coverage semantics v4 and preserve the real off-entry caller regression.

Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com>

* fix(ghidra): preserve whole-scope directional call coverage

Check body-and-entry call references independently of decoded instruction flow. Reuse collected dossier references and stream inventory-only coverage checks. Keep caller-only gaps distinct from incomplete outgoing coverage in offline path reconstruction, and invalidate prior snapshot profiles.

Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com>

* fix(mcp): retain compact Evidence field types

Share optional diagnostics across the three tagged relationship coverage states instead of advertising duplicate branches. Preserve coverage validation while keeping compact Evidence properties within the existing reference expansion budget. Verify invalid direction cardinalities against the actual SDK-advertised schema.

Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com>

---------

Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com>
Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
2026-10-14 07:52:14 +08:00

455 lines
16 KiB
TypeScript

import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
import { describe, expect, it } from "vitest";
import { EnhancedTools } from "../../../src/application/EnhancedTools.js";
import { connectGhidraMcp, sessionEvidence } from "./ghidraMcpHarness.js";
import { functionDossierSchema } from "../../../src/domain/hopperValues.js";
import { functionComparisonResultSchema } from "../../../src/domain/functionComparisonSchemas.js";
import { ghidraFunctionDossier } from "../../../src/domain/ghidraValues.fixture.js";
import {
jsonValueSchema,
type JsonValue,
} from "../../../src/domain/jsonValue.js";
import { ok } from "../../../src/domain/result.js";
import {
isGhidraFunctionOperation,
type GhidraFunctionOperation,
} from "../../../src/ghidra/GhidraFunctionValues.js";
it.each([
{ pseudocodeAvailable: true, reportsCoverage: true },
{ pseudocodeAvailable: false, reportsCoverage: true },
{ pseudocodeAvailable: true, reportsCoverage: false },
{ pseudocodeAvailable: false, reportsCoverage: false },
])(
"compares observed empty Ghidra facets with pseudocode=$pseudocodeAvailable and coverage=$reportsCoverage",
async ({ pseudocodeAvailable, reportsCoverage }) => {
const coverage = reportsCoverage
? { status: "complete" as const }
: undefined;
const dossier = functionDossierSchema.parse(ghidraFunctionDossier());
Object.assign(dossier, {
comments: [],
callers: [],
callees: [],
incoming_references: [],
outgoing_references: [],
referenced_strings: [],
referenced_names: [],
pseudocode: pseudocodeAvailable ? dossier.pseudocode : "",
facet_availability: {
pseudocode: pseudocodeAvailable
? { available: true }
: { available: false, reason: "Ghidra did not supply pseudocode." },
assembly: { available: true },
comments: { available: true },
callers: { available: true },
callees: { available: true },
references: { available: true },
strings_names: { available: true },
cfg: { available: true },
},
});
const harness = await connectGhidraMcp(
"ghidra-empty-dossier-coverage",
(operation) => {
if (operation !== "analyze_function")
throw new Error(`Unexpected operation: ${operation}`);
return Promise.resolve(
ok({
value: jsonValueSchema.parse(dossier),
limitations: [],
...(coverage === undefined
? {}
: { relationship_coverage: coverage }),
}),
);
},
);
try {
const analyzed = await harness.mcp.callTool({
name: "analyze_function",
arguments: { procedure: dossier.procedure.address },
});
expect(analyzed.isError).not.toBe(true);
const evidence = sessionEvidence(
harness.session,
analyzed.structuredContent,
);
expect(evidence.relationship_coverage).toEqual(coverage ?? null);
expect(evidence.normalized_result).toEqual(dossier);
const reply = await harness.mcp.callTool({
name: "compare_functions",
arguments: { left: evidence, right: evidence },
});
expect(reply.isError).not.toBe(true);
const comparison = functionComparisonResultSchema.parse(
sessionEvidence(harness.session, reply.structuredContent)
.normalized_result,
);
expect(
Object.fromEntries(
comparison.dimensions.map(({ dimension, status }) => [
dimension,
status,
]),
),
).toEqual({
identity: "unchanged",
pseudocode: pseudocodeAvailable ? "unchanged" : "unknown",
assembly: "unchanged",
comments: "unchanged",
calls: reportsCoverage ? "unchanged" : "unknown",
references: "unchanged",
strings_names: "unchanged",
cfg: "unchanged",
});
const replay = await harness.session.execute(
"analyze_function",
evidence.parameters,
);
if (!replay.ok) throw replay.error;
expect(replay.value.relationshipCoverage).toEqual(coverage);
expect(harness.calls).toEqual(["analyze_function"]);
} finally {
await harness.close();
}
},
);
it("retains terminal-call qualifications across function views and snapshot replay", async () => {
const limitation =
"Ghidra Listing reports a terminal call at 0x401000 to EXTERNAL:0x20 __tls_get_addr (external=true, thunk=false, hasNoReturn=true); fallthrough is excluded by Ghidra's flow model. This records Ghidra's FunctionManager flag, not an independent verification that the callee cannot return.";
const dossier = functionDossierSchema.parse(ghidraFunctionDossier());
dossier.limitations.push(limitation);
const { signature, locals, ...identity } = dossier.procedure;
if (!identity.body.available)
throw new Error("The function fixture must include its analyzed body");
const values = new Map<GhidraFunctionOperation, JsonValue>([
["procedure_pseudo_code", dossier.pseudocode],
["procedure_assembly", dossier.assembly.join("\n")],
[
"procedure_info",
{
name: identity.name,
entrypoint: identity.address,
signature,
locals,
basicblock_count: dossier.basic_blocks.length,
length: identity.body.total_bytes,
classification: identity.classification,
body: identity.body,
},
],
[
"read_function_instructions",
{
procedure: identity,
instructions: dossier.assembly,
limitations: [limitation],
},
],
["procedure_callees", ["0x402000"]],
["procedure_callers", [identity.address]],
["analyze_function", jsonValueSchema.parse(dossier)],
]);
const harness = await connectGhidraMcp(
"ghidra-terminal-call-evidence",
(operation) => {
if (!isGhidraFunctionOperation(operation))
throw new Error(`Unexpected operation: ${operation}`);
const value = values.get(operation);
if (value === undefined)
throw new Error(`Unexpected operation: ${operation}`);
return Promise.resolve(ok({ value, limitations: [limitation] }));
},
);
try {
for (const [name, value] of values) {
const request = {
name,
arguments:
name === "analyze_function"
? { procedure: identity.address }
: { procedure: identity.address, document: "fixture" },
};
const reply = await harness.mcp.callTool(request);
expect(reply.isError, name).not.toBe(true);
const evidence = sessionEvidence(
harness.session,
reply.structuredContent,
);
expect(evidence.normalized_result, name).toEqual(value);
expect(evidence.raw_result, name).toEqual({
value,
limitations: [limitation],
});
expect(evidence.limitations, name).toContain(limitation);
expect(evidence.analysis_profile?.parameters).toMatchObject({
function_boundary_observations: "ghidra-terminal-call-limitations-v2",
});
const replay = await harness.session.execute(name, evidence.parameters);
if (!replay.ok) throw replay.error;
expect(replay.value.result, name).toEqual(value);
expect(replay.value.limitations, name).toContain(limitation);
expect(replay.value.limitations, name).toContainEqual(
expect.stringContaining("Loaded from a local REA analysis snapshot"),
);
}
expect(harness.calls).toEqual([...values.keys()]);
} finally {
await harness.close();
}
});
it("rejects contradictory annotation readback across the provider and MCP boundaries", async () => {
const dossier = functionDossierSchema.parse(ghidraFunctionDossier());
const annotations = {
address: dossier.procedure.address,
name: dossier.procedure.name,
comment: null,
inline_comment: "Finding",
};
const effects = {
scope: "session-analysis-database",
source_bytes_modified: false,
persists_after_close: false,
};
const original = { annotations, dossier, effects };
let output = jsonValueSchema.parse(original);
const harness = await connectGhidraMcp("ghidra-malformed-annotation", () =>
Promise.resolve(ok(output)),
);
try {
const accepted = await harness.mcp.callTool({
name: "annotate_native_function",
arguments: {
procedure: annotations.address,
inline_comment: "Finding",
},
});
expect(accepted.isError).not.toBe(true);
expect(accepted.structuredContent).toMatchObject({
normalized_result: { annotations },
});
for (const value of [
{ ...original, annotations: { ...annotations, name: "different" } },
{ ...original, annotations: { ...annotations, address: "0x9999" } },
{ ...original, effects: { ...effects, source_bytes_modified: true } },
{ ...original, effects: { ...effects, persists_after_close: true } },
{ ...original, dossier: { ...dossier, native_value_flow: null } },
]) {
output = jsonValueSchema.parse(value);
const reply = await harness.mcp.callTool({
name: "annotate_native_function",
arguments: {
procedure: annotations.address,
inline_comment: "Finding",
},
});
expect(reply.isError).toBe(true);
expect(parseMcpToolError(reply)).toMatchObject({
error: { code: "unreadable_output" },
});
}
} finally {
await harness.close();
}
});
it("routes address naming to Ghidra with compact per-address results", async () => {
const requests: { operation: string; input: unknown }[] = [];
let output: unknown = null;
const harness = await connectGhidraMcp(
"ghidra-address-naming",
(operation, input) => {
requests.push({ operation, input });
return Promise.resolve(ok(jsonValueSchema.parse(output)));
},
);
try {
output = true;
const single = await harness.mcp.callTool({
name: "set_address_name",
arguments: { address: "0x1000", name: "fixture_entry" },
});
expect(single.isError).not.toBe(true);
expect(single.structuredContent).toMatchObject({ normalized_result: true });
output = { "0x1000": true, "0x2000": false };
const batch = await harness.mcp.callTool({
name: "set_addresses_names",
arguments: { names: { "0x1000": "fixture_entry", "0x2000": "g_value" } },
});
expect(batch.isError).not.toBe(true);
expect(batch.structuredContent).toMatchObject({
normalized_result: { "0x1000": true, "0x2000": false },
});
expect(requests).toEqual([
{
operation: "set_address_name",
input: { document: null, address: "0x1000", name: "fixture_entry" },
},
{
operation: "set_addresses_names",
input: {
document: null,
names: { "0x1000": "fixture_entry", "0x2000": "g_value" },
},
},
]);
const empty = await harness.mcp.callTool({
name: "set_addresses_names",
arguments: { names: {} },
});
expect(empty.isError).toBe(true);
expect(requests).toHaveLength(2);
output = "fixture_entry";
const malformed = await harness.mcp.callTool({
name: "set_address_name",
arguments: { address: "0x1000", name: "fixture_entry" },
});
expect(malformed.isError).toBe(true);
expect(parseMcpToolError(malformed)).toMatchObject({
error: { code: "unreadable_output" },
});
} finally {
await harness.close();
}
});
describe("Ghidra MCP evidence parity", () => {
it("preserves provider evidence, composed parity, and capability routing", async () => {
const harness = await connectGhidraMcp("ghidra-parity");
const { mcp, session } = harness;
try {
const listed = sessionEvidence(
session,
(
await mcp.callTool({
name: "list_procedures",
arguments: {},
})
).structuredContent,
);
expect(listed).toMatchObject({
operation: "list_procedures",
provider: { id: "ghidra", name: "Ghidra", version: "12.1.4" },
analysis_profile: {
provider: { id: "ghidra", version: "12.1.4" },
parameters: {
import_mode: "ephemeral-source-immutable",
annotation_policy: "atomic-function-entry-metadata-v1",
analyzer_preset: "ghidra-default",
},
},
normalized_result: [
{
address: "0x401000",
value: "fixture_main",
procedure: {
external: false,
thunk: false,
thunk_target: null,
},
},
],
});
const mcpOverview = sessionEvidence(
session,
(
await mcp.callTool({
name: "binary_overview",
arguments: {},
})
).structuredContent,
);
const directOverview = await new EnhancedTools(session).execute(
"binary_overview",
{},
);
if (!directOverview.ok) throw directOverview.error;
expect(mcpOverview.normalized_result).toEqual(directOverview.value);
expect(mcpOverview).toMatchObject({
provider: { id: "rea-workflow" },
confidence: "derived",
normalized_result: {
document: "fixture",
segment_count: 1,
procedure_count: 1,
string_count: 2,
segments: [{ name: ".text", length: 256 }],
},
});
const pseudocode = sessionEvidence(
session,
(
await mcp.callTool({
name: "procedure_pseudo_code",
arguments: { procedure: "fixture_main" },
})
).structuredContent,
);
expect(pseudocode).toMatchObject({
operation: "procedure_pseudo_code",
provider: { id: "ghidra", version: "12.1.4" },
normalized_result: expect.stringContaining("return 42"),
limitations: expect.arrayContaining([
expect.stringContaining("not text-equivalent to Hopper"),
]),
});
const analyzed = sessionEvidence(
session,
(
await mcp.callTool({
name: "analyze_function",
arguments: { procedure: "fixture_main" },
})
).structuredContent,
);
expect(analyzed).toMatchObject({
operation: "analyze_function",
provider: { id: "ghidra", version: "12.1.4" },
normalized_result: {
procedure: {
address: "0x401000",
name: "fixture_main",
classification: {
external: false,
thunk: false,
provenance: "ghidra-function-manager",
},
},
pseudocode: "int fixture_main(void) { return 42; }",
outgoing_references: [
{
kind: { available: true, provenance: "ghidra-reference-manager" },
},
],
limitations: expect.arrayContaining([
expect.stringContaining("indirect flows without target addresses"),
expect.stringContaining(
"not original source or Hopper-equivalent text",
),
]),
},
limitations: expect.arrayContaining([
expect.stringContaining("default auto-analysis complete"),
expect.stringContaining(
"original executable bytes are never written",
),
]),
});
const directAnalyzed = await new EnhancedTools(session).execute(
"analyze_function",
{ procedure: "fixture_main" },
);
if (!directAnalyzed.ok) throw directAnalyzed.error;
expect(analyzed.normalized_result).toEqual(directAnalyzed.value);
} finally {
await harness.close();
}
}, 30_000);
});