From 24d93376492d167fe7d96f6c10dee071b9ef363f Mon Sep 17 00:00:00 2001 From: Joseph Demarest <79677753+JosephDemarest@users.noreply.github.com> Date: Sat, 10 Oct 2026 09:39:28 -0400 Subject: [PATCH] fix(native): preserve compact reference provenance (#1438) Co-authored-by: N0zoM1z0 <161784452+N0zoM1z0@users.noreply.github.com> --- bridge/ghidra/ReaGhidraBridge.java | 13 +- docs/native-investigation.md | 8 + scripts/create-ghidra-windows-fixture.mjs | 48 ++++- .../lib/real-ghidra-compact-references.mjs | 170 ++++++++++++++++++ scripts/verify-packaged-ghidra-windows.mjs | 31 ++++ scripts/verify-real-ghidra-windows.mjs | 10 ++ .../native/nativeInstruction.fixture.ts | 60 +++++++ src/domain/native/nativeInstruction.ts | 27 +++ src/ghidra/GhidraFunctionValues.test.ts | 74 ++++++++ .../mcp/ghidraCompactReferencesMcp.test.ts | 94 ++++++++++ 10 files changed, 523 insertions(+), 12 deletions(-) create mode 100644 scripts/lib/real-ghidra-compact-references.mjs create mode 100644 src/domain/native/nativeInstruction.fixture.ts create mode 100644 tests/boundary/mcp/ghidraCompactReferencesMcp.test.ts diff --git a/bridge/ghidra/ReaGhidraBridge.java b/bridge/ghidra/ReaGhidraBridge.java index ea1b8d2d6..fce8a995b 100644 --- a/bridge/ghidra/ReaGhidraBridge.java +++ b/bridge/ghidra/ReaGhidraBridge.java @@ -841,14 +841,13 @@ public final class ReaGhidraBridge extends HeadlessScript { private JsonObject instructionReference(Reference reference) { JsonObject item = new JsonObject(); - RefType type = reference.getReferenceType(); + JsonObject kind = referenceKind(reference); + item.addProperty("source_address", canonicalAddress(reference.getFromAddress())); item.addProperty("target_address", canonicalAddress(reference.getToAddress())); - item.addProperty("type", type.getName()); - item.addProperty("call", type.isCall()); - item.addProperty("jump", type.isJump()); - item.addProperty("indirect", type.isIndirect()); - item.addProperty("computed", type.isComputed()); - item.addProperty("operand_index", reference.getOperandIndex()); + for (String field : List.of("type", "call", "jump", "indirect", "computed", + "operand_index", "data", "read", "write", "primary", "provenance")) + item.add(field, kind.get(field)); + item.addProperty("source", normalizedSource(reference.getSource())); return item; } diff --git a/docs/native-investigation.md b/docs/native-investigation.md index b3051cc64..12ea1718a 100644 --- a/docs/native-investigation.md +++ b/docs/native-investigation.md @@ -195,6 +195,14 @@ 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 +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 +does not establish literal addressing. Analyzer-inferred indexed targets remain +static references, not observed runtime reads or writes. Older evidence may omit +the additive fields, and null or omitted facts mean unavailable rather than false. + Ghidra distinguishes a return using decoded instruction p-code's `RETURN` operation. `flow.classification_evidence` preserves the Listing flow type and the observed return evidence; mnemonic spelling alone is not the classifier. diff --git a/scripts/create-ghidra-windows-fixture.mjs b/scripts/create-ghidra-windows-fixture.mjs index e610b4688..214d7dadc 100644 --- a/scripts/create-ghidra-windows-fixture.mjs +++ b/scripts/create-ghidra-windows-fixture.mjs @@ -13,7 +13,7 @@ for (const architecture of ["x86_64", "x86"]) { "fixtures", `rea-ghidra-windows${x86 ? "-x86" : ""}.${dll ? "dll" : "exe"}`, ); - const bytes = Buffer.alloc(1024); + const bytes = Buffer.alloc(dll ? 1024 : 1536); const peOffset = 0x80; const optionalHeader = peOffset + 24; const optionalHeaderSize = x86 ? 0xe0 : 0xf0; @@ -24,7 +24,7 @@ for (const architecture of ["x86_64", "x86"]) { bytes.writeUInt32LE(peOffset, 0x3c); bytes.write("PE\0\0", peOffset, "binary"); bytes.writeUInt16LE(x86 ? 0x14c : 0x8664, peOffset + 4); - bytes.writeUInt16LE(1, peOffset + 6); + bytes.writeUInt16LE(dll ? 1 : 2, peOffset + 6); bytes.writeUInt16LE(optionalHeaderSize, peOffset + 20); bytes.writeUInt16LE( (x86 ? 0x102 : 0x22) | (dll ? 0x2000 : 0), @@ -33,6 +33,7 @@ for (const architecture of ["x86_64", "x86"]) { bytes.writeUInt16LE(x86 ? 0x10b : 0x20b, optionalHeader); bytes.writeUInt32LE(0x200, optionalHeader + 4); + if (!dll) bytes.writeUInt32LE(0x200, optionalHeader + 8); bytes.writeUInt32LE(dll ? 0 : 0x1000, optionalHeader + 16); bytes.writeUInt32LE(0x1000, optionalHeader + 20); if (x86) bytes.writeUInt32LE(0x400000, optionalHeader + 28); @@ -41,7 +42,7 @@ for (const architecture of ["x86_64", "x86"]) { bytes.writeUInt32LE(0x200, optionalHeader + 36); bytes.writeUInt16LE(6, optionalHeader + 40); bytes.writeUInt16LE(6, optionalHeader + 48); - bytes.writeUInt32LE(0x2000, optionalHeader + 56); + bytes.writeUInt32LE(dll ? 0x2000 : 0x3000, optionalHeader + 56); bytes.writeUInt32LE(0x200, optionalHeader + 60); bytes.writeUInt16LE(3, optionalHeader + 68); bytes.writeUInt16LE(x86 ? 0x8140 : 0x8160, optionalHeader + 70); @@ -60,7 +61,7 @@ for (const architecture of ["x86_64", "x86"]) { } bytes.write(".text", sectionTable, "ascii"); - bytes.writeUInt32LE(dll ? 0x180 : 0x16, sectionTable + 8); + bytes.writeUInt32LE(dll ? 0x180 : 0x50, sectionTable + 8); bytes.writeUInt32LE(0x1000, sectionTable + 12); bytes.writeUInt32LE(0x200, sectionTable + 16); bytes.writeUInt32LE(0x200, sectionTable + 20); @@ -70,11 +71,48 @@ for (const architecture of ["x86_64", "x86"]) { bytes.set( dll ? [0xe8, 0x0b, 0, 0, 0, 0x83, 0xc0, 1, 0xc3] - : [0xe8, 0x0b, 0, 0, 0, 0x31, 0xc0, 0xc3], + : [0xe8, 0x0b, 0, 0, 0, 0xe8, 0x16, 0, 0, 0, 0x31, 0xc0, 0xc3], 0x200, ); bytes.set([0xb8, 0x2a, 0, 0, 0, 0xc3], 0x210); + if (!dll) { + const dataSection = sectionTable + 40; + bytes.write(".data", dataSection, "ascii"); + bytes.writeUInt32LE(0x20, dataSection + 8); + bytes.writeUInt32LE(0x2000, dataSection + 12); + bytes.writeUInt32LE(0x200, dataSection + 16); + bytes.writeUInt32LE(0x400, dataSection + 20); + bytes.writeUInt32LE(0xc000_0040, dataSection + 36); + bytes.writeUInt32LE(0x1122_3344, 0x400); + const referenceInstructions = x86 + ? [ + "ba00204000", // MOV EDX, address: DATA only. + "a100204000", // MOV EAX, [address]: READ. + "a304204000", // MOV [address], EAX: WRITE. + "ba10204000", // Indexed base, unknown ECX. + "85db", // TEST EBX, EBX: two candidate bases. + "7405", // JZ over the alternate base. + "ba18204000", // MOV EDX, alternate base. + "c6040a01", // MOV byte [EDX + ECX], 1. + "ffd7", // CALL EDI: unknown target. + "c3", + ] + : [ + "488d15d90f0000", // LEA RDX, [RIP + displacement]: DATA. + "8b05d30f0000", // MOV EAX, [RIP + displacement]: READ. + "8905d10f0000", // MOV [RIP + displacement], EAX: WRITE. + "488d15d60f0000", // Indexed base, unknown RCX. + "85db", // TEST EBX, EBX: two candidate bases. + "7407", // JZ over the alternate base. + "488d15d30f0000", // LEA RDX, alternate base. + "c6040a01", // MOV byte [RDX + RCX], 1. + "41ffd0", // CALL R8: unknown target. + "c3", + ]; + bytes.set(Buffer.from(referenceInstructions.join(""), "hex"), 0x220); + } + if (dll) { // Export both functions; an entry-point-free library is discovered by exports. bytes.writeUInt32LE(0x1040, directories); diff --git a/scripts/lib/real-ghidra-compact-references.mjs b/scripts/lib/real-ghidra-compact-references.mjs new file mode 100644 index 000000000..be1c4c015 --- /dev/null +++ b/scripts/lib/real-ghidra-compact-references.mjs @@ -0,0 +1,170 @@ +import assert from "node:assert/strict"; + +/** Verify actual reference facts on the source-owned Windows PE fixture. */ +export async function verifyGhidraCompactReferences(call, x86, cli) { + const base = x86 ? 0x400000n : 0x1_4000_0000n; + const address = (offset) => `0x${(base + BigInt(offset)).toString(16)}`; + const cases = [ + ["data", 0x1020, 0x2000], + ["read", x86 ? 0x1025 : 0x1027, 0x2000], + ["write", x86 ? 0x102a : 0x102d, 0x2004], + ]; + const full = await call("procedure_references", { + procedure: address(0x1020), + }); + const inspected = {}; + for (const [kind, offset, target] of cases) { + const instruction = await call("inspect_native_instruction", { + address: address(offset), + }); + inspected[kind] = instruction; + const reference = instruction.references.find( + (item) => item.target_address === address(target), + ); + assert.ok(reference, `Missing ${kind} fixture reference`); + assertReference(reference, instruction.address, full.references); + assert.equal(reference.data, true); + assert.equal(reference.read, kind === "read"); + assert.equal(reference.write, kind === "write"); + } + const indexed = await call("inspect_native_instruction", { + address: address(x86 ? 0x103d : 0x1045), + }); + for (const reference of indexed.references) + assertReference(reference, indexed.address, full.references); + assert.ok( + indexed.references.some( + (reference) => + reference.primary === false && reference.source === "analysis", + ), + "Indexed fixture lacks a non-primary analyzer reference", + ); + + const { direct, targets } = await verifyDirectCall( + call, + address(0x1000), + address(0x1010), + ); + + const indirectAddress = address(x86 ? 0x1041 : 0x1049); + const indirect = await call("inspect_native_instruction", { + address: indirectAddress, + }); + const unresolved = await call("resolve_native_call_targets", { + address: indirectAddress, + }); + assert.equal(indirect.flow.kind, "call"); + assert.deepEqual(indirect.references, []); + assert.equal(unresolved.status, "unresolved"); + assert.deepEqual(unresolved.targets, []); + + const returned = await verifyEmptyReferences( + call, + address(x86 ? 0x1043 : 0x104c), + ); + + if (cli !== undefined) { + for (const instruction of [inspected.write, indexed, direct, returned]) + assert.deepEqual( + await cli("inspect-native-instruction", instruction.address), + instruction, + ); + assert.deepEqual( + await cli("resolve-native-call-targets", direct.address), + targets, + ); + assert.deepEqual( + await cli("resolve-native-call-targets", indirectAddress), + unresolved, + ); + } + return { + data_read_write: true, + source_primary_provenance: true, + full_reference_parity: true, + direct_call_reference_parity: true, + indexed_reference_count: indexed.references.length, + indexed_non_primary_count: indexed.references.filter( + (reference) => reference.primary === false, + ).length, + indexed_non_primary_analyzer_reference: true, + indexed_reference_kinds: indexed.references.map( + ({ type, read, write, primary, source }) => ({ + type, + read, + write, + primary, + source, + }), + ), + targetless_indirect: true, + empty_and_unavailable: true, + cli_mcp_parity: cli !== undefined, + }; +} + +async function verifyDirectCall(call, entry, callee) { + const direct = await call("inspect_native_instruction", { address: entry }); + const targets = await call("resolve_native_call_targets", { address: entry }); + assert.equal(targets.status, "direct"); + assert.equal(targets.targets[0].address, callee); + assert.deepEqual( + targets.targets[0].references, + direct.references.filter((item) => item.call), + ); + const full = await call("procedure_references", { procedure: entry }); + for (const reference of direct.references) + assertReference(reference, entry, full.references); + return { direct, targets }; +} + +async function verifyEmptyReferences(call, address) { + const returned = await call("inspect_native_instruction", { address }); + assert.equal(returned.flow.kind, "return"); + assert.deepEqual(returned.references, []); + assert.equal( + (await call("resolve_native_call_targets", { address })).status, + "not-call", + ); + const outside = await call("inspect_native_instruction", { address: "0x0" }); + assert.equal(outside.status, "outside-memory"); + assert.deepEqual(outside.references, []); + assert.equal( + (await call("resolve_native_call_targets", { address: "0x0" })).status, + "unavailable", + ); + return returned; +} + +function assertReference(reference, source, full) { + assert.equal(reference.source_address, source); + assert.equal(reference.provenance, "ghidra-reference-manager"); + assert.equal(typeof reference.primary, "boolean"); + assert.equal(typeof reference.source, "string"); + assert.ok(reference.source.length > 0); + const edge = full.find( + (item) => + item.source_address === source && + item.target_address === reference.target_address && + item.kind.operand_index === reference.operand_index, + ); + assert.ok(edge, "Compact reference lacks its matching full reference"); + for (const field of [ + "type", + "call", + "jump", + "indirect", + "computed", + "operand_index", + "data", + "read", + "write", + "primary", + "provenance", + ]) + assert.equal( + reference[field], + edge.kind[field], + `Reference ${field} differs from its full representation`, + ); +} diff --git a/scripts/verify-packaged-ghidra-windows.mjs b/scripts/verify-packaged-ghidra-windows.mjs index be21fde12..9a72d1d3d 100644 --- a/scripts/verify-packaged-ghidra-windows.mjs +++ b/scripts/verify-packaged-ghidra-windows.mjs @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import { requireMcpOperationResult } from "./lib/mcp-verifier-results.mjs"; +import { verifyGhidraCompactReferences } from "./lib/real-ghidra-compact-references.mjs"; import { execFile } from "node:child_process"; import { createHash } from "node:crypto"; import { @@ -358,6 +359,36 @@ try { await call("inspect_native_data_type", { type: "/undefined8" }); await call("inspect_native_instruction", { address: procedure.address }); await call("resolve_native_call_targets", { address: procedure.address }); + // Explicit caller-selected targets need not contain our fixture instructions. + const controlledTargets = [ + "rea-ghidra-windows.exe", + "rea-ghidra-windows-x86.exe", + ].map((name) => resolve("build", "fixtures", name)); + if (controlledTargets.includes(target)) + report.compactReferences = await verifyGhidraCompactReferences( + call, + target === controlledTargets[1], + async (command, address) => { + const result = await exec( + process.execPath, + [entry, command, target, address, "--provider", "ghidra", "--json"], + { + env: environment, + cwd: callerDirectory, + timeout: 360_000, + maxBuffer: 8 * 1024 * 1024, + }, + ); + const evidence = JSON.parse(result.stdout); + assert.ok(evidence.error === undefined, `CLI ${command} failed`); + const contract = TOOL_CONTRACTS.find( + ({ name }) => name === command.replaceAll("-", "_"), + ); + assert.ok(contract, `Missing CLI ${command} contract`); + contract.outputSchema.parse(evidence); + return evidence.normalized_result; + }, + ); const expected = ( await import( pathToFileURL(join(packageRoot, "dist/ghidra/GhidraSessionValues.js")) diff --git a/scripts/verify-real-ghidra-windows.mjs b/scripts/verify-real-ghidra-windows.mjs index 3332ea4e2..6eb501437 100644 --- a/scripts/verify-real-ghidra-windows.mjs +++ b/scripts/verify-real-ghidra-windows.mjs @@ -24,6 +24,7 @@ import { windowsP0Capabilities } from "../dist/ghidra/GhidraProviderCapabilities import { GHIDRA_SESSION_CAPABILITIES } from "../dist/ghidra/GhidraSessionValues.js"; import { parseBinaryTarget } from "../dist/application/BinaryTargetResolver.js"; import { completeVerifierRun, createVerifierRun } from "./lib/verifier-run.mjs"; +import { verifyGhidraCompactReferences } from "./lib/real-ghidra-compact-references.mjs"; const verifierRun = createVerifierRun(); const SESSION_CAPABILITIES = GHIDRA_SESSION_CAPABILITIES.filter( @@ -247,6 +248,14 @@ try { `Windows Ghidra operation proof was incomplete: ${JSON.stringify([...observed])}`, ); + // The reference controls occupy the EXE fixture's additional function. + // DLL fixtures retain their independent exported-caller/return controls. + const compactReferences = dllFixture + ? null + : await verifyGhidraCompactReferences( + functionOperation, + architecture === "x86", + ); report = { ok: true, provider: { id: "ghidra", version: SUPPORTED_GHIDRA_VERSION }, @@ -261,6 +270,7 @@ try { transport: "authenticated-loopback-tcp", operations: [...observed].sort((left, right) => left.localeCompare(right)), cleanup: "complete", + compact_references: compactReferences, native_controls: { job_object: true, protected_dacl: true, diff --git a/src/domain/native/nativeInstruction.fixture.ts b/src/domain/native/nativeInstruction.fixture.ts new file mode 100644 index 000000000..ff0875f97 --- /dev/null +++ b/src/domain/native/nativeInstruction.fixture.ts @@ -0,0 +1,60 @@ +import type { JsonValue } from "../jsonValue.js"; + +/** Wire-boundary controls; these do not establish real Ghidra behavior. */ +export const compactReference = () => ({ + source_address: "0x401000", + target_address: "0x402000", + type: "COMPUTED_CALL", + call: true, + jump: false, + indirect: false, + computed: true, + operand_index: 1, + data: false, + read: false, + write: false, + primary: false, + provenance: "ghidra-reference-manager", + source: "analysis", +}); + +export const compactInstruction = (references: JsonValue[] = []) => ({ + address: "0x401000", + status: "decoded", + procedure: "0x401000", + architecture: "x86:LE:32:default", + mode: "default", + bytes: "ffd0", + length: 2, + mnemonic: "CALL", + raw_disassembly: "CALL EAX", + operands: [], + flow: { + kind: "call", + conditional: false, + computed: true, + direct_destinations: [], + }, + references, + limitations: ["Static references do not establish runtime targets."], +}); + +export const compactCallTargets = (references: JsonValue[] = []) => ({ + call_site: "0x401000", + procedure: "0x401000", + status: references.length === 0 ? "unresolved" : "resolved-indirect", + mechanism: "computed", + targets: + references.length === 0 + ? [] + : [ + { + address: "0x402000", + procedure: null, + status: "resolved-indirect", + basis: "provider-reference", + references, + }, + ], + limitations: ["Static references do not establish runtime targets."], +}); diff --git a/src/domain/native/nativeInstruction.ts b/src/domain/native/nativeInstruction.ts index 744707ed2..c13a80ca0 100644 --- a/src/domain/native/nativeInstruction.ts +++ b/src/domain/native/nativeInstruction.ts @@ -13,6 +13,33 @@ const reference = z.strictObject({ indirect: z.boolean(), computed: z.boolean(), operand_index: z.number().int(), + source_address: z.string().min(1).nullable().optional(), + data: z.boolean().nullable().optional(), + read: z.boolean().nullable().optional(), + write: z.boolean().nullable().optional(), + primary: z + .boolean() + .nullable() + .optional() + .describe( + "Provider primary-reference status, not proof of literal addressing or a runtime access. Null or omission means unavailable.", + ), + provenance: z + .string() + .min(1) + .nullable() + .optional() + .describe( + "Provider subsystem reporting this reference; null or omission means unavailable.", + ), + source: z + .string() + .min(1) + .nullable() + .optional() + .describe( + "Provider reference origin, such as analysis, default, imported or user_defined; null or omission means unavailable.", + ), }); /** Provider-decoded facts for one instruction; operand tokens preserve their native ordering. */ export const nativeInstructionSchema = z.strictObject({ diff --git a/src/ghidra/GhidraFunctionValues.test.ts b/src/ghidra/GhidraFunctionValues.test.ts index 6b92d2deb..a99d304a7 100644 --- a/src/ghidra/GhidraFunctionValues.test.ts +++ b/src/ghidra/GhidraFunctionValues.test.ts @@ -3,6 +3,11 @@ import { describe, expect, it } from "vitest"; import type { JsonValue } from "../domain/jsonValue.js"; import { jsonValueSchema } from "../domain/jsonValue.js"; import { functionDossierSchema } from "../domain/hopperValues.js"; +import { + compactReference, + compactInstruction, + compactCallTargets, +} from "../domain/native/nativeInstruction.fixture.js"; import { parseGhidraFunctionResult, type GhidraFunctionOperation, @@ -118,6 +123,75 @@ describe("Ghidra function-analysis result values", () => { }); }); +describe("Ghidra compact reference provenance", () => { + const operations = [ + ["inspect_native_instruction", compactInstruction], + ["resolve_native_call_targets", compactCallTargets], + ] as const; + + it.each(operations)( + "preserves complete, legacy and unknown facts in %s", + (operation, output) => { + const complete = compactReference(); + const legacy = Object.fromEntries( + Object.entries(complete).filter( + ([key]) => + ![ + "source_address", + "data", + "read", + "write", + "primary", + "provenance", + "source", + ].includes(key), + ), + ); + const unknown = { + ...legacy, + source_address: null, + data: null, + read: null, + write: null, + primary: null, + provenance: null, + source: null, + }; + for (const reference of [complete, legacy, unknown]) { + const expected = output([reference]); + const parsed = parseGhidraFunctionResult(operation, expected); + if (!parsed.ok) throw parsed.error; + expect(parsed.value).toEqual(expected); + } + }, + ); + + it.each(operations)( + "rejects malformed present provenance in %s", + (operation, output) => { + for (const [field, value] of [ + ["source_address", 1], + ["data", "true"], + ["read", 1], + ["write", 0], + ["primary", "false"], + ["provenance", ""], + ["source", false], + ] as const) { + expect( + parseGhidraFunctionResult( + operation, + output([{ ...compactReference(), [field]: value }]), + ), + ).toMatchObject({ + ok: false, + error: { _tag: "AnalysisOutputError" }, + }); + } + }, + ); +}); + describe("Ghidra jump-table mapping contract", () => { it("preserves evidenced defaults separately from cases and unresolved targets", () => { const dossier = functionDossierSchema.parse(ghidraFunctionDossier()); diff --git a/tests/boundary/mcp/ghidraCompactReferencesMcp.test.ts b/tests/boundary/mcp/ghidraCompactReferencesMcp.test.ts new file mode 100644 index 000000000..6495ead9b --- /dev/null +++ b/tests/boundary/mcp/ghidraCompactReferencesMcp.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from "vitest"; +import { ok } from "../../../src/domain/result.js"; +import { + compactReference, + compactInstruction, + compactCallTargets, +} from "../../../src/domain/native/nativeInstruction.fixture.js"; +import { connectGhidraMcp, sessionEvidence } from "./ghidraMcpHarness.js"; + +describe("compact Ghidra reference MCP evidence", () => { + it("retains access kind, non-primary analyzer references and call-target provenance", async () => { + const base = { + ...compactReference(), + call: false, + computed: false, + data: true, + }; + const references = [ + { ...base, type: "DATA", primary: true, operand_index: 1 }, + { ...base, type: "READ", primary: true, read: true, operand_index: 1 }, + { ...base, type: "WRITE", primary: false, write: true, operand_index: 0 }, + ]; + const outputs = { + inspect_native_instruction: compactInstruction(references), + resolve_native_call_targets: compactCallTargets([compactReference()]), + }; + const harness = await connectGhidraMcp( + "ghidra-compact-provenance", + (operation) => { + if ( + operation !== "inspect_native_instruction" && + operation !== "resolve_native_call_targets" + ) + throw new TypeError(`Unexpected operation: ${operation}`); + return Promise.resolve(ok(outputs[operation])); + }, + ); + try { + for (const name of [ + "inspect_native_instruction", + "resolve_native_call_targets", + ] as const) { + const result = await harness.mcp.callTool({ + name, + arguments: { address: "0x401000" }, + }); + expect(result.isError).not.toBe(true); + const evidence = sessionEvidence( + harness.session, + result.structuredContent, + ); + expect(evidence.normalized_result).toEqual(outputs[name]); + } + } finally { + await harness.close(); + } + }, 30_000); + + it("preserves empty references and unresolved targets without manufacturing provenance", async () => { + const outputs = { + inspect_native_instruction: compactInstruction(), + resolve_native_call_targets: compactCallTargets(), + }; + const harness = await connectGhidraMcp( + "ghidra-compact-unknown", + (operation) => { + if ( + operation !== "inspect_native_instruction" && + operation !== "resolve_native_call_targets" + ) + throw new TypeError(`Unexpected operation: ${operation}`); + return Promise.resolve(ok(outputs[operation])); + }, + ); + try { + for (const name of [ + "inspect_native_instruction", + "resolve_native_call_targets", + ] as const) { + const result = await harness.mcp.callTool({ + name, + arguments: { address: "0x401000" }, + }); + expect(result.isError).not.toBe(true); + expect( + sessionEvidence(harness.session, result.structuredContent) + .normalized_result, + ).toEqual(outputs[name]); + } + } finally { + await harness.close(); + } + }, 30_000); +});