From 3e239a108fa6f138a2c63b04db603c5445138d67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BB=9D=E5=B0=98?= <237809796@qq.com> Date: Thu, 1 Oct 2026 02:40:49 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E:=20=E4=BF=A1=E4=BB=BB?= =?UTF-8?q?=E6=89=80=E6=9C=89=E7=9B=AE=E5=BD=95=E6=89=A9=E5=B1=95=E8=87=B3?= =?UTF-8?q?=E6=95=8F=E6=84=9F=E7=9B=AE=E5=BD=95=E6=94=BE=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 开关语义升级为完全放行,含 .ssh/.aws 等黑名单目录(产品拍板体验优先,用户知情选择) validate_path 与 is_authorized 双拦截点经进程级标志同源放行,audit 层每轮工具执行前刷新 设置页开启时二次确认,正文明示敏感凭据可进对话上下文风险自担;对话区常驻放行横幅既有 测试以串行闸防并行互踩进程级标志 --- src-tauri/src/commands/ai/audit/mod.rs | 13 +++- src-tauri/src/commands/ai/audit/path_auth.rs | 69 +++++++++++++++----- src-tauri/src/commands/ai/download_file.rs | 21 ++++++ src-tauri/src/commands/ai/tool_registry.rs | 11 +++- src-tauri/src/state/allowed_dirs.rs | 39 +++++++++++ src/components/settings/AllowedDirsPanel.vue | 68 ++++++++++++++++++- src/i18n/en/settings.ts | 4 +- src/i18n/zh-CN/settings.ts | 4 +- 8 files changed, 204 insertions(+), 25 deletions(-) diff --git a/src-tauri/src/commands/ai/audit/mod.rs b/src-tauri/src/commands/ai/audit/mod.rs index 88c87d2..dc62513 100644 --- a/src-tauri/src/commands/ai/audit/mod.rs +++ b/src-tauri/src/commands/ai/audit/mod.rs @@ -27,8 +27,14 @@ mod diff; // path_auth(audit/path_auth.rs):路径授权预校验。 // 第六批从本文件抽离,行为零变更。pub(super) use 供 tests 子模块引用 + process_tool_calls 裸名调用。 -mod path_auth; +// 模块提为 pub(crate):state 层 allowed_dirs 的 is_authorized 第三道门需直接读放行标志,私有模块路径段不可穿越 +pub(crate) mod path_auth; pub(super) use path_auth::{check_file_tool_auth, FileToolAuthOutcome, PATH_AUTH_BYPASS_KEY}; +// 黑名单放行判据:tool_registry 的 validate_path 无 bypass 参数,经此跨模块读进程级标志 +pub(super) use path_auth::is_blacklist_bypassed; +// 测试专用:改写标志验证 bypass 放行语义 + 串行闸,防依赖标志结果的测试并行互踩 +#[cfg(test)] +pub(super) use path_auth::{set_blacklist_bypass, BLACKLIST_BYPASS_TEST_LOCK}; // approval(audit/approval.rs):审批门控逻辑。 // 第六批从本文件抽离,行为零变更。use 保持 process_tool_calls 裸名调用。 @@ -326,7 +332,7 @@ pub(crate) async fn process_tool_calls( .clone(); let allowed_guard = allowed_snapshot.read().await.clone(); // 「信任所有目录」总开关(KV df-ai-path-auth-bypass,默认关):开启后白名单外路径不再挂起 - // 授权弹窗(NeedsAuth 时直接放行),黑名单 Denied 仍硬拒(凭据目录自保护不受影响)。 + // 授权弹窗(NeedsAuth 时直接放行),黑名单拦截也一并放行(产品拍板:体验优先,含敏感目录)。 // 读取失败按关处理(fail-safe,默认行为不变);前端设置页改后下一轮 process_tool_calls 即生效。 let path_auth_bypass = app_handle .state::() @@ -338,6 +344,9 @@ pub(crate) async fn process_tool_calls( // 前端 appSettings.set 以 JSON.stringify 落库(带引号),归一后比对(同 df-ai-trace-approve 先例) .map(|v| { let v = v.trim_matches('"'); v == "true" || v == "1" }) .unwrap_or(false); + // 同步进程级黑名单放行标志:validate_path(tool_registry)无 bypass 参数,靠此标志放行黑名单; + // 每轮工具执行前刷新,与下方 check_file_tool_auth 的入参 bypass 同源同值,两处拦截点语义一致。 + path_auth::set_blacklist_bypass(path_auth_bypass); // 收集 NeedsAuth/Denied drafts(从 drafts 移除,不进下方 RiskLevel 循环) let mut path_auth_pending: Vec<(ToolCallDraft, serde_json::Value, PathAuthRequest)> = Vec::new(); let mut path_denied: Vec<(ToolCallDraft, String)> = Vec::new(); diff --git a/src-tauri/src/commands/ai/audit/path_auth.rs b/src-tauri/src/commands/ai/audit/path_auth.rs index d2ed7b4..57f9582 100644 --- a/src-tauri/src/commands/ai/audit/path_auth.rs +++ b/src-tauri/src/commands/ai/audit/path_auth.rs @@ -11,9 +11,32 @@ use super::PathAuthRequest; /// 「信任所有目录」总开关 KV key(app_settings,bool,默认关)。 -/// 开启后白名单外路径不再挂起授权弹窗,黑名单仍硬拒。前端设置页读写同一 key。 +/// 开启后白名单外路径不再挂起授权弹窗,黑名单拦截也一并放行(产品拍板:体验优先, +/// 用户知情选择,后期再收敛)。前端设置页读写同一 key。 pub(crate) const PATH_AUTH_BYPASS_KEY: &str = "df-ai-path-auth-bypass"; +/// 黑名单放行的进程级标志。validate_path(run_command 等)所在调用链无 bypass 参数, +/// 改签名需穿透全部调用方,故以进程级 AtomicBool 承载;真实来源是 KV PATH_AUTH_BYPASS_KEY, +/// 由 audit/mod.rs 每轮工具执行前读 KV 后经 set_blacklist_bypass 刷新,与本文件 +/// check_file_tool_auth 的入参 bypass 同源同值,两处拦截点语义一致。 +pub(crate) static PATH_AUTH_BYPASS_ACTIVE: std::sync::atomic::AtomicBool = + std::sync::atomic::AtomicBool::new(false); + +/// 每轮工具执行前由 audit 层把 KV 值同步进进程级标志。 +pub(crate) fn set_blacklist_bypass(on: bool) { + PATH_AUTH_BYPASS_ACTIVE.store(on, std::sync::atomic::Ordering::Relaxed); +} + +/// validate_path 黑名单检查的放行判据(单写多读,Relaxed 序足够)。 +pub(crate) fn is_blacklist_bypassed() -> bool { + PATH_AUTH_BYPASS_ACTIVE.load(std::sync::atomic::Ordering::Relaxed) +} + +/// 测试串行闸:进程级标志全进程单例,并行测试同时改写标志或依赖其结果会互踩, +/// 凡改写标志或依赖标志结果的测试须持锁(仅测试隔离用,非生产路径)。 +#[cfg(test)] +pub(crate) static BLACKLIST_BYPASS_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + /// 提取文件工具的路径参数(用于路径授权预校验)。 /// /// 仅对走 resolve_workspace_path 校验的文件工具返回路径;非文件工具返回空 Vec(不预校验)。 @@ -47,20 +70,21 @@ pub(crate) enum FileToolAuthOutcome { Authorized, /// 未授权 → 附带 PathAuthRequest,供挂起弹窗 NeedsAuth(PathAuthRequest), - /// 命中黑名单 → 硬拒 + /// 黑名单命中(bypass 关)或 search_files 盲搜兜底时硬拒 Denied(String), } /// 对单条文件工具调用做路径授权预校验,返回是否需挂起/拒绝/放行。 /// -/// - 路径任一命中黑名单 → `Denied(reason)`:硬拒(工具返 Err tool_result,不挂起) +/// - 路径任一命中黑名单且 bypass 关:`Denied(reason)` 硬拒(工具返 Err tool_result,不挂起) /// - 路径任一未命中白名单(persistent + session_dirs)且非黑名单 → `NeedsAuth`: /// 附带 PathAuthRequest(规范化父目录 + 原始路径列表),供挂起弹窗 /// - 全部已授权 → `Authorized`:走原 Low/Med/High 流程 -/// - `bypass=true`(「信任所有目录」总开关):非黑名单的未授权目录不再产生 NeedsAuth, -/// 直接 `Authorized`(search_files 的盲拒同为未授权场景,一并放行);黑名单 Denied 不受影响 -/// (凭据目录自保护是安全硬边界)。bypass 由调用方 process_tool_calls 读 KV 后传入 -/// (本函数同步签名,不自取异步 settings)。 +/// - `bypass=true`(「信任所有目录」总开关):未授权目录不再产生 NeedsAuth,黑名单 Denied +/// 也一并放行(产品拍板:开关语义为完全放行,含敏感目录,体验优先,search_files 盲拒 +/// 随 NeedsAuth 消失一并解除)。bypass 由调用方 process_tool_calls 读 KV 后传入; +/// validate_path 无此参数才走全局标志 PATH_AUTH_BYPASS_ACTIVE(与入参同源等价, +/// audit 层每轮刷新保证两处拦截点一致)。本函数同步签名,不自取异步 settings。 pub(crate) fn check_file_tool_auth( tool_name: &str, args: &serde_json::Value, @@ -78,9 +102,16 @@ pub(crate) fn check_file_tool_auth( let mut pending_dirs: Vec = Vec::new(); for p in &paths { match check_path_authorization(p, allowed) { - PathAuthDecision::Denied { reason } => return FileToolAuthOutcome::Denied(reason), + PathAuthDecision::Denied { reason } => { + // 总开关开启时黑名单一并放行(产品拍板:体验优先,用户知情选择);关闭时硬拒不变。 + // 此处用入参 bypass;validate_path 无参才走全局标志,两者同源等价(audit 每轮刷同一 KV)。 + if bypass { + continue; + } + return FileToolAuthOutcome::Denied(reason); + } PathAuthDecision::NeedsAuthorization { dir } => { - // 总开关开启:非黑名单的未授权目录直接放行(黑名单已在上一分支短路硬拒) + // 总开关开启:未授权目录直接放行(黑名单的放行在上一分支处理) if bypass { continue; } @@ -113,6 +144,9 @@ mod tests { /// bypass 开:白名单外路径 NeedsAuth 时直接 Authorized;bypass 关:行为不变(仍 NeedsAuth)。 #[test] fn test_bypass_on_needs_auth_becomes_authorized() { + // is_authorized 已接入全局 bypass 标志,本例基线断言(入参关)途经该读点, + // 须持串行闸,防与并行写标志的测试互踩致基线误得 Authorized。 + let _guard = BLACKLIST_BYPASS_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); let allowed = crate::state::AllowedDirs::default(); let args = serde_json::json!({ "path": "C:/some/unauthorized/dir" }); // 关(基线,行为不变):NeedsAuth @@ -127,16 +161,21 @@ mod tests { } } - /// bypass 开:黑名单仍 Denied(凭据目录自保护硬边界不受总开关影响)。 + /// bypass 开:黑名单也放行(语义反转:总开关=完全放行,含敏感目录)。 + /// 进程级标志须持串行闸并在断言前复位(先取结果再复位再断言,防 panic 泄漏标志污染并行测试)。 #[test] - fn test_bypass_on_blacklist_still_denied() { + fn test_bypass_on_blacklist_also_allowed() { + let _guard = BLACKLIST_BYPASS_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + set_blacklist_bypass(true); let allowed = crate::state::AllowedDirs::default_with_root(); // .ssh 命中系统黑名单(is_in_system_blacklist) let args = serde_json::json!({ "path": "/home/user/.ssh/config" }); - match check_file_tool_auth("read_file", &args, &allowed, true) { - FileToolAuthOutcome::Denied(_) => {} - _ => panic!("bypass 开时黑名单路径应仍 Denied"), - } + let outcome_allowed = matches!( + check_file_tool_auth("read_file", &args, &allowed, true), + FileToolAuthOutcome::Authorized + ); + set_blacklist_bypass(false); + assert!(outcome_allowed, "bypass 开时黑名单路径应放行"); } /// bypass 关:黑名单行为不变(Denied 基线,与改造前一致)。 diff --git a/src-tauri/src/commands/ai/download_file.rs b/src-tauri/src/commands/ai/download_file.rs index 175e022..90d0a02 100644 --- a/src-tauri/src/commands/ai/download_file.rs +++ b/src-tauri/src/commands/ai/download_file.rs @@ -206,11 +206,32 @@ mod tests { #[test] fn test_validate_path_rejects_sensitive_system_dirs() { + // 依赖全局黑名单放行标志为关,持串行闸防与改写标志的并行测试互踩 + let _guard = crate::commands::ai::audit::BLACKLIST_BYPASS_TEST_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); // .ssh / .aws / .gnupg 等敏感目录被拒(走 is_in_system_blacklist) assert!(validate_path("/root/.ssh/id_rsa").is_err()); assert!(validate_path("C:\\Users\\x\\.aws\\credentials").is_err()); } + #[test] + fn test_validate_path_bypass_allows_sensitive_dirs() { + // 总开关开启:黑名单放行(validate_path 层),.. 遍历检查保留(路径合法性校验,非权限边界)。 + // 先取结果再复位再断言,防 panic 泄漏标志污染其他测试。 + let _guard = crate::commands::ai::audit::BLACKLIST_BYPASS_TEST_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); + crate::commands::ai::audit::set_blacklist_bypass(true); + let ssh_ok = validate_path("/root/.ssh/id_rsa").is_ok(); + let aws_ok = validate_path("C:\\Users\\x\\.aws\\credentials").is_ok(); + let traversal_still_denied = validate_path("/root/.ssh/../.gnupg/x").is_err(); + crate::commands::ai::audit::set_blacklist_bypass(false); + assert!(ssh_ok, "bypass 开时 .ssh 路径应通过 validate_path"); + assert!(aws_ok, "bypass 开时 .aws 路径应通过 validate_path"); + assert!(traversal_still_denied, "bypass 开时 .. 遍历检查应保留"); + } + // ── handler 参数边界 ── #[tokio::test] diff --git a/src-tauri/src/commands/ai/tool_registry.rs b/src-tauri/src/commands/ai/tool_registry.rs index 21a9702..ffb8623 100644 --- a/src-tauri/src/commands/ai/tool_registry.rs +++ b/src-tauri/src/commands/ai/tool_registry.rs @@ -125,7 +125,8 @@ pub(crate) fn generate_diff(old: &str, new: &str) -> String { /// 2. **按路径分隔符分段**做 `..` 归一化检测——纯子串 `contains("..")` 会误伤 /// 合法目录名如 `my..file`,改用分段(逐段判断是否有 `..` 段)更精准,且能识别 /// 解码后 `..%2f` / `..\` 等变体。 -/// 3. 敏感系统目录黑名单仍保留(.ssh/.aws/.gnupg/AppData/ProgramData/Windows/System32)。 +/// 3. 敏感系统目录黑名单(.ssh/.aws/.gnupg/AppData/ProgramData/Windows/System32), +/// 「信任所有目录」总开关(df-ai-path-auth-bypass)开启时放行。 pub(crate) fn validate_path(path: &str) -> anyhow::Result<()> { // 1) URL 解码:防 %2e%2e / %2f / %5c 等 URL 编码绕过 (LLM/外部输入可能传编码串) // percent_decode_str 对非法 %XX 容错(保留原字节),decode_utf8_lossy 容错非 UTF-8。 @@ -147,8 +148,12 @@ pub(crate) fn validate_path(path: &str) -> anyhow::Result<()> { // 敏感系统目录(.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)) { + // 与 is_authorized 运行时黑名单同源,单一来源 — 修 agent1 问题4 两套黑名单不一致)。 + // 总开关开启时跳过(产品拍板:bypass 语义为完全放行,含敏感目录);标志由 audit 层每轮 + // 工具执行前从同一 KV 刷新,与本函数无参签名解耦。.. 遍历检查保留在上:路径合法性校验,非权限边界。 + if crate::state::is_in_system_blacklist(&PathBuf::from(&normalized)) + && !crate::commands::ai::audit::is_blacklist_bypassed() + { anyhow::bail!("禁止访问敏感系统目录"); } // AppData 不再硬拦(原 `lower.contains("\\appdata\\")` 一刀切致用户授权后仍拒)。 diff --git a/src-tauri/src/state/allowed_dirs.rs b/src-tauri/src/state/allowed_dirs.rs index e7f9433..0c8b091 100644 --- a/src-tauri/src/state/allowed_dirs.rs +++ b/src-tauri/src/state/allowed_dirs.rs @@ -73,6 +73,10 @@ impl AllowedDirs { /// 故本函数内部 best-effort canonicalize 兜底(失败回退词法),消除所有调用方形态差异。 /// 白名单仍是唯一真相源,canonicalize 不引入新放行,顺带解析 symlink 增强(非削弱)防逃逸。 pub fn is_authorized(&self, candidate: &Path) -> bool { + // 授权总开关开时不再逐目录询问,黑名单与白名单外一并放行,体验优先用户知情选择;开关关行为不变 + if crate::commands::ai::audit::path_auth::is_blacklist_bypassed() { + return true; + } // Phase C: 黑名单优先(白名单命中也拒)。validate_path 已挡 .ssh/.aws 等, // 此处补系统核心目录(用户可能误把 C:\ 加入 persistent,黑名单兜底拒 System32)。 if is_in_system_blacklist(candidate) { @@ -523,4 +527,39 @@ mod tests { assert!(!is_in_system_blacklist(&PathBuf::from("E:\\wk-lab\\devflow\\src"))); assert!(!is_in_system_blacklist(&PathBuf::from("D:\\backup\\appdata\\data"))); } + + // 授权总开关(黑名单放行)接入 is_authorized 的语义测试。 + // 进程级标志全进程单例,改写或依赖其结果的用例持 path_auth 串行闸防并行互踩。 + + /// 总开关开:白名单外路径与 .ssh 黑名单路径一律放行(is_authorized 首位短路)。 + /// 先取结果再复位再断言,防 panic 泄漏标志污染并行测试。 + #[test] + fn test_bypass_on_is_authorized_allows_all() { + let _guard = crate::commands::ai::audit::path_auth::BLACKLIST_BYPASS_TEST_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); + crate::commands::ai::audit::path_auth::set_blacklist_bypass(true); + let allowed = AllowedDirs::default(); + let outside = PathBuf::from("E:/wk-test-bypass-outside/file.txt"); + let ssh = PathBuf::from("/home/user/.ssh/id_rsa"); + let outside_ok = allowed.is_authorized(&outside); + let ssh_ok = allowed.is_authorized(&ssh); + crate::commands::ai::audit::path_auth::set_blacklist_bypass(false); + assert!(outside_ok, "总开关开时白名单外路径应放行"); + assert!(ssh_ok, "总开关开时 .ssh 黑名单路径应放行"); + } + + /// 总开关关:黑名单拒且白名单外拒,与接入旁路前行为一致。 + #[test] + fn test_bypass_off_is_authorized_unchanged() { + let _guard = crate::commands::ai::audit::path_auth::BLACKLIST_BYPASS_TEST_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); + crate::commands::ai::audit::path_auth::set_blacklist_bypass(false); + let allowed = AllowedDirs::default(); + let outside_ok = allowed.is_authorized(&PathBuf::from("E:/wk-test-bypass-outside/file.txt")); + let ssh_ok = allowed.is_authorized(&PathBuf::from("/home/user/.ssh/id_rsa")); + assert!(!outside_ok, "总开关关时白名单外路径应拒绝"); + assert!(!ssh_ok, "总开关关时 .ssh 黑名单路径应拒绝"); + } } diff --git a/src/components/settings/AllowedDirsPanel.vue b/src/components/settings/AllowedDirsPanel.vue index aafdc3b..1765f49 100644 --- a/src/components/settings/AllowedDirsPanel.vue +++ b/src/components/settings/AllowedDirsPanel.vue @@ -8,11 +8,14 @@

{{ $t('settings.descAllowedDirs') }}

- + @@ -82,6 +85,20 @@

{{ $t('settings.noteScriptSafety') }}

+ + + +
+
+
{{ confirmState.msg }}
+
+ + +
+
+
+