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",