357 lines
17 KiB
Rust
357 lines
17 KiB
Rust
//! 审批门控逻辑。
|
||
//!
|
||
//! 审批策略(方案 B:Persona 维度 + 扩展点):
|
||
//! - reviewer/analyst 人设的工具调用全 auto(只读/分析,审批无意义)
|
||
//! - 其他人设走现有 auto_exec_mode 三档
|
||
//! - Phase 2 扩展点:可在 should_auto_for_persona 内加工具维度 if 分支
|
||
|
||
use std::sync::Arc;
|
||
|
||
use tauri::{AppHandle, Emitter, Manager};
|
||
use tokio::sync::Mutex;
|
||
|
||
use df_ai::ai_tools::{AiToolRegistry, RiskLevel};
|
||
use df_ai::provider::ChatMessage;
|
||
use df_storage::crud::AiToolExecutionRepo;
|
||
use df_storage::db::Database;
|
||
|
||
use super::cache::{find_cached_high_risk_result, pending_placeholder_for};
|
||
use super::diff::build_write_file_diff;
|
||
use super::reason::build_approval_reason;
|
||
use super::record::audit_tool_call;
|
||
use super::super::{AiChatEvent, AiSession, ApprovalKind, PendingApproval, ToolCallDraft, is_destructive_command, trust_key_for, TrustKey};
|
||
|
||
/// 按 risk_level + auto_exec_mode 判定是否自动执行。
|
||
///
|
||
/// F-#97 自动执行范围三档(2026-06-22):low/medium/all,默认 low。
|
||
/// - low:仅 Low 自动(等价旧行为)
|
||
/// - medium:Low+Medium 自动
|
||
/// - all:全自动无审批(完全 AI 接管)
|
||
///
|
||
/// C-260627:patch_file 小改动(≤5 行)特例自动放行,不阻塞 AI 工作流。
|
||
pub(super) fn classify_risk_and_auto(
|
||
risk_level: RiskLevel,
|
||
auto_exec_mode: &str,
|
||
tool_name: &str,
|
||
args: &serde_json::Value,
|
||
) -> bool {
|
||
let _ = args; // 预留扩展点(未来可加路径白名单/文件类型等维度)
|
||
// patch_file 是 AI 编码主力工具,改文件是 AI Coding 的工作常态。
|
||
// 原本“小改动(≤5 行)豁免、大改动审批”的设计在实际使用中频繁打断 AI 工作流
|
||
// (AI 改文件往往远超 5 行),且文件已写入会落 audit 表可追溯,审批价值低。
|
||
// 决策:patch_file 全档位自动放行(low/medium/all 均不审批),与 read_file 同级处理。
|
||
if tool_name == "patch_file" {
|
||
return true;
|
||
}
|
||
match risk_level {
|
||
RiskLevel::Low => true,
|
||
RiskLevel::Medium => auto_exec_mode == "medium" || auto_exec_mode == "all",
|
||
RiskLevel::High => auto_exec_mode == "all",
|
||
}
|
||
}
|
||
|
||
/// 审批策略方案 B:Persona 维度判定。
|
||
///
|
||
/// 在 classify_risk_and_auto 之上叠加人设维度:
|
||
/// - reviewer/analyst 人设:工具白名单已限制为只读,全部 auto(审批纯浪费)
|
||
/// - 其他人设:走 classify_risk_and_auto 原逻辑
|
||
///
|
||
/// Phase 2 扩展点:在此函数内加工具维度 if 分支,例如:
|
||
/// ```ignore
|
||
/// if persona_id == "coder" && tool_name == "write_file" {
|
||
/// if let Some(path) = args.get("path").and_then(|v| v.as_str()) {
|
||
/// if path.ends_with(".md") { return true; } // 写文档 auto
|
||
/// }
|
||
/// }
|
||
/// ```
|
||
#[allow(dead_code)] // 接线到 process_tool_calls 需要 SubTask 携带 persona_id,后续批次接入
|
||
pub(super) fn should_auto_for_persona(
|
||
persona_id: Option<&str>,
|
||
risk_level: RiskLevel,
|
||
auto_exec_mode: &str,
|
||
tool_name: &str,
|
||
args: &serde_json::Value,
|
||
) -> bool {
|
||
// 人设维度:reviewer/analyst 全 auto
|
||
match persona_id {
|
||
Some("reviewer") | Some("analyst") => {
|
||
tracing::debug!(
|
||
persona = ?persona_id,
|
||
tool = %tool_name,
|
||
"[POLICY-B] 只读人设全 auto"
|
||
);
|
||
return true;
|
||
}
|
||
_ => {}
|
||
}
|
||
// 兜底:现有三档 + patch_file 特例
|
||
classify_risk_and_auto(risk_level, auto_exec_mode, tool_name, args)
|
||
}
|
||
|
||
/// 检查会话信任命中。
|
||
///
|
||
/// 首批信任工具:write_file / run_command。同会话已批准过同工具+同目录 →
|
||
/// `TrustKey` 命中,返回 `Some(TrustKey)`;否则返回 `None`(走原审批流程)。
|
||
///
|
||
/// 签名改 `session_arc: &Arc<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 的 IPC,CR-51 修此)
|
||
trust_hits.push((draft, args, dir_label, risk_level));
|
||
return;
|
||
}
|
||
|
||
// ── Step 3: F-05 高危去重缓存(仅 High) ──
|
||
if matches!(risk_level, RiskLevel::High) {
|
||
// find_cached_high_risk_result 内部短 lock 读 messages + 锁外 await DB 查 status,
|
||
// 返回 (cached_content, status) 时不持锁
|
||
if let Some((cached, status)) = find_cached_high_risk_result(session_arc, conv_id, audit_repo, &draft.name, &args).await {
|
||
// 命中:把缓存结果作为新 tool_call_id 的 tool_result 回传,跳过审批
|
||
tracing::info!(
|
||
tool = %draft.name,
|
||
new_tool_call_id = %draft.id,
|
||
"[F-05] 高危工具去重命中:LLM 重试同命令,复用缓存结果跳过审批(断循环)"
|
||
);
|
||
// 短 lock 段:push tool_result(纯写,无 await)
|
||
{
|
||
let mut session = session_arc.lock().await;
|
||
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &cached));
|
||
}
|
||
// L3 emit 双写:高危去重命中复用缓存 emit Completed 双路发布。
|
||
let ev = AiChatEvent::AiToolCallCompleted {
|
||
id: draft.id.clone(),
|
||
result: serde_json::Value::String(cached.clone()),
|
||
conversation_id: Some(conv_id.to_string()),
|
||
};
|
||
let _ = app_handle.emit("ai-chat-event", ev.clone());
|
||
let _ = app_handle.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
|
||
// 审计:去重命中记一条(status 透传缓存来源 completed/rejected/failed,SW-260618-16;decided_by=auto_dedup),不进 pending
|
||
audit_tool_call(audit_repo, conv_id, &draft.id, &draft.name, &draft.args, &status, risk_level, Some(cached), Some("auto_dedup"), current_message_id).await;
|
||
return;
|
||
}
|
||
}
|
||
|
||
// ── Step 4: 插入 pending 审批 ──
|
||
*pending_count += 1;
|
||
insert_pending_approval(
|
||
draft, args, risk_level, session_arc, conv_id, audit_repo, app_handle, db,
|
||
current_message_id,
|
||
).await;
|
||
}
|