优化: AI授权目录改进(误弹窗根因+安全加固)
strip_verbatim比对侧收口/黑名单统一/symlink/bind双路径reload/toast接线;cargo test 18✅
This commit is contained in:
@@ -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")));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user