From e2ece2d898fd7e9eceaba0b1e6b3e36149cb7117 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BB=9D=E5=B0=98?= <237809796@qq.com> Date: Sat, 20 Jun 2026 15:54:51 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96:=20AI=E6=8E=88=E6=9D=83?= =?UTF-8?q?=E7=9B=AE=E5=BD=95=E6=94=B9=E8=BF=9B(=E8=AF=AF=E5=BC=B9?= =?UTF-8?q?=E7=AA=97=E6=A0=B9=E5=9B=A0+=E5=AE=89=E5=85=A8=E5=8A=A0?= =?UTF-8?q?=E5=9B=BA)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit strip_verbatim比对侧收口/黑名单统一/symlink/bind双路径reload/toast接线;cargo test 18✅ --- src-tauri/src/commands/ai/commands/chat.rs | 14 +- src-tauri/src/commands/ai/tool_registry.rs | 30 ++-- src-tauri/src/commands/project.rs | 14 +- src-tauri/src/state.rs | 165 +++++++++++++++---- src/components/settings/AllowedDirsPanel.vue | 91 ++++++---- src/i18n/en/settings.ts | 7 +- src/i18n/zh-CN/settings.ts | 7 +- src/views/Settings.vue | 2 +- 8 files changed, 251 insertions(+), 79 deletions(-) diff --git a/src-tauri/src/commands/ai/commands/chat.rs b/src-tauri/src/commands/ai/commands/chat.rs index 8b0d738..5598907 100644 --- a/src-tauri/src/commands/ai/commands/chat.rs +++ b/src-tauri/src/commands/ai/commands/chat.rs @@ -478,6 +478,12 @@ pub async fn ai_approve( // 前端 store listen 刷新列表(仅命中映射的工具 emit,见 emit_data_changed)。 if exec_result.is_ok() { emit_data_changed(&app, &approval.tool_name); + // MED-1(CR-260620-01 审查): path 绑定工具(Medium)审批执行后 reload 白名单。 + // bind_dir_to_project / repo.update_field 直写 DB 绕过 project.rs IPC reload, + // 此处单点补全 → AI 绑定目录即时进白名单,防后续文件工具误弹窗。 + if matches!(approval.tool_name.as_str(), "bind_directory" | "create_project" | "update_project") { + state.reload_allowed_dirs().await; + } } // 重新获取锁,替换占位 tool_result 为真实结果(失败时为错误信息,LLM 据此决定下一步) @@ -549,7 +555,7 @@ pub async fn ai_approve( /// with path_auth=Some + emit AiDirAuthRequired)。前端弹窗三选项,用户选择后调本 IPC。 /// /// `decision`: -/// - `"once"`:写入 `PerConvState.session_allowed_dirs`(会话级,随会话销毁)→ 执行工具 → 恢复 loop +/// - `"once"`:写入 `AppState.allowed_dirs.session`(进程级会话临时授权,随 active 会话切换/新建/删除由 clear_session_allowed_dirs 清空)→ 执行工具 → 恢复 loop /// - `"always"`:写入持久化 Settings KV `allowed_dirs`(reload_allowed_dirs 同步内存)→ 执行 → 恢复 /// - `"deny"`:工具返 Err "用户拒绝路径授权" → 恢复 loop /// @@ -637,6 +643,12 @@ pub async fn ai_authorize_dir( audit_finalize(&state, &tool_call_id, audit_status, Some(result_val.to_string())).await; if exec_result.is_ok() { emit_data_changed(&app, &approval.tool_name); + // MED-1(CR-260620-01 审查): path 绑定工具(Medium)审批执行后 reload 白名单。 + // bind_dir_to_project / repo.update_field 直写 DB 绕过 project.rs IPC reload, + // 此处单点补全 → AI 绑定目录即时进白名单,防后续文件工具误弹窗。 + if matches!(approval.tool_name.as_str(), "bind_directory" | "create_project" | "update_project") { + state.reload_allowed_dirs().await; + } } // 替换占位 tool_result 为真实结果 diff --git a/src-tauri/src/commands/ai/tool_registry.rs b/src-tauri/src/commands/ai/tool_registry.rs index 06f4898..b7dde44 100644 --- a/src-tauri/src/commands/ai/tool_registry.rs +++ b/src-tauri/src/commands/ai/tool_registry.rs @@ -130,14 +130,14 @@ fn validate_path(path: &str) -> anyhow::Result<()> { anyhow::bail!("禁止路径遍历 (..)"); } - if lower.contains("\\.ssh") - || lower.contains("\\.aws") - || lower.contains("\\.gnupg") - || lower.contains("\\appdata\\") - || lower.contains("\\programdata\\") - || lower.contains("\\windows\\") - || lower.contains("\\system32\\") - { + // 敏感系统目录(.ssh/.aws/.gnupg + Windows/ProgramFiles/ProgramData + Unix 核心): + // 统一走 state::is_in_system_blacklist 分段匹配(消除 contains 子串误伤如 my\windows\backup, + // 与 is_authorized 运行时黑名单同源,单一来源 — 修 agent1 问题4 两套黑名单不一致) + if crate::state::is_in_system_blacklist(&PathBuf::from(&normalized)) { + anyhow::bail!("禁止访问敏感系统目录"); + } + // AppData 保留单独 contains(用户级数据,分段匹配会误伤 D:\backup\appdata 这类合法目录名) + if lower.contains("\\appdata\\") { anyhow::bail!("禁止访问敏感系统目录"); } Ok(()) @@ -542,6 +542,10 @@ fn register_project_tools(registry: &mut AiToolRegistry, db: &Arc) { let stack_opt = args.get("stack").and_then(|v| v.as_str()).filter(|s| !s.is_empty()); let repo = df_storage::crud::ProjectRepo::new(&db); let stack = bind_dir_to_project(&repo, id, path, stack_opt).await?; + // F-260619-03: 项目绑定目录加 AI 白名单由 reload_allowed_dirs 自动读 projects.bind_directory + // 统一处理(优雅:绑定 = 自动授权,reload 时合并)。bind handler 不直接写 allowed_dirs + // (register_project_tools 无 allowed_dirs 参数,且 reload 集中更易维护)。 + // 绑定后触发 reload:通过 app_handle emit 事件让 state reload,或下次工具调用前 reload 兜底。 Ok(serde_json::json!({ "id": id, "path": path, "stack": stack, "bound": true })) }) })}, @@ -1790,14 +1794,20 @@ fn search_files_recursive<'a>( .map_err(|e| anyhow::anyhow!("无法读取目录 {}: {}", path, e))?; while let Some(entry) = dir.next_entry().await? { let name = entry.file_name().to_string_lossy().to_string(); - let metadata = entry.metadata().await?; - let is_dir = metadata.is_dir(); + // file_type 不跟随 symlink(对比 entry.metadata 会跟随);symlink 目录递归会逃逸出白名单 + // (workspace 内 symlink → 外部目录),故 symlink 一律跳过(不匹配/不递归)。对齐 list_dir_recursive。 + let file_type = entry.file_type().await?; + if file_type.is_symlink() { + continue; + } + let is_dir = file_type.is_dir(); if !is_dir { // 字符串包含匹配(大小写不敏感) if name.to_lowercase().contains(pattern) { *total += 1; if results.len() < max_results { let full_path = std::path::Path::new(path).join(&name).to_string_lossy().into_owned(); + let metadata = entry.metadata().await?; results.push(serde_json::json!({ "path": full_path, "size": metadata.len() })); } } diff --git a/src-tauri/src/commands/project.rs b/src-tauri/src/commands/project.rs index b01d2f7..fd69366 100644 --- a/src-tauri/src/commands/project.rs +++ b/src-tauri/src/commands/project.rs @@ -109,6 +109,11 @@ async fn create_with_binding( updated_at: now, }; state.projects.insert(record.clone()).await.map_err(err_str)?; + // F-260620: 绑定目录变更后立即 reload 白名单(reload_allowed_dirs 读 projects.path 合并 persistent), + // 否则新绑定目录需重启/改 Settings 才生效 → AI 访问误弹窗(已绑定重复弹窗主因,agent3 问题5) + if record.path.is_some() { + state.reload_allowed_dirs().await; + } Ok(record) } @@ -208,7 +213,12 @@ pub async fn update_project( .projects .update_field(&id, &field, &value) .await - .map_err(err_str) + .map_err(err_str)?; + // F-260620: path 字段变更后立即 reload 白名单(新绑定/重绑目录即时生效,防 AI 误弹窗) + if field == "path" { + state.reload_allowed_dirs().await; + } + Ok(true) } /// 删除项目(软删 → 回收站,可恢复) @@ -307,6 +317,8 @@ pub async fn relocate_project_path( .update_field(&id, "path", &new_path) .await .map_err(err_str)?; + // F-260620: 重定位目录后立即 reload 白名单(新路径即时授权,防 AI 误弹窗) + state.reload_allowed_dirs().await; state .projects .update_field(&id, "stack", &stack_json) diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index 6736929..05c66d7 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -359,8 +359,13 @@ impl AllowedDirs { if is_in_system_blacklist(candidate) { return false; } - self.persistent.iter().any(|d| candidate.starts_with(d)) - || self.session.iter().any(|d| candidate.starts_with(d)) + // F-260620: 比对侧统一 strip_verbatim 收口。candidate 可能带 \\?\ 前缀 + // (handler canonicalize 后)或正斜杠(词法层 LLM 传入),white list 条目已是 + // strip+canonicalize 后的 E:\... 形态。单点 strip 消除所有调用方形态不一致 + // (误弹窗核心根因 — 此前 strip_verbatim 仅写入侧调用,比对侧遗漏)。 + let cand = strip_verbatim(candidate.to_path_buf()); + self.persistent.iter().any(|d| cand.starts_with(d)) + || self.session.iter().any(|d| cand.starts_with(d)) } } @@ -396,19 +401,26 @@ pub(crate) fn is_in_system_blacklist(path: &Path) -> bool { // Windows:按分隔符分段判定(避免 contains 误伤 "my program files backup" 这类目录名) let segs: Vec<&str> = s.split(seps).filter(|s| !s.is_empty()).collect(); for (i, seg) in segs.iter().enumerate() { - // Windows 系统目录:C:\Windows\System32 / C:\Windows\SysWOW64 / C:\Windows\System + // Windows 系统目录:C:\Windows(根本身及所有子目录 win.ini/explorer.exe/hosts 等)/ + // C:\Program Files / C:\Program Files (x86) / C:\ProgramData if cfg!(windows) { + // windows 段命中即拒(含根本身,不再限定 system32 子目录 — 防 win.ini/hosts 等) if *seg == "windows" { - if let Some(next) = segs.get(i + 1) { - if matches!(*next, "system32" | "syswow64" | "system") { - return true; - } - } + return true; } // C:\Program Files / C:\Program Files (x86) if *seg == "program files" || *seg == "program files (x86)" { return true; } + // C:\ProgramData(系统级应用数据) + if *seg == "programdata" { + return true; + } + } + // 用户级凭据目录(.ssh/.aws/.gnupg):任意路径段命中即拒(跨平台,从 validate_path 迁移统一, + // 消除 validate_path contains 与 is_in_system_blacklist 分段两套黑名单不一致) + if matches!(*seg, ".ssh" | ".aws" | ".gnupg") { + return true; } // Unix 系统目录(路径首段为这些即拒;Windows 上也防 Unix 风格绝对路径,防御性) if i == 0 && matches!(*seg, "etc" | "usr" | "bin" | "sbin" | "boot" | "dev" | "proc" | "sys") { @@ -456,7 +468,25 @@ pub fn check_path_authorization( PathAuthDecision::NeedsAuthorization { dir } } +/// Windows canonicalize 返回 `\\?\E:\...` 扩展路径(verbatim 前缀),与词法层(不 canonicalize) +/// 的 starts_with 比对不一致 → 白名单含但工具路径不匹配 → 误弹窗。strip 前缀统一为 `E:\...`。 +fn strip_verbatim(p: PathBuf) -> PathBuf { + let s = p.to_string_lossy().to_string(); + // Windows verbatim/设备路径前缀:\\?\ (Volume canonicalize)、\\.\ (设备命名空间)、 + // \??\ (对象管理器命名空间)。统一 strip 后与词法层 starts_with 比对一致。 + for prefix in [r"\\?\", r"\\.\", r"\??\"] { + if let Some(stripped) = s.strip_prefix(prefix) { + return PathBuf::from(stripped); + } + } + p +} + /// workspace 根目录(src-tauri 上两级,与 tool_registry::workspace_root 同源)。 +/// +/// ⚠️ 已知限制:env!("CARGO_MANIFEST_DIR") 是编译期写死编译机源码路径,打包分发后用户机器 +/// 无此路径 → workspace_root 失效。运行期动态方案(current_exe/用户项目绑定)见待决策.md +/// (workspace_root 分发适配)。当前仅开发机自用有效。 fn workspace_root_path() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")) .parent() @@ -554,39 +584,52 @@ impl AppState { /// 每条路径尝试 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; - } + // F-260619-03: 白名单 = Settings KV allowed_dirs(用户配)+ projects.bind_directory(项目绑定目录自动授权)。 + // 项目绑定目录 = AI 天然可访问(用户已主动绑定),不应重复弹窗。reload 时自动合并入 persistent。 + let kv_dirs: Vec = match self.settings.get(AllowedDirs::SETTINGS_KEY).await { + Ok(Some(v)) => serde_json::from_str(&v).unwrap_or_default(), + Ok(None) => Vec::new(), Err(e) => { - tracing::warn!("[F-03A] 读取 allowed_dirs 失败,保持默认: {}", e); - return; + tracing::warn!("[F-03A] 读取 allowed_dirs 失败: {}", e); + Vec::new() } }; - let dirs: Vec = match serde_json::from_str(&raw) { - Ok(v) => v, - Err(e) => { - tracing::warn!("[F-03A] allowed_dirs 非 JSON 字符串数组,保持默认: {}", e); - return; + // 读 projects.bind_directory(所有项目的绑定目录,自动白名单) + let project_dirs: Vec = { + let repo = df_storage::crud::ProjectRepo::new(&self.db); + match repo.list_all().await { + Ok(projects) => projects.iter() + .filter_map(|p| p.path.as_ref().filter(|d| !d.is_empty()).map(|d| d.clone())) + .collect(), + Err(e) => { + tracing::warn!("[F-03B] 读取项目绑定目录失败: {}", e); + Vec::new() + } } }; + // 合并:KV allowed_dirs + projects.bind_directory + let mut all_dirs = kv_dirs; + all_dirs.extend(project_dirs); + // 去重 + all_dirs.sort(); + all_dirs.dedup(); let mut set = HashSet::new(); - // F-260619-03 收尾:KV 有配时尊重用户 persistent(不无条件塞 workspace_root)。 - // 用户 KV 配置不含 workspace_root → 工程根也需授权(动态白名单完整语义, - // 兑现 commit 声称"用户删白名单后需授权")。仅 KV 未配时 default_with_root 含 workspace_root。 - for d in dirs { + // KV 未配(空)时保留 workspace_root(向后兼容:默认根免授权) + if all_dirs.is_empty() { + set.insert(workspace_root_path()); + } + for d in all_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 比对一致 + // canonicalize 成功用真实路径(去 symlink/大小写归一);失败回退原字面量 trim + // (兼容"先授权目录,目录暂不存在"用例)。失败打 warn 便于排查静默授权错路径。 + let normalized = strip_verbatim(std::fs::canonicalize(&p).unwrap_or_else(|_| { + tracing::warn!("[allowed_dirs] canonicalize 失败(目录可能不存在),按字面量保存: {}", d); PathBuf::from(d.trim_end_matches(['/', '\\'])) - }); + })); set.insert(normalized); } // F-260619-03 Phase B: reload 时保留当前会话临时授权(session 不落库,仅内存), @@ -602,23 +645,28 @@ impl AppState { /// - 内存:reload_allowed_dirs 重新加载(规范化逻辑复用,避免双份) /// 返回持久化后的规范化路径列表(供前端回显 canonicalize 后的真实路径)。 pub async fn set_allowed_dirs(&self, dirs: Vec) -> Result> { - // 去空 + 去重(保留顺序,前端展示友好) + // 去空 + 去重(保留顺序,前端展示友好)+ 黑名单预校验(防持久化系统敏感目录, + // 纵深防御:即便写入,is_authorized 运行时黑名单也兜底拒,预校验保证白名单 UI 洁净) 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())) + .filter(|s| !is_in_system_blacklist(&PathBuf::from(s))) .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)的规范化字符串列表(前端可看到真实生效路径) + // 返回内存白名单的规范化字符串列表(过滤 workspace_root,与 get_allowed_dirs 一致: + // 内部根不暴露前端,前端回显只含用户显式配置的目录) + let root = workspace_root_path(); let guard = self.allowed_dirs.read().await; let mut out: Vec = guard .persistent .iter() + .filter(|p| **p != root) .map(|p| p.to_string_lossy().to_string()) .collect(); out.sort(); @@ -627,10 +675,14 @@ impl AppState { /// F-260619-03 Phase A: 读内存白名单为字符串列表(供 Settings IPC `ai_get_allowed_dirs` 回显)。 pub async fn get_allowed_dirs(&self) -> Vec { + // 不返回内部 workspace_root(隐式授权,is_authorized 经 persistent 放行): + // 避免把内部根目录暴露到前端用户管理列表。前后端均不再各自猜测 root 形态。 + let root = workspace_root_path(); let guard = self.allowed_dirs.read().await; let mut out: Vec = guard .persistent .iter() + .filter(|p| **p != root) .map(|p| p.to_string_lossy().to_string()) .collect(); out.sort(); @@ -664,9 +716,9 @@ impl AppState { return; } let p = PathBuf::from(d); - let normalized = std::fs::canonicalize(&p).unwrap_or_else(|_| { + let normalized = strip_verbatim(std::fs::canonicalize(&p).unwrap_or_else(|_| { PathBuf::from(d.trim_end_matches(['/', '\\'])) - }); + })); self.allowed_dirs.write().await.session.insert(normalized); } @@ -903,5 +955,52 @@ mod tests { other => panic!("System32 应 Denied, got {:?}", other), } } + + // ============================================================ + // F-260620: strip_verbatim 比对侧收口 + 黑名单增强测试 + // 三方审查(安全/UX/跨端)交叉印证:strip_verbatim 仅写入侧调用,比对侧遗漏致误弹窗。 + // ============================================================ + + /// F-260620: is_authorized 比对侧 strip_verbatim — candidate 带 \\?\ 前缀也命中白名单 + /// (handler canonicalize 后路径 vs persistent 词法路径,形态不一致曾致误弹窗)。 + /// persistent 用动态 workspace_root_path(非硬编码开发机路径),candidate 拼 verbatim 前缀。 + #[test] + fn test_is_authorized_strips_verbatim_prefix() { + let allowed = AllowedDirs::default_with_root(); + let child = workspace_root_path().join("src").join("main.rs"); + // candidate 带 verbatim 前缀(handler canonicalize 后形态)应命中白名单 + let verbatim = PathBuf::from(format!("{}{}", r"\\?\", child.to_string_lossy())); + assert!(allowed.is_authorized(&verbatim), "verbatim 前缀路径应命中白名单"); + // 设备命名空间前缀(\\.\) + let dev = PathBuf::from(format!("{}{}", r"\\.\", child.to_string_lossy())); + assert!(allowed.is_authorized(&dev), "设备命名空间前缀路径应命中白名单"); + } + + /// F-260620: strip_verbatim 三前缀(\\?\ / \\.\ / \??\)统一去除 + #[test] + fn test_strip_verbatim_three_prefixes() { + assert_eq!(strip_verbatim(PathBuf::from(r"\\?\E:\foo")), PathBuf::from("E:\\foo")); + assert_eq!(strip_verbatim(PathBuf::from(r"\\.\E:\foo")), PathBuf::from("E:\\foo")); + assert_eq!(strip_verbatim(PathBuf::from(r"\??\E:\foo")), PathBuf::from("E:\\foo")); + // 无前缀原样返回 + assert_eq!(strip_verbatim(PathBuf::from("E:\\foo")), PathBuf::from("E:\\foo")); + } + + /// F-260620: 黑名单增强 — Windows 根本身 / ProgramData / 用户凭据目录(.ssh/.aws/.gnupg) + #[test] + fn test_blacklist_windows_root_programdata_creds() { + // C:\Windows 根本身(不再限定 system32 子目录 — 防 win.ini/hosts 等) + assert!(is_in_system_blacklist(&PathBuf::from("C:\\Windows\\win.ini"))); + assert!(is_in_system_blacklist(&PathBuf::from("C:\\Windows\\explorer.exe"))); + // ProgramData + assert!(is_in_system_blacklist(&PathBuf::from("C:\\ProgramData\\App\\config"))); + // 用户凭据目录(从 validate_path 迁移统一,任意路径段命中即拒) + assert!(is_in_system_blacklist(&PathBuf::from("C:\\Users\\user\\.ssh\\id_rsa"))); + assert!(is_in_system_blacklist(&PathBuf::from("/home/user/.aws/credentials"))); + assert!(is_in_system_blacklist(&PathBuf::from("/home/user/.gnupg/pubring.gpg"))); + // 普通目录不误伤(devflow 自身 / 含 appdata 段的合法备份目录) + assert!(!is_in_system_blacklist(&PathBuf::from("E:\\wk-lab\\devflow\\src"))); + assert!(!is_in_system_blacklist(&PathBuf::from("D:\\backup\\appdata\\data"))); + } } diff --git a/src/components/settings/AllowedDirsPanel.vue b/src/components/settings/AllowedDirsPanel.vue index 51081a4..3d93198 100644 --- a/src/components/settings/AllowedDirsPanel.vue +++ b/src/components/settings/AllowedDirsPanel.vue @@ -13,7 +13,7 @@