//! AI 工具注册表构建 + 文件路径校验 use std::path::{Path, PathBuf}; use std::sync::Arc; use df_ai::ai_tools::{AiToolRegistry, RiskLevel}; use df_storage::db::Database; use df_storage::models::{ProjectRecord, TaskRecord, IdeaRecord}; use df_core::types::new_id; use crate::commands::now_millis; /// 验证文件路径:禁止访问系统敏感目录 fn validate_path(path: &str) -> anyhow::Result<()> { // 规范化为反斜杠:LLM 可能传正斜杠绕过黑名单(Windows tokio::fs 两种分隔符都吃) let normalized = path.replace('/', "\\"); let lower = normalized.to_lowercase(); if lower.contains("..") { anyhow::bail!("禁止路径遍历 (..)"); } if lower.contains("\\.ssh") || lower.contains("\\.aws") || lower.contains("\\.gnupg") || lower.contains("\\appdata\\") || lower.contains("\\programdata\\") || lower.contains("\\windows\\") || lower.contains("\\system32\\") { anyhow::bail!("禁止访问敏感系统目录"); } Ok(()) } /// workspace 根目录(项目根 = src-tauri 上两级,编译期固定) fn workspace_root() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")) .parent() .and_then(|p| p.parent()) .map(PathBuf::from) .unwrap_or_else(|| PathBuf::from(".")) } /// 解析文件工具路径:相对路径锚定 workspace_root,禁止越出项目目录 /// /// 双层校验: /// 1. 词法层 starts_with(root)——对不存在路径(write_file 新建文件)兜底防越界 /// 2. canonicalize 层——对存在路径解析符号链接,防 workspace 内 symlink 指向外部的逃逸 /// 仅校验,返回词法 resolved(不含 \\?\ 前缀),保证 read_file 返回的 path 对前端友好 fn resolve_workspace_path(path: &str) -> anyhow::Result { validate_path(path)?; let root = workspace_root(); let resolved = if Path::new(path).is_absolute() { PathBuf::from(path) } else { root.join(path) }; // 词法层:防明显越界(不存在路径的兜底) if !resolved.starts_with(&root) { anyhow::bail!("禁止访问项目目录之外: {}", path); } // canonicalize 层:存在路径解析 symlink,防经符号链接逃逸出 workspace if resolved.exists() { let canon_root = root.canonicalize()?; let canon_resolved = resolved.canonicalize()?; if !canon_resolved.starts_with(&canon_root) { anyhow::bail!("禁止访问项目目录之外(符号链接逃逸): {}", path); } } Ok(resolved) } /// 构建 AI 工具注册表 — handler 即唯一执行路径(schema+risk+实现同源,消除双轨) /// /// CRUD 工具闭包捕获 `db` Arc 重建 Repo;文件系统工具复用 resolve_workspace_path / /// list_dir_recursive。新增工具只改这里一处,定义与实现同源,编译期保证一致。 pub fn build_ai_tool_registry(db: &Arc) -> AiToolRegistry { let mut registry = AiToolRegistry::new(); // ── 只读 (Low) ── registry.register( "list_projects", "列出所有项目,返回项目列表(ID、名称、状态、描述)", df_ai::ai_tools::object_schema(vec![]), RiskLevel::Low, { let db = db.clone(); Box::new(move |_args: serde_json::Value| { let db = db.clone(); Box::pin(async move { let repo = df_storage::crud::ProjectRepo::new(&db); let mut items = repo.list_active().await?; // list_active 排除回收站(deleted_at),防 LLM 看到已软删项目 items.truncate(50); // 防 LLM context 膨胀 Ok(serde_json::to_value(items)?) }) })}, ); registry.register( "list_tasks", "列出任务,可按 project_id 筛选", df_ai::ai_tools::object_schema(vec![("project_id", "string", false)]), RiskLevel::Low, { let db = db.clone(); Box::new(move |args: serde_json::Value| { let db = db.clone(); Box::pin(async move { let repo = df_storage::crud::TaskRepo::new(&db); let mut tasks = if let Some(pid) = args.get("project_id").and_then(|v| v.as_str()) { repo.query("project_id", pid).await? } else { repo.list_all().await? }; tasks.truncate(50); // 防 LLM context 膨胀 Ok(serde_json::to_value(tasks)?) }) })}, ); registry.register( "list_ideas", "列出所有想法", df_ai::ai_tools::object_schema(vec![]), RiskLevel::Low, { let db = db.clone(); Box::new(move |_args: serde_json::Value| { let db = db.clone(); Box::pin(async move { let repo = df_storage::crud::IdeaRepo::new(&db); let mut items = repo.list_all().await?; items.truncate(50); // 防 LLM context 膨胀 Ok(serde_json::to_value(items)?) }) })}, ); // ── 创建 (Medium) ── registry.register( "update_project", "更新项目的指定字段(name/status/description/path/stack),需要提供项目 ID、字段名和新值。绑定代码目录推荐改用 bind_directory", df_ai::ai_tools::object_schema(vec![("id", "string", true), ("field", "string", true), ("value", "string", true)]), RiskLevel::Medium, { let db = db.clone(); Box::new(move |args: serde_json::Value| { let db = db.clone(); Box::pin(async move { let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id"))?; let field = args["field"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 field"))?; let value = args["value"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 value"))?; match field { "name" | "status" | "description" | "path" | "stack" => {}, _ => anyhow::bail!("不允许更新字段 '{}'", field) } let repo = df_storage::crud::ProjectRepo::new(&db); repo.update_field(id, field, value).await?; Ok(serde_json::json!({ "id": id, "field": field, "updated": true })) }) })}, ); registry.register( "create_project", "创建新项目", df_ai::ai_tools::object_schema(vec![("name", "string", true), ("description", "string", false)]), RiskLevel::Medium, { let db = db.clone(); Box::new(move |args: serde_json::Value| { let db = db.clone(); Box::pin(async move { let name = args["name"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 name 参数"))?; let description = args["description"].as_str().unwrap_or(""); let repo = df_storage::crud::ProjectRepo::new(&db); let record = ProjectRecord { id: new_id(), name: name.to_string(), description: description.to_string(), status: "planning".to_string(), idea_id: None, path: None, stack: None, created_at: now_millis(), updated_at: now_millis(), }; let id = record.id.clone(); repo.insert(record).await?; Ok(serde_json::json!({ "id": id, "name": name, "status": "planning" })) }) })}, ); registry.register( "bind_directory", "为项目绑定代码目录(自动探测技术栈,防重复绑定)", df_ai::ai_tools::object_schema(vec![("id", "string", true), ("path", "string", true)]), RiskLevel::Medium, { let db = db.clone(); Box::new(move |args: serde_json::Value| { let db = db.clone(); Box::pin(async move { let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id 参数"))?; let path = args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?; let dir = std::path::Path::new(path); if !dir.is_dir() { anyhow::bail!("目录不存在: {path}"); } let repo = df_storage::crud::ProjectRepo::new(&db); // 防重复:canonicalize 规范化比较,防路径写法差异绕过 let normalize = |s: &str| -> String { std::path::Path::new(s) .canonicalize() .map(|a| a.to_string_lossy().replace('\\', "/").to_lowercase()) .unwrap_or_else(|_| s.trim_end_matches(['\\', '/']).replace('\\', "/").to_lowercase()) }; let target = normalize(path); let projects = repo.list_active().await?; for proj in &projects { if proj.id != id { if let Some(pp) = &proj.path { if normalize(pp) == target { anyhow::bail!("目录已被项目「{}」绑定", proj.name); } } } } // 探测技术栈 let stack = df_project::scan::detect_stack(dir)?; let stack_json = serde_json::to_string(&stack)?; repo.update_field(id, "path", path).await?; repo.update_field(id, "stack", &stack_json).await?; Ok(serde_json::json!({ "id": id, "path": path, "stack": stack, "bound": true })) }) })}, ); registry.register( "create_task", "在指定项目下创建新任务", df_ai::ai_tools::object_schema(vec![("project_id", "string", true), ("title", "string", true), ("description", "string", false), ("priority", "integer", false)]), RiskLevel::Medium, { let db = db.clone(); Box::new(move |args: serde_json::Value| { let db = db.clone(); Box::pin(async move { let project_id = args["project_id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 project_id"))?; let title = args["title"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 title"))?; let repo = df_storage::crud::TaskRepo::new(&db); let record = TaskRecord { id: new_id(), project_id: project_id.to_string(), title: title.to_string(), description: args["description"].as_str().unwrap_or("").to_string(), status: "todo".to_string(), priority: args["priority"].as_i64().unwrap_or(2) as i32, branch_name: None, assignee: None, workflow_def_id: None, base_branch: None, created_at: now_millis(), updated_at: now_millis(), }; let id = record.id.clone(); repo.insert(record).await?; Ok(serde_json::json!({ "id": id, "title": title, "status": "todo" })) }) })}, ); registry.register( "create_idea", "捕获一个新想法", df_ai::ai_tools::object_schema(vec![("title", "string", true), ("description", "string", false), ("tags", "string", false), ("source", "string", false)]), RiskLevel::Medium, { let db = db.clone(); Box::new(move |args: serde_json::Value| { let db = db.clone(); Box::pin(async move { let title = args["title"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 title"))?; let repo = df_storage::crud::IdeaRepo::new(&db); let record = IdeaRecord { id: new_id(), title: title.to_string(), description: args["description"].as_str().unwrap_or("").to_string(), status: "draft".to_string(), priority: args["priority"].as_i64().unwrap_or(1) as i32, score: None, tags: args["tags"].as_str().map(|s| s.to_string()), source: args["source"].as_str().map(|s| s.to_string()), promoted_to: None, ai_analysis: None, scores: None, created_at: now_millis(), updated_at: now_millis(), }; let id = record.id.clone(); repo.insert(record).await?; Ok(serde_json::json!({ "id": id, "title": title, "status": "draft" })) }) })}, ); // ── 高风险 (High) ── registry.register( "delete_project", "删除项目(移入回收站,可恢复。永久删除用 purge_project)", df_ai::ai_tools::object_schema(vec![("id", "string", true)]), RiskLevel::High, { let db = db.clone(); Box::new(move |args: serde_json::Value| { let db = db.clone(); Box::pin(async move { let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id"))?; let repo = df_storage::crud::ProjectRepo::new(&db); let deleted = repo.soft_delete(id).await?; Ok(serde_json::json!({ "deleted": deleted, "id": id })) }) })}, ); registry.register( "restore_project", "从回收站恢复已删除项目", df_ai::ai_tools::object_schema(vec![("id", "string", true)]), RiskLevel::High, { let db = db.clone(); Box::new(move |args: serde_json::Value| { let db = db.clone(); Box::pin(async move { let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id"))?; let repo = df_storage::crud::ProjectRepo::new(&db); let restored = repo.restore(id).await?; Ok(serde_json::json!({ "restored": restored, "id": id })) }) })}, ); registry.register( "purge_project", "永久删除项目及关联数据(不可恢复)", df_ai::ai_tools::object_schema(vec![("id", "string", true)]), RiskLevel::High, { let db = db.clone(); Box::new(move |args: serde_json::Value| { let db = db.clone(); Box::pin(async move { let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id"))?; let repo = df_storage::crud::ProjectRepo::new(&db); let purged = repo.purge_with_descendants(id).await?; Ok(serde_json::json!({ "purged": purged, "id": id })) }) })}, ); registry.register( "list_trash", "列出回收站已删除项目", df_ai::ai_tools::object_schema(vec![]), RiskLevel::Low, { let db = db.clone(); Box::new(move |_args: serde_json::Value| { let db = db.clone(); Box::pin(async move { let repo = df_storage::crud::ProjectRepo::new(&db); let mut items = repo.list_deleted().await?; items.truncate(50); // 防 LLM context 膨胀 Ok(serde_json::to_value(items)?) }) })}, ); registry.register( "run_workflow", "运行指定的工作流 DAG", df_ai::ai_tools::object_schema(vec![("name", "string", true), ("dag", "object", true)]), RiskLevel::High, Box::new(|_args: serde_json::Value| Box::pin(async move { // run_workflow 需完整 DAG 执行,返回提示由前端触发 Ok(serde_json::json!({ "note": "请通过工作流页面运行工作流", "tool": "run_workflow" })) })), ); // ── 文件系统 ── registry.register( "read_file", "读取文件内容,返回文本内容。支持 offset 和 limit 参数分页读取大文件", df_ai::ai_tools::object_schema(vec![("path", "string", true), ("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 metadata = tokio::fs::metadata(path).await .map_err(|e| anyhow::anyhow!("无法访问文件 {}: {}", path, e))?; if metadata.len() > 1_048_576 { anyhow::bail!("文件超过 1MB 限制 ({} 字节)", metadata.len()); } let content = tokio::fs::read_to_string(path).await .map_err(|e| anyhow::anyhow!("读取文件失败: {}", e))?; let result = if let Some(offset) = args["offset"].as_u64() { let lines: Vec<&str> = content.lines().collect(); let skip = offset as usize; let limit = args["limit"].as_u64().unwrap_or(200) as usize; lines.into_iter().skip(skip).take(limit).collect::>().join("\n") } else { content.clone() }; let line_count = content.lines().count(); Ok(serde_json::json!({ "path": path, "content": result, "size": metadata.len(), "lines": line_count })) })), ); registry.register( "list_directory", "列出目录内容,返回文件和子目录列表(名称、类型、大小)", df_ai::ai_tools::object_schema(vec![("path", "string", true), ("recursive", "boolean", false), ("skip_noise_dirs", "boolean", 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); let mut entries = Vec::new(); let truncated = list_dir_recursive(path, recursive, 0, 2, 1000, skip_noise, &mut entries).await?; Ok(serde_json::json!({ "path": path, "entries": entries, "truncated": truncated })) })), ); registry.register( "write_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 参数"))?; if let Some(parent) = std::path::Path::new(path).parent() { tokio::fs::create_dir_all(parent).await .map_err(|e| anyhow::anyhow!("创建目录失败: {}", e))?; } tokio::fs::write(path, content).await .map_err(|e| anyhow::anyhow!("写入文件失败: {}", e))?; Ok(serde_json::json!({ "path": path, "bytes_written": content.len() })) })), ); registry } /// 递归列出目录内容(最多 max_depth 层,最多 max_entries 条) /// /// - 噪音目录(`.git`/`node_modules`/`target` 等)会被列出(显示存在),但不深入其内部 /// - 达 `max_entries` 即停止,返回 `Ok(true)` 表示被截断 fn list_dir_recursive<'a>( path: &'a str, recursive: bool, depth: usize, max_depth: usize, max_entries: usize, skip_noise: bool, result: &'a mut Vec, ) -> std::pin::Pin> + Send + 'a>> { Box::pin(async move { let mut dir = tokio::fs::read_dir(path).await .map_err(|e| anyhow::anyhow!("无法读取目录 {}: {}", path, e))?; while let Some(entry) = dir.next_entry().await? { if result.len() >= max_entries { return Ok(true); // 达上限截断 } let name = entry.file_name().to_string_lossy().to_string(); let metadata = entry.metadata().await?; let is_dir = metadata.is_dir(); result.push(serde_json::json!({ "name": name, "type": if is_dir { "directory" } else { "file" }, "size": metadata.len(), "depth": depth, })); // 仅递归非噪音目录(skip_noise=true 时跳过 .git/node_modules/target 等) if recursive && is_dir && depth < max_depth && !(skip_noise && is_noise_dir(&name)) { let child_path = std::path::Path::new(path).join(&name).to_string_lossy().into_owned(); let truncated = list_dir_recursive(&child_path, true, depth + 1, max_depth, max_entries, skip_noise, result).await?; if truncated { return Ok(true); } } } Ok(false) }) } /// 判断是否为不应深入递归的噪音目录(构建产物/依赖/缓存等) fn is_noise_dir(name: &str) -> bool { const NOISE_DIRS: &[&str] = &[ ".git", "node_modules", "target", "dist", "build", ".next", ".cache", "__pycache__", ".venv", "venv", ".idea", ]; NOISE_DIRS.contains(&name) }