优化: AI授权目录改进(误弹窗根因+安全加固)

strip_verbatim比对侧收口/黑名单统一/symlink/bind双路径reload/toast接线;cargo test 18
This commit is contained in:
2026-06-20 15:54:51 +08:00
parent e5a89165e6
commit e2ece2d898
8 changed files with 251 additions and 79 deletions

View File

@@ -359,8 +359,13 @@ impl AllowedDirs {
if is_in_system_blacklist(candidate) {
return false;
}
self.persistent.iter().any(|d| candidate.starts_with(d))
|| self.session.iter().any(|d| candidate.starts_with(d))
// F-260620: 比对侧统一 strip_verbatim 收口。candidate 可能带 \\?\ 前缀
// (handler canonicalize 后)或正斜杠(词法层 LLM 传入),white list 条目已是
// strip+canonicalize 后的 E:\... 形态。单点 strip 消除所有调用方形态不一致
// (误弹窗核心根因 — 此前 strip_verbatim 仅写入侧调用,比对侧遗漏)。
let cand = strip_verbatim(candidate.to_path_buf());
self.persistent.iter().any(|d| cand.starts_with(d))
|| self.session.iter().any(|d| cand.starts_with(d))
}
}
@@ -396,19 +401,26 @@ pub(crate) fn is_in_system_blacklist(path: &Path) -> bool {
// Windows:按分隔符分段判定(避免 contains 误伤 "my program files backup" 这类目录名)
let segs: Vec<&str> = s.split(seps).filter(|s| !s.is_empty()).collect();
for (i, seg) in segs.iter().enumerate() {
// Windows 系统目录:C:\Windows\System32 / C:\Windows\SysWOW64 / C:\Windows\System
// Windows 系统目录:C:\Windows(根本身及所有子目录 win.ini/explorer.exe/hosts 等)/
// C:\Program Files / C:\Program Files (x86) / C:\ProgramData
if cfg!(windows) {
// windows 段命中即拒(含根本身,不再限定 system32 子目录 — 防 win.ini/hosts 等)
if *seg == "windows" {
if let Some(next) = segs.get(i + 1) {
if matches!(*next, "system32" | "syswow64" | "system") {
return true;
}
}
return true;
}
// C:\Program Files / C:\Program Files (x86)
if *seg == "program files" || *seg == "program files (x86)" {
return true;
}
// C:\ProgramData(系统级应用数据)
if *seg == "programdata" {
return true;
}
}
// 用户级凭据目录(.ssh/.aws/.gnupg):任意路径段命中即拒(跨平台,从 validate_path 迁移统一,
// 消除 validate_path contains 与 is_in_system_blacklist 分段两套黑名单不一致)
if matches!(*seg, ".ssh" | ".aws" | ".gnupg") {
return true;
}
// Unix 系统目录(路径首段为这些即拒;Windows 上也防 Unix 风格绝对路径,防御性)
if i == 0 && matches!(*seg, "etc" | "usr" | "bin" | "sbin" | "boot" | "dev" | "proc" | "sys") {
@@ -456,7 +468,25 @@ pub fn check_path_authorization(
PathAuthDecision::NeedsAuthorization { dir }
}
/// Windows canonicalize 返回 `\\?\E:\...` 扩展路径(verbatim 前缀),与词法层(不 canonicalize)
/// 的 starts_with 比对不一致 → 白名单含但工具路径不匹配 → 误弹窗。strip 前缀统一为 `E:\...`。
fn strip_verbatim(p: PathBuf) -> PathBuf {
let s = p.to_string_lossy().to_string();
// Windows verbatim/设备路径前缀:\\?\ (Volume canonicalize)、\\.\ (设备命名空间)、
// \??\ (对象管理器命名空间)。统一 strip 后与词法层 starts_with 比对一致。
for prefix in [r"\\?\", r"\\.\", r"\??\"] {
if let Some(stripped) = s.strip_prefix(prefix) {
return PathBuf::from(stripped);
}
}
p
}
/// workspace 根目录(src-tauri 上两级,与 tool_registry::workspace_root 同源)。
///
/// ⚠️ 已知限制:env!("CARGO_MANIFEST_DIR") 是编译期写死编译机源码路径,打包分发后用户机器
/// 无此路径 → workspace_root 失效。运行期动态方案(current_exe/用户项目绑定)见待决策.md
/// (workspace_root 分发适配)。当前仅开发机自用有效。
fn workspace_root_path() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
@@ -554,39 +584,52 @@ impl AppState {
/// 每条路径尝试 canonicalize 规范化(防大小写/分隔符差异绕过);canonicalize 失败
/// (目录不存在)回退原字面量 trim(写入后再校验场景:先授权目录路径,目录暂不存在)。
pub async fn reload_allowed_dirs(&self) {
let raw = match self.settings.get(AllowedDirs::SETTINGS_KEY).await {
Ok(Some(v)) => v,
Ok(None) => {
// 未配置:保持 default_with_root(is_authorized 始终放行 workspace_root)
return;
}
// F-260619-03: 白名单 = Settings KV allowed_dirs(用户配)+ projects.bind_directory(项目绑定目录自动授权)。
// 项目绑定目录 = AI 天然可访问(用户已主动绑定),不应重复弹窗。reload 时自动合并入 persistent。
let kv_dirs: Vec<String> = match self.settings.get(AllowedDirs::SETTINGS_KEY).await {
Ok(Some(v)) => serde_json::from_str(&v).unwrap_or_default(),
Ok(None) => Vec::new(),
Err(e) => {
tracing::warn!("[F-03A] 读取 allowed_dirs 失败,保持默认: {}", e);
return;
tracing::warn!("[F-03A] 读取 allowed_dirs 失败: {}", e);
Vec::new()
}
};
let dirs: Vec<String> = match serde_json::from_str(&raw) {
Ok(v) => v,
Err(e) => {
tracing::warn!("[F-03A] allowed_dirs 非 JSON 字符串数组,保持默认: {}", e);
return;
// 读 projects.bind_directory(所有项目的绑定目录,自动白名单)
let project_dirs: Vec<String> = {
let repo = df_storage::crud::ProjectRepo::new(&self.db);
match repo.list_all().await {
Ok(projects) => projects.iter()
.filter_map(|p| p.path.as_ref().filter(|d| !d.is_empty()).map(|d| d.clone()))
.collect(),
Err(e) => {
tracing::warn!("[F-03B] 读取项目绑定目录失败: {}", e);
Vec::new()
}
}
};
// 合并:KV allowed_dirs + projects.bind_directory
let mut all_dirs = kv_dirs;
all_dirs.extend(project_dirs);
// 去重
all_dirs.sort();
all_dirs.dedup();
let mut set = HashSet::new();
// F-260619-03 收尾:KV 有配时尊重用户 persistent(不无条件塞 workspace_root)
// 用户 KV 配置不含 workspace_root → 工程根也需授权(动态白名单完整语义,
// 兑现 commit 声称"用户删白名单后需授权")。仅 KV 未配时 default_with_root 含 workspace_root。
for d in dirs {
// KV 未配(空)时保留 workspace_root(向后兼容:默认根免授权)
if all_dirs.is_empty() {
set.insert(workspace_root_path());
}
for d in all_dirs {
let d = d.trim();
if d.is_empty() {
continue;
}
let p = PathBuf::from(d);
// canonicalize 成功用真实路径(去 symlink/大小写归一);失败回退原字面量
let normalized = std::fs::canonicalize(&p).unwrap_or_else(|_| {
// 规范化分隔符 trim 尾部,保持与 starts_with 比对一致
// canonicalize 成功用真实路径(去 symlink/大小写归一);失败回退原字面量 trim
// (兼容"先授权目录,目录暂不存在"用例)。失败打 warn 便于排查静默授权错路径。
let normalized = strip_verbatim(std::fs::canonicalize(&p).unwrap_or_else(|_| {
tracing::warn!("[allowed_dirs] canonicalize 失败(目录可能不存在),按字面量保存: {}", d);
PathBuf::from(d.trim_end_matches(['/', '\\']))
});
}));
set.insert(normalized);
}
// F-260619-03 Phase B: reload 时保留当前会话临时授权(session 不落库,仅内存),
@@ -602,23 +645,28 @@ impl AppState {
/// - 内存:reload_allowed_dirs 重新加载(规范化逻辑复用,避免双份)
/// 返回持久化后的规范化路径列表(供前端回显 canonicalize 后的真实路径)。
pub async fn set_allowed_dirs(&self, dirs: Vec<String>) -> Result<Vec<String>> {
// 去空 + 去重(保留顺序,前端展示友好)
// 去空 + 去重(保留顺序,前端展示友好)+ 黑名单预校验(防持久化系统敏感目录,
// 纵深防御:即便写入,is_authorized 运行时黑名单也兜底拒,预校验保证白名单 UI 洁净)
let mut seen = HashSet::new();
let cleaned: Vec<String> = dirs
.into_iter()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.filter(|s| seen.insert(s.clone()))
.filter(|s| !is_in_system_blacklist(&PathBuf::from(s)))
.collect();
let json = serde_json::to_string(&cleaned)?;
self.settings.set(AllowedDirs::SETTINGS_KEY, &json).await
.map_err(|e| anyhow::anyhow!("持久化 allowed_dirs 失败: {}", e))?;
self.reload_allowed_dirs().await;
// 返回内存白名单(含 workspace_root)的规范化字符串列表(前端可看到真实生效路径)
// 返回内存白名单的规范化字符串列表(过滤 workspace_root,与 get_allowed_dirs 一致:
// 内部根不暴露前端,前端回显只含用户显式配置的目录)
let root = workspace_root_path();
let guard = self.allowed_dirs.read().await;
let mut out: Vec<String> = guard
.persistent
.iter()
.filter(|p| **p != root)
.map(|p| p.to_string_lossy().to_string())
.collect();
out.sort();
@@ -627,10 +675,14 @@ impl AppState {
/// F-260619-03 Phase A: 读内存白名单为字符串列表(供 Settings IPC `ai_get_allowed_dirs` 回显)。
pub async fn get_allowed_dirs(&self) -> Vec<String> {
// 不返回内部 workspace_root(隐式授权,is_authorized 经 persistent 放行):
// 避免把内部根目录暴露到前端用户管理列表。前后端均不再各自猜测 root 形态。
let root = workspace_root_path();
let guard = self.allowed_dirs.read().await;
let mut out: Vec<String> = guard
.persistent
.iter()
.filter(|p| **p != root)
.map(|p| p.to_string_lossy().to_string())
.collect();
out.sort();
@@ -664,9 +716,9 @@ impl AppState {
return;
}
let p = PathBuf::from(d);
let normalized = std::fs::canonicalize(&p).unwrap_or_else(|_| {
let normalized = strip_verbatim(std::fs::canonicalize(&p).unwrap_or_else(|_| {
PathBuf::from(d.trim_end_matches(['/', '\\']))
});
}));
self.allowed_dirs.write().await.session.insert(normalized);
}
@@ -903,5 +955,52 @@ mod tests {
other => panic!("System32 应 Denied, got {:?}", other),
}
}
// ============================================================
// F-260620: strip_verbatim 比对侧收口 + 黑名单增强测试
// 三方审查(安全/UX/跨端)交叉印证:strip_verbatim 仅写入侧调用,比对侧遗漏致误弹窗。
// ============================================================
/// F-260620: is_authorized 比对侧 strip_verbatim — candidate 带 \\?\ 前缀也命中白名单
/// (handler canonicalize 后路径 vs persistent 词法路径,形态不一致曾致误弹窗)。
/// persistent 用动态 workspace_root_path(非硬编码开发机路径),candidate 拼 verbatim 前缀。
#[test]
fn test_is_authorized_strips_verbatim_prefix() {
let allowed = AllowedDirs::default_with_root();
let child = workspace_root_path().join("src").join("main.rs");
// candidate 带 verbatim 前缀(handler canonicalize 后形态)应命中白名单
let verbatim = PathBuf::from(format!("{}{}", r"\\?\", child.to_string_lossy()));
assert!(allowed.is_authorized(&verbatim), "verbatim 前缀路径应命中白名单");
// 设备命名空间前缀(\\.\)
let dev = PathBuf::from(format!("{}{}", r"\\.\", child.to_string_lossy()));
assert!(allowed.is_authorized(&dev), "设备命名空间前缀路径应命中白名单");
}
/// F-260620: strip_verbatim 三前缀(\\?\ / \\.\ / \??\)统一去除
#[test]
fn test_strip_verbatim_three_prefixes() {
assert_eq!(strip_verbatim(PathBuf::from(r"\\?\E:\foo")), PathBuf::from("E:\\foo"));
assert_eq!(strip_verbatim(PathBuf::from(r"\\.\E:\foo")), PathBuf::from("E:\\foo"));
assert_eq!(strip_verbatim(PathBuf::from(r"\??\E:\foo")), PathBuf::from("E:\\foo"));
// 无前缀原样返回
assert_eq!(strip_verbatim(PathBuf::from("E:\\foo")), PathBuf::from("E:\\foo"));
}
/// F-260620: 黑名单增强 — Windows 根本身 / ProgramData / 用户凭据目录(.ssh/.aws/.gnupg)
#[test]
fn test_blacklist_windows_root_programdata_creds() {
// C:\Windows 根本身(不再限定 system32 子目录 — 防 win.ini/hosts 等)
assert!(is_in_system_blacklist(&PathBuf::from("C:\\Windows\\win.ini")));
assert!(is_in_system_blacklist(&PathBuf::from("C:\\Windows\\explorer.exe")));
// ProgramData
assert!(is_in_system_blacklist(&PathBuf::from("C:\\ProgramData\\App\\config")));
// 用户凭据目录(从 validate_path 迁移统一,任意路径段命中即拒)
assert!(is_in_system_blacklist(&PathBuf::from("C:\\Users\\user\\.ssh\\id_rsa")));
assert!(is_in_system_blacklist(&PathBuf::from("/home/user/.aws/credentials")));
assert!(is_in_system_blacklist(&PathBuf::from("/home/user/.gnupg/pubring.gpg")));
// 普通目录不误伤(devflow 自身 / 含 appdata 段的合法备份目录)
assert!(!is_in_system_blacklist(&PathBuf::from("E:\\wk-lab\\devflow\\src")));
assert!(!is_in_system_blacklist(&PathBuf::from("D:\\backup\\appdata\\data")));
}
}