sql-guard
Deterministic policy engine for LLM-generated SQL. Multi-dialect (BigQuery, Snowflake, Postgres, Trino, DuckDB, ClickHouse, MySQL, …) via sqlglot.
When an LLM agent writes SQL on a user's behalf, the system prompt is not a
trust boundary — prompt injection through user input or tool outputs can
talk a model out of any rule you stated in plain English. sql-guard is the
deterministic floor: before the SQL the model produced hits your warehouse,
the guard parses it, runs your policy, and returns an allow / confirm /
deny decision. There is no LLM in the guard path.
from sql_guard import PiiDenylist, SqlGuard, SqlGuardConfig
guard = SqlGuard(SqlGuardConfig.from_settings(
pii_denylist=PiiDenylist.from_mapping({
"columns": ["email", "phone_number", "ssn"],
"substrings": ["address"],
}),
allowed_tables=["my-project.analytics.orders"],
dialect="bigquery", # or "snowflake", "postgres", "trino", ...
))
decision = guard.evaluate_static(
"SELECT customer_id, COUNT(*) FROM `my-project.analytics.orders` GROUP BY 1"
)
if decision.denied:
return decision.reason # surface to the user; do not call the warehouse
What it enforces (out of the box)
- Single SELECT only. DML, DDL, scripts, multi-statement payloads — all rejected. Even if buried in subqueries.
- PII column denylist. By default, any reference to a denylisted column
is rejected — in the select list,
WHERE,GROUP BY,HAVING,ORDER BYor aJOINcondition, at any nesting depth. Catches aliased PII (SELECT email AS x), PII through transforms (SELECT LOWER(email)), the right arm of aUNION ALL, and columns renamed inside a CTE or derived table. See PII modes. - No
SELECT *in any scope. Bare*,* EXCEPT(...),* REPLACE(...)and qualifiedt.*are all rejected, inside CTEs and subqueries as well as at the top level — the guard can't prove EXCEPT enumerates every PII column, nor what*expands to. The check is a deep walk, so stars wrapped in a function (OBJECT_CONSTRUCT(*),COLUMNS(*),* APPLY(f)) are caught too;COUNT(*)is the deliberate exception. - Nothing whose columns the guard can't enumerate. A bare table alias in a
value position (
SELECT c FROM tbl AS c) returns the whole row as a struct, andNATURAL JOINjoins on unknown shared columns. Both are rejected for the same reason asSELECT *. - Table allowlist. Only fully-qualified tables you approved can be referenced. CTE aliases are excluded.
- Cost cap. Given the bytes-processed figure from a dry-run, the guard
returns
allowbelow your auto threshold,confirmin between, anddenyabove the hard cap or bytes-billed ceiling.
Every check is a Rule you can replace or compose with.
PII modes
pii_mode controls how far the denylist reaches.
| Mode | Denies | Use when |
|---|---|---|
"reference" (default) |
Any reference to a denied column, in any clause and any scope. | The agent must not learn PII values at all. |
"project" |
Only projections of denied columns — checked in every scope. | Predicate access to PII is a deliberate, accepted trade-off. |
The default is the strict one because projection-only checking leaves the
values reachable. A denied column in a WHERE clause never appears in the
output, but the row count still answers a yes/no question about it:
-- Passes a projection-only guard. Returns 0 or non-zero.
SELECT COUNT(*) FROM `p.d.orders` WHERE billing_city = 'Columbus'
Repeat with LIKE 'a%', > 'm', and so on, and the value falls out in a
handful of queries. GROUP BY, HAVING and ORDER BY leak the same way.
The loosening path. If your deployment genuinely needs to filter on PII —
segmenting on a hashed identifier, say, or counting non-null contact rows —
set pii_mode="project":
SqlGuardConfig.from_settings(..., pii_mode="project")
That re-permits denied columns in predicates while still rejecting every projection of them, in every scope. Prefer narrowing the denylist, or exposing a pre-masked warehouse view the denylist doesn't cover, before reaching for it.
Note that "aggregate" is not a safe harbour in either mode. MAX(email),
ARRAY_AGG(email) and STRING_AGG(email) return real values and are rejected;
only aggregates that reduce to a statistic (COUNT, SUM, AVG, STDDEV, …)
are treated as PII-neutralising, and then only under pii_mode="project".
What the denylist does not cover
The guard matches column names in the SQL text. It has no schema, so:
- PII inside JSON / VARIANT / STRUCT payloads is not covered.
JSON_VALUE(payload, '$.email')names onlypayload; the field name is a string literal the engine resolves. The same applies to selecting a struct column whole (SELECT contact FROM t) and toUNNESTaliases over an array of structs (SELECT s FROM t, UNNEST(t.contacts) AS s) — both return every field without naming one. Denylist the containing column. - Re-identification through non-PII columns is out of scope. If
uidmaps 1:1 to a person, blockingemaildoes not prevent correlation with outside data. - Side channels — row counts, dry-run byte counts and error messages carry bits about denied values even when every direct reference is refused.
These are limits of a parse-level guard, not bugs. Warehouse-side column
security is the durable answer; sql-guard is defence in depth.
Scopes
Denylist and star checks run against every SELECT scope — CTE bodies,
derived tables, scalar and IN subqueries, and each arm of a set operation —
matching on the underlying column names in the scope that names them. An alias
therefore cannot launder a denied column:
-- Denied: the CTE scope still names billing_city.
WITH c AS (SELECT billing_city AS city FROM `p.d.orders`)
SELECT city FROM c
Checking only the outermost select list would see city and let it through.
The same applies through derived tables, UNION arms, and multi-hop alias
chains (a AS (...) → b AS (...) → SELECT).
The cost-cap rule in detail
Three independent thresholds bound any single query:
| Threshold | Default | Outcome |
|---|---|---|
max_cost_usd_auto |
$0.10 | Auto-execute below; ask-confirmation above. |
max_cost_usd_hard |
$20.00 | Refuse even with user confirmation. |
max_bytes_billed |
10 GiB | Hard byte cap. Bypasses the cost model so a pricing-model bug can't paper over an unbounded scan. |
The cost model is a Protocol — BigQueryOnDemandCost($5/TiB) is the
default; FlatRateCost(usd_per_byte=...) and user-supplied implementations
(Snowflake credits, Redshift node-hours) plug straight in. Per-warehouse
billing models stay accurate without forking.
Plus a dry-run-only mode where the guard runs but never lets execution through — useful for sandboxes or onboarding a new tenant.
What it deliberately does not do
- It does not call BigQuery / Snowflake / anything. Dry-runs are the caller's
job; pass
bytes_processedtoevaluate_cost. Keeps the guard testable without credentials and dialect-agnostic. - It does not introspect table schemas. If you say "this table is allowed,"
the guard takes your word for it. This is why
SELECT *is rejected everywhere: without a schema the guard cannot enumerate what*returns. - It does not authorise the user. Identity, IAM, row-level security: not in scope. The guard is a policy layer, not a permissions layer.
Multi-dialect
sqlglot parses every dialect listed below. Pass dialect="..." and the
same rule set applies:
| Dialect | Status |
|---|---|
bigquery (default) |
Heavy real-world use |
snowflake |
Tested |
postgres |
Tested |
trino / presto |
Tested |
duckdb |
Tested |
clickhouse |
Tested |
mysql |
Tested |
oracle, databricks, redshift, tsql, others |
Should work — file an issue if not |
Pluggable rules
A Rule is anything with an evaluate(ctx: RuleContext) -> GuardDecision | None
method. Return None to pass; return a GuardDecision (typically a DENY) to
short-circuit.
from dataclasses import dataclass
from sql_guard import GuardDecision, GuardOutcome, RuleContext, SqlGuard, default_rules
@dataclass(frozen=True)
class RequirePartitionFilter:
column: str
def evaluate(self, ctx: RuleContext) -> GuardDecision | None:
sql = ctx.sql.lower()
if "where" not in sql or self.column.lower() not in sql:
return GuardDecision(
outcome=GuardOutcome.DENY,
reason=f"Queries must filter on {self.column} for partition pruning.",
)
return None
guard = SqlGuard(config, rules=[RequirePartitionFilter("order_date"), *default_rules(config)])
See examples/03_custom_rule.py for a runnable version.
Pluggable cost models
from sql_guard import BigQueryOnDemandCost, FlatRateCost, SqlGuardConfig
# Default — BigQuery on-demand $5/TiB
SqlGuardConfig.from_settings(..., cost_model=BigQueryOnDemandCost())
# Custom enterprise rate
SqlGuardConfig.from_settings(..., cost_model=BigQueryOnDemandCost(usd_per_tib=3.0))
# Flat-rate (testing or contractual SKUs)
SqlGuardConfig.from_settings(..., cost_model=FlatRateCost(usd_per_byte=1e-9))
# Or your own — anything with `bytes_to_usd(int) -> float` is a CostModel.
How is this different from …
- NeMo Guardrails / LangChain guardrails / Anthropic Guardrails: those
layers sit at the LLM message boundary and rely on the model classifying
its own output.
sql-guardsits at the SQL execution boundary and uses a deterministic parser. The two are complementary — guardrails catch malicious intent,sql-guardcatches malicious queries. - LLM-as-judge for SQL: another LLM call costs tokens and is itself
vulnerable to prompt injection.
sql-guardis pure-Python, sub-millisecond, and can't be talked out of its rules. - Warehouse-side RLS / column-level security: the right long-term
answer, but requires coordinated schema work.
sql-guardgets you defence-in-depth today with a config file, not a migration. - Hand-rolled regex over generated SQL: regex over SQL is famously
brittle.
sql-guardparses the actual AST.
Install
pip install agent-sql-guard # core: only depends on sqlglot
pip install 'agent-sql-guard[adk]' # + Google ADK + BigQuery client for the
# FunctionTool integration
The distribution is agent-sql-guard; the import is sql_guard:
from sql_guard import SqlGuard, SqlGuardConfig
The unqualified name sql-guard on PyPI is an unrelated data-quality package
by another author.
Python 3.11+ supported.
License
Apache-2.0.
See also
examples/— runnable scripts: minimal use, multi-dialect, custom rule.CHANGELOG.md— semver release notes.CONTRIBUTING.md— how to add rules / cost models / dialects.SECURITY.md— disclosure process and threat model.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file agent_sql_guard-0.2.0.tar.gz.
File metadata
- Download URL: agent_sql_guard-0.2.0.tar.gz
- Upload date:
- Size: 43.3 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
6e29b2fef2955a151d2e5254b02a074efa8790a911eb08ac128bbc48a4fef12a
|
|
| MD5 |
2ec5570d45ba1c51a29cc63b002b6d8a
|
|
| BLAKE2b-256 |
b2b551de66645ddb362e72fea7bd7a62d1bdd307d4e97d029093464b969005fd
|
Provenance
The following attestation bundles were made for agent_sql_guard-0.2.0.tar.gz:
Publisher:
release.yml on sakura-sky/sql-guard
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
agent_sql_guard-0.2.0.tar.gz -
Subject digest:
6e29b2fef2955a151d2e5254b02a074efa8790a911eb08ac128bbc48a4fef12a - Sigstore transparency entry: 2498489189
- Sigstore integration time:
-
Permalink:
sakura-sky/sql-guard@5c0e5f8eef011cc48c4b4b8929d269a19125bd39 -
Branch / Tag:
refs/tags/v0.2.0 - Owner: https://github.com/sakura-sky
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@5c0e5f8eef011cc48c4b4b8929d269a19125bd39 -
Trigger Event:
push
-
Statement type:
File details
Details for the file agent_sql_guard-0.2.0-py3-none-any.whl.
File metadata
- Download URL: agent_sql_guard-0.2.0-py3-none-any.whl
- Upload date:
- Size: 23.1 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
4c4c2c96bb0368880c4e7b77ff01867280f82025a02976aea9bbd2cdb3776bfc
|
|
| MD5 |
1180cd0fa84dd73cc517be609663d728
|
|
| BLAKE2b-256 |
e4c4744148754f2837175940193b921df76a27668b33a3888ed38a6fe88fb67a
|
Provenance
The following attestation bundles were made for agent_sql_guard-0.2.0-py3-none-any.whl:
Publisher:
release.yml on sakura-sky/sql-guard
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
agent_sql_guard-0.2.0-py3-none-any.whl -
Subject digest:
4c4c2c96bb0368880c4e7b77ff01867280f82025a02976aea9bbd2cdb3776bfc - Sigstore transparency entry: 2498489193
- Sigstore integration time:
-
Permalink:
sakura-sky/sql-guard@5c0e5f8eef011cc48c4b4b8929d269a19125bd39 -
Branch / Tag:
refs/tags/v0.2.0 - Owner: https://github.com/sakura-sky
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@5c0e5f8eef011cc48c4b4b8929d269a19125bd39 -
Trigger Event:
push
-
Statement type: