From 123d912f841ee7157e64b7536e53726a79053a9f Mon Sep 17 00:00:00 2001 From: matevip Date: Sat, 23 May 2026 09:08:01 +0800 Subject: [PATCH] fix(skill): allow template file access --- .../skill/runtime/SkillFileAccessPolicy.java | 10 +++--- .../vip/mate/tool/builtin/SkillFileTool.java | 11 +++--- .../document/GeneratedFileController.java | 5 ++- .../runtime/SkillFileAccessPolicyTest.java | 35 +++++++++++++++++++ 4 files changed, 51 insertions(+), 10 deletions(-) create mode 100644 mateclaw-server/src/test/java/vip/mate/skill/runtime/SkillFileAccessPolicyTest.java diff --git a/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillFileAccessPolicy.java b/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillFileAccessPolicy.java index d8e850a2..8fa19c5e 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillFileAccessPolicy.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillFileAccessPolicy.java @@ -7,7 +7,7 @@ import java.nio.file.Path; /** * 技能文件访问策略 - * 确保只能访问 skillDir 内的 references/ 和 scripts/ 文件 + * 确保只能访问 skillDir 内的 references/、scripts/ 和 templates/ 文件 */ @Slf4j @Component @@ -17,7 +17,7 @@ public class SkillFileAccessPolicy { * 验证文件路径是否安全 * * @param skillDir 技能根目录 - * @param relativePath 相对路径(必须以 references/ 或 scripts/ 开头) + * @param relativePath 相对路径(必须以 references/、scripts/ 或 templates/ 开头) * @return 归一化后的绝对路径,如果不安全则返回 null */ public Path validateAndResolve(Path skillDir, String relativePath) { @@ -28,8 +28,10 @@ public class SkillFileAccessPolicy { // 归一化路径分隔符 String normalized = relativePath.replace("\\", "/"); - // 必须以 references/ 或 scripts/ 开头 - if (!normalized.startsWith("references/") && !normalized.startsWith("scripts/")) { + // 必须以 references/、scripts/ 或 templates/ 开头 + if (!normalized.startsWith("references/") + && !normalized.startsWith("scripts/") + && !normalized.startsWith("templates/")) { log.warn("Invalid path prefix: {}", relativePath); return null; } diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/SkillFileTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/SkillFileTool.java index c2f14b36..b4b73e8b 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/SkillFileTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/SkillFileTool.java @@ -40,18 +40,19 @@ public class SkillFileTool { private final SkillUsageService usageService; @Tool(description = """ - Read a file from a skill's directory (SKILL.md, references/, or scripts/). + Read a file from a skill's directory (SKILL.md, references/, scripts/, or templates/). Use this when you need to access skill documentation or reference files. Parameters: - skillName: Name of the skill (e.g., "channel_message") - - filePath: Relative path within skill directory, must start with "references/" or "scripts/" - (e.g., "references/config.md", "scripts/helper.py") + - filePath: Relative path within skill directory, must start with "references/", "scripts/", + or "templates/" (e.g., "references/config.md", "scripts/helper.py", + "templates/template.html") To read SKILL.md itself, use "SKILL.md" as filePath Returns: File content as string, or error message if file not found or access denied. - Security: Only files under references/ and scripts/ can be accessed. Path traversal is blocked. + Security: Only files under references/, scripts/, and templates/ can be accessed. Path traversal is blocked. """) public String readSkillFile( @JsonProperty(required = true) @@ -59,7 +60,7 @@ public class SkillFileTool { String skillName, @JsonProperty(required = true) - @JsonPropertyDescription("Relative file path (e.g., 'references/doc.md' or 'scripts/run.py')") + @JsonPropertyDescription("Relative file path (e.g., 'references/doc.md', 'scripts/run.py', or 'templates/template.html')") String filePath, @JsonProperty(required = false) diff --git a/mateclaw-server/src/main/java/vip/mate/tool/document/GeneratedFileController.java b/mateclaw-server/src/main/java/vip/mate/tool/document/GeneratedFileController.java index acd1e3d0..f0420a4d 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/document/GeneratedFileController.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/document/GeneratedFileController.java @@ -39,8 +39,11 @@ public class GeneratedFileController { HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.parseMediaType(entry.mimeType())); // RFC 5987 filename* lets non-ASCII names round-trip in browsers. + String disposition = entry.mimeType() != null && entry.mimeType().startsWith("image/") + ? "inline" + : "attachment"; headers.add(HttpHeaders.CONTENT_DISPOSITION, - "attachment; filename=\"" + sanitizeAscii(entry.filename()) + disposition + "; filename=\"" + sanitizeAscii(entry.filename()) + "\"; filename*=UTF-8''" + encodedName); headers.setContentLength(entry.bytes().length); return ResponseEntity.ok().headers(headers).body(entry.bytes()); diff --git a/mateclaw-server/src/test/java/vip/mate/skill/runtime/SkillFileAccessPolicyTest.java b/mateclaw-server/src/test/java/vip/mate/skill/runtime/SkillFileAccessPolicyTest.java new file mode 100644 index 00000000..7f63647e --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/skill/runtime/SkillFileAccessPolicyTest.java @@ -0,0 +1,35 @@ +package vip.mate.skill.runtime; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +class SkillFileAccessPolicyTest { + + private final SkillFileAccessPolicy policy = new SkillFileAccessPolicy(); + private final Path skillDir = Path.of("/workspace/skills/architecture-diagram"); + + @Test + @DisplayName("allows architecture skill templates") + void allowsTemplatesDirectory() { + Path resolved = policy.validateAndResolve(skillDir, "templates/template.html"); + + assertEquals(skillDir.resolve("templates/template.html"), resolved); + } + + @Test + @DisplayName("still rejects unsupported top-level paths") + void rejectsUnsupportedTopLevelPaths() { + assertNull(policy.validateAndResolve(skillDir, "assets/logo.svg")); + } + + @Test + @DisplayName("rejects traversal from allowed directories") + void rejectsTraversal() { + assertNull(policy.validateAndResolve(skillDir, "templates/../SKILL.md")); + } +}