Repository navigation
Expand file tree
/
Copy pathinstall-hooks.ps1
More file actions
383 lines (353 loc) · 17.1 KB
/
Copy pathinstall-hooks.ps1
File metadata and controls
383 lines (353 loc) · 17.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
<#
.SYNOPSIS
Install the work-agreement guards. Run -Check first. the operator runs this, not Claude.
.DESCRIPTION
WHY THE OPERATOR RUNS IT
If this goes wrong, every tool call in every session is blocked. Claude
cannot repair that, because repairing means editing a file and editing is
a tool call. Whoever runs it needs to be able to see the failure and fix
it, and that is the person at the terminal.
ORDER MATTERS, AND THIS IS THE ORDER
1. verify everything (writes nothing)
2. copy the guards into place (still not wired to anything)
3. SMOKE TEST the copies (prove they run on this machine)
4. back up settings.json (the undo depends on this existing)
5. write the settings block (the only irreversible-ish step)
6. read it back and verify
Any failure before step 5 leaves nothing wired. The checks come before the
write, because after the write is too late.
IT TAKES EFFECT IMMEDIATELY, INCLUDING IN RUNNING SESSIONS. Measured
2026-09-09. Do not run this while sessions you care about are mid-task.
.EXAMPLE
pwsh -NoProfile -File .\install-hooks.ps1 -Check
.EXAMPLE
pwsh -NoProfile -File .\install-hooks.ps1
#>
[CmdletBinding()]
param(
[switch]$Check,
# The work record (guard-worklog) is DEACTIVATED as of 2026-09-11, when
# ClaudeCM moved from --dangerously-skip-permissions to --permission-mode
# auto. Bypass had been switching off plan mode and the auto-mode
# classifier, so the machine had no authorization layer and one was built
# here by hand; Anthropic's classifier now does that job.
#
# The script is still installed and this switch puts it back, so the
# decision is reversible in one command. It is off by default so a routine
# reinstall cannot quietly restore a gate the operator turned off.
[switch]$WithWorkRecord,
# The edit judge (judge-edit.py), added 2026-09-12. Auto mode's classifier
# never reviews an Edit or Write inside the working directory, and that is
# the one thing the operator complains about. The judge hands a separate model his
# last five messages and the pending edit and asks whether his words
# directed it. Off by default for the same reason as the work record: a
# routine reinstall must not quietly switch a gate on or off.
[switch]$WithJudge,
[string]$ClaudeDir,
[string]$BackupPath
)
$ErrorActionPreference = 'Stop'
$repo = $PSScriptRoot
$srcHooks = Join-Path $repo 'hooks'
$claudeDir = if ($ClaudeDir) { $ClaudeDir } else { Join-Path $env:USERPROFILE '.claude' }
$dstHooks = Join-Path $claudeDir 'hooks'
$settings = Join-Path $claudeDir 'settings.json'
$agreement = Join-Path $claudeDir 'agreement.md'
$backup = if ($BackupPath) { $BackupPath } else { Join-Path $env:USERPROFILE '.claudecm\backup\settings.json.pre-hooks' }
$scripts = @('lib_agreement.py', 'guard-bash.py', 'guard-write.py', 'guard-tool.py',
'lib_worklog.py', 'record-prompt.py', 'guard-worklog.py',
'guard-output.py', 'judge-edit.py')
$script:problems = @()
function Fault([string]$m) { $script:problems += $m; Write-Output " FAIL $m" }
function Good([string]$m) { Write-Output " ok $m" }
Write-Output ''
Write-Output 'Work agreement installer'
Write-Output " source: $srcHooks"
Write-Output " target: $dstHooks"
Write-Output " settings: $settings"
Write-Output " backup: $backup"
Write-Output ''
Write-Output '1. Preflight'
# --- python resolves, and is the interpreter the settings block will name
$py = (Get-Command python -ErrorAction SilentlyContinue)
if (-not $py) {
Fault "python is not on PATH. The settings block would name an interpreter that does not exist, and a hook that cannot start BLOCKS EVERY TOOL CALL."
} else {
$ver = & python --version 2>&1
Good "python found: $($py.Source) ($ver)"
}
# --- every source script exists
foreach ($s in $scripts) {
$p = Join-Path $srcHooks $s
if (Test-Path $p) { Good "source present: $s" } else { Fault "missing source script: $p" }
}
$srcAgreement = Join-Path $srcHooks 'agreement.md'
if (Test-Path $srcAgreement) { Good "source present: agreement.md" } else { Fault "missing: $srcAgreement" }
# --- settings.json is readable and parses
if (Test-Path $settings) {
try {
$null = Get-Content $settings -Raw | ConvertFrom-Json
Good "settings.json parses"
} catch {
Fault "settings.json does not parse: $($_.Exception.Message). Fix it before installing."
}
} else {
Good "no settings.json yet, one will be created"
}
# --- somewhere to put the backup
$backupDir = Split-Path $backup
try {
New-Item -ItemType Directory -Force $backupDir | Out-Null
Good "backup directory writable: $backupDir"
} catch {
Fault "cannot create backup directory $backupDir : $($_.Exception.Message)"
}
# --- report what would change
Write-Output ''
Write-Output '2. What this would change'
Write-Output " copy $($scripts.Count) guard scripts into $dstHooks"
if (Test-Path $agreement) {
Write-Output " LEAVE $agreement alone (it already exists; your rules are not overwritten)"
} else {
Write-Output " create $agreement with NO rules in force"
}
Write-Output " add 3 PreToolUse entries to settings.json (Bash; Write/Edit; all tools)"
if ($WithJudge) {
Write-Output " add the EDIT JUDGE on Write/Edit (-WithJudge): a headless Haiku call on your subscription per judged edit"
# The judge runs `claude -p`; prove the CLI is on PATH and logged in, or the
# judge would fail open on every edit and protect nothing.
$probe = ''
try {
$env:MAX_THINKING_TOKENS = '0'
$probe = 'Reply with the single word OK.' | & claude -p --model haiku --tools '' --no-session-persistence --setting-sources '' --permission-mode dontAsk --exclude-dynamic-system-prompt-sections --system-prompt 'You reply with one word.' 2>&1 | Out-String
} catch { $probe = "$($_.Exception.Message)" }
if ($probe -match '\bOK\b') { Good "headless claude -p answers on this machine (the judge's route)" }
else { Fault "headless claude -p did not answer: $($probe.Trim().Substring(0, [Math]::Min(120, $probe.Trim().Length)))" }
} else {
Write-Output " the edit judge is NOT wired (pass -WithJudge to enable it)"
}
Write-Output " existing hooks are preserved, including the cmv trimmer"
if ($script:problems.Count -gt 0) {
Write-Output ''
Write-Output "PREFLIGHT FAILED: $($script:problems.Count) problem(s). Nothing was changed."
exit 1
}
if ($Check) {
Write-Output ''
Write-Output 'PREFLIGHT PASSED. Nothing was changed (-Check).'
Write-Output 'Re-run without -Check to install.'
exit 0
}
# ------------------------------------------------------------------ install
Write-Output ''
Write-Output '3. Copying guards (not wired to anything yet)'
New-Item -ItemType Directory -Force $dstHooks | Out-Null
foreach ($s in $scripts) {
Copy-Item (Join-Path $srcHooks $s) (Join-Path $dstHooks $s) -Force
Good "copied $s"
}
if (-not (Test-Path $agreement)) {
Copy-Item $srcAgreement $agreement -Force
Good "created agreement.md with no rules in force"
} else {
Good "kept your existing agreement.md"
}
Write-Output ''
Write-Output '4. Smoke testing the copies on this machine'
# Prove each guard runs and allows a benign payload BEFORE wiring it in. A guard
# that cannot start is the failure that wedges every session.
$benign = '{"tool_name":"Bash","tool_input":{"command":"echo hello"}}'
foreach ($s in @('guard-bash.py', 'guard-write.py', 'guard-tool.py', 'judge-edit.py')) {
$p = Join-Path $dstHooks $s
$out = $benign | & python $p 2>&1
$rc = $LASTEXITCODE
if ($rc -ne 0) {
Fault "$s exited $rc on a benign payload. NOT WIRING ANYTHING."
} elseif ("$out" -match '"permissionDecision"\s*:\s*"(deny|ask)"') {
Fault "$s blocked a benign payload. NOT WIRING ANYTHING."
} else {
Good "$s runs and allows a benign command"
}
}
if ($script:problems.Count -gt 0) {
Write-Output ''
Write-Output 'SMOKE TEST FAILED. settings.json was NOT touched, so nothing is active.'
exit 1
}
Write-Output ''
Write-Output '5. Backing up settings.json'
if (Test-Path $settings) {
Copy-Item $settings $backup -Force
Good "backed up to $backup"
} else {
'{}' | Set-Content $backup -Encoding utf8
Good "no settings.json existed; wrote an empty backup so the undo still works"
}
Write-Output ''
Write-Output '6. Wiring the hooks'
$json = if (Test-Path $settings) { Get-Content $settings -Raw | ConvertFrom-Json } else { [pscustomobject]@{} }
if (-not $json.PSObject.Properties.Name.Contains('hooks')) {
$json | Add-Member -NotePropertyName hooks -NotePropertyValue ([pscustomobject]@{})
}
if (-not $json.hooks.PSObject.Properties.Name.Contains('PreToolUse')) {
$json.hooks | Add-Member -NotePropertyName PreToolUse -NotePropertyValue @()
}
$bashCmd = 'python "' + ($dstHooks -replace '\\', '/') + '/guard-bash.py"'
$writeCmd = 'python "' + ($dstHooks -replace '\\', '/') + '/guard-write.py"'
$toolCmd = 'python "' + ($dstHooks -replace '\\', '/') + '/guard-tool.py"'
$workCmd = 'python "' + ($dstHooks -replace '\\', '/') + '/guard-worklog.py"'
$recCmd = 'python "' + ($dstHooks -replace '\\', '/') + '/record-prompt.py"'
$outCmd = 'python "' + ($dstHooks -replace '\\', '/') + '/guard-output.py"'
$judgeCmd = 'python "' + ($dstHooks -replace '\\', '/') + '/judge-edit.py"'
# Drop any previous copy of ours first, so re-running does not duplicate.
#
# This list must name EVERY guard the installer adds below. guard-worklog was
# added without being listed here, so each reinstall appended another copy of
# it: two entries by the time anyone looked, and one more per run after that.
# Harmless in verdict, since both reach the same answer, but it spawns a wasted
# Python process on every tool call in every session and grows without bound.
# Retired names stay listed so a reinstall also cleans a machine that has them.
$OURS = 'guard-bash\.py|guard-write\.py|guard-tool\.py|guard-worklog\.py|' +
'judge-edit\.py|guard-authorization\.py|guard-scope\.py'
$kept = @($json.hooks.PreToolUse | Where-Object {
$entry = $_
-not (@($entry.hooks) | Where-Object { $_.command -match $OURS })
})
$kept += [pscustomobject]@{
matcher = 'Bash'
hooks = @([pscustomobject]@{ type = 'command'; command = $bashCmd; timeout = 15 })
}
$kept += [pscustomobject]@{
matcher = 'Write|Edit|MultiEdit|NotebookEdit'
hooks = @([pscustomobject]@{ type = 'command'; command = $writeCmd; timeout = 15 })
}
# No matcher: the tool guard has to see EVERY tool, because its whole job is
# matching on the name of a tool that must not run at all.
$kept += [pscustomobject]@{
hooks = @([pscustomobject]@{ type = 'command'; command = $toolCmd; timeout = 15 })
}
# The work-record guard, only when explicitly asked for. See -WithWorkRecord.
if ($WithWorkRecord) {
$kept += [pscustomobject]@{
hooks = @([pscustomobject]@{ type = 'command'; command = $workCmd; timeout = 15 })
}
}
# The edit judge, only when explicitly asked for. Timeout 35: three votes run
# in parallel, each capped at 20s inside the script, median 3.8s and worst
# measured 9.9s. A timed-out hook blocks the tool call, so this has room.
if ($WithJudge) {
$kept += [pscustomobject]@{
matcher = 'Write|Edit|MultiEdit|NotebookEdit'
hooks = @([pscustomobject]@{ type = 'command'; command = $judgeCmd; timeout = 35 })
}
}
$json.hooks.PreToolUse = $kept
# UserPromptSubmit: classify the operator's message before any tool runs. This hook
# decides nothing and never blocks a prompt; it only writes the verdict the
# rule-1 guard reads.
if (-not $json.hooks.PSObject.Properties.Name.Contains('UserPromptSubmit')) {
$json.hooks | Add-Member -NotePropertyName UserPromptSubmit -NotePropertyValue @()
}
$keptPrompt = @($json.hooks.UserPromptSubmit | Where-Object {
$entry = $_
-not (@($entry.hooks) | Where-Object { $_.command -match 'record-prompt\.py|classify-prompt\.py' })
})
$keptPrompt += [pscustomobject]@{
hooks = @([pscustomobject]@{ type = 'command'; command = $recCmd; timeout = 15 })
}
$json.hooks.UserPromptSubmit = $keptPrompt
# Stop: check the message just written against the `output` rules. This is the
# only hook that can see Claude's own prose, via last_assistant_message in the
# payload. It refuses to block twice in one turn, so it cannot spin a session.
if (-not $json.hooks.PSObject.Properties.Name.Contains('Stop')) {
$json.hooks | Add-Member -NotePropertyName Stop -NotePropertyValue @()
}
$keptStop = @($json.hooks.Stop | Where-Object {
$entry = $_
-not (@($entry.hooks) | Where-Object { $_.command -match 'guard-output\.py|check-output\.py' })
})
$keptStop += [pscustomobject]@{
hooks = @([pscustomobject]@{ type = 'command'; command = $outCmd; timeout = 15 })
}
$json.hooks.Stop = $keptStop
# -Depth 20 is load-bearing: the default of 2 flattens nested arrays to strings.
$json | ConvertTo-Json -Depth 20 | Set-Content $settings -Encoding utf8
Good "wrote settings.json"
Write-Output ''
Write-Output '7. Verifying from disk'
$after = Get-Content $settings -Raw
$reparsed = $null
try { $reparsed = $after | ConvertFrom-Json } catch { }
if (-not $reparsed) {
Fault "settings.json no longer parses. RESTORE NOW: pwsh -NoProfile -File `"$repo\undo-hooks.ps1`""
} else {
if ($after -match 'guard-bash\.py') { Good "guard-bash is wired" } else { Fault "guard-bash is not in the file" }
if ($after -match 'guard-write\.py') { Good "guard-write is wired" } else { Fault "guard-write is not in the file" }
if ($after -match 'guard-tool\.py') { Good "guard-tool is wired" } else { Fault "guard-tool is not in the file" }
# These two were added later and were NOT checked here at first, so the
# installer reported success having verified three of five guards. Verify
# what you are claiming, not that the write returned.
if ($WithWorkRecord) {
if ($after -match 'guard-worklog\.py') { Good "guard-worklog is wired (-WithWorkRecord)" } else { Fault "guard-worklog was requested but is not in the file" }
} elseif ($after -match 'guard-worklog\.py') {
Fault "guard-worklog is wired but was not requested; it is deactivated by default"
} else {
Good "guard-worklog is deactivated (pass -WithWorkRecord to restore)"
}
if ($WithJudge) {
if ($after -match 'judge-edit\.py') { Good "judge-edit is wired (-WithJudge)" } else { Fault "judge-edit was requested but is not in the file" }
} elseif ($after -match 'judge-edit\.py') {
Fault "judge-edit is wired but was not requested; it is off by default"
} else {
Good "judge-edit is not wired (pass -WithJudge to enable)"
}
if ($after -match 'record-prompt\.py') { Good "record-prompt is wired (UserPromptSubmit)" } else { Fault "record-prompt is not in the file" }
if ($after -match 'guard-output\.py') { Good "guard-output is wired (Stop)" } else { Fault "guard-output is not in the file" }
# Nothing may reference a script that is not on disk. That is the exact
# shape of the 2026-09-10 wedge: settings pointed at a deleted file, Python
# exited non-zero, and every tool call in every session was refused.
foreach ($m in [regex]::Matches($after, '[A-Za-z0-9_-]+\.py')) {
$p = Join-Path $dstHooks $m.Value
if (-not (Test-Path $p)) { Fault "settings.json names $($m.Value) but it is not in $dstHooks" }
}
# No guard may be wired twice. A duplicate is not wrong, only wasteful, but
# it grows by one on every reinstall and nothing else would ever report it.
foreach ($g in @('guard-bash.py', 'guard-write.py', 'guard-tool.py',
'guard-worklog.py', 'record-prompt.py', 'guard-output.py',
'judge-edit.py')) {
$n = ([regex]::Matches($after, [regex]::Escape($g))).Count
if ($n -gt 1) { Fault "$g is wired $n times; it should appear once" }
}
$cmv = ($after -match 'cmv auto-trim')
if ($cmv) { Good "the cmv trimmer survived" }
}
Write-Output ''
if ($script:problems.Count -gt 0) {
Write-Output "INSTALL INCOMPLETE. Undo with:"
Write-Output " pwsh -NoProfile -File `"$repo\undo-hooks.ps1`""
exit 1
}
# Report the ACTUAL rule count rather than assuming a first install. This line
# used to say "inert: no rules are in force yet" unconditionally, which was a
# lie on every reinstall after the first.
$ruleCount = '(could not read)'
try {
$listing = & python (Join-Path $dstHooks 'lib_agreement.py') --list 2>&1 | Out-String
if ($listing -match '(\d+)\s+rule\(s\) in force') { $ruleCount = $Matches[1] }
} catch { }
if ($ruleCount -eq '0') {
Write-Output 'INSTALLED, and inert: no rules are in force yet.'
} else {
Write-Output "INSTALLED. $ruleCount rule(s) are in force."
Write-Output " Reads are never gated. A state change needs an open task in"
Write-Output " hooks\state\current-task.json citing something you actually said."
}
Write-Output ''
Write-Output "Edit your rules here: $agreement"
Write-Output "Test a rule first: python `"$dstHooks\lib_agreement.py`" --list"
Write-Output ''
Write-Output 'If anything goes wrong, from any terminal:'
Write-Output " pwsh -NoProfile -File `"$repo\undo-hooks.ps1`""
Write-Output ''
Write-Output 'Running sessions pick this up on their next tool call. No restart needed.'
exit 0