ACE-037: fail-closed on unscopable SQL#112
Open
sandeep-agami wants to merge 2 commits into
Open
Conversation
There was a problem hiding this comment.
Pull request overview
This PR adds a new “scopability” safety gate to refuse SQL that cannot be fully scoped to declared tables (e.g., unparseable SQL, table-functions/ROWS FROM, VALUES/UNNEST/LATERAL sources), and wires it into the shared execute_sql._model_safety path with an operational rollout flag.
Changes:
- Introduces
semantic_model.runtime.check_scopable(...) -> Verdict | Noneto fail-closed on unscopable queries while reusing the existing single parse (ctx.tree). - Wires the gate first in both executor entrypoints’
_model_safetyand addsAGAMI_SQL_UNSCOPABLE_POSTURE(enforcedefault,warnescape hatch). - Adds focused unit + integration coverage to pin fail-closed behavior and parity across model sources.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| tests/test_scopable_gate.py | New unit tests for check_scopable allow/refuse corpus and ctx-parity. |
| tests/test_ace051_fail_closed.py | Extends fail-closed integration tests to ensure the wired gate refuses under enforce and allows+logs under warn. |
| packages/agami-core/src/semantic_model/runtime.py | Adds check_scopable fail-closed gate implemented via sqlglot tree sweeps. |
| packages/agami-core/src/execute_sql.py | Wires check_scopable into _model_safety and adds rollout posture handling. |
| plugins/agami/lib/execute_sql.py | Mirrors the same _model_safety wiring and rollout posture handling for the plugin copy. |
| deploy/agami.env.example | Documents AGAMI_SQL_UNSCOPABLE_POSTURE and intended semantics. |
| plugins/agami/skills/agami-deploy/bundle/agami.env.example | Documents AGAMI_SQL_UNSCOPABLE_POSTURE in the bundled env example. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comment on lines
+900
to
+905
| # VALUES / UNNEST and any other non-`Table`, non-derived-subquery FROM/JOIN source. | ||
| for node in tree.find_all(exp.From, exp.Join): | ||
| src = node.this | ||
| if src is not None and not isinstance(src, (exp.Table, exp.Subquery)): | ||
| return _unscopable(f"a FROM/JOIN source is a {type(src).__name__.upper()}, not a table") | ||
| return None |
ashwin-agami
left a comment
Contributor
There was a problem hiding this comment.
Why even have a flag to warn? Why not just make it strict? Where do we have the use case for warning?
This was referenced Jul 12, 2026
sandeep-agami
force-pushed
the
ACE-035-shared-guardrail-contract
branch
from
July 12, 2026 17:39
bd445a0 to
3950f08
Compare
sandeep-agami
force-pushed
the
ACE-037-fail-closed-unscopable-sql
branch
from
July 12, 2026 22:09
637b302 to
aa7ccff
Compare
A scopability gate (runtime.check_scopable) refuses a query that passes read-only but yields no scopable tree, or a non-Table FROM/JOIN source (table-function, ROWS FROM, VALUES, UNNEST, LATERAL), rather than run it blind past the object-scope gates' degrade-to-allow branches. Wired FIRST in _model_safety, with an AGAMI_SQL_UNSCOPABLE_POSTURE flag (default enforce; warn is a staged-rollout escape hatch, never the shipped default). Reuses the single sqlglot parse (no second parser). Rebased onto the reconciled ACE-035: the gate returns its unscopable_sql refusal via _model_safety's typed (sql, Refusal | None) contract, so both the subprocess and the in-process paths build the same refused Envelope. Tests updated to the typed contract. Spec: ACE-037 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… scope - check_scopable (Copilot): walk EVERY FROM/JOIN source, not just `.this`. This sqlglot version normalizes a comma-join `FROM t1, <VALUES/UNNEST/table-fn>` to a Join whose `.this` is the source (already covered), but other versions hang the extra sources off `From.expressions` — check those too so an unscopable trailing source can't hide behind a valid leading table on either shape. - check_table_scope (security review, the finding that survived the stack): make table-scope SCHEMA-AWARE. Bare-name matching admitted `secret_schema.orders` when the model declares only `public.orders` — a confinement gap if the datasource role can see other schemas. Now a schema-QUALIFIED reference whose schema isn't one the model declares for that name is refused; an UNqualified reference still matches by name (the search_path resolves it). Reads the table's aliased `schema_name` field (plain `.schema` collides with Pydantic's BaseModel.schema). Tests: comma-join VALUES/UNNEST/table-function sources refuse (leading declared table doesn't shield them); a wrong-schema qualified ref refuses (standalone + in a join, the correctly-schema'd table not flagged); a bare/declared name and a correctly-qualified name still allow. Spec: ACE-037 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
sandeep-agami
force-pushed
the
ACE-037-fail-closed-unscopable-sql
branch
from
July 13, 2026 02:06
aa7ccff to
a1e5065
Compare
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
Closes a silent fail-open in the SQL object-scope gates: they only reject the
exp.Tablesources they find, so a query that passes the read-only guard but whoseFROM/JOINsource can't be scoped — a table-function /ROWS FROM, aVALUES/UNNEST/LATERALsource, or SQL that doesn't parse at all — leaves the scope walk with nothing to reject and is allowed to run. This adds a fail-closed scopability gate: such a query is refused (unscopable_sql) rather than run blind, at the single shared executor, on every surface.Changes
runtime.check_scopable(sql, org, ctx) -> Verdict | None— refuses when the tree is absent/unparseable, has no SELECT, or any source isn't a scopable namedexp.Table/ derivedexp.Subquery. Two whole-tree sweeps (an empty-nameexp.Tablecatches table-functions /ROWS FROM; aLATERALsweep; and a non-Table/SubqueryFrom/Joinsource catchesVALUES/UNNEST) — so every set-operation arm is covered. Reuses the existing single parse (ctx.tree) — no second parser. Inert when the model declares no tables (consistent with the object-scope gates).execute_sql._model_safety(before the object-scope gates), so an unscopable query fails closed instead of reaching their degrade-to-allow branches.AGAMI_SQL_UNSCOPABLE_POSTUREflag —enforce(default) refuses;warnlogs + allows (a staged-rollout escape hatch, never the shipped default; any non-warnvalue fails closed). Documented in bothagami.env.examplecopies.Design decisions
enforce.Test plan
uv run dev.py check— ruff, format, gitleaks, vendored-lib drift, and the pytest suite across py3.10–3.12.tests/test_scopable_gate.py(unit corpus: named tables / CTE / derived subquery / comma-join / set-op /SELECT 1allow; table-functions /ROWS FROM/VALUES/UNNEST/LATERAL/LATERAL VIEW/ unparseable / no-SELECT / parser-unavailable refuse; ctx-parity).tests/test_ace051_fail_closed.pyadditions: the differential corpus refuses through the wired_model_safetyunderenforce;warnlogs + allows; any non-warnposture value fails closed; and the model-source parity test asserts an unscopable query yields the same refusal on a file-served vs DB-served model.enforce→ a{"refusal":{"kind":"unscopable_sql"}}line + non-zero exit before any DB connection;warn→ the log line, then proceeds.Checklist
enforce);warncannot silently be the default; both surfaces coveredexecute_sqlenforcement point preservedSpec: ACE-037
🤖 Generated with Claude Code