feat(safety): add production environment safeguards

This commit is contained in:
wuxiemian 2026-07-12 10:02:27 +08:00 committed by GitHub
parent be66d6f46b
commit 1be66fb115
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
80 changed files with 4220 additions and 234 deletions

View File

@ -73,6 +73,8 @@ import { isPreviewTab } from "@/lib/tabs/tabPresentation";
import { supportsSqlFileExecution } from "@/lib/database/databaseCapabilities";
import { classifyAiSqlExecution } from "@/lib/ai/aiSqlExecutionPolicy";
import { buildAppendedEditorSql } from "@/lib/ai/aiSqlAppend";
import { assessProductionSql } from "@/lib/database/productionSafety";
import { executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard";
import { buildHistoryAiAnalysisPrompt } from "@/lib/history/historyAiAnalysis";
import { countAvailableAgentDriverUpdates, type AgentDriverUpdateBadgeState } from "@/lib/connection/agentDriverUpdateBadge";
import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/backend/safeStorage";
@ -814,7 +816,22 @@ async function saveActiveObjectSource(tab: QueryTab): Promise<boolean> {
name: source.name,
source: tab.sql,
});
await executeObjectSourceSave(tab.connectionId, tab.database, databaseType, statements, source.schema || tab.schema);
const executableSql = statements.filter((sql) => sql.trim()).join(";\n");
if (executableSql.trim()) {
const saved = await executeWithProductionSqlGuard({
connection,
database: tab.database,
sql: executableSql,
source: t("production.sourceObjectSource"),
execute: async () => {
await executeObjectSourceSave(tab.connectionId, tab.database, databaseType, statements, source.schema || tab.schema);
return true;
},
});
if (!saved) return false;
} else {
await executeObjectSourceSave(tab.connectionId, tab.database, databaseType, statements, source.schema || tab.schema);
}
queryStore.markTabClean(tab);
toast(t("objects.sourceSaved"), 2000);
return true;
@ -1286,6 +1303,12 @@ function onAiRequestAutoExecuteSql(sql: string) {
queryStore.updateSql(tabId, buildAppendedEditorSql(activeTab.value?.sql || "", sql));
selectedSql.value = "";
const productionAssessment = assessProductionSql(sql, activeConnection.value, activeTab.value?.database);
if (productionAssessment.active && productionAssessment.isMutation) {
toast(t("production.aiReviewRequired"), 5000);
return;
}
const decision = classifyAiSqlExecution(sql, activeConnection.value);
if (decision.action === "block") {
toast(t("ai.autoSqlBlocked"), 5000);

View File

@ -10,6 +10,7 @@ import { useConnectionStore } from "@/stores/connectionStore";
import { useToast } from "@/composables/useToast";
import type { ConnectionConfig } from "@/types/database";
import * as api from "@/lib/backend/api";
import { executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard";
import {
DAMENG_JOB_ENVIRONMENT_SQL,
damengClearJobHistoriesSql,
@ -169,10 +170,18 @@ async function applyPendingSql() {
applying.value = true;
try {
await ensureConnection();
await api.executeMulti(props.connection.id, executionDatabase.value, pendingSql.value, undefined, undefined, {
maxRows: 1000,
useTransaction: pendingUseTransaction.value,
const result = await executeWithProductionSqlGuard({
connection: props.connection,
database: executionDatabase.value,
sql: pendingSql.value,
source: t("production.sourceAdmin"),
execute: () =>
api.executeMulti(props.connection.id, executionDatabase.value, pendingSql.value, undefined, undefined, {
maxRows: 1000,
useTransaction: pendingUseTransaction.value,
}),
});
if (!result) return;
toast(t("damengJobAdmin.applySuccess"), 2500);
previewDialogOpen.value = false;
await (pendingAfterApply.value?.() ?? Promise.resolve());

View File

@ -13,6 +13,7 @@ import { useToast } from "@/composables/useToast";
import { useSqlHighlighter } from "@/composables/useSqlHighlighter";
import type { ConnectionConfig } from "@/types/database";
import * as api from "@/lib/backend/api";
import { executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard";
import { grantsFromQueryResult, getDatabaseUserAdminProvider, supportsDatabaseUserAdmin, type DatabaseUserIdentity, type PrivilegeScope } from "@/lib/database/databaseUserAdmin";
const props = defineProps<{
@ -165,7 +166,14 @@ async function applyPendingSql() {
if (!pendingSql.value.trim()) return;
applying.value = true;
try {
await api.executeMulti(props.connection.id, "", pendingSql.value, undefined, undefined, { maxRows: 1000 });
const result = await executeWithProductionSqlGuard({
connection: props.connection,
database: "",
sql: pendingSql.value,
source: t("production.sourceAdmin"),
execute: () => api.executeMulti(props.connection.id, "", pendingSql.value, undefined, undefined, { maxRows: 1000 }),
});
if (!result) return;
toast(t("userAdmin.applySuccess"), 2500);
sqlDialogOpen.value = false;
await (pendingAfterApply.value?.() ?? Promise.resolve());

View File

@ -0,0 +1,20 @@
<script setup lang="ts">
import { ShieldAlert } from "@lucide/vue";
import { useI18n } from "vue-i18n";
const { t } = useI18n();
withDefaults(
defineProps<{
compact?: boolean;
}>(),
{ compact: false },
);
</script>
<template>
<span class="inline-flex h-5 shrink-0 items-center gap-1 rounded-[4px] border border-red-500/40 bg-red-500/10 px-1.5 font-mono text-[10px] font-semibold leading-none text-red-700 dark:text-red-300" :class="compact ? 'px-1' : ''" :title="t('production.title')">
<ShieldAlert class="h-3 w-3" aria-hidden="true" />
<span>PROD</span>
</span>
</template>

View File

@ -43,10 +43,10 @@ import { normalizeKafkaBootstrapServers } from "@/lib/connection/kafkaBootstrapS
import { detectMqUiAuthKind, isMqAuthKindAllowedForSystem, type MqUiAuthKind } from "@/lib/connection/mqAuth";
import { driverInstallProgressPercent, type DriverInstallProgress } from "@/lib/connection/driverInstallProgressUi";
import { isSqlServerLegacyCompatibilityMode, requiresSqlServerLegacyCompatibilityComponent, setSqlServerLegacyCompatibilityMode, SQLSERVER_LEGACY_COMPATIBILITY_DRIVER_KEY } from "@/lib/connection/sqlServerLegacyCompatibility";
import { ArrowLeft, ArrowDown, ArrowUp, CheckSquare, ChevronRight, CircleHelp, Copy, ExternalLink, FilePlus2, FolderOpen, GripVertical, Grid3X3, KeyRound, Link2, List, ListFilter, Loader2, Pencil, Pipette, Plus, Search, ShieldCheck, Square, Trash2 } from "@lucide/vue";
import { ArrowLeft, ArrowDown, ArrowUp, CheckSquare, ChevronRight, CircleHelp, Copy, ExternalLink, FilePlus2, FolderOpen, GripVertical, Grid3X3, KeyRound, Link2, List, ListFilter, Loader2, Pencil, Pipette, Plus, RefreshCw, Search, ShieldAlert, ShieldCheck, Square, Trash2 } from "@lucide/vue";
import { buildDraftVisibleDatabasesConnectionId, connectionCanChooseVisibleDatabases, initialVisibleDatabaseSelection, visibleDatabaseSelectionIsStale } from "@/lib/connection/connectionVisibleDatabases";
import { canSaveVisibleDatabaseSelection, connectionUsesVisibleSchemaFilter, filterDatabaseNamesForVisiblePicker, isSystemDatabaseName, normalizeVisibleDatabaseSelection, buildDraftVisibleSchemasConnectionId, normalizeVisibleSchemaSelection } from "@/lib/database/visibleDatabases";
import { isSchemaAware } from "@/lib/database/databaseFeatureSupport";
import { isSchemaAware, isSingleDatabase } from "@/lib/database/databaseFeatureSupport";
import VisibleSchemasDialog from "@/components/sidebar/VisibleSchemasDialog.vue";
import { oceanbaseModeConnectionPatch, oceanbaseSubModeFromConfig } from "@/lib/database/oceanbaseConnectionMode";
import { translateBackendError } from "@/i18n/backend-errors";
@ -57,6 +57,7 @@ type DbCategory = { key: string; title: string; options: DbOption[] };
type DialogStep = "select" | "config";
type DbPickerView = "icon" | "list";
export type ConfigTab = "connection" | "advanced" | "tls" | "transport";
type ProductionScope = "connection" | "databases";
type MqTokenSigningMode = "none" | "hs256" | "rs256";
type NacosAuthKind = NacosAuthConfig["kind"];
type DremioConnectionMode = "arrow-flight-sql" | "legacy";
@ -136,6 +137,13 @@ const visibleDatabaseSelection = ref<Set<string>>(new Set());
const visibleDatabaseSearchText = ref("");
const visibleDatabaseError = ref("");
const visibleDatabaseShowSystem = ref(false);
const showProductionDatabasesDialog = ref(false);
const isLoadingProductionDatabases = ref(false);
const productionDatabaseNames = ref<string[]>([]);
const productionDatabaseSelection = ref<Set<string>>(new Set());
const productionDatabaseSearchText = ref("");
const productionDatabaseError = ref("");
const productionProtectionEnabled = ref(false);
const showVisibleSchemasDialog = ref(false);
const isLoadingVisibleSchemas = ref(false);
const visibleSchemaNames = ref<string[]>([]);
@ -189,6 +197,8 @@ const defaultForm = (): ConnectionForm => ({
informix_server: "",
external_config: undefined,
read_only: false,
is_production: false,
production_databases: [],
visible_databases: undefined,
});
@ -1430,8 +1440,11 @@ watch(
informix_server: config.informix_server || "",
external_config: config.external_config,
read_only: config.read_only || false,
is_production: config.is_production || false,
production_databases: config.production_databases || [],
visible_databases: config.visible_databases,
};
productionProtectionEnabled.value = !!config.is_production || (config.production_databases?.length ?? 0) > 0;
connectionUrlInput.value = config.db_type === "h2" && config.connection_string ? config.connection_string : "";
appliedConnectionUrlInput.value = connectionUrlInput.value.trim();
if (config.db_type === "mq") {
@ -1471,6 +1484,7 @@ watch(
} else {
editingId.value = null;
form.value = defaultForm();
productionProtectionEnabled.value = false;
selectedTransportLayerId.value = null;
selectedType.value = "mysql";
customDriverName.value = "";
@ -1893,6 +1907,37 @@ const visibleDatabaseHasSystemDatabases = computed(() => {
const connection = connectionConfigSnapshotForVisibleDatabases();
return visibleDatabaseNames.value.some((database) => isSystemDatabaseName(connection.db_type, database));
});
const filteredProductionDatabaseNames = computed(() => {
const query = productionDatabaseSearchText.value.trim().toLowerCase();
if (!query) return productionDatabaseNames.value;
return productionDatabaseNames.value.filter((name) => name.toLowerCase().includes(query));
});
const productionDatabaseSelectedCount = computed(() => productionDatabaseSelection.value.size);
const productionDatabaseCanSave = computed(() => productionDatabaseNames.value.length > 0 && productionDatabaseSelection.value.size > 0);
const productionDatabaseSummary = computed(() => {
const selected = form.value.production_databases?.length || 0;
if (!selected) return t("production.noDatabasesSelected");
if (!productionDatabaseNames.value.length) return t("production.databasesConfiguredCount", { count: selected });
return t("production.databasesSelectedCount", { selected, total: productionDatabaseNames.value.length });
});
const productionScope = computed<ProductionScope>({
get: () => (isSingleDatabase(form.value.db_type) || form.value.is_production ? "connection" : "databases"),
set: (scope) => {
form.value.is_production = isSingleDatabase(form.value.db_type) || scope === "connection";
},
});
const canSelectProductionDatabases = computed(() => !isSingleDatabase(form.value.db_type));
function setProductionProtectionEnabled(enabled: boolean) {
productionProtectionEnabled.value = enabled;
if (!enabled) {
form.value.is_production = false;
form.value.production_databases = [];
} else if (!form.value.is_production && !form.value.production_databases?.length) {
// Enabling protection starts with the broadest scope until the user chooses a narrower one.
form.value.is_production = true;
}
}
const canChooseVisibleSchemas = computed(() => isSchemaAware(form.value.db_type));
const visibleSchemasDatabaseKey = computed(() => form.value.database || "");
const hasVisibleSchemaFilter = computed(() => {
@ -2254,6 +2299,14 @@ function connectionConfigForSubmit(id: string): ConnectionConfig {
}
if (!config.one_time) config.one_time = undefined;
if (!config.read_only) config.read_only = undefined;
if (isSingleDatabase(config.db_type) && config.production_databases?.length) {
// Single-database drivers expose schemas or internal names, not independently selectable databases.
config.is_production = true;
config.production_databases = [];
}
if (!config.is_production) config.is_production = undefined;
config.production_databases = [...new Set((config.production_databases || []).map((database) => database.trim()).filter(Boolean))];
if (!config.production_databases.length) config.production_databases = undefined;
if (config.db_type === "mq") {
const mqConfig = buildMqAdminConfig();
config.external_config = mqConfig;
@ -2685,6 +2738,16 @@ function resetVisibleDatabaseDraftState() {
visibleDatabaseShowSystem.value = false;
}
function resetProductionDatabaseDraftState() {
showProductionDatabasesDialog.value = false;
isLoadingProductionDatabases.value = false;
productionDatabaseNames.value = [];
productionDatabaseSelection.value = new Set();
productionDatabaseSearchText.value = "";
productionDatabaseError.value = "";
productionProtectionEnabled.value = false;
}
/** Silently load database names so the summary count shows a real total. */
async function preloadVisibleDatabaseNames() {
if (!ensureConnectionHostResolvedFromUrl()) return;
@ -2755,6 +2818,88 @@ async function loadVisibleDatabaseNames(connectionId: string, config: Connection
return (await api.listDatabases(connectionId)).map((database) => database.name);
}
function normalizeProductionDatabaseSelection(selectedNames: Iterable<string>, databaseNames: string[]): string[] {
const available = new Map(databaseNames.map((name) => [name.toLowerCase(), name]));
const selected = new Set<string>();
for (const name of selectedNames) {
const canonicalName = available.get(name.toLowerCase());
if (canonicalName) selected.add(canonicalName);
}
return [...selected];
}
function initialProductionDatabaseSelection(databaseNames: string[]): string[] {
const configured = form.value.production_databases || [];
// A new database-level safeguard starts broad; users can explicitly narrow it in the picker.
return configured.length ? normalizeProductionDatabaseSelection(configured, databaseNames) : databaseNames;
}
async function loadProductionDatabaseNames(connectionId: string, config: ConnectionConfig): Promise<string[]> {
if (config.db_type === "redis") {
return (await api.redisListDatabases(connectionId)).map((database) => String(database.db));
}
if (config.db_type === "mongodb") {
return api.mongoListDatabases(connectionId);
}
return (await api.listDatabases(connectionId)).map((database) => database.name);
}
async function openProductionDatabasesPicker() {
if (!ensureConnectionHostResolvedFromUrl() || !productionProtectionEnabled.value || form.value.is_production || isLoadingProductionDatabases.value) return;
showProductionDatabasesDialog.value = true;
await reloadProductionDatabases();
}
async function reloadProductionDatabases() {
if (isLoadingProductionDatabases.value) return;
isLoadingProductionDatabases.value = true;
productionDatabaseError.value = "";
productionDatabaseSearchText.value = "";
const draftId = `__production_database_draft_${uuid()}`;
try {
const draftConfig = {
...connectionConfigForSubmit(draftId),
id: draftId,
one_time: true,
};
await api.connectDb(draftConfig);
productionDatabaseNames.value = await loadProductionDatabaseNames(draftId, draftConfig);
productionDatabaseSelection.value = new Set(initialProductionDatabaseSelection(productionDatabaseNames.value));
} catch (e: any) {
productionDatabaseNames.value = [];
productionDatabaseSelection.value = new Set();
productionDatabaseError.value = mongodbAuthFailureHint(errorMessage(e));
} finally {
await api.disconnectDb(draftId).catch(() => undefined);
isLoadingProductionDatabases.value = false;
}
}
function toggleProductionDatabase(database: string) {
const next = new Set(productionDatabaseSelection.value);
if (next.has(database)) next.delete(database);
else next.add(database);
productionDatabaseSelection.value = next;
}
function selectAllProductionDatabases() {
productionDatabaseSelection.value = new Set(productionDatabaseNames.value);
}
function clearProductionDatabaseSelection() {
productionDatabaseSelection.value = new Set();
}
function saveProductionDatabaseSelection() {
if (!productionDatabaseCanSave.value) return;
// A database selection is always narrower than a connection-wide marker.
productionProtectionEnabled.value = true;
form.value.is_production = false;
form.value.production_databases = normalizeProductionDatabaseSelection(productionDatabaseSelection.value, productionDatabaseNames.value);
showProductionDatabasesDialog.value = false;
}
function toggleVisibleDatabase(database: string) {
const next = new Set(visibleDatabaseSelection.value);
if (next.has(database)) next.delete(database);
@ -2905,6 +3050,7 @@ function resetForm() {
dbSearchQuery.value = "";
configTab.value = "connection";
resetVisibleDatabaseDraftState();
resetProductionDatabaseDraftState();
resetVisibleSchemasState();
resetTestState();
}
@ -5084,6 +5230,39 @@ function openExternalUrl(url: string) {
<span class="text-xs text-muted-foreground">{{ t("connection.readOnlyHint") }}</span>
</label>
</div>
<div class="grid grid-cols-4 items-start gap-4 rounded-[6px] border border-red-500/25 bg-red-500/[0.035] px-3 py-2.5">
<Label :class="[connectionLabelSmallClass, 'pt-0.5 text-red-700 dark:text-red-300']">
<span class="inline-flex items-center justify-end gap-1"><ShieldAlert class="h-3.5 w-3.5" />PROD</span>
</Label>
<div class="col-span-3 grid gap-2">
<div class="flex items-center justify-between gap-3">
<Label class="text-sm font-medium">{{ t("production.enable") }}</Label>
<Switch :model-value="productionProtectionEnabled" @update:model-value="setProductionProtectionEnabled" />
</div>
<p v-if="!productionProtectionEnabled" class="text-xs leading-5 text-muted-foreground">{{ t("production.disabledDescription") }}</p>
<template v-else>
<Label class="text-xs font-medium">{{ t("production.scope") }}</Label>
<Tabs v-model="productionScope" class="w-full">
<TabsList class="grid h-8 w-full grid-cols-2">
<TabsTrigger value="connection" class="text-xs">{{ t("production.allDatabases") }}</TabsTrigger>
<TabsTrigger value="databases" class="text-xs" :disabled="!canSelectProductionDatabases" :title="canSelectProductionDatabases ? undefined : t('production.singleDatabaseScopeHint')">{{ t("production.selectedDatabases") }}</TabsTrigger>
</TabsList>
</Tabs>
<p class="text-xs leading-5 text-muted-foreground">{{ productionScope === "connection" ? t("production.connectionDescription") : t("production.databaseDescription") }}</p>
<div v-if="productionScope === 'databases'" class="grid gap-1.5">
<div class="flex items-center justify-between gap-3">
<Label class="text-xs font-medium">{{ t("production.databases") }}</Label>
<span class="text-xs text-muted-foreground">{{ productionDatabaseSummary }}</span>
</div>
<Button type="button" variant="outline" size="sm" class="justify-start" :disabled="isTesting || isSaving || isLoadingProductionDatabases || !hasRequiredConnectionTarget" @click="openProductionDatabasesPicker">
<Loader2 v-if="isLoadingProductionDatabases" class="mr-1.5 h-4 w-4 animate-spin" />
<ListFilter v-else class="mr-1.5 h-4 w-4" />
{{ t("production.selectDatabases") }}
</Button>
</div>
</template>
</div>
</div>
<div v-show="form.db_type === 'sqlserver'" class="grid grid-cols-4 items-start gap-4">
<Label :class="connectionLabelSmallClass">{{ t("connection.sqlServerLegacyCompatibilityMode") }}</Label>
<div class="col-span-3 flex flex-col gap-1">
@ -5505,6 +5684,74 @@ function openExternalUrl(url: string) {
</DialogContent>
</Dialog>
<Dialog v-model:open="showProductionDatabasesDialog">
<DialogContent class="sm:max-w-[460px]">
<DialogHeader>
<DialogTitle>{{ t("production.databasePickerTitle") }}</DialogTitle>
<p class="text-sm text-muted-foreground">
{{ t("production.databasePickerDescription", { connection: form.name || selectedProfile().label }) }}
</p>
</DialogHeader>
<div class="flex items-center gap-2 rounded-md border bg-background px-2">
<Search class="h-4 w-4 shrink-0 text-muted-foreground" />
<Input v-model="productionDatabaseSearchText" :placeholder="t('production.databaseSearchPlaceholder')" class="h-8 border-0 px-0 shadow-none focus-visible:ring-0" :disabled="isLoadingProductionDatabases || !!productionDatabaseError" />
</div>
<div class="flex items-center justify-between text-xs text-muted-foreground">
<span>{{ t("production.databasesSelectedCount", { selected: productionDatabaseSelectedCount, total: productionDatabaseNames.length }) }}</span>
<div class="flex items-center gap-2">
<button class="hover:text-foreground disabled:opacity-50" :disabled="isLoadingProductionDatabases || !!productionDatabaseError" @click="selectAllProductionDatabases">
{{ t("visibleDatabases.selectAll") }}
</button>
<button class="hover:text-foreground disabled:opacity-50" :disabled="isLoadingProductionDatabases || !!productionDatabaseError" @click="clearProductionDatabaseSelection">
{{ t("visibleDatabases.clear") }}
</button>
</div>
</div>
<p v-if="!isLoadingProductionDatabases && !productionDatabaseError && !productionDatabaseCanSave" class="text-xs text-destructive">
{{ t("production.databaseSelectionRequired") }}
</p>
<div class="h-72 overflow-y-auto rounded-md border bg-background/50 p-1">
<div v-if="isLoadingProductionDatabases" class="flex h-full items-center justify-center gap-2 text-sm text-muted-foreground">
<Loader2 class="h-4 w-4 animate-spin" />
{{ t("common.loading") }}
</div>
<div v-else-if="productionDatabaseError" class="flex h-full flex-col items-start justify-center gap-3 p-3 text-sm text-destructive">
<p>{{ t("production.databaseLoadFailed", { message: productionDatabaseError }) }}</p>
<Button type="button" variant="outline" size="sm" @click="reloadProductionDatabases">
<RefreshCw class="mr-1.5 h-3.5 w-3.5" />
{{ t("production.retry") }}
</Button>
</div>
<div v-else-if="!filteredProductionDatabaseNames.length" class="p-3 text-sm text-muted-foreground">
{{ productionDatabaseNames.length ? t("grid.noSearchResults") : t("production.noDatabasesAvailable") }}
</div>
<template v-else>
<button
v-for="database in filteredProductionDatabaseNames"
:key="database"
type="button"
class="flex h-8 w-full min-w-0 items-center gap-2 rounded-sm px-2 text-left text-sm hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground focus-visible:outline-none"
@click="toggleProductionDatabase(database)"
>
<CheckSquare v-if="productionDatabaseSelection.has(database)" class="h-4 w-4 shrink-0 text-primary" />
<Square v-else class="h-4 w-4 shrink-0 text-muted-foreground" />
<span class="truncate">{{ database }}</span>
</button>
</template>
</div>
<DialogFooter>
<Button variant="outline" @click="showProductionDatabasesDialog = false">{{ t("dangerDialog.cancel") }}</Button>
<Button :disabled="isLoadingProductionDatabases || !!productionDatabaseError || !productionDatabaseCanSave" @click="saveProductionDatabaseSelection">
{{ t("visibleDatabases.save") }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<VisibleSchemasDialog
v-model:open="showVisibleSchemasDialog"
draft-mode

View File

@ -15,6 +15,7 @@ import { copyToClipboard } from "@/lib/common/clipboard";
import type { DataCompareCellValue, DataCompareModifiedRow, DataCompareResult, DataCompareRow, DataCompareSyncPlan, DataCompareSyncPlanTableOptions } from "@/lib/dataGrid/dataCompare";
import type { ColumnInfo, DatabaseType } from "@/types/database";
import * as api from "@/lib/backend/api";
import { executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard";
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
import { ArrowLeftRight, CheckSquare, ChevronDown, ChevronRight, Copy, GitCompareArrows, Loader2, Play, Square } from "@lucide/vue";
@ -713,30 +714,40 @@ async function copySql() {
async function executeSql() {
if (!syncPlan.value.syncSql.trim() || syncPlan.value.syncStatements.length === 0 || executing.value) return;
executing.value = true;
syncErrors.value = [];
executeTotal.value = syncPlan.value.syncStatements.length;
executedCount.value = 0;
const targetConnection = store.getConfig(targetConnectionId.value);
try {
await store.ensureConnected(targetConnectionId.value);
const statements = syncPlan.value.syncStatements;
for (let index = 0; index < statements.length; index += SYNC_EXECUTE_BATCH_SIZE) {
const batch = statements.slice(index, index + SYNC_EXECUTE_BATCH_SIZE);
try {
await api.executeBatch(targetConnectionId.value, targetDatabase.value, batch, targetSchema.value);
executedCount.value += batch.length;
} catch (e: any) {
for (const stmt of batch) {
const failed = await executeWithProductionSqlGuard({
connection: targetConnection,
database: targetDatabase.value,
sql: syncPlan.value.syncSql,
source: t("production.sourceDataCompare"),
execute: async () => {
executing.value = true;
syncErrors.value = [];
executeTotal.value = syncPlan.value.syncStatements.length;
executedCount.value = 0;
await store.ensureConnected(targetConnectionId.value);
const statements = syncPlan.value.syncStatements;
for (let index = 0; index < statements.length; index += SYNC_EXECUTE_BATCH_SIZE) {
const batch = statements.slice(index, index + SYNC_EXECUTE_BATCH_SIZE);
try {
await api.executeBatch(targetConnectionId.value, targetDatabase.value, [stmt], targetSchema.value);
} catch (singleError: any) {
syncErrors.value.push({ sql: stmt, error: singleError?.message || String(singleError) });
await api.executeBatch(targetConnectionId.value, targetDatabase.value, batch, targetSchema.value);
executedCount.value += batch.length;
} catch (e: any) {
for (const stmt of batch) {
try {
await api.executeBatch(targetConnectionId.value, targetDatabase.value, [stmt], targetSchema.value);
} catch (singleError: any) {
syncErrors.value.push({ sql: stmt, error: singleError?.message || String(singleError) });
}
executedCount.value++;
}
}
executedCount.value++;
}
}
}
const failed = syncErrors.value.length;
return syncErrors.value.length;
},
});
if (failed === undefined) return;
if (failed === 0) {
toast(t("dataCompare.syncSuccess"), 2000);
} else {

View File

@ -7,6 +7,7 @@ import { useConnectionStore } from "@/stores/connectionStore";
import { useToast } from "@/composables/useToast";
import { GitCompareArrows, ArrowLeft, Play, Loader2, Maximize2, Minimize2, AlertTriangle, CircleCheck } from "@lucide/vue";
import * as api from "@/lib/backend/api";
import { executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard";
import { useSchemaDiffConfig } from "@/composables/useSchemaDiffConfig";
import SchemaDiffConfigStep from "@/components/diff/SchemaDiffConfigStep.vue";
import SchemaDiffObjectTree from "@/components/diff/SchemaDiffObjectTree.vue";
@ -496,7 +497,14 @@ async function handleExecuteScript() {
executing.value = true;
try {
await api.executeScript(targetConnectionId.value, targetDatabase.value, deploySql.value, targetSchema.value);
const result = await executeWithProductionSqlGuard({
connection: store.getConfig(targetConnectionId.value),
database: targetDatabase.value,
sql: deploySql.value,
source: t("production.sourceSchemaDiff"),
execute: () => api.executeScript(targetConnectionId.value, targetDatabase.value, deploySql.value, targetSchema.value),
});
if (!result) return;
toast(t("diff.executeSuccess"), 3000);
} catch (e: any) {
toast(e?.message || String(e), 5000);
@ -621,7 +629,14 @@ async function onConfirmDeploy() {
showConfirmDialog.value = false;
executing.value = true;
try {
const result = await api.executeScript(targetConnectionId.value, targetDatabase.value, deploySql.value, targetSchema.value);
const result = await executeWithProductionSqlGuard({
connection: store.getConfig(targetConnectionId.value),
database: targetDatabase.value,
sql: deploySql.value,
source: t("production.sourceSchemaDiff"),
execute: () => api.executeScript(targetConnectionId.value, targetDatabase.value, deploySql.value, targetSchema.value),
});
if (!result) return;
deployResult.value = {
success: true,
message: t("diff.deploySuccess"),

View File

@ -57,6 +57,9 @@ import { buildAiContext, runAgentStream, isVectorDbType, isValidActionForMode, d
import { formatAiModelOption } from "@/lib/ai/aiModelPresentation";
import type { AgentEvent } from "@/lib/backend/tauri";
import { buildAiAgentPlan } from "@/lib/ai/aiAgentPlan";
import { extractFirstSqlCodeBlock } 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";
import { createAiShikiCodeHighlighter, type AiCodeHighlighter } from "@/lib/ai/aiCodeHighlighter";
import { createAiMessageRenderer } from "@/lib/ai/aiMessageRender";
@ -496,6 +499,8 @@ const proposalConfirmMessage = computed<ChatMessage | null>(() => {
let allowWriteSqlForNextRun = false;
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);
}
@ -505,6 +510,12 @@ function sendProposalReply(positive: boolean) {
if (isGenerating.value) return;
const target = proposalConfirmMessage.value;
if (!target) return;
if (positive && productionContext.value.active && proposalContainsWriteSql(target.content)) {
const sql = extractFirstSqlCodeBlock(target.content);
if (sql) emit("replaceSql", sql);
toast(t("production.aiReviewRequired"), 5000);
return;
}
const isZh = containsChinese(target.content || "");
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.";
@ -1396,7 +1407,8 @@ async function send() {
const requestedAction = activeAction.value;
const requestedMode = assistantMode.value;
const allowWriteSql = requestedMode === "agent" && allowWriteSqlForNextRun;
// Agent confirmation cannot grant autonomous writes while the active database is production.
const allowWriteSql = requestedMode === "agent" && allowWriteSqlForNextRun && !productionContext.value.active;
allowWriteSqlForNextRun = false;
isGenerating.value = true;
messages.value.push({ role: "assistant", content: "" });
@ -1483,6 +1495,7 @@ async function send() {
instruction: modelInstruction,
assistantContent: msg?.content || "",
connection: props.connection,
database: props.tab?.database,
});
if (msg && requestedMode === "agent") msg.agentSteps = buildAiAgentStepItems(agentPlan);
if (agentPlan.handoffSql) emit("requestAutoExecuteSql", agentPlan.handoffSql);
@ -1739,6 +1752,7 @@ async function openExternalUrl(url: string) {
<span class="flex flex-1 self-stretch items-center truncate text-xs font-medium" data-tauri-drag-region>
{{ chatTitle }}
</span>
<ProductionContextBadge v-if="productionContext.active" compact />
<Button variant="ghost" size="icon" class="h-6 w-6" @click="startNewChat" :title="t('ai.newChat')">
<MessageSquarePlus class="h-3.5 w-3.5" />
</Button>

View File

@ -9,6 +9,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import CustomContextMenu, { type ContextMenuItem } from "@/components/ui/CustomContextMenu.vue";
import { useHistoryStore } from "@/stores/historyStore";
import { useConnectionStore } from "@/stores/connectionStore";
import { useToast } from "@/composables/useToast";
import { resolveHistoryActivityKind } from "@/lib/history/historyActivityKind";
import { canRollbackHistoryEntry } from "@/lib/history/historyAiAnalysis";
@ -16,12 +17,14 @@ import { hasHistoryDateRange, historyDateRangeIsValid, historyEntryMatchesDateRa
import { HISTORY_ROW_HEIGHT, HISTORY_SCROLL_BUFFER, shouldVirtualizeHistory } from "@/lib/history/historyVirtualList";
import type { HistoryEntry } from "@/lib/backend/api";
import { copyToClipboard } from "@/lib/common/clipboard";
import { executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard";
import * as api from "@/lib/backend/api";
const { t } = useI18n();
const { toast } = useToast();
const { highlight } = useSqlHighlighter();
const store = useHistoryStore();
const connectionStore = useConnectionStore();
const emit = defineEmits<{
restore: [sql: string, entry: HistoryEntry];
@ -226,7 +229,14 @@ async function rollback(entry: HistoryEntry) {
isRollingBack.value = true;
const start = Date.now();
try {
const result = await api.executeScript(connectionId, entry.database, rollbackSql);
const result = await executeWithProductionSqlGuard({
connection: connectionStore.getConfig(connectionId),
database: entry.database,
sql: rollbackSql,
source: t("production.sourceQueryHistory"),
execute: () => api.executeScript(connectionId, entry.database, rollbackSql),
});
if (!result) return;
await store.add({
connection_id: connectionId,
connection_name: entry.connection_name,

View File

@ -8,6 +8,7 @@ import { displayGeneratedValue, findGeneratorKey, formatGeneratedValue, generate
import { quoteTableIdentifier } from "@/lib/table/tableSelectSql";
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
import { effectiveDatabaseTypeForConnection } from "@/lib/database/jdbcDialect";
import { executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard";
import GeneratorParamsPanel from "./params/GeneratorParamsPanel.vue";
import type { ColumnInfo, TableInfo } from "@/types/database";
@ -450,43 +451,57 @@ async function startInsert() {
const cid = props.prefillConnectionId;
const db = props.prefillDatabase;
if (!cid || !db) return;
executing.value = true;
const perTable: TableResult[] = [];
for (const r of generatedResults.value) {
const stmts = sqlStatementsForTable(r);
const rowCount = r.rows.length;
let ok = 0;
let lastError = "";
for (let si = 0; si < stmts.length; si++) {
try {
if (generateOptions.useTransaction) {
await api.executeInTransaction(cid, db, [stmts[si]], props.prefillSchema);
} else {
await api.executeQuery(cid, db, stmts[si], props.prefillSchema);
const sql = allSqlStatements().join("\n");
if (!sql.trim()) return;
try {
await executeWithProductionSqlGuard({
connection: store.getConfig(cid),
database: db,
sql,
source: t("production.sourceDataGenerate"),
execute: async () => {
executing.value = true;
const perTable: TableResult[] = [];
for (const r of generatedResults.value) {
const stmts = sqlStatementsForTable(r);
const rowCount = r.rows.length;
let ok = 0;
let lastError = "";
for (let si = 0; si < stmts.length; si++) {
try {
if (generateOptions.useTransaction) {
await api.executeInTransaction(cid, db, [stmts[si]], props.prefillSchema);
} else {
await api.executeQuery(cid, db, stmts[si], props.prefillSchema);
}
if (generateOptions.extendedInsert) {
ok = rowCount;
} else if (!(generateOptions.truncate && si === 0)) {
ok++;
}
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
console.error("[startInsert] SQL error:", msg);
if (!lastError) lastError = msg;
if (generateOptions.extendedInsert) {
ok = 0;
}
if (!generateOptions.continueOnError) break;
}
}
perTable.push({ table: r.tableName, total: rowCount, ok, err: rowCount - ok, error: lastError || undefined });
if (ok > 0) {
store.invalidateMetadataCache(cid, db, props.prefillSchema || undefined, r.tableName);
}
}
if (generateOptions.extendedInsert) {
ok = rowCount;
} else if (!(generateOptions.truncate && si === 0)) {
ok++;
}
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
console.error("[startInsert] SQL error:", msg);
if (!lastError) lastError = msg;
if (generateOptions.extendedInsert) {
ok = 0;
}
if (!generateOptions.continueOnError) break;
}
}
perTable.push({ table: r.tableName, total: rowCount, ok, err: rowCount - ok, error: lastError || undefined });
if (ok > 0) {
store.invalidateMetadataCache(cid, db, props.prefillSchema || undefined, r.tableName);
}
executeResults.value = perTable;
currentStep.value = "result";
return true;
},
});
} finally {
executing.value = false;
}
executeResults.value = perTable;
currentStep.value = "result";
executing.value = false;
}
const orderDialogOpen = ref(false);

View File

@ -18,6 +18,7 @@ const DatabaseSearchDialog = defineAsyncComponent(() => import("@/components/sea
const DatabaseExportDialog = defineAsyncComponent(() => import("@/components/export/DatabaseExportDialog.vue"));
const DataGenerateDialog = defineAsyncComponent(() => import("@/components/generate/DataGenerateDialog.vue"));
import { useConnectionStore } from "@/stores/connectionStore";
import { useProductionSafetyStore } from "@/stores/productionSafetyStore";
import { useDialogSources } from "@/composables/useDialogSources";
import type { ConnectionDeepLinkDraft } from "@/lib/connection/connectionDeepLink";
import type { SqlParameterDescriptor, SqlParameterSyntax } from "@/lib/sql/sqlParameters";
@ -82,7 +83,17 @@ const emit = defineEmits<{
const { t } = useI18n();
const connectionStore = useConnectionStore();
const productionSafetyStore = useProductionSafetyStore();
const dialogs = useDialogSources();
const productionConfirmationDetails = computed(() => {
const request = productionSafetyStore.pending;
if (!request) return "";
return t("production.confirmDetails", {
connection: request.connectionName || "-",
database: request.productionDatabases?.join(", ") || request.database || "-",
source: request.source || "-",
});
});
const editConfig = computed(() => {
const id = connectionStore.editingConnectionId;
@ -136,6 +147,18 @@ watch(
@update:suppress-future-prompts="emit('update:suppressDangerConfirm', $event)"
@confirm="emit('dangerConfirm')"
/>
<DangerConfirmDialog
v-if="productionSafetyStore.pending"
:open="true"
:title="t('production.confirmTitle')"
:message="t('production.confirmMessage')"
:details-text="productionConfirmationDetails"
:sql="productionSafetyStore.pending.sql"
:confirm-label="t('production.confirmAction')"
:close-on-confirm="false"
@update:open="(open) => !open && productionSafetyStore.cancel()"
@confirm="productionSafetyStore.confirm()"
/>
<SqlParameterDialog
v-if="showSqlParameterDialog"
:open="showSqlParameterDialog"

View File

@ -5,7 +5,7 @@ import { appendDebugLog, isDebugLoggingEnabled } from "@/lib/backend/debugLog";
import { canReloadUnavailableDataTab } from "@/lib/table/tableDataRefresh";
import type { CSSProperties } from "vue";
import { useI18n } from "vue-i18n";
import { Check, Columns3, Columns3Cog, EyeOff, Loader2, Search, TableProperties, ChevronDown, ChevronUp, Inbox, RefreshCcw, Wrench, Toolbox, Database, Download, Upload, X, Pin, Rows3, SquareDashed, Minus, Plus } from "@lucide/vue";
import { Check, Columns3, Columns3Cog, EyeOff, Loader2, Search, TableProperties, ChevronDown, ChevronUp, Inbox, RefreshCcw, Wrench, Toolbox, Database, Download, Upload, X, Pin, Rows3, SquareDashed, Minus, Plus, ShieldAlert } from "@lucide/vue";
import { Splitpanes, Pane } from "splitpanes";
import "splitpanes/dist/splitpanes.css";
import { Button } from "@/components/ui/button";
@ -80,6 +80,7 @@ import { formatElapsedSeconds } from "@/lib/common/elapsedTime";
import type { CustomSaveHandler } from "@/composables/useDataGridEditor";
import type { QueryTab, ConnectionConfig, TableInfoTab, TreeNode, VectorCollectionMeta, ObjectBrowserViewport } from "@/types/database";
import { sqlFormatDialectForDbType, type SqlFormatDialect } from "@/lib/sql/sqlFormatter";
import { productionContextForDatabase } from "@/lib/database/productionSafety";
type DataGridHandle = {
onToolbarRefresh: () => Promise<void> | void;
@ -156,7 +157,7 @@ const emit = defineEmits<{
openConnectionSettings: [connectionId: string, initialTab: "advanced"];
}>();
const { t } = useI18n();
const { t, locale } = useI18n();
const queryStore = useQueryStore();
const connectionStore = useConnectionStore();
const settingsStore = useSettingsStore();
@ -206,6 +207,13 @@ const activeTableMeta = computed(() => props.activeTab.tableMeta);
const activeDataTabTableMeta = computed(() => tableMetaForDataTab(props.activeTab));
const activeEffectiveDatabaseType = computed(() => effectiveDatabaseTypeForConnection(props.activeConnection));
const activeDataTabExecutionDatabase = computed(() => dataTabExecutionDatabase(props.activeConnection, props.activeTab.database, activeDataTabTableMeta.value?.catalog));
const activeProductionContext = computed(() => productionContextForDatabase(props.activeConnection, props.activeTab.database));
const productionWatermarkText = computed(() => (locale.value.startsWith("zh") ? "生产环境" : "PROD"));
const productionSessionDetail = computed(() => {
if (!activeProductionContext.value.active) return "";
if (activeProductionContext.value.reason === "connection") return t("production.connection");
return activeProductionContext.value.databases.join(", ") || t("production.databases");
});
function findNodeInTree(nodes: TreeNode[], id: string): TreeNode | undefined {
for (const node of nodes) {
@ -732,15 +740,23 @@ defineExpose({ focusSearch, refreshData, handleModRTarget, requestQueryEditorExe
</script>
<template>
<div class="flex flex-col flex-1 min-h-0">
<div class="production-session-shell flex flex-col flex-1 min-h-0" :class="{ 'production-session-shell--active': activeProductionContext.active }">
<div v-if="activeProductionContext.active" class="production-session-strip flex h-7 shrink-0 items-center gap-2 border-b border-red-500/35 bg-red-500/10 px-3 text-xs font-semibold text-red-800 shadow-[inset_0_1px_0_rgb(239_68_68_/_0.28)] dark:text-red-200">
<ShieldAlert class="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
<span class="font-mono uppercase tracking-normal">{{ t("production.title") }}</span>
<span v-if="productionSessionDetail" class="min-w-0 truncate rounded-[4px] border border-red-500/25 bg-background/65 px-1.5 py-0.5 font-medium text-red-700 dark:text-red-200">{{ productionSessionDetail }}</span>
</div>
<!-- Query mode: editor + results -->
<template v-if="activeTab.mode === 'query'">
<Splitpanes horizontal class="query-output-splitpanes flex-1 min-h-0 overflow-hidden" @resized="onResultsResized">
<Pane class="min-h-0" :size="editorPaneSize" :min-size="resultsPaneOpen ? 15 : 100">
<div class="h-full flex flex-col relative">
<div v-if="activeProductionContext.active" class="production-watermark pointer-events-none absolute inset-0 z-10 grid select-none" aria-hidden="true">
<span v-for="index in 4" :key="index" class="production-watermark__label whitespace-nowrap font-mono text-6xl font-extrabold text-red-700/[0.24] dark:text-red-200/[0.2]">{{ productionWatermarkText }}</span>
</div>
<QueryEditor
ref="queryEditorRef"
class="flex-1"
class="relative z-0 flex-1"
:model-value="activeTab.sql"
:connection-id="activeTab.connectionId"
:database="activeTab.database"
@ -1554,11 +1570,41 @@ defineExpose({ focusSearch, refreshData, handleModRTarget, requestQueryEditorExe
isolation: isolate;
}
.production-session-shell--active {
box-shadow: inset 3px 0 0 color-mix(in oklch, var(--destructive) 78%, transparent);
}
.production-session-strip {
background-image: linear-gradient(90deg, color-mix(in oklch, var(--destructive) 14%, transparent), color-mix(in oklch, var(--destructive) 7%, transparent));
}
.query-output-splitpanes :deep(> .splitpanes__splitter) {
z-index: 1;
flex: 0 0 3px;
}
.production-watermark {
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-rows: repeat(2, minmax(0, 1fr));
gap: 3rem;
overflow: hidden;
padding: 3rem 2.5rem;
}
.production-watermark__label {
align-self: center;
justify-self: center;
transform: rotate(-22deg);
}
@media (max-width: 700px) {
.production-watermark {
grid-template-columns: 1fr;
gap: 1.5rem;
padding-inline: 1rem;
}
}
.result-tab-scroll::-webkit-scrollbar {
display: none;
}

View File

@ -7,6 +7,7 @@ import { SearchableSelect } from "@/components/ui/searchable-select";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
import TruncatedTextTooltip from "@/components/ui/TruncatedTextTooltip.vue";
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
import ProductionContextBadge from "@/components/common/ProductionContextBadge.vue";
import { useConnectionStore } from "@/stores/connectionStore";
import { useDatabaseOptions } from "@/composables/useDatabaseOptions";
import { useSchemaOptions } from "@/composables/useSchemaOptions";
@ -15,6 +16,7 @@ import { formatDatabaseLabel, isDefaultDatabase } from "@/lib/database/defaultDa
import { connectionDisplayName } from "@/lib/tabs/tabPresentation";
import { isSingleDatabase, supportsSqlInListPaste, supportsTransaction as supportsTransactionFeature } from "@/lib/database/databaseCapabilities";
import { hexToRgba } from "@/lib/common/color";
import { productionContextForDatabase } from "@/lib/database/productionSafety";
import type { QueryTab, ConnectionConfig } from "@/types/database";
const props = defineProps<{
@ -65,6 +67,9 @@ const activeDatabaseOptions = computed(() => {
const connectionOptionIds = computed(() => connectionStore.connections.map((connection) => connection.id));
const activeDatabaseValue = computed(() => props.activeTab.database || "");
const activeProductionContext = computed(() => productionContextForDatabase(props.activeConnection, props.activeTab.database));
const showConnectionProductionBadge = computed(() => activeProductionContext.value.reason === "connection");
const showDatabaseProductionBadge = computed(() => activeProductionContext.value.reason === "database");
const activeConnectionValue = computed(() => props.activeConnection?.id || "");
const activeSchemaValue = computed(() => props.activeTab.schema || "");
const supportsExplain = computed(() => {
@ -87,6 +92,10 @@ const transactionTooltip = computed(() => {
if (isAgent) return t("toolbar.autoCommitAgent");
return isManual ? t("toolbar.manualTransaction") : t("toolbar.autoCommit");
});
const executeButtonClass = computed(() => {
if (props.activeTab.isExecuting) return "";
return activeProductionContext.value.active ? "bg-red-500/10 text-red-700 hover:bg-red-500/20 hover:text-red-800 dark:text-red-300 dark:hover:text-red-200" : "bg-emerald-500/10 text-emerald-700 hover:bg-emerald-500/20 hover:text-emerald-800 dark:text-emerald-300 dark:hover:text-emerald-200";
});
const isTransactionActive = computed(() => !!props.txnSessionId);
@ -144,6 +153,11 @@ function databaseDisplayName(database: string): string {
function connectionById(connectionId: string): ConnectionConfig | undefined {
return connectionStore.getConfig(connectionId);
}
function databaseOptionIsProduction(database: string): boolean {
if (!database || props.activeConnection?.is_production) return false;
return productionContextForDatabase(props.activeConnection, database).reason === "database";
}
</script>
<template>
@ -155,7 +169,7 @@ function connectionById(connectionId: string): ConnectionConfig | undefined {
:variant="activeTab.isExecuting ? 'destructive' : 'ghost'"
size="icon"
class="h-6 w-6"
:class="activeTab.isExecuting ? '' : 'bg-emerald-500/10 text-emerald-700 hover:bg-emerald-500/20 hover:text-emerald-800 dark:text-emerald-300 dark:hover:text-emerald-200'"
:class="executeButtonClass"
:disabled="activeTab.isCancelling || activeTab.isExplaining || (!activeTab.isExecuting && !executableSql.trim())"
@click="activeTab.isExecuting ? emit('cancel') : emit('execute')"
>
@ -318,6 +332,7 @@ function connectionById(connectionId: string): ConnectionConfig | undefined {
<div v-if="activeConnection" class="flex min-w-0 items-center gap-1.5">
<DatabaseIcon :db-type="connectionIconType(activeConnection)" class="h-3.5 w-3.5 shrink-0" />
<span class="truncate">{{ label }}</span>
<ProductionContextBadge v-if="showConnectionProductionBadge" compact />
</div>
<span v-else class="truncate text-muted-foreground">{{ t("editor.selectConnection") }}</span>
</template>
@ -354,9 +369,13 @@ function connectionById(connectionId: string): ConnectionConfig | undefined {
<template #trigger-label="{ label, loading }">
<Database class="h-3.5 w-3.5 shrink-0" />
<span class="truncate">{{ loading ? t("common.loading") : label }}</span>
<ProductionContextBadge v-if="showDatabaseProductionBadge" compact />
</template>
<template #option-label="{ label }">
<TruncatedTextTooltip :text="label" class="min-w-0 flex-1" side="left" :side-offset="8" />
<template #option-label="{ option, label }">
<div class="flex min-w-0 flex-1 items-center gap-1.5">
<TruncatedTextTooltip :text="label" class="min-w-0 flex-1" side="left" :side-offset="8" />
<ProductionContextBadge v-if="databaseOptionIsProduction(option)" compact />
</div>
</template>
</SearchableSelect>
<Tooltip v-if="activeDatabaseValue && !isSingleDb">

View File

@ -7,12 +7,15 @@ import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "
import { ScrollArea } from "@/components/ui/scroll-area";
import { buildCreateExtensionSql, buildDropExtensionSql } from "@/lib/database/dbAdminSql";
import * as api from "@/lib/backend/api";
import { useConnectionStore } from "@/stores/connectionStore";
import { executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard";
import { useToast } from "@/composables/useToast";
import { translateBackendError } from "@/i18n/backend-errors";
import type { ExtensionInfo, TreeNode } from "@/types/database";
const { t } = useI18n();
const { toast } = useToast();
const connectionStore = useConnectionStore();
const props = defineProps<{
node: TreeNode;
@ -53,7 +56,14 @@ async function installExtension(name: string) {
installing.value = name;
try {
const sql = buildCreateExtensionSql(name, props.node.schema ?? null);
await api.executeQuery(props.node.connectionId, props.node.database, sql, props.node.schema ?? undefined);
const result = await executeWithProductionSqlGuard({
connection: connectionStore.getConfig(props.node.connectionId),
database: props.node.database,
sql,
source: t("production.sourceExtension"),
execute: () => api.executeQuery(props.node.connectionId!, props.node.database!, sql, props.node.schema ?? undefined),
});
if (!result) return;
await loadData();
emit("close");
} catch (e: any) {
@ -68,7 +78,14 @@ async function dropExtension(name: string) {
dropping.value = name;
try {
const sql = buildDropExtensionSql(name, false);
await api.executeQuery(props.node.connectionId, props.node.database, sql, props.node.schema ?? undefined);
const result = await executeWithProductionSqlGuard({
connection: connectionStore.getConfig(props.node.connectionId),
database: props.node.database,
sql,
source: t("production.sourceExtension"),
execute: () => api.executeQuery(props.node.connectionId!, props.node.database!, sql, props.node.schema ?? undefined),
});
if (!result) return;
await loadData();
} catch (e: any) {
toast(t("connection.connectFailed", { message: translateBackendError(t, e?.message || String(e)) }), 5000);

View File

@ -80,6 +80,7 @@ import QueryEditor from "@/components/editor/QueryEditor.vue";
import DdlViewDialog from "./DdlViewDialog.vue";
import { sqlFormatDialectForDbType, type SqlFormatDialect } from "@/lib/sql/sqlFormatter";
import { isCancelSearchShortcut } from "@/lib/editor/keyboardShortcuts";
import { executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard";
import { batchTableEmptyFeedback, buildBatchTableEmptyPlan, runBatchTableEmpty, type BatchTableEmptyPlanItem } from "@/lib/sidebar/batchTableEmpty";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import {
@ -1145,9 +1146,13 @@ async function confirmRename() {
newName,
source: source.source,
});
for (const sql of statements) {
await api.executeQuery(props.connection.id, props.database, sql, schema);
}
const executed = await executeObjectBrowserSqlWithProductionGuard(statements.join(";\n"), async () => {
for (const sql of statements) {
await api.executeQuery(props.connection.id, props.database, sql, schema);
}
return true;
});
if (!executed) return;
} else {
const sql = await buildRenameObjectSql({
databaseType: effectiveDatabaseType.value,
@ -1156,7 +1161,8 @@ async function confirmRename() {
oldName: row.name,
newName,
});
await api.executeQuery(props.connection.id, props.database, sql, schema);
const executed = await executeObjectBrowserSqlWithProductionGuard(sql, () => api.executeQuery(props.connection.id, props.database, sql, schema));
if (!executed) return;
}
toast(t("contextMenu.renameObjectSuccess", { oldName: row.name, newName }));
showRenameDialog.value = false;
@ -1173,7 +1179,8 @@ async function confirmDrop() {
const row = dropTarget.value;
try {
const sql = dropPreviewSql.value || (await buildDropSqlForRow(row, { cascade: canDropTargetCascade.value && dropTableCascade.value }));
await api.executeQuery(props.connection.id, props.database, sql);
const executed = await executeObjectBrowserSqlWithProductionGuard(sql, () => api.executeQuery(props.connection.id, props.database, sql));
if (!executed) return;
const successKey = row.type === "VIEW" ? "contextMenu.dropViewSuccess" : row.type === "PROCEDURE" ? "contextMenu.dropProcedureSuccess" : row.type === "FUNCTION" ? "contextMenu.dropFunctionSuccess" : "contextMenu.dropTableSuccess";
toast(t(successKey, { name: row.name }));
closeDroppedTableObjectTabsForRow(row);
@ -1409,11 +1416,20 @@ async function confirmBatchDropTables() {
if (targets.length === 0) return;
try {
const useCascade = canBatchDropCascade.value && batchDropCascade.value;
for (const row of targets) {
const sql = await buildDropTableSql(tableAdminSqlOptions(row, { cascade: useCascade }));
await api.executeQuery(props.connection.id, props.database, sql);
closeDroppedTableObjectTabsForRow(row);
}
const statements = await Promise.all(
targets.map(async (row) => ({
row,
sql: await buildDropTableSql(tableAdminSqlOptions(row, { cascade: useCascade })),
})),
);
const executed = await executeObjectBrowserSqlWithProductionGuard(statements.map(({ sql }) => sql).join(";\n"), async () => {
for (const { row, sql } of statements) {
await api.executeQuery(props.connection.id, props.database, sql);
closeDroppedTableObjectTabsForRow(row);
}
return true;
});
if (!executed) return;
toast(t("objects.batchDropSuccess", { count: targets.length }));
clearTableSelection();
await reload();
@ -1468,14 +1484,23 @@ async function confirmBatchTruncateTables() {
if (targets.length === 0) return;
try {
const useCascade = canBatchTruncateCascade.value && batchTruncateCascade.value;
await runBatchTableTruncate(
targets,
async (row) => {
const sql = await buildTruncateTableSql(tableAdminSqlOptions(row, { cascade: useCascade }));
await api.executeQuery(props.connection.id, props.database, sql);
},
refreshMutatedTableDataTabsForRows,
const statements = await Promise.all(
targets.map(async (row) => ({
row,
sql: await buildTruncateTableSql(tableAdminSqlOptions(row, { cascade: useCascade })),
})),
);
const executed = await executeObjectBrowserSqlWithProductionGuard(statements.map(({ sql }) => sql).join(";\n"), async () => {
await runBatchTableTruncate(
statements,
async ({ sql }) => {
await api.executeQuery(props.connection.id, props.database, sql);
},
async (succeeded) => refreshMutatedTableDataTabsForRows(succeeded.map(({ row }) => row)),
);
return true;
});
if (!executed) return;
toast(t("objects.batchTruncateSuccess", { count: targets.length }));
clearTableSelection();
showBatchTruncateConfirm.value = false;
@ -1696,7 +1721,8 @@ async function confirmDuplicateStructure() {
sourceName: row.name,
targetName: newName,
});
await api.executeQuery(props.connection.id, props.database, sql, schema);
const executed = await executeObjectBrowserSqlWithProductionGuard(sql, () => api.executeQuery(props.connection.id, props.database, sql, schema));
if (!executed) return;
toast(t("contextMenu.duplicateStructureSuccess", { name: newName }));
await reload();
await connectionStore.refreshObjectListTreeNode(props.connection.id, props.database, schema);
@ -1799,7 +1825,8 @@ async function confirmPasteTable() {
sourceName: entry.sourceName,
targetName,
});
await api.executeQuery(props.connection.id, props.database, structureSql, schema);
const executed = await executeObjectBrowserSqlWithProductionGuard(structureSql, () => api.executeQuery(props.connection.id, props.database, structureSql, schema));
if (!executed) return;
}
if (copyData) {
const sourceColumns = await api.getColumns(props.connection.id, props.database, schema || "", entry.sourceName);
@ -1814,7 +1841,8 @@ async function confirmPasteTable() {
targetName,
...dataCopyColumnOptions,
});
await api.executeQuery(props.connection.id, props.database, dataSql, schema);
const executed = await executeObjectBrowserSqlWithProductionGuard(dataSql, () => api.executeQuery(props.connection.id, props.database, dataSql, schema));
if (!executed) return;
}
successCount++;
} catch (e: any) {
@ -1841,6 +1869,21 @@ function tableAdminSqlOptions(row: ObjectBrowserRow, options?: { cascade?: boole
return result;
}
/**
* Routes Object Browser writes through the shared production gate. The SQL is
* assessed before the callback runs so generated DDL cannot bypass protection
* via executable comments, EXPLAIN ANALYZE, or qualified production targets.
*/
async function executeObjectBrowserSqlWithProductionGuard<T>(sql: string, execute: () => Promise<T>): Promise<T | undefined> {
return executeWithProductionSqlGuard({
connection: props.connection,
database: props.database,
sql,
source: t("production.sourceObjectBrowser"),
execute,
});
}
async function refreshTruncatePreviewSql(row: ObjectBrowserRow) {
truncatePreviewSql.value = "";
truncatePreviewSql.value = await buildTruncateTableSql(tableAdminSqlOptions(row, { cascade: canTruncateTargetCascade.value && truncateTableCascade.value })).catch(() => "");
@ -1858,7 +1901,8 @@ async function confirmTruncateTable() {
if (!row) return;
try {
const sql = truncatePreviewSql.value || (await buildTruncateTableSql(tableAdminSqlOptions(row, { cascade: canTruncateTargetCascade.value && truncateTableCascade.value })));
await api.executeQuery(props.connection.id, props.database, sql);
const executed = await executeObjectBrowserSqlWithProductionGuard(sql, () => api.executeQuery(props.connection.id, props.database, sql));
if (!executed) return;
toast(t("contextMenu.truncateTableSuccess", { name: row.name }));
await refreshMutatedTableDataTabsForRows([row]);
} catch (e: any) {
@ -1883,7 +1927,8 @@ async function confirmEmptyTable() {
if (!row) return;
try {
const sql = emptyPreviewSql.value || (await buildEmptyTableSql(tableAdminSqlOptions(row)));
await api.executeQuery(props.connection.id, props.database, sql);
const executed = await executeObjectBrowserSqlWithProductionGuard(sql, () => api.executeQuery(props.connection.id, props.database, sql));
if (!executed) return;
toast(t("contextMenu.emptyTableSuccess", { name: row.name }));
await refreshMutatedTableDataTabsForRows([row]);
} catch (e: any) {
@ -1949,8 +1994,23 @@ async function saveSource() {
name: row.name,
source: sourceDraft.value,
});
await executeObjectSourceSave(connectionId, database, effectiveDatabaseType.value, statements, schema);
if (sidePanelGuard.isStale(epoch)) return;
const executableSql = statements.filter((sql) => sql.trim()).join(";\n");
if (executableSql.trim()) {
const saved = await executeWithProductionSqlGuard({
connection: props.connection,
database,
sql: executableSql,
source: t("production.sourceObjectSource"),
execute: async () => {
await executeObjectSourceSave(connectionId, database, effectiveDatabaseType.value, statements, schema);
return true;
},
});
if (!saved || sidePanelGuard.isStale(epoch)) return;
} else {
await executeObjectSourceSave(connectionId, database, effectiveDatabaseType.value, statements, schema);
if (sidePanelGuard.isStale(epoch)) return;
}
toast(t("objects.sourceSaved"));
sourceEditing.value = false;
sourceDraft.value = "";

View File

@ -3,10 +3,12 @@ import { computed, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { Clipboard, Loader2, PencilLine, RefreshCw } from "@lucide/vue";
import { useToast } from "@/composables/useToast";
import { useConnectionStore } from "@/stores/connectionStore";
import { useSettingsStore } from "@/stores/settingsStore";
import { copyToClipboard } from "@/lib/common/clipboard";
import { formatSqlForDisplay, type SqlFormatDialect } from "@/lib/sql/sqlFormatter";
import { buildEditableObjectSource, buildExecutableObjectSourceStatements, executeObjectSourceSave } from "@/lib/table/objectSourceEditor";
import { executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard";
import * as api from "@/lib/backend/api";
import QueryEditor from "@/components/editor/QueryEditor.vue";
import { Button } from "@/components/ui/button";
@ -38,6 +40,7 @@ const emit = defineEmits<{
const { t } = useI18n();
const { toast } = useToast();
const connectionStore = useConnectionStore();
const settingsStore = useSettingsStore();
const content = ref("");
@ -133,18 +136,34 @@ async function saveSource() {
return;
}
if (!draft.value.trim() || !props.databaseType) return;
const databaseType = props.databaseType;
const schema = props.schema || props.database;
saving.value = true;
saveError.value = "";
try {
const statements = await buildExecutableObjectSourceStatements({
databaseType: props.databaseType,
databaseType,
objectType: props.objectType,
schema,
name: props.name,
source: draft.value,
});
await executeObjectSourceSave(props.connectionId, props.database, props.databaseType, statements, schema);
const executableSql = statements.filter((sql) => sql.trim()).join(";\n");
if (executableSql.trim()) {
const saved = await executeWithProductionSqlGuard({
connection: connectionStore.getConfig(props.connectionId),
database: props.database,
sql: executableSql,
source: t("production.sourceObjectSource"),
execute: async () => {
await executeObjectSourceSave(props.connectionId, props.database, databaseType, statements, schema);
return true;
},
});
if (!saved) return;
} else {
await executeObjectSourceSave(props.connectionId, props.database, databaseType, statements, schema);
}
toast(t("objects.sourceSaved"));
emit("saved");
await loadSource(false);

View File

@ -160,6 +160,7 @@ import { rankSavedSqlHistory, type SavedSqlHistoryScope } from "@/lib/savedSql/s
import { isSqlServerLinkedNode } from "@/lib/database/sqlServerLinkedServers";
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
import ConnectionErrorIndicator from "@/components/connection/ConnectionErrorIndicator.vue";
import ProductionContextBadge from "@/components/common/ProductionContextBadge.vue";
import { isSchemaAware } from "@/lib/database/databaseFeatureSupport";
import VisibleDatabasesDialog from "@/components/sidebar/VisibleDatabasesDialog.vue";
import SchemaFilterDialog from "@/components/sidebar/VisibleSchemasDialog.vue";
@ -171,6 +172,8 @@ import { SearchableSelect } from "@/components/ui/searchable-select";
import LightTooltip from "@/components/ui/LightTooltip.vue";
import { flattenTree } from "@/composables/useFlatTree";
import { createDatabaseCollationOptionsForCharset, fallbackCreateDatabaseCharsetMetadata, nextCreateDatabaseCollation, normalizeCreateDatabaseCharset, parseCreateDatabaseCharsetMetadata } from "@/lib/database/createDatabaseCharsetOptions";
import { productionContextForDatabase } from "@/lib/database/productionSafety";
import { executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard";
const { t } = useI18n();
const labelRef = ref<HTMLElement>();
@ -272,6 +275,11 @@ const usesFullWidthLabel = computed(() => usesFullWidthTreeLabel(props.node.type
const sidebarTreeContext = inject(sidebarTreeContextKey, null);
const rowWidthClass = computed(() => (usesFullWidthLabel.value ? "w-max min-w-full" : "w-full min-w-0"));
const labelWidthClass = computed(() => (usesFullWidthLabel.value ? "shrink-0 whitespace-nowrap" : "min-w-0 truncate"));
const nodeProductionContext = computed(() => {
const connectionId = props.node.connectionId;
return productionContextForDatabase(connectionId ? connectionStore.getConfig(connectionId) : undefined, props.node.database);
});
const showProductionBadge = computed(() => nodeProductionContext.value.active && ["connection", "database", "redis-db", "mongo-db"].includes(props.node.type));
function currentDatabaseType(): DatabaseType | undefined {
return props.node.connectionId ? effectiveDatabaseTypeForConnection(connectionStore.getConfig(props.node.connectionId)) : undefined;
@ -2337,6 +2345,18 @@ function openRenameObjectDialog() {
showRenameObjectDialog.value = true;
}
async function executeTreeNodeSqlWithProductionGuard(node: Pick<TreeNode, "connectionId" | "database" | "schema">, sql: string, options: { database?: string; schema?: string } = {}) {
if (!node.connectionId) return undefined;
const database = options.database ?? node.database ?? "";
return executeWithProductionSqlGuard({
connection: connectionStore.getConfig(node.connectionId),
database,
sql,
source: t("production.sourceSidebar"),
execute: () => api.executeQuery(node.connectionId!, database, sql, options.schema ?? node.schema),
});
}
let renameObjectPreviewRequestId = 0;
async function refreshRenameObjectPreviewSql() {
@ -2390,7 +2410,7 @@ async function confirmRenameObject() {
source: source.source,
});
for (const sql of statements) {
await api.executeQuery(node.connectionId, node.database, sql, schema);
await executeTreeNodeSqlWithProductionGuard(node, sql, { database: node.database, schema });
}
} else {
const sql = await buildRenameObjectSql({
@ -2400,7 +2420,7 @@ async function confirmRenameObject() {
oldName: node.label,
newName,
});
await api.executeQuery(node.connectionId, node.database, sql, node.schema);
await executeTreeNodeSqlWithProductionGuard(node, sql, { database: node.database, schema: node.schema });
}
toast(t("contextMenu.renameObjectSuccess", { oldName: node.label, newName }), 3000);
showRenameObjectDialog.value = false;
@ -2418,7 +2438,7 @@ async function confirmDropObject() {
try {
await connectionStore.ensureConnected(node.connectionId);
const sql = dropObjectPreviewSql.value || (await buildDropObjectSql(options));
await api.executeQuery(node.connectionId, node.database, sql, node.schema);
await executeTreeNodeSqlWithProductionGuard(node, sql, { database: node.database, schema: node.schema });
const msgKey = node.type === "view" ? "contextMenu.dropViewSuccess" : node.type === "materialized_view" ? "contextMenu.dropViewSuccess" : node.type === "procedure" ? "contextMenu.dropProcedureSuccess" : "contextMenu.dropFunctionSuccess";
toast(t(msgKey, { name: node.label }), 3000);
closeDroppedTableObjectTabsForNode(node);
@ -2440,7 +2460,7 @@ async function confirmDropTableChildObject() {
try {
await connectionStore.ensureConnected(node.connectionId);
const sql = dropTableChildObjectPreviewSql.value || (await buildDropTableChildObjectSql(options));
await api.executeQuery(node.connectionId, node.database, sql, node.schema);
await executeTreeNodeSqlWithProductionGuard(node, sql, { database: node.database, schema: node.schema });
toast(t("contextMenu.dropTableChildObjectSuccess", { name: options.name }), 3000);
connectionStore.removeTreeNode(node.id);
} catch (e: any) {
@ -2484,7 +2504,7 @@ async function confirmBatchDrop() {
await connectionStore.ensureConnected(target.connectionId);
const sql = await dropSqlForTreeNode(target, { cascade: useCascade });
if (!sql) continue;
await api.executeQuery(target.connectionId, target.database, sql, target.schema);
await executeTreeNodeSqlWithProductionGuard(target, sql, { database: target.database, schema: target.schema });
closeDroppedTableObjectTabsForNode(target);
connectionStore.removeTreeNode(target.id);
}
@ -2507,7 +2527,8 @@ async function confirmBatchTruncate() {
await connectionStore.ensureConnected(target.connectionId);
const sql = await truncateSqlForTreeNode(target, { cascade: useCascade });
if (!sql) return false;
await api.executeQuery(target.connectionId, target.database, sql, target.schema);
const result = await executeTreeNodeSqlWithProductionGuard(target, sql, { database: target.database, schema: target.schema });
return result === undefined ? false : undefined;
},
refreshMutatedTableDataTabsForNodes,
);
@ -2527,7 +2548,7 @@ async function confirmBatchEmpty() {
await connectionStore.ensureConnected(target.connectionId);
const sql = await emptySqlForTreeNode(target);
if (!sql) throw new Error("Empty table SQL is unavailable");
await api.executeQuery(target.connectionId, target.database, sql, target.schema);
await executeTreeNodeSqlWithProductionGuard(target, sql, { database: target.database, schema: target.schema });
});
for (const failure of result.failed) {
console.error(`Failed to empty table "${failure.target.label}":`, failure.error);
@ -2719,7 +2740,7 @@ async function confirmDropTable() {
try {
await connectionStore.ensureConnected(node.connectionId);
const sql = dropTablePreviewSql.value || (await buildDropTableSql(dropTableSqlOptions()));
await api.executeQuery(node.connectionId, node.database, sql, node.schema);
await executeTreeNodeSqlWithProductionGuard(node, sql, { database: node.database, schema: node.schema });
toast(t("contextMenu.dropTableSuccess", { name: node.label }), 3000);
closeDroppedTableObjectTabsForNode(node);
connectionStore.removeTreeNode(node.id);
@ -2739,7 +2760,7 @@ async function confirmEmptyTable() {
try {
await connectionStore.ensureConnected(node.connectionId);
const sql = emptyTablePreviewSql.value || (await buildEmptyTableSql(tableAdminSqlOptions()));
await api.executeQuery(node.connectionId, node.database, sql, node.schema);
await executeTreeNodeSqlWithProductionGuard(node, sql, { database: node.database, schema: node.schema });
const messageKey = currentDatabaseType() === "clickhouse" ? "contextMenu.emptyTableSubmitted" : "contextMenu.emptyTableSuccess";
toast(t(messageKey, { name: node.label }), 3000);
await refreshMutatedTableDataTabsForNode(node);
@ -2760,7 +2781,7 @@ async function confirmTruncateTable() {
try {
await connectionStore.ensureConnected(node.connectionId);
const sql = truncateTablePreviewSql.value || (await buildTruncateTableSql(truncateTableSqlOptions()));
await api.executeQuery(node.connectionId, node.database, sql, node.schema);
await executeTreeNodeSqlWithProductionGuard(node, sql, { database: node.database, schema: node.schema });
toast(t("contextMenu.truncateTableSuccess", { name: node.label }), 3000);
await refreshMutatedTableDataTabsForNode(node);
} catch (e: any) {
@ -2895,7 +2916,7 @@ async function confirmEditDatabaseProperties() {
const options = databasePropertyEditOptions();
if (!options) return;
const sql = await buildUpdateDatabasePropertiesSql(options);
await api.executeQuery(node.connectionId, databasePropertyName(), sql);
await executeTreeNodeSqlWithProductionGuard(node, sql, { database: databasePropertyName() });
toast(t("contextMenu.editDatabasePropertiesSuccess", { name: node.label }), 3000);
showEditDatabasePropertiesDialog.value = false;
await connectionStore.loadDatabases(node.connectionId, { force: true });
@ -2968,7 +2989,7 @@ async function confirmEditSchemaComment() {
name: node.schema || node.label,
comment: schemaCommentText.value,
});
await api.executeQuery(node.connectionId, node.database, sql, node.schema);
await executeTreeNodeSqlWithProductionGuard(node, sql, { database: node.database, schema: node.schema });
toast(t("contextMenu.editSchemaCommentSuccess", { name: node.label }), 3000);
showEditSchemaCommentDialog.value = false;
await connectionStore.loadSchemas(node.connectionId, node.database, { force: true });
@ -3143,7 +3164,8 @@ async function createDuckDbAttachedDatabaseFile() {
duckDbAttachedDatabaseNameFromPath(path),
existingDatabases.map((database) => database.name),
);
await api.executeQuery(node.connectionId, "", await buildDuckDbAttachDatabaseSql(path, name));
const sql = await buildDuckDbAttachDatabaseSql(path, name);
await executeTreeNodeSqlWithProductionGuard(node, sql, { database: "" });
const config = connectionStore.getConfig(node.connectionId);
if (config) {
@ -3184,7 +3206,7 @@ async function confirmCreateDatabase() {
charset: createDatabaseCharset.value,
collation: createDatabaseCollation.value,
});
await api.executeQuery(node.connectionId, "", sql);
await executeTreeNodeSqlWithProductionGuard(node, sql, { database: "" });
toast(t("contextMenu.createDatabaseSuccess", { name }), 3000);
await connectionStore.ensureVisibleDatabase(node.connectionId, name);
await connectionStore.loadDatabases(node.connectionId, { force: true });
@ -3255,7 +3277,7 @@ async function confirmDropDatabase() {
databaseType: currentDatabaseType(),
name: node.label,
}));
await api.executeQuery(node.connectionId, "", sql);
await executeTreeNodeSqlWithProductionGuard(node, sql, { database: "" });
toast(t("contextMenu.dropDatabaseSuccess", { name: node.label }), 3000);
await connectionStore.loadDatabases(node.connectionId, { force: true });
showDropDatabaseConfirm.value = false;
@ -3349,7 +3371,7 @@ async function confirmCreateSchema() {
databaseType: effectiveDatabaseTypeForConnection(config),
name,
});
await api.executeQuery(node.connectionId, targetDatabase || "", sql);
await executeTreeNodeSqlWithProductionGuard(node, sql, { database: targetDatabase || "" });
toast(t("contextMenu.createSchemaSuccess", { name }), 3000);
if (isConnectionLevelSchemaCreation) {
await connectionStore.loadDatabases(node.connectionId, { force: true });
@ -3379,7 +3401,7 @@ async function confirmDropSchema() {
databaseType: currentDatabaseType(),
name: node.label,
}));
await api.executeQuery(node.connectionId, node.database, sql);
await executeTreeNodeSqlWithProductionGuard(node, sql, { database: node.database });
toast(t("contextMenu.dropSchemaSuccess", { name: node.label }), 3000);
const config = connectionStore.getConfig(node.connectionId);
if (config?.db_type === "sqlserver") {
@ -3417,7 +3439,7 @@ async function confirmDuplicateStructure() {
sourceName: node.label,
targetName: newName,
});
await api.executeQuery(node.connectionId, node.database, sql, node.schema);
await executeTreeNodeSqlWithProductionGuard(node, sql, { database: node.database, schema: node.schema });
toast(t("contextMenu.duplicateStructureSuccess", { name: newName }), 3000);
await refreshTableList(node);
} catch (e: any) {
@ -3446,7 +3468,7 @@ async function confirmPasteTable() {
sourceName: entry.sourceName,
targetName,
});
await api.executeQuery(entry.connectionId, entry.database, structureSql, entry.schema);
await executeTreeNodeSqlWithProductionGuard(entry, structureSql, { database: entry.database, schema: entry.schema });
}
if (copyData) {
const sourceColumns = await api.getColumns(entry.connectionId, entry.database, entry.schema || "", entry.sourceName);
@ -3461,7 +3483,7 @@ async function confirmPasteTable() {
targetName,
...dataCopyColumnOptions,
});
await api.executeQuery(entry.connectionId, entry.database, dataSql, entry.schema);
await executeTreeNodeSqlWithProductionGuard(entry, dataSql, { database: entry.database, schema: entry.schema });
}
successCount++;
const refreshKey = `${entry.connectionId}:${entry.database}:${entry.schema || ""}`;
@ -5323,6 +5345,7 @@ function treeItemMenuItems(): ContextMenuItem[] {
@click.stop
/>
<span v-else ref="labelRef" :class="labelWidthClass">{{ visibleLabel(node) }}</span>
<ProductionContextBadge v-if="showProductionBadge" compact />
<span
v-if="
(node.type === 'group-tables' || node.type === 'group-views' || node.type === 'group-materialized-views' || node.type === 'group-procedures' || node.type === 'group-functions' || node.type === 'group-sequences' || node.type === 'group-packages' || node.type === 'group-partitions') &&

View File

@ -12,6 +12,8 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
import { useToast } from "@/composables/useToast";
import { useConnectionStore } from "@/stores/connectionStore";
import { useProductionSafetyStore } from "@/stores/productionSafetyStore";
import { productionContextForDatabase } from "@/lib/database/productionSafety";
import { databaseOptionsForConnection } from "@/composables/useDatabaseOptions";
import { requiresSqlFileTargetDatabaseSelection } from "@/lib/connection/connectionLevelDatabaseBootstrap";
import { cancelSqlFileExecution, executeSqlFile, listenSqlFileProgress, listDatabases, previewSqlFile, type SqlFilePreview, type SqlFileProgress, type SqlFileStatus } from "@/lib/backend/api";
@ -31,6 +33,7 @@ const props = defineProps<{
}>();
const store = useConnectionStore();
const productionSafetyStore = useProductionSafetyStore();
const fileInput = ref<HTMLInputElement | null>(null);
const filePath = ref("");
@ -280,6 +283,18 @@ async function refreshTargetAfterImport() {
async function startExecution() {
if (!canStart.value || !preview.value) return;
const productionContext = productionContextForDatabase(selectedConnection.value, database.value);
if (productionContext.active) {
// File previews are truncated, so production file execution is always reviewed instead of inferring safety from a partial preview.
const confirmed = await productionSafetyStore.requestConfirmation({
sql: preview.value.preview,
connectionName: selectedConnection.value?.name,
database: database.value,
productionDatabases: productionContext.databases,
source: t("production.sourceSqlFile"),
});
if (!confirmed) return;
}
const id = uuid();
executionId.value = id;

View File

@ -13,6 +13,8 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
import { SearchableSelect } from "@/components/ui/searchable-select";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useConnectionStore } from "@/stores/connectionStore";
import { useProductionSafetyStore } from "@/stores/productionSafetyStore";
import { productionContextForDatabase } from "@/lib/database/productionSafety";
import { useQueryStore } from "@/stores/queryStore";
import { useHistoryStore } from "@/stores/historyStore";
import { useSettingsStore, type StructureEditorDensity } from "@/stores/settingsStore";
@ -64,6 +66,7 @@ import * as api from "@/lib/backend/api";
const { t } = useI18n();
const { isDark } = useTheme();
const store = useConnectionStore();
const productionSafetyStore = useProductionSafetyStore();
const queryStore = useQueryStore();
const historyStore = useHistoryStore();
const settingsStore = useSettingsStore();
@ -2061,13 +2064,24 @@ function toggleSqlPreviewCollapsed() {
async function applyChanges() {
if (!canApply.value || !props.connectionId || !props.database) return;
const sql = previewSqlText.value;
const connection = store.getConfig(props.connectionId);
const productionContext = productionContextForDatabase(connection, props.database);
if (productionContext.active) {
const confirmed = await productionSafetyStore.requestConfirmation({
sql,
connectionName: connection?.name,
database: props.database,
productionDatabases: productionContext.databases,
source: t("production.sourceStructure"),
});
if (!confirmed) return;
}
saving.value = true;
errorMessage.value = "";
const sql = previewSqlText.value;
const refreshScope = captureStructureRefreshScope();
const startedAt = Date.now();
try {
const connection = store.getConfig(props.connectionId);
const result = hasSqliteTypeChange.value
? await api.applySqliteTableStructureChange(props.connectionId, props.database, structureChangeOptions(), sqliteSchemaRevision.value!)
: await api.executeBatch(props.connectionId, props.database, pendingStatements.value, props.schema, queryTimeoutSecsForConnection(connection));

View File

@ -4,6 +4,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { requiresDatabaseSelection, useSqlExecution } from "../useSqlExecution";
import { useHistoryStore } from "@/stores/historyStore";
import { useQueryStore } from "@/stores/queryStore";
import { useSettingsStore } from "@/stores/settingsStore";
import { useProductionSafetyStore } from "@/stores/productionSafetyStore";
import type { ConnectionConfig, QueryTab } from "@/types/database";
vi.mock("vue-i18n", () => ({
@ -137,4 +139,34 @@ describe("useSqlExecution", () => {
expect(executedSql).toContain("set @date_start = '2026-07-04 00:00:00'");
expect(executedSql).toContain("where fp.create_at < @date_start");
});
it("requires production confirmation even when ordinary danger prompts are disabled", async () => {
const activeTab = ref<QueryTab | undefined>(queryTab("prod_app"));
const activeConnection = ref<ConnectionConfig | undefined>({ ...connection("mysql"), production_databases: ["prod_app"] });
const activeOutputView = ref<"result" | "summary" | "explain" | "chart">("result");
const queryStore = useQueryStore();
const settingsStore = useSettingsStore();
const productionSafetyStore = useProductionSafetyStore();
const executeCurrentSql = vi.spyOn(queryStore, "executeCurrentSql").mockImplementation(async () => {
if (activeTab.value) activeTab.value.result = { columns: ["ok"], rows: [[1]], affected_rows: 1, execution_time_ms: 1 };
});
vi.spyOn(useHistoryStore(), "add").mockResolvedValue(undefined);
settingsStore.editorSettings.confirmDangerousSqlExecution = false;
const execution = useSqlExecution({
activeTab: computed(() => activeTab.value),
activeConnection: computed(() => activeConnection.value),
executableSql: computed(() => "UPDATE users SET active = 1 WHERE id = 7"),
activeOutputView,
});
const pendingExecution = execution.tryExecute();
await Promise.resolve();
expect(productionSafetyStore.pending?.sql).toContain("UPDATE users");
expect(executeCurrentSql).not.toHaveBeenCalled();
productionSafetyStore.confirm();
await pendingExecution;
expect(executeCurrentSql).toHaveBeenCalledTimes(1);
});
});

View File

@ -7,6 +7,8 @@ import { rowStatusFilterAfterAddingRow, type RowStatusFilter } from "@/lib/dataG
import { supportsDataGridTransaction } from "@/lib/table/tableEditing";
import { useConnectionStore } from "@/stores/connectionStore";
import { useHistoryStore } from "@/stores/historyStore";
import { useProductionSafetyStore } from "@/stores/productionSafetyStore";
import { assessProductionSql, productionContextForDatabase } from "@/lib/database/productionSafety";
import type { ColumnInfo, DatabaseType } from "@/types/database";
import { DBX_NEO4J_ELEMENT_ID_COLUMN, DBX_ROWID_COLUMN } from "@/lib/table/tableEditing";
import { effectiveDatabaseTypeForConnection } from "@/lib/database/jdbcDialect";
@ -168,6 +170,7 @@ export function clearDataGridPendingSnapshotsForTab(tabId: string) {
export function useDataGridEditor(options: UseDataGridEditorOptions) {
const connectionStore = useConnectionStore();
const historyStore = useHistoryStore();
const productionSafetyStore = useProductionSafetyStore();
const {
result,
@ -1161,6 +1164,22 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
return;
}
const customHandler = customSaveHandler?.value;
const connection = connectionStore.getConfig(connectionId.value ?? "");
const customHandlerProductionContext = productionContextForDatabase(connection, database.value);
if (customHandler && customHandlerProductionContext.active) {
// Custom data sources may not expose SQL, but their row mutations still need the same production interlock.
if (saveOptions.autoSave) {
return;
}
const confirmed = await productionSafetyStore.requestConfirmation({
sql: describeDataGridChanges(snapshot),
connectionName: connection?.name,
database: database.value,
productionDatabases: customHandlerProductionContext.databases,
source: "Data editor",
});
if (!confirmed) return;
}
if (customHandler && snapshot.newRows.length > 0 && customHandler.supportsInsert !== true && customHandler.canInsert !== true) {
saveError.value = "当前保存目标不支持新增行。";
return;
@ -1220,6 +1239,25 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
return;
}
const rollbackStmts = preparedSave?.rollbackStatements ?? [];
const productionAssessment = assessProductionSql(stmts.join(";\n"), connection, database.value);
if (productionAssessment.active && productionAssessment.isMutation) {
// Autosave must never write production data without an operator reviewing the generated statements.
if (saveOptions.autoSave) {
await finishInterruptedSaveChanges(snapshot);
return;
}
const confirmed = await productionSafetyStore.requestConfirmation({
sql: stmts.join("\n"),
connectionName: connection?.name,
database: database.value,
productionDatabases: productionAssessment.databases,
source: "Data editor",
});
if (!confirmed) {
await finishInterruptedSaveChanges(snapshot);
return;
}
}
const start = Date.now();
let apiResult: { affected_rows?: number } | undefined;
console.info("[DBX][dataGrid:save-statements]", {
@ -1473,3 +1511,8 @@ export function useDataGridEditor(options: UseDataGridEditorOptions) {
},
};
}
function describeDataGridChanges(snapshot: { newRows: unknown[]; dirtyRows: Map<unknown, unknown>; deletedRows: Set<unknown> }): string {
const changes = [snapshot.newRows.length ? `INSERT: ${snapshot.newRows.length} row(s)` : "", snapshot.dirtyRows.size ? `UPDATE: ${snapshot.dirtyRows.size} row(s)` : "", snapshot.deletedRows.size ? `DELETE: ${snapshot.deletedRows.size} row(s)` : ""].filter(Boolean);
return changes.join("\n") || "DATA GRID WRITE";
}

View File

@ -14,6 +14,8 @@ import { isSqlExecutionSnapshot, resolveExecutableSql, type SqlExecutionOverride
import { extractSqlParameterDescriptors, type SqlParameterDescriptor, type SqlParameterSyntax } from "@/lib/sql/sqlParameters";
import { expandSqlVariables } from "@/lib/sql/sqlVariables";
import { enabledSqlParameterSyntaxes, resolveSqlVariableSyntaxToggles } from "@/lib/sql/sqlVariableSyntax";
import { assessProductionSql } from "@/lib/database/productionSafety";
import { useProductionSafetyStore } from "@/stores/productionSafetyStore";
import type { ConnectionConfig, DatabaseType, QueryTab } from "@/types/database";
const DANGER_RE = /^\s*(DROP|DELETE|TRUNCATE|ALTER|UPDATE|MERGE|REPLACE)\b/i;
@ -53,6 +55,7 @@ export function useSqlExecution(deps: {
const historyStore = useHistoryStore();
const connectionStore = useConnectionStore();
const settingsStore = useSettingsStore();
const productionSafetyStore = useProductionSafetyStore();
const { toast } = useToast();
const dangerSql = ref("");
@ -102,6 +105,19 @@ export function useSqlExecution(deps: {
}
}
}
const productionAssessment = assessProductionSql(sql, deps.activeConnection.value, deps.activeTab.value?.database);
if (productionAssessment.active && productionAssessment.isMutation) {
// Production writes always need a new explicit decision; editor preferences cannot suppress this gate.
const confirmed = await productionSafetyStore.requestConfirmation({
sql,
connectionName: deps.activeConnection.value?.name,
database: deps.activeTab.value?.database,
productionDatabases: productionAssessment.databases,
source: t("production.sourceSqlEditor"),
});
if (confirmed) await doExecute(sql);
return;
}
if (isDangerousSql(sql) && settingsStore.editorSettings.confirmDangerousSqlExecution) {
dangerSql.value = sql;
pendingDangerSql.value = sql;

View File

@ -2281,6 +2281,48 @@ export default {
cancel: "Cancel",
confirm: "Execute",
},
production: {
title: "Production environment",
connection: "Production connection",
enable: "Enable production safeguards",
disabledDescription: "Enable this to protect every database or selected databases.",
scope: "Protection scope",
allDatabases: "All databases",
selectedDatabases: "Selected databases",
singleDatabaseScopeHint: "This database type supports connection-level production safeguards only.",
databases: "Production databases",
connectionDescription: "Every database on this connection uses production safeguards.",
databaseDescription: "Choose the databases that need production safeguards. The first selection includes all databases.",
selectDatabases: "Select production databases",
noDatabasesSelected: "No databases selected",
databasesConfiguredCount: "{count} selected",
databasesSelectedCount: "{selected}/{total} selected",
databasePickerTitle: "Select production databases",
databasePickerDescription: 'Choose the databases on "{connection}" that need production safeguards.',
databaseSearchPlaceholder: "Search databases...",
databaseSelectionRequired: "Select at least one database.",
databaseLoadFailed: "Could not load databases: {message}",
noDatabasesAvailable: "No databases are available to select.",
retry: "Retry",
confirmTitle: "Confirm production write",
confirmMessage: "This operation changes a production database and requires explicit confirmation.",
confirmDetails: "Connection: {connection}\nDatabase: {database}\nSource: {source}",
confirmAction: "Execute in production",
sourceSqlEditor: "SQL editor",
sourceDataGrid: "Data editor",
sourceStructure: "Structure editor",
sourceSqlFile: "SQL file execution",
sourceObjectBrowser: "Object browser",
sourceSchemaDiff: "Schema diff",
sourceDataCompare: "Data compare",
sourceExtension: "Extension manager",
sourceSidebar: "Object tree",
sourceObjectSource: "Object source editor",
sourceDataGenerate: "Data generator",
sourceQueryHistory: "Query history rollback",
sourceAdmin: "Database administration",
aiReviewRequired: "Production SQL was added to the editor. Review it and run it manually to confirm.",
},
sqlParameters: {
title: "SQL Parameters",
description: "Fill values for SQL template placeholders. DBX replaces them before executing the SQL.",

View File

@ -3636,4 +3636,46 @@ export default withEnglishFallback({
partitionMustIncrease: "El nuevo número de particiones debe ser mayor que el actual.",
confirmDelete: '¿Está seguro de que desea eliminar el tema "{name}"? Esta operación no se puede deshacer.',
},
production: {
title: "Entorno de producción",
connection: "Conexión de producción",
enable: "Activar protección del entorno de producción",
disabledDescription: "Al activar, puede elegir proteger todas las bases de datos o bases de datos específicas.",
scope: "Alcance de la protección",
allDatabases: "Todas las bases de datos",
selectedDatabases: "Bases de datos seleccionadas",
singleDatabaseScopeHint: "Este tipo de base de datos solo admite protección del entorno de producción a nivel de conexión.",
databases: "Bases de datos de producción",
connectionDescription: "Todas las bases de datos de esta conexión tendrán habilitada la protección de producción.",
databaseDescription: "Seleccione de la conexión las bases de datos que necesiten protección de producción. La primera vez, se seleccionan todas por defecto.",
selectDatabases: "Seleccionar bases de datos de producción",
noDatabasesSelected: "Ninguna seleccionada",
databasesConfiguredCount: "{count} seleccionadas",
databasesSelectedCount: "{selected}/{total} seleccionadas",
databasePickerTitle: "Seleccionar bases de datos de producción",
databasePickerDescription: "Seleccione las bases de datos en {connection} que necesiten protección de producción.",
databaseSearchPlaceholder: "Buscar bases de datos...",
databaseSelectionRequired: "Seleccione al menos una base de datos.",
databaseLoadFailed: "No se pudo cargar la lista de bases de datos: {message}",
noDatabasesAvailable: "No se encontraron bases de datos para seleccionar.",
retry: "Reintentar",
confirmTitle: "Confirmar escritura en el entorno de producción",
confirmMessage: "Esta operación modificará la base de datos de producción. Debe confirmar explícitamente para continuar.",
confirmDetails: "Conexión: {connection}\nBase de datos: {database}\nOrigen: {source}",
confirmAction: "Ejecutar en el entorno de producción",
sourceSqlEditor: "Editor SQL",
sourceDataGrid: "Editor de datos",
sourceStructure: "Editor de estructura",
sourceSqlFile: "Ejecución de archivo SQL",
sourceObjectBrowser: "Explorador de objetos",
sourceSchemaDiff: "Comparación de esquemas",
sourceDataCompare: "Comparación de datos",
sourceExtension: "Gestor de extensiones",
sourceSidebar: "Árbol de objetos",
sourceObjectSource: "Editor de código fuente de objetos",
sourceDataGenerate: "Generador de datos",
sourceQueryHistory: "Reversión del historial de consultas",
sourceAdmin: "Administración de bases de datos",
aiReviewRequired: "El SQL de producción se ha colocado en el editor. Revíselo primero y luego ejecútelo manualmente para confirmar.",
},
});

View File

@ -3634,4 +3634,46 @@ export default withEnglishFallback({
partitionMustIncrease: "Il nuovo numero di partizioni deve essere maggiore di quello attuale.",
confirmDelete: 'Confermi di eliminare il topic "{name}"? Questa operazione è irreversibile.',
},
production: {
title: "Ambiente di produzione",
connection: "Connessione di produzione",
enable: "Abilita protezione ambiente di produzione",
disabledDescription: "Dopo l'attivazione, è possibile scegliere di proteggere tutti i database o database specifici.",
scope: "Ambito di protezione",
allDatabases: "Tutti i database",
selectedDatabases: "Database selezionati",
singleDatabaseScopeHint: "Questo tipo di database supporta solo la protezione dell'ambiente di produzione a livello di connessione.",
databases: "Database di produzione",
connectionDescription: "Tutti i database su questa connessione avranno la protezione di produzione abilitata.",
databaseDescription: "Seleziona i database che necessitano di protezione di produzione dalla connessione. Alla prima apertura, sono selezionati tutti per impostazione predefinita.",
selectDatabases: "Seleziona database di produzione",
noDatabasesSelected: "Nessuno selezionato",
databasesConfiguredCount: "{count} selezionati",
databasesSelectedCount: "Selezionati {selected}/{total}",
databasePickerTitle: "Seleziona database di produzione",
databasePickerDescription: "Seleziona i database che necessitano di protezione di produzione in «{connection}».",
databaseSearchPlaceholder: "Cerca database...",
databaseSelectionRequired: "Selezionare almeno un database.",
databaseLoadFailed: "Impossibile caricare l'elenco dei database: {message}",
noDatabasesAvailable: "Nessun database selezionabile trovato.",
retry: "Riprova",
confirmTitle: "Conferma scrittura in ambiente di produzione",
confirmMessage: "Questa operazione modificherà il database di produzione; è necessario confermare esplicitamente per procedere.",
confirmDetails: "Connessione: {connection}\nDatabase: {database}\nOrigine: {source}",
confirmAction: "Esegui in ambiente di produzione",
sourceSqlEditor: "Editor SQL",
sourceDataGrid: "Editor dati",
sourceStructure: "Editor struttura",
sourceSqlFile: "Esecuzione file SQL",
sourceObjectBrowser: "Browser oggetti",
sourceSchemaDiff: "Confronto schema",
sourceDataCompare: "Confronto dati",
sourceExtension: "Gestione estensioni",
sourceSidebar: "Albero oggetti",
sourceObjectSource: "Editor sorgente oggetto",
sourceDataGenerate: "Generatore dati",
sourceQueryHistory: "Rollback cronologia query",
sourceAdmin: "Amministrazione database",
aiReviewRequired: "La SQL di produzione è stata inserita nell'editor. Controllare prima, quindi eseguire manualmente per confermare.",
},
});

View File

@ -3635,4 +3635,46 @@ export default withEnglishFallback({
partitionMustIncrease: "新しいパーティション数は現在のパーティション数より大きくなければなりません。",
confirmDelete: "トピック「{name}」を削除してもよろしいですか?この操作は元に戻せません。",
},
production: {
title: "本番環境",
connection: "本番接続",
enable: "本番環境保護を有効にする",
disabledDescription: "有効にすると、すべてのデータベースまたは指定されたデータベースを保護するように選択できます。",
scope: "保護範囲",
allDatabases: "すべてのデータベース",
selectedDatabases: "データベースを選択",
singleDatabaseScopeHint: "このデータベースタイプでは、接続レベルの本番環境保護のみがサポートされています。",
databases: "本番データベース",
connectionDescription: "この接続上のすべてのデータベースで本番保護が有効になります。",
databaseDescription: "接続から本番保護が必要なデータベースを選択します。初回起動時はデフォルトで全選択されています。",
selectDatabases: "本番データベースを選択",
noDatabasesSelected: "まだ選択されていません",
databasesConfiguredCount: "{count} 個選択済み",
databasesSelectedCount: "{selected}/{total} 選択済み",
databasePickerTitle: "本番データベースを選択",
databasePickerDescription: "「{connection}」で本番保護が必要なデータベースを選択してください。",
databaseSearchPlaceholder: "データベースを検索...",
databaseSelectionRequired: "少なくとも1つのデータベースを選択してください。",
databaseLoadFailed: "データベースリストを読み込めませんでした:{message}",
noDatabasesAvailable: "選択可能なデータベースが見つかりません。",
retry: "再試行",
confirmTitle: "本番環境への書き込みを確認",
confirmMessage: "この操作は本番データベースを変更します。続行するには明示的な確認が必要です。",
confirmDetails: "接続:{connection}\nデータベース{database}\nソース{source}",
confirmAction: "本番環境で実行",
sourceSqlEditor: "SQL エディター",
sourceDataGrid: "データエディター",
sourceStructure: "構造エディター",
sourceSqlFile: "SQL ファイル実行",
sourceObjectBrowser: "オブジェクトブラウザー",
sourceSchemaDiff: "スキーマ差分",
sourceDataCompare: "データ比較",
sourceExtension: "拡張機能マネージャー",
sourceSidebar: "オブジェクトツリー",
sourceObjectSource: "オブジェクトソースエディター",
sourceDataGenerate: "データジェネレーター",
sourceQueryHistory: "クエリ履歴のロールバック",
sourceAdmin: "データベース管理",
aiReviewRequired: "本番SQLがエディターに配置されました。まず確認し、手動で実行して確定してください。",
},
});

View File

@ -3636,4 +3636,46 @@ export default withEnglishFallback({
partitionMustIncrease: "O novo número de partições deve ser maior que o atual.",
confirmDelete: 'Tem certeza de que deseja excluir o tópico "{name}"? Esta operação é irreversível.',
},
production: {
title: "Ambiente de Produção",
connection: "Conexão de Produção",
enable: "Ativar proteção de ambiente de produção",
disabledDescription: "Após ativar, é possível escolher proteger todos os bancos de dados ou bancos específicos.",
scope: "Escopo da proteção",
allDatabases: "Todos os bancos de dados",
selectedDatabases: "Selecionar bancos de dados",
singleDatabaseScopeHint: "Este tipo de banco de dados suporta apenas proteção de ambiente de produção em nível de conexão.",
databases: "Bancos de dados de produção",
connectionDescription: "Todos os bancos de dados nesta conexão terão proteção de produção ativada.",
databaseDescription: "Selecione os bancos de dados da conexão que precisam de proteção de produção. Na primeira abertura, todos são selecionados por padrão.",
selectDatabases: "Selecionar bancos de dados de produção",
noDatabasesSelected: "Nenhum selecionado ainda",
databasesConfiguredCount: "{count} selecionado(s)",
databasesSelectedCount: "{selected}/{total} selecionado(s)",
databasePickerTitle: "Selecionar bancos de dados de produção",
databasePickerDescription: "Selecione os bancos de dados em «{connection}» que precisam de proteção de produção.",
databaseSearchPlaceholder: "Buscar bancos de dados...",
databaseSelectionRequired: "Por favor, selecione pelo menos um banco de dados.",
databaseLoadFailed: "Não foi possível carregar a lista de bancos de dados: {message}",
noDatabasesAvailable: "Nenhum banco de dados disponível encontrado.",
retry: "Tentar novamente",
confirmTitle: "Confirmar gravação em ambiente de produção",
confirmMessage: "Esta operação irá modificar o banco de dados de produção. É necessário confirmar explicitamente para continuar.",
confirmDetails: "Conexão: {connection}\nBanco de dados: {database}\nOrigem: {source}",
confirmAction: "Executar em produção",
sourceSqlEditor: "Editor SQL",
sourceDataGrid: "Editor de dados",
sourceStructure: "Editor de estrutura",
sourceSqlFile: "Execução de arquivo SQL",
sourceObjectBrowser: "Navegador de objetos",
sourceSchemaDiff: "Comparação de esquemas",
sourceDataCompare: "Comparação de dados",
sourceExtension: "Gerenciador de extensões",
sourceSidebar: "Árvore de objetos",
sourceObjectSource: "Editor de código-fonte de objetos",
sourceDataGenerate: "Gerador de dados",
sourceQueryHistory: "Reversão do histórico de consultas",
sourceAdmin: "Administração de banco de dados",
aiReviewRequired: "O SQL de produção foi colocado no editor. Verifique-o primeiro e execute manualmente para confirmar.",
},
});

View File

@ -2280,6 +2280,48 @@ export default withEnglishFallback({
cancel: "取消",
confirm: "执行",
},
production: {
title: "生产环境",
connection: "生产连接",
enable: "启用生产环境保护",
disabledDescription: "开启后可选择保护全部数据库或指定数据库。",
scope: "保护范围",
allDatabases: "全部数据库",
selectedDatabases: "选择数据库",
singleDatabaseScopeHint: "此数据库类型仅支持连接级生产环境保护。",
databases: "生产数据库",
connectionDescription: "此连接上的所有数据库都会启用生产保护。",
databaseDescription: "从连接中选择需要生产保护的数据库。首次打开默认全选。",
selectDatabases: "选择生产数据库",
noDatabasesSelected: "尚未选择",
databasesConfiguredCount: "已选择 {count} 个",
databasesSelectedCount: "已选择 {selected}/{total}",
databasePickerTitle: "选择生产数据库",
databasePickerDescription: "选择「{connection}」中需要生产保护的数据库。",
databaseSearchPlaceholder: "搜索数据库...",
databaseSelectionRequired: "请至少选择一个数据库。",
databaseLoadFailed: "无法加载数据库列表:{message}",
noDatabasesAvailable: "未找到可选择的数据库。",
retry: "重试",
confirmTitle: "确认生产环境写入",
confirmMessage: "该操作将变更生产数据库,必须明确确认后才能继续。",
confirmDetails: "连接:{connection}\n数据库{database}\n入口{source}",
confirmAction: "在生产环境执行",
sourceSqlEditor: "SQL 编辑器",
sourceDataGrid: "数据编辑器",
sourceStructure: "结构编辑器",
sourceSqlFile: "SQL 文件执行",
sourceObjectBrowser: "对象浏览器",
sourceSchemaDiff: "结构对比",
sourceDataCompare: "数据对比",
sourceExtension: "扩展管理",
sourceSidebar: "对象树",
sourceObjectSource: "对象源码编辑器",
sourceDataGenerate: "数据生成器",
sourceQueryHistory: "查询历史回滚",
sourceAdmin: "数据库管理",
aiReviewRequired: "生产 SQL 已放入编辑器。请先检查,再手动执行以确认。",
},
sqlParameters: {
title: "SQL 参数",
description: "为 SQL 模板占位符填写参数值DBX 会在执行前替换为 SQL 字面量。",

View File

@ -3635,4 +3635,46 @@ export default withEnglishFallback({
partitionMustIncrease: "新分區數必須大於目前分區數。",
confirmDelete: "確定要刪除主題「{name}」嗎?此操作無法復原。",
},
production: {
title: "生產環境",
connection: "生產連線",
enable: "啟用生產環境保護",
disabledDescription: "開啟後可選擇保護全部資料庫或指定資料庫。",
scope: "保護範圍",
allDatabases: "全部資料庫",
selectedDatabases: "選擇資料庫",
singleDatabaseScopeHint: "此資料庫類型僅支援連線級生產環境保護。",
databases: "生產資料庫",
connectionDescription: "此連線上的所有資料庫都會啟用生產保護。",
databaseDescription: "從連線中選擇需要生產保護的資料庫。首次開啟預設全選。",
selectDatabases: "選擇生產資料庫",
noDatabasesSelected: "尚未選擇",
databasesConfiguredCount: "已選擇 {count} 個",
databasesSelectedCount: "已選擇 {selected}/{total}",
databasePickerTitle: "選擇生產資料庫",
databasePickerDescription: "選擇「{connection}」中需要生產保護的資料庫。",
databaseSearchPlaceholder: "搜尋資料庫...",
databaseSelectionRequired: "請至少選擇一個資料庫。",
databaseLoadFailed: "無法載入資料庫清單:{message}",
noDatabasesAvailable: "未找到可選擇的資料庫。",
retry: "重試",
confirmTitle: "確認生產環境寫入",
confirmMessage: "該操作將變更生產資料庫,必須明確確認後才能繼續。",
confirmDetails: "連線:{connection}\n資料庫{database}\n入口{source}",
confirmAction: "在生產環境執行",
sourceSqlEditor: "SQL 編輯器",
sourceDataGrid: "資料編輯器",
sourceStructure: "結構編輯器",
sourceSqlFile: "SQL 檔案執行",
sourceObjectBrowser: "物件瀏覽器",
sourceSchemaDiff: "結構比對",
sourceDataCompare: "資料比對",
sourceExtension: "擴充功能管理",
sourceSidebar: "物件樹",
sourceObjectSource: "物件原始碼編輯器",
sourceDataGenerate: "資料產生器",
sourceQueryHistory: "查詢歷史回復",
sourceAdmin: "資料庫管理",
aiReviewRequired: "生產 SQL 已放入編輯器。請先檢查,再手動執行以確認。",
},
});

View File

@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import { buildAiAgentPlan } from "../aiAgentPlan";
import { classifyAiSqlExecution } from "../aiSqlExecutionPolicy";
import type { ConnectionConfig } from "@/types/database";
const productionConnection: ConnectionConfig = {
id: "conn-1",
name: "Operations",
db_type: "mysql",
host: "db.internal",
port: 3306,
username: "readonly",
password: "",
production_databases: ["prod_app"],
};
describe("AI production SQL policy", () => {
it("requires confirmation for a scoped production write", () => {
expect(classifyAiSqlExecution("UPDATE users SET active = 1 WHERE id = 7", productionConnection, "prod_app")).toMatchObject({
action: "confirm",
environment: "production",
reasons: ["production_write"],
});
});
it("hands production write SQL back to the operator instead of auto-executing", () => {
const plan = buildAiAgentPlan({
mode: "agent",
action: "generate",
instruction: "execute the update",
assistantContent: "```sql\nUPDATE users SET active = 1 WHERE id = 7\n```",
connection: productionConnection,
database: "prod_app",
});
expect(plan.executableSql).toBeUndefined();
expect(plan.handoffSql).toContain("UPDATE users");
expect(plan.steps).toContainEqual({ kind: "execute_sql", status: "skipped", reason: "requires_confirmation" });
});
});

View File

@ -22,6 +22,7 @@ export interface AiAgentPlanInput {
instruction: string;
assistantContent: string;
connection?: ConnectionConfig;
database?: string;
}
export interface AiAgentPlan {
@ -61,7 +62,7 @@ export function buildAiAgentPlan(input: AiAgentPlanInput): AiAgentPlan {
return { steps };
}
const decision = classifyAiSqlExecution(sql, input.connection);
const decision = classifyAiSqlExecution(sql, input.connection, input.database);
steps.push({ kind: "risk_check", status: "done", ...decision });
if (decision.action === "auto_execute") {

View File

@ -1,4 +1,6 @@
import type { ConnectionConfig } from "@/types/database";
import { assessProductionSql, productionContextForDatabase } from "@/lib/database/productionSafety";
import { classifySqlStatementRisk, splitSqlStatementsForSafety, sqlSafetyText } from "@/lib/sql/sqlRisk";
export type ConnectionEnvironment = "production" | "non_production" | "unknown";
export type AiSqlExecutionAction = "auto_execute" | "confirm" | "block";
@ -11,40 +13,29 @@ export interface AiSqlExecutionDecision {
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;
export function stripAiSqlComments(sql: string): string {
return sql
.replace(/\/\*[\s\S]*?\*\//g, " ")
.replace(/--.*$/gm, " ")
.replace(/#.*$/gm, " ");
return sqlSafetyText(sql);
}
function sqlStatements(sql: string): string[] {
return stripAiSqlComments(sql)
.split(";")
.map((stmt) => stmt.trim())
.filter(Boolean);
return splitSqlStatementsForSafety(sql);
}
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";
function classifyStatement(statement: string, connection?: ConnectionConfig): AiSqlExecutionCategory {
const risk = classifySqlStatementRisk(statement, { dialect: connection?.db_type });
if (risk.risk === "read") return "read";
if (risk.risk === "unknown") return "unknown";
if (risk.risk === "transaction" || risk.risk === "ddl") {
return risk.firstKeyword === "create" ? "schema_change" : "dangerous";
}
if (risk.firstKeyword === "insert") return "low_risk_write";
if (risk.firstKeyword === "update") return isScopedUpdate(statement) ? "low_risk_write" : "dangerous";
if (risk.firstKeyword === "delete" || risk.firstKeyword === "merge" || risk.firstKeyword === "replace") return "write";
return "unknown";
}
@ -56,8 +47,9 @@ function isScopedUpdate(statement: string): boolean {
return /\b[\w"`.[\]]*(?:id|_id|uuid|key)[\w"`.[\]]*\s*=\s*(?:'[^']+'|"[^"]+"|`[^`]+`|[\w.-]+)/i.test(where);
}
export function classifyConnectionEnvironment(connection?: ConnectionConfig): ConnectionEnvironment {
export function classifyConnectionEnvironment(connection?: ConnectionConfig, database?: string): ConnectionEnvironment {
if (!connection) return "unknown";
if (productionContextForDatabase(connection, database).active) return "production";
const parts = [connection.name, connection.host, connection.database, connection.connection_string].filter(Boolean);
const signal = parts.join(" ");
@ -66,8 +58,8 @@ export function classifyConnectionEnvironment(connection?: ConnectionConfig): Co
return "unknown";
}
export function classifyAiSqlExecution(sql: string, connection?: ConnectionConfig): AiSqlExecutionDecision {
const environment = classifyConnectionEnvironment(connection);
export function classifyAiSqlExecution(sql: string, connection?: ConnectionConfig, database?: string): AiSqlExecutionDecision {
const environment = classifyConnectionEnvironment(connection, database);
const statements = sqlStatements(sql);
const reasons: string[] = [];
@ -75,9 +67,10 @@ export function classifyAiSqlExecution(sql: string, connection?: ConnectionConfi
return { action: "block", environment, category: "unknown", reasons: ["empty_sql"] };
}
const categories = statements.map(classifyStatement);
const categories = statements.map((statement) => classifyStatement(statement, connection));
const hasMultipleStatements = statements.length > 1;
if (hasMultipleStatements) reasons.push("multi_statement");
const productionAssessment = assessProductionSql(sql, connection, database);
if (categories.includes("dangerous")) {
return { action: "block", environment, category: "dangerous", reasons };
@ -91,6 +84,11 @@ export function classifyAiSqlExecution(sql: string, connection?: ConnectionConfi
return { action: "auto_execute", environment, category: "read", reasons };
}
if (productionAssessment.active && productionAssessment.isMutation) {
reasons.push("production_write");
return { action: "confirm", environment: "production", category: categories[0] ?? "unknown", reasons };
}
if (hasMultipleStatements) {
return { action: "confirm", environment, category: "write", reasons };
}

View File

@ -0,0 +1,119 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createPinia, setActivePinia } from "pinia";
import { executeWithProductionSqlGuard } from "../productionExecutionGuard";
import { useProductionSafetyStore } from "@/stores/productionSafetyStore";
import type { ConnectionConfig } from "@/types/database";
function connection(overrides: Partial<ConnectionConfig> = {}): ConnectionConfig {
return {
id: "conn-1",
name: "Operations",
db_type: "mysql",
host: "db.internal",
port: 3306,
username: "operator",
password: "",
production_databases: ["prod_app"],
...overrides,
};
}
describe("production SQL execution guard", () => {
beforeEach(() => {
setActivePinia(createPinia());
});
it("waits for confirmation before executing production SQL", async () => {
const execute = vi.fn().mockResolvedValue("done");
const pendingExecution = executeWithProductionSqlGuard({
connection: connection(),
database: "prod_app",
sql: "DELETE FROM users WHERE id = 1",
source: "Schema diff",
execute,
});
await Promise.resolve();
const store = useProductionSafetyStore();
expect(store.pending).toMatchObject({
sql: "DELETE FROM users WHERE id = 1",
database: "prod_app",
productionDatabases: ["prod_app"],
source: "Schema diff",
});
expect(execute).not.toHaveBeenCalled();
store.confirm();
await expect(pendingExecution).resolves.toBe("done");
expect(execute).toHaveBeenCalledTimes(1);
});
it("cancels production SQL without executing the callback", async () => {
const execute = vi.fn().mockResolvedValue("done");
const pendingExecution = executeWithProductionSqlGuard({
connection: connection(),
database: "prod_app",
sql: "TRUNCATE TABLE users",
source: "Object tree",
execute,
});
await Promise.resolve();
useProductionSafetyStore().cancel();
await expect(pendingExecution).resolves.toBeUndefined();
expect(execute).not.toHaveBeenCalled();
});
it("queues concurrent production confirmations", async () => {
const firstExecute = vi.fn().mockResolvedValue("first");
const secondExecute = vi.fn().mockResolvedValue("second");
const firstSql = "DELETE FROM prod_app.users WHERE id = 1";
const secondSql = "DELETE FROM prod_app.audit_log WHERE id = 2";
const firstExecution = executeWithProductionSqlGuard({
connection: connection(),
database: "staging",
sql: firstSql,
source: "Schema diff",
execute: firstExecute,
});
const secondExecution = executeWithProductionSqlGuard({
connection: connection(),
database: "staging",
sql: secondSql,
source: "Data compare",
execute: secondExecute,
});
await Promise.resolve();
const store = useProductionSafetyStore();
expect(store.pending?.sql).toBe(firstSql);
expect(firstExecute).not.toHaveBeenCalled();
expect(secondExecute).not.toHaveBeenCalled();
store.confirm();
await expect(firstExecution).resolves.toBe("first");
expect(firstExecute).toHaveBeenCalledTimes(1);
expect(store.pending?.sql).toBe(secondSql);
store.cancel();
await expect(secondExecution).resolves.toBeUndefined();
expect(secondExecute).not.toHaveBeenCalled();
});
it("executes non-production SQL immediately", async () => {
const execute = vi.fn().mockResolvedValue("done");
await expect(
executeWithProductionSqlGuard({
connection: connection(),
database: "staging",
sql: "UPDATE staging.users SET active = 1 WHERE id = 1",
source: "Data compare",
execute,
}),
).resolves.toBe("done");
expect(useProductionSafetyStore().pending).toBeUndefined();
expect(execute).toHaveBeenCalledTimes(1);
});
});

View File

@ -0,0 +1,117 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { assessProductionSql, isProductionMutation, productionContextForDatabase } from "../productionSafety";
import type { ConnectionConfig, DatabaseType } from "@/types/database";
interface ProductionSafetyCorpusCase {
name: string;
dialect: DatabaseType;
productionDatabases: string[];
activeDatabase: string;
sql: string;
active: boolean;
isMutation: boolean;
databases: string[];
}
const productionSafetyCorpus = JSON.parse(readFileSync(new URL("../../../../../../tests/fixtures/production-safety-corpus.json", import.meta.url), "utf8")) as ProductionSafetyCorpusCase[];
function connection(overrides: Partial<ConnectionConfig> = {}): ConnectionConfig {
return {
id: "conn-1",
name: "Operations",
db_type: "mysql",
host: "db.internal",
port: 3306,
username: "readonly",
password: "",
production_databases: ["prod_app"],
...overrides,
};
}
describe("production SQL safety", () => {
it("marks an explicitly production connection regardless of database", () => {
expect(productionContextForDatabase(connection({ is_production: true }), "scratch").active).toBe(true);
});
it("marks only configured production databases for multi-database connections", () => {
expect(productionContextForDatabase(connection(), "PROD_APP").active).toBe(true);
expect(productionContextForDatabase(connection(), "staging").active).toBe(false);
});
it("detects a write after a USE production switch despite comments", () => {
const assessment = assessProductionSql("-- install\nUSE `prod_app`; /* migration */ DELETE FROM users", connection(), "staging");
expect(assessment).toMatchObject({ active: true, isMutation: true, databases: ["prod_app"] });
});
it("detects qualified production targets in multi-statement SQL", () => {
const assessment = assessProductionSql("SELECT ';' AS literal; DELETE FROM `prod_app`.`orders`; UPDATE staging.users SET active = 1", connection(), "staging");
expect(assessment).toMatchObject({ active: true, isMutation: true, databases: ["prod_app"] });
});
it("detects production database DDL without a selected production database", () => {
expect(assessProductionSql("DROP DATABASE IF EXISTS prod_app", connection(), "staging")).toMatchObject({ active: true, isMutation: true, databases: ["prod_app"] });
});
it("detects production writes hidden behind parser-sensitive SQL forms", () => {
for (const sql of ["EXPLAIN ANALYZE DELETE FROM prod_app.users WHERE id = 1", "/*! DELETE FROM prod_app.users WHERE id = 1 */", "COPY prod_app.users FROM '/tmp/users.csv'", "SELECT * INTO prod_app.backup_users FROM users", "SELECT * FROM prod_app.users INTO OUTFILE '/tmp/users.csv'"]) {
expect(assessProductionSql(sql, connection(), "staging")).toMatchObject({ active: true, isMutation: true, databases: ["prod_app"] });
}
});
it("matches the shared SQL target safety corpus", () => {
for (const corpusCase of productionSafetyCorpus) {
const assessment = assessProductionSql(
corpusCase.sql,
connection({
db_type: corpusCase.dialect,
production_databases: corpusCase.productionDatabases,
}),
corpusCase.activeDatabase,
);
expect(
{
active: assessment.active,
isMutation: assessment.isMutation,
databases: assessment.databases,
},
corpusCase.name,
).toEqual({
active: corpusCase.active,
isMutation: corpusCase.isMutation,
databases: corpusCase.databases,
});
}
});
it("detects qualified procedure calls and privilege targets", () => {
for (const sql of ["CALL prod_app.purge_users()", "CALL `prod_app`.`purge_users`()", "GRANT ALL ON prod_app.* TO 'u'@'%'", "GRANT EXECUTE ON PROCEDURE prod_app.purge_users TO 'u'@'%'"]) {
expect(assessProductionSql(sql, connection(), "staging")).toMatchObject({ active: true, isMutation: true, databases: ["prod_app"] });
}
});
it("allows resolved non-production procedure and privilege targets", () => {
expect(assessProductionSql("CALL staging.purge_users()", connection(), "staging")).toMatchObject({ active: false, isMutation: true });
expect(assessProductionSql("GRANT ALL ON staging.* TO 'u'@'%'", connection(), "staging")).toMatchObject({ active: false, isMutation: true });
});
it("conservatively confirms ambiguous production targets", () => {
for (const sql of ["CALL purge_users()", "GRANT PROCESS ON *.* TO 'u'@'%'", "GRANT ALL ON users TO 'u'@'%'", "CREATE USER 'u'@'%'"]) {
expect(assessProductionSql(sql, connection(), "staging")).toMatchObject({ active: true, isMutation: true, databases: ["prod_app"] });
}
});
it("does not treat read-only qualified references as write targets", () => {
expect(assessProductionSql("SELECT * FROM prod_app.orders; DELETE FROM staging.users WHERE id = 1", connection(), "staging")).toMatchObject({ active: false, isMutation: true });
});
it("treats unrecognized SQL as a production mutation until proven read-only", () => {
expect(isProductionMutation("MAINTAIN UNKNOWN THING")).toBe(true);
expect(assessProductionSql("MAINTAIN UNKNOWN THING", connection(), "prod_app")).toMatchObject({ active: true, isMutation: true });
});
it("does not require a production confirmation for reads", () => {
expect(assessProductionSql("SELECT * FROM prod_app.orders", connection(), "staging")).toMatchObject({ active: false, isMutation: false });
});
});

View File

@ -0,0 +1,28 @@
import { useProductionSafetyStore } from "@/stores/productionSafetyStore";
import { assessProductionSql } from "@/lib/database/productionSafety";
import type { ConnectionConfig } from "@/types/database";
export interface ProductionSqlExecutionGuardOptions<T> {
connection?: ConnectionConfig;
database?: string | null;
sql: string;
source?: string;
execute: () => Promise<T>;
}
export async function executeWithProductionSqlGuard<T>(options: ProductionSqlExecutionGuardOptions<T>): Promise<T | undefined> {
const assessment = assessProductionSql(options.sql, options.connection, options.database);
if (assessment.active && assessment.isMutation) {
// Centralize production write confirmation so secondary tool surfaces cannot
// bypass the same explicit review step used by the SQL editor.
const confirmed = await useProductionSafetyStore().requestConfirmation({
sql: options.sql,
connectionName: options.connection?.name,
database: options.database ?? undefined,
productionDatabases: assessment.databases,
source: options.source,
});
if (!confirmed) return undefined;
}
return options.execute();
}

View File

@ -0,0 +1,347 @@
import type { ConnectionConfig, DatabaseType } from "@/types/database";
import { classifySqlRisk, isSqlRiskMutation } from "@/lib/sql/sqlRisk";
export type ProductionContextReason = "connection" | "database" | "sql_target";
export interface ProductionContext {
active: boolean;
reason?: ProductionContextReason;
databases: string[];
}
export interface ProductionSqlAssessment extends ProductionContext {
isMutation: boolean;
}
const IDENTIFIER_PATTERN = String.raw`[A-Za-z0-9_@$#-]*[A-Za-z_@$#][A-Za-z0-9_@$#-]*`;
const TARGET_NAME_PATTERN = String.raw`${IDENTIFIER_PATTERN}(?:\s*\.\s*(?:\*|${IDENTIFIER_PATTERN})){0,2}`;
const QUALIFIED_NAME_PATTERN = String.raw`${IDENTIFIER_PATTERN}\s*\.\s*(?:\*|${IDENTIFIER_PATTERN})(?:\s*\.\s*(?:\*|${IDENTIFIER_PATTERN}))?`;
const USE_RE = new RegExp(String.raw`^\s*USE\s+(${IDENTIFIER_PATTERN})`, "i");
const DML_TARGET_RE = new RegExp(String.raw`\b(?:FROM|JOIN|UPDATE|INTO|REFERENCES)\s+(${TARGET_NAME_PATTERN})`, "gi");
const DDL_OBJECT_TARGET_RE = new RegExp(String.raw`\b(?:CREATE|ALTER|DROP)\s+(?:OR\s+REPLACE\s+)?(?:TABLE|VIEW|MATERIALIZED\s+VIEW|INDEX|SEQUENCE|FUNCTION|PROCEDURE|ROUTINE|TRIGGER|EVENT|TYPE|SYNONYM)\s+(?:IF\s+(?:NOT\s+)?EXISTS\s+)?(?:ONLY\s+)?(${TARGET_NAME_PATTERN})`, "gi");
const INDEX_ON_TARGET_RE = new RegExp(String.raw`\b(?:CREATE|ALTER|DROP)\s+(?:UNIQUE\s+)?INDEX\b[\s\S]*?\bON\s+(${TARGET_NAME_PATTERN})`, "gi");
const DATABASE_TARGET_RE = new RegExp(String.raw`\b(?:CREATE|ALTER|DROP)\s+(DATABASE|SCHEMA|CATALOG)\s+(?:IF\s+(?:NOT\s+)?EXISTS\s+)?(${IDENTIFIER_PATTERN})`, "gi");
const COPY_TARGET_RE = new RegExp(String.raw`^\s*COPY\s+(${TARGET_NAME_PATTERN})\s+FROM\b`, "i");
const TRUNCATE_TARGET_RE = new RegExp(String.raw`\bTRUNCATE\s+(?:TABLE\s+)?(${TARGET_NAME_PATTERN})`, "gi");
const RENAME_TABLE_TARGET_RE = new RegExp(String.raw`\bRENAME\s+TABLE\s+(${TARGET_NAME_PATTERN})\s+TO\s+(${TARGET_NAME_PATTERN})`, "gi");
const MAINTENANCE_TABLE_TARGET_RE = new RegExp(String.raw`\b(?:ANALYZE|OPTIMIZE|REPAIR|CHECK)\s+(?:NO_WRITE_TO_BINLOG\s+|LOCAL\s+)?TABLE\s+(${TARGET_NAME_PATTERN})`, "gi");
const COMMENT_TARGET_RE = new RegExp(String.raw`\bCOMMENT\s+ON\s+(?:TABLE|VIEW|COLUMN|INDEX|SEQUENCE|FUNCTION|PROCEDURE|TYPE)\s+(${TARGET_NAME_PATTERN})`, "gi");
const ROUTINE_CALL_TARGET_RE = new RegExp(String.raw`\b(?:CALL|EXEC|EXECUTE)\s+(${QUALIFIED_NAME_PATTERN})`, "gi");
const PRIVILEGE_TARGET_RE = new RegExp(String.raw`\b(?:GRANT|REVOKE|DENY)\b[\s\S]*?\bON\s+(?:(?:TABLE|SEQUENCE|FUNCTION|PROCEDURE|ROUTINE|OBJECT)\s+|OBJECT\s*::\s*)?(${QUALIFIED_NAME_PATTERN})`, "gi");
const PRIVILEGE_DATABASE_TARGET_RE = new RegExp(String.raw`\b(?:GRANT|REVOKE|DENY)\b[\s\S]*?\bON\s+(?:DATABASE|CATALOG)(?:::|\s+)\s*(${IDENTIFIER_PATTERN})`, "gi");
const GLOBAL_PRIVILEGE_TARGET_RE = /\b(?:GRANT|REVOKE|DENY)\b[\s\S]*?\bON\s+\*\s*\.\s*\*/i;
const GLOBAL_DDL_TARGET_RE = /^\s*(?:CREATE|ALTER|DROP)\s+(?:USER|ROLE|LOGIN|SERVER|TABLESPACE|RESOURCE|PROFILE|ACCOUNT)\b/i;
const MULTI_TARGET_MUTATION_RE = /^\s*(?:DROP\s+(?:TEMPORARY\s+)?TABLE\b[\s\S]*,|RENAME\s+TABLE\b[\s\S]*,)/i;
const THREE_PART_DATABASE_QUALIFIER_TYPES = new Set<DatabaseType>(["sqlserver", "snowflake", "trino", "prestosql", "databricks", "bigquery"]);
const TRANSACTION_KEYWORDS = new Set(["begin", "start", "commit", "rollback", "abort", "savepoint", "release"]);
const SCHEMA_FIRST_QUALIFIER_TYPES = new Set<DatabaseType>([
"postgres",
"redshift",
"gaussdb",
"kwdb",
"opengauss",
"kingbase",
"highgo",
"vastbase",
"yashandb",
"oracle",
"oceanbase-oracle",
"dameng",
"firebird",
"exasol",
"teradata",
"vertica",
"db2",
"informix",
"h2",
"iris",
"xugu",
"oscar",
"gbase",
"saphana",
"sqlserver",
"snowflake",
"trino",
"prestosql",
"databricks",
"bigquery",
]);
interface ReferencedDatabaseAssessment {
databases: string[];
uncertain: boolean;
}
interface SqlTargetSafetyText {
text: string;
quotedIdentifiers: Map<string, string>;
}
/**
* Normalize database identifiers for safety matching while retaining the
* original display value separately. Safety comparisons intentionally ignore
* identifier quoting and case so a quoted MySQL production database cannot
* bypass its marker.
*/
export function normalizeProductionDatabase(value: string | undefined | null): string {
return String(value ?? "")
.trim()
.replace(/^[`"[]|[`"\]]$/g, "")
.toLowerCase();
}
export function productionDatabases(connection: ConnectionConfig | undefined): string[] {
if (!connection?.production_databases?.length) return [];
return [...new Set(connection.production_databases.map(normalizeProductionDatabase).filter(Boolean))];
}
export function productionContextForDatabase(connection: ConnectionConfig | undefined, database: string | undefined | null): ProductionContext {
if (!connection) return { active: false, databases: [] };
if (connection.is_production) return { active: true, reason: "connection", databases: [] };
const normalizedDatabase = normalizeProductionDatabase(database);
const marked = productionDatabases(connection);
if (normalizedDatabase && marked.includes(normalizedDatabase)) {
return { active: true, reason: "database", databases: [String(database)] };
}
return { active: false, databases: [] };
}
export function isProductionMutation(sql: string): boolean {
return isSqlRiskMutation(classifySqlRisk(sql).risk);
}
/**
* Resolves production scope for the SQL about to run. The active database is
* authoritative for ordinary statements; MySQL-style USE and qualified names
* additionally protect production databases referenced from another context.
*/
export function assessProductionSql(sql: string, connection: ConnectionConfig | undefined, activeDatabase: string | undefined | null): ProductionSqlAssessment {
const activeContext = productionContextForDatabase(connection, activeDatabase);
const targetText = sqlTargetSafetyText(sql);
const statements = splitTargetStatements(targetText.text);
const risk = classifySqlRisk(sql, { dialect: connection?.db_type });
const isMutation = isSqlRiskMutation(risk.risk);
if (!isMutation || !connection) return { ...activeContext, isMutation };
if (connection.is_production) return { active: true, reason: "connection", databases: [], isMutation };
if (activeContext.active) return { ...activeContext, isMutation };
const marked = productionDatabases(connection);
if (!marked.length) return { active: false, databases: [], isMutation };
const targets = referencedDatabases(statements, connection.db_type, activeDatabase, targetText.quotedIdentifiers);
const matched = targets.databases.filter((database) => marked.includes(normalizeProductionDatabase(database)));
if (matched.length) return { active: true, reason: "sql_target", databases: matched, isMutation };
if (targets.uncertain) return { active: true, reason: "sql_target", databases: marked, isMutation };
return { active: false, databases: [], isMutation };
}
function referencedDatabases(statements: string[], dbType: DatabaseType, activeDatabase: string | undefined | null, quotedIdentifiers: Map<string, string>): ReferencedDatabaseAssessment {
const databases = new Set<string>();
let uncertain = false;
let useDatabase = "";
const normalizedActiveDatabase = normalizeProductionDatabase(activeDatabase);
for (const statement of statements) {
const statementDatabases = new Set<string>();
const statementAssessment = classifySqlRisk(statement, { dialect: dbType });
const statementIsMutation = isSqlRiskMutation(statementAssessment.risk);
const useMatch = statement.match(USE_RE);
if (useMatch?.[1]) {
useDatabase = normalizeTargetDatabase(useMatch[1], quotedIdentifiers);
continue;
}
if (!statementIsMutation) continue;
const currentDatabase = useDatabase || normalizedActiveDatabase;
collectQualifiedTargetDatabases(statement, dbType, quotedIdentifiers, currentDatabase, statementDatabases, DML_TARGET_RE, DDL_OBJECT_TARGET_RE, INDEX_ON_TARGET_RE, TRUNCATE_TARGET_RE, MAINTENANCE_TABLE_TARGET_RE, COMMENT_TARGET_RE, ROUTINE_CALL_TARGET_RE, PRIVILEGE_TARGET_RE);
collectQualifiedTargetDatabaseGroups(statement, dbType, quotedIdentifiers, currentDatabase, statementDatabases, RENAME_TABLE_TARGET_RE, [1, 2]);
for (const match of statement.matchAll(DATABASE_TARGET_RE)) {
const database = databaseTargetKindMeansDatabase(match[1], dbType) ? normalizeTargetDatabase(match[2], quotedIdentifiers) : "";
if (database) statementDatabases.add(database);
}
for (const match of statement.matchAll(PRIVILEGE_DATABASE_TARGET_RE)) {
const database = normalizeTargetDatabase(match[1], quotedIdentifiers);
if (database) statementDatabases.add(database);
}
const copyTarget = statement.match(COPY_TARGET_RE);
if (copyTarget?.[1]) {
const database = databaseFromQualifiedName(copyTarget[1], dbType, quotedIdentifiers, currentDatabase);
if (database) statementDatabases.add(database);
}
for (const database of statementDatabases) databases.add(database);
// The target regexes intentionally extract one object at a time. Until all
// list forms are parsed, never let a resolved first target disable fallback.
uncertain = uncertain || GLOBAL_PRIVILEGE_TARGET_RE.test(statement) || MULTI_TARGET_MUTATION_RE.test(statement) || isAmbiguousProductionTargetStatement(statement, statementAssessment, statementDatabases.size > 0);
}
return { databases: [...databases], uncertain };
}
function collectQualifiedTargetDatabases(statement: string, dbType: DatabaseType, quotedIdentifiers: Map<string, string>, currentDatabase: string, databases: Set<string>, ...patterns: RegExp[]): void {
for (const pattern of patterns) {
collectQualifiedTargetDatabaseGroups(statement, dbType, quotedIdentifiers, currentDatabase, databases, pattern, [1]);
}
}
function collectQualifiedTargetDatabaseGroups(statement: string, dbType: DatabaseType, quotedIdentifiers: Map<string, string>, currentDatabase: string, databases: Set<string>, pattern: RegExp, captureIndexes: number[]): void {
pattern.lastIndex = 0;
for (const match of statement.matchAll(pattern)) {
for (const captureIndex of captureIndexes) {
const database = databaseFromQualifiedName(match[captureIndex], dbType, quotedIdentifiers, currentDatabase);
if (database) databases.add(database);
}
}
}
function databaseFromQualifiedName(qualifiedName: string | undefined, dbType: DatabaseType, quotedIdentifiers: Map<string, string>, currentDatabase: string): string {
const parts = String(qualifiedName ?? "")
.split(".")
.map((part) => normalizeTargetDatabase(part, quotedIdentifiers))
.filter(Boolean);
if (parts.length < 2) return currentDatabase;
if (qualifiedFirstPartIsDatabase(dbType, parts.length)) return parts[0] ?? "";
return currentDatabase;
}
function normalizeTargetDatabase(value: string | undefined, quotedIdentifiers: Map<string, string>): string {
const normalized = normalizeProductionDatabase(value);
const quoted = quotedIdentifiers.get(normalized);
return quoted === undefined ? normalized : normalizeProductionDatabase(quoted);
}
function qualifiedFirstPartIsDatabase(dbType: DatabaseType, partCount: number): boolean {
if (partCount >= 3 && THREE_PART_DATABASE_QUALIFIER_TYPES.has(dbType)) return true;
if (SCHEMA_FIRST_QUALIFIER_TYPES.has(dbType)) return false;
return partCount >= 2;
}
function databaseTargetKindMeansDatabase(kind: string | undefined, dbType: DatabaseType): boolean {
const normalizedKind = String(kind ?? "").toLowerCase();
if (normalizedKind === "database" || normalizedKind === "catalog") return true;
if (normalizedKind !== "schema") return false;
return !SCHEMA_FIRST_QUALIFIER_TYPES.has(dbType);
}
function isAmbiguousProductionTargetStatement(statement: string, assessment: ReturnType<typeof classifySqlRisk>, hasResolvedTarget: boolean): boolean {
if (!isSqlRiskMutation(assessment.risk)) return false;
if (assessment.risk === "transaction") return false;
const firstKeyword = assessment.firstKeyword;
if (firstKeyword && TRANSACTION_KEYWORDS.has(firstKeyword)) return false;
return GLOBAL_DDL_TARGET_RE.test(statement) || !hasResolvedTarget;
}
function splitTargetStatements(sql: string): string[] {
return sql
.split(";")
.map((statement) => statement.trim())
.filter(Boolean);
}
function sqlTargetSafetyText(sql: string, quotedIdentifiers = new Map<string, string>()): SqlTargetSafetyText {
let output = "";
let index = 0;
while (index < sql.length) {
const char = sql[index] ?? "";
const next = sql[index + 1] ?? "";
if (char === "-" && next === "-") {
index += 2;
while (index < sql.length && sql[index] !== "\n" && sql[index] !== "\r") index += 1;
output += " ";
continue;
}
if (char === "#") {
index += 1;
while (index < sql.length && sql[index] !== "\n" && sql[index] !== "\r") index += 1;
output += " ";
continue;
}
if (char === "/" && next === "*") {
const close = sql.indexOf("*/", index + 2);
if (close < 0) return { text: output, quotedIdentifiers };
const executablePrefixLength = mysqlExecutableCommentPrefixLength(sql, index);
if (executablePrefixLength > 0) {
const bodyStart = skipExecutableCommentVersion(sql, index + executablePrefixLength);
output += ` ${sqlTargetSafetyText(sql.slice(bodyStart, close), quotedIdentifiers).text} `;
} else {
output += " ";
}
index = close + 2;
continue;
}
const dollarQuote = dollarQuoteTagAt(sql, index);
if (dollarQuote) {
const close = sql.indexOf(dollarQuote, index + dollarQuote.length);
index = close < 0 ? sql.length : close + dollarQuote.length;
output += " ";
continue;
}
if (char === "'") {
index = readQuotedEnd(sql, index, "'", "'");
output += " ";
continue;
}
if (char === '"' || char === "`" || char === "[") {
const close = char === "[" ? "]" : char;
const end = readQuotedEnd(sql, index, char, close);
const identifier = unquoteIdentifier(sql.slice(index, end), char, close).replace(/[;]/g, " ");
const token = `__dbxq${quotedIdentifiers.size}__`;
quotedIdentifiers.set(token.toLowerCase(), identifier);
output += ` ${token} `;
index = end;
continue;
}
output += char;
index += 1;
}
return { text: output, quotedIdentifiers };
}
function mysqlExecutableCommentPrefixLength(sql: string, index: number): number {
if (sql[index] !== "/" || sql[index + 1] !== "*") return 0;
if (sql[index + 2] === "!") return 3;
if (sql[index + 2] === "M" && sql[index + 3] === "!") return 4;
return 0;
}
function skipExecutableCommentVersion(sql: string, index: number): number {
let cursor = index;
while (cursor < sql.length && /[0-9\s]/.test(sql[cursor] ?? "")) cursor += 1;
return cursor;
}
function dollarQuoteTagAt(sql: string, index: number): string | undefined {
return sql.slice(index).match(/^\$[A-Za-z_][A-Za-z0-9_]*\$|^\$\$/)?.[0];
}
function readQuotedEnd(sql: string, start: number, open: string, close: string): number {
let index = start + open.length;
while (index < sql.length) {
if (sql[index] === "\\" && (open === "'" || open === '"')) {
index += 2;
continue;
}
if (sql.startsWith(close, index)) {
if (sql.startsWith(close + close, index)) {
index += close.length * 2;
continue;
}
return index + close.length;
}
index += 1;
}
return sql.length;
}
function unquoteIdentifier(value: string, open: string, close: string): string {
if (!value.startsWith(open) || !value.endsWith(close)) return value;
return value.slice(open.length, value.length - close.length).replaceAll(close + close, close);
}

View File

@ -0,0 +1,276 @@
import type { DatabaseType } from "@/types/database";
export type SqlRiskLevel = "read" | "write" | "ddl" | "transaction" | "unknown";
export interface SqlRiskStatementAssessment {
risk: SqlRiskLevel;
firstKeyword?: string;
}
export interface SqlRiskAssessment extends SqlRiskStatementAssessment {
statements: SqlRiskStatementAssessment[];
}
interface SqlRiskOptions {
dialect?: DatabaseType | string;
}
interface SqlRiskToken {
text: string;
normalized: string;
}
const READ_KEYWORDS = new Set(["select", "show", "describe", "desc", "values", "table"]);
const WRITE_KEYWORDS = new Set(["insert", "update", "delete", "merge", "replace", "upsert", "load", "call", "exec", "execute", "flush"]);
const DDL_KEYWORDS = new Set(["create", "alter", "drop", "truncate", "rename", "grant", "revoke", "deny", "comment", "reindex", "vacuum", "optimize"]);
const TRANSACTION_KEYWORDS = new Set(["begin", "start", "commit", "rollback", "abort", "savepoint", "release"]);
const EXPLAIN_OPTION_KEYWORDS = new Set(["explain", "analyze", "analyse", "verbose", "query", "plan", "format", "type", "costs", "buffers", "timing", "summary", "settings", "wal", "generic_plan"]);
const PRIMARY_STATEMENT_KEYWORDS = new Set([...READ_KEYWORDS, ...WRITE_KEYWORDS, ...DDL_KEYWORDS, ...TRANSACTION_KEYWORDS, "with", "copy", "pragma", "use", "set"]);
const SAFE_READ_PRAGMA_NAMES = new Set(["table_info", "table_xinfo", "index_list", "index_info", "foreign_key_list", "database_list", "compile_options", "data_version"]);
const RISK_ORDER: Record<SqlRiskLevel, number> = {
read: 0,
write: 1,
ddl: 2,
transaction: 3,
unknown: 4,
};
export function splitSqlStatementsForSafety(sql: string): string[] {
return sqlSafetyText(sql)
.split(";")
.map((statement) => statement.trim())
.filter(Boolean);
}
export function classifySqlRisk(sql: string, options: SqlRiskOptions = {}): SqlRiskAssessment {
const statements = splitSqlStatementsForSafety(sql).map((statement) => classifySqlStatementRisk(statement, options));
if (!statements.length) return { risk: "unknown", statements: [] };
const highest = statements.reduce<SqlRiskStatementAssessment>((current, statement) => (RISK_ORDER[statement.risk] > RISK_ORDER[current.risk] ? statement : current), { risk: "read" });
return { ...highest, statements };
}
export function classifySqlStatementRisk(sql: string, _options: SqlRiskOptions = {}): SqlRiskStatementAssessment {
return classifyTokens(tokenizeSqlForRisk(sql));
}
export function isSqlRiskMutation(risk: SqlRiskLevel): boolean {
return risk !== "read";
}
export function sqlSafetyText(sql: string): string {
let output = "";
let index = 0;
while (index < sql.length) {
const char = sql[index] ?? "";
const next = sql[index + 1] ?? "";
if (char === "-" && next === "-") {
index += 2;
while (index < sql.length && sql[index] !== "\n" && sql[index] !== "\r") index += 1;
output += " ";
continue;
}
if (char === "#") {
index += 1;
while (index < sql.length && sql[index] !== "\n" && sql[index] !== "\r") index += 1;
output += " ";
continue;
}
if (char === "/" && next === "*") {
const close = sql.indexOf("*/", index + 2);
if (close < 0) return output;
const executablePrefixLength = mysqlExecutableCommentPrefixLength(sql, index);
if (executablePrefixLength > 0) {
const bodyStart = skipExecutableCommentVersion(sql, index + executablePrefixLength);
output += ` ${sqlSafetyText(sql.slice(bodyStart, close))} `;
} else {
output += " ";
}
index = close + 2;
continue;
}
const dollarQuote = dollarQuoteTagAt(sql, index);
if (dollarQuote) {
const close = sql.indexOf(dollarQuote, index + dollarQuote.length);
index = close < 0 ? sql.length : close + dollarQuote.length;
output += " ";
continue;
}
if (char === "'") {
index = readQuotedEnd(sql, index, "'", "'");
output += " ";
continue;
}
if (char === '"' || char === "`" || char === "[") {
const close = char === "[" ? "]" : char;
const end = readQuotedEnd(sql, index, char, close);
output += ` ${unquoteIdentifier(sql.slice(index, end), char, close).replace(/[;]/g, " ")} `;
index = end;
continue;
}
output += char;
index += 1;
}
return output;
}
function tokenizeSqlForRisk(sql: string): SqlRiskToken[] {
const tokens: SqlRiskToken[] = [];
const re = /[A-Za-z_@$#][A-Za-z0-9_@$#-]*|[0-9]+|[(),.;*]|\S/g;
for (const match of sql.matchAll(re)) {
const text = match[0];
tokens.push({ text, normalized: /^[A-Za-z_@$#]/.test(text) ? text.toLowerCase() : text });
}
return tokens;
}
function classifyTokens(tokens: SqlRiskToken[]): SqlRiskStatementAssessment {
const useful = trimWrappingParentheses(tokens);
const firstKeyword = useful.find((token) => /^[a-z_]/i.test(token.text))?.normalized;
if (!firstKeyword) return { risk: "unknown" };
if (READ_KEYWORDS.has(firstKeyword)) {
return { risk: firstKeyword === "select" && hasTopLevelSelectInto(useful) ? "write" : "read", firstKeyword };
}
if (firstKeyword === "with") {
return { risk: highestRiskInTokens(useful) ?? "read", firstKeyword };
}
if (firstKeyword === "explain") {
return classifyExplainTokens(useful);
}
if (firstKeyword === "copy") {
return { risk: classifyCopyTokens(useful), firstKeyword };
}
if (firstKeyword === "pragma") {
return { risk: classifyPragmaTokens(useful), firstKeyword };
}
if (firstKeyword === "use") return { risk: "read", firstKeyword };
if (WRITE_KEYWORDS.has(firstKeyword)) return { risk: "write", firstKeyword };
if (DDL_KEYWORDS.has(firstKeyword)) return { risk: "ddl", firstKeyword };
if (TRANSACTION_KEYWORDS.has(firstKeyword)) return { risk: "transaction", firstKeyword };
// Unknown statements are treated as unsafe until a dialect-aware parser can
// prove they are read-only.
return { risk: "unknown", firstKeyword };
}
function classifyExplainTokens(tokens: SqlRiskToken[]): SqlRiskStatementAssessment {
const analyze = tokens.some((token) => token.normalized === "analyze" || token.normalized === "analyse");
const innerIndex = tokens.findIndex((token, index) => index > 0 && PRIMARY_STATEMENT_KEYWORDS.has(token.normalized) && !EXPLAIN_OPTION_KEYWORDS.has(token.normalized));
if (innerIndex < 0) return { risk: "read", firstKeyword: "explain" };
const inner = classifyTokens(tokens.slice(innerIndex));
if (!analyze) return { risk: inner.risk === "unknown" ? "unknown" : "read", firstKeyword: "explain" };
return { risk: inner.risk, firstKeyword: inner.firstKeyword ?? "explain" };
}
function classifyCopyTokens(tokens: SqlRiskToken[]): SqlRiskLevel {
if (tokens.some((token) => token.normalized === "from")) return "write";
if (tokens.some((token) => token.normalized === "to")) return "read";
return "unknown";
}
function classifyPragmaTokens(tokens: SqlRiskToken[]): SqlRiskLevel {
const name = tokens.find((token, index) => index > 0 && /^[a-z_]/i.test(token.text))?.normalized;
if (name && SAFE_READ_PRAGMA_NAMES.has(name) && !tokens.some((token) => token.text === "=")) return "read";
return "write";
}
function highestRiskInTokens(tokens: SqlRiskToken[]): SqlRiskLevel | undefined {
let result: SqlRiskLevel | undefined;
for (const token of tokens) {
const risk = WRITE_KEYWORDS.has(token.normalized) ? "write" : DDL_KEYWORDS.has(token.normalized) ? "ddl" : TRANSACTION_KEYWORDS.has(token.normalized) ? "transaction" : undefined;
if (risk && (!result || RISK_ORDER[risk] > RISK_ORDER[result])) result = risk;
}
return result;
}
function hasTopLevelSelectInto(tokens: SqlRiskToken[]): boolean {
let depth = 0;
for (let index = 0; index < tokens.length; index += 1) {
const token = tokens[index];
if (!token) continue;
if (token.text === "(") depth += 1;
if (token.text === ")") depth = Math.max(0, depth - 1);
if (depth !== 0) continue;
if (token.normalized === "into") return true;
}
return false;
}
function trimWrappingParentheses(tokens: SqlRiskToken[]): SqlRiskToken[] {
let start = 0;
let end = tokens.length;
while (tokens[start]?.text === "(" && matchingParenIndex(tokens, start) === end - 1) {
start += 1;
end -= 1;
}
return tokens.slice(start, end);
}
function matchingParenIndex(tokens: readonly SqlRiskToken[], openIndex: number): number {
let depth = 0;
for (let index = openIndex; index < tokens.length; index += 1) {
if (tokens[index]?.text === "(") depth += 1;
if (tokens[index]?.text === ")") {
depth -= 1;
if (depth === 0) return index;
}
}
return -1;
}
function mysqlExecutableCommentPrefixLength(sql: string, index: number): number {
if (sql[index] !== "/" || sql[index + 1] !== "*") return 0;
if (sql[index + 2] === "!") return 3;
if (sql[index + 2] === "M" && sql[index + 3] === "!") return 4;
return 0;
}
function skipExecutableCommentVersion(sql: string, index: number): number {
let cursor = index;
while (cursor < sql.length && /[0-9\s]/.test(sql[cursor] ?? "")) cursor += 1;
return cursor;
}
function dollarQuoteTagAt(sql: string, index: number): string | undefined {
const match = sql.slice(index).match(/^\$[A-Za-z_][A-Za-z0-9_]*\$|^\$\$/);
return match?.[0];
}
function readQuotedEnd(sql: string, start: number, open: string, close: string): number {
let index = start + open.length;
while (index < sql.length) {
if (sql[index] === "\\" && (open === "'" || open === '"')) {
index += 2;
continue;
}
if (sql.startsWith(close, index)) {
if (sql.startsWith(close + close, index)) {
index += close.length * 2;
continue;
}
return index + close.length;
}
index += 1;
}
return sql.length;
}
function unquoteIdentifier(value: string, open: string, close: string): string {
if (!value.startsWith(open) || !value.endsWith(close)) return value;
return value.slice(open.length, value.length - close.length).replaceAll(close + close, close);
}

View File

@ -0,0 +1,63 @@
import { ref } from "vue";
import { defineStore } from "pinia";
export interface ProductionConfirmationRequest {
sql: string;
connectionName?: string;
database?: string;
productionDatabases?: string[];
source?: string;
}
interface QueuedConfirmationRequest {
request: ProductionConfirmationRequest;
resolve: (confirmed: boolean) => void;
}
/**
* Coordinates the single production-write confirmation dialog shared by all
* workbench surfaces. The request is intentionally transient and is never
* persisted, so every production write requires a fresh user decision.
*/
export const useProductionSafetyStore = defineStore("productionSafety", () => {
const pending = ref<ProductionConfirmationRequest>();
const queue: QueuedConfirmationRequest[] = [];
let resolvePending: ((confirmed: boolean) => void) | undefined;
function requestConfirmation(request: ProductionConfirmationRequest): Promise<boolean> {
return new Promise<boolean>((resolve) => {
if (pending.value) {
// Keep concurrent production writes visible instead of silently denying
// the later operation while the user is reviewing the current SQL.
queue.push({ request, resolve });
return;
}
beginRequest(request, resolve);
});
}
function beginRequest(request: ProductionConfirmationRequest, resolve: (confirmed: boolean) => void) {
pending.value = request;
resolvePending = resolve;
}
function settle(confirmed: boolean) {
const resolve = resolvePending;
resolvePending = undefined;
pending.value = undefined;
resolve?.(confirmed);
const next = queue.shift();
if (next) beginRequest(next.request, next.resolve);
}
function confirm() {
settle(true);
}
function cancel() {
settle(false);
}
return { pending, requestConfirmation, confirm, cancel };
});

View File

@ -157,6 +157,10 @@ export interface ConnectionConfig {
external_config?: unknown;
one_time?: boolean;
read_only?: boolean;
/** Explicit production marker for every database reachable through this connection. */
is_production?: boolean;
/** Database-level production markers for multi-database connections. */
production_databases?: string[];
}
export type TransportLayerConfig = ({ type: "ssh" } & SshTunnelConfig) | ({ type: "proxy" } & ProxyTunnelConfig) | ({ type: "http_tunnel" } & HttpTunnelConfig);

View File

@ -620,6 +620,8 @@ mod tests {
jdbc_driver_paths: Vec::new(),
one_time: false,
read_only: false,
is_production: false,
production_databases: vec![],
}
}

View File

@ -456,6 +456,12 @@ async fn execute_execute_query(
// Classify SQL risk using sqlparser AST
let db_type_str = format!("{:?}", db_type).to_lowercase();
let risk = crate::sql_risk::classify_sql_risk(sql, &db_type_str)?;
let connection_config = state.configs.read().await.get(connection_id).cloned();
if let Some(config) = connection_config {
if risk != SqlRisk::ReadOnly && crate::production_safety::targets_production_database(&config, database, sql) {
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 risk == SqlRisk::Transaction {
return Err("Blocked: transaction control statements are not available to the AI agent.".to_string());

View File

@ -1058,6 +1058,8 @@ mod tests {
jdbc_driver_paths: Vec::new(),
one_time: false,
read_only: false,
is_production: false,
production_databases: vec![],
}
}
@ -1116,6 +1118,8 @@ mod tests {
jdbc_driver_paths: Vec::new(),
one_time: false,
read_only: false,
is_production: false,
production_databases: vec![],
}
}
@ -1220,6 +1224,8 @@ mod tests {
jdbc_driver_paths: Vec::new(),
one_time: false,
read_only: false,
is_production: false,
production_databases: vec![],
};
scrub_connection_secrets(&mut config);
assert!(config.password.is_empty());

View File

@ -3144,6 +3144,8 @@ mod tests {
jdbc_driver_paths: Vec::new(),
one_time: false,
read_only: false,
is_production: false,
production_databases: vec![],
}
}

View File

@ -763,6 +763,8 @@ mod tests {
jdbc_driver_paths: Vec::new(),
one_time: false,
read_only: false,
is_production: false,
production_databases: vec![],
}
}

View File

@ -3108,6 +3108,8 @@ mod tests {
jdbc_driver_paths: Vec::new(),
one_time: false,
read_only: false,
is_production: false,
production_databases: vec![],
};
assert_eq!(redis_database_index(&config), 4);

View File

@ -36,6 +36,7 @@ pub mod object_source_sql;
pub mod path_utils;
pub mod plugins;
pub mod process;
pub mod production_safety;
pub mod query;
pub mod query_cancel;
pub mod query_execution_sql;

View File

@ -90,6 +90,12 @@ pub struct ConnectionConfig {
pub one_time: bool,
#[serde(default, skip_serializing_if = "is_false")]
pub read_only: bool,
/// Explicitly marks every database reachable through this connection as production.
#[serde(default, skip_serializing_if = "is_false")]
pub is_production: bool,
/// Database-level production markers for multi-database connections.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub production_databases: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
@ -457,6 +463,10 @@ struct ConnectionConfigData {
pub one_time: bool,
#[serde(default)]
pub read_only: bool,
#[serde(default)]
pub is_production: bool,
#[serde(default)]
pub production_databases: Vec<String>,
}
impl From<ConnectionConfigData> for ConnectionConfig {
@ -507,6 +517,8 @@ impl From<ConnectionConfigData> for ConnectionConfig {
jdbc_driver_paths: data.jdbc_driver_paths,
one_time: data.one_time,
read_only: data.read_only,
is_production: data.is_production,
production_databases: data.production_databases,
}
}
}
@ -1834,6 +1846,8 @@ mod tests {
jdbc_driver_paths: Vec::new(),
one_time: false,
read_only: false,
is_production: false,
production_databases: vec![],
}
}

View File

@ -144,6 +144,8 @@ mod tests {
jdbc_driver_paths: Vec::new(),
one_time: false,
read_only: false,
is_production: false,
production_databases: vec![],
};
cfg.redis_key_separator = ":".to_string();
cfg

View File

@ -596,6 +596,8 @@ mod tests {
jdbc_driver_paths: Vec::new(),
one_time: false,
read_only,
is_production: false,
production_databases: Vec::new(),
}
}

View File

@ -169,6 +169,8 @@ mod tests {
jdbc_driver_paths: Vec::new(),
one_time: false,
read_only: false,
is_production: false,
production_databases: vec![],
}
}

View File

@ -213,6 +213,8 @@ mod tests {
jdbc_driver_paths: Vec::new(),
one_time: false,
read_only: true,
is_production: false,
production_databases: Vec::new(),
};
cfg.read_only = true;
state.configs.write().await.insert(cfg.id.clone(), cfg);
@ -278,6 +280,8 @@ mod tests {
jdbc_driver_paths: Vec::new(),
one_time: false,
read_only: true,
is_production: false,
production_databases: Vec::new(),
};
state.configs.write().await.insert(cfg.id.clone(), cfg);
let err = nacos_rollback_config_core(

View File

@ -0,0 +1,687 @@
use crate::models::connection::{ConnectionConfig, DatabaseType};
use regex::Regex;
use std::collections::{HashMap, HashSet};
use std::sync::LazyLock;
const IDENTIFIER_PATTERN: &str = r"[A-Za-z0-9_@$#-]*[A-Za-z_@$#][A-Za-z0-9_@$#-]*";
const TARGET_NAME_PATTERN: &str = r"[A-Za-z0-9_@$#-]*[A-Za-z_@$#][A-Za-z0-9_@$#-]*(?:\s*\.\s*(?:\*|[A-Za-z0-9_@$#-]*[A-Za-z_@$#][A-Za-z0-9_@$#-]*)){0,2}";
const QUALIFIED_NAME_PATTERN: &str = r"[A-Za-z0-9_@$#-]*[A-Za-z_@$#][A-Za-z0-9_@$#-]*\s*\.\s*(?:\*|[A-Za-z0-9_@$#-]*[A-Za-z_@$#][A-Za-z0-9_@$#-]*)(?:\s*\.\s*(?:\*|[A-Za-z0-9_@$#-]*[A-Za-z_@$#][A-Za-z0-9_@$#-]*))?";
static DML_TARGET_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(&format!(r"(?is)\b(?:FROM|JOIN|UPDATE|INTO|REFERENCES)\s+({TARGET_NAME_PATTERN})"))
.expect("valid DML target regex")
});
static DDL_OBJECT_TARGET_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(&format!(
r"(?is)\b(?:CREATE|ALTER|DROP)\s+(?:OR\s+REPLACE\s+)?(?:TABLE|VIEW|MATERIALIZED\s+VIEW|INDEX|SEQUENCE|FUNCTION|PROCEDURE|ROUTINE|TRIGGER|EVENT|TYPE|SYNONYM)\s+(?:IF\s+(?:NOT\s+)?EXISTS\s+)?(?:ONLY\s+)?({TARGET_NAME_PATTERN})"
))
.expect("valid DDL object target regex")
});
static INDEX_ON_TARGET_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(&format!(r"(?is)\b(?:CREATE|ALTER|DROP)\s+(?:UNIQUE\s+)?INDEX\b.*?\bON\s+({TARGET_NAME_PATTERN})"))
.expect("valid index target regex")
});
static DATABASE_TARGET_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(&format!(
r"(?is)\b(?:CREATE|ALTER|DROP)\s+(DATABASE|SCHEMA|CATALOG)\s+(?:IF\s+(?:NOT\s+)?EXISTS\s+)?({IDENTIFIER_PATTERN})"
))
.expect("valid database target regex")
});
static USE_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(&format!(r"(?is)^\s*USE\s+({IDENTIFIER_PATTERN})")).expect("valid USE regex"));
static COPY_TARGET_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(&format!(r"(?is)^\s*COPY\s+({TARGET_NAME_PATTERN})\s+FROM\b")).expect("valid COPY target regex")
});
static TRUNCATE_TARGET_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(&format!(r"(?is)\bTRUNCATE\s+(?:TABLE\s+)?({TARGET_NAME_PATTERN})"))
.expect("valid truncate target regex")
});
static RENAME_TABLE_TARGET_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(&format!(r"(?is)\bRENAME\s+TABLE\s+({TARGET_NAME_PATTERN})\s+TO\s+({TARGET_NAME_PATTERN})"))
.expect("valid rename table target regex")
});
static MAINTENANCE_TABLE_TARGET_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(&format!(
r"(?is)\b(?:ANALYZE|OPTIMIZE|REPAIR|CHECK)\s+(?:NO_WRITE_TO_BINLOG\s+|LOCAL\s+)?TABLE\s+({TARGET_NAME_PATTERN})"
))
.expect("valid maintenance table target regex")
});
static COMMENT_TARGET_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(&format!(
r"(?is)\bCOMMENT\s+ON\s+(?:TABLE|VIEW|COLUMN|INDEX|SEQUENCE|FUNCTION|PROCEDURE|TYPE)\s+({TARGET_NAME_PATTERN})"
))
.expect("valid comment target regex")
});
static ROUTINE_CALL_TARGET_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(&format!(r"(?is)\b(?:CALL|EXEC|EXECUTE)\s+({QUALIFIED_NAME_PATTERN})"))
.expect("valid routine call target regex")
});
static PRIVILEGE_TARGET_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(&format!(
r"(?is)\b(?:GRANT|REVOKE|DENY)\b.*?\bON\s+(?:(?:TABLE|SEQUENCE|FUNCTION|PROCEDURE|ROUTINE|OBJECT)\s+|OBJECT\s*::\s*)?({QUALIFIED_NAME_PATTERN})"
))
.expect("valid privilege target regex")
});
static PRIVILEGE_DATABASE_TARGET_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(&format!(
r"(?is)\b(?:GRANT|REVOKE|DENY)\b.*?\bON\s+(?:DATABASE|CATALOG)(?:::|\s+)\s*({IDENTIFIER_PATTERN})"
))
.expect("valid privilege database target regex")
});
static GLOBAL_PRIVILEGE_TARGET_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?is)\b(?:GRANT|REVOKE|DENY)\b.*?\bON\s+\*\s*\.\s*\*").expect("valid global privilege target regex")
});
static GLOBAL_DDL_TARGET_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?is)^\s*(?:CREATE|ALTER|DROP)\s+(?:USER|ROLE|LOGIN|SERVER|TABLESPACE|RESOURCE|PROFILE|ACCOUNT)\b")
.expect("valid global DDL target regex")
});
static MULTI_TARGET_MUTATION_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?is)^\s*(?:DROP\s+(?:TEMPORARY\s+)?TABLE\b.*,|RENAME\s+TABLE\b.*,)")
.expect("valid multi-target mutation regex")
});
static FIRST_KEYWORD_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(IDENTIFIER_PATTERN).expect("valid first keyword regex"));
#[derive(Default)]
struct ReferencedDatabaseAssessment {
databases: HashSet<String>,
uncertain: bool,
}
#[derive(Default)]
struct SqlTargetSafetyText {
text: String,
quoted_identifiers: HashMap<String, String>,
}
/// Returns whether the selected database inherits an explicit production marker.
pub fn is_production_database(config: &ConnectionConfig, database: &str) -> bool {
config.is_production
|| (!database.trim().is_empty()
&& config
.production_databases
.iter()
.any(|name| normalize_database_name(name) == normalize_database_name(database)))
}
/// Returns whether a non-read SQL statement targets production scope.
///
/// Agent execution already classifies SQL risk with `sql_risk`; this function
/// focuses only on production scope, including qualified cross-database writes
/// such as `DELETE FROM prod_app.users` while the selected database is staging.
pub fn targets_production_database(config: &ConnectionConfig, active_database: &str, sql: &str) -> bool {
if is_production_database(config, active_database) {
return true;
}
let marked: HashSet<String> =
config.production_databases.iter().map(|name| normalize_database_name(name)).collect();
if marked.is_empty() {
return false;
}
let assessment = referenced_databases(sql, &config.db_type, active_database);
assessment.databases.into_iter().any(|database| marked.contains(&database)) || assessment.uncertain
}
fn normalize_database_name(value: &str) -> String {
value.trim().trim_matches(|ch| matches!(ch, '`' | '"' | '[' | ']')).to_ascii_lowercase()
}
fn referenced_databases(sql: &str, db_type: &DatabaseType, active_database: &str) -> ReferencedDatabaseAssessment {
let mut assessment = ReferencedDatabaseAssessment::default();
let cleaned = sql_target_safety_text(sql);
let mut use_database = String::new();
let normalized_active_database = normalize_database_name(active_database);
for statement in cleaned.text.split(';').map(str::trim).filter(|statement| !statement.is_empty()) {
let mut statement_databases = HashSet::new();
let statement_is_mutation = crate::query_execution_sql::is_write_sql(statement);
if let Some(database) = USE_RE
.captures(statement)
.and_then(|capture| capture.get(1))
.map(|value| normalize_target_database_name(value.as_str(), &cleaned.quoted_identifiers))
.filter(|value| !value.is_empty())
{
use_database = database;
continue;
}
if !statement_is_mutation {
continue;
}
let current_database =
if use_database.is_empty() { normalized_active_database.as_str() } else { use_database.as_str() };
collect_qualified_target_databases(
statement,
db_type,
&cleaned.quoted_identifiers,
current_database,
&mut statement_databases,
&[
&DML_TARGET_RE,
&DDL_OBJECT_TARGET_RE,
&INDEX_ON_TARGET_RE,
&TRUNCATE_TARGET_RE,
&MAINTENANCE_TABLE_TARGET_RE,
&COMMENT_TARGET_RE,
&ROUTINE_CALL_TARGET_RE,
&PRIVILEGE_TARGET_RE,
],
);
collect_qualified_target_database_groups(
statement,
db_type,
&cleaned.quoted_identifiers,
current_database,
&mut statement_databases,
&RENAME_TABLE_TARGET_RE,
&[1, 2],
);
for capture in DATABASE_TARGET_RE.captures_iter(statement) {
if let Some(database) = capture
.get(1)
.filter(|kind| database_target_kind_means_database(kind.as_str(), db_type))
.and_then(|_| capture.get(2))
.map(|value| normalize_target_database_name(value.as_str(), &cleaned.quoted_identifiers))
.filter(|value| !value.is_empty())
{
statement_databases.insert(database);
}
}
for capture in PRIVILEGE_DATABASE_TARGET_RE.captures_iter(statement) {
if let Some(database) = capture
.get(1)
.map(|value| normalize_target_database_name(value.as_str(), &cleaned.quoted_identifiers))
.filter(|value| !value.is_empty())
{
statement_databases.insert(database);
}
}
if let Some(database) = COPY_TARGET_RE
.captures(statement)
.and_then(|capture| capture.get(1))
.and_then(|target| {
database_from_qualified_name(target.as_str(), db_type, &cleaned.quoted_identifiers, current_database)
})
.filter(|value| !value.is_empty())
{
statement_databases.insert(database);
}
let has_resolved_target = !statement_databases.is_empty();
assessment.databases.extend(statement_databases);
// The target regexes intentionally extract one object at a time. Until all
// list forms are parsed, never let a resolved first target disable fallback.
assessment.uncertain = assessment.uncertain
|| GLOBAL_PRIVILEGE_TARGET_RE.is_match(statement)
|| MULTI_TARGET_MUTATION_RE.is_match(statement)
|| is_ambiguous_production_target_statement(statement, has_resolved_target);
}
assessment
}
fn collect_qualified_target_databases(
statement: &str,
db_type: &DatabaseType,
quoted_identifiers: &HashMap<String, String>,
current_database: &str,
databases: &mut HashSet<String>,
patterns: &[&LazyLock<Regex>],
) {
for pattern in patterns {
collect_qualified_target_database_groups(
statement,
db_type,
quoted_identifiers,
current_database,
databases,
pattern,
&[1],
);
}
}
fn collect_qualified_target_database_groups(
statement: &str,
db_type: &DatabaseType,
quoted_identifiers: &HashMap<String, String>,
current_database: &str,
databases: &mut HashSet<String>,
pattern: &LazyLock<Regex>,
capture_indexes: &[usize],
) {
for capture in pattern.captures_iter(statement) {
for capture_index in capture_indexes {
if let Some(database) = capture
.get(*capture_index)
.and_then(|target| {
database_from_qualified_name(target.as_str(), db_type, quoted_identifiers, current_database)
})
.filter(|value| !value.is_empty())
{
databases.insert(database);
}
}
}
}
fn database_from_qualified_name(
qualified_name: &str,
db_type: &DatabaseType,
quoted_identifiers: &HashMap<String, String>,
current_database: &str,
) -> Option<String> {
let parts: Vec<String> = qualified_name
.split('.')
.map(|part| normalize_target_database_name(part, quoted_identifiers))
.filter(|part| !part.is_empty())
.collect();
if parts.len() < 2 {
return (!current_database.is_empty()).then(|| current_database.to_string());
}
if qualified_first_part_is_database(db_type, parts.len()) {
return parts.first().cloned();
}
(!current_database.is_empty()).then(|| current_database.to_string())
}
fn normalize_target_database_name(value: &str, quoted_identifiers: &HashMap<String, String>) -> String {
let normalized = normalize_database_name(value);
quoted_identifiers.get(&normalized).map(|quoted| normalize_database_name(quoted)).unwrap_or(normalized)
}
fn qualified_first_part_is_database(db_type: &DatabaseType, part_count: usize) -> bool {
if part_count >= 3
&& matches!(
db_type,
DatabaseType::SqlServer
| DatabaseType::Snowflake
| DatabaseType::Trino
| DatabaseType::PrestoSql
| DatabaseType::Databricks
| DatabaseType::Bigquery
)
{
return true;
}
if schema_first_qualifier_type(db_type) {
return false;
}
part_count >= 2
}
fn database_target_kind_means_database(kind: &str, db_type: &DatabaseType) -> bool {
if kind.eq_ignore_ascii_case("database") || kind.eq_ignore_ascii_case("catalog") {
return true;
}
kind.eq_ignore_ascii_case("schema") && !schema_first_qualifier_type(db_type)
}
fn schema_first_qualifier_type(db_type: &DatabaseType) -> bool {
matches!(
db_type,
DatabaseType::Postgres
| DatabaseType::Redshift
| DatabaseType::Gaussdb
| DatabaseType::Kwdb
| DatabaseType::OpenGauss
| DatabaseType::Kingbase
| DatabaseType::Highgo
| DatabaseType::Vastbase
| DatabaseType::Yashandb
| DatabaseType::Oracle
| DatabaseType::OceanbaseOracle
| DatabaseType::Dameng
| DatabaseType::Firebird
| DatabaseType::Exasol
| DatabaseType::Teradata
| DatabaseType::Vertica
| DatabaseType::Db2
| DatabaseType::Informix
| DatabaseType::H2
| DatabaseType::Iris
| DatabaseType::Xugu
| DatabaseType::Oscar
| DatabaseType::Gbase
| DatabaseType::SapHana
| DatabaseType::SqlServer
| DatabaseType::Snowflake
| DatabaseType::Trino
| DatabaseType::PrestoSql
| DatabaseType::Databricks
| DatabaseType::Bigquery
)
}
fn is_ambiguous_production_target_statement(statement: &str, has_resolved_target: bool) -> bool {
if !crate::query_execution_sql::is_write_sql(statement) {
return false;
}
let Some(first_keyword) = first_keyword(statement) else {
return true;
};
if is_transaction_keyword(&first_keyword) {
return false;
}
GLOBAL_DDL_TARGET_RE.is_match(statement) || !has_resolved_target
}
fn first_keyword(statement: &str) -> Option<String> {
FIRST_KEYWORD_RE.find(statement).map(|value| value.as_str().to_ascii_lowercase())
}
fn is_transaction_keyword(keyword: &str) -> bool {
matches!(keyword, "begin" | "start" | "commit" | "rollback" | "abort" | "savepoint" | "release")
}
fn sql_target_safety_text(sql: &str) -> SqlTargetSafetyText {
let chars: Vec<char> = sql.chars().collect();
let mut result = SqlTargetSafetyText { text: String::with_capacity(sql.len()), quoted_identifiers: HashMap::new() };
append_sql_target_safety_text(&chars, &mut result);
result
}
fn append_sql_target_safety_text(chars: &[char], result: &mut SqlTargetSafetyText) {
let mut index = 0usize;
while index < chars.len() {
let ch = chars[index];
let next = chars.get(index + 1).copied();
if ch == '-' && next == Some('-') {
index += 2;
while index < chars.len() && chars[index] != '\n' && chars[index] != '\r' {
index += 1;
}
result.text.push(' ');
continue;
}
if ch == '#' {
index += 1;
while index < chars.len() && chars[index] != '\n' && chars[index] != '\r' {
index += 1;
}
result.text.push(' ');
continue;
}
if ch == '/' && next == Some('*') {
if let Some((body, close_index)) = mysql_executable_comment_body(&chars, index) {
result.text.push(' ');
let body_chars: Vec<char> = body.chars().collect();
append_sql_target_safety_text(&body_chars, result);
result.text.push(' ');
index = close_index;
} else {
index += 2;
while index + 1 < chars.len() && !(chars[index] == '*' && chars[index + 1] == '/') {
index += 1;
}
index = (index + 2).min(chars.len());
result.text.push(' ');
}
continue;
}
if let Some((tag, tag_len)) = dollar_quote_tag_at(&chars, index) {
index += tag_len;
while index + tag_len <= chars.len() && !chars[index..index + tag_len].iter().collect::<String>().eq(&tag) {
index += 1;
}
index = (index + tag_len).min(chars.len());
result.text.push(' ');
continue;
}
if ch == '\'' {
index = skip_string_literal(&chars, index, '\'', '\'');
result.text.push(' ');
continue;
}
if ch == '"' || ch == '`' || ch == '[' {
let close = if ch == '[' { ']' } else { ch };
index = append_quoted_identifier_token(chars, index, close, result);
continue;
}
result.text.push(ch);
index += 1;
}
}
fn mysql_executable_comment_body(chars: &[char], start: usize) -> Option<(String, usize)> {
if chars.get(start) != Some(&'/') || chars.get(start + 1) != Some(&'*') {
return None;
}
let mut index = start + 2;
match chars.get(index).copied() {
Some('!') => index += 1,
Some('M') if chars.get(index + 1) == Some(&'!') => index += 2,
_ => return None,
}
while matches!(chars.get(index), Some(ch) if ch.is_ascii_digit() || ch.is_whitespace()) {
index += 1;
}
let body_start = index;
while index + 1 < chars.len() {
if chars[index] == '*' && chars[index + 1] == '/' {
return Some((chars[body_start..index].iter().collect(), index + 2));
}
index += 1;
}
Some((chars[body_start..].iter().collect(), chars.len()))
}
fn dollar_quote_tag_at(chars: &[char], start: usize) -> Option<(String, usize)> {
if chars.get(start) != Some(&'$') {
return None;
}
let mut index = start + 1;
if chars.get(index) == Some(&'$') {
return Some(("$$".to_string(), 2));
}
if !matches!(chars.get(index), Some(ch) if ch.is_ascii_alphabetic() || *ch == '_') {
return None;
}
index += 1;
while matches!(chars.get(index), Some(ch) if ch.is_ascii_alphanumeric() || *ch == '_') {
index += 1;
}
if chars.get(index) != Some(&'$') {
return None;
}
let tag: String = chars[start..=index].iter().collect();
Some((tag, index - start + 1))
}
fn skip_string_literal(chars: &[char], start: usize, open: char, close: char) -> usize {
let mut index = start + 1;
while index < chars.len() {
if chars[index] == '\\' && matches!(open, '\'' | '"') {
index += 2;
continue;
}
if chars[index] == close {
if chars.get(index + 1) == Some(&close) {
index += 2;
continue;
}
return index + 1;
}
index += 1;
}
chars.len()
}
fn append_quoted_identifier_token(
chars: &[char],
start: usize,
close: char,
result: &mut SqlTargetSafetyText,
) -> usize {
let mut index = start + 1;
let mut identifier = String::new();
while index < chars.len() {
if chars[index] == close {
if chars.get(index + 1) == Some(&close) {
identifier.push(close);
index += 2;
continue;
}
let token = format!("__dbxq{}__", result.quoted_identifiers.len());
result.quoted_identifiers.insert(token.to_ascii_lowercase(), identifier);
result.text.push(' ');
result.text.push_str(&token);
result.text.push(' ');
return index + 1;
}
identifier.push(if chars[index] == ';' { ' ' } else { chars[index] });
index += 1;
}
let token = format!("__dbxq{}__", result.quoted_identifiers.len());
result.quoted_identifiers.insert(token.to_ascii_lowercase(), identifier);
result.text.push(' ');
result.text.push_str(&token);
result.text.push(' ');
chars.len()
}
#[cfg(test)]
mod tests {
use super::{is_production_database, targets_production_database};
use crate::models::connection::{ConnectionConfig, DatabaseType};
use serde::Deserialize;
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct ProductionSafetyCorpusCase {
name: String,
dialect: DatabaseType,
production_databases: Vec<String>,
active_database: String,
sql: String,
active: bool,
}
fn config() -> ConnectionConfig {
ConnectionConfig {
id: "conn".to_string(),
name: "test".to_string(),
db_type: DatabaseType::Mysql,
driver_profile: None,
driver_label: None,
url_params: None,
agent_java_options: vec![],
host: "localhost".to_string(),
port: 3306,
username: "root".to_string(),
password: String::new(),
database: None,
visible_databases: None,
visible_schemas: None,
attached_databases: vec![],
color: None,
transport_layers: vec![],
connect_timeout_secs: 10,
query_timeout_secs: 30,
idle_timeout_secs: 60,
keepalive_interval_secs: 30,
ssl: false,
ca_cert_path: String::new(),
client_cert_path: String::new(),
client_key_path: String::new(),
sysdba: false,
oracle_connection_type: None,
connection_string: None,
jdbc_driver_class: None,
jdbc_driver_paths: vec![],
redis_connection_mode: None,
redis_sentinel_master: String::new(),
redis_sentinel_nodes: String::new(),
redis_sentinel_username: String::new(),
redis_sentinel_password: String::new(),
redis_sentinel_tls: false,
redis_cluster_nodes: String::new(),
redis_key_separator: ":".to_string(),
redis_scan_page_size: Some(1000),
etcd_endpoints: String::new(),
gbase_server: String::new(),
informix_server: String::new(),
external_config: None,
one_time: false,
read_only: false,
is_production: false,
production_databases: vec!["prod_app".to_string()],
}
}
#[test]
fn matches_marked_database_case_insensitively() {
assert!(is_production_database(&config(), "`PROD_APP`"));
assert!(!is_production_database(&config(), "staging"));
}
#[test]
fn detects_cross_database_production_targets() {
assert!(targets_production_database(&config(), "staging", "DELETE FROM prod_app.users WHERE id = 1"));
assert!(targets_production_database(&config(), "staging", "USE prod_app; DELETE FROM users WHERE id = 1"));
assert!(targets_production_database(&config(), "staging", "COPY prod_app.users FROM '/tmp/users.csv'"));
assert!(targets_production_database(&config(), "staging", "DROP DATABASE IF EXISTS `prod_app`"));
assert!(targets_production_database(&config(), "staging", "CALL prod_app.purge_users()"));
assert!(targets_production_database(&config(), "staging", "CALL `prod_app`.`purge_users`()"));
assert!(targets_production_database(&config(), "staging", "GRANT ALL ON prod_app.* TO 'u'@'%'"));
assert!(targets_production_database(
&config(),
"staging",
"GRANT EXECUTE ON PROCEDURE prod_app.purge_users TO 'u'@'%'"
));
assert!(!targets_production_database(&config(), "staging", "DELETE FROM staging.users WHERE id = 1"));
assert!(!targets_production_database(&config(), "staging", "CALL staging.purge_users()"));
assert!(!targets_production_database(&config(), "staging", "GRANT ALL ON staging.* TO 'u'@'%'"));
assert!(!targets_production_database(
&config(),
"staging",
"DELETE FROM staging.users WHERE note = 'FROM prod_app.users'"
));
assert!(!targets_production_database(
&config(),
"staging",
"SELECT * FROM prod_app.users; DELETE FROM staging.users WHERE id = 1"
));
}
#[test]
fn matches_shared_sql_target_safety_corpus() {
let corpus: Vec<ProductionSafetyCorpusCase> =
serde_json::from_str(include_str!("../../../tests/fixtures/production-safety-corpus.json"))
.expect("production safety corpus is valid JSON");
for corpus_case in corpus {
let mut config = config();
config.db_type = corpus_case.dialect;
config.production_databases = corpus_case.production_databases;
assert_eq!(
targets_production_database(&config, &corpus_case.active_database, &corpus_case.sql),
corpus_case.active,
"{}",
corpus_case.name
);
}
}
#[test]
fn conservatively_blocks_ambiguous_production_targets() {
assert!(targets_production_database(&config(), "staging", "CALL purge_users()"));
assert!(targets_production_database(&config(), "staging", "GRANT PROCESS ON *.* TO 'u'@'%'"));
assert!(targets_production_database(&config(), "staging", "GRANT ALL ON users TO 'u'@'%'"));
assert!(targets_production_database(&config(), "staging", "CREATE USER 'u'@'%'"));
}
#[test]
fn resolves_sqlserver_database_qualifiers_dialect_aware() {
let mut sqlserver = config();
sqlserver.db_type = DatabaseType::SqlServer;
assert!(targets_production_database(&sqlserver, "staging", "DELETE FROM prod_app.dbo.users WHERE id = 1"));
assert!(!targets_production_database(&sqlserver, "staging", "DELETE FROM prod_app.users WHERE id = 1"));
}
}

View File

@ -3049,6 +3049,8 @@ mod tests {
jdbc_driver_paths: Vec::new(),
one_time: false,
read_only: false,
is_production: false,
production_databases: vec![],
}
}
@ -3856,6 +3858,8 @@ mod tests {
jdbc_driver_paths: Vec::new(),
one_time: false,
read_only: false,
is_production: false,
production_databases: vec![],
};
let params = external_driver_query_params(

View File

@ -201,11 +201,47 @@ pub fn is_write_sql(sql: &str) -> bool {
return !is_safe_read_pragma(&upper);
}
if starts_with_keyword(&upper, "SELECT") && select_contains_top_level_into(&upper) {
return true;
}
// A statement is a write if it doesn't start with a read keyword,
// or if it contains embedded dangerous keywords (e.g. CTE-wrapped writes like WITH ... AS (DELETE FROM ...))
!starts_with_read || contains_dangerous_sql_keyword(sql)
}
fn select_contains_top_level_into(upper: &str) -> bool {
let mut token = String::new();
let mut depth = 0usize;
let mut saw_select = false;
for ch in upper.chars().chain(std::iter::once(' ')) {
if ch.is_ascii_alphanumeric() || ch == '_' {
token.push(ch);
continue;
}
if !token.is_empty() {
if depth == 0 {
if token == "SELECT" {
saw_select = true;
} else if saw_select && token == "INTO" {
return true;
}
}
token.clear();
}
if ch == '(' {
depth += 1;
} else if ch == ')' {
depth = depth.saturating_sub(1);
}
}
false
}
/// Check if a PRAGMA statement is a safe read-only form.
/// Allows: PRAGMA table_info(...), PRAGMA index_list(...), etc.
/// Blocks: PRAGMA name = value, PRAGMA name(value), or unknown PRAGMA names.
@ -321,7 +357,13 @@ fn strip_sql_comments(sql: &str) -> String {
}
if ch == '/' && chars.peek() == Some(&'*') {
chars.next();
in_block_comment = true;
if let Some(body) = read_mysql_executable_comment_body(&mut chars) {
output.push(' ');
output.push_str(&strip_sql_comments(&body));
output.push(' ');
} else {
in_block_comment = true;
}
continue;
}
@ -392,7 +434,13 @@ pub fn strip_sql_comments_and_literals(sql: &str) -> String {
}
if ch == '/' && chars.peek() == Some(&'*') {
chars.next();
in_block_comment = true;
if let Some(body) = read_mysql_executable_comment_body(&mut chars) {
output.push(' ');
output.push_str(&strip_sql_comments_and_literals(&body));
output.push(' ');
} else {
in_block_comment = true;
}
continue;
}
if ch == '\'' {
@ -412,6 +460,39 @@ pub fn strip_sql_comments_and_literals(sql: &str) -> String {
output
}
fn read_mysql_executable_comment_body<I>(chars: &mut std::iter::Peekable<I>) -> Option<String>
where
I: Iterator<Item = char>,
{
let marker = chars.peek().copied()?;
if marker == '!' {
chars.next();
} else if marker == 'M' {
chars.next();
if chars.peek() != Some(&'!') {
return None;
}
chars.next();
} else {
return None;
}
let mut body = String::new();
let mut skipping_version = true;
while let Some(ch) = chars.next() {
if ch == '*' && chars.peek() == Some(&'/') {
chars.next();
return Some(body);
}
if skipping_version && (ch.is_ascii_digit() || ch.is_whitespace()) {
continue;
}
skipping_version = false;
body.push(ch);
}
Some(body)
}
#[cfg(test)]
mod tests {
use super::*;
@ -609,6 +690,11 @@ mod tests {
assert!(is_write_sql("CREATE TABLE users (id INT)"));
assert!(is_write_sql("ALTER TABLE users ADD COLUMN age INT"));
assert!(is_write_sql("TRUNCATE TABLE users"));
assert!(is_write_sql("EXPLAIN ANALYZE DELETE FROM users"));
assert!(is_write_sql("SELECT * INTO backup_users FROM users"));
assert!(is_write_sql("SELECT * FROM users INTO OUTFILE '/tmp/users.csv'"));
assert!(is_write_sql("COPY users FROM '/tmp/users.csv'"));
assert!(is_write_sql("/*! DELETE FROM users */"));
assert!(is_write_sql(
"MERGE INTO target USING source ON t.id = s.id WHEN MATCHED THEN UPDATE SET t.name = s.name"
));

View File

@ -2352,6 +2352,8 @@ mod tests {
jdbc_driver_paths: Vec::new(),
one_time: false,
read_only: false,
is_production: false,
production_databases: vec![],
}
}

View File

@ -188,6 +188,8 @@ mod tests {
jdbc_driver_paths: Vec::new(),
one_time: false,
read_only: false,
is_production: false,
production_databases: vec![],
}
}

View File

@ -1,5 +1,5 @@
use serde::{Deserialize, Serialize};
use sqlparser::ast::Statement;
use serde::{Deserialize, Serialize};
use sqlparser::ast::{Query, SetExpr, Statement};
use sqlparser::dialect::{
ClickHouseDialect, DuckDbDialect, GenericDialect, MsSqlDialect, MySqlDialect, PostgreSqlDialect, SQLiteDialect,
};
@ -60,8 +60,20 @@ fn resolve_dialect(dialect: &str) -> Box<dyn sqlparser::dialect::Dialect> {
fn classify_statement(stmt: &Statement) -> SqlRisk {
match stmt {
// Pure reads
Statement::Query(_) => SqlRisk::ReadOnly,
Statement::Explain { .. } => SqlRisk::ReadOnly,
Statement::Query(query) => {
if query_contains_select_into(query) {
SqlRisk::Write
} else {
SqlRisk::ReadOnly
}
}
Statement::Explain { analyze, statement, .. } => {
if *analyze {
classify_statement(statement)
} else {
SqlRisk::ReadOnly
}
}
Statement::ExplainTable { .. } => SqlRisk::ReadOnly,
// Show/Describe variants
@ -101,10 +113,11 @@ fn classify_statement(stmt: &Statement) -> SqlRisk {
SqlRisk::Transaction
}
// Copy (PostgreSQL) 鈥?treat as write
// COPY FROM mutates data; keep COPY conservative because sqlparser does
// not expose enough dialect-specific direction detail here.
Statement::Copy { .. } => SqlRisk::Write,
// Pragma (SQLite/DuckDB) 鈥?conservative: treat as write unless known-safe
// SQLite/DuckDB PRAGMA statements can mutate database/session state.
Statement::Pragma { .. } => SqlRisk::Write,
// Catch-all: conservative write classification
@ -112,6 +125,21 @@ fn classify_statement(stmt: &Statement) -> SqlRisk {
}
}
fn query_contains_select_into(query: &Query) -> bool {
set_expr_contains_select_into(&query.body)
}
fn set_expr_contains_select_into(expr: &SetExpr) -> bool {
match expr {
SetExpr::Select(select) => select.into.is_some(),
SetExpr::Query(query) => query_contains_select_into(query),
SetExpr::SetOperation { left, right, .. } => {
set_expr_contains_select_into(left) || set_expr_contains_select_into(right)
}
_ => false,
}
}
/// Classify SQL risk using sqlparser AST analysis.
///
/// If parsing fails (non-standard SQL, non-SQL databases), falls back to
@ -173,6 +201,13 @@ mod tests {
assert_eq!(classify_sql_risk("INSERT INTO users VALUES (1)", "postgres").unwrap(), SqlRisk::Write);
assert_eq!(classify_sql_risk("UPDATE users SET name = 'x'", "postgres").unwrap(), SqlRisk::Write);
assert_eq!(classify_sql_risk("DELETE FROM users", "postgres").unwrap(), SqlRisk::Write);
assert_eq!(classify_sql_risk("EXPLAIN ANALYZE DELETE FROM users", "postgres").unwrap(), SqlRisk::Write);
assert_eq!(classify_sql_risk("SELECT * INTO backup_users FROM users", "postgres").unwrap(), SqlRisk::Write);
assert_eq!(
classify_sql_risk("SELECT * FROM users INTO OUTFILE '/tmp/users.csv'", "mysql").unwrap(),
SqlRisk::Write
);
assert_eq!(classify_sql_risk("/*! DELETE FROM users */", "mysql").unwrap(), SqlRisk::Write);
}
#[test]

View File

@ -2461,6 +2461,8 @@ mod tests {
jdbc_driver_paths: Vec::new(),
one_time: false,
read_only: false,
is_production: false,
production_databases: vec![],
}
}
@ -2519,6 +2521,8 @@ mod tests {
jdbc_driver_paths: Vec::new(),
one_time: false,
read_only: false,
is_production: false,
production_databases: vec![],
}
}

View File

@ -4680,6 +4680,8 @@ mod tests {
jdbc_driver_paths: Vec::new(),
one_time: false,
read_only: false,
is_production: false,
production_databases: vec![],
}
}

View File

@ -61,6 +61,8 @@ fn live_postgres_config(
jdbc_driver_paths: Vec::new(),
one_time: false,
read_only: false,
is_production: false,
production_databases: vec![],
}
}

View File

@ -55,6 +55,8 @@ fn postgres_test_config(id: &str, database: &str) -> ConnectionConfig {
jdbc_driver_paths: Vec::new(),
one_time: false,
read_only: false,
is_production: false,
production_databases: vec![],
}
}

View File

@ -58,6 +58,8 @@ fn live_sqlserver_config(id: &str, database: &str) -> dbx_core::models::connecti
jdbc_driver_paths: Vec::new(),
one_time: false,
read_only: false,
is_production: false,
production_databases: vec![],
}
}

View File

@ -260,6 +260,13 @@ pub async fn ai_agent_stream(
let parsed_db_type: DatabaseType = serde_json::from_str(&format!("\"{}\"", body.db_type))
.map_err(|_| AppError(format!("Unknown database type: {}", body.db_type)))?;
let production_database = state
.app
.configs
.read()
.await
.get(&body.connection_id)
.is_some_and(|config| dbx_core::production_safety::is_production_database(config, &body.database));
let agent_ctx = AgentLoopContext {
state: state.app.clone(),
@ -268,8 +275,8 @@ pub async fn ai_agent_stream(
db_type: parsed_db_type,
cli_mcp_server_command: None,
sql_permissions: dbx_core::agent_tools::AgentSqlPermissions {
allow_writes: body.allow_write_sql,
allow_dangerous: body.allow_write_sql,
allow_writes: !production_database && body.allow_write_sql,
allow_dangerous: !production_database && body.allow_write_sql,
},
};

View File

@ -306,6 +306,8 @@ mod tests {
jdbc_driver_paths: Vec::new(),
one_time: false,
read_only: false,
is_production: false,
production_databases: vec![],
}
}

View File

@ -8,7 +8,7 @@
"dev": "vite --config apps/desktop/vite.config.ts",
"dev:tauri": "tauri dev",
"dev:web": "vite --config apps/desktop/vite.config.ts --port 5173 --mode web",
"dev:backend": "RUST_LOG=${RUST_LOG:-info} sh -c 'if cargo watch --version >/dev/null 2>&1; then cargo watch -x \"run -p dbx-web\"; else echo \"cargo-watch is not installed; running dbx-web without hot reload. Install with: cargo install cargo-watch\"; cargo run -p dbx-web; fi'",
"dev:backend": "node scripts/dev-backend.mjs",
"build:packages": "pnpm --filter @dbx-app/node-core build && pnpm --filter @dbx-app/cli build && pnpm --filter @dbx-app/mcp-server build",
"test:packages": "pnpm --filter @dbx-app/node-core test && pnpm --filter @dbx-app/cli test && pnpm --filter @dbx-app/mcp-server test",
"pack:packages": "rm -rf /tmp/dbx-pack-check && mkdir -p /tmp/dbx-pack-check && pnpm --filter @dbx-app/node-core pack --pack-destination /tmp/dbx-pack-check && pnpm --filter @dbx-app/cli pack --pack-destination /tmp/dbx-pack-check && pnpm --filter @dbx-app/mcp-server pack --pack-destination /tmp/dbx-pack-check",

View File

@ -43,6 +43,18 @@ test("scoped single update auto-executes only on non-production targets", () =>
assert.equal(classifyAiSqlExecution(sql, conn({ name: "prod-db" })).action, "confirm");
});
test("production target databases require confirmation even from staging", () => {
const decision = classifyAiSqlExecution(
"DELETE FROM prod_app.users WHERE id = 1",
conn({ db_type: "mysql", name: "staging-db", host: "10.0.0.8", database: "staging", production_databases: ["prod_app"] }),
"staging",
);
assert.equal(decision.action, "confirm");
assert.equal(decision.environment, "production");
assert.deepEqual(decision.reasons, ["production_write"]);
});
test("broad or destructive writes do not auto-execute", () => {
assert.equal(classifyAiSqlExecution("UPDATE users SET name = 'a'", conn()).action, "block");
assert.equal(classifyAiSqlExecution("UPDATE users SET name = 'a' WHERE 1=1", conn()).action, "block");

View File

@ -0,0 +1,79 @@
import { readFileSync } from "node:fs";
import { strict as assert } from "node:assert";
import { test } from "vitest";
function readSource(path: string): string {
return readFileSync(path, "utf8");
}
test("secondary write entrypoints use the shared production SQL guard", () => {
const entrypoints = [
{
path: "apps/desktop/src/components/diff/SchemaDiffDialog.vue",
executor: "api.executeScript",
sourceKey: "production.sourceSchemaDiff",
},
{
path: "apps/desktop/src/components/diff/DataCompareDialog.vue",
executor: "api.executeBatch",
sourceKey: "production.sourceDataCompare",
},
{
path: "apps/desktop/src/components/objects/InstallExtensionDialog.vue",
executor: "api.executeQuery",
sourceKey: "production.sourceExtension",
},
{
path: "apps/desktop/src/components/sidebar/TreeItem.vue",
executor: "api.executeQuery",
sourceKey: "production.sourceSidebar",
},
{
path: "apps/desktop/src/App.vue",
executor: "executeObjectSourceSave",
sourceKey: "production.sourceObjectSource",
},
{
path: "apps/desktop/src/components/objects/ObjectSourceDialog.vue",
executor: "executeObjectSourceSave",
sourceKey: "production.sourceObjectSource",
},
{
path: "apps/desktop/src/components/objects/ObjectBrowser.vue",
executor: "executeObjectSourceSave",
sourceKey: "production.sourceObjectSource",
},
{
path: "apps/desktop/src/components/objects/ObjectBrowser.vue",
executor: "api.executeQuery",
sourceKey: "production.sourceObjectBrowser",
},
{
path: "apps/desktop/src/components/generate/DataGenerateDialog.vue",
executor: "api.executeQuery",
sourceKey: "production.sourceDataGenerate",
},
{
path: "apps/desktop/src/components/editor/QueryHistory.vue",
executor: "api.executeScript",
sourceKey: "production.sourceQueryHistory",
},
{
path: "apps/desktop/src/components/admin/DatabaseUserAdmin.vue",
executor: "api.executeMulti",
sourceKey: "production.sourceAdmin",
},
{
path: "apps/desktop/src/components/admin/DamengJobAdmin.vue",
executor: "api.executeMulti",
sourceKey: "production.sourceAdmin",
},
];
for (const entrypoint of entrypoints) {
const source = readSource(entrypoint.path);
assert.match(source, /executeWithProductionSqlGuard/, entrypoint.path);
assert.ok(source.includes(entrypoint.executor), `${entrypoint.path} should still execute SQL through its original backend API`);
assert.ok(source.includes(entrypoint.sourceKey), `${entrypoint.path} should label the confirmation source`);
}
});

View File

@ -15,6 +15,9 @@ import {
mdTable,
notifyReload,
parseMongoAggregateCommand,
assessProductionSql,
isLikelyMongoMutation,
isProductionDatabase,
postBridge,
sqlSafetyFromEnv,
splitSqlStatements,
@ -245,6 +248,12 @@ export function createDbxMcpServer(backend: Backend, options: { isWebMode?: bool
if (scopedConfig.db_type !== "mongodb") {
const safety = evaluateSqlSafety(sql, { ...sqlSafetyFromEnv(), allowMultipleStatements: true });
if (!safety.allowed) return toolError("SQL_BLOCKED", safety.reason ?? "SQL blocked.");
const production = assessProductionSql(sql, scopedConfig, database ?? scope.database ?? scopedConfig.database);
if (production.active && production.isMutation) {
return toolError("PRODUCTION_WRITE_BLOCKED", "MCP cannot execute writes against a production database. Return the SQL for a user to review and run in DBX.");
}
} else if (isProductionDatabase(scopedConfig, database ?? scope.database ?? scopedConfig.database) && isLikelyMongoMutation(sql)) {
return toolError("PRODUCTION_WRITE_BLOCKED", "MCP cannot execute writes against a production database. Return the command for a user to review and run in DBX.");
}
// MongoDB shell commands don't fit the SQL safety evaluator; the backend
// (node-core executeQuery) applies command-aware read/write gating.
@ -284,6 +293,9 @@ export function createDbxMcpServer(backend: Backend, options: { isWebMode?: bool
}
const safety = evaluateRedisCommandSafety(command, sqlSafetyFromEnv());
if (!safety.allowed) return toolError("REDIS_COMMAND_BLOCKED", safety.reason ?? "Redis command blocked.");
if (isProductionDatabase(scopedConfig, String(defaultRedisDb(scopedConfig, scope, db))) && safety.safety !== "allowed") {
return toolError("PRODUCTION_WRITE_BLOCKED", "MCP cannot execute write or dangerous Redis commands against a production database.");
}
try {
const result = await backend.executeRedisCommand(scopedConfig, defaultRedisDb(scopedConfig, scope, db), command, {
skipSafetyCheck: safety.skipSafetyCheck,
@ -477,6 +489,16 @@ export function createDbxMcpServer(backend: Backend, options: { isWebMode?: bool
const safety = evaluateSqlSafety(sql, { ...safetyOptions, allowMultipleStatements: true });
if (!safety.allowed) return toolError("SQL_BLOCKED", safety.reason ?? "SQL blocked.");
}
if (config?.db_type === "mongodb") {
if (isProductionDatabase(config, database ?? scope.database ?? config.database) && isLikelyMongoMutation(sql)) {
return toolError("PRODUCTION_WRITE_BLOCKED", "MCP cannot send writes against a production database to DBX.");
}
} else {
const production = assessProductionSql(sql, config, database ?? scope.database ?? config.database);
if (production.active && production.isMutation) {
return toolError("PRODUCTION_WRITE_BLOCKED", "MCP cannot send writes against a production database to DBX.");
}
}
// MongoDB shell commands bypass the SQL safety evaluator; pass MCP
// safety flags to the desktop executor for command-aware gating.
return bridgeRequest(

View File

@ -17,8 +17,10 @@
"./entrypoint": "./dist/entrypoint.js",
"./format": "./dist/format.js",
"./paths": "./dist/paths.js",
"./production-safety": "./dist/production-safety.js",
"./redis-command": "./dist/redis-command.js",
"./schema-context": "./dist/schema-context.js",
"./sql-risk": "./dist/sql-risk.js",
"./sql-safety": "./dist/sql-safety.js"
},
"scripts": {

View File

@ -31,6 +31,8 @@ export interface ConnectionConfig {
redis_cluster_nodes?: string;
redis_key_separator?: string;
read_only?: boolean;
is_production?: boolean;
production_databases?: string[];
}
export type TransportLayerConfig = ({ type: "ssh" } & SshTunnelConfig) | ({ type: "proxy" } & ProxyTunnelConfig);

View File

@ -6,6 +6,8 @@ export * from "./diagnostics.js";
export * from "./entrypoint.js";
export * from "./format.js";
export * from "./paths.js";
export * from "./production-safety.js";
export * from "./redis-command.js";
export * from "./schema-context.js";
export * from "./sql-risk.js";
export * from "./sql-safety.js";

View File

@ -0,0 +1,314 @@
import type { ConnectionConfig } from "./connections.js";
import { classifySqlRisk, isSqlRiskMutation } from "./sql-risk.js";
export interface ProductionSqlAssessment {
active: boolean;
isMutation: boolean;
databases: string[];
}
const IDENTIFIER_PATTERN = String.raw`[A-Za-z0-9_@$#-]*[A-Za-z_@$#][A-Za-z0-9_@$#-]*`;
const TARGET_NAME_PATTERN = String.raw`${IDENTIFIER_PATTERN}(?:\s*\.\s*(?:\*|${IDENTIFIER_PATTERN})){0,2}`;
const QUALIFIED_NAME_PATTERN = String.raw`${IDENTIFIER_PATTERN}\s*\.\s*(?:\*|${IDENTIFIER_PATTERN})(?:\s*\.\s*(?:\*|${IDENTIFIER_PATTERN}))?`;
const USE_RE = new RegExp(String.raw`^\s*USE\s+(${IDENTIFIER_PATTERN})`, "i");
const DML_TARGET_RE = new RegExp(String.raw`\b(?:FROM|JOIN|UPDATE|INTO|REFERENCES)\s+(${TARGET_NAME_PATTERN})`, "gi");
const DDL_OBJECT_TARGET_RE = new RegExp(String.raw`\b(?:CREATE|ALTER|DROP)\s+(?:OR\s+REPLACE\s+)?(?:TABLE|VIEW|MATERIALIZED\s+VIEW|INDEX|SEQUENCE|FUNCTION|PROCEDURE|ROUTINE|TRIGGER|EVENT|TYPE|SYNONYM)\s+(?:IF\s+(?:NOT\s+)?EXISTS\s+)?(?:ONLY\s+)?(${TARGET_NAME_PATTERN})`, "gi");
const INDEX_ON_TARGET_RE = new RegExp(String.raw`\b(?:CREATE|ALTER|DROP)\s+(?:UNIQUE\s+)?INDEX\b[\s\S]*?\bON\s+(${TARGET_NAME_PATTERN})`, "gi");
const DATABASE_TARGET_RE = new RegExp(String.raw`\b(?:CREATE|ALTER|DROP)\s+(DATABASE|SCHEMA|CATALOG)\s+(?:IF\s+(?:NOT\s+)?EXISTS\s+)?(${IDENTIFIER_PATTERN})`, "gi");
const COPY_TARGET_RE = new RegExp(String.raw`^\s*COPY\s+(${TARGET_NAME_PATTERN})\s+FROM\b`, "i");
const TRUNCATE_TARGET_RE = new RegExp(String.raw`\bTRUNCATE\s+(?:TABLE\s+)?(${TARGET_NAME_PATTERN})`, "gi");
const RENAME_TABLE_TARGET_RE = new RegExp(String.raw`\bRENAME\s+TABLE\s+(${TARGET_NAME_PATTERN})\s+TO\s+(${TARGET_NAME_PATTERN})`, "gi");
const MAINTENANCE_TABLE_TARGET_RE = new RegExp(String.raw`\b(?:ANALYZE|OPTIMIZE|REPAIR|CHECK)\s+(?:NO_WRITE_TO_BINLOG\s+|LOCAL\s+)?TABLE\s+(${TARGET_NAME_PATTERN})`, "gi");
const COMMENT_TARGET_RE = new RegExp(String.raw`\bCOMMENT\s+ON\s+(?:TABLE|VIEW|COLUMN|INDEX|SEQUENCE|FUNCTION|PROCEDURE|TYPE)\s+(${TARGET_NAME_PATTERN})`, "gi");
const ROUTINE_CALL_TARGET_RE = new RegExp(String.raw`\b(?:CALL|EXEC|EXECUTE)\s+(${QUALIFIED_NAME_PATTERN})`, "gi");
const PRIVILEGE_TARGET_RE = new RegExp(String.raw`\b(?:GRANT|REVOKE|DENY)\b[\s\S]*?\bON\s+(?:(?:TABLE|SEQUENCE|FUNCTION|PROCEDURE|ROUTINE|OBJECT)\s+|OBJECT\s*::\s*)?(${QUALIFIED_NAME_PATTERN})`, "gi");
const PRIVILEGE_DATABASE_TARGET_RE = new RegExp(String.raw`\b(?:GRANT|REVOKE|DENY)\b[\s\S]*?\bON\s+(?:DATABASE|CATALOG)(?:::|\s+)\s*(${IDENTIFIER_PATTERN})`, "gi");
const GLOBAL_PRIVILEGE_TARGET_RE = /\b(?:GRANT|REVOKE|DENY)\b[\s\S]*?\bON\s+\*\s*\.\s*\*/i;
const GLOBAL_DDL_TARGET_RE = /^\s*(?:CREATE|ALTER|DROP)\s+(?:USER|ROLE|LOGIN|SERVER|TABLESPACE|RESOURCE|PROFILE|ACCOUNT)\b/i;
const MULTI_TARGET_MUTATION_RE = /^\s*(?:DROP\s+(?:TEMPORARY\s+)?TABLE\b[\s\S]*,|RENAME\s+TABLE\b[\s\S]*,)/i;
const THREE_PART_DATABASE_QUALIFIER_TYPES = new Set(["sqlserver", "snowflake", "trino", "prestosql", "databricks", "bigquery"]);
const TRANSACTION_KEYWORDS = new Set(["begin", "start", "commit", "rollback", "abort", "savepoint", "release"]);
const SCHEMA_FIRST_QUALIFIER_TYPES = new Set([
"postgres",
"redshift",
"gaussdb",
"kwdb",
"opengauss",
"kingbase",
"highgo",
"vastbase",
"yashandb",
"oracle",
"oceanbase-oracle",
"dameng",
"firebird",
"exasol",
"teradata",
"vertica",
"db2",
"informix",
"h2",
"iris",
"xugu",
"oscar",
"gbase",
"saphana",
"sqlserver",
"snowflake",
"trino",
"prestosql",
"databricks",
"bigquery",
]);
interface ReferencedDatabaseAssessment {
databases: string[];
uncertain: boolean;
}
interface SqlTargetSafetyText {
text: string;
quotedIdentifiers: Map<string, string>;
}
/** Normalizes quoted database names before production scope comparison. */
export function normalizeProductionDatabase(value: string | undefined | null): string {
return String(value ?? "")
.trim()
.replace(/^[`"[]|[`"\]]$/g, "")
.toLowerCase();
}
export function isProductionDatabase(config: ConnectionConfig | undefined, database?: string): boolean {
if (!config) return false;
if (config.is_production) return true;
const selected = normalizeProductionDatabase(database);
return !!selected && (config.production_databases ?? []).some((name) => normalizeProductionDatabase(name) === selected);
}
/**
* Finds writes that target a marked production database, including a MySQL
* USE switch or a qualified database.table reference in a statement batch.
*/
export function assessProductionSql(sql: string, config: ConnectionConfig | undefined, activeDatabase?: string): ProductionSqlAssessment {
const targetText = sqlTargetSafetyText(sql);
const statements = splitTargetStatements(targetText.text);
const isMutation = isSqlRiskMutation(classifySqlRisk(sql).risk);
if (!isMutation || !config) return { active: isProductionDatabase(config, activeDatabase), isMutation, databases: [] };
if (config.is_production) return { active: true, isMutation, databases: [] };
if (isProductionDatabase(config, activeDatabase)) return { active: true, isMutation, databases: activeDatabase ? [activeDatabase] : [] };
const marked = new Set((config.production_databases ?? []).map(normalizeProductionDatabase).filter(Boolean));
if (!marked.size) return { active: false, isMutation, databases: [] };
const targets = referencedDatabases(statements, config.db_type, activeDatabase, targetText.quotedIdentifiers);
const databases = targets.databases.filter((database) => marked.has(normalizeProductionDatabase(database)));
return { active: databases.length > 0 || targets.uncertain, isMutation, databases: databases.length > 0 ? databases : targets.uncertain ? [...marked] : [] };
}
function referencedDatabases(statements: string[], dbType: string, activeDatabase: string | undefined, quotedIdentifiers: Map<string, string>): ReferencedDatabaseAssessment {
const databases = new Set<string>();
let uncertain = false;
let useDatabase = "";
const normalizedActiveDatabase = normalizeProductionDatabase(activeDatabase);
for (const statement of statements) {
const statementDatabases = new Set<string>();
const statementAssessment = classifySqlRisk(statement);
const statementIsMutation = isSqlRiskMutation(statementAssessment.risk);
const useMatch = statement.match(USE_RE);
if (useMatch?.[1]) {
useDatabase = normalizeTargetDatabase(useMatch[1], quotedIdentifiers);
continue;
}
if (!statementIsMutation) continue;
const currentDatabase = useDatabase || normalizedActiveDatabase;
collectQualifiedTargetDatabases(statement, dbType, quotedIdentifiers, currentDatabase, statementDatabases, DML_TARGET_RE, DDL_OBJECT_TARGET_RE, INDEX_ON_TARGET_RE, TRUNCATE_TARGET_RE, MAINTENANCE_TABLE_TARGET_RE, COMMENT_TARGET_RE, ROUTINE_CALL_TARGET_RE, PRIVILEGE_TARGET_RE);
collectQualifiedTargetDatabaseGroups(statement, dbType, quotedIdentifiers, currentDatabase, statementDatabases, RENAME_TABLE_TARGET_RE, [1, 2]);
for (const match of statement.matchAll(DATABASE_TARGET_RE)) {
const database = databaseTargetKindMeansDatabase(match[1], dbType) ? normalizeTargetDatabase(match[2], quotedIdentifiers) : "";
if (database) statementDatabases.add(database);
}
for (const match of statement.matchAll(PRIVILEGE_DATABASE_TARGET_RE)) {
const database = normalizeTargetDatabase(match[1], quotedIdentifiers);
if (database) statementDatabases.add(database);
}
const copyTarget = statement.match(COPY_TARGET_RE);
if (copyTarget?.[1]) {
const database = databaseFromQualifiedName(copyTarget[1], dbType, quotedIdentifiers, currentDatabase);
if (database) statementDatabases.add(database);
}
for (const database of statementDatabases) databases.add(database);
// The target regexes intentionally extract one object at a time. Until all
// list forms are parsed, never let a resolved first target disable fallback.
uncertain = uncertain || GLOBAL_PRIVILEGE_TARGET_RE.test(statement) || MULTI_TARGET_MUTATION_RE.test(statement) || isAmbiguousProductionTargetStatement(statement, statementAssessment, statementDatabases.size > 0);
}
return { databases: [...databases], uncertain };
}
function collectQualifiedTargetDatabases(statement: string, dbType: string, quotedIdentifiers: Map<string, string>, currentDatabase: string, databases: Set<string>, ...patterns: RegExp[]): void {
for (const pattern of patterns) {
collectQualifiedTargetDatabaseGroups(statement, dbType, quotedIdentifiers, currentDatabase, databases, pattern, [1]);
}
}
function collectQualifiedTargetDatabaseGroups(statement: string, dbType: string, quotedIdentifiers: Map<string, string>, currentDatabase: string, databases: Set<string>, pattern: RegExp, captureIndexes: number[]): void {
pattern.lastIndex = 0;
for (const match of statement.matchAll(pattern)) {
for (const captureIndex of captureIndexes) {
const database = databaseFromQualifiedName(match[captureIndex], dbType, quotedIdentifiers, currentDatabase);
if (database) databases.add(database);
}
}
}
function databaseFromQualifiedName(qualifiedName: string | undefined, dbType: string, quotedIdentifiers: Map<string, string>, currentDatabase: string): string {
const parts = String(qualifiedName ?? "")
.split(".")
.map((part) => normalizeTargetDatabase(part, quotedIdentifiers))
.filter(Boolean);
if (parts.length < 2) return currentDatabase;
if (qualifiedFirstPartIsDatabase(dbType, parts.length)) return parts[0] ?? "";
return currentDatabase;
}
function normalizeTargetDatabase(value: string | undefined, quotedIdentifiers: Map<string, string>): string {
const normalized = normalizeProductionDatabase(value);
const quoted = quotedIdentifiers.get(normalized);
return quoted === undefined ? normalized : normalizeProductionDatabase(quoted);
}
function qualifiedFirstPartIsDatabase(dbType: string, partCount: number): boolean {
const normalizedType = dbType.toLowerCase();
if (partCount >= 3 && THREE_PART_DATABASE_QUALIFIER_TYPES.has(normalizedType)) return true;
if (SCHEMA_FIRST_QUALIFIER_TYPES.has(normalizedType)) return false;
return partCount >= 2;
}
function databaseTargetKindMeansDatabase(kind: string | undefined, dbType: string): boolean {
const normalizedKind = String(kind ?? "").toLowerCase();
if (normalizedKind === "database" || normalizedKind === "catalog") return true;
if (normalizedKind !== "schema") return false;
return !SCHEMA_FIRST_QUALIFIER_TYPES.has(dbType.toLowerCase());
}
function isAmbiguousProductionTargetStatement(statement: string, assessment: ReturnType<typeof classifySqlRisk>, hasResolvedTarget: boolean): boolean {
if (!isSqlRiskMutation(assessment.risk)) return false;
if (assessment.risk === "transaction") return false;
const firstKeyword = assessment.firstKeyword;
if (firstKeyword && TRANSACTION_KEYWORDS.has(firstKeyword)) return false;
return GLOBAL_DDL_TARGET_RE.test(statement) || !hasResolvedTarget;
}
function splitTargetStatements(sql: string): string[] {
return sql
.split(";")
.map((statement) => statement.trim())
.filter(Boolean);
}
function sqlTargetSafetyText(sql: string, quotedIdentifiers = new Map<string, string>()): SqlTargetSafetyText {
let output = "";
let index = 0;
while (index < sql.length) {
const char = sql[index] ?? "";
const next = sql[index + 1] ?? "";
if (char === "-" && next === "-") {
index += 2;
while (index < sql.length && sql[index] !== "\n" && sql[index] !== "\r") index += 1;
output += " ";
continue;
}
if (char === "#") {
index += 1;
while (index < sql.length && sql[index] !== "\n" && sql[index] !== "\r") index += 1;
output += " ";
continue;
}
if (char === "/" && next === "*") {
const close = sql.indexOf("*/", index + 2);
if (close < 0) return { text: output, quotedIdentifiers };
const executablePrefixLength = mysqlExecutableCommentPrefixLength(sql, index);
if (executablePrefixLength > 0) {
const bodyStart = skipExecutableCommentVersion(sql, index + executablePrefixLength);
output += ` ${sqlTargetSafetyText(sql.slice(bodyStart, close), quotedIdentifiers).text} `;
} else {
output += " ";
}
index = close + 2;
continue;
}
const dollarQuote = dollarQuoteTagAt(sql, index);
if (dollarQuote) {
const close = sql.indexOf(dollarQuote, index + dollarQuote.length);
index = close < 0 ? sql.length : close + dollarQuote.length;
output += " ";
continue;
}
if (char === "'") {
index = readQuotedEnd(sql, index, "'", "'");
output += " ";
continue;
}
if (char === '"' || char === "`" || char === "[") {
const close = char === "[" ? "]" : char;
const end = readQuotedEnd(sql, index, char, close);
const identifier = unquoteIdentifier(sql.slice(index, end), char, close).replace(/[;]/g, " ");
const token = `__dbxq${quotedIdentifiers.size}__`;
quotedIdentifiers.set(token.toLowerCase(), identifier);
output += ` ${token} `;
index = end;
continue;
}
output += char;
index += 1;
}
return { text: output, quotedIdentifiers };
}
function mysqlExecutableCommentPrefixLength(sql: string, index: number): number {
if (sql[index] !== "/" || sql[index + 1] !== "*") return 0;
if (sql[index + 2] === "!") return 3;
if (sql[index + 2] === "M" && sql[index + 3] === "!") return 4;
return 0;
}
function skipExecutableCommentVersion(sql: string, index: number): number {
let cursor = index;
while (cursor < sql.length && /[0-9\s]/.test(sql[cursor] ?? "")) cursor += 1;
return cursor;
}
function dollarQuoteTagAt(sql: string, index: number): string | undefined {
return sql.slice(index).match(/^\$[A-Za-z_][A-Za-z0-9_]*\$|^\$\$/)?.[0];
}
function readQuotedEnd(sql: string, start: number, open: string, close: string): number {
let index = start + open.length;
while (index < sql.length) {
if (sql[index] === "\\" && (open === "'" || open === '"')) {
index += 2;
continue;
}
if (sql.startsWith(close, index)) {
if (sql.startsWith(close + close, index)) {
index += close.length * 2;
continue;
}
return index + close.length;
}
index += 1;
}
return sql.length;
}
function unquoteIdentifier(value: string, open: string, close: string): string {
if (!value.startsWith(open) || !value.endsWith(close)) return value;
return value.slice(open.length, value.length - close.length).replaceAll(close + close, close);
}
/** MCP receives Mongo shell text rather than SQL, so use a conservative write detector. */
export function isLikelyMongoMutation(command: string): boolean {
return /\.(?:insert(?:One|Many)?|update(?:One|Many)?|replaceOne|delete(?:One|Many)?|findOneAnd(?:Update|Replace|Delete)|drop(?:Index|Indexes)?|renameCollection|createIndex)\s*\(|\bdb\.createCollection\s*\(/i.test(command);
}

View File

@ -0,0 +1,234 @@
export type SqlRiskLevel = "read" | "write" | "ddl" | "transaction" | "unknown";
export interface SqlRiskStatementAssessment {
risk: SqlRiskLevel;
firstKeyword?: string;
}
export interface SqlRiskAssessment extends SqlRiskStatementAssessment {
statements: SqlRiskStatementAssessment[];
}
interface SqlRiskToken {
text: string;
normalized: string;
}
const READ_KEYWORDS = new Set(["select", "show", "describe", "desc", "values", "table"]);
const WRITE_KEYWORDS = new Set(["insert", "update", "delete", "merge", "replace", "upsert", "load", "call", "exec", "execute", "flush"]);
const DDL_KEYWORDS = new Set(["create", "alter", "drop", "truncate", "rename", "grant", "revoke", "deny", "comment", "reindex", "vacuum", "optimize"]);
const TRANSACTION_KEYWORDS = new Set(["begin", "start", "commit", "rollback", "abort", "savepoint", "release"]);
const EXPLAIN_OPTION_KEYWORDS = new Set(["explain", "analyze", "analyse", "verbose", "query", "plan", "format", "type", "costs", "buffers", "timing", "summary", "settings", "wal", "generic_plan"]);
const PRIMARY_STATEMENT_KEYWORDS = new Set([...READ_KEYWORDS, ...WRITE_KEYWORDS, ...DDL_KEYWORDS, ...TRANSACTION_KEYWORDS, "with", "copy", "pragma", "use", "set"]);
const SAFE_READ_PRAGMA_NAMES = new Set(["table_info", "table_xinfo", "index_list", "index_info", "foreign_key_list", "database_list", "compile_options", "data_version"]);
const RISK_ORDER: Record<SqlRiskLevel, number> = { read: 0, write: 1, ddl: 2, transaction: 3, unknown: 4 };
export function splitSqlStatementsForSafety(sql: string): string[] {
return sqlSafetyText(sql)
.split(";")
.map((statement) => statement.trim())
.filter(Boolean);
}
export function classifySqlRisk(sql: string): SqlRiskAssessment {
const statements = splitSqlStatementsForSafety(sql).map(classifySqlStatementRisk);
if (!statements.length) return { risk: "unknown", statements: [] };
const highest = statements.reduce<SqlRiskStatementAssessment>((current, statement) => (RISK_ORDER[statement.risk] > RISK_ORDER[current.risk] ? statement : current), { risk: "read" });
return { ...highest, statements };
}
export function classifySqlStatementRisk(sql: string): SqlRiskStatementAssessment {
return classifyTokens(tokenizeSqlForRisk(sql));
}
export function isSqlRiskMutation(risk: SqlRiskLevel): boolean {
return risk !== "read";
}
export function sqlSafetyText(sql: string): string {
let output = "";
let index = 0;
while (index < sql.length) {
const char = sql[index] ?? "";
const next = sql[index + 1] ?? "";
if (char === "-" && next === "-") {
index += 2;
while (index < sql.length && sql[index] !== "\n" && sql[index] !== "\r") index += 1;
output += " ";
continue;
}
if (char === "#") {
index += 1;
while (index < sql.length && sql[index] !== "\n" && sql[index] !== "\r") index += 1;
output += " ";
continue;
}
if (char === "/" && next === "*") {
const close = sql.indexOf("*/", index + 2);
if (close < 0) return output;
const executablePrefixLength = mysqlExecutableCommentPrefixLength(sql, index);
if (executablePrefixLength > 0) {
const bodyStart = skipExecutableCommentVersion(sql, index + executablePrefixLength);
output += ` ${sqlSafetyText(sql.slice(bodyStart, close))} `;
} else {
output += " ";
}
index = close + 2;
continue;
}
const dollarQuote = dollarQuoteTagAt(sql, index);
if (dollarQuote) {
const close = sql.indexOf(dollarQuote, index + dollarQuote.length);
index = close < 0 ? sql.length : close + dollarQuote.length;
output += " ";
continue;
}
if (char === "'") {
index = readQuotedEnd(sql, index, "'", "'");
output += " ";
continue;
}
if (char === '"' || char === "`" || char === "[") {
const close = char === "[" ? "]" : char;
const end = readQuotedEnd(sql, index, char, close);
output += ` ${unquoteIdentifier(sql.slice(index, end), char, close).replace(/[;]/g, " ")} `;
index = end;
continue;
}
output += char;
index += 1;
}
return output;
}
function tokenizeSqlForRisk(sql: string): SqlRiskToken[] {
const tokens: SqlRiskToken[] = [];
const re = /[A-Za-z_@$#][A-Za-z0-9_@$#-]*|[0-9]+|[(),.;*]|\S/g;
for (const match of sql.matchAll(re)) {
const text = match[0] ?? "";
tokens.push({ text, normalized: /^[A-Za-z_@$#]/.test(text) ? text.toLowerCase() : text });
}
return tokens;
}
function classifyTokens(tokens: SqlRiskToken[]): SqlRiskStatementAssessment {
const useful = trimWrappingParentheses(tokens);
const firstKeyword = useful.find((token) => /^[a-z_]/i.test(token.text))?.normalized;
if (!firstKeyword) return { risk: "unknown" };
if (READ_KEYWORDS.has(firstKeyword)) {
return { risk: firstKeyword === "select" && hasTopLevelSelectInto(useful) ? "write" : "read", firstKeyword };
}
if (firstKeyword === "with") return { risk: highestRiskInTokens(useful) ?? "read", firstKeyword };
if (firstKeyword === "explain") return classifyExplainTokens(useful);
if (firstKeyword === "copy") return { risk: classifyCopyTokens(useful), firstKeyword };
if (firstKeyword === "pragma") return { risk: classifyPragmaTokens(useful), firstKeyword };
if (firstKeyword === "use") return { risk: "read", firstKeyword };
if (WRITE_KEYWORDS.has(firstKeyword)) return { risk: "write", firstKeyword };
if (DDL_KEYWORDS.has(firstKeyword)) return { risk: "ddl", firstKeyword };
if (TRANSACTION_KEYWORDS.has(firstKeyword)) return { risk: "transaction", firstKeyword };
return { risk: "unknown", firstKeyword };
}
function classifyExplainTokens(tokens: SqlRiskToken[]): SqlRiskStatementAssessment {
const analyze = tokens.some((token) => token.normalized === "analyze" || token.normalized === "analyse");
const innerIndex = tokens.findIndex((token, index) => index > 0 && PRIMARY_STATEMENT_KEYWORDS.has(token.normalized) && !EXPLAIN_OPTION_KEYWORDS.has(token.normalized));
if (innerIndex < 0) return { risk: "read", firstKeyword: "explain" };
const inner = classifyTokens(tokens.slice(innerIndex));
if (!analyze) return { risk: inner.risk === "unknown" ? "unknown" : "read", firstKeyword: "explain" };
return { risk: inner.risk, firstKeyword: inner.firstKeyword ?? "explain" };
}
function classifyCopyTokens(tokens: SqlRiskToken[]): SqlRiskLevel {
if (tokens.some((token) => token.normalized === "from")) return "write";
if (tokens.some((token) => token.normalized === "to")) return "read";
return "unknown";
}
function classifyPragmaTokens(tokens: SqlRiskToken[]): SqlRiskLevel {
const name = tokens.find((token, index) => index > 0 && /^[a-z_]/i.test(token.text))?.normalized;
if (name && SAFE_READ_PRAGMA_NAMES.has(name) && !tokens.some((token) => token.text === "=")) return "read";
return "write";
}
function highestRiskInTokens(tokens: SqlRiskToken[]): SqlRiskLevel | undefined {
let result: SqlRiskLevel | undefined;
for (const token of tokens) {
const risk = WRITE_KEYWORDS.has(token.normalized) ? "write" : DDL_KEYWORDS.has(token.normalized) ? "ddl" : TRANSACTION_KEYWORDS.has(token.normalized) ? "transaction" : undefined;
if (risk && (!result || RISK_ORDER[risk] > RISK_ORDER[result])) result = risk;
}
return result;
}
function hasTopLevelSelectInto(tokens: SqlRiskToken[]): boolean {
let depth = 0;
for (const token of tokens) {
if (token.text === "(") depth += 1;
if (token.text === ")") depth = Math.max(0, depth - 1);
if (depth !== 0) continue;
if (token.normalized === "into") return true;
}
return false;
}
function trimWrappingParentheses(tokens: SqlRiskToken[]): SqlRiskToken[] {
let start = 0;
let end = tokens.length;
while (tokens[start]?.text === "(" && matchingParenIndex(tokens, start) === end - 1) {
start += 1;
end -= 1;
}
return tokens.slice(start, end);
}
function matchingParenIndex(tokens: readonly SqlRiskToken[], openIndex: number): number {
let depth = 0;
for (let index = openIndex; index < tokens.length; index += 1) {
if (tokens[index]?.text === "(") depth += 1;
if (tokens[index]?.text === ")") {
depth -= 1;
if (depth === 0) return index;
}
}
return -1;
}
function mysqlExecutableCommentPrefixLength(sql: string, index: number): number {
if (sql[index] !== "/" || sql[index + 1] !== "*") return 0;
if (sql[index + 2] === "!") return 3;
if (sql[index + 2] === "M" && sql[index + 3] === "!") return 4;
return 0;
}
function skipExecutableCommentVersion(sql: string, index: number): number {
let cursor = index;
while (cursor < sql.length && /[0-9\s]/.test(sql[cursor] ?? "")) cursor += 1;
return cursor;
}
function dollarQuoteTagAt(sql: string, index: number): string | undefined {
return sql.slice(index).match(/^\$[A-Za-z_][A-Za-z0-9_]*\$|^\$\$/)?.[0];
}
function readQuotedEnd(sql: string, start: number, open: string, close: string): number {
let index = start + open.length;
while (index < sql.length) {
if (sql[index] === "\\" && (open === "'" || open === '"')) {
index += 2;
continue;
}
if (sql.startsWith(close, index)) {
if (sql.startsWith(close + close, index)) {
index += close.length * 2;
continue;
}
return index + close.length;
}
index += 1;
}
return sql.length;
}
function unquoteIdentifier(value: string, open: string, close: string): string {
if (!value.startsWith(open) || !value.endsWith(close)) return value;
return value.slice(open.length, value.length - close.length).replaceAll(close + close, close);
}

View File

@ -1,3 +1,5 @@
import { classifySqlStatementRisk, splitSqlStatementsForSafety, sqlSafetyText } from "./sql-risk.js";
export interface SqlSafetyOptions {
allowWrites?: boolean;
allowDangerous?: boolean;
@ -9,8 +11,7 @@ export interface SqlSafetyDecision {
reason?: string;
}
const READ_KEYWORDS = new Set(["select", "with", "show", "describe", "desc", "explain"]);
const DANGEROUS_KEYWORDS = new Set(["drop", "truncate", "alter"]);
const DANGEROUS_RISKS = new Set(["ddl", "transaction", "unknown"]);
function parseBooleanEnv(value: string | undefined): boolean | undefined {
if (value === undefined) return undefined;
@ -21,7 +22,7 @@ function parseBooleanEnv(value: string | undefined): boolean | undefined {
}
export function evaluateSqlSafety(sql: string, options: SqlSafetyOptions = {}): SqlSafetyDecision {
const statements = splitSqlStatements(sql);
const statements = splitSqlStatementsForSafety(sql);
if (statements.length === 0) return { allowed: false, reason: "SQL is empty." };
if (statements.length > 1 && !options.allowMultipleStatements) {
return { allowed: false, reason: "Only one SQL statement is allowed per query." };
@ -42,17 +43,15 @@ export function evaluateSqlSafety(sql: string, options: SqlSafetyOptions = {}):
}
function evaluateSingleSqlStatementSafety(sql: string, options: SqlSafetyOptions = {}): SqlSafetyDecision {
const normalized = stripSqlCommentsAndStrings(sql).trim();
const firstKeyword = normalized.match(/^[a-zA-Z_]+/)?.[0]?.toLowerCase();
const assessment = classifySqlStatementRisk(sql);
const firstKeyword = assessment.firstKeyword;
if (!firstKeyword) return { allowed: false, reason: "SQL statement is not recognized." };
const tokens: string[] = normalized.toLowerCase().match(/[a-z_]+/g) ?? [];
const dangerous = tokens.find((token) => DANGEROUS_KEYWORDS.has(token));
if (dangerous && !options.allowDangerous) {
return { allowed: false, reason: `Dangerous SQL keyword "${dangerous.toUpperCase()}" is blocked.` };
if (DANGEROUS_RISKS.has(assessment.risk) && !options.allowDangerous) {
return { allowed: false, reason: `Dangerous SQL or unrecognized SQL statement "${firstKeyword.toUpperCase()}" is blocked.` };
}
if (!options.allowWrites && !READ_KEYWORDS.has(firstKeyword)) {
if (!options.allowWrites && assessment.risk !== "read") {
return {
allowed: false,
reason: "MCP SQL execution is read-only for this session. Set DBX_MCP_ALLOW_WRITES=1 to allow write statements.",
@ -60,6 +59,7 @@ function evaluateSingleSqlStatementSafety(sql: string, options: SqlSafetyOptions
}
if (options.allowWrites && !options.allowDangerous) {
const tokens: string[] = sqlSafetyText(sql).toLowerCase().match(/[a-z_]+/g) ?? [];
if (firstKeyword === "update" && !tokens.includes("where")) {
return { allowed: false, reason: "UPDATE statements must include a WHERE clause." };
}
@ -81,63 +81,5 @@ export function sqlSafetyFromEnv(env: NodeJS.ProcessEnv = process.env): SqlSafet
}
export function splitSqlStatements(sql: string): string[] {
const statements: string[] = [];
let current = "";
let quote: "'" | '"' | "`" | null = null;
let inLineComment = false;
let inBlockComment = false;
for (let i = 0; i < sql.length; i++) {
const char = sql[i];
const next = sql[i + 1];
if (inLineComment) {
current += char;
if (char === "\n") inLineComment = false;
continue;
}
if (inBlockComment) {
current += char;
if (char === "*" && next === "/") {
current += next;
i++;
inBlockComment = false;
}
continue;
}
if (quote) {
current += char;
if (char === quote) {
if (next === quote) {
current += next;
i++;
} else {
quote = null;
}
}
continue;
}
if (char === "-" && next === "-") inLineComment = true;
if (char === "/" && next === "*") inBlockComment = true;
if (char === "'" || char === '"' || char === "`") quote = char;
if (char === ";") {
if (current.trim()) statements.push(current.trim());
current = "";
} else {
current += char;
}
}
if (current.trim()) statements.push(current.trim());
return statements;
}
function stripSqlCommentsAndStrings(sql: string): string {
return sql
.replace(/--.*$/gm, " ")
.replace(/\/\*[\s\S]*?\*\//g, " ")
.replace(/'([^']|'')*'/g, "''")
.replace(/"([^"]|"")*"/g, '""')
.replace(/`([^`]|``)*`/g, "``");
return splitSqlStatementsForSafety(sql);
}

View File

@ -0,0 +1,108 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { assessProductionSql, isLikelyMongoMutation, isProductionDatabase } from "../src/production-safety.js";
import type { ConnectionConfig } from "../src/connections.js";
interface ProductionSafetyCorpusCase {
name: string;
dialect: ConnectionConfig["db_type"];
productionDatabases: string[];
activeDatabase: string;
sql: string;
active: boolean;
isMutation: boolean;
databases: string[];
}
const productionSafetyCorpus = JSON.parse(readFileSync(new URL("../../../tests/fixtures/production-safety-corpus.json", import.meta.url), "utf8")) as ProductionSafetyCorpusCase[];
function connection(overrides: Partial<ConnectionConfig> = {}): ConnectionConfig {
return {
id: "conn-1",
name: "Operations",
db_type: "mysql",
host: "db.internal",
port: 3306,
username: "readonly",
password: "",
production_databases: ["prod_app"],
...overrides,
};
}
describe("production safety", () => {
it("keeps an explicit production connection in scope", () => {
expect(isProductionDatabase(connection({ is_production: true }), "scratch")).toBe(true);
});
it("detects a production write through USE and a qualified table name", () => {
expect(assessProductionSql("-- migrate\nUSE prod_app; /* delete old rows */ DELETE FROM users", connection(), "staging")).toMatchObject({ active: true, isMutation: true });
expect(assessProductionSql("DELETE FROM prod_app.orders", connection(), "staging")).toMatchObject({ active: true, isMutation: true, databases: ["prod_app"] });
expect(assessProductionSql("DROP DATABASE IF EXISTS prod_app", connection(), "staging")).toMatchObject({ active: true, isMutation: true, databases: ["prod_app"] });
});
it("detects production writes hidden behind parser-sensitive SQL forms", () => {
for (const sql of ["EXPLAIN ANALYZE DELETE FROM prod_app.users WHERE id = 1", "/*! DELETE FROM prod_app.users WHERE id = 1 */", "COPY prod_app.users FROM '/tmp/users.csv'", "SELECT * INTO prod_app.backup_users FROM users", "SELECT * FROM prod_app.users INTO OUTFILE '/tmp/users.csv'"]) {
expect(assessProductionSql(sql, connection(), "staging")).toMatchObject({ active: true, isMutation: true, databases: ["prod_app"] });
}
});
it("matches the shared SQL target safety corpus", () => {
for (const corpusCase of productionSafetyCorpus) {
const assessment = assessProductionSql(
corpusCase.sql,
connection({
db_type: corpusCase.dialect,
production_databases: corpusCase.productionDatabases,
}),
corpusCase.activeDatabase,
);
expect(
{
active: assessment.active,
isMutation: assessment.isMutation,
databases: assessment.databases,
},
corpusCase.name,
).toEqual({
active: corpusCase.active,
isMutation: corpusCase.isMutation,
databases: corpusCase.databases,
});
}
});
it("detects qualified procedure calls and privilege targets", () => {
for (const sql of ["CALL prod_app.purge_users()", "CALL `prod_app`.`purge_users`()", "GRANT ALL ON prod_app.* TO 'u'@'%'", "GRANT EXECUTE ON PROCEDURE prod_app.purge_users TO 'u'@'%'"]) {
expect(assessProductionSql(sql, connection(), "staging")).toMatchObject({ active: true, isMutation: true, databases: ["prod_app"] });
}
});
it("allows resolved non-production procedure and privilege targets", () => {
expect(assessProductionSql("CALL staging.purge_users()", connection(), "staging")).toMatchObject({ active: false, isMutation: true });
expect(assessProductionSql("GRANT ALL ON staging.* TO 'u'@'%'", connection(), "staging")).toMatchObject({ active: false, isMutation: true });
});
it("conservatively confirms ambiguous production targets", () => {
for (const sql of ["CALL purge_users()", "GRANT PROCESS ON *.* TO 'u'@'%'", "GRANT ALL ON users TO 'u'@'%'", "CREATE USER 'u'@'%'"]) {
expect(assessProductionSql(sql, connection(), "staging")).toMatchObject({ active: true, isMutation: true, databases: ["prod_app"] });
}
});
it("does not treat read-only qualified references as write targets", () => {
expect(assessProductionSql("SELECT * FROM prod_app.orders; DELETE FROM staging.users WHERE id = 1", connection(), "staging")).toMatchObject({ active: false, isMutation: true });
});
it("treats unrecognized SQL as a mutation when production is selected", () => {
expect(assessProductionSql("MAINTAIN UNKNOWN THING", connection(), "prod_app")).toMatchObject({ active: true, isMutation: true });
});
it("does not treat a read as a production write", () => {
expect(assessProductionSql("SELECT * FROM prod_app.orders", connection(), "staging")).toMatchObject({ active: false, isMutation: false });
});
it("recognizes Mongo write commands before MCP forwards them", () => {
expect(isLikelyMongoMutation("db.orders.updateOne({_id: 1}, {$set: {status: 'paid'}})")).toBe(true);
expect(isLikelyMongoMutation("db.orders.find({status: 'paid'})")).toBe(false);
});
});

View File

@ -8,6 +8,12 @@ test("allows read-only SQL by default", () => {
assert.equal(decision.allowed, true);
});
test("allows read-only EXPLAIN without ANALYZE", () => {
const decision = evaluateSqlSafety("EXPLAIN SELECT * FROM users");
assert.equal(decision.allowed, true);
});
test("allows non-dangerous write SQL by default when scoped", () => {
const decision = evaluateSqlSafety("update users set role = 'admin' where id = 1", sqlSafetyFromEnv({}));
@ -28,6 +34,27 @@ test("blocks update without where when writes are enabled", () => {
assert.match(decision.reason ?? "", /WHERE/i);
});
test("blocks writes that do not start with a write keyword in read-only mode", () => {
for (const sql of [
"EXPLAIN ANALYZE DELETE FROM users WHERE id = 1",
"/*! DELETE FROM users WHERE id = 1 */",
"COPY users FROM '/tmp/users.csv'",
"SELECT * INTO backup_users FROM users",
"SELECT * FROM users INTO OUTFILE '/tmp/users.csv'",
]) {
const decision = evaluateSqlSafety(sql);
assert.equal(decision.allowed, false, sql);
assert.match(decision.reason ?? "", /read-only|blocked/i);
}
});
test("blocks unrecognized SQL unless dangerous SQL is explicitly enabled", () => {
const decision = evaluateSqlSafety("MAINTAIN UNKNOWN THING", { allowWrites: true });
assert.equal(decision.allowed, false);
assert.match(decision.reason ?? "", /unrecognized/i);
});
test("blocks multiple SQL statements unless explicitly allowed", () => {
const decision = evaluateSqlSafety("select 1; select 2");

54
scripts/dev-backend.mjs Normal file
View File

@ -0,0 +1,54 @@
import { spawn } from "node:child_process";
const env = {
...process.env,
RUST_LOG: process.env.RUST_LOG || "info",
};
const hasCargoWatch = await commandSucceeds("cargo", ["watch", "--version"]);
if (!hasCargoWatch) {
console.warn(
"cargo-watch is not installed; running dbx-web without hot reload. Install with: cargo install cargo-watch",
);
}
const args = hasCargoWatch ? ["watch", "-x", "run -p dbx-web"] : ["run", "-p", "dbx-web"];
const child = spawn("cargo", args, {
cwd: process.cwd(),
env,
stdio: "inherit",
});
// Keep signal handling in Node so Windows does not have to parse nested shell quotes.
for (const signal of ["SIGINT", "SIGTERM"]) {
process.on(signal, () => {
child.kill(signal);
});
}
child.on("error", (error) => {
console.error(error.stack ?? String(error));
process.exit(1);
});
child.on("close", (code, signal) => {
if (signal) {
process.kill(process.pid, signal);
return;
}
process.exit(code ?? 1);
});
function commandSucceeds(command, args) {
const child = spawn(command, args, {
cwd: process.cwd(),
env,
stdio: "ignore",
});
return new Promise((resolve) => {
child.on("error", () => resolve(false));
child.on("close", (code) => resolve(code === 0));
});
}

View File

@ -99,16 +99,22 @@ pub async fn ai_agent_stream(
} else {
None
};
let production_database = state
.configs
.read()
.await
.get(&connection_id)
.is_some_and(|config| dbx_core::production_safety::is_production_database(config, &database));
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.
// Explicit confirmation grants write access only to this agent run, never to production.
sql_permissions: dbx_core::agent_tools::AgentSqlPermissions {
allow_writes: allow_write_sql.unwrap_or(false),
allow_dangerous: allow_write_sql.unwrap_or(false),
allow_writes: !production_database && allow_write_sql.unwrap_or(false),
allow_dangerous: !production_database && allow_write_sql.unwrap_or(false),
},
};
let is_agent_mode = mode.as_deref() == Some("agent");

View File

@ -204,6 +204,8 @@ mod tests {
jdbc_driver_paths: Vec::new(),
one_time: false,
read_only: false,
is_production: false,
production_databases: vec![],
}
}

View File

@ -0,0 +1,312 @@
[
{
"name": "mysql qualified delete targets production database",
"dialect": "mysql",
"productionDatabases": ["prod_app"],
"activeDatabase": "staging",
"sql": "DELETE FROM prod_app.users WHERE id = 1",
"active": true,
"isMutation": true,
"databases": ["prod_app"]
},
{
"name": "mysql use switch carries production target to unqualified mutation",
"dialect": "mysql",
"productionDatabases": ["prod_app"],
"activeDatabase": "staging",
"sql": "USE `prod_app`; DELETE FROM users WHERE id = 1",
"active": true,
"isMutation": true,
"databases": ["prod_app"]
},
{
"name": "mysql executable comment can contain a production delete",
"dialect": "mysql",
"productionDatabases": ["prod_app"],
"activeDatabase": "staging",
"sql": "/*! DELETE FROM prod_app.users WHERE id = 1 */",
"active": true,
"isMutation": true,
"databases": ["prod_app"]
},
{
"name": "mysql qualified procedure call targets production database",
"dialect": "mysql",
"productionDatabases": ["prod_app"],
"activeDatabase": "staging",
"sql": "CALL prod_app.purge_users()",
"active": true,
"isMutation": true,
"databases": ["prod_app"]
},
{
"name": "mysql qualified grant targets production database",
"dialect": "mysql",
"productionDatabases": ["prod_app"],
"activeDatabase": "staging",
"sql": "GRANT ALL ON prod_app.* TO 'u'@'%'",
"active": true,
"isMutation": true,
"databases": ["prod_app"]
},
{
"name": "mysql unqualified procedure call is ambiguous with production markers",
"dialect": "mysql",
"productionDatabases": ["prod_app"],
"activeDatabase": "staging",
"sql": "CALL purge_users()",
"active": true,
"isMutation": true,
"databases": ["prod_app"]
},
{
"name": "mysql global privilege statement is ambiguous with production markers",
"dialect": "mysql",
"productionDatabases": ["prod_app"],
"activeDatabase": "staging",
"sql": "GRANT PROCESS ON *.* TO 'u'@'%'",
"active": true,
"isMutation": true,
"databases": ["prod_app"]
},
{
"name": "mysql non-production procedure target does not trip production marker",
"dialect": "mysql",
"productionDatabases": ["prod_app"],
"activeDatabase": "staging",
"sql": "CALL staging.purge_users()",
"active": false,
"isMutation": true,
"databases": []
},
{
"name": "mysql non-production privilege target does not trip production marker",
"dialect": "mysql",
"productionDatabases": ["prod_app"],
"activeDatabase": "staging",
"sql": "GRANT ALL ON staging.* TO 'u'@'%'",
"active": false,
"isMutation": true,
"databases": []
},
{
"name": "mysql unqualified table DML resolves to the selected non-production database",
"dialect": "mysql",
"productionDatabases": ["prod_app"],
"activeDatabase": "staging",
"sql": "UPDATE users SET active = 1 WHERE id = 1",
"active": false,
"isMutation": true,
"databases": []
},
{
"name": "mysql repeated unqualified table DML resolves per statement",
"dialect": "mysql",
"productionDatabases": ["prod_app"],
"activeDatabase": "staging",
"sql": "UPDATE users SET active = 1 WHERE id = 1; DELETE FROM users WHERE id = 2",
"active": false,
"isMutation": true,
"databases": []
},
{
"name": "mysql unknown mutation without a resolved target is conservatively confirmed",
"dialect": "mysql",
"productionDatabases": ["prod_app"],
"activeDatabase": "staging",
"sql": "MAINTAIN UNKNOWN THING",
"active": true,
"isMutation": true,
"databases": ["prod_app"]
},
{
"name": "mysql transaction statement does not target a production database",
"dialect": "mysql",
"productionDatabases": ["prod_app"],
"activeDatabase": "staging",
"sql": "BEGIN",
"active": false,
"isMutation": true,
"databases": []
},
{
"name": "mysql qualified rename table source and destination target production database",
"dialect": "mysql",
"productionDatabases": ["prod_app"],
"activeDatabase": "staging",
"sql": "RENAME TABLE prod_app.old_name TO prod_app.new_name",
"active": true,
"isMutation": true,
"databases": ["prod_app"]
},
{
"name": "mysql rename table into production database is detected",
"dialect": "mysql",
"productionDatabases": ["prod_app"],
"activeDatabase": "staging",
"sql": "RENAME TABLE staging.old_name TO prod_app.new_name",
"active": true,
"isMutation": true,
"databases": ["prod_app"]
},
{
"name": "mysql resolved non-production rename table target stays non-production",
"dialect": "mysql",
"productionDatabases": ["prod_app"],
"activeDatabase": "staging",
"sql": "RENAME TABLE staging.old_name TO staging.new_name",
"active": false,
"isMutation": true,
"databases": []
},
{
"name": "mysql multi-target rename conservatively protects unresolved production target",
"dialect": "mysql",
"productionDatabases": ["prod_app"],
"activeDatabase": "staging",
"sql": "RENAME TABLE staging.a TO staging.b, prod_app.c TO prod_app.d",
"active": true,
"isMutation": true,
"databases": ["prod_app"]
},
{
"name": "mysql multi-target drop conservatively protects unresolved production target",
"dialect": "mysql",
"productionDatabases": ["prod_app"],
"activeDatabase": "staging",
"sql": "DROP TABLE staging.a, prod_app.b",
"active": true,
"isMutation": true,
"databases": ["prod_app"]
},
{
"name": "mysql qualified truncate table targets production database",
"dialect": "mysql",
"productionDatabases": ["prod_app"],
"activeDatabase": "staging",
"sql": "TRUNCATE TABLE prod_app.users",
"active": true,
"isMutation": true,
"databases": ["prod_app"]
},
{
"name": "mysql resolved non-production truncate table target stays non-production",
"dialect": "mysql",
"productionDatabases": ["prod_app"],
"activeDatabase": "staging",
"sql": "TRUNCATE TABLE staging.users",
"active": false,
"isMutation": true,
"databases": []
},
{
"name": "mysql optimize table targets production database",
"dialect": "mysql",
"productionDatabases": ["prod_app"],
"activeDatabase": "staging",
"sql": "OPTIMIZE TABLE prod_app.users",
"active": true,
"isMutation": true,
"databases": ["prod_app"]
},
{
"name": "mysql analyze table targets production database",
"dialect": "mysql",
"productionDatabases": ["prod_app"],
"activeDatabase": "staging",
"sql": "ANALYZE TABLE prod_app.users",
"active": true,
"isMutation": true,
"databases": ["prod_app"]
},
{
"name": "mysql repair table targets production database",
"dialect": "mysql",
"productionDatabases": ["prod_app"],
"activeDatabase": "staging",
"sql": "REPAIR TABLE prod_app.users",
"active": true,
"isMutation": true,
"databases": ["prod_app"]
},
{
"name": "mysql check table resolves to selected non-production database",
"dialect": "mysql",
"productionDatabases": ["prod_app"],
"activeDatabase": "staging",
"sql": "CHECK TABLE users",
"active": false,
"isMutation": true,
"databases": []
},
{
"name": "mysql read-only production reference before staging write stays non-production",
"dialect": "mysql",
"productionDatabases": ["prod_app"],
"activeDatabase": "staging",
"sql": "SELECT * FROM prod_app.orders; DELETE FROM staging.users WHERE id = 1",
"active": false,
"isMutation": true,
"databases": []
},
{
"name": "mysql quoted unicode database target is preserved",
"dialect": "mysql",
"productionDatabases": ["生产库"],
"activeDatabase": "staging",
"sql": "DELETE FROM `生产库`.users WHERE id = 1",
"active": true,
"isMutation": true,
"databases": ["生产库"]
},
{
"name": "mysql quoted database containing spaces is preserved in privilege target",
"dialect": "mysql",
"productionDatabases": ["prod app"],
"activeDatabase": "staging",
"sql": "GRANT ALL ON `prod app`.* TO 'u'@'%'",
"active": true,
"isMutation": true,
"databases": ["prod app"]
},
{
"name": "mysql quoted routine target with unicode database is preserved",
"dialect": "mysql",
"productionDatabases": ["生产库"],
"activeDatabase": "staging",
"sql": "CALL `生产库`.`purge users`()",
"active": true,
"isMutation": true,
"databases": ["生产库"]
},
{
"name": "mysql quoted database containing dot is not split as a qualifier",
"dialect": "mysql",
"productionDatabases": ["prod.app"],
"activeDatabase": "staging",
"sql": "DROP DATABASE IF EXISTS `prod.app`",
"active": true,
"isMutation": true,
"databases": ["prod.app"]
},
{
"name": "sqlserver three-part table name resolves production database",
"dialect": "sqlserver",
"productionDatabases": ["prod_app"],
"activeDatabase": "staging",
"sql": "DELETE FROM prod_app.dbo.users WHERE id = 1",
"active": true,
"isMutation": true,
"databases": ["prod_app"]
},
{
"name": "sqlserver two-part table name is schema-qualified, not database-qualified",
"dialect": "sqlserver",
"productionDatabases": ["prod_app"],
"activeDatabase": "staging",
"sql": "DELETE FROM prod_app.users WHERE id = 1",
"active": false,
"isMutation": true,
"databases": []
}
]