fix: harden SQL classification for the tool CLI and MCP server
Issue for this PR
Stacked on #1609 (base tool-cli-daemon). It holds the classifier hardening from later review rounds of #1609, kept out of that PR after it was approved. Most of these gaps also exist on main.
Type of change
- Bug fix
- New feature
- Refactor / code improvement
- Documentation
What does this PR do?
#1609 exposes the SQL classifier to outside agents (MCP server, altimate tool run): reads run by default, writes need --allow-write, and DROP DATABASE / DROP SCHEMA / TRUNCATE are refused even then. This PR fixes cases where the classifier read the SQL differently from the warehouse.
sql_explain: newly reachable through #1609; its tool code is unchanged from main.
- It sent the caller's SQL to the warehouse unchecked. On Redshift and DuckDB the driver runs every statement in the text, so
SELECT 1; DELETE FROM tran the DELETE without--allow-write. - Input starting with
ANALYZEbecameEXPLAIN ANALYZE, which executes the statement. - It now explains a single statement only, refuses EXPLAIN options in its input, and refuses blocked SQL.
Writes that were read as reads (on main too):
- Snowflake
->>statement chains. - dbt Jinja anywhere in the SQL on the dbt route.
{% do run_query(...) %}runs at compile time, even inside a comment. - Spark/Hive
TRANSFORM ... USING. - Functions that write or call outside systems:
- ClickHouse
jdbc - Spark
reflectandjava_method - Databricks
http_requestandremote_query - DuckDB
bigquery_execute,ducklake_*,postgres_attachandsqlite_attach - Snowflake
GETNEXTVAL
- ClickHouse
- Sequence advances (
.NEXTVAL), including quoted or commented forms, in Oracle, Snowflake and MariaDB. - A quote inside a Spark or MySQL
/*+ hint */, Oracleq'...'quotes, a Spark--comment ending in a backslash, and a/*inside a comment where nesting is unknown. These now make the SQL uncertain, so it is refused rather than read. - ClickHouse
$tag$...$tag$heredocs are lexed as strings.
DROP DATABASE / DROP SCHEMA / TRUNCATE block (on main too). The block reads the SQL text, so it is best effort. docs/docs/usage/tool.md now says so, and recommends a database user without DROP/TRUNCATE for a hard guarantee. Within that:
- A write's literals are read as SQL, with escapes decoded as each engine decodes them. This covers
EXEC(...),sp_executesql,EXECUTE IMMEDIATE,PREPARE ... FROM,DO $$...$$,dblink_exec, and routine bodies created and called in one call. - TRUNCATE is found:
- at the start of a statement, a block (
BEGIN [NOT ATOMIC],REPEAT, ...) or a handler; - after
EXPLAIN (...)andPREPARE p AS; - after
THEN/ELSE, with quoted, dotted,db..torIF EXISTStargets andNOWAIT/WAIT n.
- at the start of a statement, a block (
DROP CATALOG,DROP NAMESPACE,DROP EXTERNAL SCHEMAandDROP PLUGGABLE DATABASEare blocked.
Regressions from #1609 fixed here. Each of these refused a valid read:
- A column or alias named
truncate, which was hard-blocked. SELECT 1 AS throwon SQL Server.- A column named
nextval.
Two consistency fixes:
data_diffreads an emptytarget_warehouseas the source warehouse in both layers.- The omitted-warehouse dbt dialect check follows the dbt adapter the daemon has already created.
Known limits (each refuses rather than lets through):
- A write whose literal mentions DROP followed by DATABASE/SCHEMA/CATALOG/NAMESPACE, or the word TRUNCATE, is refused even with
--allow-write. Example:INSERT INTO audit VALUES ('TRUNCATE'). - Privilege and trigger names (
GRANT TRUNCATE,BEFORE TRUNCATE) are refused. - PostgreSQL literals ending in a backslash are refused, since
standard_conforming_stringsmay be off. - Oracle
q'...'quotes are refused. - ClickHouse
jdbc(...)is a write, so reads through it need--allow-write.
Out of scope, a follow-up: enforcing the DROP/TRUNCATE block in the database, through a restricted role or a read-only transaction where the driver supports one. That would cover statements built at run time, which no text check can see.
How did you verify your code works?
bun run typecheckis clean.bun test test/altimate/tools test/altimate/tool-cli test/altimate/native test/altimate/sql-query-deadline.test.ts test/altimate/dbt-first-execution.test.ts: 1741 pass, 0 fail.- Each case above has a test:
- classifier cases per dialect in
sql-classify.test.ts; - runner tests for
sql_explainwith a fake connector, which receives nothing; sql_executetests for dbt Jinja (including a MongoDB default) insql-comment-safety.test.ts.
- classifier cases per dialect in
- Large crafted inputs stay linear, with timing tests.
- The changes went through repeated independent reviews (codex and claude).
- Manual end to end against a local DuckDB warehouse, on a copy of the database:
- #1609's head:
tool run sql_explainwithSELECT 1; DELETE FROM orders, without--allow-write, returned a plan and deleted all 100 rows. - This branch (13c911cb29):
- that input, and
ANALYZE DELETE FROM orders, are refused with "Only a single statement can be explained"; - a single-statement plan still works;
- the table keeps its 100 rows.
- that input, and
- #1609's head:
Cost side effects
None. No model calls, queries or cloud resources are added; the changes only refuse more SQL before it runs.
Screenshots / recordings
Not a UI change.
Checklist
- I have tested my changes locally
- I have not included unrelated changes in this PR
- I checked for cost side effects (model calls, queries or cloud resources that grow with user data) and described them, or wrote "none"
🤖 Generated with Claude Code
Summary by cubic
Hardens SQL classification for the tool CLI and MCP server so destructive statements are refused before they run, and valid reads stop getting blocked. The hard block on DROP DATABASE / DROP SCHEMA / TRUNCATE now covers more forms but is documented as best effort, since it reads SQL text: for a hard guarantee, connect with a database user lacking those privileges.
Bug Fixes
sql_explainnow explains a single statement per call and refuses EXPLAIN options in its input, since some drivers run every statement after the prefix and EXPLAIN ANALYZE executes the statement.- Refuses more writes that ran as reads: Snowflake
->>chains, dbt Jinja anywhere in the SQL on the dbt route, Spark/HiveTRANSFORM ... USING, and functions that write or call external systems (ClickHousejdbc, Sparkreflect/java_method, Databrickshttp_request/remote_query, DuckDBbigquery_execute/ducklake_*/postgres_attach/sqlite_attach, SnowflakeGETNEXTVAL). Reads through ClickHousejdbc(...)now need--allow-write. - Sequence advances through quoted or commented
NEXTVALin Oracle, Snowflake, and MariaDB count as writes. - The hard block reads a write's literals as SQL with escapes decoded per engine, finds TRUNCATE at statement, block, and handler starts and after THEN/ELSE with quoted, dotted, and IF EXISTS targets, and also blocks DROP CATALOG, DROP NAMESPACE, DROP EXTERNAL SCHEMA, and DROP PLUGGABLE DATABASE.
- Columns or aliases named
truncate,throw, andnextvalare reads again. data_diffreads an emptytarget_warehouseas the source warehouse; the omitted-warehouse dbt dialect check follows the adapter the daemon already created.
Summary by CodeRabbit
-
New Features
- Added a standalone
toolCLI for discovering and running data tools without a model, plus a stdio MCP server for compatible clients. - Added optional workspace tools for context, skills, and approved read-only operations, along with workspace client commands.
- Schema inspection can now list schemas and tables, and SQL execution supports batches of up to eight queries.
- Added workspace skill syncing for Claude Code and optional routing guidance in
CLAUDE.md. - Analyst responses now receive stronger checks for requested items, number sources, and ambiguous readings.
- Added a standalone
-
Improvements
- Expanded warehouse catalog discovery and MongoDB collection listings, with bounded results and counts.
- Strengthened read-only SQL protections across tools and supported databases.