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

@@ -87,3 +87,31 @@ pub async fn ai_set_agent_max_retries(
state.agent_max_retries.store(clamped, Ordering::SeqCst);
Ok(())
}
// ============================================================
// F-260619-03 Phase A: AI 工具文件访问授权目录白名单 IPC
//
// 持久化到 app_settings KV key=`allowed_dirs`(JSON 字符串数组),
// 内存 AppState.allowed_dirs 同步。workspace_root 始终在白名单(向后兼容)。
// resolve_workspace_path_with_allowed 校验:文件工具(read/write/list/patch/...)
// 仅可访问授权目录。Phase A 仅持久化,不挂起弹窗(Phase B)。
// ============================================================
/// 获取 AI 工具授权目录列表(含 workspace_root,规范化字符串)。
#[tauri::command]
pub async fn ai_get_allowed_dirs(state: State<'_, AppState>) -> Result<Vec<String>, String> {
Ok(state.get_allowed_dirs().await)
}
/// 设置 AI 工具授权目录列表(持久化 Settings KV + 同步内存白名单)。
///
/// 传入目录字符串数组(去空去重);后端 canonicalize 规范化后持久化。
/// workspace_root 始终隐式在白名单(is_authorized 内置),无需前端显式传入。
/// 返回持久化后内存白名单的规范化字符串列表(供前端回显)。
#[tauri::command]
pub async fn ai_set_allowed_dirs(
state: State<'_, AppState>,
dirs: Vec<String>,
) -> Result<Vec<String>, String> {
state.set_allowed_dirs(dirs).await.map_err(|e| e.to_string())
}

View File

@@ -4,7 +4,7 @@ use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{Arc, LazyLock};
use tokio::sync::Mutex as TokioMutex;
use tokio::sync::{Mutex as TokioMutex, RwLock};
use df_ai::ai_tools::{AiToolRegistry, RiskLevel};
use df_execute::shell::{execute, ShellRequest};
@@ -14,6 +14,7 @@ use df_storage::models::{ProjectRecord, TaskRecord, IdeaRecord};
use df_types::types::new_id;
use crate::commands::now_millis;
use crate::state::AllowedDirs;
/// CRUD list 工具的默认返回上限(防 LLM context 膨胀)
/// 用于 list_projects / list_tasks / list_ideas / list_trash
@@ -282,21 +283,36 @@ fn workspace_root() -> PathBuf {
/// 解析文件工具路径:相对路径锚定 workspace_root禁止越出项目目录
///
/// 双层校验:
/// 1. 词法层 starts_with(root)——对不存在路径(write_file 新建文件)兜底防越界
/// 1. 词法层 is_authorized——对不存在路径(write_file 新建文件)兜底防越界
/// 2. canonicalize 层——对存在路径解析符号链接,防 workspace 内 symlink 指向外部的逃逸
/// 仅校验,返回词法 resolved(不含 \\?\ 前缀),保证 read_file 返回的 path 对前端友好
fn resolve_workspace_path(path: &str) -> anyhow::Result<PathBuf> {
resolve_workspace_path_impl(path)
///
/// F-260619-03 Phase A: 引入 AllowedDirs 白名单(动态多目录授权)。
/// - `resolve_workspace_path_with_allowed(path, &AllowedDirs)`: 文件工具闭包用,
/// 走完整白名单校验(workspace_root 始终授权 + persistent 任一命中)。
/// - `resolve_workspace_path_pub(path)`: trust_key_for 计算 TrustKey 目录归一化用,
/// 无白名单上下文,用 default_with_root(仅 workspace_root),零回归。
/// 闭包捕获 `Arc<RwLock<AllowedDirs>>` 后 read lock 取快照传入本函数。
fn resolve_workspace_path_with_allowed(
path: &str,
allowed: &AllowedDirs,
) -> anyhow::Result<PathBuf> {
resolve_workspace_path_impl(path, allowed)
}
/// AE-2025-04mod.rs trust_key_for 计算 write_file 的 TrustKey 目录粒度时复用本函数
/// 归一化路径(去 .. / symlink 逃逸。pub(crate) wrapper 转调私有 impl保持原私有函数
/// 可见性边界(不暴露给 crate 外,但允许同 crate ai 模块 mod.rs 调用)。
///
/// F-260619-03 Phase A: trust_key 计算无需白名单上下文(只是目录归一化作 HashSet key),
/// 用 default_with_root(仅 workspace_root),零回归。白名单校验由文件工具 handler 在
/// 实际执行时走 resolve_workspace_path_with_allowed,信任键计算的越界失败不影响
/// (trust_key_for 已对 Err 回退 workspace_root_str 保守默认)。
pub(crate) fn resolve_workspace_path_pub(path: &str) -> anyhow::Result<PathBuf> {
resolve_workspace_path_impl(path)
resolve_workspace_path_impl(path, &AllowedDirs::default_with_root())
}
fn resolve_workspace_path_impl(path: &str) -> anyhow::Result<PathBuf> {
fn resolve_workspace_path_impl(path: &str, allowed: &AllowedDirs) -> anyhow::Result<PathBuf> {
validate_path(path)?;
let root = workspace_root();
let resolved = if Path::new(path).is_absolute() {
@@ -304,16 +320,19 @@ fn resolve_workspace_path_impl(path: &str) -> anyhow::Result<PathBuf> {
} else {
root.join(path)
};
// 词法层:防明显越界(不存在路径的兜底)
if !resolved.starts_with(&root) {
anyhow::bail!("禁止访问项目目录之外: {}", path);
// 词法层:防明显越界(不存在路径的兜底)
// F-260619-03 Phase A: 单根 starts_with 升级为 AllowedDirs 白名单多目录校验
// (workspace_root 始终在白名单 → 旧单根路径零回归;新授权目录命中放行)。
if !allowed.is_authorized(&resolved) {
anyhow::bail!("路径不在授权目录内: {}", path);
}
// canonicalize 层:存在路径解析 symlink,防经符号链接逃逸出 workspace
// canonicalize 层:存在路径解析 symlink,防经符号链接逃逸出授权目录。
// 用 canonicalize 后的真实路径再做一次 is_authorized 校验(防 workspace 内 symlink
// 指向白名单外目录的逃逸)。default_with_root 时等价旧 root.canonicalize 比对。
if resolved.exists() {
let canon_root = root.canonicalize()?;
let canon_resolved = resolved.canonicalize()?;
if !canon_resolved.starts_with(&canon_root) {
anyhow::bail!("禁止访问项目目录之外(符号链接逃逸): {}", path);
if !allowed.is_authorized(&canon_resolved) {
anyhow::bail!("路径不在授权目录内(符号链接逃逸): {}", path);
}
}
Ok(resolved)
@@ -364,10 +383,17 @@ async fn bind_dir_to_project(
/// - data 层(register_data_tools):18 个持 db 的 CRUD/状态机/工作流工具,闭包捕获 db Arc 重建 Repo。
/// - file 层(register_file_tools):10 个纯文件系统工具,不持 db。
/// 新增工具改对应 register_* 一处,定义与实现同源,编译期保证一致。
pub fn build_ai_tool_registry(db: &Arc<Database>) -> AiToolRegistry {
///
/// F-260619-03 Phase A: 新增 `allowed_dirs: &Arc<RwLock<AllowedDirs>>` 形参,
/// 文件工具闭包 clone 进闭包,在 resolve_workspace_path_with_allowed 校验动态白名单。
/// data/http 工具不涉及文件路径,不传白名单。
pub fn build_ai_tool_registry(
db: &Arc<Database>,
allowed_dirs: &Arc<RwLock<AllowedDirs>>,
) -> AiToolRegistry {
let mut registry = AiToolRegistry::new();
register_data_tools(&mut registry, db);
register_file_tools(&mut registry);
register_file_tools(&mut registry, allowed_dirs);
register_http_tools(&mut registry);
registry
}
@@ -834,7 +860,18 @@ fn register_trash_tools(registry: &mut AiToolRegistry, db: &Arc<Database>) {
/// 文件系统类 AI 工具注册(命令执行/读/列/写/改/元/追加/删/移/搜)——不持 db,纯文件系统操作。
/// 从 build_ai_tool_registry 抽出(SMELL-P0-2:1091行单函数拆分,build 1090→~390 行)。
fn register_file_tools(registry: &mut AiToolRegistry) {
///
/// F-260619-03 Phase A: 各文件工具闭包捕获 `allowed_dirs: Arc<RwLock<AllowedDirs>>`,
/// handler 执行时 read lock 取白名单快照,resolve_workspace_path_with_allowed 校验动态白名单。
/// Phase A 仅持久化白名单(workspace_root 始终授权,无 allowed_dirs 配置时行为不变)。
fn register_file_tools(
registry: &mut AiToolRegistry,
allowed_dirs: &Arc<RwLock<AllowedDirs>>,
) {
// F-260619-03 Phase A: Arc clone 进各文件工具闭包(handler 执行时 read lock 取白名单快照)。
// run_command 不走白名单(High risk 靠人工审批兜底,放开目录让 AI 在用户任意项目目录闭环),
// 故该工具闭包不捕获 allowed_dirs,维持原行为。
let allowed_dirs = allowed_dirs.clone();
registry.register(
"run_command", "在指定工作目录执行 shell 命令(跑测试/构建/查看运行结果),返回 stdout/stderr/exit_code。高风险须人工批准。命令需自包含非交互式避免需用户输入的程序。默认超时 60 秒。用于验证刚写入的代码能否运行、跑测试、看报错迭代修改。",
df_ai::ai_tools::object_schema(vec![
@@ -908,9 +945,13 @@ fn register_file_tools(registry: &mut AiToolRegistry) {
"read_file", "读取文件内容,返回文本内容。支持 offset/limit 分页;传入 search 则在文件内容中搜索匹配行(大小写敏感,字符串包含匹配),返回 matches 数组限50条",
df_ai::ai_tools::object_schema(vec![("path", "string", true), ("offset", "integer", false), ("limit", "integer", false), ("search", "string", false)]),
RiskLevel::Low,
Box::new(|args: serde_json::Value| Box::pin(async move {
let resolved = resolve_workspace_path(
{ let allowed_dirs = allowed_dirs.clone(); Box::new(move |args: serde_json::Value| {
let allowed_dirs = allowed_dirs.clone();
Box::pin(async move {
let snap = allowed_dirs.read().await.clone();
let resolved = resolve_workspace_path_with_allowed(
args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?,
&snap,
)?;
let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?;
// 单次 File::open 取 metadata+read 消除 TOCTOU(FR-S2:原 metadata+read 两步 syscall 间 symlink 替换破 1MB 限)
@@ -983,15 +1024,20 @@ fn register_file_tools(registry: &mut AiToolRegistry) {
"returned_lines": result.lines().count(),
"has_more": has_more,
}))
})),
})
})},
);
registry.register(
"list_directory", "列出目录内容,返回文件和子目录列表(名称、类型、大小)",
df_ai::ai_tools::object_schema(vec![("path", "string", true), ("recursive", "boolean", false), ("skip_noise_dirs", "boolean", false), ("max_depth", "integer", false)]),
RiskLevel::Low,
Box::new(|args: serde_json::Value| Box::pin(async move {
let resolved = resolve_workspace_path(
{ let allowed_dirs = allowed_dirs.clone(); Box::new(move |args: serde_json::Value| {
let allowed_dirs = allowed_dirs.clone();
Box::pin(async move {
let snap = allowed_dirs.read().await.clone();
let resolved = resolve_workspace_path_with_allowed(
args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?,
&snap,
)?;
let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?;
let recursive = args["recursive"].as_bool().unwrap_or(false);
@@ -1002,7 +1048,8 @@ fn register_file_tools(registry: &mut AiToolRegistry) {
let mut entries = Vec::new();
let truncated = list_dir_recursive(path, recursive, 0, max_depth, 1000, skip_noise, &mut entries).await?;
Ok(serde_json::json!({ "path": path, "entries": entries, "truncated": truncated }))
})),
})
})},
);
registry.register(
"write_file", "写入或创建文件,自动创建不存在的父目录。支持 encoding=\"base64\" 写入二进制/非 UTF-8 内容(图片/PDF/Excel 等content 传 base64 编码字符串;默认 encoding=\"utf-8\" 传纯文本",
@@ -1012,9 +1059,13 @@ fn register_file_tools(registry: &mut AiToolRegistry) {
("encoding", "string", false),
]),
RiskLevel::Medium,
Box::new(|args: serde_json::Value| Box::pin(async move {
let resolved = resolve_workspace_path(
{ let allowed_dirs = allowed_dirs.clone(); Box::new(move |args: serde_json::Value| {
let allowed_dirs = allowed_dirs.clone();
Box::pin(async move {
let snap = allowed_dirs.read().await.clone();
let resolved = resolve_workspace_path_with_allowed(
args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?,
&snap,
)?;
let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?;
let raw = args["content"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 content 参数"))?;
@@ -1079,7 +1130,8 @@ fn register_file_tools(registry: &mut AiToolRegistry) {
}
}
Ok(serde_json::json!({ "path": path, "bytes_written": bytes.len(), "old_size": old_size, "encoding": encoding }))
})),
})
})},
);
// ── 局部文件编辑 (Medium risk) ──
@@ -1121,9 +1173,13 @@ fn register_file_tools(registry: &mut AiToolRegistry) {
"patch_file", "局部更新文件内容三模式互斥。模式1 old_text精确匹配原文替换含空格/缩进CAS 语义。模式2 replace_lines按行号区间 {start,end}1-based 含首尾)替换,不需原文,配 expected_hash 防行号漂移。模式3 anchor按首尾子串锚点 {start,end}(大小写敏感,子串匹配)定位行号区间替换,不需完整原文。三模式均需 path+new_textexpected_hash 可选通用。属 Medium 风险操作(修改已有文件),需人工审批。注意:若文件已被外部修改,请先重新 read_file 获取最新内容",
patch_file_schema,
RiskLevel::Medium,
Box::new(|args: serde_json::Value| Box::pin(async move {
let resolved = resolve_workspace_path(
{ let allowed_dirs = allowed_dirs.clone(); Box::new(move |args: serde_json::Value| {
let allowed_dirs = allowed_dirs.clone();
Box::pin(async move {
let snap = allowed_dirs.read().await.clone();
let resolved = resolve_workspace_path_with_allowed(
args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?,
&snap,
)?;
let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?;
let new_text = args["new_text"].as_str()
@@ -1280,7 +1336,8 @@ fn register_file_tools(registry: &mut AiToolRegistry) {
result["warning"] = serde_json::Value::String(w);
}
Ok(result)
})),
})
})},
);
// ── 文件元信息 (Low risk) ──
@@ -1288,9 +1345,13 @@ fn register_file_tools(registry: &mut AiToolRegistry) {
"file_info", "获取文件或目录的元信息(是否存在、大小、行数、修改时间、是否二进制、是否目录),不读取文件内容",
df_ai::ai_tools::object_schema(vec![("path", "string", true)]),
RiskLevel::Low,
Box::new(|args: serde_json::Value| Box::pin(async move {
let resolved = resolve_workspace_path(
{ let allowed_dirs = allowed_dirs.clone(); Box::new(move |args: serde_json::Value| {
let allowed_dirs = allowed_dirs.clone();
Box::pin(async move {
let snap = allowed_dirs.read().await.clone();
let resolved = resolve_workspace_path_with_allowed(
args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?,
&snap,
)?;
let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?;
let p = std::path::Path::new(path);
@@ -1329,7 +1390,8 @@ fn register_file_tools(registry: &mut AiToolRegistry) {
tokio::fs::read_to_string(path).await.ok().map(|c| c.lines().count() as u64)
} else { None };
Ok(serde_json::json!({ "path": path, "exists": true, "size": size, "lines": lines, "modified": modified, "is_binary": is_binary, "is_dir": is_dir }))
})),
})
})},
);
// ── 追加写入 (Medium risk) ──
@@ -1337,9 +1399,13 @@ fn register_file_tools(registry: &mut AiToolRegistry) {
"append_file", "向文件末尾追加内容,文件不存在则自动创建。返回写入字数和新文件大小",
df_ai::ai_tools::object_schema(vec![("path", "string", true), ("content", "string", true)]),
RiskLevel::Medium,
Box::new(|args: serde_json::Value| Box::pin(async move {
let resolved = resolve_workspace_path(
{ let allowed_dirs = allowed_dirs.clone(); Box::new(move |args: serde_json::Value| {
let allowed_dirs = allowed_dirs.clone();
Box::pin(async move {
let snap = allowed_dirs.read().await.clone();
let resolved = resolve_workspace_path_with_allowed(
args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?,
&snap,
)?;
let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?;
let content = args["content"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 content 参数"))?;
@@ -1358,7 +1424,8 @@ fn register_file_tools(registry: &mut AiToolRegistry) {
file.flush().await.map_err(|e| anyhow::anyhow!("刷新失败: {}", e))?;
let new_size = tokio::fs::metadata(path).await.map(|m| m.len()).unwrap_or(0);
Ok(serde_json::json!({ "path": path, "bytes_written": bytes.len(), "new_size": new_size }))
})),
})
})},
);
// ── 删除文件 (High risk, F-260615-16) ──
@@ -1369,10 +1436,14 @@ fn register_file_tools(registry: &mut AiToolRegistry) {
("permanent", "boolean", false),
]),
RiskLevel::High,
Box::new(|args: serde_json::Value| Box::pin(async move {
// validate_path 前置(workspace 内)——resolve_workspace_path 含 validate_path + symlink 防逃逸
let resolved = resolve_workspace_path(
{ let allowed_dirs = allowed_dirs.clone(); Box::new(move |args: serde_json::Value| {
let allowed_dirs = allowed_dirs.clone();
Box::pin(async move {
// validate_path 前置(workspace 内)——resolve_workspace_path_with_allowed 含 validate_path + symlink 防逃逸
let snap = allowed_dirs.read().await.clone();
let resolved = resolve_workspace_path_with_allowed(
args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?,
&snap,
)?;
let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?;
let permanent = args.get("permanent").and_then(|v| v.as_bool()).unwrap_or(false);
@@ -1419,7 +1490,8 @@ fn register_file_tools(registry: &mut AiToolRegistry) {
"backup_path": backup_path_str,
}))
}
})),
})
})},
);
// ── 重命名/移动文件 (Medium risk, F-260615-15) ──
@@ -1433,15 +1505,20 @@ fn register_file_tools(registry: &mut AiToolRegistry) {
("overwrite", "boolean", false),
]),
RiskLevel::Medium,
Box::new(|args: serde_json::Value| Box::pin(async move {
// 双向 validate_path(workspace 内)——from/to 都走 resolve_workspace_path,
{ let allowed_dirs = allowed_dirs.clone(); Box::new(move |args: serde_json::Value| {
let allowed_dirs = allowed_dirs.clone();
Box::pin(async move {
// 双向 validate_path(workspace 内)——from/to 都走 resolve_workspace_path_with_allowed,
// 各自含 validate_path(黑名单+.. 防遍历) + 词法 starts_with + canonicalize symlink 防逃逸
let from_resolved = resolve_workspace_path(
let snap = allowed_dirs.read().await.clone();
let from_resolved = resolve_workspace_path_with_allowed(
args["from"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 from 参数"))?,
&snap,
)?;
let from_path = from_resolved.to_str().ok_or_else(|| anyhow::anyhow!("from 路径含非法字符"))?;
let to_resolved = resolve_workspace_path(
let to_resolved = resolve_workspace_path_with_allowed(
args["to"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 to 参数"))?,
&snap,
)?;
let to_path = to_resolved.to_str().ok_or_else(|| anyhow::anyhow!("to 路径含非法字符"))?;
let overwrite = args.get("overwrite").and_then(|v| v.as_bool()).unwrap_or(false);
@@ -1512,7 +1589,8 @@ fn register_file_tools(registry: &mut AiToolRegistry) {
"bytes_moved": bytes_moved,
"cross_volume": true,
}))
})),
})
})},
);
// ── 文件搜索 (Low risk) ──
@@ -1520,9 +1598,13 @@ fn register_file_tools(registry: &mut AiToolRegistry) {
"search_files", "在指定目录下搜索匹配模式(字符串包含匹配)的文件名,支持 offset/limit 分页。返回 results、total、has_more。默认 limit=50",
df_ai::ai_tools::object_schema(vec![("path", "string", true), ("pattern", "string", true), ("recursive", "boolean", false), ("offset", "integer", false), ("limit", "integer", false)]),
RiskLevel::Low,
Box::new(|args: serde_json::Value| Box::pin(async move {
let resolved = resolve_workspace_path(
{ let allowed_dirs = allowed_dirs.clone(); Box::new(move |args: serde_json::Value| {
let allowed_dirs = allowed_dirs.clone();
Box::pin(async move {
let snap = allowed_dirs.read().await.clone();
let resolved = resolve_workspace_path_with_allowed(
args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?,
&snap,
)?;
let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?;
let pattern = args["pattern"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 pattern 参数"))?;
@@ -1539,7 +1621,8 @@ fn register_file_tools(registry: &mut AiToolRegistry) {
let page_results: Vec<_> = all_results.into_iter().skip(offset).take(limit).collect();
let has_more = (offset + page_results.len()) < total as usize;
Ok(serde_json::json!({ "path": path, "pattern": pattern, "results": page_results, "total": total, "has_more": has_more }))
})),
})
})},
);
}
@@ -1736,7 +1819,10 @@ mod tests {
async fn test_build_ai_tool_registry_baseline_tool_count() {
let db = Database::open_in_memory().await.expect("in-memory db 初始化失败");
let db = Arc::new(db);
let registry = build_ai_tool_registry(&db);
// F-260619-03 Phase A: build_ai_tool_registry 新增 allowed_dirs 形参,
// 测试用 default_with_root(仅 workspace_root),零回归(白名单含 workspace_root)。
let allowed_dirs = Arc::new(RwLock::new(AllowedDirs::default_with_root()));
let registry = build_ai_tool_registry(&db, &allowed_dirs);
// 总量基线:29(18 data + 10 file + 1 http)。拆分前后必须一致。
assert_eq!(

View File

@@ -164,6 +164,9 @@ pub fn run() {
commands::ai::ai_set_concurrency_config,
commands::ai::ai_set_agent_max_iterations,
commands::ai::ai_set_agent_max_retries,
// F-260619-03 Phase A: AI 工具文件访问授权目录白名单
commands::ai::ai_get_allowed_dirs,
commands::ai::ai_set_allowed_dirs,
// 审批历史面板(AE-2025-08:查 ai_tool_executions 表,敏感字段截断)
commands::ai::audit::list_tool_executions,
// 知识库

View File

@@ -1,13 +1,13 @@
//! 应用全局状态 — 数据库、Repo、事件总线、节点注册表、AI 会话
use std::collections::HashMap;
use std::path::Path;
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::AtomicUsize;
use anyhow::Result;
use serde::{Deserialize, Serialize};
use tokio::sync::{Mutex, Semaphore};
use tokio::sync::{Mutex, RwLock, Semaphore};
use df_ai::ai_tools::AiToolRegistry;
use df_storage::crud::{
@@ -297,13 +297,73 @@ pub struct AppState {
/// cancel_workflow_node IPC 经 execution_id 取出后 set_cancelled
/// 直达运行中 HumanNode 的 is_cancelled 轮询。执行完成(成功/失败)后移除条目。
pub workflow_state_registry: Arc<Mutex<HashMap<String, StateMachine>>>,
// ── F-260619-03 Phase A: AI 工具文件访问授权目录白名单 ──
/// AI 文件工具(read/write/list/patch/...)可访问的授权目录池。
/// Phase A 仅持久化白名单(Settings KV `allowed_dirs` 加载),
/// workspace_root 始终在白名单(向后兼容)。
/// Phase B 将扩展为 persistent + session(会话临时授权) + 弹窗挂起。
pub allowed_dirs: Arc<RwLock<AllowedDirs>>,
}
/// F-260619-03 Phase A: AI 工具文件访问授权目录白名单
///
/// Phase A 仅含 `persistent`(持久化白名单,从 Settings KV `allowed_dirs` 加载,
/// JSON 数组 `["E:/wk-lab/u-abc"]`)。`resolve_workspace_path` 校验时:
/// - workspace_root 始终视为已授权(向后兼容,默认根)
/// - 任一 persistent 目录 starts_with 命中即放行
///
/// Phase B 将扩展 `session: HashSet<PathBuf>`(会话临时授权) + 弹窗挂起机制,
/// 届时 is_authorized 增加 session 校验。当前结构预留扩展位但不引入。
#[derive(Debug, Clone, Default)]
pub struct AllowedDirs {
/// 持久化授权目录(Settings KV `allowed_dirs`,JSON 字符串数组)。
/// 已规范化(canonicalize 失败回退原字面量),便于 starts_with 精确比对。
pub persistent: HashSet<PathBuf>,
}
impl AllowedDirs {
/// Settings KV key:F-260619-03 Phase A 持久化白名单(JSON 字符串数组)
pub const SETTINGS_KEY: &'static str = "allowed_dirs";
/// 仅含 workspace_root 的默认白名单(向后兼容:无 allowed_dirs 时行为不变)。
/// 用于 mod.rs trust_key_for 等无白名单上下文的旧路径(零回归)。
pub fn default_with_root() -> Self {
let mut set = HashSet::new();
set.insert(workspace_root_path());
Self { persistent: set }
}
/// 路径是否被授权:workspace_root 始终授权 + persistent 任一 starts_with 命中。
///
/// 输入 `candidate` 应为 canonicalize 后的真实路径(防 symlink 逃逸);
/// 调用方(resolve_workspace_path_with_allowed)负责 canonicalize,本函数只做 starts_with 比对。
pub fn is_authorized(&self, candidate: &Path) -> bool {
let root = workspace_root_path();
if candidate.starts_with(&root) {
return true;
}
self.persistent.iter().any(|d| candidate.starts_with(d))
}
}
/// workspace 根目录(src-tauri 上两级,与 tool_registry::workspace_root 同源)。
fn workspace_root_path() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.and_then(|p| p.parent())
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("."))
}
impl AppState {
/// 初始化应用状态:打开(或创建)数据库并执行迁移,构建各 Repo 与节点注册表
pub async fn init(db_path: &Path) -> Result<Self> {
let db = Arc::new(Database::open(db_path).await?);
let ai_tools = Arc::new(crate::commands::ai::build_ai_tool_registry(&db));
// F-260619-03 Phase A: build_ai_tool_registry 注入 allowed_dirs Arc,
// 文件工具闭包捕获后 resolve_workspace_path_with_allowed 校验动态白名单。
// 此处用 default_with_root 占位,下方 init 尾部 reload_allowed_dirs 从 Settings KV 覆盖。
let allowed_dirs = Arc::new(RwLock::new(AllowedDirs::default_with_root()));
let ai_tools = Arc::new(crate::commands::ai::build_ai_tool_registry(&db, &allowed_dirs));
// build_registry 需注入 Arc<Database>(TaskAdvanceNode 持 db)。
// 在 struct 字段 `db` move 前 clone,避免 E0382。
let registry = Arc::new(build_registry(db.clone()));
@@ -330,6 +390,8 @@ impl AppState {
crate::commands::ai::agentic::DEFAULT_MAX_AGENT_RETRIES,
)),
workflow_state_registry: Arc::new(Mutex::new(HashMap::new())),
// F-260619-03 Phase A: 与 ai_tools registry 共享同一 Arc(构建时注入同一句柄)
allowed_dirs: allowed_dirs.clone(),
db,
event_bus: EventBus::new(),
registry,
@@ -345,6 +407,9 @@ impl AppState {
// 单 provider 场景:该 provider cap=global_cap → acquire_global+acquire_for_provider
// 串联,min(3,3)=3,有效上限同未配置 → 行为零变化。
state.reload_provider_caps().await;
// F-260619-03 Phase A: 从 Settings KV 加载持久化授权目录白名单覆盖默认值。
// 失败(读 KV/解析 JSON 出错)不阻断启动,保持 default_with_root(仅 workspace_root)。
state.reload_allowed_dirs().await;
Ok(state)
}
@@ -370,6 +435,91 @@ impl AppState {
.collect();
self.llm_concurrency.set_provider_caps(caps).await;
}
/// F-260619-03 Phase A: 从 Settings KV `allowed_dirs`(JSON 字符串数组)加载持久化白名单。
///
/// 启动 + Settings IPC `ai_set_allowed_dirs` 写入后调用。解析失败/缺失 → 保持
/// default_with_root(仅 workspace_root,向后兼容:is_authorized 始终放行 workspace_root)。
/// 每条路径尝试 canonicalize 规范化(防大小写/分隔符差异绕过);canonicalize 失败
/// (目录不存在)回退原字面量 trim(写入后再校验场景:先授权目录路径,目录暂不存在)。
pub async fn reload_allowed_dirs(&self) {
let raw = match self.settings.get(AllowedDirs::SETTINGS_KEY).await {
Ok(Some(v)) => v,
Ok(None) => {
// 未配置:保持 default_with_root(is_authorized 始终放行 workspace_root)
return;
}
Err(e) => {
tracing::warn!("[F-03A] 读取 allowed_dirs 失败,保持默认: {}", e);
return;
}
};
let dirs: Vec<String> = match serde_json::from_str(&raw) {
Ok(v) => v,
Err(e) => {
tracing::warn!("[F-03A] allowed_dirs 非 JSON 字符串数组,保持默认: {}", e);
return;
}
};
let mut set = HashSet::new();
// workspace_root 始终在白名单(向后兼容);不重复插入
set.insert(workspace_root_path());
for d in dirs {
let d = d.trim();
if d.is_empty() {
continue;
}
let p = PathBuf::from(d);
// canonicalize 成功用真实路径(去 symlink/大小写归一);失败回退原字面量
let normalized = std::fs::canonicalize(&p).unwrap_or_else(|_| {
// 规范化分隔符 trim 尾部,保持与 starts_with 比对一致
PathBuf::from(d.trim_end_matches(['/', '\\']))
});
set.insert(normalized);
}
*self.allowed_dirs.write().await = AllowedDirs { persistent: set };
}
/// F-260619-03 Phase A: 写 Settings KV + 同步内存白名单(供 Settings IPC 调用)。
///
/// - 持久化:JSON 字符串数组写 `app_settings` key=`allowed_dirs`
/// - 内存:reload_allowed_dirs 重新加载(规范化逻辑复用,避免双份)
/// 返回持久化后的规范化路径列表(供前端回显 canonicalize 后的真实路径)。
pub async fn set_allowed_dirs(&self, dirs: Vec<String>) -> Result<Vec<String>> {
// 去空 + 去重(保留顺序,前端展示友好)
let mut seen = HashSet::new();
let cleaned: Vec<String> = dirs
.into_iter()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.filter(|s| seen.insert(s.clone()))
.collect();
let json = serde_json::to_string(&cleaned)?;
self.settings.set(AllowedDirs::SETTINGS_KEY, &json).await
.map_err(|e| anyhow::anyhow!("持久化 allowed_dirs 失败: {}", e))?;
self.reload_allowed_dirs().await;
// 返回内存白名单(含 workspace_root)的规范化字符串列表(前端可看到真实生效路径)
let guard = self.allowed_dirs.read().await;
let mut out: Vec<String> = guard
.persistent
.iter()
.map(|p| p.to_string_lossy().to_string())
.collect();
out.sort();
Ok(out)
}
/// F-260619-03 Phase A: 读内存白名单为字符串列表(供 Settings IPC `ai_get_allowed_dirs` 回显)。
pub async fn get_allowed_dirs(&self) -> Vec<String> {
let guard = self.allowed_dirs.read().await;
let mut out: Vec<String> = guard
.persistent
.iter()
.map(|p| p.to_string_lossy().to_string())
.collect();
out.sort();
out
}
}
/// 构建节点注册表 — 注册内置节点
@@ -414,3 +564,61 @@ fn build_registry(db: Arc<Database>) -> NodeRegistry {
registry
}
#[cfg(test)]
mod tests {
use super::*;
// ============================================================
// F-260619-03 Phase A: AllowedDirs 授权语义测试
// 锁定:workspace_root 始终授权 + persistent 命中放行 + 未命中拒绝。
// ============================================================
/// workspace_root 路径在 default_with_root 白名单内授权通过
#[test]
fn test_allowed_dirs_workspace_root_authorized() {
let allowed = AllowedDirs::default_with_root();
let root = workspace_root_path();
assert!(allowed.is_authorized(&root), "workspace_root 应被授权");
// workspace 内子路径也应授权(starts_with workspace_root)
let child = root.join("src").join("main.rs");
assert!(allowed.is_authorized(&child), "workspace_root 子路径应被授权");
}
/// 自定义授权目录命中放行(模拟用户授权 E:/some/external/dir)
#[test]
fn test_allowed_dirs_custom_authorized() {
let mut set = HashSet::new();
let custom = PathBuf::from("E:/wk-test-external-dir");
set.insert(custom.clone());
let allowed = AllowedDirs { persistent: set };
// 精确命中 + 子路径 starts_with 命中
assert!(allowed.is_authorized(&custom));
assert!(allowed.is_authorized(&custom.join("sub").join("file.txt")));
}
/// 未授权目录被拒绝
#[test]
fn test_allowed_dirs_unauthorized_rejected() {
let mut set = HashSet::new();
set.insert(PathBuf::from("E:/wk-test-authorized"));
let allowed = AllowedDirs { persistent: set };
let outside = PathBuf::from("E:/wk-test-unauthorized/file.txt");
assert!(!allowed.is_authorized(&outside), "未授权目录应被拒绝");
}
/// 默认(空 persistent)只授权 workspace_root
#[test]
fn test_allowed_dirs_default_empty_persistent() {
let allowed = AllowedDirs::default();
// 空 persistent,无 workspace_root → 任何路径都不授权
// (default_with_root 才含 workspace_root;Default 不含,用于边界测试)
assert!(!allowed.is_authorized(&PathBuf::from("E:/anything")));
}
/// SETTINGS_KEY 常量稳定(防 rename 致持久化数据丢失)
#[test]
fn test_allowed_dirs_settings_key_stable() {
assert_eq!(AllowedDirs::SETTINGS_KEY, "allowed_dirs");
}
}

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'
// ============================================================