Startup Feedback
Exchange
Menu
← All posts
6 min readAdam Casson

Shipping with an agent: what broke

Eleven of the twenty-seven commits on this site exist only to fix something an earlier commit got wrong. Including a privilege escalation that let any member make themselves an admin. A build log, with the ugly parts left in.

This site was built almost entirely by an AI agent, over about a week, and I want to write down what went wrong while I still remember it. Not the marketing version. The actual list.

Here is the headline number. There are twenty-seven commits in this repository. Eleven of them exist only to fix something an earlier commit shipped broken. That is 41%, and I do not think that ratio is unusual. I think it is what happens when code arrives faster than you can read it.

The interesting part is not that things broke. Things always break. The interesting part is how they broke, because it was the same way almost every time, and it was not the way I expected.

Everything failed quietly

I went in braced for the obvious failure mode: the agent writes something that does not compile, or hallucinates an API that does not exist, or produces a function that crashes the first time you call it. That is the version everybody warns you about, and honestly it barely happened. When it did, TypeScript caught it in about four seconds and it cost nothing.

What actually happened was worse. The code ran. It ran, it looked right, it type-checked, and it did the wrong thing without saying so.

The failure mode of agent-written code is not a crash. It is a plausible-looking success.

Four examples, in ascending order of how much they would have cost me.

The admin page that had never worked

The approval queue at /admin/applications said "Nothing waiting for review." There was an application waiting for review. It had been sitting there.

The applications table has two foreign keys pointing at profiles, one for the applicant and one for whoever reviewed them. So when the query asked PostgREST for profiles(email), PostgREST refused, correctly, because it had no way to know which one I meant:

PGRST201  Could not embed because more than one relationship was found

Fine. Except the query destructured only data and never looked at error. So the error went precisely nowhere, data came back null, and applications ?? [] rendered the perfectly designed empty state. The page had never worked, not once. It just never had anything to display, so nobody noticed.

That is the one that bothers me most, and not because of the bug. An admin who is told the queue is empty has no reason to check again. Applicants would have waited forever with nobody aware anything was wrong. The fix took two minutes; the failure was structural, and the lesson is that "handle the error" is not a code-quality nicety, it is the difference between a broken page and a page that lies to you.

Two dead deploys and an empty string

Both of the first production deploys died at the same line, in the root layout:

TypeError: Invalid URL
  input: ''

input: '' is the whole story. The environment variable existed in Vercel. It was empty. And ?? only falls back on null and undefined, so an empty string sailed straight through the guard and into new URL().

Two things made this genuinely nasty. First, that code runs at module evaluation, so it failed page-data collection for every route at once, before a single page rendered, and the stack trace named layout.tsx rather than the variable actually at fault. Second, an env var that exists but is empty is absurdly easy to create by accident: Vercel's bulk "paste your .env" importer makes a key for every line in the file, including the blank ones that a .env.example is mostly made of.

I also want to record that the first diagnosis was wrong. Reproducing locally with no env vars produced a completely different error, about Supabase, and we confidently fixed that instead. The guard we added was fine. It just was not the bug. Two commits, one real cause.

The one that would have been bad

Now the actual security hole.

Writing an insert policy for the profiles table surfaced a problem in an existing update policy that had been shipped days earlier. It looked like this, and if you write Postgres you may already be wincing:

create policy "update own profile" on profiles
  for update using (id = auth.uid())
  with check (id = auth.uid());

That reads as "you may edit your own profile," which is what it was supposed to mean, and it is not what it does. WITH CHECK constrains which row may be written. It says nothing at all about which columns.

So an ordinary member, holding nothing but their own session and the anon key that ships in the page source of every public website, could send a PATCH setting role to admin. We confirmed it by running it as the authenticated role. Role became admin. Status became active. That grants every member's email address, the whole approval queue, and the private half of every review anyone has ever written.

The privacy promise around review content is the single most load-bearing thing this site has. It was one HTTP request away from being worthless, for several days, and nothing in the codebase looked wrong.

The fix is unintuitive enough that I want it written down: WITH CHECK cannot express "this column did not change", because it never sees the old row. So privileged columns are now pinned by a BEFORE UPDATE trigger that silently reverts them, rather than a policy. Silently, so that ordinary profile edits keep working instead of throwing at someone changing their display name.

The analytics that were not analytics

Smaller, but it is my favourite, because of how close it came to never being found.

PostHog is reverse-proxied through this site's own origin, since requests going straight to *.posthog.com get blocked for a decent slice of visitors (and the founders this site is for run blockers at a much higher rate than the general public). Doing that means the proxy path has to be excluded from the auth middleware explicitly.

Miss that exclusion and here is what happens: /fx-relay/static/array.js has a file extension, so the middleware lets it through, so the script loads, so the SDK initialises without complaint. The site looks fully instrumented. The dashboard stays at zero forever and you assume nobody is visiting.

I would have believed that for a month.

So what do I actually think

I am not going to tell you to stop using agents. This site exists, it works, and it took a week. Writing it by hand would have taken me two months and I would have made a different, probably larger, set of mistakes, several of which I would still not know about.

But I have changed how I work, in two ways, and both are boring.

I read the diff for what it does not say. Not "is this code correct", which is the question I used to ask and which agent code passes easily. Instead: what happens when this fails? Where does that error go? What does the user see if this returns nothing? Three of the four bugs above are answered by that question and only that question.

The other change is that I now ask for the exploit rather than the fix. "Write an RLS policy for this table" got me the vulnerable policy. "Try to escalate privileges against this table as an ordinary member, in a transaction, and roll it back" got me the exploit, a reproduction, and then the trigger. Same model, same afternoon. Turns out it is very good at attacking code and merely quite good at defending it, and I would rather point that in the useful direction.

One more, and then I will stop. There is a commit in this repo titled "drop the em dashes." Every string on the site had them, because of course it did, and a find-and-replace gets it wrong: sometimes the right replacement is a comma, sometimes a colon, sometimes parentheses, depending on what the sentence was doing. It took a human pass over every line.

Which feels like the whole thing in miniature. The agent wrote a website in a week and the tell was punctuation.

Keep reading

Related posts