Skip to content

Commit 6aa2db4

Browse files
anandgupta42claude
andcommitted
fix(sql-check): refuse DuckDB remote-execute functions in read-only sources and classify them as writes
`postgres_execute`, `mysql_execute` and `sqlite_execute` run their statement on an attached database outside the local read-only transaction. Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
1 parent 64b3c4f commit 6aa2db4

4 files changed

Lines changed: 20 additions & 3 deletions

File tree

‎packages/opencode/src/altimate/tools/sql-check.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { Tool } from "../../tool/tool"
33
import { zodToJsonSchema } from "../tool-zod-compat"
44
import * as Precedence from "../workspace/precedence"
55
import * as Registry from "../native/connections/registry"
6-
import { classifyAndCheck, isSingleQuery } from "./sql-classify"
6+
import { classifyAndCheck, isSingleQuery, REMOTE_EXECUTE_CALL } from "./sql-classify"
77
import { maskSql, tokenizeSql } from "./sql-tokenize"
88
import { executeQuery, type QueryContext } from "../native/query-execution"
99

@@ -185,7 +185,9 @@ export function readSql(sql: string, dialect?: string): string {
185185
/\b(pg_try_advisory\w*|pg_advisory_unlock\w*|pg_advisory_lock\w*|pg_advisory_xact\w*|pg_terminate_backend|pg_cancel_backend|pg_reload_conf|pg_rotate_logfile|pg_promote|pg_switch_wal|pg_logical_emit_message|pg_create_\w*_replication_slot|pg_drop_replication_slot|pg_replication_slot_advance|pg_replication_origin_\w+|dblink|dblink_\w+|pg_notify|pg_stat_reset\w*|pg_wal_replay_\w+|pg_logical_slot_\w+|pg_replication_slot_\w+|get_lock|release_lock|release_all_locks|is_free_lock|is_used_lock|master_pos_wait|source_pos_wait|sleep|benchmark|pg_xlog_replay_\w+|pg_log_backend_memory_contexts|pg_create_restore_point|pg_backup_\w+|pg_start_backup|pg_stop_backup|lo_from_bytea)\s*\(/i.test(
186186
functions,
187187
) ||
188-
SIDE_EFFECT_FUNCTION_CALL.test(functions)
188+
SIDE_EFFECT_FUNCTION_CALL.test(functions) ||
189+
// DuckDB attachments run these statements remotely, outside the local read-only transaction.
190+
REMOTE_EXECUTE_CALL.test(functions)
189191
) {
190192
throw new Error(
191193
"sql_check only accepts a single read-only SELECT (or WITH ... SELECT); it never writes or fixes data.",

‎packages/opencode/src/altimate/tools/sql-classify.ts‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,9 @@ export function classifyMulti(sql: string, dialect?: string): "read" | "write" {
165165
return classify(sql, dialect)
166166
}
167167

168+
/** DuckDB table functions that execute a statement on an attached PostgreSQL, MySQL or SQLite database. */
169+
export const REMOTE_EXECUTE_CALL = /\b(postgres_execute|mysql_execute|sqlite_execute)\s*\(/i
170+
168171
/**
169172
* Single-pass: classify and check for hard-denied statement types.
170173
* Returns both the overall query type and whether a hard-deny pattern was found.
@@ -193,7 +196,13 @@ export function classifyAndCheck(sql: string, dialect?: string): {
193196
(s: { statement_type: string }) => s.statement_type && HARD_DENY_TYPES.has(s.statement_type.toUpperCase()),
194197
)
195198

196-
return { queryType: otherIsReadOnly(sql, result.statements, dialect) ? "read" : "write", blocked }
199+
// DuckDB scanner functions run their statement on the attached remote database, outside any local
200+
// read-only transaction, so a SELECT calling them is a write.
201+
const remoteExecute = REMOTE_EXECUTE_CALL.test(maskSql(tokenizeSql(sql, dialect).tokens))
202+
return {
203+
queryType: !remoteExecute && otherIsReadOnly(sql, result.statements, dialect) ? "read" : "write",
204+
blocked,
205+
}
197206
} catch (error) {
198207
return { ...classifyFallback(sql, dialect), parseError: error instanceof Error ? error.message : String(error) }
199208
}

‎packages/opencode/test/altimate/tools/sql-check.test.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1044,6 +1044,8 @@ test.each([
10441044
"lowrite(1, 'value')",
10451045
"force_checkpoint()",
10461046
"query('SELECT 1')",
1047+
"postgres_execute('remote', 'DELETE FROM orders')",
1048+
"mysql_execute('remote', 'DELETE FROM orders')",
10471049
String.raw`U&"pg\005fterminate_backend"(1)`,
10481050
String.raw`pg_catalog.U&"pg_terminate_\+000062ackend"(1)`,
10491051
`U&"pg!005fcancel_backend" UESCAPE '!' (1)`,

‎packages/opencode/test/altimate/tools/sql-classify.test.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -358,3 +358,7 @@ describe("SQL-building aggregates are reads", () => {
358358
expect(r.parseError).toMatch(/Parse error.*Expected RParen/)
359359
})
360360
})
361+
362+
test.each(["postgres_execute", "mysql_execute"])("a SELECT calling %s is classified as a write", (fn) => {
363+
expect(classify(`SELECT * FROM ${fn}('remote', 'DELETE FROM orders')`, "duckdb")).toBe("write")
364+
})

0 commit comments

Comments
 (0)