feat(ai): add guarded SQL auto execution

This commit is contained in:
t8y2
2026-05-11 02:27:56 +08:00
parent 9355fa7e93
commit e03ce491af
6 changed files with 242 additions and 3 deletions
+131
View File
@@ -0,0 +1,131 @@
import type { ConnectionConfig } from "@/types/database";
export type ConnectionEnvironment = "production" | "non_production" | "unknown";
export type AiSqlExecutionAction = "auto_execute" | "confirm" | "block";
export type AiSqlExecutionCategory = "read" | "low_risk_write" | "write" | "schema_change" | "dangerous" | "unknown";
export interface AiSqlExecutionDecision {
action: AiSqlExecutionAction;
environment: ConnectionEnvironment;
category: AiSqlExecutionCategory;
reasons: string[];
}
const READ_RE = /^(SELECT|WITH|SHOW|DESCRIBE|DESC|EXPLAIN)\b/i;
const INSERT_RE = /^INSERT\b/i;
const UPDATE_RE = /^UPDATE\b/i;
const DELETE_RE = /^DELETE\b/i;
const CONFIRM_WRITE_RE = /^(MERGE|REPLACE)\b/i;
const BLOCK_RE = /^(DROP|TRUNCATE|ALTER|RENAME)\b/i;
const SCHEMA_RE = /^(CREATE)\b/i;
const PRODUCTION_RE = /\b(prod|prd|production)\b|生产|正式/i;
const NON_PRODUCTION_RE =
/\b(local|localhost|dev|develop|development|test|testing|stage|staging|sandbox|demo)\b|本地|开发|测试|预发/i;
const LOCAL_HOST_RE = /^(localhost|127(?:\.\d{1,3}){3}|0\.0\.0\.0|::1)$/i;
const NEGATIVE_EXECUTION_RE =
/(不要|别|不用|禁止|只生成|仅生成|只写|仅写).{0,12}(执行|运行)|do\s+not\s+execute|don't\s+execute|dont\s+execute|without\s+executing|only\s+(generate|write|return)/i;
const ACTION_INTENT_RE =
/(查一下|查询|查看|看一下|统计|列出|执行|运行|跑一下|show|list|query|find|count|get|fetch|execute|run)/i;
export function stripAiSqlComments(sql: string): string {
return sql
.replace(/\/\*[\s\S]*?\*\//g, " ")
.replace(/--.*$/gm, " ")
.replace(/#.*$/gm, " ");
}
function sqlStatements(sql: string): string[] {
return stripAiSqlComments(sql)
.split(";")
.map((stmt) => stmt.trim())
.filter(Boolean);
}
function classifyStatement(statement: string): AiSqlExecutionCategory {
if (READ_RE.test(statement)) return "read";
if (BLOCK_RE.test(statement)) return "dangerous";
if (SCHEMA_RE.test(statement)) return "schema_change";
if (INSERT_RE.test(statement)) return "low_risk_write";
if (UPDATE_RE.test(statement)) return isScopedUpdate(statement) ? "low_risk_write" : "dangerous";
if (DELETE_RE.test(statement) || CONFIRM_WRITE_RE.test(statement)) return "write";
return "unknown";
}
function isScopedUpdate(statement: string): boolean {
const whereMatch = statement.match(/\bWHERE\b([\s\S]*)$/i);
if (!whereMatch) return false;
const where = whereMatch[1];
if (/\b1\s*=\s*1\b|\btrue\b/i.test(where)) return false;
return /\b[\w"`.[\]]*(?:id|_id|uuid|key)[\w"`.[\]]*\s*=\s*(?:'[^']+'|"[^"]+"|`[^`]+`|[\w.-]+)/i.test(where);
}
export function classifyConnectionEnvironment(connection?: ConnectionConfig): ConnectionEnvironment {
if (!connection) return "unknown";
const parts = [connection.name, connection.host, connection.database, connection.connection_string].filter(Boolean);
const signal = parts.join(" ");
if (PRODUCTION_RE.test(signal)) return "production";
if (LOCAL_HOST_RE.test(connection.host) || NON_PRODUCTION_RE.test(signal)) return "non_production";
return "unknown";
}
export function classifyAiSqlExecution(sql: string, connection?: ConnectionConfig): AiSqlExecutionDecision {
const environment = classifyConnectionEnvironment(connection);
const statements = sqlStatements(sql);
const reasons: string[] = [];
if (!statements.length) {
return { action: "block", environment, category: "unknown", reasons: ["empty_sql"] };
}
const categories = statements.map(classifyStatement);
const hasMultipleStatements = statements.length > 1;
if (hasMultipleStatements) reasons.push("multi_statement");
if (categories.includes("dangerous")) {
return { action: "block", environment, category: "dangerous", reasons };
}
if (categories.includes("unknown")) {
return { action: "confirm", environment, category: "unknown", reasons };
}
if (categories.every((category) => category === "read")) {
return { action: "auto_execute", environment, category: "read", reasons };
}
if (hasMultipleStatements) {
return { action: "confirm", environment, category: "write", reasons };
}
const [category] = categories;
if (category === "low_risk_write") {
return {
action: environment === "non_production" ? "auto_execute" : "confirm",
environment,
category,
reasons,
};
}
return {
action: "confirm",
environment,
category,
reasons,
};
}
export function shouldAttemptAiAutoExecute(instruction: string, action: string): boolean {
if (action !== "generate") return false;
const normalized = instruction.trim();
if (!normalized || NEGATIVE_EXECUTION_RE.test(normalized)) return false;
return ACTION_INTENT_RE.test(normalized);
}
export function extractFirstSqlCodeBlock(content: string): string | undefined {
const match = content.match(/```(?:sql|mysql|postgresql|sqlite|tsql|clickhouse)?\s*\n([\s\S]*?)```/i);
const sql = match?.[1]?.trim();
return sql || undefined;
}