{"slug": "stop-slopsquatting-with-a-ci-gate-not-a-better-prompt", "title": "Stop Slopsquatting With a CI Gate, Not a Better Prompt", "summary": "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.", "body_md": "A coding assistant proposes `pip install requests-utils-pro`\n\nat 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.\n\nThis 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.\n\nTreat every model-suggested package name as untrusted input until it matches three things:\n\nThe gate should run before install, before lockfile update, and before any registry credential is mounted.\n\nLabel: 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.\n\n`gate.py`\n\n:\n\n``` python\nimport argparse, json, re, sys, difflib\nfrom pathlib import Path\n\nNAME = re.compile(r'^[A-Za-z0-9][A-Za-z0-9._-]*$')\n\ndef canon(name):\n    return re.sub(r'[-_.]+', '-', name).lower()\n\ndef load_lock(path):\n    locked = {}\n    for line in Path(path).read_text().splitlines():\n        line = line.strip()\n        if not line or line.startswith('#'):\n            continue\n        if '==' not in line:\n            print(json.dumps({'ok': False, 'reason': 'unpinned_lock_line', 'line': line}))\n            sys.exit(2)\n        n, v = line.split('==', 1)\n        locked[canon(n)] = v.strip()\n    return locked\n\ndef near_hit(name, locked, threshold=0.86):\n    for known in locked:\n        if known != name and difflib.SequenceMatcher(None, name, known).ratio() >= threshold:\n            return known\n    return None\n\ndef main():\n    ap = argparse.ArgumentParser()\n    ap.add_argument('--lock', required=True)\n    ap.add_argument('--allow', required=True)\n    ap.add_argument('--proposed', required=True)\n    args = ap.parse_args()\n\n    locked = load_lock(args.lock)\n    allow = {canon(x['name']): x for x in json.loads(Path(args.allow).read_text())['packages']}\n    findings = []\n\n    for raw in Path(args.proposed).read_text().splitlines():\n        raw = raw.strip()\n        if not raw or raw.startswith('#'):\n            continue\n        name = canon(raw.split('==', 1)[0])\n        item = {'input': raw, 'name': name, 'ok': False}\n        if not NAME.match(raw.split('==', 1)[0]):\n            item['reason'] = 'invalid_package_name'\n        elif name not in allow:\n            item['reason'] = 'not_in_allowlist'\n            item['near'] = near_hit(name, locked)\n        elif name not in locked:\n            item['reason'] = 'not_in_lockfile'\n            item['near'] = near_hit(name, locked)\n        elif '==' in raw and raw.split('==', 1)[1] != locked[name]:\n            item['reason'] = 'version_differs_from_lock'\n        elif allow[name].get('source') != 'pypi.org':\n            item['reason'] = 'unapproved_source'\n        else:\n            item['ok'] = True\n            item['version'] = locked[name]\n        findings.append(item)\n\n    ok = all(x['ok'] for x in findings)\n    print(json.dumps({'ok': ok, 'findings': findings}, indent=2, sort_keys=True))\n    sys.exit(0 if ok else 1)\n\nif __name__ == '__main__':\n    main()\n```\n\nFixtures:\n\n```\ncat > requirements.lock <<'EOF'\nrequests==2.32.3\nurllib3==2.2.2\nEOF\n\ncat > allowlist.json <<'EOF'\n{'packages':[\n  {'name':'requests','source':'pypi.org','owner':'platform'},\n  {'name':'urllib3','source':'pypi.org','owner':'platform'}\n]}\nEOF\n\ncat > positive.txt <<'EOF'\nrequests==2.32.3\nEOF\n\ncat > negative.txt <<'EOF'\nrequests-utils-pro\nreqeusts==2.32.3\nurllib3==9.9.9\nEOF\n\npython3 gate.py --lock requirements.lock --allow allowlist.json --proposed positive.txt\npython3 gate.py --lock requirements.lock --allow allowlist.json --proposed negative.txt\n```\n\nExpected evidence: the positive run exits 0; the negative run exits 1 and reports `not_in_allowlist`\n\nfor `requests-utils-pro`\n\n, a near-neighbor note for `reqeusts`\n\n, and `version_differs_from_lock`\n\nfor `urllib3==9.9.9`\n\n. 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.\n\nUse 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`\n\n, 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.\n\nA minimal CI shape:\n\n```\n- name: dependency-gate\n  run: |\n    set -euo pipefail\n    test ! -e ~/.netrc\n    env | grep -E 'PYPI|NPM|TOKEN|SECRET' && exit 9 || true\n    python3 gate.py --lock requirements.lock --allow allowlist.json --proposed ai_suggested.txt\n```\n\nPrevent, detect, recover:\n\n| Layer | Prevent | Detect | Recover |\n|---|---|---|---|\n| Proposal | assistant output is text-only | every suggestion logged with prompt hash | discard runner, rotate nothing because no secrets exist |\n| Install | gate before pip and lock update | exit 1 on name, source, or version drift | revert lockfile, require human exact-name approval |\n| Runtime | egress deny by default | alert on new domain after dependency change | revoke token, pin last known-good lock, rebuild from clean cache |\n\nThis 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.\n\nThe 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.", "url": "https://wpnews.pro/news/stop-slopsquatting-with-a-ci-gate-not-a-better-prompt", "canonical_source": "https://dev.to/jaryn_123/stop-slopsquatting-with-a-ci-gate-not-a-better-prompt-480m", "published_at": "2026-07-31 09:04:23+00:00", "updated_at": "2026-07-31 09:39:19.303535+00:00", "lang": "en", "topics": ["ai-safety", "developer-tools", "artificial-intelligence"], "entities": ["MonkeyCode", "Python", "requests-utils-pro"], "alternates": {"html": "https://wpnews.pro/news/stop-slopsquatting-with-a-ci-gate-not-a-better-prompt", "markdown": "https://wpnews.pro/news/stop-slopsquatting-with-a-ci-gate-not-a-better-prompt.md", "text": "https://wpnews.pro/news/stop-slopsquatting-with-a-ci-gate-not-a-better-prompt.txt", "jsonld": "https://wpnews.pro/news/stop-slopsquatting-with-a-ci-gate-not-a-better-prompt.jsonld"}}