新增: 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;
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
|
||||
/// 工具调用草稿(流式收集时的临时结构)
|
||||
|
||||
@@ -167,6 +167,8 @@ pub fn run() {
|
||||
// F-260619-03 Phase A: AI 工具文件访问授权目录白名单
|
||||
commands::ai::ai_get_allowed_dirs,
|
||||
commands::ai::ai_set_allowed_dirs,
|
||||
// F-260619-03 Phase B: 路径授权弹窗决策(消费 AiDirAuthRequired 挂起)
|
||||
commands::ai::ai_authorize_dir,
|
||||
// 审批历史面板(AE-2025-08:查 ai_tool_executions 表,敏感字段截断)
|
||||
commands::ai::audit::list_tool_executions,
|
||||
// 知识库
|
||||
|
||||
@@ -305,20 +305,26 @@ pub struct AppState {
|
||||
pub allowed_dirs: Arc<RwLock<AllowedDirs>>,
|
||||
}
|
||||
|
||||
/// F-260619-03 Phase A: AI 工具文件访问授权目录白名单
|
||||
/// F-260619-03 Phase A/B/C: AI 工具文件访问授权目录白名单
|
||||
///
|
||||
/// Phase A 仅含 `persistent`(持久化白名单,从 Settings KV `allowed_dirs` 加载,
|
||||
/// JSON 数组 `["E:/wk-lab/u-abc"]`)。`resolve_workspace_path` 校验时:
|
||||
/// - workspace_root 始终视为已授权(向后兼容,默认根)
|
||||
/// - 任一 persistent 目录 starts_with 命中即放行
|
||||
///
|
||||
/// Phase B 将扩展 `session: HashSet<PathBuf>`(会话临时授权) + 弹窗挂起机制,
|
||||
/// 届时 is_authorized 增加 session 校验。当前结构预留扩展位但不引入。
|
||||
/// - Phase A:`persistent`(持久化白名单,从 Settings KV `allowed_dirs` 加载,
|
||||
/// JSON 数组 `["E:/wk-lab/u-abc"]`)。`resolve_workspace_path` 校验时:
|
||||
/// - workspace_root 始终视为已授权(向后兼容,默认根)
|
||||
/// - 任一 persistent 目录 starts_with 命中即放行
|
||||
/// - Phase B:`session`(进程级会话临时授权,弹窗"仅本次"写入;切换/新建/删除会话清空)。
|
||||
/// 单用户桌面应用 active_conversation_id 单全局模型,session 字段随 active 切换清空,
|
||||
/// 行为等价"当前活跃会话的临时授权"。handler 闭包(read lock 取快照)与
|
||||
/// process_tool_calls 预校验均读此字段,两端一致。
|
||||
/// - Phase C:黑名单(is_authorized 内,黑名单优先于白名单 — 命中即拒)。
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct AllowedDirs {
|
||||
/// 持久化授权目录(Settings KV `allowed_dirs`,JSON 字符串数组)。
|
||||
/// 已规范化(canonicalize 失败回退原字面量),便于 starts_with 精确比对。
|
||||
pub persistent: HashSet<PathBuf>,
|
||||
/// F-260619-03 Phase B: 会话级临时授权目录(弹窗"仅本次"写入,进程级内存)。
|
||||
/// 仅当前活跃会话生效,切换/新建/删除会话由 clear_session_allowed_dirs 清空(不落库)。
|
||||
/// handler 闭包与 process_tool_calls 预校验均读此字段,确保两端授权判定一致。
|
||||
pub session: HashSet<PathBuf>,
|
||||
}
|
||||
|
||||
impl AllowedDirs {
|
||||
@@ -330,22 +336,124 @@ impl AllowedDirs {
|
||||
pub fn default_with_root() -> Self {
|
||||
let mut set = HashSet::new();
|
||||
set.insert(workspace_root_path());
|
||||
Self { persistent: set }
|
||||
Self { persistent: set, session: HashSet::new() }
|
||||
}
|
||||
|
||||
/// 路径是否被授权:workspace_root 始终授权 + persistent 任一 starts_with 命中。
|
||||
/// 路径是否被授权:workspace_root 始终授权 + persistent 或 session 任一 starts_with 命中即放行。
|
||||
///
|
||||
/// **Phase C 黑名单优先**:即使白名单命中,若路径落入系统敏感目录(Windows
|
||||
/// `\Windows\System32` / `\Program Files\`;Unix `/etc /usr /bin /sbin /boot
|
||||
/// /dev /proc /sys`)仍拒。黑名单优先于白名单,防用户误授权系统目录。
|
||||
///
|
||||
/// 输入 `candidate` 应为 canonicalize 后的真实路径(防 symlink 逃逸);
|
||||
/// 调用方(resolve_workspace_path_with_allowed)负责 canonicalize,本函数只做 starts_with 比对。
|
||||
pub fn is_authorized(&self, candidate: &Path) -> bool {
|
||||
// Phase C: 黑名单优先(白名单命中也拒)。validate_path 已挡 .ssh/.aws 等,
|
||||
// 此处补系统核心目录(用户可能误把 C:\ 加入 persistent,黑名单兜底拒 System32)。
|
||||
if is_in_system_blacklist(candidate) {
|
||||
return false;
|
||||
}
|
||||
let root = workspace_root_path();
|
||||
if candidate.starts_with(&root) {
|
||||
return true;
|
||||
}
|
||||
self.persistent.iter().any(|d| candidate.starts_with(d))
|
||||
|| self.session.iter().any(|d| candidate.starts_with(d))
|
||||
}
|
||||
}
|
||||
|
||||
/// F-260619-03 Phase C: 路径授权决策(预校验用,process_tool_calls 分类前调)。
|
||||
///
|
||||
/// `check_path_authorization` 返回本枚举,process_tool_calls 据此决定:
|
||||
/// - `Authorized`:路径已授权 → 走原 Low/Med/High 流程
|
||||
/// - `NeedsAuthorization`:路径未授权但非黑名单 → Phase B 挂起弹窗(emit AiDirAuthRequired)
|
||||
/// - `Denied`:路径命中黑名单 → 硬拒(工具返 Err tool_result,不挂起)
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum PathAuthDecision {
|
||||
/// 已授权(workspace_root / persistent / session 命中且非黑名单)
|
||||
Authorized,
|
||||
/// 未授权且非黑名单:需 Phase B 弹窗询问用户。附带规范化后的待授权目录(父目录,
|
||||
/// 对齐 session_trust 目录粒度),供"仅本次/未来都允许"写入白名单。
|
||||
NeedsAuthorization { dir: PathBuf },
|
||||
/// 命中系统黑名单(Phase C):硬拒,工具返 Err,不弹窗。
|
||||
Denied { reason: String },
|
||||
}
|
||||
|
||||
/// F-260619-03 Phase C: 系统敏感目录黑名单判定(跨平台)。
|
||||
///
|
||||
/// 白名单命中但黑名单命中 → 拒(防用户误把整个盘符如 `C:\` 加入 persistent 致
|
||||
/// System32 被放行)。validate_path(tool_registry.rs)已挡 .ssh/.aws/AppData 等,
|
||||
/// 此处补系统核心目录。
|
||||
///
|
||||
/// - Windows(不区分大小写,按路径分隔符分段):`\Windows\System32`、`\Program Files\`、
|
||||
/// `\Program Files (x86)\`、`\Windows\` 根下 System/SysWOW64
|
||||
/// - Unix:`/etc`、`/usr`、`/bin`、`/sbin`、`/boot`、`/dev`、`/proc`、`/sys`
|
||||
pub(crate) fn is_in_system_blacklist(path: &Path) -> bool {
|
||||
let s = path.to_string_lossy().to_lowercase();
|
||||
let seps = ['/', '\\'];
|
||||
// Windows:按分隔符分段判定(避免 contains 误伤 "my program files backup" 这类目录名)
|
||||
let segs: Vec<&str> = s.split(seps).filter(|s| !s.is_empty()).collect();
|
||||
for (i, seg) in segs.iter().enumerate() {
|
||||
// Windows 系统目录:C:\Windows\System32 / C:\Windows\SysWOW64 / C:\Windows\System
|
||||
if cfg!(windows) {
|
||||
if *seg == "windows" {
|
||||
if let Some(next) = segs.get(i + 1) {
|
||||
if matches!(*next, "system32" | "syswow64" | "system") {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
// C:\Program Files / C:\Program Files (x86)
|
||||
if *seg == "program files" || *seg == "program files (x86)" {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// Unix 系统目录(路径首段为这些即拒;Windows 上也防 Unix 风格绝对路径,防御性)
|
||||
if i == 0 && matches!(*seg, "etc" | "usr" | "bin" | "sbin" | "boot" | "dev" | "proc" | "sys") {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// F-260619-03 Phase B/C: 路径授权预校验(供 process_tool_calls 分类前调)。
|
||||
///
|
||||
/// 词法层判定(不 canonicalize,因路径可能不存在 — write_file 新建)。返回三态决策:
|
||||
/// - 路径规范化(去 .. / 锚定 workspace_root)后,若命中黑名单 → `Denied`
|
||||
/// - 否则若 `is_authorized(规范化路径)`(persistent + session) → `Authorized`
|
||||
/// - 否则 → `NeedsAuthorization { dir: 父目录规范化 }`(目录粒度,对齐 session_trust)
|
||||
///
|
||||
/// 注意:本函数只做词法层预判(防不存在路径兜底);实际执行时 handler 内
|
||||
/// resolve_workspace_path_with_allowed 仍做完整校验(canonicalize symlink 防逃逸)。
|
||||
/// 黑名单在两处都判(is_authorized 内 + 此处独立判),双保险。
|
||||
pub fn check_path_authorization(
|
||||
raw_path: &str,
|
||||
allowed: &AllowedDirs,
|
||||
) -> PathAuthDecision {
|
||||
// 规范化:绝对路径原样,相对路径锚定 workspace_root(与 resolve_workspace_path_impl 一致)
|
||||
let resolved = if Path::new(raw_path).is_absolute() {
|
||||
PathBuf::from(raw_path)
|
||||
} else {
|
||||
workspace_root_path().join(raw_path)
|
||||
};
|
||||
// Phase C: 黑名单优先独立判定(is_authorized 内也判,此处先判便于 NeedsAuthorization
|
||||
// 不误把黑名单路径推到弹窗 — 黑名单路径直接硬拒不让用户"授权")。
|
||||
if is_in_system_blacklist(&resolved) {
|
||||
return PathAuthDecision::Denied {
|
||||
reason: format!("路径命中系统敏感目录黑名单: {}", raw_path),
|
||||
};
|
||||
}
|
||||
if allowed.is_authorized(&resolved) {
|
||||
return PathAuthDecision::Authorized;
|
||||
}
|
||||
// 未授权 → 取父目录作待授权目录(目录粒度,对齐 session_trust 的 dir 语义)
|
||||
let dir = resolved
|
||||
.parent()
|
||||
.map(|p| p.to_path_buf())
|
||||
.unwrap_or_else(|| workspace_root_path());
|
||||
PathAuthDecision::NeedsAuthorization { dir }
|
||||
}
|
||||
|
||||
/// workspace 根目录(src-tauri 上两级,与 tool_registry::workspace_root 同源)。
|
||||
fn workspace_root_path() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
@@ -477,7 +585,11 @@ impl AppState {
|
||||
});
|
||||
set.insert(normalized);
|
||||
}
|
||||
*self.allowed_dirs.write().await = AllowedDirs { persistent: set };
|
||||
// F-260619-03 Phase B: reload 时保留当前会话临时授权(session 不落库,仅内存),
|
||||
// 仅覆盖 persistent。用户改 Settings 不影响当前会话已临时授权的目录。
|
||||
let mut guard = self.allowed_dirs.write().await;
|
||||
let preserved_session = std::mem::take(&mut guard.session);
|
||||
*guard = AllowedDirs { persistent: set, session: preserved_session };
|
||||
}
|
||||
|
||||
/// F-260619-03 Phase A: 写 Settings KV + 同步内存白名单(供 Settings IPC 调用)。
|
||||
@@ -520,6 +632,54 @@ impl AppState {
|
||||
out.sort();
|
||||
out
|
||||
}
|
||||
|
||||
/// F-260619-03 Phase B: 追加持久化授权目录(弹窗"未来都允许"选项)。
|
||||
///
|
||||
/// 读当前 persistent → 追加新目录(去重)→ set_allowed_dirs 持久化 + 同步内存。
|
||||
/// 返回持久化后规范化列表(含 workspace_root)。
|
||||
pub async fn add_persistent_allowed_dir(&self, dir: String) -> Result<Vec<String>> {
|
||||
let d = dir.trim().to_string();
|
||||
if d.is_empty() {
|
||||
return Ok(self.get_allowed_dirs().await);
|
||||
}
|
||||
let mut current = self.get_allowed_dirs().await;
|
||||
if !current.iter().any(|c| paths_eq(c, &d)) {
|
||||
current.push(d);
|
||||
}
|
||||
self.set_allowed_dirs(current).await
|
||||
}
|
||||
|
||||
/// F-260619-03 Phase B: 追加会话级临时授权目录(弹窗"仅本次"选项)。
|
||||
///
|
||||
/// 写入 `allowed_dirs.session`(进程级内存,不落库)。handler 闭包 read lock 取快照时
|
||||
/// 与 process_tool_calls 预校验读同一字段,两端授权判定一致。规范化:canonicalize
|
||||
/// 失败回退原字面量 trim(与 reload_allowed_dirs 一致)。
|
||||
pub async fn add_session_allowed_dir(&self, dir: String) {
|
||||
let d = dir.trim();
|
||||
if d.is_empty() {
|
||||
return;
|
||||
}
|
||||
let p = PathBuf::from(d);
|
||||
let normalized = std::fs::canonicalize(&p).unwrap_or_else(|_| {
|
||||
PathBuf::from(d.trim_end_matches(['/', '\\']))
|
||||
});
|
||||
self.allowed_dirs.write().await.session.insert(normalized);
|
||||
}
|
||||
|
||||
/// F-260619-03 Phase B: 清空会话级临时授权(切换/新建/删除活跃会话时调)。
|
||||
///
|
||||
/// 单用户桌面应用 active_conversation_id 单全局模型,session 字段语义为
|
||||
/// "当前活跃会话的临时授权",切走即清空(不跨会话继承临时授权)。
|
||||
pub async fn clear_session_allowed_dirs(&self) {
|
||||
self.allowed_dirs.write().await.session.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// F-260619-03 Phase B: 路径字符串等价比较(canonicalize 后比对,失败回退小写比对)。
|
||||
fn paths_eq(a: &str, b: &str) -> bool {
|
||||
let pa = std::fs::canonicalize(a).map(|p| p.to_string_lossy().to_string()).unwrap_or_else(|_| a.to_string());
|
||||
let pb = std::fs::canonicalize(b).map(|p| p.to_string_lossy().to_string()).unwrap_or_else(|_| b.to_string());
|
||||
pa.eq_ignore_ascii_case(&pb)
|
||||
}
|
||||
|
||||
/// 构建节点注册表 — 注册内置节点
|
||||
@@ -590,7 +750,7 @@ mod tests {
|
||||
let mut set = HashSet::new();
|
||||
let custom = PathBuf::from("E:/wk-test-external-dir");
|
||||
set.insert(custom.clone());
|
||||
let allowed = AllowedDirs { persistent: set };
|
||||
let allowed = AllowedDirs { persistent: set, session: HashSet::new() };
|
||||
// 精确命中 + 子路径 starts_with 命中
|
||||
assert!(allowed.is_authorized(&custom));
|
||||
assert!(allowed.is_authorized(&custom.join("sub").join("file.txt")));
|
||||
@@ -601,7 +761,7 @@ mod tests {
|
||||
fn test_allowed_dirs_unauthorized_rejected() {
|
||||
let mut set = HashSet::new();
|
||||
set.insert(PathBuf::from("E:/wk-test-authorized"));
|
||||
let allowed = AllowedDirs { persistent: set };
|
||||
let allowed = AllowedDirs { persistent: set, session: HashSet::new() };
|
||||
let outside = PathBuf::from("E:/wk-test-unauthorized/file.txt");
|
||||
assert!(!allowed.is_authorized(&outside), "未授权目录应被拒绝");
|
||||
}
|
||||
@@ -620,5 +780,124 @@ mod tests {
|
||||
fn test_allowed_dirs_settings_key_stable() {
|
||||
assert_eq!(AllowedDirs::SETTINGS_KEY, "allowed_dirs");
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// F-260619-03 Phase B: session 临时授权语义测试
|
||||
// ============================================================
|
||||
|
||||
/// Phase B: session 命中放行(persistent 未命中但 session 命中)
|
||||
#[test]
|
||||
fn test_allowed_dirs_session_authorized() {
|
||||
let mut session = HashSet::new();
|
||||
session.insert(PathBuf::from("E:/wk-temp-session"));
|
||||
let allowed = AllowedDirs { persistent: HashSet::new(), session };
|
||||
assert!(allowed.is_authorized(&PathBuf::from("E:/wk-temp-session/file.txt")));
|
||||
}
|
||||
|
||||
/// Phase B: persistent + session 任一命中即放行
|
||||
#[test]
|
||||
fn test_allowed_dirs_persistent_or_session() {
|
||||
let mut persistent = HashSet::new();
|
||||
persistent.insert(PathBuf::from("E:/wk-persist"));
|
||||
let mut session = HashSet::new();
|
||||
session.insert(PathBuf::from("E:/wk-session"));
|
||||
let allowed = AllowedDirs { persistent, session };
|
||||
assert!(allowed.is_authorized(&PathBuf::from("E:/wk-persist/a")));
|
||||
assert!(allowed.is_authorized(&PathBuf::from("E:/wk-session/b")));
|
||||
assert!(!allowed.is_authorized(&PathBuf::from("E:/wk-other/c")));
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// F-260619-03 Phase C: 系统目录黑名单测试
|
||||
// ============================================================
|
||||
|
||||
/// Phase C: Windows System32 黑名单命中(即使在白名单内也拒)
|
||||
#[test]
|
||||
fn test_blacklist_windows_system32() {
|
||||
let mut persistent = HashSet::new();
|
||||
// 用户误把整个 C:\ 加入白名单
|
||||
persistent.insert(PathBuf::from("C:\\"));
|
||||
let allowed = AllowedDirs { persistent, session: HashSet::new() };
|
||||
// System32 应被黑名单拒(尽管 C:\ 在白名单)
|
||||
assert!(!allowed.is_authorized(&PathBuf::from("C:\\Windows\\System32\\config\\sam")));
|
||||
}
|
||||
|
||||
/// Phase C: Windows Program Files 黑名单命中
|
||||
#[test]
|
||||
fn test_blacklist_windows_program_files() {
|
||||
let mut persistent = HashSet::new();
|
||||
persistent.insert(PathBuf::from("C:\\"));
|
||||
let allowed = AllowedDirs { persistent, session: HashSet::new() };
|
||||
assert!(!allowed.is_authorized(&PathBuf::from("C:\\Program Files\\SomeApp\\app.exe")));
|
||||
}
|
||||
|
||||
/// Phase C: 黑名单不误伤合法目录(含 "program files" 子串的自定义目录名)
|
||||
#[test]
|
||||
fn test_blacklist_no_false_positive() {
|
||||
// "my program files backup" 不应被拒(分段匹配,非精确段)
|
||||
assert!(!is_in_system_blacklist(&PathBuf::from("E:/my program files backup/x")));
|
||||
// 普通工作目录不拒
|
||||
assert!(!is_in_system_blacklist(&PathBuf::from("E:/wk-lab/devflow/src")));
|
||||
}
|
||||
|
||||
/// Phase C: Unix 系统目录黑名单(/etc /usr /bin 等)
|
||||
#[test]
|
||||
fn test_blacklist_unix_system_dirs() {
|
||||
assert!(is_in_system_blacklist(&PathBuf::from("/etc/passwd")));
|
||||
assert!(is_in_system_blacklist(&PathBuf::from("/usr/bin/python")));
|
||||
assert!(is_in_system_blacklist(&PathBuf::from("/proc/self/environ")));
|
||||
assert!(is_in_system_blacklist(&PathBuf::from("/sys/kernel")));
|
||||
// 普通用户目录不拒
|
||||
assert!(!is_in_system_blacklist(&PathBuf::from("/home/user/project")));
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// F-260619-03 Phase B/C: check_path_authorization 三态决策测试
|
||||
// ============================================================
|
||||
|
||||
/// Phase B/C: workspace_root 路径 → Authorized
|
||||
#[test]
|
||||
fn test_check_path_authorized_workspace() {
|
||||
let allowed = AllowedDirs::default_with_root();
|
||||
let rel = "src/main.rs";
|
||||
match check_path_authorization(rel, &allowed) {
|
||||
PathAuthDecision::Authorized => {}
|
||||
other => panic!("workspace_root 内路径应 Authorized, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
/// Phase B/C: 未授权路径 → NeedsAuthorization(附父目录)
|
||||
#[test]
|
||||
fn test_check_path_needs_auth() {
|
||||
let allowed = AllowedDirs::default(); // 空,无 workspace_root
|
||||
match check_path_authorization("E:/wk-external/file.txt", &allowed) {
|
||||
PathAuthDecision::NeedsAuthorization { dir } => {
|
||||
assert_eq!(dir, PathBuf::from("E:/wk-external"));
|
||||
}
|
||||
other => panic!("未授权路径应 NeedsAuthorization, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
/// Phase B: session 命中 → Authorized(check_path_authorization 读 session 字段)
|
||||
#[test]
|
||||
fn test_check_path_session_hit() {
|
||||
let mut session = HashSet::new();
|
||||
session.insert(PathBuf::from("E:/wk-session"));
|
||||
let allowed = AllowedDirs { persistent: HashSet::new(), session };
|
||||
match check_path_authorization("E:/wk-session/sub/file.txt", &allowed) {
|
||||
PathAuthDecision::Authorized => {}
|
||||
other => panic!("session 命中应 Authorized, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
/// Phase C: 黑名单路径 → Denied(不弹窗直接拒)
|
||||
#[test]
|
||||
fn test_check_path_denied_blacklist() {
|
||||
let allowed = AllowedDirs::default_with_root();
|
||||
match check_path_authorization("C:/Windows/System32/config/sam", &allowed) {
|
||||
PathAuthDecision::Denied { .. } => {}
|
||||
other => panic!("System32 应 Denied, got {:?}", other),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user