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.