DEV Community

Dakota Liu
Dakota Liu

Posted on

Fail Closed on AI-Suggested Installs: A From-Zero Registry Gate

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:

  1. Reads a model reply from a file.
  2. Extracts install intents with regex, not with another model.
  3. Checks PyPI or npm for existence.
  4. Checks an allowlist built from your pins.
  5. 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
Enter fullscreen mode Exit fullscreen mode

Verification:

test -s ../requirements.txt || test -f ../package-lock.json
echo "fingerprint: ok"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Verification:

wc -l allowlist.txt
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Verification:

test -s model_reply.txt
wc -c model_reply.txt
Enter fullscreen mode Exit fullscreen mode

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))
Enter fullscreen mode Exit fullscreen mode

Verification with a fixture, not with hope:

cat > fixture_reply.txt <<'EOF'
Looks good.

Enter fullscreen mode Exit fullscreen mode


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
Enter fullscreen mode Exit fullscreen mode


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
Enter fullscreen mode Exit fullscreen mode

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:

  1. Not in the registry → fail_missing_registry
  2. Registry unreachable → fail_unreachable
  3. In the registry, not in the allowlist → fail_need_review
  4. 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:$?
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)