fix(ghidra): warn when callee/caller reads stop at a truncated function body (#2360)

procedure_callees and procedure_callers returned a bare address list, so an
inferred no-return call that truncates Ghidra's function body silently dropped
real edges (fixes #2326). Wrap both views in the function-observation envelope
with noReturnCallLimitations, matching procedure_info, procedure_assembly,
procedure_pseudo_code, read_function_instructions, and analyze_function
(precedent: 69a8b3a95). Version the boundary-observation contract to
ghidra-terminal-call-limitations-v2 so older snapshots cannot replay
unqualified results.

Validation: red->green unit test for the envelope requirement, MCP boundary
evidence test extended for both views (limitations retained through Evidence
and snapshot replay), 8 harness test files + ghidra behavior tests green,
check:fast, docs:check, verify:test-discovery green. Real Ghidra host lane
unavailable here (no Ghidra installed); Java change mirrors the existing
procedureInfo call pattern.
This commit is contained in:
Saim Shafique 2026-10-13 18:26:57 +05:00 committed by GitHub
parent dea770d5d9
commit 6637161e02
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 52 additions and 11 deletions

View File

@ -812,7 +812,7 @@ public final class ReaGhidraBridge extends HeadlessScript {
return functionObservation(result, boundaryLimitations);
}
private JsonArray procedureCalls(JsonObject params, boolean callers) throws Exception {
private JsonObject procedureCalls(JsonObject params, boolean callers) throws Exception {
requireKeys(params, Set.of("document", "procedure"));
requireDocument(params);
Function function = resolveProcedure(requireString(params, "procedure"));
@ -825,7 +825,12 @@ public final class ReaGhidraBridge extends HeadlessScript {
for (Function item : ordered) {
result.add(canonicalAddress(item.getEntryPoint()));
}
return result;
return functionObservation(
result,
noReturnCallLimitations(
currentProgram.getListing().getInstructions(function.getBody(), true)
)
);
}
private JsonObject procedureInfo(JsonObject params) throws Exception {

View File

@ -131,9 +131,10 @@ overview merely because its source artifact is unchanged.
Function extents and instruction completeness refer to Ghidra's analyzed body.
An inferred no-return call can terminate that body before subsequent bytes.
Pseudocode, assembly, procedure info, instruction windows, and dossiers report
observed terminal call sites in Evidence limitations, including resolved callee
names, addresses, and FunctionManager flags when available. Instruction windows
Pseudocode, assembly, procedure info, instruction windows, callee and caller
reads, and dossiers report observed terminal call sites in Evidence limitations,
including resolved callee names, addresses, and FunctionManager flags when
available. Instruction windows
and dossiers also retain these warnings in their result limitations. An
unresolved callee or unestablished terminal-flow basis remains explicit. These
observations qualify the analysis; they do not prove that a no-return flag is

View File

@ -149,12 +149,12 @@ export const OFFICIAL_TOOL_CONTRACTS = [
),
official(
"procedure_callees",
"Return the provider's resolved direct callees for one procedure identified by symbol or address. Unresolved indirect calls may be absent; use analyze_function and typed references to preserve available edge uncertainty.",
"Return the provider's resolved direct callees for one procedure identified by symbol or address. Unresolved indirect calls may be absent; use analyze_function and typed references to preserve available edge uncertainty. Ghidra terminal-call assumptions that may cut the analyzed body are reported in Evidence limitations.",
z.object({ procedure, document }),
),
official(
"procedure_callers",
"Return the provider's resolved direct callers for one procedure identified by symbol or address. Results reflect completed static analysis and may omit unresolved indirect references.",
"Return the provider's resolved direct callers for one procedure identified by symbol or address. Results reflect completed static analysis and may omit unresolved indirect references. Ghidra terminal-call assumptions that may cut the analyzed body are reported in Evidence limitations.",
z.object({ procedure, document }),
),
official(

View File

@ -127,7 +127,7 @@ export const resolveGhidraAnalysisProfile = async (
"address-first-unique-target-source-mapping-v2",
instruction_flow_evidence: "decoded-return-pcode-v1",
instruction_references: "exact-address-incoming-v1",
function_boundary_observations: "ghidra-terminal-call-limitations-v1",
function_boundary_observations: "ghidra-terminal-call-limitations-v2",
no_return_repair:
installation.platform === "win32"
? "disabled-windows-p0"
@ -218,7 +218,7 @@ export const resolveGhidraAnalysisProfile = async (
"address-first-unique-target-source-mapping-v2",
instruction_flow_evidence: "decoded-return-pcode-v1",
instruction_references: "exact-address-incoming-v1",
function_boundary_observations: "ghidra-terminal-call-limitations-v1",
function_boundary_observations: "ghidra-terminal-call-limitations-v2",
no_return_repair:
installation.platform === "win32"
? "disabled-windows-p0"

View File

@ -23,6 +23,8 @@ import {
const observationOperations: ReadonlySet<GhidraFunctionOperation> = new Set([
"analyze_function",
"procedure_assembly",
"procedure_callees",
"procedure_callers",
"procedure_info",
"procedure_pseudo_code",
"read_function_instructions",
@ -93,6 +95,29 @@ describe("Ghidra function-analysis result values", () => {
});
});
it.each(["procedure_callees", "procedure_callers"] as const)(
"requires the function-observation envelope to carry terminal-call limitations for %s",
(operation) => {
// A bare address list is the pre-fix bridge shape; without the envelope
// a truncated function body reports no warning, so it must be rejected.
expect(parseGhidraFunctionResult(operation, ["0x2000"])).toMatchObject({
ok: false,
error: { _tag: "AnalysisOutputError" },
});
const limitation =
"Ghidra Listing reports a terminal call at 0x401000 to 0x402000 abort; fallthrough is excluded by Ghidra's flow model.";
expect(
parseGhidraFunctionResult(operation, {
value: ["0x2000"],
limitations: [limitation],
}),
).toEqual({
ok: true,
value: { value: ["0x2000"], limitations: [limitation] },
});
},
);
it.each(["reference_kinds_available", "unresolved_calls"])(
"rejects omitted %s instead of inventing complete reference observations",
(field) => {

View File

@ -409,6 +409,8 @@ const functionBoundaryObservationOperations: ReadonlySet<GhidraFunctionOperation
new Set([
"analyze_function",
"procedure_assembly",
"procedure_callees",
"procedure_callers",
"procedure_info",
"procedure_pseudo_code",
"read_function_instructions",

View File

@ -828,7 +828,11 @@ describe("Ghidra relationship coverage", () => {
start: () => Promise.resolve(ok(sessionInfo())),
callTool: (operation) =>
Promise.resolve(
ok(operation === "procedure_callees" ? ["0x2000"] : []),
ok(
operation === "procedure_callees"
? { value: ["0x2000"], limitations: [] }
: { value: [], limitations: [] },
),
),
close: () => Promise.resolve(ok(null)),
}));

View File

@ -46,6 +46,8 @@ it("retains terminal-call qualifications across function views and snapshot repl
limitations: [limitation],
},
],
["procedure_callees", ["0x402000"]],
["procedure_callers", [identity.address]],
["analyze_function", jsonValueSchema.parse(dossier)],
]);
const harness = await connectGhidraMcp(
@ -81,7 +83,7 @@ it("retains terminal-call qualifications across function views and snapshot repl
});
expect(evidence.limitations, name).toContain(limitation);
expect(evidence.analysis_profile?.parameters).toMatchObject({
function_boundary_observations: "ghidra-terminal-call-limitations-v1",
function_boundary_observations: "ghidra-terminal-call-limitations-v2",
});
const replay = await harness.session.execute(name, evidence.parameters);
if (!replay.ok) throw replay.error;

View File

@ -263,6 +263,8 @@ const resultFor = (
const functionObservationOperations: ReadonlySet<GhidraOperation> = new Set([
"analyze_function",
"procedure_assembly",
"procedure_callees",
"procedure_callers",
"procedure_info",
"procedure_pseudo_code",
"read_function_instructions",