claude-bulkhead 0.2.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +51 -0
- package/README.es.md +142 -50
- package/README.ja.md +142 -50
- package/README.ko.md +143 -51
- package/README.md +142 -50
- package/README.pt-BR.md +142 -50
- package/README.zh-CN.md +154 -62
- package/hooks/hooks.json +68 -8
- package/package.json +5 -2
- package/src/agent-hook.js +162 -0
- package/src/agent-setup.js +256 -0
- package/src/agents.js +89 -0
- package/src/allow-cli.js +65 -0
- package/src/allow-hint.js +62 -0
- package/src/allow-once.js +152 -0
- package/src/auto-queue.js +93 -0
- package/src/bash-guards.js +36 -0
- package/src/basics.js +46 -27
- package/src/checkout-guard.js +342 -74
- package/src/cli-util.js +13 -1
- package/src/cli.js +40 -4
- package/src/config-check.js +21 -4
- package/src/config-cli.js +75 -10
- package/src/config.js +106 -9
- package/src/dev-servers.js +361 -61
- package/src/disk-guard.js +8 -8
- package/src/doctor-enforcement.js +273 -0
- package/src/doctor.js +19 -9
- package/src/edit-guard.js +196 -0
- package/src/events.js +30 -5
- package/src/explain.js +251 -0
- package/src/git-info.js +55 -0
- package/src/gitargs.js +82 -0
- package/src/hook.js +38 -6
- package/src/kill-extras.js +219 -0
- package/src/kill-guard.js +228 -77
- package/src/kill-owners.js +116 -0
- package/src/log-cli.js +15 -10
- package/src/mcp.js +131 -0
- package/src/memory-guard.js +126 -24
- package/src/memory.js +21 -2
- package/src/notices.js +95 -0
- package/src/orphan-reaper.js +58 -11
- package/src/port-cli.js +78 -3
- package/src/port-guard.js +80 -68
- package/src/port-kills.js +198 -0
- package/src/pressure.js +75 -0
- package/src/processes.js +44 -12
- package/src/scan-report.js +166 -0
- package/src/scan.js +87 -18
- package/src/secrets-guard.js +301 -0
- package/src/self-allow.js +73 -0
- package/src/session-info.js +121 -0
- package/src/session-state.js +176 -0
- package/src/sessions.js +109 -20
- package/src/shell.js +279 -19
- package/src/slot-cli.js +92 -21
- package/src/slots.js +61 -13
- package/src/stable-port.js +61 -0
- package/src/status.js +55 -17
- package/src/statusline.js +45 -16
- package/src/subagent-guard.js +24 -0
- package/src/top.js +36 -11
- package/src/wait-guard.js +96 -32
- package/src/worktree-cli.js +454 -0
- package/src/worktree-copy.js +82 -0
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
// bulkhead doctor, "Enforcement": proves the guards would block, not only that files exist.
|
|
3
|
+
// What Claude Code does with settings and hooks is taken from its docs:
|
|
4
|
+
// hooks https://code.claude.com/docs/en/hooks (exec form, timeouts, exit code 2, async, onFailure)
|
|
5
|
+
// settings https://code.claude.com/docs/en/settings-reference (disableAllHooks, allowManagedHooksOnly, enabledPlugins)
|
|
6
|
+
const fs = require('fs')
|
|
7
|
+
const os = require('os')
|
|
8
|
+
const path = require('path')
|
|
9
|
+
const { spawnSync } = require('child_process')
|
|
10
|
+
const config = require('./config')
|
|
11
|
+
const { claudeDir } = require('./sessions')
|
|
12
|
+
const { shortPath } = require('./cli-util')
|
|
13
|
+
|
|
14
|
+
const ROOT = path.resolve(__dirname, '..')
|
|
15
|
+
const SECTION = 'Enforcement'
|
|
16
|
+
const PLUGIN = 'bulkhead'
|
|
17
|
+
const INSTALL = '/plugin install bulkhead@bulkhead'
|
|
18
|
+
const DOCS = 'https://code.claude.com/docs/en'
|
|
19
|
+
|
|
20
|
+
const mk = status => (id, summary) => ({ id, status, summary, section: SECTION })
|
|
21
|
+
const ok = mk('OK')
|
|
22
|
+
const warn = mk('WARN')
|
|
23
|
+
const fail = mk('FAIL')
|
|
24
|
+
|
|
25
|
+
// A harmless command each guard must block. Nothing is executed: the hook only reads the text.
|
|
26
|
+
const CANARIES = {
|
|
27
|
+
'kill-guard.js': { guard: 'kill-guard', command: 'pkill node', stderr: /kill-guard/ },
|
|
28
|
+
'wait-guard.js': { guard: 'wait-guard', command: 'sleep 100000', extra: { timeout: 3600000 }, stderr: /wait-guard/ },
|
|
29
|
+
'disk-guard.js': { guard: 'disk-guard', command: 'du -sh /', stderr: /disk-guard/ },
|
|
30
|
+
'basics.js': { guard: 'basics', command: 'rm -rf /', stderr: /basics/, rule: 'basics.rmRoot' },
|
|
31
|
+
}
|
|
32
|
+
const HOOK_DEFAULT_TIMEOUT_S = 600
|
|
33
|
+
const DOCTOR_MAX_TIMEOUT_S = 60
|
|
34
|
+
|
|
35
|
+
function readJson(file) {
|
|
36
|
+
try { return JSON.parse(fs.readFileSync(file, 'utf8')) } catch { return null }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function managedFile(platform, env) {
|
|
40
|
+
if (env.BULKHEAD_MANAGED_SETTINGS) return env.BULKHEAD_MANAGED_SETTINGS
|
|
41
|
+
if (platform === 'win32') return 'C:\\Program Files\\ClaudeCode\\managed-settings.json'
|
|
42
|
+
if (platform === 'darwin') return '/Library/Application Support/ClaudeCode/managed-settings.json'
|
|
43
|
+
return '/etc/claude-code/managed-settings.json'
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Lowest to highest precedence: user, project, local, managed.
|
|
47
|
+
function settingsFiles({ configDir, cwd, platform, env }) {
|
|
48
|
+
return [
|
|
49
|
+
{ scope: 'user', file: path.join(configDir, 'settings.json') },
|
|
50
|
+
{ scope: 'project', file: path.join(cwd, '.claude', 'settings.json') },
|
|
51
|
+
{ scope: 'local', file: path.join(cwd, '.claude', 'settings.local.json') },
|
|
52
|
+
{ scope: 'managed', file: managedFile(platform, env) },
|
|
53
|
+
].map(s => ({ ...s, json: readJson(s.file) })).filter(s => s.json && typeof s.json === 'object')
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// The last scope that sets a key wins.
|
|
57
|
+
function effective(files, key) {
|
|
58
|
+
let found = null
|
|
59
|
+
for (const s of files) if (s.json[key] !== undefined) found = { scope: s.scope, file: s.file, value: s.json[key] }
|
|
60
|
+
return found
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function enabledEntry(files) {
|
|
64
|
+
let found = null
|
|
65
|
+
for (const s of files) {
|
|
66
|
+
const e = s.json.enabledPlugins
|
|
67
|
+
if (!e || typeof e !== 'object') continue
|
|
68
|
+
for (const [id, v] of Object.entries(e)) if (id.split('@')[0] === PLUGIN) found = { id, value: v, scope: s.scope, file: s.file }
|
|
69
|
+
}
|
|
70
|
+
return found
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function installs(configDir) {
|
|
74
|
+
const j = readJson(path.join(configDir, 'plugins', 'installed_plugins.json'))
|
|
75
|
+
const out = []
|
|
76
|
+
for (const [id, list] of Object.entries((j && j.plugins) || {})) {
|
|
77
|
+
if (id.split('@')[0] !== PLUGIN) continue
|
|
78
|
+
for (const e of Array.isArray(list) ? list : []) out.push({ id, scope: e.scope, installPath: e.installPath, version: e.version })
|
|
79
|
+
}
|
|
80
|
+
return out
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function hasMarketplace(configDir) {
|
|
84
|
+
const j = readJson(path.join(configDir, 'plugins', 'known_marketplaces.json'))
|
|
85
|
+
return Object.entries(j || {}).some(([name, v]) => name === PLUGIN || /claude-bulkhead/.test(JSON.stringify(v)))
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Hooks written by hand into a settings file: not the plugin's own.
|
|
89
|
+
function manualHooks(files) {
|
|
90
|
+
const out = []
|
|
91
|
+
for (const s of files) {
|
|
92
|
+
for (const groups of Object.values(s.json.hooks || {})) {
|
|
93
|
+
for (const g of Array.isArray(groups) ? groups : []) {
|
|
94
|
+
for (const h of g.hooks || []) {
|
|
95
|
+
if (/bulkhead|(kill|wait|port|checkout|disk|memory)-guard\.js|bash-guards\.js/.test([h.command, ...(h.args || [])].join(' '))) out.push(s)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return [...new Set(out)]
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function checkInstall(ctx) {
|
|
104
|
+
const { files, configDir } = ctx
|
|
105
|
+
const list = installs(configDir)
|
|
106
|
+
const entry = enabledEntry(files)
|
|
107
|
+
const manual = manualHooks(files)
|
|
108
|
+
const out = []
|
|
109
|
+
const live = list.filter(i => i.installPath && fs.existsSync(path.join(i.installPath, 'hooks', 'hooks.json')))
|
|
110
|
+
if (!list.length && !(entry && entry.value === true)) {
|
|
111
|
+
if (manual.length) out.push(ok('install', `by hand: bulkhead hooks in ${manual.map(m => shortPath(m.file)).join(', ')}`))
|
|
112
|
+
else out.push(warn('install', `not installed in Claude Code; run ${hasMarketplace(configDir) ? '' : '/plugin marketplace add dooly6276/claude-bulkhead, then '}${INSTALL}`))
|
|
113
|
+
return { checks: out, installed: null }
|
|
114
|
+
}
|
|
115
|
+
if (entry && entry.value === false) {
|
|
116
|
+
out.push(fail('install', `${entry.id} is turned off in ${entry.scope} settings (${shortPath(entry.file)}); run /plugin enable ${entry.id}`))
|
|
117
|
+
return { checks: out, installed: live[0] || null }
|
|
118
|
+
}
|
|
119
|
+
if (!live.length) {
|
|
120
|
+
out.push(fail('install', `${list[0] ? `${shortPath(list[0].installPath || '?')} has no hooks/hooks.json` : `${entry.id} is enabled but not installed`}; reinstall with ${INSTALL}`))
|
|
121
|
+
return { checks: out, installed: null }
|
|
122
|
+
}
|
|
123
|
+
const i = live[0]
|
|
124
|
+
const state = entry ? `enabled in ${entry.scope} settings` : 'no enabledPlugins entry, so Claude Code uses the plugin default'
|
|
125
|
+
const text = `${i.id}${i.version ? ` ${i.version}` : ''} at ${shortPath(i.installPath)}, ${state}`
|
|
126
|
+
out.push(entry ? ok('install', text) : warn('install', `${text}; run /plugin enable ${i.id}`))
|
|
127
|
+
const roots = [...new Set(live.map(l => l.installPath))]
|
|
128
|
+
if (roots.length > 1) out.push(warn('duplicates', `${roots.length} installed copies (${live.map(l => l.scope || '?').join(', ')}); every guard may run once per copy. Keep one: /plugin uninstall for the extra scope`))
|
|
129
|
+
if (manual.length) out.push(warn('duplicates', `bulkhead hooks are in ${manual.map(m => shortPath(m.file)).join(', ')} and in the plugin, so each call runs the guards twice; delete the hooks from the settings file`))
|
|
130
|
+
return { checks: out, installed: i }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Settings that make Claude Code skip hooks, as the settings reference documents them.
|
|
134
|
+
function checkSettings(ctx) {
|
|
135
|
+
const { files, installed } = ctx
|
|
136
|
+
const out = []
|
|
137
|
+
const off = effective(files, 'disableAllHooks')
|
|
138
|
+
if (off && off.value === true) {
|
|
139
|
+
out.push(off.scope === 'managed'
|
|
140
|
+
? fail('hooks off', `disableAllHooks is true in managed settings (${shortPath(off.file)}); no hook runs. Ask your administrator (${DOCS}/settings-reference#disableallhooks)`)
|
|
141
|
+
: fail('hooks off', `disableAllHooks is true in ${off.scope} settings (${shortPath(off.file)}): Claude Code turns off plugin hooks. Remove it or set it to false (${DOCS}/settings-reference#disableallhooks)`))
|
|
142
|
+
}
|
|
143
|
+
const managed = effective(files, 'allowManagedHooksOnly')
|
|
144
|
+
if (managed && managed.scope === 'managed' && managed.value === true) {
|
|
145
|
+
const forced = files.some(s => s.scope === 'managed' && s.json.enabledPlugins && Object.entries(s.json.enabledPlugins).some(([id, v]) => id.split('@')[0] === PLUGIN && v === true))
|
|
146
|
+
out.push(forced
|
|
147
|
+
? ok('managed hooks', 'allowManagedHooksOnly is on, and managed settings force-enable the plugin')
|
|
148
|
+
: fail('managed hooks', `allowManagedHooksOnly is true in managed settings, so plugin hooks do not run unless managed enabledPlugins force-enables ${PLUGIN}@... (${DOCS}/settings-reference#what-runs-under-allowmanagedhooksonly)`))
|
|
149
|
+
}
|
|
150
|
+
const blocked = files.find(s => s.scope === 'managed' && s.json.enabledPlugins && Object.entries(s.json.enabledPlugins).some(([id, v]) => id.split('@')[0] === PLUGIN && v === false))
|
|
151
|
+
if (blocked) out.push(fail('managed plugin', `managed settings turn ${PLUGIN} off and block its installation (${DOCS}/settings-reference#enabledplugins)`))
|
|
152
|
+
if (!out.length && installed) out.push(ok('hooks on', 'no disableAllHooks or allowManagedHooksOnly in user, project, local or managed settings'))
|
|
153
|
+
return out
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function substitute(text, vars) {
|
|
157
|
+
return String(text).replace(/\$\{(CLAUDE_PLUGIN_ROOT|CLAUDE_PROJECT_DIR|CLAUDE_PLUGIN_DATA)\}/g, (_, k) => vars[k])
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function hookDeclarations(root) {
|
|
161
|
+
const file = path.join(root, 'hooks', 'hooks.json')
|
|
162
|
+
let json
|
|
163
|
+
try { json = JSON.parse(fs.readFileSync(file, 'utf8')) } catch (e) { return { file, error: e.code === 'ENOENT' ? 'is missing' : `cannot be read (${e.message})` } }
|
|
164
|
+
const list = []
|
|
165
|
+
for (const g of (json.hooks && json.hooks.PreToolUse) || []) for (const h of g.hooks || []) list.push({ matcher: g.matcher || '', ...h })
|
|
166
|
+
return { file, list }
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function findOnPath(name, env, platform) {
|
|
170
|
+
const sep = platform === 'win32' ? ';' : ':'
|
|
171
|
+
const exts = platform === 'win32' ? ['', ...String(env.PATHEXT || '.EXE;.CMD').split(';')] : ['']
|
|
172
|
+
for (const dir of String(env.PATH || env.Path || '').split(sep).filter(Boolean)) {
|
|
173
|
+
for (const ext of exts) {
|
|
174
|
+
const p = path.join(dir, name + ext)
|
|
175
|
+
try { if (fs.statSync(p).isFile()) return p } catch {}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return null
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Starts the hook the way Claude Code does: exec form (args present) spawns command directly on PATH,
|
|
182
|
+
// shell form goes through bash, or PowerShell when the hook asks for it. Same placeholders, same timeout.
|
|
183
|
+
function runHook(h, vars, { env, platform, spawn, input, timeoutS }) {
|
|
184
|
+
const hookEnv = { ...env, CLAUDE_PLUGIN_ROOT: vars.CLAUDE_PLUGIN_ROOT, CLAUDE_PROJECT_DIR: vars.CLAUDE_PROJECT_DIR, CLAUDE_PLUGIN_DATA: vars.CLAUDE_PLUGIN_DATA }
|
|
185
|
+
const opts = { input, encoding: 'utf8', timeout: timeoutS * 1000, env: hookEnv, windowsHide: true, killSignal: 'SIGKILL' }
|
|
186
|
+
if (Array.isArray(h.args)) return spawn(substitute(h.command, vars), h.args.map(a => substitute(a, vars)), opts)
|
|
187
|
+
const cmd = substitute(h.command, vars)
|
|
188
|
+
if (h.shell === 'powershell') return spawn('powershell', ['-NoProfile', '-Command', cmd], opts)
|
|
189
|
+
const bash = platform === 'win32' ? findOnPath('bash', env, platform) : '/bin/bash'
|
|
190
|
+
return bash ? spawn(bash, ['-c', cmd], opts) : spawn('powershell', ['-NoProfile', '-Command', cmd], opts)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// The canaries a hook entry must block: one for a single guard's script, all of them for
|
|
194
|
+
// bash-guards.js, which runs every Bash guard in one process.
|
|
195
|
+
function canariesOf(h) {
|
|
196
|
+
const script = [h.command, ...(h.args || [])].map(String).find(a => /\.js$/.test(a))
|
|
197
|
+
if (!script) return []
|
|
198
|
+
if (path.basename(script) === 'bash-guards.js') return Object.values(CANARIES)
|
|
199
|
+
return CANARIES[path.basename(script)] ? [CANARIES[path.basename(script)]] : []
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function checkHooks(ctx) {
|
|
203
|
+
const { root, installed, env, platform, spawn, cfg } = ctx
|
|
204
|
+
const out = []
|
|
205
|
+
const where = installed ? 'installed copy' : 'this copy (not installed)'
|
|
206
|
+
const decl = hookDeclarations(root)
|
|
207
|
+
if (decl.error) return [fail('hooks.json', `${shortPath(decl.file)} ${decl.error}; reinstall with ${INSTALL}`)]
|
|
208
|
+
const asyncHooks = decl.list.filter(h => h.async === true || h.asyncRewake === true)
|
|
209
|
+
if (asyncHooks.length) out.push(fail('hooks.json', `${asyncHooks.length} PreToolUse hook${asyncHooks.length === 1 ? ' is' : 's are'} async, and an async hook cannot block (${DOCS}/hooks#run-hooks-in-the-background)`))
|
|
210
|
+
else out.push(ok('hooks.json', `${where}: ${decl.list.length} PreToolUse hooks, all synchronous`))
|
|
211
|
+
|
|
212
|
+
// node must resolve on the PATH the hook gets
|
|
213
|
+
const first = decl.list.find(h => Array.isArray(h.args))
|
|
214
|
+
if (first) {
|
|
215
|
+
const resolved = findOnPath(first.command, env, platform)
|
|
216
|
+
if (!resolved) out.push(fail('hook node', `"${first.command}" is not on the PATH hooks get, so every hook fails to start and Claude Code lets the call through; put Node 18+ on PATH for the shell that starts Claude Code`))
|
|
217
|
+
else {
|
|
218
|
+
const v = spawn(resolved, ['-p', 'process.versions.node'], { encoding: 'utf8', timeout: 10000, windowsHide: true })
|
|
219
|
+
const ver = String(v.stdout || '').trim()
|
|
220
|
+
const major = Number(ver.split('.')[0])
|
|
221
|
+
out.push(major >= 18 ? ok('hook node', `${shortPath(resolved)} v${ver}`) : fail('hook node', `${shortPath(resolved)} ${ver ? `is v${ver}` : 'does not run'}; hooks need Node 18 or later first on PATH`))
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'bulkhead-doctor-'))
|
|
226
|
+
const vars = { CLAUDE_PLUGIN_ROOT: root, CLAUDE_PROJECT_DIR: tmp, CLAUDE_PLUGIN_DATA: path.join(tmp, 'data') }
|
|
227
|
+
try {
|
|
228
|
+
for (const h of decl.list) for (const canary of canariesOf(h)) {
|
|
229
|
+
const rule = canary.rule
|
|
230
|
+
if (cfg && (cfg.values[`${config.GUARDS[canary.guard]}.enabled`] === false || (rule && cfg.values[rule] === false))) {
|
|
231
|
+
out.push(ok(`hook ${canary.guard}`, 'off in your config, not run'))
|
|
232
|
+
continue
|
|
233
|
+
}
|
|
234
|
+
if (h.matcher && !new RegExp(`^(?:${h.matcher})$`).test('Bash')) {
|
|
235
|
+
out.push(fail(`hook ${canary.guard}`, `its matcher "${h.matcher}" does not match the Bash tool`))
|
|
236
|
+
continue
|
|
237
|
+
}
|
|
238
|
+
const timeoutS = Math.min(h.timeout || HOOK_DEFAULT_TIMEOUT_S, DOCTOR_MAX_TIMEOUT_S)
|
|
239
|
+
const input = JSON.stringify({ hook_event_name: 'PreToolUse', tool_name: 'Bash', tool_input: { command: canary.command, ...canary.extra }, session_id: 'bulkhead-doctor', cwd: tmp, permission_mode: 'default' })
|
|
240
|
+
const t0 = Date.now()
|
|
241
|
+
const r = runHook(h, vars, {
|
|
242
|
+
env: { ...env, BULKHEAD_STATE_DIR: tmp, BULKHEAD_DISABLE: '', BULKHEAD_NO_LOG: '1' }, platform, spawn, input, timeoutS,
|
|
243
|
+
})
|
|
244
|
+
const ms = Date.now() - t0
|
|
245
|
+
const id = `hook ${canary.guard}`
|
|
246
|
+
if (r.error && r.error.code === 'ENOENT') out.push(fail(id, `could not start "${h.command}" (not found); no call is blocked. Put Node 18+ on PATH`))
|
|
247
|
+
else if (r.error && (r.error.code === 'ETIMEDOUT' || r.signal)) out.push(fail(id, `timed out after ${timeoutS}s; Claude Code lets the call through when a PreToolUse hook times out (${DOCS}/hooks#timeouts). Run bulkhead doctor again, then report it`))
|
|
248
|
+
else if (r.status === 2 && canary.stderr.test(r.stderr || '')) out.push(ok(id, `blocked "${canary.command}" (exit 2, message shown to Claude) in ${ms} ms`))
|
|
249
|
+
else if (r.status === 2) out.push(fail(id, `exit 2 but the message is missing, Claude would not see why: ${String(r.stderr || '').trim().slice(0, 80) || '(empty)'}`))
|
|
250
|
+
else out.push(fail(id, `did not block "${canary.command}" (exit ${r.status}${r.stderr ? `: ${String(r.stderr).trim().split('\n')[0].slice(0, 80)}` : ''}); reinstall with ${INSTALL}`))
|
|
251
|
+
}
|
|
252
|
+
} finally {
|
|
253
|
+
try { fs.rmSync(tmp, { recursive: true, force: true }) } catch {}
|
|
254
|
+
}
|
|
255
|
+
return out
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function checkEnforcement({
|
|
259
|
+
root = ROOT, env = process.env, cwd = process.cwd(), platform = process.platform, spawn = spawnSync, cfg = null,
|
|
260
|
+
configDir = env.CLAUDE_CONFIG_DIR || claudeDir(),
|
|
261
|
+
} = {}) {
|
|
262
|
+
const files = settingsFiles({ configDir, cwd, platform, env })
|
|
263
|
+
const ctx = { root, env, platform, spawn, cfg, files, configDir, cwd }
|
|
264
|
+
let inst
|
|
265
|
+
try { inst = checkInstall(ctx) } catch (e) { return [fail('install', `could not read the Claude Code install: ${e.message}`)] }
|
|
266
|
+
// the hooks.json Claude Code runs is the installed copy's; without an install, test this copy
|
|
267
|
+
const runRoot = inst.installed && inst.installed.installPath ? inst.installed.installPath : root
|
|
268
|
+
const out = [...inst.checks, ...checkSettings({ files, installed: inst.installed })]
|
|
269
|
+
out.push(...checkHooks({ ...ctx, root: runRoot, installed: inst.installed && inst.installed.installPath ? inst.installed : null }))
|
|
270
|
+
return out
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
module.exports = { checkEnforcement, SECTION, CANARIES, findOnPath, managedFile }
|
package/src/doctor.js
CHANGED
|
@@ -12,6 +12,7 @@ const { readSessionFiles, findSessions, claudeDir } = require('./sessions')
|
|
|
12
12
|
const { limits, readSlots, slotDir, staleReason } = require('./slots')
|
|
13
13
|
const { useColor, paint, shortPath, bytes, rejectArgs } = require('./cli-util')
|
|
14
14
|
const remote = require('./remote')
|
|
15
|
+
const { checkEnforcement } = require('./doctor-enforcement')
|
|
15
16
|
|
|
16
17
|
const ROOT = path.resolve(__dirname, '..')
|
|
17
18
|
const SMALL_MACHINE_GB = 24
|
|
@@ -31,7 +32,7 @@ function checkPlugin(root = ROOT) {
|
|
|
31
32
|
try {
|
|
32
33
|
hooks = JSON.parse(fs.readFileSync(file, 'utf8')).hooks
|
|
33
34
|
} catch (e) {
|
|
34
|
-
return fail('
|
|
35
|
+
return fail('package', `${shortPath(file)} cannot be read: ${e.message}`)
|
|
35
36
|
}
|
|
36
37
|
const missing = []
|
|
37
38
|
const counts = []
|
|
@@ -48,8 +49,8 @@ function checkPlugin(root = ROOT) {
|
|
|
48
49
|
}
|
|
49
50
|
counts.push(`${n} ${event}`)
|
|
50
51
|
}
|
|
51
|
-
if (missing.length) return fail('
|
|
52
|
-
return ok('
|
|
52
|
+
if (missing.length) return fail('package', `hooks.json names scripts that are missing: ${missing.join(', ')}`)
|
|
53
|
+
return ok('package', `this copy ${shortPath(root)}, hooks: ${counts.join(', ')}`)
|
|
53
54
|
}
|
|
54
55
|
|
|
55
56
|
function checkStateDir(dir = stateDir()) {
|
|
@@ -69,12 +70,13 @@ function checkConfig(cfg) {
|
|
|
69
70
|
const out = []
|
|
70
71
|
if (!cfg.warnings.length) out.push(ok('config', files.length ? `loaded ${files.map(f => shortPath(f)).join(', ')}` : 'no config files, defaults in use'))
|
|
71
72
|
for (const w of cfg.warnings) out.push(warn('config', w.replace(/^(\S+)/, m => shortPath(m))))
|
|
73
|
+
out.push(ok('preset', `${cfg.preset} (${why(cfg.presetFrom)})`))
|
|
72
74
|
return out
|
|
73
75
|
}
|
|
74
76
|
|
|
75
77
|
function why(source) {
|
|
76
78
|
if (source === 'default') return 'default'
|
|
77
|
-
if (source.startsWith('env ')) return source
|
|
79
|
+
if (source.startsWith('env ') || source.startsWith('preset ')) return source
|
|
78
80
|
return shortPath(source)
|
|
79
81
|
}
|
|
80
82
|
|
|
@@ -125,14 +127,16 @@ function checkProcesses({ read = () => readProcesses(), files = () => readSessio
|
|
|
125
127
|
}
|
|
126
128
|
const ms = now() - t0
|
|
127
129
|
if (!snap) return warn('processes', `no process reader for ${platform}; port-guard, top and status see no sessions`)
|
|
128
|
-
const
|
|
129
|
-
const
|
|
130
|
+
const known = files()
|
|
131
|
+
const sessions = findSessions(snap, known)
|
|
132
|
+
const withFile = sessions.filter(s => known.has(s.pid)).length
|
|
133
|
+
const text = `${snap.procs.length} processes, ${snap.ports.length} listening ports, ${sessions.length} Claude session${sessions.length === 1 ? '' : 's'} (${withFile} with a session file) in ${(ms / 1000).toFixed(1)}s`
|
|
130
134
|
return ms > 8000 ? warn('processes', `${text}; slow, so port-guard may hit its 20s timeout on a busy machine`) : ok('processes', text)
|
|
131
135
|
}
|
|
132
136
|
|
|
133
137
|
function checkSessionsDir(dir = path.join(claudeDir(), 'sessions')) {
|
|
134
138
|
return fs.existsSync(dir)
|
|
135
|
-
? ok('sessions', `${shortPath(dir)} (${readSessionFiles(dir).size} session files)`)
|
|
139
|
+
? ok('sessions', `${shortPath(dir)} (${readSessionFiles(dir).size} session files; a running session without one is still found from the process list, but checkout-guard cannot tell which folder it works in)`)
|
|
136
140
|
: warn('sessions', `${shortPath(dir)} does not exist; checkout-guard cannot see other sessions and project names show as ?`)
|
|
137
141
|
}
|
|
138
142
|
|
|
@@ -218,13 +222,13 @@ function runAll({ remotes = true } = {}) {
|
|
|
218
222
|
checkStateDir(),
|
|
219
223
|
...checkConfig(cfg),
|
|
220
224
|
...checkGuards(cfg),
|
|
221
|
-
checkHookRun(),
|
|
222
225
|
checkProcesses(),
|
|
223
226
|
checkSessionsDir(),
|
|
224
227
|
checkMemory(),
|
|
225
228
|
checkSlots(),
|
|
226
229
|
checkLog(cfg),
|
|
227
230
|
checkGit(),
|
|
231
|
+
...checkEnforcement({ cfg }),
|
|
228
232
|
...(remotes ? checkRemotes(cfg) : []),
|
|
229
233
|
]
|
|
230
234
|
}
|
|
@@ -232,7 +236,13 @@ function runAll({ remotes = true } = {}) {
|
|
|
232
236
|
function render(checks, { color = false } = {}) {
|
|
233
237
|
const style = { OK: 'green', WARN: 'yellow', FAIL: 'red' }
|
|
234
238
|
const width = Math.max(...checks.map(c => c.id.length))
|
|
235
|
-
const lines =
|
|
239
|
+
const lines = []
|
|
240
|
+
let section = null
|
|
241
|
+
for (const c of checks) {
|
|
242
|
+
if (c.section && c.section !== section) lines.push('', paint(color, 'bold', c.section))
|
|
243
|
+
section = c.section || null
|
|
244
|
+
lines.push(`${paint(color, style[c.status], c.status.padEnd(4))} ${c.id.padEnd(width)} ${c.summary}`)
|
|
245
|
+
}
|
|
236
246
|
const count = s => checks.filter(c => c.status === s).length
|
|
237
247
|
lines.push('', `${count('OK')} OK, ${count('WARN')} WARN, ${count('FAIL')} FAIL`)
|
|
238
248
|
return lines.join('\n')
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
const fs = require('fs')
|
|
3
|
+
const os = require('os')
|
|
4
|
+
const path = require('path')
|
|
5
|
+
const crypto = require('crypto')
|
|
6
|
+
const { runHook, blocked, setting, stateDir, ASK_USER } = require('./hook')
|
|
7
|
+
const { readSessionFiles } = require('./sessions')
|
|
8
|
+
const { realPath, pathKey } = require('./paths')
|
|
9
|
+
|
|
10
|
+
// Advisory overlap detection without a server. After every edit, PostToolUse appends
|
|
11
|
+
// { session, file, time } to a small file per repository in the state directory. Before an edit,
|
|
12
|
+
// PreToolUse reads that file and, when another session that is still running edited the same
|
|
13
|
+
// file recently (in this tree or in another worktree of the same repository), tells the agent.
|
|
14
|
+
// Everything here is plain file reads: no process scan, no git, no network.
|
|
15
|
+
|
|
16
|
+
const MAX_ENTRIES = 300
|
|
17
|
+
const KEEP_MS = 2 * 60 * 60 * 1000
|
|
18
|
+
const MODES = ['warn', 'ask', 'block']
|
|
19
|
+
|
|
20
|
+
// The .git folder or file at or above `file`, read without running git. A linked worktree has a
|
|
21
|
+
// .git file that points at <common>/worktrees/<name>, whose commondir file names <common>.
|
|
22
|
+
function repoOf(file, read = fs) {
|
|
23
|
+
let dir = path.dirname(file)
|
|
24
|
+
for (let i = 0; i < 40; i++) {
|
|
25
|
+
const dotGit = path.join(dir, '.git')
|
|
26
|
+
let st = null
|
|
27
|
+
try { st = read.statSync(dotGit) } catch {}
|
|
28
|
+
if (st && st.isDirectory()) return { top: dir, gitdir: dotGit, common: dotGit }
|
|
29
|
+
if (st && st.isFile()) {
|
|
30
|
+
try {
|
|
31
|
+
const m = /^gitdir:\s*(.+)$/m.exec(read.readFileSync(dotGit, 'utf8'))
|
|
32
|
+
if (!m) return null
|
|
33
|
+
const gitdir = path.resolve(dir, m[1].trim())
|
|
34
|
+
let common = gitdir
|
|
35
|
+
try { common = path.resolve(gitdir, read.readFileSync(path.join(gitdir, 'commondir'), 'utf8').trim()) } catch {}
|
|
36
|
+
return { top: dir, gitdir, common }
|
|
37
|
+
} catch { return null }
|
|
38
|
+
}
|
|
39
|
+
const up = path.dirname(dir)
|
|
40
|
+
if (up === dir) return null
|
|
41
|
+
dir = up
|
|
42
|
+
}
|
|
43
|
+
return null
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function branchOf(top, read = fs) {
|
|
47
|
+
const repo = repoOf(path.join(top, 'x'), read)
|
|
48
|
+
if (!repo) return null
|
|
49
|
+
try {
|
|
50
|
+
const head = read.readFileSync(path.join(repo.gitdir, 'HEAD'), 'utf8').trim()
|
|
51
|
+
const m = /^ref:\s*refs\/heads\/(.+)$/.exec(head)
|
|
52
|
+
return m ? m[1] : head.slice(0, 8)
|
|
53
|
+
} catch { return null }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function filePathOf(input) {
|
|
57
|
+
const t = (input && input.tool_input) || {}
|
|
58
|
+
const p = t.file_path || t.notebook_path
|
|
59
|
+
return typeof p === 'string' && p ? p : null
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Where the file lives: the state file for its repository, and its name inside that repository.
|
|
63
|
+
function locate(file, dir, platform = process.platform) {
|
|
64
|
+
const real = realPath(file)
|
|
65
|
+
const repo = repoOf(real)
|
|
66
|
+
if (!repo) return { real, key: pathKey(real, platform), top: null, stateFile: path.join(dir, 'edits', 'nogit.json') }
|
|
67
|
+
const top = realPath(repo.top)
|
|
68
|
+
const rel = pathKey(real, platform).slice(pathKey(top, platform).length + 1)
|
|
69
|
+
const id = crypto.createHash('sha1').update(pathKey(repo.common, platform)).digest('hex').slice(0, 16)
|
|
70
|
+
return { real, key: rel, top, stateFile: path.join(dir, 'edits', `${id}.json`) }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function readState(file) {
|
|
74
|
+
try {
|
|
75
|
+
const s = JSON.parse(fs.readFileSync(file, 'utf8'))
|
|
76
|
+
if (s && Array.isArray(s.entries)) {
|
|
77
|
+
return {
|
|
78
|
+
entries: s.entries.filter(e => e && typeof e.t === 'number' && e.s && e.k),
|
|
79
|
+
seen: s.seen && typeof s.seen === 'object' && !Array.isArray(s.seen) ? s.seen : {},
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
} catch {}
|
|
83
|
+
return { entries: [], seen: {} }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Temp file and rename, so a reader never sees half a file. Two sessions writing at once can
|
|
87
|
+
// lose one entry; this is advisory, so that is accepted.
|
|
88
|
+
function writeState(file, state) {
|
|
89
|
+
fs.mkdirSync(path.dirname(file), { recursive: true })
|
|
90
|
+
const tmp = `${file}.${process.pid}.tmp`
|
|
91
|
+
fs.writeFileSync(tmp, JSON.stringify(state))
|
|
92
|
+
fs.renameSync(tmp, file)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function prune(state, now, keepMs) {
|
|
96
|
+
const entries = state.entries.filter(e => now - e.t <= keepMs).slice(-MAX_ENTRIES)
|
|
97
|
+
const seen = Object.entries(state.seen).filter(([, t]) => typeof t === 'number' && now - t <= keepMs).slice(-MAX_ENTRIES)
|
|
98
|
+
return { entries, seen: Object.fromEntries(seen) }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// PostToolUse: remember that this session edited the file.
|
|
102
|
+
function record(input, { dir = stateDir(), now = Date.now(), platform = process.platform, keepMs = KEEP_MS } = {}) {
|
|
103
|
+
const file = filePathOf(input)
|
|
104
|
+
if (!file || !input.session_id) return false
|
|
105
|
+
const loc = locate(file, dir, platform)
|
|
106
|
+
const state = readState(loc.stateFile)
|
|
107
|
+
state.entries = state.entries.filter(e => !(e.s === input.session_id && e.k === loc.key))
|
|
108
|
+
state.entries.push({ s: input.session_id, k: loc.key, top: loc.top, t: now })
|
|
109
|
+
writeState(loc.stateFile, prune(state, now, keepMs))
|
|
110
|
+
return true
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const short = (p, home = os.homedir()) => (p && (p === home || p.startsWith(home + path.sep)) ? '~' + p.slice(home.length) : p)
|
|
114
|
+
|
|
115
|
+
function ago(ms) {
|
|
116
|
+
const min = Math.floor(ms / 60000)
|
|
117
|
+
return min < 1 ? 'less than a minute ago' : `${min} min ago`
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function alive(pid) {
|
|
121
|
+
try {
|
|
122
|
+
process.kill(pid, 0)
|
|
123
|
+
return true
|
|
124
|
+
} catch (e) {
|
|
125
|
+
return e.code === 'EPERM'
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// PreToolUse: { message, mode, rule } when another live session edited this file lately, else null.
|
|
130
|
+
// The session files are read only after a recent edit by someone else is found, so the usual
|
|
131
|
+
// case costs one failed file read.
|
|
132
|
+
function check(input, {
|
|
133
|
+
dir = stateDir(), now = Date.now(), platform = process.platform, windowMs = 30 * 60 * 1000, mode = 'warn',
|
|
134
|
+
sessions = () => readSessionFiles(), isAlive = alive,
|
|
135
|
+
} = {}) {
|
|
136
|
+
const file = filePathOf(input)
|
|
137
|
+
const me = input && input.session_id
|
|
138
|
+
if (!file || !me) return null
|
|
139
|
+
const loc = locate(file, dir, platform)
|
|
140
|
+
const state = readState(loc.stateFile)
|
|
141
|
+
const recent = state.entries
|
|
142
|
+
.filter(e => e.s !== me && e.k === loc.key && now - e.t <= windowMs && e.t <= now + 60000)
|
|
143
|
+
.sort((a, b) => b.t - a.t)
|
|
144
|
+
if (!recent.length) return null
|
|
145
|
+
const files = sessions()
|
|
146
|
+
for (const e of recent) {
|
|
147
|
+
const live = [...files].find(([pid, f]) => f.sessionId === e.s && isAlive(pid))
|
|
148
|
+
if (!live) continue
|
|
149
|
+
const seenKey = `${me}|${e.s}|${e.k}`
|
|
150
|
+
// told once per edit of theirs; block mode keeps stopping until they stop editing
|
|
151
|
+
if (mode !== 'block' && state.seen[seenKey] >= e.t) continue
|
|
152
|
+
const [pid, f] = live
|
|
153
|
+
const sameTree = loc.top && e.top && pathKey(e.top, platform) === pathKey(loc.top, platform)
|
|
154
|
+
const branch = e.top ? branchOf(e.top) : null
|
|
155
|
+
const where = [short(sameTree ? f.cwd || e.top : e.top || f.cwd), branch && `branch ${branch}`].filter(Boolean).join(', ')
|
|
156
|
+
const message = sameTree || !e.top
|
|
157
|
+
? `Note: session ${pid} (${where}) edited this file ${ago(now - e.t)}; re-read it before assuming its contents, and don't revert their change.`
|
|
158
|
+
: `Note: session ${pid} edited the same file in another worktree (${where}) ${ago(now - e.t)}; the two versions will conflict on merge, so look at its change before overwriting this one.`
|
|
159
|
+
if (mode !== 'block') {
|
|
160
|
+
try { writeState(loc.stateFile, prune({ entries: state.entries, seen: { ...state.seen, [seenKey]: e.t } }, now, KEEP_MS)) } catch {}
|
|
161
|
+
}
|
|
162
|
+
return { message, mode, rule: sameTree || !e.top ? 'same-tree' : 'other-worktree' }
|
|
163
|
+
}
|
|
164
|
+
return null
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// The JSON Claude Code reads. additionalContext alone informs the model without approving the
|
|
168
|
+
// call; "ask" puts the same text in front of the user.
|
|
169
|
+
function output(hit) {
|
|
170
|
+
const hookSpecificOutput = { hookEventName: 'PreToolUse', additionalContext: hit.message }
|
|
171
|
+
if (hit.mode === 'ask') {
|
|
172
|
+
hookSpecificOutput.permissionDecision = 'ask'
|
|
173
|
+
hookSpecificOutput.permissionDecisionReason = hit.message
|
|
174
|
+
}
|
|
175
|
+
return { hookSpecificOutput }
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (require.main === module) {
|
|
179
|
+
runHook('edit-guard', input => {
|
|
180
|
+
if (input.hook_event_name === 'PostToolUse') {
|
|
181
|
+
record(input)
|
|
182
|
+
return
|
|
183
|
+
}
|
|
184
|
+
const configured = setting('editGuard.mode', 'warn')
|
|
185
|
+
const mode = MODES.includes(configured) ? configured : 'warn'
|
|
186
|
+
const minutes = Number(setting('editGuard.windowMinutes', 30))
|
|
187
|
+
const hit = check(input, { mode, windowMs: (Number.isFinite(minutes) ? minutes : 30) * 60000 })
|
|
188
|
+
if (!hit) return
|
|
189
|
+
if (hit.mode === 'block') {
|
|
190
|
+
return blocked('edit-guard', hit.rule, input, `[bulkhead] edit-guard: ${hit.message}\n${ASK_USER}`, filePathOf(input))
|
|
191
|
+
}
|
|
192
|
+
return { output: output(hit) }
|
|
193
|
+
})
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
module.exports = { check, record, output, repoOf, branchOf, locate }
|
package/src/events.js
CHANGED
|
@@ -22,6 +22,34 @@ function truncate(s, max = MAX_COMMAND) {
|
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
// event: { guard, rule, session, project, command, ...extra }. Returns true when written.
|
|
25
|
+
// Moves file to file.1 once it is over `limit` bytes. Two hooks may try at once: a lock folder
|
|
26
|
+
// lets one rotate, and it checks the size again under the lock, so a hook that saw the old big
|
|
27
|
+
// file never moves the fresh small one over the history in file.1. A lock left by a crashed hook
|
|
28
|
+
// is cleared after 10 seconds. Never throws.
|
|
29
|
+
function rotate(file, limit, now = Date.now()) {
|
|
30
|
+
const lock = file + '.lock'
|
|
31
|
+
try {
|
|
32
|
+
try {
|
|
33
|
+
fs.mkdirSync(lock)
|
|
34
|
+
} catch {
|
|
35
|
+
if (now - fs.statSync(lock).mtimeMs < 10000) return false
|
|
36
|
+
fs.rmdirSync(lock)
|
|
37
|
+
fs.mkdirSync(lock)
|
|
38
|
+
}
|
|
39
|
+
} catch {
|
|
40
|
+
return false
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
if (fs.statSync(file).size <= limit) return false
|
|
44
|
+
fs.renameSync(file, file + '.1')
|
|
45
|
+
return true
|
|
46
|
+
} catch {
|
|
47
|
+
return false
|
|
48
|
+
} finally {
|
|
49
|
+
try { fs.rmdirSync(lock) } catch {}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
25
53
|
function record(dir, event, { env = process.env, now = Date.now(), maxBytes = MAX_BYTES } = {}) {
|
|
26
54
|
if (loggingOff(env)) return false
|
|
27
55
|
try {
|
|
@@ -39,10 +67,7 @@ function record(dir, event, { env = process.env, now = Date.now(), maxBytes = MA
|
|
|
39
67
|
let size = -1
|
|
40
68
|
try { size = fs.statSync(file).size } catch {}
|
|
41
69
|
if (size < 0) fs.mkdirSync(dir, { recursive: true })
|
|
42
|
-
else if (size + line.length > maxBytes)
|
|
43
|
-
// two hooks may rotate at once; the loser's rename fails and it appends to the new file
|
|
44
|
-
try { fs.renameSync(file, file + '.1') } catch {}
|
|
45
|
-
}
|
|
70
|
+
else if (size + line.length > maxBytes) rotate(file, maxBytes - line.length)
|
|
46
71
|
fs.appendFileSync(file, line)
|
|
47
72
|
return true
|
|
48
73
|
} catch {
|
|
@@ -105,4 +130,4 @@ function logSize(dir) {
|
|
|
105
130
|
return { bytes, files }
|
|
106
131
|
}
|
|
107
132
|
|
|
108
|
-
module.exports = { record, readEvents, tailEvents, logSize, eventsFile, loggingOff, truncate, MAX_BYTES }
|
|
133
|
+
module.exports = { rotate, record, readEvents, tailEvents, logSize, eventsFile, loggingOff, truncate, MAX_BYTES }
|