mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
feat(android): integrate static APK analysis with headless JADX (#738)
* feat(android): integrate static APK analysis with headless JADX * test(android): include APK tools in explicit effects audit
This commit is contained in:
1 parent
d0f3480a3c
commit
904d03e987
59 files changed
+3342
-52
No files matched your search
@@ -0,0 +1,3 @@
|
||||
[submodule "third_party/jadx-headless-mcp"]
|
||||
path = third_party/jadx-headless-mcp
|
||||
url = https://github.com/1013503897/jadx-headless-mcp.git
|
||||
@@ -9,6 +9,7 @@
|
||||
".codex/**",
|
||||
".devin/**",
|
||||
"tests/fixtures/**",
|
||||
"third_party/jadx-headless-mcp/**",
|
||||
"tests/conformance/**/*.c",
|
||||
"tests/conformance/**/*.m",
|
||||
"tests/conformance/**/*.swift",
|
||||
|
||||
+2
-1
@@ -10,6 +10,7 @@
|
||||
"node_modules/**",
|
||||
"CHANGELOG.md",
|
||||
"package-lock.json",
|
||||
"_reference/**"
|
||||
"_reference/**",
|
||||
"third_party/jadx-headless-mcp/**"
|
||||
]
|
||||
}
|
||||
+2
-1
@@ -139,6 +139,7 @@
|
||||
".cache",
|
||||
".codex",
|
||||
".devin",
|
||||
".factory"
|
||||
".factory",
|
||||
"third_party/jadx-headless-mcp"
|
||||
]
|
||||
}
|
||||
@@ -151,6 +151,8 @@ Update either installation with `rea update`.
|
||||
|
||||
Native binary analysis requires [Hopper](https://www.hopperapp.com/) or [Ghidra](#ghidra-analysis-provider). Hopper is separate software with its own license; its demo supports analysis with vendor-defined limits. REA can use Ghidra that you have already installed.
|
||||
|
||||
Static APK analysis uses a separately supplied headless JADX JAR and Java, with no emulator or APK execution. See [Android analysis](docs/android-analysis.md) for setup, CLI/MCP operations, coverage and public test fixtures.
|
||||
|
||||
Windows Ghidra support is experimental for native x86-64 PE applications on local NTFS. The package bundles native Job Object, private-DACL, and path-admission controls. See [Windows Ghidra P0](docs/windows-ghidra-p0.md) for prerequisites and verified scope.
|
||||
|
||||
If something is not working, run:
|
||||
@@ -314,6 +316,7 @@ See [native investigation](docs/native-investigation.md) for keyed archives, ins
|
||||
| Native macOS utilities | 7 | Mach-O metadata, code signatures, plists, architectures, and Swift demangling without launching Hopper |
|
||||
| Artifact graph | 5 | directory and package inventories, compiled Interface Builder files, Apple asset catalogs, and extraction |
|
||||
| Managed PE/CLI | 7 | .NET identity, metadata, CIL instructions, native dependencies, reconstruction imports, and build comparisons |
|
||||
| Android APK | 5 | package and manifest declarations, class search, member inventories, method decompilation, and incoming static references |
|
||||
| Browser observation | 9 | page structure, network metadata, scripts, source maps, WebMCP discovery, screenshots, and capture comparisons |
|
||||
| Electron analysis | 5 | renderer observation, static app mapping, and static/runtime reconciliation |
|
||||
| JavaScript runtime | 2 | Node/Electron Inspector target discovery, script locations, and execution-context events |
|
||||
@@ -326,6 +329,8 @@ The public interface describes what the agent is trying to learn. Providers deci
|
||||
|
||||
REA supports native application, JavaScript, Electron, .NET, and browser investigation on macOS and Linux. Individual tools have platform and runtime prerequisites; use `rea capabilities` to check what is available on your host.
|
||||
|
||||
Static Android APK inspection is verified on Linux with headless JADX; see [Android analysis](docs/android-analysis.md) for its separate prerequisites and coverage.
|
||||
|
||||
- **Native binaries:** Open Mach-O, ELF, PE, and Mac `.app` targets through Hopper or Ghidra. Inspect functions, strings, assembly, decompilation, calls, and references. Hopper also accepts `.hop` databases and supports annotations.
|
||||
- **Packages and resources:** Inspect directories, ZIP, APK, IPA, ASAR, plists, compiled Interface Builder files, and Apple asset catalogs. Artifact requests name the input and requested extraction or traversal directly; macOS DMG traversal also requires the host's native mounting support.
|
||||
- **JavaScript and Electron:** Map modules, imports, source maps, routes, IPC channels, storage, and native add-ons without running the app. Compare builds and trace a feature across the recovered graph. Dynamic and ambiguous relationships remain unresolved. See [JavaScript application workflows](docs/javascript-application-workflows.md).
|
||||
@@ -461,6 +466,8 @@ flowchart LR
|
||||
Session --> Native["Native macOS provider"]
|
||||
Session --> Artifact["Artifact graph provider"]
|
||||
REA --> Browser["Browser CDP provider"]
|
||||
REA --> Android["Android static provider<br/>headless JADX adapter"]
|
||||
Android --> Runtime
|
||||
REA --> Process["Process capture provider"]
|
||||
Runtime --> Target["Target software"]
|
||||
Process --> Target
|
||||
|
||||
@@ -209,6 +209,7 @@ rea setup
|
||||
| أدوات macOS الأصلية | 7 | بيانات Mach-O والتوقيعات وplist والمعماريات واستعادة أسماء Swift |
|
||||
| الملفات والحزم | 5 | فحص الدلائل والحزم وInterface Builder وموارد Apple والاستخراج |
|
||||
| .NET PE/CLI | 7 | هوية التجميع والبيانات الوصفية وCIL والاستدعاءات الأصلية والمقارنة واستيراد إعادة البناء |
|
||||
| Android APK | 5 | بيانات الحزمة وmanifest والبحث عن الأصناف والأعضاء وفك ترجمة الأساليب والمراجع الثابتة |
|
||||
| مراقبة المتصفح | 9 | الصفحات والبرامج النصية وخرائط المصدر وWebMCP والصور ومقارنة الالتقاط |
|
||||
| تحليل Electron | 5 | مراقبة الصفحات وبنية التطبيق وربط النتائج الثابتة بنتائج التشغيل |
|
||||
| وقت تشغيل JavaScript | 2 | الاتصال بهدف Node/Electron Inspector موجود لمراقبة البرامج وسياقات التنفيذ |
|
||||
|
||||
+13
-12
@@ -191,18 +191,19 @@ REA は手順 1〜5 のバイナリ解析を処理し、手順 6 はエージェ
|
||||
|
||||
## 調査ツールカタログ
|
||||
|
||||
| ツール分類 | 数 | 用途 |
|
||||
| -------------------- | --: | --------------------------------------------------------------------------- |
|
||||
| バイナリ検査 | 41 | 関数、疑似コード、アセンブリ、文字列、シンボル、参照、注釈 |
|
||||
| 組み合わせた解析 | 14 | 概要、関数解析、一括逆コンパイル、呼び出しグラフ、Swift と ObjC |
|
||||
| macOS ネイティブ | 7 | Mach-O メタデータ、署名、plist、アーキテクチャ、Swift 名の復元 |
|
||||
| ファイルとパッケージ | 5 | ディレクトリとパッケージ、Interface Builder、Apple リソース、抽出 |
|
||||
| .NET PE/CLI | 7 | アセンブリ識別、メタデータ、CIL、ネイティブ呼び出し、比較、再構築の取り込み |
|
||||
| ブラウザー観察 | 9 | ページ、スクリプト、ソースマップ、WebMCP、スクリーンショット、比較 |
|
||||
| Electron 解析 | 5 | ページ観察、アプリ構造、静的・実行時結果の対応付け |
|
||||
| JavaScript 実行時 | 2 | 既存の Node/Electron Inspector に接続し、スクリプトと実行コンテキストを観察 |
|
||||
| アプリのワークフロー | 7 | 機能追跡、バージョン比較、戻り値構造の比較、再実装の検証 |
|
||||
| バイナリセッション | 21 | 対象切り替え、根拠の保存、プロセス・関数比較、未解決事項の記録 |
|
||||
| ツール分類 | 数 | 用途 |
|
||||
| -------------------- | --: | ------------------------------------------------------------------------------- |
|
||||
| バイナリ検査 | 41 | 関数、疑似コード、アセンブリ、文字列、シンボル、参照、注釈 |
|
||||
| 組み合わせた解析 | 14 | 概要、関数解析、一括逆コンパイル、呼び出しグラフ、Swift と ObjC |
|
||||
| macOS ネイティブ | 7 | Mach-O メタデータ、署名、plist、アーキテクチャ、Swift 名の復元 |
|
||||
| ファイルとパッケージ | 5 | ディレクトリとパッケージ、Interface Builder、Apple リソース、抽出 |
|
||||
| .NET PE/CLI | 7 | アセンブリ識別、メタデータ、CIL、ネイティブ呼び出し、比較、再構築の取り込み |
|
||||
| Android APK | 5 | パッケージと manifest、クラス検索、メンバー一覧、メソッド逆コンパイル、静的参照 |
|
||||
| ブラウザー観察 | 9 | ページ、スクリプト、ソースマップ、WebMCP、スクリーンショット、比較 |
|
||||
| Electron 解析 | 5 | ページ観察、アプリ構造、静的・実行時結果の対応付け |
|
||||
| JavaScript 実行時 | 2 | 既存の Node/Electron Inspector に接続し、スクリプトと実行コンテキストを観察 |
|
||||
| アプリのワークフロー | 7 | 機能追跡、バージョン比較、戻り値構造の比較、再実装の検証 |
|
||||
| バイナリセッション | 21 | 対象切り替え、根拠の保存、プロセス・関数比較、未解決事項の記録 |
|
||||
|
||||
## ロードマップ
|
||||
|
||||
|
||||
+13
-12
@@ -192,18 +192,19 @@ REA는 1–5단계의 바이너리 분석을 처리합니다. 6단계는 에이
|
||||
|
||||
## 조사 도구 카탈로그
|
||||
|
||||
| 도구 분류 | 수 | 용도 |
|
||||
| ----------------- | --: | -------------------------------------------------------------------- |
|
||||
| 바이너리 검사 | 41 | 함수, 의사 코드, 어셈블리, 문자열, 심볼, 참조, 주석 |
|
||||
| 결합 분석 | 14 | 개요, 함수 분석, 일괄 디컴파일, 호출 그래프, Swift와 ObjC 검사 |
|
||||
| macOS 네이티브 | 7 | Mach-O 메타데이터, 서명, plist, 아키텍처, Swift 이름 복원 |
|
||||
| 파일과 패키지 | 5 | 디렉터리와 패키지 검사, Interface Builder, Apple 리소스, 추출 |
|
||||
| .NET PE/CLI | 7 | 어셈블리 식별, 메타데이터, CIL, 네이티브 호출, 비교, 재구성 가져오기 |
|
||||
| 브라우저 관찰 | 9 | 페이지, 스크립트, 소스 맵, WebMCP, 스크린샷, 캡처 비교 |
|
||||
| Electron 분석 | 5 | 페이지 관찰, 앱 구조, 정적·런타임 결과 연결 |
|
||||
| JavaScript 런타임 | 2 | 기존 Node/Electron Inspector에 연결해 스크립트와 실행 컨텍스트 관찰 |
|
||||
| 앱 워크플로 | 7 | 기능 추적, 버전 비교, 반환 구조 비교, 재구현 검증 |
|
||||
| 바이너리 세션 | 21 | 대상 전환, 근거 저장, 프로세스·함수 비교, 미해결 항목 기록 |
|
||||
| 도구 분류 | 수 | 용도 |
|
||||
| ----------------- | --: | --------------------------------------------------------------------- |
|
||||
| 바이너리 검사 | 41 | 함수, 의사 코드, 어셈블리, 문자열, 심볼, 참조, 주석 |
|
||||
| 결합 분석 | 14 | 개요, 함수 분석, 일괄 디컴파일, 호출 그래프, Swift와 ObjC 검사 |
|
||||
| macOS 네이티브 | 7 | Mach-O 메타데이터, 서명, plist, 아키텍처, Swift 이름 복원 |
|
||||
| 파일과 패키지 | 5 | 디렉터리와 패키지 검사, Interface Builder, Apple 리소스, 추출 |
|
||||
| .NET PE/CLI | 7 | 어셈블리 식별, 메타데이터, CIL, 네이티브 호출, 비교, 재구성 가져오기 |
|
||||
| Android APK | 5 | 패키지와 manifest, 클래스 검색, 멤버 목록, 메서드 디컴파일, 정적 참조 |
|
||||
| 브라우저 관찰 | 9 | 페이지, 스크립트, 소스 맵, WebMCP, 스크린샷, 캡처 비교 |
|
||||
| Electron 분석 | 5 | 페이지 관찰, 앱 구조, 정적·런타임 결과 연결 |
|
||||
| JavaScript 런타임 | 2 | 기존 Node/Electron Inspector에 연결해 스크립트와 실행 컨텍스트 관찰 |
|
||||
| 앱 워크플로 | 7 | 기능 추적, 버전 비교, 반환 구조 비교, 재구현 검증 |
|
||||
| 바이너리 세션 | 21 | 대상 전환, 근거 저장, 프로세스·함수 비교, 미해결 항목 기록 |
|
||||
|
||||
## 로드맵
|
||||
|
||||
|
||||
+13
-12
@@ -191,18 +191,19 @@ REA 负责第 1–5 步中的二进制分析。第 6 步由智能体使用其常
|
||||
|
||||
## 调查工具目录
|
||||
|
||||
| 工具类别 | 数量 | 用途 |
|
||||
| ----------------- | ---: | --------------------------------------------------------- |
|
||||
| 二进制检查 | 41 | 函数、伪代码、汇编、字符串、符号、段、引用与注释 |
|
||||
| 组合分析 | 14 | 概览、函数分析、批量反编译、调用图、Swift 与 ObjC 检查 |
|
||||
| macOS 原生工具 | 7 | Mach-O 元数据、代码签名、plist、架构与 Swift 符号还原 |
|
||||
| 制品检查 | 5 | 目录与软件包检查、Interface Builder、Apple 资源目录与提取 |
|
||||
| .NET PE/CLI | 7 | 程序集身份、元数据、CIL、原生调用声明、成员比较与重建导入 |
|
||||
| 浏览器观察 | 9 | 页面、脚本、来源映射、WebMCP 工具、截图与捕获比较 |
|
||||
| Electron 分析 | 5 | 页面观察、应用结构与静态/运行时结果关联 |
|
||||
| JavaScript 运行时 | 2 | 连接现有 Node/Electron Inspector,观察脚本与执行上下文 |
|
||||
| 应用工作流 | 7 | 功能追踪、版本比较、返回结构比较与重建验证 |
|
||||
| 二进制会话 | 21 | 目标切换、证据包、进程与函数比较、待解决问题记录 |
|
||||
| 工具类别 | 数量 | 用途 |
|
||||
| ----------------- | ---: | -------------------------------------------------------------- |
|
||||
| 二进制检查 | 41 | 函数、伪代码、汇编、字符串、符号、段、引用与注释 |
|
||||
| 组合分析 | 14 | 概览、函数分析、批量反编译、调用图、Swift 与 ObjC 检查 |
|
||||
| macOS 原生工具 | 7 | Mach-O 元数据、代码签名、plist、架构与 Swift 符号还原 |
|
||||
| 制品检查 | 5 | 目录与软件包检查、Interface Builder、Apple 资源目录与提取 |
|
||||
| .NET PE/CLI | 7 | 程序集身份、元数据、CIL、原生调用声明、成员比较与重建导入 |
|
||||
| Android APK | 5 | 包与 manifest 声明、class 搜索、成员清单、方法反编译与静态引用 |
|
||||
| 浏览器观察 | 9 | 页面、脚本、来源映射、WebMCP 工具、截图与捕获比较 |
|
||||
| Electron 分析 | 5 | 页面观察、应用结构与静态/运行时结果关联 |
|
||||
| JavaScript 运行时 | 2 | 连接现有 Node/Electron Inspector,观察脚本与执行上下文 |
|
||||
| 应用工作流 | 7 | 功能追踪、版本比较、返回结构比较与重建验证 |
|
||||
| 二进制会话 | 21 | 目标切换、证据包、进程与函数比较、待解决问题记录 |
|
||||
|
||||
## 路线图
|
||||
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
# Static Android APK analysis
|
||||
|
||||
REA wraps an existing headless JADX engine through `src/android/`. Its source is
|
||||
kept as an unmodified, commit-pinned Git submodule; see
|
||||
[upstream provenance](../third_party/README.md). CLI and MCP use the same
|
||||
application workflow and return artifact-bound Evidence inline.
|
||||
|
||||
## Supply tools
|
||||
|
||||
Use **Java 17 or newer** and **jadx-headless-mcp 0.7.1**. REA does not install Java,
|
||||
an Android SDK, JADX, a device service or an emulator. Set the path in the shell
|
||||
or MCP server environment:
|
||||
|
||||
```sh
|
||||
export REA_JADX_MCP_JAR=/absolute/path/jadx-headless-mcp-0.7.1-all.jar
|
||||
# Optional: select an existing JDK rather than java on PATH.
|
||||
export JAVA_HOME=/absolute/path/existing-jdk
|
||||
```
|
||||
|
||||
Linux is verified with the public Appium ApiDemos fixture. The POSIX adapter can
|
||||
run on macOS, but that host has not undergone real Android verification.
|
||||
Windows is unsupported for this provider's owned stdio process boundary.
|
||||
|
||||
## Answer an analyst question
|
||||
|
||||
```sh
|
||||
rea inspect-android-package /path/Example.apk
|
||||
rea search-android-classes /path/Example.apk MainActivity
|
||||
rea inspect-android-class /path/Example.apk example.MainActivity
|
||||
rea inspect-android-method /path/Example.apk example.MainActivity onCreate
|
||||
rea inspect-android-method /path/Example.apk example.MainActivity select --overload-index 1
|
||||
rea trace-android-references /path/Example.apk example.MainActivity
|
||||
rea trace-android-references /path/Example.apk example.MainActivity --method-name onCreate
|
||||
```
|
||||
|
||||
Equivalent MCP methods are `inspect_android_package`, `search_android_classes`,
|
||||
`inspect_android_class`, `inspect_android_method` and `trace_android_references`.
|
||||
Every request includes `path`. Class/method operations use `class_name`,
|
||||
`method_name` and optional `overload_index` as applicable. For example:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "inspect_android_method",
|
||||
"arguments": {
|
||||
"path": "/path/Example.apk",
|
||||
"class_name": "example.MainActivity",
|
||||
"method_name": "select",
|
||||
"overload_index": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Use `inspect_android_class` to choose an overload. REA rejects an ambiguous
|
||||
method request rather than silently selecting the first match. The engine's
|
||||
method reference operation cannot select overloads, so REA reports that boundary
|
||||
as unsupported when multiple same-name methods exist.
|
||||
|
||||
The engine's smali fallback also joins same-name overloads; REA rejects that
|
||||
fallback for an overloaded method instead of attributing all bodies to one
|
||||
selected overload. Native/abstract methods with no decompiled body report
|
||||
`body_status: "not_available"` without inferring which implementation flag caused it.
|
||||
|
||||
## Interpret the result
|
||||
|
||||
- Package inspection retains decoded manifest XML, summary declarations,
|
||||
permissions and class/resource counts. Summary fields are extracted by the
|
||||
upstream engine and should be checked against the retained XML when needed.
|
||||
Signature verification is explicitly `not_performed`.
|
||||
- Class search consumes all pages and checks their counts. An empty `query`
|
||||
inventories all class names; class names are decompiler representations.
|
||||
- Class inventories return display signatures, fields and inner class names.
|
||||
Exact DEX descriptors are unknown. Overload indices are tied to this artifact
|
||||
and engine, not stable identities across different builds.
|
||||
- Method inspection reports Java or smali, fallback markers and complete/partial
|
||||
returned text. Decompiled source is a derived representation. A complete text
|
||||
result does not establish complete semantics or a byte-identical reconstruction.
|
||||
- Reference results are incoming static relationships between provider nodes.
|
||||
Source lines and DEX instruction offsets remain unknown. They are not observed
|
||||
runtime calls.
|
||||
- Original requests, APK SHA-256, actual engine JAR SHA-256 and raw producer
|
||||
responses remain in Evidence. Source revision is populated only for bytes
|
||||
matching the audited release JAR.
|
||||
|
||||
These operations do not execute the APK or provide split APK/AAB handling,
|
||||
signature validation, full resource-table semantics, native-library analysis or
|
||||
Android runtime capture. Existing artifact inventory/extraction tools can supply
|
||||
archive evidence; `project_android_application_graph` remains a separate,
|
||||
execution-free inventory projection.
|
||||
|
||||
## Resource and lifecycle limits
|
||||
|
||||
Each request creates private, immutable APK and JAR copies. Their workspace and
|
||||
owned process group are cleaned on success, failure, timeout and cancellation.
|
||||
REA never writes to the original APK or launches target code.
|
||||
|
||||
If process cleanup cannot be confirmed, REA retains the workspace and reports its
|
||||
location. That provider instance blocks subsequent engine launches until the
|
||||
caller resolves the reported resources and starts a fresh instance.
|
||||
|
||||
The provider serializes requests within each REA instance, uses one worker and
|
||||
sets JVM maximum heap to **512 MiB**. Heap is not a total RSS cap: the JVM also
|
||||
uses native memory. Independent REA instances have independent queues. Whole
|
||||
operations have a **120-second** execution deadline after reaching the queue's
|
||||
front; upstream decompilation is limited to **90 seconds**.
|
||||
|
||||
Decoded manifest and method text have a **1 MiB** upstream byte budget and report
|
||||
truncation. A protocol frame above **8 MiB**, or cumulative stdout/stderr above
|
||||
**32 MiB**, fails with a diagnostic instead of returning an incomplete inventory
|
||||
as complete. These are memory-safety boundaries, not undocumented result caps.
|
||||
|
||||
## Real verification
|
||||
|
||||
Download the explicit public fixtures once; this installs no tools:
|
||||
|
||||
```sh
|
||||
npm run fixtures:android
|
||||
export REA_JADX_MCP_JAR="$PWD/_reference/apk-integration/jadx-headless-mcp-0.7.1-all.jar"
|
||||
export REA_ANDROID_TEST_APK="$PWD/_reference/apk-integration/ApiDemos-debug.apk"
|
||||
npm run verify:android
|
||||
```
|
||||
|
||||
The download script verifies fixed SHA-256 values, reuses matching files and
|
||||
refuses to overwrite a different existing file. APK/JAR files live under ignored
|
||||
`_reference/` and are excluded from package contents. The lane exercises actual
|
||||
REA CLI and MCP results with the real engine, separately from synthetic protocol
|
||||
regressions. It needs no Gradle build, emulator, Android SDK or Ghidra.
|
||||
@@ -25,6 +25,7 @@ graph TD
|
||||
AUX["src/artifacts/ + src/native/<br/>disjoint artifact and native capabilities"]
|
||||
BROWSER["src/browser/<br/>Loopback CDP transport<br/>exact-origin passive observation"]
|
||||
DOTNET["src/dotnet/<br/>Managed static metadata provider<br/>PE/CLI metadata inventory, members, IL"]
|
||||
ANDROID["src/android/<br/>Static APK adapter<br/>owned SDK stdio, pinned headless JADX, normalization"]
|
||||
REFERENCE["src/reference/<br/>Filesystem reference-source reader<br/>bounded traversal, validation, errors"]
|
||||
end
|
||||
|
||||
@@ -61,6 +62,10 @@ graph TD
|
||||
MAIN --> SERVER
|
||||
MAIN --> APP
|
||||
CLI --> APP
|
||||
APP --> ANDROID
|
||||
ANDROID --> PROCESS
|
||||
ANDROID --> CONTRACTS
|
||||
ANDROID --> DOMAIN
|
||||
CLI --> ERRORS
|
||||
APP --> SESSION
|
||||
SESSION --> REGISTRY
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"skill_version": "24"
|
||||
},
|
||||
"tools": {
|
||||
"total": 118,
|
||||
"total": 123,
|
||||
"families": [
|
||||
{
|
||||
"id": "direct",
|
||||
@@ -121,6 +121,18 @@
|
||||
"verify_managed_native_boundaries"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "android",
|
||||
"surface": "android-provider",
|
||||
"count": 5,
|
||||
"tools": [
|
||||
"inspect_android_class",
|
||||
"inspect_android_method",
|
||||
"inspect_android_package",
|
||||
"search_android_classes",
|
||||
"trace_android_references"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "browser",
|
||||
"surface": "browser-provider",
|
||||
@@ -280,6 +292,18 @@
|
||||
"xrefs"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "jadx",
|
||||
"name": "JADX headless",
|
||||
"version": "0.7.1",
|
||||
"capabilities": [
|
||||
"inspect_android_class",
|
||||
"inspect_android_method",
|
||||
"inspect_android_package",
|
||||
"search_android_classes",
|
||||
"trace_android_references"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "native-macos",
|
||||
"name": "macOS native inspection utilities",
|
||||
@@ -471,7 +495,7 @@
|
||||
}
|
||||
],
|
||||
"cli": {
|
||||
"primary_count": 73,
|
||||
"primary_count": 78,
|
||||
"commands": [
|
||||
"address-to-file-offset",
|
||||
"analyze",
|
||||
@@ -506,6 +530,9 @@
|
||||
"import-managed-reconstruction",
|
||||
"import-reference-source",
|
||||
"inspect",
|
||||
"inspect-android-class",
|
||||
"inspect-android-method",
|
||||
"inspect-android-package",
|
||||
"inspect-artifact",
|
||||
"inspect-asset-catalog",
|
||||
"inspect-electron-page",
|
||||
@@ -536,8 +563,10 @@
|
||||
"reconcile-javascript-runtime",
|
||||
"resolve-native-call-targets",
|
||||
"search",
|
||||
"search-android-classes",
|
||||
"setup",
|
||||
"trace",
|
||||
"trace-android-references",
|
||||
"trace-application-feature",
|
||||
"trace-javascript-semantics",
|
||||
"trace-native-ui-action",
|
||||
@@ -556,15 +585,15 @@
|
||||
},
|
||||
"runtime_catalog": {
|
||||
"counts": {
|
||||
"cli_commands": 73,
|
||||
"mcp_tools": 118,
|
||||
"cli_commands": 78,
|
||||
"mcp_tools": 123,
|
||||
"mcp_prompts": 6
|
||||
},
|
||||
"digests": {
|
||||
"tools_sha256": "270e49d736a98880133a74f356e4c568db7d7adc55fd576358c9da71017485ad",
|
||||
"tools_sha256": "6e60913fad4c67f3a12aa8bf5113971ce6a57e9492ce402c8cd9e539d6fde01f",
|
||||
"prompts_sha256": "ef2de8814390b340623c32001610aa2709d90bed51537b6e562ab3abf144ab4d",
|
||||
"combined_sha256": "88cedc97288ecd044f4257a670a6707ca474906de73cab3f5bf436bff24f0cc7",
|
||||
"providers_sha256": "e094c361b60ec930a6062a1a90ee05536ce411541dba5b51ace7e2da2ba25af0"
|
||||
"combined_sha256": "d5fbda3a283916ab3489a62fe88aeb89da8b0a7b19c248e3742c970b1b51600e",
|
||||
"providers_sha256": "e60c6e949f499fe8a015a20ff36752516ce57edcb1ce355f0308361c8d322dd6"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -148,6 +148,23 @@ the CLI and stdio MCP, including special filenames, unresolved discovery
|
||||
locations, and independently resolved loaded scripts. Double-quote filenames
|
||||
are tested on POSIX only because Windows does not support them.
|
||||
|
||||
## Android APK analysis
|
||||
|
||||
`npm run verify:android` requires an existing Java 17+ and an explicit
|
||||
`REA_JADX_MCP_JAR` for jadx-headless-mcp 0.7.1. Set `REA_ANDROID_TEST_APK` to the
|
||||
fixed public ApiDemos v6.0.18 fixture. Obtain both with the explicit
|
||||
`npm run fixtures:android` command; files are SHA-256 verified and
|
||||
kept under ignored `_reference/`. No Gradle build, Android SDK, emulator or
|
||||
application execution is required. The lane compares real CLI/MCP package,
|
||||
class search, class inventory, method decompilation and incoming references.
|
||||
See [Android analysis](android-analysis.md) for boundaries and resource budgets.
|
||||
|
||||
Synthetic producer regressions run independently:
|
||||
|
||||
```sh
|
||||
npm run test:focused -- tests/boundary/android/jadxIntegration.test.ts tests/boundary/mcp/androidAnalysisMcp.test.ts
|
||||
```
|
||||
|
||||
## DOS Ghidra analysis
|
||||
|
||||
`npm run verify:ghidra:dos` requires the supported Ghidra and JDK installation
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"manifest_id": "ecm_4d2054e0122c65b7527e18887808279916b93eb25a0fa4b22a368d390b419322",
|
||||
"manifest_id": "ecm_42bc9f06a00e9c510cb57605d7f1cde613e2762ea0c59bf3915e032147a7c493",
|
||||
"verifier": {
|
||||
"id": "rea-managed-conformance",
|
||||
"version": "1"
|
||||
@@ -13,7 +13,7 @@
|
||||
"skill_digests": [
|
||||
{
|
||||
"skill_id": "reverse-engineer-anything",
|
||||
"sha256": "f921b1664b83f82849524d8b562b29746d82e64071c4d4689b098bfc7bd949de"
|
||||
"sha256": "ace0a167b9161d9d96c9f5cd65ff0bb39e483c3426e6cc86aac5c7c9893c31cb"
|
||||
}
|
||||
],
|
||||
"claims": [
|
||||
|
||||
@@ -136,6 +136,8 @@
|
||||
"verify:javascript:digests": "npm run build:cached && node scripts/verify-large-javascript-digests.mjs",
|
||||
"verify:application-workflows": "npm run build:cached && node scripts/verify-local-application-workflows.mjs",
|
||||
"verify:managed": "npm run build:cached && node scripts/verify-managed-conformance.mjs",
|
||||
"verify:android": "npm run build:cached && node scripts/verify-real-android.mjs",
|
||||
"fixtures:android": "node scripts/fetch-android-fixtures.mjs",
|
||||
"verify:agent": "npm run build:cached && node scripts/verify-agent-experience.mjs",
|
||||
"verify:hopper:linux": "npm run verify:fixtures && npm run build:cached && node scripts/verify-real-hopper-linux.mjs",
|
||||
"verify:setup:linux": "npm run build:cached && node scripts/verify-linux-setup-e2e.mjs",
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { createReadStream } from "node:fs";
|
||||
import { link, mkdir, open, rm } from "node:fs/promises";
|
||||
import { resolve, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { JADX_RELEASE } from "../src/android/JadxRelease.ts";
|
||||
import fixture from "./fixtures/android-apidemos.json" with { type: "json" };
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
if (args.length > 1)
|
||||
throw new Error("Usage: node scripts/fetch-android-fixtures.mjs [DIRECTORY]");
|
||||
const directory = resolve(
|
||||
args[0] ??
|
||||
fileURLToPath(new URL("../_reference/apk-integration/", import.meta.url)),
|
||||
);
|
||||
await mkdir(directory, { recursive: true });
|
||||
const hashFile = async (path) => {
|
||||
const hash = createHash("sha256");
|
||||
for await (const chunk of createReadStream(path)) hash.update(chunk);
|
||||
return hash.digest("hex");
|
||||
};
|
||||
for (const item of [
|
||||
{
|
||||
url: JADX_RELEASE.url,
|
||||
sha256: JADX_RELEASE.sha256,
|
||||
filename: "jadx-headless-mcp-0.7.1-all.jar",
|
||||
},
|
||||
fixture,
|
||||
]) {
|
||||
const path = join(directory, item.filename);
|
||||
try {
|
||||
const digest = await hashFile(path);
|
||||
if (digest !== item.sha256)
|
||||
throw new Error(
|
||||
`Existing ${path} has a different SHA-256; it was not overwritten`,
|
||||
);
|
||||
console.log(`Verified existing ${path}: ${digest}`);
|
||||
continue;
|
||||
} catch (cause) {
|
||||
if (!(cause instanceof Error && "code" in cause && cause.code === "ENOENT"))
|
||||
throw cause;
|
||||
}
|
||||
const temporary = `${path}.${randomUUID()}.partial`;
|
||||
try {
|
||||
const response = await fetch(item.url, {
|
||||
signal: AbortSignal.timeout(120_000),
|
||||
});
|
||||
if (!response.ok || response.body === null)
|
||||
throw new Error(`Download failed: ${item.url}: HTTP ${response.status}`);
|
||||
const handle = await open(temporary, "wx", 0o600);
|
||||
const hash = createHash("sha256");
|
||||
try {
|
||||
for await (const chunk of response.body) {
|
||||
hash.update(chunk);
|
||||
await handle.writeFile(chunk);
|
||||
}
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
const digest = hash.digest("hex");
|
||||
if (digest !== item.sha256)
|
||||
throw new Error(
|
||||
`SHA-256 mismatch for ${item.filename}: expected ${item.sha256}, received ${digest}`,
|
||||
);
|
||||
await link(temporary, path);
|
||||
console.log(`Downloaded and verified ${path}: ${digest}`);
|
||||
} finally {
|
||||
await rm(temporary, { force: true });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "Appium ApiDemos",
|
||||
"version": "6.0.18",
|
||||
"source": "https://github.com/appium/android-apidemos",
|
||||
"source_revision": "3e716ee04ef3390284bfab761e28da8c15c08143",
|
||||
"license": "Apache-2.0",
|
||||
"license_url": "https://github.com/appium/android-apidemos/blob/3e716ee04ef3390284bfab761e28da8c15c08143/LICENSE",
|
||||
"url": "https://github.com/appium/android-apidemos/releases/download/v6.0.18/ApiDemos-debug.apk",
|
||||
"filename": "ApiDemos-debug.apk",
|
||||
"sha256": "a9eecf37b26cd084855c530db81c2bb1b91f4c1b095a04f47aa7c20e2791f686",
|
||||
"package": "io.appium.android.apis",
|
||||
"class_name": "io.appium.android.apis.ApiDemos",
|
||||
"method_name": "onCreate"
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawn } from "node:child_process";
|
||||
import {
|
||||
cleanupOwnedProcessGroup,
|
||||
readProcessRunId,
|
||||
} from "../../dist/process/ProcessOwnership.js";
|
||||
|
||||
/** Exercise a real CLI signal while the owned Java engine is still running. */
|
||||
export const verifyAndroidCliCancellation = async ({
|
||||
entrypoint,
|
||||
environment,
|
||||
path,
|
||||
repository,
|
||||
execute,
|
||||
}) => {
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
[entrypoint, "inspect-android-package", path, "--format", "json"],
|
||||
{ cwd: repository, env: environment, stdio: ["ignore", "pipe", "pipe"] },
|
||||
);
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout.on("data", (chunk) => {
|
||||
stdout += chunk.toString();
|
||||
});
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
const exited = new Promise((resolve, reject) => {
|
||||
child.once("error", reject);
|
||||
child.once("exit", (code, signal) => resolve({ code, signal }));
|
||||
});
|
||||
let group;
|
||||
let ownership;
|
||||
try {
|
||||
const deadline = Date.now() + 30_000;
|
||||
while (Date.now() < deadline) {
|
||||
const table = (
|
||||
await execute("ps", ["-eo", "pid=,ppid=,pgid=,args="], {
|
||||
timeout: 5_000,
|
||||
maxBuffer: 2 * 1024 * 1024,
|
||||
})
|
||||
).stdout;
|
||||
for (const row of table.split("\n")) {
|
||||
const entry = /^\s*(\d+)\s+(\d+)\s+(\d+)\s+(.*)$/u.exec(row);
|
||||
if (
|
||||
entry !== null &&
|
||||
Number(entry[2]) === child.pid &&
|
||||
entry[4].includes("engine.jar") &&
|
||||
entry[4].includes("-Xmx512m")
|
||||
) {
|
||||
group = Number(entry[3]);
|
||||
const runId = await readProcessRunId(Number(entry[1]));
|
||||
assert.ok(
|
||||
runId !== undefined,
|
||||
"Owned Java process did not carry an identity token",
|
||||
);
|
||||
ownership = {
|
||||
runId,
|
||||
leaderPid: Number(entry[1]),
|
||||
processGroupId: group,
|
||||
};
|
||||
}
|
||||
}
|
||||
if (group !== undefined) break;
|
||||
if (child.exitCode !== null || child.signalCode !== null)
|
||||
throw new Error(
|
||||
`CLI exited before acquiring JADX: ${stdout}\n${stderr}`,
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
assert.ok(
|
||||
group !== undefined,
|
||||
"CLI did not acquire the expected owned Java group",
|
||||
);
|
||||
child.kill("SIGTERM");
|
||||
let timer;
|
||||
const result = await Promise.race([
|
||||
exited,
|
||||
new Promise((_, reject) => {
|
||||
timer = setTimeout(
|
||||
() => reject(new Error("CLI cancellation did not finish cleanup")),
|
||||
10_000,
|
||||
);
|
||||
}),
|
||||
]).finally(() => clearTimeout(timer));
|
||||
assert.equal(result.code, 143, `${stdout}\n${stderr}`);
|
||||
assert.equal(result.signal, null);
|
||||
assert.equal(JSON.parse(stdout).code, "cancelled");
|
||||
assert.throws(() => process.kill(-group, 0), { code: "ESRCH" });
|
||||
console.log(
|
||||
"PASS real CLI SIGTERM cancellation and owned Java group cleanup",
|
||||
);
|
||||
} finally {
|
||||
await cleanupCancellation(child, exited, ownership);
|
||||
}
|
||||
};
|
||||
|
||||
const cleanupCancellation = async (child, exited, ownership) => {
|
||||
if (child.exitCode === null && child.signalCode === null) {
|
||||
child.kill("SIGTERM");
|
||||
let timer;
|
||||
await Promise.race([
|
||||
exited,
|
||||
new Promise((resolve) => {
|
||||
timer = setTimeout(resolve, 2_000);
|
||||
}),
|
||||
]).finally(() => clearTimeout(timer));
|
||||
if (child.exitCode === null && child.signalCode === null) {
|
||||
child.kill("SIGKILL");
|
||||
await exited;
|
||||
}
|
||||
}
|
||||
if (ownership !== undefined)
|
||||
assert.equal(
|
||||
(await cleanupOwnedProcessGroup(ownership)).cleaned,
|
||||
true,
|
||||
"Verifier could not confirm its owned Java group cleanup",
|
||||
);
|
||||
};
|
||||
@@ -31,6 +31,11 @@ export const toolFamilyCatalog = (sources) => {
|
||||
...sources.managedWorkflowContracts.MANAGED_WORKFLOW_TOOL_CONTRACTS,
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "android",
|
||||
surface: "android-provider",
|
||||
contracts: sources.androidContracts.ANDROID_TOOL_CONTRACTS,
|
||||
},
|
||||
{
|
||||
id: "browser",
|
||||
surface: "browser-provider",
|
||||
@@ -113,6 +118,10 @@ export const providerCatalog = (sources) => {
|
||||
contracts:
|
||||
sources.managedWorkflowContracts.MANAGED_WORKFLOW_TOOL_CONTRACTS,
|
||||
},
|
||||
{
|
||||
identity: sources.androidProvider.JADX_PROVIDER_IDENTITY,
|
||||
contracts: sources.androidContracts.ANDROID_TOOL_CONTRACTS,
|
||||
},
|
||||
{
|
||||
identity: sources.browserProvider.CDP_BROWSER_PROVIDER_IDENTITY,
|
||||
contracts: sources.browserContracts.BROWSER_TOOL_CONTRACTS,
|
||||
|
||||
@@ -43,6 +43,8 @@ const SOURCE_PATHS = {
|
||||
nativeContracts: "dist/contracts/nativeToolContracts.js",
|
||||
artifactContracts: "dist/contracts/artifactToolContracts.js",
|
||||
managedContracts: "dist/contracts/managedToolContracts.js",
|
||||
androidContracts: "dist/contracts/androidToolContracts.js",
|
||||
androidProvider: "dist/android/JadxRelease.js",
|
||||
managedWorkflowContracts: "dist/contracts/managedWorkflowToolContracts.js",
|
||||
browserContracts: "dist/contracts/browserToolContracts.js",
|
||||
browserScenarioContracts: "dist/contracts/browserScenarioToolContracts.js",
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFile } from "node:child_process";
|
||||
import { access, writeFile } from "node:fs/promises";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
import { Client } from "@modelcontextprotocol/client";
|
||||
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
|
||||
import { parseEvidence } from "../dist/domain/evidence.js";
|
||||
import { hashAndroidFile } from "../dist/android/AndroidTargetSnapshot.js";
|
||||
import { JADX_RELEASE } from "../dist/android/JadxRelease.js";
|
||||
import fixture from "./fixtures/android-apidemos.json" with { type: "json" };
|
||||
import { verifyAndroidCliCancellation } from "./lib/android-cli-cancellation.mjs";
|
||||
|
||||
const repository = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const apk = process.env.REA_ANDROID_TEST_APK;
|
||||
const jar = process.env.REA_JADX_MCP_JAR;
|
||||
if (apk === undefined || jar === undefined)
|
||||
throw new Error(
|
||||
"verify:android requires REA_ANDROID_TEST_APK and REA_JADX_MCP_JAR. Run node scripts/fetch-android-fixtures.mjs and select the downloaded files; see docs/android-analysis.md.",
|
||||
);
|
||||
await access(apk);
|
||||
await access(jar);
|
||||
assert.equal(
|
||||
await hashAndroidFile(apk),
|
||||
fixture.sha256,
|
||||
"Real Android lane requires the fixed ApiDemos release fixture",
|
||||
);
|
||||
assert.equal(
|
||||
await hashAndroidFile(jar),
|
||||
JADX_RELEASE.sha256,
|
||||
"Real Android lane requires the audited headless JADX release JAR",
|
||||
);
|
||||
const java =
|
||||
process.env.JAVA_HOME === undefined
|
||||
? "java"
|
||||
: resolve(process.env.JAVA_HOME, "bin/java");
|
||||
const execute = promisify(execFile);
|
||||
await execute(java, ["-version"], { timeout: 10_000 });
|
||||
const entrypoint =
|
||||
process.env.REA_ANDROID_ENTRYPOINT ?? resolve(repository, "scripts/rea.mjs");
|
||||
const environment = { ...process.env, REA_JADX_MCP_JAR: resolve(jar) };
|
||||
const path = resolve(apk);
|
||||
const tasks = [
|
||||
{
|
||||
name: "inspect_android_package",
|
||||
args: ["inspect-android-package", path],
|
||||
input: { path },
|
||||
},
|
||||
{
|
||||
name: "search_android_classes",
|
||||
args: ["search-android-classes", path, "ApiDemos"],
|
||||
input: { path, query: "ApiDemos" },
|
||||
},
|
||||
{
|
||||
name: "inspect_android_class",
|
||||
args: ["inspect-android-class", path, fixture.class_name],
|
||||
input: { path, class_name: fixture.class_name },
|
||||
},
|
||||
{
|
||||
name: "inspect_android_method",
|
||||
args: [
|
||||
"inspect-android-method",
|
||||
path,
|
||||
fixture.class_name,
|
||||
fixture.method_name,
|
||||
],
|
||||
input: {
|
||||
path,
|
||||
class_name: fixture.class_name,
|
||||
method_name: fixture.method_name,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "trace_android_references",
|
||||
args: [
|
||||
"trace-android-references",
|
||||
path,
|
||||
fixture.class_name,
|
||||
"--method-name",
|
||||
"activityIntent",
|
||||
],
|
||||
input: {
|
||||
path,
|
||||
class_name: fixture.class_name,
|
||||
method_name: "activityIntent",
|
||||
},
|
||||
},
|
||||
];
|
||||
const cliResults = new Map();
|
||||
for (const task of tasks) {
|
||||
const response = await execute(
|
||||
process.execPath,
|
||||
[entrypoint, ...task.args, "--format", "json"],
|
||||
{
|
||||
cwd: repository,
|
||||
env: environment,
|
||||
timeout: 150_000,
|
||||
maxBuffer: 8 * 1024 * 1024,
|
||||
},
|
||||
);
|
||||
const evidence = parseEvidence(JSON.parse(response.stdout));
|
||||
assert.equal(evidence.subject?.digest.sha256, fixture.sha256);
|
||||
assert.equal(evidence.operation, task.name);
|
||||
cliResults.set(task.name, evidence);
|
||||
console.log(`PASS CLI ${task.name}`);
|
||||
}
|
||||
const transport = new StdioClientTransport({
|
||||
command: process.execPath,
|
||||
args: [entrypoint, "mcp"],
|
||||
cwd: repository,
|
||||
env: environment,
|
||||
stderr: "pipe",
|
||||
});
|
||||
let stderr = "";
|
||||
transport.stderr?.on("data", (chunk) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
const client = new Client({ name: "rea-real-android-verifier", version: "1" });
|
||||
try {
|
||||
await client.connect(transport, { timeout: 30_000 });
|
||||
for (const task of tasks) {
|
||||
const response = await client.callTool(
|
||||
{ name: task.name, arguments: task.input },
|
||||
{ timeout: 150_000 },
|
||||
);
|
||||
assert.notEqual(response.isError, true, JSON.stringify(response));
|
||||
const structured = response.structuredContent;
|
||||
assert.ok(structured !== undefined && structured.evidence !== undefined);
|
||||
const evidence = parseEvidence(structured.evidence);
|
||||
assert.deepEqual(
|
||||
evidence.normalized_result,
|
||||
cliResults.get(task.name).normalized_result,
|
||||
);
|
||||
// Raw observations retain different workspace paths and load durations.
|
||||
// Validate each evidence ID independently; compare portable results/intent.
|
||||
assert.equal(structured.evidence_id, evidence.evidence_id);
|
||||
assert.deepEqual(evidence.parameters, cliResults.get(task.name).parameters);
|
||||
assert.deepEqual(evidence.provider, cliResults.get(task.name).provider);
|
||||
assert.deepEqual(
|
||||
evidence.subject.digest,
|
||||
cliResults.get(task.name).subject.digest,
|
||||
);
|
||||
assert.deepEqual(
|
||||
evidence.limitations,
|
||||
cliResults.get(task.name).limitations,
|
||||
);
|
||||
assert.equal(evidence.confidence, cliResults.get(task.name).confidence);
|
||||
assert.deepEqual(structured.result, evidence.normalized_result);
|
||||
console.log(`PASS MCP/CLI parity ${task.name}`);
|
||||
}
|
||||
} catch (cause) {
|
||||
process.stderr.write(stderr);
|
||||
throw cause;
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
const packageResult = cliResults.get(
|
||||
"inspect_android_package",
|
||||
).normalized_result;
|
||||
assert.equal(packageResult.package_name, fixture.package);
|
||||
assert.equal(packageResult.version_name, fixture.version);
|
||||
assert.equal(packageResult.min_sdk, "26");
|
||||
assert.equal(packageResult.target_sdk, "33");
|
||||
assert.equal(packageResult.manifest.status, "complete");
|
||||
assert.ok(packageResult.manifest.text.includes(fixture.package));
|
||||
assert.ok(packageResult.permissions.includes("android.permission.INTERNET"));
|
||||
assert.equal(packageResult.signature_verification, "not_performed");
|
||||
assert.ok(
|
||||
cliResults
|
||||
.get("search_android_classes")
|
||||
.normalized_result.matches.includes(fixture.class_name),
|
||||
);
|
||||
const classResult = cliResults.get("inspect_android_class").normalized_result;
|
||||
assert.ok(
|
||||
classResult.methods.some((method) => method.name === fixture.method_name),
|
||||
);
|
||||
const method = cliResults.get("inspect_android_method").normalized_result;
|
||||
assert.equal(method.representation, "java");
|
||||
assert.equal(method.body_status, "available");
|
||||
assert.equal(method.source.status, "complete");
|
||||
assert.ok(method.source.text.includes("getIntent()"));
|
||||
assert.ok(method.source.text.includes("setListAdapter"));
|
||||
assert.equal(method.method.dex_descriptor, null);
|
||||
const references = cliResults.get("trace_android_references").normalized_result;
|
||||
assert.ok(references.total > 0);
|
||||
assert.ok(
|
||||
references.references.every(
|
||||
(reference) =>
|
||||
reference.dex_offset === null && reference.source_line === null,
|
||||
),
|
||||
);
|
||||
await verifyAndroidCliCancellation({
|
||||
entrypoint,
|
||||
environment,
|
||||
path,
|
||||
repository,
|
||||
execute,
|
||||
});
|
||||
assert.equal(
|
||||
await hashAndroidFile(apk),
|
||||
fixture.sha256,
|
||||
"Original APK must remain unchanged",
|
||||
);
|
||||
const report = {
|
||||
lane: "real-android",
|
||||
platform: process.platform,
|
||||
fixture: { version: fixture.version, sha256: fixture.sha256 },
|
||||
engine: packageResult.engine,
|
||||
operations: tasks.map((task) => task.name),
|
||||
cli_mcp_parity: true,
|
||||
original_apk_unchanged: true,
|
||||
apk_executed: false,
|
||||
cli_sigterm_cleanup: true,
|
||||
};
|
||||
if (process.env.REA_ANDROID_REPORT !== undefined)
|
||||
await writeFile(
|
||||
process.env.REA_ANDROID_REPORT,
|
||||
JSON.stringify(report, null, 2) + "\n",
|
||||
);
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
@@ -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 requested runtime evidence to explain features, compare versions, decompile code, or guide a reconstruction. Skip REA for ordinary source-repository architecture analysis.
|
||||
metadata:
|
||||
version: "24"
|
||||
tool_count: 118
|
||||
catalog_digest: "88cedc97288ecd044f4257a670a6707ca474906de73cab3f5bf436bff24f0cc7"
|
||||
tool_count: 123
|
||||
catalog_digest: "d5fbda3a283916ab3489a62fe88aeb89da8b0a7b19c248e3742c970b1b51600e"
|
||||
---
|
||||
|
||||
# REA
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { constants, createReadStream } from "node:fs";
|
||||
import { chmod, copyFile } from "node:fs/promises";
|
||||
import { createHash } from "node:crypto";
|
||||
import { join } from "node:path";
|
||||
import { AnalysisInputError } from "../domain/analysisErrorCore.js";
|
||||
import type { AndroidOperation } from "../domain/androidAnalysis.js";
|
||||
|
||||
/** Stream a local file digest without retaining the APK or JAR in memory. */
|
||||
export const hashAndroidFile = async (path: string): Promise<string> => {
|
||||
const hash = createHash("sha256");
|
||||
for await (const chunk of createReadStream(path)) hash.update(chunk);
|
||||
return hash.digest("hex");
|
||||
};
|
||||
|
||||
/** Bind decompiler input to the admitted artifact, even if the original changes. */
|
||||
export const snapshotAndroidTarget = async (
|
||||
path: string,
|
||||
sha256: string,
|
||||
root: string,
|
||||
operation: AndroidOperation,
|
||||
): Promise<string> => {
|
||||
const snapshot = join(root, "target.apk");
|
||||
await copyFile(path, snapshot, constants.COPYFILE_EXCL);
|
||||
await chmod(snapshot, 0o400);
|
||||
if ((await hashAndroidFile(snapshot)) !== sha256)
|
||||
throw new AnalysisInputError(operation, undefined, [
|
||||
{
|
||||
path: ["path"],
|
||||
reason: "invalid_value",
|
||||
message:
|
||||
"APK bytes changed after admission; retry against a stable file.",
|
||||
},
|
||||
]);
|
||||
return snapshot;
|
||||
};
|
||||
|
||||
/** Fingerprint the actual immutable engine bytes executed for this observation. */
|
||||
export const snapshotAndroidEngine = async (
|
||||
source: string,
|
||||
root: string,
|
||||
): Promise<{ path: string; sha256: string }> => {
|
||||
const path = join(root, "engine.jar");
|
||||
await copyFile(source, path, constants.COPYFILE_EXCL);
|
||||
await chmod(path, 0o400);
|
||||
return { path, sha256: await hashAndroidFile(path) };
|
||||
};
|
||||
@@ -0,0 +1,351 @@
|
||||
import type { z } from "zod";
|
||||
import type { AndroidRequest } from "../domain/androidAnalysis.js";
|
||||
import { androidResultSchemas } from "../domain/androidAnalysis.js";
|
||||
import {
|
||||
AnalysisCapabilityUnavailableError,
|
||||
AnalysisInputError,
|
||||
AnalysisOutputError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import {
|
||||
jadxAppSchema,
|
||||
jadxClassPageSchema,
|
||||
jadxClassSchema,
|
||||
jadxLoadSchema,
|
||||
jadxMethodSchema,
|
||||
jadxXrefsSchema,
|
||||
normalizeJadxText,
|
||||
} from "./JadxProtocol.js";
|
||||
|
||||
/** The adapter consumes tool payloads, never provider RPC details outside this layer. */
|
||||
export interface JadxToolPort {
|
||||
json(
|
||||
name: string,
|
||||
input: Readonly<Record<string, JsonValue>>,
|
||||
): Promise<JsonValue>;
|
||||
text(
|
||||
name: string,
|
||||
input: Readonly<Record<string, JsonValue>>,
|
||||
): Promise<string>;
|
||||
}
|
||||
|
||||
type Engine = z.infer<
|
||||
typeof androidResultSchemas.inspect_android_package
|
||||
>["engine"];
|
||||
type ClassSummary = z.infer<typeof jadxClassSchema>;
|
||||
|
||||
const methodsOf = (summary: ClassSummary) => {
|
||||
const seen = new Map<string, number>();
|
||||
return summary.methods.map((method) => {
|
||||
const index = seen.get(method.name) ?? 0;
|
||||
seen.set(method.name, index + 1);
|
||||
return {
|
||||
name: method.name,
|
||||
reported_signature: method.signature,
|
||||
overload_index: index,
|
||||
is_constructor: method.is_constructor,
|
||||
dex_descriptor: null,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const inputFailure = (
|
||||
request: AndroidRequest,
|
||||
field: string,
|
||||
message: string,
|
||||
): never => {
|
||||
throw new AnalysisInputError(request.operation, undefined, [
|
||||
{ path: [field], reason: "invalid_value", message },
|
||||
]);
|
||||
};
|
||||
|
||||
const classSummary = async (
|
||||
tools: JadxToolPort,
|
||||
request: Exclude<
|
||||
AndroidRequest,
|
||||
{ operation: "inspect_android_package" | "search_android_classes" }
|
||||
>,
|
||||
): Promise<ClassSummary> => {
|
||||
const raw = await tools.json("get_class_summary", {
|
||||
class_name: request.input.class_name,
|
||||
});
|
||||
if (
|
||||
raw !== null &&
|
||||
typeof raw === "object" &&
|
||||
!Array.isArray(raw) &&
|
||||
typeof raw.error === "string"
|
||||
)
|
||||
throw new AnalysisOutputError(
|
||||
request.operation,
|
||||
`JADX class summary failed: ${raw.error}`,
|
||||
);
|
||||
const summary = jadxClassSchema.safeParse(raw);
|
||||
if (!summary.success)
|
||||
throw new AnalysisOutputError(
|
||||
request.operation,
|
||||
"JADX class summary failed or was malformed; negative counts are not an empty class",
|
||||
{ cause: summary.error },
|
||||
);
|
||||
const value = summary.data;
|
||||
if (value.full_name !== request.input.class_name)
|
||||
inputFailure(
|
||||
request,
|
||||
"class_name",
|
||||
`Exact class ${request.input.class_name} resolved to ${value.full_name}; use the fully qualified class name from search_android_classes.`,
|
||||
);
|
||||
if (
|
||||
value.method_count !== value.methods.length ||
|
||||
value.field_count !== value.fields.length ||
|
||||
value.inner_class_count !== value.inner_classes.length
|
||||
)
|
||||
throw new AnalysisOutputError(
|
||||
request.operation,
|
||||
"JADX class member counts do not match the returned inventory",
|
||||
);
|
||||
return value;
|
||||
};
|
||||
|
||||
const inspectPackage = async (
|
||||
tools: JadxToolPort,
|
||||
loaded: z.infer<typeof jadxLoadSchema>,
|
||||
engine: Engine,
|
||||
): Promise<JsonValue> => {
|
||||
const app = jadxAppSchema.parse(await tools.json("get_app_info", {}));
|
||||
const manifest = normalizeJadxText(
|
||||
await tools.text("get_android_manifest", {}),
|
||||
);
|
||||
return androidResultSchemas.inspect_android_package.parse({
|
||||
engine,
|
||||
package_name: app.package || null,
|
||||
version_name: app.version_name || null,
|
||||
version_code: app.version_code || null,
|
||||
min_sdk: app.min_sdk || null,
|
||||
target_sdk: app.target_sdk || null,
|
||||
permissions: app.permissions,
|
||||
manifest,
|
||||
class_count: loaded.class_count,
|
||||
resource_count: loaded.resource_count,
|
||||
signature_verification: "not_performed",
|
||||
});
|
||||
};
|
||||
|
||||
const searchClasses = async (
|
||||
tools: JadxToolPort,
|
||||
request: Extract<AndroidRequest, { operation: "search_android_classes" }>,
|
||||
loaded: z.infer<typeof jadxLoadSchema>,
|
||||
engine: Engine,
|
||||
): Promise<JsonValue> => {
|
||||
const classes: string[] = [];
|
||||
let total: number | undefined;
|
||||
do {
|
||||
const page = jadxClassPageSchema.parse(
|
||||
await tools.json("list_classes", {
|
||||
offset: classes.length,
|
||||
limit: 200,
|
||||
}),
|
||||
);
|
||||
if (
|
||||
(total !== undefined && total !== page.total) ||
|
||||
page.offset !== classes.length ||
|
||||
page.limit !== 200 ||
|
||||
page.items.length > 200
|
||||
)
|
||||
throw new AnalysisOutputError(
|
||||
request.operation,
|
||||
"JADX class pagination changed identity or counts",
|
||||
);
|
||||
total = page.total;
|
||||
classes.push(...page.items);
|
||||
if (
|
||||
classes.length > total ||
|
||||
(page.items.length === 0 && classes.length < total)
|
||||
)
|
||||
throw new AnalysisOutputError(
|
||||
request.operation,
|
||||
"JADX class pagination is incomplete",
|
||||
);
|
||||
} while (classes.length < total);
|
||||
if (new Set(classes).size !== classes.length || total !== loaded.class_count)
|
||||
throw new AnalysisOutputError(
|
||||
request.operation,
|
||||
"JADX class inventory is duplicated or differs from the loaded class count",
|
||||
);
|
||||
return androidResultSchemas.search_android_classes.parse({
|
||||
engine,
|
||||
query: request.input.query,
|
||||
total_classes: total,
|
||||
matches: classes.filter((name) => name.includes(request.input.query)),
|
||||
coverage: "complete",
|
||||
});
|
||||
};
|
||||
|
||||
const matchingMethods = (
|
||||
summary: ClassSummary,
|
||||
request: Extract<
|
||||
AndroidRequest,
|
||||
{ operation: "inspect_android_method" | "trace_android_references" }
|
||||
>,
|
||||
) => {
|
||||
const candidates = methodsOf(summary).filter(
|
||||
(method) => method.name === request.input.method_name,
|
||||
);
|
||||
if (request.input.method_name !== undefined && candidates.length === 0)
|
||||
inputFailure(
|
||||
request,
|
||||
"method_name",
|
||||
`Method ${summary.full_name}.${request.input.method_name} was not found; inspect_android_class lists available methods.`,
|
||||
);
|
||||
return candidates;
|
||||
};
|
||||
|
||||
const inspectMethod = async (
|
||||
tools: JadxToolPort,
|
||||
request: Extract<AndroidRequest, { operation: "inspect_android_method" }>,
|
||||
summary: ClassSummary,
|
||||
engine: Engine,
|
||||
): Promise<JsonValue> => {
|
||||
const candidates = matchingMethods(summary, request);
|
||||
if (candidates.length > 1 && request.input.overload_index === undefined)
|
||||
inputFailure(
|
||||
request,
|
||||
"overload_index",
|
||||
`Select one of ${candidates.length} overloads: ${candidates.map((method) => `${method.overload_index}: ${method.reported_signature}`).join("; ")}`,
|
||||
);
|
||||
const index = request.input.overload_index ?? 0;
|
||||
const selected = candidates[index];
|
||||
if (selected === undefined)
|
||||
inputFailure(
|
||||
request,
|
||||
"overload_index",
|
||||
`Index ${index} is outside the ${candidates.length} matching overloads.`,
|
||||
);
|
||||
const body = jadxMethodSchema.parse(
|
||||
await tools.json("get_method_body", {
|
||||
class_name: summary.full_name,
|
||||
method_name: request.input.method_name,
|
||||
overload_index: index,
|
||||
smali_fallback: true,
|
||||
}),
|
||||
);
|
||||
if (
|
||||
body.class_name !== summary.full_name ||
|
||||
body.method_name !== request.input.method_name ||
|
||||
body.overload_index !== index ||
|
||||
body.overload_count !== candidates.length
|
||||
)
|
||||
throw new AnalysisOutputError(
|
||||
request.operation,
|
||||
"JADX returned a different method or overload inventory",
|
||||
);
|
||||
if (body.body.startsWith("// ERROR:"))
|
||||
throw new AnalysisOutputError(
|
||||
request.operation,
|
||||
`JADX did not produce method source: ${body.body}`,
|
||||
);
|
||||
if (body.mode === "smali" && candidates.length > 1)
|
||||
throw new AnalysisCapabilityUnavailableError(
|
||||
"jadx",
|
||||
request.operation,
|
||||
`JADX 0.7.1 smali fallback joins all same-name overloads for ${summary.full_name}.${request.input.method_name}; it cannot establish source for only overload ${index}.`,
|
||||
);
|
||||
return androidResultSchemas.inspect_android_method.parse({
|
||||
engine,
|
||||
class_name: summary.full_name,
|
||||
method: selected,
|
||||
overload_count: candidates.length,
|
||||
representation: body.mode,
|
||||
body_status:
|
||||
body.body.length === 0 ||
|
||||
body.body ===
|
||||
"// method exists but has no decompiled body (native/abstract)"
|
||||
? "not_available"
|
||||
: "available",
|
||||
fell_back: body.fell_back,
|
||||
markers: body.markers,
|
||||
source: normalizeJadxText(body.body),
|
||||
});
|
||||
};
|
||||
|
||||
/** Normalize only the selected analyst outcome and reject ambiguous engine selectors. */
|
||||
export const analyzeJadxRequest = async (
|
||||
tools: JadxToolPort,
|
||||
request: AndroidRequest,
|
||||
loaded: z.infer<typeof jadxLoadSchema>,
|
||||
engine: Engine,
|
||||
): Promise<JsonValue> => {
|
||||
if (request.operation === "inspect_android_package")
|
||||
return inspectPackage(tools, loaded, engine);
|
||||
if (request.operation === "search_android_classes")
|
||||
return searchClasses(tools, request, loaded, engine);
|
||||
const summary = await classSummary(tools, request);
|
||||
if (request.operation === "inspect_android_class")
|
||||
return androidResultSchemas.inspect_android_class.parse({
|
||||
engine,
|
||||
class_name: summary.full_name,
|
||||
methods: methodsOf(summary),
|
||||
fields: summary.fields.map((field) => ({
|
||||
name: field.name,
|
||||
reported_type: field.type,
|
||||
})),
|
||||
inner_classes: summary.inner_classes,
|
||||
});
|
||||
if (request.operation === "inspect_android_method")
|
||||
return inspectMethod(tools, request, summary, engine);
|
||||
return traceReferences(tools, request, summary, engine);
|
||||
};
|
||||
|
||||
const traceReferences = async (
|
||||
tools: JadxToolPort,
|
||||
request: Extract<AndroidRequest, { operation: "trace_android_references" }>,
|
||||
summary: ClassSummary,
|
||||
engine: Engine,
|
||||
): Promise<JsonValue> => {
|
||||
const methodName = request.input.method_name;
|
||||
const candidates = matchingMethods(summary, request);
|
||||
if (candidates.length > 1)
|
||||
throw new AnalysisCapabilityUnavailableError(
|
||||
"jadx",
|
||||
request.operation,
|
||||
`JADX 0.7.1 references select the first same-name method; ${summary.full_name}.${methodName} has ${candidates.length} overloads. No unambiguous reference result is available.`,
|
||||
);
|
||||
const expectedTarget =
|
||||
methodName === undefined
|
||||
? summary.full_name
|
||||
: `${summary.full_name}.${methodName}`;
|
||||
const xrefs = jadxXrefsSchema.parse(
|
||||
await tools.json(
|
||||
methodName === undefined ? "get_xrefs_to_class" : "get_xrefs_to_method",
|
||||
{
|
||||
class_name: summary.full_name,
|
||||
...(methodName === undefined ? {} : { method_name: methodName }),
|
||||
limit: 2_147_483_647,
|
||||
resolve_line: false,
|
||||
},
|
||||
),
|
||||
);
|
||||
if (
|
||||
xrefs.target !== expectedTarget ||
|
||||
xrefs.total !== xrefs.count ||
|
||||
xrefs.count !== xrefs.items.length
|
||||
)
|
||||
throw new AnalysisOutputError(
|
||||
request.operation,
|
||||
"JADX references are incomplete or refer to another target",
|
||||
);
|
||||
return androidResultSchemas.trace_android_references.parse({
|
||||
engine,
|
||||
class_name: summary.full_name,
|
||||
method_name: methodName ?? null,
|
||||
total: xrefs.total,
|
||||
references: xrefs.items.map((reference) => ({
|
||||
kind: reference.kind,
|
||||
name: reference.name,
|
||||
reported_full_name: reference.full_name,
|
||||
containing_class: reference.containing_class,
|
||||
top_class: reference.top_class,
|
||||
source_line: null,
|
||||
dex_offset: null,
|
||||
})),
|
||||
coverage: "complete",
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
import { access, realpath, stat } from "node:fs/promises";
|
||||
import { constants } from "node:fs";
|
||||
import { isAbsolute, join } from "node:path";
|
||||
import { AnalysisCapabilityUnavailableError } from "../domain/analysisErrorCore.js";
|
||||
import type { AndroidOperation } from "../domain/androidAnalysis.js";
|
||||
|
||||
/** Caller-supplied tools, resolved only when an Android operation is selected. */
|
||||
export interface JadxConfiguration {
|
||||
readonly jar: string;
|
||||
readonly java: string;
|
||||
}
|
||||
|
||||
/** Admit explicit local tooling without installing or changing host configuration. */
|
||||
export const resolveJadxConfiguration = async (
|
||||
environment: Readonly<Record<string, string | undefined>>,
|
||||
operation: AndroidOperation,
|
||||
): Promise<JadxConfiguration> => {
|
||||
const unavailable = (reason: string) =>
|
||||
new AnalysisCapabilityUnavailableError("jadx", operation, reason);
|
||||
if (process.platform !== "linux" && process.platform !== "darwin")
|
||||
throw unavailable(
|
||||
`Android JADX subprocess ownership is unsupported on ${process.platform}; use Linux or macOS. Only Linux has real-provider verification.`,
|
||||
);
|
||||
const jar = environment.REA_JADX_MCP_JAR;
|
||||
if (environment.JAVA_HOME !== undefined && !isAbsolute(environment.JAVA_HOME))
|
||||
throw unavailable(
|
||||
"JAVA_HOME must select an existing JDK by absolute path; omit it to use java on PATH.",
|
||||
);
|
||||
if (jar === undefined || !isAbsolute(jar))
|
||||
throw unavailable(
|
||||
"Set REA_JADX_MCP_JAR to the absolute path of a caller-supplied jadx-headless-mcp 0.7.1 JAR. REA does not download or install it.",
|
||||
);
|
||||
try {
|
||||
await access(jar, constants.R_OK);
|
||||
if (!(await stat(jar)).isFile())
|
||||
throw unavailable(`REA_JADX_MCP_JAR is not a regular file: ${jar}`);
|
||||
return {
|
||||
jar: await realpath(jar),
|
||||
java:
|
||||
environment.JAVA_HOME === undefined
|
||||
? "java"
|
||||
: join(environment.JAVA_HOME, "bin", "java"),
|
||||
};
|
||||
} catch (cause) {
|
||||
if (cause instanceof AnalysisCapabilityUnavailableError) throw cause;
|
||||
throw unavailable(
|
||||
`Cannot read REA_JADX_MCP_JAR ${jar}: ${cause instanceof Error ? cause.message : String(cause)}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,183 @@
|
||||
import {
|
||||
deserializeMessage,
|
||||
serializeMessage,
|
||||
type JSONRPCMessage,
|
||||
type Transport,
|
||||
} from "@modelcontextprotocol/client";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type {
|
||||
OwnedProviderProcessSpawnOptions,
|
||||
SpawnedOwnedProviderProcess,
|
||||
} from "../process/ProviderProcess.js";
|
||||
import {
|
||||
ProviderProcessSupervisor,
|
||||
spawnOwnedProviderProcess,
|
||||
} from "../process/ProviderProcess.js";
|
||||
import { cleanupOwnedProcessGroup } from "../process/ProcessOwnership.js";
|
||||
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
|
||||
|
||||
const MAX_FRAME_BYTES = 8 * 1024 * 1024;
|
||||
const MAX_SESSION_BYTES = 32 * 1024 * 1024;
|
||||
|
||||
/** Injectable process acquisition seam; the production launcher owns a POSIX group. */
|
||||
export type JadxLauncher = (
|
||||
options: OwnedProviderProcessSpawnOptions,
|
||||
) => Promise<SpawnedOwnedProviderProcess>;
|
||||
|
||||
/** SDK-framed stdio with REA-owned process cleanup rather than an unowned child. */
|
||||
export class JadxMcpTransport implements Transport {
|
||||
onclose: Transport["onclose"];
|
||||
onerror: Transport["onerror"];
|
||||
onmessage: Transport["onmessage"];
|
||||
readonly runId = randomUUID();
|
||||
#spawned: SpawnedOwnedProviderProcess | undefined;
|
||||
#supervisor: ProviderProcessSupervisor | undefined;
|
||||
#buffer = Buffer.alloc(0);
|
||||
#bytes = 0;
|
||||
#started = false;
|
||||
#closed = false;
|
||||
#closePromise: Promise<void> | undefined;
|
||||
#starting: Promise<void> | undefined;
|
||||
#failure: string | null = null;
|
||||
|
||||
constructor(
|
||||
readonly options: Omit<OwnedProviderProcessSpawnOptions, "runId" | "stdin">,
|
||||
readonly launcher: JadxLauncher = spawnOwnedProviderProcess,
|
||||
) {}
|
||||
|
||||
/** Acquires exactly one protocol process after the SDK installs callbacks. */
|
||||
start(): Promise<void> {
|
||||
if (this.#started || this.#closed)
|
||||
throw new Error("JADX transport cannot be restarted");
|
||||
this.#started = true;
|
||||
this.#starting = this.#acquire();
|
||||
return this.#starting;
|
||||
}
|
||||
|
||||
async #acquire(): Promise<void> {
|
||||
const spawned = await this.launcher({
|
||||
...this.options,
|
||||
runId: this.runId,
|
||||
stdin: "pipe",
|
||||
});
|
||||
this.#spawned = spawned;
|
||||
this.#supervisor = new ProviderProcessSupervisor({
|
||||
...spawned,
|
||||
ownsProcessLifetime: true,
|
||||
cleanup:
|
||||
spawned.cleanup ?? (() => cleanupOwnedProcessGroup(spawned.ownership)),
|
||||
});
|
||||
if (
|
||||
spawned.process.stdin === undefined ||
|
||||
spawned.process.stdin === null ||
|
||||
spawned.process.stdout === null
|
||||
)
|
||||
throw new Error("JADX launcher did not supply protocol stdin/stdout");
|
||||
spawned.process.stdout.on("data", this.#read);
|
||||
spawned.process.stderr?.on("data", this.#countStderr);
|
||||
spawned.process.on("error", this.#error);
|
||||
spawned.process.once("exit", this.#exit);
|
||||
spawned.process.stdin.on("error", this.#error);
|
||||
if (this.#closed) throw new Error("JADX transport closed during startup");
|
||||
}
|
||||
|
||||
/** Writes SDK-serialized messages and observes stream backpressure. */
|
||||
async send(message: JSONRPCMessage): Promise<void> {
|
||||
const input = this.#spawned?.process.stdin;
|
||||
if (this.#closed || input === undefined || input === null)
|
||||
throw new Error("JADX protocol process is not writable");
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
input.write(serializeMessage(message), (cause) =>
|
||||
cause === null || cause === undefined ? resolve() : reject(cause),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** Verifies owned process exit before declaring the transport closed. */
|
||||
close(): Promise<void> {
|
||||
this.#closePromise ??= this.#stop();
|
||||
return this.#closePromise;
|
||||
}
|
||||
|
||||
/** Captured provider stderr without persisting inherited environment values. */
|
||||
diagnostics(): string {
|
||||
return this.#supervisor?.snapshot().stderr.text ?? "";
|
||||
}
|
||||
|
||||
/** Preserve the failed protocol/resource constraint after the SDK closes requests. */
|
||||
failureReason(): string | null {
|
||||
return this.#failure;
|
||||
}
|
||||
|
||||
async #stop(): Promise<void> {
|
||||
this.#closed = true;
|
||||
// A cancellation may race acquisition. Join it before inspecting ownership.
|
||||
await this.#starting?.catch(() => undefined);
|
||||
const process = this.#spawned?.process;
|
||||
process?.stdout?.off("data", this.#read);
|
||||
process?.stderr?.off("data", this.#countStderr);
|
||||
process?.off("error", this.#error);
|
||||
process?.off("exit", this.#exit);
|
||||
process?.stdin?.off("error", this.#error);
|
||||
const stopped = await this.#supervisor?.stop();
|
||||
this.onclose?.();
|
||||
if (stopped?.status === "incomplete")
|
||||
throw new ProviderCleanupError("jadx", [this.runId], {
|
||||
reason: stopped.reason,
|
||||
stderr: this.diagnostics(),
|
||||
});
|
||||
}
|
||||
|
||||
readonly #error = (error: Error): void => {
|
||||
this.onerror?.(error);
|
||||
};
|
||||
readonly #exit = (): void => {
|
||||
this.onclose?.();
|
||||
};
|
||||
readonly #countStderr = (chunk: Buffer | string): void => {
|
||||
this.#bytes += Buffer.byteLength(chunk);
|
||||
if (this.#bytes > MAX_SESSION_BYTES)
|
||||
this.#fail(
|
||||
new Error(
|
||||
`JADX session output exceeds ${MAX_SESSION_BYTES} bytes; no complete result is available`,
|
||||
),
|
||||
);
|
||||
};
|
||||
readonly #read = (chunk: Buffer | string): void => {
|
||||
if (this.#closed) return;
|
||||
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||
this.#bytes += bytes.length;
|
||||
if (this.#bytes > MAX_SESSION_BYTES) {
|
||||
this.#fail(
|
||||
new Error(
|
||||
`JADX session output exceeds ${MAX_SESSION_BYTES} bytes; no complete result is available`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.#buffer = Buffer.concat([this.#buffer, bytes]);
|
||||
try {
|
||||
let newline: number;
|
||||
while ((newline = this.#buffer.indexOf(10)) !== -1) {
|
||||
if (newline > MAX_FRAME_BYTES)
|
||||
throw new Error(`JADX MCP frame exceeds ${MAX_FRAME_BYTES} bytes`);
|
||||
const line = this.#buffer.subarray(0, newline).toString("utf8");
|
||||
this.#buffer = this.#buffer.subarray(newline + 1);
|
||||
this.onmessage?.(deserializeMessage(line));
|
||||
}
|
||||
if (this.#buffer.length > MAX_FRAME_BYTES)
|
||||
throw new Error(`JADX MCP frame exceeds ${MAX_FRAME_BYTES} bytes`);
|
||||
} catch (cause) {
|
||||
this.#fail(cause instanceof Error ? cause : new Error(String(cause)));
|
||||
}
|
||||
};
|
||||
#fail(error: Error): void {
|
||||
this.#failure ??= error.message;
|
||||
this.onerror?.(error);
|
||||
// Closing immediately rejects the SDK's outstanding request. The owner
|
||||
// awaits close again and retains a cleanup failure in the returned Result.
|
||||
void this.close().catch((cause: unknown) =>
|
||||
this.onerror?.(cause instanceof Error ? cause : new Error(String(cause))),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { z } from "zod";
|
||||
import { AnalysisOutputError } from "../domain/analysisErrorCore.js";
|
||||
import { jsonValueSchema } from "../domain/jsonValue.js";
|
||||
import type { AndroidOperation } from "../domain/androidAnalysis.js";
|
||||
|
||||
const count = z.number().int().nonnegative();
|
||||
export const jadxLoadSchema = z.object({
|
||||
state: z.literal("LOADED"),
|
||||
apk_path: z.string(),
|
||||
class_count: count,
|
||||
resource_count: count,
|
||||
threads: z.literal(1),
|
||||
resources: z.literal("full"),
|
||||
});
|
||||
export const jadxAppSchema = z.object({
|
||||
package: z.string(),
|
||||
version_name: z.string(),
|
||||
version_code: z.string(),
|
||||
min_sdk: z.string(),
|
||||
target_sdk: z.string(),
|
||||
permissions: z.array(z.string()),
|
||||
});
|
||||
export const jadxClassPageSchema = z.object({
|
||||
total: count,
|
||||
offset: count,
|
||||
limit: count,
|
||||
items: z.array(z.string()),
|
||||
});
|
||||
export const jadxClassSchema = z.object({
|
||||
full_name: z.string(),
|
||||
name: z.string(),
|
||||
method_count: count,
|
||||
field_count: count,
|
||||
inner_class_count: count,
|
||||
methods: z.array(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
signature: z.string(),
|
||||
is_constructor: z.boolean(),
|
||||
def_pos: z.number().int(),
|
||||
}),
|
||||
),
|
||||
fields: z.array(
|
||||
z.object({ name: z.string(), type: z.string(), def_pos: z.number().int() }),
|
||||
),
|
||||
inner_classes: z.array(z.string()),
|
||||
});
|
||||
export const jadxMethodSchema = z.object({
|
||||
class_name: z.string(),
|
||||
method_name: z.string(),
|
||||
full_name: z.string(),
|
||||
overload_index: count,
|
||||
overload_count: count,
|
||||
mode: z.enum(["java", "smali"]),
|
||||
fell_back: z.boolean(),
|
||||
markers: z.array(z.string()),
|
||||
body: z.string(),
|
||||
});
|
||||
export const jadxXrefsSchema = z.object({
|
||||
target: z.string(),
|
||||
total: count,
|
||||
count,
|
||||
resolve_line: z.literal(false),
|
||||
items: z.array(
|
||||
z.object({
|
||||
kind: z.string(),
|
||||
name: z.string(),
|
||||
full_name: z.string(),
|
||||
containing_class: z.string(),
|
||||
top_class: z.string(),
|
||||
def_pos: z.number().int(),
|
||||
}),
|
||||
),
|
||||
});
|
||||
const toolResponse = z.object({
|
||||
content: z
|
||||
.array(z.object({ type: z.literal("text"), text: z.string() }))
|
||||
.length(1),
|
||||
isError: z.boolean().optional(),
|
||||
});
|
||||
|
||||
/** Parse the upstream text-only envelope, preserving errors at the producing boundary. */
|
||||
export const parseJadxEnvelope = (
|
||||
response: unknown,
|
||||
operation: AndroidOperation,
|
||||
): { text: string; failed: boolean } => {
|
||||
const parsed = toolResponse.safeParse(response);
|
||||
if (!parsed.success || parsed.data.content[0] === undefined)
|
||||
throw new AnalysisOutputError(
|
||||
operation,
|
||||
"JADX returned an unsupported MCP content envelope",
|
||||
);
|
||||
return {
|
||||
text: parsed.data.content[0].text,
|
||||
failed: parsed.data.isError === true,
|
||||
};
|
||||
};
|
||||
|
||||
/** Decode an upstream JSON text payload without trusting its contents. */
|
||||
export const parseJadxJson = (
|
||||
text: string,
|
||||
operation: AndroidOperation,
|
||||
): z.infer<typeof jsonValueSchema> => {
|
||||
try {
|
||||
return jsonValueSchema.parse(JSON.parse(text));
|
||||
} catch (cause) {
|
||||
throw new AnalysisOutputError(
|
||||
operation,
|
||||
"JADX returned malformed JSON text",
|
||||
{ cause },
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/** Upstream byte truncation is reported rather than promoted to a complete source. */
|
||||
export const normalizeJadxText = (
|
||||
text: string,
|
||||
): {
|
||||
status: "complete" | "partial";
|
||||
text: string;
|
||||
reported_total_bytes: number | null;
|
||||
} => {
|
||||
const marker =
|
||||
/\n\n\.\.\. \[truncated: exceeds (\d+) bytes, total (\d+) bytes\]$/u.exec(
|
||||
text,
|
||||
);
|
||||
const total = marker?.[2] === undefined ? null : Number(marker[2]);
|
||||
return {
|
||||
status: marker === null ? "complete" : "partial",
|
||||
text,
|
||||
reported_total_bytes:
|
||||
total !== null && Number.isSafeInteger(total) ? total : null,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,245 @@
|
||||
import { z } from "zod";
|
||||
import type { AndroidAnalysisPort } from "../application/AndroidAnalysisPort.js";
|
||||
import type { ExecutionOptions } from "../application/AnalysisProvider.js";
|
||||
import { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisCapabilityUnavailableError,
|
||||
AnalysisOutputError,
|
||||
AnalysisTimeoutError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
|
||||
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
|
||||
import type { AndroidRequest } from "../domain/androidAnalysis.js";
|
||||
import type { BinaryTarget } from "../domain/binaryTarget.js";
|
||||
import { err, ok } from "../domain/result.js";
|
||||
import { PrivateRuntimeRoot } from "../process/PrivateRuntimeRoot.js";
|
||||
import {
|
||||
snapshotAndroidEngine,
|
||||
snapshotAndroidTarget,
|
||||
} from "./AndroidTargetSnapshot.js";
|
||||
import { resolveJadxConfiguration } from "./JadxConfiguration.js";
|
||||
import type { JadxLauncher } from "./JadxMcpTransport.js";
|
||||
import { JadxSession } from "./JadxSession.js";
|
||||
|
||||
const OPERATION_TIMEOUT_MS = 120_000;
|
||||
type Outcome = Awaited<ReturnType<AndroidAnalysisPort["execute"]>>;
|
||||
|
||||
const waitForTurn = (
|
||||
predecessor: Promise<void>,
|
||||
request: AndroidRequest,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> =>
|
||||
new Promise((resolve, reject) => {
|
||||
const abort = () => reject(new AnalysisCancelledError(request.operation));
|
||||
signal?.addEventListener("abort", abort, { once: true });
|
||||
if (signal?.aborted === true) abort();
|
||||
void predecessor.then(() => {
|
||||
signal?.removeEventListener("abort", abort);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
const executionError = (context: {
|
||||
cause: unknown;
|
||||
request: AndroidRequest;
|
||||
timeout: AbortSignal;
|
||||
signal: AbortSignal | undefined;
|
||||
session: JadxSession | undefined;
|
||||
}): AnalysisError => {
|
||||
const { cause, request, timeout, signal, session } = context;
|
||||
if (signal?.aborted === true)
|
||||
return new AnalysisCancelledError(request.operation);
|
||||
if (timeout.aborted)
|
||||
return new AnalysisTimeoutError(request.operation, OPERATION_TIMEOUT_MS);
|
||||
if (cause instanceof AnalysisError) return cause;
|
||||
const protocolFailure = session?.transport.failureReason();
|
||||
if (protocolFailure !== undefined && protocolFailure !== null)
|
||||
return new AnalysisOutputError(request.operation, protocolFailure, {
|
||||
cause,
|
||||
});
|
||||
if (cause instanceof z.ZodError)
|
||||
return new AnalysisOutputError(
|
||||
request.operation,
|
||||
"JADX response violated the pinned upstream protocol",
|
||||
{ cause },
|
||||
);
|
||||
return new ProviderAdapterError("jadx", request.operation, {
|
||||
cause,
|
||||
diagnostics: {
|
||||
reason: cause instanceof Error ? cause.message : String(cause),
|
||||
stderr: session?.transport.diagnostics() ?? "",
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const cleanup = async (
|
||||
session: JadxSession | undefined,
|
||||
root: PrivateRuntimeRoot | undefined,
|
||||
previous: Outcome,
|
||||
): Promise<Outcome> => {
|
||||
const diagnostics = {
|
||||
previous_error: previous.ok
|
||||
? null
|
||||
: { tag: previous.error._tag, message: previous.error.message },
|
||||
};
|
||||
try {
|
||||
await session?.close();
|
||||
} catch (cause) {
|
||||
// Retain an uncertain workspace until the caller can resolve process ownership.
|
||||
return err(
|
||||
new ProviderCleanupError(
|
||||
"jadx",
|
||||
[
|
||||
session?.transport.runId ?? "unknown",
|
||||
...(root === undefined ? [] : [root.path]),
|
||||
],
|
||||
{
|
||||
...diagnostics,
|
||||
reason: cause instanceof Error ? cause.message : String(cause),
|
||||
provider_cleanup:
|
||||
cause instanceof ProviderCleanupError
|
||||
? (cause.diagnostics ?? null)
|
||||
: null,
|
||||
},
|
||||
{ cause },
|
||||
),
|
||||
);
|
||||
}
|
||||
try {
|
||||
await root?.close();
|
||||
} catch (cause) {
|
||||
return err(
|
||||
new ProviderCleanupError(
|
||||
"jadx",
|
||||
[root?.path ?? "unknown"],
|
||||
{
|
||||
...diagnostics,
|
||||
reason: cause instanceof Error ? cause.message : String(cause),
|
||||
},
|
||||
{ cause },
|
||||
),
|
||||
);
|
||||
}
|
||||
return previous;
|
||||
};
|
||||
|
||||
/** One queued engine per REA instance, with a fresh owned workspace for every request. */
|
||||
export class JadxProvider implements AndroidAnalysisPort {
|
||||
#tail: Promise<void> = Promise.resolve();
|
||||
#cleanupFailure: AnalysisError | undefined;
|
||||
constructor(
|
||||
readonly environment: Readonly<
|
||||
Record<string, string | undefined>
|
||||
> = process.env,
|
||||
readonly launcher?: JadxLauncher,
|
||||
) {}
|
||||
|
||||
/** Serialize expensive work and bind both raw and normalized results to the APK. */
|
||||
async execute(
|
||||
target: BinaryTarget,
|
||||
request: AndroidRequest,
|
||||
options?: ExecutionOptions,
|
||||
): Promise<Outcome> {
|
||||
const predecessor = this.#tail;
|
||||
let release: () => void = () => {};
|
||||
this.#tail = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
try {
|
||||
await waitForTurn(predecessor, request, options?.signal);
|
||||
} catch {
|
||||
void predecessor.then(release);
|
||||
return err(new AnalysisCancelledError(request.operation));
|
||||
}
|
||||
try {
|
||||
if (options?.signal?.aborted === true)
|
||||
return err(new AnalysisCancelledError(request.operation));
|
||||
if (this.#cleanupFailure !== undefined) return err(this.#cleanupFailure);
|
||||
const outcome = await this.#execute(target, request, options);
|
||||
if (!outcome.ok && outcome.error.cleanupIncomplete)
|
||||
this.#cleanupFailure = outcome.error;
|
||||
return outcome;
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
}
|
||||
|
||||
async #execute(
|
||||
target: BinaryTarget,
|
||||
request: AndroidRequest,
|
||||
options?: ExecutionOptions,
|
||||
): Promise<Outcome> {
|
||||
const timeout = AbortSignal.timeout(OPERATION_TIMEOUT_MS);
|
||||
const signal =
|
||||
options?.signal === undefined
|
||||
? timeout
|
||||
: AbortSignal.any([options.signal, timeout]);
|
||||
let root: PrivateRuntimeRoot | undefined;
|
||||
let session: JadxSession | undefined;
|
||||
let outcome: Outcome;
|
||||
try {
|
||||
if (target.format !== "apk")
|
||||
throw new AnalysisCapabilityUnavailableError(
|
||||
"jadx",
|
||||
request.operation,
|
||||
`Target ${target.path} is ${target.format}; provide one standalone APK.`,
|
||||
);
|
||||
const configuration = await resolveJadxConfiguration(
|
||||
this.environment,
|
||||
request.operation,
|
||||
);
|
||||
signal.throwIfAborted();
|
||||
root = await PrivateRuntimeRoot.create({ prefix: "rea-android-" });
|
||||
const engine = await snapshotAndroidEngine(configuration.jar, root.path);
|
||||
const snapshot = await snapshotAndroidTarget(
|
||||
target.path,
|
||||
target.sha256,
|
||||
root.path,
|
||||
request.operation,
|
||||
);
|
||||
signal.throwIfAborted();
|
||||
session = new JadxSession(
|
||||
{
|
||||
command: configuration.java,
|
||||
arguments: [
|
||||
"-Xmx512m",
|
||||
"-XX:ActiveProcessorCount=1",
|
||||
"-jar",
|
||||
engine.path,
|
||||
"--threads",
|
||||
"1",
|
||||
"--max-source-bytes",
|
||||
"1048576",
|
||||
"--decompile-timeout-ms",
|
||||
"90000",
|
||||
],
|
||||
cwd: root.path,
|
||||
hostEnvironment: this.environment,
|
||||
env: { _JAVA_OPTIONS: "-Xmx512m -XX:ActiveProcessorCount=1" },
|
||||
},
|
||||
this.launcher,
|
||||
);
|
||||
outcome = ok(
|
||||
await session.execute({
|
||||
request,
|
||||
target,
|
||||
snapshot,
|
||||
jarHash: engine.sha256,
|
||||
signal,
|
||||
}),
|
||||
);
|
||||
} catch (cause) {
|
||||
outcome = err(
|
||||
executionError({
|
||||
cause,
|
||||
request,
|
||||
timeout,
|
||||
signal: options?.signal,
|
||||
session,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return cleanup(session, root, outcome);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/** Audited upstream release; source remains unmodified in the Git submodule. */
|
||||
export const JADX_RELEASE = Object.freeze({
|
||||
version: "0.7.1",
|
||||
revision: "5844800a486d2248fa949b74c9896285f5b54de2",
|
||||
sha256: "6e5eacf500b64292bfb73c49797c1958f6ee44646e43e868039ae7feb573ff75",
|
||||
url: "https://github.com/1013503897/jadx-headless-mcp/releases/download/v0.7.1/jadx-headless-mcp-0.7.1-all.jar",
|
||||
});
|
||||
|
||||
/** Producing engine identity used by Evidence and the generated product catalog. */
|
||||
export const JADX_PROVIDER_IDENTITY = Object.freeze({
|
||||
id: "jadx",
|
||||
name: "JADX headless",
|
||||
version: JADX_RELEASE.version,
|
||||
});
|
||||
|
||||
/** Audited engine coverage, kept outside portable Android semantics. */
|
||||
export const JADX_LIMITATIONS = [
|
||||
"Static decompiler observations do not establish runtime behavior; the APK is never executed.",
|
||||
"Method signatures use provider display types; exact DEX descriptors and instruction offsets are unavailable. Overload indices are local to this engine and artifact.",
|
||||
"Manifest summary fields are extracted by the upstream engine; decoded XML is retained for verification. APK signatures, split APKs, native libraries and Android runtime capture are not verified by this provider.",
|
||||
] as const;
|
||||
@@ -0,0 +1,150 @@
|
||||
import { Client } from "@modelcontextprotocol/client";
|
||||
import {
|
||||
createAnalysisExecution,
|
||||
type AnalysisExecution,
|
||||
} from "../application/AnalysisProvider.js";
|
||||
import type { AndroidRequest } from "../domain/androidAnalysis.js";
|
||||
import {
|
||||
AnalysisCapabilityUnavailableError,
|
||||
AnalysisOutputError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import type { BinaryTarget } from "../domain/binaryTarget.js";
|
||||
import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js";
|
||||
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
|
||||
import type { OwnedProviderProcessSpawnOptions } from "../process/ProviderProcess.js";
|
||||
import { analyzeJadxRequest, type JadxToolPort } from "./JadxAnalysis.js";
|
||||
import { JadxMcpTransport, type JadxLauncher } from "./JadxMcpTransport.js";
|
||||
import {
|
||||
jadxLoadSchema,
|
||||
parseJadxEnvelope,
|
||||
parseJadxJson,
|
||||
} from "./JadxProtocol.js";
|
||||
import {
|
||||
JADX_RELEASE,
|
||||
JADX_PROVIDER_IDENTITY,
|
||||
JADX_LIMITATIONS,
|
||||
} from "./JadxRelease.js";
|
||||
|
||||
/** One owned engine connection; its wire protocol stays inside the Android adapter. */
|
||||
export class JadxSession {
|
||||
readonly transport: JadxMcpTransport;
|
||||
readonly #client = new Client({ name: "rea-android-adapter", version: "1" });
|
||||
readonly #raw: JsonValue[] = [];
|
||||
|
||||
constructor(
|
||||
options: Omit<OwnedProviderProcessSpawnOptions, "runId" | "stdin">,
|
||||
launcher?: JadxLauncher,
|
||||
) {
|
||||
this.transport = new JadxMcpTransport(options, launcher);
|
||||
}
|
||||
|
||||
/** Analyze one admitted immutable APK, checking producer identity and selectors. */
|
||||
async execute(context: {
|
||||
readonly request: AndroidRequest;
|
||||
readonly target: BinaryTarget;
|
||||
readonly snapshot: string;
|
||||
readonly jarHash: string;
|
||||
readonly signal: AbortSignal;
|
||||
}): Promise<AnalysisExecution> {
|
||||
const { request, target, snapshot, jarHash, signal } = context;
|
||||
const abort = () => {
|
||||
void this.transport.close().catch(() => undefined);
|
||||
};
|
||||
signal.addEventListener("abort", abort, { once: true });
|
||||
try {
|
||||
signal.throwIfAborted();
|
||||
await this.#client.connect(this.transport, { timeout: 30_000 });
|
||||
const server = this.#client.getServerVersion();
|
||||
if (
|
||||
server?.name !== "jadx-headless-mcp" ||
|
||||
server.version !== JADX_RELEASE.version
|
||||
)
|
||||
throw new AnalysisCapabilityUnavailableError(
|
||||
"jadx",
|
||||
request.operation,
|
||||
`Expected jadx-headless-mcp ${JADX_RELEASE.version}; server reported ${server?.name ?? "unknown"} ${server?.version ?? "unknown"}.`,
|
||||
);
|
||||
const tools = this.#tools(request, target, signal);
|
||||
const loaded = jadxLoadSchema.parse(
|
||||
await tools.json("load_apk", {
|
||||
path: snapshot,
|
||||
threads: 1,
|
||||
resources: "full",
|
||||
}),
|
||||
);
|
||||
if (loaded.apk_path !== snapshot)
|
||||
throw new AnalysisOutputError(
|
||||
request.operation,
|
||||
"JADX loaded a different APK path than the admitted snapshot",
|
||||
);
|
||||
const engine = {
|
||||
name: "jadx-headless-mcp",
|
||||
version: "0.7.1",
|
||||
artifact_sha256: jarHash,
|
||||
source_revision:
|
||||
jarHash === JADX_RELEASE.sha256 ? JADX_RELEASE.revision : null,
|
||||
worker_count: 1,
|
||||
heap_limit_mib: 512,
|
||||
} as const;
|
||||
const result = await analyzeJadxRequest(tools, request, loaded, engine);
|
||||
return createAnalysisExecution(result, JADX_PROVIDER_IDENTITY, {
|
||||
rawResult: { server, jar_sha256: jarHash, calls: this.#raw },
|
||||
subject: target,
|
||||
limitations: JADX_LIMITATIONS,
|
||||
locations:
|
||||
request.operation === "inspect_android_package"
|
||||
? [{ kind: "artifact-path", path: "AndroidManifest.xml" }]
|
||||
: [],
|
||||
});
|
||||
} finally {
|
||||
signal.removeEventListener("abort", abort);
|
||||
}
|
||||
}
|
||||
|
||||
/** Join owned cleanup even if SDK connection teardown itself fails. */
|
||||
async close(): Promise<void> {
|
||||
try {
|
||||
await this.#client.close();
|
||||
} finally {
|
||||
await this.transport.close();
|
||||
}
|
||||
}
|
||||
|
||||
#tools(
|
||||
request: AndroidRequest,
|
||||
target: BinaryTarget,
|
||||
signal: AbortSignal,
|
||||
): JadxToolPort {
|
||||
const call = async (
|
||||
name: string,
|
||||
input: Readonly<Record<string, JsonValue>>,
|
||||
): Promise<string> => {
|
||||
signal.throwIfAborted();
|
||||
const response = await this.#client.callTool(
|
||||
{ name, arguments: input },
|
||||
{ timeout: 120_000, signal },
|
||||
);
|
||||
const envelope = parseJadxEnvelope(response, request.operation);
|
||||
this.#raw.push({
|
||||
operation: name,
|
||||
input,
|
||||
response: jsonValueSchema.parse(response),
|
||||
});
|
||||
if (envelope.failed)
|
||||
throw new ProviderAdapterError("jadx", request.operation, {
|
||||
diagnostics: {
|
||||
upstream_operation: name,
|
||||
reason: envelope.text,
|
||||
target_path: target.path,
|
||||
target_sha256: target.sha256,
|
||||
},
|
||||
});
|
||||
return envelope.text;
|
||||
};
|
||||
return {
|
||||
text: call,
|
||||
json: async (name, input) =>
|
||||
parseJadxJson(await call(name, input), request.operation),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type {
|
||||
AnalysisExecution,
|
||||
ExecutionOptions,
|
||||
} from "./AnalysisProvider.js";
|
||||
import type { AndroidRequest } from "../domain/androidAnalysis.js";
|
||||
import type { BinaryTarget } from "../domain/binaryTarget.js";
|
||||
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import type { Result } from "../domain/result.js";
|
||||
|
||||
/** Static Android inspection boundary; implementation details belong to the provider. */
|
||||
export interface AndroidAnalysisPort {
|
||||
execute(
|
||||
target: BinaryTarget,
|
||||
request: AndroidRequest,
|
||||
options?: ExecutionOptions,
|
||||
): Promise<Result<AnalysisExecution, AnalysisError>>;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { AndroidAnalysisPort } from "./AndroidAnalysisPort.js";
|
||||
import type { ExecutionOptions } from "./AnalysisProvider.js";
|
||||
import { parseBinaryTarget } from "./BinaryTargetResolver.js";
|
||||
import {
|
||||
androidRequestSchema,
|
||||
type AndroidOperation,
|
||||
} from "../domain/androidAnalysis.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisInputError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import { createEvidence, type Evidence } from "../domain/evidence.js";
|
||||
import { jsonObjectSchema } from "../domain/jsonValue.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
|
||||
/** Shared CLI/MCP admission, execution and Evidence composition for Android. */
|
||||
export class AndroidAnalysisService {
|
||||
constructor(readonly provider: AndroidAnalysisPort) {}
|
||||
|
||||
/** Inspect a caller-selected APK and retain producing-provider provenance inline. */
|
||||
async execute(
|
||||
operation: AndroidOperation,
|
||||
input: unknown,
|
||||
options?: ExecutionOptions,
|
||||
): Promise<Result<Evidence, AnalysisError>> {
|
||||
if (options?.signal?.aborted === true)
|
||||
return err(new AnalysisCancelledError(operation));
|
||||
const request = androidRequestSchema.safeParse({ operation, input });
|
||||
if (!request.success)
|
||||
return err(new AnalysisInputError(operation, { cause: request.error }));
|
||||
const target = await parseBinaryTarget(request.data.input.path);
|
||||
if (!target.ok) return target;
|
||||
const executed = await this.provider.execute(
|
||||
target.value,
|
||||
request.data,
|
||||
options,
|
||||
);
|
||||
if (!executed.ok) return executed;
|
||||
const execution = executed.value;
|
||||
return ok(
|
||||
createEvidence(execution.subject ?? target.value, execution.provider, {
|
||||
operation,
|
||||
parameters: jsonObjectSchema.parse(request.data.input),
|
||||
result: execution.result,
|
||||
rawResult: execution.rawResult,
|
||||
limitations: execution.limitations,
|
||||
locations: execution.locations,
|
||||
confidence:
|
||||
operation === "inspect_android_method" ? "derived" : "observed",
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -55,6 +55,27 @@ const entry = (
|
||||
};
|
||||
|
||||
describe("capability inventory: provider status", () => {
|
||||
it("keeps Android APK operations target-free and reports their explicit engine prerequisite", () => {
|
||||
expect(
|
||||
entry("inspect_android_package", status(), {
|
||||
processCaptureEnabled: true,
|
||||
androidAnalysisEnabled: true,
|
||||
}),
|
||||
).toMatchObject({
|
||||
available: true,
|
||||
surface: "android-provider",
|
||||
reason: "available",
|
||||
});
|
||||
expect(
|
||||
entry("inspect_android_package", status(), {
|
||||
processCaptureEnabled: true,
|
||||
}),
|
||||
).toMatchObject({
|
||||
available: false,
|
||||
reason: "provider_missing",
|
||||
remediation: expect.stringContaining("REA_JADX_MCP_JAR"),
|
||||
});
|
||||
});
|
||||
it.each([
|
||||
{
|
||||
label: "requires a target",
|
||||
|
||||
@@ -28,6 +28,7 @@ type ToolAvailabilityReason = "available" | ToolUnavailabilityReason;
|
||||
type ProviderDescriptor = ProviderCapability;
|
||||
export type AvailabilityPolicy = {
|
||||
readonly processCaptureEnabled: boolean;
|
||||
readonly androidAnalysisEnabled?: boolean;
|
||||
readonly browserObservationEnabled?: boolean;
|
||||
readonly browserScenarioEnabled?: boolean;
|
||||
readonly electronObservationEnabled?: boolean;
|
||||
@@ -205,6 +206,14 @@ const workflowAvailabilityFor = ({
|
||||
kind,
|
||||
policy,
|
||||
}: AvailabilityContext): Availability | null => {
|
||||
if (kind === "android-provider")
|
||||
return policy.androidAnalysisEnabled === true
|
||||
? { reason: "available", remediation: null }
|
||||
: {
|
||||
reason: "provider_missing",
|
||||
remediation:
|
||||
"Set REA_JADX_MCP_JAR to a caller-supplied jadx-headless-mcp 0.7.1 JAR and provide Java on Linux or macOS. Only Linux has real-provider verification.",
|
||||
};
|
||||
const browser = browserProviderAvailability(name, kind, policy);
|
||||
if (browser !== null) return browser;
|
||||
if (name === "capture_process_scenario" && !policy.processCaptureEnabled)
|
||||
|
||||
@@ -7,6 +7,7 @@ import { registerCoreAnalysisCommands } from "./cli/coreAnalysisCommands.js";
|
||||
import { registerUtilityCommands } from "./cli/utilityCommands.js";
|
||||
import { registerArtifactCommands } from "./cli/artifactCommands.js";
|
||||
import { registerManagedCommands } from "./cli/managedCommands.js";
|
||||
import { registerAndroidCommands } from "./cli/androidCommands.js";
|
||||
import { registerEvidenceCommands } from "./cliEvidenceCommands.js";
|
||||
import { registerProcessCommands } from "./cli/processCommands.js";
|
||||
import { registerBrowserCommands } from "./cli/browserCommands.js";
|
||||
@@ -47,6 +48,7 @@ export const createCli = (
|
||||
registerUtilityCommands(cli, logger, environment);
|
||||
registerArtifactCommands(cli, logger);
|
||||
registerManagedCommands(cli, logger);
|
||||
registerAndroidCommands(cli, logger, environment);
|
||||
registerEvidenceCommands(cli, logger);
|
||||
registerProcessCommands(cli, logger, environment);
|
||||
registerBrowserCommands(cli, logger);
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { z } from "incur";
|
||||
import { AndroidAnalysisService } from "../application/AndroidAnalysisService.js";
|
||||
import { JadxProvider } from "../android/JadxProvider.js";
|
||||
import { CLI_COMMANDS } from "../cliCommandNames.js";
|
||||
import { logCliCommand } from "../cliLogging.js";
|
||||
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
||||
import type { AndroidOperation } from "../domain/androidAnalysis.js";
|
||||
import type { Logger } from "../logger.js";
|
||||
import type { CliInstance } from "./types.js";
|
||||
import { withCommandCancellation } from "./commandCancellation.js";
|
||||
|
||||
/** Expose the same explicit APK requests through one-shot CLI commands. */
|
||||
export const registerAndroidCommands = (
|
||||
cli: CliInstance,
|
||||
logger: Logger,
|
||||
environment: Readonly<Record<string, string | undefined>>,
|
||||
): void => {
|
||||
const service = new AndroidAnalysisService(new JadxProvider(environment));
|
||||
const execute = (name: string, operation: AndroidOperation, input: unknown) =>
|
||||
withCommandCancellation((signal) =>
|
||||
logCliCommand(logger, name, async () => {
|
||||
const result = await service.execute(operation, input, { signal });
|
||||
return result.ok ? result.value : projectAnalysisError(result.error);
|
||||
}),
|
||||
);
|
||||
const path = z.string().describe("Local standalone APK path");
|
||||
const className = z.string().describe("Exact fully qualified class name");
|
||||
cli.command(CLI_COMMANDS.inspectAndroidPackage, {
|
||||
description:
|
||||
"Inspect APK declarations and decoded manifest without executing it",
|
||||
args: z.object({ path }),
|
||||
run: ({ args }) =>
|
||||
execute(
|
||||
CLI_COMMANDS.inspectAndroidPackage,
|
||||
"inspect_android_package",
|
||||
args,
|
||||
),
|
||||
});
|
||||
cli.command(CLI_COMMANDS.searchAndroidClasses, {
|
||||
description: "Search APK class names; empty query inventories all classes",
|
||||
args: z.object({
|
||||
path,
|
||||
query: z
|
||||
.string()
|
||||
.describe(
|
||||
"Case-sensitive class-name substring; empty lists all classes",
|
||||
),
|
||||
}),
|
||||
run: ({ args }) =>
|
||||
execute(
|
||||
CLI_COMMANDS.searchAndroidClasses,
|
||||
"search_android_classes",
|
||||
args,
|
||||
),
|
||||
});
|
||||
cli.command(CLI_COMMANDS.inspectAndroidClass, {
|
||||
description: "Inspect one exact APK class's member inventory",
|
||||
args: z.object({ path, className }),
|
||||
run: ({ args }) =>
|
||||
execute(CLI_COMMANDS.inspectAndroidClass, "inspect_android_class", {
|
||||
path: args.path,
|
||||
class_name: args.className,
|
||||
}),
|
||||
});
|
||||
cli.command(CLI_COMMANDS.inspectAndroidMethod, {
|
||||
description: "Decompile one APK method with explicit overload selection",
|
||||
args: z.object({
|
||||
path,
|
||||
className,
|
||||
methodName: z.string().describe("Exact declared method name"),
|
||||
}),
|
||||
options: z.object({
|
||||
overloadIndex: z
|
||||
.number()
|
||||
.int()
|
||||
.nonnegative()
|
||||
.optional()
|
||||
.describe(
|
||||
"Explicit zero-based index among same-name methods; required when ambiguous",
|
||||
),
|
||||
}),
|
||||
run: ({ args, options }) =>
|
||||
execute(CLI_COMMANDS.inspectAndroidMethod, "inspect_android_method", {
|
||||
path: args.path,
|
||||
class_name: args.className,
|
||||
method_name: args.methodName,
|
||||
...(options.overloadIndex === undefined
|
||||
? {}
|
||||
: { overload_index: options.overloadIndex }),
|
||||
}),
|
||||
});
|
||||
cli.command(CLI_COMMANDS.traceAndroidReferences, {
|
||||
description: "Trace incoming APK class or unique-method references",
|
||||
args: z.object({ path, className }),
|
||||
options: z.object({
|
||||
methodName: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Unique declared method name; omit for class references"),
|
||||
}),
|
||||
run: ({ args, options }) =>
|
||||
execute(CLI_COMMANDS.traceAndroidReferences, "trace_android_references", {
|
||||
path: args.path,
|
||||
class_name: args.className,
|
||||
...(options.methodName === undefined
|
||||
? {}
|
||||
: { method_name: options.methodName }),
|
||||
}),
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
/** Process signal boundary injectable without emitting signals into a test runner. */
|
||||
export interface CommandCancellationHost {
|
||||
on(signal: "SIGINT" | "SIGTERM", listener: () => void): unknown;
|
||||
off(signal: "SIGINT" | "SIGTERM", listener: () => void): unknown;
|
||||
exitCode?: number | string | null | undefined;
|
||||
}
|
||||
|
||||
/** Translate one-shot CLI interrupts into awaited cleanup and conventional exit codes. */
|
||||
export const withCommandCancellation = async <Value>(
|
||||
operation: (signal: AbortSignal) => Promise<Value>,
|
||||
host: CommandCancellationHost = process,
|
||||
): Promise<Value> => {
|
||||
const controller = new AbortController();
|
||||
let exitCode: number | undefined;
|
||||
const interrupt = () => {
|
||||
exitCode ??= 130;
|
||||
controller.abort();
|
||||
};
|
||||
const terminate = () => {
|
||||
exitCode ??= 143;
|
||||
controller.abort();
|
||||
};
|
||||
host.on("SIGINT", interrupt);
|
||||
host.on("SIGTERM", terminate);
|
||||
try {
|
||||
return await operation(controller.signal);
|
||||
} finally {
|
||||
host.off("SIGINT", interrupt);
|
||||
host.off("SIGTERM", terminate);
|
||||
if (exitCode !== undefined) host.exitCode = exitCode;
|
||||
}
|
||||
};
|
||||
@@ -32,6 +32,11 @@ export const CLI_COMMANDS = Object.freeze({
|
||||
resolveNativeCallTargets: "resolve-native-call-targets",
|
||||
inspectAssetCatalog: "inspect-asset-catalog",
|
||||
inspectManagedArtifact: "inspect-managed-artifact",
|
||||
inspectAndroidPackage: "inspect-android-package",
|
||||
searchAndroidClasses: "search-android-classes",
|
||||
inspectAndroidClass: "inspect-android-class",
|
||||
inspectAndroidMethod: "inspect-android-method",
|
||||
traceAndroidReferences: "trace-android-references",
|
||||
inspectManagedMembers: "inspect-managed-members",
|
||||
inspectManagedNativeBoundaries: "inspect-managed-native-boundaries",
|
||||
compareManagedMembers: "compare-managed-members",
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import {
|
||||
androidInputSchemas,
|
||||
androidResultSchemas,
|
||||
} from "../domain/androidAnalysis.js";
|
||||
import type { ToolContract } from "./toolContractTypes.js";
|
||||
import { evidenceResultOf } from "./toolOutputSchemaPrimitives.js";
|
||||
import { toolContractMetadata } from "./toolEffects.js";
|
||||
|
||||
/** Static APK operations backed by a separately supplied headless engine. */
|
||||
export const ANDROID_TOOL_CONTRACTS = [
|
||||
{
|
||||
name: "inspect_android_package",
|
||||
...toolContractMetadata("inspect_android_package"),
|
||||
description:
|
||||
"Inspect a local APK's package, SDK and permission declarations, decoded manifest and class/resource counts. Does not execute the APK or verify signatures. Requires a caller-supplied JADX MCP JAR and Java; uses one worker in an isolated temporary workspace.",
|
||||
kind: "android-provider",
|
||||
inputSchema: androidInputSchemas.inspect_android_package,
|
||||
outputSchema: evidenceResultOf(
|
||||
androidResultSchemas.inspect_android_package,
|
||||
),
|
||||
examples: [
|
||||
{ title: "Inspect an APK", input: { path: "/tmp/Example.apk" } },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "search_android_classes",
|
||||
...toolContractMetadata("search_android_classes"),
|
||||
description:
|
||||
"Search complete decompiler class names in an explicit local APK by case-sensitive substring. Empty query inventories all classes. Retains artifact and engine identity; no APK execution.",
|
||||
kind: "android-provider",
|
||||
inputSchema: androidInputSchemas.search_android_classes,
|
||||
outputSchema: evidenceResultOf(androidResultSchemas.search_android_classes),
|
||||
examples: [
|
||||
{
|
||||
title: "Find activity classes",
|
||||
input: { path: "/tmp/Example.apk", query: "Activity" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "inspect_android_class",
|
||||
...toolContractMetadata("inspect_android_class"),
|
||||
description:
|
||||
"Inspect one exact Android class's methods, fields and inner classes. Returns display signatures and explicit unknown DEX descriptors without materializing the whole application source.",
|
||||
kind: "android-provider",
|
||||
inputSchema: androidInputSchemas.inspect_android_class,
|
||||
outputSchema: evidenceResultOf(androidResultSchemas.inspect_android_class),
|
||||
examples: [
|
||||
{
|
||||
title: "Inspect a class",
|
||||
input: { path: "/tmp/Example.apk", class_name: "example.MainActivity" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "inspect_android_method",
|
||||
...toolContractMetadata("inspect_android_method"),
|
||||
description:
|
||||
"Decompile one Android method with smali fallback. Requires explicit overload_index when multiple methods share a name. Reports representation, fallback markers and source truncation; never treats display signatures as exact DEX identities.",
|
||||
kind: "android-provider",
|
||||
inputSchema: androidInputSchemas.inspect_android_method,
|
||||
outputSchema: evidenceResultOf(androidResultSchemas.inspect_android_method),
|
||||
examples: [
|
||||
{
|
||||
title: "Decompile a method",
|
||||
input: {
|
||||
path: "/tmp/Example.apk",
|
||||
class_name: "example.MainActivity",
|
||||
method_name: "onCreate",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "trace_android_references",
|
||||
...toolContractMetadata("trace_android_references"),
|
||||
description:
|
||||
"Trace incoming static references to an exact Android class or unique method. Overloaded method reference queries are explicitly unsupported by the current engine. Source lines and DEX offsets remain unknown; no runtime calls are inferred.",
|
||||
kind: "android-provider",
|
||||
inputSchema: androidInputSchemas.trace_android_references,
|
||||
outputSchema: evidenceResultOf(
|
||||
androidResultSchemas.trace_android_references,
|
||||
),
|
||||
examples: [
|
||||
{
|
||||
title: "Find references to a class",
|
||||
input: { path: "/tmp/Example.apk", class_name: "example.MainActivity" },
|
||||
},
|
||||
],
|
||||
},
|
||||
] as const satisfies readonly ToolContract[];
|
||||
@@ -8,6 +8,7 @@ import { OFFICIAL_TOOL_CONTRACTS } from "./officialToolContracts.js";
|
||||
import { SESSION_TOOL_CONTRACTS } from "./sessionToolContracts.js";
|
||||
import { NATIVE_TOOL_CONTRACTS } from "./nativeToolContracts.js";
|
||||
import { ARTIFACT_TOOL_CONTRACTS } from "./artifactToolContracts.js";
|
||||
import { ANDROID_TOOL_CONTRACTS } from "./androidToolContracts.js";
|
||||
import { MANAGED_TOOL_CONTRACTS } from "./managedToolContracts.js";
|
||||
import { MANAGED_WORKFLOW_TOOL_CONTRACTS } from "./managedWorkflowToolContracts.js";
|
||||
import { BROWSER_TOOL_CONTRACTS } from "./browserToolContracts.js";
|
||||
@@ -73,6 +74,7 @@ describe("tool contract surface", () => {
|
||||
...ENHANCED_TOOL_CONTRACTS,
|
||||
...NATIVE_TOOL_CONTRACTS,
|
||||
...ARTIFACT_TOOL_CONTRACTS,
|
||||
...ANDROID_TOOL_CONTRACTS,
|
||||
...MANAGED_TOOL_CONTRACTS,
|
||||
...MANAGED_WORKFLOW_TOOL_CONTRACTS,
|
||||
...BROWSER_TOOL_CONTRACTS,
|
||||
|
||||
@@ -24,6 +24,7 @@ export const TOOL_KINDS = [
|
||||
"native-provider",
|
||||
"artifact-provider",
|
||||
"managed-provider",
|
||||
"android-provider",
|
||||
"browser-provider",
|
||||
"electron-provider",
|
||||
"runtime-provider",
|
||||
|
||||
@@ -6,6 +6,7 @@ import { SESSION_TOOL_CONTRACTS } from "./sessionToolContracts.js";
|
||||
import { TOOL_CONTRACTS } from "./toolContracts.js";
|
||||
import { ARTIFACT_TOOL_CONTRACTS } from "./artifactToolContracts.js";
|
||||
import { MANAGED_TOOL_CONTRACTS } from "./managedToolContracts.js";
|
||||
import { ANDROID_TOOL_CONTRACTS } from "./androidToolContracts.js";
|
||||
import { MANAGED_WORKFLOW_TOOL_CONTRACTS } from "./managedWorkflowToolContracts.js";
|
||||
import { NATIVE_TOOL_CONTRACTS } from "./nativeToolContracts.js";
|
||||
import { BROWSER_PROVIDER_TOOL_CONTRACTS } from "./browserProviderToolContracts.js";
|
||||
@@ -19,6 +20,7 @@ const GROUPS = {
|
||||
native: NATIVE_TOOL_CONTRACTS,
|
||||
artifact: ARTIFACT_TOOL_CONTRACTS,
|
||||
managed: MANAGED_TOOL_CONTRACTS,
|
||||
android: ANDROID_TOOL_CONTRACTS,
|
||||
managed_workflow: MANAGED_WORKFLOW_TOOL_CONTRACTS,
|
||||
browser_provider: BROWSER_PROVIDER_TOOL_CONTRACTS,
|
||||
electron: ELECTRON_TOOL_CONTRACTS,
|
||||
@@ -91,6 +93,11 @@ const PUBLISHED_TOOL_NAME_FLOOR = [
|
||||
"inspect_electron_page",
|
||||
"inspect_macho",
|
||||
"inspect_managed_artifact",
|
||||
"inspect_android_package",
|
||||
"search_android_classes",
|
||||
"inspect_android_class",
|
||||
"inspect_android_method",
|
||||
"trace_android_references",
|
||||
"inspect_managed_members",
|
||||
"inspect_managed_native_boundaries",
|
||||
"inspect_native_api",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NATIVE_TOOL_CONTRACTS } from "./nativeToolContracts.js";
|
||||
import { ARTIFACT_TOOL_CONTRACTS } from "./artifactToolContracts.js";
|
||||
import { MANAGED_TOOL_CONTRACTS } from "./managedToolContracts.js";
|
||||
import { ANDROID_TOOL_CONTRACTS } from "./androidToolContracts.js";
|
||||
import { MANAGED_WORKFLOW_TOOL_CONTRACTS } from "./managedWorkflowToolContracts.js";
|
||||
import { BROWSER_PROVIDER_TOOL_CONTRACTS } from "./browserProviderToolContracts.js";
|
||||
import { ELECTRON_TOOL_CONTRACTS } from "./electronToolContracts.js";
|
||||
@@ -19,6 +20,7 @@ export const TOOL_CONTRACTS = [
|
||||
...NATIVE_TOOL_CONTRACTS,
|
||||
...ARTIFACT_TOOL_CONTRACTS,
|
||||
...MANAGED_TOOL_CONTRACTS,
|
||||
...ANDROID_TOOL_CONTRACTS,
|
||||
...MANAGED_WORKFLOW_TOOL_CONTRACTS,
|
||||
...BROWSER_PROVIDER_TOOL_CONTRACTS,
|
||||
...ELECTRON_TOOL_CONTRACTS,
|
||||
|
||||
@@ -41,6 +41,31 @@ const sessionEvidence = effects({ mutatesSession: true });
|
||||
|
||||
/** Explicit effect audit for every public tool. */
|
||||
export const TOOL_EFFECTS: Readonly<Record<string, ToolEffects>> = {
|
||||
inspect_android_package: effects({
|
||||
mutatesSession: true,
|
||||
launchesProcess: true,
|
||||
writesFilesystem: true,
|
||||
}),
|
||||
search_android_classes: effects({
|
||||
mutatesSession: true,
|
||||
launchesProcess: true,
|
||||
writesFilesystem: true,
|
||||
}),
|
||||
inspect_android_class: effects({
|
||||
mutatesSession: true,
|
||||
launchesProcess: true,
|
||||
writesFilesystem: true,
|
||||
}),
|
||||
inspect_android_method: effects({
|
||||
mutatesSession: true,
|
||||
launchesProcess: true,
|
||||
writesFilesystem: true,
|
||||
}),
|
||||
trace_android_references: effects({
|
||||
mutatesSession: true,
|
||||
launchesProcess: true,
|
||||
writesFilesystem: true,
|
||||
}),
|
||||
annotate_native_function: effects({
|
||||
mutatesTarget: true,
|
||||
mutatesSession: true,
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const target = {
|
||||
path: z
|
||||
.string()
|
||||
.min(1)
|
||||
.describe("Local APK path; the target is never executed"),
|
||||
};
|
||||
const classTarget = {
|
||||
...target,
|
||||
class_name: z.string().min(1).describe("Exact fully qualified class name"),
|
||||
};
|
||||
|
||||
/** Caller intent for static Android inspection, independent of its engine. */
|
||||
export const androidInputSchemas = {
|
||||
inspect_android_package: z.strictObject(target),
|
||||
search_android_classes: z.strictObject({
|
||||
...target,
|
||||
query: z
|
||||
.string()
|
||||
.describe("Case-sensitive class-name substring; empty lists all classes"),
|
||||
}),
|
||||
inspect_android_class: z.strictObject(classTarget),
|
||||
inspect_android_method: z.strictObject({
|
||||
...classTarget,
|
||||
method_name: z.string().min(1),
|
||||
overload_index: z
|
||||
.number()
|
||||
.int()
|
||||
.nonnegative()
|
||||
.optional()
|
||||
.describe(
|
||||
"Explicit index among same-name methods; required when ambiguous",
|
||||
),
|
||||
}),
|
||||
trace_android_references: z.strictObject({
|
||||
...classTarget,
|
||||
method_name: z
|
||||
.string()
|
||||
.min(1)
|
||||
.optional()
|
||||
.describe(
|
||||
"Omit for class references; ambiguous overloaded methods are unsupported",
|
||||
),
|
||||
}),
|
||||
} as const;
|
||||
|
||||
/** Supported static Android operations. */
|
||||
export type AndroidOperation = keyof typeof androidInputSchemas;
|
||||
/** Validated Android requests carry their selected operation. */
|
||||
export type AndroidRequest = {
|
||||
[Name in AndroidOperation]: {
|
||||
operation: Name;
|
||||
input: z.infer<(typeof androidInputSchemas)[Name]>;
|
||||
};
|
||||
}[AndroidOperation];
|
||||
|
||||
/** Validate operation and input together so their types remain correlated. */
|
||||
export const androidRequestSchema = z.discriminatedUnion("operation", [
|
||||
z.strictObject({
|
||||
operation: z.literal("inspect_android_package"),
|
||||
input: androidInputSchemas.inspect_android_package,
|
||||
}),
|
||||
z.strictObject({
|
||||
operation: z.literal("search_android_classes"),
|
||||
input: androidInputSchemas.search_android_classes,
|
||||
}),
|
||||
z.strictObject({
|
||||
operation: z.literal("inspect_android_class"),
|
||||
input: androidInputSchemas.inspect_android_class,
|
||||
}),
|
||||
z.strictObject({
|
||||
operation: z.literal("inspect_android_method"),
|
||||
input: androidInputSchemas.inspect_android_method,
|
||||
}),
|
||||
z.strictObject({
|
||||
operation: z.literal("trace_android_references"),
|
||||
input: androidInputSchemas.trace_android_references,
|
||||
}),
|
||||
]);
|
||||
|
||||
const textCoverage = z.strictObject({
|
||||
status: z.enum(["complete", "partial"]),
|
||||
text: z.string(),
|
||||
reported_total_bytes: z.number().int().nonnegative().nullable(),
|
||||
});
|
||||
const method = z.strictObject({
|
||||
name: z.string(),
|
||||
reported_signature: z.string(),
|
||||
overload_index: z.number().int().nonnegative(),
|
||||
is_constructor: z.boolean(),
|
||||
dex_descriptor: z.null(),
|
||||
});
|
||||
const engine = z.strictObject({
|
||||
name: z.string().min(1),
|
||||
version: z.string().nullable(),
|
||||
artifact_sha256: z.string().regex(/^[a-f0-9]{64}$/u),
|
||||
source_revision: z.string().nullable(),
|
||||
worker_count: z.number().int().positive(),
|
||||
heap_limit_mib: z.number().int().positive().nullable(),
|
||||
});
|
||||
const common = { engine };
|
||||
|
||||
/** Portable observations with explicit decompiler and identity limitations. */
|
||||
export const androidResultSchemas = {
|
||||
inspect_android_package: z.strictObject({
|
||||
...common,
|
||||
package_name: z.string().nullable(),
|
||||
version_name: z.string().nullable(),
|
||||
version_code: z.string().nullable(),
|
||||
min_sdk: z.string().nullable(),
|
||||
target_sdk: z.string().nullable(),
|
||||
permissions: z.array(z.string()),
|
||||
manifest: textCoverage,
|
||||
class_count: z.number().int().nonnegative(),
|
||||
resource_count: z.number().int().nonnegative(),
|
||||
signature_verification: z.literal("not_performed"),
|
||||
}),
|
||||
search_android_classes: z.strictObject({
|
||||
...common,
|
||||
query: z.string(),
|
||||
total_classes: z.number().int().nonnegative(),
|
||||
matches: z.array(z.string()),
|
||||
coverage: z.literal("complete"),
|
||||
}),
|
||||
inspect_android_class: z.strictObject({
|
||||
...common,
|
||||
class_name: z.string(),
|
||||
methods: z.array(method),
|
||||
fields: z.array(
|
||||
z.strictObject({ name: z.string(), reported_type: z.string() }),
|
||||
),
|
||||
inner_classes: z.array(z.string()),
|
||||
}),
|
||||
inspect_android_method: z.strictObject({
|
||||
...common,
|
||||
class_name: z.string(),
|
||||
method: method,
|
||||
overload_count: z.number().int().positive(),
|
||||
representation: z.enum(["java", "smali"]),
|
||||
body_status: z.enum(["available", "not_available"]),
|
||||
fell_back: z.boolean(),
|
||||
markers: z.array(z.string()),
|
||||
source: textCoverage,
|
||||
}),
|
||||
trace_android_references: z.strictObject({
|
||||
...common,
|
||||
class_name: z.string(),
|
||||
method_name: z.string().nullable(),
|
||||
total: z.number().int().nonnegative(),
|
||||
references: z.array(
|
||||
z.strictObject({
|
||||
kind: z.string(),
|
||||
name: z.string(),
|
||||
reported_full_name: z.string(),
|
||||
containing_class: z.string(),
|
||||
top_class: z.string(),
|
||||
source_line: z.null(),
|
||||
dex_offset: z.null(),
|
||||
}),
|
||||
),
|
||||
coverage: z.literal("complete"),
|
||||
}),
|
||||
} as const;
|
||||
@@ -30,7 +30,7 @@ interface GeneratedPayload {
|
||||
}
|
||||
|
||||
const GENERATED_PAYLOAD_JSON =
|
||||
'{\n "auxiliaryProviders": [\n {\n "capabilities": [\n {\n "available": true,\n "effects": {\n "changesPermissions": false,\n "launchesProcess": true,\n "mayAccessNetwork": false,\n "mayShowUi": false,\n "mayWriteFilesystem": true,\n "mutatesArtifact": false,\n "requiresRoot": false\n },\n "limitations": [\n "DMG child inventory automatically uses a read-only native macOS mount; PKG remains root-hash-only.",\n "ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."\n ],\n "operation": "inventory_artifact",\n "provider": {\n "id": "rea-artifact-graph",\n "name": "REA safe artifact graph provider",\n "version": "1"\n },\n "reason": null\n },\n {\n "available": true,\n "effects": {\n "changesPermissions": false,\n "launchesProcess": true,\n "mayAccessNetwork": false,\n "mayShowUi": false,\n "mayWriteFilesystem": true,\n "mutatesArtifact": false,\n "requiresRoot": false\n },\n "limitations": [\n "DMG child inventory automatically uses a read-only native macOS mount; PKG remains root-hash-only.",\n "ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."\n ],\n "operation": "inspect_artifact",\n "provider": {\n "id": "rea-artifact-graph",\n "name": "REA safe artifact graph provider",\n "version": "1"\n },\n "reason": null\n },\n {\n "available": true,\n "effects": {\n "changesPermissions": false,\n "launchesProcess": true,\n "mayAccessNetwork": false,\n "mayShowUi": false,\n "mayWriteFilesystem": true,\n "mutatesArtifact": false,\n "requiresRoot": false\n },\n "limitations": [\n "DMG child inventory automatically uses a read-only native macOS mount; PKG remains root-hash-only.",\n "ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."\n ],\n "operation": "extract_artifact",\n "provider": {\n "id": "rea-artifact-graph",\n "name": "REA safe artifact graph provider",\n "version": "1"\n },\n "reason": null\n },\n {\n "available": true,\n "effects": {\n "changesPermissions": false,\n "launchesProcess": false,\n "mayAccessNetwork": false,\n "mayShowUi": false,\n "mayWriteFilesystem": false,\n "mutatesArtifact": false,\n "requiresRoot": false\n },\n "limitations": [\n "DMG child inventory automatically uses a read-only native macOS mount; PKG remains root-hash-only.",\n "ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."\n ],\n "operation": "decode_interface_builder",\n "provider": {\n "id": "rea-artifact-graph",\n "name": "REA safe artifact graph provider",\n "version": "1"\n },\n "reason": null\n },\n {\n "available": true,\n "effects": {\n "changesPermissions": false,\n "launchesProcess": false,\n "mayAccessNetwork": false,\n "mayShowUi": false,\n "mayWriteFilesystem": false,\n "mutatesArtifact": false,\n "requiresRoot": false\n },\n "limitations": [\n "DMG child inventory automatically uses a read-only native macOS mount; PKG remains root-hash-only.",\n "ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."\n ],\n "operation": "inspect_keyed_archive",\n "provider": {\n "id": "rea-artifact-graph",\n "name": "REA safe artifact graph provider",\n "version": "1"\n },\n "reason": null\n },\n {\n "available": true,\n "effects": {\n "changesPermissions": false,\n "launchesProcess": true,\n "mayAccessNetwork": false,\n "mayShowUi": false,\n "mayWriteFilesystem": false,\n "mutatesArtifact": false,\n "requiresRoot": false\n },\n "limitations": [\n "DLine truncated
|
||||
'{\n "auxiliaryProviders": [\n {\n "capabilities": [\n {\n "available": true,\n "effects": {\n "changesPermissions": false,\n "launchesProcess": true,\n "mayAccessNetwork": false,\n "mayShowUi": false,\n "mayWriteFilesystem": true,\n "mutatesArtifact": false,\n "requiresRoot": false\n },\n "limitations": [\n "DMG child inventory automatically uses a read-only native macOS mount; PKG remains root-hash-only.",\n "ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."\n ],\n "operation": "inventory_artifact",\n "provider": {\n "id": "rea-artifact-graph",\n "name": "REA safe artifact graph provider",\n "version": "1"\n },\n "reason": null\n },\n {\n "available": true,\n "effects": {\n "changesPermissions": false,\n "launchesProcess": true,\n "mayAccessNetwork": false,\n "mayShowUi": false,\n "mayWriteFilesystem": true,\n "mutatesArtifact": false,\n "requiresRoot": false\n },\n "limitations": [\n "DMG child inventory automatically uses a read-only native macOS mount; PKG remains root-hash-only.",\n "ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."\n ],\n "operation": "inspect_artifact",\n "provider": {\n "id": "rea-artifact-graph",\n "name": "REA safe artifact graph provider",\n "version": "1"\n },\n "reason": null\n },\n {\n "available": true,\n "effects": {\n "changesPermissions": false,\n "launchesProcess": true,\n "mayAccessNetwork": false,\n "mayShowUi": false,\n "mayWriteFilesystem": true,\n "mutatesArtifact": false,\n "requiresRoot": false\n },\n "limitations": [\n "DMG child inventory automatically uses a read-only native macOS mount; PKG remains root-hash-only.",\n "ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."\n ],\n "operation": "extract_artifact",\n "provider": {\n "id": "rea-artifact-graph",\n "name": "REA safe artifact graph provider",\n "version": "1"\n },\n "reason": null\n },\n {\n "available": true,\n "effects": {\n "changesPermissions": false,\n "launchesProcess": false,\n "mayAccessNetwork": false,\n "mayShowUi": false,\n "mayWriteFilesystem": false,\n "mutatesArtifact": false,\n "requiresRoot": false\n },\n "limitations": [\n "DMG child inventory automatically uses a read-only native macOS mount; PKG remains root-hash-only.",\n "ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."\n ],\n "operation": "decode_interface_builder",\n "provider": {\n "id": "rea-artifact-graph",\n "name": "REA safe artifact graph provider",\n "version": "1"\n },\n "reason": null\n },\n {\n "available": true,\n "effects": {\n "changesPermissions": false,\n "launchesProcess": false,\n "mayAccessNetwork": false,\n "mayShowUi": false,\n "mayWriteFilesystem": false,\n "mutatesArtifact": false,\n "requiresRoot": false\n },\n "limitations": [\n "DMG child inventory automatically uses a read-only native macOS mount; PKG remains root-hash-only.",\n "ASAR files discovered in filesystem-backed inventories are expanded without bulk extraction; other nested containers remain recorded only."\n ],\n "operation": "inspect_keyed_archive",\n "provider": {\n "id": "rea-artifact-graph",\n "name": "REA safe artifact graph provider",\n "version": "1"\n },\n "reason": null\n },\n {\n "available": true,\n "effects": {\n "changesPermissions": false,\n "launchesProcess": true,\n "mayAccessNetwork": false,\n "mayShowUi": false,\n "mayWriteFilesystem": false,\n "mutatesArtifact": false,\n "requiresRoot": false\n },\n "limitations": [\n "DLine truncated
|
||||
const generatedPayload: unknown = JSON.parse(GENERATED_PAYLOAD_JSON);
|
||||
if (!isGeneratedPayload(generatedPayload))
|
||||
throw new TypeError("Generated MCP catalog payload is invalid");
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import type { EventEmitter } from "node:events";
|
||||
import type { Readable } from "node:stream";
|
||||
import type { Readable, Writable } from "node:stream";
|
||||
|
||||
import { WindowsOwnedProcess } from "../windows/WindowsOwnedProcess.js";
|
||||
|
||||
@@ -27,6 +27,8 @@ export interface OwnedProviderProcessSpawnOptions {
|
||||
readonly platform?: NodeJS.Platform;
|
||||
/** Host environment used as the base for the child environment. */
|
||||
readonly hostEnvironment?: NodeJS.ProcessEnv;
|
||||
/** Opt into a writable protocol stream; other providers retain ignored stdin. */
|
||||
readonly stdin?: "pipe";
|
||||
}
|
||||
|
||||
/** Spawned process paired with the identity proof required for group cleanup. */
|
||||
@@ -43,6 +45,7 @@ export interface ProviderProcessHandle extends Pick<
|
||||
> {
|
||||
readonly pid?: number | undefined;
|
||||
readonly stdout: Readable | null;
|
||||
readonly stdin?: Writable | null;
|
||||
readonly stderr: Readable | null;
|
||||
readonly exitCode: number | null;
|
||||
readonly signalCode: NodeJS.Signals | null;
|
||||
@@ -135,6 +138,10 @@ export const spawnOwnedProviderProcess = async (
|
||||
REA_PROCESS_RUN_ID: options.runId,
|
||||
};
|
||||
if (platform === "win32" && process.platform === "win32") {
|
||||
if (options.stdin === "pipe")
|
||||
throw new Error(
|
||||
"Owned Windows provider processes do not support protocol stdin",
|
||||
);
|
||||
const child = new WindowsOwnedProcess(
|
||||
options.command,
|
||||
options.arguments,
|
||||
@@ -156,7 +163,7 @@ export const spawnOwnedProviderProcess = async (
|
||||
const child = spawn(options.command, [...options.arguments], {
|
||||
shell: false,
|
||||
windowsHide: true,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
stdio: [options.stdin ?? "ignore", "pipe", "pipe"],
|
||||
detached: platform !== "win32",
|
||||
windowsVerbatimArguments: options.windowsVerbatimArguments ?? false,
|
||||
env: environment,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { McpServer } from "@modelcontextprotocol/server";
|
||||
import { isAbsolute } from "node:path";
|
||||
|
||||
import type { AnalysisOperationPort } from "../application/AnalysisProvider.js";
|
||||
import type { BinarySessionPort } from "../application/BinarySession.js";
|
||||
@@ -17,6 +18,10 @@ import { registerElectronTools } from "./registerElectronTools.js";
|
||||
import { registerEnhancedTools } from "./registerEnhancedTools.js";
|
||||
import { registerJavaScriptRuntimeObservationTools } from "./registerJavaScriptRuntimeObservationTools.js";
|
||||
import { registerManagedTools } from "./registerManagedTools.js";
|
||||
import { registerAndroidTools } from "./registerAndroidTools.js";
|
||||
import { AndroidAnalysisService } from "../application/AndroidAnalysisService.js";
|
||||
import type { AndroidAnalysisPort } from "../application/AndroidAnalysisPort.js";
|
||||
import { JadxProvider } from "../android/JadxProvider.js";
|
||||
import { registerManagedWorkflowTools } from "./registerManagedWorkflowTools.js";
|
||||
import { registerNativeTools } from "./registerNativeTools.js";
|
||||
import { registerOfficialTools } from "./registerOfficialTools.js";
|
||||
@@ -33,6 +38,7 @@ const ACTIVE_TARGET_INSTRUCTIONS =
|
||||
|
||||
export interface CreateServerOptions {
|
||||
readonly logger?: Logger;
|
||||
readonly androidAnalysis?: AndroidAnalysisPort;
|
||||
readonly browserObservation?: BrowserObservationPort;
|
||||
readonly browserScenarioCapture?: BrowserScenarioCapturePort;
|
||||
readonly electronObservation?: ElectronObservationPort;
|
||||
@@ -49,6 +55,10 @@ const installSessionToolAvailability = (
|
||||
if (session === undefined) return undefined;
|
||||
const policy = sessionAvailabilityPolicy(options.availabilityPolicy, {
|
||||
optionalFeatures: {
|
||||
androidAnalysisEnabled:
|
||||
options.androidAnalysis !== undefined ||
|
||||
((process.platform === "linux" || process.platform === "darwin") &&
|
||||
isAbsolute(process.env.REA_JADX_MCP_JAR ?? "")),
|
||||
browserObservationEnabled: options.browserObservation !== undefined,
|
||||
browserScenarioEnabled: options.browserScenarioCapture !== undefined,
|
||||
electronObservationEnabled: options.electronObservation !== undefined,
|
||||
@@ -111,6 +121,12 @@ export const createServer = (
|
||||
recordEvidenceWithUnknown,
|
||||
};
|
||||
registerBinaryAnalysisTools(toolContext);
|
||||
registerAndroidTools(
|
||||
server,
|
||||
new AndroidAnalysisService(options.androidAnalysis ?? new JadxProvider()),
|
||||
toolLogger,
|
||||
recordEvidence,
|
||||
);
|
||||
registerObservationTools(toolContext);
|
||||
registerGuidedPrompts(server, analysis, session);
|
||||
if (session !== undefined) {
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { McpServer, ServerContext } from "@modelcontextprotocol/server";
|
||||
import { AndroidAnalysisService } from "../application/AndroidAnalysisService.js";
|
||||
import type { BinarySessionPort } from "../application/BinarySession.js";
|
||||
import { toolContract } from "../contracts/toolContracts.js";
|
||||
import type { ToolContract } from "../contracts/toolContractTypes.js";
|
||||
import type { AndroidOperation } from "../domain/androidAnalysis.js";
|
||||
import type { Logger } from "../logger.js";
|
||||
import { logToolExecution } from "./toolLogging.js";
|
||||
import { toolRegistrationOptions } from "./toolRegistrationOptions.js";
|
||||
import { toCallToolResult } from "./toolResult.js";
|
||||
|
||||
/** Bind Android handlers to their exact named contracts through shared workflows. */
|
||||
export const registerAndroidTools = (
|
||||
server: McpServer,
|
||||
service: AndroidAnalysisService,
|
||||
logger: Logger,
|
||||
recordEvidence?: BinarySessionPort["recordEvidence"],
|
||||
): void => {
|
||||
const handler = (contract: ToolContract<AndroidOperation>) => {
|
||||
const name = contract.name;
|
||||
return async (input: unknown, context: ServerContext) => {
|
||||
const result = await logToolExecution(logger, name, () =>
|
||||
service.execute(name, input, { signal: context.mcpReq.signal }),
|
||||
);
|
||||
if (!result.ok) return toCallToolResult(result, contract);
|
||||
const recorded = recordEvidence?.(result.value);
|
||||
return recorded !== undefined && !recorded.ok
|
||||
? toCallToolResult(recorded, contract)
|
||||
: toCallToolResult(result, contract);
|
||||
};
|
||||
};
|
||||
const packageContract = toolContract("inspect_android_package");
|
||||
server.registerTool(
|
||||
packageContract.name,
|
||||
toolRegistrationOptions(packageContract),
|
||||
handler(packageContract),
|
||||
);
|
||||
const searchContract = toolContract("search_android_classes");
|
||||
server.registerTool(
|
||||
searchContract.name,
|
||||
toolRegistrationOptions(searchContract),
|
||||
handler(searchContract),
|
||||
);
|
||||
const classContract = toolContract("inspect_android_class");
|
||||
server.registerTool(
|
||||
classContract.name,
|
||||
toolRegistrationOptions(classContract),
|
||||
handler(classContract),
|
||||
);
|
||||
const methodContract = toolContract("inspect_android_method");
|
||||
server.registerTool(
|
||||
methodContract.name,
|
||||
toolRegistrationOptions(methodContract),
|
||||
handler(methodContract),
|
||||
);
|
||||
const referencesContract = toolContract("trace_android_references");
|
||||
server.registerTool(
|
||||
referencesContract.name,
|
||||
toolRegistrationOptions(referencesContract),
|
||||
handler(referencesContract),
|
||||
);
|
||||
};
|
||||
@@ -11,6 +11,7 @@ export interface SessionAvailabilityDefaults {
|
||||
| "electronObservationEnabled"
|
||||
| "electronAutomationEnabled"
|
||||
| "v8InspectorObservationEnabled"
|
||||
| "androidAnalysisEnabled"
|
||||
>;
|
||||
}
|
||||
|
||||
@@ -22,6 +23,8 @@ export const sessionAvailabilityPolicy = (
|
||||
configured ??
|
||||
(() => ({
|
||||
processCaptureEnabled: platform !== "win32",
|
||||
androidAnalysisEnabled:
|
||||
defaults.optionalFeatures?.androidAnalysisEnabled ?? false,
|
||||
browserObservationEnabled:
|
||||
defaults.optionalFeatures?.browserObservationEnabled ?? false,
|
||||
browserScenarioEnabled:
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
import { expect, it as test } from "vitest";
|
||||
import { access, readFile, writeFile } from "node:fs/promises";
|
||||
import { parseBinaryTarget } from "../../../src/application/BinaryTargetResolver.js";
|
||||
import { AndroidAnalysisService } from "../../../src/application/AndroidAnalysisService.js";
|
||||
import { JadxProvider } from "../../../src/android/JadxProvider.js";
|
||||
import { androidResultSchemas } from "../../../src/domain/androidAnalysis.js";
|
||||
import {
|
||||
createJadxProtocolFixture as setup,
|
||||
verifyJadxFixtureCleanup as verifyCleanup,
|
||||
} from "../../fixtures/android/jadx.js";
|
||||
|
||||
const it = test.skipIf(process.platform === "win32");
|
||||
|
||||
it("retains APK identity, original input, raw producer data and normalized observations", async () => {
|
||||
const { service, apk, launches } = await setup();
|
||||
const outcome = await service.execute("inspect_android_package", {
|
||||
path: apk,
|
||||
});
|
||||
expect(outcome.ok).toBe(true);
|
||||
if (!outcome.ok) throw outcome.error;
|
||||
const result = androidResultSchemas.inspect_android_package.parse(
|
||||
outcome.value.normalized_result,
|
||||
);
|
||||
expect(result.package_name).toBe("fixture");
|
||||
expect(result.engine.source_revision).toBeNull();
|
||||
expect(outcome.value.subject?.local_path).toBe(apk);
|
||||
expect(outcome.value.parameters).toEqual({ path: apk });
|
||||
expect(outcome.value.raw_result).toMatchObject({
|
||||
calls: expect.arrayContaining([
|
||||
expect.objectContaining({ operation: "get_android_manifest" }),
|
||||
]),
|
||||
});
|
||||
await verifyCleanup(launches);
|
||||
});
|
||||
|
||||
it("fetches all class pages rather than silently retaining the first upstream page", async () => {
|
||||
const { service, apk, launches } = await setup();
|
||||
const outcome = await service.execute("search_android_classes", {
|
||||
path: apk,
|
||||
query: "",
|
||||
});
|
||||
if (!outcome.ok) throw outcome.error;
|
||||
const result = androidResultSchemas.search_android_classes.parse(
|
||||
outcome.value.normalized_result,
|
||||
);
|
||||
expect(result.total_classes).toBe(401);
|
||||
expect(result.matches).toHaveLength(401);
|
||||
expect(result.matches.at(-1)).toBe("fixture.Class399");
|
||||
await verifyCleanup(launches);
|
||||
});
|
||||
|
||||
it("exposes overload candidates and executes only an explicitly selected ambiguous method", async () => {
|
||||
const { service, apk, launches } = await setup();
|
||||
const input = {
|
||||
path: apk,
|
||||
class_name: "fixture.Target",
|
||||
method_name: "choose",
|
||||
};
|
||||
const ambiguous = await service.execute("inspect_android_method", input);
|
||||
expect(ambiguous).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
_tag: "AnalysisInputError",
|
||||
issues: [
|
||||
{
|
||||
path: ["overload_index"],
|
||||
message: expect.stringContaining("1: choose(int)"),
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const selected = await service.execute("inspect_android_method", {
|
||||
...input,
|
||||
overload_index: 1,
|
||||
});
|
||||
if (!selected.ok) throw selected.error;
|
||||
const result = androidResultSchemas.inspect_android_method.parse(
|
||||
selected.value.normalized_result,
|
||||
);
|
||||
expect(result.method).toMatchObject({
|
||||
overload_index: 1,
|
||||
reported_signature: "choose(int): void",
|
||||
dex_descriptor: null,
|
||||
});
|
||||
expect(result.source.text).toContain("overload 1");
|
||||
const references = await service.execute("trace_android_references", input);
|
||||
expect(references).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
_tag: "AnalysisCapabilityUnavailableError",
|
||||
reason: expect.stringContaining("first same-name"),
|
||||
},
|
||||
});
|
||||
await verifyCleanup(launches);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"wrong-apk",
|
||||
"empty-page",
|
||||
"summary-error",
|
||||
"wrong-method",
|
||||
"body-error",
|
||||
"partial-xrefs",
|
||||
])(
|
||||
"rejects producer failure %s rather than claiming complete analysis",
|
||||
async (mode) => {
|
||||
const { service, apk, launches } = await setup(mode);
|
||||
const operation =
|
||||
mode === "wrong-apk"
|
||||
? "inspect_android_package"
|
||||
: mode === "empty-page"
|
||||
? "search_android_classes"
|
||||
: mode === "partial-xrefs"
|
||||
? "trace_android_references"
|
||||
: "inspect_android_method";
|
||||
const outcome = await service.execute(operation, {
|
||||
path: apk,
|
||||
...(operation === "search_android_classes"
|
||||
? { query: "" }
|
||||
: operation === "inspect_android_package"
|
||||
? {}
|
||||
: { class_name: "fixture.Target", method_name: "onCreate" }),
|
||||
});
|
||||
expect(outcome).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "AnalysisOutputError" },
|
||||
});
|
||||
await verifyCleanup(launches);
|
||||
},
|
||||
);
|
||||
|
||||
it("rejects fuzzy class resolution and unsupported upstream versions", async () => {
|
||||
for (const mode of ["wrong-class", "version"]) {
|
||||
const { service, apk, launches } = await setup(mode);
|
||||
const outcome = await service.execute("inspect_android_class", {
|
||||
path: apk,
|
||||
class_name: "fixture.Target",
|
||||
});
|
||||
expect(outcome).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
_tag:
|
||||
mode === "version"
|
||||
? "AnalysisCapabilityUnavailableError"
|
||||
: "AnalysisInputError",
|
||||
},
|
||||
});
|
||||
await verifyCleanup(launches);
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["truncated", "smali"])(
|
||||
"retains %s source coverage instead of presenting it as successful complete Java",
|
||||
async (mode) => {
|
||||
const { service, apk, launches } = await setup(mode);
|
||||
const outcome = await service.execute("inspect_android_method", {
|
||||
path: apk,
|
||||
class_name: "fixture.Target",
|
||||
method_name: "onCreate",
|
||||
});
|
||||
if (!outcome.ok) throw outcome.error;
|
||||
const result = androidResultSchemas.inspect_android_method.parse(
|
||||
outcome.value.normalized_result,
|
||||
);
|
||||
expect(result.source.status).toBe(
|
||||
mode === "truncated" ? "partial" : "complete",
|
||||
);
|
||||
expect(result.representation).toBe(mode === "smali" ? "smali" : "java");
|
||||
if (mode === "truncated")
|
||||
expect(result.source.reported_total_bytes).toBe(100);
|
||||
else expect(result.fell_back).toBe(true);
|
||||
await verifyCleanup(launches);
|
||||
},
|
||||
);
|
||||
|
||||
it("cancels active and queued requests, cleans ownership and never launches a queued cancelled request", async () => {
|
||||
const { service, apk, launches } = await setup("stall");
|
||||
const activeController = new AbortController();
|
||||
const active = service.execute(
|
||||
"inspect_android_package",
|
||||
{ path: apk },
|
||||
{ signal: activeController.signal },
|
||||
);
|
||||
await expect.poll(() => launches.length).toBe(1);
|
||||
const queuedController = new AbortController();
|
||||
const queued = service.execute(
|
||||
"inspect_android_package",
|
||||
{ path: apk },
|
||||
{ signal: queuedController.signal },
|
||||
);
|
||||
queuedController.abort();
|
||||
expect(await queued).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "AnalysisCancelledError" },
|
||||
});
|
||||
expect(launches).toHaveLength(1);
|
||||
activeController.abort();
|
||||
expect(await active).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "AnalysisCancelledError" },
|
||||
});
|
||||
await verifyCleanup(launches);
|
||||
});
|
||||
|
||||
it("reports missing explicit tools without trying to install or launch them", async () => {
|
||||
const { apk } = await setup();
|
||||
const provider = new JadxProvider({}, () => {
|
||||
throw new Error("must not launch");
|
||||
});
|
||||
const outcome = await new AndroidAnalysisService(provider).execute(
|
||||
"inspect_android_package",
|
||||
{ path: apk },
|
||||
);
|
||||
expect(outcome).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
_tag: "AnalysisCapabilityUnavailableError",
|
||||
reason: expect.stringContaining("REA_JADX_MCP_JAR"),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["tool-error", "frame-overflow"])(
|
||||
"preserves the actual failed provider constraint for %s",
|
||||
async (mode) => {
|
||||
const { service, apk, launches } = await setup(mode);
|
||||
const outcome = await service.execute("inspect_android_package", {
|
||||
path: apk,
|
||||
});
|
||||
if (outcome.ok) throw new Error("Expected a provider failure");
|
||||
if (mode === "tool-error")
|
||||
expect(outcome.error).toMatchObject({
|
||||
_tag: "ProviderAdapterError",
|
||||
diagnostics: {
|
||||
reason: "manifest decoder rejected malformed binary XML",
|
||||
},
|
||||
});
|
||||
else
|
||||
expect(outcome.error).toMatchObject({
|
||||
_tag: "AnalysisOutputError",
|
||||
reason: expect.stringContaining("MCP frame exceeds"),
|
||||
});
|
||||
await verifyCleanup(launches);
|
||||
},
|
||||
);
|
||||
|
||||
it("rejects bytes changed after target admission without launching an engine", async () => {
|
||||
const { provider, apk, launches } = await setup();
|
||||
const target = await parseBinaryTarget(apk);
|
||||
if (!target.ok) throw target.error;
|
||||
await writeFile(
|
||||
apk,
|
||||
Buffer.concat([
|
||||
await readFile(apk),
|
||||
Buffer.from("changed after admission"),
|
||||
]),
|
||||
);
|
||||
const outcome = await provider.execute(target.value, {
|
||||
operation: "inspect_android_package",
|
||||
input: { path: apk },
|
||||
});
|
||||
expect(outcome).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
_tag: "AnalysisInputError",
|
||||
issues: [{ message: expect.stringContaining("bytes changed") }],
|
||||
},
|
||||
});
|
||||
expect(launches).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("retains an uncertain workspace and blocks later launches after cleanup cannot be verified", async () => {
|
||||
const { service, apk, launches } = await setup("cleanup-failure");
|
||||
const outcome = await service.execute("inspect_android_package", {
|
||||
path: apk,
|
||||
});
|
||||
expect(outcome).toMatchObject({
|
||||
ok: false,
|
||||
error: { cleanupIncomplete: true },
|
||||
});
|
||||
const workspace = launches[0]?.cwd;
|
||||
if (workspace === undefined) throw new Error("Expected acquired workspace");
|
||||
await expect(access(workspace)).resolves.toBeUndefined();
|
||||
expect(
|
||||
await service.execute("inspect_android_package", { path: apk }),
|
||||
).toMatchObject({ ok: false, error: { cleanupIncomplete: true } });
|
||||
expect(launches).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("does not attribute upstream smali containing all overloads to the selected method", async () => {
|
||||
const { service, apk, launches } = await setup("overloaded-smali");
|
||||
const outcome = await service.execute("inspect_android_method", {
|
||||
path: apk,
|
||||
class_name: "fixture.Target",
|
||||
method_name: "choose",
|
||||
overload_index: 1,
|
||||
});
|
||||
expect(outcome).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
_tag: "AnalysisCapabilityUnavailableError",
|
||||
reason: expect.stringContaining("joins all same-name overloads"),
|
||||
},
|
||||
});
|
||||
await verifyCleanup(launches);
|
||||
});
|
||||
|
||||
it("reports missing native/abstract method source without claiming an empty implementation", async () => {
|
||||
const { service, apk, launches } = await setup("no-body");
|
||||
const outcome = await service.execute("inspect_android_method", {
|
||||
path: apk,
|
||||
class_name: "fixture.Target",
|
||||
method_name: "onCreate",
|
||||
});
|
||||
if (!outcome.ok) throw outcome.error;
|
||||
const result = androidResultSchemas.inspect_android_method.parse(
|
||||
outcome.value.normalized_result,
|
||||
);
|
||||
expect(result.body_status).toBe("not_available");
|
||||
expect(result.source.text).toContain("no decompiled body");
|
||||
await verifyCleanup(launches);
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import { expect, it } from "vitest";
|
||||
import {
|
||||
parseJadxEnvelope,
|
||||
parseJadxJson,
|
||||
normalizeJadxText,
|
||||
} from "../../../src/android/JadxProtocol.js";
|
||||
import { androidInputSchemas } from "../../../src/domain/androidAnalysis.js";
|
||||
|
||||
it("validates provider text envelopes and rejects unsupported content rather than dropping it", () => {
|
||||
expect(
|
||||
parseJadxEnvelope(
|
||||
{ content: [{ type: "text", text: "reported error" }], isError: true },
|
||||
"inspect_android_package",
|
||||
),
|
||||
).toEqual({ text: "reported error", failed: true });
|
||||
for (const response of [
|
||||
{ content: [] },
|
||||
{ content: [{ type: "image", data: "AA==", mimeType: "image/png" }] },
|
||||
{
|
||||
content: [
|
||||
{ type: "text", text: "first" },
|
||||
{ type: "text", text: "second" },
|
||||
],
|
||||
},
|
||||
])
|
||||
expect(() =>
|
||||
parseJadxEnvelope(response, "inspect_android_package"),
|
||||
).toThrowError("unsupported MCP content envelope");
|
||||
expect(() =>
|
||||
parseJadxJson("{malformed", "inspect_android_package"),
|
||||
).toThrowError("malformed JSON text");
|
||||
});
|
||||
|
||||
it("retains UTF-8 source and recognizes only the producer's terminal truncation marker", () => {
|
||||
expect(normalizeJadxText("日本語")).toEqual({
|
||||
text: "日本語",
|
||||
status: "complete",
|
||||
reported_total_bytes: null,
|
||||
});
|
||||
const text = "日本語\n\n... [truncated: exceeds 9 bytes, total 100 bytes]";
|
||||
expect(normalizeJadxText(text)).toEqual({
|
||||
text,
|
||||
status: "partial",
|
||||
reported_total_bytes: 100,
|
||||
});
|
||||
expect(normalizeJadxText(text + "\nmore actual source").status).toBe(
|
||||
"complete",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects invalid APK selectors and accepts an explicit zero overload without a confirmation flag", () => {
|
||||
const input = {
|
||||
path: "/fixture.apk",
|
||||
class_name: "fixture.Target",
|
||||
method_name: "choose",
|
||||
};
|
||||
expect(
|
||||
androidInputSchemas.inspect_android_method.safeParse({
|
||||
...input,
|
||||
overload_index: 0,
|
||||
}).success,
|
||||
).toBe(true);
|
||||
expect(
|
||||
androidInputSchemas.inspect_android_method.safeParse({
|
||||
...input,
|
||||
overload_index: -1,
|
||||
}).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
androidInputSchemas.inspect_android_method.safeParse({
|
||||
...input,
|
||||
overload_index: 0.5,
|
||||
}).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
androidInputSchemas.inspect_android_method.safeParse({
|
||||
...input,
|
||||
approved: true,
|
||||
}).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
androidInputSchemas.search_android_classes.safeParse({
|
||||
path: "/fixture.apk",
|
||||
query: "",
|
||||
}).success,
|
||||
).toBe(true);
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { expect, it } from "vitest";
|
||||
import {
|
||||
withCommandCancellation,
|
||||
type CommandCancellationHost,
|
||||
} from "../../../src/cli/commandCancellation.js";
|
||||
|
||||
it.each([
|
||||
["SIGINT", 130],
|
||||
["SIGTERM", 143],
|
||||
] as const)(
|
||||
"awaits cleanup after %s and removes its listeners",
|
||||
async (event, expectedExit) => {
|
||||
const events = new EventEmitter();
|
||||
const host: CommandCancellationHost = {
|
||||
on: events.on.bind(events),
|
||||
off: events.off.bind(events),
|
||||
};
|
||||
let cleaned = false;
|
||||
const operation = withCommandCancellation(async (signal) => {
|
||||
await new Promise<void>((resolve) =>
|
||||
signal.addEventListener("abort", () => resolve(), { once: true }),
|
||||
);
|
||||
cleaned = true;
|
||||
return "cancelled after cleanup";
|
||||
}, host);
|
||||
expect(host.exitCode).toBeUndefined();
|
||||
events.emit(event);
|
||||
expect(await operation).toBe("cancelled after cleanup");
|
||||
expect(cleaned).toBe(true);
|
||||
expect(host.exitCode).toBe(expectedExit);
|
||||
expect(events.listenerCount("SIGINT")).toBe(0);
|
||||
expect(events.listenerCount("SIGTERM")).toBe(0);
|
||||
},
|
||||
);
|
||||
|
||||
it("leaves the exit code alone on success and removes listeners when execution throws", async () => {
|
||||
const events = new EventEmitter();
|
||||
const host: CommandCancellationHost = {
|
||||
on: events.on.bind(events),
|
||||
off: events.off.bind(events),
|
||||
exitCode: 0,
|
||||
};
|
||||
expect(await withCommandCancellation(async () => "success", host)).toBe(
|
||||
"success",
|
||||
);
|
||||
expect(host.exitCode).toBe(0);
|
||||
await expect(
|
||||
withCommandCancellation(async () => {
|
||||
throw new Error("fixture failure");
|
||||
}, host),
|
||||
).rejects.toThrow("fixture failure");
|
||||
expect(events.listenerCount("SIGINT")).toBe(0);
|
||||
expect(events.listenerCount("SIGTERM")).toBe(0);
|
||||
});
|
||||
@@ -22,6 +22,7 @@ import { PLAYWRIGHT_BROWSER_SCENARIO_PROVIDER_IDENTITY } from "../../../src/brow
|
||||
import { V8_INSPECTOR_PROVIDER_IDENTITY } from "../../../src/browser/V8InspectorProvider.js";
|
||||
import { CLI_COMMAND_NAMES } from "../../../src/cliCommandNames.js";
|
||||
import { createCli } from "../../../src/cli.js";
|
||||
import { JADX_PROVIDER_IDENTITY } from "../../../src/android/JadxRelease.js";
|
||||
import { TOOL_CONTRACTS } from "../../../src/contracts/toolContracts.js";
|
||||
import {
|
||||
HOPPER_PROVIDER_IDENTITY,
|
||||
@@ -71,6 +72,7 @@ describe("canonical product catalog", () => {
|
||||
expect(catalog.providers.map(({ id }) => id).sort()).toEqual(
|
||||
[
|
||||
HOPPER_PROVIDER_IDENTITY,
|
||||
JADX_PROVIDER_IDENTITY,
|
||||
GHIDRA_PROVIDER_IDENTITY,
|
||||
NATIVE_MACOS_PROVIDER_IDENTITY,
|
||||
ARTIFACT_GRAPH_PROVIDER,
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
||||
import { expect, it as test, onTestFinished } from "vitest";
|
||||
import { access } from "node:fs/promises";
|
||||
import { ANDROID_TOOL_CONTRACTS } from "../../../src/contracts/androidToolContracts.js";
|
||||
import { parseEvidence } from "../../../src/domain/evidence.js";
|
||||
import { createServer } from "../../../src/server/createServer.js";
|
||||
import { createTestBinarySession } from "../../fixtures/binarySession.js";
|
||||
import {
|
||||
createJadxProtocolFixture,
|
||||
verifyJadxFixtureCleanup,
|
||||
} from "../../fixtures/android/jadx.js";
|
||||
|
||||
const it = test.skipIf(process.platform === "win32");
|
||||
|
||||
it("publishes and executes all APK contracts with inline Evidence and no active binary", async () => {
|
||||
const { provider, apk, launches } = await createJadxProtocolFixture();
|
||||
const session = createTestBinarySession(() => {
|
||||
throw new Error("APK analysis must not acquire a deep binary provider");
|
||||
});
|
||||
const server = createServer(session, session, { androidAnalysis: provider });
|
||||
const client = new Client({
|
||||
name: "android-contract-regression",
|
||||
version: "1",
|
||||
});
|
||||
onTestFinished(async () => {
|
||||
await client.close();
|
||||
await server.close();
|
||||
await session.close();
|
||||
});
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
const advertised = await client.listTools();
|
||||
for (const contract of ANDROID_TOOL_CONTRACTS) {
|
||||
const wire = advertised.tools.find((tool) => tool.name === contract.name);
|
||||
expect(wire?.annotations).toMatchObject(contract.annotations);
|
||||
expect(contract.effects).toMatchObject({
|
||||
mutatesTarget: false,
|
||||
launchesProcess: true,
|
||||
writesFilesystem: true,
|
||||
accessesNetwork: false,
|
||||
});
|
||||
const input = {
|
||||
path: apk,
|
||||
...(contract.name === "search_android_classes"
|
||||
? { query: "Target" }
|
||||
: {}),
|
||||
...(contract.name === "inspect_android_class" ||
|
||||
contract.name === "inspect_android_method" ||
|
||||
contract.name === "trace_android_references"
|
||||
? { class_name: "fixture.Target" }
|
||||
: {}),
|
||||
...(contract.name === "inspect_android_method"
|
||||
? { method_name: "onCreate" }
|
||||
: {}),
|
||||
};
|
||||
const response = await client.callTool({
|
||||
name: contract.name,
|
||||
arguments: input,
|
||||
});
|
||||
expect(response.isError, JSON.stringify(response)).not.toBe(true);
|
||||
const result = contract.outputSchema.parse(response.structuredContent);
|
||||
const evidence = parseEvidence(result.evidence);
|
||||
expect(result.result).toEqual(evidence.normalized_result);
|
||||
expect(result.evidence_id).toBe(evidence.evidence_id);
|
||||
expect(evidence.operation).toBe(contract.name);
|
||||
expect(evidence.raw_result).not.toBeNull();
|
||||
}
|
||||
expect(launches).toHaveLength(5);
|
||||
await verifyJadxFixtureCleanup(launches);
|
||||
});
|
||||
|
||||
it("cleans an active engine when the MCP client disconnects", async () => {
|
||||
const { provider, apk, launches } = await createJadxProtocolFixture("stall");
|
||||
const session = createTestBinarySession(() => {
|
||||
throw new Error("Unexpected native provider acquisition");
|
||||
});
|
||||
const server = createServer(session, session, { androidAnalysis: provider });
|
||||
const client = new Client({
|
||||
name: "android-disconnect-regression",
|
||||
version: "1",
|
||||
});
|
||||
onTestFinished(async () => {
|
||||
await client.close();
|
||||
await server.close();
|
||||
await session.close();
|
||||
});
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
const pending = client
|
||||
.callTool({ name: "inspect_android_package", arguments: { path: apk } })
|
||||
.catch(() => undefined);
|
||||
await expect.poll(() => launches.length).toBe(1);
|
||||
await client.close();
|
||||
await pending;
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const workspace = launches[0]?.cwd;
|
||||
if (workspace === undefined) return false;
|
||||
try {
|
||||
await access(workspace);
|
||||
return false;
|
||||
} catch (cause) {
|
||||
return (
|
||||
cause instanceof Error && "code" in cause && cause.code === "ENOENT"
|
||||
);
|
||||
}
|
||||
})
|
||||
.toBe(true);
|
||||
await verifyJadxFixtureCleanup(launches);
|
||||
});
|
||||
Vendored
+135
@@ -0,0 +1,135 @@
|
||||
import { McpServer } from "@modelcontextprotocol/server";
|
||||
import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
|
||||
import { z } from "zod";
|
||||
|
||||
// An actual child MCP server exercising the production stdio/process seam.
|
||||
// It is a protocol fixture, not evidence of JADX decompiler support.
|
||||
const mode = process.env.REA_FAKE_JADX_MODE ?? "normal";
|
||||
const server = new McpServer({
|
||||
name: "jadx-headless-mcp",
|
||||
version: mode === "version" ? "99.0.0" : "0.7.1",
|
||||
});
|
||||
const classes = [
|
||||
"fixture.Target",
|
||||
...Array.from({ length: 400 }, (_, index) => `fixture.Class${index}`),
|
||||
];
|
||||
const methods = [
|
||||
{
|
||||
name: "onCreate",
|
||||
signature: "onCreate(Bundle): void",
|
||||
is_constructor: false,
|
||||
def_pos: 0,
|
||||
},
|
||||
{
|
||||
name: "choose",
|
||||
signature: "choose(String): void",
|
||||
is_constructor: false,
|
||||
def_pos: 0,
|
||||
},
|
||||
{
|
||||
name: "choose",
|
||||
signature: "choose(int): void",
|
||||
is_constructor: false,
|
||||
def_pos: 0,
|
||||
},
|
||||
];
|
||||
const text = (value) => ({ content: [{ type: "text", text: value }] });
|
||||
const json = (value) => text(JSON.stringify(value));
|
||||
const register = (name, handler) =>
|
||||
server.registerTool(name, { inputSchema: z.looseObject({}) }, handler);
|
||||
register("load_apk", async ({ path }) => {
|
||||
if (mode === "stall") await new Promise(() => {});
|
||||
return json({
|
||||
state: "LOADED",
|
||||
apk_path: mode === "wrong-apk" ? "/different.apk" : path,
|
||||
class_count: classes.length,
|
||||
resource_count: 1,
|
||||
threads: 1,
|
||||
resources: "full",
|
||||
});
|
||||
});
|
||||
register("get_app_info", () =>
|
||||
json({
|
||||
package: "fixture",
|
||||
version_name: "1",
|
||||
version_code: "1",
|
||||
min_sdk: "26",
|
||||
target_sdk: "33",
|
||||
permissions: ["android.permission.INTERNET"],
|
||||
}),
|
||||
);
|
||||
register("get_android_manifest", () => {
|
||||
if (mode === "tool-error")
|
||||
return {
|
||||
...text("manifest decoder rejected malformed binary XML"),
|
||||
isError: true,
|
||||
};
|
||||
if (mode === "frame-overflow") return text("x".repeat(9 * 1024 * 1024));
|
||||
return text('<manifest package="fixture"/>');
|
||||
});
|
||||
register("list_classes", ({ offset, limit }) =>
|
||||
json({
|
||||
total: classes.length,
|
||||
offset,
|
||||
limit,
|
||||
items: mode === "empty-page" ? [] : classes.slice(offset, offset + limit),
|
||||
}),
|
||||
);
|
||||
register("get_class_summary", () =>
|
||||
json({
|
||||
full_name: mode === "wrong-class" ? "fixture.Other" : "fixture.Target",
|
||||
name: "Target",
|
||||
method_count: mode === "summary-error" ? -1 : methods.length,
|
||||
field_count: 0,
|
||||
inner_class_count: 0,
|
||||
methods: mode === "summary-error" ? [] : methods,
|
||||
fields: [],
|
||||
inner_classes: [],
|
||||
...(mode === "summary-error" ? { error: "summary timed out" } : {}),
|
||||
}),
|
||||
);
|
||||
register("get_method_body", ({ method_name, overload_index }) =>
|
||||
json({
|
||||
class_name: "fixture.Target",
|
||||
method_name,
|
||||
full_name: `fixture.Target.${method_name}`,
|
||||
overload_index:
|
||||
mode === "wrong-method" ? overload_index + 1 : overload_index,
|
||||
overload_count: method_name === "choose" ? 2 : 1,
|
||||
mode: mode === "smali" || mode === "overloaded-smali" ? "smali" : "java",
|
||||
fell_back: mode === "smali" || mode === "overloaded-smali",
|
||||
markers:
|
||||
mode === "smali" || mode === "overloaded-smali"
|
||||
? ["Method not decompiled"]
|
||||
: [],
|
||||
body:
|
||||
mode === "no-body"
|
||||
? "// method exists but has no decompiled body (native/abstract)"
|
||||
: mode === "body-error"
|
||||
? "// ERROR: decompile timed out or failed"
|
||||
: mode === "truncated"
|
||||
? "void onCreate(){}\n\n... [truncated: exceeds 18 bytes, total 100 bytes]"
|
||||
: `void ${method_name}() { /* overload ${overload_index} */ }`,
|
||||
}),
|
||||
);
|
||||
for (const name of ["get_xrefs_to_class", "get_xrefs_to_method"])
|
||||
register(name, ({ class_name, method_name }) =>
|
||||
json({
|
||||
target:
|
||||
method_name === undefined ? class_name : `${class_name}.${method_name}`,
|
||||
total: mode === "partial-xrefs" ? 2 : 1,
|
||||
count: 1,
|
||||
resolve_line: false,
|
||||
items: [
|
||||
{
|
||||
kind: "method",
|
||||
name: "caller",
|
||||
full_name: "fixture.Other.caller",
|
||||
containing_class: "fixture.Other",
|
||||
top_class: "fixture.Other",
|
||||
def_pos: 42,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
await server.connect(new StdioServerTransport());
|
||||
Vendored
+90
@@ -0,0 +1,90 @@
|
||||
import { access, rm, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { expect, onTestFinished } from "vitest";
|
||||
import { AndroidAnalysisService } from "../../../src/application/AndroidAnalysisService.js";
|
||||
import { JadxProvider } from "../../../src/android/JadxProvider.js";
|
||||
import {
|
||||
ProviderProcessSupervisor,
|
||||
spawnOwnedProviderProcess,
|
||||
} from "../../../src/process/ProviderProcess.js";
|
||||
import { cleanupOwnedProcessGroup } from "../../../src/process/ProcessOwnership.js";
|
||||
import { writeOrderedZip } from "../artifactEntryOrder.js";
|
||||
import { createTestTempDirectory } from "../temporaryDirectory.js";
|
||||
|
||||
const fixture = fileURLToPath(new URL("./jadx-mcp.mjs", import.meta.url));
|
||||
|
||||
/** Real owned subprocess running a synthetic MCP producer, with no module mocks. */
|
||||
export const createJadxProtocolFixture = async (mode = "normal") => {
|
||||
const root = await createTestTempDirectory("rea-android-boundary-");
|
||||
const apk = join(root, "fixture.apk");
|
||||
const jar = join(root, "fixture.jar");
|
||||
await writeOrderedZip(apk, ["AndroidManifest.xml", "classes.dex"]);
|
||||
await writeFile(jar, "synthetic engine bytes; not a Java archive");
|
||||
const launches: {
|
||||
runId: string;
|
||||
cwd: string | undefined;
|
||||
pid: number | undefined;
|
||||
}[] = [];
|
||||
const provider = new JadxProvider(
|
||||
{ ...process.env, REA_JADX_MCP_JAR: jar },
|
||||
async (options) => {
|
||||
const spawned = await spawnOwnedProviderProcess({
|
||||
...options,
|
||||
command: process.execPath,
|
||||
arguments: [fixture],
|
||||
expectedCommand: process.execPath,
|
||||
env: { ...options.env, REA_FAKE_JADX_MODE: mode },
|
||||
});
|
||||
launches.push({
|
||||
runId: options.runId,
|
||||
cwd: options.cwd,
|
||||
pid: spawned.process.pid,
|
||||
});
|
||||
if (mode === "cleanup-failure") {
|
||||
onTestFinished(async () => {
|
||||
const supervisor = new ProviderProcessSupervisor({
|
||||
...spawned,
|
||||
ownsProcessLifetime: true,
|
||||
cleanup: () => cleanupOwnedProcessGroup(spawned.ownership),
|
||||
});
|
||||
expect((await supervisor.stop()).status).not.toBe("incomplete");
|
||||
if (options.cwd !== undefined)
|
||||
await rm(options.cwd, { recursive: true, force: true });
|
||||
});
|
||||
return {
|
||||
...spawned,
|
||||
cleanup: async () => ({
|
||||
cleaned: false,
|
||||
reason: "injected ownership verification failure",
|
||||
}),
|
||||
};
|
||||
}
|
||||
return spawned;
|
||||
},
|
||||
);
|
||||
return {
|
||||
service: new AndroidAnalysisService(provider),
|
||||
provider,
|
||||
apk,
|
||||
jar,
|
||||
launches,
|
||||
};
|
||||
};
|
||||
|
||||
/** Verify only this fixture's acquired group and workspace, without scanning unrelated host environments. */
|
||||
export const verifyJadxFixtureCleanup = async (
|
||||
launches: Awaited<ReturnType<typeof createJadxProtocolFixture>>["launches"],
|
||||
): Promise<void> => {
|
||||
for (const launch of launches) {
|
||||
const pid = launch.pid;
|
||||
if (pid !== undefined)
|
||||
expect(() => process.kill(-pid, 0)).toThrowError(
|
||||
expect.objectContaining({ code: "ESRCH" }),
|
||||
);
|
||||
if (launch.cwd !== undefined)
|
||||
await expect(access(launch.cwd)).rejects.toMatchObject({
|
||||
code: "ENOENT",
|
||||
});
|
||||
}
|
||||
};
|
||||
Vendored
+42
@@ -0,0 +1,42 @@
|
||||
# Upstream analysis engines
|
||||
|
||||
REA integrates existing tools through provider adapters. Upstream source remains
|
||||
unmodified and retains its own license; it is not copied into REA's domain or
|
||||
application code.
|
||||
|
||||
## jadx-headless-mcp
|
||||
|
||||
- Source: <https://github.com/1013503897/jadx-headless-mcp>
|
||||
- Submodule: `third_party/jadx-headless-mcp`
|
||||
- Release: `v0.7.1`
|
||||
- Commit: `5844800a486d2248fa949b74c9896285f5b54de2`
|
||||
- License: Apache-2.0; see the submodule's `LICENSE`.
|
||||
- Release identity: `src/android/JadxRelease.ts`.
|
||||
- REA integration: `src/android/`; no upstream patches in this version.
|
||||
|
||||
Fetch source for inspection with:
|
||||
|
||||
```sh
|
||||
git submodule update --init third_party/jadx-headless-mcp
|
||||
```
|
||||
|
||||
Normal REA builds and installed npm packages do not require this submodule or a
|
||||
Gradle build. Supply a separately obtained JAR with `REA_JADX_MCP_JAR`; REA never
|
||||
downloads engines during an analysis operation. The audited release JAR is
|
||||
identified by SHA-256. A caller-supplied build reporting the supported protocol
|
||||
version retains its actual digest and an unknown source revision unless its
|
||||
bytes match the audited release.
|
||||
|
||||
When updating: review upstream protocol and license changes, update the gitlink
|
||||
and release identity together, exercise the adapter's producer regressions, and
|
||||
run the real APK CLI/MCP lane. Never attribute modified engine bytes to the
|
||||
audited source revision.
|
||||
|
||||
## Public APK fixture
|
||||
|
||||
`scripts/fetch-android-fixtures.mjs` explicitly downloads the audited engine and
|
||||
Appium ApiDemos v6.0.18 into ignored `_reference/apk-integration/`. Both downloads
|
||||
are checked against fixed SHA-256 values. Fixture provenance is recorded in
|
||||
`scripts/fixtures/android-apidemos.json`; the source uses Apache-2.0. This is a
|
||||
static analysis fixture: no emulator, device, SDK installation or app execution
|
||||
is needed. APKs and JARs are neither tracked nor included in npm packages.
|
||||
+1
Submodule third_party/jadx-headless-mcp added at 5844800a48.
Reference in new issue
Block a user