优化: 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

@@ -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>