Hold npm, pnpm, yarn, pip, uv, and cargo installs of packages that don't exist, are brand new, or are barely downloaded, with the registry facts in the…

Hold npm, pnpm, yarn, bun, pip, uv, and cargo installs of packages that don't exist, were published in the last 30 days, or have fewer than about 100 downloads a week. The question shows the registry facts; the default answer is refuse.
The threat is specific to coding agents: a model infers a package name that sounds right, and typosquatters register exactly those names. A package already in your lockfile is never questioned, so day-to-day installs don't change.
/plugin marketplace add MDmubarak786/claude-mods
/plugin install pkg-guard@modhub
Try it for one session without installing:
claude --plugin-dir ./mods/pkg-guard
When Claude runs an install for a package that isn't in the project's lockfile, the mod looks it up:
| Registry | Looked up | Facts shown |
|---|---|---|
npm (npm i, pnpm add, yarn add, bun add) | registry.npmjs.org, api.npmjs.org | first publish date, weekly downloads, repository |
PyPI (pip install, uv add, uv pip install) | pypi.org, pypistats.org | first upload date, weekly downloads, source URL |
crates.io (cargo add) | crates.io | creation date, recent downloads |
A package installed from a URL, a git repository, a tarball, or a custom index can't be checked against a registry, so it is held too; a local path is not. An npm alias (name@npm:target) is checked by its target. Installs behind sudo, environment assignments, python -m pip, a pipe, or a $(...) substitution are all seen.
An established package installs silently; /pkg-guard lists what was checked. A suspect one opens Claude Code's question dialog:
pkg-guard: npm package "left-padd" does not exist on the registry. Install anyway?
1. Refuse
2. Install
On refuse, Claude reads the facts and is told to check the exact name on the registry, prefer a well-known package, or ask you. Lookups are cached for a day.
| Command | What it does |
|---|---|
/pkg-guard | Show whether it's on and what was checked this session. |
/pkg-guard off | Let every install through. Remembered across sessions. |
/pkg-guard on | Turn it back on. |
From claude plugin validate ./mods/pkg-guard:
hooks: session.start, command.run{command=pkg-guard}, tool.call{tool=Bash}
calls: $.command.register, $.fs.exists (via inLockfile), $.fs.read (via inLockfile), $.http.fetch (via lookup), $.store.get, $.store.set, $.ui.ask, $.ui.log
$.http.fetch sends only the package name to the public registries listed above, over HTTPS, and only for a package that isn't in your lockfile. Nothing else leaves the machine.$.fs.read reads lockfiles in the working directory to skip known packages. A lockfile vouches for a package only when it lists that exact name in the file's own syntax, never when the name appears as a substring of another package or in a comment.$.store keeps a day's cache of registry answers and the on/off flag.Failure policy. This is a guard, so if the registry can't be reached or the hook fails, an install is refused with a reason rather than let through.
claude plugin validate --strict and claude plugin test pass against a fake registry. /pkg-guard answered from a live claude -p session. Live registry lookups haven't been exercised by the author in a session yet.claude -p, nobody can answer the question, so every suspect install is refused. That's the safe default, and the reason is in Claude's result.package.json and running a bare npm install isn't checked, because the bare install has no name to check.MIT, see the repository root.
hooks/register.ts 291 lines1// pkg-guard: hold installs of packages Claude guessed.
2//
3// /pkg-guard show whether it's on and what it checked this session
4// /pkg-guard off let every install through
5// /pkg-guard on turn it back on
6//
7// When Claude runs npm/pnpm/yarn/bun/pip/uv/cargo install for a package that isn't
8// already in the project's lockfile, the mod looks the package up on its
9// registry. A package that doesn't exist, is under 30 days old, or has very few
10// downloads is held with the facts in the question, and refused by default.
11// A package installed from a URL, a git repository, or a tarball can't be
12// checked against a registry, so it's held too.
13
14const MIN_AGE_DAYS = 30
15const MIN_WEEKLY_DOWNLOADS = 100
16const CACHE_MS = 24 * 60 * 60 * 1000
17
18type Eco = 'npm' | 'pypi' | 'crates'
19type Facts = { exists: boolean; ageDays: number | null; weekly: number | null; repo: string | null }
20type Check = { eco: Eco; name: string; facts: Facts; reason: string | null }
21type Install = { eco: Eco; names: string[]; unverifiable: string[] }
22
23let enabled = true
24const checked: string[] = []
25
26const LOCKFILES: Record<Eco, string[]> = {
27 npm: ['package-lock.json', 'pnpm-lock.yaml', 'yarn.lock', 'bun.lock'],
28 pypi: ['uv.lock', 'poetry.lock', 'requirements.txt', 'Pipfile.lock'],
29 crates: ['Cargo.lock'],
30}
31
32// Separators between simple commands: chains, pipes, background, newlines, and
33// command substitution, so an install hidden in `$(...)` or after `|` is seen.
34const SEPARATORS = /&&|\|\||;|\||&|\n|\$\(|`/
35// Prefixes that don't change what runs: sudo, env assignments, and wrappers.
36const PREFIX = /^(?:sudo|doas|env|command|exec|nohup|time|nice|xvfb-run)$|^[A-Za-z_][A-Za-z0-9_]*=\S*$/
37const REMOTE = /^(?:https?:|git\+|git@|github:|gitlab:|bitbucket:|ssh:|git:)/
38const LOCAL = /^(?:\.|\/|~|file:)/
39
40function tokens(command: string): string[] {
41 const out: string[] = []
42 for (const m of command.trim().matchAll(/'([^']*)'|"([^"]*)"|(\S+)/g)) out.push((m[1] ?? m[2] ?? m[3]).replace(/[)`]+$/, ''))
43 return out.filter(Boolean)
44}
45
46// Returns the ecosystem and package names one simple command installs, or null.
47function parseInstall(part: string): Install | null {
48 let t = tokens(part)
49 while (t.length && PREFIX.test(t[0])) t = t.slice(1)
50 if (t.length < 2) return null
51 // python -m pip install ... and py -m pip install ...
52 if (/^python[23]?(?:\.\d+)?$|^py$/.test(t[0]) && t[1] === '-m' && t[2] === 'pip') t = t.slice(2)
53 const [cmd, sub, ...rest] = t
54 let eco: Eco | null = null
55 let args = rest
56 if ((cmd === 'npm' && ['install', 'i', 'add', 'in', 'ins', 'inst', 'isntall'].includes(sub)) || (cmd === 'pnpm' && ['add', 'install', 'i'].includes(sub)) || (cmd === 'yarn' && sub === 'add') || (cmd === 'bun' && ['add', 'install', 'i'].includes(sub)) || (cmd === 'npx' && sub === 'npm' && rest[0] === 'install')) {
57 eco = 'npm'
58 if (cmd === 'npx') args = rest.slice(1)
59 } else if ((cmd === 'pip' || cmd === 'pip3' || /^pip3?\.\d+$/.test(cmd)) && sub === 'install') eco = 'pypi'
60 else if (cmd === 'uv' && sub === 'add') eco = 'pypi'
61 else if (cmd === 'uv' && sub === 'pip' && rest[0] === 'install') { eco = 'pypi'; args = rest.slice(1) }
62 else if (cmd === 'pipx' && sub === 'install') eco = 'pypi'
63 else if (cmd === 'poetry' && sub === 'add') eco = 'pypi'
64 else if (cmd === 'cargo' && (sub === 'add' || sub === 'install')) eco = 'crates'
65 if (!eco) return null
66
67 const names: string[] = []
68 const unverifiable: string[] = []
69 for (let i = 0; i < args.length; i++) {
70 const a = args[i]
71 if (a === '--') continue
72 if (['-r', '--requirement', '-e', '--editable', '-c', '--constraint', '--index-url', '-i', '--extra-index-url', '--find-links', '-f', '--registry', '--git', '--path', '--index'].includes(a)) {
73 // A flag with a value: a git/URL source for cargo or pip is unverifiable.
74 const v = args[i + 1] ?? ''
75 if (['--git', '--index-url', '-i', '--extra-index-url', '--find-links', '-f', '--registry', '--index'].includes(a) && v) unverifiable.push(a + ' ' + v)
76 i++
77 continue
78 }
79 if (a.startsWith('-')) continue
80 if (LOCAL.test(a)) continue
81 if (REMOTE.test(a) || /\.(?:tgz|tar\.gz|zip|whl)$/.test(a) || a.includes('://')) {
82 unverifiable.push(a)
83 continue
84 }
85 const bare = bareName(eco, a)
86 if (bare === null) unverifiable.push(a)
87 else names.push(bare)
88 }
89 return names.length || unverifiable.length ? { eco, names, unverifiable } : null
90}
91
92// The registry name behind a spec, or null when the spec can't be reduced to one.
93function bareName(eco: Eco, spec: string): string | null {
94 if (eco === 'npm') {
95 // alias@npm:target@range or npm:target@range
96 let s = spec
97 const alias = /^[^@]+@npm:(.+)$/.exec(s) ?? /^npm:(.+)$/.exec(s)
98 if (alias) s = alias[1]
99 if (/@(?:git|https?|file|github|npm):/.test(s) || s.includes('://')) return null
100 const at = s.lastIndexOf('@')
101 const name = at > 0 ? s.slice(0, at) : s
102 return /^(?:@[a-z0-9][\w.-]*\/)?[a-z0-9][\w.-]*$/i.test(name) ? name : null
103 }
104 if (eco === 'pypi') {
105 if (spec.includes('@') || spec.includes('://')) return null
106 const name = spec.split(/[\[<>=!~; ]/)[0]
107 return /^[A-Za-z0-9][\w.-]*$/.test(name) ? name.toLowerCase().replace(/[_.]/g, '-') : null
108 }
109 const name = spec.split('@')[0]
110 return /^[A-Za-z0-9][\w-]*$/.test(name) ? name : null
111}
112
113function escapeRe(s: string): string {
114 return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
115}
116
117// Whether a lockfile lists exactly this package, as a whole token in the format's
118// own syntax: "requests-oauthlib" in a lockfile never vouches for "requests".
119function lockfileHas(eco: Eco, file: string, text: string, name: string): boolean {
120 const n = escapeRe(name)
121 if (eco === 'npm') {
122 if (file === 'package-lock.json') return new RegExp('"node_modules/' + n + '"\\s*:').test(text)
123 if (file === 'pnpm-lock.yaml') return new RegExp('^\\s*[\'"]?/?' + n + '@[^\\n]*:\\s*$', 'm').test(text)
124 if (file === 'yarn.lock') return new RegExp('^"?' + n + '@', 'm').test(text)
125 if (file === 'bun.lock') return new RegExp('^\\s*"' + n + '"\\s*:', 'm').test(text)
126 return false
127 }
128 if (eco === 'pypi') {
129 // PyPI names compare with -, _, and . as the same character.
130 const loose = n.replace(/\\\.|[-_]/g, '[-_.]')
131 if (file === 'requirements.txt') return new RegExp('^\\s*' + loose + '\\s*(?:[=<>!~\\[;@ ]|$)', 'mi').test(text)
132 if (file === 'Pipfile.lock') return new RegExp('^\\s*"' + loose + '"\\s*:', 'mi').test(text)
133 return new RegExp('^name = "' + loose + '"\\s*$', 'mi').test(text)
134 }
135 return new RegExp('^name = "' + n + '"\\s*$', 'm').test(text)
136}
137
138async function inLockfile($, eco: Eco, name: string): Promise<boolean> {
139 for (const file of LOCKFILES[eco]) {
140 try {
141 if (!(await $.fs.exists(file))) continue
142 if (lockfileHas(eco, file, await $.fs.read(file), name)) return true
143 } catch {
144 // A lockfile too large to read counts as unknown: the registry decides.
145 }
146 }
147 return false
148}
149
150function daysSince(iso: string | undefined): number | null {
151 if (!iso) return null
152 const t = Date.parse(iso)
153 return Number.isFinite(t) ? Math.floor((Date.now() - t) / 86400000) : null
154}
155
156async function lookup($, eco: Eco, name: string): Promise<Facts> {
157 const none: Facts = { exists: false, ageDays: null, weekly: null, repo: null }
158 if (eco === 'npm') {
159 const r = await $.http.fetch('https://registry.npmjs.org/' + encodeURIComponent(name).replace('%40', '@'))
160 if (r.status === 404) return none
161 if (!r.ok) throw new Error('npm registry answered ' + r.status)
162 const meta = JSON.parse(r.text)
163 let weekly: number | null = null
164 try {
165 const d = await $.http.fetch('https://api.npmjs.org/downloads/point/last-week/' + name)
166 if (d.ok) weekly = Number(JSON.parse(d.text).downloads) || 0
167 } catch {
168 weekly = null
169 }
170 return { exists: true, ageDays: daysSince(meta.time && meta.time.created), weekly, repo: meta.repository && meta.repository.url ? String(meta.repository.url) : null }
171 }
172 if (eco === 'pypi') {
173 const r = await $.http.fetch('https://pypi.org/pypi/' + encodeURIComponent(name) + '/json')
174 if (r.status === 404) return none
175 if (!r.ok) throw new Error('PyPI answered ' + r.status)
176 const meta = JSON.parse(r.text)
177 let first: string | undefined
178 for (const files of Object.values(meta.releases ?? {}) as any[]) for (const f of files) if (f.upload_time_iso_8601 && (!first || f.upload_time_iso_8601 < first)) first = f.upload_time_iso_8601
179 let weekly: number | null = null
180 try {
181 const d = await $.http.fetch('https://pypistats.org/api/packages/' + encodeURIComponent(name) + '/recent')
182 if (d.ok) weekly = Number(JSON.parse(d.text).data.last_week) || 0
183 } catch {
184 weekly = null
185 }
186 const urls = (meta.info && meta.info.project_urls) || {}
187 return { exists: true, ageDays: daysSince(first), weekly, repo: urls.Source || urls.Repository || urls.Homepage || null }
188 }
189 const r = await $.http.fetch('https://crates.io/api/v1/crates/' + encodeURIComponent(name), { headers: { 'User-Agent': 'pkg-guard (claude code mod)' } })
190 if (r.status === 404) return none
191 if (!r.ok) throw new Error('crates.io answered ' + r.status)
192 const c = JSON.parse(r.text).crate
193 return { exists: true, ageDays: daysSince(c.created_at), weekly: Number(c.recent_downloads) / 13 || 0, repo: c.repository || null }
194}
195
196function judge(f: Facts): string | null {
197 if (!f.exists) return 'does not exist on the registry'
198 if (f.ageDays !== null && f.ageDays < MIN_AGE_DAYS) return 'was first published ' + f.ageDays + ' day(s) ago'
199 if (f.weekly !== null && f.weekly < MIN_WEEKLY_DOWNLOADS) return 'has about ' + Math.round(f.weekly) + ' downloads a week'
200 return null
201}
202
203function describe(c: Check): string {
204 const f = c.facts
205 return c.eco + ' package "' + c.name + '" ' + (c.reason ?? 'looks established') +
206 (f.exists ? ' (age ' + (f.ageDays ?? '?') + ' days, ~' + (f.weekly === null ? '?' : Math.round(f.weekly)) + ' downloads/week' + (f.repo ? ', ' + f.repo : '') + ')' : '')
207}
208
209async function check($, eco: Eco, name: string): Promise<Check> {
210 const key = 'pkg:' + eco + ':' + name
211 try {
212 const cached = await $.store.get(key)
213 if (cached && typeof cached === 'object' && Date.now() - Number(cached.at) < CACHE_MS) return { eco, name, facts: cached.facts, reason: judge(cached.facts) }
214 } catch {
215 // No cache: look it up.
216 }
217 const facts = await lookup($, eco, name)
218 try {
219 await $.store.set(key, { at: Date.now(), facts })
220 } catch {
221 // Cache write failure is harmless.
222 }
223 return { eco, name, facts, reason: judge(facts) }
224}
225
226function installsIn(command: string): Install[] {
227 return command.split(SEPARATORS).map(parseInstall).filter((x): x is Install => x !== null)
228}
229
230async function guard($, e, next) {
231 if (!enabled) return next(e)
232 const reasons: string[] = []
233 for (const install of installsIn(String(e.command ?? ''))) {
234 for (const spec of install.unverifiable) reasons.push(install.eco + ' install of "' + spec + '" comes from a URL, git, or an index that can\'t be checked against the registry')
235 for (const name of install.names) {
236 if (await inLockfile($, install.eco, name)) continue
237 const c = await check($, install.eco, name)
238 checked.push(describe(c))
239 if (c.reason) reasons.push(describe(c))
240 }
241 }
242 if (!reasons.length) return next(e)
243
244 let answer = 'Refuse'
245 try {
246 answer = await $.ui.ask('pkg-guard: ' + reasons.join('; ') + '. Install anyway?', ['Refuse', 'Install'])
247 } catch {
248 // Nobody to ask: refuse.
249 }
250 if (answer !== 'Install') {
251 return {
252 deny:
253 'pkg-guard: this install was refused. ' + reasons.join('. ') + '. ' +
254 'Check the exact package name on the registry before trying again, prefer a well-known package, or ask the user.',
255 }
256 }
257 return next(e)
258}
259
260export function register(on) {
261 on('session.start', async ($, e, next) => {
262 try {
263 enabled = (await $.store.get('enabled')) !== false
264 } catch {
265 enabled = true
266 }
267 try {
268 await $.command.register({ name: 'pkg-guard', description: 'Hold installs of unknown or brand-new packages', argumentHint: '[on | off]', immediate: true })
269 } catch (error) {
270 $.ui.log('could not register /pkg-guard: ' + error)
271 }
272 return next(e)
273 })
274
275 on('command.run', { command: 'pkg-guard' }, async ($, e) => {
276 const args = e.args.trim()
277 if (args === 'on' || args === 'off') {
278 enabled = args === 'on'
279 await $.store.set('enabled', enabled)
280 return { text: enabled ? 'pkg-guard on.' : 'pkg-guard off. Every install goes through.' }
281 }
282 return { text: (enabled ? 'pkg-guard on' : 'pkg-guard off') + '. Checked this session:\n' + (checked.length ? checked.map((c) => ' ' + c).join('\n') : ' nothing yet') }
283 }).catch(async () => ({ text: 'pkg-guard: the command failed, so nothing changed.' }))
284
285 on('tool.call', { tool: 'Bash' }, guard).catch(async ($, e, next) => {
286 if (next.called) return next(e)
287 if (!installsIn(String(e.command ?? '')).length) return next(e)
288 return { deny: 'pkg-guard: could not check this install (' + next.error.kind + '), so it was not run. Try again, or ask the user to run /pkg-guard off.' }
289 })
290}
291