Visitar URL original
fix: harden SQL classification for the tool CLI and MCP server by hkaltai · Pull Request #1644 · AltimateAI/altimate-code · GitHub
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/docs/usage/tool.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ Only the data tools are exposed. Coding tools (`bash`, `read`, `edit`, ...), `db

Runs are read-only by default. Reads such as `sql_execute` on a SELECT and `schema_inspect` are allowed; anything else is refused with `Permission denied: <kind>. Re-run with --allow-write to enable writes.` Tools that change local configuration (`warehouse_add`, `warehouse_remove`, `warehouse_install_driver`) also need `--allow-write`.

`DROP DATABASE`, `DROP SCHEMA` and `TRUNCATE` are refused even with `--allow-write`. The block reads the SQL text, so it is best effort: statements built at run time, for example by a stored procedure, are not seen. For a hard guarantee, connect with a database user that lacks `DROP` and `TRUNCATE`.
`DROP` of a database, schema, catalog or namespace (including `DROP EXTERNAL SCHEMA` and `DROP PLUGGABLE DATABASE`) and `TRUNCATE` are refused even with `--allow-write` when the SQL check recognises them. The check reads the SQL text, so it is best effort: a statement the warehouse builds or decodes at run time (dynamic SQL assembled by concatenation, a stored routine, an unusual literal encoding) can get past it. For a hard guarantee, connect with a database user that has no `DROP` or `TRUNCATE` privilege.

Tool results go to the model of the agent that called the tool. `sql_execute` returns result rows, `warehouse_list` shows one sample row per table of a local database, and `schema_inspect` and `sql_execute` with `tables` return value profiles per column; set `ALTIMATE_DISABLE_SCHEMA_VALUES=1` to leave the value profiles out. Keep passwords and keys out of `warehouse_add` arguments, which end up in the calling agent's transcript: reference credentials from a connections file or `ALTIMATE_CODE_CONN_*` variables instead.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,16 @@ export async function dbtProjectAdapterType(): Promise<{ project: boolean; type?
try {
if (existsSync(config.path())) saved = JSON.parse(readFileSync(config.path(), "utf8"))
} catch {}
projectRoot = saved === undefined ? config.findProjectRoot() : (saved?.projectRoot ?? null)
if (saved === undefined) projectRoot = config.findProjectRoot()
else projectRoot = typeof saved?.projectRoot === "string" ? saved.projectRoot : null
} catch {
projectRoot = null
}
}
// A root without dbt_project.yml is rejected by the routing, which then uses the registry default.
if (!projectRoot || !existsSync(path.join(projectRoot, "dbt_project.yml"))) return { project: false, targetTypes: [] }
// A root without dbt_project.yml is rejected by the routing, which then uses the registry default;
// an adapter already created keeps running even if the file is removed since.
if (!projectRoot || (dbtRoute.root === undefined && !existsSync(path.join(projectRoot, "dbt_project.yml"))))
return { project: false, targetTypes: [] }
const targetTypes = await profileTargetTypes(projectRoot)
try {
const { resolveDbtTargetPath } = await import("../../validators/validator-utils")
Expand Down
2 changes: 1 addition & 1 deletion packages/opencode/src/altimate/tools/data-diff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ export const DataDiffTool = Tool.define("data_diff", {
target: args.target,
key_columns: args.key_columns,
source_warehouse: args.source_warehouse,
target_warehouse: args.target_warehouse,
target_warehouse: args.target_warehouse || undefined, // "" is the source warehouse, as the ask resolves it

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MINOR (test gap): no test covers target_warehouse: "". The change is correct: the native layer uses target_warehouse ?? source_warehouse, so "" used to reach the handler as a warehouse name rather than meaning the source. A test that passes target_warehouse: "" and checks the dispatched params (and that the ask names the source warehouse) would lock this in.

Blocker: no, test-only.

@hkaltai hkaltai Nov 4, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in 6b5b356: a runner test passes target_warehouse: "" and checks that the dispatched data.diff params carry the source warehouse and no target_warehouse. The ask resolves target_warehouse || source_warehouse. No test checks the ask with "" yet, so the test is named for what it does check: the dispatch.

extra_columns: args.extra_columns,
algorithm: args.algorithm,
where_clause: args.where_clause,
Expand Down
182 changes: 149 additions & 33 deletions packages/opencode/src/altimate/tools/sql-classify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,13 +171,13 @@ function otherIsReadOnly(sql: string, parsed: Array<{ category: string }>, diale
// Hard-deny patterns — blocked regardless of permissions
const HARD_DENY_TYPES = new Set(["DROP DATABASE", "DROP SCHEMA", "TRUNCATE", "TRUNCATE TABLE"])

const HARD_DENY_PATTERN = /^\s*(DROP\s+(DATABASE|SCHEMA)\b|TRUNCATE(\s+TABLE)?\b)/i
const HARD_DENY_PATTERN = /^\s*(DROP\s+((EXTERNAL|PLUGGABLE)\s+)?(DATABASE|SCHEMA|CATALOG|NAMESPACE)\b|TRUNCATE(\s+TABLE)?\b)/i

/**
* Fail closed when the parser is unavailable or rejects the input. Lexical
* statement boundaries still enforce hard denies without reading quoted text.
*/
function classifyFallback(sql: string, dialect?: string): { queryType: "read" | "write"; blocked: boolean } {
function classifyFallback(sql: string, dialect?: string, write = true): { queryType: "read" | "write"; blocked: boolean } {
const statements = statementsOf(sql, dialect)
// Executable comments can contain entire statements or fragments of a hard
// deny. Reject them outright because generic parsers discard their bodies.
Expand All @@ -192,16 +192,20 @@ function classifyFallback(sql: string, dialect?: string): { queryType: "read" |
// comment), so the words alone decide; the TRUNCATE() function does not count.
const lexical = tokenizeSql(sql, dialect)
if (!lexical.complete) {
if (mentionsHardDeny(sql)) blocked = true
// Escapes decoded too: dynamic SQL may run a literal such as '\x44ROP SCHEMA s'.
if (mentionsHardDenyAnywhere(sql)) blocked = true
} else {
// Read with certainty: the statement words in code (literals, identifiers and comments masked,
// so DROP/**/SCHEMA reads as DROP SCHEMA) decide too, since T-SQL runs `SELECT 1 DROP DATABASE x`
// Read with certainty: the statement words in code (literals and comments masked, each quoted
// identifier a placeholder name, so DROP/**/SCHEMA reads as DROP SCHEMA) decide too, since T-SQL runs `SELECT 1 DROP DATABASE x`
// as two statements with no ; and a parser may read a literal differently from the engine.
const masked = maskSql(lexical.tokens)
if (CODE_HARD_DENY.test(masked)) blocked = true
// Dynamic SQL (EXEC('...'), EXECUTE IMMEDIATE, PREPARE ... FROM, DO $$...$$) runs a string, so
// the words in its literals count too. A statement built by concatenation is not seen.
else if (DYNAMIC_SQL.test(masked) && mentionsHardDeny(sql)) blocked = true
const names = namesKept(lexical.tokens)
// Snowflake's ->> chains statements (elsewhere a JSON operator), and Snowflake Scripting's ASYNC (,
// := ( and DEFAULT ( hold one (elsewhere DEFAULT(col) is a function).
const chained =
(/^snowflake$/i.test(dialect ?? "") || !isModeledDialect(dialect)) &&
/(->>|\bASYNC\s*\(|:=\s*\(|\bDEFAULT\s*\()\s*TRUNCATE\b(?!\s*\()/i.test(names)
if (CODE_HARD_DENY.test(names) || handlerTruncate(names) || chained || (write && WRITE_TRUNCATE.test(names)))
blocked = true
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MAJOR: dynamic SQL split across literals is no longer hard-blocked. main had, at this point:

else if (DYNAMIC_SQL.test(masked) && mentionsHardDeny(sql)) blocked = true

That reads the words of the whole text, so 'DROP ' + 'DATABASE x' counts. Its replacement, quotedHardDeny, checks each literal on its own, so the pair is never seen together. Each of these is blocked on main and write, blocked=false here, so it runs with --allow-write:

-- sqlserver
EXEC('DROP ' + 'DATABASE victim')
-- bigquery
EXECUTE IMMEDIATE CONCAT('DROP ', 'SCHEMA victim CASCADE')
-- mysql
SET @s = CONCAT('DROP ', 'DATABASE victim'); PREPARE p FROM @s; EXECUTE p
-- postgres: string constants separated by a newline are one string
DO 'BEGIN DROP '
'SCHEMA victim CASCADE; END'

Run: through mcp serve --allow-write on Postgres 18, the DO form is refused on main and drops the schema at this head. MySQL 8 drops the database with the PREPARE form. The docs now say concatenation can get past the check, but main caught these forms, so this is a regression of the hard block.

Fix: keep the base check next to the new one, e.g. if (DYNAMIC_SQL.test(maskSql(lexical.tokens)) && mentionsHardDenyAnywhere(sql)) blocked = true, and add the four inputs as blocked: true tests.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in b89c0b7. classifyAndCheck again has DYNAMIC_SQL.test(maskSql(tokens)) && mentionsHardDenyAnywhere(sql) next to quotedHardDeny. All four inputs (sqlserver EXEC with +, bigquery CONCAT, mysql PREPARE, postgres newline-joined DO) are blocked: true tests, and removing the check fails them. I also corrected the quotedHardDeny doc comment, which said concatenation is not seen. Side effect, now in Known limits: separate parameter values that together read as DROP ... CATALOG are refused, the same class as N'DATABASE' on main. Splitting inside a keyword ('DR' + 'OP DATABASE') still gets through, the same as on main.

return { queryType: "write", blocked }
}
Expand All @@ -217,26 +221,115 @@ function batchedStatement(masked: string, dialect?: string): boolean {
// or clauses (USE, FETCH, OPEN, CLOSE, ENABLE, DISABLE, RENAME, THROW, ADD, MOVE, SEND, RECEIVE, END)
// count only in their statement forms.
const BATCH_STATEMENT = new RegExp(
"\\b(INSERT|UPDATE|DELETE|MERGE|DROP|ALTER|CREATE|TRUNCATE|EXEC|EXECUTE|GRANT|REVOKE|DENY|BACKUP|RESTORE|DBCC|KILL|SHUTDOWN|BULK|WAITFOR|DECLARE|SET|INTO|" +
"\\bTRUNCATE\\b(?!\\s*\\()|\\b(INSERT|UPDATE|DELETE|MERGE|DROP|ALTER|CREATE|EXEC|EXECUTE|GRANT|REVOKE|DENY|BACKUP|RESTORE|DBCC|KILL|SHUTDOWN|BULK|WAITFOR|DECLARE|SET|INTO|" +
"OPENROWSET|OPENQUERY|OPENDATASOURCE|OPENXML|CHECKPOINT|RECONFIGURE|RAISERROR|GOTO|BEGIN|COMMIT|ROLLBACK|SAVE|DEALLOCATE|" +
"READTEXT|UPDATETEXT|WRITETEXT|SETUSER|REVERT|PRINT)\\b" +
"|\\b(ENABLE|DISABLE)\\s+TRIGGER\\b|\\bRENAME\\s+(OBJECT|DATABASE)\\b|\\bTHROW\\s*($|;|[\\d@])" +
"|\\b(ENABLE|DISABLE)\\s+TRIGGER\\b|\\bRENAME\\s+(OBJECT|DATABASE)\\b|\\bTHROW\\s+[\\d@]" +
// USE except OPTION (USE HINT/PLAN) and USE INDEX; FETCH except OFFSET paging (FETCH NEXT 10 ROWS ONLY).
Comment on lines 225 to 228

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[🟠 MEDIUM] Potential Regression in THROW Statement Matching

The regular expression for THROW was updated from \\bTHROW\\s*($|;|[\\d@]) to \\bTHROW\\s+[\\d@].

This change drops support for matching a bare THROW; statement or THROW at the end of a script (often used to re-throw exceptions in CATCH blocks). This could cause valid control-flow or write scripts in T-SQL to be misclassified as read-only.

Please verify if bare THROW statements are still supported or if the regex needs to be reverted to include ($|;|[\\d@]).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bare THROW is still classified as a write: 'THROW;' and 'BEGIN CATCH THROW; END CATCH' both return write on tsql at HEAD. The narrower pattern only stops 'throw' used as an identifier from matching.

"|\\bUSE\\b(?!\\s+(HINT|PLAN|INDEX|KEY)\\b)|\\bFETCH\\b(?!\\s+(FIRST|NEXT)\\s+([\\w@().+-]+\\s+)?(PERCENT\\s+)?ROWS?\\b)" +
"|\\b(OPEN|CLOSE)\\s+(GLOBAL\\s+)?[\\w@#]+\\s*(;|\\b(SELECT|WITH)\\b)|\\bADD\\s+(SENSITIVITY|SIGNATURE|COUNTER)\\b" +
"|\\bMOVE\\s+CONVERSATION\\b|\\bSEND\\s+ON\\b|\\bRECEIVE\\s+(TOP\\b|\\*|\\w+\\s+FROM\\b)|\\bEND\\s+CONVERSATION\\b",
"i",
)

const CODE_HARD_DENY = /\bDROP\s+(DATABASE|SCHEMA)\b|\bTRUNCATE\b(?!\s*\()/i
/** A literal's text as an engine may run it, one string per reading: numeric escapes (\xHH, \uHHHH,
* \UHHHHHHHH, octal \ooo, U&'...' \HHHH and \+HHHHHH) decoded, and a single-character escape read as a
* space when it stands for whitespace (\n \r \t \f, and \v in BigQuery) and as itself otherwise;
* and SQL Server's reading, where a backslash before a line break joins the two lines. */
function escapeReadings(sql: string): string[] {
const char = (hex: string, base = 16) => String.fromCodePoint(Math.min(parseInt(hex, base), 0x10ffff))
let numeric = sql
if (/U&/i.test(numeric)) numeric = numeric.replace(/\\\+([0-9a-fA-F]{6})|\\([0-9a-fA-F]{4})/g, (_, six, four) => char(six ?? four))
const decode = (whitespace: RegExp) =>
numeric.replace(
/\\(?:x([0-9a-fA-F]{1,2})|u([0-9a-fA-F]{4})|U([0-9a-fA-F]{8})|([0-7]{1,3})|([\s\S]))/g,
(_, x, u, U, octal, single) =>
single !== undefined ? (whitespace.test(single) ? " " : single) : octal ? char(octal, 8) : char(x ?? u ?? U),
)
return [sql, sql.replace(/\\\r?\n/g, ""), decode(/^[nrtf]$/), decode(/^[nrtfv]$/)]
}

// TRUNCATE as a statement: TRUNCATE TABLE, or TRUNCATE starting a statement or a block (BEGIN [NOT
// ATOMIC], DO, LOOP, REPEAT, a handler's condition). After THEN or ELSE it must be a name and the end
// of the statement, since CASE ... THEN truncate ... is a column. In a read, a column or alias named truncate is not.
const CODE_HARD_DENY = new RegExp(
"\\bDROP\\s+((EXTERNAL|PLUGGABLE)\\s+)?(DATABASE|SCHEMA|CATALOG|NAMESPACE)\\b|\\bTRUNCATE\\s+TABLE\\b" +
// DuckDB accepts TRUNCATE after EXPLAIN (options), PREPARE p AS and as a CTE body, WITH x AS
// [[NOT] MATERIALIZED] (TRUNCATE t) ([^()] keeps the scan linear).
"|(^|;|\\bPREPARE\\s+\\w+\\s+AS\\b|\\bEXPLAIN\\s*\\([^()]*\\)|\\bAS\\s*((NOT\\s+)?MATERIALIZED\\s*)?\\()\\s*TRUNCATE\\b(?!\\s*\\()" +
// After a block or handler word (or EXPLAIN ANALYZE), a target must follow: these words are also
// column names, so `SELECT atomic truncate FROM t` is an alias.
"|\\b(BEGIN|NOT\\s+ATOMIC|DO|LOOP|REPEAT|SQLEXCEPTION|SQLWARNING|FOUND|ANALYZE|ANALYSE)\\s+TRUNCATE\\s+" +

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MAJOR: TRUNCATE without TABLE is no longer hard-blocked in positions the base blocked. The base matched \bTRUNCATE\b(?!\s*\() anywhere in code. This head matches TRUNCATE only as TRUNCATE TABLE, after ^/;/PREPARE … AS/EXPLAIN (…), after the block words on this line, or after THEN/ELSE. Each of these is blocked at the base and write, blocked=false here, so it runs with --allow-write:

  • Snowflake: CREATE TASK k AS TRUNCATE t and CREATE OR REPLACE TASK k WAREHOUSE = w AS TRUNCATE t
  • MySQL: CREATE PROCEDURE p() TRUNCATE t and CREATE PROCEDURE p() SQL SECURITY INVOKER TRUNCATE t. A single-statement routine body; MySQL procedures allow TRUNCATE.
  • Postgres: CREATE PROCEDURE p() LANGUAGE sql BEGIN ATOMIC TRUNCATE t; END; CALL p(). NOT ATOMIC is listed, but in BEGIN ATOMIC the word ATOMIC sits before TRUNCATE.
  • Oracle, MySQL and Databricks: ALTER TABLE t TRUNCATE PARTITION p. Oracle also: ALTER TABLE t TRUNCATE SUBPARTITION p.

The PR says the block "finds TRUNCATE in more positions"; for these forms it finds fewer. The same PR also starts blocking literal routine bodies (CREATE FUNCTION … AS 'TRUNCATE t'), so plain-text bodies are now the weaker side.

Suggested fix: apply the "a target must follow" rule after any preceding token except .:
\bTRUNCATE\s+(?!(FROM|AS|WHERE|ORDER|GROUP|HAVING|UNION|LIMIT|WINDOW|INTO|JOIN|ON|AND|OR|END)\b)[\w"$\u0080-\uffff]With that ruleSELECT truncate FROM t, ORDER BY truncateandAS truncate,stay reads, whileAS TRUNCATE t, ) TRUNCATE t, ATOMIC TRUNCATE tandTRUNCATE PARTITION p` are blocked. Please add a test for each form.

@hkaltai hkaltai Nov 4, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 6b5b356. TRUNCATE followed by a target is now a statement wherever it sits, so these are blocked again, each with a test:

  • CREATE [OR REPLACE] TASK k ... AS TRUNCATE t
  • CREATE PROCEDURE p() [SQL SECURITY INVOKER] TRUNCATE t
  • BEGIN ATOMIC TRUNCATE t
  • ALTER TABLE t TRUNCATE [SUB]PARTITION p, on Oracle, MySQL and Databricks

A column named truncate stays a read when what follows it is a clause word, an operator, , or ), or when it is qualified, including with a comment between, as in t./*c*/truncate. That covers FROM, ORDER BY ... DESC, ISNULL, window frames (ROWS/RANGE), WITH ROLLUP, SETTINGS, OPERATOR(...) and ASCENDING.

The qualified-name check runs only after TRUNCATE has matched, so classification stays linear; there are timing tests on 400 KB inputs.

Two limits are documented in the description. A name truncate followed by any other word is refused, for example an alias without AS (SELECT truncate x, FROM truncate t). A target that is itself one of those clause words (TRUNCATE nulls) is not caught.

"(?!(FROM|AS|WHERE|ORDER|GROUP|HAVING|UNION|LIMIT|WINDOW|INTO|JOIN|ON|AND|OR|END)\\b)[\\w\"`$\\u0080-\\uffff]" +
// After THEN/ELSE: [IF EXISTS] a (dotted, quoted or IDENTIFIER(...)) name, then the statement's end.
"|\\b(THEN|ELSE)\\s+TRUNCATE\\s+(IF\\s+EXISTS\\s+)?(?!END\\b)" +
"(IDENTIFIER\\s*\\([^()]*\\)|[^\\s;,().]+(\\s*\\.{1,2}\\s*[^\\s;,().]+)*)(\\s+(NOWAIT|WAIT\\s+\\d+))?\\s*(;|$)",
"i",
)
Comment on lines +252 to +268

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[🟠 MEDIUM] High Complexity and Maintainability Risk

The CODE_HARD_DENY (and BATCH_STATEMENT) regular expressions have become extremely long strings constructed via concatenation and contain dense assertions (like negative lookaheads).

This approach makes them:

  1. Hard to read and maintain for other developers.
  2. Prone to escaping errors.
  3. Vulnerable to Regular Expression Denial of Service (ReDoS) issues if backtracking behaves unexpectedly with complex SQL inputs.

Consider breaking these regex patterns down into smaller, well-named, and independently tested segments, or replace some of this logic with structural AST checks where possible. Make sure there is extensive unit testing for the edge cases introduced here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The patterns are assembled from commented parts and are covered by the adversarial and read-safety suites. No input that backtracks badly or misclassifies is given, so no change.


// In a write, TRUNCATE is a statement wherever it sits (a routine or task body, ALTER TABLE ... TRUNCATE
// PARTITION), so any unqualified TRUNCATE not followed by `(` (the function) counts. A column named
// truncate is exempt only in a read, which holds no statement body.
const WRITE_TRUNCATE = /\bTRUNCATE\b(?<!\.\s*TRUNCATE)(?!\s*\()/i

/** A handler's statement follows its conditions (a code, SQLSTATE '...', a name): a TRUNCATE after
* HANDLER FOR within the same statement counts. One pass over the statements. */
function handlerTruncate(masked: string): boolean {
return masked.split(";").some((part) => {
const handler = part.search(/\bHANDLER\s+FOR\b/i)
return handler >= 0 && /\bTRUNCATE\b(?!\s*\()/i.test(part.slice(handler))
})
}

/** Code with literals and comments masked, and each quoted identifier as a placeholder name, so a
* statement keeps its target (TRUNCATE `t`) without the identifier's text reading as code. */
function namesKept(tokens: ReturnType<typeof tokenizeSql>["tokens"]): string {
return tokens
.map(({ kind, text }) =>
kind === "code" ? text : kind === "identifier" ? ` ${"_".repeat(Math.max(text.length - 2, 1))} ` : text.replace(/[^\r\n]/g, " "),
)
.join("")
}

/** Whether a write's quoted text holds a hard-denied statement. A write can run its literals as SQL:
* dynamic SQL (EXEC('...'), sp_executesql N'...', EXECUTE IMMEDIATE, PREPARE ... FROM, dblink_exec),
* or a function, procedure or trigger body created and called in the same call. Each literal is read
* word by word with its escapes decoded, as uncertain SQL is, so nothing inside it (nested quotes,
* comments) can separate the words. Each literal is read alone here (classifyAndCheck reads dynamic SQL
* across its literals), and a custom UESCAPE character is not decoded, so it blocks. */
function quotedHardDeny(tokens: ReturnType<typeof tokenizeSql>["tokens"], dialect?: string): boolean {
// UESCAPE counts with a U&'...' literal: U& in code right before a literal token.
const unicode = tokens.some(({ kind, text }, i) => kind === "code" && /\bU&$/i.test(text) && tokens[i + 1]?.kind === "literal")
if (unicode && /\bUESCAPE\b/i.test(maskSql(tokens))) return true
// "..." is a string in Spark, Databricks and Hive, under SQL Server's QUOTED_IDENTIFIER OFF and in
// MySQL without ANSI_QUOTES; other identifiers never are.
const quotedStrings =
/^(sqlserver|tsql|mssql|fabric|mysql|mariadb|spark|databricks|hive)$/i.test(dialect ?? "") || !isModeledDialect(dialect)
return tokens.some(
({ kind, text }) =>
(kind === "literal" || (kind === "identifier" && quotedStrings && text[0] === '"')) &&
((/\bUESCAPE\b/i.test(text) && /\bU&'/i.test(text)) || mentionsHardDenyAnywhere(text)),
)
}

const DYNAMIC_SQL = /\bEXEC(UTE)?\b|(^|;)\s*(PREPARE|DO)\b/i
// Sequence advances outside PostgreSQL's nextval(): NEXT VALUE FOR s (SQL Server, DB2), s.NEXTVAL
// (Oracle, Snowflake).
const SEQUENCE_ADVANCE = /\bNEXT\s+VALUE\s+FOR\b|\.\s*NEXTVAL\b/i

/** The word DROP with DATABASE or SCHEMA anywhere after it, or TRUNCATE not directly followed by `(`
* (the TRUNCATE() function; a comment before the `(` does not count, since where a comment ends is
* part of what is uncertain). Only words count: quotes and comments are ignored, since how they read
// Sequence advances outside PostgreSQL's nextval(): NEXT VALUE FOR s (SQL Server, DB2), and in Oracle,
// Snowflake and MariaDB (Oracle mode) s.NEXTVAL, quoted or not (elsewhere s.nextval is a column).
function advancesSequence(tokens: ReturnType<typeof tokenizeSql>["tokens"], masked: string, dialect?: string): boolean {
if (/\bNEXT\s+VALUE\s+FOR\b/i.test(masked)) return true
if (!/^(oracle|snowflake|mysql|mariadb)$/i.test(dialect ?? "") && isModeledDialect(dialect)) return false
// Comments masked to spaces, and a quoted name counts only when it is exactly NEXTVAL, so
// s./* c */"NEXTVAL" reads as s.NEXTVAL while "s.NEXTVAL" and t."NEXTVAL VALUE" are column names.
const names = tokens.map((token) =>
token.kind === "identifier" && !/^.NEXTVAL.$/i.test(token.text) ? { ...token, text: '"_"' } : token,
)
return /\.\s*NEXTVAL\b/i.test(maskSql(names, true))
}

/** The word DROP with DATABASE, SCHEMA, CATALOG or NAMESPACE anywhere after it, or TRUNCATE not
* directly followed by `(` (the TRUNCATE() function; a comment before the `(` does not count, since
* where a comment ends is part of what is uncertain). Only words count: quotes and comments are ignored, since how they read
* is what is uncertain, so no comment or quote placement can separate the words. One forward pass. */
function mentionsHardDeny(sql: string): boolean {
let drop = false
Expand All @@ -250,7 +343,7 @@ function mentionsHardDeny(sql: string): boolean {
while (j < sql.length && /\w/.test(sql[j])) j++
const word = sql.slice(i, j).toUpperCase()
if (word === "DROP") drop = true
else if (drop && (word === "DATABASE" || word === "SCHEMA")) return true
else if (drop && (word === "DATABASE" || word === "SCHEMA" || word === "CATALOG" || word === "NAMESPACE")) return true
else if (word === "TRUNCATE" && !/^[ \t\r\n]*\(/.test(sql.slice(j, j + 64))) return true
i = j
}
Expand Down Expand Up @@ -349,7 +442,25 @@ export function classifyAndCheck(sql: string, dialect?: string): {
blocked: boolean
parseError?: string
} {
return classifySql(sql, dialect, 0)
const result = classifySql(sql, dialect, 0)
if (result.queryType === "write" && !result.blocked) {
// SQL read with certainty: a write may still run the SQL in its literals. Otherwise the word scan
// of the whole text already covered them.
const lexical = tokenizeSql(sql, dialect)
if (lexical.complete && quotedHardDeny(lexical.tokens, dialect)) return { ...result, blocked: true }
Comment on lines +448 to +450

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[🟠 MEDIUM] Redundant Tokenization

The classifySql function (called just above) already executes tokenizeSql(sql, dialect) internally. Calling it again here for write queries introduces unnecessary CPU overhead and latency, especially for large SQL statements.

Consider modifying classifySql to return the lexical analysis result along with its classification so that you can reuse it here without redundant processing.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct, a second tokenize runs, but only for non-blocked write queries and it is linear in the SQL length. Returning the lexical result from classifySql is possible but changes its signature; leaving as is unless it shows up in profiling.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MAJOR: an existing test now fails. This new literal check blocks INSERT INTO logs VALUES ('TRUNCATE TABLE users happened') (blocked=true). test/altimate/sql-validation-adversarial.test.ts:116-120 ("TRUNCATE inside string literal is not blocked") still asserts blocked: false. That file passes at the base (73/73) and fails at this head (72 pass / 1 fail). A full bun test test/altimate run at head gives 10646 pass / 1 fail, and this is the only failure that is new at head. This stacked PR's checks run no test job, so nothing flags it.

Fix: change the expectation to blocked: true with a note that it is the accepted "literals mentioning TRUNCATE in writes" limit, or move the case into the new table in sql-classify.test.ts.

@hkaltai hkaltai Nov 4, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 6b5b356. The test now expects blocked: true for INSERT INTO logs VALUES ('TRUNCATE TABLE users happened'), with a note that this is the accepted limit: a write's literals are read as SQL, so a write whose literal names TRUNCATE is refused even with --allow-write. sql-validation-adversarial.test.ts passes, and the run on the touched files is 1841 pass, 0 fail.

// Dynamic SQL can join literals into one statement ('DROP ' + 'DATABASE x', or PostgreSQL's
// adjacent constants), so its words count across literals too.
if (lexical.complete && DYNAMIC_SQL.test(maskSql(lexical.tokens)) && mentionsHardDenyAnywhere(sql))
return { ...result, blocked: true }
}
return result
}

/** Whether DROP DATABASE/SCHEMA/CATALOG/NAMESPACE or TRUNCATE appears anywhere in the text, read word by word
* with quotes and comments ignored and escapes decoded (for text another layer renders, such as dbt's
* Jinja). */
export function mentionsHardDenyAnywhere(sql: string): boolean {
return escapeReadings(sql).some(mentionsHardDeny)
}

function classifySql(sql: string, dialect: string | undefined, depth: number): {
Expand All @@ -375,22 +486,27 @@ function classifySql(sql: string, dialect: string | undefined, depth: number): {
const masked = maskSql(lexical.tokens)
const blocked =
functions.blocked ||
classifyFallback(sql, dialect).blocked ||
classifyFallback(sql, dialect, false).blocked ||
result.statements.some(
(s: { statement_type: string }) => s.statement_type && HARD_DENY_TYPES.has(s.statement_type.toUpperCase()),
)

return {
queryType:
!functions.write &&
!blocked &&
!batchedStatement(masked, dialect) &&
!SEQUENCE_ADVANCE.test(masked) &&
otherIsReadOnly(sql, result.statements, dialect)
? "read"
: "write",
blocked,
}
const queryType =
!functions.write &&
!blocked &&
!batchedStatement(masked, dialect) &&
// Snowflake's ->> runs the statements it chains, DML included.
!(/^snowflake$/i.test(dialect ?? "") && masked.includes("->>")) &&
// Spark/Hive SELECT TRANSFORM(...) USING 'cmd' runs a command on the cluster; any USING not
// followed by "(" counts (comments and the script masked), while JOIN ... USING (col) is a join.
!((/^(spark|databricks|hive)$/i.test(dialect ?? "") || !isModeledDialect(dialect)) &&
/\bTRANSFORM\s*\(/i.test(masked) &&
/\bUSING\b(?!\s*\()/i.test(masked)) &&
!advancesSequence(lexical.tokens, masked, dialect) &&
otherIsReadOnly(sql, result.statements, dialect)
? "read"
: "write"
return { queryType, blocked: blocked || (queryType === "write" && WRITE_TRUNCATE.test(namesKept(lexical.tokens))) }
} catch (error) {
return { ...classifyFallback(sql, dialect), parseError: error instanceof Error ? error.message : String(error) }
}
Expand Down
Loading
Loading