mirror of
https://github.com/morluto/rea.git
synced 2026-10-12 02:19:24 +00:00
feat(native): inspect API boundaries with evidence
This commit is contained in:
@@ -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 |
|
||||
|
||||
+1
-1
@@ -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/الحدود الأصلية والتحقق منها، إسقاط رسم التطبيق، استيراد إعادة البناء من فك الترجمة، إعادة ربط الرموز، خطط ترابط وقت التشغيل ومقارنة الإصدارات |
|
||||
|
||||
+1
-1
@@ -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 再マッピング、ランタイム相関計画、バージョン比較 |
|
||||
|
||||
+1
-1
@@ -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 재매핑, 런타임 상관 계획, 버전 비교 |
|
||||
|
||||
+1
-1
@@ -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 重映射、运行时关联计划与跨版本比较 |
|
||||
|
||||
@@ -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<Address> 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<Address> 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<CodeBlock> 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<Address> 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<Address> 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();
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -102,6 +102,7 @@ const ENHANCED_REQUIREMENTS: Readonly<Record<string, readonly string[]>> = {
|
||||
"list_strings",
|
||||
],
|
||||
analyze_function: ["analyze_function"],
|
||||
inspect_native_api: ["analyze_function"],
|
||||
trace_feature: [
|
||||
"list_strings",
|
||||
"list_procedures",
|
||||
|
||||
@@ -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<boolean> => {
|
||||
try {
|
||||
await access(path);
|
||||
|
||||
@@ -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<Record<string, JsonValue>>,
|
||||
signal?: AbortSignal,
|
||||
@@ -189,6 +215,24 @@ export class EnhancedTools {
|
||||
return result.ok ? parseFunctionDossier(result.value) : result;
|
||||
}
|
||||
|
||||
async #inspectNativeApi(
|
||||
input: z.output<typeof enhancedInputSchemas.inspect_native_api>,
|
||||
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
|
||||
|
||||
@@ -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<NativeApiBoundary, { readonly available: true }>,
|
||||
): 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)];
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 extends FunctionWorkflowName>(
|
||||
name: Name,
|
||||
description: string,
|
||||
): ToolContract<Name> => {
|
||||
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[];
|
||||
@@ -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" },
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -89,6 +89,7 @@ export const TOOL_EFFECTS: Readonly<Record<string, ToolEffects>> = {
|
||||
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 }),
|
||||
|
||||
@@ -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<Record<string, z.ZodObject>> = {
|
||||
}),
|
||||
),
|
||||
analyze_function: functionDossierOutput,
|
||||
inspect_native_api: resultOf(nativeApiInspectionResultSchema),
|
||||
trace_feature: literalTraceOutput,
|
||||
find_code_for_string: literalTraceOutput,
|
||||
trace_call_path: callPathTraceOutput,
|
||||
|
||||
@@ -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<JsonValue, AnalysisOutputError> => {
|
||||
const parsed = functionDossierSchema.safeParse(value);
|
||||
return parsed.success
|
||||
? ok(parsed.data)
|
||||
? ok(jsonValueSchema.parse(parsed.data))
|
||||
: err(
|
||||
new AnalysisOutputError(
|
||||
"analyze_function",
|
||||
|
||||
@@ -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<typeof nativeApiBoundarySchema>;
|
||||
|
||||
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
|
||||
>;
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<BinarySessionPort["recordUnknown"]>
|
||||
| { 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: [],
|
||||
|
||||
@@ -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) +
|
||||
|
||||
@@ -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",
|
||||
|
||||
Vendored
+1
-1
@@ -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 },
|
||||
|
||||
Vendored
+89
@@ -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.",
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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"),
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -185,6 +185,7 @@ describe("canonical product catalog", () => {
|
||||
"xrefs",
|
||||
"trace",
|
||||
"function",
|
||||
"inspect-native-api",
|
||||
"search",
|
||||
]) {
|
||||
expect(cliCommandOptionNames(cli, name)).toContain("provider");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user