chore: merge main into feat/signature-posture

This commit is contained in:
morluto committed 2026-10-08 15:49:33 +08:00
commit e3c15c8bbc
85 files changed
+6711 -105

No files matched your search

+66
View File
@@ -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
+1
View File
@@ -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) |
+1 -1
View File
@@ -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 والبحث عن الأصناف وقوائم الأعضاء وفك ترجمة الأساليب والمراجع الثابتة |
+14 -14
View File
@@ -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 バンドル、ナビゲーションの文脈、プロセス・成果物・関数の比較、未解決事項の記録 |
## ロードマップ
+20 -20
View File
@@ -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 앱 동작
+14 -14
View File
@@ -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 | 会话、证据包、导航上下文、进程/产物/函数比较与待解决问题跟踪 |
## 路线图
+75
View File
@@ -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`).
+6
View File
@@ -27,6 +27,7 @@ graph TD
subgraph "Provider Layers"
PWNTOOLS["src/native/pwntools/<br/>BYO offline ELF adapter<br/>owned Python process, unchanged upstream tables/heuristics"]
EVM["src/evm/<br/>unchanged EVMole/WASM in bounded owned worker<br/>explicit carrier parsing and inferred interface adapter"]
HOPPER["src/hopper/<br/>Hopper launch & Unix-socket protocol<br/>BridgeLauncher, HopperClient, protocol"]
GHIDRA["src/ghidra/<br/>Ghidra discovery, profile & function analysis<br/>snapshot, Unix/Windows transport, bounded lifecycle"]
IDA["src/ida/<br/>Upstream MCP contract adaptation<br/>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
+55
View File
@@ -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",
+84
View File
@@ -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.
+7 -28
View File
@@ -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 <app-or-mach-o>` 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 <app-or-mach-o>`
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 <app>` reads compiled
`Assets.car` metadata through macOS `assetutil --info`. Catalog digests, raw
rendition fields, pagination and exact UI resource-name matches are returned.
+22 -11
View File
@@ -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"
}
}
}
+20 -1
View File
@@ -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
@@ -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": [
+13
View File
@@ -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",
+7 -3
View File
@@ -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": {
+22 -2
View File
@@ -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 <stdio.h>
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"),
+12
View File
@@ -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 {}
}
+8 -1
View File
@@ -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,
+2
View File
@@ -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",
+1
View File
@@ -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",
+577
View File
@@ -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);
}
+3
View File
@@ -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 {
+209
View File
@@ -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,
};
}
+8 -3
View File
@@ -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
@@ -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.
+9
View File
@@ -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"
+4 -2
View File
@@ -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,
+15
View File
@@ -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<Result<AnalysisExecution, AnalysisError>>;
}
@@ -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" } });
});
@@ -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<Result<Evidence, AnalysisError>> {
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,
}),
);
}
}
+37
View File
@@ -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<Record<string, JsonValue>>,
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<Record<string, JsonValue>>,
options?: ExecutionOptions,
+2 -1
View File
@@ -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:
@@ -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<string, Promise<DylibTreeEntry | undefined>>();
readonly #images = new Map<string, Promise<MachoImageFacts>>();
readonly #identities = new Map<string, FileIdentity>();
constructor(
private readonly root: string,
private readonly signal?: AbortSignal,
) {}
entry(path: string): Promise<DylibTreeEntry | undefined> {
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<MachoImageFacts> {
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<DylibTreeEntry | undefined> {
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<DylibResolutionResult> => {
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<string, string>();
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<string[]> => {
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<boolean> => {
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<string> => {
const { signal } = options;
const handle = await open(
absolute,
constants.O_RDONLY | constants.O_NOFOLLOW,
);
const unchanged = async (): Promise<void> => {
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");
+152
View File
@@ -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<Uint8Array> =>
Promise.resolve(bytes.subarray(offset, offset + length));
@@ -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",
});
});
});
@@ -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<Uint8Array>;
/** 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<number, MachoDependency["command"]> =
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<MachoImageFacts> => {
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<MachoSlice[]> => {
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<MachoSlice> => {
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<Uint8Array> => {
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);
+2
View File
@@ -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);
+40
View File
@@ -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,
+37
View File
@@ -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<NodeJS.ProcessEnv>,
): 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);
}),
),
});
};
+2
View File
@@ -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",
+8
View File
@@ -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<NodeJS.ProcessEnv> = process.env,
): EvmInterfaceService =>
new EvmInterfaceService(new EvmoleInterfaceProvider(environment));
+10
View File
@@ -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<Record<string, Readonly<Record<string, unknown>>>> = {
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. */
+2
View File
@@ -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,
+2
View File
@@ -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"),
+26
View File
@@ -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[];
+2
View File
@@ -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,
+2
View File
@@ -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,
+6
View File
@@ -41,6 +41,11 @@ const sessionEvidence = effects({ mutatesSession: true });
/** Explicit effect audit for every public tool. */
export const TOOL_EFFECTS: Readonly<Record<string, ToolEffects>> = {
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<Record<string, ToolEffects>> = {
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,
+2
View File
@@ -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<Record<string, z.ZodObject>> = {
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. */
+1
View File
@@ -6,6 +6,7 @@ const ANALYSIS_ERROR_TAGS = [
"AnalysisArtifactChangedError",
"AnalysisOutputError",
"AnalysisCapabilityUnavailableError",
"AnalysisUnsupportedTargetError",
"AnalysisCancelledError",
"AnalysisTimeoutError",
"AnalysisResourceConstraintError",
+13
View File
@@ -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";
+6
View File
@@ -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,
@@ -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 }],
+10
View File
@@ -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<Record<string, JsonValue>> | 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"
+110
View File
@@ -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<PathLookup> => {
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<Record<string, readonly string[]>> = {
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;
};
+605
View File
@@ -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> = {}): 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> = {},
): 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<MachoSlice> = {}): MachoImageFacts =>
parsed(slice({ file_type: "execute", ...overrides }));
/** In-memory tree: files are images (or "data"), symlinks map to targets. */
const memoryView = (
files: Readonly<Record<string, MachoImageFacts | "data">>,
symlinks: Readonly<Record<string, string>> = {},
): DylibTreeView => {
const entries = new Map<string, DylibTreeEntry>();
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<ReturnType<typeof traceDylibLoading>>,
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,`);
});
});
+514
View File
@@ -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<typeof dependencySchema>;
export type MachoSlice = z.infer<typeof machoSliceSchema>;
/** 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<DylibTreeEntry | undefined>;
image(path: string): Promise<MachoImageFacts>;
}
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<typeof dylibResolutionResultSchema>;
type Candidate = z.infer<typeof candidateSchema>;
export type DylibEdge = z.infer<typeof edgeSchema>;
export type DylibFinding = z.infer<typeof findingSchema>;
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<string, LoadedImage>;
readonly byInstallName: Map<string, string>;
readonly images: Map<string, MachoImageFacts>;
}
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<Candidate> => {
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<MachoImageFacts> => {
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<Edge> => {
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<Edge[]> => {
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<DylibTrace> => {
const images = new Map<string, MachoImageFacts>();
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<MachoSlice, "dependencies"> => ({
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;
@@ -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<string, MachoImageFacts>,
): 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.",
];
+1
View File
@@ -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"
+36
View File
@@ -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);
},
);
+76
View File
@@ -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<typeof evmInterfaceSchema>;
+34
View File
@@ -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(),
);
});
+26
View File
@@ -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"));
};
+55
View File
@@ -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);
});
+58
View File
@@ -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<EvmWorkerLimits, string> => {
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 });
};
+205
View File
@@ -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,
});
+66
View File
@@ -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();
});
+59
View File
@@ -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,
})),
};
};
+401
View File
@@ -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<Parameters<typeof runOwnedCommand>[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<NodeJS.ProcessEnv> = process.env,
readonly launcher?: Launcher,
readonly createRuntime: () => Promise<
Pick<PrivateRuntimeRoot, "path" | "close">
> = () => 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<Result<AnalysisExecution, AnalysisError>> {
let root: Pick<PrivateRuntimeRoot, "path" | "close"> | 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<AnalysisExecution, AnalysisError>;
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<typeof replySchema>;
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;
}
}
+108
View File
@@ -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<void> => {
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();
+16
View File
@@ -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;
+1 -1
View File
@@ -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;
+13
View File
@@ -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(
+32
View File
@@ -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);
},
);
};
+3
View File
@@ -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,
@@ -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<Record<string, string | Uint8Array>>,
): Promise<void> => {
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<string> => {
const app = join(
await createTestTempDirectory("rea-dylib-cli-"),
"Fixture.app",
);
await writeFiles(app, {
"Contents/Info.plist":
"<plist><dict><key>CFBundleExecutable</key><string>App</string></dict></plist>",
"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<void> => {
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);
}
},
);
},
);
@@ -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,
@@ -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");
},
);
@@ -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<void>,
): Promise<void> => {
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"),
"<plist><dict><key>CFBundleExecutable</key><string>App</string></dict></plist>",
);
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, "<plist><dict/></plist>");
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"),
"<plist><dict><key>CFBundleExecutable</key><string>App</string></dict></plist>",
);
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);
}
});
},
);
@@ -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<string, unknown> = advertised.inputSchema;
const outputSchema: Record<string, unknown> = 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);
});
+44
View File
@@ -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 }],
},
);
+21
View File
@@ -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.
+22
View File
@@ -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.
+1 -1
View File
@@ -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,