diff --git a/src/application/FunctionAnalysisEvidence.ts b/src/application/FunctionAnalysisEvidence.ts index e2ef187d4d..00ef766a8f 100644 --- a/src/application/FunctionAnalysisEvidence.ts +++ b/src/application/FunctionAnalysisEvidence.ts @@ -46,6 +46,9 @@ export const executeFunctionAnalysisEvidence = async ( rawResult: observation.rawResult, limitations: observation.limitations, locations: observation.locations, + ...(observation.relationshipCoverage === undefined + ? {} + : { relationshipCoverage: observation.relationshipCoverage }), ...(observation.analysisProfile === undefined ? {} : { analysisProfile: observation.analysisProfile }), diff --git a/src/domain/callPath.test.ts b/src/domain/callPath.test.ts index d109955256..ca9515e39f 100644 --- a/src/domain/callPath.test.ts +++ b/src/domain/callPath.test.ts @@ -9,6 +9,7 @@ import { import { createEvidence, type Evidence } from "./evidence.js"; import { functionDossierSchema } from "./hopperValues.js"; import { jsonValueSchema } from "./jsonValue.js"; +import type { RelationshipCoverage } from "./relationshipCoverage.js"; const observe = ( address: string, @@ -17,6 +18,7 @@ const observe = ( options: { readonly evidenceLimitations?: readonly string[]; readonly dossierLimitations?: readonly string[]; + readonly relationshipCoverage?: RelationshipCoverage; } = {}, ): Evidence => { const value = functionDossierSchema.parse({ @@ -69,6 +71,9 @@ const observe = ( ...(options.evidenceLimitations === undefined ? {} : { limitations: options.evidenceLimitations }), + ...(options.relationshipCoverage === undefined + ? {} + : { relationshipCoverage: options.relationshipCoverage }), }, ); }; @@ -171,6 +176,21 @@ describe("call path reconstruction", () => { }); describe("call path topology and capacity", () => { + it("retains every distinct generic limitation beyond the push argument limit", () => { + const limitations = Array.from( + { length: 1_000_000 }, + (_, index) => `context-${index.toString().padStart(7, "0")}`, + ); + const result = run([ + observe("0x1000", [], "rea-workflow", { + evidenceLimitations: [...limitations].reverse(), + }), + ]); + expect(result.status).toBe("unknown"); + expect(result.search_scope.exhaustive).toBe(false); + expect(result.limitations).toEqual(limitations); + }, 30_000); + it("retains every shortest path through merging branches and cycles", () => { const result = run([ observe("0x1000", ["0x3000", "0x2000", "0x1000"]), @@ -314,6 +334,96 @@ describe("call path topology and capacity", () => { }); }); +describe("explicit call relationship coverage", () => { + it.each([150_000, 1_000_000])( + "accumulates %i partial reasons without argument-stack exhaustion", + (length) => { + const result = run([ + observe("0x1000", [], "rea-workflow", { + relationshipCoverage: { + status: "partial", + limitations: Array(length).fill("omitted"), + }, + evidenceLimitations: [], + dossierLimitations: [], + }), + ]); + expect(result).toMatchObject({ + status: "unknown", + search_scope: { exhaustive: false }, + limitations: ["omitted"], + }); + }, + ); + + it("retains contextual limits on a found path without reducing complete coverage", () => { + const limitation = "Provider results reflect the current database state."; + const result = run([ + observe("0x1000", ["0x4000"], "rea-workflow", { + relationshipCoverage: { status: "complete" }, + evidenceLimitations: [limitation], + }), + ]); + expect(result).toMatchObject({ + status: "found", + shortest_hops: 1, + search_scope: { exhaustive: true }, + }); + expect(result.limitations).toContain(limitation); + }); + + it("retains a found path while its expanded relationship coverage remains partial", () => { + const limitation = "Other callees were omitted by the provider cap."; + const result = run([ + observe("0x1000", ["0x4000"], "rea-workflow", { + relationshipCoverage: { status: "partial", limitations: [limitation] }, + }), + ]); + expect(result).toMatchObject({ + status: "found", + shortest_hops: 1, + search_scope: { exhaustive: false }, + }); + expect(result.limitations).toContain(limitation); + }); + + it("retains an unknown goal coverage reason without expanding the terminal goal", () => { + const reason = "The goal's outgoing relationship coverage is unknown."; + const result = run([ + observe("0x1000", ["0x4000"], "rea-workflow", { + relationshipCoverage: { status: "complete" }, + }), + observe("0x4000", ["0x5000"], "rea-workflow", { + relationshipCoverage: { status: "unknown", reason }, + }), + ]); + expect(result).toMatchObject({ + status: "found", + search_scope: { exhaustive: true }, + explored: { nodes: 2 }, + }); + expect(result.limitations).toEqual([reason]); + }); + + it("ignores incomplete relationship coverage outside the reachable search closure", () => { + const reason = "An unrelated function's callees were not observed."; + const result = run([ + observe("0x1000", [], "rea-workflow", { + relationshipCoverage: { status: "complete" }, + }), + observe("0x2000", ["0x5000"], "rea-workflow", { + relationshipCoverage: { status: "unknown", reason }, + }), + ]); + expect(result).toMatchObject({ + status: "not_found", + search_scope: { exhaustive: true }, + explored: { nodes: 1 }, + }); + expect(result.limitations).toEqual([]); + }); +}); + describe("call path evidence admission", () => { it("normalizes hex addresses and rejects duplicates and mixed providers", () => { expect( diff --git a/src/domain/callPath.ts b/src/domain/callPath.ts index 1da4296250..439a589490 100644 --- a/src/domain/callPath.ts +++ b/src/domain/callPath.ts @@ -236,12 +236,27 @@ const inspectSearch = ({ const depth = reached.get(node) ?? 0; const snapshot = snapshots.get(node); if (snapshot !== undefined) { - limitations.push(...snapshot.limitations); + const coverage = snapshot.evidence.relationship_coverage; + for (const limitation of snapshot.limitations) + limitations.push(limitation); + if (coverage?.status === "partial") + for (const limitation of coverage.limitations) + limitations.push(limitation); + else if (coverage?.status === "unknown") + limitations.push(coverage.reason); // The requested goal is a terminal for this search. Its outgoing edges // cannot add a path to the goal, so its dossier limits are contextual // but do not make the searched start-to-goal closure incomplete. if (node === goal) continue; - if (snapshot.limitations.length > 0) incompleteExpandedCoverage = true; + // Explicit, Evidence-bound relationship coverage owns completeness; + // generic provider/dossier limitations remain contextual. Legacy Evidence + // without that metadata retains its conservative limitation-based rule. + if ( + coverage === null + ? snapshot.limitations.length > 0 + : coverage.status !== "complete" + ) + incompleteExpandedCoverage = true; } else if (node === goal) { continue; } diff --git a/tests/boundary/mcp/investigationAnalysisMcp.test.ts b/tests/boundary/mcp/investigationAnalysisMcp.test.ts index a1259e6670..18e3166ac9 100644 --- a/tests/boundary/mcp/investigationAnalysisMcp.test.ts +++ b/tests/boundary/mcp/investigationAnalysisMcp.test.ts @@ -3,7 +3,14 @@ import { ok as resultOk } from "../../../src/domain/result.js"; import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; import { expect, it } from "vitest"; -import { createTestBinarySession } from "../../fixtures/binarySession.js"; +import { + createBinarySessionTargets, + createTestBinarySession, +} from "../../fixtures/binarySession.js"; +import { + createAnalysisExecution, + type AnalysisOperationPort, +} from "../../../src/application/AnalysisProvider.js"; import type { BinarySession } from "../../../src/application/binary/BinarySession.js"; import { FUNCTION_COMPARISON_EXAMPLE } from "../../../src/contracts/functionComparisonExample.js"; import { INVESTIGATION_EXAMPLES } from "../../../src/contracts/investigationExamples.js"; @@ -20,6 +27,7 @@ import { } from "../../../src/domain/jsonValue.js"; import { createServer } from "../../../src/server/createServer.js"; import { observed } from "../../fixtures/analysisExecution.js"; +import type { RelationshipCoverage } from "../../../src/domain/relationshipCoverage.js"; it("aggregates comparison Evidence and records an approved runtime gap", async () => { const { session, server, client } = await connected(); @@ -149,6 +157,183 @@ const dossierWithCallees = (addresses: readonly string[]) => { ); }; +it.each([ + { + label: "complete", + coverage: { status: "complete" } as RelationshipCoverage, + providerLimitations: ["The provider version identity is unavailable."], + dossierLimitations: ["The dossier has no native value-flow observation."], + status: "not_found", + exhaustive: true, + }, + { + label: "partial", + coverage: { + status: "partial", + limitations: ["Callee output was capped."], + } as RelationshipCoverage, + providerLimitations: [], + dossierLimitations: [], + status: "unknown", + exhaustive: false, + }, + { + label: "unknown", + coverage: { + status: "unknown", + reason: "Callee coverage was not established.", + } as RelationshipCoverage, + providerLimitations: [], + dossierLimitations: [], + status: "unknown", + exhaustive: false, + }, + { + label: "legacy limited", + coverage: undefined, + providerLimitations: [ + "Legacy relationship completeness was not established.", + ], + dossierLimitations: [], + status: "unknown", + exhaustive: false, + }, + { + label: "legacy unlimited", + coverage: undefined, + providerLimitations: [], + dossierLimitations: [], + status: "not_found", + exhaustive: true, + }, +])( + "preserves $label coverage through public analyze_function then build_call_path", + async (scenario) => { + const base = FUNCTION_COMPARISON_EXAMPLE.left; + if (base.subject === null) throw new Error("missing fixture subject"); + const provider = { + id: "fixture", + name: "Fixture analysis provider", + version: "1", + }; + const rawResult = { fixture_reply: scenario.label }; + const execution = createAnalysisExecution( + { + ...jsonObjectSchema.parse(base.normalized_result), + callees: [], + limitations: scenario.dossierLimitations, + }, + provider, + { + rawResult, + limitations: scenario.providerLimitations, + locations: base.locations, + ...(scenario.coverage === undefined + ? {} + : { relationshipCoverage: scenario.coverage }), + }, + ); + const { session, server, client } = await connected({ + execute: async (operation, parameters) => { + if (operation === "health") return observed(null); + if (operation !== "analyze_function") + throw new Error(`Unexpected operation: ${operation}`); + expect(parameters.procedure).toBe("0x1000"); + return resultOk(execution); + }, + }); + try { + const [path] = await createBinarySessionTargets(); + const opened = await client.callTool({ + name: "open_binary", + arguments: { path }, + }); + expect(opened.isError, JSON.stringify(opened)).not.toBe(true); + const analyzed = await client.callTool({ + name: "analyze_function", + arguments: { procedure: "0x1000" }, + }); + expect(analyzed.isError, JSON.stringify(analyzed)).not.toBe(true); + const dossier = inlineEvidence(analyzed.structuredContent); + expect(dossier).toMatchObject({ + operation: "analyze_function", + parameters: { procedure: "0x1000" }, + provider, + subject: { + local_path: path, + digest: { sha256: session.activeTarget()?.sha256 }, + format: session.activeTarget()?.format, + }, + raw_result: rawResult, + limitations: scenario.providerLimitations, + locations: base.locations, + normalized_result: { + callees: [], + limitations: scenario.dossierLimitations, + }, + }); + const response = await client.callTool({ + name: "build_call_path", + arguments: { + functions: [dossier], + start: { address: "0x1000" }, + goal: { address: "0x4000" }, + }, + }); + expect(response.isError, JSON.stringify(response)).not.toBe(true); + const evidence = inlineEvidence(response.structuredContent); + const coverage = scenario.coverage; + const reasons = + coverage?.status === "partial" + ? coverage.limitations + : coverage?.status === "unknown" + ? [coverage.reason] + : []; + expect(evidence.normalized_result).toMatchObject({ + status: scenario.status, + shortest_hops: null, + paths: [], + search_scope: { exhaustive: scenario.exhaustive }, + limitations: expect.arrayContaining([ + ...scenario.providerLimitations, + ...scenario.dossierLimitations, + ...reasons, + ]), + }); + expect(evidence.evidence_links).toEqual([dossier.evidence_id]); + expect(dossier.relationship_coverage).toEqual(coverage ?? null); + if (coverage?.status === "partial") { + const tampered = { + ...dossier, + relationship_coverage: { status: "complete" }, + }; + expect(() => parseEvidence(tampered)).toThrow(); + const rejected = await client.callTool({ + name: "build_call_path", + arguments: { + functions: [tampered], + start: { address: "0x1000" }, + goal: { address: "0x4000" }, + }, + }); + expect(rejected.isError, JSON.stringify(rejected)).toBe(true); + } + if (scenario.exhaustive) + expect(session.listUnknowns({ domain: "call-path" })).toEqual([]); + else + expect(session.listUnknowns({ domain: "call-path" })).toMatchObject([ + { + question: + "Can the requested call path be established from complete analysis?", + supporting_evidence_ids: [evidence.evidence_id], + }, + ]); + } finally { + await close(session, server, client); + } + }, +); + it("records a safe unresolved call-path question", async () => { const { session, server, client } = await connected(); const dossier = dossierWithCallees(["0x2000"]); @@ -326,10 +511,12 @@ it("cannot omit a session-owned active unknown from reconstruction input", async } }); -const connected = async () => { +const connected = async (analysis?: AnalysisOperationPort) => { const session = createTestBinarySession(() => ({ health: () => Promise.resolve(), - execute: () => Promise.resolve(observed(null)), + execute: + analysis?.execute.bind(analysis) ?? + (() => Promise.resolve(observed(null))), close: () => Promise.resolve(resultOk(null)), })); const server = createServer({ kind: "session", session });