ghostimport Docs GitHub npm

Documentation

Set up ghostimport

Everything the tool does, on one page. If you read only one section, make it agent hooks — that's the part that actually blocks an attack.

$ npm install -g ghostimport

Install

Requires Node.js 22 or newer. Zero runtime dependencies — the published package uses only Node built-ins.

install
# global — recommended if you'll use the hook
npm install -g ghostimport

# one-off, no install
npx ghostimport

# as a dev dependency
npm install --save-dev ghostimport

If you're wiring up the hook, install globally. Hooks run on every matching tool call, and npx pays a startup cost each time.

Quick start

Point it at a project. One mode, no flags to discover — the supply-chain analysis always runs.

ghostimport
$ 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
     single version published
    created 2019-08-29 · 1245/week · 1 version

   not in package.json: zod

  2 problems found.

It exits 1 when an imported package doesn't exist and 0 otherwise, so it works as a CI gate with no extra configuration.

Agent hooks

The enforcing integration. Hooks run on every matching tool call whether or not the model cooperates — unlike MCP tools, which the model has to choose to call.

Add to .claude/settings.json:

.claude/settings.json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [{ "type": "command", "command": "ghostimport hook" }]
      }
    ],
    "PostToolUse": [
      {
        "matcher": "Edit|Write|MultiEdit",
        "hooks": [{ "type": "command", "command": "ghostimport hook" }]
      }
    ]
  }
}

What each one does

  • PreToolUse on Bash — parses any npm, pnpm, yarn or bun 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, because it fires before the postinstall could run.
  • PostToolUse on edits — reads the file the agent just wrote, extracts its imports, and reports any that don't resolve.

What the agent sees when it's blocked

stderr
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. Remove the flagged packages, correct
the name if it was a typo, and tell the user what was flagged and why.

It fails open. Unreachable registry, malformed payload, or a check that exceeds its 20-second budget → it exits 0 and gets out of the way. A security tool that wedges your agent when you're offline gets uninstalled by Friday.

Results are cached for 24 hours in ~/.ghostimport/, so the steady state is zero network calls.

MCP server

Gives the model tools to check a name before it commits to writing the import. Use it alongside hooks, not instead of them — this one depends on the model choosing to call it.

Claude Code
claude mcp add ghostimport -- npx -y ghostimport mcp

For Cursor, Windsurf, Zed or any other MCP client, add to its config:

.cursor/mcp.json
{
  "mcpServers": {
    "ghostimport": {
      "command": "npx",
      "args": ["-y", "ghostimport", "mcp"]
    }
  }
}

Tools it exposes

ToolArgumentsWhat it does
check_packages packages, deep Verify names exist on npm. deep adds the risk heuristics.
check_install_command command Extract and vet every package a shell command would install.
scan_project path Audit a whole directory.

The server speaks JSON-RPC over stdio and is written directly against the wire format — no MCP SDK — so installing ghostimport still pulls in zero runtime dependencies. A supply-chain tool with its own dependency tree undercuts its own argument.

CLI

usage
ghostimport [dir] [options]
ghostimport mcp | hook

Options

FlagEffect
--quiet, -qOnly show problems
--jsonOutput results as JSON
--watch, -wRe-scan on file changes
--badgePrint a README badge after scanning
--fastSkip the deep supply-chain check on undeclared packages
--no-undeclaredHide "imported but not in package.json" warnings
--no-cacheBypass the 24-hour registry cache
--version, -vPrint the version
--help, -hShow help

--fast only skips the deep check on packages that exist. Names that don't exist are always reported as squattable — that check costs no extra requests, so there's no way to accidentally turn off the important part.

Commands

  • ghostimport mcp — run as an MCP server over stdio
  • ghostimport hook — run as an agent hook, reading a hook payload on stdin

JSON output

ghostimport --json
{
  "scanned": 142,
  "packages": 38,
  "missing":    [{ "pkg": "react-server-fetch", "files": [...] }],
  "undeclared": [{ "pkg": "zod", "files": [...] }],
  "risks":      [{ "pkg": "axois", "type": "suspicious", "risk": "high", ... }],
  "errors":     [],
  "cacheHits":  33
}

README badge

Run a scan with --badge and it prints ready-to-paste Markdown for your project's README, reflecting the result of that scan.

ghostimport --badge
$ ghostimport --badge

  README badge:
  [![ghostimport](https://img.shields.io/badge/ghostimport-%E2%9C%93%20clean-brightgreen)](https://github.com/FGuerreir0/ghostimport)

The two states

ghostimport: clean Clean. Every imported package resolves on npm.
ghostimport: 2 ghosts Ghosts found. The count is how many imported packages don't exist.

Copy the clean badge

[![ghostimport](https://img.shields.io/badge/ghostimport-%E2%9C%93%20clean-brightgreen)](https://github.com/FGuerreir0/ghostimport)

The badge is static. It captures the scan you ran when you generated it — paste it and it stays that way, even if a later commit introduces a package that doesn't exist. Treat it as a signal that you check imports, not as live status. A self-updating badge is planned for a future release; until then, pair it with the CI check, which is what actually fails the build.

Config file

Optional. Drop a .ghostimportrc.json in your project root.

.ghostimportrc.json
{
  "ignore": ["@company/*", "internal-lib"],
  "includeUndeclared": true
}
FieldDefaultDescription
ignore[]Names or scope patterns to skip. @scope/* is supported.
includeUndeclaredtrueWarn on packages that exist but aren't in package.json.

Workspace packages in a monorepo and paths aliases from your tsconfig are detected automatically — you don't need to list them.

JavaScript API

Ships with TypeScript declarations. Seven exports, each with one job.

node
import { verifyPackages, scan } from 'ghostimport'

// check names directly
await verifyPackages(['axios', 'axois'], { deep: true })
// [ { pkg: 'axios', status: 'ok', typosquatOf: null },
//   { pkg: 'axois', status: 'suspicious', risk: 'high', typosquatOf: 'axios', ... } ]

// scan a directory
const { missing, undeclared, risks } = await scan('./src')

Verdict statuses

okExists on npm, no risk signals fired.
missingDoes not exist. The name is unclaimed and anyone can register it.
suspiciousExists, but the heuristics rate it medium or high risk.
unknownCouldn't be checked — network or timeout. Never treat this as a failure.

Exports

ExportPurpose
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)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.

What gets scanned

Detected: import, require(), dynamic import(), export … from, scoped packages, and subpath imports (pkg/utils resolves to pkg).

Extensions: .js .jsx .ts .tsx .mjs .cjs .vue .svelte .astro

For .vue, .svelte and .astro, only <script> blocks and Astro frontmatter are read — markup is ignored, so a package name that appears in template text is never flagged.

Ignored:

  • Node built-ins, including node: prefixed ones
  • Relative imports (./, ../)
  • Path aliases — @/, ~/, $lib/, and tsconfig paths
  • URL and protocol imports, and virtual modules (virtual:, Vite internals)
  • Workspace packages in a monorepo
  • node_modules/, dist/, build/, .git/

Risk signals

A name that resolves isn't automatically safe. These signals are checked against packages that exist but aren't declared in your package.json.

SignalWeightWhy it matters
postinstall / preinstall / install scriptCriticalRuns arbitrary code the moment the package lands
1–2 chars from a popular nameCriticalThe classic typosquat shape
Created < 30 days agoMediumNo track record to judge it by
< 50 weekly downloadsMediumNobody else is depending on it
Single version publishedMediumAbandoned, or published once for a reason
Single maintainerAmplifierOnly counts alongside another signal — plenty of good packages have one

Rated high if any critical signal fires or two medium ones do; only medium and high are reported.

CI

The CLI already exits non-zero on a missing package, so the simplest form is one line.

.github/workflows/ci.yml
- name: Check for hallucinated packages
  run: npx ghostimport --quiet

Or use the action, which also writes a job summary and exposes counts as outputs:

.github/workflows/ci.yml
- uses: FGuerreir0/ghostimport@v0.5.0
  with:
    path: '.'

For pre-commit, in .pre-commit-config.yaml:

.pre-commit-config.yaml
repos:
  - repo: https://github.com/FGuerreir0/ghostimport
    rev: v0.5.0
    hooks:
      - id: ghostimport

CI is a backstop, not the main event. By the time a bad package reaches CI it's already in your repo — hooks catch it while it's being written.