Why unrestricted Python code generation is an unacceptable security hazard, and how to build sandboxed analytical agents with AST parsing and numerical verification.
Many early LLM analytics frameworks popularized an architectural pattern: prompt the LLM to write arbitrary Python code (using Pandas or exec()), run the code in a local subprocess, and display the output dataframe or plot.
While flexible in simple notebook demos, deploying open-ended code generation into production applications introduces critical vulnerabilities:
# Dangerous payload generated by unconstrained agent
import os; os.system("rm -rf /") # or exfiltrating environment variableswhile loops that consume 100% CPU and exhaust server memory.To build reliable data systems, we must replace arbitrary code execution with restricted tool dispatch, AST-level SQL validation, and automated assertion verification.
Instead of allowing the agent to generate free-form Python scripts, restrict the agent's action space to four discrete, immutable tools:
execute_sql_query: Executes read-only SQL queries against an in-process columnar database (DuckDB).generate_chart: Emits a structured chart specification (specifying chart type, x-column, y-column, aggregation) rendered by a deterministic client charting engine.get_column_statistics: Returns deterministic summary statistics (mean, median, null count, standard deviation, quartiles) computed directly by the engine.request_clarification: Triggered when a user query contains ambiguous column references or undefined business logic.By constraining the model to selecting tools with strict JSON schemas, the attack surface drops by orders of magnitude.
Naive SQL validation uses regex blacklist matching (e.g., checking if "DROP" or "DELETE" is present). Regex blacklists are trivially bypassed using SQL comments (/* DROP */), hexadecimal literals, CTE wrappers, or nested dynamic statements.
The proper approach is Abstract Syntax Tree (AST) parsing using sqlglot or database-native parsers:
import sqlglot
from sqlglot import exp
class SQLSafetyValidator:
ALLOWED_STATEMENT_TYPES = (exp.Select, exp.Union)
FORBIDDEN_EXPRESSIONS = (
exp.Drop, exp.Delete, exp.Insert, exp.Update,
exp.Alter, exp.Create, exp.Command, exp.Transaction
)
@classmethod
def validate_query(cls, sql_str: str) -> str:
"""
Parses and validates that a SQL query is strictly read-only and free of mutation syntax.
"""
try:
parsed = sqlglot.parse_one(sql_str, read="duckdb")
except Exception as e:
raise ValueError(f"Malformed SQL syntax: {str(e)}")
# Enforce that root expression is a SELECT or UNION
if not isinstance(parsed, cls.ALLOWED_STATEMENT_TYPES):
raise PermissionError(f"Prohibited query type: {type(parsed).__name__}. Only SELECT queries are permitted.")
# Traverse entire AST to prevent mutation commands in subqueries or CTEs
for expression in parsed.find_all(cls.FORBIDDEN_EXPRESSIONS):
raise PermissionError(f"Forbidden operation detected: {type(expression).__name__}")
# Return sanitized normalized SQL
return parsed.sql(dialect="duckdb")DuckDB is designed specifically for fast analytical queries (OLAP) directly within the application process. Unlike SQLite, DuckDB is columnar and vectorized; unlike PostgreSQL, it requires no external server process or network sockets.
To ensure physical isolation:
con = duckdb.connect(database=':memory:') con.execute("SET max_memory = '1GB'")
con.execute("SET threads = 4")A major failure mode in LLM analytics is hallucinated summary text. A query might return a sum of 452,100, but the generative model writes: "The total revenue for Q3 was 542,000."
To eliminate this, implement an automated Claim Verifier:
import re
from typing import List, Dict, Any
def verify_numerical_claims(
generated_text: str,
query_result_table: List[Dict[str, Any]],
tolerance: float = 0.01
) -> bool:
"""
Extracts numerical figures from generated summary text and verifies
that every extracted number exists in the query result table.
"""
# Extract floating-point and integer numbers from text
raw_numbers = re.findall(r'[-+]?(?:\d*\.\d+|\d+)', generated_text.replace(',', ''))
extracted_values = [float(n) for n in raw_numbers]
# Flatten all numerical values from query result
table_numbers = []
for row in query_result_table:
for val in row.values():
if isinstance(val, (int, float)):
table_numbers.append(float(val))
# Check that each significant extracted number is backed by the table
for num in extracted_values:
# Ignore common non-data numbers (e.g. 1, 2 for lists, or years like 2025 if filtered)
if num in [0.0, 1.0, 2.0]:
continue
# Verify proximity to at least one cell in the result table
is_supported = any(abs(num - tbl_val) <= (tolerance * abs(tbl_val) + 1e-5) for tbl_val in table_numbers)
if not is_supported:
return False # Flag as unsupported claim
return TrueTo ensure the LLM never outputs unparsed markdown, enforce strict JSON schemas using Pydantic:
from pydantic import BaseModel, Field
from typing import Optional, Literal, List
class ChartSpecification(BaseModel):
chart_type: Literal['bar', 'line', 'scatter', 'pie'] = Field(description="Visual chart representation")
x_axis: str = Field(description="Column name for horizontal dimension")
y_axis: str = Field(description="Column name for vertical numerical metric")
title: str = Field(description="Clean descriptive chart title")
class AnalyticalResponse(BaseModel):
sql_query: str = Field(description="Validated read-only SQL query")
reasoning: str = Field(description="Brief explanation of analytical approach")
key_findings: List[str] = Field(description="Bullet points describing verified results")
chart: Optional[ChartSpecification] = Field(default=None, description="Optional chart specification if visual is warranted")
confidence_score: float = Field(ge=0.0, le=1.0, description="Self-assessed evidence confidence score")eval() or exec(): Ban all dynamic string execution in application source code.Autonomous data analysis agent with DuckDB in-process OLAP, AST SQL safety validation, and post-synthesis numerical faithfulness verification.