import { validateReadOnlySql } from "./sql-gate.js"; const RAW_NODE_PATTERN = /(^|[.:_\-/])raw([.:_\-/]|$)|raw_aep|external_table/i; /** * dbt selection grammar is deliberately not parsed here: dbt itself is the * authority. This is only a conservative preflight for selectors that plainly * name a raw relation; callers must resolve the selector with `dbt ls` too. */ export function hasExplicitRawSelector(selector: string): boolean { return RAW_NODE_PATTERN.test(selector); } interface DbtLsNode { name?: unknown; original_file_path?: unknown; fqn?: unknown; tags?: unknown; config?: unknown; } function nodeMetadata(node: DbtLsNode): string { const config = node.config && typeof node.config === "object" ? node.config : {}; return [node.name, node.original_file_path, node.fqn, node.tags, JSON.stringify(config)] .flatMap((value) => (Array.isArray(value) ? value : [value])) .filter((value): value is string => typeof value === "string") .join(" "); } /** Parse dbt's JSON-lines selector result and fail closed on any ambiguity. */ export function validateResolvedFullRefreshOutput(output: string): string | undefined { const lines = output.split(/\r?\n/).map((line) => line.trim()).filter(Boolean); if (!lines.length) return "Refusing --full-refresh because the selector resolved to no nodes."; for (const line of lines) { let node: DbtLsNode; try { const parsed: unknown = JSON.parse(line); if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("not an object"); node = parsed as DbtLsNode; } catch { return "Unable to parse `dbt ls --output json` safely. Refusing --full-refresh."; } if (hasExplicitRawSelector(nodeMetadata(node))) { return "Refusing --full-refresh because dbt resolved a raw or external model. Raw models may permanently delete source files."; } } } /** * Fail closed when the `dbt ls --output json` capture used to resolve a * --full-refresh selector was truncated: a truncated capture can silently * drop early JSON-lines nodes (including raw/external ones) while still * leaving a well-formed, seemingly-safe suffix that parses cleanly. */ export function validateResolvedFullRefreshCapture( stdout: string, stdoutTruncated: boolean, ): string | undefined { if (stdoutTruncated) { return "Unable to safely validate the --full-refresh selector because the `dbt ls --output json` output was truncated. Refusing --full-refresh."; } return validateResolvedFullRefreshOutput(stdout); } export function validateFullRefreshRequest(select: string | undefined): string | undefined { if (!select?.trim()) { return "Refusing --full-refresh without an explicit --select selector. A project-wide full refresh can rebuild raw models and permanently delete source files."; } if (hasExplicitRawSelector(select)) { return "Refusing --full-refresh for a selector that names a raw model. Raw models may permanently delete their S3 source files."; } } /** * Detect a `snow ... sql` command before applying the intentionally strict * shell parser below. The parser rejects metacharacters, so running it against * every Bash command would incorrectly block unrelated commands such as grep * patterns containing `\\` or command chains containing `&&`. */ function hasDirectSnowSqlCandidate(command: string): boolean { let quote: "'" | '"' | undefined; let token = ""; let sawSnow = false; const consume = () => { if (!token) return; if (token.split("/").pop() === "snow") { sawSnow = true; } else if (sawSnow && token === "sql") { return true; } token = ""; return false; }; for (let i = 0; i < command.length; i += 1) { const char = command[i]; if (quote) { if (char === quote) quote = undefined; else token += char; continue; } if (char === "'" || char === '"') { quote = char; continue; } if (/\s/.test(char)) { if (consume()) return true; continue; } if ("|&;<>".includes(char)) { if (consume()) return true; sawSnow = false; continue; } token += char; } return consume() === true; } function splitShellWords(command: string): string[] | undefined { const words: string[] = []; let word = ""; let quote: "'" | '"' | undefined; for (let i = 0; i < command.length; i += 1) { const char = command[i]; if (quote) { if (char === quote) quote = undefined; else if (char === "\\" || char === "$" || char === "`") return; else word += char; continue; } if (char === "'" || char === '"') { quote = char; continue; } if (/\s/.test(char)) { if (word) words.push(word); word = ""; continue; } // These operators only matter when unquoted. Reject them rather than // trying to reproduce the shell's complete grammar. if (char === "\\" || "|&;`$<>".includes(char)) return; word += char; } if (quote) return; if (word) words.push(word); return words; } // Global `snow` options that may legitimately precede the `sql` subcommand. // Notably absent: `--config-file`, which can point the CLI at a config.toml // with a differently-defined connection and must fail closed rather than be // silently skipped. const SAFE_GLOBAL_PRE_SQL_FLAGS = new Set(["--help", "-h", "--version", "--info"]); type SnowSqlFlagKind = "connection" | "query" | "safe" | "reject"; interface SnowSqlFlagSpec { kind: SnowSqlFlagKind; hasValue: boolean; } // Exhaustive map of `snow sql` options (per `snow sql --help`, Snowflake CLI // v3.14). Anything not listed here is rejected as unrecognized/ambiguous // rather than silently passed through, per the fail-closed requirement. const SNOW_SQL_FLAG_SPECS: Record = { "-c": { kind: "connection", hasValue: true }, "--connection": { kind: "connection", hasValue: true }, "--environment": { kind: "connection", hasValue: true }, "-q": { kind: "query", hasValue: true }, "--query": { kind: "query", hasValue: true }, // Connection/credential override mechanisms: reject outright. Allowing // any of these would let a caller redirect the query to an account, // role, or identity outside the pinned connection. "--host": { kind: "reject", hasValue: true }, "--port": { kind: "reject", hasValue: true }, "--account": { kind: "reject", hasValue: true }, "--accountname": { kind: "reject", hasValue: true }, "--user": { kind: "reject", hasValue: true }, "--username": { kind: "reject", hasValue: true }, "--password": { kind: "reject", hasValue: true }, "--authenticator": { kind: "reject", hasValue: true }, "--workload-identity-provider": { kind: "reject", hasValue: true }, "--private-key-file": { kind: "reject", hasValue: true }, "--private-key-path": { kind: "reject", hasValue: true }, "--token": { kind: "reject", hasValue: true }, "--token-file-path": { kind: "reject", hasValue: true }, "--role": { kind: "reject", hasValue: true }, "--rolename": { kind: "reject", hasValue: true }, "--temporary-connection": { kind: "reject", hasValue: false }, "-x": { kind: "reject", hasValue: false }, "--mfa-passcode": { kind: "reject", hasValue: true }, "--enable-diag": { kind: "reject", hasValue: false }, "--diag-log-path": { kind: "reject", hasValue: true }, "--diag-allowlist-path": { kind: "reject", hasValue: true }, "--oauth-client-id": { kind: "reject", hasValue: true }, "--oauth-client-secret": { kind: "reject", hasValue: true }, "--oauth-authorization-url": { kind: "reject", hasValue: true }, "--oauth-token-request-url": { kind: "reject", hasValue: true }, "--oauth-redirect-uri": { kind: "reject", hasValue: true }, "--oauth-scope": { kind: "reject", hasValue: true }, "--oauth-disable-pkce": { kind: "reject", hasValue: false }, "--oauth-enable-refresh-tokens": { kind: "reject", hasValue: false }, "--oauth-enable-single-use-refresh-tokens": { kind: "reject", hasValue: false }, "--client-store-temporary-credential": { kind: "reject", hasValue: false }, "--config-file": { kind: "reject", hasValue: true }, // Client-side templating can change the SQL after validateReadOnlySql() // inspects it, including introducing additional or destructive statements. "--variable": { kind: "reject", hasValue: true }, "-D": { kind: "reject", hasValue: true }, "--project": { kind: "reject", hasValue: true }, "-p": { kind: "reject", hasValue: true }, "--env": { kind: "reject", hasValue: true }, "--enable-templating": { kind: "reject", hasValue: true }, // Harmless output/formatting/scoping flags. "--database": { kind: "safe", hasValue: true }, "--dbname": { kind: "safe", hasValue: true }, "--schema": { kind: "safe", hasValue: true }, "--schemaname": { kind: "safe", hasValue: true }, "--warehouse": { kind: "safe", hasValue: true }, "--decimal-precision": { kind: "safe", hasValue: true }, "--format": { kind: "safe", hasValue: true }, "--retain-comments": { kind: "safe", hasValue: false }, "--single-transaction": { kind: "safe", hasValue: false }, "--no-single-transaction": { kind: "safe", hasValue: false }, "--verbose": { kind: "safe", hasValue: false }, "-v": { kind: "safe", hasValue: false }, "--debug": { kind: "safe", hasValue: false }, "--silent": { kind: "safe", hasValue: false }, "--enhanced-exit-codes": { kind: "safe", hasValue: false }, "--help": { kind: "safe", hasValue: false }, "-h": { kind: "safe", hasValue: false }, }; /** Validate a direct `snow sql` invocation before allowing a shell bypass. */ export function validateDirectSnowSql(command: string, connection: string): string | undefined { if (!hasDirectSnowSqlCandidate(command)) return; const tokens = splitShellWords(command); if (!tokens) { return "Bash `snow sql` rejected: shell operators and stdin/redirection are not allowed. Use `snowflake_query`."; } const snowIndex = tokens.findIndex((token) => token.split("/").pop() === "snow"); if (snowIndex < 0) return; const afterSnow = tokens.slice(snowIndex + 1); const sqlPos = afterSnow.indexOf("sql"); if (sqlPos < 0) return; const preamble = afterSnow.slice(0, sqlPos); for (const tok of preamble) { if (!SAFE_GLOBAL_PRE_SQL_FLAGS.has(tok)) { return `Bash \`snow sql\` rejected: unsupported global option \`${tok}\` before the \`sql\` subcommand. Use the \`snowflake_query\` tool.`; } } const args = afterSnow.slice(sqlPos + 1); let connectionCount = 0; let connectionMatches = false; let query: string | undefined; for (let i = 0; i < args.length; i += 1) { const raw = args[i]; if (raw === "-f" || raw === "--filename" || raw.startsWith("--filename=") || raw.startsWith("-f=")) { return "Bash `snow sql` rejected: file SQL input is not allowed. Use `snowflake_query`."; } if (raw === "-i" || raw === "--stdin") { return "Bash `snow sql` rejected: stdin and non-inline SQL are not allowed. Use `snowflake_query`."; } let flag = raw; let inlineValue: string | undefined; if (raw.startsWith("--") && raw.includes("=")) { const eqIndex = raw.indexOf("="); flag = raw.slice(0, eqIndex); inlineValue = raw.slice(eqIndex + 1); } const spec = SNOW_SQL_FLAG_SPECS[flag]; if (!spec) { return `Bash \`snow sql\` rejected: unrecognized or unsupported option \`${raw}\`. Use the \`snowflake_query\` tool.`; } let value = inlineValue; if (spec.hasValue && value === undefined) { value = args[i + 1]; i += 1; } if (spec.kind === "reject") { return `Bash \`snow sql\` rejected: \`${flag}\` overrides connection or credential settings and is not allowed. Use the \`snowflake_query\` tool.`; } if (spec.kind === "connection") { connectionCount += 1; connectionMatches = value === connection; continue; } if (spec.kind === "query") { if (query !== undefined || !value) return "Bash `snow sql` rejected: exactly one inline -q/--query payload is required."; query = value; continue; } // spec.kind === "safe": value already consumed above; nothing else to do. } if (connectionCount !== 1 || !connectionMatches) return `Bash \`snow sql\` blocked: use exactly one \`-c ${connection}\` or the \`snowflake_query\` tool.`; if (!query) return "Bash `snow sql` rejected: stdin and non-inline SQL are not allowed. Use `snowflake_query`."; const check = validateReadOnlySql(query); return check.ok ? undefined : `Bash \`snow sql\` rejected: ${check.reason} Use the \`snowflake_query\` tool for read-only access.`; } /** Shared implementation used by the extension's `tool_call` bash handler. */ export function handleBashSnowflakeToolCall( command: string, connection: string, ): { block: true; reason: string } | undefined { if (/\bsnowsql\b/i.test(command)) { return { block: true, reason: `Direct bash \`snowsql\` (legacy CLI) is not allowed. Use the \`snowflake_query\` tool, which enforces the \`${connection}\` connection and read-only SQL.`, }; } if (/\bpython[0-9.]*\s+-[cm]\b[^\n]*\bsnowflake\.(connector|snowpark)\b/i.test(command)) { return { block: true, reason: "Reaching Snowflake from a python one-liner bypasses the connection + read-only gate. Use the `snowflake_query` tool instead.", }; } const directSnowSqlError = validateDirectSnowSql(command, connection); return directSnowSqlError ? { block: true, reason: directSnowSqlError } : undefined; } export function appendBounded(existing: string, chunk: string, maxBytes: number): string { const combined = existing + chunk; const bytes = Buffer.from(combined); if (bytes.length <= maxBytes) return combined; let start = bytes.length - maxBytes; while (start < bytes.length && (bytes[start] & 0b1100_0000) === 0b1000_0000) start += 1; return bytes.subarray(start).toString("utf8"); } /** Pi's execute contract marks a call as isError only when it throws. */ export function resultOrThrow( exitCode: number, text: string, details: T, ): { content: Array<{ type: "text"; text: string }>; details: T } { if (exitCode !== 0) throw Object.assign(new Error(text), { details }); return { content: [{ type: "text", text }], details }; } export function selectSnowflakeQueryTool(contextModeActive: boolean): "snowflake_query" | "ctx_snowflake_query" { return contextModeActive ? "ctx_snowflake_query" : "snowflake_query"; }