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