feat(ghidra): add atomic function annotation edits (#709)

* feat(ghidra): edit function annotations atomically

Return annotation readback and refreshed function analysis through CLI and MCP. Own each database transaction, invalidate caches and immutable snapshots, preserve executable bytes, and retain the Windows mutation boundary.

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

* fix(ghidra): preserve qualified function annotation identity

Preserve namespaces during local name writes and match qualified readback to the refreshed dossier. Add a producer-shaped regression and verify a real namespaced C++ function through the CLI.

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

* test(ghidra): align verification with annotation authority

Keep strict capability and profile checks while admitting the explicit session metadata operation. Verify packaged transport authority and atomic transaction commitments and preserve a read-only Windows expectation.

Assisted-by: Nitori (gensokyo-skills:nitori-reverse-engineering)
This commit is contained in:
N0zoM1z0 2026-10-06 17:24:33 +08:00 committed by GitHub
parent c3bc38bf6c
commit 9bab55337a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
49 changed files with 730 additions and 72 deletions

View File

@ -2,7 +2,7 @@
## Product Direction
REA exposes reverse-engineering tools through a CLI and MCP server. Hopper and the bring-your-own Ghidra adapter are operation-capable deep binary-analysis providers. Ghidra is supported on Linux x64 and macOS x64/arm64 with matching native decompiler tools, and has an experimental Windows x64 P0 boundary for approved native x86-64 PE applications; it supplies admitted read-only inventory and function-analysis operations but no GUI or mutation authority. Keep provider-specific code out of the domain and application layers.
REA exposes reverse-engineering tools through a CLI and MCP server. Hopper and the bring-your-own Ghidra adapter are operation-capable deep binary-analysis providers. Ghidra is supported on Linux x64 and macOS x64/arm64 with matching native decompiler tools, and has an experimental Windows x64 P0 boundary for approved native x86-64 PE applications; on Linux and macOS it supplies inventory, function analysis, and atomic function annotation edits in an ephemeral database, without modifying executable bytes or controlling a GUI. Windows P0 has no mutation authority. Keep provider-specific code out of the domain and application layers.
Prioritize:

View File

@ -149,7 +149,7 @@ Update either installation with `rea update`.
- Node.js 22.x (>=22.19), 24.x (>=24.11), or 26+
- npm; REA does not require or install a particular npm version
Native binary analysis requires [Hopper](https://www.hopperapp.com/) or [Ghidra](#ghidra-read-only-analysis-provider). Hopper is separate software with its own license; its demo supports analysis with vendor-defined limits. REA can use Ghidra that you have already installed.
Native binary analysis requires [Hopper](https://www.hopperapp.com/) or [Ghidra](#ghidra-analysis-provider). Hopper is separate software with its own license; its demo supports analysis with vendor-defined limits. REA can use Ghidra that you have already installed.
Windows Ghidra support is experimental for native x86-64 PE applications on local NTFS. The package bundles native Job Object, private-DACL, and path-admission controls. See [Windows Ghidra P0](docs/windows-ghidra-p0.md) for prerequisites and verified scope.
@ -184,7 +184,7 @@ Install the missing packages and rerun `rea setup`. The Linux demo needs Xvfb, P
REA defaults `HOPPER_LAUNCHER_PATH` to `/Applications/Hopper Disassembler.app/Contents/MacOS/hopper` on macOS and `/opt/hopper/bin/Hopper` on Linux. Explicit configuration always takes precedence.
### Ghidra read-only analysis provider
### Ghidra analysis provider
Already use Ghidra? REA can connect it to your agent on Linux x64 or macOS x64/arm64. It requires **Ghidra 12.1.4** and a **64-bit JDK 21**. On macOS, your Ghidra installation must also include the native decompiler for your architecture.
@ -200,7 +200,7 @@ rea providers --json
Setup checks the installations and saves their paths in your selected agents' configuration after approval. Ghidra and Java must already be installed; REA does not download or change them.
The adapter exposes **25 read-only operations** for functions, strings, symbols, assembly, decompilation, calls, references, instructions, and data types. These also support REA's overview, search, call-graph, and function-analysis workflows. Ghidra does not provide GUI controls or annotation changes through REA.
The adapter exposes inventory, function, memory and load-image inspection, plus atomic function annotation edits on Linux and macOS. `annotate_native_function` edits names and entry comments in the session database and returns a refreshed function dossier; executable bytes stay unchanged. Ghidra does not provide GUI controls through REA.
REA analyzes a temporary copy of the target and removes the temporary project when the session closes. Results identify what Ghidra observed and what it could not resolve. Decompilation produces pseudocode rather than the original source.
@ -307,7 +307,7 @@ See [native investigation](docs/native-investigation.md) for keyed archives, ins
| Tool family | Count | Examples |
| ------------------------- | ----: | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Native inspection | 40 | functions, pseudocode, assembly, strings, symbols, calls, references, annotations, byte reads, and file offsets |
| Native inspection | 41 | 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 |
@ -453,7 +453,7 @@ flowchart LR
REA --> Session["Target-bound session router"]
Session --> Registry["Deep-provider registry<br/>deterministic selection"]
Registry --> Hopper["Hopper provider"]
Registry --> Ghidra["Ghidra provider<br/>read-only inventory + function analysis"]
Registry --> Ghidra["Ghidra provider<br/>inventory + function analysis + annotations"]
Hopper --> Runtime["Owned provider runtime<br/>deadline + bounded diagnostics + cleanup"]
Ghidra --> Runtime
Session --> Native["Native macOS provider"]
@ -522,7 +522,7 @@ Use `--provider`, or `provider_id` in MCP, to choose Hopper or Ghidra for a targ
With `auto`, REA selects the only available tool that supports the target. If both are available, specify one before opening the target. The session keeps that choice until you explicitly switch or close it; a failure never silently switches tools. Artifact-only analysis can work without a native analysis tool.
Run `rea providers` and `rea capabilities` to check availability and supported operations. Ghidra exposes 25 read-only operations on supported Linux and macOS hosts. GUI controls and annotation changes require Hopper. Windows Ghidra P0 supports native x86-64 PE applications on local NTFS with bundled native controls.
Run `rea providers` and `rea capabilities` to check availability and supported operations. Ghidra supports inspection and atomic function annotation edits on Linux and macOS. Its database edits are discarded on close; GUI controls require Hopper. Windows Ghidra P0 supports read-only analysis of native x86-64 PE applications on local NTFS with bundled native controls.
The session also reports active work and cleanup status. If a caller times out, the analysis tool may still be busy; `analysis_activity` reports that state. A `cleanup_incomplete` result identifies resources whose shutdown or removal could not be verified. See [provider selection and analysis profiles](docs/adr/0001-provider-selection-and-analysis-profiles.md) for session, cache, and process-tracking details.
@ -628,7 +628,7 @@ No. Setup can install Hopper for you, but Hopper remains separate software with
<details>
<summary><strong>Does REA install or include Ghidra or Java?</strong></summary>
No. REA connects to an existing Ghidra installation. Run setup after providing the Ghidra and Java paths shown in the [Ghidra section](#ghidra-read-only-analysis-provider).
No. REA connects to an existing Ghidra installation. Run setup after providing the Ghidra and Java paths shown in the [Ghidra section](#ghidra-analysis-provider).
</details>

View File

@ -204,7 +204,7 @@ rea setup
| عائلة الأدوات | العدد | الاستخدام |
| --------------------- | ----: | -------------------------------------------------------------------------------------- |
| فحص الملفات التنفيذية | 40 | الدوال والشيفرة شبه المصدرية والتعليمات والسلاسل والرموز والمراجع والتعليقات |
| فحص الملفات التنفيذية | 41 | الدوال والشيفرة شبه المصدرية والتعليمات والسلاسل والرموز والمراجع والتعليقات |
| التحليل المركب | 14 | النظرة العامة وتحليل الدوال وفك الترجمة الدفعي ومخطط الاستدعاء وفحص Swift وObjC |
| أدوات macOS الأصلية | 7 | بيانات Mach-O والتوقيعات وplist والمعماريات واستعادة أسماء Swift |
| الملفات والحزم | 5 | فحص الدلائل والحزم وInterface Builder وموارد Apple والاستخراج |

View File

@ -193,7 +193,7 @@ REA は手順 1〜5 のバイナリ解析を処理し、手順 6 はエージェ
| ツール分類 | 数 | 用途 |
| -------------------- | --: | --------------------------------------------------------------------------- |
| バイナリ検査 | 40 | 関数、疑似コード、アセンブリ、文字列、シンボル、参照、注釈 |
| バイナリ検査 | 41 | 関数、疑似コード、アセンブリ、文字列、シンボル、参照、注釈 |
| 組み合わせた解析 | 14 | 概要、関数解析、一括逆コンパイル、呼び出しグラフ、Swift と ObjC |
| macOS ネイティブ | 7 | Mach-O メタデータ、署名、plist、アーキテクチャ、Swift 名の復元 |
| ファイルとパッケージ | 5 | ディレクトリとパッケージ、Interface Builder、Apple リソース、抽出 |

View File

@ -194,7 +194,7 @@ REA는 1–5단계의 바이너리 분석을 처리합니다. 6단계는 에이
| 도구 분류 | 수 | 용도 |
| ----------------- | --: | -------------------------------------------------------------------- |
| 바이너리 검사 | 40 | 함수, 의사 코드, 어셈블리, 문자열, 심볼, 참조, 주석 |
| 바이너리 검사 | 41 | 함수, 의사 코드, 어셈블리, 문자열, 심볼, 참조, 주석 |
| 결합 분석 | 14 | 개요, 함수 분석, 일괄 디컴파일, 호출 그래프, Swift와 ObjC 검사 |
| macOS 네이티브 | 7 | Mach-O 메타데이터, 서명, plist, 아키텍처, Swift 이름 복원 |
| 파일과 패키지 | 5 | 디렉터리와 패키지 검사, Interface Builder, Apple 리소스, 추출 |

View File

@ -193,7 +193,7 @@ REA 负责第 1–5 步中的二进制分析。第 6 步由智能体使用其常
| 工具类别 | 数量 | 用途 |
| ----------------- | ---: | --------------------------------------------------------- |
| 二进制检查 | 40 | 函数、伪代码、汇编、字符串、符号、段、引用与注释 |
| 二进制检查 | 41 | 函数、伪代码、汇编、字符串、符号、段、引用与注释 |
| 组合分析 | 14 | 概览、函数分析、批量反编译、调用图、Swift 与 ObjC 检查 |
| macOS 原生工具 | 7 | Mach-O 元数据、代码签名、plist、架构与 Swift 符号还原 |
| 制品检查 | 5 | 目录与软件包检查、Interface Builder、Apple 资源目录与提取 |

View File

@ -117,6 +117,7 @@ public final class ReaGhidraBridge extends HeadlessScript {
"params"
);
private static final String[] CAPABILITIES = {
"annotate_native_function",
"inspect_native_load_image",
"read_bytes",
"address_to_file_offset",
@ -191,6 +192,9 @@ public final class ReaGhidraBridge extends HeadlessScript {
"Ghidra imported-byte digest does not match the admitted target"
);
}
// GhidraScript wraps run() in a transaction. End it before serving so
// each edit owns a real outer transaction and can roll back immediately.
end(true);
try {
initializeDecompiler();
serve(descriptor);
@ -363,6 +367,11 @@ public final class ReaGhidraBridge extends HeadlessScript {
case "resolve_native_call_targets" -> resolveNativeCallTargets(request.params);
case "xrefs" -> xrefs(request.params);
case "analyze_function" -> analyzeFunction(request.params);
case "annotate_native_function" -> {
if (!descriptor.transport.equals("unix-socket"))
throw new RequestFailure("method_unavailable", "Windows P0 does not admit database mutation");
yield annotateNativeFunction(request.params);
}
default -> throw new RequestFailure(
"method_unavailable",
"Bridge method is unavailable"
@ -395,10 +404,15 @@ public final class ReaGhidraBridge extends HeadlessScript {
result.addProperty("run_id", descriptor.runId);
result.addProperty("profile_digest", descriptor.profileDigest);
result.add("provider", provider);
result.addProperty("read_only", true);
boolean readOnly = !descriptor.transport.equals("unix-socket");
result.addProperty("read_only", readOnly);
result.addProperty("analysis_complete", !timedOut);
result.addProperty("analysis_timed_out", timedOut);
result.add("capabilities", GSON.toJsonTree(CAPABILITIES));
result.add("capabilities", GSON.toJsonTree(
java.util.Arrays.stream(CAPABILITIES)
.filter(value -> !readOnly || !value.equals("annotate_native_function"))
.toArray(String[]::new)
));
result.add("target", target);
return result;
}
@ -954,6 +968,90 @@ public final class ReaGhidraBridge extends HeadlessScript {
return result;
}
private void invalidateAnalysisCaches() {
nameInventory = null;
procedureInventory = null;
stringInventory = null;
if (decompiler != null) decompiler.flushCache();
}
private JsonObject annotationReadback(Function function) {
Address entry = function.getEntryPoint();
JsonObject value = new JsonObject();
value.addProperty("address", canonicalAddress(entry));
value.addProperty("name", procedureName(function));
value.addProperty("comment", currentProgram.getListing().getComment(CommentType.PRE, entry));
value.addProperty("inline_comment", currentProgram.getListing().getComment(CommentType.EOL, entry));
return value;
}
private JsonObject annotateNativeFunction(JsonObject params) throws Exception {
if (!Set.of("procedure", "name", "comment", "inline_comment").containsAll(params.keySet()))
throw new RequestFailure("invalid_request", "Unknown function annotation field");
if (!params.has("name") && !params.has("comment") && !params.has("inline_comment"))
throw new RequestFailure("invalid_request", "Supply at least one annotation change");
Function function = resolveProcedure(requireString(params, "procedure"));
Address entry = function.getEntryPoint();
if (function.isExternal() || !currentProgram.getMemory().contains(entry))
throw new RequestFailure("invalid_request", "Annotations require a local function entry: " + canonicalAddress(entry));
int transaction = currentProgram.startTransaction("REA function annotations");
boolean commit = false;
try {
// Validate edits through Ghidra's own setters, including name rules.
// A later invalid name must roll back earlier comment writes.
for (String field : List.of("comment", "inline_comment")) {
if (params.has(field)) {
String text = requireText(params, field);
CommentType type = field.equals("comment") ? CommentType.PRE : CommentType.EOL;
currentProgram.getListing().setComment(entry, type, text.isEmpty() ? null : text);
}
}
if (params.has("name"))
function.setName(requireString(params, "name"), SourceType.USER_DEFINED);
monitor.checkCancelled();
invalidateAnalysisCaches();
JsonObject readback = annotationReadback(function);
for (String field : List.of("name", "comment", "inline_comment")) {
if (!params.has(field)) continue;
String requested = requireText(params, field);
JsonElement measured = readback.get(field);
String expected = !field.equals("name") && requested.isEmpty() ? null : requested;
String observed = field.equals("name") ? function.getName() :
(measured.isJsonNull() ? null : measured.getAsString());
if (!java.util.Objects.equals(expected, observed))
throw new RequestFailure("annotation_readback_mismatch", "Annotation readback differs for " + field + " at " + canonicalAddress(entry));
}
JsonObject query = new JsonObject();
query.addProperty("procedure", canonicalAddress(entry));
JsonObject result = new JsonObject();
result.add("annotations", readback);
result.add("dossier", analyzeFunction(query));
JsonObject effects = new JsonObject();
effects.addProperty("scope", "session-analysis-database");
effects.addProperty("source_bytes_modified", false);
effects.addProperty("persists_after_close", false);
result.add("effects", effects);
monitor.checkCancelled();
commit = true;
return result;
}
catch (ghidra.util.exception.InvalidInputException | ghidra.util.exception.DuplicateNameException exception) {
throw new RequestFailure("invalid_function_name", "Invalid function name at " + canonicalAddress(entry) + ": " + safeMessage(exception));
}
finally {
currentProgram.endTransaction(transaction, commit);
// Rollback also invalidates inventory and decompiler views.
invalidateAnalysisCaches();
}
}
private static String requireText(JsonObject params, String field) {
JsonElement value = params.get(field);
if (value == null || !value.isJsonPrimitive() || !value.getAsJsonPrimitive().isString())
throw new RequestFailure("invalid_request", "Annotation " + field + " must be text");
return value.getAsString();
}
private JsonObject analyzeFunction(JsonObject params) throws Exception {
requireKeys(params, Set.of("procedure"));
Function function = resolveProcedure(requireString(params, "procedure"));

View File

@ -569,5 +569,7 @@ The first six implementation stages are shipped:
Future stages may deepen format and indirect-flow coverage, but must continue
to compare normalized semantics rather than provider-specific pseudocode or
assembly text. Ghidra continues to omit GUI and mutation operations, so
unsupported requests cannot route to an unverified implementation.
assembly text. Linux and macOS now admit atomic function-name and entry-comment
edits in the ephemeral database, with readback, rollback, and cache invalidation.
Executable bytes remain unchanged. Windows P0 retains its read-only boundary;
GUI and other unimplemented mutations remain unavailable.

View File

@ -167,3 +167,22 @@ executable fixtures, an emulator, or an existing analysis database.
Provider p-code representations can contain process-specific address-space selector
tokens. The lane preserves those reported values and compares stable dossier
observations rather than asserting cross-process identity of raw p-code.
## Function annotations
`annotate_native_function` changes the name and/or comments at one exact local function entry, verifies readback, and returns a refreshed dossier. This is useful when recording recovered DOS function roles; it does not claim original-source names. Name writes preserve the existing namespace; readback returns the fully qualified name used by function inventories. Regular comments map to Ghidra PRE comments and inline comments to EOL comments. Empty text clears a comment; omitted fields preserve it. The whole edit rolls back if a setter, readback or analysis fails.
```json
{
"procedure": "0x10100",
"name": "entry",
"comment": "Recovered startup role",
"inline_comment": "Inspect segment setup"
}
```
```bash
rea annotate-native-function /local/program.com 0x10100 --target-format dos-com --provider ghidra --name entry --comment 'Recovered startup role' --json
```
The CLI returns the updated analysis before closing its isolated session. MCP edits remain visible to later reads until `close_binary`. Original bytes remain unchanged, database edits are discarded on close, and immutable analysis snapshots are invalidated after edits. This operation does not save a Ghidra project, change ABI/types/body ranges, patch instructions, or emulate a DOS runtime. Windows P0 does not admit it.

View File

@ -224,9 +224,11 @@ operations and twelve function-analysis operations. These cover metadata,
decompilation, assembly, resolved calls, typed references, xrefs, function
dossiers, instructions, recovered data types, measured load mappings, loaded
memory bytes, and observed file offsets. Independent load-image attestation
currently supports DOS MZ only; PE returns its measurements with that limitation.
GUI controls and annotation
changes require Hopper.
supports DOS MZ and explicitly selected COM; PE returns its measurements with that limitation.
On Linux and macOS, `annotate_native_function` also edits a function name and/or
entry comments atomically and returns refreshed analysis. These 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-64 PE applications on fixed local NTFS volumes.
The npm package bundles native Job Object ownership, protected private runtime

View File

@ -150,8 +150,10 @@ the matching native decompiler. Experimental Windows x64 P0 admits native
x86-64 PE applications 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
[issue #527](https://github.com/morluto/rea/issues/527).
Ghidra has no GUI or mutation authority, and REA never falls back automatically
to Hopper.
On Linux and macOS, `annotate_native_function` atomically edits a function name
and entry comments in the ephemeral database, returning refreshed analysis
without changing executable bytes. Windows P0 remains read-only. Ghidra has no
GUI authority, and REA never falls back automatically to Hopper.
- `npm run verify:ghidra`: host-native debug/stripped targets, native type layout,
instruction/call facts, value dependencies and process/project cleanup.

View File

@ -10,15 +10,16 @@
"skill_version": "24"
},
"tools": {
"total": 117,
"total": 118,
"families": [
{
"id": "direct",
"surface": "official-proxy",
"count": 40,
"count": 41,
"tools": [
"address_name",
"address_to_file_offset",
"annotate_native_function",
"comment",
"current_address",
"current_document",
@ -210,6 +211,7 @@
"address_name",
"address_to_file_offset",
"analyze_function",
"annotate_native_function",
"inspect_native_data_type",
"inspect_native_instruction",
"inspect_native_load_image",
@ -469,12 +471,13 @@
}
],
"cli": {
"primary_count": 72,
"primary_count": 73,
"commands": [
"address-to-file-offset",
"analyze",
"analyze-javascript-application",
"analyze-web-bundle",
"annotate-native-function",
"build-reconstruction-obligation-ledger",
"capabilities",
"capture-browser-scenario",
@ -553,15 +556,15 @@
},
"runtime_catalog": {
"counts": {
"cli_commands": 72,
"mcp_tools": 117,
"cli_commands": 73,
"mcp_tools": 118,
"mcp_prompts": 6
},
"digests": {
"tools_sha256": "be74884c49199aa22146a94dfbf8c11dd0ae0b87c0896e32a10b9ff691fdd342",
"tools_sha256": "bbd9aacf7bb0bc54a8f8743491583482e1176252513d99780a309d0fddeb3df0",
"prompts_sha256": "4762db348821fec787a5b8c6423f2c48a71f6f0ec2afee97b16ad69030ec504b",
"combined_sha256": "f63ae9b3480f206700cf334c54b3267aff8d0c3a7f4e782d8b5f7952cfdd5271",
"providers_sha256": "c1a6849b2524347e6cf9e004a17ba1805f86114b2da72eb689a075afbbb1aada"
"combined_sha256": "b4f7e5860d90b029876b58fe7cd4a8041fef1f2add23440e37e90827d808e503",
"providers_sha256": "e094c361b60ec930a6062a1a90ee05536ce411541dba5b51ace7e2da2ba25af0"
}
}
}

View File

@ -9,7 +9,10 @@ operations after analysis completes. They cover inventories, search,
decompilation, assembly, function metadata, resolved calls, references, control
flow, instructions, and recovered types. Results identify the provider version,
analysis settings, target digest, and any unavailable or incomplete facts.
Ghidra does not expose Hopper's GUI or annotation operations.
On Linux and macOS, `annotate_native_function` additionally edits names and entry
comments atomically, verifies readback, and returns refreshed function analysis.
Changes live only in the session database and leave executable bytes unchanged.
Ghidra does not expose GUI controls; Windows P0 remains read-only.
Windows x64 P0 supports native x86-64 PE applications on local NTFS with
bundled Job Object ownership, protected runtime DACLs, and handle-based path
@ -152,12 +155,12 @@ only the generic process mechanisms.
## Shortlist
| Provider | License / automation surface | What it brings | REA fit and blockers |
| ------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| [Ghidra](https://github.com/NationalSecurityAgency/ghidra) | Apache-2.0 source license; `analyzeHeadless`, Java APIs, and PyGhidra | Static analysis, multiple processors and formats, scripting, and project/database workflows | 25 read-only operations shipped on Linux/macOS with installation checks, temporary projects, a serial API queue, and real ELF/PE/Mach-O conformance. Experimental Windows x64 P0 supports native x86-64 PE on local NTFS with bundled native controls and ordinary-user CLI/MCP verification; GUI and mutation operations are unavailable. |
| [Rizin](https://github.com/rizinorg/rizin) / [rz-pipe](https://github.com/rizinorg/rz-pipe) | Rizin repository contains LGPL-3.0 and GPL-3.0 components; `rizin`, `rz-bin`, and language bridges through `rzpipe` | Portable CLI analysis, disassembly/debugging, many architectures and file formats, JSON command output | Good candidate for a process-backed Linux provider and fast metadata fallback. License/component inventory must be preserved; command output needs version-pinned parsers and semantic conformance before evidence is trusted. |
| [LIEF](https://github.com/lief-project/LIEF) | Apache-2.0; C++, Python, and other bindings | Deterministic parsing and modification of ELF, PE, Mach-O, COFF, and related executable formats; headers, sections, symbols, relocations, and functions | Best near-term complement, not a decompiler replacement. It can cover format metadata and artifact evidence without a long-lived analysis process; function semantics, pseudocode, CFG, and cross-reference parity remain out of scope unless separately demonstrated. |
| [Binary Ninja](https://docs.binary.ninja/dev/index.html) | API/documentation components are MIT, while the analysis product is licensed by edition; commercial, Ultimate, or Headless license is required for headless automation | Python/Core/C++/Rust APIs, headless loading, IL layers, function analysis, plugins, and configurable analysis | Strong technical fit for a native provider, especially function dossiers. Commercial licensing, license-secret handling, native runtime packaging, and multithreaded lifecycle rules are material deployment blockers. |
| Provider | License / automation surface | What it brings | REA fit and blockers |
| ------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [Ghidra](https://github.com/NationalSecurityAgency/ghidra) | Apache-2.0 source license; `analyzeHeadless`, Java APIs, and PyGhidra | Static analysis, multiple processors and formats, scripting, and project/database workflows | 25 read-only operations shipped on Linux/macOS with installation checks, temporary projects, a serial API queue, and real ELF/PE/Mach-O conformance. Experimental Windows x64 P0 supports native x86-64 PE on local NTFS with bundled native controls and ordinary-user CLI/MCP verification; Linux/macOS additionally support atomic session function annotations; Windows mutation and all Ghidra GUI operations are unavailable. |
| [Rizin](https://github.com/rizinorg/rizin) / [rz-pipe](https://github.com/rizinorg/rz-pipe) | Rizin repository contains LGPL-3.0 and GPL-3.0 components; `rizin`, `rz-bin`, and language bridges through `rzpipe` | Portable CLI analysis, disassembly/debugging, many architectures and file formats, JSON command output | Good candidate for a process-backed Linux provider and fast metadata fallback. License/component inventory must be preserved; command output needs version-pinned parsers and semantic conformance before evidence is trusted. |
| [LIEF](https://github.com/lief-project/LIEF) | Apache-2.0; C++, Python, and other bindings | Deterministic parsing and modification of ELF, PE, Mach-O, COFF, and related executable formats; headers, sections, symbols, relocations, and functions | Best near-term complement, not a decompiler replacement. It can cover format metadata and artifact evidence without a long-lived analysis process; function semantics, pseudocode, CFG, and cross-reference parity remain out of scope unless separately demonstrated. |
| [Binary Ninja](https://docs.binary.ninja/dev/index.html) | API/documentation components are MIT, while the analysis product is licensed by edition; commercial, Ultimate, or Headless license is required for headless automation | Python/Core/C++/Rust APIs, headless loading, IL layers, function analysis, plugins, and configurable analysis | Strong technical fit for a native provider, especially function dossiers. Commercial licensing, license-secret handling, native runtime packaging, and multithreaded lifecycle rules are material deployment blockers. |
## Recommended order

View File

@ -48,7 +48,9 @@ GitHub Copilot CLI, Command Code, and VS Code using each client's configuration
Ghidra analysis supports Linux x64 and macOS x64/arm64 with Ghidra 12.1.4 and a
64-bit full JDK 21. macOS also requires the matching native decompiler. The
adapter exposes thirteen inventory operations and twelve function-analysis
operations, for 25 read-only operations total. Approved setup saves verified
operations, for 25 read-only operations total. Linux and macOS additionally support
atomic function-name and entry-comment edits with refreshed analysis; metadata
is discarded on close and executable bytes stay unchanged. Approved setup saves verified
installation paths in agent configurations without installing or changing
Ghidra or Java.

View File

@ -1,5 +1,5 @@
{
"manifest_id": "ecm_8d5469adca2b281028f4b94f0e82c8a104c988d97542a91b51d903e764d6c779",
"manifest_id": "ecm_125743de92b0cbb08a3cb11ee0d9305f60e5c4fb81152b8b748d348f91f99ee8",
"verifier": {
"id": "rea-managed-conformance",
"version": "1"
@ -13,7 +13,7 @@
"skill_digests": [
{
"skill_id": "reverse-engineer-anything",
"sha256": "73d1f6ba53bb3d3a115a5a5895dd21975494147b05f3360cc19d35437d060cd0"
"sha256": "26338313efd5c941e1f3e1f1d35e2605d47749f674c359458e76d5a470b0cbb1"
}
],
"claims": [

View File

@ -257,7 +257,10 @@ try {
pathToFileURL(join(packageRoot, "dist/ghidra/GhidraSessionValues.js"))
)
).GHIDRA_SESSION_CAPABILITIES.filter(
(name) => name !== "ping" && name !== "shutdown",
(name) =>
name !== "ping" &&
name !== "shutdown" &&
name !== "annotate_native_function",
);
assert.ok(
expected.every((name) => report.mcpOperations.includes(name)),

View File

@ -27,7 +27,11 @@ export async function verifyPackedBridge({
"extends HeadlessScript",
'request.method.equals("ping")',
'request.method.equals("shutdown")',
'result.addProperty("read_only", true)',
'boolean readOnly = !descriptor.transport.equals("unix-socket")',
'result.addProperty("read_only", readOnly)',
'case "annotate_native_function"',
'currentProgram.startTransaction("REA function annotations")',
"currentProgram.endTransaction(transaction, commit)",
"analysisTimeoutOccurred()",
])
if (!ghidraSource.includes(commitment))

View File

@ -173,6 +173,88 @@ try {
(await cli("address-to-file-offset", fixture.near)).normalized_result,
mapping,
);
const changes = {
procedure: fixture.entry,
name: "rea_entry",
comment: "Regular\nUnicode 註記",
inline_comment: "Inline finding",
};
// Prime provider inventories and the session snapshot before edits.
await call("list_names");
const updated = await call("annotate_native_function", changes);
assert.equal(updated.annotations.name, changes.name);
assert.equal(updated.annotations.comment, changes.comment);
assert.equal(updated.annotations.inline_comment, changes.inline_comment);
assert.equal(updated.dossier.procedure.name, changes.name);
assert.ok(updated.dossier.pseudocode.includes(changes.name));
assert.equal(
await call("procedure_address", { procedure: changes.name }),
fixture.entry,
);
assert.equal(
await call("address_name", { address: fixture.entry }),
changes.name,
);
assert.ok(
(await call("list_procedures")).some((p) => p.value === changes.name),
);
assert.ok((await call("list_names")).some((p) => p.value === changes.name));
assert.equal(
(await call("analyze_function", { procedure: fixture.entry })).procedure
.name,
changes.name,
);
const rejected = await client.callTool({
name: "annotate_native_function",
arguments: {
procedure: fixture.entry,
comment: "MUST ROLL BACK",
name: "invalid name\n",
},
});
assert.equal(
rejected.isError,
true,
"Ghidra accepted an invalid function name",
);
const retained = await call("annotate_native_function", {
procedure: fixture.entry,
name: changes.name,
});
assert.equal(
retained.annotations.comment,
changes.comment,
"Failed edit leaked its earlier comment write",
);
assert.equal(retained.annotations.inline_comment, changes.inline_comment);
assert.equal(retained.dossier.procedure.name, changes.name);
const cleared = await call("annotate_native_function", {
procedure: fixture.entry,
comment: "",
inline_comment: "",
});
assert.equal(cleared.annotations.comment, null);
assert.equal(cleared.annotations.inline_comment, null);
assert.ok(!cleared.dossier.comments.some((c) => c.address === fixture.entry));
assert.equal(
(
await call("read_bytes", {
address: fixture.entry,
length: fixture.bytes.length,
})
).bytes_hex,
fixture.bytes.toString("hex"),
);
assert.equal((await call("inspect_native_load_image")).status, "verified");
const cliAnnotated = await cli("annotate-native-function", fixture.entry, [
"--name",
changes.name,
"--comment",
changes.comment,
"--inline-comment",
changes.inline_comment,
]);
assert.deepEqual(cliAnnotated.normalized_result, updated);
if (captureDirectory !== undefined)
await writeFile(
join(captureDirectory, "image.json"),
@ -204,6 +286,13 @@ try {
partial_read_bytes: 2,
},
functions: { entry: true, near: true, pseudocode_nonempty: true },
annotations: {
atomic_rollback: true,
fresh_inventories: true,
fresh_decompiler: true,
clear_comments: true,
cli_mcp_parity: true,
},
original_unchanged: true,
limitations: image.limitations,
};

View File

@ -498,7 +498,11 @@ function assertProfile(profile) {
);
assert.equal(profile.provider.id, "ghidra");
assert.equal(profile.parameters.target_format, "dos-mz");
assert.equal(profile.parameters.import_mode, "ephemeral-read-only");
assert.equal(profile.parameters.import_mode, "ephemeral-source-immutable");
assert.equal(
profile.parameters.annotation_policy,
"atomic-function-entry-metadata-v1",
);
assert.equal(profile.parameters.loader, "MzLoader");
assert.equal(profile.parameters.language_id, "x86:LE:16:Real Mode");
assert.equal(profile.parameters.compiler_spec_id, "default");

View File

@ -22,16 +22,26 @@ import {
} from "../dist/ghidra/GhidraInstallation.js";
import { GhidraHeadlessLauncher } from "../dist/ghidra/GhidraLauncher.js";
import { GHIDRA_PROVIDER_IDENTITY } from "../dist/ghidra/GhidraProvider.js";
import { GHIDRA_SESSION_CAPABILITIES as SESSION_CAPABILITIES } from "../dist/ghidra/GhidraSessionValues.js";
import { windowsP0Capabilities } from "../dist/ghidra/GhidraProviderCapabilities.js";
import { GHIDRA_SESSION_CAPABILITIES } from "../dist/ghidra/GhidraSessionValues.js";
import { parseBinaryTarget } from "../dist/application/BinaryTargetResolver.js";
import { completeVerifierRun, createVerifierRun } from "./lib/verifier-run.mjs";
const verifierRun = createVerifierRun();
const SESSION_CAPABILITIES = GHIDRA_SESSION_CAPABILITIES.filter(
(name) => name !== "annotate_native_function",
);
if (process.platform !== "win32" || process.arch !== "x64")
throw new Error(
"The real Windows Ghidra verifier requires a Windows x64 host",
);
if (
windowsP0Capabilities().some(
({ available, effects }) => available && effects.mutatesArtifact,
)
)
throw new Error("Windows Ghidra must not advertise database mutation");
const installDir = process.env.GHIDRA_INSTALL_DIR;
if (installDir === undefined || !isAbsolute(installDir))
throw new Error(
@ -185,7 +195,9 @@ try {
const expected = [
...GHIDRA_INVENTORY_OPERATIONS,
...GHIDRA_FUNCTION_OPERATIONS,
...GHIDRA_FUNCTION_OPERATIONS.filter(
(name) => name !== "annotate_native_function",
),
];
if (
observed.size !== expected.length ||

View File

@ -3,8 +3,8 @@ 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: "24"
tool_count: 117
catalog_digest: "f63ae9b3480f206700cf334c54b3267aff8d0c3a7f4e782d8b5f7952cfdd5271"
tool_count: 118
catalog_digest: "b4f7e5860d90b029876b58fe7cd4a8041fef1f2add23440e37e90827d808e503"
---
# REA

View File

@ -60,3 +60,7 @@ For headerless DOS COM, explicitly open with `format: "dos-com"` (CLI
`--target-format dos-com`). Inspect the load image before trusting function
analysis: the entry is `0x10100`, file offset zero, with imposed real-mode segment
context. PSP/stack/device state remains unmodeled. See [DOS guide](../../../docs/ghidra-dos.md).
### Function annotations in Ghidra
Use `annotate_native_function` for one function name and/or entry comments, with at least one explicit change. Review its annotation readback and refreshed dossier inline. Changes are atomic and session-scoped; empty comments clear them, omitted fields preserve them. Later MCP calls observe edits until close. CLI `annotate-native-function` returns the updated analysis before discarding the session. Original executable bytes are unchanged; edits invalidate immutable snapshots. Windows P0 does not admit database mutations.

View File

@ -43,6 +43,7 @@ export {
} from "./DirectAnalysisStatus.js";
type DirectAnalysisTool =
| "annotate_native_function"
| "inspect_native_load_image"
| "read_bytes"
| "address_to_file_offset"

View File

@ -19,6 +19,7 @@ export const registerCoreAnalysisCommands = (
logger: Logger,
): void => {
registerCoreCommands(cli, logger);
registerAnnotationCommand(cli, logger);
cli.command(CLI_COMMANDS.inspectNativeLoadImage, {
description:
"Verify loaded native bytes, source mappings, relocations and entry",
@ -722,3 +723,51 @@ const registerSearchCommand = (cli: CliInstance, logger: Logger): void => {
),
});
};
const registerAnnotationCommand = (cli: CliInstance, logger: Logger): void => {
cli.command(CLI_COMMANDS.annotateNativeFunction, {
description: "Edit function annotations and return refreshed analysis",
args: z.object({
path: z.string().describe("Local executable path"),
procedure: z
.string()
.describe("Exact function entry address or unique name"),
}),
options: z.object({
name: z.string().min(1).optional().describe("Analyst function name"),
comment: z
.string()
.optional()
.describe("Regular entry comment; empty text clears it"),
"inline-comment": z
.string()
.optional()
.describe("Inline entry comment; empty text clears it"),
"target-format": formatSelectionOption,
provider: providerSelectionOption,
}),
run: ({ args, options }) =>
logCliCommand(logger, CLI_COMMANDS.annotateNativeFunction, () =>
runDirectAnalysis(
args.path,
"annotate_native_function",
{
procedure: args.procedure,
...(options.name === undefined ? {} : { name: options.name }),
...(options.comment === undefined
? {}
: { comment: options.comment }),
...(options["inline-comment"] === undefined
? {}
: { inline_comment: options["inline-comment"] }),
},
directAnalysisOptions(
logger,
undefined,
options.provider,
options["target-format"],
),
),
),
});
};

View File

@ -14,6 +14,7 @@ export const CLI_COMMANDS = Object.freeze({
capabilities: "capabilities",
providers: "providers",
function: "function",
annotateNativeFunction: "annotate-native-function",
inspectNativeApi: "inspect-native-api",
instructions: "instructions",
search: "search",

View File

@ -1,4 +1,5 @@
import { z } from "zod";
import { nativeFunctionAnnotationsInputSchema } from "../domain/nativeFunctionAnnotations.js";
import {
officialOutputSchemas,
@ -36,6 +37,11 @@ const official = <Name extends string, Schema extends z.ZodObject>(
/** Bridge operations exposed without additional application composition. */
export const OFFICIAL_TOOL_CONTRACTS = [
official(
"annotate_native_function",
"Atomically update one function name and/or entry comments, read them back, and return a refreshed complete function dossier. Ghidra changes its ephemeral session analysis database only; original executable bytes remain unchanged. Empty comment text clears that comment. Edits remain available to subsequent MCP calls until close; CLI returns the updated dossier before discarding its session. Invalid edits roll back the entire operation. Windows P0 and providers without this capability are unsupported.",
nativeFunctionAnnotationsInputSchema,
),
official(
"inspect_native_load_image",
"Verify the provider's loaded DOS MZ or explicitly selected COM 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. COM verification includes its imposed entry register context and does not claim PSP/runtime coverage. Overlay and uninitialized coverage remain explicit; other formats return unsupported. Does not execute or modify the target.",

View File

@ -42,6 +42,11 @@ export const TOOL_EXAMPLE_OVERRIDES: Readonly<
get_call_graph: { address: "0x1000" },
find_xrefs_to_name: { name: "malloc" },
analyze_function: { procedure: "main" },
annotate_native_function: {
procedure: "0x10100",
name: "entry",
comment: "Analyst observation",
},
analyze_swift_types: { category: "classes", pattern: "Account" },
inspect_native_api: { procedure: "main" },
trace_feature: { query: "license" },

View File

@ -41,6 +41,12 @@ const sessionEvidence = effects({ mutatesSession: true });
/** Explicit effect audit for every public tool. */
export const TOOL_EFFECTS: Readonly<Record<string, ToolEffects>> = {
annotate_native_function: effects({
mutatesTarget: true,
mutatesSession: true,
launchesProcess: true,
writesFilesystem: true,
}),
inspect_native_load_image: evidence,
inspect_native_dispatch_metadata: evidence,
trace_native_ui_action: evidence,

View File

@ -1,4 +1,5 @@
import { z } from "zod";
import { nativeFunctionAnnotationsSchema } from "../domain/nativeFunctionAnnotations.js";
import { nativeLoadImageSchema } from "../domain/nativeLoadImage.js";
import { nativeUiResultSchema } from "../domain/nativeUiObservation.js";
import { nativeValueTraceSchema } from "../domain/nativeValueTrace.js";
@ -101,6 +102,7 @@ const addressedString = z.object({
/** Exact structured-content schemas shared by direct analysis providers. */
export const officialOutputSchemas: Readonly<Record<string, z.ZodObject>> = {
annotate_native_function: resultOf(nativeFunctionAnnotationsSchema),
inspect_native_load_image: resultOf(nativeLoadImageSchema),
inspect_native_data_type: resultOf(nativeDataTypeSchema),
inspect_native_instruction: resultOf(nativeInstructionSchema),

View File

@ -0,0 +1,52 @@
import { z } from "zod";
import { functionDossierSchema } from "./hopperValues.js";
/** One explicit function's analyst-authored metadata changes. */
export const nativeFunctionAnnotationsInputSchema = z
.strictObject({
procedure: z.string().min(1),
name: z.string().min(1).optional(),
comment: z
.string()
.optional()
.describe("Regular entry comment; empty text clears it"),
inline_comment: z
.string()
.optional()
.describe("Inline entry comment; empty text clears it"),
})
.refine(
(value) =>
value.name !== undefined ||
value.comment !== undefined ||
value.inline_comment !== undefined,
"Supply at least one annotation change",
);
/** Readback and refreshed analysis after one atomic metadata edit. */
export const nativeFunctionAnnotationsSchema = z
.strictObject({
annotations: z.strictObject({
address: z.string().min(1),
name: z.string().min(1),
comment: z.string().nullable(),
inline_comment: z.string().nullable(),
}),
dossier: functionDossierSchema,
effects: z.strictObject({
scope: z.literal("session-analysis-database"),
source_bytes_modified: z.literal(false),
persists_after_close: z.literal(false),
}),
})
.superRefine((value, context) => {
if (
value.annotations.address !== value.dossier.procedure.address ||
value.annotations.name !== value.dossier.procedure.name
)
context.addIssue({
code: "custom",
message:
"Annotation readback disagrees with refreshed function identity",
});
});

File diff suppressed because one or more lines are too long

View File

@ -38,7 +38,8 @@ export const resolveGhidraAnalysisProfile = (
available_architectures: [
...(target.availableArchitectures ?? []),
].sort(),
import_mode: "ephemeral-read-only",
import_mode: "ephemeral-source-immutable",
annotation_policy: "atomic-function-entry-metadata-v1",
load_image_observations: "source-mappings-entry-context-v2",
function_body_evidence: "complete-inclusive-ranges-v1",
...(dos

View File

@ -0,0 +1,114 @@
import { functionDossierSchema } from "../domain/hopperValues.js";
import { describe, expect, it } from "vitest";
import { nativeFunctionAnnotationsInputSchema } from "../domain/nativeFunctionAnnotations.js";
import { ghidraFunctionDossier } from "../domain/ghidraValues.fixture.js";
import {
parseGhidraFunctionInput,
parseGhidraFunctionResult,
} from "./GhidraFunctionValues.js";
import {
CAPABILITIES,
windowsP0Capabilities,
} from "./GhidraProviderCapabilities.js";
import { HOPPER_PROVIDER_TOOL_CONTRACTS } from "../hopper/HopperProvider.js";
const result = () => {
const dossier = functionDossierSchema.parse(ghidraFunctionDossier());
return {
annotations: {
address: dossier.procedure.address,
name: dossier.procedure.name,
comment: null,
inline_comment: "Finding",
},
dossier,
effects: {
scope: "session-analysis-database",
source_bytes_modified: false,
persists_after_close: false,
},
};
};
describe("Ghidra function annotations", () => {
it("requires an explicit function and at least one change without approval flags", () => {
for (const input of [
{},
{ procedure: "main" },
{ procedure: "", name: "entry" },
{ procedure: "main", name: "" },
{ procedure: "main", comment: null },
{ procedure: "main", name: "entry", approve: true },
])
expect(
nativeFunctionAnnotationsInputSchema.safeParse(input).success,
).toBe(false);
expect(
parseGhidraFunctionInput("annotate_native_function", {
procedure: "0x10100",
comment: "",
}),
).toEqual({ ok: true, value: { procedure: "0x10100", comment: "" } });
});
it("preserves multiline Unicode text and omitted fields", () => {
const input = { procedure: "entry", inline_comment: "Line one\n註記" };
expect(parseGhidraFunctionInput("annotate_native_function", input)).toEqual(
{ ok: true, value: input },
);
});
it("validates readback identity, exact effects and the complete Ghidra dossier", () => {
const value = result();
expect(
parseGhidraFunctionResult("annotate_native_function", value).ok,
).toBe(true);
for (const malformed of [
{ ...value, annotations: { ...value.annotations, name: "different" } },
{ ...value, annotations: { ...value.annotations, address: "0x9999" } },
{ ...value, effects: { ...value.effects, source_bytes_modified: true } },
{ ...value, effects: { ...value.effects, persists_after_close: true } },
{ ...value, dossier: { ...value.dossier, native_value_flow: null } },
])
expect(
parseGhidraFunctionResult("annotate_native_function", malformed).ok,
).toBe(false);
});
it("preserves qualified names in readback and the refreshed dossier", () => {
const value = result();
const name = "namespace::recovered_routine";
expect(
parseGhidraFunctionResult("annotate_native_function", {
...value,
annotations: { ...value.annotations, name },
dossier: {
...value.dossier,
procedure: { ...value.dossier.procedure, name },
},
}).ok,
).toBe(true);
});
it("declares mutation only for the admitted operation and keeps Windows and Hopper unavailable", () => {
expect(
CAPABILITIES.find((c) => c.operation === "annotate_native_function"),
).toMatchObject({
available: true,
effects: { mutatesArtifact: true, mayShowUi: false },
});
expect(
CAPABILITIES.find((c) => c.operation === "analyze_function"),
).toMatchObject({ effects: { mutatesArtifact: false } });
expect(
windowsP0Capabilities().find(
(c) => c.operation === "annotate_native_function",
),
).toMatchObject({ available: false });
expect(
HOPPER_PROVIDER_TOOL_CONTRACTS.some(
(c) => c.name === "annotate_native_function",
),
).toBe(false);
});
});

View File

@ -59,7 +59,7 @@ export type GhidraOperation =
| GhidraInventoryOperation
| GhidraFunctionOperation;
/** Owns one authenticated, private, read-only Ghidra headless session. */
/** Owns an authenticated private Ghidra session with transport-bound mutation authority. */
export class GhidraClient {
readonly #options: Required<
Pick<GhidraClientOptions, "startupTimeoutMs" | "transport" | "platform">
@ -406,6 +406,8 @@ export class GhidraClient {
return err(this.#failure("protocol", "Ghidra run identity is missing"));
const parsed = parseGhidraSessionInfo(value, {
runId,
expectedReadOnly:
this.#options.transport === "authenticated-loopback-tcp",
providerVersion: this.#options.providerVersion,
profileDigest: this.#options.profileDigest,
targetSha256: this.#options.targetSha256,

View File

@ -1,4 +1,8 @@
import { z } from "zod";
import {
nativeFunctionAnnotationsInputSchema,
nativeFunctionAnnotationsSchema,
} from "../domain/nativeFunctionAnnotations.js";
import { nativeDataTypeSchema } from "../domain/nativeDataType.js";
import {
nativeInstructionSchema,
@ -27,8 +31,9 @@ import {
ghidraProcedureIdentitySchema as procedureIdentity,
} from "./GhidraInventoryValues.js";
/** Read-only function operations admitted by the Ghidra adapter. */
/** Function analysis and annotation operations admitted by the Ghidra adapter. */
export const GHIDRA_FUNCTION_OPERATIONS = [
"annotate_native_function",
"inspect_native_data_type",
"inspect_native_instruction",
"resolve_native_call_targets",
@ -59,6 +64,7 @@ const procedure = ghidraIdentifierSchema;
const directProcedure = { document, procedure };
const inputSchemas = {
annotate_native_function: nativeFunctionAnnotationsInputSchema,
inspect_native_data_type: z
.object({
document,
@ -356,6 +362,9 @@ const ghidraFunctionInstructionWindow = functionInstructionWindowSchema
.strict();
const resultSchemas = {
annotate_native_function: nativeFunctionAnnotationsSchema.safeExtend({
dossier: ghidraFunctionDossier,
}),
analyze_function: ghidraFunctionDossier,
procedure_assembly: z.string(),
procedure_callees: z.array(ghidraCanonicalAddressSchema),

View File

@ -209,6 +209,19 @@ describe("Ghidra provider", () => {
});
describe("Ghidra platform support", () => {
it("keeps Windows annotation mutation unavailable independently of native controls", () => {
const ghidra = provider({ ...installationHost(), platform: "win32" });
expect(
ghidra
.capabilities()
.find(({ operation }) => operation === "annotate_native_function"),
).toMatchObject({
available: false,
reason: "Windows Ghidra P0 does not admit database mutation.",
availabilityCode: "unsupported_host",
});
});
it("keeps Windows P0 unavailable until native isolation authority exists", () => {
const ghidra = provider({ ...installationHost(), platform: "win32" });
const nativeApplication = peTarget("x86_64");
@ -343,7 +356,8 @@ describe("Ghidra client projection", () => {
expect(resolved.value.profile).toMatchObject({
provider: { id: "ghidra", name: "Ghidra", version: "12.1.4" },
parameters: {
import_mode: "ephemeral-read-only",
import_mode: "ephemeral-source-immutable",
annotation_policy: "atomic-function-entry-metadata-v1",
analyzer_preset: "ghidra-default",
},
});
@ -452,6 +466,46 @@ describe("Ghidra result projection", () => {
},
);
it("preserves the rejected name constraint and function address", async () => {
const message =
"Invalid function name at 0x10100: Symbol name contains invalid characters";
const ghidra = provider(installationHost(), () => ({
start: () => Promise.resolve(ok(sessionInfo())),
callTool: () =>
Promise.resolve(
err(
new GhidraSessionError(
"remote",
message,
{},
{ remoteCode: "invalid_function_name" },
),
),
),
close: () => Promise.resolve(),
}));
const resolved = await ghidra.resolveAnalysisProfile(
executableTarget("elf", "x86_64"),
);
if (!resolved.ok) throw resolved.error;
if (resolved.value.profile === null)
throw new Error("Expected a bound profile");
await expect(
ghidra
.createClient(executableTarget("elf", "x86_64"), resolved.value.profile)
.execute("annotate_native_function", {
procedure: "0x10100",
name: "bad name",
}),
).resolves.toMatchObject({
ok: false,
error: {
_tag: "AnalysisInputError",
issues: [{ path: ["name"], reason: "invalid_value", message }],
},
});
});
it("projects remote decompile cancellation as a provider-neutral interruption", async () => {
const code = "decompile_cancelled";
const tag = "AnalysisCancelledError";
@ -566,7 +620,7 @@ const sessionInfo = () => ({
run_id: "11111111-1111-4111-8111-111111111111",
profile_digest: "a".repeat(64),
provider: { id: "ghidra" as const, version: "12.1.4" },
read_only: true as const,
read_only: false as const,
analysis_complete: true,
analysis_timed_out: false,
capabilities: [...GHIDRA_SESSION_CAPABILITIES],

View File

@ -7,11 +7,13 @@ import { GHIDRA_INVENTORY_OPERATIONS } from "./GhidraInventoryValues.js";
import { GhidraProvider } from "./GhidraProvider.js";
describe("Ghidra provider capabilities", () => {
it("publishes only admitted read-only operations and resists caller mutation", () => {
it("publishes admitted operation effects and resists caller mutation", () => {
const config = parseConfig({});
expect(config.ok).toBe(true);
if (!config.ok) return;
const provider = new GhidraProvider(config.value, silentLogger, {
platform: "linux",
architecture: "x64",
readText: () => undefined,
executable: () => false,
probeJava: () => undefined,
@ -26,7 +28,7 @@ describe("Ghidra provider capabilities", () => {
available: true,
reason: null,
effects: {
mutatesArtifact: false,
mutatesArtifact: descriptor.operation === "annotate_native_function",
mayShowUi: false,
mayAccessNetwork: false,
changesPermissions: false,

View File

@ -81,7 +81,7 @@ export type GhidraProviderClientFactory = (
) => Pick<GhidraClient, "start" | "callTool" | "close"> &
Partial<Pick<GhidraClient, "runtimeLineage" | "readTargetSnapshot">>;
/** Ghidra candidate backed by an isolated read-only headless import. */
/** Ghidra candidate backed by an isolated ephemeral headless import. */
export class GhidraProvider implements AnalysisProviderCandidate {
#installation: GhidraInstallationInspection | undefined;
@ -475,6 +475,14 @@ const projectSessionError = (
operation: AnalysisOperation,
failure: GhidraSessionError,
): AnalysisError => {
if (
operation === "annotate_native_function" &&
failure.kind === "remote" &&
failure.remoteCode === "invalid_function_name"
)
return new AnalysisInputError(operation, { cause: failure }, [
{ path: ["name"], reason: "invalid_value", message: failure.message },
]);
if (failure.kind === "cancelled")
return new AnalysisCancelledError(operation);
if (failure.kind === "timeout" || failure.kind === "analysis_timeout")

View File

@ -23,7 +23,7 @@ const providerContractByName = new Map(
).map((contract) => [contract.name, contract]),
);
/** Provider-neutral read-only contracts implemented by the Ghidra adapter. */
/** Provider-neutral contracts implemented by the Ghidra adapter. */
export const GHIDRA_PROVIDER_TOOL_CONTRACTS = Object.freeze(
[...GHIDRA_INVENTORY_OPERATIONS, ...GHIDRA_FUNCTION_OPERATIONS].map(
(operation) => {
@ -40,7 +40,7 @@ export const GHIDRA_PROVIDER_TOOL_CONTRACTS = Object.freeze(
/** Health limitations shared by every Ghidra-backed capability. */
export const healthLimitations = Object.freeze([
"The session serves operations only after Ghidra reports default auto-analysis complete; incomplete analysis does not expose partial results.",
"The imported Program and temporary project are ephemeral, read-only to REA, and deleted on close.",
"The imported Program and temporary project are ephemeral and deleted on close. Annotation operations edit session database metadata; original executable bytes are never written.",
]);
/** Additional limitations applied to the experimental Windows x64 P0 boundary. */
@ -57,6 +57,12 @@ 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 "annotate_native_function":
return [
...common,
"Names use Ghidra USER_DEFINED source. Name writes preserve the existing namespace; readback uses the fully qualified name. Regular comments map to PRE and inline comments to EOL at the exact function entry. Changes commit together after readback and refreshed analysis; failure rolls them all back.",
"Metadata edits invalidate immutable analysis snapshots and are discarded on close. CLI returns the updated dossier before session cleanup; this is not a saved Ghidra project.",
];
case "inspect_native_load_image":
return [
...common,
@ -161,7 +167,7 @@ export const CAPABILITIES: readonly CapabilityDescriptor[] = Object.freeze(
available: true,
reason: null,
effects: Object.freeze({
mutatesArtifact: false,
mutatesArtifact: operation === "annotate_native_function",
launchesProcess: true,
mayShowUi: false,
mayAccessNetwork: false,
@ -183,6 +189,14 @@ export const windowsP0Capabilities = (): readonly CapabilityDescriptor[] => {
...capability.limitations,
...windowsP0Limitations,
]);
if (capability.effects.mutatesArtifact)
return Object.freeze({
...capability,
available: false,
reason: "Windows Ghidra P0 does not admit database mutation.",
availabilityCode: "unsupported_host",
limitations,
});
return available
? Object.freeze({ ...capability, limitations })
: Object.freeze({

View File

@ -21,7 +21,7 @@ const session = (
run_id: expected.runId,
profile_digest: expected.profileDigest,
provider: { id: "ghidra", version: expected.providerVersion },
read_only: true,
read_only: false,
analysis_complete: true,
analysis_timed_out: false,
capabilities: [...GHIDRA_SESSION_CAPABILITIES],
@ -50,3 +50,46 @@ describe("Ghidra DOS import commitment", () => {
).toBe(false);
});
});
describe("Ghidra mutation handshake", () => {
it("requires the exact mutation authority and rejects duplicate capabilities", () => {
const value = session();
expect(
parseGhidraSessionInfo({ ...value, read_only: true }, expected).ok,
).toBe(false);
expect(
parseGhidraSessionInfo(
{ ...value, capabilities: [...value.capabilities, "ping"] },
expected,
).ok,
).toBe(false);
expect(
parseGhidraSessionInfo(
{
...value,
capabilities: value.capabilities.filter(
(c) => c !== "annotate_native_function",
),
},
expected,
).ok,
).toBe(false);
});
it("excludes database mutation from the Windows transport handshake", () => {
const value = session();
const windows = {
...value,
read_only: true,
capabilities: value.capabilities.filter(
(c) => c !== "annotate_native_function",
),
};
expect(
parseGhidraSessionInfo(windows, { ...expected, expectedReadOnly: true })
.ok,
).toBe(true);
expect(
parseGhidraSessionInfo(value, { ...expected, expectedReadOnly: true }).ok,
).toBe(false);
});
});

View File

@ -26,12 +26,10 @@ const sessionInfoSchema = z
version: z.string().min(1),
})
.strict(),
read_only: z.literal(true),
read_only: z.boolean(),
analysis_complete: z.boolean(),
analysis_timed_out: z.boolean(),
capabilities: z
.array(capabilitySchema)
.length(GHIDRA_SESSION_CAPABILITIES.length),
capabilities: z.array(capabilitySchema),
target: z
.object({
name: z.string().min(1),
@ -56,14 +54,21 @@ export const parseGhidraSessionInfo = (
readonly providerVersion: string;
readonly profileDigest: string;
readonly targetSha256: string;
readonly expectedReadOnly?: boolean;
readonly expectedLanguageId?: string;
readonly expectedCompilerSpecId?: string;
},
): Result<GhidraSessionInfo, Error> => {
const parsed = sessionInfoSchema.safeParse(value);
const readOnly = expected.expectedReadOnly ?? false;
const expectedCapabilities = GHIDRA_SESSION_CAPABILITIES.filter(
(capability) => !readOnly || capability !== "annotate_native_function",
);
if (
!parsed.success ||
parsed.data.run_id !== expected.runId ||
parsed.data.read_only !== readOnly ||
parsed.data.capabilities.length !== expectedCapabilities.length ||
parsed.data.provider.version !== expected.providerVersion ||
parsed.data.profile_digest !== expected.profileDigest ||
parsed.data.target.sha256 !== expected.targetSha256 ||
@ -72,9 +77,8 @@ export const parseGhidraSessionInfo = (
(expected.expectedCompilerSpecId !== undefined &&
parsed.data.target.compiler_spec_id !==
expected.expectedCompilerSpecId) ||
new Set(parsed.data.capabilities).size !==
GHIDRA_SESSION_CAPABILITIES.length ||
GHIDRA_SESSION_CAPABILITIES.some(
new Set(parsed.data.capabilities).size !== expectedCapabilities.length ||
expectedCapabilities.some(
(capability) => !parsed.data.capabilities.includes(capability),
) ||
parsed.data.analysis_complete === parsed.data.analysis_timed_out

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 !== "annotate_native_function" &&
name !== "inspect_native_load_image" &&
name !== "inspect_native_data_type" &&
name !== "inspect_native_instruction" &&

View File

@ -134,6 +134,9 @@ const projectOfficialArguments = (
contract: ToolContract,
input: unknown,
): Readonly<Record<string, JsonValue>> => {
// Annotation omissions preserve existing values; they are not Python defaults.
if (contract.name === "annotate_native_function")
return jsonObjectSchema.parse(contract.inputSchema.parse(input));
const parsed = jsonObjectSchema.parse(input);
if (!(contract.inputSchema instanceof z.ZodObject))
throw new TypeError(

View File

@ -176,6 +176,7 @@ describe("canonical CLI catalog", () => {
"xrefs",
"trace",
"function",
"annotate-native-function",
"inspect-native-api",
"search",
]) {

View File

@ -22,7 +22,8 @@ describe("Ghidra MCP evidence parity", () => {
analysis_profile: {
provider: { id: "ghidra", version: "12.1.4" },
parameters: {
import_mode: "ephemeral-read-only",
import_mode: "ephemeral-source-immutable",
annotation_policy: "atomic-function-entry-metadata-v1",
analyzer_preset: "ghidra-default",
},
},

View File

@ -118,7 +118,7 @@ const sessionInfo = (profileDigest: string, targetSha256: string) => ({
run_id: "11111111-1111-4111-8111-111111111111",
profile_digest: profileDigest,
provider: { id: "ghidra" as const, version: "12.1.4" },
read_only: true as const,
read_only: false as const,
analysis_complete: true,
analysis_timed_out: false,
capabilities: [...GHIDRA_SESSION_CAPABILITIES],

View File

@ -1,3 +1,5 @@
import { functionDossierSchema } from "../../../src/domain/hopperValues.js";
import { ghidraFunctionDossier } from "../../../src/domain/ghidraValues.fixture.js";
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
import { afterEach, describe, expect, it } from "vitest";
@ -12,6 +14,7 @@ import { createServer } from "../../../src/server/createServer.js";
const VALID_INPUTS: Readonly<
Record<string, Readonly<Record<string, JsonValue>>>
> = {
annotate_native_function: { procedure: "0x1000", name: "entry" },
inspect_native_load_image: {},
address_name: {},
inspect_native_instruction: { address: "0x1000" },
@ -128,6 +131,23 @@ const connect = async (analysis: AnalysisOperationPort) => {
const inventory: JsonValue = [];
const outputFor = (name: string): JsonValue => {
if (name === "annotate_native_function") {
const dossier = functionDossierSchema.parse(ghidraFunctionDossier());
return {
annotations: {
address: dossier.procedure.address,
name: dossier.procedure.name,
comment: null,
inline_comment: null,
},
dossier,
effects: {
scope: "session-analysis-database",
source_bytes_modified: false,
persists_after_close: false,
},
};
}
if (name === "inspect_native_load_image")
return {
status: "unsupported",
@ -275,6 +295,10 @@ describe("official Hopper proxy tools", () => {
expect(result.isError).not.toBe(true);
}
expect(
invocations.find(({ name }) => name === "annotate_native_function")
?.arguments_,
).toEqual({ procedure: "0x1000", name: "entry" });
expect(invocations.map(({ name }) => name)).toEqual(
OFFICIAL_TOOL_CONTRACTS.map(({ name }) => name),
);

View File

@ -151,10 +151,14 @@ describe("GhidraClient", () => {
value: {
provider: { id: "ghidra", version: PROVIDER_VERSION },
profile_digest: PROFILE_DIGEST,
read_only: true,
read_only: HOST_TRANSPORT !== "unix-socket",
analysis_complete: true,
analysis_timed_out: false,
capabilities: GHIDRA_SESSION_CAPABILITIES,
capabilities: GHIDRA_SESSION_CAPABILITIES.filter(
(value) =>
HOST_TRANSPORT === "unix-socket" ||
value !== "annotate_native_function",
),
target: {
image_base: "0x1000",
default_address_space: "ram",

View File

@ -190,7 +190,7 @@ const sessionInfo = ({
run_id: sessionRunId,
profile_digest: digest,
provider: { id: "ghidra", version },
read_only: true,
read_only: transport !== "unix-socket",
analysis_complete: !timedOut,
analysis_timed_out: timedOut,
capabilities: [
@ -209,6 +209,7 @@ const sessionInfo = ({
"resolve_containing_procedure",
"search_procedures",
"search_strings",
"annotate_native_function",
"inspect_native_data_type",
"inspect_native_instruction",
"resolve_native_call_targets",
@ -221,7 +222,10 @@ const sessionInfo = ({
"read_function_instructions",
"procedure_references",
"xrefs",
],
].filter(
(value) =>
transport === "unix-socket" || value !== "annotate_native_function",
),
target: {
name: "fixture",
language_id: "x86:LE:64:default",