diff --git a/.claude/settings.json b/.claude/settings.json index c2b262dcf..f930950ff 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 d593c4452..d9a7a2904 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,30 @@ 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, 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 `|`, 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 + 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 +98,134 @@ 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 = [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 } +# 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 + +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 = [System.IO.Path]::Combine($ShellGuardProofDir, "$ShellGuardCall.$Kind") + $part = "$final.$PID.tmp" + # Written whole, then renamed, so the entry never reads a half-written proof. + # 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. + [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 = [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.DirectoryInfo]::new($ShellGuardProofDir).EnumerateFiles()) { + $cutoff = if ($f.Name.EndsWith('.session')) { $sessions } else { $calls } + if ($f.LastWriteTimeUtc -lt $cutoff) { + try { $f.Delete() } 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' + $ShellGuardGate = $ShellGuardPath + $ShellGuardHint = if ($ShellGuardSession) { [System.IO.Path]::Combine($ShellGuardProofDir, "$ShellGuardSession.session") } else { $null } + $ShellGuardDelegated = $false + try { + $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 [System.IO.File]::Exists($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 [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)") + } + } + } 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 { + $ShellGuardReader = [System.IO.StringReader]::new($ShellGuardPayload) + [Console]::SetIn($ShellGuardReader) + [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 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 { - [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 + # After the proof, so pruning never delays the entry that is waiting on it. + Clear-ShellGuardProofs + } } - $ShellGuardOutput = $ShellGuardBuffer.ToString() if (-not $ShellGuardOutput.Trim()) { continue } $ShellGuardVerdict = $null try { diff --git a/scripts/hooks/worktree_gate.ps1 b/scripts/hooks/worktree_gate.ps1 index 73b606949..0a01f8bd2 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,16 +4284,29 @@ 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 +# 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." + } 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 b80d25c17..29db1bdc1 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" @@ -798,6 +801,21 @@ 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" + $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) { Write-Host "parity : IN SYNC -- identical CONTENT to this checkout AND to $($up.Ref) (line endings are folded out, not compared)." -ForegroundColor Green @@ -1020,6 +1038,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 +1081,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 +1129,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 +1167,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,9 +1180,116 @@ 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`"" +# 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 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 +# 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 +# gate runs here at once; +# * the dispatcher is slow: the wait ends after 20 s and the gate runs here; +# * 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. +# +# 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 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, 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. 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 +# 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 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 +eu +o posix +g='__GATE__' +k='__DISPATCH__' +IFS= read -r -N 2147483647 in +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= 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.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,,}|${g,,}" ]] && 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 +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('__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 # shipped in exactly that state. tests/test_install_gate_wiring.py now asserts that every tool the script @@ -1165,14 +1307,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 bb2349bb2..9b394c32f 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_install_gate_records_the_install.py b/tests/test_install_gate_records_the_install.py index 1f3a4fd8f..7d4895755 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"), @@ -112,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 329050431..8fc575cc4 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 @@ -281,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() @@ -390,6 +418,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 39c5d8707..6da1e25d4 100644 --- a/tests/test_shell_guard_dispatch.py +++ b/tests/test_shell_guard_dispatch.py @@ -14,16 +14,23 @@ from __future__ import annotations +import atexit +import functools 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 +39,18 @@ _load, matcher_selects, ) -from tests.test_worktree_gate import assert_denied +from tests.test_install_gate_upstream_provenance import _psq +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" +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 +81,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 +107,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 +135,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 +352,672 @@ 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" +# A proof file's bytes for a prune test, where its content is never read. +_PROOF = (_SID + "\n").encode("ascii") + + +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) + # 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), + "CLAUDE_CONFIG_DIR": str(self.account), + "CLAUDE_PROJECT_DIR": str(self.project), + } + + 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({"command": "pwsh", "args": args}, indent=2), 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 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} + 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_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" + + +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_bytes(_PROOF) + 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, 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") + # 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))}", + 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", + ] + ) + 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 + + +# 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 ))" + + +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(_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 + + +@functools.cache +def _bash() -> str: + return require_bash(_scratch()) + + +class _Entry: + """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, world: _World, wait_us: int | None = None) -> None: + self.world = world + 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, 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 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, raw: str | None = None, env: dict[str, str] | None = None + ) -> tuple[subprocess.CompletedProcess[str], float]: + began = time.monotonic() + with single_spawn(): + proc = self.start(raw, env) + 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"] + # 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(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(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(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_bytes(world.proof_bytes()) + old = time.time() - 5 + os.utime(world.proof("ok"), (old, old)) + done, took = _Entry(world).run() + assert assert_denied(_decision(done.stdout)) == "installed gate" + assert world.runs() == 1 + 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) + _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_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 + + +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(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(world, wait_us=2_000_000).run() + assert assert_denied(_decision(done.stdout)) == "installed gate", done.stderr + assert world.runs() == 1 + assert world.proof("wait").exists(), "the entry did wait" + assert 1.9 < took < 40, took + + +@pytest.mark.parametrize( + ("gate", "code", "message"), + [ + ("[Console]::Error.WriteLine('gate blocks'); exit 2", 2, None), + ("exit 1", 0, "(exit 1)"), + ("throw 'broken gate'", 0, "(exit 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(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"] + + +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" + + +# ================================================================== 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(world.proof_bytes()) + 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(world.proof_bytes()) + 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"')