Looks up every new package before it installs: holds made-up names, typosquats, days-old releases and curl-into-shell for your answer, and lets the rest…

Looks up every new package before Claude installs it.
installguard is a Claude Code mod. When Claude is about to add a package your project does not already have, installguard asks the registry about it first. An established package installs without a word. A name that does not exist, a lookalike of a popular package, a release that is hours old, or a script piped from the internet into a shell is held, and you are asked before anything runs.

Coding agents invent package names, and attackers register the names they invent. They also install whatever the newest version is, minutes after it is published. installguard is the check a careful person would make, made every time.
Needs Claude Code 2.1.295 or later.
/plugin marketplace add griches/installguard
/plugin install installguard@installguard
Or from a shell:
claude plugin marketplace add griches/installguard
claude plugin install installguard@installguard
Run /reload-plugins in a session that is already open.
| Registry | Commands |
|---|---|
| npm | npm install, pnpm add, yarn add, bun add, and packages run at once by npx, pnpm dlx, bunx |
| PyPI | pip install, python -m pip install, uv add, uv pip install, poetry add, pdm add, pipx, uvx |
| crates.io | cargo add, cargo install |
| RubyGems | gem install |
Outside a registry, these are always held:
curl … | sh, bash <(curl …), sh -c "$(curl …)".user/repo shorthand.pip install from an extra index, or npm install --registry pointing anywhere but the public registry.| Flag | Meaning | Default threshold |
|---|---|---|
| Not on the registry | The name may be made up, misspelt or private. The nearest known name is suggested | |
| Lookalike | One edit, one swap or one dropped separator away from a well-known package, and not widely used itself | |
| New package | First published recently | 30 days |
| Fresh version | The version that would install is very new, or so new that no dated record of it exists yet. Most poisoned releases are pulled within days. A pinned version is checked too | 3 days |
| Release date unknown | A version you pinned whose date the registry did not give, so its age could not be checked. Shown, and held when unreachable registries are set to hold | |
| Little used | Few downloads a week | 1,000 |
| Deprecated and about to run | npx of a package its author has withdrawn, such as npx tsc without TypeScript installed |
Shown but not held on their own: a package that runs an install script, a Python package that ships source only, a deprecated package.
npm install, npm ci or pip install -r requirements.txt: the lockfile or the file is your project's own.package.json, requirements.txt, pyproject.toml, Cargo.toml or the Gemfile.npx of something already in node_modules/.bin.A package with nothing flagged installs, with a short toast:
✓ express 5.2.1 · 141M a week
A flagged command is held and Claude Code's own question dialog asks, naming the kind of concern first:
Typosquat?
Install Guard: expresss (npm): possible typosquat, looks like express (141M
downloads a week), which is a different package; little used, 693 downloads
a week. Run `npm install expresss`?
1. Cancel
2. Run it once
3. Run it and always allow
The pane beside it puts the two packages side by side:
Held before it runs: possible typosquat
$ npm install expresss
! expresss 0.0.0 · 10 years old · 693 a week
Possible typosquat
looks like express (141M downloads a week), which is a different package
Little used
693 downloads a week
You asked for expresss · 693 a week
You may mean express · 141M a week

The pane (/installguard) holds the full report while the question is up, and afterwards the list of what was checked in the session.
In a run with nobody to ask (claude -p, CI), a flagged command is refused, not run.
/installguard the pane: what was checked in this session
/installguard check left-pad look a package up without installing it
/installguard check pypi:requests the same on PyPI (also crates: and rubygems:)
/installguard allow left-pad always allow a package
/installguard forget clear the allowed list
All under installguard in /config.
| Setting | Default | What it does |
|---|---|---|
| What is held | flagged | always: every command that adds a package the project does not have |
| Sensitivity | balanced | relaxed: 7 days, 1 day, 100 downloads a week. strict: 90 days, 7 days, 10,000 |
| When a registry cannot be reached | allow | hold: the command waits for your answer |
| Say when a package passes | on | Off: no toast for a clean package |
npm install $PKG) cannot be read, so the command is held for your answer.package.json and then installs with a bare npm install is not looked up.installguard is a mod: code that runs inside Claude Code. This is everything it does.
It watches Bash commands. It hooks the Bash tool and reads the text of each command before it runs. A command that adds no new package is passed on untouched. It never changes a command, and it runs no command of its own.
It reads five files in your project folder, to learn which packages you already depend on: package.json, requirements.txt, pyproject.toml, Cargo.toml and Gemfile. It also checks whether node_modules/.bin/<name> exists. Nothing from these files leaves your machine.
It asks a public registry about a package, by name. These are the only hosts it contacts, and the package's name and version are the only things it sends:
| Host | Asked for |
|---|---|
registry.npmjs.org | An npm package's version, publish dates, install scripts and deprecation |
api.npmjs.org | An npm package's weekly downloads |
pypi.org | A PyPI package's releases and their dates |
pypistats.org | A PyPI package's weekly downloads |
crates.io | A crate's versions, dates and recent downloads |
rubygems.org | A gem's versions and dates |
api.deps.dev | The publish date of one npm version you pinned, when the package is too large to ask npm for it |
It sends no part of your conversation, your code, your files or the command itself. The full statement is in PRIVACY.md. It has no account, no telemetry and no server of its own, and it calls no model.
It can refuse a command. When you choose Cancel, when the question is dismissed, or when the guard itself fails on an install command, the command is not run and Claude is told why.
It keeps one thing between sessions: the list of packages you chose to always allow, in Claude Code's own plugin storage.
It adds the /installguard command, a pane, and a question in Claude Code's own dialog. It adds no tools for the model.
A command is read wherever it stands: after cd … &&, inside bash -c "…" or eval, behind sudo or env, and after a here-document. If the guard itself fails on a command that fetches a package, the command is refused, not run.
Found a way past it? Open an issue, or for anything sensitive use GitHub's private vulnerability reporting on this repository.
claude plugin validate .
claude plugin test .
claude --plugin-dir .
MIT. See LICENSE.
hooks/register.tsx 522 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Checked, Ecosystem, Entry, Flag, Held, Oddity, Request } from '../types'
5import { assess, compact, registryName, summary } from './assess'
6import type { Thresholds } from './assess'
7import { findInstalls } from './detect'
8import { lookup } from './registry'
9import type { Get } from './registry'
10
11const PANE = 'installguard'
12const TITLE = 'Install Guard'
13const COMMAND = 'installguard'
14const ALLOWED_KEY = 'allowed'
15const KEPT_ENTRIES = 30
16const SHOWN_ENTRIES = 8
17const MOST_PACKAGES = 12
18/** What a registry accepts as a name: nothing that could reach outside the package's own address. */
19const PACKAGE_NAME = /^(?:@[a-z0-9~-][a-z0-9._~-]*\/)?[a-z0-9~-][a-z0-9._~-]*$/i
20const USER_AGENT = 'installguard (https://github.com/griches/installguard)'
21
22type Decision = 'install' | 'always' | 'cancel' | 'unanswered' | 'interrupted'
23
24const ANSWER = { cancel: 'Cancel', install: 'Run it once', always: 'Run it and always allow' } as const
25
26const SENSITIVITY: Record<string, Omit<Thresholds, 'holdsUnchecked'>> = {
27 relaxed: { minAgeDays: 7, cooldownDays: 1, minWeeklyDownloads: 100 },
28 balanced: { minAgeDays: 30, cooldownDays: 3, minWeeklyDownloads: 1000 },
29 strict: { minAgeDays: 90, cooldownDays: 7, minWeeklyDownloads: 10_000 },
30}
31const ECOSYSTEMS: readonly Ecosystem[] = ['npm', 'pypi', 'crates', 'rubygems']
32const ODDITY: Record<Oddity['kind'], (detail: string) => string> = {
33 'pipe-to-shell': detail => `runs a script downloaded from ${detail} without showing it`,
34 'remote-source': detail => `installs from ${detail}, which no registry vouches for`,
35 tap: detail => `installs from the third-party tap ${detail}`,
36 unreadable: detail => `names its package through a shell variable, so \`${detail}\` could not be checked`,
37}
38
39const held = atom({ plugin: 'installguard', key: 'held' } as const, null)
40const log = atom({ plugin: 'installguard', key: 'log' } as const, [])
41const allowed = atom({ plugin: 'installguard', key: 'allowed' } as const, [])
42
43const keyOf = (one: Pick<Request, 'ecosystem' | 'name'>) => `${one.ecosystem}:${one.name}`
44
45const isRisky = (one: Checked) => one.flags.some(flag => flag.level === 'risk')
46
47const titled = (one: Request) => (one.version === null ? one.name : `${one.name}@${one.version}`)
48
49const record = ($: EngineInterface, entry: Entry) => update($, log, list => [...list, entry].slice(-KEPT_ENTRIES))
50
51/** What is wrong with a held command, in words Claude can act on. */
52const told = (flag: Flag) => `${flag.label.toLowerCase()}, ${flag.text}`
53
54const reasons = (packages: readonly Checked[], oddities: readonly Oddity[]) => [
55 ...packages.filter(isRisky).map(one => `${titled(one)} (${registryName(one.ecosystem)}): ${one.flags.filter(flag => flag.level === 'risk').map(told).join('; ')}`),
56 ...oddities.map(one => `${one.via} ${ODDITY[one.kind](one.detail)}`),
57]
58
59/** The dialog's chip: the kind of concern in a word or two, twelve characters at most. */
60const CHIP: Record<Flag['kind'], string> = {
61 missing: 'Unknown pkg',
62 typosquat: 'Typosquat?',
63 new: 'New package',
64 fresh: 'New release',
65 undated: 'Undated',
66 unpopular: 'Little used',
67 unchecked: 'Unchecked',
68 deprecated: 'Deprecated',
69 script: 'Install',
70 'source-only': 'Install',
71}
72const ODDITY_LABEL: Record<Oddity['kind'], string> = {
73 'pipe-to-shell': 'Downloaded script',
74 'remote-source': 'Outside a registry',
75 tap: 'Third-party tap',
76 unreadable: 'Unreadable name',
77}
78
79/** The first concern of a held command: what the heading and the dialog's chip name. */
80const concern = (packages: readonly Checked[], oddities: readonly Oddity[]) => {
81 const flag = packages.flatMap(one => one.flags).find(one => one.level === 'risk')
82 const [oddity] = oddities
83
84 if (flag !== undefined) {
85 return { label: flag.label, chip: CHIP[flag.kind] }
86 }
87
88 return oddity === undefined ? { label: 'New package', chip: 'Install' } : { label: ODDITY_LABEL[oddity.kind], chip: oddity.kind === 'pipe-to-shell' ? 'Remote code' : 'Install' }
89}
90
91const pypiName = (name: string) => name.toLowerCase().replace(/[-_.]+/g, '-')
92
93/** The lines of a TOML file that stand under a table whose header matches. */
94const tables = (toml: string, header: RegExp) => {
95 const lines: string[] = []
96 let isInside = false
97
98 for (const line of toml.split('\n').map(one => one.trim())) {
99 if (line.startsWith('[')) {
100 isInside = header.test(line)
101 } else if (isInside && line !== '' && !line.startsWith('#')) {
102 lines.push(line)
103 }
104 }
105
106 return lines
107}
108
109/** The names a project already depends on, read from its own manifests: asking for one of them again is no news. */
110const declared = async ($: EngineInterface, cwd: string): Promise<Set<string>> => {
111 const names = new Set<string>()
112 const file = (name: string) => $.fs.read(`${cwd}/${name}`).catch(() => '')
113 const [manifest, requirements, pyproject, cargo, gemfile] = await Promise.all([
114 file('package.json'),
115 file('requirements.txt'),
116 file('pyproject.toml'),
117 file('Cargo.toml'),
118 file('Gemfile'),
119 ])
120
121 try {
122 const parsed = JSON.parse(manifest) as Record<string, Record<string, string> | undefined>
123
124 for (const group of ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies']) {
125 Object.keys(parsed[group] ?? {}).forEach(name => names.add(`npm:${name.toLowerCase()}`))
126 }
127 } catch {
128 // no package.json, or not JSON
129 }
130
131 for (const found of requirements.matchAll(/^\s*([A-Za-z0-9][A-Za-z0-9._-]*)\s*(?:\[[^\]]*\])?\s*(?:[=~<>!;#]|$)/gm)) {
132 names.add(`pypi:${pypiName(found[1] ?? '')}`)
133 }
134
135 // PEP 621 lists requirements as strings; Poetry keys them under its own tables.
136 for (const block of pyproject.matchAll(/dependencies\s*=\s*\[([^\]]*)\]/g)) {
137 for (const found of (block[1] ?? '').matchAll(/["']\s*([A-Za-z0-9][A-Za-z0-9._-]*)/g)) {
138 names.add(`pypi:${pypiName(found[1] ?? '')}`)
139 }
140 }
141
142 for (const line of tables(pyproject, /^\[tool\.poetry\.(?:group\.[\w-]+\.)?(?:dev-)?dependencies\]$/)) {
143 names.add(`pypi:${pypiName(/^([A-Za-z0-9][A-Za-z0-9._-]*)\s*=/.exec(line)?.[1] ?? '')}`)
144 }
145
146 for (const line of tables(cargo, /^\[(?:workspace\.)?(?:dev-|build-)?dependencies\]$/)) {
147 names.add(`crates:${(/^([A-Za-z0-9][A-Za-z0-9_-]*)\s*=/.exec(line)?.[1] ?? '').toLowerCase()}`)
148 }
149
150 for (const found of gemfile.matchAll(/^\s*gem\s+["']([^"']+)["']/gm)) {
151 names.add(`rubygems:${(found[1] ?? '').toLowerCase()}`)
152 }
153
154 return names
155}
156
157/**
158 * Asks a registry about a package. Every request the mod makes is made here, to one of
159 * seven fixed public hosts, and carries nothing but the package's name in its address.
160 */
161const get =
162 ($: EngineInterface): Get =>
163 async url => {
164 const init = { headers: { 'user-agent': USER_AGENT, accept: 'application/json' } }
165 const rest = url.slice(url.indexOf('/', 8) + 1)
166
167 try {
168 let answered
169
170 if (url.startsWith('https://registry.npmjs.org/')) {
171 answered = await $.http.fetch(`https://registry.npmjs.org/${rest}`, init)
172 } else if (url.startsWith('https://api.npmjs.org/')) {
173 answered = await $.http.fetch(`https://api.npmjs.org/${rest}`, init)
174 } else if (url.startsWith('https://pypi.org/')) {
175 answered = await $.http.fetch(`https://pypi.org/${rest}`, init)
176 } else if (url.startsWith('https://pypistats.org/')) {
177 answered = await $.http.fetch(`https://pypistats.org/${rest}`, init)
178 } else if (url.startsWith('https://crates.io/')) {
179 answered = await $.http.fetch(`https://crates.io/${rest}`, init)
180 } else if (url.startsWith('https://rubygems.org/')) {
181 answered = await $.http.fetch(`https://rubygems.org/${rest}`, init)
182 } else if (url.startsWith('https://api.deps.dev/')) {
183 answered = await $.http.fetch(`https://api.deps.dev/${rest}`, init)
184 } else {
185 return null
186 }
187
188 return { status: answered.status, text: answered.text }
189 } catch {
190 return null
191 }
192 }
193
194const check = async ($: EngineInterface, requests: readonly Request[], thresholds: Thresholds): Promise<Checked[]> => {
195 const at = await $.clock.now()
196 const fetch = get($)
197
198 return Promise.all(
199 requests.map(async request => {
200 const facts = await lookup(fetch, request)
201 const flags = assess(request, facts, thresholds, at)
202 // A deprecated package that is about to be run, not only stored, is worth a look.
203 const raised = flags.map(flag => (flag.kind === 'deprecated' && request.isExecuted ? { ...flag, level: 'risk' as const } : flag))
204 const near = raised.find(flag => flag.near !== undefined)?.near
205
206 if (near === undefined) {
207 return { ...request, facts, flags: raised, lookalike: null }
208 }
209
210 // The package it looks like is asked about too, so both can be shown side by side.
211 const known = await lookup(fetch, { ...request, name: near, version: null })
212 const weekly = known.isFound ? known.weeklyDownloads : null
213 const used = weekly === null ? '' : ` (${compact(weekly)} downloads a week)`
214
215 return {
216 ...request,
217 facts,
218 flags: raised.map(flag => (flag.kind === 'typosquat' ? { ...flag, text: `looks like ${near}${used}, which is a different package` } : flag)),
219 lookalike: { name: near, weeklyDownloads: weekly },
220 }
221 }),
222 )
223}
224
225const allow = async ($: EngineInterface, keys: readonly string[]) => {
226 const all = [...new Set([...(await read($, allowed)), ...keys])].sort()
227 await update($, allowed, () => all)
228 await $.store.set(ALLOWED_KEY, all).catch(() => undefined)
229}
230
231/** The held command's report, drawn in the pane while the question is up. */
232const report = ($: EngineInterface, e: Parameters<EngineInterface['ui']['resolve']>[0], holding: Held, at: number) => {
233 const { Box, Text } = $.ui.resolve(e)
234 return (
235 <Box flexDirection="column">
236 <Text bold color="warning">{`Held before it runs: ${concern(holding.packages, holding.oddities).label.toLowerCase()}`}</Text>
237 <Text dimColor wrap="truncate-end">{`$ ${holding.command.replace(/\s+/g, ' ')}`}</Text>
238 {holding.packages.map(one => (
239 <Box flexDirection="column" marginTop={1}>
240 <Box flexDirection="row">
241 <Text bold color={isRisky(one) ? 'warning' : 'success'}>{`${isRisky(one) ? '!' : '✓'} `}</Text>
242 <Text bold>{one.facts.isFound ? summary(one, one.facts, at) : titled(one)}</Text>
243 <Text dimColor>{` ${registryName(one.ecosystem)} · ${one.via}`}</Text>
244 </Box>
245 {one.flags.map(flag => (
246 <Box flexDirection="column">
247 <Text bold color={flag.level === 'risk' ? 'warning' : undefined} dimColor={flag.level === 'note'}>{` ${flag.label}`}</Text>
248 <Box flexDirection="row">
249 <Box width={6} flexShrink={0}>
250 <Text> </Text>
251 </Box>
252 <Box flexGrow={1} flexShrink={1}>
253 <Text color={flag.level === 'risk' ? 'warning' : undefined} dimColor={flag.level === 'note'}>{flag.text}</Text>
254 </Box>
255 </Box>
256 </Box>
257 ))}
258 {one.lookalike !== null && (
259 <Box flexDirection="column" marginTop={1}>
260 <Text>{` You asked for ${titled(one)}${one.facts.weeklyDownloads === null ? '' : ` · ${compact(one.facts.weeklyDownloads)} a week`}`}</Text>
261 <Text color="success">{` You may mean ${one.lookalike.name}${one.lookalike.weeklyDownloads === null ? '' : ` · ${compact(one.lookalike.weeklyDownloads)} a week`}`}</Text>
262 </Box>
263 )}
264 </Box>
265 ))}
266 {holding.oddities.map(one => (
267 <Box flexDirection="row" marginTop={1}>
268 <Text bold color="warning">{'! '}</Text>
269 <Text>{`${one.via} ${ODDITY[one.kind](one.detail)}`}</Text>
270 </Box>
271 ))}
272 <Box marginTop={1}>
273 <Text dimColor>Answer the question to run or cancel it.</Text>
274 </Box>
275 </Box>
276 )
277}
278
279/** The question the dialog asks: what was flagged, then the command. */
280const question = (command: string, flagged: readonly string[], asked: number, lead: string) => {
281 const shown = flagged.slice(0, 4)
282 const rest = flagged.length > shown.length ? ` (+${flagged.length - shown.length} more in the pane)` : ''
283 const what = shown.length === 0 ? `${asked} new package${asked === 1 ? '' : 's'} this project does not have yet` : shown.join(' | ')
284 const line = command.replace(/\s+/g, ' ').trim()
285
286 return `${lead}: ${what}${rest}. Run \`${line.length > 120 ? `${line.slice(0, 120)}…` : line}\`?`
287}
288
289export const register: Register = (on, options) => {
290 const holdsAll = options.hold === 'always'
291 const thresholds: Thresholds = {
292 ...(SENSITIVITY[String(options.sensitivity)] ?? SENSITIVITY.balanced ?? { minAgeDays: 30, cooldownDays: 3, minWeeklyDownloads: 1000 }),
293 holdsUnchecked: options.unreachable === 'hold',
294 }
295 const wantsToasts = options.toasts !== false
296 on('session.start', async ($, e, next) => {
297 await $.command.register({
298 name: COMMAND,
299 description: 'Show what Install Guard checked (check <name>: look a package up; allow <name>: always allow it; forget: clear the allowed list)',
300 argumentHint: '[check|allow <package>] [forget]',
301 })
302 const kept = await $.store.get(ALLOWED_KEY).catch(() => undefined)
303 await update($, allowed, () => (Array.isArray(kept) ? kept.filter(one => typeof one === 'string') : []))
304
305 return next(e)
306 })
307
308 on('command.run', { command: COMMAND }, async ($, e) => {
309 const [verb = '', ...rest] = e.args.trim().split(/\s+/)
310 const named = rest.join(' ')
311 const [prefix, bare] = named.includes(':') ? [named.slice(0, named.indexOf(':')), named.slice(named.indexOf(':') + 1)] : ['npm', named]
312 const ecosystem = ECOSYSTEMS.find(one => one === prefix) ?? 'npm'
313
314 if (verb === 'forget') {
315 await update($, allowed, () => [])
316 await $.store.delete(ALLOWED_KEY).catch(() => undefined)
317
318 return { text: 'Install Guard: the allowed list is empty again.' }
319 }
320
321 if ((verb === 'allow' || verb === 'check') && bare !== '' && !PACKAGE_NAME.test(bare)) {
322 return { text: `Install Guard: "${bare.slice(0, 80)}" is not a package name.` }
323 }
324
325 if ((verb === 'allow' || verb === 'check') && bare === '') {
326 return { text: `Install Guard: name a package, as in /${COMMAND} ${verb} left-pad or /${COMMAND} ${verb} pypi:requests.` }
327 }
328
329 if (verb === 'allow') {
330 await allow($, [`${ecosystem}:${bare.toLowerCase()}`])
331
332 return { text: `Install Guard: ${bare} (${registryName(ecosystem)}) is always allowed from now on.` }
333 }
334
335 if (verb === 'check') {
336 const [one] = await check($, [{ ecosystem, name: bare.toLowerCase(), version: null, via: 'check', isExecuted: false }], thresholds)
337
338 if (one === undefined) {
339 return { text: 'Install Guard: nothing to check.' }
340 }
341
342 const told = one.flags.length === 0 ? ['nothing flagged'] : one.flags.map(flag => `${flag.level === 'risk' ? '!' : '·'} ${flag.label}: ${flag.text}`)
343
344 return { text: [`${summary(one, one.facts, await $.clock.now())} (${registryName(ecosystem)})`, ...told.map(line => ` ${line}`)].join('\n') }
345 }
346
347 await $.ui.open({ id: PANE, title: TITLE })
348 const entries = await read($, log)
349
350 return {
351 text:
352 entries.length === 0
353 ? 'Install Guard pane opened. Nothing checked yet in this session.'
354 : `Install Guard pane opened. ${entries.reduce((total, one) => total + one.packages.length, 0)} packages checked in this session.`,
355 }
356 })
357
358 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
359 const found = findInstalls(e.command)
360
361 if (found.requests.length === 0 && found.oddities.length === 0) {
362 return next(e)
363 }
364
365 const cwd = await $.session.cwd().catch(() => '')
366 const [known, trusted] = await Promise.all([declared($, cwd), read($, allowed)])
367 const isLocal = async (one: Request) =>
368 one.isExecuted && one.ecosystem === 'npm' && cwd !== '' && (await $.fs.exists(`${cwd}/node_modules/.bin/${one.name}`).catch(() => false))
369 const fresh: Request[] = []
370
371 for (const one of found.requests) {
372 if (!trusted.includes(keyOf(one)) && !known.has(keyOf(one)) && !(await isLocal(one))) {
373 fresh.push(one)
374 }
375 }
376
377 if (fresh.length === 0 && found.oddities.length === 0) {
378 return next(e)
379 }
380
381 const packages = await check($, fresh.slice(0, MOST_PACKAGES), thresholds)
382 const at = await $.clock.now()
383 const mustHold = holdsAll || found.oddities.length > 0 || packages.some(isRisky) || fresh.length > MOST_PACKAGES
384
385 if (!mustHold) {
386 await record($, { at, outcome: 'passed', packages, oddities: [] })
387
388 if (wantsToasts) {
389 $.ui.toast(packages.length === 1 && packages[0] !== undefined ? `✓ ${summary(packages[0], packages[0].facts, at)}` : `✓ ${packages.length} packages checked, nothing flagged`)
390 }
391
392 return next(e)
393 }
394
395 const id = e.tool_use_id
396 const flagged = reasons(packages, found.oddities)
397 const canAlways = packages.length > 0 && found.oddities.length === 0
398 let decision: Decision = 'cancel'
399 let said = ''
400
401 await update($, held, () => ({ id, command: e.command, packages, oddities: found.oddities }))
402 // The pane holds the whole report beside the question; where it is not placed the question says enough.
403 void $.ui.open({ id: PANE, title: TITLE }).catch(() => undefined)
404
405 try {
406 const first = concern(packages, found.oddities)
407 const answered = await $.ui.ask(question(e.command, flagged, fresh.length, 'Install Guard'), {
408 header: flagged.length === 0 ? 'Install' : first.chip,
409 options: canAlways ? [ANSWER.cancel, ANSWER.install, ANSWER.always] : [ANSWER.cancel, ANSWER.install],
410 })
411
412 if (answered === ANSWER.install) {
413 decision = 'install'
414 } else if (answered === ANSWER.always && canAlways) {
415 decision = 'always'
416 } else if (answered !== ANSWER.cancel) {
417 // Words typed under Other are for Claude: the command is refused and they are passed on.
418 said = answered.trim()
419 }
420 } catch {
421 // Dismissed, or nobody to ask (a `-p` run): a flagged command is not run unanswered.
422 decision = next.signal.aborted ? 'interrupted' : 'unanswered'
423 } finally {
424 await update($, held, current => (current?.id === id ? null : current)).catch(() => undefined)
425 }
426
427 const outcome = decision === 'install' || decision === 'always' ? 'installed' : decision === 'cancel' ? 'cancelled' : 'unanswered'
428 await record($, { at, outcome, packages, oddities: found.oddities })
429
430 if (decision === 'always') {
431 await allow($, packages.map(keyOf))
432 }
433
434 if (outcome === 'installed') {
435 return next(e)
436 }
437
438 const told: Partial<Record<Decision, string>> = {
439 cancel: said === '' ? 'the user chose Cancel' : `the user answered: "${said}"`,
440 unanswered: 'the question was dismissed, or nobody was there to answer it',
441 interrupted: 'the turn was interrupted',
442 }
443 const over = fresh.length > MOST_PACKAGES ? [`it names ${fresh.length} new packages at once, more than are checked in one go`] : []
444
445 return {
446 deny: `Install Guard held this command and did not run it: ${told[decision] ?? 'it was not approved'}. Flagged: ${[...flagged, ...over].join(' | ') || 'every new package is held for approval'}. Do not retry it or reach the same package another way unless the user asks you to; say what was flagged and offer an established alternative if there is one.`,
447 }
448 }).catch(($, e, next) => {
449 if (next.called) {
450 return next(e)
451 }
452
453 // The guard failed: a command that fetches nothing new goes on, one that does is refused.
454 const found = findInstalls(e.command)
455
456 return found.requests.length === 0 && found.oddities.length === 0
457 ? next(e)
458 : { deny: 'Install Guard failed while checking this command, so it was not run. Ask the user before retrying it.' }
459 })
460
461 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
462 const holding = await read($, held)
463 const at = await $.clock.now()
464
465 if (holding !== null) {
466 return report($, e, holding, at)
467 }
468
469 const { Box, Button, Text } = $.ui.resolve(e)
470 const entries = (await read($, log)).slice(-SHOWN_ENTRIES).reverse()
471 const always = await read($, allowed)
472 const GLYPH = { passed: '✓', installed: '✓', cancelled: '✗', unanswered: '◌' } as const
473 const TONE = { passed: 'success', installed: 'warning', cancelled: 'error', unanswered: undefined } as const
474 const WORD = { passed: 'nothing flagged', installed: 'flagged, run anyway', cancelled: 'flagged, cancelled', unanswered: 'flagged, not answered' } as const
475
476 return (
477 <Box flexDirection="column">
478 {entries.length === 0 && (
479 <Box flexDirection="column">
480 <Text dimColor>Nothing checked yet.</Text>
481 <Text dimColor>New packages from npm, PyPI, crates.io and RubyGems are looked up before they install.</Text>
482 </Box>
483 )}
484 {entries.map(entry => (
485 <Box flexDirection="column" marginBottom={1}>
486 <Box flexDirection="row">
487 <Text bold color={TONE[entry.outcome]}>{`${GLYPH[entry.outcome]} `}</Text>
488 <Text dimColor>{WORD[entry.outcome]}</Text>
489 </Box>
490 {entry.packages.map(one => (
491 <Box flexDirection="column">
492 <Text>{` ${one.facts.isFound ? summary(one, one.facts, at) : titled(one)}`}</Text>
493 {one.flags.filter(flag => flag.level === 'risk').map(flag => (
494 <Text color="warning">{` ${flag.label}: ${flag.text}`}</Text>
495 ))}
496 </Box>
497 ))}
498 {entry.oddities.map(one => (
499 <Text color="warning">{` ${one.via} ${ODDITY[one.kind](one.detail)}`}</Text>
500 ))}
501 </Box>
502 ))}
503 <Text dimColor>{always.length === 0 ? 'No package is always allowed.' : `Always allowed: ${always.map(one => one.slice(one.indexOf(':') + 1)).join(', ')}`}</Text>
504 {always.length > 0 && (
505 <Box marginTop={1}>
506 <Button
507 key="forget"
508 hotkey="f"
509 plain
510 label="Forget the allowed list"
511 onPress={async () => {
512 await update($, allowed, () => [])
513 await $.store.delete(ALLOWED_KEY).catch(() => undefined)
514 }}
515 />
516 </Box>
517 )}
518 </Box>
519 )
520 })
521}
522hooks/assess.ts 175 lines1import type { Ecosystem, Facts, Flag, Request } from '../types'
2import { POPULAR } from './popular'
3
4export type Thresholds = {
5 /** A package first published fewer days ago than this is new. */
6 minAgeDays: number
7 /** A version published fewer days ago than this is fresh: most poisoned releases are pulled within days. */
8 cooldownDays: number
9 minWeeklyDownloads: number
10 /** Whether a registry that cannot be reached holds the command. */
11 holdsUnchecked: boolean
12}
13
14const DAY = 86_400_000
15const REGISTRY: Record<Ecosystem, string> = { npm: 'npm', pypi: 'PyPI', crates: 'crates.io', rubygems: 'RubyGems' }
16/** A package this well used is not called a typosquat of its neighbour. */
17const ESTABLISHED = 100_000
18
19export const registryName = (ecosystem: Ecosystem) => REGISTRY[ecosystem]
20
21/** `3 hours`, `4 days`, `7 months`, `11 years`. */
22export const span = (ms: number) => {
23 const steps: [number, string][] = [
24 [365 * DAY, 'year'],
25 [30 * DAY, 'month'],
26 [DAY, 'day'],
27 [3_600_000, 'hour'],
28 ]
29
30 for (const [size, word] of steps) {
31 if (ms >= size) {
32 const count = Math.floor(ms / size)
33
34 return `${count} ${word}${count === 1 ? '' : 's'}`
35 }
36 }
37
38 return 'under an hour'
39}
40
41/** `212`, `48k`, `31M`. */
42export const compact = (count: number) => {
43 if (count < 1000) {
44 return `${count}`
45 }
46
47 return count < 1_000_000 ? `${Math.round(count / 1000)}k` : `${Math.round(count / 1_000_000)}M`
48}
49
50/** Edits between two names, a swap of neighbours counting as one; stops caring past two. */
51const distance = (a: string, b: string) => {
52 if (Math.abs(a.length - b.length) > 1) {
53 return 2
54 }
55
56 const rows = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array<number>(b.length).fill(0)])
57
58 for (let j = 0; j <= b.length; j += 1) {
59 ;(rows[0] as number[])[j] = j
60 }
61
62 for (let i = 1; i <= a.length; i += 1) {
63 for (let j = 1; j <= b.length; j += 1) {
64 const cost = a[i - 1] === b[j - 1] ? 0 : 1
65 const row = rows[i] as number[]
66 const above = rows[i - 1] as number[]
67 row[j] = Math.min((above[j] ?? 0) + 1, (row[j - 1] ?? 0) + 1, (above[j - 1] ?? 0) + cost)
68
69 if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) {
70 row[j] = Math.min(row[j] ?? 0, ((rows[i - 2] as number[])[j - 2] ?? 0) + 1)
71 }
72 }
73 }
74
75 return (rows[a.length] as number[])[b.length] ?? 2
76}
77
78const squashed = (name: string) => name.replace(/[-_.]/g, '')
79
80/** The well-known package `name` could be mistaken for, when it is not one itself. */
81export const lookalike = (ecosystem: Ecosystem, name: string): string | null => {
82 const known = POPULAR[ecosystem]
83 const bare = name.startsWith('@') ? name.slice(name.indexOf('/') + 1) : name
84
85 if (known.includes(name) || bare.length < 4) {
86 return null
87 }
88
89 return (
90 known.find(one => one !== bare && squashed(one) === squashed(bare)) ??
91 known.find(one => one.length >= 4 && one !== bare && distance(one, bare) === 1) ??
92 null
93 )
94}
95
96/** What about a package is worth a person's attention before it is fetched. */
97export const assess = (request: Request, facts: Facts, thresholds: Thresholds, now: number): Flag[] => {
98 const registry = REGISTRY[request.ecosystem]
99
100 if (!facts.isChecked) {
101 return [{ kind: 'unchecked', label: 'Not checked', level: thresholds.holdsUnchecked ? 'risk' : 'note', text: `${registry} could not be reached, so it was not checked` }]
102 }
103
104 const near = lookalike(request.ecosystem, request.name)
105
106 if (!facts.isFound) {
107 const hint = near === null ? '' : `; did you mean ${near}?`
108
109 return [{ kind: 'missing', label: 'Unknown package', level: 'risk', text: `not on ${registry}: the name may be made up or private${hint}`, ...(near === null ? {} : { near }) }]
110 }
111
112 const flags: Flag[] = []
113 const isEstablished = (facts.weeklyDownloads ?? 0) >= ESTABLISHED
114
115 if (near !== null && !isEstablished) {
116 flags.push({ kind: 'typosquat', label: 'Possible typosquat', level: 'risk', text: `looks like ${near}, which is a different package`, near })
117 }
118
119 if (facts.createdAt !== null && now - facts.createdAt < thresholds.minAgeDays * DAY) {
120 flags.push({ kind: 'new', label: 'New package', level: 'risk', text: `first published ${span(Math.max(0, now - facts.createdAt))} ago` })
121 }
122
123 if (facts.publishedAt !== null && now - facts.publishedAt < thresholds.cooldownDays * DAY) {
124 const version = facts.version === null ? 'this version' : `version ${facts.version}`
125 flags.push({ kind: 'fresh', label: 'Fresh release', level: 'risk', text: `${version} is ${span(Math.max(0, now - facts.publishedAt))} old` })
126 }
127
128 if (facts.isTooNewToDate === true) {
129 flags.push({ kind: 'fresh', label: 'Fresh release', level: 'risk', text: `version ${facts.version ?? request.version} is too new to have a publish date on record yet` })
130 }
131
132 // A version asked for by number whose date the registry did not give cannot be called old enough.
133 if (facts.isTooNewToDate !== true && request.version !== null && facts.version === request.version && facts.publishedAt === null && request.ecosystem !== 'rubygems') {
134 flags.push({
135 kind: 'undated',
136 label: 'Release date unknown',
137 level: thresholds.holdsUnchecked ? 'risk' : 'note',
138 text: `the date of version ${request.version} could not be read, so its age was not checked`,
139 })
140 }
141
142 if (facts.weeklyDownloads !== null && facts.weeklyDownloads < thresholds.minWeeklyDownloads) {
143 flags.push({ kind: 'unpopular', label: 'Little used', level: 'risk', text: `${compact(facts.weeklyDownloads)} downloads a week` })
144 }
145
146 if (facts.hasInstallScript) {
147 flags.push({ kind: 'script', label: 'Install script', level: 'note', text: 'runs a script of its own when installed' })
148 }
149
150 if (facts.isSourceOnly) {
151 flags.push({ kind: 'source-only', label: 'Source only', level: 'note', text: 'ships source only, so installing runs its build code' })
152 }
153
154 if (facts.isDeprecated) {
155 flags.push({ kind: 'deprecated', label: 'Deprecated', level: 'note', text: 'its author has withdrawn it' })
156 }
157
158 return flags
159}
160
161/** `express 4.21.0 · 11 years old · 31M a week`: what the registry knows, on one line. */
162export const summary = (request: Request, facts: Facts, now: number) => {
163 const parts = [facts.version === null ? request.name : `${request.name} ${facts.version}`]
164
165 if (facts.createdAt !== null) {
166 parts.push(`${span(Math.max(0, now - facts.createdAt))} old`)
167 }
168
169 if (facts.weeklyDownloads !== null) {
170 parts.push(`${compact(facts.weeklyDownloads)} a week`)
171 }
172
173 return parts.join(' · ')
174}
175hooks/detect.ts 283 lines1import type { Ecosystem, Oddity, Request } from '../types'
2import { commands } from './shell'
3
4export type Found = { requests: Request[]; oddities: Oddity[] }
5
6type Rule = {
7 ecosystem: Ecosystem
8 /** Flags that take the next argument as their value, which is then no package. */
9 valued: ReadonlySet<string>
10 /** Flags whose value is itself a package to fetch. */
11 naming?: ReadonlySet<string>
12 /** Flags that make every package of the command a local or remote source. */
13 local?: ReadonlySet<string>
14 isExecuted?: true
15 /** Only the first argument is a package: the rest belong to the program it runs. */
16 isFirstOnly?: true
17}
18
19const NPM_VALUED = new Set(['--prefix', '--registry', '-w', '--workspace', '--tag', '--cache', '--userconfig', '-C', '--dir', '--filter', '-F', '--cwd'])
20const PIP_VALUED = new Set([
21 '-r', '--requirement', '-c', '--constraint', '-e', '--editable', '-i', '--index-url', '--extra-index-url', '-t', '--target',
22 '--python', '-p', '-f', '--find-links', '--prefix', '--root', '--group', '--extra', '--optional', '--index', '--platform',
23 '--python-version', '--implementation', '--abi', '--src', '--upgrade-strategy', '--config-settings', '--cache-dir', '--directory', '--project',
24])
25const CARGO_VALUED = new Set([
26 '--features', '-F', '--rename', '--package', '-p', '--manifest-path', '--registry', '--branch', '--tag', '--rev', '--version', '--vers',
27 '--root', '--bin', '--example', '--profile', '--target', '--target-dir', '--index', '-j', '--jobs', '--config', '-Z',
28])
29const GEM_VALUED = new Set(['-v', '--version', '-i', '--install-dir', '-n', '--bindir', '-P', '--trust-policy', '--platform', '-g', '--file'])
30
31const NPM: Rule = { ecosystem: 'npm', valued: NPM_VALUED }
32const NPX: Rule = { ecosystem: 'npm', valued: new Set([...NPM_VALUED, '-c', '--call']), naming: new Set(['-p', '--package']), isExecuted: true, isFirstOnly: true }
33const PIP: Rule = { ecosystem: 'pypi', valued: PIP_VALUED }
34const UVX: Rule = { ecosystem: 'pypi', valued: PIP_VALUED, naming: new Set(['--from', '--with']), isExecuted: true, isFirstOnly: true }
35const CARGO: Rule = { ecosystem: 'crates', valued: CARGO_VALUED, local: new Set(['--path', '--git']) }
36const GEM: Rule = { ecosystem: 'rubygems', valued: GEM_VALUED, local: new Set(['--local', '-l']) }
37
38/** The verbs of each program that fetch packages named on the command line. */
39const VERBS: Record<string, Record<string, Rule>> = {
40 npm: { install: NPM, i: NPM, add: NPM, in: NPM, ins: NPM, inst: NPM, isntall: NPM, exec: NPX, x: NPX },
41 pnpm: { add: NPM, install: NPM, i: NPM, dlx: NPX },
42 yarn: { add: NPM, dlx: NPX },
43 bun: { add: NPM, install: NPM, i: NPM, a: NPM, x: NPX },
44 pip: { install: PIP },
45 pipx: { install: PIP, run: UVX, inject: PIP },
46 poetry: { add: PIP },
47 pdm: { add: PIP },
48 uv: { add: PIP },
49 cargo: { add: CARGO, install: CARGO, binstall: CARGO },
50 gem: { install: GEM, i: GEM },
51}
52const DIRECT: Record<string, Rule> = { npx: NPX, bunx: NPX, pnpx: NPX, uvx: UVX }
53const NEVER_FETCHES = new Set(['--no-install', '--no', '--offline', '--dry-run', '--help', '-h'])
54const NPM_NAME = /^(?:@[a-z0-9~-][a-z0-9._~-]*\/)?[a-z0-9~-][a-z0-9._~-]*$/i
55const NPM_REMOTE = /^(?:git\+|git:|git@|https?:|github:|gitlab:|bitbucket:|gist:)/i
56const NPM_SHORTHAND = /^[\w.-]+\/[\w.-]+(?:#.+)?$/
57const LOCAL = /^(?:\.|\/|~|file:|link:|workspace:|portal:|[A-Za-z]:[\\/])/
58const ARCHIVE = /\.(?:whl|tar\.gz|tgz|zip|gem|crate)$/i
59const PYPI_SPEC = /^([A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?)(?:\[[^\]]*\])?\s*(?:(===?|~=|>=|<=|!=|>|<)\s*([^,;\s]+))?/
60const PLAIN_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/
61const URL = /https?:\/\/[^\s"'|)<>]+/
62const INTERPRETER = String.raw`(?:(?:ba|z|da|k|fi)?sh|python[\d.]*|node|perl|ruby)\b`
63const PIPED = new RegExp(String.raw`\b(?:curl|wget)\b[^|;&\n]*\|\s*(?:sudo\s+(?:-\S+\s+)*)?${INTERPRETER}`)
64const SUBSTITUTED = new RegExp(String.raw`\b(?:${INTERPRETER}\s+(?:-\w+\s+)*<\(\s*|${INTERPRETER}\s+-c\s+["']?\$\(\s*|eval\s+["']?\$\(\s*)(?:curl|wget)\b`)
65
66const PUBLIC_HOSTS = new Set(['registry.npmjs.org', 'pypi.org', 'crates.io', 'rubygems.org'])
67
68const host = (url: string) => /^https?:\/\/([^/\s:]+)/.exec(url)?.[1] ?? url
69
70/** A command line with what stands inside quotes taken out, so quoted text is not read as a pipeline. */
71const unquoted = (command: string) => command.replace(/'[^']*'/g, "''").replace(/"(?:[^"\\]|\\.)*"/g, '""')
72
73const npmSpec = (spec: string, via: string, isExecuted: boolean): Request | Oddity | null => {
74 if (LOCAL.test(spec) || ARCHIVE.test(spec)) {
75 return null
76 }
77
78 if (NPM_REMOTE.test(spec) || (!spec.startsWith('@') && NPM_SHORTHAND.test(spec))) {
79 return { kind: 'remote-source', detail: spec, via }
80 }
81
82 // `alias@npm:real@1.2.3` installs `real`.
83 const real = spec.includes('@npm:') ? spec.slice(spec.indexOf('@npm:') + 5) : spec
84 const at = real.lastIndexOf('@')
85 const name = at > 0 ? real.slice(0, at) : real
86 const version = at > 0 ? real.slice(at + 1) : null
87
88 return NPM_NAME.test(name)
89 ? { ecosystem: 'npm', name: name.toLowerCase(), version: version !== null && /^\d/.test(version) ? version : null, via, isExecuted }
90 : null
91}
92
93const pypiSpec = (spec: string, via: string, isExecuted: boolean): Request | Oddity | null => {
94 if (LOCAL.test(spec) || ARCHIVE.test(spec)) {
95 return null
96 }
97
98 if (/^(?:git\+|hg\+|svn\+|bzr\+|https?:)/i.test(spec) || /\s@\s|@(?:git\+|https?:)/.test(spec)) {
99 return { kind: 'remote-source', detail: spec, via }
100 }
101
102 const found = PYPI_SPEC.exec(spec)
103 // `uvx ruff@0.6.0` pins with an at sign.
104 const pinned = /^([A-Za-z0-9][A-Za-z0-9._-]*)@(\d[^\s]*)$/.exec(spec)
105 const name = pinned?.[1] ?? found?.[1]
106
107 if (name === undefined) {
108 return null
109 }
110
111 return {
112 ecosystem: 'pypi',
113 name: name.toLowerCase().replace(/[-_.]+/g, '-'),
114 version: pinned?.[2] ?? (found?.[2] === '==' ? (found[3] ?? null) : null),
115 via,
116 isExecuted,
117 }
118}
119
120const plainSpec = (ecosystem: Ecosystem) => (spec: string, via: string, isExecuted: boolean): Request | Oddity | null => {
121 if (LOCAL.test(spec) || ARCHIVE.test(spec)) {
122 return null
123 }
124
125 const [name = '', version] = spec.split('@')
126
127 return PLAIN_NAME.test(name) ? { ecosystem, name: name.toLowerCase(), version: version !== undefined && /^\d/.test(version) ? version : null, via, isExecuted } : null
128}
129
130const SPEC = { npm: npmSpec, pypi: pypiSpec, crates: plainSpec('crates'), rubygems: plainSpec('rubygems') }
131
132const read = (rule: Rule, args: readonly string[], via: string, found: Found) => {
133 if (args.some(one => NEVER_FETCHES.has(one))) {
134 return
135 }
136
137 const specs: string[] = []
138 let positional = 0
139
140 for (let i = 0; i < args.length; i += 1) {
141 const arg = args[i] ?? ''
142 const [flag = '', inline] = arg.startsWith('--') && arg.includes('=') ? [arg.slice(0, arg.indexOf('=')), arg.slice(arg.indexOf('=') + 1)] : [arg]
143
144 if (arg === '--') {
145 break
146 }
147
148 if (rule.local?.has(flag) === true) {
149 if (flag === '--git' || flag === '--source') {
150 found.oddities.push({ kind: 'remote-source', detail: inline ?? args[i + 1] ?? flag, via })
151 }
152
153 return
154 }
155
156 if (rule.naming?.has(flag) === true) {
157 specs.push(inline ?? args[i + 1] ?? '')
158 i += inline === undefined ? 1 : 0
159 // `uvx --from pkg tool`: the positional argument is then a program, not a package.
160 positional += flag === '--from' || flag === '-p' || flag === '--package' ? 1 : 0
161 } else if (arg.startsWith('-')) {
162 const isIndex = (flag === '--extra-index-url' || flag === '--index-url' || flag === '-i') && rule.ecosystem === 'pypi'
163 const isRegistry = flag === '--registry' && (rule.ecosystem === 'npm' || rule.ecosystem === 'crates')
164 const named = host(inline ?? args[i + 1] ?? '')
165
166 // A registry other than the public one is not the one that gets looked up.
167 if ((isIndex || isRegistry) && !PUBLIC_HOSTS.has(named)) {
168 found.oddities.push({ kind: 'remote-source', detail: `${isIndex ? 'index' : 'registry'} ${named}`, via })
169 }
170
171 i += inline === undefined && rule.valued.has(flag) ? 1 : 0
172 } else {
173 if (rule.isFirstOnly !== true || positional === 0) {
174 specs.push(arg)
175 }
176
177 positional += 1
178
179 if (rule.isFirstOnly === true) {
180 break
181 }
182 }
183 }
184
185 for (const spec of specs.filter(one => one !== '')) {
186 const one = SPEC[rule.ecosystem](spec, via, rule.isExecuted === true)
187
188 if (one !== null && 'kind' in one) {
189 found.oddities.push(one)
190 } else if (one !== null && !found.requests.some(other => other.ecosystem === one.ecosystem && other.name === one.name)) {
191 found.requests.push(one)
192 }
193 }
194}
195
196const SHELLS = new Set(['sh', 'bash', 'zsh', 'dash', 'ksh', 'fish'])
197const VALUED_BEFORE_VERB = new Set([...NPM_VALUED, ...CARGO_VALUED, '--python', '--directory', '--project', '--cache-dir', '--color', '--config-file'])
198const MOST_NESTED = 3
199
200/** Where a program's verb stands among its arguments, the flags before it and their values passed over. */
201const verbAt = (args: readonly string[], from = 0) => {
202 for (let i = from; i < args.length; i += 1) {
203 const arg = args[i] ?? ''
204
205 if (arg.startsWith('-')) {
206 i += !arg.includes('=') && VALUED_BEFORE_VERB.has(arg) ? 1 : 0
207 } else if (!arg.startsWith('+')) {
208 return i
209 }
210 }
211
212 return -1
213}
214
215const scan = (command: string, found: Found, depth: number) => {
216 for (const one of commands(command)) {
217 const at = verbAt(one.args)
218 const verb = at < 0 ? undefined : one.args[at]
219 const rest = at < 0 ? [] : one.args.slice(at + 1)
220 const secondAt = verbAt(rest)
221 const second = secondAt < 0 ? undefined : rest[secondAt]
222 const after = secondAt < 0 ? [] : rest.slice(secondAt + 1)
223 const name = /^pip[\d.]*$/.test(one.name) ? 'pip' : one.name
224 const isPip = /^python[\d.]*$/.test(name) && one.args[0] === '-m' && one.args[1] === 'pip' && one.args[2] === 'install'
225 const fetches =
226 DIRECT[name] !== undefined ||
227 isPip ||
228 (verb !== undefined && VERBS[name]?.[verb] !== undefined) ||
229 (name === 'uv' && (verb === 'pip' || verb === 'tool')) ||
230 (name === 'yarn' && verb === 'global')
231
232 // A script handed to a shell or to eval runs too: it is read as a command line of its own.
233 if (depth < MOST_NESTED && (name === 'eval' || (SHELLS.has(name) && one.args.includes('-c')))) {
234 const script = name === 'eval' ? one.args.join(' ') : (one.args[one.args.indexOf('-c') + 1] ?? '')
235 scan(script, found, depth + 1)
236 } else if (one.hasDynamicArgs && fetches) {
237 found.oddities.push({ kind: 'unreadable', detail: `${name}${verb === undefined ? '' : ` ${verb}`}`, via: name })
238 } else if (DIRECT[name] !== undefined) {
239 read(DIRECT[name], one.args, name, found)
240 } else if (isPip) {
241 read(PIP, one.args.slice(3), 'pip install', found)
242 } else if (name === 'uv' && verb === 'pip' && second === 'install') {
243 read(PIP, after, 'uv pip install', found)
244 } else if (name === 'uv' && verb === 'tool' && (second === 'install' || second === 'run')) {
245 read(second === 'run' ? UVX : PIP, after, `uv tool ${second}`, found)
246 } else if (name === 'yarn' && verb === 'global' && second === 'add') {
247 read(NPM, after, 'yarn global add', found)
248 } else if (name === 'brew' && (verb === 'install' || verb === 'reinstall' || verb === 'tap')) {
249 for (const formula of rest.filter(arg => !arg.startsWith('-'))) {
250 const parts = formula.split('/')
251 const isForeign = verb === 'tap' ? parts.length === 2 : parts.length === 3
252
253 if (isForeign && parts[0]?.toLowerCase() !== 'homebrew') {
254 found.oddities.push({ kind: 'tap', detail: formula, via: `brew ${verb}` })
255 }
256 }
257 } else if (verb !== undefined && VERBS[name]?.[verb] !== undefined) {
258 read(VERBS[name][verb], rest, `${name} ${verb}`, found)
259 }
260 }
261}
262
263/**
264 * The packages a Bash command would fetch from a registry, and what it would
265 * fetch from anywhere else: a script piped into a shell, a git URL, a tap.
266 *
267 * A bare `npm install` or `pip install -r requirements.txt` asks for nothing
268 * by name, so it is not listed: the lockfile or the file is the project's own.
269 */
270export const findInstalls = (command: string): Found => {
271 const found: Found = { requests: [], oddities: [] }
272 const plain = unquoted(command)
273
274 if (PIPED.test(plain) || SUBSTITUTED.test(command)) {
275 const url = URL.exec(command)?.[0]
276 found.oddities.push({ kind: 'pipe-to-shell', detail: url === undefined ? 'a downloaded script' : host(url), via: 'curl | sh' })
277 }
278
279 scan(command, found, 0)
280
281 return found
282}
283hooks/registry.ts 235 lines1import type { Facts, Request } from '../types'
2
3/** Fetches a URL and answers its status and body, or null when the host could not be reached. */
4export type Get = (url: string) => Promise<{ status: number; text: string } | null>
5
6/** Below this many weekly downloads npm's whole record of a package is small enough to read for its dates. */
7const SMALL_PACKAGE = 10_000
8
9const UNCHECKED: Facts = {
10 isChecked: false,
11 isFound: false,
12 version: null,
13 createdAt: null,
14 publishedAt: null,
15 weeklyDownloads: null,
16 hasInstallScript: false,
17 isDeprecated: false,
18 isSourceOnly: false,
19}
20const MISSING: Facts = { ...UNCHECKED, isChecked: true }
21
22type Json = Record<string, unknown>
23
24const json = (text: string | undefined): Json | null => {
25 try {
26 const parsed: unknown = JSON.parse(text ?? '')
27
28 return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) ? (parsed as Json) : null
29 } catch {
30 return null
31 }
32}
33
34const record = (value: unknown): Json => (typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Json) : {})
35
36const list = (value: unknown): Json[] => (Array.isArray(value) ? value.map(record) : [])
37
38const text = (value: unknown) => (typeof value === 'string' && value !== '' ? value : null)
39
40const count = (value: unknown) => (typeof value === 'number' && Number.isFinite(value) ? value : null)
41
42const time = (value: unknown) => {
43 const at = typeof value === 'string' ? Date.parse(value) : Number.NaN
44
45 return Number.isNaN(at) ? null : at
46}
47
48const earliest = (times: readonly (number | null)[]) => {
49 const known = times.filter(one => one !== null)
50
51 return known.length === 0 ? null : Math.min(...known)
52}
53
54const npm = async (get: Get, request: Request): Promise<Facts> => {
55 const name = request.name.replace('/', '%2F')
56 const [asked, downloads] = await Promise.all([
57 get(`https://registry.npmjs.org/${name}/${request.version ?? 'latest'}`),
58 get(`https://api.npmjs.org/downloads/point/last-week/${request.name}`),
59 ])
60
61 if (asked === null) {
62 return UNCHECKED
63 }
64
65 // A pinned version the registry lacks still tells nothing of the package itself.
66 const found = asked.status === 404 && request.version !== null ? await get(`https://registry.npmjs.org/${name}/latest`) : asked
67
68 if (found === null || (found.status !== 200 && found.status !== 404)) {
69 return UNCHECKED
70 }
71
72 const manifest = found.status === 200 ? json(found.text) : null
73
74 if (manifest === null) {
75 return MISSING
76 }
77
78 const scripts = record(manifest.scripts)
79 const version = text(manifest.version)
80 const weeklyDownloads = downloads?.status === 200 ? count(json(downloads.text)?.downloads) : null
81 let createdAt: number | null = null
82 let publishedAt: number | null = null
83
84 let isTooNewToDate = false
85
86 if (weeklyDownloads === null || weeklyDownloads < SMALL_PACKAGE) {
87 const times = record(json((await get(`https://registry.npmjs.org/${name}`))?.text)?.time)
88 createdAt = time(times.created)
89 publishedAt = version === null ? null : time(times[version])
90 } else if (request.version === null) {
91 const hits = list(json((await get(`https://registry.npmjs.org/-/v1/search?text=${encodeURIComponent(request.name)}&size=5`))?.text)?.objects)
92 const own = hits.map(hit => record(hit.package)).find(one => one.name === request.name)
93 publishedAt = own?.version === version ? time(own?.date) : null
94 }
95
96 // A pinned version must have its own date checked, however used the package is. A much-used
97 // package's whole record runs to megabytes and cannot be read here, so the one version is
98 // asked of deps.dev, which mirrors npm's dates. A version npm has and deps.dev has not yet
99 // seen was published within the last hours: that is what a fresh release is.
100 if (request.version !== null && version === request.version && publishedAt === null) {
101 const dated = await get(`https://api.deps.dev/v3/systems/npm/packages/${encodeURIComponent(request.name)}/versions/${encodeURIComponent(version)}`)
102 publishedAt = dated?.status === 200 ? time(json(dated.text)?.publishedAt) : null
103 isTooNewToDate = dated?.status === 404
104 }
105
106 return {
107 isChecked: true,
108 isFound: true,
109 version,
110 createdAt,
111 publishedAt,
112 weeklyDownloads,
113 ...(isTooNewToDate ? { isTooNewToDate } : {}),
114 hasInstallScript: ['preinstall', 'install', 'postinstall'].some(one => text(scripts[one]) !== null),
115 isDeprecated: text(manifest.deprecated) !== null,
116 isSourceOnly: false,
117 }
118}
119
120const pypi = async (get: Get, request: Request): Promise<Facts> => {
121 const [found, downloads] = await Promise.all([
122 get(`https://pypi.org/pypi/${request.name}/json`),
123 get(`https://pypistats.org/api/packages/${request.name}/recent`),
124 ])
125
126 if (found === null || (found.status !== 200 && found.status !== 404)) {
127 return UNCHECKED
128 }
129
130 const body = found.status === 200 ? json(found.text) : null
131
132 if (body === null) {
133 return MISSING
134 }
135
136 const releases = record(body.releases)
137 const version = request.version ?? text(record(body.info).version)
138 const files = version === null ? [] : list(releases[version])
139
140 return {
141 isChecked: true,
142 isFound: true,
143 version,
144 createdAt: earliest(Object.values(releases).flatMap(release => list(release).map(file => time(file.upload_time_iso_8601)))),
145 publishedAt: earliest(files.map(file => time(file.upload_time_iso_8601))),
146 weeklyDownloads: downloads?.status === 200 ? count(record(json(downloads.text)?.data).last_week) : null,
147 hasInstallScript: false,
148 isDeprecated: files.length > 0 && files.every(file => file.yanked === true),
149 isSourceOnly: files.length > 0 && files.every(file => file.packagetype === 'sdist'),
150 }
151}
152
153const crates = async (get: Get, request: Request): Promise<Facts> => {
154 const found = await get(`https://crates.io/api/v1/crates/${request.name}`)
155
156 if (found === null || (found.status !== 200 && found.status !== 404)) {
157 return UNCHECKED
158 }
159
160 const body = found.status === 200 ? json(found.text) : null
161
162 if (body === null) {
163 return MISSING
164 }
165
166 const crate = record(body.crate)
167 const version = request.version ?? text(crate.max_stable_version) ?? text(crate.newest_version)
168 const published = list(body.versions).find(one => one.num === version)
169 const recent = count(crate.recent_downloads)
170
171 return {
172 isChecked: true,
173 isFound: true,
174 version,
175 createdAt: time(crate.created_at),
176 publishedAt: time(published?.created_at),
177 // crates.io counts the last ninety days.
178 weeklyDownloads: recent === null ? null : Math.round(recent / 13),
179 hasInstallScript: false,
180 isDeprecated: published?.yanked === true,
181 isSourceOnly: false,
182 }
183}
184
185const rubygems = async (get: Get, request: Request): Promise<Facts> => {
186 const [found, versions] = await Promise.all([
187 get(`https://rubygems.org/api/v1/gems/${request.name}.json`),
188 get(`https://rubygems.org/api/v1/versions/${request.name}.json`),
189 ])
190
191 if (found === null || (found.status !== 200 && found.status !== 404)) {
192 return UNCHECKED
193 }
194
195 const body = found.status === 200 ? json(found.text) : null
196
197 if (body === null) {
198 return MISSING
199 }
200
201 let history: Json[] = []
202
203 try {
204 history = versions?.status === 200 ? list(JSON.parse(versions.text)) : []
205 } catch {
206 history = []
207 }
208
209 const version = request.version ?? text(body.version)
210
211 return {
212 isChecked: true,
213 isFound: true,
214 version,
215 createdAt: earliest(history.map(one => time(one.created_at))),
216 publishedAt: time(history.find(one => one.number === version)?.created_at) ?? (request.version === null ? time(body.version_created_at) : null),
217 // RubyGems publishes a total, not a rate, so popularity is left unjudged.
218 weeklyDownloads: null,
219 hasInstallScript: false,
220 isDeprecated: false,
221 isSourceOnly: false,
222 }
223}
224
225const REGISTRY = { npm, pypi, crates, rubygems }
226
227/** What the package's own registry says of it. Never throws: a registry that cannot be read answers `isChecked: false`. */
228export const lookup = async (get: Get, request: Request): Promise<Facts> => {
229 try {
230 return await REGISTRY[request.ecosystem](get, request)
231 } catch {
232 return UNCHECKED
233 }
234}
235hooks/popular.ts 80 lines1// Well-known package names per registry: a name one edit away from one of these is flagged as a possible typosquat.
2
3const NPM: readonly string[] = [
4 'acorn', 'adm-zip', 'ai', 'ajv', 'alpinejs', 'angular', 'ansi-styles', 'apollo-client', 'apollo-server', 'archiver', 'astro',
5 'async', 'autoprefixer', 'aws-sdk', 'axios', 'babel-loader', 'bcrypt', 'bcryptjs', 'better-sqlite3', 'biome', 'bl', 'bluebird',
6 'body-parser', 'bootstrap', 'boxen', 'buffer', 'bunyan', 'bytes', 'canvas', 'chai', 'chalk', 'chart.js', 'cheerio', 'chokidar',
7 'classnames', 'clsx', 'color', 'color-convert', 'colors', 'commander', 'concurrently', 'cookie-parser', 'core-js', 'cors',
8 'cross-env', 'cross-fetch', 'crypto-js', 'css-loader', 'cssnano', 'csv-parse', 'cypress', 'd3', 'date-fns', 'dayjs', 'debug',
9 'deepmerge', 'discord.js', 'dotenv', 'drizzle-orm', 'ejs', 'electron', 'electron-builder', 'elysia', 'esbuild', 'escape-html',
10 'eslint', 'eslint-config-prettier', 'eslint-plugin-import', 'eslint-plugin-react', 'event-stream', 'execa', 'expo', 'express',
11 'fast-xml-parser', 'fastify', 'file-loader', 'filesize', 'firebase', 'firebase-admin', 'form-data', 'formik', 'framer-motion',
12 'fs-extra', 'gatsby', 'glob', 'googleapis', 'got', 'graceful-fs', 'graphql', 'handlebars', 'hapi', 'he', 'helmet',
13 'highlight.js', 'hono', 'html-webpack-plugin', 'htmx.org', 'husky', 'iconv-lite', 'immer', 'inherits', 'ini', 'inquirer',
14 'ioredis', 'is-number', 'is-odd', 'isomorphic-fetch', 'jest', 'jimp', 'joi', 'jose', 'jotai', 'jquery', 'js-yaml', 'jsdom',
15 'jsonwebtoken', 'jszip', 'kleur', 'knex', 'koa', 'langchain', 'left-pad', 'lerna', 'less', 'lint-staged', 'lit', 'lodash',
16 'lodash.debounce', 'lodash.get', 'lodash.merge', 'log4js', 'loglevel', 'lru-cache', 'lucide-react', 'luxon', 'markdown-it',
17 'marked', 'mime', 'mime-types', 'mini-css-extract-plugin', 'minimatch', 'minimist', 'mkdirp', 'mobx', 'mocha', 'moment',
18 'mongodb', 'mongoose', 'morgan', 'ms', 'msw', 'multer', 'mustache', 'mysql', 'mysql2', 'nanoid', 'nest', 'next', 'nock',
19 'node-cache', 'node-fetch', 'nodemailer', 'nodemon', 'npm-run-all', 'nunjucks', 'nuxt', 'nx', 'object-assign', 'openai', 'ora',
20 'oxlint', 'p-limit', 'p-map', 'p-queue', 'papaparse', 'parcel', 'passport', 'pdfkit', 'pg', 'picocolors', 'pify', 'pino',
21 'playwright', 'pm2', 'postcss', 'preact', 'prettier', 'pretty-ms', 'prisma', 'prismjs', 'pug', 'puppeteer', 'q', 'qs',
22 'query-string', 'quick-lru', 'ramda', 'react', 'react-dom', 'react-hook-form', 'react-native', 'react-query', 'react-redux',
23 'react-router', 'react-router-dom', 'readable-stream', 'recharts', 'redis', 'redux', 'regenerator-runtime', 'remix', 'request',
24 'restify', 'rimraf', 'rollup', 'rxjs', 'safe-buffer', 'sass', 'selenium-webdriver', 'semver', 'sentry', 'sequelize', 'sharp',
25 'shelljs', 'sinon', 'socket.io', 'socket.io-client', 'solid-js', 'source-map', 'source-map-support', 'sqlite3', 'storybook',
26 'string-width', 'strip-ansi', 'stripe', 'style-loader', 'styled-components', 'stylelint', 'superagent', 'supertest',
27 'supports-color', 'svelte', 'swr', 'tailwindcss', 'tar', 'telegraf', 'terser', 'three', 'through2', 'toml', 'trpc', 'ts-jest',
28 'ts-loader', 'ts-node', 'tslib', 'tsup', 'tsx', 'turbo', 'twilio', 'typeorm', 'typescript', 'uglify-js', 'unbuild', 'underscore',
29 'undici', 'unzipper', 'url-loader', 'urql', 'util-deprecate', 'uuid', 'validator', 'vite', 'vite-plugin-react', 'vitest', 'vue',
30 'webpack', 'webpack-cli', 'webpack-dev-server', 'winston', 'wrap-ansi', 'ws', 'xlsx', 'xml2js', 'yaml', 'yargs', 'yauzl', 'yup',
31 'zod', 'zustand', 'zx',
32]
33
34const PYPI: readonly string[] = [
35 'aiohttp', 'alembic', 'altair', 'ansible', 'anthropic', 'apscheduler', 'arrow', 'asyncpg', 'attrs', 'autopep8', 'awscli',
36 'azure-identity', 'azure-storage-blob', 'backoff', 'bandit', 'bcrypt', 'beautifulsoup4', 'black', 'bokeh', 'boto3', 'botocore',
37 'bottle', 'build', 'cachetools', 'catboost', 'celery', 'certifi', 'chardet', 'charset-normalizer', 'click', 'colorama',
38 'confluent-kafka', 'coverage', 'cryptography', 'cython', 'dash', 'dask', 'dataclasses-json', 'datasets', 'decorator', 'decouple',
39 'discord.py', 'diskcache', 'distlib', 'django', 'docker', 'ecdsa', 'email-validator', 'environs', 'fabric', 'factory-boy',
40 'faker', 'fastapi', 'filelock', 'flake8', 'flask', 'gensim', 'google-api-python-client', 'google-cloud-storage', 'gradio',
41 'grpcio', 'gunicorn', 'hatch', 'html5lib', 'httpcore', 'httpx', 'huggingface-hub', 'hypothesis', 'idna', 'imageio',
42 'importlib-metadata', 'ipykernel', 'ipython', 'isort', 'itsdangerous', 'jax', 'jinja2', 'joblib', 'jsonschema', 'jupyter',
43 'jupyterlab', 'kafka-python', 'keras', 'kivy', 'kubernetes', 'langchain', 'lightgbm', 'loguru', 'lxml', 'mako', 'markupsafe',
44 'marshmallow', 'matplotlib', 'mock', 'more-itertools', 'motor', 'msgpack', 'mypy', 'mysqlclient', 'nbconvert', 'networkx',
45 'nltk', 'notebook', 'nox', 'numba', 'numpy', 'oauthlib', 'openai', 'opencv-python', 'openpyxl', 'orjson', 'packaging', 'pandas',
46 'paramiko', 'passlib', 'peewee', 'pendulum', 'pika', 'pillow', 'pip', 'pipenv', 'platformdirs', 'playwright', 'plotly', 'ply',
47 'poetry', 'polars', 'pre-commit', 'prettytable', 'protobuf', 'psutil', 'psycopg2', 'psycopg2-binary', 'pyarrow', 'pycryptodome',
48 'pydantic', 'pydantic-core', 'pygame', 'pyjwt', 'pylint', 'pymongo', 'pymysql', 'pynacl', 'pyopenssl', 'pyparsing', 'pyqt5',
49 'pyramid', 'pyserial', 'pytest', 'pytest-asyncio', 'pytest-cov', 'pytest-mock', 'python-dateutil', 'python-dotenv',
50 'python-multipart', 'python-telegram-bot', 'pytz', 'pyyaml', 'pyzmq', 'redis', 'regex', 'requests', 'requests-oauthlib', 'rich',
51 'rsa', 'ruff', 's3transfer', 'sanic', 'schedule', 'scikit-image', 'scikit-learn', 'scipy', 'scrapy', 'seaborn', 'selenium',
52 'sendgrid', 'sentence-transformers', 'sentry-sdk', 'setuptools', 'simplejson', 'six', 'slack-sdk', 'spacy', 'sqlalchemy',
53 'starlette', 'statsmodels', 'streamlit', 'stripe', 'structlog', 'sympy', 'tabulate', 'tenacity', 'tensorflow', 'termcolor',
54 'thrift', 'tiktoken', 'tkinter', 'tokenizers', 'toml', 'tomli', 'torch', 'torchvision', 'tornado', 'tortoise-orm', 'tox', 'tqdm',
55 'transformers', 'tweepy', 'twilio', 'twine', 'typer', 'typing-extensions', 'tzdata', 'ujson', 'urllib3', 'uv', 'uvicorn',
56 'virtualenv', 'watchdog', 'websocket-client', 'websockets', 'werkzeug', 'wheel', 'wrapt', 'xgboost', 'xlrd', 'xlsxwriter',
57 'yapf', 'zipp',
58]
59
60const CRATES: readonly string[] = [
61 'actix-web', 'ahash', 'anyhow', 'askama', 'async-std', 'async-trait', 'axum', 'base64', 'bat', 'bevy', 'bincode', 'bitflags',
62 'bytes', 'cargo-edit', 'cargo-watch', 'cc', 'cfg-if', 'chrono', 'clap', 'colored', 'crossbeam', 'crossterm', 'csv', 'dashmap',
63 'derive_more', 'diesel', 'dirs', 'either', 'env_logger', 'fd-find', 'futures', 'glob', 'handlebars', 'hashbrown', 'hex', 'http',
64 'hyper', 'image', 'indexmap', 'indicatif', 'itertools', 'js-sys', 'lazy_static', 'libc', 'log', 'memchr', 'mongodb',
65 'native-tls', 'nom', 'num', 'num-traits', 'once_cell', 'openssl', 'parking_lot', 'pest', 'pin-project', 'proc-macro2', 'prost',
66 'quote', 'rand', 'ratatui', 'rayon', 'redis', 'regex', 'reqwest', 'ring', 'ripgrep', 'rocket', 'rusqlite', 'rustls', 'sea-orm',
67 'semver', 'serde', 'serde_derive', 'serde_json', 'serde_yaml', 'sha2', 'smallvec', 'sqlx', 'structopt', 'strum', 'syn', 'tauri',
68 'tempfile', 'tera', 'thiserror', 'time', 'tokio', 'toml', 'tonic', 'tower', 'tracing', 'url', 'uuid', 'walkdir', 'warp',
69 'wasm-bindgen', 'web-sys', 'wgpu',
70]
71
72const RUBYGEMS: readonly string[] = [
73 'activerecord', 'activesupport', 'aws-sdk', 'bootsnap', 'bundler', 'byebug', 'capybara', 'cocoapods', 'devise', 'dotenv',
74 'factory_bot', 'faker', 'faraday', 'fastlane', 'httparty', 'jekyll', 'json', 'minitest', 'mysql2', 'nokogiri', 'pg', 'pry',
75 'puma', 'rack', 'rails', 'rake', 'redis', 'rspec', 'rubocop', 'sass', 'sidekiq', 'sinatra', 'sprockets', 'sqlite3', 'stripe',
76 'thor', 'turbo-rails', 'webpacker', 'xcpretty',
77]
78
79export const POPULAR = { npm: NPM, pypi: PYPI, crates: CRATES, rubygems: RUBYGEMS } as const
80hooks/shell.ts 203 lines1export type Word = {
2 text: string
3 start: number
4 end: number
5 isRedirect: boolean
6 isDynamic: boolean
7}
8
9export type Command = {
10 /** The executable's name, any folder before it dropped. */
11 name: string
12 args: string[]
13 /** True when an argument is built at run time (`$VAR`, `$(...)`), so its text is not what runs. */
14 hasDynamicArgs: boolean
15}
16
17const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/
18const WRAPPERS = new Set(['time', 'command', 'exec', 'env', 'nohup', 'sudo', 'caffeinate', '{', '!', 'if', 'then', 'else', 'do', 'while'])
19
20const split = (command: string): Word[][] => {
21 const segments: Word[][] = []
22 let words: Word[] = []
23 let word: Word | null = null
24 const open = (at: number): Word => {
25 word ??= { text: '', start: at, end: at, isRedirect: false, isDynamic: false }
26
27 return word
28 }
29 const push = (at: number) => {
30 if (word !== null) {
31 word.end = at
32 words.push(word)
33 word = null
34 }
35 }
36 const cut = (at: number) => {
37 push(at)
38
39 if (words.length > 0) {
40 segments.push(words)
41 }
42
43 words = []
44 }
45 const size = command.length
46 let i = 0
47
48 while (i < size) {
49 const c = command.charAt(i)
50 const following = command.charAt(i + 1)
51
52 if (c === '\\') {
53 if (following !== '\n') {
54 open(i).text += following
55 }
56
57 i += 2
58 } else if (c === "'") {
59 const close = command.indexOf("'", i + 1)
60 const stop = close < 0 ? size : close
61 open(i).text += command.slice(i + 1, stop)
62 i = stop + 1
63 } else if (c === '"') {
64 const quoted = open(i)
65 i += 1
66
67 while (i < size && command.charAt(i) !== '"') {
68 const inner = command.charAt(i)
69 const escaped = command.charAt(i + 1)
70
71 if (inner === '\\' && '\\"$`\n'.includes(escaped) && escaped !== '') {
72 quoted.text += escaped === '\n' ? '' : escaped
73 i += 2
74 } else {
75 quoted.isDynamic ||= inner === '$' || inner === '`'
76 quoted.text += inner
77 i += 1
78 }
79 }
80
81 i += 1
82 } else if (c === '$' && following === '(') {
83 const substituted = open(i)
84 let depth = 0
85 let stop = i + 1
86
87 for (; stop < size; stop += 1) {
88 const inner = command.charAt(stop)
89 depth += inner === '(' ? 1 : inner === ')' ? -1 : 0
90
91 if (depth === 0) {
92 break
93 }
94 }
95
96 substituted.isDynamic = true
97 substituted.text += command.slice(i, stop + 1)
98 i = stop + 1
99 } else if (c === '`') {
100 const close = command.indexOf('`', i + 1)
101 const stop = close < 0 ? size : close
102 const substituted = open(i)
103 substituted.isDynamic = true
104 substituted.text += command.slice(i, stop + 1)
105 i = stop + 1
106 } else if (c === '#' && word === null) {
107 const newline = command.indexOf('\n', i)
108 i = newline < 0 ? size : newline
109 } else if (c === ' ' || c === '\t') {
110 push(i)
111 i += 1
112 } else if (c === '>' || c === '<') {
113 const redirect = open(i)
114 redirect.isRedirect = true
115 redirect.text += c
116 i += 1
117 } else if (c === '&' && ('<>'.includes(command.charAt(i - 1) || ' ') || following === '>')) {
118 const redirect = open(i)
119 redirect.isRedirect = true
120 redirect.text += c
121 i += 1
122 } else if (';\n|&()'.includes(c)) {
123 cut(i)
124 i += 1
125 } else {
126 const plain = open(i)
127 plain.isDynamic ||= c === '$'
128 plain.text += c
129 i += 1
130 }
131 }
132
133 cut(size)
134
135 return segments
136}
137
138const analyse = (words: Word[]): Command | null => {
139 let i = 0
140
141 while (i < words.length) {
142 const text = words[i]?.text ?? ''
143
144 if (ASSIGNMENT.test(text)) {
145 i += 1
146 } else if (WRAPPERS.has(text)) {
147 i += 1
148
149 while (words[i]?.text.startsWith('-') === true) {
150 i += 1
151 }
152 } else {
153 break
154 }
155 }
156
157 const head = words[i]
158
159 if (head === undefined || head.isRedirect || head.isDynamic) {
160 return null
161 }
162
163 const rest = words.slice(i + 1)
164 const redirect = rest.findIndex(one => one.isRedirect)
165 const args = redirect < 0 ? rest : rest.slice(0, redirect)
166
167 return {
168 name: head.text.slice(head.text.lastIndexOf('/') + 1),
169 args: args.map(one => one.text),
170 hasDynamicArgs: args.some(one => one.isDynamic),
171 }
172}
173
174const HEREDOC = /<<-?\s*(['"]?)([A-Za-z_][A-Za-z0-9_]*)\1/
175
176/** A command line without the bodies of its here-documents, which are text and not commands. */
177const withoutHeredocs = (command: string) => {
178 const kept: string[] = []
179 let end: string | null = null
180
181 for (const line of command.split('\n')) {
182 if (end !== null) {
183 end = line.trim() === end ? null : end
184 } else {
185 kept.push(line)
186 end = line.includes('<<<') ? null : (HEREDOC.exec(line)?.[2] ?? null)
187 }
188 }
189
190 return kept.join('\n')
191}
192
193/**
194 * The commands a Bash command line runs, in order.
195 *
196 * Only a command standing at a command position counts: one inside a quoted
197 * string, a `$(...)` or a here-document's body is text, not something that runs here.
198 */
199export const commands = (command: string): Command[] =>
200 split(withoutHeredocs(command))
201 .map(analyse)
202 .filter(one => one !== null)
203types/index.d.ts 85 lines1export type Ecosystem = 'npm' | 'pypi' | 'crates' | 'rubygems'
2
3/** One package a command would fetch from a registry. */
4export type Request = {
5 ecosystem: Ecosystem
6 name: string
7 /** The version asked for by name, when one was pinned. */
8 version: string | null
9 /** The command that asks for it: `npm install`, `npx`, `pip install`. */
10 via: string
11 /** True when the package is run at once, as `npx` and `uvx` do, not only stored. */
12 isExecuted: boolean
13}
14
15/** Something a command fetches that no registry vouches for. */
16export type Oddity = {
17 kind: 'pipe-to-shell' | 'remote-source' | 'tap' | 'unreadable'
18 detail: string
19 via: string
20}
21
22/** What a registry says of a package. */
23export type Facts = {
24 /** False when the registry could not be asked. */
25 isChecked: boolean
26 /** False when the registry has no package of that name. */
27 isFound: boolean
28 version: string | null
29 /** When the package was first published, in milliseconds. */
30 createdAt: number | null
31 /** When the version asked for was published, in milliseconds. */
32 publishedAt: number | null
33 weeklyDownloads: number | null
34 /** True when the version is on the registry but too new for any dated record of it to exist yet. */
35 isTooNewToDate?: boolean
36 hasInstallScript: boolean
37 isDeprecated: boolean
38 /** True when the version has no built distribution, so installing it runs its build code. */
39 isSourceOnly: boolean
40}
41
42export type Flag = {
43 kind: 'missing' | 'typosquat' | 'new' | 'fresh' | 'undated' | 'unpopular' | 'unchecked' | 'script' | 'deprecated' | 'source-only'
44 /** `risk` holds the command for an answer; `note` is only shown. */
45 level: 'risk' | 'note'
46 /** The kind of concern in two or three words, shown before the detail: `Possible typosquat`. */
47 label: string
48 text: string
49 /** The well-known package this one could be mistaken for, when that is the concern. */
50 near?: string
51}
52
53export type Checked = Request & {
54 facts: Facts
55 flags: Flag[]
56 /** The package this one looks like, with what its registry says of it. */
57 lookalike: { name: string; weeklyDownloads: number | null } | null
58}
59
60export type Held = {
61 id: string
62 command: string
63 packages: Checked[]
64 oddities: Oddity[]
65}
66
67export type Entry = {
68 at: number
69 /** `passed`: nothing flagged. `installed`, `cancelled`: the person's answer. `unanswered`: nobody answered. */
70 outcome: 'passed' | 'installed' | 'cancelled' | 'unanswered'
71 packages: Checked[]
72 oddities: Oddity[]
73}
74
75declare module 'claude-code' {
76 interface PluginState {
77 'installguard': {
78 held: Held | null
79 log: Entry[]
80 /** `ecosystem:name` of every package the person chose to always allow. */
81 allowed: string[]
82 }
83 }
84}
85