If a model emits pip install or npm install, I do not run it. I parse the line, query the public registry, and compare the name to a lockfile-derived allowlist. Missing from the registry? Unknown to the allowlist? The gate exits non-zero. That is the method.
Why so harsh? Generated tutorials look complete. They are not evidence. A fluent install command is still untrusted input.
I keep hitting the same failure in agent-style chats: the model assumes a package exists, assumes the import path, assumes my Python version. I paste. My shell becomes the last line of defense. That is a bad last line. Did you actually run the last install a model wrote for you, or did you just trust the fence?
This is a from-zero gate. Each stage has a verification command. No dashboard. The interesting work is deterministic on purpose.
What you will have
A single script, dep_gate.py, that:
- Reads a model reply from a file.
- Extracts install intents with regex, not with another model.
- Checks PyPI or npm for existence.
- Checks an allowlist built from your pins.
- Prints JSON and fails closed.
The model can draft a patch. The model does not get to decide whether a name is real.
Stage 0 — Fingerprint the project you already have
Do not start by prompting. Start by listing what is already pinned. If you skip this, the gate has nothing honest to compare against.
mkdir -p .gate
cd .gate
find .. -maxdepth 3 \(
-name 'requirements*.txt' -o -name 'pyproject.toml' -o \
-name 'package-lock.json' -o -name 'poetry.lock' -o \
-name 'pnpm-lock.yaml' -o -name 'yarn.lock'
\) | sort
Verification:
test -s ../requirements.txt || test -f ../package-lock.json
echo "fingerprint: ok"
If both are missing, stop. A gate with no allowlist will either block everything or allow everything. Neither is useful. Freeze dependencies first.
I flatten names into allowlist.txt so day one does not require five lock parsers.
python3 - <<'PY'
import json, re, pathlib
root = pathlib.Path("..")
names = set()
req = root / "requirements.txt"
if req.exists():
for line in req.read_text().splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
names.add(re.split(r"[<>=!~\[]", line, 1)[0].strip().lower())
lock = root / "package-lock.json"
if lock.exists():
data = json.loads(lock.read_text())
names.update(d.lower() for d in (data.get("dependencies") or {}))
for k in (data.get("packages") or {}):
if k.startswith("node_modules/"):
names.add(k.split("node_modules/")[-1].lower())
path = pathlib.Path("allowlist.txt")
path.write_text("\n".join(sorted(names)) + ("\n" if names else ""))
print(f"allowlist_count={len(names)}")
PY
Verification:
wc -l allowlist.txt
Zero lines is legal for a greenfield demo. It is not legal for a brownfield app. Know which one you are in.
Stage 1 — Bound the prompt. Do not dump the repo.
I ask for a small change and paste only the file in scope. Whole-repo dumps invite invented helpers. Invented helpers invite invented packages. Want a surprise utils-extra library that does not exist? Dump five directories into chat.
Prompt I use:
You are editing ONE file. Do not add dependencies unless the file cannot
import what it needs from this allowlist:
ALLOWLIST:
<paste allowlist.txt>
FILE:
<paste the target file>
Return: (1) the patched file (2) a shell block with any install commands.
If you need a package that is not on the allowlist, say NEED_NEW_DEP and stop.
I drafted that prompt with MonkeyCode's free model access, then later parked the webhook from Stage 5 on the free server option so the gate sits next to the chat instead of only on my laptop. Disclosure: This article was prepared as part of MonkeyCode's product outreach. I am not claiming a model name, a quota, hardware, or a benchmark. Strip the product mention and the gate still works in any chat box.
Save the reply as a file. Do not pipe it to a shell.
# paste the raw model output, then Ctrl-D
cat > model_reply.txt
Verification:
test -s model_reply.txt
wc -c model_reply.txt
If the file is huge, you already lost the bound. Cut it. The gate can only judge what you saved.
Stage 2 — Extract install intents without a model
Do not ask a model to "find the dangerous bits." That is a second hallucination about the first one. Regex is boring. Boring is the point.
Save this as dep_gate.py. It is the whole artifact.
#!/usr/bin/env python3
"""Fail closed on AI-suggested install commands. Example script — run locally."""
from __future__ import annotations
import json
import re
import sys
import urllib.error
import urllib.request
from pathlib import Path
INSTALL_RE = re.compile(
r"""(?mx)
^\s*(?:sudo\s+)?(?:
pip(?:3)?\s+install
| python(?:3)?\s+-m\s+pip\s+install
| poetry\s+add
| npm\s+install
| npm\s+i
| yarn\s+add
| pnpm\s+add
)\s+(.+)$
"""
)
FLAG = re.compile(r"^[-.]")
NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*")
def ecosystem(cmd: str) -> str:
c = cmd.lower()
if any(k in c for k in ("npm", "yarn", "pnpm")):
return "npm"
return "pypi"
def tokenize(tail: str):
for tok in tail.replace("\\", " ").split():
tok = tok.strip()
if not tok or FLAG.match(tok) or tok.endswith(".txt"):
continue
tok = re.split(r"[<>=!~@]", tok, 1)[0]
if NAME.match(tok):
yield tok
def extract(text: str) -> list[dict]:
found = []
for m in INSTALL_RE.finditer(text):
line = m.group(0)
eco = ecosystem(line)
for name in tokenize(m.group(1)):
found.append({"raw": line.strip(), "name": name, "ecosystem": eco})
return found
def registry_exists(eco: str, name: str, timeout: int = 8) -> dict:
if eco == "npm":
url = "https://registry.npmjs.org/" + urllib.request.quote(name)
else:
url = f"https://pypi.org/pypi/{urllib.request.quote(name)}/json"
req = urllib.request.Request(url, headers={"User-Agent": "dep-gate/0.1"})
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
return {"ok": True, "status": resp.status}
except urllib.error.HTTPError as e:
return {"ok": False, "status": e.code}
except Exception as e:
return {"ok": False, "status": None, "error": type(e).__name__}
def load_allowlist(path: Path) -> set[str]:
if not path.exists():
return set()
names = set()
for line in path.read_text().splitlines():
line = line.strip().lower()
if line and not line.startswith("#"):
names.add(line)
return names
def decide(item: dict, allow: set[str]) -> str:
reg = item["registry"]
if not reg.get("ok"):
if reg.get("status") is None:
return "fail_unreachable"
return "fail_missing_registry"
if item["name"].lower() not in allow:
return "fail_need_review"
return "pass"
def main(argv: list[str]) -> int:
if len(argv) != 3:
print("usage: dep_gate.py REPLY.txt allowlist.txt", file=sys.stderr)
return 2
reply = Path(argv[1]).read_text()
allow = load_allowlist(Path(argv[2]))
rows = extract(reply)
report = []
worst = 0
for row in rows:
row = dict(row)
row["registry"] = registry_exists(row["ecosystem"], row["name"])
row["decision"] = decide(row, allow)
report.append(row)
if row["decision"] != "pass":
worst = 1
json.dump({"intents": report, "allowlist_size": len(allow)}, sys.stdout, indent=2)
print()
return worst if rows else 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv))
Verification with a fixture, not with hope:
cat > fixture_reply.txt <<'EOF'
Looks good.
bash
pip install requests
npm install left-pad
pip install definitely-not-a-real-pkg-xyzzy
EOF
python3 - <<'PY'
from pathlib import Path
import dep_gate
rows = dep_gate.extract(Path("fixture_reply.txt").read_text())
names = {r["name"] for r in rows}
assert "requests" in names, names
assert "left-pad" in names, names
assert "definitely-not-a-real-pkg-xyzzy" in names, names
print("extract: ok", len(rows))
PY
shell
If that import fails, run the same asserts by copying extract() into a scratch file. Did the extractor miss a line wrapped with a Makefile variable? Maybe. Extend the regex when you see that. Do not "fix" it by sending the reply back to a model.
Stage 3 — Ask the registry, not the model
Existence is a network fact. I treat HTTP 404 as do not install. I treat HTTP 200 as the name is registered, not as the name is safe. Those are different sentences. Say them out loud. What does a 200 actually prove? Only that someone published that string.
Verification against a name you know is fake:
python3 - <<'PY'
import dep_gate, json
real = dep_gate.registry_exists("pypi", "requests")
fake = dep_gate.registry_exists("pypi", "definitely-not-a-real-pkg-xyzzy")
print(json.dumps({"requests": real, "fake": fake}, indent=2))
assert real.get("ok") is True
assert fake.get("ok") is False
print("registry: ok")
PY
You want requests true and the invented name false. If both come back true, stop and look at your network. If both fail, you are offline. The gate must fail closed when the registry is unreachable. That is annoying. It is also correct.
Stage 4 — Allowlist beats "it exists"
A registered name can still be a surprise. Surprise dependencies in a brownfield app are how you get a supply-chain story you did not mean to star in. Who approved the new library if the only reviewer was a chat window?
Policy I use:
- Not in the registry →
fail_missing_registry - Registry unreachable →
fail_unreachable - In the registry, not in the allowlist →
fail_need_review - In the registry and in the allowlist →
pass
New dependencies are not forbidden forever. They are forbidden as a silent side effect of a chat. Add the name to allowlist.txt after a human looks at it. Then rerun.
# requests is already pinned in many apps; the invented name must still fail
grep -qx requests allowlist.txt || echo requests >> allowlist.txt
python3 dep_gate.py fixture_reply.txt allowlist.txt; echo exit:$?
Verification:
python3 dep_gate.py fixture_reply.txt allowlist.txt > report.json || true
python3 - <<'PY'
import json
rep = json.load(open("report.json"))
decs = {i["name"]: i["decision"] for i in rep["intents"]}
print(decs)
assert decs["definitely-not-a-real-pkg-xyzzy"] == "fail_missing_registry"
assert decs["left-pad"] in {"fail_need_review", "pass"} # pass only if you pinned it
print("gate: ok")
PY
Expect a non-zero process exit when anything is not a clean pass. If the process exits 0 on that fixture, the gate is not wired. Fix the allowlist or the script before you touch a real reply.
Stage 5 — Optional: run the gate where the diff arrives
A local script dies when I forget to run it. If you have a small always-on box, wrap the gate in a POST handler that accepts the model reply as text. A free server is enough because the process is request-scoped: no database, no GPU, no session store.
# server.py — teaching listener, not a public API
from http.server import BaseHTTPRequestHandler, HTTPServer
import os, pathlib, subprocess, tempfile
ROOT = pathlib.Path(__file__).parent
class H(BaseHTTPRequestHandler):
def do_POST(self):
n = int(self.headers.get("Content-Length", "0"))
body = self.rfile.read(n)
with tempfile.NamedTemporaryFile(delete=False) as f:
f.write(body)
path = f.name
try:
p = subprocess.run(
["python3", str(ROOT / "dep_gate.py"), path, str(ROOT / "allowlist.txt")],
capture_output=True, text=True,
)
self.send_response(200 if p.returncode == 0 else 422)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write((p.stdout or p.stderr).encode())
finally:
os.unlink(path)
if __name__ == "__main__":
HTTPServer(("127.0.0.1", 8080), H).serve_forever()
Bind to localhost for the lesson. Do not put this on the public internet as-is.
Verification:
python3 server.py &
sleep 1
code=$(curl -sS -o /tmp/gate.json -w "%{http_code}" -X POST --data-binary @fixture_reply.txt http://127.0.0.1:8080/)
echo "http:$code"
cat /tmp/gate.json
kill %1
You want HTTP 422 on the fixture that includes a fake package. If you get 200, the gate is applause, not a control. Kill the demo server when you are done.
Limitations, said plainly
Existence is not safety. Typosquatting lives on real registries. A 200 from PyPI does not mean you should install it. It means the name is not a figment.
The regex will miss wrappers: uv add, bundle add, python -m pip install hidden in a Makefile variable, scoped npm names like @scope/pkg. Extend it with fixtures. Do not "just this once" paste the command.
Private registries, vendored wheels, and air-gapped installs need a different allowlist source. This tutorial talks to public PyPI and npm. If those URLs are wrong for your company, this script will make a confident, wrong decision.
If the reply contains no install line, the script exits 0. That is not a blessing. It means the model may have told you to copy a vendored file instead. Read the patch.
Who should not use this
Skip this approach if you already have a mandatory allowlist in CI, with a human reviewer for new names. You do not need a second, weaker copy.
Skip it if your policy requires signed attestations or an internal proxy whose JSON does not match public registry documents. The script will drift from that policy.
Skip it if the operator of the box cannot be trusted with outbound HTTPS to PyPI and npm. The gate must reach the registry. If it cannot, it must fail.
What I will not outsource
The extractor. The HTTP check. The exit code. Those three stay boring on purpose.
The model can still write the patch. It can still explain why a new library looks tempting. It does not get a shell. That split is the whole point of a free-model workflow that does not slowly become a package lottery.
Wire the gate first. Then let the model talk.
Top comments (0)