新增: 路径授权三档化 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

@@ -581,6 +581,7 @@
> **与 MCP ServerF-260619-02无关**:本需求是 DevFlow 应用内部 AI 工具read_file/write_file 等)的文件系统访问权限升级,不涉及对外 MCP 协议暴露。 > **与 MCP ServerF-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) - [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 向前端弹窗申请授权」。 **核心机制**:当 AI 调用 `read_file` / `write_file` 等文件系统工具时,路径校验从「单一 workspace_root 前缀匹配」升级为「动态白名单池校验 + 未命中则挂起 Agentic Loop 向前端弹窗申请授权」。

View File

@@ -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清零) ### 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` - **范围**:实测数据反推(**非静态代码审查**)。从生产 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`

View File

@@ -751,9 +751,10 @@ fn append_cross_drive_note(result: serde_json::Value, note: &str) -> serde_json:
/// 触发:process_tool_calls 预校验文件工具路径未命中白名单 → 挂起 loop(insert pending /// 触发:process_tool_calls 预校验文件工具路径未命中白名单 → 挂起 loop(insert pending
/// with path_auth=Some + emit AiDirAuthRequired)。前端弹窗三选项,用户选择后调本 IPC。 /// with path_auth=Some + emit AiDirAuthRequired)。前端弹窗三选项,用户选择后调本 IPC。
/// ///
/// `decision`: /// `decision`(三授权档 + 拒):
/// - `"once"`:写入 `AppState.allowed_dirs.session`(进程级会话临时授权,随 active 会话切换/新建/删除由 clear_session_allowed_dirs 清空)→ 执行工具 → 恢复 loop /// - `"once"`:写入 `allowed_dirs.once`(本次单次,执行后 clear_once_allowed_dirs 清空)→ 执行 → 恢复
/// - `"always"`:写入持久化 Settings KV `allowed_dirs`(reload_allowed_dirs 同步内存)→ 执行 → 恢复 /// - `"session"`:写入 `allowed_dirs.session`(当前会话,切换/新建/删除活跃会话由 clear_session_allowed_dirs 清空)→ 执行 → 恢复
/// - `"always"`:写入持久化 Settings KV `allowed_dirs`(reload_allowed_dirs 同步内存 persistent)→ 执行 → 恢复
/// - `"deny"`:工具返 Err "用户拒绝路径授权" → 恢复 loop /// - `"deny"`:工具返 Err "用户拒绝路径授权" → 恢复 loop
/// ///
/// 复用审批恢复架构:remove pending → (写授权/拒) → execute/Err → 替换占位 tool_result → /// 复用审批恢复架构:remove pending → (写授权/拒) → execute/Err → 替换占位 tool_result →
@@ -843,23 +844,33 @@ pub async fn ai_authorize_dir(
return Ok("rejected".to_string()); return Ok("rejected".to_string());
} }
// ── "once":写会话级临时授权(随会话销毁,不落库) ── // ── 授权档写入(三档:once 本次单次 / session 当前会话 / always 始终落 KV)──
// L1 补丁:遍历所有未授权父目录(rename_file from+to 不同目录时多项)。 // L1 补丁:遍历所有未授权父目录(rename_file from+to 不同目录时多项),确保 handler 放行
if decision == "once" { // deny 已在上方早 return,此处仅 once/session/always。
for d in &dir_strs { match decision.as_str() {
state.add_session_allowed_dir(d.clone()).await; "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(本次单次,执行后清)");
} }
} "session" => {
for d in &dir_strs {
// ── "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);
// 降级:写入会话临时授权,保本路径本会话可用
state.add_session_allowed_dir(d.clone()).await; 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 放行) ── // ── 执行工具(路径现已授权,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 为真实结果 // 替换占位 tool_result 为真实结果
{ {
let mut session = state.ai_session.lock().await; let mut session = state.ai_session.lock().await;

View File

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

View File

@@ -77,10 +77,10 @@ export const aiApi = {
/** /**
* F-260619-03 Phase B: 路径授权弹窗决策(消费 AiDirAuthRequired 挂起)。 * F-260619-03 Phase B: 路径授权弹窗决策(消费 AiDirAuthRequired 挂起)。
* decision: 'once'(仅本次,会话级)/ 'always'(未来都允许,持久化)/ 'deny'(拒绝)。 * decision: 'once'(本次单次,执行后清)/ 'session'(当前会话,切会话清)/ 'always'(始终,持久化)/ 'deny'(拒绝)。
* 后端 remove pending → 写授权目录(session/persistent)→ 执行工具 → try_continue 恢复 loop。 * 后端 remove pending → 写授权目录(once/session/persistent)→ 执行工具 → try_continue 恢复 loop。
*/ */
authorizeDir(toolCallId: string, decision: 'once' | 'always' | 'deny'): Promise<string> { authorizeDir(toolCallId: string, decision: 'once' | 'session' | 'always' | 'deny'): Promise<string> {
return invoke('ai_authorize_dir', { toolCallId, decision }) return invoke('ai_authorize_dir', { toolCallId, decision })
}, },

View File

@@ -358,7 +358,7 @@ export type AiChatEvent = ({
type: 'AiToolAutoApproved'; id: string; tool: string; dir: string type: 'AiToolAutoApproved'; id: string; tool: string; dir: string
} | { } | {
// path_auth 审批链阶段3b(统一审批模型):AiApprovalRequired 携带 kind 字段区分 // 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 挂起仍走独立 // 走 approve/reject)。后端阶段3a 未在 wire 上给该事件加 kind(老 path 挂起仍走独立
// AiDirAuthRequired 事件),前端 useAiEvents 入口处据 event.type 归一打标:risk 类默认 'risk', // AiDirAuthRequired 事件),前端 useAiEvents 入口处据 event.type 归一打标:risk 类默认 'risk',
// path 类(AiDirAuthRequired 归一)打 'path'。故此处 kind 为可选(老后端不传时前端默认 'risk')。 // path 类(AiDirAuthRequired 归一)打 'path'。故此处 kind 为可选(老后端不传时前端默认 'risk')。
@@ -387,7 +387,7 @@ export type AiChatEvent = ({
type: 'AiMaxRoundsReached' type: 'AiMaxRoundsReached'
} | { } | {
// F-260619-03 Phase B: 路径授权弹窗(LLM 想访问白名单外目录,后端挂起 loop 等用户决定)。 // 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 type: 'AiDirAuthRequired'; id: string; tool: string; path: string; dir: string
} | { } | {
// L1 求助协议(aichat 体验与 agent 能力系统化重构 §2.3,2026-06-21): // L1 求助协议(aichat 体验与 agent 能力系统化重构 §2.3,2026-06-21):
@@ -544,8 +544,8 @@ export interface AiToolCallInfo {
diff?: string diff?: string
/** /**
* path_auth 审批链阶段3b(统一审批模型):审批类型,区分两类挂起。 * path_auth 审批链阶段3b(统一审批模型):审批类型,区分两类挂起。
* - 'path'(F-260619-03 Phase B 路径授权挂起):ToolCard 显 once/always/deny 选项, * - 'path'(F-260619-03 Phase B 路径授权挂起):ToolCard 显 once/session/always/deny 选项,
* 调 aiApi.authorizeDir(once 写会话临时授权 / always 写持久白名单 / deny 工具返 Err)。 * 调 aiApi.authorizeDir(once 写 once 层单次执行后清 / session 写会话临时 / always 写持久白名单 / deny 工具返 Err)。
* - 'risk'(普通 RiskLevel 审批,Med/High 风险工具):ToolCard 显 approve/reject 两选项, * - 'risk'(普通 RiskLevel 审批,Med/High 风险工具):ToolCard 显 approve/reject 两选项,
* 调 aiApi.approve(一次性)。 * 调 aiApi.approve(一次性)。
* undefined = 未进 pending_approval 态(非挂起)或老后端 risk 类(默认 'risk')。 * undefined = 未进 pending_approval 态(非挂起)或老后端 risk 类(默认 'risk')。

View File

@@ -3,8 +3,9 @@
后端挂起 loop 等用户决定)阶段1 改遍历 pendingDirAuths(数组)同轮多个文件工具落不同 后端挂起 loop 等用户决定)阶段1 改遍历 pendingDirAuths(数组)同轮多个文件工具落不同
未授权目录时后端连发多条 AiDirAuthRequired,数组并存不互覆盖 未授权目录时后端连发多条 AiDirAuthRequired,数组并存不互覆盖
条件:pendingDirAuths 非空(AiDirAuthRequired 事件置)+ 当前视图正在生成(防切走后误显) 条件:pendingDirAuths 非空(AiDirAuthRequired 事件置)+ 当前视图正在生成(防切走后误显)
每项独立按钮:"本次" ai_authorize_dir(decision=once, 会话临时授权) 后端执行工具 + try_continue 每项独立按钮:"本次" ai_authorize_dir(decision=once, once 层单次授权,执行后清) 后端执行工具 + try_continue
"未来都允许" ai_authorize_dir(decision=always, 写持久化 Settings KV) 执行 + loop "当前会话" ai_authorize_dir(decision=session, session 层会话授权,切会话清) 执行 + loop
"始终放行" ai_authorize_dir(decision=always, 写持久化 Settings KV) 执行 + loop
"拒绝" ai_authorize_dir(decision=deny, 工具返 Err) loop "拒绝" ai_authorize_dir(decision=deny, 工具返 Err) loop
store 单例共享(state/aiApi),pendingDirAuths 模块级 ref(useAiEvents)直接导入 store 单例共享(state/aiApi),pendingDirAuths 模块级 ref(useAiEvents)直接导入
toast emit 转父(保持单一 toast ) --> toast emit 转父(保持单一 toast ) -->
@@ -19,6 +20,11 @@
:disabled="actingIds.has(item.id)" :disabled="actingIds.has(item.id)"
@click="handleAuthorize(item.id, 'once')" @click="handleAuthorize(item.id, 'once')"
>{{ $t('aiChat.dirAuthOnce') }}</button> >{{ $t('aiChat.dirAuthOnce') }}</button>
<button
class="ai-dir-auth-btn ai-dir-auth-btn--session"
:disabled="actingIds.has(item.id)"
@click="handleAuthorize(item.id, 'session')"
>{{ $t('aiChat.dirAuthSession') }}</button>
<button <button
class="ai-dir-auth-btn ai-dir-auth-btn--always" class="ai-dir-auth-btn ai-dir-auth-btn--always"
:disabled="actingIds.has(item.id)" :disabled="actingIds.has(item.id)"
@@ -74,7 +80,7 @@ const actingIds = reactive(new Set<string>())
/** 点三选项之一:按 id 定位挂起项,调 ai_authorize_dir。后端 remove pending → 写授权/拒 → /** 点三选项之一:按 id 定位挂起项,调 ai_authorize_dir。后端 remove pending → 写授权/拒 →
* execute → try_continue。不主动清 pendingDirAuths——由后端 AiApprovalResult/AiCompleted/AiError * execute → try_continue。不主动清 pendingDirAuths——由后端 AiApprovalResult/AiCompleted/AiError
* 在 useAiEvents 内按 id filter / 清空。IPC 失败回滚 actingIds 让用户可重试。 */ * 在 useAiEvents 内按 id filter / 清空。IPC 失败回滚 actingIds 让用户可重试。 */
async function handleAuthorize(id: string, decision: 'once' | 'always' | 'deny'): Promise<void> { async function handleAuthorize(id: string, decision: 'once' | 'session' | 'always' | 'deny'): Promise<void> {
if (actingIds.has(id)) return if (actingIds.has(id)) return
actingIds.add(id) actingIds.add(id)
try { try {
@@ -148,6 +154,12 @@ watch(() => pendingDirAuths.value, (items) => {
color: #fff; color: #fff;
} }
.ai-dir-auth-btn--once:hover:not(:disabled) { filter: brightness(1.1); } .ai-dir-auth-btn--once:hover:not(:disabled) { filter: brightness(1.1); }
.ai-dir-auth-btn--session {
background: color-mix(in srgb, var(--df-accent) 15%, transparent);
color: var(--df-accent);
border: 0.5px solid color-mix(in srgb, var(--df-accent) 40%, transparent);
}
.ai-dir-auth-btn--session:hover:not(:disabled) { filter: brightness(1.1); }
.ai-dir-auth-btn--always { .ai-dir-auth-btn--always {
background: var(--df-warning); background: var(--df-warning);
color: #000; color: #000;

View File

@@ -31,8 +31,8 @@ const _pendingApprovalIds = new Set<string>()
* 按钮无 loading 中间态、用户无重审入口;loading 现下沉到 ToolCard 局部 ref,语义正确。 * 按钮无 loading 中间态、用户无重审入口;loading 现下沉到 ToolCard 局部 ref,语义正确。
* *
* path_auth 审批链阶段3b(统一审批模型):**按 tc.kind 分派**两条 IPC—— * path_auth 审批链阶段3b(统一审批模型):**按 tc.kind 分派**两条 IPC——
* - kind='path'(路径授权挂起):调 aiApi.authorizeDir(toolCallId, decision) once/always/deny。 * - kind='path'(路径授权挂起):调 aiApi.authorizeDir(toolCallId, decision) once/session/always/deny。
* once=本次会话授权 / always=写持久白名单 / deny=拒绝。 * once=本次单次(执行后清) / session=当前会话(切会话清) / always=写持久白名单 / deny=拒绝。
* - kind='risk'(普通 RiskLevel 审批,默认):调 aiApi.approve(toolCallId, approved) 一次性 approve/reject。 * - kind='risk'(普通 RiskLevel 审批,默认):调 aiApi.approve(toolCallId, approved) 一次性 approve/reject。
* approved=true→放行执行,approved=false→拒绝。 * approved=true→放行执行,approved=false→拒绝。
* 入口据 findToolCall(toolCallId).kind 判定,kind 缺省走 risk 老路径(兼容非统一开关 / 老 toolCall)。 * 入口据 findToolCall(toolCallId).kind 判定,kind 缺省走 risk 老路径(兼容非统一开关 / 老 toolCall)。
@@ -40,16 +40,16 @@ const _pendingApprovalIds = new Set<string>()
* 调用方传 decision 但 tc.kind='risk' 时仍走 approve(approved)分支(忽略 decision)。 * 调用方传 decision 但 tc.kind='risk' 时仍走 approve(approved)分支(忽略 decision)。
* *
* @param approved risk 类专用(approve/reject, approve=true/reject=false);path 类忽略。 * @param approved risk 类专用(approve/reject, approve=true/reject=false);path 类忽略。
* @param decision path 类专用('once'|'always'|'deny');risk 类忽略。调用方须据 tc.kind 传对应入参。 * @param decision path 类专用('once'|'session'|'always'|'deny');risk 类忽略。调用方须据 tc.kind 传对应入参。
*/ */
async function approveToolCall(toolCallId: string, approved: boolean, decision?: 'once' | 'always' | 'deny') { async function approveToolCall(toolCallId: string, approved: boolean, decision?: 'once' | 'session' | 'always' | 'deny') {
// F-260616-06: 防抖——同一 id 仍在处理中则跳过(竞态点击/网络慢重试) // F-260616-06: 防抖——同一 id 仍在处理中则跳过(竞态点击/网络慢重试)
if (_pendingApprovalIds.has(toolCallId)) return if (_pendingApprovalIds.has(toolCallId)) return
_pendingApprovalIds.add(toolCallId) _pendingApprovalIds.add(toolCallId)
try { try {
// 复用 findToolCall(反向扫描)避免重复实现查找逻辑;仅缓存引用用于 IPC 失败回滚 // 复用 findToolCall(反向扫描)避免重复实现查找逻辑;仅缓存引用用于 IPC 失败回滚
const tc = findToolCall(toolCallId) const tc = findToolCall(toolCallId)
// path_auth 审批链阶段3b:按 tc.kind 分派 IPC。kind='path' 走 authorizeDir(once/always/deny); // path_auth 审批链阶段3b:按 tc.kind 分派 IPC。kind='path' 走 authorizeDir(once/session/always/deny);
// 其余(risk/缺省)走 approve(approve/reject)。 // 其余(risk/缺省)走 approve(approve/reject)。
const isPathKind = !!tc && tc.kind === 'path' const isPathKind = !!tc && tc.kind === 'path'
// 重启看门狗覆盖审批执行→续生成窗口(useAiEvents.ts AiApprovalRequired/AiDirAuthRequired 已 clear, // 重启看门狗覆盖审批执行→续生成窗口(useAiEvents.ts AiApprovalRequired/AiDirAuthRequired 已 clear,
@@ -95,7 +95,7 @@ async function approveToolCall(toolCallId: string, approved: boolean, decision?:
* *
* path_auth 审批链阶段3b:统一开关开后,pendingApprovals 可能混合 path + risk 两类挂起。 * path_auth 审批链阶段3b:统一开关开后,pendingApprovals 可能混合 path + risk 两类挂起。
* 批量操作语义: * 批量操作语义:
* - decision='approve':risk 类 approved=true;path 类 decision='once'(批量授权本次, * - decision='approve':risk 类 approved=true;path 类 decision='once'(批量授权本次单次,
* 不批量写持久白名单——always 是单卡语义,批量误写持久白名单风险高,故批量只 once)。 * 不批量写持久白名单——always 是单卡语义,批量误写持久白名单风险高,故批量只 once)。
* - decision='reject':risk 类 approved=false;path 类 decision='deny'。 * - decision='reject':risk 类 approved=false;path 类 decision='deny'。
* 单条失败不中断批量(已由 approveToolCall 内部回滚该条状态)。 * 单条失败不中断批量(已由 approveToolCall 内部回滚该条状态)。
@@ -106,7 +106,7 @@ async function batchApprove(decision: 'approve' | 'reject') {
for (const p of items) { for (const p of items) {
try { try {
if (p.kind === 'path') { if (p.kind === 'path') {
// path 类:approve→once(本次,不批量写持久白名单), reject→deny // path 类:approve→once(本次单次,不批量写持久白名单), reject→deny
await approveToolCall(p.id, false, decision === 'approve' ? 'once' : 'deny') await approveToolCall(p.id, false, decision === 'approve' ? 'once' : 'deny')
} else { } else {
// risk 类:approve/reject boolean // risk 类:approve/reject boolean

View File

@@ -155,7 +155,8 @@ export default {
dirAuthRequired: 'Path authorization required', dirAuthRequired: 'Path authorization required',
dirAuthHint: '{tool} wants to access: {path}', dirAuthHint: '{tool} wants to access: {path}',
dirAuthOnce: 'This time', dirAuthOnce: 'This time',
dirAuthAlways: 'Always allow', dirAuthSession: 'This session',
dirAuthAlways: 'Always',
dirAuthDeny: 'Deny', dirAuthDeny: 'Deny',
dirAuthFailed: 'Authorization failed: {msg}', dirAuthFailed: 'Authorization failed: {msg}',

View File

@@ -155,8 +155,9 @@ export default {
// ── F-260619-03 Phase B: 路径授权弹窗(LLM 访问白名单外目录挂起) ── // ── F-260619-03 Phase B: 路径授权弹窗(LLM 访问白名单外目录挂起) ──
dirAuthRequired: '需要路径授权', dirAuthRequired: '需要路径授权',
dirAuthHint: '{tool} 想访问:{path}', dirAuthHint: '{tool} 想访问:{path}',
dirAuthOnce: '本次', dirAuthOnce: '本次',
dirAuthAlways: '未来都允许', dirAuthSession: '当前会话',
dirAuthAlways: '始终放行',
dirAuthDeny: '拒绝', dirAuthDeny: '拒绝',
dirAuthFailed: '授权失败:{msg}', dirAuthFailed: '授权失败:{msg}',