新增: AI 工具意图识别层 + 文件访问权限模型 Phase B+C
会话意图识别层(intent.rs / df-ai crate):
- Intent 枚举 11 态 + recognize 规则识别(关键词+权重+优先级)+ tool_subset_for 工具子集映射
- 模态接口预留 None,独立模块待接入 loop,36 单测
文件访问权限模型 Phase B+C(F-260619-03):
- Phase B 会话临时白名单 + 循环挂起 + DirAuthDialog 弹窗(仅本次/未来都允许/拒绝)
- Phase C 系统目录黑名单(Win System32/Program Files;Unix /etc /usr...)分段匹配 + 写操作约束
- 已审 CR-260619-09 ✅ PASS
This commit is contained in:
@@ -305,20 +305,26 @@ pub struct AppState {
|
||||
pub allowed_dirs: Arc<RwLock<AllowedDirs>>,
|
||||
}
|
||||
|
||||
/// F-260619-03 Phase A: AI 工具文件访问授权目录白名单
|
||||
/// F-260619-03 Phase A/B/C: AI 工具文件访问授权目录白名单
|
||||
///
|
||||
/// Phase A 仅含 `persistent`(持久化白名单,从 Settings KV `allowed_dirs` 加载,
|
||||
/// JSON 数组 `["E:/wk-lab/u-abc"]`)。`resolve_workspace_path` 校验时:
|
||||
/// - workspace_root 始终视为已授权(向后兼容,默认根)
|
||||
/// - 任一 persistent 目录 starts_with 命中即放行
|
||||
///
|
||||
/// Phase B 将扩展 `session: HashSet<PathBuf>`(会话临时授权) + 弹窗挂起机制,
|
||||
/// 届时 is_authorized 增加 session 校验。当前结构预留扩展位但不引入。
|
||||
/// - Phase A:`persistent`(持久化白名单,从 Settings KV `allowed_dirs` 加载,
|
||||
/// JSON 数组 `["E:/wk-lab/u-abc"]`)。`resolve_workspace_path` 校验时:
|
||||
/// - workspace_root 始终视为已授权(向后兼容,默认根)
|
||||
/// - 任一 persistent 目录 starts_with 命中即放行
|
||||
/// - Phase B:`session`(进程级会话临时授权,弹窗"仅本次"写入;切换/新建/删除会话清空)。
|
||||
/// 单用户桌面应用 active_conversation_id 单全局模型,session 字段随 active 切换清空,
|
||||
/// 行为等价"当前活跃会话的临时授权"。handler 闭包(read lock 取快照)与
|
||||
/// process_tool_calls 预校验均读此字段,两端一致。
|
||||
/// - Phase C:黑名单(is_authorized 内,黑名单优先于白名单 — 命中即拒)。
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct AllowedDirs {
|
||||
/// 持久化授权目录(Settings KV `allowed_dirs`,JSON 字符串数组)。
|
||||
/// 已规范化(canonicalize 失败回退原字面量),便于 starts_with 精确比对。
|
||||
pub persistent: HashSet<PathBuf>,
|
||||
/// F-260619-03 Phase B: 会话级临时授权目录(弹窗"仅本次"写入,进程级内存)。
|
||||
/// 仅当前活跃会话生效,切换/新建/删除会话由 clear_session_allowed_dirs 清空(不落库)。
|
||||
/// handler 闭包与 process_tool_calls 预校验均读此字段,确保两端授权判定一致。
|
||||
pub session: HashSet<PathBuf>,
|
||||
}
|
||||
|
||||
impl AllowedDirs {
|
||||
@@ -330,22 +336,124 @@ impl AllowedDirs {
|
||||
pub fn default_with_root() -> Self {
|
||||
let mut set = HashSet::new();
|
||||
set.insert(workspace_root_path());
|
||||
Self { persistent: set }
|
||||
Self { persistent: set, session: HashSet::new() }
|
||||
}
|
||||
|
||||
/// 路径是否被授权:workspace_root 始终授权 + persistent 任一 starts_with 命中。
|
||||
/// 路径是否被授权:workspace_root 始终授权 + persistent 或 session 任一 starts_with 命中即放行。
|
||||
///
|
||||
/// **Phase C 黑名单优先**:即使白名单命中,若路径落入系统敏感目录(Windows
|
||||
/// `\Windows\System32` / `\Program Files\`;Unix `/etc /usr /bin /sbin /boot
|
||||
/// /dev /proc /sys`)仍拒。黑名单优先于白名单,防用户误授权系统目录。
|
||||
///
|
||||
/// 输入 `candidate` 应为 canonicalize 后的真实路径(防 symlink 逃逸);
|
||||
/// 调用方(resolve_workspace_path_with_allowed)负责 canonicalize,本函数只做 starts_with 比对。
|
||||
pub fn is_authorized(&self, candidate: &Path) -> bool {
|
||||
// Phase C: 黑名单优先(白名单命中也拒)。validate_path 已挡 .ssh/.aws 等,
|
||||
// 此处补系统核心目录(用户可能误把 C:\ 加入 persistent,黑名单兜底拒 System32)。
|
||||
if is_in_system_blacklist(candidate) {
|
||||
return false;
|
||||
}
|
||||
let root = workspace_root_path();
|
||||
if candidate.starts_with(&root) {
|
||||
return true;
|
||||
}
|
||||
self.persistent.iter().any(|d| candidate.starts_with(d))
|
||||
|| self.session.iter().any(|d| candidate.starts_with(d))
|
||||
}
|
||||
}
|
||||
|
||||
/// F-260619-03 Phase C: 路径授权决策(预校验用,process_tool_calls 分类前调)。
|
||||
///
|
||||
/// `check_path_authorization` 返回本枚举,process_tool_calls 据此决定:
|
||||
/// - `Authorized`:路径已授权 → 走原 Low/Med/High 流程
|
||||
/// - `NeedsAuthorization`:路径未授权但非黑名单 → Phase B 挂起弹窗(emit AiDirAuthRequired)
|
||||
/// - `Denied`:路径命中黑名单 → 硬拒(工具返 Err tool_result,不挂起)
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum PathAuthDecision {
|
||||
/// 已授权(workspace_root / persistent / session 命中且非黑名单)
|
||||
Authorized,
|
||||
/// 未授权且非黑名单:需 Phase B 弹窗询问用户。附带规范化后的待授权目录(父目录,
|
||||
/// 对齐 session_trust 目录粒度),供"仅本次/未来都允许"写入白名单。
|
||||
NeedsAuthorization { dir: PathBuf },
|
||||
/// 命中系统黑名单(Phase C):硬拒,工具返 Err,不弹窗。
|
||||
Denied { reason: String },
|
||||
}
|
||||
|
||||
/// F-260619-03 Phase C: 系统敏感目录黑名单判定(跨平台)。
|
||||
///
|
||||
/// 白名单命中但黑名单命中 → 拒(防用户误把整个盘符如 `C:\` 加入 persistent 致
|
||||
/// System32 被放行)。validate_path(tool_registry.rs)已挡 .ssh/.aws/AppData 等,
|
||||
/// 此处补系统核心目录。
|
||||
///
|
||||
/// - Windows(不区分大小写,按路径分隔符分段):`\Windows\System32`、`\Program Files\`、
|
||||
/// `\Program Files (x86)\`、`\Windows\` 根下 System/SysWOW64
|
||||
/// - Unix:`/etc`、`/usr`、`/bin`、`/sbin`、`/boot`、`/dev`、`/proc`、`/sys`
|
||||
pub(crate) fn is_in_system_blacklist(path: &Path) -> bool {
|
||||
let s = path.to_string_lossy().to_lowercase();
|
||||
let seps = ['/', '\\'];
|
||||
// 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
|
||||
if cfg!(windows) {
|
||||
if *seg == "windows" {
|
||||
if let Some(next) = segs.get(i + 1) {
|
||||
if matches!(*next, "system32" | "syswow64" | "system") {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
// C:\Program Files / C:\Program Files (x86)
|
||||
if *seg == "program files" || *seg == "program files (x86)" {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// Unix 系统目录(路径首段为这些即拒;Windows 上也防 Unix 风格绝对路径,防御性)
|
||||
if i == 0 && matches!(*seg, "etc" | "usr" | "bin" | "sbin" | "boot" | "dev" | "proc" | "sys") {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// F-260619-03 Phase B/C: 路径授权预校验(供 process_tool_calls 分类前调)。
|
||||
///
|
||||
/// 词法层判定(不 canonicalize,因路径可能不存在 — write_file 新建)。返回三态决策:
|
||||
/// - 路径规范化(去 .. / 锚定 workspace_root)后,若命中黑名单 → `Denied`
|
||||
/// - 否则若 `is_authorized(规范化路径)`(persistent + session) → `Authorized`
|
||||
/// - 否则 → `NeedsAuthorization { dir: 父目录规范化 }`(目录粒度,对齐 session_trust)
|
||||
///
|
||||
/// 注意:本函数只做词法层预判(防不存在路径兜底);实际执行时 handler 内
|
||||
/// resolve_workspace_path_with_allowed 仍做完整校验(canonicalize symlink 防逃逸)。
|
||||
/// 黑名单在两处都判(is_authorized 内 + 此处独立判),双保险。
|
||||
pub fn check_path_authorization(
|
||||
raw_path: &str,
|
||||
allowed: &AllowedDirs,
|
||||
) -> PathAuthDecision {
|
||||
// 规范化:绝对路径原样,相对路径锚定 workspace_root(与 resolve_workspace_path_impl 一致)
|
||||
let resolved = if Path::new(raw_path).is_absolute() {
|
||||
PathBuf::from(raw_path)
|
||||
} else {
|
||||
workspace_root_path().join(raw_path)
|
||||
};
|
||||
// Phase C: 黑名单优先独立判定(is_authorized 内也判,此处先判便于 NeedsAuthorization
|
||||
// 不误把黑名单路径推到弹窗 — 黑名单路径直接硬拒不让用户"授权")。
|
||||
if is_in_system_blacklist(&resolved) {
|
||||
return PathAuthDecision::Denied {
|
||||
reason: format!("路径命中系统敏感目录黑名单: {}", raw_path),
|
||||
};
|
||||
}
|
||||
if allowed.is_authorized(&resolved) {
|
||||
return PathAuthDecision::Authorized;
|
||||
}
|
||||
// 未授权 → 取父目录作待授权目录(目录粒度,对齐 session_trust 的 dir 语义)
|
||||
let dir = resolved
|
||||
.parent()
|
||||
.map(|p| p.to_path_buf())
|
||||
.unwrap_or_else(|| workspace_root_path());
|
||||
PathAuthDecision::NeedsAuthorization { dir }
|
||||
}
|
||||
|
||||
/// workspace 根目录(src-tauri 上两级,与 tool_registry::workspace_root 同源)。
|
||||
fn workspace_root_path() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
@@ -477,7 +585,11 @@ impl AppState {
|
||||
});
|
||||
set.insert(normalized);
|
||||
}
|
||||
*self.allowed_dirs.write().await = AllowedDirs { persistent: set };
|
||||
// F-260619-03 Phase B: reload 时保留当前会话临时授权(session 不落库,仅内存),
|
||||
// 仅覆盖 persistent。用户改 Settings 不影响当前会话已临时授权的目录。
|
||||
let mut guard = self.allowed_dirs.write().await;
|
||||
let preserved_session = std::mem::take(&mut guard.session);
|
||||
*guard = AllowedDirs { persistent: set, session: preserved_session };
|
||||
}
|
||||
|
||||
/// F-260619-03 Phase A: 写 Settings KV + 同步内存白名单(供 Settings IPC 调用)。
|
||||
@@ -520,6 +632,54 @@ impl AppState {
|
||||
out.sort();
|
||||
out
|
||||
}
|
||||
|
||||
/// F-260619-03 Phase B: 追加持久化授权目录(弹窗"未来都允许"选项)。
|
||||
///
|
||||
/// 读当前 persistent → 追加新目录(去重)→ set_allowed_dirs 持久化 + 同步内存。
|
||||
/// 返回持久化后规范化列表(含 workspace_root)。
|
||||
pub async fn add_persistent_allowed_dir(&self, dir: String) -> Result<Vec<String>> {
|
||||
let d = dir.trim().to_string();
|
||||
if d.is_empty() {
|
||||
return Ok(self.get_allowed_dirs().await);
|
||||
}
|
||||
let mut current = self.get_allowed_dirs().await;
|
||||
if !current.iter().any(|c| paths_eq(c, &d)) {
|
||||
current.push(d);
|
||||
}
|
||||
self.set_allowed_dirs(current).await
|
||||
}
|
||||
|
||||
/// F-260619-03 Phase B: 追加会话级临时授权目录(弹窗"仅本次"选项)。
|
||||
///
|
||||
/// 写入 `allowed_dirs.session`(进程级内存,不落库)。handler 闭包 read lock 取快照时
|
||||
/// 与 process_tool_calls 预校验读同一字段,两端授权判定一致。规范化:canonicalize
|
||||
/// 失败回退原字面量 trim(与 reload_allowed_dirs 一致)。
|
||||
pub async fn add_session_allowed_dir(&self, dir: String) {
|
||||
let d = dir.trim();
|
||||
if d.is_empty() {
|
||||
return;
|
||||
}
|
||||
let p = PathBuf::from(d);
|
||||
let normalized = std::fs::canonicalize(&p).unwrap_or_else(|_| {
|
||||
PathBuf::from(d.trim_end_matches(['/', '\\']))
|
||||
});
|
||||
self.allowed_dirs.write().await.session.insert(normalized);
|
||||
}
|
||||
|
||||
/// F-260619-03 Phase B: 清空会话级临时授权(切换/新建/删除活跃会话时调)。
|
||||
///
|
||||
/// 单用户桌面应用 active_conversation_id 单全局模型,session 字段语义为
|
||||
/// "当前活跃会话的临时授权",切走即清空(不跨会话继承临时授权)。
|
||||
pub async fn clear_session_allowed_dirs(&self) {
|
||||
self.allowed_dirs.write().await.session.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// F-260619-03 Phase B: 路径字符串等价比较(canonicalize 后比对,失败回退小写比对)。
|
||||
fn paths_eq(a: &str, b: &str) -> bool {
|
||||
let pa = std::fs::canonicalize(a).map(|p| p.to_string_lossy().to_string()).unwrap_or_else(|_| a.to_string());
|
||||
let pb = std::fs::canonicalize(b).map(|p| p.to_string_lossy().to_string()).unwrap_or_else(|_| b.to_string());
|
||||
pa.eq_ignore_ascii_case(&pb)
|
||||
}
|
||||
|
||||
/// 构建节点注册表 — 注册内置节点
|
||||
@@ -590,7 +750,7 @@ mod tests {
|
||||
let mut set = HashSet::new();
|
||||
let custom = PathBuf::from("E:/wk-test-external-dir");
|
||||
set.insert(custom.clone());
|
||||
let allowed = AllowedDirs { persistent: set };
|
||||
let allowed = AllowedDirs { persistent: set, session: HashSet::new() };
|
||||
// 精确命中 + 子路径 starts_with 命中
|
||||
assert!(allowed.is_authorized(&custom));
|
||||
assert!(allowed.is_authorized(&custom.join("sub").join("file.txt")));
|
||||
@@ -601,7 +761,7 @@ mod tests {
|
||||
fn test_allowed_dirs_unauthorized_rejected() {
|
||||
let mut set = HashSet::new();
|
||||
set.insert(PathBuf::from("E:/wk-test-authorized"));
|
||||
let allowed = AllowedDirs { persistent: set };
|
||||
let allowed = AllowedDirs { persistent: set, session: HashSet::new() };
|
||||
let outside = PathBuf::from("E:/wk-test-unauthorized/file.txt");
|
||||
assert!(!allowed.is_authorized(&outside), "未授权目录应被拒绝");
|
||||
}
|
||||
@@ -620,5 +780,124 @@ mod tests {
|
||||
fn test_allowed_dirs_settings_key_stable() {
|
||||
assert_eq!(AllowedDirs::SETTINGS_KEY, "allowed_dirs");
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// F-260619-03 Phase B: session 临时授权语义测试
|
||||
// ============================================================
|
||||
|
||||
/// Phase B: session 命中放行(persistent 未命中但 session 命中)
|
||||
#[test]
|
||||
fn test_allowed_dirs_session_authorized() {
|
||||
let mut session = HashSet::new();
|
||||
session.insert(PathBuf::from("E:/wk-temp-session"));
|
||||
let allowed = AllowedDirs { persistent: HashSet::new(), session };
|
||||
assert!(allowed.is_authorized(&PathBuf::from("E:/wk-temp-session/file.txt")));
|
||||
}
|
||||
|
||||
/// Phase B: persistent + session 任一命中即放行
|
||||
#[test]
|
||||
fn test_allowed_dirs_persistent_or_session() {
|
||||
let mut persistent = HashSet::new();
|
||||
persistent.insert(PathBuf::from("E:/wk-persist"));
|
||||
let mut session = HashSet::new();
|
||||
session.insert(PathBuf::from("E:/wk-session"));
|
||||
let allowed = AllowedDirs { persistent, session };
|
||||
assert!(allowed.is_authorized(&PathBuf::from("E:/wk-persist/a")));
|
||||
assert!(allowed.is_authorized(&PathBuf::from("E:/wk-session/b")));
|
||||
assert!(!allowed.is_authorized(&PathBuf::from("E:/wk-other/c")));
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// F-260619-03 Phase C: 系统目录黑名单测试
|
||||
// ============================================================
|
||||
|
||||
/// Phase C: Windows System32 黑名单命中(即使在白名单内也拒)
|
||||
#[test]
|
||||
fn test_blacklist_windows_system32() {
|
||||
let mut persistent = HashSet::new();
|
||||
// 用户误把整个 C:\ 加入白名单
|
||||
persistent.insert(PathBuf::from("C:\\"));
|
||||
let allowed = AllowedDirs { persistent, session: HashSet::new() };
|
||||
// System32 应被黑名单拒(尽管 C:\ 在白名单)
|
||||
assert!(!allowed.is_authorized(&PathBuf::from("C:\\Windows\\System32\\config\\sam")));
|
||||
}
|
||||
|
||||
/// Phase C: Windows Program Files 黑名单命中
|
||||
#[test]
|
||||
fn test_blacklist_windows_program_files() {
|
||||
let mut persistent = HashSet::new();
|
||||
persistent.insert(PathBuf::from("C:\\"));
|
||||
let allowed = AllowedDirs { persistent, session: HashSet::new() };
|
||||
assert!(!allowed.is_authorized(&PathBuf::from("C:\\Program Files\\SomeApp\\app.exe")));
|
||||
}
|
||||
|
||||
/// Phase C: 黑名单不误伤合法目录(含 "program files" 子串的自定义目录名)
|
||||
#[test]
|
||||
fn test_blacklist_no_false_positive() {
|
||||
// "my program files backup" 不应被拒(分段匹配,非精确段)
|
||||
assert!(!is_in_system_blacklist(&PathBuf::from("E:/my program files backup/x")));
|
||||
// 普通工作目录不拒
|
||||
assert!(!is_in_system_blacklist(&PathBuf::from("E:/wk-lab/devflow/src")));
|
||||
}
|
||||
|
||||
/// Phase C: Unix 系统目录黑名单(/etc /usr /bin 等)
|
||||
#[test]
|
||||
fn test_blacklist_unix_system_dirs() {
|
||||
assert!(is_in_system_blacklist(&PathBuf::from("/etc/passwd")));
|
||||
assert!(is_in_system_blacklist(&PathBuf::from("/usr/bin/python")));
|
||||
assert!(is_in_system_blacklist(&PathBuf::from("/proc/self/environ")));
|
||||
assert!(is_in_system_blacklist(&PathBuf::from("/sys/kernel")));
|
||||
// 普通用户目录不拒
|
||||
assert!(!is_in_system_blacklist(&PathBuf::from("/home/user/project")));
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// F-260619-03 Phase B/C: check_path_authorization 三态决策测试
|
||||
// ============================================================
|
||||
|
||||
/// Phase B/C: workspace_root 路径 → Authorized
|
||||
#[test]
|
||||
fn test_check_path_authorized_workspace() {
|
||||
let allowed = AllowedDirs::default_with_root();
|
||||
let rel = "src/main.rs";
|
||||
match check_path_authorization(rel, &allowed) {
|
||||
PathAuthDecision::Authorized => {}
|
||||
other => panic!("workspace_root 内路径应 Authorized, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
/// Phase B/C: 未授权路径 → NeedsAuthorization(附父目录)
|
||||
#[test]
|
||||
fn test_check_path_needs_auth() {
|
||||
let allowed = AllowedDirs::default(); // 空,无 workspace_root
|
||||
match check_path_authorization("E:/wk-external/file.txt", &allowed) {
|
||||
PathAuthDecision::NeedsAuthorization { dir } => {
|
||||
assert_eq!(dir, PathBuf::from("E:/wk-external"));
|
||||
}
|
||||
other => panic!("未授权路径应 NeedsAuthorization, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
/// Phase B: session 命中 → Authorized(check_path_authorization 读 session 字段)
|
||||
#[test]
|
||||
fn test_check_path_session_hit() {
|
||||
let mut session = HashSet::new();
|
||||
session.insert(PathBuf::from("E:/wk-session"));
|
||||
let allowed = AllowedDirs { persistent: HashSet::new(), session };
|
||||
match check_path_authorization("E:/wk-session/sub/file.txt", &allowed) {
|
||||
PathAuthDecision::Authorized => {}
|
||||
other => panic!("session 命中应 Authorized, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
/// Phase C: 黑名单路径 → Denied(不弹窗直接拒)
|
||||
#[test]
|
||||
fn test_check_path_denied_blacklist() {
|
||||
let allowed = AllowedDirs::default_with_root();
|
||||
match check_path_authorization("C:/Windows/System32/config/sam", &allowed) {
|
||||
PathAuthDecision::Denied { .. } => {}
|
||||
other => panic!("System32 应 Denied, got {:?}", other),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user