//! 路径授权预校验(文件工具路径提取 + 白/黑名单判定)。 //! //! 第六批从 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; /// 提取文件工具的路径参数(用于路径授权预校验)。 /// /// 仅对走 resolve_workspace_path 校验的文件工具返回路径;非文件工具返回空 Vec(不预校验)。 /// - 单路径工具(read_file/write_file/list_directory/patch_file/file_info/append_file/delete_file/search_files/grep) /// 取 args["path"] /// - rename_file 双路径取 args["from"] + args["to"](两个都需授权) /// - run_command 不返回(它只走 validate_path 黑名单,不限定 workspace,High risk 靠人工审批) pub(crate) fn extract_file_tool_paths(tool_name: &str, args: &serde_json::Value) -> Vec { match tool_name { "rename_file" => { let mut v = Vec::new(); if let Some(from) = args.get("from").and_then(|x| x.as_str()) { v.push(from.to_string()); } if let Some(to) = args.get("to").and_then(|x| x.as_str()) { v.push(to.to_string()); } v } "read_file" | "write_file" | "list_directory" | "patch_file" | "file_info" | "append_file" | "delete_file" | "search_files" | "grep" => { args.get("path").and_then(|x| x.as_str()).map(|s| vec![s.to_string()]).unwrap_or_default() } _ => Vec::new(), } } /// 路径授权预校验结果。 pub(crate) enum FileToolAuthOutcome { /// 全部已授权 → 走原 Low/Med/High 流程 Authorized, /// 未授权 → 附带 PathAuthRequest,供挂起弹窗 NeedsAuth(PathAuthRequest), /// 命中黑名单 → 硬拒 Denied(String), } /// 对单条文件工具调用做路径授权预校验,返回是否需挂起/拒绝/放行。 /// /// - 路径任一命中黑名单 → `Denied(reason)`:硬拒(工具返 Err tool_result,不挂起) /// - 路径任一未命中白名单(persistent + session_dirs)且非黑名单 → `NeedsAuth`: /// 附带 PathAuthRequest(规范化父目录 + 原始路径列表),供挂起弹窗 /// - 全部已授权 → `Authorized`:走原 Low/Med/High 流程 pub(crate) fn check_file_tool_auth( tool_name: &str, args: &serde_json::Value, allowed: &crate::state::AllowedDirs, ) -> 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 } => return FileToolAuthOutcome::Denied(reason), PathAuthDecision::NeedsAuthorization { dir } => { if !pending_dirs.contains(&dir) { pending_dirs.push(dir); } } PathAuthDecision::Authorized => {} } } if pending_dirs.is_empty() { FileToolAuthOutcome::Authorized } else if tool_name == "search_files" { // 核心设计5:search_files 兜底 — 盲搜语义应拒不应询。 // search_files 仅在已授权目录可用;用户已通过 @项目 提供项目上下文,无需搜索文件系统。 // (黑名单已在上方 for 循环内短路返 Denied,走到此处均为 NeedsAuthorization 场景。) // 其他文件工具保持原 NeedsAuth 弹窗逻辑完全不变。 FileToolAuthOutcome::Denied( "search_files 仅在已授权目录可用;请用 @[项目] 引用或提供绝对路径,不要盲搜文件系统。".to_string(), ) } else { FileToolAuthOutcome::NeedsAuth(PathAuthRequest { dirs: pending_dirs, raw_paths: paths }) } }