mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-07-08 11:31:57 +08:00
✨ feat(ai-tools): 新增 SQL 风险预检并优化视图定位
- 新增 inspect_sql_risk 内置工具,识别多语句、写入、DDL、无 WHERE 和安全策略风险 - 在 AI 设置内置工具目录和系统提示中补充 SQL 风险预检链路 - 修复同名视图定位时优先当前数据库 schema 的匹配逻辑
This commit is contained in:
@@ -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: "🪵",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user