Auto-accept mode runs destructive framework DB commands (e.g. `php artisan migrate:fresh`) without confirmation → data loss

Status Open
Maintainer reply None cached
Activity 8 comments · opened Jun 17, 2026

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 as
DROP DATABASE yet matches no shell pattern, so in auto mode it runs unchallenged.

Steps to reproduce

  1. A project with a destructive framework reset command available (Laravel here).
  2. Run Claude Code in auto-accept mode.
  3. Give a task that leads the agent to "reset the schema and run tests."
  4. The agent runs php artisan migrate:fresh against 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)

View original on GitHub ↗

8 Comments

yurukusa · 2 months ago

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 though php artisan migrate:fresh is as destructive as DROP DATABASE. Agreed that the proper fix is provider-side — these verbs should require confirmation even in acceptEdits/auto, with per-project allowlisting.
Until that lands, you can close the gap today with a PreToolUse hook on Bash that denies these verbs (exit 2 = block). I verified a hook against your exact command and your suggested list:

INPUT=$(cat); CMD=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
[[ -z "$CMD" ]] && exit 0
echo "$CMD" | grep -qiE 'artisan +(migrate:fresh|migrate:refresh|migrate:reset|db:wipe)' && { echo "BLOCKED: destructive Laravel DB command" >&2; exit 2; }
echo "$CMD" | grep -qiE 'manage\.py +(flush|sqlflush)|manage\.py +migrate +\w+ +zero' && { echo "BLOCKED: destructive Django DB command" >&2; exit 2; }
echo "$CMD" | grep -qiE '(rake|rails) +db:(drop|reset|schema:load)' && { echo "BLOCKED: destructive Rails DB command" >&2; exit 2; }
echo "$CMD" | grep -qiE 'prisma +migrate +reset|prisma +db +push +--force-reset|typeorm +schema:drop|sequelize(-cli)? +db:drop' && { echo "BLOCKED: destructive ORM DB command" >&2; exit 2; }
echo "$CMD" | grep -qiE 'DROP +(DATABASE|TABLE|SCHEMA)|TRUNCATE +TABLE' && { echo "BLOCKED: destructive SQL" >&2; exit 2; }
exit 0

Wire it in ~/.claude/settings.json:

{ "hooks": { "PreToolUse": [{ "matcher": "Bash",
  "hooks": [{ "type": "command", "command": "~/.claude/hooks/block-database-wipe.sh" }] }] } }

I smoke-tested 20 cases: it blocks migrate:fresh/migrate:refresh/migrate:reset/db:wipe, Django flush and migrate <app> zero, Rails db: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).

gilney-canaltelecom · 2 months ago

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...

yurukusa · 2 months ago

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 the migrate:fresh-style patterns entirely: the destructive call arrives as arbitrary PHP inside php artisan tinker, so a verb-list guard never sees it.
The way to cover this is a second rule that fires when artisan tinker carries an inline destructive call. I verified this against your exact case and a few variants:

if echo "$CMD" | grep -qiE 'artisan[[:space:]]+tinker'; then
  if echo "$CMD" | grep -qiE '\->[[:space:]]*(drop(Database|Collection)?|truncate|forceDelete)[[:space:]]*\(' \
     || echo "$CMD" | grep -qiE 'DB::statement\(.*(DROP|TRUNCATE)'; then
    echo "BLOCKED: artisan tinker running a destructive DB call (drop/truncate)." >&2
    echo "Note: the Laravel mongodb getMongoDB() returns the DEFAULT connection." >&2
    exit 2
  fi
fi

Smoke-tested 9 cases — it blocks tinker --execute='…getMongoDB("x")->drop()', ->dropDatabase(), ->truncate(), and DB::statement("DROP …"), while leaving read-only tinker (User::count(), User::all()) and a bare interactive tinker untouched.
One caveat worth being honest about: once you let the model run tinker with 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 keep artisan tinker off 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.sh

gilney-canaltelecom · 2 months ago

First, @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 use getDatabase("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" />

yurukusa · 2 months ago

Nice catch — that deprecation notice is the tell. So the footgun is specifically the old getMongoDB() quietly falling back to the default connection, while getDatabase("name") targets correctly. Good to know it's scoped to code still on the deprecated method. Thanks for testing it through.

guimaferreira · 2 months ago

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.

Ar9av · 1 month ago

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.

BGMLAI · 1 month ago

This shows why command safety cannot stop at executable names. php artisan migrate:fresh is semantically equivalent to a destructive database reset even though it contains no DROP TABLE token. 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.