SLOPSHOPPER

jr-pkg-guard

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…

newguardtoastnetwork
★ 2v0.1.0MITupdated 2026-10-15jrlprost/claude-code-mods/plugins/jr-pkg-guard
A shopper browsing a rack in a slop shop
README

jr-pkg-guard

Every package install Claude runs is checked against its registry before it runs.

  • The name does not exist: refused. Models sometimes invent package names, and attackers register the names they invent. The refusal tells Claude to check the exact name.
  • The package is very new (first published less than 30 days ago): held back, with its age, so you look at the author and repository first. Set it to warn instead if you prefer.
  • It runs install scripts (npm preinstall, install, postinstall): a toast names them.
  • The registry cannot be reached: the install goes ahead, and a toast says it was not checked.
EcosystemCommands readRegistry
npmnpm install/i/add, pnpm add, yarn add, bun addregistry.npmjs.org, api.npmjs.org (downloads)
PyPIpip install, pip3, pipx install, python -m pip install, uv add, uv pip install, poetry addpypi.org
crates.iocargo add, cargo installcrates.io
RubyGemsgem installrubygems.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.

Settings (/config)

OptionDefault
Minimum age (days)30Packages first published more recently are held back or flagged
New packagesblockblock refuses the install, warn lets it run with a toast

Install

/plugin install jr-pkg-guard --marketplace jrlprost/claude-code-mods

Access

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.

Source 2 files
hooks/register.ts 52 lines
1import 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}
52
hooks/parse.ts 150 lines
1// 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