Compare commits

...
Author SHA1 Message Date
github-actions[bot] 8fea6ce9cd chore(main): release rea 0.3.0 2026-07-12 12:38:00 +00:00
morluto 8f538373f0 Merge pull request #74 from morluto/morl/evidence-v2-process-fidelity
Add Evidence v2 and deterministic process investigations
2026-07-12 20:37:45 +08:00
morluto cc8681a856 feat: add evidence-backed process investigations 2026-07-12 20:36:00 +08:00
morluto 9f60348d1b Merge pull request #46 from morluto/morl/reverse_improvements
Evolve REA into an evidence-backed analysis platform
2026-07-12 19:36:51 +08:00
morluto 167a824f91 fix: keep execution options internal 2026-07-12 19:35:58 +08:00
morluto d648bcd2e4 test: add source-built Hopper conformance fixtures 2026-07-12 19:32:29 +08:00
morluto 4f18e26ee5 feat: evolve REA analysis platform 2026-07-12 19:32:13 +08:00
morluto 1f235cb52f docs: document the 43-tool workflow 2026-07-12 19:00:03 +08:00
morluto 893d2e2d05 feat(mcp): add typed bounded analysis tools 2026-07-12 18:59:53 +08:00
morluto 7e4ae21d78 Merge pull request #15 from morluto/codex/simplify-readme-onboarding
[codex] Simplify REA onboarding and app targeting
2026-07-12 18:20:29 +08:00
morluto 85f178ba20 Merge main into onboarding branch 2026-07-12 18:19:26 +08:00
morluto fc21c9a2d7 Simplify REA onboarding and app targeting 2026-07-12 12:26:16 +08:00
84 changed files with 5964 additions and 834 deletions

No files matched your search

+9 -8
View File
@@ -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
+1 -1
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
engine-strict=true
+1
View File
@@ -0,0 +1 @@
24.18.0
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "0.2.1"
".": "0.3.0"
}
+24
View File
@@ -1,5 +1,29 @@
# Changelog
## [0.3.0](https://github.com/morluto/rea/compare/rea-0.2.1...rea-0.3.0) (2026-07-12)
### Features
* add evidence-backed process investigations ([cc8681a](https://github.com/morluto/rea/commit/cc8681a856dacb296f835112d0a27133d4c05ad1))
* evolve REA analysis platform ([4f18e26](https://github.com/morluto/rea/commit/4f18e26ee58f616de788e07639f5b2f91d37ef11))
* **mcp:** add typed bounded analysis tools ([893d2e2](https://github.com/morluto/rea/commit/893d2e2d0550271297c452b418f80bdf8ae21514))
### Bug Fixes
* keep execution options internal ([167a824](https://github.com/morluto/rea/commit/167a824f9131a60d07fca3e99301eecc98d5d5e9))
### Documentation
* document the 43-tool workflow ([1f235cb](https://github.com/morluto/rea/commit/1f235cb52f61c08b65ecb674cac649c34005bc66))
### Tests
* add source-built Hopper conformance fixtures ([d648bcd](https://github.com/morluto/rea/commit/d648bcd2e41ec0b45bc02a69dee0d7206fc64f8f))
## [0.2.1](https://github.com/morluto/rea/compare/rea-0.2.0...rea-0.2.1) (2026-07-12)
+2 -1
View File
@@ -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
```
+123 -100
View File
@@ -6,39 +6,58 @@
### 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.**
[![npm version](https://img.shields.io/npm/v/%40morluto%2Frea?style=flat-square&color=cb3837)](https://www.npmjs.com/package/@morluto/rea)
[![CI](https://img.shields.io/github/actions/workflow/status/morluto/rea/ci.yml?branch=main&style=flat-square&label=CI)](https://github.com/morluto/rea/actions/workflows/ci.yml)
[![42 MCP tools](https://img.shields.io/badge/MCP_tools-42-5c4ee5?style=flat-square)](#the-42-tool-workbench)
[![Node.js 22+](https://img.shields.io/badge/Node.js-22%2B-339933?style=flat-square&logo=nodedotjs&logoColor=white)](https://nodejs.org/)
[![50 MCP tools](https://img.shields.io/badge/MCP_tools-50-5c4ee5?style=flat-square)](#50-tools-for-investigation)
[![Node.js 24](https://img.shields.io/badge/Node.js-24.18.x-339933?style=flat-square&logo=nodedotjs&logoColor=white)](https://nodejs.org/)
[![MIT license](https://img.shields.io/badge/license-MIT-f4c430?style=flat-square)](LICENSE)
[Quick start](#quick-start) · [See the workflow](#one-prompt-a-full-investigation) · [From binary to behavior](#from-binary-to-behavior) · [42 tools](#the-42-tool-workbench) · [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 />
<code>npx -y @morluto/rea setup --yes</code>
<code>npx skills add morluto/rea</code>
</div>
---
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 recover pseudocode, trace behavior across functions, inspect strings and types, and carry the evidence directly into its normal coding workflow. REA handles the reverse-engineering toolchain, analysis session, and target lifecycle 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.
## From binary to behavior
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
Install the REA skill:
```bash
npx skills add morluto/rea
```
Then ask:
```text
Set up REA and reverse engineer the Notes app. Explain how search works,
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.
## The investigation model
<table>
<tr>
<td width="33%" valign="top">
<strong>Decompile</strong><br /><br />
Open a native app or executable and recover procedures, pseudocode, assembly, strings, symbols, segments, and metadata.
Open an app and recover readable code, strings, names, and other clues about how it works.
</td>
<td width="33%" valign="top">
<strong>Understand</strong><br /><br />
Follow callers, callees, cross-references, and call graphs until the agent can explain how a feature or algorithm actually works.
Follow the code from one part of the app to another until the agent can explain how a feature actually works.
</td>
<td width="33%" valign="top">
<strong>Recreate</strong><br /><br />
@@ -47,66 +66,67 @@ Turn what the agent learned into a feature for your own product, adapted to your
</tr>
</table>
REA keeps the investigation grounded in binary evidence. It does not claim to recover original source code or automatically clone an application.
REA shows how it reached its conclusions. It does not claim to recover original source code or automatically clone an application.
## Why REA
| | |
| ------------------------ | -------------------------------------------------------------------------------------------------------- |
| **Built for agents** | Ask what a compiled app does and let your agent gather evidence instead of guessing. |
| **CLI and MCP** | Run the same reverse-engineering capabilities from your terminal or coding agent. |
| **Complexity handled** | REA sets up the tools, opens the app, keeps the investigation running, and cleans up afterward. |
| **From insight to code** | Understand a feature, then use the evidence to build your own implementation in the same coding session. |
| **Local by design** | Analysis runs on your Mac. REA does not upload the binary to a hosted analysis service. |
| **Keeps context** | Investigate several binaries without starting the analysis over for every question. |
| | |
| ------------------------ | ------------------------------------------------------------------------------------ |
| **Built for agents** | Ask what an app does and let your agent inspect it instead of guessing. |
| **CLI and MCP** | Run the same reverse-engineering capabilities from your terminal or coding agent. |
| **Complexity handled** | REA installs and manages the reverse-engineering tools behind the scenes. |
| **From insight to code** | Understand a feature, then build your own version in the same coding session. |
| **Local by design** | Analysis runs on your Mac. REA does not upload the app to a hosted analysis service. |
| **Keeps context** | Investigate several apps without starting over for every question. |
## Quick start
### Requirements
### With a coding agent — recommended
- macOS 12 or newer
- Node.js 22 or newer
- [Hopper Disassembler](https://www.hopperapp.com/)
```bash
npx skills add morluto/rea
```
REA does not bundle Hopper. Setup can install Homebrew when absent, install the `hopper-disassembler` cask, configure detected Claude Desktop and Cursor MCP clients, and install the REA agent skill. Existing client configuration is backed up, updated atomically, and read back before setup reports success.
Ask your agent to set up REA. It will check your Mac, explain anything it needs to install, ask for approval, and guide you through system prompts. After setup, restart the agent if it asks you to load the full REA toolset.
Hopper is the separate macOS analysis engine REA controls behind the scenes. Most users interact with REA through the CLI or their agent, but Hopper must be installed and licensed.
### 1. Set up REA
### From Terminal
```bash
npx -y @morluto/rea setup --yes
```
### 2. Check the integration
If macOS or an installer asks for confirmation, complete the prompt and run the same command again. Restart a configured coding agent so it loads REA.
### What setup handles
- macOS 12 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.
If something is not working, run:
```bash
npx -y @morluto/rea doctor
```
### 3. Start the MCP server
### CLI or coding agent?
```bash
npx -y @morluto/rea mcp
```
The MCP command speaks stdio, so it waits silently for a connected client. If setup detected your client, restart that client and use the registered `rea` server instead of running the command by hand.
Then ask your agent:
```text
Open /path/to/MyApp, summarize the binary, find the authentication flow,
decompile the relevant procedures, and show the evidence behind your conclusion.
```
| If you want to… | Use |
| --------------------------------------------------------- | ------------------------------------------ |
| Ask an agent to investigate an app and build a feature | Install the skill, then talk to your agent |
| Inspect or decompile one part of an app from the Terminal | `rea analyze` or `rea decompile` |
## One prompt, a full investigation
```text
Open MyApp, find how its offline search feature works, explain the control flow,
Reverse engineer the Notes app. Find how offline search works, explain it,
and build a version for my project using TypeScript and SQLite.
```
REA gives the agent a grounded path from that request to working code:
REA gives the agent a clear path from that request to working code:
| Step | What the agent does | REA tools |
| ---: | --------------------------------------- | ---------------------------------------------------------------- |
@@ -114,10 +134,10 @@ REA gives the agent a grounded path from that request to working code:
| 2 | Finds likely offline-search clues | `search_strings`, `search_procedures`, `list_names` |
| 3 | Connects those clues to executable code | `find_xrefs_to_name`, `xrefs`, `procedure_callers` |
| 4 | Reconstructs the relevant control flow | `get_call_graph`, `procedure_callees`, `procedure_info` |
| 5 | Recovers the implementation | `procedure_pseudo_code`, `procedure_assembly`, `batch_decompile` |
| 5 | Decompiles the relevant routines | `procedure_pseudo_code`, `procedure_assembly`, `batch_decompile` |
| 6 | Builds the feature in your project | code adapted to your stack, product, and requirements |
REA handles the binary-analysis work in steps 1–5. The agent performs step 6 with its normal file-editing and test tools, keeping the recreated behavior tied to evidence from the binary.
REA handles the app analysis in steps 1–5. The agent performs step 6 with its normal file-editing and test tools, using what it learned about the app.
## What agents can do
@@ -131,24 +151,47 @@ REA handles the binary-analysis work in steps 1–5. The agent performs step 6 w
- 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.
## The 42-tool workbench
## 50 tools for investigation
| Tool family | Count | Examples |
| ----------------- | ----: | ------------------------------------------------------------------------------------------------------ |
| Binary inspection | 31 | procedures, pseudocode, assembly, strings, names, segments, callers, callees, xrefs, annotations |
| Composed analysis | 8 | `binary_overview`, `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 inventory is contract-tested and verified through an isolated packaged MCP client. Real-Hopper verification separately checks the same 42-tool surface against two binaries.
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.
## MCP workflow
## Current status
1. Call `open_binary` with a readable local binary or `.hop` path.
2. Start with `binary_overview`, then narrow the investigation with strings, symbols, decompilation, callers, callees, and xrefs.
3. Call `open_binary` again to switch targets. If the new target fails, REA attempts to reopen the previous target.
4. Call `close_binary` when finished. `binary_session` reports the current state at any time.
REA is already useful for native application investigation on macOS:
Relative paths resolve from the MCP server's working directory. REA detects Mach-O/FAT, ELF, PE, and Hopper databases automatically. FAT binaries select the host-compatible architecture without presenting Hopper's common architecture picker.
- 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
Setup currently configures Claude Desktop and Cursor automatically. Any coding agent that supports local MCP servers can use REA with the configuration below.
### Manual MCP configuration
@@ -167,25 +210,29 @@ Relative paths resolve from the MCP server's working directory. REA detects Mach
```mermaid
flowchart LR
Agent["Coding agent"] -->|stdio MCP| REA["REA server<br/>42 tools"]
REA --> Session["Binary session<br/>open · switch · close"]
Session -->|authenticated Unix socket| Bridge["REA Python bridge<br/>inside Hopper"]
Bridge --> API["Hopper public<br/>Python API"]
Agent["Coding agent"] --> REA["REA<br/>CLI + MCP"]
Terminal --> REA
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 adapter call the same binary-session and analysis runtime directly; neither invokes the other. One-shot commands acquire and close their resources for each operation. MCP mode keeps one session alive across calls and target switches.
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
Use the package without installing it globally:
The agent workflow above is the easiest way to use REA. For a one-off overview from the Terminal:
```bash
npx -y @morluto/rea --help
npx -y @morluto/rea doctor --target /path/to/binary
npx -y @morluto/rea analyze /path/to/binary
npx -y @morluto/rea decompile /path/to/binary 0x100003f20
npx -y @morluto/rea analyze /Applications/Notes.app
```
Run `npx -y @morluto/rea --help` for direct decompilation and other options.
Or install the `rea` command globally:
```bash
@@ -194,16 +241,9 @@ rea --help
rea mcp
```
## Configuration
REA accepts a Mac `.app` folder directly. If an agent cannot find an app by name, tell it where the app is installed.
| Variable | Purpose |
| ------------------------- | ------------------------------------------------------------------------------------- |
| `HOPPER_TARGET_PATH` | Optional initial binary or `.hop` target. Target-free MCP sessions use `open_binary`. |
| `HOPPER_LAUNCHER_PATH` | Override the Hopper launcher path. |
| `HOPPER_TARGET_KIND` | Select `executable` or `database` for an initial target. |
| `HOPPER_LOADER_ARGS_JSON` | Override REA's derived Hopper loader arguments with a JSON array of strings. |
## 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.
@@ -213,9 +253,7 @@ Closing a REA session shuts down its bridge and removes its private socket direc
## Security model
Each bridge session has a random capability token and a Unix socket restricted to the current user. Protocol messages are bounded, and caller-visible errors omit launcher stderr and internal exception causes.
This boundary rejects unauthenticated connections and other local user accounts, but it is not a sandbox and does not defend against a malicious process already running as the same user. Opening an untrusted binary delegates parsing and analysis to Hopper with the current macOS user's 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
@@ -236,14 +274,14 @@ Hopper's launcher internally activates the application. REA requests background
<details>
<summary><strong>Does REA include Hopper?</strong></summary>
No. Hopper is separately installed and licensed software. REA supplies the CLI, MCP server, session management, analysis workflows, and authenticated bridge that make Hopper usable by agents.
No. Setup can install Hopper for you, but Hopper remains separate software with its own license. REA supplies the CLI, MCP server, and workflows that make it usable by agents.
</details>
<details>
<summary><strong>Does REA upload my binary?</strong></summary>
<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>
@@ -257,28 +295,13 @@ No decompiler can guarantee the original source. REA gives an agent pseudocode,
<details>
<summary><strong>Which agents can use REA?</strong></summary>
Any client that can launch a stdio MCP server can use the manual configuration. Setup currently detects and configures Claude Desktop and Cursor automatically.
Any coding agent that can run a local MCP server can use the manual configuration. Setup currently detects and configures Claude Desktop and Cursor automatically.
</details>
## Development
```bash
npm ci
npm run check
npm run verify:package
npm pack --dry-run
```
Real-Hopper verification requires two distinct binaries:
```bash
HOPPER_TARGET_PATH=/path/to/target-a \
HOPPER_SECOND_TARGET_PATH=/path/to/distinct-target-b \
npm run verify:hopper
```
See [CONTRIBUTING.md](CONTRIBUTING.md) for architecture guidelines, pull-request expectations, and the maintainer release checklist. Generated API documentation is available under [`docs/api`](docs/api/index.html).
See [CONTRIBUTING.md](CONTRIBUTING.md) for setup, architecture, tests, and release instructions. Generated API documentation is available under [`docs/api`](docs/api/index.html).
## Project links
+63 -78
View File
@@ -10,21 +10,36 @@
[![npm version](https://img.shields.io/npm/v/%40morluto%2Frea?style=flat-square&color=cb3837)](https://www.npmjs.com/package/@morluto/rea)
[![CI](https://img.shields.io/github/actions/workflow/status/morluto/rea/ci.yml?branch=main&style=flat-square&label=CI)](https://github.com/morluto/rea/actions/workflows/ci.yml)
[![42 MCP tools](https://img.shields.io/badge/MCP_tools-42-5c4ee5?style=flat-square)](#منصة-من-42-أداة)
[![Node.js 22+](https://img.shields.io/badge/Node.js-22%2B-339933?style=flat-square&logo=nodedotjs&logoColor=white)](https://nodejs.org/)
[![50 MCP tools](https://img.shields.io/badge/MCP_tools-50-5c4ee5?style=flat-square)](#منصة-من-50-أداة)
[![Node.js 24](https://img.shields.io/badge/Node.js-24.18.x-339933?style=flat-square&logo=nodedotjs&logoColor=white)](https://nodejs.org/)
[![MIT license](https://img.shields.io/badge/license-MIT-f4c430?style=flat-square)](LICENSE)
[البدء السريع](#البدء-السريع) · [من الملف التنفيذي إلى السلوك](#من-الملف-التنفيذي-إلى-السلوك) · [42 أداة](#منصة-من-42-أداة) · [كيف يعمل](#كيف-يعمل) · [الأسئلة الشائعة](#الأسئلة-الشائعة)
[البدء السريع](#البدء-السريع) · [من الملف التنفيذي إلى السلوك](#من-الملف-التنفيذي-إلى-السلوك) · [50 أداة](#منصة-من-50-أداة) · [كيف يعمل](#كيف-يعمل) · [الأسئلة الشائعة](#الأسئلة-الشائعة)
<br />
<code>npx -y @morluto/rea setup --yes</code>
<code>npx skills add morluto/rea</code>
</div>
هل وجدت في تطبيق ما ميزة تريدها في منتجك؟ أعط التطبيق إلى وكيل البرمجة حتى من دون شيفرته المصدرية. باستخدام REA، يستطيع الوكيل استقصاء الميزة وفهم آلية عملها ثم بناء نسخة ملائمة لتقنياتك وتصميمك ومتطلباتك.
يوفر REA هذه العملية عبر CLI وخادم MCP واحدين. يستطيع الوكيل استعادة الشيفرة شبه المصدرية، وتتبع السلوك بين الدوال، وفحص السلاسل والأنواع، ثم استخدام الأدلة مباشرة في عمله البرمجي المعتاد. ويجمع REA سلسلة أدوات الهندسة العكسية وجلسة التحليل ودورة حياة الهدف خلف واجهة واحدة.
يوفر REA هذه العملية عبر CLI وخادم MCP واحدين. يستطيع الوكيل قراءة شيفرة التطبيق وتتبع طريقة عمل الميزات ثم استخدام الأدلة مباشرة في عمله البرمجي المعتاد. ويتولى REA أدوات الهندسة العكسية المعقدة خلف واجهة واحدة.
## اطلب من وكيلك مباشرة
```bash
npx skills add morluto/rea
```
ثم اطلب:
```text
أعد REA وأجر هندسة عكسية لتطبيق Notes. اشرح طريقة عمل البحث وكيف توصلت إلى ذلك،
ثم ابنِ ميزة مشابهة لمشروعي.
```
تطبيق Notes مجرد مثال. يمكنك تسمية أي تطبيق تريد فهمه أو طلب البدء بنظرة عامة.
## من الملف التنفيذي إلى السلوك
@@ -32,7 +47,7 @@
| ----------------------------------------------------------------- | --------------------------------------------------------- | ------------------------------------------------------------- |
| استخرج الشيفرة شبه المصدرية، والتعليمات، والسلاسل النصية، والرموز | تتبع تدفق التحكم والمراجع المتبادلة والاستدعاءات والأنواع | حوّل ما تعلمه الوكيل إلى ميزة تناسب تقنياتك وواجهتك ومتطلباتك |
يربط REA الاستقصاء بأدلة من الملف التنفيذي. وهو لا يدّعي استعادة الشيفرة المصدرية الأصلية أو استنساخ التطبيق تلقائيًا.
يوضح REA كيف وصل إلى نتائجه. وهو لا يدّعي استعادة الشيفرة المصدرية الأصلية أو استنساخ التطبيق تلقائيًا.
## لماذا REA؟
@@ -47,45 +62,48 @@
## البدء السريع
### 1. المتطلبات
### باستخدام وكيل برمجة — موصى به
```bash
npx skills add morluto/rea
```
اطلب من الوكيل إعداد REA. سيفحص جهاز Mac ويشرح ما يحتاج إلى تثبيته ويطلب الموافقة ويرشدك خلال رسائل النظام. أعد تشغيل الوكيل بعد الإعداد إذا طلب ذلك لتحميل مجموعة الأدوات الكاملة.
### قبل البدء
- macOS 12 أو أحدث
- Node.js 22 أو أحدث
- [Hopper Disassembler](https://www.hopperapp.com/) مثبتًا ومرخصًا
- Node.js 24.18.x وnpm 11.16.x (شغّل `nvm use` لاختيار الإصدار المثبّت)
لا تتضمن حزمة REA برنامج Hopper. يستطيع الإعداد تثبيت Homebrew عند غيابه، وتثبيت حزمة `hopper-disassembler`، وتهيئة عملاء Claude Desktop وCursor المكتشفين، وتثبيت مهارة وكيل REA. تُنسخ إعدادات العملاء الحالية احتياطيًا، ثم تُحدّث ذريًا وتُقرأ مرة أخرى قبل الإبلاغ عن النجاح.
لا تحتاج إلى تثبيت أدوات الهندسة العكسية يدويًا. يثبت Setup برنامج Homebrew و[Hopper](https://www.hopperapp.com/) عند الحاجة، ثم يهيئ وكلاء البرمجة المدعومين. Hopper برنامج منفصل يحتاج إلى ترخيص خاص به؛ يثبته Setup لكنه لا يوفر الترخيص.
### 2. إعداد وكلائك
### 1. إعداد REA
```bash
npx -y @morluto/rea setup --yes
```
يكتشف هذا الأمر عملاء الوكلاء المدعومين ويسجل خادم MCP باستخدام أمر عائم:
إذا طلب macOS أو برنامج التثبيت تأكيدًا، فأكمل الخطوة ثم شغّل الأمر نفسه مرة أخرى.
### 2. إعادة تشغيل وكيل البرمجة
يهيئ Setup تلقائيًا نسخ Claude Desktop وCursor التي يعثر عليها. أعد تشغيل التطبيق كي يحمّل REA.
### 3. اطلب من الوكيل
يمكنك ذكر التطبيق باسمه. سيعثر وكيل البرمجة عليه ويمرر ملف البرنامج الذي يحتاجه REA.
```text
npx -y @morluto/rea mcp
استخدم REA لإجراء هندسة عكسية لتطبيق Notes. اشرح طريقة عمل البحث واعرض الأدلة،
ثم ابنِ ميزة مشابهة لمشروعي باستخدام SQLite.
```
### 3. تحقق من البيئة
إذا واجهت مشكلة، فشغّل:
```bash
npx -y @morluto/rea doctor
```
### 4. حلّل ملفًا تنفيذيًا
```bash
npx -y @morluto/rea analyze /path/to/app
npx -y @morluto/rea decompile /path/to/app 0x100003f20
```
أو شغّل خادم MCP مباشرة:
```bash
npx -y @morluto/rea mcp
```
## استقصاء كامل بطلب واحد
بعد الإعداد، يمكنك أن تطلب من وكيلك:
@@ -127,19 +145,17 @@ npx -y @morluto/rea mcp
افحص التحقق من الإدخال، والتشفير، والصلاحيات، والتخزين، ومسارات الأخطاء من دون رفع الملف التنفيذي إلى خدمة بعيدة.
## منصة من 42 أداة
## منصة من 50 أداة
| عائلة الأدوات | العدد | أمثلة |
| --------------------- | ----: | ----------------------------------------------------------------------------------- |
| فحص الملفات التنفيذية | 31 | الدوال، والشيفرة شبه المصدرية، والتعليمات، والسلاسل، والأسماء، والمراجع، والتعليقات |
| التحليل المركب | 8 | النظرة العامة، وفك الترجمة الدفعي، ومخططات الاستدعاء، والمراجع، واكتشاف الأنواع |
| جلسة الملف التنفيذي | 3 | `open_binary` و`binary_session` و`close_binary` |
| فحص الملفات التنفيذية | 33 | الدوال، والشيفرة شبه المصدرية، والتعليمات، والسلاسل، والأسماء، والمراجع، والتعليقات |
| التحليل المركب | 10 | النظرة العامة، وفك الترجمة الدفعي، ومخططات الاستدعاء، والمراجع، واكتشاف الأنواع |
| جلسة الملف التنفيذي | 7 | فتح الأهداف وحزم الأدلة والتقاط العمليات ومقارنتها |
تُختبر قائمة الأدوات العامة بعقود آلية وعميل MCP معزول من الحزمة. ويتحقق الاختبار باستخدام محرك التحليل الفعلي من واجهة الأدوات الـ42 نفسها على ملفين تنفيذيين.
## استخدام REA مع وكلاء برمجة آخرين
## استخدام REA مع وكيل MCP
يسجل `rea setup` تكوينًا مكافئًا لما يلي:
يهيئ Setup حاليًا Claude Desktop وCursor تلقائيًا. يمكن لأي وكيل برمجة يدعم خوادم MCP المحلية استخدام REA بالإعداد التالي:
```json
{
@@ -152,49 +168,31 @@ npx -y @morluto/rea mcp
}
```
يمكنك أيضًا تشغيل وضع MCP مباشرة:
```bash
rea mcp
rea --mcp
```
كلا الأمرين يشغل خادم stdio نفسه. تبقى جلسة Hopper حية بين طلبات الأدوات، ويمكن للوكيل تبديل الأهداف من دون إعادة بناء الخادم.
## كيف يعمل؟
```mermaid
flowchart LR
Human[المستخدم] --> Agent[وكيل البرمجة]
Agent -->|MCP| REA[REA]
Agent --> REA[REA]
Human -->|CLI| REA
REA --> Runtime[طبقة الجلسة والتحليل المشتركة]
Runtime --> Bridge[جسر Python داخل العملية]
Bridge --> Hopper[Hopper Disassembler]
Hopper --> Binary[ملف تنفيذي أو قاعدة .hop]
REA --> Hopper[محرك التحليل]
Hopper --> App[تطبيقك]
```
لا يستدعي محول CLI خادم MCP، ولا يستدعي خادم MCP واجهة CLI. كلاهما يعتمد مباشرة على طبقة التشغيل المشتركة نفسها.
يستخدم CLI وخادم MCP محرك التحليل نفسه. تغلق أوامر الطرفية التطبيق عند انتهائها، بينما تبقيه جلسة الوكيل مفتوحًا أثناء الاستقصاء.
## أوامر CLI
```text
rea setup [--yes] تسجيل REA لدى عملاء الوكلاء
rea doctor فحص Node.js وHopper وتكوين العملاء
rea analyze <target> تحليل ملف تنفيذي أو قاعدة Hopper
rea decompile <target> <address> فك ترجمة دالة عند عنوان محدد
rea mcp تشغيل خادم MCP عبر stdio
rea mcp add إضافة تسجيلات MCP
rea mcp doctor تشخيص تسجيلات MCP
rea --mcp اسم بديل متوافق لوضع MCP
```
اعرض جميع الخيارات باستخدام:
سير عمل الوكيل أعلاه هو أسهل طريقة لاستخدام REA. للحصول على نظرة سريعة على تطبيق من الطرفية:
```bash
npx -y @morluto/rea --help
npx -y @morluto/rea analyze /Applications/Notes.app
```
شغّل `npx -y @morluto/rea --help` لمعرفة خيارات فك الترجمة المباشر والخيارات الأخرى.
يمكن لـ REA فتح مجلد `.app` على Mac مباشرة. إذا لم يعثر الوكيل على التطبيق، فأخبره بمكان تثبيته.
## سلوك Hopper
يستطيع REA تشغيل Hopper تلقائيًا؛ لا حاجة إلى افتراض أنه يعمل مسبقًا. لكنه تطبيق macOS رسومي، لذلك قد يطلب التركيز أو يعرض حوارًا عندما يفرض Hopper قرارًا تفاعليًا. لا يمكن لـ REA ضمان تشغيل غير مرئي تمامًا لأن هذه الواجهة يملكها Hopper وmacOS.
@@ -219,7 +217,7 @@ export HOPPER_LOADER_ARGS_JSON='["-l", "Mach-O", "--aarch64"]'
<details>
<summary><strong>هل يتضمن REA محرك فك ترجمة خاصًا به؟</strong></summary>
لا. يوفر REA طبقة الوكيل وواجهة CLI وإدارة الجلسات وسير العمل، بينما يوفر Hopper محرك التحليل وفك الترجمة.
لا. يستطيع Setup تثبيت Hopper، لكنه يظل برنامجًا منفصلًا بترخيص خاص به. يوفر REA واجهة CLI وخادم MCP وسير العمل المخصص للوكلاء.
</details>
@@ -246,20 +244,7 @@ export HOPPER_LOADER_ARGS_JSON='["-l", "Mach-O", "--aarch64"]'
## التطوير
```bash
git clone https://github.com/morluto/rea.git
cd rea
npm ci
npm run check
```
يتطلب التحقق الحقيقي من Hopper تثبيتًا محليًا وملفات اختبار مناسبة:
```bash
npm run verify:hopper
```
راجع [CONTRIBUTING.md](CONTRIBUTING.md) لإرشادات التطوير والإصدار.
راجع [CONTRIBUTING.md](CONTRIBUTING.md) لإعداد بيئة التطوير وبنية المشروع والاختبارات وإرشادات الإصدار.
## المشروع
+76 -69
View File
@@ -10,15 +10,15 @@
[![npm version](https://img.shields.io/npm/v/%40morluto%2Frea?style=flat-square&color=cb3837)](https://www.npmjs.com/package/@morluto/rea)
[![CI](https://img.shields.io/github/actions/workflow/status/morluto/rea/ci.yml?branch=main&style=flat-square&label=CI)](https://github.com/morluto/rea/actions/workflows/ci.yml)
[![42 MCP tools](https://img.shields.io/badge/MCP_tools-42-5c4ee5?style=flat-square)](#42-ツールのワークベンチ)
[![Node.js 22+](https://img.shields.io/badge/Node.js-22%2B-339933?style=flat-square&logo=nodedotjs&logoColor=white)](https://nodejs.org/)
[![50 MCP tools](https://img.shields.io/badge/MCP_tools-50-5c4ee5?style=flat-square)](#50-ツールのワークベンチ)
[![Node.js 24](https://img.shields.io/badge/Node.js-24.18.x-339933?style=flat-square&logo=nodedotjs&logoColor=white)](https://nodejs.org/)
[![MIT license](https://img.shields.io/badge/license-MIT-f4c430?style=flat-square)](LICENSE)
[クイックスタート](#クイックスタート) · [バイナリから動作へ](#バイナリから動作へ) · [42 ツール](#42-ツールのワークベンチ) · [仕組み](#仕組み) · [FAQ](#faq)
[クイックスタート](#クイックスタート) · [バイナリから動作へ](#バイナリから動作へ) · [50 ツール](#50-ツールのワークベンチ) · [仕組み](#仕組み) · [FAQ](#faq)
<br />
<code>npx -y @morluto/rea setup --yes</code>
<code>npx skills add morluto/rea</code>
</div>
@@ -26,7 +26,22 @@
アプリの気になる機能を自分のプロダクトにも取り入れたいと思ったことはありませんか。ソースコードがなくても、そのアプリをコーディングエージェントに渡せます。REA を使えば、エージェントが機能を調査して仕組みを理解し、あなたの技術スタック、デザイン、要件に合わせた形で実装できます。
REA はこの流れをひとつの CLI / MCP サーバーで実現します。エージェントは疑似コードを復元し、関数をまたぐ動作を追跡し、文字列や型を調べ、その根拠を通常のコーディング作業へ直接持ち込めます。REA はツールチェーン、解析セッション、ターゲットのライフサイクルをひとつのインターフェースで扱います。
REA はこの流れをひとつの CLI / MCP サーバーで実現します。エージェントはコンパイル済みのアプリを調べ、機能の流れを追い、分かったことを通常のコーディング作業に活用できます。複雑なリバースエンジニアリングツールは REA がひとつのインターフェースの裏側で扱います。
## エージェントに頼むだけ
```bash
npx skills add morluto/rea
```
次のように依頼します:
```text
REA をセットアップしてメモアプリをリバースエンジニアリングしてください。
検索の仕組みと判断の理由を示し、私のプロジェクト向けに同様の機能を実装してください。
```
メモアプリは例にすぎません。調べたいアプリを指定するか、まず概要から始めるよう依頼できます。
## バイナリから動作へ
@@ -49,38 +64,51 @@ REA は調査をバイナリ上の根拠に結び付けます。元のソース
## クイックスタート
### 必要環境
- macOS 12 以降
- Node.js 22 以降
- [Hopper Disassembler](https://www.hopperapp.com/)
REA に Hopper は含まれません。Setup は必要に応じて Homebrew と `hopper-disassembler` cask をインストールし、検出した Claude Desktop / Cursor の MCP 設定と REA エージェントスキルを構成します。既存設定はバックアップされ、アトミックに更新された後、読み戻し検証されます。
### コーディングエージェントから(推奨)
```bash
# 1. インストールと設定
npx -y @morluto/rea setup --yes
# 2. 統合を診断
npx -y @morluto/rea doctor
# 3. stdio MCP サーバーを起動
npx -y @morluto/rea mcp
npx skills add morluto/rea
```
MCP コマンドは stdio で通信するため、クライアント接続を静かに待ちます。Setup がクライアントを構成した場合は、そのクライアントを再起動して登録済みの `rea` サーバーを使用してください。
エージェントに REA のセットアップを依頼してください。Mac を確認し、必要なインストールを説明して承認を求め、システムプロンプトを案内します。完全なツールを読み込むため再起動を求められた場合は、セットアップ後に再起動してください。
エージェントへの依頼例:
### 始める前に
- macOS 12 以降
- Node.js 24.18.x と npm 11.16.x(`nvm use` で固定バージョンを選択)
リバースエンジニアリングツールを手動でインストールする必要はありません。Setup は必要に応じて Homebrew と [Hopper](https://www.hopperapp.com/) をインストールし、対応するコーディングエージェントを設定します。Hopper は別製品で、ライセンスは別途必要です。Setup はインストールしますが、ライセンスは提供しません。
```bash
# 1. REA をセットアップ
npx -y @morluto/rea setup --yes
```
macOS やインストーラーから確認を求められた場合は、その操作を完了してから同じコマンドをもう一度実行してください。
### 2. コーディングエージェントを再起動
Setup は検出した Claude Desktop と Cursor を自動設定します。REA を読み込むため、アプリを再起動してください。
### 3. エージェントに依頼
アプリは名前で指定できます。コーディングエージェントがアプリを探し、REA に必要なプログラムファイルを渡します。
```text
/path/to/MyApp を開き、バイナリの概要を示し、認証フローを見つけ、
関連プロシージャを逆コンパイルして、結論を支える根拠を提示してください。
REA でメモアプリをリバースエンジニアリングしてください。検索機能の仕組みと
根拠を示し、SQLite を使って私のプロジェクト向けに同様の機能を実装してください。
```
問題がある場合は次を実行します:
```bash
npx -y @morluto/rea doctor
```
## ひとつのプロンプトで調査を完結
```text
MyApp を開き、オフライン検索機能の仕組みと制御フローを説明し、
メモアプリをリバースエンジニアリングし、オフライン検索機能の仕組みを説明して、
TypeScript と SQLite を使って私のプロジェクト向けに実装してください。
```
@@ -90,7 +118,7 @@ TypeScript と SQLite を使って私のプロジェクト向けに実装して
| 2 | オフライン検索の手掛かりを探す | `search_strings`, `search_procedures`, `list_names` |
| 3 | 手掛かりと実行コードを接続 | `find_xrefs_to_name`, `xrefs`, `procedure_callers` |
| 4 | 制御フローを復元 | `get_call_graph`, `procedure_callees`, `procedure_info` |
| 5 | 実装を復元 | `procedure_pseudo_code`, `procedure_assembly`, `batch_decompile` |
| 5 | 関連する処理を逆コンパイル | `procedure_pseudo_code`, `procedure_assembly`, `batch_decompile` |
| 6 | プロジェクトに機能を実装する | 技術スタック、プロダクト、要件に合わせたコード |
REA は手順 1〜5 のバイナリ解析を処理し、手順 6 はエージェントの通常の編集・テストツールが行います。
@@ -107,26 +135,17 @@ REA は手順 1〜5 のバイナリ解析を処理し、手順 6 はエージェ
- Swift / Objective-C のメタデータを解析する。
- Hopper に名前、コメント、ブックマークを残し、人間とエージェントの調査を共有する。
## 42 ツールのワークベンチ
## 50 ツールのワークベンチ
| ツール群 | 数 | 例 |
| ------------------ | --: | ----------------------------------------------------------------------------------------------- |
| バイナリ調査 | 31 | プロシージャ、疑似コード、アセンブリ、文字列、名前、セグメント、callers、callees、xrefs、注釈 |
| 合成解析 | 8 | `binary_overview`, `batch_decompile`, `get_call_graph`, `find_xrefs_to_name`, Swift / ObjC 検出 |
| バイナリセッション | 3 | `open_binary`, `binary_session`, `close_binary` |
| ツール群 | 数 | 例 |
| ------------------ | --: | ------------------------------------------------------------------------------------------------------------------- |
| バイナリ調査 | 33 | プロシージャ、疑似コード、アセンブリ、文字列、名前、セグメント、callers、callees、xrefs、注釈 |
| 合成解析 | 10 | `binary_overview`, `analyze_function`, `batch_decompile`, `get_call_graph`, `find_xrefs_to_name`, Swift / ObjC 検出 |
| バイナリセッション | 7 | `open_binary`、`binary_session`、証拠バンドル、プロセス取得と比較 |
公開ツール一覧は契約テストと分離されたパッケージ MCP クライアントで検証されています。実 Hopper 検証では 2 つのバイナリを使って同じ 42 ツールを確認します。
## 他のコーディングエージェントで使う
## MCP ワークフロー
1. `open_binary` で読み取り可能なローカルバイナリまたは `.hop` を開きます。
2. `binary_overview` から始め、文字列、シンボル、逆コンパイル、callers、callees、xrefs で範囲を絞ります。
3. `open_binary` を再度呼び出してターゲットを切り替えます。失敗時は以前のターゲットを再度開きます。
4. 終了時に `close_binary` を呼びます。`binary_session` はいつでも状態を返します。
REA は Mach-O/FAT、ELF、PE、Hopper データベースを自動判定します。相対パスは MCP サーバーの作業ディレクトリ基準で、FAT バイナリではホスト互換アーキテクチャを選択します。
### MCP 手動設定
Setup は現在 Claude Desktop と Cursor を自動設定します。ローカル MCP サーバーに対応するコーディングエージェントなら、次の設定で REA を利用できます。
```json
{
@@ -143,23 +162,24 @@ REA は Mach-O/FAT、ELF、PE、Hopper データベースを自動判定しま
```mermaid
flowchart LR
Agent["コーディングエージェント"] -->|stdio MCP| REA["REA サーバー<br/>42 ツール"]
REA --> Session["バイナリセッション<br/>開く · 切替 · 閉じる"]
Session -->|認証済み Unix ソケット| Bridge["Hopper 内の<br/>REA Python ブリッジ"]
Bridge --> API["Hopper 公開<br/>Python API"]
Agent["コーディングエージェント"] --> REA["REA<br/>CLI + MCP"]
Terminal["ターミナル"] --> REA
REA --> Hopper["解析エンジン"]
Hopper --> App["あなたのアプリ"]
```
CLI と MCP アダプターは同じセッション・解析ランタイムを直接使用し、互いを呼び出しません。単発コマンドは処理ごとにリソースを閉じ、MCP モードは複数の呼び出しやターゲット切り替えをまたいでセッションを維持します。
CLI と MCP サーバーは同じ解析エンジンを使います。ターミナルのコマンドは終了時にアプリを閉じ、エージェントのセッションは調査中アプリを開いたままにします。
## CLI
上のエージェントワークフローが、REA を使う最も簡単な方法です。ターミナルから一度だけアプリの概要を調べる場合は、次を実行します。
```bash
npx -y @morluto/rea --help
npx -y @morluto/rea doctor --target /path/to/binary
npx -y @morluto/rea analyze /path/to/binary
npx -y @morluto/rea decompile /path/to/binary 0x100003f20
npx -y @morluto/rea analyze /Applications/Notes.app
```
直接デコンパイルする方法やその他のオプションは、`npx -y @morluto/rea --help` で確認できます。
グローバルな `rea` コマンドとしてもインストールできます。
```bash
@@ -168,6 +188,8 @@ rea --help
rea mcp
```
REA は Mac の `.app` フォルダーを直接開けます。エージェントがアプリを見つけられない場合は、インストール場所を伝えてください。
## Hopper アプリの動作
REA は必要なときに Hopper を起動します。Hopper のランチャーは内部でアプリをアクティブ化するため、ターゲットを開くと Hopper が前面に出る場合があります。REA はバックグラウンド起動を要求しますが、常に背面に留まる保証はありません。
@@ -188,7 +210,7 @@ REA は必要なときに Hopper を起動します。Hopper のランチャー
<details><summary><strong>REA に Hopper は含まれますか?</strong></summary>
含まれません。Hopper は別途インストール・ライセンスが必要です。REA は CLI、MCP、セッション、解析ワークフロー、認証ブリッジを提供します。
含まれません。Setup で Hopper をインストールできますが、Hopper は独自のライセンスを持つ別製品です。REA は CLI、MCP サーバー、エージェント向けワークフローを提供します。
</details>
@@ -206,22 +228,7 @@ REA にホスト型解析サービスはありません。ローカル Unix ソ
## 開発
```bash
npm ci
npm run check
npm run verify:package
npm pack --dry-run
```
実 Hopper 検証には異なる 2 つのバイナリが必要です。
```bash
HOPPER_TARGET_PATH=/path/to/target-a \
HOPPER_SECOND_TARGET_PATH=/path/to/distinct-target-b \
npm run verify:hopper
```
アーキテクチャ規約、Pull Request、リリース手順は [CONTRIBUTING.md](CONTRIBUTING.md) を参照してください。
開発環境、アーキテクチャ、テスト、リリース手順は [CONTRIBUTING.md](CONTRIBUTING.md) を参照してください。
## ライセンス
+76 -69
View File
@@ -10,15 +10,15 @@
[![npm version](https://img.shields.io/npm/v/%40morluto%2Frea?style=flat-square&color=cb3837)](https://www.npmjs.com/package/@morluto/rea)
[![CI](https://img.shields.io/github/actions/workflow/status/morluto/rea/ci.yml?branch=main&style=flat-square&label=CI)](https://github.com/morluto/rea/actions/workflows/ci.yml)
[![42 MCP tools](https://img.shields.io/badge/MCP_tools-42-5c4ee5?style=flat-square)](#42개-도구로-구성된-워크벤치)
[![Node.js 22+](https://img.shields.io/badge/Node.js-22%2B-339933?style=flat-square&logo=nodedotjs&logoColor=white)](https://nodejs.org/)
[![50 MCP tools](https://img.shields.io/badge/MCP_tools-50-5c4ee5?style=flat-square)](#50개-도구로-구성된-워크벤치)
[![Node.js 24](https://img.shields.io/badge/Node.js-24.18.x-339933?style=flat-square&logo=nodedotjs&logoColor=white)](https://nodejs.org/)
[![MIT license](https://img.shields.io/badge/license-MIT-f4c430?style=flat-square)](LICENSE)
[빠른 시작](#빠른-시작) · [바이너리에서 동작까지](#바이너리에서-동작까지) · [42개 도구](#42개-도구로-구성된-워크벤치) · [작동 방식](#작동-방식) · [FAQ](#faq)
[빠른 시작](#빠른-시작) · [바이너리에서 동작까지](#바이너리에서-동작까지) · [50개 도구](#50개-도구로-구성된-워크벤치) · [작동 방식](#작동-방식) · [FAQ](#faq)
<br />
<code>npx -y @morluto/rea setup --yes</code>
<code>npx skills add morluto/rea</code>
</div>
@@ -26,7 +26,22 @@
어떤 앱에서 마음에 드는 기능을 자신의 제품에도 넣고 싶나요? 소스 코드가 없어도 앱을 코딩 에이전트에 전달할 수 있습니다. REA를 사용하면 에이전트가 기능을 조사하고 작동 방식을 이해한 뒤, 사용자의 기술 스택, 디자인, 요구 사항에 맞는 버전을 구현할 수 있습니다.
REA는 이 과정을 하나의 CLI 및 MCP 서버로 제공합니다. 에이전트는 의사 코드를 복구하고, 함수 사이의 동작을 추적하고, 문자열과 타입을 조사한 뒤 그 증거를 일반 코딩 작업에 바로 활용할 수 있습니다. REA는 도구 체인, 분석 세션, 대상 수명 주기를 하나의 인터페이스로 처리합니다.
REA는 이 과정을 하나의 CLI 및 MCP 서버로 제공합니다. 에이전트는 컴파일된 앱을 조사하고 기능의 작동 방식을 추적한 뒤 알아낸 내용을 일반 코딩 작업에 활용할 수 있습니다. 복잡한 리버스 엔지니어링 도구는 REA가 하나의 인터페이스 뒤에서 처리합니다.
## 에이전트에게 바로 요청하기
```bash
npx skills add morluto/rea
```
그런 다음 요청하세요.
```text
REA를 설정하고 메모 앱을 리버스 엔지니어링해 주세요. 검색 기능의 작동 방식과
그렇게 판단한 이유를 보여 주고, 제 프로젝트에 비슷한 기능을 구현해 주세요.
```
메모 앱은 예시일 뿐입니다. 이해하고 싶은 앱을 지정하거나 먼저 개요부터 살펴보라고 요청할 수 있습니다.
## 바이너리에서 동작까지
@@ -49,38 +64,51 @@ REA는 분석을 바이너리 증거에 근거하게 합니다. 원본 소스
## 빠른 시작
### 요구 사항
- macOS 12 이상
- Node.js 22 이상
- [Hopper Disassembler](https://www.hopperapp.com/)
REA는 Hopper를 번들하지 않습니다. Setup은 필요할 때 Homebrew와 `hopper-disassembler` cask를 설치하고, 감지된 Claude Desktop 및 Cursor MCP 클라이언트와 REA 에이전트 스킬을 구성합니다. 기존 설정은 백업되고 원자적으로 갱신된 뒤 다시 읽어 검증합니다.
### 코딩 에이전트에서 시작하기(권장)
```bash
# 1. 설치 및 구성
npx -y @morluto/rea setup --yes
# 2. 통합 진단
npx -y @morluto/rea doctor
# 3. stdio MCP 서버 시작
npx -y @morluto/rea mcp
npx skills add morluto/rea
```
MCP 명령은 stdio로 통신하므로 클라이언트 연결을 조용히 기다립니다. Setup이 클라이언트를 구성했다면 클라이언트를 다시 시작하고 등록된 `rea` 서버를 사용하세요.
에이전트에게 REA 설정을 요청하세요. Mac을 확인하고 필요한 설치 내용을 설명한 뒤 승인을 요청하며 시스템 프롬프트를 안내합니다. 전체 도구를 불러오기 위해 재시작하라고 하면 설정 후 에이전트를 재시작하세요.
에이전트에게 다음과 같이 요청할 수 있습니다.
### 시작하기 전에
- macOS 12 이상
- Node.js 24.18.x 및 npm 11.16.x (`nvm use`로 고정 버전 선택)
리버스 엔지니어링 도구를 직접 설치할 필요는 없습니다. Setup은 필요할 때 Homebrew와 [Hopper](https://www.hopperapp.com/)를 설치하고 지원되는 코딩 에이전트를 구성합니다. Hopper는 별도 소프트웨어이며 별도 라이선스가 필요합니다. Setup은 Hopper를 설치하지만 라이선스를 제공하지 않습니다.
```bash
# 1. REA 설정
npx -y @morluto/rea setup --yes
```
macOS나 설치 프로그램이 확인을 요청하면 해당 절차를 완료하고 같은 명령을 다시 실행하세요.
### 2. 코딩 에이전트 재시작
Setup은 감지된 Claude Desktop과 Cursor를 자동으로 구성합니다. REA를 불러오도록 앱을 재시작하세요.
### 3. 에이전트에게 요청
앱 이름만 말해도 됩니다. 코딩 에이전트가 앱을 찾아 REA에 필요한 프로그램 파일을 전달합니다.
```text
/path/to/MyApp을 열고 바이너리를 요약한 다음 인증 흐름을 찾아
관련 프로시저를 디컴파일하고 결론을 뒷받침하는 증거를 보여 주세요.
REA로 메모 앱을 리버스 엔지니어링해 주세요. 검색 기능의 작동 방식과 증거를 보여 주고,
SQLite를 사용해 제 프로젝트에 비슷한 기능을 구현해 주세요.
```
문제가 있으면 다음을 실행하세요.
```bash
npx -y @morluto/rea doctor
```
## 하나의 프롬프트로 전체 조사
```text
MyApp을 열고 오프라인 검색 기능의 작동 방식과 제어 흐름을 설명한 다음,
메모 앱을 리버스 엔지니어링해 오프라인 검색 기능의 작동 방식을 설명한 다음,
TypeScript와 SQLite를 사용해 제 프로젝트에 맞는 버전을 구현해 주세요.
```
@@ -90,7 +118,7 @@ TypeScript와 SQLite를 사용해 제 프로젝트에 맞는 버전을 구현해
| 2 | 오프라인 검색 단서 찾기 | `search_strings`, `search_procedures`, `list_names` |
| 3 | 단서를 실행 코드에 연결 | `find_xrefs_to_name`, `xrefs`, `procedure_callers` |
| 4 | 제어 흐름 재구성 | `get_call_graph`, `procedure_callees`, `procedure_info` |
| 5 | 구현 복구 | `procedure_pseudo_code`, `procedure_assembly`, `batch_decompile` |
| 5 | 관련 루틴 디컴파일 | `procedure_pseudo_code`, `procedure_assembly`, `batch_decompile` |
| 6 | 프로젝트에 기능 구현 | 기술 스택, 제품, 요구 사항에 맞는 코드 |
REA는 1–5단계의 바이너리 분석을 처리합니다. 6단계는 에이전트가 일반 파일 편집 및 테스트 도구로 수행합니다.
@@ -107,26 +135,17 @@ REA는 1–5단계의 바이너리 분석을 처리합니다. 6단계는 에이
- Swift 및 Objective-C 메타데이터를 분석합니다.
- Hopper에 이름, 주석, 북마크를 남겨 사람과 에이전트의 분석을 연결합니다.
## 42개 도구로 구성된 워크벤치
## 50개 도구로 구성된 워크벤치
| 도구 그룹 | 수 | 예시 |
| ------------- | --: | ------------------------------------------------------------------------------------------------ |
| 바이너리 검사 | 31 | 프로시저, 의사 코드, 어셈블리, 문자열, 이름, 세그먼트, callers, callees, xrefs, 주석 |
| 합성 분석 | 8 | `binary_overview`, `batch_decompile`, `get_call_graph`, `find_xrefs_to_name`, Swift 및 ObjC 탐색 |
| 바이너리 세션 | 3 | `open_binary`, `binary_session`, `close_binary` |
| 도구 그룹 | 수 | 예시 |
| ------------- | --: | -------------------------------------------------------------------------------------------------------------------- |
| 바이너리 검사 | 33 | 프로시저, 의사 코드, 어셈블리, 문자열, 이름, 세그먼트, callers, callees, xrefs, 주석 |
| 합성 분석 | 10 | `binary_overview`, `analyze_function`, `batch_decompile`, `get_call_graph`, `find_xrefs_to_name`, Swift 및 ObjC 탐색 |
| 바이너리 세션 | 7 | `open_binary`, `binary_session`, 증거 번들, 프로세스 캡처 및 비교 |
공개 도구 목록은 계약 테스트와 격리된 패키지 MCP 클라이언트로 검증됩니다. 실제 Hopper 검증은 서로 다른 두 바이너리에서 같은 42개 도구를 확인합니다.
## 다른 코딩 에이전트에서 사용하기
## MCP 워크플로
1. `open_binary`로 읽을 수 있는 로컬 바이너리 또는 `.hop` 파일을 엽니다.
2. `binary_overview`로 시작하고 문자열, 심볼, 디컴파일, callers, callees, xrefs로 범위를 좁힙니다.
3. `open_binary`를 다시 호출해 대상을 전환합니다. 새 대상이 실패하면 이전 대상을 다시 엽니다.
4. 완료되면 `close_binary`를 호출합니다. `binary_session`은 언제든 현재 상태를 반환합니다.
REA는 Mach-O/FAT, ELF, PE, Hopper 데이터베이스를 자동으로 감지합니다. 상대 경로는 MCP 서버 작업 디렉터리를 기준으로 하며 FAT 바이너리는 호스트와 호환되는 아키텍처를 선택합니다.
### 수동 MCP 구성
Setup은 현재 Claude Desktop과 Cursor를 자동으로 구성합니다. 로컬 MCP 서버를 지원하는 코딩 에이전트라면 다음 설정으로 REA를 사용할 수 있습니다.
```json
{
@@ -143,23 +162,24 @@ REA는 Mach-O/FAT, ELF, PE, Hopper 데이터베이스를 자동으로 감지합
```mermaid
flowchart LR
Agent["코딩 에이전트"] -->|stdio MCP| REA["REA 서버<br/>42개 도구"]
REA --> Session["바이너리 세션<br/>열기 · 전환 · 닫기"]
Session -->|인증 Unix 소켓| Bridge["Hopper 내부<br/>REA Python 브리지"]
Bridge --> API["Hopper 공개<br/>Python API"]
Agent["코딩 에이전트"] --> REA["REA<br/>CLI + MCP"]
Terminal["터미널"] --> REA
REA --> Hopper["분석 엔진"]
Hopper --> App["사용자의 앱"]
```
CLI와 MCP 어댑터는 같은 세션 및 분석 런타임을 직접 사용하며 서로를 호출하지 않습니다. 일회성 명령은 매 작업 후 리소스를 닫고 MCP 모드는 여러 호출과 대상 전환 동안 세션을 유지합니다.
CLI와 MCP 서버는 같은 분석 엔진을 사용합니다. 터미널 명령은 끝날 때 앱을 닫고, 에이전트 세션은 조사하는 동안 앱을 열어 둡니다.
## CLI
위의 에이전트 워크플로가 REA를 사용하는 가장 쉬운 방법입니다. 터미널에서 앱을 한 번 빠르게 살펴보려면 다음을 실행하세요.
```bash
npx -y @morluto/rea --help
npx -y @morluto/rea doctor --target /path/to/binary
npx -y @morluto/rea analyze /path/to/binary
npx -y @morluto/rea decompile /path/to/binary 0x100003f20
npx -y @morluto/rea analyze /Applications/Notes.app
```
직접 디컴파일하는 방법과 기타 옵션은 `npx -y @morluto/rea --help`에서 확인할 수 있습니다.
전역 `rea` 명령으로 설치할 수도 있습니다.
```bash
@@ -168,6 +188,8 @@ rea --help
rea mcp
```
REA는 Mac의 `.app` 폴더를 직접 열 수 있습니다. 에이전트가 앱을 찾지 못하면 설치 위치를 알려 주세요.
## Hopper 앱 동작
REA는 필요할 때 Hopper를 시작합니다. Hopper 런처는 내부적으로 앱을 활성화하므로 대상을 열 때 Hopper가 다른 창 앞으로 나타날 수 있습니다. REA는 백그라운드 시작을 요청하지만 항상 뒤에 머무르는 것을 보장할 수 없습니다.
@@ -188,7 +210,7 @@ REA는 필요할 때 Hopper를 시작합니다. Hopper 런처는 내부적으로
<details><summary><strong>REA에 Hopper가 포함되나요?</strong></summary>
아니요. Hopper는 별도로 설치하고 라이선스를 받아야 합니다. REA는 CLI, MCP, 세션 관리, 분석 워크플로 및 인증 브리지를 제공합니다.
아니요. Setup이 Hopper를 설치할 수 있지만 Hopper는 자체 라이선스가 필요한 별도 소프트웨어입니다. REA는 CLI, MCP 서버, 에이전트용 워크플로를 제공합니다.
</details>
@@ -206,22 +228,7 @@ REA에는 호스팅 분석 서비스가 없습니다. 로컬 Unix 소켓을 통
## 개발
```bash
npm ci
npm run check
npm run verify:package
npm pack --dry-run
```
실제 Hopper 검증에는 서로 다른 두 바이너리가 필요합니다.
```bash
HOPPER_TARGET_PATH=/path/to/target-a \
HOPPER_SECOND_TARGET_PATH=/path/to/distinct-target-b \
npm run verify:hopper
```
아키텍처 지침, Pull Request 요구 사항, 릴리스 체크리스트는 [CONTRIBUTING.md](CONTRIBUTING.md)를 참고하세요.
개발 환경, 아키텍처, 테스트, 릴리스 지침은 [CONTRIBUTING.md](CONTRIBUTING.md)를 참고하세요.
## 라이선스
+76 -69
View File
@@ -10,15 +10,15 @@
[![npm version](https://img.shields.io/npm/v/%40morluto%2Frea?style=flat-square&color=cb3837)](https://www.npmjs.com/package/@morluto/rea)
[![CI](https://img.shields.io/github/actions/workflow/status/morluto/rea/ci.yml?branch=main&style=flat-square&label=CI)](https://github.com/morluto/rea/actions/workflows/ci.yml)
[![42 MCP tools](https://img.shields.io/badge/MCP_tools-42-5c4ee5?style=flat-square)](#42-个工具组成的工作台)
[![Node.js 22+](https://img.shields.io/badge/Node.js-22%2B-339933?style=flat-square&logo=nodedotjs&logoColor=white)](https://nodejs.org/)
[![50 MCP tools](https://img.shields.io/badge/MCP_tools-50-5c4ee5?style=flat-square)](#50-个工具组成的工作台)
[![Node.js 24](https://img.shields.io/badge/Node.js-24.18.x-339933?style=flat-square&logo=nodedotjs&logoColor=white)](https://nodejs.org/)
[![MIT license](https://img.shields.io/badge/license-MIT-f4c430?style=flat-square)](LICENSE)
[快速开始](#快速开始) · [从二进制到行为](#从二进制到行为) · [42 个工具](#42-个工具组成的工作台) · [工作原理](#工作原理) · [常见问题](#常见问题)
[快速开始](#快速开始) · [从二进制到行为](#从二进制到行为) · [50 个工具](#50-个工具组成的工作台) · [工作原理](#工作原理) · [常见问题](#常见问题)
<br />
<code>npx -y @morluto/rea setup --yes</code>
<code>npx skills add morluto/rea</code>
</div>
@@ -26,7 +26,22 @@
看到某个应用中想加入自己产品的功能?即使没有源代码,也可以把应用交给编程智能体。借助 REA,智能体能够调查该功能、理解其工作原理,并按照你的技术栈、设计和需求构建适合你产品的版本。
REA 通过一个 CLI 与 MCP 服务器实现这套流程。智能体可以恢复伪代码、跨函数追踪行为、检查字符串和类型,并把证据直接带入日常编码工作。REA 将逆向工程工具链、分析会话和目标生命周期统一在一个接口背后。
REA 通过一个 CLI 与 MCP 服务器实现这套流程。智能体可以检查编译后的应用、追踪功能的工作方式,并把了解到的内容用于日常编码。REA 在一个接口背后处理复杂的逆向工程工具。
## 直接询问智能体
```bash
npx skills add morluto/rea
```
然后说:
```text
设置 REA 并逆向工程“备忘录”应用。解释搜索功能的工作方式,
说明你是如何得出结论的,然后为我的项目构建类似功能。
```
“备忘录”只是示例。你可以说出任何想了解的应用,也可以让智能体先从概览开始。
## 从二进制到行为
@@ -49,38 +64,51 @@ REA 让调查始终以二进制证据为依据。它不会声称能恢复原始
## 快速开始
### 环境要求
- macOS 12 或更高版本
- Node.js 22 或更高版本
- [Hopper Disassembler](https://www.hopperapp.com/)
REA 不捆绑 Hopper。Setup 可以在需要时安装 Homebrew 和 `hopper-disassembler` cask,配置检测到的 Claude Desktop 与 Cursor MCP 客户端,并安装 REA 智能体技能。现有客户端配置会先备份,再原子写入并进行语义回读验证。
### 使用编程智能体(推荐)
```bash
# 1. 安装和配置
npx -y @morluto/rea setup --yes
# 2. 检查集成
npx -y @morluto/rea doctor
# 3. 启动 stdio MCP 服务器
npx -y @morluto/rea mcp
npx skills add morluto/rea
```
MCP 命令通过 stdio 通信,因此会静默等待客户端连接。如果 setup 已检测并配置你的客户端,请重启客户端并使用已注册的 `rea` 服务器。
让智能体设置 REA。它会检查你的 Mac,说明需要安装的内容,先征得同意,并引导你处理系统提示。如果智能体要求重启以加载完整工具,请在设置后重启。
然后可以这样询问智能体:
### 开始之前
- macOS 12 或更高版本
- Node.js 24.18.x 和 npm 11.16.x(运行 `nvm use` 选择固定版本)
你不需要手动安装逆向工程工具。Setup 会在需要时安装 Homebrew 和 [Hopper](https://www.hopperapp.com/),然后配置支持的编程智能体。Hopper 是独立软件,需要单独授权;Setup 可以安装它,但不提供许可证。
```bash
# 1. 安装和配置 REA
npx -y @morluto/rea setup --yes
```
如果 macOS 或安装程序要求确认,请完成提示,然后再次运行同一命令。
### 2. 重启编程智能体
Setup 会自动配置检测到的 Claude Desktop 和 Cursor。重启应用,让它加载 REA。
### 3. 直接询问智能体
你可以直接说出应用名称。编程智能体会找到应用,并把 REA 所需的程序文件交给它。
```text
打开 /path/to/MyApp,概述该二进制文件,找到身份验证流程,
反编译相关过程,并展示支持结论的证据。
使用 REA 逆向工程“备忘录”应用。解释搜索功能的工作方式,展示证据,
然后使用 SQLite 为我的项目构建一个类似功能。
```
如果遇到问题,请运行:
```bash
npx -y @morluto/rea doctor
```
## 一个提示词,完成一次完整调查
```text
打开 MyApp,找到离线搜索功能的工作方式,解释其控制流,
逆向工程“备忘录”应用,找到离线搜索功能的工作方式,解释其控制流,
并使用 TypeScript 和 SQLite 为我的项目构建一个版本。
```
@@ -90,7 +118,7 @@ MCP 命令通过 stdio 通信,因此会静默等待客户端连接。如果 se
| 2 | 搜索可能的离线搜索线索 | `search_strings`, `search_procedures`, `list_names` |
| 3 | 将线索连接到可执行代码 | `find_xrefs_to_name`, `xrefs`, `procedure_callers` |
| 4 | 重建相关控制流 | `get_call_graph`, `procedure_callees`, `procedure_info` |
| 5 | 恢复实现 | `procedure_pseudo_code`, `procedure_assembly`, `batch_decompile` |
| 5 | 反编译相关程序 | `procedure_pseudo_code`, `procedure_assembly`, `batch_decompile` |
| 6 | 在你的项目中构建该功能 | 适合你的技术栈、产品和需求的代码 |
REA 负责第 1–5 步中的二进制分析。第 6 步由智能体使用其常规文件编辑与测试工具完成。
@@ -107,26 +135,17 @@ REA 负责第 1–5 步中的二进制分析。第 6 步由智能体使用其常
- 分析 Swift 和 Objective-C 元数据。
- 在 Hopper 中留下名称、注释与书签,使人与智能体的分析互相增强。
## 42 个工具组成的工作台
## 50 个工具组成的工作台
| 工具类别 | 数量 | 示例 |
| ---------- | ---: | ------------------------------------------------------------------------------------------------ |
| 二进制检查 | 31 | 过程、伪代码、汇编、字符串、名称、段、调用者、被调用者、交叉引用、注释 |
| 组合分析 | 8 | `binary_overview`, `batch_decompile`, `get_call_graph`, `find_xrefs_to_name`、Swift 与 ObjC 发现 |
| 二进制会话 | 3 | `open_binary`, `binary_session`, `close_binary` |
| 工具类别 | 数量 | 示例 |
| ---------- | ---: | -------------------------------------------------------------------------------------------------------------------- |
| 二进制检查 | 33 | 过程、伪代码、汇编、字符串、名称、段、调用者、被调用者、交叉引用、注释 |
| 组合分析 | 10 | `binary_overview`, `analyze_function`, `batch_decompile`, `get_call_graph`, `find_xrefs_to_name`、Swift 与 ObjC 发现 |
| 二进制会话 | 7 | `open_binary`、`binary_session`、证据包、进程捕获与比较 |
公开工具清单通过契约测试和隔离的打包 MCP 客户端验证。真实 Hopper 验证还会使用两个二进制文件检查同一套 42 工具接口。
## 与其他编程智能体一起使用
## MCP 工作流
1. 使用 `open_binary` 打开可读的本地二进制文件或 `.hop` 路径。
2. 从 `binary_overview` 开始,再通过字符串、符号、反编译、调用者、被调用者和交叉引用缩小范围。
3. 再次调用 `open_binary` 切换目标;如果新目标失败,REA 会尝试重新打开之前的目标。
4. 完成后调用 `close_binary`。`binary_session` 可随时报告当前状态。
REA 自动识别 Mach-O/FAT、ELF、PE 和 Hopper 数据库。相对路径以 MCP 服务器工作目录为基准;FAT 二进制会自动选择与宿主兼容的架构。
### 手动 MCP 配置
Setup 目前会自动配置 Claude Desktop 和 Cursor。任何支持本地 MCP 服务器的编程智能体都可以使用以下配置连接 REA。
```json
{
@@ -143,23 +162,24 @@ REA 自动识别 Mach-O/FAT、ELF、PE 和 Hopper 数据库。相对路径以 MC
```mermaid
flowchart LR
Agent["编程智能体"] -->|stdio MCP| REA["REA 服务器<br/>42 个工具"]
REA --> Session["二进制会话<br/>打开 · 切换 · 关闭"]
Session -->|认证 Unix 套接字| Bridge["Hopper 内部的<br/>REA Python 桥"]
Bridge --> API["Hopper 公开<br/>Python API"]
Agent["编程智能体"] --> REA["REA<br/>CLI + MCP"]
Terminal["终端"] --> REA
REA --> Hopper["分析引擎"]
Hopper --> App["你的应用"]
```
CLI 与 MCP 适配器直接调用同一套二进制会话和分析运行时,二者不会互相调用。一次性命令会为每次操作获取并关闭资源;MCP 模式则在多次调用和目标切换之间保持会话。
CLI 与 MCP 服务器使用相同的分析引擎。终端命令完成后会关闭应用;智能体会话则会在调查期间保持应用打开。
## CLI
上面的智能体工作流是使用 REA 最简单的方式。如果只想在终端中快速了解一个应用:
```bash
npx -y @morluto/rea --help
npx -y @morluto/rea doctor --target /path/to/binary
npx -y @morluto/rea analyze /path/to/binary
npx -y @morluto/rea decompile /path/to/binary 0x100003f20
npx -y @morluto/rea analyze /Applications/Notes.app
```
运行 `npx -y @morluto/rea --help` 查看直接反编译和其他选项。
也可以全局安装 `rea` 命令:
```bash
@@ -168,6 +188,8 @@ rea --help
rea mcp
```
REA 可以直接打开 Mac 的 `.app` 文件夹。如果智能体找不到应用,请告诉它应用安装在哪里。
## Hopper 应用行为
REA 会在需要时启动 Hopper,无需预先运行。Hopper 启动器内部会激活应用,因此打开目标时 Hopper 可能出现在其他窗口前。REA 会请求 macOS 在后台启动 Hopper,但无法保证窗口始终位于后台。
@@ -190,7 +212,7 @@ REA 会推导明确的格式和架构参数,以避免常见的 FAT 与 ARM 选
<details><summary><strong>REA 是否包含 Hopper?</strong></summary>
不包含。Hopper 是独立安装和授权的软件。REA 提供 CLI、MCP 服务器、会话管理、分析工作流和认证桥。
不包含。Setup 可以为你安装 Hopper,但 Hopper 仍是需要单独授权的软件。REA 提供 CLI、MCP 服务器和面向智能体的工作流。
</details>
@@ -208,22 +230,7 @@ REA 不提供托管分析服务,而是通过本地 Unix 套接字把操作交
## 开发
```bash
npm ci
npm run check
npm run verify:package
npm pack --dry-run
```
真实 Hopper 验证需要两个不同的二进制文件:
```bash
HOPPER_TARGET_PATH=/path/to/target-a \
HOPPER_SECOND_TARGET_PATH=/path/to/distinct-target-b \
npm run verify:hopper
```
架构规范、Pull Request 要求与维护者发布清单请参阅 [CONTRIBUTING.md](CONTRIBUTING.md)。
开发环境、架构、测试和发布说明请参阅 [CONTRIBUTING.md](CONTRIBUTING.md)。
## 许可证
+275 -5
View File
@@ -85,6 +85,86 @@ def _procedure_name(procedure):
return procedure.getSegment().getNameAtAddress(entry) or _hex(entry)
def _procedure_identity(procedure):
return {"address": _hex(procedure.getEntryPoint()), "name": _procedure_name(procedure)}
def _containing_procedure(document, address):
segment = document.getSegmentAtAddress(address)
if segment is None:
return None, "outside_segments"
procedure = segment.getProcedureAtAddress(address)
return (procedure, None) if procedure is not None else (None, "not_in_procedure")
def _instruction_addresses(procedure, limit):
result = []
seen = set()
segment = procedure.getSegment()
truncated = False
for block in procedure.basicBlockIterator():
address = block.getStartingAddress()
end = block.getEndingAddress()
while address < end and address not in seen:
if len(result) >= limit:
truncated = True
return result, truncated
seen.add(address)
instruction = segment.getInstructionAtAddress(address)
if instruction is None:
break
result.append(address)
length = instruction.getInstructionLength()
if length <= 0:
break
address += length
return result, truncated
def _procedure_references(document, params):
procedure = _procedure(document, params.get("procedure"))
direction = params.get("direction", "outgoing")
offset = params.get("offset", 0)
limit = params.get("limit", 100)
max_instructions = params.get("max_instructions", 500)
if direction not in ("incoming", "outgoing"):
raise ValueError("direction must be incoming or outgoing")
if not isinstance(offset, int) or isinstance(offset, bool) or offset < 0:
raise ValueError("offset must be a non-negative integer")
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_instructions, int) or isinstance(max_instructions, bool) or max_instructions < 1 or max_instructions > 5000:
raise ValueError("max_instructions must be an integer between 1 and 5000")
addresses, scan_truncated = _instruction_addresses(procedure, max_instructions)
edges = set()
for address in addresses:
segment = _segment(document, address)
references = segment.getReferencesFromAddress(address) if direction == "outgoing" else segment.getReferencesOfAddress(address)
for reference in references:
edges.add((address, reference) if direction == "outgoing" else (reference, address))
ordered = sorted(edges)
items = []
selected = ordered[offset:offset + limit]
for source, target in selected:
source_procedure, _ = _containing_procedure(document, source)
target_procedure, _ = _containing_procedure(document, target)
items.append({
"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"),
})
next_offset = offset + len(selected)
has_more = next_offset < len(ordered)
truncated = scan_truncated or has_more
return {
"procedure": _procedure_identity(procedure), "direction": direction,
"references": {"items": items, "total": None if scan_truncated else len(ordered), "returned": len(items), "truncated": truncated, "next_offset": next_offset if has_more and not scan_truncated else None},
"instructions_scanned": len(addresses), "instruction_scan_truncated": scan_truncated,
}
def _procedure_map(document):
result = {}
for segment in document.getSegmentsList():
@@ -102,7 +182,73 @@ def _strings(document):
return result
def _assembly(procedure):
def _page(values, offset, limit):
"""Return one deterministically address-sorted page without crossing an unbounded map."""
if not isinstance(offset, int) or isinstance(offset, bool) or offset < 0:
raise ValueError("offset must be a non-negative integer")
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")
ordered = sorted(values.items(), key=lambda item: int(item[0], 16))
selected = ordered[offset:offset + limit]
total = len(ordered)
next_offset = offset + len(selected)
has_more = next_offset < total
return {
"items": [{"address": address, "value": value} for address, value in selected],
"offset": offset,
"limit": limit,
"total": total,
"next_offset": next_offset if has_more else None,
"has_more": has_more,
}
def _unavailable(reason):
"""Describe evidence the public Hopper API cannot truthfully provide."""
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 = []
segment = procedure.getSegment()
@@ -118,6 +264,8 @@ def _assembly(procedure):
arguments = [instruction.getFormattedArgument(index) for index in range(instruction.getArgumentCount())]
suffix = ", ".join(value for value in arguments if value is not None)
lines.append("%s: %s%s" % (_hex(address), instruction.getInstructionString(), (" " + suffix) if suffix else ""))
if limit is not None and len(lines) >= limit:
return "\n".join(lines)
length = instruction.getInstructionLength()
if length <= 0:
break
@@ -125,6 +273,101 @@ def _assembly(procedure):
return "\n".join(lines)
def _analyze_function(document, params):
"""Collect a bounded, single-pass function dossier for agent callers."""
procedure = _procedure(document, params.get("procedure"))
limit = params.get("limit", 100)
max_chars = params.get("max_pseudocode_chars", 20000)
max_instructions = params.get("max_instructions", 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 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 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:
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": sorted(set(successors), key=lambda value: int(value, 16)),
})
pseudo = procedure.decompile() or ""
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 = []
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 = []
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_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},
}
def _dispatch(method, params):
"""Dispatch only the closed operation set implemented by REA's public tools."""
global _selected_document
@@ -143,6 +386,17 @@ def _dispatch(method, params):
document = _document(params.get("document"))
if method == "analyze_function":
return _analyze_function(document, params)
if method == "resolve_containing_procedure":
address = _address(document, params.get("address"))
procedure, reason = _containing_procedure(document, address)
if procedure is None:
return {"query_address": _hex(address), "found": False, "procedure": None, "reason": reason}
return {"query_address": _hex(address), "found": True, "procedure": _procedure_identity(procedure)}
if method == "procedure_references":
return _procedure_references(document, params)
if method == "current_address":
return _hex(document.getCurrentAddress())
if method == "current_procedure":
@@ -175,21 +429,37 @@ def _dispatch(method, params):
for segment in document.getSegmentsList():
start = segment.getStartingAddress()
sections = [{"name": section.getName(), "start": _hex(section.getStartingAddress()), "end": _hex(section.getStartingAddress() + section.getLength())} for section in segment.getSectionsList()]
result.append({"name": segment.getName(), "start": _hex(start), "end": _hex(start + segment.getLength()), "writable": False, "executable": False, "sections": sections})
result.append({
"name": segment.getName(),
"start": _hex(start),
"end": _hex(start + segment.getLength()),
"writable": None,
"executable": None,
"permissions": _unavailable(
"Hopper's public Python API does not expose segment permissions"
),
"sections": sections,
})
return result
if method == "list_procedures":
return _procedure_map(document)
return _page(_procedure_map(document), params.get("offset", 0), params.get("limit", 100))
if method == "list_strings":
values = _strings(document)
requested = params.get("address")
return values if requested is None else values.get(_hex(_address(document, requested)))
if requested is not None:
key = _hex(_address(document, requested))
values = {key: values[key]} if key in values else {}
return _page(values, params.get("offset", 0), params.get("limit", 100))
if method == "list_names":
result = {}
for segment in document.getSegmentsList():
for item in segment.getNamedAddresses():
result[_hex(item)] = segment.getNameAtAddress(item)
requested = params.get("address")
return result if requested is None else result.get(_hex(_address(document, requested)))
if requested is not None:
key = _hex(_address(document, requested))
result = {key: result[key]} if key in result else {}
return _page(result, params.get("offset", 0), params.get("limit", 100))
if method in ("search_procedures", "search_strings"):
flags = 0 if params.get("case_sensitive", False) else re.IGNORECASE
expression = re.compile(params["pattern"], flags)
+70 -6
View File
@@ -1,17 +1,20 @@
{
"name": "@morluto/rea",
"version": "0.2.1",
"version": "0.3.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@morluto/rea",
"version": "0.2.1",
"version": "0.3.0",
"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",
+29 -9
View File
@@ -1,7 +1,7 @@
{
"name": "@morluto/rea",
"version": "0.2.1",
"description": "Reverse-engineering automation for Hopper Disassembler through one CLI and MCP server.",
"version": "0.3.0",
"description": "Reverse engineering CLI and MCP server for coding agents to understand apps and build features.",
"license": "MIT",
"repository": {
"type": "git",
@@ -13,10 +13,17 @@
},
"keywords": [
"reverse-engineering",
"hopper",
"binary-analysis",
"coding-agents",
"agent-skills",
"cli",
"mcp-server",
"model-context-protocol",
"static-analysis",
"disassembler",
"decompiler",
"mcp"
"mcp",
"hopper"
],
"publishConfig": {
"access": "public"
@@ -26,12 +33,15 @@
"rea": "scripts/rea.mjs"
},
"engines": {
"node": ">=22"
"node": ">=24.18.0 <25",
"npm": ">=11.16.0 <12"
},
"packageManager": "npm@11.16.0",
"files": [
"dist",
"bridge/hopper_bridge.py",
"scripts/rea.mjs",
"scripts/rebuild-native.mjs",
"skills",
"README.md",
"LICENSE"
@@ -56,11 +66,14 @@
"test:watch": "vitest",
"typecheck": "tsc --noEmit",
"verify:hopper": "npm run build && node scripts/verify-real-hopper.mjs",
"build:fixtures": "node scripts/build-conformance-fixtures.mjs",
"verify:fixtures": "npm run build:fixtures && node scripts/verify-conformance-fixtures.mjs",
"verify:package": "npm run build && node scripts/verify-package.mjs",
"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",
@@ -68,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": {
"node-pty@1.1.0": true
}
}
+223
View File
@@ -0,0 +1,223 @@
import { execFile } from "node:child_process";
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { arch, platform } from "node:os";
import { join, relative, resolve } from "node:path";
import { promisify } from "node:util";
import {
generateLargeFixture,
LARGE_FIXTURE_COUNT,
sha256,
sourceDigest,
} from "./lib/conformance-fixtures.mjs";
const exec = promisify(execFile);
const root = process.cwd();
const sourceRoot = join(root, "tests/conformance");
const outputRoot = resolve(
process.env.REA_CONFORMANCE_BUILD ?? join(root, "build/conformance"),
);
if (platform() !== "darwin")
throw new Error(
"Mach-O conformance fixtures require macOS and Xcode command-line tools",
);
await rm(outputRoot, { recursive: true, force: true });
await mkdir(outputRoot, { recursive: true });
const clang = (await exec("xcrun", ["--find", "clang"])).stdout.trim();
const clangVersion = (await exec(clang, ["--version"])).stdout.split("\n")[0];
const sdkPath = (await exec("xcrun", ["--show-sdk-path"])).stdout.trim();
const architecture = arch();
const common = [
"-arch",
architecture,
"-isysroot",
sdkPath,
"-O0",
"-g",
"-fno-inline",
];
const fixtures = [];
await build(
"c",
["c/fixture.c"],
[...common, join(sourceRoot, "c/fixture.c"), "-o", join(outputRoot, "c")],
{
expectations: {
symbols: ["_rea_entry", "_rea_branch", "_rea_leaf", "_rea_c_global"],
strings: ["REA_C_ENTRY", "REA_C_LEAF"],
},
},
);
await build(
"version-v1",
["versions/v1.c"],
[
...common,
join(sourceRoot, "versions/v1.c"),
"-o",
join(outputRoot, "version-v1"),
],
{
expectations: {
symbols: ["_rea_version_entry", "_rea_version_leaf"],
strings: ["REA_VERSION_ONE"],
},
},
);
await build(
"version-v2",
["versions/v2.c"],
[
...common,
join(sourceRoot, "versions/v2.c"),
"-o",
join(outputRoot, "version-v2"),
],
{
expectations: {
symbols: ["_rea_version_entry", "_rea_version_leaf", "_rea_added"],
strings: ["REA_VERSION_TWO"],
},
},
);
const largeSource = generateLargeFixture();
const largePath = join(outputRoot, "large.c");
await writeFile(largePath, largeSource);
await build(
"large",
[{ path: "generated/large.c", content: largeSource }],
[...common, largePath, "-o", join(outputRoot, "large")],
{
expectations: {
symbolPrefix: "_rea_page_",
symbolCount: LARGE_FIXTURE_COUNT,
stringPrefix: "REA_PAGE_",
stringCount: LARGE_FIXTURE_COUNT,
},
},
);
await build(
"objc",
["objc/fixture.m"],
[
...common,
"-fobjc-arc",
join(sourceRoot, "objc/fixture.m"),
"-framework",
"Foundation",
"-o",
join(outputRoot, "objc"),
],
{
expectations: {
symbols: ["_OBJC_CLASS_$_REAWidget"],
strings: ["REAWidget", "REAWidgetDelegate", "performAction:error:"],
},
},
);
await build(
"napi",
["napi/fixture.c"],
[
...common,
"-bundle",
"-undefined",
"dynamic_lookup",
join(sourceRoot, "napi/fixture.c"),
"-o",
join(outputRoot, "fixture.node"),
],
{
expectations: {
symbols: ["_napi_register_module_v1"],
strings: ["reaProbeOne", "reaProbeTwo", "reaProbeThree"],
},
},
);
try {
const swiftc = (await exec("xcrun", ["--find", "swiftc"])).stdout.trim();
const swiftVersion = (await exec(swiftc, ["--version"])).stdout.split(
"\n",
)[0];
await build(
"swift",
["swift/fixture.swift"],
[
"-sdk",
sdkPath,
"-Onone",
"-g",
join(sourceRoot, "swift/fixture.swift"),
"-o",
join(outputRoot, "swift"),
],
{
expectations: {
strings: [
"REA_SWIFT_EXECUTE",
"REAService",
"REARecord",
"REAState",
"REAProtocol",
],
},
compiler: swiftc,
compilerVersion: swiftVersion,
},
);
} catch (cause) {
process.stderr.write(
`Skipping optional Swift fixture: ${cause instanceof Error ? cause.message : String(cause)}\n`,
);
}
const manifest = {
schemaVersion: 1,
platform: platform(),
architecture,
generatedAt: new Date(0).toISOString(),
fixtures,
};
await writeFile(
join(outputRoot, "manifest.json"),
`${JSON.stringify(manifest, null, 2)}\n`,
);
process.stdout.write(
`${JSON.stringify({ outputRoot, fixtureCount: fixtures.length })}\n`,
);
async function build(name, sources, args, options) {
const compiler = options.compiler ?? clang;
const compilerVersion = options.compilerVersion ?? clangVersion;
const normalized = await Promise.all(
sources.map(async (source) => {
if (typeof source !== "string") return source;
return {
path: source,
content: await readFile(join(sourceRoot, source), "utf8"),
};
}),
);
await exec(compiler, args, { maxBuffer: 10 * 1024 * 1024 });
const artifactPath = args.at(-1);
if (typeof artifactPath !== "string")
throw new Error(`Missing output for ${name}`);
const artifact = await readFile(artifactPath);
fixtures.push({
name,
artifact: relative(outputRoot, artifactPath),
artifactSha256: sha256(artifact),
sourceSha256: sourceDigest(normalized),
sources: normalized.map(({ path, content }) => ({
path,
sha256: sha256(content),
})),
compiler: { path: compiler, version: compilerVersion, arguments: args },
expectations: options.expectations,
});
}
+6
View File
@@ -0,0 +1,6 @@
export const LARGE_FIXTURE_COUNT: number;
export function sha256(value: string | Uint8Array): string;
export function generateLargeFixture(count?: number): string;
export function sourceDigest(
sources: readonly { readonly path: string; readonly content: string }[],
): string;
+30
View File
@@ -0,0 +1,30 @@
import { createHash } from "node:crypto";
export const LARGE_FIXTURE_COUNT = 1_205;
export const sha256 = (value) =>
createHash("sha256").update(value).digest("hex");
export const generateLargeFixture = (count = LARGE_FIXTURE_COUNT) => {
if (!Number.isInteger(count) || count < 1)
throw new Error("Fixture count must be a positive integer");
const declarations = Array.from(
{ length: count },
(_, index) =>
`__attribute__((noinline, used)) int rea_page_${String(index).padStart(4, "0")}(void) { puts("REA_PAGE_${String(index).padStart(4, "0")}"); return ${index}; }`,
);
return [
"#include <stdio.h>",
...declarations,
"int main(void) { return rea_page_0000(); }",
"",
].join("\n");
};
export const sourceDigest = (sources) => {
const canonical = [...sources]
.sort(({ path: left }, { path: right }) => left.localeCompare(right))
.map(({ path, content }) => `${path}\0${sha256(content)}\n`)
.join("");
return sha256(canonical);
};
+1 -1
View File
@@ -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 42-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");
+22
View File
@@ -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;
});
+68
View File
@@ -0,0 +1,68 @@
import { execFile } from "node:child_process";
import { readFile } from "node:fs/promises";
import { dirname, join, resolve } from "node:path";
import { promisify } from "node:util";
import { sha256 } from "./lib/conformance-fixtures.mjs";
const exec = promisify(execFile);
const manifestPath = resolve(
process.argv[2] ?? "build/conformance/manifest.json",
);
const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
if (manifest.schemaVersion !== 1 || !Array.isArray(manifest.fixtures))
throw new Error("Unsupported conformance manifest");
const root = dirname(manifestPath);
for (const fixture of manifest.fixtures) {
const artifactPath = join(root, fixture.artifact);
const artifact = await readFile(artifactPath);
if (sha256(artifact) !== fixture.artifactSha256)
throw new Error(`${fixture.name}: artifact hash mismatch`);
const fileOutput = (await exec("file", [artifactPath])).stdout;
if (!fileOutput.includes("Mach-O"))
throw new Error(`${fixture.name}: not Mach-O`);
const symbols = (
await exec("nm", ["-gU", artifactPath], { maxBuffer: 20 * 1024 * 1024 })
).stdout;
const strings = (
await exec("strings", [artifactPath], { maxBuffer: 20 * 1024 * 1024 })
).stdout;
for (const symbol of fixture.expectations.symbols ?? [])
if (!symbols.includes(symbol))
throw new Error(`${fixture.name}: missing symbol ${symbol}`);
for (const value of fixture.expectations.strings ?? [])
if (!strings.includes(value))
throw new Error(`${fixture.name}: missing string ${value}`);
verifyCount(
symbols,
fixture.expectations.symbolPrefix,
fixture.expectations.symbolCount,
fixture.name,
);
verifyCount(
strings,
fixture.expectations.stringPrefix,
fixture.expectations.stringCount,
fixture.name,
);
}
const v1 = manifest.fixtures.find(({ name }) => name === "version-v1");
const v2 = manifest.fixtures.find(({ name }) => name === "version-v2");
if (v1?.artifactSha256 === v2?.artifactSha256)
throw new Error("Version-pair artifacts unexpectedly have equal hashes");
process.stdout.write(
`${JSON.stringify({ verified: manifest.fixtures.length, manifestPath })}\n`,
);
function verifyCount(output, prefix, expected, fixture) {
if (prefix === undefined || expected === undefined) return;
const count = output
.split("\n")
.filter((line) => line.includes(prefix)).length;
if (count !== expected)
throw new Error(
`${fixture}: expected ${expected} ${prefix} records, received ${count}`,
);
}
+9 -5
View File
@@ -125,8 +125,12 @@ try {
join(home, ".agents/skills/rea-analysis/SKILL.md"),
"utf8",
);
if (!skill.includes("open_binary"))
throw new Error("packaged skill readback failed");
const canonicalSkill = await readFile(
join(root, "skills/rea-analysis/SKILL.md"),
"utf8",
);
if (skill !== canonicalSkill)
throw new Error("packaged skill did not match its canonical source");
await writeFile(cursorConfig, "malformed");
const failed = json(
@@ -157,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 !== 42)
throw new Error("packaged MCP did not expose 42 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: {},
@@ -173,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: 42, 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 });
+188 -45
View File
@@ -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-")),
);
@@ -25,6 +21,123 @@ const textValue = (result) => {
const jsonValue = (result) => JSON.parse(textValue(result));
const requireSuccessfulTool = (result, operation) => {
if (result.isError === true) {
throw new Error(`${operation} failed: ${textValue(result)}`);
}
const value = jsonValue(result);
if (value === null || typeof value !== "object" || !("result" in value)) {
throw new Error(`${operation} omitted its evidence result`);
}
return value.result;
};
const firstProcedureAddress = (result) => {
const items = result.structuredContent?.result?.items;
if (!Array.isArray(items) || items.length === 0) {
throw new Error("The verifier target contains no analyzed procedure");
}
const address = items[0]?.address;
if (typeof address !== "string" || !/^0x[0-9a-f]+$/iu.test(address)) {
throw new Error("list_procedures returned an invalid procedure address");
}
return address;
};
const requirePseudocode = (value, operation) => {
if (typeof value !== "string" || value.trim().length === 0) {
throw new Error(`${operation} returned empty pseudocode`);
}
if (value === "No output" || value.startsWith("Error:")) {
throw new Error(`${operation} returned an embedded failure: ${value}`);
}
return value;
};
const requireFunctionDossier = (value, expectedAddress) => {
if (value === null || typeof value !== "object" || Array.isArray(value)) {
throw new Error("analyze_function returned no function dossier");
}
if (value.procedure?.address !== expectedAddress) {
throw new Error("analyze_function returned the wrong procedure");
}
if (
typeof value.procedure?.name !== "string" ||
value.procedure.name.trim().length === 0
) {
throw new Error("analyze_function omitted the procedure name");
}
requirePseudocode(value.pseudocode?.text, "analyze_function");
for (const field of [
"comments",
"callers",
"callees",
"incoming_references",
"outgoing_references",
"referenced_strings",
"referenced_names",
"basic_blocks",
]) {
const collection = value[field];
if (
collection === null ||
typeof collection !== "object" ||
!Array.isArray(collection.items) ||
!Number.isInteger(collection.total)
) {
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;
};
const requireSafeDiagnostics = (chunks) => {
const lines = chunks
.join("")
.split("\n")
.map((line) => line.trim())
.filter((line) => line.length > 0);
for (const line of lines) {
let diagnostic;
try {
diagnostic = JSON.parse(line);
} catch {
throw new Error(`The MCP runtime emitted malformed stderr: ${line}`);
}
if (
diagnostic === null ||
typeof diagnostic !== "object" ||
diagnostic.application !== "rea" ||
typeof diagnostic.level !== "number" ||
diagnostic.level >= 40
) {
throw new Error(`The MCP runtime emitted unsafe stderr: ${line}`);
}
}
return lines.length;
};
const targetPath = process.env.HOPPER_TARGET_PATH;
const secondTargetPath = process.env.HOPPER_SECOND_TARGET_PATH;
if (!targetPath || !secondTargetPath)
@@ -49,8 +162,10 @@ const transport = new StdioClientTransport({
stderr: "pipe",
});
let stderrBytes = 0;
const stderrChunks = [];
transport.stderr?.on("data", (chunk) => {
stderrBytes += chunk.length;
if (stderrBytes <= 16_384) stderrChunks.push(chunk.toString("utf8"));
});
const client = new Client({ name: "real-hopper-verifier", version: "1.0.0" });
let summary;
@@ -61,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 42 tools");
throw new Error("The real server did not expose the intended 50 tools");
}
const options = { timeout };
@@ -92,15 +207,73 @@ try {
{ name: "list_procedures", arguments: {} },
options,
);
const procedureMap = jsonValue(procedures);
const firstAddress = Object.keys(procedureMap)[0];
if (firstAddress === undefined) {
throw new Error("The verifier target contains no analyzed procedure");
requireSuccessfulTool(procedures, "list_procedures");
const firstAddress = firstProcedureAddress(procedures);
const containment = requireSuccessfulTool(
await client.callTool(
{
name: "resolve_containing_procedure",
arguments: { address: firstAddress },
},
options,
),
"resolve_containing_procedure",
);
if (
containment?.found !== true ||
containment.procedure?.address !== firstAddress
) {
throw new Error(
"resolve_containing_procedure returned the wrong procedure",
);
}
const references = requireSuccessfulTool(
await client.callTool(
{
name: "procedure_references",
arguments: {
procedure: firstAddress,
direction: "outgoing",
limit: 10,
max_instructions: 100,
},
},
options,
),
"procedure_references",
);
if (!Array.isArray(references?.references?.items)) {
throw new Error("procedure_references returned an invalid bounded result");
}
const bounded = await client.callTool(
{ name: "batch_decompile", arguments: { addresses: [firstAddress] } },
options,
);
const boundedResult = requireSuccessfulTool(bounded, "batch_decompile");
if (
boundedResult === null ||
typeof boundedResult !== "object" ||
Array.isArray(boundedResult)
) {
throw new Error("batch_decompile returned an invalid result");
}
const boundedPseudocode = requirePseudocode(
boundedResult[firstAddress],
"batch_decompile",
);
const dossier = requireFunctionDossier(
requireSuccessfulTool(
await client.callTool(
{
name: "analyze_function",
arguments: { procedure: firstAddress },
},
options,
),
"analyze_function",
),
firstAddress,
);
const switched = await client.callTool(
{ name: "open_binary", arguments: { path: targetB } },
options,
@@ -127,9 +300,7 @@ try {
"Hopper's bundled MCP server was running during verification",
);
}
if (stderrBytes !== 0) {
throw new Error("The MCP runtime emitted stderr during verification");
}
const diagnosticCount = requireSafeDiagnostics(stderrChunks);
const closed = await client.callTool(
{ name: "close_binary", arguments: {} },
@@ -144,9 +315,13 @@ try {
segmentCount: jsonValue(segments).length,
boundedTool: "batch_decompile",
boundedInputCount: 1,
boundedResultKeys: Object.keys(jsonValue(bounded)).length,
boundedResultKeys: Object.keys(boundedResult).length,
boundedPseudocodeChars: boundedPseudocode.length,
analyzedProcedure: dossier.procedure,
analyzedPseudocodeChars: dossier.pseudocode.text.length,
bundledMcpRunning,
stderrBytes,
diagnosticCount,
dynamicSession: true,
targets: [targetA, targetB],
switched: true,
@@ -159,7 +334,6 @@ try {
await transport.close();
} finally {
clearInterval(keepAlive);
await terminateNewHopperProcesses(hopperProcessesBefore);
}
}
@@ -195,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;
}
}
+39 -3
View File
@@ -1,8 +1,44 @@
---
name: rea-analysis
description: Analyze local binaries with REA and Hopper through its MCP session workflow.
description: Reverse engineer apps with REA. Explore how features work, then build a version tailored to your project.
---
# REA Analysis
# REA
Call `open_binary` with a readable local path before analysis. Begin with `binary_overview`, use strings and symbol searches to identify relevant procedures, then use `procedure_pseudo_code`, callers, callees, and xrefs to build evidence. Call `open_binary` again to switch targets and `close_binary` when finished. Never claim real-Hopper behavior from mock-only evidence.
Use REA when the user wants to understand how an app or feature works, compare app versions, decompile code, or build a similar feature.
## Understand the request
Identify the app and what the user wants to understand or build. Do not ask for information they already provided.
If the app is missing, ask which app they want to reverse engineer. If the app is known but the goal is unclear, ask what they want to understand or build, and offer to start with an overview. Never require the user to supply a program path, address, architecture, or reverse-engineering terminology.
Notes is only a documentation example. Never select an app unless the user names it or confirms it.
## Ensure REA is ready
1. Run `npx -y @morluto/rea doctor`.
2. If setup is needed, explain that REA may install Homebrew and Hopper. Hopper is separate software and requires its own license.
3. Obtain approval before installing external software, then run `npx -y @morluto/rea setup --yes`.
4. If macOS or an installer requests human input, tell the user exactly what needs attention. After they finish, rerun setup and doctor.
5. If setup registers a new MCP server, tell the user to restart their coding agent to load all REA tools. Direct CLI commands remain available before restart.
## Locate the app
Accept a human-readable app name. Search macOS application locations and system metadata. If one clear match is found, continue without asking for a path. If several apps match, show their names and locations and ask which one the user means. If none match, ask where the app is installed.
REA accepts a `.app` bundle directly. Do not expose its internal `Contents/MacOS` path unless it helps explain an error.
## Investigate
Briefly tell the user what you will investigate. Open the app with `open_binary`, begin with `binary_overview`, and narrow the investigation around the requested feature. Use decompilation, strings, names, callers, callees, and cross-references as needed.
Explain conclusions in plain language. Point to the relevant decompiled code, strings, names, and connections so the user can see how the explanation was reached. Do not claim to recover original source code or automatically clone an application.
## Build
When requested, use normal coding tools to build a version suited to the user's project, stack, interface, and requirements. Keep the implementation tied to what the investigation established, and distinguish observed behavior from assumptions or design choices.
## Human input and cleanup
Hopper or macOS may show a window that needs human input. Tell the user what appeared and ask them to handle it; do not guess or take over unrelated UI. Call `close_binary` when the investigation is complete.
+58
View File
@@ -0,0 +1,58 @@
import type { BinaryTarget } from "../domain/binaryTarget.js";
import type { AnalysisError } from "../domain/errors.js";
import type { JsonValue } from "../domain/jsonValue.js";
import type { Result } from "../domain/result.js";
interface ExecutionOptions {
readonly signal?: AbortSignal;
}
/** Provider-neutral application capability for one analysis operation. */
export interface AnalysisOperationPort {
execute(
operation: string,
parameters: Readonly<Record<string, JsonValue>>,
options?: ExecutionOptions,
): Promise<Result<JsonValue, AnalysisError>>;
}
/** Provider session bound to exactly one parsed artifact. */
export interface AnalysisClient extends AnalysisOperationPort {
close(): Promise<void>;
}
export type AnalysisClientFactory = (target: BinaryTarget) => AnalysisClient;
export interface ProviderIdentity {
readonly id: string;
readonly name: string;
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 CapabilityDescriptor[];
createClient(target: BinaryTarget): AnalysisClient;
}
+82 -21
View File
@@ -3,18 +3,38 @@ import { parseBinaryTarget } from "../domain/binaryTarget.js";
import {
HopperCancelledError,
NoBinaryOpenError,
type HopperError,
type AnalysisError,
} from "../domain/errors.js";
import { err, ok, type Result } from "../domain/result.js";
import type { JsonValue } from "../hopper/protocol.js";
import type { HopperToolPort } from "./HopperToolPort.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";
/** A closeable Hopper port created for exactly one parsed target. */
export interface BinaryClient extends HopperToolPort {
close(): Promise<void>;
/** Target lifecycle used by CLI and MCP without exposing a concrete provider. */
export interface BinarySessionPort extends AnalysisOperationPort {
open(
path: string,
options?: {
readonly signal?: AbortSignal;
readonly targetKind?: BinaryTarget["kind"];
},
): Promise<Result<BinaryTarget, AnalysisError>>;
close(): Promise<Result<null, AnalysisError>>;
status(): JsonValue;
activeTarget(): BinaryTarget | undefined;
recordEvidence(evidence: Evidence): void;
exportEvidenceBundle(): EvidenceBundle;
importEvidenceBundle(bundle: unknown): number;
providerIdentity(): ProviderIdentity;
}
/** Production seam for creating one unstarted client for a parsed target. */
export type BinaryClientFactory = (target: BinaryTarget) => BinaryClient;
/**
* Owns the single active target shared by CLI and MCP adapters.
@@ -24,13 +44,46 @@ export type BinaryClientFactory = (target: BinaryTarget) => BinaryClient;
* down. A failed switch recreates the previous target instead of retaining a
* client whose bridge was already shut down.
*/
export class BinarySession implements HopperToolPort {
export class BinarySession implements BinarySessionPort {
#active:
| { readonly target: BinaryTarget; readonly client: BinaryClient }
| { readonly target: BinaryTarget; readonly client: AnalysisClient }
| undefined;
#transition: Promise<void> = Promise.resolve();
readonly #calls = new Set<Promise<unknown>>();
constructor(readonly createClient: BinaryClientFactory) {}
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);
}
/**
* Open or switch targets after draining calls against the current target.
@@ -42,7 +95,7 @@ export class BinarySession implements HopperToolPort {
readonly signal?: AbortSignal;
readonly targetKind?: BinaryTarget["kind"];
} = {},
): Promise<Result<BinaryTarget, HopperError>> {
): Promise<Result<BinaryTarget, AnalysisError>> {
return this.#serialize(async () => {
if (isAborted(options.signal)) return err(new HopperCancelledError());
const parsed = await parseBinaryTarget(
@@ -59,8 +112,8 @@ export class BinarySession implements HopperToolPort {
const previous = this.#active;
this.#active = undefined;
await previous?.client.close();
const client = this.createClient(parsed.value);
const started = await client.callTool("health", {}, options);
const client = this.#createClient(parsed.value);
const started = await client.execute("health", {}, options);
if (!started.ok) {
await client.close();
await this.#restore(previous);
@@ -77,12 +130,13 @@ export class BinarySession implements HopperToolPort {
}
/** Close the active target, if any. */
close(): Promise<Result<null, HopperError>> {
close(): Promise<Result<null, AnalysisError>> {
return this.#serialize(async () => {
const previous = this.#active;
this.#active = undefined;
await this.#drainCalls();
await previous?.client.close();
this.#evidence.clear();
return ok(null);
});
}
@@ -95,26 +149,33 @@ export class BinarySession implements HopperToolPort {
: {
open: true,
path: target.path,
sha256: target.sha256,
format: target.format,
kind: target.kind,
architecture: target.architecture ?? null,
};
}
/** Return the immutable artifact identity captured before Hopper launched. */
activeTarget(): BinaryTarget | undefined {
return this.#active?.target;
}
/**
* Invoke a Hopper tool against the active target.
* Calls may overlap, but a pending target transition prevents new calls from
* entering until the transition has settled.
*/
async callTool(
async execute(
name: string,
arguments_: Readonly<Record<string, JsonValue>>,
options?: { readonly signal?: AbortSignal },
): Promise<Result<JsonValue, HopperError>> {
): Promise<Result<JsonValue, AnalysisError>> {
const transitioned = await this.#waitForTransition(options?.signal);
if (!transitioned.ok) return transitioned;
const active = this.#active;
if (active === undefined) return err(new NoBinaryOpenError());
const call = active.client.callTool(name, arguments_, options);
const call = active.client.execute(name, arguments_, options);
this.#calls.add(call);
try {
return await call;
@@ -138,12 +199,12 @@ export class BinarySession implements HopperToolPort {
async #restore(
previous:
| { readonly target: BinaryTarget; readonly client: BinaryClient }
| { readonly target: BinaryTarget; readonly client: AnalysisClient }
| undefined,
): Promise<void> {
if (previous === undefined) return;
const client = this.createClient(previous.target);
const started = await client.callTool("health", {});
const client = this.#createClient(previous.target);
const started = await client.execute("health", {});
if (started.ok) this.#active = { target: previous.target, client };
else await client.close();
}
+8 -3
View File
@@ -1,7 +1,8 @@
import { parseConfig } from "../config.js";
import type { JsonValue } from "../hopper/protocol.js";
import type { JsonValue } from "../domain/jsonValue.js";
import { createBinarySession } from "./runtime.js";
import { silentLogger, type Logger } from "../logger.js";
import { createEvidence } from "../domain/evidence.js";
/**
* Open one binary, execute one tool, and always release the bridge session.
@@ -22,9 +23,13 @@ export const runDirectAnalysis = async (
const opened = await session.open(path);
if (!opened.ok)
return { error: opened.error._tag, message: opened.error.message };
const result = await session.callTool(tool, arguments_);
const result = await session.execute(tool, arguments_);
return result.ok
? result.value
? createEvidence(opened.value, session.providerIdentity(), {
operation: tool,
parameters: arguments_,
result: result.value,
})
: { error: result.error._tag, message: result.error.message };
} finally {
await session.close();
+6 -10
View File
@@ -6,6 +6,7 @@ import { join } from "node:path";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { parseBinaryTarget } from "../domain/binaryTarget.js";
import { probeHomebrew } from "./homebrew.js";
const execFileAsync = promisify(execFile);
@@ -25,7 +26,7 @@ export interface DoctorHost {
readonly nodeVersion: string;
readonly configuredHopperPath?: string;
macosVersion(): Promise<string | undefined>;
readable(path: string): Promise<boolean>;
validTarget(path: string): Promise<boolean>;
executable(path: string): Promise<boolean>;
brewHopperPath(): Promise<string | undefined>;
manualHopperPaths(): Promise<readonly string[]>;
@@ -89,9 +90,9 @@ export const runDoctor = async (
checks.push(
check(
"target",
await host.readable(target),
await host.validTarget(target),
target,
"Supply a readable local binary path.",
"Supply a readable local app or program path.",
),
);
return {
@@ -117,13 +118,8 @@ export const systemDoctorHost = (): DoctorHost => ({
return undefined;
}
},
async readable(path) {
try {
await access(path, constants.R_OK);
return true;
} catch {
return false;
}
async validTarget(path) {
return (await parseBinaryTarget(path)).ok;
},
async executable(path) {
try {
+242 -32
View File
@@ -1,12 +1,12 @@
import type { HopperToolPort } from "./HopperToolPort.js";
import type { AnalysisOperationPort } from "./AnalysisProvider.js";
import type { EnhancedToolName } from "../contracts/enhancedInputs.js";
import { enhancedInputSchemas } from "../contracts/enhancedInputs.js";
import { HopperProtocolError, type HopperError } from "../domain/errors.js";
import { AnalysisProtocolError, type AnalysisError } from "../domain/errors.js";
import {
parseDocuments,
parseFunctionDossier,
parseAddressedPage,
parseListCount,
parseNames,
parseProcedures,
parseRelatedAddresses,
parseSegments,
} from "../domain/hopperValues.js";
@@ -17,9 +17,15 @@ import {
discoverObjcProtocols,
discoverSwiftClasses,
} from "../domain/symbolAnalysis.js";
import type { JsonValue } from "../hopper/protocol.js";
import type { JsonValue } from "../domain/jsonValue.js";
type EnhancedResult = Promise<Result<JsonValue, HopperError>>;
type EnhancedResult = Promise<Result<JsonValue, AnalysisError>>;
type TraceMatch = { type: string; address: string; value: string };
type TraceReference = {
target_address: string;
source_address: string;
containing_procedure: JsonValue;
};
/**
* Composes official Hopper operations into bounded reverse-engineering tools.
@@ -27,7 +33,7 @@ type EnhancedResult = Promise<Result<JsonValue, HopperError>>;
* validates them, allowing direct callers to use the same service safely.
*/
export class EnhancedTools {
constructor(private readonly hopper: HopperToolPort) {}
constructor(private readonly analysis: AnalysisOperationPort) {}
/** Parse inputs again at the direct-call boundary, then dispatch exhaustively. */
execute(
@@ -70,31 +76,49 @@ export class EnhancedTools {
? this.#findXrefs(parsed.data.name, signal)
: invalidInput(name, parsed.error);
}
case "binary_overview":
return this.#binaryOverview(signal);
case "binary_overview": {
const parsed = enhancedInputSchemas.binary_overview.safeParse(input);
return parsed.success
? this.#binaryOverview(parsed.data, signal)
: invalidInput(name, parsed.error);
}
case "analyze_function": {
const parsed = enhancedInputSchemas.analyze_function.safeParse(input);
return parsed.success
? this.#analyzeFunction(parsed.data, signal)
: invalidInput(name, parsed.error);
}
case "trace_feature": {
const parsed = enhancedInputSchemas.trace_feature.safeParse(input);
return parsed.success
? this.#traceFeature(parsed.data, signal)
: invalidInput(name, parsed.error);
}
}
}
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 result = await this.#call("list_procedures", {}, signal);
if (!result.ok) return result;
const procedures = parseProcedures(result.value);
const procedures = await this.#allProcedures(signal);
return procedures.ok
? ok(discoverSwiftClasses(procedures.value, pattern))
: procedures;
}
async #objcClasses(pattern: string, signal?: AbortSignal): EnhancedResult {
const result = await this.#call("list_names", {}, signal);
if (!result.ok) return result;
const names = parseNames(result.value);
const names = await this.#allAddressed("list_names", signal);
return names.ok ? ok(discoverObjcClasses(names.value, pattern)) : names;
}
async #objcProtocols(signal?: AbortSignal): EnhancedResult {
const result = await this.#call("list_names", {}, signal);
if (!result.ok) return result;
const names = parseNames(result.value);
const names = await this.#allAddressed("list_names", signal);
return names.ok ? ok(discoverObjcProtocols(names.value)) : names;
}
@@ -187,9 +211,7 @@ export class EnhancedTools {
}
async #analyzeSwiftTypes(signal?: AbortSignal): EnhancedResult {
const result = await this.#call("list_procedures", {}, signal);
if (!result.ok) return result;
const procedures = parseProcedures(result.value);
const procedures = await this.#allProcedures(signal);
return procedures.ok
? ok(categorizeSwiftTypes(procedures.value))
: procedures;
@@ -213,45 +235,230 @@ export class EnhancedTools {
);
}
async #binaryOverview(signal?: AbortSignal): EnhancedResult {
const [segmentsResult, documentsResult, proceduresResult, stringsResult] =
async #binaryOverview(
input: { detail: "concise" | "detailed"; limit: number },
signal?: AbortSignal,
): EnhancedResult {
const [segmentsResult, documentsResult, procedures, stringsResult] =
await Promise.all([
this.#call("list_segments", {}, signal),
this.#call("list_documents", {}, signal),
this.#call("list_procedures", {}, signal),
this.#allProcedures(signal),
this.#call("list_strings", {}, signal),
]);
if (!segmentsResult.ok) return segmentsResult;
if (!documentsResult.ok) return documentsResult;
if (!proceduresResult.ok) return proceduresResult;
if (!procedures.ok) return procedures;
if (!stringsResult.ok) return stringsResult;
const segments = parseSegments(segmentsResult.value);
if (!segments.ok) return segments;
const documents = parseDocuments(documentsResult.value);
if (!documents.ok) return documents;
const procedures = parseProcedures(proceduresResult.value);
if (!procedures.ok) return procedures;
const stringCount = parseListCount(stringsResult.value, "strings");
if (!stringCount.ok) return stringCount;
return ok({
document: documents.value[0] ?? "unknown",
detail: input.detail,
segments: segments.value
.slice(0, 10)
.map(({ name, start, end }) => ({ name, start, end })),
.slice(0, input.limit)
.map(({ name, start, end }) =>
input.detail === "detailed"
? { name, start, end, length: addressDistance(start, end) }
: { name, start, end },
),
segment_count: segments.value.length,
procedure_count: procedures.value.length,
string_count: stringCount.value,
});
}
async #traceFeature(
input: {
readonly query: string;
readonly case_sensitive: boolean;
readonly limit: number;
readonly max_operations: number;
},
signal?: AbortSignal,
): EnhancedResult {
const searched = await this.#literalMatches(input, signal);
if (!searched.ok) return searched;
const residual = new Set<string>();
const traced = await this.#traceReferences(
searched.value.matches,
input.limit,
input.max_operations - searched.value.operations,
signal,
);
if (!traced.ok) return traced;
for (const reason of traced.value.residual) residual.add(reason);
const operations = searched.value.operations + traced.value.operations;
if (operations >= input.max_operations)
residual.add("Investigation reached the operation budget.");
if (searched.value.matches.length >= input.limit)
residual.add("Literal matches reached the configured limit.");
return ok({
query: input.query,
search_mode: "literal",
operations_used: operations,
operation_budget: input.max_operations,
matches: searched.value.matches,
references: traced.value.references,
truncated: residual.size > 0,
residual_unknowns: [...residual],
});
}
async #literalMatches(
input: {
readonly query: string;
readonly case_sensitive: boolean;
readonly limit: number;
readonly max_operations: number;
},
signal?: AbortSignal,
): Promise<
Result<{ matches: TraceMatch[]; operations: number }, AnalysisError>
> {
let operations = 0;
const matches: TraceMatch[] = [];
const needle = input.case_sensitive
? input.query
: input.query.toLowerCase();
for (const [tool, type] of [
["list_strings", "string"],
["list_procedures", "procedure"],
] as const) {
let offset = 0;
while (
operations < input.max_operations &&
matches.length < input.limit
) {
operations += 1;
const result = await this.#call(tool, { offset, limit: 500 }, signal);
if (!result.ok) return result;
const page = parseAddressedPage(result.value);
if (!page.ok) return page;
for (const item of page.value.items) {
const haystack = input.case_sensitive
? item.name
: item.name.toLowerCase();
if (haystack.includes(needle))
matches.push({ type, address: item.address, value: item.name });
if (matches.length >= input.limit) break;
}
if (!page.value.hasMore || page.value.nextOffset === null) break;
offset = page.value.nextOffset;
}
}
return ok({ matches, operations });
}
async #traceReferences(
matches: readonly TraceMatch[],
limit: number,
operationBudget: number,
signal?: AbortSignal,
): Promise<
Result<
{ references: TraceReference[]; operations: number; residual: string[] },
AnalysisError
>
> {
let operations = 0;
const references: TraceReference[] = [];
const residual = new Set<string>();
for (const match of matches) {
if (operations >= operationBudget) {
residual.add("Reference traversal stopped at the operation budget.");
break;
}
operations += 1;
const xrefs = await this.#call(
"xrefs",
{ address: match.address },
signal,
);
if (!xrefs.ok) return xrefs;
if (!Array.isArray(xrefs.value))
return err(
new AnalysisProtocolError("xrefs returned a non-array result"),
);
for (const source of xrefs.value) {
if (typeof source !== "string")
return err(
new AnalysisProtocolError("xrefs returned a non-address value"),
);
if (operations >= operationBudget) {
residual.add(
"Containing-procedure resolution stopped at the operation budget.",
);
break;
}
operations += 1;
const resolved = await this.#call(
"resolve_containing_procedure",
{ address: source },
signal,
);
if (!resolved.ok) return resolved;
references.push({
target_address: match.address,
source_address: source,
containing_procedure: resolved.value,
});
if (references.length >= limit) {
residual.add("Reference results reached the configured limit.");
break;
}
}
if (references.length >= limit) break;
}
return ok({
references,
operations,
residual: [...residual],
});
}
async #allProcedures(signal?: AbortSignal) {
return this.#allAddressed("list_procedures", signal);
}
async #allAddressed(
tool: "list_names" | "list_procedures",
signal?: AbortSignal,
) {
const entries: Array<{ address: string; name: string }> = [];
let offset = 0;
while (true) {
const result = await this.#call(tool, { offset, limit: 500 }, signal);
if (!result.ok) return result;
const page = parseAddressedPage(result.value);
if (!page.ok) return page;
entries.push(...page.value.items);
if (!page.value.hasMore || page.value.nextOffset === null) {
return ok(entries);
}
if (page.value.nextOffset <= offset) {
return err(
new AnalysisProtocolError(
`${tool} returned a non-advancing pagination offset`,
),
);
}
offset = page.value.nextOffset;
}
}
#call(
name: string,
arguments_: Readonly<Record<string, JsonValue>>,
signal?: AbortSignal,
): Promise<Result<JsonValue, HopperError>> {
return this.hopper.callTool(
): Promise<Result<JsonValue, AnalysisError>> {
return this.analysis.execute(
name,
arguments_,
signal === undefined ? {} : { signal },
@@ -262,7 +469,7 @@ export class EnhancedTools {
const invalidInput = (name: EnhancedToolName, cause: Error): EnhancedResult =>
Promise.resolve(
err(
new HopperProtocolError(`Invalid ${name} input after MCP validation`, {
new AnalysisProtocolError(`Invalid ${name} input after MCP validation`, {
cause,
}),
),
@@ -278,3 +485,6 @@ const resolveAddress = (value: JsonValue): string | undefined => {
? candidate
: undefined;
};
const addressDistance = (start: string, end: string): number =>
Math.max(0, Number.parseInt(end, 16) - Number.parseInt(start, 16));
+80
View File
@@ -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>",
},
});
-17
View File
@@ -1,17 +0,0 @@
import type { HopperError } from "../domain/errors.js";
import type { Result } from "../domain/result.js";
import type { JsonValue } from "../hopper/protocol.js";
/** Application-owned capability for invoking one official Hopper tool. */
export interface HopperToolPort {
/**
* Invoke a declared bridge operation with JSON-safe arguments.
* Implementations return expected Hopper, timeout, and cancellation failures
* as values and must honor an already-aborted signal.
*/
callTool(
name: string,
arguments_: Readonly<Record<string, JsonValue>>,
options?: { readonly signal?: AbortSignal },
): Promise<Result<JsonValue, HopperError>>;
}
+110
View File
@@ -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);
},
};
};
+511
View File
@@ -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 }),
);
}
};
+7 -1
View File
@@ -276,8 +276,14 @@ const installCanonicalSkill = async (
PRODUCT_IDENTITY.skillName,
"SKILL.md",
);
const content = `---\nname: ${PRODUCT_IDENTITY.skillName}\ndescription: Analyze binaries with REA and Hopper.\n---\n\nOpen a target with open_binary, begin with binary_overview, and close it when finished.\n`;
try {
const content = await readFile(
new URL(
`../../skills/${PRODUCT_IDENTITY.skillName}/SKILL.md`,
import.meta.url,
),
"utf8",
);
if (
(await readFile(destination, "utf8").catch(() => undefined)) === content
)
+2 -23
View File
@@ -1,9 +1,6 @@
import { fileURLToPath } from "node:url";
import type { AppConfig } from "../config.js";
import { BinarySession } from "./BinarySession.js";
import { HopperApplicationLauncher } from "../hopper/BridgeLauncher.js";
import { HopperClient } from "../hopper/HopperClient.js";
import { HopperProvider } from "../hopper/HopperProvider.js";
import { silentLogger, type Logger } from "../logger.js";
/**
@@ -15,23 +12,5 @@ export const createBinarySession = (
config: AppConfig,
logger: Logger = silentLogger,
): BinarySession => {
const bridgeScriptPath = fileURLToPath(
new URL("../../bridge/hopper_bridge.py", import.meta.url),
);
return new BinarySession(
(target) =>
new HopperClient({
launcher: new HopperApplicationLauncher({
launcherPath: config.hopperLauncherPath,
targetPath: target.path,
targetKind: target.kind,
loaderArgs:
config.hopperLoaderArgs.length > 0
? config.hopperLoaderArgs
: target.loaderArgs,
bridgeScriptPath,
}),
logger: logger.child({ layer: "bridge" }),
}),
);
return new BinarySession(new HopperProvider(config, logger));
};
+12 -12
View File
@@ -21,25 +21,25 @@ export const createCli = (): ReturnType<typeof Cli.create> => {
);
const cli = Cli.create(PRODUCT_IDENTITY.cliBinary, {
version: "0.1.0",
description: "Install, diagnose, and automate Hopper binary analysis.",
description: "Reverse engineer apps from your terminal or coding agent.",
mcp: {
command: PRODUCT_IDENTITY.mcpCommand,
instructions:
"Open a local target with open_binary, analyze it, and close the session when finished.",
"Ask which app and feature the user wants to understand, then open the app and investigate it.",
},
sync: {
cwd: fileURLToPath(new URL("..", import.meta.url)),
include: ["skills/*"],
suggestions: [
"open a binary and summarize it",
"decompile a procedure",
"diagnose my Hopper setup",
"understand how an app feature works",
"decompile part of an app",
"check my REA setup",
],
},
});
cli.command("setup", {
description: "Install prerequisites and configure binary analysis",
description: "Install requirements and configure coding agents",
options: z.object({
yes: z
.boolean()
@@ -51,17 +51,17 @@ export const createCli = (): ReturnType<typeof Cli.create> => {
logCliCommand(logger, "setup", () => runSetup(options.yes)),
});
cli.command("doctor", {
description: "Diagnose the local Hopper integration",
description: "Check whether REA is ready",
options: z.object({
target: z.string().optional().describe("Optional binary path to check"),
target: z.string().optional().describe("Optional app path to check"),
}),
run: ({ options }) =>
logCliCommand(logger, "doctor", () => runDoctor(options.target)),
});
cli.command("analyze", {
description: "Analyze a binary through the shared Hopper core",
description: "Get an overview of an app",
args: z.object({
path: z.string().describe("Binary or Hopper database path"),
path: z.string().describe("App, program, or Hopper database path"),
}),
run: ({ args }) =>
logCliCommand(logger, "analyze", () =>
@@ -69,9 +69,9 @@ export const createCli = (): ReturnType<typeof Cli.create> => {
),
});
cli.command("decompile", {
description: "Decompile an address through the shared Hopper core",
description: "Read one part of an app as code",
args: z.object({
path: z.string().describe("Binary path"),
path: z.string().describe("App or program path"),
address: z.string().describe("Procedure address"),
}),
run: ({ args }) =>
+48
View File
@@ -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,
},
});
};
+42 -1
View File
@@ -18,7 +18,46 @@ export const enhancedInputSchemas = {
}),
analyze_swift_types: z.object({}),
find_xrefs_to_name: z.object({ name: z.string() }),
binary_overview: z.object({}),
binary_overview: z.object({
detail: z.enum(["concise", "detailed"]).default("concise"),
limit: z.number().int().min(1).max(50).default(10),
}),
analyze_function: z.object({
procedure: z.string().describe("A procedure name or address"),
include_assembly: z.boolean().default(false),
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),
case_sensitive: z.boolean().default(false),
limit: z.number().int().min(1).max(100).default(20),
max_operations: z.number().int().min(1).max(100).default(20),
}),
} as const;
export type EnhancedToolName = keyof typeof enhancedInputSchemas;
@@ -33,4 +72,6 @@ export const enhancedToolNameSchema = z.enum([
"analyze_swift_types",
"find_xrefs_to_name",
"binary_overview",
"analyze_function",
"trace_feature",
]);
+200 -48
View File
@@ -1,6 +1,16 @@
import { z } from "zod";
import { evidenceBundleSchema } from "../domain/evidenceBundle.js";
import {
processCaptureSchema,
processScenarioSchema,
} from "../domain/processCapture.js";
import { enhancedInputSchemas } from "./enhancedInputs.js";
import {
enhancedOutputSchemas,
officialOutputSchemas,
sessionOutputSchemas,
} from "./toolOutputSchemas.js";
const document = z.string().optional().describe("The document name");
const address = z.string().describe("A Hopper address");
@@ -11,6 +21,17 @@ const caseSensitive = z
.boolean()
.default(false)
.describe("Whether to match case");
const pagination = {
offset: z.number().int().min(0).default(0),
limit: z.number().int().min(1).max(500).default(100),
};
export interface ToolAnnotations {
readonly readOnlyHint: boolean;
readonly destructiveHint: boolean;
readonly idempotentHint: true;
readonly openWorldHint: false;
}
/** Adapter family responsible for implementing a public MCP tool. */
type ToolKind = "official-proxy" | "enhanced" | "session";
@@ -21,161 +42,249 @@ export interface ToolContract {
readonly description: string;
readonly kind: ToolKind;
readonly inputSchema: z.ZodObject;
readonly outputSchema: z.ZodObject;
readonly annotations: ToolAnnotations;
}
const annotations = (name: string, kind: ToolKind): ToolAnnotations => ({
readOnlyHint:
kind === "enhanced" ||
name === "export_evidence_bundle" ||
(!name.startsWith("set_") &&
name !== "unset_bookmark" &&
name !== "goto_address" &&
kind !== "session"),
destructiveHint:
name === "unset_bookmark" ||
name === "set_address_name" ||
name === "set_addresses_names" ||
name === "set_comment" ||
name === "set_inline_comment",
idempotentHint: true,
openWorldHint: false,
});
const official = (
name: string,
description: string,
inputSchema: z.ZodObject,
): ToolContract => ({ name, description, kind: "official-proxy", inputSchema });
): ToolContract => ({
name,
description,
kind: "official-proxy",
inputSchema,
outputSchema: requireOutputSchema(officialOutputSchemas, name),
annotations: annotations(name, "official-proxy"),
});
const enhanced = (
name: string,
description: string,
inputSchema: z.ZodObject,
): ToolContract => ({ name, description, kind: "enhanced", inputSchema });
): ToolContract => ({
name,
description,
kind: "enhanced",
inputSchema,
outputSchema: requireOutputSchema(enhancedOutputSchemas, name),
annotations: annotations(name, "enhanced"),
});
const session = (
name: string,
description: string,
inputSchema: z.ZodObject,
): ToolContract => ({ name, description, kind: "session", inputSchema });
): ToolContract => ({
name,
description,
kind: "session",
inputSchema,
outputSchema: requireOutputSchema(sessionOutputSchemas, name),
annotations: annotations(name, "session"),
});
const requireOutputSchema = (
schemas: Readonly<Record<string, z.ZodObject>>,
name: string,
): z.ZodObject => {
const schema = schemas[name];
if (schema === undefined)
throw new Error(`Missing output schema for ${name}`);
return schema;
};
/** Bridge operations exposed without additional application composition. */
export const OFFICIAL_TOOL_CONTRACTS = [
official(
"address_name",
"Get the name at an address",
"Resolve the analyzed name at a code or data address, defaulting to Hopper's current cursor. Use before following a symbol into xrefs; null means Hopper has no name at that address.",
z.object({ document, address: optionalAddress }),
),
official(
"comment",
"Get the comment at an address",
"Read the regular analysis comment at an address, defaulting to the current cursor. This is read-only and returns null when no comment exists; use set_comment to persist a finding.",
z.object({ document, address: optionalAddress }),
),
official(
"current_address",
"Get the current address",
"Return Hopper's current cursor address for the selected document. Use only for interactive navigation state; prefer explicit addresses in reproducible investigations.",
z.object({ document }),
),
official(
"current_procedure",
"Get the current procedure",
"Return the analyzed procedure containing Hopper's current cursor. Use procedure_info or analyze_function next; this depends on GUI cursor state and is not an exhaustive lookup.",
z.object({ document }),
),
official("current_document", "Get the current document", z.object({})),
official(
"current_document",
"Return the document currently selected by REA's Hopper bridge. Use list_documents before switching when several documents are open.",
z.object({}),
),
official(
"goto_address",
"Navigate to an address",
"Move Hopper's GUI cursor to a hexadecimal address and return the resolved address. This changes navigation state but not analysis data; use explicit-address tools for headless workflows.",
z.object({ address, document }),
),
official(
"inline_comment",
"Get the inline comment at an address",
"Read the inline instruction comment at an address, defaulting to the current cursor. Returns null when absent; use set_inline_comment to write one.",
z.object({ document, address: optionalAddress }),
),
official("list_bookmarks", "List bookmarks", z.object({ document })),
official("list_documents", "List open documents", z.object({})),
official(
"list_bookmarks",
"List every bookmark in the selected Hopper document as address and name pairs. Use bookmarks as analyst-authored navigation aids; this does not discover code references.",
z.object({ document }),
),
official(
"list_documents",
"List all Hopper documents visible to the bridge. Use before set_current_document when a provider session contains multiple documents.",
z.object({}),
),
official(
"list_names",
"List names",
z.object({ document, address: optionalAddress }),
"Page through analyzed names as address/value pairs. Results are bounded by offset and limit; follow next_offset until has_more is false before claiming exhaustive symbol coverage.",
z.object({ document, address: optionalAddress, ...pagination }),
),
official(
"list_procedures",
"Page through analyzed procedures as address/value pairs. Wait for Hopper analysis first, then follow next_offset for exhaustive coverage; use returned addresses with analyze_function or procedure_pseudo_code.",
z.object({ document, ...pagination }),
),
official(
"list_segments",
"List all segments and sections in the selected document. Hopper's public API does not expose permissions, so writable and executable are null with explicit capability metadata.",
z.object({ document }),
),
official("list_procedures", "List procedures", z.object({ document })),
official("list_segments", "List segments", z.object({ document })),
official(
"list_strings",
"List strings",
z.object({ document, address: optionalAddress }),
"Page through analyzed strings, or filter to one address, as address/value pairs. Follow next_offset for exhaustive results and use xrefs on interesting string addresses.",
z.object({ document, address: optionalAddress, ...pagination }),
),
official(
"next_address",
"Get the next address",
"Return the next analyzed object address after an explicit address or current cursor. This is a navigation primitive, not instruction-flow or CFG analysis.",
z.object({ document, address: optionalAddress }),
),
official(
"prev_address",
"Get the previous address",
"Return the previous analyzed instruction start before an explicit address or current cursor. This is a navigation primitive and may fail at document boundaries.",
z.object({ document, address: optionalAddress }),
),
official(
"procedure_address",
"Resolve a procedure address",
"Resolve a procedure symbol name or hexadecimal address to its entry address. Use this to canonicalize user-supplied identifiers before xrefs, assembly, or decompilation.",
z.object({ procedure, document }),
),
official(
"procedure_assembly",
"Get procedure assembly",
"Return assembly for one analyzed procedure identified by symbol or hexadecimal address. Use when pseudocode loses calling-convention or instruction-level detail; output is currently returned as one unpaginated string.",
z.object({ procedure, document }),
),
official(
"procedure_callees",
"List procedure callees",
"Return Hopper's analyzed direct callees for one procedure identified by symbol or address. Indirect calls may be absent; use analyze_function and xrefs to corroborate the call path.",
z.object({ procedure, document }),
),
official(
"procedure_callers",
"List procedure callers",
"Return Hopper's analyzed direct callers for one procedure identified by symbol or address. Results reflect completed static analysis and may omit indirect references.",
z.object({ procedure, document }),
),
official(
"procedure_info",
"Get procedure metadata",
"Return bounded metadata for one procedure identified by symbol or address: entrypoint, signature, locals, size, and block count. Follow with decompilation or assembly for behavior.",
z.object({ procedure, document }),
),
official(
"procedure_references",
"Return a bounded set of raw incoming or outgoing reference edges for one procedure. Endpoint procedures are resolved only when Hopper reports containment; reference kinds remain explicitly unavailable.",
z.object({
procedure,
direction: z.enum(["incoming", "outgoing"]).default("outgoing"),
offset: z.number().int().min(0).default(0),
limit: z.number().int().min(1).max(500).default(100),
max_instructions: z.number().int().min(1).max(5000).default(500),
document,
}),
),
official(
"procedure_pseudo_code",
"Decompile a procedure",
"Decompile one analyzed procedure by symbol name or hexadecimal address. Returns Hopper pseudocode, not original source, and may return null; request procedure_assembly when instruction precision matters.",
z.object({ procedure, document }),
),
official(
"resolve_containing_procedure",
"Resolve an arbitrary address, including an xref source or interior instruction, to its Hopper-analyzed containing procedure. A negative result includes an explicit reason and is not guessed from nearby symbols.",
z.object({ address, document }),
),
official(
"search_procedures",
"Search procedure names",
"Regex-search all analyzed procedure names with optional case sensitivity. The current bridge returns an unpaginated address/name map, so constrain patterns and use list_procedures for controlled exhaustive traversal.",
z.object({ pattern, case_sensitive: caseSensitive, document }),
),
official(
"search_strings",
"Search strings",
"Regex-search all analyzed strings with optional case sensitivity. The current bridge is unpaginated and evaluates Python regex, so use narrow patterns and follow matches with xrefs.",
z.object({ pattern, case_sensitive: caseSensitive, document }),
),
official(
"set_address_name",
"Set an address name",
"Assign an analyst name to one hexadecimal address and report Hopper's boolean result. This mutates analysis metadata; read it back with address_name before relying on it.",
z.object({ address, name: z.string(), document }),
),
official(
"set_addresses_names",
"Set multiple address names",
"Assign analyst names to an address/name map and return per-address success booleans. This mutates analysis metadata; use for bounded batches and verify failures individually.",
z.object({ names: z.record(z.string(), z.string()), document }),
),
official(
"set_bookmark",
"Set a bookmark",
"Create or replace a bookmark at a hexadecimal address. This mutates navigation metadata; verify with list_bookmarks and do not treat bookmarks as binary evidence.",
z.object({ address, name: z.string().optional(), document }),
),
official(
"set_comment",
"Set an address comment",
"Write a regular analysis comment at a hexadecimal address and return whether readback matched. This mutates the Hopper document; use comments to record evidence IDs or reasoning.",
z.object({ address, comment: z.string(), document }),
),
official(
"set_current_document",
"Select the current document",
"Select an already-open Hopper document by exact document name. This changes subsequent default-document routing; call list_documents first and prefer explicit document inputs where reproducibility matters.",
z.object({ document: z.string() }),
),
official(
"set_inline_comment",
"Set an inline comment",
"Write an inline instruction comment at a hexadecimal address and return whether readback matched. This mutates analysis metadata; confirm with inline_comment.",
z.object({ address, comment: z.string(), document }),
),
official(
"unset_bookmark",
"Remove a bookmark",
"Remove the bookmark at a hexadecimal address and return whether it is absent. This mutates navigation metadata and does not alter binary bytes.",
z.object({ address, document }),
),
official(
"xrefs",
"Get cross-references to an address",
"Return analyzed references to a code or data address, defaulting to the current cursor. Use to connect strings, globals, selectors, and functions; bare addresses are untyped and indirect references may be incomplete.",
z.object({ document, address: optionalAddress }),
),
] as const satisfies readonly ToolContract[];
@@ -184,60 +293,103 @@ export const OFFICIAL_TOOL_CONTRACTS = [
export const ENHANCED_TOOL_CONTRACTS = [
enhanced(
"swift_classes",
"Discover Swift classes",
"Discover legacy-mangled Swift class procedures after exhaustively paging analyzed procedures. Returns at most 100 entries and scans at most 5,000 symbols; use analyze_swift_types for other Swift kinds.",
enhancedInputSchemas.swift_classes,
),
enhanced(
"get_objc_classes",
"Discover Objective-C classes",
"Discover and deduplicate Objective-C class labels after exhaustively paging names, optionally filtering by literal substring. Returns at most 100 classes; inspect matching metadata and references next.",
enhancedInputSchemas.get_objc_classes,
),
enhanced(
"get_objc_protocols",
"Discover Objective-C protocols",
"Discover and deduplicate Objective-C and Swift protocol labels after exhaustively paging names. Returns at most 100 entries; use xrefs or analyze_function to connect a protocol to implementations.",
enhancedInputSchemas.get_objc_protocols,
),
enhanced(
"batch_decompile",
"Decompile up to 20 procedures",
"Decompile up to 20 explicit procedure symbols or addresses concurrently. Per-item strings may contain errors or no-output markers, so validate each result; use analyze_function for a richer single-function dossier.",
enhancedInputSchemas.batch_decompile,
),
enhanced(
"get_call_graph",
"Traverse a bounded call graph",
"Traverse Hopper's caller or callee relationships from one symbol or address for at most five levels. Nodes preserve per-procedure errors; indirect calls may be missing and results are not a whole-program CFG.",
enhancedInputSchemas.get_call_graph,
),
enhanced(
"analyze_swift_types",
"Categorize Swift mangled names",
"Categorize exhaustively paged procedure names into Swift classes, structs, enums, protocols, extensions, and other symbols. Scans at most 5,000 names and returns at most 50 entries per category.",
enhancedInputSchemas.analyze_swift_types,
),
enhanced(
"find_xrefs_to_name",
"Find cross-references by name",
"Resolve a name through Hopper and return analyzed references to its address. Use when starting from a selector or symbol; resolution failure is returned explicitly and xrefs remain untyped.",
enhancedInputSchemas.find_xrefs_to_name,
),
enhanced(
"binary_overview",
"Summarize the loaded binary",
"Use immediately after opening a target to summarize document, exhaustive procedure/string counts, and a bounded segment sample. detail controls segment fields and limit controls only the returned segment sample.",
enhancedInputSchemas.binary_overview,
),
enhanced(
"analyze_function",
"Preferred bounded analysis for one procedure symbol or address. Returns identity, pseudocode, optional assembly, comments, calls, incoming references, and blocks; unsupported outgoing references and CFG edges carry explicit unavailable metadata.",
enhancedInputSchemas.analyze_function,
),
enhanced(
"trace_feature",
"Trace a bounded literal feature query through matching strings and procedures, xrefs, and truthful containing-procedure resolution. Returns the operation budget, truncation, and residual unknowns; it does not infer reference kinds.",
enhancedInputSchemas.trace_feature,
),
] as const satisfies readonly ToolContract[];
/** Target lifecycle tools available only on the long-lived MCP adapter. */
export const SESSION_TOOL_CONTRACTS = [
session(
"open_binary",
"Open or switch the active binary",
"Open a local executable, application bundle, or Hopper database, replacing the active target only after validation. This launches Hopper and may show UI; call binary_overview after success.",
z.object({ path: z.string().min(1) }),
),
session("close_binary", "Close the active binary", z.object({})),
session("binary_session", "Describe the active binary session", z.object({})),
session(
"close_binary",
"Close the active Hopper-backed target and release its provider process. The operation is idempotent; call binary_session to verify the session is closed.",
z.object({}),
),
session(
"binary_session",
"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 42 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 = [
+323
View File
@@ -0,0 +1,323 @@
import { z } from "zod";
import { evidenceSchema } from "../domain/evidence.js";
import { processCaptureSchema } from "../domain/processCapture.js";
import { evidenceBundleSchema } from "../domain/evidenceBundle.js";
const resultOf = (schema: z.ZodType) =>
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() });
const procedureIdentity = z.object({ address: z.string(), name: z.string() });
const containingProcedureResolution = z.discriminatedUnion("found", [
z.object({
query_address: z.string(),
found: z.literal(true),
procedure: procedureIdentity,
}),
z.object({
query_address: z.string(),
found: z.literal(false),
procedure: z.null(),
reason: z.enum(["outside_segments", "not_in_procedure"]),
}),
]);
const unavailable = z.object({
available: z.literal(false),
reason: z.string(),
});
const bounded = (item: z.ZodType) =>
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(),
});
const pageOutput = z.object({
items: z.array(z.object({ address: z.string(), value: z.string() })),
offset: z.number().int().min(0),
limit: z.number().int().min(1),
total: z.number().int().min(0),
next_offset: z.number().int().min(0).nullable(),
has_more: z.boolean(),
});
const segmentOutput = resultOf(
z.array(
z.object({
name: z.string(),
start: z.string(),
end: z.string(),
writable: z.boolean().nullable(),
executable: z.boolean().nullable(),
permissions: unavailable,
sections: z.array(
z.object({ name: z.string(), start: z.string(), end: z.string() }),
),
}),
),
);
const procedureInfoOutput = resultOf(
z.object({
name: z.string(),
entrypoint: z.string(),
basicblock_count: z.number().int().min(0),
length: z.number().min(0),
signature: nullableText,
locals: z.array(z.json()),
}),
);
const symbolDiscoveryOutput = (property: "classes" | "protocols") =>
resultOf(
z.object({
count: z.number().int().min(0),
[property]: z.array(addressedEntry),
}),
);
const graphNode = z.union([
z.object({ address: z.string(), calls: z.array(z.string()) }),
z.object({ address: z.string(), error: 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({
address: z.string(),
name: z.string(),
signature: nullableText,
locals: z.array(z.json()),
}),
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(),
}),
assembly: bounded(z.string()),
comments: bounded(
z.object({
address: z.string(),
kind: z.enum(["comment", "inline"]),
text: z.string(),
}),
),
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.array(z.string()),
}),
),
instruction_scan: z.object({
scanned: z.number().int().min(0),
truncated: z.boolean(),
}),
}),
);
/** Exact structured-content schemas for the direct Hopper operations. */
export const officialOutputSchemas: Readonly<Record<string, z.ZodObject>> = {
address_name: resultOf(nullableText),
comment: resultOf(nullableText),
current_address: resultOf(z.string()),
current_procedure: resultOf(z.string()),
current_document: resultOf(z.string()),
goto_address: resultOf(z.string()),
inline_comment: resultOf(nullableText),
list_bookmarks: resultOf(z.array(addressedEntry)),
list_documents: resultOf(z.array(z.string())),
list_names: resultOf(pageOutput),
list_procedures: resultOf(pageOutput),
list_segments: segmentOutput,
list_strings: resultOf(pageOutput),
next_address: resultOf(z.string()),
prev_address: resultOf(z.string()),
procedure_address: resultOf(z.string()),
procedure_assembly: resultOf(z.string()),
procedure_callees: resultOf(addressList),
procedure_callers: resultOf(addressList),
procedure_info: procedureInfoOutput,
procedure_references: resultOf(
z.object({
procedure: procedureIdentity,
direction: z.enum(["incoming", "outgoing"]),
references: bounded(
z.object({
source_address: z.string(),
target_address: z.string(),
source_procedure: procedureIdentity.nullable(),
target_procedure: procedureIdentity.nullable(),
kind: unavailable,
}),
),
instructions_scanned: z.number().int().min(0),
instruction_scan_truncated: z.boolean(),
}),
),
procedure_pseudo_code: resultOf(nullableText),
resolve_containing_procedure: resultOf(containingProcedureResolution),
search_procedures: resultOf(z.record(z.string(), z.string())),
search_strings: resultOf(z.record(z.string(), z.string())),
set_address_name: resultOf(z.boolean()),
set_addresses_names: resultOf(z.record(z.string(), z.boolean())),
set_bookmark: resultOf(z.boolean()),
set_comment: resultOf(z.boolean()),
set_current_document: resultOf(z.string()),
set_inline_comment: resultOf(z.boolean()),
unset_bookmark: resultOf(z.boolean()),
xrefs: resultOf(addressList),
};
/** Exact structured-content schemas for composed analysis workflows. */
export const enhancedOutputSchemas: Readonly<Record<string, z.ZodObject>> = {
swift_classes: symbolDiscoveryOutput("classes"),
get_objc_classes: symbolDiscoveryOutput("classes"),
get_objc_protocols: symbolDiscoveryOutput("protocols"),
batch_decompile: resultOf(
z.union([
z.object({ error: z.literal("No addresses provided") }),
z.record(z.string(), z.string()),
]),
),
get_call_graph: resultOf(z.record(z.string(), z.array(graphNode))),
analyze_swift_types: resultOf(
z.object({
total: z.number().int().min(0),
categories: z.record(
z.string(),
z.object({
count: z.number().int().min(0),
items: z.array(addressedEntry),
}),
),
}),
),
find_xrefs_to_name: resultOf(
z.union([
z.object({ xrefs: addressList }),
z.object({ error: z.string() }),
]),
),
binary_overview: resultOf(
z.object({
document: z.string(),
detail: z.enum(["concise", "detailed"]),
segments: z.array(
z.object({
name: z.string(),
start: z.string(),
end: z.string(),
length: z.number().min(0).optional(),
}),
),
segment_count: z.number().int().min(0),
procedure_count: z.number().int().min(0),
string_count: z.number().int().min(0),
}),
),
analyze_function: functionDossierOutput,
trace_feature: resultOf(
z.object({
query: z.string(),
search_mode: z.literal("literal"),
operations_used: z.number().int().min(0),
operation_budget: z.number().int().min(1),
matches: z.array(
z.object({
type: z.enum(["string", "procedure"]),
address: z.string(),
value: z.string(),
}),
),
references: z.array(
z.object({
target_address: z.string(),
source_address: z.string(),
containing_procedure: containingProcedureResolution,
}),
),
truncated: z.boolean(),
residual_unknowns: z.array(z.string()),
}),
),
};
/** Exact structured-content schemas for target lifecycle operations. */
export const sessionOutputSchemas: Readonly<Record<string, z.ZodObject>> = {
open_binary: lifecycleResultOf(
z.object({
path: z.string(),
format: z.enum(["hopper", "mach-o", "elf", "pe"]),
kind: z.enum(["executable", "database"]),
loaderArgs: z.array(z.string()),
sha256: z.string().regex(/^[a-f0-9]{64}$/u),
architecture: z.enum(["x86", "x86_64", "arm", "arm64"]).nullable(),
}),
),
close_binary: lifecycleResultOf(z.null()),
binary_session: lifecycleResultOf(
z.union([
z.object({ open: z.literal(false) }),
z.object({
open: z.literal(true),
path: z.string(),
format: z.enum(["hopper", "mach-o", "elf", "pe"]),
kind: z.enum(["executable", "database"]),
sha256: z.string().regex(/^[a-f0-9]{64}$/u),
architecture: z.enum(["x86", "x86_64", "arm", "arm64"]).nullable(),
}),
]),
),
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()),
}),
}),
),
};
+119 -4
View File
@@ -1,16 +1,23 @@
import { constants } from "node:fs";
import { createReadStream } from "node:fs";
import { createHash } from "node:crypto";
import {
access,
open,
readFile,
realpath,
stat,
type FileHandle,
} from "node:fs/promises";
import { isAbsolute, resolve } from "node:path";
import { execFile } from "node:child_process";
import { extname, isAbsolute, join, relative, resolve, sep } from "node:path";
import { promisify } from "node:util";
import { BinaryTargetError } from "./errors.js";
import { err, ok, type Result } from "./result.js";
const execFileAsync = promisify(execFile);
/** CPU families understood by the supported executable loaders. */
type BinaryArchitecture = "x86" | "x86_64" | "arm" | "arm64";
/**
@@ -20,6 +27,7 @@ type BinaryArchitecture = "x86" | "x86_64" | "arm" | "arm64";
*/
export interface BinaryTarget {
readonly path: string;
readonly sha256: string;
readonly kind: "executable" | "database";
readonly format: "hopper" | "mach-o" | "elf" | "pe";
readonly architecture?: BinaryArchitecture;
@@ -41,14 +49,23 @@ export const parseBinaryTarget = async (
const candidate = isAbsolute(input) ? input : resolve(cwd, input);
try {
await access(candidate, constants.R_OK);
const path = await realpath(candidate);
const canonical = await realpath(candidate);
const resolved = await resolveAppBundle(canonical);
if (!resolved.ok) return err(resolved.error);
const path = resolved.value;
if (!(await stat(path)).isFile())
return err(new BinaryTargetError(path, "target is not a regular file"));
if (
targetKind === "database" ||
(targetKind === undefined && path.toLowerCase().endsWith(".hop"))
)
return ok({ path, kind: "database", format: "hopper", loaderArgs: [] });
return ok({
path,
sha256: await sha256File(path),
kind: "database",
format: "hopper",
loaderArgs: [],
});
const handle = await open(path, "r");
let detected: Result<ExecutableMetadata, string>;
try {
@@ -57,7 +74,12 @@ export const parseBinaryTarget = async (
await handle.close();
}
if (!detected.ok) return err(new BinaryTargetError(path, detected.error));
return ok({ path, kind: "executable", ...detected.value });
return ok({
path,
sha256: await sha256File(path),
kind: "executable",
...detected.value,
});
} catch (cause: unknown) {
return err(
new BinaryTargetError(candidate, "path is not readable", { cause }),
@@ -65,6 +87,99 @@ export const parseBinaryTarget = async (
}
};
const sha256File = async (path: string): Promise<string> => {
const hash = createHash("sha256");
for await (const chunk of createReadStream(path)) hash.update(chunk);
return hash.digest("hex");
};
const resolveAppBundle = async (
path: string,
): Promise<Result<string, BinaryTargetError>> => {
const metadata = await stat(path);
if (!metadata.isDirectory()) return ok(path);
if (extname(path).toLowerCase() !== ".app")
return err(new BinaryTargetError(path, "target is not an app or file"));
const plistPath = join(path, "Contents", "Info.plist");
let name: string;
try {
const plist = await readFile(plistPath);
name =
plist.subarray(0, 6).toString("ascii") === "bplist"
? await readBinaryPlistExecutable(plistPath)
: parseXmlPlistExecutable(plist.toString("utf8"));
} catch (cause: unknown) {
return err(
new BinaryTargetError(path, "app has no readable CFBundleExecutable", {
cause,
}),
);
}
if (!isSafeExecutableName(name))
return err(
new BinaryTargetError(path, "app has an unsafe CFBundleExecutable"),
);
const programs = join(path, "Contents", "MacOS");
const executable = join(programs, name);
try {
const [canonicalPrograms, canonicalExecutable] = await Promise.all([
realpath(programs),
realpath(executable),
]);
const location = relative(canonicalPrograms, canonicalExecutable);
if (
location === ".." ||
location.startsWith(`..${sep}`) ||
isAbsolute(location)
)
return err(
new BinaryTargetError(path, "app program file leaves Contents/MacOS"),
);
return ok(canonicalExecutable);
} catch (cause: unknown) {
return err(
new BinaryTargetError(path, "app program file is missing", { cause }),
);
}
};
const parseXmlPlistExecutable = (plist: string): string => {
const match =
/<key>\s*CFBundleExecutable\s*<\/key>\s*<string>([^<]+)<\/string>/u.exec(
plist,
);
if (match?.[1] === undefined)
throw new Error("CFBundleExecutable is missing");
return decodeXml(match[1].trim());
};
const readBinaryPlistExecutable = async (plistPath: string): Promise<string> =>
(
await execFileAsync("/usr/bin/plutil", [
"-extract",
"CFBundleExecutable",
"raw",
"-o",
"-",
plistPath,
])
).stdout.trim();
const isSafeExecutableName = (name: string): boolean =>
name.length > 0 &&
name !== "." &&
name !== ".." &&
!name.includes("\0") &&
!/[/\\]/u.test(name);
const decodeXml = (value: string): string =>
value
.replaceAll("&amp;", "&")
.replaceAll("&lt;", "<")
.replaceAll("&gt;", ">")
.replaceAll("&quot;", '"')
.replaceAll("&apos;", "'");
type ExecutableMetadata = Pick<
BinaryTarget,
"format" | "architecture" | "availableArchitectures" | "loaderArgs"
+22 -9
View File
@@ -1,8 +1,21 @@
/** Base class for expected Hopper integration failures. */
export abstract class HopperError extends Error {
/** Base class for expected analysis, provider, and session failures. */
export abstract class AnalysisError extends Error {
abstract readonly _tag: string;
}
/** Base class for failures produced specifically by the Hopper provider. */
export abstract class HopperError extends AnalysisError {}
/** Provider-neutral invalid analysis input or output at an application boundary. */
export class AnalysisProtocolError extends AnalysisError {
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. */
export class HopperTimeoutError extends HopperError {
readonly _tag = "HopperTimeoutError";
@@ -57,28 +70,28 @@ export class HopperStartError extends HopperError {
}
/** Runtime configuration could not be parsed safely. */
export class ConfigurationError extends HopperError {
export class ConfigurationError extends AnalysisError {
readonly _tag = "ConfigurationError";
}
/** No binary session exists for an analysis request. */
export class NoBinaryOpenError extends HopperError {
/** No app or binary session exists for an analysis request. */
export class NoBinaryOpenError extends AnalysisError {
readonly _tag = "NoBinaryOpenError";
constructor() {
super(
"No binary is open. Call open_binary with a readable local path first.",
"No app is open. Ask the user which app to investigate, then call open_binary with its local path.",
);
}
}
/** A supplied target path could not be safely opened as a supported binary. */
export class BinaryTargetError extends HopperError {
/** A supplied target path could not be safely opened as a supported app or binary. */
export class BinaryTargetError extends AnalysisError {
readonly _tag = "BinaryTargetError";
constructor(
readonly path: string,
reason: string,
options?: ErrorOptions,
) {
super(`Cannot open binary: ${reason}: ${path}`, options);
super(`Cannot open app: ${reason}: ${path}`, options);
}
}
+198
View File
@@ -0,0 +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 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, provider-neutral record for one successful public observation. */
export const evidenceSchema = z.object({
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,
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[];
}
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 => {
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
: {
digest: subject.digest,
format: subject.format,
architecture: subject.architecture,
},
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: 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)),
});
};
+20
View File
@@ -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),
),
});
+198 -3
View File
@@ -1,7 +1,7 @@
import { z } from "zod";
import type { JsonValue } from "../hopper/protocol.js";
import { HopperProtocolError } from "./errors.js";
import type { JsonValue } from "./jsonValue.js";
import { AnalysisProtocolError, HopperProtocolError } from "./errors.js";
import { err, ok, type Result } from "./result.js";
export interface AddressedName {
@@ -15,6 +15,12 @@ export interface SegmentSummary {
readonly end: string;
}
export interface AddressedPage {
readonly items: readonly AddressedName[];
readonly nextOffset: number | null;
readonly hasMore: boolean;
}
const procedureMapSchema = z.record(z.string(), z.string());
const addressedNamesSchema = z.array(
z.object({ address: z.string(), name: z.string() }),
@@ -25,11 +31,185 @@ const segmentSchema = z.object({
start: z.string().default(""),
end: z.string().default(""),
});
const addressedPageSchema = z.object({
items: z.array(z.object({ address: z.string(), value: z.string() })),
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 = (
value: JsonValue,
): Result<AddressedPage, HopperProtocolError> => {
const parsed = addressedPageSchema.safeParse(value);
return parsed.success
? ok({
items: parsed.data.items.map(({ address, value: name }) => ({
address,
name,
})),
nextOffset: parsed.data.next_offset,
hasMore: parsed.data.has_more,
})
: invalid("addressed page", parsed.error);
};
/** Parse Hopper's direct or wrapped procedure map into stable entries. */
export const parseProcedures = (
value: JsonValue,
): Result<readonly AddressedName[], HopperProtocolError> => {
const page = parseAddressedPage(value);
if (page.ok) return ok(page.value.items);
const parsed = procedureMapSchema.safeParse(
unwrapProperty(value, "procedures"),
);
@@ -40,7 +220,7 @@ export const parseProcedures = (
name,
})),
)
: invalid("procedure map", parsed.error);
: page;
};
/** Parse Hopper's direct or wrapped list of address/name records. */
@@ -48,6 +228,15 @@ export const parseNames = (
value: JsonValue,
): Result<readonly AddressedName[], HopperProtocolError> => {
const unwrapped = unwrapProperty(value, "names");
const page = z
.object({
items: z.array(z.object({ address: z.string(), value: z.string() })),
})
.safeParse(unwrapped);
if (page.success)
return ok(
page.data.items.map(({ address, value: name }) => ({ address, name })),
);
const records = addressedNamesSchema.safeParse(unwrapped);
if (records.success) return ok(records.data);
const map = addressedNameMapSchema.safeParse(unwrapped);
@@ -97,6 +286,12 @@ export const parseListCount = (
property: string,
): Result<number, HopperProtocolError> => {
const unwrapped = unwrapProperty(value, property);
const pageTotal = z
.object({ total: z.number().int().min(0) })
.safeParse(unwrapped);
if (pageTotal.success) return ok(pageTotal.data.total);
const page = z.object({ items: z.array(z.unknown()) }).safeParse(unwrapped);
if (page.success) return ok(page.data.items.length);
const list = z.array(z.unknown()).safeParse(unwrapped);
if (list.success) return ok(list.data.length);
const map = z.record(z.string(), z.unknown()).safeParse(unwrapped);
+6
View File
@@ -0,0 +1,6 @@
import { z } from "zod";
/** JSON-safe value shared by domain, application, and adapter boundaries. */
export const jsonValueSchema = z.json();
export type JsonValue = z.infer<typeof jsonValueSchema>;
+396
View File
@@ -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],
};
};
+1 -1
View File
@@ -1,4 +1,4 @@
import type { JsonValue } from "../hopper/protocol.js";
import type { JsonValue } from "./jsonValue.js";
import type { AddressedName } from "./hopperValues.js";
const MAX_SCANNED_SYMBOLS = 5_000;
+2 -5
View File
@@ -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();
+87
View File
@@ -0,0 +1,87 @@
import { fileURLToPath } from "node:url";
import type {
AnalysisClient,
AnalysisProvider,
CapabilityDescriptor,
ProviderIdentity,
} from "../application/AnalysisProvider.js";
import type { AppConfig } from "../config.js";
import type { BinaryTarget } from "../domain/binaryTarget.js";
import type { Logger } from "../logger.js";
import { HopperApplicationLauncher } from "./BridgeLauncher.js";
import { HopperClient } from "./HopperClient.js";
const IDENTITY: ProviderIdentity = {
id: "hopper",
name: "Hopper Disassembler",
version: null,
};
const OPERATIONS = [
"direct-analysis",
"decompilation",
"disassembly",
"cross-references",
"containing-procedure-resolution",
"procedure-references",
"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(
private readonly config: AppConfig,
private readonly logger: Logger,
) {}
identity(): ProviderIdentity {
return IDENTITY;
}
capabilities(): readonly CapabilityDescriptor[] {
return CAPABILITIES;
}
createClient(target: BinaryTarget): AnalysisClient {
const client = new HopperClient({
launcher: new HopperApplicationLauncher({
launcherPath: this.config.hopperLauncherPath,
targetPath: target.path,
targetKind: target.kind,
loaderArgs:
this.config.hopperLoaderArgs.length > 0
? this.config.hopperLoaderArgs
: target.loaderArgs,
bridgeScriptPath: fileURLToPath(
new URL("../../bridge/hopper_bridge.py", import.meta.url),
),
}),
logger: this.logger.child({ layer: "bridge" }),
});
return {
execute: (operation, parameters, options) =>
client.callTool(operation, parameters, options),
close: () => client.close(),
};
}
}
+1 -3
View File
@@ -1,11 +1,9 @@
import { z } from "zod";
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 const jsonValueSchema = z.json();
export type JsonValue = z.infer<typeof jsonValueSchema>;
const responseSchema = z.union([
z.object({
id: z.number().int().nonnegative(),
+14 -5
View File
@@ -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");
+34 -9
View File
@@ -1,12 +1,13 @@
import { McpServer } from "@modelcontextprotocol/server";
import type { HopperToolPort } from "../application/HopperToolPort.js";
import type { BinarySession } from "../application/BinarySession.js";
import type { AnalysisOperationPort } from "../application/AnalysisProvider.js";
import type { BinarySessionPort } from "../application/BinarySession.js";
import { PRODUCT_IDENTITY } from "../identity.js";
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.
@@ -14,9 +15,10 @@ import { silentLogger, type Logger } from "../logger.js";
* fixed-target seam used by focused tests and embedders.
*/
export const createServer = (
hopper: HopperToolPort,
session?: BinarySession,
analysis: AnalysisOperationPort,
session?: BinarySessionPort,
logger: Logger = silentLogger,
processPolicy?: ProcessExecutionPolicy,
): McpServer => {
const server = new McpServer(
{ name: PRODUCT_IDENTITY.mcpServerKey, version: "0.1.0" },
@@ -24,13 +26,36 @@ export const createServer = (
capabilities: { tools: {} },
instructions:
session === undefined
? "Reverse-engineering tools for an active Hopper Disassembler target. Start with binary_overview."
: "Reverse-engineering tools for Hopper Disassembler. Open a target with open_binary, then start with binary_overview.",
? "Reverse-engineering tools for an active analysis target. Start with binary_overview."
: "Reverse-engineering tools for configured analysis providers. Open a target with open_binary, then start with binary_overview.",
},
);
const toolLogger = logger.child({ layer: "server" });
registerOfficialTools(server, hopper, toolLogger);
registerEnhancedTools(server, hopper, toolLogger);
if (session !== undefined) registerSessionTools(server, session, toolLogger);
const activeTarget =
session === undefined ? undefined : () => session.activeTarget();
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;
};
+65 -12
View File
@@ -1,7 +1,7 @@
import type { McpServer } from "@modelcontextprotocol/server";
import { EnhancedTools } from "../application/EnhancedTools.js";
import type { HopperToolPort } from "../application/HopperToolPort.js";
import type { AnalysisOperationPort } from "../application/AnalysisProvider.js";
import { enhancedToolNameSchema } from "../contracts/enhancedInputs.js";
import {
ENHANCED_TOOL_CONTRACTS,
@@ -10,16 +10,34 @@ import {
import { toCallToolResult } from "./toolResult.js";
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 { 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 = (
server: McpServer,
hopper: HopperToolPort,
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(hopper);
const services = new EnhancedTools(analysis);
for (const contract of ENHANCED_TOOL_CONTRACTS) {
registerEnhancedTool(server, services, contract, logger);
registerEnhancedTool(server, services, contract, {
logger,
activeTarget,
provider,
recordEvidence,
});
}
};
@@ -27,17 +45,52 @@ const registerEnhancedTool = (
server: McpServer,
services: EnhancedTools,
contract: ToolContract,
logger: Logger,
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);
server.registerTool(
name,
{ description: contract.description, inputSchema: contract.inputSchema },
async (input, context) =>
toCallToolResult(
await logToolExecution(logger, name, () =>
services.execute(name, input, context.mcpReq.signal),
),
),
{
description: contract.description,
inputSchema: contract.inputSchema,
outputSchema: contract.outputSchema,
annotations: contract.annotations,
},
async (input, context) => {
const parsedInput = jsonValueSchema.parse(
enhancedInputSchemas[name].parse(input),
);
if (
typeof parsedInput !== "object" ||
parsedInput === null ||
Array.isArray(parsedInput)
)
throw new Error("Validated enhanced input was not a JSON object");
const parameters: Record<string, JsonValue> = { ...parsedInput };
const result = await logToolExecution(registration.logger, name, () =>
services.execute(name, input, context.mcpReq.signal),
);
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);
},
);
};
+51 -11
View File
@@ -1,46 +1,86 @@
import type { McpServer } from "@modelcontextprotocol/server";
import type { HopperToolPort } from "../application/HopperToolPort.js";
import type { AnalysisOperationPort } from "../application/AnalysisProvider.js";
import {
OFFICIAL_TOOL_CONTRACTS,
type ToolContract,
} from "../contracts/toolContracts.js";
import { jsonValueSchema, type JsonValue } from "../hopper/protocol.js";
import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js";
import { toCallToolResult } from "./toolResult.js";
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 = (
server: McpServer,
hopper: HopperToolPort,
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, hopper, contract, logger);
registerOfficialTool(server, analysis, contract, {
logger,
activeTarget,
provider,
recordEvidence,
});
}
};
const registerOfficialTool = (
server: McpServer,
hopper: HopperToolPort,
analysis: AnalysisOperationPort,
contract: ToolContract,
logger: Logger,
registration: {
readonly logger: Logger;
readonly activeTarget: (() => BinaryTarget | undefined) | undefined;
readonly provider: ProviderIdentity;
readonly recordEvidence: ((evidence: Evidence) => void) | undefined;
},
): void => {
server.registerTool(
contract.name,
{
description: contract.description,
inputSchema: contract.inputSchema,
outputSchema: contract.outputSchema,
annotations: contract.annotations,
},
async (input, context) => {
const arguments_ = projectOfficialArguments(contract, input);
const result = await logToolExecution(logger, contract.name, () =>
hopper.callTool(contract.name, arguments_, {
signal: context.mcpReq.signal,
}),
const result = await logToolExecution(
registration.logger,
contract.name,
() =>
analysis.execute(contract.name, arguments_, {
signal: context.mcpReq.signal,
}),
);
return toCallToolResult(result);
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);
},
);
};
+221 -13
View File
@@ -1,24 +1,212 @@
import type { McpServer } from "@modelcontextprotocol/server";
import { z } from "zod";
import type { BinarySession } from "../application/BinarySession.js";
import type { BinarySessionPort } from "../application/BinarySession.js";
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: BinarySession,
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,
{
description: openContract.description,
inputSchema: openContract.inputSchema,
outputSchema: openContract.outputSchema,
annotations: openContract.annotations,
},
async (input, context) => {
const parsed = z.object({ path: z.string().min(1) }).parse(input);
@@ -26,16 +214,21 @@ export const registerSessionTools = (
session.open(parsed.path, { signal: context.mcpReq.signal }),
);
return opened.ok
? toCallToolResult({
ok: true,
value: {
path: opened.value.path,
format: opened.value.format,
kind: opened.value.kind,
loaderArgs: [...opened.value.loaderArgs],
? toCallToolResult(
{
ok: true,
value: {
path: opened.value.path,
format: opened.value.format,
kind: opened.value.kind,
loaderArgs: [...opened.value.loaderArgs],
sha256: opened.value.sha256,
architecture: opened.value.architecture ?? null,
},
},
})
: toCallToolResult(opened);
openContract,
)
: toCallToolResult(opened, openContract);
},
);
server.registerTool(
@@ -43,12 +236,15 @@ export const registerSessionTools = (
{
description: closeContract.description,
inputSchema: closeContract.inputSchema,
outputSchema: closeContract.outputSchema,
annotations: closeContract.annotations,
},
async () =>
toCallToolResult(
await logToolExecution(logger, closeContract.name, () =>
session.close(),
),
closeContract,
),
);
server.registerTool(
@@ -56,7 +252,19 @@ export const registerSessionTools = (
{
description: statusContract.description,
inputSchema: statusContract.inputSchema,
outputSchema: statusContract.outputSchema,
annotations: statusContract.annotations,
},
() => toCallToolResult({ ok: true, value: session.status() }),
() =>
toCallToolResult({ ok: true, value: session.status() }, statusContract),
);
registerEvidenceTools({ server, session, exportContract, importContract });
registerProcessTools({
server,
session,
logger,
processPolicy,
captureContract,
compareContract,
});
};
+29 -4
View File
@@ -1,8 +1,9 @@
import type { CallToolResult } from "@modelcontextprotocol/server";
import type { HopperError } from "../domain/errors.js";
import type { AnalysisError } from "../domain/errors.js";
import type { Result } from "../domain/result.js";
import type { JsonValue } from "../hopper/protocol.js";
import type { JsonValue } from "../domain/jsonValue.js";
import type { ToolContract } from "../contracts/toolContracts.js";
/**
* Translate an application result into MCP text content.
@@ -10,10 +11,11 @@ import type { JsonValue } from "../hopper/protocol.js";
* output, and other potentially sensitive details stay private.
*/
export const toCallToolResult = (
result: Result<JsonValue, HopperError>,
result: Result<JsonValue, AnalysisError>,
contract: ToolContract,
): CallToolResult =>
result.ok
? { content: [{ type: "text", text: formatValue(result.value) }] }
? successResult(result.value, contract)
: {
content: [
{
@@ -24,6 +26,29 @@ export const toCallToolResult = (
isError: true,
};
const successResult = (
value: JsonValue,
contract: ToolContract,
): CallToolResult => {
const candidate = contract.kind === "session" ? { result: value } : value;
const parsed = contract.outputSchema.safeParse(candidate);
if (!parsed.success) {
return {
content: [
{
type: "text",
text: "HopperProtocolError: Analysis output does not match the tool contract",
},
],
isError: true,
};
}
return {
content: [{ type: "text", text: formatValue(value) }],
structuredContent: candidate,
};
};
const formatValue = (value: JsonValue): string => {
if (value === null) return "OK";
if (typeof value === "string") return value;
+60 -18
View File
@@ -3,10 +3,9 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
BinarySession,
type BinaryClient,
} from "../src/application/BinarySession.js";
import { BinarySession } from "../src/application/BinarySession.js";
import type { AnalysisClient } from "../src/application/AnalysisProvider.js";
import type { AnalysisProvider } from "../src/application/AnalysisProvider.js";
import { HopperStartError } from "../src/domain/errors.js";
import { err, ok } from "../src/domain/result.js";
@@ -18,9 +17,52 @@ afterEach(async () => {
});
describe("binary session", () => {
it("runs through a non-Hopper analysis provider", async () => {
const [first] = await targets();
const operations: string[] = [];
const provider: AnalysisProvider = {
identity: () => ({ id: "fixture", name: "Fixture", version: "1" }),
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);
return Promise.resolve(ok(operation));
},
close: () => Promise.resolve(),
}),
};
const session = new BinarySession(provider);
expect((await session.open(first)).ok).toBe(true);
expect(await session.execute("fixture_operation", {})).toEqual({
ok: true,
value: "fixture_operation",
});
expect(provider.identity().id).toBe("fixture");
expect(provider.capabilities()[0]?.operation).toBe("fixture-analysis");
expect(operations).toEqual(["health", "fixture_operation"]);
await session.close();
});
it("requires an open binary and closes idempotently", async () => {
const session = new BinarySession(() => client());
expect((await session.callTool("binary_overview", {})).ok).toBe(false);
expect((await session.execute("binary_overview", {})).ok).toBe(false);
expect(await session.close()).toEqual({ ok: true, value: null });
});
@@ -34,7 +76,12 @@ describe("binary session", () => {
const session = new BinarySession(() => client(created++ === 1));
expect((await session.open(first)).ok).toBe(true);
expect((await session.open(second)).ok).toBe(false);
expect(session.status()).toMatchObject({ open: true });
expect(session.status()).toMatchObject({
open: true,
sha256:
"7692c3ad3540bb803c020b3aee66cd8887123234ea0c6e7143c0add73ff431ed",
architecture: null,
});
expect(JSON.stringify(session.status())).toContain("first.hop");
expect(created).toBe(3);
});
@@ -70,7 +117,7 @@ describe("binary session", () => {
return value;
});
await session.open(first);
const call = session.callTool("long", {});
const call = session.execute("long", {});
const switching = session.open(second);
await Promise.resolve();
expect(clients[0]?.closed).toBe(0);
@@ -106,11 +153,7 @@ describe("binary session", () => {
const session = new BinarySession(() => new TestClient(health.promise));
const opening = session.open(first);
const controller = new AbortController();
const call = session.callTool(
"overview",
{},
{ signal: controller.signal },
);
const call = session.execute("overview", {}, { signal: controller.signal });
controller.abort();
const result = await call;
expect(result.ok).toBe(false);
@@ -143,7 +186,7 @@ describe("binary session", () => {
let liveClients = 0;
let overlapped = false;
const session = new BinarySession(() => ({
callTool: () => {
execute: () => {
if (liveClients > 0) overlapped = true;
liveClients += 1;
return Promise.resolve(ok(null));
@@ -160,20 +203,19 @@ describe("binary session", () => {
});
});
const client = (fail = false): BinaryClient => ({
callTool: () =>
Promise.resolve(fail ? err(new HopperStartError()) : ok(null)),
const client = (fail = false): AnalysisClient => ({
execute: () => Promise.resolve(fail ? err(new HopperStartError()) : ok(null)),
close: () => Promise.resolve(),
});
class TestClient implements BinaryClient {
class TestClient implements AnalysisClient {
closed = 0;
constructor(
readonly pendingHealth?: Promise<ReturnType<typeof ok<null>>>,
readonly failHealth = false,
readonly pendingCall?: Promise<ReturnType<typeof ok<null>>>,
) {}
callTool(name: string) {
execute(name: string) {
if (name === "health")
return this.failHealth
? Promise.resolve(err(new HopperStartError()))
+70 -2
View File
@@ -1,4 +1,11 @@
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import {
mkdir,
mkdtemp,
realpath,
rm,
symlink,
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
@@ -70,7 +77,10 @@ describe("binary target I/O", () => {
directory = await mkdtemp(join(tmpdir(), "rea-target-"));
await writeFile(join(directory, "sample.hop"), "database");
await writeFile(join(directory, "text"), "hello");
expect((await parseBinaryTarget("sample.hop", directory)).ok).toBe(true);
const database = await parseBinaryTarget("sample.hop", directory);
expect(database.ok && database.value.sha256).toBe(
"3549b0028b75d981cdda2e573e9cb49dedc200185876df299f912b79f69dabd8",
);
expect((await parseBinaryTarget("text", directory)).ok).toBe(false);
expect((await parseBinaryTarget("missing", directory)).ok).toBe(false);
});
@@ -80,6 +90,64 @@ describe("binary target I/O", () => {
expect((await parseBinaryTarget(directory)).ok).toBe(false);
});
it("resolves a macOS app bundle to its declared program file", async () => {
directory = await mkdtemp(join(tmpdir(), "rea-target-"));
const app = join(directory, "Example App.app");
const contents = join(app, "Contents");
const programs = join(contents, "MacOS");
await mkdir(programs, { recursive: true });
await writeFile(
join(contents, "Info.plist"),
'<?xml version="1.0"?><plist><dict><key>CFBundleExecutable</key><string>Example &amp; Tool</string></dict></plist>',
);
await writeFile(
join(programs, "Example & Tool"),
thinMach(0xfeedfacf, 0x0100000c),
);
const result = await parseBinaryTarget(app, directory, "arm64");
expect(result.ok && result.value).toMatchObject({
path: await realpath(join(programs, "Example & Tool")),
format: "mach-o",
});
});
it.each([
["missing plist", undefined],
["missing executable name", "<plist><dict></dict></plist>"],
[
"unsafe executable name",
"<plist><dict><key>CFBundleExecutable</key><string>../escape</string></dict></plist>",
],
[
"missing program file",
"<plist><dict><key>CFBundleExecutable</key><string>Missing</string></dict></plist>",
],
])("rejects an app bundle with %s", async (_case, plist) => {
directory = await mkdtemp(join(tmpdir(), "rea-target-"));
const app = join(directory, "Broken.app");
const contents = join(app, "Contents");
await mkdir(join(contents, "MacOS"), { recursive: true });
if (plist !== undefined)
await writeFile(join(contents, "Info.plist"), plist);
expect((await parseBinaryTarget(app, directory, "arm64")).ok).toBe(false);
});
it("rejects an app program symlink that leaves the bundle", async () => {
directory = await mkdtemp(join(tmpdir(), "rea-target-"));
const app = join(directory, "Escaping.app");
const contents = join(app, "Contents");
const programs = join(contents, "MacOS");
const outside = join(directory, "outside");
await mkdir(programs, { recursive: true });
await writeFile(outside, thinMach(0xfeedfacf, 0x0100000c));
await writeFile(
join(contents, "Info.plist"),
"<plist><dict><key>CFBundleExecutable</key><string>Escaping</string></dict></plist>",
);
await symlink(outside, join(programs, "Escaping"));
expect((await parseBinaryTarget(app, directory, "arm64")).ok).toBe(false);
});
it("honors an explicit database kind without relying on the file suffix", async () => {
directory = await mkdtemp(join(tmpdir(), "rea-target-"));
await writeFile(join(directory, "saved-analysis"), "database");
+54
View File
@@ -0,0 +1,54 @@
import { readFile } from "node:fs/promises";
import { describe, expect, it } from "vitest";
const bridgeSource = await readFile(
new URL("../bridge/hopper_bridge.py", import.meta.url),
"utf8",
);
describe("Hopper bridge truthfulness", () => {
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", () => {
expect(bridgeSource).toContain('"writable": None');
expect(bridgeSource).toContain('"executable": None');
expect(bridgeSource).toContain('"permissions": _unavailable(');
expect(bridgeSource).not.toContain('"writable": False');
expect(bridgeSource).not.toContain('"executable": False');
});
it("uses Hopper containment and raw-reference APIs without inferring kinds", () => {
expect(bridgeSource).toContain("segment.getProcedureAtAddress(address)");
expect(bridgeSource).toContain("segment.getReferencesFromAddress(address)");
expect(bridgeSource).toContain("segment.getReferencesOfAddress(address)");
expect(bridgeSource).toContain(
"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", {})');
});
});
+6
View File
@@ -33,6 +33,12 @@ describe("runtime configuration", () => {
hopperTargetKind: "database",
hopperLoaderArgs: ["-l", "FAT", "--aarch64", "-l", "Mach-O"],
logLevel: "info",
processExecutionPolicy: {
enabled: false,
executableRoots: [],
workingRoots: [],
allowedEnvironment: [],
},
},
});
});
+16
View File
@@ -0,0 +1,16 @@
# Source-built conformance fixtures
These sources are deliberately small semantic oracles for REA providers. Run
`npm run verify:fixtures` on macOS to compile them into the ignored
`build/conformance/` directory, emit a hash-and-toolchain manifest, and verify
their Mach-O symbols and strings with native command-line tools.
Only source files are versioned. Generated binaries, generated large-fixture
source, debug information, and manifests must remain under `build/`.
The C fixture fixes a known call chain and strings. The version pair fixes an
added symbol, changed string, and changed call relationship. The generated
large fixture contains 1,205 uniquely named functions and strings so provider
pagination can be checked at boundaries of 500, 1,000, and 1,205. Objective-C,
Swift, and N-API-like fixtures provide metadata-oriented inputs without package
or network dependencies.
+19
View File
@@ -0,0 +1,19 @@
#include <stdio.h>
volatile int rea_c_global = 7;
__attribute__((noinline, used)) int rea_leaf(int value) {
puts("REA_C_LEAF");
return value + rea_c_global;
}
__attribute__((noinline, used)) int rea_branch(int value) {
return value > 3 ? rea_leaf(value) : rea_leaf(-value);
}
__attribute__((noinline, used)) int rea_entry(void) {
puts("REA_C_ENTRY");
return rea_branch(5);
}
int main(void) { return rea_entry() == 12 ? 0 : 1; }
+17
View File
@@ -0,0 +1,17 @@
typedef void *napi_env;
typedef void *napi_value;
typedef napi_value (*napi_callback)(napi_env, void *);
extern int napi_create_function(napi_env, const char *, unsigned long,
napi_callback, void *, napi_value *);
extern int napi_set_named_property(napi_env, napi_value, const char *, napi_value);
static napi_value rea_probe(napi_env env, void *info) { return (napi_value)info; }
__attribute__((visibility("default"))) napi_value napi_register_module_v1(
napi_env env, napi_value exports) {
const char *names[] = {"reaProbeOne", "reaProbeTwo", "reaProbeThree"};
for (int i = 0; i < 3; i++) {
napi_value function = 0;
napi_create_function(env, names[i], 0, rea_probe, 0, &function);
napi_set_named_property(env, exports, names[i], function);
}
return exports;
}
+21
View File
@@ -0,0 +1,21 @@
#import <Foundation/Foundation.h>
@protocol REAWidgetDelegate <NSObject>
- (void)widgetDidFinish;
@end
@interface REAWidget : NSObject
@property(nonatomic, weak) id<REAWidgetDelegate> delegate;
- (BOOL)performAction:(NSString *)action error:(NSError **)error;
@end
@implementation REAWidget
- (BOOL)performAction:(NSString *)action error:(NSError **)error {
NSLog(@"REA_OBJC_ACTION:%@", action);
return action.length > 0;
}
@end
int main(void) {
@autoreleasepool {
Protocol *protocol = @protocol(REAWidgetDelegate);
NSLog(@"%@", NSStringFromProtocol(protocol));
return [[[REAWidget alloc] init] performAction:@"run" error:0] ? 0 : 1;
}
}
+11
View File
@@ -0,0 +1,11 @@
import Darwin
protocol REAProtocol { func execute() -> Int }
struct REARecord { let value: Int }
enum REAState { case ready, finished }
final class REAService: REAProtocol {
@inline(never) func execute() -> Int { print("REA_SWIFT_EXECUTE"); return 42 }
}
extension REARecord { @inline(never) func doubled() -> Int { value * 2 } }
let service = REAService()
exit(service.execute() == 42 ? 0 : 1)
+9
View File
@@ -0,0 +1,9 @@
#include <stdio.h>
__attribute__((noinline, used)) int rea_version_leaf(void) {
puts("REA_VERSION_ONE");
return 1;
}
__attribute__((noinline, used)) int rea_version_entry(void) {
return rea_version_leaf();
}
int main(void) { return rea_version_entry() == 1 ? 0 : 1; }
+10
View File
@@ -0,0 +1,10 @@
#include <stdio.h>
__attribute__((noinline, used)) int rea_version_leaf(void) {
puts("REA_VERSION_TWO");
return 2;
}
__attribute__((noinline, used)) int rea_added(void) { return 40; }
__attribute__((noinline, used)) int rea_version_entry(void) {
return rea_version_leaf() + rea_added();
}
int main(void) { return rea_version_entry() == 42 ? 0 : 1; }
+33
View File
@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import {
generateLargeFixture,
LARGE_FIXTURE_COUNT,
sha256,
sourceDigest,
} from "../scripts/lib/conformance-fixtures.mjs";
describe("source-built conformance fixtures", () => {
it("generates a deterministic large pagination fixture", () => {
const first = generateLargeFixture();
const second = generateLargeFixture();
expect(first).toBe(second);
expect(first.match(/int rea_page_/gu)).toHaveLength(LARGE_FIXTURE_COUNT);
expect(first).toContain("rea_page_0000");
expect(first).toContain("rea_page_1204");
expect(() => generateLargeFixture(0)).toThrow(/positive integer/u);
});
it("hashes source manifests independently of input ordering", () => {
const left = [
{ path: "b.c", content: "b" },
{ path: "a.c", content: "a" },
];
const right = [...left].reverse();
expect(sourceDigest(left)).toBe(sourceDigest(right));
expect(sourceDigest(left)).toMatch(/^[0-9a-f]{64}$/u);
expect(sha256("fixture")).not.toBe(sha256("changed"));
});
});
+56 -21
View File
@@ -1,4 +1,3 @@
import { createHash } from "node:crypto";
import { describe, expect, it } from "vitest";
import { z } from "zod";
@@ -7,37 +6,73 @@ import {
OFFICIAL_TOOL_CONTRACTS,
SESSION_TOOL_CONTRACTS,
} from "../src/contracts/toolContracts.js";
import {
enhancedOutputSchemas,
officialOutputSchemas,
sessionOutputSchemas,
} from "../src/contracts/toolOutputSchemas.js";
const ORIGINAL_39_SHA256 =
"43ffddd91b775a3e00fb736c6b1a644210e5917a1e1f02745d0283ac6ab99db4";
describe("original tool contract snapshot", () => {
it("preserves every original name, description, kind, and complete JSON schema", () => {
const projection = [
...OFFICIAL_TOOL_CONTRACTS,
...ENHANCED_TOOL_CONTRACTS,
].map((contract) => ({
name: contract.name,
description: contract.description,
kind: contract.kind,
inputSchema: z.toJSONSchema(contract.inputSchema, {
describe("tool contract surface", () => {
it("advertises complete typed schemas and annotations for all analysis tools", () => {
const contracts = [...OFFICIAL_TOOL_CONTRACTS, ...ENHANCED_TOOL_CONTRACTS];
expect(contracts).toHaveLength(43);
for (const contract of contracts) {
const inputSchema = z.toJSONSchema(contract.inputSchema, {
target: "draft-07",
unrepresentable: "any",
}),
}));
expect(projection).toHaveLength(39);
expect(
createHash("sha256").update(JSON.stringify(projection)).digest("hex"),
).toBe(ORIGINAL_39_SHA256);
});
const outputSchema = z.toJSONSchema(contract.outputSchema, {
target: "draft-07",
unrepresentable: "any",
});
expect(inputSchema.type).toBe("object");
expect(outputSchema.type).toBe("object");
expect(contract.annotations).toMatchObject({
idempotentHint: true,
openWorldHint: false,
});
expect(typeof contract.annotations.readOnlyHint).toBe("boolean");
expect(typeof contract.annotations.destructiveHint).toBe("boolean");
}
});
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" },
]);
});
it("publishes a dedicated output schema and agent guidance for every tool", () => {
expect(Object.keys(officialOutputSchemas).sort()).toEqual(
OFFICIAL_TOOL_CONTRACTS.map(({ name }) => name).sort(),
);
expect(Object.keys(enhancedOutputSchemas).sort()).toEqual(
ENHANCED_TOOL_CONTRACTS.map(({ name }) => name).sort(),
);
expect(Object.keys(sessionOutputSchemas).sort()).toEqual(
SESSION_TOOL_CONTRACTS.map(({ name }) => name).sort(),
);
for (const contract of [
...OFFICIAL_TOOL_CONTRACTS,
...ENHANCED_TOOL_CONTRACTS,
...SESSION_TOOL_CONTRACTS,
]) {
const schema = z.toJSONSchema(contract.outputSchema, {
target: "draft-07",
unrepresentable: "any",
});
expect(JSON.stringify(schema)).not.toContain('"result":{}');
expect(contract.description.length).toBeGreaterThan(100);
expect(contract.description).toMatch(/[.;]/u);
}
});
});
+4 -4
View File
@@ -4,17 +4,17 @@ import { describe, expect, it } from "vitest";
import { ok } from "../src/domain/result.js";
import { createServer } from "../src/server/createServer.js";
const hopper = {
callTool: () => Promise.resolve(ok(null)),
const analysis = {
execute: () => Promise.resolve(ok(null)),
};
describe("beta.3 server composition", () => {
it("constructs independent MCP server instances", () => {
expect(createServer(hopper)).not.toBe(createServer(hopper));
expect(createServer(analysis)).not.toBe(createServer(analysis));
});
it("does not advertise unavailable session tools", async () => {
const server = createServer(hopper);
const server = createServer(analysis);
const client = new Client({ name: "composition-test", version: "1.0.0" });
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
+13 -2
View File
@@ -5,7 +5,7 @@ const host = (overrides: Partial<DoctorHost> = {}): DoctorHost => ({
platform: "darwin",
nodeVersion: "22.1.0",
macosVersion: () => Promise.resolve("12.0"),
readable: (path) => Promise.resolve(path.includes("Hopper")),
validTarget: (path) => Promise.resolve(path.includes("Hopper")),
executable: (path) => Promise.resolve(path.includes("Hopper")),
brewHopperPath: () => Promise.resolve(undefined),
manualHopperPaths: () => Promise.resolve([]),
@@ -61,13 +61,24 @@ describe("doctor", () => {
expect(result.hopperPath).toBe(path);
});
it("uses shared app target validation when a target is supplied", async () => {
const result = await runDoctor(
"/Applications/Notes.app",
host({
validTarget: (path) =>
Promise.resolve(path === "/Applications/Notes.app"),
}),
);
expect(result.checks.find(({ name }) => name === "target")?.ok).toBe(true);
});
it("rejects a readable Hopper launcher without execute permission", async () => {
const path = "/manual/Hopper";
const result = await runDoctor(
undefined,
host({
configuredHopperPath: path,
readable: () => Promise.resolve(true),
validTarget: () => Promise.resolve(true),
executable: () => Promise.resolve(false),
}),
);
+353 -20
View File
@@ -2,10 +2,10 @@ import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
import type { CallToolResult } from "@modelcontextprotocol/server";
import { afterEach, describe, expect, it } from "vitest";
import type { HopperToolPort } from "../src/application/HopperToolPort.js";
import type { AnalysisOperationPort } from "../src/application/AnalysisProvider.js";
import { ENHANCED_TOOL_CONTRACTS } from "../src/contracts/toolContracts.js";
import { ok } from "../src/domain/result.js";
import { jsonValueSchema, type JsonValue } from "../src/hopper/protocol.js";
import { jsonValueSchema, type JsonValue } from "../src/domain/jsonValue.js";
import { createServer } from "../src/server/createServer.js";
const PROCEDURES = {
@@ -17,18 +17,29 @@ const PROCEDURES = {
"0x6": "prefix_TtOther",
};
const fixturePort = (): HopperToolPort => ({
callTool: (name, arguments_) => {
const page = (values: Readonly<Record<string, string>>) => ({
items: Object.entries(values).map(([address, value]) => ({ address, value })),
offset: 0,
limit: 100,
total: Object.keys(values).length,
next_offset: null,
has_more: false,
});
const fixturePort = (): AnalysisOperationPort => ({
execute: (name, arguments_) => {
switch (name) {
case "list_procedures":
return Promise.resolve(ok(PROCEDURES));
return Promise.resolve(ok(page(PROCEDURES)));
case "list_names":
return Promise.resolve(
ok([
{ address: "0x10", name: "_OBJC_CLASS_$_Fixture" },
{ address: "0x11", name: "_OBJC_CLASS_$_Fixture" },
{ address: "0x12", name: "_OBJC_PROTOCOL_$_FixtureDelegate" },
]),
ok(
page({
"0x10": "_OBJC_CLASS_$_Fixture",
"0x11": "_OBJC_CLASS_$_Fixture",
"0x12": "_OBJC_PROTOCOL_$_FixtureDelegate",
}),
),
);
case "procedure_pseudo_code": {
const procedure = arguments_.procedure;
@@ -56,6 +67,18 @@ const fixturePort = (): HopperToolPort => ({
);
case "xrefs":
return Promise.resolve(ok(["0x20", "0x21"]));
case "resolve_containing_procedure":
return Promise.resolve(
ok({
query_address:
typeof arguments_.address === "string"
? arguments_.address
: "0x0",
found: false,
procedure: null,
reason: "not_in_procedure",
}),
);
case "list_segments":
return Promise.resolve(
ok([{ name: "__TEXT", start: "0x1000", end: "0x2000" }]),
@@ -63,13 +86,55 @@ const fixturePort = (): HopperToolPort => ({
case "list_documents":
return Promise.resolve(ok(["fixture"]));
case "list_strings":
return Promise.resolve(ok([{ address: "0x30", value: "hello" }]));
return Promise.resolve(ok(page({ "0x30": "hello" })));
case "analyze_function":
return Promise.resolve(
ok({
procedure: {
address: "0x1",
name: "entry",
signature: null,
locals: [],
},
pseudocode: {
text: "return 0;",
total_chars: 9,
returned_chars: 9,
truncated: false,
next_offset: null,
},
assembly: {
items: [],
total: 0,
returned: 0,
truncated: false,
next_offset: null,
},
comments: emptyBounded(),
callers: emptyBounded(),
callees: emptyBounded(),
incoming_references: emptyBounded(),
outgoing_references: emptyBounded(),
referenced_strings: emptyBounded(),
referenced_names: emptyBounded(),
basic_blocks: emptyBounded(),
instruction_scan: { scanned: 0, truncated: false },
}),
);
default:
return Promise.resolve(ok(null));
}
},
});
const emptyBounded = () => ({
items: [],
total: 0,
returned: 0,
truncated: false,
next_offset: null,
});
const resources: Array<{ close(): Promise<void> }> = [];
afterEach(async () => {
@@ -78,8 +143,8 @@ afterEach(async () => {
);
});
const connect = async (hopper: HopperToolPort = fixturePort()) => {
const server = createServer(hopper);
const connect = async (analysis: AnalysisOperationPort = fixturePort()) => {
const server = createServer(analysis);
const client = new Client({ name: "enhanced-test", version: "1.0.0" });
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
@@ -90,6 +155,19 @@ const connect = async (hopper: HopperToolPort = fixturePort()) => {
};
const jsonResult = (result: CallToolResult): JsonValue => {
if (result.structuredContent === undefined)
throw new Error("Tool result omitted structured content");
const structured = jsonValueSchema.safeParse(result.structuredContent);
if (!structured.success)
throw new Error("Tool structured result was not JSON");
if (
typeof structured.data === "object" &&
structured.data !== null &&
!Array.isArray(structured.data) &&
"result" in structured.data
) {
return structured.data.result ?? null;
}
const text = result.content.find((item) => item.type === "text");
if (text?.type !== "text")
throw new Error("Tool result omitted text content");
@@ -100,10 +178,10 @@ const jsonResult = (result: CallToolResult): JsonValue => {
};
describe("enhanced MCP tools", () => {
it("lists the complete 31 plus 8 surface", async () => {
it("lists the complete 33 plus 10 surface", async () => {
const client = await connect();
const listed = await client.listTools();
expect(listed.tools).toHaveLength(39);
expect(listed.tools).toHaveLength(43);
expect(
listed.tools
.map(({ name }) => name)
@@ -114,7 +192,7 @@ describe("enhanced MCP tools", () => {
).toEqual(ENHANCED_TOOL_CONTRACTS.map(({ name }) => name).sort());
});
it("executes all eight tools through production registration", async () => {
it("executes all ten tools through production registration", async () => {
const client = await connect();
const calls = [
["swift_classes", { pattern: "Fixture" }],
@@ -125,6 +203,8 @@ describe("enhanced MCP tools", () => {
["analyze_swift_types", {}],
["find_xrefs_to_name", { name: "entry" }],
["binary_overview", {}],
["analyze_function", { procedure: "0x1" }],
["trace_feature", { query: "hello", max_operations: 10 }],
] as const;
const results = [];
for (const [name, arguments_] of calls) {
@@ -152,13 +232,26 @@ describe("enhanced MCP tools", () => {
procedure_count: 6,
string_count: 1,
});
expect(results[8]).toMatchObject({
procedure: { address: "0x1", name: "entry" },
pseudocode: { text: "return 0;" },
});
expect(results[9]).toMatchObject({
query: "hello",
search_mode: "literal",
truncated: false,
references: [
{ target_address: "0x30", source_address: "0x20" },
{ target_address: "0x30", source_address: "0x21" },
],
});
});
it("bounds batch concurrency at the parsed maximum of 20", async () => {
let active = 0;
let maximum = 0;
const hopper: HopperToolPort = {
callTool: async (name) => {
const analysis: AnalysisOperationPort = {
execute: async (name) => {
if (name !== "procedure_pseudo_code") return ok(null);
active += 1;
maximum = Math.max(maximum, active);
@@ -167,7 +260,7 @@ describe("enhanced MCP tools", () => {
return ok("pseudo");
},
};
const client = await connect(hopper);
const client = await connect(analysis);
const result = await client.callTool({
name: "batch_decompile",
arguments: {
@@ -181,9 +274,198 @@ describe("enhanced MCP tools", () => {
expect(maximum).toBe(20);
});
it("follows procedure pagination for whole-binary workflows", async () => {
const offsets: number[] = [];
const client = await connect({
execute: (_name, arguments_) => {
const offset = arguments_.offset;
offsets.push(typeof offset === "number" ? offset : 0);
return Promise.resolve(
ok({
items: [
{
address: offset === 500 ? "0x2" : "0x1",
value: offset === 500 ? "_TtC4Last" : "_TtC5First",
},
],
offset: typeof offset === "number" ? offset : 0,
limit: 500,
total: 2,
next_offset: offset === 500 ? null : 500,
has_more: offset !== 500,
}),
);
},
});
const result = jsonResult(
await client.callTool({ name: "swift_classes", arguments: {} }),
);
expect(offsets).toEqual([0, 500]);
expect(result).toMatchObject({ count: 2 });
});
it("follows name pagination for exhaustive Objective-C discovery", async () => {
const offsets: number[] = [];
const client = await connect({
execute: (name, arguments_) => {
expect(name).toBe("list_names");
const offset =
typeof arguments_.offset === "number" ? arguments_.offset : 0;
offsets.push(offset);
return Promise.resolve(
ok({
items: [
{
address: offset === 500 ? "0x2" : "0x1",
value:
offset === 500 ? "_OBJC_CLASS_$_Last" : "_OBJC_CLASS_$_First",
},
],
offset,
limit: 500,
total: 2,
next_offset: offset === 500 ? null : 500,
has_more: offset !== 500,
}),
);
},
});
const result = jsonResult(
await client.callTool({ name: "get_objc_classes", arguments: {} }),
);
expect(offsets).toEqual([0, 500]);
expect(result).toMatchObject({ count: 2 });
});
it("honors overview detail and limit while reporting exhaustive totals", async () => {
const procedureOffsets: number[] = [];
const client = await connect({
execute: (name, arguments_) => {
switch (name) {
case "list_segments":
return Promise.resolve(
ok([
{ name: "__TEXT", start: "0x1000", end: "0x1800" },
{ name: "__DATA", start: "0x1800", end: "0x2000" },
]),
);
case "list_documents":
return Promise.resolve(ok(["fixture"]));
case "list_strings":
return Promise.resolve(
ok({
items: [{ address: "0x30", value: "first page only" }],
offset: 0,
limit: 100,
total: 700,
next_offset: 100,
has_more: true,
}),
);
case "list_procedures": {
const offset =
typeof arguments_.offset === "number" ? arguments_.offset : 0;
procedureOffsets.push(offset);
return Promise.resolve(
ok({
items: [
{
address: offset === 500 ? "0x2" : "0x1",
value: offset === 500 ? "last" : "first",
},
],
offset,
limit: 500,
total: 2,
next_offset: offset === 500 ? null : 500,
has_more: offset !== 500,
}),
);
}
default:
return Promise.resolve(ok(null));
}
},
});
const result = jsonResult(
await client.callTool({
name: "binary_overview",
arguments: { detail: "detailed", limit: 1 },
}),
);
expect(procedureOffsets).toEqual([0, 500]);
expect(result).toEqual({
document: "fixture",
detail: "detailed",
segments: [
{ name: "__TEXT", start: "0x1000", end: "0x1800", length: 2048 },
],
segment_count: 2,
procedure_count: 2,
string_count: 700,
});
});
it("rejects non-advancing pagination metadata", async () => {
const client = await connect({
execute: () =>
Promise.resolve(
ok({
items: [{ address: "0x1", value: "_TtC5First" }],
offset: 0,
limit: 500,
total: 2,
next_offset: 0,
has_more: true,
}),
),
});
const result = await client.callTool({
name: "swift_classes",
arguments: {},
});
expect(result.isError).toBe(true);
const text = result.content.find((item) => item.type === "text");
expect(text?.type === "text" ? text.text : "").toContain(
"non-advancing pagination offset",
);
});
it("stops trace_feature at its explicit operation budget", async () => {
let operations = 0;
const client = await connect({
execute: () => {
operations += 1;
return Promise.resolve(
ok({
items: [{ address: "0x1", value: "needle" }],
offset: 0,
limit: 500,
total: 2,
next_offset: 1,
has_more: true,
}),
);
},
});
const result = jsonResult(
await client.callTool({
name: "trace_feature",
arguments: { query: "needle", max_operations: 1 },
}),
);
expect(operations).toBe(1);
expect(result).toMatchObject({
operations_used: 1,
operation_budget: 1,
truncated: true,
});
expect(JSON.stringify(result)).toContain("operation budget");
});
it("returns a typed tool error for malformed Hopper boundary values", async () => {
const client = await connect({
callTool: () => Promise.resolve(ok(["not", "a", "procedure", "map"])),
execute: () => Promise.resolve(ok(["not", "a", "procedure", "map"])),
});
const result = await client.callTool({
name: "swift_classes",
@@ -195,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",
);
});
});
+112
View File
@@ -0,0 +1,112 @@
import { describe, expect, it } from "vitest";
import { EvidenceLedger } from "../src/application/EvidenceLedger.js";
import type { BinaryTarget } from "../src/domain/binaryTarget.js";
import {
createEvidence,
evidenceSchema,
parseEvidence,
} from "../src/domain/evidence.js";
const TARGET: BinaryTarget = {
path: "/tmp/fixture",
sha256: "a".repeat(64),
kind: "executable",
format: "mach-o",
architecture: "arm64",
availableArchitectures: ["arm64"],
loaderArgs: ["-l", "Mach-O", "--aarch64"],
};
const PROVIDER = { id: "fixture", name: "Fixture provider", version: "1" };
describe("analysis evidence", () => {
it("creates provider-neutral, deterministic Evidence v2", () => {
const observation = {
operation: "procedure_info",
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(parseEvidence(evidence)).toEqual(evidence);
expect(evidence).toMatchObject({
schema_version: 2,
provider: PROVIDER,
subject: { digest: { sha256: "a".repeat(64) } },
confidence: "observed",
authority: "shipped-artifact",
});
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("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");
});
});
+28
View File
@@ -44,6 +44,34 @@ const server = createServer((socket) => {
id: request.id,
error: { code: -32001, message: "safe fake failure" },
});
} else if (request.method === "current_document") {
send({ id: request.id, result: "fixture" });
} else if (request.method === "resolve_containing_procedure") {
send({
id: request.id,
result: {
query_address: request.params.address,
found: true,
procedure: { address: "0x1000", name: "fixture" },
},
});
} else if (request.method === "procedure_references") {
send({
id: request.id,
result: {
procedure: { address: "0x1000", name: "fixture" },
direction: request.params.direction ?? "outgoing",
references: {
items: [],
total: 0,
returned: 0,
truncated: false,
next_offset: null,
},
instructions_scanned: 1,
instruction_scan_truncated: false,
},
});
} else {
const delay = request.params?.delay ?? 0;
setTimeout(
+1 -1
View File
@@ -102,7 +102,7 @@ describe("HopperClient", () => {
const results = await Promise.all(
OFFICIAL_TOOL_CONTRACTS.map(({ name }) => client.callTool(name, {})),
);
expect(results).toHaveLength(31);
expect(results).toHaveLength(33);
expect(results.every((result) => result.ok)).toBe(true);
});
+74 -24
View File
@@ -2,11 +2,11 @@ import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
import type { CallToolResult } from "@modelcontextprotocol/server";
import { afterEach, describe, expect, it } from "vitest";
import {
BinarySession,
type BinaryClient,
} from "../../src/application/BinarySession.js";
import type { HopperToolPort } from "../../src/application/HopperToolPort.js";
import { BinarySession } from "../../src/application/BinarySession.js";
import type {
AnalysisClient,
AnalysisOperationPort,
} from "../../src/application/AnalysisProvider.js";
import { HopperRemoteError } from "../../src/domain/errors.js";
import { err, ok } from "../../src/domain/result.js";
import { createServer } from "../../src/server/createServer.js";
@@ -19,8 +19,8 @@ afterEach(async () => {
);
});
const connect = async (hopper: HopperToolPort) => {
const server = createServer(hopper);
const connect = async (analysis: AnalysisOperationPort) => {
const server = createServer(analysis);
const client = new Client({
name: "integration-test",
version: "1.0.0",
@@ -39,14 +39,33 @@ const text = (result: CallToolResult): string => {
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 Object.fromEntries(Object.entries(result.structuredContent));
};
describe("full MCP integration with multi-tool sequences", () => {
it("executes a realistic workflow: list methods, decompile selected, get xrefs", async () => {
const client = await connect({
callTool: (name, args) => {
execute: (name, args) => {
switch (name) {
case "list_procedures":
return Promise.resolve(
ok({ "0x1000": "main", "0x2000": "helper" }),
ok({
items: [
{ address: "0x1000", value: "main" },
{ address: "0x2000", value: "helper" },
],
offset: 0,
limit: 100,
total: 2,
next_offset: null,
has_more: false,
}),
);
case "procedure_pseudo_code":
return Promise.resolve(
@@ -65,32 +84,50 @@ describe("full MCP integration with multi-tool sequences", () => {
arguments: {},
});
expect(listResult.isError).not.toBe(true);
const procedures = JSON.parse(text(listResult));
expect(procedures).toEqual({ "0x1000": "main", "0x2000": "helper" });
expect(structured(listResult)).toMatchObject({
subject: null,
operation: "list_procedures",
provider: { id: "unidentified", version: null },
result: {
items: [
{ address: "0x1000", value: "main" },
{ address: "0x2000", value: "helper" },
],
total: 2,
has_more: false,
},
});
const decompileResult = await client.callTool({
name: "procedure_pseudo_code",
arguments: { procedure: "0x1000" },
});
expect(text(decompileResult)).toBe("pseudo for 0x1000");
expect(structured(decompileResult)).toMatchObject({
operation: "procedure_pseudo_code",
parameters: { document: null, procedure: "0x1000" },
result: "pseudo for 0x1000",
});
const xrefResult = await client.callTool({
name: "xrefs",
arguments: {},
});
expect(JSON.parse(text(xrefResult))).toEqual(["0x1000"]);
expect(structured(xrefResult)).toMatchObject({
operation: "xrefs",
result: ["0x1000"],
});
});
it("preserves the complete 42-tool inventory with a session", async () => {
it("preserves the complete 50-tool inventory with a session", async () => {
const session = new BinarySession(
(_path) =>
({
callTool: () => Promise.resolve(ok(null)),
execute: () => Promise.resolve(ok(null)),
close: () => Promise.resolve(),
}) satisfies BinaryClient,
}) satisfies AnalysisClient,
);
const server = createServer(
{ callTool: () => Promise.resolve(ok(null)) },
{ execute: () => Promise.resolve(ok(null)) },
session,
);
const client = new Client({
@@ -104,7 +141,7 @@ describe("full MCP integration with multi-tool sequences", () => {
await client.connect(clientTransport);
const listed = await client.listTools();
expect(listed.tools).toHaveLength(42);
expect(listed.tools).toHaveLength(50);
const names = listed.tools.map((t) => t.name);
expect(names).toContain("open_binary");
expect(names).toContain("close_binary");
@@ -113,12 +150,12 @@ describe("full MCP integration with multi-tool sequences", () => {
expect(names).toContain("batch_decompile");
});
it("preserves the 39-tool inventory without a session", async () => {
it("preserves the 43-tool inventory without a session", async () => {
const client = await connect({
callTool: () => Promise.resolve(ok(null)),
execute: () => Promise.resolve(ok(null)),
});
const listed = await client.listTools();
expect(listed.tools).toHaveLength(39);
expect(listed.tools).toHaveLength(43);
const names = listed.tools.map((t) => t.name);
expect(names).toContain("binary_overview");
expect(names).toContain("batch_decompile");
@@ -127,7 +164,7 @@ describe("full MCP integration with multi-tool sequences", () => {
it("propagates remote Hopper errors with structured content", async () => {
const client = await connect({
callTool: () =>
execute: () =>
Promise.resolve(err(new HopperRemoteError(-32000, "bridge timeout"))),
});
@@ -143,9 +180,22 @@ describe("full MCP integration with multi-tool sequences", () => {
it("handles concurrent tool calls without corruption", async () => {
const invocations: string[] = [];
const client = await connect({
callTool: (name) => {
execute: (name) => {
invocations.push(name);
return Promise.resolve(ok({ handled: name }));
return Promise.resolve(
ok(
["list_procedures", "list_strings"].includes(name)
? {
items: [],
offset: 0,
limit: 100,
total: 0,
next_offset: null,
has_more: false,
}
: [],
),
);
},
});
+72 -9
View File
@@ -1,11 +1,11 @@
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
import { afterEach, describe, expect, it } from "vitest";
import type { HopperToolPort } from "../src/application/HopperToolPort.js";
import type { AnalysisOperationPort } from "../src/application/AnalysisProvider.js";
import { OFFICIAL_TOOL_CONTRACTS } from "../src/contracts/toolContracts.js";
import { HopperRemoteError } from "../src/domain/errors.js";
import { err, ok } from "../src/domain/result.js";
import type { JsonValue } from "../src/hopper/protocol.js";
import type { JsonValue } from "../src/domain/jsonValue.js";
import { createServer } from "../src/server/createServer.js";
const VALID_INPUTS: Readonly<
@@ -31,7 +31,9 @@ const VALID_INPUTS: Readonly<
procedure_callees: { procedure: "main" },
procedure_callers: { procedure: "main" },
procedure_info: { procedure: "main" },
procedure_references: { procedure: "main" },
procedure_pseudo_code: { procedure: "main" },
resolve_containing_procedure: { address: "0x1000" },
search_procedures: { pattern: "main" },
search_strings: { pattern: "hello" },
set_address_name: { address: "0x1000", name: "entry" },
@@ -57,8 +59,8 @@ afterEach(async () => {
);
});
const connect = async (hopper: HopperToolPort) => {
const server = createServer(hopper);
const connect = async (analysis: AnalysisOperationPort) => {
const server = createServer(analysis);
const client = new Client({ name: "contract-test", version: "1.0.0" });
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
@@ -68,10 +70,71 @@ const connect = async (hopper: HopperToolPort) => {
return client;
};
const page: JsonValue = {
items: [],
offset: 0,
limit: 100,
total: 0,
next_offset: null,
has_more: false,
};
const outputFor = (name: string): JsonValue => {
if (["list_procedures", "list_names", "list_strings"].includes(name))
return page;
if (["address_name", "comment", "inline_comment"].includes(name)) return null;
if (["procedure_callees", "procedure_callers", "xrefs"].includes(name))
return [];
if (["list_bookmarks", "list_documents", "list_segments"].includes(name))
return [];
if (
[
"set_address_name",
"set_bookmark",
"set_comment",
"set_inline_comment",
"unset_bookmark",
].includes(name)
)
return true;
if (name === "set_addresses_names") return { "0x1000": true };
if (["search_procedures", "search_strings"].includes(name)) return {};
if (name === "procedure_info")
return {
name: "main",
entrypoint: "0x1000",
basicblock_count: 1,
length: 4,
signature: null,
locals: [],
};
if (name === "resolve_containing_procedure")
return {
query_address: "0x1000",
found: true,
procedure: { address: "0x1000", name: "main" },
};
if (name === "procedure_references")
return {
procedure: { address: "0x1000", name: "main" },
direction: "outgoing",
references: {
items: [],
total: 0,
returned: 0,
truncated: false,
next_offset: null,
},
instructions_scanned: 1,
instruction_scan_truncated: false,
};
return name.includes("address") ? "0x1000" : "fixture";
};
describe("official Hopper proxy tools", () => {
it("lists exactly the 31 official contracts", async () => {
it("lists every official contract", async () => {
const client = await connect({
callTool: () => Promise.resolve(ok(null)),
execute: () => Promise.resolve(ok(null)),
});
const listed = await client.listTools();
const officialNames = new Set(
@@ -88,9 +151,9 @@ describe("official Hopper proxy tools", () => {
it("executes every handler and projects omitted Python optionals to null", async () => {
const invocations: Invocation[] = [];
const client = await connect({
callTool: (name, arguments_) => {
execute: (name, arguments_) => {
invocations.push({ name, arguments_ });
return Promise.resolve(ok({ name, arguments: arguments_ }));
return Promise.resolve(ok(outputFor(name)));
},
});
@@ -122,7 +185,7 @@ describe("official Hopper proxy tools", () => {
it("returns stable safe MCP error content", async () => {
const client = await connect({
callTool: () => Promise.resolve(err(new HopperRemoteError(-1, "denied"))),
execute: () => Promise.resolve(err(new HopperRemoteError(-1, "denied"))),
});
const result = await client.callTool({
name: "list_documents",
+191
View File
@@ -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);
});
});
+40
View File
@@ -0,0 +1,40 @@
import { readdir, readFile } from "node:fs/promises";
import { join, relative } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
const sourceRoot = new URL("../src/", import.meta.url);
describe("provider-neutral architecture", () => {
it("keeps Hopper imports behind its adapter and composition root", async () => {
const forbiddenRoots = ["domain", "contracts", "server", "application"];
const violations: string[] = [];
for (const root of forbiddenRoots) {
for (const file of await typescriptFiles(
fileURLToPath(new URL(`${root}/`, sourceRoot)),
)) {
if (root === "application" && file.endsWith("/runtime.ts")) continue;
const source = await readFile(file, "utf8");
if (/from\s+["'][^"']*\/hopper\//u.test(source)) {
violations.push(relative(fileURLToPath(sourceRoot), file));
}
}
}
expect(violations).toEqual([]);
});
});
const typescriptFiles = async (
directory: string,
): Promise<readonly string[]> => {
const entries = await readdir(directory, { withFileTypes: true });
const nested = await Promise.all(
entries.map(async (entry) => {
const path = join(directory, entry.name);
if (entry.isDirectory()) return typescriptFiles(path);
return entry.isFile() && entry.name.endsWith(".ts") ? [path] : [];
}),
);
return nested.flat();
};
+31
View File
@@ -0,0 +1,31 @@
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
const readmes = [
"README.md",
"README_zh.md",
"README_ja.md",
"README_ko.md",
"README_ar.md",
] as const;
describe("localized README product facts", () => {
it.each(readmes)(
"keeps commands and requirements aligned in %s",
async (path) => {
const content = await readFile(resolve(path), "utf8");
expect(content).toContain("npx skills add morluto/rea");
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 24");
expect(content).toContain("macOS 12");
expect(content).toMatch(/\b50\b/u);
expect(content).toMatch(/\b33\b/u);
expect(content).toMatch(/\b10\b/u);
expect(content).toMatch(/\b3\b/u);
},
);
});
+3 -3
View File
@@ -10,7 +10,7 @@ const fixturePath = fileURLToPath(
);
describe("production stdio runtime", () => {
it("starts the built entrypoint, lists 42 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(42);
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(42);
expect((await client.listTools()).tools).toHaveLength(50);
} finally {
await client.close();
await transport.close();
+43 -8
View File
@@ -4,13 +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,
type BinaryClient,
} from "../src/application/BinarySession.js";
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;
@@ -44,7 +44,17 @@ describe("target-free MCP lifecycle", () => {
});
expect(before.isError).toBe(true);
expect(text(before)).toContain("NoBinaryOpenError");
expect((await mcp.listTools()).tools).toHaveLength(42);
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,
@@ -52,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(
@@ -64,9 +88,11 @@ describe("target-free MCP lifecycle", () => {
});
});
const client = (path: string, closed: string[]): BinaryClient => ({
callTool: (name) =>
Promise.resolve(ok(name === "health" ? null : { path, name })),
const client = (path: string, closed: string[]): AnalysisClient => ({
execute: (name) =>
Promise.resolve(
ok(name === "health" ? null : name === "current_document" ? path : null),
),
close: () => {
closed.push(path);
return Promise.resolve();
@@ -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);
};
+9 -5
View File
@@ -8,16 +8,20 @@ import {
} from "../src/contracts/toolContracts.js";
describe("tool contract inventory", () => {
it("preserves the original 39 contracts and adds three session tools", () => {
expect(OFFICIAL_TOOL_CONTRACTS).toHaveLength(31);
expect(ENHANCED_TOOL_CONTRACTS).toHaveLength(8);
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(42);
expect(new Set(TOOL_CONTRACTS.map(({ name }) => name)).size).toBe(42);
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", () => {