Asks before Claude runs destructive or slow-looking SQL through a DB CLI: DELETE, UPDATE without WHERE, DROP, TRUNCATE, full scans and cartesian joins.

A guard rail for SQL in Claude Code. Before Claude runs a destructive or slow-looking query through a DB CLI, you get a toast and a question only you can answer, in every permission mode.
<img src="docs/dialog.png" alt="query-guard asking whether to run a DELETE without WHERE, with Cancel as the first option" width="800">
Allow rules such as Bash(psql:*) make database work smooth, and they also wave through DELETE FROM users or a SELECT * over a billion-row table. Auto mode goes further and lets a classifier approve calls for you. query-guard reads the SQL in the command and puts every risky call back in front of you, whatever the allow rules or the permission mode would have decided.
~/.claude/settings.json: { "env": { "CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1" } }
claude plugin marketplace add nu0ma/query-guard
claude plugin install query-guard@nu0ma
/plugin marketplace add nu0ma/query-guard
/plugin install query-guard@nu0ma
[!IMPORTANT] query-guard is a safety net against mistakes, not a security boundary. A command built to hide its SQL (a CLI name assembled at run time, an encoded query, a script that talks to the database) can get past any text check. To make writes impossible, connect with a read-only database user.
query-guard looks only at Bash commands that call one of these CLIs, directly, through a pipe or heredoc, from a variable or a command substitution, or inside a wrapper such as docker exec, ssh or bash -c:
psql · pgcli · mysql · mariadb · mycli · sqlite3 · litecli · duckdb · bq · spanner-cli · spanner-readonly-cli · clickhouse-client · clickhouse client · cockroach · usql · sqlcmd · snowsql · trino · gcloud spanner databases execute-sql
| Rule | Example |
|---|---|
DELETE (flagged louder without WHERE; FROM optional, as in Spanner and BigQuery) | DELETE FROM users |
UPDATE without WHERE, multi-table forms included | UPDATE orders SET status = 'x' |
DROP of any object | DROP TABLE events |
ALTER TABLE ... DROP | ALTER TABLE t DROP c |
TRUNCATE | TRUNCATE events |
| Rule | Example |
|---|---|
SELECT ... FROM without WHERE or LIMIT | SELECT * FROM events |
CROSS JOIN | SELECT * FROM a CROSS JOIN b |
Comma join without WHERE | SELECT * FROM a, b |
LIKE with a leading wildcard | WHERE name LIKE '%foo' |
ORDER BY without LIMIT | SELECT id FROM t WHERE a = 1 ORDER BY id |
Several hits are joined into one reason, for example query-guard: destructive: DELETE without WHERE / possibly slow: LIKE with leading wildcard.
When query-guard and the database could read a statement differently, it errs toward asking:
/*! ... */ comments, and dialects disagree on where a string ends, so WHERE note = 'DROP TABLE x' asks too.WHERE or LIMIT counts only when it is the statement's own. One inside a comment (--, #, /* */), a string ('...', "...", ` ... , $$...$$), a subquery or a shell expansion that may come out empty ($VAR, ${...}, $(...)) is ignored, so UPDATE t SET a = 1 --WHERE id = 1` asks.${X:-DROP} TABLE t asks), on each shell word with its quotes removed (so psql -c "UPDATE t SET a = 1" -v x="WHERE" asks), and on each heredoc body.\psql, ps''ql, X=psql; $X and psql<<EOF all count.echo "DELETE FROM users" passes untouched.echo, printf or grep command that runs no substitution ($(...), backticks, <(...)), or in the body of a heredoc whose delimiter is quoted (<<'EOF'), that only cat or tee reads, and whose command runs nothing that could execute the written file (a shell, source, ., chmod, an interpreter, or a path such as ./s.sh), as when writing a release note.ON DELETE CASCADE, ON UPDATE, FOR UPDATE and ON DUPLICATE KEY UPDATE, which do not change rows by themselves.flowchart LR
B[Bash tool call] --> C{calls a DB CLI?}
C -- no --> V[engine verdict as is]
C -- yes --> P[raw text, shell words, heredoc bodies: strictest reading wins]
P --> R{any rule hits?}
R -- no --> V
R -- yes --> A[toast + ask the user]
A -- Run it --> E[engine: permission check, then the tool]
A -- anything else --> D[deny]
query-guard is a single tool.call hook on Bash. For a risky command it asks you through Claude Code's own question dialog ($.ui.ask) before the permission check runs, so no permission mode, allow rule or auto-mode classifier can answer for you. Only Run it lets the call go on to the normal permission flow; Cancel, any other answer, a dismissed dialog, or a headless -p run with nobody to ask denies it. Cancel is listed first so a dialog that resolves on its own lands on it.
Detection is static: regular expressions plus a small POSIX shell word splitter. It never connects to a database, and the plugin has no dependencies: no package.json, nothing to install.
psql -f file.sql, mysql < file.sql) is not inspected; only the command text is.SELECT * FROM small_lookup asks too.$(printf ps)ql, base64), eval, aliases and shell functions defined elsewhere. See the note at the top of What it catches.Run it is followed by the usual permission prompt, so you answer twice.-p run every risky command is denied, since nobody can answer.claude --plugin-dir plugins/query-guard # load this checkout; saving reloads it
claude plugin validate .
claude plugin validate plugins/query-guard
claude plugin test plugins/query-guard
Once the mod has loaded, Claude Code writes its type declarations to plugins/query-guard/.claude-plugin/types/ (ignored by git), and tsc -p plugins/query-guard type-checks it. Bump the version in plugins/query-guard/.claude-plugin/plugin.json and add a CHANGELOG.md entry with each release, since installed copies update only when the version changes.
hooks/register.ts 25 lines1import type { Register } from 'claude-code'
2import { analyze, describe } from './sql-rules.ts'
3
4export const RUN = 'Run it'
5export const CANCEL = 'Cancel'
6
7export const register: Register = on => {
8 // Ask the person directly rather than through tool.check, so no permission
9 // mode (auto, acceptEdits, bypassPermissions) can settle it without them
10 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
11 const findings = analyze(e.command)
12 if (findings.length === 0) return next(e)
13
14 const reason = describe(findings)
15 $.ui.toast(reason, { timeoutMs: 8000 })
16
17 // Cancel comes first so a dialog that resolves on its own lands on it
18 const answer = await $.ui
19 .ask(`${reason}. Run this command anyway?`, { header: 'query-guard', options: [CANCEL, RUN] })
20 .catch(() => undefined)
21
22 return answer === RUN ? next(e) : { deny: `${reason}. The user did not approve running it.` }
23 })
24}
25hooks/sql-rules.ts 291 lines1export type Severity = 'destructive' | 'slow'
2
3export type Finding = {
4 severity: Severity
5 label: string
6}
7
8const DB_CLIS = [
9 'psql',
10 'pgcli',
11 'mysql',
12 'mariadb',
13 'mycli',
14 'sqlite3',
15 'litecli',
16 'duckdb',
17 'bq',
18 'spanner-cli',
19 'spanner-readonly-cli',
20 'clickhouse-client',
21 'cockroach',
22 'usql',
23 'sqlcmd',
24 'snowsql',
25 'trino',
26]
27
28// Matched against a segment with its quotes and backslashes removed, so `\psql`, `ps''ql`,
29// `X=psql` and `psql<<EOF` all name the CLI the shell runs
30const DB_CLI = new RegExp(
31 `(?:^|[^\\w.-])(?:${DB_CLIS.join('|')})(?![\\w-])` +
32 '|\\bgcloud\\b[\\s\\S]*?\\bspanner\\s+databases\\s+execute-sql\\b' +
33 '|\\bclickhouse\\s+client\\b',
34)
35
36const SHELL_QUOTING = /['"\\]/g
37
38// Groups: the line holding the operator, the delimiter's quote, the delimiter, the body
39const HEREDOC = /^([^\n]*?<<-?[ \t]*(['"]?)([A-Za-z_]\w*)\2[^\n]*)\n([\s\S]*?)\n[ \t]*\3[ \t]*(?=\n|$)/gm
40
41// A heredoc body is only data when its delimiter is quoted (no expansion), nothing but
42// cat or tee reads it (a shell, ssh or a pipe would run it), and nothing else in the
43// command could run the file it writes
44const DATA_SINK_LINE = /^\s*(?:cat|tee)\b[^|;&`()]*$/
45
46const CODE_RUNNER =
47 /(?:^|[^\w.-])(?:bash|sh|zsh|dash|ksh|fish|source|eval|exec|chmod|xargs|python3?|node|deno|bun|ruby|perl|php|make|just)(?![\w-])/
48
49const COMMAND_PREFIX = /^(?:\w+=\S*|sudo|env|exec|nohup|time|nice|command)$/
50
51// Segments that start with these only print or search text, unless they run a substitution
52const INERT_SEGMENT = /^\s*(?:echo|printf|grep)\b/
53const SUBSTITUTION = /\$\(|`|<\(|>\(/
54
55const SHELL_SEGMENT = /;|&&|\|\||\||\n/
56
57const STATEMENT_SEPARATOR = /;|&&|\|\||\||\s-[ce]\s/
58
59// Everything that can hide a WHERE or a LIMIT from the database: comments in any dialect
60// (`--`, `#`, `/* */`), strings ('...', "...", `...`, $tag$...$tag$) with backslash
61// escapes, and unterminated ones to the end. Masking too much only asks more often.
62// Shell expansions (${...}, $VAR) count as opaque too: they can expand to nothing.
63// $(...) goes with the parenthesized groups below.
64const OPAQUE =
65 /\/\*[\s\S]*?(?:\*\/|$)|--[^\n]*|#[^\n]*|\$(\w*)\$[\s\S]*?(?:\$\1\$|$)|\$\{[^}]*(?:\}|$)|\$[A-Za-z_]\w*|\$\d|'(?:[^'\\]|\\[\s\S])*(?:'|$)|"(?:[^"\\]|\\[\s\S])*(?:"|$)|`[^`]*(?:`|$)/g
66
67// Shell syntax that can glue a keyword to its neighbours (${X:-DROP} TABLE)
68const SHELL_PUNCTUATION = /[${}()`'"\\]/g
69
70const PARENTHESIZED = /\([^()]*\)/g
71
72const HAS_WHERE = /\bWHERE\b/i
73const HAS_LIMIT = /\b(?:LIMIT|TOP|FETCH\s+FIRST)\b/i
74
75// Strips what follows a keyword down to the clauses that apply to it, so a WHERE in a
76// comment, a string or a subquery does not count as the statement's own
77const ownClauses = (rest: string): string => {
78 let text = rest.replace(OPAQUE, ' ')
79 for (let prev = ''; prev !== text; ) {
80 prev = text
81 text = text.replace(PARENTHESIZED, ' ')
82 }
83 return text
84}
85
86// The text after the first match whose prefix group is empty, as in DELETE but not ON DELETE
87const afterStatement = (statement: string, pattern: RegExp): string | undefined => {
88 for (const m of statement.matchAll(pattern)) {
89 if (m[1] === undefined) return statement.slice(m.index + m[0].length)
90 }
91 return undefined
92}
93
94const afterMatch = (statement: string, pattern: RegExp): string | undefined => {
95 const m = pattern.exec(statement)
96 return m === null ? undefined : statement.slice(m.index + m[0].length)
97}
98
99type Rule = {
100 severity: Severity
101 match: (statement: string) => string | undefined
102}
103
104// Keywords are searched in the raw statement, comments and strings included: a database
105// can run what looks like a comment (MySQL's /*! ... */) or end a string earlier than a
106// regex would, so hiding a keyword from the guard must not be possible
107const RULES: readonly Rule[] = [
108 {
109 severity: 'destructive',
110 // FROM is optional in Spanner and BigQuery
111 match: s => {
112 const rest = afterStatement(s, /\b(ON\s+)?DELETE\b/gi)
113 if (rest === undefined) return undefined
114 return HAS_WHERE.test(ownClauses(rest)) ? 'DELETE' : 'DELETE without WHERE'
115 },
116 },
117 {
118 severity: 'destructive',
119 // Covers multi-table forms (UPDATE a JOIN b ... SET) but not ON UPDATE or FOR UPDATE
120 match: s => {
121 const rest = afterStatement(s, /\b(ON\s+|FOR\s+(?:NO\s+KEY\s+)?)?UPDATE\b/gi)
122 if (rest === undefined) return undefined
123 const clauses = ownClauses(rest)
124 return /\bSET\b/i.test(clauses) && !HAS_WHERE.test(clauses) ? 'UPDATE without WHERE' : undefined
125 },
126 },
127 {
128 severity: 'destructive',
129 match: s => {
130 if (/\bALTER\s+TABLE\b[\s\S]*\bDROP\b/i.test(s)) return 'ALTER TABLE ... DROP'
131 const target = /\bDROP\s+(MATERIALIZED\s+VIEW|\w+)/i.exec(s)?.[1]
132 return target === undefined ? undefined : `DROP ${target.toUpperCase().replace(/\s+/g, ' ')}`
133 },
134 },
135 {
136 severity: 'destructive',
137 match: s => (/\bTRUNCATE\b/i.test(s) ? 'TRUNCATE' : undefined),
138 },
139 {
140 severity: 'slow',
141 match: s => {
142 const rest = afterMatch(s, /\bSELECT\b[\s\S]*?\bFROM\b/i)
143 if (rest === undefined) return undefined
144 const clauses = ownClauses(rest)
145 return HAS_WHERE.test(clauses) || HAS_LIMIT.test(clauses) ? undefined : 'possible full scan (no WHERE / LIMIT)'
146 },
147 },
148 {
149 severity: 'slow',
150 match: s => {
151 if (/\bCROSS\s+JOIN\b/i.test(s)) return 'cartesian product (CROSS JOIN)'
152 const rest = afterMatch(s, /\bFROM\s+[\w.`"]+(?:\s+(?:AS\s+)?(?!WHERE\b|JOIN\b|ON\b)\w+)?\s*,\s*[\w.`"]+/i)
153 return rest !== undefined && !HAS_WHERE.test(ownClauses(rest))
154 ? 'cartesian product (comma join without WHERE)'
155 : undefined
156 },
157 },
158 {
159 severity: 'slow',
160 match: s => (/\bI?LIKE\s+[EN]?'%/i.test(s) ? 'LIKE with leading wildcard' : undefined),
161 },
162 {
163 severity: 'slow',
164 match: s => {
165 const rest = afterMatch(s, /\bORDER\s+BY\b/i)
166 return rest !== undefined && !HAS_LIMIT.test(ownClauses(rest)) ? 'ORDER BY without LIMIT' : undefined
167 },
168 },
169]
170
171const isInert = (segment: string): boolean => INERT_SEGMENT.test(segment) && !SUBSTITUTION.test(segment)
172
173// The command a segment runs, past assignments and wrappers like sudo or env
174const commandWord = (segment: string): string =>
175 segment
176 .replace(SHELL_QUOTING, '')
177 .trim()
178 .split(/\s+/)
179 .find(word => !COMMAND_PREFIX.test(word)) ?? ''
180
181// Whether the command could run a file it wrote: an interpreter, chmod, or a path run as a command
182const runsCode = (command: string): boolean =>
183 command.split(SHELL_SEGMENT).some(segment => {
184 const word = commandWord(segment)
185 return CODE_RUNNER.test(segment.replace(SHELL_QUOTING, '')) || word === '.' || word.includes('/')
186 })
187
188export const isDbCommand = (command: string): boolean => {
189 const mayRunWrittenFile = runsCode(command.replace(HEREDOC, (_block: string, line: string) => line))
190 return command
191 .replace(HEREDOC, (block: string, line: string, quote: string) =>
192 quote !== '' && !mayRunWrittenFile && DATA_SINK_LINE.test(line) ? line : block,
193 )
194 .split(SHELL_SEGMENT)
195 .some(segment => !isInert(segment) && DB_CLI.test(segment.replace(SHELL_QUOTING, '')))
196}
197
198// Splits a command into words the way a POSIX shell does, quotes removed, so SQL passed
199// as one argument is read without the shell quotes around it and its neighbours
200const shellWords = (text: string): string[] => {
201 const words: string[] = []
202 let word = ''
203 let inWord = false
204 const flush = () => {
205 if (inWord) words.push(word)
206 word = ''
207 inWord = false
208 }
209
210 for (let i = 0; i < text.length; i++) {
211 const c = text[i] ?? ''
212 if (c === "'" || (c === '$' && text[i + 1] === "'")) {
213 // '...' is literal; $'...' takes backslash escapes
214 const ansi = c === '$'
215 let j = i + (ansi ? 2 : 1)
216 while (j < text.length && text[j] !== "'") {
217 if (ansi && text[j] === '\\') j++
218 word += text[j] ?? ''
219 j++
220 }
221 i = j
222 inWord = true
223 } else if (c === '"') {
224 let j = i + 1
225 while (j < text.length && text[j] !== '"') {
226 if (text[j] === '\\' && '$`"\\\n'.includes(text[j + 1] ?? 'x')) j++
227 word += text[j] ?? ''
228 j++
229 }
230 i = j
231 inWord = true
232 } else if (c === '\\') {
233 word += text[i + 1] ?? ''
234 i++
235 inWord = true
236 } else if (/\s/.test(c) || ';&|<>()'.includes(c)) {
237 flush()
238 } else {
239 word += c
240 inWord = true
241 }
242 }
243 flush()
244 return words
245}
246
247// Every reading of the command a rule runs on: the raw text, the text with shell
248// punctuation blanked, each shell word, and each heredoc body. Findings are unioned, so
249// no single reading can hide a statement.
250const readings = (command: string): string[] => {
251 const bodies: string[] = []
252 const withoutBodies = command.replace(HEREDOC, (_block: string, line: string, _q: string, _d: string, body: string) => {
253 bodies.push(body)
254 return line
255 })
256 return [command, command.replace(SHELL_PUNCTUATION, ' '), ...shellWords(withoutBodies), ...bodies]
257}
258
259export const analyze = (command: string): Finding[] => {
260 if (!isDbCommand(command)) return []
261
262 const labels = RULES.map(() => new Set<string>())
263 for (const reading of readings(command)) {
264 for (const statement of reading.split(STATEMENT_SEPARATOR)) {
265 RULES.forEach((rule, i) => {
266 const label = rule.match(statement)
267 if (label !== undefined) labels[i]?.add(label)
268 })
269 }
270 }
271
272 return RULES.flatMap((rule, i) => {
273 const found = labels[i] ?? new Set<string>()
274 // A reading without the WHERE outranks one that saw it
275 if (found.has('DELETE without WHERE')) found.delete('DELETE')
276 return [...found].map(label => ({ severity: rule.severity, label }))
277 })
278}
279
280export const describe = (findings: readonly Finding[]): string => {
281 const pick = (severity: Severity) => findings.filter(f => f.severity === severity).map(f => f.label)
282 const parts = [
283 ['destructive', pick('destructive')],
284 ['possibly slow', pick('slow')],
285 ] as const
286 return `query-guard: ${parts
287 .filter(([, labels]) => labels.length > 0)
288 .map(([name, labels]) => `${name}: ${labels.join(', ')}`)
289 .join(' / ')}`
290}
291