Auto-accept mode runs destructive framework DB commands (e.g. `php artisan migrate:fresh`) without confirmation → data loss
Summary
In auto-accept (auto) mode, Claude Code executed php artisan migrate:fresh as a routine
"reset the schema before running tests" step. That command drops and recreates every table in the
configured database. It ran with no confirmation, repeatedly across two days, wiping a local development
database each time (recoverable only because MySQL binary logging happened to be enabled).
Why it slipped through
The Bash command-safety check gates shell-danger patterns (rm -rf, dd, mkfs, …) but does not
recognize destructive framework/ORM commands. php artisan migrate:fresh is as destructive asDROP DATABASE yet matches no shell pattern, so in auto mode it runs unchallenged.
Steps to reproduce
- A project with a destructive framework reset command available (Laravel here).
- Run Claude Code in auto-accept mode.
- Give a task that leads the agent to "reset the schema and run tests."
- The agent runs
php artisan migrate:freshagainst the default DB connection with no confirmation;
all data is dropped.
Expected
Framework/ORM database-reset commands should be treated as destructive — requiring confirmation even in
auto-accept mode (or denied by default), the same way rm -rf is.
Suggested fix
Extend the destructive-command heuristics to cover framework DB-reset verbs, e.g.:
- Laravel:
migrate:fresh,migrate:refresh,migrate:reset,db:wipe - Rails:
db:reset,db:drop,db:schema:load - Django:
manage.py flush,migrate <app> zero - Prisma:
prisma migrate reset,prisma db push --force-reset - TypeORM/Sequelize:
schema:drop
Treat these like other destructive operations (confirm even in acceptEdits/auto; allow per-project allowlisting).
Environment
- Claude Code v2.1.170
- macOS (darwin)
- Permission mode: auto (acceptEdits)
8 Comments
Your root-cause analysis is exactly right: the Bash safety layer gates shell danger patterns (
rm -rf,dd,mkfs) but treats framework/ORM reset verbs as ordinary commands, even thoughphp artisan migrate:freshis as destructive asDROP DATABASE. Agreed that the proper fix is provider-side — these verbs should require confirmation even inacceptEdits/auto, with per-project allowlisting.Until that lands, you can close the gap today with a
PreToolUsehook onBashthat denies these verbs (exit 2 = block). I verified a hook against your exact command and your suggested list:Wire it in
~/.claude/settings.json:I smoke-tested 20 cases: it blocks
migrate:fresh/migrate:refresh/migrate:reset/db:wipe, Djangoflushandmigrate <app> zero, Railsdb:drop/db:reset/db:schema:load,prisma migrate reset,typeorm schema:drop,sequelize-cli db:drop,DROP DATABASE,dropdb— while letting safe incremental commands through (php artisan migrate,db:migrate,prisma migrate dev,typeorm migration:run), so it doesn't get in the way of normal work.Two extra belts: enabling MySQL binary logging (as you found) makes a wipe recoverable, and adding the same verbs to your project
CLAUDE.md("never run migrate:fresh/db:wipe/db:reset — ask first") reduces how often the model even proposes them.Full hook + the test that covers these cases lives here if useful: https://github.com/yurukusa/cc-safe-setup/blob/main/examples/block-database-wipe.sh (I maintain this; it's a free MIT collection of Claude Code safety hooks).
The problem is even more complicated, tbh...
At one point claude created a tmp database to load up a backup to check somethings and after that it tried to delete said tmp database, but claude used 'php artisan tinker' + 'DB::connection("mongodb")->getMongoDB("name")->drop()', which is a problem cause the laravel driver seems to always return the default database connection with this, so... yeah... claude deleted my dev database too... Luckly, the backup that it was testing was from that database, just a couple of minutes old, so it wasn't a problem, but...
That mongodb-driver footgun is genuinely nasty —
getMongoDB("name")returning the default connection regardless of the name you pass means an inline->drop()can hit a different database than the one you think you're targeting. And you're right that it slips past themigrate:fresh-style patterns entirely: the destructive call arrives as arbitrary PHP insidephp artisan tinker, so a verb-list guard never sees it.The way to cover this is a second rule that fires when
artisan tinkercarries an inline destructive call. I verified this against your exact case and a few variants:Smoke-tested 9 cases — it blocks
tinker --execute='…getMongoDB("x")->drop()',->dropDatabase(),->truncate(), andDB::statement("DROP …"), while leaving read-only tinker (User::count(),User::all()) and a bare interactivetinkeruntouched.One caveat worth being honest about: once you let the model run
tinkerwith arbitrary--execute(or piped code), you're effectively giving it an open PHP shell, so a pattern guard is a safety net, not a wall — it catches the common destructive verbs but can't see, say, a->delete()built dynamically. The sturdier habit is to keepartisan tinkeroff the auto-approve path entirely in dev, and (as you found) keep a fresh backup — the few-minutes-old dump is exactly what saved you here.I folded the framework/ORM-reset patterns this thread surfaced into the free hook (
block-database-wipe.sh) here if useful: https://github.com/yurukusa/cc-safe-setup/blob/main/examples/block-database-wipe.shFirst, @yurukusa thanks, i will take a look at your sh later 🫡
Now, just to add to my last message, i tested the command manually on tinker, which i hadn't before, and found the problem... If u run that exactly command, the driver returns the default database, but it also returns
USER DEPRECATED Since mongodb/laravel-mongodb:5.2, Method "getMongoDB()" is deprecated, use "getDatabase()" instead. in vendor/mongodb/laravel-mongodb/src/Connection.php on line 135., if u usegetDatabase("name")instead, than the driver returns the correct "name" database... 😅<img width="1687" height="620" alt="Image" src="https://github.com/user-attachments/assets/fb321378-5517-4352-bf49-15b275feedc8" />
Nice catch — that deprecation notice is the tell. So the footgun is specifically the old
getMongoDB()quietly falling back to the default connection, whilegetDatabase("name")targets correctly. Good to know it's scoped to code still on the deprecated method. Thanks for testing it through.This is a sharp catch. The safety net only knows shell-shaped danger (rm -rf, dd, mkfs), so anything destructive that hides behind a framework binary sails right through in auto mode. Two things that have saved me while we wait on a built-in fix. First, add a deny-list in your settings so the specific reset commands need real approval even in auto, for example deny Bash(php artisan migrate:fresh), Bash(php artisan db:wipe), and the Rails/Django/Prisma equivalents if you use them. Second, point Claude at a throwaway test database via a separate connection so a reset can never touch anything you care about. And since you found out the hard way, keeping binary logging on (you already had it) plus a quick nightly dump is a cheap seatbelt. I keep a short safety checklist for this kind of auto-mode setup at guima.ai/safety if it helps.
Good catch that the gap is specific to framework-level verbs rather than shell-level ones. Anything matching on rm -rf and friends is never going to catch migrate:fresh or db:wipe, and that includes hook-based guards outside Claude Code itself, not only the built-in check. I use Prismor (github.com/PrismorSec/prismor) as a PreToolUse layer for destructive shell commands, and it has the identical blind spot: an ORM's own destructive verb doesn't match anything it looks for, since it isn't shell syntax at all.
The maintained list you're proposing, treating framework destructive verbs the same as rm -rf, seems like the right shape regardless of which layer enforces it. Worth keeping that list somewhere hook authors can also pull from, since every tool doing command-level blocking hits this same long tail.
This shows why command safety cannot stop at executable names.
php artisan migrate:freshis semantically equivalent to a destructive database reset even though it contains noDROP TABLEtoken. The same class exists in Rails, Django, Prisma, Terraform, cloud CLIs, and package scripts.A practical design is policy packs for framework-level aliases, with database environment/host context as an additional risk signal. In Auto mode, an unknown framework reset should fail closed rather than inherit the generic “php is allowed” decision.
Disclosure: I maintain gate.cat, an open-source pre-execution veto layer with extensible policy packs. This incident is exactly why we treat semantic aliases as first-class policies rather than relying on a short shell denylist.