feat(ai-tools): 新增 SQL 风险预检并优化视图定位

- 新增 inspect_sql_risk 内置工具,识别多语句、写入、DDL、无 WHERE 和安全策略风险

- 在 AI 设置内置工具目录和系统提示中补充 SQL 风险预检链路

- 修复同名视图定位时优先当前数据库 schema 的匹配逻辑
This commit is contained in:
Syngnat
2026-06-09 21:56:28 +08:00
parent f0afff68c4
commit 48de0b83c4
13 changed files with 577 additions and 14 deletions

View File

@@ -378,6 +378,29 @@ export const BUILTIN_AI_INSPECTION_TOOL_INFO: AIBuiltinToolInfo[] = [
},
},
},
{
name: "inspect_sql_risk",
icon: "🛑",
desc: "检查当前或指定 SQL 的执行风险",
detail:
"读取传入 SQL 或当前活动查询页签内容识别多语句、写入、DDL、DELETE/UPDATE 无 WHERE、DROP/TRUNCATE 等风险,并结合当前 AI 安全策略返回是否允许执行。适合用户让 AI 执行、解释风险、确认能不能跑某条 SQL 前先做一次安全体检。",
params: "sql?(默认读取当前活动查询页签), previewCharLimit?",
tool: {
type: "function",
function: {
name: "inspect_sql_risk",
description:
"检查传入 SQL 或当前活动查询页签 SQL 的执行风险,返回语句数量、活动类型、风险级别、危险点、是否需要用户确认,以及当前 AI 安全策略检查结果。适用于用户要求执行、删除、更新、DDL、批量 SQL、或询问某条 SQL 能不能跑时,先读取这份风险快照再回答或继续执行。",
parameters: {
type: "object",
properties: {
sql: { type: "string", description: "可选,要检查的 SQL不传时默认读取当前活动查询页签的 SQL 草稿" },
previewCharLimit: { type: "number", description: "可选SQL 预览最多返回多少字符,默认 12000最大 40000" },
},
},
},
},
},
{
name: "inspect_app_logs",
icon: "🪵",

View File

@@ -124,6 +124,7 @@ describe('aiToolRegistry', () => {
it('registers the recent-sql-activity, saved-query, and sql-snippet inspectors as builtin tools', () => {
const recentActivityTool = BUILTIN_AI_TOOL_INFO.find((item) => item.name === 'inspect_recent_sql_activity');
const sqlRiskTool = BUILTIN_AI_TOOL_INFO.find((item) => item.name === 'inspect_sql_risk');
const appLogTool = BUILTIN_AI_TOOL_INFO.find((item) => item.name === 'inspect_app_logs');
const connectionFailureTool = BUILTIN_AI_TOOL_INFO.find((item) => item.name === 'inspect_recent_connection_failures');
const renderErrorTool = BUILTIN_AI_TOOL_INFO.find((item) => item.name === 'inspect_ai_last_render_error');
@@ -133,6 +134,8 @@ describe('aiToolRegistry', () => {
expect(recentActivityTool?.desc).toContain('最近 SQL 活动');
expect(recentActivityTool?.tool.function.description).toContain('最近 SQL 活动');
expect(sqlRiskTool?.desc).toContain('SQL 的执行风险');
expect(sqlRiskTool?.tool.function.description).toContain('危险点');
expect(appLogTool?.desc).toContain('GoNavi 应用日志');
expect(appLogTool?.tool.function.description).toContain('应用日志');
expect(connectionFailureTool?.desc).toContain('连接失败');
@@ -177,6 +180,7 @@ describe('aiToolRegistry', () => {
expect(tools.some((item) => item.function.name === 'inspect_external_sql_directories')).toBe(true);
expect(tools.some((item) => item.function.name === 'inspect_external_sql_file')).toBe(true);
expect(tools.some((item) => item.function.name === 'inspect_recent_sql_activity')).toBe(true);
expect(tools.some((item) => item.function.name === 'inspect_sql_risk')).toBe(true);
expect(tools.some((item) => item.function.name === 'inspect_app_logs')).toBe(true);
expect(tools.some((item) => item.function.name === 'inspect_recent_connection_failures')).toBe(true);
expect(tools.some((item) => item.function.name === 'inspect_ai_last_render_error')).toBe(true);

View File

@@ -700,7 +700,7 @@ describe('sidebarLocate', () => {
]);
});
it('does not guess a schema-qualified view when an unqualified locate request is ambiguous', () => {
it('prefers the current database schema when an unqualified view request matches multiple schemas', () => {
const target = resolveSidebarLocateTarget({
tabId: 'conn-1-SYSDBA-view-V_ACCOUNT',
connectionId: 'conn-1',
@@ -753,6 +753,68 @@ describe('sidebarLocate', () => {
},
];
expect(findSidebarNodePathForLocate(tree, target)).toEqual([
'conn-1',
'conn-1-SYSDBA',
'conn-1-SYSDBA-schema-SYSDBA',
'conn-1-SYSDBA-schema-SYSDBA-views',
'conn-1-SYSDBA-view-SYSDBA.V_ACCOUNT',
]);
});
it('does not guess a schema-qualified view when no current-schema preference resolves ambiguity', () => {
const target = resolveSidebarLocateTarget({
tabId: 'conn-1-SYSDBA-view-V_ACCOUNT',
connectionId: 'conn-1',
dbName: 'SYSDBA',
tableName: 'V_ACCOUNT',
objectGroup: 'views',
}, { groupBySchema: true });
const tree = [
{
key: 'conn-1',
children: [
{
key: 'conn-1-SYSDBA',
dataRef: { id: 'conn-1', dbName: 'SYSDBA' },
children: [
{
key: 'conn-1-SYSDBA-schema-APP',
children: [
{
key: 'conn-1-SYSDBA-schema-APP-views',
children: [
{
key: 'conn-1-SYSDBA-view-APP.V_ACCOUNT',
type: 'view',
dataRef: { id: 'conn-1', dbName: 'SYSDBA', viewName: 'APP.V_ACCOUNT', schemaName: 'APP' },
},
],
},
],
},
{
key: 'conn-1-SYSDBA-schema-REPORT',
children: [
{
key: 'conn-1-SYSDBA-schema-REPORT-views',
children: [
{
key: 'conn-1-SYSDBA-view-REPORT.V_ACCOUNT',
type: 'view',
dataRef: { id: 'conn-1', dbName: 'SYSDBA', viewName: 'REPORT.V_ACCOUNT', schemaName: 'REPORT' },
},
],
},
],
},
],
},
],
},
];
expect(findSidebarNodePathForLocate(tree, target)).toBeNull();
});

View File

@@ -63,6 +63,7 @@ export interface SidebarLocateTabLike {
}
const toTrimmedString = (value: unknown): string => String(value ?? '').trim();
const normalizeLocateName = (value: string): string => toTrimmedString(value).toLowerCase();
const normalizeExternalSQLLocatePath = (value: unknown): string => toTrimmedString(value).replace(/\\/g, '/');
@@ -265,28 +266,27 @@ const matchesLocateObjectName = (
const targetObject = targetParsed.objectName || target.tableName;
const resolvedNodeSchema = toTrimmedString(nodeSchemaName) || nodeParsed.schemaName;
const resolvedTargetSchema = toTrimmedString(target.schemaName) || targetParsed.schemaName;
const normalize = (value: string): string => toTrimmedString(value).toLowerCase();
if (normalize(normalizedNodeName) === normalize(target.tableName)) return true;
if (normalizeLocateName(normalizedNodeName) === normalizeLocateName(target.tableName)) return true;
if (
resolvedTargetSchema
&& !resolvedNodeSchema
&& normalize(resolvedTargetSchema) === normalize(target.dbName)
&& normalize(nodeObject) === normalize(targetObject)
&& normalizeLocateName(resolvedTargetSchema) === normalizeLocateName(target.dbName)
&& normalizeLocateName(nodeObject) === normalizeLocateName(targetObject)
) {
return true;
}
if (!resolvedTargetSchema) {
if (options.allowUnqualifiedSchemaMatch) {
return normalize(nodeObject) === normalize(targetObject);
return normalizeLocateName(nodeObject) === normalizeLocateName(targetObject);
}
return !resolvedNodeSchema && normalize(nodeObject) === normalize(targetObject);
return !resolvedNodeSchema && normalizeLocateName(nodeObject) === normalizeLocateName(targetObject);
}
return normalize(resolvedNodeSchema) === normalize(resolvedTargetSchema)
&& normalize(nodeObject) === normalize(targetObject);
return normalizeLocateName(resolvedNodeSchema) === normalizeLocateName(resolvedTargetSchema)
&& normalizeLocateName(nodeObject) === normalizeLocateName(targetObject);
};
const matchesLocateObjectNode = (
@@ -441,6 +441,40 @@ const shouldFallbackViewLocateToTableNode = (target: SidebarLocateTarget): boole
target.objectGroup === 'views' || target.objectGroup === 'materializedViews'
);
const selectPreferredSidebarLocatePath = (
paths: string[][],
target: SidebarLocateTarget,
): string[] | null => {
if (paths.length === 1) return paths[0];
if (paths.length === 0 || target.objectGroup === 'externalSqlFiles') return null;
const targetParsed = splitSidebarQualifiedName(target.tableName);
const targetObjectName = normalizeLocateName(targetParsed.objectName || target.tableName);
const schemaCandidates = [
toTrimmedString(target.schemaName),
targetParsed.schemaName,
target.dbName,
].filter(Boolean);
const normalizedSchemas = Array.from(new Set(schemaCandidates.map(normalizeLocateName)));
for (const normalizedSchema of normalizedSchemas) {
const preferredSchemaKey = `${normalizeLocateName(target.databaseKey)}-schema-${normalizedSchema}`;
const bySchemaGroup = paths.filter((path) =>
path.some((key) => normalizeLocateName(key) === preferredSchemaKey),
);
if (bySchemaGroup.length === 1) return bySchemaGroup[0];
const qualifiedSuffix = `${normalizedSchema}.${targetObjectName}`;
const byQualifiedLeafKey = paths.filter((path) => {
const leafKey = normalizeLocateName(path[path.length - 1] || '');
return leafKey.endsWith(qualifiedSuffix);
});
if (byQualifiedLeafKey.length === 1) return byQualifiedLeafKey[0];
}
return null;
};
export const findSidebarNodePathForLocate = (
nodes: SidebarLocateTreeNodeLike[],
target: SidebarLocateTarget,
@@ -452,24 +486,27 @@ export const findSidebarNodePathForLocate = (
if (strictPath) return strictPath;
const visualIdentityPaths = collectSidebarNodePathsForLocateByVisualIdentity(nodes, target);
if (visualIdentityPaths.length === 1) return visualIdentityPaths[0];
const visualIdentityPath = selectPreferredSidebarLocatePath(visualIdentityPaths, target);
if (visualIdentityPath) return visualIdentityPath;
if (shouldFallbackViewLocateToTableNode(target)) {
const tableLikeTarget = { ...target, objectGroup: 'tables' as const };
const tableLikePaths = collectSidebarNodePathsForLocateByObject(nodes, tableLikeTarget);
if (tableLikePaths.length === 1) return tableLikePaths[0];
const tableLikePath = selectPreferredSidebarLocatePath(tableLikePaths, target);
if (tableLikePath) return tableLikePath;
if (!hasLocateTargetSchema(target)) {
const relaxedTableLikePaths = collectSidebarNodePathsForLocateByObject(
nodes,
tableLikeTarget,
{ allowUnqualifiedSchemaMatch: true },
);
if (relaxedTableLikePaths.length === 1) return relaxedTableLikePaths[0];
const relaxedTableLikePath = selectPreferredSidebarLocatePath(relaxedTableLikePaths, target);
if (relaxedTableLikePath) return relaxedTableLikePath;
}
}
if (hasLocateTargetSchema(target)) return null;
const relaxedPaths = collectSidebarNodePathsForLocateByObject(nodes, target, { allowUnqualifiedSchemaMatch: true });
return relaxedPaths.length === 1 ? relaxedPaths[0] : null;
return selectPreferredSidebarLocatePath(relaxedPaths, target);
};