- 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 泄漏
434 lines
21 KiB
Rust
434 lines
21 KiB
Rust
//! 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)
|
||
}
|