mirror of
https://github.com/morluto/rea.git
synced 2026-10-19 04:19:54 -05:00
feat(ida): support optional native IDA Free MCP in attached mode (#1544)
Add an explicit attached native IDA MCP compatibility profile while preserving the legacy and headless workflows. Keep recorded input identity, complete source observations and the user's open GUI database. Batch unique callee lookups, conservatively prefilter literal strings, preserve Unicode matching and unavailable caller coverage, and require distinct-function switching in native verification. Verified with Windows IDA Pro 9.3 and the native MCP plugin built from c9d15d98e8a143fc74f6e14037c48f63889b6d6c with a loopback listener. Production CLI/MCP and an unchanged Windows whoami.exe passed. Windows IDA Free was not used. No personal paths or license registration information are included. Validation: 200 focused tests, static and documentation checks, architecture guards and discovery passed. Historical same-main warm task measurements improved from 675 to 317 ms; response token estimates fell from 16618 to 11509. Fresh-start, overall CPU and memory improvement were not established. Detailed scope is in experiments/analysis-efficiency/ida-native-windows.md. Co-authored-by: imissapixel <140405963+imissapixel@users.noreply.github.com> Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> Co-authored-by: N0zoM1z0 <161784452+N0zoM1z0@users.noreply.github.com>
This commit is contained in:
19 files changed
+1524
-149
No files matched your search
@@ -180,7 +180,7 @@ using your user permissions; each runtime guide describes its effects.
|
||||
|
||||
Native formats and host support vary by provider. See
|
||||
[Hopper and Ghidra setup](docs/installation.md#hopper), the
|
||||
[IDA guide](docs/ida-provider.md), and
|
||||
[IDA guide](docs/ida-provider.md) (including an optional native MCP bridge), and
|
||||
[experimental Windows Ghidra support](docs/windows-ghidra-p0.md).
|
||||
Ghidra also supports [16-bit DOS analysis](docs/ghidra-dos.md).
|
||||
For large binaries, raise the provider's startup deadline with
|
||||
|
||||
+35
-5
@@ -1,6 +1,6 @@
|
||||
# IDA Pro provider
|
||||
# IDA 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.
|
||||
REA adapts an existing IDA MCP server into its native analysis tools. Existing IDA Pro profiles use [mrexodia/ida-pro-mcp](https://github.com/mrexodia/ida-pro-mcp); an optional native bridge for IDA Pro and IDA Free is described below. Install and configure your selected engine and upstream MCP using their 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.
|
||||
|
||||
@@ -32,7 +32,33 @@ Open the **original input binary** in REA, not an `.idb` or `.i64` database. Its
|
||||
|
||||
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.
|
||||
|
||||
The legacy producer moves the active GUI as a side effect: assembly and raw function instructions jump to the function, and successful pseudocode opens or reuses a pseudocode view. A function dossier does both. `binary_session` reports these operations with `effects.may_show_ui: true` in attached mode; headless operations and the other attached operations report `false`.
|
||||
## Optional native IDA MCP bridge
|
||||
|
||||
IDA Pro and IDA Free can use the separate [ida-free-mcp native plugin](https://github.com/0xshlomil/ida-free-mcp) through an explicit attached registration. Install and start that plugin using its upstream instructions, open the original binary in the IDA GUI, and wait for analysis. The native MCP 1.1.2 release binds `0.0.0.0` without a listen-address setting. Restrict inbound access with the host firewall or use an upstream build that binds only to loopback before starting it. REA's loopback URL restriction does not change the plugin's listener. REA does not install IDA, build the plugin, activate a license, or launch the GUI.
|
||||
|
||||
```json
|
||||
{
|
||||
"mode": "attached",
|
||||
"protocol": "native",
|
||||
"url": "http://127.0.0.1:13337/mcp"
|
||||
}
|
||||
```
|
||||
|
||||
Set `REA_IDA_MCP_CONFIG` to this file and select `ida` using the same CLI/MCP commands above. Omitting `protocol` preserves existing IDA Pro registrations. Native mode requires `resources/read` for `ida://idb/metadata` and its recorded input SHA-256; it never substitutes a digest of an unrelated local file. The native resource's `path` describes the IDB in the upstream protocol, not the original input path. REA retains it and checks database identity and image base before and after requests.
|
||||
|
||||
This experimental profile provides function/string inventory and search, function lookup, assembly, GUI pseudocode, callees, address xrefs, instruction windows, and function dossiers. Direct callers remain unavailable: native code/data xref labels do not prove call edges. It requires neither IDAPython nor idalib. Native headless registrations are rejected.
|
||||
|
||||
Native string observations use upstream C-string rendering; other representations remain unknown. Literal searches request conservative candidates and apply REA's original case and Unicode matching rules. Regex searches retain the full scan because native byte-oriented regex semantics cannot replace REA's Unicode regex semantics. All candidate pages are examined, and upstream search errors remain failures.
|
||||
|
||||
Native callee resolution batches unique queries and checks each reply's query identity. Results remain live: a recorded input hash does not establish database revision, so cross-request analysis caches cannot prove freshness. For focused questions, use pseudocode, callees, or instruction-window tools; a function dossier requests additional facets and returns more Evidence.
|
||||
|
||||
IDA Free's cloud decompiler needs network access and may request consent. Follow its licensing and supported architecture limits; see [IDA Free](https://hex-rays.com/ida-free). Decompilation moves GUI focus and the upstream plugin closes existing pseudocode tabs before requesting fresh output. REA advertises UI/network effects and closes only its MCP connection, preserving the open database. Rendered pseudocode has upstream traversal limits and does not prove complete source recovery. Cloud/widget failures remain explicit failures; retry after the GUI finishes or dismiss its prompt.
|
||||
|
||||
Native MCP 1.1.2 can navigate in the previously focused pseudocode tab when switching functions. Use upstream 1.1.3 or a source build including [the navigation correction](https://github.com/0xshlomil/ida-free-mcp/commit/6adf2c615a2a03661059673e9c136ef28d0dc778), which focuses the disassembly view **before** jumping to the requested entry. This REA profile does not repair stale GUI output. Verification must include two different functions before claiming reliable function switching. The initial Linux verification used a locally corrected 1.1.2 plugin; upstream 1.1.3 has not been verified by that run.
|
||||
|
||||
Native tool observations retain actual tool names and complete JSON text. Upstream `structuredContent` can be a shortened preview even while text contains the full result. REA reads the full native representation and rejects missing/malformed text instead of treating previews as complete observations.
|
||||
|
||||
The legacy producer moves the active GUI as a side effect: assembly and raw function instructions jump to the function, and successful pseudocode opens or reuses a pseudocode view. A function dossier does both. Native GUI decompilation also changes the active view. `binary_session` reports these operations with `effects.may_show_ui: true`; headless operations and the other attached operations report `false`.
|
||||
|
||||
## Give REA a target for headless analysis
|
||||
|
||||
@@ -98,9 +124,9 @@ For guided registration, set `REA_IDA_MCP_CONFIG` before `rea setup --client <cl
|
||||
|
||||
## 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. In the modern headless profile, targets that are not proven x86-only use processor-neutral `callgraph` edges instead of the x86 instruction checks in upstream `callees` (mrexodia/ida-pro-mcp#549). REA requests depth zero: the pinned upstream emits the root's outgoing edges without scanning child functions, then REA batches function-name lookup for those addresses. The graph covers recognized function destinations only; imports and other non-function targets remain unknown. A server without `callgraph` returns an empty relationship list with an explicit coverage limitation. Graph and path workflows retain those limitations alongside observed edges. A failed or inconsistent graph reply remains an error. A transport-level truncated preview is rejected rather than reported as complete evidence; the error preserves upstream retrieval guidance.
|
||||
All 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. In the modern headless profile, targets that are not proven x86-only use processor-neutral `callgraph` edges instead of the x86 instruction checks in upstream `callees` (mrexodia/ida-pro-mcp#549). REA requests depth zero: the pinned upstream emits the root's outgoing edges without scanning child functions, then REA batches function-name lookup for those addresses. The graph covers recognized function destinations only; imports and other non-function targets remain unknown. A server without `callgraph` returns an empty relationship list with an explicit coverage limitation. Graph and path workflows retain those limitations alongside observed edges. A failed or inconsistent graph reply remains an error. 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.
|
||||
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 and native profiles do 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 requested GUI navigation are outside this adapter's coverage. Check session availability before composing a workflow that needs those facets.
|
||||
|
||||
@@ -110,4 +136,8 @@ Complete body ranges, typed reference edges, CFG, referenced data, source locals
|
||||
|
||||
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.
|
||||
|
||||
Native verification requires `--second-procedure other_function --second-marker unique_second_body_text`; these options remain optional for the other profiles. The marker must occur in the second function's pseudocode and be absent from the first; the verifier also checks that returning to the first restores its text.
|
||||
|
||||
The initial real workflows use stdio registrations for the legacy Windows GUI profile and a Windows x64 headless supervisor with IDA 9.3. The headless workflow has also passed on macOS arm64 with IDA Professional 9.1 (build 9.1.250226) against the same upstream commit `c133c3853faa111a9b00ee615c013b720d0c4acd`, using a source-built arm64 Mach-O fixture; Mach-O C symbols carry a leading underscore, so the procedure name is `_rea_fixture_add`. Streamable HTTP is tested against a loopback MCP fixture with the pinned client SDK; a real IDA HTTP-supervisor workflow remains unverified. Linux headless engine operation, other IDA releases, architectures, and modern attached GUI tools require their corresponding real workflows before support is claimed.
|
||||
|
||||
Native Windows verification uses IDA Pro 9.3 with the public two-function fixture and Windows `whoami.exe`, using upstream `c9d15d98e8a143fc74f6e14037c48f63889b6d6c` with a loopback-listener change. It covers production CLI/MCP parity, distinct-function switching, identity and connection cleanup. See the [correctness and performance measurements](https://github.com/morluto/rea/blob/main/experiments/analysis-efficiency/ida-native-windows.md). This follow-up did not run Windows IDA Free or native macOS.
|
||||
+8
-3
@@ -627,11 +627,12 @@ or upgrade Java, Ghidra, .NET or native toolchains. See
|
||||
`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
|
||||
the legacy or native 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.
|
||||
provides an optional small native fixture source with exported
|
||||
`rea_fixture_add` and `rea_fixture_subtract` functions. For their switching check,
|
||||
use `--second-procedure rea_fixture_subtract --second-marker='- 13'`.
|
||||
|
||||
The lane invokes the production CLI dispatcher and connects the pinned MCP
|
||||
client SDK to the production REA server. It verifies function Evidence and
|
||||
@@ -659,6 +660,10 @@ failed intermittently on that host. Linux headless, modern attached GUI tools,
|
||||
other engine versions and architectures remain unverified; see the
|
||||
[provider guide](ida-provider.md).
|
||||
|
||||
Native IDA MCP verification uses `"protocol": "native"` with an already-running native plugin and requires `--second-procedure other_function --second-marker unique_second_body_text` to exercise GUI function switching and a return to the first function. These options remain optional for other profiles. The literal marker must appear in the second function's pseudocode and be absent from the first. Native cloud/widget failures are reported without hidden retries.
|
||||
|
||||
The initial Linux x64 IDA Free 9.4 run used native MCP 1.1.2 with a local navigation correction. Windows x64 verification uses **IDA Pro 9.3**, the two-function public fixture, and the shipped Windows `whoami.exe` application. The native plugin was built from upstream `c9d15d98e8a143fc74f6e14037c48f63889b6d6c`, including the merged navigation fix, with a local loopback-listener change. Production CLI/MCP parity, switching, identity and cleanup passed. Correctness and performance measurements are recorded in the [Windows native verification notes](https://github.com/morluto/rea/blob/main/experiments/analysis-efficiency/ida-native-windows.md). Stock 1.1.2 switching, native macOS, and Windows IDA Free remain unverified. See the [native bridge prerequisites](ida-provider.md#optional-native-ida-mcp-bridge).
|
||||
|
||||
## DOS Ghidra analysis
|
||||
|
||||
`npm run verify:ghidra:dos` requires the supported Ghidra and JDK installation
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
# Native IDA MCP verification on Windows
|
||||
|
||||
Verification date: 2026-10-13. These runs used **IDA Pro 9.3** (executable
|
||||
product version `9.3.26.0213`), not Windows IDA Free. Personal paths, raw
|
||||
observations, and license registration information are excluded.
|
||||
|
||||
The native plugin was built from upstream
|
||||
[`c9d15d98e8a143fc74f6e14037c48f63889b6d6c`](https://github.com/0xshlomil/ida-free-mcp/commit/c9d15d98e8a143fc74f6e14037c48f63889b6d6c)
|
||||
with MinGW GCC 13 and its pinned public SDK. The only local source change
|
||||
bound the listener to `127.0.0.1`; the upstream function-navigation fix was
|
||||
already present. This does not verify the stock 1.1.2 release listener or
|
||||
its older navigation order.
|
||||
|
||||
## Correctness and authority
|
||||
|
||||
The public [IDA fixture](../../tests/conformance/ida/inventory.c) contains
|
||||
two exported functions with distinct bodies. Production CLI/MCP verification
|
||||
checks matching function Evidence, assembly, a unique second-function marker,
|
||||
return to the first function, original-input identity, invalid requests, and
|
||||
connection cleanup. Native verification requires the second procedure and its
|
||||
unique marker; missing, incomplete and empty arguments are rejected before
|
||||
connecting. Dossier relationship coverage remains unknown when direct callers
|
||||
are unavailable, while standalone callee coverage retains its own observed state.
|
||||
It preserves the attached GUI database.
|
||||
|
||||
The real application was a digest-verified, unchanged copy of Windows
|
||||
`whoami.exe`: 94,208 bytes, 243 recognized functions, and 314 upstream
|
||||
C-string observations. The selected 1,741-byte function had 20 resolved
|
||||
callees. Before and after, the task returned identical pseudocode, callee
|
||||
addresses, and its one `whoami` string match. The executable was never run.
|
||||
Other string representations remain unknown in the native profile.
|
||||
|
||||
A Windows C++ producer golden checked 24 literal/case combinations and
|
||||
408 candidate comparisons, including metacharacters, Kelvin sign, dotted I,
|
||||
sigma, supplementary characters, and null bytes. Adapter tests additionally
|
||||
cover malformed UTF-16 fallback, candidate pagination, explicit search errors,
|
||||
and reordered, missing, duplicate, unrelated, or failed lookup replies.
|
||||
These checks do not establish IDA recognition of every Unicode string format.
|
||||
|
||||
## Equivalent completed tasks
|
||||
|
||||
Both builds used main `59be86671`. The baseline was `fb34ccbec`, before
|
||||
the native performance edits; the comparison build added those edits.
|
||||
Schemas were identical. Each run opened the same analyzed binary, read the
|
||||
specified function's pseudocode and callees, searched literal `whoami`, and
|
||||
closed the REA session. The task then repeated on the same MCP connection.
|
||||
|
||||
There were two runs per build on a shared host. Node 24.18.0 and IDA were
|
||||
restricted to one logical CPU with below-normal priority; each Node process
|
||||
had a 768 MiB heap ceiling. The attached database was already analyzed.
|
||||
Node compile caching was disabled for the primary comparison.
|
||||
|
||||
| Measurement | Before | After |
|
||||
| --------------------------------------------------- | -----------: | -----------: |
|
||||
| Task response text | 49,139 bytes | 32,546 bytes |
|
||||
| Task response token estimate | 16,618 | 11,509 |
|
||||
| Callee lookup calls, including selector | 17 | 2 |
|
||||
| String-search pages | 4 | 1 |
|
||||
| String-search Evidence | 19,022 bytes | 3,344 bytes |
|
||||
| Mean task time, warm MCP connection | 675 ms | 317 ms |
|
||||
| Mean task time, fresh MCP process and catalog | 19.72 s | 20.86 s |
|
||||
| Mean Node family CPU, fresh and warm tasks together | 17.72 s | 18.62 s |
|
||||
| Largest Node family peak-RSS upper bound | 776.8 MiB | 780.0 MiB |
|
||||
| Mean attached IDA CPU during both tasks | 226.6 ms | 171.9 ms |
|
||||
|
||||
The warm task was about 53% faster and its response estimate about 31%
|
||||
smaller. Fresh-start, overall CPU, and memory improvements were not
|
||||
established. Startup and full catalog conversion dominate the fresh task.
|
||||
Two samples do not establish a general performance guarantee.
|
||||
|
||||
Token counts use local `cl100k_base` tokenization of complete serialized
|
||||
MCP response text and request arguments; they are estimates, not model usage.
|
||||
Requests added 99 estimated tokens per task. The unchanged full schema
|
||||
catalog contained 2,422,229 bytes and 613,965 estimated tokens. Including it
|
||||
once gives first-task estimates of 630,682 before and 625,573 after. A host
|
||||
may select schemas lazily; actual host injection, prompts, reasoning tokens,
|
||||
and model-side costs were not measured. No retries were needed.
|
||||
|
||||
Node CPU counters were captured at shutdown. The memory figure sums the
|
||||
two processes' individual OS peak RSS values: an upper bound, not a
|
||||
simultaneous family peak. Attached IDA is outside that process family; its
|
||||
unchanged lifetime peak working set was 376.9 MiB, including initial analysis.
|
||||
Measurement overhead and whole-machine resource use were not measured.
|
||||
|
||||
## Reuse and cleanup
|
||||
|
||||
Callee queries are deduplicated and batched within an operation. Literal
|
||||
search fetches a conservative candidate set, including non-ASCII text when
|
||||
needed, and preserves REA's final Unicode predicate. It examines all candidate
|
||||
pages and retains full source observations. Regex searches keep the full scan.
|
||||
|
||||
Cross-request analysis results remain live because an input digest does not
|
||||
establish database revision or freshness. An exploratory caller-selected
|
||||
[Node compile cache](https://nodejs.org/download/release/v24.16.0/docs/api/module.html#module-compile-cache)
|
||||
did not demonstrate startup improvement: empty-cache and cache-hit MCP starts
|
||||
were 7.35 s and 7.56 s. It occupied 6.7 MiB and was limited to this experiment;
|
||||
no new default cache or installation behavior was introduced.
|
||||
|
||||
Temporary source, SDK, build, portable runtime, cache, and raw measurement
|
||||
outputs are test-owned cleanup targets. Attached GUI databases are retained;
|
||||
REA never saves or closes them.
|
||||
@@ -16,6 +16,8 @@ const { values } = parseArgs({
|
||||
options: {
|
||||
target: { type: "string" },
|
||||
procedure: { type: "string", default: "rea_fixture_add" },
|
||||
"second-procedure": { type: "string" },
|
||||
"second-marker": { type: "string" },
|
||||
"package-root": { type: "string" },
|
||||
report: { type: "string" },
|
||||
},
|
||||
@@ -24,6 +26,14 @@ 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.",
|
||||
);
|
||||
if (
|
||||
(values["second-procedure"] === undefined) !==
|
||||
(values["second-marker"] === undefined) ||
|
||||
values["second-marker"] === ""
|
||||
)
|
||||
throw new Error(
|
||||
"IDA verification requires --second-procedure and a nonempty --second-marker together; choose a marker unique to the second function's pseudocode.",
|
||||
);
|
||||
const packageRoot = resolve(
|
||||
values["package-root"] ?? fileURLToPath(new URL("..", import.meta.url)),
|
||||
);
|
||||
@@ -47,6 +57,14 @@ const [
|
||||
const registration = readIdaConfiguration(process.env.REA_IDA_MCP_CONFIG);
|
||||
if (!registration.ok) throw registration.error;
|
||||
const config = registration.value;
|
||||
if (
|
||||
config.protocol === "native" &&
|
||||
(values["second-procedure"] === undefined ||
|
||||
values["second-marker"] === undefined)
|
||||
)
|
||||
throw new Error(
|
||||
"Native IDA verification requires --second-procedure and --second-marker to verify distinct-function switching; see docs/ida-provider.md.",
|
||||
);
|
||||
const environment = Object.fromEntries(
|
||||
Object.entries({
|
||||
...process.env,
|
||||
@@ -57,6 +75,8 @@ const environment = Object.fromEntries(
|
||||
const report = {
|
||||
passed: false,
|
||||
mode: config.mode,
|
||||
protocol:
|
||||
config.protocol ?? (config.mode === "attached" ? "legacy" : "modern"),
|
||||
checks: [],
|
||||
observations: [],
|
||||
};
|
||||
@@ -75,6 +95,8 @@ const observe = (input) => {
|
||||
assert.equal(evidence.analysis_profile.parameters.cache_policy, "live");
|
||||
assert.equal(evidence.analysis_profile.parameters.engine_version, null);
|
||||
assert.ok(evidence.limitations.length > 0);
|
||||
if (config.protocol === "native" && evidence.operation === "analyze_function")
|
||||
assert.equal(evidence.relationship_coverage?.status, "unknown");
|
||||
assert.ok(
|
||||
evidence.locations.some((location) => location.kind === "artifact-path"),
|
||||
);
|
||||
@@ -156,7 +178,7 @@ const mcp = async (cliDossier) => {
|
||||
["procedure_address", { procedure: dossier.procedure.name }],
|
||||
["procedure_assembly", { procedure: dossier.procedure.address }],
|
||||
["procedure_callees", { procedure: dossier.procedure.address }],
|
||||
...(config.mode === "attached"
|
||||
...(config.mode === "attached" && config.protocol !== "native"
|
||||
? [["procedure_callers", { procedure: dossier.procedure.address }]]
|
||||
: []),
|
||||
["search_procedures", { pattern: dossier.procedure.name }],
|
||||
@@ -168,6 +190,38 @@ const mcp = async (cliDossier) => {
|
||||
const result = await call(name, args);
|
||||
observe(result);
|
||||
}
|
||||
if (values["second-procedure"] !== undefined) {
|
||||
const secondAddress = observe(
|
||||
await call("procedure_address", {
|
||||
procedure: values["second-procedure"],
|
||||
}),
|
||||
);
|
||||
assert.notEqual(secondAddress, dossier.procedure.address);
|
||||
const secondCode = observe(
|
||||
await call("procedure_pseudo_code", {
|
||||
procedure: values["second-procedure"],
|
||||
}),
|
||||
);
|
||||
assert.equal(typeof secondCode, "string");
|
||||
assert.ok(secondCode.length > 0);
|
||||
assert.ok(
|
||||
!dossier.pseudocode.includes(values["second-marker"]),
|
||||
"The second marker also appears in the first function's pseudocode.",
|
||||
);
|
||||
assert.ok(
|
||||
secondCode.includes(values["second-marker"]),
|
||||
"The second function's pseudocode omitted its expected marker.",
|
||||
);
|
||||
const restored = observe(
|
||||
await call("procedure_pseudo_code", {
|
||||
procedure: dossier.procedure.address,
|
||||
}),
|
||||
);
|
||||
assert.equal(restored, dossier.pseudocode);
|
||||
report.checks.push(
|
||||
"second-function pseudocode identity and return to the original function",
|
||||
);
|
||||
}
|
||||
const invalid = await client.callTool({
|
||||
name: "xrefs",
|
||||
arguments: { address: "not-an-address" },
|
||||
@@ -195,7 +249,10 @@ const cleanup = async () => {
|
||||
try {
|
||||
await upstream.connect();
|
||||
if (config.mode === "attached") {
|
||||
const metadata = await upstream.call("get_metadata", {});
|
||||
const metadata =
|
||||
config.protocol === "native"
|
||||
? await upstream.readResource("ida://idb/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",
|
||||
|
||||
+37
-27
@@ -6,43 +6,53 @@ import { err, ok, type Result } from "../domain/result.js";
|
||||
|
||||
const common = {
|
||||
mode: z.enum(["attached", "headless"]).default("attached"),
|
||||
protocol: z.literal("native").optional(),
|
||||
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(),
|
||||
]);
|
||||
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(),
|
||||
])
|
||||
.refine(
|
||||
(config) => config.protocol !== "native" || config.mode === "attached",
|
||||
"The native IDA MCP protocol requires attached mode",
|
||||
);
|
||||
|
||||
/** An upstream MCP registration plus REA's database lifecycle selection. */
|
||||
export type IdaConfiguration = z.infer<typeof registrationSchema>;
|
||||
|
||||
/** Select a protocol without changing existing attached/headless registrations. */
|
||||
export const idaProtocol = (config: IdaConfiguration) =>
|
||||
config.protocol ?? (config.mode === "attached" ? "legacy" : "modern");
|
||||
|
||||
/** Read one explicit registration without starting or installing a provider. */
|
||||
export const readIdaConfiguration = (
|
||||
path: string,
|
||||
|
||||
@@ -19,6 +19,7 @@ export interface IdaMcpConnection {
|
||||
name: string,
|
||||
args: Readonly<Record<string, JsonValue>>,
|
||||
): Promise<JsonValue>;
|
||||
readResource?(uri: string): Promise<JsonValue>;
|
||||
serverInfo(): JsonValue;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
@@ -43,8 +44,23 @@ const toolResultSchema = z.object({
|
||||
.optional(),
|
||||
});
|
||||
|
||||
const parseIdaJsonText = (text: string): JsonValue => {
|
||||
let value: unknown;
|
||||
try {
|
||||
value = JSON.parse(text);
|
||||
} catch (cause: unknown) {
|
||||
throw new AnalysisProtocolError("IDA MCP returned malformed JSON text.", {
|
||||
cause,
|
||||
});
|
||||
}
|
||||
return jsonValueSchema.parse(value);
|
||||
};
|
||||
|
||||
/** Decode Python FastMCP's scalar wrapper without dropping structured dictionaries. */
|
||||
export const decodeIdaToolResult = (input: unknown): JsonValue => {
|
||||
export const decodeIdaToolResult = (
|
||||
input: unknown,
|
||||
protocol?: "native",
|
||||
): JsonValue => {
|
||||
const result = toolResultSchema.parse(input);
|
||||
const blocks = result.content.flatMap((block) =>
|
||||
block.type === "text" && block.text !== undefined ? [block.text] : [],
|
||||
@@ -57,6 +73,15 @@ export const decodeIdaToolResult = (input: unknown): JsonValue => {
|
||||
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."}`,
|
||||
);
|
||||
// Native MCP retains the complete JSON text while structuredContent may
|
||||
// contain a shortened preview. Parse that producer-owned representation once.
|
||||
if (protocol === "native") {
|
||||
if (blocks.length !== 1)
|
||||
throw new AnalysisProtocolError(
|
||||
"Native IDA MCP omitted its complete JSON text result.",
|
||||
);
|
||||
return parseIdaJsonText(blocks[0] ?? "");
|
||||
}
|
||||
if (result.structuredContent !== undefined) {
|
||||
const value = result.structuredContent;
|
||||
return jsonValueSchema.parse(
|
||||
@@ -156,8 +181,28 @@ export const createIdaMcpConnection = (
|
||||
await client
|
||||
.callTool({ name, arguments: args }, { timeout: config.timeoutMs })
|
||||
.catch(transportFailure),
|
||||
config.protocol,
|
||||
);
|
||||
},
|
||||
async readResource(uri) {
|
||||
const response = await client
|
||||
.readResource({ uri }, { timeout: config.timeoutMs })
|
||||
.catch(transportFailure);
|
||||
const resource = z
|
||||
.object({
|
||||
contents: z
|
||||
.array(
|
||||
z.object({
|
||||
uri: z.literal(uri),
|
||||
mimeType: z.literal("application/json"),
|
||||
text: z.string(),
|
||||
}),
|
||||
)
|
||||
.length(1),
|
||||
})
|
||||
.parse(response);
|
||||
return parseIdaJsonText(resource.contents[0]?.text ?? "");
|
||||
},
|
||||
serverInfo: () => jsonValueSchema.parse(client.getServerVersion() ?? null),
|
||||
close: () => client.close().catch(transportFailure),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,713 @@
|
||||
import { rm, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
createIdaTarget,
|
||||
RecordingIdaMcp,
|
||||
} from "../../tests/fixtures/idaMcp.js";
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import {
|
||||
AnalysisInputError,
|
||||
AnalysisProtocolError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import { readIdaConfiguration } from "./IdaConfiguration.js";
|
||||
import {
|
||||
decodeIdaToolResult,
|
||||
type IdaMcpConnection,
|
||||
} from "./IdaMcpConnection.js";
|
||||
import { parseConfig } from "../config/parseConfig.js";
|
||||
import { IdaProvider } from "./IdaProvider.js";
|
||||
import { idaCapabilities } from "./IdaProviderCapabilities.js";
|
||||
import { IdaSessionClient } from "./IdaSessionClient.js";
|
||||
|
||||
const roots: string[] = [];
|
||||
afterEach(async () => {
|
||||
for (const root of roots.splice(0))
|
||||
await rm(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const fixture = async () => {
|
||||
const { root, target } = await createIdaTarget();
|
||||
roots.push(root);
|
||||
const producer = new RecordingIdaMcp(target);
|
||||
producer.overrideCall = (name) => {
|
||||
if (name === "decompile")
|
||||
return {
|
||||
addr: "0x1000",
|
||||
ok: true,
|
||||
pseudocode: producer.pseudocode,
|
||||
lines: 1,
|
||||
};
|
||||
if (name === "disasm")
|
||||
return {
|
||||
asm: {
|
||||
name: "main",
|
||||
start_ea: "0x1000",
|
||||
lines: "main (.text @ 0x1000):\n1000 ret",
|
||||
},
|
||||
cursor: { done: true },
|
||||
};
|
||||
if (name === "callees")
|
||||
return [
|
||||
{
|
||||
callees: [{ addr: "0x2000", name: "puts", type: "external" }],
|
||||
more: false,
|
||||
error: null,
|
||||
},
|
||||
];
|
||||
if (name === "xrefs_to")
|
||||
return [
|
||||
{
|
||||
xrefs: [{ addr: "0x1004", type: "code", fn: null }],
|
||||
more: false,
|
||||
error: null,
|
||||
},
|
||||
];
|
||||
return undefined;
|
||||
};
|
||||
const resources: string[] = [];
|
||||
const connection: IdaMcpConnection = {
|
||||
connect: async () => [
|
||||
"list_funcs",
|
||||
"lookup_funcs",
|
||||
"find_regex",
|
||||
"decompile",
|
||||
"disasm",
|
||||
"callees",
|
||||
"xrefs_to",
|
||||
],
|
||||
call: (name, args) => producer.call(name, args),
|
||||
readResource: async (uri) => {
|
||||
resources.push(uri);
|
||||
const metadata = await producer.call("get_metadata", {});
|
||||
if (
|
||||
metadata === null ||
|
||||
typeof metadata !== "object" ||
|
||||
Array.isArray(metadata)
|
||||
)
|
||||
throw new Error("Invalid metadata fixture");
|
||||
return { ...metadata, path: `${producer.inputPath}.i64` };
|
||||
},
|
||||
serverInfo: () => producer.serverInfo(),
|
||||
close: () => producer.close(),
|
||||
};
|
||||
const client = new IdaSessionClient(
|
||||
{
|
||||
url: "http://127.0.0.1:13337/mcp",
|
||||
headers: {},
|
||||
mode: "attached",
|
||||
protocol: "native",
|
||||
timeoutMs: 1000,
|
||||
},
|
||||
target,
|
||||
connection,
|
||||
);
|
||||
return { producer, client, target, resources, root };
|
||||
};
|
||||
|
||||
describe("native IDA attached protocol", () => {
|
||||
it("requires explicit opt-in and rejects native headless registrations", async () => {
|
||||
const { root } = await fixture();
|
||||
const path = join(root, "native.json");
|
||||
await writeFile(
|
||||
path,
|
||||
JSON.stringify({ url: "http://127.0.0.1:13337/mcp", protocol: "native" }),
|
||||
);
|
||||
expect(readIdaConfiguration(path)).toMatchObject({
|
||||
ok: true,
|
||||
value: { mode: "attached", protocol: "native" },
|
||||
});
|
||||
await writeFile(
|
||||
path,
|
||||
JSON.stringify({
|
||||
url: "http://127.0.0.1:13337/mcp",
|
||||
protocol: "native",
|
||||
mode: "headless",
|
||||
}),
|
||||
);
|
||||
expect(readIdaConfiguration(path).ok).toBe(false);
|
||||
await writeFile(path, JSON.stringify({ command: "python" }));
|
||||
const old = readIdaConfiguration(path);
|
||||
expect(old.ok && old.value.protocol).toBeUndefined();
|
||||
});
|
||||
|
||||
it("commits native profile semantics and advertises GUI effects without starting a provider", async () => {
|
||||
const { root, target, producer } = await fixture();
|
||||
const path = join(root, "profile.json");
|
||||
await writeFile(
|
||||
path,
|
||||
JSON.stringify({ url: "http://127.0.0.1:13337/mcp", protocol: "native" }),
|
||||
);
|
||||
const config = parseConfig({ REA_IDA_MCP_CONFIG: path });
|
||||
if (!config.ok) throw config.error;
|
||||
const provider = new IdaProvider(config.value, {}, () => {
|
||||
throw new Error("Unexpected discovery startup");
|
||||
});
|
||||
expect(provider.inspectAvailability().status).toBe("available");
|
||||
const profile = await provider.resolveAnalysisProfile(target);
|
||||
if (!profile.ok) throw profile.error;
|
||||
expect(profile.value.profile.provider.name).toBe("IDA MCP adapter");
|
||||
expect(profile.value.profile.parameters).toMatchObject({
|
||||
compatibility_profile: "native-ida-mcp",
|
||||
mode: "attached",
|
||||
cache_policy: "live",
|
||||
engine_version: null,
|
||||
});
|
||||
expect(
|
||||
provider
|
||||
.capabilities()
|
||||
.find(({ operation }) => operation === "procedure_pseudo_code")?.effects
|
||||
.mayShowUi,
|
||||
).toBe(true);
|
||||
expect(
|
||||
provider
|
||||
.capabilities()
|
||||
.find(({ operation }) => operation === "procedure_assembly")?.effects
|
||||
.mayShowUi,
|
||||
).toBe(false);
|
||||
expect(producer.connects).toBe(0);
|
||||
});
|
||||
|
||||
it("uses complete native JSON text instead of a shortened structured preview", () => {
|
||||
const complete = [
|
||||
{
|
||||
data: [{ addr: "0x1000", name: "main", size: "0x20" }],
|
||||
next_offset: null,
|
||||
},
|
||||
];
|
||||
const response = {
|
||||
content: [{ type: "text", text: JSON.stringify(complete) }],
|
||||
structuredContent: { result: [], _output_truncated: true },
|
||||
};
|
||||
expect(decodeIdaToolResult(response, "native")).toEqual(complete);
|
||||
expect(() =>
|
||||
decodeIdaToolResult({ ...response, content: [] }, "native"),
|
||||
).toThrow("complete JSON");
|
||||
expect(() =>
|
||||
decodeIdaToolResult(
|
||||
{ ...response, content: [{ type: "text", text: "broken JSON" }] },
|
||||
"native",
|
||||
),
|
||||
).toThrow();
|
||||
expect(() =>
|
||||
decodeIdaToolResult({ ...response, isError: true }, "native"),
|
||||
).toThrow("tool failed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("native IDA provider observations", () => {
|
||||
it.each([false, true])(
|
||||
"keeps unknown callers in dossier coverage when callee truncation is %s",
|
||||
async (more) => {
|
||||
const { client, producer } = await fixture();
|
||||
const original = producer.overrideCall;
|
||||
producer.overrideCall = (name, args) =>
|
||||
name === "callees"
|
||||
? [
|
||||
{
|
||||
callees: [{ addr: "0x2000", name: "puts", type: "external" }],
|
||||
more,
|
||||
error: null,
|
||||
},
|
||||
]
|
||||
: original?.(name, args);
|
||||
try {
|
||||
const direct = await client.execute("procedure_callees", {
|
||||
procedure: "main",
|
||||
});
|
||||
if (!direct.ok) throw direct.error;
|
||||
expect(direct.value.relationshipCoverage).toMatchObject({
|
||||
status: more ? "partial" : "complete",
|
||||
});
|
||||
const dossier = await client.execute("analyze_function", {
|
||||
procedure: "main",
|
||||
});
|
||||
if (!dossier.ok) throw dossier.error;
|
||||
expect(dossier.value.relationshipCoverage).toEqual({
|
||||
status: "unknown",
|
||||
reason:
|
||||
"Upstream xrefs do not establish direct caller relationships.",
|
||||
});
|
||||
expect(dossier.value.result).toMatchObject({
|
||||
callers: [],
|
||||
callees: [{ address: "0x2000", name: "puts" }],
|
||||
});
|
||||
if (more)
|
||||
expect(dossier.value.limitations).toContain(
|
||||
"Upstream callees capped results at 500; additional callees remain unknown.",
|
||||
);
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("retains native tool provenance, reads recorded metadata, and closes only its connection", async () => {
|
||||
const { client, producer, resources } = await fixture();
|
||||
for (const operation of [
|
||||
"list_procedures",
|
||||
"list_strings",
|
||||
"procedure_assembly",
|
||||
"procedure_pseudo_code",
|
||||
"procedure_callees",
|
||||
"xrefs",
|
||||
"analyze_function",
|
||||
] as const) {
|
||||
const result = await client.execute(
|
||||
operation,
|
||||
operation === "list_procedures" || operation === "list_strings"
|
||||
? {}
|
||||
: operation === "xrefs"
|
||||
? { address: "0x1000" }
|
||||
: { procedure: "main" },
|
||||
);
|
||||
if (!result.ok) throw result.error;
|
||||
expect(result.value.provider.name).toBe("IDA MCP adapter");
|
||||
if (operation === "procedure_assembly")
|
||||
expect(result.value.result).toBe("0x1000: ret");
|
||||
if (operation === "procedure_pseudo_code") {
|
||||
expect(result.value.result).toBe(producer.pseudocode);
|
||||
expect(result.value.rawResult).toMatchObject({
|
||||
observations: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
tool: "decompile",
|
||||
result: {
|
||||
addr: "0x1000",
|
||||
ok: true,
|
||||
pseudocode: producer.pseudocode,
|
||||
lines: 1,
|
||||
},
|
||||
}),
|
||||
]),
|
||||
});
|
||||
}
|
||||
}
|
||||
expect(resources).toHaveLength(14);
|
||||
expect(new Set(resources)).toEqual(new Set(["ida://idb/metadata"]));
|
||||
expect(
|
||||
producer.calls.find(({ name }) => name === "find_regex")?.args.pattern,
|
||||
).toBe("[\\s\\S]*");
|
||||
expect(
|
||||
(await client.execute("procedure_callers", { procedure: "main" })).ok,
|
||||
).toBe(false);
|
||||
expect((await client.close()).ok).toBe(true);
|
||||
expect(producer.closes).toBe(1);
|
||||
expect(producer.calls.some(({ name }) => name.startsWith("idb_"))).toBe(
|
||||
false,
|
||||
);
|
||||
const capabilities = idaCapabilities("attached", false, true);
|
||||
expect(
|
||||
capabilities.find(({ operation }) => operation === "procedure_callers")
|
||||
?.available,
|
||||
).toBe(false);
|
||||
expect(
|
||||
capabilities.find(
|
||||
({ operation }) => operation === "procedure_pseudo_code",
|
||||
)?.effects,
|
||||
).toMatchObject({ mayShowUi: true, mayAccessNetwork: true });
|
||||
expect(
|
||||
idaCapabilities("attached", false).find(
|
||||
({ operation }) => operation === "procedure_callers",
|
||||
)?.available,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects unavailable or mismatched recorded input hashes and database switches", async () => {
|
||||
const { client, producer, target } = await fixture();
|
||||
for (const hash of ["unavailable", "a".repeat(64)]) {
|
||||
producer.inputSha256 = hash;
|
||||
expect((await client.execute("health", {})).ok).toBe(false);
|
||||
}
|
||||
producer.inputSha256 = target.sha256;
|
||||
expect((await client.execute("health", {})).ok).toBe(true);
|
||||
producer.inputPath += ".other.i64";
|
||||
expect((await client.execute("list_procedures", {})).ok).toBe(false);
|
||||
await client.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("native IDA query identity", () => {
|
||||
it.each([
|
||||
"procedure_address",
|
||||
"procedure_assembly",
|
||||
"procedure_pseudo_code",
|
||||
"analyze_function",
|
||||
] as const)(
|
||||
"rejects a successful lookup for another query before %s can accept the wrong function",
|
||||
async (operation) => {
|
||||
const { client, producer } = await fixture();
|
||||
producer.overrideCall = (name) =>
|
||||
name === "lookup_funcs"
|
||||
? [
|
||||
{
|
||||
query: "helper",
|
||||
fn: { addr: "0x2000", name: "helper", size: "0x20" },
|
||||
error: null,
|
||||
},
|
||||
]
|
||||
: undefined;
|
||||
try {
|
||||
const result = await client.execute(operation, { procedure: "main" });
|
||||
if (result.ok)
|
||||
throw new Error("Expected a mismatched lookup rejection");
|
||||
expect(result.error._tag).toBe("AnalysisProtocolError");
|
||||
expect(result.error.message).toContain("different query");
|
||||
expect(
|
||||
producer.calls.some(
|
||||
({ name }) => name === "disasm" || name === "decompile",
|
||||
),
|
||||
).toBe(false);
|
||||
} finally {
|
||||
expect((await client.close()).ok).toBe(true);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("native IDA callee lookup batches", () => {
|
||||
it("batches unique callees and binds reordered replies to their source queries", async () => {
|
||||
const { client, producer } = await fixture();
|
||||
const previous = producer.overrideCall;
|
||||
producer.overrideCall = (name, args) => {
|
||||
if (name === "callees")
|
||||
return [
|
||||
{
|
||||
callees: [
|
||||
{ addr: "0x2004", name: "first", type: "internal" },
|
||||
{ addr: "0x2004", name: "first", type: "internal" },
|
||||
{ addr: "0x3000", name: "second", type: "internal" },
|
||||
{ addr: "0x4000", name: "import", type: "external" },
|
||||
],
|
||||
more: false,
|
||||
error: null,
|
||||
},
|
||||
];
|
||||
if (
|
||||
name === "lookup_funcs" &&
|
||||
Array.isArray(args.queries) &&
|
||||
args.queries.length > 1
|
||||
)
|
||||
return [
|
||||
{
|
||||
query: "0x3000",
|
||||
fn: { addr: "0x3000", name: "second", size: "0x20" },
|
||||
error: null,
|
||||
},
|
||||
{
|
||||
query: "0x2004",
|
||||
fn: { addr: "0x2000", name: "first", size: "0x20" },
|
||||
error: null,
|
||||
},
|
||||
];
|
||||
return previous?.(name, args);
|
||||
};
|
||||
try {
|
||||
const result = await client.execute("procedure_callees", {
|
||||
procedure: "main",
|
||||
});
|
||||
if (!result.ok) throw result.error;
|
||||
expect(result.value.result).toEqual(["0x4000", "0x2000", "0x3000"]);
|
||||
expect(
|
||||
producer.calls
|
||||
.filter(({ name }) => name === "lookup_funcs")
|
||||
.map(({ args }) => args.queries),
|
||||
).toEqual([["main"], ["0x2004", "0x3000"]]);
|
||||
expect(result.value.rawResult).toMatchObject({
|
||||
observations: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
tool: "lookup_funcs",
|
||||
arguments: { queries: ["0x2004", "0x3000"] },
|
||||
result: [
|
||||
expect.objectContaining({ query: "0x3000" }),
|
||||
expect.objectContaining({ query: "0x2004" }),
|
||||
],
|
||||
}),
|
||||
]),
|
||||
});
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects missing, duplicate, unrelated, and failed callee lookup rows as provider faults", async () => {
|
||||
const { client, producer } = await fixture();
|
||||
const previous = producer.overrideCall;
|
||||
const first = {
|
||||
query: "0x2000",
|
||||
fn: { addr: "0x2000", name: "first", size: "0x20" },
|
||||
error: null,
|
||||
};
|
||||
const second = {
|
||||
query: "0x3000",
|
||||
fn: { addr: "0x3000", name: "second", size: "0x20" },
|
||||
error: null,
|
||||
};
|
||||
let rows: JsonValue = [];
|
||||
producer.overrideCall = (name, args) => {
|
||||
if (name === "callees")
|
||||
return [
|
||||
{
|
||||
callees: [
|
||||
{ addr: "0x2000", name: "first", type: "internal" },
|
||||
{ addr: "0x3000", name: "second", type: "internal" },
|
||||
],
|
||||
more: false,
|
||||
error: null,
|
||||
},
|
||||
];
|
||||
if (
|
||||
name === "lookup_funcs" &&
|
||||
Array.isArray(args.queries) &&
|
||||
args.queries.length > 1
|
||||
)
|
||||
return rows;
|
||||
return previous?.(name, args);
|
||||
};
|
||||
try {
|
||||
for (const reply of [
|
||||
[first],
|
||||
[first, first],
|
||||
[first, { ...second, query: "unrequested" }],
|
||||
[first, { query: "0x3000", fn: null, error: "Not found" }],
|
||||
]) {
|
||||
rows = reply;
|
||||
const result = await client.execute("procedure_callees", {
|
||||
procedure: "main",
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok)
|
||||
expect(result.error).toBeInstanceOf(AnalysisProtocolError);
|
||||
}
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("native IDA literal matching", () => {
|
||||
it("retains literal, Unicode, case, and regex semantics while narrowing native candidates", async () => {
|
||||
const { client, producer } = await fixture();
|
||||
const values = [
|
||||
"A.B",
|
||||
"axb",
|
||||
"Kelvin",
|
||||
"İstanbul",
|
||||
"σ",
|
||||
"ς",
|
||||
"literal[bracket",
|
||||
"a\\b",
|
||||
"🧪",
|
||||
];
|
||||
producer.overrideCall = (name, args) => {
|
||||
if (name !== "find_regex") return undefined;
|
||||
if (typeof args.pattern !== "string")
|
||||
throw new Error("Invalid fixture pattern");
|
||||
// Exercise a byte-oriented producer seam, including Unicode candidates
|
||||
// whose final matching semantics belong to REA, not the upstream regex.
|
||||
const regex = new RegExp(
|
||||
Buffer.from(args.pattern).toString("latin1"),
|
||||
"i",
|
||||
);
|
||||
return {
|
||||
matches: values.flatMap((value, i) =>
|
||||
regex.test(Buffer.from(value).toString("latin1"))
|
||||
? [{ addr: `0x${(0x3000 + i).toString(16)}`, string: value }]
|
||||
: [],
|
||||
),
|
||||
cursor: { done: true },
|
||||
};
|
||||
};
|
||||
const cases = [
|
||||
{
|
||||
pattern: "a.b",
|
||||
mode: "literal",
|
||||
case_sensitive: false,
|
||||
expected: ["A.B"],
|
||||
},
|
||||
{ pattern: "a.b", mode: "literal", case_sensitive: true, expected: [] },
|
||||
{
|
||||
pattern: "kelvin",
|
||||
mode: "literal",
|
||||
case_sensitive: false,
|
||||
expected: ["Kelvin"],
|
||||
},
|
||||
{
|
||||
pattern: "İ",
|
||||
mode: "literal",
|
||||
case_sensitive: false,
|
||||
expected: ["İstanbul"],
|
||||
},
|
||||
{ pattern: "σ", mode: "literal", case_sensitive: false, expected: ["σ"] },
|
||||
{
|
||||
pattern: "σ",
|
||||
mode: "regex",
|
||||
case_sensitive: false,
|
||||
expected: ["σ", "ς"],
|
||||
},
|
||||
{
|
||||
pattern: "[",
|
||||
mode: "literal",
|
||||
case_sensitive: false,
|
||||
expected: ["literal[bracket"],
|
||||
},
|
||||
{
|
||||
pattern: "\\",
|
||||
mode: "literal",
|
||||
case_sensitive: true,
|
||||
expected: ["a\\b"],
|
||||
},
|
||||
{
|
||||
pattern: "\ud800",
|
||||
mode: "literal",
|
||||
case_sensitive: false,
|
||||
expected: [],
|
||||
},
|
||||
{
|
||||
pattern: "🧪",
|
||||
mode: "literal",
|
||||
case_sensitive: true,
|
||||
expected: ["🧪"],
|
||||
},
|
||||
];
|
||||
try {
|
||||
for (const { expected, ...query } of cases) {
|
||||
const before = producer.calls.length;
|
||||
const result = await client.execute("search_strings", query);
|
||||
if (!result.ok) throw result.error;
|
||||
expect(result.value.result).toEqual(
|
||||
expected.map((value) => ({
|
||||
address: `0x${(0x3000 + values.indexOf(value)).toString(16)}`,
|
||||
value,
|
||||
})),
|
||||
);
|
||||
const reads = producer.calls
|
||||
.slice(before)
|
||||
.filter(({ name }) => name === "find_regex");
|
||||
expect(reads).toHaveLength(1);
|
||||
if (query.mode === "regex" || query.pattern === "\ud800")
|
||||
expect(reads[0]?.args.pattern).toBe("[\\s\\S]*");
|
||||
else expect(reads[0]?.args.pattern).not.toBe("[\\s\\S]*");
|
||||
}
|
||||
const before = producer.calls.length;
|
||||
const invalid = await client.execute("search_strings", {
|
||||
pattern: "[",
|
||||
mode: "regex",
|
||||
});
|
||||
expect(invalid.ok).toBe(false);
|
||||
if (!invalid.ok) expect(invalid.error).toBeInstanceOf(AnalysisInputError);
|
||||
expect(
|
||||
producer.calls.slice(before).some(({ name }) => name === "find_regex"),
|
||||
).toBe(false);
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("native IDA string pagination and failures", () => {
|
||||
it("reads all candidate pages and reports native search failures explicitly", async () => {
|
||||
const { client, producer } = await fixture();
|
||||
producer.overrideCall = (name, args) =>
|
||||
name === "find_regex"
|
||||
? {
|
||||
matches: [
|
||||
{
|
||||
addr: args.offset === 0 ? "0x3000" : "0x3001",
|
||||
string: args.offset === 0 ? "σ" : "needle",
|
||||
},
|
||||
],
|
||||
cursor: args.offset === 0 ? { next: 1 } : { done: true },
|
||||
}
|
||||
: undefined;
|
||||
try {
|
||||
const found = await client.execute("search_strings", {
|
||||
pattern: "needle",
|
||||
});
|
||||
if (!found.ok) throw found.error;
|
||||
expect(found.value.result).toEqual([
|
||||
{ address: "0x3001", value: "needle" },
|
||||
]);
|
||||
const reads = producer.calls.filter(({ name }) => name === "find_regex");
|
||||
expect(reads.map(({ args }) => args.offset)).toEqual([0, 1]);
|
||||
expect(new Set(reads.map(({ args }) => args.pattern)).size).toBe(1);
|
||||
producer.overrideCall = (name) =>
|
||||
name === "find_regex"
|
||||
? {
|
||||
matches: [],
|
||||
cursor: { done: true },
|
||||
error: "Regex resource failure",
|
||||
}
|
||||
: undefined;
|
||||
const failed = await client.execute("search_strings", {
|
||||
pattern: "needle",
|
||||
});
|
||||
expect(failed.ok).toBe(false);
|
||||
if (!failed.ok) {
|
||||
expect(failed.error).toBeInstanceOf(AnalysisProtocolError);
|
||||
expect(failed.error.message).toContain("Regex resource failure");
|
||||
}
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("native IDA malformed observations", () => {
|
||||
it("rejects wrong-function pseudocode, upstream cloud failures, and malformed assembly", async () => {
|
||||
const { client, producer } = await fixture();
|
||||
for (const value of [
|
||||
{ addr: "0x2000", ok: true, pseudocode: "int wrong(void);" },
|
||||
{
|
||||
addr: "0x1000",
|
||||
ok: false,
|
||||
error: "No pseudocode widget for this function after decompilation",
|
||||
},
|
||||
{ addr: "0x1000", ok: true },
|
||||
]) {
|
||||
producer.overrideCall = (name) =>
|
||||
name === "decompile" ? value : undefined;
|
||||
const result = await client.execute("procedure_pseudo_code", {
|
||||
procedure: "main",
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
}
|
||||
producer.overrideCall = (name) =>
|
||||
name === "disasm"
|
||||
? {
|
||||
asm: {
|
||||
name: "main",
|
||||
start_ea: "0x1000",
|
||||
lines: "main (.text @ 0x1000):\nunsupported",
|
||||
},
|
||||
cursor: { done: true },
|
||||
}
|
||||
: undefined;
|
||||
expect(
|
||||
(await client.execute("procedure_assembly", { procedure: "main" })).ok,
|
||||
).toBe(false);
|
||||
await client.close();
|
||||
});
|
||||
|
||||
it("follows native assembly pages and rejects non-advancing cursors", async () => {
|
||||
const { client, producer } = await fixture();
|
||||
producer.overrideCall = (name, args): JsonValue | undefined =>
|
||||
name === "disasm"
|
||||
? {
|
||||
asm: {
|
||||
name: "main",
|
||||
start_ea: "0x1000",
|
||||
lines: `main (.text @ 0x1000):\n${args.offset === 0 ? "1000 nop" : "1001 ret"}`,
|
||||
},
|
||||
cursor: args.offset === 0 ? { next: 1 } : { done: true },
|
||||
}
|
||||
: undefined;
|
||||
const result = await client.execute("procedure_assembly", {
|
||||
procedure: "main",
|
||||
});
|
||||
expect(result.ok && result.value.result).toBe("0x1000: nop\n0x1001: ret");
|
||||
producer.overrideCall = (name) =>
|
||||
name === "find_regex" ? { matches: [], cursor: { next: 0 } } : undefined;
|
||||
expect((await client.execute("list_strings", {})).ok).toBe(false);
|
||||
await client.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import { z } from "zod";
|
||||
import { AnalysisProtocolError } from "../domain/analysisErrorCore.js";
|
||||
import {
|
||||
cursorSchema,
|
||||
disassemblySchema,
|
||||
idaAddressSchema,
|
||||
modernCalleesSchema,
|
||||
modernStringsSchema,
|
||||
modernXrefsSchema,
|
||||
} from "./IdaProtocolValues.js";
|
||||
|
||||
/** Native MCP renders an assembly header followed by bare hexadecimal coordinates. */
|
||||
const nativeAssemblySchema = disassemblySchema
|
||||
.omit({ lines: true })
|
||||
.extend({
|
||||
lines: z.string(),
|
||||
})
|
||||
.transform((asm) => {
|
||||
const [header, ...body] = asm.lines.split("\n");
|
||||
const match = header?.match(/^(.+) \((.*) @ (0x[0-9a-f]+)\):$/iu);
|
||||
if (
|
||||
match?.[1] !== asm.name ||
|
||||
idaAddressSchema.parse(match?.[3]) !== asm.start_ea
|
||||
)
|
||||
throw new AnalysisProtocolError(
|
||||
"Native IDA assembly header contradicts its function identity.",
|
||||
);
|
||||
return {
|
||||
...asm,
|
||||
lines: body.map((line) => {
|
||||
const item = line.match(/^([0-9a-f]+) (.*)$/iu);
|
||||
if (item === null)
|
||||
throw new AnalysisProtocolError(
|
||||
"Native IDA returned an unsupported assembly line.",
|
||||
);
|
||||
return {
|
||||
address: idaAddressSchema.parse(item[1]),
|
||||
instruction: item[2] ?? "",
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
/** Native assembly retains the same explicit pagination cursor as modern MCP. */
|
||||
export const nativeDisassemblySchema = z.object({
|
||||
asm: nativeAssemblySchema.nullable(),
|
||||
error: z.string().optional(),
|
||||
cursor: cursorSchema,
|
||||
});
|
||||
|
||||
/** GUI decompilation reports the resolved entry and an explicit success/failure. */
|
||||
export const nativeDecompileSchema = z.object({
|
||||
addr: idaAddressSchema,
|
||||
ok: z.boolean(),
|
||||
pseudocode: z.string().min(1).optional(),
|
||||
lines: z.number().int().nonnegative().optional(),
|
||||
error: z.string().optional(),
|
||||
});
|
||||
|
||||
/** Native relationship successes explicitly carry null errors. Normalize that omission here. */
|
||||
const nativeErrorSchema = z
|
||||
.string()
|
||||
.nullish()
|
||||
.transform((value) => value ?? undefined);
|
||||
export const nativeCalleesSchema = z
|
||||
.array(modernCalleesSchema.element.extend({ error: nativeErrorSchema }))
|
||||
.length(1);
|
||||
export const nativeXrefsSchema = z
|
||||
.array(modernXrefsSchema.element.extend({ error: nativeErrorSchema }))
|
||||
.length(1);
|
||||
|
||||
/** Native regex failures carry an empty page, which must not imply absence. */
|
||||
export const nativeStringsSchema = modernStringsSchema
|
||||
.extend({ error: z.string().optional() })
|
||||
.transform((page) => {
|
||||
if (page.error !== undefined)
|
||||
throw new AnalysisProtocolError(
|
||||
`Native IDA string search failed: ${page.error}`,
|
||||
);
|
||||
return page;
|
||||
});
|
||||
|
||||
/**
|
||||
* Native regexes fold bytes, while REA's insensitive literal search folds
|
||||
* Unicode strings. Include every non-ASCII value as a candidate, then apply
|
||||
* REA's exact predicate. Ill-formed UTF-16 cannot cross upstream JSON/UTF-8.
|
||||
*/
|
||||
export const nativeLiteralSearchPattern = (
|
||||
pattern: string,
|
||||
caseSensitive: boolean,
|
||||
): string | undefined => {
|
||||
if (/[\uD800-\uDFFF]/u.test(pattern)) return undefined;
|
||||
let literal = (caseSensitive ? pattern : pattern.toLowerCase()).replace(
|
||||
/[.*+?^${}()|[\]\\]/gu,
|
||||
"\\$&",
|
||||
);
|
||||
// Spell ASCII variants explicitly rather than depending on an upstream locale.
|
||||
if (!caseSensitive)
|
||||
literal = literal.replace(
|
||||
/[a-z]/gu,
|
||||
(char) => `[${char}${char.toUpperCase()}]`,
|
||||
);
|
||||
return caseSensitive ? literal : `(?:${literal})|[\\x80-\\xff]`;
|
||||
};
|
||||
|
||||
/** Read-only tools required from the native bridge; GUI lifecycle tools are excluded. */
|
||||
export const nativeTools = [
|
||||
"list_funcs",
|
||||
"lookup_funcs",
|
||||
"find_regex",
|
||||
"decompile",
|
||||
"disasm",
|
||||
"callees",
|
||||
"xrefs_to",
|
||||
];
|
||||
@@ -341,6 +341,10 @@ describe("headless IDA procedure lookup failures", () => {
|
||||
["an empty error", lookup("main", null, "")],
|
||||
["a whitespace-only error", lookup("main", null, " ")],
|
||||
["an answer for a different query", lookup("helper", null, "Not found")],
|
||||
[
|
||||
"a successful answer for a different query",
|
||||
lookup("helper", knownFunction, null),
|
||||
],
|
||||
])("keeps %s as unreadable output", async (_label, answer) => {
|
||||
const client = await missingProcedureFixture(() => answer);
|
||||
try {
|
||||
|
||||
+203
-79
@@ -1,4 +1,12 @@
|
||||
import { z } from "zod";
|
||||
import {
|
||||
nativeDecompileSchema,
|
||||
nativeDisassemblySchema,
|
||||
nativeCalleesSchema,
|
||||
nativeXrefsSchema,
|
||||
nativeLiteralSearchPattern,
|
||||
nativeStringsSchema,
|
||||
} from "./IdaNativeProtocol.js";
|
||||
import { analysisSearchInput } from "../contracts/analysisSearchContract.js";
|
||||
import {
|
||||
AnalysisInputError,
|
||||
@@ -14,7 +22,10 @@ import type { RelationshipCoverage } from "../domain/relationshipCoverage.js";
|
||||
import { readCallGraphCallees, unavailableCallGraph } from "./IdaCallGraph.js";
|
||||
import type { IdaMcpConnection } from "./IdaMcpConnection.js";
|
||||
import type { IdaOperation } from "./IdaProviderCapabilities.js";
|
||||
import { IDA_LIMITATIONS } from "./IdaProviderCapabilities.js";
|
||||
import {
|
||||
IDA_LIMITATIONS,
|
||||
IDA_NATIVE_STRING_LIMITATION,
|
||||
} from "./IdaProviderCapabilities.js";
|
||||
import type { EvidenceLocation } from "../domain/evidence.js";
|
||||
import {
|
||||
disassemblySchema,
|
||||
@@ -28,6 +39,7 @@ import {
|
||||
modernDecompileSchema,
|
||||
modernDisassemblySchema,
|
||||
modernLookupSchema,
|
||||
modernLookupBatchSchema,
|
||||
modernPageSchema,
|
||||
modernStringsSchema,
|
||||
modernXrefsSchema,
|
||||
@@ -53,6 +65,38 @@ const identity = (fn: IdaFunction) => ({
|
||||
classification: null,
|
||||
body: unknownBody,
|
||||
});
|
||||
interface IdaRelationships {
|
||||
readonly items: ReturnType<typeof identity>[];
|
||||
readonly coverage: RelationshipCoverage;
|
||||
}
|
||||
|
||||
const combinedRelationshipCoverage = (
|
||||
callers: RelationshipCoverage,
|
||||
callees: RelationshipCoverage,
|
||||
): RelationshipCoverage => {
|
||||
const coverage = [callers, callees];
|
||||
const diagnostics = [
|
||||
...new Set(
|
||||
coverage.flatMap((item) =>
|
||||
"diagnostics" in item ? item.diagnostics : [],
|
||||
),
|
||||
),
|
||||
];
|
||||
const details = diagnostics.length > 0 ? { diagnostics } : {};
|
||||
const unknown = coverage.filter((item) => item.status === "unknown");
|
||||
if (unknown.length > 0)
|
||||
return {
|
||||
status: "unknown",
|
||||
reason: [...new Set(unknown.map((item) => item.reason))].join(" "),
|
||||
...details,
|
||||
};
|
||||
const limitations = coverage.flatMap((item) =>
|
||||
item.status === "partial" ? item.limitations : [],
|
||||
);
|
||||
return limitations.length > 0
|
||||
? { status: "partial", limitations, ...details }
|
||||
: { status: "complete", ...details };
|
||||
};
|
||||
/** The only lookup answers upstream reports for a name or address that is not a function. */
|
||||
const MISSING_PROCEDURE_ANSWERS: ReadonlySet<string> = new Set([
|
||||
"Not found",
|
||||
@@ -60,6 +104,7 @@ const MISSING_PROCEDURE_ANSWERS: ReadonlySet<string> = new Set([
|
||||
]);
|
||||
const lines = (asm: IdaDisassembly) =>
|
||||
asm.lines.map(({ address, instruction }) => `${address}: ${instruction}`);
|
||||
const searchQuerySchema = z.object(analysisSearchInput);
|
||||
|
||||
/** Translate explicit analyst questions into a closed set of read-only upstream requests. */
|
||||
export class IdaOperationRunner {
|
||||
@@ -69,7 +114,7 @@ export class IdaOperationRunner {
|
||||
relationshipCoverage: RelationshipCoverage | undefined;
|
||||
constructor(
|
||||
private readonly connection: IdaMcpConnection,
|
||||
private readonly protocol: "legacy" | "modern",
|
||||
private readonly protocol: "legacy" | "modern" | "native",
|
||||
private readonly database?: string,
|
||||
private readonly options: {
|
||||
readonly signal?: AbortSignal | undefined;
|
||||
@@ -131,37 +176,11 @@ export class IdaOperationRunner {
|
||||
* addresses were reported by IDA itself.
|
||||
*/
|
||||
async resolve(query: string, requestedBy?: string): Promise<IdaFunction> {
|
||||
if (this.protocol === "modern") {
|
||||
if (this.protocol !== "legacy") {
|
||||
const item = modernLookupSchema.parse(
|
||||
await this.call("lookup_funcs", { queries: [query] }),
|
||||
)[0];
|
||||
if (
|
||||
requestedBy !== undefined &&
|
||||
item?.query === query &&
|
||||
item.fn === null &&
|
||||
item.error !== null &&
|
||||
MISSING_PROCEDURE_ANSWERS.has(item.error)
|
||||
)
|
||||
throw new AnalysisInputError(
|
||||
requestedBy,
|
||||
{
|
||||
cause: new AnalysisProtocolError(
|
||||
`IDA function lookup failed for ${query}: ${item.error}`,
|
||||
),
|
||||
},
|
||||
[
|
||||
{
|
||||
path: [],
|
||||
reason: "invalid_value",
|
||||
message: `IDA could not resolve procedure ${JSON.stringify(query)}: ${item.error}.`,
|
||||
},
|
||||
],
|
||||
);
|
||||
if (item?.fn == null || item.error !== null)
|
||||
throw new AnalysisProtocolError(
|
||||
`IDA function lookup failed for ${query}: ${item?.error ?? "missing function"}`,
|
||||
);
|
||||
return item.fn;
|
||||
return this.lookupFunction(query, item, requestedBy);
|
||||
}
|
||||
const address = idaAddressSchema.safeParse(
|
||||
/^0x/iu.test(query) ? query : undefined,
|
||||
@@ -184,6 +203,43 @@ export class IdaOperationRunner {
|
||||
return entry;
|
||||
}
|
||||
|
||||
private lookupFunction(
|
||||
query: string,
|
||||
item: z.infer<typeof modernLookupBatchSchema>[number] | undefined,
|
||||
requestedBy?: string,
|
||||
): IdaFunction {
|
||||
if (item?.query !== query)
|
||||
throw new AnalysisProtocolError(
|
||||
`IDA function lookup answered for a different query than ${JSON.stringify(query)}.`,
|
||||
);
|
||||
if (
|
||||
requestedBy !== undefined &&
|
||||
item.fn === null &&
|
||||
item.error !== null &&
|
||||
MISSING_PROCEDURE_ANSWERS.has(item.error)
|
||||
)
|
||||
throw new AnalysisInputError(
|
||||
requestedBy,
|
||||
{
|
||||
cause: new AnalysisProtocolError(
|
||||
`IDA function lookup failed for ${query}: ${item.error}`,
|
||||
),
|
||||
},
|
||||
[
|
||||
{
|
||||
path: [],
|
||||
reason: "invalid_value",
|
||||
message: `IDA could not resolve procedure ${JSON.stringify(query)}: ${item.error}.`,
|
||||
},
|
||||
],
|
||||
);
|
||||
if (item.fn === null || item.error !== null)
|
||||
throw new AnalysisProtocolError(
|
||||
`IDA function lookup failed for ${query}: ${item.error ?? "missing function"}`,
|
||||
);
|
||||
return item.fn;
|
||||
}
|
||||
|
||||
/** True only for modern callee reads on targets not proven x86-only; x86 reads never reach the graph code. */
|
||||
private readsCallGraph(direction: "callers" | "callees"): boolean {
|
||||
return (
|
||||
@@ -197,7 +253,7 @@ export class IdaOperationRunner {
|
||||
private async addCallGraphCallees(
|
||||
fn: IdaFunction,
|
||||
result: Map<string, ReturnType<typeof identity>>,
|
||||
): Promise<void> {
|
||||
): Promise<RelationshipCoverage> {
|
||||
const graph =
|
||||
this.options.callGraphAvailable === false
|
||||
? unavailableCallGraph("the upstream server does not advertise it")
|
||||
@@ -205,14 +261,18 @@ export class IdaOperationRunner {
|
||||
(tool, args) => this.call(tool, args),
|
||||
fn.address,
|
||||
);
|
||||
this.relationshipCoverage = graph.coverage;
|
||||
this.limitations.push(...graph.limitations);
|
||||
for (const callee of graph.callees)
|
||||
if (!result.has(callee.address))
|
||||
result.set(callee.address, identity({ ...callee, size: "unknown" }));
|
||||
return graph.coverage;
|
||||
}
|
||||
|
||||
async strings(): Promise<{ address: string; value: string }[]> {
|
||||
async strings(
|
||||
nativePattern?: string,
|
||||
): Promise<{ address: string; value: string }[]> {
|
||||
if (this.protocol === "native")
|
||||
this.limitations.push(IDA_NATIVE_STRING_LIMITATION);
|
||||
const result: { address: string; value: string }[] = [];
|
||||
let offset: number | null = 0;
|
||||
while (offset !== null) {
|
||||
@@ -229,9 +289,14 @@ export class IdaOperationRunner {
|
||||
);
|
||||
offset = page.next_offset;
|
||||
} else {
|
||||
const page = modernStringsSchema.parse(
|
||||
const page = (
|
||||
this.protocol === "native" ? nativeStringsSchema : modernStringsSchema
|
||||
).parse(
|
||||
await this.call("find_regex", {
|
||||
pattern: "(?s).*",
|
||||
pattern:
|
||||
this.protocol === "native"
|
||||
? (nativePattern ?? "[\\s\\S]*")
|
||||
: "(?s).*",
|
||||
offset,
|
||||
limit: 100,
|
||||
}),
|
||||
@@ -260,7 +325,11 @@ export class IdaOperationRunner {
|
||||
let result: IdaDisassembly | undefined;
|
||||
let offset: number | null = 0;
|
||||
while (offset !== null) {
|
||||
const page = modernDisassemblySchema.parse(
|
||||
const page = (
|
||||
this.protocol === "native"
|
||||
? nativeDisassemblySchema
|
||||
: modernDisassemblySchema
|
||||
).parse(
|
||||
await this.call("disasm", {
|
||||
addr: fn.address,
|
||||
offset,
|
||||
@@ -294,6 +363,23 @@ export class IdaOperationRunner {
|
||||
.string()
|
||||
.nullable()
|
||||
.parse(await this.call("decompile_function", { address: fn.address }));
|
||||
if (this.protocol === "native") {
|
||||
const page = nativeDecompileSchema.parse(
|
||||
await this.call("decompile", { addr: fn.address }),
|
||||
);
|
||||
if (page.addr !== fn.address)
|
||||
throw new AnalysisProtocolError(
|
||||
"Native IDA pseudocode identifies a different function than the resolved entry.",
|
||||
);
|
||||
if (!page.ok || page.error !== undefined || page.pseudocode === undefined)
|
||||
throw new AnalysisProtocolError(
|
||||
`Native IDA decompilation failed: ${page.error ?? "missing pseudocode"}`,
|
||||
);
|
||||
this.limitations.push(
|
||||
"Native pseudocode is rendered GUI text; the upstream viewer traversal is bounded and does not establish complete source recovery.",
|
||||
);
|
||||
return page.pseudocode;
|
||||
}
|
||||
const result: string[] = [];
|
||||
let offset: number | null = 0;
|
||||
while (offset !== null) {
|
||||
@@ -323,9 +409,9 @@ export class IdaOperationRunner {
|
||||
return legacyXrefsSchema.parse(
|
||||
await this.call("get_xrefs_to", { address }),
|
||||
);
|
||||
const page = modernXrefsSchema.parse(
|
||||
await this.call("xrefs_to", { addrs: [address], limit: 1000 }),
|
||||
)[0];
|
||||
const page = (
|
||||
this.protocol === "native" ? nativeXrefsSchema : modernXrefsSchema
|
||||
).parse(await this.call("xrefs_to", { addrs: [address], limit: 1000 }))[0];
|
||||
if (page?.xrefs == null || page.error !== undefined)
|
||||
throw new AnalysisProtocolError(
|
||||
`IDA xrefs failed: ${page?.error ?? "missing references"}`,
|
||||
@@ -344,12 +430,16 @@ export class IdaOperationRunner {
|
||||
async relationships(
|
||||
fn: IdaFunction,
|
||||
direction: "callers" | "callees",
|
||||
): Promise<ReturnType<typeof identity>[]> {
|
||||
): Promise<IdaRelationships> {
|
||||
const queries: string[] = [];
|
||||
const result = new Map<string, ReturnType<typeof identity>>();
|
||||
let coverage: RelationshipCoverage = {
|
||||
status: "unknown",
|
||||
reason: "The legacy relationship response does not report coverage.",
|
||||
};
|
||||
if (this.readsCallGraph(direction)) {
|
||||
await this.addCallGraphCallees(fn, result);
|
||||
return [...result.values()];
|
||||
coverage = await this.addCallGraphCallees(fn, result);
|
||||
return { items: [...result.values()], coverage };
|
||||
}
|
||||
if (this.protocol === "legacy") {
|
||||
const value = await this.call(
|
||||
@@ -367,16 +457,20 @@ export class IdaOperationRunner {
|
||||
}
|
||||
} 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.",
|
||||
"Upstream xrefs classify references as code or data, without proving call edges. Direct callers are unavailable in this profile.",
|
||||
);
|
||||
this.relationshipCoverage = {
|
||||
status: "unknown",
|
||||
reason:
|
||||
"Modern upstream xrefs do not establish direct caller relationships.",
|
||||
return {
|
||||
items: [],
|
||||
coverage: {
|
||||
status: "unknown",
|
||||
reason:
|
||||
"Upstream xrefs do not establish direct caller relationships.",
|
||||
},
|
||||
};
|
||||
return [];
|
||||
} else {
|
||||
const page = modernCalleesSchema.parse(
|
||||
const page = (
|
||||
this.protocol === "native" ? nativeCalleesSchema : modernCalleesSchema
|
||||
).parse(
|
||||
await this.call("callees", { addrs: [fn.address], limit: 500 }),
|
||||
)[0];
|
||||
if (page?.callees == null || page.error !== undefined)
|
||||
@@ -387,7 +481,7 @@ export class IdaOperationRunner {
|
||||
this.limitations.push(
|
||||
"Upstream callees capped results at 500; additional callees remain unknown.",
|
||||
);
|
||||
this.relationshipCoverage =
|
||||
coverage =
|
||||
page.more === true
|
||||
? {
|
||||
status: "partial",
|
||||
@@ -405,35 +499,62 @@ export class IdaOperationRunner {
|
||||
else queries.push(item.addr);
|
||||
}
|
||||
}
|
||||
for (const query of new Set(queries)) {
|
||||
const resolved = identity(await this.resolve(query));
|
||||
result.set(resolved.address, resolved);
|
||||
const querySet = new Set(queries);
|
||||
if (this.protocol === "native" && querySet.size > 0) {
|
||||
const rows = modernLookupBatchSchema.parse(
|
||||
await this.call("lookup_funcs", { queries: [...querySet] }),
|
||||
);
|
||||
const byQuery = new Map(rows.map((row) => [row.query, row]));
|
||||
if (
|
||||
rows.length !== querySet.size ||
|
||||
byQuery.size !== rows.length ||
|
||||
rows.some((row) => !querySet.has(row.query))
|
||||
)
|
||||
throw new AnalysisProtocolError(
|
||||
"Native IDA returned an inconsistent callee lookup batch.",
|
||||
);
|
||||
for (const query of querySet) {
|
||||
const resolved = identity(
|
||||
this.lookupFunction(query, byQuery.get(query)),
|
||||
);
|
||||
result.set(resolved.address, resolved);
|
||||
}
|
||||
} else {
|
||||
for (const query of querySet) {
|
||||
const resolved = identity(await this.resolve(query));
|
||||
result.set(resolved.address, resolved);
|
||||
}
|
||||
}
|
||||
if (this.relationshipCoverage === undefined)
|
||||
this.relationshipCoverage = {
|
||||
status: "unknown",
|
||||
reason: "The legacy relationship response does not report coverage.",
|
||||
};
|
||||
return [...result.values()];
|
||||
return { items: [...result.values()], coverage };
|
||||
}
|
||||
|
||||
async run(
|
||||
operation: IdaOperation,
|
||||
parameters: Readonly<Record<string, JsonValue>>,
|
||||
): Promise<JsonValue> {
|
||||
const query =
|
||||
operation === "search_procedures" || operation === "search_strings"
|
||||
? searchQuerySchema.parse(parameters)
|
||||
: undefined;
|
||||
const matches =
|
||||
query === undefined ? undefined : this.searchPredicate(query);
|
||||
if (operation === "list_procedures" || operation === "search_procedures") {
|
||||
const items = (await this.functions()).map((fn) => ({
|
||||
address: fn.address,
|
||||
value: fn.name,
|
||||
}));
|
||||
return operation === "list_procedures"
|
||||
return matches === undefined
|
||||
? items
|
||||
: this.search(items, parameters);
|
||||
: items.filter(({ value }) => matches(value));
|
||||
}
|
||||
if (operation === "list_strings" || operation === "search_strings") {
|
||||
const items = await this.strings();
|
||||
return operation === "search_strings"
|
||||
? this.search(items, parameters)
|
||||
const nativePattern =
|
||||
this.protocol === "native" && query?.mode === "literal"
|
||||
? nativeLiteralSearchPattern(query.pattern, query.case_sensitive)
|
||||
: undefined;
|
||||
const items = await this.strings(nativePattern);
|
||||
return matches !== undefined
|
||||
? items.filter(({ value }) => matches(value))
|
||||
: parameters.address === undefined
|
||||
? items
|
||||
: items.filter(
|
||||
@@ -459,17 +580,18 @@ export class IdaOperationRunner {
|
||||
case "procedure_assembly":
|
||||
return lines(await this.assembly(fn)).join("\n");
|
||||
case "procedure_callers":
|
||||
if (this.protocol === "modern")
|
||||
case "procedure_callees": {
|
||||
if (operation === "procedure_callers" && this.protocol !== "legacy")
|
||||
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,
|
||||
const relationships = await this.relationships(
|
||||
fn,
|
||||
operation === "procedure_callers" ? "callers" : "callees",
|
||||
);
|
||||
this.relationshipCoverage = relationships.coverage;
|
||||
return relationships.items.map(({ address }) => address);
|
||||
}
|
||||
case "read_function_instructions":
|
||||
return functionInstructionWindowSchema.parse({
|
||||
procedure: identity(fn),
|
||||
@@ -481,11 +603,9 @@ export class IdaOperationRunner {
|
||||
}
|
||||
}
|
||||
|
||||
private search(
|
||||
items: { address: string; value: string }[],
|
||||
input: Readonly<Record<string, JsonValue>>,
|
||||
): JsonValue {
|
||||
const query = z.object(analysisSearchInput).parse(input);
|
||||
private searchPredicate(
|
||||
query: z.infer<typeof searchQuerySchema>,
|
||||
): (value: string) => boolean {
|
||||
let matches: (value: string) => boolean;
|
||||
if (query.mode === "regex") {
|
||||
try {
|
||||
@@ -504,7 +624,7 @@ export class IdaOperationRunner {
|
||||
matches = (value) =>
|
||||
(query.case_sensitive ? value : value.toLowerCase()).includes(needle);
|
||||
}
|
||||
return items.filter(({ value }) => matches(value));
|
||||
return matches;
|
||||
}
|
||||
|
||||
private async dossier(fn: IdaFunction): Promise<JsonValue> {
|
||||
@@ -512,6 +632,10 @@ export class IdaOperationRunner {
|
||||
const pseudocode = await this.pseudocode(fn);
|
||||
const callers = await this.relationships(fn, "callers");
|
||||
const callees = await this.relationships(fn, "callees");
|
||||
this.relationshipCoverage = combinedRelationshipCoverage(
|
||||
callers.coverage,
|
||||
callees.coverage,
|
||||
);
|
||||
const signature =
|
||||
asm.return_type == null || asm.arguments == null
|
||||
? null
|
||||
@@ -521,8 +645,8 @@ export class IdaOperationRunner {
|
||||
pseudocode: pseudocode ?? "",
|
||||
assembly: lines(asm),
|
||||
comments: [],
|
||||
callers,
|
||||
callees,
|
||||
callers: callers.items,
|
||||
callees: callees.items,
|
||||
incoming_references: [],
|
||||
outgoing_references: [],
|
||||
referenced_strings: [],
|
||||
|
||||
@@ -58,6 +58,7 @@ export class IdaProvider implements AnalysisProviderCandidate {
|
||||
return idaCapabilities(
|
||||
config.ok ? config.value.mode : "attached",
|
||||
config.ok && "command" in config.value,
|
||||
config.ok && config.value.protocol === "native",
|
||||
);
|
||||
}
|
||||
inspectAvailability(): ProviderAvailability {
|
||||
@@ -149,9 +150,11 @@ export class IdaProvider implements AnalysisProviderCandidate {
|
||||
upstream_distribution_version: null,
|
||||
mode: config.value.mode,
|
||||
compatibility_profile:
|
||||
config.value.mode === "attached"
|
||||
? "legacy-1.4"
|
||||
: "database-supervisor",
|
||||
config.value.protocol === "native"
|
||||
? "native-ida-mcp"
|
||||
: config.value.mode === "attached"
|
||||
? "legacy-1.4"
|
||||
: "database-supervisor",
|
||||
database_revision: null,
|
||||
cache_policy: "live",
|
||||
registration_digest: createHash("sha256")
|
||||
|
||||
@@ -3,14 +3,14 @@ import type {
|
||||
CapabilityDescriptor,
|
||||
} from "../application/AnalysisProvider.js";
|
||||
|
||||
/** This version identifies REA's adapter, not IDA or the upstream Python package. */
|
||||
/** This version identifies REA's adapter, not IDA or the upstream MCP server. */
|
||||
export const IDA_PROVIDER_IDENTITY = Object.freeze({
|
||||
id: "ida",
|
||||
name: "IDA Pro MCP adapter",
|
||||
name: "IDA MCP adapter",
|
||||
version: "1",
|
||||
});
|
||||
|
||||
/** Read-only analyst outcomes supported by both upstream compatibility profiles. */
|
||||
/** Read-only analyst outcomes supported by the upstream compatibility profiles. */
|
||||
export const IDA_OPERATIONS = Object.freeze([
|
||||
"list_procedures",
|
||||
"search_procedures",
|
||||
@@ -37,6 +37,9 @@ export const IDA_LIMITATIONS = Object.freeze([
|
||||
"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.",
|
||||
]);
|
||||
|
||||
export const IDA_NATIVE_STRING_LIMITATION =
|
||||
"Native string observations use upstream C-string rendering; other string representations remain unknown.";
|
||||
|
||||
/**
|
||||
* Attached mode requires the legacy 1.4 profile, whose `disassemble_function`
|
||||
* jumps to the function and whose `decompile_function` opens or reuses a
|
||||
@@ -53,15 +56,17 @@ const ATTACHED_GUI_NAVIGATION_OPERATIONS: ReadonlySet<IdaOperation> = new Set([
|
||||
export const idaCapabilities = (
|
||||
mode: "attached" | "headless",
|
||||
stdio: boolean,
|
||||
native = false,
|
||||
): readonly CapabilityDescriptor[] =>
|
||||
IDA_OPERATIONS.map((operation) => ({
|
||||
provider: IDA_PROVIDER_IDENTITY,
|
||||
operation,
|
||||
...(mode === "headless" && operation === "procedure_callers"
|
||||
...((mode === "headless" || native) && operation === "procedure_callers"
|
||||
? ({
|
||||
available: false,
|
||||
reason:
|
||||
"The modern upstream profile reports code references without proving direct call edges.",
|
||||
reason: native
|
||||
? "The native upstream profile reports code references without proving direct call edges."
|
||||
: "The modern upstream profile reports code references without proving direct call edges.",
|
||||
} as const)
|
||||
: ({ available: true, reason: null } as const)),
|
||||
cachePolicy: "live",
|
||||
@@ -70,11 +75,17 @@ export const idaCapabilities = (
|
||||
launchesProcess: stdio || mode === "headless",
|
||||
mayShowUi:
|
||||
mode === "attached" &&
|
||||
ATTACHED_GUI_NAVIGATION_OPERATIONS.has(operation),
|
||||
(native
|
||||
? operation === "procedure_pseudo_code" ||
|
||||
operation === "analyze_function"
|
||||
: ATTACHED_GUI_NAVIGATION_OPERATIONS.has(operation)),
|
||||
mayAccessNetwork: true,
|
||||
mayWriteFilesystem: stdio || mode === "headless",
|
||||
changesPermissions: false,
|
||||
requiresRoot: false,
|
||||
},
|
||||
limitations: IDA_LIMITATIONS,
|
||||
limitations:
|
||||
native && (operation === "list_strings" || operation === "search_strings")
|
||||
? [...IDA_LIMITATIONS, IDA_NATIVE_STRING_LIMITATION]
|
||||
: IDA_LIMITATIONS,
|
||||
}));
|
||||
@@ -23,7 +23,9 @@ 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 { idaProtocol } from "./IdaConfiguration.js";
|
||||
import type { IdaMcpConnection } from "./IdaMcpConnection.js";
|
||||
import { nativeTools } from "./IdaNativeProtocol.js";
|
||||
import { readSettledHealth } from "./IdaBusyHealth.js";
|
||||
import { isX86OnlyTarget } from "./IdaCallGraph.js";
|
||||
import { IdaOperationRunner } from "./IdaOperationRunner.js";
|
||||
@@ -75,6 +77,7 @@ const modernTools = [
|
||||
"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 =>
|
||||
@@ -191,12 +194,15 @@ export class IdaSessionClient implements AnalysisClient {
|
||||
"This integration admits read-only analysis operations only.",
|
||||
),
|
||||
);
|
||||
if (operation === "procedure_callers" && this.config.mode === "headless")
|
||||
if (
|
||||
operation === "procedure_callers" &&
|
||||
idaProtocol(this.config) !== "legacy"
|
||||
)
|
||||
return err(
|
||||
new AnalysisCapabilityUnavailableError(
|
||||
"ida",
|
||||
operation,
|
||||
"The modern upstream profile does not prove direct callers.",
|
||||
"This upstream profile does not prove direct callers.",
|
||||
),
|
||||
);
|
||||
if (operation !== "health") {
|
||||
@@ -280,7 +286,7 @@ export class IdaSessionClient implements AnalysisClient {
|
||||
#runner(options: ExecutionOptions | undefined): IdaOperationRunner {
|
||||
return new IdaOperationRunner(
|
||||
this.connection,
|
||||
this.config.mode === "attached" ? "legacy" : "modern",
|
||||
idaProtocol(this.config),
|
||||
this.#database,
|
||||
{
|
||||
signal: options?.signal,
|
||||
@@ -418,11 +424,15 @@ export class IdaSessionClient implements AnalysisClient {
|
||||
const tools = new Set(await this.connection.connect());
|
||||
this.#callGraphAdvertised = tools.has("callgraph");
|
||||
const expected =
|
||||
this.config.mode === "attached" ? legacyTools : modernTools;
|
||||
this.config.protocol === "native"
|
||||
? nativeTools
|
||||
: 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.`,
|
||||
`IDA MCP ${this.config.protocol === "native" ? "native attached" : this.config.mode} compatibility profile is missing tools: ${missing.join(", ")}. Native attached mode requires the native IDA MCP tool catalog; legacy 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.allocate(
|
||||
@@ -472,13 +482,24 @@ export class IdaSessionClient implements AnalysisClient {
|
||||
}
|
||||
}
|
||||
|
||||
async #nativeMetadata(): Promise<JsonValue> {
|
||||
if (this.connection.readResource === undefined)
|
||||
throw new AnalysisProtocolError(
|
||||
"Native IDA MCP requires resources/read for recorded input metadata.",
|
||||
);
|
||||
return this.connection.readResource("ida://idb/metadata");
|
||||
}
|
||||
|
||||
async #observe(
|
||||
options?: ExecutionOptions,
|
||||
onObserved?: (metadata: JsonValue) => void,
|
||||
): Promise<JsonValue> {
|
||||
let identity: string;
|
||||
if (this.config.mode === "attached") {
|
||||
const raw = await this.connection.call("get_metadata", {});
|
||||
const raw =
|
||||
this.config.protocol === "native"
|
||||
? await this.#nativeMetadata()
|
||||
: await this.connection.call("get_metadata", {});
|
||||
const metadata = legacyMetadataSchema.parse(raw);
|
||||
this.#metadata = raw;
|
||||
onObserved?.(raw);
|
||||
|
||||
@@ -19,7 +19,7 @@ const requestSchema = z.object({
|
||||
.passthrough()
|
||||
.optional(),
|
||||
});
|
||||
const fixture = async (redirectUrl?: string, cycle = false) => {
|
||||
const fixture = async (redirectUrl?: string, cycle = false, native = false) => {
|
||||
const methods: string[] = [];
|
||||
const headers: (string | undefined)[] = [];
|
||||
const server = createServer(async (request, response) => {
|
||||
@@ -47,7 +47,7 @@ const fixture = async (redirectUrl?: string, cycle = false) => {
|
||||
input.method === "initialize"
|
||||
? {
|
||||
protocolVersion: input.params?.protocolVersion,
|
||||
capabilities: { tools: {} },
|
||||
capabilities: { tools: {}, ...(native ? { resources: {} } : {}) },
|
||||
serverInfo: {
|
||||
name: "ida-transport-fixture",
|
||||
version: "observed-server-version",
|
||||
@@ -72,7 +72,32 @@ const fixture = async (redirectUrl?: string, cycle = false) => {
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
: { content: [], structuredContent: { observed: true } };
|
||||
: input.method === "resources/read"
|
||||
? {
|
||||
contents: [
|
||||
{
|
||||
uri: "ida://idb/metadata",
|
||||
mimeType: "application/json",
|
||||
text: JSON.stringify({
|
||||
path: "/fixture/input.i64",
|
||||
sha256: "a".repeat(64),
|
||||
base: "0x1000",
|
||||
module: "input",
|
||||
}),
|
||||
},
|
||||
],
|
||||
}
|
||||
: native
|
||||
? {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: JSON.stringify([{ observed: true }]),
|
||||
},
|
||||
],
|
||||
structuredContent: { result: [], _output_truncated: true },
|
||||
}
|
||||
: { content: [], structuredContent: { observed: true } };
|
||||
response.writeHead(200, { "Content-Type": "application/json" });
|
||||
response.end(JSON.stringify({ jsonrpc: "2.0", id: input.id, result }));
|
||||
});
|
||||
@@ -93,7 +118,8 @@ const fixture = async (redirectUrl?: string, cycle = false) => {
|
||||
{
|
||||
url: `http://127.0.0.1:${address.port}/mcp`,
|
||||
headers: { Authorization: "Bearer transport-fixture-secret" },
|
||||
mode: "headless",
|
||||
mode: native ? "attached" : "headless",
|
||||
...(native ? { protocol: "native" as const } : {}),
|
||||
timeoutMs: 2000,
|
||||
},
|
||||
{},
|
||||
@@ -138,3 +164,15 @@ it("rejects nonterminating SDK pagination instead of returning a partial tool ca
|
||||
methods.filter((method) => method === "tools/list").length,
|
||||
).toBeGreaterThan(2);
|
||||
});
|
||||
|
||||
it("reads native metadata resources and complete JSON text through the real SDK transport", async () => {
|
||||
const { connection, methods } = await fixture(undefined, false, true);
|
||||
expect(await connection.connect()).toEqual(["first", "second"]);
|
||||
expect(await connection.call("first", {})).toEqual([{ observed: true }]);
|
||||
expect(await connection.readResource?.("ida://idb/metadata")).toMatchObject({
|
||||
path: "/fixture/input.i64",
|
||||
sha256: "a".repeat(64),
|
||||
});
|
||||
await expect(connection.readResource?.("ida://other")).rejects.toThrow();
|
||||
expect(methods).toContain("resources/read");
|
||||
});
|
||||
@@ -28,6 +28,29 @@ const armSession = () =>
|
||||
leafRoots: ["0x2000", "0x3000"],
|
||||
});
|
||||
|
||||
it("keeps unknown caller coverage and callee diagnostics in an ARM64 dossier", async () => {
|
||||
const { client } = await armSession();
|
||||
try {
|
||||
const result = await client.execute("analyze_function", {
|
||||
procedure: "main",
|
||||
});
|
||||
if (!result.ok) throw result.error;
|
||||
expect(result.value.relationshipCoverage).toMatchObject({
|
||||
status: "unknown",
|
||||
reason: "Upstream xrefs do not establish direct caller relationships.",
|
||||
diagnostics: expect.arrayContaining([
|
||||
expect.stringContaining("targets IDA does not recognize as functions"),
|
||||
]),
|
||||
});
|
||||
expect(result.value.result).toMatchObject({
|
||||
callers: [],
|
||||
callees: [{ address: "0x2000" }, { address: "0x3000" }],
|
||||
});
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("shows the callee edges of an ARM64 procedure in the call graph", async () => {
|
||||
const { client, producer } = await armSession();
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
const execute = promisify(execFile);
|
||||
|
||||
it.each([
|
||||
{ protocol: "native", flags: [], required: true },
|
||||
{
|
||||
protocol: "native",
|
||||
flags: ["--second-procedure", "other", "--second-marker", "unique"],
|
||||
required: false,
|
||||
},
|
||||
{ protocol: "legacy", flags: [], required: false },
|
||||
{ protocol: "modern", flags: [], required: false },
|
||||
])(
|
||||
"enforces native switching requirements for $protocol with $flags",
|
||||
async ({ protocol, flags, required }) => {
|
||||
const root = await mkdtemp(join(tmpdir(), "rea-ida-verifier-options-"));
|
||||
try {
|
||||
const config = join(root, "ida.json");
|
||||
await writeFile(
|
||||
config,
|
||||
JSON.stringify({
|
||||
url: "http://127.0.0.1:13337/mcp",
|
||||
mode: protocol === "modern" ? "headless" : "attached",
|
||||
...(protocol === "native" ? { protocol } : {}),
|
||||
}),
|
||||
);
|
||||
// The absent target stops accepted option sets at admission, before any provider call.
|
||||
await expect(
|
||||
execute(
|
||||
process.execPath,
|
||||
[
|
||||
"scripts/verify-real-ida.mjs",
|
||||
"--target",
|
||||
join(root, "absent"),
|
||||
...flags,
|
||||
],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
env: { ...process.env, REA_IDA_MCP_CONFIG: config },
|
||||
timeout: 10_000,
|
||||
},
|
||||
),
|
||||
).rejects.toMatchObject(
|
||||
required
|
||||
? {
|
||||
stdout: "",
|
||||
stderr: expect.stringContaining(
|
||||
"Native IDA verification requires --second-procedure and --second-marker",
|
||||
),
|
||||
}
|
||||
: {
|
||||
stderr: "",
|
||||
stdout: expect.stringContaining(
|
||||
'"failed_phase": "target admission"',
|
||||
),
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -10,6 +10,10 @@ REA_EXPORT REA_NOINLINE int rea_fixture_add(int left, int right) {
|
||||
return left + right + 7;
|
||||
}
|
||||
|
||||
REA_EXPORT REA_NOINLINE int rea_fixture_subtract(int left, int right) {
|
||||
return left - right - 13;
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
return rea_fixture_add(11, 24);
|
||||
return rea_fixture_add(11, 24) + rea_fixture_subtract(24, 11);
|
||||
}
|
||||
Vendored
+12
-9
@@ -113,10 +113,13 @@ export class RecordingIdaMcp implements IdaMcpConnection {
|
||||
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);
|
||||
return this.read(name, args);
|
||||
}
|
||||
|
||||
private read(name: string): JsonValue {
|
||||
private read(
|
||||
name: string,
|
||||
args: Readonly<Record<string, JsonValue>>,
|
||||
): JsonValue {
|
||||
const fn = { address: "0x1000", name: "main", size: "0x20" };
|
||||
const asm = {
|
||||
name: "main",
|
||||
@@ -153,13 +156,13 @@ export class RecordingIdaMcp implements IdaMcpConnection {
|
||||
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,
|
||||
},
|
||||
];
|
||||
if (!Array.isArray(args.queries))
|
||||
throw new Error("Invalid fixture lookup queries");
|
||||
return args.queries.map((query) => ({
|
||||
query,
|
||||
fn: { addr: fn.address, name: fn.name, size: fn.size },
|
||||
error: null,
|
||||
}));
|
||||
case "decompile_function":
|
||||
return this.pseudocode;
|
||||
case "decompile":
|
||||
|
||||
Reference in new issue
Block a user