Skip to content

Commit 18bb8ec

Browse files
feat(safety): scope execute_sql to semantic-model tables (#91)
Add a deterministic table-scope gate to the shared model-safety pass so a query run through the engine may only reference tables the semantic model declares. Any other table in the connected database is refused — closing the gap where execute_sql would happily read arbitrary tables and the model only affected the trust receipt / join-trap detection, never table access. - runtime.check_table_scope(sql, org): parses with sqlglot, matches referenced physical tables (bare name, case-insensitive) against _model_table_index. CTE names and derived-subquery aliases are excluded (not tables). Excluded (review_state='rejected') tables are dropped by the loader, so they fall into the same "not declared -> refuse" path. Degrades to allow when sqlglot is unavailable / SQL doesn't parse / the model declares no tables — same posture as the fan/chasm and sensitive-projection gates. - execute_sql._model_safety: wire it in as the FIRST gate (before fan/chasm and sensitive), reusing the exit-1 + stderr-JSON refusal mechanism, so it surfaces to the MCP client exactly like preflight_refused / sensitive_columns (no tools.py change). Enforcement is refuse-always; only --no-safety bypasses. - Reach matches the existing model-safety guards: runs in the full-package / self-hosted server path; no-ops in the stdlib-only vendored plugin slice (runtime.py is intentionally not vendored). - Vendored plugins/agami/lib/execute_sql.py regenerated via dev.py sync-lib. - 12 unit tests (tests/test_table_scope_gate.py); full suite 1327 passed. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent ca963bc commit 18bb8ec

4 files changed

Lines changed: 197 additions & 0 deletions

File tree

packages/agami-core/src/execute_sql.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -728,6 +728,16 @@ def _model_safety(sql: str, profile: str, area: str | None):
728728
sys.stderr.write(f"[agami] could not load semantic model; skipping safety pass: {e}\n")
729729
return sql, None
730730

731+
# Table-scope guard — a query may only reference tables the semantic model
732+
# declares; any other table in the connected database is refused. Runs FIRST
733+
# so the fan/chasm and sensitive checks below only evaluate in-scope tables.
734+
ts = RT.check_table_scope(sql, org)
735+
if ts.action == "refuse":
736+
json.dump({"error": {"kind": "table_out_of_scope", "tables": ts.offending_tables,
737+
"reason": ts.reason, "suggestion": ts.suggestion}}, sys.stderr)
738+
sys.stderr.write("\n")
739+
return sql, 1
740+
731741
pf = RT.pre_flight_check(sql, org)
732742
if pf.risk and pf.action == "refuse":
733743
json.dump({"error": {"kind": "preflight_refused", "risk": pf.risk,

packages/agami-core/src/semantic_model/runtime.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -480,6 +480,81 @@ def check_sensitive_projection(sql: str, org: Organization) -> SensitiveCheckRes
480480
)
481481

482482

483+
# ---------------------------------------------------------------------------
484+
# Table-scope guard
485+
#
486+
# Enforced in the SAME shared safety pass as the fan/chasm pre-flight and the
487+
# sensitive-column guard (execute_sql.py:_model_safety), so EVERY entry point
488+
# that runs SQL through the engine only ever touches tables the semantic model
489+
# declares — a query referencing any other table in the connected database is
490+
# refused, by construction rather than by each LLM obeying a prose rule. This is
491+
# table-level scoping only; which columns of a modeled table may be projected is
492+
# the sensitive-projection guard's job.
493+
# ---------------------------------------------------------------------------
494+
495+
496+
@dataclass
497+
class TableScopeResult:
498+
action: str # "allow" | "refuse"
499+
offending_tables: list[str] = field(default_factory=list) # bare names not in the model
500+
reason: str = ""
501+
suggestion: Optional[str] = None
502+
503+
def as_dict(self) -> dict[str, Any]:
504+
return {"action": self.action, "offending_tables": self.offending_tables,
505+
"reason": self.reason, "suggestion": self.suggestion}
506+
507+
508+
def check_table_scope(sql: str, org: Organization) -> TableScopeResult:
509+
"""Refuse a query that references a table not declared in the semantic model.
510+
511+
Only *physical* table references count: CTE names (defined by WITH) and
512+
derived/subquery aliases are not tables and are never treated as undeclared.
513+
Matching is on the bare table name, case-insensitively (unquoted identifiers
514+
fold case in Postgres and friends), against the model's declared tables via
515+
`_model_table_index`, whose keys already exclude review_state='rejected'
516+
tables (dropped at load time) — so an excluded table is correctly refused.
517+
518+
Degrades to allow when sqlglot is unavailable or the SQL doesn't parse (the
519+
same posture as the fan/chasm and sensitive gates; the upstream read-only
520+
guard already rejects multi-statement / DDL input). A model with zero
521+
declared tables also allows — there is nothing to scope against.
522+
"""
523+
if not _HAVE_SQLGLOT:
524+
return TableScopeResult("allow")
525+
allow = {name.lower() for name in _model_table_index(org)}
526+
if not allow:
527+
return TableScopeResult("allow")
528+
try:
529+
tree = sqlglot.parse_one(sql, error_level="ignore")
530+
except Exception:
531+
return TableScopeResult("allow")
532+
if tree is None or not isinstance(tree, exp.Select):
533+
return TableScopeResult("allow")
534+
535+
cte_names = {c.alias_or_name.lower() for c in tree.find_all(exp.CTE)}
536+
offending: set[str] = set()
537+
for tbl in tree.find_all(exp.Table):
538+
name = tbl.name
539+
if not name or name.lower() in cte_names:
540+
continue # a CTE reference, not a physical table
541+
if name.lower() not in allow:
542+
offending.add(name)
543+
if not offending:
544+
return TableScopeResult("allow")
545+
546+
tables = sorted(offending)
547+
return TableScopeResult(
548+
"refuse",
549+
offending_tables=tables,
550+
reason="query references table(s) not in the semantic model: "
551+
+ ", ".join(tables)
552+
+ " — only tables declared in the model may be queried.",
553+
suggestion="Add the table to the model (agami-connect / '/agami-model'), "
554+
"or remove it from the query.",
555+
)
556+
557+
483558
def _cardinality_index(org: Organization) -> list[Relationship]:
484559
rels: list[Relationship] = []
485560
for sa in org.subject_areas:

plugins/agami/lib/execute_sql.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -728,6 +728,16 @@ def _model_safety(sql: str, profile: str, area: str | None):
728728
sys.stderr.write(f"[agami] could not load semantic model; skipping safety pass: {e}\n")
729729
return sql, None
730730

731+
# Table-scope guard — a query may only reference tables the semantic model
732+
# declares; any other table in the connected database is refused. Runs FIRST
733+
# so the fan/chasm and sensitive checks below only evaluate in-scope tables.
734+
ts = RT.check_table_scope(sql, org)
735+
if ts.action == "refuse":
736+
json.dump({"error": {"kind": "table_out_of_scope", "tables": ts.offending_tables,
737+
"reason": ts.reason, "suggestion": ts.suggestion}}, sys.stderr)
738+
sys.stderr.write("\n")
739+
return sql, 1
740+
731741
pf = RT.pre_flight_check(sql, org)
732742
if pf.risk and pf.action == "refuse":
733743
json.dump({"error": {"kind": "preflight_refused", "risk": pf.risk,

tests/test_table_scope_gate.py

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
"""Table-scope guard: a query may only reference tables the semantic model declares.
2+
3+
Runs in the SAME shared safety pass as the fan/chasm pre-flight and the sensitive
4+
gate (execute_sql.py:_model_safety), so every engine entry point refuses a query
5+
that touches a table outside the model — not just whichever path obeyed a prose
6+
rule. Only physical table refs count; CTE names and derived-subquery aliases are
7+
not tables. Excluded (review_state='rejected') tables are dropped by the loader,
8+
so they never reach `_model_table_index` and land in the same "not declared →
9+
refuse" path exercised here via undeclared names.
10+
"""
11+
12+
from __future__ import annotations
13+
14+
import sys
15+
from pathlib import Path
16+
17+
import pytest
18+
19+
pytest.importorskip("pydantic")
20+
pytest.importorskip("sqlglot")
21+
22+
REPO_ROOT = Path(__file__).resolve().parent.parent
23+
sys.path.insert(0, str(REPO_ROOT / "plugins" / "agami" / "scripts"))
24+
25+
from semantic_model import models as m # noqa: E402
26+
from semantic_model import runtime as rt # noqa: E402
27+
28+
29+
def _scope_org():
30+
"""Org declaring exactly two tables: orders, customers."""
31+
def _t(name):
32+
return m.Table(name=name, schema="public", storage_connection="c", grain=["id"],
33+
description=name, columns=[m.Column(name="id", type="integer")])
34+
return m.Organization(organization="Shop",
35+
subject_areas=[m.SubjectArea(name="sales",
36+
tables_defined=[_t("orders"), _t("customers")])])
37+
38+
39+
def test_declared_table_allowed():
40+
assert rt.check_table_scope("SELECT * FROM orders", _scope_org()).action == "allow"
41+
42+
43+
def test_undeclared_table_refused():
44+
res = rt.check_table_scope("SELECT * FROM sqlite_master", _scope_org())
45+
assert res.action == "refuse"
46+
assert res.offending_tables == ["sqlite_master"]
47+
assert "sqlite_master" in res.reason
48+
49+
50+
def test_join_all_declared_allowed():
51+
res = rt.check_table_scope(
52+
"SELECT * FROM orders o JOIN customers c ON o.customer_id = c.id", _scope_org())
53+
assert res.action == "allow"
54+
55+
56+
def test_join_with_undeclared_refused_lists_only_bad_one():
57+
res = rt.check_table_scope(
58+
"SELECT * FROM orders o JOIN payments p ON p.order_id = o.id", _scope_org())
59+
assert res.action == "refuse"
60+
assert res.offending_tables == ["payments"]
61+
62+
63+
def test_cte_reference_allowed():
64+
# `t` is a CTE name, not a physical table — must not be flagged.
65+
res = rt.check_table_scope(
66+
"WITH t AS (SELECT * FROM orders) SELECT * FROM t", _scope_org())
67+
assert res.action == "allow"
68+
69+
70+
def test_cte_body_referencing_undeclared_refused():
71+
res = rt.check_table_scope(
72+
"WITH t AS (SELECT * FROM secret_table) SELECT * FROM t", _scope_org())
73+
assert res.action == "refuse"
74+
assert res.offending_tables == ["secret_table"]
75+
76+
77+
def test_subquery_alias_allowed():
78+
# derived-table alias `x` is not a table; the inner `orders` is declared.
79+
res = rt.check_table_scope("SELECT * FROM (SELECT id FROM orders) x", _scope_org())
80+
assert res.action == "allow"
81+
82+
83+
def test_schema_qualified_declared_allowed():
84+
assert rt.check_table_scope("SELECT * FROM public.orders", _scope_org()).action == "allow"
85+
86+
87+
def test_case_insensitive_match():
88+
assert rt.check_table_scope("SELECT * FROM ORDERS", _scope_org()).action == "allow"
89+
90+
91+
def test_empty_model_allows():
92+
org = m.Organization(organization="Empty", subject_areas=[m.SubjectArea(name="s")])
93+
assert rt.check_table_scope("SELECT * FROM anything", org).action == "allow"
94+
95+
96+
def test_non_select_degrades_to_allow():
97+
# Non-SELECT is the upstream read-only guard's job; this gate defers (allow).
98+
assert rt.check_table_scope("DELETE FROM orders", _scope_org()).action == "allow"
99+
100+
101+
def test_unparseable_degrades_to_allow():
102+
assert rt.check_table_scope("SELECT FROM WHERE ((", _scope_org()).action == "allow"

0 commit comments

Comments
 (0)