fix(ida): declare GUI navigation for attached legacy analysis (#1764)

Attached IDA mode always uses the legacy 1.4 producer, whose
disassemble_function jumps to the function and whose decompile_function
opens or reuses a pseudocode view whenever an IDA window is active.
Session discovery still advertised may_show_ui: false for every
operation.

Declare the UI effect for the attached operations that reach those
tools: pseudocode, assembly, raw function instructions and the function
dossier. Headless operations and the other attached operations keep
false. A conformance check now compares each declaration with the
upstream tools the operation actually calls.

Fixes #1712

Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Kaoru0822
2026-10-11 01:36:56 -07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 6d5d71fab5
commit cf692072b5
3 changed files with 60 additions and 3 deletions
+3 -1
View File
@@ -32,6 +32,8 @@ 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`.
## Give REA a target for headless analysis
The headless profile supports upstream's **database supervisor** API: `idb_open`, `idb_list`, `idb_close`, explicit `database` arguments, and modern analysis tools. Legacy 1.4.0 `idalib-mcp`, which accepts one initial filename and serves SSE, does not implement this profile. Follow the [upstream headless instructions](https://github.com/mrexodia/ida-pro-mcp#headless-idalib-session-model) for an installation with the supervisor API.
@@ -100,7 +102,7 @@ Both profiles support procedure/string inventory and literal or regex search, pr
The legacy profile additionally supports direct callers. Its producer may report call-site or interior addresses; REA resolves the reported function symbol to its canonical entry. The modern profile does not prove direct callers from its code/data xref labels, so that capability is unavailable and the dossier marks it unknown.
Complete body ranges, typed reference edges, CFG, referenced data, source locals, comment kinds, and database revision are unavailable. Empty unsupported dossier facets mean **unknown**, not observed absence. `binary_overview`, whole-symbol inventory, mutation, debugging, arbitrary Python forwarding, and GUI navigation are outside this adapter's coverage. Check session availability before composing a workflow that needs those facets.
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.
`provider.version` identifies the REA adapter; the profile explicitly records `version_scope: "rea-ida-adapter"`. The MCP handshake's `serverInfo` is separate and does not establish IDA or Python distribution versions. Raw observations preserve producer metadata and output. Attached hashes identify the recorded original input; headless identity uses the digest-verified private copy and observed worker input path. Neither proves source-code recovery or an unmodified analysis database.
+15 -1
View File
@@ -37,6 +37,18 @@ 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.",
]);
/**
* Attached mode requires the legacy 1.4 profile, whose `disassemble_function`
* jumps to the function and whose `decompile_function` opens or reuses a
* pseudocode view whenever an IDA window is active.
*/
const ATTACHED_GUI_NAVIGATION_OPERATIONS: ReadonlySet<IdaOperation> = new Set([
"procedure_pseudo_code",
"procedure_assembly",
"read_function_instructions",
"analyze_function",
]);
/** Advertise live observations and configured lifecycle effects truthfully. */
export const idaCapabilities = (
mode: "attached" | "headless",
@@ -56,7 +68,9 @@ export const idaCapabilities = (
effects: {
mutatesArtifact: false,
launchesProcess: stdio || mode === "headless",
mayShowUi: false,
mayShowUi:
mode === "attached" &&
ATTACHED_GUI_NAVIGATION_OPERATIONS.has(operation),
mayAccessNetwork: true,
mayWriteFilesystem: stdio || mode === "headless",
changesPermissions: false,
@@ -3,7 +3,10 @@ import { rm } from "node:fs/promises";
import { afterEach, describe, expect, it } from "vitest";
import { createIdaTarget, RecordingIdaMcp } from "../../fixtures/idaMcp.js";
import { IdaSessionClient } from "../../../src/ida/IdaSessionClient.js";
import { IDA_OPERATIONS } from "../../../src/ida/IdaProviderCapabilities.js";
import {
IDA_OPERATIONS,
idaCapabilities,
} from "../../../src/ida/IdaProviderCapabilities.js";
import { toolContract } from "../../../src/contracts/toolContracts.js";
import { createEvidence } from "../../../src/domain/evidence.js";
import type { JsonValue } from "../../../src/domain/jsonValue.js";
@@ -16,6 +19,12 @@ afterEach(async () => {
for (const root of roots.splice(0))
await rm(root, { recursive: true, force: true });
});
/** Pinned upstream tools that navigate an active IDA GUI as a side effect. */
const GUI_NAVIGATING_TOOLS: ReadonlySet<string> = new Set([
"disassemble_function",
"decompile_function",
]);
const parametersFor = (
operation: string,
): Readonly<Record<string, JsonValue>> => {
@@ -28,6 +37,38 @@ const parametersFor = (
describe.each(["attached", "headless"] as const)(
"IDA %s caller-visible contracts",
(mode) => {
it.each(IDA_OPERATIONS)(
"declares %s UI effects matching the upstream tools it reaches",
async (operation) => {
const { root, target } = await createIdaTarget();
roots.push(root);
const producer = new RecordingIdaMcp(target, mode);
const client = new IdaSessionClient(
{
command: "fixture",
args: [],
env: {},
mode,
workspaceRoot: root,
timeoutMs: 1000,
},
target,
producer,
);
try {
await client.execute(operation, parametersFor(operation));
} finally {
await client.close();
}
const descriptor = idaCapabilities(mode, true).find(
(capability) => capability.operation === operation,
);
expect(descriptor?.effects.mayShowUi).toBe(
producer.calls.some(({ name }) => GUI_NAVIGATING_TOOLS.has(name)),
);
},
);
it.each(IDA_OPERATIONS)(
"serializes %s against its named advertised output contract",
async (operation) => {