新增: 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

@@ -215,6 +215,16 @@ export const aiApi = {
return invoke('ai_list_skills')
},
/** F-260619-03 Phase A: 获取 AI 工具文件访问授权目录列表(含 workspace_root) */
getAllowedDirs(): Promise<string[]> {
return invoke<string[]>('ai_get_allowed_dirs')
},
/** F-260619-03 Phase A: 设置 AI 工具授权目录(持久化 + 同步内存白名单),返回规范化后的列表 */
setAllowedDirs(dirs: string[]): Promise<string[]> {
return invoke<string[]>('ai_set_allowed_dirs', { dirs })
},
/** 监听 AI 聊天事件 */
onEvent(callback: (event: AiChatEvent) => void): Promise<UnlistenFn> {
return listen<AiChatEvent>('ai-chat-event', (e) => {

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>

View File

@@ -109,6 +109,16 @@ export default {
labelAgentMaxRetries: 'Stream failure retries',
descAgentMaxRetries: 'Auto-retry count on stream failure (0-10, default 3); only retries pre-stream failures (no token output yet); mid-stream failures are abandoned',
// ===== 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.',
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',
allowedDirsAdd: 'Add',
allowedDirsSave: 'Save',
allowedDirsReload: 'Reload',
// ===== Knowledge base panel =====
panelKnowledge: '📚 Knowledge base',
labelAutoExtract: 'Auto-distill knowledge',

View File

@@ -109,6 +109,16 @@ export default {
labelAgentMaxRetries: '流式失败重试次数',
descAgentMaxRetries: '流式对话失败时自动重试次数0-10默认 3只重试未输出任何 token 的流前失败,已输出文本的失败直接放弃',
// ===== F-260619-03 Phase A: AI 工具授权目录面板 =====
panelAllowedDirs: '📁 AI 工具授权目录',
descAllowedDirs: 'AI 文件工具(读/写/列/改/搜等)仅可访问以下授权目录。项目根目录(workspace_root)始终隐式授权,无需在此添加。未在列表内的目录访问将被拒绝。',
allowedDirsLoading: '加载中…',
allowedDirsEmpty: '暂无额外授权目录(仅项目根目录默认授权)',
allowedDirsInputPlaceholder: '输入授权目录绝对路径,如 E:/wk-lab/u-abc',
allowedDirsAdd: '添加',
allowedDirsSave: '保存',
allowedDirsReload: '重载',
// ===== 知识库面板 =====
panelKnowledge: '📚 知识库',
labelAutoExtract: '自动提炼知识',

View File

@@ -40,6 +40,9 @@
<!-- 知识库配置(后端 IPC,embedding provider 依赖 ProviderPanel 列表) -->
<KnowledgePanel :ai-providers="aiProviders" />
<!-- F-260619-03 Phase A: AI 工具授权目录(白名单持久化) -->
<AllowedDirsPanel />
</div>
</div>
</template>
@@ -51,6 +54,7 @@ import ProviderPanel from '@/components/settings/ProviderPanel.vue'
import ConnectionPanel from '@/components/settings/ConnectionPanel.vue'
import GeneralPanel from '@/components/settings/GeneralPanel.vue'
import KnowledgePanel from '@/components/settings/KnowledgePanel.vue'
import AllowedDirsPanel from '@/components/settings/AllowedDirsPanel.vue'
import type { AiProviderConfig } from '@/api/types'
// ============================================================