From 96a8bbf92917afc2132909de563ef008beffdcd3 Mon Sep 17 00:00:00 2001 From: Scott Hall Date: Sun, 18 Oct 2026 16:08:50 -0500 Subject: [PATCH 1/6] Run the account's worktree gate in the shell dispatcher, standing down only on a per-call proof The user-scope worktree_gate.ps1 cost its own pwsh launch on every Bash/PowerShell call. The project dispatcher now runs it in-process (--user-hook=worktree_gate, first) when the account delegates it, and writes /shell-guard-proof/.ok only after the gate allowed that very payload silently; anything else writes .run. install-gate.ps1 writes the account's Bash|PowerShell entry as a bash script that stands down only on an .ok for THIS call's tool_use_id, holding this session's id, newer than the .wait it wrote when it began a bounded, fork-free wait (read -t on /dev/ptmx, 20 s). Otherwise it runs the gate itself, so a missing, failing or slow dispatcher leaves the gate running. onFailure=block, timeout 60: if bash cannot start or the entry overruns, the call is blocked, not left ungated. A gate crash still fails open, with a systemMessage. Measured, Windows job-object CPU, median of 15: this repo 1,734 -> 1,109 ms per shell call; other projects 766 -> 875 ms. Proposed PR title: Fold the worktree gate into the shell dispatcher behind a per-call proof --- .claude/settings.json | 5 +- scripts/hooks/shell-guard-dispatch.ps1 | 168 ++++++++-- scripts/worktree/install-gate.ps1 | 96 +++++- tests/test_blanket_stage_guard.py | 7 +- tests/test_shell_guard_dispatch.py | 404 ++++++++++++++++++++++++- 5 files changed, 635 insertions(+), 45 deletions(-) diff --git a/.claude/settings.json b/.claude/settings.json index c2b262dcf1..f930950ffc 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -115,12 +115,13 @@ "-NonInteractive", "-File", "${CLAUDE_PROJECT_DIR}/scripts/hooks/shell-guard-dispatch.ps1", + "--user-hook=worktree_gate", "${CLAUDE_PROJECT_DIR}/scripts/hooks/block-blanket-git-stage.ps1", "${CLAUDE_PROJECT_DIR}/scripts/hooks/block-api-burn.ps1", "${CLAUDE_PROJECT_DIR}/scripts/hooks/block-unbounded-fs-scan.ps1" ], - "timeout": 45, - "statusMessage": "Checking for a blanket git stage, GitHub API polling, or an unbounded filesystem scan" + "timeout": 60, + "statusMessage": "Checking the worktree gate, blanket git stage, GitHub API polling and filesystem scans" } ] } diff --git a/scripts/hooks/shell-guard-dispatch.ps1 b/scripts/hooks/shell-guard-dispatch.ps1 index d593c4452d..cf3aaab9e6 100644 --- a/scripts/hooks/shell-guard-dispatch.ps1 +++ b/scripts/hooks/shell-guard-dispatch.ps1 @@ -40,10 +40,11 @@ because a guard that ran long after a deny could push the hook past its timeout, and a hook that times out lets the command run -- an already-reached deny turned into an allow. The cost is that a command two guards would refuse shows one reason, and the retry shows the other. - A guard that leaves $LASTEXITCODE at 2 is read as Claude Code's blocking exit and passed on; - its stderr already went to this process's stderr. In-process, `exit 2` and a native command - that exited 2 look the same, so a guard that runs one would block here where it allowed - alone: a false deny, which is the visible direction. The guards wired today run none. + A guard's `exit 2` is read as Claude Code's blocking exit and passed on; its stderr already + went to this process's stderr. An exit is told apart from a native command's code by `$?`: + measured 2026-10-17, `$?` after `& script` is false only when the script ran `exit N` with N + non-zero, and stays true after a git call that failed or a Write-Error. $LASTEXITCODE alone + cannot tell them apart, and worktree_gate.ps1 runs git. A non-deny decision (`ask`, `allow`) is kept, the first one wins, and it is written once every guard has run. @@ -53,12 +54,32 @@ error Claude Code showed for a separate guard process that failed to start or crashed, so a broken guard is still reported rather than silently off. - ORDER IS THE SETTINGS FILE'S, AND THE BOUNDED GUARD GOES FIRST. block-blanket-git-stage.ps1 - refuses an over-long payload, so placing it first keeps a huge payload from reaching the two - guards that parse whatever they are given. That guard's own read stops at its cap, but THIS - file reads all of stdin first, so on the wired path the read itself is not bounded; only the - parse is. The timeout in settings.json is the three guards' former per-guard timeouts summed, - so a run in which every guard finishes inside its old budget still finishes inside this one. + `--user-hook=worktree_gate` RUNS THE GATE THE ACCOUNT INSTALLED, AND PROVES IT RAN, PER CALL. + It names ~/.claude/hooks/worktree_gate.ps1, where scripts/worktree/install-gate.ps1 puts the + gate, and runs it only when this account DELEGATES it: the account's settings.json + ($env:CLAUDE_CONFIG_DIR, else ~/.claude) contains `shell-guard-proof`, which the installer + writes into the account's own Bash|PowerShell entry. That entry still runs for every call, in + parallel with this file. It stands down only when it finds, in + /shell-guard-proof/, a proof named by THIS call's tool_use_id, written after it started + waiting, holding this session's id. This file writes that proof (`.ok`) only when + the gate it ran for this payload finished normally with nothing on stdout -- a silent allow. + Anything else (a decision, an exit, a throw, a missing gate) writes `.run`, which + tells the entry to stop waiting and run the gate itself. So a missing, failing or slow copy of + this file leaves the account's entry running the gate; install-gate.ps1 says how it waits. + `.session` tells the entry that this session's dispatcher runs the gate at all, so + a project without this file costs no wait. It is a hint, never a proof: forged or stale, it + only makes the entry wait before running the gate. An account that does not delegate runs the + gate itself, and this file skips it. + + ORDER IS THE SETTINGS FILE'S: THE WORKTREE GATE FIRST, THEN THE BOUNDED GUARD. The account's + entry is waiting on the gate's proof, so the gate must not queue behind guards that could use + up the wait. It parsed whatever it was given when it ran alone, so going first costs it + nothing. block-blanket-git-stage.ps1 comes next: it refuses an over-long payload, which keeps a + huge payload from the two guards after it that parse whatever they are given. That guard's own + read stops at its cap, but THIS file reads all of stdin first, so on the wired path the read + itself is not bounded; only the parse is. The timeout in settings.json is the guards' former + per-guard timeouts summed, so a run in which every guard finishes inside its old budget still + finishes inside this one. WIRING IS NOT ASSERTED HERE ON PURPOSE. Which guards run is a property of settings.json. ASCII-only on purpose (PS 5.1 ANSI-read lesson); run under pwsh 7 by the hook. @@ -79,35 +100,124 @@ function Write-ShellGuardFailure([string]$Path, [string]$Why) { [Console]::Error.WriteLine("shell-guard-dispatch: guard '$Path' failed open: $Why") } +$ShellGuardHome = if ($env:USERPROFILE) { $env:USERPROFILE } else { [Environment]::GetFolderPath('UserProfile') } +$ShellGuardAccount = if ($env:CLAUDE_CONFIG_DIR) { $env:CLAUDE_CONFIG_DIR } else { Join-Path $ShellGuardHome '.claude' } +$ShellGuardProofDir = Join-Path $ShellGuardAccount 'shell-guard-proof' +# The same patterns the account's bash entry uses, so both name the same files; a payload they read +# differently only costs the entry its wait. +$ShellGuardSession = if ($ShellGuardPayload -cmatch '"session_id"\s*:\s*"(?[A-Za-z0-9-]{1,100})"') { $Matches['v'] } else { $null } +$ShellGuardCall = if ($ShellGuardPayload -cmatch '"tool_use_id"\s*:\s*"(?[A-Za-z0-9_-]{1,100})"') { $Matches['v'] } else { $null } +# Scoping is dynamic, so a guard reading $Matches before its own first match would see ours. +$Matches = $null + +function Write-ShellGuardProof([string]$Kind) { + # Kind 'ok' stands the account's entry down for this call; 'run' tells it to run the gate now. + if (-not $ShellGuardSession -or -not $ShellGuardCall) { return } + try { + $final = Join-Path $ShellGuardProofDir "$ShellGuardCall.$Kind" + $part = "$final.$PID.tmp" + # Written whole, then renamed, so the entry never reads a half-written proof. + [System.IO.File]::WriteAllText($part, "$ShellGuardSession`n") + [System.IO.File]::Move($part, $final) + } catch { + # No proof only costs the entry its own run of the gate. + [Console]::Error.WriteLine("shell-guard-dispatch: could not write the gate's proof: $($_.Exception.Message)") + } +} + +function Clear-ShellGuardProofs { + # Every call leaves files behind, so prune at most every 10 minutes: proofs and waits older + # than that, and session hints older than 30 days. A session older than that only waits. + try { + $stamp = Join-Path $ShellGuardProofDir 'pruned' + $calls = [DateTime]::UtcNow.AddMinutes(-10) + # A missing stamp reads as 1601, so it prunes. + if ([System.IO.File]::GetLastWriteTimeUtc($stamp) -gt $calls) { return } + [System.IO.File]::WriteAllText($stamp, '') + $sessions = [DateTime]::UtcNow.AddDays(-30) + foreach ($f in [System.IO.Directory]::GetFiles($ShellGuardProofDir)) { + $cutoff = if ($f.EndsWith('.session')) { $sessions } else { $calls } + if ([System.IO.File]::GetLastWriteTimeUtc($f) -lt $cutoff) { + try { [System.IO.File]::Delete($f) } catch { $null = $_ } + } + } + } catch { + $null = $_ + } +} + $ShellGuardFailed = $false $ShellGuardKept = $null # With no arguments the parameter is $null, and @($null) is one empty entry, not none. foreach ($ShellGuardPath in @($ShellGuardPaths | Where-Object { $_ })) { - if (-not (Test-Path -LiteralPath $ShellGuardPath -PathType Leaf)) { - Write-ShellGuardFailure $ShellGuardPath 'no such file' + # Non-null only for the gate: the proof its run leaves for the account's entry. + $ShellGuardProof = $null + if ($ShellGuardPath -ceq '--user-hook=worktree_gate') { + $ShellGuardPath = Join-Path $ShellGuardHome '.claude/hooks/worktree_gate.ps1' + $ShellGuardHint = if ($ShellGuardSession) { Join-Path $ShellGuardProofDir "$ShellGuardSession.session" } else { $null } + $ShellGuardDelegated = $false + try { + $ShellGuardDelegated = [System.IO.File]::ReadAllText((Join-Path $ShellGuardAccount 'settings.json')).Contains('shell-guard-proof') + } catch { + # No readable account settings: nothing delegates, so the gate is not this run's. + $null = $_ + } + if (-not $ShellGuardDelegated) { + # An entry still waiting on this session's hint is told to stop waiting. + if ($ShellGuardHint -and (Test-Path -LiteralPath $ShellGuardHint)) { + Write-ShellGuardProof 'run' + try { [System.IO.File]::Delete($ShellGuardHint) } catch { $null = $_ } + } + continue + } + $ShellGuardProof = 'run' + if ($ShellGuardHint) { + try { + $null = [System.IO.Directory]::CreateDirectory($ShellGuardProofDir) + if (-not (Test-Path -LiteralPath $ShellGuardHint)) { [System.IO.File]::WriteAllText($ShellGuardHint, '') } + Clear-ShellGuardProofs + } catch { + [Console]::Error.WriteLine("shell-guard-dispatch: could not record the session hint: $($_.Exception.Message)") + } + } + } elseif ($ShellGuardPath.StartsWith('--')) { + Write-ShellGuardFailure $ShellGuardPath 'unknown option' $ShellGuardFailed = $true continue } - $ShellGuardBuffer = [System.IO.StringWriter]::new() - $global:LASTEXITCODE = 0 try { - [Console]::SetIn([System.IO.StringReader]::new($ShellGuardPayload)) - [Console]::SetOut($ShellGuardBuffer) - $null = & $ShellGuardPath - } catch { - Write-ShellGuardFailure $ShellGuardPath $_.Exception.Message - $ShellGuardFailed = $true - continue + if (-not (Test-Path -LiteralPath $ShellGuardPath -PathType Leaf)) { + Write-ShellGuardFailure $ShellGuardPath 'no such file' + $ShellGuardFailed = $true + continue + } + $ShellGuardBuffer = [System.IO.StringWriter]::new() + $global:LASTEXITCODE = 0 + try { + [Console]::SetIn([System.IO.StringReader]::new($ShellGuardPayload)) + [Console]::SetOut($ShellGuardBuffer) + $null = & $ShellGuardPath + $ShellGuardCompleted = $? + } catch { + Write-ShellGuardFailure $ShellGuardPath $_.Exception.Message + $ShellGuardFailed = $true + continue + } finally { + [Console]::SetOut($ShellGuardStdout) + } + if (-not $ShellGuardCompleted) { + if ($LASTEXITCODE -eq 2) { exit 2 } + Write-ShellGuardFailure $ShellGuardPath "exit code $LASTEXITCODE" + $ShellGuardFailed = $true + continue + } + $ShellGuardOutput = $ShellGuardBuffer.ToString() + # A silent allow, and nothing else, is the gate's verdict the account's entry may stand on. + if ($ShellGuardProof -and -not $ShellGuardOutput) { $ShellGuardProof = 'ok' } } finally { - [Console]::SetOut($ShellGuardStdout) - } - if ($LASTEXITCODE -eq 2) { exit 2 } - if ($LASTEXITCODE -ne 0) { - Write-ShellGuardFailure $ShellGuardPath "exit code $LASTEXITCODE" - $ShellGuardFailed = $true - continue + # Every way out of the gate's run -- continue, exit 2, a throw -- writes its proof first. + if ($ShellGuardProof) { Write-ShellGuardProof $ShellGuardProof } } - $ShellGuardOutput = $ShellGuardBuffer.ToString() if (-not $ShellGuardOutput.Trim()) { continue } $ShellGuardVerdict = $null try { diff --git a/scripts/worktree/install-gate.ps1 b/scripts/worktree/install-gate.ps1 index b80d25c17f..5b6c8fc837 100644 --- a/scripts/worktree/install-gate.ps1 +++ b/scripts/worktree/install-gate.ps1 @@ -1148,6 +1148,83 @@ if ((Get-GateHash $GateDst) -ne $checkedSha) { $command = "pwsh -NoProfile -File `"$GateDst`"" +# THE SHELL MATCHER RUNS THE GATE UNLESS IT HOLDS PROOF THAT THE GATE ALREADY RAN FOR THIS CALL. +# A project whose settings.json passes `--user-hook=worktree_gate` to +# scripts/hooks/shell-guard-dispatch.ps1 runs this gate inside the one pwsh it starts for its own +# guards. Hooks run in parallel, so this entry cannot see that run; it waits for its proof instead. +# The proof is /shell-guard-proof/.ok, holding the session id, which the +# dispatcher writes only after the gate it ran for this very payload allowed it silently. This entry +# stands down only when that file exists, holds this session's id, and is NEWER than the +# .wait file this entry wrote when it began waiting. In every other case it runs the +# gate itself, as it did before: +# * no dispatcher ran (another project, an old checkout, a missing or broken dispatcher): no +# proof, so the wait ends and the gate runs; +# * the dispatcher's gate denied, decided, exited or threw: it writes .run, and the +# gate runs here at once; +# * the dispatcher is slow: the wait ends after 20 s and the gate runs here; +# * a proof written before this call's wait began (a stale file, or one planted for an id read +# early from the transcript) fails the NEWER-than test, and the gate runs. +# A tool_use_id is new for every call, so a proof cannot be reused for another. What this does not +# stop is a process that sits waiting for this call's .wait file and writes the .ok at once; that is +# a deliberate evasion running under the same user, which can edit this settings file outright. +# +# .session is the dispatcher's hint that this session's project runs the gate. Without +# it the entry does not wait at all, so a project with no dispatcher pays nothing. The hint never +# stands the gate down: forged or stale, it only buys a wait before the gate runs. The project's own +# settings.json must also carry `--user-hook=worktree_gate`, for the same reason. +# +# TIMEOUTS, AND WHY A FAILURE HERE BLOCKS. The wait is 20 s at most and the hook's timeout is 60 s, so +# the gate keeps at least 40 s after the longest wait, against the 15 s this entry used to give it. +# onFailure = block makes this entry's OWN failure a refusal, not a silent allow: if bash cannot +# start, or the entry and the gate together run past 60 s, the call is blocked rather than left +# ungated. That is stricter than before, when a gate past 15 s failed open; it now blocks, and only +# past 40 s. A gate that crashes still fails open, as it always did, because the entry maps the +# gate's exit other than 0 or 2 to an allow with a systemMessage saying so; a blocking exit 2 is +# passed on. The gate is not wrapped in `timeout` or captured with $(...): measured, each added +# about 0.2 s of wall time to every call that runs the gate here. (A bare `timeout` is worse than +# slow: a bash started without Git's /usr/bin on PATH resolved it to C:\Windows\System32\ +# timeout.exe, which exited 1 without running the gate.) +# +# COST, measured 2026-10-17 on mefordev01; the PR that added this records the instrument and numbers. +# The wait reads with `read -t` on a pseudo-terminal nobody writes to, which sleeps without starting +# a process; where /dev/ptmx cannot be opened, the entry runs the gate at once. +# Single-quoted in bash below, so a quote in the profile path is closed, escaped and reopened. +$gateForBash = $GateDst.Replace('\', '/').Replace("'", "'\''") +$shellEntry = @' +# worktree_gate.ps1 for shell tool calls: stands down only on this call's shell-guard-proof. +# scripts/worktree/install-gate.ps1 says why each check is here. +g='__GATE__' +IFS= read -r -d '' in +d=${CLAUDE_CONFIG_DIR:-$USERPROFILE/.claude}/shell-guard-proof +if [[ $in =~ \"session_id\"[[:space:]]*:[[:space:]]*\"([A-Za-z0-9-]{1,100})\" ]]; then + s=${BASH_REMATCH[1]} + if [[ -e $d/$s.session && $in =~ \"tool_use_id\"[[:space:]]*:[[:space:]]*\"([A-Za-z0-9_-]{1,100})\" ]]; then + t=${BASH_REMATCH[1]} ps= + { IFS= read -r -d '' ps < "$CLAUDE_PROJECT_DIR/.claude/settings.json"; } 2>/dev/null + if [[ $ps == *--user-hook=worktree_gate* ]] && { : > "$d/$t.wait" && exec {w}<>/dev/ptmx; } 2>/dev/null; then + e=$(( ${EPOCHREALTIME//[!0-9]/} + 20000000 )) + for (( i = 0; i < 400; i++ )); do + if [[ -e $d/$t.ok ]]; then + p= + IFS= read -r p < "$d/$t.ok" + [[ $d/$t.ok -nt $d/$t.wait && $p == "$s" ]] && exit 0 + break + fi + [[ -e $d/$t.run ]] && break + (( ${EPOCHREALTIME//[!0-9]/} < e )) || break + read -t 0.05 -u "$w" + done + exec {w}>&- + fi + fi +fi +pwsh -NoProfile -File "$g" <<< "$in" +r=$? +(( r == 0 || r == 2 )) && exit "$r" +printf '{"systemMessage":"worktree_gate.ps1 exited %s and failed open for this call"}\n' "$r" +exit 0 +'@.Replace('__GATE__', $gateForBash).Replace("`r`n", "`n") + # One matcher per rule in scripts/hooks/worktree_gate.ps1. A rule the hook implements but that is not # matched here NEVER FIRES -- the hook is simply not invoked for that tool, and nothing says so. Rule 3 # shipped in exactly that state. tests/test_install_gate_wiring.py now asserts that every tool the script @@ -1165,14 +1242,21 @@ if ($EnterWorktreeGate) { } $entries = foreach ($m in $matchers) { + $hook = [ordered]@{ + type = "command" + command = $command + timeout = 15 + statusMessage = "Checking worktree gate" + } + if ($m -ceq "Bash|PowerShell") { + $hook.command = $shellEntry + $hook.shell = "bash" + $hook.timeout = 60 + $hook.onFailure = "block" + } [ordered]@{ matcher = $m - hooks = @([ordered]@{ - type = "command" - command = $command - timeout = 15 - statusMessage = "Checking worktree gate" - }) + hooks = @($hook) } } diff --git a/tests/test_blanket_stage_guard.py b/tests/test_blanket_stage_guard.py index bb2349bb29..9b394c32f3 100644 --- a/tests/test_blanket_stage_guard.py +++ b/tests/test_blanket_stage_guard.py @@ -1583,7 +1583,9 @@ def _wired_command_line() -> list[str]: @pytest.mark.parametrize("tool", ["Bash", "PowerShell"]) -def test_the_wired_command_line_denies_a_blanket_stage_and_allows_its_control(tool: str) -> None: +def test_the_wired_command_line_denies_a_blanket_stage_and_allows_its_control( + tool: str, tmp_path: Path +) -> None: """Run the handler's own command line, not `run_guard`'s, so a wrong path or flag shows here.""" argv = _wired_command_line() @@ -1594,6 +1596,9 @@ def decide(command: str) -> dict[str, Any] | None: capture_output=True, text=True, timeout=60, + # Hermetic: no account delegates the installed worktree gate, so the shared handler runs + # this repository's guards only, and writes no proof into the real account. + env={**os.environ, "CLAUDE_CONFIG_DIR": str(tmp_path)}, ) assert proc.returncode == 0, f"wired hook exited {proc.returncode}: {proc.stderr}" return json.loads(proc.stdout) if proc.stdout.strip() else None diff --git a/tests/test_shell_guard_dispatch.py b/tests/test_shell_guard_dispatch.py index 39c5d8707c..0370ea994d 100644 --- a/tests/test_shell_guard_dispatch.py +++ b/tests/test_shell_guard_dispatch.py @@ -15,15 +15,20 @@ from __future__ import annotations import json +import os import shutil import subprocess +import tempfile +import time +from collections.abc import Sequence from concurrent.futures import ThreadPoolExecutor from pathlib import Path from typing import Any import pytest -from tests._spawn_lock import run_single, spawn_burst +from tests._bash_resolver import require_bash +from tests._spawn_lock import run_single, single_spawn, spawn_burst from tests.test_blanket_stage_guard import bash from tests.test_claude_settings_contract import ( _PLACEHOLDER, @@ -32,9 +37,18 @@ _load, matcher_selects, ) +from tests.test_install_gate_upstream_provenance import _psq from tests.test_worktree_gate import assert_denied DISPATCHER = _ROOT / "scripts" / "hooks" / "shell-guard-dispatch.ps1" +INSTALLER = _ROOT / "scripts" / "worktree" / "install-gate.ps1" +USER_GATE = "--user-hook=worktree_gate" + +# HERMETIC BY DEFAULT. The dispatcher reads the running account's settings.json to decide whether +# to run the installed worktree gate, and writes proofs into that account. A test that inherited +# this session's CLAUDE_CONFIG_DIR would run the real gate and write into the real account. +_NO_ACCOUNT = Path(tempfile.gettempdir()) / "mefor-test-no-claude-account" +_HERMETIC = {"CLAUDE_CONFIG_DIR": str(_NO_ACCOUNT), "CLAUDE_PROJECT_DIR": str(_NO_ACCOUNT)} pytestmark = pytest.mark.skipif( shutil.which("pwsh") is None, reason="pwsh (PowerShell 7) not on PATH" @@ -65,18 +79,23 @@ def _wired_guards() -> list[Path]: """The guard paths the settings file hands the dispatcher, in its order.""" argv = _wired_argv() after = argv[argv.index(next(a for a in argv if a.endswith(DISPATCHER.name))) + 1 :] - return [Path(a) for a in after] + return [Path(a) for a in after if not a.startswith("--")] -def _proc(argv: list[str], raw: str, *, burst: bool = False) -> subprocess.CompletedProcess[str]: +def _proc( + argv: list[str], raw: str, *, burst: bool = False, env: dict[str, str] | None = None +) -> subprocess.CompletedProcess[str]: # A single launch takes the shared side of the pwsh spawn lock (BACKLOG #1304). Inside a # burst, which already holds the exclusive side, it must not wait on its own burst. launch = subprocess.run if burst else run_single - return launch(argv, input=raw, capture_output=True, text=True, timeout=120) + full = {**os.environ, **_HERMETIC, **(env or {})} + return launch(argv, input=raw, capture_output=True, text=True, timeout=120, env=full) -def _run(argv: list[str], raw: str, *, burst: bool = False) -> str: - proc = _proc(argv, raw, burst=burst) +def _run( + argv: list[str], raw: str, *, burst: bool = False, env: dict[str, str] | None = None +) -> str: + proc = _proc(argv, raw, burst=burst, env=env) # Fail-open is the contract: a non-zero exit is a hook error, which lets the command run. assert proc.returncode == 0, f"{argv[-1]} exited {proc.returncode}: {proc.stderr}" return proc.stdout @@ -86,7 +105,7 @@ def _standalone(guard: Path, raw: str, *, burst: bool = False) -> str: return _run(["pwsh", "-NoProfile", "-NonInteractive", "-File", str(guard)], raw, burst=burst) -def _dispatch_argv(guards: list[Path]) -> list[str]: +def _dispatch_argv(guards: Sequence[Path | str]) -> list[str]: argv = ["pwsh", "-NoProfile", "-NonInteractive", "-File", str(DISPATCHER)] return argv + [str(g) for g in guards] @@ -114,6 +133,9 @@ def test_one_handler_runs_every_shell_guard_through_the_dispatcher() -> None: # The bounded guard first: it refuses an over-long payload unread, so a huge payload never # reaches the guards that parse whatever they are given. The dispatcher's header says why. assert names[0] == "block-blanket-git-stage.ps1", names + # The installed worktree gate runs before every repository guard: the account's entry waits + # on its proof (the dispatcher says why). + assert argv[5] == USER_GATE, argv for guard in _wired_guards(): assert guard.is_file(), guard @@ -328,3 +350,371 @@ def test_every_guard_allowing_is_an_allow(tmp_path: Path) -> None: raw = json.dumps(bash("git status --short")) assert _dispatch(_wired_guards(), raw) == "" assert _dispatch([], raw) == "" + + +def test_a_failed_native_command_inside_a_guard_is_not_a_failed_guard(tmp_path: Path) -> None: + """worktree_gate.ps1 runs git, and a git call that fails leaves $LASTEXITCODE non-zero while + the script goes on to finish normally. Run alone that was exit 0, so it must be here too.""" + native = _guard( + tmp_path, + "native.ps1", + "$null = [Console]::In.ReadToEnd(); $null = git rev-parse --verify no-such-ref 2>$null", + ) + proc = _proc(_dispatch_argv([native]), json.dumps(bash("git status"))) + assert proc.returncode == 0, (proc.returncode, proc.stderr) + assert proc.stdout == "" + + +def test_an_unknown_option_is_reported_not_run() -> None: + proc = _proc(_dispatch_argv(["--user-hook=../escape"]), json.dumps(bash("git status"))) + assert proc.returncode == 1 and "unknown option" in proc.stderr + + +# ================================================================== the account's worktree gate + +_SID = "0f1e2d3c-aaaa-bbbb-cccc-123456789abc" +_CALL = "toolu_01TestCallAbc" + + +class _World: + """A fake home with an installed gate stub, an account that may delegate it, and a project + whose settings pass the gate to the dispatcher. The stub logs every run to `log`.""" + + def __init__(self, tmp_path: Path, *, delegates: bool = True, gate: str | None = "") -> None: + self.log = tmp_path / "gate-runs.log" + home = tmp_path / "home" + hooks = home / ".claude" / "hooks" + hooks.mkdir(parents=True) + self.gate = hooks / "worktree_gate.ps1" + if gate is not None: + _guard( + hooks, + self.gate.name, + "$in = [Console]::In.ReadToEnd()\n" + f"Add-Content -LiteralPath {_psq(str(self.log))} -Value 'ran'\n" + gate, + ) + self.account = tmp_path / "account" + self.account.mkdir() + command = "bash entry: shell-guard-proof" if delegates else "pwsh -File worktree_gate.ps1" + entry = {"matcher": "Bash|PowerShell", "hooks": [{"command": command}]} + (self.account / "settings.json").write_text( + json.dumps({"hooks": {"PreToolUse": [entry]}}), encoding="utf-8" + ) + self.proofs = self.account / "shell-guard-proof" + self.project = tmp_path / "project" + (self.project / ".claude").mkdir(parents=True) + self.project_settings(USER_GATE) + self.env = { + "USERPROFILE": str(home), + "HOME": str(home), + "CLAUDE_CONFIG_DIR": str(self.account), + "CLAUDE_PROJECT_DIR": str(self.project), + } + + def project_settings(self, arg: str) -> None: + (self.project / ".claude" / "settings.json").write_text( + json.dumps({"args": [arg]}), encoding="utf-8" + ) + + def runs(self) -> int: + return len(self.log.read_text(encoding="utf-8").split()) if self.log.exists() else 0 + + def proof(self, kind: str, call: str = _CALL) -> Path: + return self.proofs / f"{call}.{kind}" + + def hint(self) -> Path: + return self.proofs / f"{_SID}.session" + + +def _payload(command: str = "git status", call: str | None = _CALL) -> str: + body = {**bash(command), "session_id": _SID} + if call is not None: + body["tool_use_id"] = call + return json.dumps(body) + + +_GATE_DENIES = _DENY % "installed gate" + + +def test_a_delegated_gate_that_allows_leaves_this_call_its_proof(tmp_path: Path) -> None: + world = _World(tmp_path) + proc = _proc(_dispatch_argv([USER_GATE]), _payload(), env=world.env) + assert proc.returncode == 0 and proc.stdout == "", (proc.returncode, proc.stderr) + assert world.runs() == 1 + assert world.proof("ok").read_text(encoding="utf-8") == _SID + "\n" + assert not world.proof("run").exists() + assert world.hint().is_file(), "the account's entry waits only for a hinted session" + + +def test_a_delegated_gate_that_denies_leaves_no_proof(tmp_path: Path) -> None: + world = _World(tmp_path, gate=_GATE_DENIES) + out = _run(_dispatch_argv([USER_GATE]), _payload(), env=world.env) + assert assert_denied(_decision(out)) == "installed gate" + assert not world.proof("ok").exists() + assert world.proof("run").is_file(), "the entry is told to run the gate now, not wait" + + +@pytest.mark.parametrize( + ("gate", "code"), + [ + ("[Console]::Error.WriteLine('gate blocks'); exit 2", 2), + ("exit 1", 1), + ("throw 'broken gate'", 1), + (None, 1), + (_DENY.replace("'deny'", "'ask'") % "asked", 0), + ("[Console]::Out.Write('stray text')", 0), + ], + ids=["exit-2", "exit-1", "throws", "missing", "asks", "stray-stdout"], +) +def test_anything_but_a_silent_allow_leaves_no_proof( + tmp_path: Path, gate: str | None, code: int +) -> None: + """Only a gate that finished normally and printed nothing stands the account's entry down.""" + world = _World(tmp_path, gate=gate) + proc = _proc(_dispatch_argv([USER_GATE]), _payload(), env=world.env) + assert proc.returncode == code, (proc.returncode, proc.stdout, proc.stderr) + assert not world.proof("ok").exists() + assert world.proof("run").is_file() + + +def test_a_payload_without_a_call_id_gets_the_gate_but_no_proof(tmp_path: Path) -> None: + world = _World(tmp_path) + proc = _proc(_dispatch_argv([USER_GATE]), _payload(call=None), env=world.env) + assert proc.returncode == 0, proc.stderr + assert world.runs() == 1 + assert not list(world.proofs.glob("*.ok")) and not list(world.proofs.glob("*.run")) + + +def test_an_account_that_does_not_delegate_is_not_run_twice(tmp_path: Path) -> None: + """That account still runs the gate itself, so running it here too would be a second run.""" + world = _World(tmp_path, delegates=False, gate=_GATE_DENIES) + assert _run(_dispatch_argv([USER_GATE]), _payload(), env=world.env) == "" + assert world.runs() == 0 + assert not world.proofs.exists() + + +def test_an_account_that_stopped_delegating_releases_a_waiting_entry(tmp_path: Path) -> None: + world = _World(tmp_path, delegates=False) + world.proofs.mkdir() + world.hint().touch() + assert _run(_dispatch_argv([USER_GATE]), _payload(), env=world.env) == "" + assert world.proof("run").is_file() and not world.proof("ok").exists() + assert not world.hint().exists() + + +def test_old_proofs_are_pruned(tmp_path: Path) -> None: + world = _World(tmp_path) + world.proofs.mkdir() + old = time.time() - 3600 + stale = world.proof("ok", "toolu_old") + stale.write_text(_SID + "\n", encoding="utf-8") + os.utime(stale, (old, old)) + other = world.proofs / "another-session.session" + other.touch() + os.utime(other, (old, old)) + assert _run(_dispatch_argv([USER_GATE]), _payload(), env=world.env) == "" + assert not stale.exists(), "a proof older than 10 minutes is pruned" + assert other.exists(), "a session hint is kept for 30 days" + assert world.proof("ok").exists() + + +# ================================================================== the account's shell entry + + +def _installed_shell_entry(tmp_path: Path, gate_dst: Path) -> dict[str, Any]: + """Build the installer's real Bash|PowerShell entry for a given gate path, without installing: + the region from the entry text through the entry list, run as the installer runs it.""" + text = INSTALLER.read_text(encoding="utf-8") + # From the installer's own $command line, so the test cannot carry a drifted copy of it. + start = text.index('$command = "pwsh -NoProfile -File') + region = text[start : text.index("# Wire (idempotently)")] + script = "\n".join( + [ + "$ErrorActionPreference = 'Stop'", + f"$GateDst = {_psq(str(gate_dst))}", + region, + "$entries | Where-Object { $_.matcher -ceq 'Bash|PowerShell' } |" + " ConvertTo-Json -Depth 6 -Compress", + ] + ) + runner = tmp_path / "entry.ps1" + runner.write_text(script, encoding="utf-8") + proc = run_single( + ["pwsh", "-NoProfile", "-NonInteractive", "-File", str(runner)], + capture_output=True, + text=True, + timeout=120, + ) + assert proc.returncode == 0, proc.stderr + entry: dict[str, Any] = json.loads(proc.stdout)["hooks"][0] + return entry + + +class _Entry: + """The installed entry, run through bash -c the way a shell-form hook runs.""" + + def __init__(self, tmp_path: Path, world: _World) -> None: + self.world = world + self.hook = _installed_shell_entry(tmp_path, world.gate) + self.shell = require_bash(tmp_path) + + def start(self) -> subprocess.Popen[str]: + proc = subprocess.Popen( + [self.shell, "-c", self.hook["command"]], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + env={**os.environ, **self.world.env}, + ) + assert proc.stdin is not None + proc.stdin.write(_payload()) + proc.stdin.close() + return proc + + def run(self) -> tuple[subprocess.CompletedProcess[str], float]: + began = time.monotonic() + with single_spawn(): + proc = self.start() + out, err = proc.communicate(timeout=120) + return subprocess.CompletedProcess(proc.args, proc.returncode, out, err), ( + time.monotonic() - began + ) + + +def _hinted(world: _World) -> None: + world.proofs.mkdir(exist_ok=True) + world.hint().touch() + + +def test_the_installed_shell_entry_is_bash_and_blocks_on_its_own_failure(tmp_path: Path) -> None: + hook = _installed_shell_entry(tmp_path, tmp_path / "worktree_gate.ps1") + assert hook["shell"] == "bash" + assert hook["onFailure"] == "block", ( + "an entry that cannot start must not leave the call ungated" + ) + # The wait (20 s) leaves the gate 40 s, more than its old 15 s, before the entry is refused. + assert hook["timeout"] == 60 + assert "worktree_gate.ps1" in hook["command"], "the installer's marker finds its own entry" + assert "shell-guard-proof" in hook["command"], "the dispatcher's delegation check reads this" + assert USER_GATE in hook["command"], "the entry waits only where the project wires the gate" + assert "\r" not in hook["command"] + # Measured 2026-10-17: a bash started without Git's /usr/bin on PATH ran Windows' timeout.exe + # for a bare `timeout`, which exits 1 without running the gate. The hook's timeout bounds it. + assert "timeout " not in hook["command"] + + +def test_an_unhinted_session_runs_the_gate_at_once(tmp_path: Path) -> None: + world = _World(tmp_path, gate=_GATE_DENIES) + done, took = _Entry(tmp_path, world).run() + assert done.returncode == 0, done.stderr + assert assert_denied(_decision(done.stdout)) == "installed gate" + assert world.runs() == 1 + assert took < 15, took + + +def test_the_entry_stands_down_on_this_call_s_proof_and_the_gate_runs_once( + tmp_path: Path, +) -> None: + """The whole design, end to end: the entry and the real dispatcher start together, as Claude + Code starts them, and the gate runs once -- in the dispatcher -- for an allowed call.""" + world = _World(tmp_path) + entry = _Entry(tmp_path, world) + _hinted(world) + with spawn_burst("test_shell_guard_dispatch entry and dispatcher"): + waiting = entry.start() + dispatched = _proc(_dispatch_argv([USER_GATE]), _payload(), burst=True, env=world.env) + out, err = waiting.communicate(timeout=120) + assert dispatched.returncode == 0, dispatched.stderr + assert waiting.returncode == 0 and out == "", (waiting.returncode, out, err) + assert world.runs() == 1 + + +def test_a_deny_in_the_dispatcher_is_repeated_by_the_entry_without_a_wait(tmp_path: Path) -> None: + world = _World(tmp_path, gate=_GATE_DENIES) + entry = _Entry(tmp_path, world) + _hinted(world) + with spawn_burst("test_shell_guard_dispatch entry and dispatcher, deny"): + waiting = entry.start() + dispatched = _proc(_dispatch_argv([USER_GATE]), _payload(), burst=True, env=world.env) + settled = time.monotonic() + out, err = waiting.communicate(timeout=120) + assert assert_denied(_decision(dispatched.stdout)) == "installed gate" + assert assert_denied(_decision(out)) == "installed gate", err + assert world.runs() == 2 + assert time.monotonic() - settled < 15, "the .run proof ends the wait" + + +def test_a_proof_older_than_the_wait_is_not_this_call_s(tmp_path: Path) -> None: + """A proof already on disk when the call began -- stale, or planted for an id read early from + the transcript -- does not stand the gate down.""" + world = _World(tmp_path, gate=_GATE_DENIES) + _hinted(world) + world.proof("ok").write_text(_SID + "\n", encoding="utf-8") + old = time.time() - 5 + os.utime(world.proof("ok"), (old, old)) + done, took = _Entry(tmp_path, world).run() + assert assert_denied(_decision(done.stdout)) == "installed gate" + assert world.runs() == 1 + assert took < 15, took + + +def test_a_proof_for_another_session_is_not_this_call_s(tmp_path: Path) -> None: + world = _World(tmp_path, gate=_GATE_DENIES) + entry = _Entry(tmp_path, world) + _hinted(world) + with single_spawn(): + waiting = entry.start() + deadline = time.monotonic() + 30 + while not world.proof("wait").exists(): + assert time.monotonic() < deadline, "the entry never began waiting" + time.sleep(0.02) + time.sleep(0.05) + world.proof("ok").write_text("some-other-session\n", encoding="utf-8") + out, err = waiting.communicate(timeout=120) + assert assert_denied(_decision(out)) == "installed gate", err + assert world.runs() == 1 + + +def test_a_project_that_does_not_wire_the_gate_costs_no_wait(tmp_path: Path) -> None: + """A hint alone never makes the entry wait where the project's dispatcher cannot answer.""" + world = _World(tmp_path, gate=_GATE_DENIES) + _hinted(world) + world.project_settings("--something-else") + done, took = _Entry(tmp_path, world).run() + assert assert_denied(_decision(done.stdout)) == "installed gate" + assert took < 15, took + assert not world.proof("wait").exists() + + +def test_with_no_answer_the_wait_ends_and_the_gate_runs(tmp_path: Path) -> None: + """A hinted session whose dispatcher never answers: a missing, broken or slow replacement.""" + world = _World(tmp_path, gate=_GATE_DENIES) + _hinted(world) + done, took = _Entry(tmp_path, world).run() + assert assert_denied(_decision(done.stdout)) == "installed gate", done.stderr + assert world.runs() == 1 + assert 19 < took < 55, took + + +@pytest.mark.parametrize( + ("gate", "code", "message"), + [ + ("[Console]::Error.WriteLine('gate blocks'); exit 2", 2, None), + ("exit 1", 0, "exited 1"), + ("throw 'broken gate'", 0, "exited 1"), + ], + ids=["exit-2", "exit-1", "throws"], +) +def test_the_gate_s_own_exit_keeps_its_old_meaning( + tmp_path: Path, gate: str, code: int, message: str | None +) -> None: + """onFailure=block covers the entry, not the gate: a gate that crashes still fails open, now + with a message, and its blocking exit still blocks.""" + world = _World(tmp_path, gate=gate) + done, _ = _Entry(tmp_path, world).run() + assert done.returncode == code, (done.returncode, done.stdout, done.stderr) + if message is None: + assert "gate blocks" in done.stderr + else: + assert message in json.loads(done.stdout)["systemMessage"] From 299ab2d3b83936f5ac25b1f2bcd64131bd554f0b Mon Sep 17 00:00:00 2001 From: Scott Hall Date: Sun, 18 Oct 2026 16:13:00 -0500 Subject: [PATCH 2/6] Cheaper per-call reads in the gate entry and dispatcher; faster tests From the /simplify efficiency pass. The bash entry reads stdin and the project settings with read -N (with -d, Cygwin bash reads one byte per syscall) and finds tool_use_id from its key's last occurrence with ## rather than a regex scan from the start; the dispatcher reads the same occurrence. The dispatcher builds paths with Path.Combine and File.Exists, and prunes after writing the proof rather than before. Tests build the entry once and resolve bash once, and the no-answer test shortens the wait it exercises; the real 20 s constant is asserted. Re-measured, Windows job-object CPU, median of 15: this repo 1,078 -> 609 ms per shell call; other projects 531 -> 516 ms. --- scripts/hooks/shell-guard-dispatch.ps1 | 34 +++++++++------- scripts/worktree/install-gate.ps1 | 13 ++++-- tests/test_shell_guard_dispatch.py | 55 +++++++++++++++++++------- 3 files changed, 70 insertions(+), 32 deletions(-) diff --git a/scripts/hooks/shell-guard-dispatch.ps1 b/scripts/hooks/shell-guard-dispatch.ps1 index cf3aaab9e6..d4c5c52026 100644 --- a/scripts/hooks/shell-guard-dispatch.ps1 +++ b/scripts/hooks/shell-guard-dispatch.ps1 @@ -102,11 +102,14 @@ function Write-ShellGuardFailure([string]$Path, [string]$Why) { $ShellGuardHome = if ($env:USERPROFILE) { $env:USERPROFILE } else { [Environment]::GetFolderPath('UserProfile') } $ShellGuardAccount = if ($env:CLAUDE_CONFIG_DIR) { $env:CLAUDE_CONFIG_DIR } else { Join-Path $ShellGuardHome '.claude' } -$ShellGuardProofDir = Join-Path $ShellGuardAccount 'shell-guard-proof' +$ShellGuardProofDir = [System.IO.Path]::Combine($ShellGuardAccount, 'shell-guard-proof') # The same patterns the account's bash entry uses, so both name the same files; a payload they read # differently only costs the entry its wait. $ShellGuardSession = if ($ShellGuardPayload -cmatch '"session_id"\s*:\s*"(?[A-Za-z0-9-]{1,100})"') { $Matches['v'] } else { $null } -$ShellGuardCall = if ($ShellGuardPayload -cmatch '"tool_use_id"\s*:\s*"(?[A-Za-z0-9_-]{1,100})"') { $Matches['v'] } else { $null } +# tool_use_id from the key's LAST occurrence, as the entry reads it. +$ShellGuardAt = $ShellGuardPayload.LastIndexOf('"tool_use_id"', [System.StringComparison]::Ordinal) +$ShellGuardCall = if ($ShellGuardAt -ge 0 -and + $ShellGuardPayload.Substring($ShellGuardAt + 13) -cmatch '^\s*:\s*"(?[A-Za-z0-9_-]{1,100})"') { $Matches['v'] } else { $null } # Scoping is dynamic, so a guard reading $Matches before its own first match would see ours. $Matches = $null @@ -114,7 +117,7 @@ function Write-ShellGuardProof([string]$Kind) { # Kind 'ok' stands the account's entry down for this call; 'run' tells it to run the gate now. if (-not $ShellGuardSession -or -not $ShellGuardCall) { return } try { - $final = Join-Path $ShellGuardProofDir "$ShellGuardCall.$Kind" + $final = [System.IO.Path]::Combine($ShellGuardProofDir, "$ShellGuardCall.$Kind") $part = "$final.$PID.tmp" # Written whole, then renamed, so the entry never reads a half-written proof. [System.IO.File]::WriteAllText($part, "$ShellGuardSession`n") @@ -129,16 +132,16 @@ function Clear-ShellGuardProofs { # Every call leaves files behind, so prune at most every 10 minutes: proofs and waits older # than that, and session hints older than 30 days. A session older than that only waits. try { - $stamp = Join-Path $ShellGuardProofDir 'pruned' + $stamp = [System.IO.Path]::Combine($ShellGuardProofDir, 'pruned') $calls = [DateTime]::UtcNow.AddMinutes(-10) # A missing stamp reads as 1601, so it prunes. if ([System.IO.File]::GetLastWriteTimeUtc($stamp) -gt $calls) { return } [System.IO.File]::WriteAllText($stamp, '') $sessions = [DateTime]::UtcNow.AddDays(-30) - foreach ($f in [System.IO.Directory]::GetFiles($ShellGuardProofDir)) { - $cutoff = if ($f.EndsWith('.session')) { $sessions } else { $calls } - if ([System.IO.File]::GetLastWriteTimeUtc($f) -lt $cutoff) { - try { [System.IO.File]::Delete($f) } catch { $null = $_ } + foreach ($f in [System.IO.DirectoryInfo]::new($ShellGuardProofDir).EnumerateFiles()) { + $cutoff = if ($f.Name.EndsWith('.session')) { $sessions } else { $calls } + if ($f.LastWriteTimeUtc -lt $cutoff) { + try { $f.Delete() } catch { $null = $_ } } } } catch { @@ -154,17 +157,17 @@ foreach ($ShellGuardPath in @($ShellGuardPaths | Where-Object { $_ })) { $ShellGuardProof = $null if ($ShellGuardPath -ceq '--user-hook=worktree_gate') { $ShellGuardPath = Join-Path $ShellGuardHome '.claude/hooks/worktree_gate.ps1' - $ShellGuardHint = if ($ShellGuardSession) { Join-Path $ShellGuardProofDir "$ShellGuardSession.session" } else { $null } + $ShellGuardHint = if ($ShellGuardSession) { [System.IO.Path]::Combine($ShellGuardProofDir, "$ShellGuardSession.session") } else { $null } $ShellGuardDelegated = $false try { - $ShellGuardDelegated = [System.IO.File]::ReadAllText((Join-Path $ShellGuardAccount 'settings.json')).Contains('shell-guard-proof') + $ShellGuardDelegated = [System.IO.File]::ReadAllText([System.IO.Path]::Combine($ShellGuardAccount, 'settings.json')).Contains('shell-guard-proof') } catch { # No readable account settings: nothing delegates, so the gate is not this run's. $null = $_ } if (-not $ShellGuardDelegated) { # An entry still waiting on this session's hint is told to stop waiting. - if ($ShellGuardHint -and (Test-Path -LiteralPath $ShellGuardHint)) { + if ($ShellGuardHint -and [System.IO.File]::Exists($ShellGuardHint)) { Write-ShellGuardProof 'run' try { [System.IO.File]::Delete($ShellGuardHint) } catch { $null = $_ } } @@ -174,8 +177,7 @@ foreach ($ShellGuardPath in @($ShellGuardPaths | Where-Object { $_ })) { if ($ShellGuardHint) { try { $null = [System.IO.Directory]::CreateDirectory($ShellGuardProofDir) - if (-not (Test-Path -LiteralPath $ShellGuardHint)) { [System.IO.File]::WriteAllText($ShellGuardHint, '') } - Clear-ShellGuardProofs + if (-not [System.IO.File]::Exists($ShellGuardHint)) { [System.IO.File]::WriteAllText($ShellGuardHint, '') } } catch { [Console]::Error.WriteLine("shell-guard-dispatch: could not record the session hint: $($_.Exception.Message)") } @@ -216,7 +218,11 @@ foreach ($ShellGuardPath in @($ShellGuardPaths | Where-Object { $_ })) { if ($ShellGuardProof -and -not $ShellGuardOutput) { $ShellGuardProof = 'ok' } } finally { # Every way out of the gate's run -- continue, exit 2, a throw -- writes its proof first. - if ($ShellGuardProof) { Write-ShellGuardProof $ShellGuardProof } + if ($ShellGuardProof) { + Write-ShellGuardProof $ShellGuardProof + # After the proof, so pruning never delays the entry that is waiting on it. + Clear-ShellGuardProofs + } } if (-not $ShellGuardOutput.Trim()) { continue } $ShellGuardVerdict = $null diff --git a/scripts/worktree/install-gate.ps1 b/scripts/worktree/install-gate.ps1 index 5b6c8fc837..5870fe6b61 100644 --- a/scripts/worktree/install-gate.ps1 +++ b/scripts/worktree/install-gate.ps1 @@ -1187,20 +1187,25 @@ $command = "pwsh -NoProfile -File `"$GateDst`"" # # COST, measured 2026-10-17 on mefordev01; the PR that added this records the instrument and numbers. # The wait reads with `read -t` on a pseudo-terminal nobody writes to, which sleeps without starting -# a process; where /dev/ptmx cannot be opened, the entry runs the gate at once. +# a process; where /dev/ptmx cannot be opened, the entry runs the gate at once. `read -N` because +# with -d bash reads one byte per syscall (about 2 ms per KB here). tool_use_id is found from the +# LAST occurrence of its key with `##`, because a regex scan to it from the start of the payload cost +# about 1 ms per KB; the dispatcher reads the same occurrence. JSON escapes the quotes inside a +# command, so a command cannot spell the key. # Single-quoted in bash below, so a quote in the profile path is closed, escaped and reopened. $gateForBash = $GateDst.Replace('\', '/').Replace("'", "'\''") $shellEntry = @' # worktree_gate.ps1 for shell tool calls: stands down only on this call's shell-guard-proof. # scripts/worktree/install-gate.ps1 says why each check is here. g='__GATE__' -IFS= read -r -d '' in +IFS= read -r -N 2147483647 in d=${CLAUDE_CONFIG_DIR:-$USERPROFILE/.claude}/shell-guard-proof if [[ $in =~ \"session_id\"[[:space:]]*:[[:space:]]*\"([A-Za-z0-9-]{1,100})\" ]]; then s=${BASH_REMATCH[1]} - if [[ -e $d/$s.session && $in =~ \"tool_use_id\"[[:space:]]*:[[:space:]]*\"([A-Za-z0-9_-]{1,100})\" ]]; then + r=${in##*\"tool_use_id\"} + if [[ -e $d/$s.session && $r != "$in" && $r =~ ^[[:space:]]*:[[:space:]]*\"([A-Za-z0-9_-]{1,100})\" ]]; then t=${BASH_REMATCH[1]} ps= - { IFS= read -r -d '' ps < "$CLAUDE_PROJECT_DIR/.claude/settings.json"; } 2>/dev/null + { IFS= read -r -N 2147483647 ps < "$CLAUDE_PROJECT_DIR/.claude/settings.json"; } 2>/dev/null if [[ $ps == *--user-hook=worktree_gate* ]] && { : > "$d/$t.wait" && exec {w}<>/dev/ptmx; } 2>/dev/null; then e=$(( ${EPOCHREALTIME//[!0-9]/} + 20000000 )) for (( i = 0; i < 400; i++ )); do diff --git a/tests/test_shell_guard_dispatch.py b/tests/test_shell_guard_dispatch.py index 0370ea994d..e2452a96e9 100644 --- a/tests/test_shell_guard_dispatch.py +++ b/tests/test_shell_guard_dispatch.py @@ -14,6 +14,7 @@ from __future__ import annotations +import functools import json import os import shutil @@ -550,17 +551,40 @@ def _installed_shell_entry(tmp_path: Path, gate_dst: Path) -> dict[str, Any]: return entry +# The entry is built once, for a placeholder gate path each test swaps for its own, and bash is +# resolved once: each costs a pwsh start or several bash probes. +_GATE_PLACEHOLDER = "C:/mefor-test-gate-placeholder/worktree_gate.ps1" +_WAIT = "+ 20000000 ))" + + +@functools.cache +def _entry_command() -> str: + command = str( + _installed_shell_entry(Path(tempfile.mkdtemp()), Path(_GATE_PLACEHOLDER))["command"] + ) + assert command.count(_GATE_PLACEHOLDER) == 1 and command.count(_WAIT) == 1, command + return command + + +@functools.cache +def _bash() -> str: + return require_bash(Path(tempfile.mkdtemp())) + + class _Entry: - """The installed entry, run through bash -c the way a shell-form hook runs.""" + """The installed entry, run through bash -c the way a shell-form hook runs. `wait_us` replaces + its 20 s wait, so a test of the wait running out does not spend the whole 20 s.""" - def __init__(self, tmp_path: Path, world: _World) -> None: + def __init__(self, world: _World, wait_us: int | None = None) -> None: self.world = world - self.hook = _installed_shell_entry(tmp_path, world.gate) - self.shell = require_bash(tmp_path) + self.command = _entry_command().replace(_GATE_PLACEHOLDER, world.gate.as_posix()) + if wait_us is not None: + self.command = self.command.replace(_WAIT, f"+ {wait_us} ))") + self.shell = _bash() def start(self) -> subprocess.Popen[str]: proc = subprocess.Popen( - [self.shell, "-c", self.hook["command"]], + [self.shell, "-c", self.command], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, @@ -602,11 +626,13 @@ def test_the_installed_shell_entry_is_bash_and_blocks_on_its_own_failure(tmp_pat # Measured 2026-10-17: a bash started without Git's /usr/bin on PATH ran Windows' timeout.exe # for a bare `timeout`, which exits 1 without running the gate. The hook's timeout bounds it. assert "timeout " not in hook["command"] + # The wait the tests shorten: 20 s by the clock, and at most 400 polls of 0.05 s. + assert _WAIT in hook["command"] and "i < 400" in hook["command"] def test_an_unhinted_session_runs_the_gate_at_once(tmp_path: Path) -> None: world = _World(tmp_path, gate=_GATE_DENIES) - done, took = _Entry(tmp_path, world).run() + done, took = _Entry(world).run() assert done.returncode == 0, done.stderr assert assert_denied(_decision(done.stdout)) == "installed gate" assert world.runs() == 1 @@ -619,7 +645,7 @@ def test_the_entry_stands_down_on_this_call_s_proof_and_the_gate_runs_once( """The whole design, end to end: the entry and the real dispatcher start together, as Claude Code starts them, and the gate runs once -- in the dispatcher -- for an allowed call.""" world = _World(tmp_path) - entry = _Entry(tmp_path, world) + entry = _Entry(world) _hinted(world) with spawn_burst("test_shell_guard_dispatch entry and dispatcher"): waiting = entry.start() @@ -632,7 +658,7 @@ def test_the_entry_stands_down_on_this_call_s_proof_and_the_gate_runs_once( def test_a_deny_in_the_dispatcher_is_repeated_by_the_entry_without_a_wait(tmp_path: Path) -> None: world = _World(tmp_path, gate=_GATE_DENIES) - entry = _Entry(tmp_path, world) + entry = _Entry(world) _hinted(world) with spawn_burst("test_shell_guard_dispatch entry and dispatcher, deny"): waiting = entry.start() @@ -653,7 +679,7 @@ def test_a_proof_older_than_the_wait_is_not_this_call_s(tmp_path: Path) -> None: world.proof("ok").write_text(_SID + "\n", encoding="utf-8") old = time.time() - 5 os.utime(world.proof("ok"), (old, old)) - done, took = _Entry(tmp_path, world).run() + done, took = _Entry(world).run() assert assert_denied(_decision(done.stdout)) == "installed gate" assert world.runs() == 1 assert took < 15, took @@ -661,7 +687,7 @@ def test_a_proof_older_than_the_wait_is_not_this_call_s(tmp_path: Path) -> None: def test_a_proof_for_another_session_is_not_this_call_s(tmp_path: Path) -> None: world = _World(tmp_path, gate=_GATE_DENIES) - entry = _Entry(tmp_path, world) + entry = _Entry(world) _hinted(world) with single_spawn(): waiting = entry.start() @@ -681,7 +707,7 @@ def test_a_project_that_does_not_wire_the_gate_costs_no_wait(tmp_path: Path) -> world = _World(tmp_path, gate=_GATE_DENIES) _hinted(world) world.project_settings("--something-else") - done, took = _Entry(tmp_path, world).run() + done, took = _Entry(world).run() assert assert_denied(_decision(done.stdout)) == "installed gate" assert took < 15, took assert not world.proof("wait").exists() @@ -691,10 +717,11 @@ def test_with_no_answer_the_wait_ends_and_the_gate_runs(tmp_path: Path) -> None: """A hinted session whose dispatcher never answers: a missing, broken or slow replacement.""" world = _World(tmp_path, gate=_GATE_DENIES) _hinted(world) - done, took = _Entry(tmp_path, world).run() + done, took = _Entry(world, wait_us=2_000_000).run() assert assert_denied(_decision(done.stdout)) == "installed gate", done.stderr assert world.runs() == 1 - assert 19 < took < 55, took + assert world.proof("wait").exists(), "the entry did wait" + assert 1.9 < took < 40, took @pytest.mark.parametrize( @@ -712,7 +739,7 @@ def test_the_gate_s_own_exit_keeps_its_old_meaning( """onFailure=block covers the entry, not the gate: a gate that crashes still fails open, now with a message, and its blocking exit still blocks.""" world = _World(tmp_path, gate=gate) - done, _ = _Entry(tmp_path, world).run() + done, _ = _Entry(world).run() assert done.returncode == code, (done.returncode, done.stdout, done.stderr) if message is None: assert "gate blocks" in done.stderr From 05f36c981a7efbb83a52ddf069fba9e1132eb4be Mon Sep 17 00:00:00 2001 From: Scott Hall Date: Sun, 18 Oct 2026 16:22:50 -0500 Subject: [PATCH 3/6] Gate fold: review fixes -- real-gate equivalence test, posix mode off, honest fallback message From the xhigh code-review of 96a8bbf92..299ab2d3b: - A test now runs the shipped worktree_gate.ps1 standalone and inside the dispatcher on a governed git repo, in both shells, and asserts the same decision and that .ok appears only for a silent allow (control: denies). - A test pins that bash and pwsh read the same ids when tool_use_id comes first, with odd spacing, and a command that quotes the key. - The entry runs `set +o posix`, so a redirection error on : or exec cannot end the shell (which onFailure would turn into blocking every call). - The fallback message no longer claims the gate ran; the comment names the refused here-string as the one new fail-open path, and the 40 s headroom and the loaded-box block are stated as they are. - The stand-down conditions are stated once, in install-gate.ps1; the dispatcher header states only its own half. - Module-wide test caches clean up their temp directories. --- scripts/hooks/shell-guard-dispatch.ps1 | 18 ++---- scripts/worktree/install-gate.ps1 | 13 +++- tests/test_shell_guard_dispatch.py | 90 +++++++++++++++++++++++--- 3 files changed, 99 insertions(+), 22 deletions(-) diff --git a/scripts/hooks/shell-guard-dispatch.ps1 b/scripts/hooks/shell-guard-dispatch.ps1 index d4c5c52026..37f8c3bef9 100644 --- a/scripts/hooks/shell-guard-dispatch.ps1 +++ b/scripts/hooks/shell-guard-dispatch.ps1 @@ -59,17 +59,13 @@ gate, and runs it only when this account DELEGATES it: the account's settings.json ($env:CLAUDE_CONFIG_DIR, else ~/.claude) contains `shell-guard-proof`, which the installer writes into the account's own Bash|PowerShell entry. That entry still runs for every call, in - parallel with this file. It stands down only when it finds, in - /shell-guard-proof/, a proof named by THIS call's tool_use_id, written after it started - waiting, holding this session's id. This file writes that proof (`.ok`) only when - the gate it ran for this payload finished normally with nothing on stdout -- a silent allow. - Anything else (a decision, an exit, a throw, a missing gate) writes `.run`, which - tells the entry to stop waiting and run the gate itself. So a missing, failing or slow copy of - this file leaves the account's entry running the gate; install-gate.ps1 says how it waits. - `.session` tells the entry that this session's dispatcher runs the gate at all, so - a project without this file costs no wait. It is a hint, never a proof: forged or stale, it - only makes the entry wait before running the gate. An account that does not delegate runs the - gate itself, and this file skips it. + parallel with this file, and stands down only on this file's proof for the same call; the + conditions it checks are stated once, in install-gate.ps1. This file's half: it writes + /shell-guard-proof/.ok, holding the session id, only when the gate it ran + for this payload finished normally with nothing on stdout -- a silent allow. Anything else (a + decision, an exit, a throw, a missing gate) writes `.run`, which tells the entry to + run the gate itself now. It also writes the `.session` hint the entry waits on. An + account that does not delegate runs the gate itself, and this file skips it. ORDER IS THE SETTINGS FILE'S: THE WORKTREE GATE FIRST, THEN THE BOUNDED GUARD. The account's entry is waiting on the gate's proof, so the gate must not queue behind guards that could use diff --git a/scripts/worktree/install-gate.ps1 b/scripts/worktree/install-gate.ps1 index 5870fe6b61..995e0a4183 100644 --- a/scripts/worktree/install-gate.ps1 +++ b/scripts/worktree/install-gate.ps1 @@ -1174,13 +1174,19 @@ $command = "pwsh -NoProfile -File `"$GateDst`"" # settings.json must also carry `--user-hook=worktree_gate`, for the same reason. # # TIMEOUTS, AND WHY A FAILURE HERE BLOCKS. The wait is 20 s at most and the hook's timeout is 60 s, so -# the gate keeps at least 40 s after the longest wait, against the 15 s this entry used to give it. +# the gate keeps about 40 s after the longest wait (less bash's own start), against the 15 s this +# entry used to give it. On a box loaded enough that the dispatcher's gate outlasts the wait, this +# entry's gate runs beside it, and a call whose two runs pass 60 s is blocked. # onFailure = block makes this entry's OWN failure a refusal, not a silent allow: if bash cannot # start, or the entry and the gate together run past 60 s, the call is blocked rather than left # ungated. That is stricter than before, when a gate past 15 s failed open; it now blocks, and only # past 40 s. A gate that crashes still fails open, as it always did, because the entry maps the # gate's exit other than 0 or 2 to an allow with a systemMessage saying so; a blocking exit 2 is -# passed on. The gate is not wrapped in `timeout` or captured with $(...): measured, each added +# passed on. That mapping cannot tell a gate crash from a gate that never started (pwsh missing, or +# the here-string's temp file refused), so both fail open with the same message. A missing pwsh failed +# open before too; a refused here-string is new, since the old entry read Claude Code's stdin itself. +# `set +o posix` first: in POSIX mode a redirection error on `:` or `exec` would end the shell with +# status 1, which onFailure turns into a refusal of every call. The gate is not wrapped in `timeout` or captured with $(...): measured, each added # about 0.2 s of wall time to every call that runs the gate here. (A bare `timeout` is worse than # slow: a bash started without Git's /usr/bin on PATH resolved it to C:\Windows\System32\ # timeout.exe, which exited 1 without running the gate.) @@ -1197,6 +1203,7 @@ $gateForBash = $GateDst.Replace('\', '/').Replace("'", "'\''") $shellEntry = @' # worktree_gate.ps1 for shell tool calls: stands down only on this call's shell-guard-proof. # scripts/worktree/install-gate.ps1 says why each check is here. +set +o posix g='__GATE__' IFS= read -r -N 2147483647 in d=${CLAUDE_CONFIG_DIR:-$USERPROFILE/.claude}/shell-guard-proof @@ -1226,7 +1233,7 @@ fi pwsh -NoProfile -File "$g" <<< "$in" r=$? (( r == 0 || r == 2 )) && exit "$r" -printf '{"systemMessage":"worktree_gate.ps1 exited %s and failed open for this call"}\n' "$r" +printf '{"systemMessage":"worktree_gate.ps1 did not complete (exit %s); this call went ahead ungated"}\n' "$r" exit 0 '@.Replace('__GATE__', $gateForBash).Replace("`r`n", "`n") diff --git a/tests/test_shell_guard_dispatch.py b/tests/test_shell_guard_dispatch.py index e2452a96e9..171fe0e336 100644 --- a/tests/test_shell_guard_dispatch.py +++ b/tests/test_shell_guard_dispatch.py @@ -14,6 +14,7 @@ from __future__ import annotations +import atexit import functools import json import os @@ -557,18 +558,23 @@ def _installed_shell_entry(tmp_path: Path, gate_dst: Path) -> dict[str, Any]: _WAIT = "+ 20000000 ))" +def _scratch() -> Path: + """A temp directory removed when the test process exits, for the module-wide caches below.""" + path = Path(tempfile.mkdtemp()) + atexit.register(shutil.rmtree, path, True) + return path + + @functools.cache def _entry_command() -> str: - command = str( - _installed_shell_entry(Path(tempfile.mkdtemp()), Path(_GATE_PLACEHOLDER))["command"] - ) + command = str(_installed_shell_entry(_scratch(), Path(_GATE_PLACEHOLDER))["command"]) assert command.count(_GATE_PLACEHOLDER) == 1 and command.count(_WAIT) == 1, command return command @functools.cache def _bash() -> str: - return require_bash(Path(tempfile.mkdtemp())) + return require_bash(_scratch()) class _Entry: @@ -582,7 +588,7 @@ def __init__(self, world: _World, wait_us: int | None = None) -> None: self.command = self.command.replace(_WAIT, f"+ {wait_us} ))") self.shell = _bash() - def start(self) -> subprocess.Popen[str]: + def start(self, raw: str | None = None) -> subprocess.Popen[str]: proc = subprocess.Popen( [self.shell, "-c", self.command], stdin=subprocess.PIPE, @@ -592,7 +598,7 @@ def start(self) -> subprocess.Popen[str]: env={**os.environ, **self.world.env}, ) assert proc.stdin is not None - proc.stdin.write(_payload()) + proc.stdin.write(_payload() if raw is None else raw) proc.stdin.close() return proc @@ -728,8 +734,8 @@ def test_with_no_answer_the_wait_ends_and_the_gate_runs(tmp_path: Path) -> None: ("gate", "code", "message"), [ ("[Console]::Error.WriteLine('gate blocks'); exit 2", 2, None), - ("exit 1", 0, "exited 1"), - ("throw 'broken gate'", 0, "exited 1"), + ("exit 1", 0, "(exit 1)"), + ("throw 'broken gate'", 0, "(exit 1)"), ], ids=["exit-2", "exit-1", "throws"], ) @@ -745,3 +751,71 @@ def test_the_gate_s_own_exit_keeps_its_old_meaning( assert "gate blocks" in done.stderr else: assert message in json.loads(done.stdout)["systemMessage"] + + +def test_the_entry_and_the_dispatcher_read_the_same_ids_from_any_key_layout(tmp_path: Path) -> None: + """Both sides parse the ids themselves, in two languages. Key order and spacing that JSON allows + must not make them name different files, or the entry waits out its 20 s on every call.""" + world = _World(tmp_path) + entry = _Entry(world) + _hinted(world) + raw = ( + '{"tool_use_id" : "' + _CALL + '", "tool_name": "Bash", "tool_input": ' + '{"command": "echo \\"tool_use_id\\": \\"toolu_fake\\""}, "session_id":"' + _SID + '"}' + ) + assert json.loads(raw)["tool_use_id"] == _CALL + with spawn_burst("test_shell_guard_dispatch id layout"): + waiting = entry.start(raw) + dispatched = _proc(_dispatch_argv([USER_GATE]), raw, burst=True, env=world.env) + began = time.monotonic() + out, err = waiting.communicate(timeout=120) + assert dispatched.returncode == 0, dispatched.stderr + assert waiting.returncode == 0 and out == "", (out, err) + assert world.runs() == 1, "the entry stood down on the dispatcher's proof" + assert time.monotonic() - began < 15 + + +def test_the_real_gate_decides_in_the_dispatcher_exactly_as_it_does_alone(tmp_path: Path) -> None: + """The proof is only as good as this: the shipped gate, run in-process by the dispatcher, + reaches the verdict it reaches as its own process, and leaves .ok only for a silent allow.""" + world = _World(tmp_path, gate=None) + shutil.copyfile(_ROOT / "scripts" / "hooks" / "worktree_gate.ps1", world.gate) + repo = tmp_path / "GitRepo" + repo.mkdir() + subprocess.run(["git", "init", "-q"], cwd=repo, check=True, capture_output=True) + # The gate's default allowlist sits beside it, under the fake USERPROFILE. + (world.gate.parent / "worktree-gate.repos.txt").write_text( + "# governed\n" + str(repo) + "\n", encoding="utf-8" + ) + commands = [ + "git status", + "echo hello", + "git config core.hooksPath /dev/null", + f"git -C {repo} reset --hard", + ] + denies = 0 + for n, (tool, command) in enumerate((t, c) for t in ("Bash", "PowerShell") for c in commands): + call = f"toolu_real{n}" + raw = json.dumps( + { + "session_id": _SID, + "cwd": str(repo), + "hook_event_name": "PreToolUse", + "tool_name": tool, + "tool_input": {"command": command}, + "tool_use_id": call, + } + ) + alone = _proc( + ["pwsh", "-NoProfile", "-NonInteractive", "-File", str(world.gate)], raw, env=world.env + ) + inside = _proc(_dispatch_argv([USER_GATE]), raw, env=world.env) + # Parsed, not byte-compared: the gate builds its deny from a hashtable, and .NET seeds + # string hashing per process, so two processes can order the same keys differently. + assert inside.returncode == alone.returncode, (tool, command, inside.stderr) + assert _decision(inside.stdout) == _decision(alone.stdout), (tool, command) + silent = alone.returncode == 0 and not alone.stdout + assert world.proof("ok", call).exists() == silent, (tool, command) + assert world.proof("run", call).exists() != silent, (tool, command) + denies += _decision(alone.stdout) is not None + assert denies >= 2, "the control: the corpus must reach the gate's deny in both shells" From 11c6f9bb91c88dd89f1dda1834dc78f6bcf85cf9 Mon Sep 17 00:00:00 2001 From: Scott Hall Date: Sun, 18 Oct 2026 19:15:02 -0500 Subject: [PATCH 4/6] Gate fold: answer the Lander's 2026-10-18 send-back (A1, A2, A6, A7, A9, A10, A3) A1: the entry trusted a proof from a working-tree dispatcher any session can edit. install-gate.ps1 now pins shell-guard-dispatch.ps1 beside the gate, checked against origin/main like the gate, and the entry waits only when the project's dispatcher is byte-identical to that copy (line endings aside) and settings pass --user-hook=worktree_gate as its first argument. Gate rule 1a refuses a Write or Edit to the pinned copy and under any /shell-guard-proof/. A2: the entry also requires .wait to exist and the .ok to be no newer than a .now it writes on seeing it, so a future-dated proof or a deleted .wait no longer passes. A6: set +eu +o posix. A7: the gate is fed from exec {h}<<< "$in" || exit 1, so a here-string that cannot be made blocks. A9, A10: the 60 s sentence reworded; USERPROFILE falls back to HOME. A3 (optional): .ok only when the gate read the whole payload. Tests for A1, A2, A6, A7. The proof-planting tests now write bytes: on Windows write_text wrote CRLF, so the earlier stale-proof test failed the session-id check and never reached the check it named. Each new detector was armed by removing its check and watching the test fail. --- scripts/hooks/shell-guard-dispatch.ps1 | 18 +- scripts/hooks/worktree_gate.ps1 | 11 +- scripts/worktree/install-gate.ps1 | 108 ++++++---- .../test_install_gate_records_the_install.py | 5 + .../test_install_gate_upstream_provenance.py | 3 + tests/test_shell_guard_dispatch.py | 202 +++++++++++++++++- 6 files changed, 291 insertions(+), 56 deletions(-) diff --git a/scripts/hooks/shell-guard-dispatch.ps1 b/scripts/hooks/shell-guard-dispatch.ps1 index 37f8c3bef9..9e94067938 100644 --- a/scripts/hooks/shell-guard-dispatch.ps1 +++ b/scripts/hooks/shell-guard-dispatch.ps1 @@ -62,10 +62,12 @@ parallel with this file, and stands down only on this file's proof for the same call; the conditions it checks are stated once, in install-gate.ps1. This file's half: it writes /shell-guard-proof/.ok, holding the session id, only when the gate it ran - for this payload finished normally with nothing on stdout -- a silent allow. Anything else (a - decision, an exit, a throw, a missing gate) writes `.run`, which tells the entry to - run the gate itself now. It also writes the `.session` hint the entry waits on. An - account that does not delegate runs the gate itself, and this file skips it. + for this payload read all of it and finished normally with nothing on stdout -- a silent allow. + Anything else (a decision, an exit, a throw, a missing gate) writes `.run`, which + tells the entry to run the gate itself now. It also writes the `.session` hint the + entry waits on. The entry trusts that proof only from a copy of THIS file identical to the one + the installer pinned, so an edit here costs the saving until the next install, never the gate. + An account that does not delegate runs the gate itself, and this file skips it. ORDER IS THE SETTINGS FILE'S: THE WORKTREE GATE FIRST, THEN THE BOUNDED GUARD. The account's entry is waiting on the gate's proof, so the gate must not queue behind guards that could use @@ -192,7 +194,8 @@ foreach ($ShellGuardPath in @($ShellGuardPaths | Where-Object { $_ })) { $ShellGuardBuffer = [System.IO.StringWriter]::new() $global:LASTEXITCODE = 0 try { - [Console]::SetIn([System.IO.StringReader]::new($ShellGuardPayload)) + $ShellGuardReader = [System.IO.StringReader]::new($ShellGuardPayload) + [Console]::SetIn($ShellGuardReader) [Console]::SetOut($ShellGuardBuffer) $null = & $ShellGuardPath $ShellGuardCompleted = $? @@ -210,8 +213,9 @@ foreach ($ShellGuardPath in @($ShellGuardPaths | Where-Object { $_ })) { continue } $ShellGuardOutput = $ShellGuardBuffer.ToString() - # A silent allow, and nothing else, is the gate's verdict the account's entry may stand on. - if ($ShellGuardProof -and -not $ShellGuardOutput) { $ShellGuardProof = 'ok' } + # A silent allow of THIS payload, and nothing else, is the verdict the account's entry may + # stand on: a gate that left the payload unread allowed something it never looked at. + if ($ShellGuardProof -and -not $ShellGuardOutput -and $ShellGuardReader.Peek() -eq -1) { $ShellGuardProof = 'ok' } } finally { # Every way out of the gate's run -- continue, exit 2, a throw -- writes its proof first. if ($ShellGuardProof) { diff --git a/scripts/hooks/worktree_gate.ps1 b/scripts/hooks/worktree_gate.ps1 index 73b606949f..499b34f30d 100644 --- a/scripts/hooks/worktree_gate.ps1 +++ b/scripts/hooks/worktree_gate.ps1 @@ -67,7 +67,7 @@ param( # 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.10.04.1" +$GateVersion = "2026.10.18.1" # Fail OPEN: any unhandled error must let the tool call through, never block it. $ErrorActionPreference = "SilentlyContinue" @@ -4284,11 +4284,18 @@ if (-not [System.IO.Path]::IsPathRooted($target)) { # wrong twice over: $ReposFile is a parameter that can point anywhere (under test it sits in a temp dir, # where it swallowed every unrelated path), and ~/.claude/hooks/ also holds things this rule has no # business governing. The surface worth protecting is precisely the kill switch and the script it arms. +# +# Two more files carry the gate's verdict for shell calls, so they are its surface too. The pinned +# shell-guard-dispatch.ps1 is the only dispatcher whose proof the account's shell entry trusts, and +# /shell-guard-proof/ holds those proofs: a proof written there stands the entry down for that +# call. install-gate.ps1 states the protocol. $gateFiles = @( (Get-ComparablePath $ReposFile) (Get-ComparablePath (Join-Path (Split-Path -Parent $ReposFile) "worktree_gate.ps1")) + (Get-ComparablePath (Join-Path (Split-Path -Parent $ReposFile) "shell-guard-dispatch.ps1")) ) | Where-Object { $_ } -if ((Get-ComparablePath $target) -in $gateFiles) { +$targetCmp = Get-ComparablePath $target +if ($targetCmp -in $gateFiles -or $targetCmp -match '/\.claude[^/]*/shell-guard-proof(/|$)') { Write-Deny -Rule "1a" -Detail $target -Reason @" BLOCKED: this writes to the worktree gate's own enforcement surface ($(Get-SafeForMessage $target)). diff --git a/scripts/worktree/install-gate.ps1 b/scripts/worktree/install-gate.ps1 index 995e0a4183..2200eb58ed 100644 --- a/scripts/worktree/install-gate.ps1 +++ b/scripts/worktree/install-gate.ps1 @@ -116,6 +116,9 @@ $HomeDir = if ($env:USERPROFILE) { $env:USERPROFILE } else { [Environment]::Ge $HooksDir = Join-Path $HomeDir ".claude/hooks" $GateDst = Join-Path $HooksDir "worktree_gate.ps1" $ReposFile = Join-Path $HooksDir "worktree-gate.repos.txt" +# The shell entry stands down only for a project dispatcher identical to this pinned copy; see the +# entry's comment below. +$DispatchDst = Join-Path $HooksDir "shell-guard-dispatch.ps1" # Marker so we can find (and remove) exactly the entries we added, without disturbing other hooks. $Marker = "worktree_gate.ps1" @@ -1020,6 +1023,7 @@ if ($Uninstall) { if (Test-Path -LiteralPath $ReposFile) { Copy-Item -LiteralPath $ReposFile -Destination "$ReposFile.bak" -Force } Remove-Item -LiteralPath $ReposFile -Force -ErrorAction SilentlyContinue Remove-Item -LiteralPath $GateDst -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $DispatchDst -Force -ErrorAction SilentlyContinue Write-Host "Worktree gate REMOVED from $($ConfigDir.Count) config dir(s). Sessions are no longer gated (takes effect immediately)." -ForegroundColor Yellow return } @@ -1062,6 +1066,20 @@ if ($staleRefusal) { Write-Host "NOTE: the source was checked against -UpstreamRef $UpstreamRef, not the default origin/main." -ForegroundColor Yellow } +# THE DISPATCHER IS PINNED THE SAME WAY, BECAUSE THE SHELL ENTRY TRUSTS ITS PROOF. A working-tree +# dispatcher is a file any session can edit; an edited one could write a proof without running the +# gate. So this run copies the dispatcher, checked against the same ref, beside the gate, where gate +# rule 1a protects it, and the entry stands down only for a project dispatcher identical to it. +$sourceDispatch = Join-Path $RepoRoot "scripts\hooks\shell-guard-dispatch.ps1" +$dispatchSha = Get-GateHash $sourceDispatch +$dispatchUpstream = Get-UpstreamGate -RepoRoot $RepoRoot -Ref $UpstreamRef -RelPath "scripts/hooks/shell-guard-dispatch.ps1" +$dispatchRefusal = Get-StaleSourceRefusal -SourcePath $sourceDispatch -SourceSha $dispatchSha -Upstream $dispatchUpstream +if ($dispatchRefusal) { + $dispatchRefusal = "shell-guard-dispatch.ps1, which the shell entry pins: " + $dispatchRefusal + if (-not $AllowStaleSource -or -not $dispatchSha) { throw $dispatchRefusal } + Write-Host "WARNING: pinning a dispatcher that is not the one on $UpstreamRef, because -AllowStaleSource was given." -ForegroundColor Yellow +} + New-Item -ItemType Directory -Force -Path $HooksDir | Out-Null # READ AND MERGE BEFORE ANYTHING ON THIS BOX CHANGES. An allowlist this run cannot read must stop the @@ -1096,6 +1114,7 @@ if (Test-Path -LiteralPath $GateDst) { } Copy-Item -LiteralPath $GateSrc -Destination $GateDst -Force +Copy-Item -LiteralPath $sourceDispatch -Destination $DispatchDst -Force # STAMP THE INSTALLED COPY WITH THE INSTALL TIME, AND THIS IS THE LOAD-BEARING HALF OF #1247. # @@ -1133,6 +1152,7 @@ $receipt = [ordered]@{ # from a deliberate -AllowStaleSource install. upstreamRef = $UpstreamRef upstreamGateSha256 = $upstream.Sha + dispatchSha256 = (Get-GateHash $DispatchDst) allowStaleSource = [bool]$AllowStaleSource note ='Convenience record written by install-gate.ps1. NOT protected by the gate and NOT attestation.' } | ConvertTo-Json -Depth 4 @@ -1145,6 +1165,9 @@ if ((Get-GateHash $GateDst) -ne $checkedSha) { Write-Host "WARNING: the gate changed on disk between the currency check and the copy, so the installed gate" -ForegroundColor Yellow Write-Host " is NOT the one that was checked against $UpstreamRef. Run -Status, then re-install." -ForegroundColor Yellow } +if ((Get-GateHash $DispatchDst) -ne $dispatchSha) { + Write-Host "WARNING: the dispatcher changed on disk between the currency check and the copy. Re-install." -ForegroundColor Yellow +} $command = "pwsh -NoProfile -File `"$GateDst`"" @@ -1153,43 +1176,46 @@ $command = "pwsh -NoProfile -File `"$GateDst`"" # scripts/hooks/shell-guard-dispatch.ps1 runs this gate inside the one pwsh it starts for its own # guards. Hooks run in parallel, so this entry cannot see that run; it waits for its proof instead. # The proof is /shell-guard-proof/.ok, holding the session id, which the -# dispatcher writes only after the gate it ran for this very payload allowed it silently. This entry -# stands down only when that file exists, holds this session's id, and is NEWER than the -# .wait file this entry wrote when it began waiting. In every other case it runs the -# gate itself, as it did before: -# * no dispatcher ran (another project, an old checkout, a missing or broken dispatcher): no -# proof, so the wait ends and the gate runs; +# dispatcher writes only after the gate it ran for this very payload consumed it and allowed it +# silently. This is the one place the stand-down conditions are stated. This entry waits only when: +# * the dispatcher's .session hint exists (forged or stale, it only buys a wait); +# * the project's settings.json passes `"${CLAUDE_PROJECT_DIR}/scripts/hooks/shell-guard-dispatch.ps1"` +# with `"--user-hook=worktree_gate"` as the very next argument, so the gate runs first; +# * that project dispatcher is byte-identical (line endings aside) to the copy this installer pinned +# beside the gate, checked against origin/main and protected by gate rule 1a. A working-tree +# dispatcher is a file any session can edit, and an edited one could write a proof without running +# the gate; a differing one, on any branch, makes this entry run the gate itself. +# It stands down only when, after the wait began, .ok exists, holds this session's id, +# is NEWER than the .wait this entry wrote, which must still exist, and is NOT newer than +# the .now this entry writes on seeing it. In every other case it runs the gate itself: +# * no dispatcher ran, or a broken one: no proof, so the wait ends and the gate runs; # * the dispatcher's gate denied, decided, exited or threw: it writes .run, and the # gate runs here at once; # * the dispatcher is slow: the wait ends after 20 s and the gate runs here; -# * a proof written before this call's wait began (a stale file, or one planted for an id read -# early from the transcript) fails the NEWER-than test, and the gate runs. +# * a proof written before the wait began fails NEWER than .wait; a proof back-dated or dated in +# the future fails one of the two tests; a deleted .wait fails the existence test. # A tool_use_id is new for every call, so a proof cannot be reused for another. What this does not # stop is a process that sits waiting for this call's .wait file and writes the .ok at once; that is # a deliberate evasion running under the same user, which can edit this settings file outright. +# Gate rule 1a refuses a Write or Edit under shell-guard-proof/, which closes the accidental route. # -# .session is the dispatcher's hint that this session's project runs the gate. Without -# it the entry does not wait at all, so a project with no dispatcher pays nothing. The hint never -# stands the gate down: forged or stale, it only buys a wait before the gate runs. The project's own -# settings.json must also carry `--user-hook=worktree_gate`, for the same reason. -# -# TIMEOUTS, AND WHY A FAILURE HERE BLOCKS. The wait is 20 s at most and the hook's timeout is 60 s, so -# the gate keeps about 40 s after the longest wait (less bash's own start), against the 15 s this +# TIMEOUTS, AND WHY A FAILURE HERE BLOCKS. The wait is 20 s at most and this hook's timeout is 60 s, +# so the gate keeps about 40 s after the longest wait (less bash's own start), against the 15 s this # entry used to give it. On a box loaded enough that the dispatcher's gate outlasts the wait, this -# entry's gate runs beside it, and a call whose two runs pass 60 s is blocked. +# entry runs its own gate as well, and if its wait plus that run passes 60 s the call is blocked. # onFailure = block makes this entry's OWN failure a refusal, not a silent allow: if bash cannot -# start, or the entry and the gate together run past 60 s, the call is blocked rather than left -# ungated. That is stricter than before, when a gate past 15 s failed open; it now blocks, and only -# past 40 s. A gate that crashes still fails open, as it always did, because the entry maps the +# start, if the here-string that feeds the gate cannot be made (`exit 1`), or if the entry runs past +# 60 s, the call is blocked rather than left ungated. That is stricter than before, when a gate past +# 15 s failed open. A gate that crashes still fails open, as it always did, because the entry maps the # gate's exit other than 0 or 2 to an allow with a systemMessage saying so; a blocking exit 2 is -# passed on. That mapping cannot tell a gate crash from a gate that never started (pwsh missing, or -# the here-string's temp file refused), so both fail open with the same message. A missing pwsh failed -# open before too; a refused here-string is new, since the old entry read Claude Code's stdin itself. -# `set +o posix` first: in POSIX mode a redirection error on `:` or `exec` would end the shell with -# status 1, which onFailure turns into a refusal of every call. The gate is not wrapped in `timeout` or captured with $(...): measured, each added -# about 0.2 s of wall time to every call that runs the gate here. (A bare `timeout` is worse than -# slow: a bash started without Git's /usr/bin on PATH resolved it to C:\Windows\System32\ -# timeout.exe, which exited 1 without running the gate.) +# passed on. A missing pwsh fails open the same way, as it did before. +# `set +eu +o posix` first: an errexit, nounset or POSIX mode inherited through SHELLOPTS would end +# the shell on a harmless failed test or redirection, which onFailure turns into a refusal of every +# call. BASH_ENV, SHELLOPTS and BASHOPTS are read before this line can run; the PR records that. +# The gate is not wrapped in `timeout` or captured with $(...): measured, each added about 0.2 s of +# wall time to every call that runs the gate here. (A bare `timeout` is worse than slow: a bash +# started without Git's /usr/bin on PATH resolved it to C:\Windows\System32\timeout.exe, which exited +# 1 without running the gate.) # # COST, measured 2026-10-17 on mefordev01; the PR that added this records the instrument and numbers. # The wait reads with `read -t` on a pseudo-terminal nobody writes to, which sleeps without starting @@ -1198,28 +1224,37 @@ $command = "pwsh -NoProfile -File `"$GateDst`"" # LAST occurrence of its key with `##`, because a regex scan to it from the start of the payload cost # about 1 ms per KB; the dispatcher reads the same occurrence. JSON escapes the quotes inside a # command, so a command cannot spell the key. -# Single-quoted in bash below, so a quote in the profile path is closed, escaped and reopened. +# Single-quoted in bash below, so a quote in a profile path is closed, escaped and reopened. $gateForBash = $GateDst.Replace('\', '/').Replace("'", "'\''") +$dispatchForBash = $DispatchDst.Replace('\', '/').Replace("'", "'\''") $shellEntry = @' # worktree_gate.ps1 for shell tool calls: stands down only on this call's shell-guard-proof. # scripts/worktree/install-gate.ps1 says why each check is here. -set +o posix +set +eu +o posix g='__GATE__' +k='__DISPATCH__' IFS= read -r -N 2147483647 in -d=${CLAUDE_CONFIG_DIR:-$USERPROFILE/.claude}/shell-guard-proof +d=${CLAUDE_CONFIG_DIR:-${USERPROFILE:-$HOME}/.claude}/shell-guard-proof if [[ $in =~ \"session_id\"[[:space:]]*:[[:space:]]*\"([A-Za-z0-9-]{1,100})\" ]]; then s=${BASH_REMATCH[1]} r=${in##*\"tool_use_id\"} if [[ -e $d/$s.session && $r != "$in" && $r =~ ^[[:space:]]*:[[:space:]]*\"([A-Za-z0-9_-]{1,100})\" ]]; then - t=${BASH_REMATCH[1]} ps= - { IFS= read -r -N 2147483647 ps < "$CLAUDE_PROJECT_DIR/.claude/settings.json"; } 2>/dev/null - if [[ $ps == *--user-hook=worktree_gate* ]] && { : > "$d/$t.wait" && exec {w}<>/dev/ptmx; } 2>/dev/null; then + t=${BASH_REMATCH[1]} ps= pd= pk= + { IFS= read -r -N 2147483647 ps < "$CLAUDE_PROJECT_DIR/.claude/settings.json" + IFS= read -r -N 2147483647 pd < "$CLAUDE_PROJECT_DIR/scripts/hooks/shell-guard-dispatch.ps1" + IFS= read -r -N 2147483647 pk < "$k"; } 2>/dev/null + pd=${pd//$'\r'/} pk=${pk//$'\r'/} + if [[ -n $pk && $pd == "$pk" && + $ps =~ \"\$\{CLAUDE_PROJECT_DIR\}/scripts/hooks/shell-guard-dispatch\.ps1\"[[:space:]]*,[[:space:]]*\"--user-hook=worktree_gate\" ]] && + { : > "$d/$t.wait" && exec {w}<>/dev/ptmx; } 2>/dev/null; then e=$(( ${EPOCHREALTIME//[!0-9]/} + 20000000 )) for (( i = 0; i < 400; i++ )); do if [[ -e $d/$t.ok ]]; then p= IFS= read -r p < "$d/$t.ok" - [[ $d/$t.ok -nt $d/$t.wait && $p == "$s" ]] && exit 0 + { : > "$d/$t.now"; } 2>/dev/null + [[ -e $d/$t.wait && -e $d/$t.now && $d/$t.ok -nt $d/$t.wait && + ! $d/$t.ok -nt $d/$t.now && $p == "$s" ]] && exit 0 break fi [[ -e $d/$t.run ]] && break @@ -1230,12 +1265,13 @@ if [[ $in =~ \"session_id\"[[:space:]]*:[[:space:]]*\"([A-Za-z0-9-]{1,100})\" ]] fi fi fi -pwsh -NoProfile -File "$g" <<< "$in" +exec {h}<<< "$in" || exit 1 +pwsh -NoProfile -File "$g" <&"$h" r=$? (( r == 0 || r == 2 )) && exit "$r" printf '{"systemMessage":"worktree_gate.ps1 did not complete (exit %s); this call went ahead ungated"}\n' "$r" exit 0 -'@.Replace('__GATE__', $gateForBash).Replace("`r`n", "`n") +'@.Replace('__GATE__', $gateForBash).Replace('__DISPATCH__', $dispatchForBash).Replace("`r`n", "`n") # One matcher per rule in scripts/hooks/worktree_gate.ps1. A rule the hook implements but that is not # matched here NEVER FIRES -- the hook is simply not invoked for that tool, and nothing says so. Rule 3 diff --git a/tests/test_install_gate_records_the_install.py b/tests/test_install_gate_records_the_install.py index 1f3a4fd8ff..6a66d30fc9 100644 --- a/tests/test_install_gate_records_the_install.py +++ b/tests/test_install_gate_records_the_install.py @@ -77,6 +77,9 @@ def _run(home: Path, source_age_days: int = 6) -> dict[str, Any]: (repo_root / "scripts" / "hooks").mkdir(parents=True) src = repo_root / "scripts" / "hooks" / "worktree_gate.ps1" src.write_text('$GateVersion = "2026.01.01.1"\n# new gate\n', encoding="utf-8") + # The shell entry's pinned dispatcher is copied in the same region. + dispatch_src = repo_root / "scripts" / "hooks" / "shell-guard-dispatch.ps1" + dispatch_src.write_text("# the dispatcher\n", encoding="utf-8") hooks = home / ".claude" / "hooks" hooks.mkdir(parents=True) @@ -88,6 +91,8 @@ def _run(home: Path, source_age_days: int = 6) -> dict[str, Any]: "$ErrorActionPreference = 'Stop'", f"$RepoRoot = '{repo_root.as_posix()}'", f"$GateDst = '{dst.as_posix()}'", + f"$sourceDispatch = '{dispatch_src.as_posix()}'", + f"$DispatchDst = '{(hooks / 'shell-guard-dispatch.ps1').as_posix()}'", "$ConfigDir = @('c:/fixture/.claude')", _function("Get-GateVersion"), _function("Get-GateHash"), diff --git a/tests/test_install_gate_upstream_provenance.py b/tests/test_install_gate_upstream_provenance.py index 3290504318..27cf6b3003 100644 --- a/tests/test_install_gate_upstream_provenance.py +++ b/tests/test_install_gate_upstream_provenance.py @@ -102,6 +102,8 @@ def _fixture(tmp_path: Path, *, behind: bool) -> Path: up.mkdir() _git(up, "init", "-q", "-b", "main") _write_gate(up, GATE_V1) + # The installer pins the shell entry's dispatcher against the same ref, so upstream carries one. + (up / GATE_REL.parent / "shell-guard-dispatch.ps1").write_bytes(b"# the dispatcher\n") _git(up, "add", ".") _git(up, "commit", "-q", "-m", "gate v1") @@ -252,6 +254,7 @@ def _lonely(tmp_path: Path) -> Path: checkout.mkdir() _git(checkout, "init", "-q", "-b", "main") _write_gate(checkout, GATE_V1) + (checkout / GATE_REL.parent / "shell-guard-dispatch.ps1").write_bytes(b"# the dispatcher\n") _git(checkout, "add", ".") _git(checkout, "commit", "-q", "-m", "no remote at all") return checkout diff --git a/tests/test_shell_guard_dispatch.py b/tests/test_shell_guard_dispatch.py index 171fe0e336..661d18be24 100644 --- a/tests/test_shell_guard_dispatch.py +++ b/tests/test_shell_guard_dispatch.py @@ -40,7 +40,7 @@ matcher_selects, ) from tests.test_install_gate_upstream_provenance import _psq -from tests.test_worktree_gate import assert_denied +from tests.test_worktree_gate import assert_denied, run_gate DISPATCHER = _ROOT / "scripts" / "hooks" / "shell-guard-dispatch.ps1" INSTALLER = _ROOT / "scripts" / "worktree" / "install-gate.ps1" @@ -376,6 +376,10 @@ def test_an_unknown_option_is_reported_not_run() -> None: _SID = "0f1e2d3c-aaaa-bbbb-cccc-123456789abc" _CALL = "toolu_01TestCallAbc" +# A proof exactly as the dispatcher writes it. Bytes, not write_text: on Windows text mode writes +# CRLF, and a proof holding "\r" fails the session-id test, so a test planting one would pass +# without ever reaching the check it names. +_PROOF = (_SID + "\n").encode("ascii") class _World: @@ -406,6 +410,12 @@ def __init__(self, tmp_path: Path, *, delegates: bool = True, gate: str | None = self.project = tmp_path / "project" (self.project / ".claude").mkdir(parents=True) self.project_settings(USER_GATE) + # The entry trusts a proof only from a project dispatcher identical to the pinned copy. + self.pinned = hooks / DISPATCHER.name + shutil.copyfile(DISPATCHER, self.pinned) + self.project_dispatcher = self.project / "scripts" / "hooks" / DISPATCHER.name + self.project_dispatcher.parent.mkdir(parents=True) + shutil.copyfile(DISPATCHER, self.project_dispatcher) self.env = { "USERPROFILE": str(home), "HOME": str(home), @@ -414,8 +424,10 @@ def __init__(self, tmp_path: Path, *, delegates: bool = True, gate: str | None = } def project_settings(self, arg: str) -> None: + """The wired shape: the project dispatcher, then `arg` as its first argument.""" + args = ["-File", "${CLAUDE_PROJECT_DIR}/scripts/hooks/shell-guard-dispatch.ps1", arg] (self.project / ".claude" / "settings.json").write_text( - json.dumps({"args": [arg]}), encoding="utf-8" + json.dumps({"command": "pwsh", "args": args}, indent=2), encoding="utf-8" ) def runs(self) -> int: @@ -509,7 +521,7 @@ def test_old_proofs_are_pruned(tmp_path: Path) -> None: world.proofs.mkdir() old = time.time() - 3600 stale = world.proof("ok", "toolu_old") - stale.write_text(_SID + "\n", encoding="utf-8") + stale.write_bytes(_PROOF) os.utime(stale, (old, old)) other = world.proofs / "another-session.session" other.touch() @@ -523,7 +535,9 @@ def test_old_proofs_are_pruned(tmp_path: Path) -> None: # ================================================================== the account's shell entry -def _installed_shell_entry(tmp_path: Path, gate_dst: Path) -> dict[str, Any]: +def _installed_shell_entry( + tmp_path: Path, gate_dst: Path, dispatch_dst: Path | None = None +) -> dict[str, Any]: """Build the installer's real Bash|PowerShell entry for a given gate path, without installing: the region from the entry text through the entry list, run as the installer runs it.""" text = INSTALLER.read_text(encoding="utf-8") @@ -534,6 +548,7 @@ def _installed_shell_entry(tmp_path: Path, gate_dst: Path) -> dict[str, Any]: [ "$ErrorActionPreference = 'Stop'", f"$GateDst = {_psq(str(gate_dst))}", + f"$DispatchDst = {_psq(str(dispatch_dst or gate_dst.parent / DISPATCHER.name))}", region, "$entries | Where-Object { $_.matcher -ceq 'Bash|PowerShell' } |" " ConvertTo-Json -Depth 6 -Compress", @@ -555,6 +570,7 @@ def _installed_shell_entry(tmp_path: Path, gate_dst: Path) -> dict[str, Any]: # The entry is built once, for a placeholder gate path each test swaps for its own, and bash is # resolved once: each costs a pwsh start or several bash probes. _GATE_PLACEHOLDER = "C:/mefor-test-gate-placeholder/worktree_gate.ps1" +_DISPATCH_PLACEHOLDER = "C:/mefor-test-gate-placeholder/shell-guard-dispatch.ps1" _WAIT = "+ 20000000 ))" @@ -569,6 +585,7 @@ def _scratch() -> Path: def _entry_command() -> str: command = str(_installed_shell_entry(_scratch(), Path(_GATE_PLACEHOLDER))["command"]) assert command.count(_GATE_PLACEHOLDER) == 1 and command.count(_WAIT) == 1, command + assert command.count(_DISPATCH_PLACEHOLDER) == 1, command return command @@ -583,29 +600,37 @@ class _Entry: def __init__(self, world: _World, wait_us: int | None = None) -> None: self.world = world - self.command = _entry_command().replace(_GATE_PLACEHOLDER, world.gate.as_posix()) + self.command = ( + _entry_command() + .replace(_GATE_PLACEHOLDER, world.gate.as_posix()) + .replace(_DISPATCH_PLACEHOLDER, world.pinned.as_posix()) + ) if wait_us is not None: self.command = self.command.replace(_WAIT, f"+ {wait_us} ))") self.shell = _bash() - def start(self, raw: str | None = None) -> subprocess.Popen[str]: + def start( + self, raw: str | None = None, env: dict[str, str] | None = None + ) -> subprocess.Popen[str]: proc = subprocess.Popen( [self.shell, "-c", self.command], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, - env={**os.environ, **self.world.env}, + env={**os.environ, **self.world.env, **(env or {})}, ) assert proc.stdin is not None proc.stdin.write(_payload() if raw is None else raw) proc.stdin.close() return proc - def run(self) -> tuple[subprocess.CompletedProcess[str], float]: + def run( + self, raw: str | None = None, env: dict[str, str] | None = None + ) -> tuple[subprocess.CompletedProcess[str], float]: began = time.monotonic() with single_spawn(): - proc = self.start() + proc = self.start(raw, env) out, err = proc.communicate(timeout=120) return subprocess.CompletedProcess(proc.args, proc.returncode, out, err), ( time.monotonic() - began @@ -682,7 +707,7 @@ def test_a_proof_older_than_the_wait_is_not_this_call_s(tmp_path: Path) -> None: the transcript -- does not stand the gate down.""" world = _World(tmp_path, gate=_GATE_DENIES) _hinted(world) - world.proof("ok").write_text(_SID + "\n", encoding="utf-8") + world.proof("ok").write_bytes(_PROOF) old = time.time() - 5 os.utime(world.proof("ok"), (old, old)) done, took = _Entry(world).run() @@ -702,7 +727,7 @@ def test_a_proof_for_another_session_is_not_this_call_s(tmp_path: Path) -> None: assert time.monotonic() < deadline, "the entry never began waiting" time.sleep(0.02) time.sleep(0.05) - world.proof("ok").write_text("some-other-session\n", encoding="utf-8") + world.proof("ok").write_bytes(_PROOF.replace(_SID.encode("ascii"), b"some-other-session")) out, err = waiting.communicate(timeout=120) assert assert_denied(_decision(out)) == "installed gate", err assert world.runs() == 1 @@ -819,3 +844,158 @@ def test_the_real_gate_decides_in_the_dispatcher_exactly_as_it_does_alone(tmp_pa assert world.proof("run", call).exists() != silent, (tool, command) denies += _decision(alone.stdout) is not None assert denies >= 2, "the control: the corpus must reach the gate's deny in both shells" + + +# ================================================================== the send-back of 2026-10-18 (A1-A7) + + +def _await_wait(world: _World, waiting: subprocess.Popen[str]) -> None: + deadline = time.monotonic() + 30 + while not world.proof("wait").exists(): + assert waiting.poll() is None, "the entry ended before it began waiting" + assert time.monotonic() < deadline, "the entry never began waiting" + time.sleep(0.02) + time.sleep(0.05) + + +def test_an_edited_project_dispatcher_is_not_trusted(tmp_path: Path) -> None: + """A1: the proof comes from a working-tree file any session can edit. A project dispatcher that + differs from the pinned copy makes the entry run the gate at once, without waiting.""" + world = _World(tmp_path, gate=_GATE_DENIES) + _hinted(world) + with world.project_dispatcher.open("a", encoding="utf-8") as f: + f.write("# an edit in the working tree\n") + done, took = _Entry(world).run() + assert assert_denied(_decision(done.stdout)) == "installed gate" + assert world.runs() == 1 + assert not world.proof("wait").exists(), "a mismatched dispatcher buys no wait" + assert took < 15, took + + +def test_a_missing_pinned_dispatcher_is_not_trusted(tmp_path: Path) -> None: + world = _World(tmp_path, gate=_GATE_DENIES) + _hinted(world) + world.pinned.unlink() + done, _ = _Entry(world).run() + assert assert_denied(_decision(done.stdout)) == "installed gate" + assert not world.proof("wait").exists() + + +def test_the_dispatcher_must_take_the_gate_as_its_first_argument(tmp_path: Path) -> None: + """A1: a guard placed before the gate shares its process and could change what the gate sees.""" + world = _World(tmp_path, gate=_GATE_DENIES) + _hinted(world) + settings = world.project / ".claude" / "settings.json" + body = json.loads(settings.read_text(encoding="utf-8")) + body["args"].insert(2, "${CLAUDE_PROJECT_DIR}/scripts/hooks/some-guard.ps1") + settings.write_text(json.dumps(body), encoding="utf-8") + done, _ = _Entry(world).run() + assert assert_denied(_decision(done.stdout)) == "installed gate" + assert not world.proof("wait").exists() + + +@pytest.mark.parametrize( + "target", + [ + "{hooks}/shell-guard-dispatch.ps1", + "{home}/.claude-account-3/shell-guard-proof/toolu_x.ok", + "{home}/.claude/shell-guard-proof/sid.session", + ], + ids=["pinned-dispatcher", "account-proof", "default-account-hint"], +) +def test_rule_1a_refuses_a_write_to_the_pinned_dispatcher_or_a_proof( + tmp_path: Path, target: str +) -> None: + """A1: both carry the gate's verdict for shell calls, so they are its enforcement surface.""" + hooks = tmp_path / "home" / ".claude" / "hooks" + hooks.mkdir(parents=True) + repos = hooks / "worktree-gate.repos.txt" + repos.write_text("# governed\n" + str(tmp_path / "Repo") + "\n", encoding="utf-8") + path = target.format(hooks=hooks.as_posix(), home=(tmp_path / "home").as_posix()) + + def edit(file_path: str) -> dict[str, Any]: + return { + "session_id": _SID, + "cwd": str(tmp_path), + "hook_event_name": "PreToolUse", + "tool_name": "Write", + "tool_input": {"file_path": file_path, "content": "x"}, + } + + assert "1a" in assert_denied(run_gate(edit(path), repos)) + # Control: a sibling of the proof folder is not the gate's surface. + assert ( + run_gate(edit((tmp_path / "home" / ".claude" / "elsewhere" / "x.ok").as_posix()), repos) + is None + ) + + +def test_a_proof_dated_in_the_future_is_not_this_call_s(tmp_path: Path) -> None: + """A2: NEWER than .wait alone passes a planted proof with a future mtime.""" + world = _World(tmp_path, gate=_GATE_DENIES) + entry = _Entry(world) + _hinted(world) + with single_spawn(): + waiting = entry.start() + _await_wait(world, waiting) + part = world.proofs / "planted.tmp" + part.write_bytes(_PROOF) + future = time.time() + 3600 + os.utime(part, (future, future)) + part.replace(world.proof("ok")) + out, err = waiting.communicate(timeout=120) + assert assert_denied(_decision(out)) == "installed gate", err + assert world.runs() == 1 + + +def test_a_proof_beside_a_deleted_wait_is_not_this_call_s(tmp_path: Path) -> None: + """A2: with .wait gone, any .ok reads as NEWER than it.""" + world = _World(tmp_path, gate=_GATE_DENIES) + entry = _Entry(world) + _hinted(world) + with single_spawn(): + waiting = entry.start() + _await_wait(world, waiting) + world.proof("wait").unlink() + part = world.proofs / "planted.tmp" + part.write_bytes(_PROOF) + part.replace(world.proof("ok")) + out, err = waiting.communicate(timeout=120) + assert assert_denied(_decision(out)) == "installed gate", err + assert world.runs() == 1 + + +def test_inherited_errexit_and_nounset_do_not_refuse_every_call(tmp_path: Path) -> None: + """A6: SHELLOPTS carries shell options into bash; onFailure would turn an early exit into a + refusal. The control shows the inherited options do end an entry without the reset.""" + world = _World(tmp_path, gate=_GATE_DENIES) + inherited = {"SHELLOPTS": "errexit:nounset"} + entry = _Entry(world) + assert "set +eu +o posix\n" in entry.command + done, _ = entry.run(env=inherited) + assert done.returncode == 0, (done.returncode, done.stderr) + assert assert_denied(_decision(done.stdout)) == "installed gate" + unreset = _Entry(world) + unreset.command = unreset.command.replace("set +eu +o posix\n", "") + control, _ = unreset.run(env=inherited) + assert control.returncode != 0, "the control: without the reset, errexit ends the entry" + + +def test_the_gate_gets_a_large_payload_whole_and_a_failed_feed_blocks(tmp_path: Path) -> None: + """A7: a payload too big for a pipe goes through a here-document file. It must reach the gate + whole, and a here-string that cannot be made must exit 1, which onFailure turns into a block.""" + world = _World( + tmp_path, + gate=( + "[Console]::Out.Write((@{hookSpecificOutput=@{hookEventName='PreToolUse';" + "permissionDecision='deny';permissionDecisionReason=\"len=$($in.Length)\"}}" + " | ConvertTo-Json -Compress))" + ), + ) + raw = _payload("echo " + "x" * 200_000) + done, _ = _Entry(world).run(raw) + assert done.returncode == 0, done.stderr + assert assert_denied(_decision(done.stdout)) in (f"len={len(raw)}", f"len={len(raw) + 1}") + command = _Entry(world).command + feed = command.index('exec {h}<<< "$in" || exit 1') + assert feed < command.index('pwsh -NoProfile -File "$g" <&"$h"') From 5625a5627906cdaab4dae8b45500fc410633b848 Mon Sep 17 00:00:00 2001 From: Scott Hall Date: Sun, 18 Oct 2026 19:27:11 -0500 Subject: [PATCH 5/6] Gate fold: review fixes on 11c6f9bb9 -- bind the proof to the gate run, -Status reads the pin From the xhigh code-review of 11c6f9bb9: - The dispatcher finds the gate by USERPROFILE, while the entry runs the path baked at install. The proof now holds `|`, and the entry stands down only when that path is the gate it would run. - -Status prints the pinned dispatcher and grades it against origin/main, so a dispatcher change that drops the saving is visible. - Rule 1a's deny says what a proof folder or the pinned dispatcher is, not only the allowlist. - The dispatcher header no longer restates the pinning condition. - Tests: the install copies the pin and records it in the receipt; a checkout whose dispatcher differs from origin/main refuses to install; a proof naming another gate is not trusted (armed by loosening the check and watching it fail). --- scripts/hooks/shell-guard-dispatch.ps1 | 18 +++++---- scripts/hooks/worktree_gate.ps1 | 11 ++++-- scripts/worktree/install-gate.ps1 | 19 ++++++++-- .../test_install_gate_records_the_install.py | 11 ++++++ .../test_install_gate_upstream_provenance.py | 11 ++++++ tests/test_shell_guard_dispatch.py | 38 +++++++++++++++---- 6 files changed, 86 insertions(+), 22 deletions(-) diff --git a/scripts/hooks/shell-guard-dispatch.ps1 b/scripts/hooks/shell-guard-dispatch.ps1 index 9e94067938..d9a7a2904d 100644 --- a/scripts/hooks/shell-guard-dispatch.ps1 +++ b/scripts/hooks/shell-guard-dispatch.ps1 @@ -61,13 +61,13 @@ writes into the account's own Bash|PowerShell entry. That entry still runs for every call, in parallel with this file, and stands down only on this file's proof for the same call; the conditions it checks are stated once, in install-gate.ps1. This file's half: it writes - /shell-guard-proof/.ok, holding the session id, only when the gate it ran - for this payload read all of it and finished normally with nothing on stdout -- a silent allow. - Anything else (a decision, an exit, a throw, a missing gate) writes `.run`, which - tells the entry to run the gate itself now. It also writes the `.session` hint the - entry waits on. The entry trusts that proof only from a copy of THIS file identical to the one - the installer pinned, so an edit here costs the saving until the next install, never the gate. - An account that does not delegate runs the gate itself, and this file skips it. + /shell-guard-proof/.ok, holding `|`, only when the + gate it ran for this payload read all of it and finished normally with nothing on stdout -- a + silent allow. Anything else (a decision, an exit, a throw, a missing gate) writes + `.run`, which tells the entry to run the gate itself now. It also writes the + `.session` hint the entry waits on. Which copy of this file the entry trusts, and + the rest of what it checks, is stated in install-gate.ps1. An account that does not delegate + runs the gate itself, and this file skips it. ORDER IS THE SETTINGS FILE'S: THE WORKTREE GATE FIRST, THEN THE BOUNDED GUARD. The account's entry is waiting on the gate's proof, so the gate must not queue behind guards that could use @@ -118,7 +118,8 @@ function Write-ShellGuardProof([string]$Kind) { $final = [System.IO.Path]::Combine($ShellGuardProofDir, "$ShellGuardCall.$Kind") $part = "$final.$PID.tmp" # Written whole, then renamed, so the entry never reads a half-written proof. - [System.IO.File]::WriteAllText($part, "$ShellGuardSession`n") + # The gate's path too, so the entry stands down only for the gate it would have run itself. + [System.IO.File]::WriteAllText($part, "$ShellGuardSession|$($ShellGuardGate.Replace('\', '/'))`n") [System.IO.File]::Move($part, $final) } catch { # No proof only costs the entry its own run of the gate. @@ -155,6 +156,7 @@ foreach ($ShellGuardPath in @($ShellGuardPaths | Where-Object { $_ })) { $ShellGuardProof = $null if ($ShellGuardPath -ceq '--user-hook=worktree_gate') { $ShellGuardPath = Join-Path $ShellGuardHome '.claude/hooks/worktree_gate.ps1' + $ShellGuardGate = $ShellGuardPath $ShellGuardHint = if ($ShellGuardSession) { [System.IO.Path]::Combine($ShellGuardProofDir, "$ShellGuardSession.session") } else { $null } $ShellGuardDelegated = $false try { diff --git a/scripts/hooks/worktree_gate.ps1 b/scripts/hooks/worktree_gate.ps1 index 499b34f30d..0eebbbe9f8 100644 --- a/scripts/hooks/worktree_gate.ps1 +++ b/scripts/hooks/worktree_gate.ps1 @@ -4295,12 +4295,17 @@ $gateFiles = @( (Get-ComparablePath (Join-Path (Split-Path -Parent $ReposFile) "shell-guard-dispatch.ps1")) ) | Where-Object { $_ } $targetCmp = Get-ComparablePath $target -if ($targetCmp -in $gateFiles -or $targetCmp -match '/\.claude[^/]*/shell-guard-proof(/|$)') { +$isProof = $targetCmp -match '/\.claude[^/]*/shell-guard-proof(/|$)' +if ($targetCmp -in $gateFiles -or $isProof) { + $surface = if ($isProof) { + "That folder holds per-call proofs that the gate already ran: a file there stands the gate down for a shell`ncall. Only the project's shell-guard dispatcher writes them, so a session may not write here at all." + } else { + "That directory holds the installed hook, the dispatcher whose proofs it trusts, and its allowlist. The`nallowlist is the gate's kill switch -- a single edit there turns it off for every session on this machine,`nso a session may not write here at all." + } Write-Deny -Rule "1a" -Detail $target -Reason @" BLOCKED: this writes to the worktree gate's own enforcement surface ($(Get-SafeForMessage $target)). -That directory holds the installed hook and its allowlist. The allowlist is the gate's kill switch -- a -single edit there turns it off for every session on this machine, so a session may not write here at all. +$surface This is not a file to fix in passing. If the gate is genuinely wrong -- a false positive, a rule that needs changing -- fix it at the SOURCE and diff --git a/scripts/worktree/install-gate.ps1 b/scripts/worktree/install-gate.ps1 index 2200eb58ed..b9a13af6af 100644 --- a/scripts/worktree/install-gate.ps1 +++ b/scripts/worktree/install-gate.ps1 @@ -801,6 +801,17 @@ if ($Status) { Write-Host "upstream : $($up.Ref) UNREADABLE -- $($up.Reason)" -ForegroundColor Yellow } + # The pinned dispatcher the shell entry trusts. A pin behind the ref costs no safety, only the + # saving: a project dispatcher that differs from it makes the entry run the gate itself, while + # the project's dispatcher runs it too. + $dUp = Get-UpstreamGate -RepoRoot $RepoRoot -Ref $UpstreamRef -RelPath "scripts/hooks/shell-guard-dispatch.ps1" + $dSha = Get-GateHash $DispatchDst + $dVerdict = if (-not $dSha) { "NOT PINNED -- every shell call runs the gate in its own pwsh" } + elseif (-not $dUp.Available) { "UNVERIFIED -- $($dUp.Ref) could not be read" } + elseif ($dSha -eq $dUp.Sha) { "CURRENT with $($dUp.Ref)" } + else { "NOT CURRENT with $($dUp.Ref) -- a checkout at $($dUp.Ref) runs the gate twice per shell call until re-install" } + Write-Host "dispatcher : $DispatchDst$(& $shortSha $dSha) $dVerdict" + if ($iSha -and $sSha) { if ($iSha -eq $sSha -and $up.Available -and $up.Sha -eq $iSha) { Write-Host "parity : IN SYNC -- identical CONTENT to this checkout AND to $($up.Ref) (line endings are folded out, not compared)." -ForegroundColor Green @@ -1185,8 +1196,10 @@ $command = "pwsh -NoProfile -File `"$GateDst`"" # beside the gate, checked against origin/main and protected by gate rule 1a. A working-tree # dispatcher is a file any session can edit, and an edited one could write a proof without running # the gate; a differing one, on any branch, makes this entry run the gate itself. -# It stands down only when, after the wait began, .ok exists, holds this session's id, -# is NEWER than the .wait this entry wrote, which must still exist, and is NOT newer than +# It stands down only when, after the wait began, .ok exists, holds this session's id +# and the path of the very gate this entry would run (the dispatcher finds the gate by USERPROFILE, +# so a hook environment that disagrees with this install names another file, and is not trusted), is +# NEWER than the .wait this entry wrote, which must still exist, and is NOT newer than # the .now this entry writes on seeing it. In every other case it runs the gate itself: # * no dispatcher ran, or a broken one: no proof, so the wait ends and the gate runs; # * the dispatcher's gate denied, decided, exited or threw: it writes .run, and the @@ -1254,7 +1267,7 @@ if [[ $in =~ \"session_id\"[[:space:]]*:[[:space:]]*\"([A-Za-z0-9-]{1,100})\" ]] IFS= read -r p < "$d/$t.ok" { : > "$d/$t.now"; } 2>/dev/null [[ -e $d/$t.wait && -e $d/$t.now && $d/$t.ok -nt $d/$t.wait && - ! $d/$t.ok -nt $d/$t.now && $p == "$s" ]] && exit 0 + ! $d/$t.ok -nt $d/$t.now && ${p,,} == "${s,,}|${g,,}" ]] && exit 0 break fi [[ -e $d/$t.run ]] && break diff --git a/tests/test_install_gate_records_the_install.py b/tests/test_install_gate_records_the_install.py index 6a66d30fc9..7d48957552 100644 --- a/tests/test_install_gate_records_the_install.py +++ b/tests/test_install_gate_records_the_install.py @@ -117,9 +117,20 @@ def _run(home: Path, source_age_days: int = 6) -> dict[str, Any]: "dst": dst, "bak": Path(f"{dst}.bak"), "receipt": Path(f"{dst}.receipt.json"), + "dispatch_src": dispatch_src, + "dispatch_dst": hooks / "shell-guard-dispatch.ps1", } +def test_the_install_pins_the_dispatcher_beside_the_gate(tmp_path: Path) -> None: + """The shell entry trusts a proof only from a project dispatcher identical to this copy, so an + install that skipped it would leave every shell call running the gate in its own pwsh.""" + r = _run(tmp_path) + assert r["dispatch_dst"].read_bytes() == r["dispatch_src"].read_bytes() + receipt = json.loads(r["receipt"].read_text(encoding="utf-8-sig")) + assert receipt["dispatchSha256"], receipt + + def test_the_previous_gate_is_backed_up_before_it_is_overwritten(tmp_path: Path) -> None: r = _run(tmp_path) bak = r["bak"] diff --git a/tests/test_install_gate_upstream_provenance.py b/tests/test_install_gate_upstream_provenance.py index 27cf6b3003..98dacd3313 100644 --- a/tests/test_install_gate_upstream_provenance.py +++ b/tests/test_install_gate_upstream_provenance.py @@ -393,6 +393,17 @@ def test_a_current_checkout_installs_without_the_override(tmp_path: Path) -> Non assert "WARNING" not in r.stdout, r.stdout +def test_an_install_refuses_a_dispatcher_that_is_not_origin_main_s(tmp_path: Path) -> None: + """The shell entry trusts the pinned dispatcher's proof, so it is pinned only from the ref.""" + _need_tools() + checkout = _fixture(tmp_path, behind=False) + (checkout / GATE_REL.parent / "shell-guard-dispatch.ps1").write_bytes(b"# edited locally\n") + r = _run_region(checkout, tmp_path, allow=False) + both = r.stdout + r.stderr + assert r.returncode != 0 and "which the shell entry pins" in both, both + assert "PROCEEDED PAST THE CHECK" not in r.stdout + + def test_an_install_refuses_when_origin_main_cannot_be_read(tmp_path: Path) -> None: _need_tools() r = _run_region(_lonely(tmp_path), tmp_path, allow=False) diff --git a/tests/test_shell_guard_dispatch.py b/tests/test_shell_guard_dispatch.py index 661d18be24..6da1e25d4c 100644 --- a/tests/test_shell_guard_dispatch.py +++ b/tests/test_shell_guard_dispatch.py @@ -376,9 +376,7 @@ def test_an_unknown_option_is_reported_not_run() -> None: _SID = "0f1e2d3c-aaaa-bbbb-cccc-123456789abc" _CALL = "toolu_01TestCallAbc" -# A proof exactly as the dispatcher writes it. Bytes, not write_text: on Windows text mode writes -# CRLF, and a proof holding "\r" fails the session-id test, so a test planting one would pass -# without ever reaching the check it names. +# A proof file's bytes for a prune test, where its content is never read. _PROOF = (_SID + "\n").encode("ascii") @@ -439,6 +437,12 @@ def proof(self, kind: str, call: str = _CALL) -> Path: def hint(self) -> Path: return self.proofs / f"{_SID}.session" + def proof_bytes(self, session: str = _SID) -> bytes: + """A proof exactly as the dispatcher writes it: `|`. Bytes, not + write_text: on Windows text mode writes CRLF, and a proof ending in a CR fails the content + test, so a test planting one would pass without ever reaching the check it names.""" + return f"{session}|{self.gate.as_posix()}\n".encode() + def _payload(command: str = "git status", call: str | None = _CALL) -> str: body = {**bash(command), "session_id": _SID} @@ -455,7 +459,7 @@ def test_a_delegated_gate_that_allows_leaves_this_call_its_proof(tmp_path: Path) proc = _proc(_dispatch_argv([USER_GATE]), _payload(), env=world.env) assert proc.returncode == 0 and proc.stdout == "", (proc.returncode, proc.stderr) assert world.runs() == 1 - assert world.proof("ok").read_text(encoding="utf-8") == _SID + "\n" + assert world.proof("ok").read_bytes().lower() == world.proof_bytes().lower() assert not world.proof("run").exists() assert world.hint().is_file(), "the account's entry waits only for a hinted session" @@ -707,7 +711,7 @@ def test_a_proof_older_than_the_wait_is_not_this_call_s(tmp_path: Path) -> None: the transcript -- does not stand the gate down.""" world = _World(tmp_path, gate=_GATE_DENIES) _hinted(world) - world.proof("ok").write_bytes(_PROOF) + world.proof("ok").write_bytes(world.proof_bytes()) old = time.time() - 5 os.utime(world.proof("ok"), (old, old)) done, took = _Entry(world).run() @@ -716,6 +720,24 @@ def test_a_proof_older_than_the_wait_is_not_this_call_s(tmp_path: Path) -> None: assert took < 15, took +def test_a_proof_naming_another_gate_is_not_this_call_s(tmp_path: Path) -> None: + """The dispatcher finds the gate by USERPROFILE. A proof from a run of some other gate file + (another profile, a stale copy) does not stand down the entry that would run this one.""" + world = _World(tmp_path, gate=_GATE_DENIES) + entry = _Entry(world) + _hinted(world) + other = f"{_SID}|{(tmp_path / 'elsewhere' / 'worktree_gate.ps1').as_posix()}\n".encode() + with single_spawn(): + waiting = entry.start() + _await_wait(world, waiting) + part = world.proofs / "planted.tmp" + part.write_bytes(other) + part.replace(world.proof("ok")) + out, err = waiting.communicate(timeout=120) + assert assert_denied(_decision(out)) == "installed gate", err + assert world.runs() == 1 + + def test_a_proof_for_another_session_is_not_this_call_s(tmp_path: Path) -> None: world = _World(tmp_path, gate=_GATE_DENIES) entry = _Entry(world) @@ -727,7 +749,7 @@ def test_a_proof_for_another_session_is_not_this_call_s(tmp_path: Path) -> None: assert time.monotonic() < deadline, "the entry never began waiting" time.sleep(0.02) time.sleep(0.05) - world.proof("ok").write_bytes(_PROOF.replace(_SID.encode("ascii"), b"some-other-session")) + world.proof("ok").write_bytes(world.proof_bytes("some-other-session")) out, err = waiting.communicate(timeout=120) assert assert_denied(_decision(out)) == "installed gate", err assert world.runs() == 1 @@ -939,7 +961,7 @@ def test_a_proof_dated_in_the_future_is_not_this_call_s(tmp_path: Path) -> None: waiting = entry.start() _await_wait(world, waiting) part = world.proofs / "planted.tmp" - part.write_bytes(_PROOF) + part.write_bytes(world.proof_bytes()) future = time.time() + 3600 os.utime(part, (future, future)) part.replace(world.proof("ok")) @@ -958,7 +980,7 @@ def test_a_proof_beside_a_deleted_wait_is_not_this_call_s(tmp_path: Path) -> Non _await_wait(world, waiting) world.proof("wait").unlink() part = world.proofs / "planted.tmp" - part.write_bytes(_PROOF) + part.write_bytes(world.proof_bytes()) part.replace(world.proof("ok")) out, err = waiting.communicate(timeout=120) assert assert_denied(_decision(out)) == "installed gate", err From c0612879e56a6214c8a489f7cc73427d3824e78f Mon Sep 17 00:00:00 2001 From: Scott Hall Date: Sun, 18 Oct 2026 19:34:36 -0500 Subject: [PATCH 6/6] Gate fold: review fixes on 5625a5627 -- -Status pin test, no $Matches side effect From the xhigh code-review of 5625a5627: - Rule 1a's proof test uses [regex]::IsMatch, so it no longer leaves $Matches set for the rules after it. - -Status also says when this checkout's dispatcher differs from the pin. - A test drives the -Status dispatcher line through NOT PINNED, CURRENT and NOT CURRENT. Skipped: the case-insensitive gate-path compare stays; Windows is the only supported target (#2376). --- scripts/hooks/worktree_gate.ps1 | 3 ++- scripts/worktree/install-gate.ps1 | 4 +++ .../test_install_gate_upstream_provenance.py | 25 +++++++++++++++++++ 3 files changed, 31 insertions(+), 1 deletion(-) diff --git a/scripts/hooks/worktree_gate.ps1 b/scripts/hooks/worktree_gate.ps1 index 0eebbbe9f8..0a01f8bd2a 100644 --- a/scripts/hooks/worktree_gate.ps1 +++ b/scripts/hooks/worktree_gate.ps1 @@ -4295,7 +4295,8 @@ $gateFiles = @( (Get-ComparablePath (Join-Path (Split-Path -Parent $ReposFile) "shell-guard-dispatch.ps1")) ) | Where-Object { $_ } $targetCmp = Get-ComparablePath $target -$isProof = $targetCmp -match '/\.claude[^/]*/shell-guard-proof(/|$)' +# IsMatch, not -match: -match would leave $Matches set for every rule after this one. +$isProof = [regex]::IsMatch($targetCmp, '/\.claude[^/]*/shell-guard-proof(/|$)') if ($targetCmp -in $gateFiles -or $isProof) { $surface = if ($isProof) { "That folder holds per-call proofs that the gate already ran: a file there stands the gate down for a shell`ncall. Only the project's shell-guard dispatcher writes them, so a session may not write here at all." diff --git a/scripts/worktree/install-gate.ps1 b/scripts/worktree/install-gate.ps1 index b9a13af6af..29db1bdc16 100644 --- a/scripts/worktree/install-gate.ps1 +++ b/scripts/worktree/install-gate.ps1 @@ -811,6 +811,10 @@ if ($Status) { elseif ($dSha -eq $dUp.Sha) { "CURRENT with $($dUp.Ref)" } else { "NOT CURRENT with $($dUp.Ref) -- a checkout at $($dUp.Ref) runs the gate twice per shell call until re-install" } Write-Host "dispatcher : $DispatchDst$(& $shortSha $dSha) $dVerdict" + $dSrc = Get-GateHash (Join-Path $RepoRoot "scripts\hooks\shell-guard-dispatch.ps1") + if ($dSha -and $dSrc -and $dSrc -ne $dSha) { + Write-Host " THIS CHECKOUT's dispatcher differs from the pin, so its sessions run the gate twice." -ForegroundColor Yellow + } if ($iSha -and $sSha) { if ($iSha -eq $sSha -and $up.Available -and $up.Sha -eq $iSha) { diff --git a/tests/test_install_gate_upstream_provenance.py b/tests/test_install_gate_upstream_provenance.py index 98dacd3313..8fc575cc4a 100644 --- a/tests/test_install_gate_upstream_provenance.py +++ b/tests/test_install_gate_upstream_provenance.py @@ -284,6 +284,31 @@ def test_a_current_install_read_from_a_stale_tree_is_current_not_stale(tmp_path: assert "do NOT install from it" in verdict, verdict +@pytest.mark.parametrize( + ("pinned", "verdict"), + [ + (None, "NOT PINNED"), + (b"# the dispatcher\n", "CURRENT with origin/main"), + (b"# an older dispatcher\n", "NOT CURRENT with origin/main"), + ], + ids=["missing", "current", "stale"], +) +def test_status_grades_the_pinned_dispatcher( + tmp_path: Path, pinned: bytes | None, verdict: str +) -> None: + """A pin behind the ref costs the saving silently, so -Status must say which it is.""" + _need_tools() + checkout = _fixture(tmp_path, behind=False) + home = tmp_path / "home" + _plant(home, GATE_V1) + if pinned is not None: + (home / ".claude" / "hooks" / "shell-guard-dispatch.ps1").write_bytes(pinned) + line = next(ln for ln in _status(checkout, home).splitlines() if ln.startswith("dispatcher :")) + assert verdict in line, line + if verdict == "CURRENT with origin/main": + assert "NOT CURRENT" not in line, line + + def test_stale_says_which_copy_origin_main_agrees_with(tmp_path: Path) -> None: """The STALE warning asks the reader to work out which copy is older. The ref answers it.""" _need_tools()