fix(call-path): preserve relationship coverage from analysis (#2001)
Forward observed relationship coverage into function Evidence and use its typed completeness when searching call paths. Preserve contextual limitations, legacy fallback, provenance, and unknown outcomes. Avoid argument-spread exhaustion on large valid limitation lists. Cover the public MCP producer/consumer chain, metadata tampering, terminal and unreachable coverage, and large-list capacity. Related to #1964; separate get_call_graph and trace_call_path work is not included. AI-assisted with Hermes (Nous Research).
This commit is contained in:
1 parent
976c2c083e
commit
ed6af7eb8c
4 files changed
+320
-5
No files matched your search
@@ -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 }),
|
||||
|
||||
@@ -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<string>(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(
|
||||
|
||||
+17
-2
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in new issue
Block a user