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,301 @@
1
+ 'use strict'
2
+ const fs = require('fs')
3
+ const os = require('os')
4
+ const path = require('path')
5
+ const { runHook, blocked, setting, ASK_USER } = require('./hook')
6
+ const shell = require('./shell')
7
+
8
+ // Opt-in (secretsGuard.enabled). Stops the agent from reading secret files into the conversation:
9
+ // the Read and Grep tools on them, and shell commands that print, encode or upload them or dump the
10
+ // whole environment. Writing a secret file is fine, and so are ls, stat, test -f and git check-ignore.
11
+ // This is a guard against careless reads, not a sandbox: a program that opens the file itself is not seen.
12
+
13
+ const SAFE_ENV = /^\.env\.(?:example|sample|template|dist|defaults|tpl|schema)$/i
14
+ const SSH_SAFE = /^(?:config|known_hosts(?:\.old)?|authorized_keys2?|allowed_signers|environment|rc)$|\.pub$/i
15
+ const PUBLIC_PEM = /-----BEGIN (?:CERTIFICATE|TRUSTED CERTIFICATE|PUBLIC KEY|X509 CRL|CERTIFICATE REQUEST)-----/
16
+ const NPM_TOKEN = /_authToken|_auth\b|_password|:_password/i
17
+ // Files that hold a secret only some of the time: read, and block when they do.
18
+ const CONTENT_RULES = [
19
+ [/(?:^|\/)\.npmrc$/i, NPM_TOKEN, 'an npm auth token'],
20
+ [/(?:^|\/)\.yarnrc\.yml$/i, /npmAuthToken|npmAuthIdent/i, 'an npm auth token'],
21
+ [/(?:^|\/)\.pypirc$/i, /password|token/i, 'a PyPI password or token'],
22
+ [/(?:^|\/)\.docker\/config\.json$/i, /"auth"\s*:|identitytoken|registrytoken/i, 'Docker registry credentials'],
23
+ ]
24
+ // Names a shell glob such as .env* or ~/.ssh/* could expand to.
25
+ const SAMPLES = ['.env', '.env.local', '.env.production', 'id_rsa', 'id_ed25519', 'server.pem', 'server.key', 'credentials',
26
+ 'hosts.yml', '.netrc', 'config', 'config.json', 'credentials.db', 'access_tokens.db', 'application_default_credentials.json', '.npmrc']
27
+
28
+ const SECRET_DIRS = /\/(?:\.ssh|\.aws|\.kube|\.azure|\.config\/gh|\.config\/gcloud)$/i
29
+ const READERS = new Set(['cat', 'tac', 'less', 'more', 'most', 'head', 'tail', 'nl', 'bat', 'batcat', 'cut', 'sort', 'uniq', 'od', 'xxd',
30
+ 'hexdump', 'strings', 'base64', 'base32', 'basenc', 'grep', 'egrep', 'fgrep', 'rg', 'ag', 'ack', 'sed', 'awk', 'gawk', 'mawk', 'nawk',
31
+ 'jq', 'yq', 'diff', 'cmp', 'comm', 'paste', 'column', 'fold', 'rev', 'get-content', 'gc', 'type', 'select-string', 'sls',
32
+ 'cp', 'scp', 'rsync', 'curl', 'wget'])
33
+ // the first plain argument of these is a pattern or a program, not a file
34
+ const PATTERN_FIRST = new Set(['grep', 'egrep', 'fgrep', 'rg', 'ag', 'ack', 'sed', 'awk', 'gawk', 'mawk', 'nawk', 'jq', 'yq', 'select-string', 'sls'])
35
+ const COPIERS = new Set(['cp', 'scp', 'rsync'])
36
+ // options of the copy tools that take a value which is not a file to copy (scp -i ~/.ssh/id_rsa names a key it uses)
37
+ const VALUE_FLAGS = { cp: ['-t', '-S'], scp: ['-i', '-o', '-P', '-F', '-J', '-l', '-c', '-S'], rsync: ['-e', '--rsh', '--port', '--rsync-path', '--exclude', '--include'] }
38
+ const UPLOAD_FLAG = /^(?:-T|--upload-file|--post-file|--body-file|-d|--data|--data-binary|--data-urlencode|-F|--form|--json)$/
39
+ const CLI_PRINTERS = [
40
+ [/^gh auth token\b|^gh auth status\b.*(?:\s-t\b|--show-token)/, 'the GitHub CLI token'],
41
+ [/^gcloud auth (?:application-default )?print-(?:access|identity)-token\b/, 'a Google Cloud access token'],
42
+ [/^aws configure get\s+\S*(?:secret|token|key)/i, 'an AWS secret'],
43
+ [/^npm (?:config|c) get\s+\S*(?:_auth|token|password)/i, 'an npm auth token'],
44
+ ]
45
+
46
+ function globRegex(pattern, { shellGlob = false } = {}) {
47
+ let re = ''
48
+ for (let i = 0; i < pattern.length; i++) {
49
+ const c = pattern[i]
50
+ if (c === '*' && pattern[i + 1] === '*') { re += '.*'; i++ }
51
+ else if (c === '*') re += '[^/]*'
52
+ else if (c === '?') re += '[^/]'
53
+ else re += c.replace(/[.+^${}()|[\]\\]/g, '\\$&')
54
+ }
55
+ return new RegExp((shellGlob && !pattern.startsWith('.') ? '^(?!\\.)' : '^') + re + '$', 'i')
56
+ }
57
+
58
+ function expand(arg, cwd, home) {
59
+ let p = String(arg).replace(/\\/g, '/')
60
+ p = p.replace(/^~(?=\/|$)/, home).replace(/^(?:\$HOME|\$\{HOME\}|\$env:USERPROFILE|%USERPROFILE%)(?=\/|$)/i, home)
61
+ p = p.replace(/^(?:\$PWD|\$\{PWD\})(?=\/|$)/, cwd)
62
+ return path.posix.normalize(/^(?:\/|[a-zA-Z]:\/)/.test(p) ? p : `${cwd.replace(/\\/g, '/')}/${p}`)
63
+ }
64
+
65
+ // Whether `p` (absolute, forward slashes) matches a user pattern: a plain file name, or a path
66
+ // pattern that may start with ~ and use * and **.
67
+ function userMatch(p, patterns, home) {
68
+ return patterns.some(raw => {
69
+ const pat = raw.replace(/\\/g, '/').replace(/^~(?=\/|$)/, home)
70
+ if (!pat.includes('/')) return globRegex(pat).test(p.slice(p.lastIndexOf('/') + 1))
71
+ const re = globRegex(pat.replace(/^\*\*\//, ''))
72
+ return pat.startsWith('/') || /^[a-zA-Z]:/.test(pat) ? globRegex(pat).test(p) : new RegExp(`(?:^|/)${re.source.slice(1)}`, 'i').test(p)
73
+ })
74
+ }
75
+
76
+ function peek(file) {
77
+ try {
78
+ const fd = fs.openSync(file, 'r')
79
+ try {
80
+ const buf = Buffer.alloc(32768)
81
+ return buf.toString('utf8', 0, fs.readSync(fd, buf, 0, buf.length, 0))
82
+ } finally {
83
+ fs.closeSync(fd)
84
+ }
85
+ } catch { return null }
86
+ }
87
+
88
+ // The kind of secret the file is, or null. `p` is absolute with forward slashes.
89
+ function classifyPath(p, { home = os.homedir().replace(/\\/g, '/'), extra = [], allow = [], read = peek } = {}) {
90
+ if (allow.length && userMatch(p, allow, home)) return null
91
+ const base = p.slice(p.lastIndexOf('/') + 1)
92
+ const lower = p.toLowerCase()
93
+ const parent = lower.split('/').slice(-2, -1)[0]
94
+ if (/^\.env(?:\..+)?$/i.test(base) && !SAFE_ENV.test(base)) return 'an environment file'
95
+ if (/\.(?:pem|key|p12|pfx)$/i.test(base)) {
96
+ const text = /\.(?:pem|key)$/i.test(base) ? read(p) : null
97
+ return text && PUBLIC_PEM.test(text) && !/PRIVATE KEY/.test(text) ? null : 'a private key file'
98
+ }
99
+ if (/^id_(?:rsa|dsa|ecdsa|ed25519)/i.test(base) && !/\.pub$/i.test(base)) return 'an SSH private key'
100
+ if (parent === '.ssh' && !SSH_SAFE.test(base)) return 'an SSH private key'
101
+ if (/^(?:\.netrc|_netrc)$/i.test(base)) return 'netrc credentials'
102
+ if (/^(?:\.git-credentials|\.pgpass|\.vault-token)$/i.test(base)) return 'stored credentials'
103
+ if (/\/\.aws\/credentials$/.test(lower) || /\/\.aws\/sso\/cache\//.test(lower)) return 'AWS credentials'
104
+ if (/\/\.config\/gh\/hosts\.yml$/.test(lower)) return 'the GitHub CLI token'
105
+ if (/\/\.kube\/config$/.test(lower)) return 'Kubernetes credentials'
106
+ if (/\/\.azure\/accesstokens\.json$/.test(lower)) return 'Azure tokens'
107
+ if (/\/gcloud\/(?:credentials\.db|access_tokens\.db|application_default_credentials\.json|legacy_credentials\/)/.test(lower)) return 'Google Cloud credentials'
108
+ if (/^\/proc\/[^/]+\/environ$/.test(lower)) return 'a process environment'
109
+ for (const [name, content, label] of CONTENT_RULES) {
110
+ if (!name.test(lower)) continue
111
+ const text = read(p)
112
+ return text && content.test(text) ? label : null
113
+ }
114
+ if (extra.length && userMatch(p, extra, home)) return 'a file you listed in secretsGuard.extraPatterns'
115
+ return null
116
+ }
117
+
118
+ // classifyPath for an argument: a literal path, or a glob that could expand to a secret.
119
+ function classifyArg(arg, ctx, opts) {
120
+ if (!arg || typeof arg !== 'string') return null
121
+ const p = expand(arg, ctx.cwd, ctx.home)
122
+ const name = p.slice(p.lastIndexOf('/') + 1)
123
+ if (!/[*?[]/.test(name)) return classifyPath(p, opts)
124
+ const dir = p.slice(0, p.lastIndexOf('/'))
125
+ const likely = `${dir}/${name.replace(/[*?[\]]/g, 'x')}`
126
+ if (opts.extra && opts.extra.length && !(opts.allow && opts.allow.length && userMatch(likely, opts.allow, opts.home)) && userMatch(likely, opts.extra, opts.home)) {
127
+ return 'a file you listed in secretsGuard.extraPatterns'
128
+ }
129
+ // a bare * says nothing about which names it expands to; only a folder of secrets makes it a hit
130
+ if (/^[*?]+$/.test(p.slice(p.lastIndexOf('/') + 1)) && !SECRET_DIRS.test(dir)) return null
131
+ const re = globRegex(p.slice(p.lastIndexOf('/') + 1), { shellGlob: true })
132
+ for (const s of SAMPLES) {
133
+ if (!re.test(s)) continue
134
+ const found = classifyPath(`${dir}/${s}`, opts)
135
+ if (found) return found
136
+ }
137
+ return null
138
+ }
139
+
140
+ // A folder that holds secrets (~/.ssh, ~/.aws), for commands that read everything below it.
141
+ function classifyDir(arg, ctx, opts) {
142
+ const p = expand(arg, ctx.cwd, ctx.home).replace(/\/+$/, '')
143
+ return SECRET_DIRS.test(p) ? classifyArg(`${p}/*`, ctx, opts) : null
144
+ }
145
+
146
+ // The files a reader command names, in the places a file can be named.
147
+ function fileArgs(argv, b) {
148
+ const args = argv.slice(1)
149
+ if (b === 'sed' && args.some(a => /^-[a-zA-Z]*i/.test(a) || a.startsWith('--in-place'))) return []
150
+ const out = []
151
+ const takesPattern = PATTERN_FIRST.has(b) && !args.some(a => /^(?:-e|-f|--regexp|--file|--expression|-Pattern)(?:=|$)/i.test(a))
152
+ let seen = !takesPattern
153
+ const upload = b === 'curl' || b === 'wget'
154
+ for (let i = 0; i < args.length; i++) {
155
+ const a = args[i]
156
+ if (/^(?:\d*>>?|&>>?|>\||\d*>&.*)$/.test(a) || a === '<<<' || a === '<<') { i++; continue }
157
+ if (/^\d*>/.test(a)) continue
158
+ if (a === '<') { if (args[i + 1]) out.push(args[++i]); continue }
159
+ if (/^<[^<(]/.test(a)) { out.push(a.slice(1)); continue }
160
+ if ((VALUE_FLAGS[b] || []).includes(a)) { i++; continue }
161
+ if (upload) {
162
+ const m = /^(-T|--upload-file|--post-file|--body-file|-d|--data|--data-binary|--data-urlencode|-F|--form|--json)(?:=(.*))?$/.exec(a) || /^(-[dFT])(.+)$/.exec(a)
163
+ if (m && UPLOAD_FLAG.test(m[1])) {
164
+ const v = m[2] !== undefined ? m[2] : args[++i]
165
+ if (v == null) continue
166
+ if (/^(?:-T|--upload-file|--post-file|--body-file)$/.test(m[1])) out.push(v)
167
+ else {
168
+ const f = /(?:^|=)[@<](.+)$/.exec(v) || /^[\w-]+@(.+)$/.exec(v) || /^@(.+)$/.exec(v)
169
+ if (f) out.push(f[1].split(';')[0])
170
+ }
171
+ }
172
+ continue
173
+ }
174
+ if (a.startsWith('-') && a !== '-') {
175
+ const eq = a.indexOf('=')
176
+ if (a.startsWith('--') && eq > 0) out.push(a.slice(eq + 1))
177
+ continue
178
+ }
179
+ if (!seen) { seen = true; continue }
180
+ out.push(a)
181
+ }
182
+ if (COPIERS.has(b)) out.pop()
183
+ return b === 'scp' || b === 'rsync' ? out.filter(a => !/^[^/\\]{2,}:/.test(a)) : out
184
+ }
185
+
186
+ // A pipe straight into something that only counts or tests keeps the values out of the output.
187
+ function harmlessPipe(text, end) {
188
+ const m = /^\s*\|&?\s*([\w./-]+)((?:\s+-[\w-]+)*)/.exec(text.slice(end))
189
+ if (!m) return false
190
+ const b = shell.bin(m[1])
191
+ return b === 'wc' || (/^e?grep$/.test(b) && /(?:^|\s)-[a-zA-Z]*[qcl]/.test(m[2]))
192
+ }
193
+
194
+ function withoutRedirects(words) {
195
+ const out = []
196
+ for (let i = 0; i < words.length; i++) {
197
+ if (/^(?:\d*>>?|&>>?|>\||<)$/.test(words[i])) i++
198
+ else if (!/^\d*[<>]/.test(words[i])) out.push(words[i])
199
+ }
200
+ return out
201
+ }
202
+
203
+ // `env`, `printenv`, `export -p`, a bare `set`: the whole environment.
204
+ function dumpsEnvironment(text, mode) {
205
+ const bare = rest => rest.every(a => /^-[a-zA-Z0-9-]*$/.test(a) && a !== '-S')
206
+ let unwrapped = text
207
+ try { unwrapped = shell.unwrap(text, mode) } catch {}
208
+ for (const c of shell.lex(unwrapped, mode).cmds) {
209
+ const i = c.words.findIndex(w => !/^(?:sudo|doas|command|exec|nohup|time|builtin)$/.test(shell.bin(w.v)) && !(w.v.startsWith('-') && !shell.bin(w.v)))
210
+ if (i < 0) continue
211
+ const b = shell.bin(c.words[i].v)
212
+ const rest = withoutRedirects(c.words.slice(i + 1).map(w => w.v))
213
+ const dump = ((b === 'env' || b === 'printenv') && bare(rest)) ||
214
+ ((b === 'export' || b === 'declare' || b === 'typeset') && rest.length && rest.every(a => /^-[pxf]+$/.test(a)) && !rest.includes('-f')) ||
215
+ ((b === 'export' || b === 'set') && !rest.length) ||
216
+ (b === 'compgen' && rest.join(' ') === '-e') ||
217
+ (/^(?:get-childitem|gci|dir|ls)$/.test(b) && rest.some(a => /^env:\\?\*?$/i.test(a)) && !rest.some(a => /^env:\\?\w/i.test(a)))
218
+ if (dump && !harmlessPipe(unwrapped, c.e)) return true
219
+ }
220
+ return false
221
+ }
222
+
223
+ function findHit(argv, ctx, opts) {
224
+ const args = argv.slice(1)
225
+ const exec = args.findIndex(a => /^-(?:exec|execdir|ok|okdir)$/.test(a))
226
+ if (exec < 0 || !READERS.has(shell.bin(args[exec + 1] || ''))) return null
227
+ const starts = []
228
+ for (const a of args) { if (a.startsWith('-') || a === '(' || a === '!') break; starts.push(a) }
229
+ for (const s of starts) {
230
+ const hit = classifyDir(s, ctx, opts)
231
+ if (hit) return { label: hit, what: s }
232
+ }
233
+ for (let i = 0; i < args.length - 1; i++) {
234
+ if (!/^-(?:i?name|i?path|i?wholename)$/.test(args[i])) continue
235
+ const hit = classifyArg(args[i + 1], { ...ctx, cwd: '/x' }, opts)
236
+ if (hit) return { label: hit, what: args[i + 1] }
237
+ }
238
+ return null
239
+ }
240
+
241
+ // { rule, label, what } for the first secret the command would show, or null.
242
+ function classifyCommand(cmd, { cwd = process.cwd(), home = os.homedir().replace(/\\/g, '/'), extra = [], allow = [], read = peek, mode = 'sh' } = {}) {
243
+ const ctx = { cwd: cwd.replace(/\\/g, '/'), home }
244
+ const opts = { home, extra, allow, read }
245
+ const text = shell.prepare(String(cmd || ''), mode)
246
+ for (const c of shell.commands(text, mode)) {
247
+ if (c.remote) continue
248
+ const b = shell.bin(c.argv[0])
249
+ const joined = c.argv.join(' ')
250
+ if (b === 'find') {
251
+ const hit = findHit(c.argv, ctx, opts)
252
+ if (hit) return { rule: 'print-file', label: hit.label, what: hit.what }
253
+ }
254
+ for (const [re, label] of CLI_PRINTERS) if (re.test(joined)) return { rule: 'print-token', label, what: joined }
255
+ if (!READERS.has(b)) continue
256
+ for (const a of fileArgs(c.argv, b)) {
257
+ const label = classifyArg(a, ctx, opts) || (/^(?:grep|egrep|fgrep|rg|ag|ack|get-content|gc|type|cat|cp|scp|rsync)$/.test(b) ? classifyDir(a, ctx, opts) : null)
258
+ if (label) return { rule: b === 'cp' || b === 'scp' || b === 'rsync' || b === 'curl' || b === 'wget' ? 'copy-file' : 'print-file', label, what: a }
259
+ }
260
+ }
261
+ if (/\benv\b|printenv|export|declare|typeset|\bset\b|compgen|get-childitem|gci|\bdir\b|\bls\b/i.test(text) && dumpsEnvironment(text, mode)) {
262
+ return { rule: 'dump-env', label: 'every environment variable', what: 'the whole environment' }
263
+ }
264
+ return null
265
+ }
266
+
267
+ function classifyTool(input, opts) {
268
+ const t = input.tool_input || {}
269
+ const cwd = (input.cwd || process.cwd()).replace(/\\/g, '/')
270
+ const ctx = { cwd, home: opts.home }
271
+ if (input.tool_name === 'Read') {
272
+ const label = classifyArg(t.file_path, ctx, opts)
273
+ return label && { rule: 'read-file', label, what: t.file_path }
274
+ }
275
+ if (input.tool_name === 'Grep') {
276
+ const label = classifyArg(t.path, ctx, opts) || (t.glob ? classifyArg(`${t.path || '.'}/${t.glob}`, ctx, opts) : null)
277
+ return label && { rule: 'read-file', label, what: t.path || t.glob }
278
+ }
279
+ return null
280
+ }
281
+
282
+ function message(hit) {
283
+ return `[bulkhead] secrets-guard: this would show ${hit.label} (${hit.what}) to the model.\n` +
284
+ 'Ask the user for the specific value you need, or use it by name (for example $API_KEY or --env-file .env) without printing it.\n' +
285
+ ASK_USER
286
+ }
287
+
288
+ function handle(input) {
289
+ const home = os.homedir().replace(/\\/g, '/')
290
+ const opts = { home, extra: setting('secretsGuard.extraPatterns', []), allow: setting('secretsGuard.allow', []) }
291
+ const cmd = input.tool_input && input.tool_input.command
292
+ const hit = cmd
293
+ ? classifyCommand(cmd, { ...opts, cwd: input.cwd || process.cwd(), mode: shell.modeFor(input) })
294
+ : classifyTool(input, opts)
295
+ if (!hit) return
296
+ return blocked('secrets-guard', hit.rule, input, message(hit), cmd || hit.what)
297
+ }
298
+
299
+ if (require.main === module) runHook('secrets-guard', handle)
300
+
301
+ module.exports = { handle, classifyCommand, classifyTool, classifyPath, classifyArg, message, globRegex }
@@ -0,0 +1,73 @@
1
+ 'use strict'
2
+ const path = require('path')
3
+ const shell = require('./shell')
4
+
5
+ // The agent must not approve its own blocks. `bulkhead allow <code>` is for the user, in their
6
+ // own terminal, so a Bash tool call that runs it, or that writes the allow state files by hand,
7
+ // is blocked (basics, rule self-allow).
8
+
9
+ const NAMES = /^(?:bulkhead|claude-bulkhead|bulkhead-cli)(?:\.(?:js|cjs|mjs|cmd|ps1|exe))?$/i
10
+ const SCRIPT = /(?:^|[\\/])(?:bin[\\/](?:bulkhead|claude-bulkhead|bulkhead-cli)|src[\\/](?:cli|allow-cli|allow-once)\.js)$/i
11
+ const NODE = /^(?:node|nodejs|bun|deno|tsx|ts-node)$/i
12
+ const RUNNER = /^(?:npx|bunx|pnpx|pnpm|npm|yarn|bun|dlx|exec|x)$/i
13
+
14
+ const base = a => path.basename(String(a).replace(/\\/g, '/')).replace(/@[\w.^~-]+$/, '')
15
+ const isAllow = a => /^allow$/i.test(a)
16
+
17
+ // Whether one simple command is a run of `bulkhead allow`.
18
+ function runsAllow(argv) {
19
+ if (!argv.length) return false
20
+ const b = shell.bin(argv[0])
21
+ const rest = argv.slice(1)
22
+ if (NAMES.test(b)) return rest.slice(0, 4).some(isAllow)
23
+ if (NODE.test(b)) {
24
+ const i = rest.findIndex(a => SCRIPT.test(String(a).replace(/\\/g, '/')) || NAMES.test(base(a)))
25
+ return i >= 0 && rest.slice(i + 1, i + 4).some(isAllow)
26
+ }
27
+ if (RUNNER.test(b)) {
28
+ const i = rest.findIndex(a => NAMES.test(base(a)) || /^claude-bulkhead(?:@|$)/i.test(a))
29
+ return i >= 0 && rest.slice(i + 1, i + 4).some(isAllow)
30
+ }
31
+ // $BULKHEAD allow 7F3K, `which bulkhead` allow: a name that is only known at run time
32
+ return /^[$`]|^\$\(/.test(argv[0]) && rest.some(isAllow)
33
+ }
34
+
35
+ const READERS = /^(?:cat|ls|dir|type|head|tail|less|more|grep|egrep|rg|wc|stat|file|find|test|\[|get-content|gc|get-childitem|select-string|echo|printf)$/i
36
+ const REDIRECT = />/
37
+
38
+ // The folders that hold the allow state: the default one and the one BULKHEAD_STATE_DIR names.
39
+ function stateRe(stateDir) {
40
+ const esc = s => String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/[\\/]+/g, '[\\\\/]+')
41
+ const parts = ['bulkhead[\\\\/]+allow(?:[\\\\/]|\\b)']
42
+ if (stateDir) parts.push(`${esc(stateDir)}[\\\\/]+allow(?:[\\\\/]|\\b)`)
43
+ return new RegExp(parts.join('|'), 'i')
44
+ }
45
+
46
+ // Whether the text writes into (or runs anything but a plain read on) the allow state folder.
47
+ function touchesAllowState(src, mode, stateDir) {
48
+ const re = stateRe(stateDir)
49
+ if (!re.test(src)) return false
50
+ const text = shell.prepare(src, mode)
51
+ for (const seg of text.split(/\n|;|&&|\|\|/)) {
52
+ if (!re.test(seg)) continue
53
+ const cmds = shell.commands(seg, mode)
54
+ const reads = cmds.length > 0 && cmds.every(c => READERS.test(shell.bin(c.argv[0])))
55
+ if (!reads || REDIRECT.test(seg.replace(/\d?>&\d|\d?>\s*(?:\/dev\/null|\$null|nul)\b/gi, ' '))) return true
56
+ }
57
+ return false
58
+ }
59
+
60
+ // A message when the command approves a block itself, or null.
61
+ function check(cmd, { mode = 'sh', stateDir = process.env.BULKHEAD_STATE_DIR } = {}) {
62
+ const src = String(cmd || '')
63
+ if (!/allow/i.test(src)) return null
64
+ const prepared = shell.prepare(src, mode)
65
+ const hit = shell.commands(prepared, mode).find(c => runsAllow(c.argv))
66
+ if (hit || touchesAllowState(src, mode, stateDir)) {
67
+ return '[bulkhead] basics: the agent cannot approve its own blocks. `bulkhead allow <code>` and the allow state files are for the user.\n' +
68
+ 'Do not run it or edit the files. Tell the user what was blocked and the code from the block message; they run `bulkhead allow <code>` in their own terminal, then you retry the same command.'
69
+ }
70
+ return null
71
+ }
72
+
73
+ module.exports = { check, runsAllow, touchesAllowState }
@@ -0,0 +1,121 @@
1
+ 'use strict'
2
+ const os = require('os')
3
+ const { execFileSync } = require('child_process')
4
+ const { gitInfo } = require('./git-info')
5
+ const { assignOwners } = require('./sessions')
6
+ const { findMcp } = require('./mcp')
7
+ const { readStates, stateOf } = require('./session-state')
8
+
9
+ function homeShort(p, home = os.homedir()) {
10
+ if (!p) return null
11
+ const s = String(p)
12
+ return home && s.toLowerCase().startsWith(home.toLowerCase()) ? '~' + s.slice(home.length).replace(/\\/g, '/') : s.replace(/\\/g, '/')
13
+ }
14
+
15
+ // Cut from the start so the useful tail stays: "…/dev/api". Starts at a folder boundary when it can.
16
+ function fitPath(s, width) {
17
+ s = String(s == null ? '' : s)
18
+ if (width <= 0) return ''
19
+ if (s.length <= width) return s
20
+ if (width === 1) return '…'
21
+ const tail = s.slice(s.length - (width - 1))
22
+ const cut = tail.indexOf('/')
23
+ return '…' + (cut > 0 && cut < tail.length - 1 ? tail.slice(cut) : tail)
24
+ }
25
+
26
+ // "session 83183 in ~/dev/api on branch feat-x": how guards name another session in their messages.
27
+ // `s` needs pid and cwd; the branch is read from the folder unless the session already has one.
28
+ function describeSession(s, { home = os.homedir(), git = gitInfo } = {}) {
29
+ let branch = s.branch
30
+ let worktree = s.worktree
31
+ if (branch === undefined && s.cwd) {
32
+ try {
33
+ const g = git(s.cwd)
34
+ branch = g ? g.branch : null
35
+ worktree = g ? g.worktree : false
36
+ } catch { branch = null }
37
+ }
38
+ return `session ${s.pid}` + (s.cwd ? ` in ${homeShort(s.cwd, home)}` : '') +
39
+ (branch ? ` on branch ${branch}${worktree ? ' (worktree)' : ''}` : '')
40
+ }
41
+
42
+ // pid -> cwd for sessions with no session file. macOS: one lsof for all of them; Linux reads
43
+ // /proc in findSessions already; Windows has no cheap way, so those stay unknown.
44
+ function lookupCwds(pids, { platform = process.platform, run = execFileSync } = {}) {
45
+ const out = new Map()
46
+ if (platform !== 'darwin' || !pids.length) return out
47
+ let text = ''
48
+ try {
49
+ text = run('lsof', ['-a', '-d', 'cwd', '-Fn', '-p', pids.join(',')], { encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'], env: { ...process.env, LC_ALL: 'C' } })
50
+ } catch (e) { text = String((e && e.stdout) || '') }
51
+ let pid = 0
52
+ for (const line of text.split('\n')) {
53
+ if (line[0] === 'p') pid = Number(line.slice(1))
54
+ else if (line[0] === 'n' && pid) out.set(pid, line.slice(1))
55
+ }
56
+ return out
57
+ }
58
+
59
+ // Adds branch, worktree and the working/waiting state to each session, and the MCP report.
60
+ // Everything here reads small files; the only subprocess is the optional cwd lookup.
61
+ function enrich(sessions, snapshot, files, {
62
+ states = readStates(), git = gitInfo, cwds = lookupCwds,
63
+ } = {}) {
64
+ const missing = sessions.filter(s => !s.cwd).map(s => s.pid)
65
+ const found = missing.length ? cwds(missing) : new Map()
66
+ const cache = new Map()
67
+ const gitOf = dir => {
68
+ if (!cache.has(dir)) {
69
+ let g = null
70
+ try { g = git(dir) } catch {}
71
+ cache.set(dir, g)
72
+ }
73
+ return cache.get(dir)
74
+ }
75
+ const out = sessions.map(s => {
76
+ const cwd = s.cwd || found.get(s.pid) || null
77
+ const g = cwd ? gitOf(cwd) : null
78
+ const rec = stateOf(states, s, files.get(s.pid))
79
+ return {
80
+ ...s,
81
+ cwd,
82
+ branch: g ? g.branch : null,
83
+ worktree: g ? g.worktree : false,
84
+ state: rec ? rec.state : null,
85
+ stateReason: rec ? rec.reason : null,
86
+ stateSince: rec ? rec.since : null,
87
+ }
88
+ })
89
+ const owner = assignOwners(snapshot.procs, new Set(out.map(s => s.pid)))
90
+ const mcp = findMcp(snapshot.procs, owner, out)
91
+ for (const s of out) s.mcp = mcp.instances.filter(i => i.session === s.pid).map(i => ({ name: i.name, pid: i.pid, rss: i.rss }))
92
+ return { sessions: out, mcp: mcp.groups }
93
+ }
94
+
95
+ // Human text for the STATE column.
96
+ function stateLabel(s, now) {
97
+ if (!s.state) return '-'
98
+ const mins = Math.max(0, Math.floor((now - s.stateSince) / 60000))
99
+ const age = mins >= 1 ? ` ${mins >= 60 ? `${Math.floor(mins / 60)}h` : `${mins}m`}` : ''
100
+ if (s.state === 'working') return 'working'
101
+ if (s.state === 'permission') return (s.stateReason === 'input' ? 'needs input' : 'needs permission') + age
102
+ return 'waiting' + age
103
+ }
104
+
105
+ // Sessions that need you first (permission, then waiting the longest), then the rest in their order.
106
+ function needsUserFirst(sessions) {
107
+ const rank = s => (s.state === 'permission' ? 0 : s.state === 'waiting' ? 1 : 2)
108
+ return sessions.map((s, i) => [s, i]).sort((a, b) => rank(a[0]) - rank(b[0]) ||
109
+ (rank(a[0]) < 2 ? a[0].stateSince - b[0].stateSince : 0) || a[1] - b[1]).map(x => x[0])
110
+ }
111
+
112
+ function branchLabel(s) {
113
+ return s.branch ? s.branch + (s.worktree ? ' (wt)' : '') : s.worktree ? '(wt)' : '-'
114
+ }
115
+
116
+ // "3 copies of @playwright/mcp, 1.2 GB"
117
+ function mcpLine(g, size) {
118
+ return `${g.copies} copies of ${g.name}, ${size(g.rss)}`
119
+ }
120
+
121
+ module.exports = { homeShort, fitPath, describeSession, lookupCwds, enrich, stateLabel, needsUserFirst, branchLabel, mcpLine }
@@ -0,0 +1,176 @@
1
+ 'use strict'
2
+ const fs = require('fs')
3
+ const path = require('path')
4
+ const { spawn } = require('child_process')
5
+ const { runHook, setting, stateDir } = require('./hook')
6
+
7
+ // What each session is doing: working, waiting for you, or waiting on a permission prompt.
8
+ // The hooks in hooks/hooks.json call this script; it writes one small JSON file per session in
9
+ // <state dir>/session-state/ and reads nothing else (no process table, no git). `bulkhead status`
10
+ // and `top` join those files to live sessions by session id, so a file left by a dead session
11
+ // is never shown.
12
+ //
13
+ // Events (https://code.claude.com/docs/en/hooks):
14
+ // UserPromptSubmit, PostToolUse, PostToolUseFailure the turn is running: working
15
+ // Stop Claude finished its reply: waiting
16
+ // Notification permission_prompt a tool needs your approval: permission
17
+ // Notification elicitation_dialog, elicitation_url_dialog Claude asks you a question: permission (reason input)
18
+ // Notification idle_prompt still waiting; keeps the state it has
19
+ // SessionStart (not source compact) a new prompt is open: waiting
20
+ // SessionEnd the file is removed
21
+
22
+ const WORKING = 'working'
23
+ const WAITING = 'waiting'
24
+ const PERMISSION = 'permission'
25
+ // A PostToolUse hook runs async, so the last one of a turn can finish after Stop wrote "waiting".
26
+ // It only takes a session out of "waiting" when that state is older than this.
27
+ const WAITING_GRACE_MS = 3000
28
+ const STALE_FILE_MS = 2 * 24 * 3600 * 1000
29
+
30
+ function dirFor(base = stateDir()) {
31
+ return path.join(base, 'session-state')
32
+ }
33
+
34
+ // The session id becomes a file name, so only plain characters pass.
35
+ function safeId(id) {
36
+ return typeof id === 'string' && /^[\w.-]{1,128}$/.test(id) && !/^\.+$/.test(id) ? id : null
37
+ }
38
+
39
+ // What a hook payload means: { remove: true }, { state, reason, notify, soft } or null to ignore.
40
+ // `soft` means the event only confirms the state and must not replace a waiting one younger than the grace.
41
+ function classify(input) {
42
+ switch (input && input.hook_event_name) {
43
+ case 'UserPromptSubmit': return { state: WORKING, reason: null }
44
+ case 'PostToolUse':
45
+ case 'PostToolUseFailure': return { state: WORKING, reason: null, soft: true }
46
+ case 'Stop': return { state: WAITING, reason: 'reply', notify: true }
47
+ case 'SessionStart': return input.source === 'compact' ? null : { state: WAITING, reason: 'idle' }
48
+ case 'SessionEnd': return { remove: true }
49
+ case 'Notification':
50
+ switch (input.notification_type) {
51
+ case 'permission_prompt': return { state: PERMISSION, reason: 'permission', notify: true }
52
+ case 'elicitation_dialog':
53
+ case 'elicitation_url_dialog': return { state: PERMISSION, reason: 'input', notify: true }
54
+ case 'idle_prompt': return { state: WAITING, reason: 'idle', keep: true }
55
+ }
56
+ }
57
+ return null
58
+ }
59
+
60
+ function readRecord(file) {
61
+ try { return JSON.parse(fs.readFileSync(file, 'utf8')) } catch { return null }
62
+ }
63
+
64
+ function writeRecord(file, rec) {
65
+ fs.mkdirSync(path.dirname(file), { recursive: true })
66
+ const tmp = `${file}.${process.pid}.tmp`
67
+ fs.writeFileSync(tmp, JSON.stringify(rec))
68
+ fs.renameSync(tmp, file)
69
+ }
70
+
71
+ // Applies one event. Returns { wrote, notify } where notify says a notification should go out now.
72
+ function apply(base, input, { now = Date.now(), minIntervalMs = 30000 } = {}) {
73
+ const id = safeId(input && input.session_id)
74
+ const c = classify(input)
75
+ if (!id || !c) return { wrote: false, notify: false }
76
+ const dir = dirFor(base)
77
+ const file = path.join(dir, `${id}.json`)
78
+ if (c.remove) {
79
+ try { fs.unlinkSync(file) } catch {}
80
+ return { wrote: false, notify: false }
81
+ }
82
+ const prev = readRecord(file)
83
+ const same = prev && prev.state === c.state
84
+ if (c.keep && prev && (prev.state === WAITING || prev.state === PERMISSION)) return { wrote: false, notify: false }
85
+ if (same && !c.notify && (prev.reason || null) === (c.reason || null)) return { wrote: false, notify: false }
86
+ if (c.soft && prev && prev.state === WAITING && now - prev.since < WAITING_GRACE_MS) return { wrote: false, notify: false }
87
+ if (same && c.notify && prev.reason === c.reason) return { wrote: false, notify: false }
88
+ const rec = {
89
+ v: 1,
90
+ session: id,
91
+ state: c.state,
92
+ reason: c.reason,
93
+ since: same ? prev.since : now,
94
+ at: now,
95
+ cwd: input.cwd || (prev && prev.cwd) || null,
96
+ ppid: process.ppid,
97
+ notifiedAt: (prev && prev.notifiedAt) || 0,
98
+ }
99
+ const notify = Boolean(c.notify) && now - rec.notifiedAt >= minIntervalMs
100
+ if (notify) rec.notifiedAt = now
101
+ writeRecord(file, rec)
102
+ return { wrote: true, notify, record: rec }
103
+ }
104
+
105
+ // Files of sessions that ended without a SessionEnd (crash, kill) are removed after two days.
106
+ function prune(base, now = Date.now()) {
107
+ const dir = dirFor(base)
108
+ let names = []
109
+ try { names = fs.readdirSync(dir) } catch { return }
110
+ for (const n of names) {
111
+ try {
112
+ const f = path.join(dir, n)
113
+ if (now - fs.statSync(f).mtimeMs > STALE_FILE_MS) fs.unlinkSync(f)
114
+ } catch {}
115
+ }
116
+ }
117
+
118
+ // Map of session id -> record, for the join in status. Unreadable files are skipped.
119
+ function readStates(base = stateDir()) {
120
+ const dir = dirFor(base)
121
+ const out = new Map()
122
+ let names = []
123
+ try { names = fs.readdirSync(dir) } catch { return out }
124
+ for (const n of names.filter(x => x.endsWith('.json'))) {
125
+ const rec = readRecord(path.join(dir, n))
126
+ if (rec && rec.session && [WORKING, WAITING, PERMISSION].includes(rec.state) && Number(rec.since) > 0) out.set(rec.session, rec)
127
+ }
128
+ return out
129
+ }
130
+
131
+ // The record of a live session: by session id, else by the parent pid the hook saw. A record
132
+ // older than the process (the same id resumed in a new process) is ignored.
133
+ function stateOf(states, session, file, slackMs = 5000) {
134
+ let rec = file && file.sessionId ? states.get(file.sessionId) : null
135
+ if (!rec) rec = [...states.values()].find(r => r.ppid === session.pid && r.at >= session.start) || null
136
+ if (!rec || (session.start && rec.at < session.start - slackMs)) return null
137
+ return rec
138
+ }
139
+
140
+ // Spawns the user's notify command detached; it never blocks or throws into the hook.
141
+ function runNotify(command, env, spawnFn = spawn) {
142
+ if (typeof command !== 'string' || !command.trim()) return false
143
+ try {
144
+ const child = spawnFn(command, { shell: true, detached: true, stdio: 'ignore', windowsHide: true, env: { ...process.env, ...env } })
145
+ if (child && child.unref) child.unref()
146
+ if (child && child.on) child.on('error', () => {})
147
+ return true
148
+ } catch {
149
+ return false
150
+ }
151
+ }
152
+
153
+ function sessionPid(sessionId) {
154
+ try {
155
+ for (const [pid, f] of require('./sessions').readSessionFiles()) if (f.sessionId === sessionId) return pid
156
+ } catch {}
157
+ return process.ppid
158
+ }
159
+
160
+ function handle(input) {
161
+ const minIntervalMs = Math.max(0, Number(setting('notify.minIntervalSeconds', 30))) * 1000
162
+ const r = apply(stateDir(), input, { minIntervalMs })
163
+ if (input.hook_event_name === 'SessionStart') prune(stateDir())
164
+ const command = setting('notify.command', null)
165
+ if (r.notify && command) {
166
+ runNotify(command, {
167
+ BULKHEAD_SESSION_PROJECT: input.cwd || '',
168
+ BULKHEAD_SESSION_STATE: r.record.state,
169
+ BULKHEAD_SESSION_PID: String(sessionPid(input.session_id)),
170
+ })
171
+ }
172
+ }
173
+
174
+ if (require.main === module) runHook('session-state', handle)
175
+
176
+ module.exports = { classify, apply, readStates, stateOf, runNotify, prune, safeId, dirFor, WORKING, WAITING, PERMISSION, WAITING_GRACE_MS }