This is NOT the Next.js you know


Part 5 of 5 in the series Defending Against npm Supply-Chain Attacks. Full series ↓

On the afternoon of September 8, 2026, three things happened to this site's repo within about two hours. I only realized afterward that they were one story.

First, a critical Next.js advisory landed: GHSA-p293-qw3h-jr36, CVSS 9.0, unauthenticated remote code execution on Windows-hosted servers, affecting every Next.js 16 release before 16.3.3. Second, an unrelated feature PR (a small addition to blog post pages, nothing touching dependencies) started showing a failing security-audit check, for a reason that had nothing to do with anything in its own diff. Third, buried in that same PR, I found a paragraph of prose that had no business being there and asked about it: "This doesn't seem relevant to this PR. Explain to me what this is about."

That third thing is the one worth writing about.

The problem

The PR in question added "older/newer post" navigation links to blog posts, a small change touching a handful of files. Nothing about it should have touched CLAUDE.md, the instructions file my coding agent reads at the start of every session. But it did, appending this:

<!-- BEGIN:nextjs-agent-rules -->

# This is NOT the Next.js you know

This version has breaking changes — APIs, conventions, and file structure
may all differ from your training data. Read the relevant guide before
writing any code. Heed deprecation notices.

This block is written and re-added by `next dev` — removing it from a
diff only re-creates the uncommitted change; committing it with your
work keeps the tree clean.

<!-- END:nextjs-agent-rules -->

I didn't write that. Nobody on my side did. It came from Next.js itself.

Why it happened

Next.js's dev server doesn't guess whether an AI agent is involved. next dev checks a fixed list of environment variables against known coding tools, plus a generic override any tool can set for itself:

  • Cursor
  • OpenAI Codex
  • GitHub Copilot
  • Claude Code
  • Gemini CLI, Replit, Devin, and a few others

My session had two of these set at once. Match one, and if the managed block isn't already present, Next.js writes it.

Where it writes matters more than whether it writes:

My repo already had a CLAUDE.md, long and hand-maintained, and no AGENTS.md. So the block landed inside the one file already doing that job, indistinguishable in a diff from anything else there.

None of this is a bug. Next.js's own docs show this exact block as the expected, committed state of a project's agent-instructions file, cite benchmark results as the reason, and call leaving it on the right default. Turning it off is one config line. The framework isn't tricking anyone; it's doing exactly what it says it does.

So the miss wasn't an instruction getting obeyed that shouldn't have been. It was narrower: my agent treated framework-owned scaffolding as indistinguishable from its own work, folding it into the same commit as an unrelated feature instead of either leaving it in a file the framework owns or giving it a commit of its own.

How it was fixed

The catch was me, not a linter. I noticed a chunk of CLAUDE.md unrelated to post navigation and asked what it was instead of assuming it was fine. The agent explained it was an artifact of running the dev server locally, then pushed a follow-up commit dropping the block and restoring the file to match main.

The CVE fix ran on its own track. The failing check measured the dependency tree the whole repo inherits, not the few files the post-navigation PR touched, so the fix didn't belong on that PR. It belonged wherever the shared state lived: one dedicated PR bumped Next.js past 16.3.3, re-verified everything, confirmed the audit cleared, and merged. But that check isn't part of branch protection here, so fixing the tree on the base branch didn't reach back and clear the red mark on the post-navigation PR. A completed check is a snapshot, not a subscription: it doesn't re-run itself just because the branch it measured changed underneath it. The mark stayed red, reporting a problem that no longer existed, until someone pushed a new commit or reran the workflow by hand.

That check wasn't the only thing watching. GitHub's own Dependabot flagged the same advisory independently within about forty minutes of it going public and opened its own fix PR unprompted, closing itself automatically once an equivalent fix landed elsewhere.

This repo runs four different security tools, and it's worth being precise about which ones were ever going to notice:

Tool

What it watches for

Caught this advisory?

Dependabot

Known CVEs in pinned versions, via GitHub's advisory database, continuously

Yes, within about 40 minutes

The audit check (CI)

The same advisory database, re-checked on every push

Yes, same run

Semgrep's code scanner

Bad patterns in code and config, like a download piped into a shell

Wrong problem, not applicable

Semgrep Supply Chain

Reachability-aware scanning of the lockfile, aimed at the same job as the two above

No, its own feed hadn't caught up yet

Aikido Safe Chain

Every install checked against a known-malware list

Wrong problem, not applicable

Only two of the five are actually built to catch "known CVE in a pinned version." The rest either solve a different problem entirely, or, in Semgrep Supply Chain's case, solve the same problem on a slower clock.

One small coda: two of my agent sessions ended up racing to fix the same disclosure at once, unaware of each other, because there was no tracking issue to check against, just an urgent instruction typed into chat. One PR merged; the other's identical work got discarded once I noticed the collision.

What I learned

Framework-generated content deserves its own accounting, even when it's completely legitimate. Next.js was doing exactly what it documents itself doing, in the open, with an opt-out one line away. "Fine to have in the repo" and "fine to fold silently into whatever I'm committing right now" are different judgments, and my agent only made the first one. Anything a tool writes into a file I treat as authoritative earns a beat of attention before it rides along with unrelated work, not because the tool is untrustworthy, but because I'm the one who has to account for every line of a diff.

A check can turn red on a PR for reasons its author never touched, and fixing the real cause doesn't reset it. When a check measures shared state instead of a diff, chasing a red mark on the wrong PR wastes effort; the fix belongs wherever that state lives. But a finished check run doesn't know the fix happened. Unless something pushes a new commit or reruns it by hand, it keeps reporting the old answer, correct at the moment it ran and stale forever after.

Specific curiosity beats a rubber stamp. Nothing mechanical was watching for an unrelated diff to an instructions file; it would have passed a syntax check, a type check, and a full test run without complaint. What caught it was treating an unexplained line in a diff as worth a question before assuming it was fine.

Urgent, untracked fixes need their own discovery step, same as tracked ones. My usual habit, checking for an open PR before starting, assumes an issue number to search by. A same-day CVE fix has none. Searching by package name or advisory ID instead costs one command and would have caught the duplicate before it started.

Comments

No comments yet. Be the first to comment.

Leave a comment