# Stop Slopsquatting With a CI Gate, Not a Better Prompt

> Source: <https://dev.to/jaryn_123/stop-slopsquatting-with-a-ci-gate-not-a-better-prompt-480m>
> Published: 2026-07-31 09:04:23+00:00

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.
