- 任务级授权边界判定与审批旁路单点插桩 - boundary_json 随会话落库(V45 可逆迁移) - 六路非正常终局统一安全停靠与托盘通知 - 每五轮进度事件与会话头部进度条 - 前端边界配置卡侧栏徽标及 UX 校验补齐
223 lines
12 KiB
Rust
223 lines
12 KiB
Rust
//! 路径授权预校验(文件工具路径提取 + 白/黑名单判定)。
|
||
//!
|
||
//! 第六批从 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(行为不变)"),
|
||
}
|
||
}
|
||
}
|