fix(ai): harden write SQL confirmation flow
This commit is contained in:
parent
1e72d28ed3
commit
cb9d0f7cbf
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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.",
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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."],
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -568,6 +568,7 @@ mod tests {
|
|||
agent_mode: true,
|
||||
allow_writes: false,
|
||||
allow_dangerous: false,
|
||||
confirmed_write_sql: None,
|
||||
mcp_server_command: None,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>) {
|
||||
|
|
|
|||
|
|
@ -628,6 +628,7 @@ mod tests {
|
|||
agent_mode: true,
|
||||
allow_writes: false,
|
||||
allow_dangerous: false,
|
||||
confirmed_write_sql: None,
|
||||
mcp_server_command: None,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue