SLOPSHOPPER

query-guard

Stops risky SQL and dbt commands in Bash and asks you before they run.

newpanebandguardstatusprocess
★ 1v0.1.0MITupdated 2026-10-10geethasagarb/query-guard
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · query-guard
│ ┃ query-guard ✕ › fix the failing auth test and add an audit log call │ ┃ Nothing held. Query Guard is watching SQL │ ┃ and dbt commands. ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ query-guard: 🛡 Query Guard · 0 held this session

Draws

Pane · query-guard
Nothing held. Query Guard is watching SQL and dbt commands.
README

Query Guard

Tests

Query Guard is a Claude Code mod. It stops risky database commands and asks you before they run.

Query Guard holding a DELETE without a WHERE clause

<sub>Illustrated demo of Query Guard</sub>

I built this because one bad DELETE can wipe a table. I let Claude run database commands for me, and I wanted a pause before the ones I can't undo.

Why it exists

Coding agents run shell commands. Database clients are shell commands too. Claude can type DELETE FROM customers and forget the WHERE. It can also point psql at the wrong host. Either mistake can destroy data in a second. It has already happened: in this reported incident, Claude ran an UPDATE through a Supabase MCP tool that cleared fields on about 55,000 rows, with no backup to restore from. That route is exactly what v0.2 aims to cover.

Claude Code already shows you each command before it runs. But a long command is easy to skim. After many approvals, you stop reading closely.

Query Guard reads each database command first. If the command looks destructive, Query Guard holds it. If it points at production, Query Guard holds it too. Then it shows you the target and the current row count. The command waits until you choose.

What it catches

Query Guard checks Bash commands that run sqlite3, psql, mysql, snowsql, the databricks CLI, or dbt.

RuleExample that Query Guard holds
DROP TABLE, DROP DATABASE, DROP SCHEMApsql -c "DROP TABLE users"
TRUNCATEsnowsql -q "TRUNCATE TABLE orders"
DELETE without WHEREsqlite3 demo.db "DELETE FROM customers"
UPDATE without WHEREmysql -e "UPDATE users SET active = 0"
ALTER TABLE … DROP COLUMNpsql -c "ALTER TABLE users DROP COLUMN email"
dbt --full-refresh (run, build, seed)dbt run --full-refresh -s fct_sales
A prod or production host or connection, even for safe SQLpsql -h prod-db.internal -c "SELECT 1"

The production rule looks at where the command connects. It checks hosts, URLs, and connection strings. It also checks Snowflake accounts and connections, Databricks profiles, and dbt --target and --profile. It reads variables like PGHOST and DATABASE_URL as well.

The rule matches prod as a whole word. So it holds prod-db and db.production.example.com. It lets products-db through. It never checks table names, so SELECT * FROM products runs as normal.

Some WHERE clauses don't count. Query Guard ignores a WHERE inside a SQL comment. It ignores a WHERE in a later statement. It also ignores one that comes after the shell string ends.

Everything else runs without a pause. That includes SELECT, INSERT, DELETE … WHERE, and a plain dbt run.

How it works

Query Guard is a Claude Code mod. A mod is a TypeScript hooks module that Claude Code loads as a plugin.

  1. It hooks the Bash tool. A tool.call hook sees every Bash command before it runs. Commands without a database client pass straight through.
  2. It checks the rules. The rules live in hooks/rules.ts. Each rule is a plain function over the command text. The tests check each one on its own.
  3. It holds the command and asks. On a match, the hook does not pass the command on, so it doesn't run. The hook opens a pane. The pane shows the command, the reason, and the target. Proceed (hotkey 1) runs the command unchanged. Cancel (hotkey 2) refuses it. Claude then reads what the command would have destroyed. Query Guard also tells Claude not to retry or work around it. Closing the pane counts as Cancel.
  4. It falls back to a band. Claude Code opens a pane on its own only when the terminal is wide enough. On a narrow terminal, Query Guard shows the same report and buttons above the prompt.
  5. It counts rows without writing. For a SQLite file on your machine, Query Guard runs sqlite3 -readonly … "SELECT COUNT(*) FROM <table>". You see how many rows are at stake. Query Guard never writes to the database. It never connects to a remote database to count.

A status line keeps a tally: 🛡 Query Guard · 2 held this session.

Install

Run these at a Claude Code prompt:

/plugin marketplace add geethasagarb/query-guard
/plugin install query-guard@query-guard
/reload-plugins

Try it with the demo database

git clone https://github.com/geethasagarb/query-guard.git
cd query-guard
python3 demo/seed_demo_db.py        # creates demo.db: 1,000 fake customers
claude

Then ask Claude to do these steps:

  1. Run: sqlite3 demo.db "SELECT COUNT(*) FROM customers". It runs as normal and prints 1000.
  2. Run: sqlite3 demo.db "DELETE FROM customers". Query Guard holds it and shows customers · 1,000 rows now. Press 2 to cancel.
  3. Ask for the count again. It still prints 1000.

Limitations

Query Guard is a safety net. It is not a permission system.

It reads only the text of the command that Claude passes to Bash. If that text hides the SQL or the connection, Query Guard misses it. These get past it:

  • Scripts and programs, such as python reset_db.py, ./cleanup.sh, or make reset.
  • SQL in files, such as psql -f drop.sql or sqlite3 demo.db < reset.sql.
  • Shell aliases, functions, eval, and commands that $(…) or variables build at runtime.
  • Connection settings it can't see, such as ~/.pgpass, ~/.my.cnf, a .env file, or a dbt profiles.yml whose default target is production.
  • Database access through MCP tools or any tool other than Bash.

The rules match patterns, so they will sometimes be wrong. Query Guard can flag a command that only mentions SQL. This happened to me while I wrote this README. Claude ran a Python script through Bash to update a docs file. The script's text included sqlite3 demo.db "DELETE FROM customers" as plain text. Query Guard held the script, even though it never touched a database. I pressed Proceed, and it ran.

It can also miss things. Some risky commands will slip through. Row counts work only for local SQLite files. Query Guard resolves their paths from the session's working directory.

For hard blocks, use Claude Code's permission rules. For example, add a deny rule on Bash(psql:*). On the database side, use read-only roles, separate production credentials, and backups. Treat Query Guard as one extra pause before a step you can't undo.

Roadmap

  • v0.2: database MCP tools. Cover tools like the Supabase and Postgres MCP servers, not just Bash.
  • Skip SQL inside heredoc text. Don't hold a command when the SQL is only text in a heredoc, like the false positive described above.
  • Row counts for Postgres. Use a read-only EXPLAIN or COUNT to show rows at stake.
  • Per-project rules file. Let each project turn rules on or off and add its own.
  • Run rule tests in CI. Add a small Bun stand-in for claude-code/testing so GitHub Actions can run the rule tests.

Want to help? See CONTRIBUTING.md.

Running the tests

claude plugin validate .
claude plugin test .

validate reads the manifest, the marketplace file, and the hooks module. It reads them the same way Claude Code does when it loads the plugin.

test runs two files:

  • tests/rules.test.ts checks every rule. It also checks what each rule should let through.
  • tests/guard.test.tsx runs the full flow against the engine. Query Guard holds a command and draws the pane with the row count. Then the tests check Proceed, Cancel, and the narrow-terminal band.

Project layout

.claude-plugin/
  plugin.json         the plugin manifest
  marketplace.json    makes this repo installable as a marketplace
hooks/
  hooks.json          points Claude Code at the hooks module
  register.tsx        the tool.call guard, pane, band, and status line
  rules.ts            the detection rules, as plain functions
types/index.d.ts      the session state the pane draws from
tests/                rule tests and end-to-end tests
demo/seed_demo_db.py  creates the demo database
docs/                 the demo GIF

Credits

I built Query Guard with Claude Code mods.

Author: Geetha Sagar Bonthu. MIT licensed.

Source 3 files
hooks/register.tsx 197 lines
1// The hooks Claude Code loads for Query Guard.
2// The Bash hook holds a flagged command until you press Proceed or Cancel.
3// The pane, the band above the prompt, and the status line show what it holds.
4
5import { atom, read, update } from 'claude-code'
6import type { EngineInterface, Register } from 'claude-code'
7
8import type { HeldCall, RowCount } from '../types'
9import { analyze, refusal } from './rules'
10import type { Finding } from './rules'
11
12const PANE = 'query-guard'
13const POLL = ['sleep', '0.25']
14
15const pending = atom({ plugin: 'query-guard', key: 'pending' } as const, [])
16const held = atom({ plugin: 'query-guard', key: 'held' } as const, 0)
17const where = atom({ plugin: 'query-guard', key: 'where' } as const, 'pane')
18
19type Decision = 'proceed' | 'cancel'
20
21// The person's answers, by tool_use_id. A module variable on purpose: the
22// waiting hook reads it between `$` calls, and every `$.state.get` of one
23// dispatch reads the moment the dispatch began.
24const decisions = new Map<string, Decision>()
25
26const statusText = (count: number) => `🛡 Query Guard · ${count} held this session`
27
28// A table name SQLite can be asked about: plain identifiers and one dot.
29const COUNTABLE = /^[A-Za-z_][\w$]*(?:\.[A-Za-z_][\w$]*)?$/
30
31async function rowCounts($: EngineInterface, finding: Finding): Promise<RowCount[]> {
32  const file = finding.sqliteFile
33  if (file === undefined) return []
34  const tables = [...new Set(finding.hits.filter(h => h.isTable).map(h => h.target))]
35  if (tables.length === 0) return []
36
37  const exists = await $.process.run(['test', '-f', file]).catch(() => undefined)
38  if (exists?.exitCode !== 0) return []
39
40  const counts: RowCount[] = []
41  for (const table of tables) {
42    if (!COUNTABLE.test(table)) continue
43    const quoted = table
44      .split('.')
45      .map(part => `"${part}"`)
46      .join('.')
47    const ran = await $.process
48      .run(['sqlite3', '-readonly', '-batch', '-noheader', file, `SELECT COUNT(*) FROM ${quoted};`], {
49        timeoutMs: 5_000,
50      })
51      .catch(() => undefined)
52    const rows = Number(ran?.stdout.trim())
53    if (ran?.exitCode === 0 && Number.isInteger(rows)) counts.push({ table, rows })
54  }
55  return counts
56}
57
58async function decide($: EngineInterface, id: string, decision: Decision) {
59  decisions.set(id, decision)
60  await update($, pending, list => list.filter(call => call.id !== id))
61}
62
63export const register: Register = on => {
64  on('session.start', async ($, e, next) => {
65    $.ui.status(statusText(await read($, held)))
66    return next(e)
67  })
68
69  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
70    const finding = analyze(e.command)
71    if (finding === null) return next(e)
72
73    const call: HeldCall = {
74      id: e.tool_use_id,
75      command: e.command,
76      client: finding.client,
77      reasons: finding.hits.map(h => h.reason),
78      effects: finding.hits.map(h => h.effect),
79      targets: [...new Set(finding.hits.map(h => h.target))],
80      database: finding.sqliteFile,
81      rowCounts: await rowCounts($, finding),
82    }
83
84    decisions.delete(call.id)
85    await update($, pending, list => [...list, call])
86    const count = await update($, held, n => n + 1)
87    $.ui.status(statusText(count))
88
89    const opened = await $.ui
90      .open({ id: PANE, title: 'Query Guard', focus: true })
91      .catch(() => ({ isPlaced: false }))
92    await update($, where, () => (opened.isPlaced ? 'pane' : 'band'))
93    if (!opened.isPlaced) {
94      // Too narrow for a pane: the report is drawn above the prompt instead;
95      // close the waiting pane so it does not also appear on a resize.
96      await $.ui.close({ id: PANE }).catch(() => undefined)
97    }
98
99    let decision = decisions.get(call.id)
100    while (decision === undefined) {
101      if (next.signal.aborted) {
102        await decide($, call.id, 'cancel')
103        break
104      }
105      await $.process.run(POLL).catch(() => undefined)
106      decision = decisions.get(call.id)
107    }
108    decisions.delete(call.id)
109
110    if ((await read($, pending)).length === 0) {
111      await $.ui.close({ id: PANE }).catch(() => undefined)
112    }
113
114    if (decision === 'proceed') return next(e)
115    return { deny: refusal(call.command, call.effects, call.rowCounts) }
116  }).catch(($, e, next) =>
117    next.called
118      ? next(e)
119      : { deny: 'Query Guard could not finish checking this database command, so it was not run. Ask the user to run it themselves or retry.' },
120  )
121
122  on('ui.close', { id: PANE }, async ($, e, next) => {
123    // Closing the pane by hand while a call is held cancels that call.
124    if (e.origin.kind === 'person') {
125      for (const call of await read($, pending)) await decide($, call.id, 'cancel')
126    }
127    return next(e)
128  })
129
130  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
131    const list = await read($, pending)
132    return report($, e, list, e.props.bodyColumns)
133  })
134
135  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
136    const list = await read($, pending)
137    if (list.length === 0 || (await read($, where)) !== 'band') return next(e)
138    return report($, e, list, e.props.bodyColumns)
139  })
140}
141
142function report(
143  $: EngineInterface,
144  e: Parameters<EngineInterface['ui']['resolve']>[0],
145  list: readonly HeldCall[],
146  columns: number,
147) {
148  const { Box, Text, Button } = $.ui.resolve(e)
149  const call = list[0]
150  if (call === undefined) {
151    return (
152      <Box>
153        <Text dimColor>Nothing held. Query Guard is watching SQL and dbt commands.</Text>
154      </Box>
155    )
156  }
157  const counts = new Map(call.rowCounts.map(c => [c.table, c.rows]))
158  const width = Math.max(20, columns)
159
160  return (
161    <Box flexDirection="column" width={width}>
162      <Text bold color="red">
163        🛡 Query Guard held a {call.client} command
164      </Text>
165      <Box flexDirection="column" marginTop={1}>
166        <Text dimColor>Command</Text>
167        <Text wrap="wrap">{call.command}</Text>
168      </Box>
169      <Box flexDirection="column" marginTop={1}>
170        <Text dimColor>Why it was flagged</Text>
171        {call.reasons.map(reason => (
172          <Text wrap="wrap">• {reason}</Text>
173        ))}
174      </Box>
175      <Box flexDirection="column" marginTop={1}>
176        <Text dimColor>Target{call.targets.length > 1 ? 's' : ''}</Text>
177        {call.targets.map(target => (
178          <Text wrap="wrap">
179            {target}
180            {counts.has(target) ? (
181              <Text color="yellow"> · {counts.get(target)?.toLocaleString('en-US')} rows now</Text>
182            ) : (
183              ''
184            )}
185          </Text>
186        ))}
187        {call.database !== undefined && <Text dimColor>in {call.database}</Text>}
188      </Box>
189      <Box marginTop={1} gap={2}>
190        <Button key="proceed" hotkey="1" label="Proceed" onPress={() => decide($, call.id, 'proceed')} />
191        <Button key="cancel" hotkey="2" label="Cancel" variant="primary" onPress={() => decide($, call.id, 'cancel')} />
192      </Box>
193      {list.length > 1 && <Text dimColor>{list.length - 1} more held after this one</Text>}
194    </Box>
195  )
196}
197
hooks/rules.ts 289 lines
1// The detection rules. Each rule reads the text of one Bash command.
2// Nothing here calls Claude Code, so the tests can check each rule directly.
3
4export type RuleId =
5  | 'drop-table'
6  | 'drop-database'
7  | 'drop-schema'
8  | 'truncate'
9  | 'delete-without-where'
10  | 'update-without-where'
11  | 'drop-column'
12  | 'dbt-full-refresh'
13  | 'production-target'
14
15export type Hit = {
16  rule: RuleId
17  /** Why it was held, for the person. */
18  reason: string
19  /** What it would destroy, for the refusal Claude reads. */
20  effect: string
21  /** The table, schema, database or dbt selection. */
22  target: string
23  /** True when the target is a table whose rows can be counted. */
24  isTable: boolean
25}
26
27export type Finding = {
28  client: string
29  hits: Hit[]
30  /** The SQLite database file a sqlite3 command names, when it names one. */
31  sqliteFile?: string
32}
33
34const CLIENTS = ['sqlite3', 'psql', 'mysql', 'snowsql', 'databricks', 'dbt'] as const
35type Client = (typeof CLIENTS)[number]
36
37// A client invoked as a command word: at the start, after a shell operator,
38// after `env VAR=x`, or as a path ending in the name (/usr/bin/psql).
39const CLIENT_RE = new RegExp(
40  String.raw`(?:^|[\s;&|(\x60$]|/)(${CLIENTS.join('|')})(?=$|[\s;&|)])`,
41  'g',
42)
43
44// An identifier, optionally schema-qualified and quoted in any dialect.
45const NAME = String.raw`(?:[\w$]+|"[^"]+"|\x60[^\x60]+\x60|\[[^\]]+\])(?:\.(?:[\w$]+|"[^"]+"|\x60[^\x60]+\x60|\[[^\]]+\]))*`
46
47/** Which database clients the command runs, in order of appearance. */
48export function findClients(command: string): Client[] {
49  const found: Client[] = []
50  for (const match of command.matchAll(CLIENT_RE)) {
51    const client = (match[1] ?? '') as Client
52    if (!found.includes(client)) found.push(client)
53  }
54  return found
55}
56
57/** Strips one layer of quoting from an identifier: "a"."b" -> a.b */
58export function cleanName(name: string): string {
59  return name.replace(/["\x60[\]]/g, '')
60}
61
62// Where one SQL statement ends inside a shell command: a semicolon, or a
63// closing quote followed by a shell operator or the end of the command.
64function statementEnd(text: string, from: number): number {
65  const rest = text.slice(from)
66  const match = rest.match(/;|['"]\s*(?:$|\|\||&&|[|;&)>])/)
67  return match?.index === undefined ? text.length : from + match.index
68}
69
70function sqlHits(command: string): Hit[] {
71  const hits: Hit[] = []
72  // `--` and `/* */` comments could hide a WHERE or fake one; drop them.
73  const sql = command.replace(/\/\*[\s\S]*?\*\//g, ' ').replace(/--[^\n]*/g, ' ')
74
75  for (const m of sql.matchAll(
76    new RegExp(String.raw`\bDROP\s+(TABLE|DATABASE|SCHEMA)\s+(?:IF\s+EXISTS\s+)?(${NAME})`, 'gi'),
77  )) {
78    const kind = (m[1] ?? '').toUpperCase()
79    const target = cleanName(m[2] ?? '')
80    if (kind === 'TABLE') {
81      hits.push({
82        rule: 'drop-table',
83        reason: `DROP TABLE ${target}`,
84        effect: `dropped table ${target} with all of its rows`,
85        target,
86        isTable: true,
87      })
88    } else if (kind === 'DATABASE') {
89      hits.push({
90        rule: 'drop-database',
91        reason: `DROP DATABASE ${target}`,
92        effect: `dropped database ${target} and every table in it`,
93        target,
94        isTable: false,
95      })
96    } else {
97      hits.push({
98        rule: 'drop-schema',
99        reason: `DROP SCHEMA ${target}`,
100        effect: `dropped schema ${target} and every object in it`,
101        target,
102        isTable: false,
103      })
104    }
105  }
106
107  for (const m of sql.matchAll(
108    new RegExp(String.raw`\bTRUNCATE\s+(?:TABLE\s+)?(?:ONLY\s+)?(${NAME})`, 'gi'),
109  )) {
110    const target = cleanName(m[1] ?? '')
111    hits.push({
112      rule: 'truncate',
113      reason: `TRUNCATE ${target}`,
114      effect: `removed every row from ${target}`,
115      target,
116      isTable: true,
117    })
118  }
119
120  for (const m of sql.matchAll(new RegExp(String.raw`\bDELETE\s+FROM\s+(?:ONLY\s+)?(${NAME})`, 'gi'))) {
121    const after = m.index + m[0].length
122    const statement = sql.slice(after, statementEnd(sql, after))
123    if (/\bWHERE\b/i.test(statement)) continue
124    const target = cleanName(m[1] ?? '')
125    hits.push({
126      rule: 'delete-without-where',
127      reason: `DELETE FROM ${target} has no WHERE clause`,
128      effect: `deleted every row in ${target}`,
129      target,
130      isTable: true,
131    })
132  }
133
134  for (const m of sql.matchAll(new RegExp(String.raw`\bUPDATE\s+(?:ONLY\s+)?(${NAME})\s+SET\b`, 'gi'))) {
135    const after = m.index + m[0].length
136    const statement = sql.slice(after, statementEnd(sql, after))
137    if (/\bWHERE\b/i.test(statement)) continue
138    const target = cleanName(m[1] ?? '')
139    hits.push({
140      rule: 'update-without-where',
141      reason: `UPDATE ${target} has no WHERE clause`,
142      effect: `overwritten the updated columns in every row of ${target}`,
143      target,
144      isTable: true,
145    })
146  }
147
148  for (const m of sql.matchAll(
149    new RegExp(
150      String.raw`\bALTER\s+TABLE\s+(?:IF\s+EXISTS\s+)?(?:ONLY\s+)?(${NAME})\s+[^;]*?\bDROP\s+COLUMN\s+(?:IF\s+EXISTS\s+)?(${NAME})`,
151      'gi',
152    ),
153  )) {
154    const table = cleanName(m[1] ?? '')
155    const column = cleanName(m[2] ?? '')
156    hits.push({
157      rule: 'drop-column',
158      reason: `ALTER TABLE ${table} DROP COLUMN ${column}`,
159      effect: `dropped column ${column} from ${table}, with its value in every row`,
160      target: table,
161      isTable: true,
162    })
163  }
164
165  return hits
166}
167
168function dbtHits(command: string): Hit[] {
169  const isRebuild = /\bdbt\s+(?:[^\n;&|]*?\s)?(run|build|seed)\b[^\n;&|]*?--full-refresh\b/.test(command)
170  if (!isRebuild) return []
171  const selection = command.match(/(?:--select|--models|-s|-m)[\s=]+((?:[^\s-][^\s]*\s*)+)/)
172  const target = selection ? (selection[1] ?? '').trim() : 'every model in the project'
173  return [
174    {
175      rule: 'dbt-full-refresh',
176      reason: 'dbt --full-refresh drops and rebuilds incremental models',
177      effect: `dropped and rebuilt ${target} from scratch, discarding incremental history`,
178      target,
179      isTable: false,
180    },
181  ]
182}
183
184/** True when a host, URL or connection name says prod or production as a word. */
185export function isProdName(value: string): boolean {
186  return /(?:^|[^a-z])prod(?:uction)?(?:[^a-z]|$)/i.test(value)
187}
188
189/**
190 * The parts of a command that name where it connects: URLs, host and
191 * account flags, `host=` style connection strings, dbt targets and profiles,
192 * and environment variables such as PGHOST or DATABASE_URL.
193 */
194export function connectionValues(command: string, clients: readonly string[]): string[] {
195  const values: string[] = []
196  const add = (value: string | undefined) => {
197    if (value) values.push(value.replace(/^['"]|['"]$/g, ''))
198  }
199
200  for (const m of command.matchAll(/\b[a-z][a-z0-9+.-]*:\/\/[^\s'"]+/gi)) add(m[0])
201  for (const m of command.matchAll(/\bjdbc:[^\s'"]+/gi)) add(m[0])
202
203  const flags = ['--host', '--hostname', '--server-hostname', '--account', '--accountname', '--target', '--profile', '--connection', '--dbname', '--database', '--url']
204  const shortFlags = ['-h', '-a', '-t', '-d', '-D']
205  if (clients.includes('snowsql')) shortFlags.push('-c')
206  if (clients.includes('databricks')) shortFlags.push('-p')
207  for (const m of command.matchAll(/(?:^|\s)(--?[A-Za-z-]+)(?:=|\s+)("[^"]*"|'[^']*'|[^\s]+)/g)) {
208    const flag = m[1] ?? ''
209    if (flags.includes(flag) || shortFlags.includes(flag)) add(m[2])
210  }
211  // -hprod-db, as mysql takes it.
212  for (const m of command.matchAll(/(?:^|\s)-h([^\s=-][^\s]*)/g)) add(m[1])
213
214  for (const m of command.matchAll(
215    /\b(host|hostaddr|dbname|service|server|account|[A-Z_]*(?:HOST|URL|URI|ACCOUNT|SERVER|DSN|TARGET|PROFILE|CONNECTION)[A-Z_]*)=("[^"]*"|'[^']*'|[^\s'";]+)/g,
216  )) {
217    add(m[2])
218  }
219
220  return values
221}
222
223/** The database file a sqlite3 command opens, when it names one on disk. */
224export function sqliteFile(command: string): string | undefined {
225  const at = command.search(/(?:^|[\s;&|(/])sqlite3(?=\s)/)
226  if (at < 0) return undefined
227  const rest = command.slice(at).replace(/^.*?sqlite3/, '')
228  const tokens = [...rest.matchAll(/"([^"]*)"|'([^']*)'|(\S+)/g)].map(m => ({
229    text: m[1] ?? m[2] ?? m[3] ?? '',
230    isQuoted: m[3] === undefined,
231  }))
232  const takesValue = ['-cmd', '-init', '-separator', '-newline', '-nullvalue', '-vfs', '-maxsize', '-mmap', '-pagecache', '-lookaside', '-heap', '-zip']
233  for (let i = 0; i < tokens.length; i += 1) {
234    const { text, isQuoted } = tokens[i] ?? { text: '', isQuoted: false }
235    if (!isQuoted && /^[;&|<>]/.test(text)) return undefined
236    if (!isQuoted && text.startsWith('-')) {
237      if (takesValue.includes(text.replace(/^--/, '-'))) i += 1
238      continue
239    }
240    if (text === ':memory:' || text === '') return undefined
241    return text
242  }
243  return undefined
244}
245
246/** Reads a Bash command and answers what Query Guard holds it for, or null. */
247export function analyze(command: string): Finding | null {
248  const clients = findClients(command)
249  if (clients.length === 0) return null
250
251  const hits: Hit[] = [...sqlHits(command)]
252  if (clients.includes('dbt')) hits.push(...dbtHits(command))
253
254  const prod = connectionValues(command, clients).filter(isProdName)
255  if (prod.length > 0) {
256    const where = [...new Set(prod)].join(', ')
257    hits.push({
258      rule: 'production-target',
259      reason: `connects to a production target: ${where}`,
260      effect: `run against production (${where})`,
261      target: where,
262      isTable: false,
263    })
264  }
265
266  if (hits.length === 0) return null
267  const client = clients.find(c => c !== 'dbt' || hits.some(h => h.rule === 'dbt-full-refresh')) ?? clients[0] ?? 'sql'
268  return {
269    client,
270    hits,
271    sqliteFile: clients.includes('sqlite3') ? sqliteFile(command) : undefined,
272  }
273}
274
275/** The text Claude reads when the person cancels a held call. */
276export function refusal(command: string, effects: readonly string[], counts: readonly { table: string; rows: number }[]): string {
277  const lines = effects.map(effect => {
278    const count = counts.find(c => effect.includes(` ${c.table}`))
279    return count ? `- ${effect} (${count.rows.toLocaleString('en-US')} rows right now)` : `- ${effect}`
280  })
281  return [
282    'Query Guard: the user cancelled this command. Nothing was run.',
283    `Command: ${command}`,
284    'Had it run, it would have:',
285    ...lines,
286    'Do not retry it or work around the guard with a script or alias; ask the user how they want to proceed.',
287  ].join('\n')
288}
289
types/index.d.ts 38 lines
1// The state Query Guard keeps for one session.
2// The pane and the band read it to draw the report.
3
4/** One table a held command touches, with its row count when it could be read. */
5export type RowCount = { table: string; rows: number }
6
7/** A Bash call Query Guard is holding until the person answers it. */
8export type HeldCall = {
9  /** The tool_use_id of the held Bash call. */
10  id: string
11  command: string
12  /** The database client or tool the command runs: sqlite3, psql, dbt, ... */
13  client: string
14  /** Why it was held, one line per rule that matched. */
15  reasons: string[]
16  /** What it would destroy or touch, one line per effect. */
17  effects: string[]
18  /** The tables, schemas, databases or dbt selections it targets. */
19  targets: string[]
20  /** The SQLite database file, for sqlite3 commands that name one. */
21  database?: string
22  /** Current row counts, read with SELECT COUNT(*) from local SQLite files only. */
23  rowCounts: RowCount[]
24}
25
26declare module 'claude-code' {
27  interface PluginState {
28    'query-guard': {
29      /** Held calls, oldest first; the first one is the one on screen. */
30      pending: HeldCall[]
31      /** How many calls were held this session. */
32      held: number
33      /** Where the report is drawn: the pane, or above the prompt when no pane fits. */
34      where: 'pane' | 'band'
35    }
36  }
37}
38