Every Historical and Advisory answer stated a window in AEST and queried one
shifted by ten hours.
rolling_window() builds its boundary strings in SITE_TIMEZONE - that is the
whole point of it, and its docstring says so. metrics.run() then posted the
query to Cube with no timezone at all, and Cube defaults to UTC. So
"2026-08-14T15:22:13" meant 15:22 Sydney to the code that produced it and 15:22
UTC to the engine that ran it, and MetricResult.time_window reported
SITE_TIMEZONE from config rather than whatever the query actually used, so the
two could not disagree visibly.
Measured on the fixtures, same dateRange, one field changed:
timezone UTC 8019 samples
timezone Australia/Sydney 8619 samples
600 samples. One per minute, ten hours, exactly the offset.
Nothing about the answer looked wrong. The prose was right, the count was a
real count, the window description was correctly formatted and correctly named
AEST. It was only visible by reading the Cube query in the UI's "show working"
panel - which is an argument for that panel existing, and an argument for
looking at the thing in a browser rather than trusting curl against the API.
- check_cube_query() now takes site_timezone and pins it onto the query, at
the single point every Cube query passes through. Per-query-builder is the
wrong place: "remember to set the timezone" is not a control, and this
defect is what forgetting looks like. An explicit timezone already on the
query is left alone.
- time_window now reports capped["timezone"] - the timezone the query ran in,
not the one it should have run in.
An unpinned timezone belongs in the same guardrail as an unpinned date range,
and for the same reason: both make an answer unreproducible. The difference is
that an unpinned date range is obvious in the query and an unpinned timezone
is invisible.
eval case H28 records it. Two unit tests: the timezone is pinned, and an
explicit one is not overridden.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
234 lines
8.3 KiB
Python
234 lines
8.3 KiB
Python
"""SQL allow-list, resource caps, and contract enforcement.
|
|
|
|
Three jobs, in order of how much they matter:
|
|
|
|
1. Nothing but a single bounded SELECT reaches a database. Enforced with
|
|
sqlglot on the parsed tree, not a regex over the string - a regex over SQL
|
|
is a suggestion.
|
|
2. Every query is capped: row limit and statement timeout.
|
|
3. Every contract rejection is logged to Langfuse with the offending output,
|
|
so the failure is visible rather than silently regenerated away.
|
|
|
|
The database role is the FIRST line of defence (agent_ro holds SELECT and
|
|
nothing else, see db/003_roles.sql). This module is the second. Neither is
|
|
sufficient alone: the role stops writes, this stops a SELECT that scans imh for
|
|
a year, and only the role stops a bug here from becoming a write.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from dataclasses import dataclass, field
|
|
from typing import Any
|
|
|
|
import sqlglot
|
|
from sqlglot import exp
|
|
|
|
from contracts import ContractViolation, QuestionClass
|
|
|
|
log = logging.getLogger("guardrails")
|
|
|
|
|
|
class GuardrailViolation(Exception):
|
|
"""A query was refused before execution."""
|
|
|
|
def __init__(self, rule: str, detail: str) -> None:
|
|
super().__init__(f"{rule}: {detail}")
|
|
self.rule = rule
|
|
self.detail = detail
|
|
|
|
|
|
# Tables the agent may read. Anything else is refused, including tables that
|
|
# exist and would be harmless - an allow-list that grows silently is not one.
|
|
ALLOWED_TABLES: set[str] = {
|
|
"equipment",
|
|
"tags",
|
|
"doc_chunks",
|
|
"fixture.alarm_history",
|
|
"fixture.process_value_history",
|
|
"fixture.operation_history",
|
|
}
|
|
|
|
# Built from names so a sqlglot upgrade that renames or removes a node type
|
|
# fails loudly at import rather than silently dropping a check.
|
|
_FORBIDDEN_NAMES = [
|
|
"Insert", "Update", "Delete", "Drop", "Create", "Alter", "Merge",
|
|
"Command", "Copy", "Grant",
|
|
]
|
|
_FORBIDDEN = tuple(
|
|
node for node in (getattr(exp, name, None) for name in _FORBIDDEN_NAMES) if node
|
|
)
|
|
|
|
# A top-level node that is a legitimate read. exp.Command covers anything
|
|
# sqlglot could not classify, and it is in the forbidden list above.
|
|
_READ_NODES = tuple(
|
|
node
|
|
for node in (getattr(exp, name, None) for name in ("Select", "Union", "With"))
|
|
if node
|
|
)
|
|
|
|
|
|
def check_sql(sql: str, *, max_rows: int, dialect: str = "postgres") -> str:
|
|
"""Parse, validate and return the SQL to execute, with a LIMIT applied.
|
|
|
|
Refuses anything that is not exactly one SELECT over allow-listed tables.
|
|
"""
|
|
try:
|
|
statements = sqlglot.parse(sql, dialect=dialect)
|
|
except Exception as exc:
|
|
raise GuardrailViolation("unparseable", str(exc)) from exc
|
|
|
|
statements = [s for s in statements if s is not None]
|
|
if len(statements) != 1:
|
|
raise GuardrailViolation(
|
|
"multiple_statements", f"{len(statements)} statements in one query"
|
|
)
|
|
|
|
stmt = statements[0]
|
|
if not isinstance(stmt, _READ_NODES):
|
|
raise GuardrailViolation("not_a_select", f"top level node is {type(stmt).__name__}")
|
|
|
|
for node in stmt.walk():
|
|
if isinstance(node, _FORBIDDEN):
|
|
raise GuardrailViolation("write_operation", type(node).__name__)
|
|
|
|
for table in stmt.find_all(exp.Table):
|
|
name = f"{table.db}.{table.name}" if table.db else table.name
|
|
if name.lower() not in {t.lower() for t in ALLOWED_TABLES}:
|
|
raise GuardrailViolation("table_not_allowed", name)
|
|
|
|
# Cap the rows. An explicit smaller limit is honoured; a larger one is not.
|
|
existing = stmt.args.get("limit")
|
|
if existing is None:
|
|
stmt = stmt.limit(max_rows)
|
|
else:
|
|
try:
|
|
if int(existing.expression.name) > max_rows:
|
|
stmt = stmt.limit(max_rows)
|
|
except (AttributeError, ValueError):
|
|
stmt = stmt.limit(max_rows)
|
|
|
|
return stmt.sql(dialect=dialect)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Cube query caps. Cube generates its own SQL, so check_sql does not apply -
|
|
# what is validated instead is the query object the agent asked for.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def check_cube_query(
|
|
query: dict[str, Any], *, max_rows: int, site_timezone: str | None = None
|
|
) -> dict[str, Any]:
|
|
"""Cap a Cube query, require an explicit time window, and pin the timezone.
|
|
|
|
An unpinned time window is the single most common way a data answer becomes
|
|
unreproducible: imh is live, so the same question asked twice gives two
|
|
answers and neither can be checked.
|
|
|
|
An unpinned TIMEZONE is worse, because it does not look unpinned. Cube
|
|
defaults to UTC, while rolling_window() builds its boundary strings in
|
|
SITE_TIMEZONE - so a query without a timezone silently runs over a window
|
|
shifted by the site's UTC offset while the answer states the local one. Ten
|
|
hours, at this site. It is set here, at the one point every Cube query
|
|
passes through, rather than in each query builder, because "remember to add
|
|
the timezone" is not a control.
|
|
"""
|
|
capped = dict(query)
|
|
limit = capped.get("limit")
|
|
if not isinstance(limit, int) or limit > max_rows:
|
|
capped["limit"] = max_rows
|
|
|
|
if site_timezone and not capped.get("timezone"):
|
|
capped["timezone"] = site_timezone
|
|
|
|
time_dimensions = capped.get("timeDimensions") or []
|
|
if not time_dimensions:
|
|
raise GuardrailViolation(
|
|
"unpinned_time_window",
|
|
"every Cube query must carry an explicit timeDimensions range",
|
|
)
|
|
for td in time_dimensions:
|
|
if not td.get("dateRange"):
|
|
raise GuardrailViolation(
|
|
"unpinned_time_window", f"no dateRange on {td.get('dimension')}"
|
|
)
|
|
return capped
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Contract enforcement — generate, validate, regenerate ONCE, then error.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@dataclass
|
|
class EnforcementResult:
|
|
answer: Any
|
|
attempts: int
|
|
violations: list[ContractViolation] = field(default_factory=list)
|
|
|
|
|
|
def enforce_contract(generate, klass: QuestionClass, *, trace=None) -> EnforcementResult:
|
|
"""Run `generate()` until its output satisfies the class contract.
|
|
|
|
`generate(attempt, previous_violation)` returns a dict payload.
|
|
|
|
One retry. Not two, not "until it works" - a model that fails a safety
|
|
contract twice is not going to be argued into compliance, and each retry
|
|
costs a flagship call. The second failure raises, and the caller returns an
|
|
error to the operator.
|
|
"""
|
|
from contracts import validate_answer # local import keeps the cycle out
|
|
|
|
violations: list[ContractViolation] = []
|
|
previous: ContractViolation | None = None
|
|
|
|
for attempt in (1, 2):
|
|
payload = generate(attempt, previous)
|
|
try:
|
|
answer = validate_answer(payload, klass)
|
|
return EnforcementResult(answer=answer, attempts=attempt, violations=violations)
|
|
except ContractViolation as violation:
|
|
violations.append(violation)
|
|
previous = violation
|
|
log_violation(violation, klass, attempt, trace=trace)
|
|
|
|
raise violations[-1]
|
|
|
|
|
|
def log_violation(
|
|
violation: ContractViolation,
|
|
klass: QuestionClass,
|
|
attempt: int,
|
|
*,
|
|
trace=None,
|
|
) -> None:
|
|
"""Every rejection goes to Langfuse WITH the offending output.
|
|
|
|
The offending output is the whole value of the log line - a count of
|
|
violations tells you nothing about what the model tried to say. It stays
|
|
inside Langfuse, which is behind Authelia; it never reaches the operator
|
|
and never goes in an error message.
|
|
"""
|
|
log.warning(
|
|
"contract_violation class=%s attempt=%s rule=%s detail=%s",
|
|
klass.value,
|
|
attempt,
|
|
violation.rule,
|
|
violation.detail,
|
|
)
|
|
if trace is not None:
|
|
try:
|
|
trace.event(
|
|
name="contract_violation",
|
|
level="WARNING",
|
|
metadata={
|
|
"question_class": klass.value,
|
|
"attempt": attempt,
|
|
"rule": violation.rule,
|
|
"detail": violation.detail,
|
|
},
|
|
input=violation.offending_output,
|
|
)
|
|
except Exception: # tracing must never break the request path
|
|
log.exception("failed to record contract violation in Langfuse")
|