Files
reverse-engineering-anything/tests/boundary/mcp/analyzeFunctionDetail.test.ts
T
gensokyo-dev 5508b2affd fix(analysis): retain source evidence through directory exports (#7250)
Retain authenticated provider source records independently of snapshot replay eligibility and return them explicitly to plain application callers, including late cancellation. CLI/MCP commit source records before delivering links; function dossiers share the canonical Evidence constructor. Keep directory replies compact and preserve successful retry verification.

Real Ghidra CLI/MCP investigations verify 652-function ls, historical report provenance, cross-session resume, unchanged source/project content and complete offline Evidence. Final full 14,338-function libcrypto comparison matches all metadata/pseudocode facts and independent fresh-import observations: 14,194 written, 143 external, one recorded deadline; 494.03 s, 445.63 CPU s, 1.584 GiB sampled family peak under 2-CPU/8-GiB guards, with no OOM or cleanup failure.

144 focused tests and static/docs/site checks pass; latest-main integration passes 289 cases plus typecheck/lint and actual CLI snapshot source retrieval. Final CI source/static/build/docs/quality/package checks pass. Four ADB shard failures also occur in main CI 40349559391; cancelled coverage remains unverified and author declarations stay unchecked under the user's explicit bypass authorization. No human-review attestation is made.
2026-10-26 23:56:03 +08:00

312 lines
12 KiB
TypeScript

import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
import pino from "pino";
import { expect, it, onTestFinished, vi } from "vitest";
import { EvidenceMcpServer } from "../../../src/server/EvidenceMcpServer.js";
import { registerEnhancedTools } from "../../../src/server/registerEnhancedTools.js";
import { createToolResultDelivery } from "../../../src/server/toolResult.js";
import { withAdmittedAnalysis } from "../../../src/server/analysisAdmission.js";
import type { AnalysisOperationPort } from "../../../src/application/AnalysisProvider.js";
import {
createEvidence,
parseEvidence,
type Evidence,
} from "../../../src/domain/evidence.js";
import { join } from "node:path";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import type { JsonValue } from "../../../src/domain/jsonValue.js";
import { ok } from "../../../src/domain/result.js";
import { functionDossierSchema } from "../../../src/domain/hopperValues.js";
import { ghidraFunctionDossier } from "../../../src/domain/ghidraValues.fixture.js";
import { parseGhidraFunctionInput } from "../../../src/ghidra/GhidraFunctionValues.js";
import { EvidenceLedger } from "../../../src/application/investigation/EvidenceLedger.js";
import { registerAnalysisViewTool } from "../../../src/server/registerAnalysisViewTool.js";
const dossier = functionDossierSchema.parse(ghidraFunctionDossier());
const partialCallees = {
status: "partial" as const,
limitations: ["Callees: fixture read limit"],
limited_directions: ["callees"] as const,
};
// A fixed provider that returns the fixture dossier for any analyze_function
// call, after validating the request exactly as the production Ghidra client does.
const connect = async (
retain = true,
result = dossier,
path = () => "/fixtures/native.exe",
sources: readonly Evidence[] = [],
) => {
const ledger = new EvidenceLedger();
const record = (evidence: Evidence) => ledger.record(evidence);
const lookup = (id: string) => ledger.forAnalysis(id);
const execute = vi.fn(
async (operation: string, parameters: Record<string, JsonValue>) => {
let observed: JsonValue = result;
if (operation === "analyze_function") {
const input = parseGhidraFunctionInput(operation, parameters);
if (!input.ok) return input;
} else if (operation === "list_procedures") {
observed = [{ address: "0x401000", value: "main" }];
} else if (operation === "procedure_info") {
observed = null;
} else if (operation === "procedure_pseudo_code") {
observed = "int main(void) { return 0; }";
} else throw new Error(`Unexpected operation: ${operation}`);
return ok({
result: observed,
rawResult: null,
provider: { id: "ghidra", name: "Ghidra", version: "12.1.4" },
limitations: [],
locations: [],
sourceEvidence: sources,
subject: {
path: path(),
format: "pe" as const,
sha256: "a".repeat(64),
},
relationshipCoverage: partialCallees,
});
},
);
const analysis = { execute } as unknown as AnalysisOperationPort;
const delivery = createToolResultDelivery(4 * 1024 * 1024);
const server = new EvidenceMcpServer(
{ name: "fn-detail", version: "1" },
{},
retain ? record : undefined,
delivery,
);
registerEnhancedTools(server, {
withAdmittedAnalysis: withAdmittedAnalysis({ kind: "fixed", analysis }),
logger: pino({ enabled: false }),
activeTarget: () => undefined,
analysisProfile: undefined,
recordEvidence: retain ? record : undefined,
evidenceById: retain ? lookup : undefined,
recordUnknown: undefined,
allowsSnapshotReplay: undefined,
recordWorkflowSnapshot: undefined,
});
registerAnalysisViewTool(
server,
pino({ enabled: false }),
retain ? lookup : undefined,
retain ? record : undefined,
);
const client = new Client({ name: "fn-detail-client", version: "1" });
onTestFinished(async () => {
await client.close();
await server.close();
});
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
await server.connect(serverTransport);
await client.connect(clientTransport);
return { client, ledger, execute };
};
it.each(["analyze_function", "decompile_all"] as const)(
"retains plain-port source records before delivering %s links",
async (operation) => {
const source = createEvidence(
undefined,
{ id: "ghidra", name: "Ghidra", version: "12.1.4" },
{
operation: "health",
parameters: { facet: "historical-report" },
result: { origin: "caller-project" },
},
{ immutable: true },
);
const { client, ledger } = await connect(true, dossier, undefined, [
source,
]);
const parent = await createTestTempDirectory("rea-plain-source-");
const reply = await client.callTool({
name: operation,
arguments:
operation === "analyze_function"
? { procedure: "main", detail: "complete" }
: { output_directory: join(parent, "out") },
});
expect(reply.isError).not.toBe(true);
const evidence = parseEvidence(reply.structuredContent);
expect(evidence.evidence_links).toContain(source.evidence_id);
expect(JSON.stringify(reply)).not.toContain("caller-project");
expect(ledger.forAnalysis(source.evidence_id)).toEqual(source);
expect(
ledger
.immutableBundle()
.records.filter((record) => record.evidence_id === source.evidence_id),
).toHaveLength(1);
},
);
it("refuses source links when a plain-port adapter has no retention owner", async () => {
const source = createEvidence(
undefined,
{ id: "fixture", name: "Fixture", version: "1" },
{
operation: "health",
parameters: {},
result: "historical",
},
);
const { client } = await connect(false, dossier, undefined, [source]);
const reply = await client.callTool({
name: "analyze_function",
arguments: { procedure: "main", detail: "complete" },
});
expect(reply.isError).toBe(true);
expect(JSON.stringify(reply)).toContain("retains provider source Evidence");
});
it("keeps complete and summary delivery on the current observed path despite retained same-ID history", async () => {
let selected = "/old/native.exe";
const { client, ledger } = await connect(true, dossier, () => selected);
const call = async (detail: "complete" | "summary") => {
const reply = await client.callTool({
name: "analyze_function",
arguments: { procedure: "main", detail },
});
expect(reply.isError).not.toBe(true);
return parseEvidence(reply.structuredContent);
};
const old = await call("complete");
selected = "/selected/native.exe";
const complete = await call("complete");
expect(complete.evidence_id).toBe(old.evidence_id);
expect(complete.subject?.local_path).toBe(selected);
const summary = await call("summary");
expect(summary.evidence_links).toEqual([complete.evidence_id]);
expect(summary.subject?.local_path).toBe(complete.subject?.local_path);
expect(summary.normalized_result).toMatchObject({
artifact: { path: selected },
});
expect(summary.locations).toContainEqual({
kind: "artifact-path",
path: selected,
});
expect(ledger.get(complete.evidence_id)?.subject?.local_path).toBe(
"/old/native.exe",
);
const historicalReply = await client.callTool({
name: "inspect_analysis_view",
arguments: {
source: { kind: "retained-evidence", evidence_id: complete.evidence_id },
view: { kind: "summary" },
},
});
expect(historicalReply.isError).not.toBe(true);
expect(
parseEvidence(historicalReply.structuredContent).subject?.local_path,
).toBe("/old/native.exe");
});
it("defaults to a retained summary and strips delivery detail before the provider", async () => {
const { client, ledger, execute } = await connect();
const progress: number[] = [];
const reply = await client.callTool(
{ name: "analyze_function", arguments: { procedure: "main" } },
{ onprogress: (update) => progress.push(update.progress) },
);
expect(progress.length).toBeGreaterThanOrEqual(2);
progress.forEach((value, index) => {
expect(value).toBeGreaterThan(progress[index - 1] ?? 0);
});
const evidence = parseEvidence(reply.structuredContent);
expect(evidence.normalized_result).toMatchObject({
kind: "summary",
parent_operation: "analyze_function",
});
const parentId = (
evidence.normalized_result as { parent_evidence_id: string }
).parent_evidence_id;
// The complete dossier is retained in-session so the summary's parent can be paged.
expect(ledger.has(parentId)).toBe(true);
// Delivery detail never crosses into the provider request.
const providerParameters = execute.mock.calls[0]?.[1] ?? {};
expect(providerParameters).not.toHaveProperty("detail");
expect(providerParameters).toMatchObject({ procedure: "main" });
// Partial caller/callee discovery stays with the summary that reports their counts.
expect(evidence.relationship_coverage).toEqual(partialCallees);
expect(ledger.get(parentId)?.relationship_coverage).toEqual(partialCallees);
// The summary envelope locates the function it describes.
expect(evidence.locations).toEqual([
{ kind: "artifact-path", path: "/fixtures/native.exe" },
{ kind: "address", address: dossier.procedure.address },
]);
// Optional records the fixture omits stay unknown rather than reading as empty.
expect(evidence.normalized_result).toMatchObject({
summary: {
counts: { unresolved_calls: null, swift_string_literals: null },
},
});
});
it("keeps value-flow counts unknown in the default summary when the provider records none", async () => {
// IDA and Kuna dossiers carry native_value_flow: null.
const { client } = await connect(true, {
...dossier,
native_value_flow: null,
});
const reply = await client.callTool({
name: "analyze_function",
arguments: { procedure: "main" },
});
const evidence = parseEvidence(reply.structuredContent);
expect(evidence.normalized_result).toMatchObject({
kind: "summary",
summary: {
value_flow: { available: false, truncated: false },
counts: {
value_flow_operations: null,
value_flow_def_use: null,
value_flow_effects: null,
value_flow_parameters: null,
value_flow_parameter_uses: null,
},
},
unknowns: expect.arrayContaining(["native_value_flow was not recorded"]),
});
});
it("composes inspect_native_api from a provider input without delivery detail", async () => {
const { client, execute } = await connect();
const reply = await client.callTool({
name: "inspect_native_api",
arguments: { procedure: "main" },
});
expect(reply.isError, JSON.stringify(reply.content)).not.toBe(true);
expect(
parseEvidence(reply.structuredContent).normalized_result,
).toMatchObject({ procedure: { name: dossier.procedure.name } });
expect(execute.mock.calls.map((call) => call[1])).toEqual([
{ procedure: "main" },
]);
});
it("returns the complete dossier inline when detail is complete", async () => {
const { client } = await connect();
const reply = await client.callTool({
name: "analyze_function",
arguments: { procedure: "main", detail: "complete" },
});
const evidence = parseEvidence(reply.structuredContent);
expect(evidence.normalized_result).toMatchObject({
pseudocode: dossier.pseudocode,
});
expect(evidence.normalized_result).toHaveProperty("native_value_flow");
});
it("rejects a summary request before analysis when the server retains no Evidence", async () => {
const { client, execute } = await connect(false);
const reply = await client.callTool({
name: "analyze_function",
arguments: { procedure: "main" },
});
expect(reply.isError).toBe(true);
expect(execute).not.toHaveBeenCalled();
});