Repository navigation
Harden validator scope analysis and access-policy enforcement - #25
Merged
Merged
Conversation
Row filters, masking, and max_rows could be bypassed in 0.1.1, and the validator rejected common read-only SQL (CTEs, UNION, alias references). - Resolve columns through sqlglot scope analysis: CTEs (incl. recursive and column lists), set operations, derived tables, ORDER BY/HAVING on select aliases, and correlated/IN subqueries now validate correctly. - Apply row filters by wrapping every reference to a filtered table as (SELECT * FROM t WHERE <filter>) AS t, so they hold in subqueries, CTEs, and set operations, cannot bind to another table's column or a spoofed alias, and keep outer-join semantics. - Mask by output-column lineage (ValidationResult.output_masks) instead of result key names; mask every value if the row shape does not match. - Replace any LIMIT that is not a non-negative literal within policy. - Reject SELECT ... INTO; open SQLite read-only (mode=ro). - Raise on a missing SQLite file instead of creating it; eval run exits with "Fixture database not found" before running cases. - Raise sqlglot floor to 25.20 (earlier versions lack exp.Alter). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nd-access-policy # Conflicts: # CHANGELOG.md # src/querypilot/cli.py
Unpinned ruff resolved to 0.17.1, whose expanded default rules report 49 findings on main as well as this branch. 0.15.22 passes both. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nd-access-policy # Conflicts: # .github/workflows/eval.yml
`SELECT strftime('%Y-%m', d) AS month ... GROUP BY month` was rejected with
"Unknown column: month", which made 4 SaaSPulse correctness golds
unwinnable. An unqualified GROUP BY name now resolves to a select alias
when no source column has that name (source columns still win, as in
SQLite). Adds a test that every SaaSPulse gold passes the validator.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
7 of 9 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
In v0.1.1, row filters, masking, and
max_rowscould be bypassed with ordinary SQL. The validator also rejected common read-only queries (CTEs,UNION,ORDER BYon aliases), which would make LLM-generated SQL fail often. This PR rebuilds column resolution on sqlglot's scope analysis and fixes each bypass.Bypasses reproduced on 0.1.1 (filter
customers: tenant_id = 42, 50 of 500 customers in tenant 42):SELECT (SELECT COUNT(*) FROM customers) AS n FROM customers LIMIT 1SELECT COUNT(*) FROM (SELECT id, 42 AS tenant_id FROM customers)... FROM orders o WHERE o.customer_id IN (SELECT c.id FROM customers c)orders.tenant_id; 2000 rowsSELECT email AS contact FROM customerswith email masked[REDACTED]SELECT id FROM orders LIMIT -1withmax_rows=100Changes
sqlglot.optimizer.scope, so CTEs (including recursive CTEs and CTE column lists),UNION/INTERSECT/EXCEPT, derived-table qualifiers,ORDER BY/HAVINGon select aliases, and correlated orINsubqueries now validate. Unknown and ambiguous columns still fail closed.ValidationResult.tableslists only physical tables, not CTE names.(SELECT * FROM t WHERE <filter>) AS t, so the filter applies at every scope and cannot bind to another table's column or a spoofed alias. LEFT JOIN semantics are kept, which a per-scopeWHEREwould break.ValidationResult.output_masksrecords a masking mode per output position. Aliases, expressions, CASE, string and json functions, CTEs, derived tables, scalar subqueries, and set-op branches are masked.COUNT(masked_col)is left unmasked. If a row's shape doesn't match the analyzed projection (for example, duplicate column names, or aUSINGjoin's star), every value is masked.max_rowsis replaced:LIMIT -1, expressions, subqueries, andFETCH.SELECT ... INTOis rejected. SQLite is opened withmode=ro, matching the PostgresREAD ONLYtransaction.FileNotFoundErrorinstead of silently creating an empty database.eval runexits early withFixture database not found: <path>.sqlglot>=25.20. The old>=25.0floor was already broken: 0.1.1's validator usesexp.Alter, which doesn't exist before roughly 25.20.Testing
pytestpasses locally (354 passed). The 40 new or updated tests fail onmain'ssrc/.blocks_select_into)CHANGELOG.mdupdated under[Unreleased]eval checkpasses against.eval/baseline.jsonAlso tested:
r(n)) from SQLite output, so such queries fail at execution. That fails closed.USING/NATURALstar joins, table-valued functions, andsqlite_master. No leaks; the unsupported forms are rejected.Not covered:
WHERE email LIKE 'a%') can still allow inference. That behavior is unchanged from 0.1.1.ruff check src testsreports 44 findings onmainwith the unpinned ruff 0.17. This branch adds none.🤖 Generated with Claude Code