ai-install-guard 0.1.0 → 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.
@@ -0,0 +1,728 @@
1
+ // What an install asks for, read from the text alone: the arguments of a
2
+ // `/plugin` command the person typed, or a shell command Claude is about to
3
+ // run. Nothing here runs anything or touches the disk.
4
+
5
+ import { safeLine } from '../scan/safe-text'
6
+
7
+ /** One thing to add to Claude Code: a plugin from a marketplace, or a marketplace. */
8
+ export type InstallRequest =
9
+ | { kind: 'plugin'; verb: 'install' | 'update'; plugin: string; marketplace?: string }
10
+ | { kind: 'marketplace'; source: string }
11
+
12
+ export type Reading =
13
+ /** Not an install, or Claude Code's own menu to pick one from, which the guard cannot hold yet. */
14
+ | { kind: 'pass'; browse: boolean }
15
+ | { kind: 'install'; request: InstallRequest; label: string }
16
+ /** An install the guard cannot read well enough to scan: it is held as Could not verify. */
17
+ | { kind: 'unreadable'; label: string; reason: string }
18
+
19
+ const PASS: Reading = { kind: 'pass', browse: false }
20
+ const BROWSE: Reading = { kind: 'pass', browse: true }
21
+
22
+ /** A plugin as `install` takes it: `name` or `name@marketplace`. */
23
+ const PLUGIN_ID = /^([A-Za-z0-9][\w.-]{0,127})(?:@([A-Za-z0-9][\w.-]{0,127}))?$/
24
+
25
+ /** Options of `plugin install` and `plugin update` that take a value, and those that do not. */
26
+ const INSTALL_VALUES = new Set(['--marketplace', '--scope', '-s', '--config', '--registry', '--accept-command'])
27
+ const INSTALL_FLAGS = new Set(['--json', '-y', '--yes'])
28
+ const MARKETPLACE_VALUES = new Set(['--scope'])
29
+ const MARKETPLACE_FLAGS = new Set(['--json'])
30
+
31
+ /** The arguments of `/plugin` as the person typed them. */
32
+ export function readPluginArgs(args: string): Reading {
33
+ const words = splitShell(args).commands.flatMap(item => item.words)
34
+ const sub = words[0]?.toLowerCase()
35
+ if (sub === undefined) return BROWSE
36
+ if (sub === 'install' || sub === 'i') return readInstall(words.slice(1), 'install', '/plugin install')
37
+ if (sub === 'update') return readInstall(words.slice(1), 'update', '/plugin update')
38
+ if (sub === 'marketplace' && words[1]?.toLowerCase() === 'add') return readMarketplaceAdd(words.slice(2), '/plugin marketplace add')
39
+ return PASS
40
+ }
41
+
42
+ /**
43
+ * A shell command Claude is about to run. It is held when it installs a
44
+ * plugin or adds a marketplace through the `claude` command, also behind
45
+ * `env`, `sudo` and the like, inside `sh -c`, `eval` or a here-document. An
46
+ * install is approved as that exact command, so it must be readable whole: a
47
+ * command that also runs something else, or that is only known as it runs,
48
+ * is held as one the guard cannot read, never let through.
49
+ */
50
+ export function readShellCommand(command: string): Reading {
51
+ // This runs before every shell command Claude runs: it must never fail one
52
+ // that installs nothing, and never wave one through that might.
53
+ try {
54
+ return readShell(command)
55
+ } catch {
56
+ return MAY_INSTALL.test(command) ? { kind: 'unreadable', label: 'a claude plugin command', reason: 'the guard could not read the command' } : PASS
57
+ }
58
+ }
59
+
60
+ function readShell(command: string): Reading {
61
+ const found: Reading[] = []
62
+ const { unseen, opaque, others } = collectInstalls(command, found, 0, true)
63
+ const installs = found.filter(reading => reading.kind !== 'pass')
64
+ if (installs.length > 1) return { kind: 'unreadable', label: 'several installs', reason: 'the command installs more than one thing; run each install as a command of its own' }
65
+ const [only] = installs
66
+ if (only !== undefined) {
67
+ if (only.kind !== 'install') return only
68
+ // Approving the install approves the whole command, so nothing else may ride along.
69
+ if (unseen || opaque) return { kind: 'unreadable', label: only.label, reason: `part of the command is only known as it runs, so approving it could run more than the install; run ${only.label} as a command of its own` }
70
+ if (others > 0) return { kind: 'unreadable', label: only.label, reason: `the command runs more than the install; run ${only.label} as a command of its own, so approving it approves only the install` }
71
+ // The guard cannot see the shell's current folder, so a source named
72
+ // relative to it could be another folder than the one the guard scans.
73
+ if (only.request.kind === 'marketplace' && !/^(?:https:\/\/|\/|~\/)/i.test(only.request.source)) {
74
+ return {
75
+ kind: 'unreadable',
76
+ label: only.label,
77
+ reason: `"${safeLine(only.request.source)}" could be a folder in the shell's current folder or a repository; name it as a full path or as https://github.com/<owner>/<repo>, so the guard scans what is added`,
78
+ }
79
+ }
80
+ return only
81
+ }
82
+ // A shell or eval told to run text only known as it runs could run anything, an install included.
83
+ if (opaque) {
84
+ return { kind: 'unreadable', label: 'a command built as it runs', reason: 'it runs a command only known as it runs (as in bash -c "$CMD" or eval "$X"), so the guard cannot tell whether it installs anything; run the command itself instead' }
85
+ }
86
+ if (unseen && (MAY_INSTALL.test(command) || (CLAUDE_WORD.test(command) && /\bplugins?\b/i.test(command)))) {
87
+ return { kind: 'unreadable', label: 'a claude plugin command', reason: 'part of the command is only known as it runs, so the guard cannot tell what it installs; run claude plugin install <name>@<marketplace> as a plain command' }
88
+ }
89
+ return PASS
90
+ }
91
+
92
+ /**
93
+ * The words of a plugin install however they are joined: by spaces, quotes or
94
+ * the commas of a list in another language (`['plugin', 'install']`).
95
+ */
96
+ const MAY_INSTALL = /\bplugins?\b[^\w\n]{1,8}(?:install|i|update|marketplace)\b/i
97
+
98
+ /** The `claude` command's names as words of their own, not part of a path such as `~/.claude/`. */
99
+ const CLAUDE_WORD = /(?<![\w./-])claude(?:-code|\.exe)?(?![\w./-])/i
100
+
101
+ /** Names the `claude` command goes by. */
102
+ const CLAUDE = new Set(['claude', 'claude.exe', 'claude-code'])
103
+
104
+ /** Programs that run their `-c` argument as a script. */
105
+ const SHELLS = new Set(['sh', 'bash', 'zsh', 'dash', 'ksh', 'fish'])
106
+
107
+ /**
108
+ * Programs that run code in another language: given inline, from their
109
+ * input or from a file, it is code the guard does not read.
110
+ */
111
+ const INTERPRETERS = new Set(['python', 'python2', 'python3', 'node', 'nodejs', 'deno', 'bun', 'perl', 'ruby', 'php', 'osascript', 'pwsh', 'powershell', 'lua', 'tclsh', 'rscript', 'julia', 'swift', 'groovy', 'jshell'])
112
+
113
+ /**
114
+ * Programs that only print, read or wait: an install's words among their
115
+ * arguments are text, and running one beside an install adds nothing to it.
116
+ */
117
+ const QUIET = new Set(['echo', 'printf', 'grep', 'egrep', 'fgrep', 'rg', 'ag', 'cat', 'head', 'tail', 'less', 'more', 'jq', 'sort', 'uniq', 'wc', 'tr', 'cut', 'ls', 'pwd', 'true', 'false', 'sleep', 'test', '[', 'date', 'which', 'type', 'column', 'nl', 'fold'])
118
+
119
+ /** A quiet program that writes a file all the same: `sort -o`, `uniq in out`. */
120
+ function writesAnyway(program: string, args: readonly string[]): boolean {
121
+ if (program === 'sort') return args.some(arg => /^--output/.test(arg) || /^-[A-Za-z]*o/.test(arg))
122
+ if (program === 'uniq') return args.filter(arg => !arg.startsWith('-')).length > 1
123
+ return false
124
+ }
125
+
126
+ /**
127
+ * Programs that run the command that follows their own options: the options
128
+ * that take a value, how many plain words come before the command, whether
129
+ * `NAME=value` words may come first, and whether the command's words come in
130
+ * part from input (xargs).
131
+ */
132
+ type Wrapper = { values?: readonly string[]; positionals?: number; assignments?: boolean; fromInput?: boolean; script?: readonly string[] }
133
+ const WRAPPERS: Record<string, Wrapper> = {
134
+ env: { values: ['-u', '--unset', '-C', '--chdir'], assignments: true, script: ['-S', '--split-string'] },
135
+ sudo: { values: ['-u', '--user', '-g', '--group', '-h', '--host', '-p', '--prompt', '-C', '--close-from', '-D', '--chdir', '-r', '--role', '-t', '--type', '-T', '--command-timeout', '-U', '--other-user'], assignments: true },
136
+ doas: { values: ['-u', '-C'] },
137
+ command: {},
138
+ exec: { values: ['-a'] },
139
+ nohup: {},
140
+ time: {},
141
+ nice: { values: ['-n', '--adjustment'] },
142
+ timeout: { values: ['-s', '--signal', '-k', '--kill-after'], positionals: 1 },
143
+ stdbuf: { values: ['-i', '-o', '-e', '--input', '--output', '--error'] },
144
+ caffeinate: { values: ['-t', '-w'] },
145
+ ionice: { values: ['-c', '-n', '-p', '--class', '--classdata', '--pid'] },
146
+ setsid: {},
147
+ chronic: {},
148
+ unbuffer: {},
149
+ // Multi-call programs: the next word names the program they act as.
150
+ busybox: {},
151
+ toybox: {},
152
+ xargs: { values: ['-I', '-i', '-n', '-P', '-L', '-l', '-s', '-d', '-E', '-e', '-a', '--max-args', '--max-procs', '--max-lines', '--max-chars', '--delimiter', '--eof', '--arg-file', '--replace'], fromInput: true },
153
+ }
154
+
155
+ /** Programs whose text arguments are messages and names, never commands: a commit message or a pull request may quote an install. */
156
+ const TEXT_ARGUMENTS = new Set(['git', 'gh'])
157
+
158
+ /**
159
+ * Configuration keys whose value git runs as a command (a `!` alias, a pager,
160
+ * an editor, a helper or driver), or that point it at hooks to run.
161
+ */
162
+ const GIT_RUNS = /^(?:alias\.[^=]+|core\.(?:pager|editor|sshcommand|fsmonitor|hookspath|askpass)|sequence\.editor|diff\.external|gpg\.(?:[^.]+\.)?program|credential\.(?:[^=]+\.)?helper|(?:diff|filter|merge|difftool|mergetool|pager|uploadpack|receivepack|remote\.[^.]+)\.[^=]+)=/i
163
+
164
+ /** Git's options that take a value before its subcommand. */
165
+ const GIT_VALUES = new Set(['-c', '-C', '--git-dir', '--work-tree', '--namespace', '--exec-path', '--config-env'])
166
+
167
+ /**
168
+ * The shell text git itself runs, from its arguments: a configuration value
169
+ * it runs (`-c alias.x='!…'`, `-c core.pager=…`), `rebase --exec`,
170
+ * `bisect run` and `submodule foreach`. Everything else git takes, messages
171
+ * and names included, is text. `hooks` is set when a configuration points it
172
+ * at hooks the guard cannot read.
173
+ */
174
+ function gitScripts(args: readonly string[]): { scripts: string[]; hooks: boolean } {
175
+ const scripts: string[] = []
176
+ let hooks = false
177
+ let index = 0
178
+ // Global options, before the subcommand.
179
+ for (; index < args.length; index += 1) {
180
+ const arg = args[index] as string
181
+ if (!arg.startsWith('-')) break
182
+ const config = arg === '-c' ? args[index + 1] : arg.startsWith('-c') ? arg.slice(2) : undefined
183
+ if (config !== undefined && GIT_RUNS.test(config)) {
184
+ if (/^core\.hookspath=/i.test(config)) hooks = true
185
+ else scripts.push(config.slice(config.indexOf('=') + 1).replace(/^!/, ''))
186
+ }
187
+ if (GIT_VALUES.has(arg)) index += 1
188
+ }
189
+ const sub = args[index]
190
+ const rest = args.slice(index + 1)
191
+ if (sub === 'rebase') {
192
+ rest.forEach((arg, at) => {
193
+ if (arg === '-x' || arg === '--exec') scripts.push(rest[at + 1] ?? '')
194
+ else if (arg.startsWith('--exec=')) scripts.push(arg.slice('--exec='.length))
195
+ })
196
+ }
197
+ if (sub === 'bisect' && rest[0] === 'run') scripts.push(rest.slice(1).join(' '))
198
+ if (sub === 'submodule') {
199
+ const at = rest.indexOf('foreach')
200
+ if (at !== -1) scripts.push(rest.slice(at + 1).filter(arg => arg !== '--recursive' && arg !== '-q' && arg !== '--quiet').join(' '))
201
+ }
202
+ return { scripts, hooks }
203
+ }
204
+
205
+ /** How deep `sh -c "sh -c '…'"` is followed before the guard gives up reading it. */
206
+ const MAX_DEPTH = 4
207
+
208
+ type Collected = {
209
+ /** Part of what runs is only known as it runs, or comes from input or a file the guard does not read. */
210
+ unseen: boolean
211
+ /** A shell or eval is told to run a command whose program is only known as it runs. */
212
+ opaque: boolean
213
+ /** Simple commands that run something other than an install, a quiet program or a shell the guard read into. */
214
+ others: number
215
+ }
216
+
217
+ /**
218
+ * A word whose text is only known as the command runs: an expansion, a
219
+ * substitution, a pathname pattern (`claud[e]`, `cl*`) or a brace list
220
+ * (`cl{aude,}`). `[` on its own is the test command.
221
+ */
222
+ function isDynamic(word: string): boolean {
223
+ return word !== '[' && (/[$`*?[]/.test(word) || /\{[^}]*,[^}]*\}/.test(word))
224
+ }
225
+
226
+ /**
227
+ * Where the program of a simple command is: after `NAME=value` words and
228
+ * any wrappers with their options. `scripts` are strings a wrapper runs as
229
+ * shell text (`env -S`); `fromInput` is set when its words come in part
230
+ * from input (`xargs`).
231
+ */
232
+ function programAt(words: readonly string[]): { index: number; scripts: string[]; fromInput: boolean } {
233
+ const scripts: string[] = []
234
+ let fromInput = false
235
+ let index = 0
236
+ while (index < words.length && /^[A-Za-z_]\w*=/.test(words[index] as string)) index += 1
237
+ for (let guard = 0; guard < 8 && index < words.length; guard += 1) {
238
+ const wrapper = WRAPPERS[programName(words[index])]
239
+ if (wrapper === undefined) break
240
+ fromInput ||= wrapper.fromInput === true
241
+ index += 1
242
+ let plain = wrapper.positionals ?? 0
243
+ while (index < words.length) {
244
+ const word = words[index] as string
245
+ if (word === '--') {
246
+ index += 1
247
+ break
248
+ }
249
+ if (wrapper.assignments === true && /^[A-Za-z_]\w*=/.test(word)) {
250
+ index += 1
251
+ continue
252
+ }
253
+ if (word.startsWith('-') && word !== '-') {
254
+ const name = word.includes('=') ? word.slice(0, word.indexOf('=')) : word
255
+ const takesValue = !word.includes('=') && ((wrapper.values ?? []).includes(name) || (wrapper.script ?? []).includes(name))
256
+ if ((wrapper.script ?? []).includes(name)) scripts.push(word.includes('=') ? word.slice(word.indexOf('=') + 1) : (words[index + 1] ?? ''))
257
+ index += takesValue ? 2 : 1
258
+ continue
259
+ }
260
+ if (plain > 0) {
261
+ plain -= 1
262
+ index += 1
263
+ continue
264
+ }
265
+ break
266
+ }
267
+ }
268
+ return { index, scripts, fromInput }
269
+ }
270
+
271
+ /** Whether `claude plugin …` starts at `index`. */
272
+ function isClaudePlugin(words: readonly string[], index: number): boolean {
273
+ return CLAUDE.has(programName(words[index])) && /^plugins?$/i.test(words[index + 1] ?? '')
274
+ }
275
+
276
+ /** The install a `claude plugin` command at `index` asks for, if it is one. */
277
+ function claudePlugin(words: readonly string[], index: number): Reading | undefined {
278
+ const rest = words.slice(index + 2)
279
+ const sub = rest[0]?.toLowerCase()
280
+ if (sub === 'install' || sub === 'i') return readInstall(rest.slice(1), 'install', 'claude plugin install')
281
+ if (sub === 'update') return readInstall(rest.slice(1), 'update', 'claude plugin update')
282
+ if (sub === 'marketplace' && rest[1]?.toLowerCase() === 'add') return readMarketplaceAdd(rest.slice(2), 'claude plugin marketplace add')
283
+ return undefined
284
+ }
285
+
286
+ /**
287
+ * Adds every install `command` asks for to `found`, and says whether part of
288
+ * what runs is unseen or opaque and how many other commands run beside it.
289
+ * `asScript` is set for text a shell runs as commands; an argument of another
290
+ * program, read only for installs it may run, cannot be opaque.
291
+ */
292
+ function collectInstalls(command: string, found: Reading[], depth: number, asScript: boolean): Collected {
293
+ const out: Collected = { unseen: false, opaque: false, others: 0 }
294
+ if (depth > MAX_DEPTH) return { unseen: true, opaque: asScript, others: 0 }
295
+ const merge = (inner: Collected, counts: boolean): void => {
296
+ out.unseen ||= inner.unseen
297
+ out.opaque ||= inner.opaque
298
+ if (counts) out.others += inner.others
299
+ }
300
+ const nested = (script: string): void => merge(collectInstalls(script, found, depth + 1, true), true)
301
+ for (const { words, input, scripts: substitutions, writes } of splitShell(command).commands) {
302
+ // What its substitutions run comes first, and is read as commands too.
303
+ substitutions?.forEach(nested)
304
+ // A write to a file is more than an install, whatever program makes it.
305
+ if (writes === true) out.others += 1
306
+ const { index, scripts, fromInput } = programAt(words)
307
+ scripts.forEach(nested)
308
+ const word = words[index]
309
+ // Only NAME=value words: nothing runs.
310
+ if (word === undefined) continue
311
+ const program = programName(word)
312
+ // xargs fills in words from its input, which the guard does not see; for a quiet program that is only text.
313
+ if (fromInput && !(QUIET.has(program) && !writesAnyway(program, words.slice(index + 1)))) out.unseen = true
314
+ if (CLAUDE.has(program)) {
315
+ // `claude` asked to do something only known as it runs could be installing.
316
+ const at = words.findIndex((item, place) => place > index && /^plugins?$/i.test(item))
317
+ const next = (offset: number): string => words[at + offset] ?? ''
318
+ if (isDynamic(words[index + 1] ?? '') || (at !== -1 && (isDynamic(next(1)) || (/^marketplace$/i.test(next(1)) && isDynamic(next(2)))))) {
319
+ found.push({ kind: 'unreadable', label: 'a claude command', reason: 'what it asks claude to do is only known as it runs; run claude plugin install <name>@<marketplace> as a plain command' })
320
+ continue
321
+ }
322
+ // `claude plugin …`, also after global options (`claude --debug plugin install …`).
323
+ const reading = at === -1 ? undefined : claudePlugin(words, at - 1)
324
+ if (reading !== undefined) found.push(reading)
325
+ else out.others += 1
326
+ continue
327
+ }
328
+ // A program only known as it runs; inside a script a shell runs, nothing can be told about it.
329
+ if (isDynamic(word)) {
330
+ out.unseen = true
331
+ if (asScript && depth > 0) out.opaque = true
332
+ out.others += 1
333
+ continue
334
+ }
335
+ if (SHELLS.has(program)) {
336
+ // `sh -c '…'`, `bash -lc '…'`, `bash <<EOF`: the script is read as commands too.
337
+ const flag = words.findIndex((item, at) => at > index && /^-[A-Za-z]*c[A-Za-z]*$/.test(item))
338
+ const script = flag === -1 ? undefined : words[flag + 1]
339
+ if (script !== undefined) nested(script)
340
+ else if (input !== undefined) nested(input)
341
+ // A shell given no script reads one from its input, often a pipe; one given a file runs that file.
342
+ else {
343
+ out.unseen = true
344
+ out.others += 1
345
+ }
346
+ continue
347
+ }
348
+ if (program === 'eval') {
349
+ nested(words.slice(index + 1).join(' '))
350
+ continue
351
+ }
352
+ // Code in another language, inline, from input or from a file, is not read.
353
+ if (INTERPRETERS.has(program.replace(/\.exe$/, '').replace(/^(python[23]?)\.\d+$/, '$1'))) out.unseen = true
354
+ if (QUIET.has(program) && !writesAnyway(program, words.slice(index + 1))) continue
355
+ // Any other program may run an install among its arguments (`watch`,
356
+ // `flock`), and may run it again and again: the program around it counts
357
+ // as more than the install, so it is held.
358
+ const inside = words.findIndex((item, at) => at > index && isClaudePlugin(words, at))
359
+ const reading = inside === -1 ? undefined : claudePlugin(words, inside)
360
+ if (reading !== undefined) {
361
+ found.push(reading)
362
+ out.others += 1
363
+ continue
364
+ }
365
+ // Or run an argument as a script (`su -c '…'`, `watch '…'`, git's own
366
+ // `-c alias.x='!…'`): an install there is found, and the program around
367
+ // it counts as more than it.
368
+ if (program === 'git') {
369
+ const ran = gitScripts(words.slice(index + 1))
370
+ if (ran.hooks) out.unseen = true
371
+ for (const script of ran.scripts) merge(collectInstalls(script, found, depth + 1, true), false)
372
+ } else if (!TEXT_ARGUMENTS.has(program)) {
373
+ for (const text of words.slice(index + 1).filter(argument => /\s/.test(argument))) merge(collectInstalls(text, found, depth + 1, false), false)
374
+ }
375
+ out.others += 1
376
+ }
377
+ return out
378
+ }
379
+
380
+ /** The last part of a program's path, lower-cased: `/usr/bin/Claude` is `claude`. */
381
+ function programName(word: string | undefined): string {
382
+ return (word ?? '').split(/[\\/]/).pop()?.toLowerCase() ?? ''
383
+ }
384
+
385
+ /** `install <plugin>` or `update <plugin>` with the options Claude Code takes. */
386
+ function readInstall(words: readonly string[], verb: 'install' | 'update', command: string): Reading {
387
+ const options = readOptions(words, INSTALL_VALUES, INSTALL_FLAGS)
388
+ const named = options.positionals[0]
389
+ const label = `${command}${named === undefined ? '' : ` ${safeLine(named)}`}`
390
+ if (options.unknown !== undefined) return { kind: 'unreadable', label, reason: `the guard does not know the option ${safeLine(options.unknown)}` }
391
+ // With no plugin named, Claude Code shows its menu to pick one from.
392
+ if (named === undefined) return verb === 'install' ? BROWSE : PASS
393
+ if (options.positionals.length > 1) return { kind: 'unreadable', label, reason: 'it names more than one plugin; install one at a time' }
394
+ if (options.values.has('--marketplace')) {
395
+ return {
396
+ kind: 'unreadable',
397
+ label,
398
+ reason: `it installs from a marketplace that is not added yet. Add it first with /plugin marketplace add ${safeLine(options.values.get('--marketplace') ?? '<source>')}, which the guard scans, then install ${safeLine(named)}@<marketplace>`,
399
+ }
400
+ }
401
+ const id = PLUGIN_ID.exec(named)
402
+ if (id === null) return { kind: 'unreadable', label, reason: `"${safeLine(named)}" is not a plugin name the guard can look up` }
403
+ const plugin = id[1] as string
404
+ const marketplace = id[2]
405
+ if (options.values.has('--registry') || marketplace?.toLowerCase() === 'npm') return { kind: 'unreadable', label, reason: 'it installs a package from npm, which the guard cannot read yet' }
406
+ return { kind: 'install', request: { kind: 'plugin', verb, plugin, ...(marketplace === undefined ? {} : { marketplace }) }, label }
407
+ }
408
+
409
+ /** `marketplace add <source>` with the options Claude Code takes. */
410
+ function readMarketplaceAdd(words: readonly string[], command: string): Reading {
411
+ // --sparse takes every word after it, so the source must come before it.
412
+ const sparse = words.findIndex(word => word === '--sparse')
413
+ const options = readOptions(sparse === -1 ? words : words.slice(0, sparse), MARKETPLACE_VALUES, new Set([...MARKETPLACE_FLAGS, '--claudeai']))
414
+ const source = options.positionals[0]
415
+ const label = `${command}${source === undefined ? '' : ` ${safeLine(source)}`}`
416
+ if (options.unknown !== undefined) return { kind: 'unreadable', label, reason: `the guard does not know the option ${safeLine(options.unknown)}` }
417
+ if (options.flags.has('--claudeai')) return { kind: 'unreadable', label, reason: 'it adds a marketplace claude.ai hosts, which the guard cannot fetch' }
418
+ if (source === undefined) return { kind: 'unreadable', label, reason: 'it names no source before --sparse' }
419
+ if (options.positionals.length > 1) return { kind: 'unreadable', label, reason: 'it names more than one source' }
420
+ return { kind: 'install', request: { kind: 'marketplace', source }, label }
421
+ }
422
+
423
+ type Options = { positionals: string[]; values: Map<string, string>; flags: Set<string>; unknown?: string }
424
+
425
+ /** Splits words into options and the rest; `--name=value` counts as `--name value`. */
426
+ function readOptions(words: readonly string[], valued: ReadonlySet<string>, flagged: ReadonlySet<string>): Options {
427
+ const out: Options = { positionals: [], values: new Map(), flags: new Set() }
428
+ for (let index = 0; index < words.length; index += 1) {
429
+ const word = words[index] as string
430
+ if (word === '--') {
431
+ out.positionals.push(...words.slice(index + 1))
432
+ break
433
+ }
434
+ if (!word.startsWith('-') || word === '-') {
435
+ out.positionals.push(word)
436
+ continue
437
+ }
438
+ const equals = word.indexOf('=')
439
+ const name = equals === -1 ? word : word.slice(0, equals)
440
+ if (flagged.has(name) && equals === -1) {
441
+ out.flags.add(name)
442
+ } else if (valued.has(name)) {
443
+ const value = equals === -1 ? words[(index += 1)] : word.slice(equals + 1)
444
+ if (value === undefined) {
445
+ out.unknown ??= `${name} with no value`
446
+ break
447
+ }
448
+ out.values.set(name === '-s' ? '--scope' : name, value)
449
+ } else {
450
+ out.unknown ??= name
451
+ }
452
+ }
453
+ return out
454
+ }
455
+
456
+ /**
457
+ * One simple command: its words; the text a here-document or here-string
458
+ * feeds it; the bodies of its command substitutions, which run before it
459
+ * does; and whether it writes to a file through a redirection.
460
+ */
461
+ export type ShellCommand = { words: string[]; input?: string; scripts?: string[]; writes?: boolean }
462
+
463
+ /**
464
+ * Splits shell text into simple commands of words, as a POSIX shell would
465
+ * quote them: `;`, `&&`, `||`, `|`, `&`, parentheses and new lines end a
466
+ * command; quotes and backslashes are resolved; a `#` comment is skipped.
467
+ * Redirections and their targets are not words; a redirection that writes to
468
+ * a file other than /dev/null marks its command `writes`. A here-document's
469
+ * body or a here-string becomes the command's `input`, and the body of each
470
+ * `$(…)`, backquoted or `<(…)` substitution one of its `scripts`, never
471
+ * commands of its own. `dynamic` is set when part of the text is only known
472
+ * once it runs: `$` expansions, substitutions and unclosed quotes.
473
+ */
474
+ export function splitShell(text: string): { commands: ShellCommand[]; dynamic: boolean } {
475
+ const commands: ShellCommand[] = []
476
+ let current: ShellCommand = { words: [] }
477
+ let word = ''
478
+ let inWord = false
479
+ /** Part of the word was quoted or escaped: a here-document marker so written keeps its body as it is. */
480
+ let quoted = false
481
+ let dynamic = false
482
+ /** What the next word is: a word, a redirection's target, a here-document's end marker, or a here-string. */
483
+ let next: 'word' | 'target' | 'marker' | 'herestring' = 'word'
484
+ /** For a redirection's target: whether the redirection writes, and whether it duplicates a descriptor (`>&2`). */
485
+ let writing = false
486
+ let duplicating = false
487
+ let stripTabs = false
488
+ const heredocs: { marker: string; stripTabs: boolean; quoted: boolean; command: ShellCommand }[] = []
489
+ const endWord = (): void => {
490
+ if (!inWord) return
491
+ if (next === 'marker') heredocs.push({ marker: word, stripTabs, quoted, command: current })
492
+ else if (next === 'herestring') current.input = word
493
+ else if (next === 'word') current.words.push(word)
494
+ else if (writing && word !== '/dev/null' && !(duplicating && /^(?:\d+|-)$/.test(word))) current.writes = true
495
+ next = 'word'
496
+ word = ''
497
+ inWord = false
498
+ quoted = false
499
+ }
500
+ const endCommand = (): void => {
501
+ endWord()
502
+ next = 'word'
503
+ if (current.words.length > 0 || current.input !== undefined || current.scripts !== undefined || current.writes === true) commands.push(current)
504
+ current = { words: [] }
505
+ }
506
+ /** Reads the substitution that opens at `open` (its `(` or backquote); returns where it closes. */
507
+ const substitution = (open: number): number => {
508
+ const isBackquote = text[open] === '`'
509
+ const close = isBackquote ? closingBackquote(text, open) : closingParen(text, open)
510
+ const end = close === -1 ? text.length : close
511
+ // `$((…))` is arithmetic, not a command, but a substitution inside it still runs.
512
+ const body = text.slice(open + 1, end)
513
+ if (text[open] === '(' && text[open + 1] === '(') {
514
+ const inner = substitutionsIn(body)
515
+ if (inner.length > 0) (current.scripts ??= []).push(...inner)
516
+ } else (current.scripts ??= []).push(body)
517
+ dynamic = true
518
+ word += text.slice(open - (isBackquote ? 0 : 1), Math.min(end + 1, text.length))
519
+ inWord = true
520
+ return end
521
+ }
522
+ for (let index = 0; index < text.length; index += 1) {
523
+ const char = text[index] as string
524
+ if (char === "'") {
525
+ const end = text.indexOf("'", index + 1)
526
+ if (end === -1) dynamic = true
527
+ word += text.slice(index + 1, end === -1 ? text.length : end)
528
+ index = end === -1 ? text.length : end
529
+ inWord = true
530
+ quoted = true
531
+ continue
532
+ }
533
+ if (char === '"') {
534
+ inWord = true
535
+ quoted = true
536
+ index += 1
537
+ while (index < text.length && text[index] !== '"') {
538
+ const inner = text[index] as string
539
+ if (inner === '\\' && index + 1 < text.length && '"\\$`\n'.includes(text[index + 1] as string)) {
540
+ if (text[index + 1] !== '\n') word += text[index + 1]
541
+ index += 2
542
+ continue
543
+ }
544
+ if (inner === '$' && text[index + 1] === '(') {
545
+ index = substitution(index + 1) + 1
546
+ continue
547
+ }
548
+ if (inner === '`') {
549
+ index = substitution(index) + 1
550
+ continue
551
+ }
552
+ if (inner === '$') dynamic = true
553
+ word += inner
554
+ index += 1
555
+ }
556
+ if (index >= text.length) dynamic = true
557
+ continue
558
+ }
559
+ if (char === '\\') {
560
+ if (index + 1 < text.length && text[index + 1] !== '\n') {
561
+ word += text[index + 1]
562
+ inWord = true
563
+ quoted = true
564
+ }
565
+ index += 1
566
+ continue
567
+ }
568
+ if (char === '$' && text[index + 1] === '(') {
569
+ index = substitution(index + 1)
570
+ continue
571
+ }
572
+ if (char === '`') {
573
+ index = substitution(index)
574
+ continue
575
+ }
576
+ if (char === '$') {
577
+ dynamic = true
578
+ word += char
579
+ inWord = true
580
+ continue
581
+ }
582
+ if (char === '#' && !inWord) {
583
+ const end = text.indexOf('\n', index)
584
+ index = end === -1 ? text.length : end - 1
585
+ continue
586
+ }
587
+ if (char === '\n') {
588
+ endWord()
589
+ const pending = heredocs.splice(0)
590
+ endCommand()
591
+ // Each here-document's body follows, up to the line that ends it.
592
+ for (const heredoc of pending) {
593
+ const lines: string[] = []
594
+ let end = text.length
595
+ let start = index + 1
596
+ while (start < text.length) {
597
+ const stop = text.indexOf('\n', start)
598
+ const line = text.slice(start, stop === -1 ? text.length : stop)
599
+ if ((heredoc.stripTabs ? line.replace(/^\t+/, '') : line) === heredoc.marker) {
600
+ end = stop === -1 ? text.length : stop
601
+ break
602
+ }
603
+ lines.push(heredoc.stripTabs ? line.replace(/^\t+/, '') : line)
604
+ start = stop === -1 ? text.length : stop + 1
605
+ end = start
606
+ }
607
+ heredoc.command.input = lines.join('\n')
608
+ // An unquoted marker means the shell expands the body: its substitutions run.
609
+ if (!heredoc.quoted) {
610
+ const bodies = substitutionsIn(heredoc.command.input)
611
+ if (bodies.length > 0) (heredoc.command.scripts ??= []).push(...bodies)
612
+ if (/[$`]/.test(heredoc.command.input)) dynamic = true
613
+ }
614
+ index = end
615
+ }
616
+ continue
617
+ }
618
+ if (/\s/.test(char)) {
619
+ endWord()
620
+ continue
621
+ }
622
+ // `&>file` and `&>>file` redirect both outputs; any other `&` ends a command.
623
+ const redirects = char === '<' || char === '>' || (char === '&' && text[index + 1] === '>')
624
+ if (redirects) {
625
+ // A file descriptor right before it (2>) is part of the redirection.
626
+ if (inWord && /^\d+$/.test(word)) {
627
+ word = ''
628
+ inWord = false
629
+ }
630
+ endWord()
631
+ if (char === '&') index += 1
632
+ const operator = text[index] as string
633
+ if (text[index + 1] === '(') {
634
+ // `<(…)` and `>(…)`: a command whose output or input stands in for a file.
635
+ const end = closingParen(text, index + 1)
636
+ ;(current.scripts ??= []).push(text.slice(index + 2, end === -1 ? text.length : end))
637
+ dynamic = true
638
+ index = end === -1 ? text.length : end
639
+ continue
640
+ }
641
+ if (operator === '<' && text.startsWith('<<<', index)) {
642
+ index += 2
643
+ next = 'herestring'
644
+ continue
645
+ }
646
+ if (operator === '<' && text[index + 1] === '<') {
647
+ index += 1
648
+ stripTabs = text[index + 1] === '-'
649
+ if (stripTabs) index += 1
650
+ next = 'marker'
651
+ continue
652
+ }
653
+ writing = operator === '>' || char === '&'
654
+ duplicating = false
655
+ while (text[index + 1] === '>' || text[index + 1] === '&' || text[index + 1] === '|') {
656
+ if (text[index + 1] === '&') duplicating = true
657
+ index += 1
658
+ }
659
+ // `<>file` opens it for writing too.
660
+ if (operator === '<' && text[index] === '>') writing = true
661
+ next = 'target'
662
+ continue
663
+ }
664
+ if (char === ';' || char === '&' || char === '|' || char === '(' || char === ')') {
665
+ endCommand()
666
+ continue
667
+ }
668
+ word += char
669
+ inWord = true
670
+ }
671
+ endCommand()
672
+ return { commands, dynamic }
673
+ }
674
+
675
+ /** The bodies of the `$(…)` and backquoted substitutions in text a shell expands, such as an unquoted here-document. */
676
+ function substitutionsIn(text: string): string[] {
677
+ const out: string[] = []
678
+ for (let index = 0; index < text.length; index += 1) {
679
+ if (text[index] === '\\') {
680
+ index += 1
681
+ } else if (text[index] === '$' && text[index + 1] === '(') {
682
+ const close = closingParen(text, index + 1)
683
+ const body = text.slice(index + 2, close === -1 ? text.length : close)
684
+ // Arithmetic is not a command, but a substitution inside it still runs.
685
+ if (text[index + 2] === '(') out.push(...substitutionsIn(body))
686
+ else out.push(body)
687
+ index = close === -1 ? text.length : close
688
+ } else if (text[index] === '`') {
689
+ const close = closingBackquote(text, index)
690
+ out.push(text.slice(index + 1, close === -1 ? text.length : close))
691
+ index = close === -1 ? text.length : close
692
+ }
693
+ }
694
+ return out
695
+ }
696
+
697
+ /** Where the `(` at `open` is closed, past quotes and nested parentheses; -1 if it never is. */
698
+ function closingParen(text: string, open: number): number {
699
+ let depth = 0
700
+ for (let index = open; index < text.length; index += 1) {
701
+ const char = text[index] as string
702
+ if (char === '\\') {
703
+ index += 1
704
+ } else if (char === "'") {
705
+ const end = text.indexOf("'", index + 1)
706
+ if (end === -1) return -1
707
+ index = end
708
+ } else if (char === '"') {
709
+ index += 1
710
+ while (index < text.length && text[index] !== '"') index += text[index] === '\\' ? 2 : 1
711
+ } else if (char === '(') {
712
+ depth += 1
713
+ } else if (char === ')') {
714
+ depth -= 1
715
+ if (depth === 0) return index
716
+ }
717
+ }
718
+ return -1
719
+ }
720
+
721
+ /** Where the backquote at `open` is closed; -1 if it never is. */
722
+ function closingBackquote(text: string, open: number): number {
723
+ for (let index = open + 1; index < text.length; index += 1) {
724
+ if (text[index] === '\\') index += 1
725
+ else if (text[index] === '`') return index
726
+ }
727
+ return -1
728
+ }