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:
N0zoM1z0 authored and GitHub committed 2026-10-06 12:46:48 +00:00
1 parent d0f3480a3c
commit 904d03e987
59 files changed
+3342 -52

No files matched your search

+3
View File
@@ -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
+1
View File
@@ -9,6 +9,7 @@
".codex/**",
".devin/**",
"tests/fixtures/**",
"third_party/jadx-headless-mcp/**",
"tests/conformance/**/*.c",
"tests/conformance/**/*.m",
"tests/conformance/**/*.swift",
+2 -1
View File
@@ -10,6 +10,7 @@
"node_modules/**",
"CHANGELOG.md",
"package-lock.json",
"_reference/**"
"_reference/**",
"third_party/jadx-headless-mcp/**"
]
}
+2 -1
View File
@@ -139,6 +139,7 @@
".cache",
".codex",
".devin",
".factory"
".factory",
"third_party/jadx-headless-mcp"
]
}
+7
View File
@@ -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
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 | 目标切换、证据包、进程与函数比较、待解决问题记录 |
## 路线图
+126
View File
@@ -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.
+5
View File
@@ -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
+36 -7
View File
@@ -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"
}
}
}
+17
View File
@@ -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": [
+2
View File
@@ -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",
+70
View File
@@ -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 });
}
}
+14
View File
@@ -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"
}
+120
View File
@@ -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",
);
};
+9
View File
@@ -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,
+2
View File
@@ -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",
+221
View File
@@ -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));
+2 -2
View File
@@ -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
+46
View File
@@ -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) };
};
+351
View File
@@ -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",
});
};
+50
View File
@@ -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)}`,
);
}
};
+183
View File
@@ -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))),
);
}
}
+134
View File
@@ -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,
};
};
+245
View File
@@ -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);
}
}
+21
View File
@@ -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;
+150
View File
@@ -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),
};
}
}
+17
View File
@@ -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>>;
}
+54
View File
@@ -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",
+9
View File
@@ -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)
+2
View File
@@ -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);
+110
View File
@@ -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 }),
}),
});
};
+32
View File
@@ -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;
}
};
+5
View File
@@ -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",
+91
View File
@@ -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[];
+2
View File
@@ -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,
+1
View File
@@ -24,6 +24,7 @@ export const TOOL_KINDS = [
"native-provider",
"artifact-provider",
"managed-provider",
"android-provider",
"browser-provider",
"electron-provider",
"runtime-provider",
+7
View File
@@ -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",
+2
View File
@@ -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,
+25
View File
@@ -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,
+164
View File
@@ -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;
+1 -1
View File
@@ -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");
+9 -2
View File
@@ -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,
+16
View File
@@ -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) {
+62
View File
@@ -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),
);
};
+3
View File
@@ -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);
});
+135
View File
@@ -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());
+90
View File
@@ -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",
});
}
};
+42
View File
@@ -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.