Compare commits

...
Author SHA1 Message Date
Kaoru0822 84f66cc672 Merge remote-tracking branch 'origin/main' into fix/8284-zero-argument-parameter-bindings 2026-10-31 16:33:56 +00:00
Kaoru0822andClaude Opus 5.5 ff00a77731 fix(native): keep fixed zero-parameter calls out of missing-binding unknowns
trace_native_values reported "Parameter bindings ... are unavailable or
omitted" for every call whose callee had no recovered parameter nodes. A
Ghidra function with an authored int f(void) signature therefore looked as
unresolved as one whose decompiler recovered nothing, and no structured fact
distinguished the two or a variadic prototype.

The Ghidra bridge now reports native_api.parameter_list: basis "signature"
when Ghidra fixes the HighFunction inputs from a non-default signature (an
empty one as its void-input lock, otherwise every valid parameter
type-locked) and "recovered" otherwise, plus the prototype's varargs flag.
The Ghidra adapter requires the fact; the provider-neutral schema keeps it
optional so older retained Evidence stays readable with arity unknown.

The shared trace omits the unknown only for an analyzed callee whose
boundary and value flow have no parameters and whose list has a signature
basis and is not variadic. Recovered, variadic or unreported lists, and
callees that were not analyzed, keep it. A new Ghidra profile commitment
stops replay of snapshots recorded without the fact, and a bridge CI probe
checks the basis and varargs flag on real Ghidra decompilations.

Fixes #8284

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 16:33:56 +00:00
20 changed files with 829 additions and 9 deletions

No files matched your search

@@ -95,3 +95,30 @@ 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
# Authored void, variadic and one-parameter signatures fix Ghidra's
# HighFunction inputs; default signatures are recovered from uses. Only
# the signatures are authored. The source-owned x86-64 bytes are never
# executed.
- name: Distinguish fixed from recovered HighFunction parameter lists
shell: bash
run: |
java_home_variable="JAVA_HOME_${GHIDRA_JAVA_MAJOR}_X64"
export JAVA_HOME="${!java_home_variable}"
probe_root="${RUNNER_TEMP}/rea-parameter-list"
mkdir -p "${probe_root}/project"
{
printf '\270\001\000\000\000\303\314\314' # 0x1000: mov eax, 1; ret (default)
printf '\270\001\000\000\000\303\314\314' # 0x1008: int f(void)
printf '\270\001\000\000\000\303\314\314' # 0x1010: int f(...)
printf '\270\001\000\000\000\303\314\314' # 0x1018: int f(int x)
printf '\215\004\017\303\314\314\314\314' # 0x1020: lea eax, [rdi + rcx]; ret (default)
} > "${probe_root}/parameter-list.bin"
test "$(wc -c < "${probe_root}/parameter-list.bin")" -eq 40
timeout 180s "${GHIDRA_INSTALL_DIR}/support/analyzeHeadless" "${probe_root}/project" ParameterList \
-import "${probe_root}/parameter-list.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 ReaParameterListProbe.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_PARAMETER_LIST_OK default=recovered void=signature varargs=variadic one=signature used=recovered' "${probe_root}/output.log"
+23
View File
@@ -2495,6 +2495,7 @@ public final class ReaGhidraBridge extends HeadlessScript {
JsonArray parameters = new JsonArray();
result.add("parameters", parameters);
result.add("parameter_list", parameterList(function, prototype));
JsonArray jumpTables = new JsonArray();
result.add("jump_tables", jumpTables);
@@ -2552,6 +2553,28 @@ public final class ReaGhidraBridge extends HeadlessScript {
return result;
}
/**
* Ghidra fixes prototype inputs from a non-default signature: an empty one
* reaches the decompiler as a void-input lock (FunctionPrototype), and each
* valid parameter is type-locked (LocalSymbolMap). Any other list was
* recovered from observed uses and may omit parameters.
*/
private static JsonObject parameterList(Function function, FunctionPrototype prototype) {
int count = prototype.getNumParams();
boolean signature = count > 0 || (
function.getSignatureSource() != SourceType.DEFAULT &&
function.getParameterCount() == 0
);
for (int index = 0; signature && index < count; index += 1) {
HighSymbol parameter = prototype.getParam(index);
signature = parameter != null && parameter.isTypeLocked();
}
JsonObject result = new JsonObject();
result.addProperty("basis", signature ? "signature" : "recovered");
result.addProperty("variadic", prototype.isVarArg());
return result;
}
private static JsonObject unavailableNativeValueFlow(String reason) {
JsonObject unavailable = new JsonObject();
unavailable.addProperty("available", false);
+1
View File
@@ -245,6 +245,7 @@ profile stages before retrying their retained descriptors.
| 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. |
| Parameter lists | `native_api.parameter_list.basis` is `signature` when Ghidra fixes HighFunction inputs from a non-default signature: an empty one as a void-input lock, or every valid parameter type-locked. Otherwise it is `recovered` and the decompiler may have omitted parameters. `variadic` is the HighFunction prototype's varargs flag. `signature_source` names the signature's origin, which may be analysis or an import; a signature basis is not source-level proof. Ghidra snapshots recorded without this fact are not replayed. |
Runtime updates apply to newly accepted operations on the existing Program.
They preserve accepted budgets and do not clear cleanup blockers or change the
+9
View File
@@ -421,6 +421,15 @@ nodes and edges. Node payloads are bounded to 8 MiB and serialized results to
32 MiB. Pagination returns edges whose source is on the node page and preserves
stable IDs for endpoints outside that page.
A call to an analyzed callee with no recovered parameter nodes gets the unknown
`Parameter bindings for <callee> are unavailable or omitted`, unless the
callee's `native_api` reports no parameters and a `parameter_list` with
`basis: "signature"` and `variadic: false`. That provider fact means the
callee's signature fixes zero parameters, so the call has nothing to bind.
Recovered, variadic or unreported parameter lists, and callees that were not
analyzed, keep the unknown. Evidence recorded before `parameter_list` existed
omits it, and its arity remains unknown.
Within one value trace, each exact procedure selector is attempted at most once,
including failed analyses. A failed seed returns the provider's original typed
failure and corrective guidance; it cannot establish a trace or target identity.
+1
View File
@@ -627,6 +627,7 @@ const PATH_SCOPES = {
"scripts/verify-ghidra-bridge-sources.mjs",
"tests/conformance/ghidra/ReaArmContextProbe.java",
"tests/conformance/ghidra/ReaDecodeProbe.java",
"tests/conformance/ghidra/ReaParameterListProbe.java",
".github/workflows/ghidra-bridge-sources.yml",
],
website: [
@@ -42,13 +42,61 @@ export const pcodeOperation = (
output: pcodeScalar,
});
type ParameterFact = { ordinal: number; name: string; data_type: string };
/**
* Provider parameter-list facts. Fixtures default to a decompiler-recovered
* list; "unreported" omits the facts, as Evidence from older releases does.
*/
export type ParameterListFact =
| { readonly basis: "signature" | "recovered"; readonly variadic: boolean }
| "unreported";
const recoveredParameters: ParameterListFact = {
basis: "recovered",
variadic: false,
};
/** An API boundary whose parameters and parameter-list facts match the flow. */
const nativeApi = (
base: ReturnType<typeof functionDossierSchema.parse>,
parameters: readonly ParameterFact[],
parameterList: ParameterListFact,
) => {
if (base.native_api?.available !== true) throw new Error("Invalid fixture");
const boundary = {
...base.native_api,
parameters: parameters.map(({ ordinal, name, data_type }) => ({
role: "parameter" as const,
ordinal,
name,
data_type,
size_bytes: 4,
storage: null,
confidence: "high" as const,
evidence: [
{
kind: "decompiler-type" as const,
source: "ghidra-high-function",
detail: `HighFunction recovered data type ${data_type}.`,
},
],
decompiler_artifacts: [],
})),
};
if (parameterList === "unreported") delete boundary.parameter_list;
else boundary.parameter_list = parameterList;
return boundary;
};
export const nativeFlowDossier = (
base: ReturnType<typeof functionDossierSchema.parse>,
facts: {
address: string;
name: string;
operations: ReturnType<typeof pcodeOperation>[];
parameters: { ordinal: number; name: string; data_type: string }[];
parameters: ParameterFact[];
parameterList?: ParameterListFact;
},
) => {
const { address, name, operations, parameters } = facts;
@@ -65,6 +113,11 @@ export const nativeFlowDossier = (
signature: null,
locals: [],
},
native_api: nativeApi(
base,
parameters,
facts.parameterList ?? recoveredParameters,
),
native_value_flow: {
available: true,
provenance: "ghidra-high-pcode",
@@ -84,7 +137,8 @@ export const nativeFlowDossier = (
interface StaticProcedure {
readonly operations: ReturnType<typeof pcodeOperation>[];
readonly parameters?: { ordinal: number; name: string; data_type: string }[];
readonly parameters?: ParameterFact[];
readonly parameterList?: ParameterListFact;
readonly def_use?: { definition: string; use: string; input_index: number }[];
readonly pcodeTruncated?: boolean;
}
@@ -154,6 +208,9 @@ export const staticValueGraph = (
name: address,
operations: facts.operations,
parameters: facts.parameters ?? [],
...(facts.parameterList === undefined
? {}
: { parameterList: facts.parameterList }),
}),
);
if (!dossier.native_value_flow?.available)
@@ -19,6 +19,7 @@ import { NoBinaryOpenError } from "../../domain/configurationErrors.js";
import { ok, err } from "../../domain/result.js";
import {
nativeFlowDossier,
type ParameterListFact,
pcodeOperation as node,
pcodeScalar as scalar,
staticValueGraph,
@@ -1327,3 +1328,177 @@ describe("native value traversal limits and policies", () => {
expect(limited.analyzed).toEqual(["0x1000"]);
});
});
/** 0x1000 passes `argumentCount` arguments to 0x2000, which returns a value. */
const singleCallGraph = (
callee: {
readonly parameters?: {
ordinal: number;
name: string;
data_type: string;
}[];
readonly parameterList: ParameterListFact;
},
argumentCount = 0,
) =>
staticValueGraph(
{
"0x1000": {
operations: [
{
...node("call", "0x1004", "CALL"),
inputs: Array.from({ length: argumentCount + 1 }, () => scalar),
},
],
},
"0x2000": {
operations: [node("return", "0x2008", "RETURN")],
...callee,
},
},
{ "0x1004": [{ destination: "0x2000", status: "direct" }] },
);
const parameterFacts = (count: number) =>
Array.from({ length: count }, (_, ordinal) => ({
ordinal,
name: `param_${String(ordinal + 1)}`,
data_type: "/int",
}));
const missingBindings = {
procedure: "0x1000",
address: null,
reason: "Parameter bindings for 0x2000 are unavailable or omitted",
};
const edgeKinds = (trace: { edges: readonly { kind: string }[] }) =>
trace.edges.map(({ kind }) => kind);
describe("native value parameter-list facts", () => {
it("binds no parameters to a callee whose signature fixes none", async () => {
const trace = await traced(
singleCallGraph({
parameterList: { basis: "signature", variadic: false },
}).port,
{ procedure: "0x1000" },
);
expect(trace).toMatchObject({
traversal: { status: "complete" },
truncated: false,
unknowns: [],
});
expect(edgeKinds(trace)).toEqual(["call", "return-binding"]);
});
it.each([
{
label: "a decompiler-recovered empty list",
parameterList: { basis: "recovered", variadic: false },
},
{
label: "a variadic signature without fixed parameters",
parameterList: { basis: "signature", variadic: true },
},
{
label: "a variadic recovered list",
parameterList: { basis: "recovered", variadic: true },
},
{
label: "parameter-list facts omitted by older Evidence",
parameterList: "unreported",
},
] as const)(
"keeps missing bindings unknown for $label",
async ({ parameterList }) => {
const trace = await traced(singleCallGraph({ parameterList }).port, {
procedure: "0x1000",
});
expect(trace).toMatchObject({
traversal: { status: "complete" },
unknowns: [missingBindings],
});
expect(edgeKinds(trace)).toEqual(["call", "return-binding"]);
},
);
it("keeps missing bindings unknown when the zero-parameter callee is not analyzed", async () => {
const graph = singleCallGraph({
parameterList: { basis: "signature", variadic: false },
});
expect(
await traced(graph.port, { procedure: "0x1000", max_depth: 0 }),
).toMatchObject({
traversal: { status: "truncated", reasons: ["max_depth"] },
unknowns: [
{
procedure: "0x1000",
address: "0x1004",
reason: "Call depth budget reached",
},
missingBindings,
],
});
expect(graph.analyzed).toEqual(["0x1000"]);
});
it("keeps missing bindings unknown when the provider has no API boundary", async () => {
const graph = singleCallGraph({
parameterList: { basis: "signature", variadic: false },
});
const port: AnalysisOperationPort = {
execute: async (operation, parameters, options) => {
const execution = await graph.port.execute(
operation,
parameters,
options,
);
if (!execution.ok || parameters.procedure !== "0x2000")
return execution;
const dossier = functionDossierSchema.parse(execution.value.result);
dossier.native_api = null;
return ok({
...execution.value,
result: jsonValueSchema.parse(dossier),
});
},
};
expect(await traced(port, { procedure: "0x1000" })).toMatchObject({
unknowns: [missingBindings],
});
});
it("binds every argument of a fixed signature and names one the call omits", async () => {
const callee = {
parameters: parameterFacts(2),
parameterList: { basis: "signature", variadic: false },
} as const;
const complete = await traced(singleCallGraph(callee, 2).port, {
procedure: "0x1000",
});
expect(complete).toMatchObject({ unknowns: [] });
expect(
complete.edges
.filter(({ kind }) => kind === "argument-binding")
.map(({ target, input_index }) => ({ target, input_index })),
).toEqual([
{ target: "0x2000/parameter:0", input_index: 1 },
{ target: "0x2000/parameter:1", input_index: 2 },
]);
const partial = await traced(singleCallGraph(callee, 1).port, {
procedure: "0x1000",
});
expect(partial).toMatchObject({
unknowns: [
{
procedure: "0x1000",
address: null,
reason: "Argument 1 for 0x2000 was not recovered",
},
],
});
expect(edgeKinds(partial)).toEqual([
"call",
"argument-binding",
"return-binding",
]);
});
});
+7 -1
View File
@@ -1,4 +1,5 @@
import { functionDossierSchema } from "../../domain/native/functionDossier.js";
import { takesNoParameters } from "../../domain/native/nativeApiBoundary.js";
import { nativeCallTargetsSchema } from "../../domain/native/nativeInstruction.js";
import {
nativeValueTraceInputSchema,
@@ -47,6 +48,9 @@ export const traceNativeValues = async (
destination: string;
reason: "max_depth" | "ambiguous_call_candidates";
}[] = [];
// Analyzed procedures whose provider fixed an empty, non-variadic parameter
// list: calls to them have no parameter bindings to recover.
const parameterless = new Set<string>();
const budget = createValueTraceBudget(input, unknowns);
const { nodes, edges } = budget;
let targetSha256: string | undefined;
@@ -146,6 +150,8 @@ export const traceNativeValues = async (
});
continue;
}
if (flow.parameters.length === 0 && takesNoParameters(dossier.native_api))
parameterless.add(address);
if (flow.truncated)
budget.narrow("provider_pcode_truncated", {
procedure: address,
@@ -377,7 +383,7 @@ export const traceNativeValues = async (
}
for (const binding of callBindings) {
const parameters = parametersByProcedure.get(binding.callee) ?? [];
if (parameters.length === 0)
if (parameters.length === 0 && !parameterless.has(binding.callee))
unknowns.push({
procedure: binding.caller,
address: null,
+2
View File
@@ -88,6 +88,8 @@ export const ghidraNativeApiBoundary = () => ({
],
},
parameters: [],
// A non-default empty signature is Ghidra's void-input lock.
parameter_list: { basis: "signature" as const, variadic: false },
jump_tables: [
{
dispatch_address: "0x401010",
+29
View File
@@ -46,6 +46,20 @@ const inferredParameterTypeSchema = z.strictObject({
ordinal: z.int().min(0),
});
/** Provider basis of a reported parameter list, and whether it admits variadic arguments. */
export const nativeParameterListSchema = z.strictObject({
basis: z
.enum(["signature", "recovered"])
.describe(
"signature: the provider fixed these parameters from the function's stored, non-default signature (see signature_source); that signature has no other fixed parameters. recovered: the decompiler inferred them from observed uses and may omit parameters.",
),
variadic: z
.boolean()
.describe(
"Whether the prototype accepts variable arguments after the listed parameters.",
),
});
const jumpTableDataSourceSchema = z
.object({
address: z.string().min(1),
@@ -95,6 +109,11 @@ export const availableNativeApiBoundarySchema = z
calling_convention: z.string().min(1),
return_type: inferredReturnTypeSchema,
parameters: z.array(inferredParameterTypeSchema),
parameter_list: nativeParameterListSchema
.exactOptional()
.describe(
"Absent when the provider did not report it, as in older Evidence; parameter completeness and variadic arguments then remain unknown.",
),
jump_tables: z.array(inferredJumpTableSchema),
pseudocode: z
.object({
@@ -132,6 +151,16 @@ export const nativeApiBoundarySchema = z.discriminatedUnion("available", [
/** One validated provider-neutral native API boundary observation. */
export type NativeApiBoundary = z.infer<typeof nativeApiBoundarySchema>;
/**
* True only when the provider fixed an empty, non-variadic parameter list from
* a signature. A recovered, variadic or unreported list leaves arity unknown.
*/
export const takesNoParameters = (boundary: NativeApiBoundary | null) =>
boundary?.available === true &&
boundary.parameters.length === 0 &&
boundary.parameter_list?.basis === "signature" &&
!boundary.parameter_list.variadic;
const nativeApiInspectionSubstepSchema = z
.object({
operation: z.enum([
+1
View File
@@ -68,6 +68,7 @@ const sharedAnalysisSemantics = {
instruction_status: "unlisted-address-without-decode-v1",
instruction_references: "exact-address-incoming-without-synthetic-entry-v2",
function_boundary_observations: "ghidra-function-boundary-limitations-v4",
native_api_parameter_lists: "signature-or-recovered-basis-varargs-v1",
} as const;
/**
+57
View File
@@ -734,6 +734,63 @@ describe("Ghidra jump-table mapping contract", () => {
});
});
describe("Ghidra parameter-list facts", () => {
const boundary = () => {
const dossier = functionDossierSchema.parse(ghidraFunctionDossier());
if (dossier.native_api?.available !== true)
throw new TypeError("Ghidra native API fixture is unavailable");
return { dossier, boundary: dossier.native_api };
};
it.each([
{ basis: "signature", variadic: false },
{ basis: "signature", variadic: true },
{ basis: "recovered", variadic: false },
])(
"admits the bridge's $basis parameter list (variadic $variadic)",
(list) => {
const { dossier, boundary: api } = boundary();
const parsed = parseFunctionValueFixture("analyze_function", {
...dossier,
native_api: { ...api, parameter_list: list },
});
if (!parsed.ok) throw parsed.error;
expect(
functionDossierSchema.parse(parsed.value).native_api,
).toMatchObject({ parameter_list: list });
},
);
it.each([
{ basis: "declared", variadic: false },
{ basis: "signature" },
{ basis: "signature", variadic: "false" },
{ basis: "signature", variadic: false, complete: true },
])("rejects malformed bridge parameter-list facts %j", (list) => {
const { dossier, boundary: api } = boundary();
expect(
parseFunctionValueFixture(
"analyze_function",
jsonValueSchema.parse({
...dossier,
native_api: { ...api, parameter_list: list },
}),
),
).toMatchObject({ ok: false, error: { _tag: "AnalysisOutputError" } });
});
it("requires the facts from the bridge while older retained dossiers stay readable", () => {
const { dossier, boundary: api } = boundary();
const { parameter_list: omitted, ...older } = api;
expect(omitted).toEqual({ basis: "signature", variadic: false });
const retained = { ...dossier, native_api: older };
expect(
parseFunctionValueFixture("analyze_function", retained),
).toMatchObject({ ok: false, error: { _tag: "AnalysisOutputError" } });
expect(functionDossierSchema.parse(retained).native_api).toEqual(older);
});
});
describe("Ghidra function-analysis malformed results", () => {
it.each(malformedOutputs())(
"rejects malformed %s output",
+6 -1
View File
@@ -2,6 +2,7 @@ import { z } from "zod";
import { nativeAddressObservationSchema } from "../domain/native/nativeAddress.js";
import {
availableNativeApiBoundarySchema,
nativeParameterListSchema,
unavailableNativeApiBoundarySchema,
} from "../domain/native/nativeApiBoundary.js";
import {
@@ -31,11 +32,15 @@ const ghidraJumpTable = jumpTable.extend({
),
});
/** Ghidra API facts carry canonical coordinates and HighFunction provenance. */
/**
* Ghidra API facts carry canonical coordinates and HighFunction provenance.
* The packaged bridge always reports the HighFunction's parameter-list basis.
*/
export const ghidraNativeApiBoundary = z.discriminatedUnion("available", [
unavailableNativeApiBoundarySchema,
availableNativeApiBoundarySchema.extend({
provenance: z.literal("ghidra-high-function"),
parameter_list: nativeParameterListSchema,
jump_tables: z.array(ghidraJumpTable),
}),
]);
+4 -3
View File
@@ -7,11 +7,12 @@ import { ghidraProfileInstallationHost as host } from "./GhidraInstallation.fixt
import { GhidraProvider } from "./GhidraProvider.js";
// Digests commit to the current shared policy, including the instruction status
// revision that prevents reuse of snapshots with the previous ambiguous result.
// revision that prevents reuse of snapshots with the previous ambiguous result
// and the parameter-list revision that prevents reuse of dossiers without it.
const RAW_DIGESTS = {
"6502": "8e33776d1cc5c5d77d32923d5978c05ec2d989d00920dd458cc2dafb4c9c0cad",
"6502": "8c5ec28bf23077d3f7cbdb033612c14b7314377d23962cadd70171c8adbf2b10",
"x86-disabled-analyzer":
"d17bddc8ac110c891d30fb989fc7906df421bd9bea5df4aac05a950f50c7bdaf",
"71a355ce974a5c9629a6ac0ab24070b905026bd364a7f330cbbaa38538a12c15",
} as const;
const configFor = (environment: NodeJS.ProcessEnv) => {
+5
View File
@@ -307,6 +307,11 @@ describe("Ghidra relationship coverage replay", () => {
describe("Ghidra snapshot profile admission", () => {
it.each([
{
parameter: "native_api_parameter_lists",
current: "signature-or-recovered-basis-varargs-v1",
legacy: undefined,
},
{
parameter: "instruction_status",
current: "unlisted-address-without-decode-v1",
@@ -11,6 +11,9 @@ import {
import { runDirectAnalysis } from "../../../src/application/DirectAnalysis.js";
import type { DirectAnalysisDependencies } from "../../../src/application/DirectAnalysisDependencies.js";
import {
pcodeOperation,
pcodeScalar,
staticValueGraph,
valueTraceDiamond,
valueTraceProvider,
} from "../../../src/application/native/NativeValueTrace.fixture.js";
@@ -34,8 +37,11 @@ const effects = {
};
/** One-shot CLI analysis over the shared trace with a simulated provider. */
const traceCli = async () => {
const graph = valueTraceDiamond({ sessionSubject: true });
const traceCli = async (
graph: Pick<ReturnType<typeof staticValueGraph>, "port"> = valueTraceDiamond({
sessionSubject: true,
}),
) => {
const directory = await createTestTempDirectory("rea-value-trace-cli-");
const path = join(directory, "fixture.hop");
await writeFile(path, "synthetic analysis database");
@@ -135,3 +141,48 @@ it("reports the CLI last page and its observed node budget separately", async ()
reason: "Node budget reached",
});
});
it("reports CLI missing bindings only for callees without a fixed empty parameter list", async () => {
// 0x1000 calls 0x2000, whose signature fixes no parameters, and 0x3000,
// whose empty list was recovered from uses; neither call passes arguments.
const zeroArgumentCall = (id: string, address: string) => ({
...pcodeOperation(id, address, "CALL"),
inputs: [pcodeScalar],
});
const trace = await traceCli(
staticValueGraph(
{
"0x1000": {
operations: [
zeroArgumentCall("call-fixed", "0x1004"),
zeroArgumentCall("call-recovered", "0x1008"),
],
},
"0x2000": {
operations: [pcodeOperation("return", "0x2004", "RETURN")],
parameterList: { basis: "signature", variadic: false },
},
"0x3000": {
operations: [pcodeOperation("return", "0x3004", "RETURN")],
parameterList: { basis: "recovered", variadic: false },
},
},
{
"0x1004": [{ destination: "0x2000", status: "direct" }],
"0x1008": [{ destination: "0x3000", status: "direct" }],
},
{ sessionSubject: true },
),
);
expect(await trace(["--json"])).toMatchObject({
traversal: { status: "complete" },
truncated: false,
unknowns: [
{
procedure: "0x1000",
address: null,
reason: "Parameter bindings for 0x3000 are unavailable or omitted",
},
],
});
});
@@ -780,6 +780,10 @@ it.each([
"tests/conformance/ghidra/ReaDecodeProbe.java",
{ ghidra_bridge: true, runtime: false, package: false },
],
[
"tests/conformance/ghidra/ReaParameterListProbe.java",
{ ghidra_bridge: true, runtime: false, package: false },
],
[
"bridge/ghidra/Example.java",
{
@@ -0,0 +1,225 @@
import { expect, it } from "vitest";
import { connectGhidraMcp, sessionEvidence } from "./ghidraMcpHarness.js";
import { advertisedMcpToolSchemas } from "../../fixtures/advertisedMcpTool.js";
import { ghidraFunctionDossier } from "../../../src/domain/ghidraValues.fixture.js";
import { functionDossierSchema } from "../../../src/domain/native/functionDossier.js";
import { nativeApiInspectionResultSchema } from "../../../src/domain/native/nativeApiBoundary.js";
import { nativeValueTraceSchema } from "../../../src/domain/native/nativeValueTrace.js";
import { jsonValueSchema } from "../../../src/domain/jsonValue.js";
import { ok } from "../../../src/domain/result.js";
const caller = "0x401000";
const callee = "0x401100";
const register = {
kind: "register",
size_bytes: 4,
location: "register:0x0",
constant_hex: null,
} as const;
/** A controlled bridge wire dossier; it never launches Ghidra. */
const bridgeDossier = (
address: string,
name: string,
operations: readonly Record<string, unknown>[],
api: { readonly signatureSource: string; readonly parameterList: unknown },
) => {
const base = functionDossierSchema.parse(ghidraFunctionDossier());
if (
base.native_api?.available !== true ||
base.native_value_flow?.available !== true
)
throw new Error("Invalid Ghidra fixture");
const end = `0x${(Number.parseInt(address, 16) + 5).toString(16)}`;
const boundary: Record<string, unknown> = {
...base.native_api,
signature_source: api.signatureSource,
parameters: [],
parameter_list: api.parameterList,
jump_tables: [],
};
if (api.parameterList === undefined) delete boundary.parameter_list;
return jsonValueSchema.parse({
...base,
procedure: {
...base.procedure,
address,
name,
body: { ...base.procedure.body, ranges: [{ start: address, end }] },
signature: null,
},
native_api: boundary,
native_value_flow: {
...base.native_value_flow,
operations: operations.map((operation, sequence) => ({
id: `${address}#${String(sequence)}`,
sequence,
is_dead: false,
block_membership: "member",
...operation,
})),
},
});
};
/** `audit_call_zero` calls `audit_zero`, which takes the callee's parameter facts. */
const connect = (calleeApi: Parameters<typeof bridgeDossier>[3]) =>
connectGhidraMcp("ghidra-parameter-list", async (operation, input) => {
if (operation === "resolve_native_call_targets")
return ok(
jsonValueSchema.parse({
call_site: "0x401004",
procedure: callee,
status: "direct",
mechanism: "direct",
targets: [
{
address: callee,
procedure: callee,
status: "direct",
basis: "provider-reference",
references: [],
},
],
limitations: [],
}),
);
if (operation !== "analyze_function")
throw new Error(`Unexpected controlled Ghidra operation: ${operation}`);
const value =
input.procedure === callee
? bridgeDossier(
callee,
"audit_zero",
[
{
address: "0x401104",
opcode: "RETURN",
inputs: [
{
kind: "constant",
size_bytes: 8,
location: null,
constant_hex: "0",
},
register,
],
output: null,
},
],
calleeApi,
)
: bridgeDossier(
caller,
"audit_call_zero",
[
{
address: "0x401004",
opcode: "CALL",
inputs: [
{
kind: "address",
size_bytes: 8,
location: callee,
constant_hex: null,
},
],
output: register,
},
],
{
signatureSource: "default",
parameterList: { basis: "recovered", variadic: false },
},
);
return ok(jsonValueSchema.parse({ value, limitations: [] }));
});
const missingBindings = `Parameter bindings for ${callee} are unavailable or omitted`;
it.each([
{
label: "a user-defined zero-parameter signature",
signatureSource: "user_defined",
parameterList: { basis: "signature", variadic: false },
unknowns: [],
},
{
label: "a decompiler-recovered empty list",
signatureSource: "default",
parameterList: { basis: "recovered", variadic: false },
unknowns: [missingBindings],
},
{
label: "a user-defined variadic signature without fixed parameters",
signatureSource: "user_defined",
parameterList: { basis: "signature", variadic: true },
unknowns: [missingBindings],
},
] as const)(
"traces $label through the Ghidra adapter and advertised MCP contracts",
async ({ signatureSource, parameterList, unknowns }) => {
const harness = await connect({ signatureSource, parameterList });
try {
const call = async (
name: "trace_native_values" | "inspect_native_api",
arguments_: Record<string, unknown>,
) => {
const { toolDefinition, ajv, outputSchema } =
await advertisedMcpToolSchemas(harness.mcp, name);
const reply = await harness.mcp.callTool(
{ name, arguments: arguments_ },
{ toolDefinition },
);
expect(reply.isError, JSON.stringify(reply.content)).not.toBe(true);
expect(
ajv.validate(outputSchema, reply.structuredContent),
JSON.stringify(ajv.errors),
).toBe(true);
return sessionEvidence(harness.session, reply.structuredContent)
.normalized_result;
};
const trace = nativeValueTraceSchema.parse(
await call("trace_native_values", { procedure: caller, max_depth: 1 }),
);
expect(trace).toMatchObject({
traversal: { status: "complete" },
truncated: false,
});
expect(trace.unknowns.map(({ reason }) => reason)).toEqual(unknowns);
expect(trace.edges.map(({ kind }) => kind)).toEqual([
"call",
"return-binding",
]);
expect(
nativeApiInspectionResultSchema.parse(
await call("inspect_native_api", { procedure: callee }),
).boundary,
).toMatchObject({
available: true,
signature_source: signatureSource,
parameters: [],
parameter_list: parameterList,
});
} finally {
await harness.close();
}
},
30_000,
);
it("refuses a bridge API boundary that omits its parameter-list basis", async () => {
const harness = await connect({
signatureSource: "user_defined",
parameterList: undefined,
});
try {
const reply = await harness.mcp.callTool({
name: "inspect_native_api",
arguments: { procedure: callee },
});
expect(reply.isError).toBe(true);
} finally {
await harness.close();
}
}, 30_000);
+7
View File
@@ -19,6 +19,13 @@ binaries and Ghidra projects are never committed.
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.
- `ReaParameterListProbe.java` decompiles source-owned x86-64 functions, two
with Ghidra's default signature and three with authored `int f(void)`,
`int f(...)` and `int f(int x)` signatures, through the production bridge.
The `ghidra-bridge-sources` CI job checks each `native_api.parameter_list`
basis and varargs flag, and that value-flow parameters match the boundary.
It does not establish how analysis or imports set signatures; the bytes are
never executed.
- `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
@@ -0,0 +1,134 @@
//@category REA Verification
import com.google.gson.JsonObject;
import ghidra.app.decompiler.DecompInterface;
import ghidra.app.decompiler.DecompileResults;
import ghidra.app.script.GhidraScript;
import ghidra.app.script.GhidraScriptUtil;
import ghidra.program.model.address.Address;
import ghidra.program.model.data.IntegerDataType;
import ghidra.program.model.listing.Function;
import ghidra.program.model.listing.Parameter;
import ghidra.program.model.listing.ParameterImpl;
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, function signatures and decompilation. Two functions
* keep Ghidra's default signature, one without and one with used argument
* registers; three get user-defined int f(void), int f(...) and int f(int x)
* signatures, as an analyst would author them. The production bridge must
* report each HighFunction's parameter-list basis and varargs flag, with the
* value-flow parameters matching the API boundary. The bytes are never executed.
*/
public class ReaParameterListProbe extends GhidraScript {
@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();
Function defaultEmpty = function(base); // mov eax, 1; ret
Function userVoid = function(base.add(0x08)); // mov eax, 1; ret
Function userVarargs = function(base.add(0x10)); // mov eax, 1; ret
Function userOne = function(base.add(0x18)); // mov eax, 1; ret
Function defaultUsed = function(base.add(0x20)); // lea eax, [rdi + rcx]; ret
author(userVoid, false);
author(userVarargs, true);
author(userOne, false, new ParameterImpl("x", IntegerDataType.dataType, currentProgram));
require(defaultEmpty.getSignatureSource() == SourceType.DEFAULT &&
defaultUsed.getSignatureSource() == SourceType.DEFAULT, "Default fixture signatures");
Field space = bridge.getClass().getDeclaredField("sessionDefaultAddressSpace");
space.setAccessible(true);
space.set(null, base.getAddressSpace());
Field timeout = bridge.getClass().getDeclaredField("decompileTimeoutSeconds");
timeout.setAccessible(true);
timeout.setInt(bridge, 60);
Field decompiler = bridge.getClass().getDeclaredField("decompiler");
decompiler.setAccessible(true);
invoke(method(bridge, "initializeDecompiler"), bridge);
Method decompile = method(bridge, "decompile", Function.class);
Method boundary = method(bridge, "nativeApiBoundary", Function.class, DecompileResults.class);
Method flow = method(bridge, "nativeValueFlow", DecompileResults.class);
try {
// -1 requires at least one recovered parameter; its basis is the fact under test.
check(bridge, decompile, boundary, flow, defaultEmpty, "recovered", false, 0);
check(bridge, decompile, boundary, flow, userVoid, "signature", false, 0);
check(bridge, decompile, boundary, flow, userVarargs, "signature", true, 0);
check(bridge, decompile, boundary, flow, userOne, "signature", false, 1);
check(bridge, decompile, boundary, flow, defaultUsed, "recovered", false, -1);
} finally {
((DecompInterface) decompiler.get(bridge)).dispose();
}
println("REA_PARAMETER_LIST_OK default=recovered void=signature varargs=variadic one=signature used=recovered");
}
private Function function(Address entry) throws Exception {
if (getInstructionAt(entry) == null) disassemble(entry);
require(getInstructionAt(entry) != null, "Decoded entry " + entry);
Function function = createFunction(entry, null);
require(function != null, "Created function at " + entry);
return function;
}
private static void author(Function function, boolean varargs, Parameter... parameters)
throws Exception {
function.setReturnType(IntegerDataType.dataType, SourceType.USER_DEFINED);
function.replaceParameters(Function.FunctionUpdateType.DYNAMIC_STORAGE_ALL_PARAMS, true,
SourceType.USER_DEFINED, parameters);
function.setVarArgs(varargs);
require(function.getSignatureSource() == SourceType.USER_DEFINED &&
function.getParameterCount() == parameters.length && function.hasVarArgs() == varargs,
"Authored signature " + function.getSignature().getPrototypeString());
}
private void check(Object bridge, Method decompile, Method boundary, Method flow,
Function function, String basis, boolean variadic, int parameters) throws Exception {
DecompileResults results = (DecompileResults) invoke(decompile, bridge, function);
JsonObject api = (JsonObject) invoke(boundary, bridge, function, results);
JsonObject values = (JsonObject) invoke(flow, bridge, results);
String label = function.getSignature().getPrototypeString();
println("REA_PARAMETER_LIST " + label + " " + api.get("parameter_list") +
" parameters=" + api.getAsJsonArray("parameters").size());
require(api.get("available").getAsBoolean(), "Available API boundary for " + label);
JsonObject list = api.getAsJsonObject("parameter_list");
require(list != null && list.get("basis").getAsString().equals(basis),
"Parameter-list basis " + basis + " for " + label);
require(list.get("variadic").getAsBoolean() == variadic,
"Varargs flag " + variadic + " for " + label);
int count = api.getAsJsonArray("parameters").size();
require(parameters < 0 ? count > 0 : count == parameters, "Parameter count for " + label);
require(values.get("available").getAsBoolean() &&
values.getAsJsonArray("parameters").size() == count,
"Value-flow parameters match the API boundary for " + label);
}
private static Method method(Object owner, String name, Class<?>... types)
throws NoSuchMethodException {
Method method = owner.getClass().getDeclaredMethod(name, types);
method.setAccessible(true);
return method;
}
private static Object invoke(Method method, Object owner, Object... values) throws Exception {
try { return method.invoke(owner, values); }
catch (InvocationTargetException failure) {
if (failure.getCause() instanceof Exception cause) throw cause;
throw failure;
}
}
private static void require(boolean condition, String label) {
if (!condition) throw new IllegalStateException(label);
}
}