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
package/src/explain.js ADDED
@@ -0,0 +1,251 @@
1
+ 'use strict'
2
+ const os = require('os')
3
+ const path = require('path')
4
+ const { enabled, setting, withWayOut } = require('./hook')
5
+ const { allowHint } = require('./allow-hint')
6
+ const { useColor, paint, shortPath, option, numberOption, rejectArgs } = require('./cli-util')
7
+
8
+ const USAGE = `usage: bulkhead explain [--cwd DIR] [--session-id ID|PID] [--timeout MS] [--json] -- <command...>
9
+
10
+ Runs the command through every Bash guard the way the PreToolUse hook would, against the
11
+ sessions, processes, ports and git tree on this machine right now. Nothing is executed and
12
+ nothing is logged. For each guard it prints pass or block, the rule, who owns the thing that is
13
+ in the way, the message the agent would see, and how to allow it once or for good.
14
+ --session-id explains it as that session (a session id or its pid); without it the command is
15
+ judged as a new session that none of the running ones is. --timeout is the tool call's timeout
16
+ in milliseconds, which wait-guard reads (the Bash tool's default is 120000).`
17
+
18
+ // the order the hook runs them in (bash-guards.js)
19
+ const ORDER = require('./bash-guards').ORDER.map(([name]) => name)
20
+ const PREVIEW_SESSION = 'explain-preview'
21
+
22
+ // Reads the process table at most once, and only when a guard asks for it.
23
+ function lazy(fn) {
24
+ let v
25
+ let done = false
26
+ return () => (done ? v : ((done = true), (v = fn())))
27
+ }
28
+
29
+ function liveContext(opts = {}) {
30
+ const { readProcesses } = require('./processes')
31
+ const { readSessionFiles } = require('./sessions')
32
+ const { readMemory } = require('./memory')
33
+ const { listening } = require('./port-guard')
34
+ const files = lazy(() => readSessionFiles())
35
+ const snapshot = lazy(() => readProcesses())
36
+ return { files, snapshot, memory: () => readMemory(), probe: listening, mode: 'sh', ...opts }
37
+ }
38
+
39
+ // The session the command is judged as: { sessionId, pid } from --session-id (an id or a pid),
40
+ // or a new session no running one is.
41
+ function whoAmI(ctx, wanted) {
42
+ if (!wanted) {
43
+ // run from a Claude Code Bash tool: the session the shell belongs to
44
+ const f = ctx.files().get(Number(process.env.CLAUDE_PID))
45
+ return f && f.sessionId ? { sessionId: f.sessionId, pid: Number(process.env.CLAUDE_PID), given: false, inferred: true } : { sessionId: PREVIEW_SESSION, pid: null, given: false }
46
+ }
47
+ for (const [pid, f] of ctx.files()) {
48
+ if (f.sessionId === wanted || String(pid) === String(wanted)) return { sessionId: f.sessionId || String(pid), pid, given: true, cwd: f.cwd }
49
+ }
50
+ return { sessionId: String(wanted), pid: null, given: true, unknown: true }
51
+ }
52
+
53
+ // Session owning each process in the snapshot, to say whose `node` a kill by name would stop.
54
+ function killEvidence(command, ctx) {
55
+ try {
56
+ const kill = require('./kill-guard')
57
+ const { findSessions, assignOwners } = require('./sessions')
58
+ const name = require('./allow-hint').killName(command)
59
+ const snap = ctx.snapshot()
60
+ if (!name || !snap) return []
61
+ const sessions = findSessions(snap, ctx.files())
62
+ const owner = assignOwners(snap.procs, new Set(sessions.map(s => s.pid)))
63
+ const names = [name]
64
+ const groups = new Map()
65
+ for (const p of snap.procs) {
66
+ if (!kill.nameHits(p.name, names)) continue
67
+ const key = owner.get(p.pid) || 0
68
+ if (!groups.has(key)) groups.set(key, [])
69
+ groups.get(key).push(p.pid)
70
+ }
71
+ const lines = [...groups].map(([pid, pids]) => {
72
+ const s = sessions.find(x => x.pid === pid)
73
+ const list = `${pids.slice(0, 4).join(', ')}${pids.length > 4 ? ', ...' : ''}`
74
+ return `${name} pid ${list} ${s ? `runs in the session with pid ${s.pid} (${shortPath(s.cwd)})` : 'belongs to no Claude session'}`
75
+ })
76
+ return lines.slice(0, 6)
77
+ } catch {
78
+ return []
79
+ }
80
+ }
81
+
82
+ function ownerLine(owner, what) {
83
+ return `${what} belongs to the session with pid ${owner.pid} (${shortPath(owner.cwd)})`
84
+ }
85
+
86
+ // One check per guard; each returns null (pass) or { rule, message, evidence: [text] }.
87
+ const CHECKS = {
88
+ 'kill-guard': async (cmd, ctx) => {
89
+ const kill = require('./kill-guard')
90
+ // the same process-table context the hook uses, seen from the session explain speaks for;
91
+ // a new session (no pid) counts every running session as another one
92
+ const owners = require('./kill-owners').makeContext({
93
+ cwd: ctx.cwd, off: kill.RULES.filter(rule => !enabled(rule)),
94
+ snapshot: ctx.snapshot, files: ctx.files, self: () => (ctx.who && ctx.who.pid) || -1,
95
+ })
96
+ const hit = kill.classify(cmd, {
97
+ allowNames: setting('killGuard.allowNames', []), extraSharedNames: setting('killGuard.extraSharedNames', []),
98
+ }, ctx.mode, owners)
99
+ if (!hit) return null
100
+ const evidence = killEvidence(cmd, ctx)
101
+ return {
102
+ rule: hit.rule, message: `${kill.message(hit.why, process.platform, hit.rule)}`,
103
+ evidence: evidence.length ? evidence : [hit.why],
104
+ }
105
+ },
106
+ 'wait-guard': async (cmd, ctx) => {
107
+ const wait = require('./wait-guard')
108
+ const verdict = wait.judge(cmd, ctx.timeout, setting('waitGuard.limitSeconds', 180), ctx.mode)
109
+ if (!verdict) return null
110
+ return { rule: verdict.rule, message: wait.message(verdict), evidence: [`estimated wait ${verdict.est} s, the limit is ${verdict.maxSec} s`] }
111
+ },
112
+ 'memory-guard': async (cmd, ctx) => {
113
+ let mem = null
114
+ const hit = require('./memory-guard').decide(cmd, () => (mem = ctx.memory()), undefined, ctx.mode)
115
+ if (!hit) return null
116
+ const evidence = hit.rule === 'low-memory' && mem
117
+ ? [`${mem.freeGb.toFixed(1)} GB of ${mem.totalGb.toFixed(1)} GB RAM is free`]
118
+ : ['the command writes a slot file by hand']
119
+ return { ...hit, evidence }
120
+ },
121
+ 'port-guard': async (cmd, ctx) => {
122
+ const { inspect, checkKill } = require('./port-guard')
123
+ const who = ctx.who
124
+ const common = {
125
+ probe: ctx.probe, snapshot: ctx.snapshot, files: ctx.files, mode: ctx.mode,
126
+ // the session the command is judged as; a new session when none was named, so every running one is "another"
127
+ self: procs => who.pid || (!who.given && require('./sessions').ownSession(procs)) || -1,
128
+ }
129
+ const hit = await inspect(cmd, { cwd: ctx.cwd, ...common })
130
+ if (hit) return { rule: hit.rule, message: hit.message, evidence: [ownerLine(hit.owner, `${hit.name} pid ${hit.pid} on port ${hit.port}`)] }
131
+ const kill = await checkKill(cmd, common)
132
+ if (kill) return { rule: kill.rule, message: kill.message, evidence: [ownerLine(kill.owner, `${kill.name} pid ${kill.pid} on port ${kill.port}`)] }
133
+ return null
134
+ },
135
+ 'checkout-guard': async (cmd, ctx) => {
136
+ const hit = require('./checkout-guard').classify(cmd, {
137
+ cwd: ctx.cwd, sessionId: ctx.who.sessionId, mode: ctx.mode, files: ctx.files, platform: ctx.platform, home: ctx.home, isAlive: ctx.isAlive, run: ctx.run,
138
+ })
139
+ if (!hit) return null
140
+ const evidence = hit.owner ? [ownerLine(hit.owner, 'the tree')] : [hit.rule]
141
+ return { rule: hit.rule, message: hit.why, evidence }
142
+ },
143
+ 'disk-guard': async (cmd, ctx) => {
144
+ const hit = require('./disk-guard').check(cmd, { mode: ctx.mode })
145
+ return hit ? { rule: hit.rule, message: hit.why, evidence: [hit.rule] } : null
146
+ },
147
+ 'secrets-guard': async (cmd, ctx) => {
148
+ const secrets = require('./secrets-guard')
149
+ const hit = secrets.classifyCommand(cmd, {
150
+ cwd: ctx.cwd, mode: ctx.mode, extra: setting('secretsGuard.extraPatterns', []), allow: setting('secretsGuard.allow', []),
151
+ })
152
+ return hit ? { rule: hit.rule, message: secrets.message(hit), evidence: [`${hit.label}: ${hit.what}`] } : null
153
+ },
154
+ basics: async (cmd, ctx) => {
155
+ const basics = require('./basics')
156
+ const why = basics.check(cmd, { cwd: ctx.cwd, mode: ctx.mode, run: ctx.run })
157
+ if (!why) return null
158
+ const rule = basics.ruleOf(why)
159
+ return { rule, message: why, evidence: [why.split('\n')[0].replace(/^\[bulkhead\] basics: /, '')] }
160
+ },
161
+ }
162
+
163
+ // Every guard's verdict on the command, in the order the hooks run them.
164
+ async function explain(command, opts = {}) {
165
+ const ctx = liveContext(opts)
166
+ ctx.cwd = path.resolve(opts.cwd || process.cwd())
167
+ ctx.who = whoAmI(ctx, opts.sessionId)
168
+ const guards = []
169
+ for (const guard of ORDER) {
170
+ if (!enabled(guard)) {
171
+ guards.push({ guard, status: 'off' })
172
+ continue
173
+ }
174
+ let hit = null
175
+ try { hit = await CHECKS[guard](command, ctx) } catch (e) { guards.push({ guard, status: 'error', error: e.message }); continue }
176
+ if (!hit) {
177
+ guards.push({ guard, status: 'pass' })
178
+ continue
179
+ }
180
+ const hint = allowHint(guard, hit.rule, { command, message: hit.message })
181
+ const message = hit.rule === 'self-allow' ? hit.message : withWayOut(hit.message, { code: '<code>', json: hint.json })
182
+ guards.push({
183
+ guard, status: 'block', rule: hit.rule, evidence: hit.evidence, message,
184
+ allowOnce: hit.rule !== 'self-allow', config: hint.json, disable: hint.disable,
185
+ })
186
+ }
187
+ return { command, cwd: ctx.cwd, session: ctx.who, blocked: guards.some(g => g.status === 'block'), guards }
188
+ }
189
+
190
+ function render(r, { color = false, home = os.homedir() } = {}) {
191
+ const out = []
192
+ const b = s => paint(color, 'bold', s)
193
+ out.push(`${b('command')} ${r.command}`)
194
+ out.push(`${b('folder')} ${shortPath(r.cwd, home)}`)
195
+ out.push(`${b('as')} ${r.session.given ? `session ${r.session.sessionId}${r.session.pid ? ` (pid ${r.session.pid})` : ' (not running)'}` : r.session.inferred ? `this shell's session (pid ${r.session.pid})` : 'a new session (--session-id names one)'}`)
196
+ const blockers = r.guards.filter(g => g.status === 'block')
197
+ out.push(`${b('verdict')} ${blockers.length ? paint(color, 'red', `BLOCKED by ${blockers.map(g => `${g.guard} ${g.rule}`).join(', ')}`) : paint(color, 'green', 'passes every guard')}`)
198
+ out.push('')
199
+ for (const g of r.guards) {
200
+ if (g.status === 'pass') out.push(`${g.guard.padEnd(15)} ${paint(color, 'green', 'pass')}`)
201
+ else if (g.status === 'off') out.push(`${g.guard.padEnd(15)} ${paint(color, 'gray', 'off (turned off in the config or BULKHEAD_DISABLE)')}`)
202
+ else if (g.status === 'error') out.push(`${g.guard.padEnd(15)} ${paint(color, 'yellow', `could not be checked: ${g.error}`)}`)
203
+ else {
204
+ out.push(`${g.guard.padEnd(15)} ${paint(color, 'red', 'BLOCK')} rule ${g.rule}`)
205
+ for (const e of g.evidence) out.push(` evidence ${e}`)
206
+ out.push(' the agent sees:')
207
+ for (const line of g.message.split('\n')) out.push(` ${line}`)
208
+ if (g.allowOnce) out.push(' allow once the user runs `bulkhead allow <code>` in their terminal; the code is in the real block message')
209
+ if (g.config) out.push(` allow always ${g.config} in .claude/bulkhead.json`)
210
+ if (g.disable) out.push(` or BULKHEAD_DISABLE=${g.disable}`)
211
+ if (!g.allowOnce) out.push(' not allowable: only the user can approve, and not through the agent')
212
+ }
213
+ out.push('')
214
+ }
215
+ return out.join('\n').replace(/\n+$/, '')
216
+ }
217
+
218
+ // Everything after `--` is the command: several words are quoted back as a shell line.
219
+ function commandFrom(args) {
220
+ const sep = args.indexOf('--')
221
+ const words = sep >= 0 ? args.slice(sep + 1) : null
222
+ if (!words || !words.length) return null
223
+ if (words.length === 1) return words[0]
224
+ return words.map(a => (/^[\w@%+=:,./-]+$/.test(a) ? a : `'${a.replace(/'/g, `'\\''`)}'`)).join(' ')
225
+ }
226
+
227
+ async function main(args, { stdout = process.stdout, stderr = process.stderr, env = process.env, context } = {}) {
228
+ if (args.includes('--help') || args.includes('-h')) return stdout.write(USAGE + '\n')
229
+ const sep = args.indexOf('--')
230
+ const opts = sep >= 0 ? args.slice(0, sep) : args
231
+ if (rejectArgs('explain', opts, { flags: ['json'], valued: ['cwd', 'session-id', 'timeout'] }, stderr, USAGE)) return
232
+ const command = commandFrom(args)
233
+ if (!command) {
234
+ stderr.write(USAGE + '\n')
235
+ process.exitCode = 1
236
+ return
237
+ }
238
+ const cwd = option(opts, 'cwd')
239
+ const sessionId = option(opts, 'session-id')
240
+ const timeout = numberOption(opts, 'timeout')
241
+ if (timeout.error) {
242
+ stderr.write(`bulkhead explain: ${timeout.error}\n`)
243
+ process.exitCode = 1
244
+ return
245
+ }
246
+ const r = await explain(command, { cwd: typeof cwd === 'string' ? cwd : undefined, sessionId: typeof sessionId === 'string' ? sessionId : undefined, timeout: timeout.value, ...context })
247
+ if (opts.includes('--json')) return stdout.write(JSON.stringify(r, null, 2) + '\n')
248
+ stdout.write(render(r, { color: useColor(stdout, env) }) + '\n')
249
+ }
250
+
251
+ module.exports = { main, explain, render, commandFrom, USAGE, ORDER }
@@ -0,0 +1,55 @@
1
+ 'use strict'
2
+ const fs = require('fs')
3
+ const path = require('path')
4
+
5
+ // Branch and worktree of a folder, from the files in .git. No git process: this runs for every
6
+ // session on every `bulkhead status` / `top` refresh.
7
+
8
+ const realIo = {
9
+ read: p => { try { return fs.readFileSync(p, 'utf8') } catch { return null } },
10
+ isDir: p => { try { return fs.statSync(p).isDirectory() } catch { return false } },
11
+ isFile: p => { try { return fs.statSync(p).isFile() } catch { return false } },
12
+ }
13
+
14
+ // "ref: refs/heads/feat/x" is a branch; anything else is a detached commit.
15
+ function parseHead(text) {
16
+ const t = String(text || '').trim()
17
+ const m = /^ref:\s*refs\/heads\/(.+)$/.exec(t)
18
+ if (m) return { branch: m[1], detached: false }
19
+ if (/^ref:/.test(t)) return { branch: t.replace(/^ref:\s*/, ''), detached: false }
20
+ return /^[0-9a-f]{7,64}$/i.test(t) ? { branch: t.slice(0, 7), detached: true } : null
21
+ }
22
+
23
+ function parseGitFile(text) {
24
+ const m = /^gitdir:\s*(.+)$/m.exec(String(text || ''))
25
+ return m ? m[1].trim() : null
26
+ }
27
+
28
+ // A linked worktree's gitdir is <repo>/.git/worktrees/<name>; a submodule's is .../modules/<name>.
29
+ const LINKED = /\/worktrees\/[^/]+\/?$/
30
+
31
+ function head(file, io) {
32
+ return parseHead(io.read(file)) || { branch: null, detached: false }
33
+ }
34
+
35
+ // { root, branch, detached, worktree } for the nearest repository at or above `dir`, or null.
36
+ function gitInfo(dir, io = realIo) {
37
+ if (!dir) return null
38
+ let cur = path.resolve(dir)
39
+ for (let i = 0; i < 40; i++) {
40
+ const dot = path.join(cur, '.git')
41
+ if (io.isDir(dot)) return { root: cur, worktree: false, ...head(path.join(dot, 'HEAD'), io) }
42
+ if (io.isFile(dot)) {
43
+ const gitdir = parseGitFile(io.read(dot))
44
+ if (!gitdir) return { root: cur, worktree: false, branch: null, detached: false }
45
+ const abs = path.resolve(cur, gitdir)
46
+ return { root: cur, worktree: LINKED.test(gitdir.replace(/\\/g, '/')), ...head(path.join(abs, 'HEAD'), io) }
47
+ }
48
+ const up = path.dirname(cur)
49
+ if (up === cur) return null
50
+ cur = up
51
+ }
52
+ return null
53
+ }
54
+
55
+ module.exports = { gitInfo, parseHead, parseGitFile }
package/src/gitargs.js ADDED
@@ -0,0 +1,82 @@
1
+ 'use strict'
2
+ const path = require('path')
3
+ const shell = require('./shell')
4
+
5
+ // Options that go before the git subcommand and take a value in the next word.
6
+ const GLOBAL_VALUED = new Set(['-C', '-c', '--git-dir', '--work-tree', '--namespace', '--config-env', '--attr-source', '--super-prefix', '--exec-path'])
7
+
8
+ // git [global options] <sub> args. Takes the words of one command; returns null for anything that
9
+ // is not git. dir is where git runs (-C applied); workTree and gitDir are set by --work-tree and
10
+ // --git-dir (or GIT_WORK_TREE and GIT_DIR written in front of the command, see `env`); opts are
11
+ // those two as absolute options to hand to a git run that must see the same repository.
12
+ function gitCall(t, cwd, env = {}) {
13
+ if (shell.bin(t[0] || '') !== 'git') return null
14
+ let dir = cwd
15
+ let workTree = env.GIT_WORK_TREE ? path.resolve(cwd, env.GIT_WORK_TREE) : null
16
+ let gitDir = env.GIT_DIR ? path.resolve(cwd, env.GIT_DIR) : null
17
+ let bare = false
18
+ let i = 1
19
+ for (; i < t.length && t[i].startsWith('-') && t[i] !== '-'; i++) {
20
+ const a = t[i]
21
+ const eq = a.indexOf('=')
22
+ const name = a.startsWith('--') && eq > 0 ? a.slice(0, eq) : a
23
+ const inline = a.startsWith('--') && eq > 0
24
+ const value = () => (inline ? a.slice(eq + 1) : t[++i])
25
+ const v = GLOBAL_VALUED.has(name) && (inline || name !== '--exec-path') ? value() : null
26
+ if (name === '-C' && v) dir = path.resolve(dir, v)
27
+ else if (name === '--git-dir' && v) gitDir = path.resolve(dir, v)
28
+ else if (name === '--work-tree' && v) workTree = path.resolve(dir, v)
29
+ else if (name === '--bare') bare = true
30
+ }
31
+ const opts = []
32
+ if (gitDir) opts.push(`--git-dir=${gitDir}`)
33
+ if (workTree) opts.push(`--work-tree=${workTree}`)
34
+ return { dir, sub: t[i], args: t.slice(i + 1), workTree, gitDir, bare, opts }
35
+ }
36
+
37
+ // The working tree a call acts on: the one --work-tree names, the folder git runs in when only
38
+ // --git-dir is given (git takes the current directory as the tree then), else git rev-parse.
39
+ function treeOf(call, run) {
40
+ if (call.bare) throw new Error('bare repository')
41
+ if (call.workTree) return call.workTree
42
+ if (call.gitDir) return call.dir
43
+ return run(['rev-parse', '--show-toplevel'], call.dir)
44
+ }
45
+
46
+ // Short-flag clusters and long options of one git subcommand. valuedShort lists the single letters
47
+ // that take a value (the rest of the cluster, or the next word); valuedLong the long names that
48
+ // take one in the next word. Returns { short: Set of letters, long: Set of names without --,
49
+ // pos: words that are not options (everything after -- included), after: the words after -- }.
50
+ function parseArgs(args, { valuedShort = '', valuedLong = [] } = {}) {
51
+ const short = new Set()
52
+ const long = new Set()
53
+ const pos = []
54
+ const after = []
55
+ const valued = new Set(valuedLong)
56
+ for (let i = 0; i < args.length; i++) {
57
+ const a = args[i]
58
+ if (a === '--') { after.push(...args.slice(i + 1)); pos.push(...args.slice(i + 1)); break }
59
+ if (a.startsWith('--')) {
60
+ const eq = a.indexOf('=')
61
+ const name = eq > 0 ? a.slice(2, eq) : a.slice(2)
62
+ long.add(name)
63
+ if (eq < 0 && valued.has(name)) i++
64
+ } else if (a.length > 1 && a[0] === '-') {
65
+ for (let k = 1; k < a.length; k++) {
66
+ short.add(a[k])
67
+ if (valuedShort.includes(a[k])) { if (k === a.length - 1) i++; break }
68
+ }
69
+ } else pos.push(a)
70
+ }
71
+ return { short, long, pos, after }
72
+ }
73
+
74
+ // GIT_WORK_TREE=/x GIT_DIR=/x/.git git ...: the assignments are dropped from the words of a
75
+ // command, so they are read from its text.
76
+ function gitEnv(text) {
77
+ const env = {}
78
+ for (const m of String(text).matchAll(/(?:^|[\s;&|(])GIT_(WORK_TREE|DIR)=("[^"]*"|'[^']*'|[^\s;&|)]+)/g)) env[`GIT_${m[1]}`] = m[2].replace(/^["']|["']$/g, '')
79
+ return env
80
+ }
81
+
82
+ module.exports = { gitCall, gitEnv, treeOf, parseArgs }
package/src/hook.js CHANGED
@@ -39,19 +39,47 @@ function log(name, line) {
39
39
  try {
40
40
  const dir = stateDir()
41
41
  fs.mkdirSync(dir, { recursive: true })
42
- fs.appendFileSync(path.join(dir, 'guards.log'), `${new Date().toISOString()} ${name} ${line}\n`)
42
+ const file = path.join(dir, 'guards.log')
43
+ const text = `${new Date().toISOString()} ${name} ${line}\n`
44
+ let size = 0
45
+ try { size = fs.statSync(file).size } catch {}
46
+ // kept to about 1 MB, the previous MB in guards.log.1
47
+ if (size + text.length > 1024 * 1024) require('./events').rotate(file, 1024 * 1024 - text.length)
48
+ fs.appendFileSync(file, text)
43
49
  } catch {}
44
50
  }
45
51
 
46
- // Records a block in guards.log and events.jsonl and returns the hook result that stops the call.
47
52
  // One line for the agent and the person reading it: a block can be intentional, and the answer
48
- // to that is a question to the user, not a way around the guard.
49
- const ASK_USER = 'If the user wants this on purpose, ask them: they can allow it in .claude/bulkhead.json (see docs/configuration.md).'
53
+ // to that is a question to the user, not a way around the guard. <code> is the allow-once code
54
+ // of this block; blocked() fills it in, and adds the config snippet of the rule that fired.
55
+ const ASK_USER = 'If the user wants this on purpose, ask them to run `bulkhead allow <code>` in their own terminal (allows it once), or allow it in .claude/bulkhead.json.'
56
+
57
+ // The block text with the one-line way out: the allow-once command and the exact config snippet.
58
+ function withWayOut(message, { code = null, json = null } = {}) {
59
+ const once = code ? `run \`bulkhead allow ${code}\` in their own terminal (allows it once)` : null
60
+ const perm = json ? `set ${json} in .claude/bulkhead.json` : null
61
+ const how = [once, perm].filter(Boolean).join(', or ')
62
+ const line = how ? `If the user wants this on purpose, ask them to ${how}.` : ''
63
+ const body = message.includes(ASK_USER) ? message.split(ASK_USER).join(line).replace(/\n+$/, '') : message
64
+ return line && !message.includes(ASK_USER) ? `${body}\n${line}` : body
65
+ }
50
66
 
67
+ // Records a block in guards.log and events.jsonl and returns the hook result that stops the call.
68
+ // Returns nothing when the user allowed this exact command once (bulkhead allow <code>).
51
69
  function blocked(name, rule, input, message, command = input && input.tool_input && input.tool_input.command) {
70
+ const cwd = input && input.cwd
71
+ const allow = rule === 'self-allow' ? null : require('./allow-once')
72
+ if (allow && allow.consume(name, rule, command, cwd)) {
73
+ log(name, `allowed once ${rule}: ${oneLine(command || '')}`)
74
+ return undefined
75
+ }
52
76
  log(name, `blocked ${rule}: ${oneLine(command || '')}`)
53
77
  recordEvent(name, rule, input, command)
54
- return { block: message }
78
+ let code = null
79
+ if (allow) code = allow.issue({ guard: name, rule, command, cwd, session: input && input.session_id })
80
+ let json = null
81
+ try { json = require('./allow-hint').allowHint(name, rule, { command, message }).json } catch {}
82
+ return { block: withWayOut(message, { code, json }) }
55
83
  }
56
84
 
57
85
  function recordEvent(name, rule, input = {}, command = null, extra = {}) {
@@ -62,6 +90,8 @@ function recordEvent(name, rule, input = {}, command = null, extra = {}) {
62
90
  session: input.session_id || null,
63
91
  project: process.env.CLAUDE_PROJECT_DIR || input.cwd || null,
64
92
  command,
93
+ // set by `bulkhead hook --agent <name>`; absent for Claude Code
94
+ ...(process.env.BULKHEAD_AGENT ? { agent: process.env.BULKHEAD_AGENT } : {}),
65
95
  ...extra,
66
96
  })
67
97
  }
@@ -75,6 +105,8 @@ function oneLine(s, max = 300) {
75
105
  // { output: object } to print JSON, or a promise of either. Any error lets the call through.
76
106
  function runHook(name, handler) {
77
107
  if (!enabled(name)) return
108
+ // process reads give up sooner inside a hook: a slow ps lets the tool call through instead of stalling it
109
+ process.env.BULKHEAD_IN_HOOK = '1'
78
110
  let raw = ''
79
111
  process.stdin.setEncoding('utf8')
80
112
  process.stdin.on('data', d => (raw += d))
@@ -91,4 +123,4 @@ function runHook(name, handler) {
91
123
  }
92
124
 
93
125
  module.exports = {
94
- ASK_USER, runHook, enabled, setting, envNumber, log, oneLine, stateDir, blocked, recordEvent }
126
+ ASK_USER, withWayOut, runHook, enabled, setting, envNumber, log, oneLine, stateDir, blocked, recordEvent }