[GH-ISSUE #6865] [Bug] extract-artifact writes under the wrong TMPDIR parent after a directory symlink and .. #4206

Closed
opened 2026-10-31 00:47:42 +00:00 by bsakaguchi · 1 comment
Owner

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

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

extract-artifact succeeds but places its output beneath a different existing directory when the POSIX TMPDIR value contains a directory symlink followed by ...

In the executed fixture, wrong/alias points to selected/child, so TMPDIR=wrong/alias/.. names selected. Filesystem identity checking confirms that selection. REA instead creates wrong/rea-extracted-<uuid>. The selected parent stays empty. Passing the canonical selected path creates the output in the intended parent. Both extracted payloads are exact.

Reproduction

Use the stated built checkout. Save this executed probe as /tmp/tmpdir-parent-repro.py, then run it from the repository root with Node v24.22.1 on PATH:

REA_EVIDENCE_ROOT=/tmp python3 /tmp/tmpdir-parent-repro.py
from pathlib import Path
import json, os, subprocess, tempfile, zipfile
launcher = str(Path('scripts/rea.mjs').resolve())
r = Path(tempfile.mkdtemp(prefix='tmpdir-parent-', dir=os.environ['REA_EVIDENCE_ROOT']))
(r/'wrong').mkdir(); (r/'selected/child').mkdir(parents=True); (r/'home').mkdir()
(r/'wrong/alias').symlink_to(r/'selected/child', target_is_directory=True)
temporary = str(r/'wrong/alias') + '/..'
assert os.path.samefile(temporary, r/'selected')
archive = r/'fixture.zip'
with zipfile.ZipFile(archive, 'w', compression=zipfile.ZIP_DEFLATED) as z:
    z.writestr('payload.txt', b'fixture bytes\n')
with zipfile.ZipFile(archive) as z:
    assert z.testzip() is None and z.read('payload.txt') == b'fixture bytes\n'
env = {**os.environ, 'HOME':str(r/'home'), 'XDG_CONFIG_HOME':str(r/'home/config'), 'XDG_CACHE_HOME':str(r/'home/cache')}
for name, temporary in [('selected-path', temporary), ('canonical-control', str(r/'selected'))]:
    p = subprocess.run(['node', launcher, 'extract-artifact', str(archive), '--json'], env={**env, 'TMPDIR':temporary}, capture_output=True, text=True, timeout=45)
    (r/(name+'.json')).write_text(p.stdout); (r/(name+'.stderr')).write_text(p.stderr)
    result = json.loads(p.stdout)['normalized_result']
    actual = Path(result['output_root'])
    print(name, 'exit', p.returncode, 'selected_parent', os.path.realpath(temporary), 'actual_parent', actual.parent)
    assert (actual/'payload.txt').read_bytes() == b'fixture bytes\n'
    assert actual.parent == (r/'wrong' if name == 'selected-path' else r/'selected')
    if name == 'selected-path': assert not list((r/'selected').glob('rea-extracted-*'))
print('ZIP validated; both payloads exact; canonical control selects intended parent')
print('evidence', r)

One selected-path extraction and one canonical-path control ran in the final probe; an earlier equivalent fixture produced the same result. The ZIP is ordinary Python-generated DEFLATE with one regular member; testzip() passed before extraction.

Expected behavior

Create the temporary output beneath the directory named by TMPDIR, following the filesystem's POSIX symlink/parent semantics. The canonical spelling of that same directory should select the same physical parent.

The destination helper explicitly selects os.tmpdir() as the output parent. REA's existing caller-path contract explains that POSIX resolves .. after following the preceding directory symlink and that lexical normalization can select a different file. The executed samefile() oracle and canonical control establish the intended parent independently.

Impact and scope

P2: the configured temporary-output parent is ignored for this valid POSIX path form. The returned output path truthfully names the actual wrong location, and payload bytes remain correct; no data loss or security consequence is claimed. The verified workaround is to pass the filesystem-canonical parent in TMPDIR. Linux CLI behavior was observed; macOS, Windows and MCP were not run.

REA version

Built main source 4526862728af01f1135714079c0d8a8c754eb364; public launcher reports 6.7.0. Latest published release: rea-agents-6.7.0, tag commit 27dc42d10249f0410904a75c0997842eb03b931c. The release tag was compared in source; its packaged runtime was not executed.

Runtime and platform

Node.js v24.22.1; Linux x86_64; repository-locked dependencies and prepared build. Python 3 standard library generated the fixtures and checked the filesystem. No native provider or target executable was launched.

Target and artifact context

Self-authored, CRC-valid DEFLATE ZIP containing payload.txt with fixture bytes\n. The temporary-directory tree and relative parent traversal are entirely within the disposable fixture; archive member names contain no traversal or symlinks.

Interface

CLI

Earliest failure stage

Native analysis or reconstruction

Relevant output or evidence

Observed parent comparison, with only the disposable prefix shortened:

selected-path: exit 0
  filesystem-selected parent: <fixture>/selected
  actual output parent:       <fixture>/wrong
  selected parent entries:    []
canonical-control: exit 0
  filesystem-selected parent: <fixture>/selected
  actual output parent:       <fixture>/selected
ZIP validated; both payloads exact

Evidence provenance: observed public CLI results and filesystem readback. Local verification: reproduced. Reproduction completeness: complete. The probe checks source ZIP CRC/content and reads each extracted payload; it does not execute target code or use mocks.

Typed outcome or recovery expected

Keep successful extraction but allocate under the selected physical temporary parent, as the canonical-path control does. No new destination argument or archive path-normalization change is required.

Analysis or suspected cause

Source-derived: createArtifactExtractionDestination calls join(tmpdir(), randomName). On POSIX, join removes alias/.. lexically before filesystem resolution. The CLI passes that already-altered output root to extraction; destination admission resolves the resulting wrong parent. The destination-helper file is byte-identical in the latest release tag; its packaged runtime was not separately executed.

Additional context

Open/closed searches and the 1,069-entry open-issue snapshot found no matching extraction temporary-parent report. #6828 concerns an independent IDA workspaceRoot allocator, and #5492 concerns plist input selection. Fixing either owner leaves this extraction helper unchanged. #3490 asks for destination and subset options; honoring the currently consumed TMPDIR does not add those features.

No fix or last-known-good runtime was established. Prepared with AI assistance. Verification finished before this draft was written.

Triage assessment

  • Impact level: P2
  • Assessment status: supported
  • Impact basis: A public extraction writes to a different existing temporary parent than the configured POSIX path names; a filesystem-identity oracle and canonical-path control distinguish the wrong allocation.
  • Workaround status: available
  • Workaround basis: Passing the canonical selected parent in TMPDIR was executed and produced the exact payload beneath the intended directory.
Originally created by @coygeek on GitHub (Oct 23, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/6865 ## 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 `extract-artifact` succeeds but places its output beneath a different existing directory when the POSIX `TMPDIR` value contains a directory symlink followed by `..`. In the executed fixture, `wrong/alias` points to `selected/child`, so `TMPDIR=wrong/alias/..` names `selected`. Filesystem identity checking confirms that selection. REA instead creates `wrong/rea-extracted-<uuid>`. The selected parent stays empty. Passing the canonical `selected` path creates the output in the intended parent. Both extracted payloads are exact. ## Reproduction Use the stated built checkout. Save this executed probe as `/tmp/tmpdir-parent-repro.py`, then run it from the repository root with Node v24.22.1 on `PATH`: ```sh REA_EVIDENCE_ROOT=/tmp python3 /tmp/tmpdir-parent-repro.py ``` ```python from pathlib import Path import json, os, subprocess, tempfile, zipfile launcher = str(Path('scripts/rea.mjs').resolve()) r = Path(tempfile.mkdtemp(prefix='tmpdir-parent-', dir=os.environ['REA_EVIDENCE_ROOT'])) (r/'wrong').mkdir(); (r/'selected/child').mkdir(parents=True); (r/'home').mkdir() (r/'wrong/alias').symlink_to(r/'selected/child', target_is_directory=True) temporary = str(r/'wrong/alias') + '/..' assert os.path.samefile(temporary, r/'selected') archive = r/'fixture.zip' with zipfile.ZipFile(archive, 'w', compression=zipfile.ZIP_DEFLATED) as z: z.writestr('payload.txt', b'fixture bytes\n') with zipfile.ZipFile(archive) as z: assert z.testzip() is None and z.read('payload.txt') == b'fixture bytes\n' env = {**os.environ, 'HOME':str(r/'home'), 'XDG_CONFIG_HOME':str(r/'home/config'), 'XDG_CACHE_HOME':str(r/'home/cache')} for name, temporary in [('selected-path', temporary), ('canonical-control', str(r/'selected'))]: p = subprocess.run(['node', launcher, 'extract-artifact', str(archive), '--json'], env={**env, 'TMPDIR':temporary}, capture_output=True, text=True, timeout=45) (r/(name+'.json')).write_text(p.stdout); (r/(name+'.stderr')).write_text(p.stderr) result = json.loads(p.stdout)['normalized_result'] actual = Path(result['output_root']) print(name, 'exit', p.returncode, 'selected_parent', os.path.realpath(temporary), 'actual_parent', actual.parent) assert (actual/'payload.txt').read_bytes() == b'fixture bytes\n' assert actual.parent == (r/'wrong' if name == 'selected-path' else r/'selected') if name == 'selected-path': assert not list((r/'selected').glob('rea-extracted-*')) print('ZIP validated; both payloads exact; canonical control selects intended parent') print('evidence', r) ``` One selected-path extraction and one canonical-path control ran in the final probe; an earlier equivalent fixture produced the same result. The ZIP is ordinary Python-generated DEFLATE with one regular member; `testzip()` passed before extraction. ## Expected behavior Create the temporary output beneath the directory named by `TMPDIR`, following the filesystem's POSIX symlink/parent semantics. The canonical spelling of that same directory should select the same physical parent. The [destination helper](https://github.com/morluto/rea/blob/4526862728af01f1135714079c0d8a8c754eb364/src/application/artifacts/ArtifactExtractionDestination.ts#L1-L7) explicitly selects `os.tmpdir()` as the output parent. REA's [existing caller-path contract](https://github.com/morluto/rea/blob/4526862728af01f1135714079c0d8a8c754eb364/src/filesystem/callerPath.ts#L3-L11) explains that POSIX resolves `..` after following the preceding directory symlink and that lexical normalization can select a different file. The executed `samefile()` oracle and canonical control establish the intended parent independently. ## Impact and scope P2: the configured temporary-output parent is ignored for this valid POSIX path form. The returned output path truthfully names the actual wrong location, and payload bytes remain correct; no data loss or security consequence is claimed. The verified workaround is to pass the filesystem-canonical parent in `TMPDIR`. Linux CLI behavior was observed; macOS, Windows and MCP were not run. ## REA version Built main source `4526862728af01f1135714079c0d8a8c754eb364`; public launcher reports `6.7.0`. Latest published release: `rea-agents-6.7.0`, tag commit `27dc42d10249f0410904a75c0997842eb03b931c`. The release tag was compared in source; its packaged runtime was not executed. ## Runtime and platform Node.js v24.22.1; Linux x86_64; repository-locked dependencies and prepared build. Python 3 standard library generated the fixtures and checked the filesystem. No native provider or target executable was launched. ## Target and artifact context Self-authored, CRC-valid DEFLATE ZIP containing `payload.txt` with `fixture bytes\n`. The temporary-directory tree and relative parent traversal are entirely within the disposable fixture; archive member names contain no traversal or symlinks. ## Interface CLI ## Earliest failure stage Native analysis or reconstruction ## Relevant output or evidence Observed parent comparison, with only the disposable prefix shortened: ```text selected-path: exit 0 filesystem-selected parent: <fixture>/selected actual output parent: <fixture>/wrong selected parent entries: [] canonical-control: exit 0 filesystem-selected parent: <fixture>/selected actual output parent: <fixture>/selected ZIP validated; both payloads exact ``` Evidence provenance: observed public CLI results and filesystem readback. Local verification: reproduced. Reproduction completeness: complete. The probe checks source ZIP CRC/content and reads each extracted payload; it does not execute target code or use mocks. ## Typed outcome or recovery expected Keep successful extraction but allocate under the selected physical temporary parent, as the canonical-path control does. No new destination argument or archive path-normalization change is required. ## Analysis or suspected cause Source-derived: [`createArtifactExtractionDestination`](https://github.com/morluto/rea/blob/4526862728af01f1135714079c0d8a8c754eb364/src/application/artifacts/ArtifactExtractionDestination.ts#L5-L7) calls `join(tmpdir(), randomName)`. On POSIX, `join` removes `alias/..` lexically before filesystem resolution. [The CLI](https://github.com/morluto/rea/blob/4526862728af01f1135714079c0d8a8c754eb364/src/cli/artifactCommands.ts#L213-L222) passes that already-altered output root to extraction; [destination admission](https://github.com/morluto/rea/blob/4526862728af01f1135714079c0d8a8c754eb364/src/artifacts/SafeOutputTree.ts#L102-L120) resolves the resulting wrong parent. The destination-helper file is byte-identical in the latest release tag; its packaged runtime was not separately executed. ## Additional context Open/closed searches and the 1,069-entry open-issue snapshot found no matching extraction temporary-parent report. [#6828](https://github.com/morluto/rea/issues/6828) concerns an independent IDA `workspaceRoot` allocator, and [#5492](https://github.com/morluto/rea/issues/5492) concerns plist input selection. Fixing either owner leaves this extraction helper unchanged. [#3490](https://github.com/morluto/rea/issues/3490) asks for destination and subset options; honoring the currently consumed TMPDIR does not add those features. No fix or last-known-good runtime was established. Prepared with AI assistance. Verification finished before this draft was written. ## Triage assessment - Impact level: P2 - Assessment status: supported - Impact basis: A public extraction writes to a different existing temporary parent than the configured POSIX path names; a filesystem-identity oracle and canonical-path control distinguish the wrong allocation. - Workaround status: available - Workaround basis: Passing the canonical selected parent in TMPDIR was executed and produced the exact payload beneath the intended directory.
Author
Owner

@morluto commented on GitHub (Oct 23, 2026):

Resolved on main in 5e8f8b316. The destination owner preserves TMPDIR spelling until physical resolution. The executed real-filesystem control resolves symlink/.. to the selected parent. Validation: src/application/artifacts/ArtifactCallerPaths.test.ts — allocates extraction in the physical TMPDIR parent selected through a symlink and ..; recorded execution at a732bd065. POSIX filesystem test on macOS; no Linux rerun or public CLI replay. Later test additions concern other path cases; the destination owner is unchanged.

<!-- gh-comment-id:6376122051 --> @morluto commented on GitHub (Oct 23, 2026): Resolved on main in [5e8f8b316](https://github.com/morluto/rea/commit/5e8f8b3163e4ff731b2c391a897e5b7e6a0f1a90). The destination owner preserves TMPDIR spelling until physical resolution. The executed real-filesystem control resolves symlink/.. to the selected parent. Validation: `src/application/artifacts/ArtifactCallerPaths.test.ts` — allocates extraction in the physical TMPDIR parent selected through a symlink and ..; recorded execution at `a732bd065`. POSIX filesystem test on macOS; no Linux rerun or public CLI replay. Later test additions concern other path cases; the destination owner is unchanged.
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#4206
No description provided.