
ghostimport
io.github.FGuerreir0/ghostimport
Stops AI agents installing npm packages that don't exist, typosquats, or risky install scripts.
Documentation
ghostimport
Stops your AI coding agent from installing npm packages that don't exist.
Your agent invents a package name. It doesn't exist on npm yet. Someone is watching for exactly that, and the moment they register it, npm install runs their postinstall script on your machine.
This is slopsquatting. Most dependency scanners inspect packages after they are installed; ghostimport checks names before they are — against the live registry, at the moment your agent writes or installs them.
$ ghostimport
Scanned 142 files · 38 packages
✗ react-server-fetch does not exist on npm
↳ src/data/loader.ts
↳ unregistered — anyone could claim this name with a malicious postinstall
✗ axois high risk
↳ src/api/client.ts
↳ 1-2 chars from 'axios' — likely a typo
↳ has postinstall script — runs code on npm install
created 2019-08-29 · 1245/week · 1 version
2 problems found.
Install in Claude Code
One command installs the blocking hooks and the MCP server together:
/plugin install ghostimport --marketplace FGuerreir0/ghostimport
On Claude Code older than 2.1.275, add the marketplace first: /plugin marketplace add FGuerreir0/ghostimport, then /plugin install ghostimport@ghostimport.
The plugin runs the CLI bundled in the npm package — no global install, no npx on every edit. If you already wired the hooks into .claude/settings.json by hand, remove them when you install the plugin, or every command is checked twice. Node.js 22+. Zero runtime dependencies: the published package uses only Node built-ins.
For the CLI, CI, or another agent:
npm install -g ghostimport # or: npx ghostimport
What it does in the agent loop
This is the part that matters. A CI check tells you about a bad package after it's in your repo; these stop it at the moment it's written.
Hooks — the enforcing one
The plugin installs these. To set them up by hand instead, install ghostimport globally (npm install -g ghostimport, since the hook runs ghostimport directly) and add to .claude/settings.json:
{
"hooks": {
"PreToolUse": [
{ "matcher": "Bash|PowerShell", "hooks": [{ "type": "command", "command": "ghostimport hook" }] }
],
"PostToolUse": [
{ "matcher": "Edit|Write|MultiEdit", "hooks": [{ "type": "command", "command": "ghostimport hook" }] }
]
}
}
PreToolUseon Bash and PowerShell (PowerShell is Claude Code's shell on Windows) — reads any install command, and any command that downloads and runs a package on the spot (npx,npm exec,npm create,pnpm dlx,yarn dlx,bunx), and denies the tool call if it would fetch a package that doesn't exist, is a typosquat, or runs an install script with no track record behind it. Established packages that ship one (esbuild,puppeteer) go through. This is the one that stops a real attack.PostToolUseon edits — checks imports the agent just wrote, or dependencies it just added to apackage.json, and tells it to fix them.
npx <name> deserves the attention: it runs the package's code immediately, no postinstall needed, and agents copy npx commands out of READMEs and skill files without checking them. An npx of a bin your project already has installed (npx tsc) runs the local copy and isn't checked.
The agent sees why it was stopped:
ghostimport blocked this: the install command below would fetch packages that
are unsafe or do not exist.
• 'axois' exists but is high risk: name is 1-2 chars from 'axios';
single version published.
Do not retry this command as written.
It fails open. Registry unreachable, malformed payload, or a check exceeding its 20-second budget → exits 0 and stays out of the way. A security tool that wedges your agent when you're offline gets uninstalled by Friday.
MCP — the self-service one
Lets the model verify a name before it writes the import. Hooks are mandatory; MCP tools are offered — use both.
claude mcp add ghostimport -- npx -y ghostimport mcp # included in the plugin
Cursor, Windsurf, and other MCP clients
Add to .cursor/mcp.json or your client's config:
{
"mcpServers": {
"ghostimport": {
"command": "npx",
"args": ["-y", "ghostimport", "mcp"]
}
}
}
| Tool | What it does |
|---|---|
check_packages | Verify package names exist on npm. deep adds risk heuristics. |
check_install_command | Vet a full npm/pnpm/yarn/bun command — installs, and npx/dlx/create runs. |
scan_project | Audit a whole directory. |
CLI
ghostimport # scan the current directory
ghostimport ./src # scan a folder
ghostimport --json # machine-readable
ghostimport --watch # re-scan on change
Exits 1 if any imported package doesn't exist, so it works as a CI gate as-is.
All options
| Flag | Effect |
|---|---|
--quiet, -q | Only show problems |
--json | Output results as JSON |
--watch, -w | Re-scan on file changes |
--badge | Print a README badge after scanning |
--fast | Skip the deep supply-chain check on undeclared packages |
--no-undeclared | Hide "imported but not in package.json" warnings |
--no-cache | Bypass the 24h registry cache |
--version, -v · --help, -h |
Optional .ghostimportrc.json in your project root:
{ "ignore": ["@company/*", "internal-lib"], "includeUndeclared": true }
CI: GitHub Actions and pre-commit
- uses: FGuerreir0/ghostimport@v0.6.1
with:
path: '.'
Or just run: npx ghostimport --quiet.
For pre-commit, in .pre-commit-config.yaml:
repos:
- repo: https://github.com/FGuerreir0/ghostimport
rev: v0.6.1
hooks:
- id: ghostimport
API
import { verifyPackages, scan } from 'ghostimport'
await verifyPackages(['axios', 'axois'], { deep: true })
// [ { pkg: 'axios', status: 'ok', typosquatOf: null },
// { pkg: 'axois', status: 'suspicious', risk: 'high', typosquatOf: 'axios', ... } ]
const { missing, undeclared, risks } = await scan('./src')
status is 'ok' | 'missing' | 'suspicious' | 'unknown'. 'unknown' means the registry was unreachable — never treat it as a failure.
Full API and TypeScript types
| Export | Purpose |
|---|---|
scan(dir, opts?) | Scan a directory. Returns ScanResult. |
verifyPackages(names, opts?) | Check a list of names. Returns PackageVerdict[]. |
checkNpm(name) | Does this one package exist? |
checkPackageRisk(name) | Full supply-chain check for one package. |
detectTyposquat(name) | Returns the popular package it's 1-2 chars from, or null. |
extractImports(code) | Package names from a source string. |
extractInstallTargets(cmd) | Packages a shell command would install. |
extractCommandTargets(cmd, { cwd?, dialect? }) | Packages a command would install or run (npx, dlx, create), with `action: 'install' |
interface ScanResult {
scanned: number
packages: number
missing: { pkg: string; files: string[]; claimable: boolean | null }[]
// don't exist on npm; claimable is false
// inside a scope someone already owns
undeclared: { pkg: string; files: string[] }[] // exist, but not in package.json
risks: RiskEntry[] // supply-chain findings
errors: { pkg: string; error: string; files: string[] }[]
cacheHits: number
}
type RiskEntry =
| { pkg: string; files: string[]; type: 'unregistered'; typosquatOf: string | null }
| { pkg: string; files: string[]; type: 'suspicious'
risk: 'medium' | 'high'; flags: string[]; installScripts: string[]
typosquatOf: string | null; maintainers: number
created: string; downloads: number | null; versions: number }
Types are shipped with the package: ScanResult, ScanOptions, MissingRef, RiskEntry, PackageVerdict, VerdictStatus, PackageRiskResult, NpmCheckResult, Config.
What gets scanned, and what raises a risk
Detects: import, require(), dynamic import(), export … from, scoped packages, subpath imports (pkg/utils → pkg), and <script> blocks in .vue, .svelte and .astro (markup is ignored, so a package name in template text is never flagged). Also every registry dependency declared in any package.json, whether or not anything imports it — workspace:, file:, link:, git and URL specs are skipped, and npm: aliases are followed to the real name.
Extensions: .js .jsx .ts .tsx .mjs .cjs .vue .svelte .astro
Ignores: Node built-ins, relative imports, path aliases (@/, ~/, $lib/, tsconfig paths), URL/protocol imports, virtual modules, workspace packages, and node_modules/ dist/ build/ .git/.
Risk signals:
| Signal | Weight | Why |
|---|---|---|
| Name 1-2 chars from a popular package, < 10k weekly downloads | critical | Classic typosquat. Established near names (preact, redis, cypress) aren't flagged |
postinstall / preinstall / install script | amplifier | Runs arbitrary code on npm install. Critical alongside any medium signal; normal on its own (esbuild, puppeteer) |
| Created < 30 days ago | medium | No track record |
| < 50 weekly downloads | medium | Near-zero adoption |
| Single version published | medium | Abandoned or one-shot |
| Single maintainer | amplifier | Only counts alongside another signal |
high if any critical signal fires, or 2+ medium ones (a single maintainer counts as a second). Only medium and high are reported.
A name that doesn't exist on npm is reported as squattable unless it sits in a scope someone already owns — only a scope's owner can publish under it, so @babel/made-up is a broken import but not a squatting target. That check costs at most one request per scope, so --fast doesn't disable it.
Contributing
Development setup, the source layout, and how the site in docs/ is built and deployed all live in CONTRIBUTING.md.
Support
ghostimport is free, MIT licensed and has no runtime dependencies — and it stays that way. If it caught something for you, you can buy me a coffee.
License
MIT
ghostimportnpm install ghostimport