新增: 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,12 +945,16 @@ 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(
args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?,
)?;
let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?;
// 单次 File::open 取 metadata+read 消除 TOCTOU(FR-S2:原 metadata+read 两步 syscall 间 symlink 替换破 1MB 限)
{ 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 限)
use tokio::fs::File;
use tokio::io::AsyncReadExt;
let mut file = File::open(path).await
@@ -983,26 +1024,32 @@ 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(
args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?,
)?;
let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?;
let recursive = args["recursive"].as_bool().unwrap_or(false);
let skip_noise = args["skip_noise_dirs"].as_bool().unwrap_or(true);
// BUG-260617-09: max_depth 由 LLM 参数控制,无上限时虽 entries 上限(1000)
// 隐式约束,但深递归仍可能大量 fs IO / 撑爆上下文。clamp 到合理范围 1-10。
let max_depth = args["max_depth"].as_u64().unwrap_or(3).clamp(1, 10) as usize;
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 }))
})),
{ 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);
let skip_noise = args["skip_noise_dirs"].as_bool().unwrap_or(true);
// BUG-260617-09: max_depth 由 LLM 参数控制,无上限时虽 entries 上限(1000)
// 隐式约束,但深递归仍可能大量 fs IO / 撑爆上下文。clamp 到合理范围 1-10。
let max_depth = args["max_depth"].as_u64().unwrap_or(3).clamp(1, 10) as usize;
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,12 +1059,16 @@ 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(
args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?,
)?;
let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?;
let raw = args["content"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 content 参数"))?;
{ 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 参数"))?;
// F-260615-11: encoding 参数支持 base64,写二进制(图片/PDF/Excel 等)
// 默认 utf-8:走原 &str 字节写入;base64:解码为 Vec<u8> 写字节
let encoding = args.get("encoding").and_then(|v| v.as_str()).unwrap_or("utf-8");
@@ -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,13 +1173,17 @@ 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(
args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?,
)?;
let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?;
let new_text = args["new_text"].as_str()
.ok_or_else(|| anyhow::anyhow!("缺少 new_text 参数"))?;
{ 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()
.ok_or_else(|| anyhow::anyhow!("缺少 new_text 参数"))?;
// F-260617-01: 三模式互斥校验old_text / replace_lines / anchor
// 三选一:统计传入的模式参数数,>1 报错0 报错(缺定位方式)
@@ -1280,7 +1336,8 @@ fn register_file_tools(registry: &mut AiToolRegistry) {
result["warning"] = serde_json::Value::String(w);
}
Ok(result)
})),
})
})},
);
// ── 文件元信息 (Low risk) ──
@@ -1288,14 +1345,18 @@ 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(
args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?,
)?;
let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?;
let p = std::path::Path::new(path);
if !p.exists() {
return Ok(serde_json::json!({ "path": path, "exists": false }));
{ 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);
if !p.exists() {
return Ok(serde_json::json!({ "path": path, "exists": false }));
}
let metadata = tokio::fs::metadata(path).await
.map_err(|e| anyhow::anyhow!("无法访问 {}: {}", path, e))?;
@@ -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,12 +1399,16 @@ 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(
args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?,
)?;
let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?;
let content = args["content"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 content 参数"))?;
{ 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 参数"))?;
if let Some(parent) = std::path::Path::new(path).parent() {
if !parent.starts_with(&workspace_root()) {
anyhow::bail!("禁止在项目目录之外创建目录");
@@ -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,13 +1436,17 @@ 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(
args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?,
)?;
let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?;
let permanent = args.get("permanent").and_then(|v| v.as_bool()).unwrap_or(false);
{ 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);
let target = std::path::Path::new(path);
if !target.exists() {
anyhow::bail!("文件不存在: {}", path);
@@ -1419,7 +1490,8 @@ fn register_file_tools(registry: &mut AiToolRegistry) {
"backup_path": backup_path_str,
}))
}
})),
})
})},
);
// ── 重命名/移动文件 (Medium risk, F-260615-15) ──
@@ -1433,18 +1505,23 @@ 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,
// 各自含 validate_path(黑名单+.. 防遍历) + 词法 starts_with + canonicalize symlink 防逃逸
let from_resolved = resolve_workspace_path(
args["from"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 from 参数"))?,
)?;
let from_path = from_resolved.to_str().ok_or_else(|| anyhow::anyhow!("from 路径含非法字符"))?;
let to_resolved = resolve_workspace_path(
args["to"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 to 参数"))?,
)?;
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);
{ 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 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_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);
// 源存在性 + 拒目录(rename 目录语义复杂递归风险大,留 run_command 兜底)
let from_target = std::path::Path::new(from_path);
@@ -1512,7 +1589,8 @@ fn register_file_tools(registry: &mut AiToolRegistry) {
"bytes_moved": bytes_moved,
"cross_volume": true,
}))
})),
})
})},
);
// ── 文件搜索 (Low risk) ──
@@ -1520,17 +1598,21 @@ 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(
args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?,
)?;
let path = resolved.to_str().ok_or_else(|| anyhow::anyhow!("路径含非法字符"))?;
let pattern = args["pattern"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 pattern 参数"))?;
let recursive = args["recursive"].as_bool().unwrap_or(false);
let pattern_lower = pattern.to_lowercase();
const MAX_RESULTS: usize = 50;
let offset = args["offset"].as_u64().unwrap_or(0) as usize;
let limit = args["limit"].as_u64().unwrap_or(MAX_RESULTS as u64).min(MAX_RESULTS as u64) as usize;
{ 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 参数"))?;
let recursive = args["recursive"].as_bool().unwrap_or(false);
let pattern_lower = pattern.to_lowercase();
const MAX_RESULTS: usize = 50;
let offset = args["offset"].as_u64().unwrap_or(0) as usize;
let limit = args["limit"].as_u64().unwrap_or(MAX_RESULTS as u64).min(MAX_RESULTS as u64) as usize;
// 先收集全部结果用于 total 计数,再做 skip/take 分页
let mut all_results = Vec::new();
let mut total = 0u64;
@@ -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");
}
}