Skip to content

Repository files navigation

ghostimport logo

ghostimport

Stops your AI coding agent from installing npm packages that don't exist.

CI npm ghostimport Buy me a coffee

How the attack works β†’


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. npm audit, Snyk and Socket don't catch it β€” they only inspect packages you've already installed. ghostimport checks names 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

npm install -g ghostimport     # or: npx ghostimport

Node.js 22+. Zero runtime dependencies β€” the published package uses only Node built-ins.

Use it with your AI agent

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

Add to .claude/settings.json:

{
  "hooks": {
    "PreToolUse": [
      { "matcher": "Bash", "hooks": [{ "type": "command", "command": "ghostimport hook" }] }
    ],
    "PostToolUse": [
      { "matcher": "Edit|Write|MultiEdit", "hooks": [{ "type": "command", "command": "ghostimport hook" }] }
    ]
  }
}
  • PreToolUse on Bash β€” reads any install command and denies the tool call if it would fetch a package that doesn't exist, is a typosquat, or ships an install script. This is the one that stops a real attack.
  • PostToolUse on edits β€” checks imports the agent just wrote and tells it to fix them.

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
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 install command.
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.5.2
  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.5.2
    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.
interface ScanResult {
  scanned: number
  packages: number
  missing: { pkg: string; files: string[] }[]     // don't exist on npm
  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, 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).

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
postinstall / preinstall / install script critical Runs arbitrary code on npm install
Name 1-2 chars from a popular package critical Classic typosquat
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. Only medium and high are reported.

A name that doesn't exist on npm is always reported as squattable β€” that check costs no extra requests, 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

About

πŸ‘» Detects ghost imports β€” npm packages that don't exist, hallucinated by AI coding tools like Cursor, Copilot, and Claude

Topics

Resources

Contributing

Stars

2 stars

Watchers

0 watching

Forks

Releases

Sponsor this project

Packages

Contributors

Languages