新增: AI 工具意图识别层 + 文件访问权限模型 Phase B+C

会话意图识别层(intent.rs / df-ai crate):
- Intent 枚举 11 态 + recognize 规则识别(关键词+权重+优先级)+ tool_subset_for 工具子集映射
- 模态接口预留 None,独立模块待接入 loop,36 单测

文件访问权限模型 Phase B+C(F-260619-03):
- Phase B 会话临时白名单 + 循环挂起 + DirAuthDialog 弹窗(仅本次/未来都允许/拒绝)
- Phase C 系统目录黑名单(Win System32/Program Files;Unix /etc /usr...)分段匹配 + 写操作约束
- 已审 CR-260619-09  PASS
This commit is contained in:
2026-06-19 18:58:48 +08:00
parent 968c3df5ab
commit 8da6bdcec2
17 changed files with 1572 additions and 17 deletions

View File

@@ -4,7 +4,7 @@ use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use serde::Serialize;
use tauri::{AppHandle, Emitter, State};
use tauri::{AppHandle, Emitter, Manager, State};
use df_ai::ai_tools::{AiToolRegistry, RiskLevel};
use df_ai::provider::ChatMessage;
@@ -15,7 +15,7 @@ use crate::state::AppState;
use crate::commands::err_str;
use super::{AiChatEvent, AiSession, PendingApproval, ToolCallDraft};
use super::{AiChatEvent, AiSession, PathAuthRequest, PendingApproval, ToolCallDraft};
// tool_registry::generate_diff 经 audit/diff.rs 直接 usebuild_write_file_diff 内调用),
// 本文件不再裸名用 generate_diff故不再在此 use避免 unused import warning
@@ -126,6 +126,74 @@ pub(super) use cache::{find_cached_high_risk_result, PENDING_APPROVAL_PLACEHOLDE
mod data_change;
pub(crate) use data_change::emit_data_changed;
/// 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)
/// 取 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<String> {
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" => {
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;
}
let mut pending_dir: Option<std::path::PathBuf> = None;
for p in &paths {
match check_path_authorization(p, allowed) {
PathAuthDecision::Denied { reason } => return FileToolAuthOutcome::Denied(reason),
PathAuthDecision::NeedsAuthorization { dir } => {
if pending_dir.is_none() {
pending_dir = Some(dir);
}
}
PathAuthDecision::Authorized => {}
}
}
match pending_dir {
Some(dir) => FileToolAuthOutcome::NeedsAuth(PathAuthRequest { dir, raw_paths: paths }),
None => FileToolAuthOutcome::Authorized,
}
}
/// 处理流式接收的工具调用Low 风险并行执行join_allMed/High 进审批门控
/// 返回待审批的工具数量0 = 全部自动执行完成)
pub(crate) async fn process_tool_calls(
@@ -162,6 +230,87 @@ 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 移除单独处理。
let allowed_snapshot = app_handle
.state::<crate::state::AppState>()
.allowed_dirs
.clone();
let allowed_guard = allowed_snapshot.read().await.clone();
// 收集 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 与此处预校验读同一字段,两端授权判定一致。
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)),
FileToolAuthOutcome::Denied(reason) => path_denied.push((draft, reason)),
}
}
let drafts = authorized_drafts;
// 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));
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiToolCallCompleted {
id: draft.id.clone(),
result: serde_json::Value::String(err_msg.clone()),
conversation_id: Some(conv_id.to_string()),
});
// 审计:路径黑名单拒绝(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,
"rejected", risk_level, Some(err_msg), Some("auto_blacklist"),
).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 恢复)。
for (draft, args, req) in path_auth_pending {
pending_count += 1;
let dir_str = req.dir.to_string_lossy().to_string();
let path_str = req.raw_paths.first().cloned().unwrap_or_default();
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: None,
path_auth: Some(req),
},
);
// 占位 tool_result(与 RiskLevel 审批一致),ai_authorize_dir 批准后替换为真实结果
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, PENDING_APPROVAL_PLACEHOLDER));
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiDirAuthRequired {
id: draft.id.clone(),
tool: draft.name.clone(),
path: path_str,
dir: dir_str,
conversation_id: Some(conv_id.to_string()),
});
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,
"pending", risk_level, None, None,
).await;
}
// 分类Low 收集并行执行Med/High 立即进审批门控push 占位 tool_result
//
// F-260616-05High risk 在进审批门前先查去重缓存find_cached_high_risk_result
@@ -253,6 +402,7 @@ pub(crate) async fn process_tool_calls(
conversation_id: Some(conv_id.to_string()),
recovered: false,
diff: approval_diff.clone(),
path_auth: None,
});
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;

View File

@@ -69,6 +69,9 @@ pub async fn restore_pending_approvals(state: &AppState) {
// 且恢复路径在 session.lock 内做 async IO 复杂度高,预览价值低。
// 前端见 diff=None 时回退显新 content。
diff: None,
// F-260619-03 Phase B: 重启恢复的审批一律视为普通 RiskLevel 审批(非路径授权挂起)。
// 路径授权挂起是会话级状态,重启后 session 重建,无法恢复挂起语义。
path_auth: None,
},
);
}
@@ -121,6 +124,7 @@ mod tests_f09_batch8_restore {
conversation_id: conversation_id.clone(),
recovered: true,
diff: None,
path_auth: None,
},
);
}

View File

@@ -356,6 +356,17 @@ pub async fn ai_approve(
// recovered 字段保留读取(标记重启恢复来源,未来扩展用),本次修复移除 if !recovered 落库守卫。
let _recovered = approval.recovered;
// F-260619-03 Phase B: 路径授权挂起的 pending 不走 ai_approve(它不预写授权目录,
// 直接 execute 会因路径未授权失败)。引导前端改用 ai_authorize_dir(写 session/persistent 后执行)。
// 防御:前端误调时回滚 pending(重新 insert)+ 返明确错误,避免 pending 被误消费丢失。
if approval.path_auth.is_some() {
state.ai_session.lock().await.pending_approvals.insert(tool_call_id.clone(), approval);
return Err(format!(
"tool_call_id={} 是路径授权挂起,请用 ai_authorize_dir(decision: once/always/deny)",
tool_call_id
));
}
if !approved {
// 替换占位 tool_result 为拒绝结果
// F-260616-09 B 批4:per_conv.messages 唯一真相源(conv_id 来源 approval.conversation_id)。
@@ -493,6 +504,131 @@ pub async fn ai_approve(
Ok("executed".to_string())
}
/// F-260619-03 Phase B: 路径授权弹窗决策(消费 AiDirAuthRequired 挂起的 pending)。
///
/// 触发:process_tool_calls 预校验文件工具路径未命中白名单 → 挂起 loop(insert pending
/// with path_auth=Some + emit AiDirAuthRequired)。前端弹窗三选项,用户选择后调本 IPC。
///
/// `decision`:
/// - `"once"`:写入 `PerConvState.session_allowed_dirs`(会话级,随会话销毁)→ 执行工具 → 恢复 loop
/// - `"always"`:写入持久化 Settings KV `allowed_dirs`(reload_allowed_dirs 同步内存)→ 执行 → 恢复
/// - `"deny"`:工具返 Err "用户拒绝路径授权" → 恢复 loop
///
/// 复用审批恢复架构:remove pending → (写授权/拒) → execute/Err → 替换占位 tool_result →
/// emit Completed + ApprovalResult → try_continue_agent_loop 恢复 loop(同 ai_approve)。
#[tauri::command]
pub async fn ai_authorize_dir(
app: AppHandle,
state: State<'_, AppState>,
tool_call_id: String,
decision: String,
) -> Result<String, String> {
// 取 pending(必须是 path_auth 挂起;普通 RiskLevel 审批走 ai_approve)
let approval = {
let mut session = state.ai_session.lock().await;
match session.pending_approvals.remove(&tool_call_id) {
Some(a) => a,
None => return Err(format!("未找到路径授权挂起: {}", tool_call_id)),
}
};
let req = approval.path_auth.clone().ok_or_else(|| {
format!("tool_call_id={} 非路径授权挂起(普通审批请用 ai_approve)", tool_call_id)
})?;
let dir_str = req.dir.to_string_lossy().to_string();
let conv_id = approval.conversation_id.clone();
let args = approval.arguments.clone();
// ── "deny":拒绝路径授权 → 工具返 Err,恢复 loop ──
if decision == "deny" {
let err_msg = "用户拒绝路径授权".to_string();
{
let mut session = state.ai_session.lock().await;
if let Some(ref cid) = conv_id {
session.conv(cid).messages.replace_tool_result_content(&tool_call_id, &err_msg);
}
}
let _ = app.emit("ai-chat-event", AiChatEvent::AiToolCallCompleted {
id: tool_call_id.clone(),
result: serde_json::Value::String(err_msg.clone()),
conversation_id: conv_id.clone(),
});
let _ = app.emit("ai-chat-event", AiChatEvent::AiApprovalResult {
id: tool_call_id.clone(),
approved: false,
conversation_id: conv_id.clone(),
});
audit_finalize(&state, &tool_call_id, "rejected", Some(err_msg)).await;
if let Some(ref cid) = conv_id {
save_conversation(&state.ai_session, &state.db, cid, None, None).await;
}
let cont_conv_id = conv_id.clone().unwrap_or_default();
let start_iter = {
let session = state.ai_session.lock().await;
session.conv_read(&cont_conv_id).map(|c| c.iteration_used).unwrap_or(0)
};
try_continue_agent_loop(&app, &state, &cont_conv_id, start_iter).await;
return Ok("rejected".to_string());
}
// ── "once":写会话级临时授权(随会话销毁,不落库) ──
if decision == "once" {
state.add_session_allowed_dir(dir_str.clone()).await;
}
// ── "always":写持久化授权(Settings KV + 同步内存 persistent) ──
if decision == "always" {
if let Err(e) = state.add_persistent_allowed_dir(dir_str.clone()).await {
tracing::warn!("[F-03B] 持久化授权目录失败(降级仅本次会话): {}", e);
// 降级:写入会话临时授权,保本路径本会话可用
state.add_session_allowed_dir(dir_str.clone()).await;
}
}
// ── 执行工具(路径现已授权,handler 内 resolve_workspace_path_with_allowed 放行) ──
// 复用 ai_approve 执行分支:run_workflow 特殊处理 / 其余走 ai_tools.execute
let exec_result: anyhow::Result<serde_json::Value> = if approval.tool_name == "run_workflow" {
super::super::execute_run_workflow_for_tool(&app, &state, &args).await
} else {
state.ai_tools.execute(&approval.tool_name, args.clone()).await
};
let (audit_status, result_val) = match &exec_result {
Ok(val) => ("executed", val.clone()),
Err(e) => ("failed", serde_json::Value::String(e.to_string())),
};
audit_finalize(&state, &tool_call_id, audit_status, Some(result_val.to_string())).await;
if exec_result.is_ok() {
emit_data_changed(&app, &approval.tool_name);
}
// 替换占位 tool_result 为真实结果
{
let mut session = state.ai_session.lock().await;
if let Some(ref cid) = conv_id {
session.conv(cid).messages.replace_tool_result_content(&tool_call_id, &result_val.to_string());
}
}
let _ = app.emit("ai-chat-event", AiChatEvent::AiToolCallCompleted {
id: tool_call_id.clone(),
result: result_val.clone(),
conversation_id: conv_id.clone(),
});
let _ = app.emit("ai-chat-event", AiChatEvent::AiApprovalResult {
id: tool_call_id.clone(),
approved: true,
conversation_id: conv_id.clone(),
});
if let Some(ref cid) = conv_id {
save_conversation(&state.ai_session, &state.db, cid, None, None).await;
}
let cont_conv_id = conv_id.clone().unwrap_or_default();
let start_iter = {
let session = state.ai_session.lock().await;
session.conv_read(&cont_conv_id).map(|c| c.iteration_used).unwrap_or(0)
};
try_continue_agent_loop(&app, &state, &cont_conv_id, start_iter).await;
Ok(audit_status.to_string())
}
/// 待审批工具调用信息(前端恢复 toolCard pending_approval 态用)
#[derive(Debug, Serialize)]
pub struct PendingToolCallInfo {

View File

@@ -68,6 +68,11 @@ pub async fn ai_conversation_create(
let mut session = state.ai_session.lock().await;
session.active_conversation_id = Some(id.clone());
session.active_conv_created_at = Some(now);
// F-260619-03 Phase B: 切换 active 会话 → 清空上一会话的临时授权目录(session 字段语义为
// "当前活跃会话的临时授权",不跨会话继承)。新会话从空白临时授权开始。
drop(session);
state.clear_session_allowed_dirs().await;
let mut session = state.ai_session.lock().await;
// 新会话建一份独立 per_conv(全新 state,与旧会话隔离)。
// 旧 conv 的 per_conv **不清除**(决策 e:后台 loop 继续跑),其 pending_approvals 也保留
// (switch 路径会 retain 清目标 conv 的,create 不动他人)。
@@ -140,6 +145,11 @@ pub async fn ai_conversation_switch(
// **后台 conv(目标正在跑 loop)的 per_conv 已存在则跳过 reload**——防覆盖其内存 messages
// (后台 loop 正在写自己的 per_conv.messages,reload 会用 DB 旧快照覆盖内存新消息)。
session.active_conversation_id = Some(conversation_id.clone());
// F-260619-03 Phase B: 切换 active 会话 → 清空上一会话的临时授权目录(session 字段语义为
// "当前活跃会话的临时授权",不跨会话继承)。
drop(session);
state.clear_session_allowed_dirs().await;
let mut session = state.ai_session.lock().await;
let already_live = session.conv_read(&conversation_id).map(|c| c.generating).unwrap_or(false);
if !already_live {
// 目标 conv 未在生成:从 DB reload messages 到其 per_conv(首次切入或上次切走后无后台 loop)。
@@ -203,7 +213,8 @@ pub async fn ai_conversation_delete(
// F-260616-09 B 批4:删除 conv 时移除其 per_conv 条目(设计 §4.1 conv 存在性判据依赖此,
// 旧 loop 检测 conv 不存在即退出)。per_conv 唯一真相源,删顶层 messages.clear 双写。
session.per_conv.remove(&conversation_id);
if session.active_conversation_id.as_deref() == Some(&conversation_id) {
let was_active = session.active_conversation_id.as_deref() == Some(&conversation_id);
if was_active {
session.active_conversation_id = None;
}
// F-260616-09 B 批5:同时清理 LlmConcurrency 的 per_conv Semaphore 条目(防 HashMap 无限增长)。
@@ -213,6 +224,11 @@ pub async fn ai_conversation_delete(
// 上述 retain 已清)。loop 正常收敛/达 MAX/stop 但 conv 未删时不清理(下次发消息复用,限流计数连续)。
drop(session); // 释放 AiSession 锁再取 LlmConcurrency 锁(避免潜在锁序问题)
state.llm_concurrency.release_conv(&conversation_id).await;
// F-260619-03 Phase B: 删除的是当前活跃会话 → 清空其临时授权目录(session 字段语义为
// "当前活跃会话的临时授权",active 变 None 时不再属于任何会话)。
if was_active {
state.clear_session_allowed_dirs().await;
}
Ok(())
}

View File

@@ -148,6 +148,19 @@ pub enum AiChatEvent {
/// 每次重试前 emit前端可在错误气泡内显示「重试 n/m」。
/// attempt 从 1 开始(首次失败后第一次重试=attempt 1)max_attempts = max_retries + 1。
AiStreamRetry { attempt: u32, max_attempts: u32, conversation_id: Option<String> },
/// F-260619-03 Phase B: 路径授权弹窗(LLM 想访问白名单外目录,挂起 loop 等用户决定)。
///
/// 触发:resolve_workspace_path 预校验路径未命中 persistent/session 白名单(且非黑名单)。
/// 复用审批挂起架构(PendingApproval.path_auth 标记路径授权挂起),
/// 用户经 ai_authorize_dir IPC 选择"仅本次/未来都允许/拒绝",恢复 loop。
/// `dir`:规范化后的待授权目录(父目录,目录粒度对齐 session_trust)。
AiDirAuthRequired {
id: String,
tool: String,
path: String,
dir: String,
conversation_id: Option<String>,
},
/// F-15 阶段2 手动上下文分段完成:会话归档不删(archived_segment 标记),
/// 前端可据此刷新消息视图(归档段折叠/隐藏)。
AiContextCleared { conversation_id: Option<String> },
@@ -552,6 +565,18 @@ pub struct PendingApproval {
/// recovered 审批(启动重建)无 diff文件可能已变重读无意义
#[allow(dead_code)] // IPC 活跃(useAiEvents:252+ToolCard·UX-260618-06)·cargo Rust never-read 误报
pub diff: Option<String>,
/// F-260619-03 Phase B: 路径授权挂起标记(Some = 路径授权挂起,None = 普通 RiskLevel 审批)。
/// 携带待授权目录(规范化父目录),ai_authorize_dir IPC 据此决定写入 session/persistent。
pub path_auth: Option<PathAuthRequest>,
}
/// F-260619-03 Phase B: 路径授权挂起请求(PendingApproval.path_auth 标记)。
#[derive(Debug, Clone)]
pub struct PathAuthRequest {
/// 待授权目录(规范化父目录,对齐 session_trust 目录粒度)。
pub dir: std::path::PathBuf,
/// 原始路径参数(read_file.path / write_file.path / rename_file.from+to / ...)。
pub raw_paths: Vec<String>,
}
/// 工具调用草稿(流式收集时的临时结构)