//! 工具调用审计 + pending 审批恢复 + 工具调用处理 use std::collections::{HashMap, HashSet}; use std::sync::Arc; use serde::Serialize; use tauri::{AppHandle, Emitter, Manager, State}; use df_ai::ai_tools::{AiToolRegistry, RiskLevel}; use df_ai::provider::ChatMessage; use df_storage::crud::AiToolExecutionRepo; use df_storage::db::Database; use crate::state::AppState; use crate::commands::err_str; use super::{AiChatEvent, AiSession, ApprovalKind, PathAuthRequest, PendingApproval, ToolCallDraft}; // tool_registry::generate_diff 经 audit/diff.rs 直接 use(build_write_file_diff 内调用), // 本文件不再裸名用 generate_diff,故不再在此 use(避免 unused import warning)。 // utils(audit/utils.rs):RiskLevel ↔ 字符串转换 + 字符串安全截断纯 helper。 // 第五批从本文件抽离,行为零变更。pub(crate) use 保持 finalize / restore 子模块 // super::risk_str / super::risk_from_str 引用路径不变;truncate_chars 仅供本文件 DTO 装配裸名用。 mod utils; pub(crate) use utils::{risk_from_str, risk_str}; use utils::truncate_chars; // diff(audit/diff.rs):AE-2025-03 write_file 审批预览 diff 生成。 // 第五批从本文件抽离,行为零变更。use 保持本文件 process_tool_calls 裸名调用 build_write_file_diff。 // generate_diff 经 `use super::generate_diff` 引入供 diff.rs 复用。 mod diff; use diff::build_write_file_diff; // ============================================================ // 审批历史查询 IPC(AE-2025-08) // ============================================================ /// 审批历史 DTO(传给前端的精简视图,敏感字段截断防泄露) /// /// arguments/result 在落库时是完整 JSON(可能含项目名/路径/长结果),审计面板只展示摘要, /// 故截断到固定长度(参数 120 / 结果 160),既保留可读性又不泄露全量数据到前端 DOM。 #[derive(Debug, Clone, Serialize)] pub struct ToolExecutionDto { pub id: String, pub conversation_id: Option, pub tool_call_id: String, pub tool_name: String, /// 参数摘要(截断 120 字符,完整原值仍留库) pub arguments_brief: String, /// 结果摘要(截断 160 字符,None → 空串便于前端展示) pub result_brief: Option, /// pending/approved/rejected/executing/completed/failed pub status: String, /// low/medium/high pub risk_level: String, pub requested_at: String, pub executed_at: Option, /// human/auto,None 表示尚未决策 pub decided_by: Option, } /// 审批历史面板查询:按 requested_at 倒序(最新在前)分页返回工具调用审计记录。 /// /// 默认 limit=50 / offset=0(第一页)。limit 在 storage 层钳制 ≤200 防滥用。 /// 敏感字段(arguments/result)截断成摘要返回,完整原值仍留库。 #[tauri::command] pub async fn list_tool_executions( state: State<'_, AppState>, limit: Option, offset: Option, ) -> Result, String> { let limit = limit.unwrap_or(50); let offset = offset.unwrap_or(0); let records = state .ai_tool_executions .list_recent(limit, offset) .await .map_err(err_str)?; Ok(records .into_iter() .map(|r| ToolExecutionDto { id: r.id, conversation_id: r.conversation_id, tool_call_id: r.tool_call_id, tool_name: r.tool_name, arguments_brief: truncate_chars(&r.arguments, 120), result_brief: r.result.map(|s| truncate_chars(&s, 160)), status: r.status, risk_level: r.risk_level, requested_at: r.requested_at, executed_at: r.executed_at, decided_by: r.decided_by, }) .collect()) } // reason 拼装(resolve_project_label / resolve_task_label / build_approval_reason) // 拆至子模块 audit/reason.rs(第一批 helper 抽离,行为零变更)。 mod reason; // process_tool_calls 调用 build_approval_reason,从子模块引入(super 可见,fn 为 pub(super))。 use reason::build_approval_reason; // restore(audit/restore.rs):启动恢复重建 pending_approvals。 // 第二批从本文件抽离,行为零变更。re-export 保持 commands::ai::audit::* 路径透明。 mod restore; pub use restore::restore_pending_approvals; // finalize(audit/finalize.rs):audit_tool_call 写入 + audit_finalize 审批后状态回填。 // 第二批从本文件抽离,行为零变更。pub(crate) use 供本文件 process_tool_calls / // find_cached_high_risk_result 裸名调用,且保持 commands::ai::audit::* 对 crate 内可见。 mod finalize; pub(crate) use finalize::{audit_finalize, audit_tool_call}; // cache(audit/cache.rs):F-260616-05 高危工具去重缓存。 // 第三批从本文件抽离,行为零变更。pub(super) use 供本文件 process_tool_calls 裸名调用 // (find_cached_high_risk_result)。阶段2:prior placeholder 现经 pending_placeholder_for // (内嵌 __PENDING__:tc_id 标记)生成,PENDING_APPROVAL_PLACEHOLDER 基础常量留在 cache.rs 内部。 mod cache; pub(super) use cache::{find_cached_high_risk_result, pending_placeholder_for}; // data_change(audit/data_change.rs):AR-11 数据变更联动刷新。 // 第四批从本文件抽离,行为零变更。pub(crate) use 保持 emit_data_changed 对 crate 内可见 // (chat.rs 通过 commands::ai::audit::emit_data_changed 引入并调用),data_change_for_tool 私有。 mod data_change; pub(crate) use data_change::emit_data_changed; // idea_source(audit/idea_source.rs):F-260619-04 P2(方案 B) 灵感来源消息级溯源补全。 // create_idea 工具执行后,若 AI 未填 source 且有 message_id → 补 conv_msg:{id}(低侵入,不改 handler 接口)。 // pub(crate) use 供本文件 process_tool_calls + chat.rs 审批执行路径调用(单点逻辑,多调用点)。 mod idea_source; pub(crate) use idea_source::maybe_fill_idea_source; /// F-260619-03 Phase B: 提取文件工具的路径参数(用于路径授权预校验)。 /// /// 仅对走 resolve_workspace_path 校验的文件工具返回路径;非文件工具返回空 Vec(不预校验)。 /// - 单路径工具(read_file/write_file/list_directory/patch_file/file_info/append_file/delete_file/search_files/grep) /// 取 args["path"] /// - rename_file 双路径取 args["from"] + args["to"](两个都需授权) /// - run_command 不返回(它只走 validate_path 黑名单,不限定 workspace,High risk 靠人工审批) fn extract_file_tool_paths(tool_name: &str, args: &serde_json::Value) -> Vec { match tool_name { "rename_file" => { let mut v = Vec::new(); if let Some(from) = args.get("from").and_then(|x| x.as_str()) { v.push(from.to_string()); } if let Some(to) = args.get("to").and_then(|x| x.as_str()) { v.push(to.to_string()); } v } "read_file" | "write_file" | "list_directory" | "patch_file" | "file_info" | "append_file" | "delete_file" | "search_files" | "grep" => { args.get("path").and_then(|x| x.as_str()).map(|s| vec![s.to_string()]).unwrap_or_default() } _ => Vec::new(), } } /// F-260619-03 Phase B/C: 对单条文件工具调用做路径授权预校验,返回是否需挂起/拒绝/放行。 /// /// - 路径任一命中黑名单 → `Denied(reason)`:硬拒(工具返 Err tool_result,不挂起) /// - 路径任一未命中白名单(persistent + session_dirs)且非黑名单 → `NeedsAuth`: /// 附带 PathAuthRequest(规范化父目录 + 原始路径列表),供挂起弹窗 /// - 全部已授权 → `Authorized`:走原 Low/Med/High 流程 enum FileToolAuthOutcome { Authorized, NeedsAuth(PathAuthRequest), Denied(String), } fn check_file_tool_auth( tool_name: &str, args: &serde_json::Value, allowed: &crate::state::AllowedDirs, ) -> FileToolAuthOutcome { use crate::state::{check_path_authorization, PathAuthDecision}; let paths = extract_file_tool_paths(tool_name, args); if paths.is_empty() { // 非文件工具或无路径参数(如缺 path 的异常调用)→ 放行,走原流程(handler 内会因缺参 Err) return FileToolAuthOutcome::Authorized; } // L1 补丁(rename_file 双路径漏校):收集**所有**未授权父目录(去重), // 不只首个。rename_file 的 from/to 若分属不同未授权目录,两个 dir 都需挂起授权。 let mut pending_dirs: Vec = Vec::new(); for p in &paths { match check_path_authorization(p, allowed) { PathAuthDecision::Denied { reason } => return FileToolAuthOutcome::Denied(reason), PathAuthDecision::NeedsAuthorization { dir } => { if !pending_dirs.contains(&dir) { pending_dirs.push(dir); } } PathAuthDecision::Authorized => {} } } if pending_dirs.is_empty() { FileToolAuthOutcome::Authorized } else if tool_name == "search_files" { // 核心设计5:search_files 兜底 — 盲搜语义应拒不应询。 // search_files 仅在已授权目录可用;用户已通过 @项目 提供项目上下文,无需搜索文件系统。 // (黑名单已在上方 for 循环内短路返 Denied,走到此处均为 NeedsAuthorization 场景。) // 其他文件工具(read_file/write_file/list_directory/patch_file/file_info/ // append_file/delete_file/rename_file)保持原 NeedsAuth 弹窗逻辑完全不变。 FileToolAuthOutcome::Denied( "search_files 仅在已授权目录可用;请用 @[项目] 引用或提供绝对路径,不要盲搜文件系统。".to_string(), ) } else { FileToolAuthOutcome::NeedsAuth(PathAuthRequest { dirs: pending_dirs, raw_paths: paths }) } } /// 阶段4(容错/恢复,开关 `df-ai-approval-retry`):查审计表推算同 tc_id 重试计数。 /// /// 返回语义: /// - 0:审计表无该 tc_id 落定记录(或仅 pending),属首次审批执行,正常挂起。 /// - ≥1:审计表已有该 tc_id 的落定记录(executed/failed/rejected/skipped_retry),即该 /// tc_id 此前已被审批执行过一次,LLM 又用同 id 重试 → 调用方据 ≥1 跳过执行 + emit Completed, /// 断「超时/权限错→LLM 死循环重试同 id→重新挂起→用户被迫二次授权」循环。 /// /// 实现:查 `find_by_tool_call_id`,status 为 pending 视为"尚未落定"(返 0,首次挂起审批的 /// 正常态);其余落定状态返 1。retry_count 当前仅取 0/1(断路器语义:第二次即跳过), /// 字段类型 u32 留给未来"允许多次重试"扩展(配置上限阈值)。 /// /// 兜底/回退:flag 关(文档标记)或审计查询失败 → 返 0,等价原行为(单次审批执行,无重试防护)。 async fn detect_retry_count(audit_repo: &AiToolExecutionRepo, tc_id: &str) -> u32 { // 审计查询失败不阻断主流程(DB 故障等降级为无重试防护,返回 0 走原审批流程) let rec = match audit_repo.find_by_tool_call_id(tc_id).await { Ok(opt) => match opt { Some(r) => r, None => return 0, // 无记录 = 首次 }, Err(e) => { tracing::warn!("[阶段4-retry] 查审计表 tc_id={} 失败(降级无重试防护): {}", tc_id, e); return 0; } }; // pending = 首次挂起审批(尚未落定);其余落定状态 = 已执行过 → 计 1 次重试 if rec.status == "pending" { 0 } else { 1 } } /// 处理流式接收的工具调用:Low 风险并行执行(join_all),Med/High 进审批门控 /// 返回待审批的工具数量(0 = 全部自动执行完成) /// 工具执行 + 心跳保活:execute 期间每 30s emit AiHeartbeat(对齐 stream_recv.rs /// stream_llm select! 心跳语义),execute 完 abort 心跳 task。 /// /// 根治 BUG-260624-03:工具执行在 stream_llm 之外(本模块),原本无 AiHeartbeat。 /// 单次 execute 超过前端 STREAM_TIMEOUT_MS(130s)——bash 跑 cargo/测试、read 大文件、 /// 全盘 search 等开发长命令——前端 watchdog 误判断流,抛"工具已执行完成后续中断"误报 /// (实测:用户报"一边流一边抛",前一轮 delta 文本在屏 + 当前轮工具执行静默 > 130s)。 /// 补工具执行阶段的心跳缺口,前端 watchdog 在静默期也能收到 reset,不再误杀长工具。 async fn execute_with_heartbeat( tools: &AiToolRegistry, name: &str, args: serde_json::Value, app: &AppHandle, conv_id: &str, ) -> anyhow::Result { use std::sync::atomic::{AtomicBool, Ordering}; use tokio::time::Duration; // RAII guard:drop 时 stop+abort 心跳 task,确保 execute panic(unwind)也不泄漏心跳 task。 struct HeartbeatGuard { stop: Arc, handle: tokio::task::JoinHandle<()>, } impl Drop for HeartbeatGuard { fn drop(&mut self) { self.stop.store(true, Ordering::SeqCst); self.handle.abort(); } } let stop = Arc::new(AtomicBool::new(false)); let app_h = app.clone(); let conv = conv_id.to_string(); let stop_c = stop.clone(); // 心跳 task:弃首 tick(tokio interval 首 tick 立即返回,对齐 stream_recv.rs:174), // 后每 30s emit AiHeartbeat(< 130s watchdog 确保覆盖)。 let heartbeat = tokio::spawn(async move { let mut interval = tokio::time::interval(Duration::from_secs(30)); interval.tick().await; loop { interval.tick().await; if stop_c.load(Ordering::SeqCst) { break; } let ev = AiChatEvent::AiHeartbeat { conversation_id: Some(conv.clone()) }; let _ = app_h.emit("ai-chat-event", ev.clone()); let _ = app_h.state::().ai_event_bus.publish_event(ev); } }); // RAII:execute 无论 Ok/Err/panic(unwind),_guard drop 自动 stop+abort,无心跳 task 泄漏。 let _guard = HeartbeatGuard { stop, handle: heartbeat }; tools.execute(name, args).await } pub(crate) async fn process_tool_calls( session: &mut AiSession, tool_calls_acc: HashMap, tools_arc: &Arc, db: &Arc, app_handle: &AppHandle, conv_id: &str, ) -> usize { let mut tc_list: Vec<_> = tool_calls_acc.into_iter().collect(); tc_list.sort_unstable_by_key(|(i, _)| *i); // B-260616-21 治本兜底:LLM 异常复用同 tool_use.id(stream_recv 按 content_block index 分桶, // 同 id 不同 index draft 可并存 → 每 draft emit AiToolCallStarted 致同 id emit 两次 → 前端 push 两卡, // Completed 按 id 只 update 首张 → 次张残留 running 0行)。process 层按 id 去重——同 id 保留 // 最小 index 的首个,丢弃后续,保证 emit Started 的 id 唯一。前端 useAiEvents.ts:205 findToolCall // 守卫双保险。详 docs/02-架构设计/已编号方案/B-260616-21排查方案-2026-06-16.md。 let mut seen_ids: HashSet = HashSet::new(); tc_list.retain(|(_, draft)| seen_ids.insert(draft.id.clone())); let mut pending_count = 0usize; let audit_repo = AiToolExecutionRepo::new(db); // F-260619-04 P1 消息级溯源:取当前 assistant 消息 id。 // 调用前 agentic/mod.rs 已把本轮 assistant_with_tools 消息(LLM 返回带 tool_calls 的那条) // push 到 per_conv.messages(audit/mod.rs:882),此处取末条 assistant id 作为本轮工具 // 调用所属的溯源 message_id,贯穿所有 audit_tool_call 写入。None 表示无 assistant 消息 // (异常路径/老数据无 id),audit 落 message_id=None,展示侧兼容。 let current_message_id: Option = session .conv_read(conv_id) .and_then(|c| c.messages.last_assistant_message_id()); let current_message_id = current_message_id.as_deref(); // 解析 args + 批量发 Started(前端骨架按原始 index 顺序展示) let drafts: Vec<(u32, ToolCallDraft, serde_json::Value)> = tc_list.into_iter() .map(|(idx, draft)| { let args = serde_json::from_str(&draft.args).unwrap_or(serde_json::Value::Object(Default::default())); // L3 emit 双写:AiToolCallStarted publish 到事件总线(tunnel subscriber 透传 miniapp)。 // AiTextDelta/AiToolCall* 高频事件不双写原则不适用此处(工具调用生命周期事件属关键状态变更,需透传)。 let ev = AiChatEvent::AiToolCallStarted { id: draft.id.clone(), name: draft.name.clone(), args: args.clone(), conversation_id: Some(conv_id.to_string()), }; let _ = app_handle.emit("ai-chat-event", ev.clone()); let _ = app_handle.state::().ai_event_bus.publish_event(ev); (idx, draft, args) }) .collect(); // ── F-260619-03 Phase B/C: 文件工具路径授权预校验 ── // 在 RiskLevel 分类前,对文件工具(read/write/list/patch/info/append/delete/rename/search) // 逐条预校验路径授权(persistent + 会话 session_allowed_dirs + 黑名单): // - 任一路径命中黑名单 → Denied:push 错误 tool_result + emit Completed,不挂起(Phase C) // - 任一路径未命中白名单且非黑名单 → NeedsAuth:挂起 loop(insert pending + emit AiDirAuthRequired), // 等用户经 ai_authorize_dir IPC 决定"仅本次/未来都允许/拒绝"后恢复(Phase B) // - 全部已授权 → 放行,进入下方 RiskLevel 分类走原 Low/Med/High 流程 // 预校验通过的 drafts 留在原 Vec 继续走原流程;NeedsAuth/Denied 的 drafts 从 Vec 移除单独处理。 let allowed_snapshot = app_handle .state::() .allowed_dirs .clone(); let allowed_guard = allowed_snapshot.read().await.clone(); // 收集 NeedsAuth/Denied drafts(从 drafts 移除,不进下方 RiskLevel 循环) let mut path_auth_pending: Vec<(ToolCallDraft, serde_json::Value, PathAuthRequest)> = Vec::new(); let mut path_denied: Vec<(ToolCallDraft, String)> = Vec::new(); let mut authorized_drafts: Vec<(u32, ToolCallDraft, serde_json::Value)> = Vec::new(); for (idx, draft, args) in drafts { // 会话级临时授权目录在 allowed_guard.session 内(进程级,随 active 会话切换清空), // handler 闭包 read lock 与此处预校验读同一字段,两端授权判定一致。 match check_file_tool_auth(&draft.name, &args, &allowed_guard) { FileToolAuthOutcome::Authorized => authorized_drafts.push((idx, draft, args)), FileToolAuthOutcome::NeedsAuth(req) => path_auth_pending.push((draft, args, req)), FileToolAuthOutcome::Denied(reason) => path_denied.push((draft, reason)), } } let drafts = authorized_drafts; // Phase C: Denied 路径 → 硬拒(push 错误 tool_result + emit Completed),不挂起 loop。 // 工具返 Err 让 LLM 知路径被禁,自行调整;loop 继续下一轮(不暂停)。 for (draft, reason) in path_denied { let err_msg = format!("路径授权拒绝: {}", reason); session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &err_msg)); // L3 emit 双写:路径黑名单拒绝 emit Completed 双路发布。 let ev = AiChatEvent::AiToolCallCompleted { id: draft.id.clone(), result: serde_json::Value::String(err_msg.clone()), conversation_id: Some(conv_id.to_string()), }; let _ = app_handle.emit("ai-chat-event", ev.clone()); let _ = app_handle.state::().ai_event_bus.publish_event(ev); // 审计:路径黑名单拒绝(decided_by=auto_blacklist),留痕可追溯 let risk_level = tools_arc.get(&draft.name).map(|t| t.risk_level).unwrap_or(RiskLevel::High); audit_tool_call( &audit_repo, conv_id, &draft.id, &draft.name, &draft.args, "rejected", risk_level, Some(err_msg), Some("auto_blacklist"), current_message_id, ).await; } // Phase B: NeedsAuth 路径 → 挂起 loop(insert pending + emit AiDirAuthRequired + push 占位 tool_result)。 // 复用审批挂起架构:pending_approvals 以 tool_call_id 为键,ai_authorize_dir IPC remove 后恢复。 // pending_count 计入(让 agentic loop 检测到挂起并暂停,等 ai_authorize_dir → try_continue 恢复)。 for (draft, args, req) in path_auth_pending { pending_count += 1; // L1 补丁:req.dirs 含所有未授权父目录;emit 用首个作主展示目录(前端弹窗主显)。 // ai_authorize_dir 消费时遍历所有 dirs 写白名单。 let dir_str = req.dirs.first() .map(|d| d.to_string_lossy().to_string()) .unwrap_or_default(); let path_str = req.raw_paths.first().cloned().unwrap_or_default(); // 阶段4(容错/恢复,开关 df-ai-approval-retry):同 tc_id 重试检测。 // 同 tool_call_id 在审计表已有一条落定记录(executed/failed/rejected,即已审批执行过一次) // → 推算 retry_count≥1,跳过 insert pending + emit Completed 带"已跳过重试"提示, // 断「超时/权限错→LLM 死循环重试同 id→重新挂起→用户被迫二次授权」循环。 // 兜底:flag 关或无审计记录 → retry_count=0,等价原行为(正常挂起审批)。 let retry_count = detect_retry_count(&audit_repo, &draft.id).await; if retry_count >= 1 { let skip_msg = format!( "已跳过重试(同 tool_call_id={} 此前已审批执行过,防 LLM 死循环重试同卡死工具)", draft.id ); session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &skip_msg)); // L3 emit 双写:重试跳过 emit Completed 双路发布。 let ev = AiChatEvent::AiToolCallCompleted { id: draft.id.clone(), result: serde_json::Value::String(skip_msg.clone()), conversation_id: Some(conv_id.to_string()), }; let _ = app_handle.emit("ai-chat-event", ev.clone()); let _ = app_handle.state::().ai_event_bus.publish_event(ev); let risk_level = tools_arc.get(&draft.name).map(|t| t.risk_level).unwrap_or(RiskLevel::High); audit_tool_call( &audit_repo, conv_id, &draft.id, &draft.name, &draft.args, "skipped_retry", risk_level, Some(skip_msg), Some("auto_retry_guard"), current_message_id, ).await; continue; } session.pending_approvals.insert( draft.id.clone(), PendingApproval { tool_call_id: draft.id.clone(), tool_name: draft.name.clone(), arguments: args.clone(), conversation_id: Some(conv_id.to_string()), recovered: false, // 阶段3a:路径授权挂起标 kind=Path(req)(下沉原 path_auth 字段)。 kind: ApprovalKind::Path(req), retry_count, }, ); // 占位 tool_result(与 RiskLevel 审批一致),ai_authorize_dir 批准后替换为真实结果。 // 阶段2:用 pending_placeholder_for 内嵌 __PENDING__:tc_id 标记,供 sanitize 反向 orphan // 检测豁免保留 + 出口断言自愈补头(防占位头被裁后 orphan result 触发 provider 400)。 session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &pending_placeholder_for(&draft.id))); tracing::debug!(target: "ai_dirauth", conv = %conv_id, tool = %draft.name, tc_id = %draft.id, "emit AiDirAuthRequired(路径授权挂起,等 ai_authorize_dir 恢复)"); // L3 emit 双写:路径授权挂起 AiDirAuthRequired 双路发布(tunnel 透传 miniapp 弹窗)。 let ev = AiChatEvent::AiDirAuthRequired { id: draft.id.clone(), tool: draft.name.clone(), path: path_str, dir: dir_str, conversation_id: Some(conv_id.to_string()), }; let _ = app_handle.emit("ai-chat-event", ev.clone()); let _ = app_handle.state::().ai_event_bus.publish_event(ev); let risk_level = tools_arc.get(&draft.name).map(|t| t.risk_level).unwrap_or(RiskLevel::High); audit_tool_call( &audit_repo, conv_id, &draft.id, &draft.name, &draft.args, "pending", risk_level, None, None, current_message_id, ).await; } // 分类:Low 收集并行执行,Med/High 立即进审批门控(push 占位 tool_result) // // F-260616-05:High risk 在进审批门前先查去重缓存(find_cached_high_risk_result)。 // 若 LLM 重试同命令(同 tool_name + 同 args,键序无关),命中已落定的旧 tool_result, // 把缓存结果作为新 tool_call_id 的 tool_result 回传 LLM,跳过 insert pending + 跳过审批, // 断「超时→重试→重新审批」循环。Med 不去重(去重易误伤),Low 无审批本就不进此分支。 // F-#97:low_risk 向量携带 risk_level(治 securityReview blocker :710 审计失真)。 // 原 Vec 仅 (draft,args),回填审计硬编码 RiskLevel::Low;#97 让 Med/High(all/medium 模式) // 也进此向量,需透传真实等级,审计方可追溯「all 模式执行了多少高危命令」。 let mut low_risk: Vec<(ToolCallDraft, serde_json::Value, RiskLevel)> = Vec::new(); // F-#97 自动执行范围三档(2026-06-22):KV df-ai-auto-execute-mode 取值 low/medium/all,默认 low(=现状)。 // low:仅 Low 自动(等价旧行为);medium:Low+Medium 自动;all:全自动无审批(完全 AI 接管)。 // 锁内 await KV 快读,不竞争 session 锁(settings 独立 repo)。 let auto_exec_mode = app_handle .state::() .settings .get("df-ai-auto-execute-mode") .await .ok() .flatten() .unwrap_or_else(|| "low".to_string()); // trust_hits 收集:AE-04 会话信任命中(同会话已批准同类操作),真实执行但移到锁外 spawn // (对齐 Low risk L673-720 不持锁模式)。命中时此处只 emit toast + 收集,不 .await execute。 // 元组携带 risk_level: 回填审计需原等级(Med/High 才进此分支),避免闭包内再查 registry let mut trust_hits: Vec<(ToolCallDraft, serde_json::Value, String, RiskLevel)> = Vec::new(); for (_, draft, args) in drafts { let risk_level = tools_arc.get(&draft.name).map(|t| t.risk_level).unwrap_or(RiskLevel::High); // F-#97:按 auto_exec_mode 判定是否自动执行。should_auto=true 走 low_risk(自动),false 走原审批块。 // low(默认):Low→auto, Med/High→审批(等价现状) // medium:Low/Med→auto, High→审批 // all:全 auto(完全接管,无审批) let mut should_auto = match risk_level { RiskLevel::Low => true, RiskLevel::Medium => auto_exec_mode == "medium" || auto_exec_mode == "all", RiskLevel::High => auto_exec_mode == "all", }; // C-260627: patch_file 小改动(≤5 行)自动放行,不阻塞 AI 工作流 if !should_auto && draft.name == "patch_file" { if let Some(text) = args.get("new_text").and_then(|v| v.as_str()) { if text.lines().count() <= 5 { should_auto = true; } } } if should_auto { low_risk.push((draft, args, risk_level)); } else { // 原 Medium|High 审批分支(trust_key 命中/dedup 缓存/retry guard/pending insert + emit + audit_tool_call) // 整体保留,逻辑不变。trust_hits 元组携带 risk_level 语义不变:Med 进此处=Medium, // High 进此处(仅 low/medium 模式)时=High。 // AE-2025-04 会话级信任(Session Trust):首批 write_file / run_command, // 同会话已批准过同工具+同目录 → TrustKey 命中 → 自动放行(跳过 pending + 二次确认)。 // 命中后走与 F-05 去重命中相似的「直接执行 + Completed + 审计 decided_by=auto_trust」路径, // 但与 F-05 不同:F-05 复用缓存 tool_result 跳过执行;trust 放行**真实执行工具** // (用户信任同目录同类操作,但仍要看每次的真实结果)。 let trust_hit = super::trust_key_for(&draft.name, &args) .and_then(|key| { // F-260616-09 B 批2:读 per_conv.session_trust(conv_id 来源:本函数入参)。 if session.conv_read(conv_id).map(|c| c.session_trust.contains(&key)).unwrap_or(false) { Some(key) } else { None } }); if let Some(key) = trust_hit { let dir_label = match &key { super::TrustKey::Write { dir } | super::TrustKey::Execute { dir } => dir.clone(), }; tracing::info!( tool = %draft.name, dir = %dir_label, new_tool_call_id = %draft.id, "[AE-2025-04] 会话信任命中: 同会话已批准同类操作,自动放行(跳过审批+二次确认)" ); // emit 轻量 toast 事件(前端 AiChat.vue 显示"🔓 自动放行: tool(dir)") // toast 即时反馈,锁内 emit 不阻塞 // L3 emit 双写:会话信任自动放行 toast 双路发布(tunnel 透传 miniapp 即时反馈)。 let ev = AiChatEvent::AiToolAutoApproved { id: draft.id.clone(), tool: draft.name.clone(), dir: dir_label.clone(), conversation_id: Some(conv_id.to_string()), }; let _ = app_handle.emit("ai-chat-event", ev.clone()); let _ = app_handle.state::().ai_event_bus.publish_event(ev); // 收集后循环外 spawn 执行,对齐 Low risk 不持锁(原注释 L593 声称"锁外"但代码持锁, // run_command 慢命令会阻塞同会话所有触 state.ai_session 的 IPC,CR-51 修此) trust_hits.push((draft, args, dir_label, risk_level)); continue; } // F-05:仅 High 查去重缓存;Med 保持原审批流程 if matches!(risk_level, RiskLevel::High) { if let Some((cached, status)) = find_cached_high_risk_result(session, conv_id, &audit_repo, &draft.name, &args).await { // 命中:把缓存结果作为新 tool_call_id 的 tool_result 回传,跳过审批 tracing::info!( tool = %draft.name, new_tool_call_id = %draft.id, "[F-05] 高危工具去重命中:LLM 重试同命令,复用缓存结果跳过审批(断循环)" ); // F-260616-09 B 批2:写 per_conv.messages(conv_id 来源:本函数入参)。 session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &cached)); // L3 emit 双写:高危去重命中复用缓存 emit Completed 双路发布。 let ev = AiChatEvent::AiToolCallCompleted { id: draft.id.clone(), result: serde_json::Value::String(cached.clone()), conversation_id: Some(conv_id.to_string()), }; let _ = app_handle.emit("ai-chat-event", ev.clone()); let _ = app_handle.state::().ai_event_bus.publish_event(ev); // 审计:去重命中记一条(status 透传缓存来源 completed/rejected/failed,SW-260618-16;decided_by=auto_dedup),不进 pending audit_tool_call(&audit_repo, conv_id, &draft.id, &draft.name, &draft.args, &status, risk_level, Some(cached), Some("auto_dedup"), current_message_id).await; continue; } } pending_count += 1; // AE-2025-03(路径 B):write_file 挂起审批前预读旧文件生成 diff。 // 仅 write_file(覆盖整文件,有完整新旧内容可对比);其他工具 diff=None。 // 旧文件不存在(新建)→ diff=None,前端回退显新 content。 // 读失败不阻断审批(容错:文件无读权限等极端情况降级为无 diff 预览)。 let approval_diff: Option = if draft.name == "write_file" { build_write_file_diff(&args).await } else { None }; // 阶段4(容错/恢复,开关 df-ai-approval-retry):同 tc_id 重试检测。 // 与 High risk 的 find_cached_high_risk_result 互补:去重按 (tool_name,args) 匹配 // (High only),本 guard 按 tc_id 匹配(覆盖 Med + High 残留场景)。 // 同 tc_id 已有审计落定记录 → retry_count≥1,跳过审批 + emit Completed,断死循环。 // 兜底:flag 关或无审计记录 → retry_count=0,等价原行为。 let retry_count = detect_retry_count(&audit_repo, &draft.id).await; if retry_count >= 1 { let skip_msg = format!( "已跳过重试(同 tool_call_id={} 此前已审批执行过,防 LLM 死循环重试同卡死工具)", draft.id ); session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &skip_msg)); // L3 emit 双写:重试 guard 跳过 emit Completed 双路发布。 let ev = AiChatEvent::AiToolCallCompleted { id: draft.id.clone(), result: serde_json::Value::String(skip_msg.clone()), conversation_id: Some(conv_id.to_string()), }; let _ = app_handle.emit("ai-chat-event", ev.clone()); let _ = app_handle.state::().ai_event_bus.publish_event(ev); audit_tool_call(&audit_repo, conv_id, &draft.id, &draft.name, &draft.args, "skipped_retry", risk_level, Some(skip_msg), Some("auto_retry_guard"), current_message_id).await; continue; } session.pending_approvals.insert(draft.id.clone(), PendingApproval { tool_call_id: draft.id.clone(), tool_name: draft.name.clone(), arguments: args.clone(), conversation_id: Some(conv_id.to_string()), recovered: false, // 阶段3a:普通 RiskLevel 审批标 kind=Risk{diff}(下沉原 diff 字段)。 kind: ApprovalKind::Risk { diff: approval_diff.clone() }, retry_count, }); // 阶段2:占位带 __PENDING__:tc_id 标记,供 sanitize 豁免保留 + 出口断言自愈(防 400 orphan) session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &pending_placeholder_for(&draft.id))); let reason = build_approval_reason(&draft.name, &args, risk_level, db).await; // L3 emit 双写:Med/High 风险审批挂起 AiApprovalRequired 双路发布(tunnel 透传 miniapp 弹审批窗)。 let ev = AiChatEvent::AiApprovalRequired { id: draft.id.clone(), name: draft.name.clone(), args: args.clone(), reason, diff: approval_diff, conversation_id: Some(conv_id.to_string()), }; let _ = app_handle.emit("ai-chat-event", ev.clone()); let _ = app_handle.state::().ai_event_bus.publish_event(ev); audit_tool_call(&audit_repo, conv_id, &draft.id, &draft.name, &draft.args, "pending", risk_level, None, None, current_message_id).await; } } // AE-04 trust-hit 并行执行:execute + 即时 emit 在闭包内(闭包不访问 session,非"锁已释放"—— // session 锁仍由调用方 agentic/mod.rs 持有至 process_tool_calls 返回,CR-53 审查纠正原"移锁外"误述), // push tool_result / audit 在 join_all 后串行回填(持锁)。对齐 Low risk 并行模式。 // CR-51 修:原 inline .await execute 串行执行每个工具(阻塞期间锁被持有,run_command 慢命令 // 阻塞同会话 IPC);改 join_all 并行多工具减少总阻塞时间(锁持有时长不变,并行化降阻塞)。 // join_all 保序——结果顺序 = trust_hits 输入顺序 = tc_list 原始 index 顺序,不额外 sort if !trust_hits.is_empty() { let results: Vec<(ToolCallDraft, RiskLevel, Result)> = futures::future::join_all(trust_hits.into_iter().map(|(draft, args, _dir_label, risk_level)| { let tools = tools_arc.clone(); let app_clone = app_handle.clone(); let conv_clone = conv_id.to_string(); async move { let exec_result = execute_with_heartbeat(&tools, &draft.name, args, &app_clone, &conv_clone).await; match exec_result { Ok(val) => { let content = val.to_string(); // L3 emit 双写:trust 放行工具执行成功 emit Completed 双路发布。 let ev = AiChatEvent::AiToolCallCompleted { id: draft.id.clone(), result: serde_json::Value::String(content.clone()), conversation_id: Some(conv_clone), }; let _ = app_clone.emit("ai-chat-event", ev.clone()); let _ = app_clone.state::().ai_event_bus.publish_event(ev); // AR-11:数据变更类工具执行成功后 emit df-data-changed 联动刷新 // (write_file/run_command 不在 data_change_for_tool 映射内,emit_data_changed 内部 None 即 noop) emit_data_changed(&app_clone, &draft.name); (draft, risk_level, Ok(content)) } Err(e) => { let content = e.to_string(); // L3 emit 双写:trust 放行工具执行失败 emit Completed 双路发布。 let ev = AiChatEvent::AiToolCallCompleted { id: draft.id.clone(), result: serde_json::Value::String(content.clone()), conversation_id: Some(conv_clone), }; let _ = app_clone.emit("ai-chat-event", ev.clone()); let _ = app_clone.state::().ai_event_bus.publish_event(ev); (draft, risk_level, Err(content)) } } } })).await; // 串行回填 tool_result + 审计(持 session 锁) for (draft, risk_level, outcome) in results { let (status, content) = match outcome { Ok(c) => ("completed", c), Err(c) => ("failed", c), }; // F-260616-09 B 批2:写 per_conv.messages。 session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, content.clone())); // 审计:trust 放行仍记一条(decided_by=auto_trust),留痕可追溯 audit_tool_call(&audit_repo, conv_id, &draft.id, &draft.name, &draft.args, status, risk_level, Some(content), Some("auto_trust"), current_message_id).await; } } // Low 风险并行执行:execute + 即时 emit 在闭包内(不持 session 锁), // push tool_result / audit 在 join_all 后串行回填(持锁,与 Med/High 占位拼接)。 // join_all 保序——结果顺序 = low_risk 输入顺序 = tc_list 原始 index 顺序,不额外 sort if !low_risk.is_empty() { // 携带 args + 原始 JSON result(create_idea source 补全需解析 result.id + args.source)。 let results: Vec<(ToolCallDraft, serde_json::Value, RiskLevel, Result)> = futures::future::join_all(low_risk.into_iter().map(|(draft, args, risk_level)| { let tools = tools_arc.clone(); let app_clone = app_handle.clone(); let conv_clone = conv_id.to_string(); async move { let result = execute_with_heartbeat(&tools, &draft.name, args, &app_clone, &conv_clone).await; match result { Ok(val) => { // L3 emit 双写:Low 风险工具执行成功 emit Completed 双路发布。 let ev = AiChatEvent::AiToolCallCompleted { id: draft.id.clone(), result: val.clone(), conversation_id: Some(conv_clone), }; let _ = app_clone.emit("ai-chat-event", ev.clone()); let _ = app_clone.state::().ai_event_bus.publish_event(ev); // AR-11(方案A):数据变更工具执行成功后 emit df-data-changed, // 前端 store listen 刷新列表(仅命中映射的工具 emit,见 emit_data_changed)。 emit_data_changed(&app_clone, &draft.name); (draft, val.clone(), risk_level, Ok(val.to_string())) } Err(e) => { // AR-6(定向B):Low 工具失败不 emit AiError。 // 原逻辑 emit AiError 会令前端置 streaming=false + 错误气泡,但 process_tool_calls // 仍返回 pending_count=0,agentic loop 续下一轮 → 前端 false 后端跑,状态紊乱。 // 现改为正常 emit AiToolCallCompleted(result=错误信息),错误包进 tool_result // 让 LLM 看到工具失败自行决定下一步,loop 正常续,前端状态一致。 let err_msg = format!("工具 {} 执行失败: {}", draft.name, e); // L3 emit 双写:Low 风险工具执行失败 emit Completed 双路发布。 let ev = AiChatEvent::AiToolCallCompleted { id: draft.id.clone(), result: serde_json::Value::String(err_msg.clone()), conversation_id: Some(conv_clone), }; let _ = app_clone.emit("ai-chat-event", ev.clone()); let _ = app_clone.state::().ai_event_bus.publish_event(ev); (draft, serde_json::Value::Null, risk_level, Err(err_msg)) } } } })).await; // 串行回填 tool_result + 审计(持 session 锁) for (draft, raw_result, risk_level, outcome) in results { let (status, content) = match outcome { Ok(c) => ("completed", c), Err(c) => ("failed", c), }; // F-260619-04 P2(方案 B): create_idea source 消息级溯源补全(仅 source 空 + 有 message_id)。 // 注:低风险路径目前 create_idea 不会进(Medium→pending),此处为防御/未来若调级别覆盖。 // 仅 Ok(completed) 时补(失败 result 无 idea_id 意义);args 从 draft.args 反解(JSON 原样)。 if status == "completed" { let args_val = serde_json::from_str(&draft.args).unwrap_or(serde_json::Value::Null); maybe_fill_idea_source(db, &draft.name, &args_val, &raw_result, current_message_id).await; } // F-260616-09 B 批2:写 per_conv.messages。 session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, content.clone())); // F-#97 审计留痕:low_risk 向量中 risk_level != Low 即 mode 放行(Med 仅 medium/all、High 仅 all 才进)。 // decided_by 区分接管来源,审计表可追溯 all 模式执行了多少高危命令(治 securityReview blocker: // 原硬编码 RiskLevel::Low 致 Med/High 接管操作全标 Low/auto 失真,等于没记)。 let decided_by: &str = match risk_level { RiskLevel::Low => "auto", RiskLevel::Medium => "auto_takeover_medium", RiskLevel::High => "auto_takeover_all", }; audit_tool_call(&audit_repo, conv_id, &draft.id, &draft.name, &draft.args, status, risk_level, Some(content), Some(decided_by), current_message_id).await; } } // 阶段3a:单表 pending_approvals 统计 pending 总数(path/risk 合一,kind 区分)。 let (path_count, risk_count) = session.pending_approvals.values() .fold((0usize, 0usize), |(p, r), a| match a.kind { ApprovalKind::Path(_) => (p + 1, r), ApprovalKind::Risk { .. } => (p, r + 1), }); tracing::debug!(target: "ai_dirauth", pending_count, pending_approvals_len = session.pending_approvals.len(), path_count, risk_count, "process_tool_calls 返回(路径/风险挂起分布)"); pending_count } #[cfg(test)] mod tests { use super::*; /// grep 走单路径授权申请路径(NeedsAuth),非 search_files 盲拒(Denied)。 /// /// F-260621:grep 加入 extract_file_tool_paths 单路径分支,未授权路径触发 /// AiDirAuthRequired 申请(对齐 read_file),不像 search_files 被硬拒。 /// 锁定此差异:grep 与 read_file 同款授权弹窗语义。 #[test] fn test_grep_unauthorized_path_triggers_auth_not_denied() { // 空白名单(不含 workspace_root 的目录):任何路径都未授权 → NeedsAuth let allowed = crate::state::AllowedDirs::default(); let args = serde_json::json!({ "pattern": "foo", "path": "C:/some/unauthorized/dir" }); let outcome = check_file_tool_auth("grep", &args, &allowed); match outcome { FileToolAuthOutcome::NeedsAuth(_) => { /* 期望:grep 触发授权申请 */ } FileToolAuthOutcome::Authorized => panic!("grep 未授权应返 NeedsAuth(申请授权),实际 Authorized"), FileToolAuthOutcome::Denied(_) => panic!("grep 未授权应返 NeedsAuth(申请授权),实际 Denied(grep 不应像 search_files 盲拒)"), } // 对照:search_files 同样未授权但被硬拒(Denied),锁定两工具差异 let search_outcome = check_file_tool_auth("search_files", &args, &allowed); match search_outcome { FileToolAuthOutcome::Denied(_) => { /* 期望:search_files 盲拒 */ } FileToolAuthOutcome::NeedsAuth(_) => panic!("search_files 未授权应返 Denied(盲拒),实际 NeedsAuth"), FileToolAuthOutcome::Authorized => panic!("search_files 未授权应返 Denied(盲拒),实际 Authorized"), } } /// grep 黑名单路径(.ssh 等)直接 Denied(不申请授权),对齐 read_file。 #[test] fn test_grep_blacklisted_path_denied() { let allowed = crate::state::AllowedDirs::default_with_root(); // .ssh 命中系统黑名单(is_in_system_blacklist) let args = serde_json::json!({ "pattern": "foo", "path": "/home/user/.ssh/config" }); let outcome = check_file_tool_auth("grep", &args, &allowed); match outcome { FileToolAuthOutcome::Denied(_) => { /* 期望:黑名单硬拒 */ } FileToolAuthOutcome::NeedsAuth(_) => panic!("grep 黑名单路径应返 Denied,实际 NeedsAuth"), FileToolAuthOutcome::Authorized => panic!("grep 黑名单路径应返 Denied,实际 Authorized"), } } /// extract_file_tool_paths:grep 取 args["path"](单路径分支) #[test] fn test_extract_grep_path() { let args = serde_json::json!({ "pattern": "foo", "path": "src/main.rs", "glob": "*.rs" }); let paths = extract_file_tool_paths("grep", &args); assert_eq!(paths, vec!["src/main.rs".to_string()], "grep 应取 path 参数(忽略 glob 等其他参数)"); } }