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