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
17 changes: 17 additions & 0 deletions .worktreeinclude
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Gitignored files that Claude Code copies into every worktree it creates with git --
# `claude --worktree`, desktop parallel sessions, and `isolation: worktree` subagents.
# .gitignore syntax; only files that match AND are gitignored are copied, so nothing here
# can be committed by accident.
#
# WHY THIS FILE EXISTS. `git worktree add` delivers tracked files only. The leak gate's token
# list is deliberately gitignored, and the pre-commit hook passes --require-tokens and FAILS
# CLOSED without it -- so a fresh worktree could not commit AT ALL, with an error message that
# never mentions worktrees. scripts/worktree/new.ps1 hand-copies it for the worktrees IT makes,
# but that covers none of the first-party creation paths above, which is where every nested
# .claude/worktrees/ session lands. This closes that gap for all of them.
#
# Deliberately NOT here: .env and anything under secrets/. Those are refused by policy
# (CLAUDE.md section 5), and a worktree that needs them should get them from the environment.
# Also not .venv -- it is per-worktree ON PURPOSE (docs/WORKTREES.md), because a shared
# `pip install -e .` binds to one source path and would silently test the wrong checkout.
scripts/security/scan-tokens.local.txt
525 changes: 525 additions & 0 deletions docs/SESSION-DRIFT-CONTROLS.md

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions docs/WORKTREE-GATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ worktree completely untouched.
It is opt-in, installed by hand, governs only the checkouts you list, and comes back out cleanly
(`settings.json` is restored byte-for-byte). See [Backing it out](#backing-it-out).

> This page is the gate's own design rationale. For the gate **in context** — the whole drift-control
> estate, which parts are actually installed and enforcing, and an audit of what they miss — see
> [SESSION-DRIFT-CONTROLS.md](SESSION-DRIFT-CONTROLS.md).

---

## Why it exists
Expand Down
2 changes: 2 additions & 0 deletions docs/WORKTREES.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,8 @@ they're committed to `main` (and fetched). `.claude/settings.json` is tracked (s
## The worktree gate (enforcement, not a reminder)

> Full write-up, with the measurements and the backout procedure: [WORKTREE-GATE.md](WORKTREE-GATE.md).
> The whole estate as one system — every control's LIVE/INERT status, an audit of the gaps, and the
> ultracode question: [SESSION-DRIFT-CONTROLS.md](SESSION-DRIFT-CONTROLS.md).

The `SessionStart` banner above **asks** you to work in a worktree. Measurement says asking doesn't work:
across 30 days, 166 sessions ran with their cwd in the shared primary, and **44% of all their file writes
Expand Down
434 changes: 394 additions & 40 deletions scripts/hooks/worktree_gate.ps1

Large diffs are not rendered by default.

114 changes: 102 additions & 12 deletions scripts/worktree/install-gate.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -50,21 +50,34 @@ param(
[switch]$Status,
# Do not gate Task/Agent/Workflow dispatch from the primary (writes are still gated).
[switch]$NoDispatchGate,
# Gate the EnterWorktree tool (rule 4), which relocates a LIVE session into a worktree.
#
# OPT-IN, and deliberately OFF by default. Rule 4 has never been installed, and turning it on as a
# SIDE EFFECT of installing an unrelated fix would be a trap: with rules 2 and 4 both live, a session
# started in the primary has no in-session path to isolation at all -- it can neither dispatch a
# subagent nor relocate itself, so it must be restarted elsewhere by a human. That is a hard stop on
# workflow-by-default from the directory sessions naturally open in, and it is a decision the owner
# makes on purpose (docs/WORKTREES.md), not one that rides along with a regex fix.
#
# It also duplicates a guard the vendor now ships: since v2.1.206 EnterWorktree into a path OUTSIDE
# .claude/worktrees/ raises a confirmation prompt that no permission rule can suppress, and since
# v2.1.198 the transcript follows the session's cwd BOTH ways, so relocation re-files a chat rather
# than losing it. See docs/SESSION-DRIFT-CONTROLS.md.
[switch]$EnterWorktreeGate,
# Config dirs to wire the hook into. Default: ~/.claude plus every existing ~/.claude-account-*.
[string[]]$ConfigDir
)

$ErrorActionPreference = "Stop"

if ($env:CLAUDECODE -eq "1") {
throw "Refusing to run inside Claude Code. A session that can install this gate can also remove it. Run from a plain pwsh terminal."
}

$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path

# The gate SCRIPT + its allowlist live ONCE, shared, under ~/.claude\hooks -- referenced by absolute path
# from every config dir's settings.json, so a single copy (and a single kill switch) governs all accounts.
$HooksDir = Join-Path $env:USERPROFILE ".claude\hooks"
# Null-safely: $env:USERPROFILE is Windows-only and NULL elsewhere, where Join-Path throws a
# parameter-binding error instead of returning a path. Same idiom as its sibling scripts.
$HomeDir = if ($env:USERPROFILE) { $env:USERPROFILE } else { [Environment]::GetFolderPath('UserProfile') }
$HooksDir = Join-Path $HomeDir ".claude/hooks"
$GateDst = Join-Path $HooksDir "worktree_gate.ps1"
$ReposFile = Join-Path $HooksDir "worktree-gate.repos.txt"

Expand All @@ -73,9 +86,9 @@ $Marker = "worktree_gate.ps1"

# Config dirs to wire. Default: ~/.claude + every existing ~/.claude-account-* (the VS Code launchers).
if (-not $ConfigDir -or $ConfigDir.Count -eq 0) {
$cands = @( (Join-Path $env:USERPROFILE ".claude") )
$cands = @( (Join-Path $HomeDir ".claude") )
$cands += @(
Get-ChildItem -LiteralPath $env:USERPROFILE -Directory -Filter ".claude-account-*" -ErrorAction SilentlyContinue |
Get-ChildItem -LiteralPath $HomeDir -Directory -Filter ".claude-account-*" -ErrorAction SilentlyContinue |
ForEach-Object { $_.FullName }
)
$ConfigDir = @($cands | Where-Object { Test-Path -LiteralPath $_ -PathType Container })
Expand Down Expand Up @@ -115,26 +128,101 @@ function Remove-GateHooks($Data) {
return $Data
}

function Get-GateVersion([string]$Path) {
if (-not (Test-Path -LiteralPath $Path)) { return $null }
$m = [regex]::Match((Get-Content -LiteralPath $Path -Raw), '\$GateVersion\s*=\s*"([^"]+)"')
if ($m.Success) { $m.Groups[1].Value } else { "(unstamped)" }
}

function Get-GateHash([string]$Path) {
if (-not (Test-Path -LiteralPath $Path)) { return $null }
(Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash
}

# Every tool a gate script branches on. -Status calls this on the INSTALLED copy, deliberately: the
# question it answers is "which rules does the gate that is RUNNING have, and are they all wired", and the
# source's rule set is not evidence for either. That is the whole point of the audit -- rule 4 was in the
# source, declared by this installer, and covered by tests, while the running gate had never heard of it.
function Get-HandledTools([string]$Path) {
if (-not (Test-Path -LiteralPath $Path)) { return @() }
$text = Get-Content -LiteralPath $Path -Raw
$tools = [System.Collections.Generic.HashSet[string]]::new()
foreach ($m in [regex]::Matches($text, '\$tool\s+-(?:not)?in\s+@\(([^)]*)\)')) {
foreach ($q in [regex]::Matches($m.Groups[1].Value, '"([^"]+)"')) { $null = $tools.Add($q.Groups[1].Value) }
}
@($tools)
}

# ------------------------------------------------------------------------------------------ status
# NB this branch runs BEFORE the CLAUDECODE refusal below, deliberately. Auditing is not installing, and
# a session that cannot see whether the gate is current has no way to notice the exact failure that let
# rule 4 sit unshipped for five days while every test reported it present. Installing stays a human act.
if ($Status) {
$installed = Test-Path -LiteralPath $GateDst
Write-Host "gate script : $(if ($installed) { "installed -> $GateDst" } else { 'NOT installed' })"
$srcGate = Join-Path $RepoRoot "scripts\hooks\worktree_gate.ps1"
$iVer = Get-GateVersion $GateDst ; $sVer = Get-GateVersion $srcGate
$iSha = Get-GateHash $GateDst ; $sSha = Get-GateHash $srcGate

Write-Host "installed : $(if ($iSha) { "$GateDst v$iVer" } else { 'NOT installed' })"
Write-Host "source : $(if ($sSha) { "$srcGate v$sVer" } else { 'NOT FOUND' })"
if ($iSha -and $sSha) {
if ($iSha -eq $sSha) {
Write-Host "parity : IN SYNC" -ForegroundColor Green
} else {
Write-Host "parity : *** STALE *** the running gate is NOT this checkout's script." -ForegroundColor Red
Write-Host " Re-run this installer to update it. Until you do, rules added or"
Write-Host " removed in source have no effect, and the tests still pass."
}
}

if (Test-Path -LiteralPath $ReposFile) {
Write-Host "governing :"
Get-Content -LiteralPath $ReposFile | Where-Object { $_ -and -not $_.StartsWith('#') } |
ForEach-Object { Write-Host " $_" }
} else {
Write-Host "governing : nothing (no allowlist -> gate is OFF)"
}

# Compare the wired matchers against the rules the INSTALLED script actually implements -- an
# expectation, not a count. A count of "3" is not information unless you know whether 3 is right.
$handled = @(Get-HandledTools $GateDst)
foreach ($cd in $ConfigDir) {
$sp = Join-Path $cd "settings.json"
$s = Read-Settings $sp
$n = @($s.hooks.PreToolUse | Where-Object { @($_.hooks) | Where-Object { "$($_.command)" -like "*$Marker*" } }).Count
Write-Host "hook entries: $n in $sp"
$wired = [System.Collections.Generic.HashSet[string]]::new()
foreach ($e in @($s.hooks.PreToolUse)) {
if (@($e.hooks) | Where-Object { "$($_.command)" -like "*$Marker*" }) {
foreach ($t in "$($e.matcher)".Split("|")) { if ($t) { $null = $wired.Add($t) } }
}
}
# Rules that are deliberately unwired are reported as such, never as UNWIRED. A status line that
# cries wolf about a known-and-intended state is one a reader learns to skip, which is how a real
# UNWIRED would go unnoticed -- the exact failure this whole block exists to surface.
$optIn = @("EnterWorktree")
$absent = @($handled | Where-Object { -not $wired.Contains($_) })
$missing = @($absent | Where-Object { $optIn -notcontains $_ } | Sort-Object)
$offByChoice = @($absent | Where-Object { $optIn -contains $_ } | Sort-Object)
$stray = @($wired | Where-Object { $handled -notcontains $_ } | Sort-Object)
Write-Host "wiring : $sp"
Write-Host " matched : $(@($wired | Sort-Object) -join ', ')"
if ($offByChoice) {
Write-Host " opt-in : $($offByChoice -join ', ') <- off by default, add -EnterWorktreeGate to enable"
}
if ($missing) {
Write-Host " UNWIRED : $($missing -join ', ') <- implemented but NEVER FIRES" -ForegroundColor Yellow
}
if ($stray) {
Write-Host " stray : $($stray -join ', ') <- matched but the script ignores it" -ForegroundColor Yellow
}
}
Write-Host ""
Write-Host "scanned $($ConfigDir.Count) config dir(s) against $(@($handled).Count) implemented rule(s)."
return
}

if ($env:CLAUDECODE -eq "1") {
throw "Refusing to run inside Claude Code. A session that can install this gate can also remove it. Run from a plain pwsh terminal. (-Status is allowed from a session: auditing is not installing.)"
}

# --------------------------------------------------------------------------------------- uninstall
if ($Uninstall) {
foreach ($cd in $ConfigDir) {
Expand Down Expand Up @@ -185,11 +273,13 @@ $command = "pwsh -NoProfile -File `"$GateDst`""
$matchers = @(
"Write|Edit|MultiEdit|NotebookEdit" # rule 1 -- writes INTO the primary's tree
"Bash|PowerShell" # rules 3 + 3b -- git verbs that swap the primary / hijack a worktree
"EnterWorktree" # rule 4 -- relocating a live session (loses its transcript)
)
if (-not $NoDispatchGate) {
$matchers += "Task|Agent|Workflow" # rule 2 -- subagent dispatch FROM the primary
}
if ($EnterWorktreeGate) {
$matchers += "EnterWorktree" # rule 4 -- OPT-IN, see the parameter's note for why
}

$entries = foreach ($m in $matchers) {
[ordered]@{
Expand Down
26 changes: 24 additions & 2 deletions scripts/worktree/install-selfheal.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,31 @@
#>
param(
[Parameter(Mandatory)][string]$ConfigDir,
[string]$HookPath = (Join-Path $env:USERPROFILE '.claude-hooks\worktree-selfheal.ps1')
# NO DEFAULT HERE, deliberately. Parameter defaults are evaluated during BINDING, before the first
# line of the body -- so a default that throws preempts the CLAUDECODE guard below and the script
# dies with an unrelated error instead of refusing. That is exactly what happened: the default was
# `Join-Path $env:USERPROFILE ...`, and off Windows $env:USERPROFILE is NULL, so on Linux CI the
# installer crashed with "Cannot bind argument to parameter 'Path' because it is null" and the
# refusal never ran. A guard is only a guard if nothing can run ahead of it.
[string]$HookPath
)
$ErrorActionPreference = 'Stop'

# Its sibling install-gate.ps1 has refused to run inside Claude Code since it shipped; this installer
# never did, and it is the MORE privileged of the two. It wires a user-scope SessionStart hook that runs
# `git checkout` on the shared primary unattended, and its canonical source is $PSScriptRoot -- the copy
# in the calling session's own worktree, which that session may freely edit. The higher-privilege
# component was the less protected one.
if ($env:CLAUDECODE -eq '1') {
throw "Refusing to run inside Claude Code. This installs a user-scope hook that repairs the shared primary unattended, from a script the calling session can edit. Run it from a plain pwsh terminal."
}

# Home directory, null-safely. $env:USERPROFILE is Windows-only and is NULL elsewhere; honour it when set
# (tests and account swaps rely on overriding it) and fall back to the .NET accessor, which resolves $HOME
# on Unix. Every script in this family uses the same idiom -- see worktree-selfheal.ps1 and install-gate.ps1.
$homeDir = if ($env:USERPROFILE) { $env:USERPROFILE } else { [Environment]::GetFolderPath('UserProfile') }
if (-not $HookPath) { $HookPath = Join-Path $homeDir '.claude-hooks/worktree-selfheal.ps1' }

if (-not (Test-Path -LiteralPath $ConfigDir)) { throw "Config dir not found: $ConfigDir" }
$settingsPath = Join-Path $ConfigDir 'settings.json'

Expand All @@ -37,7 +59,7 @@ elseif (-not (Test-Path -LiteralPath $HookPath)) { throw "worktree-selfheal.ps1
$reposFile = Join-Path $sharedDir 'worktree-gate.repos.txt'
if (-not (Test-Path -LiteralPath $reposFile)) {
# Seed from the worktree gate's existing allowlist if present; else a commented template.
$gateRepos = Join-Path $env:USERPROFILE '.claude\hooks\worktree-gate.repos.txt'
$gateRepos = Join-Path $homeDir '.claude/hooks/worktree-gate.repos.txt'
if (Test-Path -LiteralPath $gateRepos) { Copy-Item -LiteralPath $gateRepos -Destination $reposFile -Force }
else { Set-Content -LiteralPath $reposFile -Encoding utf8 -Value '# Primaries guarded by the SessionStart backstop. One absolute path per line. Delete to disable.' }
}
Expand Down
26 changes: 23 additions & 3 deletions scripts/worktree/worktree-selfheal.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,31 @@
#>
param(
# Shared allowlist of primary checkouts to guard (one absolute path per line, '#' comments).
# Absent or empty => the backstop is OFF. Kept OUTSIDE any per-account config dir so it is
# account-agnostic and survives an account swap.
[string]$ReposFile = (Join-Path $env:USERPROFILE '.claude-hooks\worktree-gate.repos.txt')
# Absent or empty => the backstop is OFF.
#
# ONE allowlist, shared with the PreToolUse gate. There used to be two -- the gate read
# ~/.claude/hooks/worktree-gate.repos.txt and this backstop read ~/.claude-hooks/worktree-gate.repos.txt
# -- with one installer rewriting its own unconditionally and the other seeding the second only if
# absent. Nothing kept them in sync: adding a governed repo through the gate installer never reached
# the backstop, and `install-gate.ps1 -Uninstall` left this hook armed and still willing to run
# `git checkout` on the primary long after the gate was gone. They agreed only by luck.
#
# The legacy path is still read as a FALLBACK, because this script is installed as a copy: an older
# installed copy paired with a newer allowlist (or the reverse) must not silently turn the backstop
# off. Whichever file exists wins, gate location first.
[string]$ReposFile
)

if (-not $ReposFile) {
# Null-safely: $env:USERPROFILE is Windows-only and is NULL elsewhere, where Join-Path then throws a
# parameter-binding error rather than returning a path. Honour the env var when set (tests and account
# swaps override it) and fall back to the .NET accessor, which resolves $HOME on Unix.
$homeDir = if ($env:USERPROFILE) { $env:USERPROFILE } else { [Environment]::GetFolderPath('UserProfile') }
$shared = Join-Path $homeDir '.claude/hooks/worktree-gate.repos.txt'
$legacy = Join-Path $homeDir '.claude-hooks/worktree-gate.repos.txt'
$ReposFile = if (Test-Path -LiteralPath $shared) { $shared } else { $legacy }
}

$ErrorActionPreference = 'SilentlyContinue'

function Emit-Context([string]$Message) {
Expand Down
Loading
Loading