milestone-prune archive mode follows a pre-existing symlink out of the planning tree #138

Closed
opened 2026-08-15 14:33:39 +00:00 by john · 0 comments
Owner

Source: deep dive F3; strict re-review C-05 (independent convergence)
Verdict: AGREE - low, self-documented residual

Claim as reviewed

The archive containment check is lexical. A pre-existing _archive-<label> path that is itself a symlink outside the tree still resolves lexically inside it, and renameSync then follows the directory symlink.

At HEAD f354864 (v3.3.0)

cadence-core/bin/planning.mjs:3536-3547. The guard is resolvePath(archiveRoot).startsWith(resolvePath(dir) + sep), and the comment at :3541-3543 states the residual in the source: "a pre-existing _archive-<label> that is itself a symlink out of the tree still resolves inside it."

At :3586-3590 archive mode calls mkdirSync(archiveRoot, { recursive: true }) - which succeeds silently against an existing symlink-to-directory - then renameSync(src, join(archiveRoot, String(n))), which resolves through the link at syscall time.

Note

Threat model bounds this hard: it needs a symlink planted inside .planning/, which requires the write access someone would use to edit ROADMAP.md directly.

Fix shape: lstat each existing path component and refuse symlinks; if the archive root exists require a real directory whose real parent descends from the real planning root; refuse any pre-existing per-phase destination. Cheapest to land alongside the prune transactionality fix.

**Source:** deep dive F3; strict re-review C-05 (independent convergence) **Verdict:** AGREE - low, self-documented residual ### Claim as reviewed The archive containment check is lexical. A pre-existing `_archive-<label>` path that is itself a symlink outside the tree still resolves lexically inside it, and `renameSync` then follows the directory symlink. ### At HEAD f354864 (v3.3.0) `cadence-core/bin/planning.mjs:3536-3547`. The guard is `resolvePath(archiveRoot).startsWith(resolvePath(dir) + sep)`, and the comment at `:3541-3543` states the residual in the source: "a pre-existing `_archive-<label>` that is itself a symlink out of the tree still resolves inside it." At `:3586-3590` archive mode calls `mkdirSync(archiveRoot, { recursive: true })` - which succeeds silently against an existing symlink-to-directory - then `renameSync(src, join(archiveRoot, String(n)))`, which resolves through the link at syscall time. ### Note Threat model bounds this hard: it needs a symlink planted inside `.planning/`, which requires the write access someone would use to edit `ROADMAP.md` directly. Fix shape: `lstat` each existing path component and refuse symlinks; if the archive root exists require a real directory whose real parent descends from the real planning root; refuse any pre-existing per-phase destination. Cheapest to land alongside the prune transactionality fix.
john added this to the v3.3.1 milestone 2026-08-15 14:55:51 +00:00
john closed this issue 2026-08-15 15:26:10 +00:00
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: crenshawdev/cadence-archived#138
No description provided.