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

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:

If it cannot see a local URL, it blocks the reset or force-push. It does not replace backups.

Install

  1. In the project root, create .cursor/hooks.json with the snippet above. That is the project-level path. Cursor also loads user hooks from ~/.cursor/hooks.json. (Cursor hooks)
  2. 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)
  3. Run chmod +x .cursor/hooks/block-remote-migrate.sh.
  4. Cursor watches hooks.json and reloads it on save. beforeShellExecution runs before the agent executes a matching shell command. The matcher prisma|drizzle-kit limits the hook to those commands. failClosed means 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.

Not affiliated with Cursor, Prisma, or Drizzle.

We count anonymous page visits.

Full pack (Prisma + Drizzle, 110 tests, v1.3): $19