Repository navigation
bug(gates): decision-coverage-plan and gap-analysis plan-post refuse a phase dir under a symlinked .planning ("path escapes its allowed directory") #5474
Description
-
I have searched existing issues and this bug has not already been reported
-
GSD Version: 1.17.0
-
Runtime: Claude Code
-
Operating System: macOS (27.0.1)
-
Node.js Version: v24.14.0
-
Shell: /bin/zsh
-
Installation Method: Updated from a previous version
What happened?
When .planning is a symlink to a directory outside the repo (the out-of-repo planning layout that #4815 / #4819 made findProjectRoot support), two plan-phase gates refuse every phase directory:
gsd-tools query check.decision-coverage-plan <phase_dir> <context_path>gsd-tools check gap-analysis.plan-post <phase_dir> <req_ids>
Both exit with Error: path escapes its allowed directory: <phase_dir>. That happens with the relative path .planning/phases/<phase> and with the fully resolved real path. So with this layout the decision-coverage and gap-analysis checks never run, and plan-phase has to fall back to checking coverage by hand.
Same commands, same files, with .planning as a real directory: both run normally.
What did you expect?
A phase directory reached through the project's own .planning symlink is inside the project's planning area, so both gates should accept it. They should accept the resolved real path too.
Steps to reproduce
S=$(mktemp -d)
mkdir -p "$S/store/phases/01-demo" "$S/repo"
cd "$S/repo" && git init -q && git commit -q --allow-empty -m init
ln -s "$S/store" .planning # out-of-repo .planning
cd "$S/store" && git init -q
printf '{"commit_docs":false}\n' > config.json
printf '# Roadmap\n\n## Phase 1: Demo\n' > ROADMAP.md
printf '# State\n' > STATE.md
printf -- '---\nphase: 01-demo\nplan: 01\nwave: 1\n---\n# Plan\n' > phases/01-demo/01-01-PLAN.md
printf -- '---\nphase: 01-demo\n---\n# Context\n\n<decisions>\n- **D-01:** use X\n</decisions>\n' > phases/01-demo/01-CONTEXT.md
cd "$S/repo"
G=~/.claude/gsd-core/bin/gsd-tools.cjs
P=.planning/phases/01-demo
node $G query check.decision-coverage-plan $P $P/01-CONTEXT.md
# Error: path escapes its allowed directory: .planning/phases/01-demo
node $G check gap-analysis.plan-post $P REQ-01 --raw
# Error: path escapes its allowed directory: .planning/phases/01-demo
node $G query check.decision-coverage-plan "$S/store/phases/01-demo" "$S/store/phases/01-demo/01-CONTEXT.md"
# Error: path escapes its allowed directory: <S>/store/phases/01-demoControl: copy store into the repo as a real .planning directory, and both gates return their normal JSON verdicts.
Likely cause
resolveContainedPath in bin/lib/gate-phase-context.cjs calls tryWithinRoot(candidate, projectDir, AbsoluteInsideRoot), which is realpath containment (ADR-4650). The realpath of anything under a symlinked .planning is the external store, which is outside projectDir, so the check returns null. The phase locators already resolve the planning directory through planningDir / the #4815 logic; this containment check doesn't take that resolved planning root as an allowed root.
A possible fix: also accept a path whose realpath is inside the realpath of the project's resolved planning directory, but only for that one root (not arbitrary symlink targets). Related: #4908 (one resolved ProjectContext) and #4926 (another containment refusal on legitimate inputs).
Not reproduced on 1.17.0
These worked on the same symlinked layout: query frontmatter.set (relative and real path), state.planned-phase, roadmap.annotate-dependencies, init.*, phase.complete, verify context-drift / codebase-drift, check ui-plan-gate.
Activity
trek-e commentedon Oct 18, 2026
Diagnosis
Reproduced: yes. On the repo's built
gsd-core/bin/gsd-tools.cjs, using the reporter's layout (.planningsymlinked to an external store),query check.decision-coverage-planandcheck gap-analysis.plan-postboth exit 1 withpath escapes its allowed directoryfor the relative.planning/phases/01-demopath and for the store's real path. With a real.planningdirectory (control), the gate returns its normal JSON verdict.Root cause:
src/gate-phase-context.cts:48(resolveContainedPath) callstryWithinRoot(candidate, projectDir, AbsoluteInsideRoot)(src/security.cts:271, enginevalidatePath). That realpaths the candidate and compares it torealpath(projectDir)as the only allowed root. Anything under a symlinked.planningrealpaths into the external store, so the check returns null. The function has no notion of the resolved planning root.Introduced by: 374300d (2026-09-12, #4652, ADR-4650 Phase 2); moved to
gate-phase-context.ctsin #5139 (2026-09-30) with unchanged semantics. It arrived as a side effect of the containment seam, after #4815/#4819 added symlinked-.planningsupport elsewhere. ADR-4650 records no decision about this layout. ADR-5057 (docs/adr/5057-one-owner-per-workflow-verdict.md, "Two containment roots") records the opposite principle: phase artifacts are contained in the planning root, which may be a symlinked out-of-repo store, with "every path still confined, just to the root it actually lives in".Blast radius: all 9
resolveContainedPathcall sites: decision-coverage-plan, decision-coverage-verify, gap-analysis-plan-post, verify-command-paths, evaluation-scope and the--phase-dirof gate-predicate. Gates that resolve phases via the phase locator without containment are unaffected, matching the reporter's "not reproduced" list. Risk: Med (security predicate).Coupled (must not miss):
resolveContainedPathonce, not per gate. The error text stayspath escapes its allowed directory: <arg>..planningonly, and the decision must still go throughtryWithinRoot/assertWithinRoot(ADR-4650 decision 6). No new hand-rolled comparison...traversal and a prefix-sibling directory must still be refused.validatePathitself must not be relaxed.Regression test (write first): with
.planningsymlinked to an out-of-repo store, both gates accept the relative path and the real absolute path and return the same verdict as with a real.planningdirectory. Then, still with the symlinked layout, these stay the usage failure:..traversalWith a real
.planningdirectory, behavior is unchanged.trek-e commentedon Oct 18, 2026
Agent Brief
Category: bug
Summary: The decision-coverage-plan and gap-analysis plan-post gates must accept a phase directory reached through the project's own symlinked, out-of-repo
.planning, without loosening path containment for anything else.Current behavior: With
.planningsymlinked to an external store, both gates exit with the usage errorpath escapes its allowed directory: <path>for every phase directory, whether given relative or as the store's resolved real path. The decision-coverage and gap-analysis checks never run for that layout.Desired behavior: A path that resolves inside the project's own resolved planning directory is treated as contained, in addition to paths inside the project directory. Gates return the same verdicts as with a real
.planningdirectory.Key interfaces:
Acceptance criteria:
.planningsymlinked to an out-of-repo store, both gates accept the relative.planning/phases/<phase>argument and the store's real absolute path, and produce the same verdict as with a real.planningdirectory...traversal, and a sibling directory sharing the store's name prefix are still refused with the existing usage error..planningdirectory, behavior is unchanged.Out of scope:
github-actions commentedon Oct 18, 2026
Branch
fix/5474-bug-gates-decision-coverage-plan-and-gapcreated.git fetch origin && git checkout fix/5474-bug-gates-decision-coverage-plan-and-gaphelenkwok commentedon Oct 18, 2026
Thanks for the triage. I'm picking this up and will open a PR against
nextthat follows the brief: one fix inresolveContainedPath, the realpath of the project's own.planningas the only second root, decided throughtryWithinRoot, withvalidatePathand the error text unchanged.The regression tests come first: both gates accept the relative path and the store's real path, while an in-store symlink pointing out, an absolute path outside both roots,
..traversal and a prefix-sibling directory are still refused.chore(open-gsd#5474): backfill changeset pr number
fix(open-gsd#5474): accept phase dirs under the symlinked .planning i…
chore(open-gsd#5474): backfill changeset pr number
test(open-gsd#5474): address review on gate containment