/** * Read-only SQL gate for the snowflake_query tool. * * Extracted from snowflake-query.ts so it can be unit-tested without * pulling in the pi-coding-agent runtime. * * Snowflake-dialect-specific: `WITH` is allowed because Snowflake only * permits CTEs to attach to SELECT or CTAS, so a leading `WITH` cannot * smuggle DDL/DML in this dialect. Do NOT lift this gate verbatim into * a Postgres-targeted tool — `WITH ... INSERT/UPDATE/DELETE ... RETURNING` * is legal there. */ export const ALLOWED_LEADING_KEYWORDS = new Set([ "select", "with", "show", "desc", "describe", "explain", "list", ]); export interface ValidationOk { ok: true; statement: string; } export interface ValidationFail { ok: false; reason: string; } export type Validation = ValidationOk | ValidationFail; export function stripSqlComments(sql: string): string { let out = sql.replace(/\/\*[\s\S]*?\*\//g, " "); out = out.replace(/--[^\n]*(\n|$)/g, " "); return out; } export function validateReadOnlySql(rawQuery: string): Validation { const stripped = stripSqlComments(rawQuery).trim(); if (!stripped) return { ok: false, reason: "Query is empty." }; // Naive `;` split: doesn't respect string literals or $$-dollar-quoted // bodies. This is a closed-direction failure — a SELECT containing a // literal `;` (e.g. SELECT 'a; b') is rejected as multi-statement. Safe // to refuse; do NOT relax without also implementing literal-aware // tokenization (or the gate becomes injection-prone). const statements = stripped .split(/;\s*/) .map((s) => s.trim()) .filter((s) => s.length > 0); if (statements.length > 1) { return { ok: false, reason: "Multiple statements are not allowed. Send one SQL statement per call.", }; } const statement = statements[0]; // Closed-direction failure: a leading paren like `(SELECT 1)` is // rejected because `^\s*(\w+)` won't match `(`. Fine to refuse — the // LLM can unwrap the parens. Don't relax without parsing balanced // parens. const firstWord = statement.match(/^\s*(\w+)/)?.[1]?.toLowerCase() ?? ""; if (!ALLOWED_LEADING_KEYWORDS.has(firstWord)) { return { ok: false, reason: `Only read-only queries are allowed. Statement must start with one of: ${Array.from( ALLOWED_LEADING_KEYWORDS, ) .map((k) => k.toUpperCase()) .join(", ")}. Got: ${firstWord || "(empty)"}.`, }; } return { ok: true, statement }; }