新增: 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:
@@ -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) => {
|
||||
|
||||
168
src/components/settings/AllowedDirsPanel.vue
Normal file
168
src/components/settings/AllowedDirsPanel.vue
Normal 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>
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '自动提炼知识',
|
||||
|
||||
@@ -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'
|
||||
|
||||
// ============================================================
|
||||
|
||||
Reference in New Issue
Block a user