feat(native): inspect API boundaries with evidence

This commit is contained in:
morluto
2026-07-25 14:29:28 +00:00
parent 5184816199
commit 521139d4a5
39 changed files with 1382 additions and 63 deletions
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 重映射、运行时关联计划与跨版本比较 |
+477 -4
View File
@@ -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();
}
+9 -7
View File
@@ -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"
}
}
}
+64 -2
View File
@@ -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(
+45 -4
View File
@@ -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",
{
+5 -3
View File
@@ -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;
+1
View File
@@ -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",
+15 -3
View File
@@ -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);
+51 -7
View File
@@ -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
+113
View File
@@ -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)];
};
+50
View File
@@ -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,
+1
View File
@@ -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",
+12
View File
@@ -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[];
+1
View File
@@ -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" },
+2 -5
View File
@@ -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.",
+1
View File
@@ -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 }),
+2
View File
@@ -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,
+4 -2
View File
@@ -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",
+153
View File
@@ -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
>;
+1 -1
View File
@@ -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;
+35 -3
View File
@@ -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();
+22 -5
View File
@@ -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: [],
+24
View File
@@ -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) +
+20 -3
View File
@@ -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",
+1 -1
View File
@@ -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 },
+89
View File
@@ -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.",
+1 -1
View File
@@ -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 },
},
});
+63 -1
View File
@@ -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",
+1 -1
View File
@@ -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" },
+1 -1
View File
@@ -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" },
+62
View File
@@ -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"),
],
});
});
});
+1
View File
@@ -185,6 +185,7 @@ describe("canonical product catalog", () => {
"xrefs",
"trace",
"function",
"inspect-native-api",
"search",
]) {
expect(cliCommandOptionNames(cli, name)).toContain("provider");
+3 -3
View File
@@ -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,
+1 -1
View File
@@ -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",