diff --git a/docs/ida-provider.md b/docs/ida-provider.md index 95c7cdaf4..b7bcb68e2 100644 --- a/docs/ida-provider.md +++ b/docs/ida-provider.md @@ -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. diff --git a/src/ida/IdaProviderCapabilities.ts b/src/ida/IdaProviderCapabilities.ts index 9d8e67499..c83081dcc 100644 --- a/src/ida/IdaProviderCapabilities.ts +++ b/src/ida/IdaProviderCapabilities.ts @@ -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 = 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, diff --git a/tests/conformance/ida/providerContracts.test.ts b/tests/conformance/ida/providerContracts.test.ts index bf79cd0fd..39c524637 100644 --- a/tests/conformance/ida/providerContracts.test.ts +++ b/tests/conformance/ida/providerContracts.test.ts @@ -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 = new Set([ + "disassemble_function", + "decompile_function", +]); + const parametersFor = ( operation: string, ): Readonly> => { @@ -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) => {