Before Claude installs or runs a package (npm, pnpm, yarn, bun, npx, pip, uv, poetry, pipx, cargo), asks the public registry about it. Holds the command when…

Before Claude installs or runs a package (npm, pnpm, yarn, bun, npx, pip, uv, poetry, pipx, cargo), asks the public registry about it. Holds the command when the package does not exist (a made-up name), is under 14 days old, or looks like a well-known package and is young or thin (typosquatting). Allow in the band or with /pkg-allow.
Part of the Mod Box, which bundles it with other mods and one window to switch and configure them all. This is the same mod on its own.
In a terminal Claude Code session (2.1.287 or newer):
/plugin install package-guard --marketplace LupusMalusDeviant/claude-mods
| Command | What it does |
|---|---|
/pkg-allow [name] | allow a held package |
Set them in /config (or when installing).
| Option | Default | What it does |
|---|---|---|
mode | block | block: hold the command until the package is allowed. warn: only warn and let it through. |
language | en | Language of the texts this mod shows and writes. |
MIT, see LICENSE.
hooks/entry.tsx 30 lines1// Entry of the standalone Package Guard. The mod hands its display to a band (in the Mod Box the
2// band manager draws it); here this hook, registered first and so the outermost, draws it below
3// whatever else is above the prompt.
4import type { Register } from 'claude-code'
5
6import { register as inner } from '../mods/package-guard/hooks/register'
7import { openBand, takeBand } from '../mods/settings/hooks/band'
8
9export const register: Register = (on, options) => {
10 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
11 openBand(e)
12 const rest = await next(e)
13 const parts = takeBand(e)
14 if (e.props.hasSurvey || parts.length === 0) return rest
15 const { Box, Text } = $.ui.resolve(e)
16 return (
17 <Box flexDirection="column">
18 {rest}
19 {parts.map(p => (
20 <Box key={`band-${p.id}`} flexDirection="row" paddingX={1}>
21 <Box flexShrink={1}>{p.line as any}</Box>
22 {(p.actions as any[]).flatMap((a, i) => [<Text key={`gap-${i}`}>{' '}</Text>, a])}
23 </Box>
24 ))}
25 </Box>
26 )
27 })
28 ;(inner as any)(on, options)
29}
30mods/package-guard/hooks/register.tsx 159 lines1// Package Guard: before Claude installs (or runs, via npx and the like) a package from npm, PyPI or
2// crates.io, it asks the public registry about it. It holds the command when the package does not
3// exist (a made-up name), is only days old, or looks like a well-known package and is young or thin
4// (typosquatting). Everything else goes through untouched. The person allows a held package in
5// the band; Claude then installs it again.
6import { atom, read, update } from 'claude-code'
7import type { EngineInterface, Register } from 'claude-code'
8
9import type { PackageHold, PackageInfo } from '../types'
10import { offer as offerBand } from '../../settings/hooks/band'
11import type { BandPart } from '../../settings/hooks/band'
12import { type Ecosystem, wantedPackages } from './parse'
13import { lookalike } from './similar'
14
15const hold = atom({ plugin: 'package-guard', key: 'hold' } as const, null as PackageHold | null)
16const allowed = atom({ plugin: 'package-guard', key: 'allowed' } as const, [] as string[])
17
18let lang: 'de' | 'en' = 'en'
19const t = (de: string, en: string) => (lang === 'en' ? en : de)
20let mode = 'block'
21const known = new Map<string, PackageInfo>()
22
23const DAY = 86_400_000
24const NEW_DAYS = 14
25const YOUNG_DAYS = 180
26const FEW_VERSIONS = 5
27
28/** Why a package is suspicious; empty when it is fine. Pure, for the tests. */
29export function reasonsFor(eco: Ecosystem, name: string, info: PackageInfo | null, now: number, de = false): string[] {
30 const tr = (a: string, b: string) => (de ? a : b)
31 const like = lookalike(eco, name)
32 if (!info || info.error) return like ? [tr(`ähnelt „${like}“ (Registry nicht erreichbar)`, `looks like "${like}" (registry not reachable)`)] : []
33 if (info.exists === false) return [tr('gibt es in der Registry nicht (erfundener Name?)', 'does not exist in the registry (a made-up name?)')]
34 // npm replaces removed malware with "0.0.1-security" from npm/security-holder.
35 if (/-security$/.test(info.latest ?? '') || /security-holder/.test(info.repo ?? '')) {
36 return [tr('wurde als Schadpaket entfernt (npm-Sicherheitsplatzhalter)', 'was removed as malware (npm security placeholder)')]
37 }
38 const out: string[] = []
39 const days = info.created ? Math.floor((now - Date.parse(info.created)) / DAY) : NaN
40 // a date in the future means a wrong clock: no age then
41 const age = Number.isFinite(days) && days >= 0 ? days : null
42 if (age !== null && age < NEW_DAYS) out.push(tr(`erst ${age} Tag(e) alt`, `only ${age} day(s) old`))
43 if (like && ((age !== null && age < YOUNG_DAYS) || (info.versions ?? 0) < FEW_VERSIONS)) {
44 out.push(tr(`ähnelt „${like}“ (${info.versions ?? '?'} Versionen, ${age ?? '?'} Tage alt)`, `looks like "${like}" (${info.versions ?? '?'} versions, ${age ?? '?'} days old)`))
45 }
46 return out
47}
48
49async function lookup($: EngineInterface, eco: Ecosystem, name: string): Promise<PackageInfo | null> {
50 const key = `${eco}:${name}`
51 const cached = known.get(key)
52 if (cached) return cached
53 const script = `${$.plugin.root}/mods/package-guard/registry.mjs`.replace(/\\/g, '/')
54 const r = await $.process.run(['node', script, eco, name], { timeoutMs: 15_000 }).catch(() => null)
55 let info: PackageInfo | null = null
56 try {
57 info = JSON.parse((r?.stdout ?? '').trim().split('\n').pop() || 'null') as PackageInfo | null
58 } catch {
59 info = null
60 }
61 if (info && !info.error) known.set(key, info)
62 return info
63}
64
65const label = (eco: Ecosystem) => (eco === 'npm' ? 'npm' : eco === 'pypi' ? 'PyPI' : 'crates.io')
66
67export const register: Register = (on, options) => {
68 lang = options.language === 'de' ? 'de' : 'en'
69 mode = String(options.mode ?? 'block')
70
71 on('session.start', {}, async ($, e, next) => {
72 const r = await next(e)
73 await $.command.register({ name: 'pkg-allow', description: t('Paket-Wächter: ein angehaltenes Paket erlauben', 'Package Guard: allow a held package'), argumentHint: '<name>' })
74 return r
75 })
76
77 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
78 const wanted = wantedPackages(String(e.command ?? ''))
79 if (!wanted.length) return next(e)
80 const ok = await read($, allowed)
81 const now = await $.clock.now()
82 const held: PackageHold['packages'] = []
83 for (const p of wanted) {
84 if (ok.includes(`${p.eco}:${p.name}`)) continue
85 const info = await lookup($, p.eco, p.name)
86 const reasons = reasonsFor(p.eco, p.name, info, now, lang === 'de')
87 if (reasons.length) held.push({ eco: p.eco, name: p.name, runs: p.runs, reasons, repo: info?.repo ?? null })
88 }
89 if (!held.length) return next(e)
90 const list = held.map(p => `${p.name} (${label(p.eco)}): ${p.reasons.join(', ')}`).join('; ')
91 // Without a registry answer a look-alike is only worth a warning.
92 const onlyUnsure = held.every(p => p.reasons.every(r => /registry not reachable|Registry nicht erreichbar/.test(r)))
93 if (mode === 'warn' || onlyUnsure) {
94 $.ui.toast(`📦 ${t('Paket-Wächter', 'Package Guard')}: ${list}`, { timeoutMs: 8000 })
95 return next(e)
96 }
97 await update($, hold, () => ({ command: String(e.command ?? '').slice(0, 300), packages: held, at: now, isDismissed: false }))
98 $.ui.toast(`📦 ${t('Paket-Wächter hat angehalten', 'Package Guard held')}: ${list}`, { timeoutMs: 8000 })
99 return {
100 deny:
101 `Package Guard held this command and did not run it: ${list}. ` +
102 'Check the name: it may be a typo of a well-known package or a package that does not exist. ' +
103 'Tell the user what you found. If they confirm the package, they allow it in the band above the prompt (or with /pkg-allow <name>); then run the command again. Do not install it another way.',
104 }
105 })
106
107 on('command.run', { command: 'pkg-allow' }, async ($, e) => {
108 const name = String(e.args ?? '').trim().toLowerCase()
109 const h = await read($, hold)
110 const hits = (h?.packages ?? []).filter(p => !name || p.name === name)
111 if (!hits.length) return { text: t('Kein angehaltenes Paket dieses Namens.', 'No held package by that name.') }
112 await allow($, hits.map(p => `${p.eco}:${p.name}`))
113 return { text: t(`Erlaubt: ${hits.map(p => p.name).join(', ')}. Claude kann den Befehl jetzt erneut ausführen.`, `Allowed: ${hits.map(p => p.name).join(', ')}. Claude can run the command again now.`) }
114 })
115
116 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
117 const rest = await next(e)
118 if (!e.props.hasSurvey) {
119 const part = await band($, e).catch(() => null)
120 if (part) offerBand(e, part)
121 }
122 return rest
123 })
124}
125
126async function allow($: EngineInterface, keys: string[]) {
127 await update($, allowed, list => [...new Set([...list, ...keys])])
128 await update($, hold, h => {
129 if (!h) return h
130 const rest = h.packages.filter(p => !keys.includes(`${p.eco}:${p.name}`))
131 return rest.length ? { ...h, packages: rest } : null
132 })
133}
134
135export async function band($: EngineInterface, e: any): Promise<BandPart | null> {
136 const h = await read($, hold)
137 if (!h || h.isDismissed || !h.packages.length) return null
138 const { Text, Button } = $.ui.resolve(e)
139 const names = h.packages.map(p => `${p.name} (${label(p.eco)}): ${p.reasons.join(', ')}`).join(' · ')
140 const keys = h.packages.map(p => `${p.eco}:${p.name}`)
141 return {
142 id: 'packageGuard',
143 urgent: true,
144 short: <Text color="yellow">{`📦 ${h.packages.length}`}</Text>,
145 line: (
146 <Text>
147 <Text color="yellow" bold>{'📦 '}</Text>
148 <Text>{t('Paket angehalten: ', 'Package held: ')}</Text>
149 <Text dimColor>{names}</Text>
150 </Text>
151 ),
152 actions: [
153 <Button key="pg-allow" label={t('Erlauben', 'Allow')} plain onPress={() => void allow($, keys).then(() => $.ui.toast(t('Erlaubt. Claude kann den Befehl jetzt erneut ausführen.', 'Allowed. Claude can run the command again now.')))} />,
154 <Button key="pg-dismiss" label="×" plain onPress={() => void update($, hold, x => (x ? { ...x, isDismissed: true } : x))} />,
155 ],
156 status: `📦 ${names}`,
157 }
158}
159mods/settings/hooks/band.ts 44 lines1// The band manager's mailbox. A band mod computes its display in its own AbovePrompt hook (with its
2// own $; the validator does not let $ cross an import) and drops it here with offer(). The settings
3// hook, registered first and so the outermost, runs after all of them, takes the parts and draws
4// them ordered, compact or one line each, or sends them to the status line.
5export type BandPart = {
6 /** The mod's switch key, e.g. "cacheMeter". */
7 id: string
8 /** Wants attention (CI red, sign offer …): always its own line with its buttons. */
9 urgent: boolean
10 /** One short element for the compact line. */
11 short: unknown
12 /** The full element for its own line. */
13 line: unknown
14 /** Buttons that belong to the part. */
15 actions: unknown[]
16 /** Plain text for the status line. */
17 status: string
18}
19
20const parts = new Map<string, BandPart[]>()
21
22function keyOf(e: { requestId?: string }): string {
23 return e.requestId ?? 'band'
24}
25
26/** Called by the manager before the inner hooks run. */
27export function openBand(e: { requestId?: string }) {
28 parts.set(keyOf(e), [])
29}
30
31/** Called by a band mod with its display for this render. */
32export function offer(e: { requestId?: string }, part: BandPart) {
33 const list = parts.get(keyOf(e))
34 if (list) list.push(part)
35 else parts.set(keyOf(e), [part])
36}
37
38/** Called by the manager after the inner hooks ran. */
39export function takeBand(e: { requestId?: string }): BandPart[] {
40 const list = parts.get(keyOf(e)) ?? []
41 parts.delete(keyOf(e))
42 return list
43}
44mods/package-guard/hooks/parse.ts 110 lines1// Package Guard: which packages a shell command would install or run, per ecosystem. No $ here.
2
3export type Ecosystem = 'npm' | 'pypi' | 'crates'
4export type Wanted = { eco: Ecosystem; name: string; runs: boolean }
5
6// Shell words of one command, quotes respected; good enough for install lines.
7function words(cmd: string): string[] {
8 const out: string[] = []
9 let w: string | null = null
10 let q = ''
11 for (const ch of cmd) {
12 if (q) {
13 if (ch === q) q = ''
14 else w = (w ?? '') + ch
15 } else if (ch === '"' || ch === "'") {
16 q = ch
17 w ??= ''
18 } else if (/\s/.test(ch)) {
19 if (w !== null) out.push(w)
20 w = null
21 } else w = (w ?? '') + ch
22 }
23 if (w !== null) out.push(w)
24 return out
25}
26
27// Flags that take a value: their value is no package name.
28const VALUE_FLAGS = new Set([
29 '--registry', '--prefix', '--tag', '--workspace', '-w', '--filter', '--cwd', '--dir', '-C',
30 '-r', '--requirement', '-c', '--constraint', '-i', '--index-url', '--extra-index-url', '-t', '--target', '-e', '--editable', '--python', '-p', '--group', '--extra',
31 '--git', '--branch', '--tag', '--rev', '--path', '--root', '--features', '-F', '--registry', '--package', '--manifest-path',
32])
33
34const isLocal = (a: string) => /^(\.{0,2}[\\/]|[a-z]:[\\/]|~[\\/]|file:|git\+|git:|https?:|github:|link:|workspace:)/i.test(a) || /\.(tgz|tar\.gz|whl|zip)$/i.test(a)
35
36function args(list: string[]): string[] {
37 const out: string[] = []
38 for (let i = 0; i < list.length; i++) {
39 const a = list[i] ?? ''
40 if (a.startsWith('-')) {
41 if (!a.includes('=') && VALUE_FLAGS.has(a)) i++
42 continue
43 }
44 if (!isLocal(a)) out.push(a)
45 }
46 return out
47}
48
49/** npm-style names: drop a version or tag (`name@1.2`, `@scope/name@latest`). */
50export function npmName(spec: string): string {
51 const at = spec.indexOf('@', spec.startsWith('@') ? 1 : 0)
52 return (at > 0 ? spec.slice(0, at) : spec).toLowerCase()
53}
54
55/** PyPI names: drop extras and version specifiers, normalised as PyPI does (PEP 503). */
56export function pypiName(spec: string): string {
57 return spec.replace(/\[.*?\]/g, '').split(/[<>=!~;@ ]/)[0]!.toLowerCase().replace(/[-_.]+/g, '-')
58}
59
60export function crateName(spec: string): string {
61 return spec.split('@')[0]!.toLowerCase()
62}
63
64/** The packages one shell command line would install (or run, for npx and the like). */
65export function wantedPackages(command: string): Wanted[] {
66 const out: Wanted[] = []
67 for (const part of command.split(/&&|\|\||;|\||\n/)) {
68 const w = words(part.trim())
69 // leading environment assignments and sudo
70 while (w.length && (/^\w+=/.test(w[0]!) || w[0] === 'sudo')) w.shift()
71 const [a, b, c] = w.map(x => x.toLowerCase())
72 if (!a) continue
73 const add = (eco: Ecosystem, names: string[], runs = false) => {
74 for (const n of names) {
75 const name = eco === 'npm' ? npmName(n) : eco === 'pypi' ? pypiName(n) : crateName(n)
76 if (name) out.push({ eco, name, runs })
77 }
78 }
79 if ((a === 'npm' || a === 'pnpm' || a === 'yarn' || a === 'bun') && ['i', 'install', 'add'].includes(b ?? '')) {
80 add('npm', args(w.slice(2)))
81 } else if (a === 'npx' || a === 'pnpx' || a === 'bunx' || ((a === 'pnpm' || a === 'yarn') && b === 'dlx')) {
82 const rest = args(w.slice(a === 'npx' || a === 'pnpx' || a === 'bunx' ? 1 : 2))
83 // only the first word is the package; what follows are its arguments
84 if (rest[0]) add('npm', [rest[0]], true)
85 } else if ((a === 'pip' || a === 'pip3') && b === 'install') {
86 add('pypi', args(w.slice(2)))
87 } else if ((a === 'python' || a === 'python3' || a === 'py') && b === '-m' && c === 'pip' && w[3]?.toLowerCase() === 'install') {
88 add('pypi', args(w.slice(4)))
89 } else if (a === 'uv' && b === 'pip' && c === 'install') {
90 add('pypi', args(w.slice(3)))
91 } else if ((a === 'uv' || a === 'poetry') && b === 'add') {
92 add('pypi', args(w.slice(2)))
93 } else if (a === 'pipx' && (b === 'install' || b === 'run')) {
94 add('pypi', b === 'run' ? args(w.slice(2)).slice(0, 1) : args(w.slice(2)), b === 'run')
95 } else if (a === 'uvx') {
96 add('pypi', args(w.slice(1)).slice(0, 1), true)
97 } else if (a === 'cargo' && (b === 'add' || b === 'install')) {
98 add('crates', args(w.slice(2)))
99 }
100 }
101 // the same package once
102 const seen = new Set<string>()
103 return out.filter(p => {
104 const k = `${p.eco}:${p.name}`
105 if (seen.has(k)) return false
106 seen.add(k)
107 return true
108 })
109}
110mods/package-guard/hooks/similar.ts 65 lines1// Package Guard: names that look like a well-known package but are not it (typosquatting). No $.
2import type { Ecosystem } from './parse'
3
4// Well-known packages per ecosystem: the names a typo would aim at.
5export const POPULAR: Record<Ecosystem, string[]> = {
6 npm: [
7 'react', 'react-dom', 'next', 'vue', 'nuxt', 'svelte', 'angular', 'express', 'koa', 'fastify', 'hono', 'lodash', 'underscore', 'axios', 'node-fetch',
8 'request', 'chalk', 'commander', 'yargs', 'inquirer', 'debug', 'dotenv', 'cross-env', 'rimraf', 'glob', 'minimist', 'moment', 'dayjs', 'date-fns',
9 'uuid', 'nanoid', 'classnames', 'clsx', 'typescript', 'ts-node', 'tsx', 'esbuild', 'vite', 'webpack', 'rollup', 'parcel', 'babel-core', 'eslint',
10 'prettier', 'jest', 'vitest', 'mocha', 'chai', 'sinon', 'cypress', 'playwright', 'puppeteer', 'electron', 'socket.io', 'ws', 'mongoose', 'mongodb',
11 'mysql', 'mysql2', 'pg', 'sqlite3', 'better-sqlite3', 'redis', 'ioredis', 'prisma', 'sequelize', 'typeorm', 'knex', 'zod', 'yup', 'joi', 'ajv',
12 'jsonwebtoken', 'bcrypt', 'bcryptjs', 'passport', 'cors', 'helmet', 'body-parser', 'cookie-parser', 'multer', 'morgan', 'winston', 'pino',
13 'nodemon', 'concurrently', 'husky', 'lint-staged', 'tailwindcss', 'postcss', 'autoprefixer', 'sass', 'less', 'styled-components', 'redux',
14 'zustand', 'mobx', 'rxjs', 'graphql', 'apollo-server', 'openai', 'anthropic', 'langchain', 'three', 'd3', 'chart.js', 'jquery', 'bootstrap',
15 'semver', 'colors', 'ora', 'execa', 'fs-extra', 'chokidar', 'got', 'superagent', 'cheerio', 'jsdom', 'sharp', 'jimp', 'marked', 'markdown-it',
16 'highlight.js', 'prismjs', 'shelljs', 'js-yaml', 'yaml', 'xml2js', 'qs', 'ramda', 'immer', 'async', 'bluebird', 'core-js', 'tslib',
17 ],
18 pypi: [
19 'requests', 'urllib3', 'numpy', 'pandas', 'scipy', 'matplotlib', 'seaborn', 'scikit-learn', 'tensorflow', 'torch', 'keras', 'transformers',
20 'flask', 'django', 'fastapi', 'uvicorn', 'gunicorn', 'starlette', 'pydantic', 'sqlalchemy', 'psycopg2', 'psycopg2-binary', 'pymysql', 'redis',
21 'celery', 'boto3', 'botocore', 'awscli', 'setuptools', 'wheel', 'pip', 'virtualenv', 'pytest', 'tox', 'black', 'flake8', 'pylint', 'mypy',
22 'isort', 'ruff', 'click', 'typer', 'rich', 'colorama', 'tqdm', 'pyyaml', 'toml', 'python-dotenv', 'jinja2', 'markupsafe', 'beautifulsoup4',
23 'lxml', 'selenium', 'playwright', 'scrapy', 'httpx', 'aiohttp', 'pillow', 'opencv-python', 'cryptography', 'pyjwt', 'paramiko', 'openai',
24 'anthropic', 'langchain', 'jupyter', 'ipython', 'notebook', 'pyinstaller', 'six', 'attrs', 'certifi', 'idna', 'charset-normalizer', 'pytz',
25 'python-dateutil', 'docker', 'kubernetes', 'grpcio', 'protobuf', 'networkx', 'sympy', 'nltk', 'spacy', 'gensim', 'xgboost', 'lightgbm',
26 ],
27 crates: [
28 'serde', 'serde_json', 'serde_derive', 'tokio', 'async-std', 'futures', 'reqwest', 'hyper', 'axum', 'actix-web', 'warp', 'rocket', 'clap',
29 'structopt', 'anyhow', 'thiserror', 'log', 'env_logger', 'tracing', 'tracing-subscriber', 'rand', 'regex', 'lazy_static', 'once_cell',
30 'chrono', 'time', 'uuid', 'itertools', 'rayon', 'crossbeam', 'parking_lot', 'bytes', 'base64', 'sha2', 'md5', 'ring', 'rustls', 'openssl',
31 'diesel', 'sqlx', 'rusqlite', 'redis', 'tonic', 'prost', 'syn', 'quote', 'proc-macro2', 'bitflags', 'libc', 'winapi', 'windows', 'nix',
32 'toml', 'serde_yaml', 'url', 'image', 'wgpu', 'bevy', 'egui', 'tauri', 'indicatif', 'dirs', 'walkdir', 'tempfile', 'num', 'nalgebra',
33 ],
34}
35
36/** Edit distance with transpositions (Damerau-Levenshtein, optimal string alignment). */
37export function distance(a: string, b: string): number {
38 const d: number[][] = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)])
39 for (let j = 1; j <= b.length; j++) d[0]![j] = j
40 for (let i = 1; i <= a.length; i++) {
41 for (let j = 1; j <= b.length; j++) {
42 const cost = a[i - 1] === b[j - 1] ? 0 : 1
43 let v = Math.min(d[i - 1]![j]! + 1, d[i]![j - 1]! + 1, d[i - 1]![j - 1]! + cost)
44 if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) v = Math.min(v, d[i - 2]![j - 2]! + 1)
45 d[i]![j] = v
46 }
47 }
48 return d[a.length]![b.length]!
49}
50
51const squash = (s: string) => s.replace(/^@[^/]+\//, '').replace(/[-_.]/g, '')
52
53/** The well-known package this name looks like (but is not), or null. */
54export function lookalike(eco: Ecosystem, name: string): string | null {
55 const list = POPULAR[eco]
56 if (list.includes(name)) return null
57 for (const known of list) {
58 // react-dom vs reactdom, python_dotenv vs python-dotenv
59 if (squash(known) === squash(name)) return known
60 const max = known.length >= 8 ? 2 : known.length >= 4 ? 1 : 0
61 if (max && Math.abs(known.length - name.length) <= max && distance(known, name) <= max) return known
62 }
63 return null
64}
65mods/package-guard/types/contract.d.ts 26 lines1export type PackageInfo = {
2 exists?: boolean
3 created?: string | null
4 versions?: number
5 latest?: string | null
6 repo?: string | null
7 downloads?: number | null
8 error?: string
9}
10
11export type PackageHold = {
12 command: string
13 packages: Array<{ eco: 'npm' | 'pypi' | 'crates'; name: string; runs: boolean; reasons: string[]; repo: string | null }>
14 at: number
15 isDismissed: boolean
16}
17
18declare module 'claude-code' {
19 interface PluginState {
20 'package-guard': {
21 hold: PackageHold | null
22 allowed: string[]
23 }
24 }
25}
26