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/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 }
|
package/src/git-info.js
ADDED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 }
|