diff --git a/docs/installation.md b/docs/installation.md index a5ecc7e132..3164457330 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -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. diff --git a/scripts/verify-real-ghidra-mips.mjs b/scripts/verify-real-ghidra-mips.mjs index b2e02568c0..14e794ba3e 100644 --- a/scripts/verify-real-ghidra-mips.mjs +++ b/scripts/verify-real-ghidra-mips.mjs @@ -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, diff --git a/src/ghidra/GhidraAnalysisProfile.ts b/src/ghidra/GhidraAnalysisProfile.ts index d35ca1b428..6a9b5223f4 100644 --- a/src/ghidra/GhidraAnalysisProfile.ts +++ b/src/ghidra/GhidraAnalysisProfile.ts @@ -475,11 +475,13 @@ export const existingGhidraAnalysisProfile = ( target: BinaryTarget, provider: Parameters[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", }); +}; diff --git a/src/ghidra/GhidraMipsProfile.test.ts b/src/ghidra/GhidraMipsProfile.test.ts index 27045790ce..44e2105b5e 100644 --- a/src/ghidra/GhidraMipsProfile.test.ts +++ b/src/ghidra/GhidraMipsProfile.test.ts @@ -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(); diff --git a/src/ghidra/GhidraMipsProfile.ts b/src/ghidra/GhidraMipsProfile.ts index d9838ac65f..4dc2a60d66 100644 --- a/src/ghidra/GhidraMipsProfile.ts +++ b/src/ghidra/GhidraMipsProfile.ts @@ -435,6 +435,7 @@ export const ghidraMipsProfileParameters = ( export const ghidraMipsProfileMismatchReason = ( target: BinaryTarget, parameters: Readonly>, + 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) diff --git a/src/ghidra/GhidraProviderClient.ts b/src/ghidra/GhidraProviderClient.ts index 7eb4052e7b..9464f560da 100644 --- a/src/ghidra/GhidraProviderClient.ts +++ b/src/ghidra/GhidraProviderClient.ts @@ -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(