fix(ghidra): bind existing MIPS projects to target metadata (#8605)

Co-authored-by: Cursor Agent <[email protected]>
This commit is contained in:
morlutoandCursor Agent authored and GitHub committed 2026-11-05 03:35:21 +08:00
1 parent 4afb6b79ae
commit 982e750a70
6 files changed
+159 -4

No files matched your search

+3
View File
@@ -1473,6 +1473,9 @@ reopen the target to resolve a new profile after intentionally changing it.
The profile records the caller path, resolved project/name, program and content
fingerprint. Original analysis settings and completeness remain **unknown**;
prior annotations or edits are caller-project state, not fresh REA discoveries.
For supported header-selected MIPS ELF targets, the profile also binds the
selected target's ELF metadata; it does not claim a fresh-import MIPS lane was
used for the saved project.
A matching recorded import digest does not claim that the database is unedited.
Saved REA seed/analyzer reports remain historical facts in retained source Evidence;
they are not interpreted as overrides applied by this session.
+119 -1
View File
@@ -6,12 +6,13 @@ import {
mkdtemp,
mkdir,
readFile,
readdir,
realpath,
rm,
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { basename, join, relative } from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { Client } from "@modelcontextprotocol/client";
@@ -41,8 +42,10 @@ import {
import {
requireMcpToolError,
requireMcpOperationResult,
requireMcpOutput,
} from "./lib/mcp-verifier-results.mjs";
import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs";
import { runOwnedCommand } from "../dist/process/OwnedCommand.js";
// Optional cross-target lane. No tool acquisition and no target execution.
const mode = process.argv[2];
@@ -90,6 +93,114 @@ for (const setting of ["GHIDRA_HEADLESS_MAXMEM", "GHIDRA_MAXMEM"]) {
if (process.env[setting] !== undefined) env[setting] = process.env[setting];
}
/** Observe an existing project, and verify REA never alters the caller's copy. */
async function verifyExistingMipsProject(fixture, byteOrder) {
const projectDir = join(workspace, `${byteOrder}-existing-project`);
await mkdir(projectDir);
assert.ok(installation.analyzeHeadlessPath);
await runOwnedCommand(
{
command: installation.analyzeHeadlessPath,
arguments: [
projectDir,
"ExistingMips",
"-import",
fixture.path,
"-noanalysis",
],
env,
runId: run.run_id,
expectedCommand: null,
},
{ timeoutMs: 180000, diagnosticBytes: 1024 * 1024 },
);
const projectPath = join(projectDir, "ExistingMips.gpr");
const digestTree = async () => {
const paths = [projectPath];
const walk = async (dir) => {
for (const entry of await readdir(dir, { withFileTypes: true })) {
const path = join(dir, entry.name);
if (entry.isDirectory()) await walk(path);
else if (entry.isFile()) paths.push(path);
}
};
await walk(join(projectDir, "ExistingMips.rep"));
return Promise.all(
paths.sort().map(async (path) => [
relative(projectDir, path),
createHash("sha256")
.update(await readFile(path))
.digest("hex"),
]),
);
};
const before = await digestTree();
const transport = new StdioClientTransport({
command: process.execPath,
args: [entrypoint, "mcp"],
env: {
...env,
REA_GHIDRA_PROJECT_PATH: projectPath,
REA_GHIDRA_PROGRAM_NAME: basename(fixture.path),
},
stderr: "pipe",
});
const client = new Client({ name: "rea-existing-mips-proof", version: "1" });
try {
await client.connect(transport);
const opened = await client.callTool(
{
name: "open_binary",
arguments: { path: fixture.path, provider_id: "ghidra" },
},
{ timeout: 180000 },
);
requireMcpOperationResult(opened, "open_binary");
const result = await client.callTool(
{ name: "list_procedures", arguments: {} },
{ timeout: 180000 },
);
const observation = parseEvidence(
requireMcpOutput(result, "list_procedures"),
);
assert.ok(Array.isArray(observation.normalized_result));
const parameters = observation.analysis_profile?.parameters;
assert.equal(parameters?.import_mode, "existing-project-private-copy-v1");
assert.equal(parameters?.mips_elf?.byte_order, byteOrder);
assert.equal(parameters?.mips_support_lane, undefined);
assert.deepEqual(parameters?.original_analysis, {
completeness: "unknown",
settings: "unknown",
});
requireMcpOperationResult(
await client.callTool({ name: "close_binary", arguments: {} }),
"close_binary",
);
} finally {
try {
await client.close();
} finally {
await transport.close();
}
}
assert.deepEqual(
await digestTree(),
before,
"REA changed the source project",
);
assert.equal(
createHash("sha256")
.update(await readFile(fixture.path))
.digest("hex"),
fixture.sha256,
);
return {
byte_order: byteOrder,
project_unchanged: true,
target_unchanged: true,
};
}
/**
* Typed float signatures must use soft-float o32 storage, and callers must see
* the source constants through it rather than FPU inputs.
@@ -320,6 +431,10 @@ try {
);
}
assert.equal(resolved.value.sha256, fixture.sha256);
const existingProject =
!psp && floatAbi === "hard"
? await verifyExistingMipsProject(fixture, byteOrder)
: undefined;
const snapshotPath = join(
workspace,
`${byteOrder}-${floatAbi}.snapshot.json`,
@@ -682,6 +797,9 @@ try {
}
reports.push({
...fixture,
...(existingProject === undefined
? {}
: { existing_project: existingProject }),
independent_reader: independent,
effective_language: image.observations.language_id,
effective_compiler_spec: image.observations.compiler_spec_id,
+5 -2
View File
@@ -475,11 +475,13 @@ export const existingGhidraAnalysisProfile = (
target: BinaryTarget,
provider: Parameters<typeof createAnalysisProfile>[0],
project: JsonValue,
) =>
createAnalysisProfile(provider, {
) => {
const mipsElf = ghidraMipsProfileParameters(target).mips_elf;
return createAnalysisProfile(provider, {
target_kind: target.kind,
target_format: target.format,
architecture: target.architecture ?? null,
...(mipsElf === undefined ? {} : { mips_elf: mipsElf }),
import_mode: "existing-project-private-copy-v1",
...sharedAnalysisSemantics,
annotation_policy: "read-only-existing-project-v1",
@@ -488,3 +490,4 @@ export const existingGhidraAnalysisProfile = (
no_return_repair: "disabled-existing-project",
process_launch: "inspected-jvm-launch-support-v1",
});
};
+27 -1
View File
@@ -13,11 +13,15 @@ import { HopperProvider } from "../hopper/HopperProvider.js";
import { hopperLoaderArgsForTarget } from "../hopper/HopperAnalysisProfile.js";
import { IdaProvider } from "../ida/IdaProvider.js";
import { GhidraProvider } from "./GhidraProvider.js";
import { ghidraMipsProfileParameters } from "./GhidraMipsProfile.js";
import {
ghidraMipsProfileMismatchReason,
ghidraMipsProfileParameters,
} from "./GhidraMipsProfile.js";
import { ghidraProfileInstallationHost as host } from "./GhidraInstallation.fixture.js";
import { GHIDRA_SESSION_CAPABILITIES } from "./GhidraSessionValues.js";
import type { GhidraMipsSoftFloatReport } from "./GhidraMipsProfile.js";
import {
existingGhidraAnalysisProfile,
ghidraPinnedLanguage,
ghidraProfileLanguageOverride,
ghidraProfilePinnedLanguage,
@@ -96,6 +100,28 @@ const abi = (
const softFloat = abi({ cpr1Size: 0, fpAbi: 3 });
describe("bounded Ghidra MIPS admission", () => {
it("binds a saved project's MIPS metadata without claiming a fresh-import lane", () => {
const little = target();
const big = target({ byteOrder: "big" });
const profile = existingGhidraAnalysisProfile(
little,
{ id: "ghidra", name: "Ghidra", version: "12.1.4" },
{ project_name: "fixture" },
);
expect(profile.parameters).not.toHaveProperty("mips_support_lane");
expect(
ghidraMipsProfileMismatchReason(little, profile.parameters, true),
).toBeNull();
expect(
ghidraMipsProfileMismatchReason(big, profile.parameters, true),
).toMatch(/metadata does not match/u);
expect(
ghidraMipsProfileMismatchReason(little, profile.parameters, false),
).toMatch(/different target/u);
expect(ghidraMipsProfileMismatchReason(little, {}, true)).toMatch(
/metadata does not match/u,
);
});
it("binds endian and flags without widening Windows P0", async () => {
const ghidra = new GhidraProvider(config, silentLogger, {}, host);
const little = target();
+2
View File
@@ -435,6 +435,7 @@ export const ghidraMipsProfileParameters = (
export const ghidraMipsProfileMismatchReason = (
target: BinaryTarget,
parameters: Readonly<Record<string, unknown>>,
existingProject: boolean,
): string | null => {
if (target.architecture !== "mips" || target.mips === undefined) return null;
const expected = ghidraMipsProfileParameters(target);
@@ -447,6 +448,7 @@ export const ghidraMipsProfileMismatchReason = (
const expectedBasis =
committedLane === pspLane ? undefined : expected.mips_abi_basis;
if (
!existingProject &&
expectedLane !== undefined &&
(committedLane !== expectedLane ||
parameters.mips_abi_basis !== expectedBasis)
+3
View File
@@ -439,6 +439,9 @@ export const createGhidraProviderClient = (input: {
const mipsProfileMismatch = ghidraMipsProfileMismatchReason(
target,
committedProfile.parameters,
project !== undefined &&
committedProfile.parameters.import_mode ===
"existing-project-private-copy-v1",
);
if (mipsProfileMismatch !== null)
return err(