diff --git a/scripts/verify-real-kuna.mjs b/scripts/verify-real-kuna.mjs index 9c50ce88c9..ae025aab75 100644 --- a/scripts/verify-real-kuna.mjs +++ b/scripts/verify-real-kuna.mjs @@ -28,6 +28,10 @@ import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; import { inspectKunaInstallation } from "../dist/kuna/KunaInstallation.js"; import { ArtifactResourceScope } from "../dist/artifacts/ArtifactResourceScope.js"; import { AnalysisCleanupLease } from "../dist/artifacts/AnalysisCleanupLease.js"; +import { + KUNA_XREF_MISSING_NAME_REMEDIATION, + missingKunaXrefNameMessage, +} from "../dist/kuna/KunaSelectorGuidance.js"; import { requireMcpOperationResult, requireMcpToolError, @@ -233,13 +237,13 @@ const openServer = async (provider, kunaSettings = {}) => { structured: result.structuredContent, }; }; - const callError = async (name, arguments_ = {}) => { - const result = await client.callTool( - { name, arguments: arguments_ }, - { timeout: 3_600_000 }, + const callError = async (name, arguments_ = {}) => + requireMcpToolError( + await client.callTool( + { name, arguments: arguments_ }, + { timeout: 3_600_000 }, + ), ); - return requireMcpToolError(result); - }; const close = async (priorFailure) => { clearInterval(timer); await sample(); @@ -409,6 +413,44 @@ const verifyKuna = async ( }), }); const listed = await cold.call("list_procedures"); + if (verifyNamedTrace) { + const missing = await cold.callError("xrefs", { + address: "rea_missing_xref_inventory_name", + }); + assert.equal(missing.code, "invalid_request"); + assert.equal( + missing.message, + missingKunaXrefNameMessage("rea_missing_xref_inventory_name"), + ); + assert.equal( + missing.remediation.action, + KUNA_XREF_MISSING_NAME_REMEDIATION, + ); + assert.ok(missing.message.includes("rea_missing_xref_inventory_name")); + assert.equal(missing.recovery, undefined); + const procedure = await cold.callError("procedure_address", { + procedure: "rea_missing_procedure_name", + }); + assert.equal(procedure.code, "invalid_request"); + assert.equal(procedure.recovery, undefined); + assert.match(procedure.remediation.action, /search_procedures/u); + assert.ok( + procedure.details.issues.some( + (issue) => issue.observed?.procedure === "rea_missing_procedure_name", + ), + ); + const missingAddress = await cold.callError("procedure_address", { + procedure: "0x00000001", + }); + assert.equal(missingAddress.code, "invalid_request"); + assert.equal(missingAddress.recovery, undefined); + assert.match(missingAddress.remediation.action, /list_procedures/u); + assert.ok( + missingAddress.details.issues.some( + (issue) => issue.observed?.procedure === "0x00000001", + ), + ); + } for (const [name, address] of functions) { assert.ok( listed.value.some((entry) => entry.address === address), diff --git a/src/contracts/enhancedToolContracts.ts b/src/contracts/enhancedToolContracts.ts index da8248aea8..55dd8d5701 100644 --- a/src/contracts/enhancedToolContracts.ts +++ b/src/contracts/enhancedToolContracts.ts @@ -71,7 +71,7 @@ export const ENHANCED_TOOL_CONTRACTS = [ ), enhanced( "find_xrefs_to_name", - "Resolve an exact name against the bound provider's name inventory and return a resolved or unresolved result. Unresolved names use the stable name_not_found reason; this xref workflow returns address-only projections.", + "Resolve an exact name against the bound provider's name inventory and return a resolved or unresolved result. Unresolved names use the stable name_not_found reason; this xref workflow returns address-only projections. Kuna lacks this general name inventory; xrefs can instead resolve names present in Kuna's inventory, but unlisted names may still exist in the target.", enhancedInputSchemas.find_xrefs_to_name, ), enhanced( diff --git a/src/contracts/officialToolContracts.ts b/src/contracts/officialToolContracts.ts index ae9d52a1e0..2e4e74fa72 100644 --- a/src/contracts/officialToolContracts.ts +++ b/src/contracts/officialToolContracts.ts @@ -273,7 +273,7 @@ export const OFFICIAL_TOOL_CONTRACTS = [ ), official( "xrefs", - "Return analyzed references to a code or data address. Hopper may default to its current cursor; headless providers such as Ghidra require an explicit address. Use to connect strings, globals, selectors, and functions; bare addresses are untyped and indirect references may be incomplete. Page a retained result with inspect_analysis_view and native-observation; use native-context for source caveats. Use inspect_native_instruction at the target address for typed incoming references where supported.", + "Return analyzed references to a code or data address. Hopper may default to its current cursor; headless providers such as Ghidra require an explicit address. Kuna also accepts names in its own inventory, including data names; an unlisted name may still exist in the target. Use to connect strings, globals, selectors, and functions; bare addresses are untyped and indirect references may be incomplete. Page a retained result with inspect_analysis_view and native-observation; use native-context for source caveats. Use inspect_native_instruction at the target address for typed incoming references where supported.", z.object({ document, address: optionalAddress }), ), ] as const satisfies readonly ToolContract[]; diff --git a/src/kuna/KunaSelectorGuidance.ts b/src/kuna/KunaSelectorGuidance.ts new file mode 100644 index 0000000000..8d9cadbad5 --- /dev/null +++ b/src/kuna/KunaSelectorGuidance.ts @@ -0,0 +1,25 @@ +/** Recovery for an xref name absent from Kuna's own inventory. */ +export const KUNA_XREF_MISSING_NAME_REMEDIATION = + "Inspect target symbols or exports where available, verify address coordinates, and call xrefs with an address. Absence from Kuna's inventory does not establish absence from the target."; + +/** Recovery for a name with multiple observed inventory entries. */ +export const KUNA_XREF_AMBIGUOUS_NAME_REMEDIATION = + "Select one of the reported addresses and call xrefs again."; + +export const KUNA_PROCEDURE_MISSING_NAME_REMEDIATION = + "Use search_procedures or list_procedures to inspect Kuna's current inventory. An absent name does not establish that the function is absent from the target."; + +export const KUNA_PROCEDURE_MISSING_ADDRESS_REMEDIATION = + "Use list_procedures or search_procedures to inspect Kuna's current inventory and verify the selected address. An absent entry does not establish that the function is absent from the target; REA cannot use it as a Kuna procedure in this session."; + +export const KUNA_PROCEDURE_AMBIGUOUS_NAME_REMEDIATION = + "Select one of the reported exact addresses and retry the operation."; + +export const missingKunaXrefNameMessage = (selector: string): string => + `Kuna found no inventory entry named ${selector}.`; + +export const ambiguousKunaNameMessage = ( + selector: string, + addresses: readonly string[], +): string => + `${selector} names ${String(addresses.length)} Kuna entries (${addresses.join(", ")}); select one by address.`; diff --git a/src/kuna/KunaSessionClient.ts b/src/kuna/KunaSessionClient.ts index 111e156886..7e10b27daf 100644 --- a/src/kuna/KunaSessionClient.ts +++ b/src/kuna/KunaSessionClient.ts @@ -95,6 +95,15 @@ import { KunaReferenceQueries } from "./KunaReferenceQueries.js"; import { KunaQueryWork } from "./KunaQueryWork.js"; import { KunaStringQueries } from "./KunaStringQueries.js"; import { normalizeKunaAddress } from "./KunaAddress.js"; +import { + ambiguousKunaNameMessage, + KUNA_PROCEDURE_AMBIGUOUS_NAME_REMEDIATION, + KUNA_PROCEDURE_MISSING_ADDRESS_REMEDIATION, + KUNA_PROCEDURE_MISSING_NAME_REMEDIATION, + KUNA_XREF_AMBIGUOUS_NAME_REMEDIATION, + KUNA_XREF_MISSING_NAME_REMEDIATION, + missingKunaXrefNameMessage, +} from "./KunaSelectorGuidance.js"; import type { RegexMatchingDeadline } from "../config/regexMatchingDeadline.js"; import type { RegexWorkerHeapRequest } from "../config/regexWorkerHeapRequest.js"; import { KUNA_FUNCTION_TEXT_BYTES } from "./KunaLimits.js"; @@ -1242,12 +1251,23 @@ export class KunaSessionClient implements AnalysisClient { const entry = index.byAddress.get(address); if (entry === undefined) return err( - new AnalysisInputError(operation, { - userMessage: `${address} is not a procedure entry Kuna found; list_procedures shows the entries it found.`, - conformsToInputContract: true, - remediationAction: - "Inspect list_procedures and select a discovered entry if it answers your question. An unlisted address is not proof of a function; REA cannot use it as a Kuna procedure in this session. Do not repeat the unchanged call.", - }), + new AnalysisInputError( + operation, + { + userMessage: `${address} is not a procedure entry Kuna found; list_procedures shows the entries it found.`, + remediationAction: KUNA_PROCEDURE_MISSING_ADDRESS_REMEDIATION, + conformsToInputContract: true, + }, + [ + { + path: [field], + reason: "invalid_value", + message: + "The address was not found in Kuna's procedure inventory.", + observed: { [field]: selector }, + }, + ], + ), ); return refused(entry) ? dataRefusal(entry) : selected(entry); } @@ -1257,26 +1277,52 @@ export class KunaSessionClient implements AnalysisClient { return selected(matches[0]); if (matches.length === 0 && named.length === 1 && named[0] !== undefined) return dataRefusal(named[0]); + const missing = named.length === 0; + const ambiguous = matches.length > 1; + const procedureLookupFailure = + field === "procedure" && (missing || ambiguous); + const message = missing + ? field === "address" + ? missingKunaXrefNameMessage(selector) + : `Kuna found no procedure named ${selector}; search_procedures finds similar names.` + : matches.length === 0 + ? `${selector} names ${String(named.length)} Kuna entries that lie in Mach-O data regions (${named.map(({ address }) => address).join(", ")}); they are data, not procedures.` + : ambiguousKunaNameMessage( + selector, + matches.map(({ address }) => address), + ); + const remediationAction = + field === "address" + ? missing + ? KUNA_XREF_MISSING_NAME_REMEDIATION + : KUNA_XREF_AMBIGUOUS_NAME_REMEDIATION + : procedureLookupFailure + ? missing + ? KUNA_PROCEDURE_MISSING_NAME_REMEDIATION + : KUNA_PROCEDURE_AMBIGUOUS_NAME_REMEDIATION + : matches.length === 0 + ? "Inspect the data addresses with address_name or xrefs, or select a procedure entry from list_procedures. Do not repeat the unchanged call." + : undefined; return err( - new AnalysisInputError(operation, { - conformsToInputContract: true, - remediationAction: - matches.length === 0 && named.length === 0 - ? field === "procedure" - ? "Use search_procedures or list_procedures to inspect Kuna's discovered entries, then select a matching entry. Do not repeat the unchanged call." - : "Inspect the selected target for the intended address and use that address explicitly. Do not repeat the unchanged call." - : matches.length === 0 - ? "Inspect the data addresses with address_name or xrefs, or select a procedure entry from list_procedures. Do not repeat the unchanged call." - : "Select the intended entry by its exact address. Do not repeat the unchanged call.", - userMessage: - matches.length === 0 && named.length === 0 - ? field === "procedure" - ? `Kuna found no procedure named ${selector}; search_procedures finds similar names.` - : `Kuna found no inventory entry named ${selector}.` - : matches.length === 0 - ? `${selector} names ${String(named.length)} Kuna entries that lie in Mach-O data regions (${named.map(({ address }) => address).join(", ")}); they are data, not procedures.` - : `${selector} names ${String(matches.length)} Kuna entries (${matches.map(({ address }) => address).join(", ")}); select one by address.`, - }), + new AnalysisInputError( + operation, + { + userMessage: message, + ...(remediationAction === undefined + ? {} + : { remediationAction, conformsToInputContract: true }), + }, + procedureLookupFailure + ? [ + { + path: [field], + reason: "invalid_value", + message: "The selector did not identify one procedure entry.", + observed: { procedure: selector }, + }, + ] + : undefined, + ), ); } diff --git a/tests/boundary/providers/kuna/kunaMachoInventory.test.ts b/tests/boundary/providers/kuna/kunaMachoInventory.test.ts index 40fbdafaba..0b673115d3 100644 --- a/tests/boundary/providers/kuna/kunaMachoInventory.test.ts +++ b/tests/boundary/providers/kuna/kunaMachoInventory.test.ts @@ -1,6 +1,13 @@ import { expect, it as test } from "vitest"; import { functionDossierSchema } from "../../../../src/domain/native/functionDossier.js"; +import { projectAnalysisError } from "../../../../src/domain/analysisErrorProjection.js"; +import { + ambiguousKunaNameMessage, + KUNA_XREF_AMBIGUOUS_NAME_REMEDIATION, + KUNA_XREF_MISSING_NAME_REMEDIATION, + missingKunaXrefNameMessage, +} from "../../../../src/kuna/KunaSelectorGuidance.js"; import { fakeKunaCalls } from "./fakeKuna.js"; import { MACHO_FUNCTIONS, @@ -165,6 +172,76 @@ it("resolves a data symbol's name for xrefs, which accept data addresses", async } }); +it("guides valid ambiguous and missing procedure selectors without schema repair", async () => { + const { open } = await machoSession("rea-kuna-procedure-recovery-"); + const client = open(); + try { + for (const [procedure, expected] of [ + ["puts", "address"], + ["missing_procedure", "search_procedures"], + ["0x00000001", "list_procedures"], + ] as const) { + const result = await client.execute("procedure_address", { procedure }); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("Expected unresolved procedure"); + const projected = projectAnalysisError(result.error); + expect(projected.recovery).toBeUndefined(); + expect(projected.remediation.action).toContain(expected); + expect(projected.details?.issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ observed: { procedure } }), + ]), + ); + if (procedure === "puts") { + expect(projected.message).toContain("0x100000480"); + expect(projected.message).toContain("0x100004000"); + } + } + expect( + (await run(client, "procedure_address", { procedure: "helper" })).result, + ).toBe("0x100000440"); + } finally { + expect(await client.close()).toEqual({ ok: true, value: null }); + } +}); + +it("reports missing and ambiguous xref names within Kuna's inventory", async () => { + const { open } = await machoSession("rea-kuna-macho-xref-recovery-"); + const client = open(); + try { + const missing = await client.execute("xrefs", { address: "missing_data" }); + expect(missing.ok).toBe(false); + if (missing.ok) throw new Error("Expected missing inventory name"); + expect(projectAnalysisError(missing.error)).toMatchObject({ + code: "invalid_request", + message: missingKunaXrefNameMessage("missing_data"), + remediation: { action: KUNA_XREF_MISSING_NAME_REMEDIATION }, + }); + expect(projectAnalysisError(missing.error).recovery).toBeUndefined(); + expect(projectAnalysisError(missing.error).message).toContain( + "missing_data", + ); + + const ambiguous = await client.execute("xrefs", { address: "puts" }); + expect(ambiguous.ok).toBe(false); + if (ambiguous.ok) throw new Error("Expected ambiguous inventory name"); + expect(projectAnalysisError(ambiguous.error)).toMatchObject({ + code: "invalid_request", + message: ambiguousKunaNameMessage("puts", ["0x100000480", "0x100004000"]), + remediation: { action: KUNA_XREF_AMBIGUOUS_NAME_REMEDIATION }, + }); + expect(projectAnalysisError(ambiguous.error).message).toContain( + "0x100000480", + ); + expect(projectAnalysisError(ambiguous.error).message).toContain( + "0x100004000", + ); + expect(projectAnalysisError(ambiguous.error).recovery).toBeUndefined(); + } finally { + expect(await client.close()).toEqual({ ok: true, value: null }); + } +}); + it("classifies the published export's inventory with the section table it read", async () => { const { kuna, open } = await machoSession("rea-kuna-macho-warm-"); const cold = open();