SLOPSHOPPER

query-guard

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

newguardtoast
v0.2.3MITupdated 2026-10-03nu0ma/query-guard/plugins/query-guard
A shopper browsing a rack in a slop shop
README

query-guard

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.

Claude Code plugin Claude Code 2.1.280+ Function hooks Dependencies: none License: MIT

<img src="docs/dialog.png" alt="query-guard asking whether to run a DELETE without WHERE, with Cancel as the first option" width="800">

Why

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.

Quick start

  1. Turn on function hooks (early access, Claude Code 2.1.280+) in ~/.claude/settings.json:
   { "env": { "CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1" } }
  1. Install, from your shell or inside a session:
   claude plugin marketplace add nu0ma/query-guard
   claude plugin install query-guard@nu0ma
   /plugin marketplace add nu0ma/query-guard
   /plugin install query-guard@nu0ma
  1. Start a new session. There is nothing to configure.

What it catches

[!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

Destructive

RuleExample
DELETE (flagged louder without WHERE; FROM optional, as in Spanner and BigQuery)DELETE FROM users
UPDATE without WHERE, multi-table forms includedUPDATE orders SET status = 'x'
DROP of any objectDROP TABLE events
ALTER TABLE ... DROPALTER TABLE t DROP c
TRUNCATETRUNCATE events

Possibly slow

RuleExample
SELECT ... FROM without WHERE or LIMITSELECT * FROM events
CROSS JOINSELECT * FROM a CROSS JOIN b
Comma join without WHERESELECT * FROM a, b
LIKE with a leading wildcardWHERE name LIKE '%foo'
ORDER BY without LIMITSELECT 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.

Fail-safe by design

When query-guard and the database could read a statement differently, it errs toward asking:

  • Risky keywords are searched in the raw SQL, comments and string literals included. MySQL runs /*! ... */ comments, and dialects disagree on where a string ends, so WHERE note = 'DROP TABLE x' asks too.
  • A 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.
  • Every reading of the command is checked, and the strictest wins. The rules run on the raw command, on the command with shell punctuation blanked (so ${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.
  • A CLI name is found the way the shell would run it, quotes and backslashes removed: \psql, ps''ql, X=psql; $X and psql<<EOF all count.

What it leaves alone

  • Commands that call no DB CLI: echo "DELETE FROM users" passes untouched.
  • A DB CLI that is only text: named in an 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.

How it works

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.

Limitations

  • Function hooks are early access, and their API may change between Claude Code releases.
  • SQL read from a file (psql -f file.sql, mysql < file.sql) is not inspected; only the command text is.
  • "Possibly slow" is a guess: query-guard does not know how big a table is, so SELECT * FROM small_lookup asks too.
  • SQL sent from a program (a Python or Node script, an ORM, a migration tool) is not inspected; only DB CLIs are.
  • A command can hide its SQL from any text check: a CLI name or query assembled at run time ($(printf ps)ql, base64), eval, aliases and shell functions defined elsewhere. See the note at the top of What it catches.
  • Detection leans toward over-reporting, so a keyword in a comment or a string, a CLI name in a commit message, or an escaped quote inside a shell-quoted query can ask when nothing is wrong.
  • In default mode without an allow rule for the command, approving Run it is followed by the usual permission prompt, so you answer twice.
  • In a headless -p run every risky command is denied, since nobody can answer.

Development

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.

License

MIT

Source 2 files
hooks/register.ts 25 lines
1import 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}
25
hooks/sql-rules.ts 291 lines
1export 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