新增: 路径授权三档化 once/session/always

Phase B 弹窗 once/always → once/session/always 三档(本次单次执行后清/当前会话切会话清/始终落KV)。修 once 原写 session 致本次实为本会话名实不符。AllowedDirs 加 once 层 + ai_authorize_dir decision 改 match 四分支 + 执行后 clear_once。
This commit is contained in:
2026-06-25 00:23:07 +08:00
parent 56a08fb5c1
commit 24c3fa08f1
10 changed files with 116 additions and 46 deletions

View File

@@ -436,10 +436,14 @@ pub struct AllowedDirs {
/// 持久化授权目录(Settings KV `allowed_dirs`,JSON 字符串数组)。
/// 已规范化(canonicalize 失败回退原字面量),便于 starts_with 精确比对。
pub persistent: HashSet<PathBuf>,
/// F-260619-03 Phase B: 会话级临时授权目录(弹窗"仅本次"写入,进程级内存)。
/// F-260619-03 Phase B: 会话级临时授权目录(弹窗"当前会话"选项写入,进程级内存)。
/// 仅当前活跃会话生效,切换/新建/删除会话由 clear_session_allowed_dirs 清空(不落库)。
/// handler 闭包与 process_tool_calls 预校验均读此字段,确保两端授权判定一致。
pub session: HashSet<PathBuf>,
/// 本次单次授权目录(弹窗"本次"选项写入)。单次工具执行放行后由 clear_once_allowed_dirs
/// 清空(用完即弃,下次同路径再访问仍弹窗)。区别于 session(整会话有效)。
/// 三档授权语义:once=本次单次 / session=当前会话 / persistent=始终落 KV。
pub once: HashSet<PathBuf>,
}
impl AllowedDirs {
@@ -451,7 +455,7 @@ impl AllowedDirs {
pub fn default_with_root() -> Self {
let mut set = HashSet::new();
set.insert(workspace_root_path());
Self { persistent: set, session: HashSet::new() }
Self { persistent: set, session: HashSet::new(), once: HashSet::new() }
}
/// 路径是否被授权:persistent 或 session 白名单任一 starts_with 命中即放行。
@@ -484,6 +488,7 @@ impl AllowedDirs {
let cand = strip_verbatim(best_effort_canonicalize(candidate));
self.persistent.iter().any(|d| cand.starts_with(d))
|| self.session.iter().any(|d| cand.starts_with(d))
|| self.once.iter().any(|d| cand.starts_with(d))
}
}
@@ -806,7 +811,8 @@ impl AppState {
// 仅覆盖 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 };
let preserved_once = std::mem::take(&mut guard.once);
*guard = AllowedDirs { persistent: set, session: preserved_session, once: preserved_once };
}
/// F-260619-03 Phase A: 写 Settings KV + 同步内存白名单(供 Settings IPC 调用)。
@@ -890,6 +896,28 @@ impl AppState {
pub async fn clear_session_allowed_dirs(&self) {
self.allowed_dirs.write().await.session.clear();
}
/// F-260619-03 Phase B: 追加本次单次授权目录(弹窗"本次"选项)。
///
/// 写入 `allowed_dirs.once`(进程级内存,不落库)。handler 执行工具时 read lock
/// 取快照读 once 放行,执行后由 clear_once_allowed_dirs 清空(本次放行即失效,
/// 下次同路径仍弹窗)。规范化与 add_session_allowed_dir 一致(canonicalize 失败回退 trim)。
pub async fn add_once_allowed_dir(&self, dir: String) {
let d = dir.trim();
if d.is_empty() {
return;
}
let p = PathBuf::from(d);
let normalized = strip_verbatim(std::fs::canonicalize(&p).unwrap_or_else(|_| {
PathBuf::from(d.trim_end_matches(['/', '\\']))
}));
self.allowed_dirs.write().await.once.insert(normalized);
}
/// F-260619-03 Phase B: 清空本次单次授权(工具执行后调,本次放行即失效)。
pub async fn clear_once_allowed_dirs(&self) {
self.allowed_dirs.write().await.once.clear();
}
}
/// F-260619-03 Phase B: 路径字符串等价比较(canonicalize 后比对,失败回退小写比对)。
@@ -967,7 +995,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, session: HashSet::new() };
let allowed = AllowedDirs { persistent: set, session: HashSet::new(), once: HashSet::new() };
// 精确命中 + 子路径 starts_with 命中
assert!(allowed.is_authorized(&custom));
assert!(allowed.is_authorized(&custom.join("sub").join("file.txt")));
@@ -978,7 +1006,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, session: HashSet::new() };
let allowed = AllowedDirs { persistent: set, session: HashSet::new(), once: HashSet::new() };
let outside = PathBuf::from("E:/wk-test-unauthorized/file.txt");
assert!(!allowed.is_authorized(&outside), "未授权目录应被拒绝");
}
@@ -1007,7 +1035,7 @@ mod tests {
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 };
let allowed = AllowedDirs { persistent: HashSet::new(), session, once: HashSet::new() };
assert!(allowed.is_authorized(&PathBuf::from("E:/wk-temp-session/file.txt")));
}
@@ -1018,7 +1046,7 @@ mod tests {
persistent.insert(PathBuf::from("E:/wk-persist"));
let mut session = HashSet::new();
session.insert(PathBuf::from("E:/wk-session"));
let allowed = AllowedDirs { persistent, session };
let allowed = AllowedDirs { persistent, session, once: HashSet::new() };
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")));
@@ -1034,7 +1062,7 @@ mod tests {
let mut persistent = HashSet::new();
// 用户误把整个 C:\ 加入白名单
persistent.insert(PathBuf::from("C:\\"));
let allowed = AllowedDirs { persistent, session: HashSet::new() };
let allowed = AllowedDirs { persistent, session: HashSet::new(), once: HashSet::new() };
// System32 应被黑名单拒(尽管 C:\ 在白名单)
assert!(!allowed.is_authorized(&PathBuf::from("C:\\Windows\\System32\\config\\sam")));
}
@@ -1044,7 +1072,7 @@ mod tests {
fn test_blacklist_windows_program_files() {
let mut persistent = HashSet::new();
persistent.insert(PathBuf::from("C:\\"));
let allowed = AllowedDirs { persistent, session: HashSet::new() };
let allowed = AllowedDirs { persistent, session: HashSet::new(), once: HashSet::new() };
assert!(!allowed.is_authorized(&PathBuf::from("C:\\Program Files\\SomeApp\\app.exe")));
}
@@ -1100,7 +1128,7 @@ mod tests {
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 };
let allowed = AllowedDirs { persistent: HashSet::new(), session, once: HashSet::new() };
match check_path_authorization("E:/wk-session/sub/file.txt", &allowed) {
PathAuthDecision::Authorized => {}
other => panic!("session 命中应 Authorized, got {:?}", other),