优化: 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() }));
}
}

View File

@@ -109,6 +109,11 @@ async fn create_with_binding(
updated_at: now,
};
state.projects.insert(record.clone()).await.map_err(err_str)?;
// F-260620: 绑定目录变更后立即 reload 白名单(reload_allowed_dirs 读 projects.path 合并 persistent),
// 否则新绑定目录需重启/改 Settings 才生效 → AI 访问误弹窗(已绑定重复弹窗主因,agent3 问题5)
if record.path.is_some() {
state.reload_allowed_dirs().await;
}
Ok(record)
}
@@ -208,7 +213,12 @@ pub async fn update_project(
.projects
.update_field(&id, &field, &value)
.await
.map_err(err_str)
.map_err(err_str)?;
// F-260620: path 字段变更后立即 reload 白名单(新绑定/重绑目录即时生效,防 AI 误弹窗)
if field == "path" {
state.reload_allowed_dirs().await;
}
Ok(true)
}
/// 删除项目(软删 → 回收站,可恢复)
@@ -307,6 +317,8 @@ pub async fn relocate_project_path(
.update_field(&id, "path", &new_path)
.await
.map_err(err_str)?;
// F-260620: 重定位目录后立即 reload 白名单(新路径即时授权,防 AI 误弹窗)
state.reload_allowed_dirs().await;
state
.projects
.update_field(&id, "stack", &stack_json)

View File

@@ -359,8 +359,13 @@ impl AllowedDirs {
if is_in_system_blacklist(candidate) {
return false;
}
self.persistent.iter().any(|d| candidate.starts_with(d))
|| self.session.iter().any(|d| candidate.starts_with(d))
// F-260620: 比对侧统一 strip_verbatim 收口。candidate 可能带 \\?\ 前缀
// (handler canonicalize 后)或正斜杠(词法层 LLM 传入),white list 条目已是
// strip+canonicalize 后的 E:\... 形态。单点 strip 消除所有调用方形态不一致
// (误弹窗核心根因 — 此前 strip_verbatim 仅写入侧调用,比对侧遗漏)。
let cand = strip_verbatim(candidate.to_path_buf());
self.persistent.iter().any(|d| cand.starts_with(d))
|| self.session.iter().any(|d| cand.starts_with(d))
}
}
@@ -396,19 +401,26 @@ pub(crate) fn is_in_system_blacklist(path: &Path) -> bool {
// 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
// Windows 系统目录:C:\Windows(根本身及所有子目录 win.ini/explorer.exe/hosts 等)/
// C:\Program Files / C:\Program Files (x86) / C:\ProgramData
if cfg!(windows) {
// windows 段命中即拒(含根本身,不再限定 system32 子目录 — 防 win.ini/hosts 等)
if *seg == "windows" {
if let Some(next) = segs.get(i + 1) {
if matches!(*next, "system32" | "syswow64" | "system") {
return true;
}
}
return true;
}
// C:\Program Files / C:\Program Files (x86)
if *seg == "program files" || *seg == "program files (x86)" {
return true;
}
// C:\ProgramData(系统级应用数据)
if *seg == "programdata" {
return true;
}
}
// 用户级凭据目录(.ssh/.aws/.gnupg):任意路径段命中即拒(跨平台,从 validate_path 迁移统一,
// 消除 validate_path contains 与 is_in_system_blacklist 分段两套黑名单不一致)
if matches!(*seg, ".ssh" | ".aws" | ".gnupg") {
return true;
}
// Unix 系统目录(路径首段为这些即拒;Windows 上也防 Unix 风格绝对路径,防御性)
if i == 0 && matches!(*seg, "etc" | "usr" | "bin" | "sbin" | "boot" | "dev" | "proc" | "sys") {
@@ -456,7 +468,25 @@ pub fn check_path_authorization(
PathAuthDecision::NeedsAuthorization { dir }
}
/// Windows canonicalize 返回 `\\?\E:\...` 扩展路径(verbatim 前缀),与词法层(不 canonicalize)
/// 的 starts_with 比对不一致 → 白名单含但工具路径不匹配 → 误弹窗。strip 前缀统一为 `E:\...`。
fn strip_verbatim(p: PathBuf) -> PathBuf {
let s = p.to_string_lossy().to_string();
// Windows verbatim/设备路径前缀:\\?\ (Volume canonicalize)、\\.\ (设备命名空间)、
// \??\ (对象管理器命名空间)。统一 strip 后与词法层 starts_with 比对一致。
for prefix in [r"\\?\", r"\\.\", r"\??\"] {
if let Some(stripped) = s.strip_prefix(prefix) {
return PathBuf::from(stripped);
}
}
p
}
/// workspace 根目录(src-tauri 上两级,与 tool_registry::workspace_root 同源)。
///
/// ⚠️ 已知限制:env!("CARGO_MANIFEST_DIR") 是编译期写死编译机源码路径,打包分发后用户机器
/// 无此路径 → workspace_root 失效。运行期动态方案(current_exe/用户项目绑定)见待决策.md
/// (workspace_root 分发适配)。当前仅开发机自用有效。
fn workspace_root_path() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
@@ -554,39 +584,52 @@ impl AppState {
/// 每条路径尝试 canonicalize 规范化(防大小写/分隔符差异绕过);canonicalize 失败
/// (目录不存在)回退原字面量 trim(写入后再校验场景:先授权目录路径,目录暂不存在)。
pub async fn reload_allowed_dirs(&self) {
let raw = match self.settings.get(AllowedDirs::SETTINGS_KEY).await {
Ok(Some(v)) => v,
Ok(None) => {
// 未配置:保持 default_with_root(is_authorized 始终放行 workspace_root)
return;
}
// F-260619-03: 白名单 = Settings KV allowed_dirs(用户配)+ projects.bind_directory(项目绑定目录自动授权)。
// 项目绑定目录 = AI 天然可访问(用户已主动绑定),不应重复弹窗。reload 时自动合并入 persistent。
let kv_dirs: Vec<String> = match self.settings.get(AllowedDirs::SETTINGS_KEY).await {
Ok(Some(v)) => serde_json::from_str(&v).unwrap_or_default(),
Ok(None) => Vec::new(),
Err(e) => {
tracing::warn!("[F-03A] 读取 allowed_dirs 失败,保持默认: {}", e);
return;
tracing::warn!("[F-03A] 读取 allowed_dirs 失败: {}", e);
Vec::new()
}
};
let dirs: Vec<String> = match serde_json::from_str(&raw) {
Ok(v) => v,
Err(e) => {
tracing::warn!("[F-03A] allowed_dirs 非 JSON 字符串数组,保持默认: {}", e);
return;
// 读 projects.bind_directory(所有项目的绑定目录,自动白名单)
let project_dirs: Vec<String> = {
let repo = df_storage::crud::ProjectRepo::new(&self.db);
match repo.list_all().await {
Ok(projects) => projects.iter()
.filter_map(|p| p.path.as_ref().filter(|d| !d.is_empty()).map(|d| d.clone()))
.collect(),
Err(e) => {
tracing::warn!("[F-03B] 读取项目绑定目录失败: {}", e);
Vec::new()
}
}
};
// 合并:KV allowed_dirs + projects.bind_directory
let mut all_dirs = kv_dirs;
all_dirs.extend(project_dirs);
// 去重
all_dirs.sort();
all_dirs.dedup();
let mut set = HashSet::new();
// F-260619-03 收尾:KV 有配时尊重用户 persistent(不无条件塞 workspace_root)
// 用户 KV 配置不含 workspace_root → 工程根也需授权(动态白名单完整语义,
// 兑现 commit 声称"用户删白名单后需授权")。仅 KV 未配时 default_with_root 含 workspace_root。
for d in dirs {
// KV 未配(空)时保留 workspace_root(向后兼容:默认根免授权)
if all_dirs.is_empty() {
set.insert(workspace_root_path());
}
for d in all_dirs {
let d = d.trim();
if d.is_empty() {
continue;
}
let p = PathBuf::from(d);
// canonicalize 成功用真实路径(去 symlink/大小写归一);失败回退原字面量
let normalized = std::fs::canonicalize(&p).unwrap_or_else(|_| {
// 规范化分隔符 trim 尾部,保持与 starts_with 比对一致
// canonicalize 成功用真实路径(去 symlink/大小写归一);失败回退原字面量 trim
// (兼容"先授权目录,目录暂不存在"用例)。失败打 warn 便于排查静默授权错路径。
let normalized = strip_verbatim(std::fs::canonicalize(&p).unwrap_or_else(|_| {
tracing::warn!("[allowed_dirs] canonicalize 失败(目录可能不存在),按字面量保存: {}", d);
PathBuf::from(d.trim_end_matches(['/', '\\']))
});
}));
set.insert(normalized);
}
// F-260619-03 Phase B: reload 时保留当前会话临时授权(session 不落库,仅内存),
@@ -602,23 +645,28 @@ impl AppState {
/// - 内存:reload_allowed_dirs 重新加载(规范化逻辑复用,避免双份)
/// 返回持久化后的规范化路径列表(供前端回显 canonicalize 后的真实路径)。
pub async fn set_allowed_dirs(&self, dirs: Vec<String>) -> Result<Vec<String>> {
// 去空 + 去重(保留顺序,前端展示友好)
// 去空 + 去重(保留顺序,前端展示友好)+ 黑名单预校验(防持久化系统敏感目录,
// 纵深防御:即便写入,is_authorized 运行时黑名单也兜底拒,预校验保证白名单 UI 洁净)
let mut seen = HashSet::new();
let cleaned: Vec<String> = dirs
.into_iter()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.filter(|s| seen.insert(s.clone()))
.filter(|s| !is_in_system_blacklist(&PathBuf::from(s)))
.collect();
let json = serde_json::to_string(&cleaned)?;
self.settings.set(AllowedDirs::SETTINGS_KEY, &json).await
.map_err(|e| anyhow::anyhow!("持久化 allowed_dirs 失败: {}", e))?;
self.reload_allowed_dirs().await;
// 返回内存白名单(含 workspace_root)的规范化字符串列表(前端可看到真实生效路径)
// 返回内存白名单的规范化字符串列表(过滤 workspace_root,与 get_allowed_dirs 一致:
// 内部根不暴露前端,前端回显只含用户显式配置的目录)
let root = workspace_root_path();
let guard = self.allowed_dirs.read().await;
let mut out: Vec<String> = guard
.persistent
.iter()
.filter(|p| **p != root)
.map(|p| p.to_string_lossy().to_string())
.collect();
out.sort();
@@ -627,10 +675,14 @@ impl AppState {
/// F-260619-03 Phase A: 读内存白名单为字符串列表(供 Settings IPC `ai_get_allowed_dirs` 回显)。
pub async fn get_allowed_dirs(&self) -> Vec<String> {
// 不返回内部 workspace_root(隐式授权,is_authorized 经 persistent 放行):
// 避免把内部根目录暴露到前端用户管理列表。前后端均不再各自猜测 root 形态。
let root = workspace_root_path();
let guard = self.allowed_dirs.read().await;
let mut out: Vec<String> = guard
.persistent
.iter()
.filter(|p| **p != root)
.map(|p| p.to_string_lossy().to_string())
.collect();
out.sort();
@@ -664,9 +716,9 @@ impl AppState {
return;
}
let p = PathBuf::from(d);
let normalized = std::fs::canonicalize(&p).unwrap_or_else(|_| {
let normalized = strip_verbatim(std::fs::canonicalize(&p).unwrap_or_else(|_| {
PathBuf::from(d.trim_end_matches(['/', '\\']))
});
}));
self.allowed_dirs.write().await.session.insert(normalized);
}
@@ -903,5 +955,52 @@ mod tests {
other => panic!("System32 应 Denied, got {:?}", other),
}
}
// ============================================================
// F-260620: strip_verbatim 比对侧收口 + 黑名单增强测试
// 三方审查(安全/UX/跨端)交叉印证:strip_verbatim 仅写入侧调用,比对侧遗漏致误弹窗。
// ============================================================
/// F-260620: is_authorized 比对侧 strip_verbatim — candidate 带 \\?\ 前缀也命中白名单
/// (handler canonicalize 后路径 vs persistent 词法路径,形态不一致曾致误弹窗)。
/// persistent 用动态 workspace_root_path(非硬编码开发机路径),candidate 拼 verbatim 前缀。
#[test]
fn test_is_authorized_strips_verbatim_prefix() {
let allowed = AllowedDirs::default_with_root();
let child = workspace_root_path().join("src").join("main.rs");
// candidate 带 verbatim 前缀(handler canonicalize 后形态)应命中白名单
let verbatim = PathBuf::from(format!("{}{}", r"\\?\", child.to_string_lossy()));
assert!(allowed.is_authorized(&verbatim), "verbatim 前缀路径应命中白名单");
// 设备命名空间前缀(\\.\)
let dev = PathBuf::from(format!("{}{}", r"\\.\", child.to_string_lossy()));
assert!(allowed.is_authorized(&dev), "设备命名空间前缀路径应命中白名单");
}
/// F-260620: strip_verbatim 三前缀(\\?\ / \\.\ / \??\)统一去除
#[test]
fn test_strip_verbatim_three_prefixes() {
assert_eq!(strip_verbatim(PathBuf::from(r"\\?\E:\foo")), PathBuf::from("E:\\foo"));
assert_eq!(strip_verbatim(PathBuf::from(r"\\.\E:\foo")), PathBuf::from("E:\\foo"));
assert_eq!(strip_verbatim(PathBuf::from(r"\??\E:\foo")), PathBuf::from("E:\\foo"));
// 无前缀原样返回
assert_eq!(strip_verbatim(PathBuf::from("E:\\foo")), PathBuf::from("E:\\foo"));
}
/// F-260620: 黑名单增强 — Windows 根本身 / ProgramData / 用户凭据目录(.ssh/.aws/.gnupg)
#[test]
fn test_blacklist_windows_root_programdata_creds() {
// C:\Windows 根本身(不再限定 system32 子目录 — 防 win.ini/hosts 等)
assert!(is_in_system_blacklist(&PathBuf::from("C:\\Windows\\win.ini")));
assert!(is_in_system_blacklist(&PathBuf::from("C:\\Windows\\explorer.exe")));
// ProgramData
assert!(is_in_system_blacklist(&PathBuf::from("C:\\ProgramData\\App\\config")));
// 用户凭据目录(从 validate_path 迁移统一,任意路径段命中即拒)
assert!(is_in_system_blacklist(&PathBuf::from("C:\\Users\\user\\.ssh\\id_rsa")));
assert!(is_in_system_blacklist(&PathBuf::from("/home/user/.aws/credentials")));
assert!(is_in_system_blacklist(&PathBuf::from("/home/user/.gnupg/pubring.gpg")));
// 普通目录不误伤(devflow 自身 / 含 appdata 段的合法备份目录)
assert!(!is_in_system_blacklist(&PathBuf::from("E:\\wk-lab\\devflow\\src")));
assert!(!is_in_system_blacklist(&PathBuf::from("D:\\backup\\appdata\\data")));
}
}

View File

@@ -13,7 +13,7 @@
<template v-else>
<div v-for="(dir, idx) in dirs" :key="dir + idx" class="dir-row">
<span class="dir-path" :title="dir">{{ dir }}</span>
<button class="btn btn-ghost btn-sm" @click="removeDir(idx)">
<button class="btn btn-ghost btn-sm" :disabled="saving" @click="removeDir(idx)">
{{ $t('common.delete') }}
</button>
</div>
@@ -26,19 +26,16 @@
v-model="newDir"
class="setting-select dir-input"
:placeholder="$t('settings.allowedDirsInputPlaceholder')"
@keyup.enter="addDir"
@keyup.enter="onAddClick"
/>
<button class="btn btn-primary btn-sm" :disabled="!newDir.trim()" @click="addDir">
{{ $t('settings.allowedDirsAdd') }}
<button class="btn btn-primary btn-sm" :disabled="saving" @click="onAddClick">
{{ newDir.trim() ? $t('settings.allowedDirsAdd') : $t('settings.allowedDirsSelectDir') }}
</button>
</div>
<!-- 操作 -->
<div class="actions">
<button class="btn btn-primary btn-sm" :disabled="saving" @click="save">
{{ $t('settings.allowedDirsSave') }}
</button>
<button class="btn btn-ghost btn-sm" :disabled="saving" @click="reload">
<button class="btn btn-ghost btn-sm" :disabled="loading" @click="reload">
{{ $t('settings.allowedDirsReload') }}
</button>
</div>
@@ -48,10 +45,19 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { open } from '@tauri-apps/plugin-dialog'
import { useI18n } from 'vue-i18n'
import { aiApi } from '@/api'
const { t } = useI18n()
// F-260620: 接共享 toast 通道(Settings.vue showToast),替代 console.error/warn 用户零反馈
const emit = defineEmits<{ (e: 'toast', msg: string, type: 'error' | 'warning' | 'info'): void }>()
// F-260619-03 Phase A: AI 工具文件访问授权目录白名单 UI
// 后端 workspace_root 始终隐式在白名单(is_authorized 内置),前端只管理用户额外授权目录。
// 即时持久化:添加/删除立即写后端 KV + 内存白名单,无需显式"保存"(符合"操作即生效"心智,
// 避免暂存+刷新丢失的认知错配)。dirs 始终由后端 setAllowedDirs 返回值回填(canonicalize +
// strip_verbatim 规范化后的真实路径),保证前后端读写一致。workspace_root 由后端 get 返回时
// 过滤(内部隐式授权,不进用户管理列表),前端原样展示不再猜测 root 形态。
const dirs = ref<string[]>([])
const newDir = ref('')
const loading = ref(true)
@@ -60,47 +66,69 @@ const saving = ref(false)
async function load() {
loading.value = true
try {
const all = await aiApi.getAllowedDirs()
// 后端返回的列表含 workspace_root(隐式授权),前端展示时过滤掉以减少混淆
// (workspace_root 始终授权无需用户管理)。保留其它用户授权目录。
dirs.value = all.filter(d => !isWorkspaceRoot(d))
dirs.value = await aiApi.getAllowedDirs()
} catch (e) {
console.error('加载授权目录失败:', e)
emit('toast', t('settings.allowedDirsLoadFail'), 'error')
dirs.value = []
} finally {
loading.value = false
}
}
// 粗略判定 workspace_root(src-tauri 上两级,即项目根):canonicalize 后路径以
// src-tauri 结尾。Windows canonicalize 带 \\?\ 前缀,此处宽松匹配防大小写/分隔符差异。
function isWorkspaceRoot(p: string): boolean {
const lower = p.toLowerCase().replace(/\\/g, '/')
return lower.endsWith('/src-tauri')
// 单按钮双态:newDir 空 → 选目录(Tauri dialog::open 填入 newDir);有值 → 提交白名单
async function onAddClick() {
if (!newDir.value.trim()) {
await pickDir()
return
}
await addDir()
}
function addDir() {
async function pickDir() {
try {
const selected = await open({ directory: true, multiple: false })
if (!selected || Array.isArray(selected)) return
newDir.value = selected as string
// F-260620: 选完即加(消除选目录后需二次点"添加"的摩擦)
await addDir()
} catch (e) {
console.error('选择目录失败:', e)
}
}
// 添加:本地大小写不敏感去重预检(减少无效 IPC)+ 即时持久化。
// dirs 由 setAllowedDirs 返回值回填,保证与后端规范化形态一致(读写不再漂移)。
async function addDir() {
const d = newDir.value.trim()
if (!d) return
if (dirs.value.some(x => x.toLowerCase() === d.toLowerCase())) {
emit('toast', t('settings.allowedDirsDup'), 'warning')
newDir.value = ''
return
}
dirs.value.push(d)
newDir.value = ''
}
function removeDir(idx: number) {
dirs.value.splice(idx, 1)
}
async function save() {
saving.value = true
try {
const normalized = await aiApi.setAllowedDirs(dirs.value)
dirs.value = normalized.filter(d => !isWorkspaceRoot(d))
const next = [...dirs.value, d]
dirs.value = await aiApi.setAllowedDirs(next)
newDir.value = ''
} catch (e) {
console.error('保存授权目录失败:', e)
console.error('添加授权目录失败:', e)
emit('toast', t('settings.allowedDirsAddFail'), 'error')
} finally {
saving.value = false
}
}
// 删除:即时持久化,dirs 由后端返回值回填(与添加对称,删除也立即生效)。
async function removeDir(idx: number) {
const next = dirs.value.filter((_, i) => i !== idx)
saving.value = true
try {
dirs.value = await aiApi.setAllowedDirs(next)
} catch (e) {
console.error('删除授权目录失败:', e)
emit('toast', t('settings.allowedDirsRemoveFail'), 'error')
} finally {
saving.value = false
}
@@ -158,6 +186,7 @@ onMounted(load)
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-ghost { background: transparent; color: var(--df-text-secondary); border: 0.5px solid var(--df-border); }
.btn-ghost:hover { background: var(--df-bg-card); color: var(--df-text); }
.btn-ghost:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-sm { padding: 4px 12px; font-size: 12px; }
.setting-select {

View File

@@ -111,13 +111,18 @@ export default {
// ===== F-260619-03 Phase A: AI tool allowed dirs panel =====
panelAllowedDirs: '📁 AI Tool Allowed Dirs',
descAllowedDirs: 'AI file tools (read/write/list/patch/search) can only access the following authorized directories. The project root (workspace_root) is always implicitly authorized and need not be added. Access to directories not in the list will be rejected.',
descAllowedDirs: 'AI file tools (read/write/list/patch/search) can only access the following authorized directories. The project root (devflow itself) is always implicitly authorized and need not be added. Access to directories not in the list will be rejected.',
allowedDirsLoading: 'Loading…',
allowedDirsEmpty: 'No extra authorized directories (only project root is authorized by default)',
allowedDirsInputPlaceholder: 'Enter absolute path, e.g. E:/wk-lab/u-abc',
allowedDirsSelectDir: 'Select Dir',
allowedDirsAdd: 'Add',
allowedDirsSave: 'Save',
allowedDirsReload: 'Reload',
allowedDirsLoadFail: 'Failed to load authorized directories',
allowedDirsDup: 'This directory is already in the authorized list',
allowedDirsAddFail: 'Failed to add authorized directory',
allowedDirsRemoveFail: 'Failed to remove authorized directory',
// ===== Knowledge base panel =====
panelKnowledge: '📚 Knowledge base',

View File

@@ -111,13 +111,18 @@ export default {
// ===== F-260619-03 Phase A: AI 工具授权目录面板 =====
panelAllowedDirs: '📁 AI 工具授权目录',
descAllowedDirs: 'AI 文件工具(读/写/列/改/搜等)仅可访问以下授权目录。项目根目录(workspace_root)始终隐式授权,无需在此添加。未在列表内的目录访问将被拒绝。',
descAllowedDirs: 'AI 文件工具(读/写/列/改/搜等)仅可访问以下授权目录。项目根目录(devflow 自身)始终隐式授权,无需在此添加。未在列表内的目录访问将被拒绝。',
allowedDirsLoading: '加载中…',
allowedDirsEmpty: '暂无额外授权目录(仅项目根目录默认授权)',
allowedDirsInputPlaceholder: '输入授权目录绝对路径,如 E:/wk-lab/u-abc',
allowedDirsSelectDir: '选择目录',
allowedDirsAdd: '添加',
allowedDirsSave: '保存',
allowedDirsReload: '重载',
allowedDirsLoadFail: '加载授权目录失败',
allowedDirsDup: '该目录已在授权列表中',
allowedDirsAddFail: '添加授权目录失败',
allowedDirsRemoveFail: '删除授权目录失败',
// ===== 知识库面板 =====
panelKnowledge: '📚 知识库',

View File

@@ -42,7 +42,7 @@
<KnowledgePanel :ai-providers="aiProviders" />
<!-- F-260619-03 Phase A: AI 工具授权目录(白名单持久化) -->
<AllowedDirsPanel />
<AllowedDirsPanel @toast="showToast" />
</div>
</div>
</template>