diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index bd43281e7..51beb63a4 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -96,6 +96,14 @@ import { } from "@/lib/dataGridTranspose"; import { matchesRowStatusFilter, type RowStatus, type RowStatusFilter } from "@/lib/gridRowStatus"; import { displayCellValue, type CellValue } from "@/lib/cellValue"; +import { + BINARY_CELL_DOWNLOAD_MODES, + binaryCellDownloadFileName, + binaryCellDownloadPayload, + canDownloadBinaryCellValue, + downloadBinaryCellPayload, + type BinaryCellDownloadMode, +} from "@/lib/binaryCellDownload"; import { canFormatCellDetailJson, cellDetailEditorText, @@ -2198,6 +2206,11 @@ const contextCellValue = computed(() => { if (!contextCell.value || contextCell.value.col < 0) return null; return contextRowItem.value?.data[contextCell.value.col] ?? null; }); +const contextCellDetail = computed(() => { + const cell = contextCell.value; + if (!cell || cell.col < 0) return null; + return cellDetailFor(cell.rowIndex, cell.col); +}); function cellDetailFor(rowIndex: number, columnIndex: number): DataGridCellDetail | null { const item = displayItems.value[rowIndex]; if (!item) return null; @@ -3344,6 +3357,45 @@ function copyDetailColumnName() { copyText(activeCellDetail.value.column); } +function canDownloadDetailBinaryValue(detail: DataGridCellDetail | null): boolean { + return !!detail && canDownloadBinaryCellValue(detail.value, detail.type); +} + +async function downloadDetailBinaryValue(detail: DataGridCellDetail | null, mode: BinaryCellDownloadMode) { + if (!detail || !canDownloadDetailBinaryValue(detail)) return; + try { + const payload = binaryCellDownloadPayload(detail.value, mode); + const fileName = binaryCellDownloadFileName({ + column: detail.column, + rowNumber: detail.rowNumber, + mode, + extension: payload.extension, + }); + const result = await downloadBinaryCellPayload(payload, fileName); + if (result.kind === "saved" && result.path) { + toast(t("grid.downloadSaved", { path: result.path })); + } else if (result.kind === "browser-download") { + toast(t("grid.downloadStarted", { fileName: result.fileName ?? fileName })); + } + } catch (e: any) { + toast(t("grid.exportFailed", { message: e?.message || String(e) }), 5000); + } +} + +function binaryDownloadSubmenu(detail: DataGridCellDetail | null): ContextMenuItem | null { + if (!canDownloadDetailBinaryValue(detail)) return null; + return { + label: t("grid.downloadBinaryValue"), + icon: Download, + children: BINARY_CELL_DOWNLOAD_MODES.map((mode) => ({ + label: t(`grid.binaryDownload.${mode}`), + action: () => { + void downloadDetailBinaryValue(detail, mode); + }, + })), + }; +} + async function copyDetailSqlCondition() { if (!canCopyPreparedDetailSqlCondition()) return; copyText(detailSqlConditionCopy.value.text); @@ -4331,6 +4383,8 @@ const gridContextMenuItems = computed(() => { if (contextCell.value) { if (contextColumn.value) { items.push({ label: t("grid.openCellDetailsDialog"), action: openContextCellDetailDialog, icon: Maximize2 }); + const downloadItem = binaryDownloadSubmenu(contextCellDetail.value); + if (downloadItem) items.push(downloadItem); items.push({ label: t("grid.openColumnDetailsDialog"), action: openContextColumnDetailDialog, @@ -6049,7 +6103,46 @@ const gridContextMenuItems = computed(() => {
-
{{ t("grid.cellValue") }}
+
+
{{ t("grid.cellValue") }}
+
+ + + + + + + + + {{ t(`grid.binaryDownload.${mode}`) }} + + + +
+
{{ t("grid.imagePreview") }}
(() => {
- - @@ -6379,7 +6460,46 @@ const gridContextMenuItems = computed(() => {
-
{{ t("grid.cellValue") }}
+
+
{{ t("grid.cellValue") }}
+
+ + + + + + + + + {{ t(`grid.binaryDownload.${mode}`) }} + + + +
+
(() => {
-
-
diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 8f4f1c864..541f8765c 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -551,6 +551,14 @@ export default { formattedValue: "Formatted Value", rawValue: "Raw Value", copyValue: "Copy Value", + downloadBinaryValue: "Download Value", + downloadSaved: "Saved to {path}", + downloadStarted: "Download started: {fileName}", + binaryDownload: { + binary: "Raw binary", + utf8: "Decoded text (UTF-8)", + gbk: "Decoded text (GBK)", + }, copyRowTsv: "Copy Row (TSV)", copyColumnValues: "Copy Column (JSON)", copyColumnTsv: "Copy Column (TSV)", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 9aba0e583..811c8a31a 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -522,6 +522,14 @@ export default { formattedValue: "Valor formateado", rawValue: "Valor sin procesar", copyValue: "Copiar valor", + downloadBinaryValue: "Descargar valor", + downloadSaved: "Guardado en {path}", + downloadStarted: "Descarga iniciada: {fileName}", + binaryDownload: { + binary: "Binario sin procesar", + utf8: "Texto decodificado (UTF-8)", + gbk: "Texto decodificado (GBK)", + }, copyRowTsv: "Copiar fila (TSV)", copyColumnValues: "Copiar columna (JSON)", copyColumnTsv: "Copiar columna (TSV)", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 20825570c..f60a4de42 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -546,6 +546,14 @@ export default { formattedValue: "格式化值", rawValue: "原始值", copyValue: "复制值", + downloadBinaryValue: "下载值", + downloadSaved: "已保存到 {path}", + downloadStarted: "已交给浏览器下载:{fileName}", + binaryDownload: { + binary: "原始二进制", + utf8: "解码文本 (UTF-8)", + gbk: "解码文本 (GBK)", + }, copyRowTsv: "复制行 (TSV)", copyColumnValues: "复制列 (JSON)", copyColumnTsv: "复制列 (TSV)", diff --git a/apps/desktop/src/lib/binaryCellDownload.ts b/apps/desktop/src/lib/binaryCellDownload.ts new file mode 100644 index 000000000..7a0cb04b7 --- /dev/null +++ b/apps/desktop/src/lib/binaryCellDownload.ts @@ -0,0 +1,118 @@ +import type { CellValue } from "@/lib/cellValue"; +import { isTauriRuntime } from "@/lib/tauriRuntime"; + +export type BinaryCellDownloadMode = "binary" | "utf8" | "gbk"; + +export interface BinaryCellDownloadPayload { + data: Uint8Array | string; + mimeType: string; + extension: string; +} + +export interface BinaryCellDownloadResult { + kind: "saved" | "browser-download" | "cancelled"; + path?: string; + fileName?: string; +} + +export const BINARY_CELL_DOWNLOAD_MODES: BinaryCellDownloadMode[] = ["binary", "utf8", "gbk"]; + +const HEX_VALUE_RE = /^(?:0[xX]|\\x)([0-9a-fA-F\s]+)$/; +const BINARY_TYPE_RE = + /^(?:blob|tinyblob|mediumblob|longblob|bytea|bytes|binary|varbinary|image|raw|long\s+raw)(?:\b|\()/i; + +export function parseBinaryCellHexValue(value: CellValue): Uint8Array | null { + if (typeof value !== "string") return null; + const match = value.trim().match(HEX_VALUE_RE); + if (!match) return null; + + const hex = match[1].replace(/\s+/g, ""); + if (!hex || hex.length % 2 !== 0) return null; + + const bytes = new Uint8Array(hex.length / 2); + for (let i = 0; i < bytes.length; i++) { + const parsed = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16); + if (Number.isNaN(parsed)) return null; + bytes[i] = parsed; + } + return bytes; +} + +export function isBinaryCellColumnType(columnType?: string): boolean { + const type = (columnType ?? "").trim(); + return !!type && BINARY_TYPE_RE.test(type); +} + +export function canDownloadBinaryCellValue(value: CellValue, columnType?: string): boolean { + if (!parseBinaryCellHexValue(value)) return false; + return isBinaryCellColumnType(columnType) || typeof value === "string"; +} + +export function binaryCellDownloadPayload(value: CellValue, mode: BinaryCellDownloadMode): BinaryCellDownloadPayload { + const bytes = parseBinaryCellHexValue(value); + if (!bytes) { + throw new Error("Cell value is not a hexadecimal binary value."); + } + + if (mode === "binary") { + return { + data: bytes, + mimeType: "application/octet-stream", + extension: "bin", + }; + } + + const decoder = new TextDecoder(mode === "gbk" ? "gbk" : "utf-8", { fatal: false }); + return { + data: decoder.decode(bytes), + mimeType: "text/plain;charset=utf-8", + extension: "txt", + }; +} + +export function binaryCellDownloadFileName(options: { + column: string; + rowNumber: number; + mode: BinaryCellDownloadMode; + extension: string; +}): string { + const column = options.column + .trim() + .replace(/[\\/:*?"<>|]+/g, "-") + .replace(/\s+/g, "_") + .slice(0, 48); + const safeColumn = column || "cell"; + const suffix = options.mode === "binary" ? "" : `-${options.mode}`; + return `${safeColumn}-row-${options.rowNumber}${suffix}.${options.extension}`; +} + +export async function downloadBinaryCellPayload( + payload: BinaryCellDownloadPayload, + fileName: string, +): Promise { + if (isTauriRuntime()) { + const [{ save }, fs] = await Promise.all([import("@tauri-apps/plugin-dialog"), import("@tauri-apps/plugin-fs")]); + const path = await save({ + defaultPath: fileName, + filters: [{ name: payload.extension.toUpperCase(), extensions: [payload.extension] }], + }); + if (!path) return { kind: "cancelled" }; + if (typeof payload.data === "string") { + await fs.writeTextFile(path, payload.data); + } else { + await fs.writeFile(path, payload.data); + } + return { kind: "saved", path }; + } + + const blob = new Blob([payload.data], { type: payload.mimeType }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = fileName; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); + return { kind: "browser-download", fileName }; +} diff --git a/docs/content/docs/mcp.cn.mdx b/docs/content/docs/mcp.cn.mdx index fb29a9677..ccbe62cff 100644 --- a/docs/content/docs/mcp.cn.mdx +++ b/docs/content/docs/mcp.cn.mdx @@ -218,7 +218,7 @@ MCP 查询执行默认非常保守: | 规则 | 默认行为 | |---|---| | 空 SQL 或无法识别的 SQL | 阻止 | -| 超过一条 SQL 语句 | 阻止 | +| 多条 SQL 语句 | 允许;会逐条进行安全检查并依次执行 | | 非只读 SQL | 阻止,除非显式允许写入 | | `DROP`、`TRUNCATE`、`ALTER` | 阻止,除非显式允许危险 SQL | | 允许写入时没有 `WHERE` 的 `UPDATE` 或 `DELETE` | 阻止 | diff --git a/docs/content/docs/mcp.mdx b/docs/content/docs/mcp.mdx index 3b9327151..005c96986 100644 --- a/docs/content/docs/mcp.mdx +++ b/docs/content/docs/mcp.mdx @@ -218,7 +218,7 @@ MCP query execution is intentionally conservative: | Rule | Default | |---|---| | Empty or unrecognized SQL | Blocked | -| More than one SQL statement | Blocked | +| Multiple SQL statements | Allowed; each statement is checked and executed separately | | Non-read SQL | Blocked unless writes are explicitly allowed | | `DROP`, `TRUNCATE`, or `ALTER` | Blocked unless dangerous SQL is explicitly allowed | | `UPDATE` or `DELETE` without `WHERE` when writes are allowed | Blocked | diff --git a/packages/app-tests/binaryCellDownload.test.ts b/packages/app-tests/binaryCellDownload.test.ts new file mode 100644 index 000000000..cfc80bc92 --- /dev/null +++ b/packages/app-tests/binaryCellDownload.test.ts @@ -0,0 +1,58 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + binaryCellDownloadFileName, + binaryCellDownloadPayload, + canDownloadBinaryCellValue, + isBinaryCellColumnType, + parseBinaryCellHexValue, +} from "../../apps/desktop/src/lib/binaryCellDownload.ts"; + +test("parseBinaryCellHexValue accepts 0x and \\x prefixed hex values", () => { + assert.deepEqual(Array.from(parseBinaryCellHexValue("0X48656c6c6f") ?? []), [72, 101, 108, 108, 111]); + assert.deepEqual(Array.from(parseBinaryCellHexValue("\\x00 ff") ?? []), [0, 255]); +}); + +test("parseBinaryCellHexValue rejects non-hex and odd-length payloads", () => { + assert.equal(parseBinaryCellHexValue("hello"), null); + assert.equal(parseBinaryCellHexValue("0x123"), null); + assert.equal(parseBinaryCellHexValue(null), null); +}); + +test("binary cell download detects common blob column types", () => { + assert.equal(isBinaryCellColumnType("BLOB"), true); + assert.equal(isBinaryCellColumnType("RAW(2000)"), true); + assert.equal(isBinaryCellColumnType("long raw"), true); + assert.equal(isBinaryCellColumnType("varchar"), false); +}); + +test("canDownloadBinaryCellValue allows displayed binary hex strings", () => { + assert.equal(canDownloadBinaryCellValue("0x89504e47", "BLOB"), true); + assert.equal(canDownloadBinaryCellValue("0x89504e47"), true); + assert.equal(canDownloadBinaryCellValue("89504e47", "BLOB"), false); +}); + +test("binaryCellDownloadPayload builds raw and decoded payloads", () => { + const binary = binaryCellDownloadPayload("0x4869", "binary"); + assert.equal(binary.mimeType, "application/octet-stream"); + assert.equal(binary.extension, "bin"); + assert.deepEqual(Array.from(binary.data as Uint8Array), [72, 105]); + + const text = binaryCellDownloadPayload("0x4869", "utf8"); + assert.equal(text.mimeType, "text/plain;charset=utf-8"); + assert.equal(text.extension, "txt"); + assert.equal(text.data, "Hi"); +}); + +test("binaryCellDownloadPayload decodes GBK text bytes", () => { + const payload = binaryCellDownloadPayload("0xd6d0cec4", "gbk"); + assert.equal(payload.data, "中文"); +}); + +test("binaryCellDownloadFileName sanitizes column names", () => { + assert.equal( + binaryCellDownloadFileName({ column: "avatar/blob", rowNumber: 7, mode: "gbk", extension: "txt" }), + "avatar-blob-row-7-gbk.txt", + ); +}); diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md index d4beaa809..ee68c5e4b 100644 --- a/packages/mcp-server/README.md +++ b/packages/mcp-server/README.md @@ -92,7 +92,7 @@ See the [DBX CLI README](../cli/README.md) for command details. ## SQL Safety -`dbx_execute_query` is read-only by default. To allow write statements such as `INSERT` or `UPDATE`, set: +`dbx_execute_query` accepts multiple SQL statements and executes them one at a time after checking each statement. It is read-only by default. To allow write statements such as `INSERT` or `UPDATE`, set: ```bash DBX_MCP_ALLOW_WRITES=1 @@ -213,7 +213,7 @@ dbx query local "select 1" --json ### SQL 安全 -`dbx_execute_query` 默认只读。若要允许 `INSERT`、`UPDATE` 等写操作,设置: +`dbx_execute_query` 支持多条 SQL 语句,会逐条完成安全检查并依次执行。默认只读。若要允许 `INSERT`、`UPDATE` 等写操作,设置: ```bash DBX_MCP_ALLOW_WRITES=1 diff --git a/packages/mcp-server/src/index.ts b/packages/mcp-server/src/index.ts index 421b69458..a34d58980 100644 --- a/packages/mcp-server/src/index.ts +++ b/packages/mcp-server/src/index.ts @@ -16,8 +16,10 @@ import { parseMongoAggregateCommand, postBridge, sqlSafetyFromEnv, + splitSqlStatements, type Backend, type ConnectionConfig, + type QueryResult, } from "@dbx-app/node-core"; const require = createRequire(import.meta.url); @@ -36,6 +38,13 @@ function withDatabase(config: ConnectionConfig, database?: string): ConnectionCo return database === undefined ? config : { ...config, database }; } +function formatQueryToolResult(result: QueryResult, title?: string) { + const prefix = title ? `${title}\n` : ""; + if (result.columns.length === 0) return text(`${prefix}Query executed. ${result.row_count} row(s) affected.`); + const rows = result.rows.map((r) => result.columns.map((c) => formatCell(r[c]))); + return text(`${prefix}${mdTable(result.columns, rows)}\n\n${result.row_count} row(s)`); +} + export const DBX_CONNECTION_TYPE_DESCRIPTION = "Database type: postgres, mysql, sqlite, redis, duckdb, clickhouse, sqlserver, mongodb, oracle, elasticsearch, doris, starrocks, redshift, dameng, kingbase, highgo, vastbase, goldendb, gaussdb, yashandb, databricks, saphana, teradata, vertica, firebird, exasol, opengauss, oceanbase-oracle, gbase, h2, snowflake, trino, hive, db2, informix, neo4j, cassandra, bigquery, kylin, sundb, tdengine, xugu, jdbc, access"; @@ -108,16 +117,19 @@ export function createDbxMcpServer(backend: Backend, options: { isWebMode?: bool const config = await backend.findConnection(connection_name); if (!config) return toolError("CONNECTION_NOT_FOUND", `Connection "${connection_name}" not found.`); if (config.db_type !== "mongodb") { - const safety = evaluateSqlSafety(sql, sqlSafetyFromEnv()); + const safety = evaluateSqlSafety(sql, { ...sqlSafetyFromEnv(), allowMultipleStatements: true }); if (!safety.allowed) return toolError("SQL_BLOCKED", safety.reason ?? "SQL blocked."); } // MongoDB shell commands don't fit the SQL safety evaluator; the backend // (node-core executeQuery) applies command-aware read/write gating. try { - const result = await backend.executeQuery(withDatabase(config, database), sql); - if (result.columns.length === 0) return text(`Query executed. ${result.row_count} row(s) affected.`); - const rows = result.rows.map((r) => result.columns.map((c) => formatCell(r[c]))); - return text(`${mdTable(result.columns, rows)}\n\n${result.row_count} row(s)`); + const statements = config.db_type === "mongodb" ? [sql] : splitSqlStatements(sql); + const results = []; + for (const statement of statements) { + results.push(await backend.executeQuery(withDatabase(config, database), statement)); + } + if (results.length === 1) return formatQueryToolResult(results[0]); + return text(results.map((result, index) => formatQueryToolResult(result, `Statement ${index + 1}`).content[0].text).join("\n\n")); } catch (e: unknown) { const msg = e instanceof Error ? e.message : String(e); return toolError("QUERY_ERROR", msg); @@ -235,7 +247,7 @@ export function createDbxMcpServer(backend: Backend, options: { isWebMode?: bool if (!safety.allowed) return toolError("SQL_BLOCKED", safety.reason ?? "Query blocked."); } } else { - const safety = evaluateSqlSafety(sql, safetyOptions); + const safety = evaluateSqlSafety(sql, { ...safetyOptions, allowMultipleStatements: true }); if (!safety.allowed) return toolError("SQL_BLOCKED", safety.reason ?? "SQL blocked."); } // MongoDB shell commands bypass the SQL safety evaluator; pass MCP diff --git a/packages/mcp-server/tests/server.test.ts b/packages/mcp-server/tests/server.test.ts index 5ddf74ce8..d0cec228d 100644 --- a/packages/mcp-server/tests/server.test.ts +++ b/packages/mcp-server/tests/server.test.ts @@ -74,6 +74,48 @@ test("execute query scopes the connection to the requested database", async () = assert.equal(usedDatabase, "stores_demo"); }); +test("execute query runs safe multi-statement SQL one statement at a time", async () => { + const executed: string[] = []; + const scopedBackend: Backend = { + ...backend, + executeQuery: async (_config, sql) => { + executed.push(sql); + return { columns: ["value"], rows: [{ value: executed.length }], row_count: 1 }; + }, + }; + const server = createDbxMcpServer(scopedBackend, { isWebMode: true }); + + const result = await (server as any)._registeredTools.dbx_execute_query.handler({ + connection_name: "local", + sql: "select 1; select 2;", + }); + + assert.deepEqual(executed, ["select 1", "select 2"]); + assert.match(result.content[0].text, /Statement 1/); + assert.match(result.content[0].text, /Statement 2/); +}); + +test("execute query reports the blocked statement number for unsafe multi-statement SQL", async () => { + const oldAllowWrites = process.env.DBX_MCP_ALLOW_WRITES; + process.env.DBX_MCP_ALLOW_WRITES = "1"; + const server = createDbxMcpServer(backend, { isWebMode: true }); + + try { + const result = await (server as any)._registeredTools.dbx_execute_query.handler({ + connection_name: "local", + sql: "select 1; delete from users;", + }); + + assert.equal(result.isError, true); + assert.match(result.content[0].text, /SQL_BLOCKED:/); + assert.match(result.content[0].text, /Statement 2/); + assert.match(result.content[0].text, /WHERE/); + } finally { + if (oldAllowWrites === undefined) delete process.env.DBX_MCP_ALLOW_WRITES; + else process.env.DBX_MCP_ALLOW_WRITES = oldAllowWrites; + } +}); + test("mongodb list tables returns collections from the selected database", async () => { let usedDatabase = ""; const mongoConnection: ConnectionConfig = { ...connection, db_type: "mongodb", database: "admin" }; diff --git a/packages/node-core/src/sql-safety.ts b/packages/node-core/src/sql-safety.ts index be9516c14..e833cd238 100644 --- a/packages/node-core/src/sql-safety.ts +++ b/packages/node-core/src/sql-safety.ts @@ -1,6 +1,7 @@ export interface SqlSafetyOptions { allowWrites?: boolean; allowDangerous?: boolean; + allowMultipleStatements?: boolean; } export interface SqlSafetyDecision { @@ -14,9 +15,26 @@ const DANGEROUS_KEYWORDS = new Set(["drop", "truncate", "alter"]); export function evaluateSqlSafety(sql: string, options: SqlSafetyOptions = {}): SqlSafetyDecision { const statements = splitSqlStatements(sql); if (statements.length === 0) return { allowed: false, reason: "SQL is empty." }; - if (statements.length > 1) return { allowed: false, reason: "Only one SQL statement is allowed per MCP query." }; + if (statements.length > 1 && !options.allowMultipleStatements) { + return { allowed: false, reason: "Only one SQL statement is allowed per query." }; + } - const normalized = stripSqlCommentsAndStrings(statements[0]).trim(); + for (let i = 0; i < statements.length; i++) { + const decision = evaluateSingleSqlStatementSafety(statements[i], options); + if (!decision.allowed && statements.length > 1) { + return { + allowed: false, + reason: `Statement ${i + 1}: ${decision.reason ?? "SQL blocked."}`, + }; + } + if (!decision.allowed) return decision; + } + + return { allowed: true }; +} + +function evaluateSingleSqlStatementSafety(sql: string, options: SqlSafetyOptions = {}): SqlSafetyDecision { + const normalized = stripSqlCommentsAndStrings(sql).trim(); const firstKeyword = normalized.match(/^[a-zA-Z_]+/)?.[0]?.toLowerCase(); if (!firstKeyword) return { allowed: false, reason: "SQL statement is not recognized." }; @@ -52,7 +70,7 @@ export function sqlSafetyFromEnv(env: NodeJS.ProcessEnv = process.env): SqlSafet }; } -function splitSqlStatements(sql: string): string[] { +export function splitSqlStatements(sql: string): string[] { const statements: string[] = []; let current = ""; let quote: "'" | '"' | "`" | null = null; diff --git a/packages/node-core/tests/sql-safety.test.ts b/packages/node-core/tests/sql-safety.test.ts index 69723de51..05f9846b7 100644 --- a/packages/node-core/tests/sql-safety.test.ts +++ b/packages/node-core/tests/sql-safety.test.ts @@ -29,3 +29,26 @@ test("blocks update without where when writes are enabled", () => { assert.match(decision.reason ?? "", /WHERE/i); }); +test("blocks multiple SQL statements unless explicitly allowed", () => { + const decision = evaluateSqlSafety("select 1; select 2"); + + assert.equal(decision.allowed, false); + assert.match(decision.reason ?? "", /Only one SQL statement/); +}); + +test("allows multiple read-only SQL statements when enabled", () => { + const decision = evaluateSqlSafety("select 1; show tables", { allowMultipleStatements: true }); + + assert.equal(decision.allowed, true); +}); + +test("checks every statement in a multi-statement SQL string", () => { + const decision = evaluateSqlSafety("select 1; delete from users", { + allowMultipleStatements: true, + allowWrites: true, + }); + + assert.equal(decision.allowed, false); + assert.match(decision.reason ?? "", /Statement 2/i); + assert.match(decision.reason ?? "", /WHERE/i); +});