[GH-ISSUE #7629] [Bug] Process scenario symlink loops lose their input-error classification and filesystem cause #4557

Closed
opened 2026-10-31 00:50:45 +00:00 by bsakaguchi · 0 comments
Owner

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

Before submitting

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

Area

CLI

Observed behavior

Running node scripts/rea.mjs capture-process <case>/loop-a --format json with a two-link cycle selected as the scenario file returns execution_failure / execution_failure. Its message and remediation say Evidence file could not be accessed. Check file permissions and try again. The result retains the selected path but omits the actual ELOOP cause and contains no argument issue.

A direct filesystem read of that same path reports ELOOP. The scenario cannot be opened, so capture never begins. Missing paths, dangling links, directory selections and a 300-character component instead return invalid_request / invalid_input with an input_file / invalid_value issue and a useful selection diagnostic. Every CLI call exits 1 with empty stderr.

Reproduction

  1. Use Node.js v24.22.1 in the already-built baseline checkout. From its root, run this inline recipe. It creates all fixtures and isolated application-state directories, exercises only scenario paths that cannot be read, and removes the fixture tree in finally. No scenario JSON or executable target is supplied. REPRO_SCRATCH may name an existing disposable parent directory.
node --input-type=module <<'JS'
import { mkdtempSync, mkdirSync, symlinkSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { spawnSync } from 'node:child_process';
import assert from 'node:assert/strict';

const cli = resolve('scripts/rea.mjs');
const work = mkdtempSync(join(process.env.REPRO_SCRATCH ?? tmpdir(), 'scenario-read-'));
const env = { ...process.env };
for (const key of Object.keys(env)) {
  if (/^(REA_|HOPPER_|GHIDRA_|JAVA_|JDK_)/.test(key)) delete env[key];
}
try {
  for (const key of ['HOME', 'XDG_CONFIG_HOME', 'XDG_CACHE_HOME', 'XDG_DATA_HOME', 'XDG_STATE_HOME', 'TMPDIR']) {
    env[key] = join(work, key.toLowerCase());
    mkdirSync(env[key]);
  }
  symlinkSync('loop-b', join(work, 'loop-a'));
  symlinkSync('loop-a', join(work, 'loop-b'));
  symlinkSync('absent.json', join(work, 'dangling'));
  mkdirSync(join(work, 'directory'));
  for (let attempt = 1; attempt <= 2; attempt++) {
    for (const [label, name, errno, expectedCode] of [
      ['loop', 'loop-a', 'ELOOP', 'execution_failure'],
      ['missing', 'absent.json', 'ENOENT', 'invalid_request'],
      ['dangling', 'dangling', 'ENOENT', 'invalid_request'],
      ['directory', 'directory', 'EISDIR', 'invalid_request'],
      ['long-component', 'x'.repeat(300), 'ENAMETOOLONG', 'invalid_request'],
    ]) {
      const selected = join(work, name);
      assert.throws(() => readFileSync(selected, 'utf8'), { code: errno });
      const run = spawnSync(process.execPath, [cli, 'capture-process', selected, '--format', 'json'], {
        env, encoding: 'utf8', timeout: 30000,
      });
      if (run.error) throw run.error;
      const result = JSON.parse(run.stdout);
      assert.equal(run.status, 1);
      assert.equal(run.stderr, '');
      assert.equal(result.code, expectedCode);
      if (label === 'loop') {
        assert.equal(result.category, 'execution_failure');
        assert.equal(result.details.reason, 'io');
        assert.equal(result.details.issues, undefined);
        assert.equal(run.stdout.includes('ELOOP'), false);
        console.log(JSON.stringify({ attempt, label, result }).replaceAll(work, '<case>'));
      } else {
        assert.equal(result.category, 'invalid_input');
        assert.deepEqual(result.details.issues[0].path, ['input_file']);
        assert.equal(result.details.issues[0].reason, 'invalid_value');
      }
      console.log(JSON.stringify({ attempt, label, filesystemCode: errno, exit: run.status, code: result.code }));
    }
  }
} finally {
  rmSync(work, { recursive: true, force: true });
}
JS
  1. Compare the loop rows with the four controls. In both attempts, raw filesystem reads confirm the intended native error, but only the loop receives execution_failure and loses its native cause. All assertions passed. An earlier independent replay also ran two attempts per selection, giving four reproduced loop failures and four executions of each control overall.

Expected behavior

Keep refusing the unresolvable scenario before target execution. Report that the selected scenario path cannot resolve because symbolic-link traversal loops, preserve ELOOP and the selected path, and direct the caller to repair the link chain or choose another scenario file.

Use the process-input outcome already used for other initial selection failures: invalid_request / invalid_input, with an input_file issue whose reason is invalid_value. This expectation is source-derived from the process-input boundary, its missing/nonregular/overlong-path behavior, and the requirement to preserve meaningful boundary failure reasons. The CLI guide does not explicitly prescribe an errno-by-errno scenario-read mapping. Genuine physical I/O, host access denial, resource and cleanup failures must keep their distinct meanings.

Impact and scope

P2: this is a bounded machine-readable diagnostic defect. The caller receives an execution-error classification instead of an input correction, and the public result loses the host condition needed to distinguish a loop from permission or physical-I/O failure. The incorrect Evidence-file label and permission recovery compound that loss. This goes beyond wording because the typed category and available diagnostic information differ from the other initial scenario-selection failures.

The result is non-retryable and fails safely. No automatic retries, agent recovery behavior, data loss or successful incorrect capture were observed. The raw-filesystem check provides independent diagnosis, but a successful capture after repairing the selection was deliberately not tested. Observed scope is the built public CLI on macOS; MCP, other operating systems, permissions, physical disk failures, installed-package behavior and natural frequency are unknown.

REA version

Exact node scripts/rea.mjs --version stdout:

6.8.0

The output ends with a newline. Source baseline: 65f1c447344a423fa2785847dbcea1e9a9e2454a; the checkout's prebuilt runtime was executed.

Runtime and platform

Node.js v24.22.1; macOS 27.0.1; darwin arm64. No analysis provider, Hopper or GUI application was launched.

Target and artifact context

Self-authored loop-a -> loop-b -> loop-a links, an absent path, a dangling link to that absent path, an empty directory and a generated oversized filename component. These are scenario-file selections, not paths inside an executable scenario. All fixture trees were deleted.

Interface

CLI

Earliest failure stage

Request parsing or validation

Relevant output or evidence

Complete observed loop result, with only its generated directory normalized:

{"error":"Process command failed","code":"execution_failure","category":"execution_failure","message":"Evidence file could not be accessed. Check file permissions and try again.","retryable":false,"remediation":{"action":"Evidence file could not be accessed. Check file permissions and try again."},"details":{"operation":"read","reason":"io","path":"<case>/loop-a"}}

Each of the two attempts in the inline recipe produced:

selection       raw filesystem code  CLI exit  CLI code
loop            ELOOP                1         execution_failure
missing         ENOENT               1         invalid_request
dangling        ENOENT               1         invalid_request
directory       EISDIR               1         invalid_request
long-component  ENAMETOOLONG          1         invalid_request

The loop's stdout contains no ELOOP. The controls each have an input_file / invalid_value issue. Missing and dangling controls retain ENOENT; the directory control explains that the input must be a regular file; the oversized-component control retains ENAMETOOLONG. No stderr progress records were emitted.

Contract and source checks at the baseline:

  • AGENTS.md, Architecture and Cleanup, requires tagged expected boundary failures and meaningful failure reasons. docs/cli.md:739 routes readers to the process-capture guide; its status table at lines 999-1002 describes structured failure identification. Neither that guide nor docs/process-capture.md:62-72 explicitly assigns a code to every scenario-file errno. The reference-import failure paragraph at docs/cli.md:637-638 applies to a different command.
  • src/cli/processCommands.ts:18-25 registers the scenario JSON path and dispatches this public command. src/application/process/ProcessCli.ts:42-72 reads the JSON before schema validation or calling the capture harness, establishing why these selections cannot launch a target.
  • src/application/process/ProcessCli.ts:198-207 converts expected selected-input failures to the process-input message and input_file issue, but forwards io unchanged. tests/boundary/filesystem/processCliFileInput.test.ts:257-282 intentionally preserves physical EIO as an execution error; lines 284-335 assert canonical input errors for missing/malformed inputs. These tests were inspected, not executed in this verification.
  • src/application/JsonFiles.ts:205-227,461-468 gives unclassified open errors, including ELOOP, the generic io reason. ENAMETOOLONG already takes its distinct path-limit branch and therefore reaches the process-input wrapper.
  • src/domain/analysisErrorPresentation.ts:523-542 selects the generic Evidence-file permission message. src/domain/analysisErrorProjection.ts:409-432 returns operation/reason/path without the host cause, explaining its absence from the observed loop response.
  • docs/user-visible-errors.csv:74, row 61, specifically records path/detail retention for missing or nonregular process inputs. It is supporting precedent, not an explicit ELOOP contract.

Typed outcome or recovery expected

For this initial loop selection, return the existing process-input refusal with an input_file / invalid_value issue retaining the selected path and ELOOP reason. Give link-chain/path correction guidance. Continue failing before capture; do not flatten genuine descriptor I/O, permission, resource, cancellation or cleanup failures into input errors.

Analysis or suspected cause

Source-derived: the shared reader's broad io fallback groups initial ELOOP with physical I/O. The process boundary intentionally forwards that reason unchanged, bypassing its own scenario-input projection. The shared Evidence presentation then supplies permission advice and omits the native cause. The real CLI/control observations establish the defect independently of a particular proposed implementation.

Additional context

Open/closed issue, PR and issue-comment searches found no matching process scenario-file ELOOP report. The nearest reports have different restoration conditions:

  • Closed #5397 restored canonical fields for missing/malformed process-file errors. Its closure explicitly identifies AnalysisInputError and tests requiring invalid_request for those cases. Those controls are restored here, and the loop already has canonical fields; this report does not claim that fix regressed.
  • Closed #5473 reconciled stale catalogue wording. Changing documentation cannot restore this runtime outcome.
  • Closed #5949 and merged PR #6371 handle selected/default plist resolution. That fix changes the native plist owner and does not reach the process JSON input reader.
  • Open #7607 concerns reference-import root recovery. Its result retains ELOOP/ENAMETOOLONG and its owner is separate. Repairing that reference-import guidance would leave this scenario selection unchanged.
  • Open #7573 concerns Evidence writes under a host file-size limit. Restoring EFBIG/resource recovery on writes does not require restoring read-side ELOOP input classification, even though both currently use the generic Evidence error presenter.
  • Closed #5686 preserves physical EIO over late cancellation. This case has no cancellation and fails initial path resolution; that physical-I/O guarantee should remain intact.

No fix, restored capture, last-known-good version or regression window was established. No checkout files were changed and nothing was published to GitHub.

Triage assessment

  • Impact level: P2
  • Assessment status: supported
  • Impact basis: Four real CLI loop selections received the execution-failure category and lost ELOOP, while all four initial-selection controls consistently returned typed process-input errors. This is a bounded failure of machine-readable classification and diagnosis, without evidence of data loss or a broad capture outage.
  • Workaround status: unverified
  • Workaround basis: Independent filesystem reads confirmed ELOOP and can diagnose the selection outside REA. Repairing the link chain or selecting a readable scenario is the expected correction, but restored successful capture was intentionally not exercised because this verification prohibits launching a target.
Originally created by @coygeek on GitHub (Oct 27, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/7629 ### 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 CLI ### Observed behavior Running `node scripts/rea.mjs capture-process <case>/loop-a --format json` with a two-link cycle selected as the scenario file returns `execution_failure` / `execution_failure`. Its message and remediation say `Evidence file could not be accessed. Check file permissions and try again.` The result retains the selected path but omits the actual `ELOOP` cause and contains no argument issue. A direct filesystem read of that same path reports `ELOOP`. The scenario cannot be opened, so capture never begins. Missing paths, dangling links, directory selections and a 300-character component instead return `invalid_request` / `invalid_input` with an `input_file` / `invalid_value` issue and a useful selection diagnostic. Every CLI call exits 1 with empty stderr. ### Reproduction 1. Use Node.js v24.22.1 in the already-built baseline checkout. From its root, run this inline recipe. It creates all fixtures and isolated application-state directories, exercises only scenario paths that cannot be read, and removes the fixture tree in `finally`. No scenario JSON or executable target is supplied. `REPRO_SCRATCH` may name an existing disposable parent directory. ```sh node --input-type=module <<'JS' import { mkdtempSync, mkdirSync, symlinkSync, readFileSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { spawnSync } from 'node:child_process'; import assert from 'node:assert/strict'; const cli = resolve('scripts/rea.mjs'); const work = mkdtempSync(join(process.env.REPRO_SCRATCH ?? tmpdir(), 'scenario-read-')); const env = { ...process.env }; for (const key of Object.keys(env)) { if (/^(REA_|HOPPER_|GHIDRA_|JAVA_|JDK_)/.test(key)) delete env[key]; } try { for (const key of ['HOME', 'XDG_CONFIG_HOME', 'XDG_CACHE_HOME', 'XDG_DATA_HOME', 'XDG_STATE_HOME', 'TMPDIR']) { env[key] = join(work, key.toLowerCase()); mkdirSync(env[key]); } symlinkSync('loop-b', join(work, 'loop-a')); symlinkSync('loop-a', join(work, 'loop-b')); symlinkSync('absent.json', join(work, 'dangling')); mkdirSync(join(work, 'directory')); for (let attempt = 1; attempt <= 2; attempt++) { for (const [label, name, errno, expectedCode] of [ ['loop', 'loop-a', 'ELOOP', 'execution_failure'], ['missing', 'absent.json', 'ENOENT', 'invalid_request'], ['dangling', 'dangling', 'ENOENT', 'invalid_request'], ['directory', 'directory', 'EISDIR', 'invalid_request'], ['long-component', 'x'.repeat(300), 'ENAMETOOLONG', 'invalid_request'], ]) { const selected = join(work, name); assert.throws(() => readFileSync(selected, 'utf8'), { code: errno }); const run = spawnSync(process.execPath, [cli, 'capture-process', selected, '--format', 'json'], { env, encoding: 'utf8', timeout: 30000, }); if (run.error) throw run.error; const result = JSON.parse(run.stdout); assert.equal(run.status, 1); assert.equal(run.stderr, ''); assert.equal(result.code, expectedCode); if (label === 'loop') { assert.equal(result.category, 'execution_failure'); assert.equal(result.details.reason, 'io'); assert.equal(result.details.issues, undefined); assert.equal(run.stdout.includes('ELOOP'), false); console.log(JSON.stringify({ attempt, label, result }).replaceAll(work, '<case>')); } else { assert.equal(result.category, 'invalid_input'); assert.deepEqual(result.details.issues[0].path, ['input_file']); assert.equal(result.details.issues[0].reason, 'invalid_value'); } console.log(JSON.stringify({ attempt, label, filesystemCode: errno, exit: run.status, code: result.code })); } } } finally { rmSync(work, { recursive: true, force: true }); } JS ``` 2. Compare the `loop` rows with the four controls. In both attempts, raw filesystem reads confirm the intended native error, but only the loop receives `execution_failure` and loses its native cause. All assertions passed. An earlier independent replay also ran two attempts per selection, giving four reproduced loop failures and four executions of each control overall. ### Expected behavior Keep refusing the unresolvable scenario before target execution. Report that the selected scenario path cannot resolve because symbolic-link traversal loops, preserve `ELOOP` and the selected path, and direct the caller to repair the link chain or choose another scenario file. Use the process-input outcome already used for other initial selection failures: `invalid_request` / `invalid_input`, with an `input_file` issue whose reason is `invalid_value`. This expectation is source-derived from the process-input boundary, its missing/nonregular/overlong-path behavior, and the requirement to preserve meaningful boundary failure reasons. The CLI guide does not explicitly prescribe an errno-by-errno scenario-read mapping. Genuine physical I/O, host access denial, resource and cleanup failures must keep their distinct meanings. ### Impact and scope P2: this is a bounded machine-readable diagnostic defect. The caller receives an execution-error classification instead of an input correction, and the public result loses the host condition needed to distinguish a loop from permission or physical-I/O failure. The incorrect Evidence-file label and permission recovery compound that loss. This goes beyond wording because the typed category and available diagnostic information differ from the other initial scenario-selection failures. The result is non-retryable and fails safely. No automatic retries, agent recovery behavior, data loss or successful incorrect capture were observed. The raw-filesystem check provides independent diagnosis, but a successful capture after repairing the selection was deliberately not tested. Observed scope is the built public CLI on macOS; MCP, other operating systems, permissions, physical disk failures, installed-package behavior and natural frequency are unknown. ### REA version Exact `node scripts/rea.mjs --version` stdout: ```text 6.8.0 ``` The output ends with a newline. Source baseline: `65f1c447344a423fa2785847dbcea1e9a9e2454a`; the checkout's prebuilt runtime was executed. ### Runtime and platform Node.js v24.22.1; macOS 27.0.1; darwin arm64. No analysis provider, Hopper or GUI application was launched. ### Target and artifact context Self-authored `loop-a -> loop-b -> loop-a` links, an absent path, a dangling link to that absent path, an empty directory and a generated oversized filename component. These are scenario-file selections, not paths inside an executable scenario. All fixture trees were deleted. ### Interface CLI ### Earliest failure stage Request parsing or validation ### Relevant output or evidence Complete observed loop result, with only its generated directory normalized: ```json {"error":"Process command failed","code":"execution_failure","category":"execution_failure","message":"Evidence file could not be accessed. Check file permissions and try again.","retryable":false,"remediation":{"action":"Evidence file could not be accessed. Check file permissions and try again."},"details":{"operation":"read","reason":"io","path":"<case>/loop-a"}} ``` Each of the two attempts in the inline recipe produced: ```text selection raw filesystem code CLI exit CLI code loop ELOOP 1 execution_failure missing ENOENT 1 invalid_request dangling ENOENT 1 invalid_request directory EISDIR 1 invalid_request long-component ENAMETOOLONG 1 invalid_request ``` The loop's stdout contains no `ELOOP`. The controls each have an `input_file` / `invalid_value` issue. Missing and dangling controls retain `ENOENT`; the directory control explains that the input must be a regular file; the oversized-component control retains `ENAMETOOLONG`. No stderr progress records were emitted. Contract and source checks at the baseline: - `AGENTS.md`, Architecture and Cleanup, requires tagged expected boundary failures and meaningful failure reasons. `docs/cli.md:739` routes readers to the process-capture guide; its status table at lines 999-1002 describes structured failure identification. Neither that guide nor `docs/process-capture.md:62-72` explicitly assigns a code to every scenario-file errno. The reference-import failure paragraph at `docs/cli.md:637-638` applies to a different command. - `src/cli/processCommands.ts:18-25` registers the scenario JSON path and dispatches this public command. `src/application/process/ProcessCli.ts:42-72` reads the JSON before schema validation or calling the capture harness, establishing why these selections cannot launch a target. - `src/application/process/ProcessCli.ts:198-207` converts expected selected-input failures to the process-input message and `input_file` issue, but forwards `io` unchanged. `tests/boundary/filesystem/processCliFileInput.test.ts:257-282` intentionally preserves physical EIO as an execution error; lines 284-335 assert canonical input errors for missing/malformed inputs. These tests were inspected, not executed in this verification. - `src/application/JsonFiles.ts:205-227,461-468` gives unclassified open errors, including ELOOP, the generic `io` reason. ENAMETOOLONG already takes its distinct path-limit branch and therefore reaches the process-input wrapper. - `src/domain/analysisErrorPresentation.ts:523-542` selects the generic Evidence-file permission message. `src/domain/analysisErrorProjection.ts:409-432` returns operation/reason/path without the host cause, explaining its absence from the observed loop response. - `docs/user-visible-errors.csv:74`, row 61, specifically records path/detail retention for missing or nonregular process inputs. It is supporting precedent, not an explicit ELOOP contract. ### Typed outcome or recovery expected For this initial loop selection, return the existing process-input refusal with an `input_file` / `invalid_value` issue retaining the selected path and ELOOP reason. Give link-chain/path correction guidance. Continue failing before capture; do not flatten genuine descriptor I/O, permission, resource, cancellation or cleanup failures into input errors. ### Analysis or suspected cause Source-derived: the shared reader's broad `io` fallback groups initial ELOOP with physical I/O. The process boundary intentionally forwards that reason unchanged, bypassing its own scenario-input projection. The shared Evidence presentation then supplies permission advice and omits the native cause. The real CLI/control observations establish the defect independently of a particular proposed implementation. ### Additional context Open/closed issue, PR and issue-comment searches found no matching process scenario-file ELOOP report. The nearest reports have different restoration conditions: - Closed [#5397](https://github.com/morluto/rea/issues/5397) restored canonical fields for missing/malformed process-file errors. Its closure explicitly identifies AnalysisInputError and tests requiring invalid_request for those cases. Those controls are restored here, and the loop already has canonical fields; this report does not claim that fix regressed. - Closed [#5473](https://github.com/morluto/rea/issues/5473) reconciled stale catalogue wording. Changing documentation cannot restore this runtime outcome. - Closed [#5949](https://github.com/morluto/rea/issues/5949) and merged [PR #6371](https://github.com/morluto/rea/pull/6371) handle selected/default plist resolution. That fix changes the native plist owner and does not reach the process JSON input reader. - Open [#7607](https://github.com/morluto/rea/issues/7607) concerns reference-import root recovery. Its result retains ELOOP/ENAMETOOLONG and its owner is separate. Repairing that reference-import guidance would leave this scenario selection unchanged. - Open [#7573](https://github.com/morluto/rea/issues/7573) concerns Evidence writes under a host file-size limit. Restoring EFBIG/resource recovery on writes does not require restoring read-side ELOOP input classification, even though both currently use the generic Evidence error presenter. - Closed [#5686](https://github.com/morluto/rea/issues/5686) preserves physical EIO over late cancellation. This case has no cancellation and fails initial path resolution; that physical-I/O guarantee should remain intact. No fix, restored capture, last-known-good version or regression window was established. No checkout files were changed and nothing was published to GitHub. ## Triage assessment - Impact level: P2 - Assessment status: supported - Impact basis: Four real CLI loop selections received the execution-failure category and lost ELOOP, while all four initial-selection controls consistently returned typed process-input errors. This is a bounded failure of machine-readable classification and diagnosis, without evidence of data loss or a broad capture outage. - Workaround status: unverified - Workaround basis: Independent filesystem reads confirmed ELOOP and can diagnose the selection outside REA. Repairing the link chain or selecting a readable scenario is the expected correction, but restored successful capture was intentionally not exercised because this verification prohibits launching a target.
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#4557
No description provided.