[GH-ISSUE #5826] [Bug] IDA identity refusals lose their reason and recovery guidance in CLI errors #3523

Closed
opened 2026-10-31 00:41:29 +00:00 by bsakaguchi · 1 comment
Owner

Originally created by @coygeek on GitHub (Oct 21, 2026).
Original GitHub issue: https://github.com/morluto/rea/issues/5826

Before submitting

  • I searched the open and closed issues for a related report.
  • I removed credentials, tokens, private paths, and sensitive target data.

Area

Native analysis or decompilation

Observed behavior

Using a simulated IDA MCP provider, I ran node scripts/rea.mjs decompile <generated-target.elf> main --provider ida --json for three identity-refusal cases: an attached database with a different recorded SHA-256, headless health naming a different input, and headless opening returning an unexpected session ID. Each invocation exited with code 1 and returned the same unreadable_output error, retryable: false, and no details field. Both its message and remediation told me to run rea doctor and review details that were absent. The response did not explain which identity check failed or include the adapter's recovery instruction.

A matching attached-target control returned the expected pseudocode. The failing cases dispatched no decompilation call. This was an executed production CLI reproduction with fake upstream replies, not a real-engine observation.

Reproduction

From the source revision below, with its locked dependencies and built runtime already available, save the following temporary test as src/ida/repro.test.ts. It uses the existing RecordingIdaMcp fixture through a loopback JSON-RPC server; the child is the real CLI. No external service or IDA process is needed.

import { createServer } from "node:http";
import { spawn } from "node:child_process";
import { mkdir, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect, it } from "vitest";
import { createIdaTarget, RecordingIdaMcp } from "../../tests/fixtures/idaMcp.js";

it.each(["attached-mismatch", "headless-mismatch", "unexpected-worker", "control"] as const)(
  "public CLI identity diagnostic: %s",
  async (scenario) => {
    const { root, target } = await createIdaTarget();
    const mode = scenario === "attached-mismatch" || scenario === "control" ? "attached" : "headless";
    const producer = new RecordingIdaMcp(target, mode);
    if (scenario === "attached-mismatch") producer.inputSha256 = "0".repeat(64);
    producer.overrideCall = (name, args) => {
      if (scenario === "headless-mismatch" && name === "server_health")
        return { status: "ok", input_path: join(root, "other.elf"), idb_path: join(root, "other.i64"), module: "other", imagebase: "0x1000", auto_analysis_ready: true };
      if (scenario === "unexpected-worker" && name === "idb_open") {
        producer.database = String(args.preferred_session_id);
        producer.inputPath = String(args.input_path);
        return { success: true, session: { session_id: "unexpected-worker", input_path: producer.inputPath } };
      }
      return undefined;
    };
    const server = createServer(async (request, response) => {
      if (request.method !== "POST") { response.writeHead(405).end(); return; }
      let body = "";
      for await (const chunk of request) body += chunk;
      const rpc = JSON.parse(body);
      if (rpc.id === undefined) { response.writeHead(202).end(); return; }
      let result;
      if (rpc.method === "initialize") result = { protocolVersion: rpc.params.protocolVersion, capabilities: { tools: {} }, serverInfo: producer.serverInfo() };
      else if (rpc.method === "tools/list") result = { tools: (await producer.connect()).map(name => ({ name, inputSchema: { type: "object", properties: {} } })) };
      else {
        const value = await producer.call(rpc.params.name, rpc.params.arguments);
        result = { content: [{ type: "text", text: JSON.stringify(value) }] };
      }
      response.writeHead(200, { "Content-Type": "application/json" }).end(JSON.stringify({ jsonrpc: "2.0", id: rpc.id, result }));
    });
    try {
      await new Promise<void>(resolve => server.listen(0, "127.0.0.1", resolve));
      const address = server.address();
      if (!address || typeof address === "string") throw Error("No fixture port");
      const home = join(root, "home");
      await mkdir(home);
      const config = join(root, "ida.json");
      await writeFile(config, JSON.stringify({ url: `http://127.0.0.1:${address.port}/mcp`, mode, timeoutMs: 1000, workspaceRoot: root }));
      const child = spawn(process.execPath, ["scripts/rea.mjs", "decompile", target.path, "main", "--provider", "ida", "--json"], {
        env: { PATH: process.env.PATH, HOME: home, XDG_CONFIG_HOME: home, XDG_STATE_HOME: home, XDG_CACHE_HOME: home, TMPDIR: root, REA_IDA_MCP_CONFIG: config, REA_LOG_LEVEL: "silent" },
      });
      let stdout = "", stderr = "";
      child.stdout.on("data", x => stdout += x);
      child.stderr.on("data", x => stderr += x);
      const exitCode = await new Promise<number | null>((resolve, reject) => { child.on("error", reject); child.on("close", resolve); });
      expect(stderr).toBe("");
      const output = JSON.parse(stdout);
      const calls = producer.calls.map(x => x.name);
      console.log(JSON.stringify({ scenario, exitCode, output: scenario === "control" ? { operation: output.operation, normalized_result: output.normalized_result } : output, calls }));
      if (scenario === "control") {
        expect(exitCode).toBe(0);
        expect(output.normalized_result).toBe(producer.pseudocode);
        expect(calls).toContain("decompile_function");
      } else {
        expect(exitCode).toBe(1);
        expect(output.code).toBe("unreadable_output");
        expect(output.retryable).toBe(false);
        expect(output.details).toBeUndefined();
        expect(output.message).toContain("review the reported details");
        expect(calls).not.toContain("decompile_function");
        expect(calls).not.toContain("decompile");
      }
      expect(producer.database).toBeUndefined();
      if (mode === "headless") expect(calls).toContain("idb_close");
    } finally {
      server.closeAllConnections();
      await new Promise<void>(resolve => server.close(() => resolve()));
      await rm(root, { recursive: true, force: true });
    }
  }, 20000,
);

Run the focused test with the pinned Node/npm toolchain. Isolate its temporary and home directories as well as the CLI child's directories:

work=$(mktemp -d)
mkdir -p "$work/home" "$work/temp"
HOME="$work/home" XDG_CONFIG_HOME="$work/home" TMPDIR="$work/temp" \
  npm run test:local -- src/ida/repro.test.ts

I ran the four cases once each; all four characterization tests passed. The three failure assertions describe the defect's current behavior. Delete the temporary test and its isolated work directory after the run.

Expected behavior

The refused request should retain the identity or worker-binding reason and applicable recovery guidance in the caller-facing error. For the attached SHA-256 case, the operator should learn to open the selected original input in IDA or use headless mode. The IDA provider guide explicitly promises that a mismatch or target switch fails with recovery guidance. The repository's error-handling invariant also requires preserving meaningful failure reasons.

Continue refusing mismatched identity. This report does not request accepting the unexpected worker or changing retryability policy. The error schema permits optional details; the observed output is structurally valid but omits the promised explanation.

Impact and scope

P2: callers lose actionable recovery information on bounded identity-refusal paths. The three distinct simulated conditions produced indistinguishable generic errors, so an operator cannot determine the failed binding check from the returned error. All three fail closed; no wrong-target pseudocode was returned. Both headless cases issued idb_close, and the fake producer had no remaining database after command completion.

Observed scope is the CLI, legacy attached mode and headless mode, with successful cleanup. Native attached mode and other hosts were not exercised. Source inspection shows shared attached identity validation, but does not establish real-engine occurrence frequency. Post-operation identity failures can retain their reason in partial Evidence; this report concerns refusals before operation replies exist. Cleanup failures may also add diagnostics and are outside this reproduction. A practical caller-side diagnostic workaround was not verified.

REA version

rea-agents 6.7.0; source revision a5f982de8d; existing built runtime.

Runtime and platform

Node.js v24.18.0; Darwin arm64; simulated loopback IDA MCP provider; no real IDA engine started.

Target and artifact context

Synthetic ELF x86_64 from the repository fixture; attached metadata hash, headless health input path, or opened worker session ID deliberately differs from the admitted identity.

Interface

CLI

Earliest failure stage

Transport or result projection

Relevant output or evidence

The focused run ended with:

Test Files  1 passed (1)
     Tests  4 passed (4)

Actual attached-mismatch log row:

{"scenario":"attached-mismatch","exitCode":1,"output":{"error":"Analysis failed","code":"unreadable_output","category":"execution_failure","message":"Analysis returned an unreadable result. Run `rea doctor` and review the reported details; repeating the unchanged request is not expected to succeed.","retryable":false,"remediation":{"action":"Analysis returned an unreadable result. Run `rea doctor` and review the reported details; repeating the unchanged request is not expected to succeed."}},"calls":["get_metadata"]}

The headless-mismatch and unexpected-worker rows had the identical error object. Their recorded calls were, respectively, idb_open, idb_list, server_health, idb_close, idb_list and idb_open, idb_list, idb_close, idb_list. The control log row was:

{"scenario":"control","exitCode":0,"output":{"operation":"procedure_pseudo_code","normalized_result":"int main(void) { return 42; }"},"calls":["get_metadata","get_function_by_name","get_function_by_name","decompile_function","get_metadata"]}

Checked source locations explain the three injected failures:

Typed outcome or recovery expected

Keep the typed refusal, with a caller-safe identity or binding reason and branch-specific recovery guidance. The attached-hash error should identify the mismatch and its open-the-selected-input/use-headless remedy. Worker and headless-input refusals should retain their own reasons. Do not claim analysis succeeded or release unverified resources to improve the error text.

Analysis or suspected cause

Source-derived: these guards pass their explanation only as AnalysisProtocolError.message; the error subclass adds no specific presentation fields. The session failure handler preserves typed errors unchanged. Before any operation replies exist, there is no partial-observation fallback to carry that message. Retaining a caller-safe reason at the adapter boundary appears sufficient to avoid this information loss; that is a proposed correction, not an implemented or verified fix.

Additional context

Open and closed issues and PRs were searched for IDA identity diagnostics, worker binding, and unreadable output. No matching identity-reason report was found. Related reports concern different behavior: issue 1766 covers transient busy health, and issue 4614 covers startup-operation attribution. Both are closed. Readiness classification is excluded from this report.

Originally created by @coygeek on GitHub (Oct 21, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/5826 ### Before submitting - [x] I searched the open and closed issues for a related report. - [x] I removed credentials, tokens, private paths, and sensitive target data. ### Area Native analysis or decompilation ### Observed behavior Using a simulated IDA MCP provider, I ran `node scripts/rea.mjs decompile <generated-target.elf> main --provider ida --json` for three identity-refusal cases: an attached database with a different recorded SHA-256, headless health naming a different input, and headless opening returning an unexpected session ID. Each invocation exited with code 1 and returned the same `unreadable_output` error, `retryable: false`, and no `details` field. Both its message and remediation told me to run `rea doctor` and review details that were absent. The response did not explain which identity check failed or include the adapter's recovery instruction. A matching attached-target control returned the expected pseudocode. The failing cases dispatched no decompilation call. This was an executed production CLI reproduction with fake upstream replies, not a real-engine observation. ### Reproduction From the source revision below, with its locked dependencies and built runtime already available, save the following temporary test as `src/ida/repro.test.ts`. It uses the existing `RecordingIdaMcp` fixture through a loopback JSON-RPC server; the child is the real CLI. No external service or IDA process is needed. ```ts import { createServer } from "node:http"; import { spawn } from "node:child_process"; import { mkdir, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { expect, it } from "vitest"; import { createIdaTarget, RecordingIdaMcp } from "../../tests/fixtures/idaMcp.js"; it.each(["attached-mismatch", "headless-mismatch", "unexpected-worker", "control"] as const)( "public CLI identity diagnostic: %s", async (scenario) => { const { root, target } = await createIdaTarget(); const mode = scenario === "attached-mismatch" || scenario === "control" ? "attached" : "headless"; const producer = new RecordingIdaMcp(target, mode); if (scenario === "attached-mismatch") producer.inputSha256 = "0".repeat(64); producer.overrideCall = (name, args) => { if (scenario === "headless-mismatch" && name === "server_health") return { status: "ok", input_path: join(root, "other.elf"), idb_path: join(root, "other.i64"), module: "other", imagebase: "0x1000", auto_analysis_ready: true }; if (scenario === "unexpected-worker" && name === "idb_open") { producer.database = String(args.preferred_session_id); producer.inputPath = String(args.input_path); return { success: true, session: { session_id: "unexpected-worker", input_path: producer.inputPath } }; } return undefined; }; const server = createServer(async (request, response) => { if (request.method !== "POST") { response.writeHead(405).end(); return; } let body = ""; for await (const chunk of request) body += chunk; const rpc = JSON.parse(body); if (rpc.id === undefined) { response.writeHead(202).end(); return; } let result; if (rpc.method === "initialize") result = { protocolVersion: rpc.params.protocolVersion, capabilities: { tools: {} }, serverInfo: producer.serverInfo() }; else if (rpc.method === "tools/list") result = { tools: (await producer.connect()).map(name => ({ name, inputSchema: { type: "object", properties: {} } })) }; else { const value = await producer.call(rpc.params.name, rpc.params.arguments); result = { content: [{ type: "text", text: JSON.stringify(value) }] }; } response.writeHead(200, { "Content-Type": "application/json" }).end(JSON.stringify({ jsonrpc: "2.0", id: rpc.id, result })); }); try { await new Promise<void>(resolve => server.listen(0, "127.0.0.1", resolve)); const address = server.address(); if (!address || typeof address === "string") throw Error("No fixture port"); const home = join(root, "home"); await mkdir(home); const config = join(root, "ida.json"); await writeFile(config, JSON.stringify({ url: `http://127.0.0.1:${address.port}/mcp`, mode, timeoutMs: 1000, workspaceRoot: root })); const child = spawn(process.execPath, ["scripts/rea.mjs", "decompile", target.path, "main", "--provider", "ida", "--json"], { env: { PATH: process.env.PATH, HOME: home, XDG_CONFIG_HOME: home, XDG_STATE_HOME: home, XDG_CACHE_HOME: home, TMPDIR: root, REA_IDA_MCP_CONFIG: config, REA_LOG_LEVEL: "silent" }, }); let stdout = "", stderr = ""; child.stdout.on("data", x => stdout += x); child.stderr.on("data", x => stderr += x); const exitCode = await new Promise<number | null>((resolve, reject) => { child.on("error", reject); child.on("close", resolve); }); expect(stderr).toBe(""); const output = JSON.parse(stdout); const calls = producer.calls.map(x => x.name); console.log(JSON.stringify({ scenario, exitCode, output: scenario === "control" ? { operation: output.operation, normalized_result: output.normalized_result } : output, calls })); if (scenario === "control") { expect(exitCode).toBe(0); expect(output.normalized_result).toBe(producer.pseudocode); expect(calls).toContain("decompile_function"); } else { expect(exitCode).toBe(1); expect(output.code).toBe("unreadable_output"); expect(output.retryable).toBe(false); expect(output.details).toBeUndefined(); expect(output.message).toContain("review the reported details"); expect(calls).not.toContain("decompile_function"); expect(calls).not.toContain("decompile"); } expect(producer.database).toBeUndefined(); if (mode === "headless") expect(calls).toContain("idb_close"); } finally { server.closeAllConnections(); await new Promise<void>(resolve => server.close(() => resolve())); await rm(root, { recursive: true, force: true }); } }, 20000, ); ``` Run the focused test with the pinned Node/npm toolchain. Isolate its temporary and home directories as well as the CLI child's directories: ```sh work=$(mktemp -d) mkdir -p "$work/home" "$work/temp" HOME="$work/home" XDG_CONFIG_HOME="$work/home" TMPDIR="$work/temp" \ npm run test:local -- src/ida/repro.test.ts ``` I ran the four cases once each; all four characterization tests passed. The three failure assertions describe the defect's current behavior. Delete the temporary test and its isolated work directory after the run. ### Expected behavior The refused request should retain the identity or worker-binding reason and applicable recovery guidance in the caller-facing error. For the attached SHA-256 case, the operator should learn to open the selected original input in IDA or use headless mode. The [IDA provider guide](https://github.com/morluto/rea/blob/a5f982de8dbe4b092cc20ab89d492b7e7028b758/docs/ida-provider.md#L53-L55) explicitly promises that a mismatch or target switch fails with recovery guidance. The repository's [error-handling invariant](https://github.com/morluto/rea/blob/a5f982de8dbe4b092cc20ab89d492b7e7028b758/AGENTS.md#L31-L31) also requires preserving meaningful failure reasons. Continue refusing mismatched identity. This report does not request accepting the unexpected worker or changing retryability policy. The error schema permits optional `details`; the observed output is structurally valid but omits the promised explanation. ### Impact and scope P2: callers lose actionable recovery information on bounded identity-refusal paths. The three distinct simulated conditions produced indistinguishable generic errors, so an operator cannot determine the failed binding check from the returned error. All three fail closed; no wrong-target pseudocode was returned. Both headless cases issued `idb_close`, and the fake producer had no remaining database after command completion. Observed scope is the CLI, legacy attached mode and headless mode, with successful cleanup. Native attached mode and other hosts were not exercised. Source inspection shows shared attached identity validation, but does not establish real-engine occurrence frequency. Post-operation identity failures can retain their reason in partial Evidence; this report concerns refusals before operation replies exist. Cleanup failures may also add diagnostics and are outside this reproduction. A practical caller-side diagnostic workaround was not verified. ### REA version rea-agents 6.7.0; source revision a5f982de8dbe4b092cc20ab89d492b7e7028b758; existing built runtime. ### Runtime and platform Node.js v24.18.0; Darwin arm64; simulated loopback IDA MCP provider; no real IDA engine started. ### Target and artifact context Synthetic ELF x86_64 from the repository fixture; attached metadata hash, headless health input path, or opened worker session ID deliberately differs from the admitted identity. ### Interface CLI ### Earliest failure stage Transport or result projection ### Relevant output or evidence The focused run ended with: ```text Test Files 1 passed (1) Tests 4 passed (4) ``` Actual attached-mismatch log row: ```json {"scenario":"attached-mismatch","exitCode":1,"output":{"error":"Analysis failed","code":"unreadable_output","category":"execution_failure","message":"Analysis returned an unreadable result. Run `rea doctor` and review the reported details; repeating the unchanged request is not expected to succeed.","retryable":false,"remediation":{"action":"Analysis returned an unreadable result. Run `rea doctor` and review the reported details; repeating the unchanged request is not expected to succeed."}},"calls":["get_metadata"]} ``` The headless-mismatch and unexpected-worker rows had the identical error object. Their recorded calls were, respectively, `idb_open, idb_list, server_health, idb_close, idb_list` and `idb_open, idb_list, idb_close, idb_list`. The control log row was: ```json {"scenario":"control","exitCode":0,"output":{"operation":"procedure_pseudo_code","normalized_result":"int main(void) { return 42; }"},"calls":["get_metadata","get_function_by_name","get_function_by_name","decompile_function","get_metadata"]} ``` Checked source locations explain the three injected failures: - [Attached hash validation](https://github.com/morluto/rea/blob/a5f982de8dbe4b092cc20ab89d492b7e7028b758/src/ida/IdaSessionClient.ts#L679-L682) creates a protocol error containing the recovery instruction without diagnostics. - [Headless input validation](https://github.com/morluto/rea/blob/a5f982de8dbe4b092cc20ab89d492b7e7028b758/src/ida/IdaSessionClient.ts#L701-L708) creates the specific input-identity error without diagnostics. - [Worker-session validation](https://github.com/morluto/rea/blob/a5f982de8dbe4b092cc20ab89d492b7e7028b758/src/ida/IdaSessionClient.ts#L625-L628) creates the unexpected-worker error without diagnostics. - [Caller-facing error projection](https://github.com/morluto/rea/blob/a5f982de8dbe4b092cc20ab89d492b7e7028b758/src/domain/analysisErrorProjection.ts#L52-L123) carries diagnostics and partial observations only when supplied, and omits empty details. - [Unreadable-result presentation](https://github.com/morluto/rea/blob/a5f982de8dbe4b092cc20ab89d492b7e7028b758/src/domain/analysisErrorPresentation.ts#L374-L376) supplies the generic message observed in the CLI output. ### Typed outcome or recovery expected Keep the typed refusal, with a caller-safe identity or binding reason and branch-specific recovery guidance. The attached-hash error should identify the mismatch and its open-the-selected-input/use-headless remedy. Worker and headless-input refusals should retain their own reasons. Do not claim analysis succeeded or release unverified resources to improve the error text. ### Analysis or suspected cause Source-derived: these guards pass their explanation only as `AnalysisProtocolError.message`; [the error subclass](https://github.com/morluto/rea/blob/a5f982de8dbe4b092cc20ab89d492b7e7028b758/src/domain/analysisErrorCore.ts#L59-L62) adds no specific presentation fields. The [session failure handler](https://github.com/morluto/rea/blob/a5f982de8dbe4b092cc20ab89d492b7e7028b758/src/ida/IdaSessionClient.ts#L428-L433) preserves typed errors unchanged. Before any operation replies exist, there is no partial-observation fallback to carry that message. Retaining a caller-safe reason at the adapter boundary appears sufficient to avoid this information loss; that is a proposed correction, not an implemented or verified fix. ### Additional context Open and closed issues and PRs were searched for IDA identity diagnostics, worker binding, and unreadable output. No matching identity-reason report was found. Related reports concern different behavior: [issue 1766](https://github.com/morluto/rea/issues/1766) covers transient busy health, and [issue 4614](https://github.com/morluto/rea/issues/4614) covers startup-operation attribution. Both are closed. Readiness classification is excluded from this report.
Author
Owner

@XinwangTao commented on GitHub (Oct 21, 2026):

Confirmed — attached hash mismatch, headless input mismatch, and unexpected worker session ID all collapse into a bare unreadable_output with no details field, so the CLI tells the user to "review details" that are never included. Preserving which identity check failed (with the adapter's own recovery hint, e.g. re-run doctor / verify input hash) in details/remediation would make the failure actionable instead of a dead end, and matches the documented error-handling invariant.

<!-- gh-comment-id:6312229180 --> @XinwangTao commented on GitHub (Oct 21, 2026): Confirmed — attached hash mismatch, headless input mismatch, and unexpected worker session ID all collapse into a bare unreadable_output with no details field, so the CLI tells the user to "review details" that are never included. Preserving which identity check failed (with the adapter's own recovery hint, e.g. re-run doctor / verify input hash) in details/remediation would make the failure actionable instead of a dead end, and matches the documented error-handling invariant.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
morluto/rea#3523
No description provided.