//! 审批门控逻辑。 //! //! 审批策略(方案 B:Persona 维度 + 扩展点): //! - reviewer/analyst 人设的工具调用全 auto(只读/分析,审批无意义) //! - 其他人设走现有 auto_exec_mode 三档 //! - Phase 2 扩展点:可在 should_auto_for_persona 内加工具维度 if 分支 use std::sync::Arc; use tauri::{AppHandle, Emitter, Manager}; use tokio::sync::Mutex; use df_ai::ai_tools::{AiToolRegistry, RiskLevel}; use df_ai::provider::ChatMessage; use df_storage::crud::AiToolExecutionRepo; use df_storage::db::Database; use super::cache::{find_cached_high_risk_result, pending_placeholder_for}; use super::diff::build_write_file_diff; use super::reason::build_approval_reason; use super::record::audit_tool_call; use super::super::{AiChatEvent, AiSession, ApprovalKind, PendingApproval, ToolCallDraft, is_destructive_command, trust_key_for, TrustKey}; /// 按 risk_level + auto_exec_mode 判定是否自动执行。 /// /// F-#97 自动执行范围三档(2026-06-22):low/medium/all,默认 low。 /// - low:仅 Low 自动(等价旧行为) /// - medium:Low+Medium 自动 /// - all:全自动无审批(完全 AI 接管) /// /// C-260627:patch_file 小改动(≤5 行)特例自动放行,不阻塞 AI 工作流。 pub(super) fn classify_risk_and_auto( risk_level: RiskLevel, auto_exec_mode: &str, tool_name: &str, args: &serde_json::Value, ) -> bool { let _ = args; // 预留扩展点(未来可加路径白名单/文件类型等维度) // patch_file 是 AI 编码主力工具,改文件是 AI Coding 的工作常态。 // 原本“小改动(≤5 行)豁免、大改动审批”的设计在实际使用中频繁打断 AI 工作流 // (AI 改文件往往远超 5 行),且文件已写入会落 audit 表可追溯,审批价值低。 // 决策:patch_file 全档位自动放行(low/medium/all 均不审批),与 read_file 同级处理。 if tool_name == "patch_file" { return true; } match risk_level { RiskLevel::Low => true, RiskLevel::Medium => auto_exec_mode == "medium" || auto_exec_mode == "all", RiskLevel::High => auto_exec_mode == "all", } } /// 审批策略方案 B:Persona 维度判定。 /// /// 在 classify_risk_and_auto 之上叠加人设维度: /// - reviewer/analyst 人设:工具白名单已限制为只读,全部 auto(审批纯浪费) /// - 其他人设:走 classify_risk_and_auto 原逻辑 /// /// Phase 2 扩展点:在此函数内加工具维度 if 分支,例如: /// ```ignore /// if persona_id == "coder" && tool_name == "write_file" { /// if let Some(path) = args.get("path").and_then(|v| v.as_str()) { /// if path.ends_with(".md") { return true; } // 写文档 auto /// } /// } /// ``` #[allow(dead_code)] // 接线到 process_tool_calls 需要 SubTask 携带 persona_id,后续批次接入 pub(super) fn should_auto_for_persona( persona_id: Option<&str>, risk_level: RiskLevel, auto_exec_mode: &str, tool_name: &str, args: &serde_json::Value, ) -> bool { // 人设维度:reviewer/analyst 全 auto match persona_id { Some("reviewer") | Some("analyst") => { tracing::debug!( persona = ?persona_id, tool = %tool_name, "[POLICY-B] 只读人设全 auto" ); return true; } _ => {} } // 兜底:现有三档 + patch_file 特例 classify_risk_and_auto(risk_level, auto_exec_mode, tool_name, args) } /// 检查会话信任命中。 /// /// 首批信任工具:write_file / run_command。同会话已批准过同工具+同目录 → /// `TrustKey` 命中,返回 `Some(TrustKey)`;否则返回 `None`(走原审批流程)。 /// /// 签名改 `session_arc: &Arc>`,内部短 lock 读 /// `session_trust` 后立即 drop,信任查询不持锁,与 process_tool_calls 持锁 await 反模式解耦。 pub(super) async fn check_trust_hits( draft: &ToolCallDraft, args: &serde_json::Value, session_arc: &Arc>, conv_id: &str, ) -> Option { let key = trust_key_for(&draft.name, args)?; // 安全门控(run_command 会话信任按目录不按命令的补丁): // run_command 的信任 key 只用 working_dir,一次批准 → 同目录任意命令 auto。 // 破坏性命令(rm/del/format/... 不可逆)绝不 auto 放行,即使同目录已批准过, // 也必须每轮走正常审批。命中黑名单直接返 None,让流程继续走 pending 审批。 if matches!(key, TrustKey::Execute { .. }) { if let Some(cmd) = args.get("command").and_then(|v| v.as_str()) { if is_destructive_command(cmd) { tracing::info!( tool = %draft.name, new_tool_call_id = %draft.id, "[会话信任] 命中破坏性命令黑名单,跳过 auto 放行(走正常审批)" ); return None; } } } // 短 lock 读 session_trust(仅 contains 判定,无 await,纳秒级),命中即返 key let hit = { let session = session_arc.lock().await; session.conv_read(conv_id) .map(|c| c.session_trust.contains(&key)) .unwrap_or(false) }; if hit { Some(key) } else { None } } /// 插入 pending 审批:生成 diff → 重试检测 → insert PendingApproval + 占位 tool_result /// → 拼 reason + emit AiApprovalRequired + 审计落 pending 纪录。 /// /// **注意**:调用方应在调用前先 +=1 `pending_count`(保持与原 `handle_approval_tool` /// 行为一致——重试 skip 分支也在 `pending_count += 1` 之后返回)。 /// /// 签名改 `session_arc: &Arc>`,所有慢操作 /// (build_write_file_diff/detect_retry_count/build_approval_reason/audit_tool_call) /// 在锁外 await,仅 `pending_approvals.insert` + `messages.push` 两处纯写改短 lock 段。 pub(super) async fn insert_pending_approval( draft: ToolCallDraft, args: serde_json::Value, risk_level: RiskLevel, session_arc: &Arc>, conv_id: &str, audit_repo: &AiToolExecutionRepo, app_handle: &AppHandle, db: &Arc, current_message_id: Option<&str>, ) { // (路径 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 }; // (容错/恢复,开关 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 ); // 短 lock 段:push tool_result(纯写,无 await) { let mut session = session_arc.lock().await; 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; return; } // 短 lock 段:纯写 pending_approvals.insert + 占位 tool_result push { let mut session = session_arc.lock().await; 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, // 普通风险审批标 kind=Risk{diff}(下沉原 diff 字段)。 kind: ApprovalKind::Risk { diff: approval_diff.clone() }, retry_count, created_at: Some(std::time::SystemTime::now()), }); // 占位带 __PENDING__:tc_id 标记,供 sanitize 豁免保留 + 出口断言自愈(防 400 orphan) session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &pending_placeholder_for(&draft.id))); } // 慢操作锁外:拼 reason(DB 读)+ emit + 审计落 pending 纪录(DB 写) 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; } /// (容错/恢复,开关 `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,等价原行为(单次审批执行,无重试防护)。 pub(super) 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!("[approval-retry] 查审计表 tc_id={} 失败(降级无重试防护): {}", tc_id, e); return 0; } }; // pending = 首次挂起审批(尚未落定);其余落定状态 = 已执行过 → 计 1 次重试 if rec.status == "pending" { 0 } else { 1 } } /// 对单条工具调用执行审批门控决策: /// /// 1. 按 `auto_exec_mode`(low/medium/all) + `risk_level` + `patch_file` 小改动特例 /// 判定是否应自动执行(`classify_risk_and_auto`)。若是 → 推入 `low_risk` 返回。 /// 2. 否则走审批分支:会话信任(`check_trust_hits`)→ 高危去重缓存 → 重试 guard → /// `insert_pending_approval`(write_file diff + 挂起 + emit + 审计落 pending 记录)。 /// /// 签名改 `session_arc: &Arc>`,所有 session 访问 /// 都改短 lock 段(check_trust_hits/find_cached_high_risk_result 内部短 lock,命中后 push /// tool_result 短 lock 段)。慢操作(audit_tool_call/find_cached_high_risk_result 的 DB 查) /// 全在锁外 await,根治 process_tool_calls 持 session lock 期间 await 慢操作死锁反模式。 pub(super) async fn handle_approval_tool( draft: ToolCallDraft, args: serde_json::Value, risk_level: RiskLevel, auto_exec_mode: &str, session_arc: &Arc>, conv_id: &str, _tools_arc: &Arc, audit_repo: &AiToolExecutionRepo, app_handle: &AppHandle, db: &Arc, low_risk: &mut Vec<(ToolCallDraft, serde_json::Value, RiskLevel)>, trust_hits: &mut Vec<(ToolCallDraft, serde_json::Value, String, RiskLevel)>, pending_count: &mut usize, current_message_id: Option<&str>, ) { // ── Step 1: 自动执行判定 ── if classify_risk_and_auto(risk_level, auto_exec_mode, &draft.name, &args) { low_risk.push((draft, args, risk_level)); return; } // ── Step 2: 会话信任检查 ── // 会话级信任(Session Trust):首批 write_file / run_command, // 同会话已批准过同工具+同目录 → TrustKey 命中 → 自动放行(跳过 pending + 二次确认)。 // 命中后走与去重命中相似的「直接执行 + Completed + 审计 decided_by=auto_trust」路径, // 但与 F-05 不同:F-05 复用缓存 tool_result 跳过执行;trust 放行**真实执行工具** // (用户信任同目录同类操作,但仍要看每次的真实结果)。 if let Some(key) = check_trust_hits(&draft, &args, session_arc, conv_id).await { let dir_label = match &key { TrustKey::Write { dir } | TrustKey::Execute { dir } => dir.clone(), }; tracing::info!( tool = %draft.name, dir = %dir_label, new_tool_call_id = %draft.id, "[会话信任] 命中: 同会话已批准同类操作,自动放行(跳过审批+二次确认)" ); // emit 轻量 toast 事件(前端 AiChat.vue 显示"🔓 自动放行: tool(dir)") // 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)); return; } // ── Step 3: F-05 高危去重缓存(仅 High) ── if matches!(risk_level, RiskLevel::High) { // find_cached_high_risk_result 内部短 lock 读 messages + 锁外 await DB 查 status, // 返回 (cached_content, status) 时不持锁 if let Some((cached, status)) = find_cached_high_risk_result(session_arc, 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 重试同命令,复用缓存结果跳过审批(断循环)" ); // 短 lock 段:push tool_result(纯写,无 await) { let mut session = session_arc.lock().await; 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; return; } } // ── Step 4: 插入 pending 审批 ── *pending_count += 1; insert_pending_approval( draft, args, risk_level, session_arc, conv_id, audit_repo, app_handle, db, current_message_id, ).await; }