ACE-037: fail-closed on unscopable SQL - #112
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.
| # 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
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?
bd445a0 to
3950f08
Compare
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>
aa7ccff to
a1e5065
Compare
|
Closing: superseded, and its base is doubly stale. Its base is ACE-037 itself is still live and still needed — fail-closed on unparseable or unscopable SQL is Wave 3, and it now has a second reason to exist: ACE-039's residual is that on the vendored plugin layout It gets a fresh branch off |
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