新增: 信任所有目录扩展至敏感目录放行

开关语义升级为完全放行,含 .ssh/.aws 等黑名单目录(产品拍板体验优先,用户知情选择)
validate_path 与 is_authorized 双拦截点经进程级标志同源放行,audit 层每轮工具执行前刷新
设置页开启时二次确认,正文明示敏感凭据可进对话上下文风险自担;对话区常驻放行横幅既有
测试以串行闸防并行互踩进程级标志
This commit is contained in:
lxy
2026-10-01 02:40:49 +08:00
parent 63779e8d69
commit 3e239a108f
8 changed files with 204 additions and 25 deletions
+11 -2
View File
@@ -27,8 +27,14 @@ mod diff;
// path_auth(audit/path_auth.rs):路径授权预校验。 // path_auth(audit/path_auth.rs):路径授权预校验。
// 第六批从本文件抽离,行为零变更。pub(super) use 供 tests 子模块引用 + process_tool_calls 裸名调用。 // 第六批从本文件抽离,行为零变更。pub(super) use 供 tests 子模块引用 + process_tool_calls 裸名调用。
mod path_auth; // 模块提为 pub(crate):state 层 allowed_dirs 的 is_authorized 第三道门需直接读放行标志,私有模块路径段不可穿越
pub(crate) mod path_auth;
pub(super) use path_auth::{check_file_tool_auth, FileToolAuthOutcome, PATH_AUTH_BYPASS_KEY}; pub(super) use path_auth::{check_file_tool_auth, FileToolAuthOutcome, PATH_AUTH_BYPASS_KEY};
// 黑名单放行判据:tool_registry 的 validate_path 无 bypass 参数,经此跨模块读进程级标志
pub(super) use path_auth::is_blacklist_bypassed;
// 测试专用:改写标志验证 bypass 放行语义 + 串行闸,防依赖标志结果的测试并行互踩
#[cfg(test)]
pub(super) use path_auth::{set_blacklist_bypass, BLACKLIST_BYPASS_TEST_LOCK};
// approval(audit/approval.rs):审批门控逻辑。 // approval(audit/approval.rs):审批门控逻辑。
// 第六批从本文件抽离,行为零变更。use 保持 process_tool_calls 裸名调用。 // 第六批从本文件抽离,行为零变更。use 保持 process_tool_calls 裸名调用。
@@ -326,7 +332,7 @@ pub(crate) async fn process_tool_calls(
.clone(); .clone();
let allowed_guard = allowed_snapshot.read().await.clone(); let allowed_guard = allowed_snapshot.read().await.clone();
// 「信任所有目录」总开关(KV df-ai-path-auth-bypass,默认关):开启后白名单外路径不再挂起 // 「信任所有目录」总开关(KV df-ai-path-auth-bypass,默认关):开启后白名单外路径不再挂起
// 授权弹窗(NeedsAuth 时直接放行),黑名单 Denied 仍硬拒(凭据目录自保护不受影响)。 // 授权弹窗(NeedsAuth 时直接放行),黑名单拦截也一并放行(产品拍板:体验优先,含敏感目录)。
// 读取失败按关处理(fail-safe,默认行为不变);前端设置页改后下一轮 process_tool_calls 即生效。 // 读取失败按关处理(fail-safe,默认行为不变);前端设置页改后下一轮 process_tool_calls 即生效。
let path_auth_bypass = app_handle let path_auth_bypass = app_handle
.state::<crate::state::AppState>() .state::<crate::state::AppState>()
@@ -338,6 +344,9 @@ pub(crate) async fn process_tool_calls(
// 前端 appSettings.set 以 JSON.stringify 落库(带引号),归一后比对(同 df-ai-trace-approve 先例) // 前端 appSettings.set 以 JSON.stringify 落库(带引号),归一后比对(同 df-ai-trace-approve 先例)
.map(|v| { let v = v.trim_matches('"'); v == "true" || v == "1" }) .map(|v| { let v = v.trim_matches('"'); v == "true" || v == "1" })
.unwrap_or(false); .unwrap_or(false);
// 同步进程级黑名单放行标志:validate_path(tool_registry)无 bypass 参数,靠此标志放行黑名单;
// 每轮工具执行前刷新,与下方 check_file_tool_auth 的入参 bypass 同源同值,两处拦截点语义一致。
path_auth::set_blacklist_bypass(path_auth_bypass);
// 收集 NeedsAuth/Denied drafts(从 drafts 移除,不进下方 RiskLevel 循环) // 收集 NeedsAuth/Denied drafts(从 drafts 移除,不进下方 RiskLevel 循环)
let mut path_auth_pending: Vec<(ToolCallDraft, serde_json::Value, PathAuthRequest)> = Vec::new(); let mut path_auth_pending: Vec<(ToolCallDraft, serde_json::Value, PathAuthRequest)> = Vec::new();
let mut path_denied: Vec<(ToolCallDraft, String)> = Vec::new(); let mut path_denied: Vec<(ToolCallDraft, String)> = Vec::new();
+54 -15
View File
@@ -11,9 +11,32 @@
use super::PathAuthRequest; use super::PathAuthRequest;
/// 「信任所有目录」总开关 KV key(app_settings,bool,默认关)。 /// 「信任所有目录」总开关 KV key(app_settings,bool,默认关)。
/// 开启后白名单外路径不再挂起授权弹窗,黑名单仍硬拒。前端设置页读写同一 key。 /// 开启后白名单外路径不再挂起授权弹窗,黑名单拦截也一并放行(产品拍板:体验优先,
/// 用户知情选择,后期再收敛)。前端设置页读写同一 key。
pub(crate) const PATH_AUTH_BYPASS_KEY: &str = "df-ai-path-auth-bypass"; pub(crate) const PATH_AUTH_BYPASS_KEY: &str = "df-ai-path-auth-bypass";
/// 黑名单放行的进程级标志。validate_path(run_command 等)所在调用链无 bypass 参数,
/// 改签名需穿透全部调用方,故以进程级 AtomicBool 承载;真实来源是 KV PATH_AUTH_BYPASS_KEY,
/// 由 audit/mod.rs 每轮工具执行前读 KV 后经 set_blacklist_bypass 刷新,与本文件
/// check_file_tool_auth 的入参 bypass 同源同值,两处拦截点语义一致。
pub(crate) static PATH_AUTH_BYPASS_ACTIVE: std::sync::atomic::AtomicBool =
std::sync::atomic::AtomicBool::new(false);
/// 每轮工具执行前由 audit 层把 KV 值同步进进程级标志。
pub(crate) fn set_blacklist_bypass(on: bool) {
PATH_AUTH_BYPASS_ACTIVE.store(on, std::sync::atomic::Ordering::Relaxed);
}
/// validate_path 黑名单检查的放行判据(单写多读,Relaxed 序足够)。
pub(crate) fn is_blacklist_bypassed() -> bool {
PATH_AUTH_BYPASS_ACTIVE.load(std::sync::atomic::Ordering::Relaxed)
}
/// 测试串行闸:进程级标志全进程单例,并行测试同时改写标志或依赖其结果会互踩,
/// 凡改写标志或依赖标志结果的测试须持锁(仅测试隔离用,非生产路径)。
#[cfg(test)]
pub(crate) static BLACKLIST_BYPASS_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
/// 提取文件工具的路径参数(用于路径授权预校验)。 /// 提取文件工具的路径参数(用于路径授权预校验)。
/// ///
/// 仅对走 resolve_workspace_path 校验的文件工具返回路径;非文件工具返回空 Vec(不预校验)。 /// 仅对走 resolve_workspace_path 校验的文件工具返回路径;非文件工具返回空 Vec(不预校验)。
@@ -47,20 +70,21 @@ pub(crate) enum FileToolAuthOutcome {
Authorized, Authorized,
/// 未授权 → 附带 PathAuthRequest,供挂起弹窗 /// 未授权 → 附带 PathAuthRequest,供挂起弹窗
NeedsAuth(PathAuthRequest), NeedsAuth(PathAuthRequest),
/// 命中黑名单 → 硬拒 /// 黑名单命中(bypass 关)或 search_files 盲搜兜底时硬拒
Denied(String), Denied(String),
} }
/// 对单条文件工具调用做路径授权预校验,返回是否需挂起/拒绝/放行。 /// 对单条文件工具调用做路径授权预校验,返回是否需挂起/拒绝/放行。
/// ///
/// - 路径任一命中黑名单 → `Denied(reason)`:硬拒(工具返 Err tool_result,不挂起) /// - 路径任一命中黑名单且 bypass 关:`Denied(reason)` 硬拒(工具返 Err tool_result,不挂起)
/// - 路径任一未命中白名单(persistent + session_dirs)且非黑名单 → `NeedsAuth`: /// - 路径任一未命中白名单(persistent + session_dirs)且非黑名单 → `NeedsAuth`:
/// 附带 PathAuthRequest(规范化父目录 + 原始路径列表),供挂起弹窗 /// 附带 PathAuthRequest(规范化父目录 + 原始路径列表),供挂起弹窗
/// - 全部已授权 → `Authorized`:走原 Low/Med/High 流程 /// - 全部已授权 → `Authorized`:走原 Low/Med/High 流程
/// - `bypass=true`(「信任所有目录」总开关):非黑名单的未授权目录不再产生 NeedsAuth, /// - `bypass=true`(「信任所有目录」总开关):未授权目录不再产生 NeedsAuth,黑名单 Denied
/// 直接 `Authorized`(search_files 的盲拒同为未授权场景,一并放行);黑名单 Denied 不受影响 /// 也一并放行(产品拍板:开关语义为完全放行,含敏感目录,体验优先,search_files 盲拒
/// (凭据目录自保护是安全硬边界)。bypass 由调用方 process_tool_calls 读 KV 后传入 /// 随 NeedsAuth 消失一并解除)。bypass 由调用方 process_tool_calls 读 KV 后传入;
/// (本函数同步签名,不自取异步 settings)。 /// validate_path 无此参数才走全局标志 PATH_AUTH_BYPASS_ACTIVE(与入参同源等价,
/// audit 层每轮刷新保证两处拦截点一致)。本函数同步签名,不自取异步 settings。
pub(crate) fn check_file_tool_auth( pub(crate) fn check_file_tool_auth(
tool_name: &str, tool_name: &str,
args: &serde_json::Value, args: &serde_json::Value,
@@ -78,9 +102,16 @@ pub(crate) fn check_file_tool_auth(
let mut pending_dirs: Vec<std::path::PathBuf> = Vec::new(); let mut pending_dirs: Vec<std::path::PathBuf> = Vec::new();
for p in &paths { for p in &paths {
match check_path_authorization(p, allowed) { match check_path_authorization(p, allowed) {
PathAuthDecision::Denied { reason } => return FileToolAuthOutcome::Denied(reason), PathAuthDecision::Denied { reason } => {
// 总开关开启时黑名单一并放行(产品拍板:体验优先,用户知情选择);关闭时硬拒不变。
// 此处用入参 bypass;validate_path 无参才走全局标志,两者同源等价(audit 每轮刷同一 KV)。
if bypass {
continue;
}
return FileToolAuthOutcome::Denied(reason);
}
PathAuthDecision::NeedsAuthorization { dir } => { PathAuthDecision::NeedsAuthorization { dir } => {
// 总开关开启:非黑名单的未授权目录直接放行(黑名单已在上一分支短路硬拒) // 总开关开启:未授权目录直接放行(黑名单的放行在上一分支处理)
if bypass { if bypass {
continue; continue;
} }
@@ -113,6 +144,9 @@ mod tests {
/// bypass 开:白名单外路径 NeedsAuth 时直接 Authorized;bypass 关:行为不变(仍 NeedsAuth)。 /// bypass 开:白名单外路径 NeedsAuth 时直接 Authorized;bypass 关:行为不变(仍 NeedsAuth)。
#[test] #[test]
fn test_bypass_on_needs_auth_becomes_authorized() { fn test_bypass_on_needs_auth_becomes_authorized() {
// is_authorized 已接入全局 bypass 标志,本例基线断言(入参关)途经该读点,
// 须持串行闸,防与并行写标志的测试互踩致基线误得 Authorized。
let _guard = BLACKLIST_BYPASS_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let allowed = crate::state::AllowedDirs::default(); let allowed = crate::state::AllowedDirs::default();
let args = serde_json::json!({ "path": "C:/some/unauthorized/dir" }); let args = serde_json::json!({ "path": "C:/some/unauthorized/dir" });
// 关(基线,行为不变):NeedsAuth // 关(基线,行为不变):NeedsAuth
@@ -127,16 +161,21 @@ mod tests {
} }
} }
/// bypass 开:黑名单仍 Denied(凭据目录自保护硬边界不受总开关影响)。 /// bypass 开:黑名单也放行(语义反转:总开关=完全放行,含敏感目录)。
/// 进程级标志须持串行闸并在断言前复位(先取结果再复位再断言,防 panic 泄漏标志污染并行测试)。
#[test] #[test]
fn test_bypass_on_blacklist_still_denied() { fn test_bypass_on_blacklist_also_allowed() {
let _guard = BLACKLIST_BYPASS_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
set_blacklist_bypass(true);
let allowed = crate::state::AllowedDirs::default_with_root(); let allowed = crate::state::AllowedDirs::default_with_root();
// .ssh 命中系统黑名单(is_in_system_blacklist) // .ssh 命中系统黑名单(is_in_system_blacklist)
let args = serde_json::json!({ "path": "/home/user/.ssh/config" }); let args = serde_json::json!({ "path": "/home/user/.ssh/config" });
match check_file_tool_auth("read_file", &args, &allowed, true) { let outcome_allowed = matches!(
FileToolAuthOutcome::Denied(_) => {} check_file_tool_auth("read_file", &args, &allowed, true),
_ => panic!("bypass 开时黑名单路径应仍 Denied"), FileToolAuthOutcome::Authorized
} );
set_blacklist_bypass(false);
assert!(outcome_allowed, "bypass 开时黑名单路径应放行");
} }
/// bypass 关:黑名单行为不变(Denied 基线,与改造前一致)。 /// bypass 关:黑名单行为不变(Denied 基线,与改造前一致)。
@@ -206,11 +206,32 @@ mod tests {
#[test] #[test]
fn test_validate_path_rejects_sensitive_system_dirs() { fn test_validate_path_rejects_sensitive_system_dirs() {
// 依赖全局黑名单放行标志为关,持串行闸防与改写标志的并行测试互踩
let _guard = crate::commands::ai::audit::BLACKLIST_BYPASS_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
// .ssh / .aws / .gnupg 等敏感目录被拒(走 is_in_system_blacklist) // .ssh / .aws / .gnupg 等敏感目录被拒(走 is_in_system_blacklist)
assert!(validate_path("/root/.ssh/id_rsa").is_err()); assert!(validate_path("/root/.ssh/id_rsa").is_err());
assert!(validate_path("C:\\Users\\x\\.aws\\credentials").is_err()); assert!(validate_path("C:\\Users\\x\\.aws\\credentials").is_err());
} }
#[test]
fn test_validate_path_bypass_allows_sensitive_dirs() {
// 总开关开启:黑名单放行(validate_path 层),.. 遍历检查保留(路径合法性校验,非权限边界)。
// 先取结果再复位再断言,防 panic 泄漏标志污染其他测试。
let _guard = crate::commands::ai::audit::BLACKLIST_BYPASS_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
crate::commands::ai::audit::set_blacklist_bypass(true);
let ssh_ok = validate_path("/root/.ssh/id_rsa").is_ok();
let aws_ok = validate_path("C:\\Users\\x\\.aws\\credentials").is_ok();
let traversal_still_denied = validate_path("/root/.ssh/../.gnupg/x").is_err();
crate::commands::ai::audit::set_blacklist_bypass(false);
assert!(ssh_ok, "bypass 开时 .ssh 路径应通过 validate_path");
assert!(aws_ok, "bypass 开时 .aws 路径应通过 validate_path");
assert!(traversal_still_denied, "bypass 开时 .. 遍历检查应保留");
}
// ── handler 参数边界 ── // ── handler 参数边界 ──
#[tokio::test] #[tokio::test]
+8 -3
View File
@@ -125,7 +125,8 @@ pub(crate) fn generate_diff(old: &str, new: &str) -> String {
/// 2. **按路径分隔符分段**做 `..` 归一化检测——纯子串 `contains("..")` 会误伤 /// 2. **按路径分隔符分段**做 `..` 归一化检测——纯子串 `contains("..")` 会误伤
/// 合法目录名如 `my..file`,改用分段(逐段判断是否有 `..` 段)更精准,且能识别 /// 合法目录名如 `my..file`,改用分段(逐段判断是否有 `..` 段)更精准,且能识别
/// 解码后 `..%2f` / `..\` 等变体。 /// 解码后 `..%2f` / `..\` 等变体。
/// 3. 敏感系统目录黑名单仍保留(.ssh/.aws/.gnupg/AppData/ProgramData/Windows/System32)。 /// 3. 敏感系统目录黑名单(.ssh/.aws/.gnupg/AppData/ProgramData/Windows/System32),
/// 「信任所有目录」总开关(df-ai-path-auth-bypass)开启时放行。
pub(crate) fn validate_path(path: &str) -> anyhow::Result<()> { pub(crate) fn validate_path(path: &str) -> anyhow::Result<()> {
// 1) URL 解码:防 %2e%2e / %2f / %5c 等 URL 编码绕过 (LLM/外部输入可能传编码串) // 1) URL 解码:防 %2e%2e / %2f / %5c 等 URL 编码绕过 (LLM/外部输入可能传编码串)
// percent_decode_str 对非法 %XX 容错(保留原字节),decode_utf8_lossy 容错非 UTF-8。 // percent_decode_str 对非法 %XX 容错(保留原字节),decode_utf8_lossy 容错非 UTF-8。
@@ -147,8 +148,12 @@ pub(crate) fn validate_path(path: &str) -> anyhow::Result<()> {
// 敏感系统目录(.ssh/.aws/.gnupg + Windows/ProgramFiles/ProgramData + Unix 核心): // 敏感系统目录(.ssh/.aws/.gnupg + Windows/ProgramFiles/ProgramData + Unix 核心):
// 统一走 state::is_in_system_blacklist 分段匹配(消除 contains 子串误伤如 my\windows\backup, // 统一走 state::is_in_system_blacklist 分段匹配(消除 contains 子串误伤如 my\windows\backup,
// 与 is_authorized 运行时黑名单同源,单一来源 — 修 agent1 问题4 两套黑名单不一致) // 与 is_authorized 运行时黑名单同源,单一来源 — 修 agent1 问题4 两套黑名单不一致)。
if crate::state::is_in_system_blacklist(&PathBuf::from(&normalized)) { // 总开关开启时跳过(产品拍板:bypass 语义为完全放行,含敏感目录);标志由 audit 层每轮
// 工具执行前从同一 KV 刷新,与本函数无参签名解耦。.. 遍历检查保留在上:路径合法性校验,非权限边界。
if crate::state::is_in_system_blacklist(&PathBuf::from(&normalized))
&& !crate::commands::ai::audit::is_blacklist_bypassed()
{
anyhow::bail!("禁止访问敏感系统目录"); anyhow::bail!("禁止访问敏感系统目录");
} }
// AppData 不再硬拦(原 `lower.contains("\\appdata\\")` 一刀切致用户授权后仍拒)。 // AppData 不再硬拦(原 `lower.contains("\\appdata\\")` 一刀切致用户授权后仍拒)。
+39
View File
@@ -73,6 +73,10 @@ impl AllowedDirs {
/// 故本函数内部 best-effort canonicalize 兜底(失败回退词法),消除所有调用方形态差异。 /// 故本函数内部 best-effort canonicalize 兜底(失败回退词法),消除所有调用方形态差异。
/// 白名单仍是唯一真相源,canonicalize 不引入新放行,顺带解析 symlink 增强(非削弱)防逃逸。 /// 白名单仍是唯一真相源,canonicalize 不引入新放行,顺带解析 symlink 增强(非削弱)防逃逸。
pub fn is_authorized(&self, candidate: &Path) -> bool { pub fn is_authorized(&self, candidate: &Path) -> bool {
// 授权总开关开时不再逐目录询问,黑名单与白名单外一并放行,体验优先用户知情选择;开关关行为不变
if crate::commands::ai::audit::path_auth::is_blacklist_bypassed() {
return true;
}
// Phase C: 黑名单优先(白名单命中也拒)。validate_path 已挡 .ssh/.aws 等, // Phase C: 黑名单优先(白名单命中也拒)。validate_path 已挡 .ssh/.aws 等,
// 此处补系统核心目录(用户可能误把 C:\ 加入 persistent,黑名单兜底拒 System32)。 // 此处补系统核心目录(用户可能误把 C:\ 加入 persistent,黑名单兜底拒 System32)。
if is_in_system_blacklist(candidate) { if is_in_system_blacklist(candidate) {
@@ -523,4 +527,39 @@ mod tests {
assert!(!is_in_system_blacklist(&PathBuf::from("E:\\wk-lab\\devflow\\src"))); assert!(!is_in_system_blacklist(&PathBuf::from("E:\\wk-lab\\devflow\\src")));
assert!(!is_in_system_blacklist(&PathBuf::from("D:\\backup\\appdata\\data"))); assert!(!is_in_system_blacklist(&PathBuf::from("D:\\backup\\appdata\\data")));
} }
// 授权总开关(黑名单放行)接入 is_authorized 的语义测试。
// 进程级标志全进程单例,改写或依赖其结果的用例持 path_auth 串行闸防并行互踩。
/// 总开关开:白名单外路径与 .ssh 黑名单路径一律放行(is_authorized 首位短路)。
/// 先取结果再复位再断言,防 panic 泄漏标志污染并行测试。
#[test]
fn test_bypass_on_is_authorized_allows_all() {
let _guard = crate::commands::ai::audit::path_auth::BLACKLIST_BYPASS_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
crate::commands::ai::audit::path_auth::set_blacklist_bypass(true);
let allowed = AllowedDirs::default();
let outside = PathBuf::from("E:/wk-test-bypass-outside/file.txt");
let ssh = PathBuf::from("/home/user/.ssh/id_rsa");
let outside_ok = allowed.is_authorized(&outside);
let ssh_ok = allowed.is_authorized(&ssh);
crate::commands::ai::audit::path_auth::set_blacklist_bypass(false);
assert!(outside_ok, "总开关开时白名单外路径应放行");
assert!(ssh_ok, "总开关开时 .ssh 黑名单路径应放行");
}
/// 总开关关:黑名单拒且白名单外拒,与接入旁路前行为一致。
#[test]
fn test_bypass_off_is_authorized_unchanged() {
let _guard = crate::commands::ai::audit::path_auth::BLACKLIST_BYPASS_TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
crate::commands::ai::audit::path_auth::set_blacklist_bypass(false);
let allowed = AllowedDirs::default();
let outside_ok = allowed.is_authorized(&PathBuf::from("E:/wk-test-bypass-outside/file.txt"));
let ssh_ok = allowed.is_authorized(&PathBuf::from("/home/user/.ssh/id_rsa"));
assert!(!outside_ok, "总开关关时白名单外路径应拒绝");
assert!(!ssh_ok, "总开关关时 .ssh 黑名单路径应拒绝");
}
} }
+65 -3
View File
@@ -8,11 +8,14 @@
<!-- 说明 --> <!-- 说明 -->
<p class="allowed-dirs-desc">{{ $t('settings.descAllowedDirs') }}</p> <p class="allowed-dirs-desc">{{ $t('settings.descAllowedDirs') }}</p>
<!-- 「信任所有目录」总开关:开启后白名单外路径不再逐次弹授权(后端黑名单仍硬拒)。 <!-- 「信任所有目录」总开关:开启后白名单外路径不再逐次弹授权(后端黑名单拦截同步放行)。
走 appSettings KV(df-ai-path-auth-bypass),后端 process_tool_calls 每轮读取,改后即时生效。 --> 走 appSettings KV(df-ai-path-auth-bypass),后端 process_tool_calls 每轮读取,改后即时生效。
开启方向二次确认(全放敏感目录须确认+风险自担):checked 单向绑定 + @change 裁决,
确认前不写 ref。若 v-model 直写,弹窗期间 useSetting 的 debounced 落库已把 true 推给后端,
取消也收不回这段已放行窗口。 -->
<SettingRow :label="$t('settings.labelPathAuthBypass')" :desc="$t('settings.descPathAuthBypass')"> <SettingRow :label="$t('settings.labelPathAuthBypass')" :desc="$t('settings.descPathAuthBypass')">
<label class="toggle"> <label class="toggle">
<input type="checkbox" v-model="pathAuthBypass" /> <input type="checkbox" :checked="pathAuthBypass" @change="onPathAuthBypassChange" />
<span class="toggle-slider"></span> <span class="toggle-slider"></span>
</label> </label>
</SettingRow> </SettingRow>
@@ -82,6 +85,20 @@
<p class="script-safety-note">{{ $t('settings.noteScriptSafety') }}</p> <p class="script-safety-note">{{ $t('settings.noteScriptSafety') }}</p>
</div> </div>
</section> </section>
<!-- 「信任所有目录」二次确认弹层(同 AdvancedSection 的 all 接管确认:自渲染独立弹层,
msg 拼标题+正文,confirm-msg 用 pre-line 保留换行) -->
<Transition name="confirm">
<div v-if="confirmState.visible" class="confirm-mask" @click.self="answerConfirm(false)">
<div class="confirm-box">
<div class="confirm-msg">{{ confirmState.msg }}</div>
<div class="confirm-actions">
<button class="btn btn-ghost btn-sm" @click="answerConfirm(false)">{{ $t('common.cancel') }}</button>
<button class="btn btn-danger btn-sm" @click="answerConfirm(true)">{{ confirmState.dangerLabel || $t('common.confirm') }}</button>
</div>
</div>
</div>
</Transition>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@@ -91,6 +108,7 @@ import { useI18n } from 'vue-i18n'
import { invoke } from '@tauri-apps/api/core' import { invoke } from '@tauri-apps/api/core'
import { aiApi, settingsApi } from '@/api' import { aiApi, settingsApi } from '@/api'
import { useAppSettingsStore } from '@/stores/appSettings' import { useAppSettingsStore } from '@/stores/appSettings'
import { useConfirm } from '@/composables/useConfirm'
import SettingRow from './SettingRow.vue' import SettingRow from './SettingRow.vue'
const { t } = useI18n() const { t } = useI18n()
@@ -111,6 +129,24 @@ const saving = ref(false)
// 「信任所有目录」总开关(useSetting 双向绑 KV,写走 debounced 落库,后端每轮工具执行前读) // 「信任所有目录」总开关(useSetting 双向绑 KV,写走 debounced 落库,后端每轮工具执行前读)
const pathAuthBypass = appSettings.useSetting<boolean>('df-ai-path-auth-bypass', false) const pathAuthBypass = appSettings.useSetting<boolean>('df-ai-path-auth-bypass', false)
// useConfirm 真实 API:返回 { confirmState, confirmDialog(msg, dangerLabel), answerConfirm(ok) },
// confirmDialog 返回 Promise<boolean>(true=确认),弹层 UI 在本组件模板尾部自渲染。
const { confirmState, confirmDialog, answerConfirm } = useConfirm()
// 开启方向(false→true)二次确认:确认才落 true,取消仅回弹。关闭方向不拦截直接落。
async function onPathAuthBypassChange(e: Event) {
const input = e.target as HTMLInputElement
if (!input.checked) {
pathAuthBypass.value = false
return
}
input.checked = false
const msg = t('settings.pathAuthBypassConfirmTitle') + '\n\n' + t('settings.pathAuthBypassConfirmBody')
if (await confirmDialog(msg, t('common.confirm'))) {
pathAuthBypass.value = true
}
}
async function load() { async function load() {
loading.value = true loading.value = true
try { try {
@@ -262,4 +298,30 @@ onMounted(load)
.script-safety-note { .script-safety-note {
font-size: 11px; color: var(--df-text-dim); margin: 4px 0 0; line-height: 1.5; font-size: 11px; color: var(--df-text-dim); margin: 4px 0 0; line-height: 1.5;
} }
/* 「信任所有目录」二次确认弹层(同 AdvancedSection) */
.confirm-mask {
position: fixed;
inset: 0;
z-index: 1100;
background: rgba(0,0,0,0.5);
display: flex;
align-items: center;
justify-content: center;
}
.confirm-box {
background: var(--df-bg-card);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-lg);
padding: 20px;
min-width: 280px;
max-width: 420px;
box-shadow: 0 8px 32px rgba(0,0,0,0.4);
}
.confirm-msg { font-size: 13px; color: var(--df-text); line-height: 1.5; margin-bottom: 16px; white-space: pre-line; }
.confirm-actions { display: flex; justify-content: flex-end; gap: 8px; }
.btn-danger { background: var(--df-danger); color: #fff; }
.btn-danger:hover { filter: brightness(1.1); }
.confirm-enter-active, .confirm-leave-active { transition: opacity 0.15s; }
.confirm-enter-from, .confirm-leave-to { opacity: 0; }
</style> </style>
+3 -1
View File
@@ -199,7 +199,9 @@ export default {
allowedDirsRemoveFail: 'Failed to remove authorized directory', allowedDirsRemoveFail: 'Failed to remove authorized directory',
// "Trust all directories" master switch (Settings - Security - AI Tool Allowed Dirs panel) // "Trust all directories" master switch (Settings - Security - AI Tool Allowed Dirs panel)
labelPathAuthBypass: 'Trust all directories', 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.', descPathAuthBypass: 'When enabled, AI can directly access any directory, including sensitive system and credential folders, without per-access prompts. Only enable in trusted environments; exposing sensitive directories risks credential leakage.',
pathAuthBypassConfirmTitle: 'Enable "Trust all directories"?',
pathAuthBypassConfirmBody: 'Once enabled, AI tools will no longer ask for per-access authorization and can directly reach any path, including sensitive directories such as .ssh and .aws. Sensitive credentials may end up in the conversation context; proceed at your own risk. Are you sure you want to enable it?',
// ===== ScriptNode command execution safety ===== // ===== ScriptNode command execution safety =====
panelScriptSafety: '🛡️ Command Execution Safety', panelScriptSafety: '🛡️ Command Execution Safety',
+3 -1
View File
@@ -198,7 +198,9 @@ export default {
allowedDirsRemoveFail: '删除授权目录失败', allowedDirsRemoveFail: '删除授权目录失败',
// 「信任所有目录」总开关(设置-安全-AI 工具授权目录面板) // 「信任所有目录」总开关(设置-安全-AI 工具授权目录面板)
labelPathAuthBypass: '信任所有目录', labelPathAuthBypass: '信任所有目录',
descPathAuthBypass: '开启后 AI 可直接访问任意非受保护目录,不再逐次询问;凭据等受保护目录(黑名单)仍被拒绝。', descPathAuthBypass: '开启后 AI 可直接访问任意目录(含系统与凭据等敏感目录),不再逐次询问。仅建议在受信任环境开启,放开敏感目录有凭据泄露风险。',
pathAuthBypassConfirmTitle: '开启「信任所有目录」?',
pathAuthBypassConfirmBody: '开启后 AI 工具将无需逐次授权,直接访问包括 .ssh、.aws 等敏感目录在内的任意路径,敏感凭据可能进入对话上下文,风险自担。确定开启?',
// ===== ScriptNode 命令执行安全 ===== // ===== ScriptNode 命令执行安全 =====
panelScriptSafety: '🛡️ 命令执行安全', panelScriptSafety: '🛡️ 命令执行安全',