Skip to main content
How to Block AI-Hallucinated Packages in CI: The Slopsquatting Playbook

How to Block AI-Hallucinated Packages in CI: The Slopsquatting Playbook

KU
· Founder & Chief Editor
• 19 min read•3 views

AI assistants and coding agents invent package names, and the fakes repeat. A tested CI gate, reputation checks, cooldowns, a registry proxy and agent egress rules, with the rollout plan for hundreds of repos.

Key takeaways

  • Block AI-hallucinated packages with a CI gate that diffs manifests and lockfiles and fails any new package that doesn't exist or is under 30 days old.
  • Add reputation checks, a 7-day install cooldown, and a registry proxy that every laptop, runner and coding agent must use, then enforce it org-wide with a required workflow.

To block AI-hallucinated packages, fail any pull request that adds a package, directly or through a lockfile, that doesn't exist on the registry or was first published under 30 days ago. That blunts slopsquatting, where attackers register the names models invent. Back it with a 7-day cooldown, a registry proxy and egress rules for coding agents.

Verified against

Script run on Python 3.15 against live PyPI, npm and deps.dev with npm 10 and uv 0.10 lockfiles; dependency-review-action v5.0.0, actions/checkout v7.0.1, actions/setup-python v7.1.0; pnpm, npm, Yarn, Bun, uv, pip, JFrog Curation, Sonatype Firewall, Copilot cloud agent and Claude Code docs. Checked November 2026.

Hallucinated packages are a predictable attack surface

A model inventing a package name would be a nuisance if the invention were random. It isn't. A USENIX Security 2025 paper, We Have a Package for You!, generated 576,000 Python and JavaScript code samples with 16 models and checked 2.23 million package references. 19.7% of them pointed at packages that don't exist, which came to 205,474 unique fake names.

The spread between models is wide:

Model group (2024 study)Hallucinated-package rate
Commercial models, average5.2%
Open-source models, average21.7%
Best: GPT-4 Turbo, Python3.59%
Worst: CodeLlama 7B, JavaScript35.71%
Python prompts, all models15.8%
JavaScript prompts, all models21.3%

Three findings turn this from a quality problem into a security one:

  • The names repeat. The researchers re-ran 500 prompts ten times each. 43% of hallucinated names came back in all ten runs and 58% more than once. An attacker can harvest them by asking the same questions your developers ask.
  • They aren't typos. Only 13.4% of hallucinated Python names were within two edits of a real package, and 48.6% were six or more edits away. Typosquatting detectors that look for near-misses of popular names won't flag them.
  • They cross ecosystems. 8.7% of hallucinated Python names were real packages on npm. A model blending ecosystems can send pip to a name that someone controls elsewhere, or invite them to claim it on PyPI.

Newer models are better, not immune. A July 2026 Socket write-up of an independent preprint (not yet peer reviewed) measured 4.62% to 6.10% across five frontier models in April 2026. All five invented the same 127 names, and after review 53 of those were still unregistered as of April 2026. A name every model agrees on is the most valuable one to squat. The attack has a name, coined by PSF Developer-in-Residence Seth Larson: slopsquatting.

It has moved from chat windows into agents

An early public proof was manual. Researcher Bar Lanyado noticed models suggesting pip install huggingface-cli, a package that didn't exist (the real CLI ships with huggingface_hub). He registered an empty placeholder under that name, and it drew more than 15,000 downloads in three months, by his count. An Alibaba research repository's installation instructions told users to install it. As of November 2026 the name returns 404 on PyPI.

The next wave doesn't need a human to copy a command. In January 2026, Aikido Security traced npx react-codeshift, a name an LLM blended from the real jscodeshift and react-codemod, to a batch of 47 AI-generated agent skills committed in October 2025. By the time an Aikido researcher claimed the name on npm as a safe placeholder, at least 237 GitHub repositories referenced the command, where agents read it as an instruction. An arXiv paper from July 2026 found models generating hallucinated resource names in up to 85% of repo-cloning scenarios and up to 100% of skill installs it tested.

Agents also have the network access to act on it. GitHub's Copilot cloud agent (formerly the coding agent) runs behind a firewall that is on by default, but its default "recommended allowlist" exists "to allow the agent to download dependencies" and includes the public PyPI and npm registries. An agent that hallucinates a name can install it before any human sees a diff. We cover the wider agent threat model in AI agent security.

None of the primary sources above documents a confirmed malicious slopsquatting campaign yet; every case so far is a proof of concept, a defensive registration, or an empty placeholder. That is the window to build the controls.

Why your current controls miss it

  • SCA looks for known bad. A package registered yesterday has no CVE, no advisory and no history. Your scanner reports it clean because there is nothing to report. See our SCA guide for what it does catch.
  • Typosquat detection looks for near-misses. Most hallucinated names aren't near anything.
  • Lockfiles pin what you chose. A new, hallucinated name gets resolved and locked in the same pull request that adds it.
  • Code review sees one line. httpx-retry-helper>=1.0 in a 400-line agent-authored diff looks like every other dependency.
  • SLSA doesn't cover it. SLSA v1.0 explicitly lists a compromised dependency as out of scope; it secures how your artifact is built, not which packages you chose.

The assumption that broke is that a human deliberately chose every dependency. With assistants and agents, a package can be suggested, installed and committed without anyone deciding anything.

Five layers, and what each one catches

No single control covers the problem. This is the stack we'd build, cheapest first:

LayerWhere it runsCatchesMissesEffort
1. New-package CI gatePull requestNonexistent and brand-new packages, incl. transitiveOld malicious packages; installs before the PRAn hour
2. Reputation checksPull requestPackages with no source repo, low Scorecard, known advisoriesWell-dressed malicious packagesAn afternoon
3. Install cooldownEvery installFreshly published versions and packagesAnything older than the windowOne line per tool
4. Registry proxyNetworkEverything the proxy's policy blocks, on every machineMachines that bypass itWeeks, owned by platform
5. Agent egress and instructionsAgent sandboxAgents reaching public registries directlyHumans pasting commandsA day

Layer 1: a CI gate on every new package, including transitive ones

The gate asks two questions about every package a pull request introduces: does it exist on the public registry, and is its first release more than 30 days old? It diffs requirements.txt, pyproject.toml, uv.lock, poetry.lock, package.json and package-lock.json against the base branch. Diffing lockfiles matters: when a new direct dependency drags in five packages you've never had, all six get checked. Existing dependencies are never re-checked, so a repo with 2,000 packages doesn't fail on day one.

scripts/check_new_deps.py
"""Fail CI when a PR adds a package that doesn't exist or is too new.

Diffs manifests AND lockfiles against the base branch, so packages pulled in
transitively by a new dependency are checked too. Python 3.11+, stdlib only.
"""
import argparse, json, re, subprocess, sys, tomllib, urllib.error, urllib.parse, urllib.request
from datetime import datetime, timezone

def read(path, ref=None):
    if ref:
        r = subprocess.run(["git", "show", f"{ref}:{path}"], capture_output=True, text=True)
        return r.stdout if r.returncode == 0 else ""
    try:
        return open(path, encoding="utf-8").read()
    except FileNotFoundError:
        return ""

def pep503(name):
    return re.sub(r"[-_.]+", "-", name).lower()

def req_name(spec):
    m = re.match(r"^\s*([A-Za-z0-9][A-Za-z0-9._-]*)", spec)
    return pep503(m.group(1)) if m else None

def requirements_txt(text):   # skips -r, -e and --index-url lines
    return {n for line in text.splitlines() if (n := req_name(line.split("#", 1)[0]))}

def pyproject(text):
    p = tomllib.loads(text).get("project", {}) if text.strip() else {}
    specs = p.get("dependencies", []) + [s for g in p.get("optional-dependencies", {}).values() for s in g]
    return {n for s in specs if (n := req_name(s))}

def toml_lock(text):          # uv.lock and poetry.lock: [[package]] name = "..."
    return {pep503(p["name"]) for p in (tomllib.loads(text).get("package", []) if text.strip() else [])}

def package_json(text):
    d = json.loads(text) if text.strip() else {}
    return {n for k in ("dependencies", "devDependencies", "optionalDependencies") for n in d.get(k) or {}}

def package_lock(text):       # lockfileVersion 2/3: "node_modules/a/node_modules/@s/b"
    pkgs = (json.loads(text) if text.strip() else {}).get("packages", {})
    return {k.rsplit("node_modules/", 1)[1] for k, v in pkgs.items()
            if "node_modules/" in k and not v.get("link")}

SOURCES = [("pypi", "requirements.txt", requirements_txt), ("pypi", "pyproject.toml", pyproject),
           ("pypi", "uv.lock", toml_lock), ("pypi", "poetry.lock", toml_lock),
           ("npm", "package.json", package_json), ("npm", "package-lock.json", package_lock)]

def get(url):
    req = urllib.request.Request(url, headers={"User-Agent": "dep-gate/2.0"})
    try:
        with urllib.request.urlopen(req, timeout=15) as r:
            return json.load(r)
    except urllib.error.HTTPError as e:
        if e.code == 404:
            return None
        raise

def ts(s):
    return datetime.fromisoformat(s.replace("Z", "+00:00"))

def first_release(eco, name):
    """Earliest upload time, None if the package doesn't exist, now() if it has no files."""
    if eco == "pypi":
        d = get(f"https://pypi.org/pypi/{name}/json")
        t = [f["upload_time_iso_8601"] for fs in (d or {}).get("releases", {}).values() for f in fs]
    else:
        d = get("https://registry.npmjs.org/" + urllib.parse.quote(name, safe="@"))
        t = [(d or {}).get("time", {}).get("created")] if (d or {}).get("time", {}).get("created") else []
    if d is None:
        return None
    return min(map(ts, t)) if t else datetime.now(timezone.utc)

def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--base", default="origin/main")
    ap.add_argument("--min-age-days", type=int, default=30)
    ap.add_argument("--allowlist", default=".dep-allowlist")
    a = ap.parse_args()
    allow = {l.strip() for l in read(a.allowlist).splitlines() if l.strip() and l[0] != "#"}
    added = set()
    for eco, path, parse in SOURCES:
        added |= {(eco, n) for n in parse(read(path)) - parse(read(path, a.base))}
    now, failed = datetime.now(timezone.utc), False
    for eco, name in sorted(added):
        key = f"{eco}:{name}"
        if key in allow:
            print(f"SKIP  {key} (allowlisted)")
            continue
        created = first_release(eco, name)
        if created is None:
            print(f"FAIL  {key} does not exist on the registry"); failed = True
        elif (age := (now - created).days) < a.min_age_days:
            print(f"FAIL  {key} first published {age} days ago (minimum {a.min_age_days})"); failed = True
        else:
            print(f"OK    {key} ({age} days old)")
    if failed:
        print(f"\nLegitimate? Add '<ecosystem>:<name>' to {a.allowlist} in a reviewed commit.")
    return int(failed)

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

We ran it on a branch that adds axios to an npm project, httpx to a uv project, and four lines to requirements.txt: one package published that morning and three names no registry has heard of, including huggingface-cli. Trimmed to the interesting lines:

Bash
python3 scripts/check_new_deps.py --base main
OK    npm:axios (4458 days old)
OK    npm:follow-redirects (5110 days old)
OK    npm:proxy-from-env (3920 days old)
OK    pypi:anyio (2954 days old)
FAIL  pypi:flask-jwt-session-guard does not exist on the registry
OK    pypi:httpx (2674 days old)
FAIL  pypi:huggingface-cli does not exist on the registry
FAIL  pypi:trackwright first published 0 days ago (minimum 30)
OK    pypi:typing-extensions (3344 days old)

The full run checked 19 new packages, 15 of them transitive, and exited 1. Age means the package's first release, not its latest version, so a new release of a ten-year-old library passes; layer 3 handles new versions. Allowlist entries use the normalized PyPI name (pypi:foo-bar).

Make it a required check. Don't add a paths: filter: GitHub leaves a required check that a path filter skipped in "Pending", which blocks the merge.

.github/workflows/dependency-gate.yml
name: dependency-gate
on: pull_request
permissions:
  contents: read
jobs:
  gate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          fetch-depth: 0   # the script reads the base branch
      - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
        with:
          python-version: "3.13"
      - run: python scripts/check_new_deps.py --base "origin/${{ github.base_ref }}"

Then protect the escape hatch: add /.dep-allowlist @your-org/platform-security to CODEOWNERS, so an exception needs someone other than the author. Our GitHub Actions hardening guide covers why the actions are pinned by SHA.

Layer 2: reputation, so "exists and is old" isn't enough

A patient attacker registers a predicted name months early. To catch that, look at who stands behind the package. Two free sources do most of the work.

GitHub's dependency review action (v5.0.0) fails a pull request on vulnerable dependencies, denies specific packages by purl, and shows the OpenSSF Scorecard of each changed dependency that has one. On private repositories it needs GitHub Code Security or Advanced Security.

YAML
      - uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0
        with:
          fail-on-severity: moderate
          show-openssf-scorecard: true
          warn-on-openssf-scorecard-level: 3
          deny-packages: "pkg:npm/react-codeshift"
          comment-summary-in-pr: on-failure   # needs pull-requests: write

The deps.dev API answers the questions a reviewer should ask about an unfamiliar package: when the version was published, whether it has SLSA provenance, which repository it claims, and how that repository scores.

Bash
API=https://api.deps.dev/v3 SYS=npm PKG=axios
VER=$(curl -s "$API/systems/$SYS/packages/$PKG" | jq -r '.versions[] | select(.isDefault) | .versionKey.version')
curl -s "$API/systems/$SYS/packages/$PKG/versions/$VER" | jq '{publishedAt, slsaProvenances: (.slsaProvenances | length),
    repo: [.relatedProjects[] | select(.relationType == "SOURCE_REPO") | .relationProvenance]}'
{
  "publishedAt": "2026-11-09T16:17:55Z",
  "slsaProvenances": 1,
  "repo": [
    "UNVERIFIED_METADATA",
    "SLSA_ATTESTATION"
  ]
}
curl -s "$API/projects/github.com%2Faxios%2Faxios" | jq '.scorecard.overallScore'
7.7

A source repo linked by SLSA_ATTESTATION comes from signed build provenance; UNVERIFIED_METADATA only means the package says so. A brand-new package with one maintainer, no linked repository and no Scorecard is the profile of a squatted name. Note that Scorecard's "Maintained" check only passes for GitHub projects older than 90 days, so a low score on a young project is expected rather than damning.

Layer 3: install-time cooldowns

The gate controls which packages you add. A cooldown refuses any version published in the last N days, which protects against hijacked releases of popular packages as well as freshly squatted names. Every tool below supports it, in a different unit; we covered the npm side in hardening CI against npm worms.

Tool7-day settingWhereUnitSince
pnpmminimumReleaseAge: 10080pnpm-workspace.yamlminutes10.16
npmmin-release-age=7.npmrcdays11.10.0
YarnnpmMinimalAgeGate: "7d".yarnrc.ymlduration4.11 (minutes in 4.10)
BunminimumReleaseAge = 604800bunfig.toml [install]seconds1.3
uvexclude-newer = "7 days"pyproject.toml [tool.uv]duration0.9.17
pip--uploaded-prior-to P7DCLI or PIP_UPLOADED_PRIOR_TOISO days26.1
Dependabotcooldown: default-days: 7.github/dependabot.ymldayshosted
RenovateminimumReleaseAge: "7 days"renovate.jsondurationn/a

Four units across eight tools. Copying 7 from npm into pnpm gives you a seven-minute cooldown. Most also take an exclusion list (minimumReleaseAgeExclude in pnpm, npmPreapprovedPackages in Yarn) for your own internal packages, which you want immediately.

Layer 4: a registry proxy is the real firewall

By the time a pull request opens, pip install has already run on a laptop or inside an agent sandbox, and any install script has already executed. CI is the audit trail. The control point that sees every install is an internal registry proxy that every machine is forced to use.

INI
# /etc/pip.conf (or PIP_INDEX_URL)
[global]
index-url = https://artifacts.example.internal/api/pypi/pypi-remote/simple

# .npmrc (or npm_config_registry)
registry=https://artifacts.example.internal/api/npm/npm-remote/

# pyproject.toml for uv (or UV_DEFAULT_INDEX)
[[tool.uv.index]]
name = "internal"
url = "https://artifacts.example.internal/api/pypi/pypi-remote/simple"
default = true

The proxy only becomes a firewall when it enforces policy at fetch time. JFrog Curation has a "Package version is immature" condition that blocks versions younger than a set number of days, and a "Malicious package" condition fed by the JFrog Security Research team. Sonatype Repository Firewall rates release integrity at the proxy and quarantines suspicious components while Sonatype's research team reviews them. Then close the side door: block direct egress from CI runners, developer VPN profiles and agent sandboxes to pypi.org, files.pythonhosted.org and registry.npmjs.org, or the proxy is optional.

Reserve your internal package names on the public registries, or scope them (@acme/utils on npm). A private name that doesn't exist publicly fails the CI gate correctly, and invites dependency confusion if someone else registers it; our guide to software supply chain attacks covers that pattern.

Layer 5: constrain coding agents

Agents are the newest route around review, so give them the narrowest network and the clearest instructions.

  • Point the agent at the proxy, not the internet. In the Copilot cloud agent's firewall settings, turn off the recommended allowlist and add only your proxy as a custom rule. In Claude Code's sandbox, sandbox.network.allowedDomains starts empty; list the proxy and set sandbox.network.strictAllowlist: true in managed or user settings (a repo's .claude/settings.json can't set it) so unlisted hosts are refused instead of prompted.
  • Write the rule where the agent reads it. One line in AGENTS.md or your instructions file: never add a dependency without stating why, and never install a package the registry proxy rejects.
  • Audit the instructions themselves. The react-codeshift case spread through skill files. Grep the files agents treat as instructions for install commands and check each name with the gate:
Bash
grep -rnoE '(npx|uvx|pip install|npm (i|install)) [@a-z0-9._/-]+' \
    AGENTS.md CLAUDE.md .github/copilot-instructions.md .claude/ .agents/ skills/ 2>/dev/null

Rolling it out across hundreds of repositories

A gate in one repo is a demo. At company scale, the work is enforcement and exception handling.

  1. Centralize the workflow. Put the gate in a shared repository and require it everywhere with an organization ruleset's "Require workflows to pass before merging" rule (GitHub documents this rule for Enterprise Cloud). Repos can't opt out by deleting a file.
  2. Run report-only for two weeks. Add continue-on-error: true and collect every FAIL. That list is your baseline and your false-positive budget.
  3. Staff the exception path. A platform-security CODEOWNER, a target turnaround (same business day is a good default), and a ticket for anything allowlisted so it gets re-reviewed.
  4. Measure three numbers. Packages blocked, exceptions requested, and median time to approve. A rising exception rate with instant approvals means the gate has become a rubber stamp.
  5. Map it to the framework you already report on. NIST's SSDF (SP 800-218) practice PW.4.4 asks you to verify that acquired open-source components meet your organization's requirements throughout their life cycle. This stack is a concrete, auditable answer.

Where this gate breaks

Patient attackers beat the age check. A name registered 90 days ago passes layer 1. Layers 2 and 4 exist for that case.

CI is too late for laptops and agents. Install scripts run at install time. Only the proxy and the agent egress rules act before that.

Coverage is what you parse. The script reads six file types. It misses pip install lines in Dockerfiles and notebooks, Yarn and pnpm lockfiles, and other ecosystems (Go, Maven, Cargo), although the same two checks work against any registry with a metadata API.

Registry lookups have limits. A lockfile bump that adds hundreds of packages makes hundreds of API calls. Cache results or route the lookups through your proxy's API in large monorepos.

False positives are real. Legitimate AI libraries appear weekly. If approvals become reflexive, the gate is theater.

Questions people ask

What is slopsquatting?

Slopsquatting is registering a package under a name that AI coding assistants hallucinate, so developers or agents who trust the suggestion install the attacker's code. It differs from typosquatting because the name comes from the model, not a slip of the keyboard, and most hallucinated names aren't close to any real package. The term was coined by Seth Larson, Developer-in-Residence at the Python Software Foundation.

How common are hallucinated packages?

In the USENIX Security 2025 study, 19.7% of 2.23 million package references in AI-generated code pointed at packages that don't exist: 5.2% on average for commercial models and 21.7% for open-source ones. A 2026 replication on frontier models measured roughly 4.6% to 6.1%. The names repeat: 43% came back in all ten re-runs of the same prompt.

How do I check if a package is hallucinated?

Query the registry before installing. A 404 from https://pypi.org/pypi/<name>/json or from npm view <name> means it doesn't exist. If it does exist, check its first publish date, its maintainers, and whether deps.dev links it to a real source repository, ideally through a SLSA attestation. A week-old package with one maintainer and no repository is a red flag.

Does a dependency cooldown stop slopsquatting?

Only partly. A cooldown blocks versions published in the last N days, so a name registered yesterday can't install, but one registered months ago passes. Cooldowns are strongest against hijacked releases of popular packages. Combine them with a new-package gate, reputation checks and a registry proxy.

Are coding agents riskier than chat assistants?

Yes, because they act without a human in between. An agent can read a hallucinated command from a skill file and run it in the same step. GitHub's Copilot cloud agent, for one, can reach public registries by default. Restrict agent egress to your registry proxy and audit the instruction files they read.

The decision, concretely

  • Agents or assistants edit your manifests? Ship the layer 1 gate report-only this week, required in two.
  • Need the cheapest win? A 7-day cooldown in every package manager and in Dependabot or Renovate, minding the units.
  • Running hundreds of repos? Centralize the gate as an org-required workflow and staff the exception path before you flip it on.
  • Laptops, runners and agents reach PyPI or npm directly? The registry proxy plus blocked egress is the project that actually closes the hole.

The call we'd make

Ship the CI gate and a 7-day cooldown this week; together they shut the cheap version of the attack and give you an audit trail of every new package. Then fund the registry proxy and agent egress rules as a platform project, because they're the only controls that act before an install script runs. Write one rule into your engineering guidelines: a dependency suggested by AI is an untrusted external contribution. The caveat is that existence and age only filter lazy attackers; reputation and the proxy handle the patient ones.

Sources

React
Share this post
Cite this article
Plain text

Kiril Urbonas (2026). How to Block AI-Hallucinated Packages in CI: The Slopsquatting Playbook. DevOpsNess. https://www.devopsness.com/blog/block-ai-hallucinated-packages-ci

Markdown

[How to Block AI-Hallucinated Packages in CI: The Slopsquatting Playbook](https://www.devopsness.com/blog/block-ai-hallucinated-packages-ci) (Kiril Urbonas, DevOpsNess, 2026-11-13)

KU

Kiril Urbonas

Founder & Chief Editor

596 articles
View all articles by Kiril Urbonas

You might have missed

Evergreen posts worth revisiting.