fix(ai): require exact SQL confirmation for agent writes

This commit is contained in:
Abeautifulsnow authored and GitHub committed 2026-08-21 10:36:17 +08:00
1 parent f2e13922a2
commit 12da615daa
16 files changed
+626 -30

No files matched your search

@@ -1,11 +1,18 @@
import { describe, expect, it } from "vitest";
import { containsWriteSql, looksLikeActionProposal, looksLikeWriteSqlProposal, shouldGrantWriteSqlOnShortAffirmative, type WriteSqlGrantParams } from "@/lib/ai/aiProposalDetect";
import { containsWriteSql, isActionableWriteProposalMessage, isActionableWriteSqlProposal, 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 the backend-generated write confirmation proposal", () => {
const proposal = ["This database change requires your explicit confirmation before DBX can execute it.", "", "```sql", "CREATE TABLE users (id INT);", "```", "", "Should I execute this SQL statement?"].join("\n");
expect(looksLikeActionProposal(proposal)).toBe(true);
expect(looksLikeWriteSqlProposal(proposal)).toBe(true);
expect(extractSingleSqlCodeBlock(proposal)).toBe("CREATE TABLE users (id INT);");
});
it("detects zh write-SQL proposal (CREATE in last line)", () => {
expect(looksLikeWriteSqlProposal("需要我执行 CREATE TABLE users 吗?")).toBe(true);
});
@@ -102,15 +109,15 @@ describe("looksLikeWriteSqlProposal", () => {
describe("shouldGrantWriteSqlOnShortAffirmative (manual-typing confirmation)", () => {
const writeProposal = {
role: "assistant" as const,
content: "已经分析了表结构。\n需要我执行这条 CREATE TABLE users 语句吗?",
content: "已经分析了表结构。\n```sql\nCREATE TABLE users (id INT);\n```\n需要我执行这条 CREATE TABLE users 语句吗?",
};
const insertProposal = {
role: "assistant" as const,
content: "I understand the schema.\nShould I run this INSERT for you?",
content: "I understand the schema.\n```sql\nINSERT INTO users (id) VALUES (1);\n```\nShould I run this INSERT for you?",
};
const writeProposalEn = {
role: "assistant" as const,
content: "Should I execute this CREATE TABLE users?",
content: "```sql\nCREATE TABLE users (id INT);\n```\nShould I execute this CREATE TABLE users?",
};
// Prompt-mirrored generic confirmation with SQL code block.
const genericZhProposal = {
@@ -145,6 +152,44 @@ describe("shouldGrantWriteSqlOnShortAffirmative (manual-typing confirmation)", (
expect(shouldGrantWriteSqlOnShortAffirmative(params({ messages: [{ role: "user", content: "创建表" }, genericZhProposal] }))).toBe(true);
});
it("does NOT grant for a generic write question without reviewable SQL", () => {
const genericWriteQuestion = "需要我执行这条 INSERT 语句吗?";
expect(looksLikeWriteSqlProposal(genericWriteQuestion)).toBe(true);
expect(isActionableWriteSqlProposal(genericWriteQuestion)).toBe(false);
expect(
shouldGrantWriteSqlOnShortAffirmative(
params({
messages: [
{ role: "user", content: "插入数据" },
{ role: "assistant", content: genericWriteQuestion },
],
}),
),
).toBe(false);
});
it("grants for a backend-generated localized confirmation via its structural kind", () => {
// The exact wording DBX renders from i18n (en): it does NOT contain an
// English/Chinese ask phrase, so the text detectors reject it.
const localizedEn = "This SQL changes data or schema. Review it carefully before execution.\n\n```sql\nCREATE TABLE users (id INT);\n```\n\nExecute this SQL once?";
expect(looksLikeActionProposal(localizedEn)).toBe(false);
expect(isActionableWriteSqlProposal(localizedEn)).toBe(false);
// The backend marks the message, making it actionable without phrase detection.
expect(isActionableWriteProposalMessage({ role: "assistant", content: localizedEn, kind: "writeSqlConfirmation" })).toBe(true);
expect(
shouldGrantWriteSqlOnShortAffirmative(
params({
messages: [
{ role: "user", content: "创建表" },
{ role: "assistant", content: localizedEn, kind: "writeSqlConfirmation" },
],
}),
),
).toBe(true);
// A production-block message must never be treated as a confirmable write.
expect(isActionableWriteProposalMessage({ role: "assistant", content: localizedEn, kind: "productionWriteBlocked" })).toBe(false);
});
it("skips contextSummary messages to find the assistant message", () => {
expect(
shouldGrantWriteSqlOnShortAffirmative(
+33 -2
View File
@@ -1,3 +1,5 @@
import { extractSingleSqlCodeBlock } from "@/lib/ai/aiSqlExecutionPolicy";
/**
* Pure detectors for recognizing AI assistant action proposals and user
* short replies (affirmative / negative). Used by the chat UI to render
@@ -235,6 +237,35 @@ export function looksLikeWriteSqlProposal(content: string): boolean {
return lastLineMentionsWriteSql(lastLine, isZh, content);
}
/**
* A write proposal is actionable only when the user can review exactly one
* SQL statement. A generic “execute this INSERT?” prompt cannot safely bind
* the next agent run, so the UI must not present it as an executable action.
*/
export function isActionableWriteSqlProposal(content: string): boolean {
return looksLikeWriteSqlProposal(content) && extractSingleSqlCodeBlock(content) !== undefined;
}
/** A chat message that the write-confirmation gate inspects. */
export interface WriteSqlGrantMessage {
role: "user" | "assistant";
content: string;
kind?: "contextSummary" | "writeSqlConfirmation" | "productionWriteBlocked";
}
/**
* True when a chat message can bind the next agent run to one exact SQL
* statement. Backend-generated confirmations are marked with a structural
* `kind` and carry exactly one SQL block, so they are actionable regardless of
* the locale their localized wording is in; model-authored proposals still
* have to pass the English/Chinese text detectors.
*/
export function isActionableWriteProposalMessage(msg: WriteSqlGrantMessage): boolean {
if (msg.role !== "assistant" || !msg.content) return false;
if (msg.kind === "writeSqlConfirmation") return extractSingleSqlCodeBlock(msg.content) !== undefined;
return isActionableWriteSqlProposal(msg.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
@@ -279,7 +310,7 @@ export interface WriteSqlGrantParams {
* 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" }>;
messages: WriteSqlGrantMessage[];
}
/**
@@ -296,7 +327,7 @@ export function shouldGrantWriteSqlOnShortAffirmative(params: WriteSqlGrantParam
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 (isActionableWriteProposalMessage(msg)) return true;
if (msg.role === "user") return false;
}
return false;
+3 -1
View File
@@ -438,6 +438,8 @@ export async function aiStream(sessionId: string, request: AiCompletionRequest,
export type AgentEvent =
| { type: "turn_start"; turn: number }
| { type: "text_delta"; delta: string }
| { type: "write_sql_confirmation_required"; sql: string }
| { type: "production_write_blocked"; sql: string }
| { type: "reasoning_delta"; delta: string }
| {
type: "tool_call_start";
@@ -850,7 +852,7 @@ export interface AiChatMessage {
content: string;
mentions?: unknown[];
reasoning?: string;
kind?: "contextSummary";
kind?: "contextSummary" | "writeSqlConfirmation" | "productionWriteBlocked";
}
export interface AiConversation {