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:
Iwakurakaoru0822 committed 2026-10-06 22:47:23 -07:00
commit 01779a6f61
26 files changed
+877 -406

No files matched your search

+15 -15
View File
@@ -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
View File
@@ -223,7 +223,7 @@ rea setup
| مراقبة المتصفح | 9 | بنية الصفحات وبيانات الشبكة والبرامج النصية وخرائط المصدر واكتشاف WebMCP والصور ومقارنة الالتقاط |
| تحليل Electron | 5 | مراقبة العارضين وخريطة التطبيق الثابتة والمواءمة بين نتائج الثبات والتشغيل |
| وقت تشغيل JavaScript | 2 | اكتشاف أهداف Node/Electron Inspector ومواضع البرامج وأحداث سياقات التنفيذ |
| سير عمل التطبيقات | 9 | تصدير النصوص البرمجية الملتقطة، تتبع الميزات عبر الطبقات ومقارنة الإصدارات ومواءمة المصدر التاريخي ومقارنة أشكال القيم المعادة والتحقق من إعادة الإنشاء |
| سير عمل التطبيقات | 11 | تصدير النصوص البرمجية الملتقطة، تتبع الميزات عبر الطبقات ومقارنة الإصدارات ومواءمة المصدر التاريخي ومقارنة أشكال القيم المعادة والتحقق من إعادة الإنشاء |
| مساحة العمل والملاحظة | 21 | الجلسات وحزم الأدلة وسياق التنقل ومقارنة العمليات والمخرجات والدوال وتسجيل الأسئلة غير المحسومة |
## خطة العمل
+1 -1
View File
@@ -212,7 +212,7 @@ REA は手順 1〜5 のバイナリ解析を処理し、手順 6 はエージェ
| ブラウザー観察 | 9 | ページ構成、ネットワークメタデータ、スクリプト、ソースマップ、WebMCP 検出、スクリーンショット、キャプチャ比較 |
| Electron 解析 | 5 | レンダラー観察、静的アプリマッピング、静的/実行時結果の対応付け |
| JavaScript 実行時 | 2 | Node/Electron Inspector 対象の検出、スクリプト位置、実行コンテキストイベント |
| アプリワークフロー | 9 | 取得した Web スクリプトのエクスポート、層をまたぐ機能トレース、ビルド比較、履歴ソース対応、静的戻り値形状の比較、再実装検証 |
| アプリワークフロー | 11 | 取得した Web スクリプトのエクスポート、層をまたぐ機能トレース、ビルド比較、履歴ソース対応、静的戻り値形状の比較、再実装検証 |
| ワークスペースと観察 | 21 | セッション、証跡バンドル、ナビゲーション文脈、プロセス・成果物・関数比較、未解決事項の記録 |
## ロードマップ
+1 -1
View File
@@ -213,7 +213,7 @@ REA는 1–5단계의 바이너리 분석을 처리합니다. 6단계는 에이
| 브라우저 관찰 | 9 | 페이지 구조, 네트워크 메타데이터, 스크립트, 소스 맵, WebMCP 탐색, 스크린샷, 캡처 비교 |
| Electron 분석 | 5 | 렌더러 관찰, 정적 앱 매핑, 정적·런타임 결과 연결 |
| JavaScript 런타임 | 2 | Node/Electron Inspector 대상 탐색, 스크립트 위치, 실행 컨텍스트 이벤트 |
| 앱 워크플로 | 9 | 캡처한 웹 스크립트 내보내기, 계층 간 기능 추적, 빌드 비교, 히스토리 소스 매핑, 정적 반환 구조 비교, 재구현 검증 |
| 앱 워크플로 | 11 | 캡처한 웹 스크립트 내보내기, 계층 간 기능 추적, 빌드 비교, 히스토리 소스 매핑, 정적 반환 구조 비교, 재구현 검증 |
| 작업 공간과 관찰 | 21 | 세션, 근거 번들, 탐색 컨텍스트, 프로세스·아티팩트·함수 비교, 미해결 항목 기록 |
## 로드맵
+1 -1
View File
@@ -212,7 +212,7 @@ REA 负责第 1–5 步中的二进制分析。第 6 步由智能体使用其常
| 浏览器观察 | 9 | 页面结构、网络元数据、脚本、来源映射、WebMCP 发现、截图与捕获比较 |
| Electron 分析 | 5 | 渲染进程观察、静态应用映射、静态/运行时结果关联 |
| JavaScript 运行时 | 2 | Node/Electron Inspector 目标发现、脚本位置与执行上下文事件 |
| 应用工作流 | 9 | 捕获的网站脚本导出、跨层功能追踪、构建比较、历史源码映射、静态返回结构比较与重建验证 |
| 应用工作流 | 11 | 捕获的网站脚本导出、跨层功能追踪、构建比较、历史源码映射、静态返回结构比较与重建验证 |
| 工作区与观察 | 21 | 会话、证据包、导航上下文、进程/产物/函数比较与待解决问题跟踪 |
## 路线图
+18 -2
View File
@@ -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
+13
View File
@@ -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
+24 -8
View File
@@ -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"
}
}
}
+2
View File
@@ -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": [
+17 -1
View File
@@ -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,
+2 -2
View File
@@ -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
+32
View File
@@ -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 = (
+2
View File
@@ -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. */
+49
View File
@@ -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",
},
),
],
};
+2
View File
@@ -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",
+2
View File
@@ -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,
+13 -371
View File
@@ -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),
+363
View File
@@ -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 [];
}
}),
);
+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");
+2
View File
@@ -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"],
+12
View File
@@ -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" },
},
});
});