finai / apps /api /mbf /retrieval /sql_safety.py
Sandy-sys's picture
Deploy FinAI
db7ef8e verified
Raw History Blame Contribute Delete
2.9 kB
"""SQL safety: AST allow-list (SELECT-only, no DDL/DML, no risky functions).
Uses sqlglot to parse and validate any LLM-generated SQL before execution.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Iterable
import sqlglot
from sqlglot import exp
ALLOWED_TABLES_PREFIXES: tuple[str, ...] = ("raw.", "dim.", "sem.")
DENY_FUNCTIONS = {"load_file", "sleep", "benchmark", "user", "version"}
MAX_LIMIT = 5000
@dataclass(frozen=True)
class SqlValidation:
ok: bool
reason: str = ""
rewritten: str = ""
def validate_sql(sql: str, dialect: str = "postgres") -> SqlValidation:
try:
parsed = sqlglot.parse_one(sql, read=dialect)
except Exception as e: # noqa: BLE001
return SqlValidation(False, f"parse_error: {e}")
if not isinstance(parsed, (exp.Select, exp.Subqueryable, exp.With, exp.Union)):
return SqlValidation(False, f"not_a_select: {type(parsed).__name__}")
# Reject any DML/DDL nodes anywhere in tree.
forbidden = (
exp.Insert,
exp.Update,
exp.Delete,
exp.Drop,
exp.Create,
exp.AlterTable,
exp.TruncateTable,
exp.Merge,
)
for node in parsed.walk():
n = node[0] if isinstance(node, tuple) else node
if isinstance(n, forbidden):
return SqlValidation(False, f"forbidden_statement:{type(n).__name__}")
if isinstance(n, exp.Anonymous) and (n.name or "").lower() in DENY_FUNCTIONS:
return SqlValidation(False, f"forbidden_function:{n.name}")
# Table allow-list
for tbl in parsed.find_all(exp.Table):
fq = ".".join(p for p in [tbl.db, tbl.name] if p).lower()
if not any(fq.startswith(p) or fq == p.rstrip(".") for p in ALLOWED_TABLES_PREFIXES):
# Also allow bare names that match dim/raw common tables (no schema qualifier)
if tbl.name.lower() in {
"extended_bva_v5", "bva", "item_mb", "open_po", "expense_mb",
"opening_budget_fy_25_26", "opening_underspents", "training_blocked_budget",
"funders", "projects", "categories", "vendors", "employees",
}:
continue
return SqlValidation(False, f"forbidden_table:{fq or tbl.name}")
# Force a LIMIT cap
if isinstance(parsed, exp.Select):
lim = parsed.args.get("limit")
if lim is None:
parsed.set("limit", exp.Limit(expression=exp.Literal.number(MAX_LIMIT)))
else:
try:
n = int(lim.expression.name)
if n > MAX_LIMIT:
parsed.set("limit", exp.Limit(expression=exp.Literal.number(MAX_LIMIT)))
except Exception:
pass
return SqlValidation(True, rewritten=parsed.sql(dialect=dialect))