新增: 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:
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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-04:mod.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_text,expected_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!(
|
||||
|
||||
@@ -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,
|
||||
// 知识库
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user