修复: 高风险工具名单改注册表投影
新增 ai_list_tools 只读投影 name+risk,前端启动拉取构建 手写名单缺 10 个 High 工具致审批二次确认漏弹,现名单改兜底保留 顺带删除零调用方 get_script_safety 死命令及注册
This commit is contained in:
@@ -24,6 +24,8 @@ use tauri::State;
|
|||||||
// 来自 commands/ai/skills.rs,本域 IPC 复用,不重复定义。
|
// 来自 commands/ai/skills.rs,本域 IPC 复用,不重复定义。
|
||||||
use super::super::skills::{invalidate_skills, skills_cached, SkillInfo};
|
use super::super::skills::{invalidate_skills, skills_cached, SkillInfo};
|
||||||
|
|
||||||
|
use df_ai::ai_tools::RiskLevel;
|
||||||
|
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
/// 列出本机 Claude 技能(skills + commands + plugins 三类),供前端 `/` 联想
|
/// 列出本机 Claude 技能(skills + commands + plugins 三类),供前端 `/` 联想
|
||||||
@@ -128,3 +130,37 @@ pub async fn ai_set_allowed_dirs(
|
|||||||
) -> Result<Vec<String>, String> {
|
) -> Result<Vec<String>, String> {
|
||||||
state.set_allowed_dirs(dirs).await.map_err(|e| e.to_string())
|
state.set_allowed_dirs(dirs).await.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// AI 工具风险元数据投影(只读,前端审批二次确认门控的数据源)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/// 单个 AI 工具的风险元数据(来自 AiToolRegistry 注册时声明的 risk_level)。
|
||||||
|
/// risk 沿用 RiskLevel 的 serde 序列化(小写 low/medium/high,与 audit risk_str 同口径)。
|
||||||
|
#[derive(serde::Serialize)]
|
||||||
|
pub struct AiToolMeta {
|
||||||
|
pub name: String,
|
||||||
|
pub risk: RiskLevel,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 列出全部已注册 AI 工具的名称与风险级。
|
||||||
|
///
|
||||||
|
/// 唯一消费方:前端启动(AiChat onMounted)拉取后构建 High 风险名单,
|
||||||
|
/// 供审批二次确认门控(useToolApproval)。数据源 = 工具注册表元数据,
|
||||||
|
/// 根治前端手写名单与 tools/*.rs 分散注册的双写漂移(新增 High 工具免改前端)。
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn ai_list_tools(state: State<'_, AppState>) -> Result<Vec<AiToolMeta>, String> {
|
||||||
|
let registry = state.ai_tools.as_ref();
|
||||||
|
// tool_names 拿 owned 键再 get 取 risk(不给 df-ai 加迭代器导出);排序稳定输出(HashMap 遍历序随机)
|
||||||
|
let mut metas: Vec<AiToolMeta> = registry
|
||||||
|
.tool_names()
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|name| {
|
||||||
|
registry
|
||||||
|
.get(&name)
|
||||||
|
.map(|t| AiToolMeta { name, risk: t.risk_level })
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
metas.sort_by(|a, b| a.name.cmp(&b.name));
|
||||||
|
Ok(metas)
|
||||||
|
}
|
||||||
|
|||||||
@@ -116,17 +116,6 @@ pub async fn set_script_safety(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 读取脚本安全配置。
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn get_script_safety(
|
|
||||||
state: State<'_, AppState>,
|
|
||||||
) -> Result<(String, String), String> {
|
|
||||||
// appSettings.set(JSON.stringify 带引号)与 settingsApi.set(裸串)双通道写入并存,读端归一
|
|
||||||
let wl = state.settings.get("df-script-whitelist").await.map_err(err_str)?.map(|v| v.trim_matches('"').to_string()).unwrap_or_default();
|
|
||||||
let bl = state.settings.get("df-script-blacklist").await.map_err(err_str)?.map(|v| v.trim_matches('"').to_string()).unwrap_or_default();
|
|
||||||
Ok((wl, bl))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 设置多 ReAct Plan 执行开关(默认关)。
|
/// 设置多 ReAct Plan 执行开关(默认关)。
|
||||||
/// 开启后 process_tool_calls 内以 JoinSet 并行执行同层工具。
|
/// 开启后 process_tool_calls 内以 JoinSet 并行执行同层工具。
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
|||||||
@@ -462,6 +462,8 @@ pub fn run() {
|
|||||||
// F-260619-03 Phase A: AI 工具文件访问授权目录白名单
|
// F-260619-03 Phase A: AI 工具文件访问授权目录白名单
|
||||||
commands::ai::ai_get_allowed_dirs,
|
commands::ai::ai_get_allowed_dirs,
|
||||||
commands::ai::ai_set_allowed_dirs,
|
commands::ai::ai_set_allowed_dirs,
|
||||||
|
// 工具风险元数据投影(前端启动构建 High 风险名单,审批二次确认门控)
|
||||||
|
commands::ai::ai_list_tools,
|
||||||
// F-260619-03 Phase B: 路径授权弹窗决策(消费 AiDirAuthRequired 挂起)
|
// F-260619-03 Phase B: 路径授权弹窗决策(消费 AiDirAuthRequired 挂起)
|
||||||
commands::ai::ai_authorize_dir,
|
commands::ai::ai_authorize_dir,
|
||||||
// 审批历史面板查 ai_tool_executions 表,敏感字段截断)
|
// 审批历史面板查 ai_tool_executions 表,敏感字段截断)
|
||||||
@@ -495,7 +497,6 @@ pub fn run() {
|
|||||||
commands::settings::ai_set_approval_timeout,
|
commands::settings::ai_set_approval_timeout,
|
||||||
// ScriptNode 命令安全配置(白/黑名单,前端设置页写入)
|
// ScriptNode 命令安全配置(白/黑名单,前端设置页写入)
|
||||||
commands::settings::set_script_safety,
|
commands::settings::set_script_safety,
|
||||||
commands::settings::get_script_safety,
|
|
||||||
// Plan 执行开关
|
// Plan 执行开关
|
||||||
commands::settings::set_plan_execution,
|
commands::settings::set_plan_execution,
|
||||||
commands::settings::get_plan_execution,
|
commands::settings::get_plan_execution,
|
||||||
|
|||||||
+6
-1
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { invoke } from '@tauri-apps/api/core'
|
import { invoke } from '@tauri-apps/api/core'
|
||||||
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
|
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
|
||||||
import type { AiChatEvent, AiConversationDetail, AiConversationSummary, AiProviderConfig, ContentPart, MentionSpan, ModelConfig, SkillInfo } from './types'
|
import type { AiChatEvent, AiConversationDetail, AiConversationSummary, AiProviderConfig, AiToolMeta, ContentPart, MentionSpan, ModelConfig, SkillInfo } from './types'
|
||||||
|
|
||||||
export const aiApi = {
|
export const aiApi = {
|
||||||
/**
|
/**
|
||||||
@@ -272,6 +272,11 @@ export const aiApi = {
|
|||||||
return invoke<string[]>('ai_get_allowed_dirs')
|
return invoke<string[]>('ai_get_allowed_dirs')
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** 列出全部已注册 AI 工具的名称与风险级(审批高风险名单的权威数据源) */
|
||||||
|
listTools(): Promise<AiToolMeta[]> {
|
||||||
|
return invoke('ai_list_tools')
|
||||||
|
},
|
||||||
|
|
||||||
/** 设置 AI 工具授权目录(持久化 + 同步内存白名单),返回规范化后的列表 */
|
/** 设置 AI 工具授权目录(持久化 + 同步内存白名单),返回规范化后的列表 */
|
||||||
setAllowedDirs(dirs: string[]): Promise<string[]> {
|
setAllowedDirs(dirs: string[]): Promise<string[]> {
|
||||||
return invoke<string[]>('ai_set_allowed_dirs', { dirs })
|
return invoke<string[]>('ai_set_allowed_dirs', { dirs })
|
||||||
|
|||||||
@@ -778,6 +778,12 @@ export interface SkillInfo {
|
|||||||
path: string
|
path: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** AI 工具风险元数据(ai_list_tools 投影;risk 对齐后端 RiskLevel serde 小写) */
|
||||||
|
export interface AiToolMeta {
|
||||||
|
name: string
|
||||||
|
risk: 'low' | 'medium' | 'high'
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// 知识库
|
// 知识库
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
@@ -498,6 +498,7 @@ onMounted(async () => {
|
|||||||
await store.loadProviders()
|
await store.loadProviders()
|
||||||
await store.loadConversations()
|
await store.loadConversations()
|
||||||
store.loadSkills()
|
store.loadSkills()
|
||||||
|
store.loadToolRisks()
|
||||||
// @ 提及预加载: AiChat 的 @ 联想读取 projectsStore.projects/tasks,
|
// @ 提及预加载: AiChat 的 @ 联想读取 projectsStore.projects/tasks,
|
||||||
// 但 loadProjects/loadTasks 分别绑在 Projects.vue/Tasks.vue 的 onMounted。
|
// 但 loadProjects/loadTasks 分别绑在 Projects.vue/Tasks.vue 的 onMounted。
|
||||||
// 若用户启动后直接用 AI Chat(未访问过这两个页面), store 数据为空 → @ 无匹配。
|
// 若用户启动后直接用 AI Chat(未访问过这两个页面), store 数据为空 → @ 无匹配。
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { aiApi } from '@/api'
|
|||||||
import { useAppSettingsStore } from '@/stores/appSettings'
|
import { useAppSettingsStore } from '@/stores/appSettings'
|
||||||
import { state } from '@/stores/ai'
|
import { state } from '@/stores/ai'
|
||||||
import { setStreaming } from './streamingGuard'
|
import { setStreaming } from './streamingGuard'
|
||||||
|
import { syncHighRiskTools } from './useToolApproval'
|
||||||
import { nextMsgId } from './aiShared'
|
import { nextMsgId } from './aiShared'
|
||||||
import { t } from '@/i18n/i18n-helpers'
|
import { t } from '@/i18n/i18n-helpers'
|
||||||
import type { AiMessage, AiProviderConfig } from '@/api/types'
|
import type { AiMessage, AiProviderConfig } from '@/api/types'
|
||||||
@@ -158,6 +159,15 @@ async function loadSkills() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 拉取工具风险元数据,构建 High 风险名单(审批二次确认门控的权威源;静默失败走 useToolApproval 兜底名单) */
|
||||||
|
async function loadToolRisks() {
|
||||||
|
try {
|
||||||
|
syncHighRiskTools(await aiApi.listTools())
|
||||||
|
} catch {
|
||||||
|
// 静默失败:e2e 垫片/老后端无此 IPC 时兜底名单仍在,不影响主流程
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** 切换活跃 provider(后端 + 本地 state + 持久化) */
|
/** 切换活跃 provider(后端 + 本地 state + 持久化) */
|
||||||
async function setProvider(providerId: string) {
|
async function setProvider(providerId: string) {
|
||||||
await aiApi.setProvider(providerId)
|
await aiApi.setProvider(providerId)
|
||||||
@@ -199,6 +209,7 @@ export function useAiPanel() {
|
|||||||
cyclePanelMode,
|
cyclePanelMode,
|
||||||
loadProviders,
|
loadProviders,
|
||||||
loadSkills,
|
loadSkills,
|
||||||
|
loadToolRisks,
|
||||||
setProvider,
|
setProvider,
|
||||||
clearChat,
|
clearChat,
|
||||||
// 侧栏宽度拖拽
|
// 侧栏宽度拖拽
|
||||||
|
|||||||
@@ -22,12 +22,35 @@ import type { AiToolCallInfo } from '@/api/types'
|
|||||||
*/
|
*/
|
||||||
const APPROVE_LOADING_TIMEOUT_MS = 30_000
|
const APPROVE_LOADING_TIMEOUT_MS = 30_000
|
||||||
|
|
||||||
/** High 风险工具白名单(后端 tool_registry.rs RiskLevel::High 对齐) */
|
/**
|
||||||
const HIGH_RISK_TOOLS = new Set<string>([
|
* High 风险工具「兜底」名单——仅在后端工具元数据不可用时生效
|
||||||
|
* (启动早期尚未拉到 ai_list_tools / e2e 浏览器垫片无此 IPC)。
|
||||||
|
* 权威来源是后端工具注册表 risk_level:启动后经 syncHighRiskTools 整体覆盖,
|
||||||
|
* 不再手工对齐 tools/*.rs 分散注册(原双写已漂移:手写 7 项,后端 High 实际 17 项)。
|
||||||
|
*/
|
||||||
|
const HIGH_RISK_TOOLS_FALLBACK = new Set<string>([
|
||||||
'delete_task', 'delete_project', 'restore_project', 'purge_project', 'delete_file', 'run_command',
|
'delete_task', 'delete_project', 'restore_project', 'purge_project', 'delete_file', 'run_command',
|
||||||
'http_request',
|
'http_request',
|
||||||
])
|
])
|
||||||
|
|
||||||
|
/** 后端注册表投影的 High 风险名单;null = 尚未拉到元数据(走兜底名单) */
|
||||||
|
let registryHighRisk: Set<string> | null = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用后端 ai_list_tools 元数据构建 High 风险名单(权威源,整体覆盖兜底)。
|
||||||
|
* 空结果不覆盖:注册表为空/数据异常时保留兜底名单,安全侧不放松。
|
||||||
|
* 供启动初始化调用(useAiPanel.loadToolRisks),幂等可重复调。
|
||||||
|
*/
|
||||||
|
export function syncHighRiskTools(tools: { name: string; risk: string }[]): void {
|
||||||
|
const high = tools.filter((t) => t.risk === 'high').map((t) => t.name)
|
||||||
|
if (high.length > 0) registryHighRisk = new Set(high)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 审批二次确认门控:优先注册表投影,未拉到元数据时退兜底名单 */
|
||||||
|
function isHighRiskTool(name: string): boolean {
|
||||||
|
return (registryHighRisk ?? HIGH_RISK_TOOLS_FALLBACK).has(name)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 审批 IPC 分派(共享):按 tc.kind 决定调 aiApi.authorizeDir(path 类)或 aiApi.approve(risk 类)。
|
* 审批 IPC 分派(共享):按 tc.kind 决定调 aiApi.authorizeDir(path 类)或 aiApi.approve(risk 类)。
|
||||||
*
|
*
|
||||||
@@ -150,7 +173,7 @@ export function useToolApproval(tc: Ref<AiToolCallInfo>, emit: ToolApprovalEmit)
|
|||||||
approving.value = true
|
approving.value = true
|
||||||
try {
|
try {
|
||||||
// High 风险 → 先二次确认
|
// High 风险 → 先二次确认
|
||||||
if (HIGH_RISK_TOOLS.has(tc.value.name)) {
|
if (isHighRiskTool(tc.value.name)) {
|
||||||
const ok = await confirmDialog(highRiskConfirmMsg(tc.value.name))
|
const ok = await confirmDialog(highRiskConfirmMsg(tc.value.name))
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
approving.value = false // 取消二次确认 → 解锁
|
approving.value = false // 取消二次确认 → 解锁
|
||||||
|
|||||||
Reference in New Issue
Block a user