新增: F-260619-03 PhaseA AI工具文件访问动态权限白名单

- state.rs: AllowedDirs(persistent HashSet + is_authorized workspace_root+starts_with + canonicalize) + AppState allowed_dirs Arc + reload/set/get Settings KV
- tool_registry.rs: resolve_workspace_path 多目录白名单双校验(词法+canonicalize) + handler闭包引入AllowedDirs Arc(9文件工具) + run_command不走白名单
- commands/config.rs: ai_get/set_allowed_dirs IPC + lib.rs注册
- 前端: api/ai.ts + AllowedDirsPanel.vue(Settings授权目录UI) + i18n
- 测试: 5 AllowedDirs单测 + 基线适配(29工具不变)
workspace_root始终授权(零回归); Phase A仅持久化(不含B弹窗/C写约束)
主代兜底: cargo check devflow 0 + test 129 + vue-tsc 0
This commit is contained in:
2026-06-19 16:41:45 +08:00
parent ec1daa8844
commit 968c3df5ab
9 changed files with 633 additions and 106 deletions

View File

@@ -0,0 +1,168 @@
<template>
<!-- F-260619-03 Phase A: AI 工具授权目录 -->
<section class="panel">
<div class="panel-header">
<h2>{{ $t('settings.panelAllowedDirs') }}</h2>
</div>
<div class="allowed-dirs">
<!-- 说明 -->
<p class="allowed-dirs-desc">{{ $t('settings.descAllowedDirs') }}</p>
<!-- 列表 -->
<div v-if="loading" class="empty-hint">{{ $t('settings.allowedDirsLoading') }}</div>
<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)">
{{ $t('common.delete') }}
</button>
</div>
<div v-if="dirs.length === 0" class="empty-hint">{{ $t('settings.allowedDirsEmpty') }}</div>
</template>
<!-- 新增输入 -->
<div class="add-row">
<input
v-model="newDir"
class="setting-select dir-input"
:placeholder="$t('settings.allowedDirsInputPlaceholder')"
@keyup.enter="addDir"
/>
<button class="btn btn-primary btn-sm" :disabled="!newDir.trim()" @click="addDir">
{{ $t('settings.allowedDirsAdd') }}
</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">
{{ $t('settings.allowedDirsReload') }}
</button>
</div>
</div>
</section>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { aiApi } from '@/api'
// F-260619-03 Phase A: AI 工具文件访问授权目录白名单 UI
// 后端 workspace_root 始终隐式在白名单(is_authorized 内置),前端只管理用户额外授权目录。
const dirs = ref<string[]>([])
const newDir = ref('')
const loading = ref(true)
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))
} catch (e) {
console.error('加载授权目录失败:', e)
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')
}
function addDir() {
const d = newDir.value.trim()
if (!d) return
if (dirs.value.some(x => x.toLowerCase() === d.toLowerCase())) {
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))
} catch (e) {
console.error('保存授权目录失败:', e)
} finally {
saving.value = false
}
}
async function reload() {
await load()
}
onMounted(load)
</script>
<style scoped>
.panel {
background: var(--df-bg-card);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-lg);
padding: var(--df-pad-panel);
}
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--df-gap-head);
}
.panel-header h2 { font-size: 15px; font-weight: 500; }
.allowed-dirs { display: flex; flex-direction: column; gap: 8px; }
.allowed-dirs-desc {
font-size: 12px; color: var(--df-text-dim); margin: 0 0 4px; line-height: 1.5;
}
.dir-row {
display: flex; justify-content: space-between; align-items: center;
padding: 8px 12px;
background: var(--df-bg);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-sm);
}
.dir-path {
font-size: 13px; color: var(--df-text);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
flex: 1; margin-right: 12px;
}
.empty-hint { font-size: 12px; color: var(--df-text-dim); padding: 8px 0; }
.add-row { display: flex; gap: 8px; margin-top: 4px; }
.dir-input { flex: 1; min-width: 0; }
.actions { display: flex; gap: 8px; margin-top: 8px; }
.btn {
padding: 8px 16px; border: none; border-radius: var(--df-radius-sm);
font-size: 13px; cursor: pointer; transition: all 0.15s;
}
.btn-primary { background: var(--df-accent); color: #fff; }
.btn-primary:hover { background: var(--df-accent-hover); }
.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-sm { padding: 4px 12px; font-size: 12px; }
.setting-select {
padding: 6px 12px; background: var(--df-bg); border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-sm); color: var(--df-text); font-size: 13px; outline: none;
}
.setting-select:focus { border-color: var(--df-accent); }
</style>