//! 路径授权预校验(文件工具路径提取 + 白/黑名单判定)。 //! //! 第六批从 audit/mod.rs 抽离,行为零变更。包含: //! - `extract_file_tool_paths`:从工具参数中提取文件路径 //! - `FileToolAuthOutcome`:授权判定结果枚举 //! - `check_file_tool_auth`:对单条文件工具调用做路径授权预校验 //! //! process_tool_calls 通过 `use path_auth::check_file_tool_auth` 裸名调用。 //! 测试模块通过 `super::check_file_tool_auth` 引用。 use super::PathAuthRequest; /// 「信任所有目录」总开关 KV key(app_settings,bool,默认关)。 /// 开启后白名单外路径不再挂起授权弹窗,黑名单拦截也一并放行(产品拍板:体验优先, /// 用户知情选择,后期再收敛)。前端设置页读写同一 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(不预校验)。 /// 路径参数键查注册元数据(path_args 维度,DRY 债 #5 收敛):单路径工具(read_file/ /// write_file/list_directory/patch_file/file_info/append_file/delete_file/search_files/grep) /// 声明 ["path"],rename_file 声明 ["from","to"](两个都需授权),其余工具默认空(不预校验)。 /// run_command 不声明(只走 validate_path 黑名单,不限定 workspace,High risk 靠人工审批)。 pub(crate) fn extract_file_tool_paths(tool_name: &str, args: &serde_json::Value) -> Vec { crate::commands::ai::tool_registry::ensure_tool_meta_seeded(); df_ai::ai_tools::extract_paths(df_ai::ai_tools::path_arg_keys(tool_name), args) } /// 路径授权预校验结果。 pub(crate) enum FileToolAuthOutcome { /// 全部已授权 → 走原 Low/Med/High 流程 Authorized, /// 未授权 → 附带 PathAuthRequest,供挂起弹窗 NeedsAuth(PathAuthRequest), /// 黑名单命中(bypass 关)或 search_files 盲搜兜底时硬拒 Denied(String), } /// 「信任所有目录」总开关的放行面(P3-4 按策略分流)。 /// /// - `Off`:开关关闭(默认),白名单外挂起授权 + 黑名单硬拒,行为与历史版本一致; /// - `All`:主会话全放行(体验优先,黑名单一并放行,产品拍板); /// - `KeepBlacklist`:子代理策略——bypass 只豁免白名单外挂起(含 search_files 盲拒), /// 黑名单保持硬拒。子代理无审批通道也无人监督,黑名单是安全线,不可被体验开关解除。 /// Copy:每条工具调用循环内按值传入 check_file_tool_auth,单元枚举按位复制免手动 clone。 #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum PathBypass { Off, All, KeepBlacklist, } /// 对单条文件工具调用做路径授权预校验,返回是否需挂起/拒绝/放行。 /// /// - 路径任一命中黑名单且 bypass 非 `All`:`Denied(reason)` 硬拒(工具返 Err tool_result,不挂起) /// - 路径任一未命中白名单(persistent + session_dirs)且非黑名单 → `NeedsAuth`: /// 附带 PathAuthRequest(规范化父目录 + 原始路径列表),供挂起弹窗 /// - 全部已授权 → `Authorized`:走原 Low/Med/High 流程 /// - `bypass=All`(「信任所有目录」总开关,主会话):未授权目录不再产生 NeedsAuth,黑名单 /// Denied 也一并放行(产品拍板:开关语义为完全放行,含敏感目录,体验优先,search_files 盲拒 /// 随 NeedsAuth 消失一并解除)。`bypass=KeepBlacklist`(子代理):只豁免 NeedsAuth, /// 黑名单硬拒保持。bypass 由调用方 process_tool_calls 读 KV 后按策略分流传入; /// validate_path 无此参数才走全局标志 PATH_AUTH_BYPASS_ACTIVE(主会话语义,与 All 同源等价, /// audit 层每轮刷新保证两处拦截点一致;子代理文件工具在预校验层已硬拒,handler 级标志不生效)。 /// 本函数同步签名,不自取异步 settings。 pub(crate) fn check_file_tool_auth( tool_name: &str, args: &serde_json::Value, allowed: &crate::state::AllowedDirs, bypass: PathBypass, ) -> FileToolAuthOutcome { use crate::state::{check_path_authorization, PathAuthDecision}; let paths = extract_file_tool_paths(tool_name, args); if paths.is_empty() { // 非文件工具或无路径参数(如缺 path 的异常调用)→ 放行,走原流程(handler 内会因缺参 Err) return FileToolAuthOutcome::Authorized; } // L1 补丁(rename_file 双路径漏校):收集**所有**未授权父目录(去重), // 不只首个。rename_file 的 from/to 若分属不同未授权目录,两个 dir 都需挂起授权。 let mut pending_dirs: Vec = Vec::new(); for p in &paths { match check_path_authorization(p, allowed) { PathAuthDecision::Denied { reason } => { // 总开关=All 时黑名单一并放行(产品拍板:体验优先,用户知情选择,仅主会话); // 关闭与 KeepBlacklist(子代理)时硬拒不变——黑名单是安全线。 // 此处用入参 bypass;validate_path 无参才走全局标志,两者同源等价(audit 每轮刷同一 KV)。 if matches!(bypass, PathBypass::All) { continue; } return FileToolAuthOutcome::Denied(reason); } PathAuthDecision::NeedsAuthorization { dir } => { // 总开关开启(All 或 KeepBlacklist):未授权目录直接放行(黑名单的放行仅在 All 分支) if !matches!(bypass, PathBypass::Off) { continue; } if !pending_dirs.contains(&dir) { pending_dirs.push(dir); } } PathAuthDecision::Authorized => {} } } if pending_dirs.is_empty() { FileToolAuthOutcome::Authorized } else if df_ai::ai_tools::denies_unauthorized(tool_name) { // 核心设计5:search_files 兜底 — 盲搜语义应拒不应询。 // search_files 仅在已授权目录可用;用户已通过 @项目 提供项目上下文,无需搜索文件系统。 // (黑名单已在上方 for 循环内短路返 Denied,走到此处均为 NeedsAuthorization 场景。) // 拒绝名单查注册元数据(deny_unauthorized 维度,DRY 债 #5 收敛,现仅 search_files 声明); // 其他文件工具保持原 NeedsAuth 弹窗逻辑完全不变。 FileToolAuthOutcome::Denied( "search_files 仅在已授权目录可用;请用 @[项目] 引用或提供绝对路径,不要盲搜文件系统。".to_string(), ) } else { FileToolAuthOutcome::NeedsAuth(PathAuthRequest { dirs: pending_dirs, raw_paths: paths }) } } #[cfg(test)] mod tests { use super::*; /// 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 match check_file_tool_auth("read_file", &args, &allowed, PathBypass::Off) { FileToolAuthOutcome::NeedsAuth(_) => {} _ => panic!("bypass 关时未授权路径应仍返 NeedsAuth"), } // 开:直接 Authorized,不挂起 match check_file_tool_auth("read_file", &args, &allowed, PathBypass::All) { FileToolAuthOutcome::Authorized => {} _ => panic!("bypass 开时未授权路径应直接 Authorized"), } } /// P3-4 SubAgent 分流:KeepBlacklist 只豁免白名单外挂起(不再弹授权),黑名单保持硬拒 /// ——子代理无审批通道也无人监督,黑名单安全线不得被体验开关解除。 #[test] fn test_bypass_keep_blacklist_passes_needs_auth_but_denies_blacklist() { let _guard = BLACKLIST_BYPASS_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); let allowed = crate::state::AllowedDirs::default_with_root(); // 白名单外普通路径:KeepBlacklist 直接放行(不挂起授权) let args = serde_json::json!({ "path": "C:/some/unauthorized/dir" }); assert!( matches!( check_file_tool_auth("read_file", &args, &allowed, PathBypass::KeepBlacklist), FileToolAuthOutcome::Authorized ), "KeepBlacklist 应豁免白名单外挂起" ); // 黑名单路径:仍硬拒(与 All 的语义分界) let secret = serde_json::json!({ "path": "/home/user/.ssh/config" }); assert!( matches!( check_file_tool_auth("read_file", &secret, &allowed, PathBypass::KeepBlacklist), FileToolAuthOutcome::Denied(_) ), "KeepBlacklist 下黑名单应保持硬拒" ); } /// bypass 开:黑名单也放行(语义反转:总开关=完全放行,含敏感目录)。 /// 进程级标志须持串行闸并在断言前复位(先取结果再复位再断言,防 panic 泄漏标志污染并行测试)。 #[test] 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" }); let outcome_allowed = matches!( check_file_tool_auth("read_file", &args, &allowed, PathBypass::All), FileToolAuthOutcome::Authorized ); set_blacklist_bypass(false); assert!(outcome_allowed, "bypass 开时黑名单路径应放行"); } /// bypass 关:黑名单行为不变(Denied 基线,与改造前一致)。 #[test] fn test_bypass_off_blacklist_still_denied() { // 途经 is_authorized 读进程级 bypass 标志,持串行闸防与写标志的测试并行互踩 let _guard = BLACKLIST_BYPASS_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); let allowed = crate::state::AllowedDirs::default_with_root(); let args = serde_json::json!({ "path": "/home/user/.ssh/config" }); match check_file_tool_auth("read_file", &args, &allowed, PathBypass::Off) { FileToolAuthOutcome::Denied(_) => {} _ => panic!("bypass 关时黑名单路径应 Denied(行为不变)"), } } }