This commit is contained in:
tao.chen
2026-06-18 13:59:01 +08:00
parent e6552e4f43
commit 13abf8409b
14 changed files with 1676 additions and 0 deletions
@@ -0,0 +1,52 @@
"""PySpark SQL Guardrails — validator and safe execution wrapper.
This module is the canonical implementation referenced by
.claude/skills/pyspark-sql-guardrails/SKILL.md. Do not redefine the
regex inline at call sites; do not write "lighter" versions. Always
import this module and route `spark.sql(...)` through `safe_spark_sql`.
The validator is pure-Python and has no Spark / I/O dependencies. It
is safe to import in unit tests, CI, notebooks, and ad-hoc scripts.
"""
import re
_FORBIDDEN_SQL = re.compile(
r"\b(delete|truncate|drop|alter|create|replace|merge|update|msck|refresh|analyze|cache|uncache|vacuum|optimize)\b",
re.IGNORECASE,
)
_LEADING_COMMENTS = re.compile(r"\A\s*(?:--[^\n]*\n|/\*.*?\*/\s*)*", re.DOTALL)
def assert_select_or_insert(sql: str) -> str:
"""Return the SQL body if it is a single SELECT/INSERT, else raise ValueError.
Hard-allowlist. No I/O, no logging side effects, no Spark dependency.
Safe to call in unit tests, CI, notebooks, and ad-hoc scripts.
"""
text = sql.strip()
if not text:
raise ValueError("SQL is empty")
body = text[:-1].strip() if text.endswith(";") else text
if ";" in body:
raise ValueError("Multiple SQL statements are not allowed")
normalized = _LEADING_COMMENTS.sub("", body).lstrip()
first = normalized.split(None, 1)[0].lower() if normalized else ""
if first not in {"select", "with", "insert"}:
raise ValueError(f"Only SELECT and INSERT SQL are allowed, got {first!r}")
if _FORBIDDEN_SQL.search(normalized):
raise ValueError("Forbidden SQL keyword found")
if first == "with" and not re.search(r"\bselect\b", normalized, re.IGNORECASE):
raise ValueError("WITH statements must be SELECT queries")
return body
def safe_spark_sql(spark, sql: str):
"""Validate then execute. The ONLY sanctioned path to spark.sql()."""
return spark.sql(assert_select_or_insert(sql))
@@ -0,0 +1,64 @@
"""Standard test set for sql_guard.assert_select_or_insert.
Run from the scripts/ subdirectory of the skill:
cd .claude/skills/pyspark-sql-guardrails/scripts
python3 test_sql_guard.py
Or from the project root:
python3 .claude/skills/pyspark-sql-guardrails/scripts/test_sql_guard.py
Any change to assert_select_or_insert must be followed by running this
set: all EXPECT_RAISE cases must raise, all EXPECT_OK cases must return
cleanly. Failures are loud (non-zero exit).
"""
import sys
import os
# Allow running this file directly from the scripts/ subdirectory.
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from sql_guard import assert_select_or_insert
def expect_ok(name, sql):
try:
assert_select_or_insert(sql)
print(f" [OK] {name}")
except ValueError as e:
raise AssertionError(f"{name} should have passed but raised: {e}")
def expect_raise(name, sql):
try:
assert_select_or_insert(sql)
raise AssertionError(f"{name} should have raised but passed")
except ValueError:
print(f" [OK] {name}: raised as expected")
print("=== EXPECT_OK ===")
expect_ok("plain SELECT", "SELECT 1")
expect_ok("WITH ... SELECT", "WITH t AS (SELECT 1 AS x) SELECT x FROM t")
expect_ok("trailing semicolon", "SELECT 1 FROM dual;")
expect_ok("leading -- comment", "-- comment\nSELECT 1")
expect_ok("leading /* comment", "/* hi */ SELECT 1")
expect_ok("INSERT ... SELECT", "INSERT INTO t SELECT 1")
print()
print("=== EXPECT_RAISE ===")
expect_raise("DROP", "DROP TABLE loan_order")
expect_raise("UPDATE", "UPDATE loan_order SET status = 1")
expect_raise("DELETE", "DELETE FROM loan_order")
expect_raise("TRUNCATE", "TRUNCATE TABLE loan_order")
expect_raise("ALTER", "ALTER TABLE loan_order ADD COLUMN x INT")
expect_raise("MERGE", "MERGE INTO t USING s ON t.id = s.id")
expect_raise("MSCK", "MSCK REPAIR TABLE loan_order")
expect_raise("REFRESH", "REFRESH TABLE loan_order")
expect_raise("VACUUM", "VACUUM TABLE loan_order")
expect_raise("stacked ;", "SELECT 1; DROP TABLE loan_order")
expect_raise("empty", " ")
expect_raise("comment-hidden", "-- ok\nDROP TABLE loan_order")
print()
print("ALL TESTS PASSED")
+60
View File
@@ -0,0 +1,60 @@
#!/usr/bin/env python3
"""One-line SQL guard for the pyspark-sql-guardrails skill.
Usage:
# Pipe SQL via stdin (preferred for multi-line SQL)
echo "SELECT ..." | python3 validate_sql.py
cat query.sql | python3 validate_sql.py
# Or pass SQL as the first argument (single-line, quoting-friendly)
python3 validate_sql.py "SELECT 1"
Exit code:
0 -> PASS (SQL is allowed)
1 -> FAIL (SQL violates the allowlist; reason printed to stdout)
This script is the ONLY sanctioned way to validate a generated SQL
string before showing it to the user. It is intentionally short so it
can be invoked in a single Bash call from the assistant.
"""
import sys
import os
# Allow running from anywhere; resolve bundled sql_guard.py.
# The skill layout is:
# .claude/skills/pyspark-sql-guardrails/
# ├── SKILL.md
# └── scripts/
# ├── sql_guard.py (this script's sibling)
# ├── validate_sql.py
# └── test_sql_guard.py
SCRIPTS_DIR = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, SCRIPTS_DIR)
from sql_guard import assert_select_or_insert # noqa: E402
def read_sql() -> str:
if len(sys.argv) > 1:
return sys.argv[1]
if not sys.stdin.isatty():
return sys.stdin.read()
print(__doc__, file=sys.stderr)
sys.exit(2)
def main() -> int:
sql = read_sql()
try:
body = assert_select_or_insert(sql)
except ValueError as e:
print(f"FAIL - {e}")
return 1
first = body.split(None, 1)[0].lower()
print(f"PASS - first keyword: {first}, body length: {len(body)}")
return 0
if __name__ == "__main__":
sys.exit(main())