How to stop Claude Code from dropping your database
Start with ask rules on the database clients in .claude/settings.json, so no SQL runs without you seeing it:
{
"permissions": {
"ask": ["Bash(psql *)", "Bash(mysql *)", "Bash(sqlite3 *)"],
"deny": ["Bash(dropdb *)", "Bash(npx prisma migrate reset *)", "Bash(rails db:drop *)"]
}
}
That prompts on every client call, including a harmless SELECT. If that is too noisy, the hook below blocks only the destructive statements. Neither one sees SQL inside a file, so the last section is about the database role.
What a rule can see of the SQL
Rules go in .claude/settings.json for the repo or ~/.claude/settings.json for every project. Claude Code checks deny, then ask, then allow (permissions docs).
A rule matches the command text, not the SQL. You can write Bash(psql *DROP *) and it will match psql -c "DROP TABLE users". It cannot see inside psql -f cleanup.sql. Claude Code’s docs say patterns that try to constrain arguments are fragile, and this is the kind they mean. Asking on the whole client is the rule that holds up.
An ask rule on a specific command still prompts with the sandbox on, and a matching ask rule prompts even when a hook returned allow.
A hook that reads the statement
This hook lets a SELECT and a DELETE ... WHERE through and blocks DROP, TRUNCATE and a DELETE with no WHERE, when the command also names a database client. Save it as ~/.claude/hooks/no-destructive-sql.mjs:
#!/usr/bin/env node
let raw = "";
process.stdin.on("data", (d) => (raw += d));
process.stdin.on("end", () => {
let cmd = "";
try { cmd = String(JSON.parse(raw).tool_input?.command ?? ""); } catch {}
const client = /\b(psql|pgcli|mysql|mariadb|sqlite3|duckdb|clickhouse-client|sqlcmd)\b/;
const drop = /\bDROP\s+(TABLE|DATABASE|SCHEMA)\b|\bTRUNCATE\b/i;
const bareDelete = cmd.split(";").some((s) => /\bDELETE\s+FROM\b/i.test(s) && !/\bWHERE\b/i.test(s));
if (client.test(cmd) && (drop.test(cmd) || bareDelete)) {
console.error("Blocked: destructive SQL. Stop and ask the human to run it.");
process.exit(2);
}
});
Register it in ~/.claude/settings.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "node \"$HOME/.claude/hooks/no-destructive-sql.mjs\"", "onFailure": "block" }
]
}
]
}
}
Exit code 2 is the block. Keep onFailure: "block", which needs Claude Code 2.1.295 or later, so that a hook that crashes does not let the statement run (hooks docs).
What gets past: files, ORM resets and MCP
Nine commands, and what the script did with each:
BLOCKED psql -c "DROP TABLE users"
BLOCKED mysql -e 'TRUNCATE orders'
BLOCKED echo "DELETE FROM users;" | psql app
ran psql -c "DELETE FROM users WHERE id = 1"
ran psql -c "SELECT 1"
ran psql -f cleanup.sql
ran npx prisma migrate reset --force
ran python manage.py flush --no-input
ran rails db:drop
The last four can wipe data, and all four ran:
psql -f cleanup.sqlruns whatever is in the file. The hook sees a filename.prisma migrate reset,manage.py flushandrails db:dropwipe a database with no SQL in the command. Name them inpermissions.deny, as the first block on this page does for two of them.
A fifth case never touches the shell. If the agent has a database MCP server, the SQL goes through an MCP tool call, and a Bash hook does not fire for it. Match the MCP tool in a second hook, or leave write tools off that server.
Give the agent a connection string for a role that has no DROP, TRUNCATE or DELETE on anything you care about. Keep the production URL out of .env files the agent can read. With that role, no spelling of the command works.
What ACP reads
ACP’s hook holds DROP, TRUNCATE and a DELETE with no WHERE when the statement is handed to psql, pgcli, mysql, mariadb, mycli, sqlite3, duckdb, clickhouse-client or sqlcmd. It reads the statement from a -c or -e flag, a heredoc, a here-string, or an echo piped into the client. In a Claude Code session you see the statement in a prompt before it runs. An unattended run is blocked at that point, and you approve on the console or from the email.
It has the same blind spots as the hook above, and one more: its built-in check looks for the client at the start of a command, so the SQL in docker exec db psql -c "DROP TABLE users" is not read today. SQL in a file is not read. An ORM reset against a local database is recorded in the log, not held. And this check only reads shell commands, so SQL sent through an MCP tool does not reach it.
Frequently asked questions
How do I stop Claude Code from running DROP TABLE?
Put Bash(psql *), Bash(mysql *) and your other database clients in permissions.ask so every client call prompts, and add a PreToolUse hook that blocks a client command containing DROP, TRUNCATE or a DELETE with no WHERE. Then give the agent a database role that cannot drop anything.
Can a Claude Code deny rule match the SQL inside a command?
Only loosely. A rule matches the command text, so Bash(psql *DROP *) matches psql -c with DROP in the text. It does not see SQL in a file passed with -f, and Claude Code’s docs call rules that constrain arguments fragile.
Does this stop prisma migrate reset or rails db:drop?
Not the SQL hook, because those commands contain no SQL. Add them by name: Bash(npx prisma migrate reset *), Bash(rails db:drop *) and so on in permissions.deny or permissions.ask.
What actually prevents an agent from deleting a production database?
Credentials. If the connection string in the agent’s environment belongs to a role without DROP, TRUNCATE or DELETE on production, no spelling of the command works. Rules and hooks are the layer on top.
Related
- How to stop a coding agent from dropping your production database
- How to stop Claude Code from running terraform destroy
- Why a Claude Code deny rule did not fire
- Claude Code hooks reference