diff --git a/src/application/SourceLimitations.ts b/src/application/SourceLimitations.ts new file mode 100644 index 0000000000..f30490af4c --- /dev/null +++ b/src/application/SourceLimitations.ts @@ -0,0 +1,46 @@ +import type { + AnalysisOperation, + ProviderIdentity, +} from "./AnalysisProvider.js"; +import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js"; + +/** One provider-owned caveat tied to the operation that reported it. */ +export interface SourceLimitation { + readonly provider: ProviderIdentity; + readonly operation: AnalysisOperation; + readonly limitation: string; +} + +/** Stable exact-provenance key for deduplication before rendering. */ +export const sourceLimitationKey = ({ + provider, + operation, + limitation, +}: SourceLimitation): string => + JSON.stringify([ + provider.id, + provider.name, + provider.version, + operation, + limitation, + ]); + +/** Render unique source caveats without losing their provider or operation. */ +export const renderSourceLimitations = ( + limitations: readonly SourceLimitation[], +): string[] => { + const rendered = new Map(); + for (const { provider, operation, limitation } of limitations) { + const provenance = sourceLimitationKey({ + provider, + operation, + limitation, + }); + const version = provider.version === null ? "" : ` ${provider.version}`; + rendered.set( + provenance, + `${provider.name} (${provider.id}${version}) ${operation}: ${limitation}`, + ); + } + return [...rendered.values()].sort(compareUnicodeCodePoints); +}; diff --git a/src/application/WorkflowEvidence.test.ts b/src/application/WorkflowEvidence.test.ts index b374efa014..7b18599150 100644 --- a/src/application/WorkflowEvidence.test.ts +++ b/src/application/WorkflowEvidence.test.ts @@ -95,16 +95,18 @@ it("retains unique upstream limits in canonical order across concurrent observat let calls = 0; const options = { signal: new AbortController().signal }; const analysis: AnalysisOperationPort = { - execute: async (_, parameters, forwarded) => { + execute: async (operation, parameters, forwarded) => { expect(parameters).toEqual({ marker: true }); expect(forwarded).toBe(options); const call = calls++; if (call === 0) await gate; else release(); + const limitation = + operation === "list_strings" + ? ["first", "shared"] + : ["second", "shared"]; return ok( - createAnalysisExecution([], provider, { - limitations: call === 0 ? ["first", "shared"] : ["second", "shared"], - }), + createAnalysisExecution([], provider, { limitations: limitation }), ); }, }; @@ -117,7 +119,50 @@ it("retains unique upstream limits in canonical order across concurrent observat }); expect(calls).toBe(2); expect(result).toEqual( - ok({ result: { count: 0 }, limitations: ["first", "second", "shared"] }), + ok({ + result: { count: 0 }, + limitations: [ + "Fixture (fixture 1) list_procedures: second", + "Fixture (fixture 1) list_procedures: shared", + "Fixture (fixture 1) list_strings: first", + "Fixture (fixture 1) list_strings: shared", + ], + }), + ); +}); + +it("deduplicates exact source caveats without merging providers or operations", async () => { + const firstProvider = { id: "first", name: "First", version: "1" }; + const secondProvider = { id: "second", name: "Second", version: "1" }; + const result = await executeComposedWorkflow( + { + execute: async (operation, parameters) => + ok( + createAnalysisExecution( + null, + parameters.provider === "second" ? secondProvider : firstProvider, + { limitations: ["same source caveat"] }, + ), + ), + }, + async (observed) => { + await observed.execute("list_strings", { provider: "first" }); + await observed.execute("list_strings", { provider: "first" }); + await observed.execute("list_strings", { provider: "second" }); + await observed.execute("list_procedures", { provider: "first" }); + return ok({ count: 0 }); + }, + ); + + expect(result).toEqual( + ok({ + result: { count: 0 }, + limitations: [ + "First (first 1) list_procedures: same source caveat", + "First (first 1) list_strings: same source caveat", + "Second (second 1) list_strings: same source caveat", + ], + }), ); }); diff --git a/src/application/WorkflowEvidence.ts b/src/application/WorkflowEvidence.ts index 5bbd75455f..599a5de290 100644 --- a/src/application/WorkflowEvidence.ts +++ b/src/application/WorkflowEvidence.ts @@ -23,7 +23,11 @@ import { import type { JsonValue } from "../domain/jsonValue.js"; import type { UnknownRegistryPort } from "./investigation/InvestigationRecordPort.js"; import { UnknownRegistryError } from "../domain/unknownRegistryError.js"; -import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js"; +import { + renderSourceLimitations, + sourceLimitationKey, + type SourceLimitation, +} from "./SourceLimitations.js"; /** Retain the limits of exactly the observations used by one composed call. */ export const executeComposedWorkflow = async ( @@ -37,21 +41,26 @@ export const executeComposedWorkflow = async ( AnalysisError > > => { - const limitations = new Set(); + const limitations = new Map(); const result = await execute({ execute: async (operation, parameters, options) => { const execution = await analysis.execute(operation, parameters, options); if (execution.ok) - execution.value.limitations.forEach((limitation) => - limitations.add(limitation), - ); + for (const limitation of execution.value.limitations) { + const fact = { + provider: execution.value.provider, + operation, + limitation, + } satisfies SourceLimitation; + limitations.set(sourceLimitationKey(fact), fact); + } return execution; }, }); if (!result.ok) return result; return ok({ result: result.value, - limitations: [...limitations].sort(compareUnicodeCodePoints), + limitations: renderSourceLimitations([...limitations.values()]), }); }; diff --git a/src/application/native/NativeCallRoutes.ts b/src/application/native/NativeCallRoutes.ts index affbfaf290..a4ba354e3d 100644 --- a/src/application/native/NativeCallRoutes.ts +++ b/src/application/native/NativeCallRoutes.ts @@ -9,18 +9,13 @@ import { referenceKindSchema, } from "../../domain/hopperValues.js"; import { AnalysisOutputError } from "../../domain/analysisErrorCore.js"; - -export interface NativeCallRouteLimitation { - readonly provider: ProviderIdentity; - readonly operation: "procedure_references" | "procedure_callees"; - readonly limitation: string; -} +import type { SourceLimitation } from "../SourceLimitations.js"; const limitationsFrom = ( - operation: NativeCallRouteLimitation["operation"], + operation: SourceLimitation["operation"], provider: ProviderIdentity, limitations: readonly string[], -): NativeCallRouteLimitation[] => +): SourceLimitation[] => limitations.map((limitation) => ({ provider, operation, limitation })); const referencesSchema = z.object({ @@ -85,7 +80,7 @@ export const readNativeCallRoutes = async ( reason: string; provider: ProviderIdentity; }> = []; - let limitations: NativeCallRouteLimitation[] = []; + let limitations: SourceLimitation[] = []; if (execution.ok) { limitations = limitationsFrom( "procedure_references", diff --git a/src/application/native/NativeUiActionTrace.ts b/src/application/native/NativeUiActionTrace.ts index 06d1fe4d39..c039f85cbd 100644 --- a/src/application/native/NativeUiActionTrace.ts +++ b/src/application/native/NativeUiActionTrace.ts @@ -13,10 +13,11 @@ import { import { nativeDispatchMetadataResultSchema } from "../../domain/native/objcSwiftMetadata.js"; import { inspectNativeDispatch } from "./NativeDispatchMetadataInspection.js"; import { jsonValueSchema } from "../../domain/jsonValue.js"; +import { readNativeCallRoutes } from "./NativeCallRoutes.js"; import { - readNativeCallRoutes, - type NativeCallRouteLimitation, -} from "./NativeCallRoutes.js"; + renderSourceLimitations, + type SourceLimitation, +} from "../SourceLimitations.js"; import { err, ok } from "../../domain/result.js"; import { AnalysisOutputError } from "../../domain/analysisErrorCore.js"; import { projectAnalysisError } from "../../domain/analysisErrorProjection.js"; @@ -405,7 +406,7 @@ const addDirectCallees = async (input: { let examined = 0; let omitted = 0; let truncated = false; - const sourceLimitations: NativeCallRouteLimitation[] = []; + const sourceLimitations: SourceLimitation[] = []; while (queueIndex < queue.length) { const current = queue[queueIndex++]; if (current === undefined || visited.has(current.address)) continue; @@ -554,29 +555,6 @@ const addDirectCallees = async (input: { }; }; -const renderSourceLimitations = ( - limitations: readonly NativeCallRouteLimitation[], -): string[] => { - const seen = new Set(); - const rendered: string[] = []; - for (const { provider, operation, limitation } of limitations) { - const provenance = JSON.stringify([ - provider.id, - provider.name, - provider.version, - operation, - limitation, - ]); - if (seen.has(provenance)) continue; - seen.add(provenance); - const version = provider.version === null ? "" : ` ${provider.version}`; - rendered.push( - `${provider.name} (${provider.id}${version}) ${operation}: ${limitation}`, - ); - } - return rendered; -}; - const graphNodeDepths = ( start: string, nodes: readonly z.infer< diff --git a/src/application/native/NativeValueTrace.test.ts b/src/application/native/NativeValueTrace.test.ts index 56ed3761df..7688c9cc94 100644 --- a/src/application/native/NativeValueTrace.test.ts +++ b/src/application/native/NativeValueTrace.test.ts @@ -291,31 +291,32 @@ const nativeFlowDossier = ( }); }; -const callBindingAnalysis = ( - callerAddress: string, - calleeAddress: string, - caller: ReturnType, - callee: ReturnType, -): AnalysisOperationPort => ({ +const callBindingAnalysis = (input: { + readonly callerAddress: string; + readonly calleeAddress: string; + readonly caller: ReturnType; + readonly callee: ReturnType; + readonly targetLimitations?: readonly string[]; +}): AnalysisOperationPort => ({ execute: async (operation, parameters) => { if (operation === "resolve_native_call_targets") return ok( createAnalysisExecution( { call_site: String(parameters.address), - procedure: calleeAddress, + procedure: input.calleeAddress, status: "direct", mechanism: "direct", targets: [ { - address: calleeAddress, - procedure: calleeAddress, + address: input.calleeAddress, + procedure: input.calleeAddress, status: "direct", basis: "provider-reference", references: [], }, ], - limitations: [], + limitations: [...(input.targetLimitations ?? [])], }, provider, { subject }, @@ -324,13 +325,15 @@ const callBindingAnalysis = ( if (operation !== "analyze_function") throw new Error(`Unexpected native trace operation: ${operation}`); if ( - parameters.procedure !== callerAddress && - parameters.procedure !== calleeAddress + parameters.procedure !== input.callerAddress && + parameters.procedure !== input.calleeAddress ) throw new Error(`Unexpected procedure: ${String(parameters.procedure)}`); return ok( createAnalysisExecution( - parameters.procedure === callerAddress ? caller : callee, + parameters.procedure === input.callerAddress + ? input.caller + : input.callee, provider, { subject }, ), @@ -430,6 +433,40 @@ describe("native value failed-callee traversal", () => { }); describe("native value call-binding fan-out", () => { + it("retains limitations embedded in resolved call-target results", async () => { + const callerAddress = "0x1000"; + const calleeAddress = "0x2000"; + const base = functionDossierSchema.parse(ghidraFunctionDossier()); + const caller = nativeFlowDossier(base, { + address: callerAddress, + name: "caller", + operations: [node("call", callerAddress, "CALL")], + parameters: [], + }); + const callee = nativeFlowDossier(base, { + address: calleeAddress, + name: "callee", + operations: [], + parameters: [], + }); + const caveat = "Only statically resolved call sites are included."; + const result = await traceNativeValues( + callBindingAnalysis({ + callerAddress, + calleeAddress, + caller, + callee, + targetLimitations: [caveat], + }), + { procedure: callerAddress, max_functions: 2 }, + ); + + if (!result.ok) throw result.error; + expect(nativeValueTraceSchema.parse(result.value).limitations).toContain( + `Fixture (fixture 1) resolve_native_call_targets: ${caveat}`, + ); + }); + it("preserves binding order and edge-budget behavior for repeated callees", async () => { const callCount = 128; const callerAddress = "0x1000"; @@ -455,12 +492,12 @@ describe("native value call-binding fan-out", () => { operations: [node("return", calleeAddress, "RETURN")], parameters: [{ ordinal: 0, name: "value", data_type: "/int" }], }); - const fanoutAnalysis = callBindingAnalysis( + const fanoutAnalysis = callBindingAnalysis({ callerAddress, calleeAddress, caller, callee, - ); + }); const expectedEdges = [ ...Array.from({ length: callCount }, (_, index) => ({ source: `${callerAddress}/call-${index}`, @@ -803,7 +840,12 @@ describe("native value byte-bound call bindings", () => { ], }); const result = await traceNativeValues( - callBindingAnalysis("0x1000", "0x2000", caller, callee), + callBindingAnalysis({ + callerAddress: "0x1000", + calleeAddress: "0x2000", + caller, + callee, + }), { procedure: "0x1000" }, ); if (!result.ok) throw result.error; diff --git a/src/application/native/NativeValueTrace.ts b/src/application/native/NativeValueTrace.ts index fc304201d4..f7ff79200c 100644 --- a/src/application/native/NativeValueTrace.ts +++ b/src/application/native/NativeValueTrace.ts @@ -13,6 +13,10 @@ import { err, ok } from "../../domain/result.js"; import type { AnalysisOperationPort } from "../AnalysisProvider.js"; import type { EnhancedResult } from "../EnhancedToolTypes.js"; import { jsonParts } from "../../domain/jsonSerialization.js"; +import { + renderSourceLimitations, + type SourceLimitation, +} from "../SourceLimitations.js"; const NODE_PAYLOAD_BYTES = 8 * 1024 * 1024; @@ -27,6 +31,7 @@ export const traceNativeValues = async ( const edges: typeof nativeValueTraceSchema._output.edges = []; const procedures: typeof nativeValueTraceSchema._output.procedures = []; const unknowns: typeof nativeValueTraceSchema._output.unknowns = []; + const sourceLimitations: SourceLimitation[] = []; const callBindings: { caller: string; callee: string; @@ -280,6 +285,13 @@ export const traceNativeValues = async ( "Invalid static call resolution", ), ); + sourceLimitations.push( + ...targets.data.limitations.map((limitation) => ({ + provider: resolved.value.provider, + operation: "resolve_native_call_targets" as const, + limitation, + })), + ); if (targets.data.targets.length === 0) unknowns.push({ procedure: address, @@ -424,6 +436,7 @@ export const traceNativeValues = async ( "Static decompiler def-use graphs cover recovered block members; when membership is unavailable they exclude operations marked dead. Logical CALL argument positions bind to recovered HighParam ordinals and RETURN values bind to recovered CALL outputs; these are decompiler-derived dependencies, not original source or runtime observations. Missing parameters, variadic arguments and alias dependencies remain unknown.", "LOAD and STORE are memory operations; object fields, persistent state and RNG semantics are not inferred from names or pseudocode.", "Traversal caps depth, decompilations, call-site resolutions, nodes, edges and node payloads (8 MiB). Complete serialized output is bounded to 32 MiB. Pagination reruns analysis and returns edges whose source is on the node page; endpoints outside the page retain stable identities.", + ...renderSourceLimitations(sourceLimitations), ], }); if (Buffer.byteLength(JSON.stringify(result)) > 32 * 1024 * 1024) diff --git a/tests/boundary/mcp/enhancedTools.callPathsAndBatches.test.ts b/tests/boundary/mcp/enhancedTools.callPathsAndBatches.test.ts index 8dee037b9d..24c2858019 100644 --- a/tests/boundary/mcp/enhancedTools.callPathsAndBatches.test.ts +++ b/tests/boundary/mcp/enhancedTools.callPathsAndBatches.test.ts @@ -124,8 +124,8 @@ describe("enhanced MCP graph caveats", () => { limitations: [ "Artifact identity is unavailable for this observation.", "Derived by an REA composed workflow.", - caveat, - leafCaveat, + `Fixture analysis provider (fixture 1) procedure_callees: ${caveat}`, + `Fixture analysis provider (fixture 1) procedure_callees: ${leafCaveat}`, ], normalized_result: { graph: { @@ -153,9 +153,6 @@ describe("enhanced MCP graph caveats", () => { const normalized = JSON.stringify(evidence.normalized_result); for (const item of [caveat, leafCaveat]) { expect(normalized.split(item)).toHaveLength(2); - expect( - evidence.limitations.filter((limit) => limit === item), - ).toHaveLength(1); } const trace = await client.callTool({ @@ -172,7 +169,11 @@ describe("enhanced MCP graph caveats", () => { }); const traceEvidence = parseEvidence(trace.structuredContent); expect( - traceEvidence.limitations.filter((item) => item === caveat), + traceEvidence.limitations.filter( + (item) => + item === + `Fixture analysis provider (fixture 1) procedure_callees: ${caveat}`, + ), ).toHaveLength(1); expect(JSON.stringify(traceEvidence)).not.toContain(leafCaveat); }); diff --git a/tests/boundary/mcp/enhancedTools.limitations.test.ts b/tests/boundary/mcp/enhancedTools.limitations.test.ts index bf05bb20c8..4188f78c65 100644 --- a/tests/boundary/mcp/enhancedTools.limitations.test.ts +++ b/tests/boundary/mcp/enhancedTools.limitations.test.ts @@ -31,7 +31,10 @@ it("retains provider coverage caveats in a composed MCP overview without repeate expect(evidence.limitations).toEqual([ "Artifact identity is unavailable for this observation.", "Derived by an REA composed workflow.", - limitation, + `Fixture (fixture 1) current_document: ${limitation}`, + `Fixture (fixture 1) list_procedures: ${limitation}`, + `Fixture (fixture 1) list_segments: ${limitation}`, + `Fixture (fixture 1) list_strings: ${limitation}`, ]); expect(evidence.normalized_result).toMatchObject({ document: "fixture",