SLOPSHOPPER

dep-bouncer

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

newguardtoastnetwork
★ 1v0.1.0MITupdated 2026-10-07PedroLaRosa/claude-code/mods/dep-bouncer
A shopper browsing a rack in a slop shop
README

dep-bouncer

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.

Why

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.

What gets blocked

An install is denied, with every reason that applies, when:

  • Too new: the version was published less than 72 hours ago. The reason suggests the newest release older than 72h to pin instead.
  • Too few downloads: the package has fewer than 300 weekly downloads.
  • Possible typosquat: the name is one typo away from a popular package (a letter added, dropped, changed or two swapped), and it has fewer than 50k weekly downloads itself. That second condition is so preact isn't flagged as a copy of react.
  • New install script: the version adds a preinstall, install or postinstall script that the previous version didn't have.
  • Doesn't exist: an unscoped package that isn't on npm, which usually means the name was hallucinated.

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.

Requirements

  • Claude Code with mods (built on 2.1.291): the claude CLI, for claude plugin install
  • Network access to registry.npmjs.org and api.npmjs.org

Install

claude plugin marketplace add PedroLaRosa/claude-code
claude plugin install dep-bouncer@pedro-la-rosa-claude-code

Then run /reload-plugins.

How it works

  • The hook: a 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.
  • The lookup: for each package, it fetches the full package metadata from registry.npmjs.org (for publish times and scripts) and last week's downloads from api.npmjs.org, in parallel.
  • The answer: if nothing is wrong, the command runs as normal. Otherwise the hook returns a deny with the reasons, and a toast shows which packages were blocked.

Hack on it

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 .

Limits

  • Private packages: a scoped package that isn't on the public registry (e.g. one on a private registry) is let through without checks.
  • Version ranges: a range like ^1.2 is checked against the latest version, not the highest version matching the range.
  • Not checked: npx, git/URL/file installs, and the packages a dependency pulls in.
  • Popular-names list: about 150 hand-picked packages, so typosquats of names outside it get past the typo check.
  • Large packages: the full metadata is about 11MB for @types/node. It's one request per install.
Source 1 files
hooks/register.ts 157 lines
1import 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