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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .claude/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
]
}
Expand Down
176 changes: 147 additions & 29 deletions scripts/hooks/shell-guard-dispatch.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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
<account>/shell-guard-proof/<tool_use_id>.ok, holding `<session id>|<gate path>`, 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
`<tool_use_id>.run`, which tells the entry to run the gate itself now. It also writes the
`<session id>.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.
Expand All @@ -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*"(?<v>[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*"(?<v>[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 {
Expand Down
21 changes: 17 additions & 4 deletions scripts/hooks/worktree_gate.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
# <account>/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
Expand Down
Loading
Loading