From 84a17d55199b1b46493aea6bf4e41029a3b200fc Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Fri, 9 Oct 2026 03:19:47 +0800 Subject: [PATCH] fix(ghidra): preserve function references and snapshot boundaries Collect references from the complete owned function body and exact entry, including instruction-interior destinations and embedded-data sources. Bind the corrected semantics to the analysis profile so legacy snapshots cannot replay incomplete results. Preserve snapshot mismatch reasons and recovery advice through CLI and MCP without disturbing live sessions. Return exact entry candidates for ambiguous function names. Expand the real-provider regression workflow and replace redundant parser assertions with malformed-output coverage through the pinned MCP client. Verified with Ghidra 12.1.4 on macOS arm64: full host-native lane, real CLI/MCP boundary probes, 30 focused tests, and typecheck/lint. --- bridge/ghidra/ReaGhidraBridge.java | 36 ++-- docs/native-investigation.md | 10 + docs/testing.md | 9 +- scripts/lib/ghidra-reference-snapshot-e2e.mjs | 184 ++++++++++++++++++ scripts/lib/real-ghidra-boundaries.mjs | 85 ++++++++ .../binary/AnalysisSnapshotCache.ts | 7 +- src/application/binary/BinarySessionOpen.ts | 9 +- .../binary/BinarySessionRecords.ts | 7 +- src/domain/analysisErrorPresentation.ts | 4 + src/domain/analysisErrorProjection.ts | 7 +- src/domain/evidenceErrors.ts | 3 + src/ghidra/GhidraAnalysisProfile.ts | 1 + src/ghidra/GhidraFunctionValues.test.ts | 98 ---------- src/ghidra/GhidraProviderCapabilities.ts | 2 + tests/boundary/mcp/ghidraInventoryMcp.test.ts | 12 +- tests/conformance/ghidra/inventory.c | 4 + 16 files changed, 358 insertions(+), 120 deletions(-) create mode 100644 scripts/lib/ghidra-reference-snapshot-e2e.mjs diff --git a/bridge/ghidra/ReaGhidraBridge.java b/bridge/ghidra/ReaGhidraBridge.java index 0fa3895a6..784a33da8 100644 --- a/bridge/ghidra/ReaGhidraBridge.java +++ b/bridge/ghidra/ReaGhidraBridge.java @@ -52,6 +52,8 @@ import ghidra.app.decompiler.ClangCaseToken; import ghidra.app.decompiler.ClangNode; import ghidra.framework.Application; import ghidra.program.model.address.Address; +import ghidra.program.model.address.AddressIterator; +import ghidra.program.model.address.AddressSet; import ghidra.program.model.address.AddressSpace; import ghidra.program.model.address.AddressRange; import ghidra.program.model.address.AddressRangeIterator; @@ -723,8 +725,7 @@ public final class ReaGhidraBridge extends HeadlessScript { if (!direction.equals("incoming") && !direction.equals("outgoing")) { throw new RequestFailure("invalid_request", "direction must be incoming or outgoing"); } - InstructionScan scan = scanInstructions(function, Integer.MAX_VALUE); - List references = collectReferences(scan.instructions, direction); + List references = collectReferences(function, direction); JsonArray edges = new JsonArray(); for (Reference reference : references) { edges.add(referenceEdge(reference)); @@ -735,7 +736,7 @@ public final class ReaGhidraBridge extends HeadlessScript { result.add("references", edges); result.addProperty("reference_kinds_available", true); JsonArray unresolvedCalls = new JsonArray(); - if (direction.equals("outgoing")) for (Instruction instruction : scan.instructions) { + if (direction.equals("outgoing")) for (Instruction instruction : scanInstructions(function, Integer.MAX_VALUE).instructions) { if (!instruction.getFlowType().isCall()) continue; boolean resolved = false; for (Reference reference : instruction.getReferencesFrom()) if (reference.getReferenceType().isCall()) resolved = true; @@ -1092,11 +1093,11 @@ public final class ReaGhidraBridge extends HeadlessScript { if (pseudocode == null) { pseudocode = ""; } - List incomingReferences = collectReferences(scan.instructions, "incoming") + List incomingReferences = collectReferences(function, "incoming") .stream() .filter(reference -> !function.getBody().contains(reference.getFromAddress())) .toList(); - List outgoingReferences = collectReferences(scan.instructions, "outgoing"); + List outgoingReferences = collectReferences(function, "outgoing"); JsonArray incoming = referenceEdges(incomingReferences); JsonArray outgoing = referenceEdges(outgoingReferences); JsonArray comments = comments(scan.instructions); @@ -1615,7 +1616,9 @@ public final class ReaGhidraBridge extends HeadlessScript { } } if (matches.size() > 1) - throw new RequestFailure("ambiguous", "Ghidra procedure name is ambiguous: " + value); + throw new RequestFailure("ambiguous", "Ghidra procedure name is ambiguous: " + value + + "; select an exact entry address from " + matches.stream() + .map(function -> canonicalAddress(function.getEntryPoint())).toList()); if (matches.size() == 1) return matches.get(0); } // Retain legacy bare-hex address input only when no symbol matches. @@ -2550,21 +2553,32 @@ public final class ReaGhidraBridge extends HeadlessScript { } private List collectReferences( - List instructions, + Function function, String direction) throws Exception { + // References can target instruction interiors or originate in embedded + // data. Use actual body ownership, including an external's exact entry. + AddressSet scope = new AddressSet(function.getBody()); + scope.add(function.getEntryPoint()); + boolean outgoing = direction.equals("outgoing"); + AddressIterator addresses = outgoing + ? currentProgram.getReferenceManager().getReferenceSourceIterator(scope, true) + : currentProgram.getReferenceManager().getReferenceDestinationIterator(scope, true); TreeMap observed = new TreeMap<>(); - for (Instruction instruction : instructions) { + while (addresses.hasNext()) { monitor.checkCancelled(); - if (direction.equals("outgoing")) { + Address address = addresses.next(); + if (outgoing) { for (Reference reference : currentProgram.getReferenceManager() - .getReferencesFrom(instruction.getAddress())) { + .getReferencesFrom(address)) { + monitor.checkCancelled(); addReference(observed, reference); } } else { ReferenceIterator iterator = currentProgram.getReferenceManager() - .getReferencesTo(instruction.getAddress()); + .getReferencesTo(address); while (iterator.hasNext()) { + monitor.checkCancelled(); addReference(observed, iterator.next()); } } diff --git a/docs/native-investigation.md b/docs/native-investigation.md index 4f61375bf..f394573f0 100644 --- a/docs/native-investigation.md +++ b/docs/native-investigation.md @@ -79,6 +79,16 @@ an explicit `0x` or address-space prefix selects an address; otherwise an exact database symbol name takes precedence over a bare hexadecimal address. Thus a function renamed to `dead` remains selectable by name. Use the names returned by the inventory, including their namespaces and any platform symbol prefix. +Overloads can share a fully qualified name; ambiguity errors return every +matching entry address so the caller can select the intended function directly. + +Ghidra function references cover the complete function-body AddressSet and its +exact entry, including references into instruction interiors and references +from embedded data. Addresses inside an enclosing span but outside the owned +body are excluded. `procedure_references` returns internal and external edges; +`analyze_function` omits incoming edges whose sources belong to the same function. +The reference collector's semantics participate in the analysis profile, so +older snapshots cannot replay the former incomplete results under this profile. ```bash rea inspect-native-instruction
--provider ghidra diff --git a/docs/testing.md b/docs/testing.md index 1673a4701..051122f00 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -274,8 +274,13 @@ path exercises private Unix socket allocation and cleanup, including cancellatio after a real headless process launches. Native annotation probes reject NUL and unpaired Unicode surrogates without partial edits or a broken bridge, preserve supported Unicode and control text, and check lossless malformed-text diagnostics. -Memory-to-file mapping is checked against original artifact bytes. Malformed -annotation readback, memory completeness, and inventory data remain separate +Memory-to-file mapping is checked against original artifact bytes. +The fixture also stores a pointer one byte past a function entry; exact +xrefs, raw procedure references, and CLI/MCP dossiers must retain that data edge. +A valid legacy snapshot reconstructs the former omitted edge under its older +profile; CLI and MCP must reject that binding with a mismatch reason and +recovery advice. The rejected open must preserve the active live session. +Malformed annotation readback, memory completeness, and inventory data remain separate SDK/provider integration cases; success from a real provider cannot establish rejection of a contradictory provider response. diff --git a/scripts/lib/ghidra-reference-snapshot-e2e.mjs b/scripts/lib/ghidra-reference-snapshot-e2e.mjs new file mode 100644 index 000000000..905ed6b02 --- /dev/null +++ b/scripts/lib/ghidra-reference-snapshot-e2e.mjs @@ -0,0 +1,184 @@ +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { writeFile, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { parseBinaryTarget } from "../../dist/application/BinaryTargetResolver.js"; +import { createAnalysisProfile } from "../../dist/domain/analysisProfile.js"; +import { + createAnalysisSnapshotEntry, + parseAnalysisSnapshot, + serializeAnalysisSnapshot, + snapshotBinding, + snapshotTarget, +} from "../../dist/domain/analysisSnapshot.js"; +import { createEvidence } from "../../dist/domain/evidence.js"; +import { createEvidenceBundle } from "../../dist/domain/evidenceBundle.js"; +import { requireMcpResult } from "./mcp-verifier-results.mjs"; + +/** Reject a valid older snapshot whose instruction-only collector omitted a real edge. */ +export async function verifyLegacyGhidraReferenceSnapshot( + client, + { target, procedure, omittedEdge, entrypoint, env }, +) { + const bundle = requireMcpResult( + await client.callTool({ + name: "get_evidence_bundle", + arguments: {}, + }), + "get_evidence_bundle", + ); + const observed = bundle.records.find( + (record) => + record.operation === "analyze_function" && + record.parameters.procedure === procedure, + ); + assert.ok(observed); + const { function_references: coverage, ...legacyParameters } = + observed.analysis_profile.parameters; + assert.equal( + typeof coverage, + "string", + "Reference semantics must participate in snapshot identity", + ); + const legacyProfile = createAnalysisProfile( + observed.provider, + legacyParameters, + ); + assert.notEqual(legacyProfile.digest, observed.analysis_profile.digest); + const parsed = await parseBinaryTarget( + target.path, + process.cwd(), + process.arch, + ); + if (!parsed.ok) throw parsed.error; + const nativeTarget = parsed.value; + const result = structuredClone(observed.normalized_result); + // Reconstruct the observed pre-fix omission; all other data + // comes from the real provider and the file has a fully valid Evidence binding. + result.incoming_references = result.incoming_references.filter( + (edge) => + edge.source_address !== omittedEdge.source_address || + edge.target_address !== omittedEdge.target_address, + ); + assert.equal( + result.incoming_references.length, + observed.normalized_result.incoming_references.length - 1, + ); + const evidence = createEvidence(nativeTarget, observed.provider, { + operation: observed.operation, + parameters: observed.parameters, + result, + rawResult: result, + analysisProfile: legacyProfile, + limitations: observed.limitations, + locations: observed.locations, + }); + const binding = snapshotBinding(legacyProfile); + const identity = snapshotTarget(nativeTarget); + const snapshot = parseAnalysisSnapshot({ + target: identity, + binding, + entries: [ + createAnalysisSnapshotEntry({ + target: identity, + binding, + operation: observed.operation, + parameters: observed.parameters, + execution: { + result, + rawResult: result, + provider: observed.provider, + analysisProfile: legacyProfile, + limitations: observed.limitations, + locations: observed.locations, + subject: nativeTarget, + }, + }), + ], + evidence_bundle: createEvidenceBundle([evidence]), + }); + assert.equal( + snapshot.entries.length, + 1, + "The legacy query must remain valid and replayable under its own profile", + ); + const path = join(env.TMPDIR, `legacy-reference-${randomUUID()}.json`); + await writeFile(path, serializeAnalysisSnapshot(snapshot), { flag: "wx" }); + try { + const session = requireMcpResult( + await client.callTool({ name: "binary_session", arguments: {} }), + "binary_session", + ); + const reply = await client.callTool({ + name: "open_binary", + arguments: { + path: target.path, + provider_id: "ghidra", + snapshot_path: path, + }, + }); + assert.equal(reply.isError, true); + const mcpRejection = reply.structuredContent.error; + assert.equal(mcpRejection.code, "evidence_integrity_mismatch"); + assert.match(mcpRejection.details.reason, /profile_mismatch/u); + assert.match( + mcpRejection.remediation.action, + /without this snapshot.*fresh snapshot/u, + ); + assert.deepEqual( + requireMcpResult( + await client.callTool({ name: "binary_session", arguments: {} }), + "binary_session", + ), + session, + "A rejected snapshot must preserve the selected session", + ); + const retained = requireMcpResult( + await client.callTool({ + name: "analyze_function", + arguments: { procedure }, + }), + "analyze_function", + ); + assert.ok( + retained.incoming_references.some( + (edge) => + edge.source_address === omittedEdge.source_address && + edge.target_address === omittedEdge.target_address, + ), + "A rejected legacy snapshot must not replace complete live analysis", + ); + await assert.rejects( + promisify(execFile)( + process.execPath, + [ + entrypoint, + "function", + target.path, + procedure, + "--provider", + "ghidra", + "--snapshot", + path, + "--json", + ], + { env, timeout: 240000, maxBuffer: 72 * 1024 * 1024 }, + ), + (error) => { + assert.equal(error.code, 1); + const rejection = JSON.parse(error.stdout); + assert.equal(rejection.code, "evidence_integrity_mismatch"); + assert.equal(rejection.details.reason, mcpRejection.details.reason); + assert.equal( + rejection.remediation.action, + mcpRejection.remediation.action, + ); + return true; + }, + ); + } finally { + await rm(path); + } +} diff --git a/scripts/lib/real-ghidra-boundaries.mjs b/scripts/lib/real-ghidra-boundaries.mjs index eb8986c54..c89e4533f 100644 --- a/scripts/lib/real-ghidra-boundaries.mjs +++ b/scripts/lib/real-ghidra-boundaries.mjs @@ -8,6 +8,7 @@ import { promisify } from "node:util"; import Ajv from "ajv/dist/2020.js"; import addFormats from "ajv-formats"; import { mcpTextValue, requireMcpResult } from "./mcp-verifier-results.mjs"; +import { verifyLegacyGhidraReferenceSnapshot } from "./ghidra-reference-snapshot-e2e.mjs"; /** Probe real Ghidra location, annotation and error contracts through public adapters. */ export async function verifyGhidraBoundaries( @@ -56,6 +57,7 @@ export async function verifyGhidraBoundaries( assert.match(JSON.stringify(error.details.issues), diagnostic); } rejectedCalls++; + return reply.structuredContent?.error; }; const cli = async (command, value, flags = []) => { const { stdout } = await promisify(execFile)( @@ -93,6 +95,61 @@ export async function verifyGhidraBoundaries( assert.equal((await stat(socketRoot)).mode & 0o777, 0o700); const address = original.procedure.address; const name = original.procedure.name; + const names = await call("list_names"); + const leaf = names.find((item) => + item.value.endsWith("rea_ghidra_inventory_leaf"), + ); + const pointer = names.find((item) => + item.value.endsWith("rea_ghidra_interior_pointer"), + ); + assert.ok( + leaf && pointer, + "Source fixture lacks the interior function pointer", + ); + const interiorTarget = `0x${(BigInt(leaf.address) + 1n).toString(16)}`; + assert.ok( + (await call("xrefs", { address: interiorTarget })).includes( + pointer.address, + ), + ); + const incoming = await call("procedure_references", { + procedure: leaf.address, + direction: "incoming", + }); + const interiorEdge = incoming.references.find( + (edge) => + edge.source_address === pointer.address && + edge.target_address === interiorTarget, + ); + assert.ok( + interiorEdge, + "Procedure references omitted an observed interior-body edge", + ); + assert.equal(interiorEdge.target_procedure?.address, leaf.address); + assert.equal(interiorEdge.source_procedure, null); + assert.equal(interiorEdge.kind.data, true); + const leafDossier = await call("analyze_function", { + procedure: leaf.address, + }); + assert.ok( + leafDossier.incoming_references.some( + (edge) => + edge.source_address === pointer.address && + edge.target_address === interiorTarget, + ), + ); + assert.deepEqual( + (await cli("function", leaf.value)).incoming_references, + leafDossier.incoming_references, + "CLI and MCP must retain the same interior-body references", + ); + await verifyLegacyGhidraReferenceSnapshot(client, { + target, + procedure: leaf.address, + omittedEdge: interiorEdge, + entrypoint, + env, + }); const baseline = await call("inspect_native_instruction", { address }); assert.equal(baseline.status, "decoded"); const bytes = await call("read_bytes", { address, length: baseline.length }); @@ -442,12 +499,37 @@ export async function verifyGhidraBoundaries( ); assert.equal(await call("address_name", { address }), renamed); } + for (const selected of [address, indirectProcedure.address]) { + await call("annotate_native_function", { + procedure: selected, + name: "rea_ambiguous", + }); + } + const ambiguous = await invalid( + "procedure_address", + { procedure: "rea_ambiguous" }, + /ambiguous.*select an exact entry address/u, + ); + for (const selected of [address, indirectProcedure.address]) { + assert.ok( + JSON.stringify(ambiguous.details.issues).includes(selected), + "Ambiguity diagnostics must retain every matching entry address", + ); + assert.equal( + await call("procedure_address", { procedure: selected }), + selected, + ); + } await call("close_binary"); await assert.rejects(access(socketRoot), { code: "ENOENT" }); await assert.rejects(access(runtimeRoot), { code: "ENOENT" }); await call("open_binary", { path: target.path, provider_id: "ghidra" }); assert.equal(await call("procedure_address", { procedure: name }), address); assert.equal(await call("address_name", { address }), name); + assert.equal( + await call("procedure_address", { procedure: indirectProcedure.value }), + indirectProcedure.address, + ); assert.deepEqual( (await call("analyze_function", { procedure: address })).comments, original.comments, @@ -468,6 +550,9 @@ export async function verifyGhidraBoundaries( mutation_rollback: true, annotation_native_text_validation: true, lossless_unicode_transport: true, + complete_function_body_references: true, + ambiguity_candidates_inline: true, + legacy_reference_snapshot_rejected: true, source_immutable: true, reopen_discards_edits: true, long_tmpdir_private_socket_cleanup: true, diff --git a/src/application/binary/AnalysisSnapshotCache.ts b/src/application/binary/AnalysisSnapshotCache.ts index 0c80fa4df..4f39fc970 100644 --- a/src/application/binary/AnalysisSnapshotCache.ts +++ b/src/application/binary/AnalysisSnapshotCache.ts @@ -2,7 +2,10 @@ import type { BinaryTarget } from "../../domain/binaryTarget.js"; import type { AnalysisProfileCommitment } from "../../domain/analysisProfile.js"; import type { JsonValue } from "../../domain/jsonValue.js"; import type { EvidenceBundle } from "../../domain/evidenceBundle.js"; -import { EvidenceIntegrityError } from "../../domain/evidenceErrors.js"; +import { + EvidenceIntegrityError, + AnalysisSnapshotMismatchError, +} from "../../domain/evidenceErrors.js"; import { NoBinaryOpenError } from "../../domain/configurationErrors.js"; import { type AnalysisError } from "../../domain/analysisErrorBase.js"; import { err, ok, type Result } from "../../domain/result.js"; @@ -189,7 +192,7 @@ export class AnalysisSnapshotCache { !snapshotMatchesProfile(validated.binding, active.profile)) ) return err( - new EvidenceIntegrityError( + new AnalysisSnapshotMismatchError( "Analysis snapshot profile_mismatch: target, provider, or analysis profile does not match the active binary", ), ); diff --git a/src/application/binary/BinarySessionOpen.ts b/src/application/binary/BinarySessionOpen.ts index 859d8f413..8c9634c7f 100644 --- a/src/application/binary/BinarySessionOpen.ts +++ b/src/application/binary/BinarySessionOpen.ts @@ -9,7 +9,10 @@ import { import type { AnalysisSnapshot } from "../../domain/analysisSnapshot.js"; import { parseBinaryTarget } from "../BinaryTargetResolver.js"; import { type BinaryTarget } from "../../domain/binaryTarget.js"; -import { EvidenceIntegrityError } from "../../domain/evidenceErrors.js"; +import { + EvidenceIntegrityError, + AnalysisSnapshotMismatchError, +} from "../../domain/evidenceErrors.js"; import { type AnalysisError } from "../../domain/analysisErrorBase.js"; import { err, type Result } from "../../domain/result.js"; import type { SessionProviderRoute } from "./SessionProviderRouter.js"; @@ -135,11 +138,11 @@ const validateSnapshot = ({ snapshot !== undefined && (profile === null || !snapshotMatchesBinding(snapshot, target, profile)) ) - return new EvidenceIntegrityError( + return new AnalysisSnapshotMismatchError( "Analysis snapshot profile_mismatch: target, provider, or analysis profile does not match the requested binary", ); if (current === undefined && !stagedSnapshotMatches(target, profile)) - return new EvidenceIntegrityError( + return new AnalysisSnapshotMismatchError( "Analysis snapshot profile_mismatch: staged target, provider, or analysis profile does not match", ); return undefined; diff --git a/src/application/binary/BinarySessionRecords.ts b/src/application/binary/BinarySessionRecords.ts index ecde344f6..bafce6b0f 100644 --- a/src/application/binary/BinarySessionRecords.ts +++ b/src/application/binary/BinarySessionRecords.ts @@ -5,7 +5,10 @@ import type { Evidence } from "../../domain/evidence.js"; import type { EvidenceBundle } from "../../domain/evidenceBundle.js"; import type { JsonValue } from "../../domain/jsonValue.js"; import { evidenceBundleForTarget } from "../../domain/evidenceBundle.js"; -import { EvidenceIntegrityError } from "../../domain/evidenceErrors.js"; +import { + EvidenceIntegrityError, + AnalysisSnapshotMismatchError, +} from "../../domain/evidenceErrors.js"; import { type AnalysisError } from "../../domain/analysisErrorBase.js"; import { type UnknownRegistryError } from "../../domain/unknownRegistryError.js"; import type { @@ -129,7 +132,7 @@ export abstract class BinarySessionRecords { const active = this.activeAnalysisBinding(); if (active?.profile === null) return err( - new EvidenceIntegrityError( + new AnalysisSnapshotMismatchError( "Analysis snapshot profile_mismatch: the active target has no concrete analysis profile", ), ); diff --git a/src/domain/analysisErrorPresentation.ts b/src/domain/analysisErrorPresentation.ts index 750541022..f1c8e942d 100644 --- a/src/domain/analysisErrorPresentation.ts +++ b/src/domain/analysisErrorPresentation.ts @@ -16,6 +16,7 @@ import { EvidenceFileError, EvidenceIntegrityError, EvidenceReferenceError, + AnalysisSnapshotMismatchError, } from "./evidenceErrors.js"; import { HopperRemoteError, @@ -35,6 +36,8 @@ import { type AnalysisErrorProjection } from "./analysisErrorProjection.js"; export const analysisErrorRemediationAction = ( error: AnalysisError, ): string => { + if (error instanceof AnalysisSnapshotMismatchError) + return "Run analysis without this snapshot, then save a fresh snapshot using the intended artifact, provider, and analysis profile."; if (error instanceof AnalysisUnsupportedTargetError) return "Select a target supported by this operation or choose an operation supporting the reported target format."; if (error instanceof AnalysisResourceConstraintError) @@ -151,6 +154,7 @@ const STATIC_ERROR_CATEGORIES: Readonly< }; export const analysisErrorUserMessage = (error: AnalysisError): string => { + if (error instanceof AnalysisSnapshotMismatchError) return error.message; if (error instanceof AnalysisUnsupportedTargetError) return error.message; if (error instanceof AnalysisResourceConstraintError) return error.reason; if (error instanceof AnalysisAccessDeniedError) diff --git a/src/domain/analysisErrorProjection.ts b/src/domain/analysisErrorProjection.ts index 59d61f932..1e14bde97 100644 --- a/src/domain/analysisErrorProjection.ts +++ b/src/domain/analysisErrorProjection.ts @@ -20,7 +20,11 @@ import { import { ArtifactOperationError } from "./artifactOperationError.js"; import { BinaryTargetError } from "./configurationErrors.js"; import { BrowserObservationError } from "./browserObservationError.js"; -import { EvidenceFileError, EvidenceReferenceError } from "./evidenceErrors.js"; +import { + EvidenceFileError, + EvidenceIntegrityError, + EvidenceReferenceError, +} from "./evidenceErrors.js"; import { HopperCancelledError, HopperProcessError, @@ -264,6 +268,7 @@ const requestErrorDetails = ( expected: error.expected, actual: error.actual, }; + if (error instanceof EvidenceIntegrityError) return { reason: error.message }; return undefined; }; diff --git a/src/domain/evidenceErrors.ts b/src/domain/evidenceErrors.ts index f6e0c3237..37fe1127c 100644 --- a/src/domain/evidenceErrors.ts +++ b/src/domain/evidenceErrors.ts @@ -17,6 +17,9 @@ export class EvidenceIntegrityError extends AnalysisError { } } +/** A valid snapshot belongs to a different target, provider, or analysis profile. */ +export class AnalysisSnapshotMismatchError extends EvidenceIntegrityError {} + /** A session Evidence reference is missing or has the wrong semantic identity. */ export class EvidenceReferenceError extends EvidenceIntegrityError { constructor( diff --git a/src/ghidra/GhidraAnalysisProfile.ts b/src/ghidra/GhidraAnalysisProfile.ts index 9e1f550df..1c2ec4d7f 100644 --- a/src/ghidra/GhidraAnalysisProfile.ts +++ b/src/ghidra/GhidraAnalysisProfile.ts @@ -42,6 +42,7 @@ export const resolveGhidraAnalysisProfile = ( annotation_policy: "atomic-function-entry-metadata-v1", load_image_observations: "source-mappings-entry-context-v2", function_body_evidence: "complete-inclusive-ranges-v1", + function_references: "complete-body-and-entry-reference-manager-v2", location_resolution: "explicit-address-exact-name-first-v2", process_launch: installation.platform === "darwin" diff --git a/src/ghidra/GhidraFunctionValues.test.ts b/src/ghidra/GhidraFunctionValues.test.ts index 62b3b6b65..b194e3128 100644 --- a/src/ghidra/GhidraFunctionValues.test.ts +++ b/src/ghidra/GhidraFunctionValues.test.ts @@ -16,104 +16,6 @@ import { } from "../domain/ghidraValues.fixture.js"; describe("Ghidra function-analysis result values", () => { - it("parses provider-classified function facts and reference kinds", () => { - expect( - parseGhidraFunctionResult("read_function_instructions", { - procedure: ghidraFunctionIdentity(), - instructions: ["0x401000: push rbp"], - limitations: ["Ghidra-specific instruction text."], - }), - ).toMatchObject({ ok: true }); - expect( - parseGhidraFunctionResult("read_function_instructions", { - procedure: { - ...ghidraFunctionIdentity(), - address: "0X401000", - }, - instructions: ["0x401000: push rbp"], - limitations: ["Ghidra-specific instruction text."], - }), - ).toMatchObject({ - ok: false, - error: { _tag: "AnalysisOutputError" }, - }); - expect( - parseGhidraFunctionResult("procedure_info", { - name: "fixture_main", - entrypoint: "0x401000", - basicblock_count: 1, - length: 6, - signature: "int fixture_main(void)", - locals: [ - { - description: "int local @ Stack[-0x4]:4", - provenance: "ghidra-function-database", - }, - ], - classification: ghidraFunctionClassification(), - body: ghidraFunctionBody(), - }), - ).toMatchObject({ ok: true }); - expect( - parseGhidraFunctionResult("procedure_references", { - procedure: ghidraFunctionIdentity(), - direction: "outgoing", - references: [ghidraReferenceEdge()], - }), - ).toMatchObject({ - ok: true, - value: { - references: [ - { - kind: { - available: true, - provenance: "ghidra-reference-manager", - data: true, - }, - }, - ], - }, - }); - expect( - parseGhidraFunctionResult("analyze_function", ghidraFunctionDossier()), - ).toMatchObject({ - ok: true, - value: { - native_api: { - available: true, - provenance: "ghidra-high-function", - return_type: { - data_type: "int", - confidence: "medium", - }, - jump_tables: [ - { - dispatch_address: "0x401010", - data_sources: [{ address: "0x403000" }], - mappings: [ - { - target_address: "0x401020", - }, - ], - }, - ], - pseudocode: { - classification: "decompiler-generated-non-source", - compilable: false, - }, - }, - native_value_flow: { - available: true, - provenance: "ghidra-high-pcode", - operations: [ - expect.objectContaining({ id: "0x401000#0", opcode: "COPY" }), - ], - truncated: false, - }, - }, - }); - }); - it("rejects p-code relationships that refer to omitted operation IDs", () => { const dossier = ghidraFunctionDossier(); if ( diff --git a/src/ghidra/GhidraProviderCapabilities.ts b/src/ghidra/GhidraProviderCapabilities.ts index 2d3170083..a0f246d3a 100644 --- a/src/ghidra/GhidraProviderCapabilities.ts +++ b/src/ghidra/GhidraProviderCapabilities.ts @@ -125,12 +125,14 @@ export const limitationsFor = (operation: string): readonly string[] => { case "procedure_references": return [ ...common, + "References cover the complete Ghidra function-body AddressSet and exact entry, including instruction-interior destinations and embedded-data sources; an enclosing address span does not establish function ownership.", "Reference kinds are direct Ghidra ReferenceManager observations; unresolved computed flows without a target are absent and remain unknown.", "Synthetic Ghidra entry-point references without actionable memory sources are omitted.", ]; case "analyze_function": return [ ...common, + "References cover the complete Ghidra function-body AddressSet and exact entry. Dossier incoming edges omit sources owned by that function; procedure_references also retains those internal edges.", "The dossier combines Ghidra FunctionManager, Listing, ReferenceManager, BasicBlockModel, and decompiler observations; provider-specific pseudocode and assembly are not cross-provider text invariants.", "Resolved reference metadata identifies computed, indirect, external, call, jump, and data edges; unresolved targetless flows remain unknown, and function classifications distinguish thunks and externals.", "Synthetic Ghidra entry-point references without actionable memory sources are omitted.", diff --git a/tests/boundary/mcp/ghidraInventoryMcp.test.ts b/tests/boundary/mcp/ghidraInventoryMcp.test.ts index dc3d600c8..0c6a660ec 100644 --- a/tests/boundary/mcp/ghidraInventoryMcp.test.ts +++ b/tests/boundary/mcp/ghidraInventoryMcp.test.ts @@ -1,9 +1,10 @@ import { expect, it } from "vitest"; import { jsonValueSchema } from "../../../src/domain/jsonValue.js"; import { ok } from "../../../src/domain/result.js"; +import { ghidraFunctionIdentity } from "../../../src/domain/ghidraValues.fixture.js"; import { connectGhidraMcp } from "./ghidraMcpHarness.js"; -it("rejects contradictory provider memory and inventory data before emitting MCP Evidence", async () => { +it("rejects contradictory provider output before emitting MCP Evidence", async () => { const bytes = { address: "0x401000", requested_bytes: 4, @@ -57,6 +58,15 @@ it("rejects contradictory provider memory and inventory data before emitting MCP arguments: { pattern: "needle" }, output: [{ address: "0x401000" }], }, + { + name: "read_function_instructions", + arguments: { procedure: "fixture_main" }, + output: { + procedure: { ...ghidraFunctionIdentity(), address: "0X401000" }, + instructions: ["0x401000: push rbp"], + limitations: ["Ghidra-specific instruction text."], + }, + }, ]; for (const probe of cases) { output = jsonValueSchema.parse(probe.output); diff --git a/tests/conformance/ghidra/inventory.c b/tests/conformance/ghidra/inventory.c index 83bb2d5ca..188e513c7 100644 --- a/tests/conformance/ghidra/inventory.c +++ b/tests/conformance/ghidra/inventory.c @@ -17,6 +17,10 @@ __attribute__((noinline, used)) int rea_ghidra_inventory_leaf(int value) { return value + rea_ghidra_inventory_global; } +// A reference into the function body can land inside an instruction. +__attribute__((used)) const unsigned char *volatile rea_ghidra_interior_pointer = + (const unsigned char *)(void *)rea_ghidra_inventory_leaf + 1; + __attribute__((noinline, used)) int rea_ghidra_inventory_branch(int value) { if (value > 10) { return rea_ghidra_inventory_leaf(value);