diff --git a/apps/ai-game-creator-shell/src-tauri/design-agent/tools.json b/apps/ai-game-creator-shell/src-tauri/design-agent/tools.json index 0199c7e7b..cc06a7600 100644 --- a/apps/ai-game-creator-shell/src-tauri/design-agent/tools.json +++ b/apps/ai-game-creator-shell/src-tauri/design-agent/tools.json @@ -3,7 +3,7 @@ {"type":"function","function":{"name":"list_resources","description":"列出只读随包文档的逻辑目录、资源 ID、标题和简介;使用返回的资源 ID 读取文档。","parameters":{"type":"object","properties":{},"additionalProperties":false}}}, {"type":"function","function":{"name":"read_resource","description":"读取一份固定资源文档全文。每次读取一个 resource_id;资源只读。读到内容缺失或不完整的文档时,由你自行判断和处理。","parameters":{"type":"object","properties":{"resource_id":{"type":"string"}},"required":["resource_id"],"additionalProperties":false}}}, {"type":"function","function":{"name":"patch_file","description":"局部修改 UTF-8 文件。使用 old_text/new_text,或使用 edits 一次进行多个独立替换;每个 old_text 必须非空且在原文件中唯一。所有 edit 会一次性校验;任何失败都不修改文件,错误会列出各失败项及可唯一匹配的其余项。","parameters":{"type":"object","properties":{"path":{"type":"string"},"old_text":{"type":"string"},"new_text":{"type":"string"},"edits":{"type":"array","items":{"type":"object","properties":{"old_text":{"type":"string"},"new_text":{"type":"string"}},"required":["old_text","new_text"],"additionalProperties":false}}},"required":["path"],"additionalProperties":false}}}, - {"type":"function","function":{"name":"delete_path","description":"谨慎使用;永久删除文件或目录;目录会连同全部内容递归删除,不备份。先确认目标及删除范围。不能删除工作区根目录,也不能经过链接。","parameters":{"type":"object","properties":{"path":{"type":"string"}},"required":["path"],"additionalProperties":false}}}, + {"type":"function","function":{"name":"delete_path","description":"谨慎使用;永久删除文件或目录;目录会连同全部内容递归删除,不备份。先确认目标及删除范围。不能删除工作区根目录或包含它的上级目录。允许路径经过链接;删除链接本身只移除链接,递归删除目录时不跟随其中的目录链接。","parameters":{"type":"object","properties":{"path":{"type":"string"}},"required":["path"],"additionalProperties":false}}}, {"type":"function","function":{"name":"list_dir","description":"列出目录中的文件和目录。","parameters":{"type":"object","properties":{"path":{"type":"string"}},"required":["path"],"additionalProperties":false}}}, {"type":"function","function":{"name":"read_file","description":"读取 UTF-8 文本文件。","parameters":{"type":"object","properties":{"path":{"type":"string"}},"required":["path"],"additionalProperties":false}}}, {"type":"function","function":{"name":"write_file","description":"创建或覆盖 UTF-8 文本文件。","parameters":{"type":"object","properties":{"path":{"type":"string"},"content":{"type":"string"}},"required":["path","content"],"additionalProperties":false}}}, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/design_tools.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/design_tools.rs index d2d6b4ec0..897ac91db 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/design_tools.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/design_tools.rs @@ -403,21 +403,11 @@ pub(crate) fn execute_design_file_tool( } "delete_path" => { let relative = required_tool_path(args)?; - let (display, path) = resolve_design_tool_path(root, &relative)?; - if display == "." { - return Err("不能删除工作区根目录".to_string()); - } - if design_path_is_link(&path) { - return Err("删除请使用目标的直接路径,不经过链接或路径折叠".to_string()); - } - if !path.exists() { - return Err(format!("路径不存在:{display}")); - } - if path.is_dir() { - remove_design_dir(&path)?; - } else { - fs::remove_file(&path).map_err(|error| format!("删除失败:{error}"))?; - } + let workspace = ensure_design_workspace(root)?; + // 保留 .. 的文件系统语义;祖先链接可以指向工作区外,不能提前词法折叠。 + let path = workspace.join(relative.replace('\\', "/")); + let display = design_tool_location(root, &path); + remove_design_path(&workspace, &path)?; Ok(Value::String(format!("已删除 {display}"))) } "search_text" => { @@ -852,23 +842,31 @@ fn design_path_is_link(path: &Path) -> bool { } } -fn remove_design_dir(path: &Path) -> Result<(), String> { +fn remove_design_path(workspace: &Path, path: &Path) -> Result<(), String> { + let metadata = fs::symlink_metadata(path).map_err(|error| format!("读取删除目标失败:{error}"))?; if design_path_is_link(path) { - return Err("删除请使用目标的直接路径,不经过链接或路径折叠".to_string()); - } - for entry in fs::read_dir(path).map_err(|error| format!("删除失败:{error}"))? { - let entry = entry.map_err(|error| format!("删除失败:{error}"))?; - let child = entry.path(); - if design_path_is_link(&child) { - return Err("删除请使用目标的直接路径,不经过链接或路径折叠".to_string()); - } - if child.is_dir() { - remove_design_dir(&child)?; - } else { - fs::remove_file(&child).map_err(|error| format!("删除失败:{error}"))?; + // Windows 目录链接 / junction 使用 RemoveDirectory,且不能解析到链接目标。 + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + const FILE_ATTRIBUTE_DIRECTORY: u32 = 0x10; + if metadata.file_attributes() & FILE_ATTRIBUTE_DIRECTORY != 0 { + return fs::remove_dir(path).map_err(|error| format!("删除失败:{error}")); + } } + return fs::remove_file(path).map_err(|error| format!("删除失败:{error}")); } - fs::remove_dir(path).map_err(|error| format!("删除失败:{error}")) + if metadata.is_dir() { + let target = fs::canonicalize(path).map_err(|error| format!("解析删除目标失败:{error}"))?; + let workspace = fs::canonicalize(workspace) + .map_err(|error| format!("解析策划工作区失败:{error}"))?; + if workspace.starts_with(&target) { + return Err("不能删除工作区根目录或包含它的上级目录".to_string()); + } + // 标准递归删除只移除目录内链接本身,不遍历链接目标。 + return fs::remove_dir_all(target).map_err(|error| format!("删除失败:{error}")); + } + fs::remove_file(path).map_err(|error| format!("删除失败:{error}")) } fn search_design_text( @@ -932,6 +930,65 @@ mod tests { PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("design-agent") } + #[test] + fn delete_protects_workspace_before_removing_any_contents() { + let temp = test_root(); + let root = temp.path(); + let workspace = ensure_design_workspace(root).unwrap(); + fs::create_dir(workspace.join("child")).unwrap(); + fs::write(workspace.join("keep.md"), "keep").unwrap(); + for path in [ + PathBuf::from("."), + PathBuf::from(".."), + workspace.clone(), + workspace.join("child/.."), + ] { + let error = execute_design_file_tool(root, "delete_path", &json!({"path":path})) + .expect_err("protect workspace and ancestors"); + assert!(error.contains("不能删除工作区根目录")); + assert_eq!(fs::read_to_string(workspace.join("keep.md")).unwrap(), "keep"); + assert!(workspace.join("child").is_dir()); + } + } + + #[cfg(unix)] + #[test] + fn delete_allows_ancestor_links_and_unlinks_without_following_targets() { + use std::os::unix::fs::symlink; + + let temp = test_root(); + let root = temp.path(); + let workspace = ensure_design_workspace(root).unwrap(); + let outside = tempfile::tempdir().unwrap(); + fs::write(outside.path().join("delete.md"), "delete").unwrap(); + fs::write(outside.path().join("keep.md"), "keep").unwrap(); + symlink(outside.path(), workspace.join("alias")).unwrap(); + execute_design_file_tool(root, "delete_path", &json!({"path":"alias/delete.md"})) + .expect("delete through ancestor link"); + assert!(!outside.path().join("delete.md").exists()); + + symlink(&workspace, workspace.join("self")).unwrap(); + execute_design_file_tool(root, "delete_path", &json!({"path":"self/."})) + .expect_err("workspace alias is protected"); + execute_design_file_tool(root, "delete_path", &json!({"path":"self"})) + .expect("unlink workspace alias only"); + execute_design_file_tool(root, "delete_path", &json!({"path":"alias"})) + .expect("unlink directory alias only"); + + fs::create_dir(workspace.join("remove")).unwrap(); + symlink(outside.path(), workspace.join("remove/alias")).unwrap(); + symlink(outside.path().join("missing"), workspace.join("dangling")).unwrap(); + for path in ["remove", "dangling"] { + execute_design_file_tool(root, "delete_path", &json!({"path":path})).unwrap(); + assert!(fs::symlink_metadata(workspace.join(path)).is_err()); + } + assert_eq!( + fs::read_to_string(outside.path().join("keep.md")).unwrap(), + "keep" + ); + assert!(workspace.is_dir()); + } + #[test] fn file_tools_edit_workspace_and_outside_files() { let temp = test_root(); diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 07789c0c0..1f6807071 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -10,13 +10,13 @@ ## 2026-09-29 策划文件工具说明去掉路径硬限制 - 决策:常驻提示词仍要求策划文件放在工作区内并使用相对路径。`read_file`、`write_file`、`list_dir`、`search_text`、`patch_file`、`delete_path` 的工具说明不再写「工作目录内」或「path 使用相对路径」。说明不宣布可以访问绝对路径或工作区外路径。 -- `delete_path` 仍写明不能删除工作区根目录,也不能经过链接。阶段产物、共享过程文件和速览卡链接的相对路径约定不变。 +- `delete_path` 保留工作区根目录及其上级目录保护,按真实路径在删除前判定;允许经过祖先链接,删除链接本身只移除链接,递归删除不跟随目录内的链接。阶段产物、共享过程文件和速览卡链接的相对路径约定不变。 - 关联文档:[策划 Agent 生产迁移与工作区浏览](../../technical/【技术方案】策划Agent生产迁移与工作区浏览-2026-09-10.md)。 ## 2026-09-29 策划文件工具接受绝对路径和工作区外路径 - 决策:Design Agent 的 `list_dir`、`read_file`、`write_file`、`patch_file`、`delete_path`、`search_text` 可以读写绝对路径,以及离开 `design_artifacts` 的路径。相对路径仍以策划工作区为基准,`..` 可以离开工作区。工作区内写入继续走现有私有文件写入;工作区外写入按普通文件创建父目录并写入。 -- 范围:用户工作区浏览和附件导入仍只使用 `design_artifacts`。阶段必需产物仍按工作区相对路径检查。删除不跟随符号链接,也不删除工作区根目录。提示词本轮未改。 +- 范围:用户工作区浏览和附件导入仍只使用 `design_artifacts`。阶段必需产物仍按工作区相对路径检查。删除允许经过祖先链接;删除链接本身只移除链接,递归删除不跟随目录内的链接。工作区根目录及其上级目录按真实路径保护。提示词本轮未改。 - 关联文档:[策划 Agent 生产迁移与工作区浏览](../../technical/【技术方案】策划Agent生产迁移与工作区浏览-2026-09-10.md)。 ## 2026-09-29 dev 渠道改名迁移放进安装器钩子,且只注入 dev diff --git a/docs/technical/【技术方案】策划Agent生产迁移与工作区浏览-2026-09-10.md b/docs/technical/【技术方案】策划Agent生产迁移与工作区浏览-2026-09-10.md index 677c212a7..ee0f84c68 100644 --- a/docs/technical/【技术方案】策划Agent生产迁移与工作区浏览-2026-09-10.md +++ b/docs/technical/【技术方案】策划Agent生产迁移与工作区浏览-2026-09-10.md @@ -68,7 +68,7 @@ - 旧的“批准 / 修改 / 退回重做”审批语义; - 多 Agent、Wiki、知识库、外部搜索和自动任务编排。 -2026-09-29:策划文件工具可以读取和写入绝对路径,以及 `design_artifacts` 之外的路径。相对路径仍以策划工作区为基准,`..` 可以离开工作区。用户工作区浏览和附件导入仍只使用 `design_artifacts`。删除不跟随符号链接,也不删除工作区根目录本身。凭据不写入提示词或日志。 +2026-09-29:策划文件工具可以读取和写入绝对路径,以及 `design_artifacts` 之外的路径。相对路径仍以策划工作区为基准,`..` 可以离开工作区。用户工作区浏览和附件导入仍只使用 `design_artifacts`。删除允许路径经过祖先链接;删除目标本身是链接时只移除链接(包括悬空链接),递归删除目录不跟随其中的目录链接。删除普通目录前以真实路径检查目标,拒绝工作区根目录及其上级目录,绝对路径中的 `..`、大小写或祖先链接别名均不得绕过;检查必须在删除任何内容前完成。相对删除路径保留 `..`,按文件系统实际链接目标解析,不提前做字符串折叠。凭据不写入提示词或日志。 当前实现入口如下: