[GH-ISSUE #5972] [Bug] Ghidra catalog directory-read failures are reported as invalid language configuration #3640

Closed
opened 2026-10-31 00:42:38 +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/5972

Before submitting

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

Area

Provider or capability discovery

Observed behavior

When an existing Ghidra catalog directory cannot be enumerated, open_binary reports a configured language as undefined instead of preserving the directory-read failure. A focused MCP test observed real EACCES on both Ghidra/Processors and a module's data/languages directory. In each case the catalog returned [] and MCP returned configuration_invalid / invalid_input, telling the caller to correct REA_GHIDRA_LANGUAGE_ID. The same unchanged language definition loaded before permission removal and after permissions were restored.

Reproduction

Run as an ordinary user on a filesystem that enforces directory permissions. Save the following as tests/boundary/mcp/catalogReadPermission.test.ts in the source checkout with locked dependencies installed. It uses the repository's simulated installation-readiness fixture and actual catalog file I/O, production configuration/provider/session code, target parsing, and SDK MCP transport. No engine or Java process runs.

import { chmod, mkdir, mkdtemp, readdir, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
import { expect, it } from "vitest";
import { AnalysisProviderRegistry } from "../../../src/application/binary/AnalysisProviderRegistry.js";
import { composeBinarySession } from "../../../src/application/binary/BinarySessionComposition.js";
import { parseConfig } from "../../../src/config/parseConfig.js";
import { ghidraProfileInstallationHost } from "../../../src/ghidra/GhidraInstallation.fixture.js";
import { readGhidraLanguageCatalog } from "../../../src/ghidra/GhidraLanguageCatalog.js";
import { GhidraProvider } from "../../../src/ghidra/GhidraProvider.js";
import { silentLogger } from "../../../src/logger.js";
import { createServer } from "../../../src/server/createServer.js";
import { nativeAotPeFixture } from "../../fixtures/nativeaotPe.js";
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";

it.each(["Ghidra/Processors", "Ghidra/Processors/x86/data/languages"])(
  "misclassifies EACCES reading %s through open_binary",
  async (blockedRelative) => {
    const root = await mkdtemp(join(process.env.CATALOG_REPRO_ROOT!, "case-"));
    const languages = join(root, "Ghidra/Processors/x86/data/languages");
    const blocked = join(root, blockedRelative);
    const target = join(root, "fixture.exe");
    const definition = { id: "x86:LE:64:default", compilerSpecIds: ["windows"] };
    await mkdir(languages, { recursive: true });
    await writeFile(join(languages, "x86.ldefs"), '<language_definitions><language id="x86:LE:64:default"><compiler id="windows"/></language></language_definitions>');
    await writeFile(target, nativeAotPeFixture());
    const config = parseConfig({ GHIDRA_INSTALL_DIR: root, REA_GHIDRA_LANGUAGE_ID: definition.id });
    if (!config.ok) throw config.error;
    const provider = new GhidraProvider(config.value, silentLogger, {}, ghidraProfileInstallationHost);
    const session = composeBinarySession(new AnalysisProviderRegistry([provider]));
    const server = createServer({ kind: "session", session }, { logger: silentLogger });
    const client = new Client({ name: "catalog-readdir-reproduction", version: "1.0.0" });
    const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
    try {
      expect(await readGhidraLanguageCatalog(root)).toEqual([definition]);
      await chmod(blocked, 0);
      await expect(readdir(blocked)).rejects.toMatchObject({ code: "EACCES" });
      expect(await readGhidraLanguageCatalog(root)).toEqual([]);
      await server.connect(serverTransport);
      await client.connect(clientTransport);
      const response = await client.callTool({ name: "open_binary", arguments: { path: target, provider_id: "ghidra" } });
      const error = parseMcpToolError(response);
      console.log(JSON.stringify({ blocked: blockedRelative, error }));
      expect(response.isError).toBe(true);
      expect(error).toMatchObject({ error: { code: "configuration_invalid", category: "invalid_input", details: { settings: [{ setting: "REA_GHIDRA_LANGUAGE_ID", constraint: "is not a language defined by Ghidra 12.1.4" }] } } });
      expect(JSON.stringify(error)).not.toContain("EACCES");
      await chmod(blocked, 0o755);
      expect(await readGhidraLanguageCatalog(root)).toEqual([definition]);
      const recovered = await provider.resolveAnalysisProfile({ path: target, sha256: "a".repeat(64), kind: "executable", format: "pe", architecture: "x86_64", availableArchitectures: ["x86_64"], executableRole: "application", managed: false });
      if (!recovered.ok) throw recovered.error;
      expect(recovered.value.profile?.parameters.language_id).toBe(definition.id);
    } finally {
      await chmod(blocked, 0o755);
      await Promise.allSettled([client.close(), server.close()]);
      await session.close();
      await rm(root, { recursive: true, force: true });
    }
  },
  30_000,
);

Run only this file:

CATALOG_REPRO_ROOT="$PWD" node node_modules/vitest/vitest.mjs run tests/boundary/mcp/catalogReadPermission.test.ts --maxWorkers=1

Both parameterized cases passed assertions of the current incorrect behavior. Each confirms real EACCES, the incorrect empty catalog, the public configuration error, and recovery after restoring permissions. The fixture directories are removed in finally. Remove the temporary test afterward.

Expected behavior

Do not conclude that a language is undefined when its catalog directory could not be read. Preserve the filesystem cause and affected directory in a provider/catalog read failure so callers can repair the installation or permissions. Continue allowing genuinely absent optional catalog directories where appropriate.

The installation guide promises validation against installed .ldefs files. The provider already has a catalog exception path that preserves the cause as a provider adapter failure.

Impact and scope

This is a bounded admission and recovery defect for language overrides when directory enumeration fails: an existing valid choice is falsely rejected as user configuration, and its filesystem error and useful recovery direction disappear. Restoring directory permissions restored catalog reading and profile resolution in both controls without changing the override. No data loss or false analysis success was observed; real-world frequency is unknown. Without an override this particular catalog-admission path is not used. CLI propagation and other filesystem error codes were not executed.

REA version

5241597594 (package metadata: 6.7.0).

Runtime and platform

macOS 27.0.1 arm64, Node v26.12.0, locked Vitest 5.0.3. Installation readiness was simulated as Linux x64 with Ghidra 12.1.4 and Java 21.0.11. No real Ghidra installation or engine process was used.

Target and artifact context

Repository-owned synthetic native x86_64 PE fixture and a minimal valid .ldefs document defining x86:LE:64:default with the windows compiler spec. Failure occurs during profile admission, before import.

Interface

MCP

Earliest failure stage

Setup or provider admission

Relevant output or evidence

For each unreadable directory, direct readdir rejected with code: "EACCES"; readGhidraLanguageCatalog returned []. The registered open_binary request returned:

{
  "error": {
    "code": "configuration_invalid",
    "category": "invalid_input",
    "message": "REA configuration is invalid: REA_GHIDRA_LANGUAGE_ID: is not a language defined by Ghidra 12.1.4.",
    "retryable": false,
    "remediation": {
      "action": "Correct REA_GHIDRA_LANGUAGE_ID in the environment that starts REA (your shell or the MCP client's registration), then rerun."
    },
    "details": {
      "settings": [{
        "setting": "REA_GHIDRA_LANGUAGE_ID",
        "constraint": "is not a language defined by Ghidra 12.1.4"
      }]
    }
  }
}

The output omitted EACCES. Restoring permissions returned the original language/compiler catalog and allowed resolveAnalysisProfile to succeed. Result: one focused file, two passing cases asserting the current defect and restoration controls.

Typed outcome or recovery expected

A catalog/provider read failure retaining the original filesystem error and unreadable directory, with recovery directed at installation accessibility rather than editing a valid REA_GHIDRA_LANGUAGE_ID. Preserve the distinct configuration error for a genuinely undefined language after successful catalog enumeration.

Analysis or suspected cause

The root walk and module-directory walk catch every readdir exception and continue. Consequently an unread catalog is indistinguishable from a complete catalog with no matching entry. Override admission converts that absence into a configuration error. The existing provider exception path never receives the discarded read failure.

Additional context

Fetched upstream main at 25e1a24a3ed3671a0148757555f18d16c29d936a; the catalog reader and provider file are unchanged from the reported revision. Related reports #4712/#2533 concern XML decoding, #2531 concerns bounded allocation, and #3930 concerns unsupported-provider selection diagnostics; they do not address directory-read errors. The focused cases demonstrate permissions only; extension-directory errors, transient I/O faults, and real Ghidra analysis remain untested.

Originally created by @coygeek on GitHub (Oct 21, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/5972 ### 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 Provider or capability discovery ### Observed behavior When an existing Ghidra catalog directory cannot be enumerated, `open_binary` reports a configured language as undefined instead of preserving the directory-read failure. A focused MCP test observed real `EACCES` on both `Ghidra/Processors` and a module's `data/languages` directory. In each case the catalog returned `[]` and MCP returned `configuration_invalid` / `invalid_input`, telling the caller to correct `REA_GHIDRA_LANGUAGE_ID`. The same unchanged language definition loaded before permission removal and after permissions were restored. ### Reproduction Run as an ordinary user on a filesystem that enforces directory permissions. Save the following as `tests/boundary/mcp/catalogReadPermission.test.ts` in the source checkout with locked dependencies installed. It uses the repository's simulated installation-readiness fixture and actual catalog file I/O, production configuration/provider/session code, target parsing, and SDK MCP transport. No engine or Java process runs. ```ts import { chmod, mkdir, mkdtemp, readdir, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; import { expect, it } from "vitest"; import { AnalysisProviderRegistry } from "../../../src/application/binary/AnalysisProviderRegistry.js"; import { composeBinarySession } from "../../../src/application/binary/BinarySessionComposition.js"; import { parseConfig } from "../../../src/config/parseConfig.js"; import { ghidraProfileInstallationHost } from "../../../src/ghidra/GhidraInstallation.fixture.js"; import { readGhidraLanguageCatalog } from "../../../src/ghidra/GhidraLanguageCatalog.js"; import { GhidraProvider } from "../../../src/ghidra/GhidraProvider.js"; import { silentLogger } from "../../../src/logger.js"; import { createServer } from "../../../src/server/createServer.js"; import { nativeAotPeFixture } from "../../fixtures/nativeaotPe.js"; import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; it.each(["Ghidra/Processors", "Ghidra/Processors/x86/data/languages"])( "misclassifies EACCES reading %s through open_binary", async (blockedRelative) => { const root = await mkdtemp(join(process.env.CATALOG_REPRO_ROOT!, "case-")); const languages = join(root, "Ghidra/Processors/x86/data/languages"); const blocked = join(root, blockedRelative); const target = join(root, "fixture.exe"); const definition = { id: "x86:LE:64:default", compilerSpecIds: ["windows"] }; await mkdir(languages, { recursive: true }); await writeFile(join(languages, "x86.ldefs"), '<language_definitions><language id="x86:LE:64:default"><compiler id="windows"/></language></language_definitions>'); await writeFile(target, nativeAotPeFixture()); const config = parseConfig({ GHIDRA_INSTALL_DIR: root, REA_GHIDRA_LANGUAGE_ID: definition.id }); if (!config.ok) throw config.error; const provider = new GhidraProvider(config.value, silentLogger, {}, ghidraProfileInstallationHost); const session = composeBinarySession(new AnalysisProviderRegistry([provider])); const server = createServer({ kind: "session", session }, { logger: silentLogger }); const client = new Client({ name: "catalog-readdir-reproduction", version: "1.0.0" }); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); try { expect(await readGhidraLanguageCatalog(root)).toEqual([definition]); await chmod(blocked, 0); await expect(readdir(blocked)).rejects.toMatchObject({ code: "EACCES" }); expect(await readGhidraLanguageCatalog(root)).toEqual([]); await server.connect(serverTransport); await client.connect(clientTransport); const response = await client.callTool({ name: "open_binary", arguments: { path: target, provider_id: "ghidra" } }); const error = parseMcpToolError(response); console.log(JSON.stringify({ blocked: blockedRelative, error })); expect(response.isError).toBe(true); expect(error).toMatchObject({ error: { code: "configuration_invalid", category: "invalid_input", details: { settings: [{ setting: "REA_GHIDRA_LANGUAGE_ID", constraint: "is not a language defined by Ghidra 12.1.4" }] } } }); expect(JSON.stringify(error)).not.toContain("EACCES"); await chmod(blocked, 0o755); expect(await readGhidraLanguageCatalog(root)).toEqual([definition]); const recovered = await provider.resolveAnalysisProfile({ path: target, sha256: "a".repeat(64), kind: "executable", format: "pe", architecture: "x86_64", availableArchitectures: ["x86_64"], executableRole: "application", managed: false }); if (!recovered.ok) throw recovered.error; expect(recovered.value.profile?.parameters.language_id).toBe(definition.id); } finally { await chmod(blocked, 0o755); await Promise.allSettled([client.close(), server.close()]); await session.close(); await rm(root, { recursive: true, force: true }); } }, 30_000, ); ``` Run only this file: ```sh CATALOG_REPRO_ROOT="$PWD" node node_modules/vitest/vitest.mjs run tests/boundary/mcp/catalogReadPermission.test.ts --maxWorkers=1 ``` Both parameterized cases passed assertions of the current incorrect behavior. Each confirms real `EACCES`, the incorrect empty catalog, the public configuration error, and recovery after restoring permissions. The fixture directories are removed in `finally`. Remove the temporary test afterward. ### Expected behavior Do not conclude that a language is undefined when its catalog directory could not be read. Preserve the filesystem cause and affected directory in a provider/catalog read failure so callers can repair the installation or permissions. Continue allowing genuinely absent optional catalog directories where appropriate. The [installation guide](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/docs/installation.md#L1077-L1088) promises validation against installed `.ldefs` files. The provider already has a [catalog exception path](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/ghidra/GhidraProvider.ts#L398-L426) that preserves the cause as a provider adapter failure. ### Impact and scope This is a bounded admission and recovery defect for language overrides when directory enumeration fails: an existing valid choice is falsely rejected as user configuration, and its filesystem error and useful recovery direction disappear. Restoring directory permissions restored catalog reading and profile resolution in both controls without changing the override. No data loss or false analysis success was observed; real-world frequency is unknown. Without an override this particular catalog-admission path is not used. CLI propagation and other filesystem error codes were not executed. ### REA version 52415975942a4b9c53a817915b313ccc404f280e (package metadata: 6.7.0). ### Runtime and platform macOS 27.0.1 arm64, Node v26.12.0, locked Vitest 5.0.3. Installation readiness was simulated as Linux x64 with Ghidra 12.1.4 and Java 21.0.11. No real Ghidra installation or engine process was used. ### Target and artifact context Repository-owned synthetic native x86_64 PE fixture and a minimal valid `.ldefs` document defining `x86:LE:64:default` with the `windows` compiler spec. Failure occurs during profile admission, before import. ### Interface MCP ### Earliest failure stage Setup or provider admission ### Relevant output or evidence For each unreadable directory, direct `readdir` rejected with `code: "EACCES"`; `readGhidraLanguageCatalog` returned `[]`. The registered `open_binary` request returned: ```json { "error": { "code": "configuration_invalid", "category": "invalid_input", "message": "REA configuration is invalid: REA_GHIDRA_LANGUAGE_ID: is not a language defined by Ghidra 12.1.4.", "retryable": false, "remediation": { "action": "Correct REA_GHIDRA_LANGUAGE_ID in the environment that starts REA (your shell or the MCP client's registration), then rerun." }, "details": { "settings": [{ "setting": "REA_GHIDRA_LANGUAGE_ID", "constraint": "is not a language defined by Ghidra 12.1.4" }] } } } ``` The output omitted `EACCES`. Restoring permissions returned the original language/compiler catalog and allowed `resolveAnalysisProfile` to succeed. Result: one focused file, two passing cases asserting the current defect and restoration controls. ### Typed outcome or recovery expected A catalog/provider read failure retaining the original filesystem error and unreadable directory, with recovery directed at installation accessibility rather than editing a valid `REA_GHIDRA_LANGUAGE_ID`. Preserve the distinct configuration error for a genuinely undefined language after successful catalog enumeration. ### Analysis or suspected cause The [root walk](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/ghidra/GhidraLanguageCatalog.ts#L110-L123) and [module-directory walk](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/ghidra/GhidraLanguageCatalog.ts#L153-L178) catch every `readdir` exception and continue. Consequently an unread catalog is indistinguishable from a complete catalog with no matching entry. [Override admission](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/ghidra/GhidraLanguageCatalog.ts#L181-L199) converts that absence into a configuration error. The existing provider exception path never receives the discarded read failure. ### Additional context Fetched upstream main at `25e1a24a3ed3671a0148757555f18d16c29d936a`; the catalog reader and provider file are unchanged from the reported revision. Related reports #4712/#2533 concern XML decoding, #2531 concerns bounded allocation, and #3930 concerns unsupported-provider selection diagnostics; they do not address directory-read errors. The focused cases demonstrate permissions only; extension-directory errors, transient I/O faults, and real Ghidra analysis remain untested.
Author
Owner

@coygeek commented on GitHub (Oct 24, 2026):

Checked the reported fix on main at d87e73f2db.

Both catalog directory walks now ignore only ENOENT/ENOTDIR and rethrow other read failures. Therefore EACCES reaches the existing provider catalog failure path instead of becoming an empty successful catalog and undefined-language configuration. The regression removes permissions on each reported directory, verifies a real EACCES with path from both readdir and the catalog reader, then restores permissions and checks the original catalog.

Implementation: GhidraLanguageCatalog.ts, GhidraLanguageCatalog.ts.

The relevant regression checks in GhidraLanguageOverride.test.ts passed in the focused test run on this revision. This closure is based on the current source and those checks; provider fixtures do not establish a real-engine run. Closing as completed on main.

<!-- gh-comment-id:6380768669 --> @coygeek commented on GitHub (Oct 24, 2026): Checked the reported fix on `main` at d87e73f2db50be72d33a4b3c7199af35518b441c. Both catalog directory walks now ignore only ENOENT/ENOTDIR and rethrow other read failures. Therefore EACCES reaches the existing provider catalog failure path instead of becoming an empty successful catalog and undefined-language configuration. The regression removes permissions on each reported directory, verifies a real EACCES with path from both readdir and the catalog reader, then restores permissions and checks the original catalog. Implementation: [GhidraLanguageCatalog.ts](https://github.com/morluto/rea/blob/d87e73f2db50be72d33a4b3c7199af35518b441c/src/ghidra/GhidraLanguageCatalog.ts#L55), [GhidraLanguageCatalog.ts](https://github.com/morluto/rea/blob/d87e73f2db50be72d33a4b3c7199af35518b441c/src/ghidra/GhidraLanguageCatalog.ts#L117). The relevant regression checks in [GhidraLanguageOverride.test.ts](https://github.com/morluto/rea/blob/d87e73f2db50be72d33a4b3c7199af35518b441c/src/ghidra/GhidraLanguageOverride.test.ts) passed in the focused test run on this revision. This closure is based on the current source and those checks; provider fixtures do not establish a real-engine run. Closing as completed on main.
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#3640
No description provided.