cd /news/ai-safety/stop-slopsquatting-with-a-ci-gate-no… · home topics ai-safety article
[ARTICLE · art-81617] src=dev.to ↗ pub= topic=ai-safety verified=true sentiment=· neutral

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.

read4 min views1 publishedJul 31, 2026

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

:

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.

── more in #ai-safety 4 stories · sorted by recency
── more on @monkeycode 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/stop-slopsquatting-w…] indexed:0 read:4min 2026-07-31 ·