Skip to content

ACE-037: fail-closed on unscopable SQL#112

Open
sandeep-agami wants to merge 2 commits into
ACE-035-shared-guardrail-contractfrom
ACE-037-fail-closed-unscopable-sql
Open

ACE-037: fail-closed on unscopable SQL#112
sandeep-agami wants to merge 2 commits into
ACE-035-shared-guardrail-contractfrom
ACE-037-fail-closed-unscopable-sql

Conversation

@sandeep-agami

Copy link
Copy Markdown
Collaborator

Stacked on #111 (the shared guardrail contract) — it depends on that PR's Verdict/Envelope/safety_verdict and the refactored gates. Base is the ACE-035-shared-guardrail-contract branch; retarget to main once #111 merges. Review/merge #111 first.

Summary

Closes a silent fail-open in the SQL object-scope gates: they only reject the exp.Table sources they find, so a query that passes the read-only guard but whose FROM/JOIN source can't be scoped — a table-function / ROWS FROM, a VALUES / UNNEST / LATERAL source, 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

  • New 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 named exp.Table / derived exp.Subquery. Two whole-tree sweeps (an empty-name exp.Table catches table-functions / ROWS FROM; a LATERAL sweep; and a non-Table/Subquery From/Join source catches VALUES/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).
  • Wired first in 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_POSTURE flagenforce (default) refuses; warn logs + allows (a staged-rollout escape hatch, never the shipped default; any non-warn value fails closed). Documented in both agami.env.example copies.

Design decisions

  • The gate is a separate, self-contained check placed first — it doesn't change each object-scope gate's internals; it closes the gap around them.
  • Fail-closed is uniform: unparseable, no-SELECT, and parser-unavailable all refuse under enforce.
  • Inert when no tables are declared — a deployment with no declared surface isn't scoping (the object-scope gates behave the same); it adds no new exposure.
  • The gate never touches the datasource (it reads the parsed tree + the resolved model), so it produces the same verdict whether the model is file-served or DB-served — proven by extending the existing model-source parity test.

Test plan

  • Full gate green: uv run dev.py check — ruff, format, gitleaks, vendored-lib drift, and the pytest suite across py3.10–3.12.
  • New tests/test_scopable_gate.py (unit corpus: named tables / CTE / derived subquery / comma-join / set-op / SELECT 1 allow; table-functions / ROWS FROM / VALUES / UNNEST / LATERAL / LATERAL VIEW / unparseable / no-SELECT / parser-unavailable refuse; ctx-parity).
  • tests/test_ace051_fail_closed.py additions: the differential corpus refuses through the wired _model_safety under enforce; warn logs + allows; any non-warn posture 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.
  • Verified end-to-end on the real executor subprocess: enforce → a {"refusal":{"kind":"unscopable_sql"}} line + non-zero exit before any DB connection; warn → the log line, then proceeds.

Checklist

  • Full gate green (lint, format, tests, secret scan, vendored-lib drift)
  • Fail-closed by default (enforce); warn cannot silently be the default; both surfaces covered
  • No second SQL parser (reuses the single parse); single execute_sql enforcement point preserved
  • Path-agnostic — same fail-closed verdict on file-served vs DB-served models
  • Reviewed (code-review + security-review panel: no bypass found); findings dispositioned

Spec: ACE-037

🤖 Generated with Claude Code

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 | None to fail-closed on unscopable queries while reusing the existing single parse (ctx.tree).
  • Wires the gate first in both executor entrypoints’ _model_safety and adds AGAMI_SQL_UNSCOPABLE_POSTURE (enforce default, warn escape 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 ashwin-agami left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why even have a flag to warn? Why not just make it strict? Where do we have the use case for warning?

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated no new comments.

sandeep-agami and others added 2 commits July 12, 2026 19:00
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
sandeep-agami force-pushed the ACE-037-fail-closed-unscopable-sql branch from aa7ccff to a1e5065 Compare July 13, 2026 02:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants