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.
- package/CHANGELOG.md +90 -0
- package/LICENSE +21 -0
- package/README.es.md +498 -0
- package/README.ja.md +498 -0
- package/README.ko.md +498 -0
- package/README.md +497 -2
- package/README.pt-BR.md +498 -0
- package/README.zh-CN.md +498 -0
- package/bin/bulkhead +3 -0
- package/bin/bulkhead-slot +3 -0
- package/bin/bulkhead-statusline +3 -0
- package/hooks/hooks.json +39 -0
- package/package.json +52 -4
- package/src/basics.js +236 -0
- package/src/checkout-guard.js +296 -0
- package/src/cli-util.js +79 -0
- package/src/cli.js +41 -0
- package/src/config-check.js +29 -0
- package/src/config-cli.js +51 -0
- package/src/config.js +236 -0
- package/src/dev-servers.js +223 -0
- package/src/disk-guard.js +200 -0
- package/src/doctor.js +255 -0
- package/src/events.js +108 -0
- package/src/hook.js +94 -0
- package/src/kill-guard.js +435 -0
- package/src/log-cli.js +146 -0
- package/src/memory-guard.js +145 -0
- package/src/memory.js +76 -0
- package/src/orphan-reaper.js +527 -0
- package/src/paths.js +31 -0
- package/src/port-cli.js +59 -0
- package/src/port-guard.js +142 -0
- package/src/processes.js +181 -0
- package/src/reap-cli.js +56 -0
- package/src/remote-sh.js +478 -0
- package/src/remote.js +1034 -0
- package/src/remotes.js +126 -0
- package/src/scan.js +604 -0
- package/src/sessions.js +147 -0
- package/src/shell.js +597 -0
- package/src/slot-cli.js +150 -0
- package/src/slots.js +168 -0
- package/src/status.js +111 -0
- package/src/statusline.js +69 -0
- package/src/top-worker.js +14 -0
- package/src/top.js +305 -0
- 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 }
|