feat(native): expose typed incoming references at exact addresses (#1715)

This commit is contained in:
Arthas authored and GitHub committed 2026-10-11 19:52:09 +08:00
1 parent 031d7781f0
commit cdd9fe7167
9 files changed
+212 -10

No files matched your search

+16 -3
View File
@@ -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<String, Reference> incoming = new TreeMap<>();
ReferenceIterator incomingIterator = currentProgram.getReferenceManager().getReferencesTo(address);
while (incomingIterator.hasNext()) {
monitor.checkCancelled();
addReference(incoming, incomingIterator.next());
}
List<Reference> 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<String> 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"));
+14 -1
View File
@@ -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
+83 -1
View File
@@ -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",
+2 -2
View File
@@ -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[];
+8 -1
View File
@@ -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()),
});
+1
View File
@@ -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"
+39
View File
@@ -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],
+36
View File
@@ -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<typeof fixture>["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" } },
{
@@ -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(