Supply-chain gate: vets npm/pnpm/yarn/bun installs against the registry before they run

A Claude Code mod that checks every package install before it runs. When the agent runs npm, pnpm, yarn or bun add/install <pkg>, the mod looks the package up on the npm registry first. If it looks risky, the install is denied and the agent gets a readable reason.
Agents install whatever they think they need, including packages that don't exist or that they misspelled. After the Axios, Intercom and Better-Auth supply-chain incidents this year, a check before every install is cheap insurance.
An install is denied, with every reason that applies, when:
preact isn't flagged as a copy of react.preinstall, install or postinstall script that the previous version didn't have.If the registry can't be reached, the install is denied rather than allowed through. A denied install tells the agent to ask you to run it yourself.
Example:
dep-bouncer blocked this install:
- fresh@2.0.0 was published 5h ago (under 72h). Pin an older release instead, e.g. fresh@1.0.0.
Double-check the package is the one you meant. If it is, ask the user to run the install themselves.
claude CLI, for claude plugin installregistry.npmjs.org and api.npmjs.orgclaude plugin marketplace add PedroLaRosa/claude-code
claude plugin install dep-bouncer@pedro-la-rosa-claude-code
Then run /reload-plugins.
tool.call hook on Bash. It splits the command on &&, ||, ;, | and newlines, so cd web && pnpm add foo is caught. It also follows foo@npm:bar aliases to bar.registry.npmjs.org (for publish times and scripts) and last week's downloads from api.npmjs.org, in parallel.git clone https://github.com/PedroLaRosa/claude-code ~/claude-code
claude --plugin-dir ~/claude-code/mods/dep-bouncer
To load it in every session, add the folder to CLAUDE_CODE_PLUGIN_DIRS in the env block of ~/.claude/settings.json. The thresholds and the popular-names list are constants at the top of hooks/register.ts. Check changes with claude plugin validate . and claude plugin test .
^1.2 is checked against the latest version, not the highest version matching the range.npx, git/URL/file installs, and the packages a dependency pulls in.@types/node. It's one request per install.hooks/register.ts 157 lines1import type { EngineInterface, Register } from 'claude-code'
2
3const MIN_AGE_MS = 72 * 3600_000
4const MIN_WEEKLY_DOWNLOADS = 300
5// A one-edit neighbour of a popular name is only suspicious while it is itself unpopular (preact vs react).
6const TYPOSQUAT_EXEMPT_DOWNLOADS = 50_000
7const INSTALL_SCRIPTS = ['preinstall', 'install', 'postinstall']
8
9// ponytail: hand-picked top of npm; swap for a fetched top-N list if squats slip past it
10const POPULAR = `react react-dom vue angular svelte next nuxt express koa fastify hono lodash underscore
11axios node-fetch got request superagent chalk commander yargs inquirer debug dotenv cross-env uuid nanoid
12moment dayjs date-fns luxon typescript ts-node tsx esbuild vite webpack rollup parcel babel-core
13eslint prettier jest mocha vitest chai sinon cypress playwright puppeteer jquery bootstrap tailwindcss
14postcss autoprefixer sass less styled-components emotion classnames clsx redux zustand mobx rxjs
15graphql apollo-server prisma sequelize mongoose mongodb mysql mysql2 pg redis ioredis socket.io ws
16cors body-parser cookie-parser helmet morgan jsonwebtoken bcrypt bcryptjs passport multer formidable
17zod yup joi ajv glob minimatch rimraf mkdirp fs-extra chokidar semver colors ora execa shelljs
18nodemon pm2 concurrently husky lint-staged sharp jimp cheerio jsdom marked highlight.js three d3
19chart.js electron react-router react-router-dom react-query swr formik react-hook-form immer
20openai stripe firebase aws-sdk twilio nodemailer winston pino bunyan async bluebird q
21better-auth intercom-client lodash-es tslib core-js regenerator-runtime typeorm knex drizzle-orm`
22 .split(/\s+/)
23
24// Words that may sit before the package manager in a command segment.
25const PREFIX = /^(?:[A-Za-z_][A-Za-z0-9_]*=\S*|sudo|rtk|command|exec|time)$/
26const VERBS: Record<string, string[]> = {
27 npm: ['install', 'i', 'in', 'add'],
28 pnpm: ['add', 'install', 'i'],
29 yarn: ['add'],
30 bun: ['add', 'install', 'i', 'a'],
31}
32// Flags whose next word is a value, not a package.
33const VALUE_FLAGS = new Set(['--filter', '-F', '--workspace', '--registry', '--cwd', '-C', '--prefix', '--dir'])
34
35type Spec = { name: string; want: string }
36type Packument = {
37 'dist-tags'?: Record<string, string>
38 time?: Record<string, string>
39 versions?: Record<string, { scripts?: Record<string, string> }>
40}
41
42/** Registry packages a Bash command would install via npm/pnpm/yarn/bun; [] when it installs none. */
43export function installSpecs(command: string): Spec[] {
44 const specs: Spec[] = []
45 for (const segment of command.split(/&&|\|\||[;|\n]/)) {
46 const words = segment.trim().split(/\s+/).map(w => w.replace(/^['"]|['"]$/g, ''))
47 while (words.length && PREFIX.test(words[0]!)) words.shift()
48 const verbs = VERBS[words.shift() ?? '']
49 if (words[0] === 'global') words.shift() // yarn global add
50 if (!verbs || !verbs.includes(words.shift() ?? '')) continue
51 for (let i = 0; i < words.length; i++) {
52 const w = words[i]!
53 if (VALUE_FLAGS.has(w)) i++
54 else if (!w.startsWith('-')) {
55 const spec = parseSpec(w)
56 if (spec) specs.push(spec)
57 }
58 }
59 }
60 return specs
61}
62
63function parseSpec(word: string): Spec | undefined {
64 const alias = word.indexOf('@npm:') // lodash@npm:whatever installs whatever
65 if (alias > 0) word = word.slice(alias + 5)
66 // ponytail: paths, URLs, git, workspace: and file: specs are not registry installs; not vetted
67 if (!word || /^[.~/]|:/.test(word)) return
68 const at = word.lastIndexOf('@')
69 return at > 0 ? { name: word.slice(0, at), want: word.slice(at + 1) || 'latest' } : { name: word, want: 'latest' }
70}
71
72/** True when a and b differ by one insertion, deletion, substitution or adjacent swap. */
73export function oneEditApart(a: string, b: string): boolean {
74 if (a === b || Math.abs(a.length - b.length) > 1) return false
75 let i = 0
76 while (a[i] === b[i]) i++
77 if (a.length === b.length)
78 return a.slice(i + 1) === b.slice(i + 1) || (a[i] === b[i + 1] && a[i + 1] === b[i] && a.slice(i + 2) === b.slice(i + 2))
79 return a.length > b.length ? a.slice(i + 1) === b.slice(i) : a.slice(i) === b.slice(i + 1)
80}
81
82const hasInstallScript = (doc: Packument, v: string | undefined) =>
83 !!v && INSTALL_SCRIPTS.some(s => doc.versions?.[v]?.scripts?.[s])
84
85const ago = (ms: number) => (ms < 3600_000 ? `${Math.round(ms / 60_000)}m` : `${Math.round(ms / 3600_000)}h`)
86
87/** Why `spec` should not be installed; [] when it may. */
88export async function vet($: EngineInterface, { name, want }: Spec): Promise<string[]> {
89 const path = name.replace('/', '%2f')
90 // ponytail: full packument (the abbreviated one has no publish times); multi-MB for huge packages
91 const [res, dl] = await Promise.all([
92 $.http.fetch(`https://registry.npmjs.org/${path}`),
93 $.http.fetch(`https://api.npmjs.org/downloads/point/last-week/${name}`),
94 ])
95 if (res.status === 404)
96 // A scoped 404 is most likely a private registry package; an unscoped one was probably hallucinated.
97 return name.startsWith('@') ? [] : [`${name} does not exist on the npm registry.`]
98 if (!res.ok) throw new Error(`registry answered ${res.status} for ${name}`)
99
100 const doc = JSON.parse(res.text) as Packument
101 const tags = doc['dist-tags'] ?? {}
102 // ponytail: semver ranges resolve to `latest`, not max-satisfying; close enough for a gate
103 const version = tags[want] ?? (doc.versions?.[want] ? want : tags.latest)
104 if (!version) return [`${name} has no version matching "${want}".`]
105
106 const reasons: string[] = []
107 const now = await $.clock.now()
108 const times = doc.time ?? {}
109 const age = now - Date.parse(times[version] ?? '')
110 if (age < MIN_AGE_MS) {
111 const older = Object.keys(doc.versions ?? {})
112 .filter(v => !v.includes('-') && now - Date.parse(times[v] ?? '') >= MIN_AGE_MS)
113 .sort((a, b) => Date.parse(times[b]!) - Date.parse(times[a]!))[0]
114 reasons.push(
115 `${name}@${version} was published ${ago(age)} ago (under 72h).` +
116 (older ? ` Pin an older release instead, e.g. ${name}@${older}.` : ''),
117 )
118 }
119
120 const downloads = dl.ok ? ((JSON.parse(dl.text) as { downloads?: number }).downloads ?? 0) : 0
121 if (downloads < MIN_WEEKLY_DOWNLOADS)
122 reasons.push(`${name} has only ${downloads} weekly downloads (under ${MIN_WEEKLY_DOWNLOADS}).`)
123
124 const bare = name.replace(/^@[^/]+\//, '')
125 const twin = POPULAR.find(p => p !== name && oneEditApart(bare, p))
126 if (twin && !POPULAR.includes(name) && downloads < TYPOSQUAT_EXEMPT_DOWNLOADS)
127 reasons.push(`${name} is one edit away from the popular package "${twin}" (possible typosquat).`)
128
129 const previous = Object.keys(doc.versions ?? {})
130 .filter(v => v !== version && Date.parse(times[v] ?? '') < Date.parse(times[version] ?? ''))
131 .sort((a, b) => Date.parse(times[b]!) - Date.parse(times[a]!))[0]
132 if (hasInstallScript(doc, version) && !hasInstallScript(doc, previous))
133 reasons.push(
134 `${name}@${version} adds an install script (${INSTALL_SCRIPTS.filter(s => doc.versions?.[version]?.scripts?.[s]).join(', ')}) ` +
135 (previous ? `that ${previous} did not have.` : 'in its first release.'),
136 )
137 return reasons
138}
139
140export const register: Register = on => {
141 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
142 const specs = installSpecs(e.command)
143 if (!specs.length) return next(e)
144 const verdicts = await Promise.all(specs.map(s => vet($, s)))
145 const reasons = verdicts.flat()
146 if (!reasons.length) return next(e)
147 $.ui.toast(`dep-bouncer blocked: ${specs.map(s => s.name).join(', ')}`)
148 return {
149 deny:
150 `dep-bouncer blocked this install:\n${reasons.map(r => `- ${r}`).join('\n')}\n` +
151 `Double-check the package is the one you meant. If it is, ask the user to run the install themselves.`,
152 }
153 }).catch(($, e, next) =>
154 next.called ? next(e) : { deny: `dep-bouncer could not vet this install (registry unreachable?). Ask the user to run it themselves.` },
155 )
156}
157