Files
DevFlow/src-tauri/src/commands/ai/audit/mod.rs
绝尘 f3a287bbd4 重构: audit第四批data_change收尾(strategy)
- 新建 audit/data_change.rs(50行 data_change_for_tool/emit_data_changed), audit/mod.rs 451→416(-35)
- pub(crate) re-export emit_data_changed(chat.rs super::super::audit + process_tool_calls裸名路径透明)
- F-09 per_conv保留(本批未触session/conv_id逻辑)
主代grep核验(不cargo避openai-split df-ai中间态): mod data_change/pub(crate)use + data_change.rs fn印证; agent隔离自验cargo 0+test 98
strategy: 单批1-2文件原子; audit/mod.rs余416(process_tool_calls主逻辑+build_write_file_diff/risk/truncate helper); audit四批拆分接近完成
git add指定(audit/*)
2026-06-19 04:35:41 +08:00

417 lines
22 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.
//! 工具调用审计 + pending 审批恢复 + 工具调用处理
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use serde::Serialize;
use tauri::{AppHandle, Emitter, State};
use df_ai::ai_tools::{AiToolRegistry, RiskLevel};
use df_ai::provider::ChatMessage;
use df_storage::crud::AiToolExecutionRepo;
use df_storage::db::Database;
use crate::state::AppState;
use crate::commands::err_str;
use super::{AiChatEvent, AiSession, PendingApproval, ToolCallDraft};
use super::tool_registry::generate_diff;
/// RiskLevel → 审计记录字符串low/medium/high
pub(crate) fn risk_str(r: RiskLevel) -> &'static str {
match r {
RiskLevel::Low => "low",
RiskLevel::Medium => "medium",
RiskLevel::High => "high",
}
}
/// 审计记录字符串 → RiskLevel启动重建 pending_approvals 用,未知串返回 None 跳过)
pub(crate) fn risk_from_str(s: &str) -> Option<RiskLevel> {
match s {
"low" => Some(RiskLevel::Low),
"medium" => Some(RiskLevel::Medium),
"high" => Some(RiskLevel::High),
_ => None,
}
}
// ============================================================
// 审批历史查询 IPCAE-2025-08
// ============================================================
/// 审批历史 DTO(传给前端的精简视图,敏感字段截断防泄露)
///
/// arguments/result 在落库时是完整 JSON(可能含项目名/路径/长结果),审计面板只展示摘要,
/// 故截断到固定长度(参数 120 / 结果 160),既保留可读性又不泄露全量数据到前端 DOM。
#[derive(Debug, Clone, Serialize)]
pub struct ToolExecutionDto {
pub id: String,
pub conversation_id: Option<String>,
pub tool_call_id: String,
pub tool_name: String,
/// 参数摘要(截断 120 字符,完整原值仍留库)
pub arguments_brief: String,
/// 结果摘要(截断 160 字符,None → 空串便于前端展示)
pub result_brief: Option<String>,
/// pending/approved/rejected/executing/completed/failed
pub status: String,
/// low/medium/high
pub risk_level: String,
pub requested_at: String,
pub executed_at: Option<String>,
/// human/auto,None 表示尚未决策
pub decided_by: Option<String>,
}
/// 截断字符串到 max 字符(按 char_indices 边界切,避免切坏中文/emoji)
fn truncate_chars(s: &str, max: usize) -> String {
if s.chars().count() <= max {
return s.to_string();
}
let mut out: String = s.chars().take(max).collect();
out.push('…');
out
}
/// 审批历史面板查询:按 requested_at 倒序(最新在前)分页返回工具调用审计记录。
///
/// 默认 limit=50 / offset=0(第一页)。limit 在 storage 层钳制 ≤200 防滥用。
/// 敏感字段(arguments/result)截断成摘要返回,完整原值仍留库。
#[tauri::command]
pub async fn list_tool_executions(
state: State<'_, AppState>,
limit: Option<u32>,
offset: Option<u32>,
) -> Result<Vec<ToolExecutionDto>, String> {
let limit = limit.unwrap_or(50);
let offset = offset.unwrap_or(0);
let records = state
.ai_tool_executions
.list_recent(limit, offset)
.await
.map_err(err_str)?;
Ok(records
.into_iter()
.map(|r| ToolExecutionDto {
id: r.id,
conversation_id: r.conversation_id,
tool_call_id: r.tool_call_id,
tool_name: r.tool_name,
arguments_brief: truncate_chars(&r.arguments, 120),
result_brief: r.result.map(|s| truncate_chars(&s, 160)),
status: r.status,
risk_level: r.risk_level,
requested_at: r.requested_at,
executed_at: r.executed_at,
decided_by: r.decided_by,
})
.collect())
}
// reason 拼装(resolve_project_label / resolve_task_label / build_approval_reason)
// 拆至子模块 audit/reason.rs(第一批 helper 抽离,行为零变更)。
mod reason;
// process_tool_calls 调用 build_approval_reason,从子模块引入(super 可见,fn 为 pub(super))。
use reason::build_approval_reason;
// restore(audit/restore.rs):启动恢复重建 pending_approvals。
// 第二批从本文件抽离,行为零变更。re-export 保持 commands::ai::audit::* 路径透明。
mod restore;
pub use restore::restore_pending_approvals;
// finalize(audit/finalize.rs):audit_tool_call 写入 + audit_finalize 审批后状态回填。
// 第二批从本文件抽离,行为零变更。pub(crate) use 供本文件 process_tool_calls /
// find_cached_high_risk_result 裸名调用,且保持 commands::ai::audit::* 对 crate 内可见。
mod finalize;
pub(crate) use finalize::{audit_finalize, audit_tool_call};
// cache(audit/cache.rs):F-260616-05 高危工具去重缓存。
// 第三批从本文件抽离,行为零变更。pub(super) use 供本文件 process_tool_calls 裸名调用
// (find_cached_high_risk_result),PENDING_APPROVAL_PLACEHOLDER 供 process_tool_calls 占位回填共享。
mod cache;
pub(super) use cache::{find_cached_high_risk_result, PENDING_APPROVAL_PLACEHOLDER};
// data_change(audit/data_change.rs):AR-11 数据变更联动刷新。
// 第四批从本文件抽离,行为零变更。pub(crate) use 保持 emit_data_changed 对 crate 内可见
// (chat.rs 通过 commands::ai::audit::emit_data_changed 引入并调用),data_change_for_tool 私有。
mod data_change;
pub(crate) use data_change::emit_data_changed;
/// AE-2025-03路径 Bwrite_file 审批预览 diff 生成。
///
/// 从 write_file args 取 path旧文件路径+ content新内容
/// 预读旧文件 → 复用 `generate_diff`F-260615-10 LCS 行级 diff注入审批事件。
/// 旧文件不存在(新建)/ 读失败 / args 缺字段 → None前端回退显新 content
///
/// **仅读不改**审批未通过前不动文件读路径不校验write_file handler 自身会校验
/// validate_path审批拒绝则 handler 不执行,恶意路径读失败仅得到 None 不产生危害)。
async fn build_write_file_diff(args: &serde_json::Value) -> Option<String> {
let path = args.get("path")?.as_str()?;
let new_content = args.get("content")?.as_str()?;
match tokio::fs::read_to_string(path).await {
Ok(old_content) => {
if old_content == new_content {
return None; // 无变化不生成 diff理论上 write_file 不会如此,容错)
}
Some(generate_diff(&old_content, new_content))
}
Err(_) => None, // 文件不存在(新建)/ 无读权限 → 无 diff前端显新 content
}
}
/// 处理流式接收的工具调用Low 风险并行执行join_allMed/High 进审批门控
/// 返回待审批的工具数量0 = 全部自动执行完成)
pub(crate) async fn process_tool_calls(
session: &mut AiSession,
tool_calls_acc: HashMap<u32, ToolCallDraft>,
tools_arc: &Arc<AiToolRegistry>,
db: &Arc<Database>,
app_handle: &AppHandle,
conv_id: &str,
) -> usize {
let mut tc_list: Vec<_> = tool_calls_acc.into_iter().collect();
tc_list.sort_unstable_by_key(|(i, _)| *i);
// B-260616-21 治本兜底:LLM 异常复用同 tool_use.id(stream_recv 按 content_block index 分桶,
// 同 id 不同 index draft 可并存 → 每 draft emit AiToolCallStarted 致同 id emit 两次 → 前端 push 两卡,
// Completed 按 id 只 update 首张 → 次张残留 running 0行)。process 层按 id 去重——同 id 保留
// 最小 index 的首个,丢弃后续,保证 emit Started 的 id 唯一。前端 useAiEvents.ts:205 findToolCall
// 守卫双保险。详 docs/02-架构设计/B-260616-21排查方案-2026-06-16.md。
let mut seen_ids: HashSet<String> = HashSet::new();
tc_list.retain(|(_, draft)| seen_ids.insert(draft.id.clone()));
let mut pending_count = 0usize;
let audit_repo = AiToolExecutionRepo::new(db);
// 解析 args + 批量发 Started前端骨架按原始 index 顺序展示)
let drafts: Vec<(u32, ToolCallDraft, serde_json::Value)> = tc_list.into_iter()
.map(|(idx, draft)| {
let args = serde_json::from_str(&draft.args).unwrap_or(serde_json::Value::Object(Default::default()));
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiToolCallStarted {
id: draft.id.clone(),
name: draft.name.clone(),
args: args.clone(),
conversation_id: Some(conv_id.to_string()),
});
(idx, draft, args)
})
.collect();
// 分类Low 收集并行执行Med/High 立即进审批门控push 占位 tool_result
//
// F-260616-05High risk 在进审批门前先查去重缓存find_cached_high_risk_result
// 若 LLM 重试同命令(同 tool_name + 同 args键序无关命中已落定的旧 tool_result
// 把缓存结果作为新 tool_call_id 的 tool_result 回传 LLM跳过 insert pending + 跳过审批,
// 断「超时→重试→重新审批」循环。Med 不去重去重易误伤Low 无审批本就不进此分支。
let mut low_risk: Vec<(ToolCallDraft, serde_json::Value)> = Vec::new();
// trust_hits 收集AE-04 会话信任命中(同会话已批准同类操作),真实执行但移到锁外 spawn
// (对齐 Low risk L673-720 不持锁模式)。命中时此处只 emit toast + 收集,不 .await execute。
// 元组携带 risk_level: 回填审计需原等级(Med/High 才进此分支),避免闭包内再查 registry
let mut trust_hits: Vec<(ToolCallDraft, serde_json::Value, String, RiskLevel)> = Vec::new();
for (_, draft, args) in drafts {
let risk_level = tools_arc.get(&draft.name).map(|t| t.risk_level).unwrap_or(RiskLevel::High);
match risk_level {
RiskLevel::Low => low_risk.push((draft, args)),
RiskLevel::Medium | RiskLevel::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 不阻塞
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiToolAutoApproved {
id: draft.id.clone(),
tool: draft.name.clone(),
dir: dir_label.clone(),
conversation_id: Some(conv_id.to_string()),
});
// 收集后循环外 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));
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiToolCallCompleted {
id: draft.id.clone(),
result: serde_json::Value::String(cached.clone()),
conversation_id: Some(conv_id.to_string()),
});
// 审计:去重命中记一条(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")).await;
continue;
}
}
pending_count += 1;
// AE-2025-03路径 Bwrite_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
};
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,
diff: approval_diff.clone(),
});
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, PENDING_APPROVAL_PLACEHOLDER));
let reason = build_approval_reason(&draft.name, &args, risk_level, db).await;
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiApprovalRequired {
id: draft.id.clone(),
name: draft.name.clone(),
args: args.clone(),
reason,
diff: approval_diff,
conversation_id: Some(conv_id.to_string()),
});
audit_tool_call(&audit_repo, conv_id, &draft.id, &draft.name, &draft.args, "pending", risk_level, None, None).await;
}
}
}
// AE-04 trust-hit 并行执行execute + 即时 emit 在闭包内(闭包不访问 session非"锁已释放"——
// session 锁仍由调用方 agentic.rs 持有至 process_tool_calls 返回CR-53 审查纠正原"移锁外"误述),
// push tool_result / audit 在 join_all 后串行回填(持锁)。对齐 Low risk 并行模式。
// CR-51 修:原 inline .await execute 串行执行每个工具阻塞期间锁被持有run_command 慢命令
// 阻塞同会话 IPC改 join_all 并行多工具减少总阻塞时间(锁持有时长不变,并行化降阻塞)。
// join_all 保序——结果顺序 = trust_hits 输入顺序 = tc_list 原始 index 顺序,不额外 sort
if !trust_hits.is_empty() {
let results: Vec<(ToolCallDraft, RiskLevel, Result<String, String>)> =
futures::future::join_all(trust_hits.into_iter().map(|(draft, args, _dir_label, risk_level)| {
let tools = tools_arc.clone();
let app_clone = app_handle.clone();
let conv_clone = conv_id.to_string();
async move {
let exec_result = tools.execute(&draft.name, args).await;
match exec_result {
Ok(val) => {
let content = val.to_string();
let _ = app_clone.emit("ai-chat-event", AiChatEvent::AiToolCallCompleted {
id: draft.id.clone(),
result: serde_json::Value::String(content.clone()),
conversation_id: Some(conv_clone),
});
// AR-11数据变更类工具执行成功后 emit df-data-changed 联动刷新
// (write_file/run_command 不在 data_change_for_tool 映射内,emit_data_changed 内部 None 即 noop)
emit_data_changed(&app_clone, &draft.name);
(draft, risk_level, Ok(content))
}
Err(e) => {
let content = e.to_string();
let _ = app_clone.emit("ai-chat-event", AiChatEvent::AiToolCallCompleted {
id: draft.id.clone(),
result: serde_json::Value::String(content.clone()),
conversation_id: Some(conv_clone),
});
(draft, risk_level, Err(content))
}
}
}
})).await;
// 串行回填 tool_result + 审计(持 session 锁)
for (draft, risk_level, outcome) in results {
let (status, content) = match outcome {
Ok(c) => ("completed", c),
Err(c) => ("failed", c),
};
// F-260616-09 B 批2:写 per_conv.messages。
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, content.clone()));
// 审计:trust 放行仍记一条(decided_by=auto_trust),留痕可追溯
audit_tool_call(&audit_repo, conv_id, &draft.id, &draft.name, &draft.args, status, risk_level, Some(content), Some("auto_trust")).await;
}
}
// Low 风险并行执行execute + 即时 emit 在闭包内(不持 session 锁),
// push tool_result / audit 在 join_all 后串行回填(持锁,与 Med/High 占位拼接)。
// join_all 保序——结果顺序 = low_risk 输入顺序 = tc_list 原始 index 顺序,不额外 sort
if !low_risk.is_empty() {
let results: Vec<(ToolCallDraft, Result<String, String>)> =
futures::future::join_all(low_risk.into_iter().map(|(draft, args)| {
let tools = tools_arc.clone();
let app_clone = app_handle.clone();
let conv_clone = conv_id.to_string();
async move {
let result = tools.execute(&draft.name, args).await;
match result {
Ok(val) => {
let _ = app_clone.emit("ai-chat-event", AiChatEvent::AiToolCallCompleted {
id: draft.id.clone(),
result: val.clone(),
conversation_id: Some(conv_clone),
});
// AR-11方案A数据变更工具执行成功后 emit df-data-changed,
// 前端 store listen 刷新列表(仅命中映射的工具 emit见 emit_data_changed
emit_data_changed(&app_clone, &draft.name);
(draft, Ok(val.to_string()))
}
Err(e) => {
// AR-6定向BLow 工具失败不 emit AiError。
// 原逻辑 emit AiError 会令前端置 streaming=false + 错误气泡,但 process_tool_calls
// 仍返回 pending_count=0agentic loop 续下一轮 → 前端 false 后端跑,状态紊乱。
// 现改为正常 emit AiToolCallCompletedresult=错误信息),错误包进 tool_result
// 让 LLM 看到工具失败自行决定下一步loop 正常续,前端状态一致。
let err_msg = format!("工具 {} 执行失败: {}", draft.name, e);
let _ = app_clone.emit("ai-chat-event", AiChatEvent::AiToolCallCompleted {
id: draft.id.clone(),
result: serde_json::Value::String(err_msg.clone()),
conversation_id: Some(conv_clone),
});
(draft, Err(err_msg))
}
}
}
})).await;
// 串行回填 tool_result + 审计(持 session 锁)
for (draft, outcome) in results {
let (status, content) = match outcome {
Ok(c) => ("completed", c),
Err(c) => ("failed", c),
};
// F-260616-09 B 批2:写 per_conv.messages。
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, content.clone()));
audit_tool_call(&audit_repo, conv_id, &draft.id, &draft.name, &draft.args, status, RiskLevel::Low, Some(content), Some("auto")).await;
}
}
pending_count
}