PROJECT 4
×00

The AI Code Review Gauntlet: Catching What the Model Ships Broken

Part 1 · The Agent Stack

AI code fails in predictable, well-documented ways — SSRF, broken access control, injection, hallucinated dependencies, hollow tests. Here's the review gauntlet that catches the most damage per minute of attention.

Here's the uncomfortable half of the productivity story nobody puts on the landing page. When Veracode ran security-sensitive coding tasks across more than a hundred models, roughly 45% of the generated samples introduced an OWASP Top 10 vulnerability — and that pass rate did not improve across testing cycles from 2025 into early 2026, despite every vendor promising it would. A separate 2026 study from AppSec Santa put the confirmed-vulnerability rate at about 25% across six frontier models. A Tenzai study found that every single AI-built application they tested lacked CSRF protection and had an SSRF hole. Aikido's incident data now attributes roughly one in five enterprise breaches to AI-generated code.

If you ship software with an AI assistant — and by now that's most of us — this is your problem, not a research curiosity. But there's good news buried in the bad: AI code fails in predictable, well-documented ways. Predictable failure is the easiest kind to defend against. This piece is the review gauntlet I run every AI-authored change through, with the specific patterns that catch the most damage per minute of attention.

Why AI code fails the way it does

Three structural reasons, and understanding them tells you where to look.

First, a model reproduces the average of the public code it learned from, and the average of public code is full of insecure patterns humans spent twenty years learning to avoid. The model doesn't know they're mistakes; it knows they're common.

Second, models optimize for code that looks done — the happy path compiles, the demo works, the function returns something plausible. Security failures live in the paths nobody demoed: the malformed input, the request from a user who shouldn't have access, the URL that points inward.

Third, and most important: the model has no threat model. It has no concept of a trust boundary unless you draw one. It will cheerfully fetch a URL the user supplied, run a query built from a request body, or trust a header, because nothing in the prompt told it those values are hostile. Your review is where the threat model gets applied.

The failure classes worth memorizing

Below are the recurring offenders, each with a realistic snippet of the kind of thing an assistant hands you, the fix, and — the part that actually saves you — the tell you can scan for in a diff.

1. SSRF: the standout offender

Server-Side Request Forgery was the single most common finding in the 2026 studies. It shows up any time your code fetches a URL that traces back to user input. The generated version looks completely reasonable:

# What the assistant hands you
@app.post("/fetch-preview")
def fetch_preview(url: str):
    resp = requests.get(url, timeout=5)      # fetches whatever it's given
    return {"title": extract_title(resp.text)}

Nothing here is syntactically wrong. It's also a door into your internal network: a caller passes http://169.254.169.254/latest/meta-data/ (cloud metadata) or http://localhost:6379 (your Redis) and your server dutifully fetches it and hands back the contents.

# The fix: an allowlist and a resolved-IP check, not a blocklist
from urllib.parse import urlparse
import ipaddress, socket

ALLOWED_SCHEMES = {"https"}
ALLOWED_HOSTS = {"images.example.com", "cdn.partner.com"}

def is_safe(url: str) -> bool:
    p = urlparse(url)
    if p.scheme not in ALLOWED_SCHEMES or p.hostname not in ALLOWED_HOSTS:
        return False
    ip = ipaddress.ip_address(socket.gethostbyname(p.hostname))
    return not (ip.is_private or ip.is_loopback or ip.is_link_local)

The tell: grep every diff for requests.get, fetch(, urlopen, axios.get, curl, and trace the URL argument backward. If it originates in a request and there's no allowlist between the two, you've found an SSRF. This is a thirty-second check and it catches the most common serious flaw AI produces.

2. Broken access control: authenticated ≠ authorized

Models are good at remembering to check that you're logged in and bad at checking that you're allowed to touch this specific record. One 2026 dataset found roughly 41% of AI-generated backend code shipped overly broad permissions. The classic shape:

// What the assistant hands you — looks guarded, isn't
app.get("/api/invoices/:id", requireAuth, async (req, res) => {
  const invoice = await db.invoices.findById(req.params.id);
  res.json(invoice);   // any logged-in user can read ANY invoice
});

requireAuth lulls the reviewer. But the endpoint never checks that the invoice belongs to the caller — increment the ID and you're reading someone else's billing. This is an IDOR, and it's endemic in generated CRUD.

// The fix: scope the query to the owner, don't just authenticate
app.get("/api/invoices/:id", requireAuth, async (req, res) => {
  const invoice = await db.invoices.findOne({
    id: req.params.id,
    orgId: req.user.orgId,          // ownership is part of the query
  });
  if (!invoice) return res.sendStatus(404);
  res.json(invoice);
});

The tell: for every handler that takes an :id (or any record identifier) from the request, ask "what stops me passing an id I don't own?" If the answer isn't visible in the query's where clause or an explicit authorization check, assume it's missing. The presence of an auth middleware is not the answer to this question.

3. Injection: still, in 2026

Injection-class weaknesses were about a third of all confirmed findings in the AppSec Santa study. Models slip into string-built queries and shelled-out commands the moment the "clean" path is slightly less obvious:

# What the assistant hands you
def search_users(name):
    q = f"SELECT * FROM users WHERE name LIKE '%{name}%'"   # SQL injection
    return db.execute(q).fetchall()
# The fix: parameterize, always — the driver escapes, you don't
def search_users(name):
    q = "SELECT * FROM users WHERE name LIKE %s"
    return db.execute(q, (f"%{name}%",)).fetchall()

The same reflex applies to os.system, subprocess with shell=True, child_process.exec, and any templating that renders unescaped into HTML.

The tell: search diffs for f-strings, + concatenation, or template literals feeding a query, a shell command, or markup. If a variable that came from outside is being pasted into a string that gets interpreted somewhere, that's the bug.

4. Hallucinated dependencies (slopsquatting)

A newer, sneakier class: models confidently import or npm install packages that don't exist — or worse, ones that do exist because an attacker registered the name the models love to hallucinate. This is now common enough to have a name, slopsquatting.

import { sanitizeHtml } from "html-sanitizer-pro";   // plausible. real? who registered it?

The tell: any dependency you don't recognize gets verified before it's trusted — real package, real maintainer, real download history, and pinned to a known-good version. Don't let "the import is already there and it builds" stand in for "this package is who it claims to be."

5. Hollow tests and the churn tax

Generated tests are the most dangerous kind of green checkmark, because they buy false confidence. Watch for tests that assert on mocks, re-assert the implementation verbatim, or check that a function "returns something" without checking what. This connects to a measured cost: teams shipping heavy AI-assisted code report materially higher code churn — code rewritten or reverted within a couple of weeks — which is often the sound of hollow tests failing to catch a regression the first time.

The tell: for each generated test, ask "what change to the implementation would make this test fail?" If you can't name one, the test asserts nothing.

Turning the tells into a workflow

Recognizing patterns in a review is the floor. Here's how to make the defense systematic rather than heroic.

Shift some of it to prompt time. Tell the agent the threat model up front: "Treat all request input as hostile. Parameterize every query. Any outbound fetch must use the existing allowlist in net/allowlist.ts. Don't add dependencies without flagging them." You won't catch everything this way, but you'll cut the volume of issues that reach review.

Make the agent audit its own diff. Before you review, have it do a pass: "Review this change only for security. For each of SSRF, injection, broken access control, secrets, and new dependencies, state where the risk is or say why there's none." Models are far better at finding a class of bug when named than at avoiding it while writing. Use that asymmetry.

Keep the machine gates. SAST, dependency/SCA scanning, and secret detection in CI catch the mechanical cases so your human attention goes to the semantic ones — the authorization logic a scanner can't reason about. Treat these as a floor, never a ceiling: a green pipeline means "no known-pattern flaw was found," not "safe."

Review the diff, not the demo. The demo exercises the happy path, which is exactly the path AI gets right. Your attention belongs on the inputs nobody typed on stage.

A review card worth pinning

I keep this taxonomy where I can see it during reviews. The whole point is that the list is short and the checks are fast:

A reference card titled "The AI-Code Review
  Gauntlet" listing five recurring failure classes — SSRF, Broken Access Control, Injection,
  Hallucinated Dependencies, and Hollow Tests — each with a short "tell" describing what to
  scan a diff for, over a dark technical background.
Five failure classes, one tell each — short enough to keep in view during a review.

Things to watch for

Scanner noise is real. Security researchers report false positives as their top complaint with AI-security tooling; a wall of low-confidence findings trains people to click "ignore," including on the one that mattered. Tune aggressively and route only high-confidence findings to humans.

The green-CI trap. The most expensive incidents I've seen came from changes that passed every automated gate. Automated gates find known shapes. Novel authorization logic, business-rule violations, and semantic contradictions need eyes.

This is becoming a compliance question, not just an engineering one. Regimes like the EU Cyber Resilience Act attach real obligations to shipping vulnerable software, with reporting requirements arriving on a schedule through 2026 and 2027. "The AI wrote it" will not be a defense.

Watch for review fatigue. When agents generate more diffs than you can carefully read, the honest move is to slow the generation down, not to skim the review. Volume you can't review is risk you can't see. This is exactly where a disciplined multi-agent setup — with a dedicated verification gate before anything merges — earns its keep, which is the subject of "Running a Team of Agents Without the Chaos."

The through-line: AI didn't invent new vulnerability classes, it just industrialized the old ones and handed them to us faster. The defenses are the ones we already knew — allowlists, parameterization, real authorization, verified dependencies, tests that test. What changed is that you now have to apply them at the speed the model writes, and with the assumption that the confident-looking code in front of you was written by something that has never once thought about an attacker.

This is a sensitive area in one narrow sense: the vulnerable snippets above are shown to help you recognize and fix these patterns in your own code. If you're auditing a system you don't own or aren't authorized to test, get permission first.

This is Part 1 of The Agent Stack. Next: "Running a Team of Agents Without the Chaos" — the worktree isolation and sequential merges that put your verifier gate in front of every parallel agent.