Files
othmanadi__planning-with-files/tests/test_cursor_nested_root_isolation.py
OthmanAdi 9636963b78 fix: extend the shared-cwd guards to the Codex and Cursor hook routes
The two Codex integrations are separate implementations. The reporter of #212
installed the Agent Skills route, but .codex/hooks.json is the one OpenAI's
hook documentation describes, and it carried the same defect: cwd relative
resolution, no absolute root binding, no ambiguity detection. Fixing only the
reported route would have left the bug live on the documented one.

Both Codex entry points now honour PWF_PLAN_ROOT, refuse an ambiguous cwd with
the same message, and emit the session refusal notice once per turn rather
than exiting silently. The Python adapter routes the shell helpers through the
pinned root so the resolver, the legacy task_plan.md fallback and the sessions
check all read the same place on POSIX and on Windows.

The Cursor hooks only ever supported a legacy root task_plan.md, so they
cannot hit the slug case, but they can hit the same shape when a root plan
sits above a nested project. They get the pin and the depth one check in both
the shell and PowerShell twins. Their warning names PWF_PLAN_ROOT only,
because that route implements no PLAN_ID hatch and naming one would be false
guidance.

Verified against the reporter's tree: before, the Codex hook injected the
parent's plan; after, it refuses with the ambiguity notice, and a pin injects
the nested plan. Legacy output is byte identical on both routes.
2026-08-01 22:04:52 +02:00

191 lines
8.0 KiB
Python

"""Nested plan-root isolation for the Cursor hook route (issue #212).
The two Cursor UserPromptSubmit hooks (.cursor/hooks/user-prompt-submit.sh
and .ps1) support only the legacy root task_plan.md shape, no .planning
awareness. They can still hit the issue #212 failure in the legacy-root
shape: a root task_plan.md at a shared parent cwd plus a nested project
below it carrying its own .planning plan. Ported semantics:
* PWF_PLAN_ROOT — every planning-state read goes through the pin; a
broken pin fails CLOSED with one notice; unset stays byte-identical.
* Depth-1 nested-root conflict detection — the legacy root plan is always
a cwd GUESS on this route, so a competing direct-child .planning
(.active_plan or a <slug>/task_plan.md) refuses injection with the
warning. The warning names PWF_PLAN_ROOT only: this route implements no
PLAN_ID escape hatch, so advertising one would be false guidance.
Both hook twins must behave identically; every scenario runs against sh and
PowerShell through a shared contract mixin.
"""
from __future__ import annotations
import os
import shutil
import subprocess
import tempfile
import unittest
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
CURSOR_SH = REPO_ROOT / ".cursor" / "hooks" / "user-prompt-submit.sh"
CURSOR_PS1 = REPO_ROOT / ".cursor" / "hooks" / "user-prompt-submit.ps1"
ROOT_TITLE = "ROOT-LEGACY-PARENT-PLAN"
NESTED_TITLE = "NESTED-PROJECT-PLAN"
SCRUB_VARS = ("PLAN_ID", "PWF_PLAN_ROOT", "PWF_SESSION_ID", "PLANNING_DISABLED")
def pwsh_exe() -> str | None:
return shutil.which("pwsh") or shutil.which("powershell")
def have_sh() -> bool:
return shutil.which("sh") is not None
class CursorHookContract:
"""Shared scenarios. NOT a TestCase: concrete twins mix this in and
provide _run, so every assertion runs once per interpreter."""
def setUp(self) -> None: # noqa: N802 (unittest naming)
self.tmp = Path(tempfile.mkdtemp(prefix="pwf-cursor-nested-"))
self.workspace = self.tmp / "workspace"
self.project = self.workspace / "project"
self.workspace.mkdir(parents=True)
def tearDown(self) -> None: # noqa: N802
shutil.rmtree(self.tmp, ignore_errors=True)
def build_tree(self, nested: bool = True, nested_pointer: bool = True) -> None:
(self.workspace / "task_plan.md").write_text(
f"# {ROOT_TITLE}\n", encoding="utf-8"
)
(self.workspace / "progress.md").write_text("# root progress\n", encoding="utf-8")
if nested:
plan_b = self.project / ".planning" / "plan-b"
plan_b.mkdir(parents=True)
(plan_b / "task_plan.md").write_text(f"# {NESTED_TITLE}\n", encoding="utf-8")
if nested_pointer:
(self.project / ".planning" / ".active_plan").write_text(
"plan-b\n", encoding="utf-8"
)
def build_inner_legacy(self) -> Path:
# A pinnable root carrying its own legacy-shape plan.
inner = self.workspace / "inner"
inner.mkdir(parents=True)
(inner / "task_plan.md").write_text(f"# {NESTED_TITLE}\n", encoding="utf-8")
(inner / "progress.md").write_text("# inner progress\n", encoding="utf-8")
return inner
def scrubbed_env(self, env_extra: dict | None = None) -> dict:
env = os.environ.copy()
for var in SCRUB_VARS:
env.pop(var, None)
if env_extra:
env.update(env_extra)
return env
def _run(self, env_extra: dict | None = None) -> subprocess.CompletedProcess[str]:
raise NotImplementedError
# -- conflict detection -------------------------------------------------
def test_parent_cwd_with_nested_plan_injects_nothing(self) -> None:
self.build_tree(nested=True)
result = self._run()
self.assertEqual(0, result.returncode, result.stderr)
self.assertNotIn(ROOT_TITLE, result.stdout, "root plan body must not inject")
self.assertNotIn(NESTED_TITLE, result.stdout)
self.assertIn("Ambiguous plan", result.stdout)
self.assertIn("project", result.stdout, "warning must name the nested root")
self.assertIn("PWF_PLAN_ROOT", result.stdout)
def test_nested_slug_plan_without_pointer_still_competes(self) -> None:
self.build_tree(nested=True, nested_pointer=False)
result = self._run()
self.assertEqual(0, result.returncode, result.stderr)
self.assertIn("Ambiguous plan", result.stdout)
self.assertNotIn(ROOT_TITLE, result.stdout)
def test_bare_nested_planning_is_no_conflict(self) -> None:
self.build_tree(nested=False)
(self.project / ".planning").mkdir(parents=True)
result = self._run()
self.assertEqual(0, result.returncode, result.stderr)
self.assertIn(ROOT_TITLE, result.stdout)
self.assertNotIn("Ambiguous plan", result.stdout)
# -- PWF_PLAN_ROOT ------------------------------------------------------
def test_pin_injects_the_pinned_legacy_root(self) -> None:
self.build_tree(nested=True)
inner = self.build_inner_legacy()
result = self._run({"PWF_PLAN_ROOT": str(inner)})
self.assertEqual(0, result.returncode, result.stderr)
self.assertIn(NESTED_TITLE, result.stdout, "pinned root's plan must inject")
self.assertNotIn(ROOT_TITLE, result.stdout, "cwd plan must not leak through")
self.assertNotIn("Ambiguous plan", result.stdout, "a pin is explicit")
def test_pin_without_legacy_plan_stays_silent(self) -> None:
# The pinned root has only a .planning slug plan; this route reads
# only the legacy root shape, so it injects nothing rather than
# falling back to the cwd plan.
self.build_tree(nested=True)
result = self._run({"PWF_PLAN_ROOT": str(self.project)})
self.assertEqual(0, result.returncode, result.stderr)
self.assertEqual("", result.stdout.strip())
def test_broken_pin_notices_and_injects_nothing(self) -> None:
self.build_tree(nested=True)
result = self._run({"PWF_PLAN_ROOT": str(self.workspace / "does-not-exist")})
self.assertEqual(0, result.returncode, result.stderr)
self.assertNotEqual("", result.stdout.strip(), "broken pin must emit a notice")
self.assertIn("PWF_PLAN_ROOT", result.stdout)
self.assertNotIn(ROOT_TITLE, result.stdout, "must not fall back to cwd plan")
self.assertNotIn(NESTED_TITLE, result.stdout)
# -- legacy invariant ---------------------------------------------------
def test_no_nested_plan_injects_exactly_as_before(self) -> None:
self.build_tree(nested=False)
result = self._run()
self.assertEqual(0, result.returncode, result.stderr)
self.assertIn("ACTIVE PLAN", result.stdout)
self.assertIn(ROOT_TITLE, result.stdout)
self.assertNotIn("Ambiguous plan", result.stdout)
@unittest.skipUnless(have_sh(), "requires a POSIX sh")
class CursorShNestedRootTests(CursorHookContract, unittest.TestCase):
def _run(self, env_extra: dict | None = None) -> subprocess.CompletedProcess[str]:
return subprocess.run(
["sh", str(CURSOR_SH)],
cwd=str(self.workspace),
env=self.scrubbed_env(env_extra),
text=True,
encoding="utf-8",
capture_output=True,
check=False,
timeout=60,
)
@unittest.skipUnless(pwsh_exe(), "requires PowerShell")
class CursorPs1NestedRootTests(CursorHookContract, unittest.TestCase):
def _run(self, env_extra: dict | None = None) -> subprocess.CompletedProcess[str]:
exe = pwsh_exe()
assert exe is not None
return subprocess.run(
[exe, "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", str(CURSOR_PS1)],
cwd=str(self.workspace),
env=self.scrubbed_env(env_extra),
text=True,
encoding="utf-8",
capture_output=True,
check=False,
timeout=120,
)
if __name__ == "__main__":
unittest.main()