* fix(enhanced): stop call graph traversals at unconfirmed provider cleanup A relationship read whose provider could not confirm cleanup of resources it owns was projected into a graph node or call-path failure, the traversal kept dispatching queued reads, and get_call_graph / trace_call_path returned success with the cleanup_incomplete status buried inside the result. Both traversals now read relationships through one shared owner, readCallRelationships, which classifies a cancelled read or a read with unconfirmed owned cleanup as terminal. On such a cleanup stop the workflow returns the provider's own error as the outcome, so its cleanup owner, retry binding and live resolution stay intact, and keeps the work admitted before the stop as the workflow's normal result shape with a caveat naming the stopped procedure and the unexpanded count. The composed-workflow layer turns that admitted result into partial workflow Evidence on the failure, and the MCP adapter retains it through the shared partial-error delivery. Ordinary read failures still produce successful partial results. Fixes #8369 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(enhanced): retain every carried partial Evidence on enhanced failures Enhanced-tool failures used the shared partial-error delivery only when this PR's workflow partial was attached. A failing read that already carried its own partial Evidence, and analyze_function dossier failures, were projected inline and never recorded with the session writer, so readback and bundle export could not recover them. Every enhanced execution failure now goes through one delivery owner that uses toPartialErrorToolResult with the session writer; it still falls back to inline when no writer or no valid Evidence exists. Tests compare the traversal caveat against its owning formatter instead of copying the sentence. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
341 lines
13 KiB
TypeScript
341 lines
13 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/native/functionDossier.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();
|
|
});
|
|
|
|
it.each([true, false])(
|
|
"delivers an unreadable dossier's partial Evidence through the session writer (retained=%s)",
|
|
async (retain) => {
|
|
const { client, ledger } = await connect(retain, {
|
|
not: "a dossier",
|
|
} as never);
|
|
|
|
const reply = await client.callTool({
|
|
name: "analyze_function",
|
|
arguments: { procedure: "main", detail: "complete" },
|
|
});
|
|
|
|
expect(reply.isError).toBe(true);
|
|
const partial = JSON.parse(
|
|
reply.content.find((item) => item.type === "text")?.text ?? "{}",
|
|
).error.details.partial_observation;
|
|
if (!retain) {
|
|
expect(parseEvidence(partial).operation).toBe("analyze_function");
|
|
return;
|
|
}
|
|
expect(partial).toMatchObject({ kind: "retained-evidence" });
|
|
expect(ledger.forAnalysis(partial.evidence_id)).toMatchObject({
|
|
evidence_id: partial.evidence_id,
|
|
operation: "analyze_function",
|
|
normalized_result: { not: "a dossier" },
|
|
});
|
|
},
|
|
);
|