Skip to main content

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)

  1. Single SELECT only. DML, DDL, scripts, multi-statement payloads — all rejected. Even if buried in subqueries.
  2. PII column denylist. By default, any reference to a denylisted column is rejected — in the select list, WHERE, GROUP BY, HAVING, ORDER BY or a JOIN condition, at any nesting depth. Catches aliased PII (SELECT email AS x), PII through transforms (SELECT LOWER(email)), the right arm of a UNION ALL, and columns renamed inside a CTE or derived table. See PII modes.
  3. No SELECT * in any scope. Bare *, * EXCEPT(...), * REPLACE(...) and qualified t.* 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.
  4. 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, and NATURAL JOIN joins on unknown shared columns. Both are rejected for the same reason as SELECT *.
  5. Table allowlist. Only fully-qualified tables you approved can be referenced. CTE aliases are excluded.
  6. Cost cap. Given the bytes-processed figure from a dry-run, the guard returns allow below your auto threshold, confirm in between, and deny above 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 only payload; the field name is a string literal the engine resolves. The same applies to selecting a struct column whole (SELECT contact FROM t) and to UNNEST aliases 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 uid maps 1:1 to a person, blocking email does 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 ProtocolBigQueryOnDemandCost($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_processed to evaluate_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-guard sits at the SQL execution boundary and uses a deterministic parser. The two are complementary — guardrails catch malicious intent, sql-guard catches malicious queries.
  • LLM-as-judge for SQL: another LLM call costs tokens and is itself vulnerable to prompt injection. sql-guard is 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-guard gets 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-guard parses 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

agent_sql_guard-0.2.0.tar.gz (43.3 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

agent_sql_guard-0.2.0-py3-none-any.whl (23.1 kB view details)

Uploaded Python 3

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

Hashes for agent_sql_guard-0.2.0.tar.gz
Algorithm Hash digest
SHA256 6e29b2fef2955a151d2e5254b02a074efa8790a911eb08ac128bbc48a4fef12a
MD5 2ec5570d45ba1c51a29cc63b002b6d8a
BLAKE2b-256 b2b551de66645ddb362e72fea7bd7a62d1bdd307d4e97d029093464b969005fd

See more details on using hashes here.

Provenance

The following attestation bundles were made for agent_sql_guard-0.2.0.tar.gz:

Publisher: release.yml on sakura-sky/sql-guard

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

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

Hashes for agent_sql_guard-0.2.0-py3-none-any.whl
Algorithm Hash digest
SHA256 4c4c2c96bb0368880c4e7b77ff01867280f82025a02976aea9bbd2cdb3776bfc
MD5 1180cd0fa84dd73cc517be609663d728
BLAKE2b-256 e4c4744148754f2837175940193b921df76a27668b33a3888ed38a6fe88fb67a

See more details on using hashes here.

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

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page