claude-bulkhead 0.2.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +51 -0
- package/README.es.md +142 -50
- package/README.ja.md +142 -50
- package/README.ko.md +143 -51
- package/README.md +142 -50
- package/README.pt-BR.md +142 -50
- package/README.zh-CN.md +154 -62
- package/hooks/hooks.json +68 -8
- package/package.json +5 -2
- package/src/agent-hook.js +162 -0
- package/src/agent-setup.js +256 -0
- package/src/agents.js +89 -0
- package/src/allow-cli.js +65 -0
- package/src/allow-hint.js +62 -0
- package/src/allow-once.js +152 -0
- package/src/auto-queue.js +93 -0
- package/src/bash-guards.js +36 -0
- package/src/basics.js +46 -27
- package/src/checkout-guard.js +342 -74
- package/src/cli-util.js +13 -1
- package/src/cli.js +40 -4
- package/src/config-check.js +21 -4
- package/src/config-cli.js +75 -10
- package/src/config.js +106 -9
- package/src/dev-servers.js +361 -61
- package/src/disk-guard.js +8 -8
- package/src/doctor-enforcement.js +273 -0
- package/src/doctor.js +19 -9
- package/src/edit-guard.js +196 -0
- package/src/events.js +30 -5
- package/src/explain.js +251 -0
- package/src/git-info.js +55 -0
- package/src/gitargs.js +82 -0
- package/src/hook.js +38 -6
- package/src/kill-extras.js +219 -0
- package/src/kill-guard.js +228 -77
- package/src/kill-owners.js +116 -0
- package/src/log-cli.js +15 -10
- package/src/mcp.js +131 -0
- package/src/memory-guard.js +126 -24
- package/src/memory.js +21 -2
- package/src/notices.js +95 -0
- package/src/orphan-reaper.js +58 -11
- package/src/port-cli.js +78 -3
- package/src/port-guard.js +80 -68
- package/src/port-kills.js +198 -0
- package/src/pressure.js +75 -0
- package/src/processes.js +44 -12
- package/src/scan-report.js +166 -0
- package/src/scan.js +87 -18
- package/src/secrets-guard.js +301 -0
- package/src/self-allow.js +73 -0
- package/src/session-info.js +121 -0
- package/src/session-state.js +176 -0
- package/src/sessions.js +109 -20
- package/src/shell.js +279 -19
- package/src/slot-cli.js +92 -21
- package/src/slots.js +61 -13
- package/src/stable-port.js +61 -0
- package/src/status.js +55 -17
- package/src/statusline.js +45 -16
- package/src/subagent-guard.js +24 -0
- package/src/top.js +36 -11
- package/src/wait-guard.js +96 -32
- package/src/worktree-cli.js +454 -0
- package/src/worktree-copy.js +82 -0
package/src/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
|
-
|
|
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
|
-
|
|
110
|
-
|
|
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 =
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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: (
|
|
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,
|
|
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'
|
|
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
|
-
|
|
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 === '
|
|
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
|
-
//
|
|
494
|
-
//
|
|
495
|
-
//
|
|
496
|
-
//
|
|
497
|
-
|
|
498
|
-
|
|
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
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
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 }
|