Files
DevFlow/src-tauri/src/commands/ai/audit/path_auth.rs
T
lxy b2d44f85e9 新增: 无人值守P0任务边界全链
- 任务级授权边界判定与审批旁路单点插桩
- boundary_json 随会话落库(V45 可逆迁移)
- 六路非正常终局统一安全停靠与托盘通知
- 每五轮进度事件与会话头部进度条
- 前端边界配置卡侧栏徽标及 UX 校验补齐
2026-10-04 16:27:30 +08:00

223 lines
12 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 路径授权预校验(文件工具路径提取 + 白/黑名单判定)。
//!
//! 第六批从 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<String> {
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<std::path::PathBuf> = 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(行为不变)"),
}
}
}