[GH-ISSUE #7571] [Bug] Environment bundle commands create directories before refusing a trailing .. #4511

Open
opened 2026-10-31 00:50:24 +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/7571

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

With valid capture Evidence, node scripts/rea.mjs environment-build CAPTURE PARENT/a/b/c/.. --format json returns an input refusal but leaves newly created a, a/b and a/b/c directories under the initially empty PARENT. The result has code: "invalid_request", category: "invalid_input", message Invalid extraction output root, and an issue on directory. The command exits 1 and writes no bundle files.

node scripts/rea.mjs environment-export MANIFEST --evidence CAPTURE PARENT/a/b/c/.. --format json produces the same refusal and directory side effect with an authentic capture/manifest pair. This report concerns the directories left by refusal; accepting the selected spelling is not required.

Reproduction

  1. Start at the stated baseline with its existing dependencies and built CLI, using Node.js v24.22.1 on PATH. Run the Python block below from the checkout root. It creates its HAR inline, obtains both inputs through the public CLI, isolates child HOME/XDG/temporary paths, and removes its fixtures on exit. It does not fetch the recorded URL.
  2. For each command, the script attempts two independent fresh output paths ending in /a/b/c/... It checks that the parent starts empty, captures the refusal, and lists every remaining descendant.
  3. The final calls test the same suffix with build --dry-run and a normal nested destination with export.
import json, os, pathlib, subprocess, tempfile

launcher = str(pathlib.Path('scripts/rea.mjs').resolve())
with tempfile.TemporaryDirectory() as temporary:
    root = pathlib.Path(temporary).resolve()
    env = {k: v for k, v in os.environ.items()
           if not k.startswith(('REA_', 'XDG_', 'HOPPER_', 'GHIDRA_', 'JAVA_'))}
    env['NODE_DISABLE_COMPILE_CACHE'] = '1'
    for key in ('HOME', 'XDG_CONFIG_HOME', 'XDG_DATA_HOME', 'XDG_CACHE_HOME',
                'XDG_STATE_HOME', 'XDG_RUNTIME_DIR', 'TMPDIR', 'TMP', 'TEMP'):
        directory = root / key.lower()
        directory.mkdir()
        env[key] = str(directory)
    def run(*args):
        p = subprocess.run(['node', launcher, *args, '--format', 'json'],
                           env=env, capture_output=True, text=True, timeout=45)
        return p.returncode, json.loads(p.stdout)
    timestamp = subprocess.check_output(
        ['date', '+%Y-%m-%dT%H:%M:%S%z'],
        env={**env, 'TZ': 'America/Los_Angeles'}, text=True).strip()
    request = dict(method='GET', url='https://example.test/api/items',
                   httpVersion='HTTP/1.1', headers=[], queryString=[],
                   cookies=[], headersSize=-1, bodySize=0)
    response = dict(status=200, statusText='OK', httpVersion='HTTP/1.1',
                    headers=[], cookies=[], redirectURL='', headersSize=-1,
                    bodySize=2, content=dict(size=2, mimeType='application/json', text='{}'))
    har = {'log': {'version': '1.2', 'creator': {'name': 'fixture', 'version': '1'},
                  'entries': [dict(startedDateTime=timestamp, time=1,
                                   request=request, response=response, cache={},
                                   timings=dict(send=0, wait=1, receive=0))]}}
    capture_path, manifest_path = root / 'capture.json', root / 'manifest.json'
    har_path = root / 'session.har'
    har_path.write_text(json.dumps(har))
    code, capture = run('inspect-web-network-capture', str(har_path), 'har')
    assert code == 0
    capture_path.write_text(json.dumps(capture))
    view = {'source': {'kind': 'inline', 'evidence': capture},
            'view': {'kind': 'environment-manifest', 'template_rules':
                     ['after-uid-literal', 'opaque-token', 'sibling-variance', 'prefix-variance']}}
    code, manifest = run('inspect-analysis-view', json.dumps(view))
    assert code == 0
    manifest_path.write_text(json.dumps(manifest))
    for command in ('environment-export', 'environment-build'):
        for attempt in (1, 2):
            parent = root / (command + str(attempt))
            parent.mkdir()
            destination = str(parent) + '/a/b/c/..'
            args = ([str(manifest_path), '--evidence', str(capture_path)]
                    if command == 'environment-export' else [str(capture_path)])
            assert list(parent.iterdir()) == []
            code, result = run(command, *args, destination)
            tree = sorted(str(p.relative_to(parent)) for p in parent.rglob('*'))
            print(command, attempt, 'exit', code, result['code'], result['message'], tree)
            assert code == 1 and result['code'] == 'invalid_request'
            assert result['details']['issues'][0]['path'] == ['directory']
            assert tree == ['a', 'a/b', 'a/b/c']
            assert all(p.is_dir() for p in parent.rglob('*'))
    dry = root / 'dry'
    dry.mkdir()
    code, result = run('environment-build', str(capture_path),
                       str(dry) + '/a/b/c/..', '--dry-run')
    assert code == 0 and result['status'] == 'planned' and list(dry.iterdir()) == []
    print('dry-run:', code, result['status'], 'no output directories')
    code, result = run('environment-export', str(manifest_path),
                       '--evidence', str(capture_path), str(root / 'valid/a/b/c'))
    assert code == 0 and result['status'] == 'exported'
    print('normal nested destination:', code, result['status'])
print('fixture and output directories removed')

The final recipe reproduced the side effect in 2/2 fresh export cases and 2/2 fresh build cases. Earlier exploratory CLI calls produced the same result. The dry run returned planned with no output directories; the normal nested export returned exported.

Expected behavior

Rejecting the output path before writing a bundle should leave no newly created directories behind.

The environment-build documentation promises: “A refusal before the write leaves nothing behind.” The CLI guide also says build refusals before the bundle is written write nothing. This request is refused at destination admission, with no bundle file created. The export guide separately promises no writes for its enumerated argument/destination refusals; it does not explicitly enumerate a trailing ... Both commands showed the same side effect through their shared writer.

Impact and scope

The observed consequence is persistent creation of empty directory chains despite a structured input refusal. Four fresh failing calls in the final recipe each left three directories that were absent before invocation. No existing content was changed, and no bundle file or data loss was observed.

This is P2: bounded unintended filesystem changes with a safe practical workaround. A new nested destination without the trailing .. successfully exported the same capture/manifest inputs. Known empty directories from a refused attempt can be removed by the caller. MCP, other operating systems and other refused destination spellings were not tested.

REA version

Exact output of node scripts/rea.mjs --version:

6.8.0

Source baseline: 65f1c447344a423fa2785847dbcea1e9a9e2454a. The built source launcher was tested; a separately installed npm package was not.

Runtime and platform

Node.js v24.22.1; macOS 27.0.1; arm64. Python 3 generated the synthetic fixture and checked the filesystem. No external analysis provider was used.

Target and artifact context

A HAR 1.2 document containing one synthetic GET request to https://example.test/api/items and an empty JSON object response. Capture Evidence and its environment manifest were generated by REA. Each failing destination was inside a fresh empty local test directory.

Interface

CLI

Earliest failure stage

Request parsing or validation

Relevant output or evidence

Observed output from the final recipe:

environment-export 1 exit 1 invalid_request Invalid extraction output root ['a', 'a/b', 'a/b/c']
environment-export 2 exit 1 invalid_request Invalid extraction output root ['a', 'a/b', 'a/b/c']
environment-build 1 exit 1 invalid_request Invalid extraction output root ['a', 'a/b', 'a/b/c']
environment-build 2 exit 1 invalid_request Invalid extraction output root ['a', 'a/b', 'a/b/c']
dry-run: 0 planned no output directories
normal nested destination: 0 exported
fixture and output directories removed

Each list is the complete descendant set under that case's initially empty parent. The script asserts that all listed entries are directories. The structured refusal additionally identifies details.issues[0].path as ["directory"]; build reports the bundle stage's operation as environment-export.

Contract locators at the baseline: docs/agent-environments.md:100-101 and docs/cli.md:1022-1030.

Source-derived explanation: src/application/environment/environmentBundleWrite.ts:153-158 creates the destination's parent recursively before invoking output-tree admission. src/artifacts/SafeOutputTree.ts:187-197 then refuses the final .. segment before acquiring an output tree. The build delegates to the exporter at src/application/environment/environmentBuild.ts:391-405; the export CLI invokes that exporter at src/cli/environmentCommands.ts:275-305.

Analysis or suspected cause

The ordering of parent creation and destination admission explains the observed directories: the missing prefix is materialized before the final component is rejected, and this early creation has no acquired output-tree owner to undo it. This explanation follows from source inspection together with the executed CLI results. No fix was implemented or tested.

Additional context

Open/closed issue searches, explicit comment searches and open/closed/merged PR searches found no matching refusal-side-effect report. Related reports have different closure signals:

  • #6865 concerns successful extraction selecting the wrong physical temporary parent through symlink/.. handling. Its closed fix addresses path selection, not bundle refusal cleanup. This reproduction requires no symlink and does not establish a regression of that fix.
  • #7396, #5457, #5458 and #7444 concern destination wording, permission classification or recovery guidance. Correcting their messages does not remove the directories created here.
  • #5522 and merged PR #5581 concern failures after admission and earlier bundle-file writes, where partial output is intentionally retained. Here no bundle file is written.
  • #5480 concerns the Evidence file writer's long-path staging cleanup, a different writer and trigger.

No last-known-good version or regression window was established. Prepared with AI assistance and independently reproduced before writing this report.

Triage assessment

  • Impact level: P2
  • Assessment status: supported
  • Impact basis: Both public bundle CLI commands leave three new empty directories after returning an input-validation refusal in each fresh tested case, contrary to the documented pre-write build-refusal contract.
  • Workaround status: available
  • Workaround basis: The same authentic input pair exported successfully to a new nested destination without the refused trailing component; any directories created by a refused attempt in the fixture were empty and removable.
Originally created by @coygeek on GitHub (Oct 27, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/7571 ### 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 With valid capture Evidence, `node scripts/rea.mjs environment-build CAPTURE PARENT/a/b/c/.. --format json` returns an input refusal but leaves newly created `a`, `a/b` and `a/b/c` directories under the initially empty `PARENT`. The result has `code: "invalid_request"`, `category: "invalid_input"`, message `Invalid extraction output root`, and an issue on `directory`. The command exits 1 and writes no bundle files. `node scripts/rea.mjs environment-export MANIFEST --evidence CAPTURE PARENT/a/b/c/.. --format json` produces the same refusal and directory side effect with an authentic capture/manifest pair. This report concerns the directories left by refusal; accepting the selected spelling is not required. ### Reproduction 1. Start at the stated baseline with its existing dependencies and built CLI, using Node.js v24.22.1 on PATH. Run the Python block below from the checkout root. It creates its HAR inline, obtains both inputs through the public CLI, isolates child HOME/XDG/temporary paths, and removes its fixtures on exit. It does not fetch the recorded URL. 2. For each command, the script attempts two independent fresh output paths ending in `/a/b/c/..`. It checks that the parent starts empty, captures the refusal, and lists every remaining descendant. 3. The final calls test the same suffix with build `--dry-run` and a normal nested destination with export. ```python import json, os, pathlib, subprocess, tempfile launcher = str(pathlib.Path('scripts/rea.mjs').resolve()) with tempfile.TemporaryDirectory() as temporary: root = pathlib.Path(temporary).resolve() env = {k: v for k, v in os.environ.items() if not k.startswith(('REA_', 'XDG_', 'HOPPER_', 'GHIDRA_', 'JAVA_'))} env['NODE_DISABLE_COMPILE_CACHE'] = '1' for key in ('HOME', 'XDG_CONFIG_HOME', 'XDG_DATA_HOME', 'XDG_CACHE_HOME', 'XDG_STATE_HOME', 'XDG_RUNTIME_DIR', 'TMPDIR', 'TMP', 'TEMP'): directory = root / key.lower() directory.mkdir() env[key] = str(directory) def run(*args): p = subprocess.run(['node', launcher, *args, '--format', 'json'], env=env, capture_output=True, text=True, timeout=45) return p.returncode, json.loads(p.stdout) timestamp = subprocess.check_output( ['date', '+%Y-%m-%dT%H:%M:%S%z'], env={**env, 'TZ': 'America/Los_Angeles'}, text=True).strip() request = dict(method='GET', url='https://example.test/api/items', httpVersion='HTTP/1.1', headers=[], queryString=[], cookies=[], headersSize=-1, bodySize=0) response = dict(status=200, statusText='OK', httpVersion='HTTP/1.1', headers=[], cookies=[], redirectURL='', headersSize=-1, bodySize=2, content=dict(size=2, mimeType='application/json', text='{}')) har = {'log': {'version': '1.2', 'creator': {'name': 'fixture', 'version': '1'}, 'entries': [dict(startedDateTime=timestamp, time=1, request=request, response=response, cache={}, timings=dict(send=0, wait=1, receive=0))]}} capture_path, manifest_path = root / 'capture.json', root / 'manifest.json' har_path = root / 'session.har' har_path.write_text(json.dumps(har)) code, capture = run('inspect-web-network-capture', str(har_path), 'har') assert code == 0 capture_path.write_text(json.dumps(capture)) view = {'source': {'kind': 'inline', 'evidence': capture}, 'view': {'kind': 'environment-manifest', 'template_rules': ['after-uid-literal', 'opaque-token', 'sibling-variance', 'prefix-variance']}} code, manifest = run('inspect-analysis-view', json.dumps(view)) assert code == 0 manifest_path.write_text(json.dumps(manifest)) for command in ('environment-export', 'environment-build'): for attempt in (1, 2): parent = root / (command + str(attempt)) parent.mkdir() destination = str(parent) + '/a/b/c/..' args = ([str(manifest_path), '--evidence', str(capture_path)] if command == 'environment-export' else [str(capture_path)]) assert list(parent.iterdir()) == [] code, result = run(command, *args, destination) tree = sorted(str(p.relative_to(parent)) for p in parent.rglob('*')) print(command, attempt, 'exit', code, result['code'], result['message'], tree) assert code == 1 and result['code'] == 'invalid_request' assert result['details']['issues'][0]['path'] == ['directory'] assert tree == ['a', 'a/b', 'a/b/c'] assert all(p.is_dir() for p in parent.rglob('*')) dry = root / 'dry' dry.mkdir() code, result = run('environment-build', str(capture_path), str(dry) + '/a/b/c/..', '--dry-run') assert code == 0 and result['status'] == 'planned' and list(dry.iterdir()) == [] print('dry-run:', code, result['status'], 'no output directories') code, result = run('environment-export', str(manifest_path), '--evidence', str(capture_path), str(root / 'valid/a/b/c')) assert code == 0 and result['status'] == 'exported' print('normal nested destination:', code, result['status']) print('fixture and output directories removed') ``` The final recipe reproduced the side effect in 2/2 fresh export cases and 2/2 fresh build cases. Earlier exploratory CLI calls produced the same result. The dry run returned `planned` with no output directories; the normal nested export returned `exported`. ### Expected behavior Rejecting the output path before writing a bundle should leave no newly created directories behind. The environment-build documentation promises: “A refusal before the write leaves nothing behind.” The CLI guide also says build refusals before the bundle is written write nothing. This request is refused at destination admission, with no bundle file created. The export guide separately promises no writes for its enumerated argument/destination refusals; it does not explicitly enumerate a trailing `..`. Both commands showed the same side effect through their shared writer. ### Impact and scope The observed consequence is persistent creation of empty directory chains despite a structured input refusal. Four fresh failing calls in the final recipe each left three directories that were absent before invocation. No existing content was changed, and no bundle file or data loss was observed. This is P2: bounded unintended filesystem changes with a safe practical workaround. A new nested destination without the trailing `..` successfully exported the same capture/manifest inputs. Known empty directories from a refused attempt can be removed by the caller. MCP, other operating systems and other refused destination spellings were not tested. ### REA version Exact output of `node scripts/rea.mjs --version`: ```text 6.8.0 ``` Source baseline: `65f1c447344a423fa2785847dbcea1e9a9e2454a`. The built source launcher was tested; a separately installed npm package was not. ### Runtime and platform Node.js v24.22.1; macOS 27.0.1; arm64. Python 3 generated the synthetic fixture and checked the filesystem. No external analysis provider was used. ### Target and artifact context A HAR 1.2 document containing one synthetic GET request to `https://example.test/api/items` and an empty JSON object response. Capture Evidence and its environment manifest were generated by REA. Each failing destination was inside a fresh empty local test directory. ### Interface CLI ### Earliest failure stage Request parsing or validation ### Relevant output or evidence Observed output from the final recipe: ```text environment-export 1 exit 1 invalid_request Invalid extraction output root ['a', 'a/b', 'a/b/c'] environment-export 2 exit 1 invalid_request Invalid extraction output root ['a', 'a/b', 'a/b/c'] environment-build 1 exit 1 invalid_request Invalid extraction output root ['a', 'a/b', 'a/b/c'] environment-build 2 exit 1 invalid_request Invalid extraction output root ['a', 'a/b', 'a/b/c'] dry-run: 0 planned no output directories normal nested destination: 0 exported fixture and output directories removed ``` Each list is the complete descendant set under that case's initially empty parent. The script asserts that all listed entries are directories. The structured refusal additionally identifies `details.issues[0].path` as `["directory"]`; build reports the bundle stage's operation as `environment-export`. Contract locators at the baseline: `docs/agent-environments.md:100-101` and `docs/cli.md:1022-1030`. Source-derived explanation: `src/application/environment/environmentBundleWrite.ts:153-158` creates the destination's parent recursively before invoking output-tree admission. `src/artifacts/SafeOutputTree.ts:187-197` then refuses the final `..` segment before acquiring an output tree. The build delegates to the exporter at `src/application/environment/environmentBuild.ts:391-405`; the export CLI invokes that exporter at `src/cli/environmentCommands.ts:275-305`. ### Analysis or suspected cause The ordering of parent creation and destination admission explains the observed directories: the missing prefix is materialized before the final component is rejected, and this early creation has no acquired output-tree owner to undo it. This explanation follows from source inspection together with the executed CLI results. No fix was implemented or tested. ### Additional context Open/closed issue searches, explicit comment searches and open/closed/merged PR searches found no matching refusal-side-effect report. Related reports have different closure signals: - [#6865](https://github.com/morluto/rea/issues/6865) concerns successful extraction selecting the wrong physical temporary parent through symlink/`..` handling. Its closed fix addresses path selection, not bundle refusal cleanup. This reproduction requires no symlink and does not establish a regression of that fix. - [#7396](https://github.com/morluto/rea/issues/7396), [#5457](https://github.com/morluto/rea/issues/5457), [#5458](https://github.com/morluto/rea/issues/5458) and [#7444](https://github.com/morluto/rea/issues/7444) concern destination wording, permission classification or recovery guidance. Correcting their messages does not remove the directories created here. - [#5522](https://github.com/morluto/rea/issues/5522) and merged [PR #5581](https://github.com/morluto/rea/pull/5581) concern failures after admission and earlier bundle-file writes, where partial output is intentionally retained. Here no bundle file is written. - [#5480](https://github.com/morluto/rea/issues/5480) concerns the Evidence file writer's long-path staging cleanup, a different writer and trigger. No last-known-good version or regression window was established. Prepared with AI assistance and independently reproduced before writing this report. ## Triage assessment - Impact level: P2 - Assessment status: supported - Impact basis: Both public bundle CLI commands leave three new empty directories after returning an input-validation refusal in each fresh tested case, contrary to the documented pre-write build-refusal contract. - Workaround status: available - Workaround basis: The same authentic input pair exported successfully to a new nested destination without the refused trailing component; any directories created by a refused attempt in the fixture were empty and removable.
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#4511
No description provided.