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/processes.js
CHANGED
|
@@ -3,6 +3,12 @@ const fs = require('fs')
|
|
|
3
3
|
const path = require('path')
|
|
4
4
|
const { execFileSync } = require('child_process')
|
|
5
5
|
|
|
6
|
+
// How long a process read may take. Inside a hook a slow read fails open, so it is kept short;
|
|
7
|
+
// the CLI commands wait longer for a full answer.
|
|
8
|
+
function readTimeout(cliMs, hookMs, env = process.env) {
|
|
9
|
+
return env.BULKHEAD_IN_HOOK === '1' ? hookMs : cliMs
|
|
10
|
+
}
|
|
11
|
+
|
|
6
12
|
// Every reader returns { procs: [{ pid, ppid, name, cmd, start, rss, claudePid }], listening: Set<pid>,
|
|
7
13
|
// ports: [{ port, pid }] }. start is milliseconds since the epoch, rss is bytes, and claudePid is null
|
|
8
14
|
// when the environment is unreadable.
|
|
@@ -34,7 +40,7 @@ function parseWindows(json) {
|
|
|
34
40
|
|
|
35
41
|
function readWindows(run = execFileSync) {
|
|
36
42
|
const out = run('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', WINDOWS_QUERY],
|
|
37
|
-
{ encoding: 'utf8', windowsHide: true, timeout: 60000, maxBuffer: 64 * 1024 * 1024 })
|
|
43
|
+
{ encoding: 'utf8', windowsHide: true, timeout: readTimeout(60000, 8000), maxBuffer: 64 * 1024 * 1024 })
|
|
38
44
|
return parseWindows(out)
|
|
39
45
|
}
|
|
40
46
|
|
|
@@ -57,6 +63,11 @@ function parseNetTcp(text) {
|
|
|
57
63
|
return inodes
|
|
58
64
|
}
|
|
59
65
|
|
|
66
|
+
// BULKHEAD_KEEP=1 in a process's environment marks something started on purpose to outlive its
|
|
67
|
+
// session (see orphan-reaper). Children inherit it, so a marked server's helpers are marked too.
|
|
68
|
+
const KEEP_VALUE = /^(?:1|true|yes)$/i
|
|
69
|
+
const isKeepValue = v => KEEP_VALUE.test(String(v || '').trim())
|
|
70
|
+
|
|
60
71
|
function envValue(environ, key) {
|
|
61
72
|
for (const kv of environ.split('\0')) if (kv.startsWith(key + '=')) return kv.slice(key.length + 1)
|
|
62
73
|
return null
|
|
@@ -68,11 +79,11 @@ const realFs = {
|
|
|
68
79
|
link: p => fs.readlinkSync(p),
|
|
69
80
|
}
|
|
70
81
|
|
|
71
|
-
function readLinux(io = realFs, clockTicks = 100, pageSize = 4096) {
|
|
82
|
+
function readLinux(io = realFs, clockTicks = 100, pageSize = 4096, { withPorts = true } = {}) {
|
|
72
83
|
const bootMs = Number(io.read('/proc/stat').match(/^btime (\d+)/m)[1]) * 1000
|
|
73
84
|
const tryRead = p => { try { return io.read(p) } catch { return null } }
|
|
74
85
|
const listenInodes = new Map()
|
|
75
|
-
for (const f of ['/proc/net/tcp', '/proc/net/tcp6']) {
|
|
86
|
+
for (const f of withPorts ? ['/proc/net/tcp', '/proc/net/tcp6'] : []) {
|
|
76
87
|
const t = tryRead(f)
|
|
77
88
|
if (t) for (const [inode, port] of parseNetTcp(t)) listenInodes.set(inode, port)
|
|
78
89
|
}
|
|
@@ -94,6 +105,7 @@ function readLinux(io = realFs, clockTicks = 100, pageSize = 4096) {
|
|
|
94
105
|
pid, ppid: s.ppid, name: s.name, cmd, start: bootMs + (s.startTicks / clockTicks) * 1000,
|
|
95
106
|
rss: s.rssPages * pageSize, claudePid: claudePid || null,
|
|
96
107
|
})
|
|
108
|
+
if (environ && isKeepValue(envValue(environ, 'BULKHEAD_KEEP'))) procs[procs.length - 1].keep = true
|
|
97
109
|
if (!listenInodes.size) continue
|
|
98
110
|
let fds = []
|
|
99
111
|
try { fds = io.list(`/proc/${entry}/fd`) } catch {}
|
|
@@ -150,8 +162,18 @@ function parsePsEnv(text) {
|
|
|
150
162
|
return out
|
|
151
163
|
}
|
|
152
164
|
|
|
153
|
-
|
|
154
|
-
|
|
165
|
+
// The PIDs whose `ps -E` line carries BULKHEAD_KEEP=1 as a whole word.
|
|
166
|
+
function parsePsKeep(text) {
|
|
167
|
+
const out = new Set()
|
|
168
|
+
for (const line of text.split('\n')) {
|
|
169
|
+
const m = /^\s*(\d+)\s.*\sBULKHEAD_KEEP=(\w+)(?=\s|$)/.exec(line)
|
|
170
|
+
if (m && isKeepValue(m[2])) out.add(Number(m[1]))
|
|
171
|
+
}
|
|
172
|
+
return out
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function readMac(run = execFileSync, { withEnv = false, withPorts = true } = {}) {
|
|
176
|
+
const opts = { encoding: 'utf8', timeout: readTimeout(15000, 4000), maxBuffer: 64 * 1024 * 1024, env: { ...process.env, LC_ALL: 'C' } }
|
|
155
177
|
const names = new Map()
|
|
156
178
|
for (const line of run('ps', ['-axo', 'pid=,ucomm='], opts).split('\n')) {
|
|
157
179
|
const m = /^\s*(\d+)\s+(.*)$/.exec(line)
|
|
@@ -160,22 +182,32 @@ function readMac(run = execFileSync, { withEnv = false } = {}) {
|
|
|
160
182
|
const procs = parsePs(run('ps', ['-axww', '-o', 'pid=,ppid=,rss=,lstart=,args='], opts), names)
|
|
161
183
|
if (withEnv) {
|
|
162
184
|
let env = new Map()
|
|
163
|
-
|
|
164
|
-
|
|
185
|
+
let keep = new Set()
|
|
186
|
+
try {
|
|
187
|
+
const text = run('ps', ['-axwwE', '-o', 'pid=,command='], opts)
|
|
188
|
+
env = parsePsEnv(text)
|
|
189
|
+
keep = parsePsKeep(text)
|
|
190
|
+
} catch {}
|
|
191
|
+
for (const p of procs) {
|
|
192
|
+
p.claudePid = env.get(p.pid) || null
|
|
193
|
+
if (keep.has(p.pid)) p.keep = true
|
|
194
|
+
}
|
|
165
195
|
}
|
|
166
196
|
let ports = []
|
|
167
197
|
// lsof exits 1 when nothing is listening
|
|
168
|
-
try { ports = parseLsof(run('lsof', ['-nP', '-iTCP', '-sTCP:LISTEN', '-Fn'], opts)) } catch (e) { ports = parseLsof(String(e.stdout || '')) }
|
|
198
|
+
if (withPorts) try { ports = parseLsof(run('lsof', ['-nP', '-iTCP', '-sTCP:LISTEN', '-Fn'], opts)) } catch (e) { ports = parseLsof(String(e.stdout || '')) }
|
|
169
199
|
return { procs, listening: new Set(ports.map(l => l.pid)), ports }
|
|
170
200
|
}
|
|
171
201
|
|
|
172
|
-
|
|
202
|
+
// withPorts: false (or ports: false) skips the listening sockets, the slowest part, for callers that need processes only.
|
|
203
|
+
function readProcesses(platform = process.platform, { withEnv = false, withPorts, ports } = {}) {
|
|
204
|
+
const wantPorts = withPorts !== undefined ? withPorts : ports !== undefined ? ports : true
|
|
173
205
|
if (platform === 'win32') return readWindows()
|
|
174
|
-
if (platform === 'linux') return readLinux()
|
|
175
|
-
if (platform === 'darwin') return readMac(execFileSync, { withEnv })
|
|
206
|
+
if (platform === 'linux') return readLinux(realFs, 100, 4096, { withPorts: wantPorts })
|
|
207
|
+
if (platform === 'darwin') return readMac(execFileSync, { withEnv, withPorts: wantPorts })
|
|
176
208
|
return null
|
|
177
209
|
}
|
|
178
210
|
|
|
179
211
|
module.exports = {
|
|
180
|
-
readProcesses, parseWindows, readWindows, parseProcStat, parseNetTcp, readLinux, envValue, parsePs, parseLsof, readMac, parsePsEnv,
|
|
212
|
+
readProcesses, parseWindows, readWindows, parseProcStat, parseNetTcp, readLinux, envValue, parsePs, parseLsof, readMac, parsePsEnv, parsePsKeep, isKeepValue, readTimeout,
|
|
181
213
|
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
// Plain-words views of a scan result: the shareable summary, the matched part of a command, and
|
|
3
|
+
// --explain. Everything here reads the same r.summary.rules the normal report prints.
|
|
4
|
+
const REPO = 'https://github.com/dooly6276/claude-bulkhead'
|
|
5
|
+
const DAY_MS = 86400000
|
|
6
|
+
|
|
7
|
+
// What each rule means, in words a person who has never seen the guard names understands.
|
|
8
|
+
// A group is one line of the share text; rules in the same group are added together.
|
|
9
|
+
const GROUPS = [
|
|
10
|
+
{ key: 'stage-all', match: (g, r) => g === 'checkout-guard' && r === 'stage-all', text: 'git add -A while another session was active in the same tree' },
|
|
11
|
+
{ key: 'git', match: (g, r) => g === 'checkout-guard' && r.startsWith('git-'), text: 'checkout/merge/rebase/stash in a tree another session was using' },
|
|
12
|
+
{ key: 'remove-dir', match: (g, r) => g === 'checkout-guard' && r === 'remove-session-dir', text: 'rm -rf or worktree remove on a folder another session was in' },
|
|
13
|
+
{ key: 'kill', match: g => g === 'kill-guard', text: 'pkill/killall by name, which also stops other sessions\' processes' },
|
|
14
|
+
{ key: 'wait', match: g => g === 'wait-guard', text: 'foreground sleeps and polling loops past the wait limit' },
|
|
15
|
+
{ key: 'disk', match: g => g === 'disk-guard', text: 'whole-disk or parallel disk scans' },
|
|
16
|
+
{ key: 'rm-root', match: (g, r) => g === 'basics' && r === 'rm-root', text: 'rm -rf at a root, home or system folder' },
|
|
17
|
+
{ key: 'force-push', match: (g, r) => g === 'basics' && r === 'force-push-main', text: 'force-pushes to main' },
|
|
18
|
+
{ key: 'discard', match: (g, r) => g === 'basics' && r === 'discard-changes', text: 'reset --hard or clean -f that lose uncommitted work' },
|
|
19
|
+
{ key: 'author', match: (g, r) => g === 'basics' && r === 'agent-author', text: 'commits authored as Claude' },
|
|
20
|
+
{ key: 'slots', match: g => g === 'memory-guard', text: 'hand-edited slot files' },
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
function describe(guard, rule) {
|
|
24
|
+
const g = GROUPS.find(x => x.match(guard, rule))
|
|
25
|
+
return g ? g.text : `${guard} ${rule}`
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Counts per group, largest first. Hits are commands, so the same command can count in two groups.
|
|
29
|
+
function groups(r) {
|
|
30
|
+
const by = new Map()
|
|
31
|
+
for (const x of r.summary.rules) {
|
|
32
|
+
const g = GROUPS.find(y => y.match(x.guard, x.rule))
|
|
33
|
+
const key = g ? g.key : `${x.guard} ${x.rule}`
|
|
34
|
+
const cur = by.get(key) || { text: g ? g.text : `${x.guard} ${x.rule}`, count: 0 }
|
|
35
|
+
cur.count += x.count
|
|
36
|
+
by.set(key, cur)
|
|
37
|
+
}
|
|
38
|
+
return [...by.values()].sort((a, b) => b.count - a.count || a.text.localeCompare(b.text))
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const num = n => n.toLocaleString('en-US')
|
|
42
|
+
const plural = (n, word) => `${num(n)} ${word}${n === 1 ? '' : 's'}`
|
|
43
|
+
|
|
44
|
+
function days(r) {
|
|
45
|
+
if (!r.first || !r.last) return 0
|
|
46
|
+
return Math.max(1, Math.ceil((r.last - r.first) / DAY_MS))
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// The numbers the share text carries. Nothing else leaves this function: no paths, no commands.
|
|
50
|
+
function shareSummary(r) {
|
|
51
|
+
return {
|
|
52
|
+
days: days(r),
|
|
53
|
+
sessions: r.sessions,
|
|
54
|
+
maxAtOnce: r.concurrency.max,
|
|
55
|
+
commandsHit: r.summary.commandsHit,
|
|
56
|
+
sessionsHit: r.summary.sessionsHit,
|
|
57
|
+
items: groups(r),
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const SHOWN = 4
|
|
62
|
+
|
|
63
|
+
function headline(s) {
|
|
64
|
+
const parts = []
|
|
65
|
+
if (s.days) parts.push(plural(s.days, 'day'))
|
|
66
|
+
parts.push(plural(s.sessions, 'session'))
|
|
67
|
+
if (s.maxAtOnce >= 2) parts.push(`up to ${s.maxAtOnce} at once`)
|
|
68
|
+
return parts.join(', ')
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Folds the groups beyond the shown ones into one "other" line.
|
|
72
|
+
function shownItems(s) {
|
|
73
|
+
if (s.items.length <= SHOWN) return s.items
|
|
74
|
+
const rest = s.items.slice(SHOWN - 1)
|
|
75
|
+
return [...s.items.slice(0, SHOWN - 1), { text: `other risky commands (${plural(rest.length, 'kind')})`, count: rest.reduce((n, x) => n + x.count, 0) }]
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function shareText(r) {
|
|
79
|
+
const s = shareSummary(r)
|
|
80
|
+
if (!r.commands) return 'bulkhead scan found no Claude Code shell commands to replay.'
|
|
81
|
+
const out = [`bulkhead scan, ${headline(s)}.`]
|
|
82
|
+
if (!s.items.length) out.push('Nothing in these sessions would have been stopped.')
|
|
83
|
+
else {
|
|
84
|
+
out.push(`It would have stopped ${plural(s.commandsHit, 'command')} in ${plural(s.sessionsHit, 'session')}:`)
|
|
85
|
+
for (const x of shownItems(s)) out.push(` ${num(x.count)} ${x.text}`)
|
|
86
|
+
}
|
|
87
|
+
out.push(`(bulkhead scan, ${REPO.replace('https://', '')})`)
|
|
88
|
+
return out.join('\n')
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function shareMarkdown(r) {
|
|
92
|
+
const s = shareSummary(r)
|
|
93
|
+
if (!r.commands) return 'bulkhead scan found no Claude Code shell commands to replay.'
|
|
94
|
+
const out = [`**bulkhead scan**, ${headline(s)}.`, '']
|
|
95
|
+
if (!s.items.length) out.push('Nothing in these sessions would have been stopped.')
|
|
96
|
+
else {
|
|
97
|
+
out.push('| Would have been stopped | Count |', '| --- | ---: |')
|
|
98
|
+
for (const x of shownItems(s)) out.push(`| ${x.text} | ${num(x.count)} |`)
|
|
99
|
+
out.push(`| **Total** | **${num(s.commandsHit)}** commands in ${plural(s.sessionsHit, 'session')} |`)
|
|
100
|
+
}
|
|
101
|
+
out.push('', `_bulkhead scan, [${REPO.replace('https://', '')}](${REPO})_`)
|
|
102
|
+
return out.join('\n')
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ---------------------------------------------------------------------------------------------
|
|
106
|
+
// The part of a command a rule matched. A best guess from the rule's name: the guards decide on a
|
|
107
|
+
// parsed command, scan only has the text, so this finds the clause a person would point at.
|
|
108
|
+
|
|
109
|
+
// One shell clause: up to ; & | or a newline, but `2>&1` stays inside it.
|
|
110
|
+
const CLAUSE = '(?:[^\\n;&|]|(?<=>)&)*'
|
|
111
|
+
const MATCHERS = {
|
|
112
|
+
'kill-guard': /\b(?:pkill|killall|Stop-Process|taskkill|wmic|Get-Process|xargs\s+kill|kill)\b[^\n;&|]*/,
|
|
113
|
+
'wait-guard': /\b(?:sleep|Start-Sleep|timeout|Wait-Process|tail\s+-\w*f)\b[^\n;&|]*/i,
|
|
114
|
+
'disk-guard': /\b(?:du|ncdu|dust|find|tree|xargs|parallel|ForEach-Object|Get-ChildItem|dir|ls\s+-\w*R)\b[^\n;&|]*/,
|
|
115
|
+
'memory-guard': /\S*slots?\S*/,
|
|
116
|
+
'basics:rm-root': /\brm\s[^\n;&|]*/,
|
|
117
|
+
'basics:force-push-main': /\bgit\s[^\n;&|]*?\bpush\b[^\n;&|]*/,
|
|
118
|
+
'basics:discard-changes': /\bgit\s[^\n;&|]*?\b(?:reset|clean|checkout|restore)\b[^\n;&|]*/,
|
|
119
|
+
'basics:agent-author': /--author\S*(?:\s*=?\s*(?:"[^"]*"|'[^']*'|\S+))?|git\s[^\n;&|]*user\.\w+[^\n;&|]*|GIT_(?:AUTHOR|COMMITTER)_\w+=\S+/,
|
|
120
|
+
'checkout-guard:stage-all': /\bgit\s[^\n;&|]*?\b(?:add|commit)\b[^\n;&|]*/,
|
|
121
|
+
'checkout-guard:remove-session-dir': /\b(?:rm\s[^\n;&|]*|git\s[^\n;&|]*?worktree\s+remove[^\n;&|]*)/,
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const widen = re => new RegExp(re.source.split('[^\\n;&|]*').join(CLAUSE), re.flags)
|
|
125
|
+
|
|
126
|
+
function matcherFor(guard, rule) {
|
|
127
|
+
const given = MATCHERS[`${guard}:${rule}`]
|
|
128
|
+
if (given) return widen(given)
|
|
129
|
+
if (guard === 'checkout-guard' && rule.startsWith('git-')) return new RegExp(`\\bgit\\s[^\\n;&|]*?\\b${rule.slice(4).replace(/[^\w-]/g, '')}\\b${CLAUSE}`)
|
|
130
|
+
return MATCHERS[guard] ? widen(MATCHERS[guard]) : null
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// { start, end } of the matched clause in the command, or null.
|
|
134
|
+
function findMatch(guard, rule, command) {
|
|
135
|
+
const re = matcherFor(guard, rule)
|
|
136
|
+
const m = re ? re.exec(command) : null
|
|
137
|
+
if (!m || !m[0].trim()) return null
|
|
138
|
+
const text = m[0].replace(/\s+$/, '')
|
|
139
|
+
return { start: m.index, end: m.index + text.length }
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// A command cut to `width` characters around the match. The match is wrapped by `mark`.
|
|
143
|
+
function clipAround(command, match, width, mark) {
|
|
144
|
+
const flat = command.replace(/\s*\n\s*/g, ' ⏎ ')
|
|
145
|
+
// newline collapsing moves offsets, so find the match again in the flat text
|
|
146
|
+
let m = null
|
|
147
|
+
if (match) {
|
|
148
|
+
const raw = command.slice(match.start, match.end).replace(/\s*\n\s*/g, ' ⏎ ')
|
|
149
|
+
const at = flat.indexOf(raw)
|
|
150
|
+
if (at >= 0) m = { start: at, end: at + raw.length }
|
|
151
|
+
}
|
|
152
|
+
if (!m) return flat.length > width ? flat.slice(0, Math.max(0, width - 1)) + '…' : flat
|
|
153
|
+
if (flat.length <= width) return flat.slice(0, m.start) + mark(flat.slice(m.start, m.end)) + flat.slice(m.end)
|
|
154
|
+
const len = Math.min(m.end - m.start, Math.max(10, width - 8))
|
|
155
|
+
const room = Math.max(0, width - len)
|
|
156
|
+
let from = Math.max(0, m.start - Math.floor(room * 0.4))
|
|
157
|
+
if (from + width > flat.length) from = Math.max(0, flat.length - width)
|
|
158
|
+
const to = Math.min(flat.length, from + width)
|
|
159
|
+
const mEnd = Math.min(m.start + len, to)
|
|
160
|
+
const body = flat.slice(from, m.start) + mark(flat.slice(m.start, mEnd)) + flat.slice(mEnd, to)
|
|
161
|
+
return (from > 0 ? '…' : '') + body + (to < flat.length ? '…' : '')
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const docsLink = guard => `${REPO}/blob/main/docs/guards/${guard}.md`
|
|
165
|
+
|
|
166
|
+
module.exports = { GROUPS, describe, groups, shareSummary, shareText, shareMarkdown, findMatch, clipAround, docsLink, days, REPO }
|
package/src/scan.js
CHANGED
|
@@ -8,10 +8,13 @@ const os = require('os')
|
|
|
8
8
|
const path = require('path')
|
|
9
9
|
const { claudeDir } = require('./sessions')
|
|
10
10
|
const { setting } = require('./hook')
|
|
11
|
-
const { useColor, paint, shortPath, option,
|
|
11
|
+
const { useColor, paint, shortPath, option, table, bytes } = require('./cli-util')
|
|
12
12
|
const { parseSince } = require('./log-cli')
|
|
13
|
+
const scanReport = require('./scan-report')
|
|
13
14
|
|
|
14
15
|
const USAGE = `usage: bulkhead scan [30d|DATE] [--since 30d|DATE] [--path DIR|FILE] [--project TEXT] [-n N] [--json]
|
|
16
|
+
bulkhead scan [30d] --share [--markdown]
|
|
17
|
+
bulkhead scan [30d] --explain N
|
|
15
18
|
|
|
16
19
|
Replays the shell commands in your Claude Code transcripts against bulkhead's guards and shows
|
|
17
20
|
what they would have stopped. Read-only. Nothing leaves this machine.
|
|
@@ -19,7 +22,9 @@ what they would have stopped. Read-only. Nothing leaves this machine.
|
|
|
19
22
|
--path P transcripts to read (default: ~/.claude/projects)
|
|
20
23
|
--project TEXT only sessions whose folder contains TEXT
|
|
21
24
|
-n N examples to show per rule (default 3, 0 for none)
|
|
22
|
-
--json machine-readable output
|
|
25
|
+
--json machine-readable output
|
|
26
|
+
--share 5 to 8 plain lines with counts only, to paste in a post or a chat (--markdown: a small table)
|
|
27
|
+
--explain N one example in full: the command, the part that matched, the rule, and the guard's docs`
|
|
23
28
|
|
|
24
29
|
// Minutes on either side of a command in which another session counts as active.
|
|
25
30
|
const ACTIVE_WINDOW_MIN = 10
|
|
@@ -410,53 +415,110 @@ const RULE_NOTES = {
|
|
|
410
415
|
'checkout-guard': 'another session was active in the same tree at that minute',
|
|
411
416
|
}
|
|
412
417
|
|
|
418
|
+
// Examples in the order the report prints them, numbered for --explain.
|
|
419
|
+
function exampleList(r, n) {
|
|
420
|
+
const out = []
|
|
421
|
+
for (const x of r.summary.rules) for (const e of x.examples.slice(0, n)) out.push({ n: out.length + 1, rule: x, example: e })
|
|
422
|
+
return out
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// Rules where at least half of the hits were already stopped by a hook or declined by the user.
|
|
426
|
+
function mostlyStopped(rules) {
|
|
427
|
+
return rules.filter(x => x.hook + x.user > 0 && (x.hook + x.user) * 2 >= x.count)
|
|
428
|
+
}
|
|
429
|
+
|
|
413
430
|
function renderScan(r, { color = false, home = os.homedir(), examples = 3, width = 120 } = {}) {
|
|
414
431
|
const out = []
|
|
415
432
|
const b = s => paint(color, 'bold', s)
|
|
416
|
-
|
|
417
|
-
out.push(
|
|
418
|
-
if (r.
|
|
433
|
+
const s = r.summary
|
|
434
|
+
out.push(`${b('bulkhead scan')} what your past Claude Code sessions would have run into`)
|
|
435
|
+
if (!r.commands) {
|
|
436
|
+
out.push('No shell commands found in these transcripts.')
|
|
437
|
+
return out.join('\n')
|
|
438
|
+
}
|
|
439
|
+
if (!s.rules.length) out.push(`Found nothing bulkhead would have stopped in ${plural(r.commands, 'shell command')} from ${plural(r.sessions, 'session')}.`)
|
|
440
|
+
else {
|
|
441
|
+
out.push(`Found ${b(plural(s.commandsHit, 'shell command'))} in ${s.sessionsHit.toLocaleString('en-US')} of your ${plural(r.sessions, 'session')} that bulkhead would have stopped.`)
|
|
442
|
+
const top = scanReport.groups(r).slice(0, 2).map(g => `${g.count.toLocaleString('en-US')} ${g.text}`)
|
|
443
|
+
out.push(`Most common: ${top.join('; ')}.`)
|
|
444
|
+
}
|
|
419
445
|
if (r.concurrency.max >= 2) {
|
|
420
|
-
out.push(`
|
|
421
|
-
|
|
446
|
+
out.push(`Up to ${b(r.concurrency.max)} Claude Code sessions ran at the same time (${day(Date.parse(r.concurrency.at))}); ` +
|
|
447
|
+
`on ${plural(r.concurrency.overlapDays, 'day')} two or more did.`)
|
|
422
448
|
}
|
|
449
|
+
out.push(paint(color, 'dim', ` read ${plural(r.files, 'transcript')} (${bytes(r.bytes)}), ${plural(r.commands, 'shell command')} in ${r.seconds.toFixed(1)}s` +
|
|
450
|
+
(r.first ? `, ${day(r.first)} .. ${day(r.last)}${r.sinceLabel ? ` (since ${r.sinceLabel})` : ''}` : '')))
|
|
423
451
|
out.push('')
|
|
424
|
-
if (!
|
|
425
|
-
out.push(
|
|
452
|
+
if (!s.rules.length) {
|
|
453
|
+
out.push('Nothing in these transcripts would have been stopped.')
|
|
426
454
|
return out.join('\n')
|
|
427
455
|
}
|
|
428
456
|
const rows = [['GUARD', 'RULE', 'HITS', 'SESSIONS', 'STOPPED', 'LAST']]
|
|
429
|
-
for (const x of
|
|
457
|
+
for (const x of s.rules) {
|
|
430
458
|
const stopped = x.hook + x.user
|
|
431
459
|
rows.push([x.guard, x.rule, String(x.count).padStart(4), String(x.sessions).padStart(8), stopped ? `${stopped}`.padStart(7) : ' -', x.last ? day(x.last) : ''])
|
|
432
460
|
}
|
|
433
461
|
const lines = table(rows)
|
|
434
462
|
out.push(b(lines[0]), ...lines.slice(1))
|
|
463
|
+
out.push(paint(color, 'dim', 'STOPPED: hits a hook already stopped or you declined, as the transcript recorded them. "-" means none recorded: the command ran.'))
|
|
435
464
|
if (examples > 0) {
|
|
436
465
|
let lastGuard = null
|
|
437
|
-
|
|
466
|
+
const mark = color ? t => paint(color, 'bold', t) : t => `[${t}]`
|
|
467
|
+
const numbered = exampleList(r, examples)
|
|
468
|
+
for (const x of s.rules) {
|
|
438
469
|
out.push('')
|
|
439
470
|
const note = x.guard !== lastGuard && RULE_NOTES[x.guard] ? paint(color, 'dim', ` ${RULE_NOTES[x.guard]}`) : ''
|
|
440
471
|
lastGuard = x.guard
|
|
441
472
|
out.push(`${paint(color, x.guard === 'checkout-guard' || x.guard === 'kill-guard' ? 'red' : 'yellow', `${x.guard} ${x.rule}`)} ${plural(x.count, 'time')} in ${plural(x.sessions, 'session')}${note}`)
|
|
442
|
-
for (const e of
|
|
473
|
+
for (const { n, example: e } of numbered.filter(y => y.rule === x)) {
|
|
443
474
|
const tag = e.outcome === 'hook' ? ' [stopped by a hook]' : e.outcome === 'user' ? ' [declined by you]' : ''
|
|
444
|
-
const meta = `${shortPath(e.cwd ? treeRoot(e.cwd) : '?', home)} ${e.ts ? day(e.ts) : ''}${tag}`
|
|
475
|
+
const meta = `${shortPath(e.cwd ? treeRoot(e.cwd) : '?', home)} ${e.ts ? day(e.ts) : ''}${tag} #${n}`
|
|
445
476
|
const room = Math.max(30, width - meta.length - 8)
|
|
446
|
-
out.push(` $ ${
|
|
477
|
+
out.push(` $ ${scanReport.clipAround(e.command, scanReport.findMatch(x.guard, x.rule, e.command), room, mark)} ${paint(color, 'gray', meta)}`)
|
|
447
478
|
}
|
|
448
479
|
}
|
|
480
|
+
if (!color) out.push('', paint(color, 'dim', 'The part of a command that matched is shown in [brackets].'))
|
|
449
481
|
}
|
|
450
482
|
out.push('')
|
|
451
|
-
const s = r.summary
|
|
452
483
|
const already = s.rules.reduce((n, x) => n + x.hook + x.user, 0)
|
|
453
484
|
out.push(b(`${plural(s.commandsHit, 'command')} in ${plural(s.sessionsHit, 'session')} would have been stopped by bulkhead.`) +
|
|
454
485
|
(already ? ` ${already.toLocaleString('en-US')} of the hits were already stopped by a hook or declined by you.` : ''))
|
|
486
|
+
const mostly = mostlyStopped(s.rules)
|
|
487
|
+
if (mostly.length) {
|
|
488
|
+
const names = mostly.slice(0, 3).map(x => `${x.guard} ${x.rule} (${x.hook + x.user} of ${x.count})`).join(', ')
|
|
489
|
+
out.push(`Most hits of ${names} were already stopped by your own hooks or by you, so bulkhead adds less there than the count suggests.`)
|
|
490
|
+
}
|
|
455
491
|
out.push(paint(color, 'dim', 'A hit means the command had the shape a guard blocks, not that damage was done. port-guard and the low-RAM check depend on'))
|
|
456
492
|
out.push(paint(color, 'dim', 'what was running at that moment and are not replayed. checkout-guard is replayed from which sessions were active in the same folder.'))
|
|
493
|
+
out.push(paint(color, 'dim', 'Share these numbers: bulkhead scan --share'))
|
|
494
|
+
if (examples > 0) out.push(paint(color, 'dim', 'One example in full, with the matched part and the rule: bulkhead scan --explain 1'))
|
|
457
495
|
return out.join('\n')
|
|
458
496
|
}
|
|
459
497
|
|
|
498
|
+
// One example in full: the whole command, what matched, the rule and where it is documented.
|
|
499
|
+
function renderExplain(r, number, { examples = 3, home = os.homedir(), color = false } = {}) {
|
|
500
|
+
const list = exampleList(r, examples > 0 ? examples : 3)
|
|
501
|
+
const hit = list.find(y => y.n === number)
|
|
502
|
+
if (!hit) return `No example #${number}: this scan has ${list.length}. Examples are numbered in the report (#1 ...).`
|
|
503
|
+
const { rule: x, example: e } = hit
|
|
504
|
+
const match = scanReport.findMatch(x.guard, x.rule, e.command)
|
|
505
|
+
const mark = color ? t => paint(color, 'bold', t) : t => `[${t}]`
|
|
506
|
+
const full = e.command.length > 4000 ? e.command.slice(0, 4000) + '\n…' : e.command
|
|
507
|
+
const body = match && match.end <= full.length ? full.slice(0, match.start) + mark(full.slice(match.start, match.end)) + full.slice(match.end) : full
|
|
508
|
+
const outcome = e.outcome === 'hook' ? 'stopped by a hook at the time' : e.outcome === 'user' ? 'declined by you at the time' : 'it ran'
|
|
509
|
+
return [
|
|
510
|
+
`${paint(color, 'bold', `Example #${number}`)} ${x.guard} ${x.rule}`,
|
|
511
|
+
` when ${e.ts ? day(e.ts) : '?'} in ${shortPath(e.cwd ? treeRoot(e.cwd) || e.cwd : '?', home)} (${outcome})`,
|
|
512
|
+
` meaning ${scanReport.describe(x.guard, x.rule)}`,
|
|
513
|
+
RULE_NOTES[x.guard] ? ` why ${RULE_NOTES[x.guard]}` : null,
|
|
514
|
+
match ? ` matched ${e.command.slice(match.start, match.end).replace(/\s*\n\s*/g, ' ⏎ ')}` : ' matched (the whole command; scan could not narrow it down)',
|
|
515
|
+
` docs ${scanReport.docsLink(x.guard)}`,
|
|
516
|
+
'',
|
|
517
|
+
'The command (secret-looking values are masked):',
|
|
518
|
+
body.replace(/^/gm, ' '),
|
|
519
|
+
].filter(l => l !== null).join('\n')
|
|
520
|
+
}
|
|
521
|
+
|
|
460
522
|
function toJson(r) {
|
|
461
523
|
return {
|
|
462
524
|
files: r.files,
|
|
@@ -531,8 +593,8 @@ async function scan({ dir = path.join(claudeDir(), 'projects'), since = null, pr
|
|
|
531
593
|
}
|
|
532
594
|
}
|
|
533
595
|
|
|
534
|
-
const VALUE_FLAGS = ['since', 'path', 'project']
|
|
535
|
-
const BOOL_FLAGS = ['json']
|
|
596
|
+
const VALUE_FLAGS = ['since', 'path', 'project', 'explain']
|
|
597
|
+
const BOOL_FLAGS = ['json', 'share', 'markdown']
|
|
536
598
|
// The first bare argument that is not a flag's value: `bulkhead scan 30d`.
|
|
537
599
|
function positionals(args) {
|
|
538
600
|
const out = []
|
|
@@ -554,6 +616,11 @@ function checkArgs(args) {
|
|
|
554
616
|
const name = a.startsWith('--') ? a.slice(2).split('=')[0] : null
|
|
555
617
|
if (!name || ![...VALUE_FLAGS, ...BOOL_FLAGS].includes(name)) return `unknown option ${a}`
|
|
556
618
|
}
|
|
619
|
+
if (args.includes('--markdown') && !args.includes('--share')) return '--markdown goes with --share'
|
|
620
|
+
const explain = option(args, 'explain')
|
|
621
|
+
if (explain !== undefined && !(Number.isInteger(Number(explain)) && Number(explain) >= 1)) return '--explain takes an example number, 1 or more'
|
|
622
|
+
const modes = ['json', 'share', 'explain'].filter(m => args.some(a => a === `--${m}` || a.startsWith(`--${m}=`)))
|
|
623
|
+
if (modes.length > 1) return `use one of --${modes.join(', --')}, not several`
|
|
557
624
|
const pos = positionals(args)
|
|
558
625
|
if (pos.length > 1) return `unexpected argument ${pos[1]}`
|
|
559
626
|
if (pos.length && option(args, 'since') !== undefined) return `give the time span once: --since ${option(args, 'since')} or ${pos[0]}, not both`
|
|
@@ -598,7 +665,9 @@ async function main(args, { env = process.env, stdout = process.stdout, stderr =
|
|
|
598
665
|
if (shown) stderr.write('\r\x1b[2K')
|
|
599
666
|
r.sinceLabel = typeof sinceArg === 'string' ? sinceArg : null
|
|
600
667
|
if (args.includes('--json')) return stdout.write(JSON.stringify(toJson(r), null, 2) + '\n')
|
|
668
|
+
if (args.includes('--share')) return stdout.write((args.includes('--markdown') ? scanReport.shareMarkdown(r) : scanReport.shareText(r)) + '\n')
|
|
669
|
+
if (option(args, 'explain') !== undefined) return stdout.write(renderExplain(r, Number(option(args, 'explain')), { examples: n, color: useColor(stdout, env) }) + '\n')
|
|
601
670
|
stdout.write(renderScan(r, { color: useColor(stdout, env), examples: n, width: stdout.columns || 120 }) + '\n')
|
|
602
671
|
}
|
|
603
672
|
|
|
604
|
-
module.exports = { main, scan, timeSpan, replay, renderScan, toJson, mask, outcomeOf, sessionKey, findTranscripts, concurrency, USAGE }
|
|
673
|
+
module.exports = { main, scan, timeSpan, replay, renderScan, renderExplain, exampleList, toJson, mask, outcomeOf, sessionKey, findTranscripts, concurrency, USAGE }
|