Merge remote-tracking branch 'origin/main' into fix/swift-demangle-arguments
# Conflicts: # docs/product-catalog.json # docs/verification/managed-conformance-manifest.json # skills/reverse-engineer-anything/SKILL.md # src/generatedMcpToolCatalog.ts
This commit is contained in:
commit
01779a6f61
26 files changed
+877
-406
No files matched your search
@@ -163,7 +163,7 @@ Deep native binary analysis requires [Hopper](https://www.hopperapp.com/), [Ghid
|
||||
|
||||
Firmware region inspection and explicit extraction use caller-supplied Binwalk and Unblob on Linux. See [Firmware analysis](docs/firmware-analysis.md) for setup, provenance, resource limits and native handoff.
|
||||
|
||||
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.
|
||||
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. Authenticated IPA inventory Evidence can be projected with [Apple application analysis](docs/apple-application-analysis.md).
|
||||
|
||||
Repository main and npm 4.1.0 include experimental Windows x64 Ghidra support for native x86-64 PE applications on local NTFS, with bundled Job Object, private-DACL, and path-admission controls. Check the [release boundary](docs/installation.md#released-package-and-main) before expecting this from an older npm package. See [Windows Ghidra P0](docs/windows-ghidra-p0.md) for prerequisites and verified scope.
|
||||
|
||||
@@ -384,20 +384,20 @@ See [native investigation](docs/native-investigation.md) for keyed archives, ins
|
||||
|
||||
## Tool catalog for investigation
|
||||
|
||||
| Tool family | Count | Examples |
|
||||
| ------------------------- | ----: | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Native inspection | 41 | functions, pseudocode, assembly, strings, symbols, calls, references, annotations, byte reads, and file offsets |
|
||||
| Investigation workflows | 14 | app overviews, function dossiers, native APIs and dispatch, batch decompilation, feature traces, call paths, call graphs, Swift and Objective-C discovery |
|
||||
| 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 |
|
||||
| Firmware | 2 | Linux firmware region inspection and explicit extraction |
|
||||
| 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 |
|
||||
| Application workflows | 9 | captured website script export; cross-layer feature traces, build comparisons, historical source mapping, static return-shape comparison, and reconstruction checks |
|
||||
| Workspace and observation | 21 | sessions, evidence bundles, navigation context, process/artifact/function comparisons, and open-question tracking |
|
||||
| Tool family | Count | Examples |
|
||||
| ------------------------- | ----: | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Native inspection | 41 | functions, pseudocode, assembly, strings, symbols, calls, references, annotations, byte reads, and file offsets |
|
||||
| Investigation workflows | 14 | app overviews, function dossiers, native APIs and dispatch, batch decompilation, feature traces, call paths, call graphs, Swift and Objective-C discovery |
|
||||
| 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 |
|
||||
| Firmware | 2 | Linux firmware region inspection and explicit extraction |
|
||||
| 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 |
|
||||
| Application workflows | 11 | captured website script export; Android/Apple inventory projections; cross-layer feature traces, build comparisons, historical source mapping, static return-shape comparison, and reconstruction checks |
|
||||
| Workspace and observation | 21 | sessions, evidence bundles, navigation context, process/artifact/function comparisons, and open-question tracking |
|
||||
|
||||
The public interface describes what the agent is trying to learn. Providers decide how to answer. macOS utilities handle common semantic inspection without launching Hopper; Hopper handles deeper native analysis; the process harness records direct behavioral captures.
|
||||
|
||||
|
||||
+1
-1
@@ -223,7 +223,7 @@ rea setup
|
||||
| مراقبة المتصفح | 9 | بنية الصفحات وبيانات الشبكة والبرامج النصية وخرائط المصدر واكتشاف WebMCP والصور ومقارنة الالتقاط |
|
||||
| تحليل Electron | 5 | مراقبة العارضين وخريطة التطبيق الثابتة والمواءمة بين نتائج الثبات والتشغيل |
|
||||
| وقت تشغيل JavaScript | 2 | اكتشاف أهداف Node/Electron Inspector ومواضع البرامج وأحداث سياقات التنفيذ |
|
||||
| سير عمل التطبيقات | 9 | تصدير النصوص البرمجية الملتقطة، تتبع الميزات عبر الطبقات ومقارنة الإصدارات ومواءمة المصدر التاريخي ومقارنة أشكال القيم المعادة والتحقق من إعادة الإنشاء |
|
||||
| سير عمل التطبيقات | 11 | تصدير النصوص البرمجية الملتقطة، تتبع الميزات عبر الطبقات ومقارنة الإصدارات ومواءمة المصدر التاريخي ومقارنة أشكال القيم المعادة والتحقق من إعادة الإنشاء |
|
||||
| مساحة العمل والملاحظة | 21 | الجلسات وحزم الأدلة وسياق التنقل ومقارنة العمليات والمخرجات والدوال وتسجيل الأسئلة غير المحسومة |
|
||||
|
||||
## خطة العمل
|
||||
|
||||
+1
-1
@@ -212,7 +212,7 @@ REA は手順 1〜5 のバイナリ解析を処理し、手順 6 はエージェ
|
||||
| ブラウザー観察 | 9 | ページ構成、ネットワークメタデータ、スクリプト、ソースマップ、WebMCP 検出、スクリーンショット、キャプチャ比較 |
|
||||
| Electron 解析 | 5 | レンダラー観察、静的アプリマッピング、静的/実行時結果の対応付け |
|
||||
| JavaScript 実行時 | 2 | Node/Electron Inspector 対象の検出、スクリプト位置、実行コンテキストイベント |
|
||||
| アプリワークフロー | 9 | 取得した Web スクリプトのエクスポート、層をまたぐ機能トレース、ビルド比較、履歴ソース対応、静的戻り値形状の比較、再実装検証 |
|
||||
| アプリワークフロー | 11 | 取得した Web スクリプトのエクスポート、層をまたぐ機能トレース、ビルド比較、履歴ソース対応、静的戻り値形状の比較、再実装検証 |
|
||||
| ワークスペースと観察 | 21 | セッション、証跡バンドル、ナビゲーション文脈、プロセス・成果物・関数比較、未解決事項の記録 |
|
||||
|
||||
## ロードマップ
|
||||
|
||||
+1
-1
@@ -213,7 +213,7 @@ REA는 1–5단계의 바이너리 분석을 처리합니다. 6단계는 에이
|
||||
| 브라우저 관찰 | 9 | 페이지 구조, 네트워크 메타데이터, 스크립트, 소스 맵, WebMCP 탐색, 스크린샷, 캡처 비교 |
|
||||
| Electron 분석 | 5 | 렌더러 관찰, 정적 앱 매핑, 정적·런타임 결과 연결 |
|
||||
| JavaScript 런타임 | 2 | Node/Electron Inspector 대상 탐색, 스크립트 위치, 실행 컨텍스트 이벤트 |
|
||||
| 앱 워크플로 | 9 | 캡처한 웹 스크립트 내보내기, 계층 간 기능 추적, 빌드 비교, 히스토리 소스 매핑, 정적 반환 구조 비교, 재구현 검증 |
|
||||
| 앱 워크플로 | 11 | 캡처한 웹 스크립트 내보내기, 계층 간 기능 추적, 빌드 비교, 히스토리 소스 매핑, 정적 반환 구조 비교, 재구현 검증 |
|
||||
| 작업 공간과 관찰 | 21 | 세션, 근거 번들, 탐색 컨텍스트, 프로세스·아티팩트·함수 비교, 미해결 항목 기록 |
|
||||
|
||||
## 로드맵
|
||||
|
||||
+1
-1
@@ -212,7 +212,7 @@ REA 负责第 1–5 步中的二进制分析。第 6 步由智能体使用其常
|
||||
| 浏览器观察 | 9 | 页面结构、网络元数据、脚本、来源映射、WebMCP 发现、截图与捕获比较 |
|
||||
| Electron 分析 | 5 | 渲染进程观察、静态应用映射、静态/运行时结果关联 |
|
||||
| JavaScript 运行时 | 2 | Node/Electron Inspector 目标发现、脚本位置与执行上下文事件 |
|
||||
| 应用工作流 | 9 | 捕获的网站脚本导出、跨层功能追踪、构建比较、历史源码映射、静态返回结构比较与重建验证 |
|
||||
| 应用工作流 | 11 | 捕获的网站脚本导出、跨层功能追踪、构建比较、历史源码映射、静态返回结构比较与重建验证 |
|
||||
| 工作区与观察 | 21 | 会话、证据包、导航上下文、进程/产物/函数比较与待解决问题跟踪 |
|
||||
|
||||
## 路线图
|
||||
|
||||
@@ -88,8 +88,24 @@ selected overload. Native/abstract methods with no decompiled body report
|
||||
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.
|
||||
archive evidence. Project that Evidence with the execution-free inventory tool:
|
||||
|
||||
```sh
|
||||
rea project-android-application-graph '{"inventory_evidence":[<inventory_artifact Evidence>]}'
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "project_android_application_graph",
|
||||
"arguments": {
|
||||
"inventory_evidence": ["<inventory_artifact Evidence>"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The projection reports exact component paths and hashes, runtime-family hints,
|
||||
and path-based bridge hypotheses. It does not decode DEX or claim observed
|
||||
runtime calls.
|
||||
|
||||
## Resource and lifecycle limits
|
||||
|
||||
|
||||
@@ -6,6 +6,19 @@ execution-free: it reports exact paths, content hashes, detected formats,
|
||||
runtime-family hints, and path-based bridge hypotheses. It does not parse plist
|
||||
or CMS semantics or claim observed runtime calls.
|
||||
|
||||
```sh
|
||||
rea project-apple-application-graph '{"inventory_evidence":[<inventory_artifact Evidence>]}'
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "project_apple_application_graph",
|
||||
"arguments": {
|
||||
"inventory_evidence": ["<inventory_artifact Evidence>"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The result includes every component in each inventory category and every
|
||||
JavaScript-to-native bridge candidate pair. It has no caller-selected component
|
||||
budget, prefix truncation, or omitted-count fields. Duplicate inventory pages
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"skill_version": "26"
|
||||
},
|
||||
"tools": {
|
||||
"total": 127,
|
||||
"total": 129,
|
||||
"families": [
|
||||
{
|
||||
"id": "direct",
|
||||
@@ -179,7 +179,7 @@
|
||||
{
|
||||
"id": "application",
|
||||
"surface": "application-workflow",
|
||||
"count": 9,
|
||||
"count": 11,
|
||||
"tools": [
|
||||
"build_reconstruction_obligation_ledger",
|
||||
"compare_application_versions",
|
||||
@@ -187,6 +187,8 @@
|
||||
"compare_source_to_bundle",
|
||||
"evaluate_reconstruction_coverage",
|
||||
"export_web_scripts",
|
||||
"project_android_application_graph",
|
||||
"project_apple_application_graph",
|
||||
"recover_javascript_sources",
|
||||
"trace_application_feature",
|
||||
"trace_javascript_semantics"
|
||||
@@ -351,6 +353,18 @@
|
||||
"observe_native_ui"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "rea-android-application",
|
||||
"name": "REA Android application workflows",
|
||||
"version": "1",
|
||||
"capabilities": ["project_android_application_graph"]
|
||||
},
|
||||
{
|
||||
"id": "rea-apple-application",
|
||||
"name": "REA Apple application workflows",
|
||||
"version": "1",
|
||||
"capabilities": ["project_apple_application_graph"]
|
||||
},
|
||||
{
|
||||
"id": "rea-artifact-graph",
|
||||
"name": "REA safe artifact graph provider",
|
||||
@@ -546,7 +560,7 @@
|
||||
}
|
||||
],
|
||||
"cli": {
|
||||
"primary_count": 82,
|
||||
"primary_count": 84,
|
||||
"commands": [
|
||||
"address-to-file-offset",
|
||||
"analyze",
|
||||
@@ -611,6 +625,8 @@
|
||||
"observe-javascript-runtime",
|
||||
"observe-native-ui",
|
||||
"observe-web-session",
|
||||
"project-android-application-graph",
|
||||
"project-apple-application-graph",
|
||||
"project-managed-application-graph",
|
||||
"providers",
|
||||
"read-bytes",
|
||||
@@ -640,15 +656,15 @@
|
||||
},
|
||||
"runtime_catalog": {
|
||||
"counts": {
|
||||
"cli_commands": 82,
|
||||
"mcp_tools": 127,
|
||||
"cli_commands": 84,
|
||||
"mcp_tools": 129,
|
||||
"mcp_prompts": 6
|
||||
},
|
||||
"digests": {
|
||||
"tools_sha256": "83e7791a5c733162c76b2d5654863b9ef686feed51ec23b8400035552e8a2b6d",
|
||||
"tools_sha256": "31c4f5129fe81c0a84f5a374621cd7edc2e995b91b3f40a5ce4921a34088ac33",
|
||||
"prompts_sha256": "ef2de8814390b340623c32001610aa2709d90bed51537b6e562ab3abf144ab4d",
|
||||
"combined_sha256": "4a56d8682120ad808f3843f09e31fbe13d2a80b5b911c7b60343b28cb3c298e1",
|
||||
"providers_sha256": "0bccfa9588376a90350d18abded999db7e1d0e4424a61a21d03d392a1c5418fe"
|
||||
"combined_sha256": "43e6ac2812f72123ce1d115c5e1be9fc963007987930b21892ee0e90b211dc68",
|
||||
"providers_sha256": "6e1de7ee30b9b1ff633abfa4fd2b3848bb58e6e76a2ed196aa01836a202e1941"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -181,6 +181,8 @@ 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.
|
||||
Authenticated IPA inventory projection is documented in
|
||||
[Apple application analysis](apple-application-analysis.md).
|
||||
|
||||
Synthetic producer regressions run independently:
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"manifest_id": "ecm_18e8db09b517abbcb288f199882a13efe238d78f2c63972778b05e134ef21f19",
|
||||
"manifest_id": "ecm_3290efdc772b6cd6ce4bcdf010a1c7f9e1f87bb321ccdf5f6adf8bbb412fb5ac",
|
||||
"verifier": {
|
||||
"id": "rea-managed-conformance",
|
||||
"version": "1"
|
||||
@@ -13,7 +13,7 @@
|
||||
"skill_digests": [
|
||||
{
|
||||
"skill_id": "reverse-engineer-anything",
|
||||
"sha256": "797d0110c457bf1234787dc6d6a073cc7d1c1e260e580a333fc374a052672f9b"
|
||||
"sha256": "663a5891fc00ca2a8ab1c18e49f989a6b67c132a5313be4b0ee623ffdef5400b"
|
||||
}
|
||||
],
|
||||
"claims": [
|
||||
|
||||
@@ -187,7 +187,23 @@ export const providerCatalog = (sources) => {
|
||||
{
|
||||
identity:
|
||||
sources.artifactProviders.JAVASCRIPT_APPLICATION_WORKFLOW_PROVIDER,
|
||||
contracts: sources.applicationContracts.APPLICATION_TOOL_CONTRACTS,
|
||||
contracts: sources.applicationContracts.APPLICATION_TOOL_CONTRACTS.filter(
|
||||
({ name }) =>
|
||||
name !== "project_android_application_graph" &&
|
||||
name !== "project_apple_application_graph",
|
||||
),
|
||||
},
|
||||
{
|
||||
identity: sources.artifactProviders.ANDROID_APPLICATION_PROVIDER,
|
||||
contracts: sources.applicationContracts.APPLICATION_TOOL_CONTRACTS.filter(
|
||||
({ name }) => name === "project_android_application_graph",
|
||||
),
|
||||
},
|
||||
{
|
||||
identity: sources.artifactProviders.APPLE_APPLICATION_PROVIDER,
|
||||
contracts: sources.applicationContracts.APPLICATION_TOOL_CONTRACTS.filter(
|
||||
({ name }) => name === "project_apple_application_graph",
|
||||
),
|
||||
},
|
||||
{
|
||||
identity: sources.artifactProviders.WEB_SCRIPT_EXPORT_PROVIDER,
|
||||
|
||||
@@ -3,8 +3,8 @@ name: reverse-engineer-anything
|
||||
description: Reverse engineer native, managed, Electron/JavaScript, packaged, firmware, and browser targets 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: "26"
|
||||
tool_count: 127
|
||||
catalog_digest: "4a56d8682120ad808f3843f09e31fbe13d2a80b5b911c7b60343b28cb3c298e1"
|
||||
tool_count: 129
|
||||
catalog_digest: "43e6ac2812f72123ce1d115c5e1be9fc963007987930b21892ee0e90b211dc68"
|
||||
---
|
||||
|
||||
# REA
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { reconstructionCoverageEvaluationInputSchema } from "../domain/reconstructionCoverageInput.js";
|
||||
import { Cli, z } from "incur";
|
||||
|
||||
import { projectAndroidApplicationEvidence } from "../application/AndroidApplicationService.js";
|
||||
import { projectAppleApplicationEvidence } from "../application/AppleApplicationService.js";
|
||||
import {
|
||||
compareApplicationVersionsEvidenceValidated,
|
||||
compareJavaScriptExportShapesEvidenceValidated,
|
||||
@@ -16,6 +18,8 @@ import { logCliCommand } from "../cliLogging.js";
|
||||
import { AnalysisInputError } from "../domain/analysisErrorCore.js";
|
||||
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
||||
import { type AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import { androidApplicationProjectionInputSchema } from "../domain/androidApplication.js";
|
||||
import { appleApplicationProjectionInputSchema } from "../domain/appleApplication.js";
|
||||
import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js";
|
||||
import type { Logger } from "../logger.js";
|
||||
import { traceApplicationFeatureInputSchema } from "../domain/javascriptFeatureTraceSchemas.js";
|
||||
@@ -80,6 +84,34 @@ export const registerApplicationCommands = (
|
||||
});
|
||||
registerObligationLedgerCommand(cli, logger);
|
||||
registerCoverageCommand(cli, logger);
|
||||
registerJsonCommand({
|
||||
cli,
|
||||
logger,
|
||||
name: CLI_COMMANDS.projectAndroidApplicationGraph,
|
||||
description:
|
||||
"Project authenticated APK inventory Evidence into an Android application graph",
|
||||
inputSchema: androidApplicationProjectionInputSchema,
|
||||
workflow: (input) => {
|
||||
const result = projectAndroidApplicationEvidence(input);
|
||||
return result.ok
|
||||
? { ok: true, value: jsonValueSchema.parse(result.value) }
|
||||
: result;
|
||||
},
|
||||
});
|
||||
registerJsonCommand({
|
||||
cli,
|
||||
logger,
|
||||
name: CLI_COMMANDS.projectAppleApplicationGraph,
|
||||
description:
|
||||
"Project authenticated IPA inventory Evidence into an Apple application graph",
|
||||
inputSchema: appleApplicationProjectionInputSchema,
|
||||
workflow: (input) => {
|
||||
const result = projectAppleApplicationEvidence(input);
|
||||
return result.ok
|
||||
? { ok: true, value: jsonValueSchema.parse(result.value) }
|
||||
: result;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const registerObligationLedgerCommand = (
|
||||
|
||||
@@ -82,6 +82,8 @@ export const CLI_COMMANDS = Object.freeze({
|
||||
compareJavaScriptExportShapes: "compare-javascript-export-shapes",
|
||||
buildReconstructionObligationLedger: "build-reconstruction-obligation-ledger",
|
||||
evaluateReconstructionCoverage: "evaluate-reconstruction-coverage",
|
||||
projectAndroidApplicationGraph: "project-android-application-graph",
|
||||
projectAppleApplicationGraph: "project-apple-application-graph",
|
||||
});
|
||||
|
||||
/** Ordered primary CLI inventory; aliases are intentionally excluded. */
|
||||
|
||||
@@ -16,6 +16,14 @@ import {
|
||||
reconstructionObligationLedgerInputSchema,
|
||||
reconstructionObligationLedgerSchema,
|
||||
} from "../domain/reconstructionObligationLedgerSchemas.js";
|
||||
import {
|
||||
androidApplicationProjectionInputSchema,
|
||||
androidApplicationProjectionResultSchema,
|
||||
} from "../domain/androidApplication.js";
|
||||
import {
|
||||
appleApplicationProjectionInputSchema,
|
||||
appleApplicationProjectionResultSchema,
|
||||
} from "../domain/appleApplication.js";
|
||||
import type { ToolContract } from "./toolContracts.js";
|
||||
import { toolContractMetadata } from "./toolEffects.js";
|
||||
import { evidenceResultOf } from "./toolOutputSchemas.js";
|
||||
@@ -25,6 +33,10 @@ import {
|
||||
JAVASCRIPT_FEATURE_TRACE_EXAMPLE,
|
||||
SOURCE_TO_BUNDLE_COMPARISON_EXAMPLE,
|
||||
} from "./javascriptApplicationWorkflowExamples.js";
|
||||
import {
|
||||
ANDROID_APPLICATION_GRAPH_EXAMPLE,
|
||||
APPLE_APPLICATION_GRAPH_EXAMPLE,
|
||||
} from "./mobileApplicationGraphExamples.js";
|
||||
|
||||
const traceOutputSchema = evidenceResultOf(applicationFeatureTraceResultSchema);
|
||||
const semanticTraceOutputSchema = evidenceResultOf(
|
||||
@@ -246,6 +258,37 @@ export const APPLICATION_TOOL_CONTRACTS = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "project_android_application_graph",
|
||||
...toolContractMetadata("project_android_application_graph"),
|
||||
description:
|
||||
"Project authenticated APK inventory_artifact Evidence into an execution-free Android application inventory. Reports exact component paths and hashes, runtime-family hints, and path-based bridge hypotheses without decoding DEX, executing the APK, or claiming observed runtime calls.",
|
||||
kind: "application",
|
||||
inputSchema: androidApplicationProjectionInputSchema,
|
||||
outputSchema: evidenceResultOf(androidApplicationProjectionResultSchema),
|
||||
examples: [
|
||||
{
|
||||
title:
|
||||
"Project APK inventory Evidence into an Android application graph",
|
||||
input: ANDROID_APPLICATION_GRAPH_EXAMPLE,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "project_apple_application_graph",
|
||||
...toolContractMetadata("project_apple_application_graph"),
|
||||
description:
|
||||
"Project authenticated IPA inventory_artifact Evidence into an execution-free Apple application inventory. Reports exact component paths and hashes, runtime-family hints, and path-based bridge hypotheses without parsing plist/CMS semantics or claiming observed runtime calls.",
|
||||
kind: "application",
|
||||
inputSchema: appleApplicationProjectionInputSchema,
|
||||
outputSchema: evidenceResultOf(appleApplicationProjectionResultSchema),
|
||||
examples: [
|
||||
{
|
||||
title: "Project IPA inventory Evidence into an Apple application graph",
|
||||
input: APPLE_APPLICATION_GRAPH_EXAMPLE,
|
||||
},
|
||||
],
|
||||
},
|
||||
] as const satisfies readonly ToolContract[];
|
||||
|
||||
/** Resolve one named application contract without relying on array position. */
|
||||
@@ -270,6 +313,12 @@ export function applicationToolContract(
|
||||
export function applicationToolContract(
|
||||
name: "evaluate_reconstruction_coverage",
|
||||
): (typeof APPLICATION_TOOL_CONTRACTS)[6];
|
||||
export function applicationToolContract(
|
||||
name: "project_android_application_graph",
|
||||
): (typeof APPLICATION_TOOL_CONTRACTS)[7];
|
||||
export function applicationToolContract(
|
||||
name: "project_apple_application_graph",
|
||||
): (typeof APPLICATION_TOOL_CONTRACTS)[8];
|
||||
export function applicationToolContract(
|
||||
name: (typeof APPLICATION_TOOL_CONTRACTS)[number]["name"],
|
||||
): (typeof APPLICATION_TOOL_CONTRACTS)[number] {
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { canonicalDigest } from "../domain/comparisonSemantics.js";
|
||||
import { createEvidence } from "../domain/evidence.js";
|
||||
import { jsonValueSchema } from "../domain/jsonValue.js";
|
||||
|
||||
const inventory = (format: "apk" | "ipa", digit: string) => {
|
||||
const sha = digit.repeat(64);
|
||||
const artifactId = `art_${canonicalDigest({ sha256: sha }, "Artifact inventory")}`;
|
||||
const occurrenceId = `occ_${canonicalDigest(
|
||||
{ root: artifactId },
|
||||
"Artifact inventory",
|
||||
)}`;
|
||||
const nodes = [
|
||||
{
|
||||
artifact_id: artifactId,
|
||||
kind: "resource",
|
||||
format,
|
||||
sha256: sha,
|
||||
size: 1,
|
||||
media_type: null,
|
||||
architecture: null,
|
||||
executable: false,
|
||||
content_state: "materialized",
|
||||
limitations: [],
|
||||
},
|
||||
];
|
||||
const occurrences = [
|
||||
{
|
||||
occurrence_id: occurrenceId,
|
||||
artifact_id: artifactId,
|
||||
parent_occurrence_id: null,
|
||||
logical_path: ".",
|
||||
entry_kind: "file",
|
||||
declared_size: 1,
|
||||
compressed_size: null,
|
||||
executable: false,
|
||||
encrypted: false,
|
||||
hash_status: "verified",
|
||||
source_location: null,
|
||||
limitations: [],
|
||||
},
|
||||
];
|
||||
const graphSha256 = canonicalDigest(
|
||||
{
|
||||
nodes,
|
||||
occurrences,
|
||||
edges: [],
|
||||
integrity_contradictions: [],
|
||||
},
|
||||
"Artifact inventory",
|
||||
);
|
||||
return jsonValueSchema.parse({
|
||||
manifest: {
|
||||
manifest_id: `agm_${canonicalDigest(
|
||||
{
|
||||
root_artifact_id: artifactId,
|
||||
graph_sha256: graphSha256,
|
||||
},
|
||||
"Artifact inventory",
|
||||
)}`,
|
||||
root_artifact_id: artifactId,
|
||||
root_sha256: sha,
|
||||
root_format: format,
|
||||
graph_sha256: graphSha256,
|
||||
node_count: nodes.length,
|
||||
occurrence_count: occurrences.length,
|
||||
edge_count: 0,
|
||||
},
|
||||
nodes,
|
||||
occurrences,
|
||||
edges: [],
|
||||
provenance: [],
|
||||
limitations: [],
|
||||
});
|
||||
};
|
||||
|
||||
const provider = {
|
||||
id: "rea-artifact-graph",
|
||||
name: "REA safe artifact graph provider",
|
||||
version: "1",
|
||||
} as const;
|
||||
|
||||
/** Catalog example for Android application inventory projection. */
|
||||
export const ANDROID_APPLICATION_GRAPH_EXAMPLE = {
|
||||
inventory_evidence: [
|
||||
createEvidence(
|
||||
{ path: "Fixture.apk", sha256: "a".repeat(64), format: "apk" },
|
||||
provider,
|
||||
{
|
||||
operation: "inventory_artifact",
|
||||
parameters: {},
|
||||
result: inventory("apk", "a"),
|
||||
confidence: "observed",
|
||||
authority: "shipped-artifact",
|
||||
},
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
/** Catalog example for Apple application inventory projection. */
|
||||
export const APPLE_APPLICATION_GRAPH_EXAMPLE = {
|
||||
inventory_evidence: [
|
||||
createEvidence(
|
||||
{ path: "Fixture.ipa", sha256: "b".repeat(64), format: "ipa" },
|
||||
provider,
|
||||
{
|
||||
operation: "inventory_artifact",
|
||||
parameters: {},
|
||||
result: inventory("ipa", "b"),
|
||||
confidence: "observed",
|
||||
authority: "shipped-artifact",
|
||||
},
|
||||
),
|
||||
],
|
||||
};
|
||||
@@ -137,6 +137,8 @@ const PUBLISHED_TOOL_NAME_FLOOR = [
|
||||
"procedure_info",
|
||||
"procedure_pseudo_code",
|
||||
"procedure_references",
|
||||
"project_android_application_graph",
|
||||
"project_apple_application_graph",
|
||||
"project_managed_application_graph",
|
||||
"read_bytes",
|
||||
"read_function_instructions",
|
||||
|
||||
@@ -234,6 +234,8 @@ export const TOOL_EFFECTS: Readonly<Record<string, ToolEffects>> = {
|
||||
compare_javascript_export_shapes: evidence,
|
||||
build_reconstruction_obligation_ledger: evidence,
|
||||
evaluate_reconstruction_coverage: effects(),
|
||||
project_android_application_graph: evidence,
|
||||
project_apple_application_graph: evidence,
|
||||
open_binary: effects({ mutatesSession: true, launchesProcess: true }),
|
||||
close_binary: effects({
|
||||
mutatesSession: true,
|
||||
|
||||
@@ -1,39 +1,18 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
firstString,
|
||||
parseInterfaceBuilderRecords,
|
||||
record,
|
||||
} from "./interfaceBuilderKeyedArchive.js";
|
||||
import {
|
||||
nativeInvestigationEdgeSchema,
|
||||
nativeInvestigationGraphSchema,
|
||||
nativeInvestigationNodeSchema,
|
||||
nativeInvestigationTraceLimitsSchema,
|
||||
} from "./nativeInvestigationGraph.js";
|
||||
import { jsonValueSchema } from "./jsonValue.js";
|
||||
|
||||
const recordSchema = z.record(z.string(), z.unknown());
|
||||
|
||||
const objectNode = z.strictObject({
|
||||
id: z.string().min(1),
|
||||
kind: z.enum([
|
||||
"view_controller",
|
||||
"view",
|
||||
"control",
|
||||
"constraint",
|
||||
"layout_guide",
|
||||
"resource",
|
||||
"placeholder",
|
||||
"external_object",
|
||||
"other",
|
||||
]),
|
||||
class_name: z.string().nullable(),
|
||||
name: z.string().min(1),
|
||||
attributes: z.record(z.string(), jsonValueSchema),
|
||||
});
|
||||
|
||||
const connection = z.strictObject({
|
||||
id: z.string().min(1),
|
||||
kind: z.enum(["outlet", "action", "segue", "other"]),
|
||||
source_id: z.string().min(1),
|
||||
destination_id: z.string().nullable(),
|
||||
label: z.string().nullable(),
|
||||
attributes: z.record(z.string(), jsonValueSchema),
|
||||
});
|
||||
export { parseInterfaceBuilderRecords } from "./interfaceBuilderKeyedArchive.js";
|
||||
|
||||
/** One compiled Interface Builder document projected from ibtool output. */
|
||||
export const interfaceBuilderDocumentSchema = z.strictObject({
|
||||
@@ -55,9 +34,6 @@ export const interfaceBuilderAnalysisSchema = z.strictObject({
|
||||
|
||||
type InterfaceBuilderAnalysis = z.infer<typeof interfaceBuilderAnalysisSchema>;
|
||||
|
||||
export type InterfaceBuilderObject = z.infer<typeof objectNode>;
|
||||
export type InterfaceBuilderConnection = z.infer<typeof connection>;
|
||||
|
||||
export interface InterfaceBuilderDocumentInput {
|
||||
readonly relativePath: string;
|
||||
readonly archiveSha256: string;
|
||||
@@ -65,245 +41,8 @@ export interface InterfaceBuilderDocumentInput {
|
||||
readonly raw: unknown;
|
||||
}
|
||||
|
||||
/** Parse ibtool dictionaries or compiled keyed archives into typed records. */
|
||||
export const parseInterfaceBuilderRecords = (
|
||||
value: unknown,
|
||||
): {
|
||||
readonly objects: readonly InterfaceBuilderObject[];
|
||||
readonly connections: readonly InterfaceBuilderConnection[];
|
||||
readonly hierarchy: readonly unknown[];
|
||||
readonly classes: Readonly<Record<string, unknown>>;
|
||||
readonly objectCount: number;
|
||||
readonly omittedObjects: number;
|
||||
readonly omittedConnections: number;
|
||||
} => {
|
||||
const root = record(value);
|
||||
const keyed = parseKeyedArchive(root);
|
||||
if (keyed !== null) return keyed;
|
||||
const objectsRaw = record(root["com.apple.ibtool.document.objects"]);
|
||||
const connectionsRaw = record(root["com.apple.ibtool.document.connections"]);
|
||||
const hierarchyRaw = root["com.apple.ibtool.document.hierarchy"];
|
||||
const classesRaw = record(root["com.apple.ibtool.document.classes"]);
|
||||
const objects: InterfaceBuilderObject[] = [];
|
||||
const connections: InterfaceBuilderConnection[] = [];
|
||||
let omittedConnections = 0;
|
||||
const objectEntries = Object.entries(objectsRaw);
|
||||
for (const [id, raw] of objectEntries.slice(0, 20_000)) {
|
||||
const attributes = record(raw);
|
||||
const className = firstString(
|
||||
attributes["customClass"],
|
||||
attributes["class"],
|
||||
attributes["isa"],
|
||||
);
|
||||
const label = firstString(
|
||||
attributes["label"],
|
||||
attributes["title"],
|
||||
attributes["name"],
|
||||
className,
|
||||
);
|
||||
objects.push(
|
||||
objectNode.parse({
|
||||
id,
|
||||
kind: classifyObject(className),
|
||||
class_name: className,
|
||||
name: label ?? id,
|
||||
attributes: jsonSafeRecord(attributes),
|
||||
}),
|
||||
);
|
||||
}
|
||||
for (const [sourceId, rawConnections] of Object.entries(connectionsRaw)) {
|
||||
if (!Array.isArray(rawConnections)) continue;
|
||||
for (const [index, raw] of rawConnections.entries()) {
|
||||
if (connections.length >= 40_000) {
|
||||
omittedConnections += rawConnections.length - index;
|
||||
break;
|
||||
}
|
||||
const item = record(raw);
|
||||
const type = firstString(item.type, item.connectionType) ?? "unknown";
|
||||
const destination = firstString(
|
||||
item["destination-id"],
|
||||
item.destinationId,
|
||||
);
|
||||
const label = firstString(item.label, item.selector, item.identifier);
|
||||
connections.push(
|
||||
connection.parse({
|
||||
id: `${sourceId}:connection:${index}`,
|
||||
kind: classifyConnection(type),
|
||||
source_id: sourceId,
|
||||
destination_id: destination,
|
||||
label,
|
||||
attributes: jsonSafeRecord(item),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
return {
|
||||
objects,
|
||||
connections,
|
||||
hierarchy: Array.isArray(hierarchyRaw) ? hierarchyRaw.slice(0, 20_000) : [],
|
||||
classes: classesRaw,
|
||||
objectCount: objectEntries.length,
|
||||
omittedObjects: Math.max(0, objectEntries.length - 20_000),
|
||||
omittedConnections,
|
||||
};
|
||||
};
|
||||
|
||||
/** Project class and connection objects from an NSKeyedArchiver object table. */
|
||||
const parseKeyedArchive = (
|
||||
root: Record<string, unknown>,
|
||||
): ReturnType<typeof parseInterfaceBuilderRecords> | null => {
|
||||
if (root.$archiver !== "NSKeyedArchiver" || !Array.isArray(root.$objects))
|
||||
return null;
|
||||
const objectTable = root.$objects;
|
||||
const resolve = (value: unknown, depth = 0): unknown => {
|
||||
if (depth > 16) return null;
|
||||
const uid = record(value).UID;
|
||||
if (typeof uid === "number") {
|
||||
const target = objectTable[uid];
|
||||
const resolved = target === undefined ? null : resolve(target, depth + 1);
|
||||
return typeof resolved === "object" &&
|
||||
resolved !== null &&
|
||||
!Array.isArray(resolved)
|
||||
? { ...resolved, archiveUID: uid }
|
||||
: resolved;
|
||||
}
|
||||
if (Array.isArray(value))
|
||||
return value.map((item) => resolve(item, depth + 1));
|
||||
if (typeof value !== "object" || value === null) return value;
|
||||
const output: Record<string, unknown> = {};
|
||||
for (const [key, child] of Object.entries(value)) {
|
||||
if (key === "$class") {
|
||||
const classObject = record(
|
||||
typeof record(child).UID === "number"
|
||||
? objectTable[record(child).UID as number]
|
||||
: child,
|
||||
);
|
||||
output.className = firstString(classObject.$classname);
|
||||
} else output[key] = resolve(child, depth + 1);
|
||||
}
|
||||
return output;
|
||||
};
|
||||
const toHierarchy = (value: unknown, depth = 0): unknown[] => {
|
||||
if (depth > 32) return [];
|
||||
if (Array.isArray(value))
|
||||
return value.flatMap((item) => toHierarchy(item, depth + 1));
|
||||
if (typeof value !== "object" || value === null) return [];
|
||||
const item = record(value);
|
||||
const objectId = firstString(
|
||||
item.objectID,
|
||||
item["object-id"],
|
||||
item.id,
|
||||
typeof item.archiveUID === "number" ? String(item.archiveUID) : null,
|
||||
);
|
||||
const children = Object.entries(item)
|
||||
.filter(([key]) =>
|
||||
/(?:subviews|contentview|childviewcontrollers|children|views)$/iu.test(
|
||||
key,
|
||||
),
|
||||
)
|
||||
.flatMap(([, child]) => toHierarchy(child, depth + 1));
|
||||
return objectId === null ? children : [{ objectID: objectId, children }];
|
||||
};
|
||||
const objects: InterfaceBuilderObject[] = [];
|
||||
const connections: InterfaceBuilderConnection[] = [];
|
||||
let objectCount = 0;
|
||||
let omittedConnections = 0;
|
||||
for (const [index, raw] of objectTable.entries()) {
|
||||
if (index === 0) continue;
|
||||
const resolved = record(resolve(raw));
|
||||
const className = firstString(resolved.className);
|
||||
if (className === null) continue;
|
||||
objectCount += 1;
|
||||
const fields = Object.fromEntries(
|
||||
Object.entries(resolved).filter(([key]) => key !== "className"),
|
||||
);
|
||||
const authoredId = firstString(fields.objectID, fields["object-id"]);
|
||||
const objectId =
|
||||
authoredId ??
|
||||
(typeof fields.archiveUID === "number"
|
||||
? String(fields.archiveUID)
|
||||
: String(index));
|
||||
const kind = classifyObject(className);
|
||||
if (objects.length < 20_000)
|
||||
objects.push(
|
||||
objectNode.parse({
|
||||
id: objectId,
|
||||
kind,
|
||||
class_name: className,
|
||||
name:
|
||||
firstString(
|
||||
fields.label,
|
||||
fields.title,
|
||||
fields.identifier,
|
||||
fields.accessibilityLabel,
|
||||
className,
|
||||
) ?? `object ${String(index)}`,
|
||||
attributes: jsonSafeRecord(fields),
|
||||
}),
|
||||
);
|
||||
if (
|
||||
/(?:Outlet|Connection|Segue|ActionConnection|ControlConnector)/iu.test(
|
||||
className,
|
||||
)
|
||||
) {
|
||||
const controlAction = /ControlConnector/iu.test(className);
|
||||
const source = firstObjectReference(
|
||||
controlAction ? fields.destination : fields.source,
|
||||
controlAction ? fields.to : fields.from,
|
||||
controlAction ? fields.target : fields.owner,
|
||||
);
|
||||
if (source === null) continue;
|
||||
const kind = controlAction ? "action" : classifyConnection(className);
|
||||
const parsedConnection = connection.parse({
|
||||
id: `archive:${objectId}`,
|
||||
kind,
|
||||
source_id: source,
|
||||
destination_id: firstObjectReference(
|
||||
controlAction ? fields.source : fields.destination,
|
||||
controlAction ? fields.from : fields.to,
|
||||
controlAction ? fields.owner : fields.target,
|
||||
),
|
||||
label: firstString(
|
||||
fields.label,
|
||||
fields.selector,
|
||||
fields.identifier,
|
||||
fields.action,
|
||||
),
|
||||
attributes: jsonSafeRecord(fields),
|
||||
});
|
||||
if (connections.length < 40_000) connections.push(parsedConnection);
|
||||
else omittedConnections += 1;
|
||||
}
|
||||
}
|
||||
const top = record(root.$top);
|
||||
const topObject = resolve(top.root ?? top.UITopLevelObjectsKey);
|
||||
const hierarchy = toHierarchy(topObject);
|
||||
const classes = Object.fromEntries(
|
||||
objectTable.flatMap((raw) => {
|
||||
const item = record(raw);
|
||||
const name = firstString(item.$classname);
|
||||
return name === null
|
||||
? []
|
||||
: [
|
||||
[
|
||||
name,
|
||||
{
|
||||
superclasses: Array.isArray(item.$classes) ? item.$classes : [],
|
||||
},
|
||||
],
|
||||
];
|
||||
}),
|
||||
);
|
||||
return {
|
||||
objects,
|
||||
connections,
|
||||
hierarchy,
|
||||
classes,
|
||||
objectCount,
|
||||
omittedObjects: Math.max(0, objectCount - 20_000),
|
||||
omittedConnections,
|
||||
};
|
||||
};
|
||||
type InvestigationNode = z.infer<typeof nativeInvestigationNodeSchema>;
|
||||
type InvestigationEdge = z.infer<typeof nativeInvestigationEdgeSchema>;
|
||||
|
||||
/** Build the graph returned by the static Interface Builder archive decoder. */
|
||||
export const buildInterfaceBuilderAnalysis = (input: {
|
||||
@@ -312,15 +51,8 @@ export const buildInterfaceBuilderAnalysis = (input: {
|
||||
readonly documents: readonly InterfaceBuilderDocumentInput[];
|
||||
readonly limits: z.output<typeof interfaceBuilderLimitsSchema>;
|
||||
}) => {
|
||||
const nodes = new Map<
|
||||
string,
|
||||
z.infer<
|
||||
typeof import("./nativeInvestigationGraph.js").nativeInvestigationNodeSchema
|
||||
>
|
||||
>();
|
||||
const edges: z.infer<
|
||||
typeof import("./nativeInvestigationGraph.js").nativeInvestigationEdgeSchema
|
||||
>[] = [];
|
||||
const nodes = new Map<string, InvestigationNode>();
|
||||
const edges: InvestigationEdge[] = [];
|
||||
const coverage = [] as Array<{
|
||||
facet: string;
|
||||
status: "complete" | "partial" | "unsupported" | "not_requested";
|
||||
@@ -347,11 +79,7 @@ export const buildInterfaceBuilderAnalysis = (input: {
|
||||
artifact_sha256: document.archiveSha256,
|
||||
},
|
||||
];
|
||||
const addNode = (
|
||||
node: z.infer<
|
||||
typeof import("./nativeInvestigationGraph.js").nativeInvestigationNodeSchema
|
||||
>,
|
||||
): boolean => {
|
||||
const addNode = (node: InvestigationNode): boolean => {
|
||||
if (nodes.has(node.id)) return true;
|
||||
if (nodes.size >= input.limits.max_objects) {
|
||||
truncated = true;
|
||||
@@ -783,92 +511,6 @@ export const buildInterfaceBuilderAnalysis = (input: {
|
||||
});
|
||||
};
|
||||
|
||||
const record = (value: unknown): Record<string, unknown> =>
|
||||
recordSchema.safeParse(value).data ?? {};
|
||||
|
||||
const firstString = (...values: unknown[]): string | null => {
|
||||
for (const value of values)
|
||||
if (typeof value === "string" && value.length > 0) return value;
|
||||
return null;
|
||||
};
|
||||
|
||||
const firstObjectReference = (...values: unknown[]): string | null => {
|
||||
for (const value of values) {
|
||||
const direct = firstString(value);
|
||||
if (direct !== null) return direct;
|
||||
const item = record(value);
|
||||
const objectId = firstString(
|
||||
item.objectID,
|
||||
item["object-id"],
|
||||
item.id,
|
||||
item.identifier,
|
||||
typeof item.archiveUID === "number" ? String(item.archiveUID) : null,
|
||||
);
|
||||
if (objectId !== null) return objectId;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const classifyObject = (
|
||||
className: string | null,
|
||||
): InterfaceBuilderObject["kind"] => {
|
||||
const value = className?.toLowerCase() ?? "";
|
||||
if (value.includes("constraint")) return "constraint";
|
||||
if (value.includes("layoutguide")) return "layout_guide";
|
||||
if (value.includes("placeholder") || value.includes("firstresponder"))
|
||||
return "placeholder";
|
||||
if (
|
||||
value.includes("image") ||
|
||||
value.includes("color") ||
|
||||
value.includes("font")
|
||||
)
|
||||
return "resource";
|
||||
if (value.includes("external")) return "external_object";
|
||||
if (value.includes("viewcontroller") || value.includes("windowcontroller"))
|
||||
return "view_controller";
|
||||
if (
|
||||
value.includes("button") ||
|
||||
value.includes("control") ||
|
||||
value.includes("textfield") ||
|
||||
value.includes("slider") ||
|
||||
value.includes("menuitem")
|
||||
)
|
||||
return "control";
|
||||
if (
|
||||
value.includes("view") ||
|
||||
value.includes("window") ||
|
||||
value.includes("cell")
|
||||
)
|
||||
return "view";
|
||||
return "other";
|
||||
};
|
||||
|
||||
const classifyConnection = (
|
||||
type: string,
|
||||
): InterfaceBuilderConnection["kind"] => {
|
||||
const value = type.toLowerCase();
|
||||
if (value.includes("outlet")) return "outlet";
|
||||
if (value.includes("action")) return "action";
|
||||
if (value.includes("segue")) return "segue";
|
||||
return "other";
|
||||
};
|
||||
|
||||
const jsonSafeRecord = (value: Record<string, unknown>) =>
|
||||
Object.fromEntries(
|
||||
Object.entries(value).flatMap(([key, item]) => {
|
||||
try {
|
||||
const serialized = JSON.stringify(item);
|
||||
if (serialized === undefined) return [];
|
||||
const parsed = jsonValueSchema.safeParse(JSON.parse(serialized));
|
||||
return parsed.success ? [[key, parsed.data]] : [];
|
||||
} catch (cause: unknown) {
|
||||
// Unserializable entries are omitted from the record.
|
||||
void cause;
|
||||
return [];
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
/** Hard limits shared by Interface Builder archive aggregation and tracing. */
|
||||
export const interfaceBuilderLimitsSchema = z.strictObject({
|
||||
max_documents: z.number().int().min(1).max(64).default(64),
|
||||
|
||||
@@ -0,0 +1,363 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { jsonValueSchema } from "./jsonValue.js";
|
||||
|
||||
const recordSchema = z.record(z.string(), z.unknown());
|
||||
|
||||
const objectNode = z.strictObject({
|
||||
id: z.string().min(1),
|
||||
kind: z.enum([
|
||||
"view_controller",
|
||||
"view",
|
||||
"control",
|
||||
"constraint",
|
||||
"layout_guide",
|
||||
"resource",
|
||||
"placeholder",
|
||||
"external_object",
|
||||
"other",
|
||||
]),
|
||||
class_name: z.string().nullable(),
|
||||
name: z.string().min(1),
|
||||
attributes: z.record(z.string(), jsonValueSchema),
|
||||
});
|
||||
|
||||
const connection = z.strictObject({
|
||||
id: z.string().min(1),
|
||||
kind: z.enum(["outlet", "action", "segue", "other"]),
|
||||
source_id: z.string().min(1),
|
||||
destination_id: z.string().nullable(),
|
||||
label: z.string().nullable(),
|
||||
attributes: z.record(z.string(), jsonValueSchema),
|
||||
});
|
||||
|
||||
type InterfaceBuilderObject = z.infer<typeof objectNode>;
|
||||
type InterfaceBuilderConnection = z.infer<typeof connection>;
|
||||
|
||||
/** Parse ibtool dictionaries or compiled keyed archives into typed records. */
|
||||
export const parseInterfaceBuilderRecords = (
|
||||
value: unknown,
|
||||
): {
|
||||
readonly objects: readonly InterfaceBuilderObject[];
|
||||
readonly connections: readonly InterfaceBuilderConnection[];
|
||||
readonly hierarchy: readonly unknown[];
|
||||
readonly classes: Readonly<Record<string, unknown>>;
|
||||
readonly objectCount: number;
|
||||
readonly omittedObjects: number;
|
||||
readonly omittedConnections: number;
|
||||
} => {
|
||||
const root = record(value);
|
||||
const keyed = parseKeyedArchive(root);
|
||||
if (keyed !== null) return keyed;
|
||||
const objectsRaw = record(root["com.apple.ibtool.document.objects"]);
|
||||
const connectionsRaw = record(root["com.apple.ibtool.document.connections"]);
|
||||
const hierarchyRaw = root["com.apple.ibtool.document.hierarchy"];
|
||||
const classesRaw = record(root["com.apple.ibtool.document.classes"]);
|
||||
const objects: InterfaceBuilderObject[] = [];
|
||||
const connections: InterfaceBuilderConnection[] = [];
|
||||
let omittedConnections = 0;
|
||||
const objectEntries = Object.entries(objectsRaw);
|
||||
for (const [id, raw] of objectEntries.slice(0, 20_000)) {
|
||||
const attributes = record(raw);
|
||||
const className = firstString(
|
||||
attributes["customClass"],
|
||||
attributes["class"],
|
||||
attributes["isa"],
|
||||
);
|
||||
const label = firstString(
|
||||
attributes["label"],
|
||||
attributes["title"],
|
||||
attributes["name"],
|
||||
className,
|
||||
);
|
||||
objects.push(
|
||||
objectNode.parse({
|
||||
id,
|
||||
kind: classifyObject(className),
|
||||
class_name: className,
|
||||
name: label ?? id,
|
||||
attributes: jsonSafeRecord(attributes),
|
||||
}),
|
||||
);
|
||||
}
|
||||
for (const [sourceId, rawConnections] of Object.entries(connectionsRaw)) {
|
||||
if (!Array.isArray(rawConnections)) continue;
|
||||
for (const [index, raw] of rawConnections.entries()) {
|
||||
if (connections.length >= 40_000) {
|
||||
omittedConnections += rawConnections.length - index;
|
||||
break;
|
||||
}
|
||||
const item = record(raw);
|
||||
const type = firstString(item.type, item.connectionType) ?? "unknown";
|
||||
const destination = firstString(
|
||||
item["destination-id"],
|
||||
item.destinationId,
|
||||
);
|
||||
const label = firstString(item.label, item.selector, item.identifier);
|
||||
connections.push(
|
||||
connection.parse({
|
||||
id: `${sourceId}:connection:${index}`,
|
||||
kind: classifyConnection(type),
|
||||
source_id: sourceId,
|
||||
destination_id: destination,
|
||||
label,
|
||||
attributes: jsonSafeRecord(item),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
return {
|
||||
objects,
|
||||
connections,
|
||||
hierarchy: Array.isArray(hierarchyRaw) ? hierarchyRaw.slice(0, 20_000) : [],
|
||||
classes: classesRaw,
|
||||
objectCount: objectEntries.length,
|
||||
omittedObjects: Math.max(0, objectEntries.length - 20_000),
|
||||
omittedConnections,
|
||||
};
|
||||
};
|
||||
|
||||
/** Project class and connection objects from an NSKeyedArchiver object table. */
|
||||
const parseKeyedArchive = (
|
||||
root: Record<string, unknown>,
|
||||
): ReturnType<typeof parseInterfaceBuilderRecords> | null => {
|
||||
if (root.$archiver !== "NSKeyedArchiver" || !Array.isArray(root.$objects))
|
||||
return null;
|
||||
const objectTable = root.$objects;
|
||||
const resolve = (value: unknown, depth = 0): unknown => {
|
||||
if (depth > 16) return null;
|
||||
const uid = record(value).UID;
|
||||
if (typeof uid === "number") {
|
||||
const target = objectTable[uid];
|
||||
const resolved = target === undefined ? null : resolve(target, depth + 1);
|
||||
return typeof resolved === "object" &&
|
||||
resolved !== null &&
|
||||
!Array.isArray(resolved)
|
||||
? { ...resolved, archiveUID: uid }
|
||||
: resolved;
|
||||
}
|
||||
if (Array.isArray(value))
|
||||
return value.map((item) => resolve(item, depth + 1));
|
||||
if (typeof value !== "object" || value === null) return value;
|
||||
const output: Record<string, unknown> = {};
|
||||
for (const [key, child] of Object.entries(value)) {
|
||||
if (key === "$class") {
|
||||
const classObject = record(
|
||||
typeof record(child).UID === "number"
|
||||
? objectTable[record(child).UID as number]
|
||||
: child,
|
||||
);
|
||||
output.className = firstString(classObject.$classname);
|
||||
} else output[key] = resolve(child, depth + 1);
|
||||
}
|
||||
return output;
|
||||
};
|
||||
const toHierarchy = (value: unknown, depth = 0): unknown[] => {
|
||||
if (depth > 32) return [];
|
||||
if (Array.isArray(value))
|
||||
return value.flatMap((item) => toHierarchy(item, depth + 1));
|
||||
if (typeof value !== "object" || value === null) return [];
|
||||
const item = record(value);
|
||||
const objectId = firstString(
|
||||
item.objectID,
|
||||
item["object-id"],
|
||||
item.id,
|
||||
typeof item.archiveUID === "number" ? String(item.archiveUID) : null,
|
||||
);
|
||||
const children = Object.entries(item)
|
||||
.filter(([key]) =>
|
||||
/(?:subviews|contentview|childviewcontrollers|children|views)$/iu.test(
|
||||
key,
|
||||
),
|
||||
)
|
||||
.flatMap(([, child]) => toHierarchy(child, depth + 1));
|
||||
return objectId === null ? children : [{ objectID: objectId, children }];
|
||||
};
|
||||
const objects: InterfaceBuilderObject[] = [];
|
||||
const connections: InterfaceBuilderConnection[] = [];
|
||||
let objectCount = 0;
|
||||
let omittedConnections = 0;
|
||||
for (const [index, raw] of objectTable.entries()) {
|
||||
if (index === 0) continue;
|
||||
const resolved = record(resolve(raw));
|
||||
const className = firstString(resolved.className);
|
||||
if (className === null) continue;
|
||||
objectCount += 1;
|
||||
const fields = Object.fromEntries(
|
||||
Object.entries(resolved).filter(([key]) => key !== "className"),
|
||||
);
|
||||
const authoredId = firstString(fields.objectID, fields["object-id"]);
|
||||
const objectId =
|
||||
authoredId ??
|
||||
(typeof fields.archiveUID === "number"
|
||||
? String(fields.archiveUID)
|
||||
: String(index));
|
||||
const kind = classifyObject(className);
|
||||
if (objects.length < 20_000)
|
||||
objects.push(
|
||||
objectNode.parse({
|
||||
id: objectId,
|
||||
kind,
|
||||
class_name: className,
|
||||
name:
|
||||
firstString(
|
||||
fields.label,
|
||||
fields.title,
|
||||
fields.identifier,
|
||||
fields.accessibilityLabel,
|
||||
className,
|
||||
) ?? `object ${String(index)}`,
|
||||
attributes: jsonSafeRecord(fields),
|
||||
}),
|
||||
);
|
||||
if (
|
||||
/(?:Outlet|Connection|Segue|ActionConnection|ControlConnector)/iu.test(
|
||||
className,
|
||||
)
|
||||
) {
|
||||
const controlAction = /ControlConnector/iu.test(className);
|
||||
const source = firstObjectReference(
|
||||
controlAction ? fields.destination : fields.source,
|
||||
controlAction ? fields.to : fields.from,
|
||||
controlAction ? fields.target : fields.owner,
|
||||
);
|
||||
if (source === null) continue;
|
||||
const kind = controlAction ? "action" : classifyConnection(className);
|
||||
const parsedConnection = connection.parse({
|
||||
id: `archive:${objectId}`,
|
||||
kind,
|
||||
source_id: source,
|
||||
destination_id: firstObjectReference(
|
||||
controlAction ? fields.source : fields.destination,
|
||||
controlAction ? fields.from : fields.to,
|
||||
controlAction ? fields.owner : fields.target,
|
||||
),
|
||||
label: firstString(
|
||||
fields.label,
|
||||
fields.selector,
|
||||
fields.identifier,
|
||||
fields.action,
|
||||
),
|
||||
attributes: jsonSafeRecord(fields),
|
||||
});
|
||||
if (connections.length < 40_000) connections.push(parsedConnection);
|
||||
else omittedConnections += 1;
|
||||
}
|
||||
}
|
||||
const top = record(root.$top);
|
||||
const topObject = resolve(top.root ?? top.UITopLevelObjectsKey);
|
||||
const hierarchy = toHierarchy(topObject);
|
||||
const classes = Object.fromEntries(
|
||||
objectTable.flatMap((raw) => {
|
||||
const item = record(raw);
|
||||
const name = firstString(item.$classname);
|
||||
return name === null
|
||||
? []
|
||||
: [
|
||||
[
|
||||
name,
|
||||
{
|
||||
superclasses: Array.isArray(item.$classes) ? item.$classes : [],
|
||||
},
|
||||
],
|
||||
];
|
||||
}),
|
||||
);
|
||||
return {
|
||||
objects,
|
||||
connections,
|
||||
hierarchy,
|
||||
classes,
|
||||
objectCount,
|
||||
omittedObjects: Math.max(0, objectCount - 20_000),
|
||||
omittedConnections,
|
||||
};
|
||||
};
|
||||
|
||||
/** Coerce unknown values to a string-keyed record; non-objects become `{}`. */
|
||||
export const record = (value: unknown): Record<string, unknown> =>
|
||||
recordSchema.safeParse(value).data ?? {};
|
||||
|
||||
/** First non-empty string among the candidates, otherwise null. */
|
||||
export const firstString = (...values: unknown[]): string | null => {
|
||||
for (const value of values)
|
||||
if (typeof value === "string" && value.length > 0) return value;
|
||||
return null;
|
||||
};
|
||||
|
||||
const firstObjectReference = (...values: unknown[]): string | null => {
|
||||
for (const value of values) {
|
||||
const direct = firstString(value);
|
||||
if (direct !== null) return direct;
|
||||
const item = record(value);
|
||||
const objectId = firstString(
|
||||
item.objectID,
|
||||
item["object-id"],
|
||||
item.id,
|
||||
item.identifier,
|
||||
typeof item.archiveUID === "number" ? String(item.archiveUID) : null,
|
||||
);
|
||||
if (objectId !== null) return objectId;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const classifyObject = (
|
||||
className: string | null,
|
||||
): InterfaceBuilderObject["kind"] => {
|
||||
const value = className?.toLowerCase() ?? "";
|
||||
if (value.includes("constraint")) return "constraint";
|
||||
if (value.includes("layoutguide")) return "layout_guide";
|
||||
if (value.includes("placeholder") || value.includes("firstresponder"))
|
||||
return "placeholder";
|
||||
if (
|
||||
value.includes("image") ||
|
||||
value.includes("color") ||
|
||||
value.includes("font")
|
||||
)
|
||||
return "resource";
|
||||
if (value.includes("external")) return "external_object";
|
||||
if (value.includes("viewcontroller") || value.includes("windowcontroller"))
|
||||
return "view_controller";
|
||||
if (
|
||||
value.includes("button") ||
|
||||
value.includes("control") ||
|
||||
value.includes("textfield") ||
|
||||
value.includes("slider") ||
|
||||
value.includes("menuitem")
|
||||
)
|
||||
return "control";
|
||||
if (
|
||||
value.includes("view") ||
|
||||
value.includes("window") ||
|
||||
value.includes("cell")
|
||||
)
|
||||
return "view";
|
||||
return "other";
|
||||
};
|
||||
|
||||
const classifyConnection = (
|
||||
type: string,
|
||||
): InterfaceBuilderConnection["kind"] => {
|
||||
const value = type.toLowerCase();
|
||||
if (value.includes("outlet")) return "outlet";
|
||||
if (value.includes("action")) return "action";
|
||||
if (value.includes("segue")) return "segue";
|
||||
return "other";
|
||||
};
|
||||
|
||||
const jsonSafeRecord = (value: Record<string, unknown>) =>
|
||||
Object.fromEntries(
|
||||
Object.entries(value).flatMap(([key, item]) => {
|
||||
try {
|
||||
const serialized = JSON.stringify(item);
|
||||
if (serialized === undefined) return [];
|
||||
const parsed = jsonValueSchema.safeParse(JSON.parse(serialized));
|
||||
return parsed.success ? [[key, parsed.data]] : [];
|
||||
} catch (cause: unknown) {
|
||||
// Unserializable entries are omitted from the record.
|
||||
void cause;
|
||||
return [];
|
||||
}
|
||||
}),
|
||||
);
|
||||
@@ -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");
|
||||
|
||||
@@ -7,6 +7,7 @@ import { registerCompareSourceToBundleTool } from "./registerApplicationTools/co
|
||||
import { registerCompareJavaScriptExportShapesTool } from "./registerApplicationTools/compareExportShapes.js";
|
||||
import { registerCoverageTools } from "./registerApplicationTools/coverage.js";
|
||||
import { registerReconstructionObligationLedgerTool } from "./registerApplicationTools/obligationLedger.js";
|
||||
import { registerProjectMobileApplicationGraphTools } from "./registerApplicationTools/projectMobileApplicationGraph.js";
|
||||
import type { ApplicationToolRegistration } from "./registerApplicationTools/types.js";
|
||||
|
||||
export type { ApplicationToolRegistration };
|
||||
@@ -23,4 +24,5 @@ export const registerApplicationTools = (
|
||||
registerCompareJavaScriptExportShapesTool(server, options);
|
||||
registerReconstructionObligationLedgerTool(server, options);
|
||||
registerCoverageTools(server, options);
|
||||
registerProjectMobileApplicationGraphTools(server, options);
|
||||
};
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { McpServer } from "@modelcontextprotocol/server";
|
||||
|
||||
import { projectAndroidApplicationEvidence } from "../../application/AndroidApplicationService.js";
|
||||
import { projectAppleApplicationEvidence } from "../../application/AppleApplicationService.js";
|
||||
import {
|
||||
APPLICATION_TOOL_CONTRACTS,
|
||||
applicationToolContract,
|
||||
} from "../../contracts/applicationToolContracts.js";
|
||||
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import type { Evidence } from "../../domain/evidence.js";
|
||||
import type { Result } from "../../domain/result.js";
|
||||
import { recordSessionEvidenceSources } from "../sessionEvidence.js";
|
||||
import { logToolExecution } from "../toolLogging.js";
|
||||
import { toolRegistrationOptions } from "../toolRegistrationOptions.js";
|
||||
import { toCallToolResult } from "../toolResult.js";
|
||||
import { recordResult } from "./helpers.js";
|
||||
import type { ApplicationToolRegistration } from "./types.js";
|
||||
|
||||
/** Register execution-free Android and Apple inventory projection tools. */
|
||||
export const registerProjectMobileApplicationGraphTools = (
|
||||
server: McpServer,
|
||||
options: ApplicationToolRegistration,
|
||||
): void => {
|
||||
registerProjection({
|
||||
server,
|
||||
options,
|
||||
contract: applicationToolContract("project_android_application_graph"),
|
||||
project: projectAndroidApplicationEvidence,
|
||||
});
|
||||
registerProjection({
|
||||
server,
|
||||
options,
|
||||
contract: applicationToolContract("project_apple_application_graph"),
|
||||
project: projectAppleApplicationEvidence,
|
||||
});
|
||||
};
|
||||
|
||||
const registerProjection = (context: {
|
||||
readonly server: McpServer;
|
||||
readonly options: ApplicationToolRegistration;
|
||||
readonly contract: (typeof APPLICATION_TOOL_CONTRACTS)[number];
|
||||
readonly project: (input: unknown) => Result<Evidence, AnalysisError>;
|
||||
}): void => {
|
||||
const { server, options, contract, project } = context;
|
||||
server.registerTool(
|
||||
contract.name,
|
||||
toolRegistrationOptions(contract),
|
||||
async (input: unknown) => {
|
||||
const result = await logToolExecution(options.logger, contract.name, () =>
|
||||
Promise.resolve(project(input)),
|
||||
);
|
||||
if (!result.ok) return toCallToolResult(result, contract);
|
||||
const recordedSources = recordSessionEvidenceSources(
|
||||
options.recordEvidence,
|
||||
inventoryEvidenceSources(input),
|
||||
);
|
||||
if (!recordedSources.ok)
|
||||
return toCallToolResult(recordedSources, contract);
|
||||
return recordResult(options, contract, result.value);
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const inventoryEvidenceSources = (input: unknown): readonly Evidence[] => {
|
||||
if (
|
||||
typeof input !== "object" ||
|
||||
input === null ||
|
||||
!("inventory_evidence" in input) ||
|
||||
!Array.isArray(input.inventory_evidence)
|
||||
)
|
||||
return [];
|
||||
return input.inventory_evidence as readonly Evidence[];
|
||||
};
|
||||
@@ -18,6 +18,8 @@ const JSON_COMMANDS = [
|
||||
"build-reconstruction-obligation-ledger",
|
||||
],
|
||||
["evaluate-reconstruction-coverage", "evaluate-reconstruction-coverage"],
|
||||
["project-android-application-graph", "project-android-application-graph"],
|
||||
["project-apple-application-graph", "project-apple-application-graph"],
|
||||
["import-managed-reconstruction", "import-managed-reconstruction"],
|
||||
["verify-managed-native-boundaries", "verify-managed-native-boundaries"],
|
||||
["project-managed-application-graph", "project-managed-application-graph"],
|
||||
|
||||
@@ -8,6 +8,8 @@ import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
import { SUPPORTED_CLIENT_DEFINITIONS } from "../../../src/application/SupportedClients.js";
|
||||
import {
|
||||
ANDROID_APPLICATION_PROVIDER,
|
||||
APPLE_APPLICATION_PROVIDER,
|
||||
ARTIFACT_GRAPH_PROVIDER,
|
||||
JAVASCRIPT_APPLICATION_PROVIDER,
|
||||
JAVASCRIPT_APPLICATION_WORKFLOW_PROVIDER,
|
||||
@@ -108,6 +110,8 @@ describe("canonical product catalog", () => {
|
||||
IDA_PROVIDER_IDENTITY,
|
||||
NATIVE_MACOS_PROVIDER_IDENTITY,
|
||||
ARTIFACT_GRAPH_PROVIDER,
|
||||
ANDROID_APPLICATION_PROVIDER,
|
||||
APPLE_APPLICATION_PROVIDER,
|
||||
MANAGED_STATIC_PROVIDER,
|
||||
MANAGED_WORKFLOW_PROVIDER,
|
||||
CDP_BROWSER_PROVIDER_IDENTITY,
|
||||
@@ -164,6 +168,14 @@ describe("canonical product catalog", () => {
|
||||
"trace_application_feature",
|
||||
"trace_javascript_semantics",
|
||||
]);
|
||||
expect(
|
||||
catalog.providers.find(({ id }) => id === ANDROID_APPLICATION_PROVIDER.id)
|
||||
?.capabilities,
|
||||
).toEqual(["project_android_application_graph"]);
|
||||
expect(
|
||||
catalog.providers.find(({ id }) => id === APPLE_APPLICATION_PROVIDER.id)
|
||||
?.capabilities,
|
||||
).toEqual(["project_apple_application_graph"]);
|
||||
expect(
|
||||
catalog.providers.find(({ id }) => id === MANAGED_WORKFLOW_PROVIDER.id)
|
||||
?.capabilities,
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
||||
import {
|
||||
TextReader,
|
||||
Uint8ArrayReader,
|
||||
Uint8ArrayWriter,
|
||||
ZipWriter,
|
||||
} from "@zip.js/zip.js";
|
||||
import { afterEach, expect, it } from "vitest";
|
||||
|
||||
import { runProviderAnalysis } from "../../../src/application/DirectAnalysis.js";
|
||||
import { parseEvidence } from "../../../src/domain/evidence.js";
|
||||
import { createTestBinarySession } from "../../fixtures/binarySession.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { createServer } from "../../../src/server/createServer.js";
|
||||
|
||||
const resources: Array<{ close(): Promise<unknown> }> = [];
|
||||
afterEach(async () => {
|
||||
for (const resource of resources.splice(0)) await resource.close();
|
||||
});
|
||||
|
||||
async function connect() {
|
||||
const session = createTestBinarySession(() => {
|
||||
throw new Error("mobile graph projection must not launch a provider");
|
||||
});
|
||||
const server = createServer(session, session);
|
||||
const client = new Client({
|
||||
name: "mobile-application-graph-test",
|
||||
version: "1",
|
||||
});
|
||||
resources.push(client, server, session);
|
||||
const [a, b] = InMemoryTransport.createLinkedPair();
|
||||
await server.connect(b);
|
||||
await client.connect(a);
|
||||
return client;
|
||||
}
|
||||
|
||||
const writeApk = async (directory: string): Promise<string> => {
|
||||
const path = join(directory, "Fixture.apk");
|
||||
const writer = new ZipWriter(new Uint8ArrayWriter());
|
||||
await writer.add(
|
||||
"AndroidManifest.xml",
|
||||
new Uint8ArrayReader(Uint8Array.from([3, 0, 8, 0])),
|
||||
);
|
||||
await writer.add(
|
||||
"classes.dex",
|
||||
new Uint8ArrayReader(
|
||||
Uint8Array.from([0x64, 0x65, 0x78, 0x0a, 0x30, 0x33, 0x35, 0]),
|
||||
),
|
||||
);
|
||||
await writeFile(path, await writer.close());
|
||||
return path;
|
||||
};
|
||||
|
||||
const writeIpa = async (directory: string): Promise<string> => {
|
||||
const path = join(directory, "Fixture.ipa");
|
||||
const writer = new ZipWriter(new Uint8ArrayWriter());
|
||||
await writer.add(
|
||||
"Payload/Fixture.app/Info.plist",
|
||||
new TextReader("<plist></plist>"),
|
||||
);
|
||||
await writer.add(
|
||||
"Payload/Fixture.app/Fixture",
|
||||
new Uint8ArrayReader(
|
||||
Uint8Array.from([0xcf, 0xfa, 0xed, 0xfe, 0x0c, 0, 0, 1]),
|
||||
),
|
||||
);
|
||||
await writeFile(path, await writer.close());
|
||||
return path;
|
||||
};
|
||||
|
||||
it("advertises and executes Android and Apple inventory projections", async () => {
|
||||
const client = await connect();
|
||||
const { tools } = await client.listTools();
|
||||
const names = new Set(tools.map(({ name }) => name));
|
||||
expect(names.has("project_android_application_graph")).toBe(true);
|
||||
expect(names.has("project_apple_application_graph")).toBe(true);
|
||||
|
||||
const root = await createTestTempDirectory("rea-mobile-graph-mcp-");
|
||||
const apkInventory = parseEvidence(
|
||||
await runProviderAnalysis(await writeApk(root), "inventory_artifact", {}),
|
||||
);
|
||||
const android = await client.callTool({
|
||||
name: "project_android_application_graph",
|
||||
arguments: { inventory_evidence: [apkInventory] },
|
||||
});
|
||||
if (android.isError === true)
|
||||
throw new Error(JSON.stringify(android.structuredContent, null, 2));
|
||||
expect(android.structuredContent).toMatchObject({
|
||||
evidence: {
|
||||
operation: "project_android_application_graph",
|
||||
provider: { id: "rea-android-application" },
|
||||
},
|
||||
});
|
||||
|
||||
const ipaInventory = parseEvidence(
|
||||
await runProviderAnalysis(await writeIpa(root), "inventory_artifact", {}),
|
||||
);
|
||||
const apple = await client.callTool({
|
||||
name: "project_apple_application_graph",
|
||||
arguments: { inventory_evidence: [ipaInventory] },
|
||||
});
|
||||
if (apple.isError === true)
|
||||
throw new Error(JSON.stringify(apple.structuredContent, null, 2));
|
||||
expect(apple.structuredContent).toMatchObject({
|
||||
evidence: {
|
||||
operation: "project_apple_application_graph",
|
||||
provider: { id: "rea-apple-application" },
|
||||
},
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user