An SBOM Proves What You Installed. It Can't Prove You Should Have.


A pre-install supply-chain gate returns ALLOW or DENY for each package your AI agent proposes, before npm install runs, keyed on provenance: is the name in a vouched snapshot or a popular baseline, and is the .npmrc registry trusted. An SBOM taken after resolve cannot answer that question. In this post’s attack manifest, supply_chain_gate.py returns 2 DENY and exits 1.

AI disclosure: I wrote supply_chain_gate.py with an AI assistant and ran it myself, offline, before publishing. Every number in the output blocks below is pasted from a real local run on Python 3.13.5, standard library only, no network. I checked the exit codes (0 / 1 / 2), hashed the STDOUT twice to confirm it is byte-for-byte deterministic, and edited every line. The external figures I cite (the USENIX 2025 package-hallucination study) are the researchers’ numbers, not mine, and I link the source and say how they measured. I keep their numbers and my run’s numbers in separate paragraphs on purpose.

In short:

  • An SBOM and a CVE scan run after npm install. They record what resolved and whether it has a known CVE. Neither can say whether your agent should have proposed that name in the first place.
  • A coding agent recommends a dependency with the same flat confidence whether the name is real, hallucinated, or one letter off a real one. That confidence is exactly what a post-resolve scan cannot see through: a name registered yesterday has no CVE yet, so a known-CVE scan lists it as clean.
  • supply_chain_gate.py reads a manifest (the packages the agent proposed, your vouched snapshot, and your .npmrc) and returns ALLOW or DENY per package against a bundled popular baseline, before install.
  • The result that carries the argument: the same 277-name baseline that ALLOWs express (exact match) DENYs expresss in a sibling manifest. One letter flips the verdict. What flips it is default-deny against a vouched baseline, not a static blocklist of known-bad names; the edit-distance check only labels the DENY (TYPOSQUAT:express) so a human knows which real name it shadows.
  • Standard library only (json, re, difflib, sys, hashlib). No network, no resolve, no subprocess, no install, no reading of package contents. The run is byte-for-byte deterministic. The tool and all fixtures are in this post.

Where the gap is

An SBOM is a receipt. It tells you what you installed. It does not tell you that you were right to.

Here is the failure I keep seeing in agent stacks. The team wires a software bill of materials into CI. Every build emits a list of resolved packages with versions and hashes. A scanner cross-references that list against a CVE database and a known-malware feed. Green board. Everyone feels covered, because now they can see what shipped. Then a coding agent, mid-task, writes npm install some-plausible-name, the resolver pulls it, a postinstall script runs during the install itself, and the SBOM faithfully records a package that was registered eight hours ago and has no CVE against it. The scan is honest. It answered the question it was built for. It was never asked whether the agent should have proposed that name, and it has no field to put that answer in.

That is the whole post. The SBOM is a record of resolution. The decision to trust a name is a verdict against provenance. Those are two different questions, and the second one has to be answered before the first one can do any damage, because the damaging part of a malicious install often runs during install, in the postinstall hook, before your scanner ever sees the tree.

What a scan after resolve can and cannot see

Let me be precise, because it is easy to slander SBOMs here and I do not want to. An SBOM plus a CVE scan is good at what it is for. It tells you that lodash@4.17.20 has a known prototype-pollution advisory and you should bump it. It tells you which of your resolved dependencies match a published malware indicator. That is real coverage and you should keep it.

What it cannot do is flag a name for being new and unvouched, because “new and unvouched” is not a CVE. A brand-new typosquat has a clean scan by construction: no advisory has been filed against a name that did not exist last week. The scan is keyed on known-bad. The attack is unknown-bad by design. So the number that matters, the count of proposed names your post-resolve scan would wave through, is a number you currently cannot see. This tool computes exactly that number, before install, from the name itself.

Run it in sixty seconds

No keys. No network. No install beyond Python. Save two files (supply_chain_gate.py and its bundled popular_npm.json baseline), save a manifest, run one command.

A manifest is one JSON object with three parts:

  • proposed: the packages the agent wants to add, each with a name and a version. This is the equivalent of the added-dependencies diff on a package.json.
  • known_good: a vouched snapshot of names you already validated. Think of it as the lockfile role, read as data.
  • npmrc: the lines of your .npmrc, read as data. This is where a registry redirect hides.

The popular baseline is a small bundled list of well-known npm names that ships next to the tool. It is a local data file, not a network lookup. The gate uses it to measure edit-distance for typosquats and to vouch for established names by exact match.

Here is the whole tool. One file, standard library only.

#!/usr/bin/env python3
"""
supply_chain_gate.py -- a PRE-INSTALL provenance gate for the packages an AI
coding agent proposes to add, before `npm install` ever runs.

An SBOM or a CVE scan taken AFTER install answers "what got resolved". It cannot
answer "should the agent have proposed this name at all". Those are different
questions. A coding agent recommends a dependency with the same confidence
whether the name is legitimate, hallucinated, or a one-letter typosquat, and
that flat confidence is exactly what a post-resolve scan cannot see: a name
registered yesterday carries no known CVE, so a known-CVE scan lists it as
clean.

This tool returns ALLOW or DENY for each proposed package, keyed on provenance,
BEFORE install:
  * OK_VOUCHED            exact name in your vouched snapshot (lockfile)  -> ALLOW
  * OK_POPULAR            exact name in the bundled popular baseline      -> ALLOW
  * TYPOSQUAT:<popular>   edit-distance 1..2 from a popular name          -> DENY
  * HALLUCINATION_CANDIDATE  not vouched, not popular, no near neighbor   -> DENY
  * UNPINNED             version is a range / latest / *                  -> WARN

Plus one global check on the .npmrc that would steer EVERY install:
  * REGISTRY_REDIRECT    a registry= line pointing off registry.npmjs.org -> DENY
  * PLAIN_HTTP_REGISTRY  a registry URL on http:// instead of https://    -> DENY

Offline. Keyless. Read-only. Zero network. Standard library only
(json, re, difflib, sys, hashlib). It never resolves a package, contacts a
registry, spawns a child process, adds anything, or inspects package contents.
It reads local text and returns a verdict.

Exit codes (usable as a CI gate BEFORE install):
  0  every proposed package ALLOW and the .npmrc registry is trusted
  1  >=1 package DENY (typosquat / hallucination-candidate) OR a bad .npmrc
  2  bad input (unreadable / malformed manifest, or a broken popular baseline)

Usage:
  python3 supply_chain_gate.py <manifest.json> [--strict]

--strict promotes UNPINNED warnings to failures (they exit 1). Default is soft:
unpinned versions are printed but do not fail the gate on their own.
"""

import difflib
import hashlib
import json
import re
import sys

# --- explicit thresholds (tune these, don't hide them) ----------------------
TYPO_MAX_DIST = 2          # a name within this edit-distance of a popular name
RATIO_FLOOR = 0.85         # ...AND this SequenceMatcher ratio = typosquat
CANDIDATE_CUTOFF = 0.60    # broad net for difflib candidate gathering
TRUSTED_HOSTS = {"registry.npmjs.org"}

# a strict-semver exact pin: 1.2.3 with optional -prerelease / +build
PIN_RE = re.compile(r"^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.\-]+)?$")
# an .npmrc registry assignment, optionally scoped (@scope:registry=...)
REG_RE = re.compile(r"^\s*(?:(@[^\s:]+):)?registry\s*=\s*(\S+)\s*$", re.IGNORECASE)
URL_RE = re.compile(r"^(https?)://([^/:\s]+)")


def _bad(msg):
    print("ERROR: " + msg)
    raise SystemExit(2)


def _bundled_popular_path():
    """Locate popular_npm.json next to this file, without importing os."""
    here = __file__
    if "/" in here:
        return here.rsplit("/", 1)[0] + "/popular_npm.json"
    return "popular_npm.json"


def _edit_distance(a, b, cap):
    """Levenshtein distance, capped: returns cap+1 once it provably exceeds cap."""
    la, lb = len(a), len(b)
    if abs(la - lb) > cap:
        return cap + 1
    prev = list(range(lb + 1))
    for i in range(1, la + 1):
        cur = [i] + [0] * lb
        best = cur[0]
        ca = a[i - 1]
        for j in range(1, lb + 1):
            cost = 0 if ca == b[j - 1] else 1
            cur[j] = min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + cost)
            if cur[j] < best:
                best = cur[j]
        if best > cap:
            return cap + 1
        prev = cur
    return prev[lb]


def is_unpinned(version):
    if version is None:
        return True
    v = str(version).strip()
    if v == "" or v.lower() in ("latest", "*", "x"):
        return True
    return PIN_RE.match(v) is None


def load_popular():
    path = _bundled_popular_path()
    try:
        with open(path, "r") as fh:
            data = json.loads(fh.read())
    except OSError as exc:
        _bad("cannot read bundled popular baseline (%s): %s" % (path, exc))
    except json.JSONDecodeError as exc:
        _bad("bundled popular baseline is not valid JSON: %s" % exc)
    if not isinstance(data, list) or not data:
        _bad("bundled popular baseline must be a non-empty list")
    names = []
    for item in data:
        if not isinstance(item, str) or not item.strip():
            _bad("bundled popular baseline must contain only non-empty strings")
        names.append(item.strip())
    return names


def load_manifest(path):
    try:
        with open(path, "r") as fh:
            raw = fh.read()
    except OSError as exc:
        _bad("cannot read manifest: %s" % exc)
    try:
        data = json.loads(raw)
    except json.JSONDecodeError as exc:
        _bad("manifest is not valid JSON: %s" % exc)
    if not isinstance(data, dict):
        _bad("manifest must be a JSON object")
    return data


def parse_manifest(data):
    proposed = data.get("proposed")
    if not isinstance(proposed, list) or not proposed:
        _bad("manifest.proposed must be a non-empty list")
    pkgs = []
    for item in proposed:
        if not isinstance(item, dict) or not isinstance(item.get("name"), str) \
                or not item["name"].strip():
            _bad("each proposed item must be an object with a non-empty 'name'")
        pkgs.append({"name": item["name"].strip(), "version": item.get("version")})

    known_good = data.get("known_good", [])
    if not isinstance(known_good, list) \
            or any(not isinstance(x, str) for x in known_good):
        _bad("manifest.known_good must be a list of strings")

    npmrc = data.get("npmrc", [])
    if not isinstance(npmrc, list) \
            or any(not isinstance(x, str) for x in npmrc):
        _bad("manifest.npmrc must be a list of strings (lines of the .npmrc)")

    return pkgs, set(known_good), npmrc


def classify(name, known_good, popular, popular_set):
    """Return (verdict, code, detail) for one proposed package name."""
    if name in known_good:
        return "ALLOW", "OK_VOUCHED", "exact name in vouched snapshot"
    if name in popular_set:
        return "ALLOW", "OK_POPULAR", "exact name in popular baseline"
    target, best_dist = None, TYPO_MAX_DIST + 1
    for cand in difflib.get_close_matches(name, popular, n=8, cutoff=CANDIDATE_CUTOFF):
        dist = _edit_distance(name, cand, TYPO_MAX_DIST)
        ratio = difflib.SequenceMatcher(None, name, cand).ratio()
        if 1 <= dist <= TYPO_MAX_DIST and ratio >= RATIO_FLOOR:
            if dist < best_dist or (dist == best_dist and (target is None or cand < target)):
                target, best_dist = cand, dist
    if target is not None:
        return ("DENY", "TYPOSQUAT:%s" % target,
                "edit-distance %d from popular '%s'" % (best_dist, target))
    return ("DENY", "HALLUCINATION_CANDIDATE",
            "no vouch and no near neighbor within edit-distance %d" % TYPO_MAX_DIST)


def check_npmrc(lines):
    """Return list of (code, host) findings for the .npmrc registry config."""
    findings = []
    for ln in lines:
        m = REG_RE.match(ln)
        if not m:
            continue
        url = m.group(2)
        um = URL_RE.match(url)
        if not um:
            findings.append(("REGISTRY_REDIRECT", url))
            continue
        scheme, host = um.group(1).lower(), um.group(2).lower()
        if scheme == "http":
            findings.append(("PLAIN_HTTP_REGISTRY", host))
        if host not in TRUSTED_HOSTS:
            findings.append(("REGISTRY_REDIRECT", host))
    return findings


def registry_label(findings):
    if not findings:
        return "OK (registry.npmjs.org, https)"
    parts = []
    for code, host in findings:
        if code == "REGISTRY_REDIRECT":
            parts.append("REDIRECT(%s)" % host)
        else:
            parts.append("PLAIN_HTTP(%s)" % host)
    seen, ordered = set(), []
    for p in parts:
        if p not in seen:
            seen.add(p)
            ordered.append(p)
    return " + ".join(ordered)


def main(argv):
    args = [a for a in argv[1:] if not a.startswith("--")]
    strict = "--strict" in argv[1:]
    if len(args) != 1:
        print("usage: supply_chain_gate.py <manifest.json> [--strict]")
        raise SystemExit(2)

    popular = load_popular()
    popular_set = set(popular)
    data = load_manifest(args[0])
    pkgs, known_good, npmrc = parse_manifest(data)

    rows = []
    for pkg in pkgs:
        verdict, code, detail = classify(pkg["name"], known_good, popular, popular_set)
        unpinned = is_unpinned(pkg["version"])
        rows.append({"name": pkg["name"], "version": pkg["version"],
                     "verdict": verdict, "code": code, "detail": detail,
                     "unpinned": unpinned})
    rows.sort(key=lambda r: r["name"])

    findings = check_npmrc(npmrc)
    allow = [r for r in rows if r["verdict"] == "ALLOW"]
    deny = [r for r in rows if r["verdict"] == "DENY"]
    warn = [r for r in rows if r["unpinned"]]

    out = []
    out.append("SUPPLY-CHAIN GATE REPORT (pre-install, provenance)")
    out.append("baseline: %d vouched (known_good), %d popular names (bundled)"
               % (len(known_good), len(popular)))
    out.append("proposed: %d" % len(rows))
    out.append("  ALLOW: %d" % len(allow))
    out.append("  DENY:  %d" % len(deny))
    out.append("  WARN:  %d (unpinned version)" % len(warn))
    out.append("per-package (sorted by name):")
    for r in rows:
        ver = r["version"] if r["version"] is not None else "<none>"
        line = "  %s %s -> %s (%s: %s)" % (r["name"], ver, r["verdict"],
                                           r["code"], r["detail"])
        if r["unpinned"]:
            line += " [WARN: unpinned]"
        out.append(line)

    out.append("registry (.npmrc): " + registry_label(findings))
    for code, host in findings:
        out.append("  - %s: %s" % (code, host))

    deny_names = sorted(r["name"] for r in deny)
    out.append("provenance wedge:")
    out.append("  %d proposed name(s) a known-CVE scan would list as "
               "resolved / no-known-CVE" % len(deny_names))
    if deny_names:
        out.append("  (a scan keyed on known-CVE lists cannot flag a name "
                   "registered yesterday): " + ", ".join(deny_names))

    registry_bad = bool(findings)
    strict_fail = strict and bool(warn)
    if deny or registry_bad or strict_fail:
        reasons = []
        if deny:
            reasons.append("%d package(s) DENY" % len(deny))
        if registry_bad:
            reasons.append("untrusted registry in .npmrc")
        if strict_fail:
            reasons.append("%d unpinned (--strict)" % len(warn))
        out.append("VERDICT: FAIL -- " + "; ".join(reasons))
        out.append("  DENY = verify the name before install (npm view / a "
                   "provenance service); it is a signal, not a malware verdict")
        code = 1
    else:
        out.append("VERDICT: PASS -- every proposed package vouched or "
                   "popular, registry trusted")
        code = 0

    print("\n".join(out))
    raise SystemExit(code)


if __name__ == "__main__":
    main(sys.argv)

The baseline: a clean set of proposals

Start with an agent doing normal work. It proposed three packages: express, lodash, react. All three are exact matches, either in the vouched snapshot or the popular baseline, all pinned to an exact version. The .npmrc points at registry.npmjs.org over https.

$ python3 supply_chain_gate.py fixtures/clean_manifest.json
SUPPLY-CHAIN GATE REPORT (pre-install, provenance)
baseline: 5 vouched (known_good), 277 popular names (bundled)
proposed: 3
  ALLOW: 3
  DENY:  0
  WARN:  0 (unpinned version)
per-package (sorted by name):
  express 4.18.2 -> ALLOW (OK_POPULAR: exact name in popular baseline)
  lodash 4.17.21 -> ALLOW (OK_VOUCHED: exact name in vouched snapshot)
  react 18.2.0 -> ALLOW (OK_VOUCHED: exact name in vouched snapshot)
registry (.npmrc): OK (registry.npmjs.org, https)
provenance wedge:
  0 proposed name(s) a known-CVE scan would list as resolved / no-known-CVE
VERDICT: PASS -- every proposed package vouched or popular, registry trusted

Exit 0. Nothing to see, which is the point. When every name is vouched and the registry is trusted, the gate stays quiet. Note express here: exact match against the bundled baseline, so OK_POPULAR, ALLOW. Hold that thought.

The demo that makes the case

Same tool, same 277-name baseline, same shape of manifest. This time the agent got steered. It proposed expresss (one extra s, unpinned at ^4.17.0), left-pad-utils (a name that is not in the snapshot, not in the popular list, and has no near neighbor), and lodash (fine, pinned). And the .npmrc was rewritten to a raw IP over plain http.

$ python3 supply_chain_gate.py fixtures/attack_manifest.json
SUPPLY-CHAIN GATE REPORT (pre-install, provenance)
baseline: 5 vouched (known_good), 277 popular names (bundled)
proposed: 3
  ALLOW: 1
  DENY:  2
  WARN:  1 (unpinned version)
per-package (sorted by name):
  expresss ^4.17.0 -> DENY (TYPOSQUAT:express: edit-distance 1 from popular 'express') [WARN: unpinned]
  left-pad-utils 1.0.0 -> DENY (HALLUCINATION_CANDIDATE: no vouch and no near neighbor within edit-distance 2)
  lodash 4.17.21 -> ALLOW (OK_VOUCHED: exact name in vouched snapshot)
registry (.npmrc): PLAIN_HTTP(45.13.0.7) + REDIRECT(45.13.0.7)
  - PLAIN_HTTP_REGISTRY: 45.13.0.7
  - REGISTRY_REDIRECT: 45.13.0.7
provenance wedge:
  2 proposed name(s) a known-CVE scan would list as resolved / no-known-CVE
  (a scan keyed on known-CVE lists cannot flag a name registered yesterday): expresss, left-pad-utils
VERDICT: FAIL -- 2 package(s) DENY; untrusted registry in .npmrc
  DENY = verify the name before install (npm view / a provenance service); it is a signal, not a malware verdict

Exit 1. Read the two DENY lines. expresss is flagged as a typosquat, edit-distance 1 from express, the exact name that passed in the clean run. left-pad-utils is flagged as a hallucination candidate: nothing in the baseline vouches for it and nothing sits within two edits of it. And the .npmrc line, one string, trips both the redirect check and the plain-http check, so every install this config would drive resolves through an attacker-controlled registry over plain http, before a single package name matters.

That IP address, 45.13.0.7, is a made-up fixture string. It is read as text and matched with a regex. The tool never opens a socket to it, never opens anything. I want that stated plainly because a reader scanning for the word “http” in a security tool has a right to be suspicious.

Now the falsifiability test, because the whole take rests on it. My claim is that provenance of the name, judged before install, catches a substitution that a post-resolve scan waves through. If a name carried no provenance signal that a static gate could use, this argument collapses. So look at the pair. The clean run and the attack run use the identical bundled popular_npm.json. The clean run got express and returned ALLOW. The attack run got expresss and returned DENY, with TYPOSQUAT:express as the reason. One inserted letter, opposite verdicts, same baseline.

Here is the honest mechanism, because the distinction matters and a supply-chain engineer will check it. The thing that flips the verdict is not the edit-distance math. express is ALLOW because it is in the baseline; expresss is DENY because it is not, and a name nothing vouches for is denied by default. Strip the typosquat block out of the code entirely and expresss is still DENY, only relabeled HALLUCINATION_CANDIDATE instead of TYPOSQUAT:express. The edit-distance check does not change the verdict; it explains it, naming the popular name (express) the proposal shadows so a human has somewhere to look. What does the enforcing is default-deny against a vouched baseline, not a blocklist of known-bad names. That is the real difference between a denylist (which cannot catch a name it has never seen) and a default-deny allowlist keyed on provenance (which denies everything it cannot vouch for), and it is why a novel typosquat does not slip through.

The provenance wedge line is the contrarian number stated as a count. Two of the three proposed names, expresss and left-pad-utils, are the kind an attacker pre-registers off a model’s repeatable hallucination. If one were registered, which is the whole slopsquat playbook, a scan keyed on known CVE lists would have nothing on it, because there is nothing to have yet: it would resolve, land in the SBOM, and read as clean. This gate denied both before install. To be exact about what the count is: it is the number of proposed names this gate denied on provenance, and the output’s “a known-CVE scan would list as no-known-CVE” is the claim for a freshly-registered name, not a proof the tool ran a scanner. That two is a fixture number, a property of this manifest, not a measurement of your traffic. Run the tool on your own agent’s proposals to get your own count.

Why this is not the dependency-gap auditor, and not the secret-packaging probe

Two neighbors sit close enough that I want to draw the lines myself before anyone else does.

The dependency gap auditor asks a completeness question: does the manifest declare everything the source imports, so the project installs on a clean machine. It parses your code, presumes the packages are real, and measures declared-versus-imported drift. This tool asks a legitimacy question one step earlier: is the name the agent proposed something you should trust at all. There the packages are assumed real and the worry is a missing entry. Here the worry is that the entry itself is a hallucination or a typosquat. Manifest completeness versus name provenance. Different axis.

The secret packaging gap probe is also on the npm surface, but its object is secrets leaving your repo in a published tarball: what a scanner finds in the git tree versus what npm pack actually ships. That is your outbound publish. This tool is about a name coming in on install, and whether the recommendation to add it was legitimate. Secrets going out on publish versus provenance coming in on install. Different direction, different object.

Tracking is not control

This is the same line I keep drawing across this series, applied to the supply chain. An SBOM is tracking. It is a record of what flowed into the build. Tracking is necessary and I am not arguing against it. But a record written after resolution cannot prevent the resolution it records, and the expensive minute for a malicious npm package is the install itself, when the postinstall script runs. By the time the SBOM lists the package, the hook has already read your environment. For a crypto or DeFi shop that means the postinstall dropper has already reached for the keys in .env and the CI secrets in the runner. The bill of materials will tell you, accurately, which package did it. Afterward.

Control is a verdict rendered before the side effect. ALLOW or DENY on the name, before npm install, keyed on whether anything vouches for it. That is what this gate does, and it is why it sits on the same fault line as the rest: decide before, do not just record after.

The soft signal: unpinned versions

There is a fourth per-package signal that does not fail the gate by default. If a version is a range, a latest, or a wildcard, the package is marked UNPINNED and printed as a WARN. In the attack run above, expresss@^4.17.0 carried that WARN on top of its DENY. On its own, unpinned is a hardening note, not a block, because a caret range is a normal thing a human writes. If you want it to bite, pass --strict and the WARN becomes a failure.

$ python3 supply_chain_gate.py fixtures/warnonly_manifest.json
...
  WARN:  1 (unpinned version)
...
VERDICT: PASS -- every proposed package vouched or popular, registry trusted
$ python3 supply_chain_gate.py fixtures/warnonly_manifest.json --strict
...
VERDICT: FAIL -- 1 unpinned (--strict)

Same manifest, exit 0 in the default mode and exit 1 under --strict. I kept the default soft on purpose. A gate that fails on every caret range gets muted by the second week, and a muted gate catches nothing.

What the researchers measured

I have my own numbers from the run above, and they are fixture numbers. Here is a real-world figure from someone else, kept in its own paragraph so it never blurs with mine.

In We Have a Package for You! A Comprehensive Analysis of Package Hallucinations by Code Generating LLMs, presented at the 34th USENIX Security Symposium in August 2025, Spracklen and colleagues (University of Texas at San Antonio, University of Oklahoma, Virginia Tech) generated 576,000 code samples across 16 language models and found that roughly 20% of the packages the models recommended did not exist on any public registry. The detail that makes this a supply-chain problem, not a curiosity: when they re-ran the same hallucination-triggering prompt ten times, 43% of the hallucinated names came back on all ten runs. Those are their measurements, reported via Help Net Security (14 April 2025) and the USENIX paper. Repeatable hallucinations are pre-registrable: an attacker runs the popular model, sees the same fake name you will see, and registers it first. That is why “does this name have provenance” has to be asked before install, and why a scan for known-bad after install is looking in the wrong place at the wrong time.

What this is NOT

I would rather undersell this than have you deploy it as something it is not.

  • It is not a network check and does not query the npm registry. It is offline. It works off a bundled snapshot, your vouched list, and your .npmrc text. It does not prove a name is absent from npm. It proves the name is unvouched in your baseline, which is why the code is HALLUCINATION_CANDIDATE, not “hallucinated”. A DENY is a signal to verify, with npm view or a provenance service, not a confirmed verdict.
  • It is not a malware detector. It flags provenance signals: an unvouched name, a typo-distance to a real one, a registry redirect, a missing pin. A DENY is a signal to verify, not a malware verdict.
  • It will false-positive on large parts of the legitimate ecosystem, by design. Anything not in the 277-name baseline or your vouched snapshot is denied. That includes scoped packages (the baseline carries no scoped names, so @types/node and @babel/core come back HALLUCINATION_CANDIDATE), and any mainstream package that simply is not on the list. It will also mislabel a real package as a typosquat when the name sits one or two edits from a popular one: offline, it cannot know the name is itself legitimate, so it flags preact as TYPOSQUAT:react. Read TYPOSQUAT or HALLUCINATION_CANDIDATE as “stop and verify this name,” never “this name is fake.” The practical consequence is real: your known_good snapshot has to be reasonably complete for your stack, or the gate is noisy the first week and gets muted the second.
  • It assumes the public npm registry. TRUSTED_HOSTS ships with only registry.npmjs.org, so a legitimately-configured private or corporate registry (Artifactory, Nexus, Verdaccio, GitHub Packages) trips REGISTRY_REDIRECT and fails the whole gate. If that is your setup, add your registry host to TRUSTED_HOSTS before wiring this in. It is deliberately a code edit under version control, not a manifest field, so nobody can quietly redirect the trusted set from an untrusted input file.
  • It is not the dependency gap auditor. That tool measures declared-versus-imported completeness on packages presumed real. This one asks whether the proposed name is worth trusting before install.
  • It is not a replacement for Socket, Snyk, or an SBOM. Do not read the SBOM contrast as “commercial tooling only works post-resolve”; that would be a strawman, and Socket in particular is the honest exception. Its whole thesis is that CVEs are not enough, and it already does pre-install typosquat and install-script/behavioral analysis against the live registry. It overlaps with what this gate checks. The difference here is not timing, it is scope and footprint: this is the keyless, offline, zero-network, single-file cousin you can drop into CI with no account and nothing leaving the box, and read end to end in one sitting. Snyk and a plain SBOM are closer to the known-bad-and-CVE, after-resolve picture. If you run Socket, keep it. This is complementary, not a substitute.
  • It is not runtime enforcement. It does not intercept a live npm install. It reads a manifest and returns an exit code, as a CI gate you run before install. To actually block, you wire this into the step that runs before your package manager.
  • It does not measure your production. The 2 DENY and the 277-name baseline are properties of the fixtures in this post. Point it at your own agent’s proposed dependencies to get your own numbers.

Bad input fails closed

A gate that crashes open is not a gate. Feed it a manifest where proposed is a string instead of a list, and it refuses to guess.

$ python3 supply_chain_gate.py fixtures/bad_manifest.json
ERROR: manifest.proposed must be a non-empty list
$ echo $?
2

No args, an unreadable file, malformed JSON, a proposed item with no name, an .npmrc that is not a list of strings: all exit 2. Exit 2 is distinct from exit 1 on purpose, so your CI can tell “the gate denied a package” apart from “the gate could not read the input”. I hashed the full STDOUT of the two main fixtures twice to be sure the output is stable. The clean run is 627ab179634ffe7f572926993dc0d92aa2b7424991781701d40477c654219c66, the attack run is c9e31619c7c511d58991f67f6d6d777b0ff8930f98c63005480f81dc973c207d, and each matched itself across both runs.

Where this sits next to the rest

This is a new spoke on the pre-execution gate for AI agents cluster, on the supply-chain and provenance axis: allow or deny per proposed package before install. A few neighbors and how it differs:

  • The authorization gate reconciles a policy against a span log: the trace proves an action ran, not that it was allowed. Same franchise, control before rather than record after. There the object is an agent action and its telemetry. Here it is a proposed package and its provenance.
  • The MCP tool pin verifier checks that a tool manifest has not drifted from a known-good fingerprint. That is integrity of a version. Here, an unpinned version is only one of four signals, and the core is trust in the name itself.
  • The blast radius calculator scopes what a leaked key can reach. It is worth reading alongside this one, because the thing a postinstall dropper grabs during an install is exactly that key.

The question I actually want answered

Here is the real one, and I do not have a good estimate for it, so I am asking. For platform and MCP teams whose agents run npm install or pip install off their own recommendations: over the last month, how many of those proposed names would you have blocked before install, on provenance alone, and how many of those would a post-resolve scan have marked clean because the name was too new to have a CVE? Not “how many advisories did you catch”, which your scanner knows. How many unvouched names did your agent add that nobody vetted, where the record was written after the hook already ran? Export your agent’s proposed-dependency list and your vouched snapshot, run this against them, and tell me the count. I suspect for most stacks it is not zero, and I would like to be wrong.

If this was useful, follow along for the next runnable gate in the series, and drop the strangest package name your coding agent ever tried to install for something that did not exist. I read every comment.