Compare commits
54
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
222ce83967 | ||
|
|
7929bf1688 | ||
|
|
8ab40974d3 | ||
|
|
91fb87d17e | ||
|
|
54153ed62c | ||
|
|
f942558573 | ||
|
|
1fb22b4c21 | ||
|
|
1a75f8dc23 | ||
|
|
18624df572 | ||
|
|
0ef1b91cf3 | ||
|
|
64f64fec30 | ||
|
|
4602efd27c | ||
|
|
94e6600faf | ||
|
|
4d228b4d8c | ||
|
|
a5e355a3d5 | ||
|
|
fdeeb27f23 | ||
|
|
d5d2e56d04 | ||
|
|
d7a07535b0 | ||
|
|
6c03f83b1b | ||
|
|
4e0127d620 | ||
|
|
6fa6f9b6d9 | ||
|
|
3f336fefb1 | ||
|
|
4ee30e4145 | ||
|
|
645668b70d | ||
|
|
ba3151891e | ||
|
|
c4cd0330e0 | ||
|
|
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.7.0"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,83 @@
|
||||
# Changelog
|
||||
|
||||
## [2.7.0](https://github.com/morluto/rea/compare/rea-agents-2.6.0...rea-agents-2.7.0) (2026-07-28)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **bytecode:** add JVM and Python bytecode providers ([1a75f8d](https://github.com/morluto/rea/commit/1a75f8dc236d8269a5fa07e042471ac79e79d728))
|
||||
* **bytecode:** add JVM and Python bytecode providers ([4e0127d](https://github.com/morluto/rea/commit/4e0127d620377e5dc7e09444f9db0977e3d47ed1)), closes [#367](https://github.com/morluto/rea/issues/367)
|
||||
* **conformance:** add portable conformance export package format ([91fb87d](https://github.com/morluto/rea/commit/91fb87d17e7885439762667c96167af79a2e044e))
|
||||
* **conformance:** add portable conformance export package format ([4ee30e4](https://github.com/morluto/rea/commit/4ee30e4145db9b496158cd980af05e22010856a7))
|
||||
* **metadata:** add deeper ObjC/Swift metadata and database save/licensing ([4602efd](https://github.com/morluto/rea/commit/4602efd27cbf8701c2dc15542042bc9d9ef76e7f))
|
||||
* **metadata:** add deeper ObjC/Swift metadata and database save/licensing ([d7a0753](https://github.com/morluto/rea/commit/d7a07535b0fb61ee8f9ae648bb673dda23290653)), closes [#353](https://github.com/morluto/rea/issues/353)
|
||||
* **mobile:** add Android APK/AAB/DEX and iOS IPA static investigation providers ([18624df](https://github.com/morluto/rea/commit/18624df5724a2c4d14a4e8e178660456424b2344))
|
||||
* **mobile:** add Android APK/AAB/DEX and iOS IPA static investigation providers ([fdeeb27](https://github.com/morluto/rea/commit/fdeeb27f23041c19b5857fe7873106f0cd9c6244)), closes [#366](https://github.com/morluto/rea/issues/366)
|
||||
* **package:** add MSIX/MSI/AppX package, resource, and signature analysis ([0ef1b91](https://github.com/morluto/rea/commit/0ef1b91cf31f6ca53991ee5ca60b4a22f430c01e))
|
||||
* **package:** add MSIX/MSI/AppX package, resource, and signature analysis ([6c03f83](https://github.com/morluto/rea/commit/6c03f83b1bd55095d136aeb3700733383365ecc4)), closes [#363](https://github.com/morluto/rea/issues/363)
|
||||
* **pe:** add broader PE/COFF/PDB static inspection ([f942558](https://github.com/morluto/rea/commit/f9425585736a89165390178f245b0f0f5d6cdacf))
|
||||
* **pe:** add broader PE/COFF/PDB static inspection ([94e6600](https://github.com/morluto/rea/commit/94e6600faf89436e4e2689fda8050792307b268d)), closes [#362](https://github.com/morluto/rea/issues/362)
|
||||
* **process:** replace procfs sampling with event-backed process-tree capture ([a5e355a](https://github.com/morluto/rea/commit/a5e355a3d52b19e89535449cd923c35e56ccc60d))
|
||||
* **process:** replace procfs sampling with event-backed process-tree capture ([d5d2e56](https://github.com/morluto/rea/commit/d5d2e56d0461cb0debba540bc658a9938ac30599)), closes [#332](https://github.com/morluto/rea/issues/332)
|
||||
* **protocol:** add custom TCP/UDP/IPC/XPC and auth-flow capture ([1fb22b4](https://github.com/morluto/rea/commit/1fb22b4c21e3a5581f3928003cc32ddc1cc9530b))
|
||||
* **protocol:** add custom TCP/UDP/IPC/XPC and auth-flow capture ([6fa6f9b](https://github.com/morluto/rea/commit/6fa6f9b6d9595a480a590766dadcfebb8e7111b5)), closes [#365](https://github.com/morluto/rea/issues/365)
|
||||
* **protocol:** add gRPC/Protobuf and JSON-RPC/MessagePack capture ([54153ed](https://github.com/morluto/rea/commit/54153ed62cb4f55d1efd4794490c2481ab34e212))
|
||||
* **protocol:** add gRPC/Protobuf and JSON-RPC/MessagePack capture ([3f336fe](https://github.com/morluto/rea/commit/3f336fefb132971bc6bb37b2b59aa01a5e3be5ca)), closes [#364](https://github.com/morluto/rea/issues/364)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **catalog:** digest provider projections ([645668b](https://github.com/morluto/rea/commit/645668b70dfdff74582d4ac26cda417947231764))
|
||||
* **catalog:** digest provider projections ([c4cd033](https://github.com/morluto/rea/commit/c4cd0330e00eeb8e272abbf6ecc6ce961e72f317))
|
||||
|
||||
## [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.7.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.7.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.7.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.7.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.7.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.7.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,18 @@
|
||||
},
|
||||
"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",
|
||||
"providers_sha256": "918ccd6b8ae442cd5844dcfde55086f458176241c8d166c1e9dd0f242ccd2bb0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.7.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "rea-agents",
|
||||
"version": "2.5.0",
|
||||
"version": "2.7.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/parser": "8.0.4",
|
||||
|
||||
+6
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "rea-agents",
|
||||
"version": "2.5.0",
|
||||
"version": "2.7.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",
|
||||
});
|
||||
@@ -1,6 +1,9 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { join } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
import canonicalize from "canonicalize";
|
||||
|
||||
const load = (root, path) => import(pathToFileURL(join(root, path)).href);
|
||||
|
||||
/** Sort tool/provider names alphabetically. */
|
||||
@@ -9,6 +12,14 @@ export const names = (contracts) =>
|
||||
.map(({ name }) => name)
|
||||
.sort((left, right) => left.localeCompare(right));
|
||||
|
||||
/** Return the stable digest for a generated catalog projection. */
|
||||
export const digest = (value) => {
|
||||
const encoded = canonicalize(value);
|
||||
if (encoded === undefined)
|
||||
throw new TypeError("Catalog projection is not canonical JSON");
|
||||
return createHash("sha256").update(encoded).digest("hex");
|
||||
};
|
||||
|
||||
/** Throw when two name lists diverge. */
|
||||
export const assertSameNames = (label, actual, expected) => {
|
||||
const actualSet = new Set(actual);
|
||||
|
||||
@@ -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);
|
||||
@@ -46,6 +46,9 @@ export interface ProductCatalog {
|
||||
}
|
||||
|
||||
export function createProductCatalog(root: string): Promise<ProductCatalog>;
|
||||
export function providerCatalogDigest(
|
||||
providers: ProductCatalog["providers"],
|
||||
): string;
|
||||
export function createCliInventory(cli: unknown): {
|
||||
readonly primary: readonly string[];
|
||||
readonly aliases: readonly {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { format } from "prettier";
|
||||
|
||||
import { assertSameNames, loadSources } from "./catalog-core.mjs";
|
||||
import { assertSameNames, digest, loadSources } from "./catalog-core.mjs";
|
||||
import {
|
||||
createCliInventory,
|
||||
cliCommandDescriptionIssues,
|
||||
@@ -15,6 +15,9 @@ export {
|
||||
cliCommandOptionNames,
|
||||
};
|
||||
|
||||
/** Return the stable digest for the source-derived provider projection. */
|
||||
export const providerCatalogDigest = (providers) => digest(providers);
|
||||
|
||||
/** Project current runtime contracts into deterministic, machine-readable facts. */
|
||||
export const createProductCatalog = async (root) => {
|
||||
const sources = await loadSources(root);
|
||||
@@ -25,6 +28,7 @@ export const createProductCatalog = async (root) => {
|
||||
sources.catalogIdentity.CLI_COMMAND_NAMES,
|
||||
);
|
||||
const tools = toolFamilyCatalog(sources);
|
||||
const providers = providerCatalog(sources);
|
||||
const metadata = sources.packageMetadata.PACKAGE_METADATA;
|
||||
return {
|
||||
catalog_schema_version: 1,
|
||||
@@ -39,7 +43,7 @@ export const createProductCatalog = async (root) => {
|
||||
skill_version: metadata.skillVersion,
|
||||
},
|
||||
tools,
|
||||
providers: providerCatalog(sources),
|
||||
providers,
|
||||
setup_clients: sources.supportedClients.SUPPORTED_CLIENT_DEFINITIONS.map(
|
||||
({ name, displayName, format }) => ({
|
||||
id: name,
|
||||
@@ -56,7 +60,10 @@ export const createProductCatalog = async (root) => {
|
||||
},
|
||||
runtime_catalog: {
|
||||
counts: sources.catalogIdentity.CATALOG_IDENTITY.counts,
|
||||
digests: sources.catalogIdentity.CATALOG_IDENTITY.digests,
|
||||
digests: {
|
||||
...sources.catalogIdentity.CATALOG_IDENTITY.digests,
|
||||
providers_sha256: providerCatalogDigest(providers),
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
import { z } from "zod";
|
||||
|
||||
/** Supported managed-runtime bytecode families. */
|
||||
export const bytecodeFamilySchema = z.enum(["jvm", "python"]);
|
||||
export type BytecodeFamily = z.infer<typeof bytecodeFamilySchema>;
|
||||
|
||||
/** Classification of bytecode provenance. */
|
||||
export const bytecodeProvenanceSchema = z.enum([
|
||||
"application",
|
||||
"generated",
|
||||
"vendored",
|
||||
"bundled",
|
||||
"standard_library",
|
||||
]);
|
||||
export type BytecodeProvenance = z.infer<typeof bytecodeProvenanceSchema>;
|
||||
|
||||
/** A bytecode symbol discovered through static analysis. */
|
||||
export const bytecodeSymbolSchema = z.strictObject({
|
||||
/** Fully qualified name. */
|
||||
name: z.string().min(1),
|
||||
/** Raw bytecode location or offset. */
|
||||
raw_location: z.string().min(1),
|
||||
/** Normalized symbol type. */
|
||||
kind: z.enum([
|
||||
"class",
|
||||
"interface",
|
||||
"method",
|
||||
"field",
|
||||
"function",
|
||||
"module",
|
||||
"variable",
|
||||
]),
|
||||
/** Bytecode version if known. */
|
||||
bytecode_version: z.number().int().nullable(),
|
||||
/** Provenance classification. */
|
||||
provenance: bytecodeProvenanceSchema,
|
||||
/** Whether the symbol is accessible (public). */
|
||||
is_public: z.boolean().default(false),
|
||||
});
|
||||
export type BytecodeSymbol = z.infer<typeof bytecodeSymbolSchema>;
|
||||
|
||||
/** A discovered bytecode artifact (class file, wheel, etc). */
|
||||
export const bytecodeArtifactSchema = z.strictObject({
|
||||
/** Artifact path relative to the analysis root. */
|
||||
path: z.string().min(1),
|
||||
/** Bytecode family. */
|
||||
family: bytecodeFamilySchema,
|
||||
/** Artifact format. */
|
||||
format: z.enum(["class", "jar", "war", "ear", "pyc", "pyo", "whl", "zipapp"]),
|
||||
/** Size in bytes. */
|
||||
size: z.number().int().nonnegative(),
|
||||
/** SHA-256 digest. */
|
||||
digest: z.string().regex(/^[a-f0-9]{64}$/u),
|
||||
/** Discovered symbols. */
|
||||
symbols: z.array(bytecodeSymbolSchema).default([]),
|
||||
/** Whether this is a standard library artifact. */
|
||||
is_standard_library: z.boolean().default(false),
|
||||
/** Whether this is a generated artifact. */
|
||||
is_generated: z.boolean().default(false),
|
||||
/** Whether this is a vendored artifact. */
|
||||
is_vendored: z.boolean().default(false),
|
||||
});
|
||||
export type BytecodeArtifact = z.infer<typeof bytecodeArtifactSchema>;
|
||||
|
||||
/** Static bytecode analysis result. */
|
||||
export const bytecodeAnalysisSchema = z.strictObject({
|
||||
/** Bytecode family. */
|
||||
family: bytecodeFamilySchema,
|
||||
/** All discovered artifacts. */
|
||||
artifacts: z.array(bytecodeArtifactSchema).min(0).max(10_000),
|
||||
/** Total symbols across all artifacts. */
|
||||
total_symbols: z.number().int().nonnegative(),
|
||||
/** Symbols classified as application code. */
|
||||
application_symbols: z.number().int().nonnegative(),
|
||||
/** Symbols classified as generated code. */
|
||||
generated_symbols: z.number().int().nonnegative(),
|
||||
/** Symbols classified as vendored code. */
|
||||
vendored_symbols: z.number().int().nonnegative(),
|
||||
/** Symbols classified as standard library. */
|
||||
standard_library_symbols: z.number().int().nonnegative(),
|
||||
});
|
||||
export type BytecodeAnalysis = z.infer<typeof bytecodeAnalysisSchema>;
|
||||
|
||||
/** Detect bytecode family from file extension. */
|
||||
export function detectBytecodeFamily(path: string): BytecodeFamily | null {
|
||||
const lower = path.toLowerCase();
|
||||
if (
|
||||
lower.endsWith(".class") ||
|
||||
lower.endsWith(".jar") ||
|
||||
lower.endsWith(".war") ||
|
||||
lower.endsWith(".ear")
|
||||
) {
|
||||
return "jvm";
|
||||
}
|
||||
if (
|
||||
lower.endsWith(".pyc") ||
|
||||
lower.endsWith(".pyo") ||
|
||||
lower.endsWith(".whl") ||
|
||||
lower.endsWith(".pyz") ||
|
||||
lower.endsWith(".zipapp")
|
||||
) {
|
||||
return "python";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Detect artifact format from file path. */
|
||||
export function detectArtifactFormat(
|
||||
path: string,
|
||||
): `class` | `jar` | `war` | `ear` | `pyc` | `pyo` | `whl` | `zipapp` | null {
|
||||
const lower = path.toLowerCase();
|
||||
if (lower.endsWith(".class")) return "class";
|
||||
if (lower.endsWith(".jar")) return "jar";
|
||||
if (lower.endsWith(".war")) return "war";
|
||||
if (lower.endsWith(".ear")) return "ear";
|
||||
if (lower.endsWith(".pyc")) return "pyc";
|
||||
if (lower.endsWith(".pyo")) return "pyo";
|
||||
if (lower.endsWith(".whl")) return "whl";
|
||||
if (lower.endsWith(".pyz") || lower.endsWith(".zipapp")) return "zipapp";
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Classify bytecode symbol provenance. */
|
||||
export function classifyProvenance(
|
||||
path: string,
|
||||
symbolName: string,
|
||||
): BytecodeProvenance {
|
||||
const lower = path.toLowerCase();
|
||||
// Standard library paths
|
||||
if (
|
||||
lower.includes("/stdlib/") ||
|
||||
lower.includes("/site-packages/") ||
|
||||
lower.includes("java/") ||
|
||||
lower.includes("javax/") ||
|
||||
lower.includes("sun/") ||
|
||||
symbolName.startsWith("java.") ||
|
||||
symbolName.startsWith("javax.") ||
|
||||
symbolName.startsWith("sun.") ||
|
||||
symbolName.startsWith("__")
|
||||
) {
|
||||
return "standard_library";
|
||||
}
|
||||
// Generated code
|
||||
if (
|
||||
lower.includes("/generated/") ||
|
||||
lower.includes("_generated") ||
|
||||
lower.includes("/build/") ||
|
||||
lower.includes("/target/") ||
|
||||
lower.includes("/dist-packages/") ||
|
||||
lower.includes("/__pycache__/") ||
|
||||
symbolName.includes("Generated") ||
|
||||
symbolName.includes("_generated")
|
||||
) {
|
||||
return "generated";
|
||||
}
|
||||
// Vendored code
|
||||
if (
|
||||
lower.includes("/vendor/") ||
|
||||
lower.includes("/vendored/") ||
|
||||
lower.includes("/third_party/") ||
|
||||
lower.includes("/3rdparty/") ||
|
||||
lower.includes("/node_modules/") ||
|
||||
lower.includes("/vendor/")
|
||||
) {
|
||||
return "vendored";
|
||||
}
|
||||
// Bundled code
|
||||
if (
|
||||
lower.includes("/bundle/") ||
|
||||
lower.includes("/bundled/") ||
|
||||
lower.includes(".jar!") ||
|
||||
lower.includes(".war!")
|
||||
) {
|
||||
return "bundled";
|
||||
}
|
||||
return "application";
|
||||
}
|
||||
|
||||
/** Analyze a set of bytecode artifacts and produce a summary. */
|
||||
export function analyzeBytecodeArtifacts(
|
||||
artifacts: readonly BytecodeArtifact[],
|
||||
family: BytecodeFamily,
|
||||
): BytecodeAnalysis {
|
||||
const applicationSymbols = artifacts
|
||||
.flatMap((a) => a.symbols)
|
||||
.filter((s) => s.provenance === "application").length;
|
||||
const generatedSymbols = artifacts
|
||||
.flatMap((a) => a.symbols)
|
||||
.filter((s) => s.provenance === "generated").length;
|
||||
const vendoredSymbols = artifacts
|
||||
.flatMap((a) => a.symbols)
|
||||
.filter((s) => s.provenance === "vendored").length;
|
||||
const standardLibrarySymbols = artifacts
|
||||
.flatMap((a) => a.symbols)
|
||||
.filter((s) => s.provenance === "standard_library").length;
|
||||
|
||||
return {
|
||||
family,
|
||||
artifacts: artifacts.slice(0, 10_000),
|
||||
total_symbols: artifacts.reduce((sum, a) => sum + a.symbols.length, 0),
|
||||
application_symbols: applicationSymbols,
|
||||
generated_symbols: generatedSymbols,
|
||||
vendored_symbols: vendoredSymbols,
|
||||
standard_library_symbols: standardLibrarySymbols,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import canonicalize from "canonicalize";
|
||||
import { z } from "zod";
|
||||
|
||||
import { evidenceEnvelopeSchema } from "./evidence.js";
|
||||
import { evidenceBundleSchema } from "./evidenceBundle.js";
|
||||
|
||||
/** Schema version for the conformance package format. */
|
||||
export const CONFORMANCE_PACKAGE_VERSION = 1;
|
||||
|
||||
/** A stable, path-independent identifier for a conformance package. */
|
||||
export const conformancePackageIdSchema = z
|
||||
.string()
|
||||
.regex(/^cp_[a-f0-9]{64}$/u);
|
||||
|
||||
/** Identifier for a single scenario within a package. */
|
||||
export const scenarioIdSchema = z
|
||||
.string()
|
||||
.regex(/^[A-Za-z][A-Za-z0-9._-]{0,99}$/u);
|
||||
|
||||
/** Deterministic manifest for a scenario fixture. */
|
||||
export const scenarioManifestSchema = z.strictObject({
|
||||
scenario_id: scenarioIdSchema,
|
||||
name: z.string().min(1),
|
||||
description: z.string().min(1),
|
||||
/** Source-owned fixture path relative to the repository root. */
|
||||
fixture_path: z.string().min(1),
|
||||
/** Expected exit code or null if unspecified. */
|
||||
expected_exit_code: z.number().int().nullable(),
|
||||
/** Expected output patterns that must appear in the capture. */
|
||||
expected_patterns: z.array(z.string()).default([]),
|
||||
});
|
||||
|
||||
/** Replay plan describing how a scenario is replayed deterministically. */
|
||||
export const replayPlanSchema = z.strictObject({
|
||||
scenario_id: scenarioIdSchema,
|
||||
/** Ordered steps to execute during replay. */
|
||||
steps: z
|
||||
.array(
|
||||
z.strictObject({
|
||||
step_id: z.string().min(1),
|
||||
action: z.string().min(1),
|
||||
arguments: z.array(z.string()).default([]),
|
||||
timeout_ms: z.number().int().positive(),
|
||||
}),
|
||||
)
|
||||
.min(1)
|
||||
.max(100),
|
||||
/** Environment variables to set (never inherit host paths). */
|
||||
environment: z.record(z.string(), z.string()).default({}),
|
||||
});
|
||||
|
||||
/** Shim plan for intercepting observable effects. */
|
||||
export const shimPlanSchema = z.strictObject({
|
||||
scenario_id: scenarioIdSchema,
|
||||
/** Shims to install, each intercepting a named effect. */
|
||||
shims: z
|
||||
.array(
|
||||
z.strictObject({
|
||||
shim_id: z.string().min(1),
|
||||
kind: z.enum(["filesystem", "network", "process", "signal"]),
|
||||
target: z.string().min(1),
|
||||
policy: z.enum(["observe", "allow", "block", "emulate"]),
|
||||
}),
|
||||
)
|
||||
.min(0)
|
||||
.max(50),
|
||||
});
|
||||
|
||||
/** Expected evidence for a scenario, checked against the captured run. */
|
||||
export const expectedEvidenceSchema = z.strictObject({
|
||||
scenario_id: scenarioIdSchema,
|
||||
/** Expected evidence envelopes. */
|
||||
envelopes: z.array(evidenceEnvelopeSchema).min(0).max(100),
|
||||
/** Expected evidence bundle. */
|
||||
bundle: evidenceBundleSchema.nullable(),
|
||||
/** Required dimensions that must be present in the evidence. */
|
||||
required_dimensions: z.array(z.string().min(1)).default([]),
|
||||
});
|
||||
|
||||
/** Verifier contract describing how to validate conformance. */
|
||||
export const verifierContractSchema = z.strictObject({
|
||||
scenario_id: scenarioIdSchema,
|
||||
/** Dimensions to verify. */
|
||||
dimensions: z
|
||||
.array(
|
||||
z.strictObject({
|
||||
name: z.string().min(1),
|
||||
required: z.boolean().default(true),
|
||||
comparison: z.enum(["exact", "semantic", "fuzzy"]),
|
||||
}),
|
||||
)
|
||||
.min(1)
|
||||
.max(50),
|
||||
/** Tolerance for timing differences in milliseconds. */
|
||||
timing_tolerance_ms: z.number().int().nonnegative().default(0),
|
||||
});
|
||||
|
||||
/** Top-level conformance package manifest. */
|
||||
export const conformancePackageSchema = z.strictObject({
|
||||
schema_version: z.literal(CONFORMANCE_PACKAGE_VERSION),
|
||||
package_id: conformancePackageIdSchema,
|
||||
name: z.string().min(1),
|
||||
description: z.string().min(1),
|
||||
created_at: z.string().datetime(),
|
||||
/** Scenario manifests. */
|
||||
scenarios: z.array(scenarioManifestSchema).min(1).max(100),
|
||||
/** Replay plans keyed by scenario_id. */
|
||||
replay_plans: z.array(replayPlanSchema).min(1).max(100),
|
||||
/** Shim plans keyed by scenario_id. */
|
||||
shim_plans: z.array(shimPlanSchema).default([]),
|
||||
/** Expected evidence for each scenario. */
|
||||
expected_evidence: z.array(expectedEvidenceSchema).min(1).max(100),
|
||||
/** Verifier contracts for each scenario. */
|
||||
verifier_contracts: z.array(verifierContractSchema).min(1).max(100),
|
||||
});
|
||||
|
||||
export type ConformancePackage = z.infer<typeof conformancePackageSchema>;
|
||||
export type ScenarioManifest = z.infer<typeof scenarioManifestSchema>;
|
||||
export type ReplayPlan = z.infer<typeof replayPlanSchema>;
|
||||
export type ShimPlan = z.infer<typeof shimPlanSchema>;
|
||||
export type ExpectedEvidence = z.infer<typeof expectedEvidenceSchema>;
|
||||
export type VerifierContract = z.infer<typeof verifierContractSchema>;
|
||||
|
||||
/** Error from validation or package operations. */
|
||||
export type ConformancePackageError =
|
||||
| { kind: "invalid_package"; message: string }
|
||||
| { kind: "scenario_not_found"; scenario_id: string }
|
||||
| { kind: "duplicate_scenario"; scenario_id: string };
|
||||
|
||||
/** Result type for package operations. */
|
||||
export type ConformancePackageResult<T> =
|
||||
| { ok: true; value: T }
|
||||
| { ok: false; error: ConformancePackageError };
|
||||
|
||||
/** Validate a conformance package and cross-check internal references. */
|
||||
export function validateConformancePackage(
|
||||
input: unknown,
|
||||
): ConformancePackageResult<ConformancePackage> {
|
||||
const parsed = conformancePackageSchema.safeParse(input);
|
||||
if (!parsed.success)
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
kind: "invalid_package",
|
||||
message: parsed.error.issues[0]?.message ?? "invalid package",
|
||||
},
|
||||
};
|
||||
|
||||
const pkg = parsed.data;
|
||||
|
||||
// Check for duplicate scenario IDs
|
||||
const seen = new Set<string>();
|
||||
for (const s of pkg.scenarios) {
|
||||
if (seen.has(s.scenario_id))
|
||||
return {
|
||||
ok: false,
|
||||
error: { kind: "duplicate_scenario", scenario_id: s.scenario_id },
|
||||
};
|
||||
seen.add(s.scenario_id);
|
||||
}
|
||||
|
||||
// Check that replay plans reference existing scenarios
|
||||
for (const rp of pkg.replay_plans) {
|
||||
if (!seen.has(rp.scenario_id))
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
kind: "scenario_not_found",
|
||||
scenario_id: rp.scenario_id,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Check that expected evidence references existing scenarios
|
||||
for (const ee of pkg.expected_evidence) {
|
||||
if (!seen.has(ee.scenario_id))
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
kind: "scenario_not_found",
|
||||
scenario_id: ee.scenario_id,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Check that verifier contracts reference existing scenarios
|
||||
for (const vc of pkg.verifier_contracts) {
|
||||
if (!seen.has(vc.scenario_id))
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
kind: "scenario_not_found",
|
||||
scenario_id: vc.scenario_id,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return { ok: true, value: pkg };
|
||||
}
|
||||
|
||||
/** Compute a deterministic package ID from the canonical JSON. */
|
||||
export function computePackageId(
|
||||
pkg: Omit<ConformancePackage, "package_id">,
|
||||
): string {
|
||||
const json = canonicalize(pkg);
|
||||
if (!json) throw new Error("failed to canonicalize package");
|
||||
return `cp_${createHash("sha256").update(json).digest("hex")}`;
|
||||
}
|
||||
|
||||
/** Create a conformance package with an auto-computed package_id. */
|
||||
export function createConformancePackage(
|
||||
pkg: Omit<ConformancePackage, "package_id">,
|
||||
): ConformancePackage {
|
||||
const package_id = computePackageId(pkg);
|
||||
return { package_id, ...pkg };
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
conformancePackageSchema,
|
||||
type ConformancePackage,
|
||||
} from "./conformancePackage.js";
|
||||
import { trustGateResultSchema } from "./conformanceTrustGate.js";
|
||||
|
||||
/** Result of a single scenario replay. */
|
||||
export const scenarioReplayResultSchema = z.strictObject({
|
||||
scenario_id: z.string().min(1),
|
||||
status: z.enum(["pass", "fail", "error", "skipped"]),
|
||||
exit_code: z.number().int().nullable(),
|
||||
duration_ms: z.number().int().nonnegative(),
|
||||
output: z.string().default(""),
|
||||
error: z.string().nullable(),
|
||||
});
|
||||
|
||||
/** Result of replaying an entire conformance package. */
|
||||
export const packageReplayResultSchema = z.strictObject({
|
||||
package_id: z.string().min(1),
|
||||
total_scenarios: z.number().int().positive(),
|
||||
passed: z.number().int().nonnegative(),
|
||||
failed: z.number().int().nonnegative(),
|
||||
errored: z.number().int().nonnegative(),
|
||||
skipped: z.number().int().nonnegative(),
|
||||
scenario_results: z.array(scenarioReplayResultSchema),
|
||||
trust_gate_results: z.array(trustGateResultSchema),
|
||||
drift_detected: z.boolean(),
|
||||
first_drift: z
|
||||
.strictObject({
|
||||
scenario_id: z.string().min(1),
|
||||
dimension: z.string().min(1),
|
||||
message: z.string(),
|
||||
})
|
||||
.nullable(),
|
||||
});
|
||||
|
||||
export type ScenarioReplayResult = z.infer<typeof scenarioReplayResultSchema>;
|
||||
export type PackageReplayResult = z.infer<typeof packageReplayResultSchema>;
|
||||
|
||||
/** Callback interface for replaying a scenario. */
|
||||
export interface ScenarioRunner {
|
||||
(scenarioId: string, fixturePath: string): Promise<ScenarioReplayResult>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default no-op scenario runner that skips all scenarios.
|
||||
* Real CI replays inject an actual runner.
|
||||
*/
|
||||
async function defaultRunner(): Promise<ScenarioReplayResult> {
|
||||
return {
|
||||
scenario_id: "unknown",
|
||||
status: "skipped",
|
||||
exit_code: null,
|
||||
duration_ms: 0,
|
||||
output: "",
|
||||
error: "no runner provided",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Replay a conformance package from a clean checkout.
|
||||
* Runs each scenario, evaluates trust gates, and detects drift
|
||||
* between the captured run and the committed package.
|
||||
*/
|
||||
export async function replayConformancePackage(
|
||||
packageInput: unknown,
|
||||
actualEvidence: Record<string, Record<string, unknown>>,
|
||||
runner: ScenarioRunner = defaultRunner,
|
||||
options: { truncated?: boolean } = {},
|
||||
): Promise<PackageReplayResult> {
|
||||
const parsed = conformancePackageSchema.safeParse(packageInput);
|
||||
if (!parsed.success)
|
||||
throw new TypeError(
|
||||
`invalid conformance package: ${parsed.error.issues[0]?.message ?? "unknown"}`,
|
||||
);
|
||||
const pkg: ConformancePackage = parsed.data;
|
||||
|
||||
const scenarioResults: ScenarioReplayResult[] = [];
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
let errored = 0;
|
||||
let skipped = 0;
|
||||
|
||||
for (const scenario of pkg.scenarios) {
|
||||
const result = await runner(scenario.scenario_id, scenario.fixture_path);
|
||||
scenarioResults.push(result);
|
||||
|
||||
switch (result.status) {
|
||||
case "pass":
|
||||
passed++;
|
||||
break;
|
||||
case "fail":
|
||||
failed++;
|
||||
break;
|
||||
case "error":
|
||||
errored++;
|
||||
break;
|
||||
case "skipped":
|
||||
skipped++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const trustGateResults = evaluatePackageTrustGates(
|
||||
pkg,
|
||||
actualEvidence,
|
||||
options,
|
||||
);
|
||||
|
||||
const driftGates = trustGateResults.filter((r) => r.verdict === "fail");
|
||||
const drift_detected = driftGates.length > 0;
|
||||
const first_drift_gate = driftGates[0];
|
||||
const first_drift = first_drift_gate?.first_divergence
|
||||
? {
|
||||
scenario_id: first_drift_gate.scenario_id,
|
||||
dimension: first_drift_gate.first_divergence.dimension,
|
||||
message: first_drift_gate.first_divergence.message,
|
||||
}
|
||||
: null;
|
||||
|
||||
return {
|
||||
package_id: pkg.package_id,
|
||||
total_scenarios: pkg.scenarios.length,
|
||||
passed,
|
||||
failed,
|
||||
errored,
|
||||
skipped,
|
||||
scenario_results: scenarioResults,
|
||||
trust_gate_results: trustGateResults,
|
||||
drift_detected,
|
||||
first_drift,
|
||||
};
|
||||
}
|
||||
|
||||
/** Import trust gate evaluator for replay. */
|
||||
import { evaluatePackageTrustGates } from "./conformanceTrustGate.js";
|
||||
@@ -0,0 +1,246 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import type {
|
||||
ConformancePackage,
|
||||
VerifierContract,
|
||||
} from "./conformancePackage.js";
|
||||
|
||||
/** Result of comparing a single dimension. */
|
||||
export const dimensionResultSchema = z.strictObject({
|
||||
name: z.string().min(1),
|
||||
status: z.enum(["match", "mismatch", "unknown", "truncated"]),
|
||||
message: z.string().default(""),
|
||||
evidence_ids: z.array(z.string()).default([]),
|
||||
});
|
||||
|
||||
/** Result of evaluating a trust gate for one scenario. */
|
||||
export const trustGateResultSchema = z.strictObject({
|
||||
scenario_id: z.string().min(1),
|
||||
verdict: z.enum(["pass", "fail", "unknown"]),
|
||||
dimension_results: z.array(dimensionResultSchema),
|
||||
first_divergence: z
|
||||
.strictObject({
|
||||
dimension: z.string().min(1),
|
||||
evidence_id: z.string().min(1),
|
||||
message: z.string(),
|
||||
})
|
||||
.nullable(),
|
||||
});
|
||||
|
||||
export type DimensionResult = z.infer<typeof dimensionResultSchema>;
|
||||
export type TrustGateResult = z.infer<typeof trustGateResultSchema>;
|
||||
|
||||
/** Dimension that is volatile due to timing or normalization. */
|
||||
const VOLATILE_DIMENSIONS = new Set([
|
||||
"timing",
|
||||
"timestamp",
|
||||
"pid",
|
||||
"ppid",
|
||||
"duration_ms",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]);
|
||||
|
||||
/** Dimension that carries semantic content. */
|
||||
const SEMANTIC_DIMENSIONS = new Set([
|
||||
"exit_code",
|
||||
"stdout",
|
||||
"stderr",
|
||||
"filesystem",
|
||||
"process_tree",
|
||||
"shim_events",
|
||||
"protocol_events",
|
||||
"event_journal",
|
||||
]);
|
||||
|
||||
/** Check if a dimension name is volatile (timing/normalization noise). */
|
||||
export function isVolatileDimension(name: string): boolean {
|
||||
return VOLATILE_DIMENSIONS.has(name);
|
||||
}
|
||||
|
||||
/** Check if a dimension name carries semantic content. */
|
||||
export function isSemanticDimension(name: string): boolean {
|
||||
return SEMANTIC_DIMENSIONS.has(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare two values for a dimension, classifying semantic diffs
|
||||
* from timing/normalization noise.
|
||||
*
|
||||
* Volatile dimensions (timing, timestamps, PIDs) are always treated
|
||||
* as matches because their values are expected to differ across
|
||||
* runs and do not carry semantic meaning.
|
||||
*/
|
||||
export function compareDimension(
|
||||
dimensionName: string,
|
||||
expected: unknown,
|
||||
actual: unknown,
|
||||
options: { truncated?: boolean } = {},
|
||||
): DimensionResult {
|
||||
// Volatile dimensions: always match regardless of values
|
||||
if (isVolatileDimension(dimensionName)) {
|
||||
return {
|
||||
name: dimensionName,
|
||||
status: "match",
|
||||
message: "volatile dimension ignored",
|
||||
evidence_ids: [],
|
||||
};
|
||||
}
|
||||
|
||||
if (options.truncated) {
|
||||
return {
|
||||
name: dimensionName,
|
||||
status: "truncated",
|
||||
message: "evidence was truncated",
|
||||
evidence_ids: [],
|
||||
};
|
||||
}
|
||||
|
||||
if (expected === undefined && actual === undefined) {
|
||||
return {
|
||||
name: dimensionName,
|
||||
status: "unknown",
|
||||
message: "both expected and actual are undefined",
|
||||
evidence_ids: [],
|
||||
};
|
||||
}
|
||||
|
||||
if (expected === undefined || actual === undefined) {
|
||||
return {
|
||||
name: dimensionName,
|
||||
status: "mismatch",
|
||||
message: `expected ${expected === undefined ? "present" : "absent"}, actual ${actual === undefined ? "absent" : "present"}`,
|
||||
evidence_ids: [],
|
||||
};
|
||||
}
|
||||
|
||||
// Semantic comparison for structured objects
|
||||
if (typeof expected === "object" && typeof actual === "object") {
|
||||
const expectedJson = JSON.stringify(sortKeys(expected));
|
||||
const actualJson = JSON.stringify(sortKeys(actual));
|
||||
if (expectedJson === actualJson) {
|
||||
return {
|
||||
name: dimensionName,
|
||||
status: "match",
|
||||
message: "",
|
||||
evidence_ids: [],
|
||||
};
|
||||
}
|
||||
return {
|
||||
name: dimensionName,
|
||||
status: "mismatch",
|
||||
message: "semantic content differs",
|
||||
evidence_ids: [],
|
||||
};
|
||||
}
|
||||
|
||||
// Primitive comparison
|
||||
if (expected === actual) {
|
||||
return {
|
||||
name: dimensionName,
|
||||
status: "match",
|
||||
message: "",
|
||||
evidence_ids: [],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
name: dimensionName,
|
||||
status: "mismatch",
|
||||
message: `expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`,
|
||||
evidence_ids: [],
|
||||
};
|
||||
}
|
||||
|
||||
/** Recursively sort object keys for deterministic comparison. */
|
||||
function sortKeys(value: unknown): unknown {
|
||||
if (value === null || typeof value !== "object") return value;
|
||||
if (Array.isArray(value)) return value.map(sortKeys);
|
||||
const sorted: Record<string, unknown> = {};
|
||||
for (const key of Object.keys(value as Record<string, unknown>).sort()) {
|
||||
sorted[key] = sortKeys((value as Record<string, unknown>)[key]);
|
||||
}
|
||||
return sorted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate a trust gate for one scenario by comparing expected
|
||||
* evidence against the actual captured run.
|
||||
*/
|
||||
export function evaluateTrustGate(
|
||||
contract: VerifierContract,
|
||||
expectedEvidence: Record<string, unknown>,
|
||||
actualEvidence: Record<string, unknown>,
|
||||
options: { truncated?: boolean } = {},
|
||||
): TrustGateResult {
|
||||
const dimensionResults: DimensionResult[] = [];
|
||||
let firstDivergence: TrustGateResult["first_divergence"] = null;
|
||||
|
||||
for (const dim of contract.dimensions) {
|
||||
if (!dim.required) continue;
|
||||
const expectedValue = expectedEvidence[dim.name];
|
||||
const actualValue = actualEvidence[dim.name];
|
||||
const result = compareDimension(
|
||||
dim.name,
|
||||
expectedValue,
|
||||
actualValue,
|
||||
options,
|
||||
);
|
||||
dimensionResults.push(result);
|
||||
|
||||
if (result.status === "mismatch" && firstDivergence === null) {
|
||||
firstDivergence = {
|
||||
dimension: dim.name,
|
||||
evidence_id: result.evidence_ids[0] ?? "unknown",
|
||||
message: result.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const hasFail = dimensionResults.some(
|
||||
(r) => r.status === "mismatch" || r.status === "truncated",
|
||||
);
|
||||
const hasUnknown = dimensionResults.some((r) => r.status === "unknown");
|
||||
|
||||
const verdict: TrustGateResult["verdict"] = hasFail
|
||||
? "fail"
|
||||
: hasUnknown
|
||||
? "unknown"
|
||||
: "pass";
|
||||
|
||||
return {
|
||||
scenario_id: contract.scenario_id,
|
||||
verdict,
|
||||
dimension_results: dimensionResults,
|
||||
first_divergence: firstDivergence,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate trust gates for all scenarios in a conformance package.
|
||||
* Rejects runs that differ in required dimensions. Unknown/truncated
|
||||
* evidence is never treated as equivalence.
|
||||
*/
|
||||
export function evaluatePackageTrustGates(
|
||||
pkg: ConformancePackage,
|
||||
actualEvidence: Record<string, Record<string, unknown>>,
|
||||
options: { truncated?: boolean } = {},
|
||||
): TrustGateResult[] {
|
||||
const results: TrustGateResult[] = [];
|
||||
for (const contract of pkg.verifier_contracts) {
|
||||
const expected = pkg.expected_evidence.find(
|
||||
(e) => e.scenario_id === contract.scenario_id,
|
||||
);
|
||||
if (!expected) continue;
|
||||
const expectedEvidence = expected.envelopes[0] ?? {};
|
||||
const actual = actualEvidence[contract.scenario_id] ?? {};
|
||||
const result = evaluateTrustGate(
|
||||
contract,
|
||||
expectedEvidence as Record<string, unknown>,
|
||||
actual,
|
||||
options,
|
||||
);
|
||||
results.push(result);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { jsonValueSchema } from "./jsonValue.js";
|
||||
|
||||
/** Supported transport types for custom protocol capture. */
|
||||
export const transportTypeSchema = z.enum([
|
||||
"tcp",
|
||||
"udp",
|
||||
"ipc",
|
||||
"unix-socket",
|
||||
"named-pipe",
|
||||
"xpc",
|
||||
]);
|
||||
export type TransportType = z.infer<typeof transportTypeSchema>;
|
||||
|
||||
/** Direction of a protocol frame. */
|
||||
export const frameDirectionSchema = z.enum(["sent", "received", "intercepted"]);
|
||||
export type FrameDirection = z.infer<typeof frameDirectionSchema>;
|
||||
|
||||
/** A captured protocol frame. */
|
||||
export const protocolFrameSchema = z.strictObject({
|
||||
/** Monotonic sequence number. */
|
||||
sequence: z.number().int().nonnegative(),
|
||||
/** Timestamp in milliseconds. */
|
||||
at_ms: z.number().int().nonnegative(),
|
||||
/** Transport type. */
|
||||
transport: transportTypeSchema,
|
||||
/** Direction of the frame. */
|
||||
direction: frameDirectionSchema,
|
||||
/** Source process identity. */
|
||||
source_pid: z.number().int().nullable(),
|
||||
/** Destination process identity. */
|
||||
dest_pid: z.number().int().nullable(),
|
||||
/** Endpoint address (IP:port, socket path, pipe name). */
|
||||
source_endpoint: z.string().nullable(),
|
||||
/** Destination endpoint. */
|
||||
dest_endpoint: z.string().nullable(),
|
||||
/** Raw frame data as base64. */
|
||||
raw_data: z.string().nullable(),
|
||||
/** Decoded/parsed frame content if known. */
|
||||
decoded_content: jsonValueSchema.nullable(),
|
||||
/** Frame size in bytes. */
|
||||
size: z.number().int().nonnegative(),
|
||||
/** Whether the frame was truncated. */
|
||||
truncated: z.boolean().default(false),
|
||||
});
|
||||
export type ProtocolFrame = z.infer<typeof protocolFrameSchema>;
|
||||
|
||||
/** Authentication flow stage. */
|
||||
export const authStageSchema = z.enum([
|
||||
"init",
|
||||
"challenge",
|
||||
"response",
|
||||
"token_exchange",
|
||||
"renewal",
|
||||
"revocation",
|
||||
"failure",
|
||||
"success",
|
||||
]);
|
||||
export type AuthStage = z.infer<typeof authStageSchema>;
|
||||
|
||||
/** Token lifecycle event. */
|
||||
export const tokenLifecycleEventSchema = z.enum([
|
||||
"issued",
|
||||
"refreshed",
|
||||
"expired",
|
||||
"revoked",
|
||||
"renewed",
|
||||
]);
|
||||
export type TokenLifecycleEvent = z.infer<typeof tokenLifecycleEventSchema>;
|
||||
|
||||
/** A captured authentication flow event. */
|
||||
export const authFlowEventSchema = z.strictObject({
|
||||
/** Monotonic sequence number. */
|
||||
sequence: z.number().int().nonnegative(),
|
||||
/** Timestamp in milliseconds. */
|
||||
at_ms: z.number().int().nonnegative(),
|
||||
/** Authentication stage. */
|
||||
stage: authStageSchema,
|
||||
/** Protocol used. */
|
||||
protocol: z.string().min(1),
|
||||
/** Token type if applicable. */
|
||||
token_type: z.string().nullable(),
|
||||
/** Token lifecycle event. */
|
||||
token_lifecycle: tokenLifecycleEventSchema.nullable(),
|
||||
/** Correlation ID linking challenge-response pairs. */
|
||||
correlation_id: z.string().nullable(),
|
||||
/** Whether credentials were detected and redacted. */
|
||||
credentials_redacted: z.boolean().default(false),
|
||||
/** Whether the authentication succeeded. */
|
||||
succeeded: z.boolean().default(false),
|
||||
/** Error message if authentication failed. */
|
||||
error: z.string().nullable(),
|
||||
});
|
||||
export type AuthFlowEvent = z.infer<typeof authFlowEventSchema>;
|
||||
|
||||
/** A captured protocol session. */
|
||||
export const customProtocolCaptureSchema = z.strictObject({
|
||||
/** Transport type for this session. */
|
||||
transport: transportTypeSchema,
|
||||
/** Captured frames in order. */
|
||||
frames: z.array(protocolFrameSchema).min(0).max(100_000),
|
||||
/** Authentication flow events. */
|
||||
auth_events: z.array(authFlowEventSchema).default([]),
|
||||
/** Whether any frame was truncated. */
|
||||
has_truncated: z.boolean().default(false),
|
||||
/** Whether credentials were detected. */
|
||||
credentials_detected: z.boolean().default(false),
|
||||
});
|
||||
export type CustomProtocolCapture = z.infer<typeof customProtocolCaptureSchema>;
|
||||
|
||||
/** Correlate a frame with process identity. */
|
||||
export function correlateProcessIdentity(
|
||||
frame: ProtocolFrame,
|
||||
pid: number,
|
||||
): boolean {
|
||||
return frame.source_pid === pid || frame.dest_pid === pid;
|
||||
}
|
||||
|
||||
/** Check if a frame contains credential-like content. */
|
||||
export function looksLikeCredentialFrame(frame: ProtocolFrame): boolean {
|
||||
const patterns = [
|
||||
"password",
|
||||
"token",
|
||||
"secret",
|
||||
"api_key",
|
||||
"apikey",
|
||||
"authorization",
|
||||
"credential",
|
||||
];
|
||||
const content = JSON.stringify(frame.decoded_content ?? "").toLowerCase();
|
||||
return patterns.some((p) => content.includes(p));
|
||||
}
|
||||
|
||||
/** Filter frames by transport type. */
|
||||
export function framesByTransport(
|
||||
frames: readonly ProtocolFrame[],
|
||||
transport: TransportType,
|
||||
): ProtocolFrame[] {
|
||||
return frames.filter((f) => f.transport === transport);
|
||||
}
|
||||
|
||||
/** Get all authentication flow events for a specific protocol. */
|
||||
export function authEventsByProtocol(
|
||||
events: readonly AuthFlowEvent[],
|
||||
protocol: string,
|
||||
): AuthFlowEvent[] {
|
||||
return events.filter((e) => e.protocol === protocol);
|
||||
}
|
||||
|
||||
/** Get successful authentication events. */
|
||||
export function successfulAuthEvents(
|
||||
events: readonly AuthFlowEvent[],
|
||||
): AuthFlowEvent[] {
|
||||
return events.filter((e) => e.succeeded);
|
||||
}
|
||||
|
||||
/** Get failed authentication events. */
|
||||
export function failedAuthEvents(
|
||||
events: readonly AuthFlowEvent[],
|
||||
): AuthFlowEvent[] {
|
||||
return events.filter((e) => !e.succeeded);
|
||||
}
|
||||
|
||||
/** Compute the duration of an authentication flow in milliseconds. */
|
||||
export function authFlowDuration(
|
||||
events: readonly AuthFlowEvent[],
|
||||
): number | null {
|
||||
if (events.length === 0) return null;
|
||||
const first = events[0]!;
|
||||
const last = events[events.length - 1]!;
|
||||
return last.at_ms - first.at_ms;
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
import { z } from "zod";
|
||||
|
||||
/** Event types for event-backed process-tree capture. */
|
||||
export const processEventTypeSchema = z.enum([
|
||||
"spawn",
|
||||
"exit",
|
||||
"re_exec",
|
||||
"reparent",
|
||||
"signal",
|
||||
"name_change",
|
||||
"thread_create",
|
||||
"thread_exit",
|
||||
]);
|
||||
export type ProcessEventType = z.infer<typeof processEventTypeSchema>;
|
||||
|
||||
/** A single event-backed process-tree event. */
|
||||
export const processTreeEventSchema = z.strictObject({
|
||||
/** Monotonic event sequence number. */
|
||||
sequence: z.number().int().nonnegative(),
|
||||
/** Event timestamp in milliseconds. */
|
||||
at_ms: z.number().int().nonnegative(),
|
||||
/** Event type. */
|
||||
type: processEventTypeSchema,
|
||||
/** Process ID. */
|
||||
pid: z.number().int().positive(),
|
||||
/** Parent process ID. */
|
||||
ppid: z.number().int().nullable(),
|
||||
/** Process name if known. */
|
||||
process_name: z.string().nullable(),
|
||||
/** Executable path if known. */
|
||||
executable: z.string().nullable(),
|
||||
/** Command line arguments. */
|
||||
arguments: z.array(z.string()).default([]),
|
||||
/** Previous PID (for reparent events). */
|
||||
previous_pid: z.number().int().nullable(),
|
||||
/** Previous PPID (for reparent events). */
|
||||
previous_ppid: z.number().int().nullable(),
|
||||
/** Exit code (for exit events). */
|
||||
exit_code: z.number().int().nullable(),
|
||||
/** Signal number (for signal events). */
|
||||
signal: z.number().int().nullable(),
|
||||
/** Thread ID (for thread events). */
|
||||
tid: z.number().int().nullable(),
|
||||
});
|
||||
export type ProcessTreeEvent = z.infer<typeof processTreeEventSchema>;
|
||||
|
||||
/** Process node in a reconstructed tree. */
|
||||
export const processNodeSchema = z.strictObject({
|
||||
pid: z.number().int().positive(),
|
||||
ppid: z.number().int().nullable(),
|
||||
process_name: z.string().nullable(),
|
||||
executable: z.string().nullable(),
|
||||
arguments: z.array(z.string()).default([]),
|
||||
children: z.array(z.number().int()).default([]),
|
||||
spawn_time_ms: z.number().int().nullable(),
|
||||
exit_time_ms: z.number().int().nullable(),
|
||||
exit_code: z.number().int().nullable(),
|
||||
is_re_exec: z.boolean().default(false),
|
||||
is_reparented: z.boolean().default(false),
|
||||
previous_ppid: z.number().int().nullable(),
|
||||
});
|
||||
export type ProcessNode = z.infer<typeof processNodeSchema>;
|
||||
|
||||
/** Result of event-backed process-tree reconstruction. */
|
||||
export const processTreeReconstructionSchema = z.strictObject({
|
||||
/** Root PID of the process tree. */
|
||||
root_pid: z.number().int().positive(),
|
||||
/** All reconstructed process nodes. */
|
||||
processes: z.array(processNodeSchema).min(0).max(10_000),
|
||||
/** Events that were consumed. */
|
||||
events_consumed: z.number().int().nonnegative(),
|
||||
/** Events that could not be matched. */
|
||||
events_unmatched: z.number().int().nonnegative(),
|
||||
/** Short-lived descendants that were missed by sampling. */
|
||||
short_lived_descendants: z.number().int().nonnegative(),
|
||||
/** Whether any re-exec was detected. */
|
||||
has_re_exec: z.boolean().default(false),
|
||||
/** Whether any reparenting was detected. */
|
||||
has_reparenting: z.boolean().default(false),
|
||||
});
|
||||
export type ProcessTreeReconstruction = z.infer<
|
||||
typeof processTreeReconstructionSchema
|
||||
>;
|
||||
|
||||
/** Reconstruct a process tree from event-backed capture. */
|
||||
export function reconstructProcessTree(
|
||||
events: readonly ProcessTreeEvent[],
|
||||
rootPid: number,
|
||||
): ProcessTreeReconstruction {
|
||||
const nodes = new Map<number, ProcessNode>();
|
||||
const children = new Map<number, number[]>();
|
||||
let eventsConsumed = 0;
|
||||
let eventsUnmatched = 0;
|
||||
let hasReExec = false;
|
||||
let hasReparenting = false;
|
||||
let shortLivedDescendants = 0;
|
||||
|
||||
const sorted = [...events].sort((a, b) => a.at_ms - b.at_ms);
|
||||
|
||||
for (const event of sorted) {
|
||||
eventsConsumed++;
|
||||
let node = nodes.get(event.pid);
|
||||
|
||||
switch (event.type) {
|
||||
case "spawn": {
|
||||
if (!node) {
|
||||
node = {
|
||||
pid: event.pid,
|
||||
ppid: event.ppid,
|
||||
process_name: event.process_name,
|
||||
executable: event.executable,
|
||||
arguments: event.arguments,
|
||||
children: [],
|
||||
spawn_time_ms: event.at_ms,
|
||||
exit_time_ms: null,
|
||||
exit_code: null,
|
||||
is_re_exec: false,
|
||||
is_reparented: false,
|
||||
previous_ppid: null,
|
||||
};
|
||||
nodes.set(event.pid, node);
|
||||
}
|
||||
if (event.ppid !== null) {
|
||||
const parentChildren = children.get(event.ppid) ?? [];
|
||||
parentChildren.push(event.pid);
|
||||
children.set(event.ppid, parentChildren);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "exit": {
|
||||
if (node) {
|
||||
node.exit_time_ms = event.at_ms;
|
||||
node.exit_code = event.exit_code;
|
||||
if (
|
||||
node.spawn_time_ms !== null &&
|
||||
event.at_ms - node.spawn_time_ms < 100
|
||||
)
|
||||
shortLivedDescendants++;
|
||||
} else {
|
||||
eventsUnmatched++;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "re_exec": {
|
||||
if (node) {
|
||||
node.is_re_exec = true;
|
||||
node.executable = event.executable ?? node.executable;
|
||||
node.arguments = event.arguments;
|
||||
hasReExec = true;
|
||||
} else {
|
||||
eventsUnmatched++;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "reparent": {
|
||||
if (node) {
|
||||
node.previous_ppid = node.ppid;
|
||||
node.ppid = event.ppid;
|
||||
node.is_reparented = true;
|
||||
hasReparenting = true;
|
||||
} else {
|
||||
eventsUnmatched++;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "signal":
|
||||
case "name_change":
|
||||
case "thread_create":
|
||||
case "thread_exit":
|
||||
// These events are recorded but don't modify the tree structure
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Attach children to nodes
|
||||
for (const [pid, childPids] of children) {
|
||||
const node = nodes.get(pid);
|
||||
if (node) node.children = childPids;
|
||||
}
|
||||
|
||||
return {
|
||||
root_pid: rootPid,
|
||||
processes: [...nodes.values()],
|
||||
events_consumed: eventsConsumed,
|
||||
events_unmatched: eventsUnmatched,
|
||||
short_lived_descendants: shortLivedDescendants,
|
||||
has_re_exec: hasReExec,
|
||||
has_reparenting: hasReparenting,
|
||||
};
|
||||
}
|
||||
|
||||
/** Get all descendant PIDs of a given root PID. */
|
||||
export function getDescendants(
|
||||
processes: readonly ProcessNode[],
|
||||
rootPid: number,
|
||||
): number[] {
|
||||
const byPid = new Map(processes.map((p) => [p.pid, p]));
|
||||
const visited = new Set<number>();
|
||||
const result: number[] = [];
|
||||
const queue: number[] = [rootPid];
|
||||
while (queue.length > 0) {
|
||||
const pid = queue.shift()!;
|
||||
if (visited.has(pid)) continue;
|
||||
visited.add(pid);
|
||||
const node = byPid.get(pid);
|
||||
if (node) {
|
||||
for (const child of node.children) {
|
||||
if (!visited.has(child)) {
|
||||
result.push(child);
|
||||
queue.push(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Check if a process tree contains a specific PID. */
|
||||
export function containsProcess(
|
||||
processes: readonly ProcessNode[],
|
||||
pid: number,
|
||||
): boolean {
|
||||
return processes.some((p) => p.pid === pid);
|
||||
}
|
||||
|
||||
/** Get the maximum tree depth from the root. */
|
||||
export function maxTreeDepth(
|
||||
processes: readonly ProcessNode[],
|
||||
rootPid: number,
|
||||
): number {
|
||||
const byPid = new Map(processes.map((p) => [p.pid, p]));
|
||||
function depth(pid: number, visited: Set<number>): number {
|
||||
if (visited.has(pid)) return 0;
|
||||
visited.add(pid);
|
||||
const node = byPid.get(pid);
|
||||
if (!node || node.children.length === 0) return 0;
|
||||
return 1 + Math.max(...node.children.map((c) => depth(c, visited)));
|
||||
}
|
||||
return depth(rootPid, new Set());
|
||||
}
|
||||
@@ -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,179 @@
|
||||
import { z } from "zod";
|
||||
|
||||
/** Supported mobile artifact formats. */
|
||||
export const mobileArtifactFormatSchema = z.enum([
|
||||
"apk",
|
||||
"aab",
|
||||
"ipa",
|
||||
"dex",
|
||||
"native_lib",
|
||||
]);
|
||||
export type MobileArtifactFormat = z.infer<typeof mobileArtifactFormatSchema>;
|
||||
|
||||
/** Mobile platform. */
|
||||
export const mobilePlatformSchema = z.enum([
|
||||
"android",
|
||||
"ios",
|
||||
"cross_platform",
|
||||
]);
|
||||
export type MobilePlatform = z.infer<typeof mobilePlatformSchema>;
|
||||
|
||||
/** Android manifest entry. */
|
||||
export const androidManifestSchema = z.strictObject({
|
||||
package_name: z.string().min(1),
|
||||
version_name: z.string().nullable(),
|
||||
version_code: z.number().int().nullable(),
|
||||
min_sdk: z.number().int().nullable(),
|
||||
target_sdk: z.number().int().nullable(),
|
||||
permissions: z.array(z.string()).default([]),
|
||||
activities: z.array(z.string()).default([]),
|
||||
services: z.array(z.string()).default([]),
|
||||
receivers: z.array(z.string()).default([]),
|
||||
providers: z.array(z.string()).default([]),
|
||||
});
|
||||
export type AndroidManifest = z.infer<typeof androidManifestSchema>;
|
||||
|
||||
/** iOS app manifest (Info.plist). */
|
||||
export const iosManifestSchema = z.strictObject({
|
||||
bundle_identifier: z.string().min(1),
|
||||
bundle_version: z.string().nullable(),
|
||||
short_version: z.string().nullable(),
|
||||
minimum_os_version: z.string().nullable(),
|
||||
supported_platforms: z.array(z.string()).default([]),
|
||||
ui_required_device_capabilities: z.array(z.string()).default([]),
|
||||
app_permissions: z.array(z.string()).default([]),
|
||||
});
|
||||
export type IosManifest = z.infer<typeof iosManifestSchema>;
|
||||
|
||||
/** Signing information. */
|
||||
export const signingInfoSchema = z.strictObject({
|
||||
is_signed: z.boolean().default(false),
|
||||
signer_identity: z.string().nullable(),
|
||||
certificate_fingerprint: z.string().nullable(),
|
||||
signature_scheme: z.string().nullable(),
|
||||
is_debuggable: z.boolean().default(false),
|
||||
});
|
||||
export type SigningInfo = z.infer<typeof signingInfoSchema>;
|
||||
|
||||
/** Native library entry. */
|
||||
export const nativeLibrarySchema = z.strictObject({
|
||||
path: z.string().min(1),
|
||||
architecture: z.string().nullable(),
|
||||
size: z.number().int().nonnegative(),
|
||||
digest: z
|
||||
.string()
|
||||
.regex(/^[a-f0-9]{64}$/u)
|
||||
.nullable(),
|
||||
});
|
||||
export type NativeLibrary = z.infer<typeof nativeLibrarySchema>;
|
||||
|
||||
/** Resource entry within a mobile artifact. */
|
||||
export const mobileResourceSchema = z.strictObject({
|
||||
path: z.string().min(1),
|
||||
type: z.enum([
|
||||
"layout",
|
||||
"drawable",
|
||||
"string",
|
||||
"asset",
|
||||
"raw",
|
||||
"native_lib",
|
||||
"manifest",
|
||||
"entitlements",
|
||||
"provisioning",
|
||||
"other",
|
||||
]),
|
||||
size: z.number().int().nonnegative(),
|
||||
});
|
||||
export type MobileResource = z.infer<typeof mobileResourceSchema>;
|
||||
|
||||
/** Static analysis result for a mobile artifact. */
|
||||
export const mobileArtifactManifestSchema = z.strictObject({
|
||||
format: mobileArtifactFormatSchema,
|
||||
platform: mobilePlatformSchema,
|
||||
app_name: z.string().nullable(),
|
||||
android_manifest: androidManifestSchema.nullable(),
|
||||
ios_manifest: iosManifestSchema.nullable(),
|
||||
signing: signingInfoSchema,
|
||||
resources: z.array(mobileResourceSchema).default([]),
|
||||
native_libraries: z.array(nativeLibrarySchema).default([]),
|
||||
total_size: z.number().int().nonnegative(),
|
||||
architectures: z.array(z.string()).default([]),
|
||||
});
|
||||
export type MobileArtifactManifest = z.infer<
|
||||
typeof mobileArtifactManifestSchema
|
||||
>;
|
||||
|
||||
/** Detect mobile artifact format from file extension. */
|
||||
export function detectMobileFormat(path: string): MobileArtifactFormat | null {
|
||||
const lower = path.toLowerCase();
|
||||
if (lower.endsWith(".apk")) return "apk";
|
||||
if (lower.endsWith(".aab")) return "aab";
|
||||
if (lower.endsWith(".ipa")) return "ipa";
|
||||
if (lower.endsWith(".dex")) return "dex";
|
||||
if (lower.endsWith(".so") || lower.endsWith(".dylib") || lower.endsWith(".a"))
|
||||
return "native_lib";
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Detect platform from format. */
|
||||
export function detectPlatform(format: MobileArtifactFormat): MobilePlatform {
|
||||
switch (format) {
|
||||
case "apk":
|
||||
case "aab":
|
||||
case "dex":
|
||||
return "android";
|
||||
case "ipa":
|
||||
return "ios";
|
||||
case "native_lib":
|
||||
return "cross_platform";
|
||||
}
|
||||
}
|
||||
|
||||
/** Classify a resource by its path. */
|
||||
export function classifyMobileResource(path: string): MobileResource["type"] {
|
||||
const lower = path.toLowerCase();
|
||||
if (lower.includes("androidmanifest.xml")) return "manifest";
|
||||
if (lower.includes(".entitlements")) return "entitlements";
|
||||
if (lower.includes("provisioning") || lower.includes(".mobileprovision"))
|
||||
return "provisioning";
|
||||
if (lower.includes("/layout/")) return "layout";
|
||||
if (lower.includes("/drawable/") || lower.includes("/mipmap/"))
|
||||
return "drawable";
|
||||
if (lower.includes("/values/")) return "string";
|
||||
if (lower.includes("assets/") || lower.includes("raw/")) return "asset";
|
||||
if (lower.endsWith(".so") || lower.endsWith(".dylib")) return "native_lib";
|
||||
if (lower.endsWith(".xml")) return "layout";
|
||||
return "other";
|
||||
}
|
||||
|
||||
/** Get all native libraries for a specific architecture. */
|
||||
export function nativeLibsByArchitecture(
|
||||
manifest: MobileArtifactManifest,
|
||||
arch: string,
|
||||
): NativeLibrary[] {
|
||||
return manifest.native_libraries.filter((l) => l.architecture === arch);
|
||||
}
|
||||
|
||||
/** Check if an Android artifact has internet permission. */
|
||||
export function hasInternetPermission(
|
||||
manifest: MobileArtifactManifest,
|
||||
): boolean {
|
||||
if (!manifest.android_manifest) return false;
|
||||
return manifest.android_manifest.permissions.some(
|
||||
(p) => p.includes("INTERNET") || p.includes("android.permission.INTERNET"),
|
||||
);
|
||||
}
|
||||
|
||||
/** Get all unique architectures across native libraries. */
|
||||
export function getAllArchitectures(
|
||||
manifest: MobileArtifactManifest,
|
||||
): string[] {
|
||||
const archs = new Set<string>();
|
||||
for (const lib of manifest.native_libraries) {
|
||||
if (lib.architecture) archs.add(lib.architecture);
|
||||
}
|
||||
for (const arch of manifest.architectures) {
|
||||
archs.add(arch);
|
||||
}
|
||||
return [...archs];
|
||||
}
|
||||
@@ -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
|
||||
>;
|
||||
@@ -0,0 +1,192 @@
|
||||
import { z } from "zod";
|
||||
|
||||
/** Objective-C method type. */
|
||||
export const objcMethodTypeSchema = z.enum([
|
||||
"instance",
|
||||
"class",
|
||||
"ivar_getter",
|
||||
"ivar_setter",
|
||||
]);
|
||||
export type ObjcMethodType = z.infer<typeof objcMethodTypeSchema>;
|
||||
|
||||
/** Objective-C property attribute. */
|
||||
export const objcPropertyAttributeSchema = z.strictObject({
|
||||
name: z.string().min(1),
|
||||
value: z.string(),
|
||||
is_weak: z.boolean().default(false),
|
||||
is_atomic: z.boolean().default(false),
|
||||
is_copy: z.boolean().default(false),
|
||||
is_strong: z.boolean().default(false),
|
||||
});
|
||||
export type ObjcPropertyAttribute = z.infer<typeof objcPropertyAttributeSchema>;
|
||||
|
||||
/** Objective-C method metadata. */
|
||||
export const objcMethodSchema = z.strictObject({
|
||||
selector: z.string().min(1),
|
||||
method_type: objcMethodTypeSchema,
|
||||
address: z.number().int().nullable(),
|
||||
is_required: z.boolean().default(false),
|
||||
is_optional: z.boolean().default(false),
|
||||
});
|
||||
export type ObjcMethod = z.infer<typeof objcMethodSchema>;
|
||||
|
||||
/** Objective-C property metadata. */
|
||||
export const objcPropertySchema = z.strictObject({
|
||||
name: z.string().min(1),
|
||||
type_encoding: z.string().nullable(),
|
||||
attributes: z.array(objcPropertyAttributeSchema).default([]),
|
||||
is_readonly: z.boolean().default(false),
|
||||
getter: z.string().nullable(),
|
||||
setter: z.string().nullable(),
|
||||
});
|
||||
export type ObjcProperty = z.infer<typeof objcPropertySchema>;
|
||||
|
||||
/** Objective-C protocol metadata. */
|
||||
export const objcProtocolSchema = z.strictObject({
|
||||
name: z.string().min(1),
|
||||
methods: z.array(objcMethodSchema).default([]),
|
||||
optional_methods: z.array(objcMethodSchema).default([]),
|
||||
properties: z.array(objcPropertySchema).default([]),
|
||||
});
|
||||
export type ObjcProtocol = z.infer<typeof objcProtocolSchema>;
|
||||
|
||||
/** Objective-C class metadata. */
|
||||
export const objcClassSchema = z.strictObject({
|
||||
name: z.string().min(1),
|
||||
super_class: z.string().nullable(),
|
||||
is_meta_class: z.boolean().default(false),
|
||||
is_root_class: z.boolean().default(false),
|
||||
methods: z.array(objcMethodSchema).default([]),
|
||||
properties: z.array(objcPropertySchema).default([]),
|
||||
protocols: z.array(z.string()).default([]),
|
||||
ivar_count: z.number().int().nonnegative().default(0),
|
||||
instance_size: z.number().int().nonnegative().nullable(),
|
||||
});
|
||||
export type ObjcClass = z.infer<typeof objcClassSchema>;
|
||||
|
||||
/** Swift declaration kind. */
|
||||
export const swiftDeclKindSchema = z.enum([
|
||||
"class",
|
||||
"struct",
|
||||
"enum",
|
||||
"protocol",
|
||||
"extension",
|
||||
"actor",
|
||||
"global_var",
|
||||
"global_func",
|
||||
]);
|
||||
export type SwiftDeclKind = z.infer<typeof swiftDeclKindSchema>;
|
||||
|
||||
/** Swift access level. */
|
||||
export const swiftAccessLevelSchema = z.enum([
|
||||
"private",
|
||||
"fileprivate",
|
||||
"internal",
|
||||
"public",
|
||||
"open",
|
||||
]);
|
||||
export type SwiftAccessLevel = z.infer<typeof swiftAccessLevelSchema>;
|
||||
|
||||
/** Swift declaration metadata. */
|
||||
export const swiftDeclSchema = z.strictObject({
|
||||
kind: swiftDeclKindSchema,
|
||||
name: z.string().min(1),
|
||||
module: z.string().nullable(),
|
||||
access_level: swiftAccessLevelSchema.default("internal"),
|
||||
super_class: z.string().nullable(),
|
||||
protocols: z.array(z.string()).default([]),
|
||||
is_final: z.boolean().default(false),
|
||||
is_required: z.boolean().default(false),
|
||||
is_convenience_init: z.boolean().default(false),
|
||||
is_override: z.boolean().default(false),
|
||||
});
|
||||
export type SwiftDecl = z.infer<typeof swiftDeclSchema>;
|
||||
|
||||
/** Database save/read operation type. */
|
||||
export const dbOperationSchema = z.enum([
|
||||
"save",
|
||||
"readback",
|
||||
"open_database",
|
||||
"close_database",
|
||||
"export",
|
||||
"import",
|
||||
]);
|
||||
export type DbOperation = z.infer<typeof dbOperationSchema>;
|
||||
|
||||
/** Database save operation result. */
|
||||
export const dbSaveResultSchema = z.strictObject({
|
||||
operation: dbOperationSchema,
|
||||
succeeded: z.boolean(),
|
||||
preserved_names: z.number().int().nonnegative().default(0),
|
||||
preserved_comments: z.number().int().nonnegative().default(0),
|
||||
preserved_bookmarks: z.number().int().nonnegative().default(0),
|
||||
database_path: z.string().nullable(),
|
||||
error: z.string().nullable(),
|
||||
});
|
||||
export type DbSaveResult = z.infer<typeof dbSaveResultSchema>;
|
||||
|
||||
/** Result of deeper ObjC/Swift metadata extraction. */
|
||||
export const objcSwiftMetadataSchema = z.strictObject({
|
||||
objc_classes: z.array(objcClassSchema).default([]),
|
||||
objc_protocols: z.array(objcProtocolSchema).default([]),
|
||||
swift_decls: z.array(swiftDeclSchema).default([]),
|
||||
db_save_result: dbSaveResultSchema.nullable(),
|
||||
});
|
||||
export type ObjcSwiftMetadata = z.infer<typeof objcSwiftMetadataSchema>;
|
||||
|
||||
/** Check if a selector name looks like a getter. */
|
||||
export function isGetterSelector(selector: string): boolean {
|
||||
return /^[a-z][a-zA-Z0-9_]*$/u.test(selector) && !selector.includes(":");
|
||||
}
|
||||
|
||||
/** Check if a selector name looks like a setter. */
|
||||
export function isSetterSelector(selector: string): boolean {
|
||||
return selector.startsWith("set") && selector.endsWith(":");
|
||||
}
|
||||
|
||||
/** Extract property name from a getter/setter selector. */
|
||||
export function propertyNameFromSelector(selector: string): string | null {
|
||||
if (isSetterSelector(selector)) {
|
||||
const inner = selector.slice(3, -1);
|
||||
return inner.charAt(0).toLowerCase() + inner.slice(1);
|
||||
}
|
||||
if (isGetterSelector(selector)) {
|
||||
return selector;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Count ObjC methods by type. */
|
||||
export function countMethodsByType(
|
||||
methods: readonly ObjcMethod[],
|
||||
): Record<ObjcMethodType, number> {
|
||||
return {
|
||||
instance: methods.filter((m) => m.method_type === "instance").length,
|
||||
class: methods.filter((m) => m.method_type === "class").length,
|
||||
ivar_getter: methods.filter((m) => m.method_type === "ivar_getter").length,
|
||||
ivar_setter: methods.filter((m) => m.method_type === "ivar_setter").length,
|
||||
};
|
||||
}
|
||||
|
||||
/** Get all Swift declarations of a specific kind. */
|
||||
export function swiftDeclsByKind(
|
||||
decls: readonly SwiftDecl[],
|
||||
kind: SwiftDeclKind,
|
||||
): SwiftDecl[] {
|
||||
return decls.filter((d) => d.kind === kind);
|
||||
}
|
||||
|
||||
/** Check if a database save preserved all expected items. */
|
||||
export function isDbSaveComplete(
|
||||
result: DbSaveResult,
|
||||
expectedNames: number,
|
||||
expectedComments: number,
|
||||
expectedBookmarks: number,
|
||||
): boolean {
|
||||
return (
|
||||
result.succeeded &&
|
||||
result.preserved_names >= expectedNames &&
|
||||
result.preserved_comments >= expectedComments &&
|
||||
result.preserved_bookmarks >= expectedBookmarks
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { z } from "zod";
|
||||
|
||||
/** Supported Windows package formats. */
|
||||
export const packageFormatSchema = z.enum(["msix", "msi", "appx"]);
|
||||
export type PackageFormat = z.infer<typeof packageFormatSchema>;
|
||||
|
||||
/** A resource entry within a package. */
|
||||
export const packageResourceSchema = z.strictObject({
|
||||
/** Resource path within the package. */
|
||||
path: z.string().min(1),
|
||||
/** Resource type. */
|
||||
type: z.enum([
|
||||
"manifest",
|
||||
"icon",
|
||||
"configuration",
|
||||
"certificate",
|
||||
"executable",
|
||||
"library",
|
||||
"data",
|
||||
"other",
|
||||
]),
|
||||
/** Size in bytes. */
|
||||
size: z.number().int().nonnegative(),
|
||||
/** SHA-256 digest. */
|
||||
digest: z
|
||||
.string()
|
||||
.regex(/^[a-f0-9]{64}$/u)
|
||||
.nullable(),
|
||||
});
|
||||
export type PackageResource = z.infer<typeof packageResourceSchema>;
|
||||
|
||||
/** Digital signature information. */
|
||||
export const digitalSignatureSchema = z.strictObject({
|
||||
/** Whether the package is signed. */
|
||||
is_signed: z.boolean(),
|
||||
/** Signer subject name. */
|
||||
signer_subject: z.string().nullable(),
|
||||
/** Certificate thumbprint. */
|
||||
certificate_thumbprint: z.string().nullable(),
|
||||
/** Whether the signature is valid. */
|
||||
is_valid: z.boolean().default(false),
|
||||
/** Whether the signature is timestamped. */
|
||||
is_timestamped: z.boolean().default(false),
|
||||
/** Timestamp signer if present. */
|
||||
timestamp_signer: z.string().nullable(),
|
||||
});
|
||||
export type DigitalSignature = z.infer<typeof digitalSignatureSchema>;
|
||||
|
||||
/** A Windows package manifest for static analysis. */
|
||||
export const packageManifestSchema = z.strictObject({
|
||||
/** Package format. */
|
||||
format: packageFormatSchema,
|
||||
/** Package identity name. */
|
||||
name: z.string().min(1),
|
||||
/** Package publisher. */
|
||||
publisher: z.string().nullable(),
|
||||
/** Package version. */
|
||||
version: z.string().nullable(),
|
||||
/** Architecture. */
|
||||
architecture: z.enum(["x86", "x64", "arm", "arm64", "neutral", "unknown"]),
|
||||
/** Package resources. */
|
||||
resources: z.array(packageResourceSchema).default([]),
|
||||
/** Digital signature. */
|
||||
signature: digitalSignatureSchema,
|
||||
/** Whether the package is a framework. */
|
||||
is_framework: z.boolean().default(false),
|
||||
/** Package dependencies. */
|
||||
dependencies: z.array(z.string()).default([]),
|
||||
/** Capabilities declared in the manifest. */
|
||||
capabilities: z.array(z.string()).default([]),
|
||||
});
|
||||
export type PackageManifest = z.infer<typeof packageManifestSchema>;
|
||||
|
||||
/** Detect package format from file extension. */
|
||||
export function detectPackageFormat(path: string): PackageFormat | null {
|
||||
const lower = path.toLowerCase();
|
||||
if (lower.endsWith(".msix")) return "msix";
|
||||
if (lower.endsWith(".msi")) return "msi";
|
||||
if (lower.endsWith(".appx")) return "appx";
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Check if a resource path looks like a manifest. */
|
||||
export function isManifestResource(path: string): boolean {
|
||||
const lower = path.toLowerCase();
|
||||
return (
|
||||
lower.includes("appxmanifest.xml") ||
|
||||
lower.includes("appxmanifest") ||
|
||||
lower.includes("manifest.xml") ||
|
||||
lower.endsWith("_manifest.xml")
|
||||
);
|
||||
}
|
||||
|
||||
/** Check if a resource path looks like a configuration file. */
|
||||
export function isConfigurationResource(path: string): boolean {
|
||||
const lower = path.toLowerCase();
|
||||
return (
|
||||
lower.endsWith(".xml") ||
|
||||
lower.endsWith(".json") ||
|
||||
lower.endsWith(".ini") ||
|
||||
lower.endsWith(".config")
|
||||
);
|
||||
}
|
||||
|
||||
/** Classify a resource by its path. */
|
||||
export function classifyResource(path: string): PackageResource["type"] {
|
||||
if (isManifestResource(path)) return "manifest";
|
||||
const lower = path.toLowerCase();
|
||||
if (
|
||||
lower.endsWith(".ico") ||
|
||||
lower.endsWith(".png") ||
|
||||
lower.endsWith(".jpg") ||
|
||||
lower.endsWith(".jpeg")
|
||||
)
|
||||
return "icon";
|
||||
if (isConfigurationResource(path)) return "configuration";
|
||||
if (
|
||||
lower.endsWith(".cer") ||
|
||||
lower.endsWith(".pfx") ||
|
||||
lower.endsWith(".p12")
|
||||
)
|
||||
return "certificate";
|
||||
if (lower.endsWith(".exe") || lower.endsWith(".dll"))
|
||||
return lower.endsWith(".exe") ? "executable" : "library";
|
||||
return "other";
|
||||
}
|
||||
|
||||
/** Get all resources of a specific type. */
|
||||
export function resourcesByType(
|
||||
manifest: PackageManifest,
|
||||
type: PackageResource["type"],
|
||||
): PackageResource[] {
|
||||
return manifest.resources.filter((r) => r.type === type);
|
||||
}
|
||||
|
||||
/** Get the total size of all resources. */
|
||||
export function totalResourceSize(manifest: PackageManifest): number {
|
||||
return manifest.resources.reduce((sum, r) => sum + r.size, 0);
|
||||
}
|
||||
|
||||
/** Check if a package has a valid signature. */
|
||||
export function hasValidSignature(manifest: PackageManifest): boolean {
|
||||
return manifest.signature.is_signed && manifest.signature.is_valid;
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
import { z } from "zod";
|
||||
|
||||
/** PE/COFF section characteristics flags. */
|
||||
export const sectionCharacteristicsSchema = z.object({
|
||||
/** Section contains executable code. */
|
||||
has_code: z.boolean().default(false),
|
||||
/** Section contains initialized data. */
|
||||
has_initialized_data: z.boolean().default(false),
|
||||
/** Section contains uninitialized data. */
|
||||
has_uninitialized_data: z.boolean().default(false),
|
||||
/** Section can be read. */
|
||||
readable: z.boolean().default(false),
|
||||
/** Section can be written. */
|
||||
writable: z.boolean().default(false),
|
||||
/** Section can be executed. */
|
||||
executable: z.boolean().default(false),
|
||||
});
|
||||
|
||||
/** PE/COFF section header. */
|
||||
export const peSectionSchema = z.strictObject({
|
||||
name: z.string().min(1),
|
||||
virtual_size: z.number().int().nonnegative(),
|
||||
virtual_address: z.number().int().nonnegative(),
|
||||
raw_size: z.number().int().nonnegative(),
|
||||
raw_offset: z.number().int().nonnegative(),
|
||||
characteristics: sectionCharacteristicsSchema,
|
||||
});
|
||||
export type PeSection = z.infer<typeof peSectionSchema>;
|
||||
|
||||
/** PE import entry. */
|
||||
export const peImportSchema = z.strictObject({
|
||||
dll: z.string().min(1),
|
||||
function_name: z.string().min(1),
|
||||
ordinal: z.number().int().nullable(),
|
||||
is_delay_import: z.boolean().default(false),
|
||||
});
|
||||
export type PeImport = z.infer<typeof peImportSchema>;
|
||||
|
||||
/** PE export entry. */
|
||||
export const peExportSchema = z.strictObject({
|
||||
name: z.string().min(1),
|
||||
ordinal: z.number().int().nonnegative(),
|
||||
rva: z.number().int().nonnegative(),
|
||||
forwarder: z.string().nullable(),
|
||||
});
|
||||
export type PeExport = z.infer<typeof peExportSchema>;
|
||||
|
||||
/** PE/COFF resource entry. */
|
||||
export const peResourceSchema = z.strictObject({
|
||||
type: z.string().min(1),
|
||||
name: z.string().nullable(),
|
||||
language: z.number().int().nullable(),
|
||||
rva: z.number().int().nonnegative(),
|
||||
size: z.number().int().nonnegative(),
|
||||
});
|
||||
export type PeResource = z.infer<typeof peResourceSchema>;
|
||||
|
||||
/** PE debug directory entry. */
|
||||
export const peDebugEntrySchema = z.strictObject({
|
||||
type: z.string().min(1),
|
||||
rva: z.number().int().nonnegative(),
|
||||
size: z.number().int().nonnegative(),
|
||||
/** PDB GUID if this is a CodeView entry. */
|
||||
pdb_guid: z.string().nullable(),
|
||||
/** PDB age if this is a CodeView entry. */
|
||||
pdb_age: z.number().int().nullable(),
|
||||
/** PDB path if this is a CodeView entry. */
|
||||
pdb_path: z.string().nullable(),
|
||||
});
|
||||
export type PeDebugEntry = z.infer<typeof peDebugEntrySchema>;
|
||||
|
||||
/** TLS callback entry. */
|
||||
export const peTlsCallbackSchema = z.strictObject({
|
||||
callback_rva: z.number().int().nonnegative(),
|
||||
index: z.number().int().nonnegative(),
|
||||
});
|
||||
export type PeTlsCallback = z.infer<typeof peTlsCallbackSchema>;
|
||||
|
||||
/** Architecture/machine type. */
|
||||
export const peMachineTypeSchema = z.enum([
|
||||
"x86",
|
||||
"x64",
|
||||
"arm",
|
||||
"arm64",
|
||||
"ia64",
|
||||
"unknown",
|
||||
]);
|
||||
export type PeMachineType = z.infer<typeof peMachineTypeSchema>;
|
||||
|
||||
/** PE/COFF manifest for static inspection. */
|
||||
export const peManifestSchema = z.strictObject({
|
||||
/** DOS header magic. */
|
||||
dos_magic: z.literal(0x5a4d),
|
||||
/** PE signature. */
|
||||
pe_signature: z.literal(0x00004550),
|
||||
/** Machine type. */
|
||||
machine: peMachineTypeSchema,
|
||||
/** Number of sections. */
|
||||
number_of_sections: z.number().int().positive(),
|
||||
/** Timestamp from the COFF header. */
|
||||
timestamp: z.number().int().nullable(),
|
||||
/** PE characteristics flags. */
|
||||
is_dll: z.boolean().default(false),
|
||||
is_executable: z.boolean().default(false),
|
||||
is_console: z.boolean().default(true),
|
||||
/** Entry point RVA. */
|
||||
entry_point: z.number().int().nonnegative(),
|
||||
/** Image base. */
|
||||
image_base: z.number().int().nonnegative(),
|
||||
/** Section alignment. */
|
||||
section_alignment: z.number().int().positive(),
|
||||
/** Subsystem version. */
|
||||
subsystem_version: z.string().nullable(),
|
||||
/** Sections. */
|
||||
sections: z.array(peSectionSchema).min(0).max(96),
|
||||
/** Imports. */
|
||||
imports: z.array(peImportSchema).default([]),
|
||||
/** Exports. */
|
||||
exports: z.array(peExportSchema).default([]),
|
||||
/** Resources. */
|
||||
resources: z.array(peResourceSchema).default([]),
|
||||
/** Debug directory entries. */
|
||||
debug_entries: z.array(peDebugEntrySchema).default([]),
|
||||
/** TLS callbacks. */
|
||||
tls_callbacks: z.array(peTlsCallbackSchema).default([]),
|
||||
/** Relocations count (not all entries). */
|
||||
relocations_count: z.number().int().nonnegative().default(0),
|
||||
/** Image file characteristics. */
|
||||
image_characteristics: z.array(z.string()).default([]),
|
||||
});
|
||||
export type PeManifest = z.infer<typeof peManifestSchema>;
|
||||
|
||||
/** Parse machine type from COFF machine field. */
|
||||
export function parseMachineType(machine: number): PeMachineType {
|
||||
switch (machine) {
|
||||
case 0x014c:
|
||||
return "x86";
|
||||
case 0x8664:
|
||||
return "x64";
|
||||
case 0x01c0:
|
||||
case 0x01c4:
|
||||
return "arm";
|
||||
case 0xaa64:
|
||||
return "arm64";
|
||||
case 0x0200:
|
||||
return "ia64";
|
||||
default:
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse section characteristics flags. */
|
||||
export function parseSectionCharacteristics(
|
||||
characteristics: number,
|
||||
): z.infer<typeof sectionCharacteristicsSchema> {
|
||||
const IMAGE_SCN_CNT_CODE = 0x00000020;
|
||||
const IMAGE_SCN_CNT_INITIALIZED_DATA = 0x00000040;
|
||||
const IMAGE_SCN_CNT_UNINITIALIZED_DATA = 0x00000080;
|
||||
const IMAGE_SCN_MEM_READ = 0x40000000;
|
||||
const IMAGE_SCN_MEM_WRITE = 0x80000000;
|
||||
const IMAGE_SCN_MEM_EXECUTE = 0x20000000;
|
||||
|
||||
return {
|
||||
has_code: (characteristics & IMAGE_SCN_CNT_CODE) !== 0,
|
||||
has_initialized_data:
|
||||
(characteristics & IMAGE_SCN_CNT_INITIALIZED_DATA) !== 0,
|
||||
has_uninitialized_data:
|
||||
(characteristics & IMAGE_SCN_CNT_UNINITIALIZED_DATA) !== 0,
|
||||
readable: (characteristics & IMAGE_SCN_MEM_READ) !== 0,
|
||||
writable: (characteristics & IMAGE_SCN_MEM_WRITE) !== 0,
|
||||
executable: (characteristics & IMAGE_SCN_MEM_EXECUTE) !== 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** Parse image characteristics flags. */
|
||||
export function parseImageCharacteristics(characteristics: number): string[] {
|
||||
const IMAGE_DLL = 0x2000;
|
||||
const IMAGE_EXECUTABLE = 0x0002;
|
||||
const IMAGE_CONSOLE = 0x0003;
|
||||
const flags: string[] = [];
|
||||
if (characteristics & IMAGE_DLL) flags.push("DLL");
|
||||
if (characteristics & IMAGE_EXECUTABLE) flags.push("Executable");
|
||||
if ((characteristics & 0x0000000f) === IMAGE_CONSOLE) flags.push("Console");
|
||||
return flags;
|
||||
}
|
||||
|
||||
/** Parse a PE debug directory type. */
|
||||
export function parseDebugType(type: number): string {
|
||||
switch (type) {
|
||||
case 0:
|
||||
return "UNKNOWN";
|
||||
case 1:
|
||||
return "COFF";
|
||||
case 2:
|
||||
return "CODEVIEW";
|
||||
case 3:
|
||||
return "POGO";
|
||||
case 4:
|
||||
return "MISC";
|
||||
default:
|
||||
return "UNKNOWN";
|
||||
}
|
||||
}
|
||||
|
||||
/** Check if a PE manifest has PDB debug info. */
|
||||
export function hasPdbInfo(manifest: PeManifest): boolean {
|
||||
return manifest.debug_entries.some(
|
||||
(e) => e.pdb_guid !== null && e.pdb_path !== null,
|
||||
);
|
||||
}
|
||||
|
||||
/** Get all imported DLLs from a PE manifest. */
|
||||
export function getImportedDlls(manifest: PeManifest): string[] {
|
||||
return [...new Set(manifest.imports.map((i) => i.dll))];
|
||||
}
|
||||
|
||||
/** Get all exported function names from a PE manifest. */
|
||||
export function getExportedFunctions(manifest: PeManifest): string[] {
|
||||
return manifest.exports.map((e) => e.name);
|
||||
}
|
||||
|
||||
/** Count executable sections. */
|
||||
export function countExecutableSections(manifest: PeManifest): number {
|
||||
return manifest.sections.filter((s) => s.characteristics.executable).length;
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { jsonValueSchema } from "./jsonValue.js";
|
||||
|
||||
/** Supported protocol families for structured protocol capture. */
|
||||
export const protocolFamilySchema = z.enum([
|
||||
"grpc",
|
||||
"protobuf",
|
||||
"json-rpc",
|
||||
"messagepack",
|
||||
]);
|
||||
export type ProtocolFamily = z.infer<typeof protocolFamilySchema>;
|
||||
|
||||
/** A decoded field from a protocol message. */
|
||||
export const decodedFieldSchema = z.strictObject({
|
||||
/** Field name or path within the message structure. */
|
||||
path: z.string().min(1),
|
||||
/** Wire type or encoding hint. */
|
||||
wire_type: z.string().nullable(),
|
||||
/** Decoded field value. */
|
||||
value: jsonValueSchema.nullable(),
|
||||
/** Whether this field value was inferred rather than decoded. */
|
||||
inferred: z.boolean().default(false),
|
||||
});
|
||||
export type DecodedField = z.infer<typeof decodedFieldSchema>;
|
||||
|
||||
/** Schema inference hypothesis for a repeated message pattern. */
|
||||
export const schemaHypothesisSchema = z.strictObject({
|
||||
/** Hypothesized field name. */
|
||||
field_name: z.string().min(1),
|
||||
/** Inferred type. */
|
||||
inferred_type: z.string().min(1),
|
||||
/** Confidence: 0-1 */
|
||||
confidence: z.number().min(0).max(1),
|
||||
/** Supporting evidence count. */
|
||||
evidence_count: z.number().int().nonnegative(),
|
||||
});
|
||||
export type SchemaHypothesis = z.infer<typeof schemaHypothesisSchema>;
|
||||
|
||||
/** Result of decoding a single protocol message. */
|
||||
export const protocolMessageSchema = z.strictObject({
|
||||
/** Monotonic sequence number. */
|
||||
sequence: z.number().int().nonnegative(),
|
||||
/** Timestamp in milliseconds. */
|
||||
at_ms: z.number().int().nonnegative(),
|
||||
/** Protocol family. */
|
||||
family: protocolFamilySchema,
|
||||
/** Direction of the message. */
|
||||
direction: z.enum(["request", "response"]),
|
||||
/** Transport endpoint or path (e.g. gRPC method path). */
|
||||
endpoint: z.string().nullable(),
|
||||
/** Content type of the raw payload. */
|
||||
content_type: z.string().nullable(),
|
||||
/** Raw payload bytes as base64. */
|
||||
raw_payload: z.string().nullable(),
|
||||
/** Decoded fields from the payload. */
|
||||
decoded_fields: z.array(decodedFieldSchema).default([]),
|
||||
/** Inferred schema hypotheses. */
|
||||
schema_hypotheses: z.array(schemaHypothesisSchema).default([]),
|
||||
/** Whether decoding was truncated. */
|
||||
truncated: z.boolean().default(false),
|
||||
/** Whether credentials were detected and redacted. */
|
||||
credentials_redacted: z.boolean().default(false),
|
||||
});
|
||||
export type ProtocolMessage = z.infer<typeof protocolMessageSchema>;
|
||||
|
||||
/** A captured protocol session with messages and inferred schemas. */
|
||||
export const protocolCaptureSchema = z.strictObject({
|
||||
/** Protocol family for this capture. */
|
||||
family: protocolFamilySchema,
|
||||
/** Captured messages in order. */
|
||||
messages: z.array(protocolMessageSchema).min(0).max(10_000),
|
||||
/** Aggregated schema hypotheses across all messages. */
|
||||
inferred_schema: z.array(schemaHypothesisSchema).default([]),
|
||||
/** Whether any message was truncated. */
|
||||
has_truncated: z.boolean().default(false),
|
||||
/** Whether credentials were detected and redacted. */
|
||||
credentials_detected: z.boolean().default(false),
|
||||
});
|
||||
export type ProtocolCapture = z.infer<typeof protocolCaptureSchema>;
|
||||
|
||||
/** Classification of a byte sequence as a known protocol family. */
|
||||
export function classifyProtocolFamily(
|
||||
contentType: string | null,
|
||||
path: string | null,
|
||||
): ProtocolFamily | null {
|
||||
if (contentType) {
|
||||
if (contentType.includes("application/grpc")) return "grpc";
|
||||
if (contentType.includes("application/x-protobuf")) return "protobuf";
|
||||
if (contentType.includes("application/json-rpc")) return "json-rpc";
|
||||
if (
|
||||
contentType.includes("application/msgpack") ||
|
||||
contentType.includes("application/x-msgpack")
|
||||
)
|
||||
return "messagepack";
|
||||
}
|
||||
if (path) {
|
||||
if (path.startsWith("/grpc.") || path.includes("/grpc/")) return "grpc";
|
||||
if (path.includes("/json-rpc") || path.includes("/jsonrpc"))
|
||||
return "json-rpc";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Credential-like field names that should be redacted. */
|
||||
const CREDENTIAL_PATTERNS = [
|
||||
"password",
|
||||
"passwd",
|
||||
"secret",
|
||||
"token",
|
||||
"api_key",
|
||||
"apikey",
|
||||
"authorization",
|
||||
"auth",
|
||||
"credential",
|
||||
"private_key",
|
||||
];
|
||||
|
||||
/** Check if a field path looks like it may contain credentials. */
|
||||
export function looksLikeCredential(path: string): boolean {
|
||||
const lower = path.toLowerCase();
|
||||
return CREDENTIAL_PATTERNS.some((p) => lower.includes(p));
|
||||
}
|
||||
|
||||
/** Decode a JSON-RPC message from raw payload bytes. */
|
||||
export function decodeJsonRpc(rawPayload: Uint8Array): {
|
||||
decoded_fields: DecodedField[];
|
||||
truncated: boolean;
|
||||
} {
|
||||
const text = new TextDecoder().decode(rawPayload);
|
||||
const parsed = JSON.parse(text);
|
||||
const fields: DecodedField[] = [];
|
||||
|
||||
if (typeof parsed === "object" && parsed !== null) {
|
||||
for (const [key, value] of Object.entries(parsed)) {
|
||||
if (looksLikeCredential(key)) {
|
||||
fields.push({
|
||||
path: key,
|
||||
wire_type: "json",
|
||||
value: "[REDACTED]",
|
||||
inferred: false,
|
||||
});
|
||||
} else {
|
||||
fields.push({
|
||||
path: key,
|
||||
wire_type: "json",
|
||||
value: value as never,
|
||||
inferred: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { decoded_fields: fields, truncated: false };
|
||||
}
|
||||
|
||||
/** Decode a MessagePack message (simplified: only checks basic structure). */
|
||||
export function decodeMessagePack(rawPayload: Uint8Array): {
|
||||
decoded_fields: DecodedField[];
|
||||
truncated: boolean;
|
||||
} {
|
||||
const fields: DecodedField[] = [];
|
||||
// MessagePack uses a single-byte type tag
|
||||
if (rawPayload.length === 0) {
|
||||
return { decoded_fields: [], truncated: false };
|
||||
}
|
||||
const tag = rawPayload[0]!;
|
||||
// Map type: 0x80-0x8f (fixmap), 0xde (map16), 0xdf (map32)
|
||||
if ((tag >= 0x80 && tag <= 0x8f) || tag === 0xde || tag === 0xdf) {
|
||||
fields.push({
|
||||
path: "type",
|
||||
wire_type: "msgpack",
|
||||
value: "map",
|
||||
inferred: false,
|
||||
});
|
||||
}
|
||||
// Array type: 0x90-0x9f (fixarray), 0xdc (array16), 0xdd (array32)
|
||||
else if ((tag >= 0x90 && tag <= 0x9f) || tag === 0xdc || tag === 0xdd) {
|
||||
fields.push({
|
||||
path: "type",
|
||||
wire_type: "msgpack",
|
||||
value: "array",
|
||||
inferred: false,
|
||||
});
|
||||
} else {
|
||||
fields.push({
|
||||
path: "type",
|
||||
wire_type: "msgpack",
|
||||
value: "unknown",
|
||||
inferred: true,
|
||||
});
|
||||
}
|
||||
return {
|
||||
decoded_fields: fields,
|
||||
truncated: rawPayload.length > 10_000,
|
||||
};
|
||||
}
|
||||
|
||||
/** Decode a gRPC frame (5-byte header + protobuf payload). */
|
||||
export function decodeGrpcFrame(rawPayload: Uint8Array): {
|
||||
decoded_fields: DecodedField[];
|
||||
truncated: boolean;
|
||||
} {
|
||||
// gRPC uses a 5-byte prefix: 1 byte compressed flag + 4 byte length
|
||||
if (rawPayload.length < 5) {
|
||||
return {
|
||||
decoded_fields: [
|
||||
{
|
||||
path: "error",
|
||||
wire_type: "grpc",
|
||||
value: "frame too short",
|
||||
inferred: false,
|
||||
},
|
||||
],
|
||||
truncated: false,
|
||||
};
|
||||
}
|
||||
const compressed = rawPayload[0] !== 0;
|
||||
const length =
|
||||
(rawPayload[1]! << 24) |
|
||||
(rawPayload[2]! << 16) |
|
||||
(rawPayload[3]! << 8) |
|
||||
rawPayload[4]!;
|
||||
return {
|
||||
decoded_fields: [
|
||||
{
|
||||
path: "compressed",
|
||||
wire_type: "grpc",
|
||||
value: compressed,
|
||||
inferred: false,
|
||||
},
|
||||
{
|
||||
path: "message_length",
|
||||
wire_type: "grpc",
|
||||
value: length,
|
||||
inferred: false,
|
||||
},
|
||||
],
|
||||
truncated: length > 10_000,
|
||||
};
|
||||
}
|
||||
|
||||
/** Infer schema hypotheses from a set of decoded messages. */
|
||||
export function inferMessageSchema(
|
||||
messages: readonly ProtocolMessage[],
|
||||
): SchemaHypothesis[] {
|
||||
const fieldCounts = new Map<string, { count: number; types: Set<string> }>();
|
||||
for (const msg of messages) {
|
||||
for (const field of msg.decoded_fields) {
|
||||
const existing = fieldCounts.get(field.path);
|
||||
if (existing) {
|
||||
existing.count++;
|
||||
if (field.wire_type) existing.types.add(field.wire_type);
|
||||
} else {
|
||||
fieldCounts.set(field.path, {
|
||||
count: 1,
|
||||
types: new Set(field.wire_type ?? "unknown"),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
const hypotheses: SchemaHypothesis[] = [];
|
||||
for (const [field_name, data] of fieldCounts) {
|
||||
hypotheses.push({
|
||||
field_name,
|
||||
inferred_type: [...data.types].join("|"),
|
||||
confidence: Math.min(1, data.count / messages.length || 0),
|
||||
evidence_count: data.count,
|
||||
});
|
||||
}
|
||||
return hypotheses;
|
||||
}
|
||||
@@ -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.7.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)");
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
analyzeBytecodeArtifacts,
|
||||
bytecodeAnalysisSchema,
|
||||
classifyProvenance,
|
||||
detectArtifactFormat,
|
||||
detectBytecodeFamily,
|
||||
type BytecodeArtifact,
|
||||
type BytecodeSymbol,
|
||||
} from "../src/domain/bytecodeProvider.js";
|
||||
|
||||
describe("bytecode provider", () => {
|
||||
it("detects JVM family from extensions", () => {
|
||||
expect(detectBytecodeFamily("App.class")).toBe("jvm");
|
||||
expect(detectBytecodeFamily("app.jar")).toBe("jvm");
|
||||
expect(detectBytecodeFamily("webapp.war")).toBe("jvm");
|
||||
expect(detectBytecodeFamily("app.ear")).toBe("jvm");
|
||||
});
|
||||
|
||||
it("detects Python family from extensions", () => {
|
||||
expect(detectBytecodeFamily("module.pyc")).toBe("python");
|
||||
expect(detectBytecodeFamily("module.pyo")).toBe("python");
|
||||
expect(detectBytecodeFamily("package.whl")).toBe("python");
|
||||
expect(detectBytecodeFamily("app.pyz")).toBe("python");
|
||||
expect(detectBytecodeFamily("app.zipapp")).toBe("python");
|
||||
});
|
||||
|
||||
it("returns null for unknown extensions", () => {
|
||||
expect(detectBytecodeFamily("readme.txt")).toBeNull();
|
||||
expect(detectBytecodeFamily("script.js")).toBeNull();
|
||||
});
|
||||
|
||||
it("detects artifact format from path", () => {
|
||||
expect(detectArtifactFormat("App.class")).toBe("class");
|
||||
expect(detectArtifactFormat("lib.jar")).toBe("jar");
|
||||
expect(detectArtifactFormat("web.war")).toBe("war");
|
||||
expect(detectArtifactFormat("mod.pyc")).toBe("pyc");
|
||||
expect(detectArtifactFormat("pkg.whl")).toBe("whl");
|
||||
expect(detectArtifactFormat("unknown.xyz")).toBeNull();
|
||||
});
|
||||
|
||||
it("classifies standard library provenance", () => {
|
||||
expect(classifyProvenance("/usr/lib/stdlib", "java.lang.String")).toBe(
|
||||
"standard_library",
|
||||
);
|
||||
expect(
|
||||
classifyProvenance("/lib/python3.10/site-packages/numpy", "__init__"),
|
||||
).toBe("standard_library");
|
||||
});
|
||||
|
||||
it("classifies generated provenance", () => {
|
||||
expect(classifyProvenance("/build/generated/src", "GeneratedClass")).toBe(
|
||||
"generated",
|
||||
);
|
||||
expect(
|
||||
classifyProvenance("/__pycache__/module.cpython-310.pyc", "module"),
|
||||
).toBe("generated");
|
||||
});
|
||||
|
||||
it("classifies vendored provenance", () => {
|
||||
expect(classifyProvenance("/vendor/libs/lib.jar", "VendorLib")).toBe(
|
||||
"vendored",
|
||||
);
|
||||
expect(classifyProvenance("/third_party/pkg/module.pyc", "module")).toBe(
|
||||
"vendored",
|
||||
);
|
||||
});
|
||||
|
||||
it("classifies application provenance", () => {
|
||||
expect(classifyProvenance("/src/main/App.java", "com.example.App")).toBe(
|
||||
"application",
|
||||
);
|
||||
expect(classifyProvenance("/src/app.py", "main")).toBe("application");
|
||||
});
|
||||
|
||||
it("analyzes bytecode artifacts and computes summary", () => {
|
||||
const artifacts: BytecodeArtifact[] = [
|
||||
{
|
||||
path: "src/App.class",
|
||||
family: "jvm",
|
||||
format: "class",
|
||||
size: 1000,
|
||||
digest: "a".repeat(64),
|
||||
symbols: [
|
||||
{
|
||||
name: "com.example.App",
|
||||
raw_location: "0x1000",
|
||||
kind: "class",
|
||||
bytecode_version: 52,
|
||||
provenance: "application",
|
||||
is_public: true,
|
||||
},
|
||||
{
|
||||
name: "java.lang.String",
|
||||
raw_location: "0x2000",
|
||||
kind: "class",
|
||||
bytecode_version: 52,
|
||||
provenance: "standard_library",
|
||||
is_public: true,
|
||||
},
|
||||
],
|
||||
is_standard_library: false,
|
||||
is_generated: false,
|
||||
is_vendored: false,
|
||||
},
|
||||
{
|
||||
path: "lib/generated/Generated.class",
|
||||
family: "jvm",
|
||||
format: "class",
|
||||
size: 500,
|
||||
digest: "b".repeat(64),
|
||||
symbols: [
|
||||
{
|
||||
name: "GeneratedClass",
|
||||
raw_location: "0x3000",
|
||||
kind: "class",
|
||||
bytecode_version: 52,
|
||||
provenance: "generated",
|
||||
is_public: false,
|
||||
},
|
||||
],
|
||||
is_standard_library: false,
|
||||
is_generated: true,
|
||||
is_vendored: false,
|
||||
},
|
||||
];
|
||||
const result = analyzeBytecodeArtifacts(artifacts, "jvm");
|
||||
expect(result.family).toBe("jvm");
|
||||
expect(result.total_symbols).toBe(3);
|
||||
expect(result.application_symbols).toBe(1);
|
||||
expect(result.standard_library_symbols).toBe(1);
|
||||
expect(result.generated_symbols).toBe(1);
|
||||
expect(result.vendored_symbols).toBe(0);
|
||||
|
||||
// Validate schema
|
||||
const parsed = bytecodeAnalysisSchema.safeParse(result);
|
||||
expect(parsed.success).toBe(true);
|
||||
});
|
||||
|
||||
it("uses BytecodeSymbol type", () => {
|
||||
const sym: BytecodeSymbol = {
|
||||
name: "com.example.Test",
|
||||
raw_location: "0x100",
|
||||
kind: "class",
|
||||
bytecode_version: 52,
|
||||
provenance: "application",
|
||||
is_public: true,
|
||||
};
|
||||
expect(sym.provenance).toBe("application");
|
||||
});
|
||||
});
|
||||
@@ -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; }
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user