Read only sql via regex validator
Skill Ed3Design/ed3design-skill-bundles/schema-discipline/skills/read-only-sql-via-regex-validator
Claude Code skill bundles for software engineering: 56 skills + 5 Python tools + 6 hooks + 4 sub-agents across 6 thematic plugins (token-savers, code-quality, planning-disciplines, async-forensik, schema-discipline, skill-system-meta). Empirically TDD-validated patterns, MIT licensed.
npx -y skills add Ed3Design/ed3design-skill-bundles --skill read-only-sql-via-regex-validatorAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 0 stars0 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
What its author says it does
Copied from the file, not written here
Use when exposing a read-only SQL endpoint to a less-trusted caller (browser via HTTP, MCP-Tool to LLM, internal dashboard with copy-paste query box, future API consumer). The endpoint accepts a SQL string and must reject all mutating statements before executing. Encodes the regex-based denylist+allowlist pattern: (1) strip SQL comments (`--` and `/* */`) BEFORE matching to prevent comment-smuggle bypass, (2) normalize whitespace + uppercase, (3) denylist regex for INSERT/UPDATE/DELETE/DROP/CREATE/ALTER/TRUNCATE/CALL, (4) allowlist regex for SELECT/WITH/EXPLAIN/ANALYZE start-tokens. Trigger phrases like "read-only SQL endpoint", "SELECT-only API", "SQL escape hatch", "DB query as HTTP endpoint", "MCP tool exposes raw SQL", "Postgres read API". Do NOT load when a real SQL parser is available (sqlparse, pglast), for write-allowed endpoints, stored-procedure invocation, or DBs where SELECT itself has side-effects.
SKILL.md
11.3 KB, ~2.7k tokens by cl100k_base, as published. Nobody here has run it
Read-Only SQL Endpoint via Regex Validator
For low-stakes internal SQL escape hatches (Dashboard, MCP-tool, Companion-Service), a regex-based pre-check is a 95% solution that takes 5 minutes to implement. For high-stakes write-control, use a proper SQL parser.
When to use
- Building a
/sql?q=<base64-SELECT>endpoint for internal tools - MCP-server tool that takes user-supplied SQL
- Dashboard query box that lets analysts run ad-hoc SELECTs
- Companion service that wants to expose raw query capability without full DB-credentials to the caller
When NOT to use
- Write-allowed endpoints — never expose mutation via raw SQL; parameterize specific updates instead
- Public/Internet-facing endpoints — use a real SQL parser (sqlparse, pglast) and probably an SQL-permissions-restricted DB role
- Production systems where a malicious SELECT could affect performance (long table scan,
pg_sleep, ...) — add timeout + query-cost budget on top - Stored-procedure invocation that has side-effects despite being syntactically read-only
The validator (Python, 30 lines)
import re
def _is_readonly_sql(sql: str) -> bool:
"""Validate that SQL is read-only (SELECT/WITH/EXPLAIN only).
Denies: INSERT, UPDATE, DELETE, DROP, CREATE, ALTER, TRUNCATE, CALL.
Allows: SELECT, WITH (CTE), EXPLAIN, ANALYZE.
Naive — catches obvious violations but not all corner cases.
For high-stakes use, replace with sqlparse-based AST walk.
"""
# Strip comments BEFORE matching — prevents comment-smuggle attacks
sql_clean = re.sub(r"--.*?$", "", sql, flags=re.MULTILINE)
sql_clean = re.sub(r"/\*.*?\*/", "", sql_clean, flags=re.DOTALL)
# Normalize whitespace + uppercase
sql_clean = re.sub(r"\s+", " ", sql_clean).strip().upper()
denied = [
r"^\s*INSERT\s", r"^\s*UPDATE\s", r"^\s*DELETE\s",
r"^\s*DROP\s", r"^\s*CREATE\s", r"^\s*ALTER\s",
r"^\s*TRUNCATE\s", r"^\s*CALL\s",
]
if any(re.search(p, sql_clean) for p in denied):
return False
allowed = [r"^SELECT\s", r"^WITH\s", r"^EXPLAIN\s", r"^ANALYZE\s"]
return any(re.search(p, sql_clean) for p in allowed)
Endpoint wrapper (FastAPI)
@app.get("/sql")
async def sql_endpoint(q: str = Query(..., description="Base64-encoded read-only SQL")):
try:
sql = base64.b64decode(q).decode("utf-8")
except Exception as e:
return JSONResponse({"_error": "invalid_query_encoding", "detail": str(e)}, status_code=400)
if not _is_readonly_sql(sql):
return JSONResponse(
{"_error": "forbidden_query", "detail": "Only SELECT/WITH/EXPLAIN allowed"},
status_code=403,
)
pool = get_pool()
if pool is None:
return JSONResponse({"_error": "db_not_ready"}, status_code=503)
try:
rows = await pool.fetch(sql)
return JSONResponse({
"query": sql,
"rows": [{k: _serialize_value(v) for k, v in dict(r).items()} for r in rows],
"count": len(rows),
})
except asyncpg.PostgresError as e:
return JSONResponse({"_error": "query_failed", "detail": str(e)}, status_code=400)
Why base64-encode the SQL?
URL-safe transport for query-string params with special chars (quotes, semicolons, newlines). Caller does echo "SELECT ..." | base64, server decodes. Alternative: POST body — but GET is more cache-friendly and easier from curl.
Defense layers (beyond the validator)
- DB-side: dedicated read-only user/role with
GRANT SELECT ON ALL TABLES IN SCHEMA public TO readonly_user. Even if the regex misses, the DB rejects. - Network: endpoint reachable only via private TLS cert → already gates external attackers
- Audit-log: every
/sqlcall logged JSONL (timestamp + query-hash + caller-IP + outcome). Forensic trail for misuse. - Timeout:
pool.fetch(sql, timeout=10.0)so a runaway query can't DoS the service.
Test cases (5 cases all passed)
# 1. Valid SELECT → 200
SQL=$(echo -n "SELECT count(*) FROM v3_trades" | base64)
curl /sql?q=$SQL # → {"rows":[{"count":22}], ...}
# 2. Mutating UPDATE → 403
SQL=$(echo -n "UPDATE v3_trades SET closed_at=now()" | base64)
curl /sql?q=$SQL # → 403 {"_error":"forbidden_query"}
# 3. Comment-smuggle attempt → 403
SQL=$(echo -n "--SELECT 1\nDELETE FROM v3_trades" | base64)
curl /sql?q=$SQL # → 403 (comment stripped before matching)
# 4. WITH/CTE (allowed) → 200
SQL=$(echo -n "WITH x AS (SELECT 1 a) SELECT * FROM x" | base64)
curl /sql?q=$SQL # → 200
# 5. Bad base64 → 400
curl /sql?q=INVALID # → 400 {"_error":"invalid_query_encoding"}
Anti-patterns
- ❌ Skip comment-strip pre-pass: caller smuggles
-- SELECT 1\nDELETEand regex seesSELECTat line-start — passes naively. ALWAYS strip comments first. - ❌ Allow
EXECUTEin allowlist —EXECUTEruns prepared statements that can mutate. Deny it explicitly. - ❌ Trust the regex alone: pair with a read-only DB-role. Defense-in-depth.
- ❌ Forget
pg_sleep(...)andpg_terminate_backend(...): technically SELECT but DoS-vector. For high-stakes endpoints, add SELECT-function-name denylist:_DENY_FUNCTIONS = (r"pg_sleep", r"pg_terminate_backend", r"pg_cancel_backend", r"pg_read_file", r"pg_ls_dir", r"dblink") for fn in _DENY_FUNCTIONS: if re.search(rf"\b{fn}\s*\(", sql_clean, re.IGNORECASE): return False - ❌
SELECT … INTO new_tablebypassed allowlist:SELECT * INTO new_table FROM v3_tradesstarts with SELECT but creates a table. Skill-Step 4 must explicitly check for\bINTO\bkeyword after the prefix-match. Distinction:INSERT INTOis already denied;SELECT INTOneeds separate handling. - ❌
SELECT … FOR UPDATErow-lock side-effect: syntactically SELECT, semantically locks rows + can block other sessions. For high-stakes production DB: deny\bFOR\s+(UPDATE|SHARE|NO\s+KEY\s+UPDATE|KEY\s+SHARE)\b. - ❌ CTE-mutation via
RETURNING:WITH x AS (INSERT INTO t VALUES (1) RETURNING id) SELECT * FROM x— starts with WITH (allowed prefix), contains INSERT (denylist catches it). But also add global\bRETURNING\bdenylist for safety because RETURNING outside CTE shouldn't appear in pure read queries anyway. - ❌ Multi-statement reject missing:
SELECT 1; DELETE FROM v3_tradescould bypass if validator only checks first statement. Strip trailing;then reject if any;remains in body:sql_trimmed = sql_clean.rstrip(";").strip() if ";" in sql_trimmed: return False # multiple statements - ❌ Naive
--comment-strip breaks string literals:SELECT 'a--b' AS xbecomes broken SQL after stripping. False-positive: legitimate query rejected. Edge-case acceptable for v1; production-grade needssqlparseAST-walk. - ❌ Dollar-quoting
$$...$$and$tag$...$tag$not handled: PostgreSQL string literals using dollar-quoting can hide mutating keywords inside what looks like a "string". Regex won't see through. Production-grade needs AST. - ❌ Pass SQL via shell-interpolation in a subprocess (e.g.
subprocess.run(["psql", "-c", sql])): the shell can mis-quote and turn SQL into injection. Always use parameterized libraries (asyncpg, psycopg). - ❌ Audit-log the BASE64-encoded form only: when forensics-time comes, you'll have to decode 500 lines manually. Log the decoded SQL (or both — encoded for round-trip, decoded for grep).
Production-grade upgrade path
When the naive regex isn't enough (e.g. moving from internal dashboard to external API):
- Replace
_is_readonly_sqlwithsqlparse.parse(sql)AST walk that recursively confirms every statement-token is in {SELECT, WITH, EXPLAIN, ANALYZE} - Add
pglastfor full Postgres-AST awareness (handles every Postgres-specific edge case) - Move to a query-budget approach: estimate-via-EXPLAIN, reject if cost > threshold
- Per-caller rate-limit + concurrent-query-quota
Background: TDD progress (Bulletproofing Log)
Cycle 1 — PASS with Polish
-
RED subagent (without skill, Flask + psycopg2 for
POST /sql): Wrote extensive validator with multi-layer defense (regex +set_session(readonly=True)+ statement_timeout + read-only PG role). Found 7 gaps in its own code: SELECT INTO bypass, dollar-quoting not handled, string-literal-with---false-positive, SELECT FOR UPDATE row-lock side-effect, CTE-RETURNING, identifier collisions ("drop"column), unicode whitespace. Very honest self-assessment. -
GREEN subagent (with skill via Read tool, same task): Applied 4-step pattern + additionally multi-statement defense (semicolon detection) + RETURNING denylist + audit log with decoded SQL. Identified skill adaptation from FastAPI/asyncpg to Flask/psycopg2 as trivial. Found the EXECUTE anti-pattern particularly valuable ("sounds like 'execute query', but is prepared-statement mutation").
-
Refactor applied before PROMOTE (based on RED self-findings):
- Polish-1:
pg_sleep/pg_terminate_backendwith concrete code snippet as function denylist - Polish-2:
SELECT INTObypass as own anti-pattern documented (skill-step 4 must explicitly check\bINTO\b) - Polish-3:
SELECT … FOR UPDATEas row-lock side-effect pattern added - Polish-4: Multi-statement defense as own anti-pattern with code snippet
- Polish-5:
RETURNINGglobal denylist (not just in CTE context) - Polish-6: Naive
--comment-strip false-positive on string literals as known edge case - Polish-7: Dollar-quoting limitation marked as "needs AST"
- Polish-1:
Cycle-2 Backlog (Polish, non-blocking)
- Real sqlparse-AST-Walker as production-grade alternative documented with code example
- Identifier collisions: with DB columns named after reserved words (
"drop","delete") — mention quoted-identifier detection - GROUP BY/ORDER BY edge cases with function calls (
ORDER BY pg_sleep(1)) — function denylist in full SQL not just prefix - Example DB-role setup with concrete SQL (
CREATE ROLE readonly_user WITH LOGIN PASSWORD '...'; GRANT pg_read_all_data TO readonly_user;) - Real-use-case test: apply in next MCP server or dashboard ad-hoc query
Cross-skill connections
subprocess-ssh-arg-quoting-via-shlex(GA): sibling skill on argument-safetyenum-known-values-via-insert-grep(GA): cross-table constants-set discoverysuperpowers:writing-skills: creating production-grade alternatives requires AST-walker pattern
What ships with it
Read from the repository
Just SKILL.md. No reference files, no scripts.
Gives 0 of the 12 instructions most quality gates skills give in ~2.7k tokens
Counted across 1,195 of the 2,094 authors here whose files we hold, read 2026-08-07
- Read the output and check the exit codein 54 of 1195, across 14 files
- Verify requirements using a line-by-line checklistin 53 of 1195, across 12 files
- Identify the verification command proving the claimin 51 of 1195, across 12 files
- Run the full verification commandin 50 of 1195, across 11 files
- Verify output confirms the claimin 49 of 1195, across 12 files
- Check version control diff after agent delegationin 46 of 1195, across 6 files
- State claim with evidencein 44 of 1195, across 4 files
- Run the test suitein 33 of 1195, across 26 files
- Keep state in memory by defaultin 27 of 1195, across 6 files
- Make prototype runnable with one commandin 26 of 1195, across 5 files
- Produce a verification reportin 25 of 1195, across 14 files
- Detect the package manager from lockfilesin 24 of 1195, across 5 files
Said here and by no other author read
- strip SQL comments before matching patterns
- normalize whitespace and uppercase the query
- check query against mutating statement denylist
- check query against read-only statement allowlist
- denylist SELECT functions that cause side-effects
- explicitly check for SELECT INTO keyword
Grouped from the skills themselves: near-identical wordings counted once, and counted by distinct author, so one author publishing three of these counts once. Length counted with cl100k_base; the agent that loads this file may tokenize it differently.