diff --git a/scripts/hooks/worktree_gate.ps1 b/scripts/hooks/worktree_gate.ps1 index edfb5a26..f063e55d 100644 --- a/scripts/hooks/worktree_gate.ps1 +++ b/scripts/hooks/worktree_gate.ps1 @@ -39,16 +39,26 @@ param( # a parameter-binding error instead of returning a path. In a PARAMETER DEFAULT that is evaluated # during binding, so it would kill the hook before its first line -- and a hook that exits # non-zero-but-not-2 lets the tool call through SILENTLY. The gate would be off with nothing to say so. - [string]$ReposFile = (Join-Path ( + # NB `$( ... )`, not `( ... )`. A bare paren opens a COMMAND-INVOCATION group, so PowerShell parses the + # `if` as a command NAME and fails with "The term 'if' is not recognized". A statement needs a + # subexpression. This shipped broken and was invisible to 192 tests, because every one of them passes + # -ReposFile explicitly and a parameter default is not evaluated when a value is supplied -- so nothing + # ever exercised the production path. The gate was OFF on every real tool call for the length of one + # install. tests/test_worktree_gate_default_reposfile.py now runs it with NO arguments. + [string]$ReposFile = (Join-Path $( if ($env:USERPROFILE) { $env:USERPROFILE } else { [Environment]::GetFolderPath('UserProfile') } ) ".claude/hooks/worktree-gate.repos.txt") ) -# Bumped whenever a RULE's behaviour changes, so `install-gate.ps1 -Status` can report which build is -# actually installed. The installed gate is a COPY (see install-gate.ps1); without a version stamp the only -# way to tell a stale copy from a current one is a byte compare, and nothing was doing one -- which is how -# rule 4 sat unshipped for five days while every test reported it present. -$GateVersion = "2026.07.29.1" +# A HUMAN LABEL, not the parity check. `install-gate.ps1 -Status` compares SHA-256 and that comparison is +# authoritative; this string exists so the output is readable, and it is bumped by hand. +# +# Which means it can lie, and immediately did: rules 1a, 3c and 3d were added without bumping it, so +# -Status printed the SAME version on both sides directly above a *** STALE *** verdict. The SHA caught +# the drift, but a stamp that disagrees with the verdict beside it is the exact ambiguity this machinery +# exists to remove. -Status now prints the SHA prefix on both lines, so agreement is visible rather than +# asserted, and this label can never again be the only thing a reader compares. +$GateVersion = "2026.07.29.2" # Fail OPEN: any unhandled error must let the tool call through, never block it. $ErrorActionPreference = "SilentlyContinue" diff --git a/scripts/worktree/install-gate.ps1 b/scripts/worktree/install-gate.ps1 index 60cc7fd8..d4261d0a 100644 --- a/scripts/worktree/install-gate.ps1 +++ b/scripts/worktree/install-gate.ps1 @@ -162,8 +162,14 @@ if ($Status) { $iVer = Get-GateVersion $GateDst ; $sVer = Get-GateVersion $srcGate $iSha = Get-GateHash $GateDst ; $sSha = Get-GateHash $srcGate - Write-Host "installed : $(if ($iSha) { "$GateDst v$iVer" } else { 'NOT installed' })" - Write-Host "source : $(if ($sSha) { "$srcGate v$sVer" } else { 'NOT FOUND' })" + # Print the SHA alongside the version. The version is a hand-bumped label and can disagree with + # reality -- it did: three rules shipped without a bump, so both lines read the same version directly + # above a STALE verdict. Showing the hash makes agreement VISIBLE instead of asserted. + # Lowercased: Get-FileHash returns uppercase, and every other hash a reader sees here (git, the + # parity test's output) is lowercase. Two spellings of the same digest invite a false "these differ". + $shortSha = { param($h) if ($h) { " sha $($h.Substring(0, 12).ToLowerInvariant())" } else { "" } } + Write-Host "installed : $(if ($iSha) { "$GateDst v$iVer$(& $shortSha $iSha)" } else { 'NOT installed' })" + Write-Host "source : $(if ($sSha) { "$srcGate v$sVer$(& $shortSha $sSha)" } else { 'NOT FOUND' })" if ($iSha -and $sSha) { if ($iSha -eq $sSha) { Write-Host "parity : IN SYNC" -ForegroundColor Green diff --git a/tests/test_install_gate_wiring.py b/tests/test_install_gate_wiring.py index 58bcc235..0e53aaf0 100644 --- a/tests/test_install_gate_wiring.py +++ b/tests/test_install_gate_wiring.py @@ -13,9 +13,14 @@ from __future__ import annotations +import os import re +import shutil +import subprocess from pathlib import Path +import pytest + ROOT = Path(__file__).resolve().parents[1] GATE = ROOT / "scripts" / "hooks" / "worktree_gate.ps1" INSTALLER = ROOT / "scripts" / "worktree" / "install-gate.ps1" @@ -135,3 +140,39 @@ def test_every_opt_in_tool_is_guarded_by_a_plain_switch() -> None: assert re.search(r"if \(\$EnterWorktreeGate\)", matcher_block()), ( "EnterWorktree must be added inside an `if ($EnterWorktreeGate)` block" ) + + +# ----------------------------------------------------------------------------- the -Status audit + + +def test_status_prints_a_sha_beside_each_version() -> None: + """`-Status` is the only way to see whether the RUNNING gate matches this checkout, and nothing + exercised it. It also shipped a defect worth pinning: `$GateVersion` is bumped by hand, and rules 1a, + 3c and 3d were added without a bump -- so it printed the SAME version on both lines directly above a + *** STALE *** verdict. The SHA comparison caught the drift, but a label that disagrees with the verdict + beside it is the ambiguity this whole audit exists to remove. + + Asserting the SHA is printed makes agreement visible rather than asserted. Read-only and safe to run + anywhere: `-Status` sits above the CLAUDECODE refusal precisely so a session can audit but not install. + """ + if shutil.which("pwsh") is None: + pytest.skip("SKIP (nothing run): pwsh not on PATH") + r = subprocess.run( + ["pwsh", "-NoProfile", "-NonInteractive", "-File", str(INSTALLER), "-Status"], + capture_output=True, + text=True, + timeout=120, + env={**os.environ, "CLAUDECODE": "1"}, + ) + assert r.returncode == 0, f"-Status must never fail:\n{(r.stderr + r.stdout)[:800]}" + out = r.stdout + print(out) + assert "installed :" in out and "source :" in out + # The source line always resolves (this checkout), so it must always carry a hash. + src_line = next(ln for ln in out.splitlines() if ln.startswith("source :")) + assert re.search(r"\bsha [0-9a-f]{12}\b", src_line), ( + f"the source line must show its hash, not just a hand-bumped version: {src_line!r}" + ) + assert re.search(r"\bv\d{4}\.\d{2}\.\d{2}\.\d+", src_line), ( + f"version label missing: {src_line!r}" + ) diff --git a/tests/test_worktree_gate_default_reposfile.py b/tests/test_worktree_gate_default_reposfile.py new file mode 100644 index 00000000..b6b1f300 --- /dev/null +++ b/tests/test_worktree_gate_default_reposfile.py @@ -0,0 +1,114 @@ +"""Run the gate the way PRODUCTION runs it: with no arguments at all. + +Every other test in this suite passes `-ReposFile` so it can point at a temp allowlist. That is necessary +for isolation and it left a hole the size of the whole product: **a parameter default is not evaluated when +a value is supplied**, so the default expression was never once executed by 192 passing tests. + +It shipped broken. `Join-Path ( if ... )` opens a command-invocation group, so PowerShell parsed the `if` +as a command name and the hook died with "The term 'if' is not recognized" before its first line. A +`PreToolUse` hook that exits non-zero-but-not-2 lets the tool call through, so the gate was **OFF for every +real tool call on the machine** while `install-gate.ps1 -Status` reported `IN SYNC` — parity compares the +installed copy to source, and both were equally broken. + +These tests therefore assert the one property no other test could: that the script is *runnable as +installed*. They never write, and they never depend on this machine's real allowlist contents. +""" + +from __future__ import annotations + +import json +import os +import shutil +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +GATE = ROOT / "scripts" / "hooks" / "worktree_gate.ps1" + +pytestmark = pytest.mark.skipif( + shutil.which("pwsh") is None, reason="pwsh (PowerShell 7) not on PATH" +) + + +def run_bare( + payload: dict[str, object], home: Path | None = None +) -> subprocess.CompletedProcess[str]: + """Invoke the hook with NO -ReposFile, exactly as Claude Code does.""" + env = dict(os.environ) + if home is not None: + env["USERPROFILE"] = str(home) + env["HOME"] = str(home) + return subprocess.run( + ["pwsh", "-NoProfile", "-NonInteractive", "-File", str(GATE)], + input=json.dumps(payload), + capture_output=True, + text=True, + timeout=90, + env=env, + ) + + +def read_payload() -> dict[str, object]: + return { + "session_id": "s-1", + "cwd": "C:/nowhere", + "hook_event_name": "PreToolUse", + "tool_name": "Read", + "tool_input": {"file_path": "C:/nowhere/x.txt"}, + } + + +def test_the_gate_runs_at_all_with_no_arguments(tmp_path: Path) -> None: + """The regression. The default $ReposFile expression must EVALUATE, not just parse. + + A bare `( if ... )` is a command-invocation group -- PowerShell reads `if` as a command name. Nothing + caught it because no test omitted -ReposFile. + """ + r = run_bare(read_payload(), home=tmp_path) + combined = r.stderr + r.stdout + assert "is not recognized" not in combined, ( + f"the default $ReposFile expression does not evaluate -- the hook dies before rule 1:\n" + f"{combined[:800]}" + ) + assert "ParserError" not in combined and "ParentContainsErrorRecordException" not in combined, ( + f"the script failed to parse or run:\n{combined[:800]}" + ) + assert r.returncode == 0, f"a hook must always exit 0:\n{combined[:800]}" + + +def test_a_bare_invocation_with_no_allowlist_is_a_silent_allow(tmp_path: Path) -> None: + """The kill switch, through the production path: an absent allowlist under the resolved home means the + gate is off, and it must say nothing at all rather than erroring.""" + r = run_bare(read_payload(), home=tmp_path) + assert r.returncode == 0 + assert r.stdout.strip() == "", f"expected a silent allow, got: {r.stdout[:400]}" + assert r.stderr.strip() == "", f"a clean run must not write to stderr: {r.stderr[:400]}" + + +def test_a_bare_invocation_reads_the_allowlist_under_the_resolved_home(tmp_path: Path) -> None: + """Proves the default path is not merely evaluable but CORRECT: drop an allowlist where the default + expression should look, and the gate must deny against it with no -ReposFile given.""" + hooks = tmp_path / ".claude" / "hooks" + hooks.mkdir(parents=True) + governed = tmp_path / "Governed" + (hooks / "worktree-gate.repos.txt").write_text(f"{governed}\n", encoding="utf-8") + + r = run_bare( + { + "session_id": "s-1", + "cwd": str(governed), + "hook_event_name": "PreToolUse", + "tool_name": "Edit", + "tool_input": {"file_path": str(governed / "src" / "app.py")}, + }, + home=tmp_path, + ) + assert r.returncode == 0, r.stderr + assert r.stdout.strip(), ( + "the gate found no allowlist at the default location -- it denied nothing" + ) + decision = json.loads(r.stdout)["hookSpecificOutput"] + assert decision["permissionDecision"] == "deny" + assert "SHARED PRIMARY" in decision["permissionDecisionReason"]