9249f92db8
- Full resyncs mark mirror rows missing from the download as deleted;
YNAB omits deleted entities from non-delta responses, so deletions
made just before a full sync never reached the mirror.
- YNAB tools return errors as text instead of raising, since ToolNode
re-raises and would fail the whole chat turn. ynab_sql catches all
sqlglot errors (e.g. an unbalanced quote) and runs the SQL regenerated
from the validated tree, which also drops comments that broke the
LIMIT wrapper. Results keep duplicate column names.
- Sync fetches from YNAB before opening the DB transaction, discards the
payload if the other container synced meanwhile, re-checks freshness
under the lock so parallel tool calls share one sync, and backs off
for 60s after a failure.
- The mirror is pinned to one budget; a changed YNAB_BUDGET_ID errors
instead of mixing budgets, and an unset id stays on the budget the
first sync resolved from "last-used".
- Change log: no entries for empty month rollover rows, and id fields
are logged as names (category, payee, account). ynab_recent_changes
accepts the names it prints ("assignment") and plurals.
- Outputs sized to the 4000-char tool result cap; ynab_sql reuses the
shared admin message.
- YNAB API client moves back to utils/ynab_service.py, and the frontend
tool status labels list the new tools.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
173 lines
6.3 KiB
Python
173 lines
6.3 KiB
Python
"""Admin-only read-only SQL over the YNAB mirror tables.
|
|
|
|
Defense in depth: the query is parsed and must be a single SELECT touching
|
|
only ``ynab_*`` tables with no dangerous functions, then runs in a READ ONLY
|
|
transaction with a statement timeout and a row cap.
|
|
"""
|
|
|
|
import re
|
|
from decimal import Decimal
|
|
|
|
import sqlglot
|
|
from langchain_core.runnables import RunnableConfig
|
|
from langchain.tools import tool
|
|
from sqlglot import exp
|
|
from sqlglot.optimizer.scope import traverse_scope
|
|
from tortoise.transactions import in_transaction
|
|
|
|
from utils.mcp_tools import admin_only_message
|
|
|
|
from . import models
|
|
from .sync import ensure_fresh
|
|
from .tools import NOT_SYNCED, ynab_tool_errors
|
|
|
|
MAX_ROWS = 100
|
|
# Tool results are cut at 4000 chars in blueprints/conversation/logic.py
|
|
MAX_OUTPUT_CHARS = 3800
|
|
STATEMENT_TIMEOUT = "10s"
|
|
|
|
ALLOWED_TABLES = frozenset(
|
|
m._meta.db_table
|
|
for m in vars(models).values()
|
|
if isinstance(m, type) and issubclass(m, models.Model) and m is not models.Model
|
|
)
|
|
|
|
FORBIDDEN_NODES = (
|
|
exp.Insert,
|
|
exp.Update,
|
|
exp.Delete,
|
|
exp.Merge,
|
|
exp.Create,
|
|
exp.Drop,
|
|
exp.Alter,
|
|
exp.Command,
|
|
exp.Into,
|
|
)
|
|
|
|
FORBIDDEN_FUNCTIONS = re.compile(
|
|
r"^(pg_|lo_|dblink|set_config|current_setting|"
|
|
r"(query|table|cursor|schema|database)_to_xml|txid_|nextval|setval)",
|
|
re.IGNORECASE,
|
|
)
|
|
|
|
|
|
def validate_sql(query: str) -> str | None:
|
|
"""Return an error message if ``query`` isn't allowed, else None."""
|
|
return _check(query)[0]
|
|
|
|
|
|
def _check(query: str) -> tuple[str | None, str | None]:
|
|
"""Validate ``query``; return ``(error, sql_to_run)``.
|
|
|
|
The SQL that runs is regenerated from the validated parse tree (minus
|
|
comments), so Postgres executes exactly what was checked.
|
|
"""
|
|
try:
|
|
statements = [
|
|
s
|
|
for s in sqlglot.parse(query, read="postgres")
|
|
# a trailing "; -- comment" parses as a bare Semicolon
|
|
if s and not isinstance(s, exp.Semicolon)
|
|
]
|
|
except sqlglot.errors.SqlglotError as e:
|
|
return f"Could not parse SQL: {e}", None
|
|
if len(statements) != 1:
|
|
return "Exactly one SQL statement is allowed.", None
|
|
root = statements[0]
|
|
if not isinstance(root, exp.Query):
|
|
return "Only SELECT queries are allowed.", None
|
|
if root.find(*FORBIDDEN_NODES) or any(
|
|
s.args.get("locks") for s in root.find_all(exp.Select)
|
|
):
|
|
return "Only read-only SELECT queries are allowed.", None
|
|
|
|
# A name matching a CTE isn't necessarily a CTE reference (e.g. inside the
|
|
# CTE's own body, or outside the scope that defines it), so resolve each
|
|
# FROM source through sqlglot's scopes. Any table node that isn't a CTE
|
|
# name at all is checked too, in case scope analysis misses a construct.
|
|
try:
|
|
scopes = traverse_scope(root)
|
|
except Exception as e:
|
|
return f"Could not analyze SQL: {e}", None
|
|
cte_names = {cte.alias_or_name for cte in root.find_all(exp.CTE)}
|
|
tables = [
|
|
source
|
|
for scope in scopes
|
|
for source in scope.sources.values()
|
|
if isinstance(source, exp.Table)
|
|
]
|
|
tables += [t for t in root.find_all(exp.Table) if t.name not in cte_names]
|
|
for table in tables:
|
|
if isinstance(table.this, exp.Func): # e.g. generate_series(...)
|
|
continue
|
|
if table.catalog or table.db not in ("", "public"):
|
|
return f"Schema-qualified table '{table.sql()}' is not allowed.", None
|
|
if table.name not in ALLOWED_TABLES:
|
|
return (
|
|
f"Table '{table.name}' is not allowed. "
|
|
f"Available: {', '.join(sorted(ALLOWED_TABLES))}",
|
|
None,
|
|
)
|
|
|
|
for func in root.find_all(exp.Func):
|
|
name = func.name if isinstance(func, exp.Anonymous) else func.sql_name()
|
|
if FORBIDDEN_FUNCTIONS.match(name or ""):
|
|
return f"Function '{name}' is not allowed.", None
|
|
return None, root.sql(dialect="postgres", comments=False).rstrip("; ")
|
|
|
|
|
|
def _format_value(value) -> str:
|
|
if value is None:
|
|
return ""
|
|
if isinstance(value, Decimal):
|
|
value = (
|
|
format(value.normalize(), "f") if value == value.to_integral() else value
|
|
)
|
|
return str(value).replace("\t", " ").replace("\n", " ")
|
|
|
|
|
|
def format_rows(columns: list[str], rows: list[tuple]) -> str:
|
|
if not rows:
|
|
return "Query returned no rows."
|
|
lines = ["\t".join(columns)]
|
|
lines += ["\t".join(_format_value(v) for v in row) for row in rows]
|
|
text = "\n".join(lines)
|
|
if len(text) > MAX_OUTPUT_CHARS:
|
|
text = text[:MAX_OUTPUT_CHARS] + "\n...(output truncated; aggregate more)"
|
|
elif len(rows) == MAX_ROWS:
|
|
text += f"\n(capped at {MAX_ROWS} rows; aggregate or add LIMIT)"
|
|
return text
|
|
|
|
|
|
@tool
|
|
@ynab_tool_errors
|
|
async def ynab_sql(query: str, config: RunnableConfig) -> str:
|
|
"""Run a read-only Postgres SELECT over the YNAB mirror for questions the other YNAB tools can't answer. Tables: ynab_transactions (date, amount, payee_name, category_name, account_name, memo, transfer_account_id, deleted), ynab_subtransactions (split lines), ynab_accounts, ynab_categories, ynab_category_groups, ynab_payees, ynab_months, ynab_month_categories (month, category_id, budgeted=assigned, activity, balance=available), ynab_changes (edit history: synced_at, entity_type, action, label, changes JSON). Amounts are dollars; outflows negative. Filter NOT deleted.
|
|
|
|
Args:
|
|
query: A single PostgreSQL SELECT statement
|
|
"""
|
|
if not (config or {}).get("configurable", {}).get("is_admin"):
|
|
return admin_only_message("YNAB SQL")
|
|
error, sql = _check(query)
|
|
if error:
|
|
return error
|
|
if await ensure_fresh() is None:
|
|
return NOT_SYNCED
|
|
|
|
try:
|
|
async with in_transaction() as conn:
|
|
await conn.execute_query("SET TRANSACTION READ ONLY")
|
|
await conn.execute_query(
|
|
f"SET LOCAL statement_timeout = '{STATEMENT_TIMEOUT}'"
|
|
)
|
|
# Raw records rather than dicts, which would merge same-named
|
|
# columns (e.g. c.name and g.name)
|
|
_, records = await conn.execute_query(
|
|
f"SELECT * FROM ({sql}) AS q LIMIT {MAX_ROWS}"
|
|
)
|
|
except Exception as e:
|
|
return f"SQL error: {e}"
|
|
columns = list(records[0].keys()) if records else []
|
|
return format_rows(columns, [tuple(r.values()) for r in records])
|