新增: AI工具授权目录总开关(信任所有目录)

- path_auth判定加bypass旁路:开启时白名单外目录免逐次询问直接放行
- 黑名单凭据目录保护不随开关放行(安全硬边界保留)
- 设置页授权目录面板加开关(默认关,读取失败按关处理)
- 对话区开启时常驻放行提示横幅防静默裸奔,中英i18n齐
This commit is contained in:
lxy
2026-09-29 14:59:02 +08:00
parent 0168617503
commit 8a1675b58c
8 changed files with 114 additions and 5 deletions
+18 -5
View File
@@ -28,7 +28,7 @@ mod diff;
// path_auth(audit/path_auth.rs):路径授权预校验。
// 第六批从本文件抽离,行为零变更。pub(super) use 供 tests 子模块引用 + process_tool_calls 裸名调用。
mod path_auth;
pub(super) use path_auth::{check_file_tool_auth, FileToolAuthOutcome};
pub(super) use path_auth::{check_file_tool_auth, FileToolAuthOutcome, PATH_AUTH_BYPASS_KEY};
// approval(audit/approval.rs):审批门控逻辑。
// 第六批从本文件抽离,行为零变更。use 保持 process_tool_calls 裸名调用。
@@ -325,6 +325,19 @@ pub(crate) async fn process_tool_calls(
.allowed_dirs
.clone();
let allowed_guard = allowed_snapshot.read().await.clone();
// 「信任所有目录」总开关(KV df-ai-path-auth-bypass,默认关):开启后白名单外路径不再挂起
// 授权弹窗(NeedsAuth→直接放行),黑名单 Denied 仍硬拒(凭据目录自保护不受影响)。
// 读取失败按关处理(fail-safe,默认行为不变);前端设置页改后下一轮 process_tool_calls 即生效。
let path_auth_bypass = app_handle
.state::<crate::state::AppState>()
.settings
.get(PATH_AUTH_BYPASS_KEY)
.await
.ok()
.flatten()
// 前端 appSettings.set 以 JSON.stringify 落库(带引号),归一后比对(同 df-ai-trace-approve 先例)
.map(|v| { let v = v.trim_matches('"'); v == "true" || v == "1" })
.unwrap_or(false);
// 收集 NeedsAuth/Denied drafts(从 drafts 移除,不进下方 RiskLevel 循环)
let mut path_auth_pending: Vec<(ToolCallDraft, serde_json::Value, PathAuthRequest)> = Vec::new();
let mut path_denied: Vec<(ToolCallDraft, String)> = Vec::new();
@@ -332,7 +345,7 @@ pub(crate) async fn process_tool_calls(
for (idx, draft, args) in drafts {
// 会话级临时授权目录在 allowed_guard.session 内(进程级,随 active 会话切换清空),
// handler 闭包 read lock 与此处预校验读同一字段,两端授权判定一致。
match check_file_tool_auth(&draft.name, &args, &allowed_guard) {
match check_file_tool_auth(&draft.name, &args, &allowed_guard, path_auth_bypass) {
FileToolAuthOutcome::Authorized => authorized_drafts.push((idx, draft, args)),
FileToolAuthOutcome::NeedsAuth(req) => {
// all 档全自动(用户拍板):路径授权弹窗也不打断,与 RiskLevel 门同语义。
@@ -875,7 +888,7 @@ mod tests {
let allowed = crate::state::AllowedDirs::default();
let args = serde_json::json!({ "pattern": "foo", "path": "C:/some/unauthorized/dir" });
let outcome = check_file_tool_auth("grep", &args, &allowed);
let outcome = check_file_tool_auth("grep", &args, &allowed, false);
match outcome {
FileToolAuthOutcome::NeedsAuth(_) => { /* 期望:grep 触发授权申请 */ }
FileToolAuthOutcome::Authorized => panic!("grep 未授权应返 NeedsAuth(申请授权),实际 Authorized"),
@@ -883,7 +896,7 @@ mod tests {
}
// 对照:search_files 同样未授权但被硬拒(Denied),锁定两工具差异
let search_outcome = check_file_tool_auth("search_files", &args, &allowed);
let search_outcome = check_file_tool_auth("search_files", &args, &allowed, false);
match search_outcome {
FileToolAuthOutcome::Denied(_) => { /* 期望:search_files 盲拒 */ }
FileToolAuthOutcome::NeedsAuth(_) => panic!("search_files 未授权应返 Denied(盲拒),实际 NeedsAuth"),
@@ -897,7 +910,7 @@ mod tests {
let allowed = crate::state::AllowedDirs::default_with_root();
// .ssh 命中系统黑名单(is_in_system_blacklist)
let args = serde_json::json!({ "pattern": "foo", "path": "/home/user/.ssh/config" });
let outcome = check_file_tool_auth("grep", &args, &allowed);
let outcome = check_file_tool_auth("grep", &args, &allowed, false);
match outcome {
FileToolAuthOutcome::Denied(_) => { /* 期望:黑名单硬拒 */ }
FileToolAuthOutcome::NeedsAuth(_) => panic!("grep 黑名单路径应返 Denied,实际 NeedsAuth"),
@@ -10,6 +10,10 @@
use super::PathAuthRequest;
/// 「信任所有目录」总开关 KV key(app_settings,bool,默认关)。
/// 开启后白名单外路径不再挂起授权弹窗,黑名单仍硬拒。前端设置页读写同一 key。
pub(crate) const PATH_AUTH_BYPASS_KEY: &str = "df-ai-path-auth-bypass";
/// 提取文件工具的路径参数(用于路径授权预校验)。
///
/// 仅对走 resolve_workspace_path 校验的文件工具返回路径;非文件工具返回空 Vec(不预校验)。
@@ -53,10 +57,15 @@ pub(crate) enum FileToolAuthOutcome {
/// - 路径任一未命中白名单(persistent + session_dirs)且非黑名单 → `NeedsAuth`:
/// 附带 PathAuthRequest(规范化父目录 + 原始路径列表),供挂起弹窗
/// - 全部已授权 → `Authorized`:走原 Low/Med/High 流程
/// - `bypass=true`(「信任所有目录」总开关):非黑名单的未授权目录不再产生 NeedsAuth,
/// 直接 `Authorized`(search_files 的盲拒同为未授权场景,一并放行);黑名单 Denied 不受影响
/// (凭据目录自保护是安全硬边界)。bypass 由调用方 process_tool_calls 读 KV 后传入
/// (本函数同步签名,不自取异步 settings)。
pub(crate) fn check_file_tool_auth(
tool_name: &str,
args: &serde_json::Value,
allowed: &crate::state::AllowedDirs,
bypass: bool,
) -> FileToolAuthOutcome {
use crate::state::{check_path_authorization, PathAuthDecision};
let paths = extract_file_tool_paths(tool_name, args);
@@ -71,6 +80,10 @@ pub(crate) fn check_file_tool_auth(
match check_path_authorization(p, allowed) {
PathAuthDecision::Denied { reason } => return FileToolAuthOutcome::Denied(reason),
PathAuthDecision::NeedsAuthorization { dir } => {
// 总开关开启:非黑名单的未授权目录直接放行(黑名单已在上一分支短路硬拒)
if bypass {
continue;
}
if !pending_dirs.contains(&dir) {
pending_dirs.push(dir);
}
@@ -92,3 +105,48 @@ pub(crate) fn check_file_tool_auth(
FileToolAuthOutcome::NeedsAuth(PathAuthRequest { dirs: pending_dirs, raw_paths: paths })
}
}
#[cfg(test)]
mod tests {
use super::*;
/// bypass 开:白名单外路径 NeedsAuth → 直接 Authorized;bypass 关:行为不变(仍 NeedsAuth)。
#[test]
fn test_bypass_on_needs_auth_becomes_authorized() {
let allowed = crate::state::AllowedDirs::default();
let args = serde_json::json!({ "path": "C:/some/unauthorized/dir" });
// 关(基线,行为不变):NeedsAuth
match check_file_tool_auth("read_file", &args, &allowed, false) {
FileToolAuthOutcome::NeedsAuth(_) => {}
_ => panic!("bypass 关时未授权路径应仍返 NeedsAuth"),
}
// 开:直接 Authorized,不挂起
match check_file_tool_auth("read_file", &args, &allowed, true) {
FileToolAuthOutcome::Authorized => {}
_ => panic!("bypass 开时未授权路径应直接 Authorized"),
}
}
/// bypass 开:黑名单仍 Denied(凭据目录自保护硬边界不受总开关影响)。
#[test]
fn test_bypass_on_blacklist_still_denied() {
let allowed = crate::state::AllowedDirs::default_with_root();
// .ssh 命中系统黑名单(is_in_system_blacklist)
let args = serde_json::json!({ "path": "/home/user/.ssh/config" });
match check_file_tool_auth("read_file", &args, &allowed, true) {
FileToolAuthOutcome::Denied(_) => {}
_ => panic!("bypass 开时黑名单路径应仍 Denied"),
}
}
/// bypass 关:黑名单行为不变(Denied 基线,与改造前一致)。
#[test]
fn test_bypass_off_blacklist_still_denied() {
let allowed = crate::state::AllowedDirs::default_with_root();
let args = serde_json::json!({ "path": "/home/user/.ssh/config" });
match check_file_tool_auth("read_file", &args, &allowed, false) {
FileToolAuthOutcome::Denied(_) => {}
_ => panic!("bypass 关时黑名单路径应 Denied(行为不变)"),
}
}
}
+16
View File
@@ -29,6 +29,10 @@
@provider-switched="(name: string) => showToast(t('aiChat.providerSwitched', { model: name }), 'info')"
/>
<!-- 「信任所有目录」放行横幅:开关开启期间常驻提示(设置-安全-AI 工具授权目录),
提醒当前 AI 可直接访问任意非受保护目录;黑名单目录仍被拒绝 -->
<div v-if="pathAuthBypass" class="ai-auth-bypass-banner">{{ $t('aiChat.pathAuthBypassBanner') }}</div>
<!-- 第四批抽离至 ai/MessageList.vue(消息列表 + 流式 Markdown 分块渲染 + 工具卡转发 +
折叠归档段 + 滚动跟随/回底部,零行为变更,store/useMarkdown 单例共享) -->
<MessageList
@@ -161,6 +165,8 @@ const router = useRouter()
// 关(false,兜底回退):挂 DirAuthDialog 独立弹窗老链路(useAiEvents AiDirAuthRequired case 走 pendingDirAuths)。
const appSettings = useAppSettingsStore()
const unifiedApproval = appSettings.useSetting<boolean>('df-ai-unified-approval', true)
// 「信任所有目录」总开关(与设置页 AllowedDirsPanel 同一 KV):开启时对话区顶部常驻放行横幅
const pathAuthBypass = appSettings.useSetting<boolean>('df-ai-path-auth-bypass', false)
// ── 空状态示例问题随 MessageList 子组件迁移(examplePrompts 数组在子组件内);
// 父仅保留 sendExamplePrompt 转发(子 emit 'send-example-prompt' → 父委托 ChatInput.sendExamplePrompt)。──
@@ -683,6 +689,16 @@ async function initContextEventListeners(): Promise<void> {
TopBar.vue scoped 自持 + .ai-btn-icon* 抽 components.css 全局共享,
原 AiChat scoped 不渗透子组件根,删此重复块零视觉变更) */
/* ── 「信任所有目录」放行横幅(开关开启期间常驻,warning 色系对齐 .ai-toast-warning) ── */
.ai-auth-bypass-banner {
padding: 5px 14px;
background: var(--df-warning-bg);
color: var(--df-warning);
border-bottom: 0.5px solid color-mix(in srgb, var(--df-warning) 40%, transparent);
font-size: 12px;
user-select: none;
}
/* ═══ Input ═══ */
.ai-input-area {
padding: 10px 14px;
@@ -8,6 +8,15 @@
<!-- 说明 -->
<p class="allowed-dirs-desc">{{ $t('settings.descAllowedDirs') }}</p>
<!-- 「信任所有目录」总开关:开启后白名单外路径不再逐次弹授权(后端黑名单仍硬拒)。
走 appSettings KV(df-ai-path-auth-bypass),后端 process_tool_calls 每轮读取,改后即时生效。 -->
<SettingRow :label="$t('settings.labelPathAuthBypass')" :desc="$t('settings.descPathAuthBypass')">
<label class="toggle">
<input type="checkbox" v-model="pathAuthBypass" />
<span class="toggle-slider"></span>
</label>
</SettingRow>
<!-- 列表 -->
<div v-if="loading" class="empty-hint">{{ $t('settings.allowedDirsLoading') }}</div>
<template v-else>
@@ -99,6 +108,9 @@ const newDir = ref('')
const loading = ref(true)
const saving = ref(false)
// 「信任所有目录」总开关(useSetting 双向绑 KV,写走 debounced 落库,后端每轮工具执行前读)
const pathAuthBypass = appSettings.useSetting<boolean>('df-ai-path-auth-bypass', false)
async function load() {
loading.value = true
try {
+2
View File
@@ -189,6 +189,8 @@ export default {
dirAuthFailed: 'Authorization failed: {msg}',
// M5:path deny second confirmation (deny is irreversible — tool returns Err, loop continues without retry; asymmetry with approve, prevents mis-clicks)
dirAuthDenyConfirm: 'Deny authorization? The tool will fail immediately and will not be retried this round.',
// Persistent bypass banner shown at the top of the chat area while "trust all directories" is on
pathAuthBypassBanner: 'Authorization bypassed: AI can access files directly',
// ── Input Augmentation layer: mention/skill injection feedback ──
// search_files blind search denied by whitelist fallback (no auth dialog; prompt user to use @mention or absolute path)
+3
View File
@@ -192,6 +192,9 @@ export default {
allowedDirsDup: 'This directory is already in the authorized list',
allowedDirsAddFail: 'Failed to add authorized directory',
allowedDirsRemoveFail: 'Failed to remove authorized directory',
// "Trust all directories" master switch (Settings - Security - AI Tool Allowed Dirs panel)
labelPathAuthBypass: 'Trust all directories',
descPathAuthBypass: 'When enabled, AI can directly access any unprotected directory without per-access prompts; protected (blacklisted) directories such as credential folders are still denied.',
// ===== ScriptNode command execution safety =====
panelScriptSafety: '🛡️ Command Execution Safety',
+2
View File
@@ -190,6 +190,8 @@ export default {
dirAuthFailed: '授权失败:{msg}',
// M5:path 类拒绝二次确认(拒绝不可逆——工具返 Err、loop 续跑不再重试,与 approve 不对称,防误点)
dirAuthDenyConfirm: '确定拒绝授权吗?该工具将立即失败,且本轮不会重试。',
// 「信任所有目录」总开关开启期间的常驻放行横幅(对话区顶部)
pathAuthBypassBanner: '授权已放行:AI 可直接访问文件',
// ── Input Augmentation 层: mention/skill 注入反馈 ──
// search_files 盲搜被白名单兜底拒绝(不应弹授权窗,直接友好提示用户改用 @引用或绝对路径)
+3
View File
@@ -191,6 +191,9 @@ export default {
allowedDirsDup: '该目录已在授权列表中',
allowedDirsAddFail: '添加授权目录失败',
allowedDirsRemoveFail: '删除授权目录失败',
// 「信任所有目录」总开关(设置-安全-AI 工具授权目录面板)
labelPathAuthBypass: '信任所有目录',
descPathAuthBypass: '开启后 AI 可直接访问任意非受保护目录,不再逐次询问;凭据等受保护目录(黑名单)仍被拒绝。',
// ===== ScriptNode 命令执行安全 =====
panelScriptSafety: '🛡️ 命令执行安全',