From 521139d4a57a8ab2e25bb262d2a1fd09fd06be92 Mon Sep 17 00:00:00 2001
From: morluto <76467478+morluto@users.noreply.github.com>
Date: Sat, 25 Jul 2026 14:29:28 +0000
Subject: [PATCH] feat(native): inspect API boundaries with evidence
---
README.md | 2 +-
README_ar.md | 2 +-
README_ja.md | 2 +-
README_ko.md | 2 +-
README_zh.md | 2 +-
bridge/ghidra/ReaGhidraBridge.java | 481 +++++++++++++++++-
docs/product-catalog.json | 16 +-
scripts/verify-real-ghidra-assertions.mjs | 66 ++-
scripts/verify-real-ghidra-function.mjs | 49 +-
scripts/verify-real-ghidra.mjs | 8 +-
src/application/CapabilityInventory.ts | 1 +
src/application/DirectAnalysis.ts | 18 +-
src/application/EnhancedTools.ts | 58 ++-
src/application/NativeApiInspection.ts | 113 ++++
src/cli/coreAnalysisCommands.ts | 50 ++
src/cliCommandNames.ts | 1 +
src/contracts/enhancedInputs.ts | 12 +
.../functionWorkflowToolContracts.ts | 46 ++
src/contracts/toolContractExamples.ts | 1 +
src/contracts/toolContracts.ts | 7 +-
src/contracts/toolEffects.ts | 1 +
src/contracts/toolOutputSchemaGroups.ts | 2 +
src/domain/hopperValues.ts | 6 +-
src/domain/nativeApiBoundary.ts | 153 ++++++
src/ghidra/GhidraDefaults.ts | 2 +-
src/ghidra/GhidraFunctionValues.ts | 38 +-
src/server/registerEnhancedTools.ts | 27 +-
tests/conformance/ghidra/inventory.c | 24 +
tests/enhancedTools.test.ts | 23 +-
tests/fixtures/fakeGhidra.mjs | 2 +-
tests/fixtures/ghidraFunction.ts | 89 ++++
tests/ghidraClient.test.ts | 2 +-
tests/ghidraFunctionValues.test.ts | 64 ++-
tests/ghidraMcp.test.ts | 2 +-
tests/ghidraProvider.test.ts | 2 +-
tests/nativeApiInspection.test.ts | 62 +++
tests/productCatalog.test.ts | 1 +
tests/serverIdentity.test.ts | 6 +-
tests/toolContracts.test.ts | 2 +-
39 files changed, 1382 insertions(+), 63 deletions(-)
create mode 100644 src/application/NativeApiInspection.ts
create mode 100644 src/contracts/functionWorkflowToolContracts.ts
create mode 100644 src/domain/nativeApiBoundary.ts
create mode 100644 tests/nativeApiInspection.test.ts
diff --git a/README.md b/README.md
index c56f778f8..a8db45018 100644
--- a/README.md
+++ b/README.md
@@ -374,7 +374,7 @@ REA handles the app analysis in steps 1–5. The agent performs step 6 with its
| Tool family | Count | Examples |
| ------------------------- | ----: | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Native inspection | 36 | procedures, pseudocode, assembly, strings, names, segments, callers, callees, xrefs, annotations, bounded byte reads, file-offset translation |
-| Investigation workflows | 12 | `binary_overview`, `analyze_function`, `batch_decompile`, `trace_feature`, exact string-to-code lookup, bounded call paths, call graphs, Swift and Objective-C discovery |
+| Investigation workflows | 13 | `binary_overview`, `analyze_function`, `inspect_native_api`, `batch_decompile`, `trace_feature`, exact string-to-code lookup, bounded call paths, call graphs, Swift and Objective-C discovery |
| Native macOS utilities | 5 | Mach-O metadata, code signatures, plists, architectures, Swift demangling; Hopper-free and provenance-bearing |
| Artifact graph | 3 | bounded provider-neutral inspection, deterministic directory/ZIP/APK/IPA/MSIX/AppX/ASAR inventory, and explicitly selected extraction into an absent owned tree |
| Managed PE/CLI | 8 | PE/CLI identity, metadata members, CIL hashes, P/Invoke/native-boundary declarations and verification, application-graph projection, decompiler reconstruction import, token remapping, runtime-correlation plans, and version comparison |
diff --git a/README_ar.md b/README_ar.md
index ddd44dbf3..8bd84b621 100644
--- a/README_ar.md
+++ b/README_ar.md
@@ -162,7 +162,7 @@ rea uninstall --purge-data
| عائلة الأدوات | العدد | أمثلة |
| --------------------- | ----: | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| فحص الملفات التنفيذية | 36 | الدوال، والشيفرة شبه المصدرية، والتعليمات، والسلاسل، والأسماء، والمراجع، والتعليقات |
-| التحليل المركب | 12 | النظرة العامة، وفك الترجمة الدفعي، ومخططات الاستدعاء، والمراجع، واكتشاف الأنواع |
+| التحليل المركب | 13 | النظرة العامة، و`inspect_native_api`، وفك الترجمة الدفعي، ومخططات الاستدعاء، والمراجع، واكتشاف الأنواع |
| أدوات macOS الأصلية | 5 | بيانات Mach-O والتوقيعات وملفات plist والمعماريات وفك رموز Swift دون تشغيل Hopper |
| رسم القطع الأثرية | 3 | جرد حتمي للدلائل وZIP/APK/IPA وASAR واستخراج معاملاتي محدد صراحة |
| Managed PE/CLI | 8 | هوية PE/CLI، أعضاء البيانات الوصفية، بصمات CIL، تصريحات P/Invoke/الحدود الأصلية والتحقق منها، إسقاط رسم التطبيق، استيراد إعادة البناء من فك الترجمة، إعادة ربط الرموز، خطط ترابط وقت التشغيل ومقارنة الإصدارات |
diff --git a/README_ja.md b/README_ja.md
index eb57b6b63..7daa68857 100644
--- a/README_ja.md
+++ b/README_ja.md
@@ -152,7 +152,7 @@ REA は手順 1〜5 のバイナリ解析を処理し、手順 6 はエージェ
| ツール群 | 数 | 例 |
| ---------------------- | --: | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| バイナリ調査 | 36 | プロシージャ、疑似コード、アセンブリ、文字列、名前、セグメント、callers、callees、xrefs、注釈 |
-| 合成解析 | 12 | `binary_overview`, `analyze_function`, `batch_decompile`, `get_call_graph`, `find_xrefs_to_name`, Swift / ObjC 検出 |
+| 合成解析 | 13 | `binary_overview`, `analyze_function`, `inspect_native_api`, `batch_decompile`, `get_call_graph`, `find_xrefs_to_name`, Swift / ObjC 検出 |
| macOS ネイティブ | 5 | Mach-O メタデータ、署名、plist、アーキテクチャ、Swift デマングル。Hopper 起動不要 |
| アーティファクトグラフ | 3 | ディレクトリ、ZIP/APK/IPA、ASAR の決定的一覧と明示選択されたトランザクション抽出 |
| Managed PE/CLI | 8 | PE/CLI 識別、メタデータメンバー、CIL ハッシュ、P/Invoke/ネイティブ境界宣言と検証、アプリケーショングラフ投影、逆コンパイル再構成インポート、構造的 token 再マッピング、ランタイム相関計画、バージョン比較 |
diff --git a/README_ko.md b/README_ko.md
index cfef7b4fe..d3766ec10 100644
--- a/README_ko.md
+++ b/README_ko.md
@@ -152,7 +152,7 @@ REA는 1–5단계의 바이너리 분석을 처리합니다. 6단계는 에이
| 도구 그룹 | 수 | 예시 |
| ------------------- | --: | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 바이너리 검사 | 36 | 프로시저, 의사 코드, 어셈블리, 문자열, 이름, 세그먼트, callers, callees, xrefs, 주석 |
-| 합성 분석 | 12 | `binary_overview`, `analyze_function`, `batch_decompile`, `get_call_graph`, `find_xrefs_to_name`, Swift 및 ObjC 탐색 |
+| 합성 분석 | 13 | `binary_overview`, `analyze_function`, `inspect_native_api`, `batch_decompile`, `get_call_graph`, `find_xrefs_to_name`, Swift 및 ObjC 탐색 |
| macOS 네이티브 도구 | 5 | Mach-O 메타데이터, 코드 서명, plist, 아키텍처, Swift 디맹글링. Hopper 실행 불필요 |
| 아티팩트 그래프 | 3 | 디렉터리, ZIP/APK/IPA, ASAR 결정적 인벤토리와 명시적으로 선택한 트랜잭션 추출 |
| Managed PE/CLI | 8 | PE/CLI ID, 메타데이터 멤버, CIL 해시, P/Invoke/네이티브 경계 선언 및 검증, 애플리케이션 그래프 투영, 디컴파일 재구성 가져오기, 구조적 token 재매핑, 런타임 상관 계획, 버전 비교 |
diff --git a/README_zh.md b/README_zh.md
index 19d263917..349301c6b 100644
--- a/README_zh.md
+++ b/README_zh.md
@@ -152,7 +152,7 @@ REA 负责第 1–5 步中的二进制分析。第 6 步由智能体使用其常
| 工具类别 | 数量 | 示例 |
| ----------------- | ---: | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 二进制检查 | 36 | 过程、伪代码、汇编、字符串、名称、段、调用者、被调用者、交叉引用、注释 |
-| 组合分析 | 12 | `binary_overview`, `analyze_function`, `batch_decompile`, `get_call_graph`, `find_xrefs_to_name`、Swift 与 ObjC 发现 |
+| 组合分析 | 13 | `binary_overview`, `analyze_function`, `inspect_native_api`, `batch_decompile`, `get_call_graph`, `find_xrefs_to_name`、Swift 与 ObjC 发现 |
| macOS 原生工具 | 5 | Mach-O 元数据、代码签名、plist、架构与 Swift 符号还原,无需启动 Hopper |
| 制品图谱 | 3 | 确定性目录、ZIP/APK/IPA 与 ASAR 清单,以及显式选择的事务式提取 |
| Managed PE/CLI | 8 | PE/CLI 身份、元数据成员、CIL 哈希、P/Invoke/原生边界声明与验证、应用图谱投影、反编译重建导入、结构化 token 重映射、运行时关联计划与跨版本比较 |
diff --git a/bridge/ghidra/ReaGhidraBridge.java b/bridge/ghidra/ReaGhidraBridge.java
index 285750182..fe9a2c773 100644
--- a/bridge/ghidra/ReaGhidraBridge.java
+++ b/bridge/ghidra/ReaGhidraBridge.java
@@ -55,6 +55,7 @@ import ghidra.program.model.block.CodeBlockIterator;
import ghidra.program.model.block.CodeBlockReference;
import ghidra.program.model.block.CodeBlockReferenceIterator;
import ghidra.program.model.data.StringDataInstance;
+import ghidra.program.model.data.DataType;
import ghidra.program.model.listing.CommentType;
import ghidra.program.model.listing.Data;
import ghidra.program.model.listing.DataIterator;
@@ -70,15 +71,25 @@ import ghidra.program.model.symbol.ReferenceIterator;
import ghidra.program.model.symbol.SourceType;
import ghidra.program.model.symbol.Symbol;
import ghidra.program.model.symbol.SymbolIterator;
+import ghidra.program.model.pcode.FunctionPrototype;
+import ghidra.program.model.pcode.HighFunction;
+import ghidra.program.model.pcode.HighSymbol;
+import ghidra.program.model.pcode.JumpTable;
public final class ReaGhidraBridge extends HeadlessScript {
- private static final int BRIDGE_VERSION = 5;
+ private static final int BRIDGE_VERSION = 6;
private static final int MAX_DESCRIPTOR_BYTES = 16 * 1024;
private static final int MAX_REQUEST_CHARACTERS = 256 * 1024;
private static final int MAX_RESPONSE_BYTES = 1024 * 1024;
+ private static final int RESPONSE_ENVELOPE_RESERVE_BYTES = 32 * 1024;
+ private static final int MAX_NATIVE_API_RESPONSE_BYTES = 192 * 1024;
private static final int MAX_INVENTORY_ITEMS = 1_000_000;
private static final int MAX_FUNCTION_ITEMS = 100_000;
private static final int MAX_FUNCTION_INSTRUCTIONS = 100_000;
+ private static final int MAX_NATIVE_API_PARAMETERS = 64;
+ private static final int MAX_NATIVE_API_JUMP_TABLES = 4;
+ private static final int MAX_NATIVE_API_LOAD_TABLES = 4;
+ private static final int MAX_NATIVE_API_MAPPINGS = 32;
private static final int DECOMPILE_TIMEOUT_SECONDS = 30;
private static final int DECOMPILE_PAYLOAD_MBYTES = 8;
private static final int MAX_LIST_VALUE_CODE_POINTS = 1_024;
@@ -377,7 +388,9 @@ public final class ReaGhidraBridge extends HeadlessScript {
private JsonElement procedurePseudocode(JsonObject params) throws Exception {
requireKeys(params, Set.of("document", "procedure"));
requireDocument(params);
- String value = decompile(resolveProcedure(requireString(params, "procedure")));
+ String value = decompiledText(
+ decompile(resolveProcedure(requireString(params, "procedure")))
+ );
return value == null ? JsonNull.INSTANCE : GSON.toJsonTree(value);
}
@@ -576,7 +589,8 @@ public final class ReaGhidraBridge extends HeadlessScript {
);
JsonObject offsets = requireCollectionOffsets(params);
InstructionScan scan = scanInstructions(function, maximumInstructions);
- String pseudocode = decompile(function);
+ DecompileResults decompilation = decompile(function);
+ String pseudocode = decompiledText(decompilation);
if (pseudocode == null) {
pseudocode = "";
}
@@ -662,6 +676,19 @@ public final class ReaGhidraBridge extends HeadlessScript {
instructionScan.addProperty("scanned", scan.instructions.size());
instructionScan.addProperty("truncated", scan.truncated);
result.add("instruction_scan", instructionScan);
+ int nativeApiBudget = Math.min(
+ MAX_NATIVE_API_RESPONSE_BYTES,
+ Math.max(
+ 0,
+ MAX_RESPONSE_BYTES -
+ encodedUtf8Length(result) -
+ RESPONSE_ENVELOPE_RESERVE_BYTES
+ )
+ );
+ result.add(
+ "native_api",
+ nativeApiBoundary(function, decompilation, nativeApiBudget)
+ );
JsonArray limitations = new JsonArray();
limitations.add(
"Unresolved computed or indirect flows without target addresses are not represented as reference edges."
@@ -971,7 +998,446 @@ public final class ReaGhidraBridge extends HeadlessScript {
return matches.get(0);
}
- private String decompile(Function function) {
+ private JsonObject nativeApiBoundary(
+ Function function,
+ DecompileResults decompilation,
+ int maximumBytes
+ ) throws Exception {
+ if (decompilation == null || decompilation.getHighFunction() == null) {
+ return unavailableNativeApiBoundary(
+ "Ghidra has no HighFunction model for an external or bodyless procedure"
+ );
+ }
+
+ HighFunction highFunction = decompilation.getHighFunction();
+ FunctionPrototype prototype = highFunction.getFunctionPrototype();
+ String signatureSource = function
+ .getSignatureSource()
+ .name()
+ .toLowerCase(Locale.ROOT);
+ JsonObject result = new JsonObject();
+ result.addProperty("available", true);
+ result.addProperty("provenance", "ghidra-high-function");
+ result.addProperty("signature_source", signatureSource);
+ String callingConvention = prototype.getModelName();
+ result.addProperty(
+ "calling_convention",
+ callingConvention == null || callingConvention.isBlank()
+ ? "unknown"
+ : callingConvention
+ );
+ result.add(
+ "return_type",
+ inferredBoundaryType(
+ "return",
+ null,
+ null,
+ prototype.getReturnType(),
+ prototype.getReturnStorage().toString(),
+ false,
+ signatureSource
+ )
+ );
+
+ JsonArray parameters = new JsonArray();
+ result.add("parameters", parameters);
+ result.addProperty("parameters_truncated", false);
+
+ JsonArray jumpTables = new JsonArray();
+ result.add("jump_tables", jumpTables);
+ result.addProperty("jump_tables_truncated", false);
+
+ JsonObject pseudocode = new JsonObject();
+ pseudocode.addProperty("classification", "decompiler-generated-non-source");
+ pseudocode.addProperty("compilable", false);
+ result.add("pseudocode", pseudocode);
+
+ JsonArray artifacts = new JsonArray();
+ artifacts.add("recovered-signature");
+ artifacts.add("register-and-stack-variables");
+ artifacts.add("compiler-generated-control-flow");
+ artifacts.add("pointer-arithmetic");
+ artifacts.add("pseudocode");
+ result.add("decompiler_artifacts", artifacts);
+
+ JsonArray limitations = new JsonArray();
+ limitations.add(
+ "Types and calling conventions are Ghidra decompiler observations, not source declarations."
+ );
+ limitations.add(
+ "Register variables, compiler-generated blocks, and pointer arithmetic remain decompiler artifacts."
+ );
+ limitations.add(
+ "Pseudocode is neither original source nor guaranteed to compile."
+ );
+ result.add("limitations", limitations);
+ if (encodedUtf8Length(result) > maximumBytes) {
+ return unavailableNativeApiBoundary(
+ "The remaining Ghidra response budget cannot contain a structured native API boundary"
+ );
+ }
+
+ int parameterCount = Math.min(
+ prototype.getNumParams(),
+ MAX_NATIVE_API_PARAMETERS
+ );
+ for (int index = 0; index < parameterCount; index += 1) {
+ HighSymbol parameter = prototype.getParam(index);
+ if (parameter == null) {
+ continue;
+ }
+ parameters.add(
+ inferredBoundaryType(
+ "parameter",
+ index,
+ parameter.getName(),
+ parameter.getDataType(),
+ parameter.getStorage().toString(),
+ parameter.isTypeLocked(),
+ signatureSource
+ )
+ );
+ if (encodedUtf8Length(result) > maximumBytes) {
+ parameters.remove(parameters.size() - 1);
+ break;
+ }
+ }
+ result.addProperty(
+ "parameters_truncated",
+ prototype.getNumParams() > parameters.size()
+ );
+
+ JumpTable[] recoveredJumpTables = highFunction.getJumpTables();
+ int jumpTableCount = Math.min(
+ recoveredJumpTables.length,
+ MAX_NATIVE_API_JUMP_TABLES
+ );
+ for (int index = 0; index < jumpTableCount; index += 1) {
+ jumpTables.add(inferredJumpTable(recoveredJumpTables[index]));
+ if (encodedUtf8Length(result) > maximumBytes) {
+ jumpTables.remove(jumpTables.size() - 1);
+ break;
+ }
+ }
+ result.addProperty(
+ "jump_tables_truncated",
+ recoveredJumpTables.length > jumpTables.size()
+ );
+ return result;
+ }
+
+ private static JsonObject unavailableNativeApiBoundary(String reason) {
+ JsonObject unavailable = new JsonObject();
+ unavailable.addProperty("available", false);
+ unavailable.addProperty("reason", reason);
+ JsonArray unknowns = new JsonArray();
+ unknowns.add("Return and parameter boundary types are unavailable.");
+ unknowns.add("Jump-table mappings are unavailable.");
+ unavailable.add("residual_unknowns", unknowns);
+ return unavailable;
+ }
+
+ private static int encodedUtf8Length(JsonElement value) {
+ return GSON.toJson(value).getBytes(StandardCharsets.UTF_8).length;
+ }
+
+ private static JsonObject inferredBoundaryType(
+ String role,
+ Integer ordinal,
+ String name,
+ DataType dataType,
+ String storage,
+ boolean typeLocked,
+ String signatureSource
+ ) {
+ JsonObject result = new JsonObject();
+ result.addProperty("role", role);
+ if (ordinal == null) {
+ result.add("ordinal", JsonNull.INSTANCE);
+ }
+ else {
+ result.addProperty("ordinal", ordinal);
+ }
+ if (name == null || name.isBlank()) {
+ result.add("name", JsonNull.INSTANCE);
+ }
+ else {
+ result.addProperty("name", name);
+ }
+ result.addProperty("data_type", dataType.getDisplayName());
+ int dataTypeLength = dataType.getLength();
+ if (dataTypeLength < 0) {
+ result.add("size_bytes", JsonNull.INSTANCE);
+ }
+ else {
+ result.addProperty("size_bytes", dataTypeLength);
+ }
+ if (storage.isBlank()) {
+ result.add("storage", JsonNull.INSTANCE);
+ }
+ else {
+ result.addProperty("storage", storage);
+ }
+ result.addProperty(
+ "confidence",
+ inferenceConfidence(signatureSource, typeLocked)
+ );
+
+ JsonArray evidence = new JsonArray();
+ evidence.add(
+ inferenceEvidence(
+ "signature-source",
+ "ghidra-function-manager",
+ "Function signature source is " + signatureSource + "."
+ )
+ );
+ evidence.add(
+ inferenceEvidence(
+ "decompiler-type",
+ "ghidra-high-function",
+ "HighFunction recovered data type " + dataType.getDisplayName() + "."
+ )
+ );
+ if (typeLocked) {
+ evidence.add(
+ inferenceEvidence(
+ "type-lock",
+ "ghidra-high-symbol",
+ "The decompiler marks this parameter type as locked."
+ )
+ );
+ }
+ result.add("evidence", evidence);
+
+ JsonArray artifacts = new JsonArray();
+ artifacts.add("recovered-signature");
+ if (!storage.isBlank()) {
+ artifacts.add("register-or-stack-storage");
+ }
+ if (name != null && (name.startsWith("param_") || name.startsWith("unaff_"))) {
+ artifacts.add("compiler-generated-variable");
+ }
+ result.add("decompiler_artifacts", artifacts);
+ return result;
+ }
+
+ private JsonObject inferredJumpTable(JumpTable jumpTable) throws Exception {
+ JsonObject result = new JsonObject();
+ result.addProperty(
+ "dispatch_address",
+ canonicalAddress(jumpTable.getSwitchAddress())
+ );
+ JumpTable.LoadTable[] loadTables = jumpTable.getLoadTables();
+ List
referencedDataAddresses = loadTables.length == 0
+ ? dispatchPathDataReferences(jumpTable)
+ : List.of();
+ JsonArray dataSources = new JsonArray();
+ int loadTableCount = Math.min(
+ loadTables.length,
+ MAX_NATIVE_API_LOAD_TABLES
+ );
+ for (int index = 0; index < loadTableCount; index += 1) {
+ JumpTable.LoadTable loadTable = loadTables[index];
+ JsonObject source = new JsonObject();
+ source.addProperty("address", canonicalAddress(loadTable.getAddress()));
+ source.addProperty("provenance", "ghidra-decompiler-load-table");
+ source.addProperty("entry_size_bytes", loadTable.getSize());
+ source.addProperty("entry_count", loadTable.getNum());
+ source.addProperty("confidence", "high");
+ JsonArray evidence = new JsonArray();
+ evidence.add(
+ inferenceEvidence(
+ "jump-table",
+ "ghidra-high-function",
+ "Decompiler load table starts at " +
+ canonicalAddress(loadTable.getAddress()) +
+ "."
+ )
+ );
+ source.add("evidence", evidence);
+ dataSources.add(source);
+ }
+ int referencedDataCount = Math.min(
+ referencedDataAddresses.size(),
+ MAX_NATIVE_API_LOAD_TABLES - loadTableCount
+ );
+ for (int index = 0; index < referencedDataCount; index += 1) {
+ Address address = referencedDataAddresses.get(index);
+ JsonObject source = new JsonObject();
+ source.addProperty("address", canonicalAddress(address));
+ source.addProperty(
+ "provenance",
+ "ghidra-dispatch-block-data-reference"
+ );
+ source.add("entry_size_bytes", JsonNull.INSTANCE);
+ source.add("entry_count", JsonNull.INSTANCE);
+ source.addProperty("confidence", "medium");
+ JsonArray evidence = new JsonArray();
+ evidence.add(
+ inferenceEvidence(
+ "jump-table",
+ "ghidra-reference-manager",
+ "The dispatch block or an immediate predecessor contains a typed data reference to " +
+ canonicalAddress(address) +
+ "."
+ )
+ );
+ source.add("evidence", evidence);
+ dataSources.add(source);
+ }
+ result.add("data_sources", dataSources);
+ result.addProperty(
+ "data_sources_truncated",
+ loadTables.length > loadTableCount ||
+ referencedDataAddresses.size() > referencedDataCount
+ );
+
+ Address[] targets = jumpTable.getCases();
+ Integer[] labels = jumpTable.getLabelValues();
+ JsonArray mappings = new JsonArray();
+ int mappingCount = Math.min(targets.length, MAX_NATIVE_API_MAPPINGS);
+ for (int index = 0; index < mappingCount; index += 1) {
+ JsonObject mapping = new JsonObject();
+ if (labels == null || index >= labels.length || labels[index] == null) {
+ mapping.add("case_value", JsonNull.INSTANCE);
+ }
+ else {
+ mapping.addProperty("case_value", labels[index]);
+ }
+ String targetAddress = canonicalAddress(targets[index]);
+ mapping.addProperty("target_address", targetAddress);
+ JsonArray dataAddresses = new JsonArray();
+ for (int sourceIndex = 0; sourceIndex < loadTableCount; sourceIndex += 1) {
+ dataAddresses.add(
+ canonicalAddress(loadTables[sourceIndex].getAddress())
+ );
+ }
+ mapping.add("data_addresses", dataAddresses);
+ mapping.addProperty("confidence", "medium");
+ JsonArray evidence = new JsonArray();
+ evidence.add(
+ inferenceEvidence(
+ "jump-table",
+ "ghidra-high-function",
+ "Recovered target " +
+ targetAddress +
+ " from dispatch " +
+ canonicalAddress(jumpTable.getSwitchAddress()) +
+ "."
+ )
+ );
+ mapping.add("evidence", evidence);
+ mappings.add(mapping);
+ }
+ result.add("mappings", mappings);
+ result.addProperty("mappings_truncated", targets.length > mappingCount);
+
+ JsonArray limitations = new JsonArray();
+ if (loadTables.length == 0 && referencedDataAddresses.isEmpty()) {
+ limitations.add(
+ "Ghidra recovered targets but exposed no backing load-table or dispatch-block data address."
+ );
+ }
+ if (loadTables.length == 0 && !referencedDataAddresses.isEmpty()) {
+ limitations.add(
+ "Load-table metadata was absent; table-level candidate ownership is inferred from typed data references in the dispatch block and its immediate control-flow predecessors, but candidates are not assigned to individual mappings."
+ );
+ }
+ if (labels == null || labels.length != targets.length) {
+ limitations.add(
+ "Case labels were unavailable or incomplete; null labels are preserved."
+ );
+ }
+ if (
+ loadTables.length > loadTableCount ||
+ referencedDataAddresses.size() > referencedDataCount ||
+ targets.length > mappingCount
+ ) {
+ limitations.add(
+ "Native API jump-table observations reached REA's bounded output limits."
+ );
+ }
+ result.add("limitations", limitations);
+ return result;
+ }
+
+ private List dispatchPathDataReferences(JumpTable jumpTable)
+ throws Exception {
+ BasicBlockModel model = new BasicBlockModel(currentProgram, false);
+ CodeBlock dispatchBlock = model.getFirstCodeBlockContaining(
+ jumpTable.getSwitchAddress(),
+ monitor
+ );
+ if (dispatchBlock == null) {
+ return List.of();
+ }
+ List blocks = new ArrayList<>();
+ blocks.add(dispatchBlock);
+ CodeBlockReferenceIterator sources = dispatchBlock.getSources(monitor);
+ while (sources.hasNext()) {
+ monitor.checkCancelled();
+ CodeBlock sourceBlock = sources.next().getSourceBlock();
+ if (sourceBlock != null) {
+ blocks.add(sourceBlock);
+ }
+ }
+ Set addresses = new HashSet<>();
+ for (CodeBlock block : blocks) {
+ InstructionIterator instructions = currentProgram
+ .getListing()
+ .getInstructions(block, true);
+ while (instructions.hasNext()) {
+ monitor.checkCancelled();
+ Instruction instruction = instructions.next();
+ for (
+ Reference reference : currentProgram
+ .getReferenceManager()
+ .getReferencesFrom(instruction.getAddress())
+ ) {
+ if (
+ reference.getReferenceType().isData() &&
+ currentProgram
+ .getMemory()
+ .getLoadedAndInitializedAddressSet()
+ .contains(reference.getToAddress())
+ ) {
+ addresses.add(reference.getToAddress());
+ }
+ }
+ }
+ }
+ List ordered = new ArrayList<>(addresses);
+ ordered.sort(Address::compareTo);
+ return List.copyOf(ordered);
+ }
+
+ private static JsonObject inferenceEvidence(
+ String kind,
+ String source,
+ String detail
+ ) {
+ JsonObject evidence = new JsonObject();
+ evidence.addProperty("kind", kind);
+ evidence.addProperty("source", source);
+ evidence.addProperty("detail", detail);
+ return evidence;
+ }
+
+ private static String inferenceConfidence(
+ String signatureSource,
+ boolean typeLocked
+ ) {
+ if (
+ typeLocked ||
+ signatureSource.equals("user_defined") ||
+ signatureSource.equals("imported")
+ ) {
+ return "high";
+ }
+ return signatureSource.equals("analysis") ? "medium" : "low";
+ }
+
+ private DecompileResults decompile(Function function) {
if (function.isExternal() || function.getBody().isEmpty()) {
return null;
}
@@ -995,6 +1461,13 @@ public final class ReaGhidraBridge extends HeadlessScript {
"Ghidra decompilation failed: " + boundedMessage(results.getErrorMessage())
);
}
+ return results;
+ }
+
+ private static String decompiledText(DecompileResults results) {
+ if (results == null) {
+ return null;
+ }
DecompiledFunction value = results.getDecompiledFunction();
return value == null ? null : value.getC();
}
diff --git a/docs/product-catalog.json b/docs/product-catalog.json
index 0c7ce0dde..5838b0fb2 100644
--- a/docs/product-catalog.json
+++ b/docs/product-catalog.json
@@ -11,7 +11,7 @@
"skill_version": "23"
},
"tools": {
- "total": 110,
+ "total": 111,
"families": [
{
"id": "direct",
@@ -59,7 +59,7 @@
{
"id": "enhanced",
"surface": "enhanced",
- "count": 12,
+ "count": 13,
"tools": [
"analyze_function",
"analyze_swift_types",
@@ -70,6 +70,7 @@
"get_call_graph",
"get_objc_classes",
"get_objc_protocols",
+ "inspect_native_api",
"swift_classes",
"trace_call_path",
"trace_feature"
@@ -581,7 +582,7 @@
}
],
"cli": {
- "primary_count": 66,
+ "primary_count": 67,
"commands": [
"analyze",
"analyze-javascript-application",
@@ -619,6 +620,7 @@
"inspect-managed-artifact",
"inspect-managed-members",
"inspect-managed-native-boundaries",
+ "inspect-native-api",
"inspect-plist",
"inspect-signature",
"inspect-web-page",
@@ -659,17 +661,17 @@
},
"runtime_catalog": {
"counts": {
- "cli_commands": 66,
- "mcp_tools": 110,
+ "cli_commands": 67,
+ "mcp_tools": 111,
"mcp_prompts": 6,
"mcp_resources": 2,
"mcp_resource_templates": 11
},
"digests": {
- "tools_sha256": "9a033ddd40fcb8273365cc895bbf75d943bf1c5c8c2ee1eb9c4b3494064aaf58",
+ "tools_sha256": "b1d46ad60ccd8f81a1ecc3c7572ed90f2206270df8d78577234ecd719d706695",
"prompts_sha256": "f6da3ad38a1000c11364d1710f122ac4990551f04efd8c53ffb2e819b5cf5707",
"resources_sha256": "339a984ea8cd8be5f234adf24395bf0295258d990492005cf6fe1f293d12cd46",
- "combined_sha256": "e8cac51f2f17a5b9a088c3ff773473c4897823401481fc0875a28fe7daf8eee0"
+ "combined_sha256": "1e8d0d5d87d32256eb8b49b986ff43fa87626b1f357ce643239f56a99d8289b4"
}
}
}
diff --git a/scripts/verify-real-ghidra-assertions.mjs b/scripts/verify-real-ghidra-assertions.mjs
index 0805bcdf9..3c6fd0cd7 100644
--- a/scripts/verify-real-ghidra-assertions.mjs
+++ b/scripts/verify-real-ghidra-assertions.mjs
@@ -55,9 +55,11 @@ export function assertDossier(
expectedString = null,
requireAssembly = false,
requireMultiBlock = false,
+ requireJumpTable = false,
},
) {
assertDossierProcedure(dossier, address);
+ assertNativeApiBoundary(dossier.native_api, requireJumpTable);
assertDossierReferences(dossier);
assertDossierCallees(dossier, expectedCallees);
assertDossierStrings(dossier, expectedString);
@@ -65,6 +67,55 @@ export function assertDossier(
assertDossierMultiBlock(dossier, requireMultiBlock);
}
+function assertNativeApiBoundary(observed, requireJumpTable) {
+ if (
+ observed?.available !== true ||
+ observed.provenance !== "ghidra-high-function" ||
+ observed.parameters_truncated !== false ||
+ observed.jump_tables_truncated !== false ||
+ !["low", "medium", "high"].includes(observed.return_type?.confidence) ||
+ !observed.return_type?.evidence?.some(
+ ({ source }) => source === "ghidra-high-function",
+ ) ||
+ observed.pseudocode?.classification !== "decompiler-generated-non-source" ||
+ observed.pseudocode.compilable !== false ||
+ !observed.decompiler_artifacts?.includes("pointer-arithmetic")
+ )
+ throw new Error(
+ `Ghidra native API boundary drifted: ${JSON.stringify(observed)}`,
+ );
+ if (!requireJumpTable) return;
+ if (
+ observed.jump_tables.length === 0 ||
+ !observed.jump_tables.some(
+ ({
+ data_sources: sources,
+ data_sources_truncated: sourcesTruncated,
+ mappings,
+ mappings_truncated: mappingsTruncated,
+ }) =>
+ !sourcesTruncated &&
+ !mappingsTruncated &&
+ sources.length > 0 &&
+ sources.every(
+ ({ address, evidence }) =>
+ /^0x[0-9a-f]+$/u.test(address) &&
+ evidence.some(({ kind }) => kind === "jump-table"),
+ ) &&
+ mappings.length > 0 &&
+ mappings.every(
+ ({ target_address: target, data_addresses: addresses, evidence }) =>
+ /^0x[0-9a-f]+$/u.test(target) &&
+ addresses.every((address) => /^0x[0-9a-f]+$/u.test(address)) &&
+ evidence.some(({ kind }) => kind === "jump-table"),
+ ),
+ )
+ )
+ throw new Error(
+ `Ghidra jump-table evidence drifted: ${JSON.stringify(observed.jump_tables)}`,
+ );
+}
+
function assertDossierProcedure(dossier, address) {
if (
dossier.procedure.address !== address ||
@@ -144,6 +195,14 @@ export function dossierSummary(dossier) {
outgoing_references: dossier.outgoing_references.returned,
referenced_strings: dossier.referenced_strings.returned,
basic_blocks: dossier.basic_blocks.returned,
+ native_parameters:
+ dossier.native_api.available === true
+ ? dossier.native_api.parameters.length
+ : null,
+ jump_tables:
+ dossier.native_api.available === true
+ ? dossier.native_api.jump_tables.length
+ : null,
};
}
@@ -189,12 +248,15 @@ export async function assertCleanup(coordinates) {
}
export function assertDebugFixture(observed) {
+ const expectedDocument = `target-${observed.target.sha256.slice(0, 12)}.bin`;
if (
!Array.isArray(observed.documents) ||
observed.documents.length !== 1 ||
- !observed.documents[0].includes("debug")
+ observed.documents[0] !== expectedDocument
)
- throw new Error("Ghidra did not expose exactly one debug Program identity");
+ throw new Error(
+ `Ghidra did not expose the digest-bound debug Program identity ${expectedDocument}: ${JSON.stringify(observed.documents)}`,
+ );
const entry = findValue(observed.procedures, "rea_ghidra_inventory_entry");
const leaf = findValue(observed.procedures, "rea_ghidra_inventory_leaf");
const external = observed.procedures.find(
diff --git a/scripts/verify-real-ghidra-function.mjs b/scripts/verify-real-ghidra-function.mjs
index 4572e6c43..cb719e97e 100644
--- a/scripts/verify-real-ghidra-function.mjs
+++ b/scripts/verify-real-ghidra-function.mjs
@@ -2,6 +2,10 @@ import {
parseGhidraFunctionInput,
parseGhidraFunctionResult,
} from "../dist/ghidra/GhidraFunctionValues.js";
+import {
+ parseGhidraInventoryInput,
+ parseGhidraInventoryResult,
+} from "../dist/ghidra/GhidraInventoryValues.js";
import {
assertDossier,
@@ -27,6 +31,20 @@ async function functionCall(client, operation, parameters) {
return parsed.value;
}
+async function inventoryCall(client, operation, parameters) {
+ const input = parseGhidraInventoryInput(operation, parameters);
+ if (!input.ok) throw input.error;
+ const called = await client.callTool(operation, input.value);
+ if (!called.ok)
+ throw new Error(
+ `Ghidra ${operation} failed for ${JSON.stringify(input.value)}: ${called.error.message}`,
+ { cause: called.error },
+ );
+ const parsed = parseGhidraInventoryResult(operation, called.value);
+ if (!parsed.ok) throw parsed.error;
+ return parsed.value;
+}
+
export async function verifyDebugFunctionOperations(
client,
{ procedures, strings, entry },
@@ -37,6 +55,10 @@ export async function verifyDebugFunctionOperations(
procedures,
"rea_ghidra_inventory_indirect",
);
+ const switchProcedure = requireProcedure(
+ procedures,
+ "rea_ghidra_inventory_switch",
+ );
const main = requireProcedure(procedures, "main");
const entryString = strings.find(
(item) => item.value === "REA_GHIDRA_INVENTORY_ENTRY",
@@ -88,6 +110,7 @@ export async function verifyDebugFunctionOperations(
);
const branchDossier = await analyzeBranchDossier(client, branch);
const indirectDossier = await analyzeIndirectDossier(client, indirect);
+ const switchDossier = await analyzeSwitchDossier(client, switchProcedure);
assertIndirectDossier(indirectDossier, leaf.address);
const { cancellation, timeout } = await verifyRequestCancellationAndTimeout(
@@ -100,6 +123,7 @@ export async function verifyDebugFunctionOperations(
entry: dossierSummary(entryDossier),
branch: dossierSummary(branchDossier),
indirect: dossierSummary(indirectDossier),
+ switch: dossierSummary(switchDossier),
instruction_window: {
returned: instructionWindow.instructions.returned,
truncated: instructionWindow.instructions.truncated,
@@ -164,7 +188,7 @@ async function resolveXrefOwners(client, entryXrefs) {
for (const address of entryXrefs) {
try {
xrefOwners.push(
- await functionCall(client, "resolve_containing_procedure", {
+ await inventoryCall(client, "resolve_containing_procedure", {
document: null,
address,
}),
@@ -222,6 +246,23 @@ async function analyzeEntryDossier(client, entry, branch, indirect) {
return entryDossier;
}
+async function analyzeSwitchDossier(client, switchProcedure) {
+ const dossier = await functionCall(client, "analyze_function", {
+ procedure: switchProcedure.address,
+ include_assembly: true,
+ limit: 500,
+ max_pseudocode_chars: 100_000,
+ max_instructions: 5_000,
+ });
+ assertDossier(dossier, {
+ address: switchProcedure.address,
+ requireAssembly: true,
+ requireMultiBlock: true,
+ requireJumpTable: true,
+ });
+ return dossier;
+}
+
async function analyzeBranchDossier(client, branch) {
const branchDossier = await functionCall(client, "analyze_function", {
procedure: branch.address,
@@ -310,7 +351,7 @@ export async function verifyStrippedFunctionOperations(client, entryString) {
});
if (stringXrefs.length === 0)
throw new Error("Ghidra stripped string xref probe is unavailable");
- const containing = await functionCall(
+ const containing = await inventoryCall(
client,
"resolve_containing_procedure",
{
@@ -339,7 +380,7 @@ export async function verifyStrippedFunctionOperations(client, entryString) {
expectedString: "REA_GHIDRA_INVENTORY_ENTRY",
requireAssembly: true,
});
- if (symbolTail(dossier.procedure.name) === "rea_ghidra_inventory_entry")
+ if (matchesSymbol(dossier.procedure.name, "rea_ghidra_inventory_entry"))
throw new Error("Ghidra stripped dossier invented a source symbol");
return dossierSummary(dossier);
}
@@ -424,7 +465,7 @@ export async function verifyCrossFormatOperations({
async function findCrossStringOwner(client, messageXrefs) {
for (const address of messageXrefs) {
- const candidate = await functionCall(
+ const candidate = await inventoryCall(
client,
"resolve_containing_procedure",
{
diff --git a/scripts/verify-real-ghidra.mjs b/scripts/verify-real-ghidra.mjs
index 7ee177dfa..fd8638144 100644
--- a/scripts/verify-real-ghidra.mjs
+++ b/scripts/verify-real-ghidra.mjs
@@ -126,25 +126,27 @@ try {
format: "elf",
architecture: "x86_64",
});
+ assertDebugFixture(debug);
const stripped = await verifyTarget(strippedPath, "stripped", {
format: "elf",
architecture: "x86_64",
});
+ assertStrippedFixture(stripped);
const arm64Elf = await verifyTarget(arm64ElfPath, "cross-arm64-elf", {
format: "elf",
architecture: "arm64",
});
+ assertCrossFixture(arm64Elf);
const pe = await verifyTarget(pePath, "cross-x86_64-pe", {
format: "pe",
architecture: "x86_64",
});
+ assertCrossFixture(pe);
const machObject = await verifyTarget(machObjectPath, "cross-x86_64-mach-o", {
format: "mach-o",
architecture: "x86_64",
});
- assertDebugFixture(debug);
- assertStrippedFixture(stripped);
- for (const fixture of [arm64Elf, pe, machObject]) assertCrossFixture(fixture);
+ assertCrossFixture(machObject);
await assertMalformedFixture(malformedPath);
const customPath = process.env.GHIDRA_TARGET_PATH;
diff --git a/src/application/CapabilityInventory.ts b/src/application/CapabilityInventory.ts
index 467b6751c..7c18de049 100644
--- a/src/application/CapabilityInventory.ts
+++ b/src/application/CapabilityInventory.ts
@@ -102,6 +102,7 @@ const ENHANCED_REQUIREMENTS: Readonly> = {
"list_strings",
],
analyze_function: ["analyze_function"],
+ inspect_native_api: ["analyze_function"],
trace_feature: [
"list_strings",
"list_procedures",
diff --git a/src/application/DirectAnalysis.ts b/src/application/DirectAnalysis.ts
index 2ceac7128..23e847b8f 100644
--- a/src/application/DirectAnalysis.ts
+++ b/src/application/DirectAnalysis.ts
@@ -52,6 +52,7 @@ type DirectAnalysisTool =
| "procedure_pseudo_code"
| "read_function_instructions"
| "analyze_function"
+ | "inspect_native_api"
| "search_strings"
| "search_procedures"
| "xrefs"
@@ -341,6 +342,7 @@ const executeAnalysisTool = async (input: {
if (
tool === "binary_overview" ||
tool === "analyze_function" ||
+ tool === "inspect_native_api" ||
tool === "trace_feature"
) {
const result = await new EnhancedTools(session).execute(
@@ -438,7 +440,7 @@ const replayProviderFor = (
| ManagedToolName
| DirectAnalysisTool,
) =>
- tool === "binary_overview" || tool === "trace_feature"
+ isWorkflowEvidenceTool(tool)
? REA_WORKFLOW_PROVIDER
: session.providerIdentity(tool);
@@ -450,12 +452,22 @@ const analysisProfileForEvidence = (
| ManagedToolName
| DirectAnalysisTool,
): AnalysisProfileCommitment | undefined => {
- if (tool !== "binary_overview" && tool !== "trace_feature")
- return session.analysisProfile(tool);
+ if (!isWorkflowEvidenceTool(tool)) return session.analysisProfile(tool);
const upstream = session.analysisProfile();
return upstream === undefined ? undefined : workflowAnalysisProfile(upstream);
};
+const isWorkflowEvidenceTool = (
+ tool:
+ | NativeToolName
+ | ArtifactToolName
+ | ManagedToolName
+ | DirectAnalysisTool,
+): boolean =>
+ tool === "binary_overview" ||
+ tool === "inspect_native_api" ||
+ tool === "trace_feature";
+
const fileExists = async (path: string): Promise => {
try {
await access(path);
diff --git a/src/application/EnhancedTools.ts b/src/application/EnhancedTools.ts
index 96cf7ed6f..42703644e 100644
--- a/src/application/EnhancedTools.ts
+++ b/src/application/EnhancedTools.ts
@@ -1,3 +1,5 @@
+import type { z } from "zod";
+
import type {
AnalysisOperation,
AnalysisOperationPort,
@@ -12,6 +14,7 @@ import {
} from "../domain/errors.js";
import {
addressDistance,
+ functionDossierSchema,
parseDocuments,
parseFunctionDossier,
parseListCount,
@@ -25,7 +28,7 @@ import {
discoverObjcProtocols,
discoverSwiftClasses,
} from "../domain/symbolAnalysis.js";
-import type { JsonValue } from "../domain/jsonValue.js";
+import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js";
import {
invalidEnhancedInput,
@@ -35,6 +38,7 @@ import {
import { readAllAddressed } from "./EnhancedToolPagination.js";
import { traceCallPath } from "./CallPathTracing.js";
import { traceLiteralFeature } from "./EnhancedLiteralTracing.js";
+import { projectNativeApiInspection } from "./NativeApiInspection.js";
export type { ValidatedEnhancedCall } from "./EnhancedToolTypes.js";
/**
@@ -57,6 +61,8 @@ export class EnhancedTools {
name === "trace_call_path"
)
return this.#executeTracing(name, input, signal);
+ if (name === "analyze_function" || name === "inspect_native_api")
+ return this.#executeFunctionAnalysis(name, input, signal);
switch (name) {
case "swift_classes": {
const parsed = enhancedInputSchemas.swift_classes.safeParse(input);
@@ -98,12 +104,6 @@ export class EnhancedTools {
? this.executeValidated({ name, input: parsed.data }, signal)
: invalidEnhancedInput(name, parsed.error);
}
- case "analyze_function": {
- const parsed = enhancedInputSchemas.analyze_function.safeParse(input);
- return parsed.success
- ? this.executeValidated({ name, input: parsed.data }, signal)
- : invalidEnhancedInput(name, parsed.error);
- }
}
}
@@ -131,6 +131,8 @@ export class EnhancedTools {
return this.#binaryOverview(call.input, signal);
case "analyze_function":
return this.#analyzeFunction(call.input, signal);
+ case "inspect_native_api":
+ return this.#inspectNativeApi(call.input, signal);
case "trace_feature":
return traceLiteralFeature(
(name, arguments_, operationSignal) =>
@@ -181,6 +183,30 @@ export class EnhancedTools {
);
}
+ #executeFunctionAnalysis(
+ name: "analyze_function" | "inspect_native_api",
+ input: unknown,
+ signal?: AbortSignal,
+ ): EnhancedResult {
+ const parsed = enhancedInputSchemas[name].safeParse(input);
+ if (!parsed.success) return invalidEnhancedInput(name, parsed.error);
+ if (name === "analyze_function")
+ return this.executeValidated(
+ {
+ name,
+ input: enhancedInputSchemas.analyze_function.parse(parsed.data),
+ },
+ signal,
+ );
+ return this.executeValidated(
+ {
+ name,
+ input: enhancedInputSchemas.inspect_native_api.parse(parsed.data),
+ },
+ signal,
+ );
+ }
+
async #analyzeFunction(
input: Readonly>,
signal?: AbortSignal,
@@ -189,6 +215,24 @@ export class EnhancedTools {
return result.ok ? parseFunctionDossier(result.value) : result;
}
+ async #inspectNativeApi(
+ input: z.output,
+ signal?: AbortSignal,
+ ): EnhancedResult {
+ const analysisInput = enhancedInputSchemas.analyze_function.parse({
+ procedure: input.procedure,
+ include_assembly: true,
+ max_pseudocode_chars: input.max_pseudocode_chars,
+ max_instructions: input.max_instructions,
+ });
+ const result = await this.#call("analyze_function", analysisInput, signal);
+ if (!result.ok) return result;
+ const parsed = parseFunctionDossier(result.value);
+ if (!parsed.ok) return parsed;
+ const dossier = functionDossierSchema.parse(parsed.value);
+ return ok(jsonValueSchema.parse(projectNativeApiInspection(dossier)));
+ }
+
async #swiftClasses(pattern: string, signal?: AbortSignal): EnhancedResult {
const procedures = await this.#allAddressed("list_procedures", signal);
return procedures.ok
diff --git a/src/application/NativeApiInspection.ts b/src/application/NativeApiInspection.ts
new file mode 100644
index 000000000..7994aa34f
--- /dev/null
+++ b/src/application/NativeApiInspection.ts
@@ -0,0 +1,113 @@
+import type { FunctionDossier } from "../domain/hopperValues.js";
+import {
+ nativeApiInspectionResultSchema,
+ type NativeApiBoundary,
+ type NativeApiInspectionResult,
+} from "../domain/nativeApiBoundary.js";
+
+const unavailableBoundary = (): NativeApiBoundary => ({
+ available: false,
+ reason:
+ "The selected provider did not expose a structured native API boundary model.",
+ residual_unknowns: [
+ "What are the function's return and parameter boundary types?",
+ "Does this function dispatch through a jump table with recoverable data addresses?",
+ ],
+});
+
+/** Project one analyzed-function dossier into inspectable native API substeps. */
+export const projectNativeApiInspection = (
+ dossier: FunctionDossier,
+): NativeApiInspectionResult => {
+ const boundary = dossier.native_api ?? unavailableBoundary();
+ const unsupportedBranches = boundary.available
+ ? []
+ : ["structured-boundary-types", "jump-table-data-mapping"];
+ const residualUnknowns = boundary.available
+ ? availableResidualUnknowns(boundary)
+ : boundary.residual_unknowns;
+ return nativeApiInspectionResultSchema.parse({
+ schema_version: 1,
+ procedure: {
+ address: dossier.procedure.address,
+ name: dossier.procedure.name,
+ },
+ boundary,
+ substeps: [
+ {
+ operation: "analyze_function",
+ status: "completed",
+ observations: [
+ "provider function dossier",
+ "provider pseudocode classification",
+ "provider control-flow observations",
+ ],
+ },
+ {
+ operation: "project_native_api_boundary",
+ status: boundary.available ? "completed" : "unsupported",
+ observations: boundary.available
+ ? [
+ "return and parameter boundary types",
+ "confidence and inference evidence",
+ "jump-table dispatch, data, and target addresses",
+ ]
+ : [boundary.reason],
+ },
+ {
+ operation: "preserve_residual_unknowns",
+ status: "completed",
+ observations:
+ residualUnknowns.length === 0
+ ? ["no additional structured-boundary unknowns"]
+ : residualUnknowns,
+ },
+ ],
+ unsupported_branches: unsupportedBranches,
+ residual_unknowns: residualUnknowns,
+ });
+};
+
+const availableResidualUnknowns = (
+ boundary: Extract,
+): readonly string[] => {
+ const unknowns: string[] = [];
+ if (boundary.parameters_truncated)
+ unknowns.push(
+ "Which additional parameters were omitted by the native API boundary limit?",
+ );
+ if (boundary.jump_tables_truncated)
+ unknowns.push(
+ "Which additional jump tables were omitted by the native API boundary limit?",
+ );
+ for (const table of boundary.jump_tables) {
+ if (table.data_sources_truncated || table.mappings_truncated)
+ unknowns.push(
+ `Which additional data sources or targets were omitted for the jump table dispatched at ${table.dispatch_address}?`,
+ );
+ if (table.data_sources.length === 0)
+ unknowns.push(
+ `What backing data address produced the jump table dispatched at ${table.dispatch_address}?`,
+ );
+ if (
+ table.data_sources.some(
+ ({ entry_count: count, entry_size_bytes: size }) =>
+ count === null || size === null,
+ )
+ )
+ unknowns.push(
+ `What are the exact entry size and count for candidate jump-table data dispatched at ${table.dispatch_address}?`,
+ );
+ if (table.mappings.some(({ case_value: value }) => value === null))
+ unknowns.push(
+ `Which source-level case values correspond to every target dispatched at ${table.dispatch_address}?`,
+ );
+ }
+ for (const value of [boundary.return_type, ...boundary.parameters]) {
+ if (value.confidence === "low")
+ unknowns.push(
+ `Can the inferred ${value.role} type ${value.data_type} be confirmed with imported metadata or an ABI probe?`,
+ );
+ }
+ return [...new Set(unknowns)];
+};
diff --git a/src/cli/coreAnalysisCommands.ts b/src/cli/coreAnalysisCommands.ts
index d6a0faeec..c2748ef2e 100644
--- a/src/cli/coreAnalysisCommands.ts
+++ b/src/cli/coreAnalysisCommands.ts
@@ -16,6 +16,7 @@ export const registerCoreAnalysisCommands = (
): void => {
registerCoreCommands(cli, logger);
registerFunctionCommand(cli, logger);
+ registerNativeApiCommand(cli, logger);
registerInstructionsCommand(cli, logger);
registerSearchCommand(cli, logger);
registerXrefsCommand(cli, logger);
@@ -286,6 +287,55 @@ const registerFunctionCommand = (cli: CliInstance, logger: Logger): void => {
});
};
+const registerNativeApiCommand = (cli: CliInstance, logger: Logger): void => {
+ cli.command(CLI_COMMANDS.inspectNativeApi, {
+ description: "Reconstruct one native function API boundary with evidence",
+ args: z.object({
+ path: z.string().describe("App or program path"),
+ address: z.string().describe("Procedure name or address"),
+ }),
+ options: z.object({
+ maxPseudocodeChars: z
+ .number()
+ .int()
+ .min(1)
+ .max(100_000)
+ .default(20_000)
+ .describe("Maximum pseudocode characters to inspect"),
+ maxInstructions: z
+ .number()
+ .int()
+ .min(1)
+ .max(5_000)
+ .default(500)
+ .describe("Maximum assembly instructions to inspect"),
+ snapshot: z
+ .string()
+ .min(1)
+ .optional()
+ .describe("Load and update a local analysis snapshot"),
+ provider: providerSelectionOption,
+ }),
+ alias: {
+ maxPseudocodeChars: "max-pseudocode-chars",
+ maxInstructions: "max-instructions",
+ },
+ run: ({ args, options }) =>
+ logCliCommand(logger, "inspect-native-api", () =>
+ runDirectAnalysis(
+ args.path,
+ "inspect_native_api",
+ {
+ procedure: args.address,
+ max_pseudocode_chars: options.maxPseudocodeChars,
+ max_instructions: options.maxInstructions,
+ },
+ directAnalysisOptions(logger, options.snapshot, options.provider),
+ ),
+ ),
+ });
+};
+
const registerInstructionsCommand = (
cli: CliInstance,
logger: Logger,
diff --git a/src/cliCommandNames.ts b/src/cliCommandNames.ts
index 06a069390..1acea9cb9 100644
--- a/src/cliCommandNames.ts
+++ b/src/cliCommandNames.ts
@@ -12,6 +12,7 @@ export const CLI_COMMANDS = Object.freeze({
capabilities: "capabilities",
providers: "providers",
function: "function",
+ inspectNativeApi: "inspect-native-api",
instructions: "instructions",
search: "search",
importReferenceSource: "import-reference-source",
diff --git a/src/contracts/enhancedInputs.ts b/src/contracts/enhancedInputs.ts
index 3841b02a2..8437fc6ff 100644
--- a/src/contracts/enhancedInputs.ts
+++ b/src/contracts/enhancedInputs.ts
@@ -63,6 +63,17 @@ export const enhancedInputSchemas = {
basic_blocks: 0,
}),
}),
+ inspect_native_api: z.object({
+ procedure: z.string().describe("A procedure name or address"),
+ max_pseudocode_chars: z.number().int().min(1).max(100_000).default(20_000),
+ max_instructions: z.number().int().min(1).max(5_000).default(500),
+ unknown_registry_approved: z
+ .literal(true)
+ .optional()
+ .describe(
+ "Explicit approval to record unsupported native API branches durably",
+ ),
+ }),
trace_feature: traceLiteralInputSchema,
find_code_for_string: traceLiteralInputSchema,
trace_call_path: z.object({
@@ -95,6 +106,7 @@ export const enhancedToolNameSchema = z.enum([
"find_xrefs_to_name",
"binary_overview",
"analyze_function",
+ "inspect_native_api",
"trace_feature",
"find_code_for_string",
"trace_call_path",
diff --git a/src/contracts/functionWorkflowToolContracts.ts b/src/contracts/functionWorkflowToolContracts.ts
new file mode 100644
index 000000000..ca5537457
--- /dev/null
+++ b/src/contracts/functionWorkflowToolContracts.ts
@@ -0,0 +1,46 @@
+import { jsonObjectSchema } from "../domain/jsonValue.js";
+import { enhancedInputSchemas } from "./enhancedInputs.js";
+import { TOOL_EXAMPLE_OVERRIDES } from "./toolContractExamples.js";
+import type { ToolContract } from "./toolContractTypes.js";
+import { toolContractMetadata } from "./toolEffects.js";
+import { enhancedOutputSchemas } from "./toolOutputSchemas.js";
+
+type FunctionWorkflowName = "analyze_function" | "inspect_native_api";
+
+const functionWorkflow = (
+ name: Name,
+ description: string,
+): ToolContract => {
+ const inputSchema = enhancedInputSchemas[name];
+ const outputSchema = enhancedOutputSchemas[name];
+ if (outputSchema === undefined)
+ throw new Error(`Missing function workflow output schema for ${name}`);
+ return {
+ name,
+ ...toolContractMetadata(name),
+ description,
+ kind: "enhanced",
+ inputSchema,
+ outputSchema,
+ examples: [
+ {
+ title: `Example ${name.replaceAll("_", " ")} request`,
+ input: jsonObjectSchema.parse(
+ inputSchema.parse(TOOL_EXAMPLE_OVERRIDES[name] ?? {}),
+ ),
+ },
+ ],
+ };
+};
+
+/** Function dossier and native API reconstruction workflow contracts. */
+export const FUNCTION_WORKFLOW_TOOL_CONTRACTS = [
+ functionWorkflow(
+ "analyze_function",
+ "Preferred bounded analysis for one procedure symbol or address. Returns identity, provider-specific pseudocode, optional assembly, comments, calls, typed-or-explicitly-unavailable references, referenced strings/names, and local CFG blocks with exact truncation metadata. Providers with a structured decompiler model also expose native API boundary types, confidence, evidence, jump-table data/target addresses, and explicit decompiler-artifact labels.",
+ ),
+ functionWorkflow(
+ "inspect_native_api",
+ "Reconstruct one native function boundary through inspectable substeps. Returns structured inferred return/parameter types with confidence and evidence, jump-table dispatch/data/target mappings, explicit decompiler-artifact labels, unsupported branches, and residual unknowns.",
+ ),
+] as const satisfies readonly ToolContract[];
diff --git a/src/contracts/toolContractExamples.ts b/src/contracts/toolContractExamples.ts
index 6d84c8e5b..8d170c785 100644
--- a/src/contracts/toolContractExamples.ts
+++ b/src/contracts/toolContractExamples.ts
@@ -35,6 +35,7 @@ export const TOOL_EXAMPLE_OVERRIDES: Readonly<
get_call_graph: { address: "0x1000" },
find_xrefs_to_name: { name: "malloc" },
analyze_function: { procedure: "main" },
+ inspect_native_api: { procedure: "main" },
trace_feature: { query: "license" },
find_code_for_string: { query: "authorization failed" },
trace_call_path: { start: "0x1000", goal: "0x1100" },
diff --git a/src/contracts/toolContracts.ts b/src/contracts/toolContracts.ts
index 8fd5edefd..449e38754 100644
--- a/src/contracts/toolContracts.ts
+++ b/src/contracts/toolContracts.ts
@@ -41,6 +41,7 @@ import { binarySessionInputSchema } from "./sessionStatusContract.js";
import { functionInstructionInputSchema } from "./functionInstructionContract.js";
import { HOPPER_MEMORY_TOOL_DEFINITIONS } from "./hopperMemoryContracts.js";
import { analysisSearchInput } from "./analysisSearchContract.js";
+import { FUNCTION_WORKFLOW_TOOL_CONTRACTS } from "./functionWorkflowToolContracts.js";
export { binarySessionInputSchema } from "./sessionStatusContract.js";
@@ -354,11 +355,7 @@ export const ENHANCED_TOOL_CONTRACTS = [
"Use immediately after opening a target to summarize document, exhaustive procedure/string counts, and a bounded segment sample. detail controls segment fields and limit controls only the returned segment sample.",
enhancedInputSchemas.binary_overview,
),
- enhanced(
- "analyze_function",
- "Preferred bounded analysis for one procedure symbol or address. Returns identity, provider-specific pseudocode, optional assembly, comments, calls, typed-or-explicitly-unavailable references, referenced strings/names, and local CFG blocks with exact truncation metadata.",
- enhancedInputSchemas.analyze_function,
- ),
+ ...FUNCTION_WORKFLOW_TOOL_CONTRACTS,
enhanced(
"trace_feature",
"Trace a bounded literal feature query through matching strings and procedures, xrefs, and truthful containing-procedure resolution. Returns the operation budget, truncation, and residual unknowns; unknown_registry_approved: true records them durably without inferring reference kinds.",
diff --git a/src/contracts/toolEffects.ts b/src/contracts/toolEffects.ts
index 81e29b6be..4d1fe0ff2 100644
--- a/src/contracts/toolEffects.ts
+++ b/src/contracts/toolEffects.ts
@@ -89,6 +89,7 @@ export const TOOL_EFFECTS: Readonly> = {
find_xrefs_to_name: evidence,
binary_overview: evidence,
analyze_function: evidence,
+ inspect_native_api: effects({ mutatesSession: true, idempotent: false }),
trace_feature: effects({ mutatesSession: true, idempotent: false }),
find_code_for_string: effects({ mutatesSession: true, idempotent: false }),
trace_call_path: effects({ mutatesSession: true, idempotent: false }),
diff --git a/src/contracts/toolOutputSchemaGroups.ts b/src/contracts/toolOutputSchemaGroups.ts
index 5b2d2582d..5be141a74 100644
--- a/src/contracts/toolOutputSchemaGroups.ts
+++ b/src/contracts/toolOutputSchemaGroups.ts
@@ -10,6 +10,7 @@ import {
functionInstructionWindowSchema,
referenceKindSchema,
} from "../domain/hopperValues.js";
+import { nativeApiInspectionResultSchema } from "../domain/nativeApiBoundary.js";
import {
demangleSwiftSchema,
inspectMachoSchema,
@@ -272,6 +273,7 @@ export const enhancedOutputSchemas: Readonly> = {
}),
),
analyze_function: functionDossierOutput,
+ inspect_native_api: resultOf(nativeApiInspectionResultSchema),
trace_feature: literalTraceOutput,
find_code_for_string: literalTraceOutput,
trace_call_path: callPathTraceOutput,
diff --git a/src/domain/hopperValues.ts b/src/domain/hopperValues.ts
index 7328f4115..81c32137e 100644
--- a/src/domain/hopperValues.ts
+++ b/src/domain/hopperValues.ts
@@ -1,8 +1,9 @@
import { z } from "zod";
-import type { JsonValue } from "./jsonValue.js";
+import { jsonValueSchema, type JsonValue } from "./jsonValue.js";
import { AnalysisOutputError, HopperProtocolError } from "./errors.js";
import { err, ok, type Result } from "./result.js";
+import { nativeApiBoundarySchema } from "./nativeApiBoundary.js";
export interface AddressedName {
readonly address: string;
@@ -275,6 +276,7 @@ export const functionDossierSchema = z
instruction_scan: z
.object({ scanned: z.number().int().min(0), truncated: z.boolean() })
.strict(),
+ native_api: nativeApiBoundarySchema.optional(),
limitations: z.array(z.string()).default([]),
})
.strict();
@@ -288,7 +290,7 @@ export const parseFunctionDossier = (
): Result => {
const parsed = functionDossierSchema.safeParse(value);
return parsed.success
- ? ok(parsed.data)
+ ? ok(jsonValueSchema.parse(parsed.data))
: err(
new AnalysisOutputError(
"analyze_function",
diff --git a/src/domain/nativeApiBoundary.ts b/src/domain/nativeApiBoundary.ts
new file mode 100644
index 000000000..e63301cdf
--- /dev/null
+++ b/src/domain/nativeApiBoundary.ts
@@ -0,0 +1,153 @@
+import { z } from "zod";
+
+const inferenceConfidenceSchema = z.enum(["low", "medium", "high"]);
+
+const inferenceEvidenceSchema = z
+ .object({
+ kind: z.enum([
+ "signature-source",
+ "decompiler-type",
+ "type-lock",
+ "calling-convention",
+ "jump-table",
+ ]),
+ source: z.string().min(1),
+ detail: z.string().min(1),
+ })
+ .strict();
+
+const inferredBoundaryTypeSchema = z
+ .object({
+ role: z.enum(["return", "parameter"]),
+ ordinal: z.number().int().min(0).nullable(),
+ name: z.string().min(1).nullable(),
+ data_type: z.string().min(1),
+ size_bytes: z.number().int().min(0).nullable(),
+ storage: z.string().min(1).nullable(),
+ confidence: inferenceConfidenceSchema,
+ evidence: z.array(inferenceEvidenceSchema).min(1),
+ decompiler_artifacts: z.array(
+ z.enum([
+ "recovered-signature",
+ "register-or-stack-storage",
+ "compiler-generated-variable",
+ ]),
+ ),
+ })
+ .strict();
+
+const jumpTableDataSourceSchema = z
+ .object({
+ address: z.string().min(1),
+ provenance: z.enum([
+ "ghidra-decompiler-load-table",
+ "ghidra-dispatch-block-data-reference",
+ ]),
+ entry_size_bytes: z.number().int().min(1).nullable(),
+ entry_count: z.number().int().min(0).nullable(),
+ confidence: inferenceConfidenceSchema,
+ evidence: z.array(inferenceEvidenceSchema).min(1),
+ })
+ .strict();
+
+const jumpTableMappingSchema = z
+ .object({
+ case_value: z.number().int().nullable(),
+ target_address: z.string().min(1),
+ data_addresses: z.array(z.string().min(1)),
+ confidence: inferenceConfidenceSchema,
+ evidence: z.array(inferenceEvidenceSchema).min(1),
+ })
+ .strict();
+
+const inferredJumpTableSchema = z
+ .object({
+ dispatch_address: z.string().min(1),
+ data_sources: z.array(jumpTableDataSourceSchema),
+ data_sources_truncated: z.boolean(),
+ mappings: z.array(jumpTableMappingSchema),
+ mappings_truncated: z.boolean(),
+ limitations: z.array(z.string()),
+ })
+ .strict();
+
+const availableNativeApiBoundarySchema = z
+ .object({
+ available: z.literal(true),
+ provenance: z.string().min(1),
+ signature_source: z.string().min(1),
+ calling_convention: z.string().min(1),
+ return_type: inferredBoundaryTypeSchema,
+ parameters: z.array(inferredBoundaryTypeSchema),
+ parameters_truncated: z.boolean(),
+ jump_tables: z.array(inferredJumpTableSchema),
+ jump_tables_truncated: z.boolean(),
+ pseudocode: z
+ .object({
+ classification: z.literal("decompiler-generated-non-source"),
+ compilable: z.literal(false),
+ })
+ .strict(),
+ decompiler_artifacts: z.array(
+ z.enum([
+ "recovered-signature",
+ "register-and-stack-variables",
+ "compiler-generated-control-flow",
+ "pointer-arithmetic",
+ "pseudocode",
+ ]),
+ ),
+ limitations: z.array(z.string()),
+ })
+ .strict();
+
+const unavailableNativeApiBoundarySchema = z
+ .object({
+ available: z.literal(false),
+ reason: z.string().min(1),
+ residual_unknowns: z.array(z.string().min(1)).min(1),
+ })
+ .strict();
+
+/** Provider-neutral native API boundary observations attached to a function dossier. */
+export const nativeApiBoundarySchema = z.discriminatedUnion("available", [
+ unavailableNativeApiBoundarySchema,
+ availableNativeApiBoundarySchema,
+]);
+
+/** One validated provider-neutral native API boundary observation. */
+export type NativeApiBoundary = z.infer;
+
+const nativeApiInspectionSubstepSchema = z
+ .object({
+ operation: z.enum([
+ "analyze_function",
+ "project_native_api_boundary",
+ "preserve_residual_unknowns",
+ ]),
+ status: z.enum(["completed", "unsupported"]),
+ observations: z.array(z.string().min(1)),
+ })
+ .strict();
+
+/** Agent-facing projection of structured native API reconstruction substeps. */
+export const nativeApiInspectionResultSchema = z
+ .object({
+ schema_version: z.literal(1),
+ procedure: z
+ .object({
+ address: z.string().min(1),
+ name: z.string().min(1),
+ })
+ .strict(),
+ boundary: nativeApiBoundarySchema,
+ substeps: z.array(nativeApiInspectionSubstepSchema).min(3).max(3),
+ unsupported_branches: z.array(z.string().min(1)),
+ residual_unknowns: z.array(z.string().min(1)),
+ })
+ .strict();
+
+/** One validated agent-facing native API inspection result. */
+export type NativeApiInspectionResult = z.infer<
+ typeof nativeApiInspectionResultSchema
+>;
diff --git a/src/ghidra/GhidraDefaults.ts b/src/ghidra/GhidraDefaults.ts
index 197877f34..1fa18e8bd 100644
--- a/src/ghidra/GhidraDefaults.ts
+++ b/src/ghidra/GhidraDefaults.ts
@@ -17,4 +17,4 @@ export const GHIDRA_MAX_QUEUED_REQUESTS = 32;
/** Maximum encoded response line accepted from the Java bridge. */
export const GHIDRA_MAX_LINE_BYTES = 1024 * 1024;
/** Version of REA's Java bridge handshake and request protocol. */
-export const GHIDRA_BRIDGE_VERSION = 5;
+export const GHIDRA_BRIDGE_VERSION = 6;
diff --git a/src/ghidra/GhidraFunctionValues.ts b/src/ghidra/GhidraFunctionValues.ts
index b8d42f068..44ad593ee 100644
--- a/src/ghidra/GhidraFunctionValues.ts
+++ b/src/ghidra/GhidraFunctionValues.ts
@@ -11,6 +11,7 @@ import {
jsonValueSchema,
type JsonValue,
} from "../domain/jsonValue.js";
+import { nativeApiBoundarySchema } from "../domain/nativeApiBoundary.js";
import { err, ok, type Result } from "../domain/result.js";
import {
ghidraBoundedIdentifierSchema,
@@ -220,8 +221,40 @@ const procedureInfo = z
})
.strict();
-const ghidraFunctionDossier = functionDossierSchema.superRefine(
+const ghidraNativeApiBoundary = nativeApiBoundarySchema.superRefine(
(value, context) => {
+ if (!value.available) return;
+ if (value.provenance !== "ghidra-high-function")
+ context.addIssue({
+ code: "custom",
+ message:
+ "Ghidra native API observations require HighFunction provenance",
+ });
+ const addresses = value.jump_tables.flatMap((table) => [
+ table.dispatch_address,
+ ...table.data_sources.map(({ address }) => address),
+ ...table.mappings.flatMap(({ target_address, data_addresses }) => [
+ target_address,
+ ...data_addresses,
+ ]),
+ ]);
+ if (
+ addresses.some(
+ (address) => !ghidraCanonicalAddressSchema.safeParse(address).success,
+ )
+ )
+ context.addIssue({
+ code: "custom",
+ message:
+ "Ghidra native API observation contains a non-canonical address",
+ });
+ },
+);
+
+const ghidraFunctionDossier = functionDossierSchema
+ .extend({ native_api: ghidraNativeApiBoundary })
+ .strict()
+ .superRefine((value, context) => {
const addresses = [
value.procedure.address,
...value.comments.items.map(({ address }) => address),
@@ -288,8 +321,7 @@ const ghidraFunctionDossier = functionDossierSchema.superRefine(
code: "custom",
message: "Ghidra dossier omitted required uncertainty limitations",
});
- },
-);
+ });
const ghidraFunctionInstructionWindow = functionInstructionWindowSchema
.extend({ procedure: procedureIdentity })
.strict();
diff --git a/src/server/registerEnhancedTools.ts b/src/server/registerEnhancedTools.ts
index fbf19b2bc..f2d8bc872 100644
--- a/src/server/registerEnhancedTools.ts
+++ b/src/server/registerEnhancedTools.ts
@@ -223,6 +223,12 @@ const ENHANCED_INPUT_PARSERS: {
input,
"analyze_function",
),
+ inspect_native_api: (input) =>
+ safeParseToolInput(
+ enhancedInputSchemas.inspect_native_api,
+ input,
+ "inspect_native_api",
+ ),
trace_feature: (input) =>
safeParseToolInput(
enhancedInputSchemas.trace_feature,
@@ -293,6 +299,11 @@ const parseEnhancedCall = (
ENHANCED_INPUT_PARSERS.analyze_function(input),
(value) => ({ name, input: value }),
);
+ case "inspect_native_api":
+ return mapEnhancedCall(
+ ENHANCED_INPUT_PARSERS.inspect_native_api(input),
+ (value) => ({ name, input: value }),
+ );
case "trace_feature":
return mapEnhancedCall(
ENHANCED_INPUT_PARSERS.trace_feature(input),
@@ -335,9 +346,12 @@ const recordWorkflowUnknowns = ({
| ReturnType
| { readonly ok: true; readonly value: null } => {
if (
- !["trace_feature", "find_code_for_string", "trace_call_path"].includes(
- name,
- ) ||
+ ![
+ "trace_feature",
+ "find_code_for_string",
+ "trace_call_path",
+ "inspect_native_api",
+ ].includes(name) ||
input.unknown_registry_approved !== true ||
recordUnknown === undefined ||
typeof result !== "object" ||
@@ -352,7 +366,7 @@ const recordWorkflowUnknowns = ({
approved: true,
question,
severity: "medium",
- domain: "control-flow",
+ domain: name === "inspect_native_api" ? "native-api" : "control-flow",
supporting_evidence_ids: [evidenceId],
contradicting_evidence_ids: [],
required_authority: "shipped-artifact",
@@ -361,7 +375,10 @@ const recordWorkflowUnknowns = ({
recommended_probes: [
{
operation: name,
- rationale: "Continue with a larger bounded budget.",
+ rationale:
+ name === "inspect_native_api"
+ ? "Confirm the unsupported boundary with a capable provider or ABI probe."
+ : "Continue with a larger bounded budget.",
},
],
relationships: [],
diff --git a/tests/conformance/ghidra/inventory.c b/tests/conformance/ghidra/inventory.c
index 704d57151..4cac9de09 100644
--- a/tests/conformance/ghidra/inventory.c
+++ b/tests/conformance/ghidra/inventory.c
@@ -19,6 +19,30 @@ __attribute__((noinline, used)) int rea_ghidra_inventory_indirect(
return callback(value);
}
+__attribute__((noinline, used)) int rea_ghidra_inventory_switch(
+ unsigned int selector, int value) {
+ switch (selector) {
+ case 0:
+ return rea_ghidra_inventory_leaf(value);
+ case 1:
+ return rea_ghidra_inventory_leaf(value + 3);
+ case 2:
+ return rea_ghidra_inventory_leaf(value - 5);
+ case 3:
+ return rea_ghidra_inventory_leaf(value * 2);
+ case 4:
+ return rea_ghidra_inventory_leaf(value ^ 0x55);
+ case 5:
+ return rea_ghidra_inventory_leaf(value | 0x20);
+ case 6:
+ return rea_ghidra_inventory_leaf(value & 0x3f);
+ case 7:
+ return rea_ghidra_inventory_leaf(value + rea_ghidra_inventory_global);
+ default:
+ return -1;
+ }
+}
+
__attribute__((noinline, used)) int rea_ghidra_inventory_entry(void) {
puts("REA_GHIDRA_INVENTORY_ENTRY");
return rea_ghidra_inventory_branch(35) +
diff --git a/tests/enhancedTools.test.ts b/tests/enhancedTools.test.ts
index f1118db69..e2caef1b4 100644
--- a/tests/enhancedTools.test.ts
+++ b/tests/enhancedTools.test.ts
@@ -213,7 +213,7 @@ describe("enhanced MCP tools", () => {
).toEqual(ENHANCED_TOOL_CONTRACTS.map(({ name }) => name).sort());
});
- it("executes all twelve tools through production registration", async () => {
+ it("executes all thirteen tools through production registration", async () => {
const client = await connect();
const calls = [
["swift_classes", { pattern: "Fixture" }],
@@ -225,6 +225,7 @@ describe("enhanced MCP tools", () => {
["find_xrefs_to_name", { name: "entry" }],
["binary_overview", {}],
["analyze_function", { procedure: "0x1" }],
+ ["inspect_native_api", { procedure: "0x1" }],
["trace_feature", { query: "hello", max_operations: 10 }],
["find_code_for_string", { query: "hello", max_operations: 10 }],
["trace_call_path", { start: "0x1", goal: "0x2", max_operations: 10 }],
@@ -271,6 +272,22 @@ describe("enhanced MCP tools", () => {
pseudocode: { text: "return 0;" },
});
expect(results[9]).toMatchObject({
+ procedure: { address: "0x1", name: "entry" },
+ boundary: { available: false },
+ unsupported_branches: [
+ "structured-boundary-types",
+ "jump-table-data-mapping",
+ ],
+ residual_unknowns: expect.arrayContaining([
+ expect.stringContaining("boundary types"),
+ ]),
+ substeps: [
+ { operation: "analyze_function", status: "completed" },
+ { operation: "project_native_api_boundary", status: "unsupported" },
+ { operation: "preserve_residual_unknowns", status: "completed" },
+ ],
+ });
+ expect(results[10]).toMatchObject({
query: "hello",
search_mode: "literal",
truncated: false,
@@ -279,12 +296,12 @@ describe("enhanced MCP tools", () => {
{ target_address: "0x30", source_address: "0x21" },
],
});
- expect(results[10]).toMatchObject({
+ expect(results[11]).toMatchObject({
query: "hello",
matches: [{ type: "string", address: "0x30", value: "hello" }],
truncated: false,
});
- expect(results[11]).toMatchObject({
+ expect(results[12]).toMatchObject({
start: "0x1",
goal: "0x2",
direction: "forward",
diff --git a/tests/fixtures/fakeGhidra.mjs b/tests/fixtures/fakeGhidra.mjs
index ba21325d3..6cd0ba2d7 100644
--- a/tests/fixtures/fakeGhidra.mjs
+++ b/tests/fixtures/fakeGhidra.mjs
@@ -176,7 +176,7 @@ const sessionInfo = ({
timedOut,
}) => ({
name: "REA Ghidra bridge",
- bridge_version: 5,
+ bridge_version: 6,
run_id: sessionRunId,
profile_digest: digest,
provider: { id: "ghidra", version },
diff --git a/tests/fixtures/ghidraFunction.ts b/tests/fixtures/ghidraFunction.ts
index 8d90f9eeb..67efa5969 100644
--- a/tests/fixtures/ghidraFunction.ts
+++ b/tests/fixtures/ghidraFunction.ts
@@ -48,6 +48,94 @@ export const ghidraBounded = (items: readonly JsonValue[]) => ({
next_offset: null,
});
+export const ghidraNativeApiBoundary = () => ({
+ available: true as const,
+ provenance: "ghidra-high-function",
+ signature_source: "analysis",
+ calling_convention: "__cdecl",
+ return_type: {
+ role: "return" as const,
+ ordinal: null,
+ name: null,
+ data_type: "int",
+ size_bytes: 4,
+ storage: "EAX:4",
+ confidence: "medium" as const,
+ evidence: [
+ {
+ kind: "signature-source" as const,
+ source: "ghidra-function-manager",
+ detail: "Function signature source is analysis.",
+ },
+ {
+ kind: "decompiler-type" as const,
+ source: "ghidra-high-function",
+ detail: "HighFunction recovered data type int.",
+ },
+ ],
+ decompiler_artifacts: [
+ "recovered-signature" as const,
+ "register-or-stack-storage" as const,
+ ],
+ },
+ parameters: [],
+ parameters_truncated: false,
+ jump_tables: [
+ {
+ dispatch_address: "0x401010",
+ data_sources: [
+ {
+ address: "0x403000",
+ provenance: "ghidra-decompiler-load-table" as const,
+ entry_size_bytes: 4,
+ entry_count: 2,
+ confidence: "high" as const,
+ evidence: [
+ {
+ kind: "jump-table" as const,
+ source: "ghidra-high-function",
+ detail: "Decompiler load table starts at 0x403000.",
+ },
+ ],
+ },
+ ],
+ data_sources_truncated: false,
+ mappings: [
+ {
+ case_value: 0,
+ target_address: "0x401020",
+ data_addresses: ["0x403000"],
+ confidence: "medium" as const,
+ evidence: [
+ {
+ kind: "jump-table" as const,
+ source: "ghidra-high-function",
+ detail: "Recovered target 0x401020 from dispatch 0x401010.",
+ },
+ ],
+ },
+ ],
+ mappings_truncated: false,
+ limitations: [],
+ },
+ ],
+ jump_tables_truncated: false,
+ pseudocode: {
+ classification: "decompiler-generated-non-source" as const,
+ compilable: false as const,
+ },
+ decompiler_artifacts: [
+ "recovered-signature" as const,
+ "register-and-stack-variables" as const,
+ "compiler-generated-control-flow" as const,
+ "pointer-arithmetic" as const,
+ "pseudocode" as const,
+ ],
+ limitations: [
+ "Pseudocode is neither original source nor guaranteed to compile.",
+ ],
+});
+
export const ghidraFunctionDossier = (includeAssembly = true): JsonValue => {
const pseudocode = "int fixture_main(void) { return 42; }";
return {
@@ -83,6 +171,7 @@ export const ghidraFunctionDossier = (includeAssembly = true): JsonValue => {
{ start: "0x401000", end: "0x401006", successors: [] },
]),
instruction_scan: { scanned: 2, truncated: false },
+ native_api: ghidraNativeApiBoundary(),
limitations: [
"Unresolved computed or indirect flows without target addresses are not represented as reference edges.",
"Thunk and external classifications are Ghidra FunctionManager observations; they do not resolve targetless calls.",
diff --git a/tests/ghidraClient.test.ts b/tests/ghidraClient.test.ts
index f0d3aecc7..7d7d67b73 100644
--- a/tests/ghidraClient.test.ts
+++ b/tests/ghidraClient.test.ts
@@ -156,7 +156,7 @@ describe("GhidraClient", () => {
await expect(client.start()).resolves.toMatchObject({
ok: true,
value: {
- bridge_version: 5,
+ bridge_version: 6,
target: { sha256: TARGET_SHA256 },
},
});
diff --git a/tests/ghidraFunctionValues.test.ts b/tests/ghidraFunctionValues.test.ts
index e0e0e33da..6533d2329 100644
--- a/tests/ghidraFunctionValues.test.ts
+++ b/tests/ghidraFunctionValues.test.ts
@@ -13,6 +13,7 @@ import {
ghidraFunctionClassification,
ghidraFunctionDossier,
ghidraFunctionIdentity,
+ ghidraNativeApiBoundary,
ghidraReferenceEdge,
} from "./fixtures/ghidraFunction.js";
@@ -153,7 +154,35 @@ describe("Ghidra function-analysis boundary values", () => {
});
expect(
parseGhidraFunctionResult("analyze_function", ghidraFunctionDossier()),
- ).toMatchObject({ ok: true });
+ ).toMatchObject({
+ ok: true,
+ value: {
+ native_api: {
+ available: true,
+ provenance: "ghidra-high-function",
+ return_type: {
+ data_type: "int",
+ confidence: "medium",
+ },
+ jump_tables: [
+ {
+ dispatch_address: "0x401010",
+ data_sources: [{ address: "0x403000" }],
+ mappings: [
+ {
+ target_address: "0x401020",
+ data_addresses: ["0x403000"],
+ },
+ ],
+ },
+ ],
+ pseudocode: {
+ classification: "decompiler-generated-non-source",
+ compilable: false,
+ },
+ },
+ },
+ });
});
it.each(malformedOutputs())(
@@ -174,6 +203,11 @@ const malformedOutputs = (): Array<
if (typeof dossier !== "object" || dossier === null || Array.isArray(dossier))
throw new TypeError("Ghidra dossier fixture is invalid");
const edge = ghidraReferenceEdge();
+ const nativeApi = ghidraNativeApiBoundary();
+ const jumpTable = nativeApi.jump_tables[0];
+ const mapping = jumpTable?.mappings[0];
+ if (jumpTable === undefined || mapping === undefined)
+ throw new TypeError("Ghidra native API fixture is invalid");
return [
["non-canonical address", "procedure_callers", ["401000"]],
[
@@ -201,6 +235,34 @@ const malformedOutputs = (): Array<
instruction_scan_truncated: false,
},
],
+ [
+ "missing native API boundary",
+ "analyze_function",
+ Object.fromEntries(
+ Object.entries(dossier).filter(([key]) => key !== "native_api"),
+ ),
+ ],
+ [
+ "non-canonical native API target",
+ "analyze_function",
+ {
+ ...dossier,
+ native_api: {
+ ...nativeApi,
+ jump_tables: [
+ {
+ ...jumpTable,
+ mappings: [
+ {
+ ...mapping,
+ target_address: "401020",
+ },
+ ],
+ },
+ ],
+ },
+ },
+ ],
[
"Hopper local provenance",
"analyze_function",
diff --git a/tests/ghidraMcp.test.ts b/tests/ghidraMcp.test.ts
index 851d08675..fc2d389ae 100644
--- a/tests/ghidraMcp.test.ts
+++ b/tests/ghidraMcp.test.ts
@@ -306,7 +306,7 @@ const installationHost = (): GhidraInstallationHost => ({
const sessionInfo = (profileDigest: string, targetSha256: string) => ({
name: "REA Ghidra bridge" as const,
- bridge_version: 5 as const,
+ bridge_version: 6 as const,
run_id: "11111111-1111-4111-8111-111111111111",
profile_digest: profileDigest,
provider: { id: "ghidra" as const, version: "12.1.2" },
diff --git a/tests/ghidraProvider.test.ts b/tests/ghidraProvider.test.ts
index d871faa38..9118271d9 100644
--- a/tests/ghidraProvider.test.ts
+++ b/tests/ghidraProvider.test.ts
@@ -389,7 +389,7 @@ describe("Ghidra provider", () => {
const sessionInfo = () => ({
name: "REA Ghidra bridge" as const,
- bridge_version: 5 as const,
+ bridge_version: 6 as const,
run_id: "11111111-1111-4111-8111-111111111111",
profile_digest: "a".repeat(64),
provider: { id: "ghidra" as const, version: "12.1.2" },
diff --git a/tests/nativeApiInspection.test.ts b/tests/nativeApiInspection.test.ts
new file mode 100644
index 000000000..5eb1f7624
--- /dev/null
+++ b/tests/nativeApiInspection.test.ts
@@ -0,0 +1,62 @@
+import { describe, expect, it } from "vitest";
+
+import { projectNativeApiInspection } from "../src/application/NativeApiInspection.js";
+import { functionDossierSchema } from "../src/domain/hopperValues.js";
+import { ghidraFunctionDossier } from "./fixtures/ghidraFunction.js";
+
+describe("native API inspection", () => {
+ it("projects structured boundary evidence through inspectable substeps", () => {
+ const dossier = functionDossierSchema.parse(ghidraFunctionDossier());
+
+ expect(projectNativeApiInspection(dossier)).toMatchObject({
+ schema_version: 1,
+ procedure: { address: "0x401000", name: "fixture_main" },
+ boundary: {
+ available: true,
+ return_type: { data_type: "int", confidence: "medium" },
+ jump_tables: [
+ {
+ dispatch_address: "0x401010",
+ data_sources: [{ address: "0x403000" }],
+ mappings: [
+ {
+ target_address: "0x401020",
+ data_addresses: ["0x403000"],
+ },
+ ],
+ },
+ ],
+ },
+ substeps: [
+ { operation: "analyze_function", status: "completed" },
+ { operation: "project_native_api_boundary", status: "completed" },
+ { operation: "preserve_residual_unknowns", status: "completed" },
+ ],
+ unsupported_branches: [],
+ residual_unknowns: [],
+ });
+ });
+
+ it("preserves unsupported provider branches as residual unknowns", () => {
+ const value = ghidraFunctionDossier();
+ if (typeof value !== "object" || value === null || Array.isArray(value))
+ throw new TypeError("Ghidra dossier fixture is invalid");
+ const dossier = functionDossierSchema.parse(
+ Object.fromEntries(
+ Object.entries(value).filter(([key]) => key !== "native_api"),
+ ),
+ );
+
+ expect(projectNativeApiInspection(dossier)).toMatchObject({
+ boundary: { available: false },
+ unsupported_branches: [
+ "structured-boundary-types",
+ "jump-table-data-mapping",
+ ],
+ residual_unknowns: [
+ expect.stringContaining("boundary types"),
+ expect.stringContaining("jump table"),
+ ],
+ });
+ });
+});
diff --git a/tests/productCatalog.test.ts b/tests/productCatalog.test.ts
index 9e6f0f5f8..90bcc567b 100644
--- a/tests/productCatalog.test.ts
+++ b/tests/productCatalog.test.ts
@@ -185,6 +185,7 @@ describe("canonical product catalog", () => {
"xrefs",
"trace",
"function",
+ "inspect-native-api",
"search",
]) {
expect(cliCommandOptionNames(cli, name)).toContain("provider");
diff --git a/tests/serverIdentity.test.ts b/tests/serverIdentity.test.ts
index 3af948fe7..e6676616c 100644
--- a/tests/serverIdentity.test.ts
+++ b/tests/serverIdentity.test.ts
@@ -68,10 +68,10 @@ describe("server and catalog identity", () => {
});
expect(PRODUCT_IDENTITY.packageVersion).toBe(packageJson.version);
expect(SDK_IDENTITY.server).toBe("2.0.0-beta.4");
- expect(CLI_COMMAND_NAMES).toHaveLength(66);
- expect(new Set(CLI_COMMAND_NAMES).size).toBe(66);
+ expect(CLI_COMMAND_NAMES).toHaveLength(67);
+ expect(new Set(CLI_COMMAND_NAMES).size).toBe(67);
expect(CATALOG_IDENTITY.counts).toEqual({
- cli_commands: 66,
+ cli_commands: 67,
mcp_tools: TOOL_CONTRACTS.length,
mcp_prompts: 6,
mcp_resources: 2,
diff --git a/tests/toolContracts.test.ts b/tests/toolContracts.test.ts
index a304d0bbf..ac5a313b4 100644
--- a/tests/toolContracts.test.ts
+++ b/tests/toolContracts.test.ts
@@ -17,7 +17,7 @@ import { APPLICATION_TOOL_CONTRACTS } from "../src/contracts/applicationToolCont
describe("tool contract inventory", () => {
it("publishes the canonical analysis and session tool inventory", () => {
expect(OFFICIAL_TOOL_CONTRACTS).toHaveLength(36);
- expect(ENHANCED_TOOL_CONTRACTS).toHaveLength(12);
+ expect(ENHANCED_TOOL_CONTRACTS).toHaveLength(13);
expect(NATIVE_TOOL_CONTRACTS).toHaveLength(5);
expect(ARTIFACT_TOOL_CONTRACTS.map(({ name }) => name)).toEqual([
"inventory_artifact",