From 5df557113c240f1dd83a3352ad5276f12b73eb1a Mon Sep 17 00:00:00 2001 From: Bobtoshi <201082672+Bobtoshi@users.noreply.github.com> Date: Tue, 13 Oct 2026 18:03:29 +0100 Subject: [PATCH 1/7] feat(ghidra): admit verified recordless MIPS I o32 executables --- docs/ghidra-mips.md | 19 +++ knip.json | 1 + scripts/verify-real-ghidra-mips1.mjs | 171 +++++++++++++++++++++ src/ghidra/GhidraMipsProfile.test.ts | 44 ++++++ src/ghidra/GhidraMipsProfile.ts | 51 ++++-- src/ghidra/GhidraProviderClient.ts | 6 + tests/conformance/ghidra/mips1-fixture.mjs | 92 +++++++++++ 7 files changed, 375 insertions(+), 9 deletions(-) create mode 100644 scripts/verify-real-ghidra-mips1.mjs create mode 100644 tests/conformance/ghidra/mips1-fixture.mjs diff --git a/docs/ghidra-mips.md b/docs/ghidra-mips.md index f010b4f4ca7..884dd0f9a4c 100644 --- a/docs/ghidra-mips.md +++ b/docs/ghidra-mips.md @@ -44,6 +44,25 @@ establish the recordless admission, and an `EF_MIPS_ARCH_32` header that _declares_ an ABI is refused rather than relabeled, because the declared-ABI lane is verified for `EF_MIPS_ARCH_32R2` headers only. +A third admission covers recordless MIPS I ELF32 `ET_EXEC` images whose header +explicitly declares o32, with complete ABI-record inspection and no ASE, +machine-specific, n32, 32-bit-on-64-bit, FP64 or NaN2008 declarations. Both byte +orders use the pinned `MIPS::32:default` language, checked after import. +The header's o32 declaration is retained as a fact; floating-point storage remains +unverified and is reported in Evidence limitations. ABI-record-bearing MIPS I, +MIPS II/III, relocatable/shared MIPS I and unspecified-ABI MIPS I remain outside +this increment. This does not widen the existing Windows P0 boundary. + +Run `node scripts/verify-real-ghidra-mips1.mjs` after building, with the existing +Ghidra/Java installation and `REA_READELF` when GNU readelf is not on PATH. +The source-owned integer fixture is encoded directly from its declared MIPS I +instructions; no cross compiler is required. GNU readelf independently checks +its header and symbol. The verifier checks both byte orders through production +MCP, exact loaded words, instruction/delay-slot decoding, dossier output, retained +FP uncertainty, source immutability and snapshot replay; it also checks the LE +fixture through the production CLI. Real macOS arm64/Ghidra 12.1.4 was exercised; +Linux, Windows and a real firmware corpus were not. + Explicit `EF_MIPS_FP64` and `EF_MIPS_NAN2008` declarations are refused. Their absence is insufficient: Clang can emit identical `e_flags` for FP32, FP64 and soft-float targets. Target resolution also reads the 24-byte ABI flags record diff --git a/knip.json b/knip.json index 72d09339781..f201ded8024 100644 --- a/knip.json +++ b/knip.json @@ -10,6 +10,7 @@ "scripts/verify/fridaBindingsCompatibility.ts", "scripts/verify/termux/browser.mjs", "scripts/verify-real-ghidra-noreturn.mjs", + "scripts/verify-real-ghidra-mips1.mjs", "tests/processOwnershipRunner.ts" ], "project": ["src/**/*.ts", "tests/**/*.ts", "scripts/**/*.mjs"], diff --git a/scripts/verify-real-ghidra-mips1.mjs b/scripts/verify-real-ghidra-mips1.mjs new file mode 100644 index 00000000000..fc115d801c3 --- /dev/null +++ b/scripts/verify-real-ghidra-mips1.mjs @@ -0,0 +1,171 @@ +#!/usr/bin/env node +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { createHash } from "node:crypto"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; +import { Client } from "@modelcontextprotocol/client"; +import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; +import { buildMips1Fixture } from "../tests/conformance/ghidra/mips1-fixture.mjs"; +import { requireMcpOperationResult } from "./lib/mcp-verifier-results.mjs"; +import { functionDossierSchema } from "../dist/domain/hopperValues.js"; +import { parseEvidence } from "../dist/domain/evidence.js"; +import { parseAnalysisSnapshot } from "../dist/domain/analysisSnapshot.js"; + +// Static inspection only. No MIPS compiler, acquisition or target execution. +if (!process.env.GHIDRA_INSTALL_DIR || !process.env.JAVA_HOME) + throw new Error("Supply GHIDRA_INSTALL_DIR and JAVA_HOME."); +const execute = promisify(execFile); +const root = await mkdtemp(join(tmpdir(), "rea-ghidra-mips1-")); +const reports = []; +try { + for (const byteOrder of ["little", "big"]) { + const fixture = await buildMips1Fixture(root, byteOrder); + const { stdout: headers } = await execute( + process.env.REA_READELF ?? "readelf", + ["-h", "-s", fixture.path], + ); + assert.match(headers, /ELF32/u); + assert.match(headers, /0x1001.*o32.*mips1/u); + assert.match(headers, /00401000.*rea_mips1_entry/u); + const transport = new StdioClientTransport({ + command: process.execPath, + args: [fileURLToPath(new URL("./rea.mjs", import.meta.url)), "mcp"], + env: { + ...process.env, + REA_ANALYSIS_PROVIDER: "ghidra", + REA_LOG_LEVEL: "silent", + TMPDIR: root, + }, + stderr: "pipe", + }); + const client = new Client({ name: "rea-mips1-verifier", version: "1" }); + const call = async (name, args = {}) => + requireMcpOperationResult( + await client.callTool({ name, arguments: args }, { timeout: 240_000 }), + name, + ); + try { + await client.connect(transport); + const selected = await call("open_binary", { + path: fixture.path, + provider_id: "ghidra", + }); + assert.equal(selected.sha256, fixture.sha256); + const image = await call("inspect_native_load_image"); + assert.equal( + image.observations.language_id, + `MIPS:${byteOrder === "little" ? "LE" : "BE"}:32:default`, + ); + const first = await call("inspect_native_instruction", { + address: fixture.entry, + }); + const delay = await call("inspect_native_instruction", { + address: "0x401008", + }); + assert.equal(first.status, "decoded"); + assert.match(first.mnemonic, /^addiu$/iu); + assert.match(delay.mnemonic, /^_?sll$/iu); + const loaded = await call("read_bytes", { + address: fixture.entry, + length: 24, + }); + assert.equal(loaded.returned_bytes, 24); + const bytes = Buffer.from(loaded.bytes_hex, "hex"); + const words = Array.from({ length: 6 }, (_, index) => + byteOrder === "little" + ? bytes.readUInt32LE(index * 4) + : bytes.readUInt32BE(index * 4), + ); + assert.deepEqual(words, fixture.words); + const dossier = functionDossierSchema.parse( + await call("analyze_function", { procedure: fixture.entry }), + ); + assert.ok(dossier.pseudocode.trim().length > 0); + assert.ok(dossier.assembly.some((line) => /sll/iu.test(line))); + + const snapshotPath = join(root, `snapshot-${byteOrder}.json`); + await call("close_binary", { snapshot_path: snapshotPath }); + const snapshot = parseAnalysisSnapshot( + JSON.parse(await readFile(snapshotPath, "utf8")), + ); + assert.ok( + snapshot.evidence_bundle.records.some((record) => + parseEvidence(record).limitations.some((text) => + /Floating-point ABI behavior remains unverified/u.test(text), + ), + ), + ); + assert.equal( + snapshot.binding.analysis_profile.parameters.mips_support_lane, + "elf32-exec-mips1-recordless-pinned-v1", + ); + await call("open_binary", { + path: fixture.path, + provider_id: "ghidra", + snapshot_path: snapshotPath, + }); + const replay = await call("analyze_function", { + procedure: fixture.entry, + }); + assert.deepEqual(replay, dossier); + await call("close_binary"); + if (byteOrder === "little") { + const { stdout } = await execute( + process.execPath, + [ + fileURLToPath(new URL("./rea.mjs", import.meta.url)), + "function", + fixture.path, + fixture.entry, + "--provider", + "ghidra", + "--full-output", + "--json", + ], + { env: process.env, timeout: 240_000, maxBuffer: 32 * 1024 ** 2 }, + ); + const envelope = JSON.parse(stdout); + assert.equal(envelope.ok, true); + const cliEvidence = parseEvidence(envelope.data); + assert.equal(cliEvidence.subject?.digest.sha256, fixture.sha256); + const cliDossier = functionDossierSchema.parse( + cliEvidence.normalized_result, + ); + assert.equal(cliDossier.pseudocode, dossier.pseudocode); + } + assert.equal( + createHash("sha256") + .update(await readFile(fixture.path)) + .digest("hex"), + fixture.sha256, + ); + reports.push({ + byte_order: byteOrder, + sha256: fixture.sha256, + language: image.observations.language_id, + source_unchanged: true, + loaded_bytes: true, + cli: byteOrder === "little" ? "verified" : "not-run", + instruction_decode: true, + delay_slot: true, + dossier: true, + snapshot_replay: true, + fp_semantics: "unverified", + target_executed: false, + }); + } finally { + try { + await client.close(); + } finally { + await transport.close(); + } + } + } + console.log(JSON.stringify({ status: "passed", reports }, null, 2)); +} finally { + await rm(root, { recursive: true, force: true }); +} diff --git a/src/ghidra/GhidraMipsProfile.test.ts b/src/ghidra/GhidraMipsProfile.test.ts index 800de9490db..d16c28bb1f7 100644 --- a/src/ghidra/GhidraMipsProfile.test.ts +++ b/src/ghidra/GhidraMipsProfile.test.ts @@ -80,6 +80,50 @@ const abi = ( }; const softFloat = abi({ cpr1Size: 0, fpAbi: 3 }); +it("pins inspected recordless MIPS I/o32 executables without inventing a floating-point ABI", async () => { + const provider = new GhidraProvider(config, silentLogger, {}, host); + for (const byteOrder of ["little", "big"] as const) { + const value = target({ flags: 0x1001, byteOrder, abiFlags: null }); + expect(provider.inspectTargetSupport(value).status).toBe("supported"); + const resolved = await provider.resolveAnalysisProfile(value); + if (!resolved.ok) throw resolved.error; + expect(resolved.value.profile?.parameters).toMatchObject({ + mips_support_lane: "elf32-exec-mips1-recordless-pinned-v1", + mips_abi_basis: "declared-o32-header-fp-unverified-v1", + language_id: `MIPS:${byteOrder === "little" ? "LE" : "BE"}:32:default`, + mips_elf: { abi_flags: null }, + }); + } + const unexamined = target({ flags: 0x1001, abiFlags: null }); + if (unexamined.kind !== "executable" || unexamined.architecture !== "mips") + throw new Error("Expected MIPS fixture"); + const { abiFlags, ...unexaminedHeader } = unexamined.mips; + expect(abiFlags).toBeNull(); + expect( + provider.inspectTargetSupport({ ...unexamined, mips: unexaminedHeader }) + .status, + ).toBe("unsupported"); + for (const change of [ + { flags: 1 }, + { flags: 0x1021 }, + { flags: 0x1041 }, + { flags: 0x1101 }, + { flags: 0x1201 }, + { flags: 0x1401 }, + { flags: 0x04001001 }, + { elfClass: 64 as const }, + { type: 1 }, + { type: 3 }, + { limitations: ["ABI declarations were not fully inspected"] }, + ]) { + expect( + provider.inspectTargetSupport( + target({ flags: 0x1001, abiFlags: null, ...change }), + ).status, + ).toBe("unsupported"); + } +}); + describe("bounded Ghidra MIPS admission", () => { it("binds endian and flags without widening Windows P0", async () => { const ghidra = new GhidraProvider(config, silentLogger, {}, host); diff --git a/src/ghidra/GhidraMipsProfile.ts b/src/ghidra/GhidraMipsProfile.ts index 76a753ccb66..cb050588868 100644 --- a/src/ghidra/GhidraMipsProfile.ts +++ b/src/ghidra/GhidraMipsProfile.ts @@ -8,6 +8,28 @@ const MIPS_ET_EXEC = 2; const MIPS_ET_DYN = 3; const MIPS16_HEADER_ASE = 0x04000000; +/** Inspected recordless MIPS I executables with an explicitly declared o32 ABI. */ +const hasMips1HeaderShape = (target: BinaryTarget): boolean => { + if ( + target.kind !== "executable" || + target.format !== "elf" || + target.architecture !== "mips" + ) + return false; + const { flags, abiFlags, limitations } = target.mips; + return ( + target.mips.elfClass === 32 && + target.mips.type === MIPS_ET_EXEC && + (flags & 0xf0000000) === 0 && + (flags & 0x0000f000) === 0x00001000 && + (flags & 0x0fff0000) === 0 && + // ABI2, ABI_ON32, 32BITMODE, FP64 and NAN2008 require separate semantics. + (flags & 0x760) === 0 && + abiFlags === null && + (limitations?.length ?? 0) === 0 + ); +}; + /** A distinct compressed shared-image shape; admission still checks every declaration. */ const isGhidraMips16SharedTarget = (target: BinaryTarget): boolean => target.kind === "executable" && @@ -19,7 +41,9 @@ const isGhidraMips16SharedTarget = (target: BinaryTarget): boolean => export const ghidraMipsLanguageOverride = ( target: BinaryTarget, ): GhidraLanguageOverride | undefined => - (isGhidraMips16SharedTarget(target) || hasLegacyMipsHeaderShape(target)) && + (isGhidraMips16SharedTarget(target) || + hasLegacyMipsHeaderShape(target) || + hasMips1HeaderShape(target)) && ghidraMipsUnsupportedReason(target) === null && target.architecture === "mips" ? { @@ -88,6 +112,7 @@ export const ghidraMipsUnsupportedReason = ( if ((metadata.flags & 0x00ff0000) !== 0) return "Machine-specific MIPS variants, including PSP Allegrex, need a separately supported processor profile."; const arch = metadata.flags & 0xf0000000; + if (hasMips1HeaderShape(target)) return null; if (arch !== 0x70000000 && arch !== 0x50000000) return "This Ghidra MIPS lane requires EF_MIPS_ARCH_32R2 or a recordless legacy EF_MIPS_ARCH_32 header; other ISA revisions need separate verification."; const recordlessShape = @@ -258,9 +283,12 @@ export const ghidraMipsProfileParameters = ( // A deliberate caller-selected language wins over the legacy pin; the pin // exists because header auto-selection misselects a 64-bit language. const legacy = isLegacyMipsAdmission(target); + const mips1 = hasMips1HeaderShape(target); const language = ghidraMipsLanguageOverride(target); const pinnedLanguage = - legacy && languageOverride === undefined && language !== undefined + (legacy || mips1) && + languageOverride === undefined && + language !== undefined ? { language_id: language.languageId, compiler_spec_id: language.compilerSpecId ?? "default", @@ -316,14 +344,19 @@ export const ghidraMipsProfileParameters = ( ? { mips_soft_float_storage: GHIDRA_MIPS_SOFT_FLOAT_STORAGE } : {}), instruction_context_evidence: "per-address-isa-mode-v1", - mips_support_lane: legacy - ? "elf32-exec-arch32-legacy-pinned-v1" - : isGhidraMips16SharedTarget(target) - ? "elf32-dyn-o32-arch32r2-mips16-local-rel32-v1" - : target.mips.type === MIPS_ET_REL - ? "elf32-rel-o32-arch32r2-standard-v1" - : "elf32-exec-o32-arch32r2-standard-v3", + mips_support_lane: mips1 + ? "elf32-exec-mips1-recordless-pinned-v1" + : legacy + ? "elf32-exec-arch32-legacy-pinned-v1" + : isGhidraMips16SharedTarget(target) + ? "elf32-dyn-o32-arch32r2-mips16-local-rel32-v1" + : target.mips.type === MIPS_ET_REL + ? "elf32-rel-o32-arch32r2-standard-v1" + : "elf32-exec-o32-arch32r2-standard-v3", ...(legacy ? { mips_abi_basis: "inferred-o32-assumption-v1" } : {}), + ...(mips1 + ? { mips_abi_basis: "declared-o32-header-fp-unverified-v1" } + : {}), ...pinnedLanguage, }; }; diff --git a/src/ghidra/GhidraProviderClient.ts b/src/ghidra/GhidraProviderClient.ts index 25838453ea1..479558004d3 100644 --- a/src/ghidra/GhidraProviderClient.ts +++ b/src/ghidra/GhidraProviderClient.ts @@ -563,6 +563,12 @@ export const createGhidraProviderClient = (input: { ] : []), ...ghidraMipsRelocatableLimitations(target), + ...(committedProfile.parameters.mips_abi_basis === + "declared-o32-header-fp-unverified-v1" + ? [ + "The recordless MIPS I ELF header explicitly declares o32; no ABI flags record establishes floating-point storage semantics. Floating-point ABI behavior remains unverified.", + ] + : []), ...ghidraMipsSharedLimitations(target), ...ghidraRiscvLimitations(target), ]; diff --git a/tests/conformance/ghidra/mips1-fixture.mjs b/tests/conformance/ghidra/mips1-fixture.mjs new file mode 100644 index 00000000000..ee46420c0e7 --- /dev/null +++ b/tests/conformance/ghidra/mips1-fixture.mjs @@ -0,0 +1,92 @@ +import { createHash } from "node:crypto"; +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +/** Source-owned recordless MIPS I/o32 ELF. Encode only the declared integer probe. */ +export async function buildMips1Fixture(directory, byteOrder) { + if (!["little", "big"].includes(byteOrder)) + throw new Error("Unknown fixture byte order"); + const image = Buffer.alloc(0x1188); + const u16 = (offset, value) => + byteOrder === "little" + ? image.writeUInt16LE(value, offset) + : image.writeUInt16BE(value, offset); + const u32 = (offset, value) => + byteOrder === "little" + ? image.writeUInt32LE(value, offset) + : image.writeUInt32BE(value, offset); + image.set([0x7f, 0x45, 0x4c, 0x46, 1, byteOrder === "little" ? 1 : 2, 1]); + u16(16, 2); + u16(18, 8); + u32(20, 1); + u32(24, 0x401000); + u32(28, 52); + u32(32, 0x10c0); + u32(36, 0x1001); + u16(40, 52); + u16(42, 32); + u16(44, 1); + u16(46, 40); + u16(48, 5); + u16(50, 4); + u32(52, 1); + u32(56, 0); + u32(60, 0x400000); + u32(64, 0x400000); + u32(68, image.length); + u32(72, image.length); + u32(76, 5); + u32(80, 0x1000); + // addiu v0,a0,7; beq a0,zero,done; sll v0,v0,1 (delay slot); + // addiu v0,v0,3; done: jr ra; nop. + const words = [0x24820007, 0x10800002, 0x00021040, 0x24420003, 0x03e00008, 0]; + words.forEach((word, index) => u32(0x1000 + index * 4, word)); + image.write("\0rea_mips1_entry\0", 0x1040, "ascii"); + u32(0x1070, 1); + u32(0x1074, 0x401000); + u32(0x1078, 24); + image[0x107c] = 0x12; + u16(0x107e, 1); + const names = "\0.text\0.symtab\0.strtab\0.shstrtab\0"; + image.write(names, 0x1080, "ascii"); + const section = ( + index, + name, + type, + flags, + address, + offset, + size, + link, + info, + align, + entrySize, + ) => { + const base = 0x10c0 + index * 40; + [ + names.indexOf(name), + type, + flags, + address, + offset, + size, + link, + info, + align, + entrySize, + ].forEach((value, n) => u32(base + n * 4, value)); + }; + section(1, ".text", 1, 6, 0x401000, 0x1000, 24, 0, 0, 4, 0); + section(2, ".symtab", 2, 0, 0, 0x1060, 32, 3, 1, 4, 16); + section(3, ".strtab", 3, 0, 0, 0x1040, 17, 0, 0, 1, 0); + section(4, ".shstrtab", 3, 0, 0, 0x1080, names.length, 0, 0, 1, 0); + const path = join(directory, `mips1-${byteOrder}.elf`); + await writeFile(path, image); + return { + path, + sha256: createHash("sha256").update(image).digest("hex"), + byte_order: byteOrder, + entry: "0x401000", + words, + }; +} From d8630eaf13f4d8a8112e92f1882bb5a0dc74a409 Mon Sep 17 00:00:00 2001 From: Bobtoshi <201082672+Bobtoshi@users.noreply.github.com> Date: Tue, 13 Oct 2026 18:34:17 +0100 Subject: [PATCH 2/7] test(ghidra): verify MIPS branch decoding and retain diagnostics --- scripts/verify-real-ghidra-mips1.mjs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/scripts/verify-real-ghidra-mips1.mjs b/scripts/verify-real-ghidra-mips1.mjs index fc115d801c3..4375985a8c2 100644 --- a/scripts/verify-real-ghidra-mips1.mjs +++ b/scripts/verify-real-ghidra-mips1.mjs @@ -40,7 +40,7 @@ try { REA_LOG_LEVEL: "silent", TMPDIR: root, }, - stderr: "pipe", + stderr: "inherit", }); const client = new Client({ name: "rea-mips1-verifier", version: "1" }); const call = async (name, args = {}) => @@ -66,9 +66,17 @@ try { const delay = await call("inspect_native_instruction", { address: "0x401008", }); + const branch = await call("inspect_native_instruction", { + address: "0x401004", + }); assert.equal(first.status, "decoded"); assert.match(first.mnemonic, /^addiu$/iu); assert.match(delay.mnemonic, /^_?sll$/iu); + assert.equal(branch.status, "decoded"); + assert.match(branch.mnemonic, /^beq$/iu); + assert.equal(branch.flow.kind, "jump"); + assert.equal(branch.flow.conditional, true); + assert.deepEqual(branch.flow.direct_destinations, ["0x401010"]); const loaded = await call("read_bytes", { address: fixture.entry, length: 24, @@ -151,7 +159,9 @@ try { loaded_bytes: true, cli: byteOrder === "little" ? "verified" : "not-run", instruction_decode: true, - delay_slot: true, + conditional_branch_destination: true, + expected_delay_slot_instruction_decoded: true, + delay_slot_relationship: "not-exposed-by-instruction-contract", dossier: true, snapshot_replay: true, fp_semantics: "unverified", From 5ec1a57a297fa81e14a405a49ebc374e0d4627d1 Mon Sep 17 00:00:00 2001 From: Bobtoshi <201082672+Bobtoshi@users.noreply.github.com> Date: Tue, 13 Oct 2026 20:09:52 +0100 Subject: [PATCH 3/7] fix(mips)!: reject uninspected GNU ABI attributes Preserve the MIPS I refusal until load-delay semantics are verified. Decode typed GNU attribute presence before making recordless ABI assumptions, and exercise both refusal paths plus existing MIPS32 controls through CLI/MCP. BREAKING CHANGE: ELF32 MIPS inputs with SHT_GNU_ATTRIBUTES are refused until those ABI declarations are parsed and reconciled. Preserve the original input and use a tool that understands its declared ABI; stripping metadata does not establish compatible semantics. --- docs/ghidra-mips.md | 36 ++-- knip.json | 2 +- .../verify-real-ghidra-mips-boundaries.mjs | 160 ++++++++++++++++ scripts/verify-real-ghidra-mips1.mjs | 181 ------------------ src/application/MipsElfMetadata.ts | 4 + src/ghidra/GhidraMipsProfile.test.ts | 53 ++--- src/ghidra/GhidraMipsProfile.ts | 53 ++--- src/ghidra/GhidraProviderClient.ts | 6 - .../boundary/filesystem/mipsAbiFlags.test.ts | 34 ++++ tests/conformance/ghidra/mips1-fixture.mjs | 2 +- 10 files changed, 239 insertions(+), 292 deletions(-) create mode 100644 scripts/verify-real-ghidra-mips-boundaries.mjs delete mode 100644 scripts/verify-real-ghidra-mips1.mjs diff --git a/docs/ghidra-mips.md b/docs/ghidra-mips.md index 884dd0f9a4c..75e6cea4790 100644 --- a/docs/ghidra-mips.md +++ b/docs/ghidra-mips.md @@ -8,6 +8,23 @@ PSP/Allegrex has a [separate static-ELF profile](ghidra-psp.md) tracked in ## Admission boundary +MIPS I remains unsupported. The generic Ghidra MIPS processor's immediate load +assignment does not establish MIPS I load-delay behavior; correctly decoding a +small integer fixture does not verify its decompiler/dataflow semantics. + +The ELF metadata reader refuses typed `SHT_GNU_ATTRIBUTES` sections before +provider selection. GNU MIPS ABI attributes can declare floating-point storage +that differs from MIPS ABI-flags records. Until this declaration source is parsed +and reconciled, its presence must not be treated as recordless absence. Section +names and attacker-controlled section sizes do not bypass that refusal. + +After building, `node scripts/verify-real-ghidra-mips-boundaries.mjs` checks both +byte orders through production CLI/MCP. GNU readelf independently decodes the +soft-float attribute fixture; MIPS I and uninspected GNU attributes are refused, +while a recordless MIPS32 control still produces a real Ghidra dossier and exact +loaded bytes. Supply existing Ghidra/Java and GNU readelf (`REA_READELF`). Source +hashes are checked after inspection, and no target is executed. + ELF `EM_MIPS` is retained as the provider-neutral `mips` family. The target also retains class, byte order, `e_type`, raw `e_flags`, and inspected ABI declarations. Recognition does not mean every provider or MIPS variant is supported. Existing @@ -44,25 +61,6 @@ establish the recordless admission, and an `EF_MIPS_ARCH_32` header that _declares_ an ABI is refused rather than relabeled, because the declared-ABI lane is verified for `EF_MIPS_ARCH_32R2` headers only. -A third admission covers recordless MIPS I ELF32 `ET_EXEC` images whose header -explicitly declares o32, with complete ABI-record inspection and no ASE, -machine-specific, n32, 32-bit-on-64-bit, FP64 or NaN2008 declarations. Both byte -orders use the pinned `MIPS::32:default` language, checked after import. -The header's o32 declaration is retained as a fact; floating-point storage remains -unverified and is reported in Evidence limitations. ABI-record-bearing MIPS I, -MIPS II/III, relocatable/shared MIPS I and unspecified-ABI MIPS I remain outside -this increment. This does not widen the existing Windows P0 boundary. - -Run `node scripts/verify-real-ghidra-mips1.mjs` after building, with the existing -Ghidra/Java installation and `REA_READELF` when GNU readelf is not on PATH. -The source-owned integer fixture is encoded directly from its declared MIPS I -instructions; no cross compiler is required. GNU readelf independently checks -its header and symbol. The verifier checks both byte orders through production -MCP, exact loaded words, instruction/delay-slot decoding, dossier output, retained -FP uncertainty, source immutability and snapshot replay; it also checks the LE -fixture through the production CLI. Real macOS arm64/Ghidra 12.1.4 was exercised; -Linux, Windows and a real firmware corpus were not. - Explicit `EF_MIPS_FP64` and `EF_MIPS_NAN2008` declarations are refused. Their absence is insufficient: Clang can emit identical `e_flags` for FP32, FP64 and soft-float targets. Target resolution also reads the 24-byte ABI flags record diff --git a/knip.json b/knip.json index f201ded8024..f14dca158b5 100644 --- a/knip.json +++ b/knip.json @@ -10,7 +10,7 @@ "scripts/verify/fridaBindingsCompatibility.ts", "scripts/verify/termux/browser.mjs", "scripts/verify-real-ghidra-noreturn.mjs", - "scripts/verify-real-ghidra-mips1.mjs", + "scripts/verify-real-ghidra-mips-boundaries.mjs", "tests/processOwnershipRunner.ts" ], "project": ["src/**/*.ts", "tests/**/*.ts", "scripts/**/*.mjs"], diff --git a/scripts/verify-real-ghidra-mips-boundaries.mjs b/scripts/verify-real-ghidra-mips-boundaries.mjs new file mode 100644 index 00000000000..4018ddfc988 --- /dev/null +++ b/scripts/verify-real-ghidra-mips-boundaries.mjs @@ -0,0 +1,160 @@ +#!/usr/bin/env node +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { createHash } from "node:crypto"; +import { mkdtemp, readFile, writeFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; +import { Client } from "@modelcontextprotocol/client"; +import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; +import { buildMips1Fixture } from "../tests/conformance/ghidra/mips1-fixture.mjs"; +import { + requireMcpOperationResult, + requireMcpToolError, +} from "./lib/mcp-verifier-results.mjs"; +import { functionDossierSchema } from "../dist/domain/hopperValues.js"; + +// Static source-owned controls. Never execute an ELF or acquire a toolchain. +if (!process.env.GHIDRA_INSTALL_DIR || !process.env.JAVA_HOME) + throw new Error( + "Supply GHIDRA_INSTALL_DIR and JAVA_HOME for the MIPS32 positive control.", + ); +const execute = promisify(execFile); +const root = await mkdtemp(join(tmpdir(), "rea-mips-boundaries-")); +const digest = (bytes) => createHash("sha256").update(bytes).digest("hex"); +const cli = fileURLToPath(new URL("./rea.mjs", import.meta.url)); +const reports = []; +try { + for (const byteOrder of ["little", "big"]) { + const original = await buildMips1Fixture(root, byteOrder); + const initial = await readFile(original.path); + const legacy = Buffer.from(initial); + const u32 = (bytes, offset, value) => + byteOrder === "little" + ? bytes.writeUInt32LE(value, offset) + : bytes.writeUInt32BE(value, offset); + const u16 = (bytes, offset, value) => + byteOrder === "little" + ? bytes.writeUInt16LE(value, offset) + : bytes.writeUInt16BE(value, offset); + u32(legacy, 36, 0x50000001); // Existing recordless MIPS32 lane. + const legacyPath = join(root, `legacy-${byteOrder}.elf`); + await writeFile(legacyPath, legacy); + const gnu = Buffer.alloc(0x1200); + legacy.copy(gnu); + u32(gnu, 68, gnu.length); + u32(gnu, 72, gnu.length); + u16(gnu, 48, 6); + u32(gnu, 0x1188 + 4, 0x6ffffff5); + u32(gnu, 0x1188 + 16, 0x11d0); + u32(gnu, 0x1188 + 20, 16); + gnu[0x11d0] = 0x41; + u32(gnu, 0x11d1, 15); + gnu.write("gnu\0", 0x11d5, "ascii"); + gnu[0x11d9] = 1; + u32(gnu, 0x11da, 7); + gnu[0x11de] = 4; // Tag_GNU_MIPS_ABI_FP + gnu[0x11df] = 3; // Soft float. + const gnuPath = join(root, `gnu-fp-${byteOrder}.elf`); + await writeFile(gnuPath, gnu); + const { stdout: attributes } = await execute( + process.env.REA_READELF ?? "readelf", + ["-A", gnuPath], + ); + assert.match(attributes, /Tag_GNU_MIPS_ABI_FP.*Soft float/iu); + const transport = new StdioClientTransport({ + command: process.execPath, + args: [cli, "mcp"], + env: { + ...process.env, + REA_ANALYSIS_PROVIDER: "ghidra", + REA_LOG_LEVEL: "silent", + TMPDIR: root, + }, + stderr: "inherit", + }); + const client = new Client({ name: "rea-mips-boundaries", version: "1" }); + const invoke = (name, args = {}) => + client.callTool({ name, arguments: args }, { timeout: 240_000 }); + const call = async (name, args = {}) => + requireMcpOperationResult(await invoke(name, args), name); + try { + await client.connect(transport); + for (const [path, pattern] of [ + [original.path, /MIPS I.*load-delay semantics/u], + [gnuPath, /SHT_GNU_ATTRIBUTES.*uninspected/u], + ]) { + const error = requireMcpToolError( + await invoke("open_binary", { path, provider_id: "ghidra" }), + ); + assert.match(JSON.stringify(error), pattern); + let stdout; + try { + await execute( + process.execPath, + [ + cli, + "function", + path, + original.entry, + "--provider", + "ghidra", + "--full-output", + "--json", + ], + { env: process.env, timeout: 240_000 }, + ); + assert.fail("CLI admitted an unsupported MIPS declaration"); + } catch (cause) { + assert.equal(cause.code, 1); + stdout = cause.stdout; + } + const reply = JSON.parse(stdout); + assert.equal(reply.ok, false); + assert.match(JSON.stringify(reply), pattern); + } + await call("open_binary", { path: legacyPath, provider_id: "ghidra" }); + const dossier = functionDossierSchema.parse( + await call("analyze_function", { procedure: original.entry }), + ); + assert.ok(dossier.pseudocode.length > 0); + const loaded = await call("read_bytes", { + address: original.entry, + length: 24, + }); + assert.equal( + loaded.bytes_hex, + legacy.subarray(0x1000, 0x1018).toString("hex"), + ); + await call("close_binary"); + assert.deepEqual(await readFile(original.path), initial); + assert.deepEqual(await readFile(gnuPath), gnu); + assert.deepEqual(await readFile(legacyPath), legacy); + reports.push({ + byte_order: byteOrder, + mips1: "refused by CLI and MCP", + gnu_fp_attributes: + "independently decoded soft-float declaration; refused by CLI and MCP", + existing_mips32_control: "real Ghidra dossier and exact loaded bytes", + hashes: { + mips1: digest(initial), + gnu: digest(gnu), + legacy: digest(legacy), + }, + source_unchanged: true, + target_executed: false, + }); + } finally { + try { + await client.close(); + } finally { + await transport.close(); + } + } + } + console.log(JSON.stringify({ status: "passed", reports }, null, 2)); +} finally { + await rm(root, { recursive: true, force: true }); +} diff --git a/scripts/verify-real-ghidra-mips1.mjs b/scripts/verify-real-ghidra-mips1.mjs deleted file mode 100644 index 4375985a8c2..00000000000 --- a/scripts/verify-real-ghidra-mips1.mjs +++ /dev/null @@ -1,181 +0,0 @@ -#!/usr/bin/env node -import assert from "node:assert/strict"; -import { execFile } from "node:child_process"; -import { createHash } from "node:crypto"; -import { mkdtemp, readFile, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { fileURLToPath } from "node:url"; -import { promisify } from "node:util"; -import { Client } from "@modelcontextprotocol/client"; -import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; -import { buildMips1Fixture } from "../tests/conformance/ghidra/mips1-fixture.mjs"; -import { requireMcpOperationResult } from "./lib/mcp-verifier-results.mjs"; -import { functionDossierSchema } from "../dist/domain/hopperValues.js"; -import { parseEvidence } from "../dist/domain/evidence.js"; -import { parseAnalysisSnapshot } from "../dist/domain/analysisSnapshot.js"; - -// Static inspection only. No MIPS compiler, acquisition or target execution. -if (!process.env.GHIDRA_INSTALL_DIR || !process.env.JAVA_HOME) - throw new Error("Supply GHIDRA_INSTALL_DIR and JAVA_HOME."); -const execute = promisify(execFile); -const root = await mkdtemp(join(tmpdir(), "rea-ghidra-mips1-")); -const reports = []; -try { - for (const byteOrder of ["little", "big"]) { - const fixture = await buildMips1Fixture(root, byteOrder); - const { stdout: headers } = await execute( - process.env.REA_READELF ?? "readelf", - ["-h", "-s", fixture.path], - ); - assert.match(headers, /ELF32/u); - assert.match(headers, /0x1001.*o32.*mips1/u); - assert.match(headers, /00401000.*rea_mips1_entry/u); - const transport = new StdioClientTransport({ - command: process.execPath, - args: [fileURLToPath(new URL("./rea.mjs", import.meta.url)), "mcp"], - env: { - ...process.env, - REA_ANALYSIS_PROVIDER: "ghidra", - REA_LOG_LEVEL: "silent", - TMPDIR: root, - }, - stderr: "inherit", - }); - const client = new Client({ name: "rea-mips1-verifier", version: "1" }); - const call = async (name, args = {}) => - requireMcpOperationResult( - await client.callTool({ name, arguments: args }, { timeout: 240_000 }), - name, - ); - try { - await client.connect(transport); - const selected = await call("open_binary", { - path: fixture.path, - provider_id: "ghidra", - }); - assert.equal(selected.sha256, fixture.sha256); - const image = await call("inspect_native_load_image"); - assert.equal( - image.observations.language_id, - `MIPS:${byteOrder === "little" ? "LE" : "BE"}:32:default`, - ); - const first = await call("inspect_native_instruction", { - address: fixture.entry, - }); - const delay = await call("inspect_native_instruction", { - address: "0x401008", - }); - const branch = await call("inspect_native_instruction", { - address: "0x401004", - }); - assert.equal(first.status, "decoded"); - assert.match(first.mnemonic, /^addiu$/iu); - assert.match(delay.mnemonic, /^_?sll$/iu); - assert.equal(branch.status, "decoded"); - assert.match(branch.mnemonic, /^beq$/iu); - assert.equal(branch.flow.kind, "jump"); - assert.equal(branch.flow.conditional, true); - assert.deepEqual(branch.flow.direct_destinations, ["0x401010"]); - const loaded = await call("read_bytes", { - address: fixture.entry, - length: 24, - }); - assert.equal(loaded.returned_bytes, 24); - const bytes = Buffer.from(loaded.bytes_hex, "hex"); - const words = Array.from({ length: 6 }, (_, index) => - byteOrder === "little" - ? bytes.readUInt32LE(index * 4) - : bytes.readUInt32BE(index * 4), - ); - assert.deepEqual(words, fixture.words); - const dossier = functionDossierSchema.parse( - await call("analyze_function", { procedure: fixture.entry }), - ); - assert.ok(dossier.pseudocode.trim().length > 0); - assert.ok(dossier.assembly.some((line) => /sll/iu.test(line))); - - const snapshotPath = join(root, `snapshot-${byteOrder}.json`); - await call("close_binary", { snapshot_path: snapshotPath }); - const snapshot = parseAnalysisSnapshot( - JSON.parse(await readFile(snapshotPath, "utf8")), - ); - assert.ok( - snapshot.evidence_bundle.records.some((record) => - parseEvidence(record).limitations.some((text) => - /Floating-point ABI behavior remains unverified/u.test(text), - ), - ), - ); - assert.equal( - snapshot.binding.analysis_profile.parameters.mips_support_lane, - "elf32-exec-mips1-recordless-pinned-v1", - ); - await call("open_binary", { - path: fixture.path, - provider_id: "ghidra", - snapshot_path: snapshotPath, - }); - const replay = await call("analyze_function", { - procedure: fixture.entry, - }); - assert.deepEqual(replay, dossier); - await call("close_binary"); - if (byteOrder === "little") { - const { stdout } = await execute( - process.execPath, - [ - fileURLToPath(new URL("./rea.mjs", import.meta.url)), - "function", - fixture.path, - fixture.entry, - "--provider", - "ghidra", - "--full-output", - "--json", - ], - { env: process.env, timeout: 240_000, maxBuffer: 32 * 1024 ** 2 }, - ); - const envelope = JSON.parse(stdout); - assert.equal(envelope.ok, true); - const cliEvidence = parseEvidence(envelope.data); - assert.equal(cliEvidence.subject?.digest.sha256, fixture.sha256); - const cliDossier = functionDossierSchema.parse( - cliEvidence.normalized_result, - ); - assert.equal(cliDossier.pseudocode, dossier.pseudocode); - } - assert.equal( - createHash("sha256") - .update(await readFile(fixture.path)) - .digest("hex"), - fixture.sha256, - ); - reports.push({ - byte_order: byteOrder, - sha256: fixture.sha256, - language: image.observations.language_id, - source_unchanged: true, - loaded_bytes: true, - cli: byteOrder === "little" ? "verified" : "not-run", - instruction_decode: true, - conditional_branch_destination: true, - expected_delay_slot_instruction_decoded: true, - delay_slot_relationship: "not-exposed-by-instruction-contract", - dossier: true, - snapshot_replay: true, - fp_semantics: "unverified", - target_executed: false, - }); - } finally { - try { - await client.close(); - } finally { - await transport.close(); - } - } - } - console.log(JSON.stringify({ status: "passed", reports }, null, 2)); -} finally { - await rm(root, { recursive: true, force: true }); -} diff --git a/src/application/MipsElfMetadata.ts b/src/application/MipsElfMetadata.ts index 8fd9d0e8946..4cb09c30ef5 100644 --- a/src/application/MipsElfMetadata.ts +++ b/src/application/MipsElfMetadata.ts @@ -151,6 +151,10 @@ export const readMipsElfMetadata = async ( checkCancelled(); const position = i * table.entrySize; const type = u32(contents.value, position + table.typeOffset); + if (table.optional && type === 0x6ffffff5) + return err( + `MIPS SHT_GNU_ATTRIBUTES at section index ${i} is uninspected; its GNU ABI declarations cannot be treated as absent. GNU MIPS attribute parsing requires separate verification.`, + ); if (metadata.type === 3 && table.optional && (type === 4 || type === 9)) relocationSections.push({ type, diff --git a/src/ghidra/GhidraMipsProfile.test.ts b/src/ghidra/GhidraMipsProfile.test.ts index d16c28bb1f7..894978ddbb7 100644 --- a/src/ghidra/GhidraMipsProfile.test.ts +++ b/src/ghidra/GhidraMipsProfile.test.ts @@ -12,6 +12,7 @@ import { IdaProvider } from "../ida/IdaProvider.js"; import { GhidraProvider } from "./GhidraProvider.js"; import { ghidraMipsProfileParameters, + ghidraMipsLanguageOverride, ghidraPinnedMipsLanguage, } from "./GhidraMipsProfile.js"; import type { GhidraInstallationHost } from "./GhidraInstallation.js"; @@ -80,49 +81,17 @@ const abi = ( }; const softFloat = abi({ cpr1Size: 0, fpAbi: 3 }); -it("pins inspected recordless MIPS I/o32 executables without inventing a floating-point ABI", async () => { - const provider = new GhidraProvider(config, silentLogger, {}, host); - for (const byteOrder of ["little", "big"] as const) { +it.each(["little", "big"] as const)( + "refuses MIPS I load-delay semantics independently of an explicit o32 declaration (%s)", + (byteOrder) => { + const provider = new GhidraProvider(config, silentLogger, {}, host); const value = target({ flags: 0x1001, byteOrder, abiFlags: null }); - expect(provider.inspectTargetSupport(value).status).toBe("supported"); - const resolved = await provider.resolveAnalysisProfile(value); - if (!resolved.ok) throw resolved.error; - expect(resolved.value.profile?.parameters).toMatchObject({ - mips_support_lane: "elf32-exec-mips1-recordless-pinned-v1", - mips_abi_basis: "declared-o32-header-fp-unverified-v1", - language_id: `MIPS:${byteOrder === "little" ? "LE" : "BE"}:32:default`, - mips_elf: { abi_flags: null }, - }); - } - const unexamined = target({ flags: 0x1001, abiFlags: null }); - if (unexamined.kind !== "executable" || unexamined.architecture !== "mips") - throw new Error("Expected MIPS fixture"); - const { abiFlags, ...unexaminedHeader } = unexamined.mips; - expect(abiFlags).toBeNull(); - expect( - provider.inspectTargetSupport({ ...unexamined, mips: unexaminedHeader }) - .status, - ).toBe("unsupported"); - for (const change of [ - { flags: 1 }, - { flags: 0x1021 }, - { flags: 0x1041 }, - { flags: 0x1101 }, - { flags: 0x1201 }, - { flags: 0x1401 }, - { flags: 0x04001001 }, - { elfClass: 64 as const }, - { type: 1 }, - { type: 3 }, - { limitations: ["ABI declarations were not fully inspected"] }, - ]) { - expect( - provider.inspectTargetSupport( - target({ flags: 0x1001, abiFlags: null, ...change }), - ).status, - ).toBe("unsupported"); - } -}); + const support = provider.inspectTargetSupport(value); + expect(support).toMatchObject({ status: "unsupported" }); + expect(JSON.stringify(support)).toContain("load-delay semantics"); + expect(ghidraMipsLanguageOverride(value)).toBeUndefined(); + }, +); describe("bounded Ghidra MIPS admission", () => { it("binds endian and flags without widening Windows P0", async () => { diff --git a/src/ghidra/GhidraMipsProfile.ts b/src/ghidra/GhidraMipsProfile.ts index cb050588868..5f7649f4cba 100644 --- a/src/ghidra/GhidraMipsProfile.ts +++ b/src/ghidra/GhidraMipsProfile.ts @@ -8,28 +8,6 @@ const MIPS_ET_EXEC = 2; const MIPS_ET_DYN = 3; const MIPS16_HEADER_ASE = 0x04000000; -/** Inspected recordless MIPS I executables with an explicitly declared o32 ABI. */ -const hasMips1HeaderShape = (target: BinaryTarget): boolean => { - if ( - target.kind !== "executable" || - target.format !== "elf" || - target.architecture !== "mips" - ) - return false; - const { flags, abiFlags, limitations } = target.mips; - return ( - target.mips.elfClass === 32 && - target.mips.type === MIPS_ET_EXEC && - (flags & 0xf0000000) === 0 && - (flags & 0x0000f000) === 0x00001000 && - (flags & 0x0fff0000) === 0 && - // ABI2, ABI_ON32, 32BITMODE, FP64 and NAN2008 require separate semantics. - (flags & 0x760) === 0 && - abiFlags === null && - (limitations?.length ?? 0) === 0 - ); -}; - /** A distinct compressed shared-image shape; admission still checks every declaration. */ const isGhidraMips16SharedTarget = (target: BinaryTarget): boolean => target.kind === "executable" && @@ -41,9 +19,7 @@ const isGhidraMips16SharedTarget = (target: BinaryTarget): boolean => export const ghidraMipsLanguageOverride = ( target: BinaryTarget, ): GhidraLanguageOverride | undefined => - (isGhidraMips16SharedTarget(target) || - hasLegacyMipsHeaderShape(target) || - hasMips1HeaderShape(target)) && + (isGhidraMips16SharedTarget(target) || hasLegacyMipsHeaderShape(target)) && ghidraMipsUnsupportedReason(target) === null && target.architecture === "mips" ? { @@ -112,7 +88,8 @@ export const ghidraMipsUnsupportedReason = ( if ((metadata.flags & 0x00ff0000) !== 0) return "Machine-specific MIPS variants, including PSP Allegrex, need a separately supported processor profile."; const arch = metadata.flags & 0xf0000000; - if (hasMips1HeaderShape(target)) return null; + if (arch === 0) + return "MIPS I remains unsupported: the generic Ghidra MIPS processor does not establish MIPS I load-delay semantics; instruction decoding alone cannot verify decompiler/dataflow correctness."; if (arch !== 0x70000000 && arch !== 0x50000000) return "This Ghidra MIPS lane requires EF_MIPS_ARCH_32R2 or a recordless legacy EF_MIPS_ARCH_32 header; other ISA revisions need separate verification."; const recordlessShape = @@ -283,12 +260,9 @@ export const ghidraMipsProfileParameters = ( // A deliberate caller-selected language wins over the legacy pin; the pin // exists because header auto-selection misselects a 64-bit language. const legacy = isLegacyMipsAdmission(target); - const mips1 = hasMips1HeaderShape(target); const language = ghidraMipsLanguageOverride(target); const pinnedLanguage = - (legacy || mips1) && - languageOverride === undefined && - language !== undefined + legacy && languageOverride === undefined && language !== undefined ? { language_id: language.languageId, compiler_spec_id: language.compilerSpecId ?? "default", @@ -344,19 +318,14 @@ export const ghidraMipsProfileParameters = ( ? { mips_soft_float_storage: GHIDRA_MIPS_SOFT_FLOAT_STORAGE } : {}), instruction_context_evidence: "per-address-isa-mode-v1", - mips_support_lane: mips1 - ? "elf32-exec-mips1-recordless-pinned-v1" - : legacy - ? "elf32-exec-arch32-legacy-pinned-v1" - : isGhidraMips16SharedTarget(target) - ? "elf32-dyn-o32-arch32r2-mips16-local-rel32-v1" - : target.mips.type === MIPS_ET_REL - ? "elf32-rel-o32-arch32r2-standard-v1" - : "elf32-exec-o32-arch32r2-standard-v3", + mips_support_lane: legacy + ? "elf32-exec-arch32-legacy-pinned-v1" + : isGhidraMips16SharedTarget(target) + ? "elf32-dyn-o32-arch32r2-mips16-local-rel32-v1" + : target.mips.type === MIPS_ET_REL + ? "elf32-rel-o32-arch32r2-standard-v1" + : "elf32-exec-o32-arch32r2-standard-v3", ...(legacy ? { mips_abi_basis: "inferred-o32-assumption-v1" } : {}), - ...(mips1 - ? { mips_abi_basis: "declared-o32-header-fp-unverified-v1" } - : {}), ...pinnedLanguage, }; }; diff --git a/src/ghidra/GhidraProviderClient.ts b/src/ghidra/GhidraProviderClient.ts index 479558004d3..25838453ea1 100644 --- a/src/ghidra/GhidraProviderClient.ts +++ b/src/ghidra/GhidraProviderClient.ts @@ -563,12 +563,6 @@ export const createGhidraProviderClient = (input: { ] : []), ...ghidraMipsRelocatableLimitations(target), - ...(committedProfile.parameters.mips_abi_basis === - "declared-o32-header-fp-unverified-v1" - ? [ - "The recordless MIPS I ELF header explicitly declares o32; no ABI flags record establishes floating-point storage semantics. Floating-point ABI behavior remains unverified.", - ] - : []), ...ghidraMipsSharedLimitations(target), ...ghidraRiscvLimitations(target), ]; diff --git a/tests/boundary/filesystem/mipsAbiFlags.test.ts b/tests/boundary/filesystem/mipsAbiFlags.test.ts index 1b724d31bdb..61f8647c1c1 100644 --- a/tests/boundary/filesystem/mipsAbiFlags.test.ts +++ b/tests/boundary/filesystem/mipsAbiFlags.test.ts @@ -106,6 +106,40 @@ const target = (mips: MipsElfMetadata) => ({ }); for (const little of [true, false]) { + it(`refuses typed GNU attributes before inferring an ABI (${little ? "LE" : "BE"})`, async () => { + const bytes = fixture(little); + const u32 = (value: number, offset: number) => + little + ? bytes.writeUInt32LE(value, offset) + : bytes.writeUInt32BE(value, offset); + // Keep the independently declared FPXX program record; add a conflicting + // legacy GNU soft-float attribute. Type, not section name, owns discovery. + u32(0x6ffffff5, 172); + u32(abiOffset + 24, 184); + u32(16, 188); + bytes[abiOffset + 24] = 0x41; + u32(15, abiOffset + 25); + bytes.write("gnu\0", abiOffset + 29, "ascii"); + bytes[abiOffset + 33] = 1; + u32(7, abiOffset + 34); + bytes[abiOffset + 38] = 4; + bytes[abiOffset + 39] = 3; + const result = await inspect(bytes, little); + assert.equal(result.ok, false); + if (result.ok) throw new Error("Expected uninspected attribute refusal"); + assert.match( + result.error, + /SHT_GNU_ATTRIBUTES.*cannot be treated as absent/u, + ); + // Refusal does not allocate from an untrusted attribute-section size. + u32(0xffffffff, 188); + const oversized = await inspect(bytes, little); + assert.equal(oversized.ok, false); + const path = await writeFixture(bytes); + const resolved = await parseBinaryTarget(path); + assert.equal(resolved.ok, false); + assert.deepEqual(await readFile(path), bytes); + }); it(`reads a remote ABI record through both ELF tables (${little ? "LE" : "BE"})`, async () => { const bytes = fixture(little); const result = await inspect(bytes, little); diff --git a/tests/conformance/ghidra/mips1-fixture.mjs b/tests/conformance/ghidra/mips1-fixture.mjs index ee46420c0e7..c43d01d420a 100644 --- a/tests/conformance/ghidra/mips1-fixture.mjs +++ b/tests/conformance/ghidra/mips1-fixture.mjs @@ -2,7 +2,7 @@ import { createHash } from "node:crypto"; import { writeFile } from "node:fs/promises"; import { join } from "node:path"; -/** Source-owned recordless MIPS I/o32 ELF. Encode only the declared integer probe. */ +/** Source-owned MIPS I/o32 refusal control. Its integer words do not establish load-delay semantics. */ export async function buildMips1Fixture(directory, byteOrder) { if (!["little", "big"].includes(byteOrder)) throw new Error("Unknown fixture byte order"); From 102f813f785dcd30867d135895df9779e9a0858c Mon Sep 17 00:00:00 2001 From: Bobtoshi <201082672+Bobtoshi@users.noreply.github.com> Date: Tue, 13 Oct 2026 20:30:56 +0100 Subject: [PATCH 4/7] test(mips): isolate native boundary controls from ambient settings --- .../verify-real-ghidra-mips-boundaries.mjs | 23 +++++++++++++------ src/ghidra/GhidraMipsProfile.test.ts | 13 +++++++---- 2 files changed, 25 insertions(+), 11 deletions(-) diff --git a/scripts/verify-real-ghidra-mips-boundaries.mjs b/scripts/verify-real-ghidra-mips-boundaries.mjs index 4018ddfc988..be8d35e7f32 100644 --- a/scripts/verify-real-ghidra-mips-boundaries.mjs +++ b/scripts/verify-real-ghidra-mips-boundaries.mjs @@ -25,6 +25,20 @@ const execute = promisify(execFile); const root = await mkdtemp(join(tmpdir(), "rea-mips-boundaries-")); const digest = (bytes) => createHash("sha256").update(bytes).digest("hex"); const cli = fileURLToPath(new URL("./rea.mjs", import.meta.url)); +// Only declared tool prerequisites and resource settings reach REA children. +// Ambient REA seeds, analyzer controls and session settings cannot alter controls. +const env = { + PATH: process.env.PATH ?? "/usr/bin:/bin", + TMPDIR: root, + REA_ANALYSIS_PROVIDER: "ghidra", + REA_LOG_LEVEL: "silent", + GHIDRA_INSTALL_DIR: process.env.GHIDRA_INSTALL_DIR, + JAVA_HOME: process.env.JAVA_HOME, + HOPPER_LAUNCHER_PATH: "/rea-unconfigured-deep-provider/hopper", +}; +for (const setting of ["GHIDRA_HEADLESS_MAXMEM", "GHIDRA_MAXMEM"]) { + if (process.env[setting] !== undefined) env[setting] = process.env[setting]; +} const reports = []; try { for (const byteOrder of ["little", "big"]) { @@ -67,12 +81,7 @@ try { const transport = new StdioClientTransport({ command: process.execPath, args: [cli, "mcp"], - env: { - ...process.env, - REA_ANALYSIS_PROVIDER: "ghidra", - REA_LOG_LEVEL: "silent", - TMPDIR: root, - }, + env, stderr: "inherit", }); const client = new Client({ name: "rea-mips-boundaries", version: "1" }); @@ -104,7 +113,7 @@ try { "--full-output", "--json", ], - { env: process.env, timeout: 240_000 }, + { env, timeout: 240_000 }, ); assert.fail("CLI admitted an unsupported MIPS declaration"); } catch (cause) { diff --git a/src/ghidra/GhidraMipsProfile.test.ts b/src/ghidra/GhidraMipsProfile.test.ts index 894978ddbb7..81d7f7a7496 100644 --- a/src/ghidra/GhidraMipsProfile.test.ts +++ b/src/ghidra/GhidraMipsProfile.test.ts @@ -81,11 +81,16 @@ const abi = ( }; const softFloat = abi({ cpr1Size: 0, fpAbi: 3 }); -it.each(["little", "big"] as const)( - "refuses MIPS I load-delay semantics independently of an explicit o32 declaration (%s)", - (byteOrder) => { +it.each([ + ["little", 0x1001], + ["big", 0x1001], + ["little", 0x1801], + ["big", 0x1801], +] as const)( + "refuses MIPS I load-delay semantics independently of an explicit o32 declaration (%s, %i)", + (byteOrder, flags) => { const provider = new GhidraProvider(config, silentLogger, {}, host); - const value = target({ flags: 0x1001, byteOrder, abiFlags: null }); + const value = target({ flags, byteOrder, abiFlags: null }); const support = provider.inspectTargetSupport(value); expect(support).toMatchObject({ status: "unsupported" }); expect(JSON.stringify(support)).toContain("load-delay semantics"); From 05e23666d856b739790498d37ddb578b007632a2 Mon Sep 17 00:00:00 2001 From: Bobtoshi <201082672+Bobtoshi@users.noreply.github.com> Date: Tue, 13 Oct 2026 20:33:30 +0100 Subject: [PATCH 5/7] test(mips): verify cleanup and pin readelf locale --- .../verify-real-ghidra-mips-boundaries.mjs | 28 +++++++++++++++++-- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/scripts/verify-real-ghidra-mips-boundaries.mjs b/scripts/verify-real-ghidra-mips-boundaries.mjs index be8d35e7f32..d8b2db2b720 100644 --- a/scripts/verify-real-ghidra-mips-boundaries.mjs +++ b/scripts/verify-real-ghidra-mips-boundaries.mjs @@ -14,6 +14,7 @@ import { requireMcpOperationResult, requireMcpToolError, } from "./lib/mcp-verifier-results.mjs"; +import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs"; import { functionDossierSchema } from "../dist/domain/hopperValues.js"; // Static source-owned controls. Never execute an ELF or acquire a toolchain. @@ -22,6 +23,7 @@ if (!process.env.GHIDRA_INSTALL_DIR || !process.env.JAVA_HOME) "Supply GHIDRA_INSTALL_DIR and JAVA_HOME for the MIPS32 positive control.", ); const execute = promisify(execFile); +const run = createVerifierRun(); const root = await mkdtemp(join(tmpdir(), "rea-mips-boundaries-")); const digest = (bytes) => createHash("sha256").update(bytes).digest("hex"); const cli = fileURLToPath(new URL("./rea.mjs", import.meta.url)); @@ -32,6 +34,7 @@ const env = { TMPDIR: root, REA_ANALYSIS_PROVIDER: "ghidra", REA_LOG_LEVEL: "silent", + REA_PROCESS_RUN_ID: run.run_id, GHIDRA_INSTALL_DIR: process.env.GHIDRA_INSTALL_DIR, JAVA_HOME: process.env.JAVA_HOME, HOPPER_LAUNCHER_PATH: "/rea-unconfigured-deep-provider/hopper", @@ -40,6 +43,7 @@ for (const setting of ["GHIDRA_HEADLESS_MAXMEM", "GHIDRA_MAXMEM"]) { if (process.env[setting] !== undefined) env[setting] = process.env[setting]; } const reports = []; +let primaryFailure; try { for (const byteOrder of ["little", "big"]) { const original = await buildMips1Fixture(root, byteOrder); @@ -76,6 +80,7 @@ try { const { stdout: attributes } = await execute( process.env.REA_READELF ?? "readelf", ["-A", gnuPath], + { env: { PATH: env.PATH, LC_ALL: "C" } }, ); assert.match(attributes, /Tag_GNU_MIPS_ABI_FP.*Soft float/iu); const transport = new StdioClientTransport({ @@ -163,7 +168,24 @@ try { } } } - console.log(JSON.stringify({ status: "passed", reports }, null, 2)); -} finally { - await rm(root, { recursive: true, force: true }); +} catch (cause) { + primaryFailure = cause; } +const completed = await completeVerifierRun(run); +if ( + completed.process_lineage.status !== "verified" || + completed.process_lineage.descendants.length !== 0 +) + throw new Error( + `Cleanup unverified; retained diagnostic workspace: ${root}`, + { cause: primaryFailure }, + ); +if (primaryFailure !== undefined) + throw new Error( + `MIPS boundary verification failed; retained workspace: ${root}`, + { cause: primaryFailure }, + ); +await rm(root, { recursive: true, force: true }); +console.log( + JSON.stringify({ ...completed, status: "passed", reports }, null, 2), +); From e389016ff5758221bc2394c0df2d8368cb501294 Mon Sep 17 00:00:00 2001 From: Bobtoshi <201082672+Bobtoshi@users.noreply.github.com> Date: Wed, 14 Oct 2026 20:26:38 +0100 Subject: [PATCH 6/7] chore: preserve untouched upstream tests after merge --- tests/boundary/mcp/fridaLifecycleMcp.test.ts | 6 ++++-- tests/boundary/mcp/toolInputAdmission.test.ts | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/boundary/mcp/fridaLifecycleMcp.test.ts b/tests/boundary/mcp/fridaLifecycleMcp.test.ts index a29feed8d07..4c657668ebb 100644 --- a/tests/boundary/mcp/fridaLifecycleMcp.test.ts +++ b/tests/boundary/mcp/fridaLifecycleMcp.test.ts @@ -272,9 +272,11 @@ const callPartialFridaLoadError = async ( const failure = partial === "evidence" ? new FridaScriptLoadCancelledError(script) - : new AnalysisCancelledError("load_frida_script", (partial === "non-evidence" + : new AnalysisCancelledError("load_frida_script", { + ...(partial === "non-evidence" ? { partialObservation: nonEvidencePartialObservation() } - : {})); + : {}), + }); const provider: FridaInstrumentationPort = { async listDevices() { return unusedFridaPortMethod(); diff --git a/tests/boundary/mcp/toolInputAdmission.test.ts b/tests/boundary/mcp/toolInputAdmission.test.ts index 3629b5f7f1b..8284ede7c7f 100644 --- a/tests/boundary/mcp/toolInputAdmission.test.ts +++ b/tests/boundary/mcp/toolInputAdmission.test.ts @@ -59,7 +59,7 @@ describe("catalog-wide MCP input admission", () => { expect(TOOL_CONTRACTS.length).toBeGreaterThan(0); for (const contract of TOOL_CONTRACTS) { const input = { - ...contract.examples[0]?.input, + ...(contract.examples[0]?.input ?? {}), __rea_unknown_admission_probe__: true, }; expect(contract.inputSchema.safeParse(input).success).toBe(false); From 11edd85fb0a4b81c03e46c89af575455d2518f83 Mon Sep 17 00:00:00 2001 From: Bobtoshi <201082672+Bobtoshi@users.noreply.github.com> Date: Fri, 6 Nov 2026 01:12:21 +0000 Subject: [PATCH 7/7] fix(verification): sweep the captured Ghidra provider run identities --- docs/ghidra-mips.md | 4 +- scripts/lib/verifier-token-cleanup.d.mts | 18 ++- scripts/lib/verifier-token-cleanup.mjs | 97 +++++++++++++- .../verify-real-ghidra-mips-boundaries.mjs | 37 ++++++ .../process/verifierTokenCleanup.test.ts | 125 +++++++++++++++++- 5 files changed, 268 insertions(+), 13 deletions(-) diff --git a/docs/ghidra-mips.md b/docs/ghidra-mips.md index 05d96e5c5e8..ccfddaec3bc 100644 --- a/docs/ghidra-mips.md +++ b/docs/ghidra-mips.md @@ -16,7 +16,9 @@ Ghidra dossiers and exact loaded bytes. These observations do not establish MIPS I instruction-subset or load-delay correctness. Supply existing Ghidra/Java and GNU readelf (`REA_READELF`). Source hashes are checked after inspection, and no target is executed. Cleanup checks both process lineage and an independent -run-token sweep, so reparenting cannot hide an owned survivor. +run-token sweep using each provider run ID retained from `binary_session` before +closing the target. Ghidra overrides the outer verifier token with that session +ID; checking the outer token alone cannot detect a reparented engine. ELF `EM_MIPS` is retained as the provider-neutral `mips` family. The target also retains class, byte order, `e_type`, raw `e_flags`, and inspected ABI declarations. diff --git a/scripts/lib/verifier-token-cleanup.d.mts b/scripts/lib/verifier-token-cleanup.d.mts index b7b2805a934..002cde7e61a 100644 --- a/scripts/lib/verifier-token-cleanup.d.mts +++ b/scripts/lib/verifier-token-cleanup.d.mts @@ -9,6 +9,22 @@ export function prepareVerifierTokenCleanup( run: VerifierRunStart, suppliedHost?: ProcessOwnershipHost, ): Promise<{ - verify(): Promise; + /** Retain a validated provider-owned receipt before closing its session. */ + retainProviderRun( + runId: string, + providerScope?: { + readonly leaderPid: number; + readonly processGroupId: number; + readonly sampledProcessGroupIds?: readonly number[]; + }, + ): Promise; + verify(): Promise<{ + readonly cleaned: boolean; + readonly reason?: string; + readonly runs: readonly { + readonly run_id: string; + readonly result: ProcessCleanupResult; + }[]; + }>; close(): Promise; }>; diff --git a/scripts/lib/verifier-token-cleanup.mjs b/scripts/lib/verifier-token-cleanup.mjs index 373b8e9750a..05f17242040 100644 --- a/scripts/lib/verifier-token-cleanup.mjs +++ b/scripts/lib/verifier-token-cleanup.mjs @@ -1,6 +1,10 @@ import { verifyNoTokenOwnedProcesses } from "../../dist/process/ProcessOwnership.js"; import { ProcessOwnershipEvidenceOwner } from "../../dist/process/ProcessOwnershipEvidence.js"; -import { createSystemProcessOwnershipHost } from "../../dist/process/ProcessOwnershipObservation.js"; +import { + createSystemProcessOwnershipHost, + readProcessOwnershipFacts, +} from "../../dist/process/ProcessOwnershipObservation.js"; +import { readRunTokenCarriers } from "../../dist/process/ProcessRunTokenObservations.js"; /** Capture the prelaunch identities used by the canonical, PPID-independent sweep. */ export async function prepareVerifierTokenCleanup(run, suppliedHost) { @@ -15,14 +19,93 @@ export async function prepareVerifierTokenCleanup(run, suppliedHost) { ); if (launcher === undefined) throw new Error("Verifier launcher is absent from the process snapshot"); - const relation = { - leaderPid: run.verifier_pid, - processGroupId: launcher.processGroupId, - evidenceOwner: new ProcessOwnershipEvidenceOwner(), + const relations = new Map(); + const retain = (runId, providerScope) => { + if (typeof runId !== "string" || runId.length === 0) + throw new Error("Provider run ownership receipt is unavailable"); + if (!relations.has(runId)) + relations.set(runId, { + leaderPid: providerScope?.leaderPid ?? run.verifier_pid, + processGroupId: + providerScope?.processGroupId ?? launcher.processGroupId, + ...(providerScope?.sampledProcessGroupIds === undefined + ? {} + : { sampledProcessGroupIds: providerScope.sampledProcessGroupIds }), + evidenceOwner: new ProcessOwnershipEvidenceOwner(), + }); }; + retain(run.run_id); return { - verify: () => - verifyNoTokenOwnedProcesses(run.run_id, host, baseline, relation), + retainProviderRun: async (runId, providerScope) => { + if (typeof runId !== "string" || runId.length === 0) + throw new Error("Provider run ownership receipt is unavailable"); + if (runId === run.run_id) + throw new Error( + "Provider receipt must identify its own run, not the verifier run", + ); + if (providerScope === undefined) { + // Discovery proves presence only. Identity/token/identity reads then + // establish scope anchors; the final canonical sweep proves cleanup. + const carriers = await readRunTokenCarriers(host, runId); + const facts = ( + await readProcessOwnershipFacts(carriers, host) + ).filter( + (fact) => + fact.state === "same-generation" && + fact.token.state === "readable" && + fact.token.runId === runId && + fact.identityAfter.state === "readable" && + fact.identityAfter.relation?.state === "observed", + ); + if (facts.length === 0) + throw new Error( + `No token-verified process ownership for provider run ${runId}`, + ); + providerScope = { + leaderPid: facts[0].process.pid, + processGroupId: facts[0].identityAfter.relation.processGroupId, + sampledProcessGroupIds: [ + ...new Set( + facts.map( + ({ identityAfter }) => identityAfter.relation.processGroupId, + ), + ), + ], + }; + } + retain(runId, providerScope); + const relation = relations.get(runId); + const active = await verifyNoTokenOwnedProcesses( + runId, + host, + baseline, + relation, + ); + if (relation.evidenceOwner.coverage.retainedOwned === 0) + throw new Error( + `No token-verified process ownership for provider run ${runId}: ${JSON.stringify(active)}`, + ); + }, + verify: async () => { + const runs = []; + for (const [runId, relation] of relations) { + runs.push({ + run_id: runId, + result: await verifyNoTokenOwnedProcesses( + runId, + host, + baseline, + relation, + ), + }); + } + const failed = runs.find(({ result }) => !result.cleaned); + return { + cleaned: failed === undefined, + ...(failed === undefined ? {} : { reason: failed.result.reason }), + runs, + }; + }, close: async () => { await host.close?.(); }, diff --git a/scripts/verify-real-ghidra-mips-boundaries.mjs b/scripts/verify-real-ghidra-mips-boundaries.mjs index 914b9d57a70..c1b259a7c00 100644 --- a/scripts/verify-real-ghidra-mips-boundaries.mjs +++ b/scripts/verify-real-ghidra-mips-boundaries.mjs @@ -142,11 +142,13 @@ try { assert.equal(reply.ok, false); assert.match(JSON.stringify(reply), pattern); } + const providerRuns = []; for (const [path, expected] of [ [legacyPath, legacy], [recordlessMips1Path, recordlessMips1], ]) { await call("open_binary", { path, provider_id: "ghidra" }); + // Composite open/health does not launch Ghidra; analyze_function does. const dossier = functionDossierSchema.parse( await call("analyze_function", { procedure: original.entry, @@ -154,6 +156,40 @@ try { }), ); assert.ok(dossier.pseudocode.length > 0); + const status = await call("binary_session"); + assert.equal(status.analysis_provider_binding?.provider.id, "ghidra"); + assert.equal(typeof status.analysis_run?.run_id, "string"); + // The session owner generated this ID, and the launcher installs it on + // Ghidra in place of the surrounding verifier's process-run token. + assert.equal(status.open, true); + const providerLineage = + status.analysis_run.process_lineage.snapshots?.find( + ({ provider }) => provider.id === "ghidra", + )?.observation; + const providerScope = + providerLineage?.status === "verified" + ? { + leaderPid: providerLineage.launcher_pid, + processGroupId: providerLineage.process_group_id, + sampledProcessGroupIds: providerLineage.descendants.map( + ({ process_group_id }) => process_group_id, + ), + } + : undefined; + // Capture native token-verified identities while the provider is live; + // A public lineage snapshot is optional; native ownership is mandatory. + await cleanup.retainProviderRun( + status.analysis_run.run_id, + providerScope, + ); + providerRuns.push({ + control: + path === legacyPath ? "recordless_mips32" : "recordless_mips1", + run_id: status.analysis_run.run_id, + ...(providerScope === undefined + ? { public_lineage: "not_observed" } + : providerScope), + }); const loaded = await call("read_bytes", { address: original.entry, length: 24, @@ -170,6 +206,7 @@ try { assert.deepEqual(await readFile(recordlessMips1Path), recordlessMips1); reports.push({ byte_order: byteOrder, + provider_runs: providerRuns, declared_o32_mips1: "refused by CLI and MCP", recordless_mips1: "real Ghidra dossier and exact loaded bytes; instruction subset/load-delay semantics unverified", diff --git a/tests/boundary/process/verifierTokenCleanup.test.ts b/tests/boundary/process/verifierTokenCleanup.test.ts index ca3ad76d67b..14458b11f72 100644 --- a/tests/boundary/process/verifierTokenCleanup.test.ts +++ b/tests/boundary/process/verifierTokenCleanup.test.ts @@ -1,7 +1,16 @@ +import { randomUUID } from "node:crypto"; +import { spawnOwnedProviderProcess } from "../../../src/process/ProviderProcess.js"; +import { + createSystemProcessOwnershipHost, + observeProcessStartIdentity, + observeProcessStartIdentityExit, + signalProcessWithStartIdentity, +} from "../../../src/process/ProcessOwnershipObservation.js"; import { expect, it, vi } from "vitest"; import { prepareVerifierTokenCleanup } from "../../../scripts/lib/verifier-token-cleanup.mjs"; import type { ProcessOwnershipHost, + ProcessIdentityObservation, ProcessTableEntry, } from "../../../src/process/ProcessOwnership.js"; @@ -20,6 +29,13 @@ it("rejects a token-owned survivor after it is reparented outside the verifier t state: "S", command: "Ghidra control", }; + const foreign: ProcessTableEntry = { + pid: 300, + parentPid: 1, + processGroupId: 300, + state: "S", + command: "unrelated", + }; let rows = [launcher]; const close = vi.fn(); const host: ProcessOwnershipHost = { @@ -29,14 +45,19 @@ it("rejects a token-owned survivor after it is reparented outside the verifier t captureBaseline: async () => [{ pid: launcher.pid, identity: "start:100" }], listProcesses: async () => rows, environment: async (pid) => - pid === orphan.pid ? { REA_PROCESS_RUN_ID: "owned-run" } : {}, + pid === orphan.pid ? { REA_PROCESS_RUN_ID: "provider-run" } : {}, runTokens: async (selected) => new Map( selected.map(({ pid }) => [ pid, { state: "readable", - runId: pid === orphan.pid ? "owned-run" : undefined, + runId: + pid === orphan.pid + ? "provider-run" + : pid === foreign.pid + ? "foreign-run" + : undefined, } as const, ]), ), @@ -67,16 +88,28 @@ it("rejects a token-owned survivor after it is reparented outside the verifier t { run_id: "owned-run", verifier_pid: 100, parent_pid: 50 }, host, ); - rows = [launcher, orphan]; + await expect(cleanup.retainProviderRun("owned-run")).rejects.toThrow( + "its own run", + ); + await expect(cleanup.retainProviderRun("")).rejects.toThrow( + "receipt is unavailable", + ); + rows = [launcher, orphan, foreign]; expect(rows.filter(({ parentPid }) => parentPid === launcher.pid)).toEqual( [], ); + // The old outer-token-only sweep misses the real provider token. + expect(await cleanup.verify()).toMatchObject({ cleaned: true }); + await cleanup.retainProviderRun("provider-run"); expect(await cleanup.verify()).toMatchObject({ cleaned: false, reason: "token-owned process remained after cleanup", }); - rows = [launcher]; + rows = [launcher, foreign]; expect(await cleanup.verify()).toMatchObject({ cleaned: true }); + await expect( + cleanup.retainProviderRun("unobserved-provider-run"), + ).rejects.toThrow("No token-verified process ownership"); await cleanup.close(); expect(close).toHaveBeenCalledOnce(); expect(host.signalGroup).not.toHaveBeenCalled(); @@ -98,3 +131,87 @@ it("closes the inspection host when baseline ownership cannot be established", a ).rejects.toThrow("baseline is unavailable"); expect(close).toHaveBeenCalledOnce(); }); + +it.runIf(process.platform !== "win32")( + "retains the token installed by the production provider launcher after its parent exits", + async () => { + const run = { + run_id: randomUUID(), + verifier_pid: process.pid, + parent_pid: process.ppid, + }; + const providerRun = randomUUID(); + const host = createSystemProcessOwnershipHost(); + const cleanup = await prepareVerifierTokenCleanup(run, host); + let orphanPid: number | undefined; + let orphanIdentity: ProcessIdentityObservation | undefined; + try { + const code = `const {spawn}=require('node:child_process');const child=spawn(process.execPath,['-e','setTimeout(()=>{},15000)'],{detached:true,stdio:'ignore',env:process.env});process.stdout.write(JSON.stringify({run_id:process.env.REA_PROCESS_RUN_ID,pid:child.pid}));child.unref();`; + const launched = await spawnOwnedProviderProcess({ + command: process.execPath, + arguments: ["-e", code], + runId: providerRun, + env: { REA_PROCESS_RUN_ID: run.run_id }, + expectedCommand: null, + }); + let output = ""; + launched.process.stdout?.on("data", (chunk) => { + output += String(chunk); + }); + launched.process.stderr?.resume(); + await new Promise((resolve) => { + if (launched.process.exitCode !== null) resolve(); + else launched.process.once("close", resolve); + }); + const receipt = JSON.parse(output) as { run_id: string; pid: number }; + orphanPid = receipt.pid; + orphanIdentity = await observeProcessStartIdentity(orphanPid, host); + expect(receipt.run_id).toBe(providerRun); + expect(receipt.run_id).not.toBe(run.run_id); + expect(orphanIdentity?.state).toBe("readable"); + // This is the original false green: the wrapper token is absent, but + // the reparented provider process still owns its distinct native token. + expect(await cleanup.verify()).toMatchObject({ cleaned: true }); + await cleanup.retainProviderRun(receipt.run_id, { + leaderPid: launched.ownership.leaderPid, + processGroupId: launched.ownership.processGroupId, + }); + const result = await cleanup.verify(); + expect(result).toMatchObject({ cleaned: false }); + expect( + result.runs.find(({ run_id }) => run_id === providerRun)?.result, + ).toMatchObject({ + cleaned: false, + reason: "token-owned process remained after cleanup", + }); + const row = (await host.listProcesses()).find( + ({ pid }) => pid === orphanPid, + ); + expect(row?.parentPid).not.toBe(process.pid); + expect(row?.parentPid).not.toBe(launched.process.pid); + } finally { + try { + if (orphanPid !== undefined && orphanIdentity?.state === "readable") { + const pid = orphanPid; + const identity = orphanIdentity.identity; + expect( + await signalProcessWithStartIdentity(pid, identity, "SIGTERM", { + host, + }), + ).toBe("signaled"); + await vi.waitFor( + async () => { + expect( + await observeProcessStartIdentityExit(pid, identity, host), + ).toBe("exited"); + }, + { timeout: 3000, interval: 20 }, + ); + } + } finally { + await cleanup.close(); + } + } + }, + 20000, +);