Skip to content

bug(gates): decision-coverage-plan and gap-analysis plan-post refuse a phase dir under a symlinked .planning ("path escapes its allowed directory") #5474

Open

Description

@helenkwok
  • 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-demo

Control: 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

  1. trek-e commented on Oct 18, 2026

    @trek-e
    Collaborator

    This was generated by AI during triage.

    Diagnosis

    Reproduced: yes. On the repo's built gsd-core/bin/gsd-tools.cjs, using the reporter's layout (.planning symlinked to an external store), query check.decision-coverage-plan and check gap-analysis.plan-post both exit 1 with path escapes its allowed directory for the relative .planning/phases/01-demo path and for the store's real path. With a real .planning directory (control), the gate returns its normal JSON verdict.

    Root cause: src/gate-phase-context.cts:48 (resolveContainedPath) calls tryWithinRoot(candidate, projectDir, AbsoluteInsideRoot) (src/security.cts:271, engine validatePath). That realpaths the candidate and compares it to realpath(projectDir) as the only allowed root. Anything under a symlinked .planning realpaths 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.cts in #5139 (2026-09-30) with unchanged semantics. It arrived as a side effect of the containment seam, after #4815/#4819 added symlinked-.planning support 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 resolveContainedPath call sites: decision-coverage-plan, decision-coverage-verify, gap-analysis-plan-post, verify-command-paths, evaluation-scope and the --phase-dir of 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):

    • Fix resolveContainedPath once, not per gate. The error text stays path escapes its allowed directory: <arg>.
    • The second allowed root must be the realpath of the project's own .planning only, and the decision must still go through tryWithinRoot/assertWithinRoot (ADR-4650 decision 6). No new hand-rolled comparison.
    • A symlink inside the store that points outside it, an absolute path outside both roots, .. traversal and a prefix-sibling directory must still be refused.
    • validatePath itself must not be relaxed.

    Regression test (write first): with .planning symlinked 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 .planning directory. Then, still with the symlinked layout, these stay the usage failure:

    • an in-store symlink pointing outside the store
    • an absolute path outside both roots
    • .. traversal
    • a sibling directory sharing the store's prefix

    With a real .planning directory, behavior is unchanged.

  2. trek-e commented on Oct 18, 2026

    @trek-e
    Collaborator

    This was generated by AI during triage.

    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 .planning symlinked to an external store, both gates exit with the usage error path 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 .planning directory.

    Key interfaces:

    • The shared gate path-containment helper used by every gate that takes a caller-supplied path argument.
    • The single repo-wide containment predicate (ADR-4650), which must remain the only place a containment decision is made.
    • The planning workspace's notion of the project's planning directory.

    Acceptance criteria:

    • With .planning symlinked 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 .planning directory.
    • With the symlinked layout, a symlink inside the store pointing outside the store is still refused with the existing usage error.
    • With the symlinked layout, an absolute path outside both the project and the store, a .. traversal, and a sibling directory sharing the store's name prefix are still refused with the existing usage error.
    • With a real .planning directory, behavior is unchanged.
    • The refusal message text is unchanged.

    Out of scope:

  3. added
    bugSomething isn't working
    and removed
    needs-triageNew issue awaiting maintainer review
    on Oct 18, 2026
  4. github-actions commented on Oct 18, 2026

    @github-actions
    Contributor

    Branch fix/5474-bug-gates-decision-coverage-plan-and-gap created.

    git fetch origin && git checkout fix/5474-bug-gates-decision-coverage-plan-and-gap
  5. helenkwok commented on Oct 18, 2026

    @helenkwok
    ContributorAuthor

    Thanks for the triage. I'm picking this up and will open a PR against next that follows the brief: one fix in resolveContainedPath, the realpath of the project's own .planning as the only second root, decided through tryWithinRoot, with validatePath and 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.

  6. added 4 commits that reference this issue on Oct 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingconfirmed-bugVerified reproducible bug

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions