diff --git a/.github/workflows/real-evm-interface.yml b/.github/workflows/real-evm-interface.yml
new file mode 100644
index 0000000000..a493481812
--- /dev/null
+++ b/.github/workflows/real-evm-interface.yml
@@ -0,0 +1,66 @@
+name: Real offline EVM interface
+
+on:
+ workflow_dispatch:
+ pull_request:
+ paths:
+ - ".github/workflows/real-evm-interface.yml"
+ - "src/evm/**"
+ - "src/domain/evm/**"
+ - "src/application/evm/**"
+ - "src/contracts/evm/**"
+ - "src/composition/evm.ts"
+ - "src/cli/evmCommands.ts"
+ - "src/server/registerEvmTools.ts"
+ - "src/server/createServer.ts"
+ - "src/cli.ts"
+ - "src/cliCommandNames.ts"
+ - "src/contracts/toolContracts.ts"
+ - "src/contracts/toolEffects.ts"
+ - "src/process/OwnedCommand.ts"
+ - "src/process/ProviderProcess.ts"
+ - "src/process/ProcessOwnership*.ts"
+ - "src/process/ProviderDeadline.ts"
+ - "src/process/PrivateRuntimeRoot.ts"
+ - "src/artifacts/readStableArtifact*.ts"
+ - "src/artifacts/ArtifactReader.ts"
+ - "scripts/verify-evm-interface.mjs"
+ - "scripts/fixtures/evm-interface.sol"
+ - "third_party/evmole/**"
+ - "package.json"
+ - "package-lock.json"
+
+permissions:
+ contents: read
+
+concurrency:
+ group: real-evm-interface-${{ github.event.pull_request.number || github.ref }}
+ cancel-in-progress: true
+
+env:
+ NODE_OPTIONS: --max-old-space-size=768
+ HUSKY: "0"
+
+jobs:
+ real-evm-interface:
+ runs-on: ubuntu-24.04
+ timeout-minutes: 15
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version-file: .nvmrc
+ cache: npm
+ - run: npm ci
+ - name: Acquire unchanged fixture compiler in isolated runner storage
+ shell: bash
+ run: |
+ evm_fixture_root="${RUNNER_TEMP}/rea-evm-fixture-compiler"
+ mkdir -p "${evm_fixture_root}"
+ npm install --prefix "${evm_fixture_root}" --ignore-scripts --no-audit --no-fund --save-exact solc@0.8.30
+ command -v prlimit
+ command -v strace
+ echo "REA_VERIFY_STRACE_COMMAND=$(command -v strace)" >> "${GITHUB_ENV}"
+ echo "REA_VERIFY_SOLC_MODULE=${evm_fixture_root}/node_modules/solc/index.js" >> "${GITHUB_ENV}"
+ - name: Verify source-owned compiled bytecode through public CLI and MCP
+ run: npm run verify:evm:interface
diff --git a/README.md b/README.md
index e0a1090a34..348e188296 100644
--- a/README.md
+++ b/README.md
@@ -151,6 +151,7 @@ Additional tools and host support depend on the target:
| ---------------------- | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------- |
| Native binaries | Pseudocode, assembly, strings, symbols, calls and references | Hopper, Ghidra or IDA; [native analysis](https://morluto.github.io/rea/guides/native/) |
| Offline ELF layout | Sections, segments, original symbols/relocations and static mitigation candidates | Caller-supplied pwntools on Linux x64; [binary diagnostics](docs/binary-diagnostics.md) |
+| EVM bytecode | Dispatch selectors, byte offsets, inferred arguments and mutability | Local raw/hex carrier; [offline EVM guide](docs/evm-bytecode.md) |
| JavaScript / Electron | Modules, imports, source maps, routes, IPC and native add-on relationships | Node.js and npm; [application analysis](https://morluto.github.io/rea/guides/javascript/) |
| Websites | Page structure, scripts, network observations and requested screenshots | A Chrome-family browser; [browser analysis](https://morluto.github.io/rea/guides/browser/) |
| Saved network captures | Requests, responses, exposed payloads and source locations | HAR; mitmdump on Linux for native mitmproxy captures; [capture guide](docs/web-network-captures.md) |
diff --git a/README_ar.md b/README_ar.md
index 328c3b716b..8bc08d594a 100644
--- a/README_ar.md
+++ b/README_ar.md
@@ -218,7 +218,7 @@ rea setup
| الفحص الأصلي | 41 | الدوال والشيفرة شبه المصدرية والتعليمات والسلاسل والرموز والاستدعاءات والمراجع والتعليقات وقراءة البايتات ومواضع الملفات |
| سير عمل التحقيق | 14 | نظرات عامة على التطبيقات وملفات الدوال وواجهات API الأصلية والتوزيع وفك الترجمة الدفعي وتتبع الميزات ومسارات الاستدعاء ومخططاته واكتشاف Swift وObjective-C |
| أدوات الملفات الثنائية | 9 | بيانات Mach-O والتوقيعات وplist والمعماريات وأسماء Swift ومراقبة الاستدعاءات عبر LLDB على macOS؛ تخطيط ELF وأدلة الحماية الثابتة على Linux |
-| مخطط المخرجات | 5 | جرد الدلائل والحزم وملفات Interface Builder المترجمة وكتالوجات موارد Apple والاستخراج |
+| مخطط المخرجات | 7 | جرد الدلائل والحزم وملفات Interface Builder المترجمة وكتالوجات موارد Apple وتحليل تحميل Mach-O dylib والاستخراج واستدلال واجهات EVM دون اتصال |
| PE/CLI المُدار | 7 | هوية تجميعات .NET وبياناتها الوصفية وتعليمات CIL والاعتماديات الأصلية واستيراد إعادة البناء ومقارنة الإصدارات |
| البرامج الثابتة | 2 | فحص مناطق البرامج الثابتة على Linux واستخراجها الصريح |
| Android APK | 5 | بيانات الحزمة وmanifest والبحث عن الأصناف وقوائم الأعضاء وفك ترجمة الأساليب والمراجع الثابتة |
diff --git a/README_ja.md b/README_ja.md
index 813e870b2f..85fd9ba551 100644
--- a/README_ja.md
+++ b/README_ja.md
@@ -236,20 +236,20 @@ TypeScript と SQLite を使って私のプロジェクト向けに実装して
## 調査ツールカタログ
-| ツール分類 | 数 | 用途 |
-| -------------------- | --: | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| ネイティブ検査 | 41 | 関数、疑似コード、アセンブリ、文字列、シンボル、呼び出し、参照、注釈、バイト読み取り、ファイルオフセット |
-| 調査ワークフロー | 14 | アプリ概要、関数ドシエ、ネイティブ API とディスパッチ、一括逆コンパイル、機能トレース、コールパス、コールグラフ、Swift と Objective-C の検出 |
-| ネイティブバイナリ | 9 | macOS の Mach-O メタデータ、署名、plist、アーキテクチャ、Swift シンボル、LLDB による呼び出し観測;Linux の ELF レイアウトと静的な保護機構の証拠 |
-| 成果物グラフ | 5 | ディレクトリとパッケージの目録、コンパイル済み Interface Builder ファイル、Apple アセットカタログ、抽出 |
-| マネージド PE/CLI | 7 | .NET の識別情報、メタデータ、CIL 命令、ネイティブ依存関係、再構築結果のインポート、ビルド比較 |
-| ファームウェア | 2 | Linux ファームウェア領域の検査と明示的な展開 |
-| Android APK | 5 | パッケージと manifest の宣言、クラス検索、メンバー一覧、メソッドの逆コンパイル、静的な被参照 |
-| ブラウザー観察 | 12 | ページ構成、ネットワークメタデータ、スクリプト、ソースマップ、WebMCP の検出、スクリーンショット、キャプチャ比較 |
-| Electron 解析 | 5 | レンダラーの観察、静的なアプリのマッピング、静的結果と実行時結果の照合 |
-| JavaScript 実行時 | 2 | Node/Electron Inspector の対象検出、スクリプトの位置、実行コンテキストのイベント |
-| アプリワークフロー | 13 | 取得した Web スクリプトのエクスポート、Android/Apple の目録の投影、層をまたぐ機能トレース、ビルド比較、過去のソースとの対応付け、静的な戻り値形状の比較、再構築の検証 |
-| ワークスペースと観察 | 21 | セッション、Evidence バンドル、ナビゲーションの文脈、プロセス・成果物・関数の比較、未解決事項の記録 |
+| ツール分類 | 数 | 用途 |
+| -------------------- | --: | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| ネイティブ検査 | 41 | 関数、疑似コード、アセンブリ、文字列、シンボル、呼び出し、参照、注釈、バイト読み取り、ファイルオフセット |
+| 調査ワークフロー | 14 | アプリ概要、関数ドシエ、ネイティブ API とディスパッチ、一括逆コンパイル、機能トレース、コールパス、コールグラフ、Swift と Objective-C の検出 |
+| ネイティブバイナリ | 9 | macOS の Mach-O メタデータ、署名、plist、アーキテクチャ、Swift シンボル、LLDB による呼び出し観測;Linux の ELF レイアウトと静的な保護機構の証拠 |
+| 成果物グラフ | 7 | ディレクトリとパッケージの目録、コンパイル済み Interface Builder ファイル、Apple アセットカタログ、Mach-O dylib のロード解決、抽出、オフライン EVM インターフェース推定 |
+| マネージド PE/CLI | 7 | .NET の識別情報、メタデータ、CIL 命令、ネイティブ依存関係、再構築結果のインポート、ビルド比較 |
+| ファームウェア | 2 | Linux ファームウェア領域の検査と明示的な展開 |
+| Android APK | 5 | パッケージと manifest の宣言、クラス検索、メンバー一覧、メソッドの逆コンパイル、静的な被参照 |
+| ブラウザー観察 | 12 | ページ構成、ネットワークメタデータ、スクリプト、ソースマップ、WebMCP の検出、スクリーンショット、キャプチャ比較 |
+| Electron 解析 | 5 | レンダラーの観察、静的なアプリのマッピング、静的結果と実行時結果の照合 |
+| JavaScript 実行時 | 2 | Node/Electron Inspector の対象検出、スクリプトの位置、実行コンテキストのイベント |
+| アプリワークフロー | 13 | 取得した Web スクリプトのエクスポート、Android/Apple の目録の投影、層をまたぐ機能トレース、ビルド比較、過去のソースとの対応付け、静的な戻り値形状の比較、再構築の検証 |
+| ワークスペースと観察 | 21 | セッション、Evidence バンドル、ナビゲーションの文脈、プロセス・成果物・関数の比較、未解決事項の記録 |
## ロードマップ
diff --git a/README_ko.md b/README_ko.md
index ee9a5fa0cb..3eb11722eb 100644
--- a/README_ko.md
+++ b/README_ko.md
@@ -109,7 +109,7 @@ AI 코딩 어시스턴트에 스킬을 추가하면 더 풍부한 컨텍스트
npx skills add morluto/rea --skill reverse-engineer-anything
```
-이 스킬은 REA의 조사 워크플로를 제공합니다. 위 setup을 실행해 REA를 에이전트에 연결하고 분석 도구를 설정하세요. Setup은 기본적으로 버전에 맞는 스킬을 설치합니다. 이 명령은 저장소 버전의 스킬을 설치합니다.
+이 스킬은 REA의 조사 워크플로를 제공합니다. 위 Setup을 실행해 REA를 에이전트에 연결하고 분석 도구를 설정하세요. Setup은 기본적으로 버전에 맞는 스킬을 설치합니다. 이 명령은 저장소 버전의 스킬을 설치합니다.
압축을 해제한 JavaScript/Electron 앱 트리나 ASAR라면 MCP 설정이나 네이티브 엔진 없이 바로 분석할 수 있습니다.
@@ -117,7 +117,7 @@ npx skills add morluto/rea --skill reverse-engineer-anything
npx -y rea-agents@latest analyze-javascript-application /absolute/path/to/app --json
```
-경로를 조사 대상으로 바꾸세요(Windows에서는 `"D:/apps/example"` 같은 경로). 이 정적 워크플로에서는 이런 경로도 지원됩니다. 네이티브 분석에는 전용 엔진 설정이 필요합니다.
+경로를 조사 대상의 경로로 바꾸세요(Windows에서는 `"D:/apps/example"` 같은 경로). 이 정적 워크플로에서는 이런 경로도 지원됩니다. 네이티브 분석에는 전용 엔진 설정이 필요합니다.
### rea 명령 설치하기
@@ -144,7 +144,7 @@ rea setup
네이티브 바이너리 분석에는 Hopper 또는 Ghidra가 필요합니다. Hopper는 별도 소프트웨어입니다. 데모에는 공급업체의 제한이 있지만 유료 라이선스가 필수는 아닙니다.
-Ghidra는 Linux x64와 macOS x64/arm64를 지원합니다. Ghidra 12.1.x와 그 설치본이 선언한 완전한 64비트 JDK(`application.java.min`부터 `application.java.max`까지)를 별도로 설치한 뒤 REA가 사용하도록 설정하세요. 현재 12.1 릴리스는 JDK 21 이상을 요구하고 상한은 없습니다. 브리지는 Ghidra 12.1.4와 JDK 21에서 검증됩니다. macOS에서는 호스트 아키텍처에 맞는 네이티브 디컴파일러도 필요합니다.
+Ghidra는 Linux x64와 macOS x64/arm64를 지원합니다. Ghidra 12.1.x와 그 설치본이 선언한 완전한 64비트 JDK(`application.java.min`부터 `application.java.max`까지)를 별도로 설치한 뒤 REA에서 사용하도록 설정하세요. 현재 12.1 릴리스는 JDK 21 이상을 요구하고 상한은 없습니다. 브리지는 Ghidra 12.1.4와 JDK 21에서 검증됩니다. macOS에서는 호스트 아키텍처에 맞는 네이티브 디컴파일러도 필요합니다.
Setup은 설치를 확인하고 경로를 저장할 수 있습니다. Ghidra, Java, Node.js, npm, Homebrew를 설치하거나 업데이트하지 않습니다.
@@ -164,7 +164,7 @@ Linux에서는 실행 가능한 `/opt/hopper/bin/Hopper`를 우선 사용하고,
## 현재 지원 범위
-저장소의 현재 기능과 플랫폼 요구 사항은 [영문 지원 가이드](README.md#current-status)에 설명되어 있습니다. main은 [npm 릴리스](docs/installation.md#released-package-and-main)보다 앞설 수 있습니다.
+저장소의 현재 기능과 플랫폼 요구 사항은 [영문 지원 가이드](README.md#current-status)에 설명되어 있습니다. main은 [npm 릴리스](docs/installation.md#released-package-and-main)보다 앞선 내용이 포함될 수 있습니다.
- Ghidra는 Linux x64, macOS x64/arm64, 실험적인 Windows x64 P0 경계에서 25개의 읽기 전용 작업을 제공합니다. Linux/macOS에서는 원자적 세션 함수 주석도 추가로 지원합니다. Windows P0는 읽기 전용이며 Ghidra에는 GUI 제어가 없습니다.
- 정적 Android 검사는 별도로 준비한 JADX/Java가 필요합니다. 실제 제공자 검증은 Linux를 대상으로 합니다. [Android 분석](docs/android-analysis.md)을 참고하세요.
@@ -187,7 +187,7 @@ TypeScript와 SQLite를 사용해 제 프로젝트에 맞는 버전을 구현해
| 5 | 관련 루틴 디컴파일 | `procedure_pseudo_code`, `procedure_assembly`, `batch_decompile` |
| 6 | 프로젝트에 기능 구현 | 기술 스택, 제품, 요구 사항에 맞는 코드 |
-REA는 1–5단계의 바이너리 분석을 처리합니다. 6단계는 에이전트가 일반 파일 편집 및 테스트 도구로 수행합니다.
+REA는 1~5단계의 바이너리 분석을 처리합니다. 6단계는 에이전트가 일반 파일 편집 및 테스트 도구로 수행합니다.
## 에이전트가 할 수 있는 일
@@ -203,20 +203,20 @@ REA는 1–5단계의 바이너리 분석을 처리합니다. 6단계는 에이
## 조사 도구 카탈로그
-| 도구 분류 | 수 | 용도 |
-| ----------------- | --: | ------------------------------------------------------------------------------------------------------------------------- |
-| 네이티브 검사 | 41 | 함수, 의사 코드, 어셈블리, 문자열, 심볼, 호출, 참조, 주석, 바이트 읽기, 파일 오프셋 |
-| 조사 워크플로 | 14 | 앱 개요, 함수 기록서, 네이티브 API와 디스패치, 일괄 디컴파일, 기능 추적, 호출 경로, 호출 그래프, Swift와 Objective-C 탐색 |
-| 네이티브 바이너리 | 9 | macOS의 Mach-O 메타데이터, 서명, plist, 아키텍처, Swift 심볼과 LLDB 호출 관찰; Linux의 ELF 레이아웃과 정적 보호 증거 |
-| 아티팩트 그래프 | 5 | 디렉터리와 패키지 목록, 컴파일된 Interface Builder 파일, Apple 애셋 카탈로그, 추출 |
-| 관리 PE/CLI | 7 | .NET 식별, 메타데이터, CIL 명령어, 네이티브 종속성, 재구성 가져오기, 빌드 비교 |
-| 펌웨어 | 2 | Linux 펌웨어 영역 검사 및 명시적 추출 |
-| Android APK | 5 | 패키지와 manifest 선언, 클래스 검색, 멤버 목록, 메서드 디컴파일, 정적 참조 |
-| 브라우저 관찰 | 12 | 페이지 구조, 네트워크 메타데이터, 스크립트, 소스 맵, WebMCP 탐색, 스크린샷, 캡처 비교 |
-| Electron 분석 | 5 | 렌더러 관찰, 정적 앱 매핑, 정적·런타임 결과 연결 |
-| JavaScript 런타임 | 2 | Node/Electron Inspector 대상 탐색, 스크립트 위치, 실행 컨텍스트 이벤트 |
-| 앱 워크플로 | 13 | 캡처한 웹 스크립트 내보내기, 계층 간 기능 추적, 빌드 비교, 히스토리 소스 매핑, 정적 반환 구조 비교, 재구현 검증 |
-| 작업 공간과 관찰 | 21 | 세션, 근거 번들, 탐색 컨텍스트, 프로세스·아티팩트·함수 비교, 미해결 항목 기록 |
+| 도구 분류 | 수 | 용도 |
+| ----------------- | --: | ---------------------------------------------------------------------------------------------------------------------------------------- |
+| 네이티브 검사 | 41 | 함수, 의사 코드, 어셈블리, 문자열, 심볼, 호출, 참조, 주석, 바이트 읽기, 파일 오프셋 |
+| 조사 워크플로 | 14 | 앱 개요, 함수 기록서, 네이티브 API와 디스패치, 일괄 디컴파일, 기능 추적, 호출 경로, 호출 그래프, Swift와 Objective-C 탐색 |
+| 네이티브 바이너리 | 9 | macOS의 Mach-O 메타데이터, 서명, plist, 아키텍처, Swift 심볼과 LLDB 호출 관찰; Linux의 ELF 레이아웃과 정적 보호 증거 |
+| 아티팩트 그래프 | 7 | 디렉터리와 패키지 목록, 컴파일된 Interface Builder 파일, Apple 애셋 카탈로그, Mach-O dylib 로드 해석, 추출, 오프라인 EVM 인터페이스 추론 |
+| 관리 PE/CLI | 7 | .NET 식별, 메타데이터, CIL 명령어, 네이티브 종속성, 재구성 가져오기, 빌드 비교 |
+| 펌웨어 | 2 | Linux 펌웨어 영역 검사 및 명시적 추출 |
+| Android APK | 5 | 패키지와 manifest 선언, 클래스 검색, 멤버 목록, 메서드 디컴파일, 정적 참조 |
+| 브라우저 관찰 | 12 | 페이지 구조, 네트워크 메타데이터, 스크립트, 소스 맵, WebMCP 탐색, 스크린샷, 캡처 비교 |
+| Electron 분석 | 5 | 렌더러 관찰, 정적 앱 매핑, 정적·런타임 결과 연결 |
+| JavaScript 런타임 | 2 | Node/Electron Inspector 대상 탐색, 스크립트 위치, 실행 컨텍스트 이벤트 |
+| 앱 워크플로 | 13 | 캡처한 웹 스크립트 내보내기, 계층 간 기능 추적, 빌드 비교, 히스토리 소스 매핑, 정적 반환 구조 비교, 재구현 검증 |
+| 작업 공간과 관찰 | 21 | 세션, 근거 번들, 탐색 컨텍스트, 프로세스·아티팩트·함수 비교, 미해결 항목 기록 |
## 로드맵
@@ -273,7 +273,7 @@ rea --help
rea mcp
```
-REA는 Mac의 `.app` 폴더를 직접 열 수 있습니다. 에이전트가 앱을 찾지 못하면 설치 위치를 알려 주세요.
+REA는 macOS의 `.app` 폴더를 직접 열 수 있습니다. 에이전트가 앱을 찾지 못하면 설치 위치를 알려 주세요.
## Hopper 앱 동작
diff --git a/README_zh.md b/README_zh.md
index 3c0f1d9e8a..9b187b5ce1 100644
--- a/README_zh.md
+++ b/README_zh.md
@@ -202,20 +202,20 @@ REA 负责第 1–5 步中的二进制分析。第 6 步由智能体使用其常
## 调查工具目录
-| 工具类别 | 数量 | 用途 |
-| ----------------- | ---: | ------------------------------------------------------------------------------------------------------ |
-| 原生检查 | 41 | 函数、伪代码、汇编、字符串、符号、调用、引用、注释、字节读取与文件偏移 |
-| 调查工作流 | 14 | 应用概览、函数档案、原生 API 与分发、批量反编译、功能追踪、调用路径与调用图、Swift 与 Objective-C 发现 |
-| 原生二进制工具 | 9 | macOS 的 Mach-O 元数据、签名、plist、架构、Swift 符号与 LLDB 调用观察;Linux 的 ELF 布局和静态保护证据 |
-| 产物图 | 5 | 目录与软件包清单、编译后的 Interface Builder 文件、Apple 资源目录与提取 |
-| 托管 PE/CLI | 7 | .NET 程序集身份、元数据、CIL 指令、原生依赖声明、重建导入与构建比较 |
-| 固件 | 2 | Linux 固件区域检查与显式提取 |
-| Android APK | 5 | 包与 manifest 声明、类搜索、成员清单、方法反编译与静态引用 |
-| 浏览器观察 | 12 | 页面结构、网络元数据、脚本、来源映射、WebMCP 发现、截图与捕获比较 |
-| Electron 分析 | 5 | 渲染进程观察、静态应用映射、静态/运行时结果关联 |
-| JavaScript 运行时 | 2 | Node/Electron Inspector 目标发现、脚本位置与执行上下文事件 |
-| 应用工作流 | 13 | 捕获的网站脚本导出、跨层功能追踪、构建比较、历史源码映射、静态返回结构比较与重建验证 |
-| 工作区与观察 | 21 | 会话、证据包、导航上下文、进程/产物/函数比较与待解决问题跟踪 |
+| 工具类别 | 数量 | 用途 |
+| ----------------- | ---: | ----------------------------------------------------------------------------------------------------------------- |
+| 原生检查 | 41 | 函数、伪代码、汇编、字符串、符号、调用、引用、注释、字节读取与文件偏移 |
+| 调查工作流 | 14 | 应用概览、函数档案、原生 API 与分发、批量反编译、功能追踪、调用路径与调用图、Swift 与 Objective-C 发现 |
+| 原生二进制工具 | 9 | macOS 的 Mach-O 元数据、签名、plist、架构、Swift 符号与 LLDB 调用观察;Linux 的 ELF 布局和静态保护证据 |
+| 产物图 | 7 | 目录与软件包清单、编译后的 Interface Builder 文件、Apple 资源目录、Mach-O dylib 加载解析、提取与离线 EVM 接口推断 |
+| 托管 PE/CLI | 7 | .NET 程序集身份、元数据、CIL 指令、原生依赖声明、重建导入与构建比较 |
+| 固件 | 2 | Linux 固件区域检查与显式提取 |
+| Android APK | 5 | 包与 manifest 声明、类搜索、成员清单、方法反编译与静态引用 |
+| 浏览器观察 | 12 | 页面结构、网络元数据、脚本、来源映射、WebMCP 发现、截图与捕获比较 |
+| Electron 分析 | 5 | 渲染进程观察、静态应用映射、静态/运行时结果关联 |
+| JavaScript 运行时 | 2 | Node/Electron Inspector 目标发现、脚本位置与执行上下文事件 |
+| 应用工作流 | 13 | 捕获的网站脚本导出、跨层功能追踪、构建比较、历史源码映射、静态返回结构比较与重建验证 |
+| 工作区与观察 | 21 | 会话、证据包、导航上下文、进程/产物/函数比较与待解决问题跟踪 |
## 路线图
diff --git a/docs/apple-application-analysis.md b/docs/apple-application-analysis.md
index bf606f34d7..e99504e164 100644
--- a/docs/apple-application-analysis.md
+++ b/docs/apple-application-analysis.md
@@ -110,3 +110,78 @@ The projection does not read plists. Use `inspect_plist` on a bundle's
`info_plist_path` or a launchd plist to read `CFBundleExecutable`, identifiers,
`SMPrivilegedExecutables`, `BundleProgram`, URL schemes, and usage descriptions.
Use `inspect_signature` on an executable candidate for code-signing claims.
+
+## Dylib load resolution
+
+`trace_dylib_resolution` answers which file each Mach-O dylib load reaches. It
+runs on the active Mach-O, or on every executable in the active `.app`. Each
+executable in a bundle (the main app, XPC services, app extensions, login items,
+privileged helpers, and helper tools) is a separate process root with its own
+`@executable_path`.
+
+```sh
+rea trace-dylib-resolution MyApp.app --json
+rea trace-dylib-resolution MyApp.app --root Contents/MacOS/MyApp --architecture arm64 --json
+```
+
+```json
+{
+ "name": "trace_dylib_resolution",
+ "arguments": { "roots": ["Contents/MacOS/MyApp"], "architecture": "arm64" }
+}
+```
+
+Load commands are parsed in TypeScript from Mach-O and FAT headers, including
+the `dylib_use_command` encoding newer linkers emit for flags such as delayed
+initialization. Nothing is executed and no Apple tool is required, so the tool
+also works off macOS.
+
+Resolution follows dyld:
+
+- `@executable_path` is the directory of the process root's executable. It is
+ undetermined when the root is a library.
+- `@loader_path` is the directory of the image that holds the load command, or
+ of the image that owns the `LC_RPATH` entry being expanded.
+- `@rpath` tries each `LC_RPATH` of the loading image first, then those of the
+ image that loaded it, up to the process root.
+- Dependencies load dependents-first in load-command order, and each image
+ loads once per process. A later request whose install name matches an
+ already loaded image reuses it.
+- Symlinks such as `Versions/Current` are followed inside the analyzed root.
+ An escape through `..` or an absolute link is `escapes-target`.
+
+Each edge lists every candidate path tried, with its outcome: `resolved`,
+`absent`, `not-mach-o`, `malformed`, `unsupported` (recognized Mach-O outside
+parser coverage, such as big-endian images), `architecture-missing`,
+`outside-target`, `escapes-target`, or `undetermined`. Slices follow dyld's
+graded architectures, so an `x86_64h` process also loads `x86_64`. The edge's `resolution` is one of:
+
+| Status | Meaning |
+| -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `resolved` | An image inside the analyzed root loads, and no earlier candidate was undeterminable. |
+| `conditional` | An image inside the root resolves, but an earlier candidate outside the root (for example `/usr/lib/swift`) would win if it exists on the running system. |
+| `unresolved` | Every candidate is definitively absent or unusable inside the root. |
+| `undetermined` | No candidate resolves inside the root, and some candidate lies outside it or depends on the environment. |
+
+A `conditional` image is still traced, because it is the image the analyzed
+root supplies. Every edge below it has `loader_conditional: true`, and a
+request that reuses it by install name is `conditional` too. Findings on those
+edges say that they apply only if the conditional image loads.
+
+Absolute install names such as `/usr/lib/libSystem.B.dylib` are
+`outside-target`. On macOS 11 and later most of them live in the dyld shared
+cache rather than on disk, so REA does not check them against the host.
+
+`findings` are derived, not observed:
+
+- `required-load-unresolved`, `weak-load-unresolved`, and `lazy-load-unresolved`.
+ `LC_LAZY_LOAD_DYLIB` dependencies are resolved but not traversed, because dyld
+ loads them on first use; an unresolved one is not a launch failure.
+- `earlier-rpath-candidate-absent`: a Mach-O placed at an earlier search path
+ would load first. Whether code-signing library validation would reject it is
+ not evaluated; use `inspect_signature`.
+- `dyld-environment-present`: `LC_DYLD_ENVIRONMENT` search paths are not modeled.
+
+`verify:macos-bundle` checks the parser against `otool -l` for every traced
+image. It also compares the predicted load order of two process roots with the
+images dyld actually loads (`DYLD_PRINT_LIBRARIES`).
diff --git a/docs/architecture.mermaid b/docs/architecture.mermaid
index 2bad907aa9..8bbb9a1474 100644
--- a/docs/architecture.mermaid
+++ b/docs/architecture.mermaid
@@ -27,6 +27,7 @@ graph TD
subgraph "Provider Layers"
PWNTOOLS["src/native/pwntools/
BYO offline ELF adapter
owned Python process, unchanged upstream tables/heuristics"]
+ EVM["src/evm/
unchanged EVMole/WASM in bounded owned worker
explicit carrier parsing and inferred interface adapter"]
HOPPER["src/hopper/
Hopper launch & Unix-socket protocol
BridgeLauncher, HopperClient, protocol"]
GHIDRA["src/ghidra/
Ghidra discovery, profile & function analysis
snapshot, Unix/Windows transport, bounded lifecycle"]
IDA["src/ida/
Upstream MCP contract adaptation
live GUI binding or owned headless database"]
@@ -144,3 +145,8 @@ graph TD
TESTS -.-> BROWSER
TESTS -.-> EVALUATION
TESTS -.-> ERRORS
+
+ COMPOSITION --> EVM
+ APP -->|EVM interface port| EVM
+ EVM --> PROCESS
+ EVM --> DOMAIN
diff --git a/docs/error-contract.schema.json b/docs/error-contract.schema.json
index 11f0fc6591..b79e8cee31 100644
--- a/docs/error-contract.schema.json
+++ b/docs/error-contract.schema.json
@@ -10,6 +10,7 @@
"enum": [
"invalid_input",
"unsupported_provider",
+ "unsupported_target",
"integrity_mismatch",
"truncated",
"cancelled",
@@ -45,6 +46,7 @@
"enum": [
"invalid_input",
"unsupported_provider",
+ "unsupported_target",
"integrity_mismatch",
"truncated",
"cancelled",
@@ -80,6 +82,7 @@
"enum": [
"invalid_input",
"unsupported_provider",
+ "unsupported_target",
"integrity_mismatch",
"truncated",
"cancelled",
@@ -115,6 +118,7 @@
"enum": [
"invalid_input",
"unsupported_provider",
+ "unsupported_target",
"integrity_mismatch",
"truncated",
"cancelled",
@@ -150,6 +154,43 @@
"enum": [
"invalid_input",
"unsupported_provider",
+ "unsupported_target",
+ "integrity_mismatch",
+ "truncated",
+ "cancelled",
+ "timeout",
+ "resource_constraint",
+ "unavailable",
+ "execution_failure"
+ ]
+ },
+ "message": { "type": "string", "minLength": 1 },
+ "retryable": { "type": "boolean" },
+ "remediation": {
+ "type": "object",
+ "properties": { "action": { "type": "string", "minLength": 1 } },
+ "required": ["action"],
+ "additionalProperties": false
+ },
+ "details": {
+ "type": "object",
+ "propertyNames": { "type": "string" },
+ "additionalProperties": {}
+ }
+ },
+ "required": ["code", "category", "message", "retryable", "remediation"],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "code": { "type": "string", "const": "unsupported_target" },
+ "category": {
+ "type": "string",
+ "enum": [
+ "invalid_input",
+ "unsupported_provider",
+ "unsupported_target",
"integrity_mismatch",
"truncated",
"cancelled",
@@ -185,6 +226,7 @@
"enum": [
"invalid_input",
"unsupported_provider",
+ "unsupported_target",
"integrity_mismatch",
"truncated",
"cancelled",
@@ -220,6 +262,7 @@
"enum": [
"invalid_input",
"unsupported_provider",
+ "unsupported_target",
"integrity_mismatch",
"truncated",
"cancelled",
@@ -255,6 +298,7 @@
"enum": [
"invalid_input",
"unsupported_provider",
+ "unsupported_target",
"integrity_mismatch",
"truncated",
"cancelled",
@@ -290,6 +334,7 @@
"enum": [
"invalid_input",
"unsupported_provider",
+ "unsupported_target",
"integrity_mismatch",
"truncated",
"cancelled",
@@ -325,6 +370,7 @@
"enum": [
"invalid_input",
"unsupported_provider",
+ "unsupported_target",
"integrity_mismatch",
"truncated",
"cancelled",
@@ -382,6 +428,7 @@
"enum": [
"invalid_input",
"unsupported_provider",
+ "unsupported_target",
"integrity_mismatch",
"truncated",
"cancelled",
@@ -417,6 +464,7 @@
"enum": [
"invalid_input",
"unsupported_provider",
+ "unsupported_target",
"integrity_mismatch",
"truncated",
"cancelled",
@@ -452,6 +500,7 @@
"enum": [
"invalid_input",
"unsupported_provider",
+ "unsupported_target",
"integrity_mismatch",
"truncated",
"cancelled",
@@ -487,6 +536,7 @@
"enum": [
"invalid_input",
"unsupported_provider",
+ "unsupported_target",
"integrity_mismatch",
"truncated",
"cancelled",
@@ -522,6 +572,7 @@
"enum": [
"invalid_input",
"unsupported_provider",
+ "unsupported_target",
"integrity_mismatch",
"truncated",
"cancelled",
@@ -557,6 +608,7 @@
"enum": [
"invalid_input",
"unsupported_provider",
+ "unsupported_target",
"integrity_mismatch",
"truncated",
"cancelled",
@@ -592,6 +644,7 @@
"enum": [
"invalid_input",
"unsupported_provider",
+ "unsupported_target",
"integrity_mismatch",
"truncated",
"cancelled",
@@ -627,6 +680,7 @@
"enum": [
"invalid_input",
"unsupported_provider",
+ "unsupported_target",
"integrity_mismatch",
"truncated",
"cancelled",
@@ -662,6 +716,7 @@
"enum": [
"invalid_input",
"unsupported_provider",
+ "unsupported_target",
"integrity_mismatch",
"truncated",
"cancelled",
diff --git a/docs/evm-bytecode.md b/docs/evm-bytecode.md
new file mode 100644
index 0000000000..5c0c67ae67
--- /dev/null
+++ b/docs/evm-bytecode.md
@@ -0,0 +1,84 @@
+# Offline EVM bytecode
+
+`inspect_evm_interface` / `inspect-evm-interface` infers interface candidates
+from one explicitly selected local bytecode carrier. The capability is general:
+contract investigation, compatibility, audit and CTF showcases use the same
+portable primitive. It does not require an active native binary target.
+
+```sh
+rea inspect-evm-interface ./runtime.hex hex --json
+rea inspect-evm-interface ./runtime.bin raw --json
+```
+
+```json
+{
+ "name": "inspect_evm_interface",
+ "arguments": { "path": "/artifacts/runtime.hex", "encoding": "hex" }
+}
+```
+
+Initial real verification covers flat legacy bytecode on Linux x64 and unchanged
+bundled EVMole 0.9.3. EOF-style EF00 containers return `unsupported_target`,
+with the selected path and format reason. Provider/configuration failures retain
+their distinct unavailable-provider or capability classification.
+The host must already provide util-linux `prlimit`, normally `/usr/bin/prlimit`;
+`REA_EVM_PRLIMIT_COMMAND` can select another absolute executable. REA installs
+no system tools and changes no user configuration. [Upstream provenance and
+license](../third_party/evmole/README.md) are packaged.
+
+## Evidence semantics
+
+- `encoding` is required: raw bytes or UTF-8 hex, with optional `0x` prefix and
+ outer ASCII whitespace. Odd hex, interior separators, BOM, non-ASCII whitespace
+ and invalid UTF-8 are rejected. Empty bytecode is valid and has unknown coverage.
+- Carrier SHA-256 identifies the exact original file, including prefix/casing
+ and whitespace. Decoded-byte SHA-256 identifies actual selected EVM bytes.
+ Neither digest is the Ethereum Keccak code hash.
+- Full bytecode, upstream function offsets and original producer representation
+ are returned inline. Offsets are byte positions in decoded bytecode.
+- Upstream also returns terminal metadata during selector recovery. Its reported
+ values remain in `raw_result`; integer precision and non-text-key coverage are
+ unknown in the JS binding. Full bytecode retains the original encoded metadata.
+- Selectors, argument strings and state mutability are **inferences**. Function
+ names/signatures are not looked up. Optimized code may produce different
+ mutability candidates; recovered types are not a verified source ABI.
+- Runtime/init-code identity, deployed authenticity, hardfork and discovery
+ completeness remain unknown. Empty candidates do not prove no callable interface.
+- Analysis is offline: no target runtime execution, implicit chain/RPC/explorer
+ lookup, wallet action, transaction broadcast or network request.
+
+## Resource and lifecycle limits
+
+Inputs are limited to 4 MiB, complete replies to 16 MiB and combined diagnostic
+output to 1 MiB. An owned worker has a 256 MiB JavaScript heap, 3 GiB virtual
+address-space limit, 30 CPU seconds and a 30-second command deadline. Virtual
+address space differs from resident memory. The worker retains inherited tighter
+soft/hard limits, reports its configured soft limits inline and leaves hard limits
+unchanged. WASM trap reservations are disabled
+so the engine can run within that virtual limit; actual memory is not equated to
+its 3 GiB allowance. Limits fail without partial success. Stable snapshots,
+private files and owned processes are cleaned independently of caller cancellation.
+Invalid-input, unsupported, cancellation, timeout and malformed-reply failures
+retain bounded stdout/stderr under `details.captured_output`, including whether
+diagnostic output was truncated. Cleanup failures preserve the
+original error projection and any captured output.
+Successful results expose `diagnostics.truncated` from the supervisor as well.
+Excess diagnostic output fails the complete-output contract even when the worker
+has written a valid reply.
+SIGXCPU retains a CPU resource diagnostic and CPU-specific guidance. Observed
+file-size write failures (EFBIG) and SIGXFSZ likewise retain a file-size resource
+diagnostic. Exact signal/write causes remain unknown. Configured
+soft limits are reported separately from unknown effective limits; the received
+signal alone does not establish its exact cause.
+Reserved file-size exits require a matching private marker written by the
+worker's EFBIG branch. Bare launcher exits or unwritable markers retain process
+diagnostics with the resource cause unverified.
+
+Source/metadata, proxy resolution, storage/CFG and fork-explicit execution/traces
+remain separate increments under #972, with materially different result or
+execution authority. This primitive makes no deployment or runtime behavior claim.
+
+The real verification lane traces representative raw/hex CLI workflows and checks
+that only declared Node/prlimit launchers and process ownership inspection run.
+Socket traces permit local stdio socket pairs and reject attempted network requests.
+These observations verify those fixtures; they are not a general network sandbox.
diff --git a/docs/native-investigation.md b/docs/native-investigation.md
index 24ccf7ebe8..48860c5311 100644
--- a/docs/native-investigation.md
+++ b/docs/native-investigation.md
@@ -12,34 +12,13 @@ input file and include the observed lipo slice offset for universal binaries.
If that offset is unavailable, segment evidence locations are omitted with an
explicit limitation; architecture inventory locations remain available.
-- `inspect_signature` / `rea inspect-signature ` reports
- codesign identity, requirements and entitlements, and adds:
- - **Observed facts:**
- - the parsed CodeDirectory, with flags decoded from their numeric value
- (`runtime`, `library-validation`, `restrict`, `kill`, `adhoc`,
- `linker-signed`, …), slot counts, platform identifier and runtime
- version;
- - sealed resources;
- - a local `codesign --verify --deep --strict` result, with diagnostics and the
- nested code it validated;
- - whether a notarization ticket is stapled at `Contents/CodeResources`.
- The ticket itself and Apple's notarization record are not checked,
- because that needs the network.
- - **Derived `security_facets`**, each with the flags or entitlements that
- support it:
- - library validation;
- - whether dyld honors `DYLD_*` variables;
- - debugger attach;
- - executable-memory policy;
- - App Sandbox.
-
- A CodeDirectory platform identifier counts as a platform binary only
- when the signature also satisfies `anchor apple` (Apple's own code;
- `codesign --verify -R="anchor apple"`). Otherwise the facets that depend
- on it are `unknown`. A universal file whose slices differ in signing
- state reports every facet as `unknown`, because no one CodeDirectory
- describes the process. System Integrity Protection, AMFI and setuid
- policy are not evaluated.
+- `trace_dylib_resolution` / `rea trace-dylib-resolution `
+ parses Mach-O load commands in TypeScript and follows dyld's path expansion
+ for every executable in an app bundle, or for one Mach-O within its directory.
+ It reports each `@rpath`, `@loader_path` and `@executable_path` candidate with
+ its outcome. Paths outside the analyzed root, including shared-cache system
+ libraries, stay undetermined. See
+ [Apple application analysis](apple-application-analysis.md#dylib-load-resolution).
- `inspect_asset_catalog` / `rea inspect-asset-catalog ` reads compiled
`Assets.car` metadata through macOS `assetutil --info`. Catalog digests, raw
rendition fields, pagination and exact UI resource-name matches are returned.
diff --git a/docs/product-catalog.json b/docs/product-catalog.json
index f3339f5ffc..6946f04daf 100644
--- a/docs/product-catalog.json
+++ b/docs/product-catalog.json
@@ -7,10 +7,10 @@
"client": "2.3.1",
"core": "2.3.1"
},
- "skill_version": "31"
+ "skill_version": "32"
},
"tools": {
- "total": 136,
+ "total": 138,
"families": [
{
"id": "direct",
@@ -100,13 +100,15 @@
{
"id": "artifact",
"surface": "artifact-provider",
- "count": 5,
+ "count": 7,
"tools": [
"decode_interface_builder",
"extract_artifact",
"inspect_artifact",
"inspect_asset_catalog",
- "inspect_keyed_archive"
+ "inspect_evm_interface",
+ "inspect_keyed_archive",
+ "trace_dylib_resolution"
]
},
{
@@ -238,6 +240,12 @@
"version": "3.1.0",
"capabilities": ["inspect_firmware_regions"]
},
+ {
+ "id": "evmole-interface",
+ "name": "REA EVM interface adapter",
+ "version": "evmole@0.9.3",
+ "capabilities": ["inspect_evm_interface"]
+ },
{
"id": "ghidra",
"name": "Ghidra",
@@ -400,7 +408,8 @@
"extract_artifact",
"inspect_artifact",
"inspect_asset_catalog",
- "inspect_keyed_archive"
+ "inspect_keyed_archive",
+ "trace_dylib_resolution"
]
},
{
@@ -592,7 +601,7 @@
}
],
"cli": {
- "primary_count": 91,
+ "primary_count": 93,
"commands": [
"address-to-file-offset",
"analyze",
@@ -636,6 +645,7 @@
"inspect-asset-catalog",
"inspect-binary-layout",
"inspect-electron-page",
+ "inspect-evm-interface",
"inspect-firmware-regions",
"inspect-keyed-archive",
"inspect-macho",
@@ -676,6 +686,7 @@
"trace",
"trace-android-references",
"trace-application-feature",
+ "trace-dylib-resolution",
"trace-javascript-semantics",
"trace-native-ui-action",
"trace-native-values",
@@ -695,15 +706,15 @@
},
"runtime_catalog": {
"counts": {
- "cli_commands": 91,
- "mcp_tools": 136,
+ "cli_commands": 93,
+ "mcp_tools": 138,
"mcp_prompts": 6
},
"digests": {
- "tools_sha256": "b9d50917b751626717ad032fd2e6e1c3d56c93d9df6e8d18c60887b5967e574b",
+ "tools_sha256": "a7bb9ac341e2164857914c18ad0d4a499f9f7ae7927bb2c684c983d32cfa928d",
"prompts_sha256": "ef2de8814390b340623c32001610aa2709d90bed51537b6e562ab3abf144ab4d",
- "combined_sha256": "3206a7f58e9e56f55570ba375ac90b0be91891d21ee97a493870f6a1a0149d77",
- "providers_sha256": "6e0080dea56ee7f3ebdf5ce0d74ea18e5d44ebce80b89c7f4de94e9c69f4d2e5"
+ "combined_sha256": "aab2ca1d88c1ce394e8eb2c842ab101e000e2aebd7489ce482cc46a36eef940f",
+ "providers_sha256": "44935c127fd040af492f2da591dfcd782d766770d306b91242216674220047ed"
}
}
}
diff --git a/docs/testing.md b/docs/testing.md
index 8a33a4eac7..21e33bcb56 100644
--- a/docs/testing.md
+++ b/docs/testing.md
@@ -111,7 +111,16 @@ extension, a login item, a privileged helper, launchd plists, and a helper tool,
signed ad hoc. It packs the app as a directory, a `ditto` ZIP, and an APFS DMG,
then checks that `inspect-artifact` plus `project-apple-application-graph`
report the same bundle anatomy for all three through the CLI, with stdio MCP
-parity. It also checks that the DMG is detached afterwards. It runs in macOS CI.
+parity. It also checks that the DMG is detached afterwards. The same app
+covers `trace-dylib-resolution`:
+
+- each resolution status and finding, with CLI/MCP parity;
+- for every traced image, dependencies, rpaths, and install names equal to
+ `otool -l`;
+- for the main executable and an XPC service, a predicted load order equal to
+ the images dyld actually loads under `DYLD_PRINT_LIBRARIES`.
+
+It runs in macOS CI.
Apple artifact verifiers live in `scripts/verify/apple/`, with the macOS bundle
builder under `scripts/fixtures/apple/` and NIB byte fixtures beside the decoder
@@ -641,6 +650,16 @@ the ordinary lane. Its large MCP transfer is opt-in with
explicit 256 MiB SDK receive buffer and five-minute request timeout. Ordinary
MCP fixtures retain the pinned SDK defaults.
+### Offline EVM interface
+
+`npm run verify:evm:interface` requires Linux x64, an absolute
+`REA_VERIFY_STRACE_COMMAND`, caller-supplied util-linux `prlimit` and `REA_VERIFY_SOLC_MODULE` selecting the absolute module path for
+solc 0.8.30. It compiles source-owned plain/optimized/via-IR Cancun fixtures in
+private storage and checks actual CLI/MCP selector evidence, raw/hex identity,
+unknowns, malformed carriers and independent cleanup. An optional positional
+entrypoint verifies a fresh installed package. It acquires no engine, compiler
+or chain dependency and does not execute a contract on a chain.
+
## Agent evaluation and conformance records
Evaluate native, JavaScript, managed and browser investigation tasks through a
diff --git a/docs/verification/managed-conformance-manifest.json b/docs/verification/managed-conformance-manifest.json
index 3f4cba022f..2733e0bf8f 100644
--- a/docs/verification/managed-conformance-manifest.json
+++ b/docs/verification/managed-conformance-manifest.json
@@ -1,5 +1,5 @@
{
- "manifest_id": "ecm_8c723660f3d2f6488eba07eb0dd886168f99455325f652fc3c843484cc3e016e",
+ "manifest_id": "ecm_637a2f0b5413b8b3764b3614e8d327dd3d423bab98d0e94be0a8653fa386d562",
"verifier": {
"id": "rea-managed-conformance",
"version": "1"
@@ -13,7 +13,7 @@
"skill_digests": [
{
"skill_id": "reverse-engineer-anything",
- "sha256": "427adc0c37996ff688edf9e2d194eacf8147b858187678b9912654f6009dda3d"
+ "sha256": "cd5321f1bb83f53447f7ab19b98da997211158f889c9736ca965cf7b9f825564"
}
],
"claims": [
diff --git a/package-lock.json b/package-lock.json
index 1bb44c60c8..cd3225dab0 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -25,6 +25,7 @@
"canonicalize": "3.0.0",
"cross-spawn": "7.0.6",
"diff": "9.0.0",
+ "evmole": "0.9.3",
"har-schema": "2.0.0",
"ignore": "7.0.6",
"incur": "0.6.1",
@@ -4643,6 +4644,18 @@
"node": ">=18.0.0"
}
},
+ "node_modules/evmole": {
+ "version": "0.9.3",
+ "resolved": "https://registry.npmjs.org/evmole/-/evmole-0.9.3.tgz",
+ "integrity": "sha512-UhO5HMAoEPCupSxvBJPlm/L0Qk/KVJ/OJo3r4EyhXADIEHqU5aTkAzsBfzk4lXGjqxVnaYbh1GPSmZtmSHFnQQ==",
+ "license": "MIT",
+ "bin": {
+ "evmole": "dist/evmole_cli.mjs"
+ },
+ "engines": {
+ "node": ">=22"
+ }
+ },
"node_modules/expect-type": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
diff --git a/package.json b/package.json
index aa19ecbb4d..4f7f10954a 100644
--- a/package.json
+++ b/package.json
@@ -66,7 +66,9 @@
"scripts/verify-windows-native-artifact.mjs",
"bridge/pwntools/layout.py",
"bridge/pwntools/layout_impl.py",
- "third_party/pwntools/README.md"
+ "third_party/pwntools/README.md",
+ "third_party/evmole/README.md",
+ "third_party/evmole/LICENSE"
],
"scripts": {
"deps:check": "node scripts/check-dependency-install.mjs",
@@ -200,7 +202,8 @@
"verify:browser:source-maps": "npm run build:cached && node scripts/verify-browser-source-maps.mjs",
"verify:browser:runtime": "npm run build:cached && node scripts/verify-browser-runtime.mjs",
"verify:web:network-captures": "npm run build:cached && node scripts/verify-web-network-captures.mjs",
- "verify:binary:layout": "npm run build:cached && node scripts/verify-binary-layout.mjs"
+ "verify:binary:layout": "npm run build:cached && node scripts/verify-binary-layout.mjs",
+ "verify:evm:interface": "npm run build:cached && node scripts/verify-evm-interface.mjs"
},
"lint-staged": {
"*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}": [
@@ -240,7 +243,8 @@
"smol-toml": "1.9.0",
"write-file-atomic": "7.0.0",
"ws": "8.21.0",
- "zod": "4.4.3"
+ "zod": "4.4.3",
+ "evmole": "0.9.3"
},
"overrides": {
"vitepress": {
diff --git a/scripts/fixtures/apple/macos-bundle.mjs b/scripts/fixtures/apple/macos-bundle.mjs
index de2dcae812..adb1a2c108 100644
--- a/scripts/fixtures/apple/macos-bundle.mjs
+++ b/scripts/fixtures/apple/macos-bundle.mjs
@@ -138,7 +138,11 @@ const char *core_chain(void) { return sibling_value() == 7 ? chain_variant() : "
return join(version, "Core");
};
-/** The main executable, with a weak dependency deleted after linking. */
+/**
+ * The main executable. It searches a missing `Overrides` rpath before
+ * `Frameworks`, weakly links a library deleted after linking, and
+ * delay-links a library, which the linker encodes as `dylib_use_command`.
+ */
const buildMainExecutable = async (root, contents, coreBinary) => {
const gone = join(contents, "Frameworks", "libgone.dylib");
await clang(root, {
@@ -147,22 +151,33 @@ const buildMainExecutable = async (root, contents, coreBinary) => {
output: gone,
flags: ["-dynamiclib", "-install_name", "@rpath/libgone.dylib"],
});
+ const delayed = join(contents, "Frameworks", "libdelay.dylib");
+ await clang(root, {
+ name: "delay",
+ source: `int delay_value(void) { return 3; }\n`,
+ output: delayed,
+ flags: ["-dynamiclib", "-install_name", "@rpath/libdelay.dylib"],
+ });
const executable = join(contents, "MacOS", "MacFixture");
await clang(root, {
name: "main",
source: `#include
const char *core_chain(void);
+int delay_value(void);
extern int gone_value(void) __attribute__((weak_import));
int main(void) {
+ if (delay_value() != 3) return 1;
printf("chain=%s weak=%s\\n", core_chain(), gone_value ? "present" : "absent");
return 0;
}
`,
output: executable,
flags: [
+ "-Wl,-rpath,@executable_path/../Overrides",
"-Wl,-rpath,@executable_path/../Frameworks",
coreBinary,
`-Wl,-weak_library,${gone}`,
+ `-Wl,-delay_library,${delayed}`,
],
});
await rm(gone);
@@ -191,7 +206,12 @@ const buildNestedBundles = async (root, contents, coreBinary) => {
int main(void) { return core_chain()[0] == 'b'; }
`,
output: join(service, "MacOS", "Svc"),
- flags: ["-Wl,-rpath,@executable_path/../../../../Frameworks", coreBinary],
+ // An absolute rpath outside the bundle precedes the bundled framework.
+ flags: [
+ "-Wl,-rpath,/opt/rea-fixture-absent/lib",
+ "-Wl,-rpath,@executable_path/../../../../Frameworks",
+ coreBinary,
+ ],
});
await write(
join(service, "Info.plist"),
diff --git a/scripts/fixtures/evm-interface.sol b/scripts/fixtures/evm-interface.sol
new file mode 100644
index 0000000000..396479172d
--- /dev/null
+++ b/scripts/fixtures/evm-interface.sol
@@ -0,0 +1,12 @@
+// SPDX-License-Identifier: MIT
+pragma solidity 0.8.30;
+
+// Public source-owned fixture; generated runtime bytecode is never tracked.
+contract InterfaceFixture {
+ uint256 public stored;
+ function set(uint256 value) external { stored = value; }
+ function read() external view returns (uint256) { return stored; }
+ function pair(uint32 a, address b) external pure returns (uint32, address) { return (a, b); }
+ receive() external payable {}
+ fallback() external {}
+}
diff --git a/scripts/lib/catalog-builders.mjs b/scripts/lib/catalog-builders.mjs
index efe58e26c3..8049261cd6 100644
--- a/scripts/lib/catalog-builders.mjs
+++ b/scripts/lib/catalog-builders.mjs
@@ -24,7 +24,10 @@ export const toolFamilyCatalog = (sources) => {
{
id: "artifact",
surface: "artifact-provider",
- contracts: sources.artifactContracts.ARTIFACT_TOOL_CONTRACTS,
+ contracts: [
+ ...sources.artifactContracts.ARTIFACT_TOOL_CONTRACTS,
+ ...sources.evmContracts.EVM_TOOL_CONTRACTS,
+ ],
},
{
id: "managed",
@@ -151,6 +154,10 @@ export const providerCatalog = (sources) => {
contracts:
sources.binaryDiagnosticsContracts.BINARY_DIAGNOSTICS_TOOL_CONTRACTS,
},
+ {
+ identity: sources.evmProvider.EVMOLE_PROVIDER_IDENTITY,
+ contracts: sources.evmContracts.EVM_TOOL_CONTRACTS,
+ },
{
identity: sources.artifactProviders.ARTIFACT_GRAPH_PROVIDER,
contracts: sources.artifactContracts.ARTIFACT_TOOL_CONTRACTS,
diff --git a/scripts/lib/catalog-core.mjs b/scripts/lib/catalog-core.mjs
index 178702f1ca..9dcfdbb187 100644
--- a/scripts/lib/catalog-core.mjs
+++ b/scripts/lib/catalog-core.mjs
@@ -44,6 +44,8 @@ const SOURCE_PATHS = {
"dist/contracts/native/binaryDiagnosticsToolContracts.js",
pwntoolsProvider: "dist/native/pwntools/PwntoolsRelease.js",
nativeContracts: "dist/contracts/native/nativeToolContracts.js",
+ evmContracts: "dist/contracts/evm/evmToolContracts.js",
+ evmProvider: "dist/evm/EvmoleRelease.js",
artifactContracts: "dist/contracts/artifactToolContracts.js",
managedContracts: "dist/contracts/managed/managedToolContracts.js",
firmwareContracts: "dist/contracts/firmware/firmwareToolContracts.js",
diff --git a/scripts/lib/module-boundaries.mjs b/scripts/lib/module-boundaries.mjs
index b50050d79f..d52a651f56 100644
--- a/scripts/lib/module-boundaries.mjs
+++ b/scripts/lib/module-boundaries.mjs
@@ -5,6 +5,7 @@ import { fileURLToPath, pathToFileURL } from "node:url";
const PURE_LAYERS = new Set(["domain", "contracts"]);
const MIGRATED_PROVIDER_ROOTS = new Set([
+ "evm",
"android",
"firmware",
"ghidra",
diff --git a/scripts/verify-evm-interface.mjs b/scripts/verify-evm-interface.mjs
new file mode 100644
index 0000000000..39cb2b356c
--- /dev/null
+++ b/scripts/verify-evm-interface.mjs
@@ -0,0 +1,577 @@
+#!/usr/bin/env node
+import assert from "node:assert/strict";
+import { execFile } from "node:child_process";
+import { createHash } from "node:crypto";
+import { access, readFile, readdir, writeFile } from "node:fs/promises";
+import { constants } from "node:fs";
+import { isAbsolute, join } from "node:path";
+import { fileURLToPath, pathToFileURL } from "node:url";
+import { promisify } from "node:util";
+import { Client } from "@modelcontextprotocol/client";
+import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
+import { PrivateRuntimeRoot } from "../dist/process/PrivateRuntimeRoot.js";
+import { parseEvidence } from "../dist/domain/evidence.js";
+import { mcpTextValue } from "./lib/mcp-verifier-results.mjs";
+import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs";
+
+const solcModule = process.env.REA_VERIFY_SOLC_MODULE;
+const strace = process.env.REA_VERIFY_STRACE_COMMAND;
+if (
+ process.platform !== "linux" ||
+ process.arch !== "x64" ||
+ !isAbsolute(solcModule ?? "") ||
+ !isAbsolute(strace ?? "")
+)
+ throw new Error(
+ "verify:evm:interface requires Linux x64, caller-supplied util-linux prlimit and absolute REA_VERIFY_SOLC_MODULE pointing to unchanged solc 0.8.30 and absolute REA_VERIFY_STRACE_COMMAND. No target or chain is executed.",
+ );
+try {
+ await access(
+ process.env.REA_EVM_PRLIMIT_COMMAND ?? "/usr/bin/prlimit",
+ constants.X_OK,
+ );
+ await access(solcModule, constants.R_OK);
+ await access(strace, constants.X_OK);
+} catch (cause) {
+ throw new Error(
+ "verify:evm:interface prerequisite unavailable: selected solc module, strace or util-linux prlimit",
+ { cause },
+ );
+}
+const solc = (await import(pathToFileURL(solcModule).href)).default;
+assert.equal(solc.version(), "0.8.30+commit.73712a01.Emscripten.clang");
+const source = await readFile(
+ new URL("./fixtures/evm-interface.sol", import.meta.url),
+ "utf8",
+);
+const entrypoint =
+ process.argv[2] ?? fileURLToPath(new URL("./rea.mjs", import.meta.url));
+const execute = promisify(execFile);
+const environment = Object.fromEntries(
+ Object.entries(process.env).filter(([, value]) => typeof value === "string"),
+);
+const run = createVerifierRun();
+const root = await PrivateRuntimeRoot.create({ prefix: "rea-evm-verifier-" });
+const client = new Client({ name: "evm-interface-verifier", version: "1" });
+const transport = new StdioClientTransport({
+ command: process.execPath,
+ args: [entrypoint, "mcp"],
+ env: environment,
+ stderr: "pipe",
+});
+const failures = [];
+let cases = 0;
+try {
+ await client.connect(transport);
+ const tools = await client.listTools();
+ assert.ok(tools.tools.some(({ name }) => name === "inspect_evm_interface"));
+ const inspect = async (
+ mode,
+ path,
+ encoding,
+ expectedCategory,
+ scope = { command: process.execPath, prefix: [], client },
+ ) => {
+ let value;
+ if (mode === "cli") {
+ let stdout;
+ try {
+ ({ stdout } = await execute(
+ scope.command,
+ [
+ ...scope.prefix,
+ entrypoint,
+ "inspect-evm-interface",
+ path,
+ encoding,
+ "--json",
+ ],
+ {
+ env: scope.environment ?? environment,
+ timeout: 45_000,
+ maxBuffer: 32 * 1024 * 1024,
+ },
+ ));
+ } catch (cause) {
+ if (typeof cause.code !== "number" || typeof cause.stdout !== "string")
+ throw cause;
+ stdout = cause.stdout;
+ }
+ value = JSON.parse(stdout);
+ } else {
+ const reply = await scope.client.callTool({
+ name: "inspect_evm_interface",
+ arguments: { path, encoding },
+ });
+ value = JSON.parse(mcpTextValue(reply));
+ if (expectedCategory !== undefined) {
+ assert.equal(reply.isError, true);
+ value = value.error;
+ } else assert.notEqual(reply.isError, true, mcpTextValue(reply));
+ }
+ if (expectedCategory !== undefined) {
+ assert.equal(value.category, expectedCategory, JSON.stringify(value));
+ return value;
+ }
+ assert.equal(
+ value.category,
+ undefined,
+ `Unexpected public failure: ${JSON.stringify(value)}`,
+ );
+ const evidence = parseEvidence(mode === "cli" ? value : value.evidence);
+ const report = evidence.normalized_result;
+ assert.equal(report.diagnostics.truncated, false);
+ assert.equal(evidence.provider.version, "evmole@0.9.3");
+ assert.equal(evidence.confidence, "inferred");
+ assert.equal(report.artifact.path, path);
+ assert.equal(report.artifact.encoding, encoding);
+ const carrier = await readFile(path);
+ assert.equal(
+ report.artifact.sha256,
+ createHash("sha256").update(carrier).digest("hex"),
+ );
+ assert.equal(evidence.subject.digest.sha256, report.artifact.sha256);
+ assert.equal(report.artifact.bytes, carrier.length);
+ assert.equal(
+ report.bytecode.sha256,
+ createHash("sha256")
+ .update(Buffer.from(report.bytecode.hex, "hex"))
+ .digest("hex"),
+ );
+ assert.equal(report.bytecode.bytes, report.bytecode.hex.length / 2);
+ for (const key of ["kind", "hardfork", "deployment_authenticity"])
+ assert.equal(report.bytecode[key], "unknown");
+ assert.equal(report.discovery_completeness, "unknown");
+ assert.equal(report.runtime_execution, "not-performed");
+ assert.deepEqual(
+ report.functions.map((fn) => ({
+ selector: fn.selector.slice(2),
+ bytecodeOffset: fn.bytecode_offset,
+ dispatch: fn.dispatch,
+ ...(fn.inferred_arguments === null
+ ? {}
+ : { arguments: fn.inferred_arguments }),
+ ...(fn.inferred_state_mutability === null
+ ? {}
+ : { stateMutability: fn.inferred_state_mutability }),
+ })),
+ evidence.raw_result.functions ?? [],
+ );
+ if (mode === "mcp") assert.deepEqual(value.result, report);
+ return report;
+ };
+ for (const [name, optimizer, viaIR] of [
+ ["plain", false, false],
+ ["optimized", true, false],
+ ["via-ir", true, true],
+ ]) {
+ const output = JSON.parse(
+ solc.compile(
+ JSON.stringify({
+ language: "Solidity",
+ sources: { "Interface.sol": { content: source } },
+ settings: {
+ optimizer: { enabled: optimizer, runs: 200 },
+ viaIR,
+ evmVersion: "cancun",
+ outputSelection: {
+ "*": {
+ "*": [
+ "evm.deployedBytecode.object",
+ "evm.methodIdentifiers",
+ "abi",
+ ],
+ },
+ },
+ },
+ }),
+ ),
+ );
+ assert.ok(
+ !output.errors?.some(({ severity }) => severity === "error"),
+ JSON.stringify(output.errors),
+ );
+ const artifact = output.contracts["Interface.sol"].InterfaceFixture;
+ const hex = artifact.evm.deployedBytecode.object;
+ const expected = Object.values(artifact.evm.methodIdentifiers)
+ .map((selector) => "0x" + selector)
+ .sort();
+ const forms = [
+ ["hex", ` \t0x${hex.toUpperCase()}\r\n`],
+ ["raw", Buffer.from(hex, "hex")],
+ ];
+ for (const [encoding, data] of forms) {
+ const path = join(root.path, `${name}.${encoding}`);
+ await writeFile(path, data);
+ const original = await readFile(path);
+ for (const mode of ["cli", "mcp"]) {
+ const report = await inspect(mode, path, encoding);
+ assert.equal(report.bytecode.hex, hex);
+ assert.deepEqual(
+ report.functions
+ .filter(({ dispatch }) => dispatch === "abi")
+ .map(({ selector }) => selector)
+ .sort(),
+ expected,
+ );
+ assert.ok(
+ report.functions.every(
+ ({ bytecode_offset }) => bytecode_offset < report.bytecode.bytes,
+ ),
+ );
+ const set = report.functions.find(
+ ({ selector }) =>
+ selector === "0x" + artifact.evm.methodIdentifiers["set(uint256)"],
+ );
+ assert.equal(set.inferred_arguments, "uint256");
+ assert.equal(set.inferred_state_mutability, "nonpayable");
+ cases++;
+ }
+ assert.deepEqual(await readFile(path), original);
+ }
+ }
+ for (const [name, data, encoding] of [
+ ["empty", "0x", "hex"],
+ ["empty-raw", Buffer.alloc(0), "raw"],
+ ["incomplete-push", Buffer.from([0x7f]), "raw"],
+ ["no-dispatch", "60006000f3", "hex"],
+ ]) {
+ const path = join(root.path, name);
+ await writeFile(path, data);
+ for (const mode of ["cli", "mcp"]) {
+ const report = await inspect(mode, path, encoding);
+ assert.equal(report.functions.length, 0);
+ cases++;
+ }
+ }
+ for (const [name, hex, expected] of [
+ ["zero-selector", "60003560e01c630000000014601057005b00", ["0x00000000"]],
+ ["push-immediate", "63123456785060006000f3", []],
+ ]) {
+ const path = join(root.path, name);
+ await writeFile(path, hex);
+ for (const mode of ["cli", "mcp"]) {
+ const report = await inspect(mode, path, "hex");
+ assert.deepEqual(
+ report.functions.map(({ selector }) => selector),
+ expected,
+ );
+ cases++;
+ }
+ }
+ for (const [encoding, data] of [
+ ["raw", Buffer.from("ef0001", "hex")],
+ ["hex", "0xef0001"],
+ ]) {
+ const path = join(root.path, "eof." + encoding);
+ await writeFile(path, data);
+ for (const mode of ["cli", "mcp"]) {
+ const failure = await inspect(mode, path, encoding, "unsupported_target");
+ assert.equal(failure.code, "unsupported_target");
+ assert.equal(failure.details.path, path);
+ assert.match(failure.details.reason, /EF00/);
+ assert.match(failure.remediation.action, /target format/);
+ cases++;
+ }
+ }
+ for (const [name, data] of [
+ ["odd", "0x1"],
+ ["invalid-hex", "xyz"],
+ ["interior-space", "60 00"],
+ ["bom", "\uFEFF6000"],
+ ["utf8", Buffer.from([0xff])],
+ ]) {
+ const path = join(root.path, name);
+ await writeFile(path, data);
+ for (const mode of ["cli", "mcp"]) {
+ await inspect(mode, path, "hex", "invalid_input");
+ cases++;
+ }
+ }
+ for (const mode of ["cli", "mcp"]) {
+ await inspect(mode, join(root.path, "absent"), "raw", "invalid_input");
+ cases++;
+ }
+ for (const [option, expected] of [
+ ["--as=2147483648", "address_space_bytes=2147483648"],
+ ["--cpu=20", "cpu_seconds=20"],
+ ["--fsize=8388608", "file_size_bytes=8388608"],
+ ]) {
+ const limiter = environment.REA_EVM_PRLIMIT_COMMAND ?? "/usr/bin/prlimit";
+ const prefix = [
+ option,
+ "--",
+ process.execPath,
+ "--disable-wasm-trap-handler",
+ ];
+ const limitedClient = new Client({
+ name: "limited-evm-verifier",
+ version: "1",
+ });
+ const limitedTransport = new StdioClientTransport({
+ command: limiter,
+ args: [...prefix, entrypoint, "mcp"],
+ env: environment,
+ stderr: "pipe",
+ });
+ try {
+ await limitedClient.connect(limitedTransport);
+ const scope = { command: limiter, prefix, client: limitedClient };
+ for (const mode of ["cli", "mcp"]) {
+ const report = await inspect(
+ mode,
+ join(root.path, "zero-selector"),
+ "hex",
+ undefined,
+ scope,
+ );
+ assert.ok(report.limitations.some((item) => item.includes(expected)));
+ cases++;
+ }
+ } finally {
+ try {
+ await limitedClient.close();
+ } finally {
+ await limitedTransport.close();
+ }
+ }
+ }
+ const brokenLimiter = join(root.path, "broken-limiter");
+ await writeFile(brokenLimiter, "#!/rea-verifier-missing-interpreter\n", {
+ mode: 0o700,
+ });
+ const signalLimiter = join(root.path, "signal-limiter");
+ await writeFile(signalLimiter, '#!/bin/sh\nulimit -c 0\nkill -XCPU "$$"\n', {
+ mode: 0o700,
+ });
+ const fileLimiter = join(root.path, "file-size-limiter");
+ const unmarkedLimiter = join(root.path, "unmarked-file-size-limiter");
+ await writeFile(
+ unmarkedLimiter,
+ '#!/bin/sh\nprintf "launcher exit 76\\n" >&2\nexit 76\n',
+ { mode: 0o700 },
+ );
+ // Fixture-only launcher: run the actual Node/WASM worker with a one-byte
+ // file allowance, keeping the usual address-space, CPU and core budgets.
+ await writeFile(
+ fileLimiter,
+ '#!/bin/sh\nwhile [ "$#" -gt 0 ] && [ "$1" != "--" ]; do shift; done\n[ "$#" -gt 0 ] || exit 64\nshift\nexec /usr/bin/prlimit --as=3221225472: --cpu=30: --fsize=1: --core=0: -- "$@"\n',
+ { mode: 0o700 },
+ );
+ for (const [limiter, category, resource] of [
+ [root.path, "unsupported_provider"],
+ [brokenLimiter, "unsupported_provider"],
+ [signalLimiter, "resource_constraint", "cpu"],
+ [fileLimiter, "resource_constraint", "file-size"],
+ [unmarkedLimiter, "execution_failure"],
+ ]) {
+ const selectedEnvironment = {
+ ...environment,
+ REA_EVM_PRLIMIT_COMMAND: limiter,
+ };
+ const selectedClient = new Client({
+ name: "unavailable-evm-limiter-verifier",
+ version: "1",
+ });
+ const selectedTransport = new StdioClientTransport({
+ command: process.execPath,
+ args: [entrypoint, "mcp"],
+ env: selectedEnvironment,
+ stderr: "pipe",
+ });
+ try {
+ await selectedClient.connect(selectedTransport);
+ const scope = {
+ command: process.execPath,
+ prefix: [],
+ client: selectedClient,
+ environment: selectedEnvironment,
+ };
+ for (const mode of ["cli", "mcp"]) {
+ const error = await inspect(
+ mode,
+ join(root.path, "zero-selector"),
+ "hex",
+ category,
+ scope,
+ );
+ if (category === "resource_constraint") {
+ assert.equal(error.code, "resource_constraint");
+ assert.equal(error.details.resource, resource);
+ assert.equal(
+ error.details.reported_limits.effective_soft_limits,
+ null,
+ );
+ assert.equal(
+ typeof error.details.reported_limits.configured_soft_limits
+ .cpu_seconds,
+ "number",
+ );
+ assert.ok(
+ JSON.stringify(error).includes(
+ resource === "cpu" ? "SIGXCPU" : "EFBIG",
+ ),
+ );
+ } else if (category === "execution_failure") {
+ assert.equal(error.code, "execution_failure");
+ assert.ok(JSON.stringify(error).includes("launcher exit 76"));
+ assert.ok(JSON.stringify(error).includes("file_size_failure_marker"));
+ } else {
+ assert.equal(error.code, "capability_unavailable");
+ assert.ok(JSON.stringify(error).includes(limiter));
+ }
+ cases++;
+ }
+ } finally {
+ try {
+ await selectedClient.close();
+ } finally {
+ await selectedTransport.close();
+ }
+ }
+ }
+ for (const [name, encoding] of [
+ ["plain.hex", "hex"],
+ ["via-ir.raw", "raw"],
+ ]) {
+ await execute(
+ strace,
+ [
+ "-ff",
+ "-e",
+ "trace=network,execve,execveat",
+ "-s",
+ "4096",
+ "-o",
+ join(root.path, `offline.trace.${name}`),
+ process.execPath,
+ entrypoint,
+ "inspect-evm-interface",
+ join(root.path, name),
+ encoding,
+ "--json",
+ ],
+ {
+ env: { ...environment, PATH: "/usr/bin:/bin" },
+ timeout: 45_000,
+ maxBuffer: 32 * 1024 * 1024,
+ },
+ );
+ }
+ const observedExecutables = new Set();
+ for (const file of await readdir(root.path)) {
+ if (!file.startsWith("offline.trace.")) continue;
+ const localSocketDescriptors = new Set();
+ for (const line of (await readFile(join(root.path, file), "utf8")).split(
+ "\n",
+ )) {
+ if (/execve(?:at)?\(/.test(line)) {
+ const match = /^execve\("([^"]+)"/.exec(line);
+ assert.notEqual(match, null, `Unresolved executable identity: ${line}`);
+ assert.ok(
+ line.endsWith(" = 0") || / = -1 [A-Z]+/.test(line),
+ `Incomplete exec observation: ${line}`,
+ );
+ const ownershipInspection =
+ ["/usr/bin/ps", "/bin/ps"].includes(match[1]) &&
+ line.includes('["ps", "-axo", "pid=,ppid=,pgid=,stat=,command="]');
+ assert.ok(
+ [
+ process.execPath,
+ environment.REA_EVM_PRLIMIT_COMMAND ?? "/usr/bin/prlimit",
+ ].includes(match[1]) || ownershipInspection,
+ `Unexpected host execution: ${line}`,
+ );
+ observedExecutables.add(match[1]);
+ } else if (/^[a-z]+\(/.test(line)) {
+ assert.ok(
+ permitsStdioSocketObservation(line, localSocketDescriptors),
+ `Unexpected attempted network operation: ${line}`,
+ );
+ }
+ }
+ }
+ assert.ok(observedExecutables.has(process.execPath));
+ assert.ok(
+ observedExecutables.has(
+ environment.REA_EVM_PRLIMIT_COMMAND ?? "/usr/bin/prlimit",
+ ),
+ );
+ cases += 2;
+} catch (cause) {
+ failures.push(cause);
+} finally {
+ for (const close of [
+ () => client.close(),
+ () => transport.close(),
+ () => root.close(),
+ ]) {
+ try {
+ await close();
+ } catch (cause) {
+ failures.push(cause);
+ }
+ }
+}
+const verifier = await completeVerifierRun(run);
+if (
+ verifier.process_lineage.status !== "verified" ||
+ verifier.process_lineage.descendants.length !== 0
+)
+ failures.push(
+ new Error(
+ "Verifier process lineage was not fully released: " +
+ JSON.stringify(verifier),
+ ),
+ );
+console.log(
+ JSON.stringify(
+ {
+ status: failures.length === 0 ? "passed" : "failed",
+ public_cases: cases,
+ compiler: solc.version(),
+ engine: "evmole@0.9.3",
+ ...(failures.length === 0
+ ? {
+ target_execution: "exec-syscalls-verified-absent",
+ network_requests: "socket-syscalls-verified-absent",
+ }
+ : {}),
+ verifier,
+ },
+ null,
+ 2,
+ ),
+);
+if (failures.length !== 0)
+ throw new AggregateError(
+ failures,
+ "Offline EVM interface verification failed; cleanup failures retained.",
+ );
+
+function permitsStdioSocketObservation(line, localDescriptors) {
+ if (line.startsWith("socketpair(AF_UNIX,")) {
+ const pair = /\[(\d+), (\d+)\]\)\s+= 0$/.exec(line);
+ if (pair !== null) {
+ localDescriptors.add(pair[1]);
+ localDescriptors.add(pair[2]);
+ }
+ return true;
+ }
+ const name = /^getsockname\((\d+), \{sa_family=AF_UNIX\}/.exec(line);
+ if (name !== null) {
+ localDescriptors.add(name[1]);
+ return true;
+ }
+ // A failed descriptor query performs no request and identifies no socket.
+ if (line.startsWith("getsockname(") && / = -1 ENOTSOCK/.test(line))
+ return true;
+ const metadata =
+ /^(?:getsockopt\((\d+), SOL_SOCKET, SO_TYPE,|setsockopt\((\d+), SOL_SOCKET, SO_(?:RCVBUF|SNDBUF),|shutdown\((\d+), SHUT_WR\))/.exec(
+ line,
+ );
+ const descriptor = metadata?.[1] ?? metadata?.[2] ?? metadata?.[3];
+ // Allow descriptor queries/buffer configuration only after observing a local
+ // stdio socket. Other socket creation, connection and data operations fail.
+ return descriptor !== undefined && localDescriptors.has(descriptor);
+}
diff --git a/scripts/verify/apple/macos-bundle.mjs b/scripts/verify/apple/macos-bundle.mjs
index bc4522b731..972c8a7c4a 100644
--- a/scripts/verify/apple/macos-bundle.mjs
+++ b/scripts/verify/apple/macos-bundle.mjs
@@ -14,6 +14,7 @@ import {
buildMacosBundleFixture,
preflightMacosBundleFixture,
} from "../../fixtures/apple/macos-bundle.mjs";
+import { verifyDylibResolution } from "./macos-dylib-e2e.mjs";
const exec = promisify(execFile);
@@ -150,6 +151,7 @@ try {
assert.deepEqual(anatomies[2], anatomies[0], "DMG anatomy differs");
const { stdout: attached } = await exec("/usr/bin/hdiutil", ["info"]);
assert.ok(!attached.includes(dmg), "DMG remained attached after inventory");
+ const dylibs = await verifyDylibResolution(app);
process.stdout.write(
`${JSON.stringify({
@@ -160,6 +162,7 @@ try {
containers: containers.map(({ format }) => format),
bundles: EXPECTED_BUNDLES.length,
dmg_detached: true,
+ dylib_resolution: dylibs,
})}\n`,
);
} finally {
diff --git a/scripts/verify/apple/macos-dylib-e2e.mjs b/scripts/verify/apple/macos-dylib-e2e.mjs
new file mode 100644
index 0000000000..eacd06e9fb
--- /dev/null
+++ b/scripts/verify/apple/macos-dylib-e2e.mjs
@@ -0,0 +1,209 @@
+import assert from "node:assert/strict";
+import { execFile } from "node:child_process";
+import { realpath } from "node:fs/promises";
+import { join, relative } from "node:path";
+import { promisify } from "node:util";
+
+import {
+ artifactCliEvidence,
+ artifactMcpResult,
+ withArtifactMcp,
+} from "../../lib/artifact-e2e.mjs";
+
+const exec = promisify(execFile);
+
+const MAIN = "Contents/MacOS/MacFixture";
+const SERVICE = "Contents/XPCServices/Svc.xpc/Contents/MacOS/Svc";
+const CORE = "Contents/Frameworks/Core.framework/Versions/A/Core";
+const DEPENDENCY_COMMANDS = new Set([
+ "LC_LOAD_DYLIB",
+ "LC_LOAD_WEAK_DYLIB",
+ "LC_REEXPORT_DYLIB",
+ "LC_LAZY_LOAD_DYLIB",
+ "LC_LOAD_UPWARD_DYLIB",
+]);
+
+/** Dependencies, rpaths and install name of one slice as Apple's otool reports them. */
+const otoolLoadCommands = async (path, architecture) => {
+ const { stdout } = await exec(
+ "/usr/bin/xcrun",
+ ["otool", "-arch", architecture, "-l", path],
+ { maxBuffer: 64 * 1024 * 1024 },
+ );
+ const result = { dependencies: [], rpaths: [], install_name: null };
+ for (const block of stdout.split(/\nLoad command \d+\n/u)) {
+ const command = /^\s*cmd (\S+)/mu.exec(block)?.[1];
+ const name = /^\s*name (.+?) \(offset \d+\)$/mu.exec(block)?.[1];
+ if (command === "LC_RPATH")
+ result.rpaths.push(/^\s*path (.+?) \(offset \d+\)$/mu.exec(block)?.[1]);
+ else if (command === "LC_ID_DYLIB") result.install_name = name;
+ else if (DEPENDENCY_COMMANDS.has(command))
+ result.dependencies.push(`${command} ${name}`);
+ }
+ return result;
+};
+
+/** In-bundle images dyld actually loaded, in load order. */
+const loadedImages = async (app, executable) => {
+ const { stderr } = await exec(join(app, executable), [], {
+ env: { PATH: "/usr/bin:/bin", DYLD_PRINT_LIBRARIES: "1" },
+ });
+ const appRoot = await realpath(app);
+ const loaded = [];
+ for (const line of stderr.split("\n")) {
+ const path = /^dyld\[\d+\]: <[0-9A-F-]+> (.+)$/u.exec(line)?.[1];
+ if (path === undefined) continue;
+ // Shared-cache libraries are reported by path but are not files on disk.
+ const real = await realpath(path).catch(() => undefined);
+ if (real?.startsWith(`${appRoot}/`) === true)
+ loaded.push(relative(appRoot, real));
+ }
+ return loaded;
+};
+
+/** Images a process root loads according to the trace, in first-load order. */
+const predictedImages = (trace, root) => {
+ const loaded = [root];
+ for (const edge of trace.edges)
+ if (
+ edge.root === root &&
+ ["resolved", "conditional"].includes(edge.resolution.status) &&
+ !loaded.includes(edge.resolution.image)
+ )
+ loaded.push(edge.resolution.image);
+ return loaded;
+};
+
+const findEdge = (trace, root, loader, installName) => {
+ const edge = trace.edges.find(
+ (candidate) =>
+ candidate.root === root &&
+ candidate.loader === loader &&
+ candidate.install_name === installName,
+ );
+ assert.ok(edge, `missing edge ${root}: ${loader} -> ${installName}`);
+ return edge;
+};
+
+const verifyResolutionShapes = (trace) => {
+ const core = findEdge(
+ trace,
+ MAIN,
+ MAIN,
+ "@rpath/Core.framework/Versions/A/Core",
+ );
+ assert.deepEqual(
+ core.candidates.map(({ source, outcome }) => [source, outcome]),
+ [
+ ["rpath", "absent"],
+ ["rpath", "resolved"],
+ ],
+ );
+ assert.deepEqual(core.resolution, { status: "resolved", image: CORE });
+ const weak = findEdge(trace, MAIN, MAIN, "@rpath/libgone.dylib");
+ assert.equal(weak.weak, true);
+ assert.equal(weak.resolution.status, "unresolved");
+ const delayed = findEdge(trace, MAIN, MAIN, "@rpath/libdelay.dylib");
+ assert.equal(delayed.encoding, "dylib_use_command");
+ assert.equal(delayed.delayed_init, true);
+ const chain = findEdge(trace, MAIN, CORE, "@rpath/libchain.dylib");
+ assert.equal(
+ chain.resolution.image,
+ "Contents/Frameworks/Core.framework/Versions/A/Libraries/libchain.dylib",
+ "the loading image's own LC_RPATH must be searched first",
+ );
+ const service = findEdge(
+ trace,
+ SERVICE,
+ SERVICE,
+ "@rpath/Core.framework/Versions/A/Core",
+ );
+ assert.equal(service.candidates[0]?.outcome, "outside-target");
+ assert.deepEqual(service.resolution, { status: "conditional", image: CORE });
+ const system = trace.edges.filter(({ install_name: name }) =>
+ name.startsWith("/usr/lib/"),
+ );
+ assert.ok(system.length > 0);
+ assert.ok(
+ system.every(({ resolution }) => resolution.status === "undetermined"),
+ );
+ const kinds = trace.findings.map(({ kind, image }) => `${kind}:${image}`);
+ assert.ok(kinds.includes(`weak-load-unresolved:${MAIN}`));
+ assert.ok(kinds.includes(`earlier-rpath-candidate-absent:${MAIN}`));
+};
+
+const verifyParserConformance = async (app, trace) => {
+ let slices = 0;
+ for (const image of trace.images)
+ for (const slice of image.slices) {
+ const expected = await otoolLoadCommands(
+ join(app, image.path),
+ slice.architecture,
+ );
+ const edges = trace.edges.filter(
+ ({ loader, architecture, root }) =>
+ loader === image.path &&
+ architecture === slice.architecture &&
+ root ===
+ trace.edges.find(
+ (edge) =>
+ edge.loader === image.path &&
+ edge.architecture === slice.architecture,
+ )?.root,
+ );
+ assert.deepEqual(
+ {
+ dependencies: edges.map(
+ ({ command, install_name: name }) => `${command} ${name}`,
+ ),
+ rpaths: slice.rpaths,
+ install_name: slice.install_name,
+ },
+ expected,
+ `${image.path} (${slice.architecture}) differs from otool -l`,
+ );
+ slices += 1;
+ }
+ return slices;
+};
+
+/** Verify trace_dylib_resolution against otool and the real dyld on the fixture app. */
+export async function verifyDylibResolution(app) {
+ const trace = (await artifactCliEvidence("trace-dylib-resolution", app))
+ .normalized_result;
+ await withArtifactMcp(app, async (client) => {
+ assert.deepEqual(
+ await artifactMcpResult(client, "trace_dylib_resolution"),
+ trace,
+ );
+ const invalid = await client.callTool({
+ name: "trace_dylib_resolution",
+ arguments: { roots: ["../outside"] },
+ });
+ assert.equal(invalid.isError, true);
+ });
+ assert.equal(trace.coverage.status, "complete");
+ assert.deepEqual(trace.roots.map(({ image }) => image).sort(), [
+ "Contents/Frameworks/Core.framework/Versions/A/XPCServices/FwSvc.xpc/Contents/MacOS/FwSvc",
+ "Contents/Helpers/rea-tool",
+ "Contents/Library/LaunchServices/com.example.rea.helper",
+ "Contents/Library/LoginItems/Login.app/Contents/MacOS/Login",
+ MAIN,
+ "Contents/PlugIns/Ext.appex/Contents/MacOS/Ext",
+ SERVICE,
+ ]);
+ verifyResolutionShapes(trace);
+ const slices = await verifyParserConformance(app, trace);
+ for (const root of [MAIN, SERVICE])
+ assert.deepEqual(
+ await loadedImages(app, root),
+ predictedImages(trace, root),
+ `${root} load order differs from DYLD_PRINT_LIBRARIES`,
+ );
+ return {
+ roots: trace.roots.length,
+ edges: trace.edges.length,
+ otool_slices: slices,
+ dyld_runtime_roots: 2,
+ };
+}
diff --git a/skills/reverse-engineer-anything/SKILL.md b/skills/reverse-engineer-anything/SKILL.md
index b59b1f0bb4..b74854a633 100644
--- a/skills/reverse-engineer-anything/SKILL.md
+++ b/skills/reverse-engineer-anything/SKILL.md
@@ -2,9 +2,9 @@
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: "31"
- tool_count: 136
- catalog_digest: "3206a7f58e9e56f55570ba375ac90b0be91891d21ee97a493870f6a1a0149d77"
+ version: "32"
+ tool_count: 138
+ catalog_digest: "aab2ca1d88c1ce394e8eb2c842ab101e000e2aebd7489ce482cc46a36eef940f"
---
# REA
@@ -99,6 +99,11 @@ explicit path or endpoint and do not need it.
[historical captures](https://github.com/morluto/rea/blob/main/docs/web-network-captures.md) for the exact upstream
profile and credential exclusions.
- User-owned Electron runtime already open: `list_electron_targets`.
+- Explicit local EVM bytecode carrier: `inspect_evm_interface` with caller-selected
+ `raw` or `hex` encoding. Preserve carrier/decoded digests and treat selectors,
+ argument strings and mutability as inferred candidates; this performs no chain
+ lookup or target execution. See the
+ [EVM bytecode guide](https://github.com/morluto/rea/blob/main/docs/evm-bytecode.md).
- Explicit Linux ELF file for offline layout, symbols, relocations or static
mitigation evidence: `inspect_binary_layout`. This target-free operation uses
caller-supplied pwntools without opening a disassembler database. Preserve its
diff --git a/skills/reverse-engineer-anything/references/native-and-artifacts.md b/skills/reverse-engineer-anything/references/native-and-artifacts.md
index 87edb1f9de..157da39824 100644
--- a/skills/reverse-engineer-anything/references/native-and-artifacts.md
+++ b/skills/reverse-engineer-anything/references/native-and-artifacts.md
@@ -89,6 +89,13 @@ helpers, launchd plists, symlinks, and each bundle's `info_plist_path` and
executable candidates. Roles are path conventions, not parsed plists; read the
listed plists with `inspect_plist`.
+After `open_binary` on a `.app` or Mach-O, use `trace_dylib_resolution` to see
+which file each `@rpath`, `@loader_path` and `@executable_path` load reaches for
+every executable in the bundle. It also lists missing or weak loads and earlier
+`@rpath` candidates that are absent. Narrow large bundles with `roots` or
+`architecture`. System paths stay undetermined because the dyld shared cache
+provides them.
+
Use `extract_artifact` when materialized files are needed. It takes no arguments
and materializes all regular files into a fresh temporary directory chosen by
REA. Symlinks and encrypted entries are inventory facts, not extractable files.
diff --git a/src/application/CapabilityInventory.ts b/src/application/CapabilityInventory.ts
index ab2c25d94e..ea627693e6 100644
--- a/src/application/CapabilityInventory.ts
+++ b/src/application/CapabilityInventory.ts
@@ -31,6 +31,7 @@ type ToolAvailabilityReason = "available" | ToolUnavailabilityReason;
type ProviderDescriptor = ProviderCapability;
export type AvailabilityPolicy = {
+ readonly evmInterfaceEnabled?: boolean;
readonly processCaptureEnabled: boolean;
readonly optionalProviderLoadFailures?: OptionalProviderLoadFailures;
readonly binaryLayoutEnabled?: boolean;
@@ -289,6 +290,14 @@ const workflowAvailabilityFor = ({
remediation:
"On Linux x64, set absolute REA_PWNTOOLS_PYTHON to caller-supplied Python with pwntools 4.15.0. No active binary target is required.",
};
+ if (name === "inspect_evm_interface")
+ return policy.evmInterfaceEnabled === true
+ ? { reason: "available", remediation: null }
+ : {
+ reason: "provider_missing",
+ remediation:
+ "The bundled offline EVM interface profile is currently real-verified on Linux x64. No active binary target or chain endpoint is required.",
+ };
if (kind === "firmware-provider") {
const enabled =
name === "inspect_firmware_regions"
diff --git a/src/application/binary/BinarySession.ts b/src/application/binary/BinarySession.ts
index 2e256bb1a8..fa01de1246 100644
--- a/src/application/binary/BinarySession.ts
+++ b/src/application/binary/BinarySession.ts
@@ -374,7 +374,8 @@ export class BinarySession
lookupSnapshot: (target, profile, operation, parameters) =>
operation === "decode_interface_builder" ||
operation === "inspect_asset_catalog" ||
- operation === "inspect_keyed_archive"
+ operation === "inspect_keyed_archive" ||
+ operation === "trace_dylib_resolution"
? undefined
: this.lookupSnapshot(target, profile, operation, parameters),
});
@@ -397,7 +398,8 @@ export class BinarySession
profile !== undefined &&
name !== "decode_interface_builder" &&
name !== "inspect_asset_catalog" &&
- name !== "inspect_keyed_archive"
+ name !== "inspect_keyed_archive" &&
+ name !== "trace_dylib_resolution"
) {
const evidence = createEvidence(
profiled.value.subject ?? active.target,
diff --git a/src/application/evm/EvmInterfacePort.ts b/src/application/evm/EvmInterfacePort.ts
new file mode 100644
index 0000000000..1d5c39ef45
--- /dev/null
+++ b/src/application/evm/EvmInterfacePort.ts
@@ -0,0 +1,15 @@
+import type {
+ AnalysisExecution,
+ ExecutionOptions,
+} from "../AnalysisProvider.js";
+import type { AnalysisError } from "../../domain/analysisErrorBase.js";
+import type { InspectEvmInterfaceInput } from "../../domain/evm/evmInterface.js";
+import type { Result } from "../../domain/result.js";
+
+/** Replaceable offline interface-recovery engine, independent of wallets and chain clients. */
+export interface EvmInterfacePort {
+ inspect(
+ input: InspectEvmInterfaceInput,
+ options?: ExecutionOptions,
+ ): Promise>;
+}
diff --git a/src/application/evm/EvmInterfaceService.test.ts b/src/application/evm/EvmInterfaceService.test.ts
new file mode 100644
index 0000000000..9b01973884
--- /dev/null
+++ b/src/application/evm/EvmInterfaceService.test.ts
@@ -0,0 +1,124 @@
+import { expect, it } from "vitest";
+import { EvmInterfaceService } from "./EvmInterfaceService.js";
+import {
+ evmInterfaceExecution,
+ evmInterfaceFixture,
+} from "../../../tests/fixtures/evm/interface.js";
+import { err, ok } from "../../domain/result.js";
+import { parseEvidence } from "../../domain/evidence.js";
+import { AnalysisCapabilityUnavailableError } from "../../domain/analysisErrorCore.js";
+
+it("returns full inline inferred Evidence and unmodified upstream representation", async () => {
+ const value = evmInterfaceFixture();
+ const service = new EvmInterfaceService({
+ inspect: () => Promise.resolve(ok(evmInterfaceExecution(value))),
+ });
+ const result = await service.inspect({
+ path: value.artifact.path,
+ encoding: "hex",
+ });
+ if (!result.ok) throw result.error;
+ const evidence = parseEvidence(result.value);
+ expect(evidence.normalized_result).toEqual(value);
+ expect(evidence.raw_result).toEqual({ functions: [] });
+ expect(evidence.confidence).toBe("inferred");
+ expect(evidence.subject?.digest.sha256).toBe(value.artifact.sha256);
+});
+it.each([
+ { path: "relative.hex", encoding: "hex" },
+ { path: "/selected", encoding: "auto" },
+ { path: "/selected" },
+ { path: "/selected", encoding: "raw", approval: true },
+])("rejects invalid selection before effects: %j", async (input) => {
+ const service = new EvmInterfaceService({
+ inspect: () => {
+ throw new Error("must not start");
+ },
+ });
+ expect(await service.inspect(input)).toMatchObject({
+ ok: false,
+ error: { _tag: "AnalysisInputError" },
+ });
+});
+it.each(["carrier", "encoding", "digest", "no-subject"])(
+ "rejects mismatched producer binding: %s",
+ async (problem) => {
+ const value = evmInterfaceFixture();
+ if (problem === "carrier") value.artifact.path = "/other";
+ if (problem === "encoding") value.artifact.encoding = "raw";
+ const observed = evmInterfaceExecution(value);
+ const execution = {
+ ...observed,
+ subject:
+ problem === "no-subject"
+ ? null
+ : {
+ path: "/artifacts/source-owned.hex",
+ sha256:
+ problem === "digest" ? "b".repeat(64) : value.artifact.sha256,
+ format: "file" as const,
+ },
+ };
+ const service = new EvmInterfaceService({
+ inspect: () => Promise.resolve(ok(execution)),
+ });
+ expect(
+ await service.inspect({
+ path: "/artifacts/source-owned.hex",
+ encoding: "hex",
+ }),
+ ).toMatchObject({
+ ok: false,
+ error: { _tag: "AnalysisOutputError", capturedOutput: value.diagnostics },
+ });
+ },
+);
+it("preserves meaningful provider failure and cancellation before/after execution", async () => {
+ const failure = new AnalysisCapabilityUnavailableError(
+ "evm",
+ "inspect_evm_interface",
+ "Selected limiter missing",
+ );
+ const service = new EvmInterfaceService({
+ inspect: () => Promise.resolve(err(failure)),
+ });
+ expect(await service.inspect({ path: "/code", encoding: "raw" })).toEqual(
+ err(failure),
+ );
+ const controller = new AbortController();
+ const observed = evmInterfaceFixture();
+ observed.diagnostics = {
+ stdout: "selected\u0000",
+ stderr: "warning",
+ truncated: true,
+ };
+ const cancelled = new EvmInterfaceService({
+ inspect: () => {
+ controller.abort();
+ return Promise.resolve(ok(evmInterfaceExecution(observed)));
+ },
+ });
+ expect(
+ await cancelled.inspect(
+ { path: "/artifacts/source-owned.hex", encoding: "hex" },
+ { signal: controller.signal },
+ ),
+ ).toMatchObject({
+ ok: false,
+ error: {
+ _tag: "AnalysisCancelledError",
+ capturedOutput: observed.diagnostics,
+ },
+ });
+ const untouched = new EvmInterfaceService({
+ inspect: () => {
+ throw new Error("must not start");
+ },
+ });
+ expect(
+ await untouched.inspect(
+ { path: "/code", encoding: "raw" },
+ { signal: controller.signal },
+ ),
+ ).toMatchObject({ ok: false, error: { _tag: "AnalysisCancelledError" } });
+});
diff --git a/src/application/evm/EvmInterfaceService.ts b/src/application/evm/EvmInterfaceService.ts
new file mode 100644
index 0000000000..9769255c00
--- /dev/null
+++ b/src/application/evm/EvmInterfaceService.ts
@@ -0,0 +1,92 @@
+import { isAbsolute } from "node:path";
+import { z } from "zod";
+import type { ExecutionOptions } from "../AnalysisProvider.js";
+import type { EvmInterfacePort } from "./EvmInterfacePort.js";
+import type { AnalysisError } from "../../domain/analysisErrorBase.js";
+import {
+ AnalysisCancelledError,
+ AnalysisInputError,
+ AnalysisOutputError,
+} from "../../domain/analysisErrorCore.js";
+import { createEvidence, type Evidence } from "../../domain/evidence.js";
+import {
+ inspectEvmInterfaceInputSchema,
+ evmInterfaceSchema,
+} from "../../domain/evm/evmInterface.js";
+import { projectInputIssues } from "../../domain/inputIssueProjection.js";
+import { err, ok, type Result } from "../../domain/result.js";
+
+const OPERATION = "inspect_evm_interface";
+const diagnosticsSchema = z.object({
+ diagnostics: evmInterfaceSchema.shape.diagnostics,
+});
+
+/** Shared carrier validation and typed inline Evidence for CLI/MCP. */
+export class EvmInterfaceService {
+ constructor(readonly provider: EvmInterfacePort) {}
+
+ /** Preserve selected byte identity while clearly labeling recovered ABI candidates as inferences. */
+ async inspect(
+ rawInput: unknown,
+ options?: ExecutionOptions,
+ ): Promise> {
+ if (options?.signal?.aborted)
+ return err(new AnalysisCancelledError(OPERATION));
+ const input = inspectEvmInterfaceInputSchema.safeParse(rawInput);
+ if (!input.success)
+ return err(
+ new AnalysisInputError(
+ OPERATION,
+ { cause: input.error },
+ projectInputIssues(input.error.issues, rawInput),
+ ),
+ );
+ if (!isAbsolute(input.data.path))
+ return err(
+ new AnalysisInputError(OPERATION, undefined, [
+ {
+ path: ["path"],
+ reason: "invalid_format",
+ message: "Expected an absolute filesystem path on this host.",
+ },
+ ]),
+ );
+ const inspected = await this.provider.inspect(input.data, options);
+ if (!inspected.ok) return inspected;
+ const diagnostics = diagnosticsSchema.safeParse(inspected.value.result);
+ const outputOptions = diagnostics.success
+ ? { capturedOutput: diagnostics.data.diagnostics }
+ : undefined;
+ if (options?.signal?.aborted)
+ return err(new AnalysisCancelledError(OPERATION, outputOptions));
+ const execution = inspected.value;
+ const report = evmInterfaceSchema.safeParse(execution.result);
+ if (
+ !report.success ||
+ report.data.artifact.path !== input.data.path ||
+ report.data.artifact.encoding !== input.data.encoding ||
+ execution.subject?.path !== input.data.path ||
+ execution.subject.sha256 !== report.data.artifact.sha256
+ )
+ return err(
+ new AnalysisOutputError(
+ OPERATION,
+ "Interface adapter returned malformed data or changed the selected carrier identity/encoding.",
+ outputOptions,
+ ),
+ );
+ return ok(
+ createEvidence(execution.subject, execution.provider, {
+ operation: OPERATION,
+ parameters: input.data,
+ result: report.data,
+ ...(execution.rawResult === null
+ ? {}
+ : { rawResult: execution.rawResult }),
+ confidence: "inferred",
+ locations: execution.locations,
+ limitations: execution.limitations,
+ }),
+ );
+ }
+}
diff --git a/src/artifacts/ArtifactProvider.ts b/src/artifacts/ArtifactProvider.ts
index 8d4885f48a..4a3d02fc2e 100644
--- a/src/artifacts/ArtifactProvider.ts
+++ b/src/artifacts/ArtifactProvider.ts
@@ -8,6 +8,7 @@ import {
type ExecutionOptions,
} from "../application/AnalysisProvider.js";
import { inspectBundleKeyedArchive } from "./apple/KeyedArchiveReader.js";
+import { traceDylibResolution } from "./apple/DylibResolutionReader.js";
import { basename, dirname } from "node:path";
import { inventoryArtifact } from "./inventory/ArtifactInventory.js";
import { extractArtifact } from "./extraction/ArtifactExtraction.js";
@@ -153,6 +154,8 @@ class ArtifactClient implements AnalysisClient {
if (operation === "inspect_asset_catalog") {
return await this.inspectAssetCatalog(parameters, options);
}
+ if (operation === "trace_dylib_resolution")
+ return await this.traceDylibResolution(parameters, options);
if (operation === "extract_artifact") {
const parsed = artifactExtractionExecutionSchema.parse(parameters);
const result = await extractArtifact(
@@ -234,6 +237,40 @@ class ArtifactClient implements AnalysisClient {
);
}
+ private async traceDylibResolution(
+ parameters: Readonly>,
+ options?: ExecutionOptions,
+ ) {
+ if (this.target.kind !== "executable" || this.target.format !== "mach-o")
+ throw new ArtifactReaderFailure(
+ "unavailable",
+ "trace_dylib_resolution requires an active Mach-O or .app bundle target",
+ );
+ // Only a target opened from an app bundle directory carries its Info.plist;
+ // a regular file whose name ends in .app is a standalone image.
+ const bundle =
+ this.target.bundleInfoPlist === undefined
+ ? undefined
+ : this.target.sourcePath;
+ const result = await traceDylibResolution({
+ rootPath: bundle ?? dirname(this.target.path),
+ targetPath: this.target.path,
+ targetSha256: this.target.sha256,
+ enumerateRoots: bundle !== undefined,
+ parameters,
+ ...(options?.signal === undefined ? {} : { signal: options.signal }),
+ });
+ return ok(
+ createAnalysisExecution(result, IDENTITY, {
+ limitations: result.limitations,
+ locations: result.images.map(({ path }) => ({
+ kind: "artifact-path" as const,
+ path,
+ })),
+ }),
+ );
+ }
+
private async inspectArtifact(
parameters: Readonly>,
options?: ExecutionOptions,
diff --git a/src/artifacts/ArtifactProviderMetadata.ts b/src/artifacts/ArtifactProviderMetadata.ts
index 9cbe0d65e9..3e350508ed 100644
--- a/src/artifacts/ArtifactProviderMetadata.ts
+++ b/src/artifacts/ArtifactProviderMetadata.ts
@@ -24,7 +24,8 @@ export const artifactCapabilities = (
mutatesArtifact: false,
launchesProcess:
operation !== "decode_interface_builder" &&
- operation !== "inspect_keyed_archive",
+ operation !== "inspect_keyed_archive" &&
+ operation !== "trace_dylib_resolution",
mayShowUi: false,
mayAccessNetwork: false,
mayWriteFilesystem:
diff --git a/src/artifacts/apple/DylibResolutionReader.ts b/src/artifacts/apple/DylibResolutionReader.ts
new file mode 100644
index 0000000000..812e147150
--- /dev/null
+++ b/src/artifacts/apple/DylibResolutionReader.ts
@@ -0,0 +1,360 @@
+import { createHash } from "node:crypto";
+import { constants } from "node:fs";
+import { lstat, open, readlink, realpath } from "node:fs/promises";
+import { join, relative, sep } from "node:path";
+
+import { resolveTreePath } from "../../domain/apple/dyldPaths.js";
+import {
+ dylibResolutionInputSchema,
+ dylibResolutionResultSchema,
+ traceDylibLoading,
+ type DylibResolutionResult,
+ type DylibTreeEntry,
+ type DylibTreeView,
+ type MachoImageFacts,
+} from "../../domain/apple/dylibResolution.js";
+import { ArtifactReaderFailure } from "../ArtifactReader.js";
+import { DirectoryArtifactReader } from "../DirectoryArtifactReader.js";
+import { hasMachoMagic, readMachoImage } from "./MachoLoadCommandReader.js";
+
+const HASH_CHUNK_BYTES = 1024 * 1024;
+
+/** Device, inode, size and change times of the file whose bytes were parsed. */
+type FileIdentity = string;
+
+/** Lazily probed, symlink-preserving view of one analyzed directory. */
+class FilesystemTreeView implements DylibTreeView {
+ readonly #entries = new Map>();
+ readonly #images = new Map>();
+ readonly #identities = new Map();
+
+ constructor(
+ private readonly root: string,
+ private readonly signal?: AbortSignal,
+ ) {}
+
+ entry(path: string): Promise {
+ const cached = this.#entries.get(path);
+ if (cached !== undefined) return cached;
+ const pending = this.#readEntry(path);
+ this.#entries.set(path, pending);
+ return pending;
+ }
+
+ image(path: string): Promise {
+ const cached = this.#images.get(path);
+ if (cached !== undefined) return cached;
+ const pending = readImage(join(this.root, path), this.signal).then(
+ ({ facts, identity }) => {
+ this.#identities.set(path, identity);
+ return facts;
+ },
+ );
+ this.#images.set(path, pending);
+ return pending;
+ }
+
+ /** Identity of the file whose header bytes `image()` parsed. */
+ identity(path: string): FileIdentity | undefined {
+ return this.#identities.get(path);
+ }
+
+ async #readEntry(path: string): Promise {
+ cancelled(this.signal);
+ const absolute = join(this.root, path);
+ try {
+ const metadata = await lstat(absolute);
+ if (metadata.isSymbolicLink())
+ return { kind: "symlink", target: await readlink(absolute) };
+ if (metadata.isDirectory()) return { kind: "directory" };
+ return metadata.isFile() ? { kind: "file" } : undefined;
+ } catch (cause: unknown) {
+ if (missing(cause)) return undefined;
+ throw cause;
+ }
+ }
+}
+
+/** Resolve dyld load paths for the active Mach-O or every executable in its bundle. */
+export const traceDylibResolution = async (options: {
+ readonly rootPath: string;
+ readonly targetPath: string;
+ readonly targetSha256: string;
+ readonly enumerateRoots: boolean;
+ readonly parameters: unknown;
+ readonly signal?: AbortSignal;
+}): Promise => {
+ const parsed = dylibResolutionInputSchema.safeParse(options.parameters);
+ if (!parsed.success)
+ throw new ArtifactReaderFailure(
+ "path",
+ "roots must be normalized paths relative to the analyzed root",
+ );
+ // Canonicalization is inside the translated region: a revoked permission
+ // keeps its reason and path.
+ let root = options.rootPath;
+ try {
+ root = await realpath(options.rootPath);
+ const target = relative(root, await realpath(options.targetPath))
+ .split(sep)
+ .join("/");
+ const view = new FilesystemTreeView(root, options.signal);
+ const { roots: requested, unclassified } =
+ parsed.data.roots !== undefined
+ ? { roots: parsed.data.roots, unclassified: [] }
+ : options.enumerateRoots
+ ? await executableRoots(root, view, options.signal)
+ : { roots: [target], unclassified: [] };
+ const roots = await requireMachoRoots(view, requested);
+ const trace = await traceDylibLoading(view, {
+ roots,
+ unclassified,
+ ...(parsed.data.architecture === undefined
+ ? {}
+ : { architecture: parsed.data.architecture }),
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
+ });
+ // Each digest must describe the same file whose headers were parsed.
+ const digests = new Map();
+ for (const { path } of trace.images)
+ digests.set(
+ path,
+ await fileSha256(join(root, path), {
+ path,
+ expected: view.identity(path),
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
+ }),
+ );
+ const targetDigest =
+ digests.get(target) ??
+ (await fileSha256(join(root, target), {
+ path: target,
+ expected: undefined,
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
+ }));
+ if (targetDigest !== options.targetSha256)
+ throw new ArtifactReaderFailure(
+ "integrity",
+ `Active target digest changed: expected ${options.targetSha256}, observed ${targetDigest}`,
+ );
+ return dylibResolutionResultSchema.parse({
+ ...trace,
+ root_path: options.rootPath,
+ target_sha256: options.targetSha256,
+ images: trace.images.map((image) => ({
+ ...image,
+ sha256: digests.get(image.path),
+ })),
+ });
+ } catch (cause: unknown) {
+ if (options.signal?.aborted === true)
+ throw new ArtifactReaderFailure(
+ "cancelled",
+ "Dylib resolution was cancelled",
+ { cause },
+ );
+ const denied = permissionDenied(cause);
+ if (denied !== undefined)
+ throw new ArtifactReaderFailure(
+ "unavailable",
+ `Permission denied (${denied.code}) reading ${deniedPath(root, denied.path)}`,
+ { cause },
+ );
+ throw cause;
+ }
+};
+
+/**
+ * Every Mach-O in the bundle with an executable slice is its own process root.
+ * Mach-O files that do not parse are returned separately: whether they are
+ * executables is unknown, so they make coverage partial instead of vanishing.
+ */
+const executableRoots = async (
+ root: string,
+ view: FilesystemTreeView,
+ signal?: AbortSignal,
+): Promise<{ readonly roots: string[]; readonly unclassified: string[] }> => {
+ const roots: string[] = [];
+ const unclassified: string[] = [];
+ for await (const entry of new DirectoryArtifactReader(root).entries(signal)) {
+ if (
+ entry.kind !== "file" ||
+ !(await startsWithMachoMagic(entry.adapterKey))
+ )
+ continue;
+ const facts = await view.image(entry.path);
+ if (facts.status === "malformed" || facts.status === "unsupported")
+ unclassified.push(entry.path);
+ else if (
+ facts.status === "parsed" &&
+ facts.slices.some(({ file_type: type }) => type === "execute")
+ )
+ roots.push(entry.path);
+ }
+ return {
+ roots: roots.sort(compare),
+ unclassified: unclassified.sort(compare),
+ };
+};
+
+const compare = (left: string, right: string): number =>
+ left < right ? -1 : left > right ? 1 : 0;
+
+/**
+ * Resolve each root segment by segment, as dependency candidates are, so a
+ * symlink in an intermediate directory cannot lead outside the analyzed root.
+ */
+const requireMachoRoots = async (
+ view: FilesystemTreeView,
+ roots: readonly string[],
+): Promise => {
+ const resolved: string[] = [];
+ for (const path of roots) {
+ const lookup = await resolveTreePath(view, path);
+ if (lookup.kind === "escapes")
+ throw new ArtifactReaderFailure(
+ "path",
+ `Root ${path} resolves outside the analyzed root`,
+ );
+ if (lookup.kind !== "file")
+ throw new ArtifactReaderFailure(
+ "path",
+ `Root ${path} is not a regular file in the analyzed root`,
+ );
+ if ((await view.image(lookup.path)).status === "not-mach-o")
+ throw new ArtifactReaderFailure(
+ "format",
+ `Root ${path} is not a Mach-O image`,
+ );
+ if (!resolved.includes(lookup.path)) resolved.push(lookup.path);
+ }
+ return resolved;
+};
+
+const startsWithMachoMagic = async (path: string): Promise => {
+ const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
+ try {
+ const buffer = new Uint8Array(4);
+ const { bytesRead } = await handle.read(buffer, 0, 4, 0);
+ return hasMachoMagic(buffer.subarray(0, bytesRead));
+ } finally {
+ await handle.close();
+ }
+};
+
+const identityOf = (metadata: {
+ readonly dev: bigint;
+ readonly ino: bigint;
+ readonly size: bigint;
+ readonly mtimeNs: bigint;
+ readonly ctimeNs: bigint;
+}): FileIdentity =>
+ [
+ metadata.dev,
+ metadata.ino,
+ metadata.size,
+ metadata.mtimeNs,
+ metadata.ctimeNs,
+ ].join(":");
+
+const readImage = async (
+ path: string,
+ signal?: AbortSignal,
+): Promise<{
+ readonly facts: MachoImageFacts;
+ readonly identity: FileIdentity;
+}> => {
+ cancelled(signal);
+ const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
+ try {
+ const metadata = await handle.stat({ bigint: true });
+ const size = Number(metadata.size);
+ const facts = await readMachoImage(async (offset, length) => {
+ cancelled(signal);
+ const buffer = new Uint8Array(
+ Math.max(0, Math.min(length, size - offset)),
+ );
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, offset);
+ return buffer.subarray(0, bytesRead);
+ }, size);
+ return { facts, identity: identityOf(metadata) };
+ } finally {
+ await handle.close();
+ }
+};
+
+/** Hash a file, failing if it is not the file (or the version) that was parsed. */
+const fileSha256 = async (
+ absolute: string,
+ options: {
+ readonly path: string;
+ readonly expected: FileIdentity | undefined;
+ readonly signal?: AbortSignal;
+ },
+): Promise => {
+ const { signal } = options;
+ const handle = await open(
+ absolute,
+ constants.O_RDONLY | constants.O_NOFOLLOW,
+ );
+ const unchanged = async (): Promise => {
+ if (
+ options.expected !== undefined &&
+ identityOf(await handle.stat({ bigint: true })) !== options.expected
+ )
+ throw new ArtifactReaderFailure(
+ "integrity",
+ `${options.path} changed while its dyld load commands were traced`,
+ );
+ };
+ try {
+ await unchanged();
+ const hash = createHash("sha256");
+ const buffer = new Uint8Array(HASH_CHUNK_BYTES);
+ for (;;) {
+ cancelled(signal);
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, null);
+ if (bytesRead === 0) break;
+ hash.update(buffer.subarray(0, bytesRead));
+ }
+ await unchanged();
+ return hash.digest("hex");
+ } finally {
+ await handle.close();
+ }
+};
+
+const cancelled = (signal?: AbortSignal): void => {
+ if (signal?.aborted === true)
+ throw new ArtifactReaderFailure(
+ "cancelled",
+ "Dylib resolution was cancelled",
+ );
+};
+
+/** A denied path relative to the analyzed root, or absolute when outside it. */
+const deniedPath = (root: string, path: string | undefined): string => {
+ if (path === undefined) return "a file in the analyzed root";
+ const inside = relative(root, path);
+ return inside === "" || inside.startsWith("..") ? path : inside;
+};
+
+/** Host permission denials, kept distinct from malformed or missing files. */
+const permissionDenied = (
+ cause: unknown,
+): { readonly code: string; readonly path: string | undefined } | undefined => {
+ if (!(cause instanceof Error) || !("code" in cause)) return undefined;
+ if (cause.code !== "EACCES" && cause.code !== "EPERM") return undefined;
+ return {
+ code: cause.code,
+ path:
+ "path" in cause && typeof cause.path === "string"
+ ? cause.path
+ : undefined,
+ };
+};
+
+const missing = (cause: unknown): boolean =>
+ cause instanceof Error &&
+ "code" in cause &&
+ (cause.code === "ENOENT" || cause.code === "ENOTDIR");
diff --git a/src/artifacts/apple/MachoImage.fixture.ts b/src/artifacts/apple/MachoImage.fixture.ts
new file mode 100644
index 0000000000..634ab4ecf6
--- /dev/null
+++ b/src/artifacts/apple/MachoImage.fixture.ts
@@ -0,0 +1,152 @@
+/** Little-endian Mach-O images and FAT containers built field by field for tests. */
+
+export const LC = {
+ LOAD_DYLIB: 0xc,
+ ID_DYLIB: 0xd,
+ LOAD_WEAK_DYLIB: 0x80000018,
+ REEXPORT_DYLIB: 0x8000001f,
+ LAZY_LOAD_DYLIB: 0x20,
+ LOAD_UPWARD_DYLIB: 0x80000023,
+ RPATH: 0x8000001c,
+ DYLD_ENVIRONMENT: 0x27,
+ CODE_SIGNATURE: 0x1d,
+} as const;
+
+export const CPU = {
+ arm64: { type: 0x0100000c, subtype: 0 },
+ arm64e: { type: 0x0100000c, subtype: 0x80000002 },
+ x86_64: { type: 0x01000007, subtype: 3 },
+} as const;
+
+export const FILE_TYPE = { execute: 2, dylib: 6, bundle: 8 } as const;
+
+const encoder = new TextEncoder();
+
+/** One load command with a NUL-terminated string at `nameOffset`, padded to 8 bytes. */
+const commandWithString = (
+ command: number,
+ fields: readonly number[],
+ text: string,
+): Uint8Array => {
+ const nameOffset = 8 + fields.length * 4 + 4;
+ const name = encoder.encode(text);
+ const size = Math.ceil((nameOffset + name.length + 1) / 8) * 8;
+ const bytes = new Uint8Array(size);
+ const view = new DataView(bytes.buffer);
+ view.setUint32(0, command, true);
+ view.setUint32(4, size, true);
+ view.setUint32(8, nameOffset, true);
+ fields.forEach((value, index) => view.setUint32(12 + index * 4, value, true));
+ bytes.set(name, nameOffset);
+ return bytes;
+};
+
+/** Classic `dylib_command`: timestamp, current and compatibility versions. */
+export const dylibCommand = (
+ command: number,
+ name: string,
+ versions: { readonly current?: number; readonly compatibility?: number } = {},
+): Uint8Array =>
+ commandWithString(
+ command,
+ [2, versions.current ?? 0x10000, versions.compatibility ?? 0x10000],
+ name,
+ );
+
+/** `dylib_use_command`: marker, versions and `DYLIB_USE_*` flags. */
+export const dylibUseCommand = (
+ command: number,
+ name: string,
+ flags: number,
+): Uint8Array =>
+ commandWithString(command, [0x1a741800, 0x10000, 0x10000, flags], name);
+
+export const rpathCommand = (path: string): Uint8Array =>
+ commandWithString(LC.RPATH, [], path);
+
+export const dyldEnvironmentCommand = (value: string): Uint8Array =>
+ commandWithString(LC.DYLD_ENVIRONMENT, [], value);
+
+export const codeSignatureCommand = (): Uint8Array => {
+ const bytes = new Uint8Array(16);
+ const view = new DataView(bytes.buffer);
+ view.setUint32(0, LC.CODE_SIGNATURE, true);
+ view.setUint32(4, 16, true);
+ return bytes;
+};
+
+/** A 64-bit (or 32-bit) little-endian Mach-O image made of the given commands. */
+export const machoImage = (options: {
+ readonly cpu?: { readonly type: number; readonly subtype: number };
+ readonly fileType?: number;
+ readonly commands?: readonly Uint8Array[];
+ readonly wide?: boolean;
+}): Uint8Array => {
+ const wide = options.wide ?? true;
+ const commands = options.commands ?? [];
+ const headerSize = wide ? 32 : 28;
+ const commandBytes = commands.reduce((total, item) => total + item.length, 0);
+ const bytes = new Uint8Array(headerSize + commandBytes + 16);
+ const view = new DataView(bytes.buffer);
+ const cpu = options.cpu ?? CPU.arm64;
+ view.setUint32(0, wide ? 0xfeedfacf : 0xfeedface, true);
+ view.setUint32(4, cpu.type, true);
+ view.setUint32(8, cpu.subtype, true);
+ view.setUint32(12, options.fileType ?? FILE_TYPE.execute, true);
+ view.setUint32(16, commands.length, true);
+ view.setUint32(20, commandBytes, true);
+ let offset = headerSize;
+ for (const command of commands) {
+ bytes.set(command, offset);
+ offset += command.length;
+ }
+ return bytes;
+};
+
+/**
+ * A FAT container whose slices start on 4 KiB boundaries. Headers are
+ * big-endian unless `littleEndian` selects the swapped FAT_CIGAM magics.
+ */
+export const fatImage = (
+ slices: readonly {
+ readonly cpu: { readonly type: number; readonly subtype: number };
+ readonly bytes: Uint8Array;
+ }[],
+ wide = false,
+ littleEndian = false,
+): Uint8Array => {
+ const entrySize = wide ? 32 : 20;
+ const offsets: number[] = [];
+ let end = 4096;
+ for (const slice of slices) {
+ offsets.push(end);
+ end += Math.ceil(slice.bytes.length / 4096) * 4096;
+ }
+ const bytes = new Uint8Array(end);
+ const view = new DataView(bytes.buffer);
+ view.setUint32(0, wide ? 0xcafebabf : 0xcafebabe, littleEndian);
+ view.setUint32(4, slices.length, littleEndian);
+ slices.forEach((slice, index) => {
+ const base = 8 + index * entrySize;
+ const offset = offsets[index] ?? 0;
+ view.setUint32(base, slice.cpu.type, littleEndian);
+ view.setUint32(base + 4, slice.cpu.subtype, littleEndian);
+ if (wide) {
+ view.setBigUint64(base + 8, BigInt(offset), littleEndian);
+ view.setBigUint64(base + 16, BigInt(slice.bytes.length), littleEndian);
+ view.setUint32(base + 24, 12, littleEndian);
+ } else {
+ view.setUint32(base + 8, offset, littleEndian);
+ view.setUint32(base + 12, slice.bytes.length, littleEndian);
+ view.setUint32(base + 16, 12, littleEndian);
+ }
+ bytes.set(slice.bytes, offset);
+ });
+ return bytes;
+};
+
+/** In-memory `ReadAt` over one buffer. */
+export const readerOf =
+ (bytes: Uint8Array) =>
+ (offset: number, length: number): Promise =>
+ Promise.resolve(bytes.subarray(offset, offset + length));
diff --git a/src/artifacts/apple/MachoLoadCommandReader.test.ts b/src/artifacts/apple/MachoLoadCommandReader.test.ts
new file mode 100644
index 0000000000..892312ea0d
--- /dev/null
+++ b/src/artifacts/apple/MachoLoadCommandReader.test.ts
@@ -0,0 +1,320 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ CPU,
+ FILE_TYPE,
+ LC,
+ codeSignatureCommand,
+ dyldEnvironmentCommand,
+ dylibCommand,
+ dylibUseCommand,
+ fatImage,
+ machoImage,
+ readerOf,
+ rpathCommand,
+} from "./MachoImage.fixture.js";
+import {
+ MAX_LOAD_COMMAND_BYTES,
+ hasMachoMagic,
+ readMachoImage,
+} from "./MachoLoadCommandReader.js";
+
+const read = (bytes: Uint8Array) =>
+ readMachoImage(readerOf(bytes), bytes.length);
+
+describe("Mach-O load command reader", () => {
+ it("decodes dylib loading commands of a thin image", async () => {
+ const image = machoImage({
+ fileType: FILE_TYPE.dylib,
+ commands: [
+ dylibCommand(LC.ID_DYLIB, "@rpath/Core.framework/Core"),
+ dylibCommand(LC.LOAD_DYLIB, "/usr/lib/libSystem.B.dylib", {
+ current: 0x050f0400,
+ compatibility: 0x10000,
+ }),
+ dylibCommand(LC.LOAD_WEAK_DYLIB, "@rpath/libweak.dylib"),
+ dylibCommand(LC.REEXPORT_DYLIB, "@loader_path/libre.dylib"),
+ dylibCommand(LC.LOAD_UPWARD_DYLIB, "@rpath/libup.dylib"),
+ dylibCommand(LC.LAZY_LOAD_DYLIB, "@rpath/liblazy.dylib"),
+ rpathCommand("@loader_path/Frameworks"),
+ rpathCommand("/usr/lib/swift"),
+ dyldEnvironmentCommand("DYLD_FRAMEWORK_PATH=@executable_path"),
+ codeSignatureCommand(),
+ ],
+ });
+ const facts = await read(image);
+ expect(facts.status).toBe("parsed");
+ if (facts.status !== "parsed") return;
+ expect(facts.slices).toHaveLength(1);
+ const [slice] = facts.slices;
+ expect(slice).toMatchObject({
+ architecture: "arm64",
+ file_type: "dylib",
+ install_name: "@rpath/Core.framework/Core",
+ rpaths: ["@loader_path/Frameworks", "/usr/lib/swift"],
+ dyld_environment: ["DYLD_FRAMEWORK_PATH=@executable_path"],
+ code_signature_present: true,
+ });
+ expect(
+ slice?.dependencies.map(
+ ({ command, install_name: name, weak, reexport, upward }) => [
+ command,
+ name,
+ weak,
+ reexport,
+ upward,
+ ],
+ ),
+ ).toEqual([
+ ["LC_LOAD_DYLIB", "/usr/lib/libSystem.B.dylib", false, false, false],
+ ["LC_LOAD_WEAK_DYLIB", "@rpath/libweak.dylib", true, false, false],
+ ["LC_REEXPORT_DYLIB", "@loader_path/libre.dylib", false, true, false],
+ ["LC_LOAD_UPWARD_DYLIB", "@rpath/libup.dylib", false, false, true],
+ ["LC_LAZY_LOAD_DYLIB", "@rpath/liblazy.dylib", false, false, false],
+ ]);
+ expect(slice?.dependencies[0]).toMatchObject({
+ encoding: "dylib_command",
+ current_version: "1295.4.0",
+ compatibility_version: "1.0.0",
+ });
+ });
+
+ it("decodes dylib_use_command flags", async () => {
+ const facts = await read(
+ machoImage({
+ commands: [
+ dylibUseCommand(LC.LOAD_DYLIB, "@rpath/libdelay.dylib", 0x8),
+ dylibUseCommand(LC.LOAD_DYLIB, "@rpath/libweakup.dylib", 0x1 | 0x4),
+ dylibUseCommand(LC.LOAD_DYLIB, "@rpath/libre.dylib", 0x2),
+ ],
+ }),
+ );
+ if (facts.status !== "parsed") throw new Error(facts.status);
+ expect(
+ facts.slices[0]?.dependencies.map(
+ ({ encoding, weak, upward, reexport, delayed_init: delayed }) => ({
+ encoding,
+ weak,
+ upward,
+ reexport,
+ delayed,
+ }),
+ ),
+ ).toEqual([
+ {
+ encoding: "dylib_use_command",
+ weak: false,
+ upward: false,
+ reexport: false,
+ delayed: true,
+ },
+ {
+ encoding: "dylib_use_command",
+ weak: true,
+ upward: true,
+ reexport: false,
+ delayed: false,
+ },
+ {
+ encoding: "dylib_use_command",
+ weak: false,
+ upward: false,
+ reexport: true,
+ delayed: false,
+ },
+ ]);
+ });
+
+ it("reads every slice of FAT and FAT_64 containers in either byte order", async () => {
+ for (const [wide, littleEndian] of [
+ [false, false],
+ [true, false],
+ [false, true],
+ [true, true],
+ ] as const) {
+ const facts = await read(
+ fatImage(
+ [
+ {
+ cpu: CPU.arm64e,
+ bytes: machoImage({
+ cpu: CPU.arm64e,
+ commands: [rpathCommand("@executable_path/a")],
+ }),
+ },
+ {
+ cpu: CPU.x86_64,
+ bytes: machoImage({
+ cpu: CPU.x86_64,
+ commands: [rpathCommand("@executable_path/b")],
+ }),
+ },
+ ],
+ wide,
+ littleEndian,
+ ),
+ );
+ if (facts.status !== "parsed") throw new Error(facts.status);
+ expect(
+ facts.slices.map(({ architecture, rpaths }) => [architecture, rpaths]),
+ ).toEqual([
+ ["arm64e", ["@executable_path/a"]],
+ ["x86_64", ["@executable_path/b"]],
+ ]);
+ }
+ });
+});
+
+describe("Mach-O load command reader inputs", () => {
+ it("names 32-bit and non-Mach-O inputs without guessing", async () => {
+ const i386 = await read(
+ machoImage({ cpu: { type: 7, subtype: 3 }, wide: false }),
+ );
+ expect(i386.status === "parsed" && i386.slices[0]?.architecture).toBe(
+ "i386",
+ );
+ // A Java class file shares the FAT magic; its version is not a slice count.
+ const javaClass = Uint8Array.from([0xca, 0xfe, 0xba, 0xbe, 0, 0, 0, 0x34]);
+ expect(hasMachoMagic(javaClass)).toBe(true);
+ expect(await read(javaClass)).toEqual({ status: "not-mach-o" });
+ expect(await read(new TextEncoder().encode("#!/bin/sh\n"))).toEqual({
+ status: "not-mach-o",
+ });
+ expect(await read(Uint8Array.from([0xcf, 0xfa]))).toEqual({
+ status: "not-mach-o",
+ });
+ // A recognized magic without room for a header is a truncated image.
+ expect(
+ await read(Uint8Array.from([0xcf, 0xfa, 0xed, 0xfe, 0x0c, 0x00])),
+ ).toEqual({ status: "malformed", reason: "Mach-O header is truncated" });
+ });
+});
+
+describe("Mach-O load command reader failures", () => {
+ const withCommandSize = (size: number): Uint8Array => {
+ const image = machoImage({ commands: [rpathCommand("@loader_path")] });
+ new DataView(image.buffer).setUint32(32 + 4, size, true);
+ return image;
+ };
+
+ it("reports malformed command tables with the failing command", async () => {
+ expect(await read(withCommandSize(0))).toMatchObject({
+ status: "malformed",
+ reason: expect.stringContaining("load command 0"),
+ });
+ expect(await read(withCommandSize(4096))).toMatchObject({
+ status: "malformed",
+ reason: expect.stringContaining("invalid cmdsize"),
+ });
+ const extraCommand = machoImage({ commands: [rpathCommand("x")] });
+ new DataView(extraCommand.buffer).setUint32(16, 2, true);
+ expect(await read(extraCommand)).toMatchObject({
+ status: "malformed",
+ reason: "load command 1 starts beyond sizeofcmds",
+ });
+ });
+
+ it("requires 8-byte command alignment in 64-bit images", async () => {
+ // A command the reader does not decode; only its size is checked.
+ const command = new Uint8Array(12);
+ const view = new DataView(command.buffer);
+ view.setUint32(0, 0x7f000001, true);
+ view.setUint32(4, 12, true);
+ expect(await read(machoImage({ commands: [command] }))).toMatchObject({
+ status: "malformed",
+ reason: expect.stringContaining("invalid cmdsize 12"),
+ });
+ expect(
+ await read(
+ machoImage({
+ cpu: { type: 7, subtype: 3 },
+ wide: false,
+ commands: [command],
+ }),
+ ),
+ ).toMatchObject({ status: "parsed" });
+ });
+
+ it("accepts FAT tables up to 128 records that name a Mach-O CPU", async () => {
+ const slices = Array.from({ length: 24 }, () => ({
+ cpu: CPU.arm64,
+ bytes: machoImage({}),
+ }));
+ const facts = await read(fatImage(slices));
+ expect(facts.status === "parsed" && facts.slices).toHaveLength(24);
+ // An empty table is not a Java class file, whose major version is nonzero.
+ for (const magic of [
+ [0xca, 0xfe, 0xba, 0xbe],
+ [0xbf, 0xba, 0xfe, 0xca],
+ ])
+ expect(await read(Uint8Array.from([...magic, 0, 0, 0, 0]))).toEqual({
+ status: "malformed",
+ reason: "FAT header declares no architectures",
+ });
+ });
+
+ it("rejects string commands too short to hold their string offset", async () => {
+ const short = new Uint8Array(8);
+ const view = new DataView(short.buffer);
+ view.setUint32(0, LC.RPATH, true);
+ view.setUint32(4, 8, true);
+ expect(await read(machoImage({ commands: [short] }))).toMatchObject({
+ status: "malformed",
+ reason: "load command 0 is too short to hold a string offset",
+ });
+ view.setUint32(0, LC.CODE_SIGNATURE, true);
+ expect(await read(machoImage({ commands: [short] }))).toMatchObject({
+ status: "malformed",
+ reason: "load command 0 is too short for LC_CODE_SIGNATURE",
+ });
+ });
+
+ it("rejects strings that are unterminated, misplaced or not UTF-8", async () => {
+ const unterminated = rpathCommand("abcd");
+ unterminated.fill(0x41, 12);
+ expect(await read(machoImage({ commands: [unterminated] }))).toMatchObject({
+ status: "malformed",
+ reason: expect.stringContaining("unterminated"),
+ });
+ const misplaced = rpathCommand("abcd");
+ new DataView(misplaced.buffer).setUint32(8, 4, true);
+ expect(await read(machoImage({ commands: [misplaced] }))).toMatchObject({
+ status: "malformed",
+ reason: expect.stringContaining("string offset 4"),
+ });
+ const invalid = rpathCommand("ab");
+ invalid[12] = 0xff;
+ expect(await read(machoImage({ commands: [invalid] }))).toMatchObject({
+ status: "malformed",
+ reason: expect.stringContaining("not UTF-8"),
+ });
+ });
+
+ it("refuses truncated, oversized, big-endian and out-of-range images", async () => {
+ const image = machoImage({ commands: [rpathCommand("@loader_path")] });
+ expect(await read(image.subarray(0, 20))).toMatchObject({
+ status: "malformed",
+ reason: "Mach-O header is truncated",
+ });
+ const oversized = machoImage({});
+ new DataView(oversized.buffer).setUint32(
+ 20,
+ MAX_LOAD_COMMAND_BYTES + 8,
+ true,
+ );
+ expect(await read(oversized)).toMatchObject({ status: "unsupported" });
+ const beyond = machoImage({ commands: [rpathCommand("x")] });
+ new DataView(beyond.buffer).setUint32(20, beyond.length, true);
+ expect(await read(beyond)).toMatchObject({
+ status: "malformed",
+ reason: "load commands extend beyond the Mach-O slice",
+ });
+ const bigEndian = Uint8Array.from([0xfe, 0xed, 0xfa, 0xcf, 0, 0, 0, 0]);
+ expect(await read(bigEndian)).toMatchObject({ status: "unsupported" });
+ const fat = fatImage([{ cpu: CPU.arm64, bytes: machoImage({}) }]);
+ expect(await read(fat.subarray(0, 4100))).toMatchObject({
+ status: "malformed",
+ reason: "FAT architecture 0 extends beyond the file",
+ });
+ });
+});
diff --git a/src/artifacts/apple/MachoLoadCommandReader.ts b/src/artifacts/apple/MachoLoadCommandReader.ts
new file mode 100644
index 0000000000..b78ce7fbc6
--- /dev/null
+++ b/src/artifacts/apple/MachoLoadCommandReader.ts
@@ -0,0 +1,426 @@
+import type {
+ MachoDependency,
+ MachoImageFacts,
+ MachoSlice,
+} from "../../domain/apple/dylibResolution.js";
+
+/** Read `length` bytes at `offset`; shorter results mean end of file. */
+export type ReadAt = (offset: number, length: number) => Promise;
+
+/** Load commands larger than this are refused instead of buffered. */
+export const MAX_LOAD_COMMAND_BYTES = 16 * 1024 * 1024;
+/** Target opening accepts up to 128 FAT records; Java class files share the FAT magic. */
+const MAX_FAT_ARCHITECTURES = 128;
+/** CPU types of real Mach-O slices (x86, x86_64, arm, arm64, arm64_32, ppc, ppc64). */
+const KNOWN_CPU_TYPES: readonly number[] = [
+ 7, 0x01000007, 12, 0x0100000c, 0x0200000c, 18, 0x01000012,
+];
+
+const MH_MAGIC = 0xfeedface;
+const MH_MAGIC_64 = 0xfeedfacf;
+const MH_CIGAM = 0xcefaedfe;
+const MH_CIGAM_64 = 0xcffaedfe;
+const FAT_MAGIC = 0xcafebabe;
+const FAT_MAGIC_64 = 0xcafebabf;
+const FAT_CIGAM = 0xbebafeca;
+const FAT_CIGAM_64 = 0xbfbafeca;
+
+const LC_ID_DYLIB = 0xd;
+const LC_LOAD_DYLIB = 0xc;
+const LC_LOAD_WEAK_DYLIB = 0x80000018;
+const LC_REEXPORT_DYLIB = 0x8000001f;
+const LC_LAZY_LOAD_DYLIB = 0x20;
+const LC_LOAD_UPWARD_DYLIB = 0x80000023;
+const LC_RPATH = 0x8000001c;
+const LC_DYLD_ENVIRONMENT = 0x27;
+const LC_CODE_SIGNATURE = 0x1d;
+
+/** `mach-o/loader.h` `dylib_use_command`: `nameoff == 28` and this marker. */
+const DYLIB_USE_MARKER = 0x1a741800;
+const DYLIB_USE_WEAK_LINK = 0x1;
+const DYLIB_USE_REEXPORT = 0x2;
+const DYLIB_USE_UPWARD = 0x4;
+const DYLIB_USE_DELAYED_INIT = 0x8;
+
+const DEPENDENCY_COMMANDS: ReadonlyMap =
+ new Map([
+ [LC_LOAD_DYLIB, "LC_LOAD_DYLIB"],
+ [LC_LOAD_WEAK_DYLIB, "LC_LOAD_WEAK_DYLIB"],
+ [LC_REEXPORT_DYLIB, "LC_REEXPORT_DYLIB"],
+ [LC_LAZY_LOAD_DYLIB, "LC_LAZY_LOAD_DYLIB"],
+ [LC_LOAD_UPWARD_DYLIB, "LC_LOAD_UPWARD_DYLIB"],
+ ]);
+
+/** Malformed or unsupported structure found while decoding one image. */
+class MachoFormatIssue extends Error {
+ constructor(
+ readonly status: "malformed" | "unsupported",
+ message: string,
+ ) {
+ super(message);
+ }
+}
+
+/** Report whether the first bytes carry a Mach-O or FAT magic number. */
+export const hasMachoMagic = (head: Uint8Array): boolean => {
+ if (head.byteLength < 4) return false;
+ const magic = new DataView(
+ head.buffer,
+ head.byteOffset,
+ head.byteLength,
+ ).getUint32(0, false);
+ return [
+ MH_MAGIC,
+ MH_MAGIC_64,
+ MH_CIGAM,
+ MH_CIGAM_64,
+ FAT_MAGIC,
+ FAT_MAGIC_64,
+ FAT_CIGAM,
+ FAT_CIGAM_64,
+ ].includes(magic);
+};
+
+/** Decode every slice's dylib-loading load commands without reading code. */
+export const readMachoImage = async (
+ readAt: ReadAt,
+ size: number,
+): Promise => {
+ try {
+ const head = await readAt(0, Math.min(size, 8));
+ if (head.byteLength < 8)
+ // A recognized magic with no room for a header is a truncated image.
+ return hasMachoMagic(head)
+ ? { status: "malformed", reason: "Mach-O header is truncated" }
+ : { status: "not-mach-o" };
+ const view = viewOf(head);
+ const magic = view.getUint32(0, false);
+ if ([FAT_MAGIC, FAT_MAGIC_64, FAT_CIGAM, FAT_CIGAM_64].includes(magic)) {
+ // FAT headers are big-endian; the swapped magics store them little-endian.
+ const littleEndian = magic === FAT_CIGAM || magic === FAT_CIGAM_64;
+ const count = view.getUint32(4, littleEndian);
+ const wide = magic === FAT_MAGIC_64 || magic === FAT_CIGAM_64;
+ // A Java class file's nonzero major version reads as the count, so an
+ // empty architecture table can only be a malformed universal header.
+ if (count === 0)
+ return {
+ status: "malformed",
+ reason: "FAT header declares no architectures",
+ };
+ if (count > MAX_FAT_ARCHITECTURES || 8 + count * (wide ? 32 : 20) > size)
+ return { status: "not-mach-o" };
+ const table = viewOf(
+ await readExact(readAt, 8, count * (wide ? 32 : 20), "FAT table"),
+ );
+ // A Java class file's version fields read as a FAT count; its "table" names no CPU.
+ const cpuTypes = Array.from({ length: count }, (_, index) =>
+ table.getUint32(index * (wide ? 32 : 20), littleEndian),
+ );
+ if (!cpuTypes.some((type) => KNOWN_CPU_TYPES.includes(type)))
+ return { status: "not-mach-o" };
+ return {
+ status: "parsed",
+ slices: await readFatSlices(readAt, size, {
+ wide,
+ littleEndian,
+ count,
+ }),
+ };
+ }
+ if (magic === MH_CIGAM || magic === MH_CIGAM_64)
+ return { status: "parsed", slices: [await readSlice(readAt, 0, size)] };
+ if (magic === MH_MAGIC || magic === MH_MAGIC_64)
+ throw new MachoFormatIssue(
+ "unsupported",
+ "big-endian Mach-O images are not supported",
+ );
+ return { status: "not-mach-o" };
+ } catch (cause: unknown) {
+ if (cause instanceof MachoFormatIssue)
+ return { status: cause.status, reason: cause.message };
+ throw cause;
+ }
+};
+
+const readFatSlices = async (
+ readAt: ReadAt,
+ size: number,
+ table: {
+ readonly wide: boolean;
+ readonly littleEndian: boolean;
+ readonly count: number;
+ },
+): Promise => {
+ const { wide, littleEndian, count } = table;
+ const entrySize = wide ? 32 : 20;
+ const view = viewOf(
+ await readExact(readAt, 8, count * entrySize, "FAT table"),
+ );
+ const slices: MachoSlice[] = [];
+ for (let index = 0; index < count; index++) {
+ const base = index * entrySize;
+ const offset = wide
+ ? safeNumber(view.getBigUint64(base + 8, littleEndian))
+ : view.getUint32(base + 8, littleEndian);
+ const length = wide
+ ? safeNumber(view.getBigUint64(base + 16, littleEndian))
+ : view.getUint32(base + 12, littleEndian);
+ if (offset + length > size)
+ throw new MachoFormatIssue(
+ "malformed",
+ `FAT architecture ${index} extends beyond the file`,
+ );
+ slices.push(await readSlice(readAt, offset, length));
+ }
+ return slices;
+};
+
+const readSlice = async (
+ readAt: ReadAt,
+ base: number,
+ length: number,
+): Promise => {
+ const header = viewOf(await readExact(readAt, base, 28, "Mach-O header"));
+ const magic = header.getUint32(0, true);
+ if (magic === MH_MAGIC_64 || magic === MH_MAGIC) {
+ const wide = magic === MH_MAGIC_64;
+ const headerSize = wide ? 32 : 28;
+ const commandCount = header.getUint32(16, true);
+ const commandBytes = header.getUint32(20, true);
+ if (commandBytes > MAX_LOAD_COMMAND_BYTES)
+ throw new MachoFormatIssue(
+ "unsupported",
+ `load commands occupy ${commandBytes} bytes, above the ${MAX_LOAD_COMMAND_BYTES}-byte limit`,
+ );
+ if (headerSize + commandBytes > length)
+ throw new MachoFormatIssue(
+ "malformed",
+ "load commands extend beyond the Mach-O slice",
+ );
+ const commands = await readExact(
+ readAt,
+ base + headerSize,
+ commandBytes,
+ "load commands",
+ );
+ return decodeCommands(commands, {
+ wide,
+ cpuType: header.getUint32(4, true),
+ cpuSubtype: header.getUint32(8, true),
+ fileType: header.getUint32(12, true),
+ commandCount,
+ });
+ }
+ throw new MachoFormatIssue(
+ magic === swap(MH_MAGIC) || magic === swap(MH_MAGIC_64)
+ ? "unsupported"
+ : "malformed",
+ magic === swap(MH_MAGIC) || magic === swap(MH_MAGIC_64)
+ ? "big-endian Mach-O slices are not supported"
+ : `slice at offset ${base} has no Mach-O header`,
+ );
+};
+
+const decodeCommands = (
+ bytes: Uint8Array,
+ header: {
+ readonly wide: boolean;
+ readonly cpuType: number;
+ readonly cpuSubtype: number;
+ readonly fileType: number;
+ readonly commandCount: number;
+ },
+): MachoSlice => {
+ // loader.h: load commands are 8-byte aligned in 64-bit images, 4 in 32-bit.
+ const alignment = header.wide ? 8 : 4;
+ const view = viewOf(bytes);
+ const slice: MachoSlice = {
+ architecture: architectureName(header.cpuType, header.cpuSubtype),
+ file_type: fileTypeName(header.fileType),
+ install_name: null,
+ dependencies: [],
+ rpaths: [],
+ dyld_environment: [],
+ code_signature_present: false,
+ };
+ let offset = 0;
+ for (let index = 0; index < header.commandCount; index++) {
+ if (offset + 8 > bytes.byteLength)
+ throw new MachoFormatIssue(
+ "malformed",
+ `load command ${index} starts beyond sizeofcmds`,
+ );
+ const command = view.getUint32(offset, true);
+ const size = view.getUint32(offset + 4, true);
+ if (size < 8 || size % alignment !== 0 || offset + size > bytes.byteLength)
+ throw new MachoFormatIssue(
+ "malformed",
+ `load command ${index} at offset ${offset} has invalid cmdsize ${size}`,
+ );
+ const body = bytes.subarray(offset, offset + size);
+ decodeCommand(command, body, index, slice);
+ offset += size;
+ }
+ return slice;
+};
+
+const decodeCommand = (
+ command: number,
+ body: Uint8Array,
+ index: number,
+ slice: MachoSlice,
+): void => {
+ if (command === LC_ID_DYLIB) {
+ slice.install_name = commandString(body, 24, index);
+ return;
+ }
+ if (command === LC_RPATH) {
+ slice.rpaths.push(commandString(body, 12, index));
+ return;
+ }
+ if (command === LC_DYLD_ENVIRONMENT) {
+ slice.dyld_environment.push(commandString(body, 12, index));
+ return;
+ }
+ if (command === LC_CODE_SIGNATURE) {
+ // linkedit_data_command: cmd, cmdsize, dataoff, datasize.
+ if (body.byteLength < 16)
+ throw new MachoFormatIssue(
+ "malformed",
+ `load command ${index} is too short for LC_CODE_SIGNATURE`,
+ );
+ slice.code_signature_present = true;
+ return;
+ }
+ const name = DEPENDENCY_COMMANDS.get(command);
+ if (name !== undefined)
+ slice.dependencies.push(decodeDependency(name, body, index));
+};
+
+const decodeDependency = (
+ command: MachoDependency["command"],
+ body: Uint8Array,
+ index: number,
+): MachoDependency => {
+ if (body.byteLength < 24)
+ throw new MachoFormatIssue(
+ "malformed",
+ `load command ${index} is too short for a dylib command`,
+ );
+ const view = viewOf(body);
+ const nameOffset = view.getUint32(8, true);
+ const usesFlags =
+ nameOffset === 28 &&
+ body.byteLength >= 28 &&
+ view.getUint32(12, true) === DYLIB_USE_MARKER;
+ const flags = usesFlags ? view.getUint32(24, true) : 0;
+ return {
+ command,
+ encoding: usesFlags ? "dylib_use_command" : "dylib_command",
+ install_name: commandString(body, usesFlags ? 28 : 24, index),
+ weak:
+ command === "LC_LOAD_WEAK_DYLIB" || (flags & DYLIB_USE_WEAK_LINK) !== 0,
+ upward:
+ command === "LC_LOAD_UPWARD_DYLIB" || (flags & DYLIB_USE_UPWARD) !== 0,
+ reexport:
+ command === "LC_REEXPORT_DYLIB" || (flags & DYLIB_USE_REEXPORT) !== 0,
+ delayed_init: (flags & DYLIB_USE_DELAYED_INIT) !== 0,
+ current_version: version(view.getUint32(16, true)),
+ compatibility_version: version(view.getUint32(20, true)),
+ };
+};
+
+/** NUL-terminated UTF-8 string at a `lc_str` offset inside one command. */
+const commandString = (
+ body: Uint8Array,
+ minimumOffset: number,
+ index: number,
+): string => {
+ if (body.byteLength < 12)
+ throw new MachoFormatIssue(
+ "malformed",
+ `load command ${index} is too short to hold a string offset`,
+ );
+ const offset = viewOf(body).getUint32(8, true);
+ if (offset < minimumOffset || offset >= body.byteLength)
+ throw new MachoFormatIssue(
+ "malformed",
+ `load command ${index} has string offset ${offset} outside the command`,
+ );
+ const end = body.indexOf(0, offset);
+ if (end < 0)
+ throw new MachoFormatIssue(
+ "malformed",
+ `load command ${index} has an unterminated string`,
+ );
+ try {
+ return new TextDecoder("utf-8", { fatal: true }).decode(
+ body.subarray(offset, end),
+ );
+ } catch {
+ throw new MachoFormatIssue(
+ "malformed",
+ `load command ${index} has a string that is not UTF-8`,
+ );
+ }
+};
+
+const readExact = async (
+ readAt: ReadAt,
+ offset: number,
+ length: number,
+ label: string,
+): Promise => {
+ const bytes = await readAt(offset, length);
+ if (bytes.byteLength < length)
+ throw new MachoFormatIssue("malformed", `${label} is truncated`);
+ return bytes;
+};
+
+const CPU_ARCH_ABI64 = 0x01000000;
+const CPU_ARCH_ABI64_32 = 0x02000000;
+const CPU_TYPE_X86 = 7;
+const CPU_TYPE_ARM = 12;
+const CPU_SUBTYPE_MASK = 0xff000000;
+const CPU_SUBTYPE_ARM64E = 2;
+const CPU_SUBTYPE_X86_64_H = 8;
+
+const architectureName = (cpuType: number, cpuSubtype: number): string => {
+ const subtype = cpuSubtype & ~CPU_SUBTYPE_MASK;
+ if (cpuType === (CPU_TYPE_ARM | CPU_ARCH_ABI64))
+ return subtype === CPU_SUBTYPE_ARM64E ? "arm64e" : "arm64";
+ if (cpuType === (CPU_TYPE_ARM | CPU_ARCH_ABI64_32)) return "arm64_32";
+ if (cpuType === (CPU_TYPE_X86 | CPU_ARCH_ABI64))
+ return subtype === CPU_SUBTYPE_X86_64_H ? "x86_64h" : "x86_64";
+ if (cpuType === CPU_TYPE_X86) return "i386";
+ if (cpuType === CPU_TYPE_ARM) return "arm";
+ return `cpu-${cpuType.toString(16)}`;
+};
+
+const fileTypeName = (fileType: number): MachoSlice["file_type"] =>
+ fileType === 2
+ ? "execute"
+ : fileType === 6
+ ? "dylib"
+ : fileType === 8
+ ? "bundle"
+ : "other";
+
+const version = (value: number): string =>
+ `${value >>> 16}.${(value >>> 8) & 0xff}.${value & 0xff}`;
+
+const swap = (value: number): number =>
+ (((value & 0xff) << 24) |
+ ((value & 0xff00) << 8) |
+ ((value >>> 8) & 0xff00) |
+ (value >>> 24)) >>>
+ 0;
+
+const safeNumber = (value: bigint): number => {
+ if (value > BigInt(Number.MAX_SAFE_INTEGER))
+ throw new MachoFormatIssue("malformed", "FAT offset exceeds safe range");
+ return Number(value);
+};
+
+const viewOf = (bytes: Uint8Array): DataView =>
+ new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
diff --git a/src/cli.ts b/src/cli.ts
index 6fa48dd331..38b5ab7595 100644
--- a/src/cli.ts
+++ b/src/cli.ts
@@ -1,3 +1,4 @@
+import { registerEvmCommands } from "./cli/evmCommands.js";
import { registerBinaryDiagnosticsCommands } from "./cli/binaryDiagnosticsCommands.js";
import { Cli } from "incur";
@@ -56,6 +57,7 @@ export const createCli = (
registerSetupCommands(cli, logger);
registerCoreAnalysisCommands(cli, logger, resultOutput);
registerUtilityCommands(cli, logger, environment);
+ registerEvmCommands(cli, logger, environment);
registerArtifactCommands(cli, logger);
registerManagedCommands(cli, logger);
registerAndroidCommands(cli, logger, environment);
diff --git a/src/cli/artifactCommands.ts b/src/cli/artifactCommands.ts
index 6dc93f7ecb..de16d4b33d 100644
--- a/src/cli/artifactCommands.ts
+++ b/src/cli/artifactCommands.ts
@@ -15,6 +15,7 @@ export const registerArtifactCommands = (
registerExtractionCommand(cli, logger);
registerInterfaceBuilderCommand(cli, logger);
registerAssetCatalogCommand(cli, logger);
+ registerDylibResolutionCommand(cli, logger);
cli.command(CLI_COMMANDS.inspectKeyedArchive, {
description:
"Inspect a Foundation keyed archive as an object-reference graph",
@@ -58,6 +59,45 @@ export const registerArtifactCommands = (
});
};
+const registerDylibResolutionCommand = (
+ cli: CliInstance,
+ logger: Logger,
+): void => {
+ cli.command(CLI_COMMANDS.traceDylibResolution, {
+ description:
+ "Trace dyld load-path resolution for a Mach-O or every executable in an app bundle",
+ args: z.object({
+ path: z.string().describe("Mach-O file or Apple .app bundle path"),
+ }),
+ options: z.object({
+ root: z
+ .array(z.string())
+ .optional()
+ .describe(
+ "Process root relative to the bundle or the Mach-O's directory (repeatable)",
+ ),
+ architecture: z
+ .enum(["arm64", "arm64e", "x86_64"])
+ .optional()
+ .describe("Trace only this slice of each root"),
+ }),
+ run: ({ args, options }) =>
+ logCliCommand(logger, CLI_COMMANDS.traceDylibResolution, () =>
+ runProviderAnalysis(
+ args.path,
+ "trace_dylib_resolution",
+ {
+ ...(options.root === undefined ? {} : { roots: options.root }),
+ ...(options.architecture === undefined
+ ? {}
+ : { architecture: options.architecture }),
+ },
+ logger,
+ ),
+ ),
+ });
+};
+
const registerAssetCatalogCommand = (
cli: CliInstance,
logger: Logger,
diff --git a/src/cli/evmCommands.ts b/src/cli/evmCommands.ts
new file mode 100644
index 0000000000..61f292587b
--- /dev/null
+++ b/src/cli/evmCommands.ts
@@ -0,0 +1,37 @@
+import { z } from "incur";
+import { resolve } from "node:path";
+import { createEvmInterfaceService } from "../composition/evm.js";
+import { CLI_COMMANDS } from "../cliCommandNames.js";
+import { logCliCommand } from "../cliLogging.js";
+import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
+import type { Logger } from "../logger.js";
+import type { CliInstance } from "./types.js";
+import { withCommandCancellation } from "./commandCancellation.js";
+
+/** Resolve operator paths and preserve explicit encoding through the shared workflow. */
+export const registerEvmCommands = (
+ cli: CliInstance,
+ logger: Logger,
+ environment: Readonly,
+): void => {
+ const service = createEvmInterfaceService(environment);
+ cli.command(CLI_COMMANDS.inspectEvmInterface, {
+ description: "Infer interface candidates from explicit local EVM bytecode",
+ args: z.object({
+ path: z.string().describe("Local bytecode carrier"),
+ encoding: z
+ .enum(["raw", "hex"])
+ .describe("Selected carrier representation"),
+ }),
+ run: ({ args }) =>
+ withCommandCancellation((signal) =>
+ logCliCommand(logger, CLI_COMMANDS.inspectEvmInterface, async () => {
+ const result = await service.inspect(
+ { path: resolve(args.path), encoding: args.encoding },
+ { signal },
+ );
+ return result.ok ? result.value : projectAnalysisError(result.error);
+ }),
+ ),
+ });
+};
diff --git a/src/cliCommandNames.ts b/src/cliCommandNames.ts
index 021ff04fcc..83d2194460 100644
--- a/src/cliCommandNames.ts
+++ b/src/cliCommandNames.ts
@@ -1,5 +1,6 @@
/** Canonical names used by every one-shot CLI command registration. */
export const CLI_COMMANDS = Object.freeze({
+ inspectEvmInterface: "inspect-evm-interface",
inspectWebNetworkCapture: "inspect-web-network-capture",
observeWebExecution: "observe-web-execution",
inspectWebEventListeners: "inspect-web-event-listeners",
@@ -28,6 +29,7 @@ export const CLI_COMMANDS = Object.freeze({
extractArtifact: "extract-artifact",
decodeInterfaceBuilder: "decode-interface-builder",
inspectKeyedArchive: "inspect-keyed-archive",
+ traceDylibResolution: "trace-dylib-resolution",
inspectNativeDataType: "inspect-native-data-type",
inspectNativeLoadImage: "inspect-native-load-image",
readBytes: "read-bytes",
diff --git a/src/composition/evm.ts b/src/composition/evm.ts
new file mode 100644
index 0000000000..92c7e73fce
--- /dev/null
+++ b/src/composition/evm.ts
@@ -0,0 +1,8 @@
+import { EvmInterfaceService } from "../application/evm/EvmInterfaceService.js";
+import { EvmoleInterfaceProvider } from "../evm/EvmoleInterfaceProvider.js";
+
+/** Compose offline interface analysis without import-time I/O or dependency acquisition. */
+export const createEvmInterfaceService = (
+ environment: Readonly = process.env,
+): EvmInterfaceService =>
+ new EvmInterfaceService(new EvmoleInterfaceProvider(environment));
diff --git a/src/contracts/artifactToolContracts.ts b/src/contracts/artifactToolContracts.ts
index 85fb8936bc..b52f115af1 100644
--- a/src/contracts/artifactToolContracts.ts
+++ b/src/contracts/artifactToolContracts.ts
@@ -6,6 +6,7 @@ import { jsonValueSchema } from "../domain/jsonValue.js";
import { toolContractMetadata } from "./toolEffects.js";
import { requireOutputSchema } from "./toolOutputSchemaPrimitives.js";
import { appleAssetCatalogInputSchema } from "../domain/apple/appleAssetCatalog.js";
+import { dylibResolutionInputSchema } from "../domain/apple/dylibResolution.js";
import { keyedArchiveInputSchema } from "../domain/apple/keyedArchive.js";
/** Exact caller boundary for deterministic artifact inventory. */
export const artifactInventoryInputSchema = z.strictObject({
@@ -27,6 +28,10 @@ const examples: Readonly>>> = {
decode_interface_builder: {},
inspect_asset_catalog: { offset: 0, limit: 1000 },
inspect_keyed_archive: { path: "Contents/Resources/Model.plist" },
+ trace_dylib_resolution: {
+ roots: ["Contents/MacOS/App"],
+ architecture: "arm64",
+ },
};
const artifact = <
@@ -85,6 +90,11 @@ export const ARTIFACT_TOOL_CONTRACTS = [
"Inspect compiled Assets.car metadata in an active Apple app bundle. Returns stable paginated catalog and rendition records with raw assetutil metadata and catalog digests; rendition bytes are not extracted.",
appleAssetCatalogInputSchema,
),
+ artifact(
+ "trace_dylib_resolution",
+ "Trace how dyld would resolve each Mach-O dylib load for the active Mach-O, or for every executable in the active .app bundle (the main app, XPC services, extensions, login items, helpers). Expands @executable_path, @loader_path, and @rpath through each process's LC_RPATH stack in dependents-first load order, and reports every candidate path with its outcome, the resolved image and digest, weak, re-export, and upward flags, and derived findings such as unresolved required loads or absent earlier @rpath candidates. Pure header parsing: nothing is executed, and absolute or system paths outside the analyzed root stay undetermined. Select roots or one architecture to narrow large bundles.",
+ dylibResolutionInputSchema,
+ ),
] as const satisfies readonly ToolContract[];
/** Provider operations; inventory remains an internal primitive of inspection. */
diff --git a/src/contracts/contractSnapshot.test.ts b/src/contracts/contractSnapshot.test.ts
index 82ee4acd9f..f0e7a6ce94 100644
--- a/src/contracts/contractSnapshot.test.ts
+++ b/src/contracts/contractSnapshot.test.ts
@@ -1,3 +1,4 @@
+import { EVM_TOOL_CONTRACTS } from "./evm/evmToolContracts.js";
import { BINARY_DIAGNOSTICS_TOOL_CONTRACTS } from "./native/binaryDiagnosticsToolContracts.js";
import { describe, expect, it } from "vitest";
import { z } from "zod";
@@ -72,6 +73,7 @@ describe("tool contract surface", () => {
...NATIVE_TOOL_CONTRACTS,
...BINARY_DIAGNOSTICS_TOOL_CONTRACTS,
...ARTIFACT_TOOL_CONTRACTS,
+ ...EVM_TOOL_CONTRACTS,
...ANDROID_TOOL_CONTRACTS,
...FIRMWARE_TOOL_CONTRACTS,
...MANAGED_TOOL_CONTRACTS,
diff --git a/src/contracts/errorSchemas.ts b/src/contracts/errorSchemas.ts
index da7dd924e3..a535f6abf2 100644
--- a/src/contracts/errorSchemas.ts
+++ b/src/contracts/errorSchemas.ts
@@ -5,6 +5,7 @@ import { jsonValueSchema } from "../domain/jsonValue.js";
const categorySchema = z.enum([
"invalid_input",
"unsupported_provider",
+ "unsupported_target",
"integrity_mismatch",
"truncated",
"cancelled",
@@ -37,6 +38,7 @@ export const analysisErrorProjectionSchema = z.discriminatedUnion("code", [
generic("artifact_changed"),
generic("unreadable_output"),
generic("capability_unavailable"),
+ generic("unsupported_target"),
generic("provider_unavailable"),
generic("provider_timeout"),
generic("resource_constraint"),
diff --git a/src/contracts/evm/evmToolContracts.ts b/src/contracts/evm/evmToolContracts.ts
new file mode 100644
index 0000000000..7b8f5bcf9f
--- /dev/null
+++ b/src/contracts/evm/evmToolContracts.ts
@@ -0,0 +1,26 @@
+import {
+ inspectEvmInterfaceInputSchema,
+ evmInterfaceSchema,
+} from "../../domain/evm/evmInterface.js";
+import type { ToolContract } from "../toolContractTypes.js";
+import { toolContractMetadata } from "../toolEffects.js";
+import { evidenceResultOf } from "../toolOutputSchemas.js";
+
+/** Target-free EVM capabilities are independent of archive/Apple artifact providers. */
+export const EVM_TOOL_CONTRACTS = [
+ {
+ name: "inspect_evm_interface",
+ ...toolContractMetadata("inspect_evm_interface"),
+ kind: "artifact-provider",
+ description:
+ "Inspect an explicit local flat EVM bytecode carrier and infer dispatch selectors, argument strings and mutability without executing the target or accessing a chain/RPC endpoint. Choose raw or hex encoding explicitly. Returns original carrier and decoded-byte SHA-256, full bytecode and interface candidates inline as inferred Evidence; bytecode kind, hardfork, deployed authenticity and discovery completeness remain unknown. Initial real-verified Linux x64 profile uses unchanged bundled EVMole 0.9.3/WASM in a bounded owned worker; EOF-style EF00 containers are unsupported. No signature lookup, wallet action or transaction broadcast.",
+ inputSchema: inspectEvmInterfaceInputSchema,
+ outputSchema: evidenceResultOf(evmInterfaceSchema),
+ examples: [
+ {
+ title: "Inspect a local runtime bytecode hex carrier",
+ input: { path: "/artifacts/runtime.hex", encoding: "hex" },
+ },
+ ],
+ },
+] as const satisfies readonly ToolContract[];
diff --git a/src/contracts/toolContracts.test.ts b/src/contracts/toolContracts.test.ts
index 44c1e9abd1..f0086cff4b 100644
--- a/src/contracts/toolContracts.test.ts
+++ b/src/contracts/toolContracts.test.ts
@@ -1,3 +1,4 @@
+import { EVM_TOOL_CONTRACTS } from "./evm/evmToolContracts.js";
import { BINARY_DIAGNOSTICS_TOOL_CONTRACTS } from "./native/binaryDiagnosticsToolContracts.js";
import { describe, expect, it } from "vitest";
@@ -26,6 +27,7 @@ const GROUPS = {
native: NATIVE_TOOL_CONTRACTS,
binary_diagnostics: BINARY_DIAGNOSTICS_TOOL_CONTRACTS,
artifact: ARTIFACT_TOOL_CONTRACTS,
+ evm: EVM_TOOL_CONTRACTS,
managed: MANAGED_TOOL_CONTRACTS,
android: ANDROID_TOOL_CONTRACTS,
firmware: FIRMWARE_TOOL_CONTRACTS,
diff --git a/src/contracts/toolContracts.ts b/src/contracts/toolContracts.ts
index 370c83efc3..304ef41d5f 100644
--- a/src/contracts/toolContracts.ts
+++ b/src/contracts/toolContracts.ts
@@ -1,3 +1,4 @@
+import { EVM_TOOL_CONTRACTS } from "./evm/evmToolContracts.js";
import { BINARY_DIAGNOSTICS_TOOL_CONTRACTS } from "./native/binaryDiagnosticsToolContracts.js";
import { NATIVE_TOOL_CONTRACTS } from "./native/nativeToolContracts.js";
import { ARTIFACT_TOOL_CONTRACTS } from "./artifactToolContracts.js";
@@ -26,6 +27,7 @@ export const TOOL_CONTRACTS = [
...NATIVE_TOOL_CONTRACTS,
...BINARY_DIAGNOSTICS_TOOL_CONTRACTS,
...ARTIFACT_TOOL_CONTRACTS,
+ ...EVM_TOOL_CONTRACTS,
...MANAGED_TOOL_CONTRACTS,
...ANDROID_TOOL_CONTRACTS,
...FIRMWARE_TOOL_CONTRACTS,
diff --git a/src/contracts/toolEffects.ts b/src/contracts/toolEffects.ts
index 5db06293ec..fd416c7c6c 100644
--- a/src/contracts/toolEffects.ts
+++ b/src/contracts/toolEffects.ts
@@ -41,6 +41,11 @@ const sessionEvidence = effects({ mutatesSession: true });
/** Explicit effect audit for every public tool. */
export const TOOL_EFFECTS: Readonly> = {
+ inspect_evm_interface: effects({
+ mutatesSession: true,
+ writesFilesystem: true,
+ launchesProcess: true,
+ }),
inspect_web_network_capture: effects({
mutatesSession: true,
writesFilesystem: true,
@@ -213,6 +218,7 @@ export const TOOL_EFFECTS: Readonly> = {
decode_interface_builder: evidence,
inspect_asset_catalog: nativeEvidence,
inspect_keyed_archive: evidence,
+ trace_dylib_resolution: evidence,
inspect_managed_artifact: evidence,
inspect_managed_members: evidence,
inspect_managed_native_boundaries: evidence,
diff --git a/src/contracts/toolOutputSchemaGroups.ts b/src/contracts/toolOutputSchemaGroups.ts
index 12815d3b09..3fd1575ed9 100644
--- a/src/contracts/toolOutputSchemaGroups.ts
+++ b/src/contracts/toolOutputSchemaGroups.ts
@@ -32,6 +32,7 @@ import {
import { artifactExtractionResultSchema } from "../domain/artifactGraph.js";
import { artifactInspectionResultSchema } from "../domain/artifactInspection.js";
import { interfaceBuilderAnalysisSchema } from "../domain/apple/interfaceBuilderGraph.js";
+import { dylibResolutionResultSchema } from "../domain/apple/dylibResolution.js";
import { keyedArchiveResultSchema } from "../domain/apple/keyedArchive.js";
import { appleAssetCatalogResultSchema } from "../domain/apple/appleAssetCatalog.js";
import {
@@ -338,6 +339,7 @@ export const artifactOutputSchemas: Readonly> = {
decode_interface_builder: resultOf(interfaceBuilderAnalysisSchema),
inspect_keyed_archive: resultOf(keyedArchiveResultSchema),
inspect_asset_catalog: resultOf(appleAssetCatalogResultSchema),
+ trace_dylib_resolution: resultOf(dylibResolutionResultSchema),
};
/** Exact Evidence schema for execution-free managed static analysis. */
diff --git a/src/domain/analysisErrorBase.ts b/src/domain/analysisErrorBase.ts
index 90843234b6..47fd251edf 100644
--- a/src/domain/analysisErrorBase.ts
+++ b/src/domain/analysisErrorBase.ts
@@ -6,6 +6,7 @@ const ANALYSIS_ERROR_TAGS = [
"AnalysisArtifactChangedError",
"AnalysisOutputError",
"AnalysisCapabilityUnavailableError",
+ "AnalysisUnsupportedTargetError",
"AnalysisCancelledError",
"AnalysisTimeoutError",
"AnalysisResourceConstraintError",
diff --git a/src/domain/analysisErrorCore.ts b/src/domain/analysisErrorCore.ts
index 691347facc..03835a2b36 100644
--- a/src/domain/analysisErrorCore.ts
+++ b/src/domain/analysisErrorCore.ts
@@ -4,6 +4,19 @@ import {
type AnalysisErrorOptions,
} from "./analysisErrorBase.js";
+/** A readable supplied artifact lies outside this operation's supported target formats. */
+export class AnalysisUnsupportedTargetError extends AnalysisError {
+ readonly _tag = "AnalysisUnsupportedTargetError";
+ constructor(
+ readonly operation: string,
+ readonly path: string,
+ readonly reason: string,
+ options?: AnalysisErrorOptions,
+ ) {
+ super(`Unsupported target for ${operation} at ${path}: ${reason}`, options);
+ }
+}
+
/** Provider reported a resource failure, distinct from malformed input or unsupported coverage. */
export class AnalysisResourceConstraintError extends AnalysisError {
readonly _tag = "AnalysisResourceConstraintError";
diff --git a/src/domain/analysisErrorPresentation.ts b/src/domain/analysisErrorPresentation.ts
index af486b87b5..48a1f0860a 100644
--- a/src/domain/analysisErrorPresentation.ts
+++ b/src/domain/analysisErrorPresentation.ts
@@ -2,6 +2,7 @@ import {
AnalysisAccessDeniedError,
AnalysisArtifactChangedError,
AnalysisInputError,
+ AnalysisUnsupportedTargetError,
AnalysisResourceConstraintError,
} from "./analysisErrorCore.js";
import { ArtifactOperationError } from "./artifactOperationError.js";
@@ -33,6 +34,8 @@ import { type AnalysisErrorProjection } from "./analysisErrorProjection.js";
export const analysisErrorRemediationAction = (
error: AnalysisError,
): string => {
+ if (error instanceof AnalysisUnsupportedTargetError)
+ return "Select a target supported by this operation or choose an operation supporting the reported target format.";
if (error instanceof AnalysisResourceConstraintError)
return error.resource === "cpu"
? "Review the reported worker CPU limits and observed signal. Retry with sufficient CPU time or a smaller artifact; REA retains tighter inherited limits."
@@ -125,6 +128,7 @@ const STATIC_ERROR_CATEGORIES: Readonly<
AnalysisAccessDeniedError: "unavailable",
AnalysisArtifactChangedError: "integrity_mismatch",
AnalysisCapabilityUnavailableError: "unsupported_provider",
+ AnalysisUnsupportedTargetError: "unsupported_target",
ProviderSelectionError: "unsupported_provider",
EvidenceIntegrityError: "integrity_mismatch",
AnalysisCancelledError: "cancelled",
@@ -137,6 +141,7 @@ const STATIC_ERROR_CATEGORIES: Readonly<
};
export const analysisErrorUserMessage = (error: AnalysisError): string => {
+ if (error instanceof AnalysisUnsupportedTargetError) return error.message;
if (error instanceof AnalysisResourceConstraintError) return error.reason;
if (error instanceof AnalysisAccessDeniedError)
return "Host filesystem permissions denied read access to the selected path.";
@@ -270,6 +275,7 @@ const KNOWN_ERROR_TAGS = {
AnalysisArtifactChangedError: true,
AnalysisOutputError: true,
AnalysisCapabilityUnavailableError: true,
+ AnalysisUnsupportedTargetError: true,
AnalysisCancelledError: true,
AnalysisTimeoutError: true,
AnalysisResourceConstraintError: true,
diff --git a/src/domain/analysisErrorProjection.test.ts b/src/domain/analysisErrorProjection.test.ts
index e0952a7f33..392b97966c 100644
--- a/src/domain/analysisErrorProjection.test.ts
+++ b/src/domain/analysisErrorProjection.test.ts
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import {
AnalysisCapabilityUnavailableError,
+ AnalysisUnsupportedTargetError,
AnalysisInputError,
AnalysisOutputError,
AnalysisCancelledError,
@@ -28,6 +29,32 @@ const retainedOutput = {
truncated: true,
};
+it("distinguishes unsupported target formats from unavailable provider capabilities", () => {
+ const projected = projectAnalysisError(
+ new AnalysisUnsupportedTargetError(
+ "inspect_evm_interface",
+ "/selected/runtime.hex",
+ "EOF-style EF00 container is unsupported.",
+ { capturedOutput: retainedOutput },
+ ),
+ );
+ expect(projected).toMatchObject({
+ code: "unsupported_target",
+ category: "unsupported_target",
+ retryable: false,
+ details: {
+ operation: "inspect_evm_interface",
+ path: "/selected/runtime.hex",
+ reason: "EOF-style EF00 container is unsupported.",
+ captured_output: retainedOutput,
+ },
+ });
+ expect(projected.message).toContain("EF00");
+ expect(projected.remediation.action).toContain("target format");
+ expect(projected.remediation.action).not.toContain("doctor");
+ expect(analysisErrorProjectionSchema.safeParse(projected).success).toBe(true);
+});
+
it.each([
["memory", null],
["memory", { address_space_bytes: 67108864 }],
diff --git a/src/domain/analysisErrorProjection.ts b/src/domain/analysisErrorProjection.ts
index 848b102755..6ba8cfac37 100644
--- a/src/domain/analysisErrorProjection.ts
+++ b/src/domain/analysisErrorProjection.ts
@@ -11,6 +11,7 @@ import {
AnalysisArtifactChangedError,
AnalysisCancelledError,
AnalysisCapabilityUnavailableError,
+ AnalysisUnsupportedTargetError,
AnalysisInputError,
AnalysisOutputError,
AnalysisTimeoutError,
@@ -140,6 +141,7 @@ const STATIC_ERROR_CODES = {
AnalysisAccessDeniedError: "access_denied",
AnalysisArtifactChangedError: "artifact_changed",
AnalysisCapabilityUnavailableError: "capability_unavailable",
+ AnalysisUnsupportedTargetError: "unsupported_target",
AnalysisCancelledError: "cancelled",
HopperCancelledError: "cancelled",
AnalysisTimeoutError: "provider_timeout",
@@ -187,6 +189,12 @@ const errorDetails = (
const requestErrorDetails = (
error: AnalysisError,
): Readonly> | undefined => {
+ if (error instanceof AnalysisUnsupportedTargetError)
+ return {
+ operation: error.operation,
+ path: error.path,
+ reason: error.reason,
+ };
if (error instanceof AnalysisResourceConstraintError)
return {
operation: error.operation,
@@ -420,6 +428,7 @@ export interface AnalysisErrorProjection extends Readonly<
| "artifact_changed"
| "unreadable_output"
| "capability_unavailable"
+ | "unsupported_target"
| "provider_unavailable"
| "provider_timeout"
| "resource_constraint"
@@ -437,6 +446,7 @@ export interface AnalysisErrorProjection extends Readonly<
readonly category:
| "invalid_input"
| "unsupported_provider"
+ | "unsupported_target"
| "integrity_mismatch"
| "truncated"
| "cancelled"
diff --git a/src/domain/apple/dyldPaths.ts b/src/domain/apple/dyldPaths.ts
new file mode 100644
index 0000000000..765bb15d14
--- /dev/null
+++ b/src/domain/apple/dyldPaths.ts
@@ -0,0 +1,110 @@
+import type {
+ DylibTreeEntry,
+ DylibTreeView,
+ MachoSlice,
+} from "./dylibResolution.js";
+
+const MAX_SYMLINK_HOPS = 32;
+
+export type PathLookup =
+ | { readonly kind: "file"; readonly path: string }
+ | { readonly kind: "absent" | "escapes" | "not-file" };
+
+/**
+ * Resolve a root-relative path one segment at a time, as `open` would:
+ * symlinks are followed inside the root, `..` applies to the real parent,
+ * and any escape above the root or through an absolute link stops.
+ */
+export const resolveTreePath = async (
+ view: DylibTreeView,
+ input: string,
+): Promise => {
+ const pending = segmentsOf(input);
+ const resolved: string[] = [];
+ let last: DylibTreeEntry | undefined;
+ let hops = 0;
+ while (pending.length > 0) {
+ const segment = pending.shift() ?? "";
+ if (segment === "..") {
+ if (resolved.length === 0) return { kind: "escapes" };
+ resolved.pop();
+ last = { kind: "directory" };
+ continue;
+ }
+ const path = [...resolved, segment].join("/");
+ const entry = await view.entry(path);
+ if (entry === undefined) return { kind: "absent" };
+ if (entry.kind === "symlink") {
+ hops += 1;
+ if (hops > MAX_SYMLINK_HOPS) return { kind: "absent" };
+ if (entry.target.startsWith("/")) return { kind: "escapes" };
+ pending.unshift(...segmentsOf(entry.target));
+ continue;
+ }
+ if (entry.kind === "file" && pending.length > 0) return { kind: "absent" };
+ resolved.push(segment);
+ last = entry;
+ }
+ return last?.kind === "file"
+ ? { kind: "file", path: resolved.join("/") }
+ : { kind: "not-file" };
+};
+
+const segmentsOf = (path: string): string[] =>
+ path.split("/").filter((segment) => segment !== "" && segment !== ".");
+
+export const directoryOf = (path: string): string => {
+ const index = path.lastIndexOf("/");
+ return index < 0 ? "" : path.slice(0, index);
+};
+
+export const joinPath = (directory: string, rest: string): string =>
+ directory === "" ? rest : rest === "" ? directory : `${directory}/${rest}`;
+
+export type Expansion =
+ | { readonly scope: "target"; readonly path: string }
+ | { readonly scope: "outside"; readonly path: string }
+ | { readonly scope: "undetermined"; readonly path: string };
+
+/** Expand one dyld path prefix relative to its owning image. */
+export const expandPrefix = (
+ raw: string,
+ context: {
+ readonly ownerDirectory: string;
+ readonly executable: string | null;
+ },
+): Expansion => {
+ const variable = /^@(executable_path|loader_path)(?:\/(.*))?$/u.exec(raw);
+ if (variable !== null) {
+ const rest = variable[2] ?? "";
+ if (variable[1] === "loader_path")
+ return { scope: "target", path: joinPath(context.ownerDirectory, rest) };
+ return context.executable === null
+ ? { scope: "undetermined", path: raw }
+ : {
+ scope: "target",
+ path: joinPath(directoryOf(context.executable), rest),
+ };
+ }
+ if (raw.startsWith("/")) return { scope: "outside", path: raw };
+ return { scope: "undetermined", path: raw };
+};
+
+/**
+ * Slices a process may load, best first, following dyld's graded
+ * architectures: an x86_64h process also accepts generic x86_64.
+ */
+const COMPATIBLE_ARCHITECTURES: Readonly> = {
+ x86_64h: ["x86_64h", "x86_64"],
+};
+
+export const compatibleSlice = (
+ slices: readonly MachoSlice[],
+ process: string,
+): MachoSlice | undefined => {
+ for (const architecture of COMPATIBLE_ARCHITECTURES[process] ?? [process]) {
+ const slice = slices.find((item) => item.architecture === architecture);
+ if (slice !== undefined) return slice;
+ }
+ return undefined;
+};
diff --git a/src/domain/apple/dylibResolution.test.ts b/src/domain/apple/dylibResolution.test.ts
new file mode 100644
index 0000000000..bfd0a25b58
--- /dev/null
+++ b/src/domain/apple/dylibResolution.test.ts
@@ -0,0 +1,605 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ traceDylibLoading,
+ type DylibTreeEntry,
+ type DylibTreeView,
+ type MachoDependency,
+ type MachoImageFacts,
+ type MachoSlice,
+} from "./dylibResolution.js";
+
+const slice = (overrides: Partial = {}): MachoSlice => ({
+ architecture: "arm64",
+ file_type: "dylib",
+ install_name: null,
+ dependencies: [],
+ rpaths: [],
+ dyld_environment: [],
+ code_signature_present: true,
+ ...overrides,
+});
+
+const dependency = (
+ installName: string,
+ overrides: Partial = {},
+): MachoDependency => ({
+ command: "LC_LOAD_DYLIB",
+ encoding: "dylib_command",
+ install_name: installName,
+ weak: false,
+ upward: false,
+ reexport: false,
+ delayed_init: false,
+ current_version: "1.0.0",
+ compatibility_version: "1.0.0",
+ ...overrides,
+});
+
+const parsed = (...slices: MachoSlice[]): MachoImageFacts => ({
+ status: "parsed",
+ slices,
+});
+
+const executable = (overrides: Partial = {}): MachoImageFacts =>
+ parsed(slice({ file_type: "execute", ...overrides }));
+
+/** In-memory tree: files are images (or "data"), symlinks map to targets. */
+const memoryView = (
+ files: Readonly>,
+ symlinks: Readonly> = {},
+): DylibTreeView => {
+ const entries = new Map();
+ const addParents = (path: string): void => {
+ const segments = path.split("/");
+ for (let length = 1; length < segments.length; length++)
+ entries.set(segments.slice(0, length).join("/"), { kind: "directory" });
+ };
+ for (const path of Object.keys(files)) {
+ addParents(path);
+ entries.set(path, { kind: "file" });
+ }
+ for (const [path, target] of Object.entries(symlinks)) {
+ addParents(path);
+ entries.set(path, { kind: "symlink", target });
+ }
+ return {
+ entry: (path) => Promise.resolve(entries.get(path)),
+ image: (path) => {
+ const facts = files[path];
+ return Promise.resolve(
+ facts === undefined || facts === "data"
+ ? { status: "not-mach-o" as const }
+ : facts,
+ );
+ },
+ };
+};
+
+const MAIN = "Contents/MacOS/App";
+
+const edgeFor = (
+ trace: Awaited>,
+ loader: string,
+ installName: string,
+) => {
+ const edge = trace.edges.find(
+ (candidate) =>
+ candidate.loader === loader && candidate.install_name === installName,
+ );
+ if (edge === undefined)
+ throw new Error(`missing ${loader} -> ${installName}`);
+ return edge;
+};
+
+describe("dyld path expansion", () => {
+ it("searches the loading image's rpaths before its loaders'", async () => {
+ const core = "Contents/Frameworks/Core.framework/Versions/A/Core";
+ const trace = await traceDylibLoading(
+ memoryView({
+ [MAIN]: executable({
+ rpaths: ["@executable_path/../Frameworks"],
+ dependencies: [dependency("@rpath/Core.framework/Versions/A/Core")],
+ }),
+ [core]: parsed(
+ slice({
+ rpaths: ["@loader_path/Libraries"],
+ dependencies: [dependency("@rpath/libchain.dylib")],
+ }),
+ ),
+ "Contents/Frameworks/libchain.dylib": parsed(slice()),
+ "Contents/Frameworks/Core.framework/Versions/A/Libraries/libchain.dylib":
+ parsed(slice()),
+ }),
+ { roots: [MAIN] },
+ );
+ const chain = edgeFor(trace, core, "@rpath/libchain.dylib");
+ expect(chain.via).toEqual([MAIN, core]);
+ expect(chain.candidates).toEqual([
+ expect.objectContaining({
+ source: "rpath",
+ rpath: "@loader_path/Libraries",
+ rpath_owner: core,
+ outcome: "resolved",
+ }),
+ ]);
+ expect(chain.resolution).toEqual({
+ status: "resolved",
+ image:
+ "Contents/Frameworks/Core.framework/Versions/A/Libraries/libchain.dylib",
+ });
+ });
+
+ it("expands @loader_path in an rpath relative to the image that owns it", async () => {
+ const plugin = "Contents/PlugIns/Tool.bundle/Contents/MacOS/Tool";
+ const trace = await traceDylibLoading(
+ memoryView({
+ [MAIN]: executable({
+ rpaths: ["@loader_path/../Shared"],
+ dependencies: [
+ dependency(
+ `@executable_path/../PlugIns/Tool.bundle/Contents/MacOS/Tool`,
+ ),
+ ],
+ }),
+ [plugin]: parsed(
+ slice({
+ file_type: "bundle",
+ dependencies: [dependency("@rpath/libshared.dylib")],
+ }),
+ ),
+ "Contents/Shared/libshared.dylib": parsed(slice()),
+ }),
+ { roots: [MAIN] },
+ );
+ expect(edgeFor(trace, plugin, "@rpath/libshared.dylib").candidates).toEqual(
+ [
+ expect.objectContaining({
+ path: "Contents/MacOS/../Shared/libshared.dylib",
+ rpath_owner: MAIN,
+ outcome: "resolved",
+ resolved_path: "Contents/Shared/libshared.dylib",
+ }),
+ ],
+ );
+ });
+
+ it("gives each executable root its own @executable_path", async () => {
+ const service = "Contents/XPCServices/Fetch.xpc/Contents/MacOS/Fetch";
+ const trace = await traceDylibLoading(
+ memoryView({
+ [MAIN]: executable({
+ dependencies: [dependency("@executable_path/libhere.dylib")],
+ }),
+ [service]: executable({
+ dependencies: [dependency("@executable_path/libhere.dylib")],
+ }),
+ "Contents/MacOS/libhere.dylib": parsed(slice()),
+ }),
+ { roots: [MAIN, service] },
+ );
+ expect(
+ edgeFor(trace, MAIN, "@executable_path/libhere.dylib").resolution.status,
+ ).toBe("resolved");
+ expect(
+ edgeFor(trace, service, "@executable_path/libhere.dylib"),
+ ).toMatchObject({
+ candidates: [
+ {
+ path: "Contents/XPCServices/Fetch.xpc/Contents/MacOS/libhere.dylib",
+ outcome: "absent",
+ },
+ ],
+ resolution: { status: "unresolved", image: null },
+ });
+ });
+
+ it("follows Versions/Current inside the root and stops at escaping links", async () => {
+ const trace = await traceDylibLoading(
+ memoryView(
+ {
+ [MAIN]: executable({
+ rpaths: ["@executable_path/../Frameworks"],
+ dependencies: [
+ dependency("@rpath/Core.framework/Core"),
+ dependency("@rpath/Escape.framework/Escape"),
+ dependency("@executable_path/../../../outside.dylib"),
+ ],
+ }),
+ "Contents/Frameworks/Core.framework/Versions/A/Core": parsed(slice()),
+ },
+ {
+ "Contents/Frameworks/Core.framework/Versions/Current": "A",
+ "Contents/Frameworks/Core.framework/Core": "Versions/Current/Core",
+ "Contents/Frameworks/Escape.framework":
+ "/Library/Frameworks/Escape.framework",
+ },
+ ),
+ { roots: [MAIN] },
+ );
+ expect(
+ edgeFor(trace, MAIN, "@rpath/Core.framework/Core").resolution,
+ ).toEqual({
+ status: "resolved",
+ image: "Contents/Frameworks/Core.framework/Versions/A/Core",
+ });
+ for (const name of [
+ "@rpath/Escape.framework/Escape",
+ "@executable_path/../../../outside.dylib",
+ ])
+ expect(edgeFor(trace, MAIN, name)).toMatchObject({
+ candidates: [{ outcome: "escapes-target" }],
+ resolution: { status: "undetermined", image: null },
+ });
+ });
+});
+
+describe("dyld resolution outcomes", () => {
+ it("keeps paths outside the root undetermined and in-root fallbacks conditional", async () => {
+ const trace = await traceDylibLoading(
+ memoryView({
+ [MAIN]: executable({
+ rpaths: ["/usr/lib/swift", "@executable_path/../Frameworks"],
+ dependencies: [
+ dependency("/usr/lib/libSystem.B.dylib"),
+ dependency("@rpath/libswiftCore.dylib"),
+ dependency("libleaf.dylib"),
+ ],
+ }),
+ "Contents/Frameworks/libswiftCore.dylib": parsed(slice()),
+ }),
+ { roots: [MAIN] },
+ );
+ expect(edgeFor(trace, MAIN, "/usr/lib/libSystem.B.dylib")).toMatchObject({
+ candidates: [
+ { outcome: "outside-target", path: "/usr/lib/libSystem.B.dylib" },
+ ],
+ resolution: { status: "undetermined", image: null },
+ });
+ expect(edgeFor(trace, MAIN, "@rpath/libswiftCore.dylib")).toMatchObject({
+ candidates: [
+ {
+ outcome: "outside-target",
+ path: "/usr/lib/swift/libswiftCore.dylib",
+ },
+ { outcome: "resolved" },
+ ],
+ resolution: {
+ status: "conditional",
+ image: "Contents/Frameworks/libswiftCore.dylib",
+ },
+ });
+ expect(edgeFor(trace, MAIN, "libleaf.dylib").resolution.status).toBe(
+ "undetermined",
+ );
+ });
+
+ it("derives unresolved and earlier-candidate findings separately for weak loads", async () => {
+ const trace = await traceDylibLoading(
+ memoryView({
+ [MAIN]: executable({
+ rpaths: [
+ "@executable_path/../Overrides",
+ "@executable_path/../Frameworks",
+ ],
+ dependencies: [
+ dependency("@rpath/libfound.dylib"),
+ dependency("@rpath/libgone.dylib", {
+ command: "LC_LOAD_WEAK_DYLIB",
+ weak: true,
+ }),
+ dependency("@rpath/libmissing.dylib"),
+ ],
+ dyld_environment: ["DYLD_LIBRARY_PATH=/tmp"],
+ }),
+ "Contents/Frameworks/libfound.dylib": parsed(slice()),
+ }),
+ { roots: [MAIN] },
+ );
+ expect(
+ trace.findings.map(({ kind, edge_index: index }) => [kind, index]),
+ ).toEqual([
+ ["earlier-rpath-candidate-absent", 0],
+ ["weak-load-unresolved", 1],
+ ["required-load-unresolved", 2],
+ ["dyld-environment-present", null],
+ ]);
+ expect(trace.findings[0]?.explanation).toContain(
+ "Contents/MacOS/../Overrides/libfound.dylib",
+ );
+ });
+
+ it("reports non-Mach-O, malformed and wrong-architecture candidates", async () => {
+ const trace = await traceDylibLoading(
+ memoryView({
+ [MAIN]: executable({
+ rpaths: [
+ "@executable_path/a",
+ "@executable_path/b",
+ "@executable_path/c",
+ ],
+ dependencies: [dependency("@rpath/lib.dylib")],
+ }),
+ "Contents/MacOS/a/lib.dylib": "data",
+ "Contents/MacOS/b/lib.dylib": {
+ status: "malformed",
+ reason: "load command 0 has invalid cmdsize 0",
+ },
+ "Contents/MacOS/c/lib.dylib": parsed(slice({ architecture: "x86_64" })),
+ }),
+ { roots: [MAIN] },
+ );
+ expect(
+ edgeFor(trace, MAIN, "@rpath/lib.dylib").candidates.map(
+ ({ outcome }) => outcome,
+ ),
+ ).toEqual(["not-mach-o", "malformed", "architecture-missing"]);
+ expect(trace.coverage).toEqual({
+ status: "partial",
+ unparsed_images: ["Contents/MacOS/b/lib.dylib"],
+ roots_without_architecture: [],
+ });
+ expect(trace.images.map(({ path }) => path)).toEqual([
+ MAIN,
+ "Contents/MacOS/b/lib.dylib",
+ "Contents/MacOS/c/lib.dylib",
+ ]);
+ });
+});
+
+describe("dyld load order", () => {
+ it("loads each image once per process and reuses matching install names", async () => {
+ const a = "Contents/Frameworks/libA.dylib";
+ const b = "Contents/Frameworks/libB.dylib";
+ const trace = await traceDylibLoading(
+ memoryView({
+ [MAIN]: executable({
+ rpaths: ["@executable_path/../Frameworks"],
+ dependencies: [
+ dependency("@rpath/libA.dylib"),
+ dependency("@rpath/libB.dylib"),
+ ],
+ }),
+ [a]: parsed(
+ slice({
+ install_name: "@rpath/libA.dylib",
+ dependencies: [
+ dependency("@rpath/libB.dylib"),
+ dependency("@rpath/libA.dylib"),
+ ],
+ }),
+ ),
+ [b]: parsed(
+ slice({
+ install_name: "@rpath/libB.dylib",
+ dependencies: [dependency("@rpath/libA.dylib")],
+ }),
+ ),
+ }),
+ { roots: [MAIN] },
+ );
+ expect(
+ trace.edges.map(
+ ({ loader, install_name: name }) =>
+ `${loader.split("/").at(-1)}>${name}`,
+ ),
+ ).toEqual([
+ "App>@rpath/libA.dylib",
+ "App>@rpath/libB.dylib",
+ "libA.dylib>@rpath/libB.dylib",
+ "libA.dylib>@rpath/libA.dylib",
+ "libB.dylib>@rpath/libA.dylib",
+ ]);
+ expect(edgeFor(trace, b, "@rpath/libA.dylib").candidates).toEqual([
+ expect.objectContaining({ source: "already-loaded", resolved_path: a }),
+ ]);
+ expect(edgeFor(trace, a, "@rpath/libA.dylib").install_name_matches).toBe(
+ true,
+ );
+ });
+
+ it("leaves @executable_path undetermined for a library root and filters architectures", async () => {
+ const library = "Contents/Frameworks/libroot.dylib";
+ const trace = await traceDylibLoading(
+ memoryView({
+ [library]: parsed(
+ slice({ dependencies: [dependency("@executable_path/x.dylib")] }),
+ ),
+ [MAIN]: executable(),
+ }),
+ { roots: [library, MAIN], architecture: "x86_64" },
+ );
+ expect(trace.roots).toEqual([]);
+ expect(trace.coverage.roots_without_architecture).toEqual([library, MAIN]);
+ const arm = await traceDylibLoading(
+ memoryView({
+ [library]: parsed(
+ slice({ dependencies: [dependency("@executable_path/x.dylib")] }),
+ ),
+ }),
+ { roots: [library] },
+ );
+ expect(arm.edges[0]?.candidates).toEqual([
+ expect.objectContaining({
+ source: "executable_path",
+ outcome: "undetermined",
+ }),
+ ]);
+ });
+
+ it("stops when cancelled", async () => {
+ const controller = new AbortController();
+ controller.abort();
+ await expect(
+ traceDylibLoading(memoryView({ [MAIN]: executable() }), {
+ roots: [MAIN],
+ signal: controller.signal,
+ }),
+ ).rejects.toThrow();
+ });
+});
+
+describe("dyld slice compatibility and coverage", () => {
+ it("lets an x86_64h process load generic x86_64 but not arm64e load arm64", async () => {
+ const files = {
+ [MAIN]: parsed(
+ slice({
+ architecture: "x86_64h",
+ file_type: "execute",
+ dependencies: [dependency("@executable_path/libgeneric.dylib")],
+ }),
+ slice({
+ architecture: "arm64e",
+ file_type: "execute",
+ dependencies: [dependency("@executable_path/libgeneric.dylib")],
+ }),
+ ),
+ "Contents/MacOS/libgeneric.dylib": parsed(
+ slice({ architecture: "x86_64" }),
+ slice({ architecture: "arm64" }),
+ ),
+ };
+ const trace = await traceDylibLoading(memoryView(files), { roots: [MAIN] });
+ expect(
+ trace.edges.map(({ architecture, candidates, resolution }) => [
+ architecture,
+ candidates[0]?.outcome,
+ resolution.status,
+ ]),
+ ).toEqual([
+ ["x86_64h", "resolved", "resolved"],
+ ["arm64e", "architecture-missing", "unresolved"],
+ ]);
+ });
+
+ it("keeps unsupported candidates undeterminable rather than malformed", async () => {
+ const trace = await traceDylibLoading(
+ memoryView({
+ [MAIN]: executable({
+ dependencies: [dependency("@executable_path/libbig.dylib")],
+ }),
+ "Contents/MacOS/libbig.dylib": {
+ status: "unsupported",
+ reason: "big-endian Mach-O images are not supported",
+ },
+ }),
+ { roots: [MAIN] },
+ );
+ expect(trace.edges[0]).toMatchObject({
+ candidates: [{ outcome: "unsupported" }],
+ resolution: { status: "undetermined", image: null },
+ });
+ expect(
+ trace.images.find(({ path }) => path.endsWith("libbig.dylib")),
+ ).toMatchObject({
+ parse_status: "unsupported",
+ });
+ });
+
+ it("reports unclassified Mach-O files as partial coverage", async () => {
+ const broken = "Contents/Helpers/broken";
+ const trace = await traceDylibLoading(
+ memoryView({
+ [MAIN]: executable(),
+ [broken]: {
+ status: "malformed",
+ reason: "load command 0 has invalid cmdsize 0",
+ },
+ }),
+ { roots: [MAIN], unclassified: [broken] },
+ );
+ expect(trace.coverage).toEqual({
+ status: "partial",
+ unparsed_images: [broken],
+ roots_without_architecture: [],
+ });
+ expect(trace.images.find(({ path }) => path === broken)?.reason).toBe(
+ "load command 0 has invalid cmdsize 0",
+ );
+ });
+});
+
+describe("lazily loaded dependencies", () => {
+ it("resolves lazy loads without traversing them or reporting launch failure", async () => {
+ const trace = await traceDylibLoading(
+ memoryView({
+ [MAIN]: executable({
+ rpaths: ["@executable_path/../Frameworks"],
+ dependencies: [
+ dependency("@rpath/liblazy.dylib", {
+ command: "LC_LAZY_LOAD_DYLIB",
+ }),
+ dependency("@rpath/libmissing.dylib", {
+ command: "LC_LAZY_LOAD_DYLIB",
+ }),
+ ],
+ }),
+ "Contents/Frameworks/liblazy.dylib": parsed(
+ slice({ dependencies: [dependency("@rpath/libdeep.dylib")] }),
+ ),
+ }),
+ { roots: [MAIN] },
+ );
+ expect(trace.edges.map(({ loader }) => loader)).toEqual([MAIN, MAIN]);
+ expect(trace.edges[0]?.resolution.status).toBe("resolved");
+ expect(trace.findings.map(({ kind }) => kind)).toEqual([
+ "lazy-load-unresolved",
+ ]);
+ });
+});
+
+describe("conditional loads", () => {
+ it("carries a conditional fallback's uncertainty to its dependents and reuses", async () => {
+ const VENDOR = "Contents/Frameworks/libvendor.dylib";
+ const OTHER = "Contents/Frameworks/libother.dylib";
+ const trace = await traceDylibLoading(
+ memoryView({
+ [MAIN]: executable({
+ rpaths: ["/opt/vendor/lib", "@executable_path/../Frameworks"],
+ dependencies: [
+ dependency("@rpath/libvendor.dylib"),
+ dependency("@executable_path/../Frameworks/libother.dylib"),
+ ],
+ }),
+ [VENDOR]: parsed(
+ slice({
+ install_name: "@rpath/libvendor.dylib",
+ dependencies: [
+ dependency("@loader_path/libgone.dylib"),
+ dependency("@loader_path/libweak.dylib", { weak: true }),
+ dependency("@loader_path/liblazy.dylib", {
+ command: "LC_LAZY_LOAD_DYLIB",
+ }),
+ ],
+ }),
+ ),
+ [OTHER]: parsed(
+ slice({ dependencies: [dependency("@rpath/libvendor.dylib")] }),
+ ),
+ }),
+ { roots: [MAIN] },
+ );
+ expect(edgeFor(trace, MAIN, "@rpath/libvendor.dylib")).toMatchObject({
+ resolution: { status: "conditional", image: VENDOR },
+ loader_conditional: false,
+ });
+ expect(edgeFor(trace, VENDOR, "@loader_path/libgone.dylib")).toMatchObject({
+ resolution: { status: "unresolved" },
+ loader_conditional: true,
+ });
+ expect(edgeFor(trace, OTHER, "@rpath/libvendor.dylib")).toMatchObject({
+ candidates: [{ source: "already-loaded" }],
+ resolution: { status: "conditional", image: VENDOR },
+ loader_conditional: false,
+ });
+ // Required, weak and lazy findings below the fallback are all qualified.
+ for (const kind of [
+ "required-load-unresolved",
+ "weak-load-unresolved",
+ "lazy-load-unresolved",
+ ])
+ expect(
+ trace.findings.find((finding) => finding.kind === kind)?.explanation,
+ ).toContain(`${VENDOR} loads only conditionally; if it loads,`);
+ });
+});
diff --git a/src/domain/apple/dylibResolution.ts b/src/domain/apple/dylibResolution.ts
new file mode 100644
index 0000000000..241a50be84
--- /dev/null
+++ b/src/domain/apple/dylibResolution.ts
@@ -0,0 +1,514 @@
+import { z } from "zod";
+
+import {
+ compatibleSlice,
+ directoryOf,
+ expandPrefix,
+ joinPath,
+ resolveTreePath,
+ type Expansion,
+} from "./dyldPaths.js";
+import { digestSchema } from "../digests.js";
+import {
+ DYLIB_RESOLUTION_LIMITATIONS,
+ deriveFindings,
+} from "./dylibResolutionFindings.js";
+
+/** Normalized path below the analyzed root: no `.`/`..` or empty segments. */
+const ROOT_RELATIVE_PATH =
+ /^(?!\/)(?!(?:.*\/)?\.{1,2}(?:\/|$))(?!.*\/\/)(?!.*\/$)[^\\\0]+$/u;
+const rootRelativePathSchema = z.string().min(1).regex(ROOT_RELATIVE_PATH);
+
+/** Caller selection of process roots and one CPU slice. */
+export const dylibResolutionInputSchema = z.strictObject({
+ roots: z.array(rootRelativePathSchema).min(1).optional(),
+ architecture: z.enum(["arm64", "arm64e", "x86_64"]).optional(),
+});
+
+const dependencyCommandSchema = z.enum([
+ "LC_LOAD_DYLIB",
+ "LC_LOAD_WEAK_DYLIB",
+ "LC_REEXPORT_DYLIB",
+ "LC_LAZY_LOAD_DYLIB",
+ "LC_LOAD_UPWARD_DYLIB",
+]);
+
+const dependencySchema = z.strictObject({
+ command: dependencyCommandSchema,
+ encoding: z.enum(["dylib_command", "dylib_use_command"]),
+ install_name: z.string(),
+ weak: z.boolean(),
+ upward: z.boolean(),
+ reexport: z.boolean(),
+ delayed_init: z.boolean(),
+ current_version: z.string(),
+ compatibility_version: z.string(),
+});
+
+/** Observed dylib-loading facts of one Mach-O slice. */
+export const machoSliceSchema = z.strictObject({
+ architecture: z.string().min(1),
+ file_type: z.enum(["execute", "dylib", "bundle", "other"]),
+ install_name: z.string().nullable(),
+ dependencies: z.array(dependencySchema),
+ rpaths: z.array(z.string()),
+ dyld_environment: z.array(z.string()),
+ code_signature_present: z.boolean(),
+});
+
+export type MachoDependency = z.infer;
+export type MachoSlice = z.infer;
+
+/** Header parse of one file; malformed and unsupported images keep their reason. */
+export type MachoImageFacts =
+ | { readonly status: "parsed"; readonly slices: readonly MachoSlice[] }
+ | {
+ readonly status: "malformed" | "unsupported";
+ readonly reason: string;
+ }
+ | { readonly status: "not-mach-o" };
+
+/** One observed path below the analyzed root. Symlink targets are not followed by the view. */
+export type DylibTreeEntry =
+ | { readonly kind: "file" | "directory" }
+ | { readonly kind: "symlink"; readonly target: string };
+
+/** Read-only, lazily probed view of the analyzed root. Paths are root-relative. */
+export interface DylibTreeView {
+ entry(path: string): Promise;
+ image(path: string): Promise;
+}
+
+const candidateSchema = z.strictObject({
+ path: z.string(),
+ source: z.enum([
+ "literal",
+ "executable_path",
+ "loader_path",
+ "rpath",
+ "already-loaded",
+ ]),
+ rpath: z.string().nullable(),
+ rpath_owner: z.string().nullable(),
+ outcome: z.enum([
+ "resolved",
+ "absent",
+ "not-mach-o",
+ "malformed",
+ "unsupported",
+ "architecture-missing",
+ "outside-target",
+ "escapes-target",
+ "undetermined",
+ ]),
+ resolved_path: z.string().nullable(),
+});
+
+const edgeSchema = z.strictObject({
+ root: z.string(),
+ architecture: z.string(),
+ loader: z.string(),
+ via: z.array(z.string()),
+ ...dependencySchema.shape,
+ candidates: z.array(candidateSchema),
+ resolution: z.strictObject({
+ status: z.enum(["resolved", "conditional", "unresolved", "undetermined"]),
+ image: z.string().nullable(),
+ }),
+ install_name_matches: z.boolean().nullable(),
+ /**
+ * The loader was reached through a conditional edge, so it might not load;
+ * this edge describes what happens only if it does.
+ */
+ loader_conditional: z.boolean(),
+});
+
+const findingSchema = z.strictObject({
+ kind: z.enum([
+ "required-load-unresolved",
+ "weak-load-unresolved",
+ "lazy-load-unresolved",
+ "earlier-rpath-candidate-absent",
+ "dyld-environment-present",
+ ]),
+ edge_index: z.number().int().nonnegative().nullable(),
+ image: z.string().nullable(),
+ basis: z.literal("derived"),
+ explanation: z.string().min(1),
+});
+
+const imageSchema = z.strictObject({
+ path: z.string(),
+ sha256: digestSchema,
+ parse_status: z.enum(["parsed", "malformed", "unsupported"]),
+ reason: z.string().nullable(),
+ slices: z.array(machoSliceSchema.omit({ dependencies: true })),
+});
+
+/** Static dyld load-path resolution for every selected process root. */
+export const dylibResolutionResultSchema = z.strictObject({
+ root_path: z.string(),
+ target_sha256: digestSchema,
+ roots: z.array(
+ z.strictObject({ image: z.string(), architecture: z.string() }),
+ ),
+ images: z.array(imageSchema),
+ edges: z.array(edgeSchema),
+ findings: z.array(findingSchema),
+ coverage: z.strictObject({
+ status: z.enum(["complete", "partial"]),
+ unparsed_images: z.array(z.string()),
+ roots_without_architecture: z.array(z.string()),
+ }),
+ limitations: z.array(z.string().min(1)),
+});
+
+export type DylibResolutionResult = z.infer;
+type Candidate = z.infer;
+export type DylibEdge = z.infer;
+export type DylibFinding = z.infer;
+type Edge = DylibEdge;
+
+/** Result before the adapter adds file digests and the analyzed root. */
+export type DylibTrace = Omit<
+ DylibResolutionResult,
+ "root_path" | "target_sha256" | "images"
+> & {
+ readonly images: readonly (Omit<
+ DylibResolutionResult["images"][number],
+ "sha256"
+ > & { readonly sha256?: never })[];
+};
+
+interface LoadedImage {
+ readonly slice: MachoSlice;
+ /** Images from the process root to this image; the rpath stack, outermost first. */
+ readonly chain: readonly string[];
+ /** Some edge on the chain was conditional: an unknown earlier candidate may load instead. */
+ readonly conditional: boolean;
+}
+
+interface ProcessContext {
+ readonly view: DylibTreeView;
+ readonly root: string;
+ readonly architecture: string;
+ readonly executable: string | null;
+ readonly loaded: Map;
+ readonly byInstallName: Map;
+ readonly images: Map;
+}
+
+interface CandidateTemplate {
+ readonly expansion: Expansion;
+ readonly source: Candidate["source"];
+ readonly rpath: string | null;
+ readonly rpathOwner: string | null;
+}
+
+const candidateTemplates = (
+ dependency: MachoDependency,
+ loader: string,
+ context: ProcessContext,
+): CandidateTemplate[] => {
+ const name = dependency.install_name;
+ if (!name.startsWith("@rpath/")) {
+ const source = name.startsWith("@executable_path")
+ ? "executable_path"
+ : name.startsWith("@loader_path")
+ ? "loader_path"
+ : "literal";
+ return [
+ {
+ expansion: expandPrefix(name, {
+ ownerDirectory: directoryOf(loader),
+ executable: context.executable,
+ }),
+ source,
+ rpath: null,
+ rpathOwner: null,
+ },
+ ];
+ }
+ const rest = name.slice("@rpath/".length);
+ const owners = [...(context.loaded.get(loader)?.chain ?? [loader])].reverse();
+ return owners.flatMap((owner) =>
+ (context.loaded.get(owner)?.slice.rpaths ?? []).map((rpath) => {
+ const base = expandPrefix(rpath, {
+ ownerDirectory: directoryOf(owner),
+ executable: context.executable,
+ });
+ const expansion: Expansion = {
+ scope: base.scope,
+ path:
+ base.scope === "outside"
+ ? `${base.path.replace(/\/+$/u, "")}/${rest}`
+ : joinPath(base.path, rest),
+ };
+ return { expansion, source: "rpath" as const, rpath, rpathOwner: owner };
+ }),
+ );
+};
+
+const evaluateCandidate = async (
+ template: CandidateTemplate,
+ context: ProcessContext,
+): Promise => {
+ const base = {
+ path: template.expansion.path,
+ source: template.source,
+ rpath: template.rpath,
+ rpath_owner: template.rpathOwner,
+ };
+ if (template.expansion.scope === "outside")
+ return { ...base, outcome: "outside-target", resolved_path: null };
+ if (template.expansion.scope === "undetermined")
+ return { ...base, outcome: "undetermined", resolved_path: null };
+ const lookup = await resolveTreePath(context.view, template.expansion.path);
+ if (lookup.kind === "escapes")
+ return { ...base, outcome: "escapes-target", resolved_path: null };
+ if (lookup.kind !== "file")
+ return { ...base, outcome: "absent", resolved_path: null };
+ const facts = await imageFacts(context, lookup.path);
+ const outcome: Candidate["outcome"] =
+ facts.status !== "parsed"
+ ? facts.status
+ : compatibleSlice(facts.slices, context.architecture) === undefined
+ ? "architecture-missing"
+ : "resolved";
+ return { ...base, outcome, resolved_path: lookup.path };
+};
+
+const imageFacts = async (
+ context: ProcessContext,
+ path: string,
+): Promise => {
+ const known = context.images.get(path);
+ if (known !== undefined) return known;
+ const facts = await context.view.image(path);
+ context.images.set(path, facts);
+ return facts;
+};
+
+const resolution = (candidates: readonly Candidate[]): Edge["resolution"] => {
+ let external = false;
+ for (const candidate of candidates) {
+ if (candidate.outcome === "resolved")
+ return {
+ status: external ? "conditional" : "resolved",
+ image: candidate.resolved_path,
+ };
+ // dyld might load these; REA cannot decide them from the analyzed root.
+ if (
+ candidate.outcome === "outside-target" ||
+ candidate.outcome === "escapes-target" ||
+ candidate.outcome === "unsupported" ||
+ candidate.outcome === "undetermined"
+ )
+ external = true;
+ }
+ return { status: external ? "undetermined" : "unresolved", image: null };
+};
+
+const resolveDependency = async (
+ dependency: MachoDependency,
+ loader: string,
+ context: ProcessContext,
+): Promise => {
+ const current = context.loaded.get(loader);
+ const chain = current?.chain ?? [loader];
+ const loaded = context.byInstallName.get(dependency.install_name);
+ const candidates: Candidate[] = [];
+ if (loaded !== undefined)
+ candidates.push({
+ path: dependency.install_name,
+ source: "already-loaded",
+ rpath: null,
+ rpath_owner: null,
+ outcome: "resolved",
+ resolved_path: loaded,
+ });
+ else
+ for (const template of candidateTemplates(dependency, loader, context)) {
+ const candidate = await evaluateCandidate(template, context);
+ candidates.push(candidate);
+ // dyld stops at the first loadable candidate.
+ if (candidate.outcome === "resolved") break;
+ }
+ const searched = resolution(candidates);
+ // Reusing an image that itself loads only conditionally is conditional too.
+ const resolved: Edge["resolution"] =
+ loaded !== undefined &&
+ context.loaded.get(loaded)?.conditional === true &&
+ searched.status === "resolved"
+ ? { ...searched, status: "conditional" }
+ : searched;
+ const image =
+ resolved.image === null ? undefined : context.images.get(resolved.image);
+ const slice =
+ image?.status === "parsed"
+ ? compatibleSlice(image.slices, context.architecture)
+ : undefined;
+ return {
+ root: context.root,
+ architecture: context.architecture,
+ loader,
+ via: [...chain],
+ ...dependency,
+ candidates,
+ resolution: resolved,
+ install_name_matches:
+ slice === undefined || slice.install_name === null
+ ? null
+ : slice.install_name === dependency.install_name,
+ loader_conditional: current?.conditional ?? false,
+ };
+};
+
+/**
+ * Follow dyld4's dependents-first order: resolve every dependency of one
+ * image, then recurse into the images it newly loaded, in load-command order.
+ */
+const traceProcess = async (
+ context: ProcessContext,
+ rootSlice: MachoSlice,
+ signal?: AbortSignal,
+): Promise => {
+ const edges: Edge[] = [];
+ context.loaded.set(context.root, {
+ slice: rootSlice,
+ chain: [context.root],
+ conditional: false,
+ });
+ if (rootSlice.install_name !== null)
+ context.byInstallName.set(rootSlice.install_name, context.root);
+ const pending = [context.root];
+ while (pending.length > 0) {
+ signal?.throwIfAborted();
+ const loader = pending.pop() ?? "";
+ const current = context.loaded.get(loader);
+ if (current === undefined) continue;
+ const created: string[] = [];
+ for (const dependency of current.slice.dependencies) {
+ const edge = await resolveDependency(dependency, loader, context);
+ edges.push(edge);
+ // dyld loads LC_LAZY_LOAD_DYLIB images on first use, not at launch.
+ if (dependency.command === "LC_LAZY_LOAD_DYLIB") continue;
+ const image = edge.resolution.image;
+ if (image === null || context.loaded.has(image)) continue;
+ const facts = context.images.get(image);
+ const slice =
+ facts?.status === "parsed"
+ ? compatibleSlice(facts.slices, context.architecture)
+ : undefined;
+ if (slice === undefined) continue;
+ context.loaded.set(image, {
+ slice,
+ chain: [...current.chain, image],
+ // dyld may load an unknown earlier candidate instead of this fallback.
+ conditional:
+ current.conditional || edge.resolution.status === "conditional",
+ });
+ if (
+ slice.install_name !== null &&
+ !context.byInstallName.has(slice.install_name)
+ )
+ context.byInstallName.set(slice.install_name, image);
+ created.push(image);
+ }
+ pending.push(...created.reverse());
+ }
+ return edges;
+};
+
+/** Trace dyld load paths from each process root through the analyzed root. */
+export const traceDylibLoading = async (
+ view: DylibTreeView,
+ request: {
+ readonly roots: readonly string[];
+ /** Mach-O files whose root role could not be classified because they did not parse. */
+ readonly unclassified?: readonly string[];
+ readonly architecture?: string;
+ readonly signal?: AbortSignal;
+ },
+): Promise => {
+ const images = new Map();
+ for (const path of request.unclassified ?? [])
+ images.set(path, await view.image(path));
+ const roots: DylibTrace["roots"][number][] = [];
+ const edges: Edge[] = [];
+ const withoutArchitecture: string[] = [];
+ for (const root of request.roots) {
+ const facts = await view.image(root);
+ images.set(root, facts);
+ if (facts.status !== "parsed") continue;
+ const slices = facts.slices.filter(
+ ({ architecture }) =>
+ request.architecture === undefined ||
+ architecture === request.architecture,
+ );
+ if (slices.length === 0) withoutArchitecture.push(root);
+ for (const slice of slices) {
+ roots.push({ image: root, architecture: slice.architecture });
+ edges.push(
+ ...(await traceProcess(
+ {
+ view,
+ root,
+ architecture: slice.architecture,
+ executable: slice.file_type === "execute" ? root : null,
+ loaded: new Map(),
+ byInstallName: new Map(),
+ images,
+ },
+ slice,
+ request.signal,
+ )),
+ );
+ }
+ }
+ const reported = [...images.entries()]
+ .filter(([, facts]) => facts.status !== "not-mach-o")
+ .sort(([left], [right]) => compare(left, right));
+ const unparsed = reported
+ .filter(([, facts]) => facts.status !== "parsed")
+ .map(([path]) => path);
+ return {
+ roots,
+ images: reported.map(([path, facts]) => ({
+ path,
+ parse_status:
+ facts.status === "not-mach-o" ? "unsupported" : facts.status,
+ reason:
+ facts.status === "parsed" || facts.status === "not-mach-o"
+ ? null
+ : facts.reason,
+ slices:
+ facts.status === "parsed" ? facts.slices.map(withoutDependencies) : [],
+ })),
+ edges,
+ findings: deriveFindings(edges, roots, images),
+ coverage: {
+ status:
+ unparsed.length === 0 && withoutArchitecture.length === 0
+ ? "complete"
+ : "partial",
+ unparsed_images: unparsed,
+ roots_without_architecture: withoutArchitecture,
+ },
+ limitations: DYLIB_RESOLUTION_LIMITATIONS,
+ };
+};
+
+const withoutDependencies = (
+ slice: MachoSlice,
+): Omit => ({
+ architecture: slice.architecture,
+ file_type: slice.file_type,
+ install_name: slice.install_name,
+ rpaths: slice.rpaths,
+ dyld_environment: slice.dyld_environment,
+ code_signature_present: slice.code_signature_present,
+});
+
+const compare = (left: string, right: string): number =>
+ left < right ? -1 : left > right ? 1 : 0;
diff --git a/src/domain/apple/dylibResolutionFindings.ts b/src/domain/apple/dylibResolutionFindings.ts
new file mode 100644
index 0000000000..109ad0f7f0
--- /dev/null
+++ b/src/domain/apple/dylibResolutionFindings.ts
@@ -0,0 +1,93 @@
+import type {
+ DylibEdge as Edge,
+ DylibFinding as Finding,
+ DylibTrace,
+ MachoImageFacts,
+} from "./dylibResolution.js";
+
+export const deriveFindings = (
+ edges: readonly Edge[],
+ roots: DylibTrace["roots"],
+ images: ReadonlyMap,
+): Finding[] => {
+ const findings: Finding[] = [];
+ edges.forEach((edge, index) => {
+ if (edge.resolution.status === "unresolved")
+ findings.push(unresolvedFinding(edge, index));
+ const resolvedAt = edge.candidates.findIndex(
+ ({ outcome }) => outcome === "resolved",
+ );
+ const earlier = edge.candidates
+ .slice(0, Math.max(resolvedAt, 0))
+ .filter(
+ ({ source, outcome }) => source === "rpath" && outcome === "absent",
+ )
+ .map(({ path }) => path);
+ if (resolvedAt > 0 && earlier.length > 0)
+ findings.push({
+ kind: "earlier-rpath-candidate-absent",
+ edge_index: index,
+ image: edge.loader,
+ basis: "derived",
+ explanation: `dyld searches ${earlier.join(", ")} before ${edge.resolution.image ?? edge.install_name}. A Mach-O placed at an earlier path would load first unless code-signing library validation rejects it; library validation is not evaluated here (see inspect_signature).`,
+ });
+ });
+ for (const { image, architecture } of roots) {
+ const facts = images.get(image);
+ const environment =
+ facts?.status === "parsed"
+ ? (facts.slices.find((slice) => slice.architecture === architecture)
+ ?.dyld_environment ?? [])
+ : [];
+ if (environment.length > 0)
+ findings.push({
+ kind: "dyld-environment-present",
+ edge_index: null,
+ image,
+ basis: "derived",
+ explanation: `${image} (${architecture}) sets dyld environment variables through LC_DYLD_ENVIRONMENT (${environment.join(", ")}); search paths they add are not modeled.`,
+ });
+ }
+ return findings;
+};
+
+const unresolvedFinding = (edge: Edge, index: number): Finding => {
+ const base = {
+ edge_index: index,
+ image: edge.loader,
+ basis: "derived",
+ } as const;
+ const missing = `No candidate for ${edge.command === "LC_LAZY_LOAD_DYLIB" ? "lazily loaded " : edge.weak ? "weak dependency " : ""}${edge.install_name} exists in the analyzed root`;
+ // Below a conditional fallback, every consequence applies only if it loads.
+ const when = edge.loader_conditional
+ ? `. ${edge.loader} loads only conditionally; if it loads, `
+ : "; ";
+ if (edge.command === "LC_LAZY_LOAD_DYLIB")
+ return {
+ ...base,
+ kind: "lazy-load-unresolved",
+ explanation: `${missing}${when}dyld loads it on first use, which would fail unless the image is supplied elsewhere.`,
+ };
+ return edge.weak
+ ? {
+ ...base,
+ kind: "weak-load-unresolved",
+ explanation: `${missing}${when}dyld continues without it.`,
+ }
+ : {
+ ...base,
+ kind: "required-load-unresolved",
+ explanation: `${missing}${when}dyld would fail to launch ${edge.root} (${edge.architecture}) unless the image is supplied elsewhere.`,
+ };
+};
+
+export const DYLIB_RESOLUTION_LIMITATIONS = [
+ "LC_LAZY_LOAD_DYLIB dependencies are resolved but not traversed, because dyld loads them only on first use.",
+ "Slices are matched by dyld's graded architectures (an x86_64h process also loads x86_64). arm64e processes that disable pointer authentication can also load arm64 slices; that fallback is not modeled.",
+ "Absolute install names and rpaths are outside the analyzed root and are not evaluated, including /System and /usr/lib libraries that the dyld shared cache usually provides.",
+ "Leaf and relative install names depend on dyld fallback paths, DYLD_* variables, and the working directory; they are undetermined.",
+ "Load order follows dyld's dependents-first traversal in load-command order. An image reached through several chains is resolved once per process, with the rpath stack and conditionality of the first chain; a later request whose install name matches an already loaded image reuses it.",
+ "@loader_path uses each image's symlink-resolved path within the analyzed root.",
+ "Code-signing checks that can reject a found image, such as library validation and the hardened runtime, are not evaluated.",
+ "Dylib compatibility versions are not compared. Apple's linker documentation describes a load-time rejection when an image's compatibility version is older than the loader requires, but dyld on macOS 26 was observed to load such an image.",
+];
diff --git a/src/domain/artifactOperationError.ts b/src/domain/artifactOperationError.ts
index 54046be6b8..76c94f348f 100644
--- a/src/domain/artifactOperationError.ts
+++ b/src/domain/artifactOperationError.ts
@@ -12,6 +12,7 @@ export class ArtifactOperationError extends AnalysisError {
| "decode_interface_builder"
| "inspect_asset_catalog"
| "inspect_keyed_archive"
+ | "trace_dylib_resolution"
| "export_web_scripts"
| "trace_web_module_imports"
| "trace_web_source_location"
diff --git a/src/domain/evm/evmInterface.test.ts b/src/domain/evm/evmInterface.test.ts
new file mode 100644
index 0000000000..659f838ba5
--- /dev/null
+++ b/src/domain/evm/evmInterface.test.ts
@@ -0,0 +1,36 @@
+import { expect, it } from "vitest";
+import { evmInterfaceSchema } from "./evmInterface.js";
+import { evmInterfaceFixture } from "../../../tests/fixtures/evm/interface.js";
+
+it("preserves original carrier and decoded byte identity as distinct observations", () => {
+ const fixture = evmInterfaceFixture();
+ expect(evmInterfaceSchema.parse(fixture)).toEqual(fixture);
+ expect(fixture.artifact.sha256).not.toBe(fixture.bytecode.sha256);
+});
+it.each(["byte-count", "body-offset", "selector", "authenticity"])(
+ "rejects false interface evidence: %s",
+ (problem) => {
+ const value = evmInterfaceFixture();
+ const raw = {
+ ...value,
+ ...(problem === "authenticity"
+ ? {
+ bytecode: {
+ ...value.bytecode,
+ deployment_authenticity: "verified",
+ },
+ }
+ : {}),
+ };
+ if (problem === "byte-count") raw.bytecode.bytes++;
+ if (problem === "body-offset" || problem === "selector")
+ raw.functions.push({
+ selector: problem === "selector" ? "bad" : "0xaabbccdd",
+ bytecode_offset: problem === "body-offset" ? 5 : 0,
+ dispatch: "abi",
+ inferred_arguments: null,
+ inferred_state_mutability: null,
+ });
+ expect(evmInterfaceSchema.safeParse(raw).success).toBe(false);
+ },
+);
diff --git a/src/domain/evm/evmInterface.ts b/src/domain/evm/evmInterface.ts
new file mode 100644
index 0000000000..75ada1f971
--- /dev/null
+++ b/src/domain/evm/evmInterface.ts
@@ -0,0 +1,76 @@
+import { z } from "zod";
+
+const byteOffset = z.number().int().nonnegative();
+const sha256 = z.string().regex(/^[a-f0-9]{64}$/);
+
+/** Select the carrier representation explicitly; no RPC or deployed-contract lookup is implied. */
+export const inspectEvmInterfaceInputSchema = z.strictObject({
+ path: z
+ .string()
+ .min(1)
+ .describe("Absolute path to caller-selected local bytecode carrier"),
+ encoding: z
+ .enum(["raw", "hex"])
+ .describe(
+ "Raw bytes or UTF-8 hex with optional 0x prefix and outer ASCII whitespace",
+ ),
+});
+
+/** Recovered interface candidates are inferences, independently of observed byte identity. */
+export const evmInterfaceSchema = z
+ .strictObject({
+ artifact: z.strictObject({
+ path: z.string().min(1),
+ sha256,
+ bytes: byteOffset,
+ encoding: z.enum(["raw", "hex"]),
+ }),
+ bytecode: z.strictObject({
+ sha256,
+ bytes: byteOffset,
+ hex: z.string().regex(/^(?:[0-9a-f]{2})*$/),
+ digest_algorithm: z.literal("sha256"),
+ kind: z.literal("unknown"),
+ deployment_authenticity: z.literal("unknown"),
+ hardfork: z.literal("unknown"),
+ }),
+ evidence_kind: z.literal("inferred"),
+ functions: z.array(
+ z.strictObject({
+ selector: z.string().regex(/^0x[0-9a-f]{8}$/),
+ bytecode_offset: byteOffset,
+ dispatch: z.enum(["abi", "fallback"]),
+ inferred_arguments: z.string().nullable(),
+ inferred_state_mutability: z.string().nullable(),
+ }),
+ ),
+ discovery_completeness: z.literal("unknown"),
+ runtime_execution: z.literal("not-performed"),
+ diagnostics: z.strictObject({
+ stdout: z.string(),
+ stderr: z.string(),
+ truncated: z.boolean(),
+ }),
+ limitations: z.array(z.string()),
+ })
+ .superRefine((value, context) => {
+ if (value.bytecode.hex.length !== value.bytecode.bytes * 2)
+ context.addIssue({
+ code: "custom",
+ path: ["bytecode"],
+ message:
+ "Selected byte count does not match the bytecode hex representation.",
+ });
+ for (const [index, fn] of value.functions.entries())
+ if (fn.bytecode_offset >= value.bytecode.bytes)
+ context.addIssue({
+ code: "custom",
+ path: ["functions", index, "bytecode_offset"],
+ message: "Inferred body offset lies outside the selected bytecode.",
+ });
+ });
+
+export type InspectEvmInterfaceInput = z.infer<
+ typeof inspectEvmInterfaceInputSchema
+>;
+export type EvmInterface = z.infer;
diff --git a/src/evm/EvmBytecodeCarrier.test.ts b/src/evm/EvmBytecodeCarrier.test.ts
new file mode 100644
index 0000000000..b8d485f993
--- /dev/null
+++ b/src/evm/EvmBytecodeCarrier.test.ts
@@ -0,0 +1,34 @@
+import { expect, it } from "vitest";
+import {
+ decodeEvmBytecodeCarrier,
+ EvmCarrierFailure,
+} from "./EvmBytecodeCarrier.js";
+
+it.each(["6000FF", "0x6000ff", " \t0X6000FF\r\n"])(
+ "decodes explicit hex without changing selected bytes: %j",
+ (text) => {
+ expect(
+ Buffer.from(decodeEvmBytecodeCarrier(Buffer.from(text), "hex")).toString(
+ "hex",
+ ),
+ ).toBe("6000ff");
+ },
+);
+it.each(["0x1", "60 00", "0xzz", "\uFEFF6000", "\u00a06000"])(
+ "rejects malformed carrier rather than guessing bytecode: %j",
+ (text) => {
+ expect(() => decodeEvmBytecodeCarrier(Buffer.from(text), "hex")).toThrow(
+ EvmCarrierFailure,
+ );
+ },
+);
+it("distinguishes raw bytes, empty code and malformed UTF-8", () => {
+ const bytes = Uint8Array.from([0xff, 0x00]);
+ const decoded = decodeEvmBytecodeCarrier(bytes, "raw");
+ expect(decoded).toEqual(bytes);
+ expect(decoded).not.toBe(bytes);
+ expect(() => decodeEvmBytecodeCarrier(bytes, "hex")).toThrow("valid UTF-8");
+ expect(decodeEvmBytecodeCarrier(Buffer.from("0x"), "hex")).toEqual(
+ new Uint8Array(),
+ );
+});
diff --git a/src/evm/EvmBytecodeCarrier.ts b/src/evm/EvmBytecodeCarrier.ts
new file mode 100644
index 0000000000..368b674aff
--- /dev/null
+++ b/src/evm/EvmBytecodeCarrier.ts
@@ -0,0 +1,26 @@
+/** Expected carrier encoding failure, independent of the analysis engine. */
+export class EvmCarrierFailure extends Error {}
+
+/** Decode only the explicitly selected representation; source carrier bytes are retained separately. */
+export const decodeEvmBytecodeCarrier = (
+ bytes: Uint8Array,
+ encoding: "raw" | "hex",
+): Uint8Array => {
+ if (encoding === "raw") return Uint8Array.from(bytes);
+ let text: string;
+ try {
+ text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(
+ bytes,
+ );
+ } catch (cause: unknown) {
+ throw new EvmCarrierFailure("Hex carrier must be valid UTF-8.", { cause });
+ }
+ const code = text
+ .replace(/^[\x09-\x0d\x20]+|[\x09-\x0d\x20]+$/g, "")
+ .replace(/^0x/i, "");
+ if (!/^(?:[a-fA-F0-9]{2})*$/.test(code))
+ throw new EvmCarrierFailure(
+ "Hex carrier requires complete hexadecimal byte pairs, an optional 0x prefix and outer ASCII whitespace; interior separators are invalid.",
+ );
+ return Uint8Array.from(Buffer.from(code, "hex"));
+};
diff --git a/src/evm/EvmWorkerLimits.test.ts b/src/evm/EvmWorkerLimits.test.ts
new file mode 100644
index 0000000000..fcbf8197a8
--- /dev/null
+++ b/src/evm/EvmWorkerLimits.test.ts
@@ -0,0 +1,55 @@
+import { expect, it } from "vitest";
+import { selectEvmWorkerLimits } from "./EvmWorkerLimits.js";
+
+const limits = (
+ soft = "unlimited",
+ hard = "unlimited",
+) => `Limit Soft Limit Hard Limit Units
+Max address space ${soft} ${hard} bytes
+Max cpu time unlimited unlimited seconds
+Max file size unlimited unlimited bytes
+`;
+it("uses configured maxima without equating virtual space and RSS", () => {
+ expect(selectEvmWorkerLimits(limits())).toEqual({
+ ok: true,
+ value: {
+ addressSpaceBytes: 3221225472,
+ cpuSeconds: 30,
+ fileSizeBytes: 16777216,
+ },
+ });
+});
+it.each([
+ ["1073741824", "2147483648", 1073741824],
+ ["2147483648", "2147483648", 2147483648],
+ ["0", "0", 0],
+ ["9223372036854775807", "9223372036854775807", 3221225472],
+])(
+ "preserves tighter soft/hard boundaries without integer loss: %s/%s",
+ (soft, hard, expected) => {
+ expect(selectEvmWorkerLimits(limits(soft, hard))).toMatchObject({
+ ok: true,
+ value: { addressSpaceBytes: expected },
+ });
+ },
+);
+it("independently preserves CPU and file output restrictions", () => {
+ expect(
+ selectEvmWorkerLimits(
+ limits()
+ .replace("time unlimited unlimited", "time 5 10")
+ .replace("size unlimited unlimited", "size 4096 8192"),
+ ),
+ ).toMatchObject({ ok: true, value: { cpuSeconds: 5, fileSizeBytes: 4096 } });
+});
+it.each([
+ ["not-text", null],
+ ["soft-unlimited-hard-finite", limits("unlimited", "2147483648")],
+ ["soft-exceeds-hard", limits("3", "2")],
+ ["missing-row", limits().replace("Max file size", "Max other size")],
+ ["duplicate-row", limits() + "Max cpu time 1 1 seconds\n"],
+ ["wrong-unit", limits().replace("bytes", "pages")],
+ ["oversized", "x".repeat(65537)],
+])("rejects malformed/ambiguous resource observations: %s", (_label, input) => {
+ expect(selectEvmWorkerLimits(input).ok).toBe(false);
+});
diff --git a/src/evm/EvmWorkerLimits.ts b/src/evm/EvmWorkerLimits.ts
new file mode 100644
index 0000000000..069152e2d3
--- /dev/null
+++ b/src/evm/EvmWorkerLimits.ts
@@ -0,0 +1,58 @@
+import { err, ok, type Result } from "../domain/result.js";
+import { EVM_INTERFACE_LIMITS } from "./EvmoleRelease.js";
+
+/** Selected worker soft limits to configure; inherited hard limits are left unchanged. */
+export interface EvmWorkerLimits {
+ readonly addressSpaceBytes: number;
+ readonly cpuSeconds: number;
+ readonly fileSizeBytes: number;
+}
+
+/** Read only the three selected Linux limits, retaining tighter soft/hard boundaries. */
+export const selectEvmWorkerLimits = (
+ raw: unknown,
+): Result => {
+ if (typeof raw !== "string" || Buffer.byteLength(raw) > 64 * 1024)
+ return err(
+ "Linux resource limits must be text within the 64 KiB parsing budget.",
+ );
+ const selected: number[] = [];
+ for (const [name, units, maximum] of [
+ ["Max address space", "bytes", EVM_INTERFACE_LIMITS.addressSpaceBytes],
+ ["Max cpu time", "seconds", 30],
+ ["Max file size", "bytes", EVM_INTERFACE_LIMITS.outputBytes],
+ ] as const) {
+ const entries = raw
+ .split("\n")
+ .filter((line) => line.startsWith(name + " "));
+ if (entries.length !== 1)
+ return err(`Linux resource limits must report exactly one ${name} row.`);
+ const match = new RegExp(
+ `^${name}\\s+(unlimited|[0-9]+)\\s+(unlimited|[0-9]+)\\s+${units}\\s*$`,
+ ).exec(entries[0] ?? "");
+ if (match === null)
+ return err(`Malformed inherited Linux resource limit: ${name}.`);
+ let effective = BigInt(maximum);
+ const soft = match[1];
+ const hard = match[2];
+ if (soft === undefined || hard === undefined)
+ return err(`Missing ${name} values.`);
+ if (
+ hard !== "unlimited" &&
+ (soft === "unlimited" || BigInt(soft) > BigInt(hard))
+ )
+ return err(`Inherited ${name} soft limit exceeds its hard limit.`);
+ for (const value of [soft, hard])
+ if (value !== "unlimited" && BigInt(value) < effective)
+ effective = BigInt(value);
+ selected.push(Number(effective));
+ }
+ const [addressSpaceBytes, cpuSeconds, fileSizeBytes] = selected;
+ if (
+ addressSpaceBytes === undefined ||
+ cpuSeconds === undefined ||
+ fileSizeBytes === undefined
+ )
+ return err("Required Linux resource limits were not selected.");
+ return ok({ addressSpaceBytes, cpuSeconds, fileSizeBytes });
+};
diff --git a/src/evm/EvmoleFailures.ts b/src/evm/EvmoleFailures.ts
new file mode 100644
index 0000000000..5690a079ab
--- /dev/null
+++ b/src/evm/EvmoleFailures.ts
@@ -0,0 +1,205 @@
+import type { EvmWorkerLimits } from "./EvmWorkerLimits.js";
+import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
+import {
+ AnalysisError,
+ type AnalysisCapturedOutput,
+} from "../domain/analysisErrorBase.js";
+import type { ProviderProcessSnapshot } from "../process/ProviderProcess.js";
+import {
+ AnalysisAccessDeniedError,
+ AnalysisArtifactChangedError,
+ AnalysisCapabilityUnavailableError,
+ AnalysisCancelledError,
+ AnalysisInputError,
+ AnalysisOutputError,
+ AnalysisResourceConstraintError,
+ AnalysisTimeoutError,
+} from "../domain/analysisErrorCore.js";
+import { ProviderAdapterError } from "../domain/providerAdapterError.js";
+import { ProviderCleanupError } from "../domain/providerCleanupError.js";
+import { OwnedCommandFailure } from "../process/OwnedCommand.js";
+import {
+ EVMOLE_PROVIDER_IDENTITY,
+ EVM_INTERFACE_LIMITS,
+ EVM_FILE_SIZE_FAILURE_EXIT,
+} from "./EvmoleRelease.js";
+
+/** Correlate a reserved exit with the owned worker's actual failure branch. */
+export interface EvmFileSizeFailureEvidence {
+ readonly verified: boolean;
+ readonly failure: string | null;
+}
+
+/** Keep carrier read, engine and owned lifecycle failures distinct. */
+export const evmInterfaceFailure = (
+ cause: unknown,
+ phase: "configuration" | "artifact-read" | "worker",
+ path: string,
+ workerLimits?: EvmWorkerLimits,
+ limiter = "/usr/bin/prlimit",
+ fileFailure?: EvmFileSizeFailureEvidence,
+): AnalysisError => {
+ if (cause instanceof AnalysisError) return cause;
+ if (cause instanceof OwnedCommandFailure) {
+ const outputOptions =
+ cause.snapshot === null
+ ? undefined
+ : { capturedOutput: capturedEvmOutput(cause.snapshot) };
+ if (cause.cleanupFailure !== null)
+ return new ProviderCleanupError(
+ EVMOLE_PROVIDER_IDENTITY.id,
+ cause.resources,
+ {
+ reason: cause.cleanupFailure,
+ ...(cause.snapshot === null
+ ? {}
+ : { captured_output: { ...capturedEvmOutput(cause.snapshot) } }),
+ previous_error: {
+ failure_kind: cause.reason,
+ message: cause.message,
+ exit_code: cause.snapshot?.exitCode ?? null,
+ signal: cause.snapshot?.signal ?? null,
+ stdout: cause.snapshot?.stdout.text ?? null,
+ stderr: cause.snapshot?.stderr.text ?? null,
+ },
+ },
+ { operation: "inspect_evm_interface", cause },
+ );
+ if (cause.reason === "cancelled")
+ return new AnalysisCancelledError("inspect_evm_interface", outputOptions);
+ if (cause.reason === "timeout")
+ return new AnalysisTimeoutError(
+ "inspect_evm_interface",
+ EVM_INTERFACE_LIMITS.timeoutMs,
+ outputOptions,
+ );
+ if (cause.reason === "output-limit")
+ return new AnalysisOutputError(
+ "inspect_evm_interface",
+ cause.message,
+ outputOptions,
+ );
+ if (
+ cause.reason === "process" &&
+ (cause.snapshot?.signal === "SIGXCPU" ||
+ cause.snapshot?.signal === "SIGXFSZ" ||
+ (fileFailure?.verified === true &&
+ cause.snapshot?.exitCode === EVM_FILE_SIZE_FAILURE_EXIT &&
+ cause.snapshot.signal === null))
+ )
+ return new AnalysisResourceConstraintError(
+ "inspect_evm_interface",
+ cause.snapshot.signal === "SIGXCPU" ? "cpu" : "file-size",
+ (cause.snapshot.signal === null
+ ? "Owned EVM worker reported EFBIG while writing its reply; the exact write failure cause is unknown."
+ : `Owned EVM command terminated with ${cause.snapshot.signal}; the exact signal cause is unknown.`) +
+ " Effective worker limits are unknown. Configured soft limits are retained separately.",
+ workerLimits === undefined
+ ? null
+ : {
+ configured_soft_limits: {
+ address_space_bytes: workerLimits.addressSpaceBytes,
+ cpu_seconds: workerLimits.cpuSeconds,
+ file_size_bytes: workerLimits.fileSizeBytes,
+ },
+ effective_soft_limits: null,
+ },
+ outputOptions,
+ );
+ }
+ if (
+ cause instanceof Error &&
+ "code" in cause &&
+ "syscall" in cause &&
+ typeof cause.syscall === "string" &&
+ cause.syscall.startsWith("spawn ")
+ ) {
+ const reason = `Configured util-linux prlimit could not launch (${String(cause.code)}): ${limiter}. Check its executable/interpreter and host execute access.`;
+ return new AnalysisCapabilityUnavailableError(
+ EVMOLE_PROVIDER_IDENTITY.id,
+ "inspect_evm_interface",
+ reason,
+ { cause, userMessage: reason },
+ );
+ }
+ if (cause instanceof ArtifactReaderFailure) {
+ if (cause.reason === "cancelled")
+ return new AnalysisCancelledError("inspect_evm_interface");
+ if (cause.reason === "integrity")
+ return new AnalysisArtifactChangedError(
+ "inspect_evm_interface",
+ path,
+ cause.message,
+ { cause },
+ );
+ if (
+ phase === "artifact-read" &&
+ ["limit", "path", "format"].includes(cause.reason)
+ )
+ return new AnalysisInputError("inspect_evm_interface", { cause }, [
+ {
+ path: ["path"],
+ reason: cause.reason === "limit" ? "out_of_range" : "invalid_format",
+ message: cause.message,
+ },
+ ]);
+ }
+ if (phase === "artifact-read" && cause instanceof Error && "code" in cause) {
+ if (cause.code === "EACCES" || cause.code === "EPERM")
+ return new AnalysisAccessDeniedError(
+ "inspect_evm_interface",
+ path,
+ cause.code,
+ { cause },
+ );
+ if (cause.code === "ENOENT" || cause.code === "ENOTDIR")
+ return new AnalysisInputError("inspect_evm_interface", { cause }, [
+ {
+ path: ["path"],
+ reason: "invalid_value",
+ message: `Selected bytecode carrier could not be read (${String(cause.code)}): ${path}.`,
+ },
+ ]);
+ }
+ return new ProviderAdapterError(
+ EVMOLE_PROVIDER_IDENTITY.id,
+ "inspect_evm_interface",
+ {
+ cause,
+ diagnostics: {
+ phase,
+ path,
+ ...(workerLimits === undefined
+ ? {}
+ : { configured_resource_limits: { ...workerLimits } }),
+ reason: cause instanceof Error ? cause.message : String(cause),
+ ...(fileFailure === undefined
+ ? {}
+ : { file_size_failure_marker: { ...fileFailure } }),
+ ...(cause instanceof OwnedCommandFailure
+ ? {
+ failure_kind: cause.reason,
+ exit_code: cause.snapshot?.exitCode ?? null,
+ signal: cause.snapshot?.signal ?? null,
+ stdout: cause.snapshot?.stdout.text ?? null,
+ stderr: cause.snapshot?.stderr.text ?? null,
+ ...(cause.snapshot === null
+ ? {}
+ : {
+ captured_output: { ...capturedEvmOutput(cause.snapshot) },
+ }),
+ }
+ : {}),
+ },
+ },
+ );
+};
+
+/** Preserve retained worker diagnostics with their observed truncation status. */
+export const capturedEvmOutput = (
+ snapshot: ProviderProcessSnapshot,
+): AnalysisCapturedOutput => ({
+ stdout: snapshot.stdout.text,
+ stderr: snapshot.stderr.text,
+ truncated: snapshot.diagnosticTruncated === true,
+});
diff --git a/src/evm/EvmoleInterfaceAdapter.test.ts b/src/evm/EvmoleInterfaceAdapter.test.ts
new file mode 100644
index 0000000000..a8b53f2801
--- /dev/null
+++ b/src/evm/EvmoleInterfaceAdapter.test.ts
@@ -0,0 +1,66 @@
+import { expect, it } from "vitest";
+import { projectEvmoleInterface } from "./EvmoleInterfaceAdapter.js";
+
+it("preserves the actual producer keys, duplicates and unknown inference absence", () => {
+ const raw = {
+ functions: [
+ { selector: "aabbccdd", bytecodeOffset: 1, dispatch: "abi" },
+ {
+ selector: "aabbccdd",
+ bytecodeOffset: 2,
+ dispatch: "fallback",
+ arguments: "uint256",
+ stateMutability: "view",
+ },
+ ],
+ };
+ const result = projectEvmoleInterface(raw);
+ expect(result.raw).toEqual(raw);
+ expect(result.functions).toEqual([
+ {
+ selector: "0xaabbccdd",
+ bytecode_offset: 1,
+ dispatch: "abi",
+ inferred_arguments: null,
+ inferred_state_mutability: null,
+ },
+ {
+ selector: "0xaabbccdd",
+ bytecode_offset: 2,
+ dispatch: "fallback",
+ inferred_arguments: "uint256",
+ inferred_state_mutability: "view",
+ },
+ ]);
+ expect(projectEvmoleInterface({}).functions).toEqual([]);
+ const metadata = {
+ bytecodeOffset: 1,
+ cborLength: 4,
+ entries: [
+ { key: "reported", value: { type: "integer", value: 9007199254740992 } },
+ ],
+ };
+ expect(
+ projectEvmoleInterface({ functions: [], metadata }).raw.metadata,
+ ).toEqual(metadata);
+ expect(
+ projectEvmoleInterface({
+ functions: [],
+ storage: undefined,
+ transientStorage: undefined,
+ disassembled: undefined,
+ basicBlocks: undefined,
+ controlFlowGraph: undefined,
+ metadata: undefined,
+ }).functions,
+ ).toEqual([]);
+});
+it.each([
+ { functions: [{ selector: "bad", bytecodeOffset: 0, dispatch: "abi" }] },
+ {
+ functions: [{ selector: "aabbccdd", bytecodeOffset: -1, dispatch: "abi" }],
+ },
+ { metadata: {} },
+])("rejects producer representations outside the selected profile", (raw) => {
+ expect(() => projectEvmoleInterface(raw)).toThrow();
+});
diff --git a/src/evm/EvmoleInterfaceAdapter.ts b/src/evm/EvmoleInterfaceAdapter.ts
new file mode 100644
index 0000000000..da62d28b9a
--- /dev/null
+++ b/src/evm/EvmoleInterfaceAdapter.ts
@@ -0,0 +1,59 @@
+import { z } from "zod";
+
+const offset = z.number().int().nonnegative();
+const reportedMetadata = z.strictObject({
+ bytecodeOffset: offset,
+ cborLength: offset,
+ entries: z.array(
+ z.strictObject({
+ key: z.string(),
+ value: z.discriminatedUnion("type", [
+ z.strictObject({ type: z.literal("string"), value: z.string() }),
+ z.strictObject({ type: z.literal("integer"), value: z.number() }),
+ z.strictObject({ type: z.literal("bytes"), value: z.string() }),
+ z.strictObject({ type: z.literal("bool"), value: z.boolean() }),
+ z.strictObject({ type: z.literal("undecoded"), value: z.string() }),
+ ]),
+ }),
+ ),
+});
+/** Parse the exact producer representation before assigning portable field names. */
+export const evmoleInterfaceReplySchema = z.strictObject({
+ // The unchanged JS binding exposes disabled facets as own undefined keys.
+ // Their JSON transport representation is absence; actual facet values are
+ // outside this interface-only profile and must still fail validation.
+ storage: z.undefined().optional(),
+ transientStorage: z.undefined().optional(),
+ disassembled: z.undefined().optional(),
+ basicBlocks: z.undefined().optional(),
+ controlFlowGraph: z.undefined().optional(),
+ // Upstream also extracts metadata when selectors are requested, regardless
+ // of its metadata flag. Retain its reported values without precision claims.
+ metadata: reportedMetadata.optional(),
+ functions: z
+ .array(
+ z.strictObject({
+ selector: z.string().regex(/^[a-f0-9]{8}$/),
+ bytecodeOffset: offset,
+ dispatch: z.enum(["abi", "fallback"]),
+ arguments: z.string().optional(),
+ stateMutability: z.string().optional(),
+ }),
+ )
+ .optional(),
+});
+
+/** Preserve inference strings and unknown absence; selectors use the conventional 0x display prefix. */
+export const projectEvmoleInterface = (raw: unknown) => {
+ const producer = evmoleInterfaceReplySchema.parse(raw);
+ return {
+ raw: producer,
+ functions: (producer.functions ?? []).map((fn) => ({
+ selector: `0x${fn.selector}`,
+ bytecode_offset: fn.bytecodeOffset,
+ dispatch: fn.dispatch,
+ inferred_arguments: fn.arguments ?? null,
+ inferred_state_mutability: fn.stateMutability ?? null,
+ })),
+ };
+};
diff --git a/src/evm/EvmoleInterfaceProvider.ts b/src/evm/EvmoleInterfaceProvider.ts
new file mode 100644
index 0000000000..2316f44a75
--- /dev/null
+++ b/src/evm/EvmoleInterfaceProvider.ts
@@ -0,0 +1,401 @@
+import { createHash, randomUUID } from "node:crypto";
+import { access, readFile, stat, writeFile } from "node:fs/promises";
+import { constants } from "node:fs";
+import { isAbsolute, join } from "node:path";
+import { fileURLToPath } from "node:url";
+import { z } from "zod";
+import {
+ createAnalysisExecution,
+ type AnalysisExecution,
+ type ExecutionOptions,
+} from "../application/AnalysisProvider.js";
+import type { EvmInterfacePort } from "../application/evm/EvmInterfacePort.js";
+import { readStableArtifact } from "../artifacts/readStableArtifact.js";
+import { OwnedCommandFailure } from "../process/OwnedCommand.js";
+import type {
+ AnalysisError,
+ AnalysisCapturedOutput,
+} from "../domain/analysisErrorBase.js";
+import {
+ AnalysisCancelledError,
+ AnalysisCapabilityUnavailableError,
+ AnalysisUnsupportedTargetError,
+ AnalysisInputError,
+ AnalysisOutputError,
+} from "../domain/analysisErrorCore.js";
+import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
+import type { InspectEvmInterfaceInput } from "../domain/evm/evmInterface.js";
+import { evmInterfaceSchema } from "../domain/evm/evmInterface.js";
+import { ProviderAdapterError } from "../domain/providerAdapterError.js";
+import { ProviderCleanupError } from "../domain/providerCleanupError.js";
+import { err, ok, type Result } from "../domain/result.js";
+import { runOwnedCommand } from "../process/OwnedCommand.js";
+import { PrivateRuntimeRoot } from "../process/PrivateRuntimeRoot.js";
+import {
+ evmInterfaceFailure,
+ capturedEvmOutput,
+ type EvmFileSizeFailureEvidence,
+} from "./EvmoleFailures.js";
+import {
+ evmoleInterfaceReplySchema,
+ projectEvmoleInterface,
+} from "./EvmoleInterfaceAdapter.js";
+import {
+ EVMOLE_PROVIDER_IDENTITY,
+ EVM_INTERFACE_LIMITS,
+ EVM_FILE_SIZE_FAILURE_EXIT,
+} from "./EvmoleRelease.js";
+import { decodeEvmBytecodeCarrier } from "./EvmBytecodeCarrier.js";
+import {
+ selectEvmWorkerLimits,
+ type EvmWorkerLimits,
+} from "./EvmWorkerLimits.js";
+
+const OPERATION = "inspect_evm_interface";
+const replySchema = z.discriminatedUnion("ok", [
+ z.strictObject({
+ ok: z.literal(true),
+ version: z.literal("0.9.3"),
+ bytecode: z.strictObject({
+ sha256: z.string().regex(/^[a-f0-9]{64}$/),
+ bytes: z.number().int().nonnegative(),
+ hex: z.string().regex(/^(?:[a-f0-9]{2})*$/),
+ }),
+ raw: evmoleInterfaceReplySchema,
+ }),
+ z.strictObject({
+ ok: z.literal(false),
+ reason: z.enum(["format", "decoder", "output-limit", "unsupported"]),
+ message: z.string(),
+ }),
+]);
+type Launcher = NonNullable[2]>["launcher"];
+
+/** Run unchanged EVMole/WASM in an owned worker; no target, chain or network execution. */
+export class EvmoleInterfaceProvider implements EvmInterfacePort {
+ constructor(
+ readonly environment: Readonly = process.env,
+ readonly launcher?: Launcher,
+ readonly createRuntime: () => Promise<
+ Pick
+ > = () => PrivateRuntimeRoot.create({ prefix: "rea-evm-interface-" }),
+ ) {}
+
+ /** Bind recovered interface candidates to both original carrier and decoded byte identity. */
+ async inspect(
+ input: InspectEvmInterfaceInput,
+ options?: ExecutionOptions,
+ ): Promise> {
+ let root: Pick | undefined;
+ let retainedOutput: AnalysisCapturedOutput | undefined;
+ let phase: "configuration" | "artifact-read" | "worker" = "configuration";
+ let workerLimits: EvmWorkerLimits | undefined;
+ const limiter =
+ this.environment.REA_EVM_PRLIMIT_COMMAND ?? "/usr/bin/prlimit";
+ let result: Result;
+ try {
+ if (options?.signal?.aborted) throw new AnalysisCancelledError(OPERATION);
+ if (process.platform !== "linux" || process.arch !== "x64")
+ throw new AnalysisCapabilityUnavailableError(
+ EVMOLE_PROVIDER_IDENTITY.id,
+ OPERATION,
+ "Initial real-verified offline EVM interface profile supports Linux x64 only.",
+ );
+ try {
+ if (!isAbsolute(limiter))
+ throw new Error(
+ "REA_EVM_PRLIMIT_COMMAND must be an absolute executable path.",
+ );
+ await access(limiter, constants.X_OK);
+ if (!(await stat(limiter)).isFile())
+ throw new Error(
+ "Configured prlimit must be a regular executable file, not a directory.",
+ );
+ } catch (cause: unknown) {
+ const reason = `Offline EVM interface inspection requires caller-supplied util-linux prlimit: ${limiter}; ${cause instanceof Error ? cause.message : String(cause)}`;
+ throw new AnalysisCapabilityUnavailableError(
+ EVMOLE_PROVIDER_IDENTITY.id,
+ OPERATION,
+ reason,
+ { cause, userMessage: reason },
+ );
+ }
+ let inheritedLimits: string;
+ try {
+ inheritedLimits = await readFile("/proc/self/limits", "utf8");
+ } catch (cause: unknown) {
+ const reason = `Could not read inherited Linux resource limits at /proc/self/limits: ${cause instanceof Error ? cause.message : String(cause)}`;
+ throw new AnalysisCapabilityUnavailableError(
+ EVMOLE_PROVIDER_IDENTITY.id,
+ OPERATION,
+ reason,
+ { cause, userMessage: reason },
+ );
+ }
+ const limits = selectEvmWorkerLimits(inheritedLimits);
+ if (!limits.ok)
+ throw new AnalysisCapabilityUnavailableError(
+ EVMOLE_PROVIDER_IDENTITY.id,
+ OPERATION,
+ limits.error,
+ { userMessage: limits.error },
+ );
+ workerLimits = limits.value;
+ phase = "artifact-read";
+ const carrier = await readStableArtifact(
+ input.path,
+ EVM_INTERFACE_LIMITS.inputBytes,
+ options?.signal,
+ );
+ phase = "worker";
+ root = await this.createRuntime();
+ const snapshotPath = join(root.path, "carrier.snapshot");
+ const requestPath = join(root.path, "request.json");
+ const replyPath = join(root.path, "reply.json");
+ await writeFile(snapshotPath, carrier.bytes, {
+ flag: "wx",
+ mode: 0o600,
+ ...(options?.signal === undefined ? {} : { signal: options.signal }),
+ });
+ await writeFile(
+ requestPath,
+ JSON.stringify({
+ snapshot_path: snapshotPath,
+ reply_path: replyPath,
+ failure_marker_path: join(root.path, "resource.failure"),
+ encoding: input.encoding,
+ }),
+ { flag: "wx", mode: 0o600 },
+ );
+ const { NODE_OPTIONS: _ambientNodeOptions, ...environment } =
+ this.environment;
+ const execution = await runOwnedCommand(
+ {
+ command: limiter,
+ arguments: [
+ `--as=${String(workerLimits.addressSpaceBytes)}:`,
+ `--fsize=${String(workerLimits.fileSizeBytes)}:`,
+ "--core=0:",
+ `--cpu=${String(workerLimits.cpuSeconds)}:`,
+ "--",
+ process.execPath,
+ "--max-old-space-size=256",
+ "--v8-pool-size=1",
+ "--disable-wasm-trap-handler",
+ fileURLToPath(
+ new URL("./EvmoleInterfaceWorker.js", import.meta.url),
+ ),
+ requestPath,
+ ],
+ cwd: root.path,
+ expectedCommand: null,
+ runId: `rea-evm-interface-${randomUUID()}`,
+ hostEnvironment: environment,
+ },
+ {
+ timeoutMs: EVM_INTERFACE_LIMITS.timeoutMs,
+ diagnosticBytes: EVM_INTERFACE_LIMITS.diagnosticBytes,
+ },
+ {
+ ...(options?.signal === undefined ? {} : { signal: options.signal }),
+ ...(this.launcher === undefined ? {} : { launcher: this.launcher }),
+ },
+ );
+ const capturedOutput = capturedEvmOutput(execution);
+ retainedOutput = capturedOutput;
+ let reply: z.output;
+ try {
+ const file = await readStableArtifact(
+ replyPath,
+ EVM_INTERFACE_LIMITS.outputBytes,
+ options?.signal,
+ );
+ reply = replySchema.parse(JSON.parse(file.bytes.toString("utf8")));
+ } catch (cause: unknown) {
+ if (options?.signal?.aborted)
+ throw new AnalysisCancelledError(OPERATION, { capturedOutput });
+ throw new AnalysisOutputError(
+ OPERATION,
+ `Owned interface worker reply failed for ${input.path}: ${cause instanceof Error ? cause.message : String(cause)}`,
+ { cause, capturedOutput },
+ );
+ }
+ if (!reply.ok) {
+ if (reply.reason === "unsupported")
+ throw new AnalysisUnsupportedTargetError(
+ OPERATION,
+ input.path,
+ reply.message,
+ { capturedOutput },
+ );
+ if (reply.reason === "format")
+ throw new AnalysisInputError(OPERATION, { capturedOutput }, [
+ {
+ path: ["path"],
+ reason: "invalid_format",
+ message: reply.message,
+ },
+ ]);
+ if (reply.reason === "output-limit")
+ throw new AnalysisOutputError(OPERATION, reply.message, {
+ capturedOutput,
+ });
+ throw new ProviderAdapterError(EVMOLE_PROVIDER_IDENTITY.id, OPERATION, {
+ diagnostics: {
+ phase,
+ failure_kind: reply.reason,
+ reason: reply.message,
+ stdout: execution.stdout.text,
+ stderr: execution.stderr.text,
+ captured_output: { ...capturedOutput },
+ },
+ });
+ }
+ const selectedBytes = decodeEvmBytecodeCarrier(
+ carrier.bytes,
+ input.encoding,
+ );
+ const selectedHex = Buffer.from(selectedBytes).toString("hex");
+ const decodedHash = createHash("sha256")
+ .update(selectedBytes)
+ .digest("hex");
+ if (
+ decodedHash !== reply.bytecode.sha256 ||
+ selectedHex !== reply.bytecode.hex ||
+ selectedBytes.length !== reply.bytecode.bytes
+ )
+ throw new AnalysisOutputError(
+ OPERATION,
+ "Decoded bytecode representation does not match the selected carrier, encoding or reported SHA-256.",
+ { capturedOutput },
+ );
+ const limitations = [
+ `Configured worker resource soft limits: address_space_bytes=${String(workerLimits.addressSpaceBytes)}, cpu_seconds=${String(workerLimits.cpuSeconds)}, file_size_bytes=${String(workerLimits.fileSizeBytes)}. Selected from inherited tighter limits; original hard limits are retained. Effective worker limits are not independently probed.`,
+ "Upstream may also report terminal metadata while recovering selectors. Its reported values are retained in raw_result, but integer precision and non-text-key coverage are unknown in the JS binding; complete original bytecode remains available.",
+ "Recovered selectors, argument strings and mutability are upstream inferences, not verified ABI signatures or names. Empty results do not prove the absence of callable dispatch.",
+ "Bytecode kind, hardfork and deployed-contract authenticity are unknown. This inspection performs no target runtime execution, chain/RPC lookup or network request.",
+ "Original carrier and decoded bytecode SHA-256 are distinct identities; SHA-256 is not the Ethereum Keccak code hash.",
+ "The owned worker has a 256 MiB JavaScript heap, 3 GiB virtual address-space limit and 30-second deadline. Virtual address space is distinct from resident memory; limits fail without partial success.",
+ ];
+ const validated = evmInterfaceSchema.safeParse({
+ artifact: {
+ path: input.path,
+ sha256: carrier.sha256,
+ bytes: carrier.bytes.length,
+ encoding: input.encoding,
+ },
+ bytecode: {
+ ...reply.bytecode,
+ digest_algorithm: "sha256",
+ kind: "unknown",
+ deployment_authenticity: "unknown",
+ hardfork: "unknown",
+ },
+ evidence_kind: "inferred",
+ functions: projectEvmoleInterface(reply.raw).functions,
+ discovery_completeness: "unknown",
+ runtime_execution: "not-performed",
+ diagnostics: {
+ stdout: execution.stdout.text,
+ stderr: execution.stderr.text,
+ truncated: capturedOutput.truncated,
+ },
+ limitations,
+ });
+ if (!validated.success)
+ throw new AnalysisOutputError(
+ OPERATION,
+ `Interface worker returned invalid bytecode-relative evidence: ${validated.error.issues[0]?.message ?? "schema mismatch"}`,
+ { capturedOutput },
+ );
+ const report = validated.data;
+ result = ok(
+ createAnalysisExecution(report, EVMOLE_PROVIDER_IDENTITY, {
+ rawResult: reply.raw,
+ subject: { path: input.path, format: "file", sha256: carrier.sha256 },
+ locations: [{ kind: "artifact-path", path: input.path }],
+ limitations,
+ }),
+ );
+ } catch (cause: unknown) {
+ let fileFailure: EvmFileSizeFailureEvidence | undefined;
+ if (
+ cause instanceof OwnedCommandFailure &&
+ cause.snapshot?.exitCode === EVM_FILE_SIZE_FAILURE_EXIT &&
+ cause.snapshot.signal === null &&
+ root !== undefined
+ ) {
+ try {
+ const marker = await readStableArtifact(
+ join(root.path, "resource.failure"),
+ 1,
+ );
+ if (!marker.bytes.equals(Buffer.from("F")))
+ throw new Error(
+ "Private worker failure marker does not match the observed exit status.",
+ );
+ fileFailure = { verified: true, failure: null };
+ } catch (markerFailure: unknown) {
+ fileFailure = {
+ verified: false,
+ failure:
+ markerFailure instanceof Error
+ ? markerFailure.message
+ : String(markerFailure),
+ };
+ }
+ }
+ result = err(
+ options?.signal?.aborted &&
+ (cause === options.signal.reason ||
+ (cause instanceof Error && cause.name === "AbortError"))
+ ? new AnalysisCancelledError(
+ OPERATION,
+ retainedOutput === undefined
+ ? undefined
+ : { capturedOutput: retainedOutput },
+ )
+ : evmInterfaceFailure(
+ cause,
+ phase,
+ input.path,
+ workerLimits,
+ limiter,
+ fileFailure,
+ ),
+ );
+ }
+ if (root !== undefined) {
+ try {
+ await root.close();
+ } catch (cause: unknown) {
+ return err(
+ new ProviderCleanupError(
+ EVMOLE_PROVIDER_IDENTITY.id,
+ [root.path],
+ {
+ reason: cause instanceof Error ? cause.message : String(cause),
+ previous_error: result.ok
+ ? null
+ : projectAnalysisError(result.error),
+ ...(retainedOutput === undefined
+ ? {}
+ : { captured_output: { ...retainedOutput } }),
+ },
+ { operation: OPERATION },
+ ),
+ );
+ }
+ }
+ return result.ok && options?.signal?.aborted
+ ? err(
+ new AnalysisCancelledError(
+ OPERATION,
+ retainedOutput === undefined
+ ? undefined
+ : { capturedOutput: retainedOutput },
+ ),
+ )
+ : result;
+ }
+}
diff --git a/src/evm/EvmoleInterfaceWorker.ts b/src/evm/EvmoleInterfaceWorker.ts
new file mode 100644
index 0000000000..061ceb47e7
--- /dev/null
+++ b/src/evm/EvmoleInterfaceWorker.ts
@@ -0,0 +1,108 @@
+import { createHash } from "node:crypto";
+import { readFile, writeFile } from "node:fs/promises";
+import { z } from "zod";
+import {
+ decodeEvmBytecodeCarrier,
+ EvmCarrierFailure,
+} from "./EvmBytecodeCarrier.js";
+import { projectEvmoleInterface } from "./EvmoleInterfaceAdapter.js";
+import {
+ EVM_INTERFACE_LIMITS,
+ EVM_FILE_SIZE_FAILURE_EXIT,
+} from "./EvmoleRelease.js";
+
+const requestSchema = z.strictObject({
+ snapshot_path: z.string(),
+ reply_path: z.string(),
+ failure_marker_path: z.string(),
+ encoding: z.enum(["raw", "hex"]),
+});
+class UnsupportedContainer extends Error {}
+
+const main = async (): Promise => {
+ const requestPath = process.argv[2];
+ if (requestPath === undefined)
+ throw new Error("Owned worker request path missing.");
+ const request = requestSchema.parse(
+ JSON.parse(await readFile(requestPath, "utf8")),
+ );
+ let reply: unknown;
+ try {
+ const bytes = decodeEvmBytecodeCarrier(
+ await readFile(request.snapshot_path),
+ request.encoding,
+ );
+ if (bytes[0] === 0xef && bytes[1] === 0x00)
+ throw new UnsupportedContainer(
+ "Container marker EF00 is outside the verified flat-bytecode interface profile.",
+ );
+ const modulePath = import.meta.resolve("evmole/no_tla");
+ const metadata = z
+ .object({ version: z.literal("0.9.3") })
+ .parse(
+ JSON.parse(
+ await readFile(new URL("../package.json", modulePath), "utf8"),
+ ),
+ );
+ const engine = await import("evmole/no_tla");
+ engine.initSync({
+ module: await readFile(
+ new URL(import.meta.resolve("evmole/evmole_bg.wasm")),
+ ),
+ });
+ const inferred = projectEvmoleInterface(
+ engine.contractInfo(Buffer.from(bytes).toString("hex"), {
+ selectors: true,
+ arguments: true,
+ stateMutability: true,
+ }),
+ );
+ reply = {
+ ok: true,
+ version: metadata.version,
+ bytecode: {
+ sha256: createHash("sha256").update(bytes).digest("hex"),
+ bytes: bytes.length,
+ hex: Buffer.from(bytes).toString("hex"),
+ },
+ raw: inferred.raw,
+ };
+ } catch (cause: unknown) {
+ reply = {
+ ok: false,
+ reason:
+ cause instanceof EvmCarrierFailure
+ ? "format"
+ : cause instanceof UnsupportedContainer
+ ? "unsupported"
+ : "decoder",
+ message: cause instanceof Error ? cause.message : String(cause),
+ };
+ }
+ let encoded = JSON.stringify(reply);
+ if (Buffer.byteLength(encoded) > EVM_INTERFACE_LIMITS.outputBytes)
+ encoded = JSON.stringify({
+ ok: false,
+ reason: "output-limit",
+ message: "Complete interface evidence exceeds the 16 MiB reply budget.",
+ });
+ try {
+ await writeFile(request.reply_path, encoded, { flag: "wx", mode: 0o600 });
+ } catch (cause: unknown) {
+ if (cause instanceof Error && "code" in cause && cause.code === "EFBIG") {
+ try {
+ await writeFile(request.failure_marker_path, "F", {
+ flag: "wx",
+ mode: 0o600,
+ });
+ } catch (markerFailure: unknown) {
+ process.stderr.write(
+ `File-size failure marker unavailable: ${markerFailure instanceof Error ? markerFailure.message : String(markerFailure)}\n`,
+ );
+ }
+ process.exitCode = EVM_FILE_SIZE_FAILURE_EXIT;
+ } else throw cause;
+ }
+};
+
+await main();
diff --git a/src/evm/EvmoleRelease.ts b/src/evm/EvmoleRelease.ts
new file mode 100644
index 0000000000..8e8ea10fc2
--- /dev/null
+++ b/src/evm/EvmoleRelease.ts
@@ -0,0 +1,16 @@
+/** Unchanged locked npm/WASM profile; provenance is independent of chain deployment authenticity. */
+export const EVMOLE_PROVIDER_IDENTITY = {
+ id: "evmole-interface",
+ name: "REA EVM interface adapter",
+ version: "evmole@0.9.3",
+} as const;
+/** Worker-reserved status for an observed EFBIG write without a complete reply. */
+export const EVM_FILE_SIZE_FAILURE_EXIT = 76;
+/** Complete offline inspection budgets; worker JavaScript heap is bounded separately. */
+export const EVM_INTERFACE_LIMITS = {
+ inputBytes: 4 * 1024 * 1024,
+ outputBytes: 16 * 1024 * 1024,
+ timeoutMs: 30_000,
+ diagnosticBytes: 1024 * 1024,
+ addressSpaceBytes: 3 * 1024 * 1024 * 1024,
+} as const;
diff --git a/src/generatedPackageMetadata.ts b/src/generatedPackageMetadata.ts
index fc03cee9fa..d56f777c43 100644
--- a/src/generatedPackageMetadata.ts
+++ b/src/generatedPackageMetadata.ts
@@ -5,5 +5,5 @@ export const PACKAGE_METADATA = {
serverSdkVersion: "2.3.1",
clientSdkVersion: "2.3.1",
coreSdkVersion: "2.3.1",
- skillVersion: "31",
+ skillVersion: "32",
} as const;
diff --git a/src/server/createServer.ts b/src/server/createServer.ts
index 1a69fecac9..e0291e09f5 100644
--- a/src/server/createServer.ts
+++ b/src/server/createServer.ts
@@ -1,3 +1,6 @@
+import type { EvmInterfaceService } from "../application/evm/EvmInterfaceService.js";
+import { createEvmInterfaceService } from "../composition/evm.js";
+import { registerEvmTools } from "./registerEvmTools.js";
import { registerBinaryDiagnosticsTools } from "./registerBinaryDiagnosticsTools.js";
import { createBinaryLayoutService } from "../composition/binaryDiagnostics.js";
import type { BinaryLayoutService } from "../application/binaryDiagnostics/BinaryLayoutService.js";
@@ -62,6 +65,7 @@ const ACTIVE_TARGET_INSTRUCTIONS =
"REA analyzes the active reverse-engineering target. Use the analysis tool that answers the question directly. Search or list symbols when discovery is needed; analyze_function provides a function dossier, and focused procedure tools return individual facets.";
export interface CreateServerOptions {
+ readonly evmInterface?: EvmInterfaceService;
readonly logger?: Logger;
readonly binaryLayout?: BinaryLayoutService;
readonly firmwareAnalysis?: FirmwareAnalysisPort;
@@ -89,6 +93,9 @@ const installSessionToolAvailability = (
const policy = sessionAvailabilityPolicy(options.availabilityPolicy, {
optionalProviderLoadFailures: options.optionalProviderLoadFailures,
optionalFeatures: {
+ evmInterfaceEnabled:
+ options.evmInterface !== undefined ||
+ (process.platform === "linux" && process.arch === "x64"),
webModuleResolutionEnabled:
options.webModuleTrace !== undefined ||
isAbsolute(process.env.REA_BROWSER_EXECUTABLE ?? ""),
@@ -205,6 +212,12 @@ export const createServer = (
toolLogger,
recordEvidence,
);
+ registerEvmTools(
+ server,
+ options.evmInterface ?? createEvmInterfaceService(),
+ toolLogger,
+ recordEvidence,
+ );
registerFirmwareTools(
server,
new FirmwareAnalysisService(
diff --git a/src/server/registerEvmTools.ts b/src/server/registerEvmTools.ts
new file mode 100644
index 0000000000..09af2dc26c
--- /dev/null
+++ b/src/server/registerEvmTools.ts
@@ -0,0 +1,32 @@
+import type { McpServer } from "@modelcontextprotocol/server";
+import type { EvmInterfaceService } from "../application/evm/EvmInterfaceService.js";
+import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js";
+import { toolContract } from "../contracts/toolContracts.js";
+import type { Logger } from "../logger.js";
+import { logToolExecution } from "./toolLogging.js";
+import { toolRegistrationOptions } from "./toolRegistrationOptions.js";
+import { toCallToolResult } from "./toolResult.js";
+
+/** Bind the named EVM contract to shared validation and session Evidence recording. */
+export const registerEvmTools = (
+ server: McpServer,
+ service: EvmInterfaceService,
+ logger: Logger,
+ recordEvidence?: EvidenceWriter["recordEvidence"],
+): void => {
+ const contract = toolContract("inspect_evm_interface");
+ server.registerTool(
+ contract.name,
+ toolRegistrationOptions(contract),
+ async (input, context) => {
+ const result = await logToolExecution(logger, contract.name, () =>
+ service.inspect(input, { signal: context.mcpReq.signal }),
+ );
+ if (!result.ok) return toCallToolResult(result, contract);
+ const recorded = recordEvidence?.(result.value);
+ return recorded !== undefined && !recorded.ok
+ ? toCallToolResult(recorded, contract)
+ : toCallToolResult(result, contract);
+ },
+ );
+};
diff --git a/src/server/sessionAvailabilityPolicy.ts b/src/server/sessionAvailabilityPolicy.ts
index 0303828e5e..7339296478 100644
--- a/src/server/sessionAvailabilityPolicy.ts
+++ b/src/server/sessionAvailabilityPolicy.ts
@@ -10,6 +10,7 @@ export interface SessionAvailabilityDefaults {
| undefined;
readonly optionalFeatures?: Pick<
SessionAvailability,
+ | "evmInterfaceEnabled"
| "browserObservationEnabled"
| "browserScenarioEnabled"
| "electronObservationEnabled"
@@ -32,6 +33,8 @@ export const sessionAvailabilityPolicy = (
const policy =
configured ??
(() => ({
+ evmInterfaceEnabled:
+ defaults.optionalFeatures?.evmInterfaceEnabled ?? false,
processCaptureEnabled: platform !== "win32",
binaryLayoutEnabled:
defaults.optionalFeatures?.binaryLayoutEnabled ?? false,
diff --git a/tests/boundary/cli/dylibResolutionCli.test.ts b/tests/boundary/cli/dylibResolutionCli.test.ts
new file mode 100644
index 0000000000..1911d85517
--- /dev/null
+++ b/tests/boundary/cli/dylibResolutionCli.test.ts
@@ -0,0 +1,244 @@
+import { chmod, mkdir, symlink, writeFile } from "node:fs/promises";
+import { dirname, join } from "node:path";
+
+import { describe, expect } from "vitest";
+
+import {
+ FILE_TYPE,
+ LC,
+ dylibCommand,
+ machoImage,
+ rpathCommand,
+} from "../../../src/artifacts/apple/MachoImage.fixture.js";
+import { dylibResolutionResultSchema } from "../../../src/domain/apple/dylibResolution.js";
+import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
+import { cliTest } from "../../support/cli/cliFixture.js";
+
+const ENVIRONMENT = { REA_LOG_LEVEL: "silent", REA_ANALYSIS_PROVIDER: "auto" };
+
+const writeFiles = async (
+ root: string,
+ files: Readonly>,
+): Promise => {
+ for (const [path, content] of Object.entries(files)) {
+ await mkdir(dirname(join(root, path)), { recursive: true });
+ await writeFile(join(root, path), content);
+ }
+};
+
+const fixtureApp = async (): Promise => {
+ const app = join(
+ await createTestTempDirectory("rea-dylib-cli-"),
+ "Fixture.app",
+ );
+ await writeFiles(app, {
+ "Contents/Info.plist":
+ "CFBundleExecutableApp",
+ "Contents/MacOS/App": machoImage({
+ commands: [
+ rpathCommand("@executable_path/../Frameworks"),
+ dylibCommand(LC.LOAD_DYLIB, "@rpath/libcore.dylib"),
+ dylibCommand(LC.LOAD_WEAK_DYLIB, "@rpath/libgone.dylib"),
+ ],
+ }),
+ "Contents/Frameworks/libcore.dylib": machoImage({
+ fileType: FILE_TYPE.dylib,
+ commands: [dylibCommand(LC.ID_DYLIB, "@rpath/libcore.dylib")],
+ }),
+ "Contents/Helpers/tool": machoImage({
+ commands: [dylibCommand(LC.LOAD_DYLIB, "@loader_path/libnone.dylib")],
+ }),
+ });
+ return app;
+};
+
+const withBrokenHelper = async (app: string): Promise => {
+ const broken = machoImage({ commands: [rpathCommand("x")] });
+ // cmdsize 0 makes the command table unreadable.
+ new DataView(broken.buffer).setUint32(32 + 4, 0, true);
+ await writeFiles(app, { "Contents/Helpers/broken": broken });
+};
+
+describe("trace-dylib-resolution CLI", () => {
+ cliTest("traces every executable root of an app bundle", async ({ cli }) => {
+ const app = await fixtureApp();
+ const result = await cli.run({
+ arguments: ["trace-dylib-resolution", app, "--json"],
+ environment: ENVIRONMENT,
+ });
+ expect(result.exitCode, JSON.stringify(result.json)).toBe(0);
+ const trace = dylibResolutionResultSchema.parse(
+ (result.json as { normalized_result: unknown }).normalized_result,
+ );
+ expect(trace.root_path).toBe(app);
+ expect(trace.roots.map(({ image }) => image)).toEqual([
+ "Contents/Helpers/tool",
+ "Contents/MacOS/App",
+ ]);
+ expect(
+ trace.edges.map(({ loader, install_name: name, resolution }) => [
+ loader,
+ name,
+ resolution.status,
+ resolution.image,
+ ]),
+ ).toEqual([
+ [
+ "Contents/Helpers/tool",
+ "@loader_path/libnone.dylib",
+ "unresolved",
+ null,
+ ],
+ [
+ "Contents/MacOS/App",
+ "@rpath/libcore.dylib",
+ "resolved",
+ "Contents/Frameworks/libcore.dylib",
+ ],
+ ["Contents/MacOS/App", "@rpath/libgone.dylib", "unresolved", null],
+ ]);
+ expect(trace.findings.map(({ kind }) => kind)).toEqual([
+ "required-load-unresolved",
+ "weak-load-unresolved",
+ ]);
+ expect(
+ trace.images.every(({ sha256 }) => /^[0-9a-f]{64}$/u.test(sha256)),
+ ).toBe(true);
+ });
+
+ cliTest(
+ "narrows roots and architecture, and rejects escaping roots",
+ async ({ cli }) => {
+ const app = await fixtureApp();
+ const narrowed = await cli.run({
+ arguments: [
+ "trace-dylib-resolution",
+ app,
+ "--root",
+ "Contents/MacOS/App",
+ "--architecture",
+ "arm64",
+ "--json",
+ ],
+ environment: ENVIRONMENT,
+ });
+ expect(narrowed.exitCode).toBe(0);
+ expect(
+ (narrowed.json as { normalized_result: { roots: unknown } })
+ .normalized_result.roots,
+ ).toEqual([{ image: "Contents/MacOS/App", architecture: "arm64" }]);
+ // A root below a symlinked directory that points outside the bundle.
+ const outside = await createTestTempDirectory("rea-dylib-outside-");
+ await writeFiles(outside, { Tool: machoImage({}) });
+ await symlink(outside, join(app, "Contents/Linked"));
+ for (const root of [
+ "../outside",
+ "Contents/Info.plist",
+ "Contents/Linked/Tool",
+ ]) {
+ const rejected = await cli.run({
+ arguments: ["trace-dylib-resolution", app, "--root", root, "--json"],
+ environment: ENVIRONMENT,
+ });
+ expect(rejected.exitCode).toBe(1);
+ expect(rejected.json).toMatchObject({ error: "Analysis failed" });
+ }
+ },
+ );
+
+ cliTest(
+ "resolves a standalone Mach-O against its own directory",
+ async ({ cli }) => {
+ const directory = await createTestTempDirectory("rea-dylib-standalone-");
+ await writeFiles(directory, {
+ tool: machoImage({
+ commands: [
+ dylibCommand(LC.LOAD_DYLIB, "@loader_path/lib/libhelper.dylib"),
+ dylibCommand(LC.LOAD_DYLIB, "@loader_path/../escape.dylib"),
+ ],
+ }),
+ "lib/libhelper.dylib": machoImage({ fileType: FILE_TYPE.dylib }),
+ "unrelated.txt": "not traversed",
+ });
+ const result = await cli.run({
+ arguments: [
+ "trace-dylib-resolution",
+ join(directory, "tool"),
+ "--json",
+ ],
+ environment: ENVIRONMENT,
+ });
+ expect(result.exitCode, JSON.stringify(result.json)).toBe(0);
+ const trace = dylibResolutionResultSchema.parse(
+ (result.json as { normalized_result: unknown }).normalized_result,
+ );
+ expect(trace.roots).toEqual([{ image: "tool", architecture: "arm64" }]);
+ expect(trace.edges.map(({ resolution }) => resolution)).toEqual([
+ { status: "resolved", image: "lib/libhelper.dylib" },
+ { status: "undetermined", image: null },
+ ]);
+ },
+ );
+
+ cliTest(
+ "reports unparsed Mach-O files found while enumerating roots",
+ async ({ cli }) => {
+ const app = await fixtureApp();
+ await withBrokenHelper(app);
+ const result = await cli.run({
+ arguments: ["trace-dylib-resolution", app, "--json"],
+ environment: ENVIRONMENT,
+ });
+ expect(result.exitCode, JSON.stringify(result.json)).toBe(0);
+ const trace = dylibResolutionResultSchema.parse(
+ (result.json as { normalized_result: unknown }).normalized_result,
+ );
+ expect(trace.coverage).toMatchObject({
+ status: "partial",
+ unparsed_images: ["Contents/Helpers/broken"],
+ });
+ },
+ );
+});
+
+describe.skipIf(process.getuid?.() === 0)(
+ "trace-dylib-resolution permissions",
+ () => {
+ cliTest(
+ "reports an unreadable dependency as a permission failure",
+ async ({ cli }) => {
+ const directory = await createTestTempDirectory("rea-dylib-denied-");
+ await writeFiles(directory, {
+ tool: machoImage({
+ commands: [
+ dylibCommand(LC.LOAD_DYLIB, "@loader_path/libsecret.dylib"),
+ ],
+ }),
+ "libsecret.dylib": machoImage({ fileType: FILE_TYPE.dylib }),
+ });
+ await chmod(join(directory, "libsecret.dylib"), 0o000);
+ try {
+ const result = await cli.run({
+ arguments: [
+ "trace-dylib-resolution",
+ join(directory, "tool"),
+ "--json",
+ ],
+ environment: ENVIRONMENT,
+ });
+ expect(result.exitCode).toBe(1);
+ expect(result.json).toMatchObject({
+ details: {
+ reason: "unavailable",
+ detail: expect.stringContaining(
+ "Permission denied (EACCES) reading libsecret.dylib",
+ ),
+ },
+ });
+ } finally {
+ await chmod(join(directory, "libsecret.dylib"), 0o644);
+ }
+ },
+ );
+ },
+);
diff --git a/tests/boundary/cli/productCatalog.test.ts b/tests/boundary/cli/productCatalog.test.ts
index c804c93e27..3eb9f6528e 100644
--- a/tests/boundary/cli/productCatalog.test.ts
+++ b/tests/boundary/cli/productCatalog.test.ts
@@ -1,3 +1,4 @@
+import { EVMOLE_PROVIDER_IDENTITY } from "../../../src/evm/EvmoleRelease.js";
import { PWNTOOLS_PROVIDER_IDENTITY } from "../../../src/native/pwntools/PwntoolsRelease.js";
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
@@ -117,6 +118,7 @@ describe("canonical product catalog", () => {
IDA_PROVIDER_IDENTITY,
NATIVE_MACOS_PROVIDER_IDENTITY,
PWNTOOLS_PROVIDER_IDENTITY,
+ EVMOLE_PROVIDER_IDENTITY,
ARTIFACT_GRAPH_PROVIDER,
ANDROID_APPLICATION_PROVIDER,
APPLE_APPLICATION_PROVIDER,
diff --git a/tests/boundary/evm/evmoleInterfaceProvider.test.ts b/tests/boundary/evm/evmoleInterfaceProvider.test.ts
new file mode 100644
index 0000000000..dd0e0fd8ef
--- /dev/null
+++ b/tests/boundary/evm/evmoleInterfaceProvider.test.ts
@@ -0,0 +1,597 @@
+import { createHash } from "node:crypto";
+import { access, readFile, writeFile } from "node:fs/promises";
+import { join } from "node:path";
+import { expect, it, onTestFinished } from "vitest";
+import { z } from "zod";
+import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
+import { evmInterfaceSchema } from "../../../src/domain/evm/evmInterface.js";
+import { EvmoleInterfaceProvider } from "../../../src/evm/EvmoleInterfaceProvider.js";
+import { spawnOwnedProviderProcess } from "../../../src/process/ProviderProcess.js";
+import { waitForProviderProcessReady } from "../../fixtures/providerProcess.js";
+import { PrivateRuntimeRoot } from "../../../src/process/PrivateRuntimeRoot.js";
+import {
+ createTestWorkspace,
+ removeTestWorkspace,
+} from "../../support/workspace/workspaceFixture.js";
+
+const unsupportedHost = process.platform !== "linux" || process.arch !== "x64";
+const requestSchema = z.strictObject({
+ snapshot_path: z.string(),
+ reply_path: z.string(),
+ failure_marker_path: z.string(),
+ encoding: z.enum(["raw", "hex"]),
+});
+const fixture = async () => {
+ const workspace = await createTestWorkspace("rea-evm-interface-boundary-");
+ onTestFinished(() => removeTestWorkspace(workspace.root));
+ const path = join(workspace.root, "selected.hex");
+ await writeFile(path, "0x60006000f3\n");
+ return { path, root: workspace.root };
+};
+
+it.runIf(!unsupportedHost).each(["SIGXCPU", "SIGXFSZ", "EFBIG"])(
+ "preserves %s with configured limits distinct from unknown effective limits",
+ async (failure) => {
+ const { path } = await fixture();
+ let ownedPath = "";
+ const provider = new EvmoleInterfaceProvider({}, async (spawn) => {
+ ownedPath = spawn.cwd ?? "";
+ if (failure === "EFBIG")
+ await writeFile(join(ownedPath, "resource.failure"), "F");
+ return spawnOwnedProviderProcess({
+ ...spawn,
+ command: process.execPath,
+ arguments: [
+ "-e",
+ failure === "EFBIG"
+ ? 'process.stdout.write("resource diagnostic", () => { process.exitCode = 76; })'
+ : failure === "SIGXFSZ"
+ ? 'process.on("SIGXFSZ", () => { process.removeAllListeners("SIGXFSZ"); process.kill(process.pid, "SIGXFSZ"); }); process.stdout.write("resource diagnostic", () => process.kill(process.pid, "SIGXFSZ")); setTimeout(() => {}, 1000);'
+ : `process.stdout.write("resource diagnostic", () => process.kill(process.pid, "${failure}"))`,
+ ],
+ });
+ });
+ const result = await provider.inspect({ path, encoding: "hex" });
+ if (result.ok) throw new Error("Expected observed resource failure");
+ expect(projectAnalysisError(result.error)).toMatchObject({
+ code: "resource_constraint",
+ details: {
+ resource: failure === "SIGXCPU" ? "cpu" : "file-size",
+ reported_limits: {
+ configured_soft_limits: { cpu_seconds: expect.any(Number) },
+ effective_soft_limits: null,
+ },
+ captured_output: { stdout: "resource diagnostic", truncated: false },
+ },
+ });
+ expect(result.error.message).toContain(
+ failure === "EFBIG" ? "exact write failure cause" : "exact signal cause",
+ );
+ await expect(access(ownedPath)).rejects.toMatchObject({ code: "ENOENT" });
+ },
+);
+
+it.runIf(!unsupportedHost).each(["missing", "wrong"])(
+ "does not classify a launcher exit 76 with %s worker failure evidence",
+ async (scenario) => {
+ const { path } = await fixture();
+ let ownedPath = "";
+ const provider = new EvmoleInterfaceProvider({}, async (spawn) => {
+ ownedPath = spawn.cwd ?? "";
+ if (scenario === "wrong")
+ await writeFile(
+ join(ownedPath, "resource.failure"),
+ Buffer.from([0xc6]),
+ );
+ return spawnOwnedProviderProcess({
+ ...spawn,
+ command: process.execPath,
+ arguments: [
+ "-e",
+ 'process.stdout.write("launcher failure", () => { process.exitCode = 76; });',
+ ],
+ });
+ });
+ const result = await provider.inspect({ path, encoding: "hex" });
+ if (result.ok) throw new Error("Expected launcher failure");
+ expect(projectAnalysisError(result.error).code).toBe("execution_failure");
+ expect(result.error).toMatchObject({
+ diagnostics: {
+ file_size_failure_marker: {
+ verified: false,
+ failure: expect.any(String),
+ },
+ captured_output: { stdout: "launcher failure", truncated: false },
+ },
+ });
+ await expect(access(ownedPath)).rejects.toMatchObject({ code: "ENOENT" });
+ },
+);
+
+it.runIf(!unsupportedHost)(
+ "retains actual diagnostic truncation when overflow and process cleanup both fail",
+ async () => {
+ const { path } = await fixture();
+ let ownedPath = "";
+ const provider = new EvmoleInterfaceProvider({}, async (spawn) => {
+ ownedPath = spawn.cwd ?? "";
+ const launched = await spawnOwnedProviderProcess({
+ ...spawn,
+ command: process.execPath,
+ arguments: ["-e", 'process.stdout.write("x".repeat(1048577))'],
+ });
+ return {
+ ...launched,
+ cleanup: async () => {
+ await launched.cleanup?.();
+ throw new Error("injected post-cleanup reporting failure");
+ },
+ };
+ });
+ const result = await provider.inspect({ path, encoding: "hex" });
+ if (result.ok) throw new Error("Expected compounded lifecycle failure");
+ expect(result.error).toMatchObject({
+ cleanupIncomplete: true,
+ diagnostics: {
+ reason: "injected post-cleanup reporting failure",
+ previous_error: { failure_kind: "output-limit" },
+ captured_output: { truncated: true, stderr: "" },
+ },
+ });
+ await expect(access(ownedPath)).rejects.toMatchObject({ code: "ENOENT" });
+ },
+);
+
+it.runIf(!unsupportedHost).each([1048576, 1048577])(
+ "keeps complete diagnostics or rejects a valid worker reply with %s diagnostic bytes",
+ async (size) => {
+ const { path } = await fixture();
+ let ownedPath = "";
+ const provider = new EvmoleInterfaceProvider({}, async (spawn) => {
+ ownedPath = spawn.cwd ?? "";
+ const requestPath = spawn.arguments.at(-1);
+ if (requestPath === undefined) throw new Error("Request missing");
+ const request = requestSchema.parse(
+ JSON.parse(await readFile(requestPath, "utf8")),
+ );
+ const bytes = Buffer.from("60006000f3", "hex");
+ await writeFile(
+ request.reply_path,
+ JSON.stringify({
+ ok: true,
+ version: "0.9.3",
+ bytecode: {
+ sha256: createHash("sha256").update(bytes).digest("hex"),
+ bytes: bytes.length,
+ hex: bytes.toString("hex"),
+ },
+ raw: { functions: [] },
+ }),
+ );
+ return spawnOwnedProviderProcess({
+ ...spawn,
+ command: process.execPath,
+ arguments: ["-e", `process.stderr.write("x".repeat(${String(size)}))`],
+ });
+ });
+ const result = await provider.inspect({ path, encoding: "hex" });
+ if (size === 1048576) {
+ if (!result.ok) throw result.error;
+ const report = evmInterfaceSchema.parse(result.value.result);
+ expect(report.diagnostics.stderr).toHaveLength(size);
+ expect(report.diagnostics.truncated).toBe(false);
+ } else {
+ if (result.ok)
+ throw new Error("Expected explicit diagnostic budget failure");
+ expect(projectAnalysisError(result.error)).toMatchObject({
+ code: "unreadable_output",
+ details: { captured_output: { truncated: true } },
+ });
+ }
+ await expect(access(ownedPath)).rejects.toMatchObject({ code: "ENOENT" });
+ },
+);
+
+it
+ .runIf(!unsupportedHost)
+ .each([
+ "success",
+ "format",
+ "unsupported",
+ "wrong-version",
+ "wrong-decoded-carrier",
+ "broken-reply",
+ "out-of-range-body",
+ ])(
+ "binds source carrier/worker representation and releases owned files: %s",
+ async (scenario) => {
+ const { path } = await fixture();
+ let ownedRoot = "";
+ const provider = new EvmoleInterfaceProvider(
+ { NODE_OPTIONS: "--inspect=0" },
+ async (spawn) => {
+ ownedRoot = spawn.cwd ?? "";
+ expect(spawn.hostEnvironment?.NODE_OPTIONS).toBeUndefined();
+ expect(spawn.arguments).toContain("--as=3221225472:");
+ expect(spawn.arguments).toContain("--disable-wasm-trap-handler");
+ const requestPath = spawn.arguments.at(-1);
+ if (requestPath === undefined) throw new Error("request missing");
+ const request = requestSchema.parse(
+ JSON.parse(await readFile(requestPath, "utf8")),
+ );
+ expect(request.encoding).toBe("hex");
+ expect(await readFile(request.snapshot_path, "utf8")).toBe(
+ "0x60006000f3\n",
+ );
+ const hex = scenario === "wrong-decoded-carrier" ? "00" : "60006000f3";
+ const bytes = Buffer.from(hex, "hex");
+ await writeFile(
+ request.reply_path,
+ scenario === "broken-reply"
+ ? "broken"
+ : JSON.stringify(
+ ["format", "unsupported"].includes(scenario)
+ ? {
+ ok: false,
+ reason: scenario,
+ message: "Malformed hex carrier.",
+ }
+ : {
+ ok: true,
+ version: scenario === "wrong-version" ? "0.0.0" : "0.9.3",
+ bytecode: {
+ sha256: createHash("sha256")
+ .update(bytes)
+ .digest("hex"),
+ bytes: bytes.length,
+ hex,
+ },
+ raw: {
+ functions:
+ scenario === "out-of-range-body"
+ ? [
+ {
+ selector: "aabbccdd",
+ bytecodeOffset: 5,
+ dispatch: "abi",
+ },
+ ]
+ : [],
+ },
+ },
+ ),
+ );
+ return spawnOwnedProviderProcess({
+ ...spawn,
+ command: process.execPath,
+ arguments: ["-e", 'process.stderr.write("upstream warning")'],
+ });
+ },
+ );
+ const result = await provider.inspect({ path, encoding: "hex" });
+ if (scenario === "success") {
+ if (!result.ok) throw result.error;
+ expect(result.value.result).toMatchObject({
+ artifact: { path, encoding: "hex", bytes: 13 },
+ bytecode: { hex: "60006000f3", bytes: 5 },
+ diagnostics: { stderr: "upstream warning" },
+ });
+ expect(result.value.rawResult).toEqual({ functions: [] });
+ } else
+ expect(result).toMatchObject({
+ ok: false,
+ error: {
+ _tag:
+ scenario === "format"
+ ? "AnalysisInputError"
+ : scenario === "unsupported"
+ ? "AnalysisUnsupportedTargetError"
+ : "AnalysisOutputError",
+ },
+ });
+ if (!result.ok)
+ expect(projectAnalysisError(result.error)).toMatchObject({
+ details: {
+ captured_output: {
+ stdout: "",
+ stderr: "upstream warning",
+ truncated: false,
+ },
+ },
+ });
+ expect(await readFile(path, "utf8")).toBe("0x60006000f3\n");
+ await expect(access(ownedRoot)).rejects.toMatchObject({ code: "ENOENT" });
+ },
+);
+
+it.runIf(!unsupportedHost).each(["cancelled", "output-limit", "process"])(
+ "preserves lifecycle failure and independently releases an acquired worker: %s",
+ async (scenario) => {
+ const { path } = await fixture();
+ const controller = new AbortController();
+ let pid: number | undefined;
+ let ownedRoot = "";
+ const provider = new EvmoleInterfaceProvider({}, async (spawn) => {
+ ownedRoot = spawn.cwd ?? "";
+ const launched = await spawnOwnedProviderProcess({
+ ...spawn,
+ command: process.execPath,
+ arguments: [
+ "-e",
+ scenario === "cancelled"
+ ? 'process.stdout.write("ready\\n");setInterval(()=>undefined,1000)'
+ : scenario === "output-limit"
+ ? 'process.stdout.write("x".repeat(1048577))'
+ : 'process.stderr.write("worker failed");process.exit(7)',
+ ],
+ });
+ pid = launched.process.pid;
+ if (scenario === "cancelled") {
+ await waitForProviderProcessReady(launched.process);
+ setImmediate(() => controller.abort());
+ }
+ return launched;
+ });
+ expect(
+ await provider.inspect(
+ { path, encoding: "hex" },
+ { signal: controller.signal },
+ ),
+ ).toMatchObject({
+ ok: false,
+ error: {
+ _tag:
+ scenario === "cancelled"
+ ? "AnalysisCancelledError"
+ : scenario === "output-limit"
+ ? "AnalysisOutputError"
+ : "ProviderAdapterError",
+ },
+ });
+ if (pid === undefined) throw new Error("worker not acquired");
+ const acquiredPid = pid;
+ expect(() => process.kill(acquiredPid, 0)).toThrow();
+ await expect(access(ownedRoot)).rejects.toMatchObject({ code: "ENOENT" });
+ },
+);
+
+it.runIf(!unsupportedHost)(
+ "distinguishes unavailable limiter, absent carrier and prelaunch cancellation",
+ async () => {
+ const { path } = await fixture();
+ expect(
+ await new EvmoleInterfaceProvider({
+ REA_EVM_PRLIMIT_COMMAND: path + ".absent",
+ }).inspect({ path, encoding: "hex" }),
+ ).toMatchObject({
+ ok: false,
+ error: { _tag: "AnalysisCapabilityUnavailableError" },
+ });
+ expect(
+ await new EvmoleInterfaceProvider({}).inspect({
+ path: path + ".absent",
+ encoding: "raw",
+ }),
+ ).toMatchObject({ ok: false, error: { _tag: "AnalysisInputError" } });
+ const controller = new AbortController();
+ controller.abort();
+ expect(
+ await new EvmoleInterfaceProvider({}).inspect(
+ { path, encoding: "raw" },
+ { signal: controller.signal },
+ ),
+ ).toMatchObject({ ok: false, error: { _tag: "AnalysisCancelledError" } });
+ },
+);
+it.runIf(unsupportedHost)(
+ "reports unverified host coverage before acquiring a provider",
+ async () => {
+ expect(
+ await new EvmoleInterfaceProvider({}).inspect({
+ path: "/selected.hex",
+ encoding: "hex",
+ }),
+ ).toMatchObject({
+ ok: false,
+ error: { _tag: "AnalysisCapabilityUnavailableError" },
+ });
+ },
+);
+
+it.runIf(!unsupportedHost)(
+ "preserves actual snapshot-write cancellation and root cleanup",
+ async () => {
+ const { path } = await fixture();
+ const controller = new AbortController();
+ let ownedRoot = "";
+ const provider = new EvmoleInterfaceProvider(
+ {},
+ () => {
+ throw new Error("must not launch worker");
+ },
+ async () => {
+ const root = await PrivateRuntimeRoot.create({
+ prefix: "rea-evm-cancel-",
+ });
+ ownedRoot = root.path;
+ controller.abort();
+ return root;
+ },
+ );
+ expect(
+ await provider.inspect(
+ { path, encoding: "hex" },
+ { signal: controller.signal },
+ ),
+ ).toMatchObject({ ok: false, error: { _tag: "AnalysisCancelledError" } });
+ await expect(access(ownedRoot)).rejects.toMatchObject({ code: "ENOENT" });
+ },
+);
+
+it.runIf(!unsupportedHost)(
+ "reports private storage denial without claiming selected carrier read denial",
+ async () => {
+ const { path } = await fixture();
+ const provider = new EvmoleInterfaceProvider({}, undefined, () =>
+ Promise.reject(
+ Object.assign(new Error("private root denied"), { code: "EACCES" }),
+ ),
+ );
+ expect(await provider.inspect({ path, encoding: "hex" })).toMatchObject({
+ ok: false,
+ error: {
+ _tag: "ProviderAdapterError",
+ diagnostics: { phase: "worker", reason: "private root denied" },
+ },
+ });
+ },
+);
+
+it.runIf(!unsupportedHost)(
+ "retains original format failure when owned-root cleanup fails",
+ async () => {
+ const { path, root } = await fixture();
+ const provider = new EvmoleInterfaceProvider(
+ {},
+ async (spawn) => {
+ const requestPath = spawn.arguments.at(-1);
+ if (requestPath === undefined) throw new Error("request missing");
+ const request = requestSchema.parse(
+ JSON.parse(await readFile(requestPath, "utf8")),
+ );
+ await writeFile(
+ request.reply_path,
+ JSON.stringify({
+ ok: false,
+ reason: "format",
+ message: "Odd selected hex.",
+ }),
+ );
+ return spawnOwnedProviderProcess({
+ ...spawn,
+ command: process.execPath,
+ arguments: ["-e", "process.exit(0)"],
+ });
+ },
+ () =>
+ Promise.resolve({
+ path: root,
+ close: () => Promise.reject(new Error("root cleanup failed")),
+ }),
+ );
+ expect(await provider.inspect({ path, encoding: "hex" })).toMatchObject({
+ ok: false,
+ error: {
+ cleanupIncomplete: true,
+ cleanupResources: [root],
+ diagnostics: {
+ reason: "root cleanup failed",
+ previous_error: { category: "invalid_input" },
+ },
+ },
+ });
+ },
+);
+
+it.runIf(!unsupportedHost).each(["late-cancellation", "cleanup-failure"])(
+ "retains captured warnings after a successful worker: %s",
+ async (scenario) => {
+ const { path } = await fixture();
+ const controller = new AbortController();
+ let ownedRoot = "";
+ const provider = new EvmoleInterfaceProvider(
+ {},
+ async (spawn) => {
+ const requestPath = spawn.arguments.at(-1);
+ if (requestPath === undefined) throw new Error("request missing");
+ const request = requestSchema.parse(
+ JSON.parse(await readFile(requestPath, "utf8")),
+ );
+ const bytes = Buffer.from("60006000f3", "hex");
+ await writeFile(
+ request.reply_path,
+ JSON.stringify({
+ ok: true,
+ version: "0.9.3",
+ bytecode: {
+ sha256: createHash("sha256").update(bytes).digest("hex"),
+ bytes: bytes.length,
+ hex: bytes.toString("hex"),
+ },
+ raw: { functions: [] },
+ }),
+ );
+ return spawnOwnedProviderProcess({
+ ...spawn,
+ command: process.execPath,
+ arguments: ["-e", 'process.stderr.write("upstream warning")'],
+ });
+ },
+ async () => {
+ const root = await PrivateRuntimeRoot.create({
+ prefix: "rea-evm-late-",
+ });
+ ownedRoot = root.path;
+ return {
+ path: root.path,
+ close: async () => {
+ await root.close();
+ if (scenario === "late-cancellation") controller.abort();
+ else throw new Error("reported cleanup failure");
+ },
+ };
+ },
+ );
+ const result = await provider.inspect(
+ { path, encoding: "hex" },
+ { signal: controller.signal },
+ );
+ if (result.ok) throw new Error("Expected lifecycle failure");
+ const output = { stdout: "", stderr: "upstream warning", truncated: false };
+ expect(projectAnalysisError(result.error)).toMatchObject({
+ category:
+ scenario === "late-cancellation" ? "cancelled" : "execution_failure",
+ details:
+ scenario === "late-cancellation"
+ ? { captured_output: output }
+ : { diagnostics: { captured_output: output } },
+ });
+ await expect(access(ownedRoot)).rejects.toMatchObject({ code: "ENOENT" });
+ },
+);
+
+it.runIf(!unsupportedHost).each(["directory", "missing-interpreter"])(
+ "identifies configured limiter constraints without blaming the carrier: %s",
+ async (scenario) => {
+ const { path, root } = await fixture();
+ const limiter =
+ scenario === "directory" ? root : join(root, "broken-limiter");
+ if (scenario === "missing-interpreter")
+ await writeFile(limiter, "#!/rea-missing-limiter-interpreter\n", {
+ mode: 0o700,
+ });
+ let ownedRoot: string | undefined;
+ const provider = new EvmoleInterfaceProvider(
+ { REA_EVM_PRLIMIT_COMMAND: limiter },
+ undefined,
+ async () => {
+ const runtime = await PrivateRuntimeRoot.create({
+ prefix: "rea-evm-limiter-",
+ });
+ ownedRoot = runtime.path;
+ return runtime;
+ },
+ );
+ const result = await provider.inspect({ path, encoding: "hex" });
+ if (result.ok) throw new Error("Expected unavailable limiter");
+ expect(projectAnalysisError(result.error)).toMatchObject({
+ code: "capability_unavailable",
+ category: "unsupported_provider",
+ details: { provider_id: "evmole-interface" },
+ });
+ expect(result.error.message).toContain(limiter);
+ if (ownedRoot !== undefined)
+ await expect(access(ownedRoot)).rejects.toMatchObject({ code: "ENOENT" });
+ expect(await readFile(path, "utf8")).toBe("0x60006000f3\n");
+ },
+);
diff --git a/tests/boundary/mcp/dylibResolutionMcp.test.ts b/tests/boundary/mcp/dylibResolutionMcp.test.ts
new file mode 100644
index 0000000000..179912a182
--- /dev/null
+++ b/tests/boundary/mcp/dylibResolutionMcp.test.ts
@@ -0,0 +1,195 @@
+import { chmod, mkdir, writeFile } from "node:fs/promises";
+import { join } from "node:path";
+
+import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
+import { expect, it } from "vitest";
+import { z } from "zod";
+
+import { ArtifactProvider } from "../../../src/artifacts/ArtifactProvider.js";
+import {
+ FILE_TYPE,
+ LC,
+ dylibCommand,
+ machoImage,
+ rpathCommand,
+} from "../../../src/artifacts/apple/MachoImage.fixture.js";
+import { dylibResolutionResultSchema } from "../../../src/domain/apple/dylibResolution.js";
+import { createServer } from "../../../src/server/createServer.js";
+import { createTestBinarySession } from "../../fixtures/binarySession.js";
+import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
+
+const withClient = async (
+ verify: (client: Client) => Promise,
+): Promise => {
+ const session = createTestBinarySession(new ArtifactProvider());
+ const server = createServer(session, session);
+ const client = new Client({ name: "dylib-mcp-test", version: "1" });
+ const [clientTransport, serverTransport] =
+ InMemoryTransport.createLinkedPair();
+ try {
+ await server.connect(serverTransport);
+ await client.connect(clientTransport);
+ await verify(client);
+ } finally {
+ await client.close();
+ await server.close();
+ }
+};
+
+const structuredResult = (value: unknown): unknown =>
+ z.object({ result: z.unknown() }).parse(value).result;
+
+it("traces dylib resolution for an opened app bundle and rejects a changed target", async () => {
+ const app = join(
+ await createTestTempDirectory("rea-dylib-mcp-"),
+ "Fixture.app",
+ );
+ await mkdir(join(app, "Contents/MacOS"), { recursive: true });
+ await mkdir(join(app, "Contents/Frameworks"), { recursive: true });
+ await writeFile(
+ join(app, "Contents/Info.plist"),
+ "CFBundleExecutableApp",
+ );
+ const executable = join(app, "Contents/MacOS/App");
+ await writeFile(
+ executable,
+ machoImage({
+ commands: [
+ rpathCommand("@executable_path/../Frameworks"),
+ dylibCommand(LC.LOAD_DYLIB, "@rpath/libcore.dylib"),
+ ],
+ }),
+ );
+ await writeFile(
+ join(app, "Contents/Frameworks/libcore.dylib"),
+ machoImage({ fileType: FILE_TYPE.dylib }),
+ );
+ await withClient(async (client) => {
+ const opened = await client.callTool({
+ name: "open_binary",
+ arguments: { path: app },
+ });
+ expect(opened.isError, JSON.stringify(opened)).not.toBe(true);
+ const called = await client.callTool({
+ name: "trace_dylib_resolution",
+ arguments: {},
+ });
+ expect(called.isError, JSON.stringify(called.structuredContent)).not.toBe(
+ true,
+ );
+ const trace = dylibResolutionResultSchema.parse(
+ structuredResult(called.structuredContent),
+ );
+ expect(trace.edges).toEqual([
+ expect.objectContaining({
+ install_name: "@rpath/libcore.dylib",
+ resolution: {
+ status: "resolved",
+ image: "Contents/Frameworks/libcore.dylib",
+ },
+ }),
+ ]);
+ const escaping = await client.callTool({
+ name: "trace_dylib_resolution",
+ arguments: { roots: ["Contents/../../outside"] },
+ });
+ expect(escaping.isError).toBe(true);
+
+ await writeFile(executable, machoImage({}));
+ const changed = await client.callTool({
+ name: "trace_dylib_resolution",
+ arguments: {},
+ });
+ expect(changed.isError).toBe(true);
+ expect(JSON.stringify(changed.structuredContent)).toContain("integrity");
+ });
+});
+
+it("rejects targets that are not Mach-O", async () => {
+ const directory = await createTestTempDirectory("rea-dylib-mcp-plist-");
+ const path = join(directory, "Info.plist");
+ await writeFile(path, "");
+ await withClient(async (client) => {
+ const opened = await client.callTool({
+ name: "open_binary",
+ arguments: { path },
+ });
+ expect(opened.isError).not.toBe(true);
+ const called = await client.callTool({
+ name: "trace_dylib_resolution",
+ arguments: {},
+ });
+ expect(called.isError).toBe(true);
+ expect(JSON.stringify(called.structuredContent)).toContain("unavailable");
+ });
+});
+
+it("traces a standalone Mach-O file whose name ends in .app", async () => {
+ const directory = await createTestTempDirectory("rea-dylib-mcp-file-");
+ await writeFile(
+ join(directory, "Tool.app"),
+ machoImage({
+ commands: [dylibCommand(LC.LOAD_DYLIB, "@loader_path/libcore.dylib")],
+ }),
+ );
+ await writeFile(
+ join(directory, "libcore.dylib"),
+ machoImage({ fileType: FILE_TYPE.dylib }),
+ );
+ await withClient(async (client) => {
+ const opened = await client.callTool({
+ name: "open_binary",
+ arguments: { path: join(directory, "Tool.app") },
+ });
+ expect(opened.isError, JSON.stringify(opened)).not.toBe(true);
+ const called = await client.callTool({
+ name: "trace_dylib_resolution",
+ arguments: {},
+ });
+ expect(called.isError, JSON.stringify(called.structuredContent)).not.toBe(
+ true,
+ );
+ const trace = dylibResolutionResultSchema.parse(
+ structuredResult(called.structuredContent),
+ );
+ expect(trace.edges).toEqual([
+ expect.objectContaining({
+ resolution: { status: "resolved", image: "libcore.dylib" },
+ }),
+ ]);
+ });
+});
+
+it.skipIf(process.getuid?.() === 0)(
+ "keeps a permission denied while canonicalizing the opened bundle",
+ async () => {
+ const parent = await createTestTempDirectory("rea-dylib-mcp-revoked-");
+ const app = join(parent, "Revoked.app");
+ await mkdir(join(app, "Contents/MacOS"), { recursive: true });
+ await writeFile(
+ join(app, "Contents/Info.plist"),
+ "CFBundleExecutableApp",
+ );
+ await writeFile(join(app, "Contents/MacOS/App"), machoImage({}));
+ await withClient(async (client) => {
+ const opened = await client.callTool({
+ name: "open_binary",
+ arguments: { path: app },
+ });
+ expect(opened.isError, JSON.stringify(opened)).not.toBe(true);
+ await chmod(parent, 0o000);
+ try {
+ const called = await client.callTool({
+ name: "trace_dylib_resolution",
+ arguments: {},
+ });
+ expect(called.isError).toBe(true);
+ expect(JSON.stringify(called.structuredContent)).toMatch(
+ /Permission denied \(EACCES\)/u,
+ );
+ } finally {
+ await chmod(parent, 0o755);
+ }
+ });
+ },
+);
diff --git a/tests/boundary/mcp/evmInterfaceMcp.test.ts b/tests/boundary/mcp/evmInterfaceMcp.test.ts
new file mode 100644
index 0000000000..1047b34592
--- /dev/null
+++ b/tests/boundary/mcp/evmInterfaceMcp.test.ts
@@ -0,0 +1,78 @@
+import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
+import { Ajv2020 } from "ajv/dist/2020.js";
+import { expect, it, onTestFinished } from "vitest";
+import { EvmInterfaceService } from "../../../src/application/evm/EvmInterfaceService.js";
+import { toolContract } from "../../../src/contracts/toolContracts.js";
+import { parseEvidence } from "../../../src/domain/evidence.js";
+import { ok } from "../../../src/domain/result.js";
+import { createServer } from "../../../src/server/createServer.js";
+import { createTestBinarySession } from "../../fixtures/binarySession.js";
+import {
+ evmInterfaceFixture,
+ evmInterfaceExecution,
+} from "../../fixtures/evm/interface.js";
+
+it("advertises exact valid schemas and records inline EVM interface evidence without a binary target", async () => {
+ const value = evmInterfaceFixture();
+ const service = new EvmInterfaceService({
+ inspect: () => Promise.resolve(ok(evmInterfaceExecution(value))),
+ });
+ const session = createTestBinarySession(() => {
+ throw new Error("deep provider must not start");
+ });
+ const server = createServer(session, session, { evmInterface: service });
+ const client = new Client({ name: "evm-interface-contract", version: "1" });
+ onTestFinished(async () => {
+ await client.close();
+ await server.close();
+ await session.close();
+ });
+ const [clientTransport, serverTransport] =
+ InMemoryTransport.createLinkedPair();
+ await server.connect(serverTransport);
+ await client.connect(clientTransport);
+ const advertised = (await client.listTools()).tools.find(
+ (tool) => tool.name === "inspect_evm_interface",
+ );
+ if (advertised?.outputSchema === undefined)
+ throw new Error("EVM interface inspection must publish both schemas");
+ const ajv = new Ajv2020({ strict: false, validateFormats: false });
+ const inputSchema: Record = advertised.inputSchema;
+ const outputSchema: Record = advertised.outputSchema;
+ expect(ajv.validateSchema(inputSchema)).toBe(true);
+ expect(ajv.validateSchema(outputSchema)).toBe(true);
+ expect(
+ ajv.validate(inputSchema, { path: value.artifact.path, encoding: "hex" }),
+ ).toBe(true);
+ expect(
+ ajv.validate(inputSchema, {
+ path: value.artifact.path,
+ encoding: "hex",
+ approval: true,
+ }),
+ ).toBe(false);
+ const response = await client.callTool({
+ name: "inspect_evm_interface",
+ arguments: { path: value.artifact.path, encoding: "hex" },
+ });
+ expect(response.isError).not.toBe(true);
+ expect(
+ ajv.validate(outputSchema, response.structuredContent),
+ JSON.stringify(ajv.errors),
+ ).toBe(true);
+ const parsed = toolContract("inspect_evm_interface").outputSchema.parse(
+ response.structuredContent,
+ );
+ const evidence = parseEvidence(parsed.evidence);
+ expect(parsed.result).toEqual(value);
+ expect(parsed.result).toEqual(evidence.normalized_result);
+ expect(session.evidenceById(evidence.evidence_id)).toEqual(evidence);
+ expect(
+ (
+ await client.callTool({
+ name: "inspect_evm_interface",
+ arguments: { path: "relative.hex", encoding: "hex" },
+ })
+ ).isError,
+ ).toBe(true);
+});
diff --git a/tests/fixtures/evm/interface.ts b/tests/fixtures/evm/interface.ts
new file mode 100644
index 0000000000..23bfcfa026
--- /dev/null
+++ b/tests/fixtures/evm/interface.ts
@@ -0,0 +1,44 @@
+import { createHash } from "node:crypto";
+import { createAnalysisExecution } from "../../../src/application/AnalysisProvider.js";
+import type { EvmInterface } from "../../../src/domain/evm/evmInterface.js";
+
+/** Source-owned transport seam, independent of real engine verification. */
+export const evmInterfaceFixture = (
+ path = "/artifacts/source-owned.hex",
+): EvmInterface => ({
+ artifact: { path, sha256: "a".repeat(64), bytes: 10, encoding: "hex" },
+ bytecode: {
+ sha256: createHash("sha256")
+ .update(Buffer.from("60006000f3", "hex"))
+ .digest("hex"),
+ bytes: 5,
+ hex: "60006000f3",
+ digest_algorithm: "sha256",
+ kind: "unknown",
+ deployment_authenticity: "unknown",
+ hardfork: "unknown",
+ },
+ evidence_kind: "inferred",
+ functions: [],
+ discovery_completeness: "unknown",
+ runtime_execution: "not-performed",
+ diagnostics: { stdout: "", stderr: "", truncated: false },
+ limitations: ["Interface completeness remains unknown."],
+});
+
+/** Bind a fixture to its explicit producing seam and carrier identity. */
+export const evmInterfaceExecution = (value = evmInterfaceFixture()) =>
+ createAnalysisExecution(
+ value,
+ { id: "fixture-evm", name: "Source-owned EVM seam", version: "1" },
+ {
+ rawResult: { functions: [] },
+ subject: {
+ path: value.artifact.path,
+ format: "file",
+ sha256: value.artifact.sha256,
+ },
+ limitations: value.limitations,
+ locations: [{ kind: "artifact-path", path: value.artifact.path }],
+ },
+ );
diff --git a/third_party/evmole/LICENSE b/third_party/evmole/LICENSE
new file mode 100644
index 0000000000..5f31d41836
--- /dev/null
+++ b/third_party/evmole/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2023 Maxim Andreev
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/third_party/evmole/README.md b/third_party/evmole/README.md
new file mode 100644
index 0000000000..0b7a635150
--- /dev/null
+++ b/third_party/evmole/README.md
@@ -0,0 +1,22 @@
+# EVM interface upstream profile
+
+REA integrates unchanged [EVMole](https://github.com/cdump/evmole) as the exact
+npm dependency `evmole@0.9.3`, with lockfile integrity verification. Tag `0.9.3`
+resolves to commit `0409707c2efef11a9043f9b4505cad5719054a7e`.
+
+The JavaScript binding and WASM engine remain in the original upstream package;
+REA contains only carrier validation, a worker adapter and portable contracts.
+No submodule or vendored engine fork is required. No signature database,
+LLM decompiler, blockchain node or RPC dependency is acquired.
+
+Upstream is MIT, copyright (c) 2023 Maxim Andreev. Its npm tarball omits the
+license file, so REA packages the unchanged tag's [LICENSE](LICENSE) here.
+The engine is initialized through the upstream `no_tla`/`initSync` interface
+only inside an owned worker; importing REA services performs no WASM/file I/O.
+
+Initial interface analysis uses `contractInfo` with selectors, arguments and
+stateMutability enabled. Selector recovery also extracts/reports terminal metadata;
+its reported representation is retained with explicit unknown precision/coverage.
+Other upstream outputs are separate future contracts;
+in particular, CFG destination IDs are not byte addresses and terminal CBOR
+metadata cannot establish a lossless integer claim through the current binding.
diff --git a/vitest.config.ts b/vitest.config.ts
index f882022154..0ab5bbc44b 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -37,7 +37,7 @@ const TEST_PROJECTS = [
name: "adapters",
include: [
"src/*.test.ts",
- "src/{artifacts,browser,dotnet,firmware,ghidra,hopper,ida,inspector,javascript,native,process,reference,server,windows}/**/*.test.ts",
+ "src/{artifacts,browser,dotnet,evm,firmware,ghidra,hopper,ida,inspector,javascript,native,process,reference,server,windows}/**/*.test.ts",
],
pool: "forks" as const,
maxWorkers: MAX_TEST_WORKERS,