diff --git a/src-tauri/src/commands/ai/audit/mod.rs b/src-tauri/src/commands/ai/audit/mod.rs index 2fda4da..4353c1d 100644 --- a/src-tauri/src/commands/ai/audit/mod.rs +++ b/src-tauri/src/commands/ai/audit/mod.rs @@ -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::() + .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"), diff --git a/src-tauri/src/commands/ai/audit/path_auth.rs b/src-tauri/src/commands/ai/audit/path_auth.rs index 74d6a2e..5dedd9c 100644 --- a/src-tauri/src/commands/ai/audit/path_auth.rs +++ b/src-tauri/src/commands/ai/audit/path_auth.rs @@ -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(行为不变)"), + } + } +} diff --git a/src/components/AiChat.vue b/src/components/AiChat.vue index 564f651..6e1e61c 100644 --- a/src/components/AiChat.vue +++ b/src/components/AiChat.vue @@ -29,6 +29,10 @@ @provider-switched="(name: string) => showToast(t('aiChat.providerSwitched', { model: name }), 'info')" /> + +
{{ $t('aiChat.pathAuthBypassBanner') }}
+ ('df-ai-unified-approval', true) +// 「信任所有目录」总开关(与设置页 AllowedDirsPanel 同一 KV):开启时对话区顶部常驻放行横幅 +const pathAuthBypass = appSettings.useSetting('df-ai-path-auth-bypass', false) // ── 空状态示例问题随 MessageList 子组件迁移(examplePrompts 数组在子组件内); // 父仅保留 sendExamplePrompt 转发(子 emit 'send-example-prompt' → 父委托 ChatInput.sendExamplePrompt)。── @@ -683,6 +689,16 @@ async function initContextEventListeners(): Promise { 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; diff --git a/src/components/settings/AllowedDirsPanel.vue b/src/components/settings/AllowedDirsPanel.vue index 5906957..aafdc3b 100644 --- a/src/components/settings/AllowedDirsPanel.vue +++ b/src/components/settings/AllowedDirsPanel.vue @@ -8,6 +8,15 @@

{{ $t('settings.descAllowedDirs') }}

+ + + + +
{{ $t('settings.allowedDirsLoading') }}