flowingkhaosSign in
Build a Claude Code mod that fails closedstep 5 of 8 · 0 of 18 carriedSign in to keep it
Step 5 of 8 · weight 3 · about 40 minutes

Read it, then pass the check at the end to open step 6.

Hold a production migration until you say yes

Updated

What this step is for

A permission rule says yes or no. Some calls need a question: this migration is about to run on the production database, do you mean it? That needs a mod, because only a mod can stop a tool call, ask you, and then let it go or refuse it.

This is the mod that would have saved us twice: a throwaway test run applied its migration to our live database before anyone reviewed it (see the fix linked to this step). It also teaches the most important rule of this workshop: a guard must fail closed. If your guard throws, the call it guards must be refused, not let through.

Do it

mkdir -p ~/claude-mods/db-guard/.claude-plugin ~/claude-mods/db-guard/hooks ~/.config/claude-mods

List your production database hosts, one per line. If the file is missing, the guard treats every database as production, so a missing file fails closed too:

echo "prod-db.internal" > ~/.config/claude-mods/prod-db-hosts

.claude-plugin/plugin.json and hooks/hooks.json as in step 2 (name, version, description, author), with the name db-guard and the module ./register.ts. Then hooks/register.ts:

import type { Register } from 'claude-code'

// Commands that change a schema. A miss is a hole; a false hold costs one question.
const MIGRATION = [/\bdrizzle-kit\s+(push|migrate|drop)\b/, /\bpayload\s+migrate/, /\bprisma\s+(migrate|db\s+push)\b/]
const URL_IN_COMMAND = /\bpostgres(?:ql)?:\/\/[^\s"']+/g

// Host only: the URL carries the password, so it never leaves this function.
const hostOf = (url: string) => {
  try {
    return new URL(url).hostname.toLowerCase() || undefined
  } catch {
    return undefined
  }
}

export const register: Register = on => {
  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
    if (!MIGRATION.some(re => re.test(e.command))) return next(e)

    const urls = [await $.env.get('DATABASE_URL'), ...(e.command.match(URL_IN_COMMAND) ?? [])]
    const hosts = [...new Set(urls.map(u => (u ? hostOf(u) : undefined)).filter((h): h is string => !!h))]
    if (hosts.length === 0) return next(e)

    const home = (await $.env.get('HOME')) ?? ''
    const list = await $.fs.read(`${home}/.config/claude-mods/prod-db-hosts`).catch(() => undefined)
    const prod = list?.split('\n').map(l => l.trim()).filter(Boolean)
    const hit = hosts.filter(h => !prod || prod.includes(h))
    if (hit.length === 0) return next(e)

    let answer = 'Cancel'
    try {
      answer = await $.ui.ask(`Run this migration on PRODUCTION (${hit.join(', ')})?`, ['Run on production', 'Cancel'])
    } catch {
      // No one to ask: a headless run (`claude -p`), or the question was dismissed.
      return { deny: `db-guard: this would migrate production (${hit.join(', ')}) and no one confirmed it.` }
    }
    return answer === 'Run on production' ? next(e) : { deny: 'db-guard: cancelled by the operator.' }
  }).catch(($, e, next) =>
    // The guard itself failed. If the command already ran, keep its result; otherwise refuse.
    next.called ? next(e) : { deny: 'db-guard failed, so a possible production migration was refused.' },
  )
}

Three things to notice:

  • The .catch on the hook is the fail-closed rule in code. Without it, an error in your guard lets the migration through.
  • $.ui.ask rejects when no one is there. Under claude -p, or in an agent pipeline, the question cannot be answered, so the guard refuses. Headless means refuse.
  • No hook says allow. A mod may hold, ask or refuse; only a person approves.

Our full guard also reads the .env.local and .env files near the command and any file the command sources; this one checks the environment and the command, which is enough to learn the pattern.

Check it

The probe below only prints text, so nothing runs even if your guard is wrong. Start a session with the mod:

claude --plugin-dir ~/claude-mods/db-guard

and ask: Run exactly: echo drizzle-kit push postgres://u:p@prod-db.internal/db. Claude Code asks Run this migration on PRODUCTION (prod-db.internal)? Choose Cancel, and the call is refused with cancelled by the operator.

Then the headless case, which must refuse without asking:

claude -p "Run exactly: echo drizzle-kit push postgres://u:p@prod-db.internal/db" --plugin-dir ~/claude-mods/db-guard

Claude reports db-guard: this would migrate production (prod-db.internal) and no one confirmed it.: the host, never the password. (Put the prompt first: --allowedTools and some other flags take several values and would swallow it.)

The gate · a reading check closes this stepweight 3 of Σw 15

Check what you read in “Hold a production migration until you say yes”

Three questions drawn from a larger pool, so no two papers are quite the same. Two right passes it and opens the next step. The questions are public; scoring them needs an account, and the key never leaves the server.

Loading the check…

All 8 stepsTake the check

The columns, fixes and workshops continue in the community: SEO, AI, and making money with code.

Join on Whop

Luke Sidney writes and builds everything here, alone, in the open. lukesidney.me