From 968c3df5ab8fee9c146815da548cffee97fa9c25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BB=9D=E5=B0=98?= <237809796@qq.com> Date: Fri, 19 Jun 2026 16:41:45 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E:=20F-260619-03=20PhaseA=20AI?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=E6=96=87=E4=BB=B6=E8=AE=BF=E9=97=AE=E5=8A=A8?= =?UTF-8?q?=E6=80=81=E6=9D=83=E9=99=90=E7=99=BD=E5=90=8D=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - state.rs: AllowedDirs(persistent HashSet + is_authorized workspace_root+starts_with + canonicalize) + AppState allowed_dirs Arc + reload/set/get Settings KV - tool_registry.rs: resolve_workspace_path 多目录白名单双校验(词法+canonicalize) + handler闭包引入AllowedDirs Arc(9文件工具) + run_command不走白名单 - commands/config.rs: ai_get/set_allowed_dirs IPC + lib.rs注册 - 前端: api/ai.ts + AllowedDirsPanel.vue(Settings授权目录UI) + i18n - 测试: 5 AllowedDirs单测 + 基线适配(29工具不变) workspace_root始终授权(零回归); Phase A仅持久化(不含B弹窗/C写约束) 主代兜底: cargo check devflow 0 + test 129 + vue-tsc 0 --- src-tauri/src/commands/ai/commands/config.rs | 28 ++ src-tauri/src/commands/ai/tool_registry.rs | 290 ++++++++++++------- src-tauri/src/lib.rs | 3 + src-tauri/src/state.rs | 216 +++++++++++++- src/api/ai.ts | 10 + src/components/settings/AllowedDirsPanel.vue | 168 +++++++++++ src/i18n/en/settings.ts | 10 + src/i18n/zh-CN/settings.ts | 10 + src/views/Settings.vue | 4 + 9 files changed, 633 insertions(+), 106 deletions(-) create mode 100644 src/components/settings/AllowedDirsPanel.vue diff --git a/src-tauri/src/commands/ai/commands/config.rs b/src-tauri/src/commands/ai/commands/config.rs index 91b94e3..5788e04 100644 --- a/src-tauri/src/commands/ai/commands/config.rs +++ b/src-tauri/src/commands/ai/commands/config.rs @@ -87,3 +87,31 @@ pub async fn ai_set_agent_max_retries( state.agent_max_retries.store(clamped, Ordering::SeqCst); Ok(()) } + +// ============================================================ +// F-260619-03 Phase A: AI 工具文件访问授权目录白名单 IPC +// +// 持久化到 app_settings KV key=`allowed_dirs`(JSON 字符串数组), +// 内存 AppState.allowed_dirs 同步。workspace_root 始终在白名单(向后兼容)。 +// resolve_workspace_path_with_allowed 校验:文件工具(read/write/list/patch/...) +// 仅可访问授权目录。Phase A 仅持久化,不挂起弹窗(Phase B)。 +// ============================================================ + +/// 获取 AI 工具授权目录列表(含 workspace_root,规范化字符串)。 +#[tauri::command] +pub async fn ai_get_allowed_dirs(state: State<'_, AppState>) -> Result, String> { + Ok(state.get_allowed_dirs().await) +} + +/// 设置 AI 工具授权目录列表(持久化 Settings KV + 同步内存白名单)。 +/// +/// 传入目录字符串数组(去空去重);后端 canonicalize 规范化后持久化。 +/// workspace_root 始终隐式在白名单(is_authorized 内置),无需前端显式传入。 +/// 返回持久化后内存白名单的规范化字符串列表(供前端回显)。 +#[tauri::command] +pub async fn ai_set_allowed_dirs( + state: State<'_, AppState>, + dirs: Vec, +) -> Result, String> { + state.set_allowed_dirs(dirs).await.map_err(|e| e.to_string()) +} diff --git a/src-tauri/src/commands/ai/tool_registry.rs b/src-tauri/src/commands/ai/tool_registry.rs index 93d7e74..b506b49 100644 --- a/src-tauri/src/commands/ai/tool_registry.rs +++ b/src-tauri/src/commands/ai/tool_registry.rs @@ -4,7 +4,7 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::{Arc, LazyLock}; -use tokio::sync::Mutex as TokioMutex; +use tokio::sync::{Mutex as TokioMutex, RwLock}; use df_ai::ai_tools::{AiToolRegistry, RiskLevel}; use df_execute::shell::{execute, ShellRequest}; @@ -14,6 +14,7 @@ use df_storage::models::{ProjectRecord, TaskRecord, IdeaRecord}; use df_types::types::new_id; use crate::commands::now_millis; +use crate::state::AllowedDirs; /// CRUD list 工具的默认返回上限(防 LLM context 膨胀) /// 用于 list_projects / list_tasks / list_ideas / list_trash @@ -282,21 +283,36 @@ fn workspace_root() -> PathBuf { /// 解析文件工具路径:相对路径锚定 workspace_root,禁止越出项目目录 /// /// 双层校验: -/// 1. 词法层 starts_with(root)——对不存在路径(write_file 新建文件)兜底防越界 +/// 1. 词法层 is_authorized——对不存在路径(write_file 新建文件)兜底防越界 /// 2. canonicalize 层——对存在路径解析符号链接,防 workspace 内 symlink 指向外部的逃逸 /// 仅校验,返回词法 resolved(不含 \\?\ 前缀),保证 read_file 返回的 path 对前端友好 -fn resolve_workspace_path(path: &str) -> anyhow::Result { - resolve_workspace_path_impl(path) +/// +/// F-260619-03 Phase A: 引入 AllowedDirs 白名单(动态多目录授权)。 +/// - `resolve_workspace_path_with_allowed(path, &AllowedDirs)`: 文件工具闭包用, +/// 走完整白名单校验(workspace_root 始终授权 + persistent 任一命中)。 +/// - `resolve_workspace_path_pub(path)`: trust_key_for 计算 TrustKey 目录归一化用, +/// 无白名单上下文,用 default_with_root(仅 workspace_root),零回归。 +/// 闭包捕获 `Arc>` 后 read lock 取快照传入本函数。 +fn resolve_workspace_path_with_allowed( + path: &str, + allowed: &AllowedDirs, +) -> anyhow::Result { + resolve_workspace_path_impl(path, allowed) } /// AE-2025-04:mod.rs trust_key_for 计算 write_file 的 TrustKey 目录粒度时复用本函数 /// 归一化路径(去 .. / symlink 逃逸)。pub(crate) wrapper 转调私有 impl,保持原私有函数 /// 可见性边界(不暴露给 crate 外,但允许同 crate ai 模块 mod.rs 调用)。 +/// +/// F-260619-03 Phase A: trust_key 计算无需白名单上下文(只是目录归一化作 HashSet key), +/// 用 default_with_root(仅 workspace_root),零回归。白名单校验由文件工具 handler 在 +/// 实际执行时走 resolve_workspace_path_with_allowed,信任键计算的越界失败不影响 +/// (trust_key_for 已对 Err 回退 workspace_root_str 保守默认)。 pub(crate) fn resolve_workspace_path_pub(path: &str) -> anyhow::Result { - resolve_workspace_path_impl(path) + resolve_workspace_path_impl(path, &AllowedDirs::default_with_root()) } -fn resolve_workspace_path_impl(path: &str) -> anyhow::Result { +fn resolve_workspace_path_impl(path: &str, allowed: &AllowedDirs) -> anyhow::Result { validate_path(path)?; let root = workspace_root(); let resolved = if Path::new(path).is_absolute() { @@ -304,16 +320,19 @@ fn resolve_workspace_path_impl(path: &str) -> anyhow::Result { } else { root.join(path) }; - // 词法层:防明显越界(不存在路径的兜底) - if !resolved.starts_with(&root) { - anyhow::bail!("禁止访问项目目录之外: {}", path); + // 词法层:防明显越界(不存在路径的兜底)。 + // F-260619-03 Phase A: 单根 starts_with 升级为 AllowedDirs 白名单多目录校验 + // (workspace_root 始终在白名单 → 旧单根路径零回归;新授权目录命中放行)。 + if !allowed.is_authorized(&resolved) { + anyhow::bail!("路径不在授权目录内: {}", path); } - // canonicalize 层:存在路径解析 symlink,防经符号链接逃逸出 workspace + // canonicalize 层:存在路径解析 symlink,防经符号链接逃逸出授权目录。 + // 用 canonicalize 后的真实路径再做一次 is_authorized 校验(防 workspace 内 symlink + // 指向白名单外目录的逃逸)。default_with_root 时等价旧 root.canonicalize 比对。 if resolved.exists() { - let canon_root = root.canonicalize()?; let canon_resolved = resolved.canonicalize()?; - if !canon_resolved.starts_with(&canon_root) { - anyhow::bail!("禁止访问项目目录之外(符号链接逃逸): {}", path); + if !allowed.is_authorized(&canon_resolved) { + anyhow::bail!("路径不在授权目录内(符号链接逃逸): {}", path); } } Ok(resolved) @@ -364,10 +383,17 @@ async fn bind_dir_to_project( /// - data 层(register_data_tools):18 个持 db 的 CRUD/状态机/工作流工具,闭包捕获 db Arc 重建 Repo。 /// - file 层(register_file_tools):10 个纯文件系统工具,不持 db。 /// 新增工具改对应 register_* 一处,定义与实现同源,编译期保证一致。 -pub fn build_ai_tool_registry(db: &Arc) -> AiToolRegistry { +/// +/// F-260619-03 Phase A: 新增 `allowed_dirs: &Arc>` 形参, +/// 文件工具闭包 clone 进闭包,在 resolve_workspace_path_with_allowed 校验动态白名单。 +/// data/http 工具不涉及文件路径,不传白名单。 +pub fn build_ai_tool_registry( + db: &Arc, + allowed_dirs: &Arc>, +) -> AiToolRegistry { let mut registry = AiToolRegistry::new(); register_data_tools(&mut registry, db); - register_file_tools(&mut registry); + register_file_tools(&mut registry, allowed_dirs); register_http_tools(&mut registry); registry } @@ -834,7 +860,18 @@ fn register_trash_tools(registry: &mut AiToolRegistry, db: &Arc) { /// 文件系统类 AI 工具注册(命令执行/读/列/写/改/元/追加/删/移/搜)——不持 db,纯文件系统操作。 /// 从 build_ai_tool_registry 抽出(SMELL-P0-2:1091行单函数拆分,build 1090→~390 行)。 -fn register_file_tools(registry: &mut AiToolRegistry) { +/// +/// F-260619-03 Phase A: 各文件工具闭包捕获 `allowed_dirs: Arc>`, +/// handler 执行时 read lock 取白名单快照,resolve_workspace_path_with_allowed 校验动态白名单。 +/// Phase A 仅持久化白名单(workspace_root 始终授权,无 allowed_dirs 配置时行为不变)。 +fn register_file_tools( + registry: &mut AiToolRegistry, + allowed_dirs: &Arc>, +) { + // F-260619-03 Phase A: Arc clone 进各文件工具闭包(handler 执行时 read lock 取白名单快照)。 + // run_command 不走白名单(High risk 靠人工审批兜底,放开目录让 AI 在用户任意项目目录闭环), + // 故该工具闭包不捕获 allowed_dirs,维持原行为。 + let allowed_dirs = allowed_dirs.clone(); registry.register( "run_command", "在指定工作目录执行 shell 命令(跑测试/构建/查看运行结果),返回 stdout/stderr/exit_code。高风险,须人工批准。命令需自包含(非交互式,避免需用户输入的程序)。默认超时 60 秒。用于验证刚写入的代码能否运行、跑测试、看报错迭代修改。", df_ai::ai_tools::object_schema(vec![ @@ -908,12 +945,16 @@ fn register_file_tools(registry: &mut AiToolRegistry) { "read_file", "读取文件内容,返回文本内容。支持 offset/limit 分页;传入 search 则在文件内容中搜索匹配行(大小写敏感,字符串包含匹配),返回 matches 数组(限50条)", df_ai::ai_tools::object_schema(vec![("path", "string", true), ("offset", "integer", false), ("limit", "integer", false), ("search", "string", false)]), RiskLevel::Low, - Box::new(|args: serde_json::Value| Box::pin(async move { - let resolved = resolve_workspace_path( - args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?, - )?; - let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?; - // 单次 File::open 取 metadata+read 消除 TOCTOU(FR-S2:原 metadata+read 两步 syscall 间 symlink 替换破 1MB 限) + { let allowed_dirs = allowed_dirs.clone(); Box::new(move |args: serde_json::Value| { + let allowed_dirs = allowed_dirs.clone(); + Box::pin(async move { + let snap = allowed_dirs.read().await.clone(); + let resolved = resolve_workspace_path_with_allowed( + args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?, + &snap, + )?; + let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?; + // 单次 File::open 取 metadata+read 消除 TOCTOU(FR-S2:原 metadata+read 两步 syscall 间 symlink 替换破 1MB 限) use tokio::fs::File; use tokio::io::AsyncReadExt; let mut file = File::open(path).await @@ -983,26 +1024,32 @@ fn register_file_tools(registry: &mut AiToolRegistry) { "returned_lines": result.lines().count(), "has_more": has_more, })) - })), + }) + })}, ); registry.register( "list_directory", "列出目录内容,返回文件和子目录列表(名称、类型、大小)", df_ai::ai_tools::object_schema(vec![("path", "string", true), ("recursive", "boolean", false), ("skip_noise_dirs", "boolean", false), ("max_depth", "integer", false)]), RiskLevel::Low, - Box::new(|args: serde_json::Value| Box::pin(async move { - let resolved = resolve_workspace_path( - args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?, - )?; - let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?; - let recursive = args["recursive"].as_bool().unwrap_or(false); - let skip_noise = args["skip_noise_dirs"].as_bool().unwrap_or(true); - // BUG-260617-09: max_depth 由 LLM 参数控制,无上限时虽 entries 上限(1000) - // 隐式约束,但深递归仍可能大量 fs IO / 撑爆上下文。clamp 到合理范围 1-10。 - let max_depth = args["max_depth"].as_u64().unwrap_or(3).clamp(1, 10) as usize; - let mut entries = Vec::new(); - let truncated = list_dir_recursive(path, recursive, 0, max_depth, 1000, skip_noise, &mut entries).await?; - Ok(serde_json::json!({ "path": path, "entries": entries, "truncated": truncated })) - })), + { let allowed_dirs = allowed_dirs.clone(); Box::new(move |args: serde_json::Value| { + let allowed_dirs = allowed_dirs.clone(); + Box::pin(async move { + let snap = allowed_dirs.read().await.clone(); + let resolved = resolve_workspace_path_with_allowed( + args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?, + &snap, + )?; + let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?; + let recursive = args["recursive"].as_bool().unwrap_or(false); + let skip_noise = args["skip_noise_dirs"].as_bool().unwrap_or(true); + // BUG-260617-09: max_depth 由 LLM 参数控制,无上限时虽 entries 上限(1000) + // 隐式约束,但深递归仍可能大量 fs IO / 撑爆上下文。clamp 到合理范围 1-10。 + let max_depth = args["max_depth"].as_u64().unwrap_or(3).clamp(1, 10) as usize; + let mut entries = Vec::new(); + let truncated = list_dir_recursive(path, recursive, 0, max_depth, 1000, skip_noise, &mut entries).await?; + Ok(serde_json::json!({ "path": path, "entries": entries, "truncated": truncated })) + }) + })}, ); registry.register( "write_file", "写入或创建文件,自动创建不存在的父目录。支持 encoding=\"base64\" 写入二进制/非 UTF-8 内容(图片/PDF/Excel 等),content 传 base64 编码字符串;默认 encoding=\"utf-8\" 传纯文本", @@ -1012,12 +1059,16 @@ fn register_file_tools(registry: &mut AiToolRegistry) { ("encoding", "string", false), ]), RiskLevel::Medium, - Box::new(|args: serde_json::Value| Box::pin(async move { - let resolved = resolve_workspace_path( - args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?, - )?; - let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?; - let raw = args["content"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 content 参数"))?; + { let allowed_dirs = allowed_dirs.clone(); Box::new(move |args: serde_json::Value| { + let allowed_dirs = allowed_dirs.clone(); + Box::pin(async move { + let snap = allowed_dirs.read().await.clone(); + let resolved = resolve_workspace_path_with_allowed( + args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?, + &snap, + )?; + let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?; + let raw = args["content"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 content 参数"))?; // F-260615-11: encoding 参数支持 base64,写二进制(图片/PDF/Excel 等) // 默认 utf-8:走原 &str 字节写入;base64:解码为 Vec 写字节 let encoding = args.get("encoding").and_then(|v| v.as_str()).unwrap_or("utf-8"); @@ -1079,7 +1130,8 @@ fn register_file_tools(registry: &mut AiToolRegistry) { } } Ok(serde_json::json!({ "path": path, "bytes_written": bytes.len(), "old_size": old_size, "encoding": encoding })) - })), + }) + })}, ); // ── 局部文件编辑 (Medium risk) ── @@ -1121,13 +1173,17 @@ fn register_file_tools(registry: &mut AiToolRegistry) { "patch_file", "局部更新文件内容(三模式互斥)。模式1 old_text:精确匹配原文替换(含空格/缩进,CAS 语义)。模式2 replace_lines:按行号区间 {start,end}(1-based 含首尾)替换,不需原文,配 expected_hash 防行号漂移。模式3 anchor:按首尾子串锚点 {start,end}(大小写敏感,子串匹配)定位行号区间替换,不需完整原文。三模式均需 path+new_text,expected_hash 可选通用。属 Medium 风险操作(修改已有文件),需人工审批。注意:若文件已被外部修改,请先重新 read_file 获取最新内容", patch_file_schema, RiskLevel::Medium, - Box::new(|args: serde_json::Value| Box::pin(async move { - let resolved = resolve_workspace_path( - args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?, - )?; - let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?; - let new_text = args["new_text"].as_str() - .ok_or_else(|| anyhow::anyhow!("缺少 new_text 参数"))?; + { let allowed_dirs = allowed_dirs.clone(); Box::new(move |args: serde_json::Value| { + let allowed_dirs = allowed_dirs.clone(); + Box::pin(async move { + let snap = allowed_dirs.read().await.clone(); + let resolved = resolve_workspace_path_with_allowed( + args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?, + &snap, + )?; + let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?; + let new_text = args["new_text"].as_str() + .ok_or_else(|| anyhow::anyhow!("缺少 new_text 参数"))?; // F-260617-01: 三模式互斥校验(old_text / replace_lines / anchor) // 三选一:统计传入的模式参数数,>1 报错,0 报错(缺定位方式) @@ -1280,7 +1336,8 @@ fn register_file_tools(registry: &mut AiToolRegistry) { result["warning"] = serde_json::Value::String(w); } Ok(result) - })), + }) + })}, ); // ── 文件元信息 (Low risk) ── @@ -1288,14 +1345,18 @@ fn register_file_tools(registry: &mut AiToolRegistry) { "file_info", "获取文件或目录的元信息(是否存在、大小、行数、修改时间、是否二进制、是否目录),不读取文件内容", df_ai::ai_tools::object_schema(vec![("path", "string", true)]), RiskLevel::Low, - Box::new(|args: serde_json::Value| Box::pin(async move { - let resolved = resolve_workspace_path( - args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?, - )?; - let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?; - let p = std::path::Path::new(path); - if !p.exists() { - return Ok(serde_json::json!({ "path": path, "exists": false })); + { let allowed_dirs = allowed_dirs.clone(); Box::new(move |args: serde_json::Value| { + let allowed_dirs = allowed_dirs.clone(); + Box::pin(async move { + let snap = allowed_dirs.read().await.clone(); + let resolved = resolve_workspace_path_with_allowed( + args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?, + &snap, + )?; + let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?; + let p = std::path::Path::new(path); + if !p.exists() { + return Ok(serde_json::json!({ "path": path, "exists": false })); } let metadata = tokio::fs::metadata(path).await .map_err(|e| anyhow::anyhow!("无法访问 {}: {}", path, e))?; @@ -1329,7 +1390,8 @@ fn register_file_tools(registry: &mut AiToolRegistry) { tokio::fs::read_to_string(path).await.ok().map(|c| c.lines().count() as u64) } else { None }; Ok(serde_json::json!({ "path": path, "exists": true, "size": size, "lines": lines, "modified": modified, "is_binary": is_binary, "is_dir": is_dir })) - })), + }) + })}, ); // ── 追加写入 (Medium risk) ── @@ -1337,12 +1399,16 @@ fn register_file_tools(registry: &mut AiToolRegistry) { "append_file", "向文件末尾追加内容,文件不存在则自动创建。返回写入字数和新文件大小", df_ai::ai_tools::object_schema(vec![("path", "string", true), ("content", "string", true)]), RiskLevel::Medium, - Box::new(|args: serde_json::Value| Box::pin(async move { - let resolved = resolve_workspace_path( - args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?, - )?; - let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?; - let content = args["content"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 content 参数"))?; + { let allowed_dirs = allowed_dirs.clone(); Box::new(move |args: serde_json::Value| { + let allowed_dirs = allowed_dirs.clone(); + Box::pin(async move { + let snap = allowed_dirs.read().await.clone(); + let resolved = resolve_workspace_path_with_allowed( + args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?, + &snap, + )?; + let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?; + let content = args["content"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 content 参数"))?; if let Some(parent) = std::path::Path::new(path).parent() { if !parent.starts_with(&workspace_root()) { anyhow::bail!("禁止在项目目录之外创建目录"); @@ -1358,7 +1424,8 @@ fn register_file_tools(registry: &mut AiToolRegistry) { file.flush().await.map_err(|e| anyhow::anyhow!("刷新失败: {}", e))?; let new_size = tokio::fs::metadata(path).await.map(|m| m.len()).unwrap_or(0); Ok(serde_json::json!({ "path": path, "bytes_written": bytes.len(), "new_size": new_size })) - })), + }) + })}, ); // ── 删除文件 (High risk, F-260615-16) ── @@ -1369,13 +1436,17 @@ fn register_file_tools(registry: &mut AiToolRegistry) { ("permanent", "boolean", false), ]), RiskLevel::High, - Box::new(|args: serde_json::Value| Box::pin(async move { - // validate_path 前置(workspace 内)——resolve_workspace_path 含 validate_path + symlink 防逃逸 - let resolved = resolve_workspace_path( - args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?, - )?; - let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?; - let permanent = args.get("permanent").and_then(|v| v.as_bool()).unwrap_or(false); + { let allowed_dirs = allowed_dirs.clone(); Box::new(move |args: serde_json::Value| { + let allowed_dirs = allowed_dirs.clone(); + Box::pin(async move { + // validate_path 前置(workspace 内)——resolve_workspace_path_with_allowed 含 validate_path + symlink 防逃逸 + let snap = allowed_dirs.read().await.clone(); + let resolved = resolve_workspace_path_with_allowed( + args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?, + &snap, + )?; + let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?; + let permanent = args.get("permanent").and_then(|v| v.as_bool()).unwrap_or(false); let target = std::path::Path::new(path); if !target.exists() { anyhow::bail!("文件不存在: {}", path); @@ -1419,7 +1490,8 @@ fn register_file_tools(registry: &mut AiToolRegistry) { "backup_path": backup_path_str, })) } - })), + }) + })}, ); // ── 重命名/移动文件 (Medium risk, F-260615-15) ── @@ -1433,18 +1505,23 @@ fn register_file_tools(registry: &mut AiToolRegistry) { ("overwrite", "boolean", false), ]), RiskLevel::Medium, - Box::new(|args: serde_json::Value| Box::pin(async move { - // 双向 validate_path(workspace 内)——from/to 都走 resolve_workspace_path, - // 各自含 validate_path(黑名单+.. 防遍历) + 词法 starts_with + canonicalize symlink 防逃逸 - let from_resolved = resolve_workspace_path( - args["from"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 from 参数"))?, - )?; - let from_path = from_resolved.to_str().ok_or_else(|| anyhow::anyhow!("from 路径含非法字符"))?; - let to_resolved = resolve_workspace_path( - args["to"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 to 参数"))?, - )?; - let to_path = to_resolved.to_str().ok_or_else(|| anyhow::anyhow!("to 路径含非法字符"))?; - let overwrite = args.get("overwrite").and_then(|v| v.as_bool()).unwrap_or(false); + { let allowed_dirs = allowed_dirs.clone(); Box::new(move |args: serde_json::Value| { + let allowed_dirs = allowed_dirs.clone(); + Box::pin(async move { + // 双向 validate_path(workspace 内)——from/to 都走 resolve_workspace_path_with_allowed, + // 各自含 validate_path(黑名单+.. 防遍历) + 词法 starts_with + canonicalize symlink 防逃逸 + let snap = allowed_dirs.read().await.clone(); + let from_resolved = resolve_workspace_path_with_allowed( + args["from"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 from 参数"))?, + &snap, + )?; + let from_path = from_resolved.to_str().ok_or_else(|| anyhow::anyhow!("from 路径含非法字符"))?; + let to_resolved = resolve_workspace_path_with_allowed( + args["to"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 to 参数"))?, + &snap, + )?; + let to_path = to_resolved.to_str().ok_or_else(|| anyhow::anyhow!("to 路径含非法字符"))?; + let overwrite = args.get("overwrite").and_then(|v| v.as_bool()).unwrap_or(false); // 源存在性 + 拒目录(rename 目录语义复杂递归风险大,留 run_command 兜底) let from_target = std::path::Path::new(from_path); @@ -1512,7 +1589,8 @@ fn register_file_tools(registry: &mut AiToolRegistry) { "bytes_moved": bytes_moved, "cross_volume": true, })) - })), + }) + })}, ); // ── 文件搜索 (Low risk) ── @@ -1520,17 +1598,21 @@ fn register_file_tools(registry: &mut AiToolRegistry) { "search_files", "在指定目录下搜索匹配模式(字符串包含匹配)的文件名,支持 offset/limit 分页。返回 results、total、has_more。默认 limit=50", df_ai::ai_tools::object_schema(vec![("path", "string", true), ("pattern", "string", true), ("recursive", "boolean", false), ("offset", "integer", false), ("limit", "integer", false)]), RiskLevel::Low, - Box::new(|args: serde_json::Value| Box::pin(async move { - let resolved = resolve_workspace_path( - args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?, - )?; - let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?; - let pattern = args["pattern"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 pattern 参数"))?; - let recursive = args["recursive"].as_bool().unwrap_or(false); - let pattern_lower = pattern.to_lowercase(); - const MAX_RESULTS: usize = 50; - let offset = args["offset"].as_u64().unwrap_or(0) as usize; - let limit = args["limit"].as_u64().unwrap_or(MAX_RESULTS as u64).min(MAX_RESULTS as u64) as usize; + { let allowed_dirs = allowed_dirs.clone(); Box::new(move |args: serde_json::Value| { + let allowed_dirs = allowed_dirs.clone(); + Box::pin(async move { + let snap = allowed_dirs.read().await.clone(); + let resolved = resolve_workspace_path_with_allowed( + args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?, + &snap, + )?; + let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?; + let pattern = args["pattern"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 pattern 参数"))?; + let recursive = args["recursive"].as_bool().unwrap_or(false); + let pattern_lower = pattern.to_lowercase(); + const MAX_RESULTS: usize = 50; + let offset = args["offset"].as_u64().unwrap_or(0) as usize; + let limit = args["limit"].as_u64().unwrap_or(MAX_RESULTS as u64).min(MAX_RESULTS as u64) as usize; // 先收集全部结果用于 total 计数,再做 skip/take 分页 let mut all_results = Vec::new(); let mut total = 0u64; @@ -1539,7 +1621,8 @@ fn register_file_tools(registry: &mut AiToolRegistry) { let page_results: Vec<_> = all_results.into_iter().skip(offset).take(limit).collect(); let has_more = (offset + page_results.len()) < total as usize; Ok(serde_json::json!({ "path": path, "pattern": pattern, "results": page_results, "total": total, "has_more": has_more })) - })), + }) + })}, ); } @@ -1736,7 +1819,10 @@ mod tests { async fn test_build_ai_tool_registry_baseline_tool_count() { let db = Database::open_in_memory().await.expect("in-memory db 初始化失败"); let db = Arc::new(db); - let registry = build_ai_tool_registry(&db); + // F-260619-03 Phase A: build_ai_tool_registry 新增 allowed_dirs 形参, + // 测试用 default_with_root(仅 workspace_root),零回归(白名单含 workspace_root)。 + let allowed_dirs = Arc::new(RwLock::new(AllowedDirs::default_with_root())); + let registry = build_ai_tool_registry(&db, &allowed_dirs); // 总量基线:29(18 data + 10 file + 1 http)。拆分前后必须一致。 assert_eq!( diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9fec388..d50e0c1 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -164,6 +164,9 @@ pub fn run() { commands::ai::ai_set_concurrency_config, commands::ai::ai_set_agent_max_iterations, commands::ai::ai_set_agent_max_retries, + // F-260619-03 Phase A: AI 工具文件访问授权目录白名单 + commands::ai::ai_get_allowed_dirs, + commands::ai::ai_set_allowed_dirs, // 审批历史面板(AE-2025-08:查 ai_tool_executions 表,敏感字段截断) commands::ai::audit::list_tool_executions, // 知识库 diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index dc4382d..868a8c6 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -1,13 +1,13 @@ //! 应用全局状态 — 数据库、Repo、事件总线、节点注册表、AI 会话 -use std::collections::HashMap; -use std::path::Path; +use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; use std::sync::Arc; use std::sync::atomic::AtomicUsize; use anyhow::Result; use serde::{Deserialize, Serialize}; -use tokio::sync::{Mutex, Semaphore}; +use tokio::sync::{Mutex, RwLock, Semaphore}; use df_ai::ai_tools::AiToolRegistry; use df_storage::crud::{ @@ -297,13 +297,73 @@ pub struct AppState { /// cancel_workflow_node IPC 经 execution_id 取出后 set_cancelled, /// 直达运行中 HumanNode 的 is_cancelled 轮询。执行完成(成功/失败)后移除条目。 pub workflow_state_registry: Arc>>, + // ── F-260619-03 Phase A: AI 工具文件访问授权目录白名单 ── + /// AI 文件工具(read/write/list/patch/...)可访问的授权目录池。 + /// Phase A 仅持久化白名单(Settings KV `allowed_dirs` 加载), + /// workspace_root 始终在白名单(向后兼容)。 + /// Phase B 将扩展为 persistent + session(会话临时授权) + 弹窗挂起。 + pub allowed_dirs: Arc>, +} + +/// F-260619-03 Phase A: AI 工具文件访问授权目录白名单 +/// +/// Phase A 仅含 `persistent`(持久化白名单,从 Settings KV `allowed_dirs` 加载, +/// JSON 数组 `["E:/wk-lab/u-abc"]`)。`resolve_workspace_path` 校验时: +/// - workspace_root 始终视为已授权(向后兼容,默认根) +/// - 任一 persistent 目录 starts_with 命中即放行 +/// +/// Phase B 将扩展 `session: HashSet`(会话临时授权) + 弹窗挂起机制, +/// 届时 is_authorized 增加 session 校验。当前结构预留扩展位但不引入。 +#[derive(Debug, Clone, Default)] +pub struct AllowedDirs { + /// 持久化授权目录(Settings KV `allowed_dirs`,JSON 字符串数组)。 + /// 已规范化(canonicalize 失败回退原字面量),便于 starts_with 精确比对。 + pub persistent: HashSet, +} + +impl AllowedDirs { + /// Settings KV key:F-260619-03 Phase A 持久化白名单(JSON 字符串数组) + pub const SETTINGS_KEY: &'static str = "allowed_dirs"; + + /// 仅含 workspace_root 的默认白名单(向后兼容:无 allowed_dirs 时行为不变)。 + /// 用于 mod.rs trust_key_for 等无白名单上下文的旧路径(零回归)。 + pub fn default_with_root() -> Self { + let mut set = HashSet::new(); + set.insert(workspace_root_path()); + Self { persistent: set } + } + + /// 路径是否被授权:workspace_root 始终授权 + persistent 任一 starts_with 命中。 + /// + /// 输入 `candidate` 应为 canonicalize 后的真实路径(防 symlink 逃逸); + /// 调用方(resolve_workspace_path_with_allowed)负责 canonicalize,本函数只做 starts_with 比对。 + pub fn is_authorized(&self, candidate: &Path) -> bool { + let root = workspace_root_path(); + if candidate.starts_with(&root) { + return true; + } + self.persistent.iter().any(|d| candidate.starts_with(d)) + } +} + +/// workspace 根目录(src-tauri 上两级,与 tool_registry::workspace_root 同源)。 +fn workspace_root_path() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .and_then(|p| p.parent()) + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(".")) } impl AppState { /// 初始化应用状态:打开(或创建)数据库并执行迁移,构建各 Repo 与节点注册表 pub async fn init(db_path: &Path) -> Result { let db = Arc::new(Database::open(db_path).await?); - let ai_tools = Arc::new(crate::commands::ai::build_ai_tool_registry(&db)); + // F-260619-03 Phase A: build_ai_tool_registry 注入 allowed_dirs Arc, + // 文件工具闭包捕获后 resolve_workspace_path_with_allowed 校验动态白名单。 + // 此处用 default_with_root 占位,下方 init 尾部 reload_allowed_dirs 从 Settings KV 覆盖。 + let allowed_dirs = Arc::new(RwLock::new(AllowedDirs::default_with_root())); + let ai_tools = Arc::new(crate::commands::ai::build_ai_tool_registry(&db, &allowed_dirs)); // build_registry 需注入 Arc(TaskAdvanceNode 持 db)。 // 在 struct 字段 `db` move 前 clone,避免 E0382。 let registry = Arc::new(build_registry(db.clone())); @@ -330,6 +390,8 @@ impl AppState { crate::commands::ai::agentic::DEFAULT_MAX_AGENT_RETRIES, )), workflow_state_registry: Arc::new(Mutex::new(HashMap::new())), + // F-260619-03 Phase A: 与 ai_tools registry 共享同一 Arc(构建时注入同一句柄) + allowed_dirs: allowed_dirs.clone(), db, event_bus: EventBus::new(), registry, @@ -345,6 +407,9 @@ impl AppState { // 单 provider 场景:该 provider cap=global_cap → acquire_global+acquire_for_provider // 串联,min(3,3)=3,有效上限同未配置 → 行为零变化。 state.reload_provider_caps().await; + // F-260619-03 Phase A: 从 Settings KV 加载持久化授权目录白名单覆盖默认值。 + // 失败(读 KV/解析 JSON 出错)不阻断启动,保持 default_with_root(仅 workspace_root)。 + state.reload_allowed_dirs().await; Ok(state) } @@ -370,6 +435,91 @@ impl AppState { .collect(); self.llm_concurrency.set_provider_caps(caps).await; } + + /// F-260619-03 Phase A: 从 Settings KV `allowed_dirs`(JSON 字符串数组)加载持久化白名单。 + /// + /// 启动 + Settings IPC `ai_set_allowed_dirs` 写入后调用。解析失败/缺失 → 保持 + /// default_with_root(仅 workspace_root,向后兼容:is_authorized 始终放行 workspace_root)。 + /// 每条路径尝试 canonicalize 规范化(防大小写/分隔符差异绕过);canonicalize 失败 + /// (目录不存在)回退原字面量 trim(写入后再校验场景:先授权目录路径,目录暂不存在)。 + pub async fn reload_allowed_dirs(&self) { + let raw = match self.settings.get(AllowedDirs::SETTINGS_KEY).await { + Ok(Some(v)) => v, + Ok(None) => { + // 未配置:保持 default_with_root(is_authorized 始终放行 workspace_root) + return; + } + Err(e) => { + tracing::warn!("[F-03A] 读取 allowed_dirs 失败,保持默认: {}", e); + return; + } + }; + let dirs: Vec = match serde_json::from_str(&raw) { + Ok(v) => v, + Err(e) => { + tracing::warn!("[F-03A] allowed_dirs 非 JSON 字符串数组,保持默认: {}", e); + return; + } + }; + let mut set = HashSet::new(); + // workspace_root 始终在白名单(向后兼容);不重复插入 + set.insert(workspace_root_path()); + for d in dirs { + let d = d.trim(); + if d.is_empty() { + continue; + } + let p = PathBuf::from(d); + // canonicalize 成功用真实路径(去 symlink/大小写归一);失败回退原字面量 + let normalized = std::fs::canonicalize(&p).unwrap_or_else(|_| { + // 规范化分隔符 trim 尾部,保持与 starts_with 比对一致 + PathBuf::from(d.trim_end_matches(['/', '\\'])) + }); + set.insert(normalized); + } + *self.allowed_dirs.write().await = AllowedDirs { persistent: set }; + } + + /// F-260619-03 Phase A: 写 Settings KV + 同步内存白名单(供 Settings IPC 调用)。 + /// + /// - 持久化:JSON 字符串数组写 `app_settings` key=`allowed_dirs` + /// - 内存:reload_allowed_dirs 重新加载(规范化逻辑复用,避免双份) + /// 返回持久化后的规范化路径列表(供前端回显 canonicalize 后的真实路径)。 + pub async fn set_allowed_dirs(&self, dirs: Vec) -> Result> { + // 去空 + 去重(保留顺序,前端展示友好) + let mut seen = HashSet::new(); + let cleaned: Vec = dirs + .into_iter() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .filter(|s| seen.insert(s.clone())) + .collect(); + let json = serde_json::to_string(&cleaned)?; + self.settings.set(AllowedDirs::SETTINGS_KEY, &json).await + .map_err(|e| anyhow::anyhow!("持久化 allowed_dirs 失败: {}", e))?; + self.reload_allowed_dirs().await; + // 返回内存白名单(含 workspace_root)的规范化字符串列表(前端可看到真实生效路径) + let guard = self.allowed_dirs.read().await; + let mut out: Vec = guard + .persistent + .iter() + .map(|p| p.to_string_lossy().to_string()) + .collect(); + out.sort(); + Ok(out) + } + + /// F-260619-03 Phase A: 读内存白名单为字符串列表(供 Settings IPC `ai_get_allowed_dirs` 回显)。 + pub async fn get_allowed_dirs(&self) -> Vec { + let guard = self.allowed_dirs.read().await; + let mut out: Vec = guard + .persistent + .iter() + .map(|p| p.to_string_lossy().to_string()) + .collect(); + out.sort(); + out + } } /// 构建节点注册表 — 注册内置节点 @@ -414,3 +564,61 @@ fn build_registry(db: Arc) -> NodeRegistry { registry } +#[cfg(test)] +mod tests { + use super::*; + + // ============================================================ + // F-260619-03 Phase A: AllowedDirs 授权语义测试 + // 锁定:workspace_root 始终授权 + persistent 命中放行 + 未命中拒绝。 + // ============================================================ + + /// workspace_root 路径在 default_with_root 白名单内授权通过 + #[test] + fn test_allowed_dirs_workspace_root_authorized() { + let allowed = AllowedDirs::default_with_root(); + let root = workspace_root_path(); + assert!(allowed.is_authorized(&root), "workspace_root 应被授权"); + // workspace 内子路径也应授权(starts_with workspace_root) + let child = root.join("src").join("main.rs"); + assert!(allowed.is_authorized(&child), "workspace_root 子路径应被授权"); + } + + /// 自定义授权目录命中放行(模拟用户授权 E:/some/external/dir) + #[test] + fn test_allowed_dirs_custom_authorized() { + let mut set = HashSet::new(); + let custom = PathBuf::from("E:/wk-test-external-dir"); + set.insert(custom.clone()); + let allowed = AllowedDirs { persistent: set }; + // 精确命中 + 子路径 starts_with 命中 + assert!(allowed.is_authorized(&custom)); + assert!(allowed.is_authorized(&custom.join("sub").join("file.txt"))); + } + + /// 未授权目录被拒绝 + #[test] + fn test_allowed_dirs_unauthorized_rejected() { + let mut set = HashSet::new(); + set.insert(PathBuf::from("E:/wk-test-authorized")); + let allowed = AllowedDirs { persistent: set }; + let outside = PathBuf::from("E:/wk-test-unauthorized/file.txt"); + assert!(!allowed.is_authorized(&outside), "未授权目录应被拒绝"); + } + + /// 默认(空 persistent)只授权 workspace_root + #[test] + fn test_allowed_dirs_default_empty_persistent() { + let allowed = AllowedDirs::default(); + // 空 persistent,无 workspace_root → 任何路径都不授权 + // (default_with_root 才含 workspace_root;Default 不含,用于边界测试) + assert!(!allowed.is_authorized(&PathBuf::from("E:/anything"))); + } + + /// SETTINGS_KEY 常量稳定(防 rename 致持久化数据丢失) + #[test] + fn test_allowed_dirs_settings_key_stable() { + assert_eq!(AllowedDirs::SETTINGS_KEY, "allowed_dirs"); + } +} + diff --git a/src/api/ai.ts b/src/api/ai.ts index 30f21eb..c0b64b7 100644 --- a/src/api/ai.ts +++ b/src/api/ai.ts @@ -215,6 +215,16 @@ export const aiApi = { return invoke('ai_list_skills') }, + /** F-260619-03 Phase A: 获取 AI 工具文件访问授权目录列表(含 workspace_root) */ + getAllowedDirs(): Promise { + return invoke('ai_get_allowed_dirs') + }, + + /** F-260619-03 Phase A: 设置 AI 工具授权目录(持久化 + 同步内存白名单),返回规范化后的列表 */ + setAllowedDirs(dirs: string[]): Promise { + return invoke('ai_set_allowed_dirs', { dirs }) + }, + /** 监听 AI 聊天事件 */ onEvent(callback: (event: AiChatEvent) => void): Promise { return listen('ai-chat-event', (e) => { diff --git a/src/components/settings/AllowedDirsPanel.vue b/src/components/settings/AllowedDirsPanel.vue new file mode 100644 index 0000000..51081a4 --- /dev/null +++ b/src/components/settings/AllowedDirsPanel.vue @@ -0,0 +1,168 @@ + + + + + diff --git a/src/i18n/en/settings.ts b/src/i18n/en/settings.ts index 7f7f3fa..325dbee 100644 --- a/src/i18n/en/settings.ts +++ b/src/i18n/en/settings.ts @@ -109,6 +109,16 @@ export default { labelAgentMaxRetries: 'Stream failure retries', descAgentMaxRetries: 'Auto-retry count on stream failure (0-10, default 3); only retries pre-stream failures (no token output yet); mid-stream failures are abandoned', + // ===== F-260619-03 Phase A: AI tool allowed dirs panel ===== + panelAllowedDirs: '📁 AI Tool Allowed Dirs', + descAllowedDirs: 'AI file tools (read/write/list/patch/search) can only access the following authorized directories. The project root (workspace_root) is always implicitly authorized and need not be added. Access to directories not in the list will be rejected.', + allowedDirsLoading: 'Loading…', + allowedDirsEmpty: 'No extra authorized directories (only project root is authorized by default)', + allowedDirsInputPlaceholder: 'Enter absolute path, e.g. E:/wk-lab/u-abc', + allowedDirsAdd: 'Add', + allowedDirsSave: 'Save', + allowedDirsReload: 'Reload', + // ===== Knowledge base panel ===== panelKnowledge: '📚 Knowledge base', labelAutoExtract: 'Auto-distill knowledge', diff --git a/src/i18n/zh-CN/settings.ts b/src/i18n/zh-CN/settings.ts index a557ff6..4481345 100644 --- a/src/i18n/zh-CN/settings.ts +++ b/src/i18n/zh-CN/settings.ts @@ -109,6 +109,16 @@ export default { labelAgentMaxRetries: '流式失败重试次数', descAgentMaxRetries: '流式对话失败时自动重试次数(0-10,默认 3);只重试未输出任何 token 的流前失败,已输出文本的失败直接放弃', + // ===== F-260619-03 Phase A: AI 工具授权目录面板 ===== + panelAllowedDirs: '📁 AI 工具授权目录', + descAllowedDirs: 'AI 文件工具(读/写/列/改/搜等)仅可访问以下授权目录。项目根目录(workspace_root)始终隐式授权,无需在此添加。未在列表内的目录访问将被拒绝。', + allowedDirsLoading: '加载中…', + allowedDirsEmpty: '暂无额外授权目录(仅项目根目录默认授权)', + allowedDirsInputPlaceholder: '输入授权目录绝对路径,如 E:/wk-lab/u-abc', + allowedDirsAdd: '添加', + allowedDirsSave: '保存', + allowedDirsReload: '重载', + // ===== 知识库面板 ===== panelKnowledge: '📚 知识库', labelAutoExtract: '自动提炼知识', diff --git a/src/views/Settings.vue b/src/views/Settings.vue index 179c452..146da29 100644 --- a/src/views/Settings.vue +++ b/src/views/Settings.vue @@ -40,6 +40,9 @@ + + + @@ -51,6 +54,7 @@ import ProviderPanel from '@/components/settings/ProviderPanel.vue' import ConnectionPanel from '@/components/settings/ConnectionPanel.vue' import GeneralPanel from '@/components/settings/GeneralPanel.vue' import KnowledgePanel from '@/components/settings/KnowledgePanel.vue' +import AllowedDirsPanel from '@/components/settings/AllowedDirsPanel.vue' import type { AiProviderConfig } from '@/api/types' // ============================================================