[GH-ISSUE #5997] [Bug] Dispatch metadata classifies bound-target read refusals as provider failures #3666

Closed
opened 2026-10-31 00:42:51 +00:00 by bsakaguchi · 0 comments
Owner

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

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

After open_binary successfully binds a Mach-O, inspect_native_dispatch_metadata reports execution_failure and recommends rea doctor when the bound file disappears, becomes a directory or symlink loop, or loses read permission. The raw diagnostic contains the filesystem reason, but the error has no typed artifact-change/access-denied outcome.

Executed through the production stdio MCP server with real filesystem errors. The original readable control and restored-byte control both succeed in the same bound session.

Reproduction

From a built source checkout with locked dependencies, save this driver outside the checkout. Set REPRO_ROOT to an existing disposable writable directory, then run the driver with the checkout as its working directory. Run as an ordinary user so chmod-based permission denial applies. The self-authored Mach-O is inspected, never executed.

import { createRequire } from 'node:module';
import { mkdtemp, mkdir, writeFile, rm, chmod, symlink } from 'node:fs/promises';
import { join } from 'node:path';
import assert from 'node:assert/strict';
const repo = process.cwd();
const require = createRequire(join(repo, 'package.json'));
const { Client } = await import(require.resolve('@modelcontextprotocol/client'));
const { StdioClientTransport } = await import(require.resolve('@modelcontextprotocol/client/stdio'));
const root = await mkdtemp(join(process.env.REPRO_ROOT, 'dispatch-read-'));
const path = join(root, 'fixture.dylib');
const bytes = Buffer.alloc(4096);
for (const [at, value] of [[0,0xfeedfacf],[4,0x0100000c],[12,6],[16,1],[20,72],[32,0x19],[36,72],[88,5],[92,5]]) bytes.writeUInt32LE(value,at);
bytes.write('__TEXT',40);
bytes.writeBigUInt64LE(0x100000000n,56);
bytes.writeBigUInt64LE(4096n,64);
bytes.writeBigUInt64LE(4096n,80);
const env = { PATH: process.env.PATH, REA_LOG_LEVEL: 'silent' };
for (const key of ['HOME','XDG_CONFIG_HOME','XDG_DATA_HOME','XDG_CACHE_HOME','XDG_STATE_HOME','TMPDIR']) {
  env[key] = join(root,key.toLowerCase()); await mkdir(env[key]);
}
const transport = new StdioClientTransport({command:process.execPath,args:[join(repo,'scripts/rea.mjs'),'mcp'],env,stderr:'pipe'});
let stderr=''; transport.stderr?.on('data',b=>stderr+=b);
const client = new Client({name:'dispatch-read-reproduction',version:'1'});
try {
  await writeFile(path,bytes); await client.connect(transport);
  const opened=await client.callTool({name:'open_binary',arguments:{path}});
  assert.notEqual(opened.isError,true,JSON.stringify(opened));
  for (const mode of ['control','missing','directory','loop','unreadable','restored']) {
    await rm(path,{recursive:true,force:true});
    if(mode==='directory') await mkdir(path);
    else if(mode==='loop') await symlink('fixture.dylib',path);
    else if(mode!=='missing') {await writeFile(path,bytes);if(mode==='unreadable') await chmod(path,0);}
    try {
      const response=await client.callTool({name:'inspect_native_dispatch_metadata',arguments:{max_records:100}});
      if(mode==='control'||mode==='restored') {
        assert.notEqual(response.isError,true,JSON.stringify(response)); console.log(mode,'success');
      } else {
        assert.equal(response.isError,true);
        const error=JSON.parse(response.content.find(x=>x.type==='text').text).error;
        assert.equal(error.code,'execution_failure');
        console.log(mode,JSON.stringify({code:error.code,category:error.category,retryable:error.retryable,reason:error.details.diagnostics.reason.split(':')[0],recovery:error.remediation.action}));
      }
    } finally {if(mode==='unreadable') await chmod(path,0o600);}
  }
  assert.equal(stderr,'');
} finally {await client.close();await transport.close();await rm(root,{recursive:true,force:true});}

The script ran successfully. It characterizes the current incorrect codes, so its failure-case assertions intentionally expect execution_failure. Each case was also observed with a linker-produced arm64 dylib in an earlier run.

Expected behavior

Preserve refusal while identifying the actual bound-target read failure. A removed or nonregular replacement should report artifact_changed; a permission denial should report access_denied with the selected path and system code. Recovery should explain stabilizing/reopening the target or correcting read access.

The access-denied error contract distinguishes host permission refusal from provider failure. The same provider’s signature target mapper already distinguishes removed bound targets and permission denial. The repository error-boundary rule requires typed expected failures and meaningful reasons.

Impact and scope

P2: callers receive the wrong machine-readable failure and recovery for an already bound target. Refusal still works; no stale metadata, data corruption or security-boundary crossing was observed. Restoring the original readable bytes was a demonstrated workaround. Natural frequency and downstream automated recovery were not measured.

Observed: ENOENT, ELOOP, directory replacement and EACCES on macOS. ENOTDIR, EPERM, special files, concurrent close failure and other platforms were source-derived or untested. Initial open_binary admission and digest mismatch are separate boundaries.

REA version

rea-agents 6.7.0; source revision 52415975942a4b9c53a817915b313ccc404f280e; prepared built runtime. Fetched main c538a843cb49f7156f86beb3b21501e03255af9a has no changes to the relevant dispatch mapper; that later revision was not rebuilt or executed.

Runtime and platform

macOS 27.0.1 arm64; Node.js v26.12.0. Repository Node pin: 24.18.0. Real stdio MCP and filesystem operations; no simulated provider, filesystem mock, Hopper or Ghidra.

Target and artifact context

Reduced 4096-byte thin arm64 Mach-O dylib header, one file-backed __TEXT segment, bound before path changes. An earlier run used an Apple-linker-produced arm64 dylib with the same outcomes. HOME/XDG/TMPDIR were isolated.

Interface

MCP

Earliest failure stage

Native analysis or reconstruction

Relevant output or evidence

Observed reduced-run outcomes:

control: success
missing: execution_failure / execution_failure, ENOENT
directory: execution_failure / execution_failure, Selected input must be a regular file
loop: execution_failure / execution_failure, ELOOP
unreadable: execution_failure / execution_failure, EACCES
restored: success

All four refusals set retryable: false, details.provider_id: "native-macos", and details.operation: "inspect_native_dispatch_metadata". The error message and remediation action both say:

Analysis could not complete. Run `rea doctor` and review the reported details; repeating the unchanged request is not expected to succeed.

The original path and free-form reason appear in details.diagnostics; structured path, system_code and filesystem boundary do not. stderr was empty.

Typed outcome or recovery expected

Removed/nonregular/loop replacement: artifact_changed / integrity_mismatch, with operation, path, cause and stable-artifact-read boundary, directing the caller to stabilize or reopen the target. EACCES/EPERM: access_denied with path, actual system code and filesystem-read boundary. Preserve any independent cleanup failure rather than replacing its primary cause.

Analysis or suspected cause

Source-derived: the owned dispatch reader forwards regular-file acquisition failures. appleDispatchMetadataFailure handles cancellation, unsupported format and existing analysis errors, then defaults these ordinary filesystem errors to ProviderAdapterError. Its cleanup-primary recursion uses the same mapper; that combined-close case was not executed. Classify expected failures at this acquisition owner before the generic fallback.

Additional context

Related but distinct: #5953 covers firmware initial-source admission, #5949 covers selected/default plist path resolution, and #5907 covers final pathname verification in dylib tracing. Their owners and expected typed outcomes differ. PRs #4745/#4756 concern descriptor close ownership and preserving a primary failure; neither restores filesystem classification in this dispatch mapper. No known-good revision or repaired-behavior run was established.

Originally created by @coygeek on GitHub (Oct 21, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/5997 ### 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 After `open_binary` successfully binds a Mach-O, `inspect_native_dispatch_metadata` reports `execution_failure` and recommends `rea doctor` when the bound file disappears, becomes a directory or symlink loop, or loses read permission. The raw diagnostic contains the filesystem reason, but the error has no typed artifact-change/access-denied outcome. Executed through the production stdio MCP server with real filesystem errors. The original readable control and restored-byte control both succeed in the same bound session. ### Reproduction From a built source checkout with locked dependencies, save this driver outside the checkout. Set `REPRO_ROOT` to an existing disposable writable directory, then run the driver with the checkout as its working directory. Run as an ordinary user so chmod-based permission denial applies. The self-authored Mach-O is inspected, never executed. ```js import { createRequire } from 'node:module'; import { mkdtemp, mkdir, writeFile, rm, chmod, symlink } from 'node:fs/promises'; import { join } from 'node:path'; import assert from 'node:assert/strict'; const repo = process.cwd(); const require = createRequire(join(repo, 'package.json')); const { Client } = await import(require.resolve('@modelcontextprotocol/client')); const { StdioClientTransport } = await import(require.resolve('@modelcontextprotocol/client/stdio')); const root = await mkdtemp(join(process.env.REPRO_ROOT, 'dispatch-read-')); const path = join(root, 'fixture.dylib'); const bytes = Buffer.alloc(4096); for (const [at, value] of [[0,0xfeedfacf],[4,0x0100000c],[12,6],[16,1],[20,72],[32,0x19],[36,72],[88,5],[92,5]]) bytes.writeUInt32LE(value,at); bytes.write('__TEXT',40); bytes.writeBigUInt64LE(0x100000000n,56); bytes.writeBigUInt64LE(4096n,64); bytes.writeBigUInt64LE(4096n,80); const env = { PATH: process.env.PATH, REA_LOG_LEVEL: 'silent' }; for (const key of ['HOME','XDG_CONFIG_HOME','XDG_DATA_HOME','XDG_CACHE_HOME','XDG_STATE_HOME','TMPDIR']) { env[key] = join(root,key.toLowerCase()); await mkdir(env[key]); } const transport = new StdioClientTransport({command:process.execPath,args:[join(repo,'scripts/rea.mjs'),'mcp'],env,stderr:'pipe'}); let stderr=''; transport.stderr?.on('data',b=>stderr+=b); const client = new Client({name:'dispatch-read-reproduction',version:'1'}); try { await writeFile(path,bytes); await client.connect(transport); const opened=await client.callTool({name:'open_binary',arguments:{path}}); assert.notEqual(opened.isError,true,JSON.stringify(opened)); for (const mode of ['control','missing','directory','loop','unreadable','restored']) { await rm(path,{recursive:true,force:true}); if(mode==='directory') await mkdir(path); else if(mode==='loop') await symlink('fixture.dylib',path); else if(mode!=='missing') {await writeFile(path,bytes);if(mode==='unreadable') await chmod(path,0);} try { const response=await client.callTool({name:'inspect_native_dispatch_metadata',arguments:{max_records:100}}); if(mode==='control'||mode==='restored') { assert.notEqual(response.isError,true,JSON.stringify(response)); console.log(mode,'success'); } else { assert.equal(response.isError,true); const error=JSON.parse(response.content.find(x=>x.type==='text').text).error; assert.equal(error.code,'execution_failure'); console.log(mode,JSON.stringify({code:error.code,category:error.category,retryable:error.retryable,reason:error.details.diagnostics.reason.split(':')[0],recovery:error.remediation.action})); } } finally {if(mode==='unreadable') await chmod(path,0o600);} } assert.equal(stderr,''); } finally {await client.close();await transport.close();await rm(root,{recursive:true,force:true});} ``` The script ran successfully. It characterizes the current incorrect codes, so its failure-case assertions intentionally expect `execution_failure`. Each case was also observed with a linker-produced arm64 dylib in an earlier run. ### Expected behavior Preserve refusal while identifying the actual bound-target read failure. A removed or nonregular replacement should report `artifact_changed`; a permission denial should report `access_denied` with the selected path and system code. Recovery should explain stabilizing/reopening the target or correcting read access. The [access-denied error contract](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/domain/analysisErrorCore.ts#L188-L204) distinguishes host permission refusal from provider failure. [The same provider’s signature target mapper](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/native/SignatureTargetBinding.ts#L58-L103) already distinguishes removed bound targets and permission denial. The [repository error-boundary rule](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/AGENTS.md#architecture-and-cleanup) requires typed expected failures and meaningful reasons. ### Impact and scope P2: callers receive the wrong machine-readable failure and recovery for an already bound target. Refusal still works; no stale metadata, data corruption or security-boundary crossing was observed. Restoring the original readable bytes was a demonstrated workaround. Natural frequency and downstream automated recovery were not measured. Observed: ENOENT, ELOOP, directory replacement and EACCES on macOS. ENOTDIR, EPERM, special files, concurrent close failure and other platforms were source-derived or untested. Initial `open_binary` admission and digest mismatch are separate boundaries. ### REA version `rea-agents` 6.7.0; source revision `52415975942a4b9c53a817915b313ccc404f280e`; prepared built runtime. Fetched main `c538a843cb49f7156f86beb3b21501e03255af9a` has no changes to the relevant dispatch mapper; that later revision was not rebuilt or executed. ### Runtime and platform macOS 27.0.1 arm64; Node.js v26.12.0. Repository Node pin: 24.18.0. Real stdio MCP and filesystem operations; no simulated provider, filesystem mock, Hopper or Ghidra. ### Target and artifact context Reduced 4096-byte thin arm64 Mach-O dylib header, one file-backed __TEXT segment, bound before path changes. An earlier run used an Apple-linker-produced arm64 dylib with the same outcomes. HOME/XDG/TMPDIR were isolated. ### Interface MCP ### Earliest failure stage Native analysis or reconstruction ### Relevant output or evidence Observed reduced-run outcomes: ```text control: success missing: execution_failure / execution_failure, ENOENT directory: execution_failure / execution_failure, Selected input must be a regular file loop: execution_failure / execution_failure, ELOOP unreadable: execution_failure / execution_failure, EACCES restored: success ``` All four refusals set `retryable: false`, `details.provider_id: "native-macos"`, and `details.operation: "inspect_native_dispatch_metadata"`. The error message and remediation action both say: ```text Analysis could not complete. Run `rea doctor` and review the reported details; repeating the unchanged request is not expected to succeed. ``` The original path and free-form reason appear in `details.diagnostics`; structured `path`, `system_code` and filesystem boundary do not. stderr was empty. ### Typed outcome or recovery expected Removed/nonregular/loop replacement: `artifact_changed` / `integrity_mismatch`, with operation, path, cause and `stable-artifact-read` boundary, directing the caller to stabilize or reopen the target. EACCES/EPERM: `access_denied` with path, actual system code and `filesystem-read` boundary. Preserve any independent cleanup failure rather than replacing its primary cause. ### Analysis or suspected cause Source-derived: [the owned dispatch reader](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/native/AppleDispatchMetadata.ts#L291-L315) forwards regular-file acquisition failures. [appleDispatchMetadataFailure](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/native/AppleDispatchMetadata.ts#L334-L384) handles cancellation, unsupported format and existing analysis errors, then defaults these ordinary filesystem errors to `ProviderAdapterError`. Its cleanup-primary recursion uses the same mapper; that combined-close case was not executed. Classify expected failures at this acquisition owner before the generic fallback. ### Additional context Related but distinct: #5953 covers firmware initial-source admission, #5949 covers selected/default plist path resolution, and #5907 covers final pathname verification in dylib tracing. Their owners and expected typed outcomes differ. PRs #4745/#4756 concern descriptor close ownership and preserving a primary failure; neither restores filesystem classification in this dispatch mapper. No known-good revision or repaired-behavior run was established.
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#3666
No description provided.