Block remote Prisma and Drizzle wipes in Cursor
Last checked: 2026-10-23
A small Cursor hook that stops prisma migrate reset and drizzle-kit push --force when the database URL is not local. Localhost, 127.0.0.1, file, and sqlite URLs still run. Copy the two snippets, then follow the install steps.
We count anonymous page visits.
hooks.json
{
"version": 1,
"hooks": {
"beforeShellExecution": [
{
"command": ".cursor/hooks/block-remote-migrate.sh",
"timeout": 10,
"failClosed": true,
"matcher": "prisma|drizzle-kit"
}
]
}
}
block-remote-migrate.sh
#!/bin/sh
# Free Cursor hook: block prisma migrate reset and drizzle-kit push --force
# when the target database URL is not local.
# Derived from Schema Migration Guard v1.3 hooks/block-destructive-migrate.sh
# (command scan only; no Claude Code payload, no db drop / drizzle reset).
# Docs: https://cursor.com/docs/hooks
#
# Blocked (when the URL is not local):
# prisma migrate reset
# drizzle-kit push --force (or push with a force flag such as -f)
# Local URLs still run: localhost, 127.0.0.1, ::1, file:, sqlite:, sqlite3:
# URL sources: --url on the command, DATABASE_URL / DIRECT_URL in the hook
# environment, then .env / .env.local in the payload cwd.
set -eu
json_escape() {
printf '%s' "$1" | awk '
BEGIN { ORS = "" }
{
n = length($0)
for (i = 1; i <= n; i++) {
c = substr($0, i, 1)
if (c == "\\") printf "\\\\"
else if (c == "\"") printf "\\\""
else if (c == "\n") printf "\\n"
else if (c == "\r") printf "\\r"
else if (c == "\t") printf "\\t"
else printf "%s", c
}
}
'
}
extract_string_field() {
key=$1
printf '%s' "$2" | awk -v key="$key" '
function skip_ws(s) {
while (s ~ /^[ \t\n\r]/) s = substr(s, 2)
return s
}
function parse_string(s, c, out, esc) {
out = ""
esc = 0
while (length(s) > 0) {
c = substr(s, 1, 1)
s = substr(s, 2)
if (esc) {
if (c == "n") out = out "\n"
else if (c == "t") out = out "\t"
else if (c == "r") out = out "\r"
else out = out c
esc = 0
continue
}
if (c == "\\") { esc = 1; continue }
if (c == "\"") {
RESULT = out
REST = s
return 1
}
out = out c
}
return 0
}
BEGIN { needle = "\"" key "\"" }
{
s = $0
while ((i = index(s, needle)) > 0) {
rest = skip_ws(substr(s, i + length(needle)))
if (substr(rest, 1, 1) != ":") {
s = substr(s, i + 1)
continue
}
rest = skip_ws(substr(rest, 2))
if (substr(rest, 1, 1) != "\"") {
s = substr(s, i + 1)
continue
}
if (parse_string(substr(rest, 2))) {
print RESULT
exit 0
}
s = substr(s, i + 1)
}
}
'
}
split_segments() {
printf '%s' "$1" | awk '
BEGIN { token = "" }
function flush() {
gsub(/^[ \t\n\r]+|[ \t\n\r]+$/, "", token)
if (token != "") print token
token = ""
}
{
line = $0
n = length(line)
i = 1
in_squote = 0
in_dquote = 0
while (i <= n) {
c = substr(line, i, 1)
nextc = substr(line, i + 1, 1)
if (in_squote) {
token = token c
if (c == "'\''") in_squote = 0
i++
continue
}
if (in_dquote) {
token = token c
if (c == "\\") {
if (i < n) {
token = token nextc
i += 2
continue
}
}
if (c == "\"") in_dquote = 0
i++
continue
}
if (c == "'\''") { in_squote = 1; token = token c; i++; continue }
if (c == "\"") { in_dquote = 1; token = token c; i++; continue }
if (c == "\n" || c == ";" || c == "|" || c == "&") {
if ((c == "|" && nextc == "|") || (c == "&" && nextc == "&")) {
flush()
i += 2
continue
}
if (c == "|" || c == "&" || c == ";" || c == "\n") {
flush()
i++
continue
}
}
token = token c
i++
}
flush()
}
'
}
tokenize() {
printf '%s' "$1" | awk '
{
line = $0
n = length(line)
i = 1
token = ""
in_squote = 0
in_dquote = 0
while (i <= n) {
c = substr(line, i, 1)
nextc = substr(line, i + 1, 1)
if (in_squote) {
if (c == "'\''") in_squote = 0
else token = token c
i++
continue
}
if (in_dquote) {
if (c == "\\") {
i++
if (i <= n) token = token substr(line, i, 1)
i++
continue
}
if (c == "\"") in_dquote = 0
else token = token c
i++
continue
}
if (c == "'\''") { in_squote = 1; i++; continue }
if (c == "\"") { in_dquote = 1; i++; continue }
if (c == " " || c == "\t") {
if (token != "") { print token; token = "" }
i++
continue
}
token = token c
i++
}
if (token != "") print token
}
'
}
strip_prefixes() {
awk '
function is_runner(t) {
return (t == "npx" || t == "pnpm" || t == "yarn" || t == "bunx" || t == "bun" || t == "npm")
}
function is_sub(t) {
return (t == "exec" || t == "dlx" || t == "x")
}
function is_wrap(t) {
return (t == "env" || t == "command" || t == "exec" || t == "sudo" || t == "time" || t == "nice" || t == "nohup")
}
function is_assign(t) {
return (t ~ /^[A-Za-z_][A-Za-z0-9_]*=/)
}
function is_flag(t) {
return (t == "--" || t ~ /^-/)
}
function skip_flags() {
while (i <= NR && is_flag(lines[i])) {
if (lines[i] == "-p" || lines[i] == "--package") {
i++
if (i <= NR && !is_flag(lines[i])) i++
continue
}
i++
}
}
{
lines[NR] = $0
}
END {
i = 1
progressed = 1
while (progressed && i <= NR) {
progressed = 0
while (i <= NR && is_assign(lines[i])) { i++; progressed = 1 }
while (i <= NR && is_wrap(lines[i])) {
i++
while (i <= NR && (is_flag(lines[i]) || is_assign(lines[i]))) i++
progressed = 1
}
while (i <= NR && is_runner(lines[i])) {
i++
skip_flags()
if (i <= NR && is_sub(lines[i])) {
i++
skip_flags()
}
progressed = 1
}
}
for (; i <= NR; i++) print lines[i]
}
'
}
unwrap_shell_c() {
awk '
function base(t, n, p) {
n = split(t, p, "/")
t = p[n]
split(t, p, "@")
return p[1]
}
function is_shell(t, b) {
b = base(t)
return (b == "sh" || b == "bash" || b == "zsh")
}
function is_flag(t) {
return (t == "--" || t ~ /^-/)
}
function is_c_flag(t) {
if (t == "-c") return 1
if (t ~ /^-[a-zA-Z]*c[a-zA-Z]*$/ && t !~ /^--/) return 1
return 0
}
{
tok[++n] = $0
}
END {
i = 1
if (i > n || !is_shell(tok[i])) exit 0
i++
while (i <= n) {
if (is_c_flag(tok[i])) {
if (i + 1 <= n) print tok[i + 1]
exit 0
}
if (is_flag(tok[i])) { i++; continue }
exit 0
}
}
'
}
reason_for_segment() {
awk '
function base(t, n, p) {
n = split(t, p, "/")
t = p[n]
split(t, p, "@")
return p[1]
}
function is_flag(t) {
return (t == "--" || t ~ /^-/)
}
function drizzle_force(t) {
return (t == "--force" || index(t, "--force=") == 1 || t == "-f")
}
{
tok[++n] = $0
}
END {
i = 1
while (i <= n && is_flag(tok[i])) i++
if (i > n) exit 0
kind = base(tok[i])
start = i
if (kind != "prisma" && kind != "drizzle-kit") exit 0
for (i = start; i <= n; i++) {
if (!is_flag(tok[i])) words[++w] = base(tok[i])
}
if (kind == "prisma") {
if (w >= 3 && words[1] == "prisma" && words[2] == "migrate" && words[3] == "reset") {
print "prisma_reset"
exit 0
}
}
if (kind == "drizzle-kit") {
if (w >= 2 && words[1] == "drizzle-kit" && words[2] == "push") {
for (i = start; i <= n; i++) {
if (drizzle_force(tok[i])) {
print "drizzle_force"
exit 0
}
}
}
}
}
'
}
urls_from_command() {
tokenize "$1" | awk '
{
tok[++n] = $0
}
END {
for (i = 1; i <= n; i++) {
t = tok[i]
if (index(t, "DATABASE_URL=") == 1) {
print substr(t, 14)
continue
}
if (index(t, "DIRECT_URL=") == 1) {
print substr(t, 12)
continue
}
if (index(t, "--url=") == 1) {
print substr(t, 7)
continue
}
if (t == "--url" && i < n) {
print tok[i + 1]
i++
}
}
}
'
}
read_env_file_var() {
file=$1
key=$2
[ -f "$file" ] || return 0
awk -v key="$key" '
{
line = $0
sub(/\r$/, "", line)
sub(/^[ \t]+/, "", line)
if (line == "" || line ~ /^#/) next
if (index(line, "export ") == 1) {
line = substr(line, 8)
sub(/^[ \t]+/, "", line)
}
eq = index(line, "=")
if (eq < 2) next
k = substr(line, 1, eq - 1)
gsub(/[ \t]+$/, "", k)
if (k != key) next
v = substr(line, eq + 1)
sub(/^[ \t]+/, "", v)
if (v ~ /^"/) {
v = substr(v, 2)
if (v ~ /"$/) v = substr(v, 1, length(v) - 1)
} else if (v ~ /^'\''/) {
v = substr(v, 2)
if (v ~ /'\''$/) v = substr(v, 1, length(v) - 1)
} else {
sub(/[ \t]+(#.*)?$/, "", v)
}
print v
exit 0
}
' "$file"
}
url_kind() {
printf '%s' "$1" | awk '
function last_at(s, i, p) {
p = 0
for (i = 1; i <= length(s); i++) if (substr(s, i, 1) == "@") p = i
return p
}
{
raw = $0
gsub(/^[ \t\n\r]+|[ \t\n\r]+$/, "", raw)
if (raw == "") { print "unknown"; exit 0 }
u = tolower(raw)
if (u ~ /^(file|sqlite|sqlite3):/) { print "local"; exit 0 }
q = index(u, "?")
if (q > 0) u = substr(u, 1, q - 1)
sep = index(u, "://")
if (sep < 1) { print "remote"; exit 0 }
rest = substr(u, sep + 3)
at = last_at(rest)
hostport = (at > 0) ? substr(rest, at + 1) : rest
p = index(hostport, "/")
if (p > 0) hostport = substr(hostport, 1, p - 1)
if (hostport ~ /^\[/) {
closebr = index(hostport, "]")
host = (closebr > 1) ? substr(hostport, 2, closebr - 2) : hostport
} else {
c = index(hostport, ":")
host = (c > 0) ? substr(hostport, 1, c - 1) : hostport
}
if (host == "localhost" || host == "127.0.0.1" || host == "::1" || host == "0.0.0.0") {
print "local"
exit 0
}
print "remote"
}
'
}
collect_target_urls() {
command=$1
cwd=$2
urls=$(urls_from_command "$command" || true)
if [ -n "$urls" ]; then
printf '%s\n' "$urls"
return 0
fi
if [ -n "${DATABASE_URL-}" ]; then
printf '%s\n' "$DATABASE_URL"
return 0
fi
if [ -n "${DIRECT_URL-}" ]; then
printf '%s\n' "$DIRECT_URL"
return 0
fi
if [ -n "$cwd" ]; then
for f in "$cwd/.env" "$cwd/.env.local"; do
v=$(read_env_file_var "$f" DATABASE_URL || true)
if [ -z "$v" ]; then
v=$(read_env_file_var "$f" DIRECT_URL || true)
fi
if [ -n "$v" ]; then
printf '%s\n' "$v"
return 0
fi
done
fi
}
is_local_target() {
urls=$(collect_target_urls "$1" "$2" || true)
if [ -z "$urls" ]; then
return 1
fi
printf '%s\n' "$urls" | while IFS= read -r url || [ -n "$url" ]; do
[ -z "$url" ] && continue
kind=$(url_kind "$url" || true)
if [ "$kind" != "local" ]; then
exit 1
fi
done
}
classify() {
command="$1"
depth=${2:-0}
if [ -z "$command" ]; then
printf '%s' ""
return 0
fi
if [ "$depth" -gt 16 ]; then
printf '%s' ""
return 0
fi
split_segments "$command" | while IFS= read -r seg || [ -n "$seg" ]; do
[ -z "$seg" ] && continue
stripped=$(tokenize "$seg" | strip_prefixes || true)
[ -z "$stripped" ] && continue
inner=$(printf '%s\n' "$stripped" | unwrap_shell_c || true)
if [ -n "$inner" ]; then
seg_reason=$(classify "$inner" $((depth + 1)) || true)
else
seg_reason=$(printf '%s\n' "$stripped" | reason_for_segment || true)
fi
if [ -n "$seg_reason" ]; then
printf '%s' "$seg_reason"
break
fi
done
}
deny_text() {
case "$1" in
prisma_reset)
printf '%s' "This Prisma reset was blocked because the database URL is not local (localhost, 127.0.0.1, file, or sqlite)."
;;
drizzle_force)
printf '%s' "This forced Drizzle push was blocked because the database URL is not local (localhost, 127.0.0.1, file, or sqlite)."
;;
*)
printf '%s' "This Prisma or Drizzle command was blocked because the database URL is not local."
;;
esac
}
input=$(cat || true)
command=$(extract_string_field command "$input" || true)
cwd=$(extract_string_field cwd "$input" || true)
reason=$(classify "$command" || true)
if [ -z "$reason" ]; then
printf '%s\n' '{"permission":"allow"}'
exit 0
fi
if is_local_target "$command" "$cwd"; then
printf '%s\n' '{"permission":"allow"}'
exit 0
fi
msg=$(deny_text "$reason")
escaped=$(json_escape "$msg")
printf '%s\n' "{\"permission\":\"deny\",\"user_message\":\"${escaped}\",\"agent_message\":\"${escaped}\"}"
exit 0
What it catches
prisma migrate reset, including extra spaces,&&/;chains,npx/pnpm/yarn/bunx/bun xprefixes, andbash -c/sh -c/zsh -c.drizzle-kit push --force,-f, and--force=, with the same prefixes.- Those commands only when the target URL is not local. It reads
--urlon the command, thenDATABASE_URL/DIRECT_URLin the hook process environment, then.env/.env.localin the command working directory.
What it does not catch
This is a free subset of Schema Migration Guard v1.3. It does not include the rest of that pack:
prisma db drop,prisma db push --force-reset,--accept-data-loss, anddrizzle-kit reset.- Cursor rules (
.mdcfiles): dual-read review ofDROP TABLE/TRUNCATE/DELETE FROM, migration checklists, and lock-safe Postgres patterns. - Claude Code hooks.
eval, subshells, and command substitution. The full pack's v1.2 hook does.npm runscripts that hide a reset. The hook seesnpm run …and allows it.- Raw SQL typed by the agent.
git push --force.drizzle-kit pushwithout a force flag, even on a remote database.- An agent that edits or deletes the hook files.
If it cannot see a local URL, it blocks the reset or force-push. It does not replace backups.
Install
- In the project root, create
.cursor/hooks.jsonwith the snippet above. That is the project-level path. Cursor also loads user hooks from~/.cursor/hooks.json. (Cursor hooks) - Save the script as
.cursor/hooks/block-remote-migrate.sh. Project hook commands run from the project root, so that relative path is required. (Cursor hooks) - Run
chmod +x .cursor/hooks/block-remote-migrate.sh. - Cursor watches
hooks.jsonand reloads it on save.beforeShellExecutionruns before the agent executes a matching shell command. The matcherprisma|drizzle-kitlimits the hook to those commands.failClosedmeans a crash, timeout, or non-zero exit blocks the command instead of letting it through. If hooks still do not load, restart Cursor. (Cursor hooks)
Sources
All fetched 2026-10-23.
- Cursor: Hooks (project path
.cursor/hooks.json,beforeShellExecutioninputcommand/cwd/sandbox,failClosed, matcher, reload on save) - Schema Migration Guard v1.3 README and CHANGELOG (command list, local vs remote is this free subset; the pack's hook blocks the listed commands on every database)
Not affiliated with Cursor, Prisma, or Drizzle.
We count anonymous page visits.