Files
DevFlow/src-tauri/src/commands/ai/tool_registry.rs
绝尘 3f0839ace1 修复: AI 删项目改软删 + 补回收站三工具 + list_projects 排除回收站
- delete_project 由 repo.delete 物理删改 soft_delete(与 UI 删一致)
- 新增 restore_project / purge_project / list_trash 三工具(复用 ProjectRepo 回收站方法)
- list_projects 改 list_active 排除回收站,防 LLM 看到已删项目继续操作

Sprint 20 审查 ①② + Workflow A 验证发现 list_projects 泄漏
2026-06-14 14:14:19 +08:00

434 lines
21 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<PathBuf> {
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<Database>) -> 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::<Vec<&str>>().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<serde_json::Value>,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = anyhow::Result<bool>> + 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)
}