diff --git a/.github/workflows/real-evm-interface.yml b/.github/workflows/real-evm-interface.yml
new file mode 100644
index 000000000..a49348181
--- /dev/null
+++ b/.github/workflows/real-evm-interface.yml
@@ -0,0 +1,66 @@
+name: Real offline EVM interface
+
+on:
+ workflow_dispatch:
+ pull_request:
+ paths:
+ - ".github/workflows/real-evm-interface.yml"
+ - "src/evm/**"
+ - "src/domain/evm/**"
+ - "src/application/evm/**"
+ - "src/contracts/evm/**"
+ - "src/composition/evm.ts"
+ - "src/cli/evmCommands.ts"
+ - "src/server/registerEvmTools.ts"
+ - "src/server/createServer.ts"
+ - "src/cli.ts"
+ - "src/cliCommandNames.ts"
+ - "src/contracts/toolContracts.ts"
+ - "src/contracts/toolEffects.ts"
+ - "src/process/OwnedCommand.ts"
+ - "src/process/ProviderProcess.ts"
+ - "src/process/ProcessOwnership*.ts"
+ - "src/process/ProviderDeadline.ts"
+ - "src/process/PrivateRuntimeRoot.ts"
+ - "src/artifacts/readStableArtifact*.ts"
+ - "src/artifacts/ArtifactReader.ts"
+ - "scripts/verify-evm-interface.mjs"
+ - "scripts/fixtures/evm-interface.sol"
+ - "third_party/evmole/**"
+ - "package.json"
+ - "package-lock.json"
+
+permissions:
+ contents: read
+
+concurrency:
+ group: real-evm-interface-${{ github.event.pull_request.number || github.ref }}
+ cancel-in-progress: true
+
+env:
+ NODE_OPTIONS: --max-old-space-size=768
+ HUSKY: "0"
+
+jobs:
+ real-evm-interface:
+ runs-on: ubuntu-24.04
+ timeout-minutes: 15
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version-file: .nvmrc
+ cache: npm
+ - run: npm ci
+ - name: Acquire unchanged fixture compiler in isolated runner storage
+ shell: bash
+ run: |
+ evm_fixture_root="${RUNNER_TEMP}/rea-evm-fixture-compiler"
+ mkdir -p "${evm_fixture_root}"
+ npm install --prefix "${evm_fixture_root}" --ignore-scripts --no-audit --no-fund --save-exact solc@0.8.30
+ command -v prlimit
+ command -v strace
+ echo "REA_VERIFY_STRACE_COMMAND=$(command -v strace)" >> "${GITHUB_ENV}"
+ echo "REA_VERIFY_SOLC_MODULE=${evm_fixture_root}/node_modules/solc/index.js" >> "${GITHUB_ENV}"
+ - name: Verify source-owned compiled bytecode through public CLI and MCP
+ run: npm run verify:evm:interface
diff --git a/README.md b/README.md
index e0a1090a3..348e18829 100644
--- a/README.md
+++ b/README.md
@@ -151,6 +151,7 @@ Additional tools and host support depend on the target:
| ---------------------- | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------- |
| Native binaries | Pseudocode, assembly, strings, symbols, calls and references | Hopper, Ghidra or IDA; [native analysis](https://morluto.github.io/rea/guides/native/) |
| Offline ELF layout | Sections, segments, original symbols/relocations and static mitigation candidates | Caller-supplied pwntools on Linux x64; [binary diagnostics](docs/binary-diagnostics.md) |
+| EVM bytecode | Dispatch selectors, byte offsets, inferred arguments and mutability | Local raw/hex carrier; [offline EVM guide](docs/evm-bytecode.md) |
| JavaScript / Electron | Modules, imports, source maps, routes, IPC and native add-on relationships | Node.js and npm; [application analysis](https://morluto.github.io/rea/guides/javascript/) |
| Websites | Page structure, scripts, network observations and requested screenshots | A Chrome-family browser; [browser analysis](https://morluto.github.io/rea/guides/browser/) |
| Saved network captures | Requests, responses, exposed payloads and source locations | HAR; mitmdump on Linux for native mitmproxy captures; [capture guide](docs/web-network-captures.md) |
diff --git a/README_ar.md b/README_ar.md
index 36f53fbfb..8bc08d594 100644
--- a/README_ar.md
+++ b/README_ar.md
@@ -218,7 +218,7 @@ rea setup
| الفحص الأصلي | 41 | الدوال والشيفرة شبه المصدرية والتعليمات والسلاسل والرموز والاستدعاءات والمراجع والتعليقات وقراءة البايتات ومواضع الملفات |
| سير عمل التحقيق | 14 | نظرات عامة على التطبيقات وملفات الدوال وواجهات API الأصلية والتوزيع وفك الترجمة الدفعي وتتبع الميزات ومسارات الاستدعاء ومخططاته واكتشاف Swift وObjective-C |
| أدوات الملفات الثنائية | 9 | بيانات Mach-O والتوقيعات وplist والمعماريات وأسماء Swift ومراقبة الاستدعاءات عبر LLDB على macOS؛ تخطيط ELF وأدلة الحماية الثابتة على Linux |
-| مخطط المخرجات | 6 | جرد الدلائل والحزم وملفات Interface Builder المترجمة وكتالوجات موارد Apple وحل تحميل مكتبات Mach-O الديناميكية والاستخراج |
+| مخطط المخرجات | 7 | جرد الدلائل والحزم وملفات Interface Builder المترجمة وكتالوجات موارد Apple وتحليل تحميل Mach-O dylib والاستخراج واستدلال واجهات EVM دون اتصال |
| PE/CLI المُدار | 7 | هوية تجميعات .NET وبياناتها الوصفية وتعليمات CIL والاعتماديات الأصلية واستيراد إعادة البناء ومقارنة الإصدارات |
| البرامج الثابتة | 2 | فحص مناطق البرامج الثابتة على Linux واستخراجها الصريح |
| Android APK | 5 | بيانات الحزمة وmanifest والبحث عن الأصناف وقوائم الأعضاء وفك ترجمة الأساليب والمراجع الثابتة |
diff --git a/README_ja.md b/README_ja.md
index b64b7f590..85fd9ba55 100644
--- a/README_ja.md
+++ b/README_ja.md
@@ -236,20 +236,20 @@ TypeScript と SQLite を使って私のプロジェクト向けに実装して
## 調査ツールカタログ
-| ツール分類 | 数 | 用途 |
-| -------------------- | --: | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| ネイティブ検査 | 41 | 関数、疑似コード、アセンブリ、文字列、シンボル、呼び出し、参照、注釈、バイト読み取り、ファイルオフセット |
-| 調査ワークフロー | 14 | アプリ概要、関数ドシエ、ネイティブ API とディスパッチ、一括逆コンパイル、機能トレース、コールパス、コールグラフ、Swift と Objective-C の検出 |
-| ネイティブバイナリ | 9 | macOS の Mach-O メタデータ、署名、plist、アーキテクチャ、Swift シンボル、LLDB による呼び出し観測;Linux の ELF レイアウトと静的な保護機構の証拠 |
-| 成果物グラフ | 6 | ディレクトリとパッケージの目録、コンパイル済み Interface Builder ファイル、Apple アセットカタログ、Mach-O dylib 読み込みの解決、抽出 |
-| マネージド PE/CLI | 7 | .NET の識別情報、メタデータ、CIL 命令、ネイティブ依存関係、再構築結果のインポート、ビルド比較 |
-| ファームウェア | 2 | Linux ファームウェア領域の検査と明示的な展開 |
-| Android APK | 5 | パッケージと manifest の宣言、クラス検索、メンバー一覧、メソッドの逆コンパイル、静的な被参照 |
-| ブラウザー観察 | 12 | ページ構成、ネットワークメタデータ、スクリプト、ソースマップ、WebMCP の検出、スクリーンショット、キャプチャ比較 |
-| Electron 解析 | 5 | レンダラーの観察、静的なアプリのマッピング、静的結果と実行時結果の照合 |
-| JavaScript 実行時 | 2 | Node/Electron Inspector の対象検出、スクリプトの位置、実行コンテキストのイベント |
-| アプリワークフロー | 13 | 取得した Web スクリプトのエクスポート、Android/Apple の目録の投影、層をまたぐ機能トレース、ビルド比較、過去のソースとの対応付け、静的な戻り値形状の比較、再構築の検証 |
-| ワークスペースと観察 | 21 | セッション、Evidence バンドル、ナビゲーションの文脈、プロセス・成果物・関数の比較、未解決事項の記録 |
+| ツール分類 | 数 | 用途 |
+| -------------------- | --: | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| ネイティブ検査 | 41 | 関数、疑似コード、アセンブリ、文字列、シンボル、呼び出し、参照、注釈、バイト読み取り、ファイルオフセット |
+| 調査ワークフロー | 14 | アプリ概要、関数ドシエ、ネイティブ API とディスパッチ、一括逆コンパイル、機能トレース、コールパス、コールグラフ、Swift と Objective-C の検出 |
+| ネイティブバイナリ | 9 | macOS の Mach-O メタデータ、署名、plist、アーキテクチャ、Swift シンボル、LLDB による呼び出し観測;Linux の ELF レイアウトと静的な保護機構の証拠 |
+| 成果物グラフ | 7 | ディレクトリとパッケージの目録、コンパイル済み Interface Builder ファイル、Apple アセットカタログ、Mach-O dylib のロード解決、抽出、オフライン EVM インターフェース推定 |
+| マネージド PE/CLI | 7 | .NET の識別情報、メタデータ、CIL 命令、ネイティブ依存関係、再構築結果のインポート、ビルド比較 |
+| ファームウェア | 2 | Linux ファームウェア領域の検査と明示的な展開 |
+| Android APK | 5 | パッケージと manifest の宣言、クラス検索、メンバー一覧、メソッドの逆コンパイル、静的な被参照 |
+| ブラウザー観察 | 12 | ページ構成、ネットワークメタデータ、スクリプト、ソースマップ、WebMCP の検出、スクリーンショット、キャプチャ比較 |
+| Electron 解析 | 5 | レンダラーの観察、静的なアプリのマッピング、静的結果と実行時結果の照合 |
+| JavaScript 実行時 | 2 | Node/Electron Inspector の対象検出、スクリプトの位置、実行コンテキストのイベント |
+| アプリワークフロー | 13 | 取得した Web スクリプトのエクスポート、Android/Apple の目録の投影、層をまたぐ機能トレース、ビルド比較、過去のソースとの対応付け、静的な戻り値形状の比較、再構築の検証 |
+| ワークスペースと観察 | 21 | セッション、Evidence バンドル、ナビゲーションの文脈、プロセス・成果物・関数の比較、未解決事項の記録 |
## ロードマップ
diff --git a/README_ko.md b/README_ko.md
index 6de6870d7..3eb11722e 100644
--- a/README_ko.md
+++ b/README_ko.md
@@ -203,20 +203,20 @@ REA는 1~5단계의 바이너리 분석을 처리합니다. 6단계는 에이전
## 조사 도구 카탈로그
-| 도구 분류 | 수 | 용도 |
-| ----------------- | --: | ------------------------------------------------------------------------------------------------------------------------- |
-| 네이티브 검사 | 41 | 함수, 의사 코드, 어셈블리, 문자열, 심볼, 호출, 참조, 주석, 바이트 읽기, 파일 오프셋 |
-| 조사 워크플로 | 14 | 앱 개요, 함수 기록서, 네이티브 API와 디스패치, 일괄 디컴파일, 기능 추적, 호출 경로, 호출 그래프, Swift와 Objective-C 탐색 |
-| 네이티브 바이너리 | 9 | macOS의 Mach-O 메타데이터, 서명, plist, 아키텍처, Swift 심볼과 LLDB 호출 관찰; Linux의 ELF 레이아웃과 정적 보호 증거 |
-| 아티팩트 그래프 | 6 | 디렉터리와 패키지 목록, 컴파일된 Interface Builder 파일, Apple 애셋 카탈로그, Mach-O dylib 로드 해석, 추출 |
-| 관리 PE/CLI | 7 | .NET 식별, 메타데이터, CIL 명령어, 네이티브 종속성, 재구성 가져오기, 빌드 비교 |
-| 펌웨어 | 2 | Linux 펌웨어 영역 검사 및 명시적 추출 |
-| Android APK | 5 | 패키지와 manifest 선언, 클래스 검색, 멤버 목록, 메서드 디컴파일, 정적 참조 |
-| 브라우저 관찰 | 12 | 페이지 구조, 네트워크 메타데이터, 스크립트, 소스 맵, WebMCP 탐색, 스크린샷, 캡처 비교 |
-| Electron 분석 | 5 | 렌더러 관찰, 정적 앱 매핑, 정적·런타임 결과 연결 |
-| JavaScript 런타임 | 2 | Node/Electron Inspector 대상 탐색, 스크립트 위치, 실행 컨텍스트 이벤트 |
-| 앱 워크플로 | 13 | 캡처한 웹 스크립트 내보내기, 계층 간 기능 추적, 빌드 비교, 히스토리 소스 매핑, 정적 반환 구조 비교, 재구현 검증 |
-| 작업 공간과 관찰 | 21 | 세션, 근거 번들, 탐색 컨텍스트, 프로세스·아티팩트·함수 비교, 미해결 항목 기록 |
+| 도구 분류 | 수 | 용도 |
+| ----------------- | --: | ---------------------------------------------------------------------------------------------------------------------------------------- |
+| 네이티브 검사 | 41 | 함수, 의사 코드, 어셈블리, 문자열, 심볼, 호출, 참조, 주석, 바이트 읽기, 파일 오프셋 |
+| 조사 워크플로 | 14 | 앱 개요, 함수 기록서, 네이티브 API와 디스패치, 일괄 디컴파일, 기능 추적, 호출 경로, 호출 그래프, Swift와 Objective-C 탐색 |
+| 네이티브 바이너리 | 9 | macOS의 Mach-O 메타데이터, 서명, plist, 아키텍처, Swift 심볼과 LLDB 호출 관찰; Linux의 ELF 레이아웃과 정적 보호 증거 |
+| 아티팩트 그래프 | 7 | 디렉터리와 패키지 목록, 컴파일된 Interface Builder 파일, Apple 애셋 카탈로그, Mach-O dylib 로드 해석, 추출, 오프라인 EVM 인터페이스 추론 |
+| 관리 PE/CLI | 7 | .NET 식별, 메타데이터, CIL 명령어, 네이티브 종속성, 재구성 가져오기, 빌드 비교 |
+| 펌웨어 | 2 | Linux 펌웨어 영역 검사 및 명시적 추출 |
+| Android APK | 5 | 패키지와 manifest 선언, 클래스 검색, 멤버 목록, 메서드 디컴파일, 정적 참조 |
+| 브라우저 관찰 | 12 | 페이지 구조, 네트워크 메타데이터, 스크립트, 소스 맵, WebMCP 탐색, 스크린샷, 캡처 비교 |
+| Electron 분석 | 5 | 렌더러 관찰, 정적 앱 매핑, 정적·런타임 결과 연결 |
+| JavaScript 런타임 | 2 | Node/Electron Inspector 대상 탐색, 스크립트 위치, 실행 컨텍스트 이벤트 |
+| 앱 워크플로 | 13 | 캡처한 웹 스크립트 내보내기, 계층 간 기능 추적, 빌드 비교, 히스토리 소스 매핑, 정적 반환 구조 비교, 재구현 검증 |
+| 작업 공간과 관찰 | 21 | 세션, 근거 번들, 탐색 컨텍스트, 프로세스·아티팩트·함수 비교, 미해결 항목 기록 |
## 로드맵
diff --git a/README_zh.md b/README_zh.md
index edfe0f65d..9b187b5ce 100644
--- a/README_zh.md
+++ b/README_zh.md
@@ -202,20 +202,20 @@ REA 负责第 1–5 步中的二进制分析。第 6 步由智能体使用其常
## 调查工具目录
-| 工具类别 | 数量 | 用途 |
-| ----------------- | ---: | ------------------------------------------------------------------------------------------------------ |
-| 原生检查 | 41 | 函数、伪代码、汇编、字符串、符号、调用、引用、注释、字节读取与文件偏移 |
-| 调查工作流 | 14 | 应用概览、函数档案、原生 API 与分发、批量反编译、功能追踪、调用路径与调用图、Swift 与 Objective-C 发现 |
-| 原生二进制工具 | 9 | macOS 的 Mach-O 元数据、签名、plist、架构、Swift 符号与 LLDB 调用观察;Linux 的 ELF 布局和静态保护证据 |
-| 产物图 | 6 | 目录与软件包清单、编译后的 Interface Builder 文件、Apple 资源目录、Mach-O dylib 加载解析与提取 |
-| 托管 PE/CLI | 7 | .NET 程序集身份、元数据、CIL 指令、原生依赖声明、重建导入与构建比较 |
-| 固件 | 2 | Linux 固件区域检查与显式提取 |
-| Android APK | 5 | 包与 manifest 声明、类搜索、成员清单、方法反编译与静态引用 |
-| 浏览器观察 | 12 | 页面结构、网络元数据、脚本、来源映射、WebMCP 发现、截图与捕获比较 |
-| Electron 分析 | 5 | 渲染进程观察、静态应用映射、静态/运行时结果关联 |
-| JavaScript 运行时 | 2 | Node/Electron Inspector 目标发现、脚本位置与执行上下文事件 |
-| 应用工作流 | 13 | 捕获的网站脚本导出、跨层功能追踪、构建比较、历史源码映射、静态返回结构比较与重建验证 |
-| 工作区与观察 | 21 | 会话、证据包、导航上下文、进程/产物/函数比较与待解决问题跟踪 |
+| 工具类别 | 数量 | 用途 |
+| ----------------- | ---: | ----------------------------------------------------------------------------------------------------------------- |
+| 原生检查 | 41 | 函数、伪代码、汇编、字符串、符号、调用、引用、注释、字节读取与文件偏移 |
+| 调查工作流 | 14 | 应用概览、函数档案、原生 API 与分发、批量反编译、功能追踪、调用路径与调用图、Swift 与 Objective-C 发现 |
+| 原生二进制工具 | 9 | macOS 的 Mach-O 元数据、签名、plist、架构、Swift 符号与 LLDB 调用观察;Linux 的 ELF 布局和静态保护证据 |
+| 产物图 | 7 | 目录与软件包清单、编译后的 Interface Builder 文件、Apple 资源目录、Mach-O dylib 加载解析、提取与离线 EVM 接口推断 |
+| 托管 PE/CLI | 7 | .NET 程序集身份、元数据、CIL 指令、原生依赖声明、重建导入与构建比较 |
+| 固件 | 2 | Linux 固件区域检查与显式提取 |
+| Android APK | 5 | 包与 manifest 声明、类搜索、成员清单、方法反编译与静态引用 |
+| 浏览器观察 | 12 | 页面结构、网络元数据、脚本、来源映射、WebMCP 发现、截图与捕获比较 |
+| Electron 分析 | 5 | 渲染进程观察、静态应用映射、静态/运行时结果关联 |
+| JavaScript 运行时 | 2 | Node/Electron Inspector 目标发现、脚本位置与执行上下文事件 |
+| 应用工作流 | 13 | 捕获的网站脚本导出、跨层功能追踪、构建比较、历史源码映射、静态返回结构比较与重建验证 |
+| 工作区与观察 | 21 | 会话、证据包、导航上下文、进程/产物/函数比较与待解决问题跟踪 |
## 路线图
diff --git a/docs/architecture.mermaid b/docs/architecture.mermaid
index 2bad907aa..8bbb9a147 100644
--- a/docs/architecture.mermaid
+++ b/docs/architecture.mermaid
@@ -27,6 +27,7 @@ graph TD
subgraph "Provider Layers"
PWNTOOLS["src/native/pwntools/
BYO offline ELF adapter
owned Python process, unchanged upstream tables/heuristics"]
+ EVM["src/evm/
unchanged EVMole/WASM in bounded owned worker
explicit carrier parsing and inferred interface adapter"]
HOPPER["src/hopper/
Hopper launch & Unix-socket protocol
BridgeLauncher, HopperClient, protocol"]
GHIDRA["src/ghidra/
Ghidra discovery, profile & function analysis
snapshot, Unix/Windows transport, bounded lifecycle"]
IDA["src/ida/
Upstream MCP contract adaptation
live GUI binding or owned headless database"]
@@ -144,3 +145,8 @@ graph TD
TESTS -.-> BROWSER
TESTS -.-> EVALUATION
TESTS -.-> ERRORS
+
+ COMPOSITION --> EVM
+ APP -->|EVM interface port| EVM
+ EVM --> PROCESS
+ EVM --> DOMAIN
diff --git a/docs/error-contract.schema.json b/docs/error-contract.schema.json
index 11f0fc659..b79e8cee3 100644
--- a/docs/error-contract.schema.json
+++ b/docs/error-contract.schema.json
@@ -10,6 +10,7 @@
"enum": [
"invalid_input",
"unsupported_provider",
+ "unsupported_target",
"integrity_mismatch",
"truncated",
"cancelled",
@@ -45,6 +46,7 @@
"enum": [
"invalid_input",
"unsupported_provider",
+ "unsupported_target",
"integrity_mismatch",
"truncated",
"cancelled",
@@ -80,6 +82,7 @@
"enum": [
"invalid_input",
"unsupported_provider",
+ "unsupported_target",
"integrity_mismatch",
"truncated",
"cancelled",
@@ -115,6 +118,7 @@
"enum": [
"invalid_input",
"unsupported_provider",
+ "unsupported_target",
"integrity_mismatch",
"truncated",
"cancelled",
@@ -150,6 +154,43 @@
"enum": [
"invalid_input",
"unsupported_provider",
+ "unsupported_target",
+ "integrity_mismatch",
+ "truncated",
+ "cancelled",
+ "timeout",
+ "resource_constraint",
+ "unavailable",
+ "execution_failure"
+ ]
+ },
+ "message": { "type": "string", "minLength": 1 },
+ "retryable": { "type": "boolean" },
+ "remediation": {
+ "type": "object",
+ "properties": { "action": { "type": "string", "minLength": 1 } },
+ "required": ["action"],
+ "additionalProperties": false
+ },
+ "details": {
+ "type": "object",
+ "propertyNames": { "type": "string" },
+ "additionalProperties": {}
+ }
+ },
+ "required": ["code", "category", "message", "retryable", "remediation"],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "code": { "type": "string", "const": "unsupported_target" },
+ "category": {
+ "type": "string",
+ "enum": [
+ "invalid_input",
+ "unsupported_provider",
+ "unsupported_target",
"integrity_mismatch",
"truncated",
"cancelled",
@@ -185,6 +226,7 @@
"enum": [
"invalid_input",
"unsupported_provider",
+ "unsupported_target",
"integrity_mismatch",
"truncated",
"cancelled",
@@ -220,6 +262,7 @@
"enum": [
"invalid_input",
"unsupported_provider",
+ "unsupported_target",
"integrity_mismatch",
"truncated",
"cancelled",
@@ -255,6 +298,7 @@
"enum": [
"invalid_input",
"unsupported_provider",
+ "unsupported_target",
"integrity_mismatch",
"truncated",
"cancelled",
@@ -290,6 +334,7 @@
"enum": [
"invalid_input",
"unsupported_provider",
+ "unsupported_target",
"integrity_mismatch",
"truncated",
"cancelled",
@@ -325,6 +370,7 @@
"enum": [
"invalid_input",
"unsupported_provider",
+ "unsupported_target",
"integrity_mismatch",
"truncated",
"cancelled",
@@ -382,6 +428,7 @@
"enum": [
"invalid_input",
"unsupported_provider",
+ "unsupported_target",
"integrity_mismatch",
"truncated",
"cancelled",
@@ -417,6 +464,7 @@
"enum": [
"invalid_input",
"unsupported_provider",
+ "unsupported_target",
"integrity_mismatch",
"truncated",
"cancelled",
@@ -452,6 +500,7 @@
"enum": [
"invalid_input",
"unsupported_provider",
+ "unsupported_target",
"integrity_mismatch",
"truncated",
"cancelled",
@@ -487,6 +536,7 @@
"enum": [
"invalid_input",
"unsupported_provider",
+ "unsupported_target",
"integrity_mismatch",
"truncated",
"cancelled",
@@ -522,6 +572,7 @@
"enum": [
"invalid_input",
"unsupported_provider",
+ "unsupported_target",
"integrity_mismatch",
"truncated",
"cancelled",
@@ -557,6 +608,7 @@
"enum": [
"invalid_input",
"unsupported_provider",
+ "unsupported_target",
"integrity_mismatch",
"truncated",
"cancelled",
@@ -592,6 +644,7 @@
"enum": [
"invalid_input",
"unsupported_provider",
+ "unsupported_target",
"integrity_mismatch",
"truncated",
"cancelled",
@@ -627,6 +680,7 @@
"enum": [
"invalid_input",
"unsupported_provider",
+ "unsupported_target",
"integrity_mismatch",
"truncated",
"cancelled",
@@ -662,6 +716,7 @@
"enum": [
"invalid_input",
"unsupported_provider",
+ "unsupported_target",
"integrity_mismatch",
"truncated",
"cancelled",
diff --git a/docs/evm-bytecode.md b/docs/evm-bytecode.md
new file mode 100644
index 000000000..5c0c67ae6
--- /dev/null
+++ b/docs/evm-bytecode.md
@@ -0,0 +1,84 @@
+# Offline EVM bytecode
+
+`inspect_evm_interface` / `inspect-evm-interface` infers interface candidates
+from one explicitly selected local bytecode carrier. The capability is general:
+contract investigation, compatibility, audit and CTF showcases use the same
+portable primitive. It does not require an active native binary target.
+
+```sh
+rea inspect-evm-interface ./runtime.hex hex --json
+rea inspect-evm-interface ./runtime.bin raw --json
+```
+
+```json
+{
+ "name": "inspect_evm_interface",
+ "arguments": { "path": "/artifacts/runtime.hex", "encoding": "hex" }
+}
+```
+
+Initial real verification covers flat legacy bytecode on Linux x64 and unchanged
+bundled EVMole 0.9.3. EOF-style EF00 containers return `unsupported_target`,
+with the selected path and format reason. Provider/configuration failures retain
+their distinct unavailable-provider or capability classification.
+The host must already provide util-linux `prlimit`, normally `/usr/bin/prlimit`;
+`REA_EVM_PRLIMIT_COMMAND` can select another absolute executable. REA installs
+no system tools and changes no user configuration. [Upstream provenance and
+license](../third_party/evmole/README.md) are packaged.
+
+## Evidence semantics
+
+- `encoding` is required: raw bytes or UTF-8 hex, with optional `0x` prefix and
+ outer ASCII whitespace. Odd hex, interior separators, BOM, non-ASCII whitespace
+ and invalid UTF-8 are rejected. Empty bytecode is valid and has unknown coverage.
+- Carrier SHA-256 identifies the exact original file, including prefix/casing
+ and whitespace. Decoded-byte SHA-256 identifies actual selected EVM bytes.
+ Neither digest is the Ethereum Keccak code hash.
+- Full bytecode, upstream function offsets and original producer representation
+ are returned inline. Offsets are byte positions in decoded bytecode.
+- Upstream also returns terminal metadata during selector recovery. Its reported
+ values remain in `raw_result`; integer precision and non-text-key coverage are
+ unknown in the JS binding. Full bytecode retains the original encoded metadata.
+- Selectors, argument strings and state mutability are **inferences**. Function
+ names/signatures are not looked up. Optimized code may produce different
+ mutability candidates; recovered types are not a verified source ABI.
+- Runtime/init-code identity, deployed authenticity, hardfork and discovery
+ completeness remain unknown. Empty candidates do not prove no callable interface.
+- Analysis is offline: no target runtime execution, implicit chain/RPC/explorer
+ lookup, wallet action, transaction broadcast or network request.
+
+## Resource and lifecycle limits
+
+Inputs are limited to 4 MiB, complete replies to 16 MiB and combined diagnostic
+output to 1 MiB. An owned worker has a 256 MiB JavaScript heap, 3 GiB virtual
+address-space limit, 30 CPU seconds and a 30-second command deadline. Virtual
+address space differs from resident memory. The worker retains inherited tighter
+soft/hard limits, reports its configured soft limits inline and leaves hard limits
+unchanged. WASM trap reservations are disabled
+so the engine can run within that virtual limit; actual memory is not equated to
+its 3 GiB allowance. Limits fail without partial success. Stable snapshots,
+private files and owned processes are cleaned independently of caller cancellation.
+Invalid-input, unsupported, cancellation, timeout and malformed-reply failures
+retain bounded stdout/stderr under `details.captured_output`, including whether
+diagnostic output was truncated. Cleanup failures preserve the
+original error projection and any captured output.
+Successful results expose `diagnostics.truncated` from the supervisor as well.
+Excess diagnostic output fails the complete-output contract even when the worker
+has written a valid reply.
+SIGXCPU retains a CPU resource diagnostic and CPU-specific guidance. Observed
+file-size write failures (EFBIG) and SIGXFSZ likewise retain a file-size resource
+diagnostic. Exact signal/write causes remain unknown. Configured
+soft limits are reported separately from unknown effective limits; the received
+signal alone does not establish its exact cause.
+Reserved file-size exits require a matching private marker written by the
+worker's EFBIG branch. Bare launcher exits or unwritable markers retain process
+diagnostics with the resource cause unverified.
+
+Source/metadata, proxy resolution, storage/CFG and fork-explicit execution/traces
+remain separate increments under #972, with materially different result or
+execution authority. This primitive makes no deployment or runtime behavior claim.
+
+The real verification lane traces representative raw/hex CLI workflows and checks
+that only declared Node/prlimit launchers and process ownership inspection run.
+Socket traces permit local stdio socket pairs and reject attempted network requests.
+These observations verify those fixtures; they are not a general network sandbox.
diff --git a/docs/product-catalog.json b/docs/product-catalog.json
index e9cba6f56..cc1d22510 100644
--- a/docs/product-catalog.json
+++ b/docs/product-catalog.json
@@ -7,10 +7,10 @@
"client": "2.3.1",
"core": "2.3.1"
},
- "skill_version": "31"
+ "skill_version": "32"
},
"tools": {
- "total": 137,
+ "total": 138,
"families": [
{
"id": "direct",
@@ -100,12 +100,13 @@
{
"id": "artifact",
"surface": "artifact-provider",
- "count": 6,
+ "count": 7,
"tools": [
"decode_interface_builder",
"extract_artifact",
"inspect_artifact",
"inspect_asset_catalog",
+ "inspect_evm_interface",
"inspect_keyed_archive",
"trace_dylib_resolution"
]
@@ -239,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",
@@ -594,7 +601,7 @@
}
],
"cli": {
- "primary_count": 92,
+ "primary_count": 93,
"commands": [
"address-to-file-offset",
"analyze",
@@ -638,6 +645,7 @@
"inspect-asset-catalog",
"inspect-binary-layout",
"inspect-electron-page",
+ "inspect-evm-interface",
"inspect-firmware-regions",
"inspect-keyed-archive",
"inspect-macho",
@@ -698,15 +706,15 @@
},
"runtime_catalog": {
"counts": {
- "cli_commands": 92,
- "mcp_tools": 137,
+ "cli_commands": 93,
+ "mcp_tools": 138,
"mcp_prompts": 6
},
"digests": {
- "tools_sha256": "1f9455a546d76d63e9ceba7bfdc6045f6abde8ead8492c55bd0cdfa726061a91",
+ "tools_sha256": "61b501f81acddb52ea80408c936ceec19b73e08331f8fdd05eb503708373e0fa",
"prompts_sha256": "ef2de8814390b340623c32001610aa2709d90bed51537b6e562ab3abf144ab4d",
- "combined_sha256": "ce23d159fbfc07b338a5e2d51569a71597842f0cf3a3c09986c799512752ab02",
- "providers_sha256": "6a96ebafaa7c9284661db6c2036524dcba0407e85278c6bb5ac12bbad0d85a6f"
+ "combined_sha256": "8ed2bd4b71684e3e391f909841edf32b68da20eede1f46f0dd6081e235644260",
+ "providers_sha256": "44935c127fd040af492f2da591dfcd782d766770d306b91242216674220047ed"
}
}
}
diff --git a/docs/testing.md b/docs/testing.md
index 882a7fbcc..1316ffd26 100644
--- a/docs/testing.md
+++ b/docs/testing.md
@@ -643,6 +643,16 @@ the ordinary lane. Its large MCP transfer is opt-in with
explicit 256 MiB SDK receive buffer and five-minute request timeout. Ordinary
MCP fixtures retain the pinned SDK defaults.
+### Offline EVM interface
+
+`npm run verify:evm:interface` requires Linux x64, an absolute
+`REA_VERIFY_STRACE_COMMAND`, caller-supplied util-linux `prlimit` and `REA_VERIFY_SOLC_MODULE` selecting the absolute module path for
+solc 0.8.30. It compiles source-owned plain/optimized/via-IR Cancun fixtures in
+private storage and checks actual CLI/MCP selector evidence, raw/hex identity,
+unknowns, malformed carriers and independent cleanup. An optional positional
+entrypoint verifies a fresh installed package. It acquires no engine, compiler
+or chain dependency and does not execute a contract on a chain.
+
## Agent evaluation and conformance records
Evaluate native, JavaScript, managed and browser investigation tasks through a
diff --git a/docs/verification/managed-conformance-manifest.json b/docs/verification/managed-conformance-manifest.json
index c7088bfeb..5dd871cfd 100644
--- a/docs/verification/managed-conformance-manifest.json
+++ b/docs/verification/managed-conformance-manifest.json
@@ -1,5 +1,5 @@
{
- "manifest_id": "ecm_2821fc2b686037a57ed7e5847a3927cc95b598d222a54983b34d87402e18bd30",
+ "manifest_id": "ecm_366b46bcafd1815cf76b4b9dd1799e2230a32692a5d1ebb5de33d1673f7a9264",
"verifier": {
"id": "rea-managed-conformance",
"version": "1"
@@ -13,7 +13,7 @@
"skill_digests": [
{
"skill_id": "reverse-engineer-anything",
- "sha256": "21157bdb015b42cf2fafc15fe150605ca89cc564761bb24e87b5bd6a00da7f19"
+ "sha256": "2428bbffffdabfe27409cb1f61a4acf062aa11545629f1a1df4dc72c9db364a8"
}
],
"claims": [
diff --git a/package-lock.json b/package-lock.json
index 1bb44c60c..cd3225dab 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -25,6 +25,7 @@
"canonicalize": "3.0.0",
"cross-spawn": "7.0.6",
"diff": "9.0.0",
+ "evmole": "0.9.3",
"har-schema": "2.0.0",
"ignore": "7.0.6",
"incur": "0.6.1",
@@ -4643,6 +4644,18 @@
"node": ">=18.0.0"
}
},
+ "node_modules/evmole": {
+ "version": "0.9.3",
+ "resolved": "https://registry.npmjs.org/evmole/-/evmole-0.9.3.tgz",
+ "integrity": "sha512-UhO5HMAoEPCupSxvBJPlm/L0Qk/KVJ/OJo3r4EyhXADIEHqU5aTkAzsBfzk4lXGjqxVnaYbh1GPSmZtmSHFnQQ==",
+ "license": "MIT",
+ "bin": {
+ "evmole": "dist/evmole_cli.mjs"
+ },
+ "engines": {
+ "node": ">=22"
+ }
+ },
"node_modules/expect-type": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
diff --git a/package.json b/package.json
index 3c70e52a0..ea92d1402 100644
--- a/package.json
+++ b/package.json
@@ -66,7 +66,9 @@
"scripts/verify-windows-native-artifact.mjs",
"bridge/pwntools/layout.py",
"bridge/pwntools/layout_impl.py",
- "third_party/pwntools/README.md"
+ "third_party/pwntools/README.md",
+ "third_party/evmole/README.md",
+ "third_party/evmole/LICENSE"
],
"scripts": {
"deps:check": "node scripts/check-dependency-install.mjs",
@@ -199,7 +201,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}": [
@@ -239,7 +242,8 @@
"smol-toml": "1.9.0",
"write-file-atomic": "7.0.0",
"ws": "8.21.0",
- "zod": "4.4.3"
+ "zod": "4.4.3",
+ "evmole": "0.9.3"
},
"overrides": {
"vitepress": {
diff --git a/scripts/fixtures/evm-interface.sol b/scripts/fixtures/evm-interface.sol
new file mode 100644
index 000000000..396479172
--- /dev/null
+++ b/scripts/fixtures/evm-interface.sol
@@ -0,0 +1,12 @@
+// SPDX-License-Identifier: MIT
+pragma solidity 0.8.30;
+
+// Public source-owned fixture; generated runtime bytecode is never tracked.
+contract InterfaceFixture {
+ uint256 public stored;
+ function set(uint256 value) external { stored = value; }
+ function read() external view returns (uint256) { return stored; }
+ function pair(uint32 a, address b) external pure returns (uint32, address) { return (a, b); }
+ receive() external payable {}
+ fallback() external {}
+}
diff --git a/scripts/lib/catalog-builders.mjs b/scripts/lib/catalog-builders.mjs
index efe58e26c..8049261cd 100644
--- a/scripts/lib/catalog-builders.mjs
+++ b/scripts/lib/catalog-builders.mjs
@@ -24,7 +24,10 @@ export const toolFamilyCatalog = (sources) => {
{
id: "artifact",
surface: "artifact-provider",
- contracts: sources.artifactContracts.ARTIFACT_TOOL_CONTRACTS,
+ contracts: [
+ ...sources.artifactContracts.ARTIFACT_TOOL_CONTRACTS,
+ ...sources.evmContracts.EVM_TOOL_CONTRACTS,
+ ],
},
{
id: "managed",
@@ -151,6 +154,10 @@ export const providerCatalog = (sources) => {
contracts:
sources.binaryDiagnosticsContracts.BINARY_DIAGNOSTICS_TOOL_CONTRACTS,
},
+ {
+ identity: sources.evmProvider.EVMOLE_PROVIDER_IDENTITY,
+ contracts: sources.evmContracts.EVM_TOOL_CONTRACTS,
+ },
{
identity: sources.artifactProviders.ARTIFACT_GRAPH_PROVIDER,
contracts: sources.artifactContracts.ARTIFACT_TOOL_CONTRACTS,
diff --git a/scripts/lib/catalog-core.mjs b/scripts/lib/catalog-core.mjs
index 178702f1c..9dcfdbb18 100644
--- a/scripts/lib/catalog-core.mjs
+++ b/scripts/lib/catalog-core.mjs
@@ -44,6 +44,8 @@ const SOURCE_PATHS = {
"dist/contracts/native/binaryDiagnosticsToolContracts.js",
pwntoolsProvider: "dist/native/pwntools/PwntoolsRelease.js",
nativeContracts: "dist/contracts/native/nativeToolContracts.js",
+ evmContracts: "dist/contracts/evm/evmToolContracts.js",
+ evmProvider: "dist/evm/EvmoleRelease.js",
artifactContracts: "dist/contracts/artifactToolContracts.js",
managedContracts: "dist/contracts/managed/managedToolContracts.js",
firmwareContracts: "dist/contracts/firmware/firmwareToolContracts.js",
diff --git a/scripts/lib/module-boundaries.mjs b/scripts/lib/module-boundaries.mjs
index b50050d79..d52a651f5 100644
--- a/scripts/lib/module-boundaries.mjs
+++ b/scripts/lib/module-boundaries.mjs
@@ -5,6 +5,7 @@ import { fileURLToPath, pathToFileURL } from "node:url";
const PURE_LAYERS = new Set(["domain", "contracts"]);
const MIGRATED_PROVIDER_ROOTS = new Set([
+ "evm",
"android",
"firmware",
"ghidra",
diff --git a/scripts/verify-evm-interface.mjs b/scripts/verify-evm-interface.mjs
new file mode 100644
index 000000000..39cb2b356
--- /dev/null
+++ b/scripts/verify-evm-interface.mjs
@@ -0,0 +1,577 @@
+#!/usr/bin/env node
+import assert from "node:assert/strict";
+import { execFile } from "node:child_process";
+import { createHash } from "node:crypto";
+import { access, readFile, readdir, writeFile } from "node:fs/promises";
+import { constants } from "node:fs";
+import { isAbsolute, join } from "node:path";
+import { fileURLToPath, pathToFileURL } from "node:url";
+import { promisify } from "node:util";
+import { Client } from "@modelcontextprotocol/client";
+import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
+import { PrivateRuntimeRoot } from "../dist/process/PrivateRuntimeRoot.js";
+import { parseEvidence } from "../dist/domain/evidence.js";
+import { mcpTextValue } from "./lib/mcp-verifier-results.mjs";
+import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs";
+
+const solcModule = process.env.REA_VERIFY_SOLC_MODULE;
+const strace = process.env.REA_VERIFY_STRACE_COMMAND;
+if (
+ process.platform !== "linux" ||
+ process.arch !== "x64" ||
+ !isAbsolute(solcModule ?? "") ||
+ !isAbsolute(strace ?? "")
+)
+ throw new Error(
+ "verify:evm:interface requires Linux x64, caller-supplied util-linux prlimit and absolute REA_VERIFY_SOLC_MODULE pointing to unchanged solc 0.8.30 and absolute REA_VERIFY_STRACE_COMMAND. No target or chain is executed.",
+ );
+try {
+ await access(
+ process.env.REA_EVM_PRLIMIT_COMMAND ?? "/usr/bin/prlimit",
+ constants.X_OK,
+ );
+ await access(solcModule, constants.R_OK);
+ await access(strace, constants.X_OK);
+} catch (cause) {
+ throw new Error(
+ "verify:evm:interface prerequisite unavailable: selected solc module, strace or util-linux prlimit",
+ { cause },
+ );
+}
+const solc = (await import(pathToFileURL(solcModule).href)).default;
+assert.equal(solc.version(), "0.8.30+commit.73712a01.Emscripten.clang");
+const source = await readFile(
+ new URL("./fixtures/evm-interface.sol", import.meta.url),
+ "utf8",
+);
+const entrypoint =
+ process.argv[2] ?? fileURLToPath(new URL("./rea.mjs", import.meta.url));
+const execute = promisify(execFile);
+const environment = Object.fromEntries(
+ Object.entries(process.env).filter(([, value]) => typeof value === "string"),
+);
+const run = createVerifierRun();
+const root = await PrivateRuntimeRoot.create({ prefix: "rea-evm-verifier-" });
+const client = new Client({ name: "evm-interface-verifier", version: "1" });
+const transport = new StdioClientTransport({
+ command: process.execPath,
+ args: [entrypoint, "mcp"],
+ env: environment,
+ stderr: "pipe",
+});
+const failures = [];
+let cases = 0;
+try {
+ await client.connect(transport);
+ const tools = await client.listTools();
+ assert.ok(tools.tools.some(({ name }) => name === "inspect_evm_interface"));
+ const inspect = async (
+ mode,
+ path,
+ encoding,
+ expectedCategory,
+ scope = { command: process.execPath, prefix: [], client },
+ ) => {
+ let value;
+ if (mode === "cli") {
+ let stdout;
+ try {
+ ({ stdout } = await execute(
+ scope.command,
+ [
+ ...scope.prefix,
+ entrypoint,
+ "inspect-evm-interface",
+ path,
+ encoding,
+ "--json",
+ ],
+ {
+ env: scope.environment ?? environment,
+ timeout: 45_000,
+ maxBuffer: 32 * 1024 * 1024,
+ },
+ ));
+ } catch (cause) {
+ if (typeof cause.code !== "number" || typeof cause.stdout !== "string")
+ throw cause;
+ stdout = cause.stdout;
+ }
+ value = JSON.parse(stdout);
+ } else {
+ const reply = await scope.client.callTool({
+ name: "inspect_evm_interface",
+ arguments: { path, encoding },
+ });
+ value = JSON.parse(mcpTextValue(reply));
+ if (expectedCategory !== undefined) {
+ assert.equal(reply.isError, true);
+ value = value.error;
+ } else assert.notEqual(reply.isError, true, mcpTextValue(reply));
+ }
+ if (expectedCategory !== undefined) {
+ assert.equal(value.category, expectedCategory, JSON.stringify(value));
+ return value;
+ }
+ assert.equal(
+ value.category,
+ undefined,
+ `Unexpected public failure: ${JSON.stringify(value)}`,
+ );
+ const evidence = parseEvidence(mode === "cli" ? value : value.evidence);
+ const report = evidence.normalized_result;
+ assert.equal(report.diagnostics.truncated, false);
+ assert.equal(evidence.provider.version, "evmole@0.9.3");
+ assert.equal(evidence.confidence, "inferred");
+ assert.equal(report.artifact.path, path);
+ assert.equal(report.artifact.encoding, encoding);
+ const carrier = await readFile(path);
+ assert.equal(
+ report.artifact.sha256,
+ createHash("sha256").update(carrier).digest("hex"),
+ );
+ assert.equal(evidence.subject.digest.sha256, report.artifact.sha256);
+ assert.equal(report.artifact.bytes, carrier.length);
+ assert.equal(
+ report.bytecode.sha256,
+ createHash("sha256")
+ .update(Buffer.from(report.bytecode.hex, "hex"))
+ .digest("hex"),
+ );
+ assert.equal(report.bytecode.bytes, report.bytecode.hex.length / 2);
+ for (const key of ["kind", "hardfork", "deployment_authenticity"])
+ assert.equal(report.bytecode[key], "unknown");
+ assert.equal(report.discovery_completeness, "unknown");
+ assert.equal(report.runtime_execution, "not-performed");
+ assert.deepEqual(
+ report.functions.map((fn) => ({
+ selector: fn.selector.slice(2),
+ bytecodeOffset: fn.bytecode_offset,
+ dispatch: fn.dispatch,
+ ...(fn.inferred_arguments === null
+ ? {}
+ : { arguments: fn.inferred_arguments }),
+ ...(fn.inferred_state_mutability === null
+ ? {}
+ : { stateMutability: fn.inferred_state_mutability }),
+ })),
+ evidence.raw_result.functions ?? [],
+ );
+ if (mode === "mcp") assert.deepEqual(value.result, report);
+ return report;
+ };
+ for (const [name, optimizer, viaIR] of [
+ ["plain", false, false],
+ ["optimized", true, false],
+ ["via-ir", true, true],
+ ]) {
+ const output = JSON.parse(
+ solc.compile(
+ JSON.stringify({
+ language: "Solidity",
+ sources: { "Interface.sol": { content: source } },
+ settings: {
+ optimizer: { enabled: optimizer, runs: 200 },
+ viaIR,
+ evmVersion: "cancun",
+ outputSelection: {
+ "*": {
+ "*": [
+ "evm.deployedBytecode.object",
+ "evm.methodIdentifiers",
+ "abi",
+ ],
+ },
+ },
+ },
+ }),
+ ),
+ );
+ assert.ok(
+ !output.errors?.some(({ severity }) => severity === "error"),
+ JSON.stringify(output.errors),
+ );
+ const artifact = output.contracts["Interface.sol"].InterfaceFixture;
+ const hex = artifact.evm.deployedBytecode.object;
+ const expected = Object.values(artifact.evm.methodIdentifiers)
+ .map((selector) => "0x" + selector)
+ .sort();
+ const forms = [
+ ["hex", ` \t0x${hex.toUpperCase()}\r\n`],
+ ["raw", Buffer.from(hex, "hex")],
+ ];
+ for (const [encoding, data] of forms) {
+ const path = join(root.path, `${name}.${encoding}`);
+ await writeFile(path, data);
+ const original = await readFile(path);
+ for (const mode of ["cli", "mcp"]) {
+ const report = await inspect(mode, path, encoding);
+ assert.equal(report.bytecode.hex, hex);
+ assert.deepEqual(
+ report.functions
+ .filter(({ dispatch }) => dispatch === "abi")
+ .map(({ selector }) => selector)
+ .sort(),
+ expected,
+ );
+ assert.ok(
+ report.functions.every(
+ ({ bytecode_offset }) => bytecode_offset < report.bytecode.bytes,
+ ),
+ );
+ const set = report.functions.find(
+ ({ selector }) =>
+ selector === "0x" + artifact.evm.methodIdentifiers["set(uint256)"],
+ );
+ assert.equal(set.inferred_arguments, "uint256");
+ assert.equal(set.inferred_state_mutability, "nonpayable");
+ cases++;
+ }
+ assert.deepEqual(await readFile(path), original);
+ }
+ }
+ for (const [name, data, encoding] of [
+ ["empty", "0x", "hex"],
+ ["empty-raw", Buffer.alloc(0), "raw"],
+ ["incomplete-push", Buffer.from([0x7f]), "raw"],
+ ["no-dispatch", "60006000f3", "hex"],
+ ]) {
+ const path = join(root.path, name);
+ await writeFile(path, data);
+ for (const mode of ["cli", "mcp"]) {
+ const report = await inspect(mode, path, encoding);
+ assert.equal(report.functions.length, 0);
+ cases++;
+ }
+ }
+ for (const [name, hex, expected] of [
+ ["zero-selector", "60003560e01c630000000014601057005b00", ["0x00000000"]],
+ ["push-immediate", "63123456785060006000f3", []],
+ ]) {
+ const path = join(root.path, name);
+ await writeFile(path, hex);
+ for (const mode of ["cli", "mcp"]) {
+ const report = await inspect(mode, path, "hex");
+ assert.deepEqual(
+ report.functions.map(({ selector }) => selector),
+ expected,
+ );
+ cases++;
+ }
+ }
+ for (const [encoding, data] of [
+ ["raw", Buffer.from("ef0001", "hex")],
+ ["hex", "0xef0001"],
+ ]) {
+ const path = join(root.path, "eof." + encoding);
+ await writeFile(path, data);
+ for (const mode of ["cli", "mcp"]) {
+ const failure = await inspect(mode, path, encoding, "unsupported_target");
+ assert.equal(failure.code, "unsupported_target");
+ assert.equal(failure.details.path, path);
+ assert.match(failure.details.reason, /EF00/);
+ assert.match(failure.remediation.action, /target format/);
+ cases++;
+ }
+ }
+ for (const [name, data] of [
+ ["odd", "0x1"],
+ ["invalid-hex", "xyz"],
+ ["interior-space", "60 00"],
+ ["bom", "\uFEFF6000"],
+ ["utf8", Buffer.from([0xff])],
+ ]) {
+ const path = join(root.path, name);
+ await writeFile(path, data);
+ for (const mode of ["cli", "mcp"]) {
+ await inspect(mode, path, "hex", "invalid_input");
+ cases++;
+ }
+ }
+ for (const mode of ["cli", "mcp"]) {
+ await inspect(mode, join(root.path, "absent"), "raw", "invalid_input");
+ cases++;
+ }
+ for (const [option, expected] of [
+ ["--as=2147483648", "address_space_bytes=2147483648"],
+ ["--cpu=20", "cpu_seconds=20"],
+ ["--fsize=8388608", "file_size_bytes=8388608"],
+ ]) {
+ const limiter = environment.REA_EVM_PRLIMIT_COMMAND ?? "/usr/bin/prlimit";
+ const prefix = [
+ option,
+ "--",
+ process.execPath,
+ "--disable-wasm-trap-handler",
+ ];
+ const limitedClient = new Client({
+ name: "limited-evm-verifier",
+ version: "1",
+ });
+ const limitedTransport = new StdioClientTransport({
+ command: limiter,
+ args: [...prefix, entrypoint, "mcp"],
+ env: environment,
+ stderr: "pipe",
+ });
+ try {
+ await limitedClient.connect(limitedTransport);
+ const scope = { command: limiter, prefix, client: limitedClient };
+ for (const mode of ["cli", "mcp"]) {
+ const report = await inspect(
+ mode,
+ join(root.path, "zero-selector"),
+ "hex",
+ undefined,
+ scope,
+ );
+ assert.ok(report.limitations.some((item) => item.includes(expected)));
+ cases++;
+ }
+ } finally {
+ try {
+ await limitedClient.close();
+ } finally {
+ await limitedTransport.close();
+ }
+ }
+ }
+ const brokenLimiter = join(root.path, "broken-limiter");
+ await writeFile(brokenLimiter, "#!/rea-verifier-missing-interpreter\n", {
+ mode: 0o700,
+ });
+ const signalLimiter = join(root.path, "signal-limiter");
+ await writeFile(signalLimiter, '#!/bin/sh\nulimit -c 0\nkill -XCPU "$$"\n', {
+ mode: 0o700,
+ });
+ const fileLimiter = join(root.path, "file-size-limiter");
+ const unmarkedLimiter = join(root.path, "unmarked-file-size-limiter");
+ await writeFile(
+ unmarkedLimiter,
+ '#!/bin/sh\nprintf "launcher exit 76\\n" >&2\nexit 76\n',
+ { mode: 0o700 },
+ );
+ // Fixture-only launcher: run the actual Node/WASM worker with a one-byte
+ // file allowance, keeping the usual address-space, CPU and core budgets.
+ await writeFile(
+ fileLimiter,
+ '#!/bin/sh\nwhile [ "$#" -gt 0 ] && [ "$1" != "--" ]; do shift; done\n[ "$#" -gt 0 ] || exit 64\nshift\nexec /usr/bin/prlimit --as=3221225472: --cpu=30: --fsize=1: --core=0: -- "$@"\n',
+ { mode: 0o700 },
+ );
+ for (const [limiter, category, resource] of [
+ [root.path, "unsupported_provider"],
+ [brokenLimiter, "unsupported_provider"],
+ [signalLimiter, "resource_constraint", "cpu"],
+ [fileLimiter, "resource_constraint", "file-size"],
+ [unmarkedLimiter, "execution_failure"],
+ ]) {
+ const selectedEnvironment = {
+ ...environment,
+ REA_EVM_PRLIMIT_COMMAND: limiter,
+ };
+ const selectedClient = new Client({
+ name: "unavailable-evm-limiter-verifier",
+ version: "1",
+ });
+ const selectedTransport = new StdioClientTransport({
+ command: process.execPath,
+ args: [entrypoint, "mcp"],
+ env: selectedEnvironment,
+ stderr: "pipe",
+ });
+ try {
+ await selectedClient.connect(selectedTransport);
+ const scope = {
+ command: process.execPath,
+ prefix: [],
+ client: selectedClient,
+ environment: selectedEnvironment,
+ };
+ for (const mode of ["cli", "mcp"]) {
+ const error = await inspect(
+ mode,
+ join(root.path, "zero-selector"),
+ "hex",
+ category,
+ scope,
+ );
+ if (category === "resource_constraint") {
+ assert.equal(error.code, "resource_constraint");
+ assert.equal(error.details.resource, resource);
+ assert.equal(
+ error.details.reported_limits.effective_soft_limits,
+ null,
+ );
+ assert.equal(
+ typeof error.details.reported_limits.configured_soft_limits
+ .cpu_seconds,
+ "number",
+ );
+ assert.ok(
+ JSON.stringify(error).includes(
+ resource === "cpu" ? "SIGXCPU" : "EFBIG",
+ ),
+ );
+ } else if (category === "execution_failure") {
+ assert.equal(error.code, "execution_failure");
+ assert.ok(JSON.stringify(error).includes("launcher exit 76"));
+ assert.ok(JSON.stringify(error).includes("file_size_failure_marker"));
+ } else {
+ assert.equal(error.code, "capability_unavailable");
+ assert.ok(JSON.stringify(error).includes(limiter));
+ }
+ cases++;
+ }
+ } finally {
+ try {
+ await selectedClient.close();
+ } finally {
+ await selectedTransport.close();
+ }
+ }
+ }
+ for (const [name, encoding] of [
+ ["plain.hex", "hex"],
+ ["via-ir.raw", "raw"],
+ ]) {
+ await execute(
+ strace,
+ [
+ "-ff",
+ "-e",
+ "trace=network,execve,execveat",
+ "-s",
+ "4096",
+ "-o",
+ join(root.path, `offline.trace.${name}`),
+ process.execPath,
+ entrypoint,
+ "inspect-evm-interface",
+ join(root.path, name),
+ encoding,
+ "--json",
+ ],
+ {
+ env: { ...environment, PATH: "/usr/bin:/bin" },
+ timeout: 45_000,
+ maxBuffer: 32 * 1024 * 1024,
+ },
+ );
+ }
+ const observedExecutables = new Set();
+ for (const file of await readdir(root.path)) {
+ if (!file.startsWith("offline.trace.")) continue;
+ const localSocketDescriptors = new Set();
+ for (const line of (await readFile(join(root.path, file), "utf8")).split(
+ "\n",
+ )) {
+ if (/execve(?:at)?\(/.test(line)) {
+ const match = /^execve\("([^"]+)"/.exec(line);
+ assert.notEqual(match, null, `Unresolved executable identity: ${line}`);
+ assert.ok(
+ line.endsWith(" = 0") || / = -1 [A-Z]+/.test(line),
+ `Incomplete exec observation: ${line}`,
+ );
+ const ownershipInspection =
+ ["/usr/bin/ps", "/bin/ps"].includes(match[1]) &&
+ line.includes('["ps", "-axo", "pid=,ppid=,pgid=,stat=,command="]');
+ assert.ok(
+ [
+ process.execPath,
+ environment.REA_EVM_PRLIMIT_COMMAND ?? "/usr/bin/prlimit",
+ ].includes(match[1]) || ownershipInspection,
+ `Unexpected host execution: ${line}`,
+ );
+ observedExecutables.add(match[1]);
+ } else if (/^[a-z]+\(/.test(line)) {
+ assert.ok(
+ permitsStdioSocketObservation(line, localSocketDescriptors),
+ `Unexpected attempted network operation: ${line}`,
+ );
+ }
+ }
+ }
+ assert.ok(observedExecutables.has(process.execPath));
+ assert.ok(
+ observedExecutables.has(
+ environment.REA_EVM_PRLIMIT_COMMAND ?? "/usr/bin/prlimit",
+ ),
+ );
+ cases += 2;
+} catch (cause) {
+ failures.push(cause);
+} finally {
+ for (const close of [
+ () => client.close(),
+ () => transport.close(),
+ () => root.close(),
+ ]) {
+ try {
+ await close();
+ } catch (cause) {
+ failures.push(cause);
+ }
+ }
+}
+const verifier = await completeVerifierRun(run);
+if (
+ verifier.process_lineage.status !== "verified" ||
+ verifier.process_lineage.descendants.length !== 0
+)
+ failures.push(
+ new Error(
+ "Verifier process lineage was not fully released: " +
+ JSON.stringify(verifier),
+ ),
+ );
+console.log(
+ JSON.stringify(
+ {
+ status: failures.length === 0 ? "passed" : "failed",
+ public_cases: cases,
+ compiler: solc.version(),
+ engine: "evmole@0.9.3",
+ ...(failures.length === 0
+ ? {
+ target_execution: "exec-syscalls-verified-absent",
+ network_requests: "socket-syscalls-verified-absent",
+ }
+ : {}),
+ verifier,
+ },
+ null,
+ 2,
+ ),
+);
+if (failures.length !== 0)
+ throw new AggregateError(
+ failures,
+ "Offline EVM interface verification failed; cleanup failures retained.",
+ );
+
+function permitsStdioSocketObservation(line, localDescriptors) {
+ if (line.startsWith("socketpair(AF_UNIX,")) {
+ const pair = /\[(\d+), (\d+)\]\)\s+= 0$/.exec(line);
+ if (pair !== null) {
+ localDescriptors.add(pair[1]);
+ localDescriptors.add(pair[2]);
+ }
+ return true;
+ }
+ const name = /^getsockname\((\d+), \{sa_family=AF_UNIX\}/.exec(line);
+ if (name !== null) {
+ localDescriptors.add(name[1]);
+ return true;
+ }
+ // A failed descriptor query performs no request and identifies no socket.
+ if (line.startsWith("getsockname(") && / = -1 ENOTSOCK/.test(line))
+ return true;
+ const metadata =
+ /^(?:getsockopt\((\d+), SOL_SOCKET, SO_TYPE,|setsockopt\((\d+), SOL_SOCKET, SO_(?:RCVBUF|SNDBUF),|shutdown\((\d+), SHUT_WR\))/.exec(
+ line,
+ );
+ const descriptor = metadata?.[1] ?? metadata?.[2] ?? metadata?.[3];
+ // Allow descriptor queries/buffer configuration only after observing a local
+ // stdio socket. Other socket creation, connection and data operations fail.
+ return descriptor !== undefined && localDescriptors.has(descriptor);
+}
diff --git a/skills/reverse-engineer-anything/SKILL.md b/skills/reverse-engineer-anything/SKILL.md
index 83d550ee5..5dad1c17a 100644
--- a/skills/reverse-engineer-anything/SKILL.md
+++ b/skills/reverse-engineer-anything/SKILL.md
@@ -2,9 +2,9 @@
name: reverse-engineer-anything
description: Reverse engineer native, managed, Electron/JavaScript, packaged, firmware, and browser targets with REA. Use shipped-artifact or requested runtime evidence to explain features, compare versions, decompile code, or guide a reconstruction. Skip REA for ordinary source-repository architecture analysis.
metadata:
- version: "31"
- tool_count: 137
- catalog_digest: "ce23d159fbfc07b338a5e2d51569a71597842f0cf3a3c09986c799512752ab02"
+ version: "32"
+ tool_count: 138
+ catalog_digest: "8ed2bd4b71684e3e391f909841edf32b68da20eede1f46f0dd6081e235644260"
---
# REA
@@ -99,6 +99,11 @@ explicit path or endpoint and do not need it.
[historical captures](https://github.com/morluto/rea/blob/main/docs/web-network-captures.md) for the exact upstream
profile and credential exclusions.
- User-owned Electron runtime already open: `list_electron_targets`.
+- Explicit local EVM bytecode carrier: `inspect_evm_interface` with caller-selected
+ `raw` or `hex` encoding. Preserve carrier/decoded digests and treat selectors,
+ argument strings and mutability as inferred candidates; this performs no chain
+ lookup or target execution. See the
+ [EVM bytecode guide](https://github.com/morluto/rea/blob/main/docs/evm-bytecode.md).
- Explicit Linux ELF file for offline layout, symbols, relocations or static
mitigation evidence: `inspect_binary_layout`. This target-free operation uses
caller-supplied pwntools without opening a disassembler database. Preserve its
diff --git a/src/application/CapabilityInventory.ts b/src/application/CapabilityInventory.ts
index ab2c25d94..ea627693e 100644
--- a/src/application/CapabilityInventory.ts
+++ b/src/application/CapabilityInventory.ts
@@ -31,6 +31,7 @@ type ToolAvailabilityReason = "available" | ToolUnavailabilityReason;
type ProviderDescriptor = ProviderCapability;
export type AvailabilityPolicy = {
+ readonly evmInterfaceEnabled?: boolean;
readonly processCaptureEnabled: boolean;
readonly optionalProviderLoadFailures?: OptionalProviderLoadFailures;
readonly binaryLayoutEnabled?: boolean;
@@ -289,6 +290,14 @@ const workflowAvailabilityFor = ({
remediation:
"On Linux x64, set absolute REA_PWNTOOLS_PYTHON to caller-supplied Python with pwntools 4.15.0. No active binary target is required.",
};
+ if (name === "inspect_evm_interface")
+ return policy.evmInterfaceEnabled === true
+ ? { reason: "available", remediation: null }
+ : {
+ reason: "provider_missing",
+ remediation:
+ "The bundled offline EVM interface profile is currently real-verified on Linux x64. No active binary target or chain endpoint is required.",
+ };
if (kind === "firmware-provider") {
const enabled =
name === "inspect_firmware_regions"
diff --git a/src/application/evm/EvmInterfacePort.ts b/src/application/evm/EvmInterfacePort.ts
new file mode 100644
index 000000000..1d5c39ef4
--- /dev/null
+++ b/src/application/evm/EvmInterfacePort.ts
@@ -0,0 +1,15 @@
+import type {
+ AnalysisExecution,
+ ExecutionOptions,
+} from "../AnalysisProvider.js";
+import type { AnalysisError } from "../../domain/analysisErrorBase.js";
+import type { InspectEvmInterfaceInput } from "../../domain/evm/evmInterface.js";
+import type { Result } from "../../domain/result.js";
+
+/** Replaceable offline interface-recovery engine, independent of wallets and chain clients. */
+export interface EvmInterfacePort {
+ inspect(
+ input: InspectEvmInterfaceInput,
+ options?: ExecutionOptions,
+ ): Promise>;
+}
diff --git a/src/application/evm/EvmInterfaceService.test.ts b/src/application/evm/EvmInterfaceService.test.ts
new file mode 100644
index 000000000..9b0197388
--- /dev/null
+++ b/src/application/evm/EvmInterfaceService.test.ts
@@ -0,0 +1,124 @@
+import { expect, it } from "vitest";
+import { EvmInterfaceService } from "./EvmInterfaceService.js";
+import {
+ evmInterfaceExecution,
+ evmInterfaceFixture,
+} from "../../../tests/fixtures/evm/interface.js";
+import { err, ok } from "../../domain/result.js";
+import { parseEvidence } from "../../domain/evidence.js";
+import { AnalysisCapabilityUnavailableError } from "../../domain/analysisErrorCore.js";
+
+it("returns full inline inferred Evidence and unmodified upstream representation", async () => {
+ const value = evmInterfaceFixture();
+ const service = new EvmInterfaceService({
+ inspect: () => Promise.resolve(ok(evmInterfaceExecution(value))),
+ });
+ const result = await service.inspect({
+ path: value.artifact.path,
+ encoding: "hex",
+ });
+ if (!result.ok) throw result.error;
+ const evidence = parseEvidence(result.value);
+ expect(evidence.normalized_result).toEqual(value);
+ expect(evidence.raw_result).toEqual({ functions: [] });
+ expect(evidence.confidence).toBe("inferred");
+ expect(evidence.subject?.digest.sha256).toBe(value.artifact.sha256);
+});
+it.each([
+ { path: "relative.hex", encoding: "hex" },
+ { path: "/selected", encoding: "auto" },
+ { path: "/selected" },
+ { path: "/selected", encoding: "raw", approval: true },
+])("rejects invalid selection before effects: %j", async (input) => {
+ const service = new EvmInterfaceService({
+ inspect: () => {
+ throw new Error("must not start");
+ },
+ });
+ expect(await service.inspect(input)).toMatchObject({
+ ok: false,
+ error: { _tag: "AnalysisInputError" },
+ });
+});
+it.each(["carrier", "encoding", "digest", "no-subject"])(
+ "rejects mismatched producer binding: %s",
+ async (problem) => {
+ const value = evmInterfaceFixture();
+ if (problem === "carrier") value.artifact.path = "/other";
+ if (problem === "encoding") value.artifact.encoding = "raw";
+ const observed = evmInterfaceExecution(value);
+ const execution = {
+ ...observed,
+ subject:
+ problem === "no-subject"
+ ? null
+ : {
+ path: "/artifacts/source-owned.hex",
+ sha256:
+ problem === "digest" ? "b".repeat(64) : value.artifact.sha256,
+ format: "file" as const,
+ },
+ };
+ const service = new EvmInterfaceService({
+ inspect: () => Promise.resolve(ok(execution)),
+ });
+ expect(
+ await service.inspect({
+ path: "/artifacts/source-owned.hex",
+ encoding: "hex",
+ }),
+ ).toMatchObject({
+ ok: false,
+ error: { _tag: "AnalysisOutputError", capturedOutput: value.diagnostics },
+ });
+ },
+);
+it("preserves meaningful provider failure and cancellation before/after execution", async () => {
+ const failure = new AnalysisCapabilityUnavailableError(
+ "evm",
+ "inspect_evm_interface",
+ "Selected limiter missing",
+ );
+ const service = new EvmInterfaceService({
+ inspect: () => Promise.resolve(err(failure)),
+ });
+ expect(await service.inspect({ path: "/code", encoding: "raw" })).toEqual(
+ err(failure),
+ );
+ const controller = new AbortController();
+ const observed = evmInterfaceFixture();
+ observed.diagnostics = {
+ stdout: "selected\u0000",
+ stderr: "warning",
+ truncated: true,
+ };
+ const cancelled = new EvmInterfaceService({
+ inspect: () => {
+ controller.abort();
+ return Promise.resolve(ok(evmInterfaceExecution(observed)));
+ },
+ });
+ expect(
+ await cancelled.inspect(
+ { path: "/artifacts/source-owned.hex", encoding: "hex" },
+ { signal: controller.signal },
+ ),
+ ).toMatchObject({
+ ok: false,
+ error: {
+ _tag: "AnalysisCancelledError",
+ capturedOutput: observed.diagnostics,
+ },
+ });
+ const untouched = new EvmInterfaceService({
+ inspect: () => {
+ throw new Error("must not start");
+ },
+ });
+ expect(
+ await untouched.inspect(
+ { path: "/code", encoding: "raw" },
+ { signal: controller.signal },
+ ),
+ ).toMatchObject({ ok: false, error: { _tag: "AnalysisCancelledError" } });
+});
diff --git a/src/application/evm/EvmInterfaceService.ts b/src/application/evm/EvmInterfaceService.ts
new file mode 100644
index 000000000..9769255c0
--- /dev/null
+++ b/src/application/evm/EvmInterfaceService.ts
@@ -0,0 +1,92 @@
+import { isAbsolute } from "node:path";
+import { z } from "zod";
+import type { ExecutionOptions } from "../AnalysisProvider.js";
+import type { EvmInterfacePort } from "./EvmInterfacePort.js";
+import type { AnalysisError } from "../../domain/analysisErrorBase.js";
+import {
+ AnalysisCancelledError,
+ AnalysisInputError,
+ AnalysisOutputError,
+} from "../../domain/analysisErrorCore.js";
+import { createEvidence, type Evidence } from "../../domain/evidence.js";
+import {
+ inspectEvmInterfaceInputSchema,
+ evmInterfaceSchema,
+} from "../../domain/evm/evmInterface.js";
+import { projectInputIssues } from "../../domain/inputIssueProjection.js";
+import { err, ok, type Result } from "../../domain/result.js";
+
+const OPERATION = "inspect_evm_interface";
+const diagnosticsSchema = z.object({
+ diagnostics: evmInterfaceSchema.shape.diagnostics,
+});
+
+/** Shared carrier validation and typed inline Evidence for CLI/MCP. */
+export class EvmInterfaceService {
+ constructor(readonly provider: EvmInterfacePort) {}
+
+ /** Preserve selected byte identity while clearly labeling recovered ABI candidates as inferences. */
+ async inspect(
+ rawInput: unknown,
+ options?: ExecutionOptions,
+ ): Promise> {
+ if (options?.signal?.aborted)
+ return err(new AnalysisCancelledError(OPERATION));
+ const input = inspectEvmInterfaceInputSchema.safeParse(rawInput);
+ if (!input.success)
+ return err(
+ new AnalysisInputError(
+ OPERATION,
+ { cause: input.error },
+ projectInputIssues(input.error.issues, rawInput),
+ ),
+ );
+ if (!isAbsolute(input.data.path))
+ return err(
+ new AnalysisInputError(OPERATION, undefined, [
+ {
+ path: ["path"],
+ reason: "invalid_format",
+ message: "Expected an absolute filesystem path on this host.",
+ },
+ ]),
+ );
+ const inspected = await this.provider.inspect(input.data, options);
+ if (!inspected.ok) return inspected;
+ const diagnostics = diagnosticsSchema.safeParse(inspected.value.result);
+ const outputOptions = diagnostics.success
+ ? { capturedOutput: diagnostics.data.diagnostics }
+ : undefined;
+ if (options?.signal?.aborted)
+ return err(new AnalysisCancelledError(OPERATION, outputOptions));
+ const execution = inspected.value;
+ const report = evmInterfaceSchema.safeParse(execution.result);
+ if (
+ !report.success ||
+ report.data.artifact.path !== input.data.path ||
+ report.data.artifact.encoding !== input.data.encoding ||
+ execution.subject?.path !== input.data.path ||
+ execution.subject.sha256 !== report.data.artifact.sha256
+ )
+ return err(
+ new AnalysisOutputError(
+ OPERATION,
+ "Interface adapter returned malformed data or changed the selected carrier identity/encoding.",
+ outputOptions,
+ ),
+ );
+ return ok(
+ createEvidence(execution.subject, execution.provider, {
+ operation: OPERATION,
+ parameters: input.data,
+ result: report.data,
+ ...(execution.rawResult === null
+ ? {}
+ : { rawResult: execution.rawResult }),
+ confidence: "inferred",
+ locations: execution.locations,
+ limitations: execution.limitations,
+ }),
+ );
+ }
+}
diff --git a/src/cli.ts b/src/cli.ts
index 6fa48dd33..38b5ab759 100644
--- a/src/cli.ts
+++ b/src/cli.ts
@@ -1,3 +1,4 @@
+import { registerEvmCommands } from "./cli/evmCommands.js";
import { registerBinaryDiagnosticsCommands } from "./cli/binaryDiagnosticsCommands.js";
import { Cli } from "incur";
@@ -56,6 +57,7 @@ export const createCli = (
registerSetupCommands(cli, logger);
registerCoreAnalysisCommands(cli, logger, resultOutput);
registerUtilityCommands(cli, logger, environment);
+ registerEvmCommands(cli, logger, environment);
registerArtifactCommands(cli, logger);
registerManagedCommands(cli, logger);
registerAndroidCommands(cli, logger, environment);
diff --git a/src/cli/evmCommands.ts b/src/cli/evmCommands.ts
new file mode 100644
index 000000000..61f292587
--- /dev/null
+++ b/src/cli/evmCommands.ts
@@ -0,0 +1,37 @@
+import { z } from "incur";
+import { resolve } from "node:path";
+import { createEvmInterfaceService } from "../composition/evm.js";
+import { CLI_COMMANDS } from "../cliCommandNames.js";
+import { logCliCommand } from "../cliLogging.js";
+import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
+import type { Logger } from "../logger.js";
+import type { CliInstance } from "./types.js";
+import { withCommandCancellation } from "./commandCancellation.js";
+
+/** Resolve operator paths and preserve explicit encoding through the shared workflow. */
+export const registerEvmCommands = (
+ cli: CliInstance,
+ logger: Logger,
+ environment: Readonly,
+): void => {
+ const service = createEvmInterfaceService(environment);
+ cli.command(CLI_COMMANDS.inspectEvmInterface, {
+ description: "Infer interface candidates from explicit local EVM bytecode",
+ args: z.object({
+ path: z.string().describe("Local bytecode carrier"),
+ encoding: z
+ .enum(["raw", "hex"])
+ .describe("Selected carrier representation"),
+ }),
+ run: ({ args }) =>
+ withCommandCancellation((signal) =>
+ logCliCommand(logger, CLI_COMMANDS.inspectEvmInterface, async () => {
+ const result = await service.inspect(
+ { path: resolve(args.path), encoding: args.encoding },
+ { signal },
+ );
+ return result.ok ? result.value : projectAnalysisError(result.error);
+ }),
+ ),
+ });
+};
diff --git a/src/cliCommandNames.ts b/src/cliCommandNames.ts
index f22f2b6c9..83d219446 100644
--- a/src/cliCommandNames.ts
+++ b/src/cliCommandNames.ts
@@ -1,5 +1,6 @@
/** Canonical names used by every one-shot CLI command registration. */
export const CLI_COMMANDS = Object.freeze({
+ inspectEvmInterface: "inspect-evm-interface",
inspectWebNetworkCapture: "inspect-web-network-capture",
observeWebExecution: "observe-web-execution",
inspectWebEventListeners: "inspect-web-event-listeners",
diff --git a/src/composition/evm.ts b/src/composition/evm.ts
new file mode 100644
index 000000000..92c7e73fc
--- /dev/null
+++ b/src/composition/evm.ts
@@ -0,0 +1,8 @@
+import { EvmInterfaceService } from "../application/evm/EvmInterfaceService.js";
+import { EvmoleInterfaceProvider } from "../evm/EvmoleInterfaceProvider.js";
+
+/** Compose offline interface analysis without import-time I/O or dependency acquisition. */
+export const createEvmInterfaceService = (
+ environment: Readonly = process.env,
+): EvmInterfaceService =>
+ new EvmInterfaceService(new EvmoleInterfaceProvider(environment));
diff --git a/src/contracts/contractSnapshot.test.ts b/src/contracts/contractSnapshot.test.ts
index 82ee4acd9..f0e7a6ce9 100644
--- a/src/contracts/contractSnapshot.test.ts
+++ b/src/contracts/contractSnapshot.test.ts
@@ -1,3 +1,4 @@
+import { EVM_TOOL_CONTRACTS } from "./evm/evmToolContracts.js";
import { BINARY_DIAGNOSTICS_TOOL_CONTRACTS } from "./native/binaryDiagnosticsToolContracts.js";
import { describe, expect, it } from "vitest";
import { z } from "zod";
@@ -72,6 +73,7 @@ describe("tool contract surface", () => {
...NATIVE_TOOL_CONTRACTS,
...BINARY_DIAGNOSTICS_TOOL_CONTRACTS,
...ARTIFACT_TOOL_CONTRACTS,
+ ...EVM_TOOL_CONTRACTS,
...ANDROID_TOOL_CONTRACTS,
...FIRMWARE_TOOL_CONTRACTS,
...MANAGED_TOOL_CONTRACTS,
diff --git a/src/contracts/errorSchemas.ts b/src/contracts/errorSchemas.ts
index da7dd924e..a535f6abf 100644
--- a/src/contracts/errorSchemas.ts
+++ b/src/contracts/errorSchemas.ts
@@ -5,6 +5,7 @@ import { jsonValueSchema } from "../domain/jsonValue.js";
const categorySchema = z.enum([
"invalid_input",
"unsupported_provider",
+ "unsupported_target",
"integrity_mismatch",
"truncated",
"cancelled",
@@ -37,6 +38,7 @@ export const analysisErrorProjectionSchema = z.discriminatedUnion("code", [
generic("artifact_changed"),
generic("unreadable_output"),
generic("capability_unavailable"),
+ generic("unsupported_target"),
generic("provider_unavailable"),
generic("provider_timeout"),
generic("resource_constraint"),
diff --git a/src/contracts/evm/evmToolContracts.ts b/src/contracts/evm/evmToolContracts.ts
new file mode 100644
index 000000000..7b8f5bcf9
--- /dev/null
+++ b/src/contracts/evm/evmToolContracts.ts
@@ -0,0 +1,26 @@
+import {
+ inspectEvmInterfaceInputSchema,
+ evmInterfaceSchema,
+} from "../../domain/evm/evmInterface.js";
+import type { ToolContract } from "../toolContractTypes.js";
+import { toolContractMetadata } from "../toolEffects.js";
+import { evidenceResultOf } from "../toolOutputSchemas.js";
+
+/** Target-free EVM capabilities are independent of archive/Apple artifact providers. */
+export const EVM_TOOL_CONTRACTS = [
+ {
+ name: "inspect_evm_interface",
+ ...toolContractMetadata("inspect_evm_interface"),
+ kind: "artifact-provider",
+ description:
+ "Inspect an explicit local flat EVM bytecode carrier and infer dispatch selectors, argument strings and mutability without executing the target or accessing a chain/RPC endpoint. Choose raw or hex encoding explicitly. Returns original carrier and decoded-byte SHA-256, full bytecode and interface candidates inline as inferred Evidence; bytecode kind, hardfork, deployed authenticity and discovery completeness remain unknown. Initial real-verified Linux x64 profile uses unchanged bundled EVMole 0.9.3/WASM in a bounded owned worker; EOF-style EF00 containers are unsupported. No signature lookup, wallet action or transaction broadcast.",
+ inputSchema: inspectEvmInterfaceInputSchema,
+ outputSchema: evidenceResultOf(evmInterfaceSchema),
+ examples: [
+ {
+ title: "Inspect a local runtime bytecode hex carrier",
+ input: { path: "/artifacts/runtime.hex", encoding: "hex" },
+ },
+ ],
+ },
+] as const satisfies readonly ToolContract[];
diff --git a/src/contracts/toolContracts.test.ts b/src/contracts/toolContracts.test.ts
index 44c1e9abd..f0086cff4 100644
--- a/src/contracts/toolContracts.test.ts
+++ b/src/contracts/toolContracts.test.ts
@@ -1,3 +1,4 @@
+import { EVM_TOOL_CONTRACTS } from "./evm/evmToolContracts.js";
import { BINARY_DIAGNOSTICS_TOOL_CONTRACTS } from "./native/binaryDiagnosticsToolContracts.js";
import { describe, expect, it } from "vitest";
@@ -26,6 +27,7 @@ const GROUPS = {
native: NATIVE_TOOL_CONTRACTS,
binary_diagnostics: BINARY_DIAGNOSTICS_TOOL_CONTRACTS,
artifact: ARTIFACT_TOOL_CONTRACTS,
+ evm: EVM_TOOL_CONTRACTS,
managed: MANAGED_TOOL_CONTRACTS,
android: ANDROID_TOOL_CONTRACTS,
firmware: FIRMWARE_TOOL_CONTRACTS,
diff --git a/src/contracts/toolContracts.ts b/src/contracts/toolContracts.ts
index 370c83efc..304ef41d5 100644
--- a/src/contracts/toolContracts.ts
+++ b/src/contracts/toolContracts.ts
@@ -1,3 +1,4 @@
+import { EVM_TOOL_CONTRACTS } from "./evm/evmToolContracts.js";
import { BINARY_DIAGNOSTICS_TOOL_CONTRACTS } from "./native/binaryDiagnosticsToolContracts.js";
import { NATIVE_TOOL_CONTRACTS } from "./native/nativeToolContracts.js";
import { ARTIFACT_TOOL_CONTRACTS } from "./artifactToolContracts.js";
@@ -26,6 +27,7 @@ export const TOOL_CONTRACTS = [
...NATIVE_TOOL_CONTRACTS,
...BINARY_DIAGNOSTICS_TOOL_CONTRACTS,
...ARTIFACT_TOOL_CONTRACTS,
+ ...EVM_TOOL_CONTRACTS,
...MANAGED_TOOL_CONTRACTS,
...ANDROID_TOOL_CONTRACTS,
...FIRMWARE_TOOL_CONTRACTS,
diff --git a/src/contracts/toolEffects.ts b/src/contracts/toolEffects.ts
index 292b5ee23..fd416c7c6 100644
--- a/src/contracts/toolEffects.ts
+++ b/src/contracts/toolEffects.ts
@@ -41,6 +41,11 @@ const sessionEvidence = effects({ mutatesSession: true });
/** Explicit effect audit for every public tool. */
export const TOOL_EFFECTS: Readonly> = {
+ inspect_evm_interface: effects({
+ mutatesSession: true,
+ writesFilesystem: true,
+ launchesProcess: true,
+ }),
inspect_web_network_capture: effects({
mutatesSession: true,
writesFilesystem: true,
diff --git a/src/domain/analysisErrorBase.ts b/src/domain/analysisErrorBase.ts
index 90843234b..47fd251ed 100644
--- a/src/domain/analysisErrorBase.ts
+++ b/src/domain/analysisErrorBase.ts
@@ -6,6 +6,7 @@ const ANALYSIS_ERROR_TAGS = [
"AnalysisArtifactChangedError",
"AnalysisOutputError",
"AnalysisCapabilityUnavailableError",
+ "AnalysisUnsupportedTargetError",
"AnalysisCancelledError",
"AnalysisTimeoutError",
"AnalysisResourceConstraintError",
diff --git a/src/domain/analysisErrorCore.ts b/src/domain/analysisErrorCore.ts
index 691347fac..03835a2b3 100644
--- a/src/domain/analysisErrorCore.ts
+++ b/src/domain/analysisErrorCore.ts
@@ -4,6 +4,19 @@ import {
type AnalysisErrorOptions,
} from "./analysisErrorBase.js";
+/** A readable supplied artifact lies outside this operation's supported target formats. */
+export class AnalysisUnsupportedTargetError extends AnalysisError {
+ readonly _tag = "AnalysisUnsupportedTargetError";
+ constructor(
+ readonly operation: string,
+ readonly path: string,
+ readonly reason: string,
+ options?: AnalysisErrorOptions,
+ ) {
+ super(`Unsupported target for ${operation} at ${path}: ${reason}`, options);
+ }
+}
+
/** Provider reported a resource failure, distinct from malformed input or unsupported coverage. */
export class AnalysisResourceConstraintError extends AnalysisError {
readonly _tag = "AnalysisResourceConstraintError";
diff --git a/src/domain/analysisErrorPresentation.ts b/src/domain/analysisErrorPresentation.ts
index af486b87b..48a1f0860 100644
--- a/src/domain/analysisErrorPresentation.ts
+++ b/src/domain/analysisErrorPresentation.ts
@@ -2,6 +2,7 @@ import {
AnalysisAccessDeniedError,
AnalysisArtifactChangedError,
AnalysisInputError,
+ AnalysisUnsupportedTargetError,
AnalysisResourceConstraintError,
} from "./analysisErrorCore.js";
import { ArtifactOperationError } from "./artifactOperationError.js";
@@ -33,6 +34,8 @@ import { type AnalysisErrorProjection } from "./analysisErrorProjection.js";
export const analysisErrorRemediationAction = (
error: AnalysisError,
): string => {
+ if (error instanceof AnalysisUnsupportedTargetError)
+ return "Select a target supported by this operation or choose an operation supporting the reported target format.";
if (error instanceof AnalysisResourceConstraintError)
return error.resource === "cpu"
? "Review the reported worker CPU limits and observed signal. Retry with sufficient CPU time or a smaller artifact; REA retains tighter inherited limits."
@@ -125,6 +128,7 @@ const STATIC_ERROR_CATEGORIES: Readonly<
AnalysisAccessDeniedError: "unavailable",
AnalysisArtifactChangedError: "integrity_mismatch",
AnalysisCapabilityUnavailableError: "unsupported_provider",
+ AnalysisUnsupportedTargetError: "unsupported_target",
ProviderSelectionError: "unsupported_provider",
EvidenceIntegrityError: "integrity_mismatch",
AnalysisCancelledError: "cancelled",
@@ -137,6 +141,7 @@ const STATIC_ERROR_CATEGORIES: Readonly<
};
export const analysisErrorUserMessage = (error: AnalysisError): string => {
+ if (error instanceof AnalysisUnsupportedTargetError) return error.message;
if (error instanceof AnalysisResourceConstraintError) return error.reason;
if (error instanceof AnalysisAccessDeniedError)
return "Host filesystem permissions denied read access to the selected path.";
@@ -270,6 +275,7 @@ const KNOWN_ERROR_TAGS = {
AnalysisArtifactChangedError: true,
AnalysisOutputError: true,
AnalysisCapabilityUnavailableError: true,
+ AnalysisUnsupportedTargetError: true,
AnalysisCancelledError: true,
AnalysisTimeoutError: true,
AnalysisResourceConstraintError: true,
diff --git a/src/domain/analysisErrorProjection.test.ts b/src/domain/analysisErrorProjection.test.ts
index e0952a7f3..392b97966 100644
--- a/src/domain/analysisErrorProjection.test.ts
+++ b/src/domain/analysisErrorProjection.test.ts
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import {
AnalysisCapabilityUnavailableError,
+ AnalysisUnsupportedTargetError,
AnalysisInputError,
AnalysisOutputError,
AnalysisCancelledError,
@@ -28,6 +29,32 @@ const retainedOutput = {
truncated: true,
};
+it("distinguishes unsupported target formats from unavailable provider capabilities", () => {
+ const projected = projectAnalysisError(
+ new AnalysisUnsupportedTargetError(
+ "inspect_evm_interface",
+ "/selected/runtime.hex",
+ "EOF-style EF00 container is unsupported.",
+ { capturedOutput: retainedOutput },
+ ),
+ );
+ expect(projected).toMatchObject({
+ code: "unsupported_target",
+ category: "unsupported_target",
+ retryable: false,
+ details: {
+ operation: "inspect_evm_interface",
+ path: "/selected/runtime.hex",
+ reason: "EOF-style EF00 container is unsupported.",
+ captured_output: retainedOutput,
+ },
+ });
+ expect(projected.message).toContain("EF00");
+ expect(projected.remediation.action).toContain("target format");
+ expect(projected.remediation.action).not.toContain("doctor");
+ expect(analysisErrorProjectionSchema.safeParse(projected).success).toBe(true);
+});
+
it.each([
["memory", null],
["memory", { address_space_bytes: 67108864 }],
diff --git a/src/domain/analysisErrorProjection.ts b/src/domain/analysisErrorProjection.ts
index 848b10275..6ba8cfac3 100644
--- a/src/domain/analysisErrorProjection.ts
+++ b/src/domain/analysisErrorProjection.ts
@@ -11,6 +11,7 @@ import {
AnalysisArtifactChangedError,
AnalysisCancelledError,
AnalysisCapabilityUnavailableError,
+ AnalysisUnsupportedTargetError,
AnalysisInputError,
AnalysisOutputError,
AnalysisTimeoutError,
@@ -140,6 +141,7 @@ const STATIC_ERROR_CODES = {
AnalysisAccessDeniedError: "access_denied",
AnalysisArtifactChangedError: "artifact_changed",
AnalysisCapabilityUnavailableError: "capability_unavailable",
+ AnalysisUnsupportedTargetError: "unsupported_target",
AnalysisCancelledError: "cancelled",
HopperCancelledError: "cancelled",
AnalysisTimeoutError: "provider_timeout",
@@ -187,6 +189,12 @@ const errorDetails = (
const requestErrorDetails = (
error: AnalysisError,
): Readonly> | undefined => {
+ if (error instanceof AnalysisUnsupportedTargetError)
+ return {
+ operation: error.operation,
+ path: error.path,
+ reason: error.reason,
+ };
if (error instanceof AnalysisResourceConstraintError)
return {
operation: error.operation,
@@ -420,6 +428,7 @@ export interface AnalysisErrorProjection extends Readonly<
| "artifact_changed"
| "unreadable_output"
| "capability_unavailable"
+ | "unsupported_target"
| "provider_unavailable"
| "provider_timeout"
| "resource_constraint"
@@ -437,6 +446,7 @@ export interface AnalysisErrorProjection extends Readonly<
readonly category:
| "invalid_input"
| "unsupported_provider"
+ | "unsupported_target"
| "integrity_mismatch"
| "truncated"
| "cancelled"
diff --git a/src/domain/evm/evmInterface.test.ts b/src/domain/evm/evmInterface.test.ts
new file mode 100644
index 000000000..659f838ba
--- /dev/null
+++ b/src/domain/evm/evmInterface.test.ts
@@ -0,0 +1,36 @@
+import { expect, it } from "vitest";
+import { evmInterfaceSchema } from "./evmInterface.js";
+import { evmInterfaceFixture } from "../../../tests/fixtures/evm/interface.js";
+
+it("preserves original carrier and decoded byte identity as distinct observations", () => {
+ const fixture = evmInterfaceFixture();
+ expect(evmInterfaceSchema.parse(fixture)).toEqual(fixture);
+ expect(fixture.artifact.sha256).not.toBe(fixture.bytecode.sha256);
+});
+it.each(["byte-count", "body-offset", "selector", "authenticity"])(
+ "rejects false interface evidence: %s",
+ (problem) => {
+ const value = evmInterfaceFixture();
+ const raw = {
+ ...value,
+ ...(problem === "authenticity"
+ ? {
+ bytecode: {
+ ...value.bytecode,
+ deployment_authenticity: "verified",
+ },
+ }
+ : {}),
+ };
+ if (problem === "byte-count") raw.bytecode.bytes++;
+ if (problem === "body-offset" || problem === "selector")
+ raw.functions.push({
+ selector: problem === "selector" ? "bad" : "0xaabbccdd",
+ bytecode_offset: problem === "body-offset" ? 5 : 0,
+ dispatch: "abi",
+ inferred_arguments: null,
+ inferred_state_mutability: null,
+ });
+ expect(evmInterfaceSchema.safeParse(raw).success).toBe(false);
+ },
+);
diff --git a/src/domain/evm/evmInterface.ts b/src/domain/evm/evmInterface.ts
new file mode 100644
index 000000000..75ada1f97
--- /dev/null
+++ b/src/domain/evm/evmInterface.ts
@@ -0,0 +1,76 @@
+import { z } from "zod";
+
+const byteOffset = z.number().int().nonnegative();
+const sha256 = z.string().regex(/^[a-f0-9]{64}$/);
+
+/** Select the carrier representation explicitly; no RPC or deployed-contract lookup is implied. */
+export const inspectEvmInterfaceInputSchema = z.strictObject({
+ path: z
+ .string()
+ .min(1)
+ .describe("Absolute path to caller-selected local bytecode carrier"),
+ encoding: z
+ .enum(["raw", "hex"])
+ .describe(
+ "Raw bytes or UTF-8 hex with optional 0x prefix and outer ASCII whitespace",
+ ),
+});
+
+/** Recovered interface candidates are inferences, independently of observed byte identity. */
+export const evmInterfaceSchema = z
+ .strictObject({
+ artifact: z.strictObject({
+ path: z.string().min(1),
+ sha256,
+ bytes: byteOffset,
+ encoding: z.enum(["raw", "hex"]),
+ }),
+ bytecode: z.strictObject({
+ sha256,
+ bytes: byteOffset,
+ hex: z.string().regex(/^(?:[0-9a-f]{2})*$/),
+ digest_algorithm: z.literal("sha256"),
+ kind: z.literal("unknown"),
+ deployment_authenticity: z.literal("unknown"),
+ hardfork: z.literal("unknown"),
+ }),
+ evidence_kind: z.literal("inferred"),
+ functions: z.array(
+ z.strictObject({
+ selector: z.string().regex(/^0x[0-9a-f]{8}$/),
+ bytecode_offset: byteOffset,
+ dispatch: z.enum(["abi", "fallback"]),
+ inferred_arguments: z.string().nullable(),
+ inferred_state_mutability: z.string().nullable(),
+ }),
+ ),
+ discovery_completeness: z.literal("unknown"),
+ runtime_execution: z.literal("not-performed"),
+ diagnostics: z.strictObject({
+ stdout: z.string(),
+ stderr: z.string(),
+ truncated: z.boolean(),
+ }),
+ limitations: z.array(z.string()),
+ })
+ .superRefine((value, context) => {
+ if (value.bytecode.hex.length !== value.bytecode.bytes * 2)
+ context.addIssue({
+ code: "custom",
+ path: ["bytecode"],
+ message:
+ "Selected byte count does not match the bytecode hex representation.",
+ });
+ for (const [index, fn] of value.functions.entries())
+ if (fn.bytecode_offset >= value.bytecode.bytes)
+ context.addIssue({
+ code: "custom",
+ path: ["functions", index, "bytecode_offset"],
+ message: "Inferred body offset lies outside the selected bytecode.",
+ });
+ });
+
+export type InspectEvmInterfaceInput = z.infer<
+ typeof inspectEvmInterfaceInputSchema
+>;
+export type EvmInterface = z.infer;
diff --git a/src/evm/EvmBytecodeCarrier.test.ts b/src/evm/EvmBytecodeCarrier.test.ts
new file mode 100644
index 000000000..b8d485f99
--- /dev/null
+++ b/src/evm/EvmBytecodeCarrier.test.ts
@@ -0,0 +1,34 @@
+import { expect, it } from "vitest";
+import {
+ decodeEvmBytecodeCarrier,
+ EvmCarrierFailure,
+} from "./EvmBytecodeCarrier.js";
+
+it.each(["6000FF", "0x6000ff", " \t0X6000FF\r\n"])(
+ "decodes explicit hex without changing selected bytes: %j",
+ (text) => {
+ expect(
+ Buffer.from(decodeEvmBytecodeCarrier(Buffer.from(text), "hex")).toString(
+ "hex",
+ ),
+ ).toBe("6000ff");
+ },
+);
+it.each(["0x1", "60 00", "0xzz", "\uFEFF6000", "\u00a06000"])(
+ "rejects malformed carrier rather than guessing bytecode: %j",
+ (text) => {
+ expect(() => decodeEvmBytecodeCarrier(Buffer.from(text), "hex")).toThrow(
+ EvmCarrierFailure,
+ );
+ },
+);
+it("distinguishes raw bytes, empty code and malformed UTF-8", () => {
+ const bytes = Uint8Array.from([0xff, 0x00]);
+ const decoded = decodeEvmBytecodeCarrier(bytes, "raw");
+ expect(decoded).toEqual(bytes);
+ expect(decoded).not.toBe(bytes);
+ expect(() => decodeEvmBytecodeCarrier(bytes, "hex")).toThrow("valid UTF-8");
+ expect(decodeEvmBytecodeCarrier(Buffer.from("0x"), "hex")).toEqual(
+ new Uint8Array(),
+ );
+});
diff --git a/src/evm/EvmBytecodeCarrier.ts b/src/evm/EvmBytecodeCarrier.ts
new file mode 100644
index 000000000..368b674af
--- /dev/null
+++ b/src/evm/EvmBytecodeCarrier.ts
@@ -0,0 +1,26 @@
+/** Expected carrier encoding failure, independent of the analysis engine. */
+export class EvmCarrierFailure extends Error {}
+
+/** Decode only the explicitly selected representation; source carrier bytes are retained separately. */
+export const decodeEvmBytecodeCarrier = (
+ bytes: Uint8Array,
+ encoding: "raw" | "hex",
+): Uint8Array => {
+ if (encoding === "raw") return Uint8Array.from(bytes);
+ let text: string;
+ try {
+ text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(
+ bytes,
+ );
+ } catch (cause: unknown) {
+ throw new EvmCarrierFailure("Hex carrier must be valid UTF-8.", { cause });
+ }
+ const code = text
+ .replace(/^[\x09-\x0d\x20]+|[\x09-\x0d\x20]+$/g, "")
+ .replace(/^0x/i, "");
+ if (!/^(?:[a-fA-F0-9]{2})*$/.test(code))
+ throw new EvmCarrierFailure(
+ "Hex carrier requires complete hexadecimal byte pairs, an optional 0x prefix and outer ASCII whitespace; interior separators are invalid.",
+ );
+ return Uint8Array.from(Buffer.from(code, "hex"));
+};
diff --git a/src/evm/EvmWorkerLimits.test.ts b/src/evm/EvmWorkerLimits.test.ts
new file mode 100644
index 000000000..fcbf8197a
--- /dev/null
+++ b/src/evm/EvmWorkerLimits.test.ts
@@ -0,0 +1,55 @@
+import { expect, it } from "vitest";
+import { selectEvmWorkerLimits } from "./EvmWorkerLimits.js";
+
+const limits = (
+ soft = "unlimited",
+ hard = "unlimited",
+) => `Limit Soft Limit Hard Limit Units
+Max address space ${soft} ${hard} bytes
+Max cpu time unlimited unlimited seconds
+Max file size unlimited unlimited bytes
+`;
+it("uses configured maxima without equating virtual space and RSS", () => {
+ expect(selectEvmWorkerLimits(limits())).toEqual({
+ ok: true,
+ value: {
+ addressSpaceBytes: 3221225472,
+ cpuSeconds: 30,
+ fileSizeBytes: 16777216,
+ },
+ });
+});
+it.each([
+ ["1073741824", "2147483648", 1073741824],
+ ["2147483648", "2147483648", 2147483648],
+ ["0", "0", 0],
+ ["9223372036854775807", "9223372036854775807", 3221225472],
+])(
+ "preserves tighter soft/hard boundaries without integer loss: %s/%s",
+ (soft, hard, expected) => {
+ expect(selectEvmWorkerLimits(limits(soft, hard))).toMatchObject({
+ ok: true,
+ value: { addressSpaceBytes: expected },
+ });
+ },
+);
+it("independently preserves CPU and file output restrictions", () => {
+ expect(
+ selectEvmWorkerLimits(
+ limits()
+ .replace("time unlimited unlimited", "time 5 10")
+ .replace("size unlimited unlimited", "size 4096 8192"),
+ ),
+ ).toMatchObject({ ok: true, value: { cpuSeconds: 5, fileSizeBytes: 4096 } });
+});
+it.each([
+ ["not-text", null],
+ ["soft-unlimited-hard-finite", limits("unlimited", "2147483648")],
+ ["soft-exceeds-hard", limits("3", "2")],
+ ["missing-row", limits().replace("Max file size", "Max other size")],
+ ["duplicate-row", limits() + "Max cpu time 1 1 seconds\n"],
+ ["wrong-unit", limits().replace("bytes", "pages")],
+ ["oversized", "x".repeat(65537)],
+])("rejects malformed/ambiguous resource observations: %s", (_label, input) => {
+ expect(selectEvmWorkerLimits(input).ok).toBe(false);
+});
diff --git a/src/evm/EvmWorkerLimits.ts b/src/evm/EvmWorkerLimits.ts
new file mode 100644
index 000000000..069152e2d
--- /dev/null
+++ b/src/evm/EvmWorkerLimits.ts
@@ -0,0 +1,58 @@
+import { err, ok, type Result } from "../domain/result.js";
+import { EVM_INTERFACE_LIMITS } from "./EvmoleRelease.js";
+
+/** Selected worker soft limits to configure; inherited hard limits are left unchanged. */
+export interface EvmWorkerLimits {
+ readonly addressSpaceBytes: number;
+ readonly cpuSeconds: number;
+ readonly fileSizeBytes: number;
+}
+
+/** Read only the three selected Linux limits, retaining tighter soft/hard boundaries. */
+export const selectEvmWorkerLimits = (
+ raw: unknown,
+): Result => {
+ if (typeof raw !== "string" || Buffer.byteLength(raw) > 64 * 1024)
+ return err(
+ "Linux resource limits must be text within the 64 KiB parsing budget.",
+ );
+ const selected: number[] = [];
+ for (const [name, units, maximum] of [
+ ["Max address space", "bytes", EVM_INTERFACE_LIMITS.addressSpaceBytes],
+ ["Max cpu time", "seconds", 30],
+ ["Max file size", "bytes", EVM_INTERFACE_LIMITS.outputBytes],
+ ] as const) {
+ const entries = raw
+ .split("\n")
+ .filter((line) => line.startsWith(name + " "));
+ if (entries.length !== 1)
+ return err(`Linux resource limits must report exactly one ${name} row.`);
+ const match = new RegExp(
+ `^${name}\\s+(unlimited|[0-9]+)\\s+(unlimited|[0-9]+)\\s+${units}\\s*$`,
+ ).exec(entries[0] ?? "");
+ if (match === null)
+ return err(`Malformed inherited Linux resource limit: ${name}.`);
+ let effective = BigInt(maximum);
+ const soft = match[1];
+ const hard = match[2];
+ if (soft === undefined || hard === undefined)
+ return err(`Missing ${name} values.`);
+ if (
+ hard !== "unlimited" &&
+ (soft === "unlimited" || BigInt(soft) > BigInt(hard))
+ )
+ return err(`Inherited ${name} soft limit exceeds its hard limit.`);
+ for (const value of [soft, hard])
+ if (value !== "unlimited" && BigInt(value) < effective)
+ effective = BigInt(value);
+ selected.push(Number(effective));
+ }
+ const [addressSpaceBytes, cpuSeconds, fileSizeBytes] = selected;
+ if (
+ addressSpaceBytes === undefined ||
+ cpuSeconds === undefined ||
+ fileSizeBytes === undefined
+ )
+ return err("Required Linux resource limits were not selected.");
+ return ok({ addressSpaceBytes, cpuSeconds, fileSizeBytes });
+};
diff --git a/src/evm/EvmoleFailures.ts b/src/evm/EvmoleFailures.ts
new file mode 100644
index 000000000..5690a079a
--- /dev/null
+++ b/src/evm/EvmoleFailures.ts
@@ -0,0 +1,205 @@
+import type { EvmWorkerLimits } from "./EvmWorkerLimits.js";
+import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
+import {
+ AnalysisError,
+ type AnalysisCapturedOutput,
+} from "../domain/analysisErrorBase.js";
+import type { ProviderProcessSnapshot } from "../process/ProviderProcess.js";
+import {
+ AnalysisAccessDeniedError,
+ AnalysisArtifactChangedError,
+ AnalysisCapabilityUnavailableError,
+ AnalysisCancelledError,
+ AnalysisInputError,
+ AnalysisOutputError,
+ AnalysisResourceConstraintError,
+ AnalysisTimeoutError,
+} from "../domain/analysisErrorCore.js";
+import { ProviderAdapterError } from "../domain/providerAdapterError.js";
+import { ProviderCleanupError } from "../domain/providerCleanupError.js";
+import { OwnedCommandFailure } from "../process/OwnedCommand.js";
+import {
+ EVMOLE_PROVIDER_IDENTITY,
+ EVM_INTERFACE_LIMITS,
+ EVM_FILE_SIZE_FAILURE_EXIT,
+} from "./EvmoleRelease.js";
+
+/** Correlate a reserved exit with the owned worker's actual failure branch. */
+export interface EvmFileSizeFailureEvidence {
+ readonly verified: boolean;
+ readonly failure: string | null;
+}
+
+/** Keep carrier read, engine and owned lifecycle failures distinct. */
+export const evmInterfaceFailure = (
+ cause: unknown,
+ phase: "configuration" | "artifact-read" | "worker",
+ path: string,
+ workerLimits?: EvmWorkerLimits,
+ limiter = "/usr/bin/prlimit",
+ fileFailure?: EvmFileSizeFailureEvidence,
+): AnalysisError => {
+ if (cause instanceof AnalysisError) return cause;
+ if (cause instanceof OwnedCommandFailure) {
+ const outputOptions =
+ cause.snapshot === null
+ ? undefined
+ : { capturedOutput: capturedEvmOutput(cause.snapshot) };
+ if (cause.cleanupFailure !== null)
+ return new ProviderCleanupError(
+ EVMOLE_PROVIDER_IDENTITY.id,
+ cause.resources,
+ {
+ reason: cause.cleanupFailure,
+ ...(cause.snapshot === null
+ ? {}
+ : { captured_output: { ...capturedEvmOutput(cause.snapshot) } }),
+ previous_error: {
+ failure_kind: cause.reason,
+ message: cause.message,
+ exit_code: cause.snapshot?.exitCode ?? null,
+ signal: cause.snapshot?.signal ?? null,
+ stdout: cause.snapshot?.stdout.text ?? null,
+ stderr: cause.snapshot?.stderr.text ?? null,
+ },
+ },
+ { operation: "inspect_evm_interface", cause },
+ );
+ if (cause.reason === "cancelled")
+ return new AnalysisCancelledError("inspect_evm_interface", outputOptions);
+ if (cause.reason === "timeout")
+ return new AnalysisTimeoutError(
+ "inspect_evm_interface",
+ EVM_INTERFACE_LIMITS.timeoutMs,
+ outputOptions,
+ );
+ if (cause.reason === "output-limit")
+ return new AnalysisOutputError(
+ "inspect_evm_interface",
+ cause.message,
+ outputOptions,
+ );
+ if (
+ cause.reason === "process" &&
+ (cause.snapshot?.signal === "SIGXCPU" ||
+ cause.snapshot?.signal === "SIGXFSZ" ||
+ (fileFailure?.verified === true &&
+ cause.snapshot?.exitCode === EVM_FILE_SIZE_FAILURE_EXIT &&
+ cause.snapshot.signal === null))
+ )
+ return new AnalysisResourceConstraintError(
+ "inspect_evm_interface",
+ cause.snapshot.signal === "SIGXCPU" ? "cpu" : "file-size",
+ (cause.snapshot.signal === null
+ ? "Owned EVM worker reported EFBIG while writing its reply; the exact write failure cause is unknown."
+ : `Owned EVM command terminated with ${cause.snapshot.signal}; the exact signal cause is unknown.`) +
+ " Effective worker limits are unknown. Configured soft limits are retained separately.",
+ workerLimits === undefined
+ ? null
+ : {
+ configured_soft_limits: {
+ address_space_bytes: workerLimits.addressSpaceBytes,
+ cpu_seconds: workerLimits.cpuSeconds,
+ file_size_bytes: workerLimits.fileSizeBytes,
+ },
+ effective_soft_limits: null,
+ },
+ outputOptions,
+ );
+ }
+ if (
+ cause instanceof Error &&
+ "code" in cause &&
+ "syscall" in cause &&
+ typeof cause.syscall === "string" &&
+ cause.syscall.startsWith("spawn ")
+ ) {
+ const reason = `Configured util-linux prlimit could not launch (${String(cause.code)}): ${limiter}. Check its executable/interpreter and host execute access.`;
+ return new AnalysisCapabilityUnavailableError(
+ EVMOLE_PROVIDER_IDENTITY.id,
+ "inspect_evm_interface",
+ reason,
+ { cause, userMessage: reason },
+ );
+ }
+ if (cause instanceof ArtifactReaderFailure) {
+ if (cause.reason === "cancelled")
+ return new AnalysisCancelledError("inspect_evm_interface");
+ if (cause.reason === "integrity")
+ return new AnalysisArtifactChangedError(
+ "inspect_evm_interface",
+ path,
+ cause.message,
+ { cause },
+ );
+ if (
+ phase === "artifact-read" &&
+ ["limit", "path", "format"].includes(cause.reason)
+ )
+ return new AnalysisInputError("inspect_evm_interface", { cause }, [
+ {
+ path: ["path"],
+ reason: cause.reason === "limit" ? "out_of_range" : "invalid_format",
+ message: cause.message,
+ },
+ ]);
+ }
+ if (phase === "artifact-read" && cause instanceof Error && "code" in cause) {
+ if (cause.code === "EACCES" || cause.code === "EPERM")
+ return new AnalysisAccessDeniedError(
+ "inspect_evm_interface",
+ path,
+ cause.code,
+ { cause },
+ );
+ if (cause.code === "ENOENT" || cause.code === "ENOTDIR")
+ return new AnalysisInputError("inspect_evm_interface", { cause }, [
+ {
+ path: ["path"],
+ reason: "invalid_value",
+ message: `Selected bytecode carrier could not be read (${String(cause.code)}): ${path}.`,
+ },
+ ]);
+ }
+ return new ProviderAdapterError(
+ EVMOLE_PROVIDER_IDENTITY.id,
+ "inspect_evm_interface",
+ {
+ cause,
+ diagnostics: {
+ phase,
+ path,
+ ...(workerLimits === undefined
+ ? {}
+ : { configured_resource_limits: { ...workerLimits } }),
+ reason: cause instanceof Error ? cause.message : String(cause),
+ ...(fileFailure === undefined
+ ? {}
+ : { file_size_failure_marker: { ...fileFailure } }),
+ ...(cause instanceof OwnedCommandFailure
+ ? {
+ failure_kind: cause.reason,
+ exit_code: cause.snapshot?.exitCode ?? null,
+ signal: cause.snapshot?.signal ?? null,
+ stdout: cause.snapshot?.stdout.text ?? null,
+ stderr: cause.snapshot?.stderr.text ?? null,
+ ...(cause.snapshot === null
+ ? {}
+ : {
+ captured_output: { ...capturedEvmOutput(cause.snapshot) },
+ }),
+ }
+ : {}),
+ },
+ },
+ );
+};
+
+/** Preserve retained worker diagnostics with their observed truncation status. */
+export const capturedEvmOutput = (
+ snapshot: ProviderProcessSnapshot,
+): AnalysisCapturedOutput => ({
+ stdout: snapshot.stdout.text,
+ stderr: snapshot.stderr.text,
+ truncated: snapshot.diagnosticTruncated === true,
+});
diff --git a/src/evm/EvmoleInterfaceAdapter.test.ts b/src/evm/EvmoleInterfaceAdapter.test.ts
new file mode 100644
index 000000000..a8b53f280
--- /dev/null
+++ b/src/evm/EvmoleInterfaceAdapter.test.ts
@@ -0,0 +1,66 @@
+import { expect, it } from "vitest";
+import { projectEvmoleInterface } from "./EvmoleInterfaceAdapter.js";
+
+it("preserves the actual producer keys, duplicates and unknown inference absence", () => {
+ const raw = {
+ functions: [
+ { selector: "aabbccdd", bytecodeOffset: 1, dispatch: "abi" },
+ {
+ selector: "aabbccdd",
+ bytecodeOffset: 2,
+ dispatch: "fallback",
+ arguments: "uint256",
+ stateMutability: "view",
+ },
+ ],
+ };
+ const result = projectEvmoleInterface(raw);
+ expect(result.raw).toEqual(raw);
+ expect(result.functions).toEqual([
+ {
+ selector: "0xaabbccdd",
+ bytecode_offset: 1,
+ dispatch: "abi",
+ inferred_arguments: null,
+ inferred_state_mutability: null,
+ },
+ {
+ selector: "0xaabbccdd",
+ bytecode_offset: 2,
+ dispatch: "fallback",
+ inferred_arguments: "uint256",
+ inferred_state_mutability: "view",
+ },
+ ]);
+ expect(projectEvmoleInterface({}).functions).toEqual([]);
+ const metadata = {
+ bytecodeOffset: 1,
+ cborLength: 4,
+ entries: [
+ { key: "reported", value: { type: "integer", value: 9007199254740992 } },
+ ],
+ };
+ expect(
+ projectEvmoleInterface({ functions: [], metadata }).raw.metadata,
+ ).toEqual(metadata);
+ expect(
+ projectEvmoleInterface({
+ functions: [],
+ storage: undefined,
+ transientStorage: undefined,
+ disassembled: undefined,
+ basicBlocks: undefined,
+ controlFlowGraph: undefined,
+ metadata: undefined,
+ }).functions,
+ ).toEqual([]);
+});
+it.each([
+ { functions: [{ selector: "bad", bytecodeOffset: 0, dispatch: "abi" }] },
+ {
+ functions: [{ selector: "aabbccdd", bytecodeOffset: -1, dispatch: "abi" }],
+ },
+ { metadata: {} },
+])("rejects producer representations outside the selected profile", (raw) => {
+ expect(() => projectEvmoleInterface(raw)).toThrow();
+});
diff --git a/src/evm/EvmoleInterfaceAdapter.ts b/src/evm/EvmoleInterfaceAdapter.ts
new file mode 100644
index 000000000..da62d28b9
--- /dev/null
+++ b/src/evm/EvmoleInterfaceAdapter.ts
@@ -0,0 +1,59 @@
+import { z } from "zod";
+
+const offset = z.number().int().nonnegative();
+const reportedMetadata = z.strictObject({
+ bytecodeOffset: offset,
+ cborLength: offset,
+ entries: z.array(
+ z.strictObject({
+ key: z.string(),
+ value: z.discriminatedUnion("type", [
+ z.strictObject({ type: z.literal("string"), value: z.string() }),
+ z.strictObject({ type: z.literal("integer"), value: z.number() }),
+ z.strictObject({ type: z.literal("bytes"), value: z.string() }),
+ z.strictObject({ type: z.literal("bool"), value: z.boolean() }),
+ z.strictObject({ type: z.literal("undecoded"), value: z.string() }),
+ ]),
+ }),
+ ),
+});
+/** Parse the exact producer representation before assigning portable field names. */
+export const evmoleInterfaceReplySchema = z.strictObject({
+ // The unchanged JS binding exposes disabled facets as own undefined keys.
+ // Their JSON transport representation is absence; actual facet values are
+ // outside this interface-only profile and must still fail validation.
+ storage: z.undefined().optional(),
+ transientStorage: z.undefined().optional(),
+ disassembled: z.undefined().optional(),
+ basicBlocks: z.undefined().optional(),
+ controlFlowGraph: z.undefined().optional(),
+ // Upstream also extracts metadata when selectors are requested, regardless
+ // of its metadata flag. Retain its reported values without precision claims.
+ metadata: reportedMetadata.optional(),
+ functions: z
+ .array(
+ z.strictObject({
+ selector: z.string().regex(/^[a-f0-9]{8}$/),
+ bytecodeOffset: offset,
+ dispatch: z.enum(["abi", "fallback"]),
+ arguments: z.string().optional(),
+ stateMutability: z.string().optional(),
+ }),
+ )
+ .optional(),
+});
+
+/** Preserve inference strings and unknown absence; selectors use the conventional 0x display prefix. */
+export const projectEvmoleInterface = (raw: unknown) => {
+ const producer = evmoleInterfaceReplySchema.parse(raw);
+ return {
+ raw: producer,
+ functions: (producer.functions ?? []).map((fn) => ({
+ selector: `0x${fn.selector}`,
+ bytecode_offset: fn.bytecodeOffset,
+ dispatch: fn.dispatch,
+ inferred_arguments: fn.arguments ?? null,
+ inferred_state_mutability: fn.stateMutability ?? null,
+ })),
+ };
+};
diff --git a/src/evm/EvmoleInterfaceProvider.ts b/src/evm/EvmoleInterfaceProvider.ts
new file mode 100644
index 000000000..2316f44a7
--- /dev/null
+++ b/src/evm/EvmoleInterfaceProvider.ts
@@ -0,0 +1,401 @@
+import { createHash, randomUUID } from "node:crypto";
+import { access, readFile, stat, writeFile } from "node:fs/promises";
+import { constants } from "node:fs";
+import { isAbsolute, join } from "node:path";
+import { fileURLToPath } from "node:url";
+import { z } from "zod";
+import {
+ createAnalysisExecution,
+ type AnalysisExecution,
+ type ExecutionOptions,
+} from "../application/AnalysisProvider.js";
+import type { EvmInterfacePort } from "../application/evm/EvmInterfacePort.js";
+import { readStableArtifact } from "../artifacts/readStableArtifact.js";
+import { OwnedCommandFailure } from "../process/OwnedCommand.js";
+import type {
+ AnalysisError,
+ AnalysisCapturedOutput,
+} from "../domain/analysisErrorBase.js";
+import {
+ AnalysisCancelledError,
+ AnalysisCapabilityUnavailableError,
+ AnalysisUnsupportedTargetError,
+ AnalysisInputError,
+ AnalysisOutputError,
+} from "../domain/analysisErrorCore.js";
+import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
+import type { InspectEvmInterfaceInput } from "../domain/evm/evmInterface.js";
+import { evmInterfaceSchema } from "../domain/evm/evmInterface.js";
+import { ProviderAdapterError } from "../domain/providerAdapterError.js";
+import { ProviderCleanupError } from "../domain/providerCleanupError.js";
+import { err, ok, type Result } from "../domain/result.js";
+import { runOwnedCommand } from "../process/OwnedCommand.js";
+import { PrivateRuntimeRoot } from "../process/PrivateRuntimeRoot.js";
+import {
+ evmInterfaceFailure,
+ capturedEvmOutput,
+ type EvmFileSizeFailureEvidence,
+} from "./EvmoleFailures.js";
+import {
+ evmoleInterfaceReplySchema,
+ projectEvmoleInterface,
+} from "./EvmoleInterfaceAdapter.js";
+import {
+ EVMOLE_PROVIDER_IDENTITY,
+ EVM_INTERFACE_LIMITS,
+ EVM_FILE_SIZE_FAILURE_EXIT,
+} from "./EvmoleRelease.js";
+import { decodeEvmBytecodeCarrier } from "./EvmBytecodeCarrier.js";
+import {
+ selectEvmWorkerLimits,
+ type EvmWorkerLimits,
+} from "./EvmWorkerLimits.js";
+
+const OPERATION = "inspect_evm_interface";
+const replySchema = z.discriminatedUnion("ok", [
+ z.strictObject({
+ ok: z.literal(true),
+ version: z.literal("0.9.3"),
+ bytecode: z.strictObject({
+ sha256: z.string().regex(/^[a-f0-9]{64}$/),
+ bytes: z.number().int().nonnegative(),
+ hex: z.string().regex(/^(?:[a-f0-9]{2})*$/),
+ }),
+ raw: evmoleInterfaceReplySchema,
+ }),
+ z.strictObject({
+ ok: z.literal(false),
+ reason: z.enum(["format", "decoder", "output-limit", "unsupported"]),
+ message: z.string(),
+ }),
+]);
+type Launcher = NonNullable[2]>["launcher"];
+
+/** Run unchanged EVMole/WASM in an owned worker; no target, chain or network execution. */
+export class EvmoleInterfaceProvider implements EvmInterfacePort {
+ constructor(
+ readonly environment: Readonly = process.env,
+ readonly launcher?: Launcher,
+ readonly createRuntime: () => Promise<
+ Pick
+ > = () => PrivateRuntimeRoot.create({ prefix: "rea-evm-interface-" }),
+ ) {}
+
+ /** Bind recovered interface candidates to both original carrier and decoded byte identity. */
+ async inspect(
+ input: InspectEvmInterfaceInput,
+ options?: ExecutionOptions,
+ ): Promise> {
+ let root: Pick | undefined;
+ let retainedOutput: AnalysisCapturedOutput | undefined;
+ let phase: "configuration" | "artifact-read" | "worker" = "configuration";
+ let workerLimits: EvmWorkerLimits | undefined;
+ const limiter =
+ this.environment.REA_EVM_PRLIMIT_COMMAND ?? "/usr/bin/prlimit";
+ let result: Result;
+ try {
+ if (options?.signal?.aborted) throw new AnalysisCancelledError(OPERATION);
+ if (process.platform !== "linux" || process.arch !== "x64")
+ throw new AnalysisCapabilityUnavailableError(
+ EVMOLE_PROVIDER_IDENTITY.id,
+ OPERATION,
+ "Initial real-verified offline EVM interface profile supports Linux x64 only.",
+ );
+ try {
+ if (!isAbsolute(limiter))
+ throw new Error(
+ "REA_EVM_PRLIMIT_COMMAND must be an absolute executable path.",
+ );
+ await access(limiter, constants.X_OK);
+ if (!(await stat(limiter)).isFile())
+ throw new Error(
+ "Configured prlimit must be a regular executable file, not a directory.",
+ );
+ } catch (cause: unknown) {
+ const reason = `Offline EVM interface inspection requires caller-supplied util-linux prlimit: ${limiter}; ${cause instanceof Error ? cause.message : String(cause)}`;
+ throw new AnalysisCapabilityUnavailableError(
+ EVMOLE_PROVIDER_IDENTITY.id,
+ OPERATION,
+ reason,
+ { cause, userMessage: reason },
+ );
+ }
+ let inheritedLimits: string;
+ try {
+ inheritedLimits = await readFile("/proc/self/limits", "utf8");
+ } catch (cause: unknown) {
+ const reason = `Could not read inherited Linux resource limits at /proc/self/limits: ${cause instanceof Error ? cause.message : String(cause)}`;
+ throw new AnalysisCapabilityUnavailableError(
+ EVMOLE_PROVIDER_IDENTITY.id,
+ OPERATION,
+ reason,
+ { cause, userMessage: reason },
+ );
+ }
+ const limits = selectEvmWorkerLimits(inheritedLimits);
+ if (!limits.ok)
+ throw new AnalysisCapabilityUnavailableError(
+ EVMOLE_PROVIDER_IDENTITY.id,
+ OPERATION,
+ limits.error,
+ { userMessage: limits.error },
+ );
+ workerLimits = limits.value;
+ phase = "artifact-read";
+ const carrier = await readStableArtifact(
+ input.path,
+ EVM_INTERFACE_LIMITS.inputBytes,
+ options?.signal,
+ );
+ phase = "worker";
+ root = await this.createRuntime();
+ const snapshotPath = join(root.path, "carrier.snapshot");
+ const requestPath = join(root.path, "request.json");
+ const replyPath = join(root.path, "reply.json");
+ await writeFile(snapshotPath, carrier.bytes, {
+ flag: "wx",
+ mode: 0o600,
+ ...(options?.signal === undefined ? {} : { signal: options.signal }),
+ });
+ await writeFile(
+ requestPath,
+ JSON.stringify({
+ snapshot_path: snapshotPath,
+ reply_path: replyPath,
+ failure_marker_path: join(root.path, "resource.failure"),
+ encoding: input.encoding,
+ }),
+ { flag: "wx", mode: 0o600 },
+ );
+ const { NODE_OPTIONS: _ambientNodeOptions, ...environment } =
+ this.environment;
+ const execution = await runOwnedCommand(
+ {
+ command: limiter,
+ arguments: [
+ `--as=${String(workerLimits.addressSpaceBytes)}:`,
+ `--fsize=${String(workerLimits.fileSizeBytes)}:`,
+ "--core=0:",
+ `--cpu=${String(workerLimits.cpuSeconds)}:`,
+ "--",
+ process.execPath,
+ "--max-old-space-size=256",
+ "--v8-pool-size=1",
+ "--disable-wasm-trap-handler",
+ fileURLToPath(
+ new URL("./EvmoleInterfaceWorker.js", import.meta.url),
+ ),
+ requestPath,
+ ],
+ cwd: root.path,
+ expectedCommand: null,
+ runId: `rea-evm-interface-${randomUUID()}`,
+ hostEnvironment: environment,
+ },
+ {
+ timeoutMs: EVM_INTERFACE_LIMITS.timeoutMs,
+ diagnosticBytes: EVM_INTERFACE_LIMITS.diagnosticBytes,
+ },
+ {
+ ...(options?.signal === undefined ? {} : { signal: options.signal }),
+ ...(this.launcher === undefined ? {} : { launcher: this.launcher }),
+ },
+ );
+ const capturedOutput = capturedEvmOutput(execution);
+ retainedOutput = capturedOutput;
+ let reply: z.output;
+ try {
+ const file = await readStableArtifact(
+ replyPath,
+ EVM_INTERFACE_LIMITS.outputBytes,
+ options?.signal,
+ );
+ reply = replySchema.parse(JSON.parse(file.bytes.toString("utf8")));
+ } catch (cause: unknown) {
+ if (options?.signal?.aborted)
+ throw new AnalysisCancelledError(OPERATION, { capturedOutput });
+ throw new AnalysisOutputError(
+ OPERATION,
+ `Owned interface worker reply failed for ${input.path}: ${cause instanceof Error ? cause.message : String(cause)}`,
+ { cause, capturedOutput },
+ );
+ }
+ if (!reply.ok) {
+ if (reply.reason === "unsupported")
+ throw new AnalysisUnsupportedTargetError(
+ OPERATION,
+ input.path,
+ reply.message,
+ { capturedOutput },
+ );
+ if (reply.reason === "format")
+ throw new AnalysisInputError(OPERATION, { capturedOutput }, [
+ {
+ path: ["path"],
+ reason: "invalid_format",
+ message: reply.message,
+ },
+ ]);
+ if (reply.reason === "output-limit")
+ throw new AnalysisOutputError(OPERATION, reply.message, {
+ capturedOutput,
+ });
+ throw new ProviderAdapterError(EVMOLE_PROVIDER_IDENTITY.id, OPERATION, {
+ diagnostics: {
+ phase,
+ failure_kind: reply.reason,
+ reason: reply.message,
+ stdout: execution.stdout.text,
+ stderr: execution.stderr.text,
+ captured_output: { ...capturedOutput },
+ },
+ });
+ }
+ const selectedBytes = decodeEvmBytecodeCarrier(
+ carrier.bytes,
+ input.encoding,
+ );
+ const selectedHex = Buffer.from(selectedBytes).toString("hex");
+ const decodedHash = createHash("sha256")
+ .update(selectedBytes)
+ .digest("hex");
+ if (
+ decodedHash !== reply.bytecode.sha256 ||
+ selectedHex !== reply.bytecode.hex ||
+ selectedBytes.length !== reply.bytecode.bytes
+ )
+ throw new AnalysisOutputError(
+ OPERATION,
+ "Decoded bytecode representation does not match the selected carrier, encoding or reported SHA-256.",
+ { capturedOutput },
+ );
+ const limitations = [
+ `Configured worker resource soft limits: address_space_bytes=${String(workerLimits.addressSpaceBytes)}, cpu_seconds=${String(workerLimits.cpuSeconds)}, file_size_bytes=${String(workerLimits.fileSizeBytes)}. Selected from inherited tighter limits; original hard limits are retained. Effective worker limits are not independently probed.`,
+ "Upstream may also report terminal metadata while recovering selectors. Its reported values are retained in raw_result, but integer precision and non-text-key coverage are unknown in the JS binding; complete original bytecode remains available.",
+ "Recovered selectors, argument strings and mutability are upstream inferences, not verified ABI signatures or names. Empty results do not prove the absence of callable dispatch.",
+ "Bytecode kind, hardfork and deployed-contract authenticity are unknown. This inspection performs no target runtime execution, chain/RPC lookup or network request.",
+ "Original carrier and decoded bytecode SHA-256 are distinct identities; SHA-256 is not the Ethereum Keccak code hash.",
+ "The owned worker has a 256 MiB JavaScript heap, 3 GiB virtual address-space limit and 30-second deadline. Virtual address space is distinct from resident memory; limits fail without partial success.",
+ ];
+ const validated = evmInterfaceSchema.safeParse({
+ artifact: {
+ path: input.path,
+ sha256: carrier.sha256,
+ bytes: carrier.bytes.length,
+ encoding: input.encoding,
+ },
+ bytecode: {
+ ...reply.bytecode,
+ digest_algorithm: "sha256",
+ kind: "unknown",
+ deployment_authenticity: "unknown",
+ hardfork: "unknown",
+ },
+ evidence_kind: "inferred",
+ functions: projectEvmoleInterface(reply.raw).functions,
+ discovery_completeness: "unknown",
+ runtime_execution: "not-performed",
+ diagnostics: {
+ stdout: execution.stdout.text,
+ stderr: execution.stderr.text,
+ truncated: capturedOutput.truncated,
+ },
+ limitations,
+ });
+ if (!validated.success)
+ throw new AnalysisOutputError(
+ OPERATION,
+ `Interface worker returned invalid bytecode-relative evidence: ${validated.error.issues[0]?.message ?? "schema mismatch"}`,
+ { capturedOutput },
+ );
+ const report = validated.data;
+ result = ok(
+ createAnalysisExecution(report, EVMOLE_PROVIDER_IDENTITY, {
+ rawResult: reply.raw,
+ subject: { path: input.path, format: "file", sha256: carrier.sha256 },
+ locations: [{ kind: "artifact-path", path: input.path }],
+ limitations,
+ }),
+ );
+ } catch (cause: unknown) {
+ let fileFailure: EvmFileSizeFailureEvidence | undefined;
+ if (
+ cause instanceof OwnedCommandFailure &&
+ cause.snapshot?.exitCode === EVM_FILE_SIZE_FAILURE_EXIT &&
+ cause.snapshot.signal === null &&
+ root !== undefined
+ ) {
+ try {
+ const marker = await readStableArtifact(
+ join(root.path, "resource.failure"),
+ 1,
+ );
+ if (!marker.bytes.equals(Buffer.from("F")))
+ throw new Error(
+ "Private worker failure marker does not match the observed exit status.",
+ );
+ fileFailure = { verified: true, failure: null };
+ } catch (markerFailure: unknown) {
+ fileFailure = {
+ verified: false,
+ failure:
+ markerFailure instanceof Error
+ ? markerFailure.message
+ : String(markerFailure),
+ };
+ }
+ }
+ result = err(
+ options?.signal?.aborted &&
+ (cause === options.signal.reason ||
+ (cause instanceof Error && cause.name === "AbortError"))
+ ? new AnalysisCancelledError(
+ OPERATION,
+ retainedOutput === undefined
+ ? undefined
+ : { capturedOutput: retainedOutput },
+ )
+ : evmInterfaceFailure(
+ cause,
+ phase,
+ input.path,
+ workerLimits,
+ limiter,
+ fileFailure,
+ ),
+ );
+ }
+ if (root !== undefined) {
+ try {
+ await root.close();
+ } catch (cause: unknown) {
+ return err(
+ new ProviderCleanupError(
+ EVMOLE_PROVIDER_IDENTITY.id,
+ [root.path],
+ {
+ reason: cause instanceof Error ? cause.message : String(cause),
+ previous_error: result.ok
+ ? null
+ : projectAnalysisError(result.error),
+ ...(retainedOutput === undefined
+ ? {}
+ : { captured_output: { ...retainedOutput } }),
+ },
+ { operation: OPERATION },
+ ),
+ );
+ }
+ }
+ return result.ok && options?.signal?.aborted
+ ? err(
+ new AnalysisCancelledError(
+ OPERATION,
+ retainedOutput === undefined
+ ? undefined
+ : { capturedOutput: retainedOutput },
+ ),
+ )
+ : result;
+ }
+}
diff --git a/src/evm/EvmoleInterfaceWorker.ts b/src/evm/EvmoleInterfaceWorker.ts
new file mode 100644
index 000000000..061ceb47e
--- /dev/null
+++ b/src/evm/EvmoleInterfaceWorker.ts
@@ -0,0 +1,108 @@
+import { createHash } from "node:crypto";
+import { readFile, writeFile } from "node:fs/promises";
+import { z } from "zod";
+import {
+ decodeEvmBytecodeCarrier,
+ EvmCarrierFailure,
+} from "./EvmBytecodeCarrier.js";
+import { projectEvmoleInterface } from "./EvmoleInterfaceAdapter.js";
+import {
+ EVM_INTERFACE_LIMITS,
+ EVM_FILE_SIZE_FAILURE_EXIT,
+} from "./EvmoleRelease.js";
+
+const requestSchema = z.strictObject({
+ snapshot_path: z.string(),
+ reply_path: z.string(),
+ failure_marker_path: z.string(),
+ encoding: z.enum(["raw", "hex"]),
+});
+class UnsupportedContainer extends Error {}
+
+const main = async (): Promise => {
+ const requestPath = process.argv[2];
+ if (requestPath === undefined)
+ throw new Error("Owned worker request path missing.");
+ const request = requestSchema.parse(
+ JSON.parse(await readFile(requestPath, "utf8")),
+ );
+ let reply: unknown;
+ try {
+ const bytes = decodeEvmBytecodeCarrier(
+ await readFile(request.snapshot_path),
+ request.encoding,
+ );
+ if (bytes[0] === 0xef && bytes[1] === 0x00)
+ throw new UnsupportedContainer(
+ "Container marker EF00 is outside the verified flat-bytecode interface profile.",
+ );
+ const modulePath = import.meta.resolve("evmole/no_tla");
+ const metadata = z
+ .object({ version: z.literal("0.9.3") })
+ .parse(
+ JSON.parse(
+ await readFile(new URL("../package.json", modulePath), "utf8"),
+ ),
+ );
+ const engine = await import("evmole/no_tla");
+ engine.initSync({
+ module: await readFile(
+ new URL(import.meta.resolve("evmole/evmole_bg.wasm")),
+ ),
+ });
+ const inferred = projectEvmoleInterface(
+ engine.contractInfo(Buffer.from(bytes).toString("hex"), {
+ selectors: true,
+ arguments: true,
+ stateMutability: true,
+ }),
+ );
+ reply = {
+ ok: true,
+ version: metadata.version,
+ bytecode: {
+ sha256: createHash("sha256").update(bytes).digest("hex"),
+ bytes: bytes.length,
+ hex: Buffer.from(bytes).toString("hex"),
+ },
+ raw: inferred.raw,
+ };
+ } catch (cause: unknown) {
+ reply = {
+ ok: false,
+ reason:
+ cause instanceof EvmCarrierFailure
+ ? "format"
+ : cause instanceof UnsupportedContainer
+ ? "unsupported"
+ : "decoder",
+ message: cause instanceof Error ? cause.message : String(cause),
+ };
+ }
+ let encoded = JSON.stringify(reply);
+ if (Buffer.byteLength(encoded) > EVM_INTERFACE_LIMITS.outputBytes)
+ encoded = JSON.stringify({
+ ok: false,
+ reason: "output-limit",
+ message: "Complete interface evidence exceeds the 16 MiB reply budget.",
+ });
+ try {
+ await writeFile(request.reply_path, encoded, { flag: "wx", mode: 0o600 });
+ } catch (cause: unknown) {
+ if (cause instanceof Error && "code" in cause && cause.code === "EFBIG") {
+ try {
+ await writeFile(request.failure_marker_path, "F", {
+ flag: "wx",
+ mode: 0o600,
+ });
+ } catch (markerFailure: unknown) {
+ process.stderr.write(
+ `File-size failure marker unavailable: ${markerFailure instanceof Error ? markerFailure.message : String(markerFailure)}\n`,
+ );
+ }
+ process.exitCode = EVM_FILE_SIZE_FAILURE_EXIT;
+ } else throw cause;
+ }
+};
+
+await main();
diff --git a/src/evm/EvmoleRelease.ts b/src/evm/EvmoleRelease.ts
new file mode 100644
index 000000000..8e8ea10fc
--- /dev/null
+++ b/src/evm/EvmoleRelease.ts
@@ -0,0 +1,16 @@
+/** Unchanged locked npm/WASM profile; provenance is independent of chain deployment authenticity. */
+export const EVMOLE_PROVIDER_IDENTITY = {
+ id: "evmole-interface",
+ name: "REA EVM interface adapter",
+ version: "evmole@0.9.3",
+} as const;
+/** Worker-reserved status for an observed EFBIG write without a complete reply. */
+export const EVM_FILE_SIZE_FAILURE_EXIT = 76;
+/** Complete offline inspection budgets; worker JavaScript heap is bounded separately. */
+export const EVM_INTERFACE_LIMITS = {
+ inputBytes: 4 * 1024 * 1024,
+ outputBytes: 16 * 1024 * 1024,
+ timeoutMs: 30_000,
+ diagnosticBytes: 1024 * 1024,
+ addressSpaceBytes: 3 * 1024 * 1024 * 1024,
+} as const;
diff --git a/src/generatedPackageMetadata.ts b/src/generatedPackageMetadata.ts
index fc03cee9f..d56f777c4 100644
--- a/src/generatedPackageMetadata.ts
+++ b/src/generatedPackageMetadata.ts
@@ -5,5 +5,5 @@ export const PACKAGE_METADATA = {
serverSdkVersion: "2.3.1",
clientSdkVersion: "2.3.1",
coreSdkVersion: "2.3.1",
- skillVersion: "31",
+ skillVersion: "32",
} as const;
diff --git a/src/server/createServer.ts b/src/server/createServer.ts
index 1a69fecac..e0291e09f 100644
--- a/src/server/createServer.ts
+++ b/src/server/createServer.ts
@@ -1,3 +1,6 @@
+import type { EvmInterfaceService } from "../application/evm/EvmInterfaceService.js";
+import { createEvmInterfaceService } from "../composition/evm.js";
+import { registerEvmTools } from "./registerEvmTools.js";
import { registerBinaryDiagnosticsTools } from "./registerBinaryDiagnosticsTools.js";
import { createBinaryLayoutService } from "../composition/binaryDiagnostics.js";
import type { BinaryLayoutService } from "../application/binaryDiagnostics/BinaryLayoutService.js";
@@ -62,6 +65,7 @@ const ACTIVE_TARGET_INSTRUCTIONS =
"REA analyzes the active reverse-engineering target. Use the analysis tool that answers the question directly. Search or list symbols when discovery is needed; analyze_function provides a function dossier, and focused procedure tools return individual facets.";
export interface CreateServerOptions {
+ readonly evmInterface?: EvmInterfaceService;
readonly logger?: Logger;
readonly binaryLayout?: BinaryLayoutService;
readonly firmwareAnalysis?: FirmwareAnalysisPort;
@@ -89,6 +93,9 @@ const installSessionToolAvailability = (
const policy = sessionAvailabilityPolicy(options.availabilityPolicy, {
optionalProviderLoadFailures: options.optionalProviderLoadFailures,
optionalFeatures: {
+ evmInterfaceEnabled:
+ options.evmInterface !== undefined ||
+ (process.platform === "linux" && process.arch === "x64"),
webModuleResolutionEnabled:
options.webModuleTrace !== undefined ||
isAbsolute(process.env.REA_BROWSER_EXECUTABLE ?? ""),
@@ -205,6 +212,12 @@ export const createServer = (
toolLogger,
recordEvidence,
);
+ registerEvmTools(
+ server,
+ options.evmInterface ?? createEvmInterfaceService(),
+ toolLogger,
+ recordEvidence,
+ );
registerFirmwareTools(
server,
new FirmwareAnalysisService(
diff --git a/src/server/registerEvmTools.ts b/src/server/registerEvmTools.ts
new file mode 100644
index 000000000..09af2dc26
--- /dev/null
+++ b/src/server/registerEvmTools.ts
@@ -0,0 +1,32 @@
+import type { McpServer } from "@modelcontextprotocol/server";
+import type { EvmInterfaceService } from "../application/evm/EvmInterfaceService.js";
+import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js";
+import { toolContract } from "../contracts/toolContracts.js";
+import type { Logger } from "../logger.js";
+import { logToolExecution } from "./toolLogging.js";
+import { toolRegistrationOptions } from "./toolRegistrationOptions.js";
+import { toCallToolResult } from "./toolResult.js";
+
+/** Bind the named EVM contract to shared validation and session Evidence recording. */
+export const registerEvmTools = (
+ server: McpServer,
+ service: EvmInterfaceService,
+ logger: Logger,
+ recordEvidence?: EvidenceWriter["recordEvidence"],
+): void => {
+ const contract = toolContract("inspect_evm_interface");
+ server.registerTool(
+ contract.name,
+ toolRegistrationOptions(contract),
+ async (input, context) => {
+ const result = await logToolExecution(logger, contract.name, () =>
+ service.inspect(input, { signal: context.mcpReq.signal }),
+ );
+ if (!result.ok) return toCallToolResult(result, contract);
+ const recorded = recordEvidence?.(result.value);
+ return recorded !== undefined && !recorded.ok
+ ? toCallToolResult(recorded, contract)
+ : toCallToolResult(result, contract);
+ },
+ );
+};
diff --git a/src/server/sessionAvailabilityPolicy.ts b/src/server/sessionAvailabilityPolicy.ts
index 0303828e5..733929647 100644
--- a/src/server/sessionAvailabilityPolicy.ts
+++ b/src/server/sessionAvailabilityPolicy.ts
@@ -10,6 +10,7 @@ export interface SessionAvailabilityDefaults {
| undefined;
readonly optionalFeatures?: Pick<
SessionAvailability,
+ | "evmInterfaceEnabled"
| "browserObservationEnabled"
| "browserScenarioEnabled"
| "electronObservationEnabled"
@@ -32,6 +33,8 @@ export const sessionAvailabilityPolicy = (
const policy =
configured ??
(() => ({
+ evmInterfaceEnabled:
+ defaults.optionalFeatures?.evmInterfaceEnabled ?? false,
processCaptureEnabled: platform !== "win32",
binaryLayoutEnabled:
defaults.optionalFeatures?.binaryLayoutEnabled ?? false,
diff --git a/tests/boundary/cli/productCatalog.test.ts b/tests/boundary/cli/productCatalog.test.ts
index c804c93e2..3eb9f6528 100644
--- a/tests/boundary/cli/productCatalog.test.ts
+++ b/tests/boundary/cli/productCatalog.test.ts
@@ -1,3 +1,4 @@
+import { EVMOLE_PROVIDER_IDENTITY } from "../../../src/evm/EvmoleRelease.js";
import { PWNTOOLS_PROVIDER_IDENTITY } from "../../../src/native/pwntools/PwntoolsRelease.js";
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
@@ -117,6 +118,7 @@ describe("canonical product catalog", () => {
IDA_PROVIDER_IDENTITY,
NATIVE_MACOS_PROVIDER_IDENTITY,
PWNTOOLS_PROVIDER_IDENTITY,
+ EVMOLE_PROVIDER_IDENTITY,
ARTIFACT_GRAPH_PROVIDER,
ANDROID_APPLICATION_PROVIDER,
APPLE_APPLICATION_PROVIDER,
diff --git a/tests/boundary/evm/evmoleInterfaceProvider.test.ts b/tests/boundary/evm/evmoleInterfaceProvider.test.ts
new file mode 100644
index 000000000..dd0e0fd8e
--- /dev/null
+++ b/tests/boundary/evm/evmoleInterfaceProvider.test.ts
@@ -0,0 +1,597 @@
+import { createHash } from "node:crypto";
+import { access, readFile, writeFile } from "node:fs/promises";
+import { join } from "node:path";
+import { expect, it, onTestFinished } from "vitest";
+import { z } from "zod";
+import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
+import { evmInterfaceSchema } from "../../../src/domain/evm/evmInterface.js";
+import { EvmoleInterfaceProvider } from "../../../src/evm/EvmoleInterfaceProvider.js";
+import { spawnOwnedProviderProcess } from "../../../src/process/ProviderProcess.js";
+import { waitForProviderProcessReady } from "../../fixtures/providerProcess.js";
+import { PrivateRuntimeRoot } from "../../../src/process/PrivateRuntimeRoot.js";
+import {
+ createTestWorkspace,
+ removeTestWorkspace,
+} from "../../support/workspace/workspaceFixture.js";
+
+const unsupportedHost = process.platform !== "linux" || process.arch !== "x64";
+const requestSchema = z.strictObject({
+ snapshot_path: z.string(),
+ reply_path: z.string(),
+ failure_marker_path: z.string(),
+ encoding: z.enum(["raw", "hex"]),
+});
+const fixture = async () => {
+ const workspace = await createTestWorkspace("rea-evm-interface-boundary-");
+ onTestFinished(() => removeTestWorkspace(workspace.root));
+ const path = join(workspace.root, "selected.hex");
+ await writeFile(path, "0x60006000f3\n");
+ return { path, root: workspace.root };
+};
+
+it.runIf(!unsupportedHost).each(["SIGXCPU", "SIGXFSZ", "EFBIG"])(
+ "preserves %s with configured limits distinct from unknown effective limits",
+ async (failure) => {
+ const { path } = await fixture();
+ let ownedPath = "";
+ const provider = new EvmoleInterfaceProvider({}, async (spawn) => {
+ ownedPath = spawn.cwd ?? "";
+ if (failure === "EFBIG")
+ await writeFile(join(ownedPath, "resource.failure"), "F");
+ return spawnOwnedProviderProcess({
+ ...spawn,
+ command: process.execPath,
+ arguments: [
+ "-e",
+ failure === "EFBIG"
+ ? 'process.stdout.write("resource diagnostic", () => { process.exitCode = 76; })'
+ : failure === "SIGXFSZ"
+ ? 'process.on("SIGXFSZ", () => { process.removeAllListeners("SIGXFSZ"); process.kill(process.pid, "SIGXFSZ"); }); process.stdout.write("resource diagnostic", () => process.kill(process.pid, "SIGXFSZ")); setTimeout(() => {}, 1000);'
+ : `process.stdout.write("resource diagnostic", () => process.kill(process.pid, "${failure}"))`,
+ ],
+ });
+ });
+ const result = await provider.inspect({ path, encoding: "hex" });
+ if (result.ok) throw new Error("Expected observed resource failure");
+ expect(projectAnalysisError(result.error)).toMatchObject({
+ code: "resource_constraint",
+ details: {
+ resource: failure === "SIGXCPU" ? "cpu" : "file-size",
+ reported_limits: {
+ configured_soft_limits: { cpu_seconds: expect.any(Number) },
+ effective_soft_limits: null,
+ },
+ captured_output: { stdout: "resource diagnostic", truncated: false },
+ },
+ });
+ expect(result.error.message).toContain(
+ failure === "EFBIG" ? "exact write failure cause" : "exact signal cause",
+ );
+ await expect(access(ownedPath)).rejects.toMatchObject({ code: "ENOENT" });
+ },
+);
+
+it.runIf(!unsupportedHost).each(["missing", "wrong"])(
+ "does not classify a launcher exit 76 with %s worker failure evidence",
+ async (scenario) => {
+ const { path } = await fixture();
+ let ownedPath = "";
+ const provider = new EvmoleInterfaceProvider({}, async (spawn) => {
+ ownedPath = spawn.cwd ?? "";
+ if (scenario === "wrong")
+ await writeFile(
+ join(ownedPath, "resource.failure"),
+ Buffer.from([0xc6]),
+ );
+ return spawnOwnedProviderProcess({
+ ...spawn,
+ command: process.execPath,
+ arguments: [
+ "-e",
+ 'process.stdout.write("launcher failure", () => { process.exitCode = 76; });',
+ ],
+ });
+ });
+ const result = await provider.inspect({ path, encoding: "hex" });
+ if (result.ok) throw new Error("Expected launcher failure");
+ expect(projectAnalysisError(result.error).code).toBe("execution_failure");
+ expect(result.error).toMatchObject({
+ diagnostics: {
+ file_size_failure_marker: {
+ verified: false,
+ failure: expect.any(String),
+ },
+ captured_output: { stdout: "launcher failure", truncated: false },
+ },
+ });
+ await expect(access(ownedPath)).rejects.toMatchObject({ code: "ENOENT" });
+ },
+);
+
+it.runIf(!unsupportedHost)(
+ "retains actual diagnostic truncation when overflow and process cleanup both fail",
+ async () => {
+ const { path } = await fixture();
+ let ownedPath = "";
+ const provider = new EvmoleInterfaceProvider({}, async (spawn) => {
+ ownedPath = spawn.cwd ?? "";
+ const launched = await spawnOwnedProviderProcess({
+ ...spawn,
+ command: process.execPath,
+ arguments: ["-e", 'process.stdout.write("x".repeat(1048577))'],
+ });
+ return {
+ ...launched,
+ cleanup: async () => {
+ await launched.cleanup?.();
+ throw new Error("injected post-cleanup reporting failure");
+ },
+ };
+ });
+ const result = await provider.inspect({ path, encoding: "hex" });
+ if (result.ok) throw new Error("Expected compounded lifecycle failure");
+ expect(result.error).toMatchObject({
+ cleanupIncomplete: true,
+ diagnostics: {
+ reason: "injected post-cleanup reporting failure",
+ previous_error: { failure_kind: "output-limit" },
+ captured_output: { truncated: true, stderr: "" },
+ },
+ });
+ await expect(access(ownedPath)).rejects.toMatchObject({ code: "ENOENT" });
+ },
+);
+
+it.runIf(!unsupportedHost).each([1048576, 1048577])(
+ "keeps complete diagnostics or rejects a valid worker reply with %s diagnostic bytes",
+ async (size) => {
+ const { path } = await fixture();
+ let ownedPath = "";
+ const provider = new EvmoleInterfaceProvider({}, async (spawn) => {
+ ownedPath = spawn.cwd ?? "";
+ const requestPath = spawn.arguments.at(-1);
+ if (requestPath === undefined) throw new Error("Request missing");
+ const request = requestSchema.parse(
+ JSON.parse(await readFile(requestPath, "utf8")),
+ );
+ const bytes = Buffer.from("60006000f3", "hex");
+ await writeFile(
+ request.reply_path,
+ JSON.stringify({
+ ok: true,
+ version: "0.9.3",
+ bytecode: {
+ sha256: createHash("sha256").update(bytes).digest("hex"),
+ bytes: bytes.length,
+ hex: bytes.toString("hex"),
+ },
+ raw: { functions: [] },
+ }),
+ );
+ return spawnOwnedProviderProcess({
+ ...spawn,
+ command: process.execPath,
+ arguments: ["-e", `process.stderr.write("x".repeat(${String(size)}))`],
+ });
+ });
+ const result = await provider.inspect({ path, encoding: "hex" });
+ if (size === 1048576) {
+ if (!result.ok) throw result.error;
+ const report = evmInterfaceSchema.parse(result.value.result);
+ expect(report.diagnostics.stderr).toHaveLength(size);
+ expect(report.diagnostics.truncated).toBe(false);
+ } else {
+ if (result.ok)
+ throw new Error("Expected explicit diagnostic budget failure");
+ expect(projectAnalysisError(result.error)).toMatchObject({
+ code: "unreadable_output",
+ details: { captured_output: { truncated: true } },
+ });
+ }
+ await expect(access(ownedPath)).rejects.toMatchObject({ code: "ENOENT" });
+ },
+);
+
+it
+ .runIf(!unsupportedHost)
+ .each([
+ "success",
+ "format",
+ "unsupported",
+ "wrong-version",
+ "wrong-decoded-carrier",
+ "broken-reply",
+ "out-of-range-body",
+ ])(
+ "binds source carrier/worker representation and releases owned files: %s",
+ async (scenario) => {
+ const { path } = await fixture();
+ let ownedRoot = "";
+ const provider = new EvmoleInterfaceProvider(
+ { NODE_OPTIONS: "--inspect=0" },
+ async (spawn) => {
+ ownedRoot = spawn.cwd ?? "";
+ expect(spawn.hostEnvironment?.NODE_OPTIONS).toBeUndefined();
+ expect(spawn.arguments).toContain("--as=3221225472:");
+ expect(spawn.arguments).toContain("--disable-wasm-trap-handler");
+ const requestPath = spawn.arguments.at(-1);
+ if (requestPath === undefined) throw new Error("request missing");
+ const request = requestSchema.parse(
+ JSON.parse(await readFile(requestPath, "utf8")),
+ );
+ expect(request.encoding).toBe("hex");
+ expect(await readFile(request.snapshot_path, "utf8")).toBe(
+ "0x60006000f3\n",
+ );
+ const hex = scenario === "wrong-decoded-carrier" ? "00" : "60006000f3";
+ const bytes = Buffer.from(hex, "hex");
+ await writeFile(
+ request.reply_path,
+ scenario === "broken-reply"
+ ? "broken"
+ : JSON.stringify(
+ ["format", "unsupported"].includes(scenario)
+ ? {
+ ok: false,
+ reason: scenario,
+ message: "Malformed hex carrier.",
+ }
+ : {
+ ok: true,
+ version: scenario === "wrong-version" ? "0.0.0" : "0.9.3",
+ bytecode: {
+ sha256: createHash("sha256")
+ .update(bytes)
+ .digest("hex"),
+ bytes: bytes.length,
+ hex,
+ },
+ raw: {
+ functions:
+ scenario === "out-of-range-body"
+ ? [
+ {
+ selector: "aabbccdd",
+ bytecodeOffset: 5,
+ dispatch: "abi",
+ },
+ ]
+ : [],
+ },
+ },
+ ),
+ );
+ return spawnOwnedProviderProcess({
+ ...spawn,
+ command: process.execPath,
+ arguments: ["-e", 'process.stderr.write("upstream warning")'],
+ });
+ },
+ );
+ const result = await provider.inspect({ path, encoding: "hex" });
+ if (scenario === "success") {
+ if (!result.ok) throw result.error;
+ expect(result.value.result).toMatchObject({
+ artifact: { path, encoding: "hex", bytes: 13 },
+ bytecode: { hex: "60006000f3", bytes: 5 },
+ diagnostics: { stderr: "upstream warning" },
+ });
+ expect(result.value.rawResult).toEqual({ functions: [] });
+ } else
+ expect(result).toMatchObject({
+ ok: false,
+ error: {
+ _tag:
+ scenario === "format"
+ ? "AnalysisInputError"
+ : scenario === "unsupported"
+ ? "AnalysisUnsupportedTargetError"
+ : "AnalysisOutputError",
+ },
+ });
+ if (!result.ok)
+ expect(projectAnalysisError(result.error)).toMatchObject({
+ details: {
+ captured_output: {
+ stdout: "",
+ stderr: "upstream warning",
+ truncated: false,
+ },
+ },
+ });
+ expect(await readFile(path, "utf8")).toBe("0x60006000f3\n");
+ await expect(access(ownedRoot)).rejects.toMatchObject({ code: "ENOENT" });
+ },
+);
+
+it.runIf(!unsupportedHost).each(["cancelled", "output-limit", "process"])(
+ "preserves lifecycle failure and independently releases an acquired worker: %s",
+ async (scenario) => {
+ const { path } = await fixture();
+ const controller = new AbortController();
+ let pid: number | undefined;
+ let ownedRoot = "";
+ const provider = new EvmoleInterfaceProvider({}, async (spawn) => {
+ ownedRoot = spawn.cwd ?? "";
+ const launched = await spawnOwnedProviderProcess({
+ ...spawn,
+ command: process.execPath,
+ arguments: [
+ "-e",
+ scenario === "cancelled"
+ ? 'process.stdout.write("ready\\n");setInterval(()=>undefined,1000)'
+ : scenario === "output-limit"
+ ? 'process.stdout.write("x".repeat(1048577))'
+ : 'process.stderr.write("worker failed");process.exit(7)',
+ ],
+ });
+ pid = launched.process.pid;
+ if (scenario === "cancelled") {
+ await waitForProviderProcessReady(launched.process);
+ setImmediate(() => controller.abort());
+ }
+ return launched;
+ });
+ expect(
+ await provider.inspect(
+ { path, encoding: "hex" },
+ { signal: controller.signal },
+ ),
+ ).toMatchObject({
+ ok: false,
+ error: {
+ _tag:
+ scenario === "cancelled"
+ ? "AnalysisCancelledError"
+ : scenario === "output-limit"
+ ? "AnalysisOutputError"
+ : "ProviderAdapterError",
+ },
+ });
+ if (pid === undefined) throw new Error("worker not acquired");
+ const acquiredPid = pid;
+ expect(() => process.kill(acquiredPid, 0)).toThrow();
+ await expect(access(ownedRoot)).rejects.toMatchObject({ code: "ENOENT" });
+ },
+);
+
+it.runIf(!unsupportedHost)(
+ "distinguishes unavailable limiter, absent carrier and prelaunch cancellation",
+ async () => {
+ const { path } = await fixture();
+ expect(
+ await new EvmoleInterfaceProvider({
+ REA_EVM_PRLIMIT_COMMAND: path + ".absent",
+ }).inspect({ path, encoding: "hex" }),
+ ).toMatchObject({
+ ok: false,
+ error: { _tag: "AnalysisCapabilityUnavailableError" },
+ });
+ expect(
+ await new EvmoleInterfaceProvider({}).inspect({
+ path: path + ".absent",
+ encoding: "raw",
+ }),
+ ).toMatchObject({ ok: false, error: { _tag: "AnalysisInputError" } });
+ const controller = new AbortController();
+ controller.abort();
+ expect(
+ await new EvmoleInterfaceProvider({}).inspect(
+ { path, encoding: "raw" },
+ { signal: controller.signal },
+ ),
+ ).toMatchObject({ ok: false, error: { _tag: "AnalysisCancelledError" } });
+ },
+);
+it.runIf(unsupportedHost)(
+ "reports unverified host coverage before acquiring a provider",
+ async () => {
+ expect(
+ await new EvmoleInterfaceProvider({}).inspect({
+ path: "/selected.hex",
+ encoding: "hex",
+ }),
+ ).toMatchObject({
+ ok: false,
+ error: { _tag: "AnalysisCapabilityUnavailableError" },
+ });
+ },
+);
+
+it.runIf(!unsupportedHost)(
+ "preserves actual snapshot-write cancellation and root cleanup",
+ async () => {
+ const { path } = await fixture();
+ const controller = new AbortController();
+ let ownedRoot = "";
+ const provider = new EvmoleInterfaceProvider(
+ {},
+ () => {
+ throw new Error("must not launch worker");
+ },
+ async () => {
+ const root = await PrivateRuntimeRoot.create({
+ prefix: "rea-evm-cancel-",
+ });
+ ownedRoot = root.path;
+ controller.abort();
+ return root;
+ },
+ );
+ expect(
+ await provider.inspect(
+ { path, encoding: "hex" },
+ { signal: controller.signal },
+ ),
+ ).toMatchObject({ ok: false, error: { _tag: "AnalysisCancelledError" } });
+ await expect(access(ownedRoot)).rejects.toMatchObject({ code: "ENOENT" });
+ },
+);
+
+it.runIf(!unsupportedHost)(
+ "reports private storage denial without claiming selected carrier read denial",
+ async () => {
+ const { path } = await fixture();
+ const provider = new EvmoleInterfaceProvider({}, undefined, () =>
+ Promise.reject(
+ Object.assign(new Error("private root denied"), { code: "EACCES" }),
+ ),
+ );
+ expect(await provider.inspect({ path, encoding: "hex" })).toMatchObject({
+ ok: false,
+ error: {
+ _tag: "ProviderAdapterError",
+ diagnostics: { phase: "worker", reason: "private root denied" },
+ },
+ });
+ },
+);
+
+it.runIf(!unsupportedHost)(
+ "retains original format failure when owned-root cleanup fails",
+ async () => {
+ const { path, root } = await fixture();
+ const provider = new EvmoleInterfaceProvider(
+ {},
+ async (spawn) => {
+ const requestPath = spawn.arguments.at(-1);
+ if (requestPath === undefined) throw new Error("request missing");
+ const request = requestSchema.parse(
+ JSON.parse(await readFile(requestPath, "utf8")),
+ );
+ await writeFile(
+ request.reply_path,
+ JSON.stringify({
+ ok: false,
+ reason: "format",
+ message: "Odd selected hex.",
+ }),
+ );
+ return spawnOwnedProviderProcess({
+ ...spawn,
+ command: process.execPath,
+ arguments: ["-e", "process.exit(0)"],
+ });
+ },
+ () =>
+ Promise.resolve({
+ path: root,
+ close: () => Promise.reject(new Error("root cleanup failed")),
+ }),
+ );
+ expect(await provider.inspect({ path, encoding: "hex" })).toMatchObject({
+ ok: false,
+ error: {
+ cleanupIncomplete: true,
+ cleanupResources: [root],
+ diagnostics: {
+ reason: "root cleanup failed",
+ previous_error: { category: "invalid_input" },
+ },
+ },
+ });
+ },
+);
+
+it.runIf(!unsupportedHost).each(["late-cancellation", "cleanup-failure"])(
+ "retains captured warnings after a successful worker: %s",
+ async (scenario) => {
+ const { path } = await fixture();
+ const controller = new AbortController();
+ let ownedRoot = "";
+ const provider = new EvmoleInterfaceProvider(
+ {},
+ async (spawn) => {
+ const requestPath = spawn.arguments.at(-1);
+ if (requestPath === undefined) throw new Error("request missing");
+ const request = requestSchema.parse(
+ JSON.parse(await readFile(requestPath, "utf8")),
+ );
+ const bytes = Buffer.from("60006000f3", "hex");
+ await writeFile(
+ request.reply_path,
+ JSON.stringify({
+ ok: true,
+ version: "0.9.3",
+ bytecode: {
+ sha256: createHash("sha256").update(bytes).digest("hex"),
+ bytes: bytes.length,
+ hex: bytes.toString("hex"),
+ },
+ raw: { functions: [] },
+ }),
+ );
+ return spawnOwnedProviderProcess({
+ ...spawn,
+ command: process.execPath,
+ arguments: ["-e", 'process.stderr.write("upstream warning")'],
+ });
+ },
+ async () => {
+ const root = await PrivateRuntimeRoot.create({
+ prefix: "rea-evm-late-",
+ });
+ ownedRoot = root.path;
+ return {
+ path: root.path,
+ close: async () => {
+ await root.close();
+ if (scenario === "late-cancellation") controller.abort();
+ else throw new Error("reported cleanup failure");
+ },
+ };
+ },
+ );
+ const result = await provider.inspect(
+ { path, encoding: "hex" },
+ { signal: controller.signal },
+ );
+ if (result.ok) throw new Error("Expected lifecycle failure");
+ const output = { stdout: "", stderr: "upstream warning", truncated: false };
+ expect(projectAnalysisError(result.error)).toMatchObject({
+ category:
+ scenario === "late-cancellation" ? "cancelled" : "execution_failure",
+ details:
+ scenario === "late-cancellation"
+ ? { captured_output: output }
+ : { diagnostics: { captured_output: output } },
+ });
+ await expect(access(ownedRoot)).rejects.toMatchObject({ code: "ENOENT" });
+ },
+);
+
+it.runIf(!unsupportedHost).each(["directory", "missing-interpreter"])(
+ "identifies configured limiter constraints without blaming the carrier: %s",
+ async (scenario) => {
+ const { path, root } = await fixture();
+ const limiter =
+ scenario === "directory" ? root : join(root, "broken-limiter");
+ if (scenario === "missing-interpreter")
+ await writeFile(limiter, "#!/rea-missing-limiter-interpreter\n", {
+ mode: 0o700,
+ });
+ let ownedRoot: string | undefined;
+ const provider = new EvmoleInterfaceProvider(
+ { REA_EVM_PRLIMIT_COMMAND: limiter },
+ undefined,
+ async () => {
+ const runtime = await PrivateRuntimeRoot.create({
+ prefix: "rea-evm-limiter-",
+ });
+ ownedRoot = runtime.path;
+ return runtime;
+ },
+ );
+ const result = await provider.inspect({ path, encoding: "hex" });
+ if (result.ok) throw new Error("Expected unavailable limiter");
+ expect(projectAnalysisError(result.error)).toMatchObject({
+ code: "capability_unavailable",
+ category: "unsupported_provider",
+ details: { provider_id: "evmole-interface" },
+ });
+ expect(result.error.message).toContain(limiter);
+ if (ownedRoot !== undefined)
+ await expect(access(ownedRoot)).rejects.toMatchObject({ code: "ENOENT" });
+ expect(await readFile(path, "utf8")).toBe("0x60006000f3\n");
+ },
+);
diff --git a/tests/boundary/mcp/evmInterfaceMcp.test.ts b/tests/boundary/mcp/evmInterfaceMcp.test.ts
new file mode 100644
index 000000000..1047b3459
--- /dev/null
+++ b/tests/boundary/mcp/evmInterfaceMcp.test.ts
@@ -0,0 +1,78 @@
+import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
+import { Ajv2020 } from "ajv/dist/2020.js";
+import { expect, it, onTestFinished } from "vitest";
+import { EvmInterfaceService } from "../../../src/application/evm/EvmInterfaceService.js";
+import { toolContract } from "../../../src/contracts/toolContracts.js";
+import { parseEvidence } from "../../../src/domain/evidence.js";
+import { ok } from "../../../src/domain/result.js";
+import { createServer } from "../../../src/server/createServer.js";
+import { createTestBinarySession } from "../../fixtures/binarySession.js";
+import {
+ evmInterfaceFixture,
+ evmInterfaceExecution,
+} from "../../fixtures/evm/interface.js";
+
+it("advertises exact valid schemas and records inline EVM interface evidence without a binary target", async () => {
+ const value = evmInterfaceFixture();
+ const service = new EvmInterfaceService({
+ inspect: () => Promise.resolve(ok(evmInterfaceExecution(value))),
+ });
+ const session = createTestBinarySession(() => {
+ throw new Error("deep provider must not start");
+ });
+ const server = createServer(session, session, { evmInterface: service });
+ const client = new Client({ name: "evm-interface-contract", version: "1" });
+ onTestFinished(async () => {
+ await client.close();
+ await server.close();
+ await session.close();
+ });
+ const [clientTransport, serverTransport] =
+ InMemoryTransport.createLinkedPair();
+ await server.connect(serverTransport);
+ await client.connect(clientTransport);
+ const advertised = (await client.listTools()).tools.find(
+ (tool) => tool.name === "inspect_evm_interface",
+ );
+ if (advertised?.outputSchema === undefined)
+ throw new Error("EVM interface inspection must publish both schemas");
+ const ajv = new Ajv2020({ strict: false, validateFormats: false });
+ const inputSchema: Record = advertised.inputSchema;
+ const outputSchema: Record = advertised.outputSchema;
+ expect(ajv.validateSchema(inputSchema)).toBe(true);
+ expect(ajv.validateSchema(outputSchema)).toBe(true);
+ expect(
+ ajv.validate(inputSchema, { path: value.artifact.path, encoding: "hex" }),
+ ).toBe(true);
+ expect(
+ ajv.validate(inputSchema, {
+ path: value.artifact.path,
+ encoding: "hex",
+ approval: true,
+ }),
+ ).toBe(false);
+ const response = await client.callTool({
+ name: "inspect_evm_interface",
+ arguments: { path: value.artifact.path, encoding: "hex" },
+ });
+ expect(response.isError).not.toBe(true);
+ expect(
+ ajv.validate(outputSchema, response.structuredContent),
+ JSON.stringify(ajv.errors),
+ ).toBe(true);
+ const parsed = toolContract("inspect_evm_interface").outputSchema.parse(
+ response.structuredContent,
+ );
+ const evidence = parseEvidence(parsed.evidence);
+ expect(parsed.result).toEqual(value);
+ expect(parsed.result).toEqual(evidence.normalized_result);
+ expect(session.evidenceById(evidence.evidence_id)).toEqual(evidence);
+ expect(
+ (
+ await client.callTool({
+ name: "inspect_evm_interface",
+ arguments: { path: "relative.hex", encoding: "hex" },
+ })
+ ).isError,
+ ).toBe(true);
+});
diff --git a/tests/fixtures/evm/interface.ts b/tests/fixtures/evm/interface.ts
new file mode 100644
index 000000000..23bfcfa02
--- /dev/null
+++ b/tests/fixtures/evm/interface.ts
@@ -0,0 +1,44 @@
+import { createHash } from "node:crypto";
+import { createAnalysisExecution } from "../../../src/application/AnalysisProvider.js";
+import type { EvmInterface } from "../../../src/domain/evm/evmInterface.js";
+
+/** Source-owned transport seam, independent of real engine verification. */
+export const evmInterfaceFixture = (
+ path = "/artifacts/source-owned.hex",
+): EvmInterface => ({
+ artifact: { path, sha256: "a".repeat(64), bytes: 10, encoding: "hex" },
+ bytecode: {
+ sha256: createHash("sha256")
+ .update(Buffer.from("60006000f3", "hex"))
+ .digest("hex"),
+ bytes: 5,
+ hex: "60006000f3",
+ digest_algorithm: "sha256",
+ kind: "unknown",
+ deployment_authenticity: "unknown",
+ hardfork: "unknown",
+ },
+ evidence_kind: "inferred",
+ functions: [],
+ discovery_completeness: "unknown",
+ runtime_execution: "not-performed",
+ diagnostics: { stdout: "", stderr: "", truncated: false },
+ limitations: ["Interface completeness remains unknown."],
+});
+
+/** Bind a fixture to its explicit producing seam and carrier identity. */
+export const evmInterfaceExecution = (value = evmInterfaceFixture()) =>
+ createAnalysisExecution(
+ value,
+ { id: "fixture-evm", name: "Source-owned EVM seam", version: "1" },
+ {
+ rawResult: { functions: [] },
+ subject: {
+ path: value.artifact.path,
+ format: "file",
+ sha256: value.artifact.sha256,
+ },
+ limitations: value.limitations,
+ locations: [{ kind: "artifact-path", path: value.artifact.path }],
+ },
+ );
diff --git a/third_party/evmole/LICENSE b/third_party/evmole/LICENSE
new file mode 100644
index 000000000..5f31d4183
--- /dev/null
+++ b/third_party/evmole/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2023 Maxim Andreev
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/third_party/evmole/README.md b/third_party/evmole/README.md
new file mode 100644
index 000000000..0b7a63515
--- /dev/null
+++ b/third_party/evmole/README.md
@@ -0,0 +1,22 @@
+# EVM interface upstream profile
+
+REA integrates unchanged [EVMole](https://github.com/cdump/evmole) as the exact
+npm dependency `evmole@0.9.3`, with lockfile integrity verification. Tag `0.9.3`
+resolves to commit `0409707c2efef11a9043f9b4505cad5719054a7e`.
+
+The JavaScript binding and WASM engine remain in the original upstream package;
+REA contains only carrier validation, a worker adapter and portable contracts.
+No submodule or vendored engine fork is required. No signature database,
+LLM decompiler, blockchain node or RPC dependency is acquired.
+
+Upstream is MIT, copyright (c) 2023 Maxim Andreev. Its npm tarball omits the
+license file, so REA packages the unchanged tag's [LICENSE](LICENSE) here.
+The engine is initialized through the upstream `no_tla`/`initSync` interface
+only inside an owned worker; importing REA services performs no WASM/file I/O.
+
+Initial interface analysis uses `contractInfo` with selectors, arguments and
+stateMutability enabled. Selector recovery also extracts/reports terminal metadata;
+its reported representation is retained with explicit unknown precision/coverage.
+Other upstream outputs are separate future contracts;
+in particular, CFG destination IDs are not byte addresses and terminal CBOR
+metadata cannot establish a lossless integer claim through the current binding.
diff --git a/vitest.config.ts b/vitest.config.ts
index f88202215..0ab5bbc44 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -37,7 +37,7 @@ const TEST_PROJECTS = [
name: "adapters",
include: [
"src/*.test.ts",
- "src/{artifacts,browser,dotnet,firmware,ghidra,hopper,ida,inspector,javascript,native,process,reference,server,windows}/**/*.test.ts",
+ "src/{artifacts,browser,dotnet,evm,firmware,ghidra,hopper,ida,inspector,javascript,native,process,reference,server,windows}/**/*.test.ts",
],
pool: "forks" as const,
maxWorkers: MAX_TEST_WORKERS,