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/sessions.js CHANGED
@@ -2,18 +2,26 @@
2
2
  const fs = require('fs')
3
3
  const os = require('os')
4
4
  const path = require('path')
5
+ const { execFileSync } = require('child_process')
6
+ const { agentOf, installedAgents } = require('./agents')
5
7
 
6
8
  // On Windows the desktop app and the CLI are both claude.exe. The app's processes run from
7
- // ...\app\Claude.exe (or Claude.app on macOS) and its helpers carry --type=.
8
- const DESKTOP_APP = /[\\/]app[\\/]Claude\.exe|Claude\.app[\\/]Contents[\\/]MacOS[\\/]Claude(?:["\s]|$)/i
9
+ // ...\app\Claude.exe (or Claude.app on macOS) and its helpers carry --type=. On macOS the
10
+ // app's own Claude Code sessions run from .../claude-code/<x.y.z>/<hash>/claude.app/Contents/
11
+ // MacOS/claude, so the macOS match is case-sensitive: Claude.app/.../Claude is the app,
12
+ // claude.app/.../claude is the CLI. The app starts each one through Contents/Helpers/disclaimer,
13
+ // whose command line names the CLI too; that wrapper is not a session.
14
+ const DESKTOP_APP = /[\\/]app[\\/]Claude\.exe/i
15
+ const DESKTOP_APP_MAC = /Claude\.app\/Contents\/(?:MacOS\/Claude(?:["\s]|$)|Helpers\/)/
16
+ const BUNDLED_CLI_MAC = /\/claude\.app\/Contents\/MacOS\/claude(?:["\s]|$)/
9
17
  const PID_REUSE_SLACK_MS = 120 * 1000
10
18
 
11
19
  // The native installer runs versions/<x.y.z> through a `claude` link, so the process name can be
12
20
  // the version; the command line still starts with claude or names that folder.
13
21
  function isClaudeCli(p) {
14
22
  if (!p) return false
15
- if (/--type=/.test(p.cmd) || DESKTOP_APP.test(p.cmd)) return false
16
- if (/^claude(?:\.exe)?$/i.test(p.name)) return true
23
+ if (/--type=/.test(p.cmd) || DESKTOP_APP.test(p.cmd) || DESKTOP_APP_MAC.test(p.cmd)) return false
24
+ if (/^claude(?:\.exe)?$/i.test(p.name) || BUNDLED_CLI_MAC.test(p.cmd)) return true
17
25
  if (/^["']?(?:\S*[\\/])?claude(?:\.exe)?["']?(?:\s|$)|[\\/]claude[\\/]versions[\\/]/i.test(p.cmd)) return true
18
26
  return /^(?:node|bun)(?:\.exe)?$/i.test(p.name) && /claude-code[\\/]cli\.js|@anthropic-ai[\\/]claude-code/i.test(p.cmd)
19
27
  }
@@ -36,10 +44,6 @@ function readSessionFiles(dir = path.join(claudeDir(), 'sessions')) {
36
44
  return files
37
45
  }
38
46
 
39
- function linuxCwd(pid) {
40
- try { return fs.readlinkSync(`/proc/${pid}/cwd`) } catch { return null }
41
- }
42
-
43
47
  // Maps every process to the session whose tree it is in. A child must be younger than its
44
48
  // parent, which skips Windows children whose parent PID was reused. On Linux a server that
45
49
  // outlived its shell is still matched through CLAUDE_PID.
@@ -106,14 +110,70 @@ function snapshotOwner(p, byPid, sessionPids, known = new Map()) {
106
110
  return fits.length === 1 ? fits[0] : null
107
111
  }
108
112
 
109
- // [{ pid, start, cwd, rss, ports: [{ port, pid, name }] }], oldest first.
110
- function findSessions({ procs, ports = [] }, files = new Map(), cwdOf = process.platform === 'linux' ? linuxCwd : () => null) {
113
+ function linuxCwd(pid) {
114
+ try { return fs.readlinkSync(`/proc/${pid}/cwd`) } catch { return null }
115
+ }
116
+
117
+ // The working directories of several processes at once. macOS has no /proc: one lsof call
118
+ // answers for all of them. Windows has no cheap way, so those sessions have no cwd and the
119
+ // checks that need a folder skip them.
120
+ function readCwds(pids, platform = process.platform, run = execFileSync) {
121
+ const out = new Map()
122
+ if (!pids.length) return out
123
+ if (platform === 'linux') {
124
+ for (const pid of pids) out.set(pid, linuxCwd(pid))
125
+ } else if (platform === 'darwin') {
126
+ let text = ''
127
+ try {
128
+ text = run('lsof', ['-a', '-p', pids.join(','), '-d', 'cwd', '-Fn'], { encoding: 'utf8', timeout: 5000, env: { ...process.env, LC_ALL: 'C' } })
129
+ } catch (e) { text = String(e.stdout || '') } // lsof exits 1 when one of the processes is gone
130
+ let pid = 0
131
+ for (const line of text.split('\n')) {
132
+ if (line[0] === 'p') pid = Number(line.slice(1))
133
+ else if (line[0] === 'n' && pid) out.set(pid, line.slice(1))
134
+ }
135
+ }
136
+ return out
137
+ }
138
+
139
+ // Claude Code sessions first, as before. Another agent's tree is one session at its outermost
140
+ // process: the npm launchers of Codex and Gemini start the real program as a child.
141
+ function sessionRoots(procs) {
111
142
  const byPid = new Map(procs.map(p => [p.pid, p]))
112
- const roots = procs.filter(isClaudeCli)
113
- const owner = assignOwners(procs, new Set(roots.map(p => p.pid)))
114
- return roots.sort((a, b) => a.start - b.start).map(p => {
115
- const f = files.get(p.pid)
116
- const fileMatches = f && (!f.startedAt || !p.start || Math.abs(f.startedAt - p.start) < PID_REUSE_SLACK_MS)
143
+ const roots = []
144
+ for (const p of procs) {
145
+ if (isClaudeCli(p)) {
146
+ roots.push({ proc: p, agent: 'claude' })
147
+ continue
148
+ }
149
+ const agent = agentOf(p)
150
+ if (!agent) continue
151
+ let cur = byPid.get(p.ppid)
152
+ let nested = false
153
+ for (let i = 0; cur && i < 40 && !nested; i++) {
154
+ nested = cur.start <= p.start && !isClaudeCli(cur) && agentOf(cur) === agent
155
+ cur = byPid.get(cur.ppid)
156
+ }
157
+ if (!nested) roots.push({ proc: p, agent })
158
+ }
159
+ return roots
160
+ }
161
+
162
+ // [{ pid, agent, start, cwd, rss, ports: [{ port, pid, name }] }], oldest first.
163
+ function findSessions({ procs, ports = [] }, files = new Map(), cwdOf = null, platform = process.platform, run = execFileSync) {
164
+ const byPid = new Map(procs.map(p => [p.pid, p]))
165
+ const roots = sessionRoots(procs)
166
+ const owner = assignOwners(procs, new Set(roots.map(r => r.proc.pid)))
167
+ const fileCwd = r => {
168
+ const f = files.get(r.proc.pid)
169
+ const fileMatches = f && (!f.startedAt || !r.proc.start || Math.abs(f.startedAt - r.proc.start) < PID_REUSE_SLACK_MS)
170
+ return (fileMatches && f.cwd) || null
171
+ }
172
+ // Claude Code names its folder in a session file; the others are asked once, together
173
+ const others = roots.filter(r => r.agent !== 'claude').map(r => r.proc.pid)
174
+ const asked = cwdOf ? new Map() : readCwds(others, platform, run)
175
+ return roots.sort((a, b) => a.proc.start - b.proc.start).map(r => {
176
+ const p = r.proc
117
177
  let rss = 0
118
178
  let children = 0
119
179
  for (const [pid, root] of owner) {
@@ -121,10 +181,12 @@ function findSessions({ procs, ports = [] }, files = new Map(), cwdOf = process.
121
181
  rss += byPid.get(pid).rss || 0
122
182
  if (pid !== p.pid) children++
123
183
  }
184
+ const fallback = cwdOf ? cwdOf(p.pid) : r.agent === 'claude' ? (platform === 'linux' ? linuxCwd(p.pid) : null) : asked.get(p.pid)
124
185
  return {
125
186
  pid: p.pid,
187
+ agent: r.agent,
126
188
  start: p.start,
127
- cwd: (fileMatches && f.cwd) || cwdOf(p.pid),
189
+ cwd: fileCwd(r) || fallback || null,
128
190
  rss,
129
191
  children,
130
192
  ports: ports.filter(l => owner.get(l.pid) === p.pid).map(l => ({ ...l, name: byPid.get(l.pid).name })),
@@ -132,16 +194,43 @@ function findSessions({ procs, ports = [] }, files = new Map(), cwdOf = process.
132
194
  })
133
195
  }
134
196
 
135
- // The session a process runs under: its nearest Claude ancestor, or CLAUDE_PID.
197
+ // The session a process runs under: its nearest Claude ancestor, else the outermost process of
198
+ // the nearest other agent, else CLAUDE_PID.
136
199
  function ownSession(procs, pid = process.pid, env = process.env) {
137
200
  const byPid = new Map(procs.map(p => [p.pid, p]))
138
201
  let cur = byPid.get(pid)
202
+ let found = null
139
203
  for (let i = 0; cur && i < 40; i++) {
140
- if (isClaudeCli(cur)) return cur.pid
204
+ if (isClaudeCli(cur)) return found ? found.pid : cur.pid
205
+ const agent = agentOf(cur)
206
+ if (agent && (!found || found.agent === agent)) found = { pid: cur.pid, agent }
141
207
  const par = byPid.get(cur.ppid)
142
208
  cur = par && par.start <= cur.start ? par : null
143
209
  }
144
- return Number(env.CLAUDE_PID) || null
210
+ return found ? found.pid : Number(env.CLAUDE_PID) || null
211
+ }
212
+
213
+ // Session files for the checks that need each live session's folder (checkout-guard): Claude
214
+ // Code's own files plus a cwd for every other agent's process. The process table is only read
215
+ // when one of the other agents has left a folder in the home directory, and never for the agent
216
+ // the caller runs under.
217
+ function readLiveSessionFiles({
218
+ files = readSessionFiles(), installed = installedAgents, read = () => require('./processes').readProcesses(process.platform, { ports: false }),
219
+ platform = process.platform, run = execFileSync, self = process.pid,
220
+ } = {}) {
221
+ const merged = new Map(files)
222
+ try {
223
+ if (!installed().length) return merged
224
+ const snap = read()
225
+ if (!snap) return merged
226
+ const mine = new Set()
227
+ const byPid = new Map(snap.procs.map(p => [p.pid, p]))
228
+ for (let cur = byPid.get(self), i = 0; cur && i < 40; cur = byPid.get(cur.ppid), i++) mine.add(cur.pid)
229
+ const roots = sessionRoots(snap.procs).filter(r => r.agent !== 'claude' && !mine.has(r.proc.pid))
230
+ const cwds = readCwds(roots.map(r => r.proc.pid), platform, run)
231
+ for (const r of roots) merged.set(r.proc.pid, { cwd: cwds.get(r.proc.pid) || null, startedAt: r.proc.start, status: null, sessionId: null, agent: r.agent })
232
+ } catch {}
233
+ return merged
145
234
  }
146
235
 
147
- module.exports = { isClaudeCli, readSessionFiles, findSessions, assignOwners, ownSession, claudeDir }
236
+ module.exports = { PID_REUSE_SLACK_MS, isClaudeCli, readSessionFiles, readLiveSessionFiles, readCwds, sessionRoots, findSessions, assignOwners, ownSession, claudeDir }
package/src/shell.js CHANGED
@@ -13,6 +13,8 @@
13
13
  // mode is 'sh' (Bash tool), 'ps' (PowerShell tool) or 'cmd' (inside cmd /c). Every function is
14
14
  // linear in the length of its input: no regular expression here can backtrack across the text.
15
15
 
16
+ const os = require('os')
17
+
16
18
  const MAX_DEPTH = 3
17
19
 
18
20
  // The program name of a command word: no directory, no Windows extension, lower case. Guards ask
@@ -252,7 +254,8 @@ function skipOptions(words, k, valued = new Set()) {
252
254
  // delete guards look after this machine only.
253
255
  function isRemote(argv) {
254
256
  const b = bin(argv[0].v)
255
- if (b === 'ssh' || b === 'autossh' || b === 'mosh') return true
257
+ if (b === 'ssh') return !sshToThisMachine(argv)
258
+ if (b === 'autossh' || b === 'mosh') return true
256
259
  // bulkhead remote run sends its command to another machine
257
260
  if (b === 'bulkhead' && argv[1] && argv[1].v === 'remote') return true
258
261
  const sub = argv.slice(1, 5).map(w => w.v)
@@ -262,6 +265,16 @@ function isRemote(argv) {
262
265
  return false
263
266
  }
264
267
 
268
+ const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '[::1]', '0.0.0.0', os.hostname().toLowerCase()])
269
+
270
+ // ssh localhost runs its command on this machine, so the guards read it like a local shell.
271
+ function sshToThisMachine(argv) {
272
+ const at = skipOptions(argv, 1, SSH_VALUE)
273
+ if (at >= argv.length) return false
274
+ const host = argv[at].v.replace(/^.*@/, '').toLowerCase()
275
+ return LOCAL_HOSTS.has(host) || /^127\.\d+\.\d+\.\d+$/.test(host)
276
+ }
277
+
265
278
  // UTF-16LE base64, as powershell -EncodedCommand takes it.
266
279
  function decodeEncoded(s) {
267
280
  try {
@@ -318,7 +331,11 @@ function innerOf(src, argv, mode) {
318
331
  else if (v === '--command') c = true
319
332
  k += SHELL_VALUE.has(v) ? 2 : 1
320
333
  }
321
- return c && k < argv.length ? { text: vals[k], mode: 'sh' } : null
334
+ if (c && k < argv.length) return { text: vals[k], mode: 'sh' }
335
+ // bash <(echo "...") runs what the substitution prints
336
+ const sub = vals.find((v, j) => j >= k && /^<\(.*\)$/s.test(v))
337
+ const printed = sub && printedBy(sub.slice(2, -1), mode)
338
+ return printed ? { text: printed, mode: 'sh' } : null
322
339
  }
323
340
  if (b === 'powershell' || b === 'pwsh' || b === 'powershell_ise') {
324
341
  for (let k = 1; k < argv.length; k++) {
@@ -353,7 +370,17 @@ function innerOf(src, argv, mode) {
353
370
  }
354
371
  return null
355
372
  }
356
- if (b === 'eval' && argv.length > 1) return { text: vals.slice(1).join(' '), mode }
373
+ if (b === 'ssh') return remoteInner(src, argv)
374
+ if (b === 'eval' && argv.length > 1) return { text: fed(vals.slice(1).join(' ')), mode }
375
+ if (b === 'source' || (b === '.' && mode === 'sh')) {
376
+ const sub = vals.find((v, k) => k > 0 && /^<\(.*\)$/s.test(v))
377
+ const printed = sub && printedBy(sub.slice(2, -1), mode)
378
+ return printed ? { text: printed, mode } : null
379
+ }
380
+ if (b === 'tmux') return tmuxInner(vals)
381
+ if (b === 'screen') return screenInner(argv)
382
+ if (b === 'script') return scriptInner(vals)
383
+ if (b === 'parallel') return parallelInner(vals)
357
384
  if (b === 'trap' && argv.length > 1 && vals[1] !== '-' && !vals[1].startsWith('-')) return { text: vals[1], mode }
358
385
  if (b === 'watch') {
359
386
  const k = skipOptions(argv, 1, WATCH_VALUE)
@@ -388,6 +415,84 @@ function innerOf(src, argv, mode) {
388
415
  return null
389
416
  }
390
417
 
418
+ // What echo or printf prints, when the text is that one command: the script a pipe, <( ) or
419
+ // $( ) hands to a shell. Null for anything else.
420
+ function printedBy(text, mode) {
421
+ const { cmds } = lex(String(text), mode)
422
+ if (cmds.length !== 1) return null
423
+ const ws = cmds[0].words.map(w => w.v)
424
+ const b = bin(ws[0] || '')
425
+ if (b !== 'echo' && b !== 'printf') return null
426
+ let k = 1
427
+ while (k < ws.length && /^-[neE]+$/.test(ws[k])) k++
428
+ const rest = ws.slice(k)
429
+ if (b === 'printf' && rest.length && !/%/.test(rest[0])) return rest[0]
430
+ return rest.join(' ') || null
431
+ }
432
+
433
+ // eval "$(echo ...)": the printed text is the code
434
+ function fed(text) {
435
+ const m = /^\$\((.*)\)$|^`(.*)`$/s.exec(text.trim())
436
+ const printed = m && printedBy(m[1] || m[2], 'sh')
437
+ return printed || text
438
+ }
439
+
440
+ const TMUX_VALUE = new Set(['-s', '-n', '-t', '-c', '-x', '-y', '-e', '-f', '-F', '-l', '-L', '-S', '-T'])
441
+ const TMUX_RUN = new Set(['new', 'new-session', 'new-window', 'neww', 'split-window', 'splitw', 'run-shell', 'run', 'respawn-pane', 'respawnp', 'respawn-window', 'respawnw', 'send-keys', 'send'])
442
+
443
+ // tmux new -d 'cmd', tmux run-shell 'cmd', tmux send-keys 'cmd' Enter
444
+ function tmuxInner(vals) {
445
+ let k = 1
446
+ while (k < vals.length && /^-/.test(vals[k])) k += TMUX_VALUE.has(vals[k]) ? 2 : 1
447
+ if (!TMUX_RUN.has(vals[k])) return null
448
+ const sub = vals[k]
449
+ const words = []
450
+ for (k++; k < vals.length; k++) {
451
+ const v = vals[k]
452
+ if (/^-/.test(v) && !words.length) { k += TMUX_VALUE.has(v) ? 1 : 0; continue }
453
+ if (/^send/.test(sub) && /^(?:Enter|C-m|C-j|KPEnter)$/.test(v)) continue
454
+ words.push(v)
455
+ }
456
+ return words.length ? { text: words.join(' '), mode: 'sh' } : null
457
+ }
458
+
459
+ // screen -dmS name cmd args
460
+ function screenInner(argv) {
461
+ let k = 1
462
+ while (k < argv.length && /^-/.test(argv[k].v) && !argv[k].q) {
463
+ const v = argv[k].v
464
+ if (v === '--') { k++; break }
465
+ k += /^-[a-zA-Z]*[SsTtcpeXhLlQ]$/.test(v) && /[SsTtcpeXhLQ]$/.test(v) ? 2 : 1
466
+ }
467
+ return k < argv.length ? { text: argv.slice(k).map(w => w.v).join(' '), mode: 'sh' } : null
468
+ }
469
+
470
+ // script -q /dev/null cmd args (macOS), script -qc "cmd" /dev/null (Linux)
471
+ function scriptInner(vals) {
472
+ let k = 1
473
+ let c = false
474
+ while (k < vals.length && /^-/.test(vals[k])) {
475
+ if (/^-[a-zA-Z]*c$/.test(vals[k]) || vals[k] === '--command') { c = true; k++; break }
476
+ k += /^-[tFTW]$/.test(vals[k]) ? 2 : 1
477
+ }
478
+ if (c) return vals[k] ? { text: vals[k], mode: 'sh' } : null
479
+ k++ // the typescript file
480
+ return k < vals.length ? { text: vals.slice(k).join(' '), mode: 'sh' } : null
481
+ }
482
+
483
+ // parallel pkill ::: node, parallel pkill {} ::: a b
484
+ function parallelInner(vals) {
485
+ const at = vals.indexOf(':::')
486
+ if (at < 0) return null
487
+ let k = 1
488
+ while (k < vals.length && k < at && /^-/.test(vals[k])) k += /^-[jNnSaIlLsTtbCEiRrXx]$|^--(?:jobs|max-args|colsep|arg-file)$/.test(vals[k]) ? 2 : 1
489
+ const cmd = vals.slice(k, at)
490
+ const args = vals.slice(at + 1)
491
+ if (!cmd.length || !args.length) return null
492
+ const lines = args.slice(0, 50).map(a => cmd.some(w => w.includes('{}')) ? cmd.map(w => w.split('{}').join(a)).join(' ') : `${cmd.join(' ')} ${a}`)
493
+ return { text: lines.join('\n'), mode: 'sh' }
494
+ }
495
+
391
496
  // Takes leading wrappers off a command's words. Returns { argv, inner, remote, at }.
392
497
  function strip(src, words, mode) {
393
498
  let k = 0
@@ -422,6 +527,13 @@ function strip(src, words, mode) {
422
527
  continue
423
528
  }
424
529
  if (b === 'xargs') { k = skipOptions(words, k + 1, XARGS_VALUE); continue }
530
+ if (b === 'busybox' && words[k + 1] && !SHELLS.has(bin(words[k + 1].v))) { k++; continue }
531
+ // function k() { ...; } and k() { ...; }: the body runs when the function is called
532
+ if (!w.q && v === 'function' && words[k + 1]) {
533
+ k += words[k + 2] && words[k + 2].v === '()' ? 3 : 2
534
+ continue
535
+ }
536
+ if (!w.q && /^[A-Za-z_][\w-]*\(\)$/.test(v) && k === 0 && words[1] && words[1].v === '{') { k++; continue }
425
537
  break
426
538
  }
427
539
  const argv = words.slice(k)
@@ -431,7 +543,7 @@ function strip(src, words, mode) {
431
543
  }
432
544
 
433
545
  // Quick test before lexing for wrappers: most commands have none.
434
- const WRAPPER_HINT = /(?:^|[^\w-])(?:bash|sh|zsh|dash|ksh|ash|mksh|fish|busybox|powershell|pwsh|powershell_ise|cmd|eval|trap|watch|su|env|Start-Process|saps|start)(?:\.exe)?(?![\w-])/i
546
+ const WRAPPER_HINT = /(?:^|[^\w-])(?:bash|sh|zsh|dash|ksh|ash|mksh|fish|busybox|powershell|pwsh|powershell_ise|cmd|eval|trap|watch|su|env|Start-Process|saps|start|ssh|tmux|screen|script|parallel|source|\.)(?:\.exe)?(?![\w-])/i
435
547
  const REMOTE_HINT = /(?:^|[^\w-])(?:ssh|docker|podman|nerdctl|kubectl|oc|bulkhead)(?:\.exe)?(?![\w-])/i
436
548
 
437
549
  // Every simple command the text runs, with wrappers taken off and shell wrappers opened.
@@ -446,9 +558,27 @@ function commands(src, mode = 'sh', depth = 0, out = []) {
446
558
  return out
447
559
  }
448
560
 
561
+ // The text an echo or printf feeds into the shell that follows it in a pipe (echo "..." | sh),
562
+ // or null when cmd is not that shell. prev is the command before it.
563
+ function pipedScript(src, prev, c, s, mode) {
564
+ if (!prev || prev.seg !== c.seg || prev.sub !== c.sub || !s.argv.length || s.inner) return null
565
+ const b = bin(s.argv[0].v)
566
+ if (!SHELLS.has(b) && !(b === 'busybox' && SHELLS.has(bin((s.argv[1] || {}).v || '')))) return null
567
+ if (!/^\s*\|&?\s*$/.test(src.slice(prev.e, c.s))) return null
568
+ const text = printedBy(src.slice(prev.s, prev.e), mode)
569
+ return text
570
+ }
571
+
449
572
  function collect(src, cmds, mode, depth, out) {
573
+ let prev = null
450
574
  for (const c of cmds) {
451
575
  const s = strip(src, c.words, mode)
576
+ const piped = depth < MAX_DEPTH && pipedScript(src, prev, c, s, mode)
577
+ prev = c
578
+ if (piped) {
579
+ commands(piped, mode, depth + 1, out)
580
+ continue
581
+ }
452
582
  if (s.inner && depth < MAX_DEPTH) {
453
583
  commands(s.inner.text, s.inner.mode, depth + 1, out)
454
584
  continue
@@ -468,9 +598,18 @@ function unwrap(src, mode = 'sh', depth = 0, { remote = false } = {}) {
468
598
  const cmds = lex(src, mode).cmds.sort((a, b) => a.s - b.s)
469
599
  const parts = []
470
600
  let last = 0
601
+ let prev = null
471
602
  for (const c of cmds) {
603
+ const before = prev
604
+ prev = c
472
605
  if (c.s < last) continue
473
606
  const s = strip(src, c.words, mode)
607
+ const piped = pipedScript(src, before, c, s, mode)
608
+ if (piped && before.s >= last) {
609
+ parts.push(src.slice(last, before.s), `( ${unwrap(piped, mode, depth + 1, { remote })} )`)
610
+ last = c.e
611
+ continue
612
+ }
474
613
  const inner = s.inner || (remote && s.remote ? remoteInner(src, s.argv) : null)
475
614
  if (!inner) continue
476
615
  const from = s.argv[0].s
@@ -485,17 +624,82 @@ function unwrap(src, mode = 'sh', depth = 0, { remote = false } = {}) {
485
624
  // ---------------------------------------------------------------------------------------------
486
625
  // Text that is never executed
487
626
 
488
- const EXEC_WORDS = /(?:^|[\s;&|(`"$])(?:bash|sh|zsh|eval|source|pwsh|powershell|python[\d.]*|py|node|deno|bun|ruby|perl|php|osascript)(?:\.exe)?(?=[\s"')]|$)/
489
627
  const MESSAGE_TOOLS = new Set(['git', 'gh', 'glab', 'hub', 'hg', 'svn', 'jj', 'tea'])
490
628
  const MESSAGE_FLAGS = /^(?:-m|--message|--title|--body|--notes|--description|--subject|--comment|-[a-z]*m)$/
491
629
  const GH_SHORT = /^-[tbd]$/
492
630
 
493
- // Heredocs whose body is never run: written to a file by cat or tee, printed by cat, or handed
494
- // to git and gh as a message (git commit -F -, gh pr create --body-file -). The opener line
495
- // stays; the body and terminator go. A tag is searched for once, so missing terminators cost
496
- // one pass in total.
497
- function stripHeredocs(src) {
498
- const docs = heredocs(src, heredocNotRun)
631
+ // What reads a heredoc decides what its body is. A shell (or eval, source, ssh to this machine)
632
+ // runs it as shell code. An interpreter (python, node, ...) runs it as code in its own language,
633
+ // and ssh, docker exec and the like run it on another machine. Anything else, cat, tee, read,
634
+ // git, a loop, wc, a program with its own input, only reads it as data.
635
+ const INTERPRETERS = /^(?:python[\d.]*|pythonw|py|node|nodejs|deno|bun|ruby|perl|php|lua|osascript)$/
636
+ const KIND_RANK = { data: 0, interp: 1, remote: 2, shell: 3 }
637
+
638
+ // python3 - <<E and node <<E run what is on stdin; python3 script.py <<E, node -e 'code' <<E and
639
+ // python3 -m module <<E give stdin to a program as input.
640
+ function readsScriptFromStdin(argv) {
641
+ for (let k = 1; k < argv.length; k++) {
642
+ const v = argv[k].v
643
+ if (v === '-') return true
644
+ if (/^-(?:c|e|E|m|p|r|-eval|-print)$/.test(v) || !/^-/.test(v) || argv[k].q) return false
645
+ }
646
+ return true
647
+ }
648
+
649
+ function stageKind(text, mode) {
650
+ let kind = 'data'
651
+ for (const c of lex(text, mode).cmds) {
652
+ const s = strip(text, c.words, mode)
653
+ if (!s.argv.length) continue
654
+ const b = bin(s.argv[0].v)
655
+ let k = 'data'
656
+ if (s.remote) k = 'remote'
657
+ else if (s.inner || SHELLS.has(b) || b === 'eval' || b === 'source' || (b === '.' && s.argv.length > 1) || /^(?:powershell|pwsh|cmd)$/.test(b)) k = 'shell'
658
+ else if (INTERPRETERS.test(b) && readsScriptFromStdin(s.argv)) k = 'interp'
659
+ if (KIND_RANK[k] > KIND_RANK[kind]) { kind = k; if (k !== 'data') kind = k }
660
+ }
661
+ return kind
662
+ }
663
+
664
+ // The statement the heredoc belongs to starts after the last ; && || & ( { `do` or `then`.
665
+ function lastStage(prefix) {
666
+ let cut = 0
667
+ for (const m of prefix.matchAll(/;|&&|\|\||(?<![<>|&])&(?![>&])|(?<![$<>])\(|(?<!\$)\{|(?:^|\s)(?:do|then|else)\s/g)) cut = m.index + m[0].length
668
+ const stmt = prefix.slice(cut)
669
+ return stmt.slice(stmt.lastIndexOf('|') + 1)
670
+ }
671
+
672
+ // The pipeline after the opener: <<E | ssh host bash
673
+ function pipeStages(after) {
674
+ const end = after.search(/;|&&|\|\||(?<![<>|&])&(?![>&])|\n/)
675
+ const line = end < 0 ? after : after.slice(0, end)
676
+ return line.includes('|') ? line.split('|').slice(1) : []
677
+ }
678
+
679
+ // 'shell', 'remote', 'interp' or 'data', and the program that reads it.
680
+ function consumerOf(prefix, after, mode = 'sh') {
681
+ let kind = 'data'
682
+ for (const st of [lastStage(prefix), ...pipeStages(after)]) {
683
+ const k = stageKind(st, mode)
684
+ if (KIND_RANK[k] > KIND_RANK[kind]) kind = k
685
+ }
686
+ let name = ''
687
+ if (kind === 'interp') {
688
+ for (const st of [lastStage(prefix), ...pipeStages(after)]) {
689
+ const c = lex(st, mode).cmds.map(x => strip(st, x.words, mode).argv[0]).find(w => w && INTERPRETERS.test(bin(w.v)))
690
+ if (c) name = bin(c.v)
691
+ }
692
+ }
693
+ return { kind, bin: name }
694
+ }
695
+
696
+ // Heredocs whose body is not shell code are data and go: written to a file by cat or tee,
697
+ // counted by wc, read by a loop, handed to git or gh as a message, read by python or node (code in
698
+ // their own language, not shell). The opener line stays; the body and terminator go. keep lists
699
+ // the kinds that stay: ['shell'] by default, which is what a guard that reads shell text wants.
700
+ // A tag is searched for once, so missing terminators cost one pass in total.
701
+ function stripHeredocs(src, keep = ['shell'], mode = 'sh') {
702
+ const docs = heredocs(src, (prefix, after) => !keep.includes(consumerOf(prefix, after, mode).kind))
499
703
  if (!docs.length) return src
500
704
  let out = ''
501
705
  let last = 0
@@ -544,11 +748,67 @@ function heredocs(src, want) {
544
748
  return out
545
749
  }
546
750
 
547
- function heredocNotRun(prefix, after) {
548
- if (EXEC_WORDS.test(prefix)) return false
549
- const piped = after.indexOf('|') >= 0 && !/\|\s*(?:gh|git|glab|tee|cat)\b/.test(after)
550
- if (/(?:^|[\s;&|(`"])(?:cat|tee)\b[^|]*$/.test(prefix)) return !piped
551
- return /(?:^|[\s;&|(`"$])(?:git|gh|glab)\b/.test(prefix)
751
+ // Shell commands that interpreter code runs from string literals: os.system('pkill node'),
752
+ // subprocess.run(['pkill', 'node']), execSync("pkill node"), spawn('pkill', ['node']),
753
+ // system("pkill node"). Only literals are read; a command built at run time is not seen.
754
+ const SHELL_CALL = /(?:\b(?:os\.system|os\.popen|subprocess\.\w+|(?:child_process\.)?(?:exec|execSync|spawn|spawnSync|execFile|execFileSync)|system|popen|shell_exec|passthru|proc_open))\s*[({]\s*/g
755
+ const LITERAL = /^[rRbBfFuU]{0,2}(?:"((?:[^"\\\n]|\\.)*)"|'((?:[^'\\\n]|\\.)*)')/
756
+
757
+ // The code with string literals and comments blanked to spaces, same length: a call is found only
758
+ // where the code makes it, not inside text the code edits or prints (a python script that rewrites
759
+ // a markdown file holds os.system('...') as plain text).
760
+ function maskLiterals(code) {
761
+ const n = code.length
762
+ const out = code.split('')
763
+ const blank = (from, to) => { for (let k = from; k < to && k < n; k++) if (out[k] !== '\n') out[k] = ' ' }
764
+ let i = 0
765
+ while (i < n) {
766
+ const c = code[i]
767
+ const spaceBefore = i === 0 || /[\s;]/.test(code[i - 1])
768
+ if ((c === '#' || (c === '/' && code[i + 1] === '/')) && spaceBefore) {
769
+ const nl = code.indexOf('\n', i)
770
+ const end = nl < 0 ? n : nl
771
+ blank(i, end)
772
+ i = end
773
+ } else if (c === '/' && code[i + 1] === '*') {
774
+ const close = code.indexOf('*/', i + 2)
775
+ const end = close < 0 ? n : close + 2
776
+ blank(i, end)
777
+ i = end
778
+ } else if (c === '"' || c === "'" || c === '`') {
779
+ const q = (c !== '`' && code.startsWith(c.repeat(3), i)) ? c.repeat(3) : c
780
+ let k = i + q.length
781
+ while (k < n && !code.startsWith(q, k)) {
782
+ if (code[k] === '\\') k++
783
+ else if (q.length === 1 && c !== '`' && code[k] === '\n') break
784
+ k++
785
+ }
786
+ blank(i + q.length, k)
787
+ i = k + q.length
788
+ } else i++
789
+ }
790
+ return out.join('')
791
+ }
792
+
793
+ function embeddedShell(code) {
794
+ const out = []
795
+ code = String(code || '').slice(0, 200000)
796
+ const masked = maskLiterals(code)
797
+ for (const m of masked.matchAll(SHELL_CALL)) {
798
+ if (out.length >= 20) break
799
+ const words = []
800
+ let at = m.index + m[0].length
801
+ for (let n = 0; n < 40; n++) {
802
+ const rest = code.slice(at, at + 2000)
803
+ const lead = /^[\s[,]*/.exec(rest)[0]
804
+ const lit = LITERAL.exec(rest.slice(lead.length))
805
+ if (!lit) break
806
+ words.push((lit[1] !== undefined ? lit[1] : lit[2]).replace(/\\(["'\\])/g, '$1'))
807
+ at += lead.length + lit[0].length
808
+ }
809
+ if (words.length) out.push(words.join(' '))
810
+ }
811
+ return out
552
812
  }
553
813
 
554
814
  // The quoted text after -m, --message, --title, --body, --notes in git, gh and similar commands
@@ -584,8 +844,8 @@ function stripMessages(src, mode) {
584
844
  return out + src.slice(last)
585
845
  }
586
846
 
587
- function prepare(src, mode = 'sh') {
588
- return stripMessages(stripHeredocs(String(src || '')), mode)
847
+ function prepare(src, mode = 'sh', keep = ['shell']) {
848
+ return stripMessages(stripHeredocs(String(src || ''), keep, mode), mode)
589
849
  }
590
850
 
591
851
  // The lexer mode for a hook payload's tool.
@@ -594,4 +854,4 @@ function modeFor(input) {
594
854
  return /powershell/i.test(String(tool || '')) ? 'ps' : 'sh'
595
855
  }
596
856
 
597
- module.exports = { lex, commands, unwrap, prepare, stripHeredocs, stripMessages, heredocs, strip, bin, modeFor, skipOptions, MAX_DEPTH }
857
+ module.exports = { lex, commands, unwrap, prepare, stripHeredocs, stripMessages, heredocs, consumerOf, embeddedShell, strip, bin, modeFor, skipOptions, MAX_DEPTH }