fix(#2288): archive phase history under the outgoing milestone version (#2323)

* fix(#2288): archive phase history under the outgoing milestone version

phases.clear derived its archive directory from a live getMilestoneInfo()
read, but new-milestone.md switches the milestone BEFORE phases.clear runs,
so phase history was filed under the NEW milestone's <version>-phases/ dir.

Add a --archive-version override (threaded from new-milestone.md, captured
before the switch) with precedence override -> live read -> dated label.
Harden the version label against path traversal on both phases.clear and the
sibling milestone-complete sink (the label is a moved directory name), and
persist the outgoing version via a file + quoted shell expansion so untrusted
STATE.md content is never re-parsed by the shell.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(#2288): backfill PR number 2323 into changesets

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-07-15 22:51:19 -04:00
committed by GitHub
parent b041f101fb
commit 52fab7d9d7
24 changed files with 368 additions and 32 deletions

View File

@@ -0,0 +1,5 @@
---
type: Fixed
pr: 2323
---
**`phases.clear` now archives phase history under the outgoing milestone version, not the newly-switched one** — because `new-milestone` advances the milestone before clearing leftover phases, the phase-history archive was silently misfiled under the new milestone's `<version>-phases/` directory. A new `--archive-version` override on `phases.clear` (threaded from the new-milestone workflow) files the archive under the previous milestone's version; without it, behavior is unchanged. (#2288)

View File

@@ -0,0 +1,5 @@
---
type: Security
pr: 2323
---
**`phases.clear --archive-version` and `milestone complete <version>` now reject version labels containing path separators or `..`** — the milestone version becomes a filesystem directory name that phase directories are moved into, so an unvalidated value could relocate phase history outside `.planning/milestones/`. Both now validate against a strict version-token pattern and fail loudly. (#2288)

View File

@@ -182,9 +182,20 @@ blockers, todos) is preserved across the switch — symmetric with
```bash
_GSD_SHIM_NAME="gsd-tools.cjs"; _GSD_RUNTIME_ROOT="${RUNTIME_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"; GSD_TOOLS="${_GSD_RUNTIME_ROOT}/gsd-core/bin/${_GSD_SHIM_NAME}"; if [ -f "$GSD_TOOLS" ]; then gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${_GSD_RUNTIME_ROOT}/.codex/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${_GSD_RUNTIME_ROOT}/.codex/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif command -v gsd-tools >/dev/null 2>&1; then GSD_TOOLS="$(command -v gsd-tools)"; gsd_run() { "$GSD_TOOLS" "$@"; }; elif [ -f "${CLAUDE_CONFIG_DIR:-$HOME/.claude}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${HERMES_HOME:-$HOME/.hermes}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${HERMES_HOME:-$HOME/.hermes}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${CURSOR_CONFIG_DIR:-$HOME/.cursor}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CURSOR_CONFIG_DIR:-$HOME/.cursor}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${CODEX_HOME:-$HOME/.codex}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CODEX_HOME:-$HOME/.codex}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${GEMINI_CONFIG_DIR:-$HOME/.gemini}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${GEMINI_CONFIG_DIR:-$HOME/.gemini}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${COPILOT_CONFIG_DIR:-$HOME/.copilot}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${COPILOT_CONFIG_DIR:-$HOME/.copilot}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${WINDSURF_CONFIG_DIR:-$HOME/.codeium/windsurf}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${WINDSURF_CONFIG_DIR:-$HOME/.codeium/windsurf}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${AUGMENT_CONFIG_DIR:-$HOME/.augment}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${AUGMENT_CONFIG_DIR:-$HOME/.augment}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${TRAE_CONFIG_DIR:-$HOME/.trae}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${TRAE_CONFIG_DIR:-$HOME/.trae}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${QWEN_CONFIG_DIR:-$HOME/.qwen}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${QWEN_CONFIG_DIR:-$HOME/.qwen}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${CODEBUDDY_CONFIG_DIR:-$HOME/.codebuddy}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CODEBUDDY_CONFIG_DIR:-$HOME/.codebuddy}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${CLINE_CONFIG_DIR:-$HOME/.cline}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CLINE_CONFIG_DIR:-$HOME/.cline}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${GROK_AGENTS_HOME:-$HOME/.agents}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${GROK_AGENTS_HOME:-$HOME/.agents}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${ANTIGRAVITY_CONFIG_DIR:-$HOME/.gemini/antigravity}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${ANTIGRAVITY_CONFIG_DIR:-$HOME/.gemini/antigravity}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${OPENCODE_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/opencode}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${OPENCODE_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/opencode}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${KILO_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/kilo}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${KILO_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/kilo}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; else echo "ERROR: gsd-tools.cjs not found at $GSD_TOOLS and gsd-tools is not on PATH. Run: npx -y @opengsd/gsd-core@latest --claude --local" >&2; exit 1; fi; if [ -n "${CLAUDE_ENV_FILE:-}" ] && [ -n "${GSD_TOOLS:-}" ]; then printf "export PATH='%s':\"\$PATH\"\n" "${GSD_TOOLS%/*}" >> "$CLAUDE_ENV_FILE" 2>/dev/null || true; fi
OUTGOING_MILESTONE=$(gsd_run query state.get milestone --raw 2>/dev/null || true)
printf '%s' "$OUTGOING_MILESTONE" > .planning/.gsd-outgoing-milestone 2>/dev/null || true
echo "Outgoing milestone (phase history archives under THIS version in step 6): ${OUTGOING_MILESTONE:-<unknown>}"
gsd_run query state.milestone-switch --milestone "v[X.Y]" --name "[Name]"
```
**Capture the outgoing version now.** The lines above read the *current* (previous) milestone
version BEFORE the switch flips STATE.md's `milestone:` field to the new one, and persist it to
`.planning/.gsd-outgoing-milestone` so Step 6 can consume it via a shell variable — do NOT
transcribe the echoed value into a later command by hand. Step 6 reads that file back into
`--archive-version` so the previous milestone's phase directories archive under
`<outgoing-version>-phases/`, not the new one (#2288). Once `state.milestone-switch` runs,
current-milestone state no longer holds the outgoing version, which is why it is captured here.
The resulting Current Position section looks like:
```markdown
@@ -206,12 +217,30 @@ SDK handler above — do not hand-edit STATE.md here.
Delete MILESTONE-CONTEXT.md if exists (consumed).
Clear leftover phase directories from the previous milestone:
Clear leftover phase directories from the previous milestone. Read the outgoing version
persisted in Step 5 back into a shell variable and pass it as `--archive-version` so the
archive lands under the *previous* milestone's label — the switch in Step 5 has already
advanced current-milestone state, so without this override the archive would be mislabeled
with the *new* version (#2288). Use the shell variable directly (quoted) — never hand-retype
the captured value into the command, so untrusted STATE.md content cannot be re-parsed by the
shell:
```bash
gsd_run query phases.clear --confirm
OUTGOING_MILESTONE=$(cat .planning/.gsd-outgoing-milestone 2>/dev/null || true)
if [ -n "$OUTGOING_MILESTONE" ]; then
gsd_run query phases.clear --confirm --archive-version "$OUTGOING_MILESTONE"
else
gsd_run query phases.clear --confirm
fi
rm -f .planning/.gsd-outgoing-milestone 2>/dev/null || true
```
If the captured file is empty or absent (a fresh project with no prior milestone), the
fallback branch runs `phases.clear --confirm` with no override — it then uses current-milestone
state, and a dated archive label only if no version label is resolvable at all. `phases.clear`
rejects any `--archive-version` value that is not a plain version token (no path separators or
`..`), so a malformed capture fails loudly rather than writing outside the archive directory.
Stage the phase archive move + source removal so they land in the same commit as the milestone start (atomic — no orphaned uncommitted deletions, no un-archived dirs carried forward). `phases.clear` archives each non-999 dir to `milestones/<version>-phases/`; staging both dirs captures the new archive and the removals together (#1871).
```bash

View File

@@ -37,6 +37,15 @@ const { planningPaths } = planningWorkspace;
const { extractFrontmatter } = frontmatterMod;
const { writeStateMd } = stateMod;
// #2288 security: a milestone version label becomes a filesystem directory
// component (`milestones/<label>-phases/`) into which phase directories are
// MOVED. Any label used as a path segment must be a safe version token —
// letters/digits/'.'/'-'/'_', leading alphanumeric, no path separators and no
// `..` (the leading-alphanumeric anchor rejects a bare `..`). This gates both
// the caller-supplied `--archive-version` override and the STATE.md-derived
// live-read value, so a crafted value cannot escape `.planning/milestones/`.
const ARCHIVE_VERSION_LABEL_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
interface MilestoneCompleteOptions {
name?: string;
force?: boolean;
@@ -263,6 +272,15 @@ function cmdMilestoneComplete(cwd: string, version: string, options: MilestoneCo
if (!version) {
error('version required for milestone complete (e.g., v1.0)');
}
// #2288 security: `version` is a CLI positional that is interpolated into
// multiple filesystem sinks below — `path.join(archiveDir, `${version}-ROADMAP.md`)`,
// `${version}-REQUIREMENTS.md`, `${version}-MILESTONE-AUDIT.md`, and the
// `${version}-phases` archive directory that phase dirs are MOVED into. Reject
// path separators / `..` here (same guard as `--archive-version`) so a crafted
// version cannot write or relocate content outside `.planning/milestones/`.
if (!ARCHIVE_VERSION_LABEL_RE.test(version)) {
error(`milestone complete: version "${version}" is invalid — a milestone version label may contain only letters, digits, '.', '-' and '_', and must not contain path separators or "..".`);
}
const roadmapPath = planningPaths(cwd).roadmap;
const reqPath = planningPaths(cwd).requirements;
@@ -593,6 +611,32 @@ function cmdPhasesClear(cwd: string, raw: boolean, args: string[]): void {
// --force bypasses the uncommitted-changes guard. Only use when the caller
// has already archived or explicitly accepts loss of uncommitted work. (#1447)
const force = Array.isArray(args) && args.includes('--force');
// #2288: explicit outgoing-version override for the archive destination.
// new-milestone.md runs `state.milestone-switch` BEFORE `phases.clear --confirm`,
// so a live read of STATE.md would already report the NEW milestone version by
// the time we get here. Callers that know the outgoing version pass it explicitly;
// absent an override, archivePhaseDirectories falls back to the live read.
const avIndex = Array.isArray(args) ? args.indexOf('--archive-version') : -1;
let archiveVersionOverride: string | null = null;
if (avIndex !== -1) {
const rawArchiveVersion = args[avIndex + 1];
// Missing / flag-shaped value: fail loud instead of silently dropping the
// override. A truncated invocation (e.g. a broken template substitution
// leaving `--archive-version` with no value) must NOT fall through to the
// live read — that silently re-files the archive under the new milestone,
// the exact #2288 bug this flag exists to prevent.
if (typeof rawArchiveVersion !== 'string' || rawArchiveVersion.startsWith('--') || rawArchiveVersion.trim() === '') {
error('--archive-version requires a value (a milestone version token, e.g. v1.0)');
}
const trimmed = rawArchiveVersion.trim();
// #2288 security: reject path separators / `..` so a crafted value cannot
// relocate phase history outside `.planning/milestones/` (phase dirs are
// MOVED into the archive dir — a traversal is data loss, not just an odd name).
if (!ARCHIVE_VERSION_LABEL_RE.test(trimmed)) {
error(`--archive-version "${trimmed}" is invalid — a milestone version label may contain only letters, digits, '.', '-' and '_', and must not contain path separators or "..".`);
}
archiveVersionOverride = trimmed;
}
let cleared = 0;
if (fs.existsSync(phasesDir)) {
@@ -647,7 +691,8 @@ function cmdPhasesClear(cwd: string, raw: boolean, args: string[]): void {
try {
// #1871: archive phase directories instead of destroying them (shared helper).
cleared = archivePhaseDirectories(cwd, phasesDir, dirs).archived;
// #2288: thread the explicit --archive-version override (if any) through.
cleared = archivePhaseDirectories(cwd, phasesDir, dirs, archiveVersionOverride).archived;
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
error('Failed to clear phases directory: ' + message);
@@ -659,17 +704,45 @@ function cmdPhasesClear(cwd: string, raw: boolean, args: string[]): void {
/**
* #1871: move each non-999 phase directory under `phasesDir` into
* `milestones/<version>-phases/` (collision-safe; version from getMilestoneInfo,
* timestamp fallback). Shared by `phases clear` (archive-then-remove) and the
* internal milestone.complete phase archival so phase history survives a
* milestone switch instead of being hard-deleted.
* `milestones/<version>-phases/` (collision-safe). Shared by `phases clear`
* (archive-then-remove) and the internal milestone.complete phase archival so
* phase history survives a milestone switch instead of being hard-deleted.
*
* Archive-version precedence (#2288): an explicit `archiveVersionOverride` wins
* first, then a live `getMilestoneInfo(cwd)` read (which itself defaults to a
* version like `v1.0` when ROADMAP/STATE is absent), and only a dated fallback
* label if no safe version label is resolvable at all. The override is validated
* by the caller (`cmdPhasesClear`); the live-read value is re-validated here
* (defense in depth) because `getMilestoneInfo` derives it from STATE.md's
* unvalidated `milestone:` field. The override exists because `new-milestone.md`
* runs `state.milestone-switch` BEFORE `phases.clear --confirm` — by the time
* this runs, a live read of STATE.md would already report the NEW milestone
* version, so phase history from the OLD milestone would be misfiled under the
* new version's archive directory. Callers that know the outgoing version must
* pass it explicitly.
*/
function archivePhaseDirectories(cwd: string, phasesDir: string, dirs: ReadonlyArray<{ name: string }>): { archiveDir: string; archived: number } {
let archiveVersion: string | null = null;
try {
archiveVersion = getMilestoneInfo(cwd).version ?? null;
} catch {
/* ROADMAP/STATE unreadable — fall back to a dated label */
function archivePhaseDirectories(cwd: string, phasesDir: string, dirs: ReadonlyArray<{ name: string }>, archiveVersionOverride: string | null = null): { archiveDir: string; archived: number } {
// Self-protecting (#2288 security defense in depth): the sole current caller
// (`cmdPhasesClear`) already validates the override, but re-test it here so a
// future caller cannot reopen the path-traversal sink at line ~742. An override
// that fails the safe-label check is discarded (falls through to the live read
// / dated label) rather than reaching `path.join` unvalidated.
const safeOverride = archiveVersionOverride && ARCHIVE_VERSION_LABEL_RE.test(archiveVersionOverride.trim())
? archiveVersionOverride.trim()
: null;
let archiveVersion: string | null = safeOverride;
if (!archiveVersion) {
try {
const liveVersion = getMilestoneInfo(cwd).version ?? null;
// Defense in depth (#2288 security): getMilestoneInfo reads STATE.md's
// `milestone:` field, which is unvalidated file content. Only accept it
// as a path component if it is a safe version label; a crafted value
// (path separators / `..`) falls through to the dated label below rather
// than escaping `.planning/milestones/`.
archiveVersion = liveVersion && ARCHIVE_VERSION_LABEL_RE.test(liveVersion) ? liveVersion : null;
} catch {
/* ROADMAP/STATE unreadable — fall back to a dated label */
}
}
if (!archiveVersion) {
archiveVersion = `archived-${new Date().toISOString().replace(/[-:T]/g, '').slice(0, 8)}`;

View File

@@ -262,7 +262,7 @@
"gsd-core/workflows/map-codebase.md": "c81444eb0b2e038b",
"gsd-core/workflows/milestone-summary.md": "c4620853fe489126",
"gsd-core/workflows/mvp-phase.md": "5f0e355ff4583d67",
"gsd-core/workflows/new-milestone.md": "62aea018fb72b958",
"gsd-core/workflows/new-milestone.md": "949e6046ebdfe0e0",
"gsd-core/workflows/new-project.md": "f336b2da5c3963f0",
"gsd-core/workflows/new-workspace.md": "21bfae58b9cc2990",
"gsd-core/workflows/next.md": "8beefb4335dd7c41",

View File

@@ -333,7 +333,7 @@
"gsd-core/workflows/map-codebase.md": "83d750aade709983",
"gsd-core/workflows/milestone-summary.md": "e0cdfddbd39a9043",
"gsd-core/workflows/mvp-phase.md": "344dd300e0cc1e74",
"gsd-core/workflows/new-milestone.md": "a616efb0f82d1a11",
"gsd-core/workflows/new-milestone.md": "bc020226377af22a",
"gsd-core/workflows/new-project.md": "5ffeef49fe73013e",
"gsd-core/workflows/new-workspace.md": "017688423110ed66",
"gsd-core/workflows/next.md": "13fb800f2472d970",

View File

@@ -332,7 +332,7 @@
"gsd-core/workflows/map-codebase.md": "b11ca99a885e93ef",
"gsd-core/workflows/milestone-summary.md": "99636900c216c8d2",
"gsd-core/workflows/mvp-phase.md": "254baac57e85dca7",
"gsd-core/workflows/new-milestone.md": "fcb63a8b13c02d6e",
"gsd-core/workflows/new-milestone.md": "841285575c6e795f",
"gsd-core/workflows/new-project.md": "26907d3cf3630ed0",
"gsd-core/workflows/new-workspace.md": "26615bf710f0a324",
"gsd-core/workflows/next.md": "1193222c5618d3db",

View File

@@ -261,7 +261,7 @@
"gsd-core/workflows/map-codebase.md": "a45a9aa5c10aeeed",
"gsd-core/workflows/milestone-summary.md": "ad8e69fcb3463ce4",
"gsd-core/workflows/mvp-phase.md": "344dd300e0cc1e74",
"gsd-core/workflows/new-milestone.md": "d93e3fd29f4f77d4",
"gsd-core/workflows/new-milestone.md": "7ec178aa9f921569",
"gsd-core/workflows/new-project.md": "0ea70b800c86fde1",
"gsd-core/workflows/new-workspace.md": "04356c9b675da7c9",
"gsd-core/workflows/next.md": "ef30b829a6d5c5f6",

View File

@@ -265,7 +265,7 @@
"gsd-core/workflows/map-codebase.md": "14c4995c73a671c7",
"gsd-core/workflows/milestone-summary.md": "20e56b9e3902e1d2",
"gsd-core/workflows/mvp-phase.md": "3e3936e1f247ac7c",
"gsd-core/workflows/new-milestone.md": "4fcf29b3cbcd9f83",
"gsd-core/workflows/new-milestone.md": "a85761d8c9f760be",
"gsd-core/workflows/new-project.md": "66bf6135f54ece0e",
"gsd-core/workflows/new-workspace.md": "f5eed5dd9a21878f",
"gsd-core/workflows/next.md": "3f8b61a35587a021",

View File

@@ -333,7 +333,7 @@
"gsd-core/workflows/map-codebase.md": "83d750aade709983",
"gsd-core/workflows/milestone-summary.md": "e0cdfddbd39a9043",
"gsd-core/workflows/mvp-phase.md": "344dd300e0cc1e74",
"gsd-core/workflows/new-milestone.md": "a616efb0f82d1a11",
"gsd-core/workflows/new-milestone.md": "bc020226377af22a",
"gsd-core/workflows/new-project.md": "278e0b88dd1d2ea0",
"gsd-core/workflows/new-workspace.md": "017688423110ed66",
"gsd-core/workflows/next.md": "13fb800f2472d970",

View File

@@ -368,7 +368,7 @@
"gsd-core/workflows/map-codebase.md": "2edab7d924b296ff",
"gsd-core/workflows/milestone-summary.md": "a53b30a84f904b50",
"gsd-core/workflows/mvp-phase.md": "100dded5c7b4cb86",
"gsd-core/workflows/new-milestone.md": "86b4b5245fc971f3",
"gsd-core/workflows/new-milestone.md": "f30f968a4f0912ed",
"gsd-core/workflows/new-project.md": "a9dbf207833897cc",
"gsd-core/workflows/new-workspace.md": "3630b78e83b1e491",
"gsd-core/workflows/next.md": "7097a9607a981086",

View File

@@ -263,7 +263,7 @@
"gsd-core/workflows/map-codebase.md": "b426a450e5d90cc3",
"gsd-core/workflows/milestone-summary.md": "8a53906253984a62",
"gsd-core/workflows/mvp-phase.md": "93304b26c92e4418",
"gsd-core/workflows/new-milestone.md": "e5b527a9ba2c7d31",
"gsd-core/workflows/new-milestone.md": "9909214a1782b31f",
"gsd-core/workflows/new-project.md": "5c34f0cb064568bd",
"gsd-core/workflows/new-workspace.md": "403796bdf6318515",
"gsd-core/workflows/next.md": "81c08abb34b2da88",

View File

@@ -333,7 +333,7 @@
"gsd-core/workflows/map-codebase.md": "3118100462eee628",
"gsd-core/workflows/milestone-summary.md": "6c76386b500cc5d3",
"gsd-core/workflows/mvp-phase.md": "50b189c5f4503369",
"gsd-core/workflows/new-milestone.md": "dbdff965e905117d",
"gsd-core/workflows/new-milestone.md": "efba76f3dd38d87c",
"gsd-core/workflows/new-project.md": "a2b559fba00904eb",
"gsd-core/workflows/new-workspace.md": "9e76e96253d54a57",
"gsd-core/workflows/next.md": "174356dcfd7c6c32",

View File

@@ -262,7 +262,7 @@
"gsd-core/workflows/map-codebase.md": "420caa00abbd73f5",
"gsd-core/workflows/milestone-summary.md": "353fffb60749d892",
"gsd-core/workflows/mvp-phase.md": "4d102523b5d81f95",
"gsd-core/workflows/new-milestone.md": "4dc1da729fd1438c",
"gsd-core/workflows/new-milestone.md": "75944be19e6eda0a",
"gsd-core/workflows/new-project.md": "291d623166b97754",
"gsd-core/workflows/new-workspace.md": "5c78c58ad84e886b",
"gsd-core/workflows/next.md": "8e1fa29751d96564",

View File

@@ -333,7 +333,7 @@
"gsd-core/workflows/map-codebase.md": "c3d6418d64ba2994",
"gsd-core/workflows/milestone-summary.md": "25775eeb4e2747be",
"gsd-core/workflows/mvp-phase.md": "a97708464f103300",
"gsd-core/workflows/new-milestone.md": "74779ede3babb04c",
"gsd-core/workflows/new-milestone.md": "9da5203fabe324c4",
"gsd-core/workflows/new-project.md": "6d8cd0b4fe813374",
"gsd-core/workflows/new-workspace.md": "f58d6a8c0c7796a1",
"gsd-core/workflows/next.md": "888297a0b21888ea",

View File

@@ -326,7 +326,7 @@
"gsd-core/workflows/map-codebase.md": "83d750aade709983",
"gsd-core/workflows/milestone-summary.md": "e0cdfddbd39a9043",
"gsd-core/workflows/mvp-phase.md": "344dd300e0cc1e74",
"gsd-core/workflows/new-milestone.md": "a616efb0f82d1a11",
"gsd-core/workflows/new-milestone.md": "bc020226377af22a",
"gsd-core/workflows/new-project.md": "1ec88027a8e5a2fe",
"gsd-core/workflows/new-workspace.md": "017688423110ed66",
"gsd-core/workflows/next.md": "13fb800f2472d970",

View File

@@ -333,7 +333,7 @@
"gsd-core/workflows/map-codebase.md": "6aa5a52741af0b3e",
"gsd-core/workflows/milestone-summary.md": "321ea8a6a206f38f",
"gsd-core/workflows/mvp-phase.md": "b4d688baa4c60dfc",
"gsd-core/workflows/new-milestone.md": "b34db94432f766cb",
"gsd-core/workflows/new-milestone.md": "66d00a85528a2170",
"gsd-core/workflows/new-project.md": "a3850644ef65bea6",
"gsd-core/workflows/new-workspace.md": "3d82392388ee24c7",
"gsd-core/workflows/next.md": "6719a37b8ce2ac4f",

View File

@@ -229,7 +229,7 @@
"gsd-core/workflows/map-codebase.md": "83d750aade709983",
"gsd-core/workflows/milestone-summary.md": "e0cdfddbd39a9043",
"gsd-core/workflows/mvp-phase.md": "344dd300e0cc1e74",
"gsd-core/workflows/new-milestone.md": "a616efb0f82d1a11",
"gsd-core/workflows/new-milestone.md": "bc020226377af22a",
"gsd-core/workflows/new-project.md": "4c32c69910d55ec0",
"gsd-core/workflows/new-workspace.md": "017688423110ed66",
"gsd-core/workflows/next.md": "13fb800f2472d970",

View File

@@ -262,7 +262,7 @@
"gsd-core/workflows/map-codebase.md": "2328da6a2f180f07",
"gsd-core/workflows/milestone-summary.md": "97ddfc40c6bce906",
"gsd-core/workflows/mvp-phase.md": "5ca64695120d250c",
"gsd-core/workflows/new-milestone.md": "d009cde68c003ca6",
"gsd-core/workflows/new-milestone.md": "a65c567f1f38cc73",
"gsd-core/workflows/new-project.md": "493ae04534a80f97",
"gsd-core/workflows/new-workspace.md": "0d59bb987430003e",
"gsd-core/workflows/next.md": "c13515a80c20d37d",

View File

@@ -262,7 +262,7 @@
"gsd-core/workflows/map-codebase.md": "713561638fb1b88e",
"gsd-core/workflows/milestone-summary.md": "a0063cfbe08f2250",
"gsd-core/workflows/mvp-phase.md": "8ed9f4552350ede8",
"gsd-core/workflows/new-milestone.md": "af5ad8ed5aac6677",
"gsd-core/workflows/new-milestone.md": "fd0f56ebd91dc2db",
"gsd-core/workflows/new-project.md": "32ce66cbeaa26cc5",
"gsd-core/workflows/new-workspace.md": "6b6fbad18d30f783",
"gsd-core/workflows/next.md": "2163195861877de5",

View File

@@ -262,7 +262,7 @@
"gsd-core/workflows/map-codebase.md": "0a2a653ed1603784",
"gsd-core/workflows/milestone-summary.md": "1fdbae19ad06a0a3",
"gsd-core/workflows/mvp-phase.md": "e3c4cade4fe69408",
"gsd-core/workflows/new-milestone.md": "33db858beb76e3c5",
"gsd-core/workflows/new-milestone.md": "7043ae90a2bc0401",
"gsd-core/workflows/new-project.md": "0ff192c54373ab8a",
"gsd-core/workflows/new-workspace.md": "8c2b60fbbf241e03",
"gsd-core/workflows/next.md": "d48cea140a5153cb",

View File

@@ -333,7 +333,7 @@
"gsd-core/workflows/map-codebase.md": "83d750aade709983",
"gsd-core/workflows/milestone-summary.md": "e0cdfddbd39a9043",
"gsd-core/workflows/mvp-phase.md": "344dd300e0cc1e74",
"gsd-core/workflows/new-milestone.md": "a616efb0f82d1a11",
"gsd-core/workflows/new-milestone.md": "bc020226377af22a",
"gsd-core/workflows/new-project.md": "fdb53bee6b4178ad",
"gsd-core/workflows/new-workspace.md": "017688423110ed66",
"gsd-core/workflows/next.md": "13fb800f2472d970",

View File

@@ -14,6 +14,7 @@ const { execSync } = require('child_process');
const fs = require('fs');
const path = require('path');
const { runGsdTools, createTempProject, createTempGitProject, cleanup } = require('./helpers.cjs');
const { writeState } = require('./fixtures/index.cjs');
describe('phases clear command', () => {
let tmpDir;
@@ -243,6 +244,229 @@ describe('phases clear: uncommitted-changes guard (#1447)', () => {
});
});
// ─── #2288: --archive-version override ──────────────────────────────────────
describe('phases clear: archive-version override (#2288)', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('override wins over live milestone state (new-milestone switches STATE before phases.clear)', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
'# Roadmap\n\n## v2.0 — Active Milestone\n'
);
writeState(tmpDir, '---\nmilestone: v2.0\n---\n\n# State\n');
const phasesDir = path.join(tmpDir, '.planning', 'phases');
const phase1 = path.join(phasesDir, '01-foundation');
fs.mkdirSync(phase1, { recursive: true });
fs.writeFileSync(path.join(phase1, '01-01-PLAN.md'), '# Plan');
const result = runGsdTools('phases clear --confirm --archive-version v1.0', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.cleared, 1, 'should report 1 directory cleared');
assert.ok(
fs.existsSync(path.join(tmpDir, '.planning', 'milestones', 'v1.0-phases', '01-foundation')),
'phase history should archive under the OLD (override) version'
);
assert.ok(
!fs.existsSync(path.join(tmpDir, '.planning', 'milestones', 'v2.0-phases')),
'phase history must NOT be misfiled under the live-read NEW version'
);
});
test('no override falls back to live milestone version (no behavior change)', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
'# Roadmap\n\n## v2.0 — Active Milestone\n'
);
writeState(tmpDir, '---\nmilestone: v2.0\n---\n\n# State\n');
const phasesDir = path.join(tmpDir, '.planning', 'phases');
const phase1 = path.join(phasesDir, '01-foundation');
fs.mkdirSync(phase1, { recursive: true });
fs.writeFileSync(path.join(phase1, '01-01-PLAN.md'), '# Plan');
const result = runGsdTools('phases clear --confirm', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
assert.ok(
fs.existsSync(path.join(tmpDir, '.planning', 'milestones', 'v2.0-phases', '01-foundation')),
'without an override, the live-read milestone version should still be used'
);
});
test('override with unchanged version (boundary: old === new)', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
'# Roadmap\n\n## v3.0 — Active Milestone\n'
);
writeState(tmpDir, '---\nmilestone: v3.0\n---\n\n# State\n');
const phasesDir = path.join(tmpDir, '.planning', 'phases');
const phase1 = path.join(phasesDir, '01-foundation');
fs.mkdirSync(phase1, { recursive: true });
fs.writeFileSync(path.join(phase1, '01-01-PLAN.md'), '# Plan');
const result = runGsdTools('phases clear --confirm --archive-version v3.0', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
assert.ok(
fs.existsSync(path.join(tmpDir, '.planning', 'milestones', 'v3.0-phases', '01-foundation')),
'override equal to the live version should archive normally'
);
});
test('override omitted with no ROADMAP uses getMilestoneInfo default (no-override path unchanged)', () => {
// createTempProject writes no ROADMAP.md, so getMilestoneInfo does NOT throw —
// it returns its documented default version ('v1.0'). The dated `archived-*`
// label is only reached if no safe version label is resolvable at all, which
// this common case is not. This pins the no-override path to its pre-#2288
// behavior (getMilestoneInfo-derived label), not a dated fallback.
// eslint-disable-next-line local/no-raw-rmsync-in-tests -- ensure no ROADMAP.md (SUT fallback path, not teardown)
fs.rmSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), { recursive: true, force: true });
const phasesDir = path.join(tmpDir, '.planning', 'phases');
const phase1 = path.join(phasesDir, '01-foundation');
fs.mkdirSync(phase1, { recursive: true });
fs.writeFileSync(path.join(phase1, '01-01-PLAN.md'), '# Plan');
const result = runGsdTools('phases clear --confirm', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
assert.ok(
fs.existsSync(path.join(tmpDir, '.planning', 'milestones', 'v1.0-phases', '01-foundation')),
'no override + no ROADMAP archives under getMilestoneInfo default (v1.0), unchanged from pre-#2288'
);
});
test('rejects an --archive-version containing path traversal (no phase dir escapes .planning) (#2288 security)', () => {
const phasesDir = path.join(tmpDir, '.planning', 'phases');
const phase1 = path.join(phasesDir, '01-foundation');
fs.mkdirSync(phase1, { recursive: true });
fs.writeFileSync(path.join(phase1, '01-01-PLAN.md'), '# Plan');
// A traversal payload as the archive-version must be rejected outright — the
// value becomes a MOVED directory name, so accepting it would relocate phase
// history outside .planning/milestones/.
const result = runGsdTools(
'phases clear --confirm --archive-version ../../../gsd-poc-escape',
tmpDir,
);
assert.ok(!result.success, 'phases clear must FAIL on a path-traversal --archive-version');
// The phase directory must NOT have moved anywhere — it stays put.
assert.ok(
fs.existsSync(phase1),
'phase dir must remain in place when the archive-version is rejected',
);
// Nothing may have been created outside the project's milestones dir.
assert.ok(
!fs.existsSync(path.join(tmpDir, '..', 'gsd-poc-escape-phases')),
'no directory may be created outside the project via traversal',
);
assert.ok(
!fs.existsSync(path.join(tmpDir, '.planning', 'milestones')),
'no archive dir should be created at all when the override is rejected',
);
});
test('rejects backslash path separators in --archive-version (#2288 security)', () => {
const phasesDir = path.join(tmpDir, '.planning', 'phases');
const phase1 = path.join(phasesDir, '01-foundation');
fs.mkdirSync(phase1, { recursive: true });
fs.writeFileSync(path.join(phase1, '01-01-PLAN.md'), '# Plan');
// Starts with an alphanumeric so it clears the leading-char anchor — this
// proves the backslash (Windows path separator) itself is rejected, not just
// a leading-dot traversal.
const result = runGsdTools(
'phases clear --confirm --archive-version v1\\\\..\\\\evil',
tmpDir,
);
assert.ok(!result.success, 'phases clear must FAIL on a backslash-separator --archive-version');
assert.ok(fs.existsSync(phase1), 'phase dir must remain in place');
});
test('errors when --archive-version is present but its value is missing (does not silently drop the override) (#2288)', () => {
// A truncated invocation must fail loud, not fall through to the live read
// (which would silently re-file under the new milestone — the #2288 bug).
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
'# Roadmap\n\n## v2.0 — Active Milestone\n'
);
writeState(tmpDir, '---\nmilestone: v2.0\n---\n\n# State\n');
const phasesDir = path.join(tmpDir, '.planning', 'phases');
const phase1 = path.join(phasesDir, '01-foundation');
fs.mkdirSync(phase1, { recursive: true });
fs.writeFileSync(path.join(phase1, '01-01-PLAN.md'), '# Plan');
// --archive-version as the final token with no value following it.
const result = runGsdTools('phases clear --confirm --archive-version', tmpDir);
assert.ok(!result.success, 'a value-less --archive-version must be an error, not a silent fallback');
assert.ok(fs.existsSync(phase1), 'phase dir must remain in place when the flag is rejected');
});
});
// ─── #2288: sibling sink — `milestone complete <version>` path safety ───────
describe('milestone complete: version path-traversal guard (#2288 security)', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('rejects a milestone-complete version containing path traversal (no write/move escapes .planning)', () => {
const phasesDir = path.join(tmpDir, '.planning', 'phases');
const phase1 = path.join(phasesDir, '01-foundation');
fs.mkdirSync(phase1, { recursive: true });
fs.writeFileSync(path.join(phase1, '01-01-PLAN.md'), '# Plan');
// `version` is interpolated into `${version}-ROADMAP.md`, `${version}-phases`,
// etc. as a MOVED/written path component — a traversal value must be rejected
// before any filesystem mutation.
const result = runGsdTools('milestone complete ../../../gsd-ms-escape', tmpDir);
assert.ok(!result.success, 'milestone complete must FAIL on a path-traversal version');
// No artifact created outside the project via traversal.
assert.ok(
!fs.existsSync(path.join(tmpDir, '..', 'gsd-ms-escape-phases')),
'no directory may be created outside the project via a traversal version',
);
assert.ok(
!fs.existsSync(path.join(tmpDir, '..', 'gsd-ms-escape-ROADMAP.md')),
'no file may be written outside the project via a traversal version',
);
// Phase dir untouched.
assert.ok(fs.existsSync(phase1), 'phase dir must remain in place when the version is rejected');
});
test('rejects a backslash-separator milestone-complete version (#2288 security)', () => {
const phasesDir = path.join(tmpDir, '.planning', 'phases');
const phase1 = path.join(phasesDir, '01-foundation');
fs.mkdirSync(phase1, { recursive: true });
fs.writeFileSync(path.join(phase1, '01-01-PLAN.md'), '# Plan');
const result = runGsdTools('milestone complete v1\\\\..\\\\evil', tmpDir);
assert.ok(!result.success, 'milestone complete must FAIL on a backslash-separator version');
assert.ok(fs.existsSync(phase1), 'phase dir must remain in place');
});
});
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/enh-2433-todo-phase-linking.test.cjs — consolidation epic #1969 (B4 #1973)

View File

@@ -44,7 +44,7 @@
"map-codebase.md": 20833,
"milestone-summary.md": 11842,
"mvp-phase.md": 13626,
"new-milestone.md": 32888,
"new-milestone.md": 35045,
"new-project.md": 66182,
"new-workspace.md": 11298,
"next.md": 20138,