From 24c3fa08f14075c27481930257a89e2702bb5399 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BB=9D=E5=B0=98?= <237809796@qq.com> Date: Thu, 25 Jun 2026 00:23:07 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E:=20=E8=B7=AF=E5=BE=84?= =?UTF-8?q?=E6=8E=88=E6=9D=83=E4=B8=89=E6=A1=A3=E5=8C=96=20once/session/al?= =?UTF-8?q?ways?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase B 弹窗 once/always → once/session/always 三档(本次单次执行后清/当前会话切会话清/始终落KV)。修 once 原写 session 致本次实为本会话名实不符。AllowedDirs 加 once 层 + ai_authorize_dir decision 改 match 四分支 + 执行后 clear_once。 --- docs/todo.md | 1 + docs/待审查.md | 12 ++++++ src-tauri/src/commands/ai/commands/chat.rs | 47 +++++++++++++-------- src-tauri/src/state.rs | 48 +++++++++++++++++----- src/api/ai.ts | 6 +-- src/api/types.ts | 8 ++-- src/components/ai/DirAuthDialog.vue | 18 ++++++-- src/composables/ai/useAiApproval.ts | 14 +++---- src/i18n/en/aiChat.ts | 3 +- src/i18n/zh-CN/aiChat.ts | 5 ++- 10 files changed, 116 insertions(+), 46 deletions(-) diff --git a/docs/todo.md b/docs/todo.md index a2fd166..dae971a 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -581,6 +581,7 @@ > **与 MCP Server(F-260619-02)无关**:本需求是 DevFlow 应用内部 AI 工具(read_file/write_file 等)的文件系统访问权限升级,不涉及对外 MCP 协议暴露。 - [x] **F-260619-03 [P1]** ✅ — **AI 工具文件访问动态权限模型**(Phase A 持久白名单 + Phase B 会话临时/弹窗 + Phase C 黑名单 + workspace_root 去固定根完整,CR-09 ✅ + b22e9ae reload 尊重用户 persistent) + - **2026-06-24 三档授权演进**:Phase B 弹窗从 once/always 两档 → **once/session/always 三档**(本次单次执行后清 / 当前会话切会话清 / 始终落KV)。修 once 原写 session 致"本次"实为"本会话"名实不符(确定性 bug)。AllowedDirs 加 `once` 层(state.rs)+ ai_authorize_dir decision 改 match 四分支(chat.rs)+ 执行后 clear_once + tracing 审计 + 前端 DirAuthDialog 加"当前会话"按钮 + useAiApproval/api/types.ts/i18n 联合类型扩 session。cargo check 0 + vue-tsc 0。审查见待审查 CR-260624-01。 **核心机制**:当 AI 调用 `read_file` / `write_file` 等文件系统工具时,路径校验从「单一 workspace_root 前缀匹配」升级为「动态白名单池校验 + 未命中则挂起 Agentic Loop 向前端弹窗申请授权」。 diff --git a/docs/待审查.md b/docs/待审查.md index a291fce..90c3028 100644 --- a/docs/待审查.md +++ b/docs/待审查.md @@ -23,6 +23,18 @@ ## 当前队列 +### CR-260624-01 AI 路径授权三档化(once/session/always) — ✅ 已审(PASS·单 agent + 6-agent 对抗深审 0 finding) + +- **范围**:AllowedDirs 加 `once` 层(state.rs:444)+ ai_authorize_dir decision 改 match 四分支 once/session/always(chat.rs:846)+ 执行后 clear_once(chat.rs:924)+ 前端 DirAuthDialog 加"当前会话"按钮 + useAiApproval.ts/api/ai.ts/api/types.ts decision 联合类型扩 'session' + i18n zh/en 加 dirAuthSession。 +- **维度**: + - (a) **once 清空时机**:clear_once 在 execute 后无条件清(state.rs:924 附近 chat.rs)。竞态:同 once 路径并发工具调用?现状工具串行(per_conv 单 generating),无并发,OK——但核验确认。 + - (b) **session vs once 语义边界**:session 切会话清(conversation.rs clear_session)/ once 执行后清 / reload_allowed_dirs 保留 both(state.rs:810 take once + session)。核 reload 是否漏保留 once。 + - (c) **is_authorized 读 once**:state.rs is_authorized 三层 `persistent || session || once` 任一命中。核 once 命中分支存在。 + - (d) **前端全链路**:DirAuthDialog handleAuthorize / useAiApproval approveToolCall / api.authorizeDir 三处 decision 联合类型均含 'session',types.ts decision: string 不受限(无类型保护,但 IPC 接受任意字符串,match _ 兜底)。 +- **核验方式**:grep `once|session` 贯穿 state.rs/chat.rs/前端 + 核 clear_once 调用点 + deny 分支(早 return,once 未写,不需清,核确认)。 +- **关注点**:批量 approve 仍用 once(单卡语义,不批量 always,useAiApproval:110);always 降级写 session(原降级 add_session,语义对齐)。 +- **审查结果**(2026-06-25):单 agent 审查(a2b2b35,6 维度全过)+ workflow 对抗深审(w9waeyg36,6 agent 并行 review + 每条 finding 独立 verify,**0 confirmed finding**)。once 清空时机(串行无并发误清)/ session-once 边界 / is_authorized 三层读 / deny 不触发 clear_once / 前端类型一致 全过。 + ### CR-260622-01 AI Chat 真实会话实测分析(84dc36c8·prompt 1.32M/completion 7.5K·3P0+3P1+2P2)— ✅ 已审(ISSUES·低优残留2项·P0清零) - **范围**:实测数据反推(**非静态代码审查**)。从生产 DB 会话 `84dc36c8`(D:\Downloads\kms,整理三知识库 HTML)全量 86 条消息反推上下文/工具/agent 行为缺陷。报告 `docs/05-代码审查/aichat-会话实测分析-2026-06-22.md`,分析脚本 `.temp/analyze_session.py`,DB 只读 `%APPDATA%/top.1216.devflow/devflow-dev.db`。 diff --git a/src-tauri/src/commands/ai/commands/chat.rs b/src-tauri/src/commands/ai/commands/chat.rs index 08abd50..b487c5e 100644 --- a/src-tauri/src/commands/ai/commands/chat.rs +++ b/src-tauri/src/commands/ai/commands/chat.rs @@ -751,9 +751,10 @@ fn append_cross_drive_note(result: serde_json::Value, note: &str) -> serde_json: /// 触发:process_tool_calls 预校验文件工具路径未命中白名单 → 挂起 loop(insert pending /// with path_auth=Some + emit AiDirAuthRequired)。前端弹窗三选项,用户选择后调本 IPC。 /// -/// `decision`: -/// - `"once"`:写入 `AppState.allowed_dirs.session`(进程级会话临时授权,随 active 会话切换/新建/删除由 clear_session_allowed_dirs 清空)→ 执行工具 → 恢复 loop -/// - `"always"`:写入持久化 Settings KV `allowed_dirs`(reload_allowed_dirs 同步内存)→ 执行 → 恢复 +/// `decision`(三授权档 + 拒): +/// - `"once"`:写入 `allowed_dirs.once`(本次单次,执行后 clear_once_allowed_dirs 清空)→ 执行 → 恢复 +/// - `"session"`:写入 `allowed_dirs.session`(当前会话,切换/新建/删除活跃会话由 clear_session_allowed_dirs 清空)→ 执行 → 恢复 +/// - `"always"`:写入持久化 Settings KV `allowed_dirs`(reload_allowed_dirs 同步内存 persistent)→ 执行 → 恢复 /// - `"deny"`:工具返 Err "用户拒绝路径授权" → 恢复 loop /// /// 复用审批恢复架构:remove pending → (写授权/拒) → execute/Err → 替换占位 tool_result → @@ -843,23 +844,33 @@ pub async fn ai_authorize_dir( return Ok("rejected".to_string()); } - // ── "once":写会话级临时授权(随会话销毁,不落库) ── - // L1 补丁:遍历所有未授权父目录(rename_file from+to 不同目录时多项)。 - if decision == "once" { - for d in &dir_strs { - state.add_session_allowed_dir(d.clone()).await; + // ── 授权档写入(三档:once 本次单次 / session 当前会话 / always 始终落 KV)── + // L1 补丁:遍历所有未授权父目录(rename_file from+to 不同目录时多项),确保 handler 放行。 + // deny 已在上方早 return,此处仅 once/session/always。 + match decision.as_str() { + "once" => { + for d in &dir_strs { + state.add_once_allowed_dir(d.clone()).await; + } + tracing::info!(target: "ai_authz", dirs = ?dir_strs, conv_id = ?conv_id, "[AI-AUTHZ] 授权档=once(本次单次,执行后清)"); } - } - - // ── "always":写持久化授权(Settings KV + 同步内存 persistent) ── - if decision == "always" { - for d in &dir_strs { - if let Err(e) = state.add_persistent_allowed_dir(d.clone()).await { - tracing::warn!("[F-03B] 持久化授权目录失败(降级仅本次会话): dir={} err={}", d, e); - // 降级:写入会话临时授权,保本路径本会话可用 + "session" => { + for d in &dir_strs { state.add_session_allowed_dir(d.clone()).await; } + tracing::info!(target: "ai_authz", dirs = ?dir_strs, conv_id = ?conv_id, "[AI-AUTHZ] 授权档=session(当前会话,切会话清)"); } + "always" => { + for d in &dir_strs { + if let Err(e) = state.add_persistent_allowed_dir(d.clone()).await { + tracing::warn!("[F-03B] 持久化授权目录失败(降级当前会话): dir={} err={}", d, e); + // 降级:写入会话临时授权,保本路径本会话可用 + state.add_session_allowed_dir(d.clone()).await; + } + } + tracing::info!(target: "ai_authz", dirs = ?dir_strs, conv_id = ?conv_id, "[AI-AUTHZ] 授权档=always(始终,落 KV)"); + } + _ => {} // deny 已上方早 return;未知 decision 不写授权(执行会再走预校验弹窗) } // ── 执行工具(路径现已授权,handler 内 resolve_workspace_path_with_allowed 放行) ── @@ -908,6 +919,10 @@ pub async fn ai_authorize_dir( } } + // once 授权档语义=本次单次:工具执行完(handler 已读 once 放行)立即清空,下次同路径仍弹窗。 + // 仅 once 档写入 once 层,无条件清空安全(session/always 不写 once,清空无副作用)。 + state.clear_once_allowed_dirs().await; + // 替换占位 tool_result 为真实结果 { let mut session = state.ai_session.lock().await; diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index 10c5c87..e63f94b 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -436,10 +436,14 @@ pub struct AllowedDirs { /// 持久化授权目录(Settings KV `allowed_dirs`,JSON 字符串数组)。 /// 已规范化(canonicalize 失败回退原字面量),便于 starts_with 精确比对。 pub persistent: HashSet, - /// F-260619-03 Phase B: 会话级临时授权目录(弹窗"仅本次"写入,进程级内存)。 + /// F-260619-03 Phase B: 会话级临时授权目录(弹窗"当前会话"选项写入,进程级内存)。 /// 仅当前活跃会话生效,切换/新建/删除会话由 clear_session_allowed_dirs 清空(不落库)。 /// handler 闭包与 process_tool_calls 预校验均读此字段,确保两端授权判定一致。 pub session: HashSet, + /// 本次单次授权目录(弹窗"本次"选项写入)。单次工具执行放行后由 clear_once_allowed_dirs + /// 清空(用完即弃,下次同路径再访问仍弹窗)。区别于 session(整会话有效)。 + /// 三档授权语义:once=本次单次 / session=当前会话 / persistent=始终落 KV。 + pub once: HashSet, } 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), diff --git a/src/api/ai.ts b/src/api/ai.ts index 1ed5131..3e5cda1 100644 --- a/src/api/ai.ts +++ b/src/api/ai.ts @@ -77,10 +77,10 @@ export const aiApi = { /** * F-260619-03 Phase B: 路径授权弹窗决策(消费 AiDirAuthRequired 挂起)。 - * decision: 'once'(仅本次,会话级)/ 'always'(未来都允许,持久化)/ 'deny'(拒绝)。 - * 后端 remove pending → 写授权目录(session/persistent)→ 执行工具 → try_continue 恢复 loop。 + * decision: 'once'(本次单次,执行后清)/ 'session'(当前会话,切会话清)/ 'always'(始终,持久化)/ 'deny'(拒绝)。 + * 后端 remove pending → 写授权目录(once/session/persistent)→ 执行工具 → try_continue 恢复 loop。 */ - authorizeDir(toolCallId: string, decision: 'once' | 'always' | 'deny'): Promise { + authorizeDir(toolCallId: string, decision: 'once' | 'session' | 'always' | 'deny'): Promise { return invoke('ai_authorize_dir', { toolCallId, decision }) }, diff --git a/src/api/types.ts b/src/api/types.ts index 77a041c..3e5364f 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -358,7 +358,7 @@ export type AiChatEvent = ({ type: 'AiToolAutoApproved'; id: string; tool: string; dir: string } | { // path_auth 审批链阶段3b(统一审批模型):AiApprovalRequired 携带 kind 字段区分 - // 'path'(F-260619-03 Phase B 路径授权挂起,走 once/always/deny)vs 'risk'(普通 RiskLevel 审批, + // 'path'(F-260619-03 Phase B 路径授权挂起,走 once/session/always/deny)vs 'risk'(普通 RiskLevel 审批, // 走 approve/reject)。后端阶段3a 未在 wire 上给该事件加 kind(老 path 挂起仍走独立 // AiDirAuthRequired 事件),前端 useAiEvents 入口处据 event.type 归一打标:risk 类默认 'risk', // path 类(AiDirAuthRequired 归一)打 'path'。故此处 kind 为可选(老后端不传时前端默认 'risk')。 @@ -387,7 +387,7 @@ export type AiChatEvent = ({ type: 'AiMaxRoundsReached' } | { // F-260619-03 Phase B: 路径授权弹窗(LLM 想访问白名单外目录,后端挂起 loop 等用户决定)。 - // 前端弹窗三选项:"仅本次"(session)/"未来都允许"(persistent)/"拒绝" → ai_authorize_dir IPC。 + // 前端弹窗四选项:"本次"(once 执行后清)/"当前会话"(session 切会话清)/"始终放行"(persistent)/"拒绝" → ai_authorize_dir IPC。 type: 'AiDirAuthRequired'; id: string; tool: string; path: string; dir: string } | { // L1 求助协议(aichat 体验与 agent 能力系统化重构 §2.3,2026-06-21): @@ -544,8 +544,8 @@ export interface AiToolCallInfo { diff?: string /** * path_auth 审批链阶段3b(统一审批模型):审批类型,区分两类挂起。 - * - 'path'(F-260619-03 Phase B 路径授权挂起):ToolCard 显 once/always/deny 三选项, - * 调 aiApi.authorizeDir(once 写会话临时授权 / always 写持久白名单 / deny 工具返 Err)。 + * - 'path'(F-260619-03 Phase B 路径授权挂起):ToolCard 显 once/session/always/deny 四选项, + * 调 aiApi.authorizeDir(once 写 once 层单次执行后清 / session 写会话临时 / always 写持久白名单 / deny 工具返 Err)。 * - 'risk'(普通 RiskLevel 审批,Med/High 风险工具):ToolCard 显 approve/reject 两选项, * 调 aiApi.approve(一次性)。 * undefined = 未进 pending_approval 态(非挂起)或老后端 risk 类(默认 'risk')。 diff --git a/src/components/ai/DirAuthDialog.vue b/src/components/ai/DirAuthDialog.vue index 207acba..5cc257e 100644 --- a/src/components/ai/DirAuthDialog.vue +++ b/src/components/ai/DirAuthDialog.vue @@ -3,8 +3,9 @@ 后端挂起 loop 等用户决定)。阶段1 改遍历 pendingDirAuths(数组)——同轮多个文件工具落不同 未授权目录时后端连发多条 AiDirAuthRequired,数组并存不互覆盖。 条件:pendingDirAuths 非空(AiDirAuthRequired 事件置)+ 当前视图正在生成(防切走后误显)。 - 每项独立三按钮:点"仅本次" → ai_authorize_dir(decision=once, 写会话临时授权)→ 后端执行工具 + try_continue。 - 点"未来都允许" → ai_authorize_dir(decision=always, 写持久化 Settings KV)→ 执行 + 续 loop。 + 每项独立四按钮:点"本次" → ai_authorize_dir(decision=once, 写 once 层单次授权,执行后清)→ 后端执行工具 + try_continue。 + 点"当前会话" → ai_authorize_dir(decision=session, 写 session 层会话授权,切会话清)→ 执行 + 续 loop。 + 点"始终放行" → ai_authorize_dir(decision=always, 写持久化 Settings KV)→ 执行 + 续 loop。 点"拒绝" → ai_authorize_dir(decision=deny, 工具返 Err)→ 续 loop。 store 单例共享(state/aiApi),pendingDirAuths 模块级 ref(useAiEvents)直接导入。 toast 经 emit 转父(保持单一 toast 源)。 --> @@ -19,6 +20,11 @@ :disabled="actingIds.has(item.id)" @click="handleAuthorize(item.id, 'once')" >{{ $t('aiChat.dirAuthOnce') }} +