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,