feat(ghidra): verify DOS load images and expose memory evidence (#697)

* feat(ghidra): verify DOS load images and expose memory evidence

Verify measured source bytes, mapped regions, fixups and entries against
the immutable MZ snapshot, retaining independent checks and raw evidence.
Expose loaded byte reads and authoritative original file offsets through
both CLI and MCP, with focused and actual Ghidra DOS verification.

Assisted-by: Nitori (gensokyo-skills:nitori-reverse-engineering)

* fix(cli): describe memory inspection arguments

* test(ghidra): align bridge fixtures and CLI capability accounting
This commit is contained in:
N0zoM1z0 authored and GitHub committed 2026-10-06 15:48:48 +08:00
1 parent 132a3a46be
commit a6a8f67649
35 files changed
+1513 -28

No files matched your search

+1 -1
View File
@@ -307,7 +307,7 @@ See [native investigation](docs/native-investigation.md) for keyed archives, ins
| Tool family | Count | Examples |
| ------------------------- | ----: | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Native inspection | 39 | functions, pseudocode, assembly, strings, symbols, calls, references, annotations, byte reads, and file offsets |
| Native inspection | 40 | functions, pseudocode, assembly, strings, symbols, calls, references, annotations, byte reads, and file offsets |
| Investigation workflows | 14 | app overviews, function dossiers, native APIs and dispatch, batch decompilation, feature traces, call paths, call graphs, Swift and Objective-C discovery |
| Native macOS utilities | 7 | Mach-O metadata, code signatures, plists, architectures, and Swift demangling without launching Hopper |
| Artifact graph | 5 | directory and package inventories, compiled Interface Builder files, Apple asset catalogs, and extraction |
+1 -1
View File
@@ -204,7 +204,7 @@ rea setup
| عائلة الأدوات | العدد | الاستخدام |
| --------------------- | ----: | -------------------------------------------------------------------------------------- |
| فحص الملفات التنفيذية | 39 | الدوال والشيفرة شبه المصدرية والتعليمات والسلاسل والرموز والمراجع والتعليقات |
| فحص الملفات التنفيذية | 40 | الدوال والشيفرة شبه المصدرية والتعليمات والسلاسل والرموز والمراجع والتعليقات |
| التحليل المركب | 14 | النظرة العامة وتحليل الدوال وفك الترجمة الدفعي ومخطط الاستدعاء وفحص Swift وObjC |
| أدوات macOS الأصلية | 7 | بيانات Mach-O والتوقيعات وplist والمعماريات واستعادة أسماء Swift |
| الملفات والحزم | 5 | فحص الدلائل والحزم وInterface Builder وموارد Apple والاستخراج |
+1 -1
View File
@@ -193,7 +193,7 @@ REA は手順 1〜5 のバイナリ解析を処理し、手順 6 はエージェ
| ツール分類 | 数 | 用途 |
| -------------------- | --: | --------------------------------------------------------------------------- |
| バイナリ検査 | 39 | 関数、疑似コード、アセンブリ、文字列、シンボル、参照、注釈 |
| バイナリ検査 | 40 | 関数、疑似コード、アセンブリ、文字列、シンボル、参照、注釈 |
| 組み合わせた解析 | 14 | 概要、関数解析、一括逆コンパイル、呼び出しグラフ、Swift と ObjC |
| macOS ネイティブ | 7 | Mach-O メタデータ、署名、plist、アーキテクチャ、Swift 名の復元 |
| ファイルとパッケージ | 5 | ディレクトリとパッケージ、Interface Builder、Apple リソース、抽出 |
+1 -1
View File
@@ -194,7 +194,7 @@ REA는 1–5단계의 바이너리 분석을 처리합니다. 6단계는 에이
| 도구 분류 | 수 | 용도 |
| ----------------- | --: | -------------------------------------------------------------------- |
| 바이너리 검사 | 39 | 함수, 의사 코드, 어셈블리, 문자열, 심볼, 참조, 주석 |
| 바이너리 검사 | 40 | 함수, 의사 코드, 어셈블리, 문자열, 심볼, 참조, 주석 |
| 결합 분석 | 14 | 개요, 함수 분석, 일괄 디컴파일, 호출 그래프, Swift와 ObjC 검사 |
| macOS 네이티브 | 7 | Mach-O 메타데이터, 서명, plist, 아키텍처, Swift 이름 복원 |
| 파일과 패키지 | 5 | 디렉터리와 패키지 검사, Interface Builder, Apple 리소스, 추출 |
+1 -1
View File
@@ -193,7 +193,7 @@ REA 负责第 1–5 步中的二进制分析。第 6 步由智能体使用其常
| 工具类别 | 数量 | 用途 |
| ----------------- | ---: | --------------------------------------------------------- |
| 二进制检查 | 39 | 函数、伪代码、汇编、字符串、符号、段、引用与注释 |
| 二进制检查 | 40 | 函数、伪代码、汇编、字符串、符号、段、引用与注释 |
| 组合分析 | 14 | 概览、函数分析、批量反编译、调用图、Swift 与 ObjC 检查 |
| macOS 原生工具 | 7 | Mach-O 元数据、代码签名、plist、架构与 Swift 符号还原 |
| 制品检查 | 5 | 目录与软件包检查、Interface Builder、Apple 资源目录与提取 |
+190
View File
@@ -73,6 +73,11 @@ import ghidra.program.model.listing.Instruction;
import ghidra.program.model.listing.InstructionIterator;
import ghidra.program.model.listing.Variable;
import ghidra.program.model.mem.MemoryBlock;
import ghidra.program.model.mem.MemoryBlockSourceInfo;
import ghidra.program.database.mem.FileBytes;
import ghidra.program.model.address.SegmentedAddress;
import ghidra.program.model.reloc.Relocation;
import java.io.ByteArrayOutputStream;
import ghidra.program.model.symbol.RefType;
import ghidra.program.model.symbol.Reference;
import ghidra.program.model.symbol.ReferenceIterator;
@@ -112,6 +117,9 @@ public final class ReaGhidraBridge extends HeadlessScript {
"params"
);
private static final String[] CAPABILITIES = {
"inspect_native_load_image",
"read_bytes",
"address_to_file_offset",
"ping",
"shutdown",
"address_name",
@@ -309,6 +317,9 @@ public final class ReaGhidraBridge extends HeadlessScript {
);
}
return switch (request.method) {
case "inspect_native_load_image" -> inspectNativeLoadImage(request.params);
case "read_bytes" -> readMemoryBytes(request.params);
case "address_to_file_offset" -> addressToFileOffset(request.params);
case "address_name" -> addressName(request.params);
case "list_documents" -> listDocuments(request.params);
case "list_names" -> listNames(request.params);
@@ -371,6 +382,185 @@ public final class ReaGhidraBridge extends HeadlessScript {
return result;
}
private static String bytesHex(byte[] bytes) {
return java.util.HexFormat.of().formatHex(bytes);
}
private String hashFileBytes(FileBytes source, boolean modified) throws Exception {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] chunk = new byte[65536];
for (long offset = 0; offset < source.getSize();) {
monitor.checkCancelled();
int length = (int) Math.min(chunk.length, source.getSize() - offset);
int read = modified
? source.getModifiedBytes(offset, chunk, 0, length)
: source.getOriginalBytes(offset, chunk, 0, length);
if (read != length) throw new IOException("Incomplete FileBytes read at " + offset);
digest.update(chunk, 0, read);
offset += read;
}
return bytesHex(digest.digest());
}
private String hashMemory(Address start, long length) throws Exception {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] chunk = new byte[65536];
for (long offset = 0; offset < length;) {
monitor.checkCancelled();
int count = (int) Math.min(chunk.length, length - offset);
int read = currentProgram.getMemory().getBytes(start.add(offset), chunk, 0, count);
if (read != count) throw new IOException("Incomplete memory read at " + start.add(offset));
digest.update(chunk, 0, read);
offset += read;
}
return bytesHex(digest.digest());
}
private JsonObject inspectNativeLoadImage(JsonObject params) throws Exception {
requireKeys(params, Set.of());
JsonObject result = new JsonObject();
result.addProperty("executable_format", currentProgram.getExecutableFormat());
result.addProperty("language_id", currentProgram.getLanguageID().getIdAsString());
result.addProperty("compiler_spec_id", currentProgram.getCompilerSpec().getCompilerSpecID().getIdAsString());
result.addProperty("image_base", canonicalAddress(currentProgram.getImageBase()));
result.addProperty("default_address_space", sessionDefaultAddressSpace.getName());
List<FileBytes> files = currentProgram.getMemory().getAllFileBytes();
JsonArray sources = new JsonArray();
for (FileBytes file : files) {
JsonObject source = new JsonObject();
source.addProperty("name", file.getFilename());
source.addProperty("size", file.getSize());
source.addProperty("original_sha256", hashFileBytes(file, false));
source.addProperty("modified_sha256", hashFileBytes(file, true));
sources.add(source);
}
result.add("source_files", sources);
JsonArray mappings = new JsonArray();
for (MemoryBlock block : currentProgram.getMemory().getBlocks()) {
for (MemoryBlockSourceInfo source : block.getSourceInfos()) {
monitor.checkCancelled();
JsonObject mapping = new JsonObject();
mapping.addProperty("block", block.getName());
mapping.addProperty("start", canonicalAddress(source.getMinAddress()));
mapping.addProperty("end", canonicalAddress(source.getMaxAddress()));
mapping.addProperty("address_space", source.getMinAddress().getAddressSpace().getName());
mapping.addProperty("initialized", block.isInitialized());
mapping.addProperty("loaded", block.isLoaded());
mapping.addProperty("overlay", block.isOverlay());
mapping.addProperty("length", source.getLength());
FileBytes backing = source.getFileBytes().orElse(null);
int sourceIndex = backing == null ? -1 : files.indexOf(backing);
if (sourceIndex < 0) mapping.add("source_file_index", JsonNull.INSTANCE);
else mapping.addProperty("source_file_index", sourceIndex);
long offset = source.getFileBytesOffset();
if (offset < 0) mapping.add("file_offset", JsonNull.INSTANCE);
else mapping.addProperty("file_offset", offset);
if (block.isInitialized()) mapping.addProperty("sha256", hashMemory(source.getMinAddress(), source.getLength()));
else mapping.add("sha256", JsonNull.INSTANCE);
mappings.add(mapping);
}
}
result.add("mappings", mappings);
JsonArray relocations = new JsonArray();
Iterator<Relocation> iterator = currentProgram.getRelocationTable().getRelocations();
while (iterator.hasNext()) {
monitor.checkCancelled();
Relocation relocation = iterator.next();
Address location = relocation.getAddress();
JsonObject row = new JsonObject();
row.addProperty("address", canonicalAddress(location));
if (location instanceof SegmentedAddress segmented) {
row.addProperty("segment", segmented.getSegment());
row.addProperty("offset", segmented.getSegmentOffset());
} else {
row.add("segment", JsonNull.INSTANCE);
row.add("offset", JsonNull.INSTANCE);
}
row.addProperty("status", relocation.getStatus().toString());
row.addProperty("type", relocation.getType());
row.add("values", GSON.toJsonTree(relocation.getValues() == null ? new long[0] : relocation.getValues()));
byte[] original = relocation.getBytes();
row.add("original_bytes_hex", original == null ? JsonNull.INSTANCE : GSON.toJsonTree(bytesHex(original)));
if (original != null && original.length > 0) {
byte[] memory = new byte[original.length];
int read = currentProgram.getMemory().getBytes(location, memory);
row.add("memory_bytes_hex", read == memory.length ? GSON.toJsonTree(bytesHex(memory)) : JsonNull.INSTANCE);
} else row.add("memory_bytes_hex", JsonNull.INSTANCE);
relocations.add(row);
}
result.add("relocations", relocations);
List<Address> entries = new ArrayList<>();
var entryIterator = currentProgram.getSymbolTable().getExternalEntryPointIterator();
while (entryIterator.hasNext()) entries.add(entryIterator.next());
entries.sort(Address::compareTo);
JsonArray entryPoints = new JsonArray();
for (Address entry : entries) entryPoints.add(canonicalAddress(entry));
result.add("entry_points", entryPoints);
return result;
}
private JsonObject readMemoryBytes(JsonObject params) throws Exception {
requireKeys(params, Set.of("document", "address", "length"));
requireDocument(params);
Address start = requireAddress(params, "address");
JsonElement requested = params.get("length");
long length;
try {
if (requested == null || !requested.isJsonPrimitive() || !requested.getAsJsonPrimitive().isNumber())
throw new IllegalArgumentException();
length = requested.getAsBigDecimal().longValueExact();
if (length < 1 || length > 9007199254740991L) throw new IllegalArgumentException();
} catch (RuntimeException failure) {
throw new RequestFailure("invalid_request", "Byte length must be a positive safe integer");
}
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
byte[] chunk = new byte[65536];
for (long offset = 0; offset < length;) {
monitor.checkCancelled();
Address at;
try { at = start.addNoWrap(offset); }
catch (Exception endOfSpace) { break; }
MemoryBlock block = currentProgram.getMemory().getBlock(at);
if (block == null || !block.isInitialized()) break;
int count = (int) Math.min(chunk.length, Math.min(length - offset, block.getEnd().subtract(at) + 1));
int read = currentProgram.getMemory().getBytes(at, chunk, 0, count);
bytes.write(chunk, 0, read);
if (read != count) break;
offset += read;
}
JsonObject result = new JsonObject();
result.addProperty("address", canonicalAddress(start));
result.addProperty("requested_bytes", length);
result.addProperty("returned_bytes", bytes.size());
result.addProperty("bytes_hex", bytesHex(bytes.toByteArray()));
result.addProperty("complete", bytes.size() == length);
return result;
}
private JsonObject addressToFileOffset(JsonObject params) {
requireKeys(params, Set.of("document", "address"));
requireDocument(params);
Address address = requireAddress(params, "address");
MemoryBlock block = currentProgram.getMemory().getBlock(address);
if (block == null || !block.isInitialized())
throw new RequestFailure("invalid_request", "Address has no initialized file-backed memory: " + canonicalAddress(address));
Long fileOffset = null;
int mappings = 0;
for (MemoryBlockSourceInfo source : block.getSourceInfos()) {
if (!source.contains(address) || source.getFileBytes().isEmpty()) continue;
long offset = source.getFileBytesOffset(address);
if (offset < 0) continue;
fileOffset = offset;
mappings++;
}
if (mappings != 1)
throw new RequestFailure("invalid_request", "Address has " + mappings + " authoritative source mappings: " + canonicalAddress(address));
JsonObject result = new JsonObject();
result.addProperty("address", canonicalAddress(address));
result.addProperty("file_offset", fileOffset);
return result;
}
private void initializeDecompiler() {
DecompileOptions options = new DecompileOptions();
options.setMaxInstructions(Integer.MAX_VALUE);
+27
View File
@@ -13,6 +13,9 @@ rea inspect /absolute/path/to/legacy.exe --provider ghidra --json
rea search /absolute/path/to/legacy.exe entry --kind procedures --provider ghidra --json
rea instructions /absolute/path/to/legacy.exe entry --provider ghidra --json
rea function /absolute/path/to/legacy.exe entry --provider ghidra --json
rea inspect-native-load-image /absolute/path/to/legacy.exe --provider ghidra --json
rea read-bytes /absolute/path/to/legacy.exe 0x10000 --length 16 --provider ghidra --json
rea address-to-file-offset /absolute/path/to/legacy.exe 0x10000 --provider ghidra --json
```
Use an observed procedure name or address from discovery when the target does
@@ -71,6 +74,30 @@ Ghidra's database observation, not a proof of original source ownership or
exhaustive code discovery. Providers that do not report complete ranges return
`available: false` and an explicit reason.
## Loaded-image evidence
`inspect_native_load_image` independently checks the immutable session snapshot
against the loaded Ghidra Program. It measures complete original and modified
FileBytes digests, every source mapping and its memory digest, relocation records
(including multiplicity), and external entry points. It verifies MZ header and
module partitions, relocation fixups and linear coordinates rather than relying
on the imported executable SHA alone.
The result is `verified`, `mismatch`, or `unsupported`. Every check retains its
expected and observed values. A memory-digest mismatch identifies the source
range's file offset and analysis address; it does not locate the first differing
byte inside that range. Mapping ends are **inclusive**, unlike the exclusive ends
from `list_segments`. Appended overlays remain in source-file identity and are
excluded from initialized module coverage. Uninitialized allocations are reported;
their contents and DOS runtime semantics are not verified. Other formats return
`unsupported` with measured observations, not a successful MZ verification.
`read_bytes` returns initialized provider memory, including loader fixups; an
unmapped or uninitialized byte ends the read with an explicit returned length and
`complete: false`. `address_to_file_offset` uses the provider's source mapping;
unmapped, uninitialized and ambiguous mappings fail explicitly. A relocated word
can differ from the original file word even when its source offset is known.
## Coverage boundaries
Static decompilation does not execute the target or emulate BIOS, DOS
+16 -9
View File
@@ -7,15 +7,15 @@
"client": "2.3.1",
"core": "2.3.1"
},
"skill_version": "25"
"skill_version": "26"
},
"tools": {
"total": 116,
"total": 117,
"families": [
{
"id": "direct",
"surface": "official-proxy",
"count": 39,
"count": 40,
"tools": [
"address_name",
"address_to_file_offset",
@@ -27,6 +27,7 @@
"inline_comment",
"inspect_native_data_type",
"inspect_native_instruction",
"inspect_native_load_image",
"list_bookmarks",
"list_documents",
"list_names",
@@ -207,9 +208,11 @@
"version": null,
"capabilities": [
"address_name",
"address_to_file_offset",
"analyze_function",
"inspect_native_data_type",
"inspect_native_instruction",
"inspect_native_load_image",
"list_documents",
"list_names",
"list_procedures",
@@ -222,6 +225,7 @@
"procedure_info",
"procedure_pseudo_code",
"procedure_references",
"read_bytes",
"read_function_instructions",
"resolve_containing_procedure",
"resolve_native_call_targets",
@@ -465,8 +469,9 @@
}
],
"cli": {
"primary_count": 69,
"primary_count": 72,
"commands": [
"address-to-file-offset",
"analyze",
"analyze-javascript-application",
"analyze-web-bundle",
@@ -510,6 +515,7 @@
"inspect-native-data-type",
"inspect-native-dispatch-metadata",
"inspect-native-instruction",
"inspect-native-load-image",
"inspect-plist",
"inspect-signature",
"inspect-web-page",
@@ -523,6 +529,7 @@
"observe-web-session",
"project-managed-application-graph",
"providers",
"read-bytes",
"reconcile-javascript-runtime",
"resolve-native-call-targets",
"search",
@@ -546,15 +553,15 @@
},
"runtime_catalog": {
"counts": {
"cli_commands": 69,
"mcp_tools": 116,
"cli_commands": 72,
"mcp_tools": 117,
"mcp_prompts": 6
},
"digests": {
"tools_sha256": "7849b6f39bed200cfbf5f4d09ba3f9e0661824efb5753ae2edde8ef2fb66535e",
"tools_sha256": "29258155c0d2a5e174bf63b777c35689cec70bd016c85046c62ebaad8e8a57a9",
"prompts_sha256": "4762db348821fec787a5b8c6423f2c48a71f6f0ec2afee97b16ad69030ec504b",
"combined_sha256": "5bb3f994a3c4d0c87a34ff5aab4e28141b5fa6f3a98b5ae7e95686d85d9d983e",
"providers_sha256": "2ae8b85b20be338739e500549d275e78e875aaa10235d2ca283f0ade7787da90"
"combined_sha256": "61b0a8d47bf5bf791e15b7ffe774f8f54e8371742283e110a8d307df308eb110",
"providers_sha256": "c1a6849b2524347e6cf9e004a17ba1805f86114b2da72eb689a075afbbb1aada"
}
}
}
@@ -1,5 +1,5 @@
{
"manifest_id": "ecm_6f5a77c85a58f9980ef709b832b5ccb5207c137d40e181b1ea209afa3de60c67",
"manifest_id": "ecm_8903d94ebb803743a2028ca1ea3909e2927e05f3d34f9a03eb96388434e4b629",
"verifier": {
"id": "rea-managed-conformance",
"version": "1"
@@ -13,7 +13,7 @@
"skill_digests": [
{
"skill_id": "reverse-engineer-anything",
"sha256": "08b7039f48babaeda3660d9f62fa1066a6e9f72cf522a3f35201e59634253537"
"sha256": "d2bbf020f2865b25daad721a1b05c9aed5f3fe6b16c1efa5c9652f7c3e1b775a"
}
],
"claims": [
+95 -2
View File
@@ -106,6 +106,78 @@ try {
assert.equal(target.kind, "executable");
assert.equal(target.architecture, "x86");
assert.equal(target.sha256, sha256);
const image = await call("inspect_native_load_image");
assert.equal(
image.status,
"verified",
JSON.stringify(image.checks?.filter((check) => !check.matched)),
);
assert.equal(image.target_sha256, sha256);
assert.equal(image.header_bytes, fixture.header_bytes);
assert.equal(image.module_bytes, fixture.module_bytes);
assert.equal(image.entry.linear_address, expected.entry);
assert.ok(image.checks.every((check) => check.matched));
const cliImage = await cliEvidence("inspect-native-load-image");
// Snapshot filenames are session-specific. Every measured byte/range/check is stable.
const stableImage = (value) => ({
...value,
observations: {
...value.observations,
source_files: value.observations.source_files.map(
({ name: _name, ...file }) => file,
),
},
});
assert.deepEqual(stableImage(cliImage.normalized_result), stableImage(image));
const mapped = await call("address_to_file_offset", {
address: expected.entry,
});
assert.equal(mapped.file_offset, fixture.header_bytes);
const headerMapping = await call("address_to_file_offset", {
address: "HEADER:0x0",
});
assert.equal(headerMapping.file_offset, 0);
const fileRelocation = await call("address_to_file_offset", {
address: address(fixture.relocation_offset),
});
assert.equal(
fileRelocation.file_offset,
fixture.header_bytes + fixture.relocation_offset,
);
const memoryRelocation = await call("read_bytes", {
address: address(fixture.relocation_offset),
length: 2,
});
assert.equal(memoryRelocation.bytes_hex, "0410");
assert.equal(memoryRelocation.complete, true);
assert.deepEqual(
(
await cliEvidence("read-bytes", address(fixture.relocation_offset), [
"--length",
"2",
])
).normalized_result,
memoryRelocation,
);
assert.deepEqual(
(await cliEvidence("address-to-file-offset", expected.entry))
.normalized_result,
mapped,
);
const tail = await call("read_bytes", {
address: address(fixture.module_bytes - 2),
length: 8,
});
assert.equal(tail.returned_bytes, 2);
assert.equal(tail.complete, false);
const unmapped = await call("read_bytes", { address: "0x80000", length: 8 });
assert.equal(unmapped.returned_bytes, 0);
assert.equal(unmapped.complete, false);
const noSource = await client.callTool({
name: "address_to_file_offset",
arguments: { address: "0x80000" },
});
assert.equal(noSource.isError, true);
const procedures = await call("list_procedures");
assert.ok(
Array.isArray(procedures),
@@ -330,6 +402,22 @@ try {
load_segment: profile.parameters.load_segment,
},
transport: { cli: true, stdio_mcp: true },
load_image: {
status: image.status,
checks: image.checks.length,
mappings: image.observations.mappings.length,
relocations: image.observations.relocations.length,
cli_mcp_parity: true,
},
memory_evidence: {
relocated_word_hex: memoryRelocation.bytes_hex,
entry_file_offset: mapped.file_offset,
header_file_offset: headerMapping.file_offset,
partial_read_bytes: tail.returned_bytes,
unmapped_read_bytes: unmapped.returned_bytes,
unmapped_file_offset_rejected: true,
cli_mcp_parity: true,
},
functions: facts,
instruction_oracles: {
entry_bytes: hexBytes(move.bytes),
@@ -400,6 +488,10 @@ function hexBytes(bytes) {
return bytes.replace(/\s/gu, "").toLowerCase();
}
function assertProfile(profile) {
assert.equal(
profile.parameters.load_image_evidence,
"independent-mz-mapping-relocations-v1",
);
assert.equal(
profile.parameters.function_body_evidence,
"complete-inclusive-ranges-v1",
@@ -471,14 +563,15 @@ async function evidenceCall(name, arguments_) {
assertProfile(evidence.analysis_profile.parameters.upstream_analysis_profile);
return evidence;
}
async function cliEvidence(command, procedure) {
async function cliEvidence(command, procedure, options = []) {
const { stdout } = await promisify(execFile)(
process.execPath,
[
entrypoint,
command,
targetPath,
procedure,
...(procedure === undefined ? [] : [procedure]),
...options,
"--provider",
"ghidra",
"--json",
+3 -3
View File
@@ -2,9 +2,9 @@
name: reverse-engineer-anything
description: Reverse engineer native, managed, Electron/JavaScript, packaged, and browser applications with REA. Use shipped-artifact or requested runtime evidence to explain features, compare versions, decompile code, or guide a reconstruction. Skip REA for ordinary source-repository architecture analysis.
metadata:
version: "25"
tool_count: 116
catalog_digest: "5bb3f994a3c4d0c87a34ff5aab4e28141b5fa6f3a98b5ae7e95686d85d9d983e"
version: "26"
tool_count: 117
catalog_digest: "61b0a8d47bf5bf791e15b7ffe774f8f54e8371742283e110a8d307df308eb110"
---
# REA
@@ -15,6 +15,20 @@ complete tool inventory with availability reasons and remediation. When choosing
a tool, use its entry in that result; `tools/list` retains the complete catalog
when the target or provider state changes.
## DOS MZ with Ghidra
For an admitted DOS MZ target, use `inspect_native_load_image` to check measured
loaded bytes, file mappings, relocations and the entry against the immutable
snapshot. Keep `mismatch` and `unsupported` explicit. Verified import covers the
reported static image, not runtime DOS or PC-98 hardware behavior.
Use `read_bytes` for initialized analysis memory and `address_to_file_offset` to
anchor one observed address to original file bytes. Loader fixups can change a
word; a source offset does not imply byte equality. Complete function body ranges
use inclusive ends and can contain gaps. Do not treat the enclosing span as code.
For packed targets, retain the original and separately derived artifact identities;
decompiling an unpacking stub does not recover the unpacked program.
## Managed PE/CLI
Start with `inspect_managed_artifact`. REA's canonical managed inspection is
+3
View File
@@ -42,6 +42,9 @@ export {
} from "./DirectAnalysisStatus.js";
type DirectAnalysisTool =
| "inspect_native_load_image"
| "read_bytes"
| "address_to_file_offset"
| "binary_overview"
| "procedure_pseudo_code"
| "read_function_instructions"
+57
View File
@@ -15,6 +15,63 @@ export const registerCoreAnalysisCommands = (
logger: Logger,
): void => {
registerCoreCommands(cli, logger);
cli.command(CLI_COMMANDS.inspectNativeLoadImage, {
description:
"Verify loaded native bytes, source mappings, relocations and entry",
args: z.object({ path: z.string().describe("Local executable path") }),
options: z.object({ provider: providerSelectionOption }),
run: ({ args, options }) =>
logCliCommand(logger, CLI_COMMANDS.inspectNativeLoadImage, () =>
runDirectAnalysis(
args.path,
"inspect_native_load_image",
{},
directAnalysisOptions(logger, undefined, options.provider),
),
),
});
cli.command(CLI_COMMANDS.readBytes, {
description: "Read exact provider memory bytes at one analysis address",
args: z.object({
path: z.string().describe("Local executable path"),
address: z.string().describe("Exact provider memory address"),
}),
options: z.object({
length: z
.number()
.int()
.min(1)
.default(256)
.describe("Requested byte count"),
provider: providerSelectionOption,
}),
run: ({ args, options }) =>
logCliCommand(logger, CLI_COMMANDS.readBytes, () =>
runDirectAnalysis(
args.path,
"read_bytes",
{ address: args.address, length: options.length },
directAnalysisOptions(logger, undefined, options.provider),
),
),
});
cli.command(CLI_COMMANDS.addressToFileOffset, {
description: "Resolve an analysis address to its original file byte offset",
args: z.object({
path: z.string().describe("Local executable path"),
address: z.string().describe("Exact provider memory address"),
}),
options: z.object({ provider: providerSelectionOption }),
run: ({ args, options }) =>
logCliCommand(logger, CLI_COMMANDS.addressToFileOffset, () =>
runDirectAnalysis(
args.path,
"address_to_file_offset",
{ address: args.address },
directAnalysisOptions(logger, undefined, options.provider),
),
),
});
cli.command(CLI_COMMANDS.traceNativeValues, {
description: "Trace bounded native def-use and call dependencies",
args: z.object({
+3 -2
View File
@@ -23,6 +23,9 @@ export const CLI_COMMANDS = Object.freeze({
decodeInterfaceBuilder: "decode-interface-builder",
inspectKeyedArchive: "inspect-keyed-archive",
inspectNativeDataType: "inspect-native-data-type",
inspectNativeLoadImage: "inspect-native-load-image",
readBytes: "read-bytes",
addressToFileOffset: "address-to-file-offset",
traceNativeValues: "trace-native-values",
inspectNativeInstruction: "inspect-native-instruction",
resolveNativeCallTargets: "resolve-native-call-targets",
@@ -112,8 +115,6 @@ export const MCP_TOOLS_WITHOUT_DEDICATED_CLI = Object.freeze([
"procedure_callees",
"procedure_callers",
"procedure_info",
"read_bytes",
"address_to_file_offset",
"procedure_references",
"resolve_containing_procedure",
"set_address_name",
+5
View File
@@ -36,6 +36,11 @@ const official = <Name extends string, Schema extends z.ZodObject>(
/** Bridge operations exposed without additional application composition. */
export const OFFICIAL_TOOL_CONTRACTS = [
official(
"inspect_native_load_image",
"Verify the provider's loaded DOS MZ image against its immutable target snapshot: complete original/modified source-byte digests, header/module file mappings and memory digests, relocations and external entry. Returns measured observations and independent checks with mismatch coordinates. Overlay and uninitialized coverage remain explicit; other formats return unsupported. Does not execute or modify the target.",
z.strictObject({}),
),
official(
"inspect_native_data_type",
"Inspect one recovered type by exact database category path or defined typed data address. Returns observed size, alignment, packing, fields, bitfields, enum values, and child type identities; source-level authority and flexible-tail semantics remain unknown unless substantiated.",
+1
View File
@@ -41,6 +41,7 @@ const sessionEvidence = effects({ mutatesSession: true });
/** Explicit effect audit for every public tool. */
export const TOOL_EFFECTS: Readonly<Record<string, ToolEffects>> = {
inspect_native_load_image: evidence,
inspect_native_dispatch_metadata: evidence,
trace_native_ui_action: evidence,
trace_native_values: evidence,
+2
View File
@@ -1,4 +1,5 @@
import { z } from "zod";
import { nativeLoadImageSchema } from "../domain/nativeLoadImage.js";
import { nativeUiResultSchema } from "../domain/nativeUiObservation.js";
import { nativeValueTraceSchema } from "../domain/nativeValueTrace.js";
import { nativeDataTypeSchema } from "../domain/nativeDataType.js";
@@ -100,6 +101,7 @@ const addressedString = z.object({
/** Exact structured-content schemas shared by direct analysis providers. */
export const officialOutputSchemas: Readonly<Record<string, z.ZodObject>> = {
inspect_native_load_image: resultOf(nativeLoadImageSchema),
inspect_native_data_type: resultOf(nativeDataTypeSchema),
inspect_native_instruction: resultOf(nativeInstructionSchema),
resolve_native_call_targets: resultOf(nativeCallTargetsSchema),
+102
View File
@@ -0,0 +1,102 @@
import { z } from "zod";
import { jsonValueSchema } from "./jsonValue.js";
const integer = z.number().int().min(0).max(Number.MAX_SAFE_INTEGER);
const digest = z.string().regex(/^[a-f0-9]{64}$/u);
const hexBytes = z.string().regex(/^(?:[a-f0-9]{2})*$/u);
const address = z.string().min(1);
/** Measured provider image, without exporting the complete executable bytes. */
export const nativeLoadImageObservationSchema = z.strictObject({
executable_format: z.string().min(1),
language_id: z.string().min(1),
compiler_spec_id: z.string().min(1),
image_base: address,
default_address_space: z.string().min(1),
source_files: z.array(
z.strictObject({
name: z.string(),
size: integer,
original_sha256: digest,
modified_sha256: digest,
}),
),
mappings: z.array(
z.strictObject({
block: z.string(),
start: address,
end: address,
address_space: z.string().min(1),
initialized: z.boolean(),
loaded: z.boolean(),
overlay: z.boolean(),
length: integer.min(1),
source_file_index: integer.nullable(),
file_offset: integer.nullable(),
sha256: digest.nullable(),
}),
),
relocations: z.array(
z.strictObject({
address,
segment: integer.max(0xffff).nullable(),
offset: integer.max(0xffff).nullable(),
status: z.string().min(1),
type: z.number().int(),
values: z.array(
z
.number()
.int()
.min(Number.MIN_SAFE_INTEGER)
.max(Number.MAX_SAFE_INTEGER),
),
original_bytes_hex: hexBytes.nullable(),
memory_bytes_hex: hexBytes.nullable(),
}),
),
entry_points: z.array(address),
});
/** Independent comparison, preserving both expectations and measured values. */
export const nativeLoadImageCheckSchema = z.strictObject({
name: z.string().min(1),
matched: z.boolean(),
expected: jsonValueSchema,
observed: jsonValueSchema,
file_offset: integer.nullable(),
address: address.nullable(),
});
/** Read-only native image verification; verified applies only to declared coverage. */
export const nativeLoadImageSchema = z.discriminatedUnion("status", [
z.strictObject({
status: z.literal("unsupported"),
reason: z.string().min(1),
observations: nativeLoadImageObservationSchema,
limitations: z.array(z.string().min(1)),
}),
z.strictObject({
status: z.enum(["verified", "mismatch"]),
format: z.literal("dos-mz"),
target_sha256: digest,
load_segment: integer.max(0xffff),
header_bytes: integer,
module_bytes: integer,
overlay_bytes: integer,
entry: z.strictObject({
relative_segment: integer.max(0xffff),
offset: integer.max(0xffff),
linear_address: address,
}),
observations: nativeLoadImageObservationSchema,
checks: z.array(nativeLoadImageCheckSchema),
limitations: z.array(z.string().min(1)),
}),
]);
/** Provider-neutral native load-image result. */
export type NativeLoadImage = z.infer<typeof nativeLoadImageSchema>;
/** Measured source mappings, bytes, relocations and entries. */
export type NativeLoadImageObservation = z.infer<
typeof nativeLoadImageObservationSchema
>;
+1 -1
View File
@@ -30,7 +30,7 @@ interface GeneratedPayload {
}
const GENERATED_PAYLOAD_JSON =
'{\n "auxiliaryProviders": [\n {\n "capabilities": [\n {\n "available": true,\n "effects": {\n "changesPermissions": false,\n "launchesProcess": true,\n "mayAccessNetwork": false,\n "mayShowUi": false,\n "mayWriteFilesystem": true,\n "mutatesArtifact": false,\n "requiresRoot": false\n },\n "limitations": [\n "DMG child inventory automatically uses a read-only native macOS mount; PKG remains root-hash-only.",\n "ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."\n ],\n "operation": "inventory_artifact",\n "provider": {\n "id": "rea-artifact-graph",\n "name": "REA safe artifact graph provider",\n "version": "1"\n },\n "reason": null\n },\n {\n "available": true,\n "effects": {\n "changesPermissions": false,\n "launchesProcess": true,\n "mayAccessNetwork": false,\n "mayShowUi": false,\n "mayWriteFilesystem": true,\n "mutatesArtifact": false,\n "requiresRoot": false\n },\n "limitations": [\n "DMG child inventory automatically uses a read-only native macOS mount; PKG remains root-hash-only.",\n "ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."\n ],\n "operation": "inspect_artifact",\n "provider": {\n "id": "rea-artifact-graph",\n "name": "REA safe artifact graph provider",\n "version": "1"\n },\n "reason": null\n },\n {\n "available": true,\n "effects": {\n "changesPermissions": false,\n "launchesProcess": true,\n "mayAccessNetwork": false,\n "mayShowUi": false,\n "mayWriteFilesystem": true,\n "mutatesArtifact": false,\n "requiresRoot": false\n },\n "limitations": [\n "DMG child inventory automatically uses a read-only native macOS mount; PKG remains root-hash-only.",\n "ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."\n ],\n "operation": "extract_artifact",\n "provider": {\n "id": "rea-artifact-graph",\n "name": "REA safe artifact graph provider",\n "version": "1"\n },\n "reason": null\n },\n {\n "available": true,\n "effects": {\n "changesPermissions": false,\n "launchesProcess": false,\n "mayAccessNetwork": false,\n "mayShowUi": false,\n "mayWriteFilesystem": false,\n "mutatesArtifact": false,\n "requiresRoot": false\n },\n "limitations": [\n "DMG child inventory automatically uses a read-only native macOS mount; PKG remains root-hash-only.",\n "ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."\n ],\n "operation": "decode_interface_builder",\n "provider": {\n "id": "rea-artifact-graph",\n "name": "REA safe artifact graph provider",\n "version": "1"\n },\n "reason": null\n },\n {\n "available": true,\n "effects": {\n "changesPermissions": false,\n "launchesProcess": false,\n "mayAccessNetwork": false,\n "mayShowUi": false,\n "mayWriteFilesystem": false,\n "mutatesArtifact": false,\n "requiresRoot": false\n },\n "limitations": [\n "DMG child inventory automatically uses a read-only native macOS mount; PKG remains root-hash-only.",\n "ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."\n ],\n "operation": "inspect_keyed_archive",\n "provider": {\n "id": "rea-artifact-graph",\n "name": "REA safe artifact graph provider",\n "version": "1"\n },\n "reason": null\n },\n {\n "available": true,\n "effects": {\n "changesPermissions": false,\n "launchesProcess": true,\n "mayAccessNetwork": false,\n "mayShowUi": false,\n "mayWriteFilesystem": false,\n "mutatesArtifact": false,\n "requiresRoot": false\n },\n "limitations": [\n "DLine truncated
'{\n "auxiliaryProviders": [\n {\n "capabilities": [\n {\n "available": true,\n "effects": {\n "changesPermissions": false,\n "launchesProcess": true,\n "mayAccessNetwork": false,\n "mayShowUi": false,\n "mayWriteFilesystem": true,\n "mutatesArtifact": false,\n "requiresRoot": false\n },\n "limitations": [\n "DMG child inventory automatically uses a read-only native macOS mount; PKG remains root-hash-only.",\n "ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."\n ],\n "operation": "inventory_artifact",\n "provider": {\n "id": "rea-artifact-graph",\n "name": "REA safe artifact graph provider",\n "version": "1"\n },\n "reason": null\n },\n {\n "available": true,\n "effects": {\n "changesPermissions": false,\n "launchesProcess": true,\n "mayAccessNetwork": false,\n "mayShowUi": false,\n "mayWriteFilesystem": true,\n "mutatesArtifact": false,\n "requiresRoot": false\n },\n "limitations": [\n "DMG child inventory automatically uses a read-only native macOS mount; PKG remains root-hash-only.",\n "ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."\n ],\n "operation": "inspect_artifact",\n "provider": {\n "id": "rea-artifact-graph",\n "name": "REA safe artifact graph provider",\n "version": "1"\n },\n "reason": null\n },\n {\n "available": true,\n "effects": {\n "changesPermissions": false,\n "launchesProcess": true,\n "mayAccessNetwork": false,\n "mayShowUi": false,\n "mayWriteFilesystem": true,\n "mutatesArtifact": false,\n "requiresRoot": false\n },\n "limitations": [\n "DMG child inventory automatically uses a read-only native macOS mount; PKG remains root-hash-only.",\n "ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."\n ],\n "operation": "extract_artifact",\n "provider": {\n "id": "rea-artifact-graph",\n "name": "REA safe artifact graph provider",\n "version": "1"\n },\n "reason": null\n },\n {\n "available": true,\n "effects": {\n "changesPermissions": false,\n "launchesProcess": false,\n "mayAccessNetwork": false,\n "mayShowUi": false,\n "mayWriteFilesystem": false,\n "mutatesArtifact": false,\n "requiresRoot": false\n },\n "limitations": [\n "DMG child inventory automatically uses a read-only native macOS mount; PKG remains root-hash-only.",\n "ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."\n ],\n "operation": "decode_interface_builder",\n "provider": {\n "id": "rea-artifact-graph",\n "name": "REA safe artifact graph provider",\n "version": "1"\n },\n "reason": null\n },\n {\n "available": true,\n "effects": {\n "changesPermissions": false,\n "launchesProcess": false,\n "mayAccessNetwork": false,\n "mayShowUi": false,\n "mayWriteFilesystem": false,\n "mutatesArtifact": false,\n "requiresRoot": false\n },\n "limitations": [\n "DMG child inventory automatically uses a read-only native macOS mount; PKG remains root-hash-only.",\n "ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."\n ],\n "operation": "inspect_keyed_archive",\n "provider": {\n "id": "rea-artifact-graph",\n "name": "REA safe artifact graph provider",\n "version": "1"\n },\n "reason": null\n },\n {\n "available": true,\n "effects": {\n "changesPermissions": false,\n "launchesProcess": true,\n "mayAccessNetwork": false,\n "mayShowUi": false,\n "mayWriteFilesystem": false,\n "mutatesArtifact": false,\n "requiresRoot": false\n },\n "limitations": [\n "DLine truncated
const generatedPayload: unknown = JSON.parse(GENERATED_PAYLOAD_JSON);
if (!isGeneratedPayload(generatedPayload))
throw new TypeError("Generated MCP catalog payload is invalid");
+1 -1
View File
@@ -5,5 +5,5 @@ export const PACKAGE_METADATA = {
serverSdkVersion: "2.3.1",
clientSdkVersion: "2.3.1",
coreSdkVersion: "2.3.1",
skillVersion: "25",
skillVersion: "26",
} as const;
+3
View File
@@ -38,6 +38,9 @@ export const resolveGhidraAnalysisProfile = (
].sort(),
import_mode: "ephemeral-read-only",
function_body_evidence: "complete-inclusive-ranges-v1",
...(dosMz
? { load_image_evidence: "independent-mz-mapping-relocations-v1" }
: {}),
jump_table_evidence: "typed-case-default-blocks-v1",
decompiler_jump_loads: true,
loader: dosMz ? "MzLoader" : "auto-from-header",
+23
View File
@@ -38,6 +38,7 @@ import {
type GhidraSessionInfo,
} from "./GhidraSessionValues.js";
import { createGhidraTargetSnapshot } from "./GhidraTargetSnapshot.js";
import { readFile } from "node:fs/promises";
import { connectGhidraSocket } from "./GhidraSocketConnection.js";
import type { GhidraEndpoint } from "./GhidraTransport.js";
import { GhidraWire } from "./GhidraClientWire.js";
@@ -200,6 +201,28 @@ export class GhidraClient {
);
}
/** Read the private immutable source for independent import verification. */
async readTargetSnapshot(): Promise<Result<Buffer, GhidraSessionError>> {
if (this.#snapshotPath === undefined)
return err(
this.#failure(
"protocol",
"Ghidra target snapshot is unavailable before startup or after close",
),
);
try {
return ok(await readFile(this.#snapshotPath));
} catch (cause: unknown) {
return err(
this.#failure(
"protocol",
"Ghidra target snapshot could not be read for load-image verification",
cause,
),
);
}
}
/** Stop the owned process group and remove all project/runtime artifacts. */
close(): Promise<void> {
return this.#requestClose(false);
+51
View File
@@ -7,6 +7,57 @@ import {
} from "./GhidraInventoryValues.js";
describe("Ghidra inventory boundary values", () => {
it("admits explicit byte reads and source offset mapping without invented caps", () => {
expect(
parseGhidraInventoryInput("read_bytes", { address: "0x10000" }),
).toEqual({
ok: true,
value: { address: "0x10000", document: null, length: 256 },
});
expect(
parseGhidraInventoryInput("read_bytes", {
address: "0x10000",
length: 4097,
}).ok,
).toBe(true);
expect(
parseGhidraInventoryInput("read_bytes", { address: "0x10000", length: 0 })
.ok,
).toBe(false);
expect(
parseGhidraInventoryInput("address_to_file_offset", {
address: "HEADER:0x1",
}).ok,
).toBe(true);
});
it("checks byte length and completeness before accepting memory Evidence", () => {
const result = {
address: "0x10000",
requested_bytes: 4,
returned_bytes: 2,
bytes_hex: "0410",
complete: false,
};
expect(parseGhidraInventoryResult("read_bytes", result)).toEqual({
ok: true,
value: result,
});
for (const change of [
{ complete: true },
{ returned_bytes: 4 },
{ bytes_hex: "04" },
{ requested_bytes: 1 },
])
expect(
parseGhidraInventoryResult("read_bytes", { ...result, ...change }).ok,
).toBe(false);
expect(
parseGhidraInventoryResult("address_to_file_offset", {
address: "0x10000",
file_offset: -1,
}).ok,
).toBe(false);
});
it("accepts complete inventories and searches without page controls", () => {
expect(parseGhidraInventoryInput("list_procedures", {})).toEqual({
ok: true,
+38
View File
@@ -1,4 +1,5 @@
import { z } from "zod";
import { nativeLoadImageObservationSchema } from "../domain/nativeLoadImage.js";
import {
AnalysisInputError,
@@ -13,6 +14,9 @@ import { err, ok, type Result } from "../domain/result.js";
/** Read-only direct inventory operations admitted by the Ghidra adapter. */
export const GHIDRA_INVENTORY_OPERATIONS = [
"inspect_native_load_image",
"read_bytes",
"address_to_file_offset",
"address_name",
"list_documents",
"list_names",
@@ -49,6 +53,16 @@ const searchInput = {
};
const inputSchemas = {
inspect_native_load_image: z.strictObject({}),
read_bytes: z.strictObject({
document,
address: explicitAddress,
length: z.number().int().min(1).max(Number.MAX_SAFE_INTEGER).default(256),
}),
address_to_file_offset: z.strictObject({
document,
address: explicitAddress,
}),
address_name: z.object({ document, address: explicitAddress }).strict(),
list_documents: z.object({}).strict(),
list_names: z.object({ document, address: filteredAddress }).strict(),
@@ -217,6 +231,30 @@ const containingProcedure = z.discriminatedUnion("found", [
]);
const resultSchemas = {
inspect_native_load_image: nativeLoadImageObservationSchema,
read_bytes: z
.strictObject({
address: canonicalAddress,
requested_bytes: z.number().int().min(1),
returned_bytes: z.number().int().min(0),
bytes_hex: z.string().regex(/^(?:[a-f0-9]{2})*$/u),
complete: z.boolean(),
})
.superRefine((value, context) => {
if (
value.returned_bytes * 2 !== value.bytes_hex.length ||
value.returned_bytes > value.requested_bytes ||
value.complete !== (value.returned_bytes === value.requested_bytes)
)
context.addIssue({
code: "custom",
message: "Read-byte length and completeness disagree",
});
}),
address_to_file_offset: z.strictObject({
address: canonicalAddress,
file_offset: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER),
}),
address_name: z.string().nullable(),
list_documents: z.array(z.string().min(1)).length(1),
list_names: z.array(symbolItem),
+98
View File
@@ -0,0 +1,98 @@
import { createHash } from "node:crypto";
import { dosMz } from "../domain/binaryTarget.fixture.js";
import type { NativeLoadImageObservation } from "../domain/nativeLoadImage.js";
const hash = (bytes: Buffer): string =>
createHash("sha256").update(bytes).digest("hex");
/** Source-owned MZ with one relocated word, overlay and uninitialized allocation. */
export const fixtureDosLoadImage = (): {
bytes: Buffer;
sha256: string;
observation: NativeLoadImageObservation;
} => {
const original = dosMz();
original.writeUInt16LE(1, 6);
original.writeUInt16LE(9, 28);
original.writeUInt16LE(0, 30);
original.writeUInt16LE(4, 41);
const bytes = Buffer.concat([
original,
Buffer.from("private-overlay-fixture"),
]);
const modified = Buffer.from(bytes);
modified.writeUInt16LE(0x1004, 41);
return {
bytes,
sha256: hash(bytes),
observation: {
executable_format: "Old-style DOS Executable (MZ)",
language_id: "x86:LE:16:Real Mode",
compiler_spec_id: "default",
image_base: "0x0",
default_address_space: "ram",
source_files: [
{
name: "fixture.exe",
size: bytes.length,
original_sha256: hash(bytes),
modified_sha256: hash(modified),
},
],
mappings: [
{
block: "HEADER",
start: "HEADER:0x0",
end: "HEADER:0x1f",
address_space: "HEADER",
initialized: true,
loaded: false,
overlay: true,
length: 32,
source_file_index: 0,
file_offset: 0,
sha256: hash(bytes.subarray(0, 32)),
},
{
block: "CODE_0",
start: "0x10000",
end: "0x1007f",
address_space: "ram",
initialized: true,
loaded: true,
overlay: false,
length: 128,
source_file_index: 0,
file_offset: 32,
sha256: hash(modified.subarray(32, 160)),
},
{
block: "DATA",
start: "0x10080",
end: "0x1008f",
address_space: "ram",
initialized: false,
loaded: true,
overlay: false,
length: 16,
source_file_index: null,
file_offset: null,
sha256: null,
},
],
relocations: [
{
address: "0x10009",
segment: 0x1000,
offset: 9,
status: "APPLIED",
type: 0,
values: [0, 9, 0x1004],
original_bytes_hex: "0400",
memory_bytes_hex: "0410",
},
],
entry_points: ["0x10000"],
},
};
};
+355
View File
@@ -0,0 +1,355 @@
import { describe, expect, it } from "vitest";
import { createHash } from "node:crypto";
import { attestGhidraDosLoadImage } from "./GhidraLoadImageValues.js";
import { fixtureDosLoadImage } from "./GhidraLoadImage.fixture.js";
import { nativeLoadImageObservationSchema } from "../domain/nativeLoadImage.js";
import type { NativeLoadImageObservation } from "../domain/nativeLoadImage.js";
const damage: ReadonlyArray<
readonly [string, (observation: NativeLoadImageObservation) => void]
> = [
[
"memory byte",
(x) => {
if (x.mappings[1]) x.mappings[1].sha256 = "f".repeat(64);
},
],
[
"header byte",
(x) => {
if (x.mappings[0]) x.mappings[0].sha256 = "f".repeat(64);
},
],
[
"modified source",
(x) => {
x.source_files[0] && (x.source_files[0].modified_sha256 = "f".repeat(64));
},
],
[
"original source",
(x) => {
x.source_files[0] && (x.source_files[0].original_sha256 = "f".repeat(64));
},
],
[
"source length",
(x) => {
if (x.source_files[0]) x.source_files[0].size--;
},
],
[
"source identity",
(x) => {
if (x.mappings[1]) x.mappings[1].source_file_index = 1;
},
],
[
"missing mapping",
(x) => {
x.mappings.splice(1, 1);
},
],
[
"duplicate mapping",
(x) => {
if (x.mappings[1]) x.mappings.push(structuredClone(x.mappings[1]));
},
],
[
"gap",
(x) => {
if (x.mappings[1]) x.mappings[1].file_offset = 33;
},
],
[
"overlap",
(x) => {
if (x.mappings[1]) x.mappings[1].file_offset = 31;
},
],
[
"alias address",
(x) => {
if (x.mappings[1]) x.mappings[1].start = "0x10001";
},
],
[
"end address",
(x) => {
if (x.mappings[1]) x.mappings[1].end = "0x10080";
},
],
[
"wrong address space",
(x) => {
x.default_address_space = "wrong";
},
],
[
"header loaded",
(x) => {
if (x.mappings[0]) x.mappings[0].loaded = true;
},
],
[
"module overlay",
(x) => {
if (x.mappings[1]) x.mappings[1].overlay = true;
},
],
[
"uninitialized module",
(x) => {
if (x.mappings[1]) x.mappings[1].initialized = false;
},
],
[
"complex source mapping",
(x) => {
if (x.mappings[1]) x.mappings[1].file_offset = null;
},
],
[
"missing relocation",
(x) => {
x.relocations = [];
},
],
[
"duplicate relocation",
(x) => {
if (x.relocations[0])
x.relocations.push(structuredClone(x.relocations[0]));
},
],
[
"relocation word",
(x) => {
if (x.relocations[0]) x.relocations[0].memory_bytes_hex = "0400";
},
],
[
"relocation metadata",
(x) => {
if (x.relocations[0]) x.relocations[0].values = [0, 9, 4];
},
],
[
"relocation status",
(x) => {
if (x.relocations[0]) x.relocations[0].status = "FAILURE";
},
],
[
"relocation segment",
(x) => {
if (x.relocations[0]) x.relocations[0].segment = 0;
},
],
[
"entry",
(x) => {
x.entry_points = ["0x10001"];
},
],
[
"extra entry",
(x) => {
x.entry_points.push("0x10010");
},
],
[
"loader",
(x) => {
x.executable_format = "Raw Binary";
},
],
[
"language",
(x) => {
x.language_id = "x86:LE:32:default";
},
],
[
"compiler",
(x) => {
x.compiler_spec_id = "windows";
},
],
[
"image base",
(x) => {
x.image_base = "0x10000";
},
],
[
"extra source file",
(x) => {
if (x.source_files[0])
x.source_files.push(structuredClone(x.source_files[0]));
},
],
[
"initialized unbacked alias",
(x) => {
if (x.mappings[2]) x.mappings[2].initialized = true;
},
],
[
"overlay source mapping",
(x) => {
const row = x.mappings[1];
if (row)
x.mappings.push({
...row,
file_offset: 160,
length: 23,
start: "0x10080",
end: "0x10096",
});
},
],
];
describe("independent DOS MZ import verification", () => {
it("verifies every initialized source byte while retaining overlay and BSS scope", () => {
const fixture = fixtureDosLoadImage();
const result = attestGhidraDosLoadImage(
fixture.bytes,
fixture.sha256,
fixture.observation,
);
expect(result).toMatchObject({
ok: true,
value: {
status: "verified",
module_bytes: 128,
header_bytes: 32,
overlay_bytes: 23,
entry: { linear_address: "0x10000" },
},
});
if (!result.ok || result.value.status === "unsupported")
throw new Error("Expected measured MZ checks");
expect(result.value.checks.every((check) => check.matched)).toBe(true);
expect(result.value.observations).toEqual(fixture.observation);
expect(result.value.limitations.join(" ")).toMatch(
/runtime behavior.*not verified/u,
);
});
it.each(damage)(
"rejects a %s despite a matching executable digest",
(_name, mutate) => {
const fixture = fixtureDosLoadImage();
mutate(fixture.observation);
const result = attestGhidraDosLoadImage(
fixture.bytes,
fixture.sha256,
fixture.observation,
);
expect(result).toMatchObject({
ok: true,
value: { status: "mismatch", observations: fixture.observation },
});
if (!result.ok || result.value.status === "unsupported")
throw new Error("Expected MZ verification checks");
expect(result.value.checks.some((check) => !check.matched)).toBe(true);
},
);
it("retains source range coordinates when memory evidence disagrees", () => {
const fixture = fixtureDosLoadImage();
const mapping = fixture.observation.mappings[1];
if (mapping === undefined) throw new Error("Missing module mapping");
mapping.sha256 = "f".repeat(64);
const result = attestGhidraDosLoadImage(
fixture.bytes,
fixture.sha256,
fixture.observation,
);
if (!result.ok || result.value.status === "unsupported")
throw new Error("Expected MZ verification checks");
expect(result.value.checks.filter((check) => !check.matched)).toEqual([
expect.objectContaining({
name: "mapping[1].memory_sha256",
file_offset: 32,
address: "0x10000",
observed: "f".repeat(64),
}),
]);
});
it("is independent of mapping iteration order", () => {
const fixture = fixtureDosLoadImage();
fixture.observation.mappings.reverse();
expect(
attestGhidraDosLoadImage(
fixture.bytes,
fixture.sha256,
fixture.observation,
),
).toMatchObject({ ok: true, value: { status: "verified" } });
});
it("accepts a different segment alias while preserving the observed coordinates and raw table values", () => {
const fixture = fixtureDosLoadImage();
const relocation = fixture.observation.relocations[0];
if (relocation === undefined)
throw new Error("Missing source-owned relocation");
relocation.segment = 0x0fff;
relocation.offset = 25;
expect(
attestGhidraDosLoadImage(
fixture.bytes,
fixture.sha256,
fixture.observation,
),
).toMatchObject({
ok: true,
value: {
status: "verified",
observations: {
relocations: [
{ segment: 0x0fff, offset: 25, values: [0, 9, 0x1004] },
],
},
},
});
});
it("rejects a changed snapshot and malformed MZ rather than trusting provider hashes", () => {
const fixture = fixtureDosLoadImage();
fixture.bytes[0] = 0;
expect(
attestGhidraDosLoadImage(
fixture.bytes,
fixture.sha256,
fixture.observation,
),
).toMatchObject({ ok: false, error: expect.stringContaining("digest") });
const digest = createHash("sha256").update(fixture.bytes).digest("hex");
expect(
attestGhidraDosLoadImage(fixture.bytes, digest, fixture.observation),
).toMatchObject({ ok: false, error: expect.stringContaining("MZ header") });
});
it("rejects malformed producer digests and unsafe source coordinates", () => {
const fixture = fixtureDosLoadImage();
expect(
nativeLoadImageObservationSchema.safeParse({
...fixture.observation,
source_files: [
{ ...fixture.observation.source_files[0], original_sha256: "bad" },
],
}).success,
).toBe(false);
const mapping = fixture.observation.mappings[0];
expect(
nativeLoadImageObservationSchema.safeParse({
...fixture.observation,
mappings: [{ ...mapping, file_offset: Number.MAX_SAFE_INTEGER + 1 }],
}).success,
).toBe(false);
});
});
+249
View File
@@ -0,0 +1,249 @@
import { createHash } from "node:crypto";
import {
nativeLoadImageSchema,
type NativeLoadImage,
type NativeLoadImageObservation,
} from "../domain/nativeLoadImage.js";
import { parseDosMzHeader, type DosMzHeader } from "../domain/dosMz.js";
import type { JsonValue } from "../domain/jsonValue.js";
import { err, ok, type Result } from "../domain/result.js";
const sha256 = (bytes: Buffer): string =>
createHash("sha256").update(bytes).digest("hex");
const address = (offset: number): string => `0x${offset.toString(16)}`;
const limitations = [
"Verification covers original/modified FileBytes, every initialized target-backed header/module mapping, relocation records and the external entry point. Mapping ends are inclusive.",
"Appended overlay bytes are identified by file extent and included in FileBytes identity; they must not be mapped as initialized header/module memory. Uninitialized allocations are reported but their contents and DOS runtime behavior are not verified.",
"The fixed load segment 0x1000 describes Ghidra's analysis image, not a running DOS process. Verified import does not establish original source ownership or recovered program behavior.",
"A mismatching memory digest identifies the reported source range; it does not locate the first differing byte inside that range.",
];
/** Verify measured MZ import state against the session's immutable snapshot. */
export const attestGhidraDosLoadImage = (
bytes: Buffer,
targetSha256: string,
observations: NativeLoadImageObservation,
): Result<NativeLoadImage, string> => {
if (sha256(bytes) !== targetSha256)
return err(
"Ghidra load-image snapshot digest does not match the admitted target",
);
const parsed = parseDosMzHeader(bytes);
if (!parsed.ok) return parsed;
const header = parsed.value;
const loadSegment = 0x1000;
const modified = Buffer.from(bytes);
const expectedRelocations = [];
for (let index = 0; index < header.relocationCount; index += 1) {
const table = header.relocationTableOffset + index * 4;
const offset = bytes.readUInt16LE(table);
const segment = bytes.readUInt16LE(table + 2);
const fileOffset = header.headerBytes + segment * 16 + offset;
const original = modified
.subarray(fileOffset, fileOffset + 2)
.toString("hex");
const relocated =
(modified.readUInt16LE(fileOffset) + loadSegment) & 0xffff;
modified.writeUInt16LE(relocated, fileOffset);
expectedRelocations.push({
address: address(loadSegment * 16 + segment * 16 + offset),
segment: (loadSegment + segment) & 0xffff,
offset,
status: "APPLIED",
type: 0,
values: [segment, offset, relocated],
original_bytes_hex: original,
memory_bytes_hex: modified
.subarray(fileOffset, fileOffset + 2)
.toString("hex"),
});
}
const checks: Extract<NativeLoadImage, { format: "dos-mz" }>["checks"] = [];
const check = (
name: string,
expected: JsonValue,
observed: JsonValue,
fileOffset: number | null = null,
location: string | null = null,
): void => {
checks.push({
name,
matched: JSON.stringify(expected) === JSON.stringify(observed),
expected,
observed,
file_offset: fileOffset,
address: location,
});
};
check(
"executable_format",
"Old-style DOS Executable (MZ)",
observations.executable_format,
);
check("language_id", "x86:LE:16:Real Mode", observations.language_id);
check("compiler_spec_id", "default", observations.compiler_spec_id);
check("image_base", "0x0", observations.image_base);
check("default_address_space", "ram", observations.default_address_space);
check("source_file_count", 1, observations.source_files.length);
const source = observations.source_files[0];
check("source_file_size", bytes.length, source?.size ?? null);
check("original_file_sha256", targetSha256, source?.original_sha256 ?? null);
check(
"modified_file_sha256",
sha256(modified),
source?.modified_sha256 ?? null,
);
const entryAddress = address(
((loadSegment + header.entrySegment) & 0xffff) * 16 + header.entryOffset,
);
check("entry_points", [entryAddress], [...observations.entry_points].sort());
// A multiset preserves duplicate relocations and aliases; iteration order is not semantic.
const rows = (values: NativeLoadImageObservation["relocations"]): string[] =>
values
.map(({ segment: _segment, offset: _offset, ...value }) =>
JSON.stringify(value),
)
.sort();
check(
"relocation_address_aliases",
[],
observations.relocations.filter(
(row) =>
row.segment === null ||
row.offset === null ||
address(row.segment * 16 + row.offset) !== row.address,
),
);
check(
"relocation_records",
rows(expectedRelocations),
rows(observations.relocations),
);
checkMappings(header, loadSegment, bytes, modified, observations, check);
return ok(
nativeLoadImageSchema.parse({
status: checks.every((item) => item.matched) ? "verified" : "mismatch",
format: "dos-mz",
target_sha256: targetSha256,
load_segment: loadSegment,
header_bytes: header.headerBytes,
module_bytes: header.moduleBytes,
overlay_bytes: header.overlayBytes,
entry: {
relative_segment: header.entrySegment,
offset: header.entryOffset,
linear_address: entryAddress,
},
observations,
checks,
limitations,
}),
);
};
const checkMappings = (
header: DosMzHeader,
loadSegment: number,
bytes: Buffer,
modified: Buffer,
observations: NativeLoadImageObservation,
check: (
name: string,
expected: JsonValue,
observed: JsonValue,
fileOffset?: number | null,
location?: string | null,
) => void,
): void => {
const backed = observations.mappings.filter(
(mapping) => mapping.source_file_index !== null,
);
const sorted = [...backed].sort(
(left, right) => (left.file_offset ?? -1) - (right.file_offset ?? -1),
);
let covered = 0;
for (let index = 0; index < sorted.length; index += 1) {
const mapping = sorted[index];
if (mapping === undefined) continue;
const start = mapping.file_offset;
const end = start === null ? null : start + mapping.length;
const headerMapping =
start !== null && end !== null && start >= 0 && end <= header.headerBytes;
const moduleMapping =
start !== null &&
end !== null &&
start >= header.headerBytes &&
end <= header.imageBytes;
const at = `mapping[${index}]`;
check(
`${at}.source_file_index`,
0,
mapping.source_file_index,
start,
mapping.start,
);
check(`${at}.file_coverage_start`, covered, start, start, mapping.start);
check(
`${at}.partition`,
true,
headerMapping || moduleMapping,
start,
mapping.start,
);
check(`${at}.initialized`, true, mapping.initialized, start, mapping.start);
check(`${at}.loaded`, moduleMapping, mapping.loaded, start, mapping.start);
check(
`${at}.overlay`,
headerMapping,
mapping.overlay,
start,
mapping.start,
);
check(
`${at}.address_space`,
headerMapping ? "HEADER" : observations.default_address_space,
mapping.address_space,
start,
mapping.start,
);
const expectedStart =
start === null
? null
: headerMapping
? `HEADER:${address(start)}`
: address(loadSegment * 16 + start - header.headerBytes);
const expectedEnd =
start === null
? null
: headerMapping
? `HEADER:${address(start + mapping.length - 1)}`
: address(
loadSegment * 16 +
start -
header.headerBytes +
mapping.length -
1,
);
check(`${at}.start`, expectedStart, mapping.start, start, mapping.start);
check(`${at}.end`, expectedEnd, mapping.end, start, mapping.end);
check(
`${at}.memory_sha256`,
start !== null && end !== null && end <= bytes.length
? sha256(modified.subarray(start, end))
: null,
mapping.sha256,
start,
mapping.start,
);
covered = end ?? covered;
}
check("initialized_file_coverage_end", header.imageBytes, covered);
check(
"initialized_unbacked_mappings",
[],
observations.mappings.filter(
(mapping) => mapping.initialized && mapping.source_file_index === null,
),
);
};
@@ -1,6 +1,8 @@
// Fake-backed provider coverage; real Ghidra verification lives in
// `npm run verify:ghidra` and its focused variants.
import { describe, expect, it } from "vitest";
import { fixtureDosLoadImage } from "./GhidraLoadImage.fixture.js";
import { jsonValueSchema } from "../domain/jsonValue.js";
import { parseConfig } from "../config.js";
import type { BinaryTarget } from "../domain/binaryTarget.js";
@@ -491,6 +493,67 @@ describe("Ghidra result projection", () => {
});
});
describe("Ghidra measured load-image projection", () => {
it.each([
"verified",
"mismatch",
"changed-snapshot",
"unsupported",
"missing-snapshot",
] as const)(
"preserves %s state and producing observations",
async (state) => {
const fixture = fixtureDosLoadImage();
if (state === "mismatch") fixture.observation.entry_points = ["0x10001"];
if (state === "changed-snapshot") fixture.bytes[0] = 0;
const factory: GhidraProviderClientFactory = () => ({
start: () => Promise.resolve(ok(sessionInfo())),
callTool: () =>
Promise.resolve(ok(jsonValueSchema.parse(fixture.observation))),
close: () => Promise.resolve(),
...(state === "missing-snapshot"
? {}
: {
readTargetSnapshot: () => Promise.resolve(ok(fixture.bytes)),
}),
});
const ghidra = provider(installationHost(), factory);
const target: BinaryTarget = {
path: "/tmp/source-owned-fixture.exe",
sha256: fixture.sha256,
kind: "executable",
format: state === "unsupported" ? "elf" : "dos-mz",
architecture: "x86",
availableArchitectures: ["x86"],
};
const profile = await ghidra.resolveAnalysisProfile(target);
if (!profile.ok || profile.value.profile === null)
throw new Error("Expected admitted profile");
const result = await ghidra
.createClient(target, profile.value.profile)
.execute("inspect_native_load_image", {});
if (state === "changed-snapshot" || state === "missing-snapshot") {
expect(result).toMatchObject({
ok: false,
error: {
_tag: "ProviderAdapterError",
diagnostics: { reason: expect.stringContaining("snapshot") },
},
});
} else {
expect(result).toMatchObject({
ok: true,
value: {
result: { status: state, observations: fixture.observation },
rawResult: fixture.observation,
analysisProfile: profile.value.profile,
},
});
}
},
);
});
const sessionInfo = () => ({
name: "REA Ghidra bridge" as const,
run_id: "11111111-1111-4111-8111-111111111111",
+52 -3
View File
@@ -15,7 +15,12 @@ import {
import type { AppConfig } from "../config.js";
import type { AnalysisProfileCommitment } from "../domain/analysisProfile.js";
import type { BinaryTarget } from "../domain/binaryTarget.js";
import { jsonObjectSchema } from "../domain/jsonValue.js";
import { jsonObjectSchema, jsonValueSchema } from "../domain/jsonValue.js";
import {
nativeLoadImageObservationSchema,
nativeLoadImageSchema,
} from "../domain/nativeLoadImage.js";
import { attestGhidraDosLoadImage } from "./GhidraLoadImageValues.js";
import {
AnalysisCancelledError,
AnalysisCapabilityUnavailableError,
@@ -71,7 +76,7 @@ const SUPPORTED_ARCHITECTURES = new Set(["x86", "x86_64", "arm", "arm64"]);
export type GhidraProviderClientFactory = (
options: GhidraClientOptions,
) => Pick<GhidraClient, "start" | "callTool" | "close"> &
Partial<Pick<GhidraClient, "runtimeLineage">>;
Partial<Pick<GhidraClient, "runtimeLineage" | "readTargetSnapshot">>;
/** Ghidra candidate backed by an isolated read-only headless import. */
export class GhidraProvider implements AnalysisProviderCandidate {
@@ -277,8 +282,52 @@ export class GhidraProvider implements AnalysisProviderCandidate {
? parseGhidraFunctionResult(operation, called.value)
: parseGhidraInventoryResult(operation, called.value);
if (!result.ok) return result;
let normalized = result.value;
if (operation === "inspect_native_load_image") {
const observations = nativeLoadImageObservationSchema.parse(
result.value,
);
if (target.format !== "dos-mz") {
normalized = jsonValueSchema.parse(
nativeLoadImageSchema.parse({
status: "unsupported",
reason:
"Independent load-image verification currently supports DOS MZ targets only.",
observations,
limitations: [
"Other formats expose measured mappings and source identities; no format-specific verification was performed.",
],
}),
);
} else {
if (client.readTargetSnapshot === undefined)
return err(
new ProviderAdapterError("ghidra", operation, {
diagnostics: {
reason:
"The Ghidra client does not expose its immutable target snapshot for independent load-image verification.",
},
}),
);
const snapshot = await client.readTargetSnapshot();
if (!snapshot.ok)
return err(projectSessionError(operation, snapshot.error));
const attested = attestGhidraDosLoadImage(
snapshot.value,
target.sha256 ?? "",
observations,
);
if (!attested.ok)
return err(
new ProviderAdapterError("ghidra", operation, {
diagnostics: { reason: attested.error },
}),
);
normalized = jsonValueSchema.parse(attested.value);
}
}
return ok(
createAnalysisExecution(result.value, committedProfile.provider, {
createAnalysisExecution(normalized, committedProfile.provider, {
rawResult: called.value,
analysisProfile: committedProfile,
limitations: [
+15
View File
@@ -54,6 +54,21 @@ export const limitationsFor = (operation: string): readonly string[] => {
"Default-space addresses use lowercase 0x-prefixed hexadecimal; other address spaces use <percent-encoded-space>:0x<hex>.",
];
switch (operation) {
case "inspect_native_load_image":
return [
...common,
"Independent verification currently supports DOS MZ only. Measured source mappings use inclusive end addresses and complete region digests; no target execution or project mutation occurs.",
];
case "read_bytes":
return [
...common,
"Bytes reflect Ghidra's initialized memory, including loader fixups. Reads stop at an unmapped or uninitialized byte and report completeness explicitly.",
];
case "address_to_file_offset":
return [
...common,
"Offsets come from Ghidra source-byte mappings, not virtual-address arithmetic. Uninitialized memory, missing mappings and ambiguous mappings fail explicitly.",
];
case "list_documents":
return [
...common,
+1
View File
@@ -47,6 +47,7 @@ export const HOPPER_PROVIDER_TOOL_CONTRACTS = Object.freeze([
...GENERATED_MCP_TOOL_CATALOG.filter(
({ kind, name }) =>
kind === "official-proxy" &&
name !== "inspect_native_load_image" &&
name !== "inspect_native_data_type" &&
name !== "inspect_native_instruction" &&
name !== "resolve_native_call_targets",
+18
View File
@@ -12,6 +12,7 @@ import { createServer } from "../../../src/server/createServer.js";
const VALID_INPUTS: Readonly<
Record<string, Readonly<Record<string, JsonValue>>>
> = {
inspect_native_load_image: {},
address_name: {},
inspect_native_instruction: { address: "0x1000" },
resolve_native_call_targets: { address: "0x1000" },
@@ -127,6 +128,23 @@ const connect = async (analysis: AnalysisOperationPort) => {
const inventory: JsonValue = [];
const outputFor = (name: string): JsonValue => {
if (name === "inspect_native_load_image")
return {
status: "unsupported",
reason: "Fixture provider has no load-image verifier",
observations: {
executable_format: "ELF",
language_id: "x86:LE:64:default",
compiler_spec_id: "gcc",
image_base: "0x0",
default_address_space: "ram",
source_files: [],
mappings: [],
relocations: [],
entry_points: [],
},
limitations: ["Transport fixture only"],
};
if (name === "inspect_native_instruction")
return {
address: "0x1000",
@@ -124,6 +124,23 @@ afterEach(async () => {
});
describe("GhidraClient", () => {
it("exposes the immutable import snapshot only during the live session", async () => {
const client = clientFor(new FixtureLauncher());
await expect(client.readTargetSnapshot()).resolves.toMatchObject({
ok: false,
error: { kind: "protocol" },
});
await expect(client.start()).resolves.toMatchObject({ ok: true });
const snapshot = await client.readTargetSnapshot();
expect(snapshot.ok).toBe(true);
if (snapshot.ok) expect(snapshot.value).toEqual(readFileSync(fixturePath));
await client.close();
await expect(client.readTargetSnapshot()).resolves.toMatchObject({
ok: false,
error: { kind: "protocol" },
});
});
it("completes an exact, fragmented post-analysis handshake", async () => {
const launcher = new FixtureLauncher("fragmented");
const client = clientFor(launcher);
+3
View File
@@ -196,6 +196,9 @@ const sessionInfo = ({
capabilities: [
"ping",
"shutdown",
"inspect_native_load_image",
"read_bytes",
"address_to_file_offset",
"address_name",
"list_documents",
"list_names",