Pin Your AI Reviewer to a Fixture Corpus Before You Change the Model Under It
A silent regression hit us after what looked like a routine cost optimization: someone pointed our review assistant at a different model. The tool kept producing fluent comments. It also walked past a path traversal in a
A silent regression hit us after what looked like a routine cost optimization: someone pointed our review assistant at a different model. The tool kept producing fluent comments. It also walked past a path traversal in an upload handler — a pattern the old model had flagged on sight for months. A versioned fixture flipping from pass to fail was the only alarm that fired, and it fired two days after the change.
The takeaway I now treat as non-negotiable: a model is a pinned dependency, and swapping it without regression evidence is an unreviewed change to your security posture. Below is a compact workflow for gating any model change — paid, self-hosted, or free — on a fixed corpus of known-answer cases before it earns a place in CI.
Trust boundary first
The assistant ingests untrusted code and emits judgments. Three drift modes matter, and none surface in a smoke test:
- Silent capability loss — detections it used to make quietly disappear.
- Noise inflation — it starts flagging benign code, training reviewers to dismiss everything.
- Refusal creep — it declines to analyze security-sensitive files at all, which is a detection outage wearing a safety costume.
Only a corpus with ground truth distinguishes these from 'the model seems fine.'
Build the corpus: 12–20 files, each with a verdict
Every case is one source file plus a machine-readable expectation. Keep it in the same repo as the gate:
corpus/
traversal_join/ # must flag
handler.go
verdict.yaml # verdict: flag, severity_at_least: high
traversal_sanitized/ # must pass (negative twin)
handler.go
verdict.yaml # verdict: pass
jwt_alg_none/ # must flag
auth.py
verdict.yaml
secrets_in_config/ # must flag
settings.yaml
verdict.yaml
Positive case (corpus/traversal_join/handler.go):
func serveFile(w http.ResponseWriter, r *http.Request) {
// fixture: user input joined directly into a filesystem path
name := r.URL.Query().Get("f")
http.ServeFile(w, r, "/var/data/"+name)
}
Negative twin — the same endpoint shape, sanitized, and the model must stay silent on it:
func serveFile(w http.ResponseWriter, r *http.Request) {
name := filepath.Clean(r.URL.Query().Get("f"))
if strings.Contains(name, "..") || filepath.IsAbs(name) {
http.Error(w, "invalid", http.StatusBadRequest)
return
}
http.ServeFile(w, r, filepath.Join("/var/data", name))
}
The twin structure is the point. A model that flags both files is a noise generator; one that flags neither is a rubber stamp. Only the gap between the two verdicts carries information.
Gate script (template — wire it to your endpoint before trusting it)
This follows a pattern I run against local inference; treat the listing as an unexecuted template until you've pointed it at your own deployment. Any OpenAI-compatible endpoint works:
#!/usr/bin/env python3
"""Model-swap gate. python >= 3.11, openai >= 1.40, pyyaml"""
import json, os, sys, pathlib, yaml
from openai import OpenAI
SYSTEM = (
"You audit code for exploitable defects. Respond with JSON only: "
'{"verdict": "flag" | "pass", "severity": "low" | "medium" | "high", "evidence": str}'
)
SEV_RANK = {"low": 1, "medium": 2, "high": 3}
api = OpenAI(base_url=os.environ["MODEL_BASE_URL"],
api_key=os.environ.get("MODEL_API_KEY", "unused"))
MODEL = os.environ["MODEL_NAME"] # exact pinned ID; "latest" is banned
broken = []
for case in sorted(pathlib.Path("corpus").iterdir()):
src = next(p for p in case.iterdir() if p.name != "verdict.yaml")
want = yaml.safe_load((case / "verdict.yaml").read_text())
reply = api.chat.completions.create(
model=MODEL, temperature=0,
messages=[{"role": "system", "content": SYSTEM},
{"role": "user", "content": src.read_text()}],
).choices[0].message.content
try:
got = json.loads(reply)
except json.JSONDecodeError:
broken.append((case.name, "non-JSON output"))
continue
ok = got.get("verdict") == want["verdict"]
if ok and want["verdict"] == "flag":
ok = SEV_RANK.get(got.get("severity"), 0) >= SEV_RANK[want["severity_at_least"]]
print(f"{'ok ' if ok else 'BAD'} {case.name}: want={want['verdict']} got={got.get('verdict')}")
if not ok:
broken.append((case.name, got.get("evidence", "")[:100]))
print(f"\nmodel={MODEL} failures={len(broken)}")
sys.exit(1 if broken else 0)
Then compare incumbent against candidate:
MODEL_NAME=<incumbent-exact-id> python gate.py | tee incumbent.log
MODEL_NAME=<candidate-exact-id> python gate.py | tee candidate.log
diff incumbent.log candidate.log && echo "no regression on corpus"
Constraints that turn this from a demo into a gate:
- Exact identifiers only. A floating tag makes every run a coin flip.
- Temperature 0, recorded in the log header. Otherwise flakiness and regression are indistinguishable.
- Flake = fail. If a case fails once and passes on retry, the candidate is rejected. A nondeterministic security judgment is not a judgment.
Where free compute fits — and where it stops
This workload is a dozen short completions, so it's a natural match for free model access: you can benchmark a candidate against your incumbent without paying for parallel inference. Disclosure: This article was prepared as part of MonkeyCode's product outreach. MonkeyCode currently advertises free model access and a free server option (per operator-provided information; I haven't verified quotas, available models, or offer duration — confirm current terms first).
My honest split of responsibilities:
- Free tier → the experiment. Run the corpus, diff the logs, decide whether the candidate is even worth a longer evaluation.
- Infrastructure you control → the enforcement. The blocking CI check belongs somewhere with availability guarantees, because a gate that 404s either blocks every merge or gets bypassed — both are bad outcomes.
Prevent / detect / recover
| Layer | Mechanism |
|---|---|
| Prevent | Gate triggers on any PR touching model ID, system prompt, or sampling params; pinned IDs in config |
| Detect | Cron re-run of the corpus against the live endpoint — catches provider-side weight updates nobody told you about |
| Recover | Prior model ID and endpoint stay warm for one release cycle; rollback is a config revert |
Limits, and who shouldn't bother
- A 15-file corpus proves non-regression on 15 patterns, nothing more. Grow it from real escaped incidents.
- If the assistant's output never gates anything and humans skim it casually, a lightweight spot-check list may be proportionate.
- If your tool hides the model identifier entirely, you cannot gate what you cannot name — demand versioned models from the vendor or accept unmonitored drift.
- Any pipeline stage pointing at a free endpoint needs a fallback, or your safety check becomes an availability dependency.
The boundary question I keep coming back to: which of these invariants should live in the model layer at all? The traversal case above arguably deserves a deterministic Semgrep rule as the guarantee, with the model fixture retained only to measure assistant judgment. The more of your review pipeline that runs on a system which changes without notice, the more of that line you'll want to redraw toward deterministic tooling. Where would you draw yours?
Originally published by Dev.to AI. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.