[GH-ISSUE #5873] [Bug] Resource-only app opening drops permission-denied classification #3560

Closed
opened 2026-10-31 00:41:52 +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/5873

Before submitting

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

Area

Evidence, artifacts, or reconstruction

Observed behavior

Opening a resource-only app session with open_bundle_resources reports target_unavailable when the operating system denies search permission on the app's Contents directory. The response loses the actual EACCES, the denied Info.plist path, and permission-specific recovery advice.

I reproduced this through the built stdio MCP server and through node scripts/rea.mjs inspect-plist <app> --json. Both readable controls succeeded, and restoring search permission made both routes succeed again. This used a self-authored bundle and real filesystem permission changes, without simulated filesystem errors or a deep-analysis provider.

Reproduction

Run as a non-root account on macOS with dependencies and the built runtime already available. Create a disposable Fixture.app/Contents/Info.plist containing:

<?xml version="1.0"?><plist version="1.0"><dict><key>CFBundleIdentifier</key><string>org.example.fixture</string></dict></plist>
  1. Call open_bundle_resources with the bundle's absolute path and confirm success, then call close_binary.
  2. Remove search permission from Contents with chmod 600, leaving the app directory accessible. Confirm realpath(app) succeeds while stat(Contents/Info.plist) fails with EACCES.
  3. Repeat open_bundle_resources on that app. It returns isError: true and an error whose code is target_unavailable, without system_code or boundary.
  4. Restore Contents to mode 700 and repeat the opening request; it succeeds.

The CLI counterpart actually executed was node scripts/rea.mjs inspect-plist <app> --json: exit 0 before restriction, exit 1 with the same incorrect classification during restriction, then exit 0 after restoration.

The following focused test was also executed using the repository's existing stdio MCP harness. Save it as tests/acceptance/applications/bundlePermissionReproduction.test.ts, set REA_REPRO_ROOT to an existing writable disposable directory outside the checkout, and run:

node node_modules/vitest/vitest.mjs run tests/acceptance/applications/bundlePermissionReproduction.test.ts --maxWorkers=1

This test records the current faulty result; it passes while the misclassification is present. It also compares the executable resolver's correctly classified failure on the same denied bundle and checks recovery after permission restoration.

import { chmod, mkdir, readFile, realpath, rm, stat, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect, it } from "vitest";
import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js";
import { resolveAppBundleExecutable } from "../../../src/application/AppBundleExecutable.js";
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";

it("records a real bundle permission failure through stdio MCP", async () => {
  const root = process.env.REA_REPRO_ROOT!;
  const bundle = join(root, "McpFixture.app");
  const contents = join(bundle, "Contents");
  const plist = join(contents, "Info.plist");
  const xml = '<?xml version="1.0"?><plist version="1.0"><dict><key>CFBundleIdentifier</key><string>org.example.fixture</string></dict></plist>';
  await mkdir(contents, { recursive: true });
  await writeFile(plist, xml);
  const { call } = await connectLocalToolsMcp();
  const records: Record<string, unknown> = {};
  const decode = (response: any) => JSON.parse(response.content.find((item: any) => item.type === "text").text).error;
  try {
    const readable = await call("open_bundle_resources", { path: bundle });
    expect(readable.isError).not.toBe(true);
    records.readable = readable.structuredContent;
    await call("close_binary", {});
    await chmod(contents, 0o600);
    records.canonicalBundle = await realpath(bundle);
    await expect(stat(plist)).rejects.toMatchObject({ code: "EACCES" });
    const denied = await call("open_bundle_resources", { path: bundle });
    expect(denied.isError).toBe(true);
    const projected = decode(denied);
    records.denied = projected;
    expect(projected.code).toBe("target_unavailable");
    expect(projected.details.system_code).toBeUndefined();
    expect(projected.details.reason).toContain("could not inspect app bundle path");
    const executable = await resolveAppBundleExecutable(bundle);
    expect(executable.ok).toBe(false);
    if (!executable.ok) {
      records.executableControl = projectAnalysisError(executable.error);
      expect(executable.error.systemCode).toBe("EACCES");
      expect(projectAnalysisError(executable.error).code).toBe("access_denied");
    }
    await chmod(contents, 0o700);
    const restored = await call("open_bundle_resources", { path: bundle });
    expect(restored.isError).not.toBe(true);
    records.restored = restored.structuredContent;
    expect(await readFile(plist, "utf8")).toBe(xml);
    await call("close_binary", {});
  } finally {
    await chmod(contents, 0o700);
    await rm(bundle, { recursive: true, force: true });
    await writeFile(join(root, "mcp-evidence.json"), JSON.stringify(records, null, 2).replaceAll(root, "<fixture-root>") + "\n");
  }
}, 30000);

Expected behavior

Preserve the permission error already produced by Info.plist layout discovery. The response should identify access_denied, retain the denied plist path and reason, expose details.system_code: "EACCES" and details.boundary: "filesystem-read", and provide the existing advice to check the current process's read access.

The resource-only session is an explicit supported contract in the MCP documentation. The executable resolver's catch preserves already typed errors, and its executed control retained the expected classification for this same filesystem denial.

Impact and scope

P2: clients that branch on error codes cannot identify permission-specific recovery, and the precise denied entry is replaced by a generic bundle-path failure. No data loss, target execution, or permission bypass was observed. Restoring directory search permission was an observed workaround for this fixture; both public routes then succeeded.

Only a direct macOS-layout bundle and actual EACCES were executed. The same source catch handles other layouts and EPERM, but those variants were not tested. Both projections retain category unavailable; the defect concerns the code, detailed cause, and remediation.

REA version

6.7.0 source checkout at 52415975942a4b9c53a817915b313ccc404f280e.

Runtime and platform

macOS 27.0.1 arm64, Node.js v26.12.0, non-root account; repository-pinned Vitest 5.0.3 for the focused test. Built CLI and real SDK stdio MCP server. No Hopper, Ghidra, or IDA session was needed or started.

Target and artifact context

Self-authored resource-only macOS-layout .app directory with a valid XML Info.plist and no executable declaration. Contents was changed from searchable to mode 600, then restored to mode 700. The plist bytes were unchanged.

Interface

MCP

Earliest failure stage

Setup or provider admission

Relevant output or evidence

Public MCP denial, reduced to material fields; paths are normalized placeholders:

{
  "error": {
    "code": "target_unavailable",
    "category": "unavailable",
    "retryable": false,
    "remediation": {
      "action": "Cannot open artifact: could not inspect app bundle path <app>. Check that the path exists, is readable, and points to a supported file."
    },
    "details": {
      "path": "<app>",
      "reason": "could not inspect app bundle path <app>"
    }
  }
}

The executable-resolver control, projected using projectAnalysisError on the same denied fixture, returned:

{
  "code": "access_denied",
  "category": "unavailable",
  "details": {
    "path": "<app>/Contents/Info.plist",
    "reason": "permission denied inspecting app Info.plist <app>/Contents/Info.plist",
    "system_code": "EACCES",
    "boundary": "filesystem-read"
  },
  "remediation": {
    "action": "Check the current process's read access to the selected path. Retry with a readable local file."
  }
}

The focused test finished with one test passing. An initial test attempt decoded the MCP error as a top-level projection; inspecting the actual {error:{...}} envelope corrected that observation method before the successful rerun.

Typed outcome or recovery expected

access_denied with the actual OS permission code, denied plist path, filesystem-read boundary, and existing read-access remediation. Preserve the already classified error rather than replacing it with generic target unavailability.

Analysis or suspected cause

Source-derived: layoutPlistPresent turns the failed stat into a BinaryTargetError carrying the OS cause. The resource-root catch then unconditionally wraps that error in another BinaryTargetError.

The constructor reads the immediate cause's raw code property. An existing BinaryTargetError instead exposes systemCode, so the new wrapper loses the permission classification. The shared projection consequently selects target_unavailable.

The CLI reaches the same admission path through DirectAnalysis. Source comparison against fetched upstream head f801b2fbfd28f4e3713adefddc7bd73db44c284c found the relevant resolver, session, CLI route, and error-projection files unchanged. Runtime reproduction was on the stated baseline.

Additional context

Related historical issue #1188 and its merged fix #1197 added the permission-specific projection to target errors. That behavior is present and works in the executable control; this failure arises in the later resource-only resolver's additional wrapping step. The fix would therefore be at that resource-root boundary rather than reapplying the domain projection change.

Originally created by @coygeek on GitHub (Oct 21, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/5873 ### 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 Evidence, artifacts, or reconstruction ### Observed behavior Opening a resource-only app session with `open_bundle_resources` reports `target_unavailable` when the operating system denies search permission on the app's `Contents` directory. The response loses the actual `EACCES`, the denied Info.plist path, and permission-specific recovery advice. I reproduced this through the built stdio MCP server and through `node scripts/rea.mjs inspect-plist <app> --json`. Both readable controls succeeded, and restoring search permission made both routes succeed again. This used a self-authored bundle and real filesystem permission changes, without simulated filesystem errors or a deep-analysis provider. ### Reproduction Run as a non-root account on macOS with dependencies and the built runtime already available. Create a disposable `Fixture.app/Contents/Info.plist` containing: ```xml <?xml version="1.0"?><plist version="1.0"><dict><key>CFBundleIdentifier</key><string>org.example.fixture</string></dict></plist> ``` 1. Call `open_bundle_resources` with the bundle's absolute path and confirm success, then call `close_binary`. 2. Remove search permission from `Contents` with `chmod 600`, leaving the app directory accessible. Confirm `realpath(app)` succeeds while `stat(Contents/Info.plist)` fails with `EACCES`. 3. Repeat `open_bundle_resources` on that app. It returns `isError: true` and an error whose code is `target_unavailable`, without `system_code` or `boundary`. 4. Restore `Contents` to mode 700 and repeat the opening request; it succeeds. The CLI counterpart actually executed was `node scripts/rea.mjs inspect-plist <app> --json`: exit 0 before restriction, exit 1 with the same incorrect classification during restriction, then exit 0 after restoration. The following focused test was also executed using the repository's existing stdio MCP harness. Save it as `tests/acceptance/applications/bundlePermissionReproduction.test.ts`, set `REA_REPRO_ROOT` to an existing writable disposable directory outside the checkout, and run: ```sh node node_modules/vitest/vitest.mjs run tests/acceptance/applications/bundlePermissionReproduction.test.ts --maxWorkers=1 ``` This test records the current faulty result; it passes while the misclassification is present. It also compares the executable resolver's correctly classified failure on the same denied bundle and checks recovery after permission restoration. ```ts import { chmod, mkdir, readFile, realpath, rm, stat, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { expect, it } from "vitest"; import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js"; import { resolveAppBundleExecutable } from "../../../src/application/AppBundleExecutable.js"; import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js"; it("records a real bundle permission failure through stdio MCP", async () => { const root = process.env.REA_REPRO_ROOT!; const bundle = join(root, "McpFixture.app"); const contents = join(bundle, "Contents"); const plist = join(contents, "Info.plist"); const xml = '<?xml version="1.0"?><plist version="1.0"><dict><key>CFBundleIdentifier</key><string>org.example.fixture</string></dict></plist>'; await mkdir(contents, { recursive: true }); await writeFile(plist, xml); const { call } = await connectLocalToolsMcp(); const records: Record<string, unknown> = {}; const decode = (response: any) => JSON.parse(response.content.find((item: any) => item.type === "text").text).error; try { const readable = await call("open_bundle_resources", { path: bundle }); expect(readable.isError).not.toBe(true); records.readable = readable.structuredContent; await call("close_binary", {}); await chmod(contents, 0o600); records.canonicalBundle = await realpath(bundle); await expect(stat(plist)).rejects.toMatchObject({ code: "EACCES" }); const denied = await call("open_bundle_resources", { path: bundle }); expect(denied.isError).toBe(true); const projected = decode(denied); records.denied = projected; expect(projected.code).toBe("target_unavailable"); expect(projected.details.system_code).toBeUndefined(); expect(projected.details.reason).toContain("could not inspect app bundle path"); const executable = await resolveAppBundleExecutable(bundle); expect(executable.ok).toBe(false); if (!executable.ok) { records.executableControl = projectAnalysisError(executable.error); expect(executable.error.systemCode).toBe("EACCES"); expect(projectAnalysisError(executable.error).code).toBe("access_denied"); } await chmod(contents, 0o700); const restored = await call("open_bundle_resources", { path: bundle }); expect(restored.isError).not.toBe(true); records.restored = restored.structuredContent; expect(await readFile(plist, "utf8")).toBe(xml); await call("close_binary", {}); } finally { await chmod(contents, 0o700); await rm(bundle, { recursive: true, force: true }); await writeFile(join(root, "mcp-evidence.json"), JSON.stringify(records, null, 2).replaceAll(root, "<fixture-root>") + "\n"); } }, 30000); ``` ### Expected behavior Preserve the permission error already produced by Info.plist layout discovery. The response should identify `access_denied`, retain the denied plist path and reason, expose `details.system_code: "EACCES"` and `details.boundary: "filesystem-read"`, and provide the existing advice to check the current process's read access. The resource-only session is an explicit supported contract in [the MCP documentation](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/docs/mcp-contracts.md#L531-L541). The executable resolver's catch [preserves already typed errors](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/application/AppBundleExecutable.ts#L86-L90), and its executed control retained the expected classification for this same filesystem denial. ### Impact and scope P2: clients that branch on error codes cannot identify permission-specific recovery, and the precise denied entry is replaced by a generic bundle-path failure. No data loss, target execution, or permission bypass was observed. Restoring directory search permission was an observed workaround for this fixture; both public routes then succeeded. Only a direct macOS-layout bundle and actual `EACCES` were executed. The same source catch handles other layouts and `EPERM`, but those variants were not tested. Both projections retain category `unavailable`; the defect concerns the code, detailed cause, and remediation. ### REA version 6.7.0 source checkout at `52415975942a4b9c53a817915b313ccc404f280e`. ### Runtime and platform macOS 27.0.1 arm64, Node.js v26.12.0, non-root account; repository-pinned Vitest 5.0.3 for the focused test. Built CLI and real SDK stdio MCP server. No Hopper, Ghidra, or IDA session was needed or started. ### Target and artifact context Self-authored resource-only macOS-layout `.app` directory with a valid XML Info.plist and no executable declaration. `Contents` was changed from searchable to mode 600, then restored to mode 700. The plist bytes were unchanged. ### Interface MCP ### Earliest failure stage Setup or provider admission ### Relevant output or evidence Public MCP denial, reduced to material fields; paths are normalized placeholders: ```json { "error": { "code": "target_unavailable", "category": "unavailable", "retryable": false, "remediation": { "action": "Cannot open artifact: could not inspect app bundle path <app>. Check that the path exists, is readable, and points to a supported file." }, "details": { "path": "<app>", "reason": "could not inspect app bundle path <app>" } } } ``` The executable-resolver control, projected using `projectAnalysisError` on the same denied fixture, returned: ```json { "code": "access_denied", "category": "unavailable", "details": { "path": "<app>/Contents/Info.plist", "reason": "permission denied inspecting app Info.plist <app>/Contents/Info.plist", "system_code": "EACCES", "boundary": "filesystem-read" }, "remediation": { "action": "Check the current process's read access to the selected path. Retry with a readable local file." } } ``` The focused test finished with one test passing. An initial test attempt decoded the MCP error as a top-level projection; inspecting the actual `{error:{...}}` envelope corrected that observation method before the successful rerun. ### Typed outcome or recovery expected `access_denied` with the actual OS permission code, denied plist path, `filesystem-read` boundary, and existing read-access remediation. Preserve the already classified error rather than replacing it with generic target unavailability. ### Analysis or suspected cause Source-derived: [layoutPlistPresent](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/application/AppBundleExecutable.ts#L164-L175) turns the failed `stat` into a `BinaryTargetError` carrying the OS cause. The [resource-root catch](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/application/AppBundleExecutable.ts#L142-L147) then unconditionally wraps that error in another `BinaryTargetError`. The [constructor reads the immediate cause's raw code property](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/domain/configurationErrors.ts#L49-L68). An existing `BinaryTargetError` instead exposes `systemCode`, so the new wrapper loses the permission classification. The [shared projection](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/domain/analysisErrorProjection.ts#L161-L166) consequently selects `target_unavailable`. The CLI reaches the same admission path through [DirectAnalysis](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/application/DirectAnalysis.ts#L258-L265). Source comparison against fetched upstream head `f801b2fbfd28f4e3713adefddc7bd73db44c284c` found the relevant resolver, session, CLI route, and error-projection files unchanged. Runtime reproduction was on the stated baseline. ### Additional context Related historical issue #1188 and its merged fix #1197 added the permission-specific projection to target errors. That behavior is present and works in the executable control; this failure arises in the later resource-only resolver's additional wrapping step. The fix would therefore be at that resource-root boundary rather than reapplying the domain projection change.
Author
Owner

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

Checked the reported fix on main at d87e73f2db.

Permission-denied plist stat creates BinaryTargetError at the exact denied plist path, and findAppBundleResourceRoot now returns an existing BinaryTargetError unchanged. resolveAppBundleResourceRoot propagates that error, preserving systemCode and filesystem-read projection rather than double-wrapping it at the bundle path. The inspected non-root/non-Windows regression removes Contents traversal, asserts projected access_denied with the actual plist path/EACCES/filesystem-read, restores access and asserts success; normal flat/direct bundle tests retain resource-only behavior without executable identity.

Implementation: AppBundleExecutable.ts, AppBundleExecutable.ts.

The relevant regression checks in AppBundleResourceRoot.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:6380691621 --> @coygeek commented on GitHub (Oct 24, 2026): Checked the reported fix on `main` at d87e73f2db50be72d33a4b3c7199af35518b441c. Permission-denied plist stat creates BinaryTargetError at the exact denied plist path, and findAppBundleResourceRoot now returns an existing BinaryTargetError unchanged. resolveAppBundleResourceRoot propagates that error, preserving systemCode and filesystem-read projection rather than double-wrapping it at the bundle path. The inspected non-root/non-Windows regression removes Contents traversal, asserts projected access_denied with the actual plist path/EACCES/filesystem-read, restores access and asserts success; normal flat/direct bundle tests retain resource-only behavior without executable identity. Implementation: [AppBundleExecutable.ts](https://github.com/morluto/rea/blob/d87e73f2db50be72d33a4b3c7199af35518b441c/src/application/AppBundleExecutable.ts#L139), [AppBundleExecutable.ts](https://github.com/morluto/rea/blob/d87e73f2db50be72d33a4b3c7199af35518b441c/src/application/AppBundleExecutable.ts#L154). The relevant regression checks in [AppBundleResourceRoot.test.ts](https://github.com/morluto/rea/blob/d87e73f2db50be72d33a4b3c7199af35518b441c/src/application/AppBundleResourceRoot.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#3560
No description provided.