feat(evm): inspect offline bytecode interfaces through EVMole (#1051)
* feat(evm): inspect offline bytecode interfaces through evmole * fix(evm): distinguish unsupported bytecode targets * style(evm): format verifier string-prefix checks
This commit is contained in:
parent
c5d165bd03
commit
7af7bc88ce
|
|
@ -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
|
||||
|
|
@ -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) |
|
||||
|
|
|
|||
|
|
@ -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 والبحث عن الأصناف وقوائم الأعضاء وفك ترجمة الأساليب والمراجع الثابتة |
|
||||
|
|
|
|||
28
README_ja.md
28
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 バンドル、ナビゲーションの文脈、プロセス・成果物・関数の比較、未解決事項の記録 |
|
||||
|
||||
## ロードマップ
|
||||
|
||||
|
|
|
|||
28
README_ko.md
28
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 | 세션, 근거 번들, 탐색 컨텍스트, 프로세스·아티팩트·함수 비교, 미해결 항목 기록 |
|
||||
|
||||
## 로드맵
|
||||
|
||||
|
|
|
|||
28
README_zh.md
28
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 | 会话、证据包、导航上下文、进程/产物/函数比较与待解决问题跟踪 |
|
||||
|
||||
## 路线图
|
||||
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ graph TD
|
|||
|
||||
subgraph "Provider Layers"
|
||||
PWNTOOLS["src/native/pwntools/<br/>BYO offline ELF adapter<br/>owned Python process, unchanged upstream tables/heuristics"]
|
||||
EVM["src/evm/<br/>unchanged EVMole/WASM in bounded owned worker<br/>explicit carrier parsing and inferred interface adapter"]
|
||||
HOPPER["src/hopper/<br/>Hopper launch & Unix-socket protocol<br/>BridgeLauncher, HopperClient, protocol"]
|
||||
GHIDRA["src/ghidra/<br/>Ghidra discovery, profile & function analysis<br/>snapshot, Unix/Windows transport, bounded lifecycle"]
|
||||
IDA["src/ida/<br/>Upstream MCP contract adaptation<br/>live GUI binding or owned headless database"]
|
||||
|
|
@ -144,3 +145,8 @@ graph TD
|
|||
TESTS -.-> BROWSER
|
||||
TESTS -.-> EVALUATION
|
||||
TESTS -.-> ERRORS
|
||||
|
||||
COMPOSITION --> EVM
|
||||
APP -->|EVM interface port| EVM
|
||||
EVM --> PROCESS
|
||||
EVM --> DOMAIN
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,84 @@
|
|||
# Offline EVM bytecode
|
||||
|
||||
`inspect_evm_interface` / `inspect-evm-interface` infers interface candidates
|
||||
from one explicitly selected local bytecode carrier. The capability is general:
|
||||
contract investigation, compatibility, audit and CTF showcases use the same
|
||||
portable primitive. It does not require an active native binary target.
|
||||
|
||||
```sh
|
||||
rea inspect-evm-interface ./runtime.hex hex --json
|
||||
rea inspect-evm-interface ./runtime.bin raw --json
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "inspect_evm_interface",
|
||||
"arguments": { "path": "/artifacts/runtime.hex", "encoding": "hex" }
|
||||
}
|
||||
```
|
||||
|
||||
Initial real verification covers flat legacy bytecode on Linux x64 and unchanged
|
||||
bundled EVMole 0.9.3. EOF-style EF00 containers return `unsupported_target`,
|
||||
with the selected path and format reason. Provider/configuration failures retain
|
||||
their distinct unavailable-provider or capability classification.
|
||||
The host must already provide util-linux `prlimit`, normally `/usr/bin/prlimit`;
|
||||
`REA_EVM_PRLIMIT_COMMAND` can select another absolute executable. REA installs
|
||||
no system tools and changes no user configuration. [Upstream provenance and
|
||||
license](../third_party/evmole/README.md) are packaged.
|
||||
|
||||
## Evidence semantics
|
||||
|
||||
- `encoding` is required: raw bytes or UTF-8 hex, with optional `0x` prefix and
|
||||
outer ASCII whitespace. Odd hex, interior separators, BOM, non-ASCII whitespace
|
||||
and invalid UTF-8 are rejected. Empty bytecode is valid and has unknown coverage.
|
||||
- Carrier SHA-256 identifies the exact original file, including prefix/casing
|
||||
and whitespace. Decoded-byte SHA-256 identifies actual selected EVM bytes.
|
||||
Neither digest is the Ethereum Keccak code hash.
|
||||
- Full bytecode, upstream function offsets and original producer representation
|
||||
are returned inline. Offsets are byte positions in decoded bytecode.
|
||||
- Upstream also returns terminal metadata during selector recovery. Its reported
|
||||
values remain in `raw_result`; integer precision and non-text-key coverage are
|
||||
unknown in the JS binding. Full bytecode retains the original encoded metadata.
|
||||
- Selectors, argument strings and state mutability are **inferences**. Function
|
||||
names/signatures are not looked up. Optimized code may produce different
|
||||
mutability candidates; recovered types are not a verified source ABI.
|
||||
- Runtime/init-code identity, deployed authenticity, hardfork and discovery
|
||||
completeness remain unknown. Empty candidates do not prove no callable interface.
|
||||
- Analysis is offline: no target runtime execution, implicit chain/RPC/explorer
|
||||
lookup, wallet action, transaction broadcast or network request.
|
||||
|
||||
## Resource and lifecycle limits
|
||||
|
||||
Inputs are limited to 4 MiB, complete replies to 16 MiB and combined diagnostic
|
||||
output to 1 MiB. An owned worker has a 256 MiB JavaScript heap, 3 GiB virtual
|
||||
address-space limit, 30 CPU seconds and a 30-second command deadline. Virtual
|
||||
address space differs from resident memory. The worker retains inherited tighter
|
||||
soft/hard limits, reports its configured soft limits inline and leaves hard limits
|
||||
unchanged. WASM trap reservations are disabled
|
||||
so the engine can run within that virtual limit; actual memory is not equated to
|
||||
its 3 GiB allowance. Limits fail without partial success. Stable snapshots,
|
||||
private files and owned processes are cleaned independently of caller cancellation.
|
||||
Invalid-input, unsupported, cancellation, timeout and malformed-reply failures
|
||||
retain bounded stdout/stderr under `details.captured_output`, including whether
|
||||
diagnostic output was truncated. Cleanup failures preserve the
|
||||
original error projection and any captured output.
|
||||
Successful results expose `diagnostics.truncated` from the supervisor as well.
|
||||
Excess diagnostic output fails the complete-output contract even when the worker
|
||||
has written a valid reply.
|
||||
SIGXCPU retains a CPU resource diagnostic and CPU-specific guidance. Observed
|
||||
file-size write failures (EFBIG) and SIGXFSZ likewise retain a file-size resource
|
||||
diagnostic. Exact signal/write causes remain unknown. Configured
|
||||
soft limits are reported separately from unknown effective limits; the received
|
||||
signal alone does not establish its exact cause.
|
||||
Reserved file-size exits require a matching private marker written by the
|
||||
worker's EFBIG branch. Bare launcher exits or unwritable markers retain process
|
||||
diagnostics with the resource cause unverified.
|
||||
|
||||
Source/metadata, proxy resolution, storage/CFG and fork-explicit execution/traces
|
||||
remain separate increments under #972, with materially different result or
|
||||
execution authority. This primitive makes no deployment or runtime behavior claim.
|
||||
|
||||
The real verification lane traces representative raw/hex CLI workflows and checks
|
||||
that only declared Node/prlimit launchers and process ownership inspection run.
|
||||
Socket traces permit local stdio socket pairs and reject attempted network requests.
|
||||
These observations verify those fixtures; they are not a general network sandbox.
|
||||
|
|
@ -7,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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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": [
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
10
package.json
10
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": {
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,15 @@
|
|||
import type {
|
||||
AnalysisExecution,
|
||||
ExecutionOptions,
|
||||
} from "../AnalysisProvider.js";
|
||||
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import type { InspectEvmInterfaceInput } from "../../domain/evm/evmInterface.js";
|
||||
import type { Result } from "../../domain/result.js";
|
||||
|
||||
/** Replaceable offline interface-recovery engine, independent of wallets and chain clients. */
|
||||
export interface EvmInterfacePort {
|
||||
inspect(
|
||||
input: InspectEvmInterfaceInput,
|
||||
options?: ExecutionOptions,
|
||||
): Promise<Result<AnalysisExecution, AnalysisError>>;
|
||||
}
|
||||
|
|
@ -0,0 +1,124 @@
|
|||
import { expect, it } from "vitest";
|
||||
import { EvmInterfaceService } from "./EvmInterfaceService.js";
|
||||
import {
|
||||
evmInterfaceExecution,
|
||||
evmInterfaceFixture,
|
||||
} from "../../../tests/fixtures/evm/interface.js";
|
||||
import { err, ok } from "../../domain/result.js";
|
||||
import { parseEvidence } from "../../domain/evidence.js";
|
||||
import { AnalysisCapabilityUnavailableError } from "../../domain/analysisErrorCore.js";
|
||||
|
||||
it("returns full inline inferred Evidence and unmodified upstream representation", async () => {
|
||||
const value = evmInterfaceFixture();
|
||||
const service = new EvmInterfaceService({
|
||||
inspect: () => Promise.resolve(ok(evmInterfaceExecution(value))),
|
||||
});
|
||||
const result = await service.inspect({
|
||||
path: value.artifact.path,
|
||||
encoding: "hex",
|
||||
});
|
||||
if (!result.ok) throw result.error;
|
||||
const evidence = parseEvidence(result.value);
|
||||
expect(evidence.normalized_result).toEqual(value);
|
||||
expect(evidence.raw_result).toEqual({ functions: [] });
|
||||
expect(evidence.confidence).toBe("inferred");
|
||||
expect(evidence.subject?.digest.sha256).toBe(value.artifact.sha256);
|
||||
});
|
||||
it.each([
|
||||
{ path: "relative.hex", encoding: "hex" },
|
||||
{ path: "/selected", encoding: "auto" },
|
||||
{ path: "/selected" },
|
||||
{ path: "/selected", encoding: "raw", approval: true },
|
||||
])("rejects invalid selection before effects: %j", async (input) => {
|
||||
const service = new EvmInterfaceService({
|
||||
inspect: () => {
|
||||
throw new Error("must not start");
|
||||
},
|
||||
});
|
||||
expect(await service.inspect(input)).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "AnalysisInputError" },
|
||||
});
|
||||
});
|
||||
it.each(["carrier", "encoding", "digest", "no-subject"])(
|
||||
"rejects mismatched producer binding: %s",
|
||||
async (problem) => {
|
||||
const value = evmInterfaceFixture();
|
||||
if (problem === "carrier") value.artifact.path = "/other";
|
||||
if (problem === "encoding") value.artifact.encoding = "raw";
|
||||
const observed = evmInterfaceExecution(value);
|
||||
const execution = {
|
||||
...observed,
|
||||
subject:
|
||||
problem === "no-subject"
|
||||
? null
|
||||
: {
|
||||
path: "/artifacts/source-owned.hex",
|
||||
sha256:
|
||||
problem === "digest" ? "b".repeat(64) : value.artifact.sha256,
|
||||
format: "file" as const,
|
||||
},
|
||||
};
|
||||
const service = new EvmInterfaceService({
|
||||
inspect: () => Promise.resolve(ok(execution)),
|
||||
});
|
||||
expect(
|
||||
await service.inspect({
|
||||
path: "/artifacts/source-owned.hex",
|
||||
encoding: "hex",
|
||||
}),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "AnalysisOutputError", capturedOutput: value.diagnostics },
|
||||
});
|
||||
},
|
||||
);
|
||||
it("preserves meaningful provider failure and cancellation before/after execution", async () => {
|
||||
const failure = new AnalysisCapabilityUnavailableError(
|
||||
"evm",
|
||||
"inspect_evm_interface",
|
||||
"Selected limiter missing",
|
||||
);
|
||||
const service = new EvmInterfaceService({
|
||||
inspect: () => Promise.resolve(err(failure)),
|
||||
});
|
||||
expect(await service.inspect({ path: "/code", encoding: "raw" })).toEqual(
|
||||
err(failure),
|
||||
);
|
||||
const controller = new AbortController();
|
||||
const observed = evmInterfaceFixture();
|
||||
observed.diagnostics = {
|
||||
stdout: "selected\u0000",
|
||||
stderr: "warning",
|
||||
truncated: true,
|
||||
};
|
||||
const cancelled = new EvmInterfaceService({
|
||||
inspect: () => {
|
||||
controller.abort();
|
||||
return Promise.resolve(ok(evmInterfaceExecution(observed)));
|
||||
},
|
||||
});
|
||||
expect(
|
||||
await cancelled.inspect(
|
||||
{ path: "/artifacts/source-owned.hex", encoding: "hex" },
|
||||
{ signal: controller.signal },
|
||||
),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
_tag: "AnalysisCancelledError",
|
||||
capturedOutput: observed.diagnostics,
|
||||
},
|
||||
});
|
||||
const untouched = new EvmInterfaceService({
|
||||
inspect: () => {
|
||||
throw new Error("must not start");
|
||||
},
|
||||
});
|
||||
expect(
|
||||
await untouched.inspect(
|
||||
{ path: "/code", encoding: "raw" },
|
||||
{ signal: controller.signal },
|
||||
),
|
||||
).toMatchObject({ ok: false, error: { _tag: "AnalysisCancelledError" } });
|
||||
});
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
import { isAbsolute } from "node:path";
|
||||
import { z } from "zod";
|
||||
import type { ExecutionOptions } from "../AnalysisProvider.js";
|
||||
import type { EvmInterfacePort } from "./EvmInterfacePort.js";
|
||||
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisInputError,
|
||||
AnalysisOutputError,
|
||||
} from "../../domain/analysisErrorCore.js";
|
||||
import { createEvidence, type Evidence } from "../../domain/evidence.js";
|
||||
import {
|
||||
inspectEvmInterfaceInputSchema,
|
||||
evmInterfaceSchema,
|
||||
} from "../../domain/evm/evmInterface.js";
|
||||
import { projectInputIssues } from "../../domain/inputIssueProjection.js";
|
||||
import { err, ok, type Result } from "../../domain/result.js";
|
||||
|
||||
const OPERATION = "inspect_evm_interface";
|
||||
const diagnosticsSchema = z.object({
|
||||
diagnostics: evmInterfaceSchema.shape.diagnostics,
|
||||
});
|
||||
|
||||
/** Shared carrier validation and typed inline Evidence for CLI/MCP. */
|
||||
export class EvmInterfaceService {
|
||||
constructor(readonly provider: EvmInterfacePort) {}
|
||||
|
||||
/** Preserve selected byte identity while clearly labeling recovered ABI candidates as inferences. */
|
||||
async inspect(
|
||||
rawInput: unknown,
|
||||
options?: ExecutionOptions,
|
||||
): Promise<Result<Evidence, AnalysisError>> {
|
||||
if (options?.signal?.aborted)
|
||||
return err(new AnalysisCancelledError(OPERATION));
|
||||
const input = inspectEvmInterfaceInputSchema.safeParse(rawInput);
|
||||
if (!input.success)
|
||||
return err(
|
||||
new AnalysisInputError(
|
||||
OPERATION,
|
||||
{ cause: input.error },
|
||||
projectInputIssues(input.error.issues, rawInput),
|
||||
),
|
||||
);
|
||||
if (!isAbsolute(input.data.path))
|
||||
return err(
|
||||
new AnalysisInputError(OPERATION, undefined, [
|
||||
{
|
||||
path: ["path"],
|
||||
reason: "invalid_format",
|
||||
message: "Expected an absolute filesystem path on this host.",
|
||||
},
|
||||
]),
|
||||
);
|
||||
const inspected = await this.provider.inspect(input.data, options);
|
||||
if (!inspected.ok) return inspected;
|
||||
const diagnostics = diagnosticsSchema.safeParse(inspected.value.result);
|
||||
const outputOptions = diagnostics.success
|
||||
? { capturedOutput: diagnostics.data.diagnostics }
|
||||
: undefined;
|
||||
if (options?.signal?.aborted)
|
||||
return err(new AnalysisCancelledError(OPERATION, outputOptions));
|
||||
const execution = inspected.value;
|
||||
const report = evmInterfaceSchema.safeParse(execution.result);
|
||||
if (
|
||||
!report.success ||
|
||||
report.data.artifact.path !== input.data.path ||
|
||||
report.data.artifact.encoding !== input.data.encoding ||
|
||||
execution.subject?.path !== input.data.path ||
|
||||
execution.subject.sha256 !== report.data.artifact.sha256
|
||||
)
|
||||
return err(
|
||||
new AnalysisOutputError(
|
||||
OPERATION,
|
||||
"Interface adapter returned malformed data or changed the selected carrier identity/encoding.",
|
||||
outputOptions,
|
||||
),
|
||||
);
|
||||
return ok(
|
||||
createEvidence(execution.subject, execution.provider, {
|
||||
operation: OPERATION,
|
||||
parameters: input.data,
|
||||
result: report.data,
|
||||
...(execution.rawResult === null
|
||||
? {}
|
||||
: { rawResult: execution.rawResult }),
|
||||
confidence: "inferred",
|
||||
locations: execution.locations,
|
||||
limitations: execution.limitations,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,37 @@
|
|||
import { z } from "incur";
|
||||
import { resolve } from "node:path";
|
||||
import { createEvmInterfaceService } from "../composition/evm.js";
|
||||
import { CLI_COMMANDS } from "../cliCommandNames.js";
|
||||
import { logCliCommand } from "../cliLogging.js";
|
||||
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
||||
import type { Logger } from "../logger.js";
|
||||
import type { CliInstance } from "./types.js";
|
||||
import { withCommandCancellation } from "./commandCancellation.js";
|
||||
|
||||
/** Resolve operator paths and preserve explicit encoding through the shared workflow. */
|
||||
export const registerEvmCommands = (
|
||||
cli: CliInstance,
|
||||
logger: Logger,
|
||||
environment: Readonly<NodeJS.ProcessEnv>,
|
||||
): void => {
|
||||
const service = createEvmInterfaceService(environment);
|
||||
cli.command(CLI_COMMANDS.inspectEvmInterface, {
|
||||
description: "Infer interface candidates from explicit local EVM bytecode",
|
||||
args: z.object({
|
||||
path: z.string().describe("Local bytecode carrier"),
|
||||
encoding: z
|
||||
.enum(["raw", "hex"])
|
||||
.describe("Selected carrier representation"),
|
||||
}),
|
||||
run: ({ args }) =>
|
||||
withCommandCancellation((signal) =>
|
||||
logCliCommand(logger, CLI_COMMANDS.inspectEvmInterface, async () => {
|
||||
const result = await service.inspect(
|
||||
{ path: resolve(args.path), encoding: args.encoding },
|
||||
{ signal },
|
||||
);
|
||||
return result.ok ? result.value : projectAnalysisError(result.error);
|
||||
}),
|
||||
),
|
||||
});
|
||||
};
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
import { EvmInterfaceService } from "../application/evm/EvmInterfaceService.js";
|
||||
import { EvmoleInterfaceProvider } from "../evm/EvmoleInterfaceProvider.js";
|
||||
|
||||
/** Compose offline interface analysis without import-time I/O or dependency acquisition. */
|
||||
export const createEvmInterfaceService = (
|
||||
environment: Readonly<NodeJS.ProcessEnv> = process.env,
|
||||
): EvmInterfaceService =>
|
||||
new EvmInterfaceService(new EvmoleInterfaceProvider(environment));
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -41,6 +41,11 @@ const sessionEvidence = effects({ mutatesSession: true });
|
|||
|
||||
/** Explicit effect audit for every public tool. */
|
||||
export const TOOL_EFFECTS: Readonly<Record<string, ToolEffects>> = {
|
||||
inspect_evm_interface: effects({
|
||||
mutatesSession: true,
|
||||
writesFilesystem: true,
|
||||
launchesProcess: true,
|
||||
}),
|
||||
inspect_web_network_capture: effects({
|
||||
mutatesSession: true,
|
||||
writesFilesystem: true,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ const ANALYSIS_ERROR_TAGS = [
|
|||
"AnalysisArtifactChangedError",
|
||||
"AnalysisOutputError",
|
||||
"AnalysisCapabilityUnavailableError",
|
||||
"AnalysisUnsupportedTargetError",
|
||||
"AnalysisCancelledError",
|
||||
"AnalysisTimeoutError",
|
||||
"AnalysisResourceConstraintError",
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import {
|
|||
AnalysisAccessDeniedError,
|
||||
AnalysisArtifactChangedError,
|
||||
AnalysisInputError,
|
||||
AnalysisUnsupportedTargetError,
|
||||
AnalysisResourceConstraintError,
|
||||
} from "./analysisErrorCore.js";
|
||||
import { ArtifactOperationError } from "./artifactOperationError.js";
|
||||
|
|
@ -33,6 +34,8 @@ import { type AnalysisErrorProjection } from "./analysisErrorProjection.js";
|
|||
export const analysisErrorRemediationAction = (
|
||||
error: AnalysisError,
|
||||
): string => {
|
||||
if (error instanceof AnalysisUnsupportedTargetError)
|
||||
return "Select a target supported by this operation or choose an operation supporting the reported target format.";
|
||||
if (error instanceof AnalysisResourceConstraintError)
|
||||
return error.resource === "cpu"
|
||||
? "Review the reported worker CPU limits and observed signal. Retry with sufficient CPU time or a smaller artifact; REA retains tighter inherited limits."
|
||||
|
|
@ -125,6 +128,7 @@ const STATIC_ERROR_CATEGORIES: Readonly<
|
|||
AnalysisAccessDeniedError: "unavailable",
|
||||
AnalysisArtifactChangedError: "integrity_mismatch",
|
||||
AnalysisCapabilityUnavailableError: "unsupported_provider",
|
||||
AnalysisUnsupportedTargetError: "unsupported_target",
|
||||
ProviderSelectionError: "unsupported_provider",
|
||||
EvidenceIntegrityError: "integrity_mismatch",
|
||||
AnalysisCancelledError: "cancelled",
|
||||
|
|
@ -137,6 +141,7 @@ const STATIC_ERROR_CATEGORIES: Readonly<
|
|||
};
|
||||
|
||||
export const analysisErrorUserMessage = (error: AnalysisError): string => {
|
||||
if (error instanceof AnalysisUnsupportedTargetError) return error.message;
|
||||
if (error instanceof AnalysisResourceConstraintError) return error.reason;
|
||||
if (error instanceof AnalysisAccessDeniedError)
|
||||
return "Host filesystem permissions denied read access to the selected path.";
|
||||
|
|
@ -270,6 +275,7 @@ const KNOWN_ERROR_TAGS = {
|
|||
AnalysisArtifactChangedError: true,
|
||||
AnalysisOutputError: true,
|
||||
AnalysisCapabilityUnavailableError: true,
|
||||
AnalysisUnsupportedTargetError: true,
|
||||
AnalysisCancelledError: true,
|
||||
AnalysisTimeoutError: true,
|
||||
AnalysisResourceConstraintError: true,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
AnalysisCapabilityUnavailableError,
|
||||
AnalysisUnsupportedTargetError,
|
||||
AnalysisInputError,
|
||||
AnalysisOutputError,
|
||||
AnalysisCancelledError,
|
||||
|
|
@ -28,6 +29,32 @@ const retainedOutput = {
|
|||
truncated: true,
|
||||
};
|
||||
|
||||
it("distinguishes unsupported target formats from unavailable provider capabilities", () => {
|
||||
const projected = projectAnalysisError(
|
||||
new AnalysisUnsupportedTargetError(
|
||||
"inspect_evm_interface",
|
||||
"/selected/runtime.hex",
|
||||
"EOF-style EF00 container is unsupported.",
|
||||
{ capturedOutput: retainedOutput },
|
||||
),
|
||||
);
|
||||
expect(projected).toMatchObject({
|
||||
code: "unsupported_target",
|
||||
category: "unsupported_target",
|
||||
retryable: false,
|
||||
details: {
|
||||
operation: "inspect_evm_interface",
|
||||
path: "/selected/runtime.hex",
|
||||
reason: "EOF-style EF00 container is unsupported.",
|
||||
captured_output: retainedOutput,
|
||||
},
|
||||
});
|
||||
expect(projected.message).toContain("EF00");
|
||||
expect(projected.remediation.action).toContain("target format");
|
||||
expect(projected.remediation.action).not.toContain("doctor");
|
||||
expect(analysisErrorProjectionSchema.safeParse(projected).success).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["memory", null],
|
||||
["memory", { address_space_bytes: 67108864 }],
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
AnalysisArtifactChangedError,
|
||||
AnalysisCancelledError,
|
||||
AnalysisCapabilityUnavailableError,
|
||||
AnalysisUnsupportedTargetError,
|
||||
AnalysisInputError,
|
||||
AnalysisOutputError,
|
||||
AnalysisTimeoutError,
|
||||
|
|
@ -140,6 +141,7 @@ const STATIC_ERROR_CODES = {
|
|||
AnalysisAccessDeniedError: "access_denied",
|
||||
AnalysisArtifactChangedError: "artifact_changed",
|
||||
AnalysisCapabilityUnavailableError: "capability_unavailable",
|
||||
AnalysisUnsupportedTargetError: "unsupported_target",
|
||||
AnalysisCancelledError: "cancelled",
|
||||
HopperCancelledError: "cancelled",
|
||||
AnalysisTimeoutError: "provider_timeout",
|
||||
|
|
@ -187,6 +189,12 @@ const errorDetails = (
|
|||
const requestErrorDetails = (
|
||||
error: AnalysisError,
|
||||
): Readonly<Record<string, JsonValue>> | undefined => {
|
||||
if (error instanceof AnalysisUnsupportedTargetError)
|
||||
return {
|
||||
operation: error.operation,
|
||||
path: error.path,
|
||||
reason: error.reason,
|
||||
};
|
||||
if (error instanceof AnalysisResourceConstraintError)
|
||||
return {
|
||||
operation: error.operation,
|
||||
|
|
@ -420,6 +428,7 @@ export interface AnalysisErrorProjection extends Readonly<
|
|||
| "artifact_changed"
|
||||
| "unreadable_output"
|
||||
| "capability_unavailable"
|
||||
| "unsupported_target"
|
||||
| "provider_unavailable"
|
||||
| "provider_timeout"
|
||||
| "resource_constraint"
|
||||
|
|
@ -437,6 +446,7 @@ export interface AnalysisErrorProjection extends Readonly<
|
|||
readonly category:
|
||||
| "invalid_input"
|
||||
| "unsupported_provider"
|
||||
| "unsupported_target"
|
||||
| "integrity_mismatch"
|
||||
| "truncated"
|
||||
| "cancelled"
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
);
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
import { z } from "zod";
|
||||
|
||||
const byteOffset = z.number().int().nonnegative();
|
||||
const sha256 = z.string().regex(/^[a-f0-9]{64}$/);
|
||||
|
||||
/** Select the carrier representation explicitly; no RPC or deployed-contract lookup is implied. */
|
||||
export const inspectEvmInterfaceInputSchema = z.strictObject({
|
||||
path: z
|
||||
.string()
|
||||
.min(1)
|
||||
.describe("Absolute path to caller-selected local bytecode carrier"),
|
||||
encoding: z
|
||||
.enum(["raw", "hex"])
|
||||
.describe(
|
||||
"Raw bytes or UTF-8 hex with optional 0x prefix and outer ASCII whitespace",
|
||||
),
|
||||
});
|
||||
|
||||
/** Recovered interface candidates are inferences, independently of observed byte identity. */
|
||||
export const evmInterfaceSchema = z
|
||||
.strictObject({
|
||||
artifact: z.strictObject({
|
||||
path: z.string().min(1),
|
||||
sha256,
|
||||
bytes: byteOffset,
|
||||
encoding: z.enum(["raw", "hex"]),
|
||||
}),
|
||||
bytecode: z.strictObject({
|
||||
sha256,
|
||||
bytes: byteOffset,
|
||||
hex: z.string().regex(/^(?:[0-9a-f]{2})*$/),
|
||||
digest_algorithm: z.literal("sha256"),
|
||||
kind: z.literal("unknown"),
|
||||
deployment_authenticity: z.literal("unknown"),
|
||||
hardfork: z.literal("unknown"),
|
||||
}),
|
||||
evidence_kind: z.literal("inferred"),
|
||||
functions: z.array(
|
||||
z.strictObject({
|
||||
selector: z.string().regex(/^0x[0-9a-f]{8}$/),
|
||||
bytecode_offset: byteOffset,
|
||||
dispatch: z.enum(["abi", "fallback"]),
|
||||
inferred_arguments: z.string().nullable(),
|
||||
inferred_state_mutability: z.string().nullable(),
|
||||
}),
|
||||
),
|
||||
discovery_completeness: z.literal("unknown"),
|
||||
runtime_execution: z.literal("not-performed"),
|
||||
diagnostics: z.strictObject({
|
||||
stdout: z.string(),
|
||||
stderr: z.string(),
|
||||
truncated: z.boolean(),
|
||||
}),
|
||||
limitations: z.array(z.string()),
|
||||
})
|
||||
.superRefine((value, context) => {
|
||||
if (value.bytecode.hex.length !== value.bytecode.bytes * 2)
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["bytecode"],
|
||||
message:
|
||||
"Selected byte count does not match the bytecode hex representation.",
|
||||
});
|
||||
for (const [index, fn] of value.functions.entries())
|
||||
if (fn.bytecode_offset >= value.bytecode.bytes)
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["functions", index, "bytecode_offset"],
|
||||
message: "Inferred body offset lies outside the selected bytecode.",
|
||||
});
|
||||
});
|
||||
|
||||
export type InspectEvmInterfaceInput = z.infer<
|
||||
typeof inspectEvmInterfaceInputSchema
|
||||
>;
|
||||
export type EvmInterface = z.infer<typeof evmInterfaceSchema>;
|
||||
|
|
@ -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(),
|
||||
);
|
||||
});
|
||||
|
|
@ -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"));
|
||||
};
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import { EVM_INTERFACE_LIMITS } from "./EvmoleRelease.js";
|
||||
|
||||
/** Selected worker soft limits to configure; inherited hard limits are left unchanged. */
|
||||
export interface EvmWorkerLimits {
|
||||
readonly addressSpaceBytes: number;
|
||||
readonly cpuSeconds: number;
|
||||
readonly fileSizeBytes: number;
|
||||
}
|
||||
|
||||
/** Read only the three selected Linux limits, retaining tighter soft/hard boundaries. */
|
||||
export const selectEvmWorkerLimits = (
|
||||
raw: unknown,
|
||||
): Result<EvmWorkerLimits, string> => {
|
||||
if (typeof raw !== "string" || Buffer.byteLength(raw) > 64 * 1024)
|
||||
return err(
|
||||
"Linux resource limits must be text within the 64 KiB parsing budget.",
|
||||
);
|
||||
const selected: number[] = [];
|
||||
for (const [name, units, maximum] of [
|
||||
["Max address space", "bytes", EVM_INTERFACE_LIMITS.addressSpaceBytes],
|
||||
["Max cpu time", "seconds", 30],
|
||||
["Max file size", "bytes", EVM_INTERFACE_LIMITS.outputBytes],
|
||||
] as const) {
|
||||
const entries = raw
|
||||
.split("\n")
|
||||
.filter((line) => line.startsWith(name + " "));
|
||||
if (entries.length !== 1)
|
||||
return err(`Linux resource limits must report exactly one ${name} row.`);
|
||||
const match = new RegExp(
|
||||
`^${name}\\s+(unlimited|[0-9]+)\\s+(unlimited|[0-9]+)\\s+${units}\\s*$`,
|
||||
).exec(entries[0] ?? "");
|
||||
if (match === null)
|
||||
return err(`Malformed inherited Linux resource limit: ${name}.`);
|
||||
let effective = BigInt(maximum);
|
||||
const soft = match[1];
|
||||
const hard = match[2];
|
||||
if (soft === undefined || hard === undefined)
|
||||
return err(`Missing ${name} values.`);
|
||||
if (
|
||||
hard !== "unlimited" &&
|
||||
(soft === "unlimited" || BigInt(soft) > BigInt(hard))
|
||||
)
|
||||
return err(`Inherited ${name} soft limit exceeds its hard limit.`);
|
||||
for (const value of [soft, hard])
|
||||
if (value !== "unlimited" && BigInt(value) < effective)
|
||||
effective = BigInt(value);
|
||||
selected.push(Number(effective));
|
||||
}
|
||||
const [addressSpaceBytes, cpuSeconds, fileSizeBytes] = selected;
|
||||
if (
|
||||
addressSpaceBytes === undefined ||
|
||||
cpuSeconds === undefined ||
|
||||
fileSizeBytes === undefined
|
||||
)
|
||||
return err("Required Linux resource limits were not selected.");
|
||||
return ok({ addressSpaceBytes, cpuSeconds, fileSizeBytes });
|
||||
};
|
||||
|
|
@ -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,
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
|
|
@ -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,
|
||||
})),
|
||||
};
|
||||
};
|
||||
|
|
@ -0,0 +1,401 @@
|
|||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { access, readFile, stat, writeFile } from "node:fs/promises";
|
||||
import { constants } from "node:fs";
|
||||
import { isAbsolute, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
createAnalysisExecution,
|
||||
type AnalysisExecution,
|
||||
type ExecutionOptions,
|
||||
} from "../application/AnalysisProvider.js";
|
||||
import type { EvmInterfacePort } from "../application/evm/EvmInterfacePort.js";
|
||||
import { readStableArtifact } from "../artifacts/readStableArtifact.js";
|
||||
import { OwnedCommandFailure } from "../process/OwnedCommand.js";
|
||||
import type {
|
||||
AnalysisError,
|
||||
AnalysisCapturedOutput,
|
||||
} from "../domain/analysisErrorBase.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisCapabilityUnavailableError,
|
||||
AnalysisUnsupportedTargetError,
|
||||
AnalysisInputError,
|
||||
AnalysisOutputError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
||||
import type { InspectEvmInterfaceInput } from "../domain/evm/evmInterface.js";
|
||||
import { evmInterfaceSchema } from "../domain/evm/evmInterface.js";
|
||||
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
|
||||
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import { runOwnedCommand } from "../process/OwnedCommand.js";
|
||||
import { PrivateRuntimeRoot } from "../process/PrivateRuntimeRoot.js";
|
||||
import {
|
||||
evmInterfaceFailure,
|
||||
capturedEvmOutput,
|
||||
type EvmFileSizeFailureEvidence,
|
||||
} from "./EvmoleFailures.js";
|
||||
import {
|
||||
evmoleInterfaceReplySchema,
|
||||
projectEvmoleInterface,
|
||||
} from "./EvmoleInterfaceAdapter.js";
|
||||
import {
|
||||
EVMOLE_PROVIDER_IDENTITY,
|
||||
EVM_INTERFACE_LIMITS,
|
||||
EVM_FILE_SIZE_FAILURE_EXIT,
|
||||
} from "./EvmoleRelease.js";
|
||||
import { decodeEvmBytecodeCarrier } from "./EvmBytecodeCarrier.js";
|
||||
import {
|
||||
selectEvmWorkerLimits,
|
||||
type EvmWorkerLimits,
|
||||
} from "./EvmWorkerLimits.js";
|
||||
|
||||
const OPERATION = "inspect_evm_interface";
|
||||
const replySchema = z.discriminatedUnion("ok", [
|
||||
z.strictObject({
|
||||
ok: z.literal(true),
|
||||
version: z.literal("0.9.3"),
|
||||
bytecode: z.strictObject({
|
||||
sha256: z.string().regex(/^[a-f0-9]{64}$/),
|
||||
bytes: z.number().int().nonnegative(),
|
||||
hex: z.string().regex(/^(?:[a-f0-9]{2})*$/),
|
||||
}),
|
||||
raw: evmoleInterfaceReplySchema,
|
||||
}),
|
||||
z.strictObject({
|
||||
ok: z.literal(false),
|
||||
reason: z.enum(["format", "decoder", "output-limit", "unsupported"]),
|
||||
message: z.string(),
|
||||
}),
|
||||
]);
|
||||
type Launcher = NonNullable<Parameters<typeof runOwnedCommand>[2]>["launcher"];
|
||||
|
||||
/** Run unchanged EVMole/WASM in an owned worker; no target, chain or network execution. */
|
||||
export class EvmoleInterfaceProvider implements EvmInterfacePort {
|
||||
constructor(
|
||||
readonly environment: Readonly<NodeJS.ProcessEnv> = process.env,
|
||||
readonly launcher?: Launcher,
|
||||
readonly createRuntime: () => Promise<
|
||||
Pick<PrivateRuntimeRoot, "path" | "close">
|
||||
> = () => PrivateRuntimeRoot.create({ prefix: "rea-evm-interface-" }),
|
||||
) {}
|
||||
|
||||
/** Bind recovered interface candidates to both original carrier and decoded byte identity. */
|
||||
async inspect(
|
||||
input: InspectEvmInterfaceInput,
|
||||
options?: ExecutionOptions,
|
||||
): Promise<Result<AnalysisExecution, AnalysisError>> {
|
||||
let root: Pick<PrivateRuntimeRoot, "path" | "close"> | undefined;
|
||||
let retainedOutput: AnalysisCapturedOutput | undefined;
|
||||
let phase: "configuration" | "artifact-read" | "worker" = "configuration";
|
||||
let workerLimits: EvmWorkerLimits | undefined;
|
||||
const limiter =
|
||||
this.environment.REA_EVM_PRLIMIT_COMMAND ?? "/usr/bin/prlimit";
|
||||
let result: Result<AnalysisExecution, AnalysisError>;
|
||||
try {
|
||||
if (options?.signal?.aborted) throw new AnalysisCancelledError(OPERATION);
|
||||
if (process.platform !== "linux" || process.arch !== "x64")
|
||||
throw new AnalysisCapabilityUnavailableError(
|
||||
EVMOLE_PROVIDER_IDENTITY.id,
|
||||
OPERATION,
|
||||
"Initial real-verified offline EVM interface profile supports Linux x64 only.",
|
||||
);
|
||||
try {
|
||||
if (!isAbsolute(limiter))
|
||||
throw new Error(
|
||||
"REA_EVM_PRLIMIT_COMMAND must be an absolute executable path.",
|
||||
);
|
||||
await access(limiter, constants.X_OK);
|
||||
if (!(await stat(limiter)).isFile())
|
||||
throw new Error(
|
||||
"Configured prlimit must be a regular executable file, not a directory.",
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
const reason = `Offline EVM interface inspection requires caller-supplied util-linux prlimit: ${limiter}; ${cause instanceof Error ? cause.message : String(cause)}`;
|
||||
throw new AnalysisCapabilityUnavailableError(
|
||||
EVMOLE_PROVIDER_IDENTITY.id,
|
||||
OPERATION,
|
||||
reason,
|
||||
{ cause, userMessage: reason },
|
||||
);
|
||||
}
|
||||
let inheritedLimits: string;
|
||||
try {
|
||||
inheritedLimits = await readFile("/proc/self/limits", "utf8");
|
||||
} catch (cause: unknown) {
|
||||
const reason = `Could not read inherited Linux resource limits at /proc/self/limits: ${cause instanceof Error ? cause.message : String(cause)}`;
|
||||
throw new AnalysisCapabilityUnavailableError(
|
||||
EVMOLE_PROVIDER_IDENTITY.id,
|
||||
OPERATION,
|
||||
reason,
|
||||
{ cause, userMessage: reason },
|
||||
);
|
||||
}
|
||||
const limits = selectEvmWorkerLimits(inheritedLimits);
|
||||
if (!limits.ok)
|
||||
throw new AnalysisCapabilityUnavailableError(
|
||||
EVMOLE_PROVIDER_IDENTITY.id,
|
||||
OPERATION,
|
||||
limits.error,
|
||||
{ userMessage: limits.error },
|
||||
);
|
||||
workerLimits = limits.value;
|
||||
phase = "artifact-read";
|
||||
const carrier = await readStableArtifact(
|
||||
input.path,
|
||||
EVM_INTERFACE_LIMITS.inputBytes,
|
||||
options?.signal,
|
||||
);
|
||||
phase = "worker";
|
||||
root = await this.createRuntime();
|
||||
const snapshotPath = join(root.path, "carrier.snapshot");
|
||||
const requestPath = join(root.path, "request.json");
|
||||
const replyPath = join(root.path, "reply.json");
|
||||
await writeFile(snapshotPath, carrier.bytes, {
|
||||
flag: "wx",
|
||||
mode: 0o600,
|
||||
...(options?.signal === undefined ? {} : { signal: options.signal }),
|
||||
});
|
||||
await writeFile(
|
||||
requestPath,
|
||||
JSON.stringify({
|
||||
snapshot_path: snapshotPath,
|
||||
reply_path: replyPath,
|
||||
failure_marker_path: join(root.path, "resource.failure"),
|
||||
encoding: input.encoding,
|
||||
}),
|
||||
{ flag: "wx", mode: 0o600 },
|
||||
);
|
||||
const { NODE_OPTIONS: _ambientNodeOptions, ...environment } =
|
||||
this.environment;
|
||||
const execution = await runOwnedCommand(
|
||||
{
|
||||
command: limiter,
|
||||
arguments: [
|
||||
`--as=${String(workerLimits.addressSpaceBytes)}:`,
|
||||
`--fsize=${String(workerLimits.fileSizeBytes)}:`,
|
||||
"--core=0:",
|
||||
`--cpu=${String(workerLimits.cpuSeconds)}:`,
|
||||
"--",
|
||||
process.execPath,
|
||||
"--max-old-space-size=256",
|
||||
"--v8-pool-size=1",
|
||||
"--disable-wasm-trap-handler",
|
||||
fileURLToPath(
|
||||
new URL("./EvmoleInterfaceWorker.js", import.meta.url),
|
||||
),
|
||||
requestPath,
|
||||
],
|
||||
cwd: root.path,
|
||||
expectedCommand: null,
|
||||
runId: `rea-evm-interface-${randomUUID()}`,
|
||||
hostEnvironment: environment,
|
||||
},
|
||||
{
|
||||
timeoutMs: EVM_INTERFACE_LIMITS.timeoutMs,
|
||||
diagnosticBytes: EVM_INTERFACE_LIMITS.diagnosticBytes,
|
||||
},
|
||||
{
|
||||
...(options?.signal === undefined ? {} : { signal: options.signal }),
|
||||
...(this.launcher === undefined ? {} : { launcher: this.launcher }),
|
||||
},
|
||||
);
|
||||
const capturedOutput = capturedEvmOutput(execution);
|
||||
retainedOutput = capturedOutput;
|
||||
let reply: z.output<typeof replySchema>;
|
||||
try {
|
||||
const file = await readStableArtifact(
|
||||
replyPath,
|
||||
EVM_INTERFACE_LIMITS.outputBytes,
|
||||
options?.signal,
|
||||
);
|
||||
reply = replySchema.parse(JSON.parse(file.bytes.toString("utf8")));
|
||||
} catch (cause: unknown) {
|
||||
if (options?.signal?.aborted)
|
||||
throw new AnalysisCancelledError(OPERATION, { capturedOutput });
|
||||
throw new AnalysisOutputError(
|
||||
OPERATION,
|
||||
`Owned interface worker reply failed for ${input.path}: ${cause instanceof Error ? cause.message : String(cause)}`,
|
||||
{ cause, capturedOutput },
|
||||
);
|
||||
}
|
||||
if (!reply.ok) {
|
||||
if (reply.reason === "unsupported")
|
||||
throw new AnalysisUnsupportedTargetError(
|
||||
OPERATION,
|
||||
input.path,
|
||||
reply.message,
|
||||
{ capturedOutput },
|
||||
);
|
||||
if (reply.reason === "format")
|
||||
throw new AnalysisInputError(OPERATION, { capturedOutput }, [
|
||||
{
|
||||
path: ["path"],
|
||||
reason: "invalid_format",
|
||||
message: reply.message,
|
||||
},
|
||||
]);
|
||||
if (reply.reason === "output-limit")
|
||||
throw new AnalysisOutputError(OPERATION, reply.message, {
|
||||
capturedOutput,
|
||||
});
|
||||
throw new ProviderAdapterError(EVMOLE_PROVIDER_IDENTITY.id, OPERATION, {
|
||||
diagnostics: {
|
||||
phase,
|
||||
failure_kind: reply.reason,
|
||||
reason: reply.message,
|
||||
stdout: execution.stdout.text,
|
||||
stderr: execution.stderr.text,
|
||||
captured_output: { ...capturedOutput },
|
||||
},
|
||||
});
|
||||
}
|
||||
const selectedBytes = decodeEvmBytecodeCarrier(
|
||||
carrier.bytes,
|
||||
input.encoding,
|
||||
);
|
||||
const selectedHex = Buffer.from(selectedBytes).toString("hex");
|
||||
const decodedHash = createHash("sha256")
|
||||
.update(selectedBytes)
|
||||
.digest("hex");
|
||||
if (
|
||||
decodedHash !== reply.bytecode.sha256 ||
|
||||
selectedHex !== reply.bytecode.hex ||
|
||||
selectedBytes.length !== reply.bytecode.bytes
|
||||
)
|
||||
throw new AnalysisOutputError(
|
||||
OPERATION,
|
||||
"Decoded bytecode representation does not match the selected carrier, encoding or reported SHA-256.",
|
||||
{ capturedOutput },
|
||||
);
|
||||
const limitations = [
|
||||
`Configured worker resource soft limits: address_space_bytes=${String(workerLimits.addressSpaceBytes)}, cpu_seconds=${String(workerLimits.cpuSeconds)}, file_size_bytes=${String(workerLimits.fileSizeBytes)}. Selected from inherited tighter limits; original hard limits are retained. Effective worker limits are not independently probed.`,
|
||||
"Upstream may also report terminal metadata while recovering selectors. Its reported values are retained in raw_result, but integer precision and non-text-key coverage are unknown in the JS binding; complete original bytecode remains available.",
|
||||
"Recovered selectors, argument strings and mutability are upstream inferences, not verified ABI signatures or names. Empty results do not prove the absence of callable dispatch.",
|
||||
"Bytecode kind, hardfork and deployed-contract authenticity are unknown. This inspection performs no target runtime execution, chain/RPC lookup or network request.",
|
||||
"Original carrier and decoded bytecode SHA-256 are distinct identities; SHA-256 is not the Ethereum Keccak code hash.",
|
||||
"The owned worker has a 256 MiB JavaScript heap, 3 GiB virtual address-space limit and 30-second deadline. Virtual address space is distinct from resident memory; limits fail without partial success.",
|
||||
];
|
||||
const validated = evmInterfaceSchema.safeParse({
|
||||
artifact: {
|
||||
path: input.path,
|
||||
sha256: carrier.sha256,
|
||||
bytes: carrier.bytes.length,
|
||||
encoding: input.encoding,
|
||||
},
|
||||
bytecode: {
|
||||
...reply.bytecode,
|
||||
digest_algorithm: "sha256",
|
||||
kind: "unknown",
|
||||
deployment_authenticity: "unknown",
|
||||
hardfork: "unknown",
|
||||
},
|
||||
evidence_kind: "inferred",
|
||||
functions: projectEvmoleInterface(reply.raw).functions,
|
||||
discovery_completeness: "unknown",
|
||||
runtime_execution: "not-performed",
|
||||
diagnostics: {
|
||||
stdout: execution.stdout.text,
|
||||
stderr: execution.stderr.text,
|
||||
truncated: capturedOutput.truncated,
|
||||
},
|
||||
limitations,
|
||||
});
|
||||
if (!validated.success)
|
||||
throw new AnalysisOutputError(
|
||||
OPERATION,
|
||||
`Interface worker returned invalid bytecode-relative evidence: ${validated.error.issues[0]?.message ?? "schema mismatch"}`,
|
||||
{ capturedOutput },
|
||||
);
|
||||
const report = validated.data;
|
||||
result = ok(
|
||||
createAnalysisExecution(report, EVMOLE_PROVIDER_IDENTITY, {
|
||||
rawResult: reply.raw,
|
||||
subject: { path: input.path, format: "file", sha256: carrier.sha256 },
|
||||
locations: [{ kind: "artifact-path", path: input.path }],
|
||||
limitations,
|
||||
}),
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
let fileFailure: EvmFileSizeFailureEvidence | undefined;
|
||||
if (
|
||||
cause instanceof OwnedCommandFailure &&
|
||||
cause.snapshot?.exitCode === EVM_FILE_SIZE_FAILURE_EXIT &&
|
||||
cause.snapshot.signal === null &&
|
||||
root !== undefined
|
||||
) {
|
||||
try {
|
||||
const marker = await readStableArtifact(
|
||||
join(root.path, "resource.failure"),
|
||||
1,
|
||||
);
|
||||
if (!marker.bytes.equals(Buffer.from("F")))
|
||||
throw new Error(
|
||||
"Private worker failure marker does not match the observed exit status.",
|
||||
);
|
||||
fileFailure = { verified: true, failure: null };
|
||||
} catch (markerFailure: unknown) {
|
||||
fileFailure = {
|
||||
verified: false,
|
||||
failure:
|
||||
markerFailure instanceof Error
|
||||
? markerFailure.message
|
||||
: String(markerFailure),
|
||||
};
|
||||
}
|
||||
}
|
||||
result = err(
|
||||
options?.signal?.aborted &&
|
||||
(cause === options.signal.reason ||
|
||||
(cause instanceof Error && cause.name === "AbortError"))
|
||||
? new AnalysisCancelledError(
|
||||
OPERATION,
|
||||
retainedOutput === undefined
|
||||
? undefined
|
||||
: { capturedOutput: retainedOutput },
|
||||
)
|
||||
: evmInterfaceFailure(
|
||||
cause,
|
||||
phase,
|
||||
input.path,
|
||||
workerLimits,
|
||||
limiter,
|
||||
fileFailure,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (root !== undefined) {
|
||||
try {
|
||||
await root.close();
|
||||
} catch (cause: unknown) {
|
||||
return err(
|
||||
new ProviderCleanupError(
|
||||
EVMOLE_PROVIDER_IDENTITY.id,
|
||||
[root.path],
|
||||
{
|
||||
reason: cause instanceof Error ? cause.message : String(cause),
|
||||
previous_error: result.ok
|
||||
? null
|
||||
: projectAnalysisError(result.error),
|
||||
...(retainedOutput === undefined
|
||||
? {}
|
||||
: { captured_output: { ...retainedOutput } }),
|
||||
},
|
||||
{ operation: OPERATION },
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
return result.ok && options?.signal?.aborted
|
||||
? err(
|
||||
new AnalysisCancelledError(
|
||||
OPERATION,
|
||||
retainedOutput === undefined
|
||||
? undefined
|
||||
: { capturedOutput: retainedOutput },
|
||||
),
|
||||
)
|
||||
: result;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,108 @@
|
|||
import { createHash } from "node:crypto";
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
decodeEvmBytecodeCarrier,
|
||||
EvmCarrierFailure,
|
||||
} from "./EvmBytecodeCarrier.js";
|
||||
import { projectEvmoleInterface } from "./EvmoleInterfaceAdapter.js";
|
||||
import {
|
||||
EVM_INTERFACE_LIMITS,
|
||||
EVM_FILE_SIZE_FAILURE_EXIT,
|
||||
} from "./EvmoleRelease.js";
|
||||
|
||||
const requestSchema = z.strictObject({
|
||||
snapshot_path: z.string(),
|
||||
reply_path: z.string(),
|
||||
failure_marker_path: z.string(),
|
||||
encoding: z.enum(["raw", "hex"]),
|
||||
});
|
||||
class UnsupportedContainer extends Error {}
|
||||
|
||||
const main = async (): Promise<void> => {
|
||||
const requestPath = process.argv[2];
|
||||
if (requestPath === undefined)
|
||||
throw new Error("Owned worker request path missing.");
|
||||
const request = requestSchema.parse(
|
||||
JSON.parse(await readFile(requestPath, "utf8")),
|
||||
);
|
||||
let reply: unknown;
|
||||
try {
|
||||
const bytes = decodeEvmBytecodeCarrier(
|
||||
await readFile(request.snapshot_path),
|
||||
request.encoding,
|
||||
);
|
||||
if (bytes[0] === 0xef && bytes[1] === 0x00)
|
||||
throw new UnsupportedContainer(
|
||||
"Container marker EF00 is outside the verified flat-bytecode interface profile.",
|
||||
);
|
||||
const modulePath = import.meta.resolve("evmole/no_tla");
|
||||
const metadata = z
|
||||
.object({ version: z.literal("0.9.3") })
|
||||
.parse(
|
||||
JSON.parse(
|
||||
await readFile(new URL("../package.json", modulePath), "utf8"),
|
||||
),
|
||||
);
|
||||
const engine = await import("evmole/no_tla");
|
||||
engine.initSync({
|
||||
module: await readFile(
|
||||
new URL(import.meta.resolve("evmole/evmole_bg.wasm")),
|
||||
),
|
||||
});
|
||||
const inferred = projectEvmoleInterface(
|
||||
engine.contractInfo(Buffer.from(bytes).toString("hex"), {
|
||||
selectors: true,
|
||||
arguments: true,
|
||||
stateMutability: true,
|
||||
}),
|
||||
);
|
||||
reply = {
|
||||
ok: true,
|
||||
version: metadata.version,
|
||||
bytecode: {
|
||||
sha256: createHash("sha256").update(bytes).digest("hex"),
|
||||
bytes: bytes.length,
|
||||
hex: Buffer.from(bytes).toString("hex"),
|
||||
},
|
||||
raw: inferred.raw,
|
||||
};
|
||||
} catch (cause: unknown) {
|
||||
reply = {
|
||||
ok: false,
|
||||
reason:
|
||||
cause instanceof EvmCarrierFailure
|
||||
? "format"
|
||||
: cause instanceof UnsupportedContainer
|
||||
? "unsupported"
|
||||
: "decoder",
|
||||
message: cause instanceof Error ? cause.message : String(cause),
|
||||
};
|
||||
}
|
||||
let encoded = JSON.stringify(reply);
|
||||
if (Buffer.byteLength(encoded) > EVM_INTERFACE_LIMITS.outputBytes)
|
||||
encoded = JSON.stringify({
|
||||
ok: false,
|
||||
reason: "output-limit",
|
||||
message: "Complete interface evidence exceeds the 16 MiB reply budget.",
|
||||
});
|
||||
try {
|
||||
await writeFile(request.reply_path, encoded, { flag: "wx", mode: 0o600 });
|
||||
} catch (cause: unknown) {
|
||||
if (cause instanceof Error && "code" in cause && cause.code === "EFBIG") {
|
||||
try {
|
||||
await writeFile(request.failure_marker_path, "F", {
|
||||
flag: "wx",
|
||||
mode: 0o600,
|
||||
});
|
||||
} catch (markerFailure: unknown) {
|
||||
process.stderr.write(
|
||||
`File-size failure marker unavailable: ${markerFailure instanceof Error ? markerFailure.message : String(markerFailure)}\n`,
|
||||
);
|
||||
}
|
||||
process.exitCode = EVM_FILE_SIZE_FAILURE_EXIT;
|
||||
} else throw cause;
|
||||
}
|
||||
};
|
||||
|
||||
await main();
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
);
|
||||
};
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { EVMOLE_PROVIDER_IDENTITY } from "../../../src/evm/EvmoleRelease.js";
|
||||
import { PWNTOOLS_PROVIDER_IDENTITY } from "../../../src/native/pwntools/PwntoolsRelease.js";
|
||||
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
|
@ -117,6 +118,7 @@ describe("canonical product catalog", () => {
|
|||
IDA_PROVIDER_IDENTITY,
|
||||
NATIVE_MACOS_PROVIDER_IDENTITY,
|
||||
PWNTOOLS_PROVIDER_IDENTITY,
|
||||
EVMOLE_PROVIDER_IDENTITY,
|
||||
ARTIFACT_GRAPH_PROVIDER,
|
||||
ANDROID_APPLICATION_PROVIDER,
|
||||
APPLE_APPLICATION_PROVIDER,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,597 @@
|
|||
import { createHash } from "node:crypto";
|
||||
import { access, readFile, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { expect, it, onTestFinished } from "vitest";
|
||||
import { z } from "zod";
|
||||
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
|
||||
import { evmInterfaceSchema } from "../../../src/domain/evm/evmInterface.js";
|
||||
import { EvmoleInterfaceProvider } from "../../../src/evm/EvmoleInterfaceProvider.js";
|
||||
import { spawnOwnedProviderProcess } from "../../../src/process/ProviderProcess.js";
|
||||
import { waitForProviderProcessReady } from "../../fixtures/providerProcess.js";
|
||||
import { PrivateRuntimeRoot } from "../../../src/process/PrivateRuntimeRoot.js";
|
||||
import {
|
||||
createTestWorkspace,
|
||||
removeTestWorkspace,
|
||||
} from "../../support/workspace/workspaceFixture.js";
|
||||
|
||||
const unsupportedHost = process.platform !== "linux" || process.arch !== "x64";
|
||||
const requestSchema = z.strictObject({
|
||||
snapshot_path: z.string(),
|
||||
reply_path: z.string(),
|
||||
failure_marker_path: z.string(),
|
||||
encoding: z.enum(["raw", "hex"]),
|
||||
});
|
||||
const fixture = async () => {
|
||||
const workspace = await createTestWorkspace("rea-evm-interface-boundary-");
|
||||
onTestFinished(() => removeTestWorkspace(workspace.root));
|
||||
const path = join(workspace.root, "selected.hex");
|
||||
await writeFile(path, "0x60006000f3\n");
|
||||
return { path, root: workspace.root };
|
||||
};
|
||||
|
||||
it.runIf(!unsupportedHost).each(["SIGXCPU", "SIGXFSZ", "EFBIG"])(
|
||||
"preserves %s with configured limits distinct from unknown effective limits",
|
||||
async (failure) => {
|
||||
const { path } = await fixture();
|
||||
let ownedPath = "";
|
||||
const provider = new EvmoleInterfaceProvider({}, async (spawn) => {
|
||||
ownedPath = spawn.cwd ?? "";
|
||||
if (failure === "EFBIG")
|
||||
await writeFile(join(ownedPath, "resource.failure"), "F");
|
||||
return spawnOwnedProviderProcess({
|
||||
...spawn,
|
||||
command: process.execPath,
|
||||
arguments: [
|
||||
"-e",
|
||||
failure === "EFBIG"
|
||||
? 'process.stdout.write("resource diagnostic", () => { process.exitCode = 76; })'
|
||||
: failure === "SIGXFSZ"
|
||||
? 'process.on("SIGXFSZ", () => { process.removeAllListeners("SIGXFSZ"); process.kill(process.pid, "SIGXFSZ"); }); process.stdout.write("resource diagnostic", () => process.kill(process.pid, "SIGXFSZ")); setTimeout(() => {}, 1000);'
|
||||
: `process.stdout.write("resource diagnostic", () => process.kill(process.pid, "${failure}"))`,
|
||||
],
|
||||
});
|
||||
});
|
||||
const result = await provider.inspect({ path, encoding: "hex" });
|
||||
if (result.ok) throw new Error("Expected observed resource failure");
|
||||
expect(projectAnalysisError(result.error)).toMatchObject({
|
||||
code: "resource_constraint",
|
||||
details: {
|
||||
resource: failure === "SIGXCPU" ? "cpu" : "file-size",
|
||||
reported_limits: {
|
||||
configured_soft_limits: { cpu_seconds: expect.any(Number) },
|
||||
effective_soft_limits: null,
|
||||
},
|
||||
captured_output: { stdout: "resource diagnostic", truncated: false },
|
||||
},
|
||||
});
|
||||
expect(result.error.message).toContain(
|
||||
failure === "EFBIG" ? "exact write failure cause" : "exact signal cause",
|
||||
);
|
||||
await expect(access(ownedPath)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
},
|
||||
);
|
||||
|
||||
it.runIf(!unsupportedHost).each(["missing", "wrong"])(
|
||||
"does not classify a launcher exit 76 with %s worker failure evidence",
|
||||
async (scenario) => {
|
||||
const { path } = await fixture();
|
||||
let ownedPath = "";
|
||||
const provider = new EvmoleInterfaceProvider({}, async (spawn) => {
|
||||
ownedPath = spawn.cwd ?? "";
|
||||
if (scenario === "wrong")
|
||||
await writeFile(
|
||||
join(ownedPath, "resource.failure"),
|
||||
Buffer.from([0xc6]),
|
||||
);
|
||||
return spawnOwnedProviderProcess({
|
||||
...spawn,
|
||||
command: process.execPath,
|
||||
arguments: [
|
||||
"-e",
|
||||
'process.stdout.write("launcher failure", () => { process.exitCode = 76; });',
|
||||
],
|
||||
});
|
||||
});
|
||||
const result = await provider.inspect({ path, encoding: "hex" });
|
||||
if (result.ok) throw new Error("Expected launcher failure");
|
||||
expect(projectAnalysisError(result.error).code).toBe("execution_failure");
|
||||
expect(result.error).toMatchObject({
|
||||
diagnostics: {
|
||||
file_size_failure_marker: {
|
||||
verified: false,
|
||||
failure: expect.any(String),
|
||||
},
|
||||
captured_output: { stdout: "launcher failure", truncated: false },
|
||||
},
|
||||
});
|
||||
await expect(access(ownedPath)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
},
|
||||
);
|
||||
|
||||
it.runIf(!unsupportedHost)(
|
||||
"retains actual diagnostic truncation when overflow and process cleanup both fail",
|
||||
async () => {
|
||||
const { path } = await fixture();
|
||||
let ownedPath = "";
|
||||
const provider = new EvmoleInterfaceProvider({}, async (spawn) => {
|
||||
ownedPath = spawn.cwd ?? "";
|
||||
const launched = await spawnOwnedProviderProcess({
|
||||
...spawn,
|
||||
command: process.execPath,
|
||||
arguments: ["-e", 'process.stdout.write("x".repeat(1048577))'],
|
||||
});
|
||||
return {
|
||||
...launched,
|
||||
cleanup: async () => {
|
||||
await launched.cleanup?.();
|
||||
throw new Error("injected post-cleanup reporting failure");
|
||||
},
|
||||
};
|
||||
});
|
||||
const result = await provider.inspect({ path, encoding: "hex" });
|
||||
if (result.ok) throw new Error("Expected compounded lifecycle failure");
|
||||
expect(result.error).toMatchObject({
|
||||
cleanupIncomplete: true,
|
||||
diagnostics: {
|
||||
reason: "injected post-cleanup reporting failure",
|
||||
previous_error: { failure_kind: "output-limit" },
|
||||
captured_output: { truncated: true, stderr: "" },
|
||||
},
|
||||
});
|
||||
await expect(access(ownedPath)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
},
|
||||
);
|
||||
|
||||
it.runIf(!unsupportedHost).each([1048576, 1048577])(
|
||||
"keeps complete diagnostics or rejects a valid worker reply with %s diagnostic bytes",
|
||||
async (size) => {
|
||||
const { path } = await fixture();
|
||||
let ownedPath = "";
|
||||
const provider = new EvmoleInterfaceProvider({}, async (spawn) => {
|
||||
ownedPath = spawn.cwd ?? "";
|
||||
const requestPath = spawn.arguments.at(-1);
|
||||
if (requestPath === undefined) throw new Error("Request missing");
|
||||
const request = requestSchema.parse(
|
||||
JSON.parse(await readFile(requestPath, "utf8")),
|
||||
);
|
||||
const bytes = Buffer.from("60006000f3", "hex");
|
||||
await writeFile(
|
||||
request.reply_path,
|
||||
JSON.stringify({
|
||||
ok: true,
|
||||
version: "0.9.3",
|
||||
bytecode: {
|
||||
sha256: createHash("sha256").update(bytes).digest("hex"),
|
||||
bytes: bytes.length,
|
||||
hex: bytes.toString("hex"),
|
||||
},
|
||||
raw: { functions: [] },
|
||||
}),
|
||||
);
|
||||
return spawnOwnedProviderProcess({
|
||||
...spawn,
|
||||
command: process.execPath,
|
||||
arguments: ["-e", `process.stderr.write("x".repeat(${String(size)}))`],
|
||||
});
|
||||
});
|
||||
const result = await provider.inspect({ path, encoding: "hex" });
|
||||
if (size === 1048576) {
|
||||
if (!result.ok) throw result.error;
|
||||
const report = evmInterfaceSchema.parse(result.value.result);
|
||||
expect(report.diagnostics.stderr).toHaveLength(size);
|
||||
expect(report.diagnostics.truncated).toBe(false);
|
||||
} else {
|
||||
if (result.ok)
|
||||
throw new Error("Expected explicit diagnostic budget failure");
|
||||
expect(projectAnalysisError(result.error)).toMatchObject({
|
||||
code: "unreadable_output",
|
||||
details: { captured_output: { truncated: true } },
|
||||
});
|
||||
}
|
||||
await expect(access(ownedPath)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
},
|
||||
);
|
||||
|
||||
it
|
||||
.runIf(!unsupportedHost)
|
||||
.each([
|
||||
"success",
|
||||
"format",
|
||||
"unsupported",
|
||||
"wrong-version",
|
||||
"wrong-decoded-carrier",
|
||||
"broken-reply",
|
||||
"out-of-range-body",
|
||||
])(
|
||||
"binds source carrier/worker representation and releases owned files: %s",
|
||||
async (scenario) => {
|
||||
const { path } = await fixture();
|
||||
let ownedRoot = "";
|
||||
const provider = new EvmoleInterfaceProvider(
|
||||
{ NODE_OPTIONS: "--inspect=0" },
|
||||
async (spawn) => {
|
||||
ownedRoot = spawn.cwd ?? "";
|
||||
expect(spawn.hostEnvironment?.NODE_OPTIONS).toBeUndefined();
|
||||
expect(spawn.arguments).toContain("--as=3221225472:");
|
||||
expect(spawn.arguments).toContain("--disable-wasm-trap-handler");
|
||||
const requestPath = spawn.arguments.at(-1);
|
||||
if (requestPath === undefined) throw new Error("request missing");
|
||||
const request = requestSchema.parse(
|
||||
JSON.parse(await readFile(requestPath, "utf8")),
|
||||
);
|
||||
expect(request.encoding).toBe("hex");
|
||||
expect(await readFile(request.snapshot_path, "utf8")).toBe(
|
||||
"0x60006000f3\n",
|
||||
);
|
||||
const hex = scenario === "wrong-decoded-carrier" ? "00" : "60006000f3";
|
||||
const bytes = Buffer.from(hex, "hex");
|
||||
await writeFile(
|
||||
request.reply_path,
|
||||
scenario === "broken-reply"
|
||||
? "broken"
|
||||
: JSON.stringify(
|
||||
["format", "unsupported"].includes(scenario)
|
||||
? {
|
||||
ok: false,
|
||||
reason: scenario,
|
||||
message: "Malformed hex carrier.",
|
||||
}
|
||||
: {
|
||||
ok: true,
|
||||
version: scenario === "wrong-version" ? "0.0.0" : "0.9.3",
|
||||
bytecode: {
|
||||
sha256: createHash("sha256")
|
||||
.update(bytes)
|
||||
.digest("hex"),
|
||||
bytes: bytes.length,
|
||||
hex,
|
||||
},
|
||||
raw: {
|
||||
functions:
|
||||
scenario === "out-of-range-body"
|
||||
? [
|
||||
{
|
||||
selector: "aabbccdd",
|
||||
bytecodeOffset: 5,
|
||||
dispatch: "abi",
|
||||
},
|
||||
]
|
||||
: [],
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
return spawnOwnedProviderProcess({
|
||||
...spawn,
|
||||
command: process.execPath,
|
||||
arguments: ["-e", 'process.stderr.write("upstream warning")'],
|
||||
});
|
||||
},
|
||||
);
|
||||
const result = await provider.inspect({ path, encoding: "hex" });
|
||||
if (scenario === "success") {
|
||||
if (!result.ok) throw result.error;
|
||||
expect(result.value.result).toMatchObject({
|
||||
artifact: { path, encoding: "hex", bytes: 13 },
|
||||
bytecode: { hex: "60006000f3", bytes: 5 },
|
||||
diagnostics: { stderr: "upstream warning" },
|
||||
});
|
||||
expect(result.value.rawResult).toEqual({ functions: [] });
|
||||
} else
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
_tag:
|
||||
scenario === "format"
|
||||
? "AnalysisInputError"
|
||||
: scenario === "unsupported"
|
||||
? "AnalysisUnsupportedTargetError"
|
||||
: "AnalysisOutputError",
|
||||
},
|
||||
});
|
||||
if (!result.ok)
|
||||
expect(projectAnalysisError(result.error)).toMatchObject({
|
||||
details: {
|
||||
captured_output: {
|
||||
stdout: "",
|
||||
stderr: "upstream warning",
|
||||
truncated: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(await readFile(path, "utf8")).toBe("0x60006000f3\n");
|
||||
await expect(access(ownedRoot)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
},
|
||||
);
|
||||
|
||||
it.runIf(!unsupportedHost).each(["cancelled", "output-limit", "process"])(
|
||||
"preserves lifecycle failure and independently releases an acquired worker: %s",
|
||||
async (scenario) => {
|
||||
const { path } = await fixture();
|
||||
const controller = new AbortController();
|
||||
let pid: number | undefined;
|
||||
let ownedRoot = "";
|
||||
const provider = new EvmoleInterfaceProvider({}, async (spawn) => {
|
||||
ownedRoot = spawn.cwd ?? "";
|
||||
const launched = await spawnOwnedProviderProcess({
|
||||
...spawn,
|
||||
command: process.execPath,
|
||||
arguments: [
|
||||
"-e",
|
||||
scenario === "cancelled"
|
||||
? 'process.stdout.write("ready\\n");setInterval(()=>undefined,1000)'
|
||||
: scenario === "output-limit"
|
||||
? 'process.stdout.write("x".repeat(1048577))'
|
||||
: 'process.stderr.write("worker failed");process.exit(7)',
|
||||
],
|
||||
});
|
||||
pid = launched.process.pid;
|
||||
if (scenario === "cancelled") {
|
||||
await waitForProviderProcessReady(launched.process);
|
||||
setImmediate(() => controller.abort());
|
||||
}
|
||||
return launched;
|
||||
});
|
||||
expect(
|
||||
await provider.inspect(
|
||||
{ path, encoding: "hex" },
|
||||
{ signal: controller.signal },
|
||||
),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
_tag:
|
||||
scenario === "cancelled"
|
||||
? "AnalysisCancelledError"
|
||||
: scenario === "output-limit"
|
||||
? "AnalysisOutputError"
|
||||
: "ProviderAdapterError",
|
||||
},
|
||||
});
|
||||
if (pid === undefined) throw new Error("worker not acquired");
|
||||
const acquiredPid = pid;
|
||||
expect(() => process.kill(acquiredPid, 0)).toThrow();
|
||||
await expect(access(ownedRoot)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
},
|
||||
);
|
||||
|
||||
it.runIf(!unsupportedHost)(
|
||||
"distinguishes unavailable limiter, absent carrier and prelaunch cancellation",
|
||||
async () => {
|
||||
const { path } = await fixture();
|
||||
expect(
|
||||
await new EvmoleInterfaceProvider({
|
||||
REA_EVM_PRLIMIT_COMMAND: path + ".absent",
|
||||
}).inspect({ path, encoding: "hex" }),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "AnalysisCapabilityUnavailableError" },
|
||||
});
|
||||
expect(
|
||||
await new EvmoleInterfaceProvider({}).inspect({
|
||||
path: path + ".absent",
|
||||
encoding: "raw",
|
||||
}),
|
||||
).toMatchObject({ ok: false, error: { _tag: "AnalysisInputError" } });
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
expect(
|
||||
await new EvmoleInterfaceProvider({}).inspect(
|
||||
{ path, encoding: "raw" },
|
||||
{ signal: controller.signal },
|
||||
),
|
||||
).toMatchObject({ ok: false, error: { _tag: "AnalysisCancelledError" } });
|
||||
},
|
||||
);
|
||||
it.runIf(unsupportedHost)(
|
||||
"reports unverified host coverage before acquiring a provider",
|
||||
async () => {
|
||||
expect(
|
||||
await new EvmoleInterfaceProvider({}).inspect({
|
||||
path: "/selected.hex",
|
||||
encoding: "hex",
|
||||
}),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "AnalysisCapabilityUnavailableError" },
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.runIf(!unsupportedHost)(
|
||||
"preserves actual snapshot-write cancellation and root cleanup",
|
||||
async () => {
|
||||
const { path } = await fixture();
|
||||
const controller = new AbortController();
|
||||
let ownedRoot = "";
|
||||
const provider = new EvmoleInterfaceProvider(
|
||||
{},
|
||||
() => {
|
||||
throw new Error("must not launch worker");
|
||||
},
|
||||
async () => {
|
||||
const root = await PrivateRuntimeRoot.create({
|
||||
prefix: "rea-evm-cancel-",
|
||||
});
|
||||
ownedRoot = root.path;
|
||||
controller.abort();
|
||||
return root;
|
||||
},
|
||||
);
|
||||
expect(
|
||||
await provider.inspect(
|
||||
{ path, encoding: "hex" },
|
||||
{ signal: controller.signal },
|
||||
),
|
||||
).toMatchObject({ ok: false, error: { _tag: "AnalysisCancelledError" } });
|
||||
await expect(access(ownedRoot)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
},
|
||||
);
|
||||
|
||||
it.runIf(!unsupportedHost)(
|
||||
"reports private storage denial without claiming selected carrier read denial",
|
||||
async () => {
|
||||
const { path } = await fixture();
|
||||
const provider = new EvmoleInterfaceProvider({}, undefined, () =>
|
||||
Promise.reject(
|
||||
Object.assign(new Error("private root denied"), { code: "EACCES" }),
|
||||
),
|
||||
);
|
||||
expect(await provider.inspect({ path, encoding: "hex" })).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
_tag: "ProviderAdapterError",
|
||||
diagnostics: { phase: "worker", reason: "private root denied" },
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.runIf(!unsupportedHost)(
|
||||
"retains original format failure when owned-root cleanup fails",
|
||||
async () => {
|
||||
const { path, root } = await fixture();
|
||||
const provider = new EvmoleInterfaceProvider(
|
||||
{},
|
||||
async (spawn) => {
|
||||
const requestPath = spawn.arguments.at(-1);
|
||||
if (requestPath === undefined) throw new Error("request missing");
|
||||
const request = requestSchema.parse(
|
||||
JSON.parse(await readFile(requestPath, "utf8")),
|
||||
);
|
||||
await writeFile(
|
||||
request.reply_path,
|
||||
JSON.stringify({
|
||||
ok: false,
|
||||
reason: "format",
|
||||
message: "Odd selected hex.",
|
||||
}),
|
||||
);
|
||||
return spawnOwnedProviderProcess({
|
||||
...spawn,
|
||||
command: process.execPath,
|
||||
arguments: ["-e", "process.exit(0)"],
|
||||
});
|
||||
},
|
||||
() =>
|
||||
Promise.resolve({
|
||||
path: root,
|
||||
close: () => Promise.reject(new Error("root cleanup failed")),
|
||||
}),
|
||||
);
|
||||
expect(await provider.inspect({ path, encoding: "hex" })).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
cleanupIncomplete: true,
|
||||
cleanupResources: [root],
|
||||
diagnostics: {
|
||||
reason: "root cleanup failed",
|
||||
previous_error: { category: "invalid_input" },
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.runIf(!unsupportedHost).each(["late-cancellation", "cleanup-failure"])(
|
||||
"retains captured warnings after a successful worker: %s",
|
||||
async (scenario) => {
|
||||
const { path } = await fixture();
|
||||
const controller = new AbortController();
|
||||
let ownedRoot = "";
|
||||
const provider = new EvmoleInterfaceProvider(
|
||||
{},
|
||||
async (spawn) => {
|
||||
const requestPath = spawn.arguments.at(-1);
|
||||
if (requestPath === undefined) throw new Error("request missing");
|
||||
const request = requestSchema.parse(
|
||||
JSON.parse(await readFile(requestPath, "utf8")),
|
||||
);
|
||||
const bytes = Buffer.from("60006000f3", "hex");
|
||||
await writeFile(
|
||||
request.reply_path,
|
||||
JSON.stringify({
|
||||
ok: true,
|
||||
version: "0.9.3",
|
||||
bytecode: {
|
||||
sha256: createHash("sha256").update(bytes).digest("hex"),
|
||||
bytes: bytes.length,
|
||||
hex: bytes.toString("hex"),
|
||||
},
|
||||
raw: { functions: [] },
|
||||
}),
|
||||
);
|
||||
return spawnOwnedProviderProcess({
|
||||
...spawn,
|
||||
command: process.execPath,
|
||||
arguments: ["-e", 'process.stderr.write("upstream warning")'],
|
||||
});
|
||||
},
|
||||
async () => {
|
||||
const root = await PrivateRuntimeRoot.create({
|
||||
prefix: "rea-evm-late-",
|
||||
});
|
||||
ownedRoot = root.path;
|
||||
return {
|
||||
path: root.path,
|
||||
close: async () => {
|
||||
await root.close();
|
||||
if (scenario === "late-cancellation") controller.abort();
|
||||
else throw new Error("reported cleanup failure");
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
const result = await provider.inspect(
|
||||
{ path, encoding: "hex" },
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
if (result.ok) throw new Error("Expected lifecycle failure");
|
||||
const output = { stdout: "", stderr: "upstream warning", truncated: false };
|
||||
expect(projectAnalysisError(result.error)).toMatchObject({
|
||||
category:
|
||||
scenario === "late-cancellation" ? "cancelled" : "execution_failure",
|
||||
details:
|
||||
scenario === "late-cancellation"
|
||||
? { captured_output: output }
|
||||
: { diagnostics: { captured_output: output } },
|
||||
});
|
||||
await expect(access(ownedRoot)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
},
|
||||
);
|
||||
|
||||
it.runIf(!unsupportedHost).each(["directory", "missing-interpreter"])(
|
||||
"identifies configured limiter constraints without blaming the carrier: %s",
|
||||
async (scenario) => {
|
||||
const { path, root } = await fixture();
|
||||
const limiter =
|
||||
scenario === "directory" ? root : join(root, "broken-limiter");
|
||||
if (scenario === "missing-interpreter")
|
||||
await writeFile(limiter, "#!/rea-missing-limiter-interpreter\n", {
|
||||
mode: 0o700,
|
||||
});
|
||||
let ownedRoot: string | undefined;
|
||||
const provider = new EvmoleInterfaceProvider(
|
||||
{ REA_EVM_PRLIMIT_COMMAND: limiter },
|
||||
undefined,
|
||||
async () => {
|
||||
const runtime = await PrivateRuntimeRoot.create({
|
||||
prefix: "rea-evm-limiter-",
|
||||
});
|
||||
ownedRoot = runtime.path;
|
||||
return runtime;
|
||||
},
|
||||
);
|
||||
const result = await provider.inspect({ path, encoding: "hex" });
|
||||
if (result.ok) throw new Error("Expected unavailable limiter");
|
||||
expect(projectAnalysisError(result.error)).toMatchObject({
|
||||
code: "capability_unavailable",
|
||||
category: "unsupported_provider",
|
||||
details: { provider_id: "evmole-interface" },
|
||||
});
|
||||
expect(result.error.message).toContain(limiter);
|
||||
if (ownedRoot !== undefined)
|
||||
await expect(access(ownedRoot)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
expect(await readFile(path, "utf8")).toBe("0x60006000f3\n");
|
||||
},
|
||||
);
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
||||
import { Ajv2020 } from "ajv/dist/2020.js";
|
||||
import { expect, it, onTestFinished } from "vitest";
|
||||
import { EvmInterfaceService } from "../../../src/application/evm/EvmInterfaceService.js";
|
||||
import { toolContract } from "../../../src/contracts/toolContracts.js";
|
||||
import { parseEvidence } from "../../../src/domain/evidence.js";
|
||||
import { ok } from "../../../src/domain/result.js";
|
||||
import { createServer } from "../../../src/server/createServer.js";
|
||||
import { createTestBinarySession } from "../../fixtures/binarySession.js";
|
||||
import {
|
||||
evmInterfaceFixture,
|
||||
evmInterfaceExecution,
|
||||
} from "../../fixtures/evm/interface.js";
|
||||
|
||||
it("advertises exact valid schemas and records inline EVM interface evidence without a binary target", async () => {
|
||||
const value = evmInterfaceFixture();
|
||||
const service = new EvmInterfaceService({
|
||||
inspect: () => Promise.resolve(ok(evmInterfaceExecution(value))),
|
||||
});
|
||||
const session = createTestBinarySession(() => {
|
||||
throw new Error("deep provider must not start");
|
||||
});
|
||||
const server = createServer(session, session, { evmInterface: service });
|
||||
const client = new Client({ name: "evm-interface-contract", version: "1" });
|
||||
onTestFinished(async () => {
|
||||
await client.close();
|
||||
await server.close();
|
||||
await session.close();
|
||||
});
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
const advertised = (await client.listTools()).tools.find(
|
||||
(tool) => tool.name === "inspect_evm_interface",
|
||||
);
|
||||
if (advertised?.outputSchema === undefined)
|
||||
throw new Error("EVM interface inspection must publish both schemas");
|
||||
const ajv = new Ajv2020({ strict: false, validateFormats: false });
|
||||
const inputSchema: Record<string, unknown> = advertised.inputSchema;
|
||||
const outputSchema: Record<string, unknown> = advertised.outputSchema;
|
||||
expect(ajv.validateSchema(inputSchema)).toBe(true);
|
||||
expect(ajv.validateSchema(outputSchema)).toBe(true);
|
||||
expect(
|
||||
ajv.validate(inputSchema, { path: value.artifact.path, encoding: "hex" }),
|
||||
).toBe(true);
|
||||
expect(
|
||||
ajv.validate(inputSchema, {
|
||||
path: value.artifact.path,
|
||||
encoding: "hex",
|
||||
approval: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
const response = await client.callTool({
|
||||
name: "inspect_evm_interface",
|
||||
arguments: { path: value.artifact.path, encoding: "hex" },
|
||||
});
|
||||
expect(response.isError).not.toBe(true);
|
||||
expect(
|
||||
ajv.validate(outputSchema, response.structuredContent),
|
||||
JSON.stringify(ajv.errors),
|
||||
).toBe(true);
|
||||
const parsed = toolContract("inspect_evm_interface").outputSchema.parse(
|
||||
response.structuredContent,
|
||||
);
|
||||
const evidence = parseEvidence(parsed.evidence);
|
||||
expect(parsed.result).toEqual(value);
|
||||
expect(parsed.result).toEqual(evidence.normalized_result);
|
||||
expect(session.evidenceById(evidence.evidence_id)).toEqual(evidence);
|
||||
expect(
|
||||
(
|
||||
await client.callTool({
|
||||
name: "inspect_evm_interface",
|
||||
arguments: { path: "relative.hex", encoding: "hex" },
|
||||
})
|
||||
).isError,
|
||||
).toBe(true);
|
||||
});
|
||||
|
|
@ -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 }],
|
||||
},
|
||||
);
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue