Makes Claude ask before it touches your database migrations or runs destructive SQL, with the risk and what it found.

A Claude Code mod that makes Claude ask before it touches your database migrations.
I let Claude Code work on a large Postgres codebase every day, and it's good at it. But migrations are the one place where "looks fine" isn't enough. A single generated file can drop a column or quietly lose a pile of indexes, and you find out in production. So this makes Claude ask me first, and only for migrations and the database.
claude --version. If it's older, run claude update. git clone https://github.com/balen-abd/claude-migration-guard ~/.claude/skills/migration-guard
claude -p /migration-guard should print migration-guard: Nothing asked yet.That's all. Next time Claude goes to change a migration, it asks you first. Windows, updating, removing and other ways to load it are under Install; what you can change is under Settings.
High risk
Claude wants to write a migration file: .../db/migrations/0042_cleanup.sql.
DROP COLUMN: deletes the column and its data; DROP INDEX: can make queries
slow. Found: ALTER TABLE "users" DROP COLUMN "email". Allow it?
1. No Block it. Claude is told to stop and not work around it.
2. Yes Allow this once.
3. Yes for this file Don't ask again for this file, unless something more
destructive shows up.
No comes first, so an accidental Enter (or anything that picks the first option for you) blocks.
Commands get the same treatment:
High risk
Claude wants to run prisma: npx prisma migrate reset --force.
It can wipe the database or drop data. Allow it?
Say no, or type what you want instead ("make a new migration, don't edit this one"), and Claude is told to stop and not work around it.
npm run migration:run, pnpm --filter api db:migrate, prisma migrate deploy, rails db:rollback, alembic downgrade, php artisan migrate, dotnet ef database update, make migrate, and about 30 other tools.cat >, sed -i, rm, git checkout --, find ... -delete, Remove-Item, a node -e/python -c one-liner, or a Python heredoc that rewrites one.psql -c "DROP TABLE ...", psql -f file.sql, cat x.sql | psql, mongosh --eval "db.users.drop()", dropdb, docker compose down -v.apply_migration, execute_sql with a DROP in it, Prisma's migrate-reset, or a filesystem server writing into migrations/.It watches Claude Code's Bash tool, its PowerShell tool on Windows, and its Monitor tool, so a command can't slip past by going through a different one.
Everything else goes through untouched, including anything that only reads or mentions a migration: grep ... | head, cat, commit messages and PR bodies (heredocs included), and scripts that edit a doc which happens to talk about migrations.
| Level | What counts |
|---|---|
| High | DROP TABLE, DROP COLUMN, DROP DATABASE/SCHEMA, TRUNCATE, DELETE or UPDATE that hits every row (WHERE 1=1 counts), commands that undo, reset, push or wipe |
| Medium | DROP INDEX, DROP CONSTRAINT, column type changes, renames, emptying a migration or deleting part of one, running pending migrations |
| Low | A migration with nothing destructive in it |
Each risky command says why: "it undoes applied migrations, and their down steps usually drop things", "it pushes the schema straight to the database and can drop data to make it match", and so on.
To keep it quiet:
queryRunner.dropColumn, remove_column, RemoveField, op.drop_column and migrationBuilder.DropColumn all show up as DROP COLUMN.down() or -- +goose Down, because the undo half always drops what the forward half created. Undo files (.down.sql, Flyway's U2__x.sql) are Low risk for the same reason.DROP TABLE inside a SQL comment doesn't count.When Claude is writing several migration files, pick "Yes to all files, 15 min". For the next 15 minutes ordinary migration file changes go through without asking. High-risk ones still ask, and commands always ask.
/migration-guard off turns it off for this session; /migration-guard off 30 for 30 minutes./migration-guard on turns it back on. So does restarting Claude Code, so it can't stay off by accident.mode to off in /config, or delete the folder.Type /migration-guard to see what it asked and what you answered, newest first:
Last 3 of 3 (1 blocked). /migration-guard all shows 300, /migration-guard clear empties it.
2026-10-04 20:56 blocked (nobody answered) [medium] run a package script that migrates: npm run migration:run in .../acme/api
2026-10-04 20:41 went through (yes to all files) [low] write a migration file: .../db/migrations/0043_add_index.sql in .../acme/api
2026-10-04 20:40 Yes to all files, 15 min [low] write a migration file: .../db/migrations/0042_orders.sql in .../acme/api
It also lists the changes that went through without a question because of an earlier "Yes for this file" or "Yes to all files", so you can see what happened while you weren't looking. It keeps the last 300, in the store Claude Code gives each mod (not a file in your project), with passwords and tokens in commands masked. Set keepHistory to off if you don't want it.
One command. Claude Code loads any mod it finds in ~/.claude/skills/, so there's nothing else to set up:
git clone https://github.com/balen-abd/claude-migration-guard ~/.claude/skills/migration-guard
On Windows, in PowerShell:
git clone https://github.com/balen-abd/claude-migration-guard "$HOME\.claude\skills\migration-guard"
Check it's on:
claude -p /migration-guard
It should print migration-guard: Nothing asked yet. If you get anything else, run claude --debug and look for a migration-guard line saying why it didn't load.
You need Claude Code 2.1.287 or newer (claude --version). Mods are early access: older builds, which includes the stable channel at the time of writing (2.1.285), can have them switched off. Either update, or add "CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1" to the env block of ~/.claude/settings.json.
Update: git -C ~/.claude/skills/migration-guard pull. An open session picks it up, and any "Yes for this file" answers start over.
Remove: delete the folder.
Keeping mods somewhere else? Point CLAUDE_CODE_PLUGIN_DIRS at the folder in the env block of ~/.claude/settings.json, or use claude --plugin-dir <folder> for one session. Use one way, not two, or every question comes twice.
In /config, under migration-guard:
| Setting | Default | |
|---|---|---|
mode | always | destructive-only asks only when something destructive or risky shows up; off never asks |
extraPathPattern | a regex for migration paths it misses, e.g. schema/changes/ | |
extraCommandPattern | a regex for commands it misses, e.g. ./scripts/deploy-db.sh | |
keepHistory | on | off keeps no history at all |
If a pattern isn't a valid regex, it tells you when the session starts and in /migration-guard, instead of quietly ignoring it.
Or in ~/.claude/settings.json. The key is migration-guard@skills-dir for the install above, and migration-guard if you load it with CLAUDE_CODE_PLUGIN_DIRS or --plugin-dir:
{
"pluginConfigs": {
"migration-guard@skills-dir": {
"options": { "mode": "destructive-only", "extraPathPattern": "schema/changes/" }
}
}
}
Claude Code reads these from your user settings (or --settings), not from a project's .claude/settings.json, so a repo can't change them behind your back.
Don't take my word for it. It's small enough to check:
hooks/. detect.ts decides what counts, question.ts is the wording, history.ts is /migration-guard, register.ts wires it into Claude Code.$ API, and claude plugin validate . lists every $ call a mod makes. This one asks you ($.ui.ask, $.ui.toast, $.ui.status), reads the time and the folder name for the history, keeps the history in its own store ($.store), and adds the /migration-guard command. It also hooks AskUserQuestion, but only to touch its own questions (see below). Nothing reads your files, runs anything, or sends anything anywhere: `` > ./register.ts hooks: session.start, tool.call{tool=AskUserQuestion}, command.run{command=migration-guard}, tool.call{tool=Write}, tool.call{tool=Edit}, tool.call{tool=/"^(Bash|PowerShell|Monitor)$"/}, tool.call{tool=/"^mcp__"/} > ./register.ts calls: $.clock.now (via confirm, guardCommand, remember, stateLine), $.command.register, $.session.cwd (via remember), $.store.get (via guardCommand, remember), $.store.set (via guardCommand, remember), $.ui.ask (via askPerson), $.ui.status (via confirm, guardCommand), $.ui.toast ``claude -p and CI, a dismissed question, and one more case I only found by testing live. In VS Code, Claude Code's question dialog answers itself after about 30 seconds with nobody at the keyboard, and it picked "Yes". The guard now recognizes that kind of answer (the dialog marks it) and treats it as no answer, and No is the first option in case anything picks the first one.destructive-only mode a safe migration goes through without a question, headless or not../deploy.sh), or by your app when it starts (TypeORM's migrationsRun: true and the like). Add the script to extraCommandPattern.rm $DIR/x.sql).apply_migration, execute_sql, run_sql, write_file...) and what's in the arguments.destructive-only mode something past that point in a huge file won't be scanned.It doesn't replace backups. Keep them, and give Claude a database user that can't drop things in production.
claude plugin validate --strict .
claude plugin test .
100 tests. CI runs them on Linux, macOS and Windows, against 2.1.287 and the latest Claude Code, every week.
To see if it's actually worth having, I replayed every tool call from 365 of my own past Claude Code sessions through it: 26,157 shell commands and 682 file writes and edits. It would have asked 46 times. 21 were migration files Claude wrote or edited, 23 were migration files changed from the shell or by a Python script, and 2 were real npm run migration:run runs. As far as I can tell only one was a false alarm: deleting from a scratch copy of the migrations folder. The other 26,000-odd commands went through without a word.
I also ran it for real. By hand on 2.1.287: no, yes, "Yes for this file", and a subagent's write. Headless with claude -p on 2.1.289, installed with the one git clone above: a destructive migration write and npm run migration:run were blocked, an ordinary file went through, destructive-only set in settings let a safe migration through, and /migration-guard showed it all.
MIT
hooks/register.ts 349 lines1import type { EngineInterface, Register } from 'claude-code'
2import {
3 approvalKey,
4 classifyCommand,
5 classifyMcpCall,
6 findDestructive,
7 isEffectivelyEmpty,
8 isMigrationPath,
9 isUndoFile,
10 looksMigrationRelated,
11 removedBuild,
12 safeRegex,
13 shortPath,
14 undoFileNote,
15 usesHashComments,
16 type Finding,
17} from './detect'
18import { HISTORY_KEY, append, asEntries, render, type Entry } from './history'
19import { ALL_WINDOW_MS, NO, YES, YES_ALL, YES_COMMAND, YES_FILE, describeOption, levelOf, optionsFor, questionFor, type Ask } from './question'
20
21// "Yes for this file/command": the key, and the destructive kinds the person
22// had seen. A new kind asks again. A reload of the mod starts it over.
23const approved = new Map<string, Set<string>>()
24// "Yes to all files": until when migration file changes that aren't high risk go through.
25let allowAllUntil = 0
26
27const EDIT_HINT = "If it already ran, editing it won't change the database."
28
29// The history behind /migration-guard lives only in the store, read fresh on
30// every write: several Claude Code sessions can share it, and a copy kept here
31// would bring back what another session cleared.
32let keepHistory = true
33let lastWrite: Promise<void> = Promise.resolve()
34
35/** One history write at a time in this session, so two at once can't lose one. */
36async function takeTurn(): Promise<() => void> {
37 const before = lastWrite
38 let release = () => {}
39 lastWrite = new Promise<void>((resolve) => {
40 release = resolve
41 })
42 await before
43 return release
44}
45
46/** Writes down what happened. History is a convenience: it never blocks or breaks the guard. */
47async function remember($: EngineInterface, ask: Ask, answer: string) {
48 if (!keepHistory) return
49 const done = await takeTurn()
50 try {
51 const list = asEntries(await $.store.get(HISTORY_KEY))
52 const cwd = await $.session.cwd()
53 const at = await $.clock.now()
54 const entry: Entry = { at, project: shortPath(cwd), what: ask.what, target: ask.target, risk: levelOf(ask), labels: ask.findings.map((f) => f.label), answer }
55 await $.store.set(HISTORY_KEY, append(list, entry))
56 } catch {
57 // A store or clock that isn't there (a test, an old build) just means no history.
58 } finally {
59 done()
60 }
61}
62
63// The off switch: /migration-guard off [minutes] until when, Infinity for "until on".
64// Per session, and a reload of the mod turns it back on, so it can't stay off by accident.
65let offUntil = 0
66
67// The guard's own questions while they're on screen, so the AskUserQuestion
68// hook below touches only those and leaves everyone else's alone.
69const ourQuestions = new Set<string>()
70
71/**
72 * Asks the person and resolves to what they picked or typed, or undefined
73 * when nobody really answered (dismissed, headless, or the dialog answered
74 * itself while they were away; the AskUserQuestion hook turns that into no
75 * answer).
76 */
77async function askPerson($: EngineInterface, ask: Ask): Promise<string | undefined> {
78 const { header, question } = questionFor(ask)
79 ourQuestions.add(question)
80 try {
81 return await $.ui.ask(question, { header, options: optionsFor(ask).map((o) => o.label) })
82 } catch {
83 return undefined
84 } finally {
85 ourQuestions.delete(question)
86 }
87}
88
89/** Asks the person; resolves to a deny reason, or undefined when they said yes. */
90async function confirm($: EngineInterface, ask: Ask) {
91 if (offInSettings) return undefined
92 if (offUntil > 0) {
93 if (offUntil === Infinity || offUntil > (await $.clock.now())) {
94 await remember($, ask, 'went through (guard was off)')
95 return undefined
96 }
97 offUntil = 0 // a timed "off" ran out
98 $.ui.status(undefined)
99 }
100 const seen = approved.get(ask.key)
101 if (seen && ask.findings.every((f) => seen.has(f.label))) {
102 await remember($, ask, 'went through (said yes for it earlier)')
103 return undefined
104 }
105 if (ask.kind === 'file' && allowAllUntil > 0 && levelOf(ask) !== 'high' && allowAllUntil > (await $.clock.now())) {
106 await remember($, ask, 'went through (yes to all files)')
107 return undefined
108 }
109
110 const answer = await askPerson($, ask)
111 if (answer === undefined) {
112 await remember($, ask, 'blocked (nobody answered)')
113 return `${$.plugin.name}: no confirmation for "${ask.target}" (the question was dismissed, or nobody was there to answer). Do not retry; ask the user how to proceed.`
114 }
115 await remember($, ask, answer === YES || answer === YES_FILE || answer === YES_COMMAND || answer === YES_ALL || answer === NO ? answer : `"${answer}"`)
116
117 if (answer === YES) return undefined
118 if (answer === YES_FILE || answer === YES_COMMAND) {
119 approved.set(ask.key, new Set([...(seen ?? []), ...ask.findings.map((f) => f.label)]))
120 return undefined
121 }
122 if (answer === YES_ALL) {
123 allowAllUntil = (await $.clock.now()) + ALL_WINDOW_MS
124 return undefined
125 }
126 $.ui.toast(`blocked: ${ask.target}`)
127 const said = answer === NO ? '' : ` They said: "${answer}"`
128 return `${$.plugin.name}: the user did not allow Claude to ${ask.what} (${ask.target}).${said} Do not retry or work around it; follow what they said, or ask how they want to proceed.`
129}
130
131// Settings that were filled in but aren't valid regular expressions, so they do nothing.
132let badSettings: string[] = []
133
134/** A warning line for settings that are being ignored, or nothing. */
135function settingsWarning(): string {
136 return badSettings.length > 0 ? `${badSettings.join(' and ')} in /config isn't a valid regular expression, so it's ignored.` : ''
137}
138
139// The guard turned off for good in /config (mode: off).
140let offInSettings = false
141
142/** One line on whether the guard is on, for the top of /migration-guard. */
143async function stateLine($: EngineInterface): Promise<string> {
144 if (offInSettings) return 'The guard is off (mode: off in /config).'
145 if (offUntil === Infinity) return 'The guard is off until you type /migration-guard on.'
146 if (offUntil === 0) return ''
147 const left = offUntil - (await $.clock.now())
148 return left > 0 ? `The guard is off for ${Math.ceil(left / 60_000)} more min. /migration-guard on turns it back on.` : ''
149}
150
151/**
152 * /migration-guard: the history, or the off switch. Only the person can flip
153 * the switch: a command that didn't come from their own keyboard (Claude, a
154 * script, another plugin) is refused.
155 */
156async function guardCommand($: EngineInterface, args: string | undefined, fromPerson: boolean): Promise<string> {
157 const [word = '', minutes] = (args ?? '').trim().split(/\s+/)
158 const lines = [settingsWarning()]
159 if (word === 'off' || word === 'on') {
160 if (!fromPerson) return 'Only you can turn the guard off or on, by typing /migration-guard yourself.'
161 const now = await $.clock.now()
162 const span = Number(minutes)
163 if (word === 'off') {
164 offUntil = Number.isFinite(span) && span > 0 ? now + span * 60_000 : Infinity
165 const until = offUntil === Infinity ? 'until you turn it back on' : `for ${span} min`
166 $.ui.status(`migration-guard: off ${offUntil === Infinity ? '' : `for ${span} min`}`.trim())
167 await remember($, { kind: 'command', key: '', what: 'turn the guard off', target: until, findings: [] }, 'by you')
168 return `The guard is off ${until}. Migration changes go through without asking, and still show up here. /migration-guard on turns it back on.`
169 }
170 offUntil = 0
171 $.ui.status(undefined)
172 await remember($, { kind: 'command', key: '', what: 'turn the guard on', target: 'now', findings: [] }, 'by you')
173 return 'The guard is on again.'
174 }
175 lines.push(await stateLine($))
176 if (!keepHistory) return [...lines, 'History is off (keepHistory in /config).'].filter(Boolean).join('\n')
177 const done = await takeTurn()
178 try {
179 if (word === 'clear') {
180 await $.store.set(HISTORY_KEY, [])
181 return [...lines, 'History cleared.'].filter(Boolean).join('\n')
182 }
183 lines.push(render(asEntries(await $.store.get(HISTORY_KEY)), word === 'all' ? Number.MAX_SAFE_INTEGER : 20))
184 return lines.filter(Boolean).join('\n')
185 } finally {
186 done()
187 }
188}
189
190/**
191 * When a guard hook itself fails, Claude Code would run the tool unguarded.
192 * Block instead if the call is about migrations; let anything else through.
193 */
194function failClosed(about: boolean, name: string) {
195 return about
196 ? { deny: `${name}: the guard hit an internal error, so this was blocked to be safe. Ask the user to run it themselves, or to check the mod.` }
197 : undefined
198}
199
200function clip(text: string, max: number): string {
201 return text.length > max ? `${text.slice(0, max - 3)}...` : text
202}
203
204/**
205 * How a command shows in the question: whole when it's one short line;
206 * otherwise just the part that matters, on one line, with "..." when that
207 * part isn't where the command starts.
208 */
209function commandTarget(command: string, part: string): string {
210 if (!command.includes('\n') && command.length <= 120) return command
211 const shown = clip(part.replace(/\s+/g, ' ').trim(), 113)
212 return shown.startsWith('...') || command.trimStart().startsWith(part.trim().slice(0, 20)) ? shown : `... ${shown}`
213}
214
215/** What a file's own path says: an undo file only runs on rollback, so its drops are expected. */
216function fileFindings(path: string, findings: () => Finding[]): { findings: Finding[]; note?: string } {
217 return isUndoFile(path) ? { findings: [], note: undoFileNote() } : { findings: findings() }
218}
219
220export const register: Register = (on, options) => {
221 const onlyDestructive = options.mode === 'destructive-only'
222 const extraPath = safeRegex(options.extraPathPattern)
223 const extraCommand = safeRegex(options.extraCommandPattern)
224 keepHistory = options.keepHistory !== 'off'
225 offInSettings = options.mode === 'off'
226 const filled = (value: unknown) => typeof value === 'string' && value.trim() !== ''
227 badSettings = [
228 ...(filled(options.extraPathPattern) && !extraPath ? ['extraPathPattern'] : []),
229 ...(filled(options.extraCommandPattern) && !extraCommand ? ['extraCommandPattern'] : []),
230 ]
231
232 on('session.start', async ($, e, next) => {
233 await $.command.register({
234 name: 'migration-guard',
235 description: 'What migration-guard asked and what you answered; off/on to pause it',
236 argumentHint: '[all | clear | off [minutes] | on]',
237 })
238 const warning = settingsWarning()
239 if (warning) $.ui.toast(`migration-guard: ${warning}`)
240 return next(e)
241 })
242
243 // The guard's own questions, on their way to the dialog and back. Claude Code's
244 // dialog answers itself after a while with nobody at the keyboard (it picked
245 // "Yes" in VS Code on 2.1.289) and marks the result with afkTimeoutMs; that
246 // answer is turned into no answer, so the change is blocked. On the way in,
247 // each option gets its one-line description, which $.ui.ask leaves empty.
248 on('tool.call', { tool: 'AskUserQuestion' }, async ($, e, next) => {
249 const first = e.questions[0]
250 if (!first || !ourQuestions.has(first.question)) return next(e)
251 const options = first.options.map((o) => ({ ...o, description: describeOption(o.label) || o.description }))
252 const r = await next({ ...e, questions: [{ ...first, options }, ...e.questions.slice(1)] })
253 if (r.deny !== undefined || !r.result) return r
254 const result = r.result as { answers?: Record<string, unknown>; response?: unknown; afkTimeoutMs?: unknown }
255 if (result.afkTimeoutMs !== undefined) return { deny: 'Nobody answered: the question timed out with nobody at the keyboard.' }
256 // What the person typed instead of picking an option counts as their answer.
257 const typed = typeof result.response === 'string' ? result.response.trim() : ''
258 const answers = result.answers ?? {}
259 if (typed && typeof answers[first.question] !== 'string') {
260 return { ...r, result: { ...result, answers: { ...answers, [first.question]: typed } } }
261 }
262 return r
263 })
264
265 // The person's own Enter, at the terminal or through a bridge to their own device.
266 on('command.run', { command: 'migration-guard' }, async ($, e) => ({
267 text: await guardCommand($, e.args, e.origin.kind === 'composer' || e.origin.kind === 'bridge'),
268 }))
269
270 on('tool.call', { tool: 'Write' }, async ($, e, next) => {
271 if (!isMigrationPath(e.file_path, extraPath)) return next(e)
272 const found = isEffectivelyEmpty(e.content, e.file_path)
273 ? { findings: [{ label: 'Empties the file', snippet: '' }] }
274 : fileFindings(e.file_path, () => findDestructive(e.content, { skipDown: true, hashComments: usesHashComments(e.file_path) }))
275 if (onlyDestructive && found.findings.length === 0) return next(e)
276 const deny = await confirm($, {
277 kind: 'file',
278 key: approvalKey(e.file_path),
279 what: 'write a migration file',
280 target: shortPath(e.file_path),
281 ...found,
282 })
283 return deny ? { deny } : next(e)
284 }).catch(($, e, next) =>
285 next.called ? next(e) : (failClosed(isMigrationPath(e.file_path, extraPath) || looksMigrationRelated(e.content), $.plugin.name) ?? next(e)),
286 )
287
288 on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
289 if (!isMigrationPath(e.file_path, extraPath)) return next(e)
290 // Deleting the text outright, or dropping a CREATE INDEX/TABLE and keeping the rest.
291 const emptied = e.new_string.trim() === '' && e.old_string.trim() !== ''
292 const gone = emptied ? e.old_string.trim().split('\n')[0]?.slice(0, 80) : removedBuild(e.old_string, e.new_string)
293 const found =
294 gone !== undefined
295 ? { findings: [{ label: 'Removes code', snippet: gone ?? '' }] }
296 : fileFindings(e.file_path, () => findDestructive(e.new_string, { hashComments: usesHashComments(e.file_path) }))
297 if (onlyDestructive && found.findings.length === 0) return next(e)
298 const deny = await confirm($, {
299 kind: 'file',
300 key: approvalKey(e.file_path),
301 what: 'edit a migration file',
302 target: shortPath(e.file_path),
303 hint: EDIT_HINT,
304 ...found,
305 })
306 return deny ? { deny } : next(e)
307 }).catch(($, e, next) =>
308 next.called ? next(e) : (failClosed(isMigrationPath(e.file_path, extraPath) || looksMigrationRelated(e.new_string), $.plugin.name) ?? next(e)),
309 )
310
311 // Bash, the PowerShell tool on Windows, and Monitor all run shell commands.
312 on('tool.call', { tool: /^(Bash|PowerShell|Monitor)$/ }, async ($, e, next) => {
313 const command = 'command' in e && typeof e.command === 'string' ? e.command : ''
314 const hit = classifyCommand(command, { extra: extraCommand, extraPath, powershell: String(e.tool) === 'PowerShell' })
315 if (!hit) return next(e)
316 if (onlyDestructive && !hit.risky) return next(e)
317 const deny = await confirm($, {
318 kind: 'command',
319 key: command,
320 what: hit.reason,
321 target: commandTarget(command, hit.part),
322 findings: hit.findings,
323 note: hit.note,
324 baseRisk: hit.risky ? 'high' : 'medium',
325 })
326 return deny ? { deny } : next(e)
327 }).catch(($, e, next) =>
328 next.called ? next(e) : (failClosed('command' in e && typeof e.command === 'string' && looksMigrationRelated(e.command), $.plugin.name) ?? next(e)),
329 )
330
331 // MCP servers: database ones (Supabase, Neon, Prisma...) and ones that write files.
332 on('tool.call', { tool: /^mcp__/ }, async ($, e, next) => {
333 const args = Object.fromEntries(Object.entries(e).filter(([key]) => !['tool', 'tool_use_id', 'agentId'].includes(key)))
334 const hit = classifyMcpCall(String(e.tool), args, extraPath)
335 if (!hit) return next(e)
336 if (onlyDestructive && !hit.risky && hit.findings.length === 0) return next(e)
337 const deny = await confirm($, {
338 kind: hit.kind,
339 key: hit.key,
340 what: hit.reason,
341 target: hit.target,
342 findings: hit.findings,
343 note: hit.note || undefined,
344 baseRisk: hit.kind === 'file' ? undefined : hit.risky ? 'high' : 'medium',
345 })
346 return deny ? { deny } : next(e)
347 }).catch(($, e, next) => (next.called ? next(e) : (failClosed(looksMigrationRelated(JSON.stringify(e)), $.plugin.name) ?? next(e))))
348}
349hooks/detect.ts 1012 lines1// Decides what counts as touching a migration, and what in it can destroy
2// data. No engine calls in here, so every rule can be tested on its own.
3
4export type Finding = { label: string; snippet: string }
5
6export type CommandHit = {
7 /** What the command does, as the question shows it ("run prisma"). */
8 reason: string
9 /** True when the command can lose data or undo work by its own name. */
10 risky: boolean
11 /** One short line on why it matters, shown when no destructive SQL was spotted. */
12 note: string
13 /** Destructive SQL in the command itself. */
14 findings: Finding[]
15 /** The step that matched, shown when the whole command is too long to read in the question. */
16 part: string
17}
18
19const NOTE = {
20 applies: 'it applies pending migrations to the database',
21 wipes: 'it can wipe the database or drop data',
22 undoes: 'it undoes applied migrations, and their down steps usually drop things',
23 pushes: 'it pushes the schema straight to the database and can drop data to make it match',
24 forced: "it's forced, so the tool's own safety checks are skipped",
25 history: 'it changes which migrations count as applied, so history can drift',
26 sqlDirect: 'it runs straight against the database, with no migration to review',
27 sqlFile: "the guard can't see what the file does",
28 shellDeletes: 'it deletes migration files; if one already ran, history can drift',
29 shellWrites: "it changes migration files outside the file tools, so there's no diff to review",
30 scriptWrites: "a script rewrites it, so there's no diff to review",
31 volumes: 'any database data in those volumes is gone for good',
32 dropDb: 'it deletes the whole database',
33 restore: 'it writes a dump into the database',
34 restoreClean: 'it drops what is there before restoring',
35 mcp: 'it applies the change to the database right away',
36 extra: 'you listed it in extraCommandPattern',
37 undoFile: "it's an undo file, so it only runs on rollback",
38 tooLong: "it's over 1 MB, so the guard can't read all of it",
39}
40
41// Even big generated migrations stay well under this. The cap keeps every
42// check fast when someone pastes something enormous.
43const MAX_SCAN = 1024 * 1024
44function capped(text: string): string {
45 return text.length > MAX_SCAN ? text.slice(0, MAX_SCAN) : text
46}
47
48// ---------------------------------------------------------------- files
49
50const MIGRATION_DIRS: readonly RegExp[] = [
51 /(^|[\\/])migrations?[\\/]/i, // migrations/ or migration/ (TypeORM, Prisma, Knex, Supabase, Flyway, EF Core...)
52 /(^|[\\/])db[\\/]migrate[\\/]/i, // Rails
53 /(^|[\\/])alembic[\\/]versions[\\/]/i, // Alembic
54 /(^|[\\/])db[\\/]changelog[\\/]/i, // Liquibase
55 /(^|[\\/])db\.changelog[\w.-]*$/i, // Liquibase master changelog
56 /(^|[\\/])drizzle[\\/]([^\\/]+\.sql|meta[\\/][^\\/]+\.json)$/i, // drizzle-kit output and its journal
57 /(^|[\\/])sqitch[\\/](deploy|revert)[\\/]/i, // Sqitch
58 /(^|[\\/])conf[\\/]evolutions[\\/]/i, // Play
59]
60
61const CODE_FILE = /\.(p?g?sql|[cm]?[jt]sx?|py|rb|go|rs|php|java|kt|cs|swift|exs?|xml|ya?ml|json)$/i
62// Tests and fixtures that sit next to migrations, and code other people ship.
63const NOT_OURS = /\.(spec|test)\.[^.\\/]+$|_(test|spec)\.\w+$|[\\/]test_[^\\/]+\.py$|[\\/](__tests__|node_modules|vendor|site-packages)[\\/]/i
64// golang-migrate's .down.sql, Diesel's down.sql, Flyway's U2__undo.sql
65const UNDO_FILE = /(^|[\\/._-])down\.sql$|(^|[\\/])U\d+(_\d+)*__[^\\/]*\.sql$/i
66
67/** A migration file. Docs, READMEs and tests that live in a migrations folder are not. */
68export function isMigrationPath(path: string, extra?: RegExp): boolean {
69 if (extra?.test(path)) return true
70 if (!CODE_FILE.test(path) || NOT_OURS.test(path)) return false
71 if (/\.json$/i.test(path) && !/changelog|drizzle/i.test(path)) return false // JSON only for Liquibase and Drizzle
72 return MIGRATION_DIRS.some((re) => re.test(path))
73}
74
75/** A file that only runs on rollback, where drops are the whole point. */
76export function isUndoFile(path: string): boolean {
77 return UNDO_FILE.test(path)
78}
79
80export function undoFileNote(): string {
81 return NOTE.undoFile
82}
83
84// ---------------------------------------------------- destructive statements
85
86type Rule = readonly [RegExp, string | ((m: RegExpExecArray) => string)]
87
88const RULES: readonly Rule[] = [
89 [/\bDROP\s+TABLE\b/i, 'DROP TABLE'],
90 [/\bDROP\s+COLUMN\b/i, 'DROP COLUMN'],
91 // Postgres and MySQL let you leave out COLUMN: ALTER TABLE users DROP email
92 [
93 /\bALTER\s+TABLE\s+(?:ONLY\s+)?(?:IF\s+EXISTS\s+)?[\w."`[\]]+\s+DROP\s+(?!COLUMN\b|CONSTRAINT\b|INDEX\b|KEY\b|DEFAULT\b|NOT\b|IDENTITY\b|EXPRESSION\b|PRIMARY\b|FOREIGN\b|CHECK\b|PARTITION\b)["`[\w]/i,
94 'DROP COLUMN',
95 ],
96 [/\bDROP\s+(UNIQUE\s+)?(INDEX|KEY)\b/i, 'DROP INDEX'], // DROP KEY is MySQL for an index
97 [/\bDROP\s+(CONSTRAINT|FOREIGN\s+KEY|PRIMARY\s+KEY)\b/i, 'DROP CONSTRAINT'],
98 [/\bDROP\s+PARTITION\b/i, 'DROP PARTITION'],
99 [
100 /\bDROP\s+(SCHEMA|DATABASE|MATERIALIZED\s+VIEW|VIEW|TYPE|TRIGGER|FUNCTION|PROCEDURE|EXTENSION|SEQUENCE|OWNED|POLICY)\b/i,
101 (m) => `DROP ${(m[1] ?? '').toUpperCase().replace(/\s+/g, ' ')}`,
102 ],
103 [/\bDROP\b[^;]{0,200}?(?<!\bON\s+(DELETE|UPDATE)\s+)\bCASCADE\b/i, 'CASCADE'], // takes everything that depends on it along
104 // Several actions in one ALTER: ALTER TABLE users ADD COLUMN a int, DROP b
105 [/\bALTER\s+TABLE\b[^;]{0,500}?,\s*DROP\s+(?!CONSTRAINT\b|INDEX\b|KEY\b|DEFAULT\b|NOT\b|IDENTITY\b|EXPRESSION\b|PRIMARY\b|FOREIGN\b|CHECK\b|PARTITION\b)["`[\w]/i, 'DROP COLUMN'],
106 [/\bTRUNCATE\b(?!\s*(,|ON\b))/i, 'TRUNCATE'], // not GRANT ... TRUNCATE ON, or a trigger's AFTER TRUNCATE ON
107 [/\bALTER\s+(COLUMN\s+)?\S+\s+(SET\s+DATA\s+)?TYPE\b/i, 'ALTER COLUMN TYPE'],
108 [/\bMODIFY\s+COLUMN\b|\bALTER\s+TABLE\b[^;]{0,500}?\bMODIFY\s+(?!COLUMN\b)\w/i, 'MODIFY COLUMN'],
109 [/\bRENAME\s+((COLUMN\s+)?\S+\s+)?TO\b|\bRENAME\s+TABLE\b/i, 'RENAME'],
110 // ORM calls get the same names as the SQL they produce, so the question reads the same everywhere.
111 [/\bqueryRunner\.(drop\w*|clearTable|clearDatabase|renameColumn|renameTable|changeColumns?)\b/, (m) => fromMethod(m[1])], // TypeORM
112 // Knex, Laravel, Sequelize, node-pg-migrate, db-migrate, Phinx: table.dropColumn(), $table->dropColumn(), pgm.alterColumn()...
113 [
114 /(?<!queryRunner)(?:\.|::|->)(drop[A-Z]\w*|dropIfExists|remove(?:Column|Index|Constraint|ForeignKey)s?|changeColumn|alterColumn|rename(?:Column|Table))\s*\(/,
115 (m) => fromMethod(m[1]),
116 ],
117 [/\bSchema::drop\s*\(|->drop\(\s*\)/, 'DROP TABLE'], // Laravel, Phinx
118 [/(?:->|\.)(change|alter)\(\s*\)/, 'ALTER COLUMN'], // Laravel ->change(), Knex .alter()
119 [/\b(drop_table|drop_join_table|remove_columns?|remove_timestamps|remove_index|remove_reference|remove_foreign_key|rename_column|rename_table|change_column)\b/, (m) => fromMethod(m[1])], // Rails
120 [/\bt\.(remove|rename)\b/, (m) => (m[1] === 'remove' ? 'DROP COLUMN' : 'RENAME')], // Rails change_table
121 [/\bmigrations\.(RemoveField|DeleteModel|AlterField|RenameField|RenameModel|RemoveIndex|RemoveConstraint)\b/, (m) => fromMethod(m[1])], // Django
122 [/\b\w*op\.(drop_table|drop_column|drop_index|drop_constraint|alter_column|rename_table)\b/, (m) => fromMethod(m[1])], // Alembic, batch_op too
123 [/\bmigrationBuilder\.(Drop\w+|RenameColumn|RenameTable|AlterColumn)\b/, (m) => fromMethod(m[1])], // EF Core
124 [/^\s*(remove(?:_if_exists)?\s+:\w+|drop(?:_if_exists)?\s+(table|index|constraint)\()/m, (m) => (/^\s*remove/.test(m[1] ?? '') ? 'DROP COLUMN' : `DROP ${(m[2] ?? '').toUpperCase()}`)], // Ecto
125 [/<(dropTable|dropColumn|dropIndex|dropForeignKeyConstraint|dropPrimaryKey|dropUniqueConstraint|dropView|dropSequence|renameColumn|renameTable|modifyDataType)\b/, (m) => fromMethod(m[1])], // Liquibase XML
126 [/^\s*-?\s*(dropTable|dropColumn|dropIndex|dropForeignKeyConstraint|renameColumn|renameTable|modifyDataType)\s*:/m, (m) => fromMethod(m[1])], // Liquibase YAML
127 [/\.dropDatabase\s*\(\s*\)/, 'DROP DATABASE'], // Mongo
128 [/\.drop\s*\(\s*\)/, 'DROP COLLECTION'],
129 [/\.deleteMany\s*\(\s*(\{\s*\})?\s*\)|\.deleteFrom\([^)]{0,100}\)\s*\.execute\(|\bknex\(\s*['"][\w.]+['"]\s*\)\s*\.(del|delete)\(\s*\)/, 'DELETE without WHERE'], // Mongo, Prisma, Kysely, Knex
130]
131
132/** Maps an ORM method (dropColumn, remove_column, RemoveField...) to the SQL it stands for. */
133function fromMethod(raw: string | undefined): string {
134 const m = (raw ?? '').replace(/_/g, '').toLowerCase()
135 if (/^(droptable(ifexists)?|dropalltables|dropjointable|dropifexists|deletemodel)$/.test(m)) return 'DROP TABLE'
136 if (m === 'cleardatabase') return 'DROP DATABASE'
137 if (/^(dropcolumns?|removecolumns?|removefield|removetimestamps|drop(timestamps|softdeletes)(tz)?|dropconstrainedforeignid|dropforeignidfor|dropmorphs|dropremembertoken)$/.test(m)) {
138 return 'DROP COLUMN'
139 }
140 if (/^(dropind(ex|exes|ices)|removeindex)$/.test(m)) return 'DROP INDEX'
141 if (/^(drop(foreign(keys?)?(constraint)?|primary(key)?|unique(constraints?)?|check(constraints?)?|exclusion(constraints?)?|constraint)|remove(reference|foreignkey|constraint))$/.test(m)) {
142 return 'DROP CONSTRAINT'
143 }
144 if (/^(cleartable|truncate)$/.test(m)) return 'TRUNCATE'
145 if (/^rename(column|table|field|model)$/.test(m)) return 'RENAME'
146 if (m === 'modifydatatype') return 'ALTER COLUMN TYPE'
147 if (/^(changecolumns?|altercolumn|alterfield)$/.test(m)) return 'ALTER COLUMN'
148 if (m === 'dropmaterializedview') return 'DROP MATERIALIZED VIEW'
149 const object = /^drop(view|schema|database|sequence|function|procedure|trigger|type|extension|policy)$/.exec(m)?.[1]
150 return object ? `DROP ${object.toUpperCase()}` : `${raw ?? 'drop'}()` // named as written, with the generic warning
151}
152
153export type Level = 'high' | 'medium' | 'low'
154
155// One fixed line per kind of change, so the question never guesses.
156const IMPACT: Readonly<Record<string, { level: Level; what: string }>> = {
157 'DROP TABLE': { level: 'high', what: 'deletes the table and every row in it' },
158 'DROP COLLECTION': { level: 'high', what: 'deletes the collection and its documents' },
159 'DROP COLUMN': { level: 'high', what: 'deletes the column and its data' },
160 'DROP DATABASE': { level: 'high', what: 'deletes the whole database' },
161 'DROP SCHEMA': { level: 'high', what: 'deletes the schema and everything in it' },
162 'DROP PARTITION': { level: 'high', what: 'deletes the partition and its rows' },
163 'DROP OWNED': { level: 'high', what: 'deletes everything the role owns' },
164 CASCADE: { level: 'high', what: 'also drops everything that depends on it' },
165 TRUNCATE: { level: 'high', what: 'deletes every row' },
166 'DELETE without WHERE': { level: 'high', what: 'deletes every row' },
167 'UPDATE without WHERE': { level: 'high', what: 'changes every row' },
168 'ALTER COLUMN TYPE': { level: 'medium', what: 'rewrites the column; can lose data and lock the table' },
169 'MODIFY COLUMN': { level: 'medium', what: 'rewrites the column; can lose data and lock the table' },
170 'ALTER COLUMN': { level: 'medium', what: 'changes a column definition; can lock the table or cut data' },
171 RENAME: { level: 'medium', what: 'breaks code that still uses the old name' },
172 'DROP INDEX': { level: 'medium', what: 'can make queries slow' },
173 'DROP CONSTRAINT': { level: 'medium', what: 'lets bad or duplicate data in' },
174 'Empties the file': { level: 'medium', what: 'the migration would do nothing; if it already ran, history can drift' },
175 'Removes code': { level: 'medium', what: 'part of the migration is deleted' }, // edits only, and the edit hint covers "if it already ran"
176}
177
178/** How bad a finding is, in one short line. Unknown kinds get an honest generic line. */
179export function impactOf(label: string): { level: Level; what: string } {
180 return IMPACT[label] ?? { level: 'medium', what: 'removes it, and anything that depends on it breaks' }
181}
182
183const RANK: Readonly<Record<Level, number>> = { high: 2, medium: 1, low: 0 }
184
185/** Worst first, so the question leads with what matters. */
186export function bySeverity(findings: readonly Finding[]): Finding[] {
187 return [...findings].sort((a, b) => RANK[impactOf(b.label).level] - RANK[impactOf(a.label).level])
188}
189
190/** The overall risk: the worst finding, or the command's own risk when nothing was found. */
191export function riskOf(findings: readonly Finding[], commandRisk: Level = 'low'): Level {
192 return findings.reduce<Level>((worst, f) => {
193 const level = impactOf(f.label).level
194 return RANK[level] > RANK[worst] ? level : worst
195 }, commandRisk)
196}
197
198// A WHERE that matches every row is no WHERE at all: WHERE 1=1, WHERE true, MySQL's WHERE 1.
199const REAL_WHERE = /\bWHERE\b(?!\s+(?:1\s*=\s*1|true|1)\s*(?:$|;|\)|LIMIT\b|ORDER\b|RETURNING\b))/i
200
201// Where the forward and undo halves start, written as methods, exports or
202// keys at the start of a line, so a "see down()" inside up() doesn't count.
203const LEAD = String.raw`^[ \t]*(?:(?:public|protected|private|static|export|async|override|void|function|const|let|var)\s+)*`
204const half = (names: string, go: string) =>
205 new RegExp(
206 `${LEAD}(?:${names})\\s*[(:=]|^[ \\t]*func\\s+${go}[A-Z_]\\w*\\s*\\(|^[ \\t]*(?:module\\.)?exports\\.${go}\\b|^[ \\t]*def\\s+(?:${names})\\b`,
207 'm',
208 )
209const UP = half('up|upgrade|change|Up', 'up')
210const DOWN = half('down|downgrade|Down', 'down')
211// SQL files keep both halves in one file, split by a comment: goose, dbmate, sql-migrate, MyBatis, Play.
212const SQL_DOWN_MARKER = /^[ \t]*(?:--[ \t]*(?:\+goose[ \t]+Down|migrate:down|\+migrate[ \t]+Down|\/\/@UNDO)|#[ \t]*---[ \t]*!Downs)\b/im
213// Django's RunSQL(..., reverse_sql=...) is the undo half inside the forward one.
214const REVERSE_SQL = /\breverse_sql\s*=\s*(\[[^\]]{0,5000}\]|"""[\s\S]{0,5000}?"""|'''[\s\S]{0,5000}?'''|"[^"\n]{0,5000}"|'[^'\n]{0,5000}')/g
215
216/** The part of a migration that runs forward: everything before down() when up() comes first. */
217function forwardPart(text: string): string {
218 const up = text.search(UP)
219 const down = text.search(DOWN)
220 return (up >= 0 && down > up ? text.slice(0, down) : text).replace(REVERSE_SQL, '')
221}
222
223/**
224 * `-- DROP TABLE users` in a comment is not a drop. A block comment never
225 * spans a `;`, so a `/*` inside a string can't swallow the statements after it.
226 */
227function stripComments(text: string, hash: boolean): string {
228 const out = text
229 .replace(/\/\*(?:(?!\*\/|\/\*)[^;])*\*\//g, ' ')
230 .replace(/(^|\s)--\s[^\n]*/g, '$1')
231 .replace(/(^|\s)\/\/[^\n]*/g, '$1')
232 return hash ? out.replace(/(^|\s)#[^\n]*/g, '$1') : out
233}
234
235/** Python, Ruby, Elixir and YAML comment with #. */
236export function usesHashComments(path: string): boolean {
237 return /\.(py|rb|exs?|ya?ml)$/i.test(path)
238}
239
240/** Nothing left once comments and blank lines are gone: the migration does nothing. */
241export function isEffectivelyEmpty(text: string, path: string): boolean {
242 return stripComments(capped(text), usesHashComments(path)).trim() === ''
243}
244
245// Statements that build something. Fewer of them after an edit means part of the migration is gone.
246const BUILDS =
247 /\bCREATE\s+(UNIQUE\s+)?(INDEX|TABLE|VIEW|TRIGGER|FUNCTION|TYPE|SEQUENCE|POLICY)\b|\bADD\s+(COLUMN|CONSTRAINT)\b|\b(createIndex|createTable|addColumn|addIndex|addForeignKey|add_index|add_column|add_reference|create_table|create_index|AddField|CreateModel|AddIndex|AddConstraint|CreateTable|AddColumn|CreateIndex)\b/gi
248
249/** The first building statement the edit takes away, if it takes any away. */
250export function removedBuild(before: string, after: string): string | undefined {
251 const count = (text: string) => (capped(text).match(BUILDS) ?? []).length
252 if (count(after) >= count(before)) return undefined
253 const kept = new Set(after.split('\n').map((l) => l.trim()))
254 return before.split('\n').find((l) => new RegExp(BUILDS.source, 'i').test(l) && !kept.has(l.trim()))?.trim().slice(0, 80) ?? ''
255}
256
257function snippetAt(text: string, index: number): string {
258 const start = text.lastIndexOf('\n', index) + 1
259 const end = text.indexOf('\n', index)
260 const line = text.slice(start, end < 0 ? undefined : end).trim()
261 return line.length > 80 ? `${line.slice(0, 77)}...` : line
262}
263
264/**
265 * Statements that can lose data or break running code. With `skipDown`, a
266 * whole migration file is read up to its down()/downgrade, because the undo
267 * half always drops what up() created and would cry wolf every time.
268 * Commands keep their comments: a glob like `/*` would otherwise hide SQL.
269 */
270export function findDestructive(
271 text: string,
272 opts: { skipDown?: boolean; keepComments?: boolean; hashComments?: boolean } = {},
273): Finding[] {
274 let body = capped(text)
275 if (opts.skipDown) {
276 const marker = body.search(SQL_DOWN_MARKER)
277 if (marker >= 0) body = body.slice(0, marker)
278 }
279 if (!opts.keepComments) body = stripComments(body, opts.hashComments === true)
280 if (opts.skipDown) body = forwardPart(body)
281
282 const found: Finding[] = []
283 const seen = new Set<string>()
284 const add = (label: string, index: number) => {
285 if (seen.has(label)) return
286 seen.add(label)
287 found.push({ label, snippet: snippetAt(body, index) })
288 }
289
290 // Every match, not just the first: dropIndex then dropTable must still report DROP TABLE.
291 for (const [re, label] of RULES) {
292 const all = new RegExp(re.source, re.flags.includes('g') ? re.flags : `${re.flags}g`)
293 let count = 0
294 for (const m of body.matchAll(all)) {
295 add(typeof label === 'string' ? label : label(m), m.index ?? 0)
296 if (++count >= 50) break
297 }
298 }
299
300 let offset = 0
301 for (const statement of body.split(';')) {
302 const del = /\bDELETE\s+FROM\b/i.exec(statement)
303 if (del && !REAL_WHERE.test(statement)) add('DELETE without WHERE', offset + del.index)
304 const upd = /\bUPDATE\s+(?:ONLY\s+)?["`\w.[\]]+(?:\s+(?:AS\s+)?\w+)?\s+SET\b/i.exec(statement)
305 if (upd && !REAL_WHERE.test(statement)) add('UPDATE without WHERE', offset + upd.index)
306 offset += statement.length + 1
307 }
308
309 return found
310}
311
312/** A cheap "could this be about migrations?" test, for failing closed when the guard itself breaks. */
313export function looksMigrationRelated(text: string): boolean {
314 return /migrat|alembic|changelog|db:(migrate|rollback|drop|reset)|\bDROP\s|\bTRUNCATE\b/i.test(capped(text))
315}
316
317// ------------------------------------------------------- reading commands
318
319type Step = { text: string; substitutes: boolean }
320
321/**
322 * Splits a command line into steps on && || ; & and newlines (and on | when
323 * asked), never inside quotes. `substitutes` notes $( ) or backticks, which
324 * run commands even inside an echo.
325 */
326function splitSteps(command: string, pipes: boolean, powershell: boolean): Step[] {
327 const escape = powershell ? '`' : '\\'
328 const steps: Step[] = []
329 let quote = ''
330 let start = 0
331 let substitutes = false
332 const cut = (end: number) => {
333 const text = command.slice(start, end)
334 if (text.trim() !== '') steps.push({ text, substitutes })
335 substitutes = false
336 }
337 for (let i = 0; i < command.length; i++) {
338 const c = command[i]
339 const after = command[i + 1] ?? ''
340 if (quote === "'") {
341 if (c === "'") quote = ''
342 continue
343 }
344 if (c === escape) {
345 i++
346 continue
347 }
348 if (!powershell && (c === '`' || (c === '$' && after === '('))) substitutes = true
349 if (powershell && c === '$' && after === '(') substitutes = true
350 if (quote === '"') {
351 if (c === '"') quote = ''
352 continue
353 }
354 if (c === '"' || c === "'") {
355 quote = c
356 continue
357 }
358 let width = 0
359 if (c === '\n' || c === ';') width = 1
360 else if ((c === '&' || c === '|') && after === c) width = 2
361 else if (c === '|' && pipes) width = 1
362 else if (c === '&' && after !== '>' && command[i - 1] !== '>') width = 1
363 if (width > 0) {
364 cut(i)
365 start = i + width
366 i += width - 1
367 }
368 }
369 cut(command.length)
370 return steps
371}
372
373// Who reads a heredoc decides what its body is: text (a commit message), a
374// script (python, node...), or more shell (bash, ssh, a database client).
375const TEXT_READER = /\b(cat|git|gh|glab|echo|printf|tee|less|more|head|tail|wc|jq|yq)\b/
376const SCRIPT_READER = /\b(python[\d.]*|node|deno|bun|ruby|perl|php|tsx|ts-node)\b/
377const SHELL_READER = /\b(bash|sh|zsh|dash|ksh|fish|pwsh|powershell|xargs|eval|source|docker|podman|kubectl|ssh|patch|git\s+(apply|am))\b/
378// A patch says which files it changes in its body, not on the command line.
379const APPLIES_PATCH = /\bgit\s+(apply|am)\b|\bpatch\s+(-p\d|-i\b|<)/
380const HEREDOC = /<<(-?)[ \t]*(['"]?)([A-Za-z_][\w.-]*)\2/
381
382/**
383 * Takes heredoc bodies out of the command line, so a commit message that
384 * says "npm run migration:run" isn't a command. A body fed to a shell or a
385 * database client stays, since it is more commands. A body fed to python,
386 * node and the like goes to `scripts`, judged as code. A body written into a
387 * migration (cat > migrations/x.sql <<EOF) goes to `written`, so the question
388 * can say what's in it.
389 */
390function readHeredocs(command: string, extraPath?: RegExp): { text: string; written: string[]; scripts: string[] } {
391 const lines = command.split('\n')
392 const kept: string[] = []
393 const written: string[] = []
394 const scripts: string[] = []
395 let seen = 0
396 for (let i = 0; i < lines.length; i++) {
397 const line = lines[i] ?? ''
398 kept.push(line)
399 const m = HEREDOC.exec(line)
400 if (!m || insideQuotes(line, m.index) || ++seen > 20) continue
401 let end = -1
402 for (let j = i + 1; j < lines.length; j++) {
403 if ((m[1] === '-' ? (lines[j] ?? '').replace(/^\t+/, '') : (lines[j] ?? '')).replace(/\r$/, '') === m[3]) {
404 end = j
405 break
406 }
407 }
408 if (end < 0) continue // never closed: not a heredoc we understand, keep everything
409 if (SHELL_READER.test(line) || DB_CLIENT.test(line)) continue
410 const body = lines.slice(i + 1, end).join('\n')
411 if (SCRIPT_READER.test(line)) scripts.push(body)
412 else if (!TEXT_READER.test(line)) continue
413 else if (writesMigration(line, extraPath)) written.push(body)
414 i = end - 1 // skip the body; the closing line is kept and is harmless
415 }
416 return { text: kept.join('\n'), written, scripts }
417}
418
419/** Whether `index` sits inside quotes. `$(` starts fresh quoting, as in "$(cat <<'EOF' ...)". */
420function insideQuotes(line: string, index: number): boolean {
421 const open: string[] = []
422 for (let i = 0; i < index; i++) {
423 const c = line[i]
424 const top = open[open.length - 1]
425 if (top === "'") {
426 if (c === "'") open.pop()
427 } else if (c === '\\') i++
428 else if (c === '$' && line[i + 1] === '(') {
429 open.push('(')
430 i++
431 } else if (top === '"') {
432 if (c === '"') open.pop()
433 } else if (c === ')' && top === '(') open.pop()
434 else if (c === '"' || c === "'") open.push(c)
435 }
436 const top = open[open.length - 1]
437 return top === '"' || top === "'"
438}
439
440// Commands that only print, search or read: mentioning "migration:run" in them runs nothing,
441// and a pipeline made only of them (grep ... | cut ... | head) changes nothing.
442const ONLY_MENTIONS =
443 /^\s*(echo|printf|grep|egrep|fgrep|rg|ag|ack|findstr|cat|bat|head|tail|cut|sort|uniq|wc|tr|column|nl|less|more|jq|yq|ls|tree|stat|file|diff|cmp|xargs\s+(?:-\S+\s+)*(?:grep|egrep|rg|cat|head|tail|wc|ls|file|stat)|Write-(Output|Host)|Select-String|Get-Content|Get-ChildItem|git\s+(commit|log|show|diff|grep|tag|status|blame|ls-files)|gh\s+(pr|issue)\s+(create|comment|edit|view))\b/i
444
445function onlyMentions(step: Step, powershell: boolean): boolean {
446 return !step.substitutes && splitSteps(step.text, true, powershell).every((part) => ONLY_MENTIONS.test(part.text))
447}
448
449// ------------------------------------------------------------- commands
450
451// Commands that only look, plan, or write a new migration file.
452const READ_ONLY =
453 /\b(migration:(show|status|generate|create)|migrate:(status|make|install)|migrate\s+(status|create|new|make|list)|showmigrations|alembic\s+(current|history|heads|check|show|branches|revision)|prisma\s+migrate\s+(status|diff)|ef\s+migrations\s+(list|add|script)|(update|rollback)-?sql)\b|--create-only\b|\bmigrate\b[^|;&\n]{0,200}?\s--(plan|check)\b/i
454
455const WIPE_WORD = /\b(reset|fresh|refresh|drop|purge|flush|wipe|clean|truncate|destroy|nuke|replant|reset_db|truncate_all|dropAll|flywayClean)\b/i
456const UNDO_WORD = /\b(revert|rollback|undo|down|downgrade|redo|zero|remove|update\s+0|VERSION=0)\b/i
457const PUSH_WORD = /\b(push|sync)\b/i
458const FORCE_WORD = /\bforce\b/i
459const HISTORY_WORD = /\b(stamp|resolve|repair|fake|baseline|changelog-?sync|mark)\b/i
460
461/** How risky a migration command is by its own words, and the line that says why. */
462function riskOfWords(text: string): { risky: boolean; note: string } {
463 if (WIPE_WORD.test(text)) return { risky: true, note: NOTE.wipes }
464 if (UNDO_WORD.test(text)) return { risky: true, note: NOTE.undoes }
465 if (PUSH_WORD.test(text)) return { risky: true, note: NOTE.pushes }
466 if (FORCE_WORD.test(text)) return { risky: true, note: NOTE.forced }
467 if (HISTORY_WORD.test(text)) return { risky: false, note: NOTE.history }
468 return { risky: false, note: NOTE.applies }
469}
470
471// Flags with an optional value, as package managers and make take them before the script: --filter api, -C apps/api
472const FLAGS = String.raw`(?:--?[\w-]+(?:[=\s][^\s-]\S*)?\s+)`
473
474// Bounded gaps ([^...]{0,200}) instead of .* keep every rule linear on long input.
475const RUNNERS: ReadonlyArray<readonly [RegExp, string]> = [
476 // Package scripts, workspace flags included: pnpm --filter api db:migrate, npm --prefix api run migrate
477 [
478 new RegExp(
479 String.raw`\b(npm|pnpm|yarn|bun)(\.cmd|\.exe)?\s+(?:${FLAGS}|workspace\s+\S+\s+|run(?:-script)?\s+){0,6}(?!test|lint|check|verify|typecheck|format)[\w:.-]*(migrat|db[-_:]?(reset|drop|rollback|push|wipe))[\w:.-]*`,
480 'i',
481 ),
482 'a package script that migrates',
483 ],
484 [/\bturbo\s+(?:\S+\s+){0,5}?\S*migrat|\bnx\s+(run|run-many|affected)\b[^|;&\n]{0,200}?[\s:]migrat|\blerna\s+run\s+\S*migrat/i, 'a monorepo task that migrates'],
485 [
486 new RegExp(String.raw`\b(make|just|task|mage)(\.exe)?\s+${FLAGS}{0,4}[\w:./-]*(migrat|db[-_:]?(reset|drop|rollback|push))[\w:./-]*`, 'i'),
487 'a make/just/task target that migrates',
488 ],
489 [/\b(docker|podman)[- ]compose\b[^|;&\n]{0,200}?\b(run|up)\b[^|;&\n]{0,200}?\bmigrat\w*/i, 'a container that migrates'],
490 [/\btypeorm(-ts-node-\w+)?\s+(migration:(run|revert)|schema:(drop|sync))\b/i, 'typeorm'],
491 // Any CLI driven by a migrate:/migration: verb, e.g. `node node_modules/typeorm/cli.js migration:run`.
492 [/\bmigrat(e|ion):(run|revert|up|down|latest|rollback|reset|fresh|refresh)\b/i, 'migrations'],
493 [/\bschema:(drop|sync|update|fresh)\b/i, 'a schema sync'],
494 [/\bprisma\s+(migrate\s+(dev|deploy|reset|resolve)|db\s+push)\b/i, 'prisma'],
495 [/\bsequelize(-cli)?\s+db:(migrate|drop|seed:undo)/i, 'sequelize'],
496 [/\b(rails|rake)\s+db:(migrate|rollback|drop|reset|setup|prepare|schema:load|purge|seed:replant|truncate_all)\b/i, 'a rails db task'],
497 [/\balembic\b[^|;&\n]{0,200}?\b(upgrade|downgrade|stamp)\b/i, 'alembic'],
498 [/\bflask\s+db\s+(upgrade|downgrade|stamp)\b/i, 'flask-migrate'],
499 [/\b(manage\.py|django-admin)\s+(migrate|flush|reset_db)\b/i, 'django migrate'],
500 [/\bartisan\s+(migrate|db:wipe)\b/i, 'laravel migrate'],
501 [/\bdoctrine:(migrations:(migrate|execute|rollup|version)|schema:(update|drop))\b/i, 'doctrine'],
502 [/\bphinx\s+(migrate|rollback)\b|\byii\s+migrate(?!\/(create|history|new))\b|\bcake\s+migrations\s+(migrate|rollback)\b/i, 'php migrations'],
503 [/\bflyway\b[^|;&\n]{0,200}?\b(migrate|clean|repair|undo|baseline)\b|\bflyway(Migrate|Clean|Repair)\b/i, 'flyway'],
504 [/\bliquibase\b[^|;&\n]{0,200}?\b(update|rollback|drop-?all|changelog-?sync)\b/i, 'liquibase'],
505 [/\bdrizzle-kit\s+(push|migrate)\b/i, 'drizzle-kit'],
506 [/\bsupabase\s+(db\s+(push|reset)|migration\s+(up|repair|squash))\b/i, 'supabase'],
507 [/\bwrangler\s+d1\s+migrations\s+apply\b/i, 'wrangler d1'],
508 [/\bhasura\s+(migrate\s+(apply|delete)|metadata\s+apply)\b/i, 'hasura'],
509 [/\b(node-pg-migrate|db-migrate)\s+(up|down|redo|reset)\b|\bsqitch\s+(deploy|revert|rebase)\b/i, 'migrations'],
510 [/\b(goose|dbmate)\b[^|;&\n]{0,200}?\b(up|up-by-one|up-to|down|down-to|reset|redo|rollback|drop|migrate)\b/i, 'goose/dbmate'],
511 [/\bmigrate\b[^|;&\n]{0,200}?\s-(path|source|database)\b[^|;&\n]{0,200}?\b(up|down|drop|force|goto)\b/i, 'golang-migrate'],
512 [/\batlas\s+(migrate\s+apply|schema\s+(apply|clean))\b/i, 'atlas'],
513 [/\bdotnet\s+ef\s+(database\s+(update|drop)|migrations\s+remove)\b|\b(Update|Drop)-Database\b/i, 'EF Core'],
514 [/\bdiesel\s+(migration\s+(run|revert|redo)|database\s+reset)\b/i, 'diesel'],
515 [/\bsqlx\s+(migrate\s+(run|revert)|database\s+(drop|reset))\b/i, 'sqlx'],
516 [/\bmix\s+ecto\.(migrate|rollback|drop|reset)\b/i, 'ecto'],
517 // Hand-rolled ones: node scripts/migrate.ts, python migrate.py, go run ./cmd/migrate up
518 [
519 /\b(node|tsx|ts-node|bun|deno\s+run|python[\d.]*|ruby|php)\b[^|;&\n]{0,200}?(^|[\s'"\\/])(run[-_])?(migrate|migrations?|migrator)([-_](up|down|latest|all))?\.(m?[jt]s|py|rb|php)\b|\bgo\s+run\s+\S*cmd[\\/]migrate\b/i,
520 'a migration script',
521 ],
522]
523
524// Database clients as commands, not as part of a path or a file name (bitnami/mysql is a chart, mysql.yaml a file).
525const DB_CLIENT =
526 /(?<![\w.-])(psql|pgcli|mysql|mycli|mariadb|sqlite3|litecli|sqlcmd|Invoke-Sqlcmd|sqlplus|mongosh|mongo|clickhouse-client|cockroach\s+sql|duckdb|usql|turso\s+db\s+shell|pscale\s+shell|wrangler\s+d1\s+execute|prisma\s+db\s+execute|typeorm(-ts-node-\w+)?\s+query|manage\.py\s+dbshell|rails\s+(dbconsole|db)(?=\s|$)|artisan\s+db(?=\s|$))(?![\w./-])/i
527// Text after the client that feeds it SQL we can't see: -f x, --file x, < x, \i x, .read x, source x.sql.
528// Case matters: psql's -F is a field separator. A -f with a config file is some other tool's flag.
529const SQL_FILE_INPUT =
530 /\s-f\s*(?!\S*\.(?:ya?ml|json|toml|conf|ini|env|cfg)\b)\S|\s--file[=\s]\S|(?<!<)<(?!<)\s*[^\s<]+|\s-i\s+\S|\\i\s+\S|\\include\s+\S|\.read\s+\S|--stdin\b|\.[sS][qQ][lL]\b|\bsource\s+\S/
531// cat x.sql | psql, gunzip -c dump.sql.gz | psql
532const SQL_PIPED_IN = /\.sql(\.(gz|bz2|xz|zst))?\b["']?\s*\|/i
533
534const DATA_WIPES: ReadonlyArray<readonly [RegExp, string, string]> = [
535 [/\b(docker|podman)[- ]compose\b[^|;&\n]{0,200}?\bdown\b[^|;&\n]{0,200}?\s(-v|--volumes)\b/i, 'delete Docker volumes', NOTE.volumes],
536 [/\b(docker|podman)\s+(volume\s+(rm|prune)|system\s+prune\b[^|;&\n]{0,200}?--volumes)\b/i, 'delete Docker volumes', NOTE.volumes],
537 [/\bdropdb\b|\bmysqladmin\b[^|;&\n]{0,200}?\bdrop\b/i, 'drop a database', NOTE.dropDb],
538 [/\bheroku\s+pg:reset\b/i, 'reset a Heroku database', NOTE.dropDb],
539]
540
541const MIGRATION_DIR_WORD =
542 /(^|[\s'"=\\/*(,])(migrations?|db[\\/]migrate|db[\\/]changelog|alembic[\\/]versions|drizzle(?=[\\/]))(?=$|[\s'"\\/*),])/i
543// The folder has to be a whole path segment: cd tools/migration-guard is not cd migrations.
544const CD_INTO_MIGRATIONS =
545 /^\s*(cd|pushd|Set-Location|Push-Location|sl)\s+["']?(?:\S{0,300}[\\/])?(migrations?|db[\\/]migrate|db[\\/]changelog|alembic[\\/]versions|drizzle)(?=$|[\\/\s'"])/i
546const CD = /^\s*(cd|pushd|popd|Set-Location|Push-Location|Pop-Location|sl)\b/i
547const REDIRECT = /(?:>{1,2}|\btee\s+(?:-a\s+)?)\s*(\S+)/g
548
549/** A redirect (`>`, `>>`, `tee`) whose target is a migration file. A README or a test next to it doesn't count. */
550function writesMigration(text: string, extraPath?: RegExp): boolean {
551 for (const m of capped(text).matchAll(REDIRECT)) {
552 if (isMigrationPath((m[1] ?? '').replace(/["']/g, ''), extraPath)) return true
553 }
554 return false
555}
556
557// A short command name only counts where a command starts: `grep -rni` is not PowerShell's `rni`.
558const AT_COMMAND = String.raw`(?:^\s*|[|({\x60]\s*|\$\(\s*|\bxargs(?:\s+-\S+)*\s+|-exec(?:dir)?\s+|\b(?:sudo|time|nohup|command|env)\s+(?:-\S+\s+)*|\b\w+=\S*\s+)`
559const IN_PLACE = /\b(sed|perl)\b[^|;&\n]{0,300}?\s(-\w*i|--in-place)/i
560// Unix, Windows (cmd, PowerShell) ways of changing, moving or deleting files, other than editing in place.
561const OTHER_FILE_VERB = new RegExp(
562 [
563 `${AT_COMMAND}(rm|rmdir|mv|truncate|unlink|shred|del|erase|rd|ren|rename|move|ri|ni|mi|rni|touch|unzip)(?=\\s|$)`,
564 `${AT_COMMAND}(tar\\s+-?\\w*x|tar\\b[^|;&\\n]{0,200}--extract|dd\\b[^|;&\\n]{0,300}?\\bof=|(curl|wget)\\b[^|;&\\n]{0,300}?\\s-[oO]\\b)`,
565 String.raw`\bgit\s+(checkout(?!\s+-[bBt]\b)|restore(?![^|;&\n]{0,200}--staged\b)|rm|mv|clean|apply|stash)\b`,
566 String.raw`\b(Remove|Move|Rename|New|Clear)-Item\b|\b(Set|Add|Clear)-Content\b|\bOut-File\b`,
567 String.raw`\s-delete\b`,
568 ].join('|'),
569 'i',
570)
571const FILE_VERB = new RegExp(`${IN_PLACE.source}|${OTHER_FILE_VERB.source}`, 'i')
572
573/**
574 * The step without the script that sed or perl runs. In `sed -i 's/a migration/b/' notes.txt`
575 * the quoted part is code and only notes.txt changes, so only notes.txt (and anything piped in,
576 * as in `find migrations | xargs sed -i ...`) should say where the edit lands.
577 */
578function withoutEditScript(text: string): string {
579 // The sed or perl that edits in place, not just the first "sed" in the line (./sed.txt).
580 const m = IN_PLACE.exec(text)
581 if (!m) return text
582 const name = m[1] ?? ''
583 const sed = name.toLowerCase() === 'sed'
584 const head = m.index + name.length
585 const words: Array<{ raw: string; value: string }> = []
586 let i = head
587 let end = text.length
588 while (i < text.length) {
589 while (i < text.length && /\s/.test(text.charAt(i))) i++
590 if (i >= text.length) break
591 if (/[|;&]/.test(text.charAt(i))) {
592 end = i
593 break
594 }
595 let raw = ''
596 let value = ''
597 while (i < text.length && !/[\s|;&]/.test(text.charAt(i))) {
598 const ch = text.charAt(i)
599 if (ch === "'" || ch === '"') {
600 let j = i + 1
601 while (j < text.length && text.charAt(j) !== ch) j += ch === '"' && text.charAt(j) === '\\' ? 2 : 1
602 value += text.slice(i + 1, j)
603 raw += text.slice(i, j + 1)
604 i = j + 1
605 } else if (ch === '\\' && i + 1 < text.length) {
606 value += text.charAt(i + 1)
607 raw += text.slice(i, i + 2)
608 i += 2
609 } else {
610 value += ch
611 raw += ch
612 i++
613 }
614 }
615 words.push({ raw, value })
616 }
617
618 const drop = new Set<number>()
619 let script = false
620 let options = true
621 for (let k = 0; k < words.length; k++) {
622 const w = words[k]?.value ?? ''
623 if (options && w === '--') {
624 options = false
625 } else if (options && /^--(expression|file)$/.test(w)) {
626 drop.add(k + 1)
627 k++
628 script = true
629 } else if (options && /^--(expression|file)=/.test(w)) {
630 drop.add(k)
631 script = true
632 } else if (options && /^-[^-]/.test(w)) {
633 // A bundle like -pi, -ne, -i.bak. After i comes a backup suffix; after e (or f for sed) the script.
634 for (let c = 1; c < w.length; c++) {
635 const flag = w.charAt(c)
636 if (flag === 'i') {
637 // BSD sed takes the suffix as its own word: sed -i '' 's/a/b/' x
638 if (sed && c === w.length - 1 && w.length === 2 && (words[k + 1]?.value === '' || words[k + 1]?.value.startsWith('.'))) {
639 drop.add(k + 1)
640 k++
641 }
642 break
643 }
644 // sed -E is extended regexps; perl -E is -e with features on.
645 if (flag === 'e' || (flag === 'E' && !sed) || (flag === 'f' && sed)) {
646 if (c < w.length - 1) drop.add(k)
647 else {
648 drop.add(k + 1)
649 k++
650 }
651 script = true
652 break
653 }
654 if (/[lI0MmFCdDx]/.test(flag)) break // takes the rest of the bundle as its value
655 }
656 } else if (!script) {
657 // No -e: sed's first word is its script, perl's is a program file. Neither is a file it edits.
658 drop.add(k)
659 script = true
660 options = false
661 }
662 }
663 const kept = words.filter((_, k) => !drop.has(k)).map((w) => w.raw)
664 return `${text.slice(0, head)} ${kept.join(' ')} ${text.slice(end)}`
665}
666// Copies only change migrations when they land in a migrations folder: cp migrations/x.sql /tmp/ is a read.
667const COPY_VERB = new RegExp(`${AT_COMMAND}(cp|copy|cpi|rsync|install|ln)(?=\\s|$)|\\bCopy-Item\\b`, 'i')
668const DELETES = new RegExp(
669 `${AT_COMMAND}(rm|rmdir|del|erase|rd|ri|unlink|shred|truncate)(?=\\s|$)|\\bgit\\s+(rm|clean)\\b|\\b(Remove-Item|Clear-Content)\\b|\\s-delete\\b`,
670 'i',
671)
672// Steps whose quoted text is code that runs (bash -c "...", ssh host "..."), not text to ignore.
673const RUNS_QUOTED = /\b(bash|sh|zsh|dash|ksh|fish|pwsh|powershell|cmd(\.exe)?|ssh|eval|su|docker\s+(exec|run)|podman\s+(exec|run)|kubectl\s+exec)\b/i
674// One-liners: python -c "...", node -e "...", ruby -e, php -r, deno eval
675const ONE_LINER = /^\s*(?:\w+=\S*\s+)*(?:sudo\s+)?(python[\d.]*|node|deno|bun|ruby|perl|php|tsx)\b[^|;&\n]{0,100}?\s(-c|-e|-r|-p|--eval|eval)\s/i
676
677/** The step with quoted text blanked out: `grep "TRUNCATE" migrations/` searches, it doesn't truncate. */
678function unquoted(text: string): string {
679 return text.replace(/'[^']*'|"(?:[^"\\]|\\.)*"/g, '""')
680}
681
682/** The last word of a step, without quotes: where cp, rsync and friends put things. */
683function destination(text: string): string {
684 return (text.trim().split(/\s+/).pop() ?? '').replace(/^["']|["']$/g, '')
685}
686
687/** A shell change to migration files. Deleting is high risk; writing is judged by what's written. */
688function shellChange(step: Step, written: readonly string[]): CommandHit {
689 const findings = dedupe([
690 ...findDestructive(step.text, { keepComments: true }),
691 ...written.flatMap((body) => findDestructive(body, { skipDown: true })),
692 ])
693 const deletes = DELETES.test(unquoted(step.text))
694 const risky = deletes || riskOf(findings) === 'high'
695 // `cat > migrations/x.sql <<'EOF'` reads better without the heredoc marker.
696 const part = step.text.trim().replace(/\s*<<-?\s*(['"]?)[\w.-]+\1\s*$/, '')
697 return { reason: 'change migration files from the shell', risky, note: deletes ? NOTE.shellDeletes : NOTE.shellWrites, findings, part }
698}
699
700// ------------------------------------------------------------- scripts
701
702// What scripts call to write, move or delete files, and the path arguments they take:
703// open(p, 'w'), fs.writeFileSync(p, ...), os.remove(p), shutil.move(a, b), Path(p).write_text(...), p.unlink()
704const WRITE_CALL =
705 /\bopen\(\s*([^,)]{1,300}?)\s*,\s*[rf]?['"][wax]|\b(?:writeFile(?:Sync)?|appendFile(?:Sync)?|unlink(?:Sync)?|rmSync|rmdirSync|renameSync|copyFileSync|createWriteStream|fs\.(?:rm|unlink|rename|writeFile|appendFile|copyFile)|os\.(?:remove|unlink|rename|replace|rmdir)|shutil\.(?:rmtree|move|copy\w*)|FileUtils\.(?:rm\w*|mv|cp)|File\.(?:write|delete|rename))\s*\(([^)]{0,300})\)|\bPath\(\s*([^)]{1,300}?)\s*\)\s*\.(?:write_text|write_bytes|unlink|rename|replace|rmdir)\b|\b(\w+)\.(?:write_text|write_bytes|unlink)\s*\(/g
706const SCRIPT_DELETE = /\b(unlink(Sync)?|rmSync|rmdirSync|os\.(remove|unlink|rmdir)|shutil\.rmtree|FileUtils\.rm\w*|File\.delete|fs\.(rm|unlink))\b/
707const SCRIPT_SPAWNS = /\b(subprocess\.\w+|os\.system|os\.popen|execSync|execFileSync|spawnSync|exec|spawn|system|popen)\s*\(/g
708const SCRIPT_QUERIES = /\.(execute|executescript|query|exec|raw)\s*\(/g
709const LITERAL = /'''([\s\S]{0,20000}?)'''|"""([\s\S]{0,20000}?)"""|'((?:[^'\\\n]|\\.){0,5000})'|"((?:[^"\\\n]|\\.){0,5000})"|`([^`]{0,20000})`/g
710// A migrations folder, or a folder inside one: 'migrations', 'src/database/migrations/index/'
711const MIGRATION_DIR_ONLY = /(^|[\\/])(migrations?|db[\\/]migrate|db[\\/]changelog|alembic[\\/]versions|drizzle)([\\/][^.]*)?$/i
712
713/** The code a one-liner runs: the quoted argument after -c or -e. */
714function oneLinerCode(text: string): string {
715 return /\s(?:-c|-e|-r|-p|--eval|eval)\s+(['"])((?:(?!\1)[^\\]|\\[\s\S]){0,20000})\1/.exec(text)?.[2] ?? text
716}
717
718function stringLiterals(code: string): string[] {
719 const out: string[] = []
720 for (const m of capped(code).matchAll(LITERAL)) {
721 out.push(m[1] ?? m[2] ?? m[3] ?? m[4] ?? m[5] ?? '')
722 if (out.length >= 2000) break
723 }
724 return out
725}
726
727/** Where the script's longer strings sit, so a call written inside a string (text being inserted) doesn't count. */
728function stringRanges(code: string): Array<readonly [number, number]> {
729 const out: Array<readonly [number, number]> = []
730 for (const m of code.matchAll(LITERAL)) {
731 if (m[0].length > 6) out.push([m.index ?? 0, (m.index ?? 0) + m[0].length])
732 if (out.length >= 2000) break
733 }
734 return out
735}
736
737function insideAny(ranges: ReadonlyArray<readonly [number, number]>, index: number): boolean {
738 return ranges.some(([start, end]) => index > start && index < end)
739}
740
741/**
742 * What a path argument stands for: a string ('x.ts', f'...{n}.ts'), a variable
743 * assigned a string somewhere in the script (p = 'x.ts'), the first argument
744 * at every call of a helper whose parameter it is (def edit(p, ...) called as
745 * edit('x.ts', ...)), or the strings in an expression
746 * (os.path.join(ROOT, 'migrations', name)). `sure` is false when the script
747 * builds the path in a way we can't follow.
748 */
749function pathValues(expr: string, code: string): { values: string[]; sure: boolean } {
750 const e = expr.trim()
751 const literal = /^[rfb]?(['"`])([\s\S]*)\1$/.exec(e)
752 if (literal) return { values: [literal[2] ?? ''], sure: true }
753 if (/^\w+$/.test(e)) {
754 const assigned = new RegExp(String.raw`(?:^|[\s;(,])(?:const\s+|let\s+|var\s+)?${e}\s*=\s*(?:Path\(\s*)?[rf]?(['"\x60])([^'"\x60\n]{0,500})\1`, 'm').exec(code)
755 if (assigned) return { values: [assigned[2] ?? ''], sure: true }
756 // A helper's first parameter: look at what each call passes.
757 const helper = new RegExp(String.raw`\b(?:def|function)\s+(\w+)\s*\(\s*${e}\b`).exec(code)?.[1]
758 if (helper) {
759 const passed = [...code.matchAll(new RegExp(String.raw`(?<!def |function )\b${helper}\(\s*([^,)]{1,300})`, 'g'))].slice(0, 50)
760 const each = passed.map((m) => pathValues(m[1] ?? '', code.replace(new RegExp(String.raw`\b(?:def|function)\s+${helper}\b`, 'g'), '')))
761 return { values: each.flatMap((p) => p.values), sure: passed.length > 0 && each.every((p) => p.sure) }
762 }
763 return { values: [], sure: false }
764 }
765 return { values: stringLiterals(e), sure: false }
766}
767
768/**
769 * A script (python -c, node -e, a heredoc fed to python) that writes or
770 * deletes a migration file, runs migrations, or runs destructive SQL. A write
771 * counts when its target is a migration path; when the target can't be
772 * followed, when any string in the script is one. So a script that edits a
773 * doc which merely mentions migrations/ stays quiet.
774 */
775function scriptChange(code: string, extraPath: RegExp | undefined, insideMigrations: boolean): CommandHit | undefined {
776 code = capped(code)
777 const literals = stringLiterals(code)
778 const ranges = stringRanges(code)
779 // After a cd into a migrations folder, a bare file name ('001.sql') is a migration too.
780 const isTarget = (l: string) =>
781 !/[*?]/.test(l) && (isMigrationPath(l, extraPath) || MIGRATION_DIR_ONLY.test(l) || (insideMigrations && CODE_FILE.test(l) && !/[\\/\s]/.test(l)))
782
783 let writes = false
784 let hit: string | undefined // the migration it writes, named in the question
785 let unsure = false
786 let looked = 0
787 const resolved = new Map<string, { values: string[]; sure: boolean }>()
788 for (const m of code.matchAll(WRITE_CALL)) {
789 if (hit !== undefined) break
790 if (++looked > 50) {
791 unsure = true // real scripts write a handful of files; past that, fall back to any migration path in it
792 break
793 }
794 if (insideAny(ranges, m.index ?? 0)) continue
795 writes = true
796 const args = m[1] ?? m[3] ?? m[4] ?? (m[2] ?? '').split(',').slice(0, 2).join('\u0000')
797 for (const arg of args.split('\u0000')) {
798 const key = arg.trim()
799 const found = resolved.get(key) ?? pathValues(key, code)
800 resolved.set(key, found)
801 hit ??= found.values.find(isTarget)
802 if (!found.sure) unsure = true
803 }
804 }
805 if (writes && unsure) hit ??= literals.find(isTarget)
806 if (writes && hit !== undefined) {
807 const findings = findDestructive(literals.join('\n'), { skipDown: true })
808 const deletes = SCRIPT_DELETE.test(code)
809 const risky = deletes || riskOf(findings) === 'high'
810 return { reason: 'change a migration file from a script', risky, note: deletes ? NOTE.shellDeletes : NOTE.scriptWrites, findings, part: shortPath(hit) }
811 }
812
813 const calls = (re: RegExp) => {
814 let looked = 0
815 for (const m of code.matchAll(re)) {
816 if (!insideAny(ranges, m.index ?? 0)) return true
817 if (++looked > 50) break
818 }
819 return false
820 }
821 const words = literals.join(' ')
822 if (calls(SCRIPT_SPAWNS)) {
823 for (const [re, what] of RUNNERS) {
824 const ran = re.exec(words)?.[0]
825 if (ran) return { reason: `run ${what} from a script`, ...riskOfWords(words), findings: [], part: ran }
826 }
827 }
828 if (calls(SCRIPT_QUERIES)) {
829 const findings = findDestructive(words, { keepComments: true })
830 const first = findings[0]
831 if (first) return { reason: 'run destructive SQL from a script', risky: riskOf(findings) === 'high', note: NOTE.sqlDirect, findings, part: first.snippet }
832 }
833 return undefined
834}
835
836function dedupe(findings: Finding[]): Finding[] {
837 return findings.filter((f, i) => findings.findIndex((g) => g.label === f.label) === i)
838}
839
840/** Why a shell command touches migrations or the database, or undefined when it doesn't. */
841export function classifyCommand(
842 command: string,
843 opts: { extra?: RegExp; extraPath?: RegExp; powershell?: boolean } = {},
844): CommandHit | undefined {
845 const powershell = opts.powershell === true
846 if (command.length > MAX_SCAN) return { reason: 'run a command too long to check', risky: true, note: NOTE.tooLong, findings: [], part: command.slice(-100) }
847 // A line continuation (\ in sh, ` in PowerShell) joins one command.
848 command = command.replace(powershell ? /`\r?\n/g : /\\\r?\n/g, ' ')
849 const heredocs = readHeredocs(command, opts.extraPath)
850 const steps = splitSteps(heredocs.text, false, powershell)
851 const live = steps.filter((s) => !onlyMentions(s, powershell))
852 const liveText = live.map((s) => s.text).join('\n')
853 const findings = findDestructive(liveText, { keepComments: true })
854
855 if (opts.extra?.test(command)) return { reason: 'run a command from your extraCommandPattern', ...riskOfExtra(command), findings, part: command }
856
857 for (const step of live) {
858 for (const [re, reason, note] of DATA_WIPES) {
859 if (re.test(step.text)) return { reason, risky: true, note, findings, part: step.text.trim() }
860 }
861 if (/\bpg_restore\b[^|;&\n]{0,300}\s(-d|--dbname)\b|\bmongorestore\b/i.test(step.text)) {
862 const clean = /\s(-c|--clean|--drop)\b/i.test(step.text)
863 return { reason: 'restore a dump into a database', risky: clean, note: clean ? NOTE.restoreClean : NOTE.restore, findings, part: step.text.trim() }
864 }
865 }
866
867 const client = live.find((s) => DB_CLIENT.test(s.text))
868 if (client) {
869 const part = client.text.trim()
870 if (findings.length > 0) return { reason: 'run destructive SQL directly', risky: riskOf(findings) === 'high', note: NOTE.sqlDirect, findings, part }
871 const at = client.text.search(DB_CLIENT)
872 if (SQL_FILE_INPUT.test(client.text.slice(at)) || SQL_PIPED_IN.test(client.text.slice(0, at))) {
873 return { reason: 'run a SQL file against a database', risky: true, note: NOTE.sqlFile, findings, part }
874 }
875 }
876
877 // Each step on its own, so `rm -rf /tmp/x && echo "see migrations/"` doesn't
878 // count. Pipes stay inside a step (`find migrations | xargs rm`), and a
879 // `cd` into a migrations folder carries over until the next `cd`.
880 let insideMigrations = false
881 let wentInside = false
882 for (const step of steps) {
883 if (CD_INTO_MIGRATIONS.test(step.text)) insideMigrations = wentInside = true
884 else if (CD.test(step.text)) insideMigrations = false
885 if (writesMigration(step.text, opts.extraPath)) return shellChange(step, heredocs.written)
886 if (onlyMentions(step, powershell)) continue
887 if (ONE_LINER.test(step.text) && !IN_PLACE.test(step.text)) {
888 const hit = scriptChange(oneLinerCode(step.text), opts.extraPath, insideMigrations)
889 if (hit) return hit
890 continue
891 }
892 // Quoted text is a search pattern or a message, unless the step hands it to a shell.
893 const verbs = RUNS_QUOTED.test(step.text) ? step.text : unquoted(step.text)
894 const patch = APPLIES_PATCH.test(verbs)
895 // sed -i and perl -pi: the script they run is not a path, so only the files they edit count.
896 const where = patch ? liveText : IN_PLACE.test(verbs) && !OTHER_FILE_VERB.test(verbs) ? withoutEditScript(step.text) : step.text
897 if ((patch || FILE_VERB.test(verbs)) && (insideMigrations || MIGRATION_DIR_WORD.test(where))) {
898 return shellChange(step, [])
899 }
900 if (COPY_VERB.test(verbs)) {
901 const to = destination(step.text)
902 if (MIGRATION_DIR_WORD.test(to) || (insideMigrations && !/^([\\/~]|[a-z]:)/i.test(to))) return shellChange(step, [])
903 }
904 }
905 for (const script of heredocs.scripts) {
906 const hit = scriptChange(script, opts.extraPath, wentInside)
907 if (hit) return hit
908 }
909
910 for (const step of live) {
911 if (ONE_LINER.test(step.text)) continue // judged as a script above
912 for (const part of splitSteps(step.text, true, powershell)) {
913 if (READ_ONLY.test(part.text) || (ONLY_MENTIONS.test(part.text) && !part.substitutes)) continue
914 // Quotes the shell removes don't hide a name: npm run 'db:migrate', mig""ration:run
915 const plain = part.text.replace(/["']/g, '')
916 for (const [re, what] of RUNNERS) {
917 if (re.test(part.text) || re.test(plain)) return { reason: `run ${what}`, ...riskOfWords(part.text), findings, part: part.text.trim() }
918 }
919 }
920 }
921 return undefined
922}
923
924function riskOfExtra(command: string): { risky: boolean; note: string } {
925 const { risky } = riskOfWords(command)
926 return { risky, note: NOTE.extra }
927}
928
929// ------------------------------------------------------------- MCP tools
930
931export type McpHit = CommandHit & { kind: 'file' | 'command'; key: string; target: string }
932
933const MCP_READS = /^(list|get|show|describe|read|search|find|fetch|view|check|explain)|[-_](status|list|history)$/i
934const MCP_RUNS_SQL = /sql|query|exec|statement|transaction|(^|[-_])run([-_]|$)/i
935const MCP_WRITES = /write|edit|create|update|move|rename|delete|remove|patch|put|append|replace|copy|upload|push|commit/i
936const PATH_KEY = /path|file|source|destination|target|^(from|to|src|dest|uri)$/i
937
938/** Every string in a tool's arguments with the key it sits under, a few levels deep. */
939function stringLeaves(value: unknown, key = '', depth = 0, out: { key: string; value: string }[] = []) {
940 if (out.length >= 200 || depth > 4) return out
941 if (typeof value === 'string') out.push({ key, value })
942 else if (Array.isArray(value)) for (const item of value) stringLeaves(item, key, depth + 1, out)
943 else if (value && typeof value === 'object') {
944 for (const [k, v] of Object.entries(value)) stringLeaves(v, k, depth + 1, out)
945 }
946 return out
947}
948
949/**
950 * An MCP tool that migrates (Supabase apply_migration, Prisma migrate-reset),
951 * runs destructive SQL (execute_sql, run_sql), or writes a migration file
952 * (a filesystem or GitHub server).
953 */
954export function classifyMcpCall(tool: string, args: Readonly<Record<string, unknown>>, extraPath?: RegExp): McpHit | undefined {
955 const [, server = '', ...rest] = tool.split('__')
956 const name = rest.join('__')
957 const label = `${name} (${server.replace(/^claude_ai_/i, '')})`
958 const leaves = stringLeaves(args)
959 const text = capped(leaves.map((l) => l.value).join('\n'))
960 const findings = findDestructive(text, { keepComments: true })
961 const key = `${tool}\n${text}`
962
963 if (/migrat/i.test(name) && !MCP_READS.test(name)) {
964 const words = riskOfWords(name)
965 const note = words.note === NOTE.applies ? NOTE.mcp : words.note
966 return { kind: 'command', key, target: label, reason: 'change the database with an MCP tool', risky: words.risky, note, findings, part: label }
967 }
968 if (MCP_WRITES.test(name)) {
969 const path = leaves.find((l) => PATH_KEY.test(l.key) && isMigrationPath(l.value, extraPath))
970 if (path) {
971 const content = leaves.filter((l) => !PATH_KEY.test(l.key)).map((l) => l.value).join('\n')
972 return {
973 kind: 'file',
974 key: approvalKey(path.value),
975 target: shortPath(path.value),
976 reason: `change a migration file with ${label}`,
977 risky: false,
978 note: '',
979 findings: isUndoFile(path.value) ? [] : findDestructive(content, { skipDown: true, hashComments: usesHashComments(path.value) }),
980 part: label,
981 }
982 }
983 }
984 if (findings.length > 0 && MCP_RUNS_SQL.test(name)) {
985 return { kind: 'command', key, target: label, reason: 'run destructive SQL with an MCP tool', risky: riskOf(findings) === 'high', note: NOTE.sqlDirect, findings, part: label }
986 }
987 return undefined
988}
989
990// ------------------------------------------------------------- helpers
991
992/** A user-supplied pattern from the plugin's settings; an invalid one is ignored. */
993export function safeRegex(source: unknown): RegExp | undefined {
994 if (typeof source !== 'string' || source.trim() === '') return undefined
995 try {
996 return new RegExp(source, 'i')
997 } catch {
998 return undefined
999 }
1000}
1001
1002/** The last three path segments, so the question stays readable. */
1003export function shortPath(path: string): string {
1004 const parts = path.split(/[\\/]/).filter(Boolean)
1005 return parts.length > 3 ? `.../${parts.slice(-3).join('/')}` : path
1006}
1007
1008/** Windows paths are case-insensitive, so "don't ask again" shouldn't care about case there. */
1009export function approvalKey(path: string): string {
1010 return /^[a-z]:[\\/]|\\/i.test(path) ? path.toLowerCase() : path
1011}
1012hooks/history.ts 64 lines1// What was asked and what you answered, kept in Claude Code's own store for
2// this mod (not a file in your project) and shown by /migration-guard.
3
4import type { Level } from './detect'
5
6export type Entry = {
7 /** When, in ms. */
8 at: number
9 /** The folder Claude was working in. */
10 project: string
11 /** "write a migration file", "run prisma", ... */
12 what: string
13 target: string
14 risk: Level
15 labels: string[]
16 /** What the person picked or typed, or why nobody was asked. */
17 answer: string
18}
19
20export const HISTORY_KEY = 'history'
21export const HISTORY_LIMIT = 300
22
23/** Passwords and tokens in commands never reach the store. */
24export function redact(text: string): string {
25 const out = text
26 .replace(/\b([a-z][\w+.-]*:\/\/[^\s:/@]+:)[^\s@/]+@/gi, '$1***@') // postgres://user:REDACTED@host
27 .replace(/\b([A-Z0-9_]*(?:PASSWORD|PASSWD|PWD|TOKEN|SECRET|API_?KEY)[A-Z0-9_]*)=("[^"]*"|'[^']*'|[^\s'"]+)/gi, '$1=***')
28 .replace(/(--password\s)("[^"]*"|'[^']*'|[^\s'"]+)/gi, '$1***')
29 // mysql -psecret: only for the MySQL tools, where -p glued to a value is the password
30 return /\b(mysql|mariadb|mysqladmin|mysqldump)\b/i.test(out) ? out.replace(/(\s-p)(?=[^\s-])("[^"]*"|'[^']*'|\S+)/g, '$1***') : out
31}
32
33/** Adds an entry and drops the oldest past the limit. */
34export function append(list: readonly Entry[], entry: Entry): Entry[] {
35 const next = [...list, { ...entry, target: redact(entry.target).slice(0, 200) }]
36 return next.length > HISTORY_LIMIT ? next.slice(next.length - HISTORY_LIMIT) : next
37}
38
39/** Reads what the store holds, ignoring anything that isn't a list of entries. */
40export function asEntries(value: unknown): Entry[] {
41 if (!Array.isArray(value)) return []
42 return value.filter((v): v is Entry => typeof v === 'object' && v !== null && typeof (v as { at?: unknown }).at === 'number')
43}
44
45const pad = (n: number) => String(n).padStart(2, '0')
46
47function when(ms: number): string {
48 const d = new Date(ms)
49 return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
50}
51
52/** The newest `count` entries, newest first, one line each, for /migration-guard. */
53export function render(list: readonly Entry[], count: number): string {
54 if (list.length === 0) return 'Nothing asked yet.'
55 const shown = list.slice(-count).reverse()
56 const lines = shown.map((e) => {
57 const found = e.labels.length > 0 ? ` (${e.labels.join(', ')})` : ''
58 return `${when(e.at)} ${e.answer} [${e.risk}] ${e.what}: ${e.target}${found} in ${e.project}`
59 })
60 const blocked = list.filter((e) => /^(No|blocked)/.test(e.answer) || e.answer.startsWith('"')).length
61 const head = `Last ${shown.length} of ${list.length} (${blocked} blocked). /migration-guard all shows ${HISTORY_LIMIT}, /migration-guard clear empties it.`
62 return [head, ...lines].join('\n')
63}
64hooks/question.ts 84 lines1// The words the person sees. Kept apart so they're easy to read, test and change.
2
3import { bySeverity, impactOf, riskOf, type Finding, type Level } from './detect'
4
5export const YES = 'Yes'
6export const YES_FILE = 'Yes for this file'
7export const YES_COMMAND = 'Yes for this command'
8export const YES_ALL = 'Yes to all files, 15 min'
9export const NO = 'No'
10
11/** How long "Yes to all files" lasts. High-risk changes still ask inside it. */
12export const ALL_WINDOW_MS = 15 * 60_000
13
14const HEADER: Readonly<Record<Level, string>> = { high: 'High risk', medium: 'Medium risk', low: 'Low risk' }
15
16export type Ask = {
17 /** A file Claude writes or edits, or a command (shell or MCP). */
18 kind: 'file' | 'command'
19 /** What "Yes for this file/command" remembers: the file or the exact command. */
20 key: string
21 /** "write a migration file", "run prisma", ... */
22 what: string
23 /** The short file path, the command, or the MCP tool, as shown. */
24 target: string
25 findings: Finding[]
26 /** Why it matters when nothing destructive was spotted. */
27 note?: string
28 /** The command's own risk, used when there are no findings. */
29 baseRisk?: Level
30 /** One more line worth knowing, shown last. */
31 hint?: string
32}
33
34export function levelOf(ask: Ask): Level {
35 return riskOf(ask.findings, ask.baseRisk)
36}
37
38export type Option = { label: string; description: string }
39
40const OPTION: Readonly<Record<string, string>> = {
41 [NO]: 'Block it. Claude is told to stop and not work around it.',
42 [YES]: 'Allow this once.',
43 [YES_FILE]: "Don't ask again for this file, unless something more destructive shows up.",
44 [YES_COMMAND]: "Don't ask again for this exact command in this session.",
45 [YES_ALL]: 'Ordinary migration file changes go through for 15 minutes. High risk still asks.',
46}
47
48/** The one line under an option in the dialog. */
49export function describeOption(label: string): string {
50 return OPTION[label] ?? ''
51}
52
53/**
54 * The buttons, No first: anything that picks the first one by default picks
55 * the safe one. "Yes to all files" only shows for files that aren't high
56 * risk, since it never covers high-risk changes anyway.
57 */
58export function optionsFor(ask: Ask): Option[] {
59 const labels =
60 ask.kind === 'command' ? [NO, YES, YES_COMMAND] : levelOf(ask) === 'high' ? [NO, YES, YES_FILE] : [NO, YES, YES_FILE, YES_ALL]
61 return labels.map((label) => ({ label, description: OPTION[label] ?? '' }))
62}
63
64/** Risk in the header chip; then what was found and what it does, worst first. */
65export function questionFor(ask: Ask): { header: string; question: string } {
66 const findings = bySeverity(ask.findings)
67 const lines = findings.slice(0, 2).map((f) => `${f.label}: ${impactOf(f.label).what}`)
68 if (findings.length > 2) lines.push(`+${findings.length - 2} more`)
69 // The line it came from, unless the command shown already says it.
70 const snippet = findings[0]?.snippet.replace(/[;\s]+$/, '') ?? ''
71 const evidence = snippet && !ask.target.includes(snippet) ? ` Found: ${snippet}.` : ''
72 const detail = lines.length > 0 ? `${lines.join('; ')}.${evidence}` : `${capitalize(ask.note ?? 'nothing destructive spotted')}.`
73 const hint = ask.hint ? ` ${ask.hint}` : ''
74 const stop = /[.!?;]$/.test(ask.target) ? '' : '.'
75 return {
76 header: HEADER[levelOf(ask)],
77 question: `Claude wants to ${ask.what}: ${ask.target}${stop} ${detail}${hint} Allow it?`,
78 }
79}
80
81function capitalize(text: string): string {
82 return text.charAt(0).toUpperCase() + text.slice(1)
83}
84