fix(ai): harden write SQL confirmation flow

This commit is contained in:
Abeautifulsnow 2026-07-28 11:56:23 +08:00 committed by GitHub
parent 1e72d28ed3
commit cb9d0f7cbf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 1258 additions and 51 deletions

2
.gitignore vendored
View File

@ -64,4 +64,6 @@ DBX_*_x64-portable.zip
.agents/skills
.pnpm-store/*
.dbx-web
# Local devtest connections (contains credentials; do not commit)
dbx-devtest-connections.json
.npmrc

View File

@ -1,4 +1,4 @@
<script setup lang="ts">
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref, watch, type Component } from "vue";
import { uuid } from "@/lib/common/utils";
import { useI18n } from "vue-i18n";
@ -61,7 +61,7 @@ import { ACTIVE_TEMPLATES_TOTAL_MAX, promptTemplateCharacterCount } from "@/type
import type { AgentEvent } from "@/lib/backend/tauri";
import { buildAiAgentPlan } from "@/lib/ai/aiAgentPlan";
import { extractFirstSqlCodeBlock } from "@/lib/ai/aiSqlExecutionPolicy";
import { extractFirstSqlCodeBlock, extractSingleSqlCodeBlock } from "@/lib/ai/aiSqlExecutionPolicy";
import { productionContextForDatabase } from "@/lib/database/productionSafety";
import ProductionContextBadge from "@/components/common/ProductionContextBadge.vue";
import { buildAiAgentStepItems, toolCallStepKey, upsertAgentStep, type AiAgentStepItem, type AiAgentStepTone } from "@/lib/ai/aiAgentStepPresentation";
@ -79,7 +79,7 @@ import { parseExplainResult, parseOracleExplainText, type ParsedExplainPlan } fr
import { copyToClipboard } from "@/lib/common/clipboard";
import { AI_TABLE_MENTION_CANDIDATE_LIMIT, AI_TABLE_MENTION_SCHEMA_LIMIT, filterAiTableMentionCandidates, formatAiTableMention, parseAiTableMentions, type AiTableMention } from "@/lib/ai/aiTableMentions";
import { isAiPromptImeCompositionEvent, shouldSubmitAiPromptOnKeydown } from "@/lib/ai/aiPromptKeyboard";
import { looksLikeActionProposal, containsChinese } from "@/lib/ai/aiProposalDetect";
import { looksLikeActionProposal, containsChinese, looksLikeWriteSqlProposal, shouldGrantWriteSqlOnShortAffirmative } from "@/lib/ai/aiProposalDetect";
import { visibleToActualIndex } from "@/lib/ai/aiMessageEdit";
import { shouldShowReasoningCharCount, reasoningCharCountClass } from "@/lib/ai/aiReasoningPresentation";
@ -566,19 +566,30 @@ const proposalConfirmMessage = computed<ChatMessage | null>(() => {
});
let allowWriteSqlForNextRun = false;
/** The specific write SQL embedded in the confirmed proposal, for binding to the agent run. */
let confirmedWriteSqlText: string | undefined = undefined;
/** Connection/database snapshot captured at confirmation time, verified at send time
* to prevent a database change between confirmation and execution. */
let confirmedConnectionId: string | undefined = undefined;
let confirmedDatabase: string | undefined = undefined;
/** Clear all pending write-confirmation state. Call on every early-return
* and failure path so a stale grant cannot leak into a subsequent send(). */
function clearPendingWriteGrant() {
allowWriteSqlForNextRun = false;
confirmedWriteSqlText = undefined;
confirmedConnectionId = undefined;
confirmedDatabase = undefined;
}
const productionContext = computed(() => productionContextForDatabase(props.connection, props.tab?.database));
function proposalContainsWriteSql(content: string) {
return /\b(insert|update|delete|replace|merge|create|alter|drop|truncate|rename|grant|revoke)\b/i.test(content);
}
function sendProposalReply(positive: boolean) {
// Disable while a stream is in flight or no proposal is currently active.
if (isGenerating.value) return;
const target = proposalConfirmMessage.value;
if (!target) return;
if (positive && productionContext.value.active && proposalContainsWriteSql(target.content)) {
if (positive && productionContext.value.active && looksLikeWriteSqlProposal(target.content)) {
const sql = extractFirstSqlCodeBlock(target.content);
if (sql) emit("replaceSql", sql);
toast(t("production.aiReviewRequired"), 5000);
@ -588,7 +599,17 @@ function sendProposalReply(positive: boolean) {
const replyZh = positive ? "请执行上面你刚提议的操作,不要再反问确认。" : "不用执行上面提到的操作,继续当前对话。";
const replyEn = positive ? "Execute the action you just proposed above; do not ask for confirmation again." : "Do not execute the action mentioned above; continue the current conversation.";
prompt.value = isZh ? replyZh : replyEn;
allowWriteSqlForNextRun = positive && assistantMode.value === "agent" && proposalContainsWriteSql(target.content);
if (positive && assistantMode.value === "agent" && looksLikeWriteSqlProposal(target.content)) {
confirmedWriteSqlText = extractSingleSqlCodeBlock(target.content);
if (confirmedWriteSqlText) {
allowWriteSqlForNextRun = true;
confirmedConnectionId = props.connection?.id;
confirmedDatabase = props.tab?.database || "";
}
// When no SQL code block is found in the proposal, treat the
// confirmation as rejected we cannot bind the agent to a
// specific SQL statement, so we must not grant blanket write access.
}
// Use the existing send pipeline so the message is added to history, persisted, etc.
send();
}
@ -1462,8 +1483,16 @@ async function send() {
const text = prompt.value.trim();
if ((!text && !selectedMentions.value.length && !selectedSqlFileMentions.value.length) || isGenerating.value) return;
if (!props.connection || !props.tab) return;
// Snapshot the target connection/database before any async work so that
// suspension points during context loading cannot cause a TOCTOU target switch.
const connection = props.connection;
const tab = props.tab;
if (!connection || !tab) {
clearPendingWriteGrant();
return;
}
if (!settings.isConfigured) {
clearPendingWriteGrant();
toast(t("ai.noConfig"));
return;
}
@ -1472,6 +1501,7 @@ async function send() {
// resume into concurrent agent runs.
isGenerating.value = true;
if (!(await promptTemplateStore.ensureLoaded())) {
clearPendingWriteGrant();
isGenerating.value = false;
toast(t("ai.customInstructionsLoadFailed"), 5000);
return;
@ -1503,9 +1533,58 @@ async function send() {
const requestedAction = activeAction.value;
const requestedMode = assistantMode.value;
// Detect user-typed short confirmation (e.g. ""/"go ahead") as an alternative
// path to the proposal button. Delegates to the shared pure function so the
// component and its unit tests share the same gating logic.
if (!allowWriteSqlForNextRun) {
allowWriteSqlForNextRun = shouldGrantWriteSqlOnShortAffirmative({
mode: requestedMode,
alreadyGranted: false,
isProduction: productionContext.value.active,
userText: text,
// Pass the history BEFORE the just-pushed user message so the function skips it.
messages: messages.value.slice(0, -1),
});
if (allowWriteSqlForNextRun) {
// Extract the confirmed SQL from the assistant's proposal message.
// If no SQL code block is found, treat the confirmation as rejected
// we cannot bind the agent to a specific SQL statement.
for (let i = messages.value.length - 2; i >= 0; i--) {
const msg = messages.value[i];
if (msg.kind === "contextSummary") continue;
if (msg.role === "assistant" && msg.content) {
confirmedWriteSqlText = extractSingleSqlCodeBlock(msg.content);
confirmedConnectionId = connection.id;
confirmedDatabase = tab.database || "";
break;
}
if (msg.role === "user") break;
}
if (!confirmedWriteSqlText) {
allowWriteSqlForNextRun = false;
}
}
}
// Verify the connection/database haven't changed since the user confirmed
// the write operation. If the user switched connections or databases between
// confirmation and execution, the grant is void.
if (allowWriteSqlForNextRun && confirmedWriteSqlText) {
if (confirmedConnectionId !== connection.id || confirmedDatabase !== (tab.database || "")) {
allowWriteSqlForNextRun = false;
confirmedWriteSqlText = undefined;
}
}
// Agent confirmation cannot grant autonomous writes while the active database is production.
const allowWriteSql = requestedMode === "agent" && allowWriteSqlForNextRun && !productionContext.value.active;
const confirmedWriteSql = allowWriteSql ? confirmedWriteSqlText : undefined;
// Capture the confirmed target snapshot before clearing the one-shot grant
// state, so the values survive to be passed through to the backend.
const confirmedTargetConnId = allowWriteSql ? confirmedConnectionId : undefined;
const confirmedTargetDb = allowWriteSql ? confirmedDatabase : undefined;
allowWriteSqlForNextRun = false;
confirmedWriteSqlText = undefined;
confirmedConnectionId = undefined;
confirmedDatabase = undefined;
messages.value.push({ role: "assistant", content: "" });
const assistantIdx = messages.value.length - 1;
const sessionId = uuid();
@ -1514,7 +1593,7 @@ async function send() {
agentTokens.value = null;
try {
const sqlFiles = await loadReferencedSqlFiles(selectedSqlFiles);
const context = await buildAiContext(props.tab, props.connection, {
const context = await buildAiContext(tab, connection, {
mentionedTables,
sqlFiles,
});
@ -1527,6 +1606,9 @@ async function send() {
instruction: modelInstruction,
context,
allowWriteSql,
confirmedWriteSql,
confirmedConnectionId: confirmedTargetConnId,
confirmedDatabase: confirmedTargetDb,
},
history,
(event: AgentEvent) => {
@ -1590,8 +1672,8 @@ async function send() {
action: requestedAction,
instruction: modelInstruction,
assistantContent: msg?.content || "",
connection: props.connection,
database: props.tab?.database,
connection: connection,
database: tab.database,
});
if (msg && requestedMode === "agent") msg.agentSteps = buildAiAgentStepItems(agentPlan);
if (agentPlan.handoffSql) emit("requestAutoExecuteSql", agentPlan.handoffSql);

View File

@ -31,4 +31,30 @@ describe("AI SQL dialect prompt", () => {
expect(prompt).toContain('double quotes "name"');
expect(prompt).toContain("Do not switch dialects merely because the user mentions another database in prose.");
});
it("agent mode instructs to ask for write confirmation instead of blocking", () => {
const prompt = buildSystemPrompt("general", context(), "agent");
expect(prompt).not.toContain("explain why it is blocked");
expect(prompt).toContain("ask for explicit confirmation");
expect(prompt).toContain("Never execute writes without confirmation");
});
it("agent mode zh instructs to ask for write confirmation instead of blocking", async () => {
await setLocale("zh-CN");
const prompt = buildSystemPrompt("general", context(), "agent");
expect(prompt).not.toContain("不要执行");
expect(prompt).toContain("明确询问用户是否确认执行");
expect(prompt).toContain("禁止不经确认直接执行写入");
await setLocale("en");
});
it("ask mode does not include agent write-confirmation prompt", () => {
const prompt = buildSystemPrompt("general", context(), "ask");
expect(prompt).not.toContain("ask for explicit confirmation");
expect(prompt).not.toContain("Never execute writes without confirmation");
expect(prompt).toContain("Ask mode");
});
});

View File

@ -0,0 +1,368 @@
import { describe, expect, it } from "vitest";
import { containsWriteSql, looksLikeActionProposal, looksLikeWriteSqlProposal, shouldGrantWriteSqlOnShortAffirmative, type WriteSqlGrantParams } from "@/lib/ai/aiProposalDetect";
import { aiSkillForAction } from "@/lib/ai/aiSkills";
import { extractFirstSqlCodeBlock, extractSingleSqlCodeBlock, countSqlCodeBlocks } from "@/lib/ai/aiSqlExecutionPolicy";
// ── looksLikeWriteSqlProposal ──────────────────────────────────────────────
describe("looksLikeWriteSqlProposal", () => {
it("detects zh write-SQL proposal (CREATE in last line)", () => {
expect(looksLikeWriteSqlProposal("需要我执行 CREATE TABLE users 吗?")).toBe(true);
});
it("detects en write-SQL proposal (INSERT in last line)", () => {
expect(looksLikeWriteSqlProposal("Should I run this INSERT?")).toBe(true);
});
it("detects write-SQL proposal split across lines", () => {
const multi = "已经分析了表结构。\n需要我执行这条 DELETE 语句吗?";
expect(looksLikeWriteSqlProposal(multi)).toBe(true);
});
it("detects generic confirmation phrase when message has SQL code block elsewhere", () => {
// The prompt instructs the model to ask "需要我执行这条 SQL 吗?"
// This has no write keywords in the last line, but the message body
// has CREATE TABLE in a code block — `looksLikeWriteSqlProposal` must
// check the whole message for write keywords, not just the last line.
const withCodeBlock = "以下是建表 SQL\n```sql\nCREATE TABLE users (id INT);\n```\n需要我执行这条 SQL 吗?";
expect(looksLikeWriteSqlProposal(withCodeBlock)).toBe(true);
});
it("detects en generic confirmation with SQL code block elsewhere", () => {
const en = "Here is the SQL:\n```sql\nINSERT INTO users VALUES (1);\n```\nShould I run this?";
expect(looksLikeWriteSqlProposal(en)).toBe(true);
});
it("rejects proposal where write keyword is in an earlier line but last line asks about reading", () => {
// DELETE appears in first line; the proposal (last line) is about a read query.
// With the safety rule `lastLineMentionsWriteSql`, this is rejected because
// the last line does NOT explicitly reference the write SQL or operation.
const ambiguous = "DELETE 语句会删除所有匹配记录。\n需要我帮你查询一下删除后还剩多少条数据吗";
expect(looksLikeActionProposal(ambiguous)).toBe(true);
expect(containsWriteSql(ambiguous)).toBe(true);
// REJECTED: last line asks about 查询 (read), not 执行 (write)
expect(looksLikeWriteSqlProposal(ambiguous)).toBe(false);
});
it("rejects read-only proposal even if write keyword appears elsewhere", () => {
const content = "DROP TABLE is dangerous.\nShould I list the tables for you?";
expect(containsWriteSql(content)).toBe(true);
// REJECTED: last line asks about "list" (read), not "execute" (write), and
// there is no SQL code block connection
expect(looksLikeWriteSqlProposal(content)).toBe(false);
});
it("rejects pure explanation without proposal", () => {
expect(looksLikeWriteSqlProposal("DELETE 会删除数据,你手动执行吧。")).toBe(false);
});
it("rejects empty content", () => {
expect(looksLikeWriteSqlProposal("")).toBe(false);
});
// Safety rule: last line must reference write SQL or write operation
it("rejects zh proposal where last line asks about read even though message has DELETE", () => {
// DELETE keyword in first sentence, but last line is about 查询 (read).
// Without lastLineMentionsWriteSql this was a false-positive.
const msg = "DELETE 语句会删除所有匹配记录。\n需要我帮你查询一下删除后还剩多少条数据吗";
expect(looksLikeWriteSqlProposal(msg)).toBe(false);
});
it("rejects en proposal where last line asks about list even though message has DROP", () => {
const msg = "DROP TABLE is dangerous.\nShould I list the tables for you?";
expect(looksLikeWriteSqlProposal(msg)).toBe(false);
});
it("accepts zh proposal referencing '这条 SQL' when message has write SQL code block", () => {
const msg = "以下是建表 SQL\n```sql\nCREATE TABLE users (id INT);\n```\n需要我执行这条 SQL 吗?";
expect(looksLikeWriteSqlProposal(msg)).toBe(true);
});
it("accepts en proposal referencing 'this SQL' when message has write SQL code block", () => {
const msg = "Here is the SQL:\n```sql\nINSERT INTO users VALUES (1);\n```\nShould I run this SQL?";
expect(looksLikeWriteSqlProposal(msg)).toBe(true);
});
it("rejects zh proposal: code block present but last line asks about 查询", () => {
// Has a write SQL code block, but the last line explicitly asks about a
// read operation (查询). This should NOT grant write permission.
const msg = "```sql\nDELETE FROM old_records;\n```\n需要我帮你查询一下 old_records 还剩多少条吗?";
expect(looksLikeWriteSqlProposal(msg)).toBe(false);
});
it("rejects en proposal: code block present but last line asks about query", () => {
const msg = "```sql\nDELETE FROM old_records;\n```\nShould I query how many records remain?";
expect(looksLikeWriteSqlProposal(msg)).toBe(false);
});
});
// ── shouldGrantWriteSqlOnShortAffirmative ──────────────────────────────────
describe("shouldGrantWriteSqlOnShortAffirmative (manual-typing confirmation)", () => {
const writeProposal = {
role: "assistant" as const,
content: "已经分析了表结构。\n需要我执行这条 CREATE TABLE users 语句吗?",
};
const insertProposal = {
role: "assistant" as const,
content: "I understand the schema.\nShould I run this INSERT for you?",
};
const writeProposalEn = {
role: "assistant" as const,
content: "Should I execute this CREATE TABLE users?",
};
// Prompt-mirrored generic confirmation with SQL code block.
const genericZhProposal = {
role: "assistant" as const,
content: "以下是建表 SQL\n```sql\nCREATE TABLE users (id INT, name TEXT);\n```\n需要我执行这条 SQL 吗?",
};
function params(overrides: Partial<WriteSqlGrantParams> = {}): WriteSqlGrantParams {
return {
mode: "agent",
alreadyGranted: false,
isProduction: false,
userText: "可以",
messages: [{ role: "user", content: "帮我创建表" }, writeProposal],
...overrides,
};
}
it("grants when user types 可以 and assistant asked to execute a write", () => {
expect(shouldGrantWriteSqlOnShortAffirmative(params())).toBe(true);
});
it("grants when user types yes and assistant asked to run INSERT", () => {
expect(shouldGrantWriteSqlOnShortAffirmative(params({ userText: "yes", messages: [{ role: "user", content: "帮我插入" }, insertProposal] }))).toBe(true);
});
it("grants when user types go ahead (en)", () => {
expect(shouldGrantWriteSqlOnShortAffirmative(params({ userText: "go ahead", messages: [{ role: "user", content: "please insert" }, writeProposalEn] }))).toBe(true);
});
it("grants for generic confirmation phrase when message has SQL code block", () => {
expect(shouldGrantWriteSqlOnShortAffirmative(params({ messages: [{ role: "user", content: "创建表" }, genericZhProposal] }))).toBe(true);
});
it("skips contextSummary messages to find the assistant message", () => {
expect(
shouldGrantWriteSqlOnShortAffirmative(
params({
userText: "好",
messages: [{ role: "user", content: "帮我创建" }, { role: "assistant", content: "--- context compaction ---", kind: "contextSummary" }, writeProposal],
}),
),
).toBe(true);
});
it("stops at user messages (does not look past the current turn)", () => {
expect(
shouldGrantWriteSqlOnShortAffirmative(
params({
messages: [{ role: "user", content: "查一下数据" }, { role: "assistant", content: "SELECT * FROM users 的结果是..." }, { role: "user", content: "现在帮我建表" }, writeProposal],
}),
),
).toBe(true);
});
// ── negative — write keyword in earlier line, NOT the proposal line ─────
it("does NOT grant when assistant only explains write SQL without asking to execute", () => {
const explanation = {
role: "assistant" as const,
content: "DELETE 语句会删除所有匹配的数据。当前环境不允许执行写操作,你可以手动在查询器里运行。",
};
expect(containsWriteSql(explanation.content)).toBe(true); // has DELETE
expect(looksLikeActionProposal(explanation.content)).toBe(false); // not a proposal
expect(shouldGrantWriteSqlOnShortAffirmative(params({ messages: [{ role: "user", content: "帮我删除" }, explanation] }))).toBe(false);
});
it("does NOT grant when assistant proposes a non-write action", () => {
const readProposal = {
role: "assistant" as const,
content: "需要我查询一下 users 表的结构吗?",
};
expect(looksLikeActionProposal(readProposal.content)).toBe(true);
expect(looksLikeWriteSqlProposal(readProposal.content)).toBe(false);
expect(shouldGrantWriteSqlOnShortAffirmative(params({ userText: "好", messages: [{ role: "user", content: "看看有哪些表" }, readProposal] }))).toBe(false);
});
// ── guard conditions ────────────────────────────────────────────────────
it("does NOT grant in ask mode", () => {
expect(shouldGrantWriteSqlOnShortAffirmative(params({ mode: "ask" }))).toBe(false);
});
it("does NOT grant when production DB is active", () => {
expect(shouldGrantWriteSqlOnShortAffirmative(params({ isProduction: true }))).toBe(false);
});
it("does NOT grant when already granted (avoid overwrite)", () => {
expect(shouldGrantWriteSqlOnShortAffirmative(params({ alreadyGranted: true }))).toBe(false);
});
it("does NOT grant when user text is NOT a short affirmative", () => {
expect(shouldGrantWriteSqlOnShortAffirmative(params({ userText: "好的,我来看看具体情况再说" }))).toBe(false);
});
});
// ── containsWriteSql regex ──────────────────────────────────────────────────
describe("containsWriteSql regex", () => {
it("matches INSERT", () => expect(containsWriteSql("INSERT INTO users VALUES (1)")).toBe(true));
it("matches UPDATE", () => expect(containsWriteSql("UPDATE users SET name='x'")).toBe(true));
it("matches DELETE", () => expect(containsWriteSql("DELETE FROM users")).toBe(true));
it("matches CREATE TABLE", () => expect(containsWriteSql("CREATE TABLE t (id INT)")).toBe(true));
it("matches ALTER TABLE", () => expect(containsWriteSql("ALTER TABLE users ADD COLUMN x INT")).toBe(true));
it("matches DROP TABLE", () => expect(containsWriteSql("DROP TABLE users")).toBe(true));
it("matches TRUNCATE", () => expect(containsWriteSql("TRUNCATE TABLE users")).toBe(true));
it("matches RENAME", () => expect(containsWriteSql("RENAME TABLE old TO new")).toBe(true));
it("matches GRANT", () => expect(containsWriteSql("GRANT SELECT ON t TO u")).toBe(true));
it("matches REVOKE", () => expect(containsWriteSql("REVOKE SELECT ON t FROM u")).toBe(true));
it("matches REPLACE", () => expect(containsWriteSql("REPLACE INTO users VALUES (1)")).toBe(true));
it("matches MERGE", () => expect(containsWriteSql("MERGE INTO t USING s ON ...")).toBe(true));
it("case-insensitive: insert", () => expect(containsWriteSql("insert into users")).toBe(true));
it("does NOT match SELECT", () => expect(containsWriteSql("SELECT * FROM users")).toBe(false));
it("does NOT match SELECT only content", () => expect(containsWriteSql("需要我帮你查一下数据?")).toBe(false));
it("does NOT match empty", () => expect(containsWriteSql("")).toBe(false));
});
// ── Button-confirmation flow (looksLikeActionProposal) ──────────────────────
describe("button-confirmation write-SQL flow (looksLikeActionProposal path)", () => {
it("detects a write-SQL action proposal (需要我执行 CREATE...?)", () => {
const content = "已经分析了表结构。\n需要我执行 CREATE TABLE users 吗?";
expect(looksLikeActionProposal(content)).toBe(true);
expect(looksLikeWriteSqlProposal(content)).toBe(true);
});
it("detects a write-SQL action proposal (Should I run this INSERT?)", () => {
const content = "I think we need to add a row.\nShould I run this INSERT?";
expect(looksLikeActionProposal(content)).toBe(true);
expect(looksLikeWriteSqlProposal(content)).toBe(true);
});
it("detects generic confirmation phrase when message body has SQL code block", () => {
const content = "以下是建表 SQL\n```sql\nCREATE TABLE users (id INT, name TEXT);\n```\n需要我执行这条 SQL 吗?";
expect(looksLikeActionProposal(content)).toBe(true);
expect(looksLikeWriteSqlProposal(content)).toBe(true);
});
});
// ── executeAndExplain skill prompt ──────────────────────────────────────────
describe("executeAndExplain write-confirmation prompt", () => {
it("en: instructs to ask for confirmation instead of blocking", () => {
const skill = aiSkillForAction("executeAndExplain");
const enRule = skill.systemRules.en[0];
expect(enRule).toContain("ask the user for explicit confirmation");
expect(enRule).toContain("Do not execute writes without user confirmation");
expect(enRule).not.toContain("do not execute");
});
it("zh: instructs to ask for confirmation instead of blocking", () => {
const skill = aiSkillForAction("executeAndExplain");
const zhRule = skill.systemRules.zh[0];
expect(zhRule).toContain("明确询问用户是否确认执行");
expect(zhRule).toContain("禁止不经确认直接执行写入");
expect(zhRule).not.toContain("不要执行");
});
});
// ── extractFirstSqlCodeBlock (SQL binding) ─────────────────────────────────
describe("extractFirstSqlCodeBlock", () => {
it("extracts SQL from a fenced code block", () => {
const content = "以下是建表 SQL\n```sql\nCREATE TABLE users (id INT);\n```\n需要我执行这条吗";
expect(extractFirstSqlCodeBlock(content)).toBe("CREATE TABLE users (id INT);");
});
it("returns undefined when there is no code block", () => {
const content = "需要我执行 CREATE TABLE users 吗?";
expect(extractFirstSqlCodeBlock(content)).toBeUndefined();
});
it("returns undefined when only an inline code span is present", () => {
const content = "需要我执行 `CREATE TABLE users` 吗?";
expect(extractFirstSqlCodeBlock(content)).toBeUndefined();
});
it("returns undefined for empty content", () => {
expect(extractFirstSqlCodeBlock("")).toBeUndefined();
});
it("extracts SQL from a code block without language tag", () => {
const content = "Here is the SQL:\n```\nDROP TABLE old;\n```\nShould I execute it?";
expect(extractFirstSqlCodeBlock(content)).toBe("DROP TABLE old;");
});
});
// ── countSqlCodeBlocks ──────────────────────────────────────────────────────
describe("countSqlCodeBlocks", () => {
it("counts zero when there are no code blocks", () => {
expect(countSqlCodeBlocks("需要我执行 CREATE TABLE users 吗?")).toBe(0);
});
it("counts one code block", () => {
expect(countSqlCodeBlocks("```sql\nSELECT 1;\n```")).toBe(1);
});
it("counts two code blocks", () => {
const content = "```sql\nDELETE FROM old;\n```\n```sql\nDELETE FROM new;\n```";
expect(countSqlCodeBlocks(content)).toBe(2);
});
it("counts three code blocks", () => {
const content = "```sql\nA\n```\n```\nB\n```\n```sql\nC\n```";
expect(countSqlCodeBlocks(content)).toBe(3);
});
it("counts zero for inline code spans", () => {
expect(countSqlCodeBlocks("Use `SELECT 1` to test.")).toBe(0);
});
});
// ── extractSingleSqlCodeBlock (regression: ambiguous multi-block proposals) ──
describe("extractSingleSqlCodeBlock (multi-block safety)", () => {
it("extracts SQL when exactly one code block exists", () => {
const content = "以下是建表 SQL\n```sql\nCREATE TABLE users (id INT);\n```\n需要我执行这条吗";
expect(extractSingleSqlCodeBlock(content)).toBe("CREATE TABLE users (id INT);");
});
it("returns undefined when there are zero code blocks", () => {
const content = "需要我执行 CREATE TABLE users 吗?";
expect(extractSingleSqlCodeBlock(content)).toBeUndefined();
});
it("returns undefined when there are multiple code blocks (fail-closed)", () => {
// Regression: a message with "do not execute" DELETE first, then a
// recommended DELETE second. extractFirstSqlCodeBlock would return the
// first (dangerous) block, but extractSingleSqlCodeBlock rightly refuses.
const content = ["I found two approaches:", "```sql", "DELETE FROM users; -- do NOT execute this", "```", "```sql", "DELETE FROM users WHERE id = 7; -- recommended", "```", "Should I execute the recommended SQL?"].join("\n");
expect(extractFirstSqlCodeBlock(content)).toBe("DELETE FROM users; -- do NOT execute this");
expect(extractSingleSqlCodeBlock(content)).toBeUndefined();
});
it("returns undefined when multiple blocks have different language tags", () => {
const content = "```sql\nSELECT 1;\n```\n```postgresql\nSELECT 2;\n```";
expect(extractSingleSqlCodeBlock(content)).toBeUndefined();
});
it("returns undefined when multiple blocks have no language tags", () => {
const content = "```\nblock A\n```\n```\nblock B\n```";
expect(extractSingleSqlCodeBlock(content)).toBeUndefined();
});
it("counts code blocks case-insensitively for language tags", () => {
const content = "```SQL\nDELETE FROM a;\n```\n```Sql\nDELETE FROM b;\n```";
expect(countSqlCodeBlocks(content)).toBe(2);
expect(extractSingleSqlCodeBlock(content)).toBeUndefined();
});
});

View File

@ -95,6 +95,11 @@ export interface AiRequestInput {
instruction: string;
context: AiContext;
allowWriteSql?: boolean;
/** When allowWriteSql is true, the specific write SQL the user confirmed. */
confirmedWriteSql?: string;
/** Connection/database snapshot at confirmation time; verified at backend. */
confirmedConnectionId?: string;
confirmedDatabase?: string;
}
export interface CustomPromptContext {
@ -187,6 +192,9 @@ export async function runAgentStream(input: AiRequestInput, history: api.AiMessa
onEvent,
input.mode || "ask",
input.allowWriteSql || false,
input.confirmedWriteSql,
input.confirmedConnectionId,
input.confirmedDatabase,
);
}
@ -348,8 +356,8 @@ function buildModePromptLines(mode: AiAssistantMode, isZh: boolean): string[] {
? "用户提出数据查询意图时,必须调用 execute_query 工具执行 SQL不要只输出 SQL 文本后停止。先用 list_tables/get_columns 了解 schema再调用 execute_query 获取真实结果,最后基于结果回答用户。"
: "When the user expresses a data query intent, you MUST call the execute_query tool to run the SQL — do NOT just output SQL text and stop. Use list_tables/get_columns to understand the schema first, then call execute_query to get real results, then answer based on the actual data.",
isZh
? "只有 SELECT、WITH、SHOW、DESCRIBE、EXPLAIN 可以通过 execute_query 执行。如果用户要求写入操作,先解释原因,不要执行。"
: "Only SELECT, WITH, SHOW, DESCRIBE, EXPLAIN can be executed via execute_query. If the user requests a write operation, explain why it is blocked instead of executing.",
? "当用户要求写入操作INSERT/UPDATE/DELETE/CREATE/ALTER/DROP/TRUNCATE 等)时,先在一个 ```sql 代码块中给出精确的写 SQL再在回复末尾用问句明确询问用户是否确认执行例如'需要我执行这条 CREATE TABLE 语句吗?')。待用户明确确认后再调用 execute_query并原样使用该代码块中的 SQL不得改写、重新格式化或补充语句。禁止不经确认直接执行写入。"
: "When the user requests a write operation (INSERT/UPDATE/DELETE/CREATE/ALTER/DROP/TRUNCATE, etc.), first put the exact proposed write SQL in one ```sql code block, then ask for explicit confirmation at the end of your reply with a question that names the specific operation (e.g., 'Should I execute this CREATE TABLE?'). Only call execute_query for writes after the user explicitly confirms, and use the exact SQL from that code block without rewriting, reformatting, or adding statements. Never execute writes without confirmation.",
isZh ? "如果安全执行条件不满足,先说明原因,再给只读预览或澄清问题。" : "If safe execution requirements are not met, explain why first, then provide a read-only preview or a clarifying question.",
];
}

View File

@ -22,7 +22,7 @@ function stripOuterPunctuation(input: string): string {
}
/** Returns the last non-empty line/sentence-ish chunk of the assistant message. */
function lastNonEmptyLine(content: string): string {
export function lastNonEmptyLine(content: string): string {
const lines = content.split(/\r?\n/);
for (let i = lines.length - 1; i >= 0; i--) {
const trimmed = lines[i].trim();
@ -166,3 +166,138 @@ export function isShortNegative(content: string): boolean {
const all = [...ZH_NEGATIVE, ...EN_NEGATIVE];
return all.some((re) => re.test(cleaned));
}
/**
* Regular expression matching common SQL write/DDL keywords.
* Shared between AiAssistant.vue and tests so the regex stays in one place.
*/
export const WRITE_SQL_KEYWORD_RE = /\b(insert|update|delete|replace|merge|create|alter|drop|truncate|rename|grant|revoke)\b/i;
/**
* Returns true when the text contains SQL write or DDL keywords.
*/
export function containsWriteSql(text: string): boolean {
return WRITE_SQL_KEYWORD_RE.test(text);
}
/**
* Returns true when the assistant message ends with an action proposal AND
* the message as a whole contains write/DDL SQL keywords.
*
* Check order:
* 1. Last non-empty line must be an action proposal (question mark + ask
* phrase + action verb).
* 2. The *entire message* (not just the last line) must contain at least
* one write/DDL keyword (INSERT, DELETE, CREATE, etc.).
*
* This two-step check handles the common pattern where the model outputs
* a SQL code block followed by a generic confirmation question like
* "需要我执行这条 SQL 吗?" the write keywords live in the code block,
* not in the last line.
*
* **Safety rule**: the last line (the proposal itself) MUST explicitly
* reference the write SQL or the write operation. A message where the
* last line asks about a read operation but a write keyword appears
* elsewhere is NOT treated as a write-SQL proposal the assistant is
* offering a read, not a write. Without this rule, confirming "need me
* to query the remaining records?" could inadvertently grant write
* permission because DELETE appeared earlier in the message.
*/
export function looksLikeWriteSqlProposal(content: string): boolean {
if (!content) return false;
const lastLine = lastNonEmptyLine(content);
if (!lastLine) return false;
// Step 1: last line must be an action proposal (ask + action + question mark).
if (!/[?]\s*$/.test(lastLine)) return false;
const isZh = containsChinese(lastLine);
const askPhrases = isZh ? ZH_ASK_PHRASES : EN_ASK_PHRASES;
const actionPhrases = isZh ? ZH_ACTION_PHRASES : EN_ACTION_PHRASES;
if (!askPhrases.some((re) => re.test(lastLine))) return false;
if (!actionPhrases.some((re) => re.test(lastLine))) return false;
// Step 2: the full message must contain write/DDL SQL keywords somewhere
// (typically in a code block or earlier sentence).
if (!containsWriteSql(content)) return false;
// Safety check: the last line must explicitly reference the write SQL or
// the write/DDL operation itself. A last line that asks about a read
// ("查询"/"query") but happens to have a write keyword elsewhere in the
// message is NOT a write-SQL proposal — it is a read proposal.
// We check for three signals:
// a) The last line contains a write/DDL keyword directly, OR
// b) The last line contains a "this SQL" / "这条 SQL" reference that
// points back to the code block with the write SQL, OR
// c) The content has a SQL code block AND the last line mentions
// SQL / 执行 without specifying a different action (e.g. 查询).
return lastLineMentionsWriteSql(lastLine, isZh, content);
}
/**
* Returns true when the proposal line explicitly references a write/DDL
* SQL operation (not a read-only action that happens to coexist with a
* write keyword elsewhere in the message).
*/
function lastLineMentionsWriteSql(lastLine: string, isZh: boolean, fullContent: string): boolean {
// Signal (a): last line contains a write/DDL keyword itself.
if (containsWriteSql(lastLine)) return true;
// Signal (b): last line references "this SQL" / "这条 SQL" / "这条语句"
// pointing back to a code block that contains the write SQL.
if (isZh) {
if (/这条\s*SQL|这条\s*语句|这个\s*SQL|这段\s*SQL|上述\s*SQL|以上\s*SQL/.test(lastLine)) return true;
} else {
if (/this\s+SQL|the\s+SQL|this\s+statement|the\s+statement|this\s+query|the\s+query|above\s+SQL/.test(lastLine)) return true;
}
// Signal (c): message has a SQL code block AND the last line mentions
// 执行/execute/run without specifying a different action like 查询/query.
const hasCodeBlock = /```sql|```mysql|```postgresql|```sqlite|```tsql|```clickhouse|```\s*\n/.test(fullContent);
if (hasCodeBlock) {
if (isZh) {
// Must mention 执行 or 运行 (execute/write-like), NOT 查询/查看 (read-like).
if (/(?:执行|运行)/.test(lastLine) && !/(?:查询|查看|看看|看一下|读取)/.test(lastLine)) return true;
} else {
if (/\b(?:execute|run)\b/i.test(lastLine) && !/\b(?:query|read|fetch|retrieve|list|inspect|check|sample)\b/i.test(lastLine)) return true;
}
}
return false;
}
/** Parameters for {@link shouldGrantWriteSqlOnShortAffirmative}. */
export interface WriteSqlGrantParams {
mode: string;
/** True when allowWriteSqlForNextRun has already been set, e.g. by the button path. */
alreadyGranted: boolean;
isProduction: boolean;
userText: string;
/**
* Conversation history BEFORE the current user text was pushed.
* The function scans backward from the last message, skipping
* contextSummary entries, and stops at the first user message.
*/
messages: Array<{ role: "user" | "assistant"; content: string; kind?: "contextSummary" }>;
}
/**
* Pure-function extraction of the manual-confirmation gating logic used in
* AiAssistant.vue send(). Returns true when the user just typed a short
* affirmative reply (e.g. "可以"/"go ahead") and the most recent assistant
* message before the user message is a write-SQL action proposal.
*
* Shared between the component and its unit tests so the two cannot drift.
*/
export function shouldGrantWriteSqlOnShortAffirmative(params: WriteSqlGrantParams): boolean {
if (params.mode !== "agent" || params.isProduction || params.alreadyGranted) return false;
if (!isShortAffirmative(params.userText)) return false;
for (let i = params.messages.length - 1; i >= 0; i--) {
const msg = params.messages[i];
if (msg.kind === "contextSummary") continue;
if (msg.role === "assistant" && msg.content && looksLikeWriteSqlProposal(msg.content)) return true;
if (msg.role === "user") return false;
}
return false;
}

View File

@ -237,8 +237,14 @@ export const AI_SKILL_DEFINITIONS: AiSkillDefinition[] = [
zh: "执行当前的 SQL 并解释结果。先调用 execute_query 运行当前 SQL再基于真实结果解释查询含义、数据特征和值得注意的点。",
},
systemRules: {
en: ["Only SELECT/WITH/SHOW/DESCRIBE/EXPLAIN can be executed. If the current SQL is a write operation, do not execute; explain its impact instead.", "Base explanations on real execution results; do not speculate about data."],
zh: ["只有 SELECT/WITH/SHOW/DESCRIBE/EXPLAIN 可执行。如果当前 SQL 是写操作,不要执行,改为解释其影响。", "解释要基于真实执行结果,不要凭空推测数据。"],
en: [
"If the current SQL is a write operation, first put its exact text in one ```sql code block and ask the user for explicit confirmation (e.g., 'Should I execute this SQL?') instead of executing immediately. After confirmation, execute that code-block SQL verbatim without rewriting, reformatting, or adding statements. Do not execute writes without user confirmation.",
"Base explanations on real execution results; do not speculate about data.",
],
zh: [
"如果当前 SQL 是写操作,先在一个 ```sql 代码块中给出其精确文本,并在回复末尾明确询问用户是否确认执行(例如'需要我执行这条 SQL 吗?'),不要直接执行。确认后必须原样执行该代码块中的 SQL不得改写、重新格式化或补充语句。禁止不经确认直接执行写入。",
"解释要基于真实执行结果,不要凭空推测数据。",
],
},
outputContract: {
en: ["Output format: lead with an execution-result summary, then step through the key data and its meaning."],

View File

@ -123,3 +123,23 @@ export function extractFirstSqlCodeBlock(content: string): string | undefined {
const sql = match?.[1]?.trim();
return sql || undefined;
}
/**
* Count SQL code blocks in content. Used to reject ambiguous multi-block
* proposals when a message contains more than one SQL code block, we
* cannot determine which one the user intended to confirm.
*/
export function countSqlCodeBlocks(content: string): number {
const matches = content.match(/```(?:sql|mysql|postgresql|sqlite|tsql|clickhouse)?\s*\n[\s\S]*?```/gi);
return matches ? matches.length : 0;
}
/**
* Extract a single SQL code block from content. Returns undefined when the
* message has zero or more than one code block only an unambiguous
* single-block proposal yields a confirmed SQL binding.
*/
export function extractSingleSqlCodeBlock(content: string): string | undefined {
if (countSqlCodeBlocks(content) !== 1) return undefined;
return extractFirstSqlCodeBlock(content);
}

View File

@ -1137,11 +1137,24 @@ function isAgentEvent(v: unknown): v is import("@/lib/backend/tauri").AgentEvent
return typeof v === "object" && v !== null && "type" in v && typeof (v as Record<string, unknown>).type === "string";
}
export async function aiAgentStream(sessionId: string, request: AiCompletionRequest, connectionId: string, database: string, dbType: string, onEvent: (event: import("@/lib/backend/tauri").AgentEvent) => void, mode?: string, allowWriteSql = false, signal?: AbortSignal): Promise<string> {
export async function aiAgentStream(
sessionId: string,
request: AiCompletionRequest,
connectionId: string,
database: string,
dbType: string,
onEvent: (event: import("@/lib/backend/tauri").AgentEvent) => void,
mode?: string,
allowWriteSql = false,
confirmedWriteSql?: string,
confirmedConnectionId?: string,
confirmedDatabase?: string,
signal?: AbortSignal,
): Promise<string> {
const res = await fetch(apiUrl("/api/ai/agent-stream"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sessionId, request, connectionId, database, dbType, mode: mode || "ask", allowWriteSql }),
body: JSON.stringify({ sessionId, request, connectionId, database, dbType, mode: mode || "ask", allowWriteSql, confirmedWriteSql, confirmedConnectionId, confirmedDatabase }),
signal,
});
if (!res.ok) throw new Error(await res.text());

View File

@ -390,7 +390,20 @@ export type AgentEvent =
| { type: "context_compacted"; summary: string; summary_tokens: number; compacted_messages: number; estimated_before: number; estimated_after: number }
| { type: "error"; message: string };
export async function aiAgentStream(sessionId: string, request: AiCompletionRequest, connectionId: string, database: string, dbType: string, onEvent: (event: AgentEvent) => void, mode?: string, allowWriteSql = false, _signal?: AbortSignal): Promise<string> {
export async function aiAgentStream(
sessionId: string,
request: AiCompletionRequest,
connectionId: string,
database: string,
dbType: string,
onEvent: (event: AgentEvent) => void,
mode?: string,
allowWriteSql = false,
confirmedWriteSql?: string,
confirmedConnectionId?: string,
confirmedDatabase?: string,
_signal?: AbortSignal,
): Promise<string> {
const unlisten: UnlistenFn = await listen<AgentEvent>("ai-agent-event", (event) => {
onEvent(event.payload);
if (event.payload.type === "agent_end" || event.payload.type === "error") {
@ -398,7 +411,7 @@ export async function aiAgentStream(sessionId: string, request: AiCompletionRequ
}
});
try {
return await invoke("ai_agent_stream", { sessionId, request, connectionId, database, dbType, mode, allowWriteSql });
return await invoke("ai_agent_stream", { sessionId, request, connectionId, database, dbType, mode, allowWriteSql, confirmedWriteSql, confirmedConnectionId, confirmedDatabase });
} catch (e) {
unlisten();
throw e;

View File

@ -132,6 +132,7 @@ pub async fn run_agent_loop(
agent_mode: is_agent_mode,
allow_writes: agent_ctx.sql_permissions.allow_writes,
allow_dangerous: agent_ctx.sql_permissions.allow_dangerous,
confirmed_write_sql: agent_ctx.sql_permissions.confirmed_write_sql.clone(),
mcp_server_command: agent_ctx.cli_mcp_server_command.clone(),
};
if matches!(config.provider, AiProvider::ClaudeCodeCli) {
@ -163,7 +164,7 @@ pub async fn run_agent_loop(
.await;
}
let tools = if is_agent_mode {
agent_tools::all_tools(agent_ctx.db_type, agent_ctx.sql_permissions)
agent_tools::all_tools(agent_ctx.db_type, agent_ctx.sql_permissions.clone())
} else {
agent_tools::read_only_tools(agent_ctx.db_type)
};
@ -368,7 +369,7 @@ pub async fn run_agent_loop(
let conn2 = agent_ctx.connection_id.clone();
let db2 = agent_ctx.database.clone();
let db_type = agent_ctx.db_type;
let sql_permissions = agent_ctx.sql_permissions;
let sql_permissions = agent_ctx.sql_permissions.clone();
// Split by index into parallel and sequential groups using tool metadata
let tool_parallel_map: std::collections::HashMap<&str, bool> =
@ -387,7 +388,8 @@ pub async fn run_agent_loop(
let state = Arc::clone(&state2);
let conn = conn2.clone();
let db = db2.clone();
async move { agent_tools::execute_tool(&tc, &state, &conn, &db, &db_type, sql_permissions).await }
let perms = sql_permissions.clone();
async move { agent_tools::execute_tool(&tc, &state, &conn, &db, &db_type, perms).await }
})
.collect();
let parallel_results = join_all(parallel_futures).await;
@ -397,7 +399,7 @@ pub async fn run_agent_loop(
for &i in &sequential_indices {
let tc = make_tc(&collected_tool_calls[i]);
sequential_results
.push(agent_tools::execute_tool(&tc, &state2, &conn2, &db2, &db_type, sql_permissions).await);
.push(agent_tools::execute_tool(&tc, &state2, &conn2, &db2, &db_type, sql_permissions.clone()).await);
}
// Merge results back into original order

View File

@ -27,10 +27,68 @@ const BROWSE_COLLECTION_LIMIT: usize = 20;
/// Absolute maximum rows any query tool may request.
const MAX_ALLOWED_ROWS: usize = 100;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct AgentSqlPermissions {
pub allow_writes: bool,
pub allow_dangerous: bool,
/// When present, write/DDL execute_query calls must match this SQL
/// (after trimming only surrounding whitespace). Set by the frontend
/// when the user confirms a specific write-SQL proposal.
pub confirmed_write_sql: Option<String>,
}
/// Build the write permissions for one AI-agent run from an explicit user
/// confirmation. Both Desktop and Web use this fail-closed boundary so an
/// empty confirmation or a production target cannot enable writes.
pub fn confirmed_write_sql_permissions(
production_database: bool,
allow_write_sql: bool,
confirmed_write_sql: Option<String>,
) -> AgentSqlPermissions {
let confirmed_write_sql = confirmed_write_sql.filter(|sql| !sql.trim().is_empty());
let write_sql_confirmed = !production_database && allow_write_sql && confirmed_write_sql.is_some();
AgentSqlPermissions {
allow_writes: write_sql_confirmed,
allow_dangerous: write_sql_confirmed,
confirmed_write_sql: write_sql_confirmed.then_some(confirmed_write_sql).flatten(),
}
}
/// Verify that the confirmed connection/database snapshot matches the actual
/// target. Returns `(allow_write_sql, confirmed_write_sql)` — when the target
/// does not match, the grant is voided (allow=false, confirmed=None).
///
/// This is defense-in-depth: the frontend also verifies synchronously, but
/// this backend check protects CLI-provider and API-driven paths.
pub fn verify_confirmed_target(
allow_write_sql: Option<bool>,
confirmed_write_sql: Option<String>,
confirmed_connection_id: Option<String>,
confirmed_database: Option<String>,
actual_connection_id: &str,
actual_database: &str,
) -> (Option<bool>, Option<String>) {
let Some(ref confirmed_sql) = confirmed_write_sql else {
return (allow_write_sql, confirmed_write_sql);
};
// Only verify when a write SQL was actually confirmed.
let target_mismatch = confirmed_connection_id.as_deref() != Some(actual_connection_id)
|| confirmed_database.as_deref() != Some(actual_database);
if target_mismatch {
log::warn!(
"Write-SQL grant voided: confirmed target (conn={:?}, db={:?}) does not match actual (conn={}, db={}).",
confirmed_connection_id,
confirmed_database,
actual_connection_id,
actual_database,
);
// SQL can contain literals or credentials. Keep diagnostic visibility
// behind the shared debug-only redaction boundary.
crate::sql_diagnostics::debug_sql("write_sql_grant_voided", confirmed_sql);
return (Some(false), None);
}
(allow_write_sql, confirmed_write_sql)
}
fn sql_risk_allowed(risk: SqlRisk, permissions: AgentSqlPermissions) -> bool {
@ -433,7 +491,35 @@ async fn execute_get_columns(
Ok(lines.join("\n"))
}
/// Execute a read-only SQL query via the execute_query tool.
/// Normalize a SQL string for confirmation comparison.
///
/// Confirmation is intentionally fail-closed: only surrounding whitespace is
/// ignored. SQL case, internal whitespace, comments, literals, and quoted
/// identifiers can all affect execution semantics across supported dialects.
pub fn normalize_sql_for_confirmation(sql: &str) -> String {
sql.trim().to_string()
}
fn truncate_sql_for_error(sql: &str) -> String {
let s = sql.trim();
let char_count = s.chars().count();
if char_count <= 120 {
s.to_string()
} else {
format!("{}...", s.chars().take(117).collect::<String>())
}
}
/// Check whether `executed_sql` matches the user-confirmed write SQL. Returns
/// `true` when no confirmation is required (confirmed is `None`) or when the
/// trimmed forms match.
fn sql_matches_confirmed_write(executed_sql: &str, confirmed: &Option<String>) -> bool {
match confirmed {
None => true,
Some(confirmed) => normalize_sql_for_confirmation(executed_sql) == normalize_sql_for_confirmation(confirmed),
}
}
async fn execute_execute_query(
tool_call: &ToolCall,
state: &Arc<AppState>,
@ -463,7 +549,7 @@ async fn execute_execute_query(
return Err("Blocked: AI agents cannot execute writes or DDL on a production database. Return the SQL for the user to review and execute manually in DBX.".to_string());
}
}
if !sql_risk_allowed(risk, sql_permissions) {
if !sql_risk_allowed(risk, sql_permissions.clone()) {
if risk == SqlRisk::Transaction {
return Err("Blocked: transaction control statements are not available to the AI agent.".to_string());
}
@ -473,6 +559,19 @@ async fn execute_execute_query(
));
}
// When the user confirmed a specific write SQL, the agent must
// execute only that SQL — not an arbitrary different statement.
if risk != SqlRisk::ReadOnly && !sql_matches_confirmed_write(sql, &sql_permissions.confirmed_write_sql) {
let confirmed = sql_permissions.confirmed_write_sql.as_deref().unwrap_or("");
return Err(format!(
"Blocked: the executed SQL does not match the user-confirmed SQL.\n\
Confirmed: {}\n\
Attempted: {}",
truncate_sql_for_error(confirmed),
truncate_sql_for_error(sql),
));
}
// Execute query using existing infrastructure
let options = QueryExecutionOptions { max_rows: Some(limit), timeout_secs: Some(30), ..Default::default() };
let result =
@ -838,7 +937,10 @@ mod tests {
#[test]
fn confirmed_sql_permissions_update_execute_query_contract() {
let tools = all_tools(DatabaseType::Mysql, AgentSqlPermissions { allow_writes: true, allow_dangerous: true });
let tools = all_tools(
DatabaseType::Mysql,
AgentSqlPermissions { allow_writes: true, allow_dangerous: true, confirmed_write_sql: None },
);
let execute_query = tools.iter().find(|tool| tool.name == "execute_query").unwrap();
assert!(execute_query.description.contains("explicitly confirmed"));
@ -849,10 +951,13 @@ mod tests {
fn sql_permissions_keep_writes_blocked_until_confirmation() {
assert!(!sql_risk_allowed(SqlRisk::Write, AgentSqlPermissions::default()));
assert!(!sql_risk_allowed(SqlRisk::Ddl, AgentSqlPermissions::default()));
assert!(sql_risk_allowed(SqlRisk::Ddl, AgentSqlPermissions { allow_writes: true, allow_dangerous: true }));
assert!(sql_risk_allowed(
SqlRisk::Ddl,
AgentSqlPermissions { allow_writes: true, allow_dangerous: true, confirmed_write_sql: None }
));
assert!(!sql_risk_allowed(
SqlRisk::Transaction,
AgentSqlPermissions { allow_writes: true, allow_dangerous: true }
AgentSqlPermissions { allow_writes: true, allow_dangerous: true, confirmed_write_sql: None }
));
}
@ -946,4 +1051,128 @@ mod tests {
let result = build_browse_query(&DatabaseType::Postgres, "articles", "", 10);
assert!(result.is_err());
}
// ── SQL confirmation binding tests ──────────────────────────────────────
#[test]
fn normalize_sql_only_trims_outer_whitespace() {
assert_eq!(normalize_sql_for_confirmation(" CREATE TABLE users (id INT);\n"), "CREATE TABLE users (id INT);");
}
#[test]
fn confirmed_sql_binding_rejects_quoted_identifier_case_change() {
let confirmed = Some("DROP TABLE \"Users\"".to_string());
assert!(!sql_matches_confirmed_write("DROP TABLE \"users\"", &confirmed));
}
#[test]
fn confirmed_sql_binding_rejects_keyword_case_or_reformatting() {
let confirmed = Some("DELETE FROM AuditLog WHERE id = 1".to_string());
assert!(!sql_matches_confirmed_write("delete from AuditLog where id = 1", &confirmed));
assert!(!sql_matches_confirmed_write("DELETE FROM AuditLog\nWHERE id = 1", &confirmed));
}
#[test]
fn confirmed_sql_binding_rejects_line_comment_newline_change() {
let confirmed = Some("DELETE FROM users -- only one record\nWHERE id = 1".to_string());
assert!(!sql_matches_confirmed_write("DELETE FROM users -- only one record WHERE id = 1", &confirmed));
}
#[test]
fn sql_matches_when_no_confirmation_required() {
assert!(sql_matches_confirmed_write("DELETE FROM users WHERE id = 1", &None));
}
#[test]
fn sql_matches_when_only_outer_whitespace_differs() {
// The confirmed statement itself must be unchanged; only surrounding
// whitespace is ignored before comparison.
let confirmed = Some("CREATE TABLE users (id INT)".to_string());
assert!(sql_matches_confirmed_write(" CREATE TABLE users (id INT) ", &confirmed,));
}
#[test]
fn sql_mismatch_rejected_when_executed_differs_from_confirmed() {
let confirmed = Some("CREATE TABLE users (id INT)".to_string());
assert!(!sql_matches_confirmed_write("DROP TABLE users", &confirmed,));
}
#[test]
fn confirmed_sql_binding_rejects_same_table_different_statement() {
let confirmed = Some("INSERT INTO users (id, name) VALUES (1, 'test')".to_string());
assert!(!sql_matches_confirmed_write("DELETE FROM users WHERE id = 1", &confirmed,));
}
#[test]
fn confirmed_sql_binding_rejects_different_case_in_data_values() {
// Confirmed VALUES ('Alice') must NOT match executed VALUES ('alice').
// String-literal data values are preserved verbatim.
let confirmed = Some("INSERT INTO users (name) VALUES ('Alice')".to_string());
assert!(!sql_matches_confirmed_write("INSERT INTO users (name) VALUES ('alice')", &confirmed,));
}
#[test]
fn confirmed_sql_binding_rejects_different_whitespace_in_data_values() {
// Confirmed VALUES ('a b') must NOT match executed VALUES ('a b').
let confirmed = Some("INSERT INTO t (c) VALUES ('a b')".to_string());
assert!(!sql_matches_confirmed_write("INSERT INTO t (c) VALUES ('a b')", &confirmed,));
}
#[test]
fn confirmed_sql_default_is_none() {
let perms = AgentSqlPermissions::default();
assert_eq!(perms.confirmed_write_sql, None);
}
#[test]
fn confirmed_write_sql_diagnostics_redact_sensitive_literals() {
let confirmed_sql = "CREATE USER app_user WITH PASSWORD 'secret-123'";
let diagnostic = crate::sql_diagnostics::redact_sql_for_diagnostics(confirmed_sql);
assert!(!diagnostic.contains("secret-123"));
assert!(diagnostic.contains("'[REDACTED]'"));
}
#[test]
fn confirmed_write_permissions_bind_only_a_nonproduction_nonempty_confirmation() {
let confirmed_sql = Some("DELETE FROM sessions WHERE id = 7".to_string());
let permissions = confirmed_write_sql_permissions(false, true, confirmed_sql.clone());
assert!(permissions.allow_writes);
assert!(permissions.allow_dangerous);
assert_eq!(permissions.confirmed_write_sql, confirmed_sql);
assert!(!sql_matches_confirmed_write("DROP TABLE sessions", &permissions.confirmed_write_sql));
}
#[test]
fn confirmed_write_permissions_fail_closed_for_production_or_empty_confirmation() {
for (production_database, confirmed_write_sql) in
[(true, Some("DELETE FROM sessions".to_string())), (false, None), (false, Some(" \n".to_string()))]
{
let permissions = confirmed_write_sql_permissions(production_database, true, confirmed_write_sql);
assert!(!permissions.allow_writes);
assert!(!permissions.allow_dangerous);
assert_eq!(permissions.confirmed_write_sql, None);
}
}
#[test]
fn confirmed_sql_preserved_through_permission_construction() {
let perms = AgentSqlPermissions {
allow_writes: true,
allow_dangerous: true,
confirmed_write_sql: Some("CREATE TABLE t (c INT)".to_string()),
};
assert_eq!(perms.confirmed_write_sql.as_deref(), Some("CREATE TABLE t (c INT)"));
}
#[test]
fn write_allowed_when_confirmed_sql_is_none_and_writes_enabled() {
// confirmed_write_sql=None + allow_writes=true: write SQL is allowed
// (the check passes because sql_matches_confirmed_write returns true
// when no confirmation is required). This documents the current
// contract — the frontend is responsible for only sending
// allow_write_sql=true when a specific SQL was confirmed.
assert!(sql_matches_confirmed_write("INSERT INTO t VALUES (1)", &None));
}
}

View File

@ -568,6 +568,7 @@ mod tests {
agent_mode: true,
allow_writes: false,
allow_dangerous: false,
confirmed_write_sql: None,
mcp_server_command: None,
}
}

View File

@ -17,6 +17,9 @@ pub struct CliAgentRunOptions {
pub agent_mode: bool,
pub allow_writes: bool,
pub allow_dangerous: bool,
/// When present, write/DDL execute_query calls in the MCP subprocess must
/// match this SQL after trimming only surrounding whitespace.
pub confirmed_write_sql: Option<String>,
pub mcp_server_command: Option<CliAgentCommandSpec>,
}
@ -61,13 +64,41 @@ pub fn dbx_mcp_enabled_tools(agent_mode: bool) -> Vec<&'static str> {
}
pub fn dbx_mcp_scope_env(options: &CliAgentRunOptions) -> Vec<(&'static str, String)> {
vec![
let mut env = vec![
("DBX_MCP_ALLOW_WRITES", if options.allow_writes { "1" } else { "0" }.to_string()),
("DBX_MCP_ALLOW_DANGEROUS_SQL", if options.allow_dangerous { "1" } else { "0" }.to_string()),
("DBX_MCP_SCOPE_CONNECTION_ID", options.connection_id.clone()),
("DBX_MCP_SCOPE_CONNECTION_NAME", options.connection_name.clone()),
("DBX_MCP_SCOPE_DATABASE", options.database.clone()),
]
];
if let Some(ref sql) = options.confirmed_write_sql {
env.push(("DBX_MCP_CONFIRMED_WRITE_SQL", sql.clone()));
}
env
}
#[cfg(test)]
mod scope_env_tests {
use super::*;
#[test]
fn confirmed_write_sql_is_passed_to_the_scoped_mcp_subprocess() {
let options = CliAgentRunOptions {
connection_id: "connection-1".to_string(),
connection_name: "staging".to_string(),
database: "app".to_string(),
agent_mode: true,
allow_writes: true,
allow_dangerous: true,
confirmed_write_sql: Some("DELETE FROM sessions WHERE id = 7".to_string()),
mcp_server_command: None,
};
let env = dbx_mcp_scope_env(&options);
assert!(env.contains(&("DBX_MCP_CONFIRMED_WRITE_SQL", "DELETE FROM sessions WHERE id = 7".to_string())));
assert!(env.contains(&("DBX_MCP_ALLOW_WRITES", "1".to_string())));
assert!(env.contains(&("DBX_MCP_ALLOW_DANGEROUS_SQL", "1".to_string())));
}
}
pub fn append_config_overrides(args: &mut Vec<String>, overrides: impl IntoIterator<Item = String>) {

View File

@ -628,6 +628,7 @@ mod tests {
agent_mode: true,
allow_writes: false,
allow_dangerous: false,
confirmed_write_sql: None,
mcp_server_command: None,
}
}

View File

@ -58,9 +58,13 @@ fn effective_mcp_policy_with_legacy_allow_writes(
state: McpGlobalPolicyState,
legacy_allow_writes: Option<bool>,
) -> McpGlobalPolicy {
let configured = state.configured;
let mut policy = state.policy();
if !configured && legacy_allow_writes == Some(false) {
// When DBX_MCP_ALLOW_WRITES is explicitly set to false by the CLI agent
// for an unconfirmed run, force read-only regardless of the configured
// persistent MCP policy. Run-scoped CLI restrictions must override the
// persistent policy, otherwise a user with a writable configured policy
// can execute unconfirmed writes through CLI providers.
if legacy_allow_writes == Some(false) {
policy.read_only = true;
}
policy
@ -636,7 +640,7 @@ impl DbxBackend for WebBackend {
database: &str,
tool_name: &str,
arguments: Value,
_permissions: AgentSqlPermissions,
permissions: AgentSqlPermissions,
) -> ToolResult {
let result = async {
if tool_name != "execute_query" {
@ -649,6 +653,29 @@ impl DbxBackend for WebBackend {
}
self.ensure_connected(connection).await?;
let sql = arguments.get("sql").and_then(Value::as_str).ok_or("Missing SQL query")?;
// Replicate the confirmed-SQL binding check here because the
// /api/query/execute endpoint performs its own risk checks but
// does NOT receive the confirmed_write_sql binding from the MCP
// layer. Without this, a CLI/MCP agent could execute a different
// write/DDL statement after a single user confirmation.
let risk = dbx_core::sql_risk::classify_sql_risk_for_database(sql, connection.db_type)
.map_err(|error| format!("SQL risk classification failed: {error}"))?;
if risk != dbx_core::sql_risk::SqlRisk::ReadOnly {
if let Some(ref confirmed) = permissions.confirmed_write_sql {
let normalized = agent_tools::normalize_sql_for_confirmation(sql);
let normalized_confirmed = agent_tools::normalize_sql_for_confirmation(confirmed);
if normalized != normalized_confirmed {
return Err(format!(
"Blocked: the executed SQL does not match the user-confirmed SQL.\n\
Confirmed: {}\n\
Attempted: {}",
confirmed, sql,
));
}
}
}
let max_rows = arguments.get("limit").and_then(Value::as_u64).unwrap_or(100) as usize;
let response = self
.request(
@ -1397,11 +1424,19 @@ mod tests {
}
#[test]
fn legacy_read_only_only_restricts_an_unconfigured_policy() {
fn legacy_read_only_overrides_configured_and_unconfigured_policies() {
// DBX_MCP_ALLOW_WRITES=0 always forces read_only, even when the
// persistent MCP policy is configured as writable.
assert!(effective_mcp_policy_with_legacy_allow_writes(policy_state(false, false), Some(false)).read_only);
assert!(!effective_mcp_policy_with_legacy_allow_writes(policy_state(false, false), Some(true)).read_only);
assert!(!effective_mcp_policy_with_legacy_allow_writes(policy_state(true, false), Some(false)).read_only);
assert!(effective_mcp_policy_with_legacy_allow_writes(policy_state(true, false), Some(false)).read_only);
assert!(!effective_mcp_policy_with_legacy_allow_writes(policy_state(true, false), Some(true)).read_only);
// Configured read_only is a hard upper bound — env var cannot relax it.
assert!(effective_mcp_policy_with_legacy_allow_writes(policy_state(true, true), Some(true)).read_only);
assert!(effective_mcp_policy_with_legacy_allow_writes(policy_state(true, true), Some(false)).read_only);
// Unset env var leaves the policy as-is.
assert!(!effective_mcp_policy_with_legacy_allow_writes(policy_state(true, false), None).read_only);
assert!(effective_mcp_policy_with_legacy_allow_writes(policy_state(true, true), None).read_only);
}
#[test]

View File

@ -793,9 +793,23 @@ fn mcp_permissions(
dbx_core::agent_tools::AgentSqlPermissions {
allow_writes: !policy.read_only && !connection.read_only,
allow_dangerous: !policy.read_only && !connection.read_only && policy.allow_dangerous_sql,
confirmed_write_sql: mcp_confirmed_write_sql_from_env(),
}
}
/// Read the DBX_MCP_CONFIRMED_WRITE_SQL env var (set by the CLI agent when the
/// user confirmed a specific write SQL). Returns None when the var is unset or
/// empty, so desktop-embedded MCP contexts (which don't set this var) continue
/// to work without a confirmed-SQL binding.
fn mcp_confirmed_write_sql_from_env() -> Option<String> {
normalize_confirmed_write_sql(std::env::var("DBX_MCP_CONFIRMED_WRITE_SQL").ok())
}
fn normalize_confirmed_write_sql(value: Option<String>) -> Option<String> {
let trimmed = value?.trim().to_string();
(!trimmed.is_empty()).then_some(trimmed)
}
// CallToolResult is the transport-native error payload; boxing it would complicate every MCP call site.
#[allow(clippy::result_large_err)]
fn validate_sql_policy(
@ -823,13 +837,24 @@ fn validate_sql_policy(
));
}
let high_risk = risk == SqlRisk::Ddl || is_dangerous_sql_for_database(sql, connection.db_type);
if high_risk && !policy.allow_dangerous_sql {
let confirmed_sql = mcp_confirmed_write_sql_from_env();
if high_risk && !policy.allow_dangerous_sql && confirmed_sql.is_none() {
return Err(tool_error("SQL_BLOCKED", "High-risk SQL is disabled in DBX MCP settings."));
}
if is_write && targets_production_database(connection, database, sql) {
return Err(tool_error("PRODUCTION_WRITE_BLOCKED", "MCP cannot execute writes against a production database."));
}
Ok(mcp_permissions(connection, policy))
let mut permissions = mcp_permissions(connection, policy);
// When the user confirmed a specific write/DDL SQL via the CLI agent, the
// confirmed-SQL binding authorises dangerous (DDL) execution in the SQL
// path only. The precise SQL match check (sql_matches_confirmed_write) in
// agent_tools still guards execution — only the exact confirmed statement
// can run. Redis and Mongo paths are unaffected; they continue to use the
// persistent policy (mcp_permissions) without any confirmed-SQL elevation.
if confirmed_sql.is_some() {
permissions.allow_dangerous = true;
}
Ok(permissions)
}
// CallToolResult is the transport-native error payload; boxing it would complicate every MCP call site.
@ -1225,4 +1250,92 @@ mod tests {
});
assert!(result_text(&result).contains("Error [MCP_READ_ONLY]: policy changed"));
}
#[test]
fn mcp_confirmation_and_policy_guards_fail_closed() {
assert_eq!(
normalize_confirmed_write_sql(Some(" DELETE FROM sessions WHERE id = 7 ".to_string())),
Some("DELETE FROM sessions WHERE id = 7".to_string())
);
assert_eq!(normalize_confirmed_write_sql(Some(" \n ".to_string())), None);
let read_only = ConnectionConfig { read_only: true, ..connection("readonly", "readonly", "postgres", "app") };
let writable_policy =
McpGlobalPolicy { read_only: false, allow_dangerous_sql: true, allowed_connection_ids: None };
let read_only_error =
validate_sql_policy(&read_only, &writable_policy, "app", "DELETE FROM sessions").unwrap_err();
assert!(result_text(&read_only_error).contains("CONNECTION_READ_ONLY"));
let mut production = connection("production", "production", "postgres", "app");
production.production_databases = vec!["app".to_string()];
let production_error =
validate_sql_policy(&production, &writable_policy, "app", "DROP TABLE sessions").unwrap_err();
assert!(result_text(&production_error).contains("PRODUCTION_WRITE_BLOCKED"));
}
/// RAII guard that sets an env var and restores the original value (or
/// removes the var) on drop. Panic-safe — cleanup runs even when an
/// assertion fails.
struct EnvGuard {
key: &'static str,
original: Option<String>,
}
impl EnvGuard {
fn set(key: &'static str, value: &str) -> Self {
let original = std::env::var(key).ok();
std::env::set_var(key, value);
Self { key, original }
}
fn remove(key: &'static str) -> Self {
let original = std::env::var(key).ok();
std::env::remove_var(key);
Self { key, original }
}
}
impl Drop for EnvGuard {
fn drop(&mut self) {
match &self.original {
Some(original) => std::env::set_var(self.key, original),
None => std::env::remove_var(self.key),
}
}
}
#[test]
fn confirmed_sql_binding_tests() {
let connection = connection("dev", "dev", "postgres", "app");
// 1. mcp_permissions (Redis/Mongo path) must NOT elevate allow_dangerous.
let _guard = EnvGuard::set("DBX_MCP_CONFIRMED_WRITE_SQL", "CREATE TABLE metrics (id INT)");
let policy = McpGlobalPolicy { read_only: false, allow_dangerous_sql: false, allowed_connection_ids: None };
let permissions = mcp_permissions(&connection, &policy);
assert!(!permissions.allow_dangerous, "confirmed SQL must NOT elevate allow_dangerous for Redis/Mongo paths");
assert!(permissions.allow_writes);
assert_eq!(permissions.confirmed_write_sql.as_deref(), Some("CREATE TABLE metrics (id INT)"));
// 2. SQL path (validate_sql_policy) elevates allow_dangerous with a confirmed binding.
let permissions = validate_sql_policy(&connection, &policy, "app", "CREATE TABLE metrics (id INT)").unwrap();
assert!(permissions.allow_dangerous, "SQL path should elevate allow_dangerous with confirmed binding");
assert!(permissions.allow_writes);
assert_eq!(permissions.confirmed_write_sql.as_deref(), Some("CREATE TABLE metrics (id INT)"));
drop(_guard);
// 3. Without a confirmed binding, high-risk SQL is blocked.
let _guard = EnvGuard::remove("DBX_MCP_CONFIRMED_WRITE_SQL");
let error = validate_sql_policy(&connection, &policy, "app", "DROP TABLE sessions").unwrap_err();
assert!(result_text(&error).contains("SQL_BLOCKED"));
assert!(result_text(&error).contains("High-risk SQL is disabled"));
drop(_guard);
// 4. Confirmed SQL must not bypass global read_only.
let _guard = EnvGuard::set("DBX_MCP_CONFIRMED_WRITE_SQL", "CREATE TABLE metrics (id INT)");
let read_only_policy =
McpGlobalPolicy { read_only: true, allow_dangerous_sql: false, allowed_connection_ids: None };
let error =
validate_sql_policy(&connection, &read_only_policy, "app", "CREATE TABLE metrics (id INT)").unwrap_err();
assert!(result_text(&error).contains("MCP_READ_ONLY"), "confirmed SQL must not bypass global read_only");
}
}

View File

@ -86,6 +86,14 @@ pub struct AiAgentStreamRequest {
pub mode: String,
#[serde(default)]
pub allow_write_sql: bool,
/// When allow_write_sql is true, the specific SQL the user confirmed.
#[serde(default)]
pub confirmed_write_sql: Option<String>,
/// Connection/database snapshot at confirmation time; verified at this boundary.
#[serde(default)]
pub confirmed_connection_id: Option<String>,
#[serde(default)]
pub confirmed_database: Option<String>,
}
fn default_agent_mode() -> String {
@ -336,16 +344,31 @@ pub async fn ai_agent_stream(
log::warn!("Failed to load max_agent_turns setting, using default: {err}");
dbx_core::agent_loop::DEFAULT_MAX_AGENT_TURNS
});
// Reject the confirmed-write grant when the connection or database changed
// between the user's confirmation and this backend request (defense-in-depth).
let (allow_write_sql, confirmed_write_sql) = dbx_core::agent_tools::verify_confirmed_target(
Some(body.allow_write_sql),
body.confirmed_write_sql,
body.confirmed_connection_id,
body.confirmed_database,
&body.connection_id,
&body.database,
);
// Writes are only allowed when a specific SQL statement was confirmed —
// an empty confirmed_write_sql is treated as "no confirmation" so the
// agent cannot execute arbitrary write/DDL statements.
let sql_permissions = dbx_core::agent_tools::confirmed_write_sql_permissions(
production_database,
allow_write_sql.unwrap_or(false),
confirmed_write_sql,
);
let agent_ctx = AgentLoopContext {
state: state.app.clone(),
connection_id: body.connection_id,
database: body.database,
db_type: parsed_db_type,
cli_mcp_server_command: None,
sql_permissions: dbx_core::agent_tools::AgentSqlPermissions {
allow_writes: !production_database && body.allow_write_sql,
allow_dangerous: !production_database && body.allow_write_sql,
},
sql_permissions,
max_agent_turns,
};

View File

@ -88,7 +88,7 @@ const cases: PromptEvalCase[] = [
name: "agent generate keeps the first SQL block executable and read-oriented",
action: "generate",
mode: "agent",
mustInclude: [/Agent 模式/, /execute_query/, /第一个 ```sql 代码块只放最终推荐 SQL/, /只有 SELECT.*WITH.*SHOW.*DESCRIBE.*EXPLAIN.*可以通过 execute_query 执行/],
mustInclude: [/Agent 模式/, /execute_query/, /第一个 ```sql 代码块只放最终推荐 SQL/, /明确询问用户是否确认执行/],
},
{
name: "ask generate never implies auto execution",

View File

@ -87,6 +87,9 @@ pub async fn ai_agent_stream(
db_type: String,
mode: Option<String>,
allow_write_sql: Option<bool>,
confirmed_write_sql: Option<String>,
confirmed_connection_id: Option<String>,
confirmed_database: Option<String>,
) -> Result<String, String> {
let request = resolve_cli_provider_request(request);
@ -110,17 +113,34 @@ pub async fn ai_agent_stream(
log::warn!("Failed to load max_agent_turns setting, using default: {err}");
dbx_core::agent_loop::DEFAULT_MAX_AGENT_TURNS
});
// Reject the confirmed-write grant when the connection or database changed
// between the user's confirmation and this backend request. The frontend
// also verifies this synchronously, but this backend check provides
// defense-in-depth for CLI-provider and API-driven paths.
let (allow_write_sql, confirmed_write_sql) = dbx_core::agent_tools::verify_confirmed_target(
allow_write_sql,
confirmed_write_sql,
confirmed_connection_id,
confirmed_database,
&connection_id,
&database,
);
// Explicit confirmation grants write access only to this agent run, never to
// production. Writes are only allowed when a specific SQL statement was
// confirmed — an empty confirmed_write_sql is treated as "no confirmation"
// so the agent cannot execute arbitrary write/DDL statements.
let sql_permissions = dbx_core::agent_tools::confirmed_write_sql_permissions(
production_database,
allow_write_sql.unwrap_or(false),
confirmed_write_sql,
);
let agent_ctx = AgentLoopContext {
state: state.inner().clone(),
connection_id,
database,
db_type: parsed_db_type,
cli_mcp_server_command,
// Explicit confirmation grants write access only to this agent run, never to production.
sql_permissions: dbx_core::agent_tools::AgentSqlPermissions {
allow_writes: !production_database && allow_write_sql.unwrap_or(false),
allow_dangerous: !production_database && allow_write_sql.unwrap_or(false),
},
sql_permissions,
max_agent_turns,
};
let is_agent_mode = mode.as_deref() == Some("agent");
@ -192,3 +212,82 @@ pub async fn load_ai_conversations(state: State<'_, Arc<AppState>>) -> Result<Ve
pub async fn delete_ai_conversation(state: State<'_, Arc<AppState>>, id: String) -> Result<(), String> {
state.storage.delete_ai_conversation(&id).await
}
#[cfg(test)]
mod tests {
#[test]
fn verify_confirmed_target_allows_matching_connection_and_database() {
let (allow, confirmed) = dbx_core::agent_tools::verify_confirmed_target(
Some(true),
Some("DELETE FROM users WHERE id = 1".to_string()),
Some("conn-1".to_string()),
Some("app".to_string()),
"conn-1",
"app",
);
assert_eq!(allow, Some(true));
assert_eq!(confirmed, Some("DELETE FROM users WHERE id = 1".to_string()));
}
#[test]
fn verify_confirmed_target_rejects_mismatched_connection() {
let (allow, confirmed) = dbx_core::agent_tools::verify_confirmed_target(
Some(true),
Some("DELETE FROM users WHERE id = 1".to_string()),
Some("conn-staging".to_string()),
Some("app".to_string()),
"conn-production",
"app",
);
assert_eq!(allow, Some(false));
assert_eq!(confirmed, None);
}
#[test]
fn verify_confirmed_target_rejects_mismatched_database() {
let (allow, confirmed) = dbx_core::agent_tools::verify_confirmed_target(
Some(true),
Some("DELETE FROM users WHERE id = 1".to_string()),
Some("conn-1".to_string()),
Some("staging".to_string()),
"conn-1",
"production",
);
assert_eq!(allow, Some(false));
assert_eq!(confirmed, None);
}
#[test]
fn verify_confirmed_target_passes_through_when_no_sql_confirmed() {
// Without a confirmed SQL, no target verification is needed — the
// grant has no write permission to protect.
let (allow, confirmed) = dbx_core::agent_tools::verify_confirmed_target(
Some(false),
None,
Some("conn-staging".to_string()),
Some("staging".to_string()),
"conn-production",
"production",
);
assert_eq!(allow, Some(false));
assert_eq!(confirmed, None);
}
#[test]
fn verify_confirmed_target_rejects_when_no_snapshot_provided() {
// When confirmed_connection_id is None (e.g. older frontend that
// doesn't send snapshots), the target cannot be verified, so the
// grant must be rejected — fail-closed.
let (allow, confirmed) = dbx_core::agent_tools::verify_confirmed_target(
Some(true),
Some("DELETE FROM users WHERE id = 1".to_string()),
None,
None,
"conn-1",
"app",
);
assert_eq!(allow, Some(false));
assert_eq!(confirmed, None);
}
}