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);