From 3e219ac303a626224ca57f65a6b734ebe2bded44 Mon Sep 17 00:00:00 2001 From: Jothi Mani Rajendran <250249270+jothimani-rajendran@users.noreply.github.com> Date: Fri, 9 Oct 2026 01:21:59 +0000 Subject: [PATCH 1/7] test(gate): Windows spelling matrix for Cursor's scoped write gate Drives the built Cursor plugin with a path-scoped gate, a payload with no cwd, and the hook run from a folder outside the workspace. On Windows it crosses file_path spellings (C:/, C:\, c:\, c:/) with workspace_roots spellings (/c:/, /C:/, c:\, C:/). A new windows-paths CI job runs this one file on windows-latest, since PR CI is Linux-only. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Jothi Mani Rajendran <250249270+jothimani-rajendran@users.noreply.github.com> --- .github/workflows/ci.yml | 26 +++++ tests/test_cursor_windows_write_gate.py | 135 ++++++++++++++++++++++++ 2 files changed, 161 insertions(+) create mode 100644 tests/test_cursor_windows_write_gate.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bd7c1651..cc3beb84 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -65,6 +65,32 @@ jobs: done < <(git rev-list --no-merges "origin/$BASE_REF..HEAD") exit "$missing" + # Windows path spellings in the agent write gate (drive-letter case, `/c:/` roots, either + # slash). Linux cannot exercise them, and the full matrix does not run on PRs, so this one + # targeted file runs on Windows every time. + windows-paths: + runs-on: windows-latest + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 + with: + python-version: "3.12" + + - name: Install dependencies + run: | + python -m pip install --require-hashes --upgrade -r requirements/pip.txt + pip install -e '.[dev,build]' + + - name: Windows write-gate path tests + run: pytest -q tests/test_cursor_windows_write_gate.py + # Commit messages and PR bodies are as public as the diff and much easier to leak # into -- container paths, session identifiers, personal addresses. Checked here # because the local test can only see the history it has. diff --git a/tests/test_cursor_windows_write_gate.py b/tests/test_cursor_windows_write_gate.py new file mode 100644 index 00000000..d426b038 --- /dev/null +++ b/tests/test_cursor_windows_write_gate.py @@ -0,0 +1,135 @@ +"""CU-1: Cursor's write-time gate judges a scoped write however Windows spells the path and the root. + +Witnessed on Windows (Cursor 3.22.12, 2026-10-09): a `Write` of `.github/workflows/witness.yml` +went through `preToolUse` while a path-scoped gate covered it. Cursor sends no `cwd`, spells the +workspace root `/c:/...` and the file `C:/...` or `C:\\...`, and runs the hook from a cwd of its own. +""" + +from __future__ import annotations + +import json +import os +import subprocess +from pathlib import Path + +import pytest +from conftest import init_repo +from test_plugin_gate import POLICY_ID, _manifest +from test_plugin_gate import policy as shared_policy + +from chock.plugin import cursor + +#: The shared fixture under a name no test parameter shadows. +gate_policy = shared_policy + +SCOPE = ".github/workflows/*" +WORKFLOW = (".github", "workflows", "witness.yml") +WINDOWS = os.name == "nt" + + +def _scoped_manifest() -> dict: + manifest = _manifest(kind="content_regex") + manifest["applies_to"] = {"paths": [SCOPE]} + return manifest + + +@pytest.fixture +def plugin(gate_policy, tmp_path: Path) -> Path: + manifest = _scoped_manifest() + out = tmp_path / "dist" / "cursor" / POLICY_ID + cursor.build_cursor_plugin(gate_policy(manifest), manifest, tmp_path, out) + return out + + +@pytest.fixture +def repo(tmp_path: Path) -> Path: + project = tmp_path / "witness-repo-cursor" + (project / ".github" / "workflows").mkdir(parents=True) + init_repo(project) + return project + + +def _elsewhere(tmp_path: Path) -> Path: + """A process cwd that is not the workspace, as Cursor's own install or the user's home is.""" + away = tmp_path / "cursor-install" + away.mkdir(exist_ok=True) + return away + + +def _write(out: Path, cwd: Path, file_path: str, roots: list[str], content: str = "FORBIDDEN") -> str: + """The `permission` Cursor gets for a `Write`; the payload carries no `cwd`, as Cursor's does not.""" + payload = { + "conversation_id": "c", + "generation_id": "g", + "cursor_version": "3.22.12", + "workspace_roots": roots, + "hook_event_name": "preToolUse", + "tool_name": "Write", + "tool_input": {"file_path": file_path, "content": content}, + } + hooks = json.loads((out / cursor.HOOKS_REL).read_text(encoding="utf-8"))["hooks"] + command = hooks["preToolUse"][0]["command"].replace("${CURSOR_PLUGIN_ROOT}", str(out)) + proc = subprocess.run( + command, + cwd=cwd, + shell=True, + env={**os.environ, "CURSOR_PLUGIN_ROOT": str(out)}, + capture_output=True, + text=True, + encoding="utf-8", + input="\ufeff" + json.dumps(payload), + check=False, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout)["permission"] + + +def _drive_spellings(path: Path) -> dict[str, str]: + """`path` as Windows clients write it: forward or back slashes, upper- or lower-case drive.""" + text = str(path) + upper, lower = text[0].upper() + text[1:], text[0].lower() + text[1:] + return { + "C:/fwd": upper.replace("\\", "/"), + "C:\\back": upper.replace("/", "\\"), + "c:\\back": lower.replace("/", "\\"), + "c:/fwd": lower.replace("\\", "/"), + } + + +def _root_spellings(root: Path) -> dict[str, str]: + """The workspace root as Cursor (`/c:/...`) and other clients spell it.""" + forms = _drive_spellings(root) + return { + "/c:/": "/" + forms["c:/fwd"], + "/C:/": "/" + forms["C:/fwd"], + "c:\\": forms["c:\\back"], + "C:/": forms["C:/fwd"], + } + + +def test_a_scoped_write_is_refused_with_no_cwd_from_elsewhere(plugin: Path, repo: Path, tmp_path: Path) -> None: + """The baseline on every OS: the root comes from `workspace_roots`, not the hook's process cwd.""" + target = str(repo.joinpath(*WORKFLOW)) + assert _write(plugin, _elsewhere(tmp_path), target, [str(repo)]) == "deny" + assert _write(plugin, _elsewhere(tmp_path), target, [str(repo)], content="ok") == "allow" + assert _write(plugin, _elsewhere(tmp_path), str(repo / "A.java"), [str(repo)]) == "allow", "outside the scope" + + +@pytest.mark.skipif(not WINDOWS, reason="drive-letter spellings are Windows paths") +@pytest.mark.parametrize("root_form", ["/c:/", "/C:/", "c:\\", "C:/"]) +@pytest.mark.parametrize("file_form", ["C:/fwd", "C:\\back", "c:\\back", "c:/fwd"]) +def test_every_windows_spelling_of_a_scoped_write_is_refused( + plugin: Path, repo: Path, tmp_path: Path, root_form: str, file_form: str +) -> None: + file_path = _drive_spellings(repo.joinpath(*WORKFLOW))[file_form] + root = _root_spellings(repo)[root_form] + assert _write(plugin, _elsewhere(tmp_path), file_path, [root]) == "deny", (file_path, root) + assert _write(plugin, _elsewhere(tmp_path), file_path, [root], content="ok") == "allow" + + +@pytest.mark.skipif(not WINDOWS, reason="drive-letter spellings are Windows paths") +@pytest.mark.parametrize("cwd_kind", ["home", "elsewhere"]) +def test_a_scoped_write_is_refused_whatever_the_hook_cwd(plugin: Path, repo: Path, tmp_path: Path, cwd_kind) -> None: + cwd = Path.home() if cwd_kind == "home" else _elsewhere(tmp_path) + file_path = _drive_spellings(repo.joinpath(*WORKFLOW))["C:/fwd"] + assert _write(plugin, cwd, file_path, [_root_spellings(repo)["/c:/"]]) == "deny" From 571b6a69872408b970e0cf37744ea7524c1d46bb Mon Sep 17 00:00:00 2001 From: Jothi Mani Rajendran <250249270+jothimani-rajendran@users.noreply.github.com> Date: Fri, 9 Oct 2026 01:23:42 +0000 Subject: [PATCH 2/7] test(gate): run the Cursor adapter directly in the Windows matrix The plugin hook command is a POSIX launcher that cmd.exe cannot parse, so every Windows case failed before reaching the gate. The launcher only locates Python; the test now runs the bundled cursor.py with this interpreter, which is the code under test. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Jothi Mani Rajendran <250249270+jothimani-rajendran@users.noreply.github.com> --- tests/test_cursor_windows_write_gate.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/tests/test_cursor_windows_write_gate.py b/tests/test_cursor_windows_write_gate.py index d426b038..45d74fb8 100644 --- a/tests/test_cursor_windows_write_gate.py +++ b/tests/test_cursor_windows_write_gate.py @@ -10,6 +10,7 @@ import json import os import subprocess +import sys from pathlib import Path import pytest @@ -67,13 +68,10 @@ def _write(out: Path, cwd: Path, file_path: str, roots: list[str], content: str "tool_name": "Write", "tool_input": {"file_path": file_path, "content": content}, } - hooks = json.loads((out / cursor.HOOKS_REL).read_text(encoding="utf-8"))["hooks"] - command = hooks["preToolUse"][0]["command"].replace("${CURSOR_PLUGIN_ROOT}", str(out)) - proc = subprocess.run( - command, + scripts = out / "scripts" + proc = subprocess.run( # the bundled adapter itself: the POSIX launcher only finds this interpreter + [sys.executable, str(scripts / "cursor.py"), "--gate", str(scripts / "gate.json")], cwd=cwd, - shell=True, - env={**os.environ, "CURSOR_PLUGIN_ROOT": str(out)}, capture_output=True, text=True, encoding="utf-8", From 993a8a7c0222ba19859f978fc7dbd4719319ab2f Mon Sep 17 00:00:00 2001 From: Jothi Mani Rajendran <250249270+jothimani-rajendran@users.noreply.github.com> Date: Fri, 9 Oct 2026 01:26:50 +0000 Subject: [PATCH 3/7] test(gate): Windows path matrix for Copilot create, real launcher for Cursor Runs each client's hook command through Git Bash, as the repo's other hook tests do, and adds Copilot CLI create {path, file_text} payloads with cwd and path in C:\, c:\ and C:/ spellings. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Jothi Mani Rajendran <250249270+jothimani-rajendran@users.noreply.github.com> --- tests/test_cursor_windows_write_gate.py | 133 ++++++++++++++++-------- 1 file changed, 89 insertions(+), 44 deletions(-) diff --git a/tests/test_cursor_windows_write_gate.py b/tests/test_cursor_windows_write_gate.py index 45d74fb8..3b2b5829 100644 --- a/tests/test_cursor_windows_write_gate.py +++ b/tests/test_cursor_windows_write_gate.py @@ -1,23 +1,25 @@ -"""CU-1: Cursor's write-time gate judges a scoped write however Windows spells the path and the root. +"""CU-1: the write-time gate judges a write however Windows spells the path, the root and the cwd. -Witnessed on Windows (Cursor 3.22.12, 2026-10-09): a `Write` of `.github/workflows/witness.yml` -went through `preToolUse` while a path-scoped gate covered it. Cursor sends no `cwd`, spells the -workspace root `/c:/...` and the file `C:/...` or `C:\\...`, and runs the hook from a cwd of its own. +Witnessed on Windows (2026-10-09, chock 3786b59): Cursor 3.22.12's `Write` and Copilot CLI 1.0.94's +`create` of `.github/workflows/witness.yml` both went through at write time while a gate covered it. +Cursor sends no `cwd`, spells the workspace root `/c:/...` and runs the hook from a cwd of its own; +Copilot sends `cwd` and `tool_input.path`. Each client's hooks run here the way the client runs them. """ from __future__ import annotations import json import os -import subprocess -import sys from pathlib import Path import pytest -from conftest import init_repo +from bundle_fixtures import MARKER +from conftest import init_repo, run_hook_command +from guardrails_support import build, outcomes, refused from test_plugin_gate import POLICY_ID, _manifest from test_plugin_gate import policy as shared_policy +from chock.install.package import client from chock.plugin import cursor #: The shared fixture under a name no test parameter shadows. @@ -26,17 +28,31 @@ SCOPE = ".github/workflows/*" WORKFLOW = (".github", "workflows", "witness.yml") WINDOWS = os.name == "nt" +ON_WINDOWS = pytest.mark.skipif(not WINDOWS, reason="drive-letter spellings are Windows paths") -def _scoped_manifest() -> dict: - manifest = _manifest(kind="content_regex") - manifest["applies_to"] = {"paths": [SCOPE]} - return manifest +def _drive_spellings(path: Path) -> dict[str, str]: + """`path` as Windows clients write it: forward or back slashes, upper- or lower-case drive.""" + text = str(path) + upper, lower = text[0].upper() + text[1:], text[0].lower() + text[1:] + return { + "C:/fwd": upper.replace("\\", "/"), + "C:\\back": upper.replace("/", "\\"), + "c:\\back": lower.replace("/", "\\"), + "c:/fwd": lower.replace("\\", "/"), + } + + +FILE_FORMS = ["C:/fwd", "C:\\back", "c:\\back", "c:/fwd"] + + +# Cursor: the per-policy package, path-scoped like pin-github-actions. @pytest.fixture def plugin(gate_policy, tmp_path: Path) -> Path: - manifest = _scoped_manifest() + manifest = _manifest(kind="content_regex") + manifest["applies_to"] = {"paths": [SCOPE]} out = tmp_path / "dist" / "cursor" / POLICY_ID cursor.build_cursor_plugin(gate_policy(manifest), manifest, tmp_path, out) return out @@ -57,7 +73,7 @@ def _elsewhere(tmp_path: Path) -> Path: return away -def _write(out: Path, cwd: Path, file_path: str, roots: list[str], content: str = "FORBIDDEN") -> str: +def _cursor_write(out: Path, cwd: Path, file_path: str, roots: list[str], content: str = "FORBIDDEN") -> str: """The `permission` Cursor gets for a `Write`; the payload carries no `cwd`, as Cursor's does not.""" payload = { "conversation_id": "c", @@ -68,32 +84,13 @@ def _write(out: Path, cwd: Path, file_path: str, roots: list[str], content: str "tool_name": "Write", "tool_input": {"file_path": file_path, "content": content}, } - scripts = out / "scripts" - proc = subprocess.run( # the bundled adapter itself: the POSIX launcher only finds this interpreter - [sys.executable, str(scripts / "cursor.py"), "--gate", str(scripts / "gate.json")], - cwd=cwd, - capture_output=True, - text=True, - encoding="utf-8", - input="\ufeff" + json.dumps(payload), - check=False, - ) + command = json.loads((out / cursor.HOOKS_REL).read_text(encoding="utf-8"))["hooks"]["preToolUse"][0]["command"] + env = {**os.environ, "CURSOR_PLUGIN_ROOT": out.as_posix()} + proc = run_hook_command(command, cwd, "\ufeff" + json.dumps(payload), env=env) assert proc.returncode == 0, proc.stderr return json.loads(proc.stdout)["permission"] -def _drive_spellings(path: Path) -> dict[str, str]: - """`path` as Windows clients write it: forward or back slashes, upper- or lower-case drive.""" - text = str(path) - upper, lower = text[0].upper() + text[1:], text[0].lower() + text[1:] - return { - "C:/fwd": upper.replace("\\", "/"), - "C:\\back": upper.replace("/", "\\"), - "c:\\back": lower.replace("/", "\\"), - "c:/fwd": lower.replace("\\", "/"), - } - - def _root_spellings(root: Path) -> dict[str, str]: """The workspace root as Cursor (`/c:/...`) and other clients spell it.""" forms = _drive_spellings(root) @@ -108,26 +105,74 @@ def _root_spellings(root: Path) -> dict[str, str]: def test_a_scoped_write_is_refused_with_no_cwd_from_elsewhere(plugin: Path, repo: Path, tmp_path: Path) -> None: """The baseline on every OS: the root comes from `workspace_roots`, not the hook's process cwd.""" target = str(repo.joinpath(*WORKFLOW)) - assert _write(plugin, _elsewhere(tmp_path), target, [str(repo)]) == "deny" - assert _write(plugin, _elsewhere(tmp_path), target, [str(repo)], content="ok") == "allow" - assert _write(plugin, _elsewhere(tmp_path), str(repo / "A.java"), [str(repo)]) == "allow", "outside the scope" + assert _cursor_write(plugin, _elsewhere(tmp_path), target, [str(repo)]) == "deny" + assert _cursor_write(plugin, _elsewhere(tmp_path), target, [str(repo)], content="ok") == "allow" + assert _cursor_write(plugin, _elsewhere(tmp_path), str(repo / "A.java"), [str(repo)]) == "allow", "out of scope" -@pytest.mark.skipif(not WINDOWS, reason="drive-letter spellings are Windows paths") +@ON_WINDOWS @pytest.mark.parametrize("root_form", ["/c:/", "/C:/", "c:\\", "C:/"]) -@pytest.mark.parametrize("file_form", ["C:/fwd", "C:\\back", "c:\\back", "c:/fwd"]) +@pytest.mark.parametrize("file_form", FILE_FORMS) def test_every_windows_spelling_of_a_scoped_write_is_refused( plugin: Path, repo: Path, tmp_path: Path, root_form: str, file_form: str ) -> None: file_path = _drive_spellings(repo.joinpath(*WORKFLOW))[file_form] root = _root_spellings(repo)[root_form] - assert _write(plugin, _elsewhere(tmp_path), file_path, [root]) == "deny", (file_path, root) - assert _write(plugin, _elsewhere(tmp_path), file_path, [root], content="ok") == "allow" + assert _cursor_write(plugin, _elsewhere(tmp_path), file_path, [root]) == "deny", (file_path, root) + assert _cursor_write(plugin, _elsewhere(tmp_path), file_path, [root], content="ok") == "allow" -@pytest.mark.skipif(not WINDOWS, reason="drive-letter spellings are Windows paths") +@ON_WINDOWS @pytest.mark.parametrize("cwd_kind", ["home", "elsewhere"]) def test_a_scoped_write_is_refused_whatever_the_hook_cwd(plugin: Path, repo: Path, tmp_path: Path, cwd_kind) -> None: cwd = Path.home() if cwd_kind == "home" else _elsewhere(tmp_path) file_path = _drive_spellings(repo.joinpath(*WORKFLOW))["C:/fwd"] - assert _write(plugin, cwd, file_path, [_root_spellings(repo)["/c:/"]]) == "deny" + assert _cursor_write(plugin, cwd, file_path, [_root_spellings(repo)["/c:/"]]) == "deny" + + +# Copilot CLI: the merged bundle as `--client copilot` ships it, judged on a `create` {path, file_text}. + + +@pytest.fixture +def copilot(tmp_path: Path) -> tuple[Path, Path, list[str], Path]: + """(plugin, repo, the PreToolUse commands a `create` fires, home).""" + plugin, project, _ = build(tmp_path, client("copilot")["format"]) + project.joinpath(*WORKFLOW).parent.mkdir(parents=True) + home = tmp_path / "home" + home.mkdir() + entries = json.loads(next(plugin.rglob("hooks.json")).read_text(encoding="utf-8"))["hooks"]["PreToolUse"] + commands = [h["command"] for e in entries if "Write" in e.get("matcher", "").split("|") for h in e["hooks"]] + assert commands, "no PreToolUse entry fires for Copilot's create" + return plugin, project, commands, home + + +def _copilot_refuses(copilot: tuple, cwd: str, path: str, text: str = f"{MARKER}\n") -> bool: + plugin, project, commands, home = copilot + payload = { + "hook_event_name": "PreToolUse", + "session_id": "s", + "timestamp": "2026-10-09T00:00:00.000Z", + "cwd": cwd, + "tool_name": "Write", + "tool_input": {"path": path, "file_text": text}, + } + return any(refused(p) for p in outcomes(commands, plugin, project, payload, home)) + + +def test_a_copilot_create_is_refused_by_its_absolute_path(copilot: tuple) -> None: + """The baseline on every OS.""" + _, project, _, _ = copilot + target = str(project.joinpath(*WORKFLOW)) + assert _copilot_refuses(copilot, str(project), target) + assert not _copilot_refuses(copilot, str(project), target, "jobs: {}\n") + + +@ON_WINDOWS +@pytest.mark.parametrize("cwd_form", ["C:\\back", "c:\\back"]) +@pytest.mark.parametrize("file_form", FILE_FORMS) +def test_every_windows_spelling_of_a_copilot_create_is_refused(copilot: tuple, cwd_form: str, file_form: str) -> None: + _, project, _, _ = copilot + cwd = _drive_spellings(project)[cwd_form] + path = _drive_spellings(project.joinpath(*WORKFLOW))[file_form] + assert _copilot_refuses(copilot, cwd, path), (cwd, path) + assert not _copilot_refuses(copilot, cwd, path, "jobs: {}\n") From c37cbbf30c3de9f4c37b387fd14a111d475e77e9 Mon Sep 17 00:00:00 2001 From: Jothi Mani Rajendran <250249270+jothimani-rajendran@users.noreply.github.com> Date: Fri, 9 Oct 2026 01:28:28 +0000 Subject: [PATCH 4/7] ci: move the Windows path job to its own workflow ci.yml is held to the 300-line review budget; the job pushed it to 326. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Jothi Mani Rajendran <250249270+jothimani-rajendran@users.noreply.github.com> --- .github/workflows/ci.yml | 26 -------------------- .github/workflows/windows-paths.yml | 37 +++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 26 deletions(-) create mode 100644 .github/workflows/windows-paths.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cc3beb84..bd7c1651 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -65,32 +65,6 @@ jobs: done < <(git rev-list --no-merges "origin/$BASE_REF..HEAD") exit "$missing" - # Windows path spellings in the agent write gate (drive-letter case, `/c:/` roots, either - # slash). Linux cannot exercise them, and the full matrix does not run on PRs, so this one - # targeted file runs on Windows every time. - windows-paths: - runs-on: windows-latest - defaults: - run: - shell: bash - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - with: - persist-credentials: false - - - name: Set up Python - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 - with: - python-version: "3.12" - - - name: Install dependencies - run: | - python -m pip install --require-hashes --upgrade -r requirements/pip.txt - pip install -e '.[dev,build]' - - - name: Windows write-gate path tests - run: pytest -q tests/test_cursor_windows_write_gate.py - # Commit messages and PR bodies are as public as the diff and much easier to leak # into -- container paths, session identifiers, personal addresses. Checked here # because the local test can only see the history it has. diff --git a/.github/workflows/windows-paths.yml b/.github/workflows/windows-paths.yml new file mode 100644 index 00000000..cac6e020 --- /dev/null +++ b/.github/workflows/windows-paths.yml @@ -0,0 +1,37 @@ +name: Windows paths + +# Windows path spellings in the agent write gate (drive-letter case, `/c:/` roots, either +# slash). Linux cannot exercise them, and CI's full matrix does not run on PRs, so this one +# targeted file runs on Windows every time. + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + windows-paths: + runs-on: windows-latest + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 + with: + python-version: "3.12" + + - name: Install dependencies + run: | + python -m pip install --require-hashes --upgrade -r requirements/pip.txt + pip install -e '.[dev,build]' + + - name: Windows write-gate path tests + run: pytest -q tests/test_cursor_windows_write_gate.py From 04edfd1216a4dd5cf628e5b82809a9b65633be3f Mon Sep 17 00:00:00 2001 From: Jothi Mani Rajendran <250249270+jothimani-rajendran@users.noreply.github.com> Date: Fri, 9 Oct 2026 01:28:53 +0000 Subject: [PATCH 5/7] test(gate): send the Cursor matrix payload without a BOM The shared hook runner writes stdin in the locale encoding, cp1252 on a Windows runner, which cannot encode U+FEFF. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Jothi Mani Rajendran <250249270+jothimani-rajendran@users.noreply.github.com> --- tests/test_cursor_windows_write_gate.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_cursor_windows_write_gate.py b/tests/test_cursor_windows_write_gate.py index 3b2b5829..29106f5e 100644 --- a/tests/test_cursor_windows_write_gate.py +++ b/tests/test_cursor_windows_write_gate.py @@ -86,7 +86,8 @@ def _cursor_write(out: Path, cwd: Path, file_path: str, roots: list[str], conten } command = json.loads((out / cursor.HOOKS_REL).read_text(encoding="utf-8"))["hooks"]["preToolUse"][0]["command"] env = {**os.environ, "CURSOR_PLUGIN_ROOT": out.as_posix()} - proc = run_hook_command(command, cwd, "\ufeff" + json.dumps(payload), env=env) + # No BOM: the shared runner writes stdin in the locale encoding, cp1252 on a Windows runner. + proc = run_hook_command(command, cwd, json.dumps(payload), env=env) assert proc.returncode == 0, proc.stderr return json.loads(proc.stdout)["permission"] From 41c13c3333bc6370cb936d1c38e5211c090afb02 Mon Sep 17 00:00:00 2001 From: Jothi Mani Rajendran <250249270+jothimani-rajendran@users.noreply.github.com> Date: Fri, 9 Oct 2026 01:30:18 +0000 Subject: [PATCH 6/7] test(gate): real pin-github-actions on Windows, both clients, three Pythons Merges the repo's own pin-github-actions into a bundle as Cursor and Copilot (Claude format) install it and feeds the witnessed content, an indented unpinned uses: in a new workflow of a committed repository, across the Windows path spellings. The Windows job now runs on Python 3.12, 3.13 and 3.14. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Jothi Mani Rajendran <250249270+jothimani-rajendran@users.noreply.github.com> --- .github/workflows/windows-paths.yml | 8 ++- tests/test_cursor_windows_write_gate.py | 80 ++++++++++++++++++++++++- 2 files changed, 85 insertions(+), 3 deletions(-) diff --git a/.github/workflows/windows-paths.yml b/.github/workflows/windows-paths.yml index cac6e020..6cc1061b 100644 --- a/.github/workflows/windows-paths.yml +++ b/.github/workflows/windows-paths.yml @@ -14,6 +14,12 @@ permissions: jobs: windows-paths: + strategy: + fail-fast: false + matrix: + # The interpreter a client finds on the user's machine is not CI's, and pathlib's Windows + # semantics move between minors. + python-version: ["3.12", "3.13", "3.14"] runs-on: windows-latest defaults: run: @@ -26,7 +32,7 @@ jobs: - name: Set up Python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 with: - python-version: "3.12" + python-version: ${{ matrix.python-version }} - name: Install dependencies run: | diff --git a/tests/test_cursor_windows_write_gate.py b/tests/test_cursor_windows_write_gate.py index 29106f5e..b75336cb 100644 --- a/tests/test_cursor_windows_write_gate.py +++ b/tests/test_cursor_windows_write_gate.py @@ -10,17 +10,21 @@ import json import os +import subprocess from pathlib import Path import pytest -from bundle_fixtures import MARKER -from conftest import init_repo, run_hook_command +import yaml +from bundle_fixtures import MARKER, bundle, hook_commands +from bundle_fixtures import write as write_files +from conftest import REPO_POLICIES, init_repo, run_hook_command from guardrails_support import build, outcomes, refused from test_plugin_gate import POLICY_ID, _manifest from test_plugin_gate import policy as shared_policy from chock.install.package import client from chock.plugin import cursor +from chock.plugin.bundle_build import Member, merged_files #: The shared fixture under a name no test parameter shadows. gate_policy = shared_policy @@ -177,3 +181,75 @@ def test_every_windows_spelling_of_a_copilot_create_is_refused(copilot: tuple, c path = _drive_spellings(project.joinpath(*WORKFLOW))[file_form] assert _copilot_refuses(copilot, cwd, path), (cwd, path) assert not _copilot_refuses(copilot, cwd, path, "jobs: {}\n") + + +# The real `pin-github-actions` member, merged into a bundle as each client installs it, fed the +# witnessed content: an indented, unpinned `uses:` in a new workflow of a repository with a commit. + +UNPINNED = ( + "name: witness\non: push\njobs:\n w:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@v4\n" +) +PINNED = UNPINNED.replace("@v4", "@" + "0" * 40) + + +@pytest.fixture(params=["cursor", "copilot"]) +def real(request, tmp_path: Path) -> tuple[str, Path, Path, list[str], Path]: + """(client, plugin, repo, the pre-tool commands, home) for the repo's own pin-github-actions.""" + pack = REPO_POLICIES / "pin-github-actions" + member = Member(pack, yaml.safe_load((pack / "manifest.yaml").read_text(encoding="utf-8"))) + fmt = client(request.param)["format"] + plugin = write_files(tmp_path / "dist" / fmt / "w", merged_files(fmt, bundle([member]), [member], tmp_path)) + (tmp_path / "witness-repo").mkdir() + project = init_repo(tmp_path / "witness-repo") + (project / "README.md").write_text("w\n", encoding="utf-8") + subprocess.run(["git", "add", "README.md"], cwd=project, check=True) + subprocess.run(["git", "commit", "-qm", "init"], cwd=project, check=True) + project.joinpath(*WORKFLOW).parent.mkdir(parents=True) + doc = json.loads(next(plugin.rglob("hooks.json")).read_text(encoding="utf-8"))["hooks"] + pre = doc.get("preToolUse") or doc["PreToolUse"] + home = tmp_path / "home" + home.mkdir() + return request.param, plugin, project, hook_commands(pre), home + + +def _real_payload(name: str, project: Path, path: str, text: str, roots: list[str]) -> dict: + if name == "cursor": + tool_input = {"file_path": path, "content": text} + return { + "conversation_id": "c", + "generation_id": "g", + "workspace_roots": roots, + **_WRITE, + "tool_input": tool_input, + } + tool_input = {"path": path, "file_text": text} + return {"hook_event_name": "PreToolUse", "cwd": roots[0], "tool_name": "Write", "tool_input": tool_input} + + +_WRITE = {"hook_event_name": "preToolUse", "tool_name": "Write"} + + +def _real_refuses(real: tuple, file_form: str, root_form: str, text: str) -> bool: + name, plugin, project, commands, home = real + path = _drive_spellings(project.joinpath(*WORKFLOW))[file_form] if WINDOWS else str(project.joinpath(*WORKFLOW)) + root = _root_spellings(project)[root_form] if WINDOWS and name == "cursor" else str(project) + if WINDOWS and name == "copilot": + root = _drive_spellings(project)[root_form if root_form in FILE_FORMS else "C:\\back"] + cwd = _elsewhere(project.parent) if name == "cursor" else project + return any( + refused(p) for p in outcomes(commands, plugin, cwd, _real_payload(name, project, path, text, [root]), home) + ) + + +def test_the_real_pin_policy_refuses_the_witnessed_workflow(real: tuple) -> None: + """The baseline on every OS, for both clients.""" + assert _real_refuses(real, "C:\\back", "c:\\", UNPINNED) + assert not _real_refuses(real, "C:\\back", "c:\\", PINNED) + + +@ON_WINDOWS +@pytest.mark.parametrize("root_form", ["/c:/", "c:\\", "C:/"]) +@pytest.mark.parametrize("file_form", FILE_FORMS) +def test_every_windows_spelling_of_the_witnessed_workflow_is_refused(real: tuple, file_form: str, root_form: str): + assert _real_refuses(real, file_form, root_form, UNPINNED), (real[0], file_form, root_form) + assert not _real_refuses(real, file_form, root_form, PINNED) From 8795295d10026b7e4fe29952f6d0792e1d659f3d Mon Sep 17 00:00:00 2001 From: Jothi Mani Rajendran <250249270+jothimani-rajendran@users.noreply.github.com> Date: Fri, 9 Oct 2026 01:31:47 +0000 Subject: [PATCH 7/7] test(gate): unborn HEAD and Python 3.12.0 in the Windows matrix The owner's witness repos were git init only, and their interpreter is CPython 3.12.0. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Jothi Mani Rajendran <250249270+jothimani-rajendran@users.noreply.github.com> --- .github/workflows/windows-paths.yml | 4 ++-- tests/test_cursor_windows_write_gate.py | 16 +++++++++------- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/.github/workflows/windows-paths.yml b/.github/workflows/windows-paths.yml index 6cc1061b..15c0228f 100644 --- a/.github/workflows/windows-paths.yml +++ b/.github/workflows/windows-paths.yml @@ -18,8 +18,8 @@ jobs: fail-fast: false matrix: # The interpreter a client finds on the user's machine is not CI's, and pathlib's Windows - # semantics move between minors. - python-version: ["3.12", "3.13", "3.14"] + # semantics moved between releases. 3.12.0 is the witnessed owner's. + python-version: ["3.12.0", "3.12", "3.13", "3.14"] runs-on: windows-latest defaults: run: diff --git a/tests/test_cursor_windows_write_gate.py b/tests/test_cursor_windows_write_gate.py index b75336cb..a643c891 100644 --- a/tests/test_cursor_windows_write_gate.py +++ b/tests/test_cursor_windows_write_gate.py @@ -184,7 +184,7 @@ def test_every_windows_spelling_of_a_copilot_create_is_refused(copilot: tuple, c # The real `pin-github-actions` member, merged into a bundle as each client installs it, fed the -# witnessed content: an indented, unpinned `uses:` in a new workflow of a repository with a commit. +# witnessed content: an indented, unpinned `uses:` in a new workflow, HEAD unborn or committed. UNPINNED = ( "name: witness\non: push\njobs:\n w:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@v4\n" @@ -192,24 +192,26 @@ def test_every_windows_spelling_of_a_copilot_create_is_refused(copilot: tuple, c PINNED = UNPINNED.replace("@v4", "@" + "0" * 40) -@pytest.fixture(params=["cursor", "copilot"]) +@pytest.fixture(params=[(c, h) for c in ("cursor", "copilot") for h in ("unborn", "committed")], ids=str) def real(request, tmp_path: Path) -> tuple[str, Path, Path, list[str], Path]: """(client, plugin, repo, the pre-tool commands, home) for the repo's own pin-github-actions.""" pack = REPO_POLICIES / "pin-github-actions" member = Member(pack, yaml.safe_load((pack / "manifest.yaml").read_text(encoding="utf-8"))) - fmt = client(request.param)["format"] + name, head = request.param + fmt = client(name)["format"] plugin = write_files(tmp_path / "dist" / fmt / "w", merged_files(fmt, bundle([member]), [member], tmp_path)) (tmp_path / "witness-repo").mkdir() project = init_repo(tmp_path / "witness-repo") - (project / "README.md").write_text("w\n", encoding="utf-8") - subprocess.run(["git", "add", "README.md"], cwd=project, check=True) - subprocess.run(["git", "commit", "-qm", "init"], cwd=project, check=True) + if head == "committed": # the owner's witness repos were `git init` only: HEAD unborn + (project / "README.md").write_text("w\n", encoding="utf-8") + subprocess.run(["git", "add", "README.md"], cwd=project, check=True) + subprocess.run(["git", "commit", "-qm", "init"], cwd=project, check=True) project.joinpath(*WORKFLOW).parent.mkdir(parents=True) doc = json.loads(next(plugin.rglob("hooks.json")).read_text(encoding="utf-8"))["hooks"] pre = doc.get("preToolUse") or doc["PreToolUse"] home = tmp_path / "home" home.mkdir() - return request.param, plugin, project, hook_commands(pre), home + return name, plugin, project, hook_commands(pre), home def _real_payload(name: str, project: Path, path: str, text: str, roots: list[str]) -> dict: