The short answer
A regular expression can’t reliably block destructive SQL, because SQL isn’t a flat string of words. Comments can sit between keywords, comments nest, a DO block can build a statement from pieces at run time, and a plain SELECT can call a function that drops a table. We ran six statements past the pattern \bDROP\s+TABLE\b on PostgreSQL 18.6. Five of them dropped a table and the regex let all five through. It blocked one statement, which only read a row containing the words “DROP TABLE”.1
What works instead: parse the SQL the way Postgres does, allow one statement per call, refuse what the parser can’t classify, and connect as a role that can’t drop tables in the first place.
Deedglass
A tool call meets your rules before it runs
Asks to run
DELETE FROM visits;
Deletes every row in visits
Deedglass checks your rules
- Reading dataAllowed
- Deleting a whole table (DROP, TRUNCATE)Never
- Changing or deleting rows without saying which (WHERE)This one
- Other changesWait for a person
The test
We created five empty tables and a drop_it() function that drops one of them, then sent each statement below to Postgres and asked the regex about it first.1
| # | Statement | Regex | What Postgres did |
|---|---|---|---|
| 1 | DROP/**/TABLE t1 |
passed | DROP TABLE |
| 2 | DROP /* outer /* nested */ still a comment */ TABLE t2 |
passed | DROP TABLE |
| 3 | DO $$ BEGIN EXECUTE 'DR' || 'OP TABLE t3'; END $$ |
passed | dropped t3 |
| 4 | DO $x$ BEGIN EXECUTE format('%s TABLE %I', 'DROP', 't4'); END $x$ |
passed | dropped t4 |
| 5 | SELECT drop_it() |
passed | dropped t5 |
| 6 | SELECT count(*) FROM audit_log WHERE note = 'DROP TABLE t5' |
blocked | read one row |
After the run, audit_log was the only table left.1
Why each one got through
1. A comment between keywords. \s+ matches whitespace, and /**/ isn’t whitespace. To Postgres, a comment is.
2. Nested comments. In PostgreSQL, block comments nest, as the SQL standard specifies and unlike C.1 A regex that tries to strip /* … */ first will usually stop at the first */, leaving still a comment */ TABLE t2 behind. You’d need a counter, at which point you’re writing a lexer.
3. String concatenation inside DO. The statement only exists once the block runs. The text the regex sees contains 'DR' || 'OP TABLE t3', no DROP TABLE anywhere.
4. Dollar quoting and format(). A dollar-quoted string can use any tag between the dollar signs, here $x$.1 Inside, format() assembles the statement from parts. No pattern over the outer text can know what it builds.
5. A function. SELECT drop_it() is a read, as far as any text check can tell. What the function does is defined somewhere else, possibly by someone else, possibly changed since you last looked.
There is a sixth way not in the table: stacked statements. When a simple query holds more than one statement, Postgres runs them together in one implicit transaction unless the text includes transaction control.2 Datadog Security Labs showed what that means for a read-only guard: a query beginning COMMIT; ended the archived Postgres MCP server’s read-only transaction, so what followed ran outside it.3 A regex would see DROP TABLE there, but a guard built on read-only transactions didn’t help. Our read-only bypass post has the details.
The false positive is just as bad
Statement 6 dropped nothing. It searched a log for a string. Blocking it is wrong in a way that compounds: an agent refused for a harmless query learns to rephrase, and the people around it learn that the guard cries wolf. Both make the guard weaker over time.
Text-matching guards fail in both directions at once. They miss the obfuscated destructive statement and catch the honest mention. The reason is the same: they don’t know which characters are keywords, which are comments and which are string contents. Only a lexer and parser know that.
What works instead
1. Parse, then decide. Use a real SQL parser, ideally one built from Postgres’s own grammar, and decide on the parsed statement type. Comments and string contents disappear from the decision because the parser knows what they are.
2. One statement per call. Refuse any request with more than one statement. That closes stacked-statement tricks entirely.
3. Refuse what you can’t read. If the parser isn’t confident, or the statement is a DO block, refuse it by default. Agents rarely need anonymous code blocks.
4. Treat unknown functions as writes. A SELECT that calls a function you haven’t vetted might do anything. Allow reads only when they call no unknown function.
5. Limit the role. Only the table owner, the schema owner and a superuser can drop a table.6 If the agent connects as none of those, every statement in the table above fails at Postgres, whatever any guard says. This is the control that holds when the others have bugs.
Our own policy works along these lines. The postgres-check starter denies any request with more than one statement, denies SQL the classifier can’t read with confidence, and allows a read only when it calls no unknown function; it denied COMMIT; DROP SCHEMA public CASCADE and SELECT public.cleanup_orders() by default.5 In our attack suite, comment-obfuscated DDL with block, nested and inline comments is denied as destructive SQL, a DO block is denied, and stacked statements with a COMMIT; are denied as multi-statement; each matched its expected outcome on 0.11.0.4 A parser can still have gaps, which is why item 5 comes last but matters most.
Where a regex is still fine
Text matching has a place: as a cheap first pass that asks rather than blocks, in front of a person. A Claude Code hook that asks before any command containing DROP is useful precisely because a person reads the prompt; our hook post uses one. The mistake is treating a pattern as the decision, especially when no one is watching.
A checklist
- No regex as the final decision on SQL an agent sends.
- Parse with a real SQL grammar and decide on statement type.
- One statement per call; refuse the rest.
- Refuse
DOblocks and unclassifiable SQL by default. - Treat unknown function calls as writes.
- Connect as a role that can’t drop or truncate. See the read-only role comparison.
- Test your guard with the six statements above. If any of 1 to 5 gets through, it isn’t a guard.
A tool-call policy is where these decisions live, and an UPDATE with no WHERE is the next thing it should catch. To watch destructive SQL refused on a real Postgres in your browser, open the playground; to grade what an agent can reach on your machine, run npx deedglass scan (the grade, explained).
Written with AI assistance and checked against our claims register.