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
package/src/kill-guard.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
'use strict'
|
|
2
|
-
const { runHook, blocked, setting, ASK_USER } = require('./hook')
|
|
2
|
+
const { runHook, blocked, setting, enabled, ASK_USER } = require('./hook')
|
|
3
3
|
const shell = require('./shell')
|
|
4
|
+
const owners = require('./kill-owners')
|
|
5
|
+
const extras = require('./kill-extras')
|
|
4
6
|
|
|
5
7
|
// Programs that other sessions, editors and the user are likely running too.
|
|
6
8
|
const SHARED = [
|
|
@@ -13,6 +15,13 @@ const SHARED = [
|
|
|
13
15
|
// apps and servers the user or another session has open
|
|
14
16
|
'electron', 'simulator', 'xcode', 'postgres',
|
|
15
17
|
'docker desktop', 'code helper', 'cursor', 'cursor helper',
|
|
18
|
+
// other coding agents, whose sessions are as shared as Claude's
|
|
19
|
+
'codex', 'gemini', 'copilot', 'opencode', 'cline', 'droid', 'cursor-agent',
|
|
20
|
+
// dev servers and their children, by the names they show in ps and Activity Monitor
|
|
21
|
+
'next-server', 'next-router-worker', 'next-render-worker', 'uvicorn', 'gunicorn', 'rails', 'puma', 'flask', 'expo', 'metro',
|
|
22
|
+
'storybook', 'turbo', 'tsc', 'nodemon', 'qemu', 'emulator', 'playwright', 'puppeteer', 'ms-playwright', 'php',
|
|
23
|
+
'headless_shell', 'headless-shell', 'chrome-headless', 'google chrome', 'google chrome helper', 'chrome helper', 'chromedriver',
|
|
24
|
+
'http.server',
|
|
16
25
|
]
|
|
17
26
|
const bare = n => String(n).trim().replace(/^["']+|["']+$/g, '').toLowerCase().replace(/\.exe$/, '')
|
|
18
27
|
|
|
@@ -43,24 +52,61 @@ function scopedPattern(pattern) {
|
|
|
43
52
|
return /[\\/]|\.(?:py|js|mjs|cjs|ts|mts|rb|php|sh|jar|ps1)\b/i.test(first) || /[\\/]|\.(?:py|js|mjs|cjs|ts|rb|php|sh|jar|ps1)\b/i.test(pattern)
|
|
44
53
|
}
|
|
45
54
|
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
55
|
+
// pkill -f "ssh -N -L 9333": a program name followed by arguments that name one job (a flag, a
|
|
56
|
+
// number, a path, a host). "next dev" and "node server" have none, so they still match every one.
|
|
57
|
+
function specificPattern(pattern) {
|
|
58
|
+
return pattern.trim().split(/\s+/).slice(1).some(t => /^-|[\d:=/\\@~]|\.\w/.test(t))
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const REGEX_CHARS = /[.*+?^${}()|[\]\\]/
|
|
62
|
+
// pkill -f dev, -f server: words that stand for "whatever dev server is running"
|
|
63
|
+
const DEV_WORDS = new Set(['dev', 'server', 'serve', 'start', 'runserver', 'http.server'])
|
|
64
|
+
const DEV_TAIL = 'dev next server serve start run'
|
|
65
|
+
|
|
66
|
+
// node22, nodejs and python3.11 are the shared name with a version
|
|
67
|
+
function sharedWord(lw, names) {
|
|
68
|
+
if (names.includes(lw)) return true
|
|
69
|
+
const base = lw.replace(/^nodejs$/, 'node').replace(/^(node|python|ruby|java)[\d.]+$/, '$1')
|
|
70
|
+
return base !== lw && names.includes(base)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// One word of a pkill, killall or grep pattern against the shared names. Without -x the pattern
|
|
74
|
+
// is a regular expression searched in the name, so a part of a name ("ode") reaches it too.
|
|
75
|
+
function wordHits(w, names, { full, exact }) {
|
|
76
|
+
if (!w) return false
|
|
77
|
+
if (sharedWord(bare(w), names)) return true
|
|
78
|
+
if (!REGEX_CHARS.test(w.replace(/\.exe$/i, ''))) {
|
|
79
|
+
const lw = bare(w)
|
|
80
|
+
if (sharedWord(lw, names) || (full && DEV_WORDS.has(lw))) return true
|
|
81
|
+
// a name the user took off the list (allowNames) is not searched inside other names either
|
|
82
|
+
return !exact && lw.length >= 3 && !SHARED.includes(lw) && names.some(n => n.includes(lw))
|
|
83
|
+
}
|
|
57
84
|
let re
|
|
58
85
|
try {
|
|
59
|
-
re = new RegExp(exact ? `^(?:${
|
|
86
|
+
re = new RegExp(exact ? `^(?:${w})$` : w, 'i')
|
|
60
87
|
} catch {
|
|
61
|
-
return names.includes(bare(
|
|
88
|
+
return names.includes(bare(w))
|
|
62
89
|
}
|
|
63
|
-
|
|
90
|
+
if (names.some(n => re.test(n) || (!exact && re.test(n + '.exe')))) return true
|
|
91
|
+
// pkill -f "node.*next": a name, then whatever a dev server's command line says
|
|
92
|
+
return full && !exact && names.some(n => re.test(`${n} ${DEV_TAIL}`))
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// A pkill, pgrep or grep pattern. Without -f it is a regular expression matched against the
|
|
96
|
+
// process name; with -f (and for grep over ps output) against the whole command line, where the
|
|
97
|
+
// first word of each alternative decides which program it hits. [n]ode is node.
|
|
98
|
+
function patternHits(pattern, names, { full = false, exact = false } = {}) {
|
|
99
|
+
const p = String(pattern || '').trim()
|
|
100
|
+
if (!p || p.length > 500) return false
|
|
101
|
+
if (!full) return !scopedPattern(p) && wordHits(p, names, { full, exact })
|
|
102
|
+
return p.split('|', 20).some(alt => {
|
|
103
|
+
alt = alt.trim()
|
|
104
|
+
if (!alt || scopedPattern(alt)) return false
|
|
105
|
+
// "Google Chrome Helper": a name of several words
|
|
106
|
+
if (/\s/.test(alt) && names.includes(bare(alt))) return true
|
|
107
|
+
if (specificPattern(alt)) return false
|
|
108
|
+
return wordHits(alt.split(/\s+/)[0], names, { full, exact }) || wordHits(alt, names, { full, exact })
|
|
109
|
+
})
|
|
64
110
|
}
|
|
65
111
|
|
|
66
112
|
const PKILL_VALUE = new Set(['-g', '-G', '-P', '-s', '-t', '-u', '-U', '-F', '--signal', '--pgroup', '--group', '--parent', '--session', '--terminal', '--euid', '--uid', '--pidfile', '--ns', '--nslist', '--env'])
|
|
@@ -101,26 +147,58 @@ function parseKillByName(argv, valued) {
|
|
|
101
147
|
|
|
102
148
|
const zeroSignal = s => s != null && /^(?:0|SIG0)$/i.test(String(s))
|
|
103
149
|
|
|
104
|
-
|
|
150
|
+
const NAME_WHY = 'pkill/killall matches every process with that name.'
|
|
151
|
+
const escapeRe = w => String(w).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
152
|
+
|
|
153
|
+
// A name that is no shared program but belongs to an app or a service the other sessions may be
|
|
154
|
+
// using: it counts only while another Claude session is alive.
|
|
155
|
+
function softName(pattern, full) {
|
|
156
|
+
const words = full ? String(pattern).split('|', 20).map(a => a.trim()) : [String(pattern)]
|
|
157
|
+
for (const w of words) {
|
|
158
|
+
for (const cand of full ? [w, w.split(/\s+/)[0]] : [w]) {
|
|
159
|
+
if (extras.isApp(cand)) return { rule: 'app-quit', why: `Quitting ${cand} closes it for everyone.` }
|
|
160
|
+
if (extras.isService(cand)) return { rule: 'service-stop', why: `Stopping ${cand} takes it away from every session.` }
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return null
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// pkill, killall: [rule, why] to block now, [rule, why, check] to block when check(table) says so.
|
|
167
|
+
function checkPkill(argv, names, { stdin = [], off = new Set() } = {}) {
|
|
105
168
|
const b = shell.bin(argv[0])
|
|
106
169
|
if (b === 'pkill') {
|
|
107
170
|
const p = parseKillByName(argv, PKILL_VALUE)
|
|
108
171
|
if (zeroSignal(p.signal) || p.opts.some(ov => PKILL_SCOPE.test(ov))) return null
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
172
|
+
const operands = p.operands.length ? p.operands : stdin.slice(0, 1)
|
|
173
|
+
if (!operands.length) return null
|
|
174
|
+
const full = /f/.test(p.flags) || p.opts.some(([o]) => o === '--full')
|
|
175
|
+
const exact = /x/.test(p.flags)
|
|
176
|
+
return byName(operands[0], names, { full, exact }, off)
|
|
112
177
|
}
|
|
113
178
|
if (b === 'killall') {
|
|
114
179
|
const p = parseKillByName(argv, KILLALL_VALUE)
|
|
115
180
|
if (zeroSignal(p.signal)) return null
|
|
116
181
|
const regex = /r/.test(p.flags) || p.opts.some(([o]) => o === '--regexp')
|
|
117
|
-
|
|
118
|
-
|
|
182
|
+
const operands = (p.operands.length ? p.operands : stdin).slice(0, 100)
|
|
183
|
+
let later = null
|
|
184
|
+
for (const o of operands) {
|
|
185
|
+
const hit = regex ? patternHits(o, names, { exact: true }) : sharedWord(bare(o), names)
|
|
186
|
+
if (hit) return ['pkill', NAME_WHY]
|
|
187
|
+
later = later || byName(regex ? o : escapeRe(o), names, { full: false, exact: true }, off, !regex && o)
|
|
119
188
|
}
|
|
189
|
+
return later
|
|
120
190
|
}
|
|
121
191
|
return null
|
|
122
192
|
}
|
|
123
193
|
|
|
194
|
+
function byName(pattern, names, { full, exact }, off, plain) {
|
|
195
|
+
if (patternHits(pattern, names, { full, exact })) return ['pkill', NAME_WHY]
|
|
196
|
+
const soft = softName(plain || pattern, full)
|
|
197
|
+
if (soft && !off.has(soft.rule)) return [soft.rule, soft.why, t => owners.othersCheck(t)]
|
|
198
|
+
if (off.has('owner-check')) return null
|
|
199
|
+
return ['other-session-match', '', t => owners.patternCheck(t, pattern, { full, exact })]
|
|
200
|
+
}
|
|
201
|
+
|
|
124
202
|
// A comma list after a flag: -Name node,python or -Name node, python. Real lists are short; the
|
|
125
203
|
// first 256 names are enough, and a longer list is not copied name by name.
|
|
126
204
|
const MAX_LIST = 256
|
|
@@ -149,12 +227,12 @@ function checkTaskkill(argv, names) {
|
|
|
149
227
|
else if (o === 'pid') { byPid = true; k++ } else if (o === 'fi') filters.push(argv[++k] || '')
|
|
150
228
|
}
|
|
151
229
|
const hit = images.find(i => nameHits(i, names))
|
|
152
|
-
if (hit) return ['taskkill-im', `taskkill /IM kills every process with that name: ${argv.join(' ')}
|
|
230
|
+
if (hit) return ['taskkill-im', `taskkill /IM kills every process with that name: ${argv.join(' ')}.`]
|
|
153
231
|
for (const f of filters) {
|
|
154
232
|
if (/\bpid\s+(?:eq|ne|gt|lt|ge|le)\b/i.test(f) || byPid) continue
|
|
155
233
|
const img = /\bimagename\s+eq\s+(\S+)/i.exec(f)
|
|
156
234
|
if (img && !nameHits(img[1], names)) continue
|
|
157
|
-
return ['taskkill-filter', `taskkill /FI picks processes by filter, not by PID: ${argv.join(' ')}
|
|
235
|
+
return ['taskkill-filter', `taskkill /FI picks processes by filter, not by PID: ${argv.join(' ')}.`]
|
|
158
236
|
}
|
|
159
237
|
return null
|
|
160
238
|
}
|
|
@@ -169,7 +247,7 @@ function checkStopProcess(argv, names) {
|
|
|
169
247
|
const m = NAME_FLAG.exec(argv[k])
|
|
170
248
|
if (!m) continue
|
|
171
249
|
const list = m[1] ? m[1].split(',') : listAfter(argv, k + 1)
|
|
172
|
-
if (list.some(n => nameHits(n, names))) return ['stop-process-name', 'Stop-Process -Name kills every process with that name']
|
|
250
|
+
if (list.some(n => nameHits(n, names))) return ['stop-process-name', 'Stop-Process -Name kills every process with that name.']
|
|
173
251
|
}
|
|
174
252
|
return null
|
|
175
253
|
}
|
|
@@ -180,7 +258,7 @@ function checkWmic(argv, names) {
|
|
|
180
258
|
const text = argv.slice(1).join(' ')
|
|
181
259
|
if (!/\bprocess\b/i.test(text) || !/\b(?:delete|terminate)\b/i.test(text)) return null
|
|
182
260
|
if (/\b(?:processid|commandline|executablepath|parentprocessid)\b/i.test(text)) return null
|
|
183
|
-
if (wmiNameHits(text, names)) return ['wmic-name', 'wmic process where name=... deletes every process with that name']
|
|
261
|
+
if (wmiNameHits(text, names)) return ['wmic-name', 'wmic process where name=... deletes every process with that name.']
|
|
184
262
|
return null
|
|
185
263
|
}
|
|
186
264
|
|
|
@@ -197,14 +275,14 @@ function wmiNameHits(text, names) {
|
|
|
197
275
|
// narrows the selection to known processes (Id, CommandLine, Path).
|
|
198
276
|
function whereFilter(text, names) {
|
|
199
277
|
const scoped = /\b(?:Id|ProcessId|CommandLine|Path|ExecutablePath|ParentProcessId)\b/i.test(text)
|
|
200
|
-
let
|
|
278
|
+
let named = false
|
|
201
279
|
for (const m of text.matchAll(/\b(?:Name|ProcessName|ImageName)\b['"]?\s*-[ic]?(eq|like|match|in|contains)\s+([^|}]{1,200})/gi)) {
|
|
202
280
|
const op = m[1].toLowerCase()
|
|
203
281
|
const values = m[2].split(/[\s,]+/).map(s => s.replace(/^[('"]+|[)'"]+$/g, '')).filter(Boolean)
|
|
204
|
-
if (op === 'match')
|
|
205
|
-
else
|
|
282
|
+
if (op === 'match') named = named || values.slice(0, 1).some(v => patternHits(v, names))
|
|
283
|
+
else named = named || values.some(v => nameHits(v, names))
|
|
206
284
|
}
|
|
207
|
-
return { scoped, byName }
|
|
285
|
+
return { scoped, byName: named }
|
|
208
286
|
}
|
|
209
287
|
|
|
210
288
|
// Commands that only print or search text: their words are never a kill or a selection.
|
|
@@ -215,21 +293,31 @@ const PS_SINKS = /^(?:stop-process|spps|invoke-cimmethod|remove-ciminstance|remo
|
|
|
215
293
|
// Does any of these commands select processes by a shared name, and does another one kill what
|
|
216
294
|
// they select? Commands of one statement are judged together: a pipe, $( ) and a loop body all
|
|
217
295
|
// carry the PIDs from one to the other.
|
|
218
|
-
function judgeStatement(cmds, names, mode, vars) {
|
|
296
|
+
function judgeStatement(cmds, names, mode, vars, cfg, deferred) {
|
|
219
297
|
let selector = null
|
|
220
298
|
let scoped = false
|
|
221
299
|
let killer = false
|
|
222
300
|
let usesVar = false
|
|
223
301
|
let whereDepth = 0
|
|
224
302
|
const listsProcesses = cmds.some(x => x.argv.length && /^(?:ps|tasklist|wmic|get-process|gps)$/i.test(shell.bin(x.argv[0])))
|
|
303
|
+
// echo node | xargs pkill, pkill <<< node: what the pipe or here-string supplies
|
|
304
|
+
const stdin = []
|
|
305
|
+
for (const c of cmds) {
|
|
306
|
+
const at = c.argv.indexOf('<<<')
|
|
307
|
+
if (at > 0) { stdin.push(...c.argv.slice(at + 1)); c.argv = c.argv.slice(0, at) }
|
|
308
|
+
else if (c.inert) stdin.push(...c.argv.slice(1).filter(a => !/^-[neE]+$/.test(a)))
|
|
309
|
+
}
|
|
225
310
|
for (const c of cmds) {
|
|
226
311
|
const argv = c.argv
|
|
227
|
-
if (!argv.length) continue
|
|
312
|
+
if (!argv.length || c.inert) continue
|
|
228
313
|
const b = shell.bin(argv[0])
|
|
229
314
|
// direct kills by name
|
|
230
|
-
const direct = checkPkill(argv, names) || checkTaskkill(argv, names) || checkStopProcess(argv, names) || checkWmic(argv, names)
|
|
231
|
-
if (direct) return direct
|
|
315
|
+
const direct = checkPkill(argv, names, { stdin, off: cfg.off }) || checkTaskkill(argv, names) || checkStopProcess(argv, names) || checkWmic(argv, names)
|
|
316
|
+
if (direct && !direct[2]) return direct
|
|
317
|
+
if (direct) deferred.push(direct)
|
|
232
318
|
if (c.remote) continue
|
|
319
|
+
const more = extras.detect(argv, { mode, off: cfg.off, stmt: cmds, env: cfg.env || new Map() })
|
|
320
|
+
if (more && !direct) deferred.push(more)
|
|
233
321
|
// killers
|
|
234
322
|
if (isKiller(argv, mode)) killer = true
|
|
235
323
|
for (const w of argv) {
|
|
@@ -286,10 +374,10 @@ function judgeStatement(cmds, names, mode, vars) {
|
|
|
286
374
|
}
|
|
287
375
|
|
|
288
376
|
const SELECTOR_WHY = {
|
|
289
|
-
'pgrep-kill': 'pgrep <name> feeding kill matches every process with that name',
|
|
290
|
-
'ps-grep-kill': 'ps | grep <name> | kill matches every process with that name',
|
|
291
|
-
'get-process-pipe': 'Get-Process <name> | Stop-Process kills every process with that name',
|
|
292
|
-
'win32-process-name': 'Win32_Process selected by Name alone is terminated',
|
|
377
|
+
'pgrep-kill': 'pgrep <name> feeding kill matches every process with that name.',
|
|
378
|
+
'ps-grep-kill': 'ps | grep <name> | kill matches every process with that name.',
|
|
379
|
+
'get-process-pipe': 'Get-Process <name> | Stop-Process kills every process with that name.',
|
|
380
|
+
'win32-process-name': 'Win32_Process selected by Name alone is terminated.',
|
|
293
381
|
}
|
|
294
382
|
|
|
295
383
|
function isKiller(argv, mode) {
|
|
@@ -338,35 +426,88 @@ function assignedVar(cmds) {
|
|
|
338
426
|
return null
|
|
339
427
|
}
|
|
340
428
|
|
|
341
|
-
//
|
|
342
|
-
|
|
429
|
+
// Interpreter code that starts shell commands from string literals: python3 -c "...os.system('...')",
|
|
430
|
+
// a python or node heredoc, perl -e. The strings are read as shell commands.
|
|
431
|
+
const INTERPRETER_WORD = /\b(?:python[\d.]*|py|pythonw|node|nodejs|deno|bun|ruby|perl|php)\b/
|
|
432
|
+
const SHELLING_OUT = /system|exec|spawn|popen|subprocess|passthru|`|%x/
|
|
433
|
+
const CODE_FLAGS = /^(?:-c|-e|-E|-r|-p|--eval|--print)$/
|
|
434
|
+
|
|
435
|
+
function embedded(cmd, mode) {
|
|
436
|
+
if (!INTERPRETER_WORD.test(cmd) || !SHELLING_OUT.test(cmd)) return []
|
|
437
|
+
const codes = []
|
|
438
|
+
for (const c of shell.commands(shell.prepare(cmd, mode, ['shell', 'interp']), mode)) {
|
|
439
|
+
if (!/^(?:python[\d.]*|py|pythonw|node|nodejs|deno|bun|ruby|perl|php)$/.test(shell.bin(c.argv[0]))) continue
|
|
440
|
+
const i = c.argv.findIndex((a, k) => k > 0 && CODE_FLAGS.test(a))
|
|
441
|
+
if (i > 0 && c.argv[i + 1]) codes.push(c.argv[i + 1])
|
|
442
|
+
}
|
|
443
|
+
if (cmd.includes('<<')) for (const d of shell.heredocs(cmd, (p, a) => shell.consumerOf(p, a, mode).kind === 'interp')) codes.push(d.body)
|
|
444
|
+
return codes.flatMap(shell.embeddedShell)
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// Rules that stop only while another Claude session is alive, and the table checks, are settled
|
|
448
|
+
// here: ctx.table() reads the process table once, and only now.
|
|
449
|
+
function resolve(hit, ctx) {
|
|
450
|
+
if (!hit || !hit.deferred) return hit
|
|
451
|
+
if (!ctx) return null
|
|
452
|
+
const t = ctx.table()
|
|
453
|
+
if (!t) return null
|
|
454
|
+
for (const d of hit.deferred) {
|
|
455
|
+
const detail = d.check(t, ctx)
|
|
456
|
+
if (detail) return { rule: d.rule, why: [d.why, detail].filter(Boolean).join(' ') }
|
|
457
|
+
}
|
|
458
|
+
return null
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// { rule, why } for the first kill, or null. ctx (see kill-owners makeContext) lets the checks
|
|
462
|
+
// that need the process table run; without it only what the text alone shows is judged.
|
|
463
|
+
function classify(cmd, names = {}, mode = 'sh', ctx = null) {
|
|
343
464
|
const list = nameList(names)
|
|
465
|
+
const cfg = { off: ctx ? ctx.off : new Set() }
|
|
344
466
|
const text = shell.unwrap(shell.prepare(String(cmd || ''), mode), mode)
|
|
345
|
-
|
|
467
|
+
let hit = judge(text, list, mode, cfg)
|
|
468
|
+
if (!hit || hit.deferred) {
|
|
469
|
+
for (const inner of embedded(String(cmd || ''), mode)) {
|
|
470
|
+
const more = judge(shell.unwrap(shell.prepare(inner, mode), mode), list, mode, cfg)
|
|
471
|
+
if (more && !more.deferred) { hit = more; break }
|
|
472
|
+
if (more) hit = { deferred: [...(hit ? hit.deferred : []), ...more.deferred] }
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
return resolve(hit, ctx)
|
|
346
476
|
}
|
|
347
477
|
|
|
348
478
|
// Every kill this guard knows names one of these words, so text without them needs no parsing.
|
|
349
|
-
const KILL_WORD = /kill|stop-process|spps|terminate|remove-ciminstance|rcim|remove-wmiobject|rwmi|wmic/i
|
|
479
|
+
const KILL_WORD = /kill|stop-process|spps|terminate|remove-ciminstance|rcim|remove-wmiobject|rwmi|wmic|docker|podman|nerdctl|pm2|simctl|osascript|brew|launchctl|systemctl|service|adb\b/i
|
|
350
480
|
|
|
351
|
-
function judge(text, names, mode) {
|
|
481
|
+
function judge(text, names, mode, cfg) {
|
|
352
482
|
if (!KILL_WORD.test(text)) return null
|
|
353
483
|
const { cmds, unterminated } = shell.lex(text, mode)
|
|
354
|
-
const hit = judgeLexed(text, cmds, names, mode)
|
|
484
|
+
const hit = judgeLexed(text, cmds, names, mode, cfg)
|
|
355
485
|
if (hit || !unterminated) return hit
|
|
356
486
|
// a quote that never closes: read the text again without quotes, as the shell never would
|
|
357
|
-
return judgeLexed(text, shell.lex(text.replace(/["'`]/g, ' '), mode).cmds, names, mode)
|
|
487
|
+
return judgeLexed(text, shell.lex(text.replace(/["'`]/g, ' '), mode).cmds, names, mode, cfg)
|
|
358
488
|
}
|
|
359
489
|
|
|
360
|
-
function judgeLexed(text, lexed, names, mode) {
|
|
490
|
+
function judgeLexed(text, lexed, names, mode, cfg) {
|
|
361
491
|
const bySeg = new Map()
|
|
362
492
|
const add = (seg, x) => {
|
|
363
493
|
if (!bySeg.has(seg)) bySeg.set(seg, [])
|
|
364
494
|
bySeg.get(seg).push(x)
|
|
365
495
|
}
|
|
366
496
|
let forF = false
|
|
497
|
+
const assigned = new Map()
|
|
498
|
+
cfg.env = assigned
|
|
367
499
|
for (const c of lexed) {
|
|
368
500
|
const s = shell.strip(text, c.words, mode)
|
|
501
|
+
for (const w of c.words) {
|
|
502
|
+
if (/^(?:export|declare|typeset|local)$/.test(w.v)) continue
|
|
503
|
+
const m = /^([A-Za-z_]\w*)=([^$`]*)$/.exec(w.v)
|
|
504
|
+
if (m) assigned.set(m[1], m[2])
|
|
505
|
+
else break
|
|
506
|
+
}
|
|
369
507
|
const argv = s.argv.map(w => w.v)
|
|
508
|
+
// cmd=pkill; $cmd node
|
|
509
|
+
const ref = argv.length && /^\$\{?(\w+)\}?$/.exec(argv[0])
|
|
510
|
+
if (ref && assigned.has(ref[1]) && assigned.get(ref[1])) argv.splice(0, 1, ...assigned.get(ref[1]).split(/\s+/).filter(Boolean))
|
|
370
511
|
// cmd's for /f ... in ('tasklist ^| findstr node') do ...: the quoted command runs too
|
|
371
512
|
if (forF && argv.length) {
|
|
372
513
|
forF = false
|
|
@@ -380,19 +521,24 @@ function judgeLexed(text, lexed, names, mode) {
|
|
|
380
521
|
}
|
|
381
522
|
}
|
|
382
523
|
if (/^for$/i.test(argv[0] || '') && /^\/f$/i.test(argv[1] || '') && /^in$/i.test(argv[argv.length - 1])) forF = true
|
|
383
|
-
if (argv.length && INERT.test(shell.bin(argv[0])))
|
|
524
|
+
if (argv.length && INERT.test(shell.bin(argv[0]))) {
|
|
525
|
+
// echo node | xargs pkill: what an echo prints is what the next command reads
|
|
526
|
+
if (/^(?:echo|printf)$/.test(shell.bin(argv[0]))) add(c.seg, { argv, words: c.words, remote: false, sub: c.sub, inert: true })
|
|
527
|
+
continue
|
|
528
|
+
}
|
|
384
529
|
add(c.seg, { argv, words: c.words, remote: s.remote, sub: c.sub })
|
|
385
530
|
}
|
|
386
531
|
const vars = new Set()
|
|
532
|
+
const deferred = []
|
|
387
533
|
for (const cmds of bySeg.values()) {
|
|
388
|
-
const r = judgeStatement(cmds, names, mode, vars)
|
|
534
|
+
const r = judgeStatement(cmds, names, mode, vars, cfg, deferred)
|
|
389
535
|
if (Array.isArray(r)) return { rule: r[0], why: r[1] }
|
|
390
536
|
if (r && r.assign) {
|
|
391
537
|
const v = assignedVar(cmds)
|
|
392
538
|
if (v) vars.add(v)
|
|
393
539
|
}
|
|
394
540
|
}
|
|
395
|
-
return null
|
|
541
|
+
return deferred.length ? { deferred: deferred.map(([rule, why, fn]) => ({ rule, why, check: fn })) } : null
|
|
396
542
|
}
|
|
397
543
|
|
|
398
544
|
function check(cmd, names, mode) {
|
|
@@ -400,36 +546,41 @@ function check(cmd, names, mode) {
|
|
|
400
546
|
return hit ? hit.why : null
|
|
401
547
|
}
|
|
402
548
|
|
|
403
|
-
|
|
549
|
+
// What to do instead, by what was blocked.
|
|
550
|
+
const FIX = {
|
|
551
|
+
'docker-mass': 'Stop only your own containers, by name: docker stop <your container> (docker ps --filter name=<your project>).',
|
|
552
|
+
'device-kill': 'Target your own device: adb -s <your device> emu kill, xcrun simctl shutdown <your device UDID>.',
|
|
553
|
+
'app-quit': 'Close only what you opened: kill <your own pid>.',
|
|
554
|
+
'service-stop': 'Stop only a service you started yourself, by its PID: kill <your own pid>.',
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function howToFix(platform, rule) {
|
|
558
|
+
if (FIX[rule]) return FIX[rule]
|
|
404
559
|
if (platform === 'win32') {
|
|
405
|
-
return
|
|
406
|
-
|
|
407
|
-
"By project: Get-CimInstance Win32_Process | ? { $_.CommandLine -like '*<your project dir>*' } | select ProcessId, CommandLine",
|
|
408
|
-
'Then: taskkill /PID <pid> /T /F',
|
|
409
|
-
].join('\n')
|
|
560
|
+
return 'Stop only what you started: taskkill /PID <your own pid> /T /F (netstat -ano | findstr :<port> finds a port owner; ' +
|
|
561
|
+
"Get-CimInstance Win32_Process | ? { $_.CommandLine -like '*<your project dir>*' } finds your project's processes)."
|
|
410
562
|
}
|
|
411
|
-
return
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
]
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
function message(why, platform = process.platform) {
|
|
419
|
-
return `[bulkhead] kill-guard: ${why}\n` +
|
|
420
|
-
'Other Claude sessions, subagents and the user run these programs on this machine too. ' +
|
|
421
|
-
'Killing by name takes down their dev servers, test runs and builds, and can end your own session.\n' +
|
|
422
|
-
'Stop only the processes you started, by PID:\n' + howToFindPid(platform)
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
if (require.main === module) {
|
|
426
|
-
runHook('kill-guard', input => {
|
|
427
|
-
const cmd = input.tool_input && input.tool_input.command
|
|
428
|
-
if (typeof cmd !== 'string' || !cmd) return
|
|
429
|
-
const hit = classify(cmd, { allowNames: setting('killGuard.allowNames', []), extraSharedNames: setting('killGuard.extraSharedNames', []) }, shell.modeFor(input))
|
|
430
|
-
if (!hit) return
|
|
431
|
-
return blocked('kill-guard', hit.rule, input, `${message(hit.why)}\n${ASK_USER}`)
|
|
432
|
-
})
|
|
563
|
+
return "Stop only what you started: kill <your own pid> (lsof -ti :<port> or pgrep -af '<your project dir>' finds it), or pkill -f '<your project dir>'."
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
function message(why, platform = process.platform, rule) {
|
|
567
|
+
const sentence = /[.!?]$/.test(why) ? why : why + '.'
|
|
568
|
+
return `[bulkhead] kill-guard: ${sentence}\n${howToFix(platform, rule)}`
|
|
433
569
|
}
|
|
434
570
|
|
|
435
|
-
|
|
571
|
+
// Rules with their own switch (config keys killGuard.<name>), on inside a guard that is on.
|
|
572
|
+
const RULES = ['mass-kill', 'docker-mass', 'device-kill', 'app-quit', 'service-stop', 'owner-check']
|
|
573
|
+
|
|
574
|
+
function handle(input) {
|
|
575
|
+
const cmd = input.tool_input && input.tool_input.command
|
|
576
|
+
if (typeof cmd !== 'string' || !cmd) return
|
|
577
|
+
const off = RULES.filter(rule => !enabled(rule))
|
|
578
|
+
const ctx = owners.makeContext({ cwd: input.cwd || process.cwd(), off })
|
|
579
|
+
const hit = classify(cmd, { allowNames: setting('killGuard.allowNames', []), extraSharedNames: setting('killGuard.extraSharedNames', []) }, shell.modeFor(input), ctx)
|
|
580
|
+
if (!hit) return
|
|
581
|
+
return blocked('kill-guard', hit.rule, input, `${message(hit.why, process.platform, hit.rule)}\n${ASK_USER}`)
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
if (require.main === module) runHook('kill-guard', handle)
|
|
585
|
+
|
|
586
|
+
module.exports = { handle, check, classify, message, nameHits, patternHits, nameList, RULES }
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
// Who owns what on this machine, for the kill checks that need to know. The process table is read
|
|
3
|
+
// at most once per hook and only when a command asks for it: kill with literal PIDs, pkill -f with
|
|
4
|
+
// a specific pattern, and the mass kills that matter only while another session is alive.
|
|
5
|
+
const os = require('os')
|
|
6
|
+
const path = require('path')
|
|
7
|
+
const processes = require('./processes')
|
|
8
|
+
const sessions = require('./sessions')
|
|
9
|
+
|
|
10
|
+
function shortPath(p) {
|
|
11
|
+
const home = os.homedir()
|
|
12
|
+
const s = String(p || '')
|
|
13
|
+
return home && (s === home || s.startsWith(home + path.sep)) ? '~' + s.slice(home.length) : s
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// A lazy view of the machine. Nothing is read until table() is called; a failed read gives null
|
|
17
|
+
// and every check then lets the command through.
|
|
18
|
+
function makeContext({ snapshot, files, self, cwd = process.cwd(), off = [] } = {}) {
|
|
19
|
+
let cached
|
|
20
|
+
return {
|
|
21
|
+
cwd,
|
|
22
|
+
off: new Set(off),
|
|
23
|
+
table() {
|
|
24
|
+
if (cached === undefined) {
|
|
25
|
+
try {
|
|
26
|
+
cached = loadTable({
|
|
27
|
+
snapshot: snapshot || (() => processes.readProcesses(process.platform, { withPorts: false })),
|
|
28
|
+
files: files || (() => sessions.readSessionFiles()),
|
|
29
|
+
self: self || (procs => sessions.ownSession(procs)),
|
|
30
|
+
})
|
|
31
|
+
} catch {
|
|
32
|
+
cached = null
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return cached
|
|
36
|
+
},
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function loadTable({ snapshot, files, self }) {
|
|
41
|
+
const snap = snapshot()
|
|
42
|
+
if (!snap || !Array.isArray(snap.procs)) return null
|
|
43
|
+
const list = sessions.findSessions(snap, files())
|
|
44
|
+
const roots = new Set(list.map(s => s.pid))
|
|
45
|
+
const me = self(snap.procs)
|
|
46
|
+
return {
|
|
47
|
+
procs: snap.procs,
|
|
48
|
+
byPid: new Map(snap.procs.map(p => [p.pid, p])),
|
|
49
|
+
owner: sessions.assignOwners(snap.procs, roots),
|
|
50
|
+
info: new Map(list.map(s => [s.pid, s])),
|
|
51
|
+
me,
|
|
52
|
+
// without knowing which session this is, no session can be called another one
|
|
53
|
+
others: me ? list.filter(s => s.pid !== me) : [],
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function label(t, pid) {
|
|
58
|
+
const s = t.info.get(pid)
|
|
59
|
+
return s && s.cwd ? `${shortPath(s.cwd)} (pid ${pid})` : `pid ${pid}`
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// The other session a process belongs to, or null.
|
|
63
|
+
function otherOwner(t, pid) {
|
|
64
|
+
const o = t.owner.get(pid)
|
|
65
|
+
return o && o !== t.me && t.info.has(o) ? o : null
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Another live session exists: a sentence naming one, or null.
|
|
69
|
+
function othersCheck(t) {
|
|
70
|
+
return t && t.others.length ? `Another Claude session is running here: ${label(t, t.others[0].pid)}.` : null
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// A literal PID that is another session's process, or any Claude session's own process.
|
|
74
|
+
function pidsCheck(t, pids) {
|
|
75
|
+
if (!t || !t.me) return null
|
|
76
|
+
for (const raw of pids) {
|
|
77
|
+
const pid = Math.abs(raw)
|
|
78
|
+
const p = t.byPid.get(pid)
|
|
79
|
+
if (!p) continue
|
|
80
|
+
if (pid === t.me) return `PID ${pid} is your own Claude session.`
|
|
81
|
+
const o = otherOwner(t, pid)
|
|
82
|
+
if (o) return `PID ${pid} (${p.name}) belongs to the Claude session ${label(t, o)}.`
|
|
83
|
+
}
|
|
84
|
+
return null
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// A pkill or killall pattern that would reach another session's processes.
|
|
88
|
+
function patternCheck(t, pattern, { full = false, exact = false } = {}) {
|
|
89
|
+
if (!t || !t.me || !pattern || String(pattern).length > 500) return null
|
|
90
|
+
let re
|
|
91
|
+
try {
|
|
92
|
+
re = new RegExp(exact ? `^(?:${pattern})$` : pattern, 'i')
|
|
93
|
+
} catch {
|
|
94
|
+
return null
|
|
95
|
+
}
|
|
96
|
+
for (const p of t.procs) {
|
|
97
|
+
const o = otherOwner(t, p.pid)
|
|
98
|
+
if (!o) continue
|
|
99
|
+
if (re.test(full ? p.cmd : p.name)) return `That pattern matches PID ${p.pid} (${p.name}) of the Claude session ${label(t, o)}.`
|
|
100
|
+
}
|
|
101
|
+
return null
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// docker compose down in a folder another live session works in.
|
|
105
|
+
function sameProjectCheck(t, dir) {
|
|
106
|
+
if (!t || !t.others.length) return null
|
|
107
|
+
const here = path.resolve(dir)
|
|
108
|
+
const hit = t.others.find(s => {
|
|
109
|
+
if (!s.cwd) return false
|
|
110
|
+
const there = path.resolve(s.cwd)
|
|
111
|
+
return there === here || there.startsWith(here + path.sep) || here.startsWith(there + path.sep)
|
|
112
|
+
})
|
|
113
|
+
return hit ? `Claude session ${label(t, hit.pid)} works in the same project.` : null
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
module.exports = { makeContext, loadTable, label, otherOwner, othersCheck, pidsCheck, patternCheck, sameProjectCheck, shortPath }
|
package/src/log-cli.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
'use strict'
|
|
2
2
|
const { stateDir } = require('./hook')
|
|
3
3
|
const { readEvents, eventsFile } = require('./events')
|
|
4
|
-
const { useColor, paint, shortPath, option, fit, table, rejectArgs } = require('./cli-util')
|
|
4
|
+
const { useColor, paint, shortPath, option, numberOption, fit, table, rejectArgs } = require('./cli-util')
|
|
5
5
|
|
|
6
|
-
const USAGE = `usage: bulkhead log [-n N] [--since 7d|24h|30m] [--guard NAME] [--summary] [--json]
|
|
6
|
+
const USAGE = `usage: bulkhead log [SPAN] [-n N] [--since 7d|24h|30m] [--guard NAME] [--summary] [--json]
|
|
7
7
|
|
|
8
8
|
Lists what the guards blocked and what orphan-reaper stopped, newest last.
|
|
9
9
|
-n N how many events to list (default 20; ignored with --summary)
|
|
@@ -110,23 +110,28 @@ function renderList(events, { color = false, home, width = 120, now = Date.now()
|
|
|
110
110
|
|
|
111
111
|
function main(args, { env = process.env, stdout = process.stdout, stderr = process.stderr, now = Date.now(), dir = stateDir() } = {}) {
|
|
112
112
|
if (args.includes('--help') || args.includes('-h')) return stdout.write(USAGE + '\n')
|
|
113
|
+
// `bulkhead log 24h` reads as `--since 24h`, the way /bulkhead:log takes its argument
|
|
114
|
+
if (args.length && /^(\d+[mhdw]|\d{4}-\d{2}-\d{2})$/.test(args[0])) args = ['--since', args[0], ...args.slice(1)]
|
|
113
115
|
if (rejectArgs('log', args, { flags: ['json', 'summary'], valued: ['since', 'guard', 'limit'], shorts: ['n'] }, stderr, USAGE)) return
|
|
114
116
|
const sinceArg = option(args, 'since')
|
|
115
117
|
let since = null
|
|
116
118
|
if (sinceArg !== undefined && sinceArg !== '') {
|
|
117
|
-
since = parseSince(sinceArg, now)
|
|
119
|
+
since = sinceArg === true ? NaN : parseSince(sinceArg, now)
|
|
118
120
|
if (!Number.isFinite(since)) {
|
|
119
|
-
stderr.write(`bulkhead log: cannot read --since ${sinceArg}; use 30m, 24h, 7d, 2w or a date like 2026-10-01\n`)
|
|
121
|
+
stderr.write(`bulkhead log: ${sinceArg === true ? '--since needs a value' : `cannot read --since ${sinceArg}`}; use 30m, 24h, 7d, 2w or a date like 2026-10-01\n`)
|
|
120
122
|
process.exitCode = 1
|
|
121
123
|
return
|
|
122
124
|
}
|
|
123
125
|
}
|
|
124
126
|
const guard = option(args, 'guard')
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
127
|
+
const i = args.indexOf('-n')
|
|
128
|
+
const count = numberOption(i >= 0 ? ['--n', args[i + 1]] : args, i >= 0 ? 'n' : 'limit', { fallback: 20 })
|
|
129
|
+
if (count.error) {
|
|
130
|
+
stderr.write(`bulkhead log: ${count.error.replace('--n ', '-n ')}\n`)
|
|
131
|
+
process.exitCode = 1
|
|
132
|
+
return
|
|
133
|
+
}
|
|
134
|
+
const n = Math.max(1, Math.floor(count.value))
|
|
130
135
|
const events = filter(readEvents(dir), { since, guard: typeof guard === 'string' ? guard : null })
|
|
131
136
|
const json = args.includes('--json')
|
|
132
137
|
const color = useColor(stdout, env)
|
|
@@ -143,4 +148,4 @@ function main(args, { env = process.env, stdout = process.stdout, stderr = proce
|
|
|
143
148
|
stdout.write(renderList(recent, { color, width: stdout.columns || 160, now }) + '\n')
|
|
144
149
|
}
|
|
145
150
|
|
|
146
|
-
module.exports = { main, parseSince, filter, summarize, renderSummary, renderList }
|
|
151
|
+
module.exports = { main, parseSince, filter, summarize, renderSummary, renderList, USAGE }
|