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.
Boundary rule
Treat every model-suggested package name as untrusted input until it matches three things:
- It is already in the pinned lockfile, or explicitly added by a human.
- It is on an allowlist that names the package, the approved source, and the owning team.
- It is not a near-neighbor of a high-value package unless a human signs the exact name.
The gate should run before install, before lockfile update, and before any registry credential is mounted.
Runnable fixture: pre-install dependency gate
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.
Where the free server fits without widening trust
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 |
Limitations and who should not use this
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.
Top comments (0)