feat(ghidra): support raw NMOS 6502 analysis (#2079)

* feat(ghidra): admit raw NMOS 6502 analysis

* test(ghidra): require verified POSIX verifier cleanup

* test(ghidra): verify 6502 low-memory raw imports

* docs(skill): document raw NMOS 6502 support

* fix(ghidra): preserve requested Windows raw source paths

* docs(ghidra): include raw 6502 in Windows installation scope

* test(ghidra): verify Windows raw junction rejection through CLI and MCP

* fix(validation): make fixtures portable and preserve explicit Codex home

* fix(ghidra): attest raw mapping state and address space

* fix(ghidra): align diagnostics and regression groups

* fix(ghidra): bind raw attestation to language address space

* chore(ghidra): avoid verifier insertion conflict

* docs(ghidra): align linked raw admission guides

* fix(ghidra): retain 6502 verifier workspace on failure

* test(ghidra): isolate Windows authority doctor fixture

* fix(binary): re-admit Windows raw paths on session reopen

* test(binary): require fresh provider execution after path change

* Constrain 6502 to raw lifecycle and snapshot targets

* Bypass saved reads for unadmitted Windows paths

* Guard direct CLI snapshot replay on Windows paths

* fix(snapshot): gate Windows Ghidra replay on native admission

* fix(snapshot): constrain nested 6502 subjects to raw images

* test: make dyld slice fixtures portable
This commit is contained in:
记得吃中饭 2026-10-13 21:13:01 +08:00 committed by GitHub
parent 352c46aa75
commit 9c14999efd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
54 changed files with 1650 additions and 197 deletions

View File

@ -224,10 +224,13 @@ 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`, or `arm64`), and `entry_point` (`0x` hex). File offset 0 is mapped at
`arm`, `arm64`, or `6502`), 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. See [raw binary guide](https://github.com/morluto/rea/blob/main/docs/raw-binary.md).
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).
### Function annotations in Ghidra

View File

@ -1,6 +1,8 @@
import ghidra.app.script.GhidraScript;
import ghidra.program.model.address.Address;
import ghidra.program.model.listing.Program;
import ghidra.program.model.mem.MemoryBlock;
import ghidra.program.model.mem.MemoryBlockSourceInfo;
/** Seed the caller-supplied raw-binary entry before default auto-analysis. */
public final class ReaGhidraPrepareRaw extends GhidraScript {
@ -21,8 +23,19 @@ public final class ReaGhidraPrepareRaw extends GhidraScript {
);
}
Address entry = currentProgram.getAddressFactory().getDefaultAddressSpace().getAddress(entryHex);
Address start = currentProgram.getMinAddress();
if (entry == null || !entry.equals(start)) {
MemoryBlock block = entry == null ? null : currentProgram.getMemory().getBlock(entry);
boolean fileStart = false;
if (block != null) {
// Processor-defined zero page/stack blocks can precede the imported file.
for (MemoryBlockSourceInfo source : block.getSourceInfos()) {
if (source.getFileBytes().isPresent() && source.getFileBytesOffset() == 0 &&
entry.equals(source.getMinAddress())) {
fileStart = true;
break;
}
}
}
if (!fileStart) {
throw new IllegalArgumentException(
"Raw binary import must map file offset 0 at the caller-supplied entry"
);

View File

@ -174,7 +174,9 @@ rea analyze /absolute/path/to/program --provider ghidra --snapshot /absolute/pat
rea analyze /absolute/path/to/program --provider ghidra --snapshot /absolute/path/to/analysis/program.json
```
An exact CLI cache hit is read before starting a provider process.
An exact CLI cache hit is normally read before starting a provider process.
On Windows, Ghidra first admits the selected source through its native snapshot
owner, so replay can start that provider.
In MCP, `open_binary` accepts `snapshot_path` to import a snapshot atomically
for its matching target; an MCP provider may still start before returning a
cached result. `close_binary` accepts `snapshot_path` and optional

View File

@ -111,7 +111,7 @@ MIPS header and ABI record facts are committed in the analysis profile so
interpretation changes invalidate profile-bound snapshots. The support-lane
identity is versioned independently of file identity. Evidence, lifecycle
outputs and saved snapshots preserve the `mips` family. Hopper and IDA adapters
do not implicitly gain MIPS support. Windows Ghidra P0 remains PE x86/x86-64 only.
do not implicitly gain MIPS support. Windows Ghidra P0 does not admit MIPS targets.
### Soft-float prototype

View File

@ -11,8 +11,8 @@ ELF `EM_RISCV` (243) is retained as the provider-neutral `riscv` family. The
target also retains the raw ELF declarations that decide a lane: class, byte
order, `e_type`, and `e_flags`. Recognition does not mean every provider or
RISC-V variant is supported. Existing PE, managed and Mach-O architecture sets
are unchanged, and caller-supplied headerless raw-binary entry still accepts only
its existing four families.
are unchanged. Headerless inputs use the separate [raw-binary admission
boundary](raw-binary.md); raw RISC-V images are not supported.
The Ghidra lane admits little-endian ELF32 and ELF64 `ET_EXEC` images that
declare `EF_RISCV_FLOAT_ABI_DOUBLE` in `e_flags`, with no `EF_RISCV_RVE`,

View File

@ -512,7 +512,10 @@ and `set_addresses_names` name a function entry or any other mapped address
session metadata edits leave executable bytes unchanged and are discarded on close. GUI controls
require Hopper; Windows P0 remains read-only.
Windows P0 admits native x86 and x86-64 PE applications and DLLs on fixed local NTFS volumes.
Windows P0 admits native x86 and x86-64 PE applications and DLLs, plus explicitly
selected [raw NMOS 6502 images](raw-binary.md#nmos-6502) with a caller-supplied
entry at file offset 0, on fixed local NTFS volumes. Other raw CPU families remain
unsupported on Windows.
The npm package bundles native Job Object ownership, protected private runtime
DACLs, and handle-based path admission; no separate addon installation is needed.
See the [Windows Ghidra P0 guide](windows-ghidra-p0.md) for verified scope and

View File

@ -476,8 +476,11 @@ the tool fails with a `debugger-attach-denied` reason.
Install a Ghidra 12.1.x release and the 64-bit full JDK it declares, then configure REA to
use them. Ghidra analysis supports Linux x64/arm64 and macOS x64/arm64; each installation requires
the matching native decompiler. Experimental Windows x64 P0 admits native
x86 and x86-64 PE applications and DLLs on local NTFS using bundled Job Object ownership,
protected runtime DACLs, and handle-based path admission. See [Windows Ghidra P0](windows-ghidra-p0.md) and
x86 and x86-64 PE applications and DLLs, plus explicitly selected
[raw NMOS 6502 images](raw-binary.md#nmos-6502) with a caller-supplied load address,
on fixed local NTFS volumes. Other raw CPU families remain unsupported on Windows.
The boundary uses bundled Job Object ownership, protected runtime DACLs, and
handle-based path admission. See [Windows Ghidra P0](windows-ghidra-p0.md) and
[issue #527](https://github.com/morluto/rea/issues/527).
On Linux and macOS, `annotate_native_function` atomically edits a function name
and entry comments in the ephemeral database, returning refreshed analysis

View File

@ -25,10 +25,12 @@ rea inspect-native-load-image /absolute/path/to/ecu.bin --target-format raw-bina
}
```
`architecture` is one of `x86`, `x86_64`, `arm`, or `arm64`. `entry_point` is a
`architecture` is one of `x86`, `x86_64`, `arm`, `arm64`, or `6502`. `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 entries may be byte-aligned.
ARM and AArch64 entries must be 4-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.
`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
@ -37,12 +39,13 @@ Omitted `format` keeps header-based detection; architecture and entry without
Ghidra imports a private snapshot with `BinaryLoader` and these little-endian
generic languages and compiler specifications:
| 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` |
| 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` |
Compiler specifications select analysis conventions; they do not identify the
original compiler or ABI of a headerless target. Ghidra's x86 languages do not
@ -57,11 +60,51 @@ The mapped start is independent of Ghidra's program image base, which remains
`0x0` for BinaryLoader. Ghidra may report additional language-defined entry
points, including unmapped ARM vectors. Load-image verification requires the
caller-supplied entry and preserves those other entries as observations.
For 6502, BinaryLoader maps the file before adding its language-defined zero-page
and stack blocks. Conflicting default blocks are skipped; low-address files stay
in the default address space rather than an overlay. Non-conflicting uninitialized
blocks remain observations, not file-backed bytes.
Independent raw verification streams the complete private snapshot digest and
extent with bounded memory and supports cancellation during the read.
It also requires the source-backed block to be initialized, loaded, and
non-overlay in the bound language's default address space. Contradictory mapping
observations are retained with `mismatch`, even when the source hashes agree.
Hopper and IDA refuse raw-binary targets. Windows Ghidra P0 continues to admit
native PE applications and DLLs only; raw-binary targets are unsupported.
Hopper and IDA refuse raw-binary targets. Windows Ghidra P0 admits native PE
applications/DLLs and explicitly selected raw 6502 images. Other raw CPU families
remain outside the Windows boundary. Native path admission, private DACLs, Job
Object ownership, immutable snapshots, and the no-mutation policy still apply.
## NMOS 6502
```bash
rea function /absolute/path/to/image.bin 0x8001 --target-format raw-binary --architecture 6502 --entry-point 0x8001 --provider ghidra --json
rea inspect-native-load-image /absolute/path/to/image.bin --target-format raw-binary --architecture 6502 --entry-point 0x8001 --provider ghidra --json
```
Select only headerless NMOS 6502 code with a known entry at file offset 0.
The built-in Ghidra processor supplies static instructions, functions,
pseudocode, and references. Evidence preserves `architecture: "6502"`, 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 iNES headers, cartridge mappers, bank switching,
or other console-specific memory layouts. 65C02 and 65816 programs need different
processor semantics and are not admitted as those variants. REA does not infer
the CPU from the file suffix, guess the reset vector, or emulate the target.
`npm run verify:ghidra:6502` assembles and links the source-owned fixture with
`ca65` and `ld65` (tested from cc65 tag `V2.19`, whose tools report `V2.18`).
Set `REA_CA65` and `REA_LD65` to explicit executable paths if needed. The lane
checks real CLI/MCP load-image parity, mapped bytes, decoded calls, decompilation,
invalid mappings, unchanged source bytes and runtime cleanup.
Low-memory checks include a file spanning the zero-page/stack boundary at `0xff`
and a complete 64-KiB image at `0x0`, with verified default-space mappings through
both CLI and MCP. On Windows, the lane also checks that a caller-selected
junction path is rejected at snapshot admission through CLI and MCP, with
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.
## Limitations

View File

@ -734,6 +734,16 @@ truncation constraint, while leading-zero coordinates still resolve correctly.
It has the same Ghidra/JDK prerequisites as the MZ lane. Neither lane claims DOS
runtime or PC-98 device execution.
`npm run verify:ghidra:6502` uses `ca65` and `ld65` to build the source-owned
NMOS 6502 fixture from `tests/conformance/ghidra/6502.s` and `6502.cfg`. Set
`REA_CA65` and `REA_LD65` for non-PATH installations (tested with cc65 `V2.19`).
With an installed Ghidra 12.1.4 and full JDK, it verifies real CLI/stdio MCP
mapping parity, exact source bytes, functions, a direct call reference,
decompilation, 16-bit overflow rejection without replacing the active session,
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: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,

View File

@ -17,14 +17,16 @@ additional permission flag or degraded mode is required.
- Node.js 22.x (>=22.19), 24.x (>=24.11), or 26+;
- an operator-installed Ghidra 12.1.x distribution (verified with 12.1.4);
- a 64-bit full JDK inside that installation's declared Java range (JDK 21 or newer, with no maximum, for current 12.1 releases);
- an explicit native, non-managed x86 or x86-64 PE application or DLL; and
- an explicit native, non-managed x86 or x86-64 PE application/DLL, or a
[raw NMOS 6502 image](raw-binary.md#nmos-6502) with a caller-supplied entry; and
- the 25 read-only Ghidra inventory, memory, and function-analysis operations.
Loaded memory reads and file offsets preserve Ghidra's observed source mappings.
PE load-image inspection returns an explicit unsupported attestation status.
Independent verification covers admitted DOS MZ, explicit COM, and explicit
[raw-binary imports](raw-binary.md) on their supported hosts. This does not
extend Windows P0 admission beyond native PE. For x64 PE
[raw-binary imports](raw-binary.md) on their supported hosts, including the
admitted raw NMOS 6502 mapping on Windows. Windows P0 admission remains limited
to native PE and raw NMOS 6502 images. For x64 PE
NativeAOT RTR 9.1, the read-only snapshot parser can report metadata through
`inspect_native_load_image` and `inspect_native_data_type` without a JAR or
database mutation. A real Windows-host NativeAOT Ghidra run is still required
@ -35,6 +37,12 @@ state, annotations, other target architectures and formats, and Hopper-only
operations remain unavailable through this provider. Managed PE/CLI inspection
uses its separate execution-free provider.
Raw 6502 imports retain the same native filesystem and process ownership
requirements. The complete image must fit in the 16-bit address space; no
console-specific cartridge loader, CPU variant, or bank-switching model is
implied. Run `npm run verify:ghidra:6502` with caller-installed `ca65`/`ld65`
to check the source-built fixture through real CLI and MCP workflows.
REA does not install or upgrade Ghidra, Java, Python, npm, Node.js, Hopper, or a
compiler. The adapter uses the packaged Java `HeadlessScript`; Python and
PyGhidra are not prerequisites. Users do not build the native addon.

View File

@ -94,6 +94,9 @@ def main():
if undeclared:
raise ValueError('Declare inherited provider settings explicitly in --providers: '
+ ', '.join(undeclared) + '. Values are not logged.')
source_auth_home = (Path(os.environ['CODEX_HOME']) if 'CODEX_HOME' in os.environ
else Path.home() / '.codex')
source_auth = source_auth_home / 'auth.json'
args.output.mkdir(parents=True, exist_ok=False, mode=0o700)
runtime_files, runtime_digest = runtime_fingerprint(repository)
(args.output / 'runtime-files.json').write_text(json.dumps(runtime_files))
@ -103,7 +106,6 @@ def main():
workspace = profile / 'workspace'
for directory in [trial_codex_home, account, workspace]:
directory.mkdir(mode=0o700)
source_auth = Path(os.environ.get('CODEX_HOME', str(Path.home() / '.codex'))) / 'auth.json'
auth = trial_codex_home / 'auth.json'
started = time.monotonic()
timeout = False

View File

@ -71,7 +71,9 @@ class ContextCaptureTests(unittest.TestCase):
self.assertNotIn(marker, text)
self.assertIn('caller-selected evidence and paths', text)
self.assertIn('original-response', text)
self.assertEqual((output / 'codex-tool-context.jsonl').stat().st_mode & 0o777, 0o600)
# Windows mode bits do not describe access control; this is a POSIX assertion.
if os.name == 'posix':
self.assertEqual((output / 'codex-tool-context.jsonl').stat().st_mode & 0o777, 0o600)
def test_missing_rollouts_are_unknown_and_malformed_lines_remain_partial(self):
with tempfile.TemporaryDirectory() as directory:
@ -124,6 +126,7 @@ class ContextCaptureTests(unittest.TestCase):
patch.object(agent.subprocess, 'check_output', return_value='v24.18.0\n'), \
patch.object(agent.subprocess, 'Popen', return_value=child), \
patch.object(agent, 'capture_tool_context', side_effect=OSError('fixture capture failure')), \
patch.object(agent.Path, 'home', side_effect=AssertionError('Explicit CODEX_HOME must not resolve the default home')), \
patch('builtins.print'):
with self.assertRaisesRegex(OSError, 'fixture capture failure'):
agent.main()

View File

@ -174,6 +174,7 @@
"verify:ghidra:seeds": "npm run build:cached && node scripts/verify/ghidra/seeds.mjs",
"verify:ghidra:mips": "npm run build:cached && node scripts/verify-real-ghidra-mips.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: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",

View File

@ -0,0 +1,479 @@
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { createHash } from "node:crypto";
import {
mkdir,
mkdtemp,
readFile,
readdir,
rm,
symlink,
unlink,
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-6502.mjs");
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));
const ca65 = process.env.REA_CA65 ?? "ca65";
const ld65 = process.env.REA_LD65 ?? "ld65";
const toolchain = {};
for (const [name, command] of Object.entries({ ca65, ld65 })) {
const version = await exec(command, ["--version"], { timeout: 10000 });
toolchain[name] = (version.stdout + version.stderr).trim();
}
await mkdir(join(root, "build"), { recursive: true });
const workspace = await mkdtemp(join(root, "build", "rea-6502-proof-"));
const runtime = join(workspace, "runtime");
await mkdir(runtime);
const path = join(workspace, "image.bin");
const object = join(workspace, "image.o");
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-6502-proof", version: "1" });
let stderr = "";
transport.stderr?.on("data", (chunk) => {
stderr = (stderr + chunk.toString()).slice(-65536);
});
let report;
try {
await exec(
ca65,
[join(root, "tests/conformance/ghidra/6502.s"), "-o", object],
{ timeout: 10000 },
);
await exec(
ld65,
["-C", join(root, "tests/conformance/ghidra/6502.cfg"), "-o", path, object],
{ timeout: 10000 },
);
const bytes = await readFile(path);
// Independently known opcodes and link addresses from the source fixture.
assert.equal(bytes.toString("hex"), "a907201080c90ad003a92a60a9006018690360");
const sha256 = createHash("sha256").update(bytes).digest("hex");
await client.connect(transport);
const opened = await call("open_binary", {
path,
format: "raw-binary",
architecture: "6502",
entry_point: "0x008001",
provider_id: "ghidra",
});
assert.equal(opened.architecture, "6502");
assert.equal(opened.entry_point, "0x8001");
const status = await call("binary_session");
const image = evidence(await call("inspect_native_load_image"), sha256);
assert.equal(
image.normalized_result.status,
"verified",
JSON.stringify(image.normalized_result),
);
assert.equal(image.normalized_result.architecture, "6502");
assert.equal(image.normalized_result.base_address, "0x8001");
assert.equal(image.normalized_result.language_id, "6502:LE:16:default");
assert.equal(image.normalized_result.compiler_spec_id, "default");
assert.equal(image.normalized_result.file_bytes, bytes.length);
const memory = evidence(
await call("read_bytes", { address: "0x8001", length: bytes.length }),
sha256,
);
assert.equal(memory.normalized_result.bytes_hex, bytes.toString("hex"));
const procedures = evidence(await call("list_procedures"), sha256);
for (const address of ["0x8001", "0x8010"])
assert.ok(
procedures.normalized_result.some(
(procedure) => procedure.address === address,
),
);
const instruction = evidence(
await call("inspect_native_instruction", { address: "0x8003" }),
sha256,
);
assert.equal(instruction.normalized_result.mnemonic.toUpperCase(), "JSR");
assert.ok(
instruction.normalized_result.references.some(
(reference) => reference.call && reference.target_address === "0x8010",
),
);
const pseudo = evidence(
await call("procedure_pseudo_code", { procedure: "0x8010" }),
sha256,
);
assert.match(pseudo.normalized_result, /\+\s*(?:3|0x3|'\\x03')/u);
const filePseudo = evidence(
await call("procedure_pseudo_code", { procedure: "file-offset:0xf" }),
sha256,
);
assert.equal(filePseudo.normalized_result, pseudo.normalized_result);
assert.ok(
filePseudo.locations.some(
(location) => location.kind === "file-offset" && location.offset === 15,
),
);
const rejected = await client.callTool({
name: "open_binary",
arguments: {
path,
format: "raw-binary",
architecture: "6502",
entry_point: "0xffff",
},
});
assert.equal(rejected.isError, true);
assert.match(JSON.stringify(rejected.content), /6502 address width/u);
const preserved = await call("binary_session");
assert.equal(preserved.analysis_run.run_id, status.analysis_run.run_id);
assert.equal(preserved.entry_point, "0x8001");
await call("close_binary");
await client.ping();
const { stdout } = await exec(
process.execPath,
[
entrypoint,
"inspect-native-load-image",
path,
"--target-format",
"raw-binary",
"--architecture",
"6502",
"--entry-point",
"0x8001",
"--provider",
"ghidra",
"--json",
],
{ env, timeout: 240000, maxBuffer: 16 * 1024 * 1024 },
);
const cli = parseEvidence(JSON.parse(stdout));
assert.deepEqual(cli.normalized_result, image.normalized_result);
assert.equal(cli.analysis_profile.digest, image.analysis_profile.digest);
assert.equal(cli.subject.digest.sha256, sha256);
const fileCli = await exec(
process.execPath,
[
entrypoint,
"decompile",
path,
"--file-offset=0xf",
"--target-format",
"raw-binary",
"--architecture",
"6502",
"--entry-point",
"0x8001",
"--provider",
"ghidra",
"--json",
],
{ env, timeout: 240000, maxBuffer: 16 * 1024 * 1024 },
);
const fileCliEvidence = parseEvidence(JSON.parse(fileCli.stdout));
assert.equal(fileCliEvidence.normalized_result, filePseudo.normalized_result);
assert.deepEqual(fileCliEvidence.locations, filePseudo.locations);
assert.equal(fileCliEvidence.subject.digest.sha256, sha256);
assert.deepEqual(await readFile(path), bytes);
assert.deepEqual(await readdir(runtime), []);
const lowMemory = [];
// BinaryLoader maps file bytes before trying processor-defined blocks. Prove
// that overlapping zero-page/stack definitions never overlay the selected image.
for (const [entry, fullAddressSpace] of [
[0xff, false],
[0, true],
]) {
const lowPath = join(workspace, `low-${entry}.bin`);
await exec(
ld65,
[
"-C",
join(root, "tests/conformance/ghidra/6502.cfg"),
"--start-addr",
String(entry),
"-o",
lowPath,
object,
],
{ timeout: 10000 },
);
const linked = await readFile(lowPath);
const expectedCode = Buffer.from(
"a907200000c90ad003a92a60a9006018690360",
"hex",
);
expectedCode.writeUInt16LE(entry + 15, 3);
assert.deepEqual(linked, expectedCode);
const lowBytes = fullAddressSpace
? Buffer.concat([linked, Buffer.alloc(65536 - linked.length)])
: linked;
await writeFile(lowPath, lowBytes);
const lowSha256 = createHash("sha256").update(lowBytes).digest("hex");
const lowEntry = `0x${entry.toString(16)}`;
const lowOpened = await call("open_binary", {
path: lowPath,
format: "raw-binary",
architecture: "6502",
entry_point: lowEntry,
provider_id: "ghidra",
});
assert.equal(lowOpened.entry_point, lowEntry);
const lowImage = evidence(
await call("inspect_native_load_image"),
lowSha256,
);
assert.equal(
lowImage.normalized_result.status,
"verified",
JSON.stringify(lowImage.normalized_result),
);
assert.equal(lowImage.normalized_result.base_address, lowEntry);
assert.equal(lowImage.normalized_result.file_bytes, lowBytes.length);
const fileMappings =
lowImage.normalized_result.observations.mappings.filter(
(mapping) => mapping.source_file_index !== null,
);
assert.equal(fileMappings.length, 1);
assert.equal(
fileMappings[0].address_space,
lowImage.normalized_result.observations.default_address_space,
);
assert.equal(fileMappings[0].overlay, false);
assert.equal(fileMappings[0].start, lowEntry);
assert.equal(fileMappings[0].length, lowBytes.length);
const lowRead = evidence(
await call("read_bytes", { address: lowEntry, length: linked.length }),
lowSha256,
);
assert.equal(lowRead.normalized_result.bytes_hex, linked.toString("hex"));
const lowCall = evidence(
await call("inspect_native_instruction", {
address: `0x${(entry + 2).toString(16)}`,
}),
lowSha256,
);
assert.equal(lowCall.normalized_result.mnemonic.toUpperCase(), "JSR");
assert.ok(
lowCall.normalized_result.references.some(
(reference) =>
reference.call &&
reference.target_address === `0x${(entry + 15).toString(16)}`,
),
);
await call("close_binary");
const lowCli = await exec(
process.execPath,
[
entrypoint,
"inspect-native-load-image",
lowPath,
"--target-format",
"raw-binary",
"--architecture",
"6502",
"--entry-point",
lowEntry,
"--provider",
"ghidra",
"--json",
],
{ env, timeout: 240000, maxBuffer: 16 * 1024 * 1024 },
);
const lowCliEvidence = evidence(JSON.parse(lowCli.stdout), lowSha256);
assert.deepEqual(
lowCliEvidence.normalized_result,
lowImage.normalized_result,
);
assert.equal(
lowCliEvidence.analysis_profile.digest,
lowImage.analysis_profile.digest,
);
assert.deepEqual(await readFile(lowPath), lowBytes);
assert.deepEqual(await readdir(runtime), []);
lowMemory.push({
entry: lowEntry,
bytes: lowBytes.length,
sha256: lowSha256,
});
}
let windowsReparseAdmission = "not-applicable";
if (process.platform === "win32") {
const junction = join(workspace, "linked");
await symlink(workspace, junction, "junction");
try {
const linkedPath = join(junction, "image.bin");
// Reopening the same bytes through a junction must replace the direct session.
await call("open_binary", {
path,
format: "raw-binary",
architecture: "6502",
entry_point: "0x8001",
provider_id: "ghidra",
});
const directImage = evidence(
await call("inspect_native_load_image"),
sha256,
);
assert.equal(directImage.normalized_result.status, "verified");
// Canonical file identity must not bypass admission of the new path.
await call("open_binary", {
path: linkedPath,
format: "raw-binary",
architecture: "6502",
entry_point: "0x8001",
provider_id: "ghidra",
});
// open_binary selects a lazy session; the first analysis admits the snapshot.
const linkedImage = await client.callTool(
{ name: "inspect_native_load_image", arguments: {} },
{ timeout: 240000 },
);
assert.equal(linkedImage.isError, true);
assert.match(
JSON.stringify(linkedImage),
/Reparse path component is unsupported/u,
);
await call("close_binary");
const snapshotPath = join(workspace, "direct-snapshot.json");
const cliArgs = (selectedPath) => [
entrypoint,
"analyze",
selectedPath,
"--target-format",
"raw-binary",
"--architecture",
"6502",
"--entry-point",
"0x8001",
"--provider",
"ghidra",
"--snapshot",
snapshotPath,
"--json",
];
const saved = await exec(process.execPath, cliArgs(path), {
env,
timeout: 240000,
maxBuffer: 16 * 1024 * 1024,
});
assert.equal(
evidence(JSON.parse(saved.stdout), sha256).operation,
"binary_overview",
);
const savedSnapshot = JSON.parse(await readFile(snapshotPath, "utf8"));
assert.ok(
savedSnapshot.workflow_entries.some(
({ operation }) => operation === "binary_overview",
),
);
assert.deepEqual(await readdir(runtime), []);
// Replaying a matching snapshot must still reach native path admission.
await assert.rejects(
exec(process.execPath, cliArgs(linkedPath), {
env,
timeout: 240000,
maxBuffer: 16 * 1024 * 1024,
}),
(cause) => {
assert.match(
cause.stdout + cause.stderr,
/Reparse path component is unsupported/u,
);
return true;
},
);
assert.deepEqual(await readFile(path), bytes);
assert.deepEqual(await readdir(runtime), []);
windowsReparseAdmission = "cli-snapshot-mcp-rejected";
} finally {
await unlink(junction);
}
}
report = {
ok: true,
mocked: false,
lane: "ghidra-6502",
host: process.platform,
toolchain,
provider: image.provider,
target: {
sha256,
bytes: bytes.length,
architecture: "6502",
entry: "0x8001",
},
cli_mcp_load_image_parity: true,
instructions_and_call_reference: true,
decompilation: true,
file_offset_decompilation_cli_mcp: true,
invalid_mapping_preserves_session: true,
low_memory_cli_mcp_parity: lowMemory,
windows_reparse_admission: windowsReparseAdmission,
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();
}
}
const verifierRun = await completeVerifierRun(run);
if (process.platform !== "win32") {
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, sha256) {
const result = parseEvidence(value);
assert.equal(result.subject.digest.sha256, sha256);
assert.equal(result.subject.architecture, "6502");
assert.equal(result.subject.format, "raw-binary");
return result;
}

View File

@ -76,6 +76,8 @@ export const parseBinaryTarget = async (
await access(candidate, constants.R_OK);
throwIfTargetResolutionCancelled(signal);
const canonical = await realpath(candidate);
// Windows native admission must inspect the caller-selected reparse components.
const sourcePath = process.platform === "win32" ? candidate : canonical;
throwIfTargetResolutionCancelled(signal);
const resolved = await resolveAppBundle(canonical);
throwIfTargetResolutionCancelled(signal);
@ -113,7 +115,7 @@ export const parseBinaryTarget = async (
);
return ok({
path,
sourcePath: canonical,
sourcePath,
sha256: await sha256Handle(handle, path, metadata, signal),
kind: "executable",
format: "raw-binary",
@ -152,7 +154,7 @@ export const parseBinaryTarget = async (
);
return ok({
path,
sourcePath: canonical,
sourcePath,
sha256: await sha256Handle(handle, path, metadata, signal),
kind: "executable",
format: "dos-com",
@ -166,7 +168,7 @@ export const parseBinaryTarget = async (
)
return ok({
path,
sourcePath: process.platform === "win32" ? candidate : canonical,
sourcePath,
sha256: await sha256Handle(handle, path, metadata, signal),
kind: "database",
format: "analysis-database",
@ -175,7 +177,7 @@ export const parseBinaryTarget = async (
if (artifactFormat !== undefined) {
const identity = {
path,
sourcePath: process.platform === "win32" ? candidate : canonical,
sourcePath,
sha256: await sha256Handle(handle, path, metadata, signal),
};
return isArchiveFormat(artifactFormat)
@ -195,7 +197,7 @@ export const parseBinaryTarget = async (
);
return ok({
path,
sourcePath: process.platform === "win32" ? candidate : canonical,
sourcePath,
...bundle,
sha256: await sha256Handle(handle, path, metadata, signal),
kind: "executable",

View File

@ -23,6 +23,7 @@ import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import type { AnalysisError } from "../domain/analysisErrorBase.js";
import { access } from "node:fs/promises";
import { readAnalysisSnapshot } from "./binary/AnalysisSnapshotFiles.js";
import { requiresNativeSourceAdmissionBeforeReplay } from "./binary/AnalysisSnapshotCache.js";
import { parseBinaryTarget } from "./BinaryTargetResolver.js";
import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
import {
@ -254,9 +255,14 @@ const runAnalysis = async (
const route = preview.value.route;
const bindingProfile = route.profile ?? undefined;
const evidenceProfile = analysisProfileForRoute(route, tool);
// A parsed Windows path does not prove Ghidra's native snapshot owner
// admitted the selected source; CLI replay must wait for that client.
if (
bindingProfile !== undefined &&
evidenceProfile !== undefined &&
!requiresNativeSourceAdmissionBeforeReplay(
route.binding?.identity.id,
) &&
allowsSnapshotReplay(route, tool)
) {
const cached = snapshotEvidenceForQuery(snapshot, {

View File

@ -84,6 +84,11 @@ export const isSnapshotCacheable = (
descriptor.effects.mayWriteFilesystem === false) &&
descriptor.effects.changesPermissions === false;
/** Windows Ghidra must admit the selected source through its native snapshot owner. */
export const requiresNativeSourceAdmissionBeforeReplay = (
providerId: string | undefined,
): boolean => process.platform === "win32" && providerId === "ghidra";
/** Bounded in-memory cache for one immutable binary identity. */
export class AnalysisSnapshotCache {
readonly #entries = new Map<string, AnalysisSnapshotEntry>();

View File

@ -58,6 +58,7 @@ interface SessionBinding {
readonly profile: AnalysisProfileCommitment | null;
readonly route: SessionProviderRoute;
readonly runId: string;
nativeSourceAdmitted: boolean;
}
/**
@ -323,6 +324,7 @@ export class BinarySession
profile,
route,
runId,
nativeSourceAdmitted: false,
};
this.#clearRuntimeAvailability();
this.resetSnapshotInvalidation();
@ -609,6 +611,7 @@ export class BinarySession
active: this.#active,
operation: name,
parameters: arguments_,
nativeSourceAdmitted: this.#active?.nativeSourceAdmitted === true,
unboundOperationError: (operation, route) =>
this.#providerRouter.unboundOperationError(operation, route),
lookupSnapshot: (target, profile, operation, parameters) =>
@ -645,6 +648,14 @@ export class BinarySession
},
})
: profiled;
// A successful Ghidra operation proves this client's native source snapshot
// was admitted; a composite health response and parsed path do not.
if (
execution.ok &&
capability.provider.id === "ghidra" &&
this.#active === active
)
this.#active.nativeSourceAdmitted = true;
this.#observeRuntimeAvailability(name, execution);
if (
execution.ok &&
@ -838,6 +849,7 @@ export class BinarySession
profile: previous.profile,
route: previous.route,
runId,
nativeSourceAdmitted: false,
};
return ok(null);
}

View File

@ -13,7 +13,10 @@ import type {
AnalysisOperation,
CapabilityDescriptor,
} from "../AnalysisProvider.js";
import { isSnapshotCacheable } from "./AnalysisSnapshotCache.js";
import {
isSnapshotCacheable,
requiresNativeSourceAdmissionBeforeReplay,
} from "./AnalysisSnapshotCache.js";
import type { SessionProviderRoute } from "./SessionProviderRouter.js";
interface ActiveExecutionBinding {
@ -35,6 +38,7 @@ interface PrepareSessionExecutionInput {
readonly active: ActiveExecutionBinding | undefined;
readonly operation: AnalysisOperation;
readonly parameters: Readonly<Record<string, JsonValue>>;
readonly nativeSourceAdmitted: boolean;
readonly unboundOperationError: (
operation: AnalysisOperation,
route: SessionProviderRoute,
@ -55,6 +59,7 @@ export const prepareSessionExecution = (
active,
operation,
parameters,
nativeSourceAdmitted,
unboundOperationError,
lookupSnapshot,
} = input;
@ -79,9 +84,12 @@ export const prepareSessionExecution = (
const cacheable =
profile !== undefined &&
isSnapshotCacheable(operation, capability, parameters);
const cached = cacheable
? lookupSnapshot(active.target, profile, operation, parameters)
: undefined;
const cached =
cacheable &&
(!requiresNativeSourceAdmissionBeforeReplay(capability.provider.id) ||
nativeSourceAdmitted)
? lookupSnapshot(active.target, profile, operation, parameters)
: undefined;
return ok({ active, capability, profile, cacheable, cached });
};

View File

@ -92,9 +92,7 @@ export const resolveSessionTarget = async (
input: ResolveSessionTargetInput,
): Promise<Result<ResolvedSessionOpen, AnalysisError>> => {
const { router, current, target, options } = input;
const sameTarget =
current?.target.path === target.path &&
snapshotMatchesTarget(snapshotTarget(current.target), target);
const sameTarget = sameSessionTarget(current?.target, target);
const resolvedRoute =
sameTarget && options.providerId === undefined && current !== undefined
? ({ ok: true, value: current.route } as const)
@ -113,9 +111,7 @@ export const validateResolvedSessionTarget = (
},
): Result<ResolvedSessionOpen, AnalysisError> => {
const { current, target, route, options, stagedSnapshotMatches } = input;
const sameTarget =
current?.target.path === target.path &&
snapshotMatchesTarget(snapshotTarget(current.target), target);
const sameTarget = sameSessionTarget(current?.target, target);
const snapshotError = validateSnapshot({
snapshot: options.snapshot,
target,
@ -127,6 +123,16 @@ export const validateResolvedSessionTarget = (
return { ok: true, value: { target, route, sameTarget } };
};
const sameSessionTarget = (
current: BinaryTarget | undefined,
target: BinaryTarget,
): boolean =>
current !== undefined &&
current.path === target.path &&
// Native admission is bound to the requested path, not just its realpath.
(current.sourcePath ?? current.path) === (target.sourcePath ?? target.path) &&
snapshotMatchesTarget(snapshotTarget(current), target);
interface ValidateSnapshotInput {
readonly snapshot: AnalysisSnapshot | undefined;
readonly target: BinaryTarget;

View File

@ -6,6 +6,34 @@ import { z } from "zod";
import { toolContract } from "./toolContracts.js";
import { toolOutputSchemaWithMetadata } from "./toolSchemaMetadata.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) => {
const valid = createEvidence(
{
path: "/fixture/image.bin",
sha256: "a".repeat(64),
format: "raw-binary",
architecture: "6502",
},
{ id: "fixture", name: "Fixture", version: "1" },
{ operation: "address_name", parameters: {}, result: "entry" },
);
const advertised = toolOutputSchemaWithMetadata(
toolContract("address_name"),
)["~standard"].jsonSchema.output({ target });
const validate = new AjvJsonSchemaValidator().getValidator(advertised);
expect(validate(valid).valid).toBe(true);
expect(
validate({
...valid,
subject: { ...valid.subject, format: "pe" },
}).valid,
).toBe(false);
},
);
it("keeps dialect-specific validators distinct after JSON schema serialization", () => {
const contract = {

View File

@ -444,11 +444,11 @@ const lifecycleTargetFields = {
path: z.string(),
kind: targetKindSchema,
sha256: z.string().regex(/^[a-f0-9]{64}$/u),
architecture: z.enum(BINARY_ARCHITECTURES).nullable(),
};
const detectedLifecycleTargetSchema = z.object({
...lifecycleTargetFields,
format: targetFormatSchema.exclude(["raw-binary"]),
architecture: z.enum(BINARY_ARCHITECTURES).exclude(["6502"]).nullable(),
});
const rawLifecycleTargetSchema = z.object({
...lifecycleTargetFields,

View File

@ -52,7 +52,7 @@ const snapshotWithHistoricalEvidence = () => {
};
return {
current,
snapshot: {
snapshot: analysisSnapshotSchema.parse({
target,
binding,
entries: [entry],
@ -66,11 +66,32 @@ const snapshotWithHistoricalEvidence = () => {
result: { summary: "unprofiled" },
}),
]),
} satisfies AnalysisSnapshot,
}),
};
};
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("partitions raw snapshots and query identities by the canonical entry", () => {
const target: BinaryTarget = {
path: "/fixture/image.bin",
@ -120,6 +141,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,
},
},
],
}).success,
).toBe(false);
expect(
analysisSnapshotSchema.safeParse({
...snapshot,
workflow_entries: [
{
...entry,
execution: {
...entry.execution,
analysis_profile: snapshot.binding.analysis_profile,
subject: malformedSubject,
},
},
],
}).success,
).toBe(false);
});
});
describe("analysis snapshot contract", () => {
it.each([
["dos-mz", "x86"],

View File

@ -41,7 +41,10 @@ import { digestSchema } from "./../domain/digests.js";
import { prefixedDigestSchema } from "./../domain/digests.js";
import { canonicalJsonDigestSteps } from "./canonicalJsonDigestSteps.js";
import { freezeJsonSnapshot } from "./immutableJson.js";
import { rawBinaryEntryPointSchema } from "./rawBinary.js";
import {
binaryArchitectureSchema,
rawBinaryEntryPointSchema,
} from "./rawBinary.js";
import {
relationshipCoverageSchema,
type RelationshipCoverage,
@ -73,20 +76,30 @@ const formatSchema = z.enum([
"source-map",
]);
const kindSchema = z.enum(["executable", "database", "archive", "artifact"]);
const subjectSchema = z.object({
const subjectBaseSchema = z.object({
path: localPathStringSchema,
sha256: digestSchema,
format: z.enum([
...formatSchema.options,
"hopper",
"directory",
"file",
"unknown",
"javascript-bundle",
"entitlements",
]),
architecture: architectureSchema.nullable(),
});
const subjectFormatSchema = z.enum([
...formatSchema.options,
"hopper",
"directory",
"file",
"unknown",
"javascript-bundle",
"entitlements",
]);
// Snapshot entries retain the same raw-only architecture boundary as targets.
const subjectSchema = z.discriminatedUnion("format", [
subjectBaseSchema.extend({
format: subjectFormatSchema.exclude(["raw-binary"]),
architecture: architectureSchema.exclude(["6502"]).nullable(),
}),
subjectBaseSchema.extend({
format: z.literal("raw-binary"),
architecture: architectureSchema.nullable(),
}),
]);
const targetIdentitySchema = z.object({
sha256: digestSchema,
kind: kindSchema,
@ -97,9 +110,12 @@ const targetSchema = z.discriminatedUnion("format", [
format: z.enum(
formatSchema.options.filter((format) => format !== "raw-binary"),
),
architecture: architectureSchema.exclude(["6502"]).nullable(),
}),
targetIdentitySchema.extend({
format: z.literal("raw-binary"),
kind: z.literal("executable"),
architecture: binaryArchitectureSchema,
entry_point: rawBinaryEntryPointSchema,
}),
]);
@ -332,18 +348,21 @@ const canonicalJson = (value: unknown): string => {
};
/** Project a binary into the path-free identity used for cache invalidation. */
export const snapshotTarget = (
target: BinaryTarget,
): AnalysisSnapshotTarget => {
const identity = {
sha256: target.sha256,
kind: target.kind,
architecture: target.architecture ?? null,
};
return target.format === "raw-binary"
? { ...identity, format: target.format, entry_point: target.entryPoint }
: { ...identity, format: target.format };
};
export const snapshotTarget = (target: BinaryTarget): AnalysisSnapshotTarget =>
target.format === "raw-binary"
? {
sha256: target.sha256,
kind: target.kind,
architecture: target.architecture,
format: target.format,
entry_point: target.entryPoint,
}
: {
sha256: target.sha256,
kind: target.kind,
architecture: target.architecture ?? null,
format: target.format,
};
/** Build the immutable provider/profile binding used by a cached snapshot. */
export const snapshotBinding = (
@ -475,10 +494,10 @@ export const createAnalysisSnapshotWorkflowEntry = (input: {
subject:
execution.subject === null
? null
: {
: subjectSchema.parse({
...execution.subject,
architecture: execution.subject.architecture ?? null,
},
}),
},
};
};
@ -538,10 +557,10 @@ export const createAnalysisSnapshotEntry = (input: {
subject:
execution.subject === null
? null
: {
: subjectSchema.parse({
...execution.subject,
architecture: execution.subject.architecture ?? null,
},
}),
},
};
};

View File

@ -351,7 +351,9 @@ const machArchitecture = (cpu: number): HeaderArchitecture | undefined => {
return undefined;
};
const elfArchitecture = (machine: number): BinaryArchitecture | undefined => {
const elfArchitecture = (
machine: number,
): Exclude<BinaryArchitecture, "6502"> | undefined => {
switch (machine) {
case 3:
return "x86";

View File

@ -1,12 +1,13 @@
import type { ZipPackageFormat } from "./zipPackageFormat.js";
/** Provider-neutral CPU families detected from supported executable headers. */
/** Provider-neutral CPU families detected from headers or explicitly selected raw loads. */
export const BINARY_ARCHITECTURES = [
"x86",
"x86_64",
"arm",
"arm64",
"mips",
"6502",
"riscv",
] as const;
export type BinaryArchitecture = (typeof BINARY_ARCHITECTURES)[number];
@ -40,7 +41,7 @@ export const isElfMetadataArchitecture = (
/** Recognized family with no ELF metadata member; the header alone is the fact. */
export type HeaderArchitecture = Exclude<
BinaryArchitecture,
ElfMetadataArchitecture
ElfMetadataArchitecture | "6502"
>;
/** Raw ELF facts; family recognition does not establish ISA or ABI support. */
@ -144,8 +145,17 @@ export type BinaryTarget =
readonly executableRole?: never;
readonly managed?: never;
})
| (ExecutableTarget & {
| (BinaryTargetIdentity & {
readonly kind: "executable";
readonly format: "raw-binary";
readonly architecture: Exclude<
BinaryArchitecture,
ElfMetadataArchitecture
>;
readonly availableArchitectures: readonly Exclude<
BinaryArchitecture,
ElfMetadataArchitecture
>[];
readonly entryPoint: string;
readonly entryPointSource: string;
readonly executableRole?: never;

View File

@ -19,6 +19,33 @@ 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("preserves omitted relationship diagnostics and authenticates reported diagnostics", () => {
const observation = {
operation: "procedure_callees",

View File

@ -38,42 +38,52 @@ export const providerSchema = z.strictObject({
name: z.string().min(1),
version: z.string().nullable(),
});
const subjectSchema = z.strictObject({
const subjectFormatSchema = z.enum([
"hopper",
"analysis-database",
"mach-o",
"elf",
"pe",
"dos-mz",
"dos-com",
"raw-binary",
"zip",
"ipa",
"apk",
"msix",
"appx",
"hap",
"hsp",
"app-pack",
"asar",
"dmg",
"pkg",
"plist",
"javascript",
"source-map",
"directory",
"file",
"unknown",
"mach-o-universal",
"javascript-bundle",
"entitlements",
]);
const subjectBaseSchema = z.strictObject({
name: z.string().min(1),
digest: z.strictObject({ sha256: digestSchema }),
format: z.enum([
"hopper",
"analysis-database",
"mach-o",
"elf",
"pe",
"dos-mz",
"dos-com",
"raw-binary",
"zip",
"ipa",
"apk",
"msix",
"appx",
"hap",
"hsp",
"app-pack",
"asar",
"dmg",
"pkg",
"plist",
"javascript",
"source-map",
"directory",
"file",
"unknown",
"mach-o-universal",
"javascript-bundle",
"entitlements",
]),
architecture: z.enum(BINARY_ARCHITECTURES).nullable(),
local_path: nulFreeStringSchema,
});
// The format owns the architecture claim, including nested Evidence subjects.
const subjectSchema = z.discriminatedUnion("format", [
subjectBaseSchema.extend({
format: subjectFormatSchema.exclude(["raw-binary"]),
architecture: z.enum(BINARY_ARCHITECTURES).exclude(["6502"]).nullable(),
}),
subjectBaseSchema.extend({
format: z.literal("raw-binary"),
architecture: z.enum(BINARY_ARCHITECTURES).nullable(),
}),
]);
/** Source location attached to an evidence observation. */
/** Source-location schema shared by evidence-bearing persistence formats. */
export const evidenceLocationSchema = z.discriminatedUnion("kind", [
@ -186,7 +196,7 @@ const immutableResultSchema = z
});
export type EvidenceLocation = z.infer<typeof evidenceLocationSchema>;
/** Minimal immutable local artifact identity accepted by Evidence. */
/** Candidate local artifact identity validated by the Evidence subject schema. */
export interface EvidenceSubjectTarget {
readonly path: string;
readonly sha256: string;

View File

@ -16,14 +16,17 @@ import {
} from "./residualUnknown.js";
import { freezeJsonSnapshot } from "./immutableJson.js";
const artifactManifestSchema = evidenceSchema.shape.subject
.unwrap()
.pick({
digest: true,
format: true,
architecture: true,
})
.strict();
const [detectedSubjectSchema, rawSubjectSchema] =
evidenceSchema.shape.subject.unwrap().options;
const manifestFields = {
digest: true,
format: true,
architecture: true,
} as const;
const artifactManifestSchema = z.discriminatedUnion("format", [
detectedSubjectSchema.pick(manifestFields),
rawSubjectSchema.pick(manifestFields),
]);
const providerManifestSchema = providerSchema;
const environmentManifestSchema = evidenceSchema.shape.environment.unwrap();
const scenarioManifestSchema = z.strictObject({
@ -85,11 +88,11 @@ export const createEvidenceBundle = (
subject === null
? []
: [
{
artifactManifestSchema.parse({
digest: subject.digest,
format: subject.format,
architecture: subject.architecture,
},
}),
],
),
),

View File

@ -1,6 +1,7 @@
import { z } from "zod";
import { nativeMetadataRecoverySummarySchema } from "./nativeMetadataRecovery.js";
import { jsonValueSchema } from "../jsonValue.js";
import { binaryArchitectureSchema } from "../rawBinary.js";
const integer = z.number().int().min(0).max(Number.MAX_SAFE_INTEGER);
const digest = z.string().regex(/^[a-f0-9]{64}$/u);
@ -112,7 +113,7 @@ export const nativeLoadImageSchema = z.union([
status: z.enum(["verified", "mismatch"]),
format: z.literal("raw-binary"),
target_sha256: digest,
architecture: z.enum(["x86", "x86_64", "arm", "arm64"]),
architecture: binaryArchitectureSchema,
language_id: z.string().min(1),
compiler_spec_id: z.string().min(1),
base_address: address,

View File

@ -39,6 +39,44 @@ describe("raw-binary entry addresses", () => {
});
describe("raw-binary admission", () => {
it("admits byte-aligned 6502 images up to the 16-bit address boundary", () => {
for (const [entryPoint, fileBytes] of [
["0x8001", 8],
["0xffff", 1],
["0x0", 65536],
] as const) {
const result = admitRawBinary({
architecture: "6502",
entryPoint,
fileBytes,
});
if (!result.ok) throw new Error(result.error.reason);
expect(result.value).toEqual({
architecture: "6502",
entryPoint,
entryPointSource: entryPoint,
});
}
});
it.each([
["0x10000", 1],
["0xffff", 2],
["0x0", 65537],
])(
"rejects a 6502 mapping at %s with %i bytes that exceeds 16 bits",
(entryPoint, fileBytes) => {
const result = admitRawBinary({
architecture: "6502",
entryPoint,
fileBytes,
});
if (result.ok) throw new Error("expected 16-bit overflow rejection");
expect(result.error).toEqual({
reason: `raw-binary mapping from ${entryPoint} must fit in the 6502 address width`,
constraint: "load_selection_rejected",
});
},
);
it("admits a little-endian ARM image mapped at the caller entry", () => {
expect(
admitRawBinary({

View File

@ -13,6 +13,7 @@ export const BINARY_ARCHITECTURES = [
"x86_64",
"arm",
"arm64",
"6502",
] as const satisfies readonly BinaryArchitecture[];
/** Provider-neutral CPU family for a caller-supplied raw-binary load. */
@ -58,7 +59,7 @@ export const validateRawBinaryLength = (
/** Admitted headerless load identity used by target resolution and profiles. */
export interface RawBinaryAdmission {
readonly architecture: HeaderArchitecture;
readonly architecture: HeaderArchitecture | "6502";
readonly entryPoint: string;
readonly entryPointSource: string;
}
@ -74,9 +75,11 @@ export interface RawBinaryAdmissionRejection {
}
const addressWidth = (architecture: BinaryArchitecture): bigint =>
architecture === "x86" || architecture === "arm"
? 0xffffffffn
: 0xffffffffffffffffn;
architecture === "6502"
? 0xffffn
: architecture === "x86" || architecture === "arm"
? 0xffffffffn
: 0xffffffffffffffffn;
/** Validate architecture, entry, and that the mapped image fits the CPU width. */
export const admitRawBinary = (input: {
@ -88,7 +91,7 @@ export const admitRawBinary = (input: {
if (!architecture.success)
return err({
reason:
"raw-binary format requires architecture (x86, x86_64, arm, or arm64)",
"raw-binary format requires architecture (x86, x86_64, arm, arm64, or 6502)",
constraint: "load_selection_rejected",
});
if (typeof input.entryPoint !== "string")

View File

@ -136,7 +136,7 @@ export const resolveGhidraAnalysisProfile = async (
installation.platform === "win32"
? "official-headless-script-v1"
: "inspected-jvm-launch-support-v1",
load_image_evidence: "independent-raw-mapping-context-v2",
load_image_evidence: "independent-raw-mapping-context-v4",
jump_table_evidence: "typed-case-default-blocks-v1",
decompiler_jump_loads: true,
loader: "BinaryLoader",

View File

@ -361,7 +361,10 @@ export const attestGhidraRawBinaryLoadImage = (
});
};
const source = observations.source_files[0];
const mapping = observations.mappings[0];
const fileMappings = observations.mappings.filter(
(item) => item.source_file_index !== null,
);
const mapping = fileMappings[0];
check("executable_format", "Raw Binary", observations.executable_format);
check("language_id", language.languageId, observations.language_id);
check(
@ -371,11 +374,34 @@ export const attestGhidraRawBinaryLoadImage = (
);
// BinaryLoader's block base is independent of the Program image base.
check("image_base", "0x0", observations.image_base);
check(
"default_address_space",
language.defaultAddressSpace,
observations.default_address_space,
);
check("source_file_count", 1, observations.source_files.length);
check("source_file_size", snapshot.bytes, source?.size ?? null);
check("original_file_sha256", target.sha256, source?.original_sha256 ?? null);
check("modified_file_sha256", target.sha256, source?.modified_sha256 ?? null);
check("mapping_count", 1, observations.mappings.length);
// Language-defined uninitialized zero page/stack blocks are observations, not file bytes.
check("mapping_count", 1, fileMappings.length);
check(
"initialized_unbacked_mappings",
0,
observations.mappings.filter(
(item) => item.initialized && item.source_file_index === null,
).length,
);
check("mapping.source_file_index", 0, mapping?.source_file_index ?? null);
// Matching bytes alone do not establish the admitted default-space load.
check("mapping.initialized", true, mapping?.initialized ?? null);
check("mapping.loaded", true, mapping?.loaded ?? null);
check("mapping.overlay", false, mapping?.overlay ?? null);
check(
"mapping.address_space",
observations.default_address_space,
mapping?.address_space ?? null,
);
check(
"mapping.file_offset",
0,
@ -427,8 +453,9 @@ 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, mapped file bytes, 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 little-endian Ghidra language. 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.",
"Mapping ends are inclusive; a mismatching digest identifies its source range, not the first differing byte.",
],

View File

@ -8,7 +8,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { describe, expect, it } from "vitest";
import { describe, expect, it, onTestFinished, vi } from "vitest";
import { fixtureDosLoadImage } from "./GhidraLoadImage.fixture.js";
import { fixtureRawBinaryLoadImage } from "./GhidraRawBinaryLoadImage.fixture.js";
import {
@ -30,6 +30,8 @@ import { err, ok } from "../domain/result.js";
import type { Result } from "../domain/result.js";
import { GhidraSessionError } from "./GhidraSessionError.js";
import { silentLogger } from "../logger.js";
import * as WindowsNativeLoader from "../windows/WindowsNativeLoader.js";
import { WINDOWS_NATIVE_AUTHORITY_UNAVAILABLE_REASON } from "../process/WindowsAuthority.js";
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import { ProviderStartupDeadline } from "../process/ProviderDeadline.js";
@ -190,6 +192,36 @@ describe("Ghidra provider", () => {
});
describe("Ghidra raw-binary profile", () => {
it("admits raw 6502 on Windows and binds its 16-bit language without mutation authority", async () => {
const ghidra = provider({ ...installationHost(), platform: "win32" });
const target: BinaryTarget = {
path: "/tmp/6502.bin",
sha256: "a".repeat(64),
kind: "executable",
format: "raw-binary",
architecture: "6502",
availableArchitectures: ["6502"],
entryPoint: "0x8001",
entryPointSource: "0x008001",
};
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({
architecture: "6502",
loader: "BinaryLoader",
language_id: "6502:LE:16:default",
compiler_spec_id: "default",
entry_point: "0x8001",
language_selection: "caller-supplied-v1",
load_image_evidence: "independent-raw-mapping-context-v4",
});
expect(
ghidra
.capabilities()
.some(({ available, effects }) => available && effects.mutatesArtifact),
).toBe(false);
});
it("commits BinaryLoader language and caller-supplied entry", async () => {
const ghidra = provider();
const target: BinaryTarget = {
@ -324,6 +356,13 @@ describe("Ghidra platform support", () => {
});
it("keeps Windows P0 unavailable until native isolation authority exists", () => {
const native = vi
.spyOn(WindowsNativeLoader, "systemWindowsNativeAuthority")
.mockReturnValue({
available: false,
reason: WINDOWS_NATIVE_AUTHORITY_UNAVAILABLE_REASON,
});
onTestFinished(() => native.mockRestore());
const ghidra = provider({ ...installationHost(), platform: "win32" });
const nativeApplication = peTarget("x86_64");
@ -812,11 +851,22 @@ describe("Ghidra relationship coverage", () => {
});
});
describe("Ghidra measured load-image projection", () => {
it.each(["verified", "changed-snapshot", "cancelled"] as const)(
describe("Ghidra measured raw load-image projection", () => {
it.each([
"verified",
"mapping-mismatch",
"changed-snapshot",
"cancelled",
] as const)(
"streams raw snapshot identity and preserves %s outcomes",
async (state) => {
const fixture = fixtureRawBinaryLoadImage();
if (state === "mapping-mismatch") {
const mapping = fixture.observation.mappings[0];
if (mapping === undefined)
throw new Error("Missing source mapping fixture");
mapping.overlay = true;
}
const controller = new AbortController();
const signals: (AbortSignal | undefined)[] = [];
const ghidra = provider(installationHost(), () => ({
@ -872,12 +922,23 @@ describe("Ghidra measured load-image projection", () => {
{ signal: controller.signal },
);
expect(signals).toEqual([controller.signal]);
if (state === "verified") {
if (state === "verified" || state === "mapping-mismatch") {
if (!result.ok) throw result.error;
expect(result.value.result).toMatchObject({
status: "verified",
status: state === "verified" ? "verified" : "mismatch",
observations: fixture.observation,
});
if (state === "mapping-mismatch") {
expect(result.value.result).toMatchObject({
checks: expect.arrayContaining([
expect.objectContaining({
name: "mapping.overlay",
observed: true,
matched: false,
}),
]),
});
}
} else {
expect(result).toMatchObject({
ok: false,
@ -894,7 +955,9 @@ describe("Ghidra measured load-image projection", () => {
}
},
);
});
describe("Ghidra measured DOS load-image projection", () => {
it.each([
"verified",
"mismatch",

View File

@ -66,6 +66,7 @@ const SUPPORTED_ARCHITECTURES = new Set([
"arm",
"arm64",
"mips",
"6502",
"riscv",
]);
@ -184,7 +185,7 @@ export class GhidraProvider implements AnalysisProviderCandidate {
status: "unsupported",
code: "architecture_unsupported",
reason:
"Ghidra v1 requires a supported x86, x86_64, arm, arm64, or MIPS target profile.",
"Ghidra v1 requires a supported x86, x86_64, arm, arm64, MIPS, RISC-V, or raw 6502 target profile.",
diagnostics,
};
return {
@ -304,11 +305,15 @@ const inspectWindowsP0TargetSupport = (
target: BinaryTarget,
diagnostics: Readonly<Record<string, JsonValue>>,
): ProviderTargetSupport => {
// The raw 6502 lane uses the same immutable snapshot and native ownership boundary.
if (target.format === "raw-binary" && target.architecture === "6502")
return { status: "supported", code: null, reason: null, diagnostics };
if (target.format !== "pe")
return {
status: "unsupported",
code: "target_format_unsupported",
reason: "Windows Ghidra P0 accepts PE targets only.",
reason:
"Windows Ghidra P0 accepts native PE and explicitly selected raw 6502 targets only.",
diagnostics,
};
if (target.architecture !== "x86" && target.architecture !== "x86_64")

View File

@ -33,7 +33,7 @@ export const healthLimitations = Object.freeze([
/** Additional limitations applied to the experimental Windows x64 P0 boundary. */
export const windowsP0Limitations = Object.freeze([
"Windows Ghidra P0 accepts approved native x86 and x86-64 PE applications and DLLs; managed, hostile, sensitive, and mutable-path targets are unsupported.",
"Windows Ghidra P0 accepts native x86 and x86-64 PE applications and DLLs, and explicitly selected raw 6502 images; managed, hostile, sensitive, and mutable-path targets are unsupported.",
"The Windows bridge uses authenticated IPv4 loopback because Node path-based IPC does not expose Java AF_UNIX sockets; the endpoint file contains no bearer token.",
"Windows sessions require the matching packaged Windows x64 native addon, local NTFS targets and runtimes, and Windows 10 or later. Native handles enforce path admission, private DACLs, and Job Object ownership automatically.",
]);

View File

@ -516,7 +516,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, and arm64. Big-endian, Thumb-only, Cortex-M, 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. Big-endian, Thumb-only, Cortex-M, 65C02, 65816, and other processor variants are unsupported in this increment.",
"Static analysis does not emulate reset vectors, memory-mapped I/O, or firmware boot sequences.",
]
: []),

View File

@ -1,28 +1,51 @@
import type { HeaderArchitecture } from "../domain/binaryTargetTypes.js";
import type { RawBinaryAdmission } from "../domain/rawBinary.js";
/** Bound Ghidra language for a caller-supplied raw-binary architecture. */
export interface GhidraRawBinaryLanguage {
readonly languageId: string;
readonly compilerSpecId: "default" | "gcc";
readonly defaultAddressSpace: "ram" | "RAM";
}
/**
* Map the four caller-visible CPU families to little-endian generic Ghidra
* 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.
*/
export const ghidraRawBinaryLanguage = (
architecture: HeaderArchitecture,
architecture: RawBinaryAdmission["architecture"],
): GhidraRawBinaryLanguage => {
switch (architecture) {
case "6502":
return {
languageId: "6502:LE:16:default",
compilerSpecId: "default",
defaultAddressSpace: "RAM",
};
case "x86":
return { languageId: "x86:LE:32:default", compilerSpecId: "gcc" };
return {
languageId: "x86:LE:32:default",
compilerSpecId: "gcc",
defaultAddressSpace: "ram",
};
case "x86_64":
return { languageId: "x86:LE:64:default", compilerSpecId: "gcc" };
return {
languageId: "x86:LE:64:default",
compilerSpecId: "gcc",
defaultAddressSpace: "ram",
};
case "arm":
return { languageId: "ARM:LE:32:v7", compilerSpecId: "default" };
return {
languageId: "ARM:LE:32:v7",
compilerSpecId: "default",
defaultAddressSpace: "ram",
};
case "arm64":
return { languageId: "AARCH64:LE:64:v8A", compilerSpecId: "default" };
return {
languageId: "AARCH64:LE:64:v8A",
compilerSpecId: "default",
defaultAddressSpace: "ram",
};
}
};

View File

@ -9,23 +9,161 @@ describe("raw-binary Ghidra language binding", () => {
expect(ghidraRawBinaryLanguage("x86")).toEqual({
languageId: "x86:LE:32:default",
compilerSpecId: "gcc",
defaultAddressSpace: "ram",
});
expect(ghidraRawBinaryLanguage("x86_64")).toEqual({
languageId: "x86:LE:64:default",
compilerSpecId: "gcc",
defaultAddressSpace: "ram",
});
expect(ghidraRawBinaryLanguage("arm")).toEqual({
languageId: "ARM:LE:32:v7",
compilerSpecId: "default",
defaultAddressSpace: "ram",
});
expect(ghidraRawBinaryLanguage("arm64")).toEqual({
languageId: "AARCH64:LE:64:v8A",
compilerSpecId: "default",
defaultAddressSpace: "ram",
});
});
});
describe("raw-binary mapped-block state", () => {
it.each(["RAM", "ram"])(
"requires the bound 6502 address space for %s observations",
(space) => {
const f = fixtureRawBinaryLoadImage();
const target = {
...f.target,
architecture: "6502" as const,
availableArchitectures: ["6502" as const],
entryPoint: "0x8001",
entryPointSource: "0x8001",
};
f.observation.language_id = "6502:LE:16: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 = "0x8004";
mapping.address_space = space;
expect(
attestGhidraRawBinaryLoadImage(f.snapshot, target, f.observation),
).toMatchObject({
ok: true,
value: {
status: space === "RAM" ? "verified" : "mismatch",
checks: expect.arrayContaining([
expect.objectContaining({
name: "default_address_space",
expected: "RAM",
observed: space,
matched: space === "RAM",
}),
]),
},
});
},
);
it.each([
["initialized", false],
["loaded", false],
["overlay", true],
["address_space", "overlay"],
] as const)(
"retains a mismatch when the file mapping declares %s=%s",
(property, value) => {
const f = fixtureRawBinaryLoadImage();
const mapping = f.observation.mappings[0];
if (mapping === undefined)
throw new Error("Missing source mapping fixture");
Object.assign(mapping, { [property]: value });
expect(
attestGhidraRawBinaryLoadImage(f.snapshot, f.target, f.observation),
).toMatchObject({
ok: true,
value: {
status: "mismatch",
observations: f.observation,
checks: expect.arrayContaining([
expect.objectContaining({
name: `mapping.${property}`,
matched: false,
observed: value,
}),
]),
},
});
},
);
it("does not verify a foreign default address space even when its mapping agrees", () => {
const f = fixtureRawBinaryLoadImage();
f.observation.default_address_space = "foreign";
const mapping = f.observation.mappings[0];
if (mapping === undefined)
throw new Error("Missing source mapping fixture");
mapping.address_space = "foreign";
expect(
attestGhidraRawBinaryLoadImage(f.snapshot, f.target, f.observation),
).toMatchObject({
ok: true,
value: {
status: "mismatch",
observations: f.observation,
checks: expect.arrayContaining([
expect.objectContaining({
name: "default_address_space",
matched: false,
observed: "foreign",
}),
]),
},
});
});
});
describe("raw-binary independent load-image checks", () => {
it("retains uninitialized language memory without treating it as file-backed bytes", () => {
const f = fixtureRawBinaryLoadImage();
const zeroPage = {
block: "ZERO_PAGE",
start: "0x0",
end: "0xff",
address_space: "ram",
initialized: false,
loaded: true,
overlay: false,
length: 256,
source_file_index: null,
file_offset: null,
sha256: null,
};
f.observation.mappings.unshift(zeroPage);
expect(
attestGhidraRawBinaryLoadImage(f.snapshot, f.target, f.observation),
).toMatchObject({
ok: true,
value: { status: "verified", observations: f.observation },
});
zeroPage.initialized = true;
expect(
attestGhidraRawBinaryLoadImage(f.snapshot, f.target, f.observation),
).toMatchObject({
ok: true,
value: {
status: "mismatch",
checks: expect.arrayContaining([
expect.objectContaining({
name: "initialized_unbacked_mappings",
matched: false,
}),
]),
},
});
});
it("verifies mapped bytes at the caller-supplied entry", () => {
const f = fixtureRawBinaryLoadImage();
const result = attestGhidraRawBinaryLoadImage(

View File

@ -1,6 +1,6 @@
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { join, parse, relative } from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { parseConfig } from "../config/parseConfig.js";
import type {
@ -92,7 +92,14 @@ const target = (change: Partial<RiscvElfMetadata> = {}): BinaryTarget => ({
let installDir = "";
beforeAll(async () => {
installDir = await mkdtemp(join(tmpdir(), "rea-ghidra-riscv-languages-"));
const root =
process.platform === "win32" ? join(process.cwd(), "build") : tmpdir();
await mkdir(root, { recursive: true });
installDir = await mkdtemp(join(root, "rea-ghidra-riscv-languages-"));
// The simulated Linux host needs a POSIX absolute path. A current-drive root
// path also reaches this real fixture on Windows without changing admission.
if (process.platform === "win32")
installDir = `/${relative(parse(installDir).root, installDir).replaceAll("\\", "/")}`;
const languages = join(installDir, "Ghidra/Processors/RISCV/data/languages");
await mkdir(languages, { recursive: true });
await writeFile(join(languages, "riscv.ldefs"), RISCV_LDEFS);

View File

@ -123,6 +123,8 @@ export const hopperLoaderArgsForTarget = (
if (target.kind === "database") return ok([]);
if (target.kind !== "executable")
return err(new ProviderAdapterError("hopper", "resolve_analysis_profile"));
if (target.format === "raw-binary")
return err(new ProviderAdapterError("hopper", "resolve_analysis_profile"));
const architecture = target.architecture;
if (isElfMetadataArchitecture(architecture))
return err(
@ -155,7 +157,6 @@ export const hopperLoaderArgsForTarget = (
return ok(["-l", "WinPE", flag]);
case "dos-mz":
case "dos-com":
case "raw-binary":
return err(
new ProviderAdapterError("hopper", "resolve_analysis_profile"),
);

View File

@ -2,11 +2,7 @@ import { createHash } from "node:crypto";
import { open, type FileHandle } from "node:fs/promises";
import { z } from "zod";
import {
isMachOFormat,
isElfMetadataArchitecture,
type BinaryTarget,
} from "../domain/binaryTargetTypes.js";
import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
import { BinaryTargetError } from "../domain/configurationErrors.js";
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
import {
@ -48,10 +44,7 @@ export const resolveHopperMachOImage = async (
),
);
if (signal?.aborted) return err(new AnalysisCancelledError("open_binary"));
if (
!isMachOFormat(target.format) ||
isElfMetadataArchitecture(target.architecture)
)
if (target.format !== "mach-o" && target.format !== "mach-o-universal")
return invalid("Hopper FAT64 image preparation requires a Mach-O target");
let file: FileHandle;
try {

View File

@ -180,14 +180,17 @@ describe("trace-dylib-resolution CLI", () => {
// A root below a symlinked directory that points outside the bundle.
const outside = await createTestTempDirectory("rea-dylib-outside-");
await writeFiles(outside, { Tool: machoImage({}) });
await symlink(outside, join(app, "Contents/Linked"));
await symlink(outside, join(app, "Contents/Linked"), "dir");
for (const [root, reason, message] of [
["../outside", "invalid_format", "Use a normalized path"],
["Contents/Info.plist", "invalid_value", "is not a Mach-O image"],
[
"Contents/Linked/Tool",
"invalid_value",
"resolves outside the analyzed root",
// Windows link text is outside the POSIX dyld path representation.
process.platform === "win32"
? "is not a regular file in the analyzed root"
: "resolves outside the analyzed root",
],
] as const) {
await expectRootInputError(cli, { app, root, reason, message });
@ -378,7 +381,8 @@ cliTest(
},
);
describe.skipIf(process.getuid?.() === 0)(
// This permission fixture requires POSIX mode bits and a non-root caller.
describe.skipIf(process.platform === "win32" || process.getuid?.() === 0)(
"trace-dylib-resolution permissions",
() => {
cliTest(
@ -510,6 +514,8 @@ cliTest(
app,
"--root",
"Contents/MacOS/App",
"--architecture",
"arm64e",
"--json",
],
environment: ENVIRONMENT,
@ -518,9 +524,7 @@ cliTest(
const trace = dylibResolutionResultSchema.parse(
parseEvidence(result.json).normalized_result,
);
expect(
trace.roots.filter(({ architecture }) => architecture === "arm64e"),
).toHaveLength(2);
expect(trace.roots).toHaveLength(2);
const environments = trace.findings
.filter(({ kind }) => kind === "dyld-environment-present")
.map(({ explanation }) =>

View File

@ -38,6 +38,36 @@ cliTest(
},
);
cliTest(
"rejects a 6502 image crossing the 16-bit boundary before provider startup",
async ({ cli }) => {
const root = await createTestTempDirectory("rea-6502-cli-overflow-");
const path = join(root, "image.bin");
await writeFile(path, Buffer.from("a92a60", "hex"));
const result = await cli.run({
arguments: [
"inspect",
path,
"--target-format",
"raw-binary",
"--architecture",
"6502",
"--entry-point",
"0xffff",
"--provider",
"ghidra",
"--json",
],
environment: { HOME: root, XDG_CONFIG_HOME: root, XDG_CACHE_HOME: root },
});
expect(result.exitCode).toBe(1);
expect(result.json).toMatchObject({
code: "invalid_request",
details: { reason: expect.stringContaining("6502 address width") },
});
},
);
cliTest(
"rejects an unaligned AArch64 entry before provider startup",
async ({ cli }) => {

View File

@ -1,5 +1,5 @@
import { parseConfig } from "../../../src/config/parseConfig.js";
import { writeFile } from "node:fs/promises";
import { symlink, writeFile } from "node:fs/promises";
import { basename, dirname, join } from "node:path";
import { describe, expect, it, vi } from "vitest";
@ -14,7 +14,7 @@ import type {
import type { DirectAnalysisDependencies } from "../../../src/application/DirectAnalysisDependencies.js";
import { createAnalysisProfile } from "../../../src/domain/analysisProfile.js";
import { createAnalysisExecution } from "../../../src/application/AnalysisProvider.js";
import { ok } from "../../../src/domain/result.js";
import { err, ok } from "../../../src/domain/result.js";
import {
readAnalysisSnapshot,
writeAnalysisSnapshot,
@ -40,6 +40,7 @@ import {
} from "../../fixtures/binarySession.js";
import { BinarySession } from "../../../src/application/binary/BinarySession.js";
import { OwnedStagedOutput } from "../../../src/filesystem/OwnedStagedOutput.js";
import { ProviderAdapterError } from "../../../src/domain/providerAdapterError.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import {
CAPABILITIES as GHIDRA_CAPABILITIES,
@ -396,7 +397,85 @@ describe("snapshot output cleanup lifecycle", () => {
});
});
const verifyWindowsSnapshotAdmission = async (
selectedPath: "direct" | "junction",
): Promise<void> => {
const fixture = await createSnapshotReplayFixture(
"rea-direct-snapshot-junction-",
);
const rawPath = join(dirname(fixture.path), "image.bin");
await writeFile(rawPath, Buffer.from("a92a60", "hex"));
const linked = join(dirname(fixture.path), "linked");
const linkedPath = join(linked, basename(rawPath));
const identity = { ...GHIDRA_PROVIDER_IDENTITY, version: "12.1.4" };
const profile = createAnalysisProfile(identity, {
fixture: true,
});
const baseProvider = fixture.createProvider({
identity,
profile,
supportedOperations: ["search_strings"],
});
let rejectAdmission = false;
const provider: AnalysisProvider = {
...baseProvider,
createClient: (target, profile, context) => {
const client = baseProvider.createClient(target, profile, context);
return {
...client,
execute: (operation, parameters, options) =>
rejectAdmission && operation === "search_strings"
? Promise.resolve(
err(new ProviderAdapterError("ghidra", operation)),
)
: client.execute(operation, parameters, options),
};
},
};
const dependencies = fixture.dependencies(provider);
const arguments_ = { pattern: "fixture", document: "fixture" };
const options = {
snapshotPath: fixture.snapshotPath,
formatHint: "raw-binary" as const,
architecture: "6502" as const,
entryPoint: "0x8001",
};
const first = await runDirectAnalysis(
dependencies,
rawPath,
"search_strings",
arguments_,
options,
);
expect(first).toMatchObject({ operation: "search_strings" });
expect(fixture.starts).toHaveLength(1);
if (selectedPath === "junction")
await symlink(dirname(fixture.path), linked, "junction");
rejectAdmission = true;
const second = await runDirectAnalysis(
dependencies,
selectedPath === "junction" ? linkedPath : rawPath,
"search_strings",
arguments_,
options,
);
expect(second).toMatchObject({ error: "Analysis failed" });
expect(fixture.starts).toHaveLength(2);
expect(
fixture.calls.filter((call) => call === "search_strings"),
).toHaveLength(1);
};
describe("direct analysis composed snapshot replay", () => {
it.skipIf(process.platform !== "win32")(
"does not replay a CLI snapshot through a Windows junction",
() => verifyWindowsSnapshotAdmission("junction"),
);
it.skipIf(process.platform !== "win32")(
"does not replay a CLI snapshot before native admission on a canonical path",
() => verifyWindowsSnapshotAdmission("direct"),
);
it.each([
{
tool: "search_strings",
@ -516,7 +595,7 @@ describe("Ghidra composed workflow snapshot replay", () => {
{ tool: "inspect_native_api", arguments: { procedure: "0x1000" } },
{ tool: "trace_feature", arguments: { query: "fixture" } },
] as const)(
"persists and replays $tool with production Ghidra capability policies",
"persists $tool with production Ghidra capability policies",
async ({ tool, arguments: parameters }) => {
const fixture = await createSnapshotReplayFixture("rea-ghidra-workflow-");
const profile = createAnalysisProfile(
@ -554,8 +633,14 @@ describe("Ghidra composed workflow snapshot replay", () => {
{ snapshotPath: fixture.snapshotPath },
);
expect(replay).toEqual(first);
expect(fixture.starts).toHaveLength(1);
expect(fixture.calls).toEqual(initialCalls);
if (process.platform === "win32") {
// The first provider call in a new CLI session admits its source.
expect(fixture.starts).toHaveLength(2);
expect(fixture.calls.length).toBeGreaterThan(initialCalls.length);
} else {
expect(fixture.starts).toHaveLength(1);
expect(fixture.calls).toEqual(initialCalls);
}
},
);
});

View File

@ -388,7 +388,7 @@ it("produces deterministic ASAR graphs with unpacked native linkage and complete
await mkdir(source);
await writeJavaScriptArtifactFixture(source);
const archive = join(root, "app.asar");
await createPackageWithOptions(source, archive, { unpack: "**/*.node" });
await createPackageWithOptions(source, archive, { unpack: "*.node" });
const first = await reconstructJavaScriptArtifact({
input_path: archive,
@ -487,7 +487,7 @@ it("keeps ASAR analysis usable when unpacked native companion bytes are absent",
await mkdir(source);
await writeJavaScriptArtifactFixture(source);
const archive = join(root, "app.asar");
await createPackageWithOptions(source, archive, { unpack: "**/*.node" });
await createPackageWithOptions(source, archive, { unpack: "*.node" });
await rm(join(`${archive}.unpacked`, "native", "addon.node"));
const snapshot = await scanArtifactInventory(archive);
@ -515,7 +515,7 @@ it("recurses into filesystem-backed ASAR containers without losing container-rel
const resources = join(outer, "resources");
await mkdir(resources, { recursive: true });
await createPackageWithOptions(source, join(resources, "app.asar"), {
unpack: "**/*.node",
unpack: "*.node",
});
const result = await reconstructJavaScriptArtifact({

View File

@ -1,5 +1,11 @@
import { createHash } from "node:crypto";
import { readFile, writeFile } from "node:fs/promises";
import {
mkdir,
readFile,
realpath,
symlink,
writeFile,
} from "node:fs/promises";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
@ -7,6 +13,37 @@ import { parseBinaryTarget } from "../../../src/application/BinaryTargetResolver
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
describe("raw-binary admission", () => {
it("preserves the requested raw path for Windows reparse admission", async () => {
const directory = await createTestTempDirectory("rea-raw-linked-");
const actual = join(directory, "actual");
const linked = join(directory, "linked");
await mkdir(actual);
const bytes = Buffer.from("a90160", "hex");
await writeFile(join(actual, "fixture.bin"), bytes);
await symlink(
actual,
linked,
process.platform === "win32" ? "junction" : "dir",
);
const requested = join(linked, "fixture.bin");
const canonical = await realpath(requested);
const target = await parseBinaryTarget("linked/fixture.bin", {
cwd: directory,
formatHint: "raw-binary",
architecture: "6502",
entryPoint: "0x8000",
});
expect(target).toMatchObject({
ok: true,
value: {
path: canonical,
sourcePath: process.platform === "win32" ? requested : canonical,
sha256: createHash("sha256").update(bytes).digest("hex"),
format: "raw-binary",
architecture: "6502",
},
});
});
it("requires explicit architecture and entry even with a misleading suffix", async () => {
const directory = await createTestTempDirectory("rea-raw-target-");
const path = join(directory, "fixture.js");

View File

@ -1,4 +1,4 @@
import { writeFile } from "node:fs/promises";
import { mkdir, symlink, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
@ -10,7 +10,8 @@ import { createServer } from "../../../src/server/createServer.js";
import { toolContract } from "../../../src/contracts/toolContracts.js";
import type { AnalysisProvider } from "../../../src/application/AnalysisProvider.js";
import { createAnalysisProfile } from "../../../src/domain/analysisProfile.js";
import { ok } from "../../../src/domain/result.js";
import { ProviderAdapterError } from "../../../src/domain/providerAdapterError.js";
import { err, ok } from "../../../src/domain/result.js";
import { observed } from "../../fixtures/analysisExecution.js";
import {
createCacheProvider,
@ -18,6 +19,7 @@ import {
} from "../../fixtures/binarySession.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { silentLogger } from "../../../src/logger.js";
import { GHIDRA_PROVIDER_IDENTITY } from "../../../src/ghidra/GhidraProviderCapabilities.js";
import type { CallToolResult } from "@modelcontextprotocol/server";
const connect = async (
@ -50,29 +52,97 @@ const structured = (result: CallToolResult): Record<string, unknown> => {
return z.record(z.string(), z.unknown()).parse(result.structuredContent);
};
it("opens a headerless image when architecture and entry are supplied", async () => {
const root = await createTestTempDirectory("rea-raw-mcp-");
const path = join(root, "ecu.bin");
await writeFile(path, Buffer.from("1eff2fe1", "hex"));
const { client } = await connect();
const opened = await client.callTool({
name: "open_binary",
arguments: {
const withGhidraFixtureIdentity = (
provider: AnalysisProvider,
): AnalysisProvider => {
const identity = { ...GHIDRA_PROVIDER_IDENTITY, version: "12.1.4" };
const profile = createAnalysisProfile(identity, { fixture: true });
return {
...provider,
identity: () => identity,
capabilities: () =>
provider.capabilities().map((capability) => ({
...capability,
provider: identity,
})),
resolveAnalysisProfile: () => Promise.resolve(ok({ profile })),
createClient: (target, _profile, context) => {
const client = provider.createClient(target, profile, context);
return {
...client,
execute: async (operation, parameters, options) => {
const result = await client.execute(operation, parameters, options);
return result.ok
? ok({
...result.value,
provider: identity,
analysisProfile: profile,
})
: result;
},
};
},
};
};
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([
["arm", "0x08000000", "0x8000000", "1eff2fe1"],
["6502", "0x008001", "0x8001", "a92a60"],
])(
"opens a headerless %s image when architecture and entry are supplied",
async (architecture, entryPoint, canonical, bytes) => {
const root = await createTestTempDirectory("rea-raw-mcp-");
const path = join(root, "ecu.bin");
await writeFile(path, Buffer.from(bytes, "hex"));
const { client } = await connect();
const opened = await client.callTool({
name: "open_binary",
arguments: {
path,
format: "raw-binary",
architecture,
entry_point: entryPoint,
},
});
expect(opened.isError).not.toBe(true);
expect(structured(opened).result).toMatchObject({
path,
format: "raw-binary",
architecture: "arm",
entry_point: "0x08000000",
},
});
expect(opened.isError).not.toBe(true);
expect(structured(opened).result).toMatchObject({
path,
format: "raw-binary",
kind: "executable",
architecture: "arm",
entry_point: "0x8000000",
});
});
kind: "executable",
architecture,
entry_point: canonical,
});
},
);
it("rejects an unaligned AArch64 entry before selecting or starting a provider", async () => {
const root = await createTestTempDirectory("rea-raw-mcp-alignment-");
@ -217,3 +287,118 @@ it("reuses a canonical raw entry and replaces the session when the entry changes
expect(created).toEqual(["0x8000000", "0x9000000"]);
expect(closed).toEqual(["0x8000000"]);
});
it.skipIf(process.platform !== "win32")(
"does not replay a saved read after switching to a Windows junction",
async () => {
const root = await createTestTempDirectory("rea-raw-mcp-linked-reopen-");
const actual = join(root, "actual");
const linked = join(root, "linked");
await mkdir(actual);
const path = join(actual, "image.bin");
await writeFile(path, Buffer.from("a92a60", "hex"));
await symlink(actual, linked, "junction");
const created: string[] = [];
const executed: string[] = [];
const fixture = createCacheProvider([]);
const { client } = await connect(
withGhidraFixtureIdentity({
...fixture,
createClient: (target) => {
created.push(target.sourcePath ?? target.path);
return {
execute: (operation) => {
if (operation === "address_name")
executed.push(target.sourcePath ?? target.path);
if (
operation === "address_name" &&
target.sourcePath === join(linked, "image.bin")
)
return Promise.resolve(
err(new ProviderAdapterError("fixture", operation)),
);
return Promise.resolve(
observed(operation === "address_name" ? "entry" : null),
);
},
close: () => Promise.resolve(ok(null)),
};
},
}),
);
const linkedPath = join(linked, "image.bin");
for (const requested of [path, linkedPath]) {
const opened = await client.callTool({
name: "open_binary",
arguments: {
path: requested,
format: "raw-binary",
architecture: "6502",
entry_point: "0x8001",
},
});
expect(opened.isError, JSON.stringify(opened)).not.toBe(true);
const address = await client.callTool({
name: "address_name",
arguments: { address: "0x8001", document: "image.bin" },
});
expect(address.isError === true, JSON.stringify(address)).toBe(
requested === linkedPath,
);
}
expect(created).toEqual([path, linkedPath]);
expect(executed).toEqual([path, linkedPath]);
},
);
it.skipIf(process.platform !== "win32")(
"reuses saved reads after a fixture admits a noncanonical Windows path",
async () => {
const root = await createTestTempDirectory("rea-raw-mcp-admitted-path-");
const actual = join(root, "actual");
const linked = join(root, "linked");
await mkdir(actual);
const path = join(actual, "image.bin");
await writeFile(path, Buffer.from("a92a60", "hex"));
await symlink(actual, linked, "junction");
const calls: string[] = [];
const fixture = createCacheProvider([]);
// This fixture accepts the alias to isolate cache behavior after admission.
// The real Ghidra verifier checks that a junction itself is rejected.
const { client, session } = await connect(
withGhidraFixtureIdentity({
...fixture,
createClient: () => ({
execute: (operation) => {
if (operation === "address_name") calls.push(operation);
return Promise.resolve(
observed(operation === "health" ? null : "entry"),
);
},
close: () => Promise.resolve(ok(null)),
}),
}),
);
const linkedPath = join(linked, "image.bin");
const opened = await client.callTool({
name: "open_binary",
arguments: {
path: linkedPath,
format: "raw-binary",
architecture: "6502",
entry_point: "0x8001",
},
});
expect(opened.isError, JSON.stringify(opened)).not.toBe(true);
expect(session.activeTarget()?.sourcePath).toBe(linkedPath);
expect(session.activeTarget()?.path).toBe(path);
for (const _ of [0, 1]) {
const address = await client.callTool({
name: "address_name",
arguments: { address: "0x8001", document: "image.bin" },
});
expect(address.isError, JSON.stringify(address)).not.toBe(true);
}
expect(calls).toEqual(["address_name"]);
},
);

View File

@ -135,7 +135,7 @@ it("ignores unavailable members of a nested ASAR but refuses them as active entr
const source = join(root, "bundle");
await mkdir(source);
const archive = join(source, "app.asar");
await createPackageWithOptions(contents, archive, { unpack: "**/*.node" });
await createPackageWithOptions(contents, archive, { unpack: "*.node" });
await rm(join(`${archive}.unpacked`, "native", "addon.node"));
const nestedOutput = join(root, "nested-output");

View File

@ -0,0 +1,9 @@
FEATURES {
STARTADDRESS: default = $8001;
}
MEMORY {
IMAGE: start = %S, size = $10000 - %S, file = %O;
}
SEGMENTS {
CODE: load = IMAGE, type = ro;
}

View File

@ -0,0 +1,19 @@
; Source-owned NMOS 6502 fixture: direct call, conditional branch, and arithmetic.
.setcpu "6502"
.segment "CODE"
.export entry, add_three
entry:
lda #7
jsr add_three
cmp #10
bne mismatch
lda #42
rts
mismatch:
lda #0
rts
add_three:
clc
adc #3
rts

View File

@ -35,6 +35,20 @@ const shellExamples = (content: string): string[] =>
);
describe("onboarding documentation product facts", () => {
it.each([
["docs/native-investigation.md", "raw-binary.md#nmos-6502"],
["docs/ghidra-riscv.md", "raw-binary.md"],
])(
"routes raw admission guidance in %s to the owning guide",
async (path, target) => {
const content = await readFile(resolve(path), "utf8");
const links = [...content.matchAll(/\]\(([^)\s]+)\)/gu)].map(
(match) => match[1],
);
expect(links, path).toContain(target);
},
);
it("keeps the installation MCP example aligned with the package registration command", async () => {
const content = await readFile(resolve("docs/installation.md"), "utf8");
expect(jsonExamples(content)).toContainEqual(

View File

@ -29,7 +29,7 @@ export const createStrippedAsarAddon = async (
await mkdir(dirname(path), { recursive: true });
await writeFile(path, file.contents);
}
await createPackageWithOptions(source, archive, { unpack: "**/*.node" });
await createPackageWithOptions(source, archive, { unpack: "*.node" });
await writeFile(
join(`${archive}.unpacked`, "node_modules", "btime", "binding.node"),
stripped,