Checks every package install against its registry first: names that do not exist (hallucinated or typosquatted) are refused, very new packages are held back or…

Every package install Claude runs is checked against its registry before it runs.
preinstall, install, postinstall): a toast names them.| Ecosystem | Commands read | Registry |
|---|---|---|
| npm | npm install/i/add, pnpm add, yarn add, bun add | registry.npmjs.org, api.npmjs.org (downloads) |
| PyPI | pip install, pip3, pipx install, python -m pip install, uv add, uv pip install, poetry add | pypi.org |
| crates.io | cargo add, cargo install | crates.io |
| RubyGems | gem install | rubygems.org |
Flags, local paths, URLs, git sources, tarballs and -r requirements.txt files are skipped. A plain npm install (no package named) is not checked.
npm documents of popular packages weigh several megabytes, so npm is read in steps: the small latest manifest, then the monthly downloads, and the full document only for quiet packages (under 5,000 downloads a month), to read when they were first published.
/config)| Option | Default | |
|---|---|---|
| Minimum age (days) | 30 | Packages first published more recently are held back or flagged |
| New packages | block | block refuses the install, warn lets it run with a toast |
/plugin install jr-pkg-guard --marketplace jrlprost/claude-code-mods
Reads the command of each Bash call. On an install, sends the package names to their public registries (the hosts above) to look them up. Writes nothing, runs nothing.
hooks/register.ts 52 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import type { Finding, Wanted } from './parse'
4import { readNpmDownloads, readNpmLatest, readRegistry, registryUrl, verdict, wantedPackages, WELL_USED } from './parse'
5
6const HEADERS = { 'User-Agent': 'jr-pkg-guard (github.com/jrlprost/claude-code-mods)', Accept: 'application/json' }
7
8/**
9 * npm documents of popular packages weigh megabytes, so npm is read in steps: the small
10 * `latest` manifest (exists? install scripts?), the monthly downloads, and the full document
11 * only for quiet packages, which are small, to learn when they were first published.
12 */
13async function lookUpNpm($: EngineInterface, wanted: Wanted): Promise<Finding> {
14 const base = registryUrl(wanted)
15 const latest = readNpmLatest(...(await statusAndText($, `${base}/latest`)))
16 if (!latest || !latest.exists) return { wanted, lookup: latest }
17 const [, downloadsText] = await statusAndText($, `https://api.npmjs.org/downloads/point/last-month/${wanted.name}`)
18 if (readNpmDownloads(downloadsText) >= WELL_USED) return { wanted, lookup: latest }
19 const full = readRegistry(wanted, ...(await statusAndText($, base)))
20 return { wanted, lookup: full ? { ...full, installScripts: latest.installScripts } : latest }
21}
22
23async function statusAndText($: EngineInterface, url: string): Promise<[number, string]> {
24 const res = await $.http.fetch(url, { headers: HEADERS })
25 return [res.status, res.text]
26}
27
28async function lookUp($: EngineInterface, wanted: Wanted): Promise<Finding> {
29 try {
30 if (wanted.ecosystem === 'npm') return await lookUpNpm($, wanted)
31 return { wanted, lookup: readRegistry(wanted, ...(await statusAndText($, registryUrl(wanted)))) }
32 } catch {
33 return { wanted, lookup: null }
34 }
35}
36
37export const register: Register = (on, options) => {
38 const minDays = Number(options.minAgeDays ?? 30)
39 const blockNew = (options.newPackages ?? 'block') === 'block'
40
41 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
42 const wanted = wantedPackages(e.command)
43 if (wanted.length === 0) return next(e)
44 const findings: Finding[] = []
45 for (const w of wanted.slice(0, 20)) findings.push(await lookUp($, w))
46 const result = verdict(findings, await $.clock.now(), minDays, blockNew)
47 if (result.deny) return { deny: result.deny }
48 if (result.warnings.length) $.ui.toast(`jr pkg guard · ${result.warnings.join(' · ')}`)
49 return next(e)
50 }).catch(($, e, next) => (next.called ? next(e) : { deny: 'jr-pkg-guard: the package check failed, so the install was held back.' }))
51}
52hooks/parse.ts 150 lines1// Which packages a shell command installs, per ecosystem.
2
3export type Ecosystem = 'npm' | 'pypi' | 'crates' | 'rubygems'
4export type Wanted = { ecosystem: Ecosystem; name: string }
5
6const segments = (command: string) => command.split(/&&|\|\||;|\||\n/).map(s => s.trim()).filter(Boolean)
7
8const words = (segment: string) =>
9 (segment.match(/"[^"]*"|'[^']*'|\S+/g) ?? []).map(w => w.replace(/^["']|["']$/g, ''))
10
11const isLocal = (w: string) =>
12 /^(\.{1,2}\/|\/|~\/|file:|git\+|git:|https?:|github:|workspace:|link:|portal:)/.test(w) || /\.(tgz|tar\.gz|whl|zip|gem)$/.test(w)
13
14/** `react@18`, `@scope/pkg@^1` → the bare name. */
15const npmName = (w: string) => (w.startsWith('@') ? `@${w.slice(1).split('@')[0]}` : w.split('@')[0]) ?? w
16
17/** `requests==2.31`, `uvicorn[standard]>=0.2` → the bare name. */
18const pypiName = (w: string) => w.split(/[\[<>=!~;@ ]/)[0] ?? w
19
20const after = (ws: string[], i: number) => ws.slice(i).filter(w => !w.startsWith('-'))
21
22export const wantedPackages = (command: string): Wanted[] => {
23 const out: Wanted[] = []
24 for (const segment of segments(command)) {
25 const all = words(segment)
26 let lead = 0
27 while (lead < all.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(all[lead] ?? '')) lead++ // leading ENV=value
28 const ws = all.slice(lead)
29 const [tool, verb, third] = ws
30 const add = (ecosystem: Ecosystem, names: string[]) => {
31 for (const n of names) if (n && !isLocal(n)) out.push({ ecosystem, name: n })
32 }
33 if (tool === 'npm' && (verb === 'install' || verb === 'i' || verb === 'add')) add('npm', after(ws, 2).map(npmName))
34 else if ((tool === 'pnpm' || tool === 'yarn' || tool === 'bun') && (verb === 'add' || verb === 'install' || verb === 'i'))
35 add('npm', after(ws, 2).map(npmName))
36 else if ((tool === 'pip' || tool === 'pip3' || tool === 'pipx') && verb === 'install') {
37 const skip = new Set<number>()
38 ws.forEach((w, i) => {
39 if (w === '-r' || w === '--requirement' || w === '-e' || w === '--editable' || w === '-c') skip.add(i + 1)
40 })
41 add('pypi', ws.slice(2).filter((w, i) => !w.startsWith('-') && !skip.has(i + 2)).map(pypiName))
42 } else if (tool === 'python' || tool === 'python3') {
43 if (verb === '-m' && third === 'pip' && ws[3] === 'install') add('pypi', after(ws, 4).map(pypiName))
44 } else if (tool === 'uv' && verb === 'add') add('pypi', after(ws, 2).map(pypiName))
45 else if (tool === 'uv' && verb === 'pip' && third === 'install') add('pypi', after(ws, 3).map(pypiName))
46 else if (tool === 'poetry' && verb === 'add') add('pypi', after(ws, 2).map(pypiName))
47 else if (tool === 'cargo' && (verb === 'add' || verb === 'install')) add('crates', after(ws, 2).map(w => w.split('@')[0] ?? w))
48 else if (tool === 'gem' && verb === 'install') add('rubygems', after(ws, 2))
49 }
50 return out
51}
52
53export type Lookup = { exists: boolean; firstPublished?: number; installScripts?: string[] }
54
55/** The registry URL that answers whether a package exists and since when. */
56export const registryUrl = (w: Wanted) => {
57 const n = encodeURIComponent(w.name).replace('%40', '@')
58 switch (w.ecosystem) {
59 case 'npm':
60 return `https://registry.npmjs.org/${w.name.startsWith('@') ? `@${encodeURIComponent(w.name.slice(1))}` : n}`
61 case 'pypi':
62 return `https://pypi.org/pypi/${n}/json`
63 case 'crates':
64 return `https://crates.io/api/v1/crates/${n}`
65 case 'rubygems':
66 return `https://rubygems.org/api/v1/versions/${n}.json`
67 }
68}
69
70/** npm's small `<name>/latest` manifest: whether the package exists, and its install scripts. */
71export const readNpmLatest = (status: number, body: string): Lookup | null => {
72 if (status === 404) return { exists: false }
73 if (status < 200 || status >= 300) return null
74 try {
75 const scripts = (JSON.parse(body) as { scripts?: Record<string, string> }).scripts ?? {}
76 return { exists: true, installScripts: ['preinstall', 'install', 'postinstall'].filter(k => scripts[k]) }
77 } catch {
78 return null
79 }
80}
81
82export const readNpmDownloads = (body: string) => {
83 try {
84 return Number((JSON.parse(body) as { downloads?: number }).downloads ?? 0)
85 } catch {
86 return 0
87 }
88}
89
90/** Packages with this many downloads a month are not new; only quieter ones get their full (small) document read. */
91export const WELL_USED = 5000
92
93/** Reads a registry answer: existence, the first publication date, npm install scripts. */
94export const readRegistry = (w: Wanted, status: number, body: string): Lookup | null => {
95 if (status === 404) return { exists: false }
96 if (status < 200 || status >= 300) return null
97 try {
98 const json = JSON.parse(body) as Record<string, unknown>
99 if (w.ecosystem === 'npm') {
100 const time = json.time as Record<string, string> | undefined
101 const latest = (json['dist-tags'] as Record<string, string> | undefined)?.latest
102 const scripts = latest ? ((json.versions as Record<string, { scripts?: Record<string, string> }>)?.[latest]?.scripts ?? {}) : {}
103 const installScripts = ['preinstall', 'install', 'postinstall'].filter(k => scripts[k])
104 return { exists: true, firstPublished: time?.created ? Date.parse(time.created) : undefined, installScripts }
105 }
106 if (w.ecosystem === 'pypi') {
107 const releases = json.releases as Record<string, { upload_time_iso_8601?: string }[]> | undefined
108 const times = Object.values(releases ?? {})
109 .flat()
110 .map(f => Date.parse(f.upload_time_iso_8601 ?? ''))
111 .filter(Number.isFinite)
112 return { exists: true, firstPublished: times.length ? Math.min(...times) : undefined }
113 }
114 if (w.ecosystem === 'crates') {
115 const created = (json.crate as { created_at?: string } | undefined)?.created_at
116 return { exists: true, firstPublished: created ? Date.parse(created) : undefined }
117 }
118 const versions = JSON.parse(body) as { created_at?: string }[]
119 const times = versions.map(v => Date.parse(v.created_at ?? '')).filter(Number.isFinite)
120 return { exists: true, firstPublished: times.length ? Math.min(...times) : undefined }
121 } catch {
122 return null
123 }
124}
125
126export type Finding = { wanted: Wanted; lookup: Lookup | null }
127
128export const ageDays = (lookup: Lookup | null, now: number) =>
129 lookup?.firstPublished ? Math.floor((now - lookup.firstPublished) / 86_400_000) : undefined
130
131export const verdict = (findings: Finding[], now: number, minDays: number, blockNew: boolean) => {
132 const label = (f: Finding) => `${f.wanted.name} (${f.wanted.ecosystem})`
133 const missing = findings.filter(f => f.lookup && !f.lookup.exists)
134 const fresh = findings.filter(f => {
135 const age = ageDays(f.lookup, now)
136 return f.lookup?.exists && age !== undefined && age < minDays
137 })
138 const scripted = findings.filter(f => f.lookup?.installScripts?.length)
139 const unchecked = findings.filter(f => !f.lookup)
140 const reasons: string[] = []
141 if (missing.length) reasons.push(`${missing.map(label).join(', ')} ${missing.length > 1 ? 'do' : 'does'} not exist on the registry: likely a hallucinated or typosquatted name. Check the exact package name.`)
142 if (blockNew && fresh.length)
143 reasons.push(`${fresh.map(f => `${label(f)}, first published ${ageDays(f.lookup, now)} days ago`).join('; ')}. Packages younger than ${minDays} days are held back; check the author and repository first.`)
144 const warnings: string[] = []
145 if (!blockNew && fresh.length) warnings.push(`new package: ${fresh.map(f => `${f.wanted.name} (${ageDays(f.lookup, now)} d)`).join(', ')}`)
146 if (scripted.length) warnings.push(`install scripts: ${scripted.map(f => `${f.wanted.name} (${f.lookup!.installScripts!.join(', ')})`).join(', ')}`)
147 if (unchecked.length) warnings.push(`not checked (registry unreachable): ${unchecked.map(f => f.wanted.name).join(', ')}`)
148 return { deny: reasons.length ? `jr-pkg-guard: ${reasons.join(' ')}` : null, warnings }
149}
150