Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8fea6ce9cd | ||
|
|
8f538373f0 | ||
|
|
cc8681a856 | ||
|
|
9f60348d1b | ||
|
|
167a824f91 | ||
|
|
d648bcd2e4 | ||
|
|
4f18e26ee5 | ||
|
|
1f235cb52f | ||
|
|
893d2e2d05 | ||
|
|
7e4ae21d78 | ||
|
|
85f178ba20 | ||
|
|
fc21c9a2d7 | ||
|
|
625265c3f5 | ||
|
|
4eeefdaed6 | ||
|
|
051b6db656 | ||
|
|
82ff57cafa | ||
|
|
910eda6f32 | ||
|
|
b72e9083cd | ||
|
|
d1bb959295 | ||
|
|
12a95777cc | ||
|
|
07a39144aa | ||
|
|
8f035abb3a | ||
|
|
03072e0ea7 |
+11
-10
@@ -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,10 +50,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 knip
|
||||
- run: npm run lint:dead
|
||||
|
||||
duplicates:
|
||||
name: Duplicate Code (jscpd)
|
||||
@@ -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,10 +86,11 @@ 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 test -- --reporter=verbose --reporter=junit --outputFile.junit=test-results.xml
|
||||
- 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()
|
||||
with:
|
||||
@@ -104,7 +105,7 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
node-version-file: .nvmrc
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run docs:generate
|
||||
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
node-version-file: .nvmrc
|
||||
package-manager-cache: false
|
||||
registry-url: https://registry.npmjs.org
|
||||
|
||||
|
||||
+2
-1
@@ -1 +1,2 @@
|
||||
npx oxlint --fix
|
||||
npx lint-staged
|
||||
npm run typecheck
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "0.2.0"
|
||||
".": "0.3.0"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,37 @@
|
||||
# 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)
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
* redesign and localize README ([051b6db](https://github.com/morluto/rea/commit/051b6db65687fd14826b6462cd623feb2504f123))
|
||||
* redesign and localize README ([910eda6](https://github.com/morluto/rea/commit/910eda6f323ba1151b0771fe5de6c60a91e3d46d))
|
||||
|
||||
## [0.2.0](https://github.com/morluto/rea/compare/rea-0.1.0...rea-0.2.0) (2026-07-12)
|
||||
|
||||
|
||||
|
||||
+14
-1
@@ -4,10 +4,11 @@ REA welcomes focused bug fixes, documentation improvements, tests, and reverse-e
|
||||
|
||||
## Development setup
|
||||
|
||||
REA requires macOS 12 or newer, Node.js 22 or newer, and a separately installed Hopper Disassembler application for real-Hopper verification.
|
||||
REA requires macOS 12 or newer, Node.js 24.18.x, npm 11.16.x, and a separately installed Hopper Disassembler application for real-Hopper verification. Run `nvm use` before installing dependencies.
|
||||
|
||||
```bash
|
||||
npm ci
|
||||
npm run rebuild:native # only when the packaged PTY binary is incompatible
|
||||
npm run build
|
||||
npm test
|
||||
```
|
||||
@@ -22,6 +23,18 @@ npm run verify:package
|
||||
npm pack --dry-run
|
||||
```
|
||||
|
||||
`npm install` prepares the Husky pre-commit hook. Commits format and lint staged
|
||||
files with lint-staged, then typecheck the complete project. The normal test and
|
||||
CI coverage runs enforce the thresholds in `vitest.config.ts`; `npm run
|
||||
lint:dead` rejects unused files, exports, and dependencies.
|
||||
|
||||
Set `REA_LOG_LEVEL` to `trace`, `debug`, `info`, `warn`, `error`, `fatal`, or
|
||||
`silent` to control structured JSON diagnostics. MCP mode defaults to `info` and
|
||||
always writes logs to stderr so the stdio protocol remains intact. One-shot CLI
|
||||
logging is opt-in and writes to stdout when a level is configured, preserving
|
||||
machine-readable command output by default. Request arguments, bridge
|
||||
authentication tokens, and environment data are redacted.
|
||||
|
||||
Changes that claim real Hopper behavior must also be tested against two distinct binaries:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -1,65 +1,199 @@
|
||||
# REA
|
||||
<div align="center">
|
||||
|
||||
[npm package](https://www.npmjs.com/package/@morluto/rea) · [Issues](https://github.com/morluto/rea/issues) · [Hopper Disassembler](https://www.hopperapp.com/)
|
||||
**English** · [简体中文](README_zh.md) · [日本語](README_ja.md) · [한국어](README_ko.md) · [العربية](README_ar.md)
|
||||
|
||||
REA gives coding agents access to Hopper Disassembler through one `rea` command. It exposes 42 MCP tools: 31 direct Hopper operations, 8 composed analysis workflows, and 3 target-session tools.
|
||||
# REA: Reverse Engineer Anything
|
||||
|
||||
REA uses Hopper's public Python API through its own authenticated local bridge. It does not use Hopper's bundled MCP server.
|
||||
### One CLI and MCP server for coding agents to reverse engineer anything
|
||||
|
||||
**REA is not a disassembler.** Hopper Disassembler is a separate macOS dependency that must be installed and licensed independently. REA launches and controls Hopper underneath through its public Python API.
|
||||
**See a feature you like. Understand how it works, down to the binary level.**
|
||||
|
||||
## Requirements
|
||||
[](https://www.npmjs.com/package/@morluto/rea)
|
||||
[](https://github.com/morluto/rea/actions/workflows/ci.yml)
|
||||
[](#50-tools-for-investigation)
|
||||
[](https://nodejs.org/)
|
||||
[](LICENSE)
|
||||
|
||||
- macOS 12 or newer
|
||||
- Node.js 22 or newer
|
||||
- Hopper Disassembler
|
||||
[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)
|
||||
|
||||
REA does not bundle Hopper. `rea setup --yes` can install Homebrew when absent, install Hopper with the `hopper-disassembler` Homebrew cask, configure detected MCP clients, and install the REA agent skill. Setup is idempotent and returns structured remediation when an installer needs human attention.
|
||||
<br />
|
||||
|
||||
## Install
|
||||
<code>npx skills add morluto/rea</code>
|
||||
|
||||
Quick start:
|
||||
</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, explain how it works, show its evidence, and build a version adapted to your stack and requirements.
|
||||
|
||||
REA gives agents one consistent way to investigate software. Today that includes deep native analysis through Hopper, complete function dossiers, reproducible Evidence v2 records, and controlled process capture. The longer-term toolkit extends the same agent workflow to packaged apps, JavaScript bundles, websites, APIs, protocols, mobile artifacts, firmware, runtime behavior, and differences between versions.
|
||||
|
||||
Reverse engineering normally makes the operator choose a tool, learn its API, move evidence between programs, and decide what to inspect next. REA gives that work to the agent through commands, skills, structured results, and repeatable investigation workflows.
|
||||
|
||||
## Just ask your agent
|
||||
|
||||
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 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 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 />
|
||||
Turn what the agent learned into a feature for your own product, adapted to your stack, interface, and requirements.
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
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 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
|
||||
|
||||
### With a coding agent — recommended
|
||||
|
||||
```bash
|
||||
npx skills add morluto/rea
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
### From Terminal
|
||||
|
||||
```bash
|
||||
npx -y @morluto/rea setup --yes
|
||||
```
|
||||
|
||||
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
|
||||
npx -y @morluto/rea mcp
|
||||
```
|
||||
|
||||
The final command starts the stdio MCP server for clients that launch it directly. `setup` can write the equivalent persistent client registration automatically.
|
||||
### CLI or coding agent?
|
||||
|
||||
For source-checkout development:
|
||||
| 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` |
|
||||
|
||||
```bash
|
||||
npm ci
|
||||
npm run build
|
||||
npm link
|
||||
rea setup --yes
|
||||
## One prompt, a full investigation
|
||||
|
||||
```text
|
||||
Reverse engineer the Notes app. Find how offline search works, explain it,
|
||||
and build a version for my project using TypeScript and SQLite.
|
||||
```
|
||||
|
||||
The CLI and MCP adapter call the same binary-session runtime directly; neither invokes the other. One-shot `analyze` and `decompile` commands close their bridge session before returning. MCP mode keeps one session alive so an agent can switch targets.
|
||||
REA gives the agent a clear path from that request to working code:
|
||||
|
||||
```bash
|
||||
rea --help
|
||||
rea --llms
|
||||
rea doctor --target /path/to/binary
|
||||
rea mcp add
|
||||
```
|
||||
| Step | What the agent does | REA tools |
|
||||
| ---: | --------------------------------------- | ---------------------------------------------------------------- |
|
||||
| 1 | Opens and identifies the binary | `open_binary`, `binary_overview` |
|
||||
| 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 | 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 |
|
||||
|
||||
## MCP workflow
|
||||
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.
|
||||
|
||||
Start the MCP adapter with `rea mcp` (or `rea --mcp`) with no target. In the connected agent:
|
||||
## What agents can do
|
||||
|
||||
1. Call `open_binary` with any readable local binary or `.hop` path.
|
||||
2. Call `binary_overview`, then use decompilation, strings, symbols, and xrefs tools.
|
||||
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 current state.
|
||||
- Investigate a feature you like and build a version tailored to your own product.
|
||||
- Explain how a feature works when its source code is unavailable.
|
||||
- Reconstruct an app's authentication, storage, update, or networking flow.
|
||||
- Recover enough structure to document an undocumented format or interface.
|
||||
- Trace a suspicious behavior from a string or symbol to the code that implements it.
|
||||
- Compare implementation paths across two app versions by switching targets in one session.
|
||||
- Turn recovered behavior into product features, tests, migration notes, ports, or interoperable replacements.
|
||||
- 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.
|
||||
|
||||
Relative target paths resolve against the MCP server's working directory. Mach-O/FAT, ELF, PE, and Hopper databases are detected automatically. FAT loading selects the host-compatible architecture.
|
||||
## 50 tools for investigation
|
||||
|
||||
`HOPPER_TARGET_PATH` is optional and opens an initial target at server startup. `HOPPER_LAUNCHER_PATH` overrides the Hopper launcher. `HOPPER_TARGET_KIND` selects `executable` or `database` for an initial target. `HOPPER_LOADER_ARGS_JSON` overrides REA's derived Hopper loader arguments for supported executable formats.
|
||||
| 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 |
|
||||
|
||||
Manual target-free MCP configuration:
|
||||
The public interface describes what the agent is trying to learn. Providers decide how to answer. Hopper currently implements the native-analysis capabilities; the process harness implements controlled behavioral capture. Future providers can satisfy the same capability without changing the agent's investigation workflow.
|
||||
|
||||
## Current status
|
||||
|
||||
REA is already useful for native application investigation on macOS:
|
||||
|
||||
- Open Mach-O, ELF, PE, `.app`, and Hopper database targets.
|
||||
- Build bounded function dossiers with pseudocode, assembly, CFG edges, comments, calls, references, strings, and names.
|
||||
- Search and trace features across symbols, strings, metadata, references, and call paths.
|
||||
- Record every successful result as deterministic Evidence v2 with artifact and provider identity, confidence, authority, limitations, and locations.
|
||||
- Export and import evidence bundles across sessions.
|
||||
- Capture approved PTY scenarios, child processes, filesystem changes, and loopback HTTP/WebSocket exchanges, then compare normalized captures.
|
||||
|
||||
Hopper is the first provider, not the boundary of the project. Some current workflows still require Hopper and macOS; every evidence record identifies the provider and limitations behind its result.
|
||||
|
||||
## Roadmap
|
||||
|
||||
REA is growing into a toolkit for understanding software across static artifacts and observed behavior. The next capability families are:
|
||||
|
||||
1. **Artifact decomposition** — DMG, ASAR, ZIP, packages, universal-binary slices, application resources, embedded frameworks, mobile packages, and artifact graphs.
|
||||
2. **Web and Electron investigation** — Playwright/CDP capture of DOM, accessibility trees, screenshots, storage, console, IPC, HTTP, WebSocket, routes, and visual or structural differences.
|
||||
3. **Deterministic behavior harnesses** — stronger process-tree ownership, protocol fixtures, network policy, filesystem tracing, signals, reconnects, and cross-version comparison.
|
||||
4. **JavaScript and source recovery** — bundle indexing, AST/module reconstruction, source-map discovery, historical-source matching, and CodeDB-backed cross-references.
|
||||
5. **Runtime observation** — approval-gated LLDB, Frida, system logs, process and filesystem observers, and native API tracing.
|
||||
6. **More static-analysis providers** — native platform utilities first, followed by Ghidra, IDA/Hex-Rays, Binary Ninja, Rizin, LIEF, and other engines behind provider-neutral capabilities.
|
||||
7. **More targets and platforms** — Windows-native providers and ConPTY verification, Linux parity, websites and APIs, mobile artifacts, firmware, document formats, and other software-defined systems.
|
||||
8. **Differential reconstruction** — compare artifacts, functions, bundles, protocols, UIs, and process captures; track residual unknowns; verify a reconstruction against observed behavior.
|
||||
|
||||
Roadmap items describe direction, not shipped support. New providers must produce the same evidence and safety metadata as existing capabilities before they become part of the public workflow.
|
||||
|
||||
## Using REA with other coding agents
|
||||
|
||||
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
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -72,39 +206,107 @@ Manual target-free MCP configuration:
|
||||
}
|
||||
```
|
||||
|
||||
The first `open_binary` can take time while Hopper analyzes the file. Analysis calls made without an open target return an actionable `NoBinaryOpenError`.
|
||||
## How it works
|
||||
|
||||
## Hopper application behavior
|
||||
```mermaid
|
||||
flowchart LR
|
||||
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
|
||||
```
|
||||
|
||||
REA starts Hopper when needed; Hopper does not have 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 Hopper will remain behind the current application.
|
||||
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.
|
||||
|
||||
Explicit format and architecture loader arguments prevent Hopper's common FAT/ARM selection dialogs. Other Hopper or macOS dialogs can still require a person. REA reports timeouts and setup remediation through the CLI or MCP result instead of trying to answer UI prompts.
|
||||
## CLI
|
||||
|
||||
The agent workflow above is the easiest way to use REA. For a one-off overview from the Terminal:
|
||||
|
||||
```bash
|
||||
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
|
||||
npm install --global @morluto/rea
|
||||
rea --help
|
||||
rea mcp
|
||||
```
|
||||
|
||||
REA accepts a Mac `.app` folder directly. If an agent cannot find an app by name, tell it where the app is installed.
|
||||
|
||||
## 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.
|
||||
|
||||
REA derives explicit format and architecture arguments to prevent common FAT and ARM selection dialogs. Other Hopper or macOS dialogs may still require a person. REA reports timeouts and remediation through CLI or MCP results instead of attempting to answer UI prompts.
|
||||
|
||||
Closing a REA session shuts down its bridge and removes its private socket directory. It does not quit a Hopper application the user may be using.
|
||||
|
||||
## Local security boundary
|
||||
## Security model
|
||||
|
||||
Each bridge session uses a random capability token and a Unix socket restricted to the current user. This rejects unauthenticated connections and access from other local user accounts, but it does not defend against a malicious process already running as the same user. REA bounds protocol messages and exposes sanitized error messages instead of launcher stderr or internal exception causes.
|
||||
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).
|
||||
|
||||
This boundary prevents unrelated local processes from accidentally using a bridge session; it is not a sandbox. Opening an untrusted binary still delegates parsing and analysis to Hopper under the current macOS user account.
|
||||
## FAQ
|
||||
|
||||
## Development and verification
|
||||
<details>
|
||||
<summary><strong>Does Hopper need to be running before I start REA?</strong></summary>
|
||||
|
||||
```bash
|
||||
npm run check
|
||||
npm pack --dry-run
|
||||
npm run verify:package
|
||||
HOPPER_TARGET_PATH=/path/to/target-a \
|
||||
HOPPER_SECOND_TARGET_PATH=/path/to/distinct-target-b \
|
||||
npm run verify:hopper
|
||||
```
|
||||
No. REA starts Hopper when an operation needs it. An already-running Hopper application is also supported.
|
||||
|
||||
Real-Hopper verification requires Hopper and two distinct binaries. It checks target-free startup, all 42 tools, analysis, bounded decompilation, target switching, bridge cleanup, and server shutdown. The verifier terminates only Hopper bundle processes that appeared during its run; it preserves Hopper processes that were already running.
|
||||
</details>
|
||||
|
||||
The npm package is named `@morluto/rea`, while the product and installed executable remain `REA` and `rea`. Setup writes a floating MCP registration using `npx -y @morluto/rea mcp`. See [CONTRIBUTING.md](CONTRIBUTING.md) for the release checklist and local-tarball verification workflow.
|
||||
<details>
|
||||
<summary><strong>Why did Hopper appear in front of my other windows?</strong></summary>
|
||||
|
||||
Security reports should follow [SECURITY.md](SECURITY.md).
|
||||
Hopper's launcher internally activates the application. REA requests background startup, but macOS and Hopper may still bring a window or dialog forward. See [Hopper application behavior](#hopper-application-behavior).
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Does REA include Hopper?</strong></summary>
|
||||
|
||||
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 the app?</strong></summary>
|
||||
|
||||
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>
|
||||
|
||||
<details>
|
||||
<summary><strong>Can REA recover the original source code?</strong></summary>
|
||||
|
||||
No decompiler can guarantee the original source. REA gives an agent pseudocode, assembly, symbols, strings, metadata, and relationships that it can use to explain or compatibly recreate observed behavior.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Which agents can use REA?</strong></summary>
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
[npm](https://www.npmjs.com/package/@morluto/rea) · [Issues](https://github.com/morluto/rea/issues) · [Security](SECURITY.md) · [Contributing](CONTRIBUTING.md) · [Hopper](https://www.hopperapp.com/)
|
||||
|
||||
## License
|
||||
|
||||
MIT, see [LICENSE](LICENSE).
|
||||
[MIT](LICENSE)
|
||||
|
||||
+259
@@ -0,0 +1,259 @@
|
||||
<div align="center">
|
||||
|
||||
[English](README.md) · [简体中文](README_zh.md) · [日本語](README_ja.md) · [한국어](README_ko.md) · **العربية**
|
||||
|
||||
# REA: هندسة أي شيء عكسيًا
|
||||
|
||||
### واجهة CLI وخادم MCP واحدان يمكّنان وكلاء البرمجة من إجراء هندسة عكسية لأي شيء
|
||||
|
||||
**اعثر على ميزة تعجبك. افهم آلية عملها. ابنها بالطريقة التي تريدها.**
|
||||
|
||||
[](https://www.npmjs.com/package/@morluto/rea)
|
||||
[](https://github.com/morluto/rea/actions/workflows/ci.yml)
|
||||
[](#منصة-من-50-أداة)
|
||||
[](https://nodejs.org/)
|
||||
[](LICENSE)
|
||||
|
||||
[البدء السريع](#البدء-السريع) · [من الملف التنفيذي إلى السلوك](#من-الملف-التنفيذي-إلى-السلوك) · [50 أداة](#منصة-من-50-أداة) · [كيف يعمل](#كيف-يعمل) · [الأسئلة الشائعة](#الأسئلة-الشائعة)
|
||||
|
||||
<br />
|
||||
|
||||
<code>npx skills add morluto/rea</code>
|
||||
|
||||
</div>
|
||||
|
||||
هل وجدت في تطبيق ما ميزة تريدها في منتجك؟ أعط التطبيق إلى وكيل البرمجة حتى من دون شيفرته المصدرية. باستخدام REA، يستطيع الوكيل استقصاء الميزة وفهم آلية عملها ثم بناء نسخة ملائمة لتقنياتك وتصميمك ومتطلباتك.
|
||||
|
||||
يوفر REA هذه العملية عبر CLI وخادم MCP واحدين. يستطيع الوكيل قراءة شيفرة التطبيق وتتبع طريقة عمل الميزات ثم استخدام الأدلة مباشرة في عمله البرمجي المعتاد. ويتولى REA أدوات الهندسة العكسية المعقدة خلف واجهة واحدة.
|
||||
|
||||
## اطلب من وكيلك مباشرة
|
||||
|
||||
```bash
|
||||
npx skills add morluto/rea
|
||||
```
|
||||
|
||||
ثم اطلب:
|
||||
|
||||
```text
|
||||
أعد REA وأجر هندسة عكسية لتطبيق Notes. اشرح طريقة عمل البحث وكيف توصلت إلى ذلك،
|
||||
ثم ابنِ ميزة مشابهة لمشروعي.
|
||||
```
|
||||
|
||||
تطبيق Notes مجرد مثال. يمكنك تسمية أي تطبيق تريد فهمه أو طلب البدء بنظرة عامة.
|
||||
|
||||
## من الملف التنفيذي إلى السلوك
|
||||
|
||||
| فك الترجمة | الفهم | إعادة الإنشاء |
|
||||
| ----------------------------------------------------------------- | --------------------------------------------------------- | ------------------------------------------------------------- |
|
||||
| استخرج الشيفرة شبه المصدرية، والتعليمات، والسلاسل النصية، والرموز | تتبع تدفق التحكم والمراجع المتبادلة والاستدعاءات والأنواع | حوّل ما تعلمه الوكيل إلى ميزة تناسب تقنياتك وواجهتك ومتطلباتك |
|
||||
|
||||
يوضح REA كيف وصل إلى نتائجه. وهو لا يدّعي استعادة الشيفرة المصدرية الأصلية أو استنساخ التطبيق تلقائيًا.
|
||||
|
||||
## لماذا REA؟
|
||||
|
||||
| | |
|
||||
| -------------------- | ----------------------------------------------------------------------------------------- |
|
||||
| **مصمم للوكلاء** | اسأل عما يفعله تطبيق مترجم ودع الوكيل يجمع الأدلة بدلًا من التخمين. |
|
||||
| **CLI وMCP** | استخدم قدرات الهندسة العكسية نفسها من الطرفية أو وكيل البرمجة. |
|
||||
| **يتولى التعقيد** | يدير REA إعداد الأدوات وفتح التطبيق واستمرار الاستقصاء والتنظيف بعد الانتهاء. |
|
||||
| **سير عمل كامل** | انتقل من النظرة الأولى إلى الشيفرة شبه المصدرية وعلاقات الاستدعاء والأنواع وأدلة التنفيذ. |
|
||||
| **محلي حسب التصميم** | يجري التحليل على جهاز Mac ولا يرفع REA الملف التنفيذي إلى خدمة تحليل مستضافة. |
|
||||
| **يحافظ على السياق** | استقصِ عدة ملفات تنفيذية من دون بدء التحليل من جديد عند كل سؤال. |
|
||||
|
||||
## البدء السريع
|
||||
|
||||
### باستخدام وكيل برمجة — موصى به
|
||||
|
||||
```bash
|
||||
npx skills add morluto/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 لكنه لا يوفر الترخيص.
|
||||
|
||||
### 1. إعداد REA
|
||||
|
||||
```bash
|
||||
npx -y @morluto/rea setup --yes
|
||||
```
|
||||
|
||||
إذا طلب macOS أو برنامج التثبيت تأكيدًا، فأكمل الخطوة ثم شغّل الأمر نفسه مرة أخرى.
|
||||
|
||||
### 2. إعادة تشغيل وكيل البرمجة
|
||||
|
||||
يهيئ Setup تلقائيًا نسخ Claude Desktop وCursor التي يعثر عليها. أعد تشغيل التطبيق كي يحمّل REA.
|
||||
|
||||
### 3. اطلب من الوكيل
|
||||
|
||||
يمكنك ذكر التطبيق باسمه. سيعثر وكيل البرمجة عليه ويمرر ملف البرنامج الذي يحتاجه REA.
|
||||
|
||||
```text
|
||||
استخدم REA لإجراء هندسة عكسية لتطبيق Notes. اشرح طريقة عمل البحث واعرض الأدلة،
|
||||
ثم ابنِ ميزة مشابهة لمشروعي باستخدام SQLite.
|
||||
```
|
||||
|
||||
إذا واجهت مشكلة، فشغّل:
|
||||
|
||||
```bash
|
||||
npx -y @morluto/rea doctor
|
||||
```
|
||||
|
||||
## استقصاء كامل بطلب واحد
|
||||
|
||||
بعد الإعداد، يمكنك أن تطلب من وكيلك:
|
||||
|
||||
> افتح التطبيق، واكتشف آلية عمل البحث من دون اتصال، واشرح تدفق التحكم، ثم ابنِ نسخة لمشروعي باستخدام TypeScript وSQLite.
|
||||
|
||||
يمكن للوكيل تحويل ذلك إلى سير عمل قابل للتحقق:
|
||||
|
||||
```text
|
||||
فتح الهدف
|
||||
→ انتظار اكتمال تحليل Hopper
|
||||
→ جرد المعماريات والرموز والسلاسل النصية
|
||||
→ تحديد نقاط الدخول والدوال المهمة
|
||||
→ تتبع الاستدعاءات والمراجع المتبادلة وتدفق التحكم
|
||||
→ فك ترجمة التنفيذ ذي الصلة
|
||||
→ تلخيص السلوك والقيود والحالات الطرفية
|
||||
→ بناء الميزة بما يلائم تقنيات المشروع ومتطلباته
|
||||
```
|
||||
|
||||
## ما الذي يمكنك فعله؟
|
||||
|
||||
### فهم تطبيق لا تملك مصدره
|
||||
|
||||
اكشف نقاط الدخول، وأسماء الفئات، والسلاسل النصية، والأطر المستخدمة، ومسارات الميزات من الملف التنفيذي وحده.
|
||||
|
||||
### بناء ميزة تعجبك بطريقتك
|
||||
|
||||
استقصِ الميزة، وافهم سلوكها، ثم ابنِ نسخة ملائمة لمنتجك وتقنياتك ومتطلباتك.
|
||||
|
||||
### توثيق بروتوكول أو تنسيق غير موثق
|
||||
|
||||
تتبع الموزعات، والثوابت، والمقارنات، والقراءة والكتابة لفهم الرسائل وتنسيقات الملفات وآلات الحالات.
|
||||
|
||||
### إعادة إنشاء سلوك متوافق
|
||||
|
||||
حوّل منطقًا مفكوك الترجمة إلى مواصفات واضحة، ثم نفذ بديلًا نظيفًا تحكمه اختبارات السلوك الملحوظ.
|
||||
|
||||
### تدقيق منطق أمني حساس
|
||||
|
||||
افحص التحقق من الإدخال، والتشفير، والصلاحيات، والتخزين، ومسارات الأخطاء من دون رفع الملف التنفيذي إلى خدمة بعيدة.
|
||||
|
||||
## منصة من 50 أداة
|
||||
|
||||
| عائلة الأدوات | العدد | أمثلة |
|
||||
| --------------------- | ----: | ----------------------------------------------------------------------------------- |
|
||||
| فحص الملفات التنفيذية | 33 | الدوال، والشيفرة شبه المصدرية، والتعليمات، والسلاسل، والأسماء، والمراجع، والتعليقات |
|
||||
| التحليل المركب | 10 | النظرة العامة، وفك الترجمة الدفعي، ومخططات الاستدعاء، والمراجع، واكتشاف الأنواع |
|
||||
| جلسة الملف التنفيذي | 7 | فتح الأهداف وحزم الأدلة والتقاط العمليات ومقارنتها |
|
||||
|
||||
## استخدام REA مع وكلاء برمجة آخرين
|
||||
|
||||
يهيئ Setup حاليًا Claude Desktop وCursor تلقائيًا. يمكن لأي وكيل برمجة يدعم خوادم MCP المحلية استخدام REA بالإعداد التالي:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"rea": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@morluto/rea", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## كيف يعمل؟
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Human[المستخدم] --> Agent[وكيل البرمجة]
|
||||
Agent --> REA[REA]
|
||||
Human -->|CLI| REA
|
||||
REA --> Hopper[محرك التحليل]
|
||||
Hopper --> App[تطبيقك]
|
||||
```
|
||||
|
||||
يستخدم CLI وخادم MCP محرك التحليل نفسه. تغلق أوامر الطرفية التطبيق عند انتهائها، بينما تبقيه جلسة الوكيل مفتوحًا أثناء الاستقصاء.
|
||||
|
||||
## أوامر CLI
|
||||
|
||||
سير عمل الوكيل أعلاه هو أسهل طريقة لاستخدام REA. للحصول على نظرة سريعة على تطبيق من الطرفية:
|
||||
|
||||
```bash
|
||||
npx -y @morluto/rea analyze /Applications/Notes.app
|
||||
```
|
||||
|
||||
شغّل `npx -y @morluto/rea --help` لمعرفة خيارات فك الترجمة المباشر والخيارات الأخرى.
|
||||
|
||||
يمكن لـ REA فتح مجلد `.app` على Mac مباشرة. إذا لم يعثر الوكيل على التطبيق، فأخبره بمكان تثبيته.
|
||||
|
||||
## سلوك Hopper
|
||||
|
||||
يستطيع REA تشغيل Hopper تلقائيًا؛ لا حاجة إلى افتراض أنه يعمل مسبقًا. لكنه تطبيق macOS رسومي، لذلك قد يطلب التركيز أو يعرض حوارًا عندما يفرض Hopper قرارًا تفاعليًا. لا يمكن لـ REA ضمان تشغيل غير مرئي تمامًا لأن هذه الواجهة يملكها Hopper وmacOS.
|
||||
|
||||
لتجنب مربع اختيار معمارية ملف شامل، حدد معطيات المحمل مسبقًا:
|
||||
|
||||
```bash
|
||||
export HOPPER_LOADER_ARGS_JSON='["-l", "Mach-O", "--aarch64"]'
|
||||
```
|
||||
|
||||
يدير REA دورة حياة Hopper ويحاول إغلاق العمليات التي بدأها عند انتهاء الأمر أو جلسة MCP.
|
||||
|
||||
## الأمان والخصوصية
|
||||
|
||||
- يبقى تحليل الملف التنفيذي محليًا بين REA وHopper.
|
||||
- لا يرفع REA الأهداف إلى خدمة مستضافة.
|
||||
- راجع الملفات التنفيذية غير الموثوقة واعزلها كما تفعل مع أي مدخل أصلي قد يكون ضارًا.
|
||||
- أبلغ عن الثغرات وفق [سياسة الأمان](SECURITY.md)، وليس عبر قضية عامة.
|
||||
|
||||
## الأسئلة الشائعة
|
||||
|
||||
<details>
|
||||
<summary><strong>هل يتضمن REA محرك فك ترجمة خاصًا به؟</strong></summary>
|
||||
|
||||
لا. يستطيع Setup تثبيت Hopper، لكنه يظل برنامجًا منفصلًا بترخيص خاص به. يوفر REA واجهة CLI وخادم MCP وسير العمل المخصص للوكلاء.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>هل يجب أن يكون Hopper مفتوحًا مسبقًا؟</strong></summary>
|
||||
|
||||
لا. يستطيع REA تشغيله. لكن Hopper قد يصبح مرئيًا أو يطلب إدخالًا عند الحاجة إلى قرار تفاعلي.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>هل يعمل REA على Linux أو Windows؟</strong></summary>
|
||||
|
||||
ليس حاليًا. يعتمد المحول الحالي على تطبيق Hopper لنظام macOS وواجهة Python الخاصة به.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>هل يمكنني تحليل قواعد Hopper المحفوظة؟</strong></summary>
|
||||
|
||||
نعم. يدعم وقت التشغيل الملفات التنفيذية وقواعد `.hop`.
|
||||
|
||||
</details>
|
||||
|
||||
## التطوير
|
||||
|
||||
راجع [CONTRIBUTING.md](CONTRIBUTING.md) لإعداد بيئة التطوير وبنية المشروع والاختبارات وإرشادات الإصدار.
|
||||
|
||||
## المشروع
|
||||
|
||||
- [حزمة npm](https://www.npmjs.com/package/@morluto/rea)
|
||||
- [GitHub](https://github.com/morluto/rea)
|
||||
- [القضايا والطلبات](https://github.com/morluto/rea/issues)
|
||||
- [Hopper Disassembler](https://www.hopperapp.com/)
|
||||
- [سياسة الأمان](SECURITY.md)
|
||||
|
||||
## الترخيص
|
||||
|
||||
[MIT](LICENSE)
|
||||
+235
@@ -0,0 +1,235 @@
|
||||
<div align="center">
|
||||
|
||||
[English](README.md) · [简体中文](README_zh.md) · **日本語** · [한국어](README_ko.md) · [العربية](README_ar.md)
|
||||
|
||||
# REA:あらゆるものをリバースエンジニアリング
|
||||
|
||||
### コーディングエージェントがあらゆるものをリバースエンジニアリングするための、ひとつの CLI / MCP サーバー
|
||||
|
||||
**気になる機能を見つけ、仕組みを理解し、自分の形で実装する。**
|
||||
|
||||
[](https://www.npmjs.com/package/@morluto/rea)
|
||||
[](https://github.com/morluto/rea/actions/workflows/ci.yml)
|
||||
[](#50-ツールのワークベンチ)
|
||||
[](https://nodejs.org/)
|
||||
[](LICENSE)
|
||||
|
||||
[クイックスタート](#クイックスタート) · [バイナリから動作へ](#バイナリから動作へ) · [50 ツール](#50-ツールのワークベンチ) · [仕組み](#仕組み) · [FAQ](#faq)
|
||||
|
||||
<br />
|
||||
|
||||
<code>npx skills add morluto/rea</code>
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
アプリの気になる機能を自分のプロダクトにも取り入れたいと思ったことはありませんか。ソースコードがなくても、そのアプリをコーディングエージェントに渡せます。REA を使えば、エージェントが機能を調査して仕組みを理解し、あなたの技術スタック、デザイン、要件に合わせた形で実装できます。
|
||||
|
||||
REA はこの流れをひとつの CLI / MCP サーバーで実現します。エージェントはコンパイル済みのアプリを調べ、機能の流れを追い、分かったことを通常のコーディング作業に活用できます。複雑なリバースエンジニアリングツールは REA がひとつのインターフェースの裏側で扱います。
|
||||
|
||||
## エージェントに頼むだけ
|
||||
|
||||
```bash
|
||||
npx skills add morluto/rea
|
||||
```
|
||||
|
||||
次のように依頼します:
|
||||
|
||||
```text
|
||||
REA をセットアップしてメモアプリをリバースエンジニアリングしてください。
|
||||
検索の仕組みと判断の理由を示し、私のプロジェクト向けに同様の機能を実装してください。
|
||||
```
|
||||
|
||||
メモアプリは例にすぎません。調べたいアプリを指定するか、まず概要から始めるよう依頼できます。
|
||||
|
||||
## バイナリから動作へ
|
||||
|
||||
| 逆コンパイル | 理解 | 再現 |
|
||||
| -------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
|
||||
| ネイティブアプリや実行ファイルから、プロシージャ、疑似コード、アセンブリ、文字列、シンボル、セグメント、メタデータを復元します。 | 呼び出し元、呼び出し先、クロスリファレンス、コールグラフをたどり、機能やアルゴリズムの実際の動作を説明できる状態にします。 | エージェントが得た知見を、あなたの技術スタック、画面、要件に合うプロダクト機能へ変えます。 |
|
||||
|
||||
REA は調査をバイナリ上の根拠に結び付けます。元のソースコードを復元したり、アプリ全体を自動複製したりするとは主張しません。
|
||||
|
||||
## REA を選ぶ理由
|
||||
|
||||
| | |
|
||||
| ---------------------- | ------------------------------------------------------------------------------------- |
|
||||
| **エージェント向け** | コンパイル済みアプリについて質問し、推測ではなく根拠を集めさせることができます。 |
|
||||
| **CLI と MCP** | ターミナルとコーディングエージェントから同じリバースエンジニアリング機能を使えます。 |
|
||||
| **複雑さを処理** | ツール設定、アプリの読み込み、調査の維持、終了後のクリーンアップを REA が担います。 |
|
||||
| **一連の調査に対応** | 最初の概要から疑似コード、呼び出し関係、型、実装の手掛かりまで掘り下げられます。 |
|
||||
| **ローカルで解析** | 解析は Mac 上で実行され、REA がバイナリをホスト型解析サービスへ送ることはありません。 |
|
||||
| **コンテキストを維持** | 質問ごとに解析を最初からやり直さず、複数のバイナリを続けて調査できます。 |
|
||||
|
||||
## クイックスタート
|
||||
|
||||
### コーディングエージェントから(推奨)
|
||||
|
||||
```bash
|
||||
npx skills add morluto/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
|
||||
REA でメモアプリをリバースエンジニアリングしてください。検索機能の仕組みと
|
||||
根拠を示し、SQLite を使って私のプロジェクト向けに同様の機能を実装してください。
|
||||
```
|
||||
|
||||
問題がある場合は次を実行します:
|
||||
|
||||
```bash
|
||||
npx -y @morluto/rea doctor
|
||||
```
|
||||
|
||||
## ひとつのプロンプトで調査を完結
|
||||
|
||||
```text
|
||||
メモアプリをリバースエンジニアリングし、オフライン検索機能の仕組みを説明して、
|
||||
TypeScript と SQLite を使って私のプロジェクト向けに実装してください。
|
||||
```
|
||||
|
||||
| 手順 | エージェントの処理 | REA ツール |
|
||||
| ---: | ------------------------------ | ---------------------------------------------------------------- |
|
||||
| 1 | バイナリを開いて識別 | `open_binary`, `binary_overview` |
|
||||
| 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` |
|
||||
| 6 | プロジェクトに機能を実装する | 技術スタック、プロダクト、要件に合わせたコード |
|
||||
|
||||
REA は手順 1〜5 のバイナリ解析を処理し、手順 6 はエージェントの通常の編集・テストツールが行います。
|
||||
|
||||
## エージェントにできること
|
||||
|
||||
- ソースコードがない機能の仕組みを説明する。
|
||||
- アプリの認証、保存、更新、ネットワークフローを復元する。
|
||||
- 非公開の形式やインターフェースを文書化できる構造を回収する。
|
||||
- 文字列やシンボルから疑わしい動作の実装コードまで追跡する。
|
||||
- 同じセッションで 2 バージョンを切り替え、実装経路を比較する。
|
||||
- 気になる機能を調査し、自分のプロダクトに合わせた形で実装する。
|
||||
- 復元した動作をプロダクト機能、テスト、移行ノート、移植、相互運用できる代替実装へ変換する。
|
||||
- Swift / Objective-C のメタデータを解析する。
|
||||
- Hopper に名前、コメント、ブックマークを残し、人間とエージェントの調査を共有する。
|
||||
|
||||
## 50 ツールのワークベンチ
|
||||
|
||||
| ツール群 | 数 | 例 |
|
||||
| ------------------ | --: | ------------------------------------------------------------------------------------------------------------------- |
|
||||
| バイナリ調査 | 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`、証拠バンドル、プロセス取得と比較 |
|
||||
|
||||
## 他のコーディングエージェントで使う
|
||||
|
||||
Setup は現在 Claude Desktop と Cursor を自動設定します。ローカル MCP サーバーに対応するコーディングエージェントなら、次の設定で REA を利用できます。
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"rea": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@morluto/rea", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 仕組み
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Agent["コーディングエージェント"] --> REA["REA<br/>CLI + MCP"]
|
||||
Terminal["ターミナル"] --> REA
|
||||
REA --> Hopper["解析エンジン"]
|
||||
Hopper --> App["あなたのアプリ"]
|
||||
```
|
||||
|
||||
CLI と MCP サーバーは同じ解析エンジンを使います。ターミナルのコマンドは終了時にアプリを閉じ、エージェントのセッションは調査中アプリを開いたままにします。
|
||||
|
||||
## CLI
|
||||
|
||||
上のエージェントワークフローが、REA を使う最も簡単な方法です。ターミナルから一度だけアプリの概要を調べる場合は、次を実行します。
|
||||
|
||||
```bash
|
||||
npx -y @morluto/rea analyze /Applications/Notes.app
|
||||
```
|
||||
|
||||
直接デコンパイルする方法やその他のオプションは、`npx -y @morluto/rea --help` で確認できます。
|
||||
|
||||
グローバルな `rea` コマンドとしてもインストールできます。
|
||||
|
||||
```bash
|
||||
npm install --global @morluto/rea
|
||||
rea --help
|
||||
rea mcp
|
||||
```
|
||||
|
||||
REA は Mac の `.app` フォルダーを直接開けます。エージェントがアプリを見つけられない場合は、インストール場所を伝えてください。
|
||||
|
||||
## Hopper アプリの動作
|
||||
|
||||
REA は必要なときに Hopper を起動します。Hopper のランチャーは内部でアプリをアクティブ化するため、ターゲットを開くと Hopper が前面に出る場合があります。REA はバックグラウンド起動を要求しますが、常に背面に留まる保証はありません。
|
||||
|
||||
明示的な形式・アーキテクチャ引数により一般的な FAT / ARM 選択ダイアログを避けますが、別の Hopper / macOS ダイアログは人の操作を必要とする場合があります。セッションを閉じるとブリッジとソケットを削除しますが、ユーザーが利用中の Hopper は終了しません。
|
||||
|
||||
## セキュリティモデル
|
||||
|
||||
各セッションはランダムな capability token と現在のユーザーだけが使える Unix ソケットを使用します。これはサンドボックスではなく、同じユーザー権限で動作する悪意あるプロセスを防御しません。信頼できないバイナリの解析は、現在の macOS ユーザー権限で Hopper に委譲されます。脆弱性は [SECURITY.md](SECURITY.md) の非公開手順で報告してください。
|
||||
|
||||
## FAQ
|
||||
|
||||
<details><summary><strong>Hopper を先に起動する必要がありますか?</strong></summary>
|
||||
|
||||
いいえ。REA が必要時に起動します。すでに起動している Hopper にも対応します。
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary><strong>REA に Hopper は含まれますか?</strong></summary>
|
||||
|
||||
含まれません。Setup で Hopper をインストールできますが、Hopper は独自のライセンスを持つ別製品です。REA は CLI、MCP サーバー、エージェント向けワークフローを提供します。
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary><strong>バイナリはアップロードされますか?</strong></summary>
|
||||
|
||||
REA にホスト型解析サービスはありません。ローカル Unix ソケット経由で Hopper を操作します。エージェントやモデル提供者のデータポリシーは別途確認してください。
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary><strong>元のソースコードを復元できますか?</strong></summary>
|
||||
|
||||
保証できません。REA は疑似コード、アセンブリ、シンボル、文字列、メタデータ、関係を提供し、エージェントが観察した動作を説明または互換再現できるようにします。
|
||||
|
||||
</details>
|
||||
|
||||
## 開発
|
||||
|
||||
開発環境、アーキテクチャ、テスト、リリース手順は [CONTRIBUTING.md](CONTRIBUTING.md) を参照してください。
|
||||
|
||||
## ライセンス
|
||||
|
||||
[MIT](LICENSE)
|
||||
+235
@@ -0,0 +1,235 @@
|
||||
<div align="center">
|
||||
|
||||
[English](README.md) · [简体中文](README_zh.md) · [日本語](README_ja.md) · **한국어** · [العربية](README_ar.md)
|
||||
|
||||
# REA: 무엇이든 리버스 엔지니어링
|
||||
|
||||
### 코딩 에이전트가 무엇이든 리버스 엔지니어링할 수 있게 하는 하나의 CLI 및 MCP 서버
|
||||
|
||||
**마음에 드는 기능을 찾고, 작동 방식을 이해하고, 원하는 방식으로 구현하세요.**
|
||||
|
||||
[](https://www.npmjs.com/package/@morluto/rea)
|
||||
[](https://github.com/morluto/rea/actions/workflows/ci.yml)
|
||||
[](#50개-도구로-구성된-워크벤치)
|
||||
[](https://nodejs.org/)
|
||||
[](LICENSE)
|
||||
|
||||
[빠른 시작](#빠른-시작) · [바이너리에서 동작까지](#바이너리에서-동작까지) · [50개 도구](#50개-도구로-구성된-워크벤치) · [작동 방식](#작동-방식) · [FAQ](#faq)
|
||||
|
||||
<br />
|
||||
|
||||
<code>npx skills add morluto/rea</code>
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
어떤 앱에서 마음에 드는 기능을 자신의 제품에도 넣고 싶나요? 소스 코드가 없어도 앱을 코딩 에이전트에 전달할 수 있습니다. REA를 사용하면 에이전트가 기능을 조사하고 작동 방식을 이해한 뒤, 사용자의 기술 스택, 디자인, 요구 사항에 맞는 버전을 구현할 수 있습니다.
|
||||
|
||||
REA는 이 과정을 하나의 CLI 및 MCP 서버로 제공합니다. 에이전트는 컴파일된 앱을 조사하고 기능의 작동 방식을 추적한 뒤 알아낸 내용을 일반 코딩 작업에 활용할 수 있습니다. 복잡한 리버스 엔지니어링 도구는 REA가 하나의 인터페이스 뒤에서 처리합니다.
|
||||
|
||||
## 에이전트에게 바로 요청하기
|
||||
|
||||
```bash
|
||||
npx skills add morluto/rea
|
||||
```
|
||||
|
||||
그런 다음 요청하세요.
|
||||
|
||||
```text
|
||||
REA를 설정하고 메모 앱을 리버스 엔지니어링해 주세요. 검색 기능의 작동 방식과
|
||||
그렇게 판단한 이유를 보여 주고, 제 프로젝트에 비슷한 기능을 구현해 주세요.
|
||||
```
|
||||
|
||||
메모 앱은 예시일 뿐입니다. 이해하고 싶은 앱을 지정하거나 먼저 개요부터 살펴보라고 요청할 수 있습니다.
|
||||
|
||||
## 바이너리에서 동작까지
|
||||
|
||||
| 디컴파일 | 이해 | 재현 |
|
||||
| ------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
|
||||
| 네이티브 앱이나 실행 파일에서 프로시저, 의사 코드, 어셈블리, 문자열, 심볼, 세그먼트, 메타데이터를 복구합니다. | 호출자, 피호출자, 상호 참조, 호출 그래프를 따라 기능이나 알고리즘의 실제 동작을 설명합니다. | 에이전트가 배운 내용을 사용자의 기술 스택, 화면, 요구 사항에 맞는 제품 기능으로 만듭니다. |
|
||||
|
||||
REA는 분석을 바이너리 증거에 근거하게 합니다. 원본 소스 코드를 복원하거나 앱 전체를 자동으로 복제한다고 주장하지 않습니다.
|
||||
|
||||
## REA를 사용하는 이유
|
||||
|
||||
| | |
|
||||
| ------------------ | ---------------------------------------------------------------------------------- |
|
||||
| **에이전트용** | 컴파일된 앱에 관해 질문하고 추측 대신 증거를 수집하게 합니다. |
|
||||
| **CLI와 MCP** | 터미널과 코딩 에이전트에서 동일한 리버스 엔지니어링 기능을 사용합니다. |
|
||||
| **복잡성 처리** | 도구 설정, 앱 열기, 조사 유지, 작업 후 정리를 REA가 처리합니다. |
|
||||
| **전체 조사 과정** | 첫 개요에서 의사 코드, 호출 관계, 타입, 구현 단서까지 이어서 조사합니다. |
|
||||
| **로컬 분석** | 분석은 Mac에서 실행되며 REA는 바이너리를 호스팅 분석 서비스에 업로드하지 않습니다. |
|
||||
| **컨텍스트 유지** | 질문마다 분석을 처음부터 시작하지 않고 여러 바이너리를 연속으로 조사합니다. |
|
||||
|
||||
## 빠른 시작
|
||||
|
||||
### 코딩 에이전트에서 시작하기(권장)
|
||||
|
||||
```bash
|
||||
npx skills add morluto/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
|
||||
REA로 메모 앱을 리버스 엔지니어링해 주세요. 검색 기능의 작동 방식과 증거를 보여 주고,
|
||||
SQLite를 사용해 제 프로젝트에 비슷한 기능을 구현해 주세요.
|
||||
```
|
||||
|
||||
문제가 있으면 다음을 실행하세요.
|
||||
|
||||
```bash
|
||||
npx -y @morluto/rea doctor
|
||||
```
|
||||
|
||||
## 하나의 프롬프트로 전체 조사
|
||||
|
||||
```text
|
||||
메모 앱을 리버스 엔지니어링해 오프라인 검색 기능의 작동 방식을 설명한 다음,
|
||||
TypeScript와 SQLite를 사용해 제 프로젝트에 맞는 버전을 구현해 주세요.
|
||||
```
|
||||
|
||||
| 단계 | 에이전트 작업 | REA 도구 |
|
||||
| ---: | ----------------------- | ---------------------------------------------------------------- |
|
||||
| 1 | 바이너리 열기 및 식별 | `open_binary`, `binary_overview` |
|
||||
| 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` |
|
||||
| 6 | 프로젝트에 기능 구현 | 기술 스택, 제품, 요구 사항에 맞는 코드 |
|
||||
|
||||
REA는 1–5단계의 바이너리 분석을 처리합니다. 6단계는 에이전트가 일반 파일 편집 및 테스트 도구로 수행합니다.
|
||||
|
||||
## 에이전트가 할 수 있는 일
|
||||
|
||||
- 소스 코드가 없는 기능의 동작을 설명합니다.
|
||||
- 앱의 인증, 저장소, 업데이트 또는 네트워크 흐름을 재구성합니다.
|
||||
- 문서화되지 않은 형식이나 인터페이스의 구조를 복구합니다.
|
||||
- 문자열이나 심볼에서 의심스러운 동작을 구현한 코드까지 추적합니다.
|
||||
- 한 세션에서 두 앱 버전을 전환하며 구현 경로를 비교합니다.
|
||||
- 마음에 드는 기능을 조사하고 자신의 제품에 맞는 버전으로 구현합니다.
|
||||
- 복구한 동작을 제품 기능, 테스트, 마이그레이션 문서, 포팅 또는 상호 운용 가능한 대체품으로 변환합니다.
|
||||
- Swift 및 Objective-C 메타데이터를 분석합니다.
|
||||
- Hopper에 이름, 주석, 북마크를 남겨 사람과 에이전트의 분석을 연결합니다.
|
||||
|
||||
## 50개 도구로 구성된 워크벤치
|
||||
|
||||
| 도구 그룹 | 수 | 예시 |
|
||||
| ------------- | --: | -------------------------------------------------------------------------------------------------------------------- |
|
||||
| 바이너리 검사 | 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`, 증거 번들, 프로세스 캡처 및 비교 |
|
||||
|
||||
## 다른 코딩 에이전트에서 사용하기
|
||||
|
||||
Setup은 현재 Claude Desktop과 Cursor를 자동으로 구성합니다. 로컬 MCP 서버를 지원하는 코딩 에이전트라면 다음 설정으로 REA를 사용할 수 있습니다.
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"rea": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@morluto/rea", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 작동 방식
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Agent["코딩 에이전트"] --> REA["REA<br/>CLI + MCP"]
|
||||
Terminal["터미널"] --> REA
|
||||
REA --> Hopper["분석 엔진"]
|
||||
Hopper --> App["사용자의 앱"]
|
||||
```
|
||||
|
||||
CLI와 MCP 서버는 같은 분석 엔진을 사용합니다. 터미널 명령은 끝날 때 앱을 닫고, 에이전트 세션은 조사하는 동안 앱을 열어 둡니다.
|
||||
|
||||
## CLI
|
||||
|
||||
위의 에이전트 워크플로가 REA를 사용하는 가장 쉬운 방법입니다. 터미널에서 앱을 한 번 빠르게 살펴보려면 다음을 실행하세요.
|
||||
|
||||
```bash
|
||||
npx -y @morluto/rea analyze /Applications/Notes.app
|
||||
```
|
||||
|
||||
직접 디컴파일하는 방법과 기타 옵션은 `npx -y @morluto/rea --help`에서 확인할 수 있습니다.
|
||||
|
||||
전역 `rea` 명령으로 설치할 수도 있습니다.
|
||||
|
||||
```bash
|
||||
npm install --global @morluto/rea
|
||||
rea --help
|
||||
rea mcp
|
||||
```
|
||||
|
||||
REA는 Mac의 `.app` 폴더를 직접 열 수 있습니다. 에이전트가 앱을 찾지 못하면 설치 위치를 알려 주세요.
|
||||
|
||||
## Hopper 앱 동작
|
||||
|
||||
REA는 필요할 때 Hopper를 시작합니다. Hopper 런처는 내부적으로 앱을 활성화하므로 대상을 열 때 Hopper가 다른 창 앞으로 나타날 수 있습니다. REA는 백그라운드 시작을 요청하지만 항상 뒤에 머무르는 것을 보장할 수 없습니다.
|
||||
|
||||
명시적인 형식과 아키텍처 인자로 일반적인 FAT/ARM 선택 대화상자를 피하지만, 다른 Hopper 또는 macOS 대화상자에는 사람이 응답해야 할 수 있습니다. 세션 종료 시 브리지와 소켓 디렉터리를 제거하지만 사용자가 작업 중인 Hopper 앱은 종료하지 않습니다.
|
||||
|
||||
## 보안 모델
|
||||
|
||||
각 세션은 무작위 capability token과 현재 사용자 전용 Unix 소켓을 사용합니다. 이는 샌드박스가 아니며 같은 사용자로 실행되는 악성 프로세스를 방어하지 않습니다. 신뢰할 수 없는 바이너리를 열면 현재 macOS 사용자 권한으로 Hopper가 분석합니다. 취약점은 [SECURITY.md](SECURITY.md)의 비공개 절차로 신고하세요.
|
||||
|
||||
## FAQ
|
||||
|
||||
<details><summary><strong>Hopper를 미리 실행해야 하나요?</strong></summary>
|
||||
|
||||
아니요. REA가 필요할 때 시작하며 이미 실행 중인 Hopper도 지원합니다.
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary><strong>REA에 Hopper가 포함되나요?</strong></summary>
|
||||
|
||||
아니요. Setup이 Hopper를 설치할 수 있지만 Hopper는 자체 라이선스가 필요한 별도 소프트웨어입니다. REA는 CLI, MCP 서버, 에이전트용 워크플로를 제공합니다.
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary><strong>바이너리가 업로드되나요?</strong></summary>
|
||||
|
||||
REA에는 호스팅 분석 서비스가 없습니다. 로컬 Unix 소켓을 통해 Hopper를 조작합니다. 에이전트나 모델 공급자의 데이터 정책은 별도로 확인하세요.
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary><strong>원본 소스 코드를 복구할 수 있나요?</strong></summary>
|
||||
|
||||
보장할 수 없습니다. REA는 의사 코드, 어셈블리, 심볼, 문자열, 메타데이터 및 관계를 제공하여 에이전트가 관찰된 동작을 설명하거나 호환되게 재현하도록 돕습니다.
|
||||
|
||||
</details>
|
||||
|
||||
## 개발
|
||||
|
||||
개발 환경, 아키텍처, 테스트, 릴리스 지침은 [CONTRIBUTING.md](CONTRIBUTING.md)를 참고하세요.
|
||||
|
||||
## 라이선스
|
||||
|
||||
[MIT](LICENSE)
|
||||
+237
@@ -0,0 +1,237 @@
|
||||
<div align="center">
|
||||
|
||||
[English](README.md) · **简体中文** · [日本語](README_ja.md) · [한국어](README_ko.md) · [العربية](README_ar.md)
|
||||
|
||||
# REA:逆向工程一切
|
||||
|
||||
### 一个 CLI 与 MCP 服务器,让编程智能体逆向工程任何程序
|
||||
|
||||
**看到喜欢的功能。理解它的原理。按照你的方式实现。**
|
||||
|
||||
[](https://www.npmjs.com/package/@morluto/rea)
|
||||
[](https://github.com/morluto/rea/actions/workflows/ci.yml)
|
||||
[](#50-个工具组成的工作台)
|
||||
[](https://nodejs.org/)
|
||||
[](LICENSE)
|
||||
|
||||
[快速开始](#快速开始) · [从二进制到行为](#从二进制到行为) · [50 个工具](#50-个工具组成的工作台) · [工作原理](#工作原理) · [常见问题](#常见问题)
|
||||
|
||||
<br />
|
||||
|
||||
<code>npx skills add morluto/rea</code>
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
看到某个应用中想加入自己产品的功能?即使没有源代码,也可以把应用交给编程智能体。借助 REA,智能体能够调查该功能、理解其工作原理,并按照你的技术栈、设计和需求构建适合你产品的版本。
|
||||
|
||||
REA 通过一个 CLI 与 MCP 服务器实现这套流程。智能体可以检查编译后的应用、追踪功能的工作方式,并把了解到的内容用于日常编码。REA 在一个接口背后处理复杂的逆向工程工具。
|
||||
|
||||
## 直接询问智能体
|
||||
|
||||
```bash
|
||||
npx skills add morluto/rea
|
||||
```
|
||||
|
||||
然后说:
|
||||
|
||||
```text
|
||||
设置 REA 并逆向工程“备忘录”应用。解释搜索功能的工作方式,
|
||||
说明你是如何得出结论的,然后为我的项目构建类似功能。
|
||||
```
|
||||
|
||||
“备忘录”只是示例。你可以说出任何想了解的应用,也可以让智能体先从概览开始。
|
||||
|
||||
## 从二进制到行为
|
||||
|
||||
| 反编译 | 理解 | 重建 |
|
||||
| ---------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | ------------------------------------------------------------ |
|
||||
| 打开原生应用或可执行文件,恢复过程、伪代码、汇编、字符串、符号、段和元数据。 | 沿调用者、被调用者、交叉引用和调用图追踪,直到智能体能够解释功能或算法的实际工作方式。 | 将智能体学到的内容变成适合你的技术栈、界面和需求的产品功能。 |
|
||||
|
||||
REA 让调查始终以二进制证据为依据。它不会声称能恢复原始源代码,也不会自动克隆整个应用。
|
||||
|
||||
## 为什么选择 REA
|
||||
|
||||
| | |
|
||||
| ---------------- | -------------------------------------------------------------- |
|
||||
| **为智能体设计** | 直接询问编译后应用的行为,让智能体搜集证据,而不是猜测。 |
|
||||
| **CLI 与 MCP** | 在终端或编程智能体中使用同一套逆向工程能力。 |
|
||||
| **处理复杂流程** | REA 负责工具设置、打开应用、维持调查过程,并在完成后清理资源。 |
|
||||
| **完整工作流** | 从初步概览推进到伪代码、调用关系、类型和实现线索。 |
|
||||
| **本地运行** | 分析在你的 Mac 上运行;REA 不会把二进制上传到托管式分析服务。 |
|
||||
| **保留上下文** | 连续调查多个二进制文件,无需为每个问题重新开始整个分析过程。 |
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 使用编程智能体(推荐)
|
||||
|
||||
```bash
|
||||
npx skills add morluto/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
|
||||
使用 REA 逆向工程“备忘录”应用。解释搜索功能的工作方式,展示证据,
|
||||
然后使用 SQLite 为我的项目构建一个类似功能。
|
||||
```
|
||||
|
||||
如果遇到问题,请运行:
|
||||
|
||||
```bash
|
||||
npx -y @morluto/rea doctor
|
||||
```
|
||||
|
||||
## 一个提示词,完成一次完整调查
|
||||
|
||||
```text
|
||||
逆向工程“备忘录”应用,找到离线搜索功能的工作方式,解释其控制流,
|
||||
并使用 TypeScript 和 SQLite 为我的项目构建一个版本。
|
||||
```
|
||||
|
||||
| 步骤 | 智能体的操作 | REA 工具 |
|
||||
| ---: | ---------------------- | ---------------------------------------------------------------- |
|
||||
| 1 | 打开并识别二进制文件 | `open_binary`, `binary_overview` |
|
||||
| 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` |
|
||||
| 6 | 在你的项目中构建该功能 | 适合你的技术栈、产品和需求的代码 |
|
||||
|
||||
REA 负责第 1–5 步中的二进制分析。第 6 步由智能体使用其常规文件编辑与测试工具完成。
|
||||
|
||||
## 智能体可以完成什么
|
||||
|
||||
- 在没有源代码时解释某项功能的实现方式。
|
||||
- 重建应用的身份验证、存储、更新或网络流程。
|
||||
- 恢复足够的结构,以记录未公开的格式或接口。
|
||||
- 从字符串或符号追踪到实现可疑行为的代码。
|
||||
- 在一个会话中切换两个应用版本并比较实现路径。
|
||||
- 调查你喜欢的功能,并为自己的产品构建量身定制的版本。
|
||||
- 将恢复的行为转换为产品功能、测试、迁移说明、移植代码或互操作替代品。
|
||||
- 分析 Swift 和 Objective-C 元数据。
|
||||
- 在 Hopper 中留下名称、注释与书签,使人与智能体的分析互相增强。
|
||||
|
||||
## 50 个工具组成的工作台
|
||||
|
||||
| 工具类别 | 数量 | 示例 |
|
||||
| ---------- | ---: | -------------------------------------------------------------------------------------------------------------------- |
|
||||
| 二进制检查 | 33 | 过程、伪代码、汇编、字符串、名称、段、调用者、被调用者、交叉引用、注释 |
|
||||
| 组合分析 | 10 | `binary_overview`, `analyze_function`, `batch_decompile`, `get_call_graph`, `find_xrefs_to_name`、Swift 与 ObjC 发现 |
|
||||
| 二进制会话 | 7 | `open_binary`、`binary_session`、证据包、进程捕获与比较 |
|
||||
|
||||
## 与其他编程智能体一起使用
|
||||
|
||||
Setup 目前会自动配置 Claude Desktop 和 Cursor。任何支持本地 MCP 服务器的编程智能体都可以使用以下配置连接 REA。
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"rea": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@morluto/rea", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 工作原理
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Agent["编程智能体"] --> REA["REA<br/>CLI + MCP"]
|
||||
Terminal["终端"] --> REA
|
||||
REA --> Hopper["分析引擎"]
|
||||
Hopper --> App["你的应用"]
|
||||
```
|
||||
|
||||
CLI 与 MCP 服务器使用相同的分析引擎。终端命令完成后会关闭应用;智能体会话则会在调查期间保持应用打开。
|
||||
|
||||
## CLI
|
||||
|
||||
上面的智能体工作流是使用 REA 最简单的方式。如果只想在终端中快速了解一个应用:
|
||||
|
||||
```bash
|
||||
npx -y @morluto/rea analyze /Applications/Notes.app
|
||||
```
|
||||
|
||||
运行 `npx -y @morluto/rea --help` 查看直接反编译和其他选项。
|
||||
|
||||
也可以全局安装 `rea` 命令:
|
||||
|
||||
```bash
|
||||
npm install --global @morluto/rea
|
||||
rea --help
|
||||
rea mcp
|
||||
```
|
||||
|
||||
REA 可以直接打开 Mac 的 `.app` 文件夹。如果智能体找不到应用,请告诉它应用安装在哪里。
|
||||
|
||||
## Hopper 应用行为
|
||||
|
||||
REA 会在需要时启动 Hopper,无需预先运行。Hopper 启动器内部会激活应用,因此打开目标时 Hopper 可能出现在其他窗口前。REA 会请求 macOS 在后台启动 Hopper,但无法保证窗口始终位于后台。
|
||||
|
||||
REA 会推导明确的格式和架构参数,以避免常见的 FAT 与 ARM 选择对话框。其他 Hopper 或 macOS 对话框仍可能需要人工响应。关闭 REA 会话会终止桥并删除私有套接字目录,但不会退出用户正在使用的 Hopper 应用。
|
||||
|
||||
## 安全模型
|
||||
|
||||
每个桥会话都使用随机能力令牌和仅限当前用户的 Unix 套接字。协议消息有大小限制,面向调用者的错误不会暴露启动器 stderr 或内部异常原因。
|
||||
|
||||
这不是沙箱,也无法防御以同一 macOS 用户身份运行的恶意进程。打开不可信二进制文件会让 Hopper 以当前用户权限进行解析和分析。请按照 [SECURITY.md](SECURITY.md) 中的私密流程报告漏洞。
|
||||
|
||||
## 常见问题
|
||||
|
||||
<details><summary><strong>Hopper 是否需要提前运行?</strong></summary>
|
||||
|
||||
不需要。REA 会在操作需要时启动 Hopper,也支持已经运行的 Hopper。
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary><strong>REA 是否包含 Hopper?</strong></summary>
|
||||
|
||||
不包含。Setup 可以为你安装 Hopper,但 Hopper 仍是需要单独授权的软件。REA 提供 CLI、MCP 服务器和面向智能体的工作流。
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary><strong>REA 会上传我的二进制文件吗?</strong></summary>
|
||||
|
||||
REA 不提供托管分析服务,而是通过本地 Unix 套接字把操作交给 Hopper。你的智能体或模型服务商可能有自己的数据政策,请单独核查。
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary><strong>REA 能恢复原始源代码吗?</strong></summary>
|
||||
|
||||
不能保证。REA 提供伪代码、汇编、符号、字符串、元数据和关系,智能体可据此解释或兼容地重建观察到的行为。
|
||||
|
||||
</details>
|
||||
|
||||
## 开发
|
||||
|
||||
开发环境、架构、测试和发布说明请参阅 [CONTRIBUTING.md](CONTRIBUTING.md)。
|
||||
|
||||
## 许可证
|
||||
|
||||
[MIT](LICENSE)
|
||||
+275
-5
@@ -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)
|
||||
|
||||
@@ -15,15 +15,15 @@
|
||||
"ignore": ["dist/**", "docs/**", ".codex/**", ".devin/**"],
|
||||
"ignoreDependencies": [],
|
||||
"rules": {
|
||||
"files": "warn",
|
||||
"dependencies": "warn",
|
||||
"devDependencies": "warn",
|
||||
"files": "error",
|
||||
"dependencies": "error",
|
||||
"devDependencies": "error",
|
||||
"unlisted": "error",
|
||||
"exports": "warn",
|
||||
"nsExports": "warn",
|
||||
"types": "warn",
|
||||
"nsTypes": "warn",
|
||||
"enumMembers": "warn",
|
||||
"exports": "error",
|
||||
"nsExports": "error",
|
||||
"types": "error",
|
||||
"nsTypes": "error",
|
||||
"enumMembers": "error",
|
||||
"duplicates": "error"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+641
-10
@@ -1,16 +1,20 @@
|
||||
{
|
||||
"name": "@morluto/rea",
|
||||
"version": "0.2.0",
|
||||
"version": "0.3.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@morluto/rea",
|
||||
"version": "0.2.0",
|
||||
"version": "0.3.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/server": "2.0.0-beta.3",
|
||||
"canonicalize": "3.0.0",
|
||||
"incur": "0.4.13",
|
||||
"node-pty": "1.1.0",
|
||||
"pino": "9.14.0",
|
||||
"ws": "8.21.0",
|
||||
"zod": "4.4.3"
|
||||
},
|
||||
"bin": {
|
||||
@@ -19,10 +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",
|
||||
"oxlint": "1.73.0",
|
||||
"prettier": "3.6.2",
|
||||
"typedoc": "0.28.20",
|
||||
@@ -30,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": {
|
||||
@@ -100,24 +107,26 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@emnapi/core": {
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
|
||||
"integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
|
||||
"version": "1.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.2.tgz",
|
||||
"integrity": "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@emnapi/wasi-threads": "1.2.2",
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/runtime": {
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
|
||||
"integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
|
||||
"version": "1.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz",
|
||||
"integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
@@ -515,6 +524,29 @@
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-parser/binding-wasm32-wasi/node_modules/@emnapi/core": {
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
|
||||
"integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/wasi-threads": "1.2.2",
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-parser/binding-wasm32-wasi/node_modules/@emnapi/runtime": {
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
|
||||
"integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-parser/binding-win32-arm64-msvc": {
|
||||
"version": "0.137.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.137.0.tgz",
|
||||
@@ -1193,6 +1225,12 @@
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@pinojs/redact": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
|
||||
"integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm64": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz",
|
||||
@@ -1416,6 +1454,29 @@
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": {
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
|
||||
"integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/wasi-threads": "1.2.2",
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": {
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
|
||||
"integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz",
|
||||
@@ -1591,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",
|
||||
@@ -1735,6 +1806,48 @@
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-escapes": {
|
||||
"version": "7.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz",
|
||||
"integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"environment": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-regex": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
|
||||
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-styles": {
|
||||
"version": "6.2.3",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
|
||||
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/argparse": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
||||
@@ -1764,6 +1877,27 @@
|
||||
"js-tokens": "^10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/atomic-sleep": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
|
||||
"integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"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",
|
||||
@@ -1774,6 +1908,56 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/cli-cursor": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz",
|
||||
"integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"restore-cursor": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/cli-truncate": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.2.0.tgz",
|
||||
"integrity": "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"slice-ansi": "^8.0.0",
|
||||
"string-width": "^8.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/colorette": {
|
||||
"version": "2.0.20",
|
||||
"resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz",
|
||||
"integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/commander": {
|
||||
"version": "14.0.3",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz",
|
||||
"integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/convert-source-map": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||
@@ -1806,6 +1990,13 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/emoji-regex": {
|
||||
"version": "10.6.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz",
|
||||
"integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/entities": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
|
||||
@@ -1819,6 +2010,19 @@
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/environment": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz",
|
||||
"integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/es-module-lexer": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.0.tgz",
|
||||
@@ -1836,6 +2040,13 @@
|
||||
"@types/estree": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/eventemitter3": {
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
|
||||
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/eventsource": {
|
||||
"version": "3.0.7",
|
||||
"resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz",
|
||||
@@ -1928,6 +2139,19 @@
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/get-east-asian-width": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz",
|
||||
"integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/get-tsconfig": {
|
||||
"version": "4.14.0",
|
||||
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz",
|
||||
@@ -1996,6 +2220,22 @@
|
||||
"node": ">=22"
|
||||
}
|
||||
},
|
||||
"node_modules/is-fullwidth-code-point": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz",
|
||||
"integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"get-east-asian-width": "^1.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/isexe": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||
@@ -2494,6 +2734,85 @@
|
||||
"uc.micro": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lint-staged": {
|
||||
"version": "16.4.0",
|
||||
"resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-16.4.0.tgz",
|
||||
"integrity": "sha512-lBWt8hujh/Cjysw5GYVmZpFHXDCgZzhrOm8vbcUdobADZNOK/bRshr2kM3DfgrrtR1DQhfupW9gnIXOfiFi+bw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"commander": "^14.0.3",
|
||||
"listr2": "^9.0.5",
|
||||
"picomatch": "^4.0.3",
|
||||
"string-argv": "^0.3.2",
|
||||
"tinyexec": "^1.0.4",
|
||||
"yaml": "^2.8.2"
|
||||
},
|
||||
"bin": {
|
||||
"lint-staged": "bin/lint-staged.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.17"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/lint-staged"
|
||||
}
|
||||
},
|
||||
"node_modules/listr2": {
|
||||
"version": "9.0.5",
|
||||
"resolved": "https://registry.npmjs.org/listr2/-/listr2-9.0.5.tgz",
|
||||
"integrity": "sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cli-truncate": "^5.0.0",
|
||||
"colorette": "^2.0.20",
|
||||
"eventemitter3": "^5.0.1",
|
||||
"log-update": "^6.1.0",
|
||||
"rfdc": "^1.4.1",
|
||||
"wrap-ansi": "^9.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/log-update": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz",
|
||||
"integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-escapes": "^7.0.0",
|
||||
"cli-cursor": "^5.0.0",
|
||||
"slice-ansi": "^7.1.0",
|
||||
"strip-ansi": "^7.1.0",
|
||||
"wrap-ansi": "^9.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/log-update/node_modules/slice-ansi": {
|
||||
"version": "7.1.2",
|
||||
"resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz",
|
||||
"integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^6.2.1",
|
||||
"is-fullwidth-code-point": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/slice-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/lunr": {
|
||||
"version": "2.3.9",
|
||||
"resolved": "https://registry.npmjs.org/lunr/-/lunr-2.3.9.tgz",
|
||||
@@ -2574,6 +2893,19 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/mimic-function": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz",
|
||||
"integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.15",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
|
||||
@@ -2593,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",
|
||||
@@ -2607,6 +2955,31 @@
|
||||
"node": ">=12.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/on-exit-leak-free": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
|
||||
"integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/onetime": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz",
|
||||
"integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mimic-function": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/oxc-parser": {
|
||||
"version": "0.137.0",
|
||||
"resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.137.0.tgz",
|
||||
@@ -2772,6 +3145,43 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/pino": {
|
||||
"version": "9.14.0",
|
||||
"resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz",
|
||||
"integrity": "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@pinojs/redact": "^0.4.0",
|
||||
"atomic-sleep": "^1.0.0",
|
||||
"on-exit-leak-free": "^2.1.0",
|
||||
"pino-abstract-transport": "^2.0.0",
|
||||
"pino-std-serializers": "^7.0.0",
|
||||
"process-warning": "^5.0.0",
|
||||
"quick-format-unescaped": "^4.0.3",
|
||||
"real-require": "^0.2.0",
|
||||
"safe-stable-stringify": "^2.3.1",
|
||||
"sonic-boom": "^4.0.1",
|
||||
"thread-stream": "^3.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"pino": "bin.js"
|
||||
}
|
||||
},
|
||||
"node_modules/pino-abstract-transport": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz",
|
||||
"integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"split2": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pino-std-serializers": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz",
|
||||
"integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pkce-challenge": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz",
|
||||
@@ -2827,6 +3237,22 @@
|
||||
"url": "https://github.com/prettier/prettier?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/process-warning": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz",
|
||||
"integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/punycode.js": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz",
|
||||
@@ -2837,6 +3263,21 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/quick-format-unescaped": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
|
||||
"integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/real-require": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz",
|
||||
"integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/resolve-pkg-maps": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
|
||||
@@ -2847,6 +3288,30 @@
|
||||
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/restore-cursor": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz",
|
||||
"integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"onetime": "^7.0.0",
|
||||
"signal-exit": "^4.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/rfdc": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz",
|
||||
"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/rolldown": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz",
|
||||
@@ -2881,6 +3346,15 @@
|
||||
"@rolldown/binding-win32-x64-msvc": "1.1.5"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-stable-stringify": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz",
|
||||
"integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.8.5",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
|
||||
@@ -2924,6 +3398,36 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/signal-exit": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
|
||||
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/slice-ansi": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-8.0.0.tgz",
|
||||
"integrity": "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^6.2.3",
|
||||
"is-fullwidth-code-point": "^5.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/slice-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/smol-toml": {
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.7.0.tgz",
|
||||
@@ -2937,6 +3441,15 @@
|
||||
"url": "https://github.com/sponsors/cyyynthia"
|
||||
}
|
||||
},
|
||||
"node_modules/sonic-boom": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz",
|
||||
"integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"atomic-sleep": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
@@ -2947,6 +3460,15 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/split2": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
|
||||
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">= 10.x"
|
||||
}
|
||||
},
|
||||
"node_modules/stackback": {
|
||||
"version": "0.0.2",
|
||||
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
|
||||
@@ -2961,6 +3483,49 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/string-argv": {
|
||||
"version": "0.3.2",
|
||||
"resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz",
|
||||
"integrity": "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.6.19"
|
||||
}
|
||||
},
|
||||
"node_modules/string-width": {
|
||||
"version": "8.2.2",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz",
|
||||
"integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"get-east-asian-width": "^1.5.0",
|
||||
"strip-ansi": "^7.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-ansi": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
|
||||
"integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^6.2.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-json-comments": {
|
||||
"version": "5.0.3",
|
||||
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-5.0.3.tgz",
|
||||
@@ -2987,6 +3552,15 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/thread-stream": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.2.0.tgz",
|
||||
"integrity": "sha512-zLBvqpwr4Esa0kRjcrzGU6zL25lePWaCLMx0RQFrmteozIfeNdaMLpG5U7PeHzvlFkAWaRKA9/KVW4F60iB+qw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"real-require": "^0.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tinybench": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||
@@ -3357,6 +3931,63 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/wrap-ansi": {
|
||||
"version": "9.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz",
|
||||
"integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^6.2.1",
|
||||
"string-width": "^7.0.0",
|
||||
"strip-ansi": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/wrap-ansi/node_modules/string-width": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
|
||||
"integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^10.3.0",
|
||||
"get-east-asian-width": "^1.0.0",
|
||||
"strip-ansi": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"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",
|
||||
|
||||
+35
-7
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@morluto/rea",
|
||||
"version": "0.2.0",
|
||||
"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"
|
||||
@@ -44,38 +54,56 @@
|
||||
"lint": "oxlint",
|
||||
"lint:fix": "oxlint --fix",
|
||||
"knip": "knip",
|
||||
"lint:dead": "knip",
|
||||
"jscpd": "jscpd src/ tests/",
|
||||
"scan:todos": "node scripts/scan-todos.mjs",
|
||||
"start": "node dist/main.js",
|
||||
"pretest": "npm run build",
|
||||
"test": "vitest run",
|
||||
"pretest:coverage": "npm run build",
|
||||
"test:coverage": "vitest run --coverage.enabled --coverage.provider=v8",
|
||||
"test:verbose": "vitest run --reporter=verbose",
|
||||
"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",
|
||||
"*.ts": "oxlint --fix"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/server": "2.0.0-beta.3",
|
||||
"canonicalize": "3.0.0",
|
||||
"incur": "0.4.13",
|
||||
"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",
|
||||
"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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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}`,
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -1,6 +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.
|
||||
@@ -11,18 +13,23 @@ export const runDirectAnalysis = async (
|
||||
path: string,
|
||||
tool: "binary_overview" | "procedure_pseudo_code",
|
||||
arguments_: Readonly<Record<string, JsonValue>>,
|
||||
logger: Logger = silentLogger,
|
||||
): Promise<JsonValue> => {
|
||||
const config = parseConfig(process.env);
|
||||
if (!config.ok)
|
||||
return { error: config.error._tag, message: config.error.message };
|
||||
const session = createBinarySession(config.value);
|
||||
const session = createBinarySession(config.value, logger);
|
||||
try {
|
||||
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,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 {
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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>",
|
||||
},
|
||||
});
|
||||
@@ -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>>;
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { createServer, type Server } from "node:http";
|
||||
import { WebSocketServer, type WebSocket } from "ws";
|
||||
import type {
|
||||
ProcessScenario,
|
||||
ProtocolEvent,
|
||||
} from "../domain/processCapture.js";
|
||||
|
||||
/** Owned loopback replay endpoints and their bounded protocol observations. */
|
||||
export interface LoopbackReplay {
|
||||
readonly httpUrl: string;
|
||||
readonly websocketUrl: string;
|
||||
readonly events: ProtocolEvent[];
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
const closeServer = async (server: Server): Promise<void> =>
|
||||
new Promise((resolveClose, rejectClose) => {
|
||||
server.close((error) =>
|
||||
error === undefined ? resolveClose() : rejectClose(error),
|
||||
);
|
||||
});
|
||||
|
||||
/** Start bounded HTTP and WebSocket replay endpoints bound only to IPv4 loopback. */
|
||||
export const startLoopbackReplay = async (
|
||||
scenario: ProcessScenario,
|
||||
): Promise<LoopbackReplay> => {
|
||||
const events: ProtocolEvent[] = [];
|
||||
const record = (event: Omit<ProtocolEvent, "sequence">): void => {
|
||||
if (events.length < 10_000)
|
||||
events.push({ sequence: events.length, ...event });
|
||||
};
|
||||
const server = createServer((request, response) => {
|
||||
const method = request.method ?? "GET";
|
||||
const path = new URL(request.url ?? "/", "http://127.0.0.1").pathname;
|
||||
record({ protocol: "http", direction: "request", method, path, data: "" });
|
||||
const route = scenario.replay.http.find(
|
||||
(candidate) => candidate.method === method && candidate.path === path,
|
||||
);
|
||||
response.statusCode = route?.status ?? 404;
|
||||
const body = route?.body ?? "";
|
||||
response.end(body);
|
||||
record({
|
||||
protocol: "http",
|
||||
direction: "response",
|
||||
method,
|
||||
path,
|
||||
data: body,
|
||||
});
|
||||
});
|
||||
const websocket = new WebSocketServer({
|
||||
noServer: true,
|
||||
maxPayload: 1_000_000,
|
||||
clientTracking: true,
|
||||
});
|
||||
server.on("upgrade", (request, socket, head) => {
|
||||
if (new URL(request.url ?? "/", "http://127.0.0.1").pathname !== "/ws") {
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
websocket.handleUpgrade(request, socket, head, (client) =>
|
||||
websocket.emit("connection", client, request),
|
||||
);
|
||||
});
|
||||
websocket.on("connection", (client: WebSocket) => {
|
||||
client.on("error", () => undefined);
|
||||
client.on("message", (value) =>
|
||||
record({
|
||||
protocol: "websocket",
|
||||
direction: "received",
|
||||
method: null,
|
||||
path: "/ws",
|
||||
data: value.toString(),
|
||||
}),
|
||||
);
|
||||
for (const message of scenario.replay.websocket_messages) {
|
||||
client.send(message);
|
||||
record({
|
||||
protocol: "websocket",
|
||||
direction: "sent",
|
||||
method: null,
|
||||
path: "/ws",
|
||||
data: message,
|
||||
});
|
||||
}
|
||||
});
|
||||
await new Promise<void>((resolveListen, rejectListen) => {
|
||||
server.once("error", rejectListen);
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
server.off("error", rejectListen);
|
||||
resolveListen();
|
||||
});
|
||||
});
|
||||
const address = server.address();
|
||||
if (address === null || typeof address === "string") {
|
||||
await closeServer(server);
|
||||
throw new Error("loopback replay did not acquire a TCP port");
|
||||
}
|
||||
return {
|
||||
httpUrl: `http://127.0.0.1:${String(address.port)}`,
|
||||
websocketUrl: `ws://127.0.0.1:${String(address.port)}/ws`,
|
||||
events,
|
||||
async close() {
|
||||
for (const client of websocket.clients) client.terminate();
|
||||
await new Promise<void>((resolveClose) =>
|
||||
websocket.close(() => resolveClose()),
|
||||
);
|
||||
await closeServer(server);
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,511 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
lstat,
|
||||
mkdtemp,
|
||||
readdir,
|
||||
readFile,
|
||||
readlink,
|
||||
realpath,
|
||||
rm,
|
||||
} from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, relative } from "node:path";
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { spawn, type IPty } from "node-pty";
|
||||
import type {
|
||||
FileState,
|
||||
ProcessCapture,
|
||||
ProcessExecutionPolicy,
|
||||
ProcessSample,
|
||||
ProcessScenario,
|
||||
TerminalFrame,
|
||||
} from "../domain/processCapture.js";
|
||||
import { authorizeProcessScenario } from "../domain/processCapture.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import { AnalysisError } from "../domain/errors.js";
|
||||
import { startLoopbackReplay, type LoopbackReplay } from "./LoopbackReplay.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
const isWithin = (candidate: string, root: string): boolean =>
|
||||
candidate === root ||
|
||||
candidate.startsWith(`${root.endsWith("/") ? root.slice(0, -1) : root}/`);
|
||||
|
||||
const assertRealPathAuthority = async (
|
||||
scenario: ProcessScenario,
|
||||
policy: ProcessExecutionPolicy,
|
||||
): Promise<void> => {
|
||||
const executable = await realpath(scenario.executable);
|
||||
const executableRoots = await Promise.all(
|
||||
policy.executableRoots.map((root) => realpath(root)),
|
||||
);
|
||||
if (!executableRoots.some((root) => isWithin(executable, root)))
|
||||
throw new ProcessCaptureError(
|
||||
"resolved executable is outside approved roots",
|
||||
);
|
||||
const workingDirectory = await realpath(scenario.working_directory);
|
||||
const workingRoots = await Promise.all(
|
||||
policy.workingRoots.map((root) => realpath(root)),
|
||||
);
|
||||
if (!workingRoots.some((root) => isWithin(workingDirectory, root)))
|
||||
throw new ProcessCaptureError(
|
||||
"resolved working directory is outside approved roots",
|
||||
);
|
||||
for (const root of scenario.filesystem_roots) {
|
||||
const resolvedRoot = await realpath(root);
|
||||
if (!workingRoots.some((approved) => isWithin(resolvedRoot, approved)))
|
||||
throw new ProcessCaptureError(
|
||||
"resolved filesystem root is outside approved roots",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/** Expected refusal or runtime failure from the process capture adapter. */
|
||||
export class ProcessCaptureError extends AnalysisError {
|
||||
readonly _tag = "ProcessCaptureError";
|
||||
}
|
||||
|
||||
/** Runtime availability of the native PTY adapter on this host. */
|
||||
export type ProcessCaptureCapability =
|
||||
| { readonly available: true; readonly backend: "node-pty" }
|
||||
| {
|
||||
readonly available: false;
|
||||
readonly backend: "node-pty";
|
||||
readonly reason: string;
|
||||
};
|
||||
|
||||
/** Probe the actual native PTY seam instead of inferring support from the OS name. */
|
||||
export const probeProcessCaptureCapability =
|
||||
async (): Promise<ProcessCaptureCapability> => {
|
||||
try {
|
||||
const terminal = spawn(
|
||||
process.platform === "win32" ? "cmd.exe" : "/bin/sh",
|
||||
process.platform === "win32" ? ["/c", "exit", "0"] : ["-c", "exit 0"],
|
||||
{
|
||||
cwd: tmpdir(),
|
||||
env: { HOME: tmpdir(), TERM: "xterm-256color" },
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
name: "xterm-256color",
|
||||
},
|
||||
);
|
||||
await new Promise<void>((resolveExit) =>
|
||||
terminal.onExit(() => resolveExit()),
|
||||
);
|
||||
return { available: true, backend: "node-pty" };
|
||||
} catch {
|
||||
return {
|
||||
available: false,
|
||||
backend: "node-pty",
|
||||
reason: "the native PTY backend could not start a probe process",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
interface SnapshotResult {
|
||||
readonly files: readonly FileState[];
|
||||
readonly truncated: boolean;
|
||||
}
|
||||
|
||||
const hashFile = async (
|
||||
path: string,
|
||||
maxBytes: number,
|
||||
): Promise<string | null> => {
|
||||
const value = await readFile(path);
|
||||
if (value.byteLength > maxBytes) return null;
|
||||
return createHash("sha256").update(value).digest("hex");
|
||||
};
|
||||
|
||||
const snapshotRoots = async (
|
||||
scenario: ProcessScenario,
|
||||
): Promise<SnapshotResult> => {
|
||||
const entries: FileState[] = [];
|
||||
let remainingBytes = scenario.limits.file_bytes;
|
||||
let truncated = false;
|
||||
const visit = async (root: string, path: string): Promise<void> => {
|
||||
if (entries.length >= scenario.limits.files) {
|
||||
truncated = true;
|
||||
return;
|
||||
}
|
||||
let stats;
|
||||
try {
|
||||
stats = await lstat(path);
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error && "code" in error && error.code === "ENOENT")
|
||||
return;
|
||||
throw error;
|
||||
}
|
||||
const relativePath = relative(root, path) || ".";
|
||||
if (stats.isSymbolicLink()) {
|
||||
entries.push({
|
||||
path: `${root}:${relativePath}`,
|
||||
type: "symlink",
|
||||
mode: stats.mode,
|
||||
size: stats.size,
|
||||
sha256: null,
|
||||
symlink_target: await readlink(path),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (stats.isFile()) {
|
||||
const allowedBytes = Math.max(
|
||||
0,
|
||||
Math.min(remainingBytes, scenario.limits.file_bytes),
|
||||
);
|
||||
const sha256 =
|
||||
allowedBytes >= stats.size ? await hashFile(path, allowedBytes) : null;
|
||||
remainingBytes -= sha256 === null ? 0 : stats.size;
|
||||
if (sha256 === null) truncated = true;
|
||||
entries.push({
|
||||
path: `${root}:${relativePath}`,
|
||||
type: "file",
|
||||
mode: stats.mode,
|
||||
size: stats.size,
|
||||
sha256,
|
||||
symlink_target: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const type = stats.isDirectory() ? "directory" : "other";
|
||||
entries.push({
|
||||
path: `${root}:${relativePath}`,
|
||||
type,
|
||||
mode: stats.mode,
|
||||
size: stats.size,
|
||||
sha256: null,
|
||||
symlink_target: null,
|
||||
});
|
||||
if (type !== "directory") return;
|
||||
for (const child of (await readdir(path)).sort())
|
||||
await visit(root, join(path, child));
|
||||
};
|
||||
for (const root of scenario.filesystem_roots) await visit(root, root);
|
||||
return { files: entries, truncated };
|
||||
};
|
||||
|
||||
const sampleProcesses = async (
|
||||
rootPid: number,
|
||||
elapsedMs: number,
|
||||
limit: number,
|
||||
): Promise<readonly ProcessSample[]> => {
|
||||
if (process.platform === "win32") return [];
|
||||
const { stdout } = await execFileAsync("ps", ["-axo", "pid=,ppid=,command="]);
|
||||
const rows = stdout
|
||||
.split("\n")
|
||||
.map((line) => /\s*(\d+)\s+(\d+)\s+(.*)/.exec(line))
|
||||
.filter((match): match is RegExpExecArray => match !== null)
|
||||
.map((match) => ({
|
||||
pid: Number(match[1]),
|
||||
parent_pid: Number(match[2]),
|
||||
command: match[3] ?? "",
|
||||
}));
|
||||
const owned = new Set([rootPid]);
|
||||
let changed = true;
|
||||
while (changed) {
|
||||
changed = false;
|
||||
for (const row of rows) {
|
||||
if (owned.has(row.parent_pid) && !owned.has(row.pid)) {
|
||||
owned.add(row.pid);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return rows
|
||||
.filter((row) => owned.has(row.pid))
|
||||
.slice(0, limit)
|
||||
.map((row) => ({ at_ms: elapsedMs, ...row }));
|
||||
};
|
||||
|
||||
const makeEnvironment = (
|
||||
scenario: ProcessScenario,
|
||||
home: string,
|
||||
replay: LoopbackReplay,
|
||||
): Record<string, string> => {
|
||||
const environment: Record<string, string> = {
|
||||
HOME: home,
|
||||
TERM: "xterm-256color",
|
||||
...scenario.environment,
|
||||
};
|
||||
for (const name of scenario.inherit_environment) {
|
||||
const value = process.env[name];
|
||||
if (value !== undefined) environment[name] = value;
|
||||
}
|
||||
environment.REA_REPLAY_HTTP_URL = replay.httpUrl;
|
||||
environment.REA_REPLAY_WEBSOCKET_URL = replay.websocketUrl;
|
||||
return environment;
|
||||
};
|
||||
|
||||
const normalizeTerminalData = (
|
||||
value: string,
|
||||
scenario: ProcessScenario,
|
||||
temporaryRoot: string,
|
||||
pid: number,
|
||||
): string => {
|
||||
let normalized = value;
|
||||
for (const alias of scenario.secret_aliases) {
|
||||
const secret = scenario.environment[alias];
|
||||
if (secret !== undefined && secret.length > 0)
|
||||
normalized = normalized.replaceAll(secret, "<redacted>");
|
||||
}
|
||||
if (scenario.normalization.paths)
|
||||
normalized = normalized.replaceAll(temporaryRoot, "<temporary-root>");
|
||||
if (scenario.normalization.pids)
|
||||
normalized = normalized.replaceAll(String(pid), "<pid>");
|
||||
if (scenario.normalization.ports)
|
||||
normalized = normalized.replaceAll(/(?<=[:=])\d{2,5}\b/g, "<port>");
|
||||
for (const pattern of scenario.normalization.patterns)
|
||||
normalized = normalized.replaceAll(pattern.pattern, pattern.replacement);
|
||||
return normalized;
|
||||
};
|
||||
|
||||
const scheduleScenarioEvents = (
|
||||
scenario: ProcessScenario,
|
||||
getTerminal: () => IPty | undefined,
|
||||
timers: Set<NodeJS.Timeout>,
|
||||
): void => {
|
||||
for (const event of scenario.events) {
|
||||
const timer = setTimeout(() => {
|
||||
const terminal = getTerminal();
|
||||
if (event.type === "input") terminal?.write(event.data);
|
||||
else if (event.type === "resize")
|
||||
terminal?.resize(event.columns, event.rows);
|
||||
else terminal?.kill(event.signal);
|
||||
}, event.at_ms);
|
||||
timers.add(timer);
|
||||
}
|
||||
};
|
||||
|
||||
interface TerminalExitOptions {
|
||||
readonly terminal: IPty;
|
||||
readonly scenario: ProcessScenario;
|
||||
readonly started: number;
|
||||
readonly lastOutput: () => number;
|
||||
readonly signal: AbortSignal | undefined;
|
||||
readonly timers: Set<NodeJS.Timeout>;
|
||||
}
|
||||
|
||||
interface CaptureResultOptions {
|
||||
readonly frames: readonly TerminalFrame[];
|
||||
readonly exit: { readonly exitCode: number; readonly signal?: number };
|
||||
readonly samples: readonly ProcessSample[];
|
||||
readonly replay: LoopbackReplay;
|
||||
readonly before: SnapshotResult;
|
||||
readonly after: SnapshotResult;
|
||||
readonly truncated: boolean;
|
||||
readonly scenario: ProcessScenario;
|
||||
readonly rootPid: number;
|
||||
}
|
||||
|
||||
const normalizeSamples = (
|
||||
samples: readonly ProcessSample[],
|
||||
scenario: ProcessScenario,
|
||||
rootPid: number,
|
||||
): readonly ProcessSample[] => {
|
||||
const identifiers = [
|
||||
rootPid,
|
||||
...samples.flatMap((sample) => [sample.pid, sample.parent_pid]),
|
||||
];
|
||||
const mapping = new Map<number, number>();
|
||||
for (const identifier of identifiers)
|
||||
if (identifier > 0 && !mapping.has(identifier))
|
||||
mapping.set(identifier, mapping.size + 1);
|
||||
return samples.map((sample) => ({
|
||||
at_ms:
|
||||
Math.floor(sample.at_ms / scenario.normalization.time_bucket_ms) *
|
||||
scenario.normalization.time_bucket_ms,
|
||||
pid: mapping.get(sample.pid) ?? 1,
|
||||
parent_pid: mapping.get(sample.parent_pid) ?? 0,
|
||||
command: normalizeTerminalData(
|
||||
sample.command,
|
||||
scenario,
|
||||
"<no-temporary-root>",
|
||||
rootPid,
|
||||
),
|
||||
}));
|
||||
};
|
||||
|
||||
const redactProtocolEvents = (
|
||||
events: readonly ProcessCapture["protocol_events"][number][],
|
||||
scenario: ProcessScenario,
|
||||
): readonly ProcessCapture["protocol_events"][number][] =>
|
||||
events.map((event) => ({
|
||||
...event,
|
||||
data: normalizeTerminalData(
|
||||
event.data,
|
||||
scenario,
|
||||
"<no-temporary-root>",
|
||||
-1,
|
||||
),
|
||||
}));
|
||||
|
||||
const buildCaptureResult = (options: CaptureResultOptions): ProcessCapture => ({
|
||||
schema_version: 1,
|
||||
frames: options.frames,
|
||||
exit: {
|
||||
code: options.exit.exitCode < 0 ? null : options.exit.exitCode,
|
||||
signal: options.exit.signal ?? null,
|
||||
},
|
||||
process_samples: normalizeSamples(
|
||||
options.samples,
|
||||
options.scenario,
|
||||
options.rootPid,
|
||||
),
|
||||
protocol_events: redactProtocolEvents(
|
||||
options.replay.events,
|
||||
options.scenario,
|
||||
),
|
||||
files_before: options.before.files,
|
||||
files_after: options.after.files,
|
||||
truncated: options.truncated,
|
||||
limitations: [
|
||||
"Process trees are sampled and may omit short-lived descendants.",
|
||||
"Filesystem observations are before/after snapshots, not syscall traces.",
|
||||
"The harness does not enforce external network isolation.",
|
||||
],
|
||||
});
|
||||
|
||||
const awaitTerminalExit = async ({
|
||||
terminal,
|
||||
scenario,
|
||||
started,
|
||||
lastOutput,
|
||||
signal,
|
||||
timers,
|
||||
}: TerminalExitOptions): Promise<{ exitCode: number; signal?: number }> =>
|
||||
new Promise((resolveExit) => {
|
||||
terminal.onExit(resolveExit);
|
||||
const timeout = setInterval(() => {
|
||||
if (
|
||||
Date.now() - started >= scenario.timeout_ms ||
|
||||
Date.now() - lastOutput() >= scenario.idle_timeout_ms ||
|
||||
signal?.aborted === true
|
||||
) {
|
||||
terminal.kill("SIGKILL");
|
||||
}
|
||||
}, 20);
|
||||
timers.add(timeout);
|
||||
});
|
||||
|
||||
/** Execute one authorized scenario and return bounded observations. */
|
||||
const runProcessScenario = async (
|
||||
scenario: ProcessScenario,
|
||||
policy: ProcessExecutionPolicy,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ProcessCapture> => {
|
||||
const decision = authorizeProcessScenario(scenario, policy);
|
||||
if (!decision.allowed) throw new ProcessCaptureError(decision.reason);
|
||||
await assertRealPathAuthority(scenario, policy);
|
||||
if (signal?.aborted === true)
|
||||
throw new ProcessCaptureError("process capture was cancelled");
|
||||
|
||||
const temporaryRoot = await mkdtemp(join(tmpdir(), "rea-process-"));
|
||||
const home = join(temporaryRoot, "home");
|
||||
await import("node:fs/promises").then(({ mkdir }) => mkdir(home));
|
||||
const before = await snapshotRoots(scenario);
|
||||
const frames: TerminalFrame[] = [];
|
||||
const samples: ProcessSample[] = [];
|
||||
let outputBytes = 0;
|
||||
let truncated = before.truncated;
|
||||
let terminal: IPty | undefined;
|
||||
let replay: LoopbackReplay | undefined;
|
||||
const started = Date.now();
|
||||
let lastOutput = started;
|
||||
const timers = new Set<NodeJS.Timeout>();
|
||||
|
||||
try {
|
||||
replay = await startLoopbackReplay(scenario);
|
||||
terminal = spawn(scenario.executable, [...scenario.arguments], {
|
||||
cwd: scenario.working_directory,
|
||||
env: makeEnvironment(scenario, home, replay),
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
name: "xterm-256color",
|
||||
});
|
||||
terminal.onData((data) => {
|
||||
lastOutput = Date.now();
|
||||
const bytes = Buffer.byteLength(data);
|
||||
if (
|
||||
frames.length >= scenario.limits.frames ||
|
||||
outputBytes + bytes > scenario.limits.output_bytes
|
||||
) {
|
||||
truncated = true;
|
||||
return;
|
||||
}
|
||||
outputBytes += bytes;
|
||||
frames.push({
|
||||
sequence: frames.length,
|
||||
at_ms:
|
||||
Math.floor(
|
||||
(Date.now() - started) / scenario.normalization.time_bucket_ms,
|
||||
) * scenario.normalization.time_bucket_ms,
|
||||
data: normalizeTerminalData(
|
||||
data,
|
||||
scenario,
|
||||
temporaryRoot,
|
||||
terminal?.pid ?? -1,
|
||||
),
|
||||
});
|
||||
});
|
||||
scheduleScenarioEvents(scenario, () => terminal, timers);
|
||||
const sampler = setInterval(() => {
|
||||
void sampleProcesses(
|
||||
terminal?.pid ?? -1,
|
||||
Date.now() - started,
|
||||
scenario.limits.processes - samples.length,
|
||||
)
|
||||
.then((values) => samples.push(...values))
|
||||
.catch(() => undefined);
|
||||
}, 50);
|
||||
timers.add(sampler);
|
||||
const exit = await awaitTerminalExit({
|
||||
terminal,
|
||||
scenario,
|
||||
started,
|
||||
lastOutput: () => lastOutput,
|
||||
signal,
|
||||
timers,
|
||||
});
|
||||
await new Promise((resolveSettle) =>
|
||||
setTimeout(resolveSettle, scenario.settle_ms),
|
||||
);
|
||||
const after = await snapshotRoots(scenario);
|
||||
truncated ||=
|
||||
after.truncated || samples.length >= scenario.limits.processes;
|
||||
return buildCaptureResult({
|
||||
frames,
|
||||
exit,
|
||||
samples,
|
||||
replay,
|
||||
before,
|
||||
after,
|
||||
truncated,
|
||||
scenario,
|
||||
rootPid: terminal.pid,
|
||||
});
|
||||
} catch (cause: unknown) {
|
||||
terminal?.kill("SIGKILL");
|
||||
throw new ProcessCaptureError("process capture failed", { cause });
|
||||
} finally {
|
||||
for (const timer of timers) clearTimeout(timer);
|
||||
await replay?.close();
|
||||
await rm(temporaryRoot, { recursive: true, force: true });
|
||||
}
|
||||
};
|
||||
|
||||
/** Execute one scenario through a typed expected-failure channel. */
|
||||
export const captureProcessScenario = async (
|
||||
scenario: ProcessScenario,
|
||||
policy: ProcessExecutionPolicy,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Result<ProcessCapture, ProcessCaptureError>> => {
|
||||
try {
|
||||
return ok(await runProcessScenario(scenario, policy, signal));
|
||||
} catch (cause: unknown) {
|
||||
return err(
|
||||
cause instanceof ProcessCaptureError
|
||||
? cause
|
||||
: new ProcessCaptureError("process capture failed", { cause }),
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -1,32 +1,16 @@
|
||||
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";
|
||||
|
||||
/**
|
||||
* Compose the target-switching runtime shared directly by CLI and MCP adapters.
|
||||
* This is the sole production wiring point for Hopper so neither adapter shells
|
||||
* out to the other and both retain identical loader and bridge semantics.
|
||||
*/
|
||||
export const createBinarySession = (config: AppConfig): 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,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
export const createBinarySession = (
|
||||
config: AppConfig,
|
||||
logger: Logger = silentLogger,
|
||||
): BinarySession => {
|
||||
return new BinarySession(new HopperProvider(config, logger));
|
||||
};
|
||||
|
||||
+65
-18
@@ -5,6 +5,7 @@ import { runDoctor } from "./application/Doctor.js";
|
||||
import { runDirectAnalysis } from "./application/DirectAnalysis.js";
|
||||
import { runSetup } from "./application/Setup.js";
|
||||
import { PRODUCT_IDENTITY } from "./identity.js";
|
||||
import { createLogger, parseLogLevel, type Logger } from "./logger.js";
|
||||
|
||||
/**
|
||||
* Build the one-shot Incur CLI without starting Hopper at import time.
|
||||
@@ -12,27 +13,33 @@ import { PRODUCT_IDENTITY } from "./identity.js";
|
||||
* `--mcp` are intercepted by the executable dispatcher before this module loads.
|
||||
*/
|
||||
export const createCli = (): ReturnType<typeof Cli.create> => {
|
||||
const logger = createLogger(
|
||||
"cli",
|
||||
process.env.REA_LOG_LEVEL === undefined
|
||||
? "silent"
|
||||
: parseLogLevel(process.env.REA_LOG_LEVEL),
|
||||
);
|
||||
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()
|
||||
@@ -40,32 +47,72 @@ export const createCli = (): ReturnType<typeof Cli.create> => {
|
||||
.describe("Approve prerequisite installation"),
|
||||
}),
|
||||
alias: { yes: "y" },
|
||||
run: ({ options }) => runSetup(options.yes),
|
||||
run: ({ options }) =>
|
||||
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 }) => runDoctor(options.target),
|
||||
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 }) => runDirectAnalysis(args.path, "binary_overview", {}),
|
||||
run: ({ args }) =>
|
||||
logCliCommand(logger, "analyze", () =>
|
||||
runDirectAnalysis(args.path, "binary_overview", {}, logger),
|
||||
),
|
||||
});
|
||||
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 }) =>
|
||||
runDirectAnalysis(args.path, "procedure_pseudo_code", {
|
||||
procedure: args.address,
|
||||
}),
|
||||
logCliCommand(logger, "decompile", () =>
|
||||
runDirectAnalysis(
|
||||
args.path,
|
||||
"procedure_pseudo_code",
|
||||
{ procedure: args.address },
|
||||
logger,
|
||||
),
|
||||
),
|
||||
});
|
||||
return cli;
|
||||
};
|
||||
|
||||
const logCliCommand = async <Value>(
|
||||
logger: Logger,
|
||||
command: string,
|
||||
execute: () => Promise<Value>,
|
||||
): Promise<Value> => {
|
||||
const startedAt = performance.now();
|
||||
try {
|
||||
const value = await execute();
|
||||
logger.info(
|
||||
{
|
||||
command,
|
||||
durationMs: Math.round((performance.now() - startedAt) * 100) / 100,
|
||||
status: "ok",
|
||||
},
|
||||
"CLI command completed",
|
||||
);
|
||||
return value;
|
||||
} catch (cause: unknown) {
|
||||
logger.error(
|
||||
{
|
||||
command,
|
||||
durationMs: Math.round((performance.now() - startedAt) * 100) / 100,
|
||||
status: "error",
|
||||
},
|
||||
"CLI command failed",
|
||||
);
|
||||
throw cause;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2,6 +2,8 @@ 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";
|
||||
@@ -11,6 +13,8 @@ export interface AppConfig {
|
||||
readonly hopperTargetPath: string | undefined;
|
||||
readonly hopperTargetKind: "executable" | "database";
|
||||
readonly hopperLoaderArgs: readonly string[];
|
||||
readonly logLevel: LogLevel;
|
||||
readonly processExecutionPolicy: ProcessExecutionPolicy;
|
||||
}
|
||||
|
||||
const environmentSchema = z.object({
|
||||
@@ -18,8 +22,36 @@ const environmentSchema = z.object({
|
||||
HOPPER_TARGET_PATH: z.string().min(1).optional(),
|
||||
HOPPER_TARGET_KIND: z.enum(["executable", "database"]).default("executable"),
|
||||
HOPPER_LOADER_ARGS_JSON: z.string().optional(),
|
||||
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
|
||||
@@ -59,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 ??
|
||||
@@ -66,5 +113,12 @@ export const parseConfig = (
|
||||
hopperTargetPath: parsedEnvironment.data.HOPPER_TARGET_PATH,
|
||||
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,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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
@@ -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 = [
|
||||
|
||||
@@ -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
@@ -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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll(""", '"')
|
||||
.replaceAll("'", "'");
|
||||
|
||||
type ExecutableMetadata = Pick<
|
||||
BinaryTarget,
|
||||
"format" | "architecture" | "availableArchitectures" | "loaderArgs"
|
||||
|
||||
+22
-9
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
});
|
||||
};
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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>;
|
||||
@@ -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,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;
|
||||
|
||||
@@ -13,12 +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 {
|
||||
parseResponseLine,
|
||||
responseResult,
|
||||
type JsonValue,
|
||||
} from "./protocol.js";
|
||||
import { silentLogger, type Logger } from "../logger.js";
|
||||
import { parseResponseLine, responseResult } from "./protocol.js";
|
||||
|
||||
const MAX_LINE_BYTES = 10 * 1024 * 1024;
|
||||
const SESSION_ROOT = process.platform === "darwin" ? "/tmp" : tmpdir();
|
||||
@@ -29,6 +27,7 @@ export interface HopperClientOptions {
|
||||
readonly requestTimeoutMs?: number;
|
||||
readonly startupTimeoutMs?: number;
|
||||
readonly onDiagnostic?: (event: HopperDiagnostic) => void;
|
||||
readonly logger?: Logger;
|
||||
}
|
||||
|
||||
/** Safe launcher telemetry; stderr content is intentionally never exposed. */
|
||||
@@ -62,6 +61,7 @@ export class HopperClient {
|
||||
> &
|
||||
HopperClientOptions;
|
||||
readonly #pending = new Map<number, PendingRequest>();
|
||||
readonly #logger: Logger;
|
||||
#socket: Socket | undefined;
|
||||
#launch: BridgeLaunch | undefined;
|
||||
#directory: string | undefined;
|
||||
@@ -73,6 +73,7 @@ export class HopperClient {
|
||||
#closePromise: Promise<void> | undefined;
|
||||
|
||||
constructor(options: HopperClientOptions) {
|
||||
this.#logger = options.logger ?? silentLogger;
|
||||
this.#options = {
|
||||
...options,
|
||||
requestTimeoutMs: options.requestTimeoutMs ?? 30_000,
|
||||
@@ -224,6 +225,7 @@ export class HopperClient {
|
||||
if (options.signal?.aborted === true)
|
||||
return err(new HopperCancelledError());
|
||||
const id = this.#nextId++;
|
||||
const startedAt = performance.now();
|
||||
const timeoutMs = options.timeoutMs ?? this.#options.requestTimeoutMs;
|
||||
const response = new Promise<Result<JsonValue, HopperError>>((resolve) => {
|
||||
const onAbort =
|
||||
@@ -252,7 +254,17 @@ export class HopperClient {
|
||||
this.#settle(id, err(new HopperProcessError(null)));
|
||||
},
|
||||
);
|
||||
return response;
|
||||
const result = await response;
|
||||
this.#logger[result.ok ? "debug" : "warn"](
|
||||
{
|
||||
method,
|
||||
durationMs: Math.round((performance.now() - startedAt) * 100) / 100,
|
||||
status: result.ok ? "ok" : "error",
|
||||
...(result.ok ? {} : { errorTag: result.error._tag }),
|
||||
},
|
||||
"Hopper bridge request completed",
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
#attachSocket(socket: Socket): void {
|
||||
|
||||
@@ -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,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(),
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import pino, { type Logger } from "pino";
|
||||
import { z } from "zod";
|
||||
|
||||
/** Supported structured log levels at the process boundary. */
|
||||
export type LogLevel = z.infer<typeof logLevelSchema>;
|
||||
|
||||
const logLevelSchema = z
|
||||
.enum(["trace", "debug", "info", "warn", "error", "fatal", "silent"])
|
||||
.default("info");
|
||||
|
||||
/** Parse the optional REA_LOG_LEVEL environment variable. */
|
||||
export const parseLogLevel = (value: unknown): LogLevel =>
|
||||
logLevelSchema.parse(value);
|
||||
|
||||
/** Create a JSON logger whose destination cannot corrupt MCP stdout frames. */
|
||||
export const createLogger = (mode: "mcp" | "cli", level: LogLevel): Logger =>
|
||||
pino(
|
||||
{
|
||||
level,
|
||||
base: { application: "rea", mode },
|
||||
redact: {
|
||||
paths: [
|
||||
"token",
|
||||
"*.token",
|
||||
"params",
|
||||
"*.params",
|
||||
"arguments",
|
||||
"*.arguments",
|
||||
"environment",
|
||||
"*.environment",
|
||||
],
|
||||
censor: "[Redacted]",
|
||||
},
|
||||
},
|
||||
pino.destination({ dest: mode === "mcp" ? 2 : 1, sync: false }),
|
||||
);
|
||||
|
||||
/** Logger used by embedders that have not opted into process output. */
|
||||
export const silentLogger = pino({ level: "silent" });
|
||||
|
||||
export type { Logger };
|
||||
+24
-5
@@ -11,6 +11,7 @@ import {
|
||||
import { parseConfig } from "./config.js";
|
||||
import { createBinarySession } from "./application/runtime.js";
|
||||
import { createServer } from "./server/createServer.js";
|
||||
import { createLogger } from "./logger.js";
|
||||
|
||||
/**
|
||||
* Start the long-lived MCP adapter and install idempotent shutdown handlers.
|
||||
@@ -23,27 +24,44 @@ export const run = async (): Promise<number> => {
|
||||
process.stderr.write(`${config.error._tag}: ${config.error.message}\n`);
|
||||
return 1;
|
||||
}
|
||||
const logger = createLogger("mcp", config.value.logLevel);
|
||||
const serverLogger = logger.child({ layer: "server" });
|
||||
|
||||
const session = createBinarySession(config.value);
|
||||
const session = createBinarySession(config.value, logger);
|
||||
if (config.value.hopperTargetPath !== undefined) {
|
||||
const opened = await session.open(config.value.hopperTargetPath, {
|
||||
targetKind: config.value.hopperTargetKind,
|
||||
});
|
||||
if (!opened.ok) {
|
||||
await session.close();
|
||||
serverLogger.error(
|
||||
{ errorTag: opened.error._tag },
|
||||
"Initial target failed to open",
|
||||
);
|
||||
process.stderr.write(`${opened.error._tag}: ${opened.error.message}\n`);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
let handle: StdioServerHandle;
|
||||
try {
|
||||
handle = serveStdio(() => createServer(session, session), {
|
||||
onerror: () => {
|
||||
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");
|
||||
process.stderr.write("Failed to start MCP stdio transport\n");
|
||||
return 1;
|
||||
}
|
||||
@@ -59,6 +77,7 @@ export const run = async (): Promise<number> => {
|
||||
const requestShutdown = (): void => {
|
||||
shutdown().catch(() => {
|
||||
process.exitCode = 1;
|
||||
serverLogger.error("Failed to shut down cleanly");
|
||||
process.stderr.write("Failed to shut down cleanly\n");
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,11 +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.
|
||||
@@ -13,8 +15,10 @@ import { registerSessionTools } from "./registerSessionTools.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" },
|
||||
@@ -22,12 +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.",
|
||||
},
|
||||
);
|
||||
registerOfficialTools(server, hopper);
|
||||
registerEnhancedTools(server, hopper);
|
||||
if (session !== undefined) registerSessionTools(server, session);
|
||||
const toolLogger = logger.child({ layer: "server" });
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -1,22 +1,43 @@
|
||||
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,
|
||||
type ToolContract,
|
||||
} from "../contracts/toolContracts.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 { 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);
|
||||
registerEnhancedTool(server, services, contract, {
|
||||
logger,
|
||||
activeTarget,
|
||||
provider,
|
||||
recordEvidence,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -24,14 +45,52 @@ const registerEnhancedTool = (
|
||||
server: McpServer,
|
||||
services: EnhancedTools,
|
||||
contract: ToolContract,
|
||||
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 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);
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,40 +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);
|
||||
registerOfficialTool(server, analysis, contract, {
|
||||
logger,
|
||||
activeTarget,
|
||||
provider,
|
||||
recordEvidence,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const registerOfficialTool = (
|
||||
server: McpServer,
|
||||
hopper: HopperToolPort,
|
||||
analysis: AnalysisOperationPort,
|
||||
contract: ToolContract,
|
||||
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 hopper.callTool(contract.name, arguments_, {
|
||||
signal: context.mcpReq.signal,
|
||||
});
|
||||
return toCallToolResult(result);
|
||||
const result = await logToolExecution(
|
||||
registration.logger,
|
||||
contract.name,
|
||||
() =>
|
||||
analysis.execute(contract.name, arguments_, {
|
||||
signal: context.mcpReq.signal,
|
||||
}),
|
||||
);
|
||||
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);
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,38 +1,234 @@
|
||||
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);
|
||||
const opened = await session.open(parsed.path, {
|
||||
signal: context.mcpReq.signal,
|
||||
});
|
||||
const opened = await logToolExecution(logger, openContract.name, () =>
|
||||
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(
|
||||
@@ -40,15 +236,35 @@ export const registerSessionTools = (
|
||||
{
|
||||
description: closeContract.description,
|
||||
inputSchema: closeContract.inputSchema,
|
||||
outputSchema: closeContract.outputSchema,
|
||||
annotations: closeContract.annotations,
|
||||
},
|
||||
async () => toCallToolResult(await session.close()),
|
||||
async () =>
|
||||
toCallToolResult(
|
||||
await logToolExecution(logger, closeContract.name, () =>
|
||||
session.close(),
|
||||
),
|
||||
closeContract,
|
||||
),
|
||||
);
|
||||
server.registerTool(
|
||||
statusContract.name,
|
||||
{
|
||||
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,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { Result } from "../domain/result.js";
|
||||
import type { Logger } from "../logger.js";
|
||||
|
||||
/** Log one MCP tool's duration and status without recording caller input. */
|
||||
export const logToolExecution = async <Value, Failure>(
|
||||
logger: Logger,
|
||||
tool: string,
|
||||
execute: () => Promise<Result<Value, Failure>>,
|
||||
): Promise<Result<Value, Failure>> => {
|
||||
const startedAt = performance.now();
|
||||
const result = await execute();
|
||||
logger[result.ok ? "info" : "warn"](
|
||||
{
|
||||
tool,
|
||||
durationMs: Math.round((performance.now() - startedAt) * 100) / 100,
|
||||
status: result.ok ? "ok" : "error",
|
||||
},
|
||||
"MCP tool execution completed",
|
||||
);
|
||||
return result;
|
||||
};
|
||||
@@ -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
@@ -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()))
|
||||
|
||||
@@ -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 & 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");
|
||||
|
||||
@@ -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", {})');
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,7 @@ describe("runtime configuration", () => {
|
||||
expect(result.value.hopperLauncherPath).toContain("/MacOS/hopper");
|
||||
expect(result.value.hopperTargetKind).toBe("executable");
|
||||
expect(result.value.hopperLoaderArgs).toEqual([]);
|
||||
expect(result.value.logLevel).toBe("info");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -31,6 +32,13 @@ describe("runtime configuration", () => {
|
||||
hopperTargetPath: "/fixture/sample.hop",
|
||||
hopperTargetKind: "database",
|
||||
hopperLoaderArgs: ["-l", "FAT", "--aarch64", "-l", "Mach-O"],
|
||||
logLevel: "info",
|
||||
processExecutionPolicy: {
|
||||
enabled: false,
|
||||
executableRoots: [],
|
||||
workingRoots: [],
|
||||
allowedEnvironment: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -46,4 +54,10 @@ describe("runtime configuration", () => {
|
||||
).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it("parses supported log levels and rejects unknown levels", () => {
|
||||
const configured = parseConfig({ REA_LOG_LEVEL: "debug" });
|
||||
expect(configured.ok && configured.value.logLevel).toBe("debug");
|
||||
expect(parseConfig({ REA_LOG_LEVEL: "verbose" }).ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
@@ -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; }
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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; }
|
||||
@@ -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; }
|
||||
@@ -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"));
|
||||
});
|
||||
});
|
||||
@@ -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,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
@@ -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
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Vendored
+28
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
: [],
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
};
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
+19
-4
@@ -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: {},
|
||||
@@ -42,7 +42,22 @@ describe("production stdio runtime", () => {
|
||||
await client.close();
|
||||
await transport.close();
|
||||
}
|
||||
expect(stderr).toBe("");
|
||||
const records = stderr
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter((line) => line.length > 0)
|
||||
.map((line: string): unknown => JSON.parse(line));
|
||||
expect(records).toContainEqual(
|
||||
expect.objectContaining({
|
||||
application: "rea",
|
||||
mode: "mcp",
|
||||
layer: "server",
|
||||
tool: "current_document",
|
||||
status: "ok",
|
||||
}),
|
||||
);
|
||||
expect(stderr).not.toContain("HOPPER_LOADER_ARGS_JSON");
|
||||
expect(stderr).not.toContain(fixturePath);
|
||||
});
|
||||
|
||||
it("honors the configured kind for an initial database target", async () => {
|
||||
@@ -63,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();
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user