claude-bulkhead 0.0.0-stage → 0.2.2

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 (48) hide show
  1. package/CHANGELOG.md +90 -0
  2. package/LICENSE +21 -0
  3. package/README.es.md +498 -0
  4. package/README.ja.md +498 -0
  5. package/README.ko.md +498 -0
  6. package/README.md +497 -2
  7. package/README.pt-BR.md +498 -0
  8. package/README.zh-CN.md +498 -0
  9. package/bin/bulkhead +3 -0
  10. package/bin/bulkhead-slot +3 -0
  11. package/bin/bulkhead-statusline +3 -0
  12. package/hooks/hooks.json +39 -0
  13. package/package.json +52 -4
  14. package/src/basics.js +236 -0
  15. package/src/checkout-guard.js +296 -0
  16. package/src/cli-util.js +79 -0
  17. package/src/cli.js +41 -0
  18. package/src/config-check.js +29 -0
  19. package/src/config-cli.js +51 -0
  20. package/src/config.js +236 -0
  21. package/src/dev-servers.js +223 -0
  22. package/src/disk-guard.js +200 -0
  23. package/src/doctor.js +255 -0
  24. package/src/events.js +108 -0
  25. package/src/hook.js +94 -0
  26. package/src/kill-guard.js +435 -0
  27. package/src/log-cli.js +146 -0
  28. package/src/memory-guard.js +145 -0
  29. package/src/memory.js +76 -0
  30. package/src/orphan-reaper.js +527 -0
  31. package/src/paths.js +31 -0
  32. package/src/port-cli.js +59 -0
  33. package/src/port-guard.js +142 -0
  34. package/src/processes.js +181 -0
  35. package/src/reap-cli.js +56 -0
  36. package/src/remote-sh.js +478 -0
  37. package/src/remote.js +1034 -0
  38. package/src/remotes.js +126 -0
  39. package/src/scan.js +604 -0
  40. package/src/sessions.js +147 -0
  41. package/src/shell.js +597 -0
  42. package/src/slot-cli.js +150 -0
  43. package/src/slots.js +168 -0
  44. package/src/status.js +111 -0
  45. package/src/statusline.js +69 -0
  46. package/src/top-worker.js +14 -0
  47. package/src/top.js +305 -0
  48. package/src/wait-guard.js +263 -0
package/src/shell.js ADDED
@@ -0,0 +1,597 @@
1
+ 'use strict'
2
+ // Shell parsing shared by the guards.
3
+ //
4
+ // lex(text, mode) words and simple commands, quote aware, with $( ) and backticks
5
+ // commands(text, mode) the simple commands a line runs, wrappers taken off (sudo, env,
6
+ // VAR=value, nohup, time, nice, xargs, timeout) and shell wrappers
7
+ // opened (bash -c, sh -c, powershell -Command, cmd /c, eval), up to
8
+ // three levels deep
9
+ // unwrap(text, mode) the same text with each shell wrapper replaced by ( inner command )
10
+ // prepare(text, mode) the text without what is never executed: heredocs written to files
11
+ // or handed to git and gh, and git and gh message arguments
12
+ //
13
+ // mode is 'sh' (Bash tool), 'ps' (PowerShell tool) or 'cmd' (inside cmd /c). Every function is
14
+ // linear in the length of its input: no regular expression here can backtrack across the text.
15
+
16
+ const MAX_DEPTH = 3
17
+
18
+ // The program name of a command word: no directory, no Windows extension, lower case. Guards ask
19
+ // this many times per command, so short words are cached. A word of thousands of characters is
20
+ // not a program name, and reading it again for every check would cost a pass over it each time.
21
+ const BIN_CACHE = new Map()
22
+ function bin(v) {
23
+ const s = String(v)
24
+ if (s.length > 4096) return ''
25
+ let r = BIN_CACHE.get(s)
26
+ if (r !== undefined) return r
27
+ const cut = Math.max(s.lastIndexOf('/'), s.lastIndexOf('\\'))
28
+ r = (cut >= 0 ? s.slice(cut + 1) : s).replace(/\.(?:exe|cmd|bat|ps1)$/i, '').toLowerCase()
29
+ if (BIN_CACHE.size >= 4096) BIN_CACHE.clear()
30
+ BIN_CACHE.set(s, r)
31
+ return r
32
+ }
33
+
34
+ // ---------------------------------------------------------------------------------------------
35
+ // Lexer
36
+
37
+ // In the sh mode an unquoted backslash escapes only these; elsewhere it stays, so C:\Users survives.
38
+ const SH_ESCAPABLE = new Set([' ', '\t', '"', "'", '\\', '$', '`', ';', '&', '|', '(', ')', '<', '>'])
39
+ const LOOP_OPEN = new Set(['for', 'while', 'until', 'select', 'if', 'case'])
40
+ const LOOP_CLOSE = new Set(['done', 'fi', 'esac'])
41
+ // separators that end a statement; a pipe keeps the statement going
42
+ const STATEMENT_OPS = new Set([';', '\n', '&&', '||', '&'])
43
+
44
+ // Returns { cmds, unterminated }. Each command is { words, s, e, seg, sub }: words are
45
+ // { v (value, quotes removed), s, e (raw offsets), q (some part was quoted) }, seg numbers the
46
+ // top-level statement the command belongs to (a loop or a { } block is one statement), and sub
47
+ // is true inside $( ), backticks or <( ).
48
+ function lex(src, mode = 'sh') {
49
+ src = String(src)
50
+ const ps = mode === 'ps'
51
+ const cmd = mode === 'cmd'
52
+ const cmds = []
53
+ let unterminated = false
54
+ let seg = 0
55
+ let depth = 0 // loops, if blocks and braces at the top level
56
+ const stack = []
57
+ const frame = (kind, start) => ({ kind, start, words: [], word: null, quote: null, paren: 0, wordParen: 0 })
58
+ let f = frame('top', 0)
59
+
60
+ const startWord = i => { if (!f.word) f.word = { v: '', s: i, e: i, q: false } }
61
+ const endWord = i => {
62
+ const w = f.word
63
+ if (!w) return
64
+ w.e = i
65
+ if (f.kind === 'top' && !ps && !w.q && f.words.length === 0) {
66
+ if (LOOP_OPEN.has(w.v)) depth++
67
+ else if (LOOP_CLOSE.has(w.v)) depth = Math.max(0, depth - 1)
68
+ }
69
+ f.words.push(w)
70
+ f.word = null
71
+ f.wordParen = 0
72
+ }
73
+ const endCmd = () => {
74
+ if (f.words.length) {
75
+ const ws = f.words
76
+ cmds.push({ words: ws, s: ws[0].s, e: ws[ws.length - 1].e, seg, sub: f.kind !== 'top' })
77
+ }
78
+ f.words = []
79
+ }
80
+ const op = (i, o) => {
81
+ endWord(i)
82
+ endCmd()
83
+ if (f.kind === 'top' && depth === 0 && STATEMENT_OPS.has(o)) seg++
84
+ }
85
+ // Each closing $( ) copies its text into the word around it, so nesting is capped: past 16
86
+ // levels, which no real command reaches, $( is read as plain text and the copying stays linear.
87
+ const push = (kind, at) => {
88
+ if (stack.length >= 16) return false
89
+ stack.push(f)
90
+ f = frame(kind, at)
91
+ return true
92
+ }
93
+ const pop = i => {
94
+ endWord(i)
95
+ endCmd()
96
+ const inner = f
97
+ f = stack.pop()
98
+ f.word.v += src.slice(inner.start, i + 1)
99
+ }
100
+ // braces and parentheses group statements: `{ a; b; }`, `(cd web && x)`
101
+ const brace = c => {
102
+ if (f.kind !== 'top') return
103
+ if (c === '{' || c === '(') depth++
104
+ else if (c === '}' || c === ')') depth = Math.max(0, depth - 1)
105
+ }
106
+
107
+ const n = src.length
108
+ for (let i = 0; i < n; i++) {
109
+ const c = src[i]
110
+ const next = src[i + 1]
111
+ if (f.quote === "'") {
112
+ if (c === "'") {
113
+ if (ps && next === "'") { f.word.v += "'"; i++ } else f.quote = null
114
+ } else f.word.v += c
115
+ continue
116
+ }
117
+ if (f.quote === '"') {
118
+ if (!ps && !cmd && c === '\\' && (next === '"' || next === '\\' || next === '$' || next === '`' || next === '\n')) {
119
+ if (next !== '\n') f.word.v += next
120
+ i++
121
+ } else if (ps && c === '`' && next !== undefined) {
122
+ f.word.v += next
123
+ i++
124
+ } else if (c === '"') {
125
+ if (ps && next === '"') { f.word.v += '"'; i++ } else f.quote = null
126
+ } else if (!cmd && c === '$' && next === '(') {
127
+ if (!push('sub', i)) f.word.v += '$('
128
+ i++
129
+ } else if (!ps && !cmd && c === '`') {
130
+ if (!push('bt', i)) f.word.v += c
131
+ } else f.word.v += c
132
+ continue
133
+ }
134
+ // unquoted
135
+ if (f.kind === 'bt' && c === '`') { pop(i); continue }
136
+ if (f.kind === 'sub' && c === ')' && f.paren === 0 && f.wordParen === 0) { pop(i); continue }
137
+ if (c === ' ' || c === '\t' || c === '\r' || c === '\f' || c === '\v') { endWord(i); continue }
138
+ if (c === '\n' || c === ';') { op(i, c); continue }
139
+ if (c === '&') {
140
+ if (next === '&') { op(i, '&&'); i++; continue }
141
+ const prev = f.word ? src[i - 1] : ''
142
+ if (prev === '>' || prev === '<' || next === '>') { startWord(i); f.word.v += c; continue }
143
+ op(i, '&')
144
+ continue
145
+ }
146
+ if (c === '|') {
147
+ const prev = f.word ? src[i - 1] : ''
148
+ if (prev === '>') { f.word.v += c; continue }
149
+ if (next === '|') { op(i, '||'); i++; continue }
150
+ if (next === '&') { op(i, '|'); i++; continue }
151
+ op(i, '|')
152
+ continue
153
+ }
154
+ if (cmd && c === '^' && next !== undefined) { startWord(i); f.word.v += next; i++; continue }
155
+ if (ps && c === '`' && next !== undefined) {
156
+ if (next === '\n' || next === '\r') { i++; continue }
157
+ startWord(i)
158
+ f.word.v += next
159
+ i++
160
+ continue
161
+ }
162
+ if (!ps && !cmd && c === '\\') {
163
+ if (next === '\n') { endWord(i); i++; continue }
164
+ startWord(i)
165
+ if (next !== undefined && SH_ESCAPABLE.has(next)) { f.word.v += next; i++ } else f.word.v += c
166
+ continue
167
+ }
168
+ if (c === '(') {
169
+ if (f.word) {
170
+ // the raw previous character: reading the end of a word built by += would flatten it every time
171
+ const prev = src[i - 1]
172
+ if (!cmd && (prev === '<' || prev === '>') && i - f.word.s <= 2 && push('sub', i)) continue
173
+ f.word.v += c
174
+ f.wordParen++
175
+ continue
176
+ }
177
+ op(i, '(')
178
+ f.paren++
179
+ brace(c)
180
+ continue
181
+ }
182
+ if (c === ')') {
183
+ if (f.word && f.wordParen > 0) { f.word.v += c; f.wordParen--; continue }
184
+ op(i, ')')
185
+ f.paren = Math.max(0, f.paren - 1)
186
+ brace(c)
187
+ continue
188
+ }
189
+ if (!cmd && c === '$' && next === '(') {
190
+ startWord(i)
191
+ if (!push('sub', i)) f.word.v += '$('
192
+ i++
193
+ continue
194
+ }
195
+ if (!ps && !cmd && c === '`') {
196
+ startWord(i)
197
+ if (!push('bt', i)) f.word.v += c
198
+ continue
199
+ }
200
+ if (c === '#' && !f.word && !cmd) {
201
+ const nl = src.indexOf('\n', i)
202
+ i = (nl < 0 ? n : nl) - 1
203
+ continue
204
+ }
205
+ if ((c === '"' || (c === "'" && !cmd))) {
206
+ startWord(i)
207
+ f.word.q = true
208
+ f.quote = c
209
+ continue
210
+ }
211
+ if (c === '{' || c === '}') brace(c)
212
+ startWord(i)
213
+ f.word.v += c
214
+ }
215
+ for (;;) {
216
+ if (f.quote || f.kind !== 'top') unterminated = true
217
+ if (f.kind === 'top') break
218
+ pop(n - 1)
219
+ }
220
+ endWord(n)
221
+ endCmd()
222
+ return { cmds, unterminated }
223
+ }
224
+
225
+ // ---------------------------------------------------------------------------------------------
226
+ // Wrappers
227
+
228
+ const RESERVED = new Set(['!', '{', '}', 'do', 'then', 'else', 'elif', 'if', 'while', 'until', 'builtin', 'noglob', 'nocorrect'])
229
+ const SUDO_VALUE = new Set(['-u', '-g', '-p', '-C', '-h', '-U', '-r', '-t', '-D', '-R', '-T', '--user', '--group', '--prompt', '--close-from', '--host', '--other-user', '--role', '--type', '--chdir', '--chroot', '--command-timeout'])
230
+ const XARGS_VALUE = new Set(['-I', '-n', '-P', '-L', '-l', '-s', '-d', '-E', '-e', '-a', '--max-args', '--max-procs', '--max-lines', '--max-chars', '--delimiter', '--eof', '--arg-file', '--replace'])
231
+ const TIMEOUT_VALUE = new Set(['-s', '-k', '--signal', '--kill-after'])
232
+ const ENV_VALUE = new Set(['-u', '-C', '--unset', '--chdir'])
233
+ const NICE_VALUE = new Set(['-n', '--adjustment'])
234
+ const IONICE_VALUE = new Set(['-c', '-n', '--class', '--classdata'])
235
+ const STDBUF_VALUE = new Set(['-i', '-o', '-e'])
236
+ const WATCH_VALUE = new Set(['-n', '--interval', '-q', '--equexit'])
237
+ const SHELLS = new Set(['bash', 'sh', 'zsh', 'dash', 'ksh', 'ash', 'mksh', 'fish'])
238
+ const SHELL_VALUE = new Set(['-o', '+o', '-O', '+O', '--rcfile', '--init-file'])
239
+
240
+ // Index of the first word at or after k that is not an option. `--` ends the options.
241
+ function skipOptions(words, k, valued = new Set()) {
242
+ while (k < words.length) {
243
+ const v = words[k].v
244
+ if (v === '--') return k + 1
245
+ if (!/^[-+]./.test(v) || words[k].q) return k
246
+ k += valued.has(v) ? 2 : 1
247
+ }
248
+ return k
249
+ }
250
+
251
+ // Programs whose arguments run on another machine or in a container: the kill, checkout and
252
+ // delete guards look after this machine only.
253
+ function isRemote(argv) {
254
+ const b = bin(argv[0].v)
255
+ if (b === 'ssh' || b === 'autossh' || b === 'mosh') return true
256
+ // bulkhead remote run sends its command to another machine
257
+ if (b === 'bulkhead' && argv[1] && argv[1].v === 'remote') return true
258
+ const sub = argv.slice(1, 5).map(w => w.v)
259
+ if (/^(?:docker|podman|nerdctl|docker-compose|kubectl|oc|lxc|incus)$/.test(b)) return sub.includes('exec') || sub.includes('run') || sub.includes('debug')
260
+ if (b === 'vagrant' || b === 'gcloud' || b === 'az' || b === 'fly' || b === 'flyctl') return sub.includes('ssh')
261
+ if (b === 'heroku') return sub.includes('run')
262
+ return false
263
+ }
264
+
265
+ // UTF-16LE base64, as powershell -EncodedCommand takes it.
266
+ function decodeEncoded(s) {
267
+ try {
268
+ const out = Buffer.from(s, 'base64').toString('utf16le')
269
+ return /[\x20-\x7e]/.test(out) ? out : null
270
+ } catch {
271
+ return null
272
+ }
273
+ }
274
+
275
+ function rawFrom(src, words, k) {
276
+ return k < words.length ? src.slice(words[k].s, words[words.length - 1].e) : ''
277
+ }
278
+
279
+ const SSH_VALUE = new Set(['-p', '-i', '-l', '-o', '-F', '-J', '-L', '-R', '-D', '-b', '-c', '-E', '-e', '-m', '-O', '-Q', '-S', '-W', '-w', '-B', '-I'])
280
+ const EXEC_VALUE = new Set(['-e', '--env', '-u', '--user', '-w', '--workdir', '--env-file', '--detach-keys', '-c', '--container', '-n', '--namespace', '--index'])
281
+
282
+ // The command ssh, docker exec or kubectl exec runs on the other side: { text, mode } or null.
283
+ function remoteInner(src, argv) {
284
+ const b = bin(argv[0].v)
285
+ const vals = argv.map(w => w.v)
286
+ let k
287
+ if (b === 'ssh') {
288
+ k = skipOptions(argv, 1, SSH_VALUE) + 1
289
+ } else if (b === 'bulkhead') {
290
+ // bulkhead remote run [options] -- <command>: this side waits for it like ssh does
291
+ const dash = vals.indexOf('--')
292
+ if (dash < 0) return null
293
+ k = dash + 1
294
+ } else {
295
+ const at = vals.indexOf('exec')
296
+ if (at < 1 || at > 3) return null
297
+ const dash = vals.indexOf('--', at)
298
+ if (vals.some(v => v === '-d' || v === '--detach')) return null
299
+ if (dash > 0) k = dash + 1
300
+ else if (b === 'kubectl' || b === 'oc') return null
301
+ else k = skipOptions(argv, at + 1, EXEC_VALUE) + 1
302
+ }
303
+ if (k >= argv.length) return null
304
+ return { text: k === argv.length - 1 ? vals[k] : rawFrom(src, argv, k), mode: 'sh' }
305
+ }
306
+
307
+ // The inner command of a shell wrapper: { text, mode } or null.
308
+ function innerOf(src, argv, mode) {
309
+ const b = bin(argv[0].v)
310
+ const vals = argv.map(w => w.v)
311
+ if (SHELLS.has(b) || (b === 'busybox' && SHELLS.has(bin(vals[1] || '')))) {
312
+ let k = b === 'busybox' ? 2 : 1
313
+ let c = false
314
+ while (k < argv.length && /^[-+]/.test(vals[k]) && !argv[k].q) {
315
+ const v = vals[k]
316
+ if (v === '--' || v === '-') { k++; break }
317
+ if (/^-[a-zA-Z]+$/.test(v) && v.includes('c')) c = true
318
+ else if (v === '--command') c = true
319
+ k += SHELL_VALUE.has(v) ? 2 : 1
320
+ }
321
+ return c && k < argv.length ? { text: vals[k], mode: 'sh' } : null
322
+ }
323
+ if (b === 'powershell' || b === 'pwsh' || b === 'powershell_ise') {
324
+ for (let k = 1; k < argv.length; k++) {
325
+ const v = vals[k].toLowerCase()
326
+ if (!v.startsWith('-') || argv[k].q) {
327
+ if (/\.ps1$/i.test(vals[k])) return null
328
+ return { text: vals.slice(k).join(' '), mode: 'ps' }
329
+ }
330
+ if (/^-(?:c|co|com|comm|comma|comman|command)$/.test(v)) {
331
+ const rest = vals.slice(k + 1)
332
+ if (!rest.length || rest[0] === '-') return null
333
+ return { text: rest.join(' '), mode: 'ps' }
334
+ }
335
+ if (/^-(?:e|ec|en|enc|enco|encod|encode|encoded|encodedc|encodedco|encodedcom|encodedcomm|encodedcomma|encodedcomman|encodedcommand)$/.test(v)) {
336
+ const text = decodeEncoded(vals[k + 1] || '')
337
+ return text ? { text, mode: 'ps' } : null
338
+ }
339
+ if (/^-(?:f|fi|fil|file)$/.test(v)) return null
340
+ if (/^-(?:ex|ep|executionpolicy|w|win|windowstyle|wd|workingdirectory|configurationname|of|outputformat|if|inputformat|v|version|psconsolefile|custompipename|settingsfile)$/.test(v)) k++
341
+ }
342
+ return null
343
+ }
344
+ if (b === 'cmd') {
345
+ for (let k = 1; k < argv.length; k++) {
346
+ const v = vals[k].toLowerCase()
347
+ if (/^\/\/?[ck]$/.test(v)) {
348
+ let text = rawFrom(src, argv, k + 1)
349
+ if (text.length > 1 && text[0] === '"' && text[text.length - 1] === '"') text = text.slice(1, -1)
350
+ return text ? { text, mode: 'cmd' } : null
351
+ }
352
+ if (!/^\/\/?[a-z](?::\w+)?$/.test(v)) return null
353
+ }
354
+ return null
355
+ }
356
+ if (b === 'eval' && argv.length > 1) return { text: vals.slice(1).join(' '), mode }
357
+ if (b === 'trap' && argv.length > 1 && vals[1] !== '-' && !vals[1].startsWith('-')) return { text: vals[1], mode }
358
+ if (b === 'watch') {
359
+ const k = skipOptions(argv, 1, WATCH_VALUE)
360
+ return k < argv.length ? { text: vals.slice(k).join(' '), mode } : null
361
+ }
362
+ if (b === 'su') {
363
+ const i = vals.findIndex((v, j) => j > 0 && (v === '-c' || v === '--command'))
364
+ if (i > 0 && vals[i + 1]) return { text: vals[i + 1], mode: 'sh' }
365
+ const eq = vals.find(v => v.startsWith('--command='))
366
+ return eq ? { text: eq.slice(10), mode: 'sh' } : null
367
+ }
368
+ if (b === 'env') {
369
+ const i = vals.findIndex((v, j) => j > 0 && (v === '-S' || v === '--split-string'))
370
+ if (i > 0 && vals[i + 1]) return { text: vals.slice(i + 1).join(' '), mode: 'sh' }
371
+ }
372
+ if (b === 'start-process' || b === 'saps' || (b === 'start' && mode === 'ps')) {
373
+ let file = null
374
+ let args = null
375
+ const pos = []
376
+ for (let k = 1; k < argv.length; k++) {
377
+ const v = vals[k].toLowerCase()
378
+ if (!argv[k].q && /^-(?:filepath|fi|file|path)$/.test(v)) file = vals[++k]
379
+ else if (!argv[k].q && /^-(?:argumentlist|args|arguments|a)$/.test(v)) args = vals[++k]
380
+ else if (!argv[k].q && v.startsWith('-')) {
381
+ if (/^-(?:workingdirectory|wo|redirectstandard\w*|verb|windowstyle|credential|wait)$/.test(v) && !/^-wait$/.test(v)) k++
382
+ } else pos.push(vals[k])
383
+ }
384
+ file = file || pos[0]
385
+ args = args || pos[1] || ''
386
+ return file ? { text: `${file} ${String(args).replace(/,/g, ' ')}`.trim(), mode: 'cmd' } : null
387
+ }
388
+ return null
389
+ }
390
+
391
+ // Takes leading wrappers off a command's words. Returns { argv, inner, remote, at }.
392
+ function strip(src, words, mode) {
393
+ let k = 0
394
+ for (let guard = 0; k < words.length && guard < 64; guard++) {
395
+ const w = words[k]
396
+ const v = w.v
397
+ if (!w.q && RESERVED.has(v)) { k++; continue }
398
+ if (!w.q && /^[A-Za-z_]\w*\+?=/.test(v)) { k++; continue }
399
+ const b = bin(v)
400
+ if (b === 'sudo' || b === 'doas' || b === 'run0') { k = skipOptions(words, k + 1, SUDO_VALUE); continue }
401
+ if (b === 'nohup' || b === 'unbuffer' || b === 'setsid' || b === 'caffeinate' || b === 'cross-env' || b === 'chronic') { k = skipOptions(words, k + 1); continue }
402
+ if (b === 'exec' || b === 'time') { k = skipOptions(words, k + 1, new Set(['-a'])); continue }
403
+ if (b === 'command') {
404
+ if (words[k + 1] && /^-[a-zA-Z]*[vV]/.test(words[k + 1].v)) return { argv: [], inner: null, remote: false }
405
+ k = skipOptions(words, k + 1)
406
+ continue
407
+ }
408
+ if (b === 'nice') { k = skipOptions(words, k + 1, NICE_VALUE); continue }
409
+ if (b === 'ionice') { k = skipOptions(words, k + 1, IONICE_VALUE); continue }
410
+ if (b === 'stdbuf') { k = skipOptions(words, k + 1, STDBUF_VALUE); continue }
411
+ if (b === 'timeout' || b === 'gtimeout') {
412
+ const j = skipOptions(words, k + 1, TIMEOUT_VALUE)
413
+ if (words[j] && /^\d/.test(words[j].v)) { k = j + 1; continue }
414
+ break
415
+ }
416
+ if (b === 'env') {
417
+ const j = skipOptions(words, k + 1, ENV_VALUE)
418
+ let split = false
419
+ for (let x = k + 1; x < j; x++) if (words[x].v === '-S' || words[x].v === '--split-string') split = true
420
+ if (split) break
421
+ k = j
422
+ continue
423
+ }
424
+ if (b === 'xargs') { k = skipOptions(words, k + 1, XARGS_VALUE); continue }
425
+ break
426
+ }
427
+ const argv = words.slice(k)
428
+ if (!argv.length) return { argv, inner: null, remote: false }
429
+ if (isRemote(argv)) return { argv, inner: null, remote: true }
430
+ return { argv, inner: innerOf(src, argv, mode), remote: false }
431
+ }
432
+
433
+ // 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
435
+ const REMOTE_HINT = /(?:^|[^\w-])(?:ssh|docker|podman|nerdctl|kubectl|oc|bulkhead)(?:\.exe)?(?![\w-])/i
436
+
437
+ // Every simple command the text runs, with wrappers taken off and shell wrappers opened.
438
+ // Each item is { argv, words, remote, depth, sub }.
439
+ function commands(src, mode = 'sh', depth = 0, out = []) {
440
+ src = String(src || '')
441
+ const { cmds, unterminated } = lex(src, mode)
442
+ collect(src, cmds, mode, depth, out)
443
+ // A quote that never closes is a syntax error for the shell, which then runs nothing. Read it
444
+ // once more without quotes so a stray apostrophe cannot hide a command.
445
+ if (unterminated && depth === 0) collect(src, lex(src.replace(/["'`]/g, ' '), mode).cmds, mode, depth, out)
446
+ return out
447
+ }
448
+
449
+ function collect(src, cmds, mode, depth, out) {
450
+ for (const c of cmds) {
451
+ const s = strip(src, c.words, mode)
452
+ if (s.inner && depth < MAX_DEPTH) {
453
+ commands(s.inner.text, s.inner.mode, depth + 1, out)
454
+ continue
455
+ }
456
+ if (s.argv.length) out.push({ argv: s.argv.map(w => w.v), words: s.argv, remote: s.remote, depth, sub: c.sub, seg: c.seg, s: c.s, e: c.e })
457
+ }
458
+ }
459
+
460
+ // The text with each shell wrapper replaced by ( inner command ), recursively. Prefixes such as
461
+ // timeout 60 stay, so a guard still sees them. Returns the input when there is nothing to open.
462
+ // With remote: true, the command ssh, docker exec or kubectl exec runs is opened too: it runs
463
+ // elsewhere, but the session still waits for it.
464
+ function unwrap(src, mode = 'sh', depth = 0, { remote = false } = {}) {
465
+ src = String(src || '')
466
+ if (depth >= MAX_DEPTH || !(WRAPPER_HINT.test(src) || (remote && REMOTE_HINT.test(src)))) return src
467
+ // commands inside $( ) end before the command around them, so put them in text order
468
+ const cmds = lex(src, mode).cmds.sort((a, b) => a.s - b.s)
469
+ const parts = []
470
+ let last = 0
471
+ for (const c of cmds) {
472
+ if (c.s < last) continue
473
+ const s = strip(src, c.words, mode)
474
+ const inner = s.inner || (remote && s.remote ? remoteInner(src, s.argv) : null)
475
+ if (!inner) continue
476
+ const from = s.argv[0].s
477
+ parts.push(src.slice(last, from), `( ${unwrap(inner.text, inner.mode, depth + 1, { remote })} )`)
478
+ last = c.e
479
+ }
480
+ if (!parts.length) return src
481
+ parts.push(src.slice(last))
482
+ return parts.join('')
483
+ }
484
+
485
+ // ---------------------------------------------------------------------------------------------
486
+ // Text that is never executed
487
+
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
+ const MESSAGE_TOOLS = new Set(['git', 'gh', 'glab', 'hub', 'hg', 'svn', 'jj', 'tea'])
490
+ const MESSAGE_FLAGS = /^(?:-m|--message|--title|--body|--notes|--description|--subject|--comment|-[a-z]*m)$/
491
+ const GH_SHORT = /^-[tbd]$/
492
+
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)
499
+ if (!docs.length) return src
500
+ let out = ''
501
+ let last = 0
502
+ for (const d of docs) {
503
+ out += src.slice(last, d.start) + ' ' + d.rest
504
+ last = d.end
505
+ }
506
+ return out + src.slice(last)
507
+ }
508
+
509
+ // Heredocs for which want(prefix, after) holds, in order: { start, end, prefix, rest, body }.
510
+ // prefix is the opener line before <<TAG (at most 300 characters), rest the line after it.
511
+ // A heredoc whose terminator never comes is left alone. Linear: each tag's terminator is
512
+ // searched for once, and the line-start search never leaves a 300-character window.
513
+ function heredocs(src, want) {
514
+ const out = []
515
+ if (src.indexOf('<<') < 0) return out
516
+ const opener = /<<(-?)[ \t]*(['"]?)([\w.-]+)\2/g
517
+ let lines = null
518
+ const missing = new Set()
519
+ let last = 0
520
+ let lineEnd = -1
521
+ let m
522
+ while ((m = opener.exec(src))) {
523
+ if (src[m.index - 1] === '<' || src[m.index + 2] === '<') continue
524
+ const tag = m[3]
525
+ if (missing.has(tag)) continue
526
+ const win = src.slice(Math.max(last, m.index - 300), m.index)
527
+ const prefix = win.slice(win.lastIndexOf('\n') + 1)
528
+ if (lineEnd < m.index) {
529
+ lineEnd = src.indexOf('\n', m.index)
530
+ if (lineEnd < 0) lineEnd = src.length
531
+ }
532
+ const after = src.slice(m.index + m[0].length, Math.min(lineEnd, m.index + m[0].length + 300))
533
+ if (!want(prefix, after)) continue
534
+ if (!lines) lines = new Set(src.split('\n').map(l => l.trim()))
535
+ if (!lines.has(tag)) { missing.add(tag); continue }
536
+ const end = new RegExp(`\\n[ \\t]*${tag.replace(/[.]/g, '\\.')}[ \\t]*(?=\\r?\\n|$)`, 'g')
537
+ end.lastIndex = lineEnd
538
+ const e = lineEnd < src.length ? end.exec(src) : null
539
+ if (!e) { missing.add(tag); continue }
540
+ out.push({ start: m.index, end: e.index + e[0].length, prefix, rest: src.slice(m.index + m[0].length, lineEnd), body: src.slice(lineEnd + 1, e.index) })
541
+ last = opener.lastIndex = e.index + e[0].length
542
+ lineEnd = -1
543
+ }
544
+ return out
545
+ }
546
+
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)
552
+ }
553
+
554
+ // The quoted text after -m, --message, --title, --body, --notes in git, gh and similar commands
555
+ // becomes '' so its words never look like a command.
556
+ function stripMessages(src, mode) {
557
+ if (!/\b(?:git|gh|glab|hub|hg|svn|jj|tea)\b/i.test(src)) return src
558
+ const { cmds } = lex(src, mode)
559
+ const cut = []
560
+ for (const c of cmds) {
561
+ const s = strip(src, c.words, mode)
562
+ if (!s.argv.length || !MESSAGE_TOOLS.has(bin(s.argv[0].v))) continue
563
+ const gh = /^(?:gh|glab|tea)$/.test(bin(s.argv[0].v))
564
+ const ws = s.argv
565
+ for (let k = 1; k < ws.length; k++) {
566
+ const v = ws[k].v
567
+ const eq = /^(--(?:message|title|body|notes|description|subject|comment))=/.exec(v)
568
+ if (eq && ws[k].q) { cut.push([ws[k].s, ws[k].e, eq[0] + "''"]); continue }
569
+ if ((MESSAGE_FLAGS.test(v) || (gh && GH_SHORT.test(v))) && !ws[k].q && ws[k + 1] && (ws[k + 1].q || ws[k + 1].v.includes('$('))) {
570
+ cut.push([ws[k + 1].s, ws[k + 1].e, "''"])
571
+ k++
572
+ }
573
+ }
574
+ }
575
+ if (!cut.length) return src
576
+ cut.sort((a, b) => a[0] - b[0])
577
+ let out = ''
578
+ let last = 0
579
+ for (const [s, e, r] of cut) {
580
+ if (s < last) continue
581
+ out += src.slice(last, s) + r
582
+ last = e
583
+ }
584
+ return out + src.slice(last)
585
+ }
586
+
587
+ function prepare(src, mode = 'sh') {
588
+ return stripMessages(stripHeredocs(String(src || '')), mode)
589
+ }
590
+
591
+ // The lexer mode for a hook payload's tool.
592
+ function modeFor(input) {
593
+ const tool = input && input.tool_name
594
+ return /powershell/i.test(String(tool || '')) ? 'ps' : 'sh'
595
+ }
596
+
597
+ module.exports = { lex, commands, unwrap, prepare, stripHeredocs, stripMessages, heredocs, strip, bin, modeFor, skipOptions, MAX_DEPTH }