feat(ida): add read-only GUI and headless MCP providers (#754)
This commit is contained in:
parent
c8e66c704a
commit
8238232485
|
|
@ -38,7 +38,7 @@
|
|||
{
|
||||
"patterns": [
|
||||
{
|
||||
"group": ["**/ghidra/**", "**/hopper/**"],
|
||||
"group": ["**/ghidra/**", "**/hopper/**", "**/ida/**"],
|
||||
"message": "Deep-provider imports belong behind adapters or in the application composition root."
|
||||
}
|
||||
]
|
||||
|
|
@ -71,6 +71,7 @@
|
|||
"**/browser/**",
|
||||
"**/ghidra/**",
|
||||
"**/hopper/**",
|
||||
"**/ida/**",
|
||||
"**/replay/**",
|
||||
"**/cli.*",
|
||||
"**/main.*"
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
## Product Direction
|
||||
|
||||
REA exposes reverse-engineering tools through a CLI and MCP server. Hopper and the bring-your-own Ghidra adapter are operation-capable deep binary-analysis providers. Ghidra is supported on Linux x64 and macOS x64/arm64 with matching native decompiler tools, and has an experimental Windows x64 P0 boundary for approved native x86-64 PE applications; on Linux and macOS it supplies inventory, function analysis, and atomic function annotation edits in an ephemeral database, without modifying executable bytes or controlling a GUI. Windows P0 has no mutation authority. Keep provider-specific code out of the domain and application layers.
|
||||
REA exposes reverse-engineering tools through a CLI and MCP server. Hopper, the bring-your-own Ghidra adapter, and the bring-your-own IDA MCP adapter are operation-capable deep binary-analysis providers. Ghidra is supported on Linux x64 and macOS x64/arm64 with matching native decompiler tools, and has an experimental Windows x64 P0 boundary for approved native x86-64 PE applications; on Linux and macOS it supplies inventory, function analysis, and atomic function annotation edits in an ephemeral database, without modifying executable bytes or controlling a GUI. Windows P0 has no mutation authority. IDA adapts upstream legacy attached GUI and modern headless database-supervisor profiles for live read-only analysis; initial real verification covers Windows, and an attached GUI database is never saved or closed. Keep provider-specific code out of the domain and application layers.
|
||||
|
||||
Prioritize:
|
||||
|
||||
|
|
@ -20,7 +20,7 @@ REA is a local-only tool. Preserve caller-selected inputs, captured output, URLs
|
|||
REA is a layered ESM TypeScript application. Dependencies flow inward from pure domain logic through contracts, providers, application workflows, and CLI/MCP adapters. See [docs/architecture.mermaid](docs/architecture.mermaid) for the component map.
|
||||
|
||||
- `src/domain/` owns pure provider-neutral semantics; `src/contracts/` owns caller-visible schemas and the canonical tool inventory.
|
||||
- `src/hopper/`, `src/ghidra/`, `src/browser/`, `src/native/`, `src/artifacts/`, and `src/dotnet/` own provider-specific boundaries. Keep provider protocols out of domain and application code.
|
||||
- `src/hopper/`, `src/ghidra/`, `src/ida/`, `src/browser/`, `src/native/`, `src/artifacts/`, and `src/dotnet/` own provider-specific boundaries. Keep provider protocols out of domain and application code.
|
||||
- `src/application/` composes shared CLI/MCP workflows; `src/server/` translates MCP requests; `src/cli.ts` and `src/main.ts` are the CLI and MCP entry points.
|
||||
- `src/process/` owns shared process lifecycle primitives, not provider wire protocols. `bridge/` contains provider-side adapters.
|
||||
- `tests/` contains unit, composition, boundary, acceptance, and conformance tests. `scripts/verify-*` contains real-toolchain checks.
|
||||
|
|
|
|||
24
README.md
24
README.md
|
|
@ -172,7 +172,7 @@ provider guides describe their supported platforms.
|
|||
- Node.js 22.x (>=22.19), 24.x (>=24.11), or 26+
|
||||
- npm; REA does not require or install a particular npm version
|
||||
|
||||
Native binary analysis requires [Hopper](https://www.hopperapp.com/) or [Ghidra](#ghidra-analysis-provider). Hopper is separate software with its own license; its demo supports analysis with vendor-defined limits. REA can use Ghidra that you have already installed.
|
||||
Deep native binary analysis requires [Hopper](https://www.hopperapp.com/), [Ghidra](#ghidra-analysis-provider), or [IDA Pro](#ida-pro-analysis-provider). Hopper is separate software with its own license; its demo supports analysis with vendor-defined limits. Ghidra and IDA are bring-your-own providers.
|
||||
|
||||
Firmware region inspection and explicit extraction use caller-supplied Binwalk and Unblob on Linux. See [Firmware analysis](docs/firmware-analysis.md) for setup, provenance, resource limits and native handoff.
|
||||
|
||||
|
|
@ -248,6 +248,19 @@ rea uninstall --purge-data # also removes only ~/.rea/cache and ~/.rea/state
|
|||
|
||||
Uninstall preserves Hopper, Node.js, Evidence files, captures, unrelated skills, and other MCP servers. It refuses malformed client configuration and never follows purge-data symlinks.
|
||||
|
||||
### IDA Pro analysis provider
|
||||
|
||||
Already have [mrexodia/ida-pro-mcp](https://github.com/mrexodia/ida-pro-mcp) working? REA can reuse its MCP registration for read-only analysis of the current GUI target, or use its database supervisor to open and analyze a supplied binary headlessly.
|
||||
|
||||
```bash
|
||||
export REA_IDA_MCP_CONFIG=/absolute/path/to/ida-mcp.json
|
||||
rea function /absolute/path/to/program main --provider ida --json
|
||||
```
|
||||
|
||||
The registration selects `attached` (default, legacy 1.4 tools) or `headless` (modern database supervisor). Setup can preserve this file reference in your selected agent's REA registration. REA adapts existing analysis contracts and manages its own headless database; it leaves an attached GUI database open and never saves it. Results stay live because external IDA database changes are not immutable snapshots.
|
||||
|
||||
See the [IDA provider guide](docs/ida-provider.md) for upstream installation links, exact configuration examples, Windows-native headless operation, lifecycle cleanup, and coverage. The initial real workflows cover a Windows GUI and Windows x64 headless IDA 9.3; other engine/platform combinations remain unverified. Use a package version that includes this adapter; repository main can lead the npm release.
|
||||
|
||||
### CLI or agent?
|
||||
|
||||
| If you want to… | Use |
|
||||
|
|
@ -359,7 +372,7 @@ REA supports native application, JavaScript, Electron, .NET, and browser investi
|
|||
|
||||
Static Android APK inspection is verified on Linux with headless JADX; see [Android analysis](docs/android-analysis.md) for its separate prerequisites and coverage.
|
||||
|
||||
- **Native binaries:** Open Mach-O, ELF, PE, and Mac `.app` targets through Hopper or Ghidra. Inspect functions, strings, assembly, decompilation, calls, and references. Hopper also accepts `.hop` databases and supports annotations.
|
||||
- **Native binaries:** Open Mach-O, ELF, PE, and Mac `.app` targets through a selected deep provider. Hopper and Ghidra cover broad inventory and function analysis; the IDA adapter supplies its documented read-only function/string operations. Hopper also accepts `.hop` databases and supports annotations.
|
||||
- **Packages and resources:** Inspect directories, ZIP, APK, IPA, ASAR, plists, compiled Interface Builder files, and Apple asset catalogs. Artifact requests name the input and requested extraction or traversal directly; macOS DMG traversal also requires the host's native mounting support.
|
||||
- **JavaScript and Electron:** Map modules, imports, source maps, routes, IPC channels, storage, and native add-ons without running the app. Compare builds and trace a feature across the recovered graph. Dynamic and ambiguous relationships remain unresolved. See [JavaScript application workflows](docs/javascript-application-workflows.md).
|
||||
- **Websites:** Inspect a selected page in an existing Chrome-family browser. Capture page structure, network metadata, script evidence, and screenshots requested by the call. Passive observation does not navigate or execute page JavaScript. See [browser observation](docs/browser-observation.md).
|
||||
|
|
@ -493,8 +506,11 @@ flowchart LR
|
|||
Session --> Registry["Deep-provider registry<br/>deterministic selection"]
|
||||
Registry --> Hopper["Hopper provider"]
|
||||
Registry --> Ghidra["Ghidra provider<br/>inventory + function analysis + annotations"]
|
||||
Registry --> Ida["IDA MCP provider<br/>attached GUI or owned headless database"]
|
||||
Hopper --> Runtime["Owned provider runtime<br/>deadline + bounded diagnostics + cleanup"]
|
||||
Ghidra --> Runtime
|
||||
Ida --> IdaRuntime["Upstream MCP lifecycle<br/>live observations + owned database cleanup"]
|
||||
IdaRuntime --> Target
|
||||
Session --> Native["Native macOS provider"]
|
||||
Session --> Artifact["Artifact graph provider"]
|
||||
REA --> Browser["Browser CDP provider"]
|
||||
|
|
@ -561,7 +577,7 @@ For MCP, pass the optional selector on `open_binary`:
|
|||
}
|
||||
```
|
||||
|
||||
Use `--provider`, or `provider_id` in MCP, to choose Hopper or Ghidra for a target. This choice overrides `REA_ANALYSIS_PROVIDER`.
|
||||
Use `--provider`, or `provider_id` in MCP, to choose Hopper, Ghidra, or IDA for a target. This choice overrides `REA_ANALYSIS_PROVIDER`.
|
||||
|
||||
With `auto`, REA selects the only available tool that supports the target. If both are available, specify one before opening the target. The session keeps that choice until you explicitly switch or close it; a failure never silently switches tools. Artifact-only analysis can work without a native analysis tool.
|
||||
|
||||
|
|
@ -639,7 +655,7 @@ verified or absent.
|
|||
|
||||
## Security model
|
||||
|
||||
Analysis runs locally. REA communicates with Hopper and Ghidra through authenticated private local sockets. Your agent or model provider has its own data policy.
|
||||
Analysis runs locally. REA communicates with Hopper and Ghidra through authenticated private local sockets and with IDA through its configured local MCP registration. Your agent or model provider has its own data policy.
|
||||
|
||||
Runtime requests act on the declared target and lifecycle. Analysis tools and launched targets run with your user permissions, and native UI capture still depends on macOS Accessibility and Screen Recording access. Static JavaScript analysis does not execute extracted modules; use direct browser, Electron, or process capture when runtime behavior is needed.
|
||||
|
||||
|
|
|
|||
|
|
@ -22,6 +22,8 @@ graph TD
|
|||
subgraph "Provider Layers"
|
||||
HOPPER["src/hopper/<br/>Hopper launch & Unix-socket protocol<br/>BridgeLauncher, HopperClient, protocol"]
|
||||
GHIDRA["src/ghidra/<br/>Ghidra discovery, profile & function analysis<br/>snapshot, Unix/Windows transport, bounded lifecycle"]
|
||||
IDA["src/ida/<br/>Upstream MCP contract adaptation<br/>live GUI binding or owned headless database"]
|
||||
IDA_MCP["External ida-pro-mcp<br/>GUI proxy or database supervisor<br/>upstream installation and engine lifecycle"]
|
||||
AUX["src/artifacts/ + src/native/<br/>disjoint artifact and native capabilities"]
|
||||
BROWSER["src/browser/<br/>Loopback CDP transport<br/>exact-origin passive observation"]
|
||||
DOTNET["src/dotnet/<br/>Managed static metadata provider<br/>PE/CLI metadata inventory, members, IL"]
|
||||
|
|
@ -75,6 +77,10 @@ graph TD
|
|||
SESSION --> REGISTRY
|
||||
REGISTRY --> HOPPER
|
||||
REGISTRY --> GHIDRA
|
||||
REGISTRY --> IDA
|
||||
IDA --> IDA_MCP
|
||||
IDA --> CONTRACTS
|
||||
IDA --> DOMAIN
|
||||
SESSION --> AUX
|
||||
APP --> JS_RECON
|
||||
APP --> INSTALL
|
||||
|
|
@ -105,6 +111,7 @@ graph TD
|
|||
TESTS -.-> MAIN
|
||||
TESTS -.-> HOPPER
|
||||
TESTS -.-> GHIDRA
|
||||
TESTS -.-> IDA
|
||||
TESTS -.-> PROCESS
|
||||
TESTS -.-> BROWSER
|
||||
TESTS -.-> EVALUATION
|
||||
|
|
|
|||
|
|
@ -0,0 +1,111 @@
|
|||
# IDA Pro provider
|
||||
|
||||
REA adapts an existing [mrexodia/ida-pro-mcp](https://github.com/mrexodia/ida-pro-mcp) server into its native analysis tools. Install and configure IDA, Hex-Rays, Python, and upstream MCP using that repository's instructions. REA does not install, upgrade, activate, or license them, and does not bundle another IDA server.
|
||||
|
||||
Choose one lifecycle mode in an upstream MCP JSON registration. Set `REA_IDA_MCP_CONFIG` to that file, then select `ida` once through `REA_ANALYSIS_PROVIDER`, the CLI's `--provider ida`, or MCP's `open_binary.provider_id`. No provider startup occurs during discovery.
|
||||
|
||||
## Analyze an already-open GUI target
|
||||
|
||||
The attached profile supports upstream **1.4.0 legacy tools**, including `get_metadata`, `list_functions`, and `decompile_function`. Copy the upstream registration's `command`, `args`, and optional `env` into a JSON file. An existing `mcpServers` object with an `ida-pro-mcp` entry is also accepted. For example:
|
||||
|
||||
```json
|
||||
{
|
||||
"command": "python",
|
||||
"args": [
|
||||
"/absolute/path/to/ida_pro_mcp/server.py",
|
||||
"--ida-rpc",
|
||||
"http://127.0.0.1:13337"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Use the executable and arguments from your working upstream registration. The legacy GUI's `/mcp` endpoint is its internal RPC endpoint; connect REA through the upstream stdio proxy rather than treating that URL as Streamable HTTP.
|
||||
|
||||
```bash
|
||||
export REA_IDA_MCP_CONFIG=/absolute/path/to/ida-mcp.json
|
||||
export REA_ANALYSIS_PROVIDER=ida
|
||||
rea function /absolute/path/to/program main --json
|
||||
rea decompile /absolute/path/to/program main --json
|
||||
```
|
||||
|
||||
Open the **original input binary** in REA, not an `.idb` or `.i64` database. Its SHA-256 must match IDA's recorded input hash. REA checks the attached identity and image base around each analysis request. A mismatch or target switch fails with recovery guidance. The original input must remain readable by REA; WSL can use its mounted alias while the GUI runs on Windows.
|
||||
|
||||
REA's close operation releases its MCP connection or proxy. It never saves or closes the existing GUI database. External edits remain visible because results are live and are not replayed from snapshots. These checks do not lock the GUI or prove that the database is unpatched.
|
||||
|
||||
## Give REA a target for headless analysis
|
||||
|
||||
The headless profile supports upstream's **database supervisor** API: `idb_open`, `idb_list`, `idb_close`, explicit `database` arguments, and modern analysis tools. Legacy 1.4.0 `idalib-mcp`, which accepts one initial filename and serves SSE, does not implement this profile. Follow the [upstream headless instructions](https://github.com/mrexodia/ida-pro-mcp#headless-idalib-session-model) for an installation with the supervisor API.
|
||||
|
||||
```json
|
||||
{
|
||||
"command": "idalib-mcp",
|
||||
"args": ["--stdio", "--max-workers", "1"],
|
||||
"mode": "headless"
|
||||
}
|
||||
```
|
||||
|
||||
Use an already-installed executable. When required by your existing idalib installation, include `"env": { "IDADIR": "/absolute/path/to/ida" }`. REA neither edits the global activation configuration nor installs dependencies. A configured local Streamable HTTP supervisor can instead use `"url": "http://127.0.0.1:8745/mcp"` and `"mode": "headless"`.
|
||||
|
||||
The same CLI commands work in both modes. For a Windows-native REA process:
|
||||
|
||||
```powershell
|
||||
$env:REA_IDA_MCP_CONFIG = 'D:\analysis\ida-mcp.json'
|
||||
$env:REA_ANALYSIS_PROVIDER = 'ida'
|
||||
rea function 'D:\samples\program.exe' main --json
|
||||
```
|
||||
|
||||
Headless REA and the configured supervisor must share the host filesystem and native path syntax. Run REA on Windows when the supervisor runs on Windows. The attached profile can span WSL and a Windows GUI because it binds recorded input hashes instead of asking the GUI to open a WSL path.
|
||||
|
||||
REA copies the admitted binary into a private temporary directory, checks its digest, and requests an owned worker with `force_headless`. POSIX workspaces use mode `0700`; Windows uses the matching package's native private-DACL boundary on a local NTFS temporary directory. It injects the returned database ID into analysis requests. It verifies the active owned worker and input path rather than adopting a user database. On close or failed startup, it releases its session with `save: false`, verifies that it is absent from upstream session discovery, and removes its private input and sidecars. An unverified release retains the workspace and reports incomplete cleanup.
|
||||
|
||||
One REA session owns one database. Stdio registrations default `IDA_MCP_MAX_WORKERS` to `1`; the explicit upstream `--max-workers 1` also bounds a supervisor. Database size and engine memory depend on the target and upstream; REA does not impose an engine memory limit. The original binary is never executed or overwritten. Optional `workspaceRoot` selects an existing absolute temporary-directory parent on the REA host. Ensure it has space for the input copy and IDA working files.
|
||||
|
||||
Headless cleanup uses the upstream database lifecycle. Abruptly terminating REA, its proxy, or the supervisor does not establish worker release. Prefer `close_binary` for MCP sessions; after an interrupted process, inspect upstream session inventory and retained workspace diagnostics before removing working files. This adapter does not claim Windows Job Object containment for the upstream detached workers.
|
||||
|
||||
## Connect an agent once
|
||||
|
||||
Add the registration path to the **REA** MCP entry's environment, keeping the upstream registration in its separate JSON file:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"rea": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "rea-agents@<version-with-ida>", "mcp"],
|
||||
"env": {
|
||||
"REA_ANALYSIS_PROVIDER": "ida",
|
||||
"REA_IDA_MCP_CONFIG": "/absolute/path/to/ida-mcp.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Use a package version that actually includes this adapter; repository main can be ahead of the npm release. After reconnecting, call:
|
||||
|
||||
```json
|
||||
{"name":"open_binary","arguments":{"path":"/absolute/path/to/program","provider_id":"ida"}}
|
||||
{"name":"analyze_function","arguments":{"procedure":"main"}}
|
||||
{"name":"search_strings","arguments":{"pattern":"license"}}
|
||||
{"name":"close_binary","arguments":{}}
|
||||
```
|
||||
|
||||
Opening an MCP target starts its selected provider session; headless opening waits for auto-analysis. Set the agent's MCP request deadline long enough for the target. The registration's optional `timeoutMs` defaults to 300000 for upstream requests. Cancellation drains the current upstream request before cleanup; it does not force-stop GUI analysis. An open request that times out without a completed response retains the private workspace because worker inventory alone cannot prove the request has stopped. `binary_session` reports actual tool availability. Repeated doctor/setup calls are unnecessary when the registration already works.
|
||||
|
||||
For guided registration, set `REA_IDA_MCP_CONFIG` before `rea setup --client <client>`. Setup includes that file reference in its reviewed plan and preserves it after approval; it does not copy upstream credentials into the agent configuration. `rea doctor --provider ida --json` validates the registration without starting IDA and explicitly leaves runtime compatibility and target binding for analysis.
|
||||
|
||||
## Coverage and evidence
|
||||
|
||||
Both profiles support procedure/string inventory and literal or regex search, procedure resolution, pseudocode, assembly, raw function instructions, resolved callees, incoming address references, and a function dossier. Inventories and modern pseudocode/assembly follow upstream pagination. Upstream limits on modern xrefs and callees are retained as explicit truncation limitations. A transport-level truncated preview is rejected rather than reported as complete evidence; the error preserves upstream retrieval guidance.
|
||||
|
||||
The legacy profile additionally supports direct callers. Its producer may report call-site or interior addresses; REA resolves the reported function symbol to its canonical entry. The modern profile does not prove direct callers from its code/data xref labels, so that capability is unavailable and the dossier marks it unknown.
|
||||
|
||||
Complete body ranges, typed reference edges, CFG, referenced data, source locals, comment kinds, and database revision are unavailable. Empty unsupported dossier facets mean **unknown**, not observed absence. `binary_overview`, whole-symbol inventory, mutation, debugging, arbitrary Python forwarding, and GUI navigation are outside this adapter's coverage. Check session availability before composing a workflow that needs those facets.
|
||||
|
||||
`provider.version` identifies the REA adapter; the profile explicitly records `version_scope: "rea-ida-adapter"`. The MCP handshake's `serverInfo` is separate and does not establish IDA or Python distribution versions. Raw observations preserve producer metadata and output. Attached hashes identify the recorded original input; headless identity uses the digest-verified private copy and observed worker input path. Neither proves source-code recovery or an unmodified analysis database.
|
||||
|
||||
## Verification
|
||||
|
||||
Run `npm run verify:ida -- --target /absolute/path/to/program --procedure main` with a configured upstream server. It exercises production CLI and MCP tools, validates their output contracts and Evidence, checks original-input preservation and session cleanup, and keeps local target data out of its console summary. Supply `--package-root` to verify an extracted or installed package. See [testing](testing.md) for the distinction between adapter tests and real-provider evidence.
|
||||
|
||||
The initial real workflows use stdio registrations for the legacy Windows GUI profile and a Windows x64 headless supervisor with IDA 9.3. Streamable HTTP is tested against a loopback MCP fixture with the pinned client SDK; a real IDA HTTP-supervisor workflow remains unverified. Linux/macOS headless engine operation, other IDA releases, architectures, and modern attached GUI tools require their corresponding real workflows before support is claimed.
|
||||
|
|
@ -7,7 +7,7 @@
|
|||
"client": "2.3.1",
|
||||
"core": "2.3.1"
|
||||
},
|
||||
"skill_version": "25"
|
||||
"skill_version": "26"
|
||||
},
|
||||
"tools": {
|
||||
"total": 125,
|
||||
|
|
@ -304,6 +304,25 @@
|
|||
"xrefs"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "ida",
|
||||
"name": "IDA Pro MCP adapter",
|
||||
"version": "1",
|
||||
"capabilities": [
|
||||
"analyze_function",
|
||||
"list_procedures",
|
||||
"list_strings",
|
||||
"procedure_address",
|
||||
"procedure_assembly",
|
||||
"procedure_callees",
|
||||
"procedure_callers",
|
||||
"procedure_pseudo_code",
|
||||
"read_function_instructions",
|
||||
"search_procedures",
|
||||
"search_strings",
|
||||
"xrefs"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "jadx",
|
||||
"name": "JADX headless",
|
||||
|
|
@ -613,7 +632,7 @@
|
|||
"tools_sha256": "eec3abb8fa5b25c6e5d6e361b7ebd157e3599f69b68defda85680dbfe061b3d3",
|
||||
"prompts_sha256": "ef2de8814390b340623c32001610aa2709d90bed51537b6e562ab3abf144ab4d",
|
||||
"combined_sha256": "ed9c36900e9223561d38ba30a44921f4ff101d5eca989920f9edc9dc91df470b",
|
||||
"providers_sha256": "dfba0ee59f12c82cdfb5320e7e0d70a114e533b2cb88afd15db9b9a4a175c9b0"
|
||||
"providers_sha256": "044634314b36b966bd918f99f116f4684108556ac1dabc912e76ccd351d97e3e"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ The process-global Vitest configuration contract rejects new direct temporary-ro
|
|||
creation outside the workspace seam and its narrowly documented boundary/package
|
||||
exceptions.
|
||||
|
||||
Real Hopper, Ghidra, browser, package, and managed-code claims belong to their
|
||||
Real Hopper, Ghidra, IDA, browser, package, and managed-code claims belong to their
|
||||
explicit `npm run verify:*` lanes. The reconstruction-readiness lane also
|
||||
checks deterministic rerun, tamper, and stale-input handling; those checks do
|
||||
not execute extracted JavaScript modules. When application runtime behavior is
|
||||
|
|
@ -195,6 +195,37 @@ Keep builds/imports sequential on small hosts; scope `GHIDRA_HEADLESS_MAXMEM`
|
|||
or upgrade Java, Ghidra, .NET or native toolchains. See
|
||||
[the supported layout and provenance](ghidra-nativeaot.md).
|
||||
|
||||
## IDA MCP adapter
|
||||
|
||||
`npm run verify:ida -- --target /absolute/path/to/program --procedure main`
|
||||
uses the existing `REA_IDA_MCP_CONFIG` registration. It installs no engine,
|
||||
Python package, or compiler. The target must already be open in the GUI for
|
||||
the legacy attached profile; the database-supervisor headless profile opens
|
||||
a digest-verified private copy. A caller-supplied fixture keeps prerequisites
|
||||
limited to the selected engine and host. `tests/conformance/ida/inventory.c`
|
||||
provides an optional small native fixture source with an exported
|
||||
`rea_fixture_add` function.
|
||||
|
||||
The lane invokes the production CLI dispatcher and connects the pinned MCP
|
||||
client SDK to the production REA server. It verifies function Evidence and
|
||||
CLI/MCP parity, inventory/search, pseudocode, instructions, xrefs, malformed
|
||||
input, original-input preservation, and lifecycle cleanup. For headless
|
||||
analysis it confirms that the owned database IDs disappear from upstream
|
||||
discovery and private workspaces are removed. For attached analysis it confirms
|
||||
the existing GUI target remains reachable with the same input identity.
|
||||
`--package-root` selects an installed/extracted REA artifact. `--report` writes
|
||||
private local observations with mode `0600`; the console summary contains no
|
||||
target paths or upstream output.
|
||||
|
||||
Adapter and composition tests cover producer parsing, pagination, canonical
|
||||
entries, external callees, target switches, cancellation draining, snapshot
|
||||
replay exclusion, ownership failures, and incomplete cleanup. They do not
|
||||
establish real IDA operation. The initial real workflows cover legacy upstream
|
||||
1.4.0 on a Windows GUI and the modern supervisor at upstream commit
|
||||
`c133c3853faa111a9b00ee615c013b720d0c4acd` with Windows x64 IDA 9.3.
|
||||
Linux/macOS headless, modern attached GUI tools, other engine versions and
|
||||
architectures remain unverified; see the [provider guide](ida-provider.md).
|
||||
|
||||
## DOS Ghidra analysis
|
||||
|
||||
`npm run verify:ghidra:dos` requires the supported Ghidra and JDK installation
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"manifest_id": "ecm_ffc0fa54e6d99b09a87fb90cb629015b0f7dfc62bcd44c3ffdef562ed2658426",
|
||||
"manifest_id": "ecm_fbd47cbd3815d7e252ebcb60c4d086dcc1fc6b63f020e83513e28bf4d94c732c",
|
||||
"verifier": {
|
||||
"id": "rea-managed-conformance",
|
||||
"version": "1"
|
||||
|
|
@ -13,7 +13,7 @@
|
|||
"skill_digests": [
|
||||
{
|
||||
"skill_id": "reverse-engineer-anything",
|
||||
"sha256": "14e42a5e3de32cc288709f3ecf92c5acf90e59a798f47313062c8f2dcf39e7ba"
|
||||
"sha256": "b317babebb82157b46b4dff698a170045490bcb21aaf9af582e20908ba2bb998"
|
||||
}
|
||||
],
|
||||
"claims": [
|
||||
|
|
|
|||
|
|
@ -123,6 +123,7 @@
|
|||
"verify:native-ui": "npm run build:cached && node scripts/verify-native-ui.mjs",
|
||||
"verify:keyed-archive": "npm run build:cached && node scripts/verify-keyed-archive.mjs",
|
||||
"verify:ghidra": "npm run build:cached && node scripts/verify-real-ghidra.mjs",
|
||||
"verify:ida": "npm run build:cached && node scripts/verify-real-ida.mjs",
|
||||
"verify:ghidra:switch": "npm run build:cached && node scripts/verify-real-ghidra-switch.mjs",
|
||||
"verify:ghidra:dos": "npm run build:cached && node scripts/verify-real-ghidra-dos.mjs",
|
||||
"verify:ghidra:com": "npm run build:cached && node scripts/verify-real-ghidra-com.mjs",
|
||||
|
|
|
|||
|
|
@ -106,6 +106,10 @@ export const providerCatalog = (sources) => {
|
|||
identity: sources.ghidraProvider.GHIDRA_PROVIDER_IDENTITY,
|
||||
contracts: sources.ghidraProvider.GHIDRA_PROVIDER_TOOL_CONTRACTS,
|
||||
},
|
||||
{
|
||||
identity: sources.idaProvider.IDA_PROVIDER_IDENTITY,
|
||||
contracts: sources.idaProvider.IDA_PROVIDER_TOOL_CONTRACTS,
|
||||
},
|
||||
{
|
||||
identity: sources.nativeProvider.NATIVE_MACOS_PROVIDER_IDENTITY,
|
||||
contracts: sources.nativeContracts.NATIVE_TOOL_CONTRACTS,
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ const SOURCE_PATHS = {
|
|||
supportedClients: "dist/application/SupportedClients.js",
|
||||
hopperProvider: "dist/hopper/HopperProvider.js",
|
||||
ghidraProvider: "dist/ghidra/GhidraProvider.js",
|
||||
idaProvider: "dist/ida/IdaProviderCapabilities.js",
|
||||
nativeProvider: "dist/native/NativeMacOSProvider.js",
|
||||
artifactProviders: "dist/application/InvestigationProviders.js",
|
||||
browserProvider: "dist/browser/CdpBrowserProvider.js",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,256 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { execFile } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { createReadStream } from "node:fs";
|
||||
import { access, writeFile } from "node:fs/promises";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { parseArgs, promisify } from "node:util";
|
||||
import { Client } from "@modelcontextprotocol/client";
|
||||
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
|
||||
|
||||
const { values } = parseArgs({
|
||||
options: {
|
||||
target: { type: "string" },
|
||||
procedure: { type: "string", default: "rea_fixture_add" },
|
||||
"package-root": { type: "string" },
|
||||
report: { type: "string" },
|
||||
},
|
||||
});
|
||||
if (values.target === undefined || process.env.REA_IDA_MCP_CONFIG === undefined)
|
||||
throw new Error(
|
||||
"IDA verification requires --target and REA_IDA_MCP_CONFIG; see docs/ida-provider.md. No toolchain is installed by this lane.",
|
||||
);
|
||||
const packageRoot = resolve(
|
||||
values["package-root"] ?? fileURLToPath(new URL("..", import.meta.url)),
|
||||
);
|
||||
const target = resolve(values.target);
|
||||
const entry = join(packageRoot, "scripts", "rea.mjs");
|
||||
const importBuilt = (path) =>
|
||||
import(pathToFileURL(join(packageRoot, "dist", path)).href);
|
||||
const [
|
||||
{ toolContract },
|
||||
{ evidenceSchema },
|
||||
{ functionDossierSchema },
|
||||
{ readIdaConfiguration },
|
||||
{ createIdaMcpConnection },
|
||||
] = await Promise.all([
|
||||
importBuilt("contracts/toolContracts.js"),
|
||||
importBuilt("domain/evidence.js"),
|
||||
importBuilt("domain/hopperValues.js"),
|
||||
importBuilt("ida/IdaConfiguration.js"),
|
||||
importBuilt("ida/IdaMcpConnection.js"),
|
||||
]);
|
||||
const registration = readIdaConfiguration(process.env.REA_IDA_MCP_CONFIG);
|
||||
if (!registration.ok) throw registration.error;
|
||||
const config = registration.value;
|
||||
const environment = Object.fromEntries(
|
||||
Object.entries({
|
||||
...process.env,
|
||||
REA_ANALYSIS_PROVIDER: "ida",
|
||||
REA_LOG_LEVEL: "error",
|
||||
}).filter(([, value]) => value !== undefined),
|
||||
);
|
||||
const report = {
|
||||
passed: false,
|
||||
mode: config.mode,
|
||||
checks: [],
|
||||
observations: [],
|
||||
};
|
||||
let phase = "target admission";
|
||||
const workspaces = new Set();
|
||||
const databases = new Set();
|
||||
const digest = async () => {
|
||||
const hash = createHash("sha256");
|
||||
for await (const chunk of createReadStream(target)) hash.update(chunk);
|
||||
return hash.digest("hex");
|
||||
};
|
||||
const observe = (input) => {
|
||||
const evidence = evidenceSchema.parse(input);
|
||||
assert.equal(evidence.provider.id, "ida");
|
||||
assert.equal(evidence.provider.version, "1");
|
||||
assert.equal(evidence.analysis_profile.parameters.cache_policy, "live");
|
||||
assert.equal(evidence.analysis_profile.parameters.engine_version, null);
|
||||
assert.ok(evidence.limitations.length > 0);
|
||||
assert.ok(
|
||||
evidence.locations.some((location) => location.kind === "artifact-path"),
|
||||
);
|
||||
if (config.mode === "headless") {
|
||||
assert.notEqual(evidence.raw_result.metadata_before.input_path, target);
|
||||
workspaces.add(dirname(evidence.raw_result.metadata_before.input_path));
|
||||
for (const observation of evidence.raw_result.observations) {
|
||||
assert.equal(typeof observation.arguments.database, "string");
|
||||
databases.add(observation.arguments.database);
|
||||
}
|
||||
}
|
||||
report.observations.push(evidence);
|
||||
return evidence.normalized_result;
|
||||
};
|
||||
const cli = async () => {
|
||||
phase = "production CLI function analysis";
|
||||
const { stdout } = await promisify(execFile)(
|
||||
process.execPath,
|
||||
[
|
||||
entry,
|
||||
"function",
|
||||
target,
|
||||
values.procedure,
|
||||
"--provider",
|
||||
"ida",
|
||||
"--json",
|
||||
],
|
||||
{
|
||||
env: environment,
|
||||
timeout: config.timeoutMs * 3,
|
||||
maxBuffer: 16 * 1024 * 1024,
|
||||
},
|
||||
).catch((cause) => {
|
||||
report.failed_cli = { stdout: cause.stdout, stderr: cause.stderr };
|
||||
throw cause;
|
||||
});
|
||||
const dossier = functionDossierSchema.parse(observe(JSON.parse(stdout)));
|
||||
assert.ok(dossier.pseudocode.length > 0);
|
||||
assert.ok(dossier.assembly.length > 0);
|
||||
assert.equal(dossier.procedure.body.available, false);
|
||||
report.checks.push("production CLI and complete function Evidence");
|
||||
return dossier;
|
||||
};
|
||||
const mcp = async (cliDossier) => {
|
||||
const client = new Client({ name: "rea-real-ida-verifier", version: "1" });
|
||||
const transport = new StdioClientTransport({
|
||||
command: process.execPath,
|
||||
args: [entry, "mcp"],
|
||||
env: environment,
|
||||
stderr: "pipe",
|
||||
});
|
||||
transport.stderr?.on("data", () => undefined);
|
||||
const call = async (name, args) => {
|
||||
phase = `production MCP ${name}`;
|
||||
const result = await client.callTool(
|
||||
{ name, arguments: args },
|
||||
{ timeout: config.timeoutMs * 2 },
|
||||
);
|
||||
if (result.isError === true) report.failed_tool_result = result;
|
||||
assert.notEqual(result.isError, true);
|
||||
return toolContract(name).outputSchema.parse(result.structuredContent);
|
||||
};
|
||||
let opened = false;
|
||||
try {
|
||||
await client.connect(transport, { timeout: config.timeoutMs });
|
||||
await call("open_binary", { path: target, provider_id: "ida" });
|
||||
opened = true;
|
||||
const analyzed = await call("analyze_function", {
|
||||
procedure: values.procedure,
|
||||
});
|
||||
const dossier = functionDossierSchema.parse(observe(analyzed.evidence));
|
||||
assert.deepEqual(dossier.procedure, cliDossier.procedure);
|
||||
assert.equal(dossier.pseudocode, cliDossier.pseudocode);
|
||||
assert.deepEqual(dossier.assembly, cliDossier.assembly);
|
||||
assert.equal(analyzed.evidence_id, analyzed.evidence.evidence_id);
|
||||
for (const [name, args] of [
|
||||
["list_procedures", {}],
|
||||
["list_strings", {}],
|
||||
["procedure_address", { procedure: dossier.procedure.name }],
|
||||
["procedure_assembly", { procedure: dossier.procedure.address }],
|
||||
["procedure_callees", { procedure: dossier.procedure.address }],
|
||||
...(config.mode === "attached"
|
||||
? [["procedure_callers", { procedure: dossier.procedure.address }]]
|
||||
: []),
|
||||
["search_procedures", { pattern: dossier.procedure.name }],
|
||||
["procedure_pseudo_code", { procedure: dossier.procedure.address }],
|
||||
["read_function_instructions", { procedure: dossier.procedure.address }],
|
||||
["search_strings", { pattern: "a.b", mode: "literal" }],
|
||||
["xrefs", { address: dossier.procedure.address }],
|
||||
]) {
|
||||
const result = await call(name, args);
|
||||
observe(result.evidence);
|
||||
}
|
||||
const invalid = await client.callTool({
|
||||
name: "xrefs",
|
||||
arguments: { address: "not-an-address" },
|
||||
});
|
||||
assert.equal(invalid.isError, true);
|
||||
assert.equal(invalid.structuredContent.error.code, "invalid_request");
|
||||
await call("close_binary", {});
|
||||
opened = false;
|
||||
report.checks.push(
|
||||
"one MCP connection, target selection, analysis/search/xrefs, invalid input, and close",
|
||||
);
|
||||
report.checks.push(
|
||||
"CLI/MCP function identity, pseudocode, assembly, and raw Evidence parity",
|
||||
);
|
||||
} finally {
|
||||
const completedPhase = phase;
|
||||
if (opened) await call("close_binary", {});
|
||||
await client.close();
|
||||
phase = completedPhase;
|
||||
}
|
||||
};
|
||||
const cleanup = async () => {
|
||||
phase = "upstream lifecycle verification";
|
||||
const upstream = createIdaMcpConnection(config);
|
||||
try {
|
||||
await upstream.connect();
|
||||
if (config.mode === "attached") {
|
||||
const metadata = await upstream.call("get_metadata", {});
|
||||
assert.equal(metadata.sha256.toLowerCase(), await digest());
|
||||
report.checks.push(
|
||||
"existing GUI target remains open with the original input identity",
|
||||
);
|
||||
} else {
|
||||
const inventory = await upstream.call("idb_list", {});
|
||||
assert.ok(
|
||||
inventory.sessions.every(
|
||||
(session) => !databases.has(session.session_id),
|
||||
),
|
||||
);
|
||||
for (const workspace of workspaces)
|
||||
await assert.rejects(access(workspace), { code: "ENOENT" });
|
||||
report.checks.push(
|
||||
"owned database IDs absent from upstream discovery and private workspaces removed",
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await upstream.close();
|
||||
}
|
||||
};
|
||||
try {
|
||||
const before = await digest();
|
||||
await mcp(await cli());
|
||||
await cleanup();
|
||||
assert.equal(await digest(), before);
|
||||
report.checks.push("original binary unchanged and never executed");
|
||||
report.passed = true;
|
||||
} catch (cause) {
|
||||
report.failure = {
|
||||
phase,
|
||||
message: cause instanceof Error ? cause.message : String(cause),
|
||||
};
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
if (values.report !== undefined)
|
||||
await writeFile(
|
||||
resolve(values.report),
|
||||
`${JSON.stringify(report, null, 2)}\n`,
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
passed: report.passed,
|
||||
mode: report.mode,
|
||||
checks: report.checks,
|
||||
...(report.passed
|
||||
? {}
|
||||
: {
|
||||
failed_phase: phase,
|
||||
detail: "Use --report for private local diagnostics.",
|
||||
}),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
name: reverse-engineer-anything
|
||||
description: Reverse engineer native, managed, Electron/JavaScript, packaged, firmware, and browser targets with REA. Use shipped-artifact or requested runtime evidence to explain features, compare versions, decompile code, or guide a reconstruction. Skip REA for ordinary source-repository architecture analysis.
|
||||
metadata:
|
||||
version: "25"
|
||||
version: "26"
|
||||
tool_count: 125
|
||||
catalog_digest: "ed9c36900e9223561d38ba30a44921f4ff101d5eca989920f9edc9dc91df470b"
|
||||
---
|
||||
|
|
@ -53,7 +53,7 @@ When tools are absent or registration is stale:
|
|||
in the session, then resume the original investigation. If they remain
|
||||
absent, inspect the client's MCP launch error rather than repeating setup.
|
||||
|
||||
REA setup never installs or upgrades Node.js, npm, Homebrew, Java, Ghidra,
|
||||
REA setup never installs or upgrades Node.js, npm, Homebrew, Java, Ghidra, IDA,
|
||||
JADX, Binwalk, or Unblob. Use existing prerequisites; do not install unrelated
|
||||
software to repair
|
||||
MCP registration. For an unsupported client, use manual stdio registration
|
||||
|
|
@ -96,7 +96,19 @@ explicit path or endpoint and do not need it.
|
|||
- User-owned Electron runtime already open: `list_electron_targets`.
|
||||
- Native executable, library, or analysis database: `open_binary`, then
|
||||
use focused analysis tools directly; call `binary_overview` when metadata or
|
||||
inventory context is useful.
|
||||
inventory context is useful and available from the selected provider.
|
||||
|
||||
For an existing IDA MCP configuration, use `open_binary` with the original
|
||||
input binary and `provider_id: "ida"`; do not pass an `.idb` or `.i64` database.
|
||||
The legacy attached profile binds the already-open GUI input by SHA-256 and
|
||||
leaves its database open. The modern headless profile creates and closes an
|
||||
owned private database without saving. Installation and registration reuse
|
||||
[mrexodia/ida-pro-mcp](https://github.com/mrexodia/ida-pro-mcp); see the
|
||||
[IDA provider guide](https://github.com/morluto/rea/blob/main/docs/ida-provider.md).
|
||||
Proceed directly to `analyze_function`, pseudocode, or function/string searches
|
||||
after opening; IDA does not supply `binary_overview`. Consult session availability
|
||||
when composing broader workflows. Modern direct callers and unsupported dossier
|
||||
facets remain unknown; live IDA results are not replayed from snapshots.
|
||||
|
||||
If the app is missing, ask which app to inspect. Resolve a human-readable app
|
||||
name to one clear installed artifact when possible; ask only when matches are
|
||||
|
|
|
|||
|
|
@ -159,6 +159,8 @@ type CapabilityAvailability =
|
|||
};
|
||||
|
||||
export type CapabilityDescriptor = CapabilityAvailability & {
|
||||
/** Live external state must be re-observed, including after snapshot import. */
|
||||
readonly cachePolicy?: "snapshot" | "live";
|
||||
readonly provider: ProviderIdentity;
|
||||
readonly operation: Exclude<AnalysisOperation, "health">;
|
||||
readonly effects: CapabilityEffects;
|
||||
|
|
|
|||
|
|
@ -68,6 +68,7 @@ export const isSnapshotCacheable = (
|
|||
(!CURSOR_DEFAULT_OPERATIONS.has(operation) ||
|
||||
typeof parameters.address === "string") &&
|
||||
descriptor?.effects.mutatesArtifact === false &&
|
||||
descriptor.cachePolicy !== "live" &&
|
||||
descriptor.effects.mayWriteFilesystem === false &&
|
||||
descriptor.effects.changesPermissions === false;
|
||||
|
||||
|
|
|
|||
|
|
@ -114,6 +114,16 @@ export class BinarySession
|
|||
);
|
||||
}
|
||||
|
||||
/** External live state cannot be replayed by a direct or composed CLI workflow. */
|
||||
allowsSnapshotReplay(operation: AnalysisOperation): boolean {
|
||||
const capabilities = this.#active?.route.capabilities;
|
||||
const descriptor = capabilities?.get(operation);
|
||||
if (descriptor !== undefined) return descriptor.cachePolicy !== "live";
|
||||
return ![...(capabilities?.values() ?? [])].some(
|
||||
({ cachePolicy }) => cachePolicy === "live",
|
||||
);
|
||||
}
|
||||
|
||||
/** Return the selected immutable profile, optionally scoped to an operation. */
|
||||
analysisProfile(
|
||||
operation?: AnalysisOperation,
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import type { ExecutableFormatHint } from "../domain/dosCom.js";
|
|||
import { parseConfig } from "../config.js";
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import { EnhancedTools } from "./EnhancedTools.js";
|
||||
import { executeFunctionAnalysisEvidence } from "./FunctionAnalysisEvidence.js";
|
||||
import { createBinarySession, createManagedBinarySession } from "./runtime.js";
|
||||
import { silentLogger, type Logger } from "../logger.js";
|
||||
import { createEvidence } from "../domain/evidence.js";
|
||||
|
|
@ -201,7 +202,7 @@ const runAnalysis = async (
|
|||
const evidenceProfile = analysisProfileForEvidence(session, tool);
|
||||
const bindingProfile = session.analysisProfile();
|
||||
if (
|
||||
tool !== "trace_native_ui_action" &&
|
||||
canReplayDirectTool(session, tool) &&
|
||||
snapshot !== undefined &&
|
||||
evidenceProfile !== undefined &&
|
||||
bindingProfile !== undefined
|
||||
|
|
@ -245,6 +246,12 @@ const runAnalysis = async (
|
|||
}
|
||||
};
|
||||
|
||||
const canReplayDirectTool = (
|
||||
session: ReturnType<typeof createBinarySession>,
|
||||
tool: Parameters<typeof runAnalysis>[1],
|
||||
): boolean =>
|
||||
tool !== "trace_native_ui_action" && session.allowsSnapshotReplay(tool);
|
||||
|
||||
const executeAnalysisTool = async (input: {
|
||||
readonly session: ReturnType<typeof createBinarySession>;
|
||||
readonly openedTarget: BinaryTarget;
|
||||
|
|
@ -258,9 +265,19 @@ const executeAnalysisTool = async (input: {
|
|||
readonly evidenceProfile: AnalysisProfileCommitment | undefined;
|
||||
}): Promise<{ readonly output: JsonValue; readonly evidence?: Evidence }> => {
|
||||
const { session, tool, signal, evidenceProfile } = input;
|
||||
if (tool === "analyze_function") {
|
||||
const result = await executeFunctionAnalysisEvidence(
|
||||
session,
|
||||
input.arguments,
|
||||
input.openedTarget,
|
||||
{ signal },
|
||||
);
|
||||
return result.ok
|
||||
? { output: result.value, evidence: result.value }
|
||||
: { output: cliError(result.error) };
|
||||
}
|
||||
if (
|
||||
tool === "binary_overview" ||
|
||||
tool === "analyze_function" ||
|
||||
tool === "inspect_native_api" ||
|
||||
tool === "inspect_native_dispatch_metadata" ||
|
||||
tool === "trace_feature" ||
|
||||
|
|
@ -273,22 +290,16 @@ const executeAnalysisTool = async (input: {
|
|||
signal,
|
||||
);
|
||||
if (!result.ok) return { output: cliError(result.error) };
|
||||
const evidence = createEvidence(
|
||||
input.openedTarget,
|
||||
tool === "analyze_function"
|
||||
? session.providerIdentity(tool)
|
||||
: REA_WORKFLOW_PROVIDER,
|
||||
{
|
||||
operation: tool,
|
||||
parameters: input.arguments,
|
||||
result: result.value,
|
||||
...(evidenceProfile === undefined
|
||||
? {}
|
||||
: { analysisProfile: evidenceProfile }),
|
||||
confidence: "derived",
|
||||
limitations: ["Derived by an REA composed workflow."],
|
||||
},
|
||||
);
|
||||
const evidence = createEvidence(input.openedTarget, REA_WORKFLOW_PROVIDER, {
|
||||
operation: tool,
|
||||
parameters: input.arguments,
|
||||
result: result.value,
|
||||
...(evidenceProfile === undefined
|
||||
? {}
|
||||
: { analysisProfile: evidenceProfile }),
|
||||
confidence: "derived",
|
||||
limitations: ["Derived by an REA composed workflow."],
|
||||
});
|
||||
return { output: evidence, evidence };
|
||||
}
|
||||
const result = await session.execute(tool, input.arguments, { signal });
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ import {
|
|||
} from "./DoctorScope.js";
|
||||
|
||||
/** Deep provider IDs whose readiness can be selected explicitly by doctor. */
|
||||
export const DOCTOR_PROVIDER_IDS = ["hopper", "ghidra"] as const;
|
||||
export const DOCTOR_PROVIDER_IDS = ["hopper", "ghidra", "ida"] as const;
|
||||
/** One actionable environment diagnostic. */
|
||||
export interface DoctorCheck {
|
||||
readonly name: string;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,54 @@
|
|||
import type {
|
||||
AnalysisOperationPort,
|
||||
ExecutionOptions,
|
||||
} from "./AnalysisProvider.js";
|
||||
import { toolContract } from "../contracts/toolContracts.js";
|
||||
import { AnalysisInputError } from "../domain/analysisErrorCore.js";
|
||||
import type { BinaryTarget } from "../domain/binaryTarget.js";
|
||||
import { createEvidence } from "../domain/evidence.js";
|
||||
import { parseFunctionDossier } from "../domain/hopperValues.js";
|
||||
import { jsonObjectSchema, type JsonValue } from "../domain/jsonValue.js";
|
||||
import { err, ok } from "../domain/result.js";
|
||||
import { projectInputIssues } from "../domain/inputIssueProjection.js";
|
||||
|
||||
/** Preserve the provider's observations when presenting a validated function dossier. */
|
||||
export const executeFunctionAnalysisEvidence = async (
|
||||
analysis: AnalysisOperationPort,
|
||||
parameters: Readonly<Record<string, JsonValue>>,
|
||||
target: BinaryTarget | undefined,
|
||||
options?: ExecutionOptions,
|
||||
) => {
|
||||
const input =
|
||||
toolContract("analyze_function").inputSchema.safeParse(parameters);
|
||||
if (!input.success)
|
||||
return err(
|
||||
new AnalysisInputError(
|
||||
"analyze_function",
|
||||
{ cause: input.error },
|
||||
projectInputIssues(input.error.issues, parameters),
|
||||
),
|
||||
);
|
||||
const arguments_ = jsonObjectSchema.parse(input.data);
|
||||
const execution = await analysis.execute(
|
||||
"analyze_function",
|
||||
arguments_,
|
||||
options,
|
||||
);
|
||||
if (!execution.ok) return execution;
|
||||
const dossier = parseFunctionDossier(execution.value.result);
|
||||
if (!dossier.ok) return dossier;
|
||||
const observation = execution.value;
|
||||
return ok(
|
||||
createEvidence(observation.subject ?? target, observation.provider, {
|
||||
operation: "analyze_function",
|
||||
parameters: arguments_,
|
||||
result: dossier.value,
|
||||
rawResult: observation.rawResult,
|
||||
limitations: observation.limitations,
|
||||
locations: observation.locations,
|
||||
...(observation.analysisProfile === undefined
|
||||
? {}
|
||||
: { analysisProfile: observation.analysisProfile }),
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
|
@ -2,6 +2,7 @@ import type { AppConfig } from "../config.js";
|
|||
import type { BinarySession } from "./BinarySession.js";
|
||||
import { HopperProvider } from "../hopper/HopperProvider.js";
|
||||
import { GhidraProvider } from "../ghidra/GhidraProvider.js";
|
||||
import { IdaProvider } from "../ida/IdaProvider.js";
|
||||
import { silentLogger, type Logger } from "../logger.js";
|
||||
import { GENERATED_AUXILIARY_PROVIDERS } from "../generatedMcpToolCatalog.js";
|
||||
import { AnalysisProviderRegistry } from "./AnalysisProviderRegistry.js";
|
||||
|
|
@ -22,6 +23,7 @@ export const createBinarySession = (
|
|||
): BinarySession => {
|
||||
const hopper = new HopperProvider(config, logger);
|
||||
const ghidra = new GhidraProvider(config, logger);
|
||||
const ida = new IdaProvider(config);
|
||||
const auxiliary = new Map(
|
||||
GENERATED_AUXILIARY_PROVIDERS.map((provider) => [
|
||||
provider.identity.id,
|
||||
|
|
@ -42,7 +44,10 @@ export const createBinarySession = (
|
|||
});
|
||||
};
|
||||
return composeBinarySession(
|
||||
new AnalysisProviderRegistry([hopper, ghidra], config.analysisProvider),
|
||||
new AnalysisProviderRegistry(
|
||||
[hopper, ghidra, ida],
|
||||
config.analysisProvider,
|
||||
),
|
||||
[
|
||||
lazyProvider("rea-artifact-graph", async () => {
|
||||
const { ArtifactProvider } =
|
||||
|
|
|
|||
|
|
@ -28,6 +28,16 @@ describe("runtime configuration", () => {
|
|||
});
|
||||
|
||||
it("parses one shared provider selector and rejects unstable IDs", () => {
|
||||
expect(
|
||||
parseConfig({
|
||||
REA_ANALYSIS_PROVIDER: "ida",
|
||||
REA_IDA_MCP_CONFIG: "/tmp/ida-mcp.json",
|
||||
}),
|
||||
).toMatchObject({
|
||||
ok: true,
|
||||
value: { analysisProvider: "ida", idaMcpConfigPath: "/tmp/ida-mcp.json" },
|
||||
});
|
||||
expect(parseConfig({ REA_IDA_MCP_CONFIG: "relative.json" }).ok).toBe(false);
|
||||
expect(parseConfig({ REA_ANALYSIS_PROVIDER: "ghidra" })).toMatchObject({
|
||||
ok: true,
|
||||
value: { analysisProvider: "ghidra" },
|
||||
|
|
|
|||
|
|
@ -7,6 +7,11 @@ import { analysisProviderSelectorSchema } from "../contracts/providerSelection.j
|
|||
|
||||
const environmentSchema = z.object({
|
||||
REA_ANALYSIS_PROVIDER: analysisProviderSelectorSchema.default("auto"),
|
||||
REA_IDA_MCP_CONFIG: z
|
||||
.string()
|
||||
.min(1)
|
||||
.refine(isAbsolute, "REA_IDA_MCP_CONFIG must be absolute")
|
||||
.optional(),
|
||||
GHIDRA_INSTALL_DIR: z
|
||||
.string()
|
||||
.min(1)
|
||||
|
|
|
|||
|
|
@ -25,6 +25,9 @@ export const parseConfig = (
|
|||
if (!secretPatterns.ok) return secretPatterns;
|
||||
return ok({
|
||||
analysisProvider: env.REA_ANALYSIS_PROVIDER,
|
||||
...(env.REA_IDA_MCP_CONFIG === undefined
|
||||
? {}
|
||||
: { idaMcpConfigPath: env.REA_IDA_MCP_CONFIG }),
|
||||
ghidraInstallDir: env.GHIDRA_INSTALL_DIR,
|
||||
ghidraJavaHome: env.JAVA_HOME,
|
||||
...(env.REA_GHIDRA_NATIVEAOT_JAR === undefined
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import type { AnalysisProviderSelector } from "../contracts/providerSelection.js
|
|||
|
||||
export interface AppConfig {
|
||||
readonly analysisProvider: AnalysisProviderSelector;
|
||||
readonly idaMcpConfigPath?: string;
|
||||
readonly ghidraInstallDir: string | undefined;
|
||||
readonly ghidraJavaHome: string | undefined;
|
||||
readonly ghidraNativeAotJar?: string;
|
||||
|
|
|
|||
|
|
@ -5,12 +5,16 @@ import {
|
|||
} from "./application/Doctor.js";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { inspectSystemGhidraProvider } from "./ghidra/GhidraDoctor.js";
|
||||
import { inspectIdaRegistration } from "./ida/IdaDoctor.js";
|
||||
import { selectLinuxPrivateDisplayStrategy } from "./hopper/LinuxPrivateDisplayProbe.js";
|
||||
|
||||
/** Compose provider diagnostics at the outer CLI adapter boundary. */
|
||||
export const createSystemDoctorHost = (): DoctorHost =>
|
||||
systemDoctorHost({
|
||||
providerInspections: async () => [await inspectSystemGhidraProvider()],
|
||||
providerInspections: async () => [
|
||||
await inspectSystemGhidraProvider(),
|
||||
inspectIdaRegistration(process.env.REA_IDA_MCP_CONFIG),
|
||||
],
|
||||
linuxDemoRuntimeCheck: inspectLinuxPrivateDisplay,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -5,5 +5,5 @@ export const PACKAGE_METADATA = {
|
|||
serverSdkVersion: "2.3.1",
|
||||
clientSdkVersion: "2.3.1",
|
||||
coreSdkVersion: "2.3.1",
|
||||
skillVersion: "25",
|
||||
skillVersion: "26",
|
||||
} as const;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,89 @@
|
|||
import { rm, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { createIdaTarget } from "../../tests/fixtures/idaMcp.js";
|
||||
import { readIdaConfiguration } from "./IdaConfiguration.js";
|
||||
import { decodeIdaToolResult } from "./IdaMcpConnection.js";
|
||||
|
||||
const roots: string[] = [];
|
||||
afterEach(async () => {
|
||||
for (const root of roots.splice(0))
|
||||
await rm(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("IDA registration and MCP result boundaries", () => {
|
||||
it("reuses a direct or upstream mcpServers registration with no installation", async () => {
|
||||
const { root } = await createIdaTarget();
|
||||
roots.push(root);
|
||||
const path = join(root, "mcp.json");
|
||||
for (const input of [
|
||||
{ command: "python", args: ["server.py"] },
|
||||
{
|
||||
mcpServers: {
|
||||
"ida-pro-mcp": { command: "python", args: ["server.py"] },
|
||||
unrelated: { command: "untouched" },
|
||||
},
|
||||
},
|
||||
]) {
|
||||
await writeFile(path, JSON.stringify(input));
|
||||
expect(readIdaConfiguration(path)).toMatchObject({
|
||||
ok: true,
|
||||
value: { command: "python", args: ["server.py"], mode: "attached" },
|
||||
});
|
||||
}
|
||||
});
|
||||
it("validates local transport and lifecycle inputs without leaking auth into a profile", async () => {
|
||||
const { root } = await createIdaTarget();
|
||||
roots.push(root);
|
||||
const path = join(root, "mcp.json");
|
||||
for (const input of [
|
||||
{ url: "https://example.com/mcp" },
|
||||
{ url: "http://token@127.0.0.1/mcp" },
|
||||
{ command: "python", mode: "force_gui" },
|
||||
{ command: "python", workspaceRoot: "relative" },
|
||||
{ command: "python", args: "server.py" },
|
||||
]) {
|
||||
await writeFile(path, JSON.stringify(input));
|
||||
expect(readIdaConfiguration(path).ok).toBe(false);
|
||||
}
|
||||
await writeFile(
|
||||
path,
|
||||
JSON.stringify({
|
||||
url: "http://127.0.0.1:8745/mcp",
|
||||
mode: "headless",
|
||||
headers: { Authorization: "Bearer fixture-secret" },
|
||||
}),
|
||||
);
|
||||
expect(readIdaConfiguration(path).ok).toBe(true);
|
||||
await writeFile(path, "malformed");
|
||||
expect(readIdaConfiguration(path).ok).toBe(false);
|
||||
expect(readIdaConfiguration(join(root, "absent")).ok).toBe(false);
|
||||
});
|
||||
it("parses FastMCP wrappers, structured dictionaries, text, arrays, and explicit errors", () => {
|
||||
expect(
|
||||
decodeIdaToolResult({
|
||||
content: [],
|
||||
structuredContent: { result: [{ address: "0x1" }] },
|
||||
}),
|
||||
).toEqual([{ address: "0x1" }]);
|
||||
expect(
|
||||
decodeIdaToolResult({
|
||||
content: [],
|
||||
structuredContent: { address: "0x1", name: "f" },
|
||||
}),
|
||||
).toEqual({ address: "0x1", name: "f" });
|
||||
expect(
|
||||
decodeIdaToolResult({ content: [{ type: "text", text: "int f() {}" }] }),
|
||||
).toBe("int f() {}");
|
||||
expect(
|
||||
decodeIdaToolResult({ content: [{ type: "text", text: '{"data":[]}' }] }),
|
||||
).toEqual({ data: [] });
|
||||
expect(() =>
|
||||
decodeIdaToolResult({
|
||||
content: [{ type: "text", text: "No function at 0x1" }],
|
||||
isError: true,
|
||||
}),
|
||||
).toThrow("No function at 0x1");
|
||||
expect(() => decodeIdaToolResult({ content: "invalid" })).toThrow();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
import { readFileSync, statSync } from "node:fs";
|
||||
import { isAbsolute } from "node:path";
|
||||
import { z } from "zod";
|
||||
import { ConfigurationError } from "../domain/configurationErrors.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
|
||||
const common = {
|
||||
mode: z.enum(["attached", "headless"]).default("attached"),
|
||||
timeoutMs: z.number().int().positive().max(2_147_483_647).default(300_000),
|
||||
workspaceRoot: z
|
||||
.string()
|
||||
.refine(isAbsolute, "workspaceRoot must be absolute on the REA host")
|
||||
.optional(),
|
||||
};
|
||||
const registrationSchema = z.union([
|
||||
z
|
||||
.object({
|
||||
...common,
|
||||
type: z.literal("stdio").optional(),
|
||||
command: z.string().min(1),
|
||||
args: z.array(z.string()).default([]),
|
||||
env: z.record(z.string(), z.string()).default({}),
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
...common,
|
||||
type: z.literal("http").optional(),
|
||||
url: z.url().refine((value) => {
|
||||
const url = new URL(value);
|
||||
return (
|
||||
["http:", "https:"].includes(url.protocol) &&
|
||||
["localhost", "127.0.0.1", "[::1]"].includes(url.hostname) &&
|
||||
url.username === "" &&
|
||||
url.password === ""
|
||||
);
|
||||
}, "IDA MCP must use a local HTTP endpoint without URL credentials"),
|
||||
headers: z.record(z.string(), z.string()).default({}),
|
||||
})
|
||||
.strict(),
|
||||
]);
|
||||
|
||||
/** An upstream MCP registration plus REA's database lifecycle selection. */
|
||||
export type IdaConfiguration = z.infer<typeof registrationSchema>;
|
||||
|
||||
/** Read one explicit registration without starting or installing a provider. */
|
||||
export const readIdaConfiguration = (
|
||||
path: string,
|
||||
): Result<IdaConfiguration, ConfigurationError> => {
|
||||
try {
|
||||
if (statSync(path).size > 65_536)
|
||||
return err(
|
||||
new ConfigurationError(
|
||||
"REA_IDA_MCP_CONFIG exceeds the 64 KiB registration size.",
|
||||
),
|
||||
);
|
||||
const input: unknown = JSON.parse(readFileSync(path, "utf8"));
|
||||
const envelope = z
|
||||
.object({ mcpServers: z.object({ "ida-pro-mcp": z.unknown() }) })
|
||||
.safeParse(input);
|
||||
const parsed = registrationSchema.safeParse(
|
||||
envelope.success ? envelope.data.mcpServers["ida-pro-mcp"] : input,
|
||||
);
|
||||
return parsed.success
|
||||
? ok(parsed.data)
|
||||
: err(
|
||||
new ConfigurationError(
|
||||
"REA_IDA_MCP_CONFIG must contain an IDA MCP command/args or local url registration; see docs/ida-provider.md.",
|
||||
{ cause: parsed.error },
|
||||
),
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
return err(
|
||||
new ConfigurationError(
|
||||
`Cannot read IDA MCP registration ${path}; provide a readable JSON configuration.`,
|
||||
{ cause },
|
||||
),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, expect, it } from "vitest";
|
||||
import { inspectIdaRegistration } from "./IdaDoctor.js";
|
||||
import { providerRegistrationEnvironment } from "../application/SetupRegistrationEnvironment.js";
|
||||
|
||||
const roots: string[] = [];
|
||||
afterEach(async () => {
|
||||
for (const root of roots.splice(0))
|
||||
await rm(root, { recursive: true, force: true });
|
||||
});
|
||||
it("keeps IDA configuration for setup without copying transport credentials or claiming runtime verification", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "rea-ida-doctor-"));
|
||||
roots.push(root);
|
||||
const path = join(root, "registration.json");
|
||||
await writeFile(
|
||||
path,
|
||||
JSON.stringify({
|
||||
url: "http://127.0.0.1:12345/mcp",
|
||||
headers: { Authorization: "Bearer fixture-secret" },
|
||||
mode: "headless",
|
||||
}),
|
||||
);
|
||||
const inspection = inspectIdaRegistration(path);
|
||||
expect(inspection).toMatchObject({
|
||||
configured: true,
|
||||
available: true,
|
||||
providerVersion: null,
|
||||
});
|
||||
expect(inspection.checks[0]?.detail).toContain("no IDA process was launched");
|
||||
expect(providerRegistrationEnvironment([inspection])).toEqual({
|
||||
REA_IDA_MCP_CONFIG: path,
|
||||
});
|
||||
expect(JSON.stringify(inspection)).not.toContain("fixture-secret");
|
||||
await writeFile(path, JSON.stringify({ url: "https://example.com/mcp" }));
|
||||
expect(inspectIdaRegistration(path)).toMatchObject({
|
||||
available: false,
|
||||
registrationEnvironment: {},
|
||||
});
|
||||
expect(inspectIdaRegistration(undefined)).toMatchObject({
|
||||
configured: false,
|
||||
available: false,
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
import type {
|
||||
DoctorProviderCheck,
|
||||
DoctorProviderInspection,
|
||||
} from "../application/Doctor.js";
|
||||
import { readIdaConfiguration } from "./IdaConfiguration.js";
|
||||
import { privateRuntimeRootCapability } from "../process/PrivateRuntimeRoot.js";
|
||||
|
||||
/** Check the registration without launching IDA, and retain only the configuration file reference. */
|
||||
export const inspectIdaRegistration = (
|
||||
path: string | undefined,
|
||||
): DoctorProviderInspection => {
|
||||
const parsed = path === undefined ? undefined : readIdaConfiguration(path);
|
||||
const valid = parsed?.ok === true;
|
||||
const workspaceChecks =
|
||||
parsed?.ok === true && parsed.value.mode === "headless"
|
||||
? [inspectPrivateWorkspace()]
|
||||
: [];
|
||||
return {
|
||||
id: "ida",
|
||||
configured: path !== undefined,
|
||||
available: valid && workspaceChecks.every(({ ok }) => ok),
|
||||
providerVersion: null,
|
||||
registrationEnvironment:
|
||||
valid && path !== undefined ? { REA_IDA_MCP_CONFIG: path } : {},
|
||||
checks: [
|
||||
{
|
||||
name: "registration",
|
||||
ok: valid,
|
||||
code: valid
|
||||
? null
|
||||
: path === undefined
|
||||
? "not_configured"
|
||||
: "invalid_configuration",
|
||||
detail: valid
|
||||
? "IDA MCP registration is valid. Upstream availability, compatibility, and target binding are checked when analysis starts; no IDA process was launched."
|
||||
: parsed !== undefined && !parsed.ok
|
||||
? parsed.error.message
|
||||
: "IDA MCP registration is not configured.",
|
||||
remediation: valid
|
||||
? null
|
||||
: "Set REA_IDA_MCP_CONFIG to your upstream IDA MCP registration JSON; see docs/ida-provider.md.",
|
||||
classification:
|
||||
path === undefined ? "missing_analysis_engine" : "config_drift",
|
||||
},
|
||||
...workspaceChecks,
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
const inspectPrivateWorkspace = (): DoctorProviderCheck => {
|
||||
const privacy = privateRuntimeRootCapability();
|
||||
return {
|
||||
name: "private_workspace",
|
||||
ok: privacy.available,
|
||||
code: privacy.available ? null : "unsupported_host",
|
||||
detail: privacy.available
|
||||
? `Private workspace authority is available (${privacy.proof}).`
|
||||
: privacy.reason,
|
||||
remediation: privacy.available
|
||||
? null
|
||||
: "Use the matching Windows x64 REA package and a writable local NTFS temporary directory.",
|
||||
classification: "unsupported_host",
|
||||
};
|
||||
};
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
import { z } from "zod";
|
||||
import { TOOL_CONTRACTS } from "../contracts/toolContracts.js";
|
||||
import { AnalysisInputError } from "../domain/analysisErrorCore.js";
|
||||
import { jsonObjectSchema, type JsonValue } from "../domain/jsonValue.js";
|
||||
import { err, ok } from "../domain/result.js";
|
||||
import { idaAddressSchema } from "./IdaProtocolValues.js";
|
||||
import { projectInputIssues } from "../domain/inputIssueProjection.js";
|
||||
|
||||
/** Apply the named public contract and IDA's explicit-address boundary before startup. */
|
||||
export const parseIdaInput = (
|
||||
operation: string,
|
||||
parameters: Readonly<Record<string, JsonValue>>,
|
||||
) => {
|
||||
const contract = TOOL_CONTRACTS.find(({ name }) => name === operation);
|
||||
const schema = contract?.inputSchema;
|
||||
if (schema instanceof z.ZodObject) {
|
||||
const shape: Readonly<Record<string, z.ZodType>> = schema.shape;
|
||||
parameters = Object.fromEntries(
|
||||
Object.entries(parameters).filter(
|
||||
([key, value]) =>
|
||||
value !== null || shape[key]?.safeParse(undefined).success !== true,
|
||||
),
|
||||
);
|
||||
}
|
||||
const parsed = (
|
||||
schema instanceof z.ZodObject ? schema.strict() : schema
|
||||
)?.safeParse(parameters);
|
||||
if (parsed?.success !== true)
|
||||
return err(
|
||||
new AnalysisInputError(
|
||||
operation,
|
||||
{ cause: parsed?.error },
|
||||
parsed?.error === undefined
|
||||
? []
|
||||
: projectInputIssues(parsed.error.issues, parameters),
|
||||
),
|
||||
);
|
||||
if (operation === "xrefs" && typeof parameters.address !== "string")
|
||||
return err(
|
||||
new AnalysisInputError(operation, undefined, [
|
||||
{
|
||||
path: ["address"],
|
||||
reason: "missing_argument",
|
||||
message: "IDA requires an explicit hexadecimal address.",
|
||||
},
|
||||
]),
|
||||
);
|
||||
if (
|
||||
parameters.address !== undefined &&
|
||||
!idaAddressSchema.safeParse(parameters.address).success
|
||||
)
|
||||
return err(
|
||||
new AnalysisInputError(operation, undefined, [
|
||||
{
|
||||
path: ["address"],
|
||||
reason: "invalid_format",
|
||||
message: "IDA requires a hexadecimal address.",
|
||||
},
|
||||
]),
|
||||
);
|
||||
if (
|
||||
typeof parameters.procedure === "string" &&
|
||||
parameters.procedure.length === 0
|
||||
)
|
||||
return err(
|
||||
new AnalysisInputError(operation, undefined, [
|
||||
{
|
||||
path: ["procedure"],
|
||||
reason: "invalid_value",
|
||||
message: "Supply a function name or address.",
|
||||
},
|
||||
]),
|
||||
);
|
||||
if (parameters.mode === "regex" && typeof parameters.pattern === "string") {
|
||||
try {
|
||||
new RegExp(
|
||||
parameters.pattern,
|
||||
parameters.case_sensitive === true ? "u" : "iu",
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
return err(
|
||||
new AnalysisInputError(operation, { cause }, [
|
||||
{
|
||||
path: ["pattern"],
|
||||
reason: "invalid_format",
|
||||
message: "Supply a valid regular expression.",
|
||||
},
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
return ok(jsonObjectSchema.parse(parsed.data));
|
||||
};
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
decodeIdaToolResult,
|
||||
redactIdaTransportFailure,
|
||||
} from "./IdaMcpConnection.js";
|
||||
|
||||
describe("upstream IDA tool result decoding", () => {
|
||||
it("redacts configured transport authentication without redacting caller evidence", () => {
|
||||
const error = redactIdaTransportFailure(
|
||||
new Error(
|
||||
"Invalid Authorization value Bearer fixture-secret at /caller/input",
|
||||
),
|
||||
{
|
||||
url: "http://127.0.0.1:12345/mcp",
|
||||
headers: { Authorization: "Bearer fixture-secret" },
|
||||
mode: "attached",
|
||||
timeoutMs: 1000,
|
||||
},
|
||||
);
|
||||
expect(error.message).not.toContain("fixture-secret");
|
||||
expect(error.message).toContain("/caller/input");
|
||||
expect(
|
||||
decodeIdaToolResult({
|
||||
content: [
|
||||
{ type: "text", text: "Caller-selected fixture-secret evidence" },
|
||||
],
|
||||
}),
|
||||
).toBe("Caller-selected fixture-secret evidence");
|
||||
});
|
||||
it("decodes scalar wrappers and legacy text without dropping structured dictionaries", () => {
|
||||
expect(
|
||||
decodeIdaToolResult({
|
||||
content: [],
|
||||
structuredContent: { result: "code" },
|
||||
}),
|
||||
).toBe("code");
|
||||
expect(
|
||||
decodeIdaToolResult({
|
||||
content: [],
|
||||
structuredContent: { path: "input", result: "field" },
|
||||
}),
|
||||
).toEqual({ path: "input", result: "field" });
|
||||
expect(
|
||||
decodeIdaToolResult({
|
||||
content: [{ type: "text", text: '[{"address":"0x1000"}]' }],
|
||||
}),
|
||||
).toEqual([{ address: "0x1000" }]);
|
||||
expect(
|
||||
decodeIdaToolResult({
|
||||
content: [{ type: "text", text: "int main(void);" }],
|
||||
}),
|
||||
).toBe("int main(void);");
|
||||
});
|
||||
it("preserves upstream failures and rejects truncated previews that still satisfy item schemas", () => {
|
||||
expect(() => decodeIdaToolResult({ content: [] })).toThrow(
|
||||
"empty observation",
|
||||
);
|
||||
expect(() =>
|
||||
decodeIdaToolResult({
|
||||
isError: true,
|
||||
content: [{ type: "text", text: "Decompiler license is unavailable" }],
|
||||
}),
|
||||
).toThrow("Decompiler license is unavailable");
|
||||
expect(() =>
|
||||
decodeIdaToolResult({
|
||||
isError: false,
|
||||
structuredContent: {
|
||||
result: [{ addr: "0x1000", name: "main", size: "0x20" }],
|
||||
},
|
||||
content: [{ type: "text", text: "preview" }],
|
||||
_meta: {
|
||||
ida_mcp: {
|
||||
output_truncated: true,
|
||||
total_chars: 70000,
|
||||
download_hint: "Download the full upstream output",
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toThrow("Download the full upstream output");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,154 @@
|
|||
import {
|
||||
Client,
|
||||
StreamableHTTPClientTransport,
|
||||
} from "@modelcontextprotocol/client";
|
||||
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
|
||||
import { z } from "zod";
|
||||
import type { IdaConfiguration } from "./IdaConfiguration.js";
|
||||
import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js";
|
||||
import { AnalysisProtocolError } from "../domain/analysisErrorCore.js";
|
||||
|
||||
/** Injectable MCP boundary used by the provider and transport conformance tests. */
|
||||
export interface IdaMcpConnection {
|
||||
connect(): Promise<readonly string[]>;
|
||||
call(
|
||||
name: string,
|
||||
args: Readonly<Record<string, JsonValue>>,
|
||||
): Promise<JsonValue>;
|
||||
serverInfo(): JsonValue;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
const toolResultSchema = z.object({
|
||||
isError: z.boolean().optional(),
|
||||
structuredContent: z.record(z.string(), z.unknown()).optional(),
|
||||
content: z.array(
|
||||
z.object({ type: z.string(), text: z.string().optional() }).passthrough(),
|
||||
),
|
||||
_meta: z
|
||||
.object({
|
||||
ida_mcp: z
|
||||
.object({
|
||||
output_truncated: z.boolean().optional(),
|
||||
download_hint: z.string().optional(),
|
||||
})
|
||||
.passthrough()
|
||||
.optional(),
|
||||
})
|
||||
.passthrough()
|
||||
.optional(),
|
||||
});
|
||||
|
||||
/** Decode Python FastMCP's scalar wrapper without dropping structured dictionaries. */
|
||||
export const decodeIdaToolResult = (input: unknown): JsonValue => {
|
||||
const result = toolResultSchema.parse(input);
|
||||
const blocks = result.content.flatMap((block) =>
|
||||
block.type === "text" && block.text !== undefined ? [block.text] : [],
|
||||
);
|
||||
if (result.isError === true)
|
||||
throw new AnalysisProtocolError(
|
||||
`IDA MCP tool failed: ${blocks.join("\n")}`,
|
||||
);
|
||||
if (result._meta?.ida_mcp?.output_truncated === true)
|
||||
throw new AnalysisProtocolError(
|
||||
`IDA MCP truncated its output; the preview cannot establish a complete observation. ${result._meta.ida_mcp.download_hint ?? "Use the upstream output download workflow to retrieve the full result."}`,
|
||||
);
|
||||
if (result.structuredContent !== undefined) {
|
||||
const value = result.structuredContent;
|
||||
return jsonValueSchema.parse(
|
||||
Object.keys(value).length === 1 && "result" in value
|
||||
? value.result
|
||||
: value,
|
||||
);
|
||||
}
|
||||
if (blocks.length !== 1)
|
||||
throw new AnalysisProtocolError(
|
||||
"IDA MCP omitted a single structured or text result; unsupported content cannot establish an empty observation.",
|
||||
);
|
||||
const values = blocks.map((text): JsonValue => {
|
||||
try {
|
||||
return jsonValueSchema.parse(JSON.parse(text));
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
});
|
||||
return values[0] ?? null;
|
||||
};
|
||||
|
||||
/** Redact configured transport authentication values from SDK diagnostics only. */
|
||||
export const redactIdaTransportFailure = (
|
||||
cause: unknown,
|
||||
config: IdaConfiguration,
|
||||
): Error => {
|
||||
let message =
|
||||
cause instanceof Error
|
||||
? cause.message
|
||||
: "Unknown IDA MCP transport failure";
|
||||
if ("headers" in config) {
|
||||
for (const [name, value] of Object.entries(config.headers)) {
|
||||
if (!/^(?:authorization|proxy-authorization|cookie)$/iu.test(name))
|
||||
continue;
|
||||
const credential = value.match(/^(?:Bearer|Basic)\s+([\s\S]+)$/iu)?.[1];
|
||||
for (const secret of [value, credential])
|
||||
if (secret !== undefined && secret.length > 0)
|
||||
message = message
|
||||
.split(secret)
|
||||
.join("[redacted transport credential]");
|
||||
}
|
||||
}
|
||||
return new Error(message, { cause });
|
||||
};
|
||||
|
||||
/** Create a standard SDK connection to an existing upstream registration. */
|
||||
export const createIdaMcpConnection = (
|
||||
config: IdaConfiguration,
|
||||
): IdaMcpConnection => {
|
||||
const client = new Client({ name: "rea-ida-provider", version: "1" });
|
||||
const transportFailure = (cause: unknown): never => {
|
||||
throw redactIdaTransportFailure(cause, config);
|
||||
};
|
||||
const transport =
|
||||
"command" in config
|
||||
? new StdioClientTransport({
|
||||
command: config.command,
|
||||
args: config.args,
|
||||
env: {
|
||||
...Object.fromEntries(
|
||||
Object.entries(process.env).filter(
|
||||
(entry): entry is [string, string] => entry[1] !== undefined,
|
||||
),
|
||||
),
|
||||
IDA_MCP_MAX_WORKERS: "1",
|
||||
...config.env,
|
||||
},
|
||||
stderr: "pipe",
|
||||
})
|
||||
: new StreamableHTTPClientTransport(new URL(config.url), {
|
||||
requestInit: { headers: config.headers, redirect: "error" },
|
||||
});
|
||||
// Drain proxy diagnostics without persisting ambient environment or transport credentials.
|
||||
if (transport instanceof StdioClientTransport)
|
||||
transport.stderr?.on("data", () => undefined);
|
||||
return {
|
||||
async connect() {
|
||||
await client
|
||||
.connect(transport, { timeout: config.timeoutMs })
|
||||
.catch(transportFailure);
|
||||
const catalog = await client
|
||||
.listTools(undefined, {
|
||||
timeout: config.timeoutMs,
|
||||
})
|
||||
.catch(transportFailure);
|
||||
return catalog.tools.map(({ name }) => name);
|
||||
},
|
||||
async call(name, args) {
|
||||
return decodeIdaToolResult(
|
||||
await client
|
||||
.callTool({ name, arguments: args }, { timeout: config.timeoutMs })
|
||||
.catch(transportFailure),
|
||||
);
|
||||
},
|
||||
serverInfo: () => jsonValueSchema.parse(client.getServerVersion() ?? null),
|
||||
close: () => client.close().catch(transportFailure),
|
||||
};
|
||||
};
|
||||
|
|
@ -0,0 +1,437 @@
|
|||
import { z } from "zod";
|
||||
import { analysisSearchInput } from "../contracts/analysisSearchContract.js";
|
||||
import {
|
||||
AnalysisInputError,
|
||||
AnalysisProtocolError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import {
|
||||
functionDossierSchema,
|
||||
functionInstructionWindowSchema,
|
||||
} from "../domain/hopperValues.js";
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import type { IdaMcpConnection } from "./IdaMcpConnection.js";
|
||||
import type { IdaOperation } from "./IdaProviderCapabilities.js";
|
||||
import { IDA_LIMITATIONS } from "./IdaProviderCapabilities.js";
|
||||
import type { EvidenceLocation } from "../domain/evidence.js";
|
||||
import {
|
||||
disassemblySchema,
|
||||
idaAddressSchema,
|
||||
idaFunctionSchema,
|
||||
legacyCalleesSchema,
|
||||
legacyPageSchema,
|
||||
legacyStringSchema,
|
||||
legacyXrefsSchema,
|
||||
modernCalleesSchema,
|
||||
modernDecompileSchema,
|
||||
modernDisassemblySchema,
|
||||
modernLookupSchema,
|
||||
modernPageSchema,
|
||||
modernStringsSchema,
|
||||
modernXrefsSchema,
|
||||
nextOffset,
|
||||
type IdaDisassembly,
|
||||
type IdaFunction,
|
||||
} from "./IdaProtocolValues.js";
|
||||
|
||||
/** One operation's raw source observations, retained alongside normalized results. */
|
||||
export interface IdaRawObservation {
|
||||
readonly tool: string;
|
||||
readonly arguments: Readonly<Record<string, JsonValue>>;
|
||||
readonly result: JsonValue;
|
||||
}
|
||||
|
||||
const unknownBody = {
|
||||
available: false,
|
||||
reason: "IDA MCP does not report complete function body ranges.",
|
||||
} as const;
|
||||
const identity = (fn: IdaFunction) => ({
|
||||
address: fn.address,
|
||||
name: fn.name,
|
||||
classification: null,
|
||||
body: unknownBody,
|
||||
});
|
||||
const lines = (asm: IdaDisassembly) =>
|
||||
asm.lines.map(({ address, instruction }) => `${address}: ${instruction}`);
|
||||
|
||||
/** Translate explicit analyst questions into a closed set of read-only upstream requests. */
|
||||
export class IdaOperationRunner {
|
||||
readonly raw: IdaRawObservation[] = [];
|
||||
readonly locations: EvidenceLocation[] = [];
|
||||
readonly limitations: string[] = [];
|
||||
constructor(
|
||||
private readonly connection: IdaMcpConnection,
|
||||
private readonly protocol: "legacy" | "modern",
|
||||
private readonly database?: string,
|
||||
) {}
|
||||
|
||||
async call(
|
||||
tool: string,
|
||||
args: Readonly<Record<string, JsonValue>>,
|
||||
): Promise<JsonValue> {
|
||||
const scoped =
|
||||
this.database === undefined ? args : { ...args, database: this.database };
|
||||
const result = await this.connection.call(tool, scoped);
|
||||
this.raw.push({ tool, arguments: scoped, result });
|
||||
return result;
|
||||
}
|
||||
|
||||
async functions(): Promise<IdaFunction[]> {
|
||||
const result: IdaFunction[] = [];
|
||||
let offset: number | null = 0;
|
||||
while (offset !== null) {
|
||||
const current: number = offset;
|
||||
const value: z.infer<typeof legacyPageSchema> | undefined =
|
||||
this.protocol === "legacy"
|
||||
? legacyPageSchema.parse(
|
||||
await this.call("list_functions", { offset, count: 100 }),
|
||||
)
|
||||
: modernPageSchema.parse(
|
||||
await this.call("list_funcs", {
|
||||
queries: { offset, count: 100 },
|
||||
}),
|
||||
)[0];
|
||||
if (value === undefined)
|
||||
throw new AnalysisProtocolError("IDA MCP omitted its function page.");
|
||||
result.push(...value.data.map((fn) => idaFunctionSchema.parse(fn)));
|
||||
offset = value.next_offset;
|
||||
if (offset !== null && offset <= current)
|
||||
throw new AnalysisProtocolError(
|
||||
"IDA MCP function pagination did not advance.",
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async resolve(query: string): Promise<IdaFunction> {
|
||||
if (this.protocol === "modern") {
|
||||
const item = modernLookupSchema.parse(
|
||||
await this.call("lookup_funcs", { queries: [query] }),
|
||||
)[0];
|
||||
if (item?.fn == null || item.error !== null)
|
||||
throw new AnalysisProtocolError(
|
||||
`IDA function lookup failed for ${query}: ${item?.error ?? "missing function"}`,
|
||||
);
|
||||
return item.fn;
|
||||
}
|
||||
const address = idaAddressSchema.safeParse(
|
||||
/^0x/iu.test(query) ? query : undefined,
|
||||
);
|
||||
const found = idaFunctionSchema.parse(
|
||||
await this.call(
|
||||
address.success ? "get_function_by_address" : "get_function_by_name",
|
||||
address.success ? { address: address.data } : { name: query },
|
||||
),
|
||||
);
|
||||
// Legacy producers may echo an interior query or call site in address.
|
||||
// Resolve the reported symbol again to obtain its actual entry, never infer from size.
|
||||
const entry = idaFunctionSchema.parse(
|
||||
await this.call("get_function_by_name", { name: found.name }),
|
||||
);
|
||||
if (entry.name !== found.name)
|
||||
throw new AnalysisProtocolError(
|
||||
"IDA function identity changed during lookup.",
|
||||
);
|
||||
return entry;
|
||||
}
|
||||
|
||||
async strings(): Promise<{ address: string; value: string }[]> {
|
||||
const result: { address: string; value: string }[] = [];
|
||||
let offset: number | null = 0;
|
||||
while (offset !== null) {
|
||||
const current: number = offset;
|
||||
if (this.protocol === "legacy") {
|
||||
const page = legacyPageSchema.parse(
|
||||
await this.call("list_strings", { offset, count: 100 }),
|
||||
);
|
||||
result.push(
|
||||
...page.data.map((item) => {
|
||||
const s = legacyStringSchema.parse(item);
|
||||
return { address: s.address, value: s.string };
|
||||
}),
|
||||
);
|
||||
offset = page.next_offset;
|
||||
} else {
|
||||
const page = modernStringsSchema.parse(
|
||||
await this.call("find_regex", {
|
||||
pattern: "(?s).*",
|
||||
offset,
|
||||
limit: 100,
|
||||
}),
|
||||
);
|
||||
result.push(
|
||||
...page.matches.map((s) => ({ address: s.addr, value: s.string })),
|
||||
);
|
||||
offset = nextOffset(page.cursor, current);
|
||||
}
|
||||
if (offset !== null && offset <= current)
|
||||
throw new AnalysisProtocolError(
|
||||
"IDA MCP string pagination did not advance.",
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async assembly(fn: IdaFunction): Promise<IdaDisassembly> {
|
||||
if (this.protocol === "legacy") {
|
||||
const result = disassemblySchema.parse(
|
||||
await this.call("disassemble_function", { start_address: fn.address }),
|
||||
);
|
||||
this.verifyAssembly(fn, result);
|
||||
return result;
|
||||
}
|
||||
let result: IdaDisassembly | undefined;
|
||||
let offset: number | null = 0;
|
||||
while (offset !== null) {
|
||||
const page = modernDisassemblySchema.parse(
|
||||
await this.call("disasm", {
|
||||
addr: fn.address,
|
||||
offset,
|
||||
max_instructions: 100,
|
||||
}),
|
||||
);
|
||||
if (page.asm === null || page.error !== undefined)
|
||||
throw new AnalysisProtocolError(
|
||||
`IDA disassembly failed: ${page.error ?? "missing assembly"}`,
|
||||
);
|
||||
this.verifyAssembly(fn, page.asm);
|
||||
if (result === undefined) result = page.asm;
|
||||
else result.lines.push(...page.asm.lines);
|
||||
offset = nextOffset(page.cursor, offset);
|
||||
}
|
||||
if (result === undefined)
|
||||
throw new AnalysisProtocolError("IDA omitted assembly.");
|
||||
return result;
|
||||
}
|
||||
|
||||
private verifyAssembly(fn: IdaFunction, asm: IdaDisassembly): void {
|
||||
if (asm.start_ea !== fn.address || asm.name !== fn.name)
|
||||
throw new AnalysisProtocolError(
|
||||
"IDA disassembly identifies a different function than the resolved entry.",
|
||||
);
|
||||
}
|
||||
|
||||
async pseudocode(fn: IdaFunction): Promise<string | null> {
|
||||
if (this.protocol === "legacy")
|
||||
return z
|
||||
.string()
|
||||
.nullable()
|
||||
.parse(await this.call("decompile_function", { address: fn.address }));
|
||||
const result: string[] = [];
|
||||
let offset: number | null = 0;
|
||||
while (offset !== null) {
|
||||
const page = modernDecompileSchema.parse(
|
||||
await this.call("decompile", {
|
||||
addr: fn.address,
|
||||
offset,
|
||||
max_lines: 100,
|
||||
}),
|
||||
);
|
||||
if (page.code === null)
|
||||
throw new AnalysisProtocolError(
|
||||
`IDA decompilation failed: ${page.error ?? "missing code"}`,
|
||||
);
|
||||
result.push(page.code);
|
||||
offset = nextOffset(page.cursor, offset);
|
||||
}
|
||||
return result.join("\n");
|
||||
}
|
||||
|
||||
async xrefs(
|
||||
address: string,
|
||||
): Promise<
|
||||
{ address: string; type: string; function: IdaFunction | null }[]
|
||||
> {
|
||||
if (this.protocol === "legacy")
|
||||
return legacyXrefsSchema.parse(
|
||||
await this.call("get_xrefs_to", { address }),
|
||||
);
|
||||
const page = modernXrefsSchema.parse(
|
||||
await this.call("xrefs_to", { addrs: [address], limit: 1000 }),
|
||||
)[0];
|
||||
if (page?.xrefs == null)
|
||||
throw new AnalysisProtocolError(
|
||||
`IDA xrefs failed: ${page?.error ?? "missing references"}`,
|
||||
);
|
||||
if (page.more === true)
|
||||
this.limitations.push(
|
||||
"Upstream xrefs_to capped results at 1000 references; additional references remain unknown.",
|
||||
);
|
||||
return page.xrefs.map((xref) => ({
|
||||
address: xref.addr,
|
||||
type: xref.type,
|
||||
function: xref.fn,
|
||||
}));
|
||||
}
|
||||
|
||||
async relationships(
|
||||
fn: IdaFunction,
|
||||
direction: "callers" | "callees",
|
||||
): Promise<ReturnType<typeof identity>[]> {
|
||||
const queries: string[] = [];
|
||||
const result = new Map<string, ReturnType<typeof identity>>();
|
||||
if (this.protocol === "legacy") {
|
||||
const value = await this.call(
|
||||
direction === "callers" ? "get_callers" : "get_callees",
|
||||
{ function_address: fn.address },
|
||||
);
|
||||
const items =
|
||||
direction === "callers"
|
||||
? z.array(idaFunctionSchema).parse(value)
|
||||
: legacyCalleesSchema.parse(value);
|
||||
for (const item of items) {
|
||||
if ("type" in item && item.type === "external")
|
||||
result.set(item.address, identity({ ...item, size: "unknown" }));
|
||||
else queries.push(item.name);
|
||||
}
|
||||
} else if (direction === "callers") {
|
||||
this.limitations.push(
|
||||
"Modern upstream xrefs classify references as code or data, without proving call edges. Direct callers are unavailable in this profile.",
|
||||
);
|
||||
return [];
|
||||
} else {
|
||||
const page = modernCalleesSchema.parse(
|
||||
await this.call("callees", { addrs: [fn.address], limit: 500 }),
|
||||
)[0];
|
||||
if (page?.callees == null)
|
||||
throw new AnalysisProtocolError(
|
||||
`IDA callees failed: ${page?.error ?? "missing callees"}`,
|
||||
);
|
||||
if (page.more === true)
|
||||
this.limitations.push(
|
||||
"Upstream callees capped results at 500; additional callees remain unknown.",
|
||||
);
|
||||
for (const item of page.callees) {
|
||||
if ("type" in item && item.type === "external")
|
||||
result.set(
|
||||
item.addr,
|
||||
identity({ address: item.addr, name: item.name, size: "unknown" }),
|
||||
);
|
||||
else queries.push(item.addr);
|
||||
}
|
||||
}
|
||||
for (const query of new Set(queries)) {
|
||||
const resolved = identity(await this.resolve(query));
|
||||
result.set(resolved.address, resolved);
|
||||
}
|
||||
return [...result.values()];
|
||||
}
|
||||
|
||||
async run(
|
||||
operation: IdaOperation,
|
||||
parameters: Readonly<Record<string, JsonValue>>,
|
||||
): Promise<JsonValue> {
|
||||
if (operation === "list_procedures" || operation === "search_procedures") {
|
||||
const items = (await this.functions()).map((fn) => ({
|
||||
address: fn.address,
|
||||
value: fn.name,
|
||||
}));
|
||||
return operation === "list_procedures"
|
||||
? items
|
||||
: this.search(items, parameters);
|
||||
}
|
||||
if (operation === "list_strings" || operation === "search_strings") {
|
||||
const items = await this.strings();
|
||||
return operation === "search_strings"
|
||||
? this.search(items, parameters)
|
||||
: parameters.address === undefined
|
||||
? items
|
||||
: items.filter(
|
||||
({ address }) =>
|
||||
address === idaAddressSchema.parse(parameters.address),
|
||||
);
|
||||
}
|
||||
if (operation === "xrefs") {
|
||||
const inspectedAddress = idaAddressSchema.parse(parameters.address);
|
||||
this.locations.push({ kind: "address", address: inspectedAddress });
|
||||
return (await this.xrefs(inspectedAddress)).map(({ address }) => address);
|
||||
}
|
||||
const fn = await this.resolve(
|
||||
z.string().min(1).parse(parameters.procedure),
|
||||
);
|
||||
this.locations.push({ kind: "address", address: fn.address });
|
||||
switch (operation) {
|
||||
case "procedure_address":
|
||||
return fn.address;
|
||||
case "procedure_pseudo_code":
|
||||
return this.pseudocode(fn);
|
||||
case "procedure_assembly":
|
||||
return lines(await this.assembly(fn)).join("\n");
|
||||
case "procedure_callers":
|
||||
if (this.protocol === "modern")
|
||||
throw new AnalysisProtocolError(
|
||||
"This IDA MCP profile cannot distinguish direct callers from other code references.",
|
||||
);
|
||||
return (await this.relationships(fn, "callers")).map(
|
||||
({ address }) => address,
|
||||
);
|
||||
case "procedure_callees":
|
||||
return (await this.relationships(fn, "callees")).map(
|
||||
({ address }) => address,
|
||||
);
|
||||
case "read_function_instructions":
|
||||
return functionInstructionWindowSchema.parse({
|
||||
procedure: identity(fn),
|
||||
instructions: lines(await this.assembly(fn)),
|
||||
limitations: IDA_LIMITATIONS,
|
||||
});
|
||||
case "analyze_function":
|
||||
return this.dossier(fn);
|
||||
}
|
||||
}
|
||||
|
||||
private search(
|
||||
items: { address: string; value: string }[],
|
||||
input: Readonly<Record<string, JsonValue>>,
|
||||
): JsonValue {
|
||||
const query = z.object(analysisSearchInput).parse(input);
|
||||
let matches: (value: string) => boolean;
|
||||
if (query.mode === "regex") {
|
||||
try {
|
||||
const regex = new RegExp(
|
||||
query.pattern,
|
||||
query.case_sensitive ? "u" : "iu",
|
||||
);
|
||||
matches = (value) => regex.test(value);
|
||||
} catch (cause: unknown) {
|
||||
throw new AnalysisInputError("search", { cause });
|
||||
}
|
||||
} else {
|
||||
const needle = query.case_sensitive
|
||||
? query.pattern
|
||||
: query.pattern.toLowerCase();
|
||||
matches = (value) =>
|
||||
(query.case_sensitive ? value : value.toLowerCase()).includes(needle);
|
||||
}
|
||||
return items.filter(({ value }) => matches(value));
|
||||
}
|
||||
|
||||
private async dossier(fn: IdaFunction): Promise<JsonValue> {
|
||||
const asm = await this.assembly(fn);
|
||||
const pseudocode = await this.pseudocode(fn);
|
||||
const callers = await this.relationships(fn, "callers");
|
||||
const callees = await this.relationships(fn, "callees");
|
||||
const signature =
|
||||
asm.return_type == null || asm.arguments == null
|
||||
? null
|
||||
: `${asm.return_type} ${fn.name}(${asm.arguments.map((arg) => `${arg.type} ${arg.name}`).join(", ")})`;
|
||||
return functionDossierSchema.parse({
|
||||
procedure: { ...identity(fn), signature, locals: [] },
|
||||
pseudocode: pseudocode ?? "",
|
||||
assembly: lines(asm),
|
||||
comments: [],
|
||||
callers,
|
||||
callees,
|
||||
incoming_references: [],
|
||||
outgoing_references: [],
|
||||
referenced_strings: [],
|
||||
referenced_names: [],
|
||||
basic_blocks: [],
|
||||
limitations: [
|
||||
...IDA_LIMITATIONS,
|
||||
...this.limitations,
|
||||
"Stack frame records are not evidence of source locals. Rendered comments do not identify regular versus inline origin.",
|
||||
...(pseudocode === null ? ["Pseudocode is unavailable."] : []),
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,214 @@
|
|||
import { z } from "zod";
|
||||
import { jsonValueSchema } from "../domain/jsonValue.js";
|
||||
import { AnalysisProtocolError } from "../domain/analysisErrorCore.js";
|
||||
|
||||
/** Normalize an IDA hexadecimal coordinate without passing through a JavaScript number. */
|
||||
export const idaAddressSchema = z
|
||||
.string()
|
||||
.regex(/^(?:0x)?[0-9a-f]+$/iu)
|
||||
.transform(
|
||||
(value) =>
|
||||
`0x${BigInt(value.startsWith("0x") || value.startsWith("0X") ? value : `0x${value}`).toString(16)}`,
|
||||
);
|
||||
|
||||
/** A function observation; size is an enclosing span, never a complete body. */
|
||||
export const idaFunctionSchema = z.union([
|
||||
z.object({
|
||||
address: idaAddressSchema,
|
||||
name: z.string().min(1),
|
||||
size: z.string(),
|
||||
}),
|
||||
z
|
||||
.object({
|
||||
addr: idaAddressSchema,
|
||||
name: z.string().min(1),
|
||||
size: z.string(),
|
||||
})
|
||||
.transform(({ addr, ...rest }) => ({ address: addr, ...rest })),
|
||||
]);
|
||||
export type IdaFunction = z.infer<typeof idaFunctionSchema>;
|
||||
|
||||
/** Legacy metadata describes the recorded input, whereas modern health names live paths. */
|
||||
export const legacyMetadataSchema = z
|
||||
.object({
|
||||
path: z.string().min(1),
|
||||
module: z.string(),
|
||||
sha256: z.string().regex(/^[0-9a-f]{64}$/iu),
|
||||
base: idaAddressSchema,
|
||||
})
|
||||
.passthrough();
|
||||
export const modernHealthSchema = z
|
||||
.object({
|
||||
status: z.literal("ok"),
|
||||
input_path: z.string().min(1),
|
||||
idb_path: z.string().nullable(),
|
||||
imagebase: idaAddressSchema,
|
||||
auto_analysis_ready: z.boolean().nullable(),
|
||||
module: z.string(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
/** An upstream cursor must either finish or advance strictly to avoid replay loops. */
|
||||
export const cursorSchema = z.union([
|
||||
z.object({ next: z.number().int().nonnegative() }),
|
||||
z.object({ done: z.literal(true) }),
|
||||
]);
|
||||
export const nextOffset = (
|
||||
cursor: z.infer<typeof cursorSchema>,
|
||||
current: number,
|
||||
): number | null => {
|
||||
if ("done" in cursor) return null;
|
||||
if (cursor.next <= current)
|
||||
throw new AnalysisProtocolError(
|
||||
"IDA MCP pagination did not advance its offset.",
|
||||
);
|
||||
return cursor.next;
|
||||
};
|
||||
|
||||
export const legacyPageSchema = z.object({
|
||||
data: z.array(jsonValueSchema),
|
||||
next_offset: z.number().int().nonnegative().nullable(),
|
||||
});
|
||||
export const modernPageSchema = z
|
||||
.array(
|
||||
z.object({
|
||||
data: z.array(jsonValueSchema),
|
||||
next_offset: z.number().int().nonnegative().nullable(),
|
||||
}),
|
||||
)
|
||||
.length(1);
|
||||
|
||||
export const modernLookupSchema = z
|
||||
.array(
|
||||
z.object({
|
||||
query: z.string(),
|
||||
fn: idaFunctionSchema.nullable(),
|
||||
error: z.string().nullable(),
|
||||
}),
|
||||
)
|
||||
.length(1);
|
||||
|
||||
const instructionSchema = z.union([
|
||||
z.object({
|
||||
address: idaAddressSchema,
|
||||
instruction: z.string(),
|
||||
comments: z.array(z.string()).nullish(),
|
||||
}),
|
||||
z
|
||||
.object({
|
||||
addr: idaAddressSchema,
|
||||
instruction: z.string(),
|
||||
comments: z.array(z.string()).nullish(),
|
||||
})
|
||||
.transform(({ addr, ...rest }) => ({ address: addr, ...rest })),
|
||||
]);
|
||||
export const disassemblySchema = z.object({
|
||||
name: z.string(),
|
||||
start_ea: idaAddressSchema,
|
||||
lines: z.array(instructionSchema),
|
||||
return_type: z.string().nullish(),
|
||||
arguments: z
|
||||
.array(z.object({ name: z.string(), type: z.string() }))
|
||||
.nullish(),
|
||||
});
|
||||
export type IdaDisassembly = z.infer<typeof disassemblySchema>;
|
||||
export const modernDisassemblySchema = z.object({
|
||||
asm: disassemblySchema.nullable(),
|
||||
error: z.string().optional(),
|
||||
cursor: cursorSchema,
|
||||
});
|
||||
export const modernDecompileSchema = z.object({
|
||||
code: z.string().nullable(),
|
||||
error: z.string().optional(),
|
||||
cursor: cursorSchema,
|
||||
line_count: z.number().int().nonnegative().optional(),
|
||||
total_lines: z.number().int().nonnegative().optional(),
|
||||
});
|
||||
|
||||
export const legacyStringSchema = z.object({
|
||||
address: idaAddressSchema,
|
||||
string: z.string(),
|
||||
length: z.number().int().nonnegative(),
|
||||
});
|
||||
export const modernStringsSchema = z.object({
|
||||
matches: z.array(z.object({ addr: idaAddressSchema, string: z.string() })),
|
||||
cursor: cursorSchema,
|
||||
});
|
||||
export const legacyXrefsSchema = z.array(
|
||||
z.object({
|
||||
address: idaAddressSchema,
|
||||
type: z.string(),
|
||||
function: idaFunctionSchema.nullable(),
|
||||
}),
|
||||
);
|
||||
export const modernXrefsSchema = z
|
||||
.array(
|
||||
z.object({
|
||||
xrefs: z
|
||||
.array(
|
||||
z.object({
|
||||
addr: idaAddressSchema,
|
||||
type: z.string(),
|
||||
fn: idaFunctionSchema.nullable(),
|
||||
}),
|
||||
)
|
||||
.nullable(),
|
||||
error: z.string().optional(),
|
||||
more: z.boolean().optional(),
|
||||
}),
|
||||
)
|
||||
.length(1);
|
||||
export const modernCalleesSchema = z
|
||||
.array(
|
||||
z.object({
|
||||
callees: z
|
||||
.array(
|
||||
z.object({
|
||||
addr: idaAddressSchema,
|
||||
name: z.string(),
|
||||
type: z.enum(["internal", "external"]),
|
||||
}),
|
||||
)
|
||||
.nullable(),
|
||||
error: z.string().optional(),
|
||||
more: z.boolean().optional(),
|
||||
}),
|
||||
)
|
||||
.length(1);
|
||||
export const legacyCalleesSchema = z.array(
|
||||
z.object({ address: idaAddressSchema, name: z.string() }).passthrough(),
|
||||
);
|
||||
|
||||
/** Parsed database lifecycle observations retain explicit ownership information. */
|
||||
export const openDatabaseSchema = z
|
||||
.object({
|
||||
success: z.literal(true),
|
||||
session: z
|
||||
.object({ session_id: z.string().min(1), input_path: z.string().min(1) })
|
||||
.passthrough(),
|
||||
})
|
||||
.passthrough();
|
||||
export const databaseListSchema = z
|
||||
.object({
|
||||
sessions: z.array(
|
||||
z
|
||||
.object({
|
||||
session_id: z.string(),
|
||||
input_path: z.string(),
|
||||
owned: z.boolean(),
|
||||
backend: z.enum(["worker", "gui"]),
|
||||
is_active: z.boolean(),
|
||||
})
|
||||
.passthrough(),
|
||||
),
|
||||
})
|
||||
.passthrough();
|
||||
export const closeDatabaseSchema = z
|
||||
.object({
|
||||
success: z.literal(true),
|
||||
session_id: z.string(),
|
||||
saved: z.boolean().nullable().optional(),
|
||||
owned: z.boolean(),
|
||||
backend: z.enum(["worker", "gui"]),
|
||||
})
|
||||
.passthrough();
|
||||
|
|
@ -0,0 +1,171 @@
|
|||
import { createAnalysisProfile } from "../domain/analysisProfile.js";
|
||||
import type { AnalysisProfileCommitment } from "../domain/analysisProfile.js";
|
||||
import type { BinaryTarget } from "../domain/binaryTarget.js";
|
||||
import { err, ok } from "../domain/result.js";
|
||||
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
|
||||
import type {
|
||||
AnalysisClient,
|
||||
AnalysisProfileResolutionOptions,
|
||||
AnalysisProviderCandidate,
|
||||
ProviderAvailability,
|
||||
ProviderTargetSupport,
|
||||
} from "../application/AnalysisProvider.js";
|
||||
import type { AppConfig } from "../config.js";
|
||||
import { ConfigurationError } from "../domain/configurationErrors.js";
|
||||
import { createHash } from "node:crypto";
|
||||
import canonicalize from "canonicalize";
|
||||
import type { Result } from "../domain/result.js";
|
||||
import {
|
||||
readIdaConfiguration,
|
||||
type IdaConfiguration,
|
||||
} from "./IdaConfiguration.js";
|
||||
import {
|
||||
createIdaMcpConnection,
|
||||
type IdaMcpConnection,
|
||||
} from "./IdaMcpConnection.js";
|
||||
import {
|
||||
idaCapabilities,
|
||||
IDA_PROVIDER_IDENTITY,
|
||||
} from "./IdaProviderCapabilities.js";
|
||||
import { IdaSessionClient } from "./IdaSessionClient.js";
|
||||
import { privateRuntimeRootCapability } from "../process/PrivateRuntimeRoot.js";
|
||||
|
||||
export { IDA_PROVIDER_IDENTITY } from "./IdaProviderCapabilities.js";
|
||||
|
||||
/** Bring-your-own IDA MCP candidate; discovery performs no provider startup. */
|
||||
export class IdaProvider implements AnalysisProviderCandidate {
|
||||
#registration: Result<IdaConfiguration, ConfigurationError> | undefined;
|
||||
constructor(
|
||||
private readonly config: AppConfig,
|
||||
private readonly connectionFactory: (
|
||||
config: IdaConfiguration,
|
||||
) => IdaMcpConnection = createIdaMcpConnection,
|
||||
) {}
|
||||
|
||||
identity() {
|
||||
return IDA_PROVIDER_IDENTITY;
|
||||
}
|
||||
capabilities() {
|
||||
const config = this.#configuration();
|
||||
return idaCapabilities(
|
||||
config.ok ? config.value.mode : "attached",
|
||||
config.ok && "command" in config.value,
|
||||
);
|
||||
}
|
||||
inspectAvailability(): ProviderAvailability {
|
||||
const config = this.#configuration();
|
||||
if (config.ok && config.value.mode === "headless") {
|
||||
const privacy = privateRuntimeRootCapability();
|
||||
if (!privacy.available)
|
||||
return {
|
||||
status: "unavailable",
|
||||
code: "unsupported_host",
|
||||
reason: privacy.reason,
|
||||
diagnostics: {
|
||||
configured: true,
|
||||
mode: "headless",
|
||||
live_connection_probed: false,
|
||||
private_workspace_available: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
return config.ok
|
||||
? {
|
||||
status: "available",
|
||||
code: null,
|
||||
reason: null,
|
||||
diagnostics: {
|
||||
configured: true,
|
||||
mode: config.value.mode,
|
||||
transport: "command" in config.value ? "stdio" : "http",
|
||||
live_connection_probed: false,
|
||||
},
|
||||
}
|
||||
: {
|
||||
status: "unavailable",
|
||||
code:
|
||||
this.config.idaMcpConfigPath === undefined
|
||||
? "not_configured"
|
||||
: "open_options_invalid",
|
||||
reason: config.error.message,
|
||||
diagnostics: { configured: false, live_connection_probed: false },
|
||||
};
|
||||
}
|
||||
inspectTargetSupport(target: BinaryTarget): ProviderTargetSupport {
|
||||
const diagnostics = {
|
||||
target_kind: target.kind,
|
||||
target_format: target.format,
|
||||
};
|
||||
return target.kind === "executable"
|
||||
? { status: "supported", code: null, reason: null, diagnostics }
|
||||
: {
|
||||
status: "unsupported",
|
||||
code: "target_kind_unsupported",
|
||||
reason:
|
||||
"IDA MCP integration admits executable inputs, not analysis databases. Select the original input binary.",
|
||||
diagnostics,
|
||||
};
|
||||
}
|
||||
async resolveAnalysisProfile(
|
||||
_target: BinaryTarget,
|
||||
options?: AnalysisProfileResolutionOptions,
|
||||
) {
|
||||
if (options?.signal?.aborted === true)
|
||||
return err(new AnalysisCancelledError("ida:profile"));
|
||||
const config = this.#configuration();
|
||||
if (!config.ok) return config;
|
||||
return ok({
|
||||
profile: createAnalysisProfile(IDA_PROVIDER_IDENTITY, {
|
||||
version_scope: "rea-ida-adapter",
|
||||
engine_version: null,
|
||||
upstream_distribution_version: null,
|
||||
mode: config.value.mode,
|
||||
compatibility_profile:
|
||||
config.value.mode === "attached"
|
||||
? "legacy-1.4"
|
||||
: "database-supervisor",
|
||||
database_revision: null,
|
||||
cache_policy: "live",
|
||||
registration_digest: createHash("sha256")
|
||||
.update(canonicalize(config.value) ?? "")
|
||||
.digest("hex"),
|
||||
}),
|
||||
compatibility: {
|
||||
mode: config.value.mode,
|
||||
engine_version: null,
|
||||
upstream_distribution_version: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
createClient(
|
||||
target: BinaryTarget,
|
||||
profile?: AnalysisProfileCommitment,
|
||||
): AnalysisClient {
|
||||
const config = this.#configuration();
|
||||
if (!config.ok)
|
||||
return {
|
||||
execute: async () => err(config.error),
|
||||
close: async () => undefined,
|
||||
};
|
||||
return new IdaSessionClient(
|
||||
config.value,
|
||||
target,
|
||||
this.connectionFactory(config.value),
|
||||
profile,
|
||||
);
|
||||
}
|
||||
#configuration() {
|
||||
this.#registration ??=
|
||||
this.config.idaMcpConfigPath === undefined
|
||||
? readIdaConfigurationNotConfigured()
|
||||
: readIdaConfiguration(this.config.idaMcpConfigPath);
|
||||
return this.#registration;
|
||||
}
|
||||
}
|
||||
|
||||
const readIdaConfigurationNotConfigured = () =>
|
||||
err(
|
||||
new ConfigurationError(
|
||||
"Set REA_IDA_MCP_CONFIG to an upstream IDA MCP JSON registration; see docs/ida-provider.md.",
|
||||
),
|
||||
);
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
import type { CapabilityDescriptor } from "../application/AnalysisProvider.js";
|
||||
import { GENERATED_MCP_TOOL_CATALOG } from "../generatedMcpToolCatalog.js";
|
||||
|
||||
/** This version identifies REA's adapter, not IDA or the upstream Python package. */
|
||||
export const IDA_PROVIDER_IDENTITY = Object.freeze({
|
||||
id: "ida",
|
||||
name: "IDA Pro MCP adapter",
|
||||
version: "1",
|
||||
});
|
||||
|
||||
/** Read-only analyst outcomes supported by both upstream compatibility profiles. */
|
||||
export const IDA_OPERATIONS = [
|
||||
"list_procedures",
|
||||
"search_procedures",
|
||||
"list_strings",
|
||||
"search_strings",
|
||||
"procedure_address",
|
||||
"procedure_pseudo_code",
|
||||
"procedure_assembly",
|
||||
"procedure_callers",
|
||||
"procedure_callees",
|
||||
"read_function_instructions",
|
||||
"xrefs",
|
||||
"analyze_function",
|
||||
] as const;
|
||||
export type IdaOperation = (typeof IDA_OPERATIONS)[number];
|
||||
|
||||
/** Shared limitations of observations from an externally mutable analysis database. */
|
||||
export const IDA_LIMITATIONS = Object.freeze([
|
||||
"Provider version identifies the REA IDA adapter. IDA engine and upstream distribution versions are unknown unless separately observed; MCP serverInfo is reported independently.",
|
||||
"Results describe the current IDA analysis database. Input identity does not prove an unmodified database; patches, annotations, analysis completeness, and database revision remain unknown.",
|
||||
"Live results are never replayed from analysis snapshots. Target checks before and after a request do not provide an atomic lock against external GUI changes.",
|
||||
"Caller cancellation drains the in-flight MCP request before cleanup; it does not prove that IDA engine work was interrupted.",
|
||||
"Headless workers are released through the upstream database lifecycle. Abrupt termination of REA, its proxy, or the supervisor cannot prove worker cleanup; inspect upstream inventory after an interrupted process.",
|
||||
"Complete function body ranges, typed references, unresolved indirect calls, referenced data, and basic blocks are unavailable in this adapter. Empty unsupported dossier facets are unknown, not observed absence.",
|
||||
]);
|
||||
|
||||
/** Existing REA contracts implemented by the adapter; no upstream tools are exposed directly. */
|
||||
export const IDA_PROVIDER_TOOL_CONTRACTS = Object.freeze(
|
||||
IDA_OPERATIONS.map((operation) => {
|
||||
const contract = GENERATED_MCP_TOOL_CATALOG.find(
|
||||
({ name }) => name === operation,
|
||||
);
|
||||
if (contract === undefined)
|
||||
throw new TypeError(`Missing contract for ${operation}`);
|
||||
return { ...contract, name: operation };
|
||||
}),
|
||||
);
|
||||
|
||||
/** Advertise live observations and configured lifecycle effects truthfully. */
|
||||
export const idaCapabilities = (
|
||||
mode: "attached" | "headless",
|
||||
stdio: boolean,
|
||||
): readonly CapabilityDescriptor[] =>
|
||||
IDA_PROVIDER_TOOL_CONTRACTS.map(({ name: operation }) => ({
|
||||
provider: IDA_PROVIDER_IDENTITY,
|
||||
operation,
|
||||
...(mode === "headless" && operation === "procedure_callers"
|
||||
? ({
|
||||
available: false,
|
||||
reason:
|
||||
"The modern upstream profile reports code references without proving direct call edges.",
|
||||
} as const)
|
||||
: ({ available: true, reason: null } as const)),
|
||||
cachePolicy: "live",
|
||||
effects: {
|
||||
mutatesArtifact: false,
|
||||
launchesProcess: stdio || mode === "headless",
|
||||
mayShowUi: false,
|
||||
mayAccessNetwork: true,
|
||||
mayWriteFilesystem: stdio || mode === "headless",
|
||||
changesPermissions: false,
|
||||
requiresRoot: false,
|
||||
},
|
||||
limitations: IDA_LIMITATIONS,
|
||||
}));
|
||||
|
|
@ -0,0 +1,415 @@
|
|||
import { readFile, readdir, rm } from "node:fs/promises";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
createIdaTarget,
|
||||
RecordingIdaMcp,
|
||||
} from "../../tests/fixtures/idaMcp.js";
|
||||
import { IdaSessionClient } from "./IdaSessionClient.js";
|
||||
import { functionDossierSchema } from "../domain/hopperValues.js";
|
||||
import { jsonValueSchema } from "../domain/jsonValue.js";
|
||||
|
||||
const roots: string[] = [];
|
||||
afterEach(async () => {
|
||||
for (const root of roots.splice(0))
|
||||
await rm(root, { recursive: true, force: true });
|
||||
});
|
||||
const fixture = async (mode: "attached" | "headless" = "attached") => {
|
||||
const { root, target } = await createIdaTarget();
|
||||
roots.push(root);
|
||||
const producer = new RecordingIdaMcp(target, mode);
|
||||
const client = new IdaSessionClient(
|
||||
{
|
||||
command: "fixture",
|
||||
args: [],
|
||||
env: {},
|
||||
mode,
|
||||
timeoutMs: 1000,
|
||||
workspaceRoot: root,
|
||||
},
|
||||
target,
|
||||
producer,
|
||||
);
|
||||
return { root, target, producer, client };
|
||||
};
|
||||
|
||||
describe("headless IDA producer pagination", () => {
|
||||
it("follows reported cursors for modern inventories, pseudocode, and bare-hex assembly without losing pages", async () => {
|
||||
const { producer, client } = await fixture("headless");
|
||||
producer.overrideCall = (name, args) => {
|
||||
const first = args.offset === 0;
|
||||
const cursor = first ? { next: 1 } : { done: true };
|
||||
if (name === "decompile")
|
||||
return { code: first ? "first line" : "second line", cursor };
|
||||
if (name === "disasm")
|
||||
return {
|
||||
asm: {
|
||||
name: "main",
|
||||
start_ea: "0x1000",
|
||||
lines: [
|
||||
{
|
||||
addr: first ? "1000" : "1001",
|
||||
instruction: first ? "push rbp" : "ret",
|
||||
},
|
||||
],
|
||||
},
|
||||
cursor,
|
||||
};
|
||||
if (name === "find_regex")
|
||||
return {
|
||||
matches: [
|
||||
{
|
||||
addr: first ? "0x3000" : "0x3002",
|
||||
string: first ? "a.b" : "a+b",
|
||||
},
|
||||
],
|
||||
cursor,
|
||||
};
|
||||
if (name === "list_funcs") {
|
||||
const page = z
|
||||
.object({ offset: z.number() })
|
||||
.parse(args.queries).offset;
|
||||
return [
|
||||
{
|
||||
data: [
|
||||
{
|
||||
addr: page === 0 ? "0x1000" : "0x2000",
|
||||
name: page === 0 ? "main" : "other",
|
||||
size: "0x20",
|
||||
},
|
||||
],
|
||||
next_offset: page === 0 ? 1 : null,
|
||||
},
|
||||
];
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
const inventory = await client.execute("list_procedures", {});
|
||||
expect(inventory.ok && inventory.value.result).toHaveLength(2);
|
||||
const strings = await client.execute("list_strings", {});
|
||||
expect(strings.ok && strings.value.result).toHaveLength(2);
|
||||
const result = await client.execute("analyze_function", {
|
||||
procedure: "main",
|
||||
});
|
||||
if (!result.ok) throw result.error;
|
||||
const dossier = functionDossierSchema.parse(result.value.result);
|
||||
expect(dossier.pseudocode).toBe("first line\nsecond line");
|
||||
expect(dossier.assembly).toEqual(["0x1000: push rbp", "0x1001: ret"]);
|
||||
await client.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("attached IDA observation semantics", () => {
|
||||
it("rejects an unrelated document before connecting or opening a provider", async () => {
|
||||
const { producer, client } = await fixture();
|
||||
const result = await client.execute("list_procedures", {
|
||||
document: "/unrelated/input",
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error._tag).toBe("AnalysisInputError");
|
||||
expect(result.error.message).toContain("list_procedures");
|
||||
}
|
||||
expect(producer.connects).toBe(0);
|
||||
expect(producer.calls).toEqual([]);
|
||||
await client.close();
|
||||
});
|
||||
it("preserves hexadecimal-looking function names as names unless the caller uses an explicit 0x address", async () => {
|
||||
const { producer, client } = await fixture();
|
||||
producer.overrideCall = (name, args) =>
|
||||
name === "get_function_by_name" && args.name === "f"
|
||||
? { address: "0x1000", name: "f", size: "0x20" }
|
||||
: undefined;
|
||||
const found = await client.execute("procedure_address", { procedure: "f" });
|
||||
expect(found.ok && found.value.result).toBe("0x1000");
|
||||
expect(
|
||||
producer.calls.some(({ name }) => name === "get_function_by_address"),
|
||||
).toBe(false);
|
||||
await client.close();
|
||||
});
|
||||
it("accepts MCP's null projection for omitted optional fields without accepting null required fields", async () => {
|
||||
const { producer, client } = await fixture();
|
||||
expect(
|
||||
(await client.execute("list_procedures", { document: null })).ok,
|
||||
).toBe(true);
|
||||
expect(
|
||||
(await client.execute("list_strings", { document: null, address: null }))
|
||||
.ok,
|
||||
).toBe(true);
|
||||
expect(
|
||||
(
|
||||
await client.execute("procedure_pseudo_code", {
|
||||
document: null,
|
||||
procedure: "main",
|
||||
})
|
||||
).ok,
|
||||
).toBe(true);
|
||||
const required = await client.execute("procedure_pseudo_code", {
|
||||
document: null,
|
||||
procedure: null,
|
||||
});
|
||||
expect(required.ok).toBe(false);
|
||||
if (!required.ok) expect(required.error._tag).toBe("AnalysisInputError");
|
||||
expect(
|
||||
producer.calls.filter(({ name }) => name === "decompile_function"),
|
||||
).toHaveLength(1);
|
||||
await client.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("attached IDA function observations", () => {
|
||||
it("resolves interior addresses and call sites to canonical entries, preserves externals, and reports unknown body evidence", async () => {
|
||||
const { producer, client } = await fixture();
|
||||
const result = await client.execute("analyze_function", {
|
||||
procedure: "0x1004",
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) throw result.error;
|
||||
const dossier = functionDossierSchema.parse(result.value.result);
|
||||
expect(dossier.procedure.address).toBe("0x1000");
|
||||
expect(dossier.callers.map((caller) => caller.address)).toEqual(["0x1000"]);
|
||||
expect(dossier.callees.map((callee) => callee.address)).toEqual(["0x2000"]);
|
||||
expect(dossier.procedure.body.available).toBe(false);
|
||||
expect(dossier.comments).toEqual([]);
|
||||
expect(result.value.rawResult).toMatchObject({
|
||||
observations: expect.arrayContaining([
|
||||
expect.objectContaining({ tool: "get_callers" }),
|
||||
]),
|
||||
});
|
||||
expect(result.value.provider.version).toBe("1");
|
||||
expect(producer.serverInfo()).toMatchObject({
|
||||
version: "sdk-version-is-not-ida",
|
||||
});
|
||||
await client.close();
|
||||
expect(producer.calls.some(({ name }) => name.startsWith("idb_"))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
it("returns complete paged inventory and distinguishes literal from regex search", async () => {
|
||||
const { producer, client } = await fixture();
|
||||
producer.overrideCall = (name, args) =>
|
||||
name === "list_functions"
|
||||
? {
|
||||
data: [
|
||||
{
|
||||
address: args.offset === 0 ? "0x1000" : "0x2000",
|
||||
name: "main",
|
||||
size: "0x10",
|
||||
},
|
||||
],
|
||||
next_offset: args.offset === 0 ? 100 : null,
|
||||
}
|
||||
: undefined;
|
||||
const inventory = await client.execute("list_procedures", {});
|
||||
expect(inventory.ok && inventory.value.result).toHaveLength(2);
|
||||
for (const [pattern, mode, count] of [
|
||||
["a.b", "literal", 1],
|
||||
["a+b", "literal", 0],
|
||||
["a.b", "regex", 1],
|
||||
] as const) {
|
||||
const found = await client.execute("search_strings", { pattern, mode });
|
||||
expect(found.ok && found.value.result).toHaveLength(count);
|
||||
}
|
||||
expect(
|
||||
(await client.execute("search_strings", { pattern: "[", mode: "regex" }))
|
||||
.ok,
|
||||
).toBe(false);
|
||||
await client.close();
|
||||
});
|
||||
it("rejects mismatched identities and target switches rather than returning a successful observation", async () => {
|
||||
const { producer, client, target } = await fixture();
|
||||
producer.inputSha256 = "a".repeat(64);
|
||||
const mismatch = await client.execute("health", {});
|
||||
expect(mismatch.ok).toBe(false);
|
||||
if (!mismatch.ok) expect(mismatch.error.message).toContain("SHA-256");
|
||||
producer.inputSha256 = target.sha256;
|
||||
producer.beforeCall = async (name) => {
|
||||
if (name === "decompile_function") producer.imageBase = "0x2000";
|
||||
};
|
||||
const changed = await client.execute("procedure_pseudo_code", {
|
||||
procedure: "main",
|
||||
});
|
||||
expect(changed.ok).toBe(false);
|
||||
if (!changed.ok) expect(changed.error.message).toContain("changed");
|
||||
await client.close();
|
||||
});
|
||||
it("rejects malformed producer data, non-advancing cursors, unsupported mutation, and missing explicit addresses", async () => {
|
||||
const { producer, client } = await fixture();
|
||||
expect(
|
||||
(
|
||||
await client.execute("set_address_name", {
|
||||
address: "0x1000",
|
||||
name: "changed",
|
||||
})
|
||||
).ok,
|
||||
).toBe(false);
|
||||
expect(producer.connects).toBe(0);
|
||||
expect((await client.execute("xrefs", {})).ok).toBe(false);
|
||||
const invalidAddress = await client.execute("xrefs", {
|
||||
address: "not-an-address",
|
||||
});
|
||||
expect(invalidAddress.ok).toBe(false);
|
||||
if (!invalidAddress.ok)
|
||||
expect(invalidAddress.error._tag).toBe("AnalysisInputError");
|
||||
expect(producer.connects).toBe(0);
|
||||
producer.overrideCall = (name) =>
|
||||
name === "list_functions" ? { data: [], next_offset: 0 } : undefined;
|
||||
expect((await client.execute("list_procedures", {})).ok).toBe(false);
|
||||
producer.overrideCall = (name) =>
|
||||
name === "decompile_function" ? { unexpected: true } : undefined;
|
||||
const malformed = await client.execute("procedure_pseudo_code", {
|
||||
procedure: "main",
|
||||
});
|
||||
expect(malformed.ok).toBe(false);
|
||||
if (!malformed.ok) expect(malformed.error._tag).toBe("AnalysisOutputError");
|
||||
await client.close();
|
||||
});
|
||||
it("drains in-flight read-only work on cancellation before closing its proxy", async () => {
|
||||
const { producer, client } = await fixture();
|
||||
await client.execute("health", {});
|
||||
let release: (() => void) | undefined;
|
||||
let notify: (() => void) | undefined;
|
||||
const started = new Promise<void>((resolve) => {
|
||||
notify = resolve;
|
||||
});
|
||||
producer.beforeCall = async (name) => {
|
||||
if (name === "decompile_function") {
|
||||
notify?.();
|
||||
await new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
}
|
||||
};
|
||||
const controller = new AbortController();
|
||||
const call = client.execute(
|
||||
"procedure_pseudo_code",
|
||||
{ procedure: "main" },
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
await started;
|
||||
controller.abort();
|
||||
const close = client.closeWithOutcome();
|
||||
expect(producer.closes).toBe(0);
|
||||
release?.();
|
||||
const result = await call;
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.error._tag).toBe("AnalysisCancelledError");
|
||||
expect((await close).ok).toBe(true);
|
||||
expect(producer.closes).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("headless IDA lifecycle boundaries", () => {
|
||||
it("never closes an unowned worker even when open echoes the requested private identity", async () => {
|
||||
const { producer, client } = await fixture("headless");
|
||||
producer.owned = false;
|
||||
expect((await client.execute("health", {})).ok).toBe(false);
|
||||
const closed = await client.closeWithOutcome();
|
||||
expect(closed.ok).toBe(false);
|
||||
if (!closed.ok) expect(closed.error.cleanupIncomplete).toBe(true);
|
||||
expect(producer.calls.some(({ name }) => name === "idb_close")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("headless IDA session ownership and cleanup", () => {
|
||||
it("retains the workspace after an unconfirmed open, even when inventory is empty, and never retries startup implicitly", async () => {
|
||||
const { producer, client, root } = await fixture("headless");
|
||||
producer.beforeCall = async (name) => {
|
||||
if (name === "idb_open")
|
||||
throw new Error("Timed out while the worker may still be opening");
|
||||
};
|
||||
expect((await client.execute("health", {})).ok).toBe(false);
|
||||
expect((await client.execute("health", {})).ok).toBe(false);
|
||||
expect(
|
||||
producer.calls.filter(({ name }) => name === "idb_open"),
|
||||
).toHaveLength(1);
|
||||
const closed = await client.closeWithOutcome();
|
||||
expect(closed.ok).toBe(false);
|
||||
if (!closed.ok) expect(closed.error.cleanupIncomplete).toBe(true);
|
||||
expect(
|
||||
(await readdir(root)).some((name) => name.startsWith("rea-ida-")),
|
||||
).toBe(true);
|
||||
expect(producer.closes).toBe(1);
|
||||
});
|
||||
it("opens a private digest-verified copy, scopes requests, closes without saving, and removes only its workspace", async () => {
|
||||
const { producer, client, root, target } = await fixture("headless");
|
||||
const original = await readFile(target.path);
|
||||
const result = await client.execute("analyze_function", {
|
||||
procedure: "main",
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) throw result.error;
|
||||
expect(producer.inputPath).not.toBe(target.path);
|
||||
expect(await readFile(producer.inputPath)).toEqual(original);
|
||||
expect(
|
||||
functionDossierSchema
|
||||
.parse(result.value.result)
|
||||
.limitations.some((value) => value.includes("Direct callers")),
|
||||
).toBe(true);
|
||||
const open = producer.calls.find(({ name }) => name === "idb_open");
|
||||
expect(open?.args).toMatchObject({
|
||||
mode: "force_headless",
|
||||
run_auto_analysis: true,
|
||||
});
|
||||
for (const call of producer.calls.filter(
|
||||
({ name }) => !["idb_open", "idb_list"].includes(name),
|
||||
))
|
||||
expect(call.args.database).toBe(producer.database);
|
||||
expect((await client.closeWithOutcome()).ok).toBe(true);
|
||||
expect(
|
||||
producer.calls.find(({ name }) => name === "idb_close")?.args.save,
|
||||
).toBe(false);
|
||||
expect(await readdir(root)).toEqual(["target.elf"]);
|
||||
expect(await readFile(target.path)).toEqual(original);
|
||||
});
|
||||
it("retains the private workspace and reports cleanup failure when worker release is unverified", async () => {
|
||||
const { producer, client, root } = await fixture("headless");
|
||||
await client.execute("health", {});
|
||||
producer.failClose = true;
|
||||
const result = await client.closeWithOutcome();
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.error.cleanupIncomplete).toBe(true);
|
||||
expect(
|
||||
(await readdir(root)).some((name) => name.startsWith("rea-ida-")),
|
||||
).toBe(true);
|
||||
});
|
||||
it("never closes an unrelated database when startup returns a different input identity", async () => {
|
||||
const { producer, client, root } = await fixture("headless");
|
||||
producer.overrideCall = (name) =>
|
||||
name === "idb_open"
|
||||
? {
|
||||
success: true,
|
||||
session: {
|
||||
session_id: "user-database",
|
||||
input_path: "/unrelated/input",
|
||||
},
|
||||
}
|
||||
: undefined;
|
||||
expect((await client.execute("health", {})).ok).toBe(false);
|
||||
expect((await client.closeWithOutcome()).ok).toBe(true);
|
||||
expect(producer.calls.some(({ name }) => name === "idb_close")).toBe(false);
|
||||
expect(await readdir(root)).toEqual(["target.elf"]);
|
||||
});
|
||||
it("releases a privately owned worker after malformed open output using verified session inventory", async () => {
|
||||
const { producer, client, root } = await fixture("headless");
|
||||
producer.beforeCall = async (name, args) => {
|
||||
if (name === "idb_open") {
|
||||
producer.database = z.string().parse(args.preferred_session_id);
|
||||
producer.inputPath = z.string().parse(args.input_path);
|
||||
}
|
||||
};
|
||||
producer.overrideCall = (name) =>
|
||||
name === "idb_open"
|
||||
? jsonValueSchema.parse({
|
||||
success: true,
|
||||
session: { session_id: producer.database },
|
||||
})
|
||||
: undefined;
|
||||
expect((await client.execute("health", {})).ok).toBe(false);
|
||||
expect((await client.closeWithOutcome()).ok).toBe(true);
|
||||
expect(
|
||||
producer.calls.find(({ name }) => name === "idb_close")?.args.database,
|
||||
).toMatch(/^rea-/u);
|
||||
expect(await readdir(root)).toEqual(["target.elf"]);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,472 @@
|
|||
import { z } from "zod";
|
||||
import {
|
||||
createAnalysisExecution,
|
||||
type AnalysisClient,
|
||||
type AnalysisOperation,
|
||||
type ExecutionOptions,
|
||||
} from "../application/AnalysisProvider.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisCapabilityUnavailableError,
|
||||
AnalysisProtocolError,
|
||||
AnalysisOutputError,
|
||||
AnalysisInputError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
|
||||
import type { BinaryTarget } from "../domain/binaryTarget.js";
|
||||
import type { AnalysisProfileCommitment } from "../domain/analysisProfile.js";
|
||||
import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import type { IdaConfiguration } from "./IdaConfiguration.js";
|
||||
import type { IdaMcpConnection } from "./IdaMcpConnection.js";
|
||||
import { IdaOperationRunner } from "./IdaOperationRunner.js";
|
||||
import {
|
||||
IDA_LIMITATIONS,
|
||||
IDA_OPERATIONS,
|
||||
IDA_PROVIDER_IDENTITY,
|
||||
type IdaOperation,
|
||||
} from "./IdaProviderCapabilities.js";
|
||||
import {
|
||||
closeDatabaseSchema,
|
||||
databaseListSchema,
|
||||
legacyMetadataSchema,
|
||||
modernHealthSchema,
|
||||
openDatabaseSchema,
|
||||
} from "./IdaProtocolValues.js";
|
||||
import {
|
||||
idaFileDigest,
|
||||
IdaWorkspace,
|
||||
sameIdaHostPath,
|
||||
} from "./IdaWorkspace.js";
|
||||
import { parseIdaInput } from "./IdaInput.js";
|
||||
|
||||
const legacyTools = [
|
||||
"get_metadata",
|
||||
"list_functions",
|
||||
"list_strings",
|
||||
"get_function_by_name",
|
||||
"get_function_by_address",
|
||||
"decompile_function",
|
||||
"disassemble_function",
|
||||
"get_callers",
|
||||
"get_callees",
|
||||
"get_xrefs_to",
|
||||
];
|
||||
const modernTools = [
|
||||
"idb_open",
|
||||
"idb_list",
|
||||
"idb_close",
|
||||
"server_health",
|
||||
"list_funcs",
|
||||
"lookup_funcs",
|
||||
"find_regex",
|
||||
"decompile",
|
||||
"disasm",
|
||||
"callees",
|
||||
"xrefs_to",
|
||||
];
|
||||
const operationSet: ReadonlySet<string> = new Set(IDA_OPERATIONS);
|
||||
const isIdaOperation = (operation: string): operation is IdaOperation =>
|
||||
operationSet.has(operation);
|
||||
const wasCancelled = (options: ExecutionOptions | undefined): boolean =>
|
||||
options?.signal?.aborted === true;
|
||||
|
||||
/** A failed cleanup retains its private workspace and reports the remaining resource. */
|
||||
class IdaCleanupError extends ProviderAdapterError {
|
||||
override readonly cleanupIncomplete = true;
|
||||
override readonly cleanupResources: readonly string[];
|
||||
constructor(path: string, cause: unknown) {
|
||||
super("ida", "close", {
|
||||
cause,
|
||||
diagnostics: {
|
||||
reason:
|
||||
"IDA worker release could not be verified. The private workspace was retained.",
|
||||
workspace: path,
|
||||
},
|
||||
});
|
||||
this.cleanupResources = [path];
|
||||
}
|
||||
}
|
||||
|
||||
/** Serial read-only session whose lifecycle authority excludes an attached GUI. */
|
||||
export class IdaSessionClient implements AnalysisClient {
|
||||
#tail: Promise<void> = Promise.resolve();
|
||||
#startup: Promise<void> | undefined;
|
||||
#closed = false;
|
||||
#closing = false;
|
||||
#workspace: IdaWorkspace | undefined;
|
||||
#database: string | undefined;
|
||||
#openAttempted = false;
|
||||
#openCompleted = false;
|
||||
#initialIdentity: string | undefined;
|
||||
#metadata: JsonValue = null;
|
||||
#lifecycle: JsonValue[] = [];
|
||||
|
||||
constructor(
|
||||
private readonly config: IdaConfiguration,
|
||||
private readonly target: BinaryTarget,
|
||||
private readonly connection: IdaMcpConnection,
|
||||
private readonly profile?: AnalysisProfileCommitment,
|
||||
) {}
|
||||
|
||||
execute(
|
||||
operation: AnalysisOperation,
|
||||
parameters: Readonly<Record<string, JsonValue>>,
|
||||
options?: ExecutionOptions,
|
||||
) {
|
||||
const call = this.#tail.then(() =>
|
||||
this.#execute(operation, parameters, options),
|
||||
);
|
||||
this.#tail = call.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
return call;
|
||||
}
|
||||
|
||||
async #execute(
|
||||
operation: AnalysisOperation,
|
||||
parameters: Readonly<Record<string, JsonValue>>,
|
||||
options?: ExecutionOptions,
|
||||
) {
|
||||
if (this.#closed || this.#closing)
|
||||
return err(
|
||||
new AnalysisProtocolError(
|
||||
"IDA session is closing or closed; reopen the target.",
|
||||
),
|
||||
);
|
||||
if (wasCancelled(options))
|
||||
return err(new AnalysisCancelledError(operation));
|
||||
if (operation !== "health" && !isIdaOperation(operation))
|
||||
return err(
|
||||
new AnalysisCapabilityUnavailableError(
|
||||
"ida",
|
||||
operation,
|
||||
"This integration admits read-only analysis operations only.",
|
||||
),
|
||||
);
|
||||
if (operation === "procedure_callers" && this.config.mode === "headless")
|
||||
return err(
|
||||
new AnalysisCapabilityUnavailableError(
|
||||
"ida",
|
||||
operation,
|
||||
"The modern upstream profile does not prove direct callers.",
|
||||
),
|
||||
);
|
||||
if (operation !== "health") {
|
||||
const parsed = parseIdaInput(operation, parameters);
|
||||
if (!parsed.ok) return parsed;
|
||||
parameters = parsed.value;
|
||||
}
|
||||
const document = parameters.document;
|
||||
if (
|
||||
document !== undefined &&
|
||||
document !== this.target.path &&
|
||||
document !== this.target.sourcePath
|
||||
)
|
||||
return err(
|
||||
new AnalysisInputError(operation, undefined, [
|
||||
{
|
||||
path: ["document"],
|
||||
reason: "invalid_value",
|
||||
expected: this.target.path,
|
||||
message:
|
||||
"IDA document must be the REA-bound target; omit document or use its admitted path.",
|
||||
},
|
||||
]),
|
||||
);
|
||||
try {
|
||||
await this.#start();
|
||||
const before = await this.#observe();
|
||||
if (wasCancelled(options))
|
||||
return err(new AnalysisCancelledError(operation));
|
||||
if (operation === "health") return this.#health(before);
|
||||
if (!isIdaOperation(operation))
|
||||
throw new AnalysisProtocolError(
|
||||
"IDA operation changed after validation.",
|
||||
);
|
||||
const runner = new IdaOperationRunner(
|
||||
this.connection,
|
||||
this.config.mode === "attached" ? "legacy" : "modern",
|
||||
this.#database,
|
||||
);
|
||||
const result = await runner.run(operation, parameters);
|
||||
const after = await this.#observe();
|
||||
if (wasCancelled(options))
|
||||
return err(new AnalysisCancelledError(operation));
|
||||
return ok(
|
||||
createAnalysisExecution(result, IDA_PROVIDER_IDENTITY, {
|
||||
rawResult: {
|
||||
upstream: this.connection.serverInfo(),
|
||||
metadata_before: before,
|
||||
metadata_after: after,
|
||||
observations: jsonValueSchema.parse(runner.raw),
|
||||
},
|
||||
limitations: [...IDA_LIMITATIONS, ...runner.limitations],
|
||||
locations: [
|
||||
{ kind: "artifact-path", path: this.target.path },
|
||||
...runner.locations,
|
||||
],
|
||||
...(this.profile === undefined
|
||||
? {}
|
||||
: { analysisProfile: this.profile }),
|
||||
}),
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
return this.#failure(operation, cause, options);
|
||||
}
|
||||
}
|
||||
|
||||
#health(metadata: JsonValue) {
|
||||
return ok(
|
||||
createAnalysisExecution(
|
||||
{
|
||||
ready: true,
|
||||
mode: this.config.mode,
|
||||
database: this.#database ?? null,
|
||||
upstream: this.connection.serverInfo(),
|
||||
metadata,
|
||||
lifecycle: this.#lifecycle,
|
||||
},
|
||||
IDA_PROVIDER_IDENTITY,
|
||||
{
|
||||
limitations: IDA_LIMITATIONS,
|
||||
...(this.profile === undefined
|
||||
? {}
|
||||
: { analysisProfile: this.profile }),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
#failure(
|
||||
operation: string,
|
||||
cause: unknown,
|
||||
options: ExecutionOptions | undefined,
|
||||
) {
|
||||
if (wasCancelled(options))
|
||||
return err(new AnalysisCancelledError(operation));
|
||||
if (cause instanceof AnalysisError) return err(cause);
|
||||
if (cause instanceof z.ZodError)
|
||||
return err(
|
||||
new AnalysisOutputError(
|
||||
operation,
|
||||
`IDA MCP returned malformed or unsupported producer data: ${cause.issues.map((issue) => `${issue.path.join(".") || "$"}: ${issue.message}`).join("; ")}`,
|
||||
{ cause },
|
||||
),
|
||||
);
|
||||
return err(
|
||||
new ProviderAdapterError("ida", operation, {
|
||||
cause,
|
||||
diagnostics: {
|
||||
reason:
|
||||
cause instanceof Error ? cause.message : "Unknown upstream failure",
|
||||
recovery:
|
||||
"Check the configured upstream MCP registration and selected target; see docs/ida-provider.md.",
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
#start(): Promise<void> {
|
||||
this.#startup ??= this.#initialize();
|
||||
return this.#startup;
|
||||
}
|
||||
|
||||
async #initialize(): Promise<void> {
|
||||
const tools = new Set(await this.connection.connect());
|
||||
const expected =
|
||||
this.config.mode === "attached" ? legacyTools : modernTools;
|
||||
const missing = expected.filter((name) => !tools.has(name));
|
||||
if (missing.length > 0)
|
||||
throw new AnalysisProtocolError(
|
||||
`IDA MCP ${this.config.mode} compatibility profile is missing tools: ${missing.join(", ")}. Attached mode requires the legacy 1.4 profile; headless mode requires the database supervisor profile. See docs/ida-provider.md.`,
|
||||
);
|
||||
if (this.config.mode === "headless") {
|
||||
this.#workspace = await IdaWorkspace.create(
|
||||
this.target,
|
||||
this.config.workspaceRoot,
|
||||
);
|
||||
this.#openAttempted = true;
|
||||
const raw = await this.connection.call("idb_open", {
|
||||
input_path: this.#workspace.inputPath,
|
||||
mode: "force_headless",
|
||||
run_auto_analysis: true,
|
||||
build_caches: true,
|
||||
init_hexrays: true,
|
||||
preferred_session_id: this.#workspace.sessionId,
|
||||
});
|
||||
this.#openCompleted = true;
|
||||
this.#lifecycle.push(raw);
|
||||
const opened = openDatabaseSchema.parse(raw);
|
||||
if (
|
||||
!sameIdaHostPath(opened.session.input_path, this.#workspace.inputPath)
|
||||
)
|
||||
throw new AnalysisProtocolError(
|
||||
"IDA opened a different input than the private admitted target copy.",
|
||||
);
|
||||
if (opened.session.session_id !== this.#workspace.sessionId)
|
||||
throw new AnalysisProtocolError(
|
||||
"IDA returned an adopted or unexpected session instead of the requested private worker.",
|
||||
);
|
||||
const inventory = databaseListSchema.parse(
|
||||
await this.connection.call("idb_list", {}),
|
||||
);
|
||||
this.#lifecycle.push(jsonValueSchema.parse(inventory));
|
||||
const entry = inventory.sessions.find(
|
||||
({ session_id }) => session_id === opened.session.session_id,
|
||||
);
|
||||
if (
|
||||
entry?.owned !== true ||
|
||||
entry.backend !== "worker" ||
|
||||
entry.is_active !== true ||
|
||||
!sameIdaHostPath(entry.input_path, this.#workspace.inputPath)
|
||||
)
|
||||
throw new AnalysisProtocolError(
|
||||
"IDA did not return an active owned headless worker for the private input copy.",
|
||||
);
|
||||
this.#database = opened.session.session_id;
|
||||
}
|
||||
}
|
||||
|
||||
async #observe(): Promise<JsonValue> {
|
||||
let identity: string;
|
||||
if (this.config.mode === "attached") {
|
||||
const raw = await this.connection.call("get_metadata", {});
|
||||
const metadata = legacyMetadataSchema.parse(raw);
|
||||
if (metadata.sha256.toLowerCase() !== this.target.sha256.toLowerCase())
|
||||
throw new AnalysisProtocolError(
|
||||
`The attached IDA input SHA-256 does not match the selected target ${this.target.path}; open that target in IDA or use headless mode.`,
|
||||
);
|
||||
identity = JSON.stringify([
|
||||
metadata.path,
|
||||
metadata.sha256.toLowerCase(),
|
||||
metadata.base,
|
||||
]);
|
||||
this.#metadata = raw;
|
||||
} else {
|
||||
if (this.#database === undefined || this.#workspace === undefined)
|
||||
throw new AnalysisProtocolError(
|
||||
"IDA headless session has no database binding.",
|
||||
);
|
||||
const raw = await this.connection.call("server_health", {
|
||||
database: this.#database,
|
||||
});
|
||||
const metadata = modernHealthSchema.parse(raw);
|
||||
if (
|
||||
!sameIdaHostPath(metadata.input_path, this.#workspace.inputPath) ||
|
||||
(await idaFileDigest(this.#workspace.inputPath)) !== this.target.sha256
|
||||
)
|
||||
throw new AnalysisProtocolError(
|
||||
"IDA headless input identity changed after target admission.",
|
||||
);
|
||||
if (metadata.auto_analysis_ready !== true)
|
||||
throw new AnalysisProtocolError(
|
||||
"IDA auto-analysis is incomplete or readiness is unknown; retry after analysis completes.",
|
||||
);
|
||||
identity = JSON.stringify([
|
||||
this.#database,
|
||||
metadata.input_path,
|
||||
metadata.idb_path,
|
||||
metadata.imagebase,
|
||||
]);
|
||||
this.#metadata = raw;
|
||||
}
|
||||
if (
|
||||
this.#initialIdentity !== undefined &&
|
||||
identity !== this.#initialIdentity
|
||||
)
|
||||
throw new AnalysisProtocolError(
|
||||
"The attached IDA target or image base changed during the REA session; reopen the target.",
|
||||
);
|
||||
this.#initialIdentity = identity;
|
||||
return this.#metadata;
|
||||
}
|
||||
|
||||
async closeWithOutcome(): Promise<Result<null, AnalysisError>> {
|
||||
this.#closing = true;
|
||||
await this.#tail;
|
||||
if (this.#closed) return ok(null);
|
||||
try {
|
||||
if (this.#workspace !== undefined && this.#openAttempted) {
|
||||
if (this.#database === undefined) {
|
||||
const inventory = databaseListSchema.parse(
|
||||
await this.connection.call("idb_list", {}),
|
||||
);
|
||||
const workspace = this.#workspace;
|
||||
this.#database = inventory.sessions.find(
|
||||
(entry) =>
|
||||
entry.owned &&
|
||||
entry.backend === "worker" &&
|
||||
entry.session_id === workspace.sessionId &&
|
||||
sameIdaHostPath(entry.input_path, workspace.inputPath),
|
||||
)?.session_id;
|
||||
if (
|
||||
this.#database === undefined &&
|
||||
inventory.sessions.some((entry) =>
|
||||
sameIdaHostPath(entry.input_path, workspace.inputPath),
|
||||
)
|
||||
)
|
||||
throw new AnalysisProtocolError(
|
||||
"An unowned worker still uses the private workspace; it was retained.",
|
||||
);
|
||||
}
|
||||
if (this.#database !== undefined) {
|
||||
const closed = closeDatabaseSchema.parse(
|
||||
await this.connection.call("idb_close", {
|
||||
database: this.#database,
|
||||
save: false,
|
||||
}),
|
||||
);
|
||||
if (
|
||||
closed.session_id !== this.#database ||
|
||||
closed.saved === true ||
|
||||
closed.owned !== true ||
|
||||
closed.backend !== "worker"
|
||||
)
|
||||
throw new AnalysisProtocolError(
|
||||
"IDA database close did not confirm the requested owned, unsaved session release.",
|
||||
);
|
||||
const inventory = databaseListSchema.parse(
|
||||
await this.connection.call("idb_list", {}),
|
||||
);
|
||||
if (
|
||||
inventory.sessions.some(
|
||||
(entry) =>
|
||||
entry.session_id === this.#database ||
|
||||
sameIdaHostPath(
|
||||
entry.input_path,
|
||||
this.#workspace?.inputPath ?? "",
|
||||
),
|
||||
)
|
||||
)
|
||||
throw new AnalysisProtocolError(
|
||||
"IDA worker remains registered after close.",
|
||||
);
|
||||
}
|
||||
if (!this.#openCompleted)
|
||||
throw new AnalysisProtocolError(
|
||||
"IDA database open did not return a completed response. Worker inventory cannot prove that the request has stopped; the private workspace was retained.",
|
||||
);
|
||||
}
|
||||
await this.connection.close();
|
||||
await this.#workspace?.remove();
|
||||
this.#closed = true;
|
||||
return ok(null);
|
||||
} catch (cause: unknown) {
|
||||
await this.connection.close().catch(() => undefined);
|
||||
return err(
|
||||
new IdaCleanupError(
|
||||
this.#workspace?.directory ?? "IDA MCP connection",
|
||||
cause,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
const result = await this.closeWithOutcome();
|
||||
if (!result.ok) throw result.error;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { createReadStream, constants } from "node:fs";
|
||||
import { copyFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { basename, join, normalize } from "node:path";
|
||||
import type { BinaryTarget } from "../domain/binaryTarget.js";
|
||||
import { AnalysisProtocolError } from "../domain/analysisErrorCore.js";
|
||||
import { PrivateRuntimeRoot } from "../process/PrivateRuntimeRoot.js";
|
||||
|
||||
/** Read a file digest with bounded memory rather than retaining binary bytes. */
|
||||
export const idaFileDigest = async (path: string): Promise<string> => {
|
||||
const hash = createHash("sha256");
|
||||
for await (const chunk of createReadStream(path)) hash.update(chunk);
|
||||
return hash.digest("hex");
|
||||
};
|
||||
|
||||
/** Compare host-local paths without confusing Windows paths with POSIX coordinates. */
|
||||
export const sameIdaHostPath = (left: string, right: string): boolean =>
|
||||
process.platform === "win32"
|
||||
? normalize(left).toLowerCase() === normalize(right).toLowerCase()
|
||||
: normalize(left) === normalize(right);
|
||||
|
||||
/** Private input copy isolates upstream database and sidecar writes from the original. */
|
||||
export class IdaWorkspace {
|
||||
readonly sessionId = `rea-${randomUUID()}`;
|
||||
private constructor(
|
||||
private readonly runtime: PrivateRuntimeRoot,
|
||||
readonly inputPath: string,
|
||||
) {}
|
||||
|
||||
/** Filesystem root owned exclusively by this headless session. */
|
||||
get directory(): string {
|
||||
return this.runtime.path;
|
||||
}
|
||||
|
||||
/** Allocate a protected workspace and copy the admitted input with digest verification. */
|
||||
static async create(
|
||||
target: BinaryTarget,
|
||||
root = tmpdir(),
|
||||
): Promise<IdaWorkspace> {
|
||||
const runtime = await PrivateRuntimeRoot.create({
|
||||
parent: normalize(root),
|
||||
prefix: "rea-ida-",
|
||||
});
|
||||
const inputPath = join(runtime.path, basename(target.path));
|
||||
try {
|
||||
await copyFile(target.path, inputPath, constants.COPYFILE_EXCL);
|
||||
if ((await idaFileDigest(inputPath)) !== target.sha256)
|
||||
throw new AnalysisProtocolError(
|
||||
"IDA target changed between admission and the private input copy; reopen the target.",
|
||||
);
|
||||
return new IdaWorkspace(runtime, inputPath);
|
||||
} catch (cause: unknown) {
|
||||
await runtime.close();
|
||||
throw cause;
|
||||
}
|
||||
}
|
||||
|
||||
/** Delete only this object's privately allocated directory after worker cleanup. */
|
||||
async remove(): Promise<void> {
|
||||
await this.runtime.close();
|
||||
}
|
||||
}
|
||||
|
|
@ -25,6 +25,7 @@ import { mcpProgressReporter } from "./mcpProgress.js";
|
|||
import { logToolExecution } from "./toolLogging.js";
|
||||
import { toolRegistrationOptions } from "./toolRegistrationOptions.js";
|
||||
import { toCallToolResult } from "./toolResult.js";
|
||||
import { executeFunctionAnalysisEvidence } from "../application/FunctionAnalysisEvidence.js";
|
||||
|
||||
/** Optional session services used by enhanced tool registration. */
|
||||
export interface EnhancedToolRegistration {
|
||||
|
|
@ -208,6 +209,11 @@ const executeEnhancedTool = async (
|
|||
});
|
||||
const parsedInput = validatedCall.input;
|
||||
const parameters = jsonParameters(parsedInput);
|
||||
if (name === "analyze_function")
|
||||
return executeFunctionTool(analysis, registration, contract, {
|
||||
context,
|
||||
parameters,
|
||||
});
|
||||
const services = new EnhancedTools({
|
||||
execute: (operation, operationParameters, executionOptions) =>
|
||||
analysis.execute(operation, operationParameters, {
|
||||
|
|
@ -260,6 +266,46 @@ const executeEnhancedTool = async (
|
|||
return toCallToolResult(result, contract);
|
||||
};
|
||||
|
||||
const executeFunctionTool = async (
|
||||
analysis: AnalysisOperationPort,
|
||||
registration: EnhancedToolRegistration,
|
||||
contract: ToolContract,
|
||||
request: {
|
||||
readonly context: ServerContext;
|
||||
readonly parameters: Readonly<Record<string, JsonValue>>;
|
||||
},
|
||||
): Promise<CallToolResult> => {
|
||||
const { context, parameters } = request;
|
||||
const progress = mcpProgressReporter(context);
|
||||
const result = await logToolExecution(
|
||||
registration.logger,
|
||||
"analyze_function",
|
||||
() =>
|
||||
executeFunctionAnalysisEvidence(
|
||||
analysis,
|
||||
parameters,
|
||||
registration.activeTarget?.(),
|
||||
{
|
||||
signal: context.mcpReq.signal,
|
||||
progress,
|
||||
},
|
||||
),
|
||||
);
|
||||
await progress.report({
|
||||
phase: "analyze_function",
|
||||
completed: 1,
|
||||
total: 1,
|
||||
message: result.ok ? "completed" : "failed",
|
||||
terminal: true,
|
||||
});
|
||||
if (result.ok) {
|
||||
const recorded = registration.recordEvidence?.(result.value);
|
||||
if (recorded !== undefined && !recorded.ok)
|
||||
return toCallToolResult(recorded, contract);
|
||||
}
|
||||
return toCallToolResult(result, contract);
|
||||
};
|
||||
|
||||
const jsonParameters = (
|
||||
input: Readonly<Record<string, JsonValue | undefined>>,
|
||||
): Record<string, JsonValue> =>
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ import {
|
|||
GHIDRA_PROVIDER_TOOL_CONTRACTS,
|
||||
} from "../../../src/ghidra/GhidraProvider.js";
|
||||
import { NATIVE_MACOS_PROVIDER_IDENTITY } from "../../../src/native/NativeMacOSProvider.js";
|
||||
import { IDA_PROVIDER_IDENTITY } from "../../../src/ida/IdaProvider.js";
|
||||
import {
|
||||
assertDocumentationFacts,
|
||||
documentationFactIssues,
|
||||
|
|
@ -101,6 +102,7 @@ describe("canonical product catalog", () => {
|
|||
BINWALK_PROVIDER_IDENTITY,
|
||||
UNBLOB_PROVIDER_IDENTITY,
|
||||
GHIDRA_PROVIDER_IDENTITY,
|
||||
IDA_PROVIDER_IDENTITY,
|
||||
NATIVE_MACOS_PROVIDER_IDENTITY,
|
||||
ARTIFACT_GRAPH_PROVIDER,
|
||||
MANAGED_STATIC_PROVIDER,
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ describe("direct analysis snapshot files", () => {
|
|||
details: {
|
||||
selection_reason: "unknown_provider",
|
||||
requested_provider_id: "request-provider",
|
||||
candidate_ids: ["ghidra", "hopper"],
|
||||
candidate_ids: ["ghidra", "hopper", "ida"],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -97,7 +97,7 @@ describe("Ghidra MCP evidence parity", () => {
|
|||
);
|
||||
expect(analyzed).toMatchObject({
|
||||
operation: "analyze_function",
|
||||
provider: { id: "rea-workflow", version: "1" },
|
||||
provider: { id: "ghidra", version: "12.1.4" },
|
||||
normalized_result: {
|
||||
procedure: {
|
||||
address: "0x401000",
|
||||
|
|
@ -121,9 +121,12 @@ describe("Ghidra MCP evidence parity", () => {
|
|||
),
|
||||
]),
|
||||
},
|
||||
limitations: [
|
||||
"Derived by an REA workflow from one or more provider observations.",
|
||||
],
|
||||
limitations: expect.arrayContaining([
|
||||
expect.stringContaining("default auto-analysis complete"),
|
||||
expect.stringContaining(
|
||||
"original executable bytes are never written",
|
||||
),
|
||||
]),
|
||||
});
|
||||
const directAnalyzed = await new EnhancedTools(session).execute(
|
||||
"analyze_function",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,137 @@
|
|||
import { createServer } from "node:http";
|
||||
import { afterEach, expect, it } from "vitest";
|
||||
import { z } from "zod";
|
||||
import { createIdaMcpConnection } from "../../../src/ida/IdaMcpConnection.js";
|
||||
import type { IdaMcpConnection } from "../../../src/ida/IdaMcpConnection.js";
|
||||
|
||||
const cleanups: (() => Promise<void>)[] = [];
|
||||
afterEach(async () => {
|
||||
for (const cleanup of cleanups.splice(0).reverse()) await cleanup();
|
||||
});
|
||||
const requestSchema = z.object({
|
||||
id: z.union([z.string(), z.number()]).optional(),
|
||||
method: z.string(),
|
||||
params: z
|
||||
.object({
|
||||
protocolVersion: z.string().optional(),
|
||||
cursor: z.string().optional(),
|
||||
})
|
||||
.passthrough()
|
||||
.optional(),
|
||||
});
|
||||
const fixture = async (redirectUrl?: string, cycle = false) => {
|
||||
const methods: string[] = [];
|
||||
const headers: (string | undefined)[] = [];
|
||||
const server = createServer(async (request, response) => {
|
||||
if (redirectUrl !== undefined) {
|
||||
response.writeHead(307, { Location: redirectUrl });
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
if (request.method !== "POST") {
|
||||
response.writeHead(405);
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
let body = "";
|
||||
for await (const chunk of request) body += String(chunk);
|
||||
const input = requestSchema.parse(JSON.parse(body));
|
||||
methods.push(input.method);
|
||||
headers.push(request.headers.authorization);
|
||||
if (input.id === undefined) {
|
||||
response.writeHead(202);
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
const result =
|
||||
input.method === "initialize"
|
||||
? {
|
||||
protocolVersion: input.params?.protocolVersion,
|
||||
capabilities: { tools: {} },
|
||||
serverInfo: {
|
||||
name: "ida-transport-fixture",
|
||||
version: "observed-server-version",
|
||||
},
|
||||
}
|
||||
: input.method === "tools/list"
|
||||
? {
|
||||
tools: [
|
||||
{
|
||||
name: input.params?.cursor === undefined ? "first" : "second",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
},
|
||||
],
|
||||
...(input.params?.cursor === undefined
|
||||
? { nextCursor: "next-page" }
|
||||
: cycle
|
||||
? {
|
||||
nextCursor:
|
||||
input.params.cursor === "next-page"
|
||||
? "other-page"
|
||||
: "next-page",
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
: { content: [], structuredContent: { observed: true } };
|
||||
response.writeHead(200, { "Content-Type": "application/json" });
|
||||
response.end(JSON.stringify({ jsonrpc: "2.0", id: input.id, result }));
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
cleanups.push(
|
||||
() =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
server.close((error) =>
|
||||
error === undefined ? resolve() : reject(error),
|
||||
);
|
||||
server.closeAllConnections();
|
||||
}),
|
||||
);
|
||||
const address = server.address();
|
||||
if (address === null || typeof address === "string")
|
||||
throw new Error("Missing HTTP fixture address");
|
||||
const connection: IdaMcpConnection = createIdaMcpConnection({
|
||||
url: `http://127.0.0.1:${address.port}/mcp`,
|
||||
headers: { Authorization: "Bearer transport-fixture-secret" },
|
||||
mode: "headless",
|
||||
timeoutMs: 2000,
|
||||
});
|
||||
cleanups.push(() => connection.close());
|
||||
return {
|
||||
connection,
|
||||
methods,
|
||||
headers,
|
||||
url: `http://127.0.0.1:${address.port}/mcp`,
|
||||
};
|
||||
};
|
||||
|
||||
it("uses the pinned SDK for a local Streamable HTTP handshake, paged discovery, structured results, and configured authentication", async () => {
|
||||
const { connection, methods, headers } = await fixture();
|
||||
expect(await connection.connect()).toEqual(["first", "second"]);
|
||||
expect(connection.serverInfo()).toEqual({
|
||||
name: "ida-transport-fixture",
|
||||
version: "observed-server-version",
|
||||
});
|
||||
expect(await connection.call("first", {})).toEqual({ observed: true });
|
||||
expect(methods).toContain("notifications/initialized");
|
||||
expect(methods.filter((name) => name === "tools/list")).toHaveLength(2);
|
||||
expect(
|
||||
headers.every((value) => value === "Bearer transport-fixture-secret"),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects redirects instead of forwarding transport credentials to another endpoint", async () => {
|
||||
const destination = await fixture();
|
||||
const { connection } = await fixture(destination.url);
|
||||
await expect(connection.connect()).rejects.toThrow();
|
||||
expect(destination.headers).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects nonterminating SDK pagination instead of returning a partial tool catalog", async () => {
|
||||
const { connection, methods } = await fixture(undefined, true);
|
||||
await expect(connection.connect()).rejects.toThrow(
|
||||
"pagination did not terminate",
|
||||
);
|
||||
expect(
|
||||
methods.filter((method) => method === "tools/list").length,
|
||||
).toBeGreaterThan(2);
|
||||
});
|
||||
|
|
@ -0,0 +1,159 @@
|
|||
import { rm, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { IdaProvider } from "../../src/ida/IdaProvider.js";
|
||||
import { createIdaTarget, RecordingIdaMcp } from "../fixtures/idaMcp.js";
|
||||
import { createTestBinarySession } from "../fixtures/binarySession.js";
|
||||
import { parseConfig } from "../../src/config.js";
|
||||
import {
|
||||
createAnalysisSnapshotEntry,
|
||||
snapshotBinding,
|
||||
snapshotTarget,
|
||||
} from "../../src/domain/analysisSnapshot.js";
|
||||
import { createEvidence } from "../../src/domain/evidence.js";
|
||||
import { createEvidenceBundle } from "../../src/domain/evidenceBundle.js";
|
||||
import { createAnalysisExecution } from "../../src/application/AnalysisProvider.js";
|
||||
import { IDA_PROVIDER_IDENTITY } from "../../src/ida/IdaProvider.js";
|
||||
|
||||
const roots: string[] = [];
|
||||
afterEach(async () => {
|
||||
for (const root of roots.splice(0))
|
||||
await rm(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("IDA provider composition", () => {
|
||||
it("discovers without launching and commits adapter semantics without persisting credentials", async () => {
|
||||
const { root, target } = await createIdaTarget();
|
||||
roots.push(root);
|
||||
const path = join(root, "registration.json");
|
||||
await writeFile(
|
||||
path,
|
||||
JSON.stringify({
|
||||
url: "http://127.0.0.1:8745/mcp",
|
||||
headers: { Authorization: "Bearer private-fixture-token" },
|
||||
mode: "headless",
|
||||
}),
|
||||
);
|
||||
const config = parseConfig({ REA_IDA_MCP_CONFIG: path });
|
||||
if (!config.ok) throw config.error;
|
||||
const producer = new RecordingIdaMcp(target, "headless");
|
||||
const provider = new IdaProvider(config.value, () => producer);
|
||||
expect(provider.inspectAvailability()).toMatchObject({
|
||||
status: "available",
|
||||
diagnostics: { live_connection_probed: false },
|
||||
});
|
||||
const resolution = await provider.resolveAnalysisProfile(target);
|
||||
expect(resolution.ok).toBe(true);
|
||||
if (!resolution.ok) throw resolution.error;
|
||||
expect(resolution.value.profile.parameters).toMatchObject({
|
||||
engine_version: null,
|
||||
upstream_distribution_version: null,
|
||||
cache_policy: "live",
|
||||
version_scope: "rea-ida-adapter",
|
||||
});
|
||||
expect(JSON.stringify(resolution)).not.toContain("private-fixture-token");
|
||||
expect(producer.connects).toBe(0);
|
||||
expect(
|
||||
provider
|
||||
.capabilities()
|
||||
.find(({ operation }) => operation === "procedure_callers")?.available,
|
||||
).toBe(false);
|
||||
});
|
||||
it("re-observes externally mutable state even when a document is explicit and a snapshot is imported", async () => {
|
||||
const { root, target } = await createIdaTarget();
|
||||
roots.push(root);
|
||||
const path = join(root, "registration.json");
|
||||
await writeFile(path, JSON.stringify({ command: "fixture" }));
|
||||
const config = parseConfig({ REA_IDA_MCP_CONFIG: path });
|
||||
if (!config.ok) throw config.error;
|
||||
const producer = new RecordingIdaMcp(target);
|
||||
const session = createTestBinarySession(
|
||||
new IdaProvider(config.value, () => producer),
|
||||
);
|
||||
expect((await session.open(target.path)).ok).toBe(true);
|
||||
const query = { procedure: "main", document: target.path };
|
||||
const first = await session.execute("procedure_pseudo_code", query);
|
||||
expect(first.ok && first.value.result).toContain("42");
|
||||
const snapshot = session.exportAnalysisSnapshot();
|
||||
expect(snapshot.ok).toBe(true);
|
||||
if (!snapshot.ok) throw snapshot.error;
|
||||
expect(snapshot.value.entries).toEqual([]);
|
||||
const profile = session.analysisProfile();
|
||||
if (profile === undefined) throw new Error("Missing profile");
|
||||
const cached = createAnalysisExecution(
|
||||
"stale output",
|
||||
IDA_PROVIDER_IDENTITY,
|
||||
{
|
||||
analysisProfile: profile,
|
||||
subject: {
|
||||
path: target.path,
|
||||
sha256: target.sha256,
|
||||
format: target.format,
|
||||
...(target.architecture === undefined
|
||||
? {}
|
||||
: { architecture: target.architecture }),
|
||||
},
|
||||
},
|
||||
);
|
||||
snapshot.value.entries.push(
|
||||
createAnalysisSnapshotEntry({
|
||||
target: snapshotTarget(target),
|
||||
binding: snapshotBinding(profile),
|
||||
operation: "procedure_pseudo_code",
|
||||
parameters: query,
|
||||
execution: cached,
|
||||
}),
|
||||
);
|
||||
snapshot.value.evidence_bundle = createEvidenceBundle([
|
||||
...snapshot.value.evidence_bundle.records,
|
||||
createEvidence(target, IDA_PROVIDER_IDENTITY, {
|
||||
operation: "procedure_pseudo_code",
|
||||
parameters: query,
|
||||
result: cached.result,
|
||||
rawResult: cached.rawResult,
|
||||
analysisProfile: profile,
|
||||
}),
|
||||
]);
|
||||
expect(session.importAnalysisSnapshot(snapshot.value).ok).toBe(true);
|
||||
expect(session.allowsSnapshotReplay("procedure_pseudo_code")).toBe(false);
|
||||
expect(session.allowsSnapshotReplay("binary_overview")).toBe(false);
|
||||
producer.pseudocode = "int main(void) { return 99; }";
|
||||
const second = await session.execute("procedure_pseudo_code", query);
|
||||
expect(second.ok && second.value.result).toContain("99");
|
||||
expect(
|
||||
producer.calls.filter(({ name }) => name === "decompile_function"),
|
||||
).toHaveLength(2);
|
||||
await session.close();
|
||||
expect(producer.closes).toBe(1);
|
||||
});
|
||||
it("rejects unsupported target kinds and incomplete upstream compatibility profiles with useful reasons", async () => {
|
||||
const { root, target } = await createIdaTarget();
|
||||
roots.push(root);
|
||||
const path = join(root, "registration.json");
|
||||
await writeFile(
|
||||
path,
|
||||
JSON.stringify({
|
||||
command: "fixture",
|
||||
mode: "headless",
|
||||
workspaceRoot: root,
|
||||
}),
|
||||
);
|
||||
const config = parseConfig({ REA_IDA_MCP_CONFIG: path });
|
||||
if (!config.ok) throw config.error;
|
||||
const producer = new RecordingIdaMcp(target);
|
||||
const provider = new IdaProvider(config.value, () => producer);
|
||||
expect(
|
||||
provider.inspectTargetSupport({
|
||||
path: target.path,
|
||||
sha256: target.sha256,
|
||||
kind: "database",
|
||||
format: "analysis-database",
|
||||
}),
|
||||
).toMatchObject({ status: "unsupported", code: "target_kind_unsupported" });
|
||||
const session = createTestBinarySession(provider);
|
||||
const opened = await session.open(target.path);
|
||||
expect(opened.ok).toBe(false);
|
||||
if (!opened.ok) expect(opened.error.message).toContain("missing tools");
|
||||
expect(producer.closes).toBe(1);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
#ifdef _WIN32
|
||||
#define REA_EXPORT __declspec(dllexport)
|
||||
#define REA_NOINLINE __declspec(noinline)
|
||||
#else
|
||||
#define REA_EXPORT __attribute__((visibility("default")))
|
||||
#define REA_NOINLINE __attribute__((noinline))
|
||||
#endif
|
||||
|
||||
REA_EXPORT REA_NOINLINE int rea_fixture_add(int left, int right) {
|
||||
return left + right + 7;
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
return rea_fixture_add(11, 24);
|
||||
}
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
import { rm } from "node:fs/promises";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { createIdaTarget, RecordingIdaMcp } from "../../fixtures/idaMcp.js";
|
||||
import { IdaSessionClient } from "../../../src/ida/IdaSessionClient.js";
|
||||
import { IDA_OPERATIONS } from "../../../src/ida/IdaProviderCapabilities.js";
|
||||
import { toolContract } from "../../../src/contracts/toolContracts.js";
|
||||
import { createEvidence } from "../../../src/domain/evidence.js";
|
||||
import type { JsonValue } from "../../../src/domain/jsonValue.js";
|
||||
import { toCallToolResult } from "../../../src/server/toolResult.js";
|
||||
|
||||
const roots: string[] = [];
|
||||
afterEach(async () => {
|
||||
for (const root of roots.splice(0))
|
||||
await rm(root, { recursive: true, force: true });
|
||||
});
|
||||
const parametersFor = (
|
||||
operation: string,
|
||||
): Readonly<Record<string, JsonValue>> => {
|
||||
if (operation.startsWith("search_")) return { pattern: "a.b" };
|
||||
if (operation === "xrefs") return { address: "0x1000" };
|
||||
if (operation.startsWith("list_")) return { document: null };
|
||||
return { procedure: "main" };
|
||||
};
|
||||
|
||||
describe.each(["attached", "headless"] as const)(
|
||||
"IDA %s caller-visible contracts",
|
||||
(mode) => {
|
||||
it.each(IDA_OPERATIONS)(
|
||||
"serializes %s against its named advertised output contract",
|
||||
async (operation) => {
|
||||
const { root, target } = await createIdaTarget();
|
||||
roots.push(root);
|
||||
const client = new IdaSessionClient(
|
||||
{
|
||||
command: "fixture",
|
||||
args: [],
|
||||
env: {},
|
||||
mode,
|
||||
workspaceRoot: root,
|
||||
timeoutMs: 1000,
|
||||
},
|
||||
target,
|
||||
new RecordingIdaMcp(target, mode),
|
||||
);
|
||||
try {
|
||||
const parameters = parametersFor(operation);
|
||||
const execution = await client.execute(operation, parameters);
|
||||
if (mode === "headless" && operation === "procedure_callers") {
|
||||
expect(execution.ok).toBe(false);
|
||||
if (!execution.ok)
|
||||
expect(execution.error._tag).toBe(
|
||||
"AnalysisCapabilityUnavailableError",
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!execution.ok) throw execution.error;
|
||||
const evidence = createEvidence(target, execution.value.provider, {
|
||||
operation,
|
||||
parameters,
|
||||
result: execution.value.result,
|
||||
rawResult: execution.value.rawResult,
|
||||
locations: execution.value.locations,
|
||||
limitations: execution.value.limitations,
|
||||
});
|
||||
const contract = toolContract(operation);
|
||||
const serialized = toCallToolResult(
|
||||
{ ok: true, value: evidence },
|
||||
contract,
|
||||
);
|
||||
expect(
|
||||
contract.outputSchema.safeParse(serialized.structuredContent)
|
||||
.success,
|
||||
).toBe(true);
|
||||
expect(serialized.content[0]).toEqual({
|
||||
type: "text",
|
||||
text: JSON.stringify(serialized.structuredContent),
|
||||
});
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
@ -0,0 +1,262 @@
|
|||
import { createHash } from "node:crypto";
|
||||
import { mkdtemp, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { BinaryTarget } from "../../src/domain/binaryTarget.js";
|
||||
import type { JsonValue } from "../../src/domain/jsonValue.js";
|
||||
import type { IdaMcpConnection } from "../../src/ida/IdaMcpConnection.js";
|
||||
import { elf } from "../../src/domain/binaryTarget.fixture.js";
|
||||
|
||||
/** Create a test-owned executable and workspace parent without invoking an engine. */
|
||||
export const createIdaTarget = async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "rea-ida-test-"));
|
||||
const path = join(root, "target.elf");
|
||||
const bytes = elf(2, 1, 62);
|
||||
await writeFile(path, bytes);
|
||||
const target: BinaryTarget = {
|
||||
path,
|
||||
sha256: createHash("sha256").update(bytes).digest("hex"),
|
||||
kind: "executable",
|
||||
format: "elf",
|
||||
architecture: "x86_64",
|
||||
availableArchitectures: ["x86_64"],
|
||||
};
|
||||
return { root, target };
|
||||
};
|
||||
|
||||
const legacyNames = [
|
||||
"get_metadata",
|
||||
"list_functions",
|
||||
"list_strings",
|
||||
"get_function_by_name",
|
||||
"get_function_by_address",
|
||||
"decompile_function",
|
||||
"disassemble_function",
|
||||
"get_callers",
|
||||
"get_callees",
|
||||
"get_xrefs_to",
|
||||
];
|
||||
const modernNames = [
|
||||
"idb_open",
|
||||
"idb_list",
|
||||
"idb_close",
|
||||
"server_health",
|
||||
"list_funcs",
|
||||
"lookup_funcs",
|
||||
"find_regex",
|
||||
"decompile",
|
||||
"disasm",
|
||||
"callees",
|
||||
"xrefs_to",
|
||||
];
|
||||
|
||||
/** Stateful producer fixture at the real connection seam; no module mocking. */
|
||||
export class RecordingIdaMcp implements IdaMcpConnection {
|
||||
readonly calls: {
|
||||
name: string;
|
||||
args: Readonly<Record<string, JsonValue>>;
|
||||
}[] = [];
|
||||
connects = 0;
|
||||
closes = 0;
|
||||
inputSha256: string;
|
||||
inputPath: string;
|
||||
imageBase = "0x1000";
|
||||
pseudocode = "int main(void) { return 42; }";
|
||||
owned = true;
|
||||
active = true;
|
||||
database: string | undefined;
|
||||
failClose = false;
|
||||
beforeCall?: (
|
||||
name: string,
|
||||
args: Readonly<Record<string, JsonValue>>,
|
||||
) => Promise<void>;
|
||||
overrideCall?: (
|
||||
name: string,
|
||||
args: Readonly<Record<string, JsonValue>>,
|
||||
) => JsonValue | undefined;
|
||||
|
||||
constructor(
|
||||
readonly target: BinaryTarget,
|
||||
readonly mode: "attached" | "headless" = "attached",
|
||||
) {
|
||||
this.inputSha256 = target.sha256;
|
||||
this.inputPath = target.path;
|
||||
}
|
||||
async connect() {
|
||||
this.connects += 1;
|
||||
return this.mode === "attached" ? legacyNames : modernNames;
|
||||
}
|
||||
serverInfo() {
|
||||
return { name: "upstream-fixture", version: "sdk-version-is-not-ida" };
|
||||
}
|
||||
async close() {
|
||||
this.closes += 1;
|
||||
}
|
||||
async call(
|
||||
name: string,
|
||||
args: Readonly<Record<string, JsonValue>>,
|
||||
): Promise<JsonValue> {
|
||||
this.calls.push({ name, args: structuredClone(args) });
|
||||
await this.beforeCall?.(name, args);
|
||||
const overridden = this.overrideCall?.(name, args);
|
||||
if (overridden !== undefined) return overridden;
|
||||
if (["idb_open", "idb_list", "idb_close", "server_health"].includes(name))
|
||||
return this.lifecycle(name, args);
|
||||
return this.read(name);
|
||||
}
|
||||
|
||||
private read(name: string): JsonValue {
|
||||
const fn = { address: "0x1000", name: "main", size: "0x20" };
|
||||
const asm = {
|
||||
name: "main",
|
||||
start_ea: "0x1000",
|
||||
lines: [
|
||||
{
|
||||
address: "0x1000",
|
||||
instruction: "ret",
|
||||
comments: ["observed comment"],
|
||||
},
|
||||
],
|
||||
return_type: "int",
|
||||
arguments: [],
|
||||
};
|
||||
switch (name) {
|
||||
case "get_metadata":
|
||||
return {
|
||||
path: this.inputPath,
|
||||
module: "target.elf",
|
||||
sha256: this.inputSha256,
|
||||
base: this.imageBase,
|
||||
};
|
||||
case "list_functions":
|
||||
return { data: [fn], next_offset: null };
|
||||
case "list_funcs":
|
||||
return [
|
||||
{
|
||||
data: [{ addr: fn.address, name: fn.name, size: fn.size }],
|
||||
next_offset: null,
|
||||
},
|
||||
];
|
||||
case "get_function_by_address":
|
||||
return { ...fn, address: "0x1004" };
|
||||
case "get_function_by_name":
|
||||
return fn;
|
||||
case "lookup_funcs":
|
||||
return [
|
||||
{
|
||||
query: "main",
|
||||
fn: { addr: fn.address, name: fn.name, size: fn.size },
|
||||
error: null,
|
||||
},
|
||||
];
|
||||
case "decompile_function":
|
||||
return this.pseudocode;
|
||||
case "decompile":
|
||||
return { code: this.pseudocode, cursor: { done: true } };
|
||||
case "disassemble_function":
|
||||
return asm;
|
||||
case "disasm":
|
||||
return { asm, cursor: { done: true } };
|
||||
case "get_callers":
|
||||
return [
|
||||
{ address: "0x1004", name: "main", size: "0x20" },
|
||||
{ address: "0x1008", name: "main", size: "0x20" },
|
||||
];
|
||||
case "get_callees":
|
||||
return [{ address: "0x2000", name: "puts", type: "external" }];
|
||||
case "callees":
|
||||
return [
|
||||
{
|
||||
callees: [{ addr: "0x2000", name: "puts", type: "external" }],
|
||||
more: false,
|
||||
},
|
||||
];
|
||||
case "get_xrefs_to":
|
||||
return [{ address: "0x1004", type: "code", function: fn }];
|
||||
case "xrefs_to":
|
||||
return [
|
||||
{
|
||||
xrefs: [
|
||||
{
|
||||
addr: "0x1004",
|
||||
type: "code",
|
||||
fn: { addr: fn.address, name: fn.name, size: fn.size },
|
||||
},
|
||||
],
|
||||
more: false,
|
||||
},
|
||||
];
|
||||
case "list_strings":
|
||||
return {
|
||||
data: [{ address: "0x3000", length: 5, string: "a.b\nC" }],
|
||||
next_offset: null,
|
||||
};
|
||||
case "find_regex":
|
||||
return {
|
||||
matches: [{ addr: "0x3000", string: "a.b\nC" }],
|
||||
cursor: { done: true },
|
||||
};
|
||||
default:
|
||||
throw new Error(`Unimplemented producer tool ${name}`);
|
||||
}
|
||||
}
|
||||
|
||||
private lifecycle(
|
||||
name: string,
|
||||
args: Readonly<Record<string, JsonValue>>,
|
||||
): JsonValue {
|
||||
switch (name) {
|
||||
case "idb_open": {
|
||||
if (
|
||||
typeof args.preferred_session_id !== "string" ||
|
||||
typeof args.input_path !== "string"
|
||||
)
|
||||
throw new Error("Missing lifecycle identity");
|
||||
this.database = args.preferred_session_id;
|
||||
this.inputPath = args.input_path;
|
||||
return {
|
||||
success: true,
|
||||
session: { session_id: this.database, input_path: this.inputPath },
|
||||
};
|
||||
}
|
||||
case "idb_list":
|
||||
return {
|
||||
sessions:
|
||||
this.database === undefined
|
||||
? []
|
||||
: [
|
||||
{
|
||||
session_id: this.database,
|
||||
input_path: this.inputPath,
|
||||
owned: this.owned,
|
||||
backend: "worker",
|
||||
is_active: this.active,
|
||||
},
|
||||
],
|
||||
};
|
||||
case "server_health":
|
||||
return {
|
||||
status: "ok",
|
||||
input_path: this.inputPath,
|
||||
idb_path: `${this.inputPath}.i64`,
|
||||
module: "target.elf",
|
||||
imagebase: this.imageBase,
|
||||
auto_analysis_ready: true,
|
||||
};
|
||||
case "idb_close": {
|
||||
if (this.failClose) throw new Error("Worker release failed");
|
||||
const session_id = this.database;
|
||||
this.database = undefined;
|
||||
return {
|
||||
success: true,
|
||||
session_id: session_id ?? "missing",
|
||||
saved: null,
|
||||
owned: this.owned,
|
||||
backend: "worker",
|
||||
};
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unimplemented producer tool ${name}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -37,7 +37,7 @@ const TEST_PROJECTS = [
|
|||
name: "adapters",
|
||||
include: [
|
||||
"src/*.test.ts",
|
||||
"src/{artifacts,browser,dotnet,firmware,ghidra,hopper,native,process,reference,server,windows}/**/*.test.ts",
|
||||
"src/{artifacts,browser,dotnet,firmware,ghidra,hopper,ida,native,process,reference,server,windows}/**/*.test.ts",
|
||||
],
|
||||
pool: "forks" as const,
|
||||
maxWorkers: MAX_TEST_WORKERS,
|
||||
|
|
|
|||
Loading…
Reference in New Issue