feat(ghidra): support raw big-endian m68k analysis (#4018)

* feat(ghidra): support raw big-endian m68k analysis

Admit `m68k` for headerless raw-binary targets. Admission requires a
2-byte aligned entry and a mapping within 0x0..0xffffffff, and Ghidra
imports with BinaryLoader using 68000:BE:32:default, the default compiler
specification, and the `ram` default address space.

`6502` and `m68k` now share one RAW_ONLY_ARCHITECTURES owner, so detected
targets, Evidence subjects, snapshots, and lifecycle output contracts
exclude both instead of repeating a 6502 literal.

Ghidra's default 68000 language decodes the 68040 superset; separate
68020/68030/CPU32/ColdFire variants, Amiga/Atari containers, and reset
vector discovery remain unsupported. Windows Ghidra P0 still admits raw
6502 only.

Verified with Ghidra 12.1.4 on macOS against an 8-byte self-built fixture
(nop; nop; moveq #1,d0; rts at 0x1000): load image verified in `ram`,
entry decompiles to `return 1`.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* test(ghidra): verify raw m68k analysis with real Ghidra

Add npm run verify:ghidra:m68k. The verifier loads a source-owned 22-byte
68000 fixture (annotated hex, no assembler) at 0x1000 and checks, through
the compiled CLI and stdio MCP server: the 68000:BE:32:default BinaryLoader
import, load-image parity, exact mapped bytes, both functions, the bsr.b
call reference, decompilation by address and file offset, rejection of a
misaligned entry and of a mapping past 0xffffffff without replacing the
active session, unchanged source bytes, and runtime cleanup.

Run with Ghidra 12.1.4 and JDK 21 on macOS arm64. Linux has not been
exercised; Windows Ghidra P0 does not admit m68k.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
zhugehyuk
2026-10-17 00:57:34 +09:00
committed by GitHub
parent 56738f3070
commit d62d3082c2
23 changed files with 742 additions and 187 deletions
@@ -246,13 +246,15 @@ context. PSP/stack/device state remains unmodeled. See [DOS guide](https://githu
For other headerless images (embedded firmware without ELF/Mach-O/PE/MZ
headers), open with `format: "raw-binary"`, `architecture` (`x86`, `x86_64`,
`arm`, `arm64`, or `6502`), and `entry_point` (`0x` hex). File offset 0 is mapped at
`arm`, `arm64`, `6502`, or `m68k`), and `entry_point` (`0x` hex). File offset 0 is mapped at
that address. Hopper and IDA refuse this interpretation; Ghidra uses
`BinaryLoader` with a little-endian generic language. Big-endian, Thumb-only,
and Cortex-M variants are unsupported. `6502` selects NMOS 6502; the entry and
complete file mapping must fit within `0x0000..0xffff`. 65C02, 65816, cartridge
containers, bank switching, and mapper semantics are unsupported. Windows
Ghidra P0 admits raw images only for `6502`. See [raw binary guide](https://github.com/morluto/rea/blob/main/docs/raw-binary.md).
`BinaryLoader` with a generic language: big-endian for `m68k`, little-endian
otherwise. Big-endian ARM, Thumb-only, and Cortex-M variants are unsupported.
`6502` selects NMOS 6502; the entry and complete file mapping must fit within
`0x0000..0xffff`. 65C02, 65816, cartridge containers, bank switching, and mapper
semantics are unsupported. `m68k` requires an even entry and decodes the 68040
superset of the 68000 family; Amiga/Atari containers and vector-table entry
discovery are unsupported. Windows Ghidra P0 admits raw images only for `6502`. See [raw binary guide](https://github.com/morluto/rea/blob/main/docs/raw-binary.md).
### Function annotations in Ghidra
+45 -13
View File
@@ -25,27 +25,31 @@ rea inspect-native-load-image /absolute/path/to/ecu.bin --target-format raw-bina
}
```
`architecture` is one of `x86`, `x86_64`, `arm`, `arm64`, or `6502`. `entry_point` is a
`architecture` is one of `x86`, `x86_64`, `arm`, `arm64`, `6502`, or `m68k`. `entry_point` is a
hexadecimal address of the form `0x…`. The complete file is mapped starting at
that address, and Ghidra seeds an `entry` function there before auto-analysis.
ARM and AArch64 entries must be 4-byte aligned; x86 and 6502 entries may be byte-aligned.
ARM and AArch64 entries must be 4-byte aligned; m68k entries must be 2-byte
aligned; x86 and 6502 entries may be byte-aligned.
For 6502, the entry and complete mapped file must fit within `0x0000..0xffff`;
images that cross the 16-bit boundary are rejected before provider startup.
For m68k, the mapping must fit within `0x0..0xffffffff`.
`open_binary` and an open `binary_session` return the canonical `entry_point`
for raw targets so callers can verify the selected mapping directly.
Omitted `format` keeps header-based detection; architecture and entry without
`format=raw-binary` are rejected.
Ghidra imports a private snapshot with `BinaryLoader` and these little-endian
generic languages and compiler specifications:
Ghidra imports a private snapshot with `BinaryLoader` and these generic
languages and compiler specifications. Each family has one fixed byte order:
m68k is big-endian and the others are little-endian.
| architecture | language_id | compiler_spec_id |
| ------------ | -------------------- | ---------------- |
| x86 | `x86:LE:32:default` | `gcc` |
| x86_64 | `x86:LE:64:default` | `gcc` |
| arm | `ARM:LE:32:v7` | `default` |
| arm64 | `AARCH64:LE:64:v8A` | `default` |
| 6502 | `6502:LE:16:default` | `default` |
| architecture | language_id | compiler_spec_id |
| ------------ | --------------------- | ---------------- |
| x86 | `x86:LE:32:default` | `gcc` |
| x86_64 | `x86:LE:64:default` | `gcc` |
| arm | `ARM:LE:32:v7` | `default` |
| arm64 | `AARCH64:LE:64:v8A` | `default` |
| 6502 | `6502:LE:16:default` | `default` |
| m68k | `68000:BE:32:default` | `default` |
Compiler specifications select analysis conventions; they do not identify the
original compiler or ABI of a headerless target. Ghidra's x86 languages do not
@@ -106,12 +110,40 @@ unchanged source bytes and complete runtime cleanup. Windows requires the
matching native addon; the POSIX provider path has not been exercised by the
Windows run.
## Motorola 68000
```bash
rea function /absolute/path/to/image.bin 0x1000 --target-format raw-binary --architecture m68k --entry-point 0x1000 --provider ghidra --json
rea inspect-native-load-image /absolute/path/to/image.bin --target-format raw-binary --architecture m68k --entry-point 0x1000 --provider ghidra --json
```
Select only headerless big-endian 68000-family code with a known, even entry at
file offset 0. Ghidra's `68000:BE:32:default` language decodes the 68040
superset in the 32-bit `ram` address space. Instructions that a real MC68000
would not execute are therefore not rejected, and 68020, 68030, CPU32, and
ColdFire programs are not admitted as those variants. Addresses are not masked
to the MC68000 24-bit bus. Evidence preserves `architecture: "m68k"`, the
selected language, entry, file digest, and verified source mapping. Register and
calling conventions are analysis conventions, not a recovered source ABI.
This lane does not interpret Amiga hunks, Atari ST, or other container headers,
cartridge or console memory maps, or bank switching. REA does not read the
initial stack pointer and reset vector from the first eight bytes; select the
entry explicitly. Windows Ghidra P0 does not admit m68k.
`npm run verify:ghidra:m68k` uses a source-owned 22-byte fixture written as
annotated hex in the verifier, so it needs no assembler. The lane checks real
CLI/MCP load-image parity, mapped bytes, a decoded call, decompilation by address
and file offset, misaligned and out-of-range mappings, unchanged source bytes and
runtime cleanup. It has been run with Ghidra 12.1.4 on macOS; Linux has not been
exercised.
## Limitations
- Headers are not discovered from file bytes. This increment does not guess
load addresses, vector tables, or memory maps.
- Big-endian, Thumb-only, Cortex-M, and other Ghidra processor variants are
unsupported.
- Big-endian ARM, Thumb-only, Cortex-M, and other Ghidra processor variants are
unsupported. m68k is the only big-endian family.
- Reset vectors, memory-mapped I/O, and firmware boot sequences are not
emulated.
- Independent load-image verification covers source identity, mapped file
+10
View File
@@ -976,6 +976,16 @@ and runtime cleanup. Windows additionally requires the matching native addon;
its verifier lineage remains explicitly unavailable outside the provider's
retained Job Object, as in the other Windows lanes.
`npm run verify:ghidra:m68k` needs no assembler: its 22-byte big-endian 68000
fixture is source-owned annotated hex in the verifier, loaded at `0x1000`. With
an installed Ghidra 12.1.4 and full JDK on Linux or macOS, it verifies the
`68000:BE:32:default` BinaryLoader import, real CLI/stdio MCP load-image parity,
exact source bytes, both functions, the `bsr.b` call reference, decompilation
through address and file offset, rejection of a misaligned entry and of a mapping
past `0xffffffff` without replacing the active session, and runtime cleanup.
Windows Ghidra P0 does not admit m68k, so this is not a Windows lane. The lane
has been run on macOS; Linux has not been exercised.
`npm run verify:ghidra:raw` uses a generated ARM little-endian headerless image
through the compiled CLI and stdio MCP server. With the same Ghidra/JDK
prerequisites, it checks preparation and bridge startup, verified load mappings,
+1
View File
@@ -189,6 +189,7 @@
"verify:ghidra:ppc": "npm run build:cached && node scripts/verify-real-ghidra-ppc.mjs",
"verify:ghidra:riscv": "npm run build:cached && node scripts/verify-real-ghidra-riscv.mjs",
"verify:ghidra:6502": "npm run build:cached && node scripts/verify-real-ghidra-6502.mjs",
"verify:ghidra:m68k": "npm run build:cached && node scripts/verify-real-ghidra-m68k.mjs",
"verify:ghidra:mips-shared": "npm run build:cached && node scripts/verify-real-ghidra-mips-shared.mjs",
"verify:ghidra:mips-relocatable": "npm run build:cached && node scripts/verify-real-ghidra-mips-relocatable.mjs",
"verify:ghidra:swift": "npm run build:cached && node scripts/verify-real-ghidra-swift.mjs",
+296
View File
@@ -0,0 +1,296 @@
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { createHash } from "node:crypto";
import {
mkdir,
mkdtemp,
readFile,
readdir,
rm,
writeFile,
} from "node:fs/promises";
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 { parseEvidence } from "../dist/domain/evidence.js";
import { requireMcpOperationResult } from "./lib/mcp-verifier-results.mjs";
import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs";
if (process.argv.length !== 2)
throw new Error("Usage: node scripts/verify-real-ghidra-m68k.mjs");
// Windows Ghidra P0 admits raw 6502 only; m68k has no Windows lane.
if (!["linux", "darwin"].includes(process.platform))
throw new Error(
"m68k raw-binary verification requires a Linux or macOS Ghidra host.",
);
if (process.env.GHIDRA_INSTALL_DIR === undefined)
throw new Error(
"Set GHIDRA_INSTALL_DIR to a bring-your-own Ghidra installation.",
);
const exec = promisify(execFile);
const run = createVerifierRun();
const root = fileURLToPath(new URL("..", import.meta.url));
// Source-owned big-endian 68000 image, loaded at 0x1000:
// 0x1000 7007 moveq #7,d0
// 0x1002 610e bsr.b add_three (0x1012)
// 0x1004 0c40000a cmpi.w #10,d0
// 0x1008 6604 bne.b mismatch (0x100e)
// 0x100a 702a moveq #42,d0
// 0x100c 4e75 rts
// 0x100e 7000 mismatch: moveq #0,d0
// 0x1010 4e75 rts
// 0x1012 5680 add_three: addq.l #3,d0
// 0x1014 4e75 rts
const bytes = Buffer.from(
"7007610e0c40000a6604702a4e7570004e7556804e75",
"hex",
);
const sha256 = createHash("sha256").update(bytes).digest("hex");
assert.equal(
sha256,
"7679358409efee4c5c2465f9a47158c4d49e0415ba12b3170898440db7ea0bc9",
);
await mkdir(join(root, "build"), { recursive: true });
const workspace = await mkdtemp(join(root, "build", "rea-m68k-proof-"));
const runtime = join(workspace, "runtime");
await mkdir(runtime);
const path = join(workspace, "image.bin");
await writeFile(path, bytes);
const entrypoint = join(root, "scripts", "rea.mjs");
const env = {
...process.env,
TMPDIR: runtime,
TMP: runtime,
TEMP: runtime,
REA_LOG_LEVEL: "silent",
REA_ANALYSIS_PROVIDER: "ghidra",
REA_PROCESS_RUN_ID: run.run_id,
};
const transport = new StdioClientTransport({
command: process.execPath,
args: [entrypoint, "mcp"],
env,
stderr: "pipe",
});
const client = new Client({ name: "rea-real-m68k-proof", version: "1" });
let stderr = "";
transport.stderr?.on("data", (chunk) => {
stderr = (stderr + chunk.toString()).slice(-65536);
});
let report;
try {
await client.connect(transport);
const opened = await call("open_binary", {
path,
format: "raw-binary",
architecture: "m68k",
entry_point: "0x1000",
provider_id: "ghidra",
});
assert.equal(opened.architecture, "m68k");
assert.equal(opened.entry_point, "0x1000");
const status = await call("binary_session");
const image = evidence(await call("inspect_native_load_image"));
assert.equal(
image.normalized_result.status,
"verified",
JSON.stringify(image.normalized_result),
);
assert.equal(image.normalized_result.architecture, "m68k");
assert.equal(image.normalized_result.base_address, "0x1000");
assert.equal(image.normalized_result.language_id, "68000:BE:32:default");
assert.equal(image.normalized_result.compiler_spec_id, "default");
assert.equal(image.normalized_result.file_bytes, bytes.length);
assert.equal(image.analysis_profile.parameters.loader, "BinaryLoader");
assert.equal(
image.analysis_profile.parameters.language_id,
"68000:BE:32:default",
);
assert.equal(image.analysis_profile.parameters.compiler_spec_id, "default");
assert.equal(image.analysis_profile.parameters.entry_point, "0x1000");
const memory = evidence(
await call("read_bytes", { address: "0x1000", length: bytes.length }),
);
assert.equal(memory.normalized_result.bytes_hex, bytes.toString("hex"));
const procedures = evidence(await call("list_procedures"));
assert.deepEqual(
procedures.normalized_result.map((procedure) => procedure.address).sort(),
["0x1000", "0x1012"],
);
const instruction = evidence(
await call("inspect_native_instruction", { address: "0x1002" }),
);
assert.equal(instruction.normalized_result.mnemonic, "bsr.b");
assert.equal(instruction.normalized_result.flow.kind, "call");
assert.deepEqual(instruction.normalized_result.flow.direct_destinations, [
"0x1012",
]);
assert.ok(
instruction.normalized_result.references.some(
(reference) =>
reference.call &&
reference.type === "UNCONDITIONAL_CALL" &&
reference.target_address === "0x1012",
),
);
const pseudo = evidence(
await call("procedure_pseudo_code", { procedure: "0x1012" }),
);
assert.match(pseudo.normalized_result, /\+\s*(?:3|0x3)\b/u);
const caller = evidence(
await call("procedure_pseudo_code", { procedure: "0x1000" }),
);
assert.match(caller.normalized_result, /==\s*(?:10|0xa)\b/iu);
assert.match(caller.normalized_result, /return\s+(?:42|0x2a)\s*;/iu);
assert.match(caller.normalized_result, /return\s+0\s*;/u);
const filePseudo = evidence(
await call("procedure_pseudo_code", { procedure: "file-offset:0x12" }),
);
assert.equal(filePseudo.normalized_result, pseudo.normalized_result);
assert.ok(
filePseudo.locations.some(
(location) => location.kind === "file-offset" && location.offset === 18,
),
);
// Rejected selections must not replace the live session.
for (const [entryPoint, reason] of [
["0x1001", /raw-binary m68k entry_point 0x1001 must be 2-byte aligned/u],
[
"0xfffffff0",
/raw-binary mapping from 0xfffffff0 must fit in the m68k address width/u,
],
]) {
const rejected = await client.callTool({
name: "open_binary",
arguments: {
path,
format: "raw-binary",
architecture: "m68k",
entry_point: entryPoint,
},
});
assert.equal(rejected.isError, true);
assert.match(JSON.stringify(rejected.content), reason);
const preserved = await call("binary_session");
assert.equal(preserved.analysis_run.run_id, status.analysis_run.run_id);
assert.equal(preserved.entry_point, "0x1000");
}
await call("close_binary");
await client.ping();
const { stdout } = await exec(
process.execPath,
[
entrypoint,
"inspect-native-load-image",
path,
"--target-format",
"raw-binary",
"--architecture",
"m68k",
"--entry-point",
"0x1000",
"--provider",
"ghidra",
"--json",
],
{ env, timeout: 240000, maxBuffer: 16 * 1024 * 1024 },
);
const cli = evidence(JSON.parse(stdout));
assert.deepEqual(cli.normalized_result, image.normalized_result);
assert.equal(cli.analysis_profile.digest, image.analysis_profile.digest);
const fileCli = await exec(
process.execPath,
[
entrypoint,
"decompile",
path,
"--file-offset=0x12",
"--target-format",
"raw-binary",
"--architecture",
"m68k",
"--entry-point",
"0x1000",
"--provider",
"ghidra",
"--json",
],
{ env, timeout: 240000, maxBuffer: 16 * 1024 * 1024 },
);
const fileCliEvidence = evidence(JSON.parse(fileCli.stdout));
assert.equal(fileCliEvidence.normalized_result, filePseudo.normalized_result);
assert.deepEqual(fileCliEvidence.locations, filePseudo.locations);
assert.deepEqual(await readFile(path), bytes);
// Ghidra projects end with their sessions; on macOS the server keeps its
// process-token helper under TMPDIR until shutdown, checked after close.
assert.deepEqual(
(await readdir(runtime)).filter((name) => name.startsWith("rea-ghidra-")),
[],
"Owned Ghidra runtime survived close",
);
report = {
ok: true,
mocked: false,
lane: "ghidra-m68k",
host: process.platform,
provider: image.provider,
target: {
sha256,
bytes: bytes.length,
architecture: "m68k",
entry: "0x1000",
},
cli_mcp_load_image_parity: true,
instructions_and_call_reference: true,
decompilation: true,
file_offset_decompilation_cli_mcp: true,
misaligned_entry_preserves_session: true,
invalid_mapping_preserves_session: true,
source_unchanged: true,
runtime_cleanup: "complete",
};
} catch (cause) {
if (stderr) process.stderr.write(stderr);
throw cause;
} finally {
try {
await client.close();
} finally {
await transport.close();
}
}
assert.deepEqual(
await readdir(runtime),
[],
`Runtime resources survived server shutdown; retained workspace: ${workspace}`,
);
const verifierRun = await completeVerifierRun(run);
assert.equal(verifierRun.process_lineage.status, "verified");
assert.deepEqual(verifierRun.process_lineage.descendants, []);
// Keep the workspace for diagnosis unless verification and cleanup both pass.
await rm(workspace, { recursive: true, force: true });
process.stdout.write(
JSON.stringify({ verifier_run: verifierRun, ...report }) + "\n",
);
async function call(name, args = {}) {
const result = await client.callTool(
{ name, arguments: args },
{ timeout: 240000 },
);
assert.notEqual(result.isError, true, JSON.stringify(result));
if (["open_binary", "binary_session", "close_binary"].includes(name))
return requireMcpOperationResult(result, name);
return result.structuredContent;
}
function evidence(value) {
const result = parseEvidence(value);
assert.equal(result.subject.digest.sha256, sha256);
assert.equal(result.subject.architecture, "m68k");
assert.equal(result.subject.format, "raw-binary");
return result;
}
+11 -4
View File
@@ -6,17 +6,24 @@ import { z } from "zod";
import { toolContract } from "./toolContracts.js";
import { toolOutputSchemaWithMetadata } from "./toolSchemaMetadata.js";
import { RAW_ONLY_ARCHITECTURES } from "../domain/binaryTargetTypes.js";
import { createEvidence } from "../domain/evidence.js";
it.each(["draft-2020-12", "draft-07"] as const)(
"advertises the raw-only 6502 Evidence subject constraint in %s",
(target) => {
it.each(
(["draft-2020-12", "draft-07"] as const).flatMap((target) =>
RAW_ONLY_ARCHITECTURES.map(
(architecture) => [target, architecture] as const,
),
),
)(
"advertises the raw-only Evidence subject constraint in %s for %s",
(target, architecture) => {
const valid = createEvidence(
{
path: "/fixture/image.bin",
sha256: "a".repeat(64),
format: "raw-binary",
architecture: "6502",
architecture,
},
{ id: "fixture", name: "Fixture", version: "1" },
{ operation: "address_name", parameters: {}, result: "entry" },
+8 -2
View File
@@ -8,7 +8,10 @@ import {
managedMemberSummaryViewResultSchema,
} from "../domain/analysisView/analysisView.js";
import { evidenceBundleSummarySchema } from "../domain/evidenceRecordSummary.js";
import { BINARY_ARCHITECTURES } from "../domain/binaryTargetTypes.js";
import {
BINARY_ARCHITECTURES,
RAW_ONLY_ARCHITECTURES,
} from "../domain/binaryTargetTypes.js";
import {
binaryArchitectureSchema,
rawBinaryEntryPointSchema,
@@ -465,7 +468,10 @@ const lifecycleTargetFields = {
const detectedLifecycleTargetSchema = z.object({
...lifecycleTargetFields,
format: targetFormatSchema.exclude(["raw-binary"]),
architecture: z.enum(BINARY_ARCHITECTURES).exclude(["6502"]).nullable(),
architecture: z
.enum(BINARY_ARCHITECTURES)
.exclude([...RAW_ONLY_ARCHITECTURES])
.nullable(),
});
const rawLifecycleTargetSchema = z.object({
...lifecycleTargetFields,
+67 -58
View File
@@ -17,7 +17,10 @@ import {
} from "./analysisSnapshot.fixture.js";
import { createEvidence } from "./evidence.js";
import { createEvidenceBundle } from "./evidenceBundle.js";
import type { BinaryTarget } from "./binaryTargetTypes.js";
import {
RAW_ONLY_ARCHITECTURES,
type BinaryTarget,
} from "./binaryTargetTypes.js";
const snapshotWithHistoricalEvidence = () => {
const target = snapshotTarget(ANALYSIS_SNAPSHOT_TARGET);
@@ -71,26 +74,29 @@ const snapshotWithHistoricalEvidence = () => {
};
describe("analysis snapshot target identity", () => {
it("keeps 6502 confined to executable raw snapshots", () => {
const target = {
sha256: ANALYSIS_SNAPSHOT_TARGET.sha256,
kind: "executable",
architecture: "6502",
};
expect(
analysisSnapshotSchema.shape.target.safeParse({
...target,
format: "pe",
}).success,
).toBe(false);
expect(
analysisSnapshotSchema.shape.target.safeParse({
...target,
format: "raw-binary",
entry_point: "0x8001",
}).success,
).toBe(true);
});
it.each(RAW_ONLY_ARCHITECTURES)(
"keeps %s confined to executable raw snapshots",
(architecture) => {
const target = {
sha256: ANALYSIS_SNAPSHOT_TARGET.sha256,
kind: "executable",
architecture,
};
expect(
analysisSnapshotSchema.shape.target.safeParse({
...target,
format: "pe",
}).success,
).toBe(false);
expect(
analysisSnapshotSchema.shape.target.safeParse({
...target,
format: "raw-binary",
entry_point: "0x8000",
}).success,
).toBe(true);
},
);
it("partitions raw snapshots and query identities by the canonical entry", () => {
const target: BinaryTarget = {
@@ -142,45 +148,48 @@ describe("analysis snapshot target identity", () => {
});
describe("analysis snapshot nested subject contract", () => {
it("rejects raw-only 6502 in nested detected execution subjects", () => {
const { snapshot } = snapshotWithHistoricalEvidence();
const entry = snapshot.entries[0];
if (entry === undefined) throw new Error("Fixture has no snapshot entry");
const malformedSubject = {
...entry.execution.subject,
format: "pe",
architecture: "6502",
};
expect(
analysisSnapshotSchema.safeParse({
...snapshot,
entries: [
{
...entry,
execution: {
...entry.execution,
subject: malformedSubject,
it.each(RAW_ONLY_ARCHITECTURES)(
"rejects raw-only %s in nested detected execution subjects",
(architecture) => {
const { snapshot } = snapshotWithHistoricalEvidence();
const entry = snapshot.entries[0];
if (entry === undefined) throw new Error("Fixture has no snapshot entry");
const malformedSubject = {
...entry.execution.subject,
format: "pe",
architecture,
};
expect(
analysisSnapshotSchema.safeParse({
...snapshot,
entries: [
{
...entry,
execution: {
...entry.execution,
subject: malformedSubject,
},
},
},
],
}).success,
).toBe(false);
expect(
analysisSnapshotSchema.safeParse({
...snapshot,
workflow_entries: [
{
...entry,
execution: {
...entry.execution,
analysis_profile: snapshot.binding.analysis_profile,
subject: malformedSubject,
],
}).success,
).toBe(false);
expect(
analysisSnapshotSchema.safeParse({
...snapshot,
workflow_entries: [
{
...entry,
execution: {
...entry.execution,
analysis_profile: snapshot.binding.analysis_profile,
subject: malformedSubject,
},
},
},
],
}).success,
).toBe(false);
});
],
}).success,
).toBe(false);
},
);
});
describe("analysis snapshot contract", () => {
+7 -2
View File
@@ -17,6 +17,7 @@ import {
} from "./analysisProfile.js";
import {
BINARY_ARCHITECTURES,
RAW_ONLY_ARCHITECTURES,
type BinaryTarget,
} from "./binaryTargetTypes.js";
import {
@@ -93,7 +94,9 @@ const subjectFormatSchema = z.enum([
const subjectSchema = z.discriminatedUnion("format", [
subjectBaseSchema.extend({
format: subjectFormatSchema.exclude(["raw-binary"]),
architecture: architectureSchema.exclude(["6502"]).nullable(),
architecture: architectureSchema
.exclude([...RAW_ONLY_ARCHITECTURES])
.nullable(),
}),
subjectBaseSchema.extend({
format: z.literal("raw-binary"),
@@ -110,7 +113,9 @@ const targetSchema = z.discriminatedUnion("format", [
format: z.enum(
formatSchema.options.filter((format) => format !== "raw-binary"),
),
architecture: architectureSchema.exclude(["6502"]).nullable(),
architecture: architectureSchema
.exclude([...RAW_ONLY_ARCHITECTURES])
.nullable(),
}),
targetIdentitySchema.extend({
format: z.literal("raw-binary"),
+2 -1
View File
@@ -3,6 +3,7 @@ import type {
ElfFamilyMetadata,
HeaderArchitecture,
MipsElfMetadata,
RawOnlyArchitecture,
PeCodeViewRecord,
RiscvElfMetadata,
PowerPcElfMetadata,
@@ -545,7 +546,7 @@ const machArchitecture = (cpu: number): HeaderArchitecture | undefined => {
const elfArchitecture = (
machine: number,
): Exclude<BinaryArchitecture, "6502"> | undefined => {
): Exclude<BinaryArchitecture, RawOnlyArchitecture> | undefined => {
switch (machine) {
case 3:
return "x86";
+12 -1
View File
@@ -10,9 +10,20 @@ export const BINARY_ARCHITECTURES = [
"6502",
"riscv",
"powerpc",
"m68k",
] as const;
export type BinaryArchitecture = (typeof BINARY_ARCHITECTURES)[number];
/**
* Families admitted only by explicit raw-binary selection. Header detection
* never yields them, so detected targets and Evidence subjects exclude them.
*/
export const RAW_ONLY_ARCHITECTURES = [
"6502",
"m68k",
] as const satisfies readonly BinaryArchitecture[];
export type RawOnlyArchitecture = (typeof RAW_ONLY_ARCHITECTURES)[number];
/**
* ELF header facts for a family whose ISA and ABI are not established by the
* family name alone. Consumers admit a lane from these declarations rather than
@@ -51,7 +62,7 @@ export const isElfMetadataArchitecture = (
/** Recognized family with no ELF metadata member; the header alone is the fact. */
export type HeaderArchitecture = Exclude<
BinaryArchitecture,
ElfMetadataArchitecture | "6502"
ElfMetadataArchitecture | RawOnlyArchitecture
>;
/** Raw ELF facts; family recognition does not establish ISA or ABI support. */
+33 -27
View File
@@ -2,7 +2,10 @@ import { fc, it } from "@fast-check/vitest";
import { describe, expect } from "vitest";
import { createAnalysisProfile } from "./analysisProfile.js";
import type { BinaryTarget } from "./binaryTargetTypes.js";
import {
RAW_ONLY_ARCHITECTURES,
type BinaryTarget,
} from "./binaryTargetTypes.js";
import { createEvidence, evidenceSchema, parseEvidence } from "./evidence.js";
import { MAX_JSON_DEPTH, type JsonValue } from "./jsonValue.js";
import { createEvidenceBundle } from "./evidenceBundle.js";
@@ -19,32 +22,35 @@ const TARGET: BinaryTarget = {
const PROVIDER = { id: "fixture", name: "Fixture provider", version: "1" };
const PROFILE = createAnalysisProfile(PROVIDER, { loader: "default" });
it("rejects a detected Evidence subject with the raw-only 6502 architecture", () => {
const selected = {
path: TARGET.path,
sha256: TARGET.sha256,
format: "raw-binary" as const,
architecture: "6502" as const,
};
expect(
createEvidence(selected, PROVIDER, {
operation: "inspect",
parameters: {},
result: null,
}).subject,
).toMatchObject({ format: "raw-binary", architecture: "6502" });
const malformed = {
...selected,
format: "pe" as const,
};
expect(() =>
createEvidence(malformed, PROVIDER, {
operation: "inspect",
parameters: {},
result: null,
}),
).toThrow();
});
it.each(RAW_ONLY_ARCHITECTURES)(
"rejects a detected Evidence subject with the raw-only %s architecture",
(architecture) => {
const selected = {
path: TARGET.path,
sha256: TARGET.sha256,
format: "raw-binary" as const,
architecture,
};
expect(
createEvidence(selected, PROVIDER, {
operation: "inspect",
parameters: {},
result: null,
}).subject,
).toMatchObject({ format: "raw-binary", architecture });
const malformed = {
...selected,
format: "pe" as const,
};
expect(() =>
createEvidence(malformed, PROVIDER, {
operation: "inspect",
parameters: {},
result: null,
}),
).toThrow();
},
);
it("preserves omitted relationship diagnostics and authenticates reported diagnostics", () => {
const observation = {
+5 -1
View File
@@ -11,6 +11,7 @@ import {
} from "./analysisProfile.js";
import {
BINARY_ARCHITECTURES,
RAW_ONLY_ARCHITECTURES,
type BinaryArchitecture,
type BinaryTarget,
} from "./binaryTargetTypes.js";
@@ -77,7 +78,10 @@ const subjectBaseSchema = z.strictObject({
const subjectSchema = z.discriminatedUnion("format", [
subjectBaseSchema.extend({
format: subjectFormatSchema.exclude(["raw-binary"]),
architecture: z.enum(BINARY_ARCHITECTURES).exclude(["6502"]).nullable(),
architecture: z
.enum(BINARY_ARCHITECTURES)
.exclude([...RAW_ONLY_ARCHITECTURES])
.nullable(),
}),
subjectBaseSchema.extend({
format: z.literal("raw-binary"),
+54
View File
@@ -151,6 +151,7 @@ describe("raw-binary admission", () => {
"missing architecture",
{ architecture: undefined, entryPoint: "0x8000000" },
],
["mismatched family name", { architecture: "68000", entryPoint: "0x1000" }],
])("classifies a %s as caller input", (_name, selection) => {
const result = admitRawBinary({ ...selection, fileBytes: 4 });
expect(result.ok).toBe(false);
@@ -171,6 +172,59 @@ describe("raw-binary admission", () => {
);
});
describe("m68k raw-binary admission", () => {
it("admits even m68k entries up to the 32-bit address boundary", () => {
for (const [entryPoint, fileBytes] of [
["0x0", 8],
["0x1000", 8],
["0xfffffffe", 2],
] as const) {
const result = admitRawBinary({
architecture: "m68k",
entryPoint,
fileBytes,
});
if (!result.ok) throw new Error(result.error.reason);
expect(result.value).toEqual({
architecture: "m68k",
entryPoint,
entryPointSource: entryPoint,
});
}
});
it.each(["0x1", "0x1001", "0x1003", "0xffffffff"])(
"rejects the odd m68k entry %s before provider selection",
(entryPoint) => {
expect(
admitRawBinary({ architecture: "m68k", entryPoint, fileBytes: 2 }),
).toEqual({
ok: false,
error: {
reason: `raw-binary m68k entry_point ${entryPoint} must be 2-byte aligned`,
constraint: "load_selection_rejected",
},
});
},
);
it.each([
["0x100000000", 2],
["0xfffffffe", 4],
])(
"rejects an m68k mapping at %s with %i bytes that exceeds 32 bits",
(entryPoint, fileBytes) => {
expect(
admitRawBinary({ architecture: "m68k", entryPoint, fileBytes }),
).toEqual({
ok: false,
error: {
reason: `raw-binary mapping from ${entryPoint} must fit in the m68k address width`,
constraint: "load_selection_rejected",
},
});
},
);
});
describe("raw-binary selection pairing", () => {
it("requires architecture and entry with format=raw-binary", () => {
expect(rawBinarySelectionError({ format: "raw-binary" })).toBe(
+13 -3
View File
@@ -3,6 +3,7 @@ import { z } from "zod";
import type {
BinaryArchitecture,
HeaderArchitecture,
RawOnlyArchitecture,
} from "./binaryTargetTypes.js";
import type { ExecutableFormatHint } from "./dosCom.js";
import { err, ok, type Result } from "./result.js";
@@ -14,6 +15,7 @@ export const BINARY_ARCHITECTURES = [
"arm",
"arm64",
"6502",
"m68k",
] as const satisfies readonly BinaryArchitecture[];
/** Provider-neutral CPU family for a caller-supplied raw-binary load. */
@@ -59,7 +61,7 @@ export const validateRawBinaryLength = (
/** Admitted headerless load identity used by target resolution and profiles. */
export interface RawBinaryAdmission {
readonly architecture: HeaderArchitecture | "6502";
readonly architecture: HeaderArchitecture | RawOnlyArchitecture;
readonly entryPoint: string;
readonly entryPointSource: string;
}
@@ -77,7 +79,9 @@ export interface RawBinaryAdmissionRejection {
const addressWidth = (architecture: BinaryArchitecture): bigint =>
architecture === "6502"
? 0xffffn
: architecture === "x86" || architecture === "arm"
: architecture === "x86" ||
architecture === "arm" ||
architecture === "m68k"
? 0xffffffffn
: 0xffffffffffffffffn;
@@ -91,7 +95,7 @@ export const admitRawBinary = (input: {
if (!architecture.success)
return err({
reason:
"raw-binary format requires architecture (x86, x86_64, arm, arm64, or 6502)",
"raw-binary format requires architecture (x86, x86_64, arm, arm64, 6502, or m68k)",
constraint: "load_selection_rejected",
});
if (typeof input.entryPoint !== "string")
@@ -114,6 +118,12 @@ export const admitRawBinary = (input: {
reason: `raw-binary ${architecture.data} entry_point ${entry.value.canonical} must be 4-byte aligned`,
constraint: "load_selection_rejected",
});
// 68000-family instruction words are fetched from even addresses.
if (architecture.data === "m68k" && entry.value.value % 2n !== 0n)
return err({
reason: `raw-binary m68k entry_point ${entry.value.canonical} must be 2-byte aligned`,
constraint: "load_selection_rejected",
});
const length = validateRawBinaryLength(input.fileBytes);
if (!length.ok)
return err({
+2 -2
View File
@@ -453,10 +453,10 @@ export const attestGhidraRawBinaryLoadImage = (
checks,
limitations: [
"Raw binary is a headerless interpretation explicitly selected by the caller, not format identification from file bytes.",
"Verification covers complete original/modified source identity, initialized and loaded non-overlay file bytes in the default address space, the caller-supplied entry, and the bound little-endian Ghidra language. File offset 0 is mapped at the entry address.",
"Verification covers complete original/modified source identity, initialized and loaded non-overlay file bytes in the default address space, the caller-supplied entry, and the bound Ghidra language with its fixed byte order. File offset 0 is mapped at the entry address.",
"Ghidra may report additional language-defined external entries outside the mapped image. They are preserved as observations; verification requires the caller-supplied entry and does not attest other entries.",
"Language-defined uninitialized memory, such as 6502 zero page and stack, is preserved as observations but is not attested as file-backed bytes.",
"Reset vectors, memory-mapped I/O, big-endian and Thumb-only variants, and runtime boot behavior are not modeled or verified. No original executable bytes are changed.",
"Reset vectors, memory-mapped I/O, alternate-endian and Thumb-only variants, and runtime boot behavior are not modeled or verified. No original executable bytes are changed.",
"Mapping ends are inclusive; a mismatching digest identifies its source range, not the first differing byte.",
],
}),
@@ -338,6 +338,37 @@ describe("Ghidra raw-binary profile", () => {
const windows = provider(installationHost("win32"));
expect(windows.inspectTargetSupport(target).status).toBe("unsupported");
});
it("binds raw m68k to the big-endian 68000 language outside Windows P0", async () => {
const target: BinaryTarget = {
path: "/tmp/m68k.bin",
sha256: "a".repeat(64),
kind: "executable",
format: "raw-binary",
architecture: "m68k",
availableArchitectures: ["m68k"],
entryPoint: "0x1000",
entryPointSource: "0x00001000",
};
const ghidra = provider();
expect(ghidra.inspectTargetSupport(target).status).toBe("supported");
const resolved = await ghidra.resolveAnalysisProfile(target);
if (!resolved.ok) throw resolved.error;
expect(resolved.value.profile?.parameters).toMatchObject({
target_format: "raw-binary",
architecture: "m68k",
loader: "BinaryLoader",
language_id: "68000:BE:32:default",
compiler_spec_id: "default",
language_selection: "caller-supplied-v1",
entry_point: "0x1000",
});
expect(
provider(installationHost("win32")).inspectTargetSupport(target),
).toMatchObject({
status: "unsupported",
code: "target_format_unsupported",
});
});
});
describe("Ghidra Mach-O slice support", () => {
+2 -1
View File
@@ -73,6 +73,7 @@ const SUPPORTED_ARCHITECTURES = new Set([
"6502",
"riscv",
"powerpc",
"m68k",
]);
/** Ghidra candidate backed by an isolated ephemeral headless import. */
@@ -211,7 +212,7 @@ export class GhidraProvider implements AnalysisProviderCandidate {
status: "unsupported",
code: "architecture_unsupported",
reason:
"Ghidra v1 requires a supported x86, x86_64, arm, arm64, MIPS, RISC-V, or raw 6502 target profile.",
"Ghidra v1 requires a supported x86, x86_64, arm, arm64, MIPS, RISC-V, raw 6502, or raw m68k target profile.",
diagnostics,
};
return {
+1 -1
View File
@@ -548,7 +548,7 @@ export const createGhidraProviderClient = (input: {
...(target.format === "raw-binary"
? [
"Headerless raw-binary analysis uses the caller-supplied architecture and entry address; file offset 0 is mapped at that address. Headers are not discovered from file bytes.",
"Little-endian generic Ghidra languages are used for x86, x86_64, arm, arm64, and NMOS 6502. Big-endian, Thumb-only, Cortex-M, 65C02, 65816, and other processor variants are unsupported in this increment.",
"Little-endian generic Ghidra languages are used for x86, x86_64, arm, arm64, and NMOS 6502; m68k uses the big-endian 68000 default language, which decodes the 68040 superset. Big-endian ARM, Thumb-only, Cortex-M, 65C02, 65816, separate 68020/68030/CPU32/ColdFire variants, and other processor variants are unsupported in this increment.",
"Static analysis does not emulate reset vectors, memory-mapped I/O, or firmware boot sequences.",
]
: []),
+13 -4
View File
@@ -8,10 +8,11 @@ export interface GhidraRawBinaryLanguage {
}
/**
* Map caller-visible CPU families to little-endian generic Ghidra
* languages with a supported generic compiler specification. These are
* analysis conventions, not evidence of the target's original compiler.
* Big-endian, Thumb-only, and Cortex-M variants are out of scope.
* Map caller-visible CPU families to generic Ghidra languages with a
* supported generic compiler specification. These are analysis conventions,
* not evidence of the target's original compiler. Each family has one fixed
* byte order: m68k is big-endian and the others are little-endian. Big-endian
* ARM, Thumb-only, Cortex-M, and other processor variants are out of scope.
*/
export const ghidraRawBinaryLanguage = (
architecture: RawBinaryAdmission["architecture"],
@@ -23,6 +24,14 @@ export const ghidraRawBinaryLanguage = (
compilerSpecId: "default",
defaultAddressSpace: "RAM",
};
case "m68k":
// Ghidra's default 68000 language decodes the 68040 superset; no
// MC68000-only variant is declared in the 68000 processor module.
return {
languageId: "68000:BE:32:default",
compilerSpecId: "default",
defaultAddressSpace: "ram",
};
case "x86":
return {
languageId: "x86:LE:32:default",
@@ -5,6 +5,13 @@ import { fixtureRawBinaryLoadImage } from "./GhidraRawBinaryLoadImage.fixture.js
import { ghidraRawBinaryLanguage } from "./GhidraRawBinaryLanguage.js";
describe("raw-binary Ghidra language binding", () => {
it("maps the big-endian m68k family to the default 68000 language", () => {
expect(ghidraRawBinaryLanguage("m68k")).toEqual({
languageId: "68000:BE:32:default",
compilerSpecId: "default",
defaultAddressSpace: "ram",
});
});
it("maps the four admitted families to little-endian generic languages", () => {
expect(ghidraRawBinaryLanguage("x86")).toEqual({
languageId: "x86:LE:32:default",
@@ -68,6 +75,45 @@ describe("raw-binary mapped-block state", () => {
});
},
);
it.each(["ram", "RAM"])(
"requires the bound m68k address space for %s observations",
(space) => {
const f = fixtureRawBinaryLoadImage();
const target = {
...f.target,
architecture: "m68k" as const,
availableArchitectures: ["m68k" as const],
entryPoint: "0x1000",
entryPointSource: "0x1000",
};
f.observation.language_id = "68000:BE:32:default";
f.observation.default_address_space = space;
f.observation.entry_points = [target.entryPoint];
const mapping = f.observation.mappings[0];
if (mapping === undefined)
throw new Error("Missing source mapping fixture");
mapping.start = target.entryPoint;
mapping.end = "0x1003";
mapping.address_space = space;
expect(
attestGhidraRawBinaryLoadImage(f.snapshot, target, f.observation),
).toMatchObject({
ok: true,
value: {
status: space === "ram" ? "verified" : "mismatch",
language_id: "68000:BE:32:default",
checks: expect.arrayContaining([
expect.objectContaining({
name: "default_address_space",
expected: "ram",
observed: space,
matched: space === "ram",
}),
]),
},
});
},
);
it.each([
["initialized", false],
["loaded", false],
+10 -7
View File
@@ -68,12 +68,15 @@ cliTest(
},
);
cliTest(
"rejects an unaligned AArch64 entry before provider startup",
async ({ cli }) => {
cliTest.for([
["arm64", "c0035fd6", "0x8000001", "must be 4-byte aligned"],
["m68k", "4e75", "0x1001", "must be 2-byte aligned"],
] as const)(
"rejects an unaligned %s entry before provider startup",
async ([architecture, bytes, entryPoint, reason], { cli }) => {
const root = await createTestTempDirectory("rea-raw-cli-alignment-");
const path = join(root, "image.bin");
await writeFile(path, Buffer.from("c0035fd6", "hex"));
await writeFile(path, Buffer.from(bytes, "hex"));
const result = await cli.run({
arguments: [
"inspect",
@@ -81,9 +84,9 @@ cliTest(
"--target-format",
"raw-binary",
"--architecture",
"arm64",
architecture,
"--entry-point",
"0x8000001",
entryPoint,
"--provider",
"ghidra",
"--json",
@@ -93,7 +96,7 @@ cliTest(
expect(result.exitCode).toBe(1);
expect(result.json).toMatchObject({
code: "invalid_request",
details: { reason: expect.stringContaining("must be 4-byte aligned") },
details: { reason: expect.stringContaining(reason) },
});
},
);
+65 -54
View File
@@ -20,6 +20,7 @@ import {
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { silentLogger } from "../../../src/logger.js";
import { GHIDRA_PROVIDER_IDENTITY } from "../../../src/ghidra/GhidraProviderCapabilities.js";
import { RAW_ONLY_ARCHITECTURES } from "../../../src/domain/binaryTargetTypes.js";
import type { CallToolResult } from "@modelcontextprotocol/server";
const connect = async (
@@ -85,38 +86,42 @@ const withGhidraFixtureIdentity = (
};
};
it("keeps 6502 out of detected lifecycle output contracts", () => {
const schema = toolContract("open_binary").outputSchema;
const result = {
path: "/fixture/image.bin",
kind: "executable",
sha256: "a".repeat(64),
architecture: "6502",
};
expect(
schema.safeParse({ result: { ...result, format: "pe" } }).success,
).toBe(false);
expect(
schema.safeParse({
result: { ...result, format: "raw-binary", entry_point: "0x8001" },
}).success,
).toBe(true);
const validateAdvertised = new Ajv2020({ strict: false }).compile(
z.toJSONSchema(schema),
);
expect(validateAdvertised({ result: { ...result, format: "pe" } })).toBe(
false,
);
expect(
validateAdvertised({
result: { ...result, format: "raw-binary", entry_point: "0x8001" },
}),
).toBe(true);
});
it.each(RAW_ONLY_ARCHITECTURES)(
"keeps %s out of detected lifecycle output contracts",
(architecture) => {
const schema = toolContract("open_binary").outputSchema;
const result = {
path: "/fixture/image.bin",
kind: "executable",
sha256: "a".repeat(64),
architecture,
};
expect(
schema.safeParse({ result: { ...result, format: "pe" } }).success,
).toBe(false);
expect(
schema.safeParse({
result: { ...result, format: "raw-binary", entry_point: "0x8000" },
}).success,
).toBe(true);
const validateAdvertised = new Ajv2020({ strict: false }).compile(
z.toJSONSchema(schema),
);
expect(validateAdvertised({ result: { ...result, format: "pe" } })).toBe(
false,
);
expect(
validateAdvertised({
result: { ...result, format: "raw-binary", entry_point: "0x8000" },
}),
).toBe(true);
},
);
it.each([
["arm", "0x08000000", "0x8000000", "1eff2fe1"],
["6502", "0x008001", "0x8001", "a92a60"],
["m68k", "0x00001000", "0x1000", "4e714e7170014e75"],
])(
"opens a headerless %s image when architecture and entry are supplied",
async (architecture, entryPoint, canonical, bytes) => {
@@ -144,32 +149,38 @@ it.each([
},
);
it("rejects an unaligned AArch64 entry before selecting or starting a provider", async () => {
const root = await createTestTempDirectory("rea-raw-mcp-alignment-");
const path = join(root, "image.bin");
await writeFile(path, Buffer.from("c0035fd6", "hex"));
const { client, session } = await connect({
...createCacheProvider([]),
resolveAnalysisProfile: () => {
throw new Error("unaligned input must not select a provider");
},
createClient: () => {
throw new Error("unaligned input must not launch a provider");
},
});
const opened = await client.callTool({
name: "open_binary",
arguments: {
path,
format: "raw-binary",
architecture: "arm64",
entry_point: "0x8000001",
},
});
expect(opened.isError).toBe(true);
expect(JSON.stringify(opened.content)).toContain("must be 4-byte aligned");
expect(session.activeTarget()).toBeUndefined();
});
it.each([
["arm64", "c0035fd6", "0x8000001", "must be 4-byte aligned"],
["m68k", "4e75", "0x1001", "must be 2-byte aligned"],
])(
"rejects an unaligned %s entry before selecting or starting a provider",
async (architecture, bytes, entryPoint, reason) => {
const root = await createTestTempDirectory("rea-raw-mcp-alignment-");
const path = join(root, "image.bin");
await writeFile(path, Buffer.from(bytes, "hex"));
const { client, session } = await connect({
...createCacheProvider([]),
resolveAnalysisProfile: () => {
throw new Error("unaligned input must not select a provider");
},
createClient: () => {
throw new Error("unaligned input must not launch a provider");
},
});
const opened = await client.callTool({
name: "open_binary",
arguments: {
path,
format: "raw-binary",
architecture,
entry_point: entryPoint,
},
});
expect(opened.isError).toBe(true);
expect(JSON.stringify(opened.content)).toContain(reason);
expect(session.activeTarget()).toBeUndefined();
},
);
it("rejects raw-binary open requests that omit architecture", async () => {
const root = await createTestTempDirectory("rea-raw-mcp-missing-");