Skip to content

Commit 94874e7

Browse files
fix(spec-stop-guard): ignore active plan outside current project
When PILOT_SESSION_ID is unset, the session-scoped active_plan.json collapses to the shared "default" file. A /spec plan registered by another repo's session (e.g. a COMPLETE plan in repo A) then leaks into an unrelated repo B's session and blocks its stops — the guard fires on a plan that has nothing to do with the current project. Scope the guard to plans that actually live inside the current project root (CLAUDE_PROJECT_ROOT, else git root, else cwd). Fails open (returns True -> legacy behaviour) when the root can't be determined, so legitimate same-project guarding is never weakened. Adds two tests: a foreign-project plan must not block; an absolute plan path inside the current project still blocks.
1 parent 0caa960 commit 94874e7

2 files changed

Lines changed: 82 additions & 0 deletions

File tree

pilot/hooks/spec_stop_guard.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
from _lib.util import (
2626
_sessions_base,
2727
build_objective_reinjection,
28+
find_git_root,
2829
get_session_plan_path,
2930
is_waiting_for_user_input,
3031
stop_block,
@@ -67,6 +68,41 @@ def get_handoff_sentinel_path() -> Path:
6768
return guard_dir / "spec-handoff-pending"
6869

6970

71+
def _current_project_root() -> Path | None:
72+
"""Best-effort current project root: CLAUDE_PROJECT_ROOT, else git root, else cwd."""
73+
root = os.environ.get("CLAUDE_PROJECT_ROOT", "").strip()
74+
if root:
75+
return Path(root)
76+
git_root = find_git_root()
77+
if git_root is not None:
78+
return git_root
79+
try:
80+
return Path.cwd()
81+
except OSError:
82+
return None
83+
84+
85+
def _plan_in_current_project(plan_file: Path) -> bool:
86+
"""True if plan_file lives inside the current project root.
87+
88+
Cross-session bleed guard: when PILOT_SESSION_ID is unset, the session-scoped
89+
active_plan.json collapses to the shared "default" file, so a /spec plan
90+
registered by ANOTHER repo's session can block stops in an unrelated repo.
91+
Only enforce the stop-guard for a plan that actually lives in the project this
92+
session is running in. Fails open (returns True -> legacy behavior) when the
93+
project root cannot be determined, so legitimate guarding is never weakened.
94+
"""
95+
root = _current_project_root()
96+
if root is None:
97+
return True
98+
try:
99+
root_real = os.path.realpath(root)
100+
plan_real = os.path.realpath(plan_file)
101+
return os.path.commonpath([root_real, plan_real]) == root_real
102+
except (ValueError, OSError):
103+
return True
104+
105+
70106
def find_active_plan() -> tuple[Path | None, str | None]:
71107
"""Find the active plan for THIS session via session-scoped active_plan.json."""
72108
plan_json = get_session_plan_path()
@@ -89,6 +125,12 @@ def find_active_plan() -> tuple[Path | None, str | None]:
89125
if not plan_file.exists():
90126
return None, None
91127

128+
# Cross-session bleed guard: ignore an active plan that isn't part of this
129+
# project (e.g. a COMPLETE plan from another repo's /spec session leaking in
130+
# through the shared "default" active_plan.json when PILOT_SESSION_ID unset).
131+
if not _plan_in_current_project(plan_file):
132+
return None, None
133+
92134
try:
93135
content = plan_file.read_text()
94136
status_match = re.search(r"^Status:\s*(\w+)", content, re.MULTILINE)

pilot/hooks/tests/test_spec_stop_guard.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -707,6 +707,46 @@ def test_resolves_relative_plan_path_against_project_root(self, tmp_path: Path)
707707
assert _is_blocked(stdout)
708708
assert "cannot stop" in stdout.lower()
709709

710+
def test_ignores_plan_outside_current_project(self, tmp_path: Path) -> None:
711+
"""Cross-session bleed: a registered plan that lives OUTSIDE the current
712+
project root must not block. Reproduces the failure where PILOT_SESSION_ID
713+
is unset, active_plan.json collapses to the shared 'default' file, and a
714+
/spec plan from another repo's session blocked stops in an unrelated repo.
715+
"""
716+
project = tmp_path / "current-project"
717+
plans_dir = project / "docs" / "plans"
718+
plans_dir.mkdir(parents=True)
719+
720+
other_plans = tmp_path / "other-project" / "docs" / "plans"
721+
other_plans.mkdir(parents=True)
722+
foreign_plan = other_plans / "2026-02-06-foreign.md"
723+
foreign_plan.write_text("# Foreign\n\nStatus: PENDING\nApproved: Yes\n")
724+
_register_plan_for_session(foreign_plan, "PENDING")
725+
726+
with patch.dict(os.environ, {"CLAUDE_PROJECT_ROOT": str(project)}):
727+
exit_code, stdout, _ = _run_subprocess({"stop_hook_active": False}, plans_dir)
728+
729+
assert exit_code == 0
730+
assert not _is_blocked(stdout)
731+
732+
def test_blocks_absolute_plan_inside_current_project(self, tmp_path: Path) -> None:
733+
"""The project-scope guard must not over-suppress: an absolute plan path
734+
INSIDE the current project root still blocks."""
735+
project = tmp_path / "current-project"
736+
plans_dir = project / "docs" / "plans"
737+
plans_dir.mkdir(parents=True)
738+
739+
plan_file = plans_dir / "2026-02-06-in-project.md"
740+
plan_file.write_text("# In Project\n\nStatus: PENDING\nApproved: No\n")
741+
_register_plan_for_session(plan_file, "PENDING")
742+
743+
with patch.dict(os.environ, {"CLAUDE_PROJECT_ROOT": str(project)}):
744+
exit_code, stdout, _ = _run_subprocess({"stop_hook_active": False}, plans_dir)
745+
746+
assert exit_code == 0
747+
assert _is_blocked(stdout)
748+
assert "cannot stop" in stdout.lower()
749+
710750

711751
class TestHandoffSentinel:
712752
"""The model-switch handoff sentinel grants permission to stop while it lives.

0 commit comments

Comments
 (0)