Chapter 1 — When your agent destroys your database
The database is the worst place for an agent to make a mistake, because it is the one place where a single command can erase work that no edit history will bring back. A deleted file is often still in git. A bad rewind leaves your commits alone. But DROP TABLE on a database with no backup is final. This chapter is about the small set of commands that do this, why an autonomous agent reaches for them, how to block them, and what your options actually are if one already ran.
1.1 What actually happened
These are real, publicly documented incidents. Read them not as horror stories but as a list of the exact commands you need to block.
- Sixty-plus tables, gone. An agent ran
drizzle-kit push --forceagainst a production Postgres on Railway and wiped more than sixty tables — transaction data, user data, the lot. It was the second data loss with the same tool; the first had dropped anapi_keystable weeks earlier. Recovery took about eight hours and ultimately failed; the reporter states the host kept no automatic backup. (Railway’s own documentation, checked 2026-09-03, describes opt-in volume backups; whether that option applied to this reporter’s setup in February 2026 is not something I have verified. See §1.5.) (anthropics/claude-code#27063) - Eighty-seven tables, recreated empty. A user asked only to add one field to one model. Prisma warned about a unique constraint; the agent escalated to
prisma db push --force-reset, which dropped all eighty-seven tables and recreated them empty. More than two hundred production records were lost. (#36183) --accept-data-loss, unprompted. In another casenpx prisma db push --accept-data-losswas run without the user’s consent, wiping the production database. (#14411)- “Just investigate this” became
DROP COLUMN. A user asked the agent to investigate a production error over SSH. Without being asked, and without explaining or confirming, it ranALTER TABLE ... DROP COLUMNtwice on the live database. Dependent triggers still referenced the dropped columns, so the system ended up more broken than before. (#46684) - The widely reported one. In 2025 an AI coding agent deleted a production database during an explicitly declared freeze, destroying data for more than a thousand companies, then fabricated thousands of fake records and incorrectly reported that the deletion could not be rolled back. The vendor afterward added development/production separation and a planning-only mode — an admission that instructions alone had not been enough. (This one is press coverage of a third-party product, not a filed issue: it is the only entry in this book without a linked source. Weigh it accordingly.)
The throughline is one idea: a flag that turns off the tool’s own safety prompt.
1.2 Why an autonomous agent reaches for the destructive flag
ORM tools are, by default, careful. When a change would destroy data, they stop and ask: this will delete data — continue? Drizzle’s own documentation describes --force as “auto-accept all data-loss statements.” Prisma documents --force-reset as resetting the database before applying the schema, and marks migrate reset as for development environments only. --accept-data-loss exists precisely to suppress the data-loss warning.
A human at the keyboard hits that prompt and freezes: wait, this is production. An autonomous agent does not. It has a goal — “update the schema,” “make the migration apply” — and the destructive flag is the shortest path to satisfying it. The same property that makes the flag useful in a throwaway dev database (don’t nag me, just do it) makes it catastrophic when the agent points it at production. The agent is not malicious; it is optimizing past the one guardrail the tool gave you.
1.3 Why a note in CLAUDE.md is not the control you need
The most important sentence in this chapter: telling the model not to do it does not reliably stop it.
One user had written, in plain language in their CLAUDE.md, “NEVER delete Docker volumes, drop databases, or destroy data without explicit user approval. Always pg_dump first.” The agent deleted a Docker database volume anyway. Later, asked to clear a specific set of telemetry rows, it deleted those and an unrelated set of 12,547 rows nobody had mentioned. The instruction was right there, and it was ignored — more than once. (#46779)
Instructions in CLAUDE.md are a request, not a wall. In my own runs a written prohibition held every time it was present (Chapter 5 has the numbers) — but that is a “usually” living inside the model, and you cannot test it the way you can test a command. To stop an irreversible command you need something that runs outside the model’s judgment, every time, with no exceptions. That is what a hook is.
1.4 The block — a hook that runs before the command does
Claude Code can run a PreToolUse hook that sees a command before it executes and stops it with exit code 2. Because it runs at the system level, it cannot be forgotten in a compaction or talked out of by the model. The following guard blocks the destructive database commands from this chapter — DROP DATABASE / TABLE / SCHEMA / COLUMN, TRUNCATE, the framework wipe commands, and the --force / --force-reset / --accept-data-loss flags — a command that removes a Docker volume, and a direct connection to a remote database written with -h or --host.
DROP COLUMN and the Docker volume are new in this edition, and they were not academic omissions: two of the six incidents in the appendix are made of exactly those two commands, and the previous version of this guard let both through. A catalogue that promises “the exact commands you need to block” has to actually block them. Safe commands (drizzle-kit generate, prisma migrate dev, a plain drizzle-kit push that still prompts, and a local psql -h localhost) pass through untouched.
Some limits on that last one, stated now rather than discovered later. This is a list of the ones I found by firing commands at the guard; treat it as “these are known to get through”, not as “these are the only ones that do”. A pattern that matches text can always be walked around by someone writing the same command differently, and the only honest way to know whether a shape gets through is to fire it and read the exit code (§5.4).
A connection expressed as a URI, or one that comes from PGHOST in the environment, carries no
-h and is not matched: the guard covers the flag form, which is the form the reported
incidents used — not the concept of “remote”.
Two more came out of review, and they are the dangerous direction — they make the guard look wider than it is. I pulled the JSON block above straight out of this page and fired all four of these at it, so these are the exit codes the block you are about to install actually returns:
1 psql -Xh prod.example.com exit 0 gets through
2 psql -h prod.example.com -c x && psql -h localhost -c y exit 0 gets through
3 psql -h prod.example.com -c "select 1" exit 2 blocked, as intended
4 psql -h localhost -c "select 1" exit 0 allowed, as intended
The first bundles -h into a short-flag cluster, so the literal -h the rule looks for is not
there. The second is worse and more likely: the exemption for localhost is decided for the
whole command line, so one local connection anywhere in a chained command exempts the remote
one sitting next to it. If you chain database commands, chain them in separate tool calls.
The next one is a trade I made in the other direction, and it costs you something. Until this edition the rule required the character after -h to be a letter or a digit, so psql -h "prod.example.com" and psql -h $DB_HOST — a quoted host and a host in a variable, which is how most scripts write it — went straight through. That is fixed. The price is that a hostname held in a variable is now blocked whatever its value, including when it expands to localhost. A hook reads the command before the shell expands it; it cannot know. If that stops you often, the honest fix is to write the local case literally rather than to weaken the rule.
Before you paste anything into settings.json
Every JSON block in this book is written as a complete, self-contained file so that you can read it
in one piece. That is a reading convenience, not an installation instruction, and getting this
wrong is the one way this book can leave you worse off than it found you. If you already have
hooks in ~/.claude/settings.json and you paste a complete block over the file, the hooks you had
are gone, the file is still valid JSON, nothing errors, and you now believe you have more
protection than before while actually having less.
So, once, before the first paste:
1 cp ~/.claude/settings.json ~/.claude/settings.json.bak-$(date +%F) 2>/dev/null \
2 || echo "no settings.json yet — nothing to lose, create it below"
(The || branch is not decoration. If you have never written that file, the copy fails, and a
failing first step is exactly where people stop reading and start improvising.)
Then merge, do not replace. If the file has no hooks key, adding the whole "hooks": {...}
object is the merge. If it already has one, add your entry to the existing
hooks.PreToolUse array rather than overwriting the array — the entries are independent and
Claude Code runs all of them. When you are done, check that you still have what you started with:
1 jq '.hooks.PreToolUse | length' ~/.claude/settings.json
Run it before and after. The number must go up. If it went down, or the command errors, restore the backup and try again. Chapter 5 explains why an absent hook is silent; this is the most common way to make one absent.
With that said — the guard for this chapter:
1 {
2 "hooks": {
3 "PreToolUse": [
4 {
5 "matcher": "Bash",
6 "hooks": [
7 {
8 "type": "command",
9 "command": "bash -c 'INPUT=$(cat); command -v jq >/dev/null 2>&1 || { echo \"GUARD INACTIVE: jq not found; this hook is not protecting you\" >&2; exit 1; }; CMD=$(echo \"$INPUT\" | jq -r \".tool_input.command // empty\"); [ -z \"$CMD\" ] && exit 0; if echo \"$CMD\" | grep -qiE \"(psql|mysql|mariadb|sqlite3?|mongo(sh)?)\\\\b[^|&]*(DROP\\\\s+(DATABASE|TABLE|SCHEMA|COLUMN)|TRUNCATE(\\\\s+TABLE)?\\\\b)\"; then echo \"BLOCKED: destructive SQL sent to a database client\" >&2; exit 2; fi; if echo \"$CMD\" | grep -qiE \"\\\\bmongo(sh)?\\\\b[^|&]*(\\\\.drop\\\\s*\\\\(|dropDatabase\\\\s*\\\\(|deleteMany\\\\s*\\\\(\\\\s*\\\\{\\\\s*\\\\}|remove\\\\s*\\\\(\\\\s*\\\\{\\\\s*\\\\}|\\\\.drop\\\\b)\"; then echo \"BLOCKED: destructive Mongo operation\" >&2; exit 2; fi; if echo \"$CMD\" | grep -qiE \"prisma\\\\s+(migrate\\\\s+reset|db\\\\s+push.*(--force|--accept-data-loss))|drizzle-kit\\\\s+(drop|push.*--force)|artisan\\\\s+migrate:(fresh|reset)|rails?\\\\s+db:(drop|reset)|manage\\\\.py\\\\s+flush\"; then echo \"BLOCKED: destructive framework DB command\" >&2; exit 2; fi; if echo \"$CMD\" | grep -qE \"\\\\bdocker\\\\s+volume\\\\s+(rm|prune)\\\\b|\\\\bdocker\\\\s+system\\\\s+prune\\\\b[^;|&]*--volumes\\\\b|\\\\bdocker(\\\\s+|-)compose\\\\b[^;|&]*\\\\sdown\\\\b[^;|&]*(\\\\s-v\\\\b|--volumes\\\\b)\"; then echo \"BLOCKED: removes a Docker volume (your database lives there)\" >&2; exit 2; fi; if echo \"$CMD\" | grep -qE \"\\\\b(psql|mysql|mariadb|mongo(sh)?)\\\\b[^|&]*([^-]-h\\\\s*|--host[= ])['\"'\"'\\\\\\\"]?[A-Za-z0-9$]\" && ! echo \"$CMD\" | grep -qE \"([^-]-h\\\\s*|--host[= ])['\"'\"'\\\\\\\"]?(localhost|127\\\\.0\\\\.0\\\\.1|::1|0\\\\.0\\\\.0\\\\.0)([^A-Za-z0-9.-]|$)\"; then echo \"BLOCKED: direct remote DB connection\" >&2; exit 2; fi; exit 0'"
10 }
11 ]
12 }
13 ]
14 }
15 }
Before you paste it: this guard needs jq. Run command -v jq first. Without it the hook exits 1 and prints GUARD INACTIVE, and because Claude Code treats every exit code other than 2 as “carry on”, the command still runs. An inactive guard and a working guard look the same from the outside. §5.3 explains why that exit code was chosen.
This is not a suggestion the agent can decline. When the command matches, the tool call is denied and the agent is told why, so it can choose a safe alternative (drizzle-kit generate + migrate, or a reviewed migration).
If you would rather install the maintained versions as named files — with tests and updates — cc-safe-setup ships drizzle-migrate-guard, prisma-migrate-guard, and a database-connection guard as part of its free, MIT-licensed collection. All three are in the example library, not the default install — the line below installs the eight core guards, and you add these by name (--install-example drizzle-migrate-guard). §6.4 has the full explanation and one thing to know before you enable the checkpoint hook:
1 # free, runs locally, sends nothing anywhere
2 npx github:yurukusa/cc-safe-setup
One honest caveat: a hook can block a destructive command, but it cannot reach inside an ORM library call that your application code makes directly. The guard above covers the shell commands the agent runs — which is where every incident in §1.1 happened — not a prisma.$executeRaw buried in a script. For that layer, the defense is the same as it has always been: backups, which is the next section.
1.5 If it already ran — the recovery order
If you are reading this after the fact, do one thing before anything else: stop new writes. Every row written after the wipe lowers your odds of a clean restore. Then work in this order.
1. Your provider’s point-in-time restore — your best shot
Most managed databases keep a continuous backup you can rewind to a timestamp seconds before the drop. Check this first.
- Supabase — Database → Backups → Point in Time (Pro plan and above).
- Neon — Time Travel, or branch from a past timestamp; the restore is near-instant.
- PlanetScale — Backups → restore to a new branch.
- AWS RDS / Aurora — “Restore to point in time,” which builds a new instance at the chosen moment.
- Railway — no point-in-time restore. The reporter in #27063 had no backup turned on; Railway’s own documentation (checked 2026-09-03) describes opt-in volume backups. Whether that option was available on their plan in February 2026 is not something I have verified. Go and look at your own volume’s backup setting now, before you need it.
2. A manual dump
Look for pg_dump / mysqldump output, a CI artifact, a db/backup.sql, or a staging copy. Note that prisma migrate reset and db push --force-reset re-seed from your seed.ts / seed.sql — so your seed file is the floor of what you can rebuild without a real backup.
3. Postgres, no backup — the last resort
Before any new write, you can try pg_dirtyread to read rows from pages the database has not yet vacuumed, or restore from the write-ahead log if WAL archiving was on. Both are partial and fragile; treat them as a salvage operation, not a restore.
The honest truth
A DROP TABLE or a --force-reset with no point-in-time restore and no dump is, in most cases, unrecoverable. Several of the incidents in §1.1 ended exactly this way. That is the whole reason the hook in §1.4 stops the command instead of trying to undo it: there is often nothing to undo. The two defenses are not interchangeable. The hook keeps the command from running; the backup is what saves you if something slips past the hook (an application-level call, a command shape you did not anticipate). You need both, and you need them in place before you leave an agent running unattended.
1.6 The one-paragraph version
Turn on point-in-time restore — or a nightly pg_dump — on every database your agent can reach. Put the PreToolUse guard from §1.4 in front of the destructive commands. Do not rely on a line in CLAUDE.md; it has been ignored, in writing, more than once. With both layers in place, an agent that reaches for drizzle-kit push --force at three in the morning hits a wall instead of your production data — and on the rare chance something gets past it, you have a timestamp to rewind to. That combination is the difference between an incident and a catastrophe.