From 1f9bf8af0ec5cc39f896cf81f751609304cdb10c Mon Sep 17 00:00:00 2001 From: Kaoru0822 Date: Mon, 9 Nov 2026 05:03:14 -0800 Subject: [PATCH] fix(providers): mark callee coverage partial at computed call sites (#8340) * fix(call-path): keep computed-call targets out of complete callee coverage Ghidra reported complete callee coverage when a computed call had one ReferenceManager target, so trace_call_path, build_call_path and the function comparisons turned the static candidate into a definite negative. The bridge now keeps those targets as observed callees and marks callee coverage partial, naming the computed call sites. IDA callee reads cannot classify their call sites, so their callee coverage is partial too. The Ghidra call-coverage profile revision refuses older snapshots. A source-owned x86-64 bridge probe in the ghidra-bridge-sources job checks the slot, register and direct-call controls against real Ghidra. Fixes #8277 Co-Authored-By: Claude Opus 5.5 * fix(ci): match the observed call coverage probe marker --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> --- .github/workflows/ghidra-bridge-sources.yml | 28 ++++ bridge/ghidra/ReaGhidraBridge.java | 46 +++++- docs/development/tool-design.md | 3 +- docs/providers/ghidra-provider.md | 2 +- docs/providers/ida-provider.md | 2 +- docs/reference/mcp-contracts.md | 3 +- scripts/ci/scopes.mjs | 1 + src/ghidra/GhidraAnalysisProfile.ts | 2 +- src/ghidra/GhidraProvider.behavior.test.ts | 2 +- src/ghidra/GhidraRawProfileDigest.test.ts | 4 +- src/ghidra/GhidraSnapshotCache.test.ts | 18 +- src/ida/IdaCallGraph.test.ts | 20 +++ src/ida/IdaCallGraph.ts | 26 ++- src/ida/IdaNativeProtocol.test.ts | 7 + src/ida/IdaOperationRunner.ts | 35 ++-- .../boundary/filesystem/ciChangeScope.test.ts | 4 + .../providers/ida/calleeLookupBatch.test.ts | 13 +- .../providers/ida/calleeRelationships.test.ts | 22 ++- tests/conformance/ghidra/README.md | 9 + .../ghidra/ReaComputedCallCoverageProbe.java | 154 ++++++++++++++++++ 20 files changed, 355 insertions(+), 46 deletions(-) create mode 100644 tests/conformance/ghidra/ReaComputedCallCoverageProbe.java diff --git a/.github/workflows/ghidra-bridge-sources.yml b/.github/workflows/ghidra-bridge-sources.yml index 14f876cb4c..78f0d3a921 100644 --- a/.github/workflows/ghidra-bridge-sources.yml +++ b/.github/workflows/ghidra-bridge-sources.yml @@ -95,3 +95,31 @@ jobs: decode cortex ARM:LE:32:Cortex mrs/4/msp bx/2/lr decode v8m ARM:LE:32:v8-m mrs/4/msplim bx/2/lr decode xtensa Xtensa:LE:32:default entry/3/a1 retw.n/2 + # A call through a writable slot keeps Ghidra's recorded target as an + # observed callee while callee coverage stays partial; a direct call is + # the complete control. The source-owned x86-64 bytes are never executed. + - name: Keep computed-call targets non-exhaustive in callee coverage + shell: bash + run: | + java_home_variable="JAVA_HOME_${GHIDRA_JAVA_MAJOR}_X64" + export JAVA_HOME="${!java_home_variable}" + probe_root="${RUNNER_TEMP}/rea-computed-call" + mkdir -p "${probe_root}/project" + { + printf '\270\001\000\000\000\303\314\314' # 0x1000: mov eax, 1; ret + printf '\270\002\000\000\000\303\314\314' # 0x1008: mov eax, 2; ret + printf '\377\025\052\000\000\000\303\314' # 0x1010: call [rip + 0x2a]; ret + printf '\377\320\303\314\314\314\314\314' # 0x1018: call rax; ret + printf '\350\333\377\377\377\303\314\314' # 0x1020: call 0x1000; ret + printf '\314%.0s' $(seq 24) # 0x1028: int3 padding + printf '\000\020\000\000\000\000\000\000' # 0x1040: slot = 0x1000 + } > "${probe_root}/computed-call.bin" + test "$(wc -c < "${probe_root}/computed-call.bin")" -eq 72 + timeout 180s "${GHIDRA_INSTALL_DIR}/support/analyzeHeadless" "${probe_root}/project" ComputedCall \ + -import "${probe_root}/computed-call.bin" -loader BinaryLoader -loader-baseAddr 0x1000 \ + -processor x86:LE:64:default -noanalysis -readOnly -deleteProject \ + -scriptPath "${GITHUB_WORKSPACE}/tests/conformance/ghidra;${GITHUB_WORKSPACE}/bridge/ghidra" \ + -postScript ReaComputedCallCoverageProbe.java "${GITHUB_WORKSPACE}/bridge/ghidra" \ + > "${probe_root}/output.log" 2>&1 || { cat "${probe_root}/output.log"; exit 1; } + cat "${probe_root}/output.log" + grep -F 'REA_COMPUTED_CALL_COVERAGE_OK slot=observed-complete-discovery-unknown register=partial direct=complete callers=discovery-unknown' "${probe_root}/output.log" diff --git a/bridge/ghidra/ReaGhidraBridge.java b/bridge/ghidra/ReaGhidraBridge.java index 3c7bcf1667..55ef0d1676 100644 --- a/bridge/ghidra/ReaGhidraBridge.java +++ b/bridge/ghidra/ReaGhidraBridge.java @@ -3753,6 +3753,20 @@ public final class ReaGhidraBridge extends HeadlessScript { private JsonObject callRelationshipCoverage( CallBoundaryObservation calls, Integer missingCallers) { JsonObject coverage = completeResolvedCallCoverage(); + JsonObject discovery = new JsonObject(); + if (missingCallers != null) { + JsonObject callers = new JsonObject(); + callers.addProperty("status", "unknown"); + callers.addProperty("reason", "Recorded incoming references do not establish additional computed callers elsewhere in the program."); + discovery.add("callers", callers); + } + if (calls != null && !calls.computedCallSites().isEmpty()) { + JsonObject callees = new JsonObject(); + callees.addProperty("status", "unknown"); + callees.addProperty("reason", computedCallTargetLimitation(calls.computedCallSites())); + discovery.add("callees", callees); + } + if (!discovery.isEmpty()) coverage.add("discovery", discovery); Integer omitted = calls == null ? null : calls.unrepresentedCalleeCalls(); JsonArray directions = new JsonArray(); if (missingCallers != null && missingCallers > 0) directions.add("callers"); @@ -3771,10 +3785,10 @@ public final class ReaGhidraBridge extends HeadlessScript { } JsonArray limitations = new JsonArray(); if (callerLimitation != null) limitations.add(callerLimitation); + if (omitted != null && omitted > 0) directions.add("callees"); if (omitted != null && omitted > 0) { limitations.add("Ghidra's callee inventory omits targets of " + omitted + " call reference(s) or decoded call(s) without destinations."); - directions.add("callees"); } if (!limitations.isEmpty()) { coverage.addProperty("status", "partial"); @@ -3824,6 +3838,24 @@ public final class ReaGhidraBridge extends HeadlessScript { return coverage; } + /** Name a few sites inline; reference reads and call-target queries recover the rest. */ + private static final int NAMED_COMPUTED_CALL_SITES = 4; + + /** + * A computed call's ReferenceManager targets are static candidates: Ghidra + * records no proof that other values cannot reach the call, so the callee + * set stays incomplete while the recorded candidates remain observed. + */ + private static String computedCallTargetLimitation(List sites) { + if (sites.isEmpty()) return null; + int named = Math.min(sites.size(), NAMED_COMPUTED_CALL_SITES); + String listed = String.join(", ", sites.subList(0, named)) + + (sites.size() > named ? " and " + (sites.size() - named) + " more" : ""); + return "Ghidra records call reference targets at " + sites.size() + + " computed call site(s) (" + listed + "); these static references do not establish" + + " every possible target, so additional callees remain unknown."; + } + private boolean unrepresentedCaller(Reference reference, Set callers) { if (!reference.getReferenceType().isCall()) return false; Function caller = functionAtOrContaining(reference.getFromAddress()); @@ -3858,9 +3890,11 @@ public final class ReaGhidraBridge extends HeadlessScript { monitor.checkCancelled(); calls.observe(reference); } + List computedCallSites = new ArrayList<>(); for (Instruction instruction : instructions) { boundary.observe(instruction); - if (!instruction.getFlowType().isCall()) continue; + FlowType flow = instruction.getFlowType(); + if (!flow.isCall()) continue; boolean destination = false; for (Reference reference : instruction.getReferencesFrom()) { if (!reference.getReferenceType().isCall()) continue; @@ -3872,9 +3906,13 @@ public final class ReaGhidraBridge extends HeadlessScript { calls.unresolved(instruction.getAddress(), "Instruction decoder identifies a call but ReferenceManager supplies no call destination"); } + // The same decoded flow classifies resolve_native_call_targets' + // mechanism; its recorded targets are candidates, not a closed set. + else if (flow.isComputed()) computedCallSites.add(canonicalAddress(instruction.getAddress())); } return new CallBoundaryObservation(boundary.values(), - returnedCallees == null ? null : calls.unrepresented, calls.unresolvedCalls()); + returnedCallees == null ? null : calls.unrepresented, calls.unresolvedCalls(), + List.copyOf(computedCallSites)); } private final class FunctionBoundaryLimitations { @@ -5320,7 +5358,7 @@ public final class ReaGhidraBridge extends HeadlessScript { } } private record FunctionAnalysisObservation(JsonObject dossier, List limitations, JsonObject relationshipCoverage, boolean decompilerFailed) {} - private record CallBoundaryObservation(List limitations, Integer unrepresentedCalleeCalls, JsonArray unresolvedCalls) {} + private record CallBoundaryObservation(List limitations, Integer unrepresentedCalleeCalls, JsonArray unresolvedCalls, List computedCallSites) {} private record SwiftStringLiteralDecode(JsonArray literals, JsonArray omissions) {} private record SwiftLiteralLayout( String representation, diff --git a/docs/development/tool-design.md b/docs/development/tool-design.md index 64a4915bde..e0bc4b040b 100644 --- a/docs/development/tool-design.md +++ b/docs/development/tool-design.md @@ -182,7 +182,8 @@ does not establish another instance. whether applicable static relationships could have been missed by discovery; missing discovery is unknown. Preserve paths supported by observed edges, and qualify negative and shortest-path claims by discovery scope. Missing - relationship coverage remains `unknown`. + relationship coverage remains `unknown`. Computed candidates and unestablished incoming targets qualify directional discovery; they do not by themselves mean that recorded edges were omitted. + :docs/development/tool-design.md - Declare process, filesystem, network, UI, and mutation effects truthfully; enforce the requested target and lifecycle. - Add limits only for real format, protocol, authority, or measured resource diff --git a/docs/providers/ghidra-provider.md b/docs/providers/ghidra-provider.md index e600640865..15c43e64b8 100644 --- a/docs/providers/ghidra-provider.md +++ b/docs/providers/ghidra-provider.md @@ -267,7 +267,7 @@ profile stages before retrying their retained descriptors. | Function identity | Every function result carries the entry address and Ghidra FunctionManager classification for external, thunk, and resolved thunk target. These are observations, not proof that unresolved targetless calls have been recovered. | | Assembly and pseudocode | Assembly is complete Ghidra Listing text; pseudocode is Ghidra decompiler output. Neither is original source, and cross-provider comparison never treats Hopper and Ghidra text as equal or unequal semantic facts. | | Instruction fast path | `read_function_instructions` returns every raw Listing instruction for the requested function without invoking the decompiler or whole-program name/string inventories. | -| Calls and references | Callers/callees contain only resolved functions. Reference edges preserve exact ReferenceManager type and call/jump/data/read/write/indirect/computed/conditional/terminal/external facts. Targetless computed flow remains unknown. Synthetic entry-point references without actionable memory sources are omitted explicitly. | +| Calls and references | Callers/callees contain only resolved functions. Reference edges preserve exact ReferenceManager type and call/jump/data/read/write/indirect/computed/conditional/terminal/external facts. Targetless computed flow remains unknown. A computed call's recorded targets stay observed callees, but they are static candidates rather than an exhaustive set, so callee coverage is `partial` and names those call sites. Synthetic entry-point references without actionable memory sources are omitted explicitly. | | CFG | Dossiers use `BasicBlockModel` and retain only non-call successors inside the function body. CFG topology is address-normalized for comparison; provider-specific block construction remains a declared difference. | | P-code value flow | Function dossiers include up to 3,000 high-p-code operations, at most 64 inputs per operation, and 12,000 def-use edges. Results identify memory reads/writes, branches, and calls by p-code opcode, and mark decompiler-dead operations. Large results report truncation, an omitted-operation lower bound, and known omitted-input/edge counts. The dossier is an intra-function graph; `trace_native_values` composes derived parameter/argument and return/output dependencies across resolved calls. It does not resolve memory aliasing, call side effects, runtime behavior, persistent state, or business meaning. | | Result extent | Function instruction scans and native API boundary observations remain complete. P-code flow is the explicit bounded exception; its count fields distinguish exact retained counts from lower-bound or known-omitted counts. Caller cancellation and provider failures remain distinct. | diff --git a/docs/providers/ida-provider.md b/docs/providers/ida-provider.md index 7c66e388cc..86c58fc4cb 100644 --- a/docs/providers/ida-provider.md +++ b/docs/providers/ida-provider.md @@ -233,7 +233,7 @@ admits it explicitly. Raw-binary and GameCube DOL targets are refused; open them with Ghidra. Admission is not verification: the real workflows under [Verification](#verification) cover x86_64 and arm64 only. -All profiles support procedure/string inventory and literal or regex search, procedure resolution, pseudocode, assembly, raw function instructions, resolved callees, incoming address references, and a function dossier. Inventories and modern pseudocode/assembly follow upstream pagination. Upstream limits on modern xrefs and callees are retained as explicit truncation limitations. In the modern headless profile, targets that are not proven x86-only use processor-neutral `callgraph` edges instead of the x86 instruction checks in upstream `callees` (mrexodia/ida-pro-mcp#549). REA requests depth zero: the pinned upstream emits the root's outgoing edges without scanning child functions, then REA batches function-name lookup for those addresses. The graph covers recognized function destinations only; imports and other non-function targets remain unknown. A server without `callgraph` returns an empty relationship list with an explicit coverage limitation. Graph and path workflows retain those limitations alongside observed edges. A failed or inconsistent graph reply remains an error. A transport-level truncated preview is rejected rather than reported as complete evidence; the error preserves upstream retrieval guidance. +All profiles support procedure/string inventory and literal or regex search, procedure resolution, pseudocode, assembly, raw function instructions, resolved callees, incoming address references, and a function dossier. Inventories and modern pseudocode/assembly follow upstream pagination. Upstream limits on modern xrefs and callees are retained as explicit truncation limitations. In the modern headless profile, targets that are not proven x86-only use processor-neutral `callgraph` edges instead of the x86 instruction checks in upstream `callees` (mrexodia/ida-pro-mcp#549). REA requests depth zero: the pinned upstream emits the root's outgoing edges without scanning child functions, then REA batches function-name lookup for those addresses. The graph covers recognized function destinations only; imports and other non-function targets remain unknown. A server without `callgraph` returns an empty relationship list with an explicit coverage limitation. Neither `callees` nor `callgraph` reports the call sites behind its edges, so a computed call's targets are never established as exhaustive; callee coverage is therefore `partial` even for an uncapped reply, and a call path that does not reach its goal remains `unknown`. Graph and path workflows retain those limitations alongside observed edges. A failed or inconsistent graph reply remains an error. A transport-level truncated preview is rejected rather than reported as complete evidence; the error preserves upstream retrieval guidance. All profiles expose `list_documents` as the single target admitted for the session. Attached mode verifies its digest against IDA metadata before returning it; headless mode binds the digest-checked private input. `inspect_address_context`, `trace_feature`, and `binary_overview` can therefore resolve that identity without upstream document-listing tools. Segment enumeration is optional: when the selected profile cannot provide it, `binary_overview` still returns document and procedure/string counts and omits `segments` and `segment_count`; omission means unobserved, not empty. The modern headless profile also supports `resolve_containing_procedure`. An address resolves through upstream `lookup_funcs` to the canonical entry of the function that contains it; the body stays unavailable like other IDA identities. When no function contains it, REA reads the database-scoped minimal `survey_binary` and reports `not_in_procedure` for an address inside one of its segments (ends are exclusive) and `outside_segments` otherwise. A missing, truncated or inconsistent survey fails the read instead of guessing a reason. `trace_feature` marks a trace truncated, with a residual unknown for that address, when upstream caps the references it returned. Attached profiles do not declare address containment resolution. diff --git a/docs/reference/mcp-contracts.md b/docs/reference/mcp-contracts.md index 1a583779d2..2b15330728 100644 --- a/docs/reference/mcp-contracts.md +++ b/docs/reference/mcp-contracts.md @@ -221,11 +221,12 @@ graph, while `search_scope.discovery` describes whether discovery of applicable direct relationships is established. A missing goal is `unknown` when either discovery or a relationship read is unestablished. A found path is shortest within the observed graph; undiscovered calls may produce a shorter path. -`truncated` describes relationship reads, not unestablished discovery. +`truncated` describes relationship reads, not unestablished discovery. Ghidra's recorded incoming references do not establish every computed caller; computed outgoing candidates likewise retain unknown callee discovery. IDA function-edge replies retain their observed truncation status while unclassified call targets remain unknown in discovery. `build_call_path` applies the same distinction to supplied function Evidence: its `search_scope.exhaustive` describes the supplied graph and `search_scope.observed_status` preserves that graph's result. Evidence without discovery metadata has unknown discovery. Cancellation remains a tool error. +:docs/reference/mcp-contracts.md `trace_feature` validates each reference's containing-procedure resolution using the same contract as navigation context. Explicit absence remains a successful diff --git a/scripts/ci/scopes.mjs b/scripts/ci/scopes.mjs index 167f5fb837..7d35e994d2 100644 --- a/scripts/ci/scopes.mjs +++ b/scripts/ci/scopes.mjs @@ -633,6 +633,7 @@ const PATH_SCOPES = { "src/ghidra/GhidraReleasePolicy.ts", "scripts/verify-ghidra-bridge-sources.mjs", "tests/conformance/ghidra/ReaArmContextProbe.java", + "tests/conformance/ghidra/ReaComputedCallCoverageProbe.java", "tests/conformance/ghidra/ReaDecodeProbe.java", ".github/workflows/ghidra-bridge-sources.yml", ], diff --git a/src/ghidra/GhidraAnalysisProfile.ts b/src/ghidra/GhidraAnalysisProfile.ts index 354861457b..c129ed4f43 100644 --- a/src/ghidra/GhidraAnalysisProfile.ts +++ b/src/ghidra/GhidraAnalysisProfile.ts @@ -59,7 +59,7 @@ const sharedAnalysisSemantics = { load_image_observations: "source-mappings-entry-context-relocation-symbol-v3", function_body_evidence: "complete-inclusive-ranges-v1", function_references: "complete-body-and-entry-reference-manager-v3", - call_relationship_coverage: "function-body-call-coverage-v5", + call_relationship_coverage: "observed-call-inventory-and-discovery-v7", function_dossier_coverage: "observed-facets-and-call-relationships-v1", value_flow_effect_scope: "pcode-load-store-explicit-v1", string_inventory_evidence: "defined-data-coverage-v1", diff --git a/src/ghidra/GhidraProvider.behavior.test.ts b/src/ghidra/GhidraProvider.behavior.test.ts index 61b43133ec..07679732ff 100644 --- a/src/ghidra/GhidraProvider.behavior.test.ts +++ b/src/ghidra/GhidraProvider.behavior.test.ts @@ -1233,7 +1233,7 @@ describe("Ghidra client projection", () => { expect(resolved.value.profile.parameters).toMatchObject({ language_id: "auto-from-header", compiler_spec_id: "auto-default", - call_relationship_coverage: "function-body-call-coverage-v5", + call_relationship_coverage: "observed-call-inventory-and-discovery-v7", function_dossier_coverage: "observed-facets-and-call-relationships-v1", }); diff --git a/src/ghidra/GhidraRawProfileDigest.test.ts b/src/ghidra/GhidraRawProfileDigest.test.ts index cafe447013..f4da6c1ce1 100644 --- a/src/ghidra/GhidraRawProfileDigest.test.ts +++ b/src/ghidra/GhidraRawProfileDigest.test.ts @@ -11,9 +11,9 @@ import { GhidraProvider } from "./GhidraProvider.js"; // bit-field base-type and inventory-search revisions that prevent reuse of snapshots with earlier // results. const RAW_DIGESTS = { - "6502": "2878a9daeb48e08755a5a840044a9f1c9f19784b92d6c08c1d27eca5b27d675a", + "6502": "540037ef007380e737a606cd14c402a9bdf055132cf44fb73d29ab1ec9f84af6", "x86-disabled-analyzer": - "480aa8cac604f14abbb6e6fc909378faa27b44a06fd36f3db6a8c67248277980", + "8498e58d5a1147cdfe7dad2b486b4b4087efc76944ee1902c9af9d0c0da56237", } as const; const configFor = (environment: NodeJS.ProcessEnv) => { diff --git a/src/ghidra/GhidraSnapshotCache.test.ts b/src/ghidra/GhidraSnapshotCache.test.ts index a6e5656370..b8d848a081 100644 --- a/src/ghidra/GhidraSnapshotCache.test.ts +++ b/src/ghidra/GhidraSnapshotCache.test.ts @@ -369,24 +369,34 @@ describe("Ghidra snapshot profile admission", () => { }, { parameter: "call_relationship_coverage", - current: "function-body-call-coverage-v5", + current: "observed-call-inventory-and-discovery-v7", legacy: "resolved-call-reference-diagnostics-v1", }, { parameter: "call_relationship_coverage", - current: "function-body-call-coverage-v5", + current: "observed-call-inventory-and-discovery-v7", legacy: "decoded-call-target-coverage-v2", }, { parameter: "call_relationship_coverage", - current: "function-body-call-coverage-v5", + current: "observed-call-inventory-and-discovery-v7", legacy: "decoded-call-target-coverage-v3", }, { parameter: "call_relationship_coverage", - current: "function-body-call-coverage-v5", + current: "observed-call-inventory-and-discovery-v7", legacy: "function-body-call-coverage-v4", }, + { + parameter: "call_relationship_coverage", + current: "observed-call-inventory-and-discovery-v7", + legacy: "function-body-call-coverage-v5", + }, + { + parameter: "call_relationship_coverage", + current: "observed-call-inventory-and-discovery-v7", + legacy: "function-body-call-coverage-v6", + }, ])( "refuses snapshots with outdated $parameter semantics", async ({ parameter, current, legacy: prior }) => { diff --git a/src/ida/IdaCallGraph.test.ts b/src/ida/IdaCallGraph.test.ts index 9d533523c3..c596a7ca5d 100644 --- a/src/ida/IdaCallGraph.test.ts +++ b/src/ida/IdaCallGraph.test.ts @@ -8,6 +8,7 @@ import type { JsonValue } from "../domain/jsonValue.js"; import { isX86OnlyTarget, readCallGraphCallees, + UNCLASSIFIED_CALL_SITE_LIMITATION, UNRECOGNIZED_CALLEE_LIMITATION, } from "./IdaCallGraph.js"; @@ -232,6 +233,12 @@ describe("call graph coverage and failure handling", () => { /callgraph was truncated.*callees remain unknown/iu, ), ); + expect(result.coverage).toMatchObject({ + status: "partial", + limitations: [ + "Upstream callgraph was truncated; additional callees remain unknown.", + ], + }); }); it("keeps an explicitly uncapped root below its edge cap untruncated", async () => { @@ -244,6 +251,19 @@ describe("call graph coverage and failure handling", () => { result.limitations, "an uncapped, unsaturated root has no truncation evidence", ).toEqual([UNRECOGNIZED_CALLEE_LIMITATION]); + expect( + result.coverage, + "function edges without call sites cannot establish a computed call's targets", + ).toEqual({ + status: "complete", + discovery: { + callees: { + status: "unknown", + reason: UNCLASSIFIED_CALL_SITE_LIMITATION, + }, + }, + diagnostics: [UNRECOGNIZED_CALLEE_LIMITATION], + }); }); it("reports a graph that saturates its per-function cap with root edges", async () => { diff --git a/src/ida/IdaCallGraph.ts b/src/ida/IdaCallGraph.ts index f37d26af5b..88385cc2c9 100644 --- a/src/ida/IdaCallGraph.ts +++ b/src/ida/IdaCallGraph.ts @@ -16,6 +16,15 @@ import { /** The graph only emits edges whose destinations IDA recognizes as functions. */ export const UNRECOGNIZED_CALLEE_LIMITATION = "This relationship read does not establish calls to targets IDA does not recognize as functions; imports and other non-function call targets remain unknown."; +/** + * IDA callee reads return function edges without the call sites behind them, + * so a computed call's target set is never established as exhaustive. + */ +export const UNCLASSIFIED_CALL_SITE_LIMITATION = + "IDA's callee read does not identify computed or indirect call sites, so it cannot establish every target of such a call; additional callees may remain unknown."; +export const IDA_CALLEE_DISCOVERY = { + callees: { status: "unknown", reason: UNCLASSIFIED_CALL_SITE_LIMITATION }, +} as const; const TRUNCATED_LIMITATION = "Upstream callgraph was truncated; additional callees remain unknown."; const POSSIBLY_TRUNCATED_LIMITATION = @@ -165,14 +174,15 @@ export const readCallGraphCallees = async ( }; }), limitations, - coverage: { - ...(coverageLimitations.length === 0 - ? { status: "complete" as const } - : { - status: "partial" as const, + coverage: + coverageLimitations.length > 0 + ? { + status: "partial", limitations: coverageLimitations, - }), - diagnostics, - }, + limited_directions: ["callees"], + diagnostics, + discovery: IDA_CALLEE_DISCOVERY, + } + : { status: "complete", diagnostics, discovery: IDA_CALLEE_DISCOVERY }, }; }; diff --git a/src/ida/IdaNativeProtocol.test.ts b/src/ida/IdaNativeProtocol.test.ts index 736d978655..a70b89ff75 100644 --- a/src/ida/IdaNativeProtocol.test.ts +++ b/src/ida/IdaNativeProtocol.test.ts @@ -19,6 +19,7 @@ import { type IdaMcpConnection, } from "./IdaMcpConnection.js"; import { parseConfig } from "../config/parseConfig.js"; +import { UNCLASSIFIED_CALL_SITE_LIMITATION } from "./IdaCallGraph.js"; import { IdaProvider } from "./IdaProvider.js"; import { idaCapabilities } from "./IdaProviderCapabilities.js"; import { IdaSessionClient } from "./IdaSessionClient.js"; @@ -385,6 +386,12 @@ describe("native IDA provider observations", () => { if (!direct.ok) throw direct.error; expect(direct.value.relationshipCoverage).toMatchObject({ status: more ? "partial" : "complete", + discovery: { + callees: { + status: "unknown", + reason: UNCLASSIFIED_CALL_SITE_LIMITATION, + }, + }, }); const dossier = await client.execute("analyze_function", { procedure: "main", diff --git a/src/ida/IdaOperationRunner.ts b/src/ida/IdaOperationRunner.ts index 41df1f2741..85e05bd0d8 100644 --- a/src/ida/IdaOperationRunner.ts +++ b/src/ida/IdaOperationRunner.ts @@ -29,7 +29,11 @@ import { import type { JsonValue } from "../domain/jsonValue.js"; import type { RelationshipCoverage } from "../domain/relationshipCoverage.js"; import { combineFunctionRelationshipCoverage } from "../domain/functionCallCoverage.js"; -import { readCallGraphCallees, unavailableCallGraph } from "./IdaCallGraph.js"; +import { + readCallGraphCallees, + IDA_CALLEE_DISCOVERY, + unavailableCallGraph, +} from "./IdaCallGraph.js"; import type { IdaMcpConnection } from "./IdaMcpConnection.js"; import type { IdaOperation } from "./IdaProviderCapabilities.js"; import { @@ -551,21 +555,24 @@ export class IdaOperationRunner { this.limitations.push( "Upstream callees capped results at 500; additional callees remain unknown.", ); - coverage = - page.more === true + coverage = { + ...(page.more === undefined ? { - status: "partial", - limitations: [ - "Upstream callees capped results at 500; additional callees remain unknown.", - ], + status: "unknown" as const, + reason: + "Upstream callees omitted an explicit truncation marker; additional callees remain unknown.", } - : page.more === false - ? { status: "complete" } - : { - status: "unknown", - reason: - "Upstream callees omitted an explicit truncation marker; additional callees remain unknown.", - }; + : page.more + ? { + status: "partial" as const, + limitations: [ + "Upstream callees capped results at 500; additional callees remain unknown.", + ], + limited_directions: ["callees"] as const, + } + : { status: "complete" as const }), + discovery: IDA_CALLEE_DISCOVERY, + }; for (const item of page.callees) { if ("type" in item && item.type === "external") result.set( diff --git a/tests/boundary/filesystem/ciChangeScope.test.ts b/tests/boundary/filesystem/ciChangeScope.test.ts index 01606fa7df..7ff6281d9b 100644 --- a/tests/boundary/filesystem/ciChangeScope.test.ts +++ b/tests/boundary/filesystem/ciChangeScope.test.ts @@ -785,6 +785,10 @@ it.each([ "tests/conformance/ghidra/ReaArmContextProbe.java", { ghidra_bridge: true, runtime: false, package: false }, ], + [ + "tests/conformance/ghidra/ReaComputedCallCoverageProbe.java", + { ghidra_bridge: true, runtime: false, package: false }, + ], [ "tests/conformance/ghidra/ReaDecodeProbe.java", { ghidra_bridge: true, runtime: false, package: false }, diff --git a/tests/boundary/providers/ida/calleeLookupBatch.test.ts b/tests/boundary/providers/ida/calleeLookupBatch.test.ts index 2ee735f365..96d49f0a83 100644 --- a/tests/boundary/providers/ida/calleeLookupBatch.test.ts +++ b/tests/boundary/providers/ida/calleeLookupBatch.test.ts @@ -1,6 +1,7 @@ import { rm } from "node:fs/promises"; import { afterEach, describe, expect, it } from "vitest"; import { z } from "zod"; +import { UNCLASSIFIED_CALL_SITE_LIMITATION } from "../../../../src/ida/IdaCallGraph.js"; import { IdaOperationRunner } from "../../../../src/ida/IdaOperationRunner.js"; import { IdaSessionClient } from "../../../../src/ida/IdaSessionClient.js"; import { createIdaTarget, RecordingIdaMcp } from "../../../fixtures/idaMcp.js"; @@ -11,6 +12,12 @@ afterEach(async () => { await rm(root, { recursive: true, force: true }); }); const queriesSchema = z.object({ queries: z.array(z.string()) }); +/** An uncapped page still cannot classify the call sites behind its edges. */ +const uncappedCalleeCoverage = { + status: "partial", + limitations: [UNCLASSIFIED_CALL_SITE_LIMITATION], + limited_directions: ["callees"], +}; const addresses = Array.from( { length: 20 }, (_, index) => `0x${(0x2000 + index * 16).toString(16)}`, @@ -79,7 +86,7 @@ describe.each(["modern", "native"] as const)( "lookup_funcs", ]); expect(runner.raw.at(-1)?.result).toEqual(addresses.map(row).reverse()); - expect(runner.relationshipCoverage).toEqual({ status: "complete" }); + expect(runner.relationshipCoverage).toEqual(uncappedCalleeCoverage); }); it.each([ @@ -140,7 +147,7 @@ describe.each(["modern", "native"] as const)( }, ); -it("uses the batch through a modern session and preserves complete callee Evidence", async () => { +it("uses the batch through a modern session and preserves the observed callee Evidence", async () => { const { producer, target, root } = await fixture(); const client = new IdaSessionClient( { @@ -160,7 +167,7 @@ it("uses the batch through a modern session and preserves complete callee Eviden }); if (!result.ok) throw result.error; expect(result.value.result).toEqual(["0x9000", ...addresses]); - expect(result.value.relationshipCoverage).toEqual({ status: "complete" }); + expect(result.value.relationshipCoverage).toEqual(uncappedCalleeCoverage); expect( producer.calls .filter(({ name }) => name === "lookup_funcs") diff --git a/tests/boundary/providers/ida/calleeRelationships.test.ts b/tests/boundary/providers/ida/calleeRelationships.test.ts index ca40caf93c..e64c3b44d4 100644 --- a/tests/boundary/providers/ida/calleeRelationships.test.ts +++ b/tests/boundary/providers/ida/calleeRelationships.test.ts @@ -1,6 +1,7 @@ import { afterEach, expect, it } from "vitest"; import { z } from "zod"; import { EnhancedTools } from "../../../../src/application/EnhancedTools.js"; +import { UNCLASSIFIED_CALL_SITE_LIMITATION } from "../../../../src/ida/IdaCallGraph.js"; import { callGraphSession, edge, @@ -38,6 +39,7 @@ it("keeps unknown caller coverage and callee diagnostics in an ARM64 dossier", a expect(result.value.relationshipCoverage).toMatchObject({ status: "unknown", reason: expect.stringMatching(/xrefs.*not establish.*caller/iu), + diagnostics: expect.arrayContaining([ expect.stringContaining("targets IDA does not recognize as functions"), ]), @@ -152,16 +154,26 @@ it("traverses every ARM64 callee in a call path without a goal and records no fa { source_address: "0x1000", target_address: "0x3000" }, ], failures: [], + // The recorded graph is complete; possible undiscovered calls remain unknown. truncated: false, limitations: expect.arrayContaining([ "This relationship read does not establish calls to targets IDA does not recognize as functions; imports and other non-function call targets remain unknown.", ]), residual_unknowns: [], - relationship_coverage: { - "0x1000": { status: "complete" }, - "0x2000": { status: "complete" }, - "0x3000": { status: "complete" }, - }, + relationship_coverage: Object.fromEntries( + ["0x1000", "0x2000", "0x3000"].map((address) => [ + address, + { + status: "complete", + discovery: { + callees: { + status: "unknown", + reason: UNCLASSIFIED_CALL_SITE_LIMITATION, + }, + }, + }, + ]), + ), }); expect( requestedRoots(producer), diff --git a/tests/conformance/ghidra/README.md b/tests/conformance/ghidra/README.md index 774f37aab2..56ab857c13 100644 --- a/tests/conformance/ghidra/README.md +++ b/tests/conformance/ghidra/README.md @@ -25,6 +25,15 @@ no-overwrite CLI delivery. It checks the target and project files for changes. language's default context and checks each mnemonic, length and optional complete operand. The `ghidra-bridge-sources` CI job runs it for `ARM:LE:32:Cortex`, `ARM:LE:32:v8-m` and `Xtensa:LE:32:default`; the bytes are never executed. +- `ReaComputedCallCoverageProbe.java` decodes source-owned x86-64 calls + through a writable slot, a register and a direct target, injects only the + slot call's recorded `COMPUTED_CALL` target, and reads callees through the + production bridge. The recorded target stays an observed callee while callee + coverage is `partial` and names the call site; the targetless call stays + `partial` without that candidate limitation, and the direct call stays + `complete`. The `ghidra-bridge-sources` CI job runs it; the bytes are never + executed. It does not establish which references Ghidra's own analysis + records for other slot shapes. - `inventory.c` produces separate x86-64 debug and stripped ELF executables. It covers imports, an external `puts` function, linker thunks, source symbols, direct calls, a targetless callback call, two referenced strings, and a diff --git a/tests/conformance/ghidra/ReaComputedCallCoverageProbe.java b/tests/conformance/ghidra/ReaComputedCallCoverageProbe.java new file mode 100644 index 0000000000..f5590c8326 --- /dev/null +++ b/tests/conformance/ghidra/ReaComputedCallCoverageProbe.java @@ -0,0 +1,154 @@ +//@category REA Verification + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonNull; +import com.google.gson.JsonObject; +import ghidra.app.script.GhidraScript; +import ghidra.app.script.GhidraScriptUtil; +import ghidra.program.model.address.Address; +import ghidra.program.model.listing.Instruction; +import ghidra.program.model.symbol.RefType; +import ghidra.program.model.symbol.Reference; +import ghidra.program.model.symbol.SourceType; +import java.io.File; +import java.lang.reflect.Field; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; + +/** + * Real x86-64 decoding, functions and ReferenceManager state for calls through + * a writable slot, a register and a direct target. Only the slot call's + * recorded target is injected, as Ghidra's analysis records one for an + * initialized slot. The production bridge must keep that candidate while + * observed coverage stays complete and discovery names the unknown targets; the direct call is + * the complete control. The source-owned bytes are never executed. + */ +public class ReaComputedCallCoverageProbe extends GhidraScript { + private static final String COMPUTED_LIMITATION = "computed call site(s) ("; + + @Override + public void run() throws Exception { + String[] args = getScriptArgs(); + require(args.length == 1, "Expected the exact production bridge directory"); + var source = GhidraScriptUtil.findScriptByName("ReaGhidraBridge.java"); + require(source != null && new File(source.getAbsolutePath()).getCanonicalFile().equals( + new File(args[0], "ReaGhidraBridge.java").getCanonicalFile()), "Production bridge source"); + GhidraScript bridge = GhidraScriptUtil.getProvider(source).getScriptInstance(source, errorWriter); + bridge.set(getState(), getControls()); + require(currentProgram.getLanguageID().getIdAsString().equals("x86:LE:64:default"), + "x86-64 fixture language"); + Address base = currentProgram.getMemory().getMinAddress(); + Address selected = base; // mov eax, 1; ret + Address alternate = base.add(0x08); // mov eax, 2; ret + Address slotCaller = base.add(0x10); // call qword ptr [rip + 0x2a] (slot at +0x40); ret + Address registerCaller = base.add(0x18); // call rax; ret + Address directCaller = base.add(0x20); // call selected; ret + for (Address entry : new Address[] { selected, alternate, slotCaller, registerCaller, directCaller }) { + if (getInstructionAt(entry) == null) disassemble(entry); + require(getInstructionAt(entry) != null, "Decoded entry " + entry); + require(createFunction(entry, null) != null, "Created function at " + entry); + } + Instruction slotCall = requireCall(slotCaller, true); + requireCall(registerCaller, true); + Instruction directCall = requireCall(directCaller, false); + currentProgram.getReferenceManager().addMemoryReference( + slotCall.getAddress(), selected, RefType.COMPUTED_CALL, SourceType.ANALYSIS, 0); + if (!hasCallReference(directCall)) + currentProgram.getReferenceManager().addMemoryReference( + directCall.getAddress(), selected, RefType.UNCONDITIONAL_CALL, SourceType.ANALYSIS, 0); + + Field space = bridge.getClass().getDeclaredField("sessionDefaultAddressSpace"); + space.setAccessible(true); + space.set(null, base.getAddressSpace()); + Method callees = bridge.getClass().getDeclaredMethod("procedureCalls", JsonObject.class, boolean.class); + callees.setAccessible(true); + String selectedAddress = canonical(selected); + + JsonObject computed = callees(callees, bridge, slotCaller); + require(contains(computed.getAsJsonArray("value"), selectedAddress), + "The recorded slot target remains an observed callee"); + require(!contains(computed.getAsJsonArray("value"), canonical(alternate)), + "No callee is invented for the alternate value"); + JsonObject computedCoverage = computed.getAsJsonObject("relationship_coverage"); + require(computedCoverage.get("status").getAsString().equals("complete"), + "The recorded computed target inventory is complete"); + JsonObject calleeDiscovery = computedCoverage.getAsJsonObject("discovery").getAsJsonObject("callees"); + require(calleeDiscovery.get("status").getAsString().equals("unknown"), "Other computed targets stay unknown"); + require(calleeDiscovery.get("reason").getAsString().contains("1 " + COMPUTED_LIMITATION + canonical(slotCall.getAddress()) + ")"), + "Discovery uncertainty names the computed call site"); + + JsonObject targetless = callees(callees, bridge, registerCaller); + JsonObject targetlessCoverage = targetless.getAsJsonObject("relationship_coverage"); + require(targetlessCoverage.get("status").getAsString().equals("partial"), + "A targetless computed call keeps partial coverage"); + require(!limitationContains(targetlessCoverage, COMPUTED_LIMITATION), + "A targetless call is not reported as having recorded candidates"); + + JsonObject direct = callees(callees, bridge, directCaller); + JsonArray directValue = direct.getAsJsonArray("value"); + require(directValue.size() == 1 && contains(directValue, selectedAddress), "Direct callee"); + require(direct.getAsJsonObject("relationship_coverage").get("status").getAsString().equals("complete"), + "A direct call with its recorded destination stays complete"); + for (Address target : new Address[] { selected, alternate }) { + JsonObject input = new JsonObject(); input.add("document", JsonNull.INSTANCE); input.addProperty("procedure", canonical(target)); + JsonObject incoming = (JsonObject) callees.invoke(bridge, input, true); + JsonObject coverage = incoming.getAsJsonObject("relationship_coverage"); + require(coverage.get("status").getAsString().equals("complete"), "Recorded incoming edges remain complete"); + require(coverage.getAsJsonObject("discovery").getAsJsonObject("callers").get("status").getAsString().equals("unknown"), "Additional computed callers stay unknown"); + if (target.equals(selected)) require(contains(incoming.getAsJsonArray("value"), canonical(directCaller)), "Known direct caller survives"); + else require(incoming.getAsJsonArray("value").isEmpty(), "No alternate caller is invented"); + } + println("REA_COMPUTED_CALL_COVERAGE_OK slot=observed-complete-discovery-unknown register=partial direct=complete callers=discovery-unknown"); + } + + private Instruction requireCall(Address entry, boolean computed) { + Instruction instruction = getInstructionAt(entry); + require(instruction != null && instruction.getFlowType().isCall() && + instruction.getFlowType().isComputed() == computed, + (computed ? "Computed" : "Direct") + " call decoded at " + entry); + return instruction; + } + + private static boolean hasCallReference(Instruction instruction) { + for (Reference reference : instruction.getReferencesFrom()) + if (reference.getReferenceType().isCall()) return true; + return false; + } + + private JsonObject callees(Method method, Object bridge, Address procedure) throws Exception { + JsonObject input = new JsonObject(); + input.add("document", JsonNull.INSTANCE); + input.addProperty("procedure", canonical(procedure)); + JsonObject result; + try { + result = (JsonObject) method.invoke(bridge, input, false); + } catch (InvocationTargetException failure) { + throw new IllegalStateException("procedure_callees failed at " + procedure, failure.getCause()); + } + println("REA_COMPUTED_CALL_COVERAGE " + canonical(procedure) + " " + result); + return result; + } + + private static String canonical(Address address) { + return "0x" + Long.toHexString(address.getOffset()); + } + + private static boolean contains(JsonArray values, String expected) { + for (JsonElement value : values) + if (value.getAsString().equals(expected)) return true; + return false; + } + + private static boolean limitationContains(JsonObject coverage, String expected) { + JsonArray limitations = coverage.getAsJsonArray("limitations"); + if (limitations == null) return false; + for (JsonElement limitation : limitations) + if (limitation.getAsString().contains(expected)) return true; + return false; + } + + private static void require(boolean condition, String label) { + if (!condition) throw new IllegalStateException(label); + } +}