Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 16 additions & 6 deletions scripts/hooks/worktree_gate.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
10 changes: 8 additions & 2 deletions scripts/worktree/install-gate.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
41 changes: 41 additions & 0 deletions tests/test_install_gate_wiring.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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}"
)
114 changes: 114 additions & 0 deletions tests/test_worktree_gate_default_reposfile.py
Original file line number Diff line number Diff line change
@@ -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"]
Loading