From f7dc5489b3cb8a7018136194392b2e8fdcfacaf5 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sat, 7 Nov 2026 23:33:41 +0800 Subject: [PATCH] feat(kuna): preserve stored pointer references (#8851) * feat(kuna): verify release 1.990 * feat(kuna): preserve stored pointer references * fix(kuna): group image-wide reference coverage gaps * fix(evidence): retain producer-required xref observations * fix(mcp): clarify xref address page scope * fix(kuna): preserve reference target aliases --------- Co-authored-by: Cursor Agent --- docs/mcp-contracts.md | 2 + src/application/AnalysisProvider.ts | 28 +++ src/application/DirectAnalysis.ts | 6 +- src/application/EnhancedLiteralTracing.ts | 29 +++ src/application/WorkflowAnalysisProfile.ts | 2 +- .../binary/AnalysisSnapshotCache.test.ts | 99 +++++++- .../binary/AnalysisSnapshotCache.ts | 31 ++- src/application/binary/BinarySession.ts | 16 +- .../JavaScriptArtifactGraphBuilder.ts | 12 +- .../analysisView/nativeObservationView.ts | 7 + src/domain/native/literalFeatureTrace.ts | 12 + src/kuna/KunaProvider.ts | 7 +- src/kuna/KunaReferenceFiles.ts | 78 ++++++- src/kuna/KunaSessionClient.ts | 68 +++++- ...enhancedTools.traceFeatureCoverage.test.ts | 100 ++++++++ .../mcp/nativeObservationViewsMcp.test.ts | 4 + .../providers/kuna/kunaReferenceFiles.test.ts | 220 ++++++++++++++++++ 17 files changed, 692 insertions(+), 29 deletions(-) diff --git a/docs/mcp-contracts.md b/docs/mcp-contracts.md index 90ab1c0bd7..014ee633b7 100644 --- a/docs/mcp-contracts.md +++ b/docs/mcp-contracts.md @@ -318,6 +318,8 @@ Retained `xrefs` Evidence also supports `native-observation` address pages and default and does not accept `detail`; when its MCP response exceeds the budget, follow the returned continuation to page the retained result. These addresses remain untyped; paging does not add reference kinds or establish completeness. +The page tells callers that any additional provider detail recorded in the parent +requires retrieving or exporting the complete Evidence. These views reference complete source context through `source_context.digest` and caveat counts. Read it using the same parent with diff --git a/src/application/AnalysisProvider.ts b/src/application/AnalysisProvider.ts index 0eff579021..8b3ffbca65 100644 --- a/src/application/AnalysisProvider.ts +++ b/src/application/AnalysisProvider.ts @@ -82,6 +82,22 @@ export interface AnalysisExecution extends AnalysisObservation { * output. */ readonly referencesTruncated?: boolean; + /** The producer needs this observation retained as source Evidence for composed workflows. */ + readonly requiresSourceEvidence?: true; + /** Known reference-discovery gaps; absent means coverage was not reported. */ + readonly referenceCoverageGaps?: readonly { + readonly code: string; + readonly scope: "image" | "observation"; + readonly description: string; + }[]; + /** Typed source facts for this live xref observation; absent means unreported. */ + readonly referenceClassifications?: readonly { + readonly sourceAddress: string; + readonly targetAddress: string; + readonly kind: "stored_pointer"; + readonly provenance: string; + readonly width: number; + }[]; /** * Sibling operations known to be unavailable at the provider level when this * operation answered from a standalone or fallback path. @@ -97,6 +113,9 @@ interface CreateAnalysisExecutionOptions { readonly limitations?: readonly string[]; readonly relationshipCoverage?: RelationshipCoverage; readonly referencesTruncated?: boolean; + readonly requiresSourceEvidence?: true; + readonly referenceClassifications?: AnalysisExecution["referenceClassifications"]; + readonly referenceCoverageGaps?: AnalysisExecution["referenceCoverageGaps"]; readonly siblingUnavailability?: AnalysisCapabilityUnavailableError; readonly locations?: readonly EvidenceLocation[]; readonly sourceEvidence?: readonly Evidence[]; @@ -156,6 +175,15 @@ export function createAnalysisExecution( ...(options.referencesTruncated === undefined ? {} : { referencesTruncated: options.referencesTruncated }), + ...(options.requiresSourceEvidence === undefined + ? {} + : { requiresSourceEvidence: options.requiresSourceEvidence }), + ...(options.referenceCoverageGaps === undefined + ? {} + : { referenceCoverageGaps: options.referenceCoverageGaps }), + ...(options.referenceClassifications === undefined + ? {} + : { referenceClassifications: options.referenceClassifications }), ...(options.siblingUnavailability === undefined ? {} : { siblingUnavailability: options.siblingUnavailability }), diff --git a/src/application/DirectAnalysis.ts b/src/application/DirectAnalysis.ts index 08325bcf97..4bb668ce2b 100644 --- a/src/application/DirectAnalysis.ts +++ b/src/application/DirectAnalysis.ts @@ -25,8 +25,10 @@ import { retainAnalysisResultSources, type AnalysisResultWithSources, } from "./investigation/recordEvidenceSources.js"; -import { resolveInvestigationEvidenceClosure } from "./investigation/InvestigationEvidence.js"; -import { createInvestigationEvidenceClosureChecker } from "./investigation/InvestigationEvidence.js"; +import { + resolveInvestigationEvidenceClosure, + createInvestigationEvidenceClosureChecker, +} from "./investigation/InvestigationEvidence.js"; import type { Evidence } from "../domain/evidence.js"; import type { NativeToolName } from "../contracts/native/nativeToolContracts.js"; import type { EnhancedToolName } from "../contracts/enhancedInputs.js"; diff --git a/src/application/EnhancedLiteralTracing.ts b/src/application/EnhancedLiteralTracing.ts index d46c241f5f..db03757a40 100644 --- a/src/application/EnhancedLiteralTracing.ts +++ b/src/application/EnhancedLiteralTracing.ts @@ -137,6 +137,7 @@ const traceReferences = async ( const tracedTargets = new Set(); // A source address is resolved once however many matches reference it. const containing = new Map(); + const imageCoverageGaps = new Set(); for (const match of request.matches) { if (tracedTargets.has(match.address)) continue; tracedTargets.add(match.address); @@ -154,6 +155,17 @@ const traceReferences = async ( `References to ${match.address} were truncated by the provider; additional references may exist.`, ); } + for (const gap of observed.value.referenceCoverageGaps ?? []) { + if (gap.scope === "observation") { + residual.push(`References to ${match.address}: ${gap.description}`); + } else { + const key = JSON.stringify([observed.value.provider.id, gap.code]); + if (!imageCoverageGaps.has(key)) { + imageCoverageGaps.add(key); + residual.push(gap.description); + } + } + } const sources = observed.value.result; if (!Array.isArray(sources)) return err( @@ -162,6 +174,20 @@ const traceReferences = async ( "provider returned a non-array result", ), ); + const classifications = new Map< + string, + { kind: "stored_pointer"; provenance: string; width: number }[] + >(); + for (const fact of observed.value.referenceClassifications ?? []) { + if (fact.targetAddress !== match.address) continue; + const values = classifications.get(fact.sourceAddress) ?? []; + values.push({ + kind: fact.kind, + provenance: fact.provenance, + width: fact.width, + }); + classifications.set(fact.sourceAddress, values); + } for (const source of sources) { if (typeof source !== "string") return err( @@ -211,6 +237,9 @@ const traceReferences = async ( target_address: match.address, source_address: source, containing_procedure: containingProcedure, + ...(classifications.has(source) + ? { source_classifications: classifications.get(source)! } + : {}), }); } } diff --git a/src/application/WorkflowAnalysisProfile.ts b/src/application/WorkflowAnalysisProfile.ts index 5c54417703..49debe8460 100644 --- a/src/application/WorkflowAnalysisProfile.ts +++ b/src/application/WorkflowAnalysisProfile.ts @@ -21,7 +21,7 @@ export const workflowAnalysisProfile = ( : operation === "batch_decompile" ? { workflow_revision: 4 } : operation === "trace_feature" - ? { workflow_revision: 4 } + ? { workflow_revision: 8 } : operation === "get_call_graph" ? { workflow_revision: 3 } : operation === "trace_native_values" diff --git a/src/application/binary/AnalysisSnapshotCache.test.ts b/src/application/binary/AnalysisSnapshotCache.test.ts index 2858552cdf..c6de868fbb 100644 --- a/src/application/binary/AnalysisSnapshotCache.test.ts +++ b/src/application/binary/AnalysisSnapshotCache.test.ts @@ -864,6 +864,73 @@ describe("document-scoped snapshot admission", () => { }); }); +it("retains producer-required xrefs without replaying a filesystem-writing query", () => { + const descriptor: CapabilityDescriptor = { + operation: "xrefs", + provider: ANALYSIS_SNAPSHOT_PROVIDER, + available: true, + reason: null, + effects: { + mutatesArtifact: false, + launchesProcess: true, + mayShowUi: false, + mayAccessNetwork: false, + mayWriteFilesystem: false, + changesPermissions: false, + requiresRoot: false, + }, + limitations: [], + }; + const xrefs = descriptor; + expect( + isSourceEvidenceRetainable("xrefs", xrefs, { address: "0x1000" }), + ).toBe(false); + expect(isSnapshotCacheable("xrefs", xrefs, { address: "0x1000" })).toBe( + false, + ); + expect(isSourceEvidenceRetainable("xrefs", xrefs, {})).toBe(false); + const writingXrefs = { + ...xrefs, + effects: { ...xrefs.effects, mayWriteFilesystem: true }, + }; + expect( + isSourceEvidenceRetainable("xrefs", writingXrefs, { address: "0x1000" }), + ).toBe(false); + expect( + isSourceEvidenceRetainable( + "xrefs", + writingXrefs, + { address: "0x1000" }, + { requiresSourceEvidence: true }, + ), + ).toBe(true); + expect( + isSourceEvidenceRetainable( + "xrefs", + writingXrefs, + {}, + { requiresSourceEvidence: true }, + ), + ).toBe(false); + expect( + isSourceEvidenceRetainable( + "xrefs", + { + ...writingXrefs, + effects: { ...writingXrefs.effects, mutatesArtifact: true }, + }, + { address: "0x1000" }, + { requiresSourceEvidence: true }, + ), + ).toBe(false); + expect( + isSnapshotCacheable("xrefs", writingXrefs, { + address: "0x1000", + document: "fixture", + }), + ).toBe(false); +}); + describe("analysis snapshot cache partitioning", () => { it.each<{ operation: Exclude; @@ -1490,13 +1557,43 @@ describe("executions that report truncated references", () => { }); }); +it("does not replay references when the snapshot would lose their classifications", () => { + const cache = new AnalysisSnapshotCache(); + const parameters = { address: "0x1000", document: "fixture" }; + cache.record({ + target: ANALYSIS_SNAPSHOT_TARGET, + profile: ANALYSIS_SNAPSHOT_PROFILE, + operation: "xrefs", + parameters, + execution: createAnalysisExecution(["0x2000"], ANALYSIS_SNAPSHOT_PROVIDER, { + referenceClassifications: [ + { + sourceAddress: "0x2000", + targetAddress: "0x1000", + kind: "stored_pointer", + provenance: "relocation", + width: 8, + }, + ], + }), + }); + expect( + cache.lookup( + ANALYSIS_SNAPSHOT_TARGET, + ANALYSIS_SNAPSHOT_PROFILE, + "xrefs", + parameters, + ), + ).toBeUndefined(); +}); + it("revises the trace_feature workflow profile so earlier results are not replayed", () => { const profile = ANALYSIS_SNAPSHOT_PROFILE; expect( workflowAnalysisProfile(profile, "trace_feature").parameters .workflow_revision, - ).toBe(4); + ).toBe(8); expect(workflowAnalysisProfile(profile, "trace_feature")).not.toEqual( workflowAnalysisProfile(profile), diff --git a/src/application/binary/AnalysisSnapshotCache.ts b/src/application/binary/AnalysisSnapshotCache.ts index d0b5e1883a..e2f381e0c4 100644 --- a/src/application/binary/AnalysisSnapshotCache.ts +++ b/src/application/binary/AnalysisSnapshotCache.ts @@ -117,25 +117,30 @@ const DOCUMENTLESS_SOURCE_EVIDENCE_OPERATIONS: ReadonlySet = /** * Whether a source observation is safe to retain as Evidence. - * A missing document selector blocks snapshot replay. Only caller and callee - * walks stay in the ledger without one. + * A missing document selector blocks snapshot replay. A producer can require + * its observation as Evidence independently of whether it can be replayed. */ export const isSourceEvidenceRetainable = ( operation: AnalysisOperation, descriptor: CapabilityDescriptor | undefined, parameters: Readonly>, - context?: ExecutionOptions["snapshotContext"], + options: { + readonly snapshotContext?: ExecutionOptions["snapshotContext"]; + readonly requiresSourceEvidence?: boolean; + } = {}, ): descriptor is CapabilityDescriptor => !STATEFUL_OPERATIONS.has(operation) && !RESOURCE_SCOPED_OPERATIONS.has(operation) && (!DOCUMENT_SCOPED_OPERATIONS.has(operation) || typeof parameters.document === "string" || DOCUMENTLESS_SOURCE_EVIDENCE_OPERATIONS.has(operation) || - isOneShotPseudocode(operation, descriptor, context)) && + options.requiresSourceEvidence === true || + isOneShotPseudocode(operation, descriptor, options.snapshotContext)) && (!CURSOR_DEFAULT_OPERATIONS.has(operation) || typeof parameters.address === "string") && descriptor?.effects.mutatesArtifact === false && - (descriptor.cachePolicy === "snapshot" || + (options.requiresSourceEvidence === true || + descriptor.cachePolicy === "snapshot" || descriptor.effects.mayWriteFilesystem === false) && descriptor.effects.changesPermissions === false; @@ -146,7 +151,9 @@ export const isSnapshotCacheable = ( parameters: Readonly>, context?: ExecutionOptions["snapshotContext"], ): descriptor is CapabilityDescriptor => - isSourceEvidenceRetainable(operation, descriptor, parameters, context) && + isSourceEvidenceRetainable(operation, descriptor, parameters, { + snapshotContext: context, + }) && (replayDocumentSelected(operation, parameters) || isOneShotPseudocode(operation, descriptor, context)) && descriptor.cachePolicy !== "live"; @@ -589,9 +596,15 @@ export class AnalysisSnapshotCache { ): void { const { target, profile, operation, parameters, execution } = input; this.select(target, profile); - // The snapshot format does not carry reference truncation; a replay would - // present a capped read as complete, so it is not recorded. - if (execution.referencesTruncated === true) return; + // The snapshot format does not carry reference truncation or stored-pointer + // classifications. Replaying either would present a capped or untyped read + // as the live observation. + if ( + execution.referencesTruncated === true || + execution.referenceClassifications !== undefined || + execution.referenceCoverageGaps !== undefined + ) + return; let sourceBinding: SnapshotReplayBinding | undefined; if ((execution.sourceEvidence?.length ?? 0) > 0) { if (execution.evidenceId === undefined || lookup === undefined) return; diff --git a/src/application/binary/BinarySession.ts b/src/application/binary/BinarySession.ts index 75daea3ecf..404b3bb3bc 100644 --- a/src/application/binary/BinarySession.ts +++ b/src/application/binary/BinarySession.ts @@ -1539,12 +1539,10 @@ export class BinarySession } if ( execution.ok && - isSourceEvidenceRetainable( - name, - capability, - arguments_, - options?.snapshotContext, - ) && + isSourceEvidenceRetainable(name, capability, arguments_, { + snapshotContext: options?.snapshotContext, + requiresSourceEvidence: execution.value.requiresSourceEvidence === true, + }) && profile !== undefined ) { const evidence = createAnalysisExecutionEvidence( @@ -1561,7 +1559,11 @@ export class BinarySession "observed", ); const recorded = this.recordEvidence(evidence); - if (!recorded.ok) return recorded; + if (!recorded.ok) { + // The provider finished. A full ledger must not discard that observation. + recorded.error.retainPartialObservation(evidence); + return recorded; + } const retained = { ...execution.value, evidenceId: evidence.evidence_id }; if (cacheable && execution.value.cachePolicy !== "live") this.recordSnapshot({ diff --git a/src/application/javascript/JavaScriptArtifactGraphBuilder.ts b/src/application/javascript/JavaScriptArtifactGraphBuilder.ts index f5d6a42466..e57b943528 100644 --- a/src/application/javascript/JavaScriptArtifactGraphBuilder.ts +++ b/src/application/javascript/JavaScriptArtifactGraphBuilder.ts @@ -1,5 +1,10 @@ import type { ElectronBoundarySummary } from "../../domain/javascript/javascriptApplicationAnalysis.js"; -import { electronSemanticsState } from "./ElectronBoundaryAnalysis.js"; +import { + electronSemanticsState, + createElectronIpcContext, + summarizeElectronBoundaries, + type ElectronIpcContext, +} from "./ElectronBoundaryAnalysis.js"; import { ELECTRON_IDENTITY_LIMITATION } from "../../domain/javascript/javascriptElectronMemberWrites.js"; import type { ArtifactInventorySnapshot } from "../../domain/artifactInventorySnapshot.js"; import { @@ -47,11 +52,6 @@ import { } from "./JavaScriptArtifactGraphStructure.js"; import { addJavaScriptBundlerNodes } from "./JavaScriptArtifactGraphBundlers.js"; import { addElectronBoundaries } from "./ElectronBoundaryGraph.js"; -import { - createElectronIpcContext, - summarizeElectronBoundaries, - type ElectronIpcContext, -} from "./ElectronBoundaryAnalysis.js"; /** * Build a validated application graph with cooperative immutable ownership. diff --git a/src/domain/analysisView/nativeObservationView.ts b/src/domain/analysisView/nativeObservationView.ts index ee4530adc3..226ea044ff 100644 --- a/src/domain/analysisView/nativeObservationView.ts +++ b/src/domain/analysisView/nativeObservationView.ts @@ -30,6 +30,10 @@ import { } from "./nativeObservationSchemas.js"; import { projectNativeGraphRows } from "./nativeGraphView.js"; +/** What an address page leaves in the complete parent, when recorded. */ +export const XREFS_ADDRESS_PAGE_LIMITATION = + "This page contains source addresses only, not reference kinds or provenance. Retrieve or export the complete parent Evidence for any additional provider details it recorded."; + const readAddresses = createAnalysisViewResultReader(nativeAddressListSchema); const readAddress = createAnalysisViewResultReader( nativeObservationSourceSchemas.procedure_address, @@ -347,6 +351,9 @@ export const projectNativeObservationView = ( const { total, ...payload } = page; const observation = { ...common, + ...(source.value.operation === "xrefs" + ? { unknowns: [...common.unknowns, XREFS_ADDRESS_PAGE_LIMITATION] } + : {}), ...payload, summary: { observations: total }, coverage: pageViewCoverage(selection.offset, page.items.rows.length, total), diff --git a/src/domain/native/literalFeatureTrace.ts b/src/domain/native/literalFeatureTrace.ts index 88988d90e7..e1aed4d2b3 100644 --- a/src/domain/native/literalFeatureTrace.ts +++ b/src/domain/native/literalFeatureTrace.ts @@ -7,6 +7,18 @@ export const literalFeatureReferenceSchema = z.object({ target_address: z.string(), source_address: z.string(), containing_procedure: traceContainingProcedureSchema, + source_classifications: z + .array( + z.strictObject({ + kind: z.literal("stored_pointer"), + provenance: z.string(), + width: z.int().positive(), + }), + ) + .exactOptional() + .describe( + "Provider-reported source classifications; omission means none were supplied. The list need not exhaust all kinds.", + ), }); /** Complete result of a native literal feature trace. */ diff --git a/src/kuna/KunaProvider.ts b/src/kuna/KunaProvider.ts index c7aba09c46..d619893984 100644 --- a/src/kuna/KunaProvider.ts +++ b/src/kuna/KunaProvider.ts @@ -273,7 +273,12 @@ export class KunaProvider implements AnalysisProviderCandidate { bodies: false, edge_kind: "call", }, - xrefs: { command: "xrefs", direction: "to", exact_address: true }, + xrefs: { + command: "xrefs", + direction: "to", + exact_address: true, + stored_pointers: true, + }, strings: { command: "strings", encoding: "all", diff --git a/src/kuna/KunaReferenceFiles.ts b/src/kuna/KunaReferenceFiles.ts index 832d8eda38..5d52b881ed 100644 --- a/src/kuna/KunaReferenceFiles.ts +++ b/src/kuna/KunaReferenceFiles.ts @@ -224,6 +224,32 @@ const xrefRow = z.object({ to_function: xrefFunction.nullable(), instruction: z.string(), }); +const xrefPointer = z.object({ + kind: z.literal("pointer"), + from_address: address, + from_address_hex: address, + from_name: z.string().nullable(), + to_address: address, + to_address_hex: address, + to_name: z.string().nullable(), + width: z.int().positive(), + source: z.string().min(1), +}); +const xrefWalkCoverage = z.object({ + decoded: z.boolean(), + section: z.string().nullable(), + code_section: z.boolean().nullable(), + executable: z.boolean().nullable(), + unwalked: z.array( + z.object({ + name: z.string().nullable(), + address, + address_hex: address, + section: z.string(), + }), + ), + notes: z.array(z.string()), +}); const xrefsDocument = z.object({ binary: z.string(), direction: z.literal("to"), @@ -235,6 +261,10 @@ const xrefsDocument = z.object({ aliases: z.array(xrefFunction), }), xrefs: z.array(z.unknown()), + coverage: xrefWalkCoverage.optional(), + data_refs_coverage: z.string().optional(), + data_refs_count: count.optional(), + data_refs: z.array(xrefPointer).optional(), }); export interface KunaIncomingReferences { @@ -243,6 +273,15 @@ export interface KunaIncomingReferences { readonly bytes: number; readonly rows: number; readonly excludedAliasRows: number; + readonly targetAliases: readonly { + readonly name: string; + readonly address: string; + }[]; + readonly walkCoverage: z.infer | null; + readonly pointerCoverage: string | null; + readonly pointers: Array< + Omit, "from_address_hex" | "to_address_hex"> + > | null; } /** Code from incoming-reference admission, plus the address field when one failed. */ @@ -275,8 +314,34 @@ export const parseKunaIncomingReferences = ( throw new KunaDocumentError("xref_alias_hex"); aliases.add(alias.address); } + if ( + (document.data_refs === undefined) !== + (document.data_refs_count === undefined) + ) + throw new KunaDocumentError("xref_count"); + if ( + document.data_refs !== undefined && + document.data_refs.length !== document.data_refs_count + ) + throw new KunaDocumentError("xref_count"); + for (const item of document.coverage?.unwalked ?? []) + if (item.address !== item.address_hex) + throw new KunaDocumentError("xref_endpoints"); + for (const pointer of document.data_refs ?? []) { + if ( + pointer.from_address !== pointer.from_address_hex || + pointer.to_address !== pointer.to_address_hex + ) + throw new KunaDocumentError("xref_endpoints"); + if (!aliases.has(pointer.to_address)) + throw new KunaDocumentError("xref_target"); + } const addresses = new Set(); let excludedAliasRows = 0; + for (const pointer of document.data_refs ?? []) { + if (pointer.to_address === target) addresses.add(pointer.from_address); + else excludedAliasRows += 1; + } for (const raw of document.xrefs) { const row = xrefRow.parse(raw); if ( @@ -296,8 +361,19 @@ export const parseKunaIncomingReferences = ( } return ok({ addresses: [...addresses].sort(compareKunaAddresses), - rows: document.xrefs.length, + rows: document.xrefs.length + (document.data_refs?.length ?? 0), excludedAliasRows, + targetAliases: document.target.aliases.map(({ name, address }) => ({ + name, + address, + })), + walkCoverage: document.coverage ?? null, + pointerCoverage: document.data_refs_coverage ?? null, + pointers: + document.data_refs?.map( + ({ from_address_hex: _from, to_address_hex: _to, ...pointer }) => + pointer, + ) ?? null, bytes: Buffer.byteLength(text), sha256: createHash("sha256").update(text).digest("hex"), }); diff --git a/src/kuna/KunaSessionClient.ts b/src/kuna/KunaSessionClient.ts index cf50340eb8..b5a68c0592 100644 --- a/src/kuna/KunaSessionClient.ts +++ b/src/kuna/KunaSessionClient.ts @@ -178,8 +178,11 @@ interface Answer { readonly locations: readonly EvidenceLocation[]; readonly limitations: readonly string[]; readonly referenceSource?: JsonValue; + readonly requiresSourceEvidence?: true; readonly stringSource?: JsonValue; readonly relationshipCoverage?: RelationshipCoverage; + readonly referenceClassifications?: AnalysisExecution["referenceClassifications"]; + readonly referenceCoverageGaps?: AnalysisExecution["referenceCoverageGaps"]; readonly siblingUnavailability?: AnalysisCapabilityUnavailableError; } @@ -725,6 +728,15 @@ export class KunaSessionClient implements AnalysisClient { ...(answer.relationshipCoverage === undefined ? {} : { relationshipCoverage: answer.relationshipCoverage }), + ...(answer.requiresSourceEvidence === undefined + ? {} + : { requiresSourceEvidence: answer.requiresSourceEvidence }), + ...(answer.referenceCoverageGaps === undefined + ? {} + : { referenceCoverageGaps: answer.referenceCoverageGaps }), + ...(answer.referenceClassifications === undefined + ? {} + : { referenceClassifications: answer.referenceClassifications }), ...(answer.siblingUnavailability === undefined ? {} : { siblingUnavailability: answer.siblingUnavailability }), @@ -1005,11 +1017,59 @@ export class KunaSessionClient implements AnalysisClient { options.signal, ); if (!references.ok) return references; + const coverageGaps = [ + ...(references.value.walkCoverage !== null && + references.value.walkCoverage.unwalked.length > 0 + ? [ + { + code: "unwalked-code", + scope: "observation" as const, + description: + "Kuna reports unwalked code candidates whose outgoing references are not included. Their presence does not establish a reference to the selected target; the source observation identifies them.", + }, + ] + : []), + ...(references.value.pointerCoverage === "none" + ? [ + { + code: "pointer-relocations-unavailable", + scope: "image" as const, + description: + "Kuna reports no pointer-relocation coverage for this image; stored pointers may still exist.", + }, + ] + : []), + ]; return ok({ result: [...references.value.addresses], source: null, locations, - limitations, + limitations: [ + ...limitations, + ...(references.value.pointerCoverage === null + ? [] + : [ + "Kuna pointer references come from relocation metadata. The reported metadata source does not establish successful or complete enumeration.", + ]), + ...coverageGaps.map(({ description }) => description), + ], + ...(coverageGaps.length === 0 + ? {} + : { referenceCoverageGaps: coverageGaps }), + ...(references.value.pointers === null + ? {} + : { + referenceClassifications: references.value.pointers + .filter((pointer) => pointer.to_address === address) + .map((pointer) => ({ + sourceAddress: pointer.from_address, + targetAddress: pointer.to_address, + kind: "stored_pointer" as const, + provenance: pointer.source, + width: pointer.width, + })), + }), + requiresSourceEvidence: true, referenceSource: { kind: "kuna-xrefs", direction: "to", @@ -1018,6 +1078,12 @@ export class KunaSessionClient implements AnalysisClient { bytes: references.value.bytes, examined_rows: references.value.rows, excluded_alias_rows: references.value.excludedAliasRows, + target_aliases: references.value.targetAliases.map( + ({ name, address }) => ({ name, address }), + ), + walk_coverage: references.value.walkCoverage, + pointer_coverage: references.value.pointerCoverage, + pointers: references.value.pointers, }, }); } diff --git a/tests/boundary/mcp/enhancedTools.traceFeatureCoverage.test.ts b/tests/boundary/mcp/enhancedTools.traceFeatureCoverage.test.ts index 7b672f183b..b199ef8654 100644 --- a/tests/boundary/mcp/enhancedTools.traceFeatureCoverage.test.ts +++ b/tests/boundary/mcp/enhancedTools.traceFeatureCoverage.test.ts @@ -140,6 +140,106 @@ const trace = async (analysis: AnalysisOperationPort) => { return result.structuredContent as { normalized_result: unknown }; }; +it("reports known reference-discovery gaps without claiming truncation or a missing edge", async () => { + const baseline = provider(); + const gap = + "An unwalked candidate may have outgoing references; its presence does not establish a reference to the selected target."; + const output = await trace({ + execute: (operation, input, options) => + operation === "xrefs" && input.address === "0x10" + ? Promise.resolve( + ok( + createAnalysisExecution([], PROVIDER, { + referenceCoverageGaps: [ + { code: "unwalked", scope: "observation", description: gap }, + ], + }), + ), + ) + : baseline.execute(operation, input, options), + }); + expect(output.normalized_result).toMatchObject({ + truncated: false, + residual_unknowns: [`References to 0x10: ${gap}`], + references: [{ target_address: "0x11", source_address: "0x21" }], + }); +}); + +it("deduplicates an image-wide gap without merging target-specific gaps", async () => { + const baseline = provider(); + const output = await trace({ + execute: async (operation, input, options) => { + const result = await baseline.execute(operation, input, options); + if (operation !== "xrefs" || !result.ok) return result; + return ok({ + ...result.value, + referenceCoverageGaps: [ + { + code: "missing-relocations", + scope: "image", + description: "Image relocation coverage unavailable.", + }, + { + code: "unwalked", + scope: "observation", + description: "This observation has unwalked sources.", + }, + ], + }); + }, + }); + expect(output.normalized_result).toMatchObject({ + truncated: false, + residual_unknowns: [ + "Image relocation coverage unavailable.", + "References to 0x10: This observation has unwalked sources.", + "References to 0x11: This observation has unwalked sources.", + ], + }); +}); + +it("carries reported pointer classifications without inferring other reference kinds", async () => { + const baseline = provider(); + const output = await trace({ + execute: (operation, input, options) => + operation === "xrefs" && input.address === "0x10" + ? Promise.resolve( + ok( + createAnalysisExecution(["0x20"], PROVIDER, { + referenceClassifications: [ + { + sourceAddress: "0x20", + targetAddress: "0x10", + kind: "stored_pointer", + provenance: "chained-fixups", + width: 8, + }, + { + sourceAddress: "0x20", + targetAddress: "0x99", + kind: "stored_pointer", + provenance: "other", + width: 4, + }, + ], + }), + ), + ) + : baseline.execute(operation, input, options), + }); + expect(output.normalized_result).toMatchObject({ + references: [ + { + source_address: "0x20", + source_classifications: [ + { kind: "stored_pointer", provenance: "chained-fixups", width: 8 }, + ], + }, + { source_address: "0x21" }, + ], + }); +}); + it("preserves references and explicitly unknown owners without claiming truncation", async () => { const baseline = provider(); const output = await trace({ diff --git a/tests/boundary/mcp/nativeObservationViewsMcp.test.ts b/tests/boundary/mcp/nativeObservationViewsMcp.test.ts index e4222343d9..d7a76f8591 100644 --- a/tests/boundary/mcp/nativeObservationViewsMcp.test.ts +++ b/tests/boundary/mcp/nativeObservationViewsMcp.test.ts @@ -12,6 +12,7 @@ import { analysisViewResultSchema, inspectAnalysisViewInputSchema, } from "../../../src/domain/analysisView/analysisView.js"; +import { XREFS_ADDRESS_PAGE_LIMITATION } from "../../../src/domain/analysisView/nativeObservationView.js"; import { NATIVE_PRIMITIVE_OBSERVATION_OPERATIONS } from "../../../src/domain/native/nativeObservation.js"; import { connectGhidraMcp } from "./ghidraMcpHarness.js"; import { createMcpSchemaValidation } from "../../fixtures/mcpSchemaValidation.js"; @@ -138,6 +139,9 @@ it("delivers native primitive and graph summaries with exact retained parents an }, coverage: { total: 1, exhausted: true }, }); + expect( + analysisViewResultSchema.parse(page.normalized_result).unknowns, + ).toContain(XREFS_ADDRESS_PAGE_LIMITATION); const caveats = await call("inspect_analysis_view", { source, view: { kind: "native-context", facet: "limitations" }, diff --git a/tests/boundary/providers/kuna/kunaReferenceFiles.test.ts b/tests/boundary/providers/kuna/kunaReferenceFiles.test.ts index 44eeb9ddb2..d3aa72c27d 100644 --- a/tests/boundary/providers/kuna/kunaReferenceFiles.test.ts +++ b/tests/boundary/providers/kuna/kunaReferenceFiles.test.ts @@ -286,6 +286,226 @@ it("keeps exact endpoints, deduplicates sites and counts excluded forwarding ali }); }); +it("preserves pointer and walk coverage separately and rejects inconsistent pointer identity", () => { + const pointer = { + kind: "pointer", + from_address: "@16384", + from_address_hex: "0x4000", + from_name: "slot", + to_address: "@8192", + to_address_hex: "0x2000", + to_name: "callee", + width: 8, + source: "chained-fixups", + }; + const source = { + ...xrefs(), + coverage: { + decoded: true, + section: "__TEXT,__text", + code_section: true, + executable: true, + unwalked: [ + { + name: "middle", + address: "@20480", + address_hex: "0x5000", + section: "__TEXT,__const", + }, + ], + notes: ["middle was not walked"], + }, + data_refs_coverage: "chained-fixups", + data_refs_count: 1, + data_refs: [pointer], + }; + expect( + parseKunaIncomingReferences(wire(source), input, "0x2000"), + ).toMatchObject({ + ok: true, + value: { + addresses: ["0x1004", "0x4000"], + pointerCoverage: "chained-fixups", + pointers: [ + { + from_address: "0x4000", + to_address: "0x2000", + source: "chained-fixups", + }, + ], + walkCoverage: { unwalked: [{ address: "0x5000" }] }, + }, + }); + pointer.to_address_hex = "0x2001"; + expect( + parseKunaIncomingReferences(wire(source), input, "0x2000"), + ).toMatchObject({ + ok: false, + error: { code: "xref_endpoints" }, + }); +}); + +it("counts pointer rows excluded because they target a forwarding alias", () => { + const pointer = (from: number, to: number) => ({ + kind: "pointer", + from_address: `@${from}`, + from_address_hex: `0x${from.toString(16)}`, + from_name: null, + to_address: `@${to}`, + to_address_hex: `0x${to.toString(16)}`, + to_name: null, + width: 8, + source: "dynamic-relocations", + }); + const source = { + ...xrefs(), + data_refs_coverage: "dynamic-relocations", + data_refs_count: 2, + data_refs: [pointer(16384, 8192), pointer(16392, 12288)], + }; + expect( + parseKunaIncomingReferences(wire(source), input, "0x2000"), + ).toMatchObject({ + ok: true, + value: { + addresses: ["0x1004", "0x4000"], + rows: 5, + excludedAliasRows: 2, + targetAliases: [{ name: "slot", address: "0x3000" }], + pointers: [ + { from_address: "0x4000", to_address: "0x2000" }, + { from_address: "0x4008", to_address: "0x3000" }, + ], + }, + }); +}); + +it("preserves full-width pointer and unwalked addresses without numeric rounding", () => { + const source = { + binary: input, + direction: "to", + count: 0, + target: { + name: null, + address: "@18446744073709551615", + address_hex: "0xffffffffffffffff", + aliases: [], + }, + xrefs: [], + coverage: { + decoded: false, + section: null, + code_section: null, + executable: null, + unwalked: [ + { + name: "candidate", + address: "@9007199254740993", + address_hex: "0x20000000000001", + section: ".text", + }, + ], + notes: [], + }, + data_refs_coverage: "dynamic-relocations", + data_refs_count: 1, + data_refs: [ + { + kind: "pointer", + from_address: "@9007199254740995", + from_address_hex: "0x20000000000003", + from_name: null, + to_address: "@18446744073709551615", + to_address_hex: "0xffffffffffffffff", + to_name: null, + width: 8, + source: "dynamic-relocations", + }, + ], + }; + expect( + parseKunaIncomingReferences(wire(source), input, "0xffffffffffffffff"), + ).toMatchObject({ + ok: true, + value: { + addresses: ["0x20000000000003"], + rows: 1, + excludedAliasRows: 0, + pointers: [ + { from_address: "0x20000000000003", to_address: "0xffffffffffffffff" }, + ], + walkCoverage: { unwalked: [{ address: "0x20000000000001" }] }, + }, + }); +}); + +it("distinguishes unreported pointer coverage from an explicitly empty pointer list", () => { + const source = xrefs(); + expect( + parseKunaIncomingReferences(wire(source), input, "0x2000"), + ).toMatchObject({ + ok: true, + value: { pointerCoverage: null, pointers: null, walkCoverage: null }, + }); + expect( + parseKunaIncomingReferences( + wire({ + ...source, + data_refs_coverage: "none", + data_refs_count: 0, + data_refs: [], + }), + input, + "0x2000", + ), + ).toMatchObject({ + ok: true, + value: { pointerCoverage: "none", pointers: [], walkCoverage: null }, + }); +}); + +it("rejects partial or inconsistent pointer inventories rather than reporting them empty", () => { + const source = xrefs(); + for (const fields of [ + { data_refs: [] }, + { data_refs_count: 0 }, + { data_refs_count: 1, data_refs: [] }, + ]) { + expect( + parseKunaIncomingReferences( + wire({ ...source, ...fields }), + input, + "0x2000", + ), + ).toEqual({ + ok: false, + error: { code: "xref_count" }, + }); + } +}); + +it("preserves unknown coverage for an address outside known sections", () => { + const source = { + ...xrefs(), + coverage: { + decoded: false, + section: null, + code_section: null, + executable: null, + unwalked: [], + notes: [], + }, + }; + expect( + parseKunaIncomingReferences(wire(source), input, "0x2000"), + ).toMatchObject({ + ok: true, + value: { + walkCoverage: { section: null, code_section: null, executable: null }, + }, + }); +}); + it.each([ [ "count",