fix(#4652): use the validated path, collapse the duplication, correct two false claims
Seven findings from the two-axis review, all fixed in place. THE ONE THAT MATTERS: cmdTodoComplete validated sourcePath and targetPath and then ran every fs call against the RAW strings — existsSync, statSync, readFileSync, platformWriteSync, unlinkSync, and the dry-run path payload — never sourceCheck.resolved / targetCheck.resolved. That is the exact "validate one path, use another" shape ADR-4650 names as the defect this epic exists to prevent, and it is the same bug this phase had just fixed in check-command-router. Committed inside the fix for it. All I/O now uses the resolved paths; user-facing messages still echo the raw filename, never a resolved absolute path. A VACUOUS TEST, and the false doc claim it was propping up. The test "[RED #4327] an absolute path outside the project is rejected" would have passed with ZERO containment logic: path.join(pendingDir, '/abs/outside/x') yields <pendingDir>/abs/outside/x — Node does not let a later absolute segment escape — so the name is FOLDED under the root, passes containment, and simply 404s. The test only ever observed "Todo not found". It now asserts what is actually true and actually valuable: an absolute name is neutralized, and the real outside file is not read, not moved, and still present afterward. docs/CLI-TOOLS.md claimed such a path "is rejected as a usage error", which was false; it now describes the fold-under-root behavior. Traversal and embedded separators ARE rejected, and those claims stand. DUPLICATION THIS EPIC EXISTS TO REMOVE. resolvePath already did isAbsolute-or-join + validatePath + reject; cmdGapAnalysisPlanPost and cmdCheckPredicate each re-inlined the identical triplet in the same file. Both now call resolvePath. Cost, stated rather than hidden: its generic message replaces the two sites' distinct "phase-dir escapes…" wording. The message still names the offending input, and one predicate with one message is the point. SYMLINK COVERAGE was required by #4652's "Done when" and was missing. Added for both the todos root and --phase-dir, skipping cleanly on EPERM so the Windows lanes do not fail where unprivileged symlink creation is disallowed. Both fast-check properties were UNSEEDED. Seeded now. The changeset named "check decision-coverage-plan" as a boundary; that is a caller of the shared resolvePath, which the body never mentioned. Corrected. DISCLOSED, not hidden: ctx.phaseDir is now always the resolved ABSOLUTE path, so ${PHASE_DIR} interpolation and the "not found in <targetDir>" message show an absolute value where a relative --phase-dir previously produced a relative one. That is an observable output change. A test pins it and docs/reference/gate-predicates.md states it. Also regenerated scripts/lib/platform-conformance-tier.generated.cjs and its macos twin — the new tests changed check-predicate.test.cjs's tier classification. Caught by npm run lint:ci locally rather than by a bench run. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1178,12 +1178,17 @@ from `todos/pending/` to `todos/completed/` and upserts `completed:` and
|
||||
rejected loudly.
|
||||
|
||||
`<filename>` is a **basename inside the todos root**, not a path. A value that
|
||||
resolves outside that root — a traversal like `../../escaped`, an embedded
|
||||
separator like `sub/name.md`, or an absolute path — is rejected as a usage
|
||||
error **before** any file is read or moved (#4327). The check covers both halves
|
||||
of the move, so neither the source nor the destination can land outside the
|
||||
root, and `--dry-run` is rejected on the same terms rather than previewing a
|
||||
resolved outside path.
|
||||
resolves outside that root — a traversal like `../../escaped` or an embedded
|
||||
separator like `sub/name.md` — is rejected as a usage error **before** any file
|
||||
is read or moved (#4327). An absolute path is handled differently: it is
|
||||
**folded under the todos root** (Node's `path.join` does not let a later
|
||||
absolute segment escape a prior one), so it cannot reach a file outside the
|
||||
root — it simply fails with the ordinary "Todo not found" error unless a file
|
||||
of that joined name happens to exist under `todos/pending/`; it is not
|
||||
rejected as a containment violation. The check covers both halves of the move,
|
||||
so neither the source nor the destination can land outside the root, and
|
||||
`--dry-run` is rejected on the same terms rather than previewing a resolved
|
||||
outside path.
|
||||
|
||||
```bash
|
||||
# UAT audit — scan all phases for unresolved items
|
||||
|
||||
@@ -88,7 +88,10 @@ rejected as a usage error rather than evaluated. This applies to both kinds —
|
||||
`command-exit-zero` interpolates it into `${PHASE_DIR}` — so an unconfined value
|
||||
would let a **blocking** gate return `block: false` on evidence from a directory
|
||||
the caller chose (#4354). An absolute path inside the project is still accepted;
|
||||
absolute is not a synonym for escaping.
|
||||
absolute is not a synonym for escaping. `${PHASE_DIR}` always interpolates the
|
||||
**resolved absolute path**, even when `--phase-dir` was given as a relative
|
||||
value — a command relying on `${PHASE_DIR}` staying relative must not assume
|
||||
that.
|
||||
|
||||
**Sandbox.** cwd = project root; env = inherited from the GSD process; killed
|
||||
(SIGTERM) on timeout. The command runs as the user, on the user's machine —
|
||||
|
||||
Reference in New Issue
Block a user