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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +10 -2
- package/CHANGELOG.md +50 -0
- package/README.md +103 -24
- package/assets/reviewed-by-ai-install-guard-light@1x.png +0 -0
- package/hooks/gate/gate.ts +116 -0
- package/hooks/gate/recheck.ts +33 -0
- package/hooks/gate/requests.ts +728 -0
- package/hooks/gate/resolve.ts +210 -0
- package/hooks/guard/badge.ts +35 -0
- package/hooks/guard/decisions.ts +44 -3
- package/hooks/guard/pane.tsx +200 -29
- package/hooks/guard/passes.ts +34 -0
- package/hooks/guard/run.ts +158 -0
- package/hooks/guard/view.ts +24 -1
- package/hooks/mod-host.ts +1 -0
- package/hooks/register.tsx +117 -75
- package/hooks/scan/checks/code.ts +110 -8
- package/hooks/scan/checks/mcp.ts +271 -0
- package/hooks/scan/checks/mod.ts +120 -27
- package/hooks/scan/checks/patterns.ts +4 -3
- package/hooks/scan/checks/programs.ts +150 -0
- package/hooks/scan/checks/prompts.ts +5 -2
- package/hooks/scan/checks/provenance.ts +190 -0
- package/hooks/scan/checks/structure.ts +92 -0
- package/hooks/scan/checks/types.ts +18 -2
- package/hooks/scan/checks/validator.ts +15 -1
- package/hooks/scan/github.ts +219 -0
- package/hooks/scan/host.ts +13 -0
- package/hooks/scan/lookups.ts +370 -0
- package/hooks/scan/remote.ts +427 -0
- package/hooks/scan/report.ts +12 -3
- package/hooks/scan/scan.ts +94 -8
- package/hooks/scan/verdict.ts +9 -3
- package/package.json +8 -3
|
@@ -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
|
+
}
|