From c313f33674fa198a14f50c23c716b1f7e4b9f32e Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sat, 24 Oct 2026 02:58:35 +0800 Subject: [PATCH] fix(native): share exact-name xref ambiguity resolution --- docs/native-investigation.md | 4 ++ src/application/EnhancedTools.ts | 17 ++++--- src/application/XrefsAddressResolution.ts | 45 +++++++++++++--- ...3-returns-typed-graph-failures-and.test.ts | 51 +++++++++++++++++++ 4 files changed, 103 insertions(+), 14 deletions(-) diff --git a/docs/native-investigation.md b/docs/native-investigation.md index 829125ad2f..fa8c14aeac 100644 --- a/docs/native-investigation.md +++ b/docs/native-investigation.md @@ -978,3 +978,7 @@ skipped uninitialized blocks. A missing match does not establish absence from uninitialized memory or across block boundaries. See [Ghidra byte search](ghidra-provider.md#masked-byte-search) for mask and coverage semantics. This query never executes or modifies target bytes. + +Exact-name xref lookup reports distinct candidate addresses as an input ambiguity; +inventory order cannot select one silently. Duplicate rows at one address remain +a singular match, and a missing name remains an unresolved observation. diff --git a/src/application/EnhancedTools.ts b/src/application/EnhancedTools.ts index 167cdf1725..7860e31dd2 100644 --- a/src/application/EnhancedTools.ts +++ b/src/application/EnhancedTools.ts @@ -59,6 +59,7 @@ import { } from "./EnhancedToolTypes.js"; import { resolveAnalysisDocument } from "./AnalysisDocument.js"; import { traceCallPath } from "./CallPathTracing.js"; +import { resolveXrefsName } from "./XrefsAddressResolution.js"; import { resolveProcedureAddress } from "./ProcedureAddressResolution.js"; import { traceLiteralFeature } from "./EnhancedLiteralTracing.js"; import { projectNativeApiInspection } from "./native/NativeApiInspection.js"; @@ -686,15 +687,19 @@ export class EnhancedTools { } async #findXrefs(name: string, signal?: AbortSignal): EnhancedResult { - const names = await this.#allAddressed("list_names", signal); - if (!names.ok) return names; - const resolved = names.value.find((entry) => entry.name === name); - if (resolved === undefined) + const resolved = await resolveXrefsName( + this.analysis, + name, + signal, + "find_xrefs_to_name", + ); + if (!resolved.ok) return resolved; + if (resolved.value === null) return ok({ status: "unresolved", name, reason: "name_not_found" }); const xrefs = await this.#call( "xrefs", - { address: resolved.address }, + { address: resolved.value }, signal, ); if (!xrefs.ok) return xrefs; @@ -711,7 +716,7 @@ export class EnhancedTools { return ok({ status: "resolved", name, - address: resolved.address, + address: resolved.value, xrefs: xrefs.value, }); } diff --git a/src/application/XrefsAddressResolution.ts b/src/application/XrefsAddressResolution.ts index 3a2f5e52ed..f101724d14 100644 --- a/src/application/XrefsAddressResolution.ts +++ b/src/application/XrefsAddressResolution.ts @@ -2,6 +2,7 @@ import { addressedValueSchema } from "../domain/native/nativeInventory.js"; import type { AnalysisError } from "../domain/analysisErrorBase.js"; import { AnalysisCapabilityUnavailableError, + AnalysisCancelledError, AnalysisInputError, AnalysisOutputError, } from "../domain/analysisErrorCore.js"; @@ -23,9 +24,38 @@ export const resolveXrefsAddress = async ( ) return ok(selector); - const inventory = await analysis.execute("list_names", {}, { signal }); + const named = await resolveXrefsName(analysis, selector, signal); + if (!named.ok) return named; + return named.value !== null + ? ok(named.value) + : err( + new AnalysisInputError("xrefs", undefined, [ + { + path: ["address"], + reason: "invalid_value", + message: `Unknown analyzed symbol name ${JSON.stringify(selector)}. Use an exact analyzed name or a hexadecimal address.`, + }, + ]), + ); +}; + +/** Exact name resolution has one ambiguity owner for enhanced tools and CLI fallback. */ +export const resolveXrefsName = async ( + analysis: AnalysisOperationPort, + selector: string, + signal?: AbortSignal, + operation: "xrefs" | "find_xrefs_to_name" = "xrefs", +): Promise> => { + if (signal?.aborted) + return err(new AnalysisCancelledError(operation, { cause: signal.reason })); + const inventory = await analysis.execute( + "list_names", + {}, + signal === undefined ? {} : { signal }, + ); if (!inventory.ok) { if ( + operation === "xrefs" && inventory.error instanceof AnalysisCapabilityUnavailableError && inventory.error.operation === "list_names" && !inventory.error.cleanupIncomplete @@ -44,6 +74,8 @@ export const resolveXrefsAddress = async ( ); return inventory; } + if (signal?.aborted) + return err(new AnalysisCancelledError(operation, { cause: signal.reason })); if (!Array.isArray(inventory.value.result)) return err( new AnalysisOutputError( @@ -65,16 +97,13 @@ export const resolveXrefsAddress = async ( if (parsed.data.value === selector) addresses.add(parsed.data.address); } const [address] = addresses; - if (addresses.size === 1 && address !== undefined) return ok(address); + if (addresses.size <= 1) return ok(address ?? null); return err( - new AnalysisInputError("xrefs", undefined, [ + new AnalysisInputError(operation, undefined, [ { - path: ["address"], + path: [operation === "xrefs" ? "address" : "name"], reason: "invalid_value", - message: - addresses.size === 0 - ? `Unknown analyzed symbol name ${JSON.stringify(selector)}. Use an exact analyzed name or a hexadecimal address.` - : `Analyzed symbol name ${JSON.stringify(selector)} is ambiguous at ${[...addresses].join(", ")}. Select an exact address.`, + message: `Analyzed symbol name ${JSON.stringify(selector)} is ambiguous at ${[...addresses].sort().join(", ")}. Select an exact address.`, }, ]), ); diff --git a/tests/boundary/mcp/enhancedTools.03-returns-typed-graph-failures-and.test.ts b/tests/boundary/mcp/enhancedTools.03-returns-typed-graph-failures-and.test.ts index 340bc01e4e..0b8c10c1b2 100644 --- a/tests/boundary/mcp/enhancedTools.03-returns-typed-graph-failures-and.test.ts +++ b/tests/boundary/mcp/enhancedTools.03-returns-typed-graph-failures-and.test.ts @@ -9,6 +9,7 @@ import { inventory, jsonResult, } from "./enhancedToolsHarness.js"; +import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; import { observed as ok } from "../../fixtures/analysisExecution.js"; afterEach(closeEnhancedToolResources); @@ -75,3 +76,53 @@ describe("enhanced MCP tools", () => { expect(result.error._tag).toBe("AnalysisCancelledError"); }); }); + +it("exact-name xrefs preserves ambiguity independently of inventory order and accepts repeated rows", async () => { + let rows = [ + { address: "0x20", value: "entry" }, + { address: "0x10", value: "entry" }, + ]; + const queried: unknown[] = []; + const client = await connect({ + execute: async (operation, parameters) => { + if (operation === "list_names") return ok(rows); + if (operation === "xrefs") { + queried.push(parameters.address); + return ok(["0x90"]); + } + throw new Error(`Unexpected operation ${operation}`); + }, + }); + for (const reversed of [false, true]) { + if (reversed) rows.reverse(); + const rejected = await client.callTool({ + name: "find_xrefs_to_name", + arguments: { name: "entry" }, + }); + expect(parseMcpToolError(rejected).error).toMatchObject({ + code: "invalid_request", + details: { + issues: [ + expect.objectContaining({ + path: ["name"], + message: expect.stringContaining("ambiguous at 0x10, 0x20"), + }), + ], + }, + }); + } + expect(queried).toEqual([]); + rows = [ + { address: "0x10", value: "entry" }, + { address: "0x10", value: "entry" }, + ]; + expect( + jsonResult( + await client.callTool({ + name: "find_xrefs_to_name", + arguments: { name: "entry" }, + }), + ), + ).toMatchObject({ status: "resolved", address: "0x10", xrefs: ["0x90"] }); + expect(queried).toEqual(["0x10"]); +});