Spaces:
Runtime error
Runtime error
| """Validate model-generated SQL before it ever touches DuckDB. | |
| This is the safety boundary. Because I plan to run this with no human approval | |
| on the web, anything that is not a plain row-level SELECT/INSERT/UPDATE/DELETE | |
| against a registered workbook table must be refused here. | |
| Two layers: | |
| - `validate_statement_static`: text-only checks (no DB context). Blocks comments, | |
| multi-statements, disallowed commands, JOIN-in-DML, and DuckDB file/admin | |
| access (read_csv, ATTACH, COPY, ...). | |
| - `validate_statement_tables`: after rewriting, confirms every table the | |
| statement touches is one I actually registered (a positive allowlist). | |
| Author: mohamedgamal04 | |
| """ | |
| from __future__ import annotations | |
| import re | |
| ALLOWED_SQL_COMMANDS = {"select", "insert", "update", "delete"} | |
| # DuckDB table functions that read from the filesystem or run nested SQL. These | |
| # are the real exfiltration vector: a SELECT that wraps one bypasses the command | |
| # allowlist. I block them whenever they appear as a function call. | |
| FILE_FUNCTION_BLOCKLIST = { | |
| "read_csv", | |
| "read_csv_auto", | |
| "read_parquet", | |
| "read_json", | |
| "read_json_auto", | |
| "read_json_objects", | |
| "read_ndjson", | |
| "read_ndjson_objects", | |
| "read_text", | |
| "read_blob", | |
| "read_xlsx", | |
| "glob", | |
| "parquet_scan", | |
| "csv_scan", | |
| "sniff_csv", | |
| "query", | |
| "query_table", | |
| } | |
| # Admin / attach / extension keywords that must never appear, even embedded. | |
| # NOTE: deliberately excludes SET/LOAD/RESET/CALL/IMPORT/EXPORT — those collide | |
| # with legitimate column names or UPDATE's SET clause. Leading uses of those are | |
| # already caught by the command allowlist. | |
| ADMIN_TOKEN_BLOCKLIST = { | |
| "attach", | |
| "detach", | |
| "pragma", | |
| "install", | |
| "copy", | |
| } | |
| def _strip_leading_sql_noise(statement: str) -> str: | |
| """Remove leading whitespace and SQL comments before checking the command.""" | |
| text = statement.lstrip() | |
| while text: | |
| if text.startswith("--"): | |
| newline_index = text.find("\n") | |
| if newline_index == -1: | |
| return "" | |
| text = text[newline_index + 1 :].lstrip() | |
| continue | |
| if text.startswith("/*"): | |
| end_index = text.find("*/", 2) | |
| if end_index == -1: | |
| return "" | |
| text = text[end_index + 2 :].lstrip() | |
| continue | |
| break | |
| return text | |
| def _is_dml_statement(statement: str) -> bool: | |
| """Check whether a statement mutates data and may require save confirmation.""" | |
| return statement.lstrip().lower().startswith(("delete", "insert", "update")) | |
| def _scan_sql(text: str) -> tuple[str, bool, list[int]]: | |
| """Scan SQL once, tracking string-literal and comment state. | |
| Returns a tuple of: | |
| - `cleaned`: the statement with string-literal bodies and comments replaced | |
| by spaces, so later keyword/token checks never trip on data or comments. | |
| - `has_comment`: whether any `--` or block comment was found outside literals. | |
| - `semicolons`: positions of `;` that sit at the top level (not in a literal | |
| or comment). | |
| """ | |
| cleaned: list[str] = [] | |
| has_comment = False | |
| semicolons: list[int] = [] | |
| in_single = False | |
| in_double = False | |
| escape = False | |
| i = 0 | |
| length = len(text) | |
| while i < length: | |
| char = text[i] | |
| if in_single: | |
| cleaned.append(" ") | |
| if escape: | |
| escape = False | |
| elif char == "\\": | |
| escape = True | |
| elif char == "'": | |
| in_single = False | |
| i += 1 | |
| continue | |
| if in_double: | |
| # Double-quoted identifiers are kept verbatim so table/column names | |
| # survive for the keyword and allowlist checks. | |
| cleaned.append(char) | |
| if escape: | |
| escape = False | |
| elif char == "\\": | |
| escape = True | |
| elif char == '"': | |
| in_double = False | |
| i += 1 | |
| continue | |
| # Not inside any quote: look for comments first. | |
| if char == "-" and i + 1 < length and text[i + 1] == "-": | |
| has_comment = True | |
| newline = text.find("\n", i) | |
| if newline == -1: | |
| break | |
| cleaned.append(" " * (newline - i)) | |
| i = newline | |
| continue | |
| if char == "/" and i + 1 < length and text[i + 1] == "*": | |
| has_comment = True | |
| end = text.find("*/", i + 2) | |
| if end == -1: | |
| break | |
| cleaned.append(" " * (end + 2 - i)) | |
| i = end + 2 | |
| continue | |
| if char == "'": | |
| in_single = True | |
| cleaned.append(" ") | |
| i += 1 | |
| continue | |
| if char == '"': | |
| in_double = True | |
| cleaned.append(char) | |
| i += 1 | |
| continue | |
| if char == ";": | |
| semicolons.append(len(cleaned)) | |
| cleaned.append(char) | |
| i += 1 | |
| return "".join(cleaned), has_comment, semicolons | |
| def _find_blocklisted_token(cleaned: str) -> str | None: | |
| """Return the first blocklisted file-function call or admin token, if any.""" | |
| lowered = cleaned.lower() | |
| # File-reading functions are only dangerous as calls: `name (`. | |
| for match in re.finditer(r"\b([a-z_][a-z0-9_]*)\s*\(", lowered): | |
| name = match.group(1) | |
| if name in FILE_FUNCTION_BLOCKLIST: | |
| return name | |
| for match in re.finditer(r"\b([a-z_][a-z0-9_]*)\b", lowered): | |
| if match.group(1) in ADMIN_TOKEN_BLOCKLIST: | |
| return match.group(1) | |
| return None | |
| def validate_statement_static(statement: str) -> str | None: | |
| """Text-only safety checks. Return an error message or None when accepted.""" | |
| stripped = _strip_leading_sql_noise(statement) | |
| if not stripped: | |
| return "empty SQL statement" | |
| cleaned, has_comment, _ = _scan_sql(stripped) | |
| if has_comment: | |
| return "SQL comments are not allowed" | |
| if ";" in cleaned.rstrip().rstrip(";"): | |
| return "multiple SQL statements are not allowed" | |
| match = re.match(r"([A-Za-z]+)", stripped) | |
| if not match: | |
| return "unable to determine the SQL command" | |
| keyword = match.group(1).lower() | |
| if keyword not in ALLOWED_SQL_COMMANDS: | |
| return f"'{keyword.upper()}' is not allowed" | |
| # JOIN is allowed for reads, but DML must stay single-target so write-back | |
| # can resolve exactly one sheet. | |
| if keyword != "select" and re.search(r"\bjoin\b", cleaned, flags=re.IGNORECASE): | |
| return "'JOIN' is not allowed" | |
| blocked = _find_blocklisted_token(cleaned) | |
| if blocked is not None: | |
| return f"'{blocked.upper()}' is not allowed" | |
| return None | |
| def _extract_table_position_tokens(cleaned: str) -> list[str]: | |
| """Collect identifiers sitting in a table position (after FROM/JOIN/etc).""" | |
| tokens: list[str] = [] | |
| pattern = re.compile( | |
| r"\b(?:from|join|into|update|table)\s+(\"[^\"]+\"|[A-Za-z_][A-Za-z0-9_]*)", | |
| flags=re.IGNORECASE, | |
| ) | |
| for match in pattern.finditer(cleaned): | |
| token = match.group(1).strip() | |
| if token.startswith('"') and token.endswith('"'): | |
| token = token[1:-1] | |
| tokens.append(token) | |
| return tokens | |
| def validate_statement_tables(prepared_statement: str, allowed_tables: set[str]) -> str | None: | |
| """Positive control: every table referenced must be one I registered.""" | |
| cleaned, _, _ = _scan_sql(prepared_statement) | |
| allowed_lower = {name.lower() for name in allowed_tables} | |
| for token in _extract_table_position_tokens(cleaned): | |
| if token.lower() not in allowed_lower: | |
| return f"table '{token}' is not a registered workbook table" | |
| return None | |
| def _validate_sql_allowlist(statement: str) -> str | None: | |
| """Backwards-compatible wrapper kept for existing callers and tests.""" | |
| return validate_statement_static(statement) | |