fix(skill): allow template file access

This commit is contained in:
matevip 2026-05-23 09:08:01 +08:00
parent cef1730e6e
commit 123d912f84
4 changed files with 51 additions and 10 deletions

View File

@ -7,7 +7,7 @@ import java.nio.file.Path;
/** /**
* 技能文件访问策略 * 技能文件访问策略
* 确保只能访问 skillDir 内的 references/ scripts/ 文件 * 确保只能访问 skillDir 内的 references/scripts/ templates/ 文件
*/ */
@Slf4j @Slf4j
@Component @Component
@ -17,7 +17,7 @@ public class SkillFileAccessPolicy {
* 验证文件路径是否安全 * 验证文件路径是否安全
* *
* @param skillDir 技能根目录 * @param skillDir 技能根目录
* @param relativePath 相对路径必须以 references/ scripts/ 开头 * @param relativePath 相对路径必须以 references/scripts/ templates/ 开头
* @return 归一化后的绝对路径如果不安全则返回 null * @return 归一化后的绝对路径如果不安全则返回 null
*/ */
public Path validateAndResolve(Path skillDir, String relativePath) { public Path validateAndResolve(Path skillDir, String relativePath) {
@ -28,8 +28,10 @@ public class SkillFileAccessPolicy {
// 归一化路径分隔符 // 归一化路径分隔符
String normalized = relativePath.replace("\\", "/"); String normalized = relativePath.replace("\\", "/");
// 必须以 references/ scripts/ 开头 // 必须以 references/scripts/ templates/ 开头
if (!normalized.startsWith("references/") && !normalized.startsWith("scripts/")) { if (!normalized.startsWith("references/")
&& !normalized.startsWith("scripts/")
&& !normalized.startsWith("templates/")) {
log.warn("Invalid path prefix: {}", relativePath); log.warn("Invalid path prefix: {}", relativePath);
return null; return null;
} }

View File

@ -40,18 +40,19 @@ public class SkillFileTool {
private final SkillUsageService usageService; private final SkillUsageService usageService;
@Tool(description = """ @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. Use this when you need to access skill documentation or reference files.
Parameters: Parameters:
- skillName: Name of the skill (e.g., "channel_message") - skillName: Name of the skill (e.g., "channel_message")
- filePath: Relative path within skill directory, must start with "references/" or "scripts/" - filePath: Relative path within skill directory, must start with "references/", "scripts/",
(e.g., "references/config.md", "scripts/helper.py") or "templates/" (e.g., "references/config.md", "scripts/helper.py",
"templates/template.html")
To read SKILL.md itself, use "SKILL.md" as filePath 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. 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( public String readSkillFile(
@JsonProperty(required = true) @JsonProperty(required = true)
@ -59,7 +60,7 @@ public class SkillFileTool {
String skillName, String skillName,
@JsonProperty(required = true) @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, String filePath,
@JsonProperty(required = false) @JsonProperty(required = false)

View File

@ -39,8 +39,11 @@ public class GeneratedFileController {
HttpHeaders headers = new HttpHeaders(); HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.parseMediaType(entry.mimeType())); headers.setContentType(MediaType.parseMediaType(entry.mimeType()));
// RFC 5987 filename* lets non-ASCII names round-trip in browsers. // 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, headers.add(HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=\"" + sanitizeAscii(entry.filename()) disposition + "; filename=\"" + sanitizeAscii(entry.filename())
+ "\"; filename*=UTF-8''" + encodedName); + "\"; filename*=UTF-8''" + encodedName);
headers.setContentLength(entry.bytes().length); headers.setContentLength(entry.bytes().length);
return ResponseEntity.ok().headers(headers).body(entry.bytes()); return ResponseEntity.ok().headers(headers).body(entry.bytes());

View File

@ -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"));
}
}