Stop Slopsquatting With a CI Gate, Not a Better Prompt A developer at MonkeyCode built a CI gate to block 'slopsquatting,' where AI coding assistants suggest malicious package names that are then installed without verification. The gate, written in Python 3.11 stdlib, checks proposed dependencies against an allowlist and lockfile before installation, using fuzzy matching to flag near-miss names. It is model-agnostic and can be used with any coding assistant. A coding assistant proposes pip install requests-utils-pro at 09:41. By 09:43 the dependency is in a pull request, by 09:47 CI is green because the install worked, and by 10:15 the package has egress to a domain no reviewer typed. The failure is not that a model hallucinated. The failure is that a suggestion crossed the install boundary without proving it was already inside your dependency universe. This article builds a small gate for that boundary. It is model-agnostic and useful with a local LLM, a hosted coding assistant, or no assistant at all. Disclosure: This article was prepared as part of MonkeyCode's product outreach. I used MonkeyCode's free model access and free server option only as an isolated place to generate candidate dependency suggestions and run the gate; I did not assume quotas, model names, permanence, hardware, or registry access, and the workflow below still stands if you remove MonkeyCode and use any other runner. Treat every model-suggested package name as untrusted input until it matches three things: The gate should run before install, before lockfile update, and before any registry credential is mounted. Label: runnable template, not a claim that I found a malicious package. It uses Python 3.11+ stdlib only and no network. Pin your own tool versions in CI. gate.py : python import argparse, json, re, sys, difflib from pathlib import Path NAME = re.compile r'^ A-Za-z0-9 A-Za-z0-9. - $' def canon name : return re.sub r' - . +', '-', name .lower def load lock path : locked = {} for line in Path path .read text .splitlines : line = line.strip if not line or line.startswith ' ' : continue if '==' not in line: print json.dumps {'ok': False, 'reason': 'unpinned lock line', 'line': line} sys.exit 2 n, v = line.split '==', 1 locked canon n = v.strip return locked def near hit name, locked, threshold=0.86 : for known in locked: if known = name and difflib.SequenceMatcher None, name, known .ratio = threshold: return known return None def main : ap = argparse.ArgumentParser ap.add argument '--lock', required=True ap.add argument '--allow', required=True ap.add argument '--proposed', required=True args = ap.parse args locked = load lock args.lock allow = {canon x 'name' : x for x in json.loads Path args.allow .read text 'packages' } findings = for raw in Path args.proposed .read text .splitlines : raw = raw.strip if not raw or raw.startswith ' ' : continue name = canon raw.split '==', 1 0 item = {'input': raw, 'name': name, 'ok': False} if not NAME.match raw.split '==', 1 0 : item 'reason' = 'invalid package name' elif name not in allow: item 'reason' = 'not in allowlist' item 'near' = near hit name, locked elif name not in locked: item 'reason' = 'not in lockfile' item 'near' = near hit name, locked elif '==' in raw and raw.split '==', 1 1 = locked name : item 'reason' = 'version differs from lock' elif allow name .get 'source' = 'pypi.org': item 'reason' = 'unapproved source' else: item 'ok' = True item 'version' = locked name findings.append item ok = all x 'ok' for x in findings print json.dumps {'ok': ok, 'findings': findings}, indent=2, sort keys=True sys.exit 0 if ok else 1 if name == ' main ': main Fixtures: cat requirements.lock <<'EOF' requests==2.32.3 urllib3==2.2.2 EOF cat allowlist.json <<'EOF' {'packages': {'name':'requests','source':'pypi.org','owner':'platform'}, {'name':'urllib3','source':'pypi.org','owner':'platform'} } EOF cat positive.txt <<'EOF' requests==2.32.3 EOF cat negative.txt <<'EOF' requests-utils-pro reqeusts==2.32.3 urllib3==9.9.9 EOF python3 gate.py --lock requirements.lock --allow allowlist.json --proposed positive.txt python3 gate.py --lock requirements.lock --allow allowlist.json --proposed negative.txt Expected evidence: the positive run exits 0; the negative run exits 1 and reports not in allowlist for requests-utils-pro , a near-neighbor note for reqeusts , and version differs from lock for urllib3==9.9.9 . Fix the allowlist JSON to real JSON before CI; the heredoc above is intentionally readable, so convert single quotes to double quotes in your repo. Use a disposable runner only to do work that is already safe to do badly: ask the model to explain why a dependency is needed, enumerate alternatives, and draft the proposed line. Then run the gate with no PIP INDEX URL , no registry token, no SSH key, and egress blocked except to a logging sink. If the runner is compromised, the blast radius should be a throwaway CPU budget, not your package mirror. A minimal CI shape: - name: dependency-gate run: | set -euo pipefail test -e ~/.netrc env | grep -E 'PYPI|NPM|TOKEN|SECRET' && exit 9 || true python3 gate.py --lock requirements.lock --allow allowlist.json --proposed ai suggested.txt Prevent, detect, recover: | Layer | Prevent | Detect | Recover | |---|---|---|---| | Proposal | assistant output is text-only | every suggestion logged with prompt hash | discard runner, rotate nothing because no secrets exist | | Install | gate before pip and lock update | exit 1 on name, source, or version drift | revert lockfile, require human exact-name approval | | Runtime | egress deny by default | alert on new domain after dependency change | revoke token, pin last known-good lock, rebuild from clean cache | This is a name-and-policy gate, not a malware scanner. It will not prove a package is safe, catch a compromised legitimate release, replace sigstore or SLSA provenance, or justify autonomous installs. Do not use it as your only control in regulated production deploys, in repos that cannot keep a deterministic lockfile, or where maintainers will rubber-stamp every near-neighbor exception. If a suggestion is both outside the allowlist and close to a critical package, require a human to type the exact package name into the exception record; never let the model approve its own spelling. The invariant worth putting in CI is simple: no model-generated token sequence becomes an installed dependency unless it was already pinned, approved, and boring. Which layer in your stack should enforce that: the assistant client, the CI job, or the registry proxy? If you want a low-risk place to rehearse the gate, a free MonkeyCode server can be the disposable runner; keep the credentials somewhere else.