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,162 @@
1
+ 'use strict'
2
+ const path = require('path')
3
+ const { option } = require('./cli-util')
4
+
5
+ // `bulkhead hook --agent <name>`: the Bash guards for coding agents other than Claude Code. The
6
+ // agent pipes its pre-tool payload on stdin; the adapter maps it to the shape the guards read
7
+ // ({ tool_name, tool_input: { command }, cwd, session_id }), runs the same guards as the Claude
8
+ // Code PreToolUse hook, and answers in the agent's own block format. Anything unexpected lets
9
+ // the command through, and Copilot and Cursor treat a crash or bad output as a denial, so those
10
+ // two always get an explicit allow.
11
+
12
+ const str = v => (typeof v === 'string' && v ? v : null)
13
+ const obj = v => (v && typeof v === 'object' && !Array.isArray(v) ? v : null)
14
+ const json = v => JSON.stringify(v)
15
+
16
+ function asObject(v) {
17
+ if (typeof v !== 'string') return obj(v)
18
+ try { return obj(JSON.parse(v)) } catch { return null }
19
+ }
20
+
21
+ const sh = (command, cwd, session, extra = {}) =>
22
+ command ? { tool_name: 'Bash', tool_input: { command, ...extra }, cwd: cwd || undefined, session_id: session || undefined } : null
23
+
24
+ const powershell = input => (input ? { ...input, tool_name: 'PowerShell' } : null)
25
+
26
+ // Cline's run_commands takes a string, a list, { commands }, { command, args } or { cmd }.
27
+ function clineCommands(input) {
28
+ const quote = a => (/^[\w@%+=:,./-]+$/.test(a) ? a : `'${a.replace(/'/g, `'\\''`)}'`)
29
+ const one = c => (typeof c === 'string' ? c : obj(c) && str(c.command) ? [c.command, ...(Array.isArray(c.args) ? c.args.map(String) : [])].map(quote).join(' ') : null)
30
+ let list = []
31
+ if (typeof input === 'string') list = [input]
32
+ else if (Array.isArray(input)) list = input
33
+ else if (obj(input)) {
34
+ if ('commands' in input) list = Array.isArray(input.commands) ? input.commands : [input.commands]
35
+ else if ('command' in input) list = [input]
36
+ else if ('cmd' in input) list = [input.cmd]
37
+ }
38
+ return list.map(one).filter(Boolean).join('\n')
39
+ }
40
+
41
+ const resolveDir = (cwd, dir) => (str(dir) ? path.resolve(cwd || process.cwd(), dir) : cwd)
42
+
43
+ // How each agent's payload maps and how it wants a block (and, where silence is unsafe, an allow)
44
+ // answered. read() returns null for anything that is not a shell command.
45
+ const ADAPTERS = {
46
+ // https://developers.openai.com/codex/hooks: PreToolUse, tool_name "Bash", tool_input.command
47
+ codex: {
48
+ read: p => (p.tool_name === 'Bash' ? sh(str(obj(p.tool_input) && p.tool_input.command), p.cwd, p.session_id) : null),
49
+ block: msg => ({ out: json({ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: msg } }) }),
50
+ },
51
+ // https://github.com/google-gemini/gemini-cli/blob/main/docs/hooks/reference.md: BeforeTool, run_shell_command
52
+ gemini: {
53
+ read: p => {
54
+ const ti = obj(p.tool_input)
55
+ if (p.tool_name !== 'run_shell_command' || !ti) return null
56
+ return sh(str(ti.command), resolveDir(p.cwd, ti.dir_path), p.session_id, ti.is_background ? { run_in_background: true } : {})
57
+ },
58
+ block: msg => ({ out: json({ decision: 'deny', reason: msg }) }),
59
+ },
60
+ // https://docs.github.com/en/copilot/reference/hooks-configuration: preToolUse, camelCase or
61
+ // (PreToolUse) snake_case payload; toolArgs may be a JSON string
62
+ copilot: {
63
+ read: p => {
64
+ const name = String(p.toolName || p.tool_name || '').toLowerCase()
65
+ if (name !== 'bash' && name !== 'powershell') return null
66
+ const args = asObject(p.toolArgs !== undefined ? p.toolArgs : p.tool_input)
67
+ const input = sh(str(args && args.command), p.cwd, p.sessionId || p.session_id)
68
+ return name === 'powershell' ? powershell(input) : input
69
+ },
70
+ block: msg => ({ out: json({ permissionDecision: 'deny', permissionDecisionReason: msg }) }),
71
+ allow: '',
72
+ },
73
+ // https://cursor.com/docs/hooks: beforeShellExecution { command, cwd }, answer { permission }
74
+ cursor: {
75
+ read: p => sh(str(p.command), str(p.cwd) || (Array.isArray(p.workspace_roots) && str(p.workspace_roots[0])), p.conversation_id),
76
+ block: msg => ({ out: json({ permission: 'deny', user_message: msg.split('\n')[0], agent_message: msg }) }),
77
+ allow: json({ permission: 'allow' }),
78
+ },
79
+ // Windsurf (Cascade) pre_run_command { tool_info: { command_line, cwd } }; exit 2 blocks
80
+ windsurf: {
81
+ read: p => {
82
+ const ti = obj(p.tool_info)
83
+ return ti ? sh(str(ti.command_line), ti.cwd, p.trajectory_id) : null
84
+ },
85
+ block: msg => ({ err: msg, code: 2 }),
86
+ },
87
+ // https://docs.factory.ai/reference/hooks-reference: PreToolUse, tool_name "Execute"
88
+ droid: {
89
+ read: p => (p.tool_name === 'Execute' ? sh(str(obj(p.tool_input) && p.tool_input.command), p.cwd, p.session_id) : null),
90
+ block: msg => ({ out: json({ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: msg } }) }),
91
+ },
92
+ // https://docs.devin.ai/cli/extensibility/hooks/overview.md: PreToolUse, tool_name "exec"; exit 2 blocks
93
+ devin: {
94
+ read: p => (/^(?:exec|execute|bash|shell)$/i.test(String(p.tool_name)) ? sh(str(obj(p.tool_input) && p.tool_input.command), p.cwd, p.session_id) : null),
95
+ block: msg => ({ err: msg, code: 2 }),
96
+ },
97
+ // https://github.com/cline/cline/blob/main/sdk/examples/hooks/README.md: PreToolUse file hook, run_commands
98
+ cline: {
99
+ read: p => {
100
+ const call = obj(p.tool_call)
101
+ if (!call || call.name !== 'run_commands') return null
102
+ return sh(clineCommands(call.input), Array.isArray(p.workspaceRoots) ? p.workspaceRoots[0] : null, p.taskId)
103
+ },
104
+ block: msg => ({ out: json({ cancel: true, errorMessage: msg }) }),
105
+ allow: '{}',
106
+ },
107
+ // The opencode and Amp plugins that `bulkhead setup` writes pipe { command, cwd, session_id }
108
+ // and turn exit code 2 into a rejected tool call.
109
+ opencode: { read: p => sh(str(p.command), p.cwd, p.session_id), block: msg => ({ err: msg, code: 2 }) },
110
+ amp: { read: p => sh(str(p.command), p.cwd, p.session_id), block: msg => ({ err: msg, code: 2 }) },
111
+ }
112
+
113
+ const AGENT_NAMES = Object.keys(ADAPTERS)
114
+
115
+ // The same guards, in the same order, as the Claude Code hook (bash-guards.js): the first block wins.
116
+ async function judge(input) {
117
+ const r = await require('./bash-guards').judge(input, { rewrite: false })
118
+ return r && r.block ? r.block : null
119
+ }
120
+
121
+ // raw: the payload text. Returns { out, err, code } to write and exit with; never throws.
122
+ async function run(agent, raw) {
123
+ const adapter = ADAPTERS[agent]
124
+ const allow = { out: adapter.allow || '', code: 0 }
125
+ try {
126
+ const input = adapter.read(JSON.parse(raw || '{}'))
127
+ if (!input) return allow
128
+ process.env.BULKHEAD_AGENT = agent
129
+ const message = await judge(input)
130
+ if (!message) return allow
131
+ const answer = adapter.block(message.endsWith('\n') ? message.slice(0, -1) : message)
132
+ return { out: '', err: '', code: 0, ...answer }
133
+ } catch {
134
+ return allow
135
+ }
136
+ }
137
+
138
+ function readStdin(stream) {
139
+ return new Promise(resolve => {
140
+ if (stream.isTTY) return resolve('')
141
+ let raw = ''
142
+ stream.setEncoding('utf8')
143
+ stream.on('data', d => (raw += d))
144
+ stream.on('end', () => resolve(raw))
145
+ stream.on('error', () => resolve(''))
146
+ })
147
+ }
148
+
149
+ async function main(args, { stdin = process.stdin, stdout = process.stdout, stderr = process.stderr, exit = process.exit } = {}) {
150
+ const agent = option(args, 'agent')
151
+ if (typeof agent !== 'string' || !ADAPTERS[agent]) {
152
+ stderr.write(`usage: bulkhead hook --agent <${AGENT_NAMES.join('|')}> (reads the agent's pre-tool payload on stdin)\n`)
153
+ return exit(1)
154
+ }
155
+ const { out, err, code } = await run(agent, await readStdin(stdin))
156
+ if (err) stderr.write(err.endsWith('\n') ? err : err + '\n')
157
+ // exit only once the pipe has taken the answer
158
+ if (out) stdout.write(out, () => exit(code))
159
+ else exit(code)
160
+ }
161
+
162
+ module.exports = { main, run, judge, ADAPTERS, AGENT_NAMES, clineCommands }
@@ -0,0 +1,256 @@
1
+ 'use strict'
2
+ const fs = require('fs')
3
+ const os = require('os')
4
+ const path = require('path')
5
+ const { rejectArgs } = require('./cli-util')
6
+
7
+ // `bulkhead setup <agent> [--print|--write]`: the config that makes an agent call
8
+ // `bulkhead hook --agent <name>` before each shell command. --print (the default) shows it,
9
+ // --write merges it into the agent's config file after copying the original aside. Running it
10
+ // again changes nothing.
11
+
12
+ const SCRIPT = path.join(__dirname, '..', 'bin', 'bulkhead')
13
+ const MARK = 'bulkhead-managed'
14
+
15
+ const quote = s => (/[\s"]/.test(s) ? `"${s}"` : s)
16
+ // The node that runs this now, by absolute path: agents started from a desktop launcher often
17
+ // have no node on their PATH, and Copilot turns a hook that cannot start into a denial.
18
+ const launcher = (node = process.execPath, script = SCRIPT) => [node, script]
19
+ const commandLine = (agent, argv = launcher()) => [...argv.map(quote), 'hook', '--agent', agent].join(' ')
20
+ const isOurs = (text, agent) => new RegExp(`bulkhead\\\\?["']?\\s+hook\\s+--agent[ =]${agent}\\b`).test(text)
21
+ const pretty = v => JSON.stringify(v, null, 2) + '\n'
22
+
23
+ // The `command`/`bash` strings of every hook in a list of matcher groups.
24
+ const commandsIn = groups => JSON.stringify(groups || [])
25
+
26
+ // Adds `group` to doc[...path][event] unless one of our hooks is already there. Returns the doc.
27
+ function addGroup(doc, pathKeys, event, group, agent) {
28
+ let node = doc
29
+ for (const k of pathKeys) {
30
+ if (node[k] === undefined) node[k] = {}
31
+ if (!node[k] || typeof node[k] !== 'object' || Array.isArray(node[k])) throw new Error(`"${k}" is not an object`)
32
+ node = node[k]
33
+ }
34
+ if (node[event] === undefined) node[event] = []
35
+ if (!Array.isArray(node[event])) throw new Error(`"${event}" is not a list`)
36
+ if (!isOurs(commandsIn(node[event]), agent)) node[event].push(group)
37
+ return doc
38
+ }
39
+
40
+ function jsonPlan(file, existing, build) {
41
+ const fragment = build({})
42
+ const snippet = pretty(fragment)
43
+ if (existing === null || !existing.trim()) return { file, snippet, next: snippet }
44
+ let doc
45
+ try {
46
+ doc = JSON.parse(existing)
47
+ } catch (e) {
48
+ return { file, snippet, error: `${file} is not plain JSON (${e.message}); add the snippet by hand` }
49
+ }
50
+ if (!doc || typeof doc !== 'object' || Array.isArray(doc)) return { file, snippet, error: `${file} does not hold a JSON object; add the snippet by hand` }
51
+ const before = pretty(doc)
52
+ let next
53
+ try {
54
+ next = pretty(build(doc))
55
+ } catch (e) {
56
+ return { file, snippet, error: `${file}: ${e.message}; add the snippet by hand` }
57
+ }
58
+ return { file, snippet, next: next === before ? null : next }
59
+ }
60
+
61
+ // A file bulkhead owns outright (a script, a plugin): install it, update ours, never replace another's.
62
+ function ownedPlan(file, existing, content, agent) {
63
+ if (existing !== null && !existing.includes(MARK) && !isOurs(existing, agent)) {
64
+ return { file, snippet: content, error: `${file} already exists and is not from bulkhead; put the call to \`bulkhead hook --agent ${agent}\` in it by hand` }
65
+ }
66
+ return { file, snippet: content, next: existing === content ? null : content, mode: 0o755 }
67
+ }
68
+
69
+ const pluginSource = (agent, argv) => ({
70
+ opencode: `// ${MARK}: written by \`bulkhead setup opencode\`, safe to delete
71
+ import { spawnSync } from 'node:child_process'
72
+
73
+ const HOOK = ${JSON.stringify([...argv, 'hook', '--agent', 'opencode'])}
74
+
75
+ export const BulkheadPlugin = async ({ directory }) => ({
76
+ 'tool.execute.before': async (input, output) => {
77
+ if (input.tool !== 'bash') return
78
+ const command = output.args && output.args.command
79
+ if (typeof command !== 'string' || !command) return
80
+ const cwd = (output.args && output.args.workdir) || directory
81
+ const r = spawnSync(HOOK[0], HOOK.slice(1), {
82
+ input: JSON.stringify({ command, cwd, session_id: input.sessionID }), encoding: 'utf8', timeout: 25000, windowsHide: true,
83
+ })
84
+ if (r.status === 2) throw new Error((r.stderr || 'blocked by bulkhead').trim())
85
+ },
86
+ })
87
+ `,
88
+ amp: `// ${MARK}: written by \`bulkhead setup amp\`, safe to delete
89
+ import { spawnSync } from 'node:child_process'
90
+ import type { PluginAPI } from '@ampcode/plugin'
91
+
92
+ const HOOK = ${JSON.stringify([...argv, 'hook', '--agent', 'amp'])}
93
+
94
+ export default function (amp: PluginAPI) {
95
+ amp.on('tool.call', async event => {
96
+ const shell = amp.helpers.shellCommandFromToolCall(event)
97
+ if (!shell || !shell.command) return { action: 'allow' }
98
+ const r = spawnSync(HOOK[0], HOOK.slice(1), {
99
+ input: JSON.stringify({ command: shell.command, cwd: shell.dir || process.cwd(), session_id: event.thread.id }),
100
+ encoding: 'utf8', timeout: 25000, windowsHide: true,
101
+ })
102
+ if (r.status === 2) return { action: 'reject-and-continue', message: (r.stderr || 'blocked by bulkhead').trim() }
103
+ return { action: 'allow' }
104
+ })
105
+ }
106
+ `,
107
+ })[agent]
108
+
109
+ const clineScript = (argv, platform) => platform === 'win32'
110
+ ? `# ${MARK}: written by \`bulkhead setup cline\`\r\n$input | & ${argv.map(a => `"${a}"`).join(' ')} hook --agent cline\r\n`
111
+ : `#!/usr/bin/env bash\n# ${MARK}: written by \`bulkhead setup cline\`\nexec ${argv.map(a => `'${a.replace(/'/g, `'\\''`)}'`).join(' ')} hook --agent cline\n`
112
+
113
+ // agent: { where(home, env, platform) -> file, plan(file, existing, ctx) -> plan, note }
114
+ const SETUP = {
115
+ codex: {
116
+ where: (home, env) => path.join(env.CODEX_HOME || path.join(home, '.codex'), 'hooks.json'),
117
+ plan: (file, existing, { argv }) => jsonPlan(file, existing, doc => addGroup(doc, ['hooks'], 'PreToolUse',
118
+ { matcher: '^Bash$', hooks: [{ type: 'command', command: commandLine('codex', argv), timeout: 30, statusMessage: 'bulkhead guards' }] }, 'codex')),
119
+ note: 'Codex asks you to review a new hook once: open /hooks in Codex and trust it.',
120
+ },
121
+ gemini: {
122
+ where: home => path.join(home, '.gemini', 'settings.json'),
123
+ plan: (file, existing, { argv }) => jsonPlan(file, existing, doc => addGroup(doc, ['hooks'], 'BeforeTool',
124
+ { matcher: 'run_shell_command', hooks: [{ name: 'bulkhead', type: 'command', command: commandLine('gemini', argv), timeout: 30000 }] }, 'gemini')),
125
+ },
126
+ copilot: {
127
+ where: (home, env) => path.join(env.COPILOT_HOME || path.join(home, '.copilot'), 'hooks', 'bulkhead.json'),
128
+ plan: (file, existing, { argv }) => {
129
+ const command = commandLine('copilot', argv)
130
+ return ownedPlan(file, existing, pretty({ version: 1, hooks: { preToolUse: [{ type: 'command', bash: command, powershell: command, timeoutSec: 30 }] } }), 'copilot')
131
+ },
132
+ note: 'Copilot CLI treats a hook that fails to start as a denial. If you move or remove this node, run the setup again.',
133
+ },
134
+ cursor: {
135
+ where: home => path.join(home, '.cursor', 'hooks.json'),
136
+ plan: (file, existing, { argv }) => jsonPlan(file, existing, doc => {
137
+ doc.version = doc.version || 1
138
+ const list = (doc.hooks = doc.hooks || {}).beforeShellExecution = (doc.hooks.beforeShellExecution || [])
139
+ if (!isOurs(commandsIn(list), 'cursor')) list.push({ command: commandLine('cursor', argv), timeout: 30 })
140
+ return doc
141
+ }),
142
+ },
143
+ windsurf: {
144
+ where: home => path.join(home, '.codeium', 'windsurf', 'hooks.json'),
145
+ plan: (file, existing, { argv }) => jsonPlan(file, existing, doc => {
146
+ const list = (doc.hooks = doc.hooks || {}).pre_run_command = (doc.hooks.pre_run_command || [])
147
+ if (!isOurs(commandsIn(list), 'windsurf')) list.push({ command: commandLine('windsurf', argv), show_output: false })
148
+ return doc
149
+ }),
150
+ note: 'This is the Windsurf IDE user file. The JetBrains plugin reads ~/.codeium/hooks.json instead.',
151
+ },
152
+ droid: {
153
+ // hooks.json wins over the hooks key in settings.json, so a user who only has the second keeps it
154
+ where: home => {
155
+ const hooks = path.join(home, '.factory', 'hooks.json')
156
+ const settings = path.join(home, '.factory', 'settings.json')
157
+ try {
158
+ if (!fs.existsSync(hooks) && JSON.parse(fs.readFileSync(settings, 'utf8')).hooks) return settings
159
+ } catch {}
160
+ return hooks
161
+ },
162
+ plan: (file, existing, { argv }) => jsonPlan(file, existing, doc => addGroup(doc, path.basename(file) === 'settings.json' ? ['hooks'] : [], 'PreToolUse',
163
+ { matcher: 'Execute', hooks: [{ type: 'command', command: commandLine('droid', argv), timeout: 30 }] }, 'droid')),
164
+ },
165
+ devin: {
166
+ where: (home, env, platform) => platform === 'win32'
167
+ ? path.join(env.APPDATA || path.join(home, 'AppData', 'Roaming'), 'devin', 'config.json')
168
+ : path.join(env.XDG_CONFIG_HOME || path.join(home, '.config'), 'devin', 'config.json'),
169
+ plan: (file, existing, { argv }) => jsonPlan(file, existing, doc => addGroup(doc, ['hooks'], 'PreToolUse',
170
+ { matcher: 'exec', hooks: [{ type: 'command', command: commandLine('devin', argv) }] }, 'devin')),
171
+ },
172
+ cline: {
173
+ where: (home, env, platform) => path.join(env.CLINE_DIR || path.join(home, '.cline'), 'hooks', platform === 'win32' ? 'PreToolUse.ps1' : 'PreToolUse.sh'),
174
+ plan: (file, existing, { argv, platform }) => ownedPlan(file, existing, clineScript(argv, platform), 'cline'),
175
+ },
176
+ opencode: {
177
+ where: (home, env) => path.join(env.XDG_CONFIG_HOME || path.join(home, '.config'), 'opencode', 'plugins', 'bulkhead.js'),
178
+ plan: (file, existing, { argv }) => ownedPlan(file, existing, pluginSource('opencode', argv), 'opencode'),
179
+ note: 'Restart opencode to load the plugin.',
180
+ },
181
+ amp: {
182
+ where: (home, env) => path.join(env.XDG_CONFIG_HOME || path.join(home, '.config'), 'amp', 'plugins', 'bulkhead.ts'),
183
+ plan: (file, existing, { argv }) => ownedPlan(file, existing, pluginSource('amp', argv), 'amp'),
184
+ note: 'Run `plugins: reload` from the Amp command palette to load the plugin.',
185
+ },
186
+ }
187
+
188
+ const AGENT_NAMES = Object.keys(SETUP)
189
+
190
+ function readIfExists(file) {
191
+ try { return fs.readFileSync(file, 'utf8') } catch (e) { if (e.code === 'ENOENT') return null; throw e }
192
+ }
193
+
194
+ // What `setup` would do for one agent. { file, snippet, next, error?, note }; next is null when
195
+ // the file already has it.
196
+ function plan(agent, { home = os.homedir(), env = process.env, platform = process.platform, argv = launcher() } = {}) {
197
+ const spec = SETUP[agent]
198
+ const file = spec.where(home, env, platform)
199
+ let existing
200
+ try { existing = readIfExists(file) } catch (e) { return { file, snippet: '', error: `cannot read ${file}: ${e.message}` } }
201
+ return { ...spec.plan(file, existing, { argv, platform }), existing, note: spec.note || null }
202
+ }
203
+
204
+ function backupName(file, exists = fs.existsSync, now = Date.now()) {
205
+ const base = file + '.bulkhead-backup'
206
+ return exists(base) ? `${base}-${now}` : base
207
+ }
208
+
209
+ // Writes the plan. Returns { wrote, backup }. The original is copied first and the new content
210
+ // goes in through a temporary file, so a crash never leaves a half-written config.
211
+ function apply(p) {
212
+ if (p.next === null || p.next === undefined) return { wrote: false, backup: null }
213
+ fs.mkdirSync(path.dirname(p.file), { recursive: true })
214
+ let backup = null
215
+ if (p.existing !== null && p.existing !== undefined) {
216
+ backup = backupName(p.file)
217
+ fs.copyFileSync(p.file, backup)
218
+ }
219
+ const tmp = `${p.file}.bulkhead-tmp-${process.pid}`
220
+ fs.writeFileSync(tmp, p.next, { mode: p.mode || 0o644 })
221
+ fs.renameSync(tmp, p.file)
222
+ return { wrote: true, backup }
223
+ }
224
+
225
+ function main(args, { stdout = process.stdout, stderr = process.stderr, exit = process.exit, ctx = {} } = {}) {
226
+ const agent = args.find(a => !a.startsWith('--'))
227
+ const list = () => `agents: ${AGENT_NAMES.join(', ')}`
228
+ if (rejectArgs('setup', args.filter(a => a !== agent), { flags: ['print', 'write'], valued: [] }, stderr, null)) return exit(1)
229
+ if (!agent || !SETUP[agent]) {
230
+ stderr.write(`usage: bulkhead setup <agent> [--print|--write]\n${list()}\n`)
231
+ return exit(1)
232
+ }
233
+ if (args.includes('--print') && args.includes('--write')) {
234
+ stderr.write('bulkhead setup: use --print or --write, not both\n')
235
+ return exit(1)
236
+ }
237
+ const p = plan(agent, ctx)
238
+ if (p.error) {
239
+ stderr.write(`bulkhead setup: ${p.error}\n`)
240
+ if (p.snippet) stderr.write(`\n${p.snippet}`)
241
+ return exit(1)
242
+ }
243
+ if (!args.includes('--write')) {
244
+ stdout.write(`# ${p.file}${p.next === null ? ' (already set up)' : ''}\n${p.snippet}`)
245
+ if (p.note) stdout.write(`# ${p.note}\n`)
246
+ return exit(0)
247
+ }
248
+ const done = apply(p)
249
+ stdout.write(done.wrote
250
+ ? `${p.existing === null ? 'created' : 'updated'} ${p.file}${done.backup ? `\nbackup of the original: ${done.backup}` : ''}\n`
251
+ : `${p.file} already has bulkhead; nothing changed\n`)
252
+ if (p.note) stdout.write(`${p.note}\n`)
253
+ return exit(0)
254
+ }
255
+
256
+ module.exports = { main, plan, apply, backupName, commandLine, isOurs, SETUP, AGENT_NAMES }
package/src/agents.js ADDED
@@ -0,0 +1,89 @@
1
+ 'use strict'
2
+ const fs = require('fs')
3
+ const os = require('os')
4
+ const path = require('path')
5
+
6
+ // The coding agents other than Claude Code that bulkhead recognizes as live sessions. Each entry
7
+ // matches the agent's CLI process. The patterns come from the published `bin` entries of the
8
+ // packages and the agents' install scripts (see docs/other-agents.md); an entry is only used for
9
+ // matching, so a pattern that misses just leaves that agent out of the session list.
10
+ const RUNTIME = /^(?:node|bun)(?:\.exe)?$/i
11
+ const nameIs = (p, re) => re.test(p.name || '') || re.test(basename(p.cmd))
12
+
13
+ function basename(cmd) {
14
+ const first = /^\s*(?:"([^"]+)"|'([^']+)'|(\S+))/.exec(cmd || '')
15
+ const prog = first ? first[1] || first[2] || first[3] : ''
16
+ return prog.split(/[\\/]/).pop()
17
+ }
18
+
19
+ // `node .../bin/<name>` or `node .../<name>.js`: a script launched through its runtime.
20
+ const script = base => new RegExp(`[\\\\/]${base}(?:\\.[cm]?js)?(?=["']?(?:\\s|$))`, 'i')
21
+
22
+ const AGENTS = {
23
+ codex: {
24
+ label: 'Codex',
25
+ // the npm package starts a native codex binary from node; the outer node process is the root
26
+ match: p => nameIs(p, /^codex(?:\.exe)?$/i) || (RUNTIME.test(p.name) && /@openai[\\/]codex[\\/]bin[\\/]codex\.js/.test(p.cmd)),
27
+ dirs: ['.codex'],
28
+ },
29
+ gemini: {
30
+ label: 'Gemini',
31
+ // gemini relaunches itself as a child with more heap; the outer process is the root
32
+ match: p => nameIs(p, /^gemini(?:\.exe)?$/i) || (RUNTIME.test(p.name) && (/@google[\\/]gemini-cli[\\/]/.test(p.cmd) || script('gemini').test(p.cmd))),
33
+ dirs: ['.gemini'],
34
+ },
35
+ copilot: {
36
+ label: 'Copilot',
37
+ match: p => nameIs(p, /^copilot(?:\.exe)?$/i) || (RUNTIME.test(p.name) && (/@github[\\/]copilot[\\/]/.test(p.cmd) || script('copilot').test(p.cmd))),
38
+ dirs: ['.copilot'],
39
+ },
40
+ cursor: {
41
+ label: 'Cursor',
42
+ // the cursor-agent CLI (also installed as `agent`); the Cursor editor itself is not matched
43
+ match: p => nameIs(p, /^cursor-agent(?:\.exe)?$/i) || /[\\/]cursor-agent[\\/]versions[\\/]/.test(p.cmd),
44
+ dirs: ['.cursor'],
45
+ },
46
+ opencode: {
47
+ label: 'opencode',
48
+ match: p => nameIs(p, /^opencode(?:\.exe)?$/i) || (RUNTIME.test(p.name) && script('opencode').test(p.cmd)),
49
+ dirs: ['.config/opencode', '.local/share/opencode'],
50
+ },
51
+ amp: {
52
+ label: 'Amp',
53
+ match: p => nameIs(p, /^amp(?:\.exe)?$/i) || /@ampcode[\\/]cli[\\/]/.test(p.cmd),
54
+ dirs: ['.config/amp'],
55
+ },
56
+ cline: {
57
+ label: 'Cline',
58
+ match: p => nameIs(p, /^cline(?:\.exe)?$/i) || (RUNTIME.test(p.name) && script('cline').test(p.cmd)),
59
+ dirs: ['.cline'],
60
+ },
61
+ droid: {
62
+ label: 'Droid',
63
+ match: p => nameIs(p, /^droid(?:\.exe)?$/i) || (RUNTIME.test(p.name) && script('droid').test(p.cmd)),
64
+ dirs: ['.factory'],
65
+ },
66
+ }
67
+
68
+ // Names of the agents other than Claude Code (and of the ones that only have a hook, no CLI).
69
+ const NAMES = Object.keys(AGENTS)
70
+
71
+ // The agent a process is the CLI of, or null. Claude Code is decided by sessions.isClaudeCli.
72
+ function agentOf(p) {
73
+ if (!p) return null
74
+ for (const name of NAMES) if (AGENTS[name].match(p)) return name
75
+ return null
76
+ }
77
+
78
+ function agentLabel(agent) {
79
+ return !agent || agent === 'claude' ? 'Claude' : (AGENTS[agent] && AGENTS[agent].label) || agent
80
+ }
81
+
82
+ // A user who never installed another agent has none of its folders, so a process scan that only
83
+ // looks for other agents can be skipped without reading the process table.
84
+ function installedAgents(home = os.homedir(), exists = fs.existsSync, env = process.env) {
85
+ const xdg = env.XDG_CONFIG_HOME
86
+ return NAMES.filter(name => AGENTS[name].dirs.some(d => exists(path.join(home, d)) || (xdg && d.startsWith('.config/') && exists(path.join(xdg, d.slice(8))))))
87
+ }
88
+
89
+ module.exports = { AGENTS, NAMES, agentOf, agentLabel, installedAgents }
@@ -0,0 +1,65 @@
1
+ 'use strict'
2
+ const allowOnce = require('./allow-once')
3
+ const { rejectArgs, useColor, paint, table, shortPath } = require('./cli-util')
4
+
5
+ const USAGE = `usage: bulkhead allow <code>
6
+ bulkhead allow --list [--json]
7
+
8
+ Run this yourself, in your own terminal, when a guard blocked a command you want to go ahead.
9
+ <code> is the short code in the block message. The agent can then run that exact command, from
10
+ the same folder, once, within allowOnce.ttlMinutes (default 10). --list shows the codes waiting.
11
+ The agent cannot run this command: bulkhead blocks it.`
12
+
13
+ function ago(ms, now) {
14
+ const m = Math.max(0, Math.round((now - ms) / 60000))
15
+ return m < 1 ? 'just now' : `${m} min ago`
16
+ }
17
+
18
+ function left(ms, now) {
19
+ return `${Math.max(1, Math.ceil((ms - now) / 60000))} min`
20
+ }
21
+
22
+ function renderList(entries, { now = Date.now(), color = false } = {}) {
23
+ if (!entries.length) return 'no codes waiting. A code appears in the message when a guard blocks a command.'
24
+ const rows = [['CODE', 'STATE', 'GUARD', 'RULE', 'FOLDER', 'COMMAND', 'BLOCKED', 'LEFT']]
25
+ for (const e of entries) {
26
+ rows.push([e.code, e.approved ? 'allowed' : 'waiting', e.guard, e.rule, shortPath(e.cwd), e.cmd.slice(0, 50), ago(e.created, now), left(e.expires, now)])
27
+ }
28
+ const lines = table(rows)
29
+ return [paint(color, 'bold', lines[0]), ...lines.slice(1)].join('\n')
30
+ }
31
+
32
+ function main(args, { stdout = process.stdout, stderr = process.stderr, env = process.env, now = Date.now(), base } = {}) {
33
+ if (args.includes('--help') || args.includes('-h')) return stdout.write(USAGE + '\n')
34
+ if (args.includes('--list')) {
35
+ if (rejectArgs('allow', args, { flags: ['list', 'json'], valued: [] }, stderr, USAGE)) return
36
+ const entries = allowOnce.list({ base, now })
37
+ if (args.includes('--json')) return stdout.write(JSON.stringify(entries, null, 2) + '\n')
38
+ return stdout.write(renderList(entries, { now, color: useColor(stdout, env) }) + '\n')
39
+ }
40
+ const codes = args.filter(a => !a.startsWith('-'))
41
+ if (codes.length !== 1 || args.some(a => a.startsWith('-'))) {
42
+ stderr.write(USAGE + '\n')
43
+ process.exitCode = 1
44
+ return
45
+ }
46
+ const res = allowOnce.approve(codes[0], { base, now })
47
+ if (!res.ok) {
48
+ const why = {
49
+ unknown: `no waiting code ${codes[0].toUpperCase()}. It may have expired (codes last ${Math.round(allowOnce.ttlMs() / 60000)} minutes); run the blocked command again for a new one, or \`bulkhead allow --list\`.`,
50
+ used: `code ${codes[0].toUpperCase()} is already allowed and waiting for the agent to retry the command.`,
51
+ 'no-code': 'give the code from the block message, for example: bulkhead allow 7F3K',
52
+ unwritable: 'could not write the allow state; check that the bulkhead state directory is writable.',
53
+ unreadable: 'could not read the allow state; check the bulkhead state directory.',
54
+ }[res.reason]
55
+ stderr.write(`bulkhead allow: ${why}\n`)
56
+ process.exitCode = 1
57
+ return
58
+ }
59
+ const e = res.entries[0]
60
+ const guards = res.entries.map(x => `${x.guard} ${x.rule}`).join(', ')
61
+ stdout.write(`Allowed once: ${e.cmd}\n folder ${e.cwd || '?'}\n guards ${guards}\n` +
62
+ `The agent can run exactly that command now, once, within ${left(e.expires, now)}.\n`)
63
+ }
64
+
65
+ module.exports = { main, renderList, USAGE }
@@ -0,0 +1,62 @@
1
+ 'use strict'
2
+ // How to let one blocked rule through for good: the exact config snippet and the
3
+ // BULKHEAD_DISABLE name. Shown in block messages (one line) and by `bulkhead explain`.
4
+
5
+ const RULE_KEYS = {
6
+ 'rm-root': ['basics', 'rmRoot'],
7
+ 'force-push-main': ['basics', 'forcePushMain'],
8
+ 'protected-delete': ['basics', 'protectedDelete'],
9
+ 'discard-changes': ['basics', 'discardChanges'],
10
+ 'agent-author': ['basics', 'agentAuthor'],
11
+ 'stage-all': ['checkoutGuard', 'stageAll'],
12
+ 'remove-session-dir': ['checkoutGuard', 'removeSessionDir'],
13
+ 'mass-kill': ['killGuard', 'massKill'],
14
+ 'docker-mass': ['killGuard', 'dockerMass'],
15
+ 'device-kill': ['killGuard', 'deviceKill'],
16
+ 'app-quit': ['killGuard', 'appQuit'],
17
+ 'service-stop': ['killGuard', 'serviceStop'],
18
+ 'other-session-pid': ['killGuard', 'ownerCheck', 'owner-check'],
19
+ }
20
+ const SECTION = {
21
+ 'kill-guard': 'killGuard', 'wait-guard': 'waitGuard', 'memory-guard': 'memoryGuard', 'port-guard': 'portGuard',
22
+ 'checkout-guard': 'checkoutGuard', 'disk-guard': 'diskGuard', basics: 'basics', 'orphan-reaper': 'orphanReaper', 'secrets-guard': 'secretsGuard', 'edit-guard': 'editGuard',
23
+ }
24
+
25
+ // The shared program name a kill command aims at, to put in allowNames.
26
+ function killName(command) {
27
+ try {
28
+ const kill = require('./kill-guard')
29
+ const shell = require('./shell')
30
+ const names = kill.nameList()
31
+ const words = shell.unwrap(shell.prepare(String(command || ''), 'sh'), 'sh').split(/[^\w.*?-]+/)
32
+ return words.map(w => w.toLowerCase().replace(/\.exe$/, '')).find(w => w && kill.nameHits(w, names)) || null
33
+ } catch {
34
+ return null
35
+ }
36
+ }
37
+
38
+ // { json, disable } for a guard and rule; json is null when the rule cannot be allowed.
39
+ function allowHint(guard, rule, { command = '', message = '' } = {}) {
40
+ const j = o => JSON.stringify(o)
41
+ if (rule === 'self-allow') return { json: null, disable: null }
42
+ const key = RULE_KEYS[rule]
43
+ if (key) return { json: j({ [key[0]]: { [key[1]]: false } }), disable: key[2] || rule }
44
+ const section = SECTION[guard]
45
+ if (!section) return { json: null, disable: guard }
46
+ const off = { json: j({ [section]: { enabled: false } }), disable: guard }
47
+ if (guard === 'kill-guard') return { json: j({ killGuard: { allowNames: [killName(command) || '<name>'] } }), disable: guard }
48
+ if (guard === 'port-guard') {
49
+ const port = /\bport (\d{2,5})\b/.exec(message)
50
+ return port ? { json: j({ portGuard: { ignorePorts: [Number(port[1])] } }), disable: guard } : off
51
+ }
52
+ if (guard === 'secrets-guard') {
53
+ const file = /\(([^()\n]+)\) to the model/.exec(message)
54
+ return { json: j({ secretsGuard: { allow: [file && !/\s/.test(file[1]) ? file[1] : '<file>'] } }), disable: guard }
55
+ }
56
+ if (guard === 'edit-guard') return { json: j({ editGuard: { mode: 'warn' } }), disable: guard }
57
+ if (guard === 'wait-guard') return { json: j({ waitGuard: { limitSeconds: 600 } }), disable: guard }
58
+ if (guard === 'memory-guard' && rule === 'low-memory') return { json: j({ memory: { minFreeGb: 1 } }), disable: guard }
59
+ return off
60
+ }
61
+
62
+ module.exports = { allowHint, killName }