新增: 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:
@@ -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 直接 use(build_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_all),Med/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-05:High 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;
|
||||
|
||||
Reference in New Issue
Block a user