{"slug": "show-hn-access-aware-text-to-sql-stop-llm-agents-overfetching-data", "title": "Show HN: Access-aware text-to-SQL – stop LLM agents overfetching data", "summary": "A developer released an open-source access-aware text-to-SQL guard that enforces user-specific data-access rules on SQL queries generated by LLM agents before execution. The deterministic guard parses proposed SQL, checks it against the asker's role, injects row filters, and either rewrites the query or denies it, preventing overfetching of sensitive data. The tool requires Python 3.14+ and is available on GitHub.", "body_md": "**A deterministic access guard for LLM text-to-SQL agents.** It enforces each user's real data-access rules on the SQL an agent generates — *before* the query runs — while keeping the agent aware of the full catalog so it can answer honestly about what it can't see.\n\nAn LLM agent that queries a database connects as *itself* — one privileged service identity — not as the person asking. So a well-formed `SELECT`\n\ncan return data the user was never entitled to, and neither the model nor the database notices: the query is authorized, it just *overfetches*. This repo shows the pattern that fixes it, with a small, readable, heavily-tested guard you can drop in front of any text-to-SQL agent.\n\nThree people ask the same bank assistant the same question — *\"profitability by business unit for Q2\"* — and each correctly gets a different answer:\n\n**Deterministic enforcement, outside the model.** The agent proposes SQL; a non-LLM guard parses it, checks it against the*asker's*role, injects row filters, and only then lets it run — or refuses. No prompt can talk it out of a decision, and it does not get less reliable as your policy grows.**Full catalog visibility.** The agent sees*every*table's name and description — including the ones it can't query — so it always knows what exists. Metadata is far less sensitive than data, and this is what keeps the agent from going blind.**Honest incompleteness.** Because it knows a restricted table exists, the agent can say*\"I can show revenue, but not profitability, because the cost table is outside your access\"*— instead of silently mislabeling revenue as profitability. It labels every answer`FULL`\n\n,`SCOPED`\n\n, or`INCOMPLETE`\n\n.\n\nRequires **Python 3.14+** and [uv](https://docs.astral.sh/uv/).\n\n```\nuv sync                                  # create .venv and install everything\n\n# Run the guard's test suite — 121 tests, no API key needed (pure deterministic Python)\nuv run pytest -q\n\n# Open the annotated showcase notebook (three personas, live agent vs. the guard)\nuv run jupyter notebook notebooks/showcase.ipynb\n```\n\nThe **guard and the tests need no API key.** Only the notebook's *live agent* cells call an LLM — copy `.env.example`\n\nto `.env`\n\nand set `ANTHROPIC_API_KEY`\n\nto run them; without it, those cells fall back to a static illustration.\n\n`sql_guard.py`\n\nis a single deterministic module. Everything goes through one call:\n\n``` python\nfrom sql_guard import SqlAccessGuard, DEMO_POLICY, DEMO_SCHEMA\n\nguard = SqlAccessGuard(DEMO_POLICY, DEMO_SCHEMA)\n\n# Dana is a \"revenue_analyst\": EMEA revenue only — no costs, no client identifiers.\nd = guard.check(\n    \"SELECT business_unit, SUM(revenue_amount) FROM fact_revenue GROUP BY business_unit\",\n    role=\"revenue_analyst\",\n)\nd.allowed            # True\nd.injected_filters   # [\"fact_revenue.region IN ('EMEA')\"]   <- welded in by the guard\nd.safe_sql           # the rewritten query, scoped to Dana's region, ready to execute\n\n# She reaches for the restricted cost table:\nd = guard.check(\"SELECT * FROM fact_direct_cost\", role=\"revenue_analyst\")\nd.allowed            # False\nd.reasons            # [\"role 'revenue_analyst' is not permitted to access table 'fact_direct_cost'\"]\n```\n\n`guard.check(sql, role)`\n\nreturns a `GuardDecision`\n\nwith `allowed`\n\n, `safe_sql`\n\n, `injected_filters`\n\n, `reasons`\n\n, and `referenced_tables`\n\n. It **never raises** — every parse error, unknown role, or unexpected construct returns a *denied* decision (fail-closed).\n\n| Path | What it is |\n|---|---|\n`sql_guard.py` |\nThe guard. Parse → policy check → row-filter injection → rewrite/deny. ~540 lines, sqlglot only. |\n`catalog.py` |\nBuilds the governed catalog: live schema introspection + a curated business overlay. |\n`agent_prompt.py` |\nThe access-aware agent's system prompt: metric definitions, honesty rules, `INCOMPLETE / SCOPED / FULL` report format. |\n`tests/test_guard.py` |\nBehavioral tests — documents what the guard is supposed to do (47). |\n`tests/test_bypass.py` |\nRed-team suite — attacks that try to leak data past the guard (74). |\n`notebooks/showcase.ipynb` |\nThe annotated end-to-end demo (see below). |\n`docs/ROADMAP.md` |\nDesign notes and not-yet-built ideas. |\n\n`notebooks/showcase.ipynb`\n\nbuilds the whole thing over a miniature investment-bank warehouse (Revenue, Direct costs, a Client registry, a Product catalog) and three roles:\n\n| Person | Role | May see |\n|---|---|---|\nFrank |\n`finance_controller` |\nEverything |\nBianca |\n`bu_manager` |\nRevenue + cost, her unit & region only |\nDana |\n`revenue_analyst` |\nRevenue only, EMEA only — no costs, no client identifiers |\n\nThe agent is built on LangChain; the user's `role`\n\nis injected via `InjectedState`\n\nso it **never appears in the tool schema the model sees** — the model cannot choose or spoof its own identity. The notebook shows:\n\n- the same profitability question producing\n`FULL`\n\n/`SCOPED`\n\n/`INCOMPLETE`\n\nfor the three roles; - a\n**naive agent**(same model, ungoverned tools, no guard) leaking cost data and client PII live, for contrast; - a\n**blind-catalog contrast** showing why the agent must*see*restricted tables (metadata) even though it can't*read*them (data).\n\nThe pipeline in `SqlAccessGuard.check`\n\n:\n\n- Parse with sqlglot; reject anything that isn't a single\n`SELECT`\n\n/ set-operation-of-`SELECT`\n\ns. - Reject\n`NATURAL JOIN`\n\n— its implicit join key never surfaces as an explicit column reference, so it can't be access-checked. `qualify`\n\nagainst the schema so`*`\n\n, aliases, and CTEs resolve to explicit, table-qualified columns.- Enforce the\n**table** allow-list, then the**column** allow-list (deny-by-default — anything not provably allowed is refused; a`*`\n\nthat expands to a forbidden column is denied, never silently dropped). - Inject\n`col IN (...)`\n\n**row filters** for the role. In a scope with an outer join, the filter is applied by wrapping the table at its source (`(SELECT * FROM t WHERE ...) AS t`\n\n) rather than in a WHERE/ON clause — an earlier version leaked out-of-region rows through an outer-join no-op predicate;`tests/test_bypass.py`\n\nlocks that fix in. - Render the rewritten AST back to SQL.\n\nDesign stance: **deny-by-default and fail-closed.** Only the standard library and sqlglot; no network, no LLM in the enforcement path.\n\n```\nuv run pytest -q                                   # all 121\nuv run pytest tests/test_bypass.py -q              # just the red-team suite\nuv run pytest tests/test_bypass.py -k \"natural\"    # a single attack family\n```\n\n`test_bypass.py`\n\nis the point: it loads the exact demo data into in-memory SQLite, sends hostile queries through the guard, executes the rewritten `safe_sql`\n\n, and asserts on the **rows actually returned** — column-block bypasses, outer-join tricks, set-operation smuggling, `NATURAL JOIN`\n\n, correlated subqueries. A guard is only as good as its refusal to be tricked, so the red-team suite is treated as part of the control, not incidental coverage.\n\n**Access-safe, not analytically correct.** The guard makes sure a query only*reads what it's allowed to*. Whether the agent computed the*right number*(correct grain, no fan-out) is a separate concern — that belongs to prompting and a semantic layer, not the guard.**Defense-in-depth, not the only control.** Pair it with engine-native row-level security as the hard backstop, bind identity through your IAM/SSO (never let the agent choose its own), and route decisions to your audit log. The guard closes the gap where the agent's identity isn't the user's; the engine closes the gap a parser bug might miss.**Metadata visibility is itself governed** in the general case. Here every role sees every table name; where table*names*are sensitive, filter the catalog per role as a layer on top.\n\nA deterministic parse-and-rewrite guard is an established pattern; this repo's contribution is the *combination* — the guard plus full catalog visibility plus honest incompleteness, integrated into the agent. Credit where due:\n\n[Thales Group](https://github.com/ThalesGroup/sql-data-guard)— an open-source SQL guard on the same parser (sqlglot).`sql-data-guard`\n\n[HeimdaLLM](https://github.com/amoffat/HeimdaLLM)— \"no AI in the enforcement path,\" static grammars and parsers.[LangShield (Pedro et al., ICSE 2025)](https://syssec.dpss.inesc-id.pt/papers/pedro_icse25.pdf)— peer-reviewed SQL-rewriting defense, measured at sub-2ms, recommended within defense-in-depth.\n\n[MIT](/sparklingneuronics/access-aware-text-to-sql/blob/main/LICENSE).", "url": "https://wpnews.pro/news/show-hn-access-aware-text-to-sql-stop-llm-agents-overfetching-data", "canonical_source": "https://github.com/sparklingneuronics/access-aware-text-to-sql", "published_at": "2026-07-07 08:51:21+00:00", "updated_at": "2026-07-07 08:59:41.770269+00:00", "lang": "en", "topics": ["large-language-models", "ai-safety", "ai-tools", "ai-agents"], "entities": ["Anthropic", "GitHub"], "alternates": {"html": "https://wpnews.pro/news/show-hn-access-aware-text-to-sql-stop-llm-agents-overfetching-data", "markdown": "https://wpnews.pro/news/show-hn-access-aware-text-to-sql-stop-llm-agents-overfetching-data.md", "text": "https://wpnews.pro/news/show-hn-access-aware-text-to-sql-stop-llm-agents-overfetching-data.txt", "jsonld": "https://wpnews.pro/news/show-hn-access-aware-text-to-sql-stop-llm-agents-overfetching-data.jsonld"}}