优化: AI授权目录改进(误弹窗根因+安全加固)

strip_verbatim比对侧收口/黑名单统一/symlink/bind双路径reload/toast接线;cargo test 18
This commit is contained in:
2026-06-20 15:54:51 +08:00
parent e5a89165e6
commit e2ece2d898
8 changed files with 251 additions and 79 deletions

View File

@@ -478,6 +478,12 @@ pub async fn ai_approve(
// 前端 store listen 刷新列表(仅命中映射的工具 emit见 emit_data_changed
if exec_result.is_ok() {
emit_data_changed(&app, &approval.tool_name);
// MED-1(CR-260620-01 审查): path 绑定工具(Medium)审批执行后 reload 白名单。
// bind_dir_to_project / repo.update_field 直写 DB 绕过 project.rs IPC reload,
// 此处单点补全 → AI 绑定目录即时进白名单,防后续文件工具误弹窗。
if matches!(approval.tool_name.as_str(), "bind_directory" | "create_project" | "update_project") {
state.reload_allowed_dirs().await;
}
}
// 重新获取锁,替换占位 tool_result 为真实结果失败时为错误信息LLM 据此决定下一步)
@@ -549,7 +555,7 @@ pub async fn ai_approve(
/// with path_auth=Some + emit AiDirAuthRequired)。前端弹窗三选项,用户选择后调本 IPC。
///
/// `decision`:
/// - `"once"`:写入 `PerConvState.session_allowed_dirs`(会话级,随会话销毁)→ 执行工具 → 恢复 loop
/// - `"once"`:写入 `AppState.allowed_dirs.session`(进程级会话临时授权,随 active 会话切换/新建/删除由 clear_session_allowed_dirs 清空)→ 执行工具 → 恢复 loop
/// - `"always"`:写入持久化 Settings KV `allowed_dirs`(reload_allowed_dirs 同步内存)→ 执行 → 恢复
/// - `"deny"`:工具返 Err "用户拒绝路径授权" → 恢复 loop
///
@@ -637,6 +643,12 @@ pub async fn ai_authorize_dir(
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);
// MED-1(CR-260620-01 审查): path 绑定工具(Medium)审批执行后 reload 白名单。
// bind_dir_to_project / repo.update_field 直写 DB 绕过 project.rs IPC reload,
// 此处单点补全 → AI 绑定目录即时进白名单,防后续文件工具误弹窗。
if matches!(approval.tool_name.as_str(), "bind_directory" | "create_project" | "update_project") {
state.reload_allowed_dirs().await;
}
}
// 替换占位 tool_result 为真实结果

View File

@@ -130,14 +130,14 @@ fn validate_path(path: &str) -> anyhow::Result<()> {
anyhow::bail!("禁止路径遍历 (..)");
}
if lower.contains("\\.ssh")
|| lower.contains("\\.aws")
|| lower.contains("\\.gnupg")
|| lower.contains("\\appdata\\")
|| lower.contains("\\programdata\\")
|| lower.contains("\\windows\\")
|| lower.contains("\\system32\\")
{
// 敏感系统目录(.ssh/.aws/.gnupg + Windows/ProgramFiles/ProgramData + Unix 核心):
// 统一走 state::is_in_system_blacklist 分段匹配(消除 contains 子串误伤如 my\windows\backup,
// 与 is_authorized 运行时黑名单同源,单一来源 — 修 agent1 问题4 两套黑名单不一致)
if crate::state::is_in_system_blacklist(&PathBuf::from(&normalized)) {
anyhow::bail!("禁止访问敏感系统目录");
}
// AppData 保留单独 contains(用户级数据,分段匹配会误伤 D:\backup\appdata 这类合法目录名)
if lower.contains("\\appdata\\") {
anyhow::bail!("禁止访问敏感系统目录");
}
Ok(())
@@ -542,6 +542,10 @@ fn register_project_tools(registry: &mut AiToolRegistry, db: &Arc<Database>) {
let stack_opt = args.get("stack").and_then(|v| v.as_str()).filter(|s| !s.is_empty());
let repo = df_storage::crud::ProjectRepo::new(&db);
let stack = bind_dir_to_project(&repo, id, path, stack_opt).await?;
// F-260619-03: 项目绑定目录加 AI 白名单由 reload_allowed_dirs 自动读 projects.bind_directory
// 统一处理(优雅:绑定 = 自动授权,reload 时合并)。bind handler 不直接写 allowed_dirs
// (register_project_tools 无 allowed_dirs 参数,且 reload 集中更易维护)。
// 绑定后触发 reload:通过 app_handle emit 事件让 state reload,或下次工具调用前 reload 兜底。
Ok(serde_json::json!({ "id": id, "path": path, "stack": stack, "bound": true }))
})
})},
@@ -1790,14 +1794,20 @@ fn search_files_recursive<'a>(
.map_err(|e| anyhow::anyhow!("无法读取目录 {}: {}", path, e))?;
while let Some(entry) = dir.next_entry().await? {
let name = entry.file_name().to_string_lossy().to_string();
let metadata = entry.metadata().await?;
let is_dir = metadata.is_dir();
// file_type 不跟随 symlink(对比 entry.metadata 会跟随);symlink 目录递归会逃逸出白名单
// (workspace 内 symlink → 外部目录),故 symlink 一律跳过(不匹配/不递归)。对齐 list_dir_recursive。
let file_type = entry.file_type().await?;
if file_type.is_symlink() {
continue;
}
let is_dir = file_type.is_dir();
if !is_dir {
// 字符串包含匹配(大小写不敏感)
if name.to_lowercase().contains(pattern) {
*total += 1;
if results.len() < max_results {
let full_path = std::path::Path::new(path).join(&name).to_string_lossy().into_owned();
let metadata = entry.metadata().await?;
results.push(serde_json::json!({ "path": full_path, "size": metadata.len() }));
}
}