diff --git a/bridge/ghidra/ReaGhidraBridge.java b/bridge/ghidra/ReaGhidraBridge.java index 3f56922552..1a04130426 100644 --- a/bridge/ghidra/ReaGhidraBridge.java +++ b/bridge/ghidra/ReaGhidraBridge.java @@ -375,7 +375,7 @@ public final class ReaGhidraBridge extends HeadlessScript { case "search_procedures" -> search(request.params, true); case "search_strings" -> search(request.params, false); case "inspect_native_data_type" -> inspectNativeDataType(request.params); - case "inspect_native_instruction" -> inspectNativeInstruction(request.params); + case "inspect_native_instruction" -> inspectNativeInstruction(request.params, true); case "resolve_native_call_targets" -> resolveNativeCallTargets(request.params); case "xrefs" -> xrefs(request.params); case "analyze_function" -> { @@ -885,7 +885,7 @@ public final class ReaGhidraBridge extends HeadlessScript { return item; } - private JsonObject inspectNativeInstruction(JsonObject params) throws Exception { + private JsonObject inspectNativeInstruction(JsonObject params, boolean includeIncoming) throws Exception { requireKeys(params, Set.of("document", "address")); requireDocument(params); Address address = requireAddress(params, "address"); @@ -968,6 +968,19 @@ public final class ReaGhidraBridge extends HeadlessScript { result.add("operands", operands); result.add("flow", flow); result.add("references", references); + if (includeIncoming) { + TreeMap incoming = new TreeMap<>(); + ReferenceIterator incomingIterator = currentProgram.getReferenceManager().getReferencesTo(address); + while (incomingIterator.hasNext()) { + monitor.checkCancelled(); + addReference(incoming, incomingIterator.next()); + } + List orderedIncoming = new ArrayList<>(incoming.values()); + orderedIncoming.sort(REFERENCE_ORDER); + JsonArray incomingReferences = new JsonArray(); + for (Reference reference : orderedIncoming) incomingReferences.add(instructionReference(reference)); + result.add("incoming_references", incomingReferences); + } List limitations = new ArrayList<>(List.of("Operand components are Ghidra Listing decoder tokens; effective memory base/index/displacement roles and per-instruction context mode are unavailable. The mode field is the program language variant.")); if (flow.get("kind").getAsString().equals("terminal")) limitations.add("No RETURN operation was observed in the provider's instruction p-code; terminal classification remains generic."); result.add("limitations", GSON.toJsonTree(limitations)); @@ -975,7 +988,7 @@ public final class ReaGhidraBridge extends HeadlessScript { } private JsonObject resolveNativeCallTargets(JsonObject params) throws Exception { - JsonObject instruction = inspectNativeInstruction(params); + JsonObject instruction = inspectNativeInstruction(params, false); JsonObject result = new JsonObject(); result.add("call_site", instruction.get("address")); result.add("procedure", instruction.get("procedure")); diff --git a/docs/native-investigation.md b/docs/native-investigation.md index 93492137a1..1d2b6c01a3 100644 --- a/docs/native-investigation.md +++ b/docs/native-investigation.md @@ -195,7 +195,20 @@ outside-memory and undecodable addresses have separate outcomes. Effective memory base/index/displacement roles and per-instruction context mode remain unavailable; `mode` is the program language variant. -Compact instruction references and call-target references preserve the source +`inspect_native_instruction` also returns `incoming_references` to the exact +queried address, including data, interior-instruction and undecodable addresses. +Ghidra enumerates its ReferenceManager, excludes synthetic entry-point +references, and returns deterministic source-address ordering while retaining +distinct operand references from the same source. `[]` means that enumeration +found no references; omission or `null` means this facet is unavailable in an +older capture or another provider. Empty static references do not prove that +the address is unused at runtime. The existing `references` field still holds +outgoing instruction references, and `xrefs` still returns bare source addresses. +The Ghidra analysis profile versions this incoming-reference coverage. Reopen +the target without an older snapshot and save a fresh snapshot to acquire it; +the older snapshot's Evidence bundle remains readable with this facet unavailable. + +Compact incoming, outgoing instruction and call-target references preserve the source address, operand index, DATA/READ/WRITE classification, primary status, provider `provenance`, and reference `source` (for Ghidra: `analysis`, `default`, `imported` or `user_defined`). Primary status reports the provider's chosen reference; it diff --git a/scripts/lib/real-ghidra-compact-references.mjs b/scripts/lib/real-ghidra-compact-references.mjs index be1c4c015c..8d5c49a682 100644 --- a/scripts/lib/real-ghidra-compact-references.mjs +++ b/scripts/lib/real-ghidra-compact-references.mjs @@ -13,6 +13,7 @@ export async function verifyGhidraCompactReferences(call, x86, cli) { procedure: address(0x1020), }); const inspected = {}; + const incomingByTarget = new Map(); for (const [kind, offset, target] of cases) { const instruction = await call("inspect_native_instruction", { address: address(offset), @@ -26,6 +27,12 @@ export async function verifyGhidraCompactReferences(call, x86, cli) { assert.equal(reference.data, true); assert.equal(reference.read, kind === "read"); assert.equal(reference.write, kind === "write"); + const incoming = await call("inspect_native_instruction", { + address: address(target), + }); + assert.notEqual(incoming.status, "decoded"); + assertIncomingReference(incoming, reference); + incomingByTarget.set(incoming.address, incoming); } const indexed = await call("inspect_native_instruction", { address: address(x86 ? 0x103d : 0x1045), @@ -39,6 +46,15 @@ export async function verifyGhidraCompactReferences(call, x86, cli) { ), "Indexed fixture lacks a non-primary analyzer reference", ); + for (const reference of indexed.references) { + const incoming = await call("inspect_native_instruction", { + address: reference.target_address, + }); + assertIncomingReference(incoming, reference); + incomingByTarget.set(incoming.address, incoming); + } + for (const incoming of incomingByTarget.values()) + await verifyIncomingReferences(call, incoming); const { direct, targets } = await verifyDirectCall( call, @@ -64,7 +80,13 @@ export async function verifyGhidraCompactReferences(call, x86, cli) { ); if (cli !== undefined) { - for (const instruction of [inspected.write, indexed, direct, returned]) + for (const instruction of [ + inspected.write, + indexed, + direct, + returned, + ...incomingByTarget.values(), + ]) assert.deepEqual( await cli("inspect-native-instruction", instruction.address), instruction, @@ -82,6 +104,10 @@ export async function verifyGhidraCompactReferences(call, x86, cli) { data_read_write: true, source_primary_provenance: true, full_reference_parity: true, + typed_incoming_reference_parity: true, + incoming_reference_ordering: true, + incoming_xrefs_parity: true, + synthetic_entry_reference_excluded: true, direct_call_reference_parity: true, indexed_reference_count: indexed.references.length, indexed_non_primary_count: indexed.references.filter( @@ -115,9 +141,64 @@ async function verifyDirectCall(call, entry, callee) { const full = await call("procedure_references", { procedure: entry }); for (const reference of direct.references) assertReference(reference, entry, full.references); + const incoming = await call("inspect_native_instruction", { + address: callee, + }); + const callReference = direct.references.find( + (reference) => reference.target_address === callee && reference.call, + ); + assert.ok(callReference, "Direct fixture call lacks its reference"); + assertIncomingReference(incoming, callReference); + await verifyIncomingReferences(call, incoming); + // PE header pointers are real incoming data references; synthetic loader + // entry-point references must not be presented as callers. + await verifyIncomingReferences(call, direct); + for (const reference of direct.incoming_references) { + assert.equal(reference.type, "DATA"); + assert.equal(reference.data, true); + assert.equal(reference.source, "default"); + } return { direct, targets }; } +function assertIncomingReference(instruction, reference) { + const incoming = instruction.incoming_references.find( + (item) => + item.source_address === reference.source_address && + item.target_address === reference.target_address && + item.type === reference.type && + item.operand_index === reference.operand_index && + item.primary === reference.primary, + ); + assert.ok( + incoming, + "Outgoing reference lacks its typed incoming counterpart", + ); + assert.deepEqual(incoming, reference); +} + +async function verifyIncomingReferences(call, instruction) { + const repeated = await call("inspect_native_instruction", { + address: instruction.address, + }); + assert.deepEqual( + repeated.incoming_references, + instruction.incoming_references, + "Repeated incoming reference queries changed order or provider facts", + ); + assert.deepEqual( + [ + ...new Set( + instruction.incoming_references.map( + (reference) => reference.source_address, + ), + ), + ], + await call("xrefs", { address: instruction.address }), + "Incoming references differ from xrefs source addresses or their order", + ); +} + async function verifyEmptyReferences(call, address) { const returned = await call("inspect_native_instruction", { address }); assert.equal(returned.flow.kind, "return"); @@ -129,6 +210,7 @@ async function verifyEmptyReferences(call, address) { const outside = await call("inspect_native_instruction", { address: "0x0" }); assert.equal(outside.status, "outside-memory"); assert.deepEqual(outside.references, []); + assert.deepEqual(outside.incoming_references, []); assert.equal( (await call("resolve_native_call_targets", { address: "0x0" })).status, "unavailable", diff --git a/src/contracts/officialToolContracts.ts b/src/contracts/officialToolContracts.ts index 0838e105eb..f9c024fe51 100644 --- a/src/contracts/officialToolContracts.ts +++ b/src/contracts/officialToolContracts.ts @@ -54,7 +54,7 @@ export const OFFICIAL_TOOL_CONTRACTS = [ ), official( "inspect_native_instruction", - "Inspect one exact instruction address: decoded bytes, mnemonic, ordered operand tokens, flow and typed references. Memory addressing decomposition is unavailable when the provider supplies only tokens. Data, interior instruction addresses and undecodable bytes are distinct outcomes.", + "Inspect one exact code or data address: decoded bytes, mnemonic, ordered operand tokens, flow, outgoing instruction references and typed incoming references where available. Incoming references preserve source, access kind, operand index and analysis provenance even at data addresses; they do not prove runtime access. Memory addressing decomposition is unavailable when the provider supplies only tokens. Data, interior instruction addresses and undecodable bytes are distinct outcomes.", nativeInstructionInputSchema, ), official( @@ -231,7 +231,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.", + "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. 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/domain/native/nativeInstruction.ts b/src/domain/native/nativeInstruction.ts index c13a80ca0d..e4c112cd1f 100644 --- a/src/domain/native/nativeInstruction.ts +++ b/src/domain/native/nativeInstruction.ts @@ -1,6 +1,6 @@ import { z } from "zod"; -/** Exact instruction location within the active analysis program. */ +/** Exact code or data location within the active analysis program. */ export const nativeInstructionInputSchema = z.strictObject({ address: z.string().min(1), document: z.string().optional(), @@ -99,6 +99,13 @@ export const nativeInstructionSchema = z.strictObject({ ), }), references: z.array(reference), + incoming_references: z + .array(reference) + .nullable() + .optional() + .describe( + "Static references to this exact code or data address, excluding synthetic entry-point references. An empty array means the provider examined the address and found none; null or omission means unavailable. Analyzer references do not prove literal addressing or runtime access.", + ), limitations: z.array(z.string()), }); diff --git a/src/ghidra/GhidraAnalysisProfile.ts b/src/ghidra/GhidraAnalysisProfile.ts index 81e9a3e853..a057ce4bab 100644 --- a/src/ghidra/GhidraAnalysisProfile.ts +++ b/src/ghidra/GhidraAnalysisProfile.ts @@ -113,6 +113,7 @@ export const resolveGhidraAnalysisProfile = async ( string_inventory_evidence: "defined-data-coverage-v1", location_resolution: "explicit-address-exact-entry-symbol-first-v3", instruction_flow_evidence: "decoded-return-pcode-v1", + instruction_references: "exact-address-incoming-v1", function_boundary_observations: "ghidra-terminal-call-limitations-v1", no_return_repair: installation.platform === "win32" diff --git a/src/ghidra/GhidraFunctionValues.test.ts b/src/ghidra/GhidraFunctionValues.test.ts index f868d65a04..662db0a263 100644 --- a/src/ghidra/GhidraFunctionValues.test.ts +++ b/src/ghidra/GhidraFunctionValues.test.ts @@ -172,6 +172,45 @@ describe("Ghidra function-analysis result values", () => { }); describe("Ghidra compact reference provenance", () => { + it("distinguishes incoming references, examined emptiness and unavailable coverage at data addresses", () => { + const instruction = { + ...compactInstruction(), + address: "ram:00402000", + status: "data", + procedure: null, + }; + const reference = { + ...compactReference(), + source_address: "ram:00401000", + target_address: instruction.address, + type: "WRITE", + call: false, + data: true, + write: true, + }; + for (const incoming of [ + {}, + { incoming_references: null }, + { incoming_references: [] }, + { incoming_references: [reference] }, + { incoming_references: [{ ...reference, primary: null, source: null }] }, + ]) { + const expected = { ...instruction, ...incoming }; + const parsed = parseFunctionValueFixture( + "inspect_native_instruction", + expected, + ); + if (!parsed.ok) throw parsed.error; + expect(parsed.value).toEqual(expected); + } + expect( + parseFunctionValueFixture("inspect_native_instruction", { + ...instruction, + incoming_references: [{ ...reference, primary: "false" }], + }), + ).toMatchObject({ ok: false, error: { _tag: "AnalysisOutputError" } }); + }); + const operations = [ ["inspect_native_instruction", compactInstruction], ["resolve_native_call_targets", compactCallTargets], diff --git a/src/ghidra/GhidraSnapshotCache.test.ts b/src/ghidra/GhidraSnapshotCache.test.ts index a7f30f196e..703bb49063 100644 --- a/src/ghidra/GhidraSnapshotCache.test.ts +++ b/src/ghidra/GhidraSnapshotCache.test.ts @@ -4,6 +4,7 @@ import { createTestBinarySession } from "../../tests/fixtures/binarySession.js"; import { parseConfig } from "../config/parseConfig.js"; import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; import { functionDossierSchema } from "../domain/hopperValues.js"; +import { createAnalysisProfile } from "../domain/analysisProfile.js"; import { ghidraFunctionDossier } from "../domain/ghidraValues.fixture.js"; import { err, ok } from "../domain/result.js"; import { silentLogger } from "../logger.js"; @@ -130,6 +131,41 @@ const open = async (session: ReturnType["session"]) => { }; describe("Ghidra snapshot cache", () => { + it("refuses snapshots whose profile predates typed incoming references", async () => { + const initial = fixture(); + await open(initial.session); + const snapshot = initial.session.exportAnalysisSnapshot(); + if (!snapshot.ok) throw snapshot.error; + await initial.session.close(); + const profile = snapshot.value.binding.analysis_profile; + expect(profile.parameters.instruction_references).toBe( + "exact-address-incoming-v1", + ); + const legacy = { + ...snapshot.value, + binding: { + ...snapshot.value.binding, + analysis_profile: createAnalysisProfile( + profile.provider, + Object.fromEntries( + Object.entries(profile.parameters).filter( + ([key]) => key !== "instruction_references", + ), + ), + ), + }, + }; + const replay = fixture(); + const imported = replay.session.importAnalysisSnapshot(legacy); + if (!imported.ok) throw imported.error; + expect(await replay.session.previewTarget(TARGET)).toMatchObject({ + ok: false, + error: { _tag: "EvidenceIntegrityError" }, + }); + expect(replay.calls).toEqual([]); + await replay.session.close(); + }); + it.each([ { operation: "analyze_function", parameters: { procedure: "0x1000" } }, { diff --git a/tests/boundary/mcp/ghidraCompactReferencesMcp.test.ts b/tests/boundary/mcp/ghidraCompactReferencesMcp.test.ts index 6495ead9be..5d9994a9ee 100644 --- a/tests/boundary/mcp/ghidraCompactReferencesMcp.test.ts +++ b/tests/boundary/mcp/ghidraCompactReferencesMcp.test.ts @@ -21,7 +21,14 @@ describe("compact Ghidra reference MCP evidence", () => { { ...base, type: "WRITE", primary: false, write: true, operand_index: 0 }, ]; const outputs = { - inspect_native_instruction: compactInstruction(references), + inspect_native_instruction: { + ...compactInstruction(references), + incoming_references: references.map((reference) => ({ + ...reference, + source_address: "ram:00403000", + target_address: "0x401000", + })), + }, resolve_native_call_targets: compactCallTargets([compactReference()]), }; const harness = await connectGhidraMcp( @@ -58,7 +65,11 @@ describe("compact Ghidra reference MCP evidence", () => { it("preserves empty references and unresolved targets without manufacturing provenance", async () => { const outputs = { - inspect_native_instruction: compactInstruction(), + inspect_native_instruction: { + ...compactInstruction(), + status: "data", + incoming_references: [], + }, resolve_native_call_targets: compactCallTargets(), }; const harness = await connectGhidraMcp(