Compare commits
28
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1caa0d6e54 | ||
|
|
17c8b4c2f1 | ||
|
|
58c749e831 | ||
|
|
8ec9bb1bfe | ||
|
|
7157ab0b70 | ||
|
|
fecddc0549 | ||
|
|
6d37efe8fa | ||
|
|
90bb1610f5 | ||
|
|
1a0931b630 | ||
|
|
40bf55b74c | ||
|
|
b2f7561bdd | ||
|
|
f2c4edd2bf | ||
|
|
4716fa2ece | ||
|
|
521139d4a5 | ||
|
|
5184816199 | ||
|
|
9522adf19a | ||
|
|
7a43420029 | ||
|
|
7856d460e9 | ||
|
|
a17c7ef27d | ||
|
|
0190bcbef1 | ||
|
|
fbe442b1e2 | ||
|
|
305e72995b | ||
|
|
c5e8871bda | ||
|
|
809dfbfe48 | ||
|
|
adc4852f3a | ||
|
|
674364443d | ||
|
|
d43a461958 | ||
|
|
119954c701 |
@@ -7,15 +7,17 @@ on:
|
||||
env:
|
||||
HUSKY: "0"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
id-token: write
|
||||
concurrency:
|
||||
group: release-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
release-please:
|
||||
name: Release Please
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
outputs:
|
||||
release_created: ${{ steps.release.outputs.release_created }}
|
||||
tag_name: ${{ steps.release.outputs.tag_name }}
|
||||
@@ -25,11 +27,61 @@ jobs:
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Check out release pull request
|
||||
if: steps.release.outputs.prs_created == 'true'
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ fromJSON(steps.release.outputs.pr).headBranchName }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Node.js for generated documentation
|
||||
if: steps.release.outputs.prs_created == 'true'
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Install dependencies
|
||||
if: steps.release.outputs.prs_created == 'true'
|
||||
run: npm ci
|
||||
|
||||
- name: Regenerate release documentation
|
||||
if: steps.release.outputs.prs_created == 'true'
|
||||
run: npm run docs:generate
|
||||
|
||||
- name: Commit canonical release catalog
|
||||
if: steps.release.outputs.prs_created == 'true'
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
RELEASE_PR_BRANCH: ${{ fromJSON(steps.release.outputs.pr).headBranchName }}
|
||||
run: |
|
||||
changed_paths="$(git diff --name-only)"
|
||||
if [[ -z "${changed_paths}" ]]; then
|
||||
echo "Release documentation is already canonical."
|
||||
exit 0
|
||||
fi
|
||||
if [[ "${changed_paths}" != "docs/product-catalog.json" ]]; then
|
||||
echo "docs:generate changed unexpected tracked paths:" >&2
|
||||
printf '%s\n' "${changed_paths}" >&2
|
||||
exit 1
|
||||
fi
|
||||
git diff --check
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git add -- docs/product-catalog.json
|
||||
git commit -m "chore: normalize release product catalog"
|
||||
gh auth setup-git
|
||||
git push origin "HEAD:${RELEASE_PR_BRANCH}"
|
||||
|
||||
publish:
|
||||
name: Publish to npm
|
||||
needs: release-please
|
||||
if: needs.release-please.outputs.release_created == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "2.5.0"
|
||||
".": "2.6.0"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,53 @@
|
||||
# Changelog
|
||||
|
||||
## [2.6.0](https://github.com/morluto/rea/compare/rea-agents-2.5.1...rea-agents-2.6.0) (2026-07-26)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* complete evidence-backed roadmap workflows ([1a0931b](https://github.com/morluto/rea/commit/1a0931b630587701d495d1634fbafe3638ad5516))
|
||||
* **hopper:** report bridge progress diagnostics ([7a43420](https://github.com/morluto/rea/commit/7a434200297df85c62c02bb103cf870d14f202f7))
|
||||
* **native:** inspect API boundaries with evidence ([521139d](https://github.com/morluto/rea/commit/521139d4a57a8ab2e25bb262d2a1fd09fd06be92))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **setup:** refresh stale local npx bootstrap ([6d37efe](https://github.com/morluto/rea/commit/6d37efe8fa1fb9b09c8eaa3a1ed6b6f63188ae22))
|
||||
* **setup:** refresh stale local npx bootstrap ([90bb161](https://github.com/morluto/rea/commit/90bb1610f56448846adf85b31d9ae96be12dfb4a))
|
||||
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* **mcp:** reduce cold-start work ([8ec9bb1](https://github.com/morluto/rea/commit/8ec9bb1bfea8bc57441bec72f3fcfbe93cf5d4f4))
|
||||
* **mcp:** reduce cold-start work ([fecddc0](https://github.com/morluto/rea/commit/fecddc0549a31e99a5ea72cbda84e448b1a961d5))
|
||||
|
||||
|
||||
### Tests
|
||||
|
||||
* add critical provider workflow coverage ([4716fa2](https://github.com/morluto/rea/commit/4716fa2ece954f5e53bdda9d1f33afad57d2e151))
|
||||
* **hopper:** remove brittle diagnostic source assertion ([40bf55b](https://github.com/morluto/rea/commit/40bf55b74ca6b2c90b3243a29018cb75879ba081))
|
||||
* **investigation:** prove multi-version fixture closure ([5184816](https://github.com/morluto/rea/commit/5184816199895ceff6a489810e12fba0c2c41eb7))
|
||||
* **mcp:** stabilize lazy-loading verification ([7157ab0](https://github.com/morluto/rea/commit/7157ab0b70833cc8237271e8625ccb7965c318c3))
|
||||
* **package:** prove artifact cleanup and identity ([9522adf](https://github.com/morluto/rea/commit/9522adf19ac915247e2c74b18d3ef5cab95befc5))
|
||||
|
||||
## [2.5.1](https://github.com/morluto/rea/compare/rea-agents-2.5.0...rea-agents-2.5.1) (2026-07-25)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **ci:** normalize generated release metadata ([d43a461](https://github.com/morluto/rea/commit/d43a461958c7bef56a61f75d7f7da73b43e7c387))
|
||||
* **ci:** normalize release pull request metadata ([119954c](https://github.com/morluto/rea/commit/119954c701533491ccbc4de649592e059f3f5d39))
|
||||
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* **browser:** lazy-load Playwright sessions ([305e729](https://github.com/morluto/rea/commit/305e72995bd52caee7ad8b42aae531b8924e7d0d))
|
||||
* **dotnet:** reuse authenticated artifact snapshots ([c5e8871](https://github.com/morluto/rea/commit/c5e8871bda737c52d6b09303f9c95e225bf845f2))
|
||||
* eliminate repeated work in analysis hot paths ([fbe442b](https://github.com/morluto/rea/commit/fbe442b1e2395b93d1bffa2c192ea5ac9a0fab34))
|
||||
* **evidence:** validate ledger mutations incrementally ([809dfbf](https://github.com/morluto/rea/commit/809dfbfe4868478bf62be40e5f9db35d1b52f6be))
|
||||
* **javascript:** reuse parsed source across analyses ([adc4852](https://github.com/morluto/rea/commit/adc4852f3aea5bd2847ce9efb53fe43ad030dde6))
|
||||
* **server:** cache advertised JSON schemas ([6743644](https://github.com/morluto/rea/commit/674364443dc12b89fe6444fdfb364f1b53086f90))
|
||||
|
||||
## [2.5.0](https://github.com/morluto/rea/compare/rea-agents-2.4.0...rea-agents-2.5.0) (2026-07-24)
|
||||
|
||||
|
||||
|
||||
@@ -103,6 +103,13 @@ detected agents, then asks which capabilities to set up: agent integration
|
||||
Nothing is preselected. Choosing agent integration opens a second empty
|
||||
checklist for the specific detected agents that should receive a registration.
|
||||
|
||||
If bare `npx` finds a project-local REA package, its dispatcher restarts setup
|
||||
through `rea-agents@latest` before planning any changes. This prevents an older
|
||||
local dependency from silently writing stale integration state. An already
|
||||
published older release cannot contain that dispatcher protection; run
|
||||
`npx -y rea-agents@latest setup` once to repair registrations created by one.
|
||||
Intentional rollbacks remain available through an exact package request.
|
||||
|
||||
REA keeps the journey inline so its history remains in the terminal. Selecting
|
||||
a capability does not select every detected target or authorize a change.
|
||||
Before anything changes, REA validates existing configuration, prints exact
|
||||
@@ -374,7 +381,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 |
|
||||
@@ -530,7 +537,7 @@ Setup detects Claude Code, Claude Desktop, Codex, Cursor, Gemini CLI, Windsurf,
|
||||
"mcpServers": {
|
||||
"rea": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "rea-agents@2.5.0", "mcp"]
|
||||
"args": ["-y", "rea-agents@2.6.0", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -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/الحدود الأصلية والتحقق منها، إسقاط رسم التطبيق، استيراد إعادة البناء من فك الترجمة، إعادة ربط الرموز، خطط ترابط وقت التشغيل ومقارنة الإصدارات |
|
||||
@@ -183,7 +183,7 @@ rea uninstall --purge-data
|
||||
"mcpServers": {
|
||||
"rea": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "rea-agents@2.5.0", "mcp"]
|
||||
"args": ["-y", "rea-agents@2.6.0", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -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 再マッピング、ランタイム相関計画、バージョン比較 |
|
||||
@@ -173,7 +173,7 @@ Setup は Claude Code、Claude Desktop、Codex、Cursor、Gemini CLI、Windsurf
|
||||
"mcpServers": {
|
||||
"rea": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "rea-agents@2.5.0", "mcp"]
|
||||
"args": ["-y", "rea-agents@2.6.0", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -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 재매핑, 런타임 상관 계획, 버전 비교 |
|
||||
@@ -173,7 +173,7 @@ Setup은 Claude Code, Claude Desktop, Codex, Cursor, Gemini CLI, Windsurf, Devin
|
||||
"mcpServers": {
|
||||
"rea": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "rea-agents@2.5.0", "mcp"]
|
||||
"args": ["-y", "rea-agents@2.6.0", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -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 重映射、运行时关联计划与跨版本比较 |
|
||||
@@ -173,7 +173,7 @@ Setup 会检测 Claude Code、Claude Desktop、Codex、Cursor、Gemini CLI、Win
|
||||
"mcpServers": {
|
||||
"rea": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "rea-agents@2.5.0", "mcp"]
|
||||
"args": ["-y", "rea-agents@2.6.0", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
+54
-4
@@ -1046,29 +1046,65 @@ def _serve_connection(connection):
|
||||
if not line or len(line) > MAX_LINE_BYTES:
|
||||
break
|
||||
request_id = None
|
||||
authenticated = False
|
||||
should_stop = False
|
||||
try:
|
||||
request = json.loads(line.decode("utf-8"))
|
||||
if set(request) != {"id", "token", "method", "params"}:
|
||||
raise ValueError("Invalid bridge request shape")
|
||||
request_id = request["id"]
|
||||
if isinstance(request_id, bool) or not isinstance(request_id, int) or request_id < 0:
|
||||
raise ValueError("Invalid bridge request id")
|
||||
if not isinstance(request["token"], str) or not hmac.compare_digest(request["token"], REA_TOKEN):
|
||||
raise PermissionError("Invalid bridge capability")
|
||||
authenticated = True
|
||||
_write_message(file, {"id": request_id, "event": {
|
||||
"type": "progress",
|
||||
"phase": "hopper_bridge",
|
||||
"completed": 0,
|
||||
"total": 1,
|
||||
"message": "Hopper bridge started request",
|
||||
}})
|
||||
result = _dispatch(request["method"], request["params"])
|
||||
should_stop = request["method"] == "shutdown_document" or (
|
||||
request["method"] == "shutdown" and not result.get("cleanup_required", False)
|
||||
)
|
||||
response = {"id": request_id, "result": _json_safe(result)}
|
||||
safe_result = _json_safe(result)
|
||||
_write_message(file, {"id": request_id, "event": {
|
||||
"type": "progress",
|
||||
"phase": "hopper_bridge",
|
||||
"completed": 1,
|
||||
"total": 1,
|
||||
"message": "Hopper bridge completed request",
|
||||
"terminal": True,
|
||||
}})
|
||||
response = {"id": request_id, "result": safe_result}
|
||||
except Exception as error:
|
||||
response = {"id": request_id if isinstance(request_id, int) else 0, "error": {"code": -32000, "message": str(error)[:512], "type": _diagnostic_type(error)}}
|
||||
file.write((json.dumps(response, separators=(",", ":")) + "\n").encode("utf-8"))
|
||||
file.flush()
|
||||
diagnostic = _safe_diagnostic(error)
|
||||
if authenticated:
|
||||
_write_message(file, {"id": request_id, "event": {
|
||||
"type": "diagnostic",
|
||||
"error": diagnostic,
|
||||
}})
|
||||
response_id = request_id if (
|
||||
isinstance(request_id, int)
|
||||
and not isinstance(request_id, bool)
|
||||
and request_id >= 0
|
||||
) else 0
|
||||
response = {"id": response_id, "error": diagnostic}
|
||||
_write_message(file, response)
|
||||
if should_stop:
|
||||
break
|
||||
file.close()
|
||||
connection.close()
|
||||
|
||||
|
||||
def _write_message(file, message):
|
||||
"""Write and flush one compact bridge message."""
|
||||
file.write((json.dumps(message, separators=(",", ":")) + "\n").encode("utf-8"))
|
||||
file.flush()
|
||||
|
||||
|
||||
def _diagnostic_type(error):
|
||||
if isinstance(error, CapabilityUnavailableError):
|
||||
return "capability_unavailable"
|
||||
@@ -1079,6 +1115,20 @@ def _diagnostic_type(error):
|
||||
return "bridge_exception"
|
||||
|
||||
|
||||
def _safe_diagnostic(error):
|
||||
"""Project one exception without retaining provider or credential text."""
|
||||
diagnostic_type = _diagnostic_type(error)
|
||||
if diagnostic_type == "capability_unavailable":
|
||||
message = str(error)[:512]
|
||||
elif diagnostic_type == "authorization":
|
||||
message = "Invalid bridge capability"
|
||||
elif diagnostic_type == "invalid_request":
|
||||
message = "Invalid Hopper bridge request"
|
||||
else:
|
||||
message = "%s: Hopper bridge operation failed" % type(error).__name__[:128]
|
||||
return {"code": -32000, "message": message, "type": diagnostic_type}
|
||||
|
||||
|
||||
def _run():
|
||||
"""Own a permission-restricted, single-client Unix socket for this bridge."""
|
||||
if os.path.exists(REA_SOCKET):
|
||||
|
||||
@@ -15,6 +15,24 @@ current package-runner invocation. REA still prints its own setup plan and asks
|
||||
for separate approval before changing agent configuration or installing a
|
||||
product-owned component.
|
||||
|
||||
When bare `npx` resolves `rea-agents` from the current project's
|
||||
`node_modules`, the selected dispatcher restarts setup through
|
||||
`rea-agents@latest` before REA plans or writes configuration. This protects
|
||||
current and future releases from silently persisting stale integration state.
|
||||
Code in an already-published older release cannot acquire that check
|
||||
retroactively; invoke `npx -y rea-agents@latest setup` once to repair any
|
||||
registration it created.
|
||||
|
||||
For an intentional rollback, make the package request explicit:
|
||||
|
||||
```bash
|
||||
npm exec --yes --package=rea-agents@2.4.0 -- rea setup
|
||||
```
|
||||
|
||||
Setup continues to pin persistent MCP registrations to the exact version that
|
||||
performed setup. Running current setup later migrates unversioned or older
|
||||
managed registrations through the normal reviewed setup transaction.
|
||||
|
||||
REA supports Node.js 22.19+ and 24.11+ (including newer releases). It uses the npm already paired with that runtime and never upgrades Node.js, npm, or Homebrew.
|
||||
|
||||
Running `npm install rea-agents` without `--global` installs the executable only
|
||||
@@ -145,6 +163,26 @@ host mount and the rest of `/tmp` remain unchanged; this fallback never invokes
|
||||
`sudo`. `rea doctor --provider hopper --detail full --json` reports the selected
|
||||
strategy and both host and effective mount facts.
|
||||
|
||||
### Hopper in CI
|
||||
|
||||
REA's unattended Linux path is validated against Hopper's offered demo mode.
|
||||
A paid license is not required for that path, and REA does not read, install, or
|
||||
automate license credentials. Licensed Hopper installations remain supported,
|
||||
but license activation is an operator-owned prerequisite rather than part of
|
||||
REA setup.
|
||||
|
||||
macOS requires Hopper's first-run UI to be completed in the same user session
|
||||
that will run REA: choose the demo mode or activate an existing license before
|
||||
starting an unattended job. Ephemeral macOS runners therefore need a
|
||||
pre-provisioned user session or a deliberate interactive bootstrap step.
|
||||
|
||||
When Hopper cannot start in CI, run
|
||||
`rea doctor --provider hopper --detail full --json` in the failing runner.
|
||||
Structured failures distinguish private-display dependencies, an unsupported
|
||||
demo dialog or build, process-ownership conflicts, and an early lifecycle exit.
|
||||
Apply the reported remediation rather than exposing the runner's desktop,
|
||||
copying license secrets into logs, or killing unrelated Hopper processes.
|
||||
|
||||
## Ghidra
|
||||
|
||||
REA's Ghidra provider is bring-your-own and supports Linux x64 with the exact
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"catalog_schema_version": 1,
|
||||
"package": {
|
||||
"name": "rea-agents",
|
||||
"version": "2.5.0",
|
||||
"version": "2.6.0",
|
||||
"sdk": {
|
||||
"server": "2.0.0-beta.4",
|
||||
"client": "2.0.0-beta.4",
|
||||
@@ -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": "5a88bf598a1e0fe705761db6af92f1ceabaf026cb6efa425e87b9633f1d7a3de",
|
||||
"prompts_sha256": "f6da3ad38a1000c11364d1710f122ac4990551f04efd8c53ffb2e819b5cf5707",
|
||||
"resources_sha256": "339a984ea8cd8be5f234adf24395bf0295258d990492005cf6fe1f293d12cd46",
|
||||
"combined_sha256": "e8cac51f2f17a5b9a088c3ff773473c4897823401481fc0875a28fe7daf8eee0"
|
||||
"combined_sha256": "861cd8618e793342daa8b149bc2944360d84b077fd19cf22f6e0a57b6b249c73"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"manifest_id": "ecm_ee8e5f2f4ea090052051d2994cbff148d9c796e1bc28b2fdc145aa5fa48cd248",
|
||||
"manifest_id": "ecm_24026e05aeb579814a3d44d04eb8914b1d4ce301d916536bc68a745418c5d892",
|
||||
"verifier": {
|
||||
"id": "rea-managed-conformance",
|
||||
"version": "1"
|
||||
@@ -14,7 +14,7 @@
|
||||
"skill_digests": [
|
||||
{
|
||||
"skill_id": "reverse-engineer-anything",
|
||||
"sha256": "6a910f067c946d545f5eb711eacbdf804839dbfb6b3b7b6ea91769be37ae47ce"
|
||||
"sha256": "9bb090837e251fc7ff1c1e621b77230f54c88650efb6520aff74d33877b023d8"
|
||||
}
|
||||
],
|
||||
"claims": [
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "rea-agents",
|
||||
"version": "2.5.0",
|
||||
"version": "2.6.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "rea-agents",
|
||||
"version": "2.5.0",
|
||||
"version": "2.6.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/parser": "8.0.4",
|
||||
|
||||
+6
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "rea-agents",
|
||||
"version": "2.5.0",
|
||||
"version": "2.6.0",
|
||||
"description": "Reverse engineer anything from your terminal or agent with one CLI and MCP server.",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
@@ -43,6 +43,7 @@
|
||||
"bridge/hopper_bridge.py",
|
||||
"bridge/ghidra/ReaGhidraBridge.java",
|
||||
"scripts/rea.mjs",
|
||||
"scripts/package-runner-bootstrap.mjs",
|
||||
"scripts/hopper-demo-x11.py",
|
||||
"scripts/prepack.mjs",
|
||||
"scripts/prepare.mjs",
|
||||
@@ -54,6 +55,8 @@
|
||||
"deps:check": "node scripts/check-dependency-install.mjs",
|
||||
"metadata:generate": "node scripts/generate-package-metadata.mjs",
|
||||
"metadata:check": "node scripts/generate-package-metadata.mjs --check",
|
||||
"mcp-catalog:generate": "npm run build:cached && node scripts/generate-mcp-tool-catalog.mjs",
|
||||
"mcp-catalog:check": "node scripts/generate-mcp-tool-catalog.mjs --check",
|
||||
"prebuild": "npm run deps:check && npm run metadata:check",
|
||||
"build": "tsc -p tsconfig.build.json",
|
||||
"precheck": "npm run deps:check",
|
||||
@@ -123,9 +126,9 @@
|
||||
"evidence:check": "npm run build:cached && node scripts/generate-completion-ledger.mjs --check",
|
||||
"docs:api": "typedoc",
|
||||
"docs:api:cached": "turbo run docs:api",
|
||||
"docs:generate": "npm run metadata:generate && npm run build:cached && node scripts/generate-skill-metadata.mjs && node scripts/generate-product-catalog.mjs && node scripts/generate-completion-ledger.mjs && npm run docs:api:cached && node scripts/generate-error-schema.mjs",
|
||||
"docs:generate": "npm run metadata:generate && npm run build:cached && node scripts/generate-mcp-tool-catalog.mjs && node scripts/generate-skill-metadata.mjs && node scripts/generate-product-catalog.mjs && node scripts/generate-completion-ledger.mjs && npm run docs:api:cached && node scripts/generate-error-schema.mjs",
|
||||
"docs:check": "turbo run docs:check:built",
|
||||
"docs:check:built": "node scripts/generate-package-metadata.mjs --check && node scripts/generate-skill-metadata.mjs --check && node scripts/generate-product-catalog.mjs --check && node scripts/generate-completion-ledger.mjs --check && node scripts/check-doc-facts.mjs && node scripts/generate-error-schema.mjs --check",
|
||||
"docs:check:built": "node scripts/generate-package-metadata.mjs --check && node scripts/generate-mcp-tool-catalog.mjs --check && node scripts/generate-skill-metadata.mjs --check && node scripts/generate-product-catalog.mjs --check && node scripts/generate-completion-ledger.mjs --check && node scripts/check-doc-facts.mjs && node scripts/generate-error-schema.mjs --check",
|
||||
"docs:error-schema": "npm run build:cached && node scripts/generate-error-schema.mjs",
|
||||
"prepack": "node scripts/prepack.mjs",
|
||||
"prepare": "node scripts/prepare.mjs"
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { format } from "prettier";
|
||||
|
||||
import { TOOL_CONTRACTS } from "../dist/contracts/toolContracts.js";
|
||||
import { MANAGED_WORKFLOW_TOOL_CONTRACTS } from "../dist/contracts/managedWorkflowToolContracts.js";
|
||||
import { ArtifactProvider } from "../dist/artifacts/ArtifactProvider.js";
|
||||
import { ManagedStaticProvider } from "../dist/dotnet/ManagedStaticProvider.js";
|
||||
import { NativeMacOSProvider } from "../dist/native/NativeMacOSProvider.js";
|
||||
import { toolRegistrationOptions } from "../dist/server/toolRegistrationOptions.js";
|
||||
import { ensureGeneratedFile } from "./lib/generated-file.mjs";
|
||||
|
||||
const arguments_ = new Set(process.argv.slice(2));
|
||||
for (const argument of arguments_)
|
||||
if (argument !== "--check")
|
||||
throw new Error(`Unknown MCP tool catalog option: ${argument}`);
|
||||
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const sessionToolNames = new Set([
|
||||
...TOOL_CONTRACTS.filter(({ kind }) => kind === "session").map(
|
||||
({ name }) => name,
|
||||
),
|
||||
...MANAGED_WORKFLOW_TOOL_CONTRACTS.map(({ name }) => name),
|
||||
]);
|
||||
const catalog = TOOL_CONTRACTS.map((contract) => {
|
||||
const options = toolRegistrationOptions(contract);
|
||||
return {
|
||||
name: contract.name,
|
||||
analysisOperation: [
|
||||
"official-proxy",
|
||||
"enhanced",
|
||||
"native-provider",
|
||||
"artifact-provider",
|
||||
"managed-provider",
|
||||
].includes(contract.kind)
|
||||
? contract.name
|
||||
: null,
|
||||
title: options.title,
|
||||
description: options.description,
|
||||
kind: contract.kind,
|
||||
requiresSession: sessionToolNames.has(contract.name),
|
||||
inputSchema: options.inputSchema["~standard"].jsonSchema.input({
|
||||
target: "draft-2020-12",
|
||||
}),
|
||||
outputSchema: options.outputSchema["~standard"].jsonSchema.output({
|
||||
target: "draft-2020-12",
|
||||
}),
|
||||
annotations: options.annotations,
|
||||
effects: contract.effects,
|
||||
};
|
||||
});
|
||||
const auxiliaryProviders = [
|
||||
new ArtifactProvider(false, false),
|
||||
new NativeMacOSProvider(),
|
||||
new ManagedStaticProvider(),
|
||||
].map((provider) => ({
|
||||
identity: provider.identity(),
|
||||
capabilities: provider.capabilities(),
|
||||
}));
|
||||
const payloadJson = JSON.stringify({ catalog, auxiliaryProviders });
|
||||
const source = await format(
|
||||
`import type { ToolAnnotations } from "@modelcontextprotocol/server";
|
||||
import type {
|
||||
CapabilityDescriptor,
|
||||
ProviderIdentity,
|
||||
} from "./application/AnalysisProvider.js";
|
||||
import type { ToolKind } from "./contracts/toolContractTypes.js";
|
||||
import type { ToolEffects } from "./contracts/toolEffects.js";
|
||||
|
||||
interface GeneratedTool {
|
||||
readonly name: string;
|
||||
readonly analysisOperation: CapabilityDescriptor["operation"] | null;
|
||||
readonly title: string;
|
||||
readonly description: string;
|
||||
readonly kind: ToolKind;
|
||||
readonly requiresSession: boolean;
|
||||
readonly inputSchema: Readonly<Record<string, unknown>>;
|
||||
readonly outputSchema: Readonly<Record<string, unknown>>;
|
||||
readonly annotations: ToolAnnotations;
|
||||
readonly effects: ToolEffects;
|
||||
}
|
||||
|
||||
interface GeneratedProvider {
|
||||
readonly identity: ProviderIdentity;
|
||||
readonly capabilities: readonly CapabilityDescriptor[];
|
||||
}
|
||||
|
||||
interface GeneratedPayload {
|
||||
readonly catalog: readonly GeneratedTool[];
|
||||
readonly auxiliaryProviders: readonly GeneratedProvider[];
|
||||
}
|
||||
|
||||
const GENERATED_PAYLOAD_JSON = ${JSON.stringify(payloadJson)};
|
||||
const generatedPayload: unknown = JSON.parse(GENERATED_PAYLOAD_JSON);
|
||||
if (!isGeneratedPayload(generatedPayload))
|
||||
throw new TypeError("Generated MCP catalog payload is invalid");
|
||||
|
||||
/** Generated from TOOL_CONTRACTS; do not edit. */
|
||||
export const GENERATED_MCP_TOOL_CATALOG = generatedPayload.catalog;
|
||||
|
||||
/** Generated lightweight metadata for lazily loaded auxiliary providers. */
|
||||
export const GENERATED_AUXILIARY_PROVIDERS = generatedPayload.auxiliaryProviders;
|
||||
|
||||
function isGeneratedPayload(value: unknown): value is GeneratedPayload {
|
||||
return (
|
||||
isRecord(value) &&
|
||||
Array.isArray(value.catalog) &&
|
||||
value.catalog.every(isGeneratedTool) &&
|
||||
Array.isArray(value.auxiliaryProviders) &&
|
||||
value.auxiliaryProviders.every(isGeneratedProvider)
|
||||
);
|
||||
}
|
||||
|
||||
function isGeneratedTool(value: unknown): value is GeneratedTool {
|
||||
return (
|
||||
isRecord(value) &&
|
||||
typeof value.name === "string" &&
|
||||
(typeof value.analysisOperation === "string" ||
|
||||
value.analysisOperation === null) &&
|
||||
typeof value.title === "string" &&
|
||||
typeof value.description === "string" &&
|
||||
typeof value.kind === "string" &&
|
||||
typeof value.requiresSession === "boolean" &&
|
||||
isRecord(value.inputSchema) &&
|
||||
isRecord(value.outputSchema) &&
|
||||
isRecord(value.annotations) &&
|
||||
isRecord(value.effects)
|
||||
);
|
||||
}
|
||||
|
||||
function isGeneratedProvider(value: unknown): value is GeneratedProvider {
|
||||
return (
|
||||
isRecord(value) &&
|
||||
isRecord(value.identity) &&
|
||||
Array.isArray(value.capabilities)
|
||||
);
|
||||
}
|
||||
|
||||
function isRecord(
|
||||
value: unknown,
|
||||
): value is Readonly<Record<string, unknown>> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
`,
|
||||
{ parser: "typescript" },
|
||||
);
|
||||
|
||||
await ensureGeneratedFile({
|
||||
path: join(root, "src/generatedMcpToolCatalog.ts"),
|
||||
source,
|
||||
check: arguments_.has("--check"),
|
||||
generateCommand: "npm run mcp-catalog:generate",
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
interface McpStartupPolicy {
|
||||
readonly initializeBudgetMs: number;
|
||||
readonly toolsListBudgetMs: number;
|
||||
readonly firstCatalogBudgetMs: number;
|
||||
readonly doctorDeadlineMs: number;
|
||||
}
|
||||
|
||||
interface McpProcessOptions {
|
||||
readonly command: string;
|
||||
readonly args: readonly string[];
|
||||
readonly environment: Readonly<Record<string, string | undefined>>;
|
||||
readonly policy: McpStartupPolicy;
|
||||
}
|
||||
|
||||
interface McpModuleProfileOptions extends McpProcessOptions {
|
||||
readonly packageName: string;
|
||||
}
|
||||
|
||||
interface RssMeasurement {
|
||||
readonly supported: boolean;
|
||||
readonly method: string | null;
|
||||
readonly peak_bytes: number | null;
|
||||
}
|
||||
|
||||
/** Measure the first usable MCP catalog through a child-process boundary. */
|
||||
export function measureMcpStartup(options: McpProcessOptions): Promise<{
|
||||
readonly process_started_to_initialized_ms: number;
|
||||
readonly initialized_to_tools_list_ms: number;
|
||||
readonly process_started_to_tools_list_ms: number;
|
||||
readonly listed_tool_count: number;
|
||||
readonly rss: RssMeasurement;
|
||||
}>;
|
||||
|
||||
/** Profile ESM file loading in a separate diagnostic MCP child. */
|
||||
export function profileMcpModuleLoading(
|
||||
options: McpModuleProfileOptions,
|
||||
): Promise<{
|
||||
readonly supported: true;
|
||||
readonly method: "node-debug-esm";
|
||||
readonly unique_file_modules: number;
|
||||
readonly unique_package_modules: number;
|
||||
readonly unique_dependency_modules: number;
|
||||
readonly debug_lines: number;
|
||||
readonly truncated: boolean;
|
||||
}>;
|
||||
|
||||
/** Parse Linux procfs VmRSS text into bytes. */
|
||||
export function parseLinuxVmRssBytes(status: string): number | null;
|
||||
|
||||
/** Collect unique file-backed ESM module URLs from Node diagnostics. */
|
||||
export function createEsmModuleProfileCollector(packageName?: string): {
|
||||
append(chunk: string): void;
|
||||
result(): {
|
||||
readonly supported: true;
|
||||
readonly method: "node-debug-esm";
|
||||
readonly unique_file_modules: number;
|
||||
readonly unique_package_modules: number;
|
||||
readonly unique_dependency_modules: number;
|
||||
readonly debug_lines: number;
|
||||
readonly truncated: boolean;
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,217 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { performance } from "node:perf_hooks";
|
||||
|
||||
import { Client } from "@modelcontextprotocol/client";
|
||||
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
|
||||
|
||||
const RSS_SAMPLE_INTERVAL_MS = 10;
|
||||
const MAXIMUM_PROFILE_BUFFER_BYTES = 2 * 1_024 * 1_024;
|
||||
const MAXIMUM_PROFILE_MODULES = 10_000;
|
||||
|
||||
/**
|
||||
* Measure the first usable MCP catalog through the installed process boundary.
|
||||
*/
|
||||
export async function measureMcpStartup({
|
||||
command,
|
||||
args,
|
||||
environment,
|
||||
policy,
|
||||
}) {
|
||||
const transport = new StdioClientTransport({
|
||||
command,
|
||||
args,
|
||||
env: environment,
|
||||
stderr: "pipe",
|
||||
});
|
||||
let stderr = "";
|
||||
transport.stderr?.on("data", (chunk) => {
|
||||
stderr = boundedAppend(stderr, chunk.toString());
|
||||
});
|
||||
const rss = createLinuxRssSampler(() => transport.pid);
|
||||
const client = new Client({
|
||||
name: "package-startup-probe",
|
||||
version: "1.0.0",
|
||||
});
|
||||
const startedAt = performance.now();
|
||||
rss.start();
|
||||
try {
|
||||
await client.connect(transport, { timeout: policy.initializeBudgetMs });
|
||||
const initializedAt = performance.now();
|
||||
const tools = await client.listTools(undefined, {
|
||||
timeout: policy.toolsListBudgetMs,
|
||||
});
|
||||
const listedAt = performance.now();
|
||||
rss.stop();
|
||||
const measurement = {
|
||||
process_started_to_initialized_ms: elapsed(startedAt, initializedAt),
|
||||
initialized_to_tools_list_ms: elapsed(initializedAt, listedAt),
|
||||
process_started_to_tools_list_ms: elapsed(startedAt, listedAt),
|
||||
listed_tool_count: tools.tools.length,
|
||||
rss: rss.result(),
|
||||
};
|
||||
assertStartupBudgets(measurement, policy);
|
||||
return measurement;
|
||||
} catch (cause) {
|
||||
throw new Error(`packaged MCP startup probe failed: ${stderr}`, { cause });
|
||||
} finally {
|
||||
rss.stop();
|
||||
await Promise.allSettled([client.close(), transport.close()]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Profile ESM file loading in a separate diagnostic child.
|
||||
*/
|
||||
export async function profileMcpModuleLoading({
|
||||
command,
|
||||
args,
|
||||
environment,
|
||||
policy,
|
||||
packageName,
|
||||
}) {
|
||||
const profile = createEsmModuleProfileCollector(packageName);
|
||||
const transport = new StdioClientTransport({
|
||||
command,
|
||||
args,
|
||||
env: { ...environment, NODE_DEBUG: "esm" },
|
||||
stderr: "pipe",
|
||||
});
|
||||
transport.stderr?.on("data", (chunk) => profile.append(chunk.toString()));
|
||||
const client = new Client({
|
||||
name: "package-module-profile",
|
||||
version: "1.0.0",
|
||||
});
|
||||
try {
|
||||
await client.connect(transport, {
|
||||
timeout: policy.doctorDeadlineMs,
|
||||
});
|
||||
await client.listTools(undefined, {
|
||||
timeout: policy.toolsListBudgetMs,
|
||||
});
|
||||
return profile.result();
|
||||
} finally {
|
||||
await Promise.allSettled([client.close(), transport.close()]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Linux procfs VmRSS text into bytes.
|
||||
*/
|
||||
export const parseLinuxVmRssBytes = (status) => {
|
||||
const match = /^VmRSS:\s+(\d+)\s+kB$/mu.exec(status);
|
||||
return match === null ? null : Number(match[1]) * 1_024;
|
||||
};
|
||||
|
||||
/**
|
||||
* Collect unique file-backed ESM module URLs from Node's diagnostic stream.
|
||||
*/
|
||||
export const createEsmModuleProfileCollector = (packageName = "rea-agents") => {
|
||||
const modules = new Set();
|
||||
let buffer = "";
|
||||
let debugLines = 0;
|
||||
let truncated = false;
|
||||
const consume = (line) => {
|
||||
if (line.startsWith("ESM ")) debugLines += 1;
|
||||
for (const match of line.matchAll(/file:\/\/\/[^\s'",}\])]+/gu)) {
|
||||
if (modules.size >= MAXIMUM_PROFILE_MODULES) {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
modules.add(match[0]);
|
||||
}
|
||||
};
|
||||
return {
|
||||
append(chunk) {
|
||||
buffer += chunk;
|
||||
if (Buffer.byteLength(buffer) > MAXIMUM_PROFILE_BUFFER_BYTES) {
|
||||
buffer = buffer.slice(-MAXIMUM_PROFILE_BUFFER_BYTES);
|
||||
truncated = true;
|
||||
}
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() ?? "";
|
||||
for (const line of lines) consume(line);
|
||||
},
|
||||
result() {
|
||||
if (buffer.length > 0) consume(buffer);
|
||||
const values = [...modules];
|
||||
const packageSegment = `/node_modules/${packageName}/`;
|
||||
return {
|
||||
supported: true,
|
||||
method: "node-debug-esm",
|
||||
unique_file_modules: values.length,
|
||||
unique_package_modules: values.filter((value) =>
|
||||
value.includes(packageSegment),
|
||||
).length,
|
||||
unique_dependency_modules: values.filter(
|
||||
(value) =>
|
||||
value.includes("/node_modules/") && !value.includes(packageSegment),
|
||||
).length,
|
||||
debug_lines: debugLines,
|
||||
truncated,
|
||||
};
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const createLinuxRssSampler = (pid) => {
|
||||
if (process.platform !== "linux")
|
||||
return {
|
||||
start() {},
|
||||
stop() {},
|
||||
result: () => ({
|
||||
supported: false,
|
||||
method: null,
|
||||
peak_bytes: null,
|
||||
}),
|
||||
};
|
||||
let peakBytes = null;
|
||||
let timer;
|
||||
const sample = () => {
|
||||
const currentPid = pid();
|
||||
if (currentPid === null) return;
|
||||
try {
|
||||
const current = parseLinuxVmRssBytes(
|
||||
readFileSync(`/proc/${String(currentPid)}/status`, "utf8"),
|
||||
);
|
||||
if (current !== null && (peakBytes === null || current > peakBytes))
|
||||
peakBytes = current;
|
||||
} catch {
|
||||
// The short-lived child may exit between resolving its PID and reading.
|
||||
}
|
||||
};
|
||||
return {
|
||||
start() {
|
||||
sample();
|
||||
timer = setInterval(sample, RSS_SAMPLE_INTERVAL_MS);
|
||||
timer.unref();
|
||||
},
|
||||
stop() {
|
||||
if (timer !== undefined) clearInterval(timer);
|
||||
sample();
|
||||
},
|
||||
result: () => ({
|
||||
supported: true,
|
||||
method: "linux-proc-status-vmrss-sampled",
|
||||
peak_bytes: peakBytes,
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
const assertStartupBudgets = (measurement, policy) => {
|
||||
if (measurement.process_started_to_initialized_ms > policy.initializeBudgetMs)
|
||||
throw new Error("packaged MCP exceeded the initialize regression budget");
|
||||
if (measurement.initialized_to_tools_list_ms > policy.toolsListBudgetMs)
|
||||
throw new Error("packaged MCP exceeded the tools/list regression budget");
|
||||
if (
|
||||
measurement.process_started_to_tools_list_ms > policy.firstCatalogBudgetMs
|
||||
)
|
||||
throw new Error(
|
||||
"packaged MCP exceeded the first-catalog regression budget",
|
||||
);
|
||||
};
|
||||
|
||||
const elapsed = (startedAt, completedAt) =>
|
||||
Math.round((completedAt - startedAt) * 100) / 100;
|
||||
|
||||
const boundedAppend = (current, chunk) =>
|
||||
`${current}${chunk}`.slice(-64 * 1_024);
|
||||
@@ -1,5 +1,60 @@
|
||||
import { requireFunctionDossierOracle } from "../../dist/application/RealHopperAssertions.js";
|
||||
|
||||
/** Require truthful tri-state permissions from real Hopper memory regions. */
|
||||
export const requireTruthfulMemoryRegions = (segments) => {
|
||||
if (!Array.isArray(segments) || segments.length === 0) {
|
||||
throw new Error("list_segments returned no real memory regions");
|
||||
}
|
||||
const regions = segments.flatMap((segment) => [
|
||||
segment,
|
||||
...(Array.isArray(segment.sections) ? segment.sections : []),
|
||||
]);
|
||||
for (const region of regions) {
|
||||
for (const permission of ["readable", "writable", "executable"]) {
|
||||
if (![true, false, null].includes(region?.[permission])) {
|
||||
throw new Error(`list_segments omitted tri-state ${permission}`);
|
||||
}
|
||||
}
|
||||
if (
|
||||
region.provenance !== "hopper-public-python-api" ||
|
||||
region.permissions?.available !== false ||
|
||||
typeof region.permissions.reason !== "string"
|
||||
) {
|
||||
throw new Error("list_segments omitted permission provenance");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/** Require correlated start and terminal progress from the real bridge. */
|
||||
export function requireBridgeProgress(updates) {
|
||||
const messages = updates.map(({ message }) => message);
|
||||
if (
|
||||
!messages.includes("Hopper bridge started request") ||
|
||||
!messages.includes("Hopper bridge completed request")
|
||||
)
|
||||
throw new Error(
|
||||
`The MCP client did not observe correlated Hopper bridge progress: ${JSON.stringify(updates)}`,
|
||||
);
|
||||
}
|
||||
|
||||
/** Require one sanitized invalid-request diagnostic from the real bridge. */
|
||||
export function requireBridgeDiagnostic(chunks) {
|
||||
const diagnostics = chunks
|
||||
.join("")
|
||||
.split("\n")
|
||||
.filter((line) => line.trim().length > 0)
|
||||
.map((line) => JSON.parse(line));
|
||||
if (
|
||||
!diagnostics.some(
|
||||
(diagnostic) =>
|
||||
diagnostic.type === "bridge-diagnostic" &&
|
||||
diagnostic.category === "invalid_request" &&
|
||||
diagnostic.message === "Invalid Hopper bridge request",
|
||||
)
|
||||
)
|
||||
throw new Error("The MCP runtime omitted the correlated Hopper diagnostic");
|
||||
}
|
||||
|
||||
/** Verify named positive semantics from the source-owned C fixture. */
|
||||
export async function verifyRealHopperFixture({
|
||||
client,
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
export interface PackageRunnerSetupInput {
|
||||
readonly args: readonly string[];
|
||||
readonly environment: Readonly<Record<string, string | undefined>>;
|
||||
readonly packageRoot: string;
|
||||
readonly packageName: string;
|
||||
}
|
||||
|
||||
export interface PackageRunnerSetupPlan {
|
||||
readonly command: "npm";
|
||||
readonly args: readonly string[];
|
||||
}
|
||||
|
||||
export function packageRunnerSetupPlan(
|
||||
input: PackageRunnerSetupInput,
|
||||
): PackageRunnerSetupPlan | undefined;
|
||||
|
||||
export function runPackageRunnerSetupBootstrap(
|
||||
input: PackageRunnerSetupInput,
|
||||
): Promise<number | undefined>;
|
||||
@@ -0,0 +1,72 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const BOOTSTRAP_MARKER = "REA_PACKAGE_RUNNER_BOOTSTRAPPED";
|
||||
|
||||
/**
|
||||
* Plan a current-release setup bootstrap when bare npx selected a project-local
|
||||
* package. Explicit `npm exec --package=<name>@<version>` rollbacks bypass it.
|
||||
*/
|
||||
export function packageRunnerSetupPlan(input) {
|
||||
if (input.args[0] !== "setup") return undefined;
|
||||
if (input.environment[BOOTSTRAP_MARKER] === "1") return undefined;
|
||||
if (input.environment.npm_lifecycle_event !== "npx") return undefined;
|
||||
|
||||
const localPrefix = input.environment.npm_config_local_prefix;
|
||||
if (localPrefix === undefined || localPrefix.length === 0) return undefined;
|
||||
if (
|
||||
!isPackageBelowLocalPrefix(
|
||||
input.packageRoot,
|
||||
localPrefix,
|
||||
input.packageName,
|
||||
)
|
||||
)
|
||||
return undefined;
|
||||
|
||||
const requestedPackage = input.environment.npm_config_package;
|
||||
if (
|
||||
requestedPackage !== undefined &&
|
||||
isExactPackageVersion(requestedPackage, input.packageName)
|
||||
)
|
||||
return undefined;
|
||||
|
||||
return {
|
||||
command: "npm",
|
||||
args: [
|
||||
"exec",
|
||||
"--yes",
|
||||
`--package=${input.packageName}@latest`,
|
||||
"--",
|
||||
"rea",
|
||||
...input.args,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/** Execute a planned package-runner bootstrap and return its exit status. */
|
||||
export async function runPackageRunnerSetupBootstrap(input) {
|
||||
const plan = packageRunnerSetupPlan(input);
|
||||
if (plan === undefined) return undefined;
|
||||
|
||||
return await new Promise((resolveExitCode) => {
|
||||
const child = spawn(plan.command, plan.args, {
|
||||
stdio: "inherit",
|
||||
env: { ...input.environment, [BOOTSTRAP_MARKER]: "1" },
|
||||
});
|
||||
child.once("error", () => resolveExitCode(1));
|
||||
child.once("exit", (code) => resolveExitCode(code ?? 1));
|
||||
});
|
||||
}
|
||||
|
||||
function isPackageBelowLocalPrefix(packageRoot, localPrefix, packageName) {
|
||||
const expectedRoot = resolve(localPrefix, "node_modules", packageName);
|
||||
return resolve(packageRoot) === expectedRoot;
|
||||
}
|
||||
|
||||
function isExactPackageVersion(packageSpecifier, packageName) {
|
||||
const version = packageSpecifier.slice(`${packageName}@`.length);
|
||||
return (
|
||||
packageSpecifier.startsWith(`${packageName}@`) &&
|
||||
/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/u.test(version)
|
||||
);
|
||||
}
|
||||
+11
-1
@@ -4,6 +4,8 @@ import { access } from "node:fs/promises";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { runPackageRunnerSetupBootstrap } from "./package-runner-bootstrap.mjs";
|
||||
|
||||
// Route production MCP before importing Incur. Incur owns registration helpers
|
||||
// such as `mcp add`, while only dist/main.js may serve the stdio tool catalog.
|
||||
const args = process.argv.slice(2);
|
||||
@@ -15,13 +17,21 @@ const isMcpMode =
|
||||
args.length === 1 && (args[0] === "--mcp" || args[0] === "mcp");
|
||||
const isMcpDoctorMode = args[0] === "mcp" && args[1] === "doctor";
|
||||
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const bootstrapExitCode = await runPackageRunnerSetupBootstrap({
|
||||
args,
|
||||
environment: process.env,
|
||||
packageRoot,
|
||||
packageName: packageJson.name,
|
||||
});
|
||||
const runtimeFiles = isMcpMode
|
||||
? ["dist/main.js"]
|
||||
: isMcpDoctorMode
|
||||
? ["dist/main.js", "dist/mcpDoctor.js"]
|
||||
: ["dist/cli.js", "dist/cliOutput.js"];
|
||||
|
||||
if (!(await compiledRuntimeExists(runtimeFiles))) {
|
||||
if (bootstrapExitCode !== undefined) {
|
||||
process.exitCode = bootstrapExitCode;
|
||||
} else if (!(await compiledRuntimeExists(runtimeFiles))) {
|
||||
process.stderr.write(
|
||||
`REA's compiled runtime is missing. Run \`npm ci\` in ${packageRoot} to install dependencies and build REA, then restart it. If this is an installed package, reinstall rea-agents.\n`,
|
||||
);
|
||||
|
||||
@@ -8,6 +8,7 @@ import { fileURLToPath } from "node:url";
|
||||
import { createPackage } from "@electron/asar";
|
||||
|
||||
import { evaluateCodexEvents } from "../dist/evaluation/CodexAgentEval.js";
|
||||
import { MCP_STARTUP_POLICY } from "../dist/mcpStartupPolicy.js";
|
||||
import { completeVerifierRun, createVerifierRun } from "./lib/verifier-run.mjs";
|
||||
|
||||
const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
@@ -346,7 +347,7 @@ async function runCodex(prompt, investigationRoot) {
|
||||
"-c",
|
||||
`mcp_servers.rea.args=${JSON.stringify([join(repositoryRoot, "scripts/rea.mjs"), "mcp"])}`,
|
||||
"-c",
|
||||
"mcp_servers.rea.startup_timeout_sec=30",
|
||||
`mcp_servers.rea.startup_timeout_sec=${String(MCP_STARTUP_POLICY.codexStartupTimeoutSeconds)}`,
|
||||
"-c",
|
||||
mcpEnvironmentOverride,
|
||||
...(optionalModel === undefined ? [] : ["--model", optionalModel]),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
import { join } from "node:path";
|
||||
import { TextReader, Uint8ArrayWriter, ZipWriter } from "@zip.js/zip.js";
|
||||
|
||||
@@ -21,12 +22,30 @@ export async function verifyPackageArtifactAndElectron({
|
||||
environment,
|
||||
),
|
||||
);
|
||||
const repeatedArtifactInventory = json(
|
||||
await run(
|
||||
cli,
|
||||
["inventory-artifact", artifactArchive, "--limit", "500", "--json"],
|
||||
environment,
|
||||
),
|
||||
);
|
||||
if (
|
||||
artifactInventory.operation !== "inventory_artifact" ||
|
||||
artifactInventory.provider?.id !== "rea-artifact-graph" ||
|
||||
artifactInventory.normalized_result?.manifest?.root_format !== "zip"
|
||||
artifactInventory.normalized_result?.manifest?.root_format !== "zip" ||
|
||||
!sameArtifactIdentity(
|
||||
artifactInventory.normalized_result,
|
||||
repeatedArtifactInventory.normalized_result,
|
||||
)
|
||||
)
|
||||
throw new Error("packaged artifact inventory CLI failed");
|
||||
await verifyPackagedArtifactExtraction({
|
||||
artifactArchive,
|
||||
artifactInventory,
|
||||
cli,
|
||||
environment,
|
||||
workspace,
|
||||
});
|
||||
const applicationRoot = join(workspace, "electron-app");
|
||||
await mkdir(applicationRoot);
|
||||
await writeFile(
|
||||
@@ -73,6 +92,51 @@ export async function verifyPackageArtifactAndElectron({
|
||||
return { artifactArchive };
|
||||
}
|
||||
|
||||
const sameArtifactIdentity = (left, right) =>
|
||||
isDeepStrictEqual(left?.manifest, right?.manifest) &&
|
||||
isDeepStrictEqual(left?.nodes?.items, right?.nodes?.items) &&
|
||||
isDeepStrictEqual(left?.occurrences?.items, right?.occurrences?.items) &&
|
||||
isDeepStrictEqual(left?.edges?.items, right?.edges?.items);
|
||||
|
||||
const verifyPackagedArtifactExtraction = async ({
|
||||
artifactArchive,
|
||||
artifactInventory,
|
||||
cli,
|
||||
environment,
|
||||
workspace,
|
||||
}) => {
|
||||
const occurrence =
|
||||
artifactInventory.normalized_result?.occurrences?.items?.find(
|
||||
({ logical_path: path }) => path === "app/main.js",
|
||||
);
|
||||
if (occurrence?.occurrence_id === undefined)
|
||||
throw new Error("packaged artifact inventory omitted the selected member");
|
||||
const outputRoot = join(workspace, "artifact-extraction");
|
||||
const extraction = json(
|
||||
await run(
|
||||
cli,
|
||||
[
|
||||
"extract-artifact",
|
||||
artifactArchive,
|
||||
outputRoot,
|
||||
"--occurrence-ids",
|
||||
occurrence.occurrence_id,
|
||||
"--json",
|
||||
],
|
||||
environment,
|
||||
),
|
||||
);
|
||||
if (
|
||||
extraction.operation !== "extract_artifact" ||
|
||||
extraction.provider?.id !== "rea-artifact-graph" ||
|
||||
extraction.normalized_result?.containment_verified !== true ||
|
||||
extraction.normalized_result?.artifacts?.items?.[0]?.relative_path !==
|
||||
"app/main.js" ||
|
||||
(await readFile(join(outputRoot, "app/main.js"), "utf8")) !== "main();"
|
||||
)
|
||||
throw new Error("packaged artifact extraction CLI failed");
|
||||
};
|
||||
|
||||
const assertRoutedApplicationAnalysis = (analysis) => {
|
||||
if (
|
||||
analysis.operation !== "analyze_javascript_application" ||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { promisify } from "node:util";
|
||||
|
||||
import { Client } from "@modelcontextprotocol/client";
|
||||
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
|
||||
import { MCP_STARTUP_POLICY } from "../dist/mcpStartupPolicy.js";
|
||||
import * as prompts from "./verify-package-prompts.mjs";
|
||||
import {
|
||||
json,
|
||||
@@ -180,7 +181,7 @@ export async function verifyPackageMcp({
|
||||
(
|
||||
await execute(cli, ["mcp", "doctor", "--json"], {
|
||||
env: environment,
|
||||
timeout: 30_000,
|
||||
timeout: MCP_STARTUP_POLICY.doctorDeadlineMs,
|
||||
})
|
||||
).stdout,
|
||||
);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { join } from "node:path";
|
||||
|
||||
import { TOOL_CONTRACTS } from "../dist/contracts/toolContracts.js";
|
||||
import { PRODUCT_IDENTITY } from "../dist/identity.js";
|
||||
import { MCP_STARTUP_POLICY } from "../dist/mcpStartupPolicy.js";
|
||||
import * as prompts from "./verify-package-prompts.mjs";
|
||||
import { verifyPackagedInvestigation } from "./verify-package-investigation.mjs";
|
||||
import { verifyPackageArtifactAndElectron } from "./verify-package-artifact.mjs";
|
||||
@@ -19,6 +20,10 @@ import { verifyPackageSetup } from "./verify-package-setup.mjs";
|
||||
import { verifyManaged } from "./verify-package-managed.mjs";
|
||||
import { verifyUnknownProvider } from "./verify-package-unknown-provider.mjs";
|
||||
import { completeVerifierRun, createVerifierRun } from "./lib/verifier-run.mjs";
|
||||
import {
|
||||
measureMcpStartup,
|
||||
profileMcpModuleLoading,
|
||||
} from "./lib/mcp-startup-probe.mjs";
|
||||
|
||||
const root = process.cwd();
|
||||
const verifierRun = createVerifierRun();
|
||||
@@ -26,6 +31,8 @@ const workspace = await mkdtemp(join(tmpdir(), "rea-package-"));
|
||||
const evidenceRoot = join(workspace, "evidence");
|
||||
const referenceRoot = join(workspace, "reference-source");
|
||||
let tarball;
|
||||
let mcpStartup;
|
||||
let mcpModuleLoading;
|
||||
|
||||
try {
|
||||
({ tarball } = await verifyPackagePack({ root, workspace }));
|
||||
@@ -42,6 +49,25 @@ try {
|
||||
workspace,
|
||||
environment: environmentData.environment,
|
||||
});
|
||||
mcpStartup = await measureMcpStartup({
|
||||
command: cli,
|
||||
args: ["mcp"],
|
||||
environment: {
|
||||
...environmentData.environment,
|
||||
REA_INVESTIGATION_INPUT_ROOTS_JSON: JSON.stringify([]),
|
||||
},
|
||||
policy: MCP_STARTUP_POLICY,
|
||||
});
|
||||
mcpModuleLoading = await profileMcpModuleLoading({
|
||||
command: cli,
|
||||
args: ["mcp"],
|
||||
environment: {
|
||||
...environmentData.environment,
|
||||
REA_INVESTIGATION_INPUT_ROOTS_JSON: JSON.stringify([]),
|
||||
},
|
||||
policy: MCP_STARTUP_POLICY,
|
||||
packageName: PRODUCT_IDENTITY.packageName,
|
||||
});
|
||||
const { supportedSetupHost } = await verifyPackageDiscovery({
|
||||
cli,
|
||||
environment: environmentData.environment,
|
||||
@@ -102,7 +128,7 @@ try {
|
||||
investigationReplay,
|
||||
});
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({ verifier_run: await completeVerifierRun(verifierRun), cli: true, analysisCli: true, artifactCli: true, managedCli: true, managedReconstructionCli: true, managedNativeVerificationCli: true, managedRuntimePlanCli: true, managedApplicationGraphCli: true, evidenceCli: true, incurMcpCommand: PRODUCT_IDENTITY.mcpCommand, lifecycleScriptsRequired: false, doctor: "platform-appropriate", setup: supportedSetupHost ? "planned-then-idempotent" : "unsupported-host-rejected", setupPlanReadOnly: supportedSetupHost, existingHopperPreserved: supportedSetupHost, clients: supportedSetupHost ? 3 : 0, backupReadback: supportedSetupHost, failureRecovery: supportedSetupHost, configSymlinkLifecycle: supportedSetupHost, skill: supportedSetupHost, skillReferences: supportedSetupHost, mcpTools: TOOL_CONTRACTS.length, mcpPrompts: prompts.names.length, promptCompletion: true, promptCompletionLifecycle: true, evidenceMcp: true, targetFree: true, targetLifecycle: true, boundedRegexBridge: true })}\n`,
|
||||
`${JSON.stringify({ verifier_run: await completeVerifierRun(verifierRun), cli: true, analysisCli: true, artifactCli: true, managedCli: true, managedReconstructionCli: true, managedNativeVerificationCli: true, managedRuntimePlanCli: true, managedApplicationGraphCli: true, evidenceCli: true, incurMcpCommand: PRODUCT_IDENTITY.mcpCommand, lifecycleScriptsRequired: false, doctor: "platform-appropriate", setup: supportedSetupHost ? "planned-then-idempotent" : "unsupported-host-rejected", setupPlanReadOnly: supportedSetupHost, existingHopperPreserved: supportedSetupHost, clients: supportedSetupHost ? 3 : 0, backupReadback: supportedSetupHost, failureRecovery: supportedSetupHost, configSymlinkLifecycle: supportedSetupHost, skill: supportedSetupHost, skillReferences: supportedSetupHost, mcpTools: TOOL_CONTRACTS.length, mcpPrompts: prompts.names.length, promptCompletion: true, promptCompletionLifecycle: true, evidenceMcp: true, targetFree: true, targetLifecycle: true, boundedRegexBridge: true, mcpStartup, mcpModuleLoading })}\n`,
|
||||
);
|
||||
} finally {
|
||||
if (tarball) await rm(join(root, tarball), { force: true });
|
||||
|
||||
@@ -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,78 @@ export function assertDossier(
|
||||
assertDossierMultiBlock(dossier, requireMultiBlock);
|
||||
}
|
||||
|
||||
export function assertDenseSwitchDossier(dossier, address) {
|
||||
assertDossier(dossier, {
|
||||
address,
|
||||
requireAssembly: true,
|
||||
requireMultiBlock: true,
|
||||
});
|
||||
const truncated = dossier.native_api.jump_tables.find(
|
||||
({ mappings_truncated: mappingsTruncated }) => mappingsTruncated,
|
||||
);
|
||||
if (
|
||||
truncated === undefined ||
|
||||
truncated.mappings.length !== 32 ||
|
||||
truncated.mappings.some(
|
||||
({ target_address: target, evidence }) =>
|
||||
!/^0x[0-9a-f]+$/u.test(target) ||
|
||||
!evidence.some(({ kind }) => kind === "jump-table"),
|
||||
)
|
||||
)
|
||||
throw new Error(
|
||||
`Ghidra dense jump-table truncation drifted: ${JSON.stringify(dossier.native_api.jump_tables)}`,
|
||||
);
|
||||
}
|
||||
|
||||
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 +218,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 +271,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,8 +2,13 @@ import {
|
||||
parseGhidraFunctionInput,
|
||||
parseGhidraFunctionResult,
|
||||
} from "../dist/ghidra/GhidraFunctionValues.js";
|
||||
import {
|
||||
parseGhidraInventoryInput,
|
||||
parseGhidraInventoryResult,
|
||||
} from "../dist/ghidra/GhidraInventoryValues.js";
|
||||
|
||||
import {
|
||||
assertDenseSwitchDossier,
|
||||
assertDossier,
|
||||
assertLocalProcedureInfo,
|
||||
assertProviderText,
|
||||
@@ -27,6 +32,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 +56,14 @@ export async function verifyDebugFunctionOperations(
|
||||
procedures,
|
||||
"rea_ghidra_inventory_indirect",
|
||||
);
|
||||
const switchProcedure = requireProcedure(
|
||||
procedures,
|
||||
"rea_ghidra_inventory_switch",
|
||||
);
|
||||
const denseSwitchProcedure = requireProcedure(
|
||||
procedures,
|
||||
"rea_ghidra_inventory_dense_switch",
|
||||
);
|
||||
const main = requireProcedure(procedures, "main");
|
||||
const entryString = strings.find(
|
||||
(item) => item.value === "REA_GHIDRA_INVENTORY_ENTRY",
|
||||
@@ -88,6 +115,15 @@ export async function verifyDebugFunctionOperations(
|
||||
);
|
||||
const branchDossier = await analyzeBranchDossier(client, branch);
|
||||
const indirectDossier = await analyzeIndirectDossier(client, indirect);
|
||||
const switchDossier = await analyzeSwitchDossier(client, switchProcedure);
|
||||
const denseSwitchDossier = await functionCall(client, "analyze_function", {
|
||||
procedure: denseSwitchProcedure.address,
|
||||
include_assembly: true,
|
||||
limit: 500,
|
||||
max_pseudocode_chars: 100_000,
|
||||
max_instructions: 5_000,
|
||||
});
|
||||
assertDenseSwitchDossier(denseSwitchDossier, denseSwitchProcedure.address);
|
||||
assertIndirectDossier(indirectDossier, leaf.address);
|
||||
|
||||
const { cancellation, timeout } = await verifyRequestCancellationAndTimeout(
|
||||
@@ -100,6 +136,8 @@ export async function verifyDebugFunctionOperations(
|
||||
entry: dossierSummary(entryDossier),
|
||||
branch: dossierSummary(branchDossier),
|
||||
indirect: dossierSummary(indirectDossier),
|
||||
switch: dossierSummary(switchDossier),
|
||||
dense_switch: dossierSummary(denseSwitchDossier),
|
||||
instruction_window: {
|
||||
returned: instructionWindow.instructions.returned,
|
||||
truncated: instructionWindow.instructions.truncated,
|
||||
@@ -164,7 +202,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 +260,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 +365,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 +394,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 +479,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,28 @@ try {
|
||||
format: "elf",
|
||||
architecture: "x86_64",
|
||||
});
|
||||
assertDebugFixture(debug);
|
||||
const nativeApiCli = await verifyNativeApiCli(debugPath);
|
||||
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;
|
||||
@@ -164,6 +167,7 @@ try {
|
||||
summary(machObject),
|
||||
],
|
||||
malformed_target: "rejected-before-provider-start",
|
||||
native_api_cli: nativeApiCli,
|
||||
custom_target: custom === null ? null : summary(custom),
|
||||
cleanup: "complete",
|
||||
})}\n`,
|
||||
@@ -172,6 +176,52 @@ try {
|
||||
await rm(fixtureRoot, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
async function verifyNativeApiCli(targetPath) {
|
||||
const { stdout } = await exec(
|
||||
process.execPath,
|
||||
[
|
||||
"scripts/rea.mjs",
|
||||
"inspect-native-api",
|
||||
targetPath,
|
||||
"rea_ghidra_inventory_dense_switch",
|
||||
"--provider",
|
||||
"ghidra",
|
||||
"--json",
|
||||
],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
env: process.env,
|
||||
maxBuffer: 2 * 1024 * 1024,
|
||||
},
|
||||
);
|
||||
const evidence = JSON.parse(stdout);
|
||||
const boundary = evidence.normalized_result?.boundary;
|
||||
const truncated = boundary?.jump_tables?.find(
|
||||
({ mappings_truncated: mappingsTruncated }) => mappingsTruncated === true,
|
||||
);
|
||||
const omittedQuestion =
|
||||
"Which additional data sources or targets were omitted";
|
||||
if (
|
||||
evidence.operation !== "inspect_native_api" ||
|
||||
evidence.provider?.id !== "rea-workflow" ||
|
||||
evidence.analysis_profile?.provider?.id !== "rea-workflow" ||
|
||||
boundary?.available !== true ||
|
||||
truncated?.mappings?.length !== 32 ||
|
||||
!evidence.normalized_result?.residual_unknowns?.some((question) =>
|
||||
question.includes(omittedQuestion),
|
||||
)
|
||||
)
|
||||
throw new Error(
|
||||
`The shipped inspect-native-api CLI did not preserve dense jump-table truncation: ${stdout}`,
|
||||
);
|
||||
return {
|
||||
operation: evidence.operation,
|
||||
provider: evidence.provider,
|
||||
mappings_returned: truncated.mappings.length,
|
||||
residual_unknowns: evidence.normalized_result.residual_unknowns.length,
|
||||
};
|
||||
}
|
||||
|
||||
async function verifyTarget(targetPath, variant, expectedTarget = null) {
|
||||
const parsedTarget = await parseBinaryTarget(targetPath);
|
||||
if (!parsedTarget.ok) throw parsedTarget.error;
|
||||
|
||||
@@ -16,8 +16,11 @@ import { HOPPER_PROVIDER_IDENTITY } from "../dist/hopper/HopperProvider.js";
|
||||
import { REA_WORKFLOW_PROVIDER } from "../dist/application/InvestigationProviders.js";
|
||||
import { loadRealHopperFixtureTargets } from "./lib/real-hopper-fixture.mjs";
|
||||
import {
|
||||
requireBridgeDiagnostic,
|
||||
requireBridgeProgress,
|
||||
requireCurrentDocument,
|
||||
requireSafeDiagnostics,
|
||||
requireTruthfulMemoryRegions,
|
||||
verifyRealHopperFixture,
|
||||
} from "./lib/real-hopper-semantic.mjs";
|
||||
import {
|
||||
@@ -99,30 +102,6 @@ const requireOverview = (value, operation) => {
|
||||
return value;
|
||||
};
|
||||
|
||||
const requireTruthfulMemoryRegions = (segments) => {
|
||||
if (!Array.isArray(segments) || segments.length === 0) {
|
||||
throw new Error("list_segments returned no real memory regions");
|
||||
}
|
||||
const regions = segments.flatMap((segment) => [
|
||||
segment,
|
||||
...(Array.isArray(segment.sections) ? segment.sections : []),
|
||||
]);
|
||||
for (const region of regions) {
|
||||
for (const permission of ["readable", "writable", "executable"]) {
|
||||
if (![true, false, null].includes(region?.[permission])) {
|
||||
throw new Error(`list_segments omitted tri-state ${permission}`);
|
||||
}
|
||||
}
|
||||
if (
|
||||
region.provenance !== "hopper-public-python-api" ||
|
||||
region.permissions?.available !== false ||
|
||||
typeof region.permissions.reason !== "string"
|
||||
) {
|
||||
throw new Error("list_segments omitted permission provenance");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const verifyRelationships = async (client, options, procedure) => {
|
||||
const related = {};
|
||||
for (const operation of ["procedure_callers", "procedure_callees"]) {
|
||||
@@ -312,7 +291,11 @@ try {
|
||||
);
|
||||
}
|
||||
|
||||
const options = { timeout };
|
||||
const progressUpdates = [];
|
||||
const options = {
|
||||
timeout,
|
||||
onprogress: (update) => progressUpdates.push(update),
|
||||
};
|
||||
const fullSessionStatus = () =>
|
||||
client.callTool(
|
||||
{ name: "binary_session", arguments: { detail: "full" } },
|
||||
@@ -343,6 +326,16 @@ try {
|
||||
{ name: "list_documents", arguments: {} },
|
||||
options,
|
||||
);
|
||||
requireBridgeProgress(progressUpdates);
|
||||
const rejectedProcedure = await client.callTool(
|
||||
{
|
||||
name: "procedure_info",
|
||||
arguments: { procedure: "__rea_missing_procedure__" },
|
||||
},
|
||||
options,
|
||||
);
|
||||
if (rejectedProcedure.isError !== true)
|
||||
throw new Error("The real Hopper bridge accepted an unknown procedure");
|
||||
const overview = await client.callTool(
|
||||
{ name: "binary_overview", arguments: {} },
|
||||
options,
|
||||
@@ -435,6 +428,7 @@ try {
|
||||
);
|
||||
}
|
||||
const diagnosticCount = requireSafeDiagnostics(stderrChunks);
|
||||
requireBridgeDiagnostic(stderrChunks);
|
||||
|
||||
const closed = await client.callTool(
|
||||
{ name: "close_binary", arguments: {} },
|
||||
@@ -460,6 +454,7 @@ try {
|
||||
bundledMcpRunning,
|
||||
stderrBytes,
|
||||
diagnosticCount,
|
||||
bridgeProgressUpdates: progressUpdates.length,
|
||||
dynamicSession: true,
|
||||
providerBinding,
|
||||
targets: [targetA, targetB, largeTarget],
|
||||
|
||||
@@ -3,8 +3,8 @@ name: reverse-engineer-anything
|
||||
description: Reverse engineer native, managed, Electron/JavaScript, packaged, and browser applications with REA. Use shipped-artifact or approved runtime evidence to explain features, compare versions, decompile code, or guide a reconstruction. Skip REA for ordinary source-repository architecture analysis.
|
||||
metadata:
|
||||
version: "23"
|
||||
tool_count: 110
|
||||
catalog_digest: "e8cac51f2f17a5b9a088c3ff773473c4897823401481fc0875a28fe7daf8eee0"
|
||||
tool_count: 111
|
||||
catalog_digest: "861cd8618e793342daa8b149bc2944360d84b077fd19cf22f6e0a57b6b249c73"
|
||||
---
|
||||
|
||||
# REA
|
||||
|
||||
@@ -26,7 +26,7 @@ import type {
|
||||
AnalysisOperation,
|
||||
CapabilityDescriptor,
|
||||
} from "./AnalysisProvider.js";
|
||||
import { OFFICIAL_TOOL_CONTRACTS } from "../contracts/toolContracts.js";
|
||||
import { GENERATED_MCP_TOOL_CATALOG } from "../generatedMcpToolCatalog.js";
|
||||
|
||||
const STATEFUL_OPERATIONS: ReadonlySet<AnalysisOperation> = new Set([
|
||||
"health",
|
||||
@@ -47,13 +47,15 @@ const CURSOR_DEFAULT_OPERATIONS: ReadonlySet<AnalysisOperation> = new Set([
|
||||
"xrefs",
|
||||
]);
|
||||
|
||||
const DOCUMENT_SCOPED_OPERATIONS: ReadonlySet<AnalysisOperation> = new Set(
|
||||
OFFICIAL_TOOL_CONTRACTS.map(({ name }) => name).filter(
|
||||
(name) =>
|
||||
name !== "current_document" &&
|
||||
name !== "list_documents" &&
|
||||
name !== "set_current_document",
|
||||
),
|
||||
const DOCUMENT_SCOPED_OPERATIONS: ReadonlySet<string> = new Set(
|
||||
GENERATED_MCP_TOOL_CATALOG.filter(({ kind }) => kind === "official-proxy")
|
||||
.map(({ name }) => name)
|
||||
.filter(
|
||||
(name) =>
|
||||
name !== "current_document" &&
|
||||
name !== "list_documents" &&
|
||||
name !== "set_current_document",
|
||||
),
|
||||
);
|
||||
|
||||
/** Whether an operation is immutable and independent of provider UI state. */
|
||||
|
||||
@@ -24,8 +24,7 @@ import type {
|
||||
ProviderIdentity,
|
||||
} from "./AnalysisProvider.js";
|
||||
import type { AnalysisOperation } from "./AnalysisProvider.js";
|
||||
import { enhancedToolNameSchema } from "../contracts/enhancedInputs.js";
|
||||
import { OFFICIAL_TOOL_CONTRACTS } from "../contracts/toolContracts.js";
|
||||
import { GENERATED_MCP_TOOL_CATALOG } from "../generatedMcpToolCatalog.js";
|
||||
import type { BinarySessionPort } from "./BinarySessionPort.js";
|
||||
import {
|
||||
SessionProviderRouter,
|
||||
@@ -41,7 +40,14 @@ import { prepareSessionExecution } from "./BinarySessionExecution.js";
|
||||
import { closeAnalysisClient } from "./AnalysisClientCleanup.js";
|
||||
export type { BinarySessionPort } from "./BinarySessionPort.js";
|
||||
const OFFICIAL_OPERATIONS: ReadonlySet<string> = new Set(
|
||||
OFFICIAL_TOOL_CONTRACTS.map(({ name }) => name),
|
||||
GENERATED_MCP_TOOL_CATALOG.filter(
|
||||
({ kind }) => kind === "official-proxy",
|
||||
).map(({ name }) => name),
|
||||
);
|
||||
const ENHANCED_OPERATIONS: ReadonlySet<string> = new Set(
|
||||
GENERATED_MCP_TOOL_CATALOG.filter(({ kind }) => kind === "enhanced").map(
|
||||
({ name }) => name,
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -100,7 +106,7 @@ export class BinarySession
|
||||
if (operation !== undefined) {
|
||||
const exact = route.capabilities?.get(operation)?.provider;
|
||||
if (exact !== undefined) selected = exact;
|
||||
else if (enhancedToolNameSchema.safeParse(operation).success) {
|
||||
else if (ENHANCED_OPERATIONS.has(operation)) {
|
||||
const providers = new Map<string, ProviderIdentity>();
|
||||
for (const descriptor of route.capabilities?.values() ?? [])
|
||||
if (
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { TOOL_CONTRACTS } from "../contracts/toolContracts.js";
|
||||
import type { ClientFeatureAvailability } from "../contracts/toolOutputSchemaPrimitives.js";
|
||||
import type { ToolKind } from "../contracts/toolContractTypes.js";
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import { GENERATED_MCP_TOOL_CATALOG } from "../generatedMcpToolCatalog.js";
|
||||
|
||||
const statusSchema = z.object({
|
||||
open: z.boolean(),
|
||||
@@ -28,7 +29,6 @@ type ToolAvailabilityReason =
|
||||
| "unsupported_host"
|
||||
| "policy_disabled";
|
||||
|
||||
type ToolKind = (typeof TOOL_CONTRACTS)[number]["kind"];
|
||||
type ProviderDescriptor = {
|
||||
readonly available: boolean;
|
||||
readonly reason: string | null;
|
||||
@@ -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",
|
||||
@@ -127,7 +128,7 @@ export const buildCapabilityInventory = (
|
||||
},
|
||||
]),
|
||||
);
|
||||
return TOOL_CONTRACTS.map((contract) => {
|
||||
return GENERATED_MCP_TOOL_CATALOG.map((contract) => {
|
||||
const availability = availabilityFor({
|
||||
name: contract.name,
|
||||
kind: contract.kind,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { parse as parseToml } from "smol-toml";
|
||||
import { z } from "zod";
|
||||
|
||||
import { PRODUCT_IDENTITY } from "../identity.js";
|
||||
import { MCP_STARTUP_POLICY } from "../mcpStartupPolicy.js";
|
||||
import { supportedClients } from "./SupportedClients.js";
|
||||
|
||||
export interface ClientRegistrationStatus {
|
||||
@@ -80,7 +81,11 @@ const registrationAligned = (
|
||||
currentCommandPath: string,
|
||||
): boolean => {
|
||||
const command = [registration.command, ...registration.args];
|
||||
if (client === "codex" && registration.startup_timeout_sec !== 30)
|
||||
if (
|
||||
client === "codex" &&
|
||||
registration.startup_timeout_sec !==
|
||||
MCP_STARTUP_POLICY.codexStartupTimeoutSeconds
|
||||
)
|
||||
return false;
|
||||
if (
|
||||
command.length === 4 &&
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -3,6 +3,7 @@ import canonicalize from "canonicalize";
|
||||
import {
|
||||
createEvidenceBundle,
|
||||
parseEvidenceBundle,
|
||||
validateResidualUnknownAddition,
|
||||
type EvidenceBundle,
|
||||
} from "../domain/evidenceBundle.js";
|
||||
import {
|
||||
@@ -216,8 +217,6 @@ export class EvidenceLedger {
|
||||
} catch (cause: unknown) {
|
||||
return err(new UnknownRegistryError("invalid-transition", { cause }));
|
||||
}
|
||||
const pendingRecords = new Map(this.#records);
|
||||
pendingRecords.set(evidence.evidence_id, evidence);
|
||||
const existingUnknown = this.#unknownHeads.get(unknown.unknown_id);
|
||||
if (existingUnknown !== undefined) {
|
||||
if (
|
||||
@@ -225,20 +224,12 @@ export class EvidenceLedger {
|
||||
existingUnknown.revision_digest !== unknown.revision_digest
|
||||
)
|
||||
return err(new UnknownRegistryError("already-exists"));
|
||||
const checked = this.#validateCandidate(
|
||||
pendingRecords,
|
||||
this.#unknownRevisions,
|
||||
);
|
||||
const checked = this.#appendIncremental([evidence]);
|
||||
if (!checked.ok) return checked;
|
||||
this.#commit(pendingRecords, this.#unknownRevisions, checked.value);
|
||||
return ok(null);
|
||||
}
|
||||
pendingRecords.set(mutation.evidence_id, mutation);
|
||||
const pendingUnknowns = new Map(this.#unknownRevisions);
|
||||
pendingUnknowns.set(unknownRevisionKey(unknown), unknown);
|
||||
const checked = this.#validateCandidate(pendingRecords, pendingUnknowns);
|
||||
const checked = this.#appendIncremental([evidence, mutation], unknown);
|
||||
if (!checked.ok) return checked;
|
||||
this.#commit(pendingRecords, pendingUnknowns, checked.value);
|
||||
return ok(structuredClone(unknown));
|
||||
}
|
||||
|
||||
@@ -332,16 +323,68 @@ export class EvidenceLedger {
|
||||
}),
|
||||
);
|
||||
}
|
||||
const pendingRecords = new Map(this.#records);
|
||||
pendingRecords.set(mutationEvidence.evidence_id, mutationEvidence);
|
||||
const pendingUnknowns = new Map(this.#unknownRevisions);
|
||||
pendingUnknowns.set(unknownRevisionKey(unknown), unknown);
|
||||
const checked = this.#validateCandidate(pendingRecords, pendingUnknowns);
|
||||
const checked = this.#appendIncremental([mutationEvidence], unknown);
|
||||
if (!checked.ok) return checked;
|
||||
this.#commit(pendingRecords, pendingUnknowns, checked.value);
|
||||
return ok(structuredClone(unknown));
|
||||
}
|
||||
|
||||
#appendIncremental(
|
||||
records: readonly Evidence[],
|
||||
unknown?: ResidualUnknown,
|
||||
): Result<
|
||||
void,
|
||||
EvidenceIntegrityError | EvidenceLimitError | UnknownRegistryError
|
||||
> {
|
||||
const additions = new Map<string, Evidence>();
|
||||
let addedBytes = 0;
|
||||
for (const evidence of records) {
|
||||
const existing =
|
||||
additions.get(evidence.evidence_id) ??
|
||||
this.#records.get(evidence.evidence_id);
|
||||
if (existing !== undefined) {
|
||||
if (!recordsAgree(existing, evidence))
|
||||
return err(new EvidenceIntegrityError("Conflicting evidence record"));
|
||||
continue;
|
||||
}
|
||||
additions.set(evidence.evidence_id, evidence);
|
||||
addedBytes += serializedBytes(evidence);
|
||||
}
|
||||
const unknownKey =
|
||||
unknown === undefined ? undefined : unknownRevisionKey(unknown);
|
||||
if (unknownKey !== undefined && this.#unknownRevisions.has(unknownKey))
|
||||
return err(new UnknownRegistryError("integrity"));
|
||||
const unknownBytes = unknown === undefined ? 0 : serializedBytes(unknown);
|
||||
if (
|
||||
this.#exceedsRecordLimit(
|
||||
this.#records.size + additions.size,
|
||||
this.#unknownRevisions.size + (unknown === undefined ? 0 : 1),
|
||||
)
|
||||
)
|
||||
return err(new EvidenceLimitError("records", this.limits.maxRecords));
|
||||
if (this.#bytes + addedBytes + unknownBytes > this.limits.maxBytes)
|
||||
return err(new EvidenceLimitError("bytes", this.limits.maxBytes));
|
||||
if (unknown !== undefined)
|
||||
try {
|
||||
validateResidualUnknownAddition(
|
||||
unknown,
|
||||
{
|
||||
get: (id) => additions.get(id) ?? this.#records.get(id),
|
||||
has: (id) => additions.has(id) || this.#records.has(id),
|
||||
},
|
||||
this.#unknownHeads,
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
return err(new UnknownRegistryError("integrity", { cause }));
|
||||
}
|
||||
for (const [id, evidence] of additions) this.#records.set(id, evidence);
|
||||
if (unknown !== undefined && unknownKey !== undefined) {
|
||||
this.#unknownRevisions.set(unknownKey, unknown);
|
||||
this.#unknownHeads.set(unknown.unknown_id, unknown);
|
||||
}
|
||||
this.#bytes += addedBytes + unknownBytes;
|
||||
return ok(undefined);
|
||||
}
|
||||
|
||||
#validateCandidate(
|
||||
records: ReadonlyMap<string, Evidence>,
|
||||
unknowns: ReadonlyMap<string, ResidualUnknown>,
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import { analyzeJavaScriptStaticSource } from "../domain/javascriptStaticAnalysis.js";
|
||||
import { analyzeJavaScriptSemantics } from "../domain/javascriptSemanticAnalysis.js";
|
||||
import { analyzeParsedJavaScriptStaticSource } from "../domain/javascriptStaticAnalysis.js";
|
||||
import { analyzeParsedJavaScriptSemantics } from "../domain/javascriptSemanticAnalysis.js";
|
||||
import {
|
||||
DEFAULT_JAVASCRIPT_SEMANTIC_LIMITS,
|
||||
type JavaScriptSemanticLimits,
|
||||
} from "../domain/javascriptSemanticIr.js";
|
||||
import { parseJavaScriptSource } from "../domain/javascriptSourceParser.js";
|
||||
import type {
|
||||
JavaScriptSourceRange,
|
||||
JavaScriptSourcePoint,
|
||||
JavaScriptStaticAnalysis,
|
||||
} from "../domain/javascriptStaticAnalysisTypes.js";
|
||||
import { failedJavaScriptStaticAnalysis } from "../domain/javascriptStaticAnalysisHelpers.js";
|
||||
import type {
|
||||
JavaScriptArtifactFile,
|
||||
JavaScriptArtifactFileSet,
|
||||
@@ -142,13 +144,24 @@ const analyzeArtifactFile = (
|
||||
state.truncatedScopes += 1;
|
||||
return;
|
||||
}
|
||||
const analysis = analyzeJavaScriptStaticSource(file.text.value, {
|
||||
maxAstNodes: remainingNodes,
|
||||
maxFindings: remainingFindings,
|
||||
maxModules: remainingModules,
|
||||
deadline: context.deadline,
|
||||
now: context.now,
|
||||
});
|
||||
const parsed = parseJavaScriptSource(file.text.value);
|
||||
if (parsed === null) {
|
||||
const analysis = failedJavaScriptStaticAnalysis();
|
||||
state.files.push({ file, javascript: analysis, semantic: null });
|
||||
state.parseFailures += 1;
|
||||
return;
|
||||
}
|
||||
const analysis = analyzeParsedJavaScriptStaticSource(
|
||||
file.text.value,
|
||||
parsed,
|
||||
{
|
||||
maxAstNodes: remainingNodes,
|
||||
maxFindings: remainingFindings,
|
||||
maxModules: remainingModules,
|
||||
deadline: context.deadline,
|
||||
now: context.now,
|
||||
},
|
||||
);
|
||||
const staticFindings = findingCount(analysis);
|
||||
const semanticFindingBudget = Math.max(
|
||||
0,
|
||||
@@ -164,7 +177,7 @@ const analyzeArtifactFile = (
|
||||
};
|
||||
const semantics =
|
||||
analysis.parse_status === "complete" || analysis.parse_status === "partial"
|
||||
? analyzeJavaScriptSemantics(file.text.value, semanticLimits)
|
||||
? analyzeParsedJavaScriptSemantics(parsed, semanticLimits)
|
||||
: null;
|
||||
state.files.push({
|
||||
file,
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import type { AnalysisProfileCommitment } from "../domain/analysisProfile.js";
|
||||
import type { BinaryTarget } from "../domain/binaryTarget.js";
|
||||
import type {
|
||||
AnalysisClient,
|
||||
AnalysisClientContext,
|
||||
AnalysisProvider,
|
||||
CapabilityDescriptor,
|
||||
ProviderIdentity,
|
||||
ProviderRequestActivitySnapshot,
|
||||
ProviderRuntimeLineageSnapshot,
|
||||
} from "./AnalysisProvider.js";
|
||||
|
||||
type LoadAnalysisProvider = () => Promise<AnalysisProvider>;
|
||||
|
||||
/**
|
||||
* Preserve synchronous provider discovery while loading implementation modules
|
||||
* only when a target-bound client first executes.
|
||||
*/
|
||||
export class LazyAnalysisProvider implements AnalysisProvider {
|
||||
readonly #providerIdentity: ProviderIdentity;
|
||||
readonly #capabilityDescriptors: readonly CapabilityDescriptor[];
|
||||
readonly #loadProvider: LoadAnalysisProvider;
|
||||
#provider: Promise<AnalysisProvider> | undefined;
|
||||
|
||||
constructor(options: {
|
||||
readonly identity: ProviderIdentity;
|
||||
readonly capabilities: readonly CapabilityDescriptor[];
|
||||
readonly load: LoadAnalysisProvider;
|
||||
}) {
|
||||
this.#providerIdentity = options.identity;
|
||||
this.#capabilityDescriptors = options.capabilities;
|
||||
this.#loadProvider = options.load;
|
||||
}
|
||||
|
||||
/** Return generated provider identity without loading its implementation. */
|
||||
identity(): ProviderIdentity {
|
||||
return this.#providerIdentity;
|
||||
}
|
||||
|
||||
/** Return generated capability metadata without loading its implementation. */
|
||||
capabilities(): readonly CapabilityDescriptor[] {
|
||||
return this.#capabilityDescriptors;
|
||||
}
|
||||
|
||||
/** Create a client whose implementation is loaded on first execution. */
|
||||
createClient(
|
||||
target: BinaryTarget,
|
||||
profile?: AnalysisProfileCommitment,
|
||||
context?: AnalysisClientContext,
|
||||
): AnalysisClient {
|
||||
return new LazyAnalysisClient(async () => {
|
||||
const provider = await this.#load();
|
||||
return provider.createClient(target, profile, context);
|
||||
});
|
||||
}
|
||||
|
||||
async #load(): Promise<AnalysisProvider> {
|
||||
this.#provider ??= this.#loadProvider().catch((cause: unknown) => {
|
||||
this.#provider = undefined;
|
||||
throw cause;
|
||||
});
|
||||
return this.#provider;
|
||||
}
|
||||
}
|
||||
|
||||
class LazyAnalysisClient implements AnalysisClient {
|
||||
readonly #loadClient: () => Promise<AnalysisClient>;
|
||||
#client: AnalysisClient | undefined;
|
||||
#loading: Promise<AnalysisClient> | undefined;
|
||||
#closed = false;
|
||||
|
||||
constructor(loadClient: () => Promise<AnalysisClient>) {
|
||||
this.#loadClient = loadClient;
|
||||
}
|
||||
|
||||
execute: AnalysisClient["execute"] = async (
|
||||
operation,
|
||||
parameters,
|
||||
options,
|
||||
) => {
|
||||
const client = await this.#load();
|
||||
return client.execute(operation, parameters, options);
|
||||
};
|
||||
|
||||
runtimeLineageSnapshots(): readonly ProviderRuntimeLineageSnapshot[] {
|
||||
return this.#client?.runtimeLineageSnapshots?.() ?? [];
|
||||
}
|
||||
|
||||
requestActivitySnapshots(): readonly ProviderRequestActivitySnapshot[] {
|
||||
return this.#client?.requestActivitySnapshots?.() ?? [];
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
this.#closed = true;
|
||||
if (this.#loading === undefined) return;
|
||||
const client = await this.#loading;
|
||||
await client.close();
|
||||
}
|
||||
|
||||
async #load(): Promise<AnalysisClient> {
|
||||
if (this.#closed) throw new Error("Lazy analysis client is closed");
|
||||
this.#loading ??= this.#loadClient()
|
||||
.then((client) => {
|
||||
this.#client = client;
|
||||
return client;
|
||||
})
|
||||
.catch((cause: unknown) => {
|
||||
this.#loading = undefined;
|
||||
throw cause;
|
||||
});
|
||||
return this.#loading;
|
||||
}
|
||||
}
|
||||
@@ -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)];
|
||||
};
|
||||
@@ -6,6 +6,7 @@ import writeFileAtomic from "write-file-atomic";
|
||||
import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
|
||||
|
||||
import { PRODUCT_IDENTITY } from "../identity.js";
|
||||
import { MCP_STARTUP_POLICY } from "../mcpStartupPolicy.js";
|
||||
import { resolveClientConfigTransactionPath } from "./ClientConfigPath.js";
|
||||
import type {
|
||||
ClientConfigurationInspection,
|
||||
@@ -21,8 +22,6 @@ const defaultCommand = (): readonly string[] => [
|
||||
"mcp",
|
||||
];
|
||||
|
||||
const CODEX_STARTUP_TIMEOUT_SECONDS = 30;
|
||||
|
||||
/** Back up, atomically update, and semantically read back one JSON MCP configuration. */
|
||||
export const configureJsonClient = async (
|
||||
client: SetupClient,
|
||||
@@ -299,7 +298,9 @@ const clientConfigurationDesired = (
|
||||
command: command[0] ?? PRODUCT_IDENTITY.cliBinary,
|
||||
args: command.slice(1),
|
||||
...(client.name === "codex"
|
||||
? { startup_timeout_sec: CODEX_STARTUP_TIMEOUT_SECONDS }
|
||||
? {
|
||||
startup_timeout_sec: MCP_STARTUP_POLICY.codexStartupTimeoutSeconds,
|
||||
}
|
||||
: {}),
|
||||
...(Object.keys(environment).length === 0 ? {} : { env: environment }),
|
||||
};
|
||||
|
||||
@@ -2,11 +2,10 @@ import type { AppConfig } from "../config.js";
|
||||
import { BinarySession } from "./BinarySession.js";
|
||||
import { HopperProvider } from "../hopper/HopperProvider.js";
|
||||
import { GhidraProvider } from "../ghidra/GhidraProvider.js";
|
||||
import { NativeMacOSProvider } from "../native/NativeMacOSProvider.js";
|
||||
import { ArtifactProvider } from "../artifacts/ArtifactProvider.js";
|
||||
import { ManagedStaticProvider } from "../dotnet/ManagedStaticProvider.js";
|
||||
import { silentLogger, type Logger } from "../logger.js";
|
||||
import { GENERATED_AUXILIARY_PROVIDERS } from "../generatedMcpToolCatalog.js";
|
||||
import { AnalysisProviderRegistry } from "./AnalysisProviderRegistry.js";
|
||||
import { LazyAnalysisProvider } from "./LazyAnalysisProvider.js";
|
||||
import { SessionProviderRouter } from "./SessionProviderRouter.js";
|
||||
|
||||
/**
|
||||
@@ -20,16 +19,46 @@ export const createBinarySession = (
|
||||
): BinarySession => {
|
||||
const hopper = new HopperProvider(config, logger);
|
||||
const ghidra = new GhidraProvider(config, logger);
|
||||
const auxiliary = new Map(
|
||||
GENERATED_AUXILIARY_PROVIDERS.map((provider) => [
|
||||
provider.identity.id,
|
||||
provider,
|
||||
]),
|
||||
);
|
||||
const lazyProvider = (
|
||||
id: string,
|
||||
load: ConstructorParameters<typeof LazyAnalysisProvider>[0]["load"],
|
||||
) => {
|
||||
const generated = auxiliary.get(id);
|
||||
if (generated === undefined)
|
||||
throw new TypeError(`Missing generated provider metadata for ${id}`);
|
||||
return new LazyAnalysisProvider({ ...generated, load });
|
||||
};
|
||||
return new BinarySession(
|
||||
SessionProviderRouter.selectable(
|
||||
new AnalysisProviderRegistry([hopper, ghidra], config.analysisProvider),
|
||||
[
|
||||
new ArtifactProvider(
|
||||
config.artifactNativeMountEnabled,
|
||||
config.artifactIntegrityContinueEnabled,
|
||||
),
|
||||
new NativeMacOSProvider(),
|
||||
new ManagedStaticProvider(),
|
||||
lazyProvider("rea-artifact-graph", async () => {
|
||||
const { ArtifactProvider } = await import(
|
||||
"../artifacts/ArtifactProvider.js"
|
||||
);
|
||||
return new ArtifactProvider(
|
||||
config.artifactNativeMountEnabled,
|
||||
config.artifactIntegrityContinueEnabled,
|
||||
);
|
||||
}),
|
||||
lazyProvider("native-macos", async () => {
|
||||
const { NativeMacOSProvider } = await import(
|
||||
"../native/NativeMacOSProvider.js"
|
||||
);
|
||||
return new NativeMacOSProvider();
|
||||
}),
|
||||
lazyProvider("rea-dotnet-static", async () => {
|
||||
const { ManagedStaticProvider } = await import(
|
||||
"../dotnet/ManagedStaticProvider.js"
|
||||
);
|
||||
return new ManagedStaticProvider();
|
||||
}),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -29,9 +29,19 @@ import type {
|
||||
BrowserScenarioSessionPort,
|
||||
} from "./BrowserScenarioSessionPort.js";
|
||||
import { BrowserScenarioCaptureBudget } from "./PlaywrightScenarioArtifacts.js";
|
||||
import { PlaywrightScenarioSessionFactory } from "./PlaywrightScenarioSession.js";
|
||||
|
||||
const OPERATION = "capture_browser_scenario" as const;
|
||||
let defaultFactory: Promise<BrowserScenarioSessionFactory> | undefined;
|
||||
|
||||
const lazyPlaywrightFactory: BrowserScenarioSessionFactory = {
|
||||
async open(scenario, options) {
|
||||
defaultFactory ??= import("./PlaywrightScenarioSession.js").then(
|
||||
({ PlaywrightScenarioSessionFactory }) =>
|
||||
new PlaywrightScenarioSessionFactory(),
|
||||
);
|
||||
return (await defaultFactory).open(scenario, options);
|
||||
},
|
||||
};
|
||||
|
||||
export const PLAYWRIGHT_BROWSER_SCENARIO_PROVIDER_IDENTITY: ProviderIdentity =
|
||||
Object.freeze({
|
||||
@@ -353,7 +363,7 @@ export class PlaywrightBrowserScenarioProvider
|
||||
implements BrowserScenarioCapturePort
|
||||
{
|
||||
constructor(
|
||||
private readonly factory: BrowserScenarioSessionFactory = new PlaywrightScenarioSessionFactory(),
|
||||
private readonly factory: BrowserScenarioSessionFactory = lazyPlaywrightFactory,
|
||||
) {}
|
||||
|
||||
identity(): ProviderIdentity {
|
||||
|
||||
@@ -90,14 +90,16 @@ const registerExtractionCommand = (cli: CliInstance, logger: Logger): void => {
|
||||
args: z.object({
|
||||
path: z.string().describe("Application or package path"),
|
||||
outputRoot: z.string().describe("Absent absolute output root"),
|
||||
}),
|
||||
options: z.object({
|
||||
occurrenceIds: z
|
||||
.array(z.string().regex(/^occ_[a-f0-9]{64}$/u))
|
||||
.min(1)
|
||||
.max(500)
|
||||
.describe("Exact artifact occurrence IDs selected for extraction"),
|
||||
}),
|
||||
alias: { outputRoot: "output-root", occurrenceIds: "occurrence-ids" },
|
||||
run: ({ args }) =>
|
||||
alias: { occurrenceIds: "occurrence-ids" },
|
||||
run: ({ args, options }) =>
|
||||
logCliCommand(logger, "extract-artifact", () =>
|
||||
runProviderAnalysis(
|
||||
args.path,
|
||||
@@ -105,7 +107,7 @@ const registerExtractionCommand = (cli: CliInstance, logger: Logger): void => {
|
||||
{
|
||||
approved: true,
|
||||
output_root: args.outputRoot,
|
||||
occurrence_ids: args.occurrenceIds,
|
||||
occurrence_ids: options.occurrenceIds,
|
||||
},
|
||||
logger,
|
||||
),
|
||||
|
||||
@@ -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[];
|
||||
@@ -1,4 +1,4 @@
|
||||
import { TOOL_CONTRACTS } from "./toolContracts.js";
|
||||
import type { TOOL_CONTRACTS } from "./toolContracts.js";
|
||||
|
||||
type ToolName = (typeof TOOL_CONTRACTS)[number]["name"];
|
||||
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -32,7 +32,7 @@ export const TOOL_KINDS = [
|
||||
] as const;
|
||||
|
||||
/** Adapter family responsible for implementing a public MCP tool. */
|
||||
type ToolKind = (typeof TOOL_KINDS)[number];
|
||||
export type ToolKind = (typeof TOOL_KINDS)[number];
|
||||
|
||||
/** Single source of truth for a public MCP tool. */
|
||||
export interface ToolContract<Name extends string = string> {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -239,7 +239,7 @@ const validateUnknownGraph = (
|
||||
|
||||
const validateVerifiedResolution = (
|
||||
unknown: ResidualUnknown,
|
||||
evidenceById: ReadonlyMap<string, Evidence>,
|
||||
evidenceById: Pick<ReadonlyMap<string, Evidence>, "get">,
|
||||
): void => {
|
||||
if (unknown.contradicting_evidence_ids.length > 0)
|
||||
throw new TypeError(
|
||||
@@ -260,6 +260,78 @@ const validateVerifiedResolution = (
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Validate one new residual-unknown revision against an already valid ledger.
|
||||
* This keeps ordinary mutations proportional to the affected revision while
|
||||
* full bundle imports continue to validate the complete graph.
|
||||
*/
|
||||
export const validateResidualUnknownAddition = (
|
||||
unknown: ResidualUnknown,
|
||||
evidenceById: Pick<ReadonlyMap<string, Evidence>, "get" | "has">,
|
||||
currentHeads: ReadonlyMap<string, ResidualUnknown>,
|
||||
): void => {
|
||||
const current = currentHeads.get(unknown.unknown_id);
|
||||
if (
|
||||
current === undefined
|
||||
? unknown.revision !== 1 || unknown.previous_revision_digest !== null
|
||||
: unknown.revision !== current.revision + 1 ||
|
||||
unknown.previous_revision_digest !== current.revision_digest
|
||||
)
|
||||
throw new TypeError(
|
||||
`Residual unknown ${unknown.unknown_id} revision chain is broken`,
|
||||
);
|
||||
for (const evidenceId of referencedEvidenceIds(unknown))
|
||||
if (!evidenceById.has(evidenceId))
|
||||
throw new TypeError(
|
||||
`Residual unknown references missing evidence ${evidenceId}`,
|
||||
);
|
||||
for (const relationship of unknown.relationships)
|
||||
if (
|
||||
relationship.unknown_id !== unknown.unknown_id &&
|
||||
!currentHeads.has(relationship.unknown_id)
|
||||
)
|
||||
throw new TypeError(
|
||||
`Residual unknown references missing unknown ${relationship.unknown_id}`,
|
||||
);
|
||||
if (unknown.resolution?.disposition === "verified")
|
||||
validateVerifiedResolution(unknown, evidenceById);
|
||||
for (const relationship of unknown.relationships)
|
||||
if (
|
||||
relationship.type === "depends-on" &&
|
||||
reachesUnknown(
|
||||
relationship.unknown_id,
|
||||
unknown.unknown_id,
|
||||
currentHeads,
|
||||
unknown,
|
||||
)
|
||||
)
|
||||
throw new TypeError("Residual unknown dependency graph contains a cycle");
|
||||
};
|
||||
|
||||
const reachesUnknown = (
|
||||
start: string,
|
||||
goal: string,
|
||||
currentHeads: ReadonlyMap<string, ResidualUnknown>,
|
||||
candidate: ResidualUnknown,
|
||||
): boolean => {
|
||||
const pending = [start];
|
||||
const visited = new Set<string>();
|
||||
while (pending.length > 0) {
|
||||
const id = pending.pop();
|
||||
if (id === undefined) break;
|
||||
if (id === goal) return true;
|
||||
if (visited.has(id)) continue;
|
||||
visited.add(id);
|
||||
const unknown =
|
||||
id === candidate.unknown_id ? candidate : currentHeads.get(id);
|
||||
if (unknown === undefined) continue;
|
||||
for (const relationship of unknown.relationships)
|
||||
if (relationship.type === "depends-on")
|
||||
pending.push(relationship.unknown_id);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const evidenceQualifies = (
|
||||
unknown: ResidualUnknown,
|
||||
evidence: Evidence,
|
||||
|
||||
@@ -65,7 +65,7 @@ const failures = [
|
||||
[
|
||||
75,
|
||||
"hopper_exited_during_startup",
|
||||
"Hopper exited before the REA bridge was ready. Run rea doctor and retry the analysis.",
|
||||
"Hopper exited before the REA bridge was ready. In CI, run rea doctor --provider hopper --detail full --json; if Hopper showed a UI or license prompt, complete it in the runner user session, otherwise close stale Hopper sessions and retry.",
|
||||
],
|
||||
[
|
||||
76,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { parse } from "@babel/parser";
|
||||
import * as t from "@babel/types";
|
||||
|
||||
import {
|
||||
@@ -41,6 +40,10 @@ import {
|
||||
propertyName,
|
||||
range,
|
||||
} from "./javascriptStaticAnalysisHelpers.js";
|
||||
import {
|
||||
parseJavaScriptSource,
|
||||
type ParsedJavaScriptSource,
|
||||
} from "./javascriptSourceParser.js";
|
||||
|
||||
interface BindPatternInput {
|
||||
readonly pattern: t.Node;
|
||||
@@ -68,18 +71,19 @@ interface AddBindingInput {
|
||||
export const analyzeJavaScriptSemantics = (
|
||||
source: string,
|
||||
inputLimits: Partial<JavaScriptSemanticLimits> = {},
|
||||
): JavaScriptSemanticIr => {
|
||||
const file = parseJavaScriptSource(source);
|
||||
return file === null
|
||||
? failedJavaScriptSemanticIr()
|
||||
: analyzeParsedJavaScriptSemantics(file, inputLimits);
|
||||
};
|
||||
|
||||
/** Recover semantics from an already parsed JavaScript artifact. */
|
||||
export const analyzeParsedJavaScriptSemantics = (
|
||||
file: ParsedJavaScriptSource,
|
||||
inputLimits: Partial<JavaScriptSemanticLimits> = {},
|
||||
): JavaScriptSemanticIr => {
|
||||
const limits = { ...DEFAULT_JAVASCRIPT_SEMANTIC_LIMITS, ...inputLimits };
|
||||
let file: ReturnType<typeof parse>;
|
||||
try {
|
||||
file = parse(source, {
|
||||
sourceType: "unambiguous",
|
||||
errorRecovery: true,
|
||||
plugins: ["jsx", "typescript"],
|
||||
});
|
||||
} catch {
|
||||
return failedJavaScriptSemanticIr();
|
||||
}
|
||||
const state = createState(file.program, limits);
|
||||
collectDefinitions(file.program, state);
|
||||
const references = collectSemanticReferences(file.program, state);
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { parse } from "@babel/parser";
|
||||
|
||||
/** Babel AST produced by REA's inert JavaScript parser boundary. */
|
||||
export type ParsedJavaScriptSource = ReturnType<typeof parse>;
|
||||
|
||||
/** Parse JavaScript or TypeScript once without retaining unused comment nodes. */
|
||||
export const parseJavaScriptSource = (
|
||||
source: string,
|
||||
): ParsedJavaScriptSource | null => {
|
||||
try {
|
||||
return parse(source, {
|
||||
sourceType: "unambiguous",
|
||||
errorRecovery: true,
|
||||
attachComment: false,
|
||||
plugins: ["jsx", "typescript"],
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
@@ -1,4 +1,3 @@
|
||||
import { parse } from "@babel/parser";
|
||||
import * as t from "@babel/types";
|
||||
|
||||
import { inspectElectronStaticNode } from "./electronStaticAnalysis.js";
|
||||
@@ -29,22 +28,28 @@ import type {
|
||||
JavaScriptStaticAnalysis,
|
||||
JavaScriptStaticAnalysisLimits,
|
||||
} from "./javascriptStaticAnalysisTypes.js";
|
||||
import {
|
||||
parseJavaScriptSource,
|
||||
type ParsedJavaScriptSource,
|
||||
} from "./javascriptSourceParser.js";
|
||||
|
||||
/** Parse one bounded JavaScript artifact and recover static structure only. */
|
||||
export const analyzeJavaScriptStaticSource = (
|
||||
source: string,
|
||||
limits: JavaScriptStaticAnalysisLimits,
|
||||
): JavaScriptStaticAnalysis => {
|
||||
let file: ReturnType<typeof parse>;
|
||||
try {
|
||||
file = parse(source, {
|
||||
sourceType: "unambiguous",
|
||||
errorRecovery: true,
|
||||
plugins: ["jsx", "typescript"],
|
||||
});
|
||||
} catch {
|
||||
return failedJavaScriptStaticAnalysis();
|
||||
}
|
||||
const file = parseJavaScriptSource(source);
|
||||
return file === null
|
||||
? failedJavaScriptStaticAnalysis()
|
||||
: analyzeParsedJavaScriptStaticSource(source, file, limits);
|
||||
};
|
||||
|
||||
/** Recover static structure from an already parsed JavaScript artifact. */
|
||||
export const analyzeParsedJavaScriptStaticSource = (
|
||||
source: string,
|
||||
file: ParsedJavaScriptSource,
|
||||
limits: JavaScriptStaticAnalysisLimits,
|
||||
): JavaScriptStaticAnalysis => {
|
||||
const accumulator = createJavaScriptAnalysisAccumulator();
|
||||
traverseStaticSource(source, file, accumulator, limits);
|
||||
addSourceMapDirectives(source, accumulator, limits.maxFindings);
|
||||
@@ -53,7 +58,7 @@ export const analyzeJavaScriptStaticSource = (
|
||||
|
||||
const traverseStaticSource = (
|
||||
source: string,
|
||||
file: ReturnType<typeof parse>,
|
||||
file: ParsedJavaScriptSource,
|
||||
accumulator: AnalysisAccumulator,
|
||||
limits: JavaScriptStaticAnalysisLimits,
|
||||
): void => {
|
||||
@@ -76,7 +81,7 @@ const traverseStaticSource = (
|
||||
|
||||
const finalizeStaticAnalysis = (
|
||||
source: string,
|
||||
file: ReturnType<typeof parse>,
|
||||
file: ParsedJavaScriptSource,
|
||||
accumulator: AnalysisAccumulator,
|
||||
limits: JavaScriptStaticAnalysisLimits,
|
||||
): JavaScriptStaticAnalysis => {
|
||||
|
||||
@@ -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,4 +1,5 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { createReadStream } from "node:fs";
|
||||
import { readFile, stat } from "node:fs/promises";
|
||||
|
||||
import {
|
||||
@@ -85,6 +86,8 @@ export class ManagedStaticProvider implements AnalysisProvider {
|
||||
}
|
||||
|
||||
class ManagedStaticClient implements AnalysisClient {
|
||||
#snapshotBytes: Buffer | undefined;
|
||||
|
||||
constructor(private readonly target: BinaryTarget) {}
|
||||
|
||||
async execute(
|
||||
@@ -123,11 +126,12 @@ class ManagedStaticClient implements AnalysisClient {
|
||||
`Artifact size ${String(metadata.size)} exceeds max_file_bytes ${String(maxFileBytes)}.`,
|
||||
),
|
||||
);
|
||||
const bytes = await readFile(
|
||||
this.target.path,
|
||||
options?.signal === undefined ? undefined : { signal: options.signal },
|
||||
);
|
||||
if (bytes.length > maxFileBytes)
|
||||
const snapshot = this.#snapshotBytes;
|
||||
const observed =
|
||||
snapshot === undefined
|
||||
? await readManagedSnapshot(this.target.path, options?.signal)
|
||||
: await hashManagedSource(this.target.path, options?.signal);
|
||||
if (observed.byteLength > maxFileBytes)
|
||||
return err(
|
||||
new AnalysisCapabilityUnavailableError(
|
||||
IDENTITY.id,
|
||||
@@ -135,13 +139,16 @@ class ManagedStaticClient implements AnalysisClient {
|
||||
`Artifact grew beyond max_file_bytes ${String(maxFileBytes)} while it was read.`,
|
||||
),
|
||||
);
|
||||
const observedDigest = createHash("sha256").update(bytes).digest("hex");
|
||||
if (observedDigest !== this.target.sha256)
|
||||
if (observed.sha256 !== this.target.sha256)
|
||||
return err(
|
||||
new EvidenceIntegrityError(
|
||||
`Managed artifact digest changed after open: expected ${this.target.sha256}, observed ${observedDigest} at ${this.target.path}`,
|
||||
`Managed artifact digest changed after open: expected ${this.target.sha256}, observed ${observed.sha256} at ${this.target.path}`,
|
||||
),
|
||||
);
|
||||
const bytes = snapshot ?? observed.bytes;
|
||||
if (bytes === undefined)
|
||||
throw new TypeError("Managed snapshot bytes are unavailable");
|
||||
this.#snapshotBytes = bytes;
|
||||
const result = inspectManagedOperation(
|
||||
operation,
|
||||
parameters,
|
||||
@@ -164,10 +171,51 @@ class ManagedStaticClient implements AnalysisClient {
|
||||
}
|
||||
|
||||
close(): Promise<void> {
|
||||
this.#snapshotBytes = undefined;
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
interface ManagedSourceObservation {
|
||||
readonly byteLength: number;
|
||||
readonly sha256: string;
|
||||
readonly bytes?: Buffer;
|
||||
}
|
||||
|
||||
const readManagedSnapshot = async (
|
||||
path: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ManagedSourceObservation> => {
|
||||
const bytes = await readFile(
|
||||
path,
|
||||
signal === undefined ? undefined : { signal },
|
||||
);
|
||||
return {
|
||||
byteLength: bytes.length,
|
||||
sha256: createHash("sha256").update(bytes).digest("hex"),
|
||||
bytes,
|
||||
};
|
||||
};
|
||||
|
||||
const hashManagedSource = async (
|
||||
path: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ManagedSourceObservation> => {
|
||||
const digest = createHash("sha256");
|
||||
let byteLength = 0;
|
||||
const stream = createReadStream(
|
||||
path,
|
||||
signal === undefined ? undefined : { signal },
|
||||
);
|
||||
for await (const chunk of stream) {
|
||||
if (!Buffer.isBuffer(chunk))
|
||||
throw new TypeError("Managed source stream returned non-buffer data");
|
||||
byteLength += chunk.length;
|
||||
digest.update(chunk);
|
||||
}
|
||||
return { byteLength, sha256: digest.digest("hex") };
|
||||
};
|
||||
|
||||
const isManagedOperation = (
|
||||
operation: AnalysisOperation,
|
||||
): operation is ManagedToolName =>
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,7 +1,7 @@
|
||||
/** Generated from package.json and package-lock.json; do not edit. */
|
||||
export const PACKAGE_METADATA = {
|
||||
name: "rea-agents",
|
||||
version: "2.5.0", // x-release-please-version
|
||||
version: "2.6.0", // x-release-please-version
|
||||
serverSdkVersion: "2.0.0-beta.4",
|
||||
clientSdkVersion: "2.0.0-beta.4",
|
||||
coreSdkVersion: "2.0.0-beta.4",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -2,10 +2,7 @@ import type {
|
||||
CapabilityDescriptor,
|
||||
ProviderIdentity,
|
||||
} from "../application/AnalysisProvider.js";
|
||||
import {
|
||||
ENHANCED_TOOL_CONTRACTS,
|
||||
OFFICIAL_TOOL_CONTRACTS,
|
||||
} from "../contracts/toolContracts.js";
|
||||
import { GENERATED_MCP_TOOL_CATALOG } from "../generatedMcpToolCatalog.js";
|
||||
import {
|
||||
GHIDRA_DECOMPILE_REQUEST_TIMEOUT_MS,
|
||||
GHIDRA_MAX_LINE_BYTES,
|
||||
@@ -22,10 +19,9 @@ export const GHIDRA_PROVIDER_IDENTITY: ProviderIdentity = Object.freeze({
|
||||
});
|
||||
|
||||
const providerContractByName = new Map(
|
||||
[...OFFICIAL_TOOL_CONTRACTS, ...ENHANCED_TOOL_CONTRACTS].map((contract) => [
|
||||
contract.name,
|
||||
contract,
|
||||
]),
|
||||
GENERATED_MCP_TOOL_CATALOG.filter(
|
||||
({ kind }) => kind === "official-proxy" || kind === "enhanced",
|
||||
).map((contract) => [contract.name, contract]),
|
||||
);
|
||||
|
||||
/** Provider-neutral read-only contracts implemented by the Ghidra adapter. */
|
||||
@@ -165,10 +161,13 @@ export const limitationsFor = (operation: string): readonly string[] => {
|
||||
|
||||
/** Provider-neutral capabilities advertised by every non-Windows Ghidra session. */
|
||||
export const CAPABILITIES: readonly CapabilityDescriptor[] = Object.freeze(
|
||||
GHIDRA_PROVIDER_TOOL_CONTRACTS.map((contract) =>
|
||||
Object.freeze({
|
||||
GHIDRA_PROVIDER_TOOL_CONTRACTS.map((contract) => {
|
||||
const operation = contract.analysisOperation;
|
||||
if (operation === null)
|
||||
throw new TypeError(`Missing analysis operation for ${contract.name}`);
|
||||
return Object.freeze({
|
||||
provider: GHIDRA_PROVIDER_IDENTITY,
|
||||
operation: contract.name,
|
||||
operation,
|
||||
inputContractVersion: 1,
|
||||
outputContractVersion: 1,
|
||||
available: true,
|
||||
@@ -198,8 +197,8 @@ export const CAPABILITIES: readonly CapabilityDescriptor[] = Object.freeze(
|
||||
: GHIDRA_REQUEST_TIMEOUT_MS,
|
||||
}),
|
||||
limitations: Object.freeze(limitationsFor(contract.name)),
|
||||
}),
|
||||
),
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
/** Capabilities advertised for the experimental Windows x64 P0 boundary. */
|
||||
|
||||
@@ -42,7 +42,11 @@ import {
|
||||
type HopperRequestActivity,
|
||||
} from "./HopperRequestQueue.js";
|
||||
import { HopperResponseStream } from "./HopperResponseStream.js";
|
||||
import { responseResult } from "./protocol.js";
|
||||
import {
|
||||
type HopperBridgeEvent,
|
||||
type HopperBridgeMessage,
|
||||
responseResult,
|
||||
} from "./protocol.js";
|
||||
|
||||
export type { HopperServerInfo } from "./HopperSessionValues.js";
|
||||
|
||||
@@ -125,8 +129,7 @@ export class HopperClient {
|
||||
},
|
||||
);
|
||||
this.#responses = new HopperResponseStream({
|
||||
accept: (response) =>
|
||||
this.#requests.accept(response.id, responseResult(response)),
|
||||
accept: (message) => this.#acceptBridgeMessage(message),
|
||||
hasQueued: (id) => this.#requests.hasQueued(id),
|
||||
nextRequestId: () => this.#nextId,
|
||||
abort: (message, cause) => this.#abortProtocol(message, cause),
|
||||
@@ -450,6 +453,32 @@ export class HopperClient {
|
||||
socket.on("close", this.#onSocketClose);
|
||||
}
|
||||
|
||||
#acceptBridgeMessage(message: HopperBridgeMessage): boolean {
|
||||
if (!("event" in message))
|
||||
return this.#requests.accept(message.id, responseResult(message));
|
||||
if (!this.#requests.acceptEvent(message)) return false;
|
||||
this.#emitBridgeDiagnostic(message);
|
||||
return true;
|
||||
}
|
||||
|
||||
#emitBridgeDiagnostic(message: HopperBridgeEvent): void {
|
||||
if (message.event.type !== "diagnostic") return;
|
||||
try {
|
||||
this.#options.onDiagnostic?.({
|
||||
type: "bridge-diagnostic",
|
||||
request_id: message.id,
|
||||
code: message.event.error.code,
|
||||
category: message.event.error.type,
|
||||
message: message.event.error.message,
|
||||
});
|
||||
} catch {
|
||||
this.#logger.warn(
|
||||
{ requestId: message.id },
|
||||
"Hopper bridge diagnostic consumer rejected an event",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#detachSocket(socket: Socket): void {
|
||||
socket.off("data", this.#onSocketData);
|
||||
socket.off("error", this.#onSocketError);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { HopperDiagnosticType } from "../domain/errors.js";
|
||||
import type { ProcessCleanupResult } from "../process/ProcessOwnership.js";
|
||||
import type { ProviderProcessStopResult } from "../process/ProviderProcess.js";
|
||||
import type { BridgeLaunch } from "./BridgeLauncher.js";
|
||||
@@ -6,6 +7,13 @@ import type { BridgeLaunch } from "./BridgeLauncher.js";
|
||||
export type HopperDiagnostic =
|
||||
| { readonly type: "launcher-stderr"; readonly bytes: number }
|
||||
| { readonly type: "launcher-exit"; readonly code: number | null }
|
||||
| {
|
||||
readonly type: "bridge-diagnostic";
|
||||
readonly request_id: number;
|
||||
readonly code: number;
|
||||
readonly category: HopperDiagnosticType;
|
||||
readonly message: string;
|
||||
}
|
||||
| {
|
||||
readonly type: "owned-shutdown";
|
||||
readonly status: ProviderProcessStopResult["status"];
|
||||
|
||||
@@ -18,10 +18,7 @@ import type { BinaryTarget } from "../domain/binaryTarget.js";
|
||||
import type { Logger } from "../logger.js";
|
||||
import { AnalysisCapabilityUnavailableError } from "../domain/errors.js";
|
||||
import { err } from "../domain/result.js";
|
||||
import {
|
||||
ENHANCED_TOOL_CONTRACTS,
|
||||
OFFICIAL_TOOL_CONTRACTS,
|
||||
} from "../contracts/toolContracts.js";
|
||||
import { GENERATED_MCP_TOOL_CATALOG } from "../generatedMcpToolCatalog.js";
|
||||
import { HopperApplicationLauncher } from "./BridgeLauncher.js";
|
||||
import {
|
||||
hopperLoaderArgsForTarget,
|
||||
@@ -47,22 +44,29 @@ const MUTATING_OPERATIONS = new Set([
|
||||
|
||||
/** Tool contracts implemented directly by the Hopper adapter. */
|
||||
export const HOPPER_PROVIDER_TOOL_CONTRACTS = Object.freeze([
|
||||
...OFFICIAL_TOOL_CONTRACTS,
|
||||
...ENHANCED_TOOL_CONTRACTS.filter(
|
||||
(contract) => contract.name === "analyze_function",
|
||||
...GENERATED_MCP_TOOL_CATALOG.filter(({ kind }) => kind === "official-proxy"),
|
||||
...GENERATED_MCP_TOOL_CATALOG.filter(
|
||||
({ name }) => name === "analyze_function",
|
||||
),
|
||||
]);
|
||||
|
||||
const CAPABILITIES: readonly CapabilityDescriptor[] = Object.freeze(
|
||||
HOPPER_PROVIDER_TOOL_CONTRACTS.map((contract) =>
|
||||
Object.freeze({
|
||||
HOPPER_PROVIDER_TOOL_CONTRACTS.map((contract) => {
|
||||
const operation = contract.analysisOperation;
|
||||
if (operation === null)
|
||||
throw new TypeError(`Missing analysis operation for ${contract.name}`);
|
||||
return Object.freeze({
|
||||
provider: IDENTITY,
|
||||
operation: contract.name,
|
||||
operation,
|
||||
inputContractVersion: 1,
|
||||
outputContractVersion: 1,
|
||||
available: true,
|
||||
reason: null,
|
||||
pagination: "offset" in contract.inputSchema.shape ? "offset" : "none",
|
||||
pagination:
|
||||
isRecord(contract.inputSchema.properties) &&
|
||||
"offset" in contract.inputSchema.properties
|
||||
? "offset"
|
||||
: "none",
|
||||
exhaustive: false,
|
||||
effects: Object.freeze({
|
||||
mutatesArtifact: MUTATING_OPERATIONS.has(contract.name),
|
||||
@@ -81,10 +85,14 @@ const CAPABILITIES: readonly CapabilityDescriptor[] = Object.freeze(
|
||||
limitations: Object.freeze([
|
||||
"Results depend on Hopper's completed static analysis.",
|
||||
]),
|
||||
}),
|
||||
),
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
function isRecord(value: unknown): value is Readonly<Record<string, unknown>> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/** Concrete analysis provider backed by REA's private Hopper bridge. */
|
||||
export class HopperProvider implements AnalysisProviderCandidate {
|
||||
constructor(
|
||||
@@ -233,6 +241,8 @@ export class HopperProvider implements AnalysisProviderCandidate {
|
||||
}),
|
||||
...(context === undefined ? {} : { runId: context.runId }),
|
||||
logger: this.logger.child({ layer: "bridge" }),
|
||||
onDiagnostic: (diagnostic) =>
|
||||
this.logger.info(diagnostic, "Hopper bridge reported a diagnostic"),
|
||||
});
|
||||
return {
|
||||
execute: async (operation, parameters, options) => {
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { performance } from "node:perf_hooks";
|
||||
|
||||
import type { ProgressReporter } from "../application/ProgressReporter.js";
|
||||
import type {
|
||||
ProgressReporter,
|
||||
ProgressUpdate,
|
||||
} from "../application/ProgressReporter.js";
|
||||
import type { HopperBridgeEvent } from "./protocol.js";
|
||||
import {
|
||||
HopperCancelledError,
|
||||
type HopperError,
|
||||
@@ -127,6 +131,28 @@ export class HopperRequestQueue {
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Forward one correlated bridge event without releasing the wire request. */
|
||||
acceptEvent(event: HopperBridgeEvent): boolean {
|
||||
const entry = this.#active;
|
||||
if (entry === undefined || entry.id !== event.id) return false;
|
||||
if (event.event.type === "progress" && !entry.callerSettled) {
|
||||
if (event.event.terminal === true && entry.heartbeat !== undefined) {
|
||||
clearInterval(entry.heartbeat);
|
||||
entry.heartbeat = undefined;
|
||||
}
|
||||
this.#reportUpdate(entry, {
|
||||
phase: event.event.phase,
|
||||
completed: event.event.completed,
|
||||
total: event.event.total,
|
||||
message: event.event.message,
|
||||
...(event.event.terminal === undefined
|
||||
? {}
|
||||
: { terminal: event.event.terminal }),
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Fail the active wire request and every request still waiting in the FIFO. */
|
||||
failAll(error: HopperError): void {
|
||||
const active = this.#active;
|
||||
@@ -267,13 +293,21 @@ export class HopperRequestQueue {
|
||||
}
|
||||
|
||||
#report(entry: QueuedRequest, message: string): void {
|
||||
void entry.progress
|
||||
?.report({
|
||||
phase: "hopper_request",
|
||||
completed: 0,
|
||||
total: 1,
|
||||
message,
|
||||
})
|
||||
.catch(() => undefined);
|
||||
this.#reportUpdate(entry, {
|
||||
phase: "hopper_request",
|
||||
completed: 0,
|
||||
total: 1,
|
||||
message,
|
||||
});
|
||||
}
|
||||
|
||||
#reportUpdate(entry: QueuedRequest, update: ProgressUpdate): void {
|
||||
try {
|
||||
void Promise.resolve(entry.progress?.report(update)).catch(
|
||||
() => undefined,
|
||||
);
|
||||
} catch {
|
||||
// Progress observation cannot change the request outcome.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { parseResponseLine, type HopperResponse } from "./protocol.js";
|
||||
import {
|
||||
parseBridgeMessageLine,
|
||||
type HopperBridgeMessage,
|
||||
} from "./protocol.js";
|
||||
|
||||
const MAX_LINE_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
export interface HopperResponseStreamOptions {
|
||||
readonly accept: (response: HopperResponse) => boolean;
|
||||
readonly accept: (message: HopperBridgeMessage) => boolean;
|
||||
readonly hasQueued: (id: number) => boolean;
|
||||
readonly nextRequestId: () => number;
|
||||
readonly abort: (message: string, cause?: Error) => void;
|
||||
@@ -38,7 +41,7 @@ export class HopperResponseStream {
|
||||
}
|
||||
|
||||
#acceptLine(line: string): boolean {
|
||||
const parsed = parseResponseLine(line);
|
||||
const parsed = parseBridgeMessageLine(line);
|
||||
if (!parsed.ok) {
|
||||
this.options.abort(parsed.error.message, parsed.error);
|
||||
return false;
|
||||
|
||||
+75
-26
@@ -4,35 +4,84 @@ import { HopperProtocolError, HopperRemoteError } from "../domain/errors.js";
|
||||
import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
|
||||
const remoteErrorSchema = z
|
||||
.object({
|
||||
code: z.number().int(),
|
||||
message: z.string().max(512),
|
||||
type: z
|
||||
.enum([
|
||||
"remote",
|
||||
"authorization",
|
||||
"invalid_request",
|
||||
"capability_unavailable",
|
||||
"bridge_exception",
|
||||
])
|
||||
.default("remote"),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const responseSchema = z.union([
|
||||
z.object({
|
||||
id: z.number().int().nonnegative(),
|
||||
result: jsonValueSchema,
|
||||
}),
|
||||
z.object({
|
||||
id: z.number().int().nonnegative(),
|
||||
error: z.object({
|
||||
code: z.number().int(),
|
||||
message: z.string(),
|
||||
type: z
|
||||
.enum([
|
||||
"remote",
|
||||
"authorization",
|
||||
"invalid_request",
|
||||
"capability_unavailable",
|
||||
"bridge_exception",
|
||||
])
|
||||
.default("remote"),
|
||||
}),
|
||||
}),
|
||||
z
|
||||
.object({
|
||||
id: z.number().int().nonnegative(),
|
||||
result: jsonValueSchema,
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
id: z.number().int().nonnegative(),
|
||||
error: remoteErrorSchema,
|
||||
})
|
||||
.strict(),
|
||||
]);
|
||||
|
||||
export type HopperResponse = z.infer<typeof responseSchema>;
|
||||
const eventSchema = z
|
||||
.object({
|
||||
id: z.number().int().nonnegative(),
|
||||
event: z.discriminatedUnion("type", [
|
||||
z
|
||||
.object({
|
||||
type: z.literal("progress"),
|
||||
phase: z.string().min(1).max(64),
|
||||
completed: z.number().nonnegative().finite(),
|
||||
total: z.number().nonnegative().finite().nullable(),
|
||||
message: z.string().min(1).max(512),
|
||||
terminal: z.boolean().optional(),
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
type: z.literal("diagnostic"),
|
||||
error: remoteErrorSchema,
|
||||
})
|
||||
.strict(),
|
||||
]),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((message, context) => {
|
||||
if (
|
||||
message.event.type === "progress" &&
|
||||
message.event.total !== null &&
|
||||
message.event.completed > message.event.total
|
||||
) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "Progress total cannot be less than completed",
|
||||
path: ["event", "total"],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/** Parse one complete Hopper NDJSON response line. */
|
||||
export const parseResponseLine = (
|
||||
const messageSchema = z.union([responseSchema, eventSchema]);
|
||||
|
||||
export type HopperResponse = z.infer<typeof responseSchema>;
|
||||
export type HopperBridgeEvent = z.infer<typeof eventSchema>;
|
||||
export type HopperBridgeMessage = z.infer<typeof messageSchema>;
|
||||
|
||||
/** Parse one complete Hopper NDJSON response or event line. */
|
||||
export const parseBridgeMessageLine = (
|
||||
line: string,
|
||||
): Result<HopperResponse, HopperProtocolError> => {
|
||||
): Result<HopperBridgeMessage, HopperProtocolError> => {
|
||||
let decoded: unknown;
|
||||
try {
|
||||
decoded = JSON.parse(line);
|
||||
@@ -42,12 +91,12 @@ export const parseResponseLine = (
|
||||
);
|
||||
}
|
||||
|
||||
const parsed = responseSchema.safeParse(decoded);
|
||||
const parsed = messageSchema.safeParse(decoded);
|
||||
return parsed.success
|
||||
? ok(parsed.data)
|
||||
: err(
|
||||
new HopperProtocolError(
|
||||
"Hopper returned a response outside the bridge contract",
|
||||
"Hopper returned a message outside the bridge contract",
|
||||
{ cause: parsed.error },
|
||||
),
|
||||
);
|
||||
|
||||
+19
-12
@@ -10,10 +10,6 @@ import { createBinarySession } from "./application/runtime.js";
|
||||
import { createLogger } from "./logger.js";
|
||||
import { projectAnalysisError } from "./domain/errors.js";
|
||||
import { loadConfiguredPermissionAuthority } from "./application/PermissionConfiguration.js";
|
||||
import { CdpBrowserProvider } from "./browser/CdpBrowserProvider.js";
|
||||
import { CdpElectronProvider } from "./browser/CdpElectronProvider.js";
|
||||
import { V8InspectorProvider } from "./browser/V8InspectorProvider.js";
|
||||
import { PlaywrightBrowserScenarioProvider } from "./browser/PlaywrightBrowserScenarioProvider.js";
|
||||
import type { RuntimeDependencies } from "./main/types.js";
|
||||
import { SERVER_START_FAILED } from "./main/messages.js";
|
||||
import { createRuntimeState } from "./main/state.js";
|
||||
@@ -73,10 +69,6 @@ export const run = async (
|
||||
}
|
||||
|
||||
const session = createBinarySession(config.value, logger);
|
||||
const browserObservation = new CdpBrowserProvider();
|
||||
const browserScenarioCapture = new PlaywrightBrowserScenarioProvider();
|
||||
const electronObservation = new CdpElectronProvider();
|
||||
const javascriptRuntimeObservation = new V8InspectorProvider();
|
||||
const opened = await openInitialTarget(
|
||||
session,
|
||||
config.value,
|
||||
@@ -88,10 +80,25 @@ export const run = async (
|
||||
const transport = await startMcpTransport(dependencies, session, {
|
||||
logger,
|
||||
serverLogger,
|
||||
browserObservation,
|
||||
browserScenarioCapture,
|
||||
electronObservation,
|
||||
javascriptRuntimeObservation,
|
||||
loadOptionalProviders: async () => {
|
||||
const [
|
||||
{ CdpBrowserProvider },
|
||||
{ PlaywrightBrowserScenarioProvider },
|
||||
{ CdpElectronProvider },
|
||||
{ V8InspectorProvider },
|
||||
] = await Promise.all([
|
||||
import("./browser/CdpBrowserProvider.js"),
|
||||
import("./browser/PlaywrightBrowserScenarioProvider.js"),
|
||||
import("./browser/CdpElectronProvider.js"),
|
||||
import("./browser/V8InspectorProvider.js"),
|
||||
]);
|
||||
return {
|
||||
browserObservation: new CdpBrowserProvider(),
|
||||
browserScenarioCapture: new PlaywrightBrowserScenarioProvider(),
|
||||
electronObservation: new CdpElectronProvider(),
|
||||
javascriptRuntimeObservation: new V8InspectorProvider(),
|
||||
};
|
||||
},
|
||||
permissionAuthority: permissionAuthority.value,
|
||||
runtimeState,
|
||||
});
|
||||
|
||||
+8
-14
@@ -1,13 +1,12 @@
|
||||
import type { StdioServerHandle } from "@modelcontextprotocol/server/stdio";
|
||||
|
||||
import type { BinarySession } from "../application/BinarySession.js";
|
||||
import type { CdpBrowserProvider } from "../browser/CdpBrowserProvider.js";
|
||||
import type { CdpElectronProvider } from "../browser/CdpElectronProvider.js";
|
||||
import type { V8InspectorProvider } from "../browser/V8InspectorProvider.js";
|
||||
import type { PlaywrightBrowserScenarioProvider } from "../browser/PlaywrightBrowserScenarioProvider.js";
|
||||
import type { PermissionAuthority } from "../application/PermissionAuthority.js";
|
||||
import type { Logger } from "../logger.js";
|
||||
import { createServer } from "../server/createServer.js";
|
||||
import {
|
||||
createServer,
|
||||
type CreateServerOptions,
|
||||
} from "../server/createServer.js";
|
||||
import type { RuntimeDependencies } from "./types.js";
|
||||
import type { RuntimeState } from "./state.js";
|
||||
import {
|
||||
@@ -20,10 +19,9 @@ type McpServerInstance = ReturnType<typeof createServer>;
|
||||
interface ServerContext {
|
||||
readonly logger: Logger;
|
||||
readonly serverLogger: Logger;
|
||||
readonly browserObservation: CdpBrowserProvider;
|
||||
readonly browserScenarioCapture: PlaywrightBrowserScenarioProvider;
|
||||
readonly electronObservation: CdpElectronProvider;
|
||||
readonly javascriptRuntimeObservation: V8InspectorProvider;
|
||||
readonly loadOptionalProviders: NonNullable<
|
||||
CreateServerOptions["loadOptionalProviders"]
|
||||
>;
|
||||
readonly permissionAuthority: PermissionAuthority;
|
||||
readonly runtimeState: RuntimeState;
|
||||
}
|
||||
@@ -58,11 +56,7 @@ export const startMcpTransport = async (
|
||||
analysisSnapshotFilePolicy:
|
||||
serverContext.runtimeState.snapshotPolicy,
|
||||
permissionAuthority: serverContext.permissionAuthority,
|
||||
browserObservation: serverContext.browserObservation,
|
||||
browserScenarioCapture: serverContext.browserScenarioCapture,
|
||||
electronObservation: serverContext.electronObservation,
|
||||
javascriptRuntimeObservation:
|
||||
serverContext.javascriptRuntimeObservation,
|
||||
loadOptionalProviders: serverContext.loadOptionalProviders,
|
||||
artifactIntegrityContinueEnabled: () =>
|
||||
serverContext.runtimeState.currentConfig
|
||||
.artifactIntegrityContinueEnabled,
|
||||
|
||||
+3
-2
@@ -10,8 +10,8 @@ import {
|
||||
} from "./catalogIdentity.js";
|
||||
import { PROMPT_CONTRACTS } from "./contracts/promptContracts.js";
|
||||
import { PRODUCT_IDENTITY } from "./identity.js";
|
||||
import { MCP_STARTUP_POLICY } from "./mcpStartupPolicy.js";
|
||||
|
||||
const DEFAULT_DEADLINE_MS = 20_000;
|
||||
const STDERR_LIMIT_BYTES = 64 * 1_024;
|
||||
const OUTPUT_FORMATS = ["toon", "json", "yaml", "md", "jsonl"] as const;
|
||||
type OutputFormat = (typeof OUTPUT_FORMATS)[number];
|
||||
@@ -40,7 +40,8 @@ interface McpDoctorCheck {
|
||||
|
||||
/** Diagnose the actual production stdio adapter with one bounded SDK session. */
|
||||
export const runProductionMcpDoctor = async (options: McpDoctorOptions) => {
|
||||
const deadline = Date.now() + (options.deadlineMs ?? DEFAULT_DEADLINE_MS);
|
||||
const deadline =
|
||||
Date.now() + (options.deadlineMs ?? MCP_STARTUP_POLICY.doctorDeadlineMs);
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(
|
||||
() => controller.abort(new Error("Production MCP doctor deadline expired")),
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Supported MCP startup deadlines and regression budgets.
|
||||
*
|
||||
* The Codex timeout is caller-facing configuration. The smaller phase budgets
|
||||
* preserve operational headroom, while the doctor deadline covers additional
|
||||
* inventory requests after initialization.
|
||||
*/
|
||||
export const MCP_STARTUP_POLICY = {
|
||||
codexStartupTimeoutSeconds: 30,
|
||||
initializeBudgetMs: 15_000,
|
||||
toolsListBudgetMs: 5_000,
|
||||
firstCatalogBudgetMs: 20_000,
|
||||
doctorDeadlineMs: 30_000,
|
||||
} as const;
|
||||
@@ -0,0 +1,181 @@
|
||||
import type {
|
||||
AnyToolHandler,
|
||||
JsonSchemaValidator,
|
||||
jsonSchemaValidator,
|
||||
McpServer,
|
||||
RegisteredTool,
|
||||
StandardSchemaWithJSON,
|
||||
} from "@modelcontextprotocol/server";
|
||||
import {
|
||||
fromJsonSchema,
|
||||
isCallToolResult,
|
||||
isInputRequiredResult,
|
||||
} from "@modelcontextprotocol/server";
|
||||
|
||||
import { GENERATED_MCP_TOOL_CATALOG } from "../generatedMcpToolCatalog.js";
|
||||
import type { ToolKind } from "../contracts/toolContractTypes.js";
|
||||
|
||||
type HydrateToolRegistrations = (kind: ToolKind) => Promise<void>;
|
||||
|
||||
/**
|
||||
* Advertise the generated MCP catalog immediately and hydrate its callbacks
|
||||
* through the existing registrars on first execution.
|
||||
*/
|
||||
export class LazyToolCatalog {
|
||||
readonly #server: McpServer;
|
||||
readonly #hydrateRegistrations: HydrateToolRegistrations;
|
||||
readonly #includeSessionTools: boolean;
|
||||
readonly #tools = new Map<string, RegisteredTool>();
|
||||
readonly #hydrations = new Map<ToolKind, Promise<void>>();
|
||||
#hydrationQueue: Promise<void> = Promise.resolve();
|
||||
#closed = false;
|
||||
|
||||
constructor(
|
||||
server: McpServer,
|
||||
hydrateRegistrations: HydrateToolRegistrations,
|
||||
includeSessionTools = true,
|
||||
) {
|
||||
this.#server = server;
|
||||
this.#hydrateRegistrations = hydrateRegistrations;
|
||||
this.#includeSessionTools = includeSessionTools;
|
||||
}
|
||||
|
||||
/** Register every generated tool with a lazy callback. */
|
||||
register(): void {
|
||||
for (const contract of GENERATED_MCP_TOOL_CATALOG) {
|
||||
if (contract.requiresSession && !this.#includeSessionTools) continue;
|
||||
const inputSchema = advertisedSchema(contract.inputSchema);
|
||||
const outputSchema = advertisedSchema(contract.outputSchema);
|
||||
let registered: RegisteredTool | undefined;
|
||||
const callback: AnyToolHandler<StandardSchemaWithJSON> = async (
|
||||
input,
|
||||
context,
|
||||
) => {
|
||||
await this.#hydrate(contract.kind);
|
||||
const handler = registered?.handler;
|
||||
if (handler === undefined || handler === callback)
|
||||
throw new Error(`MCP tool ${contract.name} was not hydrated`);
|
||||
const result: unknown = await Reflect.apply(handler, undefined, [
|
||||
input,
|
||||
context,
|
||||
]);
|
||||
if (isCallToolResult(result) || isInputRequiredResult(result))
|
||||
return result;
|
||||
throw new TypeError(
|
||||
`MCP tool ${contract.name} returned an invalid result`,
|
||||
);
|
||||
};
|
||||
registered = this.#server.registerTool(
|
||||
contract.name,
|
||||
{
|
||||
title: contract.title,
|
||||
description: contract.description,
|
||||
inputSchema,
|
||||
outputSchema,
|
||||
annotations: contract.annotations,
|
||||
},
|
||||
callback,
|
||||
);
|
||||
this.#tools.set(contract.name, registered);
|
||||
}
|
||||
}
|
||||
|
||||
/** Wait for in-flight hydration and reject future first-use loading. */
|
||||
async close(): Promise<void> {
|
||||
this.#closed = true;
|
||||
await Promise.allSettled(this.#hydrations.values());
|
||||
}
|
||||
|
||||
async #hydrate(kind: ToolKind): Promise<void> {
|
||||
if (this.#closed) throw new Error("MCP tool catalog is closed");
|
||||
const existing = this.#hydrations.get(kind);
|
||||
if (existing !== undefined) {
|
||||
await existing;
|
||||
return;
|
||||
}
|
||||
const hydration = this.#hydrationQueue
|
||||
.catch(() => undefined)
|
||||
.then(() => this.#runHydration(kind));
|
||||
this.#hydrationQueue = hydration;
|
||||
this.#hydrations.set(kind, hydration);
|
||||
try {
|
||||
await hydration;
|
||||
} catch (cause: unknown) {
|
||||
if (this.#hydrations.get(kind) === hydration)
|
||||
this.#hydrations.delete(kind);
|
||||
throw cause;
|
||||
}
|
||||
}
|
||||
|
||||
async #runHydration(kind: ToolKind): Promise<void> {
|
||||
if (this.#closed) throw new Error("MCP tool catalog is closed");
|
||||
const expected = GENERATED_MCP_TOOL_CATALOG.filter(
|
||||
(contract) =>
|
||||
contract.kind === kind &&
|
||||
(this.#includeSessionTools || !contract.requiresSession),
|
||||
).map((contract) => contract.name);
|
||||
const expectedNames = new Set(expected);
|
||||
const hydrated = new Set<string>();
|
||||
const originalRegisterTool = this.#server.registerTool;
|
||||
const hydrationRegisterTool = (...args: unknown[]): unknown => {
|
||||
const [name, config, callback] = args;
|
||||
if (
|
||||
typeof name !== "string" ||
|
||||
!isRecord(config) ||
|
||||
typeof callback !== "function"
|
||||
)
|
||||
throw new TypeError("Invalid lazy MCP tool registration");
|
||||
const registered = this.#tools.get(name);
|
||||
if (registered === undefined || !expectedNames.has(name))
|
||||
throw new TypeError(
|
||||
`MCP tool ${name} is not in the ${kind} catalog family`,
|
||||
);
|
||||
if (hydrated.has(name))
|
||||
throw new TypeError(`MCP tool ${name} was hydrated more than once`);
|
||||
hydrated.add(name);
|
||||
Reflect.apply(registered.update, registered, [
|
||||
{
|
||||
title: config.title,
|
||||
description: config.description,
|
||||
paramsSchema: config.inputSchema,
|
||||
outputSchema: config.outputSchema,
|
||||
annotations: config.annotations,
|
||||
callback,
|
||||
},
|
||||
]);
|
||||
return registered;
|
||||
};
|
||||
if (!Reflect.set(this.#server, "registerTool", hydrationRegisterTool))
|
||||
throw new TypeError("Unable to install lazy MCP tool hydration");
|
||||
try {
|
||||
await this.#hydrateRegistrations(kind);
|
||||
} finally {
|
||||
Reflect.set(this.#server, "registerTool", originalRegisterTool);
|
||||
}
|
||||
const missing = expected.filter((name) => !hydrated.has(name));
|
||||
if (missing.length > 0)
|
||||
throw new TypeError(
|
||||
`Generated MCP tools were not hydrated: ${missing.join(", ")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const advertisedSchema = (
|
||||
jsonSchema: Readonly<Record<string, unknown>>,
|
||||
): StandardSchemaWithJSON =>
|
||||
fromJsonSchema(jsonSchema, DEFERRED_VALIDATION_PROVIDER);
|
||||
|
||||
class DeferredValidationProvider implements jsonSchemaValidator {
|
||||
getValidator<T>(): JsonSchemaValidator<T> {
|
||||
return (input: unknown) => ({
|
||||
valid: true,
|
||||
data: input as T,
|
||||
errorMessage: undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const DEFERRED_VALIDATION_PROVIDER = new DeferredValidationProvider();
|
||||
|
||||
const isRecord = (value: unknown): value is Readonly<Record<string, unknown>> =>
|
||||
typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
+85
-204
@@ -10,29 +10,16 @@ import {
|
||||
import type { AnalysisOperationPort } from "../application/AnalysisProvider.js";
|
||||
import type { BinarySessionPort } from "../application/BinarySession.js";
|
||||
import { PRODUCT_IDENTITY } from "../identity.js";
|
||||
import { registerEnhancedTools } from "./registerEnhancedTools.js";
|
||||
import { registerOfficialTools } from "./registerOfficialTools.js";
|
||||
import { registerSessionTools } from "./registerSessionTools.js";
|
||||
import { registerNativeTools } from "./registerNativeTools.js";
|
||||
import { registerArtifactTools } from "./registerArtifactTools.js";
|
||||
import { registerManagedTools } from "./registerManagedTools.js";
|
||||
import { registerManagedWorkflowTools } from "./registerManagedWorkflowTools.js";
|
||||
import { silentLogger, type Logger } from "../logger.js";
|
||||
import type { ProcessExecutionPolicy } from "../domain/processCapture.js";
|
||||
import type { EvidenceFilePolicy } from "../domain/evidenceBundle.js";
|
||||
import { registerGuidedPrompts } from "./registerPrompts.js";
|
||||
import { registerEvidenceResources } from "./registerEvidenceResources.js";
|
||||
import { createServerIdentity } from "../serverIdentity.js";
|
||||
import type { PermissionAuthority } from "../application/PermissionAuthority.js";
|
||||
import type { BrowserObservationPort } from "../application/BrowserObservationPort.js";
|
||||
import type { BrowserScenarioCapturePort } from "../application/BrowserScenarioCapturePort.js";
|
||||
import { registerBrowserTools } from "./registerBrowserTools.js";
|
||||
import { registerBrowserScenarioTool } from "./registerBrowserScenarioTool.js";
|
||||
import type { ElectronObservationPort } from "../application/ElectronObservationPort.js";
|
||||
import { registerElectronTools } from "./registerElectronTools.js";
|
||||
import type { JavaScriptRuntimeObservationPort } from "../application/JavaScriptRuntimeObservationPort.js";
|
||||
import { registerJavaScriptRuntimeObservationTools } from "./registerJavaScriptRuntimeObservationTools.js";
|
||||
import { registerApplicationTools } from "./registerApplicationTools.js";
|
||||
import type { SessionAvailability } from "./sessionAvailabilityPolicy.js";
|
||||
import { sessionAvailabilityPolicy } from "./sessionAvailabilityPolicy.js";
|
||||
import {
|
||||
@@ -52,12 +39,11 @@ import type {
|
||||
JavaScriptReplayRunner,
|
||||
} from "../application/JavaScriptReplayPlanning.js";
|
||||
import type { ManagedRuntimePolicy } from "../application/ManagedRuntimeCorrelationService.js";
|
||||
import { LinuxJavaScriptReplayRunner } from "../replay/LinuxJavaScriptReplayRunner.js";
|
||||
import { SystemJavaScriptReplayHost } from "../replay/SystemJavaScriptReplayHost.js";
|
||||
import {
|
||||
PROCESS_CAPTURE_ELICITATION_POLICY,
|
||||
type ProcessCaptureElicitationState,
|
||||
} from "./ProcessCaptureElicitation.js";
|
||||
import { LazyToolCatalog } from "./LazyToolCatalog.js";
|
||||
|
||||
export interface CreateServerOptions {
|
||||
readonly logger?: Logger;
|
||||
@@ -76,6 +62,12 @@ export interface CreateServerOptions {
|
||||
readonly javascriptReplayRunner?: JavaScriptReplayRunner;
|
||||
readonly managedRuntimePolicy?: ManagedRuntimePolicy;
|
||||
readonly availabilityPolicy?: () => SessionAvailability;
|
||||
readonly loadOptionalProviders?: () => Promise<{
|
||||
readonly browserObservation: BrowserObservationPort;
|
||||
readonly browserScenarioCapture: BrowserScenarioCapturePort;
|
||||
readonly electronObservation: ElectronObservationPort;
|
||||
readonly javascriptRuntimeObservation: JavaScriptRuntimeObservationPort;
|
||||
}>;
|
||||
}
|
||||
|
||||
const installSessionToolAvailability = (
|
||||
@@ -155,51 +147,88 @@ export const createServer = (
|
||||
const toolLogger = logger.child({ layer: "server" });
|
||||
const { activeTarget, recordEvidence, recordEvidenceWithUnknown } =
|
||||
createSessionRecorders(server, session);
|
||||
const toolContext = {
|
||||
server,
|
||||
analysis,
|
||||
session,
|
||||
options,
|
||||
logger: toolLogger,
|
||||
permissionAuthority,
|
||||
activeTarget,
|
||||
recordEvidence,
|
||||
recordEvidenceWithUnknown,
|
||||
const processCaptureElicitation = {
|
||||
stateCodec: processCaptureStateCodec,
|
||||
supported: (context: {
|
||||
readonly mcpReq: {
|
||||
readonly envelope?: Readonly<Record<string, unknown>>;
|
||||
};
|
||||
}) => {
|
||||
const envelope = context.mcpReq.envelope;
|
||||
const version = envelope?.[PROTOCOL_VERSION_META_KEY];
|
||||
const capabilities = envelope?.[CLIENT_CAPABILITIES_META_KEY];
|
||||
return (
|
||||
typeof version === "string" &&
|
||||
PROCESS_CAPTURE_ELICITATION_POLICY.protocolVersions.some(
|
||||
(supported) => supported === version,
|
||||
) &&
|
||||
isRecord(capabilities) &&
|
||||
isRecord(capabilities.elicitation) &&
|
||||
capabilities.elicitation.form !== undefined
|
||||
);
|
||||
},
|
||||
now: Date.now,
|
||||
consumedNonces: new Map<string, number>(),
|
||||
};
|
||||
registerBinaryAnalysisTools(toolContext);
|
||||
registerObservationTools(toolContext);
|
||||
let optionalProviders:
|
||||
| ReturnType<NonNullable<CreateServerOptions["loadOptionalProviders"]>>
|
||||
| undefined;
|
||||
const loadOptionalProviders = () => {
|
||||
optionalProviders ??= options
|
||||
.loadOptionalProviders?.()
|
||||
.catch((cause: unknown) => {
|
||||
optionalProviders = undefined;
|
||||
throw cause;
|
||||
});
|
||||
return optionalProviders;
|
||||
};
|
||||
const lazyTools = new LazyToolCatalog(
|
||||
server,
|
||||
async (kind) => {
|
||||
const needsObservationProviders =
|
||||
kind === "browser-provider" ||
|
||||
kind === "electron-provider" ||
|
||||
kind === "runtime-provider";
|
||||
const [{ hydrateServerToolFamily }, loadedOptionalProviders] =
|
||||
await Promise.all([
|
||||
import("./hydrateServerTools.js"),
|
||||
needsObservationProviders ? loadOptionalProviders() : undefined,
|
||||
]);
|
||||
const hydratedOptions =
|
||||
loadedOptionalProviders === undefined
|
||||
? options
|
||||
: { ...options, ...loadedOptionalProviders };
|
||||
await hydrateServerToolFamily({
|
||||
kind,
|
||||
server,
|
||||
analysis,
|
||||
session,
|
||||
options: hydratedOptions,
|
||||
context: {
|
||||
logger: toolLogger,
|
||||
permissionAuthority,
|
||||
activeTarget,
|
||||
recordEvidence,
|
||||
recordEvidenceWithUnknown,
|
||||
availabilityPolicy: dynamicTools?.policy,
|
||||
startedAt,
|
||||
processCaptureElicitation,
|
||||
},
|
||||
});
|
||||
},
|
||||
session !== undefined,
|
||||
);
|
||||
lazyTools.register();
|
||||
registerGuidedPrompts(server, analysis, session);
|
||||
if (session !== undefined) {
|
||||
registerEvidenceResources(server, session);
|
||||
registerSessionTools(server, session, toolLogger, {
|
||||
...options,
|
||||
...(dynamicTools === undefined
|
||||
? {}
|
||||
: { availabilityPolicy: dynamicTools.policy }),
|
||||
...(permissionAuthority === undefined ? {} : { permissionAuthority }),
|
||||
startedAt,
|
||||
processCaptureElicitation: {
|
||||
stateCodec: processCaptureStateCodec,
|
||||
supported: (context) => {
|
||||
const envelope = context.mcpReq.envelope;
|
||||
const version = envelope?.[PROTOCOL_VERSION_META_KEY];
|
||||
const capabilities = envelope?.[CLIENT_CAPABILITIES_META_KEY];
|
||||
return (
|
||||
typeof version === "string" &&
|
||||
PROCESS_CAPTURE_ELICITATION_POLICY.protocolVersions.some(
|
||||
(supported) => supported === version,
|
||||
) &&
|
||||
isRecord(capabilities) &&
|
||||
isRecord(capabilities.elicitation) &&
|
||||
capabilities.elicitation.form !== undefined
|
||||
);
|
||||
},
|
||||
now: Date.now,
|
||||
consumedNonces: new Map(),
|
||||
},
|
||||
});
|
||||
}
|
||||
dynamicTools?.controller.synchronize();
|
||||
const close = server.close.bind(server);
|
||||
server.close = async () => {
|
||||
await lazyTools.close();
|
||||
await close();
|
||||
};
|
||||
return server;
|
||||
};
|
||||
|
||||
@@ -243,7 +272,8 @@ const registerServerIdentityResource = (
|
||||
description: "Live package, SDK, protocol, and catalog identity.",
|
||||
mimeType: "application/json",
|
||||
},
|
||||
(uri) => {
|
||||
async (uri) => {
|
||||
const { createServerIdentity } = await import("../serverIdentity.js");
|
||||
const client = server.server.getClientVersion();
|
||||
const protocolVersion = server.server.getNegotiatedProtocolVersion();
|
||||
return {
|
||||
@@ -267,154 +297,5 @@ const registerServerIdentityResource = (
|
||||
);
|
||||
};
|
||||
|
||||
interface ServerToolContext extends ReturnType<typeof createSessionRecorders> {
|
||||
readonly server: McpServer;
|
||||
readonly analysis: AnalysisOperationPort;
|
||||
readonly session: BinarySessionPort | undefined;
|
||||
readonly options: CreateServerOptions;
|
||||
readonly logger: Logger;
|
||||
readonly permissionAuthority: PermissionAuthority | undefined;
|
||||
}
|
||||
|
||||
const registerBinaryAnalysisTools = ({
|
||||
server,
|
||||
analysis,
|
||||
session,
|
||||
options,
|
||||
logger,
|
||||
permissionAuthority,
|
||||
activeTarget,
|
||||
recordEvidence,
|
||||
recordEvidenceWithUnknown,
|
||||
}: ServerToolContext): void => {
|
||||
const recordUnknown =
|
||||
session === undefined
|
||||
? undefined
|
||||
: (input: Parameters<typeof session.recordUnknown>[0]) =>
|
||||
session.recordUnknown(input);
|
||||
registerOfficialTools(server, analysis, {
|
||||
logger,
|
||||
activeTarget,
|
||||
recordEvidence,
|
||||
recordUnknown,
|
||||
});
|
||||
registerEnhancedTools(server, analysis, {
|
||||
logger,
|
||||
activeTarget,
|
||||
analysisProfile:
|
||||
session === undefined ? undefined : () => session.analysisProfile(),
|
||||
recordEvidence,
|
||||
recordUnknown,
|
||||
});
|
||||
registerNativeTools(server, analysis, {
|
||||
logger,
|
||||
activeTarget,
|
||||
recordEvidence,
|
||||
});
|
||||
registerArtifactTools(server, analysis, {
|
||||
logger,
|
||||
activeTarget,
|
||||
recordEvidence,
|
||||
...(permissionAuthority === undefined ? {} : { permissionAuthority }),
|
||||
});
|
||||
registerManagedTools(server, analysis, {
|
||||
logger,
|
||||
activeTarget,
|
||||
recordEvidence,
|
||||
session,
|
||||
});
|
||||
if (session !== undefined)
|
||||
registerManagedWorkflowTools(server, {
|
||||
logger,
|
||||
recordEvidence,
|
||||
recordEvidenceWithUnknown,
|
||||
session,
|
||||
runtime: {
|
||||
policy: options.managedRuntimePolicy ?? {
|
||||
enabled: false,
|
||||
roots: [],
|
||||
executablePath: "/usr/bin/dotnet",
|
||||
},
|
||||
authority: permissionAuthority,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const registerObservationTools = ({
|
||||
server,
|
||||
session,
|
||||
options,
|
||||
logger,
|
||||
permissionAuthority,
|
||||
recordEvidence,
|
||||
recordEvidenceWithUnknown,
|
||||
}: ServerToolContext): void => {
|
||||
registerBrowserTools(server, {
|
||||
logger,
|
||||
browser: options.browserObservation,
|
||||
permissionAuthority,
|
||||
recordEvidence,
|
||||
});
|
||||
registerBrowserScenarioTool(server, {
|
||||
logger,
|
||||
provider: options.browserScenarioCapture,
|
||||
permissionAuthority,
|
||||
recordEvidence,
|
||||
});
|
||||
registerElectronTools(server, {
|
||||
logger,
|
||||
electron: options.electronObservation,
|
||||
permissionAuthority,
|
||||
recordEvidence,
|
||||
});
|
||||
registerJavaScriptRuntimeObservationTools(server, {
|
||||
logger,
|
||||
runtime: options.javascriptRuntimeObservation,
|
||||
permissionAuthority,
|
||||
recordEvidence,
|
||||
});
|
||||
registerApplicationTools(server, {
|
||||
logger,
|
||||
recordEvidence,
|
||||
recordEvidenceWithUnknown,
|
||||
evidenceLookup:
|
||||
session === undefined
|
||||
? undefined
|
||||
: (evidenceId) => session.evidenceById(evidenceId),
|
||||
evidenceFilePolicy: options.evidenceFilePolicy ?? {
|
||||
roots: [],
|
||||
maxBytes: 1,
|
||||
maxDepth: 1,
|
||||
maxStringLength: 1,
|
||||
maxNodes: 1,
|
||||
},
|
||||
permissionAuthority,
|
||||
retainCoverageWorkspace:
|
||||
session === undefined
|
||||
? undefined
|
||||
: (workspace) => {
|
||||
const retained =
|
||||
session.retainReconstructionCoverageWorkspace(workspace);
|
||||
if (retained === "added") server.sendResourceListChanged();
|
||||
return retained;
|
||||
},
|
||||
replay: {
|
||||
policy: options.javascriptReplayPolicy ?? {
|
||||
enabled: false,
|
||||
roots: [],
|
||||
nodePath: process.execPath,
|
||||
bubblewrapPath: "/usr/bin/bwrap",
|
||||
systemdRunPath: "/usr/bin/systemd-run",
|
||||
systemctlPath: "/usr/bin/systemctl",
|
||||
shellPath: "/usr/bin/bash",
|
||||
},
|
||||
host: options.javascriptReplayHost ?? new SystemJavaScriptReplayHost(),
|
||||
runner:
|
||||
options.javascriptReplayRunner ?? new LinuxJavaScriptReplayRunner(),
|
||||
authority: permissionAuthority,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
import type { McpServer } from "@modelcontextprotocol/server";
|
||||
|
||||
import type { AnalysisOperationPort } from "../application/AnalysisProvider.js";
|
||||
import type { BinarySessionPort } from "../application/BinarySession.js";
|
||||
import type { PermissionAuthority } from "../application/PermissionAuthority.js";
|
||||
import type { BinaryTarget } from "../domain/binaryTarget.js";
|
||||
import type { Logger } from "../logger.js";
|
||||
import type { ToolKind } from "../contracts/toolContractTypes.js";
|
||||
import type { CreateServerOptions } from "./createServer.js";
|
||||
import type { ProcessCaptureElicitation } from "./ProcessCaptureElicitation.js";
|
||||
import type { SessionAvailability } from "./sessionAvailabilityPolicy.js";
|
||||
|
||||
interface HydratedToolContext {
|
||||
readonly logger: Logger;
|
||||
readonly permissionAuthority: PermissionAuthority | undefined;
|
||||
readonly activeTarget: (() => BinaryTarget | undefined) | undefined;
|
||||
readonly recordEvidence: BinarySessionPort["recordEvidence"] | undefined;
|
||||
readonly recordEvidenceWithUnknown:
|
||||
| BinarySessionPort["recordEvidenceWithUnknown"]
|
||||
| undefined;
|
||||
readonly availabilityPolicy: (() => SessionAvailability) | undefined;
|
||||
readonly startedAt: string;
|
||||
readonly processCaptureElicitation: ProcessCaptureElicitation;
|
||||
}
|
||||
|
||||
export interface HydrateServerToolFamilyInput {
|
||||
readonly kind: ToolKind;
|
||||
readonly server: McpServer;
|
||||
readonly analysis: AnalysisOperationPort;
|
||||
readonly session: BinarySessionPort | undefined;
|
||||
readonly options: CreateServerOptions;
|
||||
readonly context: HydratedToolContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Install production callbacks on generated lazy tool registrations.
|
||||
*/
|
||||
export const hydrateServerToolFamily = async ({
|
||||
kind,
|
||||
server,
|
||||
analysis,
|
||||
session,
|
||||
options,
|
||||
context,
|
||||
}: HydrateServerToolFamilyInput): Promise<void> => {
|
||||
const toolContext = {
|
||||
server,
|
||||
analysis,
|
||||
session,
|
||||
options,
|
||||
logger: context.logger,
|
||||
permissionAuthority: context.permissionAuthority,
|
||||
activeTarget: context.activeTarget,
|
||||
recordEvidence: context.recordEvidence,
|
||||
recordEvidenceWithUnknown: context.recordEvidenceWithUnknown,
|
||||
};
|
||||
switch (kind) {
|
||||
case "official-proxy": {
|
||||
const { registerOfficialTools } = await import(
|
||||
"./registerOfficialTools.js"
|
||||
);
|
||||
registerOfficialTools(server, analysis, analysisOptions(toolContext));
|
||||
return;
|
||||
}
|
||||
case "enhanced": {
|
||||
const { registerEnhancedTools } = await import(
|
||||
"./registerEnhancedTools.js"
|
||||
);
|
||||
registerEnhancedTools(server, analysis, enhancedOptions(toolContext));
|
||||
return;
|
||||
}
|
||||
case "native-provider": {
|
||||
const { registerNativeTools } = await import("./registerNativeTools.js");
|
||||
registerNativeTools(server, analysis, evidenceOptions(toolContext));
|
||||
return;
|
||||
}
|
||||
case "artifact-provider": {
|
||||
const { registerArtifactTools } = await import(
|
||||
"./registerArtifactTools.js"
|
||||
);
|
||||
registerArtifactTools(server, analysis, {
|
||||
...evidenceOptions(toolContext),
|
||||
...(context.permissionAuthority === undefined
|
||||
? {}
|
||||
: { permissionAuthority: context.permissionAuthority }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
case "managed-provider": {
|
||||
const { registerManagedTools } = await import(
|
||||
"./registerManagedTools.js"
|
||||
);
|
||||
registerManagedTools(server, analysis, {
|
||||
...evidenceOptions(toolContext),
|
||||
session,
|
||||
});
|
||||
return;
|
||||
}
|
||||
case "application":
|
||||
await registerApplicationFamily(toolContext);
|
||||
return;
|
||||
case "browser-provider":
|
||||
case "electron-provider":
|
||||
case "runtime-provider": {
|
||||
await hydrateObservationFamily(kind, toolContext);
|
||||
return;
|
||||
}
|
||||
case "session": {
|
||||
if (session === undefined)
|
||||
throw new TypeError("Session tools require a binary session");
|
||||
const { registerSessionTools } = await import(
|
||||
"./registerSessionTools.js"
|
||||
);
|
||||
registerSessionTools(server, session, context.logger, {
|
||||
...options,
|
||||
...(context.availabilityPolicy === undefined
|
||||
? {}
|
||||
: { availabilityPolicy: context.availabilityPolicy }),
|
||||
...(context.permissionAuthority === undefined
|
||||
? {}
|
||||
: { permissionAuthority: context.permissionAuthority }),
|
||||
startedAt: context.startedAt,
|
||||
processCaptureElicitation: context.processCaptureElicitation,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const hydrateObservationFamily = async (
|
||||
kind: "browser-provider" | "electron-provider" | "runtime-provider",
|
||||
context: ServerToolContext,
|
||||
): Promise<void> => {
|
||||
const common = {
|
||||
logger: context.logger,
|
||||
permissionAuthority: context.permissionAuthority,
|
||||
recordEvidence: context.recordEvidence,
|
||||
};
|
||||
if (kind === "browser-provider") {
|
||||
const [{ registerBrowserTools }, { registerBrowserScenarioTool }] =
|
||||
await Promise.all([
|
||||
import("./registerBrowserTools.js"),
|
||||
import("./registerBrowserScenarioTool.js"),
|
||||
]);
|
||||
registerBrowserTools(context.server, {
|
||||
...common,
|
||||
browser: context.options.browserObservation,
|
||||
});
|
||||
registerBrowserScenarioTool(context.server, {
|
||||
...common,
|
||||
provider: context.options.browserScenarioCapture,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (kind === "electron-provider") {
|
||||
const { registerElectronTools } = await import(
|
||||
"./registerElectronTools.js"
|
||||
);
|
||||
registerElectronTools(context.server, {
|
||||
...common,
|
||||
electron: context.options.electronObservation,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const { registerJavaScriptRuntimeObservationTools } = await import(
|
||||
"./registerJavaScriptRuntimeObservationTools.js"
|
||||
);
|
||||
registerJavaScriptRuntimeObservationTools(context.server, {
|
||||
...common,
|
||||
runtime: context.options.javascriptRuntimeObservation,
|
||||
});
|
||||
};
|
||||
|
||||
interface ServerToolContext {
|
||||
readonly server: McpServer;
|
||||
readonly analysis: AnalysisOperationPort;
|
||||
readonly session: BinarySessionPort | undefined;
|
||||
readonly options: CreateServerOptions;
|
||||
readonly logger: Logger;
|
||||
readonly permissionAuthority: PermissionAuthority | undefined;
|
||||
readonly activeTarget: (() => BinaryTarget | undefined) | undefined;
|
||||
readonly recordEvidence: BinarySessionPort["recordEvidence"] | undefined;
|
||||
readonly recordEvidenceWithUnknown:
|
||||
| BinarySessionPort["recordEvidenceWithUnknown"]
|
||||
| undefined;
|
||||
}
|
||||
|
||||
const analysisOptions = ({
|
||||
logger,
|
||||
activeTarget,
|
||||
recordEvidence,
|
||||
session,
|
||||
}: ServerToolContext) => {
|
||||
const recordUnknown =
|
||||
session === undefined
|
||||
? undefined
|
||||
: (input: Parameters<typeof session.recordUnknown>[0]) =>
|
||||
session.recordUnknown(input);
|
||||
return {
|
||||
logger,
|
||||
activeTarget,
|
||||
recordEvidence,
|
||||
recordUnknown,
|
||||
};
|
||||
};
|
||||
|
||||
const enhancedOptions = (context: ServerToolContext) => ({
|
||||
...analysisOptions(context),
|
||||
analysisProfile:
|
||||
context.session === undefined
|
||||
? undefined
|
||||
: () => context.session?.analysisProfile(),
|
||||
});
|
||||
|
||||
const evidenceOptions = ({
|
||||
logger,
|
||||
activeTarget,
|
||||
recordEvidence,
|
||||
}: ServerToolContext) => ({
|
||||
logger,
|
||||
activeTarget,
|
||||
recordEvidence,
|
||||
});
|
||||
|
||||
const registerApplicationFamily = async ({
|
||||
server,
|
||||
session,
|
||||
options,
|
||||
logger,
|
||||
permissionAuthority,
|
||||
recordEvidence,
|
||||
recordEvidenceWithUnknown,
|
||||
}: ServerToolContext): Promise<void> => {
|
||||
const [
|
||||
{ registerApplicationTools },
|
||||
{ registerManagedWorkflowTools },
|
||||
{ LinuxJavaScriptReplayRunner },
|
||||
{ SystemJavaScriptReplayHost },
|
||||
] = await Promise.all([
|
||||
import("./registerApplicationTools.js"),
|
||||
import("./registerManagedWorkflowTools.js"),
|
||||
import("../replay/LinuxJavaScriptReplayRunner.js"),
|
||||
import("../replay/SystemJavaScriptReplayHost.js"),
|
||||
]);
|
||||
if (session !== undefined)
|
||||
registerManagedWorkflowTools(server, {
|
||||
logger,
|
||||
recordEvidence,
|
||||
recordEvidenceWithUnknown,
|
||||
session,
|
||||
runtime: {
|
||||
policy: options.managedRuntimePolicy ?? {
|
||||
enabled: false,
|
||||
roots: [],
|
||||
executablePath: "/usr/bin/dotnet",
|
||||
},
|
||||
authority: permissionAuthority,
|
||||
},
|
||||
});
|
||||
registerApplicationTools(server, {
|
||||
logger,
|
||||
recordEvidence,
|
||||
recordEvidenceWithUnknown,
|
||||
evidenceLookup:
|
||||
session === undefined
|
||||
? undefined
|
||||
: (evidenceId) => session.evidenceById(evidenceId),
|
||||
evidenceFilePolicy: options.evidenceFilePolicy ?? {
|
||||
roots: [],
|
||||
maxBytes: 1,
|
||||
maxDepth: 1,
|
||||
maxStringLength: 1,
|
||||
maxNodes: 1,
|
||||
},
|
||||
permissionAuthority,
|
||||
retainCoverageWorkspace:
|
||||
session === undefined
|
||||
? undefined
|
||||
: (workspace) => {
|
||||
const retained =
|
||||
session.retainReconstructionCoverageWorkspace(workspace);
|
||||
if (retained === "added") server.sendResourceListChanged();
|
||||
return retained;
|
||||
},
|
||||
replay: {
|
||||
policy: options.javascriptReplayPolicy ?? {
|
||||
enabled: false,
|
||||
roots: [],
|
||||
nodePath: process.execPath,
|
||||
bubblewrapPath: "/usr/bin/bwrap",
|
||||
systemdRunPath: "/usr/bin/systemd-run",
|
||||
systemctlPath: "/usr/bin/systemctl",
|
||||
shellPath: "/usr/bin/bash",
|
||||
},
|
||||
host: options.javascriptReplayHost ?? new SystemJavaScriptReplayHost(),
|
||||
runner:
|
||||
options.javascriptReplayRunner ?? new LinuxJavaScriptReplayRunner(),
|
||||
authority: permissionAuthority,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -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: [],
|
||||
|
||||
@@ -2,6 +2,15 @@ import { z } from "zod";
|
||||
|
||||
import type { ToolContract } from "../contracts/toolContracts.js";
|
||||
|
||||
const ADVERTISED_INPUT_SCHEMAS = new WeakMap<
|
||||
z.ZodObject,
|
||||
Readonly<Record<string, unknown>>
|
||||
>();
|
||||
const ADVERTISED_OUTPUT_SCHEMAS = new WeakMap<
|
||||
z.ZodObject,
|
||||
Readonly<Record<string, unknown>>
|
||||
>();
|
||||
|
||||
/** Project the public contract fields accepted by MCP tool registration. */
|
||||
export const toolRegistrationOptions = (contract: ToolContract) => ({
|
||||
title: contract.title,
|
||||
@@ -22,10 +31,8 @@ const advertisedInputSchema = (schema: z.ZodObject) => ({
|
||||
vendor: "rea",
|
||||
validate: (input: unknown) => ({ value: input }),
|
||||
jsonSchema: {
|
||||
input: () =>
|
||||
describeProperties(closeObjectSchemas(inputJsonSchema(schema))),
|
||||
output: () =>
|
||||
describeProperties(closeObjectSchemas(inputJsonSchema(schema))),
|
||||
input: () => advertisedInputJsonSchema(schema),
|
||||
output: () => advertisedInputJsonSchema(schema),
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -41,19 +48,42 @@ const advertisedOutputSchema = (schema: z.ZodObject) => {
|
||||
"~standard": {
|
||||
...standard,
|
||||
jsonSchema: {
|
||||
input: () =>
|
||||
describeRootProperties(
|
||||
closeObjectSchemas(converter({ target: "draft-2020-12" })),
|
||||
),
|
||||
output: () =>
|
||||
describeRootProperties(
|
||||
closeObjectSchemas(converter({ target: "draft-2020-12" })),
|
||||
),
|
||||
input: () => advertisedOutputJsonSchema(schema, converter),
|
||||
output: () => advertisedOutputJsonSchema(schema, converter),
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const advertisedInputJsonSchema = (
|
||||
schema: z.ZodObject,
|
||||
): Readonly<Record<string, unknown>> =>
|
||||
cachedJsonSchema(ADVERTISED_INPUT_SCHEMAS, schema, () =>
|
||||
describeProperties(closeObjectSchemas(inputJsonSchema(schema))),
|
||||
);
|
||||
|
||||
const advertisedOutputJsonSchema = (
|
||||
schema: z.ZodObject,
|
||||
converter: NonNullable<z.ZodObject["~standard"]["jsonSchema"]>["output"],
|
||||
): Readonly<Record<string, unknown>> =>
|
||||
cachedJsonSchema(ADVERTISED_OUTPUT_SCHEMAS, schema, () =>
|
||||
describeRootProperties(
|
||||
closeObjectSchemas(converter({ target: "draft-2020-12" })),
|
||||
),
|
||||
);
|
||||
|
||||
const cachedJsonSchema = (
|
||||
cache: WeakMap<z.ZodObject, Readonly<Record<string, unknown>>>,
|
||||
schema: z.ZodObject,
|
||||
create: () => Record<string, unknown>,
|
||||
): Readonly<Record<string, unknown>> => {
|
||||
const cached = cache.get(schema);
|
||||
if (cached !== undefined) return cached;
|
||||
const created = create();
|
||||
cache.set(schema, created);
|
||||
return created;
|
||||
};
|
||||
|
||||
const inputJsonSchema = (schema: z.ZodObject): Record<string, unknown> => {
|
||||
const converter = schema["~standard"].jsonSchema?.input;
|
||||
if (converter === undefined)
|
||||
|
||||
@@ -107,12 +107,6 @@ describe("Hopper bridge truthfulness", () => {
|
||||
expect(bridgeSource).toContain("_invalidate_search_inventory(document)");
|
||||
});
|
||||
|
||||
it("classifies bridge exceptions without forwarding tracebacks", () => {
|
||||
expect(bridgeSource).toContain("def _diagnostic_type(error):");
|
||||
expect(bridgeSource).toContain('"type": _diagnostic_type(error)');
|
||||
expect(bridgeSource).not.toContain("traceback.format_exc");
|
||||
});
|
||||
|
||||
it("stops background analysis and closes the session-owned document", () => {
|
||||
expect(bridgeSource).toContain("def _session_document():");
|
||||
expect(bridgeSource).toContain("os.path.realpath(REA_TARGET_PATH)");
|
||||
|
||||
@@ -19,10 +19,82 @@ __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_dense_switch(
|
||||
unsigned int selector, int value) {
|
||||
switch (selector) {
|
||||
case 0: return rea_ghidra_inventory_leaf(value + 0);
|
||||
case 1: return rea_ghidra_inventory_leaf(value + 1);
|
||||
case 2: return rea_ghidra_inventory_leaf(value + 2);
|
||||
case 3: return rea_ghidra_inventory_leaf(value + 3);
|
||||
case 4: return rea_ghidra_inventory_leaf(value + 4);
|
||||
case 5: return rea_ghidra_inventory_leaf(value + 5);
|
||||
case 6: return rea_ghidra_inventory_leaf(value + 6);
|
||||
case 7: return rea_ghidra_inventory_leaf(value + 7);
|
||||
case 8: return rea_ghidra_inventory_leaf(value + 8);
|
||||
case 9: return rea_ghidra_inventory_leaf(value + 9);
|
||||
case 10: return rea_ghidra_inventory_leaf(value + 10);
|
||||
case 11: return rea_ghidra_inventory_leaf(value + 11);
|
||||
case 12: return rea_ghidra_inventory_leaf(value + 12);
|
||||
case 13: return rea_ghidra_inventory_leaf(value + 13);
|
||||
case 14: return rea_ghidra_inventory_leaf(value + 14);
|
||||
case 15: return rea_ghidra_inventory_leaf(value + 15);
|
||||
case 16: return rea_ghidra_inventory_leaf(value + 16);
|
||||
case 17: return rea_ghidra_inventory_leaf(value + 17);
|
||||
case 18: return rea_ghidra_inventory_leaf(value + 18);
|
||||
case 19: return rea_ghidra_inventory_leaf(value + 19);
|
||||
case 20: return rea_ghidra_inventory_leaf(value + 20);
|
||||
case 21: return rea_ghidra_inventory_leaf(value + 21);
|
||||
case 22: return rea_ghidra_inventory_leaf(value + 22);
|
||||
case 23: return rea_ghidra_inventory_leaf(value + 23);
|
||||
case 24: return rea_ghidra_inventory_leaf(value + 24);
|
||||
case 25: return rea_ghidra_inventory_leaf(value + 25);
|
||||
case 26: return rea_ghidra_inventory_leaf(value + 26);
|
||||
case 27: return rea_ghidra_inventory_leaf(value + 27);
|
||||
case 28: return rea_ghidra_inventory_leaf(value + 28);
|
||||
case 29: return rea_ghidra_inventory_leaf(value + 29);
|
||||
case 30: return rea_ghidra_inventory_leaf(value + 30);
|
||||
case 31: return rea_ghidra_inventory_leaf(value + 31);
|
||||
case 32: return rea_ghidra_inventory_leaf(value + 32);
|
||||
case 33: return rea_ghidra_inventory_leaf(value + 33);
|
||||
case 34: return rea_ghidra_inventory_leaf(value + 34);
|
||||
case 35: return rea_ghidra_inventory_leaf(value + 35);
|
||||
case 36: return rea_ghidra_inventory_leaf(value + 36);
|
||||
case 37: return rea_ghidra_inventory_leaf(value + 37);
|
||||
case 38: return rea_ghidra_inventory_leaf(value + 38);
|
||||
case 39: return rea_ghidra_inventory_leaf(value + 39);
|
||||
default: return -1;
|
||||
}
|
||||
}
|
||||
|
||||
__attribute__((noinline, used)) int rea_ghidra_inventory_entry(void) {
|
||||
puts("REA_GHIDRA_INVENTORY_ENTRY");
|
||||
return rea_ghidra_inventory_branch(35) +
|
||||
rea_ghidra_inventory_indirect(rea_ghidra_inventory_leaf, 0);
|
||||
rea_ghidra_inventory_indirect(rea_ghidra_inventory_leaf, 0) +
|
||||
rea_ghidra_inventory_dense_switch(100, 0) + 1;
|
||||
}
|
||||
|
||||
int main(void) { return rea_ghidra_inventory_entry() == 49 ? 0 : 1; }
|
||||
|
||||
@@ -62,6 +62,21 @@ const emptySchemaPaths = (value: unknown, path = "$"): string[] => {
|
||||
};
|
||||
|
||||
describe("tool contract surface", () => {
|
||||
it("reuses finalized advertised schemas", () => {
|
||||
const contract = TOOL_CONTRACTS[0];
|
||||
expect(contract).toBeDefined();
|
||||
if (contract === undefined) return;
|
||||
|
||||
const first = toolRegistrationOptions(contract);
|
||||
const second = toolRegistrationOptions(contract);
|
||||
expect(first.inputSchema["~standard"].jsonSchema.input()).toBe(
|
||||
second.inputSchema["~standard"].jsonSchema.output(),
|
||||
);
|
||||
expect(first.outputSchema["~standard"].jsonSchema.input()).toBe(
|
||||
second.outputSchema["~standard"].jsonSchema.output(),
|
||||
);
|
||||
});
|
||||
|
||||
it("gives fallback parameter guidance operational meaning", () => {
|
||||
const contract = TOOL_CONTRACTS.find(
|
||||
({ name }) => name === "analyze_function",
|
||||
|
||||
@@ -25,6 +25,10 @@ describe("executable dispatcher", () => {
|
||||
await Promise.all([mkdir(scripts), mkdir(dist)]);
|
||||
await Promise.all([
|
||||
copyFile("scripts/rea.mjs", join(scripts, "rea.mjs")),
|
||||
copyFile(
|
||||
"scripts/package-runner-bootstrap.mjs",
|
||||
join(scripts, "package-runner-bootstrap.mjs"),
|
||||
),
|
||||
writeFile(
|
||||
join(fixtureRoot, "package.json"),
|
||||
JSON.stringify({ type: "module", version: "1.0.0" }),
|
||||
@@ -77,6 +81,10 @@ describe("executable dispatcher", () => {
|
||||
await mkdir(scripts);
|
||||
await Promise.all([
|
||||
copyFile("scripts/rea.mjs", join(scripts, "rea.mjs")),
|
||||
copyFile(
|
||||
"scripts/package-runner-bootstrap.mjs",
|
||||
join(scripts, "package-runner-bootstrap.mjs"),
|
||||
),
|
||||
writeFile(
|
||||
join(fixtureRoot, "package.json"),
|
||||
JSON.stringify({ type: "module", version: "1.0.0" }),
|
||||
|
||||
@@ -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
+68
-14
@@ -16,6 +16,43 @@ const capabilityUnavailableResult = (id) => ({
|
||||
type: "capability_unavailable",
|
||||
},
|
||||
});
|
||||
const bridgeEventFixtureMessages = (request) => {
|
||||
const progress = {
|
||||
type: "progress",
|
||||
phase: "hopper_bridge",
|
||||
completed: 0,
|
||||
total: 1,
|
||||
message: "Hopper bridge started request",
|
||||
};
|
||||
switch (request.method) {
|
||||
case "wrong_event_id":
|
||||
return [{ id: request.id + 100, event: progress }];
|
||||
case "malformed_event":
|
||||
return [
|
||||
{
|
||||
id: request.id,
|
||||
event: {
|
||||
...progress,
|
||||
completed: 2,
|
||||
message: "invalid progress",
|
||||
},
|
||||
},
|
||||
];
|
||||
case "remote_error": {
|
||||
const error = {
|
||||
code: -32001,
|
||||
message: "safe fake failure",
|
||||
type: "bridge_exception",
|
||||
};
|
||||
return [
|
||||
{ id: request.id, event: { type: "diagnostic", error } },
|
||||
{ id: request.id, error },
|
||||
];
|
||||
}
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
const enhancedFixtureResult = (method) => {
|
||||
switch (method) {
|
||||
case "list_segments":
|
||||
@@ -109,6 +146,33 @@ const enhancedFixtureResult = (method) => {
|
||||
}
|
||||
};
|
||||
|
||||
const sendEchoWithProgress = (send, request) => {
|
||||
send({
|
||||
id: request.id,
|
||||
event: {
|
||||
type: "progress",
|
||||
phase: "hopper_bridge",
|
||||
completed: 0,
|
||||
total: 1,
|
||||
message: "Hopper bridge started request",
|
||||
},
|
||||
});
|
||||
setTimeout(() => {
|
||||
send({
|
||||
id: request.id,
|
||||
event: {
|
||||
type: "progress",
|
||||
phase: "hopper_bridge",
|
||||
completed: 1,
|
||||
total: 1,
|
||||
message: "Hopper bridge completed request",
|
||||
terminal: true,
|
||||
},
|
||||
});
|
||||
send({ id: request.id, result: request.params ?? {} });
|
||||
}, request.params?.delay ?? 0);
|
||||
};
|
||||
|
||||
const server = createServer((socket) => {
|
||||
socket.on("error", () => undefined);
|
||||
socket.setEncoding("utf8");
|
||||
@@ -125,6 +189,7 @@ const server = createServer((socket) => {
|
||||
let newline = buffer.indexOf("\n");
|
||||
while (newline >= 0) {
|
||||
const request = JSON.parse(buffer.slice(0, newline));
|
||||
const bridgeEventMessages = bridgeEventFixtureMessages(request);
|
||||
buffer = buffer.slice(newline + 1);
|
||||
if (request.token !== token) {
|
||||
send({ id: request.id, error: { code: -32001, message: "bad token" } });
|
||||
@@ -152,15 +217,8 @@ const server = createServer((socket) => {
|
||||
socket.write("{not-json}\n");
|
||||
} else if (request.method === "wrong_id") {
|
||||
send({ id: request.id + 100, result: {} });
|
||||
} else if (request.method === "remote_error") {
|
||||
send({
|
||||
id: request.id,
|
||||
error: {
|
||||
code: -32001,
|
||||
message: "safe fake failure",
|
||||
type: "bridge_exception",
|
||||
},
|
||||
});
|
||||
} else if (bridgeEventMessages !== undefined) {
|
||||
for (const message of bridgeEventMessages) send(message);
|
||||
} else if (request.method === "capability_unavailable") {
|
||||
send(capabilityUnavailableResult(request.id));
|
||||
} else if (request.method === "current_document") {
|
||||
@@ -194,11 +252,7 @@ const server = createServer((socket) => {
|
||||
},
|
||||
});
|
||||
} else {
|
||||
const delay = request.params?.delay ?? 0;
|
||||
setTimeout(
|
||||
() => send({ id: request.id, result: request.params ?? {} }),
|
||||
delay,
|
||||
);
|
||||
sendEchoWithProgress(send, request);
|
||||
}
|
||||
newline = buffer.indexOf("\n");
|
||||
}
|
||||
|
||||
Vendored
+93
-1
@@ -48,7 +48,98 @@ export const ghidraBounded = (items: readonly JsonValue[]) => ({
|
||||
next_offset: null,
|
||||
});
|
||||
|
||||
export const ghidraFunctionDossier = (includeAssembly = true): JsonValue => {
|
||||
export const ghidraNativeApiBoundary = (mappingsTruncated = false) => ({
|
||||
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: mappingsTruncated,
|
||||
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,
|
||||
mappingsTruncated = false,
|
||||
): JsonValue => {
|
||||
const pseudocode = "int fixture_main(void) { return 42; }";
|
||||
return {
|
||||
procedure: {
|
||||
@@ -83,6 +174,7 @@ export const ghidraFunctionDossier = (includeAssembly = true): JsonValue => {
|
||||
{ start: "0x401000", end: "0x401006", successors: [] },
|
||||
]),
|
||||
instruction_scan: { scanned: 2, truncated: false },
|
||||
native_api: ghidraNativeApiBoundary(mappingsTruncated),
|
||||
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.",
|
||||
|
||||
+32
@@ -2,7 +2,9 @@
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
import socket
|
||||
import sys
|
||||
import threading
|
||||
|
||||
|
||||
class FakeDocument:
|
||||
@@ -74,6 +76,34 @@ def main():
|
||||
"message": str(error),
|
||||
}
|
||||
|
||||
bridge["REA_TOKEN"] = "probe-token"
|
||||
bridge["_dispatch"] = lambda method, params: (
|
||||
(_ for _ in ()).throw(RuntimeError("credential=supersecret"))
|
||||
if method == "fail"
|
||||
else params
|
||||
)
|
||||
server_socket, client_socket = socket.socketpair()
|
||||
worker = threading.Thread(
|
||||
target=bridge["_serve_connection"], args=(server_socket,)
|
||||
)
|
||||
worker.start()
|
||||
client_file = client_socket.makefile("rwb")
|
||||
client_file.write(
|
||||
b'{"id":7,"token":"probe-token","method":"fail","params":{}}\n'
|
||||
)
|
||||
client_file.flush()
|
||||
bridge_messages = [
|
||||
json.loads(client_file.readline().decode("utf-8")) for _ in range(3)
|
||||
]
|
||||
client_file.write(
|
||||
b'{"id":-1,"token":"probe-token","method":"echo","params":{}}\n'
|
||||
)
|
||||
client_file.flush()
|
||||
invalid_id_response = json.loads(client_file.readline().decode("utf-8"))
|
||||
client_file.close()
|
||||
client_socket.close()
|
||||
worker.join(timeout=1)
|
||||
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
@@ -82,6 +112,8 @@ def main():
|
||||
"current_address": current_address,
|
||||
"session_document": selected,
|
||||
"analysis_guard": analysis_guard,
|
||||
"bridge_messages": bridge_messages,
|
||||
"invalid_id_response": invalid_id_response,
|
||||
},
|
||||
sort_keys=True,
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
|
||||
+145
-43
@@ -29,46 +29,9 @@ const INSTALL = "/opt/ghidra_12.1.2_PUBLIC";
|
||||
|
||||
describe("Ghidra MCP and shared CLI composition", () => {
|
||||
it("preserves provider evidence, composed parity, and capability routing", async () => {
|
||||
const calls: GhidraOperation[] = [];
|
||||
const factory: GhidraProviderClientFactory = (options) => ({
|
||||
start: () =>
|
||||
Promise.resolve(
|
||||
ok(sessionInfo(options.profileDigest, options.targetSha256)),
|
||||
),
|
||||
callTool: (operation, input) => {
|
||||
calls.push(operation);
|
||||
return Promise.resolve(ok(resultFor(operation, input)));
|
||||
},
|
||||
close: () => Promise.resolve(),
|
||||
});
|
||||
const config = parseConfig({ GHIDRA_INSTALL_DIR: INSTALL });
|
||||
if (!config.ok) throw config.error;
|
||||
const provider = new GhidraProvider(
|
||||
config.value,
|
||||
silentLogger,
|
||||
installationHost(),
|
||||
factory,
|
||||
);
|
||||
const session = new BinarySession(
|
||||
SessionProviderRouter.selectable(
|
||||
new AnalysisProviderRegistry([provider]),
|
||||
[],
|
||||
),
|
||||
);
|
||||
const server = createServer(session, session, { logger: silentLogger });
|
||||
const mcp = new Client({ name: "ghidra-parity", version: "1.0.0" });
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
|
||||
const harness = await connectGhidraMcp("ghidra-parity");
|
||||
const { calls, mcp, session } = harness;
|
||||
try {
|
||||
await server.connect(serverTransport);
|
||||
await mcp.connect(clientTransport);
|
||||
const opened = await mcp.callTool({
|
||||
name: "open_binary",
|
||||
arguments: { path: process.execPath },
|
||||
});
|
||||
expect(opened.isError).not.toBe(true);
|
||||
|
||||
const listed = sessionEvidence(
|
||||
session,
|
||||
(
|
||||
@@ -269,12 +232,147 @@ describe("Ghidra MCP and shared CLI composition", () => {
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
await Promise.allSettled([mcp.close(), server.close()]);
|
||||
await session.close();
|
||||
await harness.close();
|
||||
}
|
||||
}, 10_000);
|
||||
|
||||
it("records approved native API truncation once and links its evidence", async () => {
|
||||
const harness = await connectGhidraMcp("ghidra-native-api");
|
||||
const { mcp, session } = harness;
|
||||
try {
|
||||
const unapprovedInspection = sessionEvidence(
|
||||
session,
|
||||
(
|
||||
await mcp.callTool({
|
||||
name: "inspect_native_api",
|
||||
arguments: { procedure: "fixture_truncated" },
|
||||
})
|
||||
).structuredContent,
|
||||
);
|
||||
expect(unapprovedInspection.normalized_result).toMatchObject({
|
||||
boundary: {
|
||||
available: true,
|
||||
jump_tables: [{ mappings_truncated: true }],
|
||||
},
|
||||
residual_unknowns: [
|
||||
expect.stringContaining("additional data sources or targets"),
|
||||
],
|
||||
});
|
||||
expect(
|
||||
(
|
||||
await mcp.callTool({
|
||||
name: "list_unknowns",
|
||||
arguments: {},
|
||||
})
|
||||
).structuredContent,
|
||||
).toMatchObject({ result: [] });
|
||||
|
||||
const approvedInspection = sessionEvidence(
|
||||
session,
|
||||
(
|
||||
await mcp.callTool({
|
||||
name: "inspect_native_api",
|
||||
arguments: {
|
||||
procedure: "fixture_truncated",
|
||||
unknown_registry_approved: true,
|
||||
},
|
||||
})
|
||||
).structuredContent,
|
||||
);
|
||||
await mcp.callTool({
|
||||
name: "inspect_native_api",
|
||||
arguments: {
|
||||
procedure: "fixture_truncated",
|
||||
unknown_registry_approved: true,
|
||||
},
|
||||
});
|
||||
expect(
|
||||
(
|
||||
await mcp.callTool({
|
||||
name: "list_unknowns",
|
||||
arguments: {},
|
||||
})
|
||||
).structuredContent,
|
||||
).toMatchObject({
|
||||
result: [
|
||||
{
|
||||
status: "open",
|
||||
domain: "native-api",
|
||||
question: expect.stringContaining(
|
||||
"additional data sources or targets",
|
||||
),
|
||||
supporting_evidence_ids: [approvedInspection.evidence_id],
|
||||
recommended_probes: [
|
||||
{
|
||||
operation: "inspect_native_api",
|
||||
rationale: expect.stringContaining("ABI probe"),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
} finally {
|
||||
await harness.close();
|
||||
}
|
||||
}, 10_000);
|
||||
});
|
||||
|
||||
const connectGhidraMcp = async (name: string) => {
|
||||
const calls: GhidraOperation[] = [];
|
||||
const factory: GhidraProviderClientFactory = (options) => ({
|
||||
start: () =>
|
||||
Promise.resolve(
|
||||
ok(sessionInfo(options.profileDigest, options.targetSha256)),
|
||||
),
|
||||
callTool: (operation, input) => {
|
||||
calls.push(operation);
|
||||
return Promise.resolve(ok(resultFor(operation, input)));
|
||||
},
|
||||
close: () => Promise.resolve(),
|
||||
});
|
||||
const config = parseConfig({ GHIDRA_INSTALL_DIR: INSTALL });
|
||||
if (!config.ok) throw config.error;
|
||||
const provider = new GhidraProvider(
|
||||
config.value,
|
||||
silentLogger,
|
||||
installationHost(),
|
||||
factory,
|
||||
);
|
||||
const session = new BinarySession(
|
||||
SessionProviderRouter.selectable(
|
||||
new AnalysisProviderRegistry([provider]),
|
||||
[],
|
||||
),
|
||||
);
|
||||
const server = createServer(session, session, { logger: silentLogger });
|
||||
const mcp = new Client({ name, version: "1.0.0" });
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
const close = async () => {
|
||||
await Promise.allSettled([mcp.close(), server.close()]);
|
||||
await session.close();
|
||||
};
|
||||
try {
|
||||
await server.connect(serverTransport);
|
||||
await mcp.connect(clientTransport);
|
||||
const opened = await mcp.callTool({
|
||||
name: "open_binary",
|
||||
arguments: { path: process.execPath },
|
||||
});
|
||||
if (opened.isError === true)
|
||||
throw new Error("The Ghidra MCP harness could not open its target");
|
||||
} catch (cause: unknown) {
|
||||
await close();
|
||||
throw cause;
|
||||
}
|
||||
return {
|
||||
calls,
|
||||
mcp,
|
||||
session,
|
||||
close,
|
||||
};
|
||||
};
|
||||
|
||||
const sessionEvidence = (session: BinarySession, value: unknown) => {
|
||||
if (
|
||||
typeof value !== "object" ||
|
||||
@@ -306,7 +404,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" },
|
||||
@@ -453,7 +551,11 @@ const resultBuilders = new Map<GhidraOperation, GhidraResultBuilder>([
|
||||
["xrefs", () => ["0x401001"]],
|
||||
[
|
||||
"analyze_function",
|
||||
(input, _limit) => ghidraFunctionDossier(input.include_assembly === true),
|
||||
(input, _limit) =>
|
||||
ghidraFunctionDossier(
|
||||
input.include_assembly === true,
|
||||
input.procedure === "fixture_truncated",
|
||||
),
|
||||
],
|
||||
]);
|
||||
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -24,6 +24,45 @@ const probeResultSchema = z.strictObject({
|
||||
diagnostic_type: z.literal("capability_unavailable"),
|
||||
message: z.string(),
|
||||
}),
|
||||
bridge_messages: z.tuple([
|
||||
z.strictObject({
|
||||
id: z.literal(7),
|
||||
event: z.strictObject({
|
||||
type: z.literal("progress"),
|
||||
phase: z.literal("hopper_bridge"),
|
||||
completed: z.literal(0),
|
||||
total: z.literal(1),
|
||||
message: z.literal("Hopper bridge started request"),
|
||||
}),
|
||||
}),
|
||||
z.strictObject({
|
||||
id: z.literal(7),
|
||||
event: z.strictObject({
|
||||
type: z.literal("diagnostic"),
|
||||
error: z.strictObject({
|
||||
code: z.literal(-32000),
|
||||
message: z.literal("RuntimeError: Hopper bridge operation failed"),
|
||||
type: z.literal("bridge_exception"),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
z.strictObject({
|
||||
id: z.literal(7),
|
||||
error: z.strictObject({
|
||||
code: z.literal(-32000),
|
||||
message: z.literal("RuntimeError: Hopper bridge operation failed"),
|
||||
type: z.literal("bridge_exception"),
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
invalid_id_response: z.strictObject({
|
||||
id: z.literal(0),
|
||||
error: z.strictObject({
|
||||
code: z.literal(-32000),
|
||||
message: z.literal("Invalid Hopper bridge request"),
|
||||
type: z.literal("invalid_request"),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
describe("Hopper API facade", () => {
|
||||
@@ -38,6 +77,7 @@ describe("Hopper API facade", () => {
|
||||
},
|
||||
);
|
||||
const result = probeResultSchema.parse(JSON.parse(stdout));
|
||||
expect(stdout).not.toContain("supersecret");
|
||||
expect(result.analysis_guard.message).toContain(
|
||||
"requires completed Hopper background analysis",
|
||||
);
|
||||
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
BridgeSession,
|
||||
} from "../src/hopper/BridgeLauncher.js";
|
||||
import { HopperClient } from "../src/hopper/HopperClient.js";
|
||||
import type { HopperDiagnostic } from "../src/hopper/HopperDiagnostics.js";
|
||||
import { LINUX_PRIVATE_DISPLAY_DIAGNOSTIC_PREFIX } from "../src/hopper/LinuxPrivateDisplayDiagnostic.js";
|
||||
import { providerCleanupFailure } from "../src/hopper/HopperDiagnostics.js";
|
||||
import { cleanupOwnedProcessGroup } from "../src/process/ProcessOwnership.js";
|
||||
@@ -296,6 +297,8 @@ describe("HopperClient", () => {
|
||||
it.each([
|
||||
["malformed", "HopperProtocolError"],
|
||||
["wrong_id", "HopperProtocolError"],
|
||||
["wrong_event_id", "HopperProtocolError"],
|
||||
["malformed_event", "HopperProtocolError"],
|
||||
["remote_error", "HopperRemoteError"],
|
||||
["hang", "HopperTimeoutError"],
|
||||
["exit", "HopperProcessError"],
|
||||
@@ -307,7 +310,15 @@ describe("HopperClient", () => {
|
||||
});
|
||||
|
||||
it("preserves a sanitized bridge exception diagnostic", async () => {
|
||||
const client = await startClient();
|
||||
const diagnostics: HopperDiagnostic[] = [];
|
||||
const client = new HopperClient({
|
||||
launcher: new FixtureLauncher(),
|
||||
requestTimeoutMs: 100,
|
||||
startupTimeoutMs: 1_000,
|
||||
onDiagnostic: (event) => diagnostics.push(event),
|
||||
});
|
||||
clients.push(client);
|
||||
await expect(client.start()).resolves.toMatchObject({ ok: true });
|
||||
const result = await client.callTool("remote_error");
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok)
|
||||
@@ -315,6 +326,66 @@ describe("HopperClient", () => {
|
||||
_tag: "HopperRemoteError",
|
||||
diagnosticType: "bridge_exception",
|
||||
});
|
||||
expect(diagnostics).toContainEqual({
|
||||
type: "bridge-diagnostic",
|
||||
request_id: 2,
|
||||
code: -32001,
|
||||
category: "bridge_exception",
|
||||
message: "safe fake failure",
|
||||
});
|
||||
});
|
||||
|
||||
it("forwards correlated Python progress before the terminal response", async () => {
|
||||
const client = await startClient();
|
||||
const updates: Array<{
|
||||
readonly phase: string;
|
||||
readonly completed: number;
|
||||
readonly terminal?: boolean;
|
||||
}> = [];
|
||||
const result = await client.callTool(
|
||||
"echo",
|
||||
{ value: "progress" },
|
||||
{
|
||||
progress: {
|
||||
report: (update) => {
|
||||
updates.push(update);
|
||||
return Promise.resolve();
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toEqual({ ok: true, value: { value: "progress" } });
|
||||
expect(updates).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
phase: "hopper_bridge",
|
||||
completed: 0,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
phase: "hopper_bridge",
|
||||
completed: 1,
|
||||
terminal: true,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("isolates synchronous progress observer failures from bridge responses", async () => {
|
||||
const client = await startClient();
|
||||
await expect(
|
||||
client.callTool(
|
||||
"echo",
|
||||
{ value: "alive" },
|
||||
{
|
||||
progress: {
|
||||
report: () => {
|
||||
throw new Error("observer failed");
|
||||
},
|
||||
},
|
||||
},
|
||||
),
|
||||
).resolves.toEqual({ ok: true, value: { value: "alive" } });
|
||||
});
|
||||
|
||||
it("projects a missing bridge API as typed capability unavailability", async () => {
|
||||
@@ -460,6 +531,28 @@ describe("HopperClient", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it("projects actionable CI remediation for Hopper UI, license, and lifecycle failures", async () => {
|
||||
const client = new HopperClient({
|
||||
launcher: new ExitingLauncher(75),
|
||||
startupTimeoutMs: 10_000,
|
||||
});
|
||||
clients.push(client);
|
||||
const result = await client.start();
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) return;
|
||||
|
||||
const projected = projectAnalysisError(result.error);
|
||||
expect(projected.message).toContain(
|
||||
"rea doctor --provider hopper --detail full --json",
|
||||
);
|
||||
expect(projected.message).toContain("UI or license prompt");
|
||||
expect(projected.message).toContain("close stale Hopper sessions");
|
||||
expect(projected.details).toMatchObject({
|
||||
failure_code: "hopper_exited_during_startup",
|
||||
exit_code: 75,
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves the bounded private-display diagnostic from an adapter exit", async () => {
|
||||
const client = new HopperClient({
|
||||
launcher: new DiagnosticExitingLauncher(),
|
||||
|
||||
@@ -240,6 +240,65 @@ describe("persistent cross-version investigation workspace", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("produces stable multi-provider Evidence closure across independent version runs", async () => {
|
||||
const { input } = await fixture();
|
||||
if (directory === undefined) throw new Error("missing fixture root");
|
||||
const repeatedInput = crossVersionInvestigationInputSchema.parse({
|
||||
...input,
|
||||
workspace_path: join(directory, "repeated-workspace.json"),
|
||||
});
|
||||
const [first, repeated] = await Promise.all([
|
||||
runCrossVersionInvestigation(input, policy(directory), {
|
||||
inputRoots: [directory],
|
||||
}),
|
||||
runCrossVersionInvestigation(repeatedInput, policy(directory), {
|
||||
inputRoots: [directory],
|
||||
}),
|
||||
]);
|
||||
if (!first.ok) throw first.error;
|
||||
if (!repeated.ok) throw repeated.error;
|
||||
|
||||
expect(repeated.value.evidence).toEqual(first.value.evidence);
|
||||
expect(
|
||||
[
|
||||
...new Set(
|
||||
first.value.workspace.bundle.records.map(
|
||||
({ provider }) => provider.id,
|
||||
),
|
||||
),
|
||||
].sort(),
|
||||
).toEqual([
|
||||
"rea-artifact-comparison",
|
||||
"rea-artifact-graph",
|
||||
"rea-changed-behavior",
|
||||
]);
|
||||
|
||||
const comparison = first.value.workspace.bundle.records.find(
|
||||
({ operation }) => operation === "compare_artifacts",
|
||||
);
|
||||
if (comparison === undefined)
|
||||
throw new Error("missing artifact comparison Evidence");
|
||||
const result = changedBehaviorResultSchema.parse(
|
||||
first.value.evidence.normalized_result,
|
||||
);
|
||||
const evidenceIds = new Set(
|
||||
first.value.workspace.bundle.records.map(({ evidence_id: id }) => id),
|
||||
);
|
||||
expect(result.findings.items).not.toHaveLength(0);
|
||||
for (const finding of result.findings.items) {
|
||||
expect(finding).toMatchObject({
|
||||
classification: "derived_relationship",
|
||||
source_comparison_id: comparison.evidence_id,
|
||||
});
|
||||
expect(finding.evidence_links).toContain(comparison.evidence_id);
|
||||
expect(
|
||||
finding.evidence_links.every((evidenceId) =>
|
||||
evidenceIds.has(evidenceId),
|
||||
),
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("replays only an explicitly selected and fully verified complete run", async () => {
|
||||
const { left, right, input } = await fixture();
|
||||
if (directory === undefined) throw new Error("missing fixture root");
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { MCP_STARTUP_POLICY } from "../src/mcpStartupPolicy.js";
|
||||
import {
|
||||
createEsmModuleProfileCollector,
|
||||
parseLinuxVmRssBytes,
|
||||
} from "../scripts/lib/mcp-startup-probe.mjs";
|
||||
|
||||
describe("MCP startup policy", () => {
|
||||
it("preserves headroom beneath the configured Codex timeout", () => {
|
||||
const codexTimeoutMs =
|
||||
MCP_STARTUP_POLICY.codexStartupTimeoutSeconds * 1_000;
|
||||
|
||||
expect(MCP_STARTUP_POLICY.initializeBudgetMs).toBeLessThan(codexTimeoutMs);
|
||||
expect(MCP_STARTUP_POLICY.firstCatalogBudgetMs).toBeLessThan(
|
||||
codexTimeoutMs,
|
||||
);
|
||||
expect(MCP_STARTUP_POLICY.firstCatalogBudgetMs).toBe(
|
||||
MCP_STARTUP_POLICY.initializeBudgetMs +
|
||||
MCP_STARTUP_POLICY.toolsListBudgetMs,
|
||||
);
|
||||
expect(MCP_STARTUP_POLICY.doctorDeadlineMs).toBeGreaterThanOrEqual(
|
||||
codexTimeoutMs,
|
||||
);
|
||||
});
|
||||
|
||||
it("parses Linux resident memory without accepting other fields", () => {
|
||||
expect(
|
||||
parseLinuxVmRssBytes("Name:\tnode\nVmRSS:\t 12345 kB\nThreads:\t7\n"),
|
||||
).toBe(12_641_280);
|
||||
expect(parseLinuxVmRssBytes("VmSize:\t12345 kB\n")).toBeNull();
|
||||
});
|
||||
|
||||
it("profiles unique file-backed ESM modules without retaining paths", () => {
|
||||
const collector = createEsmModuleProfileCollector();
|
||||
collector.append(
|
||||
"ESM 1: Storing file:///opt/rea/dist/main.js\n" +
|
||||
"ESM 1: Translating StandardModule file:///opt/rea/dist/main.js\n" +
|
||||
"ESM 1: Storing file:///opt/rea/node_modules/zod/index.js",
|
||||
);
|
||||
|
||||
expect(collector.result()).toEqual({
|
||||
supported: true,
|
||||
method: "node-debug-esm",
|
||||
unique_file_modules: 2,
|
||||
unique_package_modules: 0,
|
||||
unique_dependency_modules: 1,
|
||||
debug_lines: 3,
|
||||
truncated: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user