//! AI 工具注册表构建 + 文件路径校验 use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::Arc; use df_ai::ai_tools::{AiToolRegistry, RiskLevel}; use df_execute::shell::{execute, ShellRequest}; 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(()) } /// 截断命令输出:超过 max 字节则保留尾部(报错堆栈通常在末尾)+ 追加截断提示。 /// run_command 专用:防编译输出/find//cat 大文件撑爆 LLM context。 /// 按 char 边界截(防切多字节 UTF-8 中间 panic)。 fn truncate_output(s: &str, max: usize) -> (String, bool) { if s.len() <= max { return (s.to_string(), false); } let end = s.len(); let mut start = end.saturating_sub(max); // 推进到 char 边界,避免从多字节 UTF-8 中间切开 while start < end && !s.is_char_boundary(start) { start += 1; } let tail = &s[start..]; ( format!("[输出已截断,原始 {} 字节,仅保留末尾 {} 字节]\n{}", s.len(), tail.len(), tail), true, ) } /// 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) } /// 为项目绑定代码目录的公共逻辑(FR-D7 抽取:create_project 与 bind_directory 共用,消除逐行重复) /// /// 步骤:① 目录存在性校验 ② normalize_path 比较防重复绑定(跨其他项目) /// ③ stack 由调用方提供则用,否则 spawn_blocking 探测(detect_stack 内含多次同步 fs IO) /// ④ 写回 path / stack 字段 /// /// - `stack_opt`: 调用方解析好的逗号分隔技术栈原始串(None=让本函数自动探测) /// - 返回最终采用的 stack(供调用方回填响应,保持原 bind_directory 返回结构) async fn bind_dir_to_project( repo: &df_storage::crud::ProjectRepo, id: &str, path: &str, stack_opt: Option<&str>, ) -> anyhow::Result> { let dir = std::path::Path::new(path); if !dir.is_dir() { anyhow::bail!("目录不存在: {path}"); } // 防重复(DRY R-PD-11):委托 ProjectRepo::find_path_conflict,与 project.rs::find_binding_conflict // 共用同一实现。normalize_path 规范化比较,防路径写法差异绕过(复用 df-project 公共 normalize_path)。 let target = df_project::scan::normalize_path(path); if let Some(conflict) = repo.find_path_conflict(&target, Some(id)).await? { anyhow::bail!("目录已被项目「{}」绑定", conflict.name); } // stack:AI/调用方提供则用,否则探测(detect_stack 内含多次同步 fs IO,必须 spawn_blocking 防阻塞 tokio runtime) let stack: Vec = if let Some(s) = stack_opt { s.split(',').map(|t| t.trim().to_string()).filter(|t| !t.is_empty()).collect() } else { let dir_buf = std::path::PathBuf::from(path); tokio::task::spawn_blocking(move || df_project::scan::detect_stack(&dir_buf)) .await .map_err(|e| anyhow::anyhow!("技术栈探测任务失败: {e}"))?? }; let stack_json = serde_json::to_string(&stack)?; repo.update_field(id, "path", path).await?; repo.update_field(id, "stack", &stack_json).await?; Ok(stack) } /// 构建 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、名称、状态、描述)。最多返回 50 条", 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 看到已软删项目 let total = items.len(); let truncated = total > 50; items.truncate(50); // 防 LLM context 膨胀 Ok(serde_json::json!({ "items": items, "truncated": truncated })) }) })}, ); registry.register( "list_tasks", "列出任务,可按 project_id 筛选。最多返回 50 条", 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? }; let total = tasks.len(); let truncated = total > 50; tasks.truncate(50); // 防 LLM context 膨胀 Ok(serde_json::json!({ "items": tasks, "truncated": truncated })) }) })}, ); registry.register( "list_ideas", "列出所有灵感。最多返回 50 条", 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?; let total = items.len(); let truncated = total > 50; items.truncate(50); // 防 LLM context 膨胀 Ok(serde_json::json!({ "items": items, "truncated": truncated })) }) })}, ); // ── 创建 (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"))?; // 复用 df-storage CRUD 白名单(按表隔离),与 update_field 校验同源 if !df_storage::crud::is_allowed_column("projects", field) { 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", "创建新项目,可选传 path/stack 一步完成创建+绑定目录(无需再调 bind_directory)", df_ai::ai_tools::object_schema(vec![ ("name", "string", true), ("description", "string", false), ("path", "string", false), ("stack", "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?; // path 缺省:保持原行为(仅创建,不绑定);有 path 则复用公共绑定闭包(create_project 与 bind_directory 同源) let path_opt = args.get("path").and_then(|v| v.as_str()).filter(|s| !s.is_empty()); if let Some(path) = path_opt { let stack_opt = args.get("stack").and_then(|v| v.as_str()).filter(|s| !s.is_empty()); let stack = bind_dir_to_project(&repo, &id, path, stack_opt).await?; Ok(serde_json::json!({ "id": id, "name": name, "status": "planning", "path": path, "stack": stack, "bound": true })) } else { 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), ("stack", "string", false)]), 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 stack_opt = args.get("stack").and_then(|v| v.as_str()).filter(|s| !s.is_empty()); let repo = df_storage::crud::ProjectRepo::new(&db); let stack = bind_dir_to_project(&repo, id, path, stack_opt).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(), // priority 默认 2(medium):与 commands::task::default_priority 一致,新任务默认中优先级(非 high) 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( "update_task", "更新任务的指定字段(title/description/status/priority/assignee 等),需要提供任务 ID、字段名和新值", 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"))?; // 复用 df-storage CRUD 白名单(按表隔离),与 update_field 校验同源 if !df_storage::crud::is_allowed_column("tasks", field) { anyhow::bail!("不允许更新字段 '{}'", field); } let repo = df_storage::crud::TaskRepo::new(&db); repo.update_field(id, field, value).await?; Ok(serde_json::json!({ "id": id, "field": field, "updated": true })) }) })}, ); // FR-D6:delete_task 对齐 commands::task::delete_task —— tasks 表无 deleted_at,走硬删 // (与 delete_project 的软删不同:projects 有专门回收站机制,tasks 没有,只能硬删) registry.register( "delete_task", "删除任务(硬删,不可恢复。清理孤儿任务时务必用本工具,不要误用 delete_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::TaskRepo::new(&db); let deleted = repo.delete(id).await?; Ok(serde_json::json!({ "deleted": deleted, "id": id })) }) })}, ); registry.register( "create_idea", "捕获一个新灵感", df_ai::ai_tools::object_schema(vec![ ("title", "string", true), ("description", "string", false), ("tags", "string", false), ("source", "string", false), // priority:与 commands::idea::CreateIdeaInput 默认值一致(=1,灵感默认普通优先级) ("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 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(), // priority 默认 1:灵感默认普通优先级(与 commands::idea::default_priority 及 tasks 表 SQL DEFAULT 1 对齐) 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( "run_command", "在指定工作目录执行 shell 命令(跑测试/构建/查看运行结果),返回 stdout/stderr/exit_code。高风险,须人工批准。命令需自包含(非交互式,避免需用户输入的程序)。默认超时 60 秒。用于验证刚写入的代码能否运行、跑测试、看报错迭代修改。", df_ai::ai_tools::object_schema(vec![ ("command", "string", true), ("working_dir", "string", false), ("timeout_secs", "integer", false), ]), RiskLevel::High, Box::new(|args: serde_json::Value| Box::pin(async move { let command = args["command"].as_str() .ok_or_else(|| anyhow::anyhow!("缺少 command 参数"))?; // working_dir 默认 workspace_root()(与 write_file 锚定一致:AI 写代码→同目录跑命令,闭环)。 // 走 validate_path 黑名单(.. + 敏感系统目录)作基础防线;不走 resolve_workspace_path 越界校验—— // run_command 是 High risk 靠人工审批兜底(用户在审批卡看清 command+working_dir), // 放开目录才能让 AI 在用户任意项目目录形成「写→跑→看→改」真闭环。 let working_dir = match args.get("working_dir").and_then(|v| v.as_str()) { Some(d) => { validate_path(d)?; d.to_string() } None => workspace_root().to_string_lossy().to_string(), }; // timeout 默认 60s:防 hang(交互式命令/死循环/大构建),LLM 可覆盖 let timeout_secs = args["timeout_secs"].as_u64().unwrap_or(60); let request = ShellRequest { command: command.to_string(), working_dir: Some(working_dir.clone()), env: HashMap::new(), timeout_secs: Some(timeout_secs), }; let result = execute(request).await?; // 输出截断:防编译输出/find//cat 大文件撑爆 LLM context(各 10KB,尾部保留-报错堆栈在末尾) const MAX_OUT: usize = 10_000; let (stdout, stdout_trunc) = truncate_output(&result.stdout, MAX_OUT); let (stderr, stderr_trunc) = truncate_output(&result.stderr, MAX_OUT); Ok(serde_json::json!({ "command": command, "working_dir": working_dir, "exit_code": result.exit_code, "duration_ms": result.duration_ms, "stdout": stdout, "stderr": stderr, "truncated": stdout_trunc || stderr_trunc, })) })), ); // ── 文件系统 ── 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!("路径含非法字符"))?; // 单次 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 .map_err(|e| anyhow::anyhow!("无法访问文件 {}: {}", path, e))?; let metadata = file.metadata().await .map_err(|e| anyhow::anyhow!("读取元数据失败 {}: {}", path, e))?; if metadata.len() > 1_048_576 { anyhow::bail!("文件超过 1MB 限制 ({} 字节)", metadata.len()); } // 二进制/非 UTF-8 降级:read_to_string 对二进制硬失败,降级返 binary 标记而非错(防读二进制炸对话) let mut content = String::new(); if let Err(e) = file.read_to_string(&mut content).await { if e.kind() == std::io::ErrorKind::InvalidData { return Ok(serde_json::json!({ "path": path, "content": null, "binary": true, "size": metadata.len(), "error": "文件非 UTF-8 文本(疑似二进制),无法作为文本读取" })); } anyhow::bail!("读取文件失败: {}", e); } // limit 硬上限 2000 行(防 LLM 传超大 limit 读全文件,1MB 限下仍可能数万行) 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).min(2000) 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), ("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); let max_depth = args["max_depth"].as_u64().unwrap_or(3) 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", "写入或创建文件,自动创建不存在的父目录", 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 参数"))?; // 写入大小上限(FR-S2:原无限制,LLM 经审批可写超大文件撑爆磁盘/对话历史) if content.len() > 1_048_576 { anyhow::bail!("写入内容超过 1MB 限制 ({} 字节)", content.len()); } let target = std::path::Path::new(path); // FR-S7 覆盖防护:覆盖非空文件前自动 .bak 备份(防 LLM 误用 write_file 当 edit 致数据彻底丢失) // 起因:会话 3473fcb7 AI 误传头部 3 行把 PROGRESS.md 762 行/72KB 覆盖成 248 字节 let old_size: Option = match tokio::fs::metadata(target).await { Ok(m) if m.len() > 0 => { let bak = format!("{}.bak", path); tokio::fs::copy(path, &bak).await .map_err(|e| anyhow::anyhow!("备份 .bak 失败: {}", e))?; Some(m.len()) } Ok(_) => Some(0), // 空文件(无需备份) Err(_) => None, // 不存在(新建) }; if let Some(parent) = target.parent() { // FR-S8 残余:parent 也必须在 workspace 内(防 path=workspace 根时 parent 越界 create_dir_all) if !parent.starts_with(&workspace_root()) { anyhow::bail!("禁止在项目目录之外创建目录"); } tokio::fs::create_dir_all(parent).await .map_err(|e| anyhow::anyhow!("创建目录失败: {}", e))?; } // FR-S7 原子写:tmp→rename,避免写到一半崩溃留半成品(.tmp-write 同目录保证 rename 不跨卷) let tmp = format!("{}.tmp-write", path); if let Err(e) = tokio::fs::write(&tmp, content).await { let _ = tokio::fs::remove_file(&tmp).await; return Err(anyhow::anyhow!("写入临时文件失败: {}", e)); } if let Err(e) = tokio::fs::rename(&tmp, path).await { let _ = tokio::fs::remove_file(&tmp).await; return Err(anyhow::anyhow!("原子替换失败: {}", e)); } // R-P2-2:rename 成功后清理 .bak(原子写已完成,.bak 不再需要); // rename 失败分支不删 .bak——它是回退依据(失败分支已 return,不会走到这里)。 // 仅当 old_size>0(曾备份过)才清理;忽略清理失败(非阻断,最多留个孤儿 .bak 文件) if old_size.map(|s| s > 0).unwrap_or(false) { let bak = format!("{}.bak", path); let _ = tokio::fs::remove_file(&bak).await; } // FR-S7 大小异动 warn:新内容远小于旧(疑似误覆盖整文件),提示用户查 .bak if let Some(old) = old_size { if old > 0 && (content.len() as f64 / old as f64) < 0.1 { tracing::warn!("write_file 疑似误覆盖: {} {}→{} 字节(缩减>90%),.bak 已备份", path, old, content.len()); } } Ok(serde_json::json!({ "path": path, "bytes_written": content.len(), "old_size": old_size })) })), ); // ── 文件元信息 (Low risk) ── registry.register( "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 metadata = tokio::fs::metadata(path).await .map_err(|e| anyhow::anyhow!("无法访问 {}: {}", path, e))?; let is_dir = metadata.is_dir(); let size = metadata.len(); let modified = metadata.modified() .ok().and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) .map(|d| d.as_millis() as i64); // is_binary: 读前 8KB 检测 \x00 let is_binary = if !is_dir && size > 0 { let sample = tokio::fs::read(path).await.unwrap_or_default(); sample[..sample.len().min(8192)].contains(&0x00) } else { false }; // lines: 文本文件 \n 计数(>2MB 跳过避免全量读) let lines = if !is_dir && !is_binary && size <= 2_097_152 { 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) ── registry.register( "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 参数"))?; if let Some(parent) = std::path::Path::new(path).parent() { if !parent.starts_with(&workspace_root()) { anyhow::bail!("禁止在项目目录之外创建目录"); } tokio::fs::create_dir_all(parent).await .map_err(|e| anyhow::anyhow!("创建目录失败: {}", e))?; } use tokio::io::AsyncWriteExt; let mut file = tokio::fs::OpenOptions::new().append(true).create(true).open(path).await .map_err(|e| anyhow::anyhow!("打开文件失败: {}", e))?; let bytes = content.as_bytes(); file.write_all(bytes).await.map_err(|e| anyhow::anyhow!("追加写入失败: {}", e))?; 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 })) })), ); // ── 文件搜索 (Low risk) ── registry.register( "search_files", "在指定目录下搜索匹配模式(字符串包含匹配)的文件名,返回路径和大小列表。支持递归搜索,结果限 50 条", df_ai::ai_tools::object_schema(vec![("path", "string", true), ("pattern", "string", true), ("recursive", "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 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 mut results = Vec::new(); let mut total = 0u64; search_files_recursive(path, &pattern_lower, recursive, 0, 5, MAX_RESULTS, &mut results, &mut total).await?; let has_more = total as usize > MAX_RESULTS; Ok(serde_json::json!({ "path": path, "pattern": pattern, "results": results, "total": total, "has_more": has_more })) })), ); registry } /// 递归列出目录内容(最多 max_depth 层,最多 max_entries 条) /// /// - 噪音目录(`.git`/`node_modules`/`target` 等)在 `skip_noise=true` 时不作 entry 返回,也不深入其内部 /// - 达 `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(); // 噪音目录(.git/node_modules/target 等)既不深入、也不作 entry 返回(避免污染 AI 工具上下文) if skip_noise && is_noise_dir(&name) { continue; } // 临时文件(.bak/.tmp-write 等)也不返回:write_file 原子写过程的副产物, // 崩溃/中断会留孤儿文件,泄漏进 list_directory 噪化 AI 上下文(CR-260615-03)。 if skip_noise && is_noise_file(&name) { continue; } // FR-S8:用 file_type() 不跟随 symlink——entry.metadata() 会解析符号链接目标, // 经 workspace 内 symlink 即可泄露外部目标的 size/类型,且递归会进入 symlink 指向的 workspace 外目录 let file_type = entry.file_type().await?; let is_symlink = file_type.is_symlink(); let is_dir = file_type.is_dir(); // symlink 算 symlink 非 directory,不会被递归 result.push(serde_json::json!({ "name": name, "type": if is_symlink { "symlink" } else if is_dir { "directory" } else { "file" }, "size": if is_symlink { 0 } else { entry.metadata().await.map(|m| m.len()).unwrap_or(0) }, "depth": depth, })); // 仅递归真实目录(symlink 不递归,防符号链接逃逸到 workspace 外) 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) } /// 判断是否为不应返回给 AI 的噪音文件(临时/备份/编辑器产物)。 /// /// write_file 原子写过程产生 `.tmp-write`(rename 前)与 `.bak`(覆盖前备份,rename 成功后清理), /// 崩溃/中断会留孤儿文件污染 list_directory 上下文。其它编辑器临时文件(`.swp`/`~` 等)同此处理。 /// 注:仅按后缀匹配,不依赖文件存在性——保持 list_directory 纯过滤语义,无额外 fs IO。 fn is_noise_file(name: &str) -> bool { const NOISE_SUFFIXES: &[&str] = &[".bak", ".tmp-write", ".swp", "~"]; NOISE_SUFFIXES.iter().any(|sfx| name.ends_with(sfx)) } /// 递归搜索文件(字符串包含匹配,大小写不敏感) fn search_files_recursive<'a>( path: &'a str, pattern: &'a str, recursive: bool, depth: usize, max_depth: usize, max_results: usize, results: &'a mut Vec, total: &'a mut u64, ) -> 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? { let name = entry.file_name().to_string_lossy().to_string(); let metadata = entry.metadata().await?; let is_dir = metadata.is_dir(); if !is_dir { // 字符串包含匹配(大小写不敏感) if name.to_lowercase().contains(pattern) { *total += 1; if results.len() < max_results { let full_path = std::path::Path::new(path).join(&name).to_string_lossy().into_owned(); results.push(serde_json::json!({ "path": full_path, "size": metadata.len() })); } } } else if recursive && depth < max_depth { let child_path = std::path::Path::new(path).join(&name).to_string_lossy().into_owned(); search_files_recursive(&child_path, pattern, true, depth + 1, max_depth, max_results, results, total).await?; } } Ok(()) }) } #[cfg(test)] mod tests { use super::*; use std::fs; /// is_noise_dir 纯函数:覆盖 .git/.gitignore 区分(目录是噪音,.gitignore 文件名不是) #[test] fn test_is_noise_dir_distinguishes_dir_and_gitignore_file() { // 噪音目录命中 assert!(is_noise_dir(".git")); assert!(is_noise_dir("node_modules")); assert!(is_noise_dir("target")); assert!(is_noise_dir("dist")); assert!(is_noise_dir("build")); // .gitignore / .gitattributes 等文件名不应命中(只匹配纯目录名 .git) assert!(!is_noise_dir(".gitignore")); assert!(!is_noise_dir(".gitattributes")); assert!(!is_noise_dir(".gitkeep")); } /// is_noise_dir 纯函数:普通目录不命中 #[test] fn test_is_noise_dir_normal_dirs_not_matched() { assert!(!is_noise_dir("src")); assert!(!is_noise_dir("tests")); assert!(!is_noise_dir("docs")); assert!(!is_noise_dir("main.rs")); assert!(!is_noise_dir("")); } /// is_noise_dir 纯函数:大小写敏感(不靠 to_lowercase 误命中 Target) #[test] fn test_is_noise_dir_case_sensitive() { // 文件系统在 Windows 上大小写不敏感,但函数本身用精确匹配; // 锁定当前语义:大写变体不命中(避免后续误改 to_lowercase 引入行为变化) assert!(!is_noise_dir("Target")); assert!(!is_noise_dir("DIST")); assert!(!is_noise_dir("NODE_MODULES")); } /// is_noise_file 纯函数:write_file 副产物 / 编辑器临时文件命中 #[test] fn test_is_noise_file_matches_temp_artifacts() { // write_file 原子写副产物 assert!(is_noise_file("PROGRESS.md.bak")); assert!(is_noise_file("PROGRESS.md.tmp-write")); // 编辑器临时文件 assert!(is_noise_file(".main.rs.swp")); assert!(is_noise_file("main.rs~")); // 路径含但非后缀的应不命中(避免误杀) assert!(!is_noise_file("backup.bak.md")); assert!(!is_noise_file("main.rs")); assert!(!is_noise_file(".gitignore")); assert!(!is_noise_file("")); } /// 造一个临时目录树用于 list_dir_recursive 测试 fn build_noise_tree(root: &Path) { // .git/HEAD (噪音目录,内部文件不应出现) fs::create_dir_all(root.join(".git")).unwrap(); fs::write(root.join(".git").join("HEAD"), "ref: refs/heads/main").unwrap(); // node_modules/x/index.js (噪音目录,内部文件不应出现) fs::create_dir_all(root.join("node_modules").join("x")).unwrap(); fs::write(root.join("node_modules").join("x").join("index.js"), "module.exports=1;").unwrap(); // target/debug/bin (噪音目录,内部文件不应出现) fs::create_dir_all(root.join("target").join("debug")).unwrap(); fs::write(root.join("target").join("debug").join("app"), "binary").unwrap(); // 普通 src/main.rs (应出现) fs::create_dir_all(root.join("src")).unwrap(); fs::write(root.join("src").join("main.rs"), "fn main(){}").unwrap(); // .gitignore 文件 (文件名不是噪音目录,应出现) fs::write(root.join(".gitignore"), "/target\n").unwrap(); } /// list_dir_recursive:skip_noise=true 时噪音目录不出现在 entries(且不深入其内部) #[tokio::test] async fn test_list_dir_recursive_filters_noise_dirs() { let tmp = std::env::temp_dir().join(format!("df_tool_noise_{}", std::process::id())); let _ = fs::remove_dir_all(&tmp); fs::create_dir_all(&tmp).unwrap(); build_noise_tree(&tmp); let root = tmp.to_string_lossy().to_string(); let mut entries = Vec::new(); let truncated = list_dir_recursive(&root, true, 0, 3, 1000, true, &mut entries).await.unwrap(); assert!(!truncated); let names: Vec = entries.iter() .filter_map(|e| e["name"].as_str().map(|s| s.to_string())) .collect(); // 噪音目录本身及其内部文件均不应出现 assert!(!names.contains(&".git".to_string()), ".git 不应作为 entry 返回"); assert!(!names.contains(&"node_modules".to_string()), "node_modules 不应作为 entry 返回"); assert!(!names.contains(&"target".to_string()), "target 不应作为 entry 返回"); // 噪音目录内部文件也不应被递归带入 assert!(!names.iter().any(|n| n == "HEAD"), ".git/HEAD 不应出现"); assert!(!names.iter().any(|n| n == "index.js"), "node_modules/x/index.js 不应出现"); assert!(!names.iter().any(|n| n == "app"), "target/debug/app 不应出现"); // 普通目录与 .gitignore 文件应正常返回 assert!(names.contains(&"src".to_string()), "src 应作为 entry 返回"); assert!(names.contains(&"main.rs".to_string()), "src/main.rs 应被递归返回"); assert!(names.contains(&".gitignore".to_string()), ".gitignore 文件名不是噪音目录,应返回"); fs::remove_dir_all(&tmp).ok(); } /// list_dir_recursive:skip_noise=false 时噪音目录正常返回 #[tokio::test] async fn test_list_dir_recursive_keeps_noise_when_disabled() { let tmp = std::env::temp_dir().join(format!("df_tool_noisefalse_{}", std::process::id())); let _ = fs::remove_dir_all(&tmp); fs::create_dir_all(&tmp).unwrap(); build_noise_tree(&tmp); let root = tmp.to_string_lossy().to_string(); let mut entries = Vec::new(); list_dir_recursive(&root, false, 0, 1, 1000, false, &mut entries).await.unwrap(); let names: Vec = entries.iter() .filter_map(|e| e["name"].as_str().map(|s| s.to_string())) .collect(); // 关闭过滤后噪音目录应作为 entry 出现 assert!(names.contains(&".git".to_string())); assert!(names.contains(&"node_modules".to_string())); assert!(names.contains(&"target".to_string())); fs::remove_dir_all(&tmp).ok(); } /// list_dir_recursive:max_depth 控制递归深度 #[tokio::test] async fn test_list_dir_recursive_max_depth() { // 造 a/b/c/main.rs (4 层: a 在 depth0, b depth1, c depth2, main.rs depth3) let tmp = std::env::temp_dir().join(format!("df_tool_depth_{}", std::process::id())); let _ = fs::remove_dir_all(&tmp); fs::create_dir_all(tmp.join("a").join("b").join("c")).unwrap(); fs::write(tmp.join("a").join("b").join("c").join("main.rs"), "fn main(){}").unwrap(); let root = tmp.to_string_lossy().to_string(); // max_depth=1: 只到 depth1 (列出 a, 深入 a 列出 b, 但不再深入 b 因为 depth1 不 < 1) let mut entries = Vec::new(); list_dir_recursive(&root, true, 0, 1, 1000, true, &mut entries).await.unwrap(); let names: Vec = entries.iter() .filter_map(|e| e["name"].as_str().map(|s| s.to_string())) .collect(); assert!(names.contains(&"a".to_string()), "depth1 应含 a"); assert!(names.contains(&"b".to_string()), "max_depth=1 应深入 a 列出 b (depth1<1 为 false, 列 b 自身但不再递归)"); assert!(!names.contains(&"c".to_string()), "max_depth=1 不应到 c (depth1 已达上限)"); assert!(!names.contains(&"main.rs".to_string()), "max_depth=1 不应到 main.rs"); // max_depth=3: 应能到 depth3 的 main.rs let mut entries = Vec::new(); list_dir_recursive(&root, true, 0, 3, 1000, true, &mut entries).await.unwrap(); let names: Vec = entries.iter() .filter_map(|e| e["name"].as_str().map(|s| s.to_string())) .collect(); assert!(names.contains(&"main.rs".to_string()), "max_depth=3 应能递归到 main.rs"); fs::remove_dir_all(&tmp).ok(); } }