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.
Files changed (66) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/README.es.md +142 -50
  3. package/README.ja.md +142 -50
  4. package/README.ko.md +143 -51
  5. package/README.md +142 -50
  6. package/README.pt-BR.md +142 -50
  7. package/README.zh-CN.md +154 -62
  8. package/hooks/hooks.json +68 -8
  9. package/package.json +5 -2
  10. package/src/agent-hook.js +162 -0
  11. package/src/agent-setup.js +256 -0
  12. package/src/agents.js +89 -0
  13. package/src/allow-cli.js +65 -0
  14. package/src/allow-hint.js +62 -0
  15. package/src/allow-once.js +152 -0
  16. package/src/auto-queue.js +93 -0
  17. package/src/bash-guards.js +36 -0
  18. package/src/basics.js +46 -27
  19. package/src/checkout-guard.js +342 -74
  20. package/src/cli-util.js +13 -1
  21. package/src/cli.js +40 -4
  22. package/src/config-check.js +21 -4
  23. package/src/config-cli.js +75 -10
  24. package/src/config.js +106 -9
  25. package/src/dev-servers.js +361 -61
  26. package/src/disk-guard.js +8 -8
  27. package/src/doctor-enforcement.js +273 -0
  28. package/src/doctor.js +19 -9
  29. package/src/edit-guard.js +196 -0
  30. package/src/events.js +30 -5
  31. package/src/explain.js +251 -0
  32. package/src/git-info.js +55 -0
  33. package/src/gitargs.js +82 -0
  34. package/src/hook.js +38 -6
  35. package/src/kill-extras.js +219 -0
  36. package/src/kill-guard.js +228 -77
  37. package/src/kill-owners.js +116 -0
  38. package/src/log-cli.js +15 -10
  39. package/src/mcp.js +131 -0
  40. package/src/memory-guard.js +126 -24
  41. package/src/memory.js +21 -2
  42. package/src/notices.js +95 -0
  43. package/src/orphan-reaper.js +58 -11
  44. package/src/port-cli.js +78 -3
  45. package/src/port-guard.js +80 -68
  46. package/src/port-kills.js +198 -0
  47. package/src/pressure.js +75 -0
  48. package/src/processes.js +44 -12
  49. package/src/scan-report.js +166 -0
  50. package/src/scan.js +87 -18
  51. package/src/secrets-guard.js +301 -0
  52. package/src/self-allow.js +73 -0
  53. package/src/session-info.js +121 -0
  54. package/src/session-state.js +176 -0
  55. package/src/sessions.js +109 -20
  56. package/src/shell.js +279 -19
  57. package/src/slot-cli.js +92 -21
  58. package/src/slots.js +61 -13
  59. package/src/stable-port.js +61 -0
  60. package/src/status.js +55 -17
  61. package/src/statusline.js +45 -16
  62. package/src/subagent-guard.js +24 -0
  63. package/src/top.js +36 -11
  64. package/src/wait-guard.js +96 -32
  65. package/src/worktree-cli.js +454 -0
  66. 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('plugin', `${shortPath(file)} cannot be read: ${e.message}`)
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('plugin', `hooks.json names scripts that are missing: ${missing.join(', ')}`)
52
- return ok('plugin', `${shortPath(root)}, hooks: ${counts.join(', ')}`)
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 sessions = findSessions(snap, files())
129
- const text = `${snap.procs.length} processes, ${snap.ports.length} listening ports, ${sessions.length} Claude session${sessions.length === 1 ? '' : 's'} in ${(ms / 1000).toFixed(1)}s`
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 = checks.map(c => `${paint(color, style[c.status], c.status.padEnd(4))} ${c.id.padEnd(width)} ${c.summary}`)
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 }