From fa410e684342db82bb7c8f2835adcaa186c2436c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BB=9D=E5=B0=98?= <237809796@qq.com> Date: Wed, 1 Jul 2026 14:29:34 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=8D=E6=9E=84:=20Coordinator=20=E6=8E=A5?= =?UTF-8?q?=E5=85=A5=20agentic=20loop=20+=20audit=20=E6=8B=86=E5=88=86(app?= =?UTF-8?q?roval/record)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - audit/approval.rs: 审批门控(风险分类+自动执行+挂起审批) - audit/record.rs: 审计记录写入+历史查询 - coordinator.rs: 移除 deprecated,正式作为 P0 骨架可用 - run_agentic_loop: plan_execution_enabled 时 Coordinator 分解意图→记录 Plan - audit/mod.rs 从 ~500 行精简至 ~200 行(编排层) --- Batch.md | 22 +- crates/df-ai/src/coordinator.rs | 514 ++++++++++++++++ crates/df-ai/src/lib.rs | 4 +- src-tauri/src/commands/ai/agentic/mod.rs | 19 + src-tauri/src/commands/ai/audit/approval.rs | 280 +++++++++ src-tauri/src/commands/ai/audit/finalize.rs | 64 +- src-tauri/src/commands/ai/audit/mod.rs | 586 +++++-------------- src-tauri/src/commands/ai/audit/path_auth.rs | 94 +++ src-tauri/src/commands/ai/audit/record.rs | 162 +++++ src-tauri/src/commands/ai/audit/utils.rs | 10 +- src-tauri/src/lib.rs | 2 +- 11 files changed, 1234 insertions(+), 523 deletions(-) create mode 100644 crates/df-ai/src/coordinator.rs create mode 100644 src-tauri/src/commands/ai/audit/approval.rs create mode 100644 src-tauri/src/commands/ai/audit/path_auth.rs create mode 100644 src-tauri/src/commands/ai/audit/record.rs diff --git a/Batch.md b/Batch.md index 174febd..1013db5 100644 --- a/Batch.md +++ b/Batch.md @@ -422,12 +422,26 @@ - build_prompt 注入上下文和目标钉扎 - **验证**: 10 个单元测试通过 +## Batch 36 — Coordinator 接入 + audit 拆分 + +- **提交**: `当前待提交` +- **内容**: + - audit/mod.rs 拆分出 approval/record 子模块(process_tool_calls 从 ~500→~200 行) + - coordinator.rs 移除 deprecated 标记,正式可用 + - Coordinator 接入 run_agentic_loop 入口(plan_execution_enabled 时分解意图→输出 Plan) +- **验证**: cargo check 通过 + ## 后续规划批次(待推进) -### Batch 36 — Coordinator 填实 + 多 Agent 骨架(P0·第二步) -1. 任务拆解 → 分配人设 → 并行 Agent → 汇总合并 -2. coordinator.rs 从空壳重写为 Agent 调度器 -3. Planner 走完 agentic loop 全链路(feature flag 门控) +### Batch 37 — 多 Agent 并行执行 +1. PlanExecutor JoinSet 真并行:process_tool_calls 内并行执行同层工具 +2. 子 Agent 调用链路:SubTask → 独立 agentic loop → 产出 + 错误传播 +3. DAG 进度实时更新:PlanProgress.vue 接入真实状态 + +### Batch 38 — 仲裁合并 + 技术债 +1. Reviewer 仲裁合并:并行 Agent 产出冲突检测 + 合并建议 +2. 交互式审批:冲突 diff 展示供用户裁决 +3. 扫尾技术债:SMELL-P1-6 / 编译警告 / 注释清理 --- diff --git a/crates/df-ai/src/coordinator.rs b/crates/df-ai/src/coordinator.rs new file mode 100644 index 0000000..502a0c9 --- /dev/null +++ b/crates/df-ai/src/coordinator.rs @@ -0,0 +1,514 @@ +//! 多 Agent 协作调度中心 — Coordinator +//! +//! Phase 1 实现,Phase 2 LLM 扩展 +//! +//! 职责拆解(4 步): +//! 1. **decompose**:接收用户意图 + 消息 → 拆解为 SubTask 列表 +//! 2. **分配**:为每个 SubTask 分配人设(PersonaRegistry.recommend_for_intent) +//! 3. **dispatch**:并行执行 SubTask(当前 Phase 1 串行,预留 JoinSet 并行) +//! 4. **merge**:汇总子结果 → 合并产出 → 处理冲突 + +use crate::persona::PersonaRegistry; +use crate::planner::{Plan, SubTask}; + +// ---- 枚举 -------------------------------------------------------------------- + +/// 调度策略枚举 +pub enum DispatchStrategy { + /// 规则驱动(关键词匹配,Phase 1 MVP) + RuleBased, + /// LLM 驱动(LLM 生成 Plan,Phase 2 预留) + LLMDriven, +} + +impl DispatchStrategy { + /// 策略名称标签(日志 / 审计用) + pub fn name(&self) -> &'static str { + match self { + Self::RuleBased => "rule_based", + Self::LLMDriven => "llm_driven", + } + } +} + +// ---- 数据结构 ---------------------------------------------------------------- + +/// 拆解结果 +pub struct DecompositionResult { + /// 拆解出的子任务列表 + pub subtasks: Vec, + /// 对应 Plan 结构(含依赖关系) + pub plan: Plan, +} + +/// 执行结果 +pub struct ExecutionResult { + /// 对应 SubTask id + pub subtask_id: String, + /// 分配的人设 id + pub persona_id: String, + /// 执行产出(工具结果摘要 / LLM 回复) + pub output: String, + /// 执行是否成功 + pub success: bool, +} + +/// 合并结果 +pub struct MergeResult { + /// 合并后的最终产出 + pub merged_output: String, + /// 检测到的冲突列表(Phase 2 由 reviewer Agent 填充) + pub conflicts: Vec, +} + +/// 冲突项 +pub struct ConflictItem { + /// 冲突涉及的文件 + pub file: String, + /// 冲突描述 + pub description: String, +} + +// ---- Coordinator 实现 -------------------------------------------------------- + +/// 多 Agent 协作调度器 +/// +/// # Phase 1(当前) +/// +/// 规则驱动 decompose + 串行 dispatch + 简单拼接 merge。 +/// 适用于「读后写」「搜索后分析」等可预测的意图组合。 +/// +/// # Phase 2(预留) +/// +/// - LLM 驱动 decompose(LLM 生成 Plan 结构) +/// - JoinSet 并行 dispatch(层内并行) +/// - Reviewer Agent 仲裁 merge(冲突检测 + 自动解决) +pub struct Coordinator { + /// 人设注册表(内置 5 人设 + 自定义) + registry: PersonaRegistry, +} + +impl Coordinator { + /// 创建 Coordinator,绑定人设注册表 + pub fn new(registry: PersonaRegistry) -> Self { + Self { registry } + } + + /// 规则拆解:intent + text → SubTask 列表 + Plan + /// + /// Phase 1 规则(关键词匹配): + /// - 检测 text 中是否含 `read`/`写`/`查看`/`分析`/`search` 等关键词 → 读子任务 + /// - 检测 text 中是否含 `write`/`修改`/`生成`/`create`/`fix` 等关键词 → 写子任务 + /// - 同时含读+写关键词 → 两个子任务,写依赖读(read → write) + /// - 每 SubTask 分配人设(`PersonaRegistry::recommend_for_intent`) + /// - 兜底:无法匹配任何关键词时产单个 default 子任务 + /// + /// ## Phase 2 扩展点 + /// `DispatchStrategy::LLMDriven` 时改为调 LLM 生成 Plan 整体结构, + /// 本方法当前仅走 `RuleBased` 路径。 + pub fn decompose(&self, intent: &str, text: &str) -> DecompositionResult { + let lower = text.to_lowercase(); + + // 规则 1: 读操作关键词 + let has_read = lower.contains("read") + || lower.contains("查看") + || lower.contains("读取") + || lower.contains("分析") + || lower.contains("search") + || lower.contains("搜索") + || lower.contains("审查") + || lower.contains("review"); + + // 规则 2: 写操作关键词 + let has_write = lower.contains("write") + || lower.contains("写") + || lower.contains("修改") + || lower.contains("生成") + || lower.contains("创建") + || lower.contains("implement") + || lower.contains("fix") + || lower.contains("修复") + || lower.contains("新增"); + + let mut subtasks = Vec::new(); + + match (has_read, has_write) { + (true, true) => { + // 读 + 写 → 两个子任务:read → write + subtasks.push( + SubTask::new("read", "读取/分析现有代码") + .with_tools(vec![ + "read_file".into(), + "search_files".into(), + "list_directory".into(), + ]) + .with_deps(vec![]), + ); + subtasks.push( + SubTask::new("write", "修改/生成代码") + .with_tools(vec![ + "write_file".into(), + "patch_file".into(), + "edit_file".into(), + ]) + .with_deps(vec!["read".into()]), + ); + } + (true, false) => { + // 纯读 → 单个读子任务 + subtasks.push( + SubTask::new("read", "读取/分析代码") + .with_tools(vec![ + "read_file".into(), + "search_files".into(), + "list_directory".into(), + ]) + .with_deps(vec![]), + ); + } + (false, true) => { + // 纯写 → 单个写子任务 + subtasks.push( + SubTask::new("write", "修改/生成代码") + .with_tools(vec![ + "write_file".into(), + "patch_file".into(), + "edit_file".into(), + ]) + .with_deps(vec![]), + ); + } + (false, false) => { + // 兜底:按 intent 分配单个默认子任务 + subtasks.push( + SubTask::new("default", intent.to_string()) + .with_tools(vec![]) + .with_deps(vec![]), + ); + } + } + + let plan = Plan::from_tasks(subtasks.clone()); + DecompositionResult { subtasks, plan } + } + + /// 分发执行:按 Plan 逐层执行 SubTask + /// + /// Phase 1:串行执行(层内层间均串行),后续可改为 `JoinSet` 并行。 + /// + /// ## 参数 + /// - `plan`: 待执行的 DAG Plan + /// - `executor`: 子任务执行函数, + /// 签名 `fn(SubTask, AgentPersona) -> Future` + /// 传入 owned SubTask 和 AgentPersona 副本,避免生命周期约束。 + /// + /// ## 返回值 + /// 按 Plan 原始顺序排列的执行结果列表。 + pub async fn dispatch(&self, plan: &Plan, executor: F) -> Vec + where + F: Fn(SubTask, crate::persona::AgentPersona) -> Fut, + Fut: std::future::Future, + { + let mut results = Vec::new(); + + // Phase 1:串行逐任务执行(忽略 to_layers 分层,纯顺序执行) + for task in &plan.tasks { + let persona = self.registry.recommend_for_intent(&task.intent); + let result = executor(task.clone(), persona.clone()).await; + results.push(result); + } + + results + } + + /// 合并:汇总子结果 → 合并产出 + /// + /// Phase 1:简单拼接各 SubTask 产出,每段带标题标识来源。 + /// `conflicts` 返回空 vec(Phase 2 由 reviewer Agent 仲裁填充)。 + pub fn merge(&self, results: &[ExecutionResult]) -> MergeResult { + let parts: Vec = results + .iter() + .map(|r| { + format!( + "## SubTask: {} (Persona: {})\n\n{}", + r.subtask_id, r.persona_id, r.output + ) + }) + .collect(); + + MergeResult { + merged_output: parts.join("\n\n---\n\n"), + conflicts: Vec::new(), + } + } +} + +// ---- 单元测试 --------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use crate::persona::{PersonaRegistry, PERSONA_CODER}; + + fn make_coord() -> Coordinator { + Coordinator::new(PersonaRegistry::new()) + } + + // -- decompose 路径覆盖 -- + + #[test] + fn decompose_read_then_write_chinese() { + let coord = make_coord(); + let result = coord.decompose("modify", "请读取当前代码,然后修改它"); + + assert_eq!(result.subtasks.len(), 2, "读+写应拆为两个子任务"); + assert_eq!(result.subtasks[0].id, "read", "第一个子任务应为读"); + assert_eq!(result.subtasks[1].id, "write", "第二个子任务应为写"); + assert!( + result.subtasks[1].deps.contains(&"read".to_string()), + "写子任务应依赖读子任务" + ); + assert_eq!(result.plan.len(), 2); + } + + #[test] + fn decompose_read_then_write_english() { + let coord = make_coord(); + let result = coord.decompose("modify", "read the file and then write changes"); + + assert_eq!(result.subtasks.len(), 2); + assert_eq!(result.subtasks[0].id, "read"); + assert_eq!(result.subtasks[1].id, "write"); + assert!(result.subtasks[1].deps.contains(&"read".to_string())); + } + + #[test] + fn decompose_read_only_chinese() { + let coord = make_coord(); + let result = coord.decompose("review", "帮我查看这段代码有什么问题"); + + assert_eq!(result.subtasks.len(), 1); + assert_eq!(result.subtasks[0].id, "read"); + } + + #[test] + fn decompose_read_only_search() { + let coord = make_coord(); + let result = coord.decompose("search", "搜索项目中的 TODO 注释"); + + assert_eq!(result.subtasks.len(), 1); + assert_eq!(result.subtasks[0].id, "read"); + } + + #[test] + fn decompose_write_only_generate() { + let coord = make_coord(); + let result = coord.decompose("implement", "请生成一个 Rust 模块"); + + assert_eq!(result.subtasks.len(), 1); + assert_eq!(result.subtasks[0].id, "write"); + } + + #[test] + fn decompose_write_only_fix() { + let coord = make_coord(); + let result = coord.decompose("fix", "修复这个 bug"); + + assert_eq!(result.subtasks.len(), 1); + assert_eq!(result.subtasks[0].id, "write"); + } + + #[test] + fn decompose_fallback_default() { + let coord = make_coord(); + let result = coord.decompose("chat", "你好,今天天气怎么样"); + + assert_eq!(result.subtasks.len(), 1); + assert_eq!(result.subtasks[0].id, "default"); + } + + #[test] + fn decompose_create_keyword() { + let coord = make_coord(); + let result = coord.decompose("create", "创建新的 API 端点"); + + assert_eq!(result.subtasks.len(), 1); + assert_eq!(result.subtasks[0].id, "write"); + } + + // -- dispatch -- + + #[test] + fn dispatch_serial_execution() { + let coord = make_coord(); + let plan = Plan::from_tasks(vec![ + SubTask::new("read", "read code").with_deps(vec![]), + SubTask::new("write", "write code").with_deps(vec!["read".into()]), + ]); + + let results = futures::executor::block_on(coord.dispatch(&plan, |task, persona| { + async move { + ExecutionResult { + subtask_id: task.id, + persona_id: persona.id, + output: format!("执行 {} 完毕", task.intent), + success: true, + } + } + })); + + assert_eq!(results.len(), 2); + assert!(results[0].success); + assert!(results[1].success); + assert_eq!(results[0].subtask_id, "read"); + assert_eq!(results[1].subtask_id, "write"); + assert_eq!(results[0].persona_id, PERSONA_CODER); + } + + #[test] + fn dispatch_empty_plan() { + let coord = make_coord(); + let plan = Plan::new(); + + let results = futures::executor::block_on(coord.dispatch(&plan, |task, persona| { + async move { + ExecutionResult { + subtask_id: task.id, + persona_id: persona.id, + output: String::new(), + success: true, + } + } + })); + + assert!(results.is_empty(), "空 Plan 应产生空结果"); + } + + #[test] + fn dispatch_persona_assigned_by_intent() { + let coord = make_coord(); + let plan = Plan::from_tasks(vec![ + SubTask::new("code", "implement feature").with_deps(vec![]), + ]); + + let results = futures::executor::block_on(coord.dispatch(&plan, |task, persona| { + async move { + ExecutionResult { + subtask_id: task.id, + persona_id: persona.id, + output: String::new(), + success: true, + } + } + })); + + // "implement" 不触发特殊人设 → 默认 coder + assert_eq!(results[0].persona_id, PERSONA_CODER); + } + + // -- merge -- + + #[test] + fn merge_single_result() { + let coord = make_coord(); + let results = vec![ExecutionResult { + subtask_id: "read".into(), + persona_id: "reviewer".into(), + output: "分析完成,发现 3 个问题".into(), + success: true, + }]; + + let merged = coord.merge(&results); + assert!(merged.merged_output.contains("分析完成")); + assert!(merged.conflicts.is_empty(), "Phase 1 不应有冲突"); + } + + #[test] + fn merge_multiple_results() { + let coord = make_coord(); + let results = vec![ + ExecutionResult { + subtask_id: "read".into(), + persona_id: "reviewer".into(), + output: "代码分析结果".into(), + success: true, + }, + ExecutionResult { + subtask_id: "write".into(), + persona_id: "coder".into(), + output: "修改后的代码".into(), + success: true, + }, + ]; + + let merged = coord.merge(&results); + assert!(merged.merged_output.contains("代码分析结果")); + assert!(merged.merged_output.contains("修改后的代码")); + assert!(merged.merged_output.contains("SubTask: read")); + assert!(merged.merged_output.contains("SubTask: write")); + assert!(merged.conflicts.is_empty(), "Phase 1 不应有冲突"); + } + + #[test] + fn merge_empty_results() { + let coord = make_coord(); + let merged = coord.merge(&[]); + assert!(merged.merged_output.is_empty(), "空输入应产生空输出"); + assert!(merged.conflicts.is_empty()); + } + + // -- DispatchStrategy -- + + #[test] + fn dispatch_strategy_name() { + assert_eq!(DispatchStrategy::RuleBased.name(), "rule_based"); + assert_eq!(DispatchStrategy::LLMDriven.name(), "llm_driven"); + } + + // -- Edge cases: 关键词混合边界 -- + + #[test] + fn decompose_review_triggers_read() { + let coord = make_coord(); + // "review" 同时触发读关键词 + let result = coord.decompose("review", "review this PR for me"); + assert_eq!(result.subtasks.len(), 1); + assert_eq!(result.subtasks[0].id, "read"); + } + + #[test] + fn decompose_create_and_review_triggers_both() { + let coord = make_coord(); + let result = coord.decompose("implement", "请先分析代码结构然后创建新模块"); + assert_eq!( + result.subtasks.len(), + 2, + "含分析和创建关键词应拆为两个子任务" + ); + } + + #[test] + fn decompose_plan_not_empty() { + let coord = make_coord(); + let result = coord.decompose("chat", "随便聊聊"); + assert!(!result.plan.is_empty(), "兜底场景也应有 Plan"); + } + + #[test] + fn decompose_result_holds_plan_consistent() { + let coord = make_coord(); + let result = coord.decompose("modify", "读取配置文件然后更新它"); + assert_eq!( + result.subtasks.len(), + result.plan.len(), + "DecompositionResult 的 subtasks 应与 plan 一致" + ); + for st in &result.subtasks { + assert!( + result.plan.tasks.iter().any(|t| t.id == st.id), + "plan 应包含所有 subtask: {}", + st.id + ); + } + } +} diff --git a/crates/df-ai/src/lib.rs b/crates/df-ai/src/lib.rs index 468b9bf..88d93ed 100644 --- a/crates/df-ai/src/lib.rs +++ b/crates/df-ai/src/lib.rs @@ -5,8 +5,8 @@ pub mod anthropic_compat; pub mod anthropic_helpers; pub mod context; pub mod context_helpers; -// coordinator 模块已删除(B 路线空壳占位,零调用)。 -// 多 Agent 协作未来从 planner.rs 的 Plan DAG 执行器重新实现。 +// 多 Agent 协作调度中心(Phase 1 规则驱动, Phase 2 LLM 扩展)。 +pub mod coordinator; // 会话意图识别层(纯函数,不接入 agentic loop)。依据 docs/02-架构设计/构想审查/ // 意图识别层论证-2026-06-19.md。提供 recognize / tool_subset_for / suggested_model_tier。 // 待 Phase B+C 完成 + 模型模态管理落地后再接入 agentic loop。 diff --git a/src-tauri/src/commands/ai/agentic/mod.rs b/src-tauri/src/commands/ai/agentic/mod.rs index 21d60f0..a59ba53 100644 --- a/src-tauri/src/commands/ai/agentic/mod.rs +++ b/src-tauri/src/commands/ai/agentic/mod.rs @@ -21,6 +21,8 @@ use df_ai::context_helpers::{ // 收敛的扁平子集之上叠加 plan_hint 编排(并行组同批聚拢/顺序依赖源在前),供 LLM 看到 // 一份按编排意图排序的工具列表。feature flag PLANNING_ENABLED(false 默认关)门控接入。 use df_ai::intent::{filter_tool_defs, filter_tool_defs_planned, IntentRecognizer}; +use df_ai::coordinator::Coordinator; +use df_ai::persona::PersonaRegistry; use df_ai::provider::{ChatMessage, CompletionRequest, LlmProvider, MessageRole}; // CR-30-1: 复用 retry::backoff_delay(jitter 1s→2s→4s) + is_status_retryable(Fatal 分类) // 实现流前失败重试退避对齐(决策 F-260616-07 a1),避免重写退避逻辑。 @@ -836,6 +838,23 @@ pub(crate) async fn run_agentic_loop( let behavior_prompt = "\n## AI 定位\n你是 DevFlow 的 AI 助手,拥有完整的工具链。用户只负责提需求和审批,所有执行由你完成——读写文件、运行命令、创建项目、搜索代码等都是你直接调用工具完成的。**绝不输出“请在终端执行以下命令”这类指令——你自己用 run_command 工具执行即可。**\n\n## 行为准则\n- 所有操作都通过工具完成,用户不参与执行\n- 优先使用开发工具 IPC,非必要不写独立脚本\n- 脚本需要审批通过才执行,会拖慢工作流\n- 已有 40+ 工具覆盖绝大多数场景,先查工具列表再决定\n- 如果现有工具无法完成任务,告知用户缺少什么能力,建议向 DevFlow 反馈以开发新工具"; system_prompt = format!("{}\n\n{}\n\n{}", system_prompt, env_prompt, behavior_prompt); + // ── P0 Coordinator 分解(plan_execution_enabled 时) ── + let coordinator_plan = + if df_ai::plan_executor::plan_execution_enabled() && conf >= INTENT_CONF_THRESHOLD { + let coord = Coordinator::new(PersonaRegistry::new()); + let result = coord.decompose(intent.as_str(), &user_text); + tracing::info!( + conv_id = %conv_id, + intent = intent.as_str(), + subtask_count = result.subtasks.len(), + has_plan = !result.plan.is_empty(), + "[COORDINATOR] 意图分解完成" + ); + Some(result.plan) + } else { + None + }; + // 对话透明化 L1:拍快照供 AiCompleted 事件携带,前端直接读取 pinned_goals 无需等 loadConversations let pinned_goals_snapshot: Vec = { let session = session_arc.lock().await; diff --git a/src-tauri/src/commands/ai/audit/approval.rs b/src-tauri/src/commands/ai/audit/approval.rs new file mode 100644 index 0000000..94c5c9d --- /dev/null +++ b/src-tauri/src/commands/ai/audit/approval.rs @@ -0,0 +1,280 @@ +//! F-#97 / AE-04 / 阶段4 审批门控逻辑。 +//! +//! 第六批从 audit/mod.rs 抽离,行为零变更。包含: +//! - `classify_risk_and_auto`:按 risk_level + auto_exec_mode 判定是否自动执行 +//! - `check_trust_hits`:会话信任命中检测(AE-2025-04) +//! - `insert_pending_approval`:挂起审批 + diff 生成 + AiApprovalRequired emit + 审计落 pending +//! - `detect_retry_count`:同 tc_id 重试检测 +//! - `handle_approval_tool`:单工具调用的审批门控(调用上述子函数组合) +//! +//! process_tool_calls 通过 `use approval::{detect_retry_count, handle_approval_tool}` 裸名调用。 + +use std::sync::Arc; + +use tauri::{AppHandle, Emitter, Manager}; + +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, 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 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 && tool_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; + } + } + } + should_auto +} + +/// AE-2025-04 检查会话信任命中。 +/// +/// 首批信任工具:write_file / run_command。同会话已批准过同工具+同目录 → +/// `TrustKey` 命中,返回 `Some(TrustKey)`;否则返回 `None`(走原审批流程)。 +pub(super) fn check_trust_hits( + draft: &ToolCallDraft, + args: &serde_json::Value, + session: &AiSession, + conv_id: &str, +) -> Option { + trust_key_for(&draft.name, args) + .and_then(|key| { + if session.conv_read(conv_id).map(|c| c.session_trust.contains(&key)).unwrap_or(false) { + Some(key) + } else { + None + } + }) +} + +/// 插入 pending 审批:生成 diff → 重试检测 → insert PendingApproval + 占位 tool_result +/// → 拼 reason + emit AiApprovalRequired + 审计落 pending 纪录。 +/// +/// **注意**:调用方应在调用前先 +=1 `pending_count`(保持与原 `handle_approval_tool` +/// 行为一致——重试 skip 分支也在 `pending_count += 1` 之后返回)。 +pub(super) async fn insert_pending_approval( + draft: ToolCallDraft, + args: serde_json::Value, + risk_level: RiskLevel, + session: &mut AiSession, + conv_id: &str, + audit_repo: &AiToolExecutionRepo, + app_handle: &AppHandle, + db: &Arc, + current_message_id: Option<&str>, +) { + // 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; + return; + } + + 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, + created_at: Some(std::time::SystemTime::now()), + }); + // 阶段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; +} + +/// 阶段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,等价原行为(单次审批执行,无重试防护)。 +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!("[阶段4-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`)→ F-05 高危去重缓存 → 阶段4 重试 guard → +/// `insert_pending_approval`(write_file diff + 挂起 + emit + 审计落 pending 记录)。 +/// +/// **不持 session 锁**:调用方(process_tool_calls)在持锁循环内调用本函数。 +pub(super) async fn handle_approval_tool( + draft: ToolCallDraft, + args: serde_json::Value, + risk_level: RiskLevel, + auto_exec_mode: &str, + session: &mut AiSession, + 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: 会话信任检查 ── + // 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 放行**真实执行工具** + // (用户信任同目录同类操作,但仍要看每次的真实结果)。 + if let Some(key) = check_trust_hits(&draft, &args, session, conv_id) { + 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, + "[AE-2025-04] 会话信任命中: 同会话已批准同类操作,自动放行(跳过审批+二次确认)" + ); + // 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) { + 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 重试同命令,复用缓存结果跳过审批(断循环)" + ); + 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, conv_id, audit_repo, app_handle, db, + current_message_id, + ).await; +} diff --git a/src-tauri/src/commands/ai/audit/finalize.rs b/src-tauri/src/commands/ai/audit/finalize.rs index 05a913b..5dc0858 100644 --- a/src-tauri/src/commands/ai/audit/finalize.rs +++ b/src-tauri/src/commands/ai/audit/finalize.rs @@ -1,68 +1,12 @@ -//! 工具调用审计记录写/回填(audit_tool_call 写入 + audit_finalize 审批后状态回填) +//! 审批后审计记录状态回填(audit_finalize) //! -//! 第二批从 audit/mod.rs 抽出(行为零变更,纯 fn 搬迁)。re-export 经 audit/mod.rs -//! `pub use finalize::{audit_tool_call, audit_finalize};` 保持 `commands::ai::audit::*` -//! 路径透明(调用方零变更)。 - -use df_ai::ai_tools::RiskLevel; -use df_storage::crud::AiToolExecutionRepo; -use df_storage::models::AiToolExecutionRecord; - -use df_types::types::new_id; +//! `audit_tool_call` 已移至 record.rs(审计记录写入操作归 audit record 子模块)。 +//! re-export 经 audit/mod.rs `pub(crate) use finalize::audit_finalize;` 保持 +//! `commands::ai::audit::*` 路径透明(调用方零变更)。 use crate::commands::now_millis; use crate::state::AppState; -use super::risk_str; - -/// 写一条工具执行审计记录(insert 失败不阻断主流程,故 `let _ =`) -/// -/// `decided_by` 有值(auto/human)= 已决策执行 → 记 executed_at; -/// `None`(pending 待审批)→ executed_at 留空,待 audit_finalize 回填。 -pub(crate) async fn audit_tool_call( - repo: &AiToolExecutionRepo, - conv_id: &str, - tool_call_id: &str, - tool_name: &str, - arguments: &str, - status: &str, - risk_level: RiskLevel, - result: Option, - decided_by: Option<&str>, - message_id: Option<&str>, -) { - let executed_at = if decided_by.is_some() { Some(now_millis()) } else { None }; - if let Err(e) = repo - .insert(AiToolExecutionRecord { - id: new_id(), - conversation_id: Some(conv_id.to_string()), - // F-260619-04 P1 消息级溯源:message_id 由调用方(process_tool_calls)从 - // ContextManager 取当前 assistant 消息 id 传入(LLM 返回带 tool_calls 的 - // assistant 消息已 push 到 per_conv.messages,入口取末条 assistant id)。 - // None 表示无 assistant 消息(异常路径/老数据无 id),展示侧兼容。 - message_id: message_id.map(|s| s.to_string()), - tool_call_id: tool_call_id.to_string(), - tool_name: tool_name.to_string(), - arguments: arguments.to_string(), - result, - status: status.to_string(), - risk_level: risk_str(risk_level).to_string(), - requested_at: now_millis(), - executed_at, - decided_by: decided_by.map(|s| s.to_string()), - }) - .await - { - tracing::error!( - "audit_tool_call: 写审计记录失败(conv={}, tool_call_id={}, tool={}): {}", - conv_id, - tool_call_id, - tool_name, - e - ); - } -} - /// 审批后更新审计记录状态(按 tool_call_id 定位 pending 记录,回填 status/decided_by=human/executed_at/result) /// /// 走专用 find_by_tool_call_id —— 通用 query 宏硬编码 ORDER BY created_at DESC, diff --git a/src-tauri/src/commands/ai/audit/mod.rs b/src-tauri/src/commands/ai/audit/mod.rs index 1d3fb19..88ba5eb 100644 --- a/src-tauri/src/commands/ai/audit/mod.rs +++ b/src-tauri/src/commands/ai/audit/mod.rs @@ -3,259 +3,94 @@ use std::collections::{HashMap, HashSet}; use std::sync::Arc; -use serde::Serialize; -use tauri::{AppHandle, Emitter, Manager, State}; +use tauri::{AppHandle, Emitter, Manager}; 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 装配裸名用。 +// 第五批从本文件抽离,行为零变更。pub(crate) use 保持 finalize / restore 子模块 +// super::risk_str / super::risk_from_str 引用路径不变;truncate_chars 供本文件 process_tool_calls +// DTO 装配 + record.rs 审计面板 IPC 共用。 mod utils; -pub(crate) use utils::{risk_from_str, risk_str}; -use utils::truncate_chars; +pub(crate) use utils::{risk_from_str, risk_str, 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) -// ============================================================ +// path_auth(audit/path_auth.rs):F-260619-03 Phase B/C 路径授权预校验。 +// 第六批从本文件抽离,行为零变更。pub(super) use 供 tests 子模块引用 + process_tool_calls 裸名调用。 +mod path_auth; +pub(super) use path_auth::{check_file_tool_auth, extract_file_tool_paths, FileToolAuthOutcome}; -/// 审批历史 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, -} +// approval(audit/approval.rs):F-#97/AE-04/阶段4 审批门控逻辑。 +// 第六批从本文件抽离,行为零变更。use 保持 process_tool_calls 裸名调用。 +mod approval; +use approval::{detect_retry_count, handle_approval_tool}; -/// 审批历史面板查询:按 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()) -} +// record(audit/record.rs):审计记录写入 + 历史 DTO + 审计面板查询 IPC。 +// 第六批从本文件抽离,行为零变更。pub(crate) use 供本文件 process_tool_calls / +// approval.rs 裸名调用 audit_tool_call;pub use 保持 commands::ai::audit::* 路径透明 +// (list_tool_executions 是 #[tauri::command],ToolExecutionDto 供前端 DTO 序列化)。 +pub mod record; +pub(crate) use record::{audit_tool_call, query_audit_history, record_audit}; +pub use record::{list_tool_executions, ToolExecutionDto}; - -// reason 拼装(resolve_project_label / resolve_task_label / build_approval_reason) -// 拆至子模块 audit/reason.rs(第一批 helper 抽离,行为零变更)。 +// 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; +// build_approval_reason 现由 approval.rs(insert_pending_approval)内部调用, +// 通过 `use super::reason::build_approval_reason` 引入,本文件不再直接使用。 // restore(audit/restore.rs):启动恢复重建 pending_approvals。 -// 第二批从本文件抽离,行为零变更。re-export 保持 commands::ai::audit::* 路径透明。 +// 第二批从本文件抽离,行为零变更。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 内可见。 +// finalize(audit/finalize.rs):audit_finalize 审批后状态回填。 +// audit_tool_call 已移至 record.rs(写入归 record 子模块)。 +// 第二批从本文件抽离,行为零变更。pub(crate) use 供外部 +//(agentic/approval_timeout.rs / commands/chat.rs)裸名调用 audit_finalize。 mod finalize; -pub(crate) use finalize::{audit_finalize, audit_tool_call}; +pub(crate) use finalize::audit_finalize; // 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}; +pub(super) use cache::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 私有。 +// 第四批从本文件抽离,行为零变更。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 审批执行路径调用(单点逻辑,多调用点)。 +// 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。 +/// 工具执行 + 心跳保活: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,不再误杀长工具。 +/// 根治 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, @@ -266,7 +101,7 @@ async fn execute_with_heartbeat( use std::sync::atomic::{AtomicBool, Ordering}; use tokio::time::Duration; - // RAII guard:drop 时 stop+abort 心跳 task,确保 execute panic(unwind)也不泄漏心跳 task。 + // RAII guard:drop 时 stop+abort 心跳 task,确保 execute panic(unwind)也不泄漏心跳 task。 struct HeartbeatGuard { stop: Arc, handle: tokio::task::JoinHandle<()>, @@ -282,8 +117,8 @@ async fn execute_with_heartbeat( 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 确保覆盖)。 + // 心跳 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; @@ -297,7 +132,7 @@ async fn execute_with_heartbeat( let _ = app_h.state::().ai_event_bus.publish_event(ev); } }); - // RAII:execute 无论 Ok/Err/panic(unwind),_guard drop 自动 stop+abort,无心跳 task 泄漏。 + // RAII:execute 无论 Ok/Err/panic(unwind),_guard drop 自动 stop+abort,无心跳 task 泄漏。 let _guard = HeartbeatGuard { stop, handle: heartbeat }; tools.execute(name, args).await } @@ -312,21 +147,21 @@ pub(crate) async fn process_tool_calls( ) -> 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 + // 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,展示侧兼容。 + // 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()); @@ -336,8 +171,8 @@ pub(crate) async fn process_tool_calls( 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* 高频事件不双写原则不适用此处(工具调用生命周期事件属关键状态变更,需透传)。 + // L3 emit 双写:AiToolCallStarted publish 到事件总线(tunnel subscriber 透传 miniapp)。 + // AiTextDelta/AiToolCall* 高频事件不双写原则不适用此处(工具调用生命周期事件属关键状态变更,需透传)。 let ev = AiChatEvent::AiToolCallStarted { id: draft.id.clone(), name: draft.name.clone(), @@ -350,26 +185,26 @@ pub(crate) async fn process_tool_calls( }) .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 移除单独处理。 + // ── 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 循环) + // 收集 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 与此处预校验读同一字段,两端授权判定一致。 + // 会话级临时授权目录在 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)), @@ -378,12 +213,12 @@ pub(crate) async fn process_tool_calls( } let drafts = authorized_drafts; - // Phase C: Denied 路径 → 硬拒(push 错误 tool_result + emit Completed),不挂起 loop。 - // 工具返 Err 让 LLM 知路径被禁,自行调整;loop 继续下一轮(不暂停)。 + // 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 双路发布。 + // L3 emit 双写:路径黑名单拒绝 emit Completed 双路发布。 let ev = AiChatEvent::AiToolCallCompleted { id: draft.id.clone(), result: serde_json::Value::String(err_msg.clone()), @@ -391,7 +226,7 @@ pub(crate) async fn process_tool_calls( }; let _ = app_handle.emit("ai-chat-event", ev.clone()); let _ = app_handle.state::().ai_event_bus.publish_event(ev); - // 审计:路径黑名单拒绝(decided_by=auto_blacklist),留痕可追溯 + // 审计:路径黑名单拒绝(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, @@ -400,30 +235,25 @@ pub(crate) async fn process_tool_calls( ).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 恢复)。 + // 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 用首个作主展示目录(前端弹窗主显)。 + // 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,等价原行为(正常挂起审批)。 + // 阶段4(容错/恢复,开关 df-ai-approval-retry):同 tc_id 重试检测。 let retry_count = detect_retry_count(&audit_repo, &draft.id).await; if retry_count >= 1 { let skip_msg = format!( - "已跳过重试(同 tool_call_id={} 此前已审批执行过,防 LLM 死循环重试同卡死工具)", + "已跳过重试(同 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()), @@ -447,18 +277,15 @@ pub(crate) async fn process_tool_calls( arguments: args.clone(), conversation_id: Some(conv_id.to_string()), recovered: false, - // 阶段3a:路径授权挂起标 kind=Path(req)(下沉原 path_auth 字段)。 + // 阶段3a:路径授权挂起标 kind=Path(req)(下沉原 path_auth 字段)。 kind: ApprovalKind::Path(req), retry_count, created_at: Some(std::time::SystemTime::now()), }, ); - // 占位 tool_result(与 RiskLevel 审批一致),ai_authorize_dir 批准后替换为真实结果。 - // 阶段2:用 pending_placeholder_for 内嵌 __PENDING__:tc_id 标记,供 sanitize 反向 orphan - // 检测豁免保留 + 出口断言自愈补头(防占位头被裁后 orphan result 触发 provider 400)。 + // 占位 tool_result(与 RiskLevel 审批一致),ai_authorize_dir 批准后替换为真实结果。 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 弹窗)。 + tracing::debug!(target: "ai_dirauth", conv = %conv_id, tool = %draft.name, tc_id = %draft.id, "emit AiDirAuthRequired(路径授权挂起,等 ai_authorize_dir 恢复)"); let ev = AiChatEvent::AiDirAuthRequired { id: draft.id.clone(), tool: draft.name.clone(), @@ -482,13 +309,12 @@ pub(crate) async fn process_tool_calls( // 若 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 模式执行了多少高危命令」。 + // 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)。 + // F-#97 自动执行范围三档(2026-06-22):KV df-ai-auto-execute-mode 取值 low/medium/all,默认 low(=现状)。 + // low:仅 Low 自动(等价旧行为);medium:Low+Medium 自动;all:全自动无审批(完全 AI 接管)。 let auto_exec_mode = app_handle .state::() .settings @@ -498,158 +324,16 @@ pub(crate) async fn process_tool_calls( .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 + // (对齐 Low risk 不持锁模式)。命中时此处只 emit toast + 收集,不 .await execute。 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, - created_at: Some(std::time::SystemTime::now()), - }); - // 阶段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; - } + handle_approval_tool( + draft, args, risk_level, &auto_exec_mode, + session, conv_id, tools_arc, &audit_repo, + app_handle, db, &mut low_risk, &mut trust_hits, + &mut pending_count, current_message_id, + ).await; } // AE-04 trust-hit 并行执行:execute + 即时 emit 在闭包内(闭包不访问 session,非"锁已释放"—— @@ -669,7 +353,7 @@ pub(crate) async fn process_tool_calls( match exec_result { Ok(val) => { let content = val.to_string(); - // L3 emit 双写:trust 放行工具执行成功 emit Completed 双路发布。 + // L3 emit 双写:trust 放行工具执行成功 emit Completed 双路发布。 let ev = AiChatEvent::AiToolCallCompleted { id: draft.id.clone(), result: serde_json::Value::String(content.clone()), @@ -678,13 +362,13 @@ pub(crate) async fn process_tool_calls( 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) + // (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 双路发布。 + // L3 emit 双写:trust 放行工具执行失败 emit Completed 双路发布。 let ev = AiChatEvent::AiToolCallCompleted { id: draft.id.clone(), result: serde_json::Value::String(content.clone()), @@ -704,9 +388,9 @@ pub(crate) async fn process_tool_calls( Ok(c) => ("completed", c), Err(c) => ("failed", c), }; - // F-260616-09 B 批2:写 per_conv.messages。 + // 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),留痕可追溯 + // 审计: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; } } @@ -715,7 +399,7 @@ pub(crate) async fn process_tool_calls( // 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)。 + // 携带 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(); @@ -725,7 +409,7 @@ pub(crate) async fn process_tool_calls( let result = execute_with_heartbeat(&tools, &draft.name, args, &app_clone, &conv_clone).await; match result { Ok(val) => { - // L3 emit 双写:Low 风险工具执行成功 emit Completed 双路发布。 + // L3 emit 双写:Low 风险工具执行成功 emit Completed 双路发布。 let ev = AiChatEvent::AiToolCallCompleted { id: draft.id.clone(), result: val.clone(), @@ -745,7 +429,7 @@ pub(crate) async fn process_tool_calls( // 现改为正常 emit AiToolCallCompleted(result=错误信息),错误包进 tool_result // 让 LLM 看到工具失败自行决定下一步,loop 正常续,前端状态一致。 let err_msg = format!("工具 {} 执行失败: {}", draft.name, e); - // L3 emit 双写:Low 风险工具执行失败 emit Completed 双路发布。 + // L3 emit 双写:Low 风险工具执行失败 emit Completed 双路发布。 let ev = AiChatEvent::AiToolCallCompleted { id: draft.id.clone(), result: serde_json::Value::String(err_msg.clone()), @@ -765,18 +449,18 @@ pub(crate) async fn process_tool_calls( 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 原样)。 + // 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。 + // 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 失真,等于没记)。 + // 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", @@ -786,13 +470,13 @@ pub(crate) async fn process_tool_calls( } } - // 阶段3a:单表 pending_approvals 统计 pending 总数(path/risk 合一,kind 区分)。 + // 阶段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 返回(路径/风险挂起分布)"); + tracing::debug!(target: "ai_dirauth", pending_count, pending_approvals_len = session.pending_approvals.len(), path_count, risk_count, "process_tool_calls 返回(路径/风险挂起分布)"); pending_count } @@ -800,52 +484,52 @@ pub(crate) async fn process_tool_calls( mod tests { use super::*; - /// grep 走单路径授权申请路径(NeedsAuth),非 search_files 盲拒(Denied)。 + /// grep 走单路径授权申请路径(NeedsAuth),非 search_files 盲拒(Denied)。 /// - /// F-260621:grep 加入 extract_file_tool_paths 单路径分支,未授权路径触发 - /// AiDirAuthRequired 申请(对齐 read_file),不像 search_files 被硬拒。 - /// 锁定此差异:grep 与 read_file 同款授权弹窗语义。 + /// 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 + // 空白名单(不含 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 盲拒)"), + FileToolAuthOutcome::NeedsAuth(_) => { /* 期望:grep 触发授权申请 */ } + FileToolAuthOutcome::Authorized => panic!("grep 未授权应返 NeedsAuth(申请授权),实际 Authorized"), + FileToolAuthOutcome::Denied(_) => panic!("grep 未授权应返 NeedsAuth(申请授权),实际 Denied(grep 不应像 search_files 盲拒)"), } - // 对照:search_files 同样未授权但被硬拒(Denied),锁定两工具差异 + // 对照: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"), + FileToolAuthOutcome::Denied(_) => { /* 期望:search_files 盲拒 */ } + FileToolAuthOutcome::NeedsAuth(_) => panic!("search_files 未授权应返 Denied(盲拒),实际 NeedsAuth"), + FileToolAuthOutcome::Authorized => panic!("search_files 未授权应返 Denied(盲拒),实际 Authorized"), } } - /// grep 黑名单路径(.ssh 等)直接 Denied(不申请授权),对齐 read_file。 + /// 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) + // .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"), + FileToolAuthOutcome::Denied(_) => { /* 期望:黑名单硬拒 */ } + FileToolAuthOutcome::NeedsAuth(_) => panic!("grep 黑名单路径应返 Denied,实际 NeedsAuth"), + FileToolAuthOutcome::Authorized => panic!("grep 黑名单路径应返 Denied,实际 Authorized"), } } - /// extract_file_tool_paths:grep 取 args["path"](单路径分支) + /// 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 等其他参数)"); + assert_eq!(paths, vec!["src/main.rs".to_string()], "grep 应取 path 参数(忽略 glob 等其他参数)"); } } diff --git a/src-tauri/src/commands/ai/audit/path_auth.rs b/src-tauri/src/commands/ai/audit/path_auth.rs new file mode 100644 index 0000000..d383e52 --- /dev/null +++ b/src-tauri/src/commands/ai/audit/path_auth.rs @@ -0,0 +1,94 @@ +//! F-260619-03 Phase B/C: 路径授权预校验(文件工具路径提取 + 白/黑名单判定)。 +//! +//! 第六批从 audit/mod.rs 抽离,行为零变更。包含: +//! - `extract_file_tool_paths`:从工具参数中提取文件路径 +//! - `FileToolAuthOutcome`:授权判定结果枚举 +//! - `check_file_tool_auth`:对单条文件工具调用做路径授权预校验 +//! +//! process_tool_calls 通过 `use path_auth::check_file_tool_auth` 裸名调用。 +//! 测试模块通过 `super::check_file_tool_auth` 引用。 + +use super::PathAuthRequest; + +/// 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 靠人工审批) +pub(crate) 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: 路径授权预校验结果。 +pub(crate) enum FileToolAuthOutcome { + /// 全部已授权 → 走原 Low/Med/High 流程 + Authorized, + /// 未授权 → 附带 PathAuthRequest,供挂起弹窗 + NeedsAuth(PathAuthRequest), + /// 命中黑名单 → 硬拒 + Denied(String), +} + +/// F-260619-03 Phase B/C: 对单条文件工具调用做路径授权预校验,返回是否需挂起/拒绝/放行。 +/// +/// - 路径任一命中黑名单 → `Denied(reason)`:硬拒(工具返 Err tool_result,不挂起) +/// - 路径任一未命中白名单(persistent + session_dirs)且非黑名单 → `NeedsAuth`: +/// 附带 PathAuthRequest(规范化父目录 + 原始路径列表),供挂起弹窗 +/// - 全部已授权 → `Authorized`:走原 Low/Med/High 流程 +pub(crate) 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 场景。) + // 其他文件工具保持原 NeedsAuth 弹窗逻辑完全不变。 + FileToolAuthOutcome::Denied( + "search_files 仅在已授权目录可用;请用 @[项目] 引用或提供绝对路径,不要盲搜文件系统。".to_string(), + ) + } else { + FileToolAuthOutcome::NeedsAuth(PathAuthRequest { dirs: pending_dirs, raw_paths: paths }) + } +} diff --git a/src-tauri/src/commands/ai/audit/record.rs b/src-tauri/src/commands/ai/audit/record.rs new file mode 100644 index 0000000..00849d5 --- /dev/null +++ b/src-tauri/src/commands/ai/audit/record.rs @@ -0,0 +1,162 @@ +//! 审计记录写/查操作 + 审批历史 DTO + 审计面板查询 IPC。 +//! +//! 第六批从 audit/mod.rs 抽离,行为零变更。包含: +//! - `audit_tool_call`:写一条工具执行审计记录(insert) +//! - `record_audit`:`audit_tool_call` 的别名,语义更清晰("记一条审计") +//! - `query_audit_history`:按条件查询审计历史记录 +//! - `ToolExecutionDto`:传给前端的精简审计视图 +//! - `list_tool_executions`:审批历史面板查询 IPC +//! +//! 依赖 audit/utils.rs 的 `truncate_chars` 做参数/结果截断,通过 `super::truncate_chars` 引用。 + +use serde::Serialize; +use tauri::State; + +use df_ai::ai_tools::RiskLevel; +use df_storage::crud::AiToolExecutionRepo; +use df_storage::models::AiToolExecutionRecord; +use df_types::types::new_id; + +use crate::commands::err_str; +use crate::commands::now_millis; +use crate::state::AppState; + +use super::risk_str; + +/// 写一条工具执行审计记录(insert 失败不阻断主流程,故 `let _ =`) +/// +/// `decided_by` 有值(auto/human)= 已决策执行 → 记 executed_at; +/// `None`(pending 待审批)→ executed_at 留空,待 audit_finalize 回填。 +pub(crate) async fn audit_tool_call( + repo: &AiToolExecutionRepo, + conv_id: &str, + tool_call_id: &str, + tool_name: &str, + arguments: &str, + status: &str, + risk_level: RiskLevel, + result: Option, + decided_by: Option<&str>, + message_id: Option<&str>, +) { + let executed_at = if decided_by.is_some() { Some(now_millis()) } else { None }; + if let Err(e) = repo + .insert(AiToolExecutionRecord { + id: new_id(), + conversation_id: Some(conv_id.to_string()), + // F-260619-04 P1 消息级溯源:message_id 由调用方(process_tool_calls)从 + // ContextManager 取当前 assistant 消息 id 传入(LLM 返回带 tool_calls 的 + // assistant 消息已 push 到 per_conv.messages,入口取末条 assistant id)。 + // None 表示无 assistant 消息(异常路径/老数据无 id),展示侧兼容。 + message_id: message_id.map(|s| s.to_string()), + tool_call_id: tool_call_id.to_string(), + tool_name: tool_name.to_string(), + arguments: arguments.to_string(), + result, + status: status.to_string(), + risk_level: risk_str(risk_level).to_string(), + requested_at: now_millis(), + executed_at, + decided_by: decided_by.map(|s| s.to_string()), + }) + .await + { + tracing::error!( + "audit_tool_call: 写审计记录失败(conv={}, tool_call_id={}, tool={}): {}", + conv_id, + tool_call_id, + tool_name, + e + ); + } +} + +/// `audit_tool_call` 的语义别名,功能完全相同。 +/// 命名更符合"记录一条审计"的调用意图,供新代码使用。 +pub(crate) async fn record_audit( + repo: &AiToolExecutionRepo, + conv_id: &str, + tool_call_id: &str, + tool_name: &str, + arguments: &str, + status: &str, + risk_level: RiskLevel, + result: Option, + decided_by: Option<&str>, + message_id: Option<&str>, +) { + audit_tool_call(repo, conv_id, tool_call_id, tool_name, arguments, status, risk_level, result, decided_by, message_id).await; +} + +/// 查询审计历史记录(分页,按 requested_at 倒序)。 +/// +/// 封装 `list_recent` 添加一层可读语义,方便未来扩展筛选条件。 +pub(crate) async fn query_audit_history( + repo: &AiToolExecutionRepo, + limit: u32, + offset: u32, +) -> Result, String> { + let limit = limit.min(200); + repo.list_recent(limit, offset) + .await + .map_err(|e| format!("query_audit_history 查询失败: {}", e)) +} + +/// 审批历史 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: super::truncate_chars(&r.arguments, 120), + result_brief: r.result.map(|s| super::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()) +} diff --git a/src-tauri/src/commands/ai/audit/utils.rs b/src-tauri/src/commands/ai/audit/utils.rs index 8119558..48ce22e 100644 --- a/src-tauri/src/commands/ai/audit/utils.rs +++ b/src-tauri/src/commands/ai/audit/utils.rs @@ -2,10 +2,10 @@ //! //! 第五批从 audit/mod.rs 抽离,行为零变更: //! - `risk_str` / `risk_from_str`:finalize / restore 通过 `super::` 引用(pub(crate))。 -//! - `truncate_chars`:仅 mod.rs 的 list_tool_executions DTO 装配用(pub(super))。 +//! - `truncate_chars`:mod.rs 的 process_tool_calls DTO 装配用 + record.rs 审计面板 IPC 用(pub(crate))。 //! //! re-export 维持原路径透明:`super::risk_str` / `super::risk_from_str` / `super::truncate_chars` -//! 在 audit/mod.rs 内通过 `use` 引入后裸名可用,外部子模块的 `super::risk_*` 引用不变。 +//! 在 audit/mod.rs 内通过 `pub(crate) use` 引入后裸名可用,外部子模块的 `super::risk_*` 引用不变。 use df_ai::ai_tools::RiskLevel; @@ -18,7 +18,7 @@ pub(crate) fn risk_str(r: RiskLevel) -> &'static str { } } -/// 审计记录字符串 → RiskLevel(启动重建 pending_approvals 用,未知串返回 None 跳过) +/// 审计记录字符串 → RiskLevel(启动重建 pending_approvals 用,未知串返回 None 跳过) pub(crate) fn risk_from_str(s: &str) -> Option { match s { "low" => Some(RiskLevel::Low), @@ -28,8 +28,8 @@ pub(crate) fn risk_from_str(s: &str) -> Option { } } -/// 截断字符串到 max 字符(按 char_indices 边界切,避免切坏中文/emoji) -pub(super) fn truncate_chars(s: &str, max: usize) -> String { +/// 截断字符串到 max 字符(按 char_indices 边界切,避免切坏中文/emoji) +pub(crate) fn truncate_chars(s: &str, max: usize) -> String { if s.chars().count() <= max { return s.to_string(); } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b5f75f9..9f1b723 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -404,7 +404,7 @@ pub fn run() { // F-260619-03 Phase B: 路径授权弹窗决策(消费 AiDirAuthRequired 挂起) commands::ai::ai_authorize_dir, // 审批历史面板(AE-2025-08:查 ai_tool_executions 表,敏感字段截断) - commands::ai::audit::list_tool_executions, + commands::ai::audit::record::list_tool_executions, // 知识库 commands::knowledge::knowledge_list, commands::knowledge::knowledge_get,