SLOPSHOPPER

Install Guard

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…

newpaneguardcommandtoastnetwork
★ 1v0.1.11MITupdated 2026-10-09griches/installguard
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · installguard
│ ┃ Install Guard ✕ › fix the failing auth test and add an audit log call │ ┃ Nothing checked yet. │ ┃ New packages from npm, PyPI, crates.io and ⏺ Read(src/auth.ts) │ ┃ RubyGems are looked up before they install. ⎿ Read 6 lines │ ┃ No package is always allowed. ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /installguard │ ⎿ installguard: Install Guard pane opened. Nothing checked yet in │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Install Guard
Nothing checked yet. New packages from npm, PyPI, crates.io and RubyGems are looked up before they install. No package is always allowed.
README

installguard

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.

An install of a lookalike package held with a question, then cancelled

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.

Install

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.

What is checked

RegistryCommands
npmnpm install, pnpm add, yarn add, bun add, and packages run at once by npx, pnpm dlx, bunx
PyPIpip install, python -m pip install, uv add, uv pip install, poetry add, pdm add, pipx, uvx
crates.iocargo add, cargo install
RubyGemsgem install

Outside a registry, these are always held:

  • A download piped into a shell or an interpreter: curl … | sh, bash <(curl …), sh -c "$(curl …)".
  • A package from a git URL, a tarball URL or a user/repo shorthand.
  • pip install from an extra index, or npm install --registry pointing anywhere but the public registry.
  • An install whose package name comes from a shell variable, since it cannot be read.
  • A Homebrew formula from a third-party tap.

What is flagged

FlagMeaningDefault threshold
Not on the registryThe name may be made up, misspelt or private. The nearest known name is suggested
LookalikeOne edit, one swap or one dropped separator away from a well-known package, and not widely used itself
New packageFirst published recently30 days
Fresh versionThe 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 too3 days
Release date unknownA 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 usedFew downloads a week1,000
Deprecated and about to runnpx 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.

What is never asked about

  • A bare npm install, npm ci or pip install -r requirements.txt: the lockfile or the file is your project's own.
  • A package already in package.json, requirements.txt, pyproject.toml, Cargo.toml or the Gemfile.
  • npx of something already in node_modules/.bin.
  • A local path or a workspace package.
  • A package you chose to always allow.

What you see

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 question on the left and the report pane on the right while an install is held

  • Cancel refuses the command and tells Claude why, so it can offer the package you meant.
  • Run it once runs it. The package is asked about again next time.
  • Run it and always allow runs it and remembers the package across sessions.
  • Typing an answer refuses the command and passes your words to Claude: "use express instead".

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.

Commands

/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

Settings

All under installguard in /config.

SettingDefaultWhat it does
What is heldflaggedalways: every command that adds a package the project does not have
Sensitivitybalancedrelaxed: 7 days, 1 day, 100 downloads a week. strict: 90 days, 7 days, 10,000
When a registry cannot be reachedallowhold: the command waits for your answer
Say when a package passesonOff: no toast for a clean package

Limits

  • It reads the command line. A package pulled in as a dependency of the one you named is not looked up.
  • A package named through a shell variable (npm install $PKG) cannot be read, so the command is held for your answer.
  • It watches commands, not files. A dependency Claude writes into package.json and then installs with a bare npm install is not looked up.
  • Popularity on RubyGems is not judged, since RubyGems publishes a total and not a rate.
  • A private package is "not on the registry" as far as the public registry knows. Answer once with "always allow".
  • It is a second look, not a scanner: it does not read the package's code.

What it does on your machine

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:

HostAsked for
registry.npmjs.orgAn npm package's version, publish dates, install scripts and deprecation
api.npmjs.orgAn npm package's weekly downloads
pypi.orgA PyPI package's releases and their dates
pypistats.orgA PyPI package's weekly downloads
crates.ioA crate's versions, dates and recent downloads
rubygems.orgA gem's versions and dates
api.deps.devThe 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.

Security

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.

Development

claude plugin validate .
claude plugin test .
claude --plugin-dir .

Licence

MIT. See LICENSE.

Source 7 files
hooks/register.tsx 522 lines
1import { 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}
522
hooks/assess.ts 175 lines
1import 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}
175
hooks/detect.ts 283 lines
1import 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}
283
hooks/registry.ts 235 lines
1import 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}
235
hooks/popular.ts 80 lines
1// 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
80
hooks/shell.ts 203 lines
1export 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)
203
types/index.d.ts 85 lines
1export 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