Skip to content

fix: volatility-gate unknown functions, allow EXPLAIN ANALYZE on SELECTs (restricted mode) - #218

Closed
eculver wants to merge 1 commit into
crystaldba:mainfrom
sfcompute:upstream-volatility-gate
Closed

eculver wants to merge 1 commit into
crystaldba:mainfrom
sfcompute:upstream-volatility-gate

Conversation

@eculver

@eculver eculver commented Aug 28, 2026

Copy link
Copy Markdown

Problem

The restricted-mode SQL validator (SafeSqlDriver) rejects more than the read-only boundary requires, in two ways:

  1. Any function outside the static ALLOWED_FUNCTIONS list is hard-rejected. Databases with custom read-only functions (IMMUTABLE/STABLE SQL or PL/pgSQL helpers) can't call them at all in restricted mode, even though such functions cannot write or carry side effects.
  2. EXPLAIN ANALYZE is rejected categorically, which makes the explain_query tool's own analyze=true parameter unusable in restricted mode — the tool offers a flag the validator can never accept.

Change

  1. Unknown functions are volatility-gated instead of hard-rejected. The parse walk collects function names not on the static allowlist; before executing, a single pg_proc probe allows them only when every overload of the name is proven non-volatile (provolatile <> 'v'). Volatile names (pg_sleep, pg_read_file, lo_import, ...) and names absent from pg_proc stay rejected — fail closed, including when the probe itself fails or returns nothing.
  2. EXPLAIN ANALYZE is allowed when the explained statement is a SELECT. The recursive walk still validates the inner statement (including the new function gate); non-SELECT inner statements are still rejected.

Why this is safe

The volatility proof is enforced by Postgres itself, not by trust in the allowlist: a non-volatile function that attempts a write errors server-side, and SafeSqlDriver continues to execute everything inside a forced read-only transaction (force_readonly=True). Mislabeled volatility on untrusted-language functions requires superuser to create in the first place. Operators and casts (which can invoke functions without FuncCall parse nodes) are outside this change's surface — behavior there is identical before and after.

Testing

Each new branch has a driving test in tests/unit/sql/test_safe_sql.py: proven-non-volatile allow, volatile-overload reject, absent-from-pg_proc reject (schema-qualified), probe-returns-None fail-closed, partial-proof rejects only the unproven names, EXPLAIN ANALYZE SELECT allow / non-SELECT reject, and probe contents (allowlisted names are never probed).

uv run pytest tests/unit — 198 passed, 1 xfailed; ruff check / ruff format --check clean (validated against this repo's lockfile).

🤖 Generated with Claude Code

…CTs (restricted mode)

Functions outside the static ALLOWED_FUNCTIONS list are no longer hard-rejected:
the validator collects them and allows execution only when pg_proc proves every
overload of the name non-volatile (IMMUTABLE/STABLE cannot write or carry side
effects). Volatile names (pg_sleep, pg_read_file, lo_import, ...) and names
absent from pg_proc stay rejected — fail closed. EXPLAIN ANALYZE is now allowed
when the explained statement is a SELECT; the recursive walk still validates the
inner statement and execution stays inside the forced read-only transaction.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
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.

2 participants