mirror of
https://github.com/morluto/rea.git
synced 2026-10-11 21:39:55 +00:00
feat: add evidence-backed process investigations
This commit is contained in:
@@ -14,7 +14,7 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run typecheck
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run lint
|
||||
@@ -38,7 +38,7 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run format:check
|
||||
@@ -50,7 +50,7 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run lint:dead
|
||||
@@ -62,7 +62,7 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run jscpd
|
||||
@@ -74,7 +74,7 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run scan:todos
|
||||
@@ -86,9 +86,10 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run rebuild:native
|
||||
- run: npm run test:coverage -- --reporter=verbose --reporter=junit --outputFile.junit=test-results.xml
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
@@ -104,7 +105,7 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run docs:generate
|
||||
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
node-version-file: .nvmrc
|
||||
package-manager-cache: false
|
||||
registry-url: https://registry.npmjs.org
|
||||
|
||||
|
||||
+2
-1
@@ -4,10 +4,11 @@ REA welcomes focused bug fixes, documentation improvements, tests, and reverse-e
|
||||
|
||||
## Development setup
|
||||
|
||||
REA requires macOS 12 or newer, Node.js 22 or newer, and a separately installed Hopper Disassembler application for real-Hopper verification.
|
||||
REA requires macOS 12 or newer, Node.js 24.18.x, npm 11.16.x, and a separately installed Hopper Disassembler application for real-Hopper verification. Run `nvm use` before installing dependencies.
|
||||
|
||||
```bash
|
||||
npm ci
|
||||
npm run rebuild:native # only when the packaged PTY binary is incompatible
|
||||
npm run build
|
||||
npm test
|
||||
```
|
||||
|
||||
@@ -6,15 +6,15 @@
|
||||
|
||||
### One CLI and MCP server for coding agents to reverse engineer anything
|
||||
|
||||
**See a feature you like. Understand how it works. Build it your way.**
|
||||
**See a feature you like. Understand how it works, down to the binary level.**
|
||||
|
||||
[](https://www.npmjs.com/package/@morluto/rea)
|
||||
[](https://github.com/morluto/rea/actions/workflows/ci.yml)
|
||||
[](#46-tools-for-deeper-investigation)
|
||||
[](https://nodejs.org/)
|
||||
[](#50-tools-for-investigation)
|
||||
[](https://nodejs.org/)
|
||||
[](LICENSE)
|
||||
|
||||
[Quick start](#quick-start) · [See the workflow](#one-prompt-a-full-investigation) · [From app to feature](#from-app-to-feature) · [46 tools](#46-tools-for-deeper-investigation) · [How it works](#how-it-works) · [FAQ](#faq)
|
||||
[Quick start](#quick-start) · [Current status](#current-status) · [Investigation model](#the-investigation-model) · [50 tools](#50-tools-for-investigation) · [Roadmap](#roadmap) · [How it works](#how-it-works)
|
||||
|
||||
<br />
|
||||
|
||||
@@ -24,9 +24,11 @@
|
||||
|
||||
---
|
||||
|
||||
See a feature in an app that you want in your own product? Give the app to your coding agent—even without its source code. With REA, the agent can investigate the feature, understand how it works, and build a version adapted to your stack, design, and requirements.
|
||||
See a feature in an app that you want in your own product? Give the app to your coding agent—even without its source code. With REA, the agent can investigate the feature, explain how it works, show its evidence, and build a version adapted to your stack and requirements.
|
||||
|
||||
REA makes that possible through one CLI and MCP server. Your agent can inspect the compiled app, follow how features work, and use what it learns in its normal coding workflow. REA handles the reverse-engineering tools behind one interface.
|
||||
REA gives agents one consistent way to investigate software. Today that includes deep native analysis through Hopper, complete function dossiers, reproducible Evidence v2 records, and controlled process capture. The longer-term toolkit extends the same agent workflow to packaged apps, JavaScript bundles, websites, APIs, protocols, mobile artifacts, firmware, runtime behavior, and differences between versions.
|
||||
|
||||
Reverse engineering normally makes the operator choose a tool, learn its API, move evidence between programs, and decide what to inspect next. REA gives that work to the agent through commands, skills, structured results, and repeatable investigation workflows.
|
||||
|
||||
## Just ask your agent
|
||||
|
||||
@@ -45,7 +47,7 @@ show me how you know, and build a similar feature for my project.
|
||||
|
||||
Notes is only an example. Name any app you want to understand, or ask the agent to start with an overview.
|
||||
|
||||
## From app to feature
|
||||
## The investigation model
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
@@ -98,7 +100,9 @@ If macOS or an installer asks for confirmation, complete the prompt and run the
|
||||
### What setup handles
|
||||
|
||||
- macOS 12 or newer
|
||||
- Node.js 22 or newer
|
||||
- Node.js 24.18.x with npm 11.16.x (`nvm use` selects the pinned version)
|
||||
|
||||
If process capture reports that its native PTY backend is unavailable, install Xcode command-line tools and run `npm run rebuild:native`. Linux source builds require Python, `make`, and a C++ toolchain. Compatible packaged binaries do not require this rebuild.
|
||||
|
||||
You do not need to install the reverse-engineering tools manually. Setup installs Homebrew and [Hopper](https://www.hopperapp.com/) when needed, configures detected Claude Desktop and Cursor installations, and installs the REA skill. Hopper is separate software and requires its own license; setup installs it but does not provide a license.
|
||||
|
||||
@@ -147,13 +151,43 @@ REA handles the app analysis in steps 1–5. The agent performs step 6 with its
|
||||
- Analyze Swift and Objective-C metadata without manually untangling every mangled symbol.
|
||||
- Leave names, comments, and bookmarks in Hopper so human and agent analysis reinforce each other.
|
||||
|
||||
## 46 tools for deeper investigation
|
||||
## 50 tools for investigation
|
||||
|
||||
| Tool family | Count | Examples |
|
||||
| ----------------- | ----: | -------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Binary inspection | 33 | procedures, pseudocode, assembly, strings, names, segments, callers, callees, xrefs, annotations |
|
||||
| Composed analysis | 10 | `binary_overview`, `analyze_function`, `batch_decompile`, `get_call_graph`, `find_xrefs_to_name`, Swift and ObjC discovery |
|
||||
| Binary session | 3 | `open_binary`, `binary_session`, `close_binary` |
|
||||
| Tool family | Count | Examples |
|
||||
| ------------------------- | ----: | ----------------------------------------------------------------------------------------------------------------------- |
|
||||
| Native inspection | 33 | procedures, pseudocode, assembly, strings, names, segments, callers, callees, xrefs, annotations |
|
||||
| Investigation workflows | 10 | `binary_overview`, `analyze_function`, `batch_decompile`, `trace_feature`, call graphs, Swift and Objective-C discovery |
|
||||
| Workspace and observation | 7 | target lifecycle, Evidence v2 bundle import/export, deterministic process capture and comparison |
|
||||
|
||||
The public interface describes what the agent is trying to learn. Providers decide how to answer. Hopper currently implements the native-analysis capabilities; the process harness implements controlled behavioral capture. Future providers can satisfy the same capability without changing the agent's investigation workflow.
|
||||
|
||||
## Current status
|
||||
|
||||
REA is already useful for native application investigation on macOS:
|
||||
|
||||
- Open Mach-O, ELF, PE, `.app`, and Hopper database targets.
|
||||
- Build bounded function dossiers with pseudocode, assembly, CFG edges, comments, calls, references, strings, and names.
|
||||
- Search and trace features across symbols, strings, metadata, references, and call paths.
|
||||
- Record every successful result as deterministic Evidence v2 with artifact and provider identity, confidence, authority, limitations, and locations.
|
||||
- Export and import evidence bundles across sessions.
|
||||
- Capture approved PTY scenarios, child processes, filesystem changes, and loopback HTTP/WebSocket exchanges, then compare normalized captures.
|
||||
|
||||
Hopper is the first provider, not the boundary of the project. Some current workflows still require Hopper and macOS; every evidence record identifies the provider and limitations behind its result.
|
||||
|
||||
## Roadmap
|
||||
|
||||
REA is growing into a toolkit for understanding software across static artifacts and observed behavior. The next capability families are:
|
||||
|
||||
1. **Artifact decomposition** — DMG, ASAR, ZIP, packages, universal-binary slices, application resources, embedded frameworks, mobile packages, and artifact graphs.
|
||||
2. **Web and Electron investigation** — Playwright/CDP capture of DOM, accessibility trees, screenshots, storage, console, IPC, HTTP, WebSocket, routes, and visual or structural differences.
|
||||
3. **Deterministic behavior harnesses** — stronger process-tree ownership, protocol fixtures, network policy, filesystem tracing, signals, reconnects, and cross-version comparison.
|
||||
4. **JavaScript and source recovery** — bundle indexing, AST/module reconstruction, source-map discovery, historical-source matching, and CodeDB-backed cross-references.
|
||||
5. **Runtime observation** — approval-gated LLDB, Frida, system logs, process and filesystem observers, and native API tracing.
|
||||
6. **More static-analysis providers** — native platform utilities first, followed by Ghidra, IDA/Hex-Rays, Binary Ninja, Rizin, LIEF, and other engines behind provider-neutral capabilities.
|
||||
7. **More targets and platforms** — Windows-native providers and ConPTY verification, Linux parity, websites and APIs, mobile artifacts, firmware, document formats, and other software-defined systems.
|
||||
8. **Differential reconstruction** — compare artifacts, functions, bundles, protocols, UIs, and process captures; track residual unknowns; verify a reconstruction against observed behavior.
|
||||
|
||||
Roadmap items describe direction, not shipped support. New providers must produce the same evidence and safety metadata as existing capabilities before they become part of the public workflow.
|
||||
|
||||
## Using REA with other coding agents
|
||||
|
||||
@@ -178,11 +212,16 @@ Setup currently configures Claude Desktop and Cursor automatically. Any coding a
|
||||
flowchart LR
|
||||
Agent["Coding agent"] --> REA["REA<br/>CLI + MCP"]
|
||||
Terminal --> REA
|
||||
REA --> Hopper["Analysis engine"]
|
||||
Hopper --> App["Your app"]
|
||||
REA --> Workspace["Investigation workspace<br/>evidence + artifacts + captures"]
|
||||
Workspace --> Router["Capability router"]
|
||||
Router --> Hopper["Hopper provider"]
|
||||
Router --> Process["Process capture provider"]
|
||||
Router -. roadmap .-> More["Artifact, browser, dynamic,<br/>and additional static providers"]
|
||||
Hopper --> Target["Target software"]
|
||||
Process --> Target
|
||||
```
|
||||
|
||||
The CLI and MCP server use the same analysis engine. Terminal commands close the app when they finish; an agent session keeps it open while the investigation continues.
|
||||
The CLI and MCP server use the same application workflows and evidence contracts. A provider declares which capabilities it supports and the side effects those capabilities may have. Terminal commands are short-lived; an MCP session can retain an active target and evidence ledger across an investigation.
|
||||
|
||||
## CLI
|
||||
|
||||
@@ -204,7 +243,7 @@ rea mcp
|
||||
|
||||
REA accepts a Mac `.app` folder directly. If an agent cannot find an app by name, tell it where the app is installed.
|
||||
|
||||
## Hopper application behavior
|
||||
## Current Hopper provider
|
||||
|
||||
REA starts Hopper when needed; Hopper does not need to be running first. Hopper's launcher internally activates the application, so opening a target may bring Hopper to the foreground. REA asks macOS to start Hopper hidden and in the background when possible, but cannot guarantee that it will remain behind the current application.
|
||||
|
||||
@@ -214,7 +253,7 @@ Closing a REA session shuts down its bridge and removes its private socket direc
|
||||
|
||||
## Security model
|
||||
|
||||
REA communicates with Hopper over a private local connection and does not provide a hosted analysis service. This is not a sandbox: Hopper opens apps with your current macOS permissions. Report vulnerabilities through the private process in [SECURITY.md](SECURITY.md).
|
||||
REA does not provide a hosted analysis service. Hopper communication uses an authenticated private local socket. Dynamic capabilities are disabled by default and require both operator policy and explicit per-call approval. REA is not a security sandbox: providers and launched targets run with the current user's permissions, and each capability reports its side effects and limitations. Report vulnerabilities through the private process in [SECURITY.md](SECURITY.md).
|
||||
|
||||
## FAQ
|
||||
|
||||
@@ -242,7 +281,7 @@ No. Setup can install Hopper for you, but Hopper remains separate software with
|
||||
<details>
|
||||
<summary><strong>Does REA upload the app?</strong></summary>
|
||||
|
||||
REA has no hosted analysis service. It passes local operations to Hopper through a current-user Unix socket. Your coding agent or model provider may have its own data policy, so review that separately.
|
||||
REA has no hosted analysis service. Current providers analyze artifacts and capture behavior locally. Your coding agent or model provider may have its own data policy, so review that separately.
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
+6
-6
@@ -10,11 +10,11 @@
|
||||
|
||||
[](https://www.npmjs.com/package/@morluto/rea)
|
||||
[](https://github.com/morluto/rea/actions/workflows/ci.yml)
|
||||
[](#منصة-من-46-أداة)
|
||||
[](https://nodejs.org/)
|
||||
[](#منصة-من-50-أداة)
|
||||
[](https://nodejs.org/)
|
||||
[](LICENSE)
|
||||
|
||||
[البدء السريع](#البدء-السريع) · [من الملف التنفيذي إلى السلوك](#من-الملف-التنفيذي-إلى-السلوك) · [46 أداة](#منصة-من-46-أداة) · [كيف يعمل](#كيف-يعمل) · [الأسئلة الشائعة](#الأسئلة-الشائعة)
|
||||
[البدء السريع](#البدء-السريع) · [من الملف التنفيذي إلى السلوك](#من-الملف-التنفيذي-إلى-السلوك) · [50 أداة](#منصة-من-50-أداة) · [كيف يعمل](#كيف-يعمل) · [الأسئلة الشائعة](#الأسئلة-الشائعة)
|
||||
|
||||
<br />
|
||||
|
||||
@@ -73,7 +73,7 @@ npx skills add morluto/rea
|
||||
### قبل البدء
|
||||
|
||||
- macOS 12 أو أحدث
|
||||
- Node.js 22 أو أحدث
|
||||
- Node.js 24.18.x وnpm 11.16.x (شغّل `nvm use` لاختيار الإصدار المثبّت)
|
||||
|
||||
لا تحتاج إلى تثبيت أدوات الهندسة العكسية يدويًا. يثبت Setup برنامج Homebrew و[Hopper](https://www.hopperapp.com/) عند الحاجة، ثم يهيئ وكلاء البرمجة المدعومين. Hopper برنامج منفصل يحتاج إلى ترخيص خاص به؛ يثبته Setup لكنه لا يوفر الترخيص.
|
||||
|
||||
@@ -145,13 +145,13 @@ npx -y @morluto/rea doctor
|
||||
|
||||
افحص التحقق من الإدخال، والتشفير، والصلاحيات، والتخزين، ومسارات الأخطاء من دون رفع الملف التنفيذي إلى خدمة بعيدة.
|
||||
|
||||
## منصة من 46 أداة
|
||||
## منصة من 50 أداة
|
||||
|
||||
| عائلة الأدوات | العدد | أمثلة |
|
||||
| --------------------- | ----: | ----------------------------------------------------------------------------------- |
|
||||
| فحص الملفات التنفيذية | 33 | الدوال، والشيفرة شبه المصدرية، والتعليمات، والسلاسل، والأسماء، والمراجع، والتعليقات |
|
||||
| التحليل المركب | 10 | النظرة العامة، وفك الترجمة الدفعي، ومخططات الاستدعاء، والمراجع، واكتشاف الأنواع |
|
||||
| جلسة الملف التنفيذي | 3 | `open_binary` و`binary_session` و`close_binary` |
|
||||
| جلسة الملف التنفيذي | 7 | فتح الأهداف وحزم الأدلة والتقاط العمليات ومقارنتها |
|
||||
|
||||
## استخدام REA مع وكلاء برمجة آخرين
|
||||
|
||||
|
||||
+6
-6
@@ -10,11 +10,11 @@
|
||||
|
||||
[](https://www.npmjs.com/package/@morluto/rea)
|
||||
[](https://github.com/morluto/rea/actions/workflows/ci.yml)
|
||||
[](#46-ツールのワークベンチ)
|
||||
[](https://nodejs.org/)
|
||||
[](#50-ツールのワークベンチ)
|
||||
[](https://nodejs.org/)
|
||||
[](LICENSE)
|
||||
|
||||
[クイックスタート](#クイックスタート) · [バイナリから動作へ](#バイナリから動作へ) · [46 ツール](#46-ツールのワークベンチ) · [仕組み](#仕組み) · [FAQ](#faq)
|
||||
[クイックスタート](#クイックスタート) · [バイナリから動作へ](#バイナリから動作へ) · [50 ツール](#50-ツールのワークベンチ) · [仕組み](#仕組み) · [FAQ](#faq)
|
||||
|
||||
<br />
|
||||
|
||||
@@ -75,7 +75,7 @@ npx skills add morluto/rea
|
||||
### 始める前に
|
||||
|
||||
- macOS 12 以降
|
||||
- Node.js 22 以降
|
||||
- Node.js 24.18.x と npm 11.16.x(`nvm use` で固定バージョンを選択)
|
||||
|
||||
リバースエンジニアリングツールを手動でインストールする必要はありません。Setup は必要に応じて Homebrew と [Hopper](https://www.hopperapp.com/) をインストールし、対応するコーディングエージェントを設定します。Hopper は別製品で、ライセンスは別途必要です。Setup はインストールしますが、ライセンスは提供しません。
|
||||
|
||||
@@ -135,13 +135,13 @@ REA は手順 1〜5 のバイナリ解析を処理し、手順 6 はエージェ
|
||||
- Swift / Objective-C のメタデータを解析する。
|
||||
- Hopper に名前、コメント、ブックマークを残し、人間とエージェントの調査を共有する。
|
||||
|
||||
## 46 ツールのワークベンチ
|
||||
## 50 ツールのワークベンチ
|
||||
|
||||
| ツール群 | 数 | 例 |
|
||||
| ------------------ | --: | ------------------------------------------------------------------------------------------------------------------- |
|
||||
| バイナリ調査 | 33 | プロシージャ、疑似コード、アセンブリ、文字列、名前、セグメント、callers、callees、xrefs、注釈 |
|
||||
| 合成解析 | 10 | `binary_overview`, `analyze_function`, `batch_decompile`, `get_call_graph`, `find_xrefs_to_name`, Swift / ObjC 検出 |
|
||||
| バイナリセッション | 3 | `open_binary`, `binary_session`, `close_binary` |
|
||||
| バイナリセッション | 7 | `open_binary`、`binary_session`、証拠バンドル、プロセス取得と比較 |
|
||||
|
||||
## 他のコーディングエージェントで使う
|
||||
|
||||
|
||||
+6
-6
@@ -10,11 +10,11 @@
|
||||
|
||||
[](https://www.npmjs.com/package/@morluto/rea)
|
||||
[](https://github.com/morluto/rea/actions/workflows/ci.yml)
|
||||
[](#46개-도구로-구성된-워크벤치)
|
||||
[](https://nodejs.org/)
|
||||
[](#50개-도구로-구성된-워크벤치)
|
||||
[](https://nodejs.org/)
|
||||
[](LICENSE)
|
||||
|
||||
[빠른 시작](#빠른-시작) · [바이너리에서 동작까지](#바이너리에서-동작까지) · [46개 도구](#46개-도구로-구성된-워크벤치) · [작동 방식](#작동-방식) · [FAQ](#faq)
|
||||
[빠른 시작](#빠른-시작) · [바이너리에서 동작까지](#바이너리에서-동작까지) · [50개 도구](#50개-도구로-구성된-워크벤치) · [작동 방식](#작동-방식) · [FAQ](#faq)
|
||||
|
||||
<br />
|
||||
|
||||
@@ -75,7 +75,7 @@ npx skills add morluto/rea
|
||||
### 시작하기 전에
|
||||
|
||||
- macOS 12 이상
|
||||
- Node.js 22 이상
|
||||
- Node.js 24.18.x 및 npm 11.16.x (`nvm use`로 고정 버전 선택)
|
||||
|
||||
리버스 엔지니어링 도구를 직접 설치할 필요는 없습니다. Setup은 필요할 때 Homebrew와 [Hopper](https://www.hopperapp.com/)를 설치하고 지원되는 코딩 에이전트를 구성합니다. Hopper는 별도 소프트웨어이며 별도 라이선스가 필요합니다. Setup은 Hopper를 설치하지만 라이선스를 제공하지 않습니다.
|
||||
|
||||
@@ -135,13 +135,13 @@ REA는 1–5단계의 바이너리 분석을 처리합니다. 6단계는 에이
|
||||
- Swift 및 Objective-C 메타데이터를 분석합니다.
|
||||
- Hopper에 이름, 주석, 북마크를 남겨 사람과 에이전트의 분석을 연결합니다.
|
||||
|
||||
## 46개 도구로 구성된 워크벤치
|
||||
## 50개 도구로 구성된 워크벤치
|
||||
|
||||
| 도구 그룹 | 수 | 예시 |
|
||||
| ------------- | --: | -------------------------------------------------------------------------------------------------------------------- |
|
||||
| 바이너리 검사 | 33 | 프로시저, 의사 코드, 어셈블리, 문자열, 이름, 세그먼트, callers, callees, xrefs, 주석 |
|
||||
| 합성 분석 | 10 | `binary_overview`, `analyze_function`, `batch_decompile`, `get_call_graph`, `find_xrefs_to_name`, Swift 및 ObjC 탐색 |
|
||||
| 바이너리 세션 | 3 | `open_binary`, `binary_session`, `close_binary` |
|
||||
| 바이너리 세션 | 7 | `open_binary`, `binary_session`, 증거 번들, 프로세스 캡처 및 비교 |
|
||||
|
||||
## 다른 코딩 에이전트에서 사용하기
|
||||
|
||||
|
||||
+6
-6
@@ -10,11 +10,11 @@
|
||||
|
||||
[](https://www.npmjs.com/package/@morluto/rea)
|
||||
[](https://github.com/morluto/rea/actions/workflows/ci.yml)
|
||||
[](#46-个工具组成的工作台)
|
||||
[](https://nodejs.org/)
|
||||
[](#50-个工具组成的工作台)
|
||||
[](https://nodejs.org/)
|
||||
[](LICENSE)
|
||||
|
||||
[快速开始](#快速开始) · [从二进制到行为](#从二进制到行为) · [46 个工具](#46-个工具组成的工作台) · [工作原理](#工作原理) · [常见问题](#常见问题)
|
||||
[快速开始](#快速开始) · [从二进制到行为](#从二进制到行为) · [50 个工具](#50-个工具组成的工作台) · [工作原理](#工作原理) · [常见问题](#常见问题)
|
||||
|
||||
<br />
|
||||
|
||||
@@ -75,7 +75,7 @@ npx skills add morluto/rea
|
||||
### 开始之前
|
||||
|
||||
- macOS 12 或更高版本
|
||||
- Node.js 22 或更高版本
|
||||
- Node.js 24.18.x 和 npm 11.16.x(运行 `nvm use` 选择固定版本)
|
||||
|
||||
你不需要手动安装逆向工程工具。Setup 会在需要时安装 Homebrew 和 [Hopper](https://www.hopperapp.com/),然后配置支持的编程智能体。Hopper 是独立软件,需要单独授权;Setup 可以安装它,但不提供许可证。
|
||||
|
||||
@@ -135,13 +135,13 @@ REA 负责第 1–5 步中的二进制分析。第 6 步由智能体使用其常
|
||||
- 分析 Swift 和 Objective-C 元数据。
|
||||
- 在 Hopper 中留下名称、注释与书签,使人与智能体的分析互相增强。
|
||||
|
||||
## 46 个工具组成的工作台
|
||||
## 50 个工具组成的工作台
|
||||
|
||||
| 工具类别 | 数量 | 示例 |
|
||||
| ---------- | ---: | -------------------------------------------------------------------------------------------------------------------- |
|
||||
| 二进制检查 | 33 | 过程、伪代码、汇编、字符串、名称、段、调用者、被调用者、交叉引用、注释 |
|
||||
| 组合分析 | 10 | `binary_overview`, `analyze_function`, `batch_decompile`, `get_call_graph`, `find_xrefs_to_name`、Swift 与 ObjC 发现 |
|
||||
| 二进制会话 | 3 | `open_binary`, `binary_session`, `close_binary` |
|
||||
| 二进制会话 | 7 | `open_binary`、`binary_session`、证据包、进程捕获与比较 |
|
||||
|
||||
## 与其他编程智能体一起使用
|
||||
|
||||
|
||||
+112
-32
@@ -208,6 +208,46 @@ def _unavailable(reason):
|
||||
return {"available": False, "reason": reason}
|
||||
|
||||
|
||||
def _offset(params, name):
|
||||
value = params.get(name, 0)
|
||||
if not isinstance(value, int) or isinstance(value, bool) or value < 0:
|
||||
raise ValueError("%s must be a non-negative integer" % name)
|
||||
return value
|
||||
|
||||
|
||||
def _collection_offset(params, name):
|
||||
values = params.get("collection_offset", {})
|
||||
if not isinstance(values, dict):
|
||||
raise ValueError("collection_offset must be an object")
|
||||
value = values.get(name, 0)
|
||||
if not isinstance(value, int) or isinstance(value, bool) or value < 0:
|
||||
raise ValueError("collection_offset.%s must be a non-negative integer" % name)
|
||||
return value
|
||||
|
||||
|
||||
def _bounded(items, offset, limit, total=None, scan_truncated=False):
|
||||
selected = items[offset:offset + limit]
|
||||
known_total = len(items) if total is None and not scan_truncated else total
|
||||
has_more = offset + len(selected) < len(items)
|
||||
return {
|
||||
"items": selected,
|
||||
"total": known_total,
|
||||
"returned": len(selected),
|
||||
"truncated": scan_truncated or has_more,
|
||||
"next_offset": offset + len(selected) if has_more else None,
|
||||
}
|
||||
|
||||
|
||||
def _name_map(document):
|
||||
result = {}
|
||||
for segment in document.getSegmentsList():
|
||||
for address in segment.getNamedAddresses():
|
||||
name = segment.getNameAtAddress(address)
|
||||
if name is not None:
|
||||
result[address] = name
|
||||
return result
|
||||
|
||||
|
||||
def _assembly(procedure, limit=None):
|
||||
"""Render bounded assembly while guarding against malformed instruction cycles."""
|
||||
lines = []
|
||||
@@ -239,52 +279,92 @@ def _analyze_function(document, params):
|
||||
limit = params.get("limit", 100)
|
||||
max_chars = params.get("max_pseudocode_chars", 20000)
|
||||
max_instructions = params.get("max_instructions", 500)
|
||||
if not isinstance(limit, int) or limit < 1 or limit > 500:
|
||||
pseudocode_offset = _offset(params, "pseudocode_offset")
|
||||
assembly_offset = _offset(params, "assembly_offset")
|
||||
if not isinstance(limit, int) or isinstance(limit, bool) or limit < 1 or limit > 500:
|
||||
raise ValueError("limit must be an integer between 1 and 500")
|
||||
if not isinstance(max_chars, int) or max_chars < 1 or max_chars > 100000:
|
||||
if not isinstance(max_chars, int) or isinstance(max_chars, bool) or max_chars < 1 or max_chars > 100000:
|
||||
raise ValueError("max_pseudocode_chars must be between 1 and 100000")
|
||||
if not isinstance(max_instructions, int) or max_instructions < 1 or max_instructions > 5000:
|
||||
if not isinstance(max_instructions, int) or isinstance(max_instructions, bool) or max_instructions < 1 or max_instructions > 5000:
|
||||
raise ValueError("max_instructions must be between 1 and 5000")
|
||||
addresses, instruction_scan_truncated = _instruction_addresses(procedure, max_instructions)
|
||||
blocks = []
|
||||
all_blocks = list(procedure.basicBlockIterator())
|
||||
for block in all_blocks[:limit]:
|
||||
for block in all_blocks:
|
||||
successors = []
|
||||
for index in range(block.getSuccessorCount()):
|
||||
successor = block.getSuccessorAddressAtIndex(index)
|
||||
if successor not in BAD_ADDRESSES:
|
||||
successors.append(_hex(successor))
|
||||
blocks.append({
|
||||
"start": _hex(block.getStartingAddress()),
|
||||
"end": _hex(block.getEndingAddress()),
|
||||
"successors": _unavailable(
|
||||
"Hopper's public Python API does not expose CFG successor edges"
|
||||
),
|
||||
"successors": sorted(set(successors), key=lambda value: int(value, 16)),
|
||||
})
|
||||
pseudo = procedure.decompile() or ""
|
||||
assembly_sample = _assembly(procedure, max_instructions + 1).splitlines() if params.get("include_assembly", False) else []
|
||||
assembly_truncated = len(assembly_sample) > max_instructions
|
||||
assembly = assembly_sample[:max_instructions]
|
||||
callers = [_procedure_name(item) for item in procedure.getAllCallerProcedures()]
|
||||
callees = [_procedure_name(item) for item in procedure.getAllCalleeProcedures()]
|
||||
def bounded(items):
|
||||
return {"items": items[:limit], "total": len(items), "returned": min(len(items), limit), "truncated": len(items) > limit, "next_offset": limit if len(items) > limit else None}
|
||||
entry = procedure.getEntryPoint()
|
||||
segment = procedure.getSegment()
|
||||
assembly_lines = _assembly(procedure).splitlines() if params.get("include_assembly", False) else []
|
||||
callers = sorted((_procedure_identity(item) for item in procedure.getAllCallerProcedures()), key=lambda item: int(item["address"], 16))
|
||||
callees = sorted((_procedure_identity(item) for item in procedure.getAllCalleeProcedures()), key=lambda item: int(item["address"], 16))
|
||||
comments = []
|
||||
comment = segment.getCommentAtAddress(entry)
|
||||
inline_comment = segment.getInlineCommentAtAddress(entry)
|
||||
if comment:
|
||||
comments.append({"address": _hex(entry), "kind": "comment", "text": comment})
|
||||
if inline_comment:
|
||||
comments.append({"address": _hex(entry), "kind": "inline", "text": inline_comment})
|
||||
edges = set()
|
||||
for address in addresses:
|
||||
segment = _segment(document, address)
|
||||
comment = segment.getCommentAtAddress(address)
|
||||
inline_comment = segment.getInlineCommentAtAddress(address)
|
||||
if comment:
|
||||
comments.append({"address": _hex(address), "kind": "comment", "text": comment})
|
||||
if inline_comment:
|
||||
comments.append({"address": _hex(address), "kind": "inline", "text": inline_comment})
|
||||
for target in segment.getReferencesFromAddress(address):
|
||||
edges.add((address, target))
|
||||
for source in segment.getReferencesOfAddress(address):
|
||||
edges.add((source, address))
|
||||
incoming = []
|
||||
for candidate in document.getSegmentsList():
|
||||
incoming.extend(_hex(value) for value in candidate.getReferencesOfAddress(entry))
|
||||
incoming = sorted(set(incoming), key=lambda value: int(value, 16))
|
||||
outgoing = []
|
||||
procedure_addresses = set(addresses)
|
||||
for source, target in sorted(edges):
|
||||
source_procedure, _ = _containing_procedure(document, source)
|
||||
target_procedure, _ = _containing_procedure(document, target)
|
||||
item = {
|
||||
"source_address": _hex(source),
|
||||
"target_address": _hex(target),
|
||||
"source_procedure": _procedure_identity(source_procedure) if source_procedure is not None else None,
|
||||
"target_procedure": _procedure_identity(target_procedure) if target_procedure is not None else None,
|
||||
"kind": _unavailable("Hopper's public Python API does not classify reference kinds"),
|
||||
}
|
||||
if target in procedure_addresses and source not in procedure_addresses:
|
||||
incoming.append(item)
|
||||
if source in procedure_addresses:
|
||||
outgoing.append(item)
|
||||
string_map = {int(address, 16): value for address, value in _strings(document).items()}
|
||||
name_map = _name_map(document)
|
||||
referenced_strings = []
|
||||
referenced_names = []
|
||||
for edge in outgoing:
|
||||
target = int(edge["target_address"], 16)
|
||||
if target in string_map:
|
||||
referenced_strings.append({"address": edge["target_address"], "value": string_map[target], "source_address": edge["source_address"]})
|
||||
if target in name_map:
|
||||
referenced_names.append({"address": edge["target_address"], "value": name_map[target], "source_address": edge["source_address"]})
|
||||
comments.sort(key=lambda item: (int(item["address"], 16), item["kind"]))
|
||||
referenced_strings.sort(key=lambda item: (int(item["address"], 16), int(item["source_address"], 16)))
|
||||
referenced_names.sort(key=lambda item: (int(item["address"], 16), int(item["source_address"], 16)))
|
||||
pseudo_text = pseudo[pseudocode_offset:pseudocode_offset + max_chars]
|
||||
pseudo_next = pseudocode_offset + len(pseudo_text)
|
||||
def collection(name, items, scan_limited=False):
|
||||
return _bounded(items, _collection_offset(params, name), limit, None, scan_limited)
|
||||
return {
|
||||
"procedure": {"address": _hex(procedure.getEntryPoint()), "name": _procedure_name(procedure), "signature": procedure.signatureString(), "locals": _json_safe(procedure.getLocalVariableList())},
|
||||
"pseudocode": {"text": pseudo[:max_chars], "total_chars": len(pseudo), "returned_chars": min(len(pseudo), max_chars), "truncated": len(pseudo) > max_chars, "next_offset": max_chars if len(pseudo) > max_chars else None},
|
||||
"assembly": {"items": assembly, "total": None if assembly_truncated else len(assembly), "returned": len(assembly), "truncated": assembly_truncated, "next_offset": len(assembly) if assembly_truncated else None},
|
||||
"comments": bounded(comments), "callers": bounded(callers), "callees": bounded(callees),
|
||||
"incoming_references": bounded(incoming),
|
||||
"referenced_strings": _unavailable("Hopper's public API does not expose typed outgoing string references for this traversal"),
|
||||
"referenced_names": _unavailable("Hopper's public API does not expose typed outgoing name references for this traversal"),
|
||||
"basic_blocks": bounded(blocks),
|
||||
"pseudocode": {"text": pseudo_text, "total_chars": len(pseudo), "returned_chars": len(pseudo_text), "truncated": pseudo_next < len(pseudo), "next_offset": pseudo_next if pseudo_next < len(pseudo) else None},
|
||||
"assembly": _bounded(assembly_lines, assembly_offset, max_instructions),
|
||||
"comments": collection("comments", comments, instruction_scan_truncated),
|
||||
"callers": collection("callers", callers), "callees": collection("callees", callees),
|
||||
"incoming_references": collection("incoming_references", incoming, instruction_scan_truncated),
|
||||
"outgoing_references": collection("outgoing_references", outgoing, instruction_scan_truncated),
|
||||
"referenced_strings": collection("referenced_strings", referenced_strings, instruction_scan_truncated),
|
||||
"referenced_names": collection("referenced_names", referenced_names, instruction_scan_truncated),
|
||||
"basic_blocks": collection("basic_blocks", blocks),
|
||||
"instruction_scan": {"scanned": len(addresses), "truncated": instruction_scan_truncated},
|
||||
}
|
||||
|
||||
|
||||
|
||||
Generated
+68
-4
@@ -10,8 +10,11 @@
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/server": "2.0.0-beta.3",
|
||||
"canonicalize": "3.0.0",
|
||||
"incur": "0.4.13",
|
||||
"pino": "^9.14.0",
|
||||
"node-pty": "1.1.0",
|
||||
"pino": "9.14.0",
|
||||
"ws": "8.21.0",
|
||||
"zod": "4.4.3"
|
||||
},
|
||||
"bin": {
|
||||
@@ -20,11 +23,12 @@
|
||||
"devDependencies": {
|
||||
"@modelcontextprotocol/client": "2.0.0-beta.3",
|
||||
"@types/node": "24.10.1",
|
||||
"@vitest/coverage-v8": "^4.1.10",
|
||||
"@types/ws": "8.18.1",
|
||||
"@vitest/coverage-v8": "4.1.10",
|
||||
"husky": "9.1.7",
|
||||
"jscpd": "5.0.12",
|
||||
"knip": "6.26.0",
|
||||
"lint-staged": "^16.4.0",
|
||||
"lint-staged": "16.4.0",
|
||||
"oxlint": "1.73.0",
|
||||
"prettier": "3.6.2",
|
||||
"typedoc": "0.28.20",
|
||||
@@ -32,7 +36,8 @@
|
||||
"vitest": "4.1.10"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
"node": ">=24.18.0 <25",
|
||||
"npm": ">=11.16.0 <12"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-string-parser": {
|
||||
@@ -1647,6 +1652,16 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/ws": {
|
||||
"version": "8.18.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/coverage-v8": {
|
||||
"version": "4.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.10.tgz",
|
||||
@@ -1871,6 +1886,18 @@
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/canonicalize": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/canonicalize/-/canonicalize-3.0.0.tgz",
|
||||
"integrity": "sha512-yYLfHyDMIXRyRqsKBRLX023riFLpXY2YOfdtqKXZRZy9qsfOJ9U+4F9YZL7MEzL5+ziN2x2nlBvY/Voi3EBljA==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"canonicalize": "bin/canonicalize.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/chai": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
|
||||
@@ -2898,6 +2925,22 @@
|
||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/node-addon-api": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
|
||||
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/node-pty": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.1.0.tgz",
|
||||
"integrity": "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"node-addon-api": "^7.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/obug": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz",
|
||||
@@ -3924,6 +3967,27 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.21.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
|
||||
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/yaml": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
|
||||
|
||||
+16
-5
@@ -33,12 +33,15 @@
|
||||
"rea": "scripts/rea.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
"node": ">=24.18.0 <25",
|
||||
"npm": ">=11.16.0 <12"
|
||||
},
|
||||
"packageManager": "[email protected]",
|
||||
"files": [
|
||||
"dist",
|
||||
"bridge/hopper_bridge.py",
|
||||
"scripts/rea.mjs",
|
||||
"scripts/rebuild-native.mjs",
|
||||
"skills",
|
||||
"README.md",
|
||||
"LICENSE"
|
||||
@@ -69,7 +72,8 @@
|
||||
"config:print": "node scripts/print-mcp-config.mjs",
|
||||
"docs:generate": "typedoc",
|
||||
"prepack": "npm run build",
|
||||
"prepare": "husky"
|
||||
"prepare": "husky",
|
||||
"rebuild:native": "node scripts/rebuild-native.mjs"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{ts,mjs,json,md,yml,yaml}": "prettier --write",
|
||||
@@ -77,22 +81,29 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/server": "2.0.0-beta.3",
|
||||
"canonicalize": "3.0.0",
|
||||
"incur": "0.4.13",
|
||||
"pino": "^9.14.0",
|
||||
"node-pty": "1.1.0",
|
||||
"pino": "9.14.0",
|
||||
"ws": "8.21.0",
|
||||
"zod": "4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@modelcontextprotocol/client": "2.0.0-beta.3",
|
||||
"@types/node": "24.10.1",
|
||||
"@vitest/coverage-v8": "^4.1.10",
|
||||
"@types/ws": "8.18.1",
|
||||
"@vitest/coverage-v8": "4.1.10",
|
||||
"husky": "9.1.7",
|
||||
"jscpd": "5.0.12",
|
||||
"knip": "6.26.0",
|
||||
"lint-staged": "^16.4.0",
|
||||
"lint-staged": "16.4.0",
|
||||
"oxlint": "1.73.0",
|
||||
"prettier": "3.6.2",
|
||||
"typedoc": "0.28.20",
|
||||
"typescript": "5.9.3",
|
||||
"vitest": "4.1.10"
|
||||
},
|
||||
"allowScripts": {
|
||||
"[email protected]": true
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
// Route production MCP before importing Incur. Incur owns registration helpers
|
||||
// such as `mcp add`, while only dist/main.js may serve the 46-tool stdio server.
|
||||
// such as `mcp add`, while only dist/main.js may serve the 50-tool stdio server.
|
||||
const args = process.argv.slice(2);
|
||||
const isMcpMode =
|
||||
args[0] === "--mcp" || (args.length === 1 && args[0] === "mcp");
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
const npm = process.platform === "win32" ? "npm.cmd" : "npm";
|
||||
const child = spawn(npm, ["rebuild", "node-pty"], {
|
||||
stdio: "inherit",
|
||||
env: { ...process.env, npm_config_build_from_source: "true" },
|
||||
});
|
||||
|
||||
child.once("error", (error) => {
|
||||
console.error(`Unable to rebuild node-pty: ${error.message}`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
child.once("exit", (code, signal) => {
|
||||
if (signal !== null) {
|
||||
console.error(`node-pty rebuild terminated by ${signal}`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
process.exitCode = code ?? 1;
|
||||
});
|
||||
@@ -161,8 +161,8 @@ try {
|
||||
const client = new Client({ name: "package-smoke", version: "1.0.0" });
|
||||
try {
|
||||
await client.connect(transport);
|
||||
if ((await client.listTools()).tools.length !== 46)
|
||||
throw new Error("packaged MCP did not expose 46 tools");
|
||||
if ((await client.listTools()).tools.length !== 50)
|
||||
throw new Error("packaged MCP did not expose 50 tools");
|
||||
const result = await client.callTool({
|
||||
name: "current_document",
|
||||
arguments: {},
|
||||
@@ -177,7 +177,7 @@ try {
|
||||
}
|
||||
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({ cli: true, incurMcpCommand: "npx -y @morluto/rea mcp", doctor: true, setup: "idempotent", clients: 2, backupReadback: true, failureRecovery: true, skill: true, mcpTools: 46, targetFree: true })}\n`,
|
||||
`${JSON.stringify({ cli: true, incurMcpCommand: "npx -y @morluto/rea mcp", doctor: true, setup: "idempotent", clients: 2, backupReadback: true, failureRecovery: true, skill: true, mcpTools: 50, targetFree: true })}\n`,
|
||||
);
|
||||
} finally {
|
||||
if (tarball) await rm(join(root, tarball), { force: true });
|
||||
|
||||
@@ -9,10 +9,6 @@ import { TOOL_CONTRACTS } from "../dist/contracts/toolContracts.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const timeout = 180_000;
|
||||
const hopperProcessPrefix = "/Applications/Hopper Disassembler.app/Contents/";
|
||||
// Snapshot bundle processes so cleanup terminates only Hopper processes this
|
||||
// verifier caused to appear, never an instance the user already had running.
|
||||
const hopperProcessesBefore = await hopperProcessIds();
|
||||
const sessionsBefore = new Set(
|
||||
(await readdir("/tmp")).filter((name) => name.startsWith("rea-")),
|
||||
);
|
||||
@@ -77,6 +73,9 @@ const requireFunctionDossier = (value, expectedAddress) => {
|
||||
"callers",
|
||||
"callees",
|
||||
"incoming_references",
|
||||
"outgoing_references",
|
||||
"referenced_strings",
|
||||
"referenced_names",
|
||||
"basic_blocks",
|
||||
]) {
|
||||
const collection = value[field];
|
||||
@@ -89,6 +88,27 @@ const requireFunctionDossier = (value, expectedAddress) => {
|
||||
throw new Error(`analyze_function returned an invalid ${field} result`);
|
||||
}
|
||||
}
|
||||
for (const field of ["callers", "callees"]) {
|
||||
for (const identity of value[field].items) {
|
||||
if (
|
||||
typeof identity?.address !== "string" ||
|
||||
typeof identity?.name !== "string"
|
||||
) {
|
||||
throw new Error(`analyze_function returned an untyped ${field} item`);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const block of value.basic_blocks.items) {
|
||||
if (!Array.isArray(block?.successors)) {
|
||||
throw new Error("analyze_function omitted CFG successor evidence");
|
||||
}
|
||||
}
|
||||
if (
|
||||
typeof value.instruction_scan?.scanned !== "number" ||
|
||||
typeof value.instruction_scan?.truncated !== "boolean"
|
||||
) {
|
||||
throw new Error("analyze_function omitted instruction scan limitations");
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
@@ -156,7 +176,7 @@ try {
|
||||
const expectedNames = TOOL_CONTRACTS.map(({ name }) => name).sort();
|
||||
const actualNames = listed.tools.map(({ name }) => name).sort();
|
||||
if (JSON.stringify(actualNames) !== JSON.stringify(expectedNames)) {
|
||||
throw new Error("The real server did not expose the intended 46 tools");
|
||||
throw new Error("The real server did not expose the intended 50 tools");
|
||||
}
|
||||
|
||||
const options = { timeout };
|
||||
@@ -314,7 +334,6 @@ try {
|
||||
await transport.close();
|
||||
} finally {
|
||||
clearInterval(keepAlive);
|
||||
await terminateNewHopperProcesses(hopperProcessesBefore);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -350,34 +369,3 @@ await new Promise((resolve, reject) => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
async function hopperProcessIds() {
|
||||
const result = await execFileAsync("ps", ["-ax", "-o", "pid=,command="]);
|
||||
return new Set(
|
||||
result.stdout
|
||||
.split("\n")
|
||||
.map((line) => line.trim().match(/^(\d+)\s+(.+)$/))
|
||||
.filter((match) => match?.[2]?.startsWith(hopperProcessPrefix) === true)
|
||||
.map((match) => Number(match[1])),
|
||||
);
|
||||
}
|
||||
|
||||
async function terminateNewHopperProcesses(previous) {
|
||||
const current = await hopperProcessIds();
|
||||
const owned = [...current].filter((pid) => !previous.has(pid));
|
||||
for (const pid of owned) signalProcess(pid, "SIGTERM");
|
||||
await new Promise((resolve) => setTimeout(resolve, 1_000));
|
||||
for (const pid of owned)
|
||||
if (signalProcess(pid, 0)) signalProcess(pid, "SIGKILL");
|
||||
}
|
||||
|
||||
function signalProcess(pid, signal) {
|
||||
try {
|
||||
process.kill(pid, signal);
|
||||
return true;
|
||||
} catch (cause) {
|
||||
if (cause instanceof Error && "code" in cause && cause.code === "ESRCH")
|
||||
return false;
|
||||
throw cause;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,9 +29,30 @@ export interface ProviderIdentity {
|
||||
readonly version: string | null;
|
||||
}
|
||||
|
||||
type AnalysisOperation = string;
|
||||
|
||||
interface CapabilityEffects {
|
||||
readonly mutatesArtifact: boolean;
|
||||
readonly launchesProcess: boolean;
|
||||
readonly mayShowUi: boolean;
|
||||
readonly mayAccessNetwork: boolean;
|
||||
readonly mayWriteFilesystem: boolean;
|
||||
readonly requiresPrivileges: boolean;
|
||||
}
|
||||
|
||||
export interface CapabilityDescriptor {
|
||||
readonly operation: AnalysisOperation;
|
||||
readonly version: number;
|
||||
readonly available: boolean;
|
||||
readonly pagination: "none" | "offset" | "cursor";
|
||||
readonly exhaustive: boolean;
|
||||
readonly effects: CapabilityEffects;
|
||||
readonly limitations: readonly string[];
|
||||
}
|
||||
|
||||
/** Factory and capability declaration for an analysis implementation. */
|
||||
export interface AnalysisProvider {
|
||||
identity(): ProviderIdentity;
|
||||
capabilities(): readonly string[];
|
||||
capabilities(): readonly CapabilityDescriptor[];
|
||||
createClient(target: BinaryTarget): AnalysisClient;
|
||||
}
|
||||
|
||||
@@ -7,12 +7,16 @@ import {
|
||||
} from "../domain/errors.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import type { Evidence } from "../domain/evidence.js";
|
||||
import type { EvidenceBundle } from "../domain/evidenceBundle.js";
|
||||
import type {
|
||||
AnalysisClient,
|
||||
AnalysisClientFactory,
|
||||
AnalysisOperationPort,
|
||||
AnalysisProvider,
|
||||
ProviderIdentity,
|
||||
} from "./AnalysisProvider.js";
|
||||
import { EvidenceLedger } from "./EvidenceLedger.js";
|
||||
|
||||
/** Target lifecycle used by CLI and MCP without exposing a concrete provider. */
|
||||
export interface BinarySessionPort extends AnalysisOperationPort {
|
||||
@@ -26,6 +30,10 @@ export interface BinarySessionPort extends AnalysisOperationPort {
|
||||
close(): Promise<Result<null, AnalysisError>>;
|
||||
status(): JsonValue;
|
||||
activeTarget(): BinaryTarget | undefined;
|
||||
recordEvidence(evidence: Evidence): void;
|
||||
exportEvidenceBundle(): EvidenceBundle;
|
||||
importEvidenceBundle(bundle: unknown): number;
|
||||
providerIdentity(): ProviderIdentity;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -43,12 +51,38 @@ export class BinarySession implements BinarySessionPort {
|
||||
#transition: Promise<void> = Promise.resolve();
|
||||
readonly #calls = new Set<Promise<unknown>>();
|
||||
readonly #createClient: AnalysisClientFactory;
|
||||
readonly #providerIdentity: ProviderIdentity;
|
||||
readonly #evidence = new EvidenceLedger({ maxRecords: 10_000 });
|
||||
|
||||
constructor(readonly provider: AnalysisProvider | AnalysisClientFactory) {
|
||||
this.#createClient =
|
||||
typeof provider === "function"
|
||||
? provider
|
||||
: (target) => provider.createClient(target);
|
||||
this.#providerIdentity =
|
||||
typeof provider === "function"
|
||||
? { id: "unidentified", name: "Unidentified provider", version: null }
|
||||
: provider.identity();
|
||||
}
|
||||
|
||||
/** Identify the provider producing evidence for this session. */
|
||||
providerIdentity(): ProviderIdentity {
|
||||
return this.#providerIdentity;
|
||||
}
|
||||
|
||||
/** Add one successful public observation to the session ledger. */
|
||||
recordEvidence(evidence: Evidence): void {
|
||||
this.#evidence.record(evidence);
|
||||
}
|
||||
|
||||
/** Return a deterministic snapshot without clearing session evidence. */
|
||||
exportEvidenceBundle(): EvidenceBundle {
|
||||
return this.#evidence.export();
|
||||
}
|
||||
|
||||
/** Atomically merge a validated evidence bundle into this session. */
|
||||
importEvidenceBundle(bundle: unknown): number {
|
||||
return this.#evidence.import(bundle);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -102,6 +136,7 @@ export class BinarySession implements BinarySessionPort {
|
||||
this.#active = undefined;
|
||||
await this.#drainCalls();
|
||||
await previous?.client.close();
|
||||
this.#evidence.clear();
|
||||
return ok(null);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ export const runDirectAnalysis = async (
|
||||
return { error: opened.error._tag, message: opened.error.message };
|
||||
const result = await session.execute(tool, arguments_);
|
||||
return result.ok
|
||||
? createEvidence(opened.value, {
|
||||
? createEvidence(opened.value, session.providerIdentity(), {
|
||||
operation: tool,
|
||||
parameters: arguments_,
|
||||
result: result.value,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { enhancedInputSchemas } from "../contracts/enhancedInputs.js";
|
||||
import { AnalysisProtocolError, type AnalysisError } from "../domain/errors.js";
|
||||
import {
|
||||
parseDocuments,
|
||||
parseFunctionDossier,
|
||||
parseAddressedPage,
|
||||
parseListCount,
|
||||
parseRelatedAddresses,
|
||||
@@ -84,7 +85,7 @@ export class EnhancedTools {
|
||||
case "analyze_function": {
|
||||
const parsed = enhancedInputSchemas.analyze_function.safeParse(input);
|
||||
return parsed.success
|
||||
? this.#call("analyze_function", parsed.data, signal)
|
||||
? this.#analyzeFunction(parsed.data, signal)
|
||||
: invalidInput(name, parsed.error);
|
||||
}
|
||||
case "trace_feature": {
|
||||
@@ -96,6 +97,14 @@ export class EnhancedTools {
|
||||
}
|
||||
}
|
||||
|
||||
async #analyzeFunction(
|
||||
input: Readonly<Record<string, JsonValue>>,
|
||||
signal?: AbortSignal,
|
||||
): EnhancedResult {
|
||||
const result = await this.#call("analyze_function", input, signal);
|
||||
return result.ok ? parseFunctionDossier(result.value) : result;
|
||||
}
|
||||
|
||||
async #swiftClasses(pattern: string, signal?: AbortSignal): EnhancedResult {
|
||||
const procedures = await this.#allProcedures(signal);
|
||||
return procedures.ok
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import {
|
||||
createEvidenceBundle,
|
||||
evidenceBundleSchema,
|
||||
type EvidenceBundle,
|
||||
} from "../domain/evidenceBundle.js";
|
||||
import { parseEvidence, type Evidence } from "../domain/evidence.js";
|
||||
|
||||
export interface EvidenceLedgerLimits {
|
||||
readonly maxRecords: number;
|
||||
}
|
||||
|
||||
/** Bounded, session-owned set of immutable evidence records. */
|
||||
export class EvidenceLedger {
|
||||
readonly #records = new Map<string, Evidence>();
|
||||
|
||||
constructor(private readonly limits: EvidenceLedgerLimits) {
|
||||
if (!Number.isSafeInteger(limits.maxRecords) || limits.maxRecords < 1)
|
||||
throw new RangeError("maxRecords must be a positive safe integer");
|
||||
}
|
||||
|
||||
/** Record evidence idempotently; conflicting content is rejected. */
|
||||
record(input: Evidence): "added" | "duplicate" {
|
||||
const evidence = parseEvidence(input);
|
||||
const existing = this.#records.get(evidence.evidence_id);
|
||||
if (existing !== undefined) {
|
||||
if (!recordsAgree(existing, evidence))
|
||||
throw new Error(`Conflicting evidence record: ${evidence.evidence_id}`);
|
||||
return "duplicate";
|
||||
}
|
||||
if (this.#records.size >= this.limits.maxRecords)
|
||||
throw new RangeError("Evidence ledger record limit exceeded");
|
||||
this.#records.set(evidence.evidence_id, evidence);
|
||||
return "added";
|
||||
}
|
||||
|
||||
/** Validate an entire bundle before atomically merging its records. */
|
||||
import(input: unknown): number {
|
||||
const bundle = evidenceBundleSchema.parse(input);
|
||||
const pending = new Map(this.#records);
|
||||
for (const unparsed of bundle.records) {
|
||||
const evidence = parseEvidence(unparsed);
|
||||
const existing = pending.get(evidence.evidence_id);
|
||||
if (existing !== undefined && !recordsAgree(existing, evidence))
|
||||
throw new Error(`Conflicting evidence record: ${evidence.evidence_id}`);
|
||||
pending.set(evidence.evidence_id, evidence);
|
||||
}
|
||||
if (pending.size > this.limits.maxRecords)
|
||||
throw new RangeError("Evidence ledger record limit exceeded");
|
||||
const added = pending.size - this.#records.size;
|
||||
this.#records.clear();
|
||||
for (const [id, evidence] of pending) this.#records.set(id, evidence);
|
||||
return added;
|
||||
}
|
||||
|
||||
/** Export records in deterministic, semantically irrelevant ID order. */
|
||||
export(): EvidenceBundle {
|
||||
return createEvidenceBundle([...this.#records.values()]);
|
||||
}
|
||||
|
||||
/** Clear records when the owning session closes. */
|
||||
clear(): void {
|
||||
this.#records.clear();
|
||||
}
|
||||
}
|
||||
|
||||
const recordsAgree = (left: Evidence, right: Evidence): boolean =>
|
||||
JSON.stringify(withoutLocalPath(left)) ===
|
||||
JSON.stringify(withoutLocalPath(right));
|
||||
|
||||
const withoutLocalPath = (evidence: Evidence): Evidence => ({
|
||||
...evidence,
|
||||
subject:
|
||||
evidence.subject === null
|
||||
? null
|
||||
: {
|
||||
...evidence.subject,
|
||||
name: "<non-identity-name>",
|
||||
local_path: "<non-identity-local-path>",
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import { createServer, type Server } from "node:http";
|
||||
import { WebSocketServer, type WebSocket } from "ws";
|
||||
import type {
|
||||
ProcessScenario,
|
||||
ProtocolEvent,
|
||||
} from "../domain/processCapture.js";
|
||||
|
||||
/** Owned loopback replay endpoints and their bounded protocol observations. */
|
||||
export interface LoopbackReplay {
|
||||
readonly httpUrl: string;
|
||||
readonly websocketUrl: string;
|
||||
readonly events: ProtocolEvent[];
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
const closeServer = async (server: Server): Promise<void> =>
|
||||
new Promise((resolveClose, rejectClose) => {
|
||||
server.close((error) =>
|
||||
error === undefined ? resolveClose() : rejectClose(error),
|
||||
);
|
||||
});
|
||||
|
||||
/** Start bounded HTTP and WebSocket replay endpoints bound only to IPv4 loopback. */
|
||||
export const startLoopbackReplay = async (
|
||||
scenario: ProcessScenario,
|
||||
): Promise<LoopbackReplay> => {
|
||||
const events: ProtocolEvent[] = [];
|
||||
const record = (event: Omit<ProtocolEvent, "sequence">): void => {
|
||||
if (events.length < 10_000)
|
||||
events.push({ sequence: events.length, ...event });
|
||||
};
|
||||
const server = createServer((request, response) => {
|
||||
const method = request.method ?? "GET";
|
||||
const path = new URL(request.url ?? "/", "http://127.0.0.1").pathname;
|
||||
record({ protocol: "http", direction: "request", method, path, data: "" });
|
||||
const route = scenario.replay.http.find(
|
||||
(candidate) => candidate.method === method && candidate.path === path,
|
||||
);
|
||||
response.statusCode = route?.status ?? 404;
|
||||
const body = route?.body ?? "";
|
||||
response.end(body);
|
||||
record({
|
||||
protocol: "http",
|
||||
direction: "response",
|
||||
method,
|
||||
path,
|
||||
data: body,
|
||||
});
|
||||
});
|
||||
const websocket = new WebSocketServer({
|
||||
noServer: true,
|
||||
maxPayload: 1_000_000,
|
||||
clientTracking: true,
|
||||
});
|
||||
server.on("upgrade", (request, socket, head) => {
|
||||
if (new URL(request.url ?? "/", "http://127.0.0.1").pathname !== "/ws") {
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
websocket.handleUpgrade(request, socket, head, (client) =>
|
||||
websocket.emit("connection", client, request),
|
||||
);
|
||||
});
|
||||
websocket.on("connection", (client: WebSocket) => {
|
||||
client.on("error", () => undefined);
|
||||
client.on("message", (value) =>
|
||||
record({
|
||||
protocol: "websocket",
|
||||
direction: "received",
|
||||
method: null,
|
||||
path: "/ws",
|
||||
data: value.toString(),
|
||||
}),
|
||||
);
|
||||
for (const message of scenario.replay.websocket_messages) {
|
||||
client.send(message);
|
||||
record({
|
||||
protocol: "websocket",
|
||||
direction: "sent",
|
||||
method: null,
|
||||
path: "/ws",
|
||||
data: message,
|
||||
});
|
||||
}
|
||||
});
|
||||
await new Promise<void>((resolveListen, rejectListen) => {
|
||||
server.once("error", rejectListen);
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
server.off("error", rejectListen);
|
||||
resolveListen();
|
||||
});
|
||||
});
|
||||
const address = server.address();
|
||||
if (address === null || typeof address === "string") {
|
||||
await closeServer(server);
|
||||
throw new Error("loopback replay did not acquire a TCP port");
|
||||
}
|
||||
return {
|
||||
httpUrl: `http://127.0.0.1:${String(address.port)}`,
|
||||
websocketUrl: `ws://127.0.0.1:${String(address.port)}/ws`,
|
||||
events,
|
||||
async close() {
|
||||
for (const client of websocket.clients) client.terminate();
|
||||
await new Promise<void>((resolveClose) =>
|
||||
websocket.close(() => resolveClose()),
|
||||
);
|
||||
await closeServer(server);
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,511 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
lstat,
|
||||
mkdtemp,
|
||||
readdir,
|
||||
readFile,
|
||||
readlink,
|
||||
realpath,
|
||||
rm,
|
||||
} from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, relative } from "node:path";
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { spawn, type IPty } from "node-pty";
|
||||
import type {
|
||||
FileState,
|
||||
ProcessCapture,
|
||||
ProcessExecutionPolicy,
|
||||
ProcessSample,
|
||||
ProcessScenario,
|
||||
TerminalFrame,
|
||||
} from "../domain/processCapture.js";
|
||||
import { authorizeProcessScenario } from "../domain/processCapture.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import { AnalysisError } from "../domain/errors.js";
|
||||
import { startLoopbackReplay, type LoopbackReplay } from "./LoopbackReplay.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
const isWithin = (candidate: string, root: string): boolean =>
|
||||
candidate === root ||
|
||||
candidate.startsWith(`${root.endsWith("/") ? root.slice(0, -1) : root}/`);
|
||||
|
||||
const assertRealPathAuthority = async (
|
||||
scenario: ProcessScenario,
|
||||
policy: ProcessExecutionPolicy,
|
||||
): Promise<void> => {
|
||||
const executable = await realpath(scenario.executable);
|
||||
const executableRoots = await Promise.all(
|
||||
policy.executableRoots.map((root) => realpath(root)),
|
||||
);
|
||||
if (!executableRoots.some((root) => isWithin(executable, root)))
|
||||
throw new ProcessCaptureError(
|
||||
"resolved executable is outside approved roots",
|
||||
);
|
||||
const workingDirectory = await realpath(scenario.working_directory);
|
||||
const workingRoots = await Promise.all(
|
||||
policy.workingRoots.map((root) => realpath(root)),
|
||||
);
|
||||
if (!workingRoots.some((root) => isWithin(workingDirectory, root)))
|
||||
throw new ProcessCaptureError(
|
||||
"resolved working directory is outside approved roots",
|
||||
);
|
||||
for (const root of scenario.filesystem_roots) {
|
||||
const resolvedRoot = await realpath(root);
|
||||
if (!workingRoots.some((approved) => isWithin(resolvedRoot, approved)))
|
||||
throw new ProcessCaptureError(
|
||||
"resolved filesystem root is outside approved roots",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/** Expected refusal or runtime failure from the process capture adapter. */
|
||||
export class ProcessCaptureError extends AnalysisError {
|
||||
readonly _tag = "ProcessCaptureError";
|
||||
}
|
||||
|
||||
/** Runtime availability of the native PTY adapter on this host. */
|
||||
export type ProcessCaptureCapability =
|
||||
| { readonly available: true; readonly backend: "node-pty" }
|
||||
| {
|
||||
readonly available: false;
|
||||
readonly backend: "node-pty";
|
||||
readonly reason: string;
|
||||
};
|
||||
|
||||
/** Probe the actual native PTY seam instead of inferring support from the OS name. */
|
||||
export const probeProcessCaptureCapability =
|
||||
async (): Promise<ProcessCaptureCapability> => {
|
||||
try {
|
||||
const terminal = spawn(
|
||||
process.platform === "win32" ? "cmd.exe" : "/bin/sh",
|
||||
process.platform === "win32" ? ["/c", "exit", "0"] : ["-c", "exit 0"],
|
||||
{
|
||||
cwd: tmpdir(),
|
||||
env: { HOME: tmpdir(), TERM: "xterm-256color" },
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
name: "xterm-256color",
|
||||
},
|
||||
);
|
||||
await new Promise<void>((resolveExit) =>
|
||||
terminal.onExit(() => resolveExit()),
|
||||
);
|
||||
return { available: true, backend: "node-pty" };
|
||||
} catch {
|
||||
return {
|
||||
available: false,
|
||||
backend: "node-pty",
|
||||
reason: "the native PTY backend could not start a probe process",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
interface SnapshotResult {
|
||||
readonly files: readonly FileState[];
|
||||
readonly truncated: boolean;
|
||||
}
|
||||
|
||||
const hashFile = async (
|
||||
path: string,
|
||||
maxBytes: number,
|
||||
): Promise<string | null> => {
|
||||
const value = await readFile(path);
|
||||
if (value.byteLength > maxBytes) return null;
|
||||
return createHash("sha256").update(value).digest("hex");
|
||||
};
|
||||
|
||||
const snapshotRoots = async (
|
||||
scenario: ProcessScenario,
|
||||
): Promise<SnapshotResult> => {
|
||||
const entries: FileState[] = [];
|
||||
let remainingBytes = scenario.limits.file_bytes;
|
||||
let truncated = false;
|
||||
const visit = async (root: string, path: string): Promise<void> => {
|
||||
if (entries.length >= scenario.limits.files) {
|
||||
truncated = true;
|
||||
return;
|
||||
}
|
||||
let stats;
|
||||
try {
|
||||
stats = await lstat(path);
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error && "code" in error && error.code === "ENOENT")
|
||||
return;
|
||||
throw error;
|
||||
}
|
||||
const relativePath = relative(root, path) || ".";
|
||||
if (stats.isSymbolicLink()) {
|
||||
entries.push({
|
||||
path: `${root}:${relativePath}`,
|
||||
type: "symlink",
|
||||
mode: stats.mode,
|
||||
size: stats.size,
|
||||
sha256: null,
|
||||
symlink_target: await readlink(path),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (stats.isFile()) {
|
||||
const allowedBytes = Math.max(
|
||||
0,
|
||||
Math.min(remainingBytes, scenario.limits.file_bytes),
|
||||
);
|
||||
const sha256 =
|
||||
allowedBytes >= stats.size ? await hashFile(path, allowedBytes) : null;
|
||||
remainingBytes -= sha256 === null ? 0 : stats.size;
|
||||
if (sha256 === null) truncated = true;
|
||||
entries.push({
|
||||
path: `${root}:${relativePath}`,
|
||||
type: "file",
|
||||
mode: stats.mode,
|
||||
size: stats.size,
|
||||
sha256,
|
||||
symlink_target: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const type = stats.isDirectory() ? "directory" : "other";
|
||||
entries.push({
|
||||
path: `${root}:${relativePath}`,
|
||||
type,
|
||||
mode: stats.mode,
|
||||
size: stats.size,
|
||||
sha256: null,
|
||||
symlink_target: null,
|
||||
});
|
||||
if (type !== "directory") return;
|
||||
for (const child of (await readdir(path)).sort())
|
||||
await visit(root, join(path, child));
|
||||
};
|
||||
for (const root of scenario.filesystem_roots) await visit(root, root);
|
||||
return { files: entries, truncated };
|
||||
};
|
||||
|
||||
const sampleProcesses = async (
|
||||
rootPid: number,
|
||||
elapsedMs: number,
|
||||
limit: number,
|
||||
): Promise<readonly ProcessSample[]> => {
|
||||
if (process.platform === "win32") return [];
|
||||
const { stdout } = await execFileAsync("ps", ["-axo", "pid=,ppid=,command="]);
|
||||
const rows = stdout
|
||||
.split("\n")
|
||||
.map((line) => /\s*(\d+)\s+(\d+)\s+(.*)/.exec(line))
|
||||
.filter((match): match is RegExpExecArray => match !== null)
|
||||
.map((match) => ({
|
||||
pid: Number(match[1]),
|
||||
parent_pid: Number(match[2]),
|
||||
command: match[3] ?? "",
|
||||
}));
|
||||
const owned = new Set([rootPid]);
|
||||
let changed = true;
|
||||
while (changed) {
|
||||
changed = false;
|
||||
for (const row of rows) {
|
||||
if (owned.has(row.parent_pid) && !owned.has(row.pid)) {
|
||||
owned.add(row.pid);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return rows
|
||||
.filter((row) => owned.has(row.pid))
|
||||
.slice(0, limit)
|
||||
.map((row) => ({ at_ms: elapsedMs, ...row }));
|
||||
};
|
||||
|
||||
const makeEnvironment = (
|
||||
scenario: ProcessScenario,
|
||||
home: string,
|
||||
replay: LoopbackReplay,
|
||||
): Record<string, string> => {
|
||||
const environment: Record<string, string> = {
|
||||
HOME: home,
|
||||
TERM: "xterm-256color",
|
||||
...scenario.environment,
|
||||
};
|
||||
for (const name of scenario.inherit_environment) {
|
||||
const value = process.env[name];
|
||||
if (value !== undefined) environment[name] = value;
|
||||
}
|
||||
environment.REA_REPLAY_HTTP_URL = replay.httpUrl;
|
||||
environment.REA_REPLAY_WEBSOCKET_URL = replay.websocketUrl;
|
||||
return environment;
|
||||
};
|
||||
|
||||
const normalizeTerminalData = (
|
||||
value: string,
|
||||
scenario: ProcessScenario,
|
||||
temporaryRoot: string,
|
||||
pid: number,
|
||||
): string => {
|
||||
let normalized = value;
|
||||
for (const alias of scenario.secret_aliases) {
|
||||
const secret = scenario.environment[alias];
|
||||
if (secret !== undefined && secret.length > 0)
|
||||
normalized = normalized.replaceAll(secret, "<redacted>");
|
||||
}
|
||||
if (scenario.normalization.paths)
|
||||
normalized = normalized.replaceAll(temporaryRoot, "<temporary-root>");
|
||||
if (scenario.normalization.pids)
|
||||
normalized = normalized.replaceAll(String(pid), "<pid>");
|
||||
if (scenario.normalization.ports)
|
||||
normalized = normalized.replaceAll(/(?<=[:=])\d{2,5}\b/g, "<port>");
|
||||
for (const pattern of scenario.normalization.patterns)
|
||||
normalized = normalized.replaceAll(pattern.pattern, pattern.replacement);
|
||||
return normalized;
|
||||
};
|
||||
|
||||
const scheduleScenarioEvents = (
|
||||
scenario: ProcessScenario,
|
||||
getTerminal: () => IPty | undefined,
|
||||
timers: Set<NodeJS.Timeout>,
|
||||
): void => {
|
||||
for (const event of scenario.events) {
|
||||
const timer = setTimeout(() => {
|
||||
const terminal = getTerminal();
|
||||
if (event.type === "input") terminal?.write(event.data);
|
||||
else if (event.type === "resize")
|
||||
terminal?.resize(event.columns, event.rows);
|
||||
else terminal?.kill(event.signal);
|
||||
}, event.at_ms);
|
||||
timers.add(timer);
|
||||
}
|
||||
};
|
||||
|
||||
interface TerminalExitOptions {
|
||||
readonly terminal: IPty;
|
||||
readonly scenario: ProcessScenario;
|
||||
readonly started: number;
|
||||
readonly lastOutput: () => number;
|
||||
readonly signal: AbortSignal | undefined;
|
||||
readonly timers: Set<NodeJS.Timeout>;
|
||||
}
|
||||
|
||||
interface CaptureResultOptions {
|
||||
readonly frames: readonly TerminalFrame[];
|
||||
readonly exit: { readonly exitCode: number; readonly signal?: number };
|
||||
readonly samples: readonly ProcessSample[];
|
||||
readonly replay: LoopbackReplay;
|
||||
readonly before: SnapshotResult;
|
||||
readonly after: SnapshotResult;
|
||||
readonly truncated: boolean;
|
||||
readonly scenario: ProcessScenario;
|
||||
readonly rootPid: number;
|
||||
}
|
||||
|
||||
const normalizeSamples = (
|
||||
samples: readonly ProcessSample[],
|
||||
scenario: ProcessScenario,
|
||||
rootPid: number,
|
||||
): readonly ProcessSample[] => {
|
||||
const identifiers = [
|
||||
rootPid,
|
||||
...samples.flatMap((sample) => [sample.pid, sample.parent_pid]),
|
||||
];
|
||||
const mapping = new Map<number, number>();
|
||||
for (const identifier of identifiers)
|
||||
if (identifier > 0 && !mapping.has(identifier))
|
||||
mapping.set(identifier, mapping.size + 1);
|
||||
return samples.map((sample) => ({
|
||||
at_ms:
|
||||
Math.floor(sample.at_ms / scenario.normalization.time_bucket_ms) *
|
||||
scenario.normalization.time_bucket_ms,
|
||||
pid: mapping.get(sample.pid) ?? 1,
|
||||
parent_pid: mapping.get(sample.parent_pid) ?? 0,
|
||||
command: normalizeTerminalData(
|
||||
sample.command,
|
||||
scenario,
|
||||
"<no-temporary-root>",
|
||||
rootPid,
|
||||
),
|
||||
}));
|
||||
};
|
||||
|
||||
const redactProtocolEvents = (
|
||||
events: readonly ProcessCapture["protocol_events"][number][],
|
||||
scenario: ProcessScenario,
|
||||
): readonly ProcessCapture["protocol_events"][number][] =>
|
||||
events.map((event) => ({
|
||||
...event,
|
||||
data: normalizeTerminalData(
|
||||
event.data,
|
||||
scenario,
|
||||
"<no-temporary-root>",
|
||||
-1,
|
||||
),
|
||||
}));
|
||||
|
||||
const buildCaptureResult = (options: CaptureResultOptions): ProcessCapture => ({
|
||||
schema_version: 1,
|
||||
frames: options.frames,
|
||||
exit: {
|
||||
code: options.exit.exitCode < 0 ? null : options.exit.exitCode,
|
||||
signal: options.exit.signal ?? null,
|
||||
},
|
||||
process_samples: normalizeSamples(
|
||||
options.samples,
|
||||
options.scenario,
|
||||
options.rootPid,
|
||||
),
|
||||
protocol_events: redactProtocolEvents(
|
||||
options.replay.events,
|
||||
options.scenario,
|
||||
),
|
||||
files_before: options.before.files,
|
||||
files_after: options.after.files,
|
||||
truncated: options.truncated,
|
||||
limitations: [
|
||||
"Process trees are sampled and may omit short-lived descendants.",
|
||||
"Filesystem observations are before/after snapshots, not syscall traces.",
|
||||
"The harness does not enforce external network isolation.",
|
||||
],
|
||||
});
|
||||
|
||||
const awaitTerminalExit = async ({
|
||||
terminal,
|
||||
scenario,
|
||||
started,
|
||||
lastOutput,
|
||||
signal,
|
||||
timers,
|
||||
}: TerminalExitOptions): Promise<{ exitCode: number; signal?: number }> =>
|
||||
new Promise((resolveExit) => {
|
||||
terminal.onExit(resolveExit);
|
||||
const timeout = setInterval(() => {
|
||||
if (
|
||||
Date.now() - started >= scenario.timeout_ms ||
|
||||
Date.now() - lastOutput() >= scenario.idle_timeout_ms ||
|
||||
signal?.aborted === true
|
||||
) {
|
||||
terminal.kill("SIGKILL");
|
||||
}
|
||||
}, 20);
|
||||
timers.add(timeout);
|
||||
});
|
||||
|
||||
/** Execute one authorized scenario and return bounded observations. */
|
||||
const runProcessScenario = async (
|
||||
scenario: ProcessScenario,
|
||||
policy: ProcessExecutionPolicy,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ProcessCapture> => {
|
||||
const decision = authorizeProcessScenario(scenario, policy);
|
||||
if (!decision.allowed) throw new ProcessCaptureError(decision.reason);
|
||||
await assertRealPathAuthority(scenario, policy);
|
||||
if (signal?.aborted === true)
|
||||
throw new ProcessCaptureError("process capture was cancelled");
|
||||
|
||||
const temporaryRoot = await mkdtemp(join(tmpdir(), "rea-process-"));
|
||||
const home = join(temporaryRoot, "home");
|
||||
await import("node:fs/promises").then(({ mkdir }) => mkdir(home));
|
||||
const before = await snapshotRoots(scenario);
|
||||
const frames: TerminalFrame[] = [];
|
||||
const samples: ProcessSample[] = [];
|
||||
let outputBytes = 0;
|
||||
let truncated = before.truncated;
|
||||
let terminal: IPty | undefined;
|
||||
let replay: LoopbackReplay | undefined;
|
||||
const started = Date.now();
|
||||
let lastOutput = started;
|
||||
const timers = new Set<NodeJS.Timeout>();
|
||||
|
||||
try {
|
||||
replay = await startLoopbackReplay(scenario);
|
||||
terminal = spawn(scenario.executable, [...scenario.arguments], {
|
||||
cwd: scenario.working_directory,
|
||||
env: makeEnvironment(scenario, home, replay),
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
name: "xterm-256color",
|
||||
});
|
||||
terminal.onData((data) => {
|
||||
lastOutput = Date.now();
|
||||
const bytes = Buffer.byteLength(data);
|
||||
if (
|
||||
frames.length >= scenario.limits.frames ||
|
||||
outputBytes + bytes > scenario.limits.output_bytes
|
||||
) {
|
||||
truncated = true;
|
||||
return;
|
||||
}
|
||||
outputBytes += bytes;
|
||||
frames.push({
|
||||
sequence: frames.length,
|
||||
at_ms:
|
||||
Math.floor(
|
||||
(Date.now() - started) / scenario.normalization.time_bucket_ms,
|
||||
) * scenario.normalization.time_bucket_ms,
|
||||
data: normalizeTerminalData(
|
||||
data,
|
||||
scenario,
|
||||
temporaryRoot,
|
||||
terminal?.pid ?? -1,
|
||||
),
|
||||
});
|
||||
});
|
||||
scheduleScenarioEvents(scenario, () => terminal, timers);
|
||||
const sampler = setInterval(() => {
|
||||
void sampleProcesses(
|
||||
terminal?.pid ?? -1,
|
||||
Date.now() - started,
|
||||
scenario.limits.processes - samples.length,
|
||||
)
|
||||
.then((values) => samples.push(...values))
|
||||
.catch(() => undefined);
|
||||
}, 50);
|
||||
timers.add(sampler);
|
||||
const exit = await awaitTerminalExit({
|
||||
terminal,
|
||||
scenario,
|
||||
started,
|
||||
lastOutput: () => lastOutput,
|
||||
signal,
|
||||
timers,
|
||||
});
|
||||
await new Promise((resolveSettle) =>
|
||||
setTimeout(resolveSettle, scenario.settle_ms),
|
||||
);
|
||||
const after = await snapshotRoots(scenario);
|
||||
truncated ||=
|
||||
after.truncated || samples.length >= scenario.limits.processes;
|
||||
return buildCaptureResult({
|
||||
frames,
|
||||
exit,
|
||||
samples,
|
||||
replay,
|
||||
before,
|
||||
after,
|
||||
truncated,
|
||||
scenario,
|
||||
rootPid: terminal.pid,
|
||||
});
|
||||
} catch (cause: unknown) {
|
||||
terminal?.kill("SIGKILL");
|
||||
throw new ProcessCaptureError("process capture failed", { cause });
|
||||
} finally {
|
||||
for (const timer of timers) clearTimeout(timer);
|
||||
await replay?.close();
|
||||
await rm(temporaryRoot, { recursive: true, force: true });
|
||||
}
|
||||
};
|
||||
|
||||
/** Execute one scenario through a typed expected-failure channel. */
|
||||
export const captureProcessScenario = async (
|
||||
scenario: ProcessScenario,
|
||||
policy: ProcessExecutionPolicy,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Result<ProcessCapture, ProcessCaptureError>> => {
|
||||
try {
|
||||
return ok(await runProcessScenario(scenario, policy, signal));
|
||||
} catch (cause: unknown) {
|
||||
return err(
|
||||
cause instanceof ProcessCaptureError
|
||||
? cause
|
||||
: new ProcessCaptureError("process capture failed", { cause }),
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -3,6 +3,7 @@ import { z } from "zod";
|
||||
import { ConfigurationError } from "./domain/errors.js";
|
||||
import { err, ok, type Result } from "./domain/result.js";
|
||||
import type { LogLevel } from "./logger.js";
|
||||
import type { ProcessExecutionPolicy } from "./domain/processCapture.js";
|
||||
|
||||
const DEFAULT_HOPPER_LAUNCHER_PATH =
|
||||
"/Applications/Hopper Disassembler.app/Contents/MacOS/hopper";
|
||||
@@ -13,6 +14,7 @@ export interface AppConfig {
|
||||
readonly hopperTargetKind: "executable" | "database";
|
||||
readonly hopperLoaderArgs: readonly string[];
|
||||
readonly logLevel: LogLevel;
|
||||
readonly processExecutionPolicy: ProcessExecutionPolicy;
|
||||
}
|
||||
|
||||
const environmentSchema = z.object({
|
||||
@@ -23,8 +25,33 @@ const environmentSchema = z.object({
|
||||
REA_LOG_LEVEL: z
|
||||
.enum(["trace", "debug", "info", "warn", "error", "fatal", "silent"])
|
||||
.default("info"),
|
||||
REA_PROCESS_CAPTURE_ENABLED: z.enum(["true", "false"]).default("false"),
|
||||
REA_PROCESS_EXECUTABLE_ROOTS_JSON: z.string().default("[]"),
|
||||
REA_PROCESS_WORKING_ROOTS_JSON: z.string().default("[]"),
|
||||
REA_PROCESS_ALLOWED_ENV_JSON: z.string().default("[]"),
|
||||
});
|
||||
|
||||
const parseStringArray = (
|
||||
encoded: string,
|
||||
name: string,
|
||||
): Result<readonly string[], ConfigurationError> => {
|
||||
try {
|
||||
const parsed = z
|
||||
.array(z.string().min(1))
|
||||
.max(128)
|
||||
.safeParse(JSON.parse(encoded));
|
||||
return parsed.success
|
||||
? ok(parsed.data)
|
||||
: err(
|
||||
new ConfigurationError(`${name} must encode an array of strings`, {
|
||||
cause: parsed.error,
|
||||
}),
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
return err(new ConfigurationError(`${name} must be valid JSON`, { cause }));
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse Hopper launcher configuration once at the composition root.
|
||||
* Explicit loader arguments override REA's header-derived defaults for a
|
||||
@@ -64,6 +91,21 @@ export const parseConfig = (
|
||||
),
|
||||
);
|
||||
}
|
||||
const executableRoots = parseStringArray(
|
||||
parsedEnvironment.data.REA_PROCESS_EXECUTABLE_ROOTS_JSON,
|
||||
"REA_PROCESS_EXECUTABLE_ROOTS_JSON",
|
||||
);
|
||||
if (!executableRoots.ok) return executableRoots;
|
||||
const workingRoots = parseStringArray(
|
||||
parsedEnvironment.data.REA_PROCESS_WORKING_ROOTS_JSON,
|
||||
"REA_PROCESS_WORKING_ROOTS_JSON",
|
||||
);
|
||||
if (!workingRoots.ok) return workingRoots;
|
||||
const allowedEnvironment = parseStringArray(
|
||||
parsedEnvironment.data.REA_PROCESS_ALLOWED_ENV_JSON,
|
||||
"REA_PROCESS_ALLOWED_ENV_JSON",
|
||||
);
|
||||
if (!allowedEnvironment.ok) return allowedEnvironment;
|
||||
return ok({
|
||||
hopperLauncherPath:
|
||||
parsedEnvironment.data.HOPPER_LAUNCHER_PATH ??
|
||||
@@ -72,5 +114,11 @@ export const parseConfig = (
|
||||
hopperTargetKind: parsedEnvironment.data.HOPPER_TARGET_KIND,
|
||||
hopperLoaderArgs: parsedArgs.data,
|
||||
logLevel: parsedEnvironment.data.REA_LOG_LEVEL,
|
||||
processExecutionPolicy: {
|
||||
enabled: parsedEnvironment.data.REA_PROCESS_CAPTURE_ENABLED === "true",
|
||||
executableRoots: executableRoots.value,
|
||||
workingRoots: workingRoots.value,
|
||||
allowedEnvironment: allowedEnvironment.value,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -28,6 +28,29 @@ export const enhancedInputSchemas = {
|
||||
limit: z.number().int().min(1).max(500).default(100),
|
||||
max_pseudocode_chars: z.number().int().min(1).max(100_000).default(20_000),
|
||||
max_instructions: z.number().int().min(1).max(5_000).default(500),
|
||||
pseudocode_offset: z.number().int().min(0).default(0),
|
||||
assembly_offset: z.number().int().min(0).default(0),
|
||||
collection_offset: z
|
||||
.object({
|
||||
comments: z.number().int().min(0).default(0),
|
||||
callers: z.number().int().min(0).default(0),
|
||||
callees: z.number().int().min(0).default(0),
|
||||
incoming_references: z.number().int().min(0).default(0),
|
||||
outgoing_references: z.number().int().min(0).default(0),
|
||||
referenced_strings: z.number().int().min(0).default(0),
|
||||
referenced_names: z.number().int().min(0).default(0),
|
||||
basic_blocks: z.number().int().min(0).default(0),
|
||||
})
|
||||
.default({
|
||||
comments: 0,
|
||||
callers: 0,
|
||||
callees: 0,
|
||||
incoming_references: 0,
|
||||
outgoing_references: 0,
|
||||
referenced_strings: 0,
|
||||
referenced_names: 0,
|
||||
basic_blocks: 0,
|
||||
}),
|
||||
}),
|
||||
trace_feature: z.object({
|
||||
query: z.string().min(1),
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { z } from "zod";
|
||||
import { evidenceBundleSchema } from "../domain/evidenceBundle.js";
|
||||
import {
|
||||
processCaptureSchema,
|
||||
processScenarioSchema,
|
||||
} from "../domain/processCapture.js";
|
||||
|
||||
import { enhancedInputSchemas } from "./enhancedInputs.js";
|
||||
import {
|
||||
@@ -44,6 +49,7 @@ export interface ToolContract {
|
||||
const annotations = (name: string, kind: ToolKind): ToolAnnotations => ({
|
||||
readOnlyHint:
|
||||
kind === "enhanced" ||
|
||||
name === "export_evidence_bundle" ||
|
||||
(!name.startsWith("set_") &&
|
||||
name !== "unset_bookmark" &&
|
||||
name !== "goto_address" &&
|
||||
@@ -354,11 +360,36 @@ export const SESSION_TOOL_CONTRACTS = [
|
||||
"Report whether a target is open and, when open, its canonical path, format, and kind. Use before analysis calls or target switches; this performs no analysis.",
|
||||
z.object({}),
|
||||
),
|
||||
session(
|
||||
"export_evidence_bundle",
|
||||
"Return the session's deterministic Evidence v2 bundle without clearing it. Records are sorted by evidence ID, and array order carries no investigative meaning.",
|
||||
z.object({}),
|
||||
),
|
||||
session(
|
||||
"import_evidence_bundle",
|
||||
"Validate and atomically merge a local Evidence v2 bundle supplied as data. Semantic IDs are recomputed; tampering, unsupported versions, conflicts, and ledger overflow reject the entire import.",
|
||||
z.object({ bundle: evidenceBundleSchema }),
|
||||
),
|
||||
session(
|
||||
"capture_process_scenario",
|
||||
"Run one bounded process under a PTY using operator-approved executable and working roots. Requires approved: true on every call. Captures normalized terminal frames, sampled descendants, filesystem snapshots, and loopback HTTP/WebSocket replay. This launches a process and is disabled unless operator policy enables it; it is not a security sandbox.",
|
||||
processScenarioSchema,
|
||||
),
|
||||
session(
|
||||
"compare_process_captures",
|
||||
"Compare two bounded process captures across terminal, exit, sampled process, filesystem, HTTP, and WebSocket evidence. Missing or truncated observations are never treated as equivalent.",
|
||||
z.object({
|
||||
left_evidence_id: z.string().regex(/^ev_[a-f0-9]{64}$/u),
|
||||
left: processCaptureSchema,
|
||||
right_evidence_id: z.string().regex(/^ev_[a-f0-9]{64}$/u),
|
||||
right: processCaptureSchema,
|
||||
}),
|
||||
),
|
||||
] as const satisfies readonly ToolContract[];
|
||||
|
||||
/**
|
||||
* Complete ordered public inventory used by registration and verification.
|
||||
* Keep this collection at 46 tools unless a deliberate contract change updates
|
||||
* Keep this collection at 50 tools unless a deliberate contract change updates
|
||||
* snapshots, package verification, and real-Hopper verification together.
|
||||
*/
|
||||
export const TOOL_CONTRACTS = [
|
||||
|
||||
@@ -1,24 +1,19 @@
|
||||
import { z } from "zod";
|
||||
import { evidenceSchema } from "../domain/evidence.js";
|
||||
import { processCaptureSchema } from "../domain/processCapture.js";
|
||||
import { evidenceBundleSchema } from "../domain/evidenceBundle.js";
|
||||
|
||||
const evidenceMetadata = {
|
||||
schema_version: z.literal(1),
|
||||
artifact: z
|
||||
.object({
|
||||
path: z.string(),
|
||||
sha256: z.string().regex(/^[a-f0-9]{64}$/u),
|
||||
format: z.enum(["hopper", "mach-o", "elf", "pe"]),
|
||||
architecture: z.enum(["x86", "x86_64", "arm", "arm64"]).nullable(),
|
||||
})
|
||||
.nullable(),
|
||||
provider: z.object({ id: z.literal("hopper"), version: z.null() }),
|
||||
operation: z.string().min(1),
|
||||
parameters: z.record(z.string(), z.json()),
|
||||
confidence: z.literal("observed"),
|
||||
limitations: z.array(z.string()),
|
||||
};
|
||||
const resultOf = (schema: z.ZodType) =>
|
||||
z.object({ ...evidenceMetadata, result: schema });
|
||||
evidenceSchema.omit({ result: true }).extend({ result: schema });
|
||||
const lifecycleResultOf = (schema: z.ZodType) => z.object({ result: schema });
|
||||
const comparisonStatus = z.enum([
|
||||
"unchanged",
|
||||
"added",
|
||||
"removed",
|
||||
"changed",
|
||||
"truncated",
|
||||
"unknown",
|
||||
]);
|
||||
const nullableText = z.string().nullable();
|
||||
const addressList = z.array(z.string());
|
||||
const addressedEntry = z.object({ address: z.string(), name: z.string() });
|
||||
@@ -92,7 +87,18 @@ const graphNode = z.union([
|
||||
z.object({ address: z.string(), calls: z.array(z.string()) }),
|
||||
z.object({ address: z.string(), error: z.string() }),
|
||||
]);
|
||||
const unavailableOrBoundedStrings = z.union([unavailable, bounded(z.string())]);
|
||||
const referenceEdge = z.object({
|
||||
source_address: z.string(),
|
||||
target_address: z.string(),
|
||||
source_procedure: procedureIdentity.nullable(),
|
||||
target_procedure: procedureIdentity.nullable(),
|
||||
kind: unavailable,
|
||||
});
|
||||
const referencedValue = z.object({
|
||||
address: z.string(),
|
||||
value: z.string(),
|
||||
source_address: z.string(),
|
||||
});
|
||||
const functionDossierOutput = resultOf(
|
||||
z.object({
|
||||
procedure: z.object({
|
||||
@@ -116,18 +122,23 @@ const functionDossierOutput = resultOf(
|
||||
text: z.string(),
|
||||
}),
|
||||
),
|
||||
callers: bounded(z.string()),
|
||||
callees: bounded(z.string()),
|
||||
incoming_references: bounded(z.string()),
|
||||
referenced_strings: unavailableOrBoundedStrings,
|
||||
referenced_names: unavailableOrBoundedStrings,
|
||||
callers: bounded(procedureIdentity),
|
||||
callees: bounded(procedureIdentity),
|
||||
incoming_references: bounded(referenceEdge),
|
||||
outgoing_references: bounded(referenceEdge),
|
||||
referenced_strings: bounded(referencedValue),
|
||||
referenced_names: bounded(referencedValue),
|
||||
basic_blocks: bounded(
|
||||
z.object({
|
||||
start: z.string(),
|
||||
end: z.string(),
|
||||
successors: z.union([unavailable, z.array(z.string())]),
|
||||
successors: z.array(z.string()),
|
||||
}),
|
||||
),
|
||||
instruction_scan: z.object({
|
||||
scanned: z.number().int().min(0),
|
||||
truncated: z.boolean(),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -284,4 +295,29 @@ export const sessionOutputSchemas: Readonly<Record<string, z.ZodObject>> = {
|
||||
}),
|
||||
]),
|
||||
),
|
||||
export_evidence_bundle: lifecycleResultOf(evidenceBundleSchema),
|
||||
import_evidence_bundle: lifecycleResultOf(
|
||||
z.object({
|
||||
imported: z.number().int().min(0),
|
||||
total: z.number().int().min(0),
|
||||
}),
|
||||
),
|
||||
capture_process_scenario: lifecycleResultOf(
|
||||
evidenceSchema
|
||||
.omit({ result: true })
|
||||
.extend({ result: processCaptureSchema }),
|
||||
),
|
||||
compare_process_captures: lifecycleResultOf(
|
||||
evidenceSchema.omit({ result: true }).extend({
|
||||
result: z.object({
|
||||
status: comparisonStatus,
|
||||
terminal: comparisonStatus,
|
||||
exit: comparisonStatus,
|
||||
filesystem: comparisonStatus,
|
||||
protocol: comparisonStatus,
|
||||
process: comparisonStatus,
|
||||
limitations: z.array(z.string()),
|
||||
}),
|
||||
}),
|
||||
),
|
||||
};
|
||||
|
||||
@@ -8,7 +8,12 @@ export abstract class HopperError extends AnalysisError {}
|
||||
|
||||
/** Provider-neutral invalid analysis input or output at an application boundary. */
|
||||
export class AnalysisProtocolError extends AnalysisError {
|
||||
readonly _tag = "HopperProtocolError";
|
||||
readonly _tag = "AnalysisProtocolError";
|
||||
}
|
||||
|
||||
/** An evidence bundle or bounded session ledger rejected caller-controlled data. */
|
||||
export class EvidenceLedgerError extends AnalysisError {
|
||||
readonly _tag = "EvidenceLedgerError";
|
||||
}
|
||||
|
||||
/** Hopper did not respond within the configured operation deadline. */
|
||||
|
||||
+163
-27
@@ -1,62 +1,198 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import canonicalize from "canonicalize";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { BinaryTarget } from "./binaryTarget.js";
|
||||
import { jsonValueSchema, type JsonValue } from "./jsonValue.js";
|
||||
|
||||
const artifactEvidenceSchema = z.object({
|
||||
path: z.string(),
|
||||
sha256: z.string().regex(/^[a-f0-9]{64}$/u),
|
||||
const digestSchema = z.string().regex(/^[a-f0-9]{64}$/u);
|
||||
const providerSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
version: z.string().nullable(),
|
||||
});
|
||||
const subjectSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
digest: z.object({ sha256: digestSchema }),
|
||||
format: z.enum(["hopper", "mach-o", "elf", "pe"]),
|
||||
architecture: z.enum(["x86", "x86_64", "arm", "arm64"]).nullable(),
|
||||
local_path: z.string(),
|
||||
});
|
||||
const locationSchema = z.discriminatedUnion("kind", [
|
||||
z.object({ kind: z.literal("address"), address: z.string().min(1) }),
|
||||
z.object({
|
||||
kind: z.literal("address-range"),
|
||||
start: z.string().min(1),
|
||||
end: z.string().min(1),
|
||||
}),
|
||||
z.object({ kind: z.literal("artifact-path"), path: z.string().min(1) }),
|
||||
]);
|
||||
|
||||
const evidenceAuthoritySchema = z.enum([
|
||||
"shipped-artifact",
|
||||
"controlled-replay",
|
||||
"historical-reference",
|
||||
"external-service",
|
||||
"analyst-inference",
|
||||
]);
|
||||
|
||||
const executionEnvironmentSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
platform: z.string().min(1),
|
||||
architecture: z.string().min(1),
|
||||
isolation: z.enum(["none", "process", "container", "virtual-machine"]),
|
||||
});
|
||||
|
||||
/** Strict JSON representation of one successful public analysis observation. */
|
||||
/** Strict, provider-neutral record for one successful public observation. */
|
||||
export const evidenceSchema = z.object({
|
||||
schema_version: z.literal(1),
|
||||
artifact: artifactEvidenceSchema.nullable(),
|
||||
provider: z.object({ id: z.literal("hopper"), version: z.null() }),
|
||||
schema_version: z.literal(2),
|
||||
evidence_id: z.string().regex(/^ev_[a-f0-9]{64}$/u),
|
||||
subject: subjectSchema.nullable(),
|
||||
provider: providerSchema,
|
||||
predicate_type: z.string().min(1),
|
||||
operation: z.string().min(1),
|
||||
parameters: z.record(z.string(), jsonValueSchema),
|
||||
result: jsonValueSchema,
|
||||
confidence: z.literal("observed"),
|
||||
raw_payload_sha256: digestSchema.nullable(),
|
||||
confidence: z.enum(["observed", "derived", "inferred"]),
|
||||
authority: evidenceAuthoritySchema,
|
||||
environment: executionEnvironmentSchema.nullable(),
|
||||
limitations: z.array(z.string()),
|
||||
locations: z.array(locationSchema),
|
||||
evidence_links: z.array(z.string().regex(/^ev_[a-f0-9]{64}$/u)),
|
||||
});
|
||||
|
||||
export type Evidence = z.infer<typeof evidenceSchema>;
|
||||
type EvidenceLocation = z.infer<typeof locationSchema>;
|
||||
type EvidenceAuthority = z.infer<typeof evidenceAuthoritySchema>;
|
||||
type ExecutionEnvironment = z.infer<typeof executionEnvironmentSchema>;
|
||||
|
||||
export interface EvidenceProvider {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly version: string | null;
|
||||
}
|
||||
|
||||
export interface EvidenceObservation {
|
||||
readonly predicateType?: string;
|
||||
readonly operation: string;
|
||||
readonly parameters: Readonly<Record<string, JsonValue>>;
|
||||
readonly result: JsonValue;
|
||||
readonly redactedRawPayload?: JsonValue;
|
||||
readonly confidence?: "observed" | "derived" | "inferred";
|
||||
readonly authority?: EvidenceAuthority;
|
||||
readonly environment?: ExecutionEnvironment | null;
|
||||
readonly limitations?: readonly string[];
|
||||
readonly locations?: readonly EvidenceLocation[];
|
||||
readonly evidenceLinks?: readonly string[];
|
||||
}
|
||||
|
||||
/** Build deterministic evidence without claiming unavailable provider metadata. */
|
||||
const sha256 = (value: string): string =>
|
||||
createHash("sha256").update(value).digest("hex");
|
||||
|
||||
/** Serialize JSON according to RFC 8785 JSON Canonicalization Scheme. */
|
||||
const canonicalJson = (value: JsonValue): string => {
|
||||
const serialized = canonicalize(value);
|
||||
if (serialized === undefined)
|
||||
throw new TypeError("RFC 8785 canonicalization rejected a JSON value");
|
||||
return serialized;
|
||||
};
|
||||
|
||||
const semanticProjection = (
|
||||
evidence: Omit<Evidence, "evidence_id">,
|
||||
): JsonValue => ({
|
||||
schema_version: evidence.schema_version,
|
||||
subject:
|
||||
evidence.subject === null
|
||||
? null
|
||||
: {
|
||||
digest: evidence.subject.digest,
|
||||
format: evidence.subject.format,
|
||||
architecture: evidence.subject.architecture,
|
||||
},
|
||||
provider: evidence.provider,
|
||||
predicate_type: evidence.predicate_type,
|
||||
operation: evidence.operation,
|
||||
parameters: evidence.parameters,
|
||||
result: evidence.result,
|
||||
confidence: evidence.confidence,
|
||||
authority: evidence.authority,
|
||||
environment: evidence.environment,
|
||||
limitations: evidence.limitations,
|
||||
locations: evidence.locations,
|
||||
evidence_links: evidence.evidence_links,
|
||||
});
|
||||
|
||||
/** Recompute the semantic identifier, excluding paths and raw payload bytes. */
|
||||
const computeEvidenceId = (evidence: Omit<Evidence, "evidence_id">): string =>
|
||||
`ev_${sha256(canonicalJson(semanticProjection(evidence)))}`;
|
||||
|
||||
/** Parse evidence and reject a syntactically valid but tampered semantic ID. */
|
||||
export const parseEvidence = (input: unknown): Evidence => {
|
||||
const evidence = evidenceSchema.parse(input);
|
||||
const { evidence_id: evidenceId, ...withoutId } = evidence;
|
||||
if (computeEvidenceId(withoutId) !== evidenceId)
|
||||
throw new TypeError(
|
||||
"Evidence semantic identifier does not match its record",
|
||||
);
|
||||
return evidence;
|
||||
};
|
||||
|
||||
/** Build deterministic Evidence v2 from an immutable artifact subject. */
|
||||
export const createEvidence = (
|
||||
target: BinaryTarget | undefined,
|
||||
provider: EvidenceProvider,
|
||||
observation: EvidenceObservation,
|
||||
): Evidence =>
|
||||
evidenceSchema.parse({
|
||||
schema_version: 1,
|
||||
artifact:
|
||||
target === undefined
|
||||
): Evidence => {
|
||||
const subject =
|
||||
target === undefined
|
||||
? null
|
||||
: {
|
||||
name: target.path.split("/").at(-1) ?? target.path,
|
||||
digest: { sha256: target.sha256 },
|
||||
format: target.format,
|
||||
architecture: target.architecture ?? null,
|
||||
local_path: target.path,
|
||||
};
|
||||
const semantic = {
|
||||
schema_version: 2,
|
||||
subject:
|
||||
subject === null
|
||||
? null
|
||||
: {
|
||||
path: target.path,
|
||||
sha256: target.sha256,
|
||||
format: target.format,
|
||||
architecture: target.architecture ?? null,
|
||||
digest: subject.digest,
|
||||
format: subject.format,
|
||||
architecture: subject.architecture,
|
||||
},
|
||||
provider: { id: "hopper", version: null },
|
||||
provider: {
|
||||
id: provider.id,
|
||||
name: provider.name,
|
||||
version: provider.version,
|
||||
},
|
||||
predicate_type: observation.predicateType ?? "rea.analysis/v2",
|
||||
operation: observation.operation,
|
||||
parameters: observation.parameters,
|
||||
result: observation.result,
|
||||
confidence: "observed",
|
||||
limitations:
|
||||
target === undefined
|
||||
? [
|
||||
"Artifact identity is unavailable for this fixed-target adapter.",
|
||||
...(observation.limitations ?? []),
|
||||
]
|
||||
: [...(observation.limitations ?? [])],
|
||||
confidence: observation.confidence ?? "observed",
|
||||
authority: observation.authority ?? "shipped-artifact",
|
||||
environment: observation.environment ?? null,
|
||||
limitations: [
|
||||
...(target === undefined
|
||||
? ["Artifact identity is unavailable for this observation."]
|
||||
: []),
|
||||
...(observation.limitations ?? []),
|
||||
],
|
||||
locations: [...(observation.locations ?? [])],
|
||||
evidence_links: [...(observation.evidenceLinks ?? [])],
|
||||
} satisfies JsonValue;
|
||||
return evidenceSchema.parse({
|
||||
...semantic,
|
||||
evidence_id: `ev_${sha256(canonicalJson(semantic))}`,
|
||||
subject,
|
||||
raw_payload_sha256:
|
||||
observation.redactedRawPayload === undefined
|
||||
? null
|
||||
: sha256(canonicalJson(observation.redactedRawPayload)),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { evidenceSchema, type Evidence } from "./evidence.js";
|
||||
|
||||
export const evidenceBundleSchema = z.object({
|
||||
bundle_version: z.literal(1),
|
||||
records: z.array(evidenceSchema),
|
||||
});
|
||||
|
||||
export type EvidenceBundle = z.infer<typeof evidenceBundleSchema>;
|
||||
|
||||
/** Project records into a deterministic bundle whose order has no semantics. */
|
||||
export const createEvidenceBundle = (
|
||||
records: readonly Evidence[],
|
||||
): EvidenceBundle => ({
|
||||
bundle_version: 1,
|
||||
records: [...records].sort((left, right) =>
|
||||
left.evidence_id.localeCompare(right.evidence_id),
|
||||
),
|
||||
});
|
||||
+151
-1
@@ -1,7 +1,7 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import type { JsonValue } from "./jsonValue.js";
|
||||
import { HopperProtocolError } from "./errors.js";
|
||||
import { AnalysisProtocolError, HopperProtocolError } from "./errors.js";
|
||||
import { err, ok, type Result } from "./result.js";
|
||||
|
||||
export interface AddressedName {
|
||||
@@ -36,6 +36,156 @@ const addressedPageSchema = z.object({
|
||||
next_offset: z.number().int().min(0).nullable(),
|
||||
has_more: z.boolean(),
|
||||
});
|
||||
const unavailableSchema = z
|
||||
.object({ available: z.literal(false), reason: z.string() })
|
||||
.strict();
|
||||
const procedureIdentitySchema = z
|
||||
.object({ address: z.string(), name: z.string() })
|
||||
.strict();
|
||||
const boundedSchema = <T extends z.ZodType>(item: T) =>
|
||||
z
|
||||
.object({
|
||||
items: z.array(item),
|
||||
total: z.number().int().min(0).nullable(),
|
||||
returned: z.number().int().min(0),
|
||||
truncated: z.boolean(),
|
||||
next_offset: z.number().int().min(0).nullable(),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((value, context) => {
|
||||
if (value.returned !== value.items.length) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "returned must equal the number of items",
|
||||
path: ["returned"],
|
||||
});
|
||||
}
|
||||
if (value.total !== null && value.total < value.returned) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "total cannot be smaller than returned",
|
||||
path: ["total"],
|
||||
});
|
||||
}
|
||||
if (value.next_offset !== null && !value.truncated) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "a continuation requires truncated output",
|
||||
path: ["next_offset"],
|
||||
});
|
||||
}
|
||||
});
|
||||
const referenceEdgeSchema = z
|
||||
.object({
|
||||
source_address: z.string(),
|
||||
target_address: z.string(),
|
||||
source_procedure: procedureIdentitySchema.nullable(),
|
||||
target_procedure: procedureIdentitySchema.nullable(),
|
||||
kind: unavailableSchema,
|
||||
})
|
||||
.strict();
|
||||
const functionDossierSchema = z
|
||||
.object({
|
||||
procedure: z
|
||||
.object({
|
||||
address: z.string(),
|
||||
name: z.string(),
|
||||
signature: z.string().nullable(),
|
||||
locals: z.array(z.json()),
|
||||
})
|
||||
.strict(),
|
||||
pseudocode: z
|
||||
.object({
|
||||
text: z.string(),
|
||||
total_chars: z.number().int().min(0),
|
||||
returned_chars: z.number().int().min(0),
|
||||
truncated: z.boolean(),
|
||||
next_offset: z.number().int().min(0).nullable(),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((value, context) => {
|
||||
if (value.returned_chars !== value.text.length) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "returned_chars must equal the text length",
|
||||
path: ["returned_chars"],
|
||||
});
|
||||
}
|
||||
if (value.total_chars < value.returned_chars) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "total_chars cannot be smaller than returned_chars",
|
||||
path: ["total_chars"],
|
||||
});
|
||||
}
|
||||
if (value.next_offset !== null && !value.truncated) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "a continuation requires truncated pseudocode",
|
||||
path: ["next_offset"],
|
||||
});
|
||||
}
|
||||
}),
|
||||
assembly: boundedSchema(z.string()),
|
||||
comments: boundedSchema(
|
||||
z
|
||||
.object({
|
||||
address: z.string(),
|
||||
kind: z.enum(["comment", "inline"]),
|
||||
text: z.string(),
|
||||
})
|
||||
.strict(),
|
||||
),
|
||||
callers: boundedSchema(procedureIdentitySchema),
|
||||
callees: boundedSchema(procedureIdentitySchema),
|
||||
incoming_references: boundedSchema(referenceEdgeSchema),
|
||||
outgoing_references: boundedSchema(referenceEdgeSchema),
|
||||
referenced_strings: boundedSchema(
|
||||
z
|
||||
.object({
|
||||
address: z.string(),
|
||||
value: z.string(),
|
||||
source_address: z.string(),
|
||||
})
|
||||
.strict(),
|
||||
),
|
||||
referenced_names: boundedSchema(
|
||||
z
|
||||
.object({
|
||||
address: z.string(),
|
||||
value: z.string(),
|
||||
source_address: z.string(),
|
||||
})
|
||||
.strict(),
|
||||
),
|
||||
basic_blocks: boundedSchema(
|
||||
z
|
||||
.object({
|
||||
start: z.string(),
|
||||
end: z.string(),
|
||||
successors: z.array(z.string()),
|
||||
})
|
||||
.strict(),
|
||||
),
|
||||
instruction_scan: z
|
||||
.object({ scanned: z.number().int().min(0), truncated: z.boolean() })
|
||||
.strict(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
/** Strictly parse a complete Hopper function dossier at the provider boundary. */
|
||||
export const parseFunctionDossier = (
|
||||
value: JsonValue,
|
||||
): Result<JsonValue, AnalysisProtocolError> => {
|
||||
const parsed = functionDossierSchema.safeParse(value);
|
||||
return parsed.success
|
||||
? ok(parsed.data)
|
||||
: err(
|
||||
new AnalysisProtocolError("Invalid analyze_function provider output", {
|
||||
cause: parsed.error,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
/** Parse page entries and continuation metadata returned by list operations. */
|
||||
export const parseAddressedPage = (
|
||||
|
||||
@@ -0,0 +1,396 @@
|
||||
import { resolve } from "node:path";
|
||||
import { z } from "zod";
|
||||
|
||||
const positiveBudget = z.number().int().positive();
|
||||
const timedEventBase = { at_ms: z.number().int().nonnegative() };
|
||||
|
||||
/** Exact boundary schema for bounded dynamic process scenarios. */
|
||||
export const processScenarioSchema = z
|
||||
.object({
|
||||
approved: z
|
||||
.literal(true)
|
||||
.describe(
|
||||
"Explicit per-call acknowledgement that this operation launches the target",
|
||||
),
|
||||
executable: z.string().startsWith("/"),
|
||||
arguments: z.array(z.string()).max(256).default([]),
|
||||
working_directory: z.string().startsWith("/"),
|
||||
environment: z.record(z.string(), z.string()).default({}),
|
||||
inherit_environment: z.array(z.string()).max(64).default([]),
|
||||
secret_aliases: z.array(z.string()).max(64).default([]),
|
||||
filesystem_roots: z.array(z.string().startsWith("/")).max(16).default([]),
|
||||
events: z
|
||||
.array(
|
||||
z.discriminatedUnion("type", [
|
||||
z.object({
|
||||
...timedEventBase,
|
||||
type: z.literal("input"),
|
||||
data: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
...timedEventBase,
|
||||
type: z.literal("resize"),
|
||||
columns: z.number().int().min(1).max(1_000),
|
||||
rows: z.number().int().min(1).max(1_000),
|
||||
}),
|
||||
z.object({
|
||||
...timedEventBase,
|
||||
type: z.literal("signal"),
|
||||
signal: z.enum(["SIGINT", "SIGTERM", "SIGKILL"]),
|
||||
}),
|
||||
]),
|
||||
)
|
||||
.max(1_000)
|
||||
.default([]),
|
||||
timeout_ms: positiveBudget.max(300_000).default(30_000),
|
||||
idle_timeout_ms: positiveBudget.max(300_000).default(30_000),
|
||||
settle_ms: z.number().int().nonnegative().max(10_000).default(100),
|
||||
limits: z
|
||||
.object({
|
||||
output_bytes: positiveBudget.max(10_000_000).default(1_000_000),
|
||||
frames: positiveBudget.max(100_000).default(10_000),
|
||||
files: positiveBudget.max(100_000).default(10_000),
|
||||
file_bytes: positiveBudget.max(100_000_000).default(10_000_000),
|
||||
processes: positiveBudget.max(10_000).default(1_000),
|
||||
})
|
||||
.default({
|
||||
output_bytes: 1_000_000,
|
||||
frames: 10_000,
|
||||
files: 10_000,
|
||||
file_bytes: 10_000_000,
|
||||
processes: 1_000,
|
||||
}),
|
||||
normalization: z
|
||||
.object({
|
||||
paths: z.boolean().default(true),
|
||||
pids: z.boolean().default(true),
|
||||
ports: z.boolean().default(true),
|
||||
time_bucket_ms: positiveBudget.max(60_000).default(10),
|
||||
patterns: z
|
||||
.array(
|
||||
z.object({
|
||||
pattern: z.string().max(500),
|
||||
replacement: z.string().max(100),
|
||||
}),
|
||||
)
|
||||
.max(32)
|
||||
.default([]),
|
||||
})
|
||||
.default({
|
||||
paths: true,
|
||||
pids: true,
|
||||
ports: true,
|
||||
time_bucket_ms: 10,
|
||||
patterns: [],
|
||||
}),
|
||||
replay: z
|
||||
.object({
|
||||
http: z
|
||||
.array(
|
||||
z.object({
|
||||
method: z.string().max(16),
|
||||
path: z.string().startsWith("/"),
|
||||
status: z.number().int().min(100).max(599),
|
||||
body: z.string().max(1_000_000),
|
||||
}),
|
||||
)
|
||||
.max(100)
|
||||
.default([]),
|
||||
websocket_messages: z
|
||||
.array(z.string().max(1_000_000))
|
||||
.max(100)
|
||||
.default([]),
|
||||
})
|
||||
.default({ http: [], websocket_messages: [] }),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((scenario, context) => {
|
||||
for (let index = 1; index < scenario.events.length; index += 1) {
|
||||
if (scenario.events[index]!.at_ms < scenario.events[index - 1]!.at_ms) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "events must be ordered by at_ms",
|
||||
path: ["events", index],
|
||||
});
|
||||
}
|
||||
}
|
||||
const secrets = new Set(scenario.secret_aliases);
|
||||
for (const alias of secrets) {
|
||||
if (!(alias in scenario.environment)) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "secret alias has no environment value",
|
||||
path: ["secret_aliases"],
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/** A parsed, bounded dynamic process observation scenario. */
|
||||
export type ProcessScenario = z.infer<typeof processScenarioSchema>;
|
||||
|
||||
/** Parse untrusted process scenario input and apply safe default budgets. */
|
||||
export const parseProcessScenario = (input: unknown): ProcessScenario =>
|
||||
processScenarioSchema.parse(input);
|
||||
|
||||
/** Operator-owned policy for process capture. */
|
||||
export interface ProcessExecutionPolicy {
|
||||
readonly enabled: boolean;
|
||||
readonly executableRoots: readonly string[];
|
||||
readonly workingRoots: readonly string[];
|
||||
readonly allowedEnvironment: readonly string[];
|
||||
}
|
||||
|
||||
/** A safe, caller-visible process-policy decision. */
|
||||
export type ProcessPolicyDecision =
|
||||
| { readonly allowed: true }
|
||||
| { readonly allowed: false; readonly reason: string };
|
||||
|
||||
const isWithin = (candidate: string, root: string): boolean =>
|
||||
resolve(candidate) === resolve(root) ||
|
||||
resolve(candidate).startsWith(
|
||||
`${resolve(root)}${resolve(root).endsWith("/") ? "" : "/"}`,
|
||||
);
|
||||
|
||||
/** Evaluate scenario authority before any process or filesystem side effect occurs. */
|
||||
export const authorizeProcessScenario = (
|
||||
scenario: ProcessScenario,
|
||||
policy: ProcessExecutionPolicy,
|
||||
): ProcessPolicyDecision => {
|
||||
if (!policy.enabled)
|
||||
return { allowed: false, reason: "process capture is disabled" };
|
||||
if (
|
||||
!policy.executableRoots.some((root) => isWithin(scenario.executable, root))
|
||||
) {
|
||||
return { allowed: false, reason: "executable is outside approved roots" };
|
||||
}
|
||||
if (
|
||||
!policy.workingRoots.some((root) =>
|
||||
isWithin(scenario.working_directory, root),
|
||||
)
|
||||
) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: "working directory is outside approved roots",
|
||||
};
|
||||
}
|
||||
const requestedNames = [
|
||||
...Object.keys(scenario.environment),
|
||||
...scenario.inherit_environment,
|
||||
];
|
||||
if (
|
||||
requestedNames.some((name) => !policy.allowedEnvironment.includes(name))
|
||||
) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: "scenario requests an environment variable not allowed by policy",
|
||||
};
|
||||
}
|
||||
if (
|
||||
scenario.filesystem_roots.some(
|
||||
(path) => !policy.workingRoots.some((root) => isWithin(path, root)),
|
||||
)
|
||||
) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: "filesystem root is outside approved roots",
|
||||
};
|
||||
}
|
||||
return { allowed: true };
|
||||
};
|
||||
|
||||
/** One bounded terminal observation. */
|
||||
export interface TerminalFrame {
|
||||
readonly sequence: number;
|
||||
readonly at_ms: number;
|
||||
readonly data: string;
|
||||
}
|
||||
|
||||
/** One filesystem state used for before/after comparison. */
|
||||
export interface FileState {
|
||||
readonly path: string;
|
||||
readonly type: "file" | "directory" | "symlink" | "other";
|
||||
readonly mode: number;
|
||||
readonly size: number;
|
||||
readonly sha256: string | null;
|
||||
readonly symlink_target: string | null;
|
||||
}
|
||||
|
||||
/** A sampled owned-process observation; sampling cannot prove syscall completeness. */
|
||||
export interface ProcessSample {
|
||||
readonly at_ms: number;
|
||||
readonly pid: number;
|
||||
readonly parent_pid: number;
|
||||
readonly command: string;
|
||||
}
|
||||
|
||||
/** A bounded loopback replay observation. */
|
||||
export interface ProtocolEvent {
|
||||
readonly sequence: number;
|
||||
readonly protocol: "http" | "websocket";
|
||||
readonly direction: "request" | "response" | "received" | "sent";
|
||||
readonly method: string | null;
|
||||
readonly path: string | null;
|
||||
readonly data: string;
|
||||
}
|
||||
|
||||
/** A bounded process observation with explicit incompleteness metadata. */
|
||||
export interface ProcessCapture {
|
||||
readonly schema_version: 1;
|
||||
readonly frames: readonly TerminalFrame[];
|
||||
readonly exit: {
|
||||
readonly code: number | null;
|
||||
readonly signal: number | null;
|
||||
};
|
||||
readonly process_samples: readonly ProcessSample[];
|
||||
readonly protocol_events: readonly ProtocolEvent[];
|
||||
readonly files_before: readonly FileState[];
|
||||
readonly files_after: readonly FileState[];
|
||||
readonly truncated: boolean;
|
||||
readonly limitations: readonly string[];
|
||||
}
|
||||
|
||||
/** Exact serialized shape of a bounded process capture. */
|
||||
export const processCaptureSchema: z.ZodType<ProcessCapture> = z.object({
|
||||
schema_version: z.literal(1),
|
||||
frames: z.array(
|
||||
z.object({
|
||||
sequence: z.number().int().nonnegative(),
|
||||
at_ms: z.number().int().nonnegative(),
|
||||
data: z.string(),
|
||||
}),
|
||||
),
|
||||
exit: z.object({
|
||||
code: z.number().int().nullable(),
|
||||
signal: z.number().int().nullable(),
|
||||
}),
|
||||
process_samples: z.array(
|
||||
z.object({
|
||||
at_ms: z.number().int().nonnegative(),
|
||||
pid: z.number().int().positive(),
|
||||
parent_pid: z.number().int().nonnegative(),
|
||||
command: z.string(),
|
||||
}),
|
||||
),
|
||||
protocol_events: z.array(
|
||||
z.object({
|
||||
sequence: z.number().int().nonnegative(),
|
||||
protocol: z.enum(["http", "websocket"]),
|
||||
direction: z.enum(["request", "response", "received", "sent"]),
|
||||
method: z.string().nullable(),
|
||||
path: z.string().nullable(),
|
||||
data: z.string(),
|
||||
}),
|
||||
),
|
||||
files_before: z.array(
|
||||
z.object({
|
||||
path: z.string(),
|
||||
type: z.enum(["file", "directory", "symlink", "other"]),
|
||||
mode: z.number().int().nonnegative(),
|
||||
size: z.number().int().nonnegative(),
|
||||
sha256: z
|
||||
.string()
|
||||
.regex(/^[a-f0-9]{64}$/u)
|
||||
.nullable(),
|
||||
symlink_target: z.string().nullable(),
|
||||
}),
|
||||
),
|
||||
files_after: z.array(
|
||||
z.object({
|
||||
path: z.string(),
|
||||
type: z.enum(["file", "directory", "symlink", "other"]),
|
||||
mode: z.number().int().nonnegative(),
|
||||
size: z.number().int().nonnegative(),
|
||||
sha256: z
|
||||
.string()
|
||||
.regex(/^[a-f0-9]{64}$/u)
|
||||
.nullable(),
|
||||
symlink_target: z.string().nullable(),
|
||||
}),
|
||||
),
|
||||
truncated: z.boolean(),
|
||||
limitations: z.array(z.string()),
|
||||
});
|
||||
|
||||
/** Comparison classification that never equates incomplete evidence. */
|
||||
type ComparisonStatus =
|
||||
| "unchanged"
|
||||
| "added"
|
||||
| "removed"
|
||||
| "changed"
|
||||
| "truncated"
|
||||
| "unknown";
|
||||
|
||||
/** Pure normalized comparison between two captures. */
|
||||
export interface ProcessCaptureComparison {
|
||||
readonly status: ComparisonStatus;
|
||||
readonly terminal: ComparisonStatus;
|
||||
readonly exit: ComparisonStatus;
|
||||
readonly filesystem: ComparisonStatus;
|
||||
readonly protocol: ComparisonStatus;
|
||||
readonly process: ComparisonStatus;
|
||||
readonly limitations: readonly string[];
|
||||
}
|
||||
|
||||
const stableFiles = (files: readonly FileState[]): string =>
|
||||
JSON.stringify(
|
||||
[...files].sort((left, right) => left.path.localeCompare(right.path)),
|
||||
);
|
||||
|
||||
/** Compare bounded captures without claiming equality for incomplete observations. */
|
||||
export const compareProcessCaptures = (
|
||||
left: ProcessCapture,
|
||||
right: ProcessCapture,
|
||||
): ProcessCaptureComparison => {
|
||||
if (left.truncated || right.truncated) {
|
||||
return {
|
||||
status: "truncated",
|
||||
terminal: "truncated",
|
||||
exit: "truncated",
|
||||
filesystem: "truncated",
|
||||
protocol: "truncated",
|
||||
process: "truncated",
|
||||
limitations: ["At least one capture is truncated."],
|
||||
};
|
||||
}
|
||||
const terminal =
|
||||
left.frames.map(({ data }) => data).join("") ===
|
||||
right.frames.map(({ data }) => data).join("")
|
||||
? "unchanged"
|
||||
: "changed";
|
||||
const exit =
|
||||
JSON.stringify(left.exit) === JSON.stringify(right.exit)
|
||||
? "unchanged"
|
||||
: "changed";
|
||||
const filesystem =
|
||||
stableFiles(left.files_after) === stableFiles(right.files_after)
|
||||
? "unchanged"
|
||||
: "changed";
|
||||
const protocol =
|
||||
JSON.stringify(left.protocol_events) ===
|
||||
JSON.stringify(right.protocol_events)
|
||||
? "unchanged"
|
||||
: "changed";
|
||||
const process =
|
||||
JSON.stringify(left.process_samples.map(({ command }) => command)) ===
|
||||
JSON.stringify(right.process_samples.map(({ command }) => command))
|
||||
? "unchanged"
|
||||
: "changed";
|
||||
const status =
|
||||
terminal === "unchanged" &&
|
||||
exit === "unchanged" &&
|
||||
filesystem === "unchanged" &&
|
||||
protocol === "unchanged" &&
|
||||
process === "unchanged"
|
||||
? "unchanged"
|
||||
: "changed";
|
||||
return {
|
||||
status,
|
||||
terminal,
|
||||
exit,
|
||||
filesystem,
|
||||
protocol,
|
||||
process,
|
||||
limitations: [...left.limitations, ...right.limitations],
|
||||
};
|
||||
};
|
||||
@@ -13,13 +13,10 @@ import {
|
||||
HopperTimeoutError,
|
||||
} from "../domain/errors.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import type { BridgeLaunch, BridgeLauncher } from "./BridgeLauncher.js";
|
||||
import { silentLogger, type Logger } from "../logger.js";
|
||||
import {
|
||||
parseResponseLine,
|
||||
responseResult,
|
||||
type JsonValue,
|
||||
} from "./protocol.js";
|
||||
import { parseResponseLine, responseResult } from "./protocol.js";
|
||||
|
||||
const MAX_LINE_BYTES = 10 * 1024 * 1024;
|
||||
const SESSION_ROOT = process.platform === "darwin" ? "/tmp" : tmpdir();
|
||||
|
||||
@@ -3,6 +3,7 @@ import { fileURLToPath } from "node:url";
|
||||
import type {
|
||||
AnalysisClient,
|
||||
AnalysisProvider,
|
||||
CapabilityDescriptor,
|
||||
ProviderIdentity,
|
||||
} from "../application/AnalysisProvider.js";
|
||||
import type { AppConfig } from "../config.js";
|
||||
@@ -17,7 +18,7 @@ const IDENTITY: ProviderIdentity = {
|
||||
version: null,
|
||||
};
|
||||
|
||||
const CAPABILITIES = [
|
||||
const OPERATIONS = [
|
||||
"direct-analysis",
|
||||
"decompilation",
|
||||
"disassembly",
|
||||
@@ -27,6 +28,25 @@ const CAPABILITIES = [
|
||||
"analysis-metadata-mutation",
|
||||
] as const;
|
||||
|
||||
const CAPABILITIES: readonly CapabilityDescriptor[] = OPERATIONS.map(
|
||||
(operation) => ({
|
||||
operation,
|
||||
version: 1,
|
||||
available: true,
|
||||
pagination: "none",
|
||||
exhaustive: false,
|
||||
effects: {
|
||||
mutatesArtifact: operation === "analysis-metadata-mutation",
|
||||
launchesProcess: true,
|
||||
mayShowUi: true,
|
||||
mayAccessNetwork: false,
|
||||
mayWriteFilesystem: operation === "analysis-metadata-mutation",
|
||||
requiresPrivileges: false,
|
||||
},
|
||||
limitations: ["Results depend on Hopper's completed static analysis."],
|
||||
}),
|
||||
);
|
||||
|
||||
/** Concrete analysis provider backed by REA's private Hopper bridge. */
|
||||
export class HopperProvider implements AnalysisProvider {
|
||||
constructor(
|
||||
@@ -38,7 +58,7 @@ export class HopperProvider implements AnalysisProvider {
|
||||
return IDENTITY;
|
||||
}
|
||||
|
||||
capabilities(): readonly string[] {
|
||||
capabilities(): readonly CapabilityDescriptor[] {
|
||||
return CAPABILITIES;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,8 +4,6 @@ import { HopperProtocolError, HopperRemoteError } from "../domain/errors.js";
|
||||
import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
|
||||
export { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js";
|
||||
|
||||
const responseSchema = z.union([
|
||||
z.object({
|
||||
id: z.number().int().nonnegative(),
|
||||
|
||||
+14
-5
@@ -44,12 +44,21 @@ export const run = async (): Promise<number> => {
|
||||
}
|
||||
let handle: StdioServerHandle;
|
||||
try {
|
||||
handle = serveStdio(() => createServer(session, session, logger), {
|
||||
onerror: () => {
|
||||
serverLogger.error("MCP stdio transport error");
|
||||
process.stderr.write("MCP stdio transport error\n");
|
||||
handle = serveStdio(
|
||||
() =>
|
||||
createServer(
|
||||
session,
|
||||
session,
|
||||
logger,
|
||||
config.value.processExecutionPolicy,
|
||||
),
|
||||
{
|
||||
onerror: () => {
|
||||
serverLogger.error("MCP stdio transport error");
|
||||
process.stderr.write("MCP stdio transport error\n");
|
||||
},
|
||||
},
|
||||
});
|
||||
);
|
||||
} catch {
|
||||
await session.close();
|
||||
serverLogger.error("Failed to start MCP stdio transport");
|
||||
|
||||
@@ -7,6 +7,7 @@ import { registerEnhancedTools } from "./registerEnhancedTools.js";
|
||||
import { registerOfficialTools } from "./registerOfficialTools.js";
|
||||
import { registerSessionTools } from "./registerSessionTools.js";
|
||||
import { silentLogger, type Logger } from "../logger.js";
|
||||
import type { ProcessExecutionPolicy } from "../domain/processCapture.js";
|
||||
|
||||
/**
|
||||
* Construct one MCP server without acquiring subprocess resources.
|
||||
@@ -17,6 +18,7 @@ export const createServer = (
|
||||
analysis: AnalysisOperationPort,
|
||||
session?: BinarySessionPort,
|
||||
logger: Logger = silentLogger,
|
||||
processPolicy?: ProcessExecutionPolicy,
|
||||
): McpServer => {
|
||||
const server = new McpServer(
|
||||
{ name: PRODUCT_IDENTITY.mcpServerKey, version: "0.1.0" },
|
||||
@@ -31,8 +33,29 @@ export const createServer = (
|
||||
const toolLogger = logger.child({ layer: "server" });
|
||||
const activeTarget =
|
||||
session === undefined ? undefined : () => session.activeTarget();
|
||||
registerOfficialTools(server, analysis, toolLogger, activeTarget);
|
||||
registerEnhancedTools(server, analysis, toolLogger, activeTarget);
|
||||
if (session !== undefined) registerSessionTools(server, session, toolLogger);
|
||||
const provider = session?.providerIdentity();
|
||||
const recordEvidence =
|
||||
session === undefined
|
||||
? undefined
|
||||
: (evidence: Parameters<typeof session.recordEvidence>[0]) =>
|
||||
session.recordEvidence(evidence);
|
||||
registerOfficialTools(
|
||||
server,
|
||||
analysis,
|
||||
toolLogger,
|
||||
activeTarget,
|
||||
provider,
|
||||
recordEvidence,
|
||||
);
|
||||
registerEnhancedTools(
|
||||
server,
|
||||
analysis,
|
||||
toolLogger,
|
||||
activeTarget,
|
||||
provider,
|
||||
recordEvidence,
|
||||
);
|
||||
if (session !== undefined)
|
||||
registerSessionTools(server, session, toolLogger, processPolicy);
|
||||
return server;
|
||||
};
|
||||
|
||||
@@ -14,6 +14,8 @@ import type { BinaryTarget } from "../domain/binaryTarget.js";
|
||||
import { createEvidence } from "../domain/evidence.js";
|
||||
import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js";
|
||||
import { enhancedInputSchemas } from "../contracts/enhancedInputs.js";
|
||||
import type { ProviderIdentity } from "../application/AnalysisProvider.js";
|
||||
import type { Evidence } from "../domain/evidence.js";
|
||||
|
||||
/** Register composed workflows against the same port as direct bridge tools. */
|
||||
export const registerEnhancedTools = (
|
||||
@@ -21,10 +23,21 @@ export const registerEnhancedTools = (
|
||||
analysis: AnalysisOperationPort,
|
||||
logger: Logger,
|
||||
activeTarget?: () => BinaryTarget | undefined,
|
||||
provider: ProviderIdentity = {
|
||||
id: "unidentified",
|
||||
name: "Unidentified provider",
|
||||
version: null,
|
||||
},
|
||||
recordEvidence?: (evidence: Evidence) => void,
|
||||
): void => {
|
||||
const services = new EnhancedTools(analysis);
|
||||
for (const contract of ENHANCED_TOOL_CONTRACTS) {
|
||||
registerEnhancedTool(server, services, contract, { logger, activeTarget });
|
||||
registerEnhancedTool(server, services, contract, {
|
||||
logger,
|
||||
activeTarget,
|
||||
provider,
|
||||
recordEvidence,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -35,6 +48,8 @@ const registerEnhancedTool = (
|
||||
registration: {
|
||||
readonly logger: Logger;
|
||||
readonly activeTarget: (() => BinaryTarget | undefined) | undefined;
|
||||
readonly provider: ProviderIdentity;
|
||||
readonly recordEvidence: ((evidence: Evidence) => void) | undefined;
|
||||
},
|
||||
): void => {
|
||||
const name = enhancedToolNameSchema.parse(contract.name);
|
||||
@@ -60,19 +75,22 @@ const registerEnhancedTool = (
|
||||
const result = await logToolExecution(registration.logger, name, () =>
|
||||
services.execute(name, input, context.mcpReq.signal),
|
||||
);
|
||||
return toCallToolResult(
|
||||
result.ok
|
||||
? {
|
||||
ok: true,
|
||||
value: createEvidence(registration.activeTarget?.(), {
|
||||
operation: name,
|
||||
parameters,
|
||||
result: result.value,
|
||||
}),
|
||||
}
|
||||
: result,
|
||||
contract,
|
||||
);
|
||||
if (result.ok) {
|
||||
const evidence = createEvidence(
|
||||
registration.activeTarget?.(),
|
||||
registration.provider,
|
||||
{
|
||||
operation: name,
|
||||
parameters,
|
||||
result: result.value,
|
||||
confidence: "derived",
|
||||
redactedRawPayload: result.value,
|
||||
},
|
||||
);
|
||||
registration.recordEvidence?.(evidence);
|
||||
return toCallToolResult({ ok: true, value: evidence }, contract);
|
||||
}
|
||||
return toCallToolResult(result, contract);
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
@@ -11,6 +11,8 @@ import type { Logger } from "../logger.js";
|
||||
import { logToolExecution } from "./toolLogging.js";
|
||||
import type { BinaryTarget } from "../domain/binaryTarget.js";
|
||||
import { createEvidence } from "../domain/evidence.js";
|
||||
import type { ProviderIdentity } from "../application/AnalysisProvider.js";
|
||||
import type { Evidence } from "../domain/evidence.js";
|
||||
|
||||
/** Register direct bridge proxies, preserving MCP cancellation and typed errors. */
|
||||
export const registerOfficialTools = (
|
||||
@@ -18,9 +20,20 @@ export const registerOfficialTools = (
|
||||
analysis: AnalysisOperationPort,
|
||||
logger: Logger,
|
||||
activeTarget?: () => BinaryTarget | undefined,
|
||||
provider: ProviderIdentity = {
|
||||
id: "unidentified",
|
||||
name: "Unidentified provider",
|
||||
version: null,
|
||||
},
|
||||
recordEvidence?: (evidence: Evidence) => void,
|
||||
): void => {
|
||||
for (const contract of OFFICIAL_TOOL_CONTRACTS) {
|
||||
registerOfficialTool(server, analysis, contract, { logger, activeTarget });
|
||||
registerOfficialTool(server, analysis, contract, {
|
||||
logger,
|
||||
activeTarget,
|
||||
provider,
|
||||
recordEvidence,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -31,6 +44,8 @@ const registerOfficialTool = (
|
||||
registration: {
|
||||
readonly logger: Logger;
|
||||
readonly activeTarget: (() => BinaryTarget | undefined) | undefined;
|
||||
readonly provider: ProviderIdentity;
|
||||
readonly recordEvidence: ((evidence: Evidence) => void) | undefined;
|
||||
},
|
||||
): void => {
|
||||
server.registerTool(
|
||||
@@ -51,19 +66,21 @@ const registerOfficialTool = (
|
||||
signal: context.mcpReq.signal,
|
||||
}),
|
||||
);
|
||||
return toCallToolResult(
|
||||
result.ok
|
||||
? {
|
||||
ok: true,
|
||||
value: createEvidence(registration.activeTarget?.(), {
|
||||
operation: contract.name,
|
||||
parameters: arguments_,
|
||||
result: result.value,
|
||||
}),
|
||||
}
|
||||
: result,
|
||||
contract,
|
||||
);
|
||||
if (result.ok) {
|
||||
const evidence = createEvidence(
|
||||
registration.activeTarget?.(),
|
||||
registration.provider,
|
||||
{
|
||||
operation: contract.name,
|
||||
parameters: arguments_,
|
||||
result: result.value,
|
||||
redactedRawPayload: result.value,
|
||||
},
|
||||
);
|
||||
registration.recordEvidence?.(evidence);
|
||||
return toCallToolResult({ ok: true, value: evidence }, contract);
|
||||
}
|
||||
return toCallToolResult(result, contract);
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
@@ -6,14 +6,200 @@ import { SESSION_TOOL_CONTRACTS } from "../contracts/toolContracts.js";
|
||||
import { toCallToolResult } from "./toolResult.js";
|
||||
import type { Logger } from "../logger.js";
|
||||
import { logToolExecution } from "./toolLogging.js";
|
||||
import { err, ok } from "../domain/result.js";
|
||||
import { EvidenceLedgerError } from "../domain/errors.js";
|
||||
import type { ProcessExecutionPolicy } from "../domain/processCapture.js";
|
||||
import {
|
||||
compareProcessCaptures,
|
||||
processCaptureSchema,
|
||||
processScenarioSchema,
|
||||
} from "../domain/processCapture.js";
|
||||
import { captureProcessScenario } from "../application/ProcessHarness.js";
|
||||
import { createEvidence } from "../domain/evidence.js";
|
||||
import { jsonValueSchema } from "../domain/jsonValue.js";
|
||||
|
||||
const DENY_PROCESS_POLICY: ProcessExecutionPolicy = {
|
||||
enabled: false,
|
||||
executableRoots: [],
|
||||
workingRoots: [],
|
||||
allowedEnvironment: [],
|
||||
};
|
||||
|
||||
const PROCESS_PROVIDER = {
|
||||
id: "rea-process",
|
||||
name: "REA deterministic process harness",
|
||||
version: "1",
|
||||
} as const;
|
||||
|
||||
interface ProcessToolRegistration {
|
||||
readonly server: McpServer;
|
||||
readonly session: BinarySessionPort;
|
||||
readonly logger: Logger;
|
||||
readonly processPolicy: ProcessExecutionPolicy;
|
||||
readonly captureContract: (typeof SESSION_TOOL_CONTRACTS)[5];
|
||||
readonly compareContract: (typeof SESSION_TOOL_CONTRACTS)[6];
|
||||
}
|
||||
|
||||
const registerProcessTools = ({
|
||||
server,
|
||||
session,
|
||||
logger,
|
||||
processPolicy,
|
||||
captureContract,
|
||||
compareContract,
|
||||
}: ProcessToolRegistration): void => {
|
||||
server.registerTool(
|
||||
captureContract.name,
|
||||
{
|
||||
description: captureContract.description,
|
||||
inputSchema: captureContract.inputSchema,
|
||||
outputSchema: captureContract.outputSchema,
|
||||
annotations: captureContract.annotations,
|
||||
},
|
||||
async (input, context) => {
|
||||
const scenario = processScenarioSchema.parse(input);
|
||||
const captured = await logToolExecution(
|
||||
logger,
|
||||
captureContract.name,
|
||||
() =>
|
||||
captureProcessScenario(
|
||||
scenario,
|
||||
processPolicy,
|
||||
context.mcpReq.signal,
|
||||
),
|
||||
);
|
||||
if (!captured.ok) return toCallToolResult(captured, captureContract);
|
||||
const evidence = createEvidence(undefined, PROCESS_PROVIDER, {
|
||||
predicateType: "rea.process-capture/v1",
|
||||
operation: captureContract.name,
|
||||
parameters: {
|
||||
executable_name:
|
||||
scenario.executable.split("/").at(-1) ?? scenario.executable,
|
||||
argument_count: scenario.arguments.length,
|
||||
event_count: scenario.events.length,
|
||||
filesystem_root_count: scenario.filesystem_roots.length,
|
||||
},
|
||||
result: jsonValueSchema.parse(captured.value),
|
||||
confidence: "observed",
|
||||
authority: "controlled-replay",
|
||||
environment: {
|
||||
id: `${process.platform}-${process.arch}`,
|
||||
platform: process.platform,
|
||||
architecture: process.arch,
|
||||
isolation: "process",
|
||||
},
|
||||
limitations: captured.value.limitations,
|
||||
});
|
||||
session.recordEvidence(evidence);
|
||||
return toCallToolResult(ok(evidence), captureContract);
|
||||
},
|
||||
);
|
||||
server.registerTool(
|
||||
compareContract.name,
|
||||
{
|
||||
description: compareContract.description,
|
||||
inputSchema: compareContract.inputSchema,
|
||||
outputSchema: compareContract.outputSchema,
|
||||
annotations: compareContract.annotations,
|
||||
},
|
||||
(input) => {
|
||||
const parsed = z
|
||||
.object({
|
||||
left_evidence_id: z.string(),
|
||||
left: processCaptureSchema,
|
||||
right_evidence_id: z.string(),
|
||||
right: processCaptureSchema,
|
||||
})
|
||||
.parse(input);
|
||||
const comparison = compareProcessCaptures(parsed.left, parsed.right);
|
||||
const evidence = createEvidence(undefined, PROCESS_PROVIDER, {
|
||||
predicateType: "rea.process-comparison/v1",
|
||||
operation: compareContract.name,
|
||||
parameters: {},
|
||||
result: jsonValueSchema.parse(comparison),
|
||||
confidence: "derived",
|
||||
authority: "analyst-inference",
|
||||
limitations: comparison.limitations,
|
||||
evidenceLinks: [parsed.left_evidence_id, parsed.right_evidence_id],
|
||||
});
|
||||
session.recordEvidence(evidence);
|
||||
return toCallToolResult(ok(evidence), compareContract);
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
interface EvidenceToolRegistration {
|
||||
readonly server: McpServer;
|
||||
readonly session: BinarySessionPort;
|
||||
readonly exportContract: (typeof SESSION_TOOL_CONTRACTS)[3];
|
||||
readonly importContract: (typeof SESSION_TOOL_CONTRACTS)[4];
|
||||
}
|
||||
|
||||
const registerEvidenceTools = ({
|
||||
server,
|
||||
session,
|
||||
exportContract,
|
||||
importContract,
|
||||
}: EvidenceToolRegistration): void => {
|
||||
server.registerTool(
|
||||
exportContract.name,
|
||||
{
|
||||
description: exportContract.description,
|
||||
inputSchema: exportContract.inputSchema,
|
||||
outputSchema: exportContract.outputSchema,
|
||||
annotations: exportContract.annotations,
|
||||
},
|
||||
() => toCallToolResult(ok(session.exportEvidenceBundle()), exportContract),
|
||||
);
|
||||
server.registerTool(
|
||||
importContract.name,
|
||||
{
|
||||
description: importContract.description,
|
||||
inputSchema: importContract.inputSchema,
|
||||
outputSchema: importContract.outputSchema,
|
||||
annotations: importContract.annotations,
|
||||
},
|
||||
(input) => {
|
||||
try {
|
||||
const bundle = z.object({ bundle: z.unknown() }).parse(input).bundle;
|
||||
const imported = session.importEvidenceBundle(bundle);
|
||||
return toCallToolResult(
|
||||
ok({
|
||||
imported,
|
||||
total: session.exportEvidenceBundle().records.length,
|
||||
}),
|
||||
importContract,
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
return toCallToolResult(
|
||||
err(
|
||||
new EvidenceLedgerError("Evidence bundle validation failed", {
|
||||
cause,
|
||||
}),
|
||||
),
|
||||
importContract,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
/** Register MCP-only target lifecycle operations on a long-lived session. */
|
||||
export const registerSessionTools = (
|
||||
server: McpServer,
|
||||
session: BinarySessionPort,
|
||||
logger: Logger,
|
||||
processPolicy: ProcessExecutionPolicy = DENY_PROCESS_POLICY,
|
||||
): void => {
|
||||
const [openContract, closeContract, statusContract] = SESSION_TOOL_CONTRACTS;
|
||||
const [
|
||||
openContract,
|
||||
closeContract,
|
||||
statusContract,
|
||||
exportContract,
|
||||
importContract,
|
||||
captureContract,
|
||||
compareContract,
|
||||
] = SESSION_TOOL_CONTRACTS;
|
||||
server.registerTool(
|
||||
openContract.name,
|
||||
{
|
||||
@@ -72,4 +258,13 @@ export const registerSessionTools = (
|
||||
() =>
|
||||
toCallToolResult({ ok: true, value: session.status() }, statusContract),
|
||||
);
|
||||
registerEvidenceTools({ server, session, exportContract, importContract });
|
||||
registerProcessTools({
|
||||
server,
|
||||
session,
|
||||
logger,
|
||||
processPolicy,
|
||||
captureContract,
|
||||
compareContract,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -22,7 +22,24 @@ describe("binary session", () => {
|
||||
const operations: string[] = [];
|
||||
const provider: AnalysisProvider = {
|
||||
identity: () => ({ id: "fixture", name: "Fixture", version: "1" }),
|
||||
capabilities: () => ["fixture-analysis"],
|
||||
capabilities: () => [
|
||||
{
|
||||
operation: "fixture-analysis",
|
||||
version: 1,
|
||||
available: true,
|
||||
pagination: "none",
|
||||
exhaustive: true,
|
||||
effects: {
|
||||
mutatesArtifact: false,
|
||||
launchesProcess: false,
|
||||
mayShowUi: false,
|
||||
mayAccessNetwork: false,
|
||||
mayWriteFilesystem: false,
|
||||
requiresPrivileges: false,
|
||||
},
|
||||
limitations: [],
|
||||
},
|
||||
],
|
||||
createClient: () => ({
|
||||
execute: (operation) => {
|
||||
operations.push(operation);
|
||||
@@ -38,7 +55,7 @@ describe("binary session", () => {
|
||||
value: "fixture_operation",
|
||||
});
|
||||
expect(provider.identity().id).toBe("fixture");
|
||||
expect(provider.capabilities()).toEqual(["fixture-analysis"]);
|
||||
expect(provider.capabilities()[0]?.operation).toBe("fixture-analysis");
|
||||
expect(operations).toEqual(["health", "fixture_operation"]);
|
||||
await session.close();
|
||||
});
|
||||
|
||||
@@ -8,9 +8,12 @@ const bridgeSource = await readFile(
|
||||
);
|
||||
|
||||
describe("Hopper bridge truthfulness", () => {
|
||||
it("marks unavailable CFG successors instead of returning deceptive empty arrays", () => {
|
||||
expect(bridgeSource).toContain('"successors": _unavailable(');
|
||||
expect(bridgeSource).not.toContain('"successors": []');
|
||||
it("collects CFG successors from Hopper instead of fabricating empty edges", () => {
|
||||
expect(bridgeSource).toContain("block.getSuccessorCount()");
|
||||
expect(bridgeSource).toContain("block.getSuccessorAddressAtIndex(index)");
|
||||
expect(bridgeSource).not.toContain(
|
||||
"Hopper's public Python API does not expose CFG successor edges",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not fabricate non-writable and non-executable segment permissions", () => {
|
||||
@@ -29,4 +32,23 @@ describe("Hopper bridge truthfulness", () => {
|
||||
"Hopper's public Python API does not classify reference kinds",
|
||||
);
|
||||
});
|
||||
|
||||
it("scans procedure instructions for comments and typed reference evidence", () => {
|
||||
expect(bridgeSource).toContain("for address in addresses:");
|
||||
expect(bridgeSource).toContain("segment.getCommentAtAddress(address)");
|
||||
expect(bridgeSource).toContain(
|
||||
"segment.getInlineCommentAtAddress(address)",
|
||||
);
|
||||
expect(bridgeSource).toContain('"source_procedure"');
|
||||
expect(bridgeSource).toContain('"target_procedure"');
|
||||
expect(bridgeSource).toContain('"outgoing_references"');
|
||||
expect(bridgeSource).toContain('"referenced_strings"');
|
||||
expect(bridgeSource).toContain('"referenced_names"');
|
||||
});
|
||||
|
||||
it("supports independent dossier continuation offsets", () => {
|
||||
expect(bridgeSource).toContain('_offset(params, "pseudocode_offset")');
|
||||
expect(bridgeSource).toContain('_offset(params, "assembly_offset")');
|
||||
expect(bridgeSource).toContain('params.get("collection_offset", {})');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,6 +33,12 @@ describe("runtime configuration", () => {
|
||||
hopperTargetKind: "database",
|
||||
hopperLoaderArgs: ["-l", "FAT", "--aarch64", "-l", "Mach-O"],
|
||||
logLevel: "info",
|
||||
processExecutionPolicy: {
|
||||
enabled: false,
|
||||
executableRoots: [],
|
||||
workingRoots: [],
|
||||
allowedEnvironment: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -36,13 +36,17 @@ describe("tool contract surface", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps exactly three additive session contracts", () => {
|
||||
it("keeps exactly seven additive session contracts", () => {
|
||||
expect(
|
||||
SESSION_TOOL_CONTRACTS.map(({ name, kind }) => ({ name, kind })),
|
||||
).toEqual([
|
||||
{ name: "open_binary", kind: "session" },
|
||||
{ name: "close_binary", kind: "session" },
|
||||
{ name: "binary_session", kind: "session" },
|
||||
{ name: "export_evidence_bundle", kind: "session" },
|
||||
{ name: "import_evidence_bundle", kind: "session" },
|
||||
{ name: "capture_process_scenario", kind: "session" },
|
||||
{ name: "compare_process_captures", kind: "session" },
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -114,9 +114,11 @@ const fixturePort = (): AnalysisOperationPort => ({
|
||||
callers: emptyBounded(),
|
||||
callees: emptyBounded(),
|
||||
incoming_references: emptyBounded(),
|
||||
referenced_strings: { available: false, reason: "unsupported" },
|
||||
referenced_names: { available: false, reason: "unsupported" },
|
||||
outgoing_references: emptyBounded(),
|
||||
referenced_strings: emptyBounded(),
|
||||
referenced_names: emptyBounded(),
|
||||
basic_blocks: emptyBounded(),
|
||||
instruction_scan: { scanned: 0, truncated: false },
|
||||
}),
|
||||
);
|
||||
default:
|
||||
@@ -475,4 +477,55 @@ describe("enhanced MCP tools", () => {
|
||||
"HopperProtocolError",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects incomplete function dossiers at the application boundary", async () => {
|
||||
const client = await connect({
|
||||
execute: () =>
|
||||
Promise.resolve(
|
||||
ok({
|
||||
procedure: { address: "0x1", name: "entry" },
|
||||
pseudocode: { text: "plausible but incomplete" },
|
||||
}),
|
||||
),
|
||||
});
|
||||
const result = await client.callTool({
|
||||
name: "analyze_function",
|
||||
arguments: { procedure: "0x1" },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
const text = result.content.find((item) => item.type === "text");
|
||||
expect(text?.type === "text" ? text.text : "").toContain(
|
||||
"AnalysisProtocolError",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects deceptive function dossier collection metadata", async () => {
|
||||
const malformedPort = fixturePort();
|
||||
const client = await connect({
|
||||
execute: async (name, arguments_, options) => {
|
||||
const result = await malformedPort.execute(name, arguments_, options);
|
||||
if (!result.ok || name !== "analyze_function") return result;
|
||||
const dossier = result.value as Record<string, JsonValue>;
|
||||
return ok({
|
||||
...dossier,
|
||||
comments: {
|
||||
items: [],
|
||||
total: 0,
|
||||
returned: 1,
|
||||
truncated: false,
|
||||
next_offset: null,
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
const result = await client.callTool({
|
||||
name: "analyze_function",
|
||||
arguments: { procedure: "0x1" },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
const text = result.content.find((item) => item.type === "text");
|
||||
expect(text?.type === "text" ? text.text : "").toContain(
|
||||
"AnalysisProtocolError",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+86
-30
@@ -1,8 +1,12 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { EvidenceLedger } from "../src/application/EvidenceLedger.js";
|
||||
import type { BinaryTarget } from "../src/domain/binaryTarget.js";
|
||||
import { createEvidence, evidenceSchema } from "../src/domain/evidence.js";
|
||||
import { jsonValueSchema } from "../src/hopper/protocol.js";
|
||||
import {
|
||||
createEvidence,
|
||||
evidenceSchema,
|
||||
parseEvidence,
|
||||
} from "../src/domain/evidence.js";
|
||||
|
||||
const TARGET: BinaryTarget = {
|
||||
path: "/tmp/fixture",
|
||||
@@ -13,44 +17,96 @@ const TARGET: BinaryTarget = {
|
||||
availableArchitectures: ["arm64"],
|
||||
loaderArgs: ["-l", "Mach-O", "--aarch64"],
|
||||
};
|
||||
const PROVIDER = { id: "fixture", name: "Fixture provider", version: "1" };
|
||||
|
||||
describe("analysis evidence", () => {
|
||||
it("preserves strict JSON inputs and results without timestamps", () => {
|
||||
const evidence = createEvidence(TARGET, {
|
||||
it("creates provider-neutral, deterministic Evidence v2", () => {
|
||||
const observation = {
|
||||
operation: "procedure_info",
|
||||
parameters: { procedure: "0x1000", document: null },
|
||||
parameters: { document: null, procedure: "0x1000" },
|
||||
result: { name: "main" },
|
||||
});
|
||||
redactedRawPayload: { token: "<redacted:token>" },
|
||||
} as const;
|
||||
const evidence = createEvidence(TARGET, PROVIDER, observation);
|
||||
expect(evidenceSchema.parse(evidence)).toEqual(evidence);
|
||||
expect(jsonValueSchema.parse(evidence)).toEqual(evidence);
|
||||
expect(parseEvidence(evidence)).toEqual(evidence);
|
||||
expect(evidence).toMatchObject({
|
||||
schema_version: 1,
|
||||
artifact: {
|
||||
path: "/tmp/fixture",
|
||||
sha256: "a".repeat(64),
|
||||
architecture: "arm64",
|
||||
},
|
||||
provider: { id: "hopper", version: null },
|
||||
operation: "procedure_info",
|
||||
schema_version: 2,
|
||||
provider: PROVIDER,
|
||||
subject: { digest: { sha256: "a".repeat(64) } },
|
||||
confidence: "observed",
|
||||
limitations: [],
|
||||
authority: "shipped-artifact",
|
||||
});
|
||||
expect(evidence).not.toHaveProperty("timestamp");
|
||||
expect(evidence).not.toHaveProperty("evidence_id");
|
||||
expect(evidence.evidence_id).toMatch(/^ev_[a-f0-9]{64}$/u);
|
||||
expect(evidence.raw_payload_sha256).toMatch(/^[a-f0-9]{64}$/u);
|
||||
expect(createEvidence(TARGET, PROVIDER, observation)).toEqual(evidence);
|
||||
});
|
||||
|
||||
it("marks missing fixed-target identity as unavailable", () => {
|
||||
expect(
|
||||
createEvidence(undefined, {
|
||||
operation: "health",
|
||||
parameters: {},
|
||||
result: null,
|
||||
}),
|
||||
).toMatchObject({
|
||||
artifact: null,
|
||||
limitations: [
|
||||
"Artifact identity is unavailable for this fixed-target adapter.",
|
||||
],
|
||||
it("excludes local paths and redacted raw payloads from semantic identity", () => {
|
||||
const observation = {
|
||||
operation: "health",
|
||||
parameters: {},
|
||||
result: true,
|
||||
redactedRawPayload: { pid: 100 },
|
||||
} as const;
|
||||
const first = createEvidence(TARGET, PROVIDER, observation);
|
||||
const moved = createEvidence(
|
||||
{ ...TARGET, path: "/other/renamed-fixture" },
|
||||
PROVIDER,
|
||||
{ ...observation, redactedRawPayload: { pid: 200 } },
|
||||
);
|
||||
expect(moved.evidence_id).toBe(first.evidence_id);
|
||||
expect(moved.raw_payload_sha256).not.toBe(first.raw_payload_sha256);
|
||||
});
|
||||
|
||||
it("rejects semantic tampering", () => {
|
||||
const evidence = createEvidence(TARGET, PROVIDER, {
|
||||
operation: "health",
|
||||
parameters: {},
|
||||
result: true,
|
||||
});
|
||||
expect(() => parseEvidence({ ...evidence, result: false })).toThrow(
|
||||
"semantic identifier",
|
||||
);
|
||||
});
|
||||
|
||||
it("deduplicates and atomically imports bounded bundles", () => {
|
||||
const evidence = createEvidence(TARGET, PROVIDER, {
|
||||
operation: "health",
|
||||
parameters: {},
|
||||
result: true,
|
||||
});
|
||||
const ledger = new EvidenceLedger({ maxRecords: 1 });
|
||||
expect(ledger.record(evidence)).toBe("added");
|
||||
expect(ledger.record(evidence)).toBe("duplicate");
|
||||
const relocated = createEvidence(
|
||||
{ ...TARGET, path: "/relocated/different-name" },
|
||||
PROVIDER,
|
||||
{ operation: "health", parameters: {}, result: true },
|
||||
);
|
||||
expect(relocated.evidence_id).toBe(evidence.evidence_id);
|
||||
expect(ledger.record(relocated)).toBe("duplicate");
|
||||
expect(ledger.import({ bundle_version: 1, records: [evidence] })).toBe(0);
|
||||
expect(ledger.export().records).toEqual([evidence]);
|
||||
ledger.clear();
|
||||
expect(ledger.export().records).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects duplicate semantic IDs with conflicting raw payload hashes", () => {
|
||||
const ledger = new EvidenceLedger({ maxRecords: 2 });
|
||||
const first = createEvidence(TARGET, PROVIDER, {
|
||||
operation: "health",
|
||||
parameters: {},
|
||||
result: true,
|
||||
redactedRawPayload: { pid: 1 },
|
||||
});
|
||||
const second = createEvidence(TARGET, PROVIDER, {
|
||||
operation: "health",
|
||||
parameters: {},
|
||||
result: true,
|
||||
redactedRawPayload: { pid: 2 },
|
||||
});
|
||||
ledger.record(first);
|
||||
expect(() => ledger.record(second)).toThrow("Conflicting evidence");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -85,9 +85,9 @@ describe("full MCP integration with multi-tool sequences", () => {
|
||||
});
|
||||
expect(listResult.isError).not.toBe(true);
|
||||
expect(structured(listResult)).toMatchObject({
|
||||
artifact: null,
|
||||
subject: null,
|
||||
operation: "list_procedures",
|
||||
provider: { id: "hopper", version: null },
|
||||
provider: { id: "unidentified", version: null },
|
||||
result: {
|
||||
items: [
|
||||
{ address: "0x1000", value: "main" },
|
||||
@@ -118,7 +118,7 @@ describe("full MCP integration with multi-tool sequences", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves the complete 46-tool inventory with a session", async () => {
|
||||
it("preserves the complete 50-tool inventory with a session", async () => {
|
||||
const session = new BinarySession(
|
||||
(_path) =>
|
||||
({
|
||||
@@ -141,7 +141,7 @@ describe("full MCP integration with multi-tool sequences", () => {
|
||||
await client.connect(clientTransport);
|
||||
|
||||
const listed = await client.listTools();
|
||||
expect(listed.tools).toHaveLength(46);
|
||||
expect(listed.tools).toHaveLength(50);
|
||||
const names = listed.tools.map((t) => t.name);
|
||||
expect(names).toContain("open_binary");
|
||||
expect(names).toContain("close_binary");
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import WebSocket from "ws";
|
||||
import { startLoopbackReplay } from "../src/application/LoopbackReplay.js";
|
||||
import {
|
||||
captureProcessScenario,
|
||||
probeProcessCaptureCapability,
|
||||
ProcessCaptureError,
|
||||
} from "../src/application/ProcessHarness.js";
|
||||
import {
|
||||
authorizeProcessScenario,
|
||||
compareProcessCaptures,
|
||||
parseProcessScenario,
|
||||
type ProcessExecutionPolicy,
|
||||
} from "../src/domain/processCapture.js";
|
||||
|
||||
describe("process capture domain", () => {
|
||||
const base = {
|
||||
approved: true as const,
|
||||
executable: "/bin/sh",
|
||||
working_directory: "/tmp",
|
||||
};
|
||||
|
||||
it("parses bounded scenarios and rejects unordered events", () => {
|
||||
expect(parseProcessScenario(base).timeout_ms).toBe(30_000);
|
||||
expect(() =>
|
||||
parseProcessScenario({
|
||||
...base,
|
||||
events: [
|
||||
{ type: "input", at_ms: 2, data: "a" },
|
||||
{ type: "input", at_ms: 1, data: "b" },
|
||||
],
|
||||
}),
|
||||
).toThrow(/ordered/);
|
||||
});
|
||||
|
||||
it("refuses paths and environment outside operator policy", () => {
|
||||
const scenario = parseProcessScenario({
|
||||
...base,
|
||||
environment: { TOKEN: "secret" },
|
||||
});
|
||||
expect(
|
||||
authorizeProcessScenario(scenario, {
|
||||
enabled: true,
|
||||
executableRoots: ["/bin"],
|
||||
workingRoots: ["/tmp"],
|
||||
allowedEnvironment: [],
|
||||
}),
|
||||
).toEqual({
|
||||
allowed: false,
|
||||
reason: "scenario requests an environment variable not allowed by policy",
|
||||
});
|
||||
});
|
||||
|
||||
it("never considers truncated captures equivalent", () => {
|
||||
const capture = {
|
||||
schema_version: 1 as const,
|
||||
frames: [],
|
||||
exit: { code: 0, signal: null },
|
||||
process_samples: [],
|
||||
protocol_events: [],
|
||||
files_before: [],
|
||||
files_after: [],
|
||||
truncated: true,
|
||||
limitations: [],
|
||||
};
|
||||
expect(compareProcessCaptures(capture, capture).status).toBe("truncated");
|
||||
});
|
||||
});
|
||||
|
||||
describe("process capture adapter", () => {
|
||||
it("serves bounded HTTP and WebSocket replay on loopback", async () => {
|
||||
const scenario = parseProcessScenario({
|
||||
approved: true,
|
||||
executable: "/bin/sh",
|
||||
working_directory: "/tmp",
|
||||
replay: {
|
||||
http: [{ method: "GET", path: "/ready", status: 201, body: "ready" }],
|
||||
websocket_messages: ["welcome"],
|
||||
},
|
||||
});
|
||||
const replay = await startLoopbackReplay(scenario);
|
||||
try {
|
||||
const response = await fetch(`${replay.httpUrl}/ready`);
|
||||
expect(response.status).toBe(201);
|
||||
expect(await response.text()).toBe("ready");
|
||||
const websocketMessage = await new Promise<string>(
|
||||
(resolveMessage, rejectMessage) => {
|
||||
const socket = new WebSocket(replay.websocketUrl);
|
||||
socket.once("message", (value) => {
|
||||
resolveMessage(value.toString());
|
||||
socket.close();
|
||||
});
|
||||
socket.once("error", rejectMessage);
|
||||
},
|
||||
);
|
||||
expect(websocketMessage).toBe("welcome");
|
||||
expect(replay.events.map((event) => event.protocol)).toContain(
|
||||
"websocket",
|
||||
);
|
||||
} finally {
|
||||
await replay.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("captures PTY, filesystem, descendants, HTTP replay, and redacts environment", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "rea-harness-test-"));
|
||||
const script = join(root, "fixture.mjs");
|
||||
await writeFile(
|
||||
script,
|
||||
[
|
||||
'import { writeFile } from "node:fs/promises";',
|
||||
'import { spawn } from "node:child_process";',
|
||||
'await writeFile(new URL("result.txt", `file://${process.cwd()}/`), "created");',
|
||||
"const response = await fetch(`${process.env.REA_REPLAY_HTTP_URL}/probe`);",
|
||||
"console.log(`reply:${await response.text()}`);",
|
||||
"console.log(`sensitive:${process.env.SECRET}`);",
|
||||
'const child = spawn(process.execPath, ["-e", "setTimeout(()=>{}, 150)"], { stdio: "ignore" });',
|
||||
"await new Promise((resolve) => setTimeout(resolve, 80));",
|
||||
"child.kill();",
|
||||
].join("\n"),
|
||||
);
|
||||
const policy: ProcessExecutionPolicy = {
|
||||
enabled: true,
|
||||
executableRoots: [dirname(process.execPath)],
|
||||
workingRoots: [root],
|
||||
allowedEnvironment: ["SECRET"],
|
||||
};
|
||||
const scenario = parseProcessScenario({
|
||||
approved: true,
|
||||
executable: process.execPath,
|
||||
arguments: [script],
|
||||
working_directory: root,
|
||||
filesystem_roots: [root],
|
||||
environment: { SECRET: "do-not-record" },
|
||||
secret_aliases: ["SECRET"],
|
||||
replay: {
|
||||
http: [{ method: "GET", path: "/probe", status: 200, body: "ok" }],
|
||||
},
|
||||
});
|
||||
try {
|
||||
const capability = await probeProcessCaptureCapability();
|
||||
if (!capability.available) {
|
||||
expect(capability.reason).toMatch(/native PTY/);
|
||||
return;
|
||||
}
|
||||
const capture = await captureProcessScenario(scenario, policy);
|
||||
expect(capture.ok).toBe(true);
|
||||
if (!capture.ok) throw capture.error;
|
||||
expect(
|
||||
capture.value.frames.map((frame) => frame.data).join(""),
|
||||
).toContain("reply:ok");
|
||||
expect(
|
||||
capture.value.frames.map((frame) => frame.data).join(""),
|
||||
).toContain("sensitive:<redacted>");
|
||||
expect(
|
||||
capture.value.files_after.some((file) =>
|
||||
file.path.endsWith("result.txt"),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
capture.value.protocol_events.some(
|
||||
(event) => event.protocol === "http" && event.path === "/probe",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(JSON.stringify(capture.value)).not.toContain("do-not-record");
|
||||
expect(await readFile(join(root, "result.txt"), "utf8")).toBe("created");
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("does not launch when policy denies capture", async () => {
|
||||
const scenario = parseProcessScenario({
|
||||
approved: true,
|
||||
executable: "/bin/sh",
|
||||
working_directory: "/tmp",
|
||||
});
|
||||
const result = await captureProcessScenario(scenario, {
|
||||
enabled: false,
|
||||
executableRoots: [],
|
||||
workingRoots: [],
|
||||
allowedEnvironment: [],
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) throw new Error("expected policy refusal");
|
||||
expect(result.error).toBeInstanceOf(ProcessCaptureError);
|
||||
});
|
||||
});
|
||||
@@ -20,9 +20,9 @@ describe("localized README product facts", () => {
|
||||
expect(content).toContain("npx -y @morluto/rea setup --yes");
|
||||
expect(content).toContain("npx -y @morluto/rea doctor");
|
||||
expect(content).toContain('"args": ["-y", "@morluto/rea", "mcp"]');
|
||||
expect(content).toContain("Node.js 22");
|
||||
expect(content).toContain("Node.js 24");
|
||||
expect(content).toContain("macOS 12");
|
||||
expect(content).toMatch(/\b46\b/u);
|
||||
expect(content).toMatch(/\b50\b/u);
|
||||
expect(content).toMatch(/\b33\b/u);
|
||||
expect(content).toMatch(/\b10\b/u);
|
||||
expect(content).toMatch(/\b3\b/u);
|
||||
|
||||
@@ -10,7 +10,7 @@ const fixturePath = fileURLToPath(
|
||||
);
|
||||
|
||||
describe("production stdio runtime", () => {
|
||||
it("starts the built entrypoint, lists 46 tools, calls one, and shuts down", async () => {
|
||||
it("starts the built entrypoint, lists 50 tools, calls one, and shuts down", async () => {
|
||||
const transport = new StdioClientTransport({
|
||||
command: process.execPath,
|
||||
args: [mainPath],
|
||||
@@ -32,7 +32,7 @@ describe("production stdio runtime", () => {
|
||||
try {
|
||||
await client.connect(transport);
|
||||
const tools = await client.listTools();
|
||||
expect(tools.tools).toHaveLength(46);
|
||||
expect(tools.tools).toHaveLength(50);
|
||||
const result = await client.callTool({
|
||||
name: "current_document",
|
||||
arguments: {},
|
||||
@@ -78,7 +78,7 @@ describe("production stdio runtime", () => {
|
||||
|
||||
try {
|
||||
await client.connect(transport);
|
||||
expect((await client.listTools()).tools).toHaveLength(46);
|
||||
expect((await client.listTools()).tools).toHaveLength(50);
|
||||
} finally {
|
||||
await client.close();
|
||||
await transport.close();
|
||||
|
||||
@@ -4,11 +4,13 @@ import { join } from "node:path";
|
||||
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
||||
import type { CallToolResult } from "@modelcontextprotocol/server";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
import { BinarySession } from "../src/application/BinarySession.js";
|
||||
import type { AnalysisClient } from "../src/application/AnalysisProvider.js";
|
||||
import { ok } from "../src/domain/result.js";
|
||||
import { createServer } from "../src/server/createServer.js";
|
||||
import { evidenceBundleSchema } from "../src/domain/evidenceBundle.js";
|
||||
|
||||
const resources: Array<{ close(): Promise<unknown> }> = [];
|
||||
let directory: string | undefined;
|
||||
@@ -42,7 +44,17 @@ describe("target-free MCP lifecycle", () => {
|
||||
});
|
||||
expect(before.isError).toBe(true);
|
||||
expect(text(before)).toContain("NoBinaryOpenError");
|
||||
expect((await mcp.listTools()).tools).toHaveLength(46);
|
||||
expect((await mcp.listTools()).tools).toHaveLength(50);
|
||||
const deniedCapture = await mcp.callTool({
|
||||
name: "capture_process_scenario",
|
||||
arguments: {
|
||||
approved: true,
|
||||
executable: "/bin/sh",
|
||||
working_directory: "/tmp",
|
||||
},
|
||||
});
|
||||
expect(deniedCapture.isError).toBe(true);
|
||||
expect(text(deniedCapture)).toContain("process capture is disabled");
|
||||
expect(
|
||||
(await mcp.callTool({ name: "open_binary", arguments: { path: first } }))
|
||||
.isError,
|
||||
@@ -50,6 +62,20 @@ describe("target-free MCP lifecycle", () => {
|
||||
expect(
|
||||
text(await mcp.callTool({ name: "current_document", arguments: {} })),
|
||||
).toContain("first.hop");
|
||||
const exported = await mcp.callTool({
|
||||
name: "export_evidence_bundle",
|
||||
arguments: {},
|
||||
});
|
||||
const bundle = evidenceBundleSchema.parse(structured(exported).result);
|
||||
expect(bundle.records).toHaveLength(1);
|
||||
expect(
|
||||
structured(
|
||||
await mcp.callTool({
|
||||
name: "import_evidence_bundle",
|
||||
arguments: { bundle },
|
||||
}),
|
||||
).result,
|
||||
).toEqual({ imported: 0, total: 1 });
|
||||
await mcp.callTool({ name: "open_binary", arguments: { path: second } });
|
||||
expect(closed.some((path) => path.endsWith("first.hop"))).toBe(true);
|
||||
expect(
|
||||
@@ -78,3 +104,12 @@ const text = (result: CallToolResult): string => {
|
||||
if (content?.type !== "text") throw new Error("missing text result");
|
||||
return content.text;
|
||||
};
|
||||
|
||||
const structured = (result: CallToolResult): Record<string, unknown> => {
|
||||
if (
|
||||
typeof result.structuredContent !== "object" ||
|
||||
result.structuredContent === null
|
||||
)
|
||||
throw new Error("missing structured result");
|
||||
return z.record(z.string(), z.unknown()).parse(result.structuredContent);
|
||||
};
|
||||
|
||||
@@ -8,16 +8,20 @@ import {
|
||||
} from "../src/contracts/toolContracts.js";
|
||||
|
||||
describe("tool contract inventory", () => {
|
||||
it("publishes 43 analysis contracts and three session tools", () => {
|
||||
it("publishes 43 analysis contracts and seven session tools", () => {
|
||||
expect(OFFICIAL_TOOL_CONTRACTS).toHaveLength(33);
|
||||
expect(ENHANCED_TOOL_CONTRACTS).toHaveLength(10);
|
||||
expect(SESSION_TOOL_CONTRACTS.map(({ name }) => name)).toEqual([
|
||||
"open_binary",
|
||||
"close_binary",
|
||||
"binary_session",
|
||||
"export_evidence_bundle",
|
||||
"import_evidence_bundle",
|
||||
"capture_process_scenario",
|
||||
"compare_process_captures",
|
||||
]);
|
||||
expect(TOOL_CONTRACTS).toHaveLength(46);
|
||||
expect(new Set(TOOL_CONTRACTS.map(({ name }) => name)).size).toBe(46);
|
||||
expect(TOOL_CONTRACTS).toHaveLength(50);
|
||||
expect(new Set(TOOL_CONTRACTS.map(({ name }) => name)).size).toBe(50);
|
||||
});
|
||||
|
||||
it("retains documented enhanced-tool limits at the input boundary", () => {
|
||||
|
||||
Reference in New Issue
Block a user