Files
DevFlow/src-tauri/src/commands/ai/audit/approval.rs
T

357 lines
17 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 审批门控逻辑。
//!
//! 审批策略(方案 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 自动(等价旧行为)
/// - mediumLow+Medium 自动
/// - all:全自动无审批(完全 AI 接管)
///
/// C-260627patch_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<Mutex<AiSession>>`,内部短 lock 读
/// `session_trust` 后立即 drop,信任查询不持锁,与 process_tool_calls 持锁 await 反模式解耦。
pub(super) async fn check_trust_hits(
draft: &ToolCallDraft,
args: &serde_json::Value,
session_arc: &Arc<Mutex<AiSession>>,
conv_id: &str,
) -> Option<TrustKey> {
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<Mutex<AiSession>>`,所有慢操作
/// (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<Mutex<AiSession>>,
conv_id: &str,
audit_repo: &AiToolExecutionRepo,
app_handle: &AppHandle,
db: &Arc<Database>,
current_message_id: Option<&str>,
) {
// (路径 B):write_file 挂起审批前预读旧文件生成 diff。
// 仅 write_file(覆盖整文件,有完整新旧内容可对比);其他工具 diff=None。
// 旧文件不存在(新建)→ diff=None,前端回退显新 content。
// 读失败不阻断审批(容错:文件无读权限等极端情况降级为无 diff 预览)。
let approval_diff: Option<String> = 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::<crate::state::AppState>().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::<crate::state::AppState>().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<Mutex<AiSession>>`,所有 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<Mutex<AiSession>>,
conv_id: &str,
_tools_arc: &Arc<AiToolRegistry>,
audit_repo: &AiToolExecutionRepo,
app_handle: &AppHandle,
db: &Arc<Database>,
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::<crate::state::AppState>().ai_event_bus.publish_event(ev);
// 收集后循环外 spawn 执行,对齐 Low risk 不持锁(原注释 L593 声称"锁外"但代码持锁,
// run_command 慢命令会阻塞同会话所有触 state.ai_session 的 IPCCR-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::<crate::state::AppState>().ai_event_bus.publish_event(ev);
// 审计:去重命中记一条(status 透传缓存来源 completed/rejected/failedSW-260618-16decided_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;
}