Files
Agent-Native/runtime/skills/frontmatter.go
weichen e1fac5864d
ci / vet-and-test (pull_request) Successful in 1m1s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s
Validate skills against the open Agent Skills spec (N2 WP4)
Why: the completion doc's N2 requires skills validated per the open
spec; today frontmatter was a description-only line hack with silent
skips, no name field, and no error surface.
Evidence: GOWORK=off go vet + go test -count=1 ./... green; parser
units cover all field forms and reject malformed shapes with located
errors; the validation matrix covers kebab-case/name-equals-dir/
required description/limits; Store.Diagnostics surfaces per-skill
reasons and Load returns the located validation error for same-named
invalid skills; existing fixtures updated to spec form.
Impact: stricter by design — skills without name/description or with
unknown frontmatter keys now produce located errors instead of silently
loading or silently skipping; ValidateSkillDocument is exported so the
product plugin/seed paths share the same validator.
Refs: Agent-System-Documents#42 (N2 plan WP4)
2026-10-07 13:51:10 +00:00

185 lines
6.4 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package skills
import (
"fmt"
"strings"
)
// frontmatter.go — SKILL.md frontmatter 的开放规范校验(N2 WP4)。
// 坚持不引入 YAML 依赖(skills.go 包注释的既有约束):手写严格子集,
// 只支持规范字段形态——标量、metadata 字符串映射、allowed-tools 空格
// 分隔列表;形态不合法即错误,不猜测。
// 已知键白名单(agentskills.io 开放规范);未知键带字段定位拒绝。
var knownFrontmatterKeys = map[string]bool{
"name": true, "description": true, "license": true,
"compatibility": true, "metadata": true, "allowed-tools": true,
}
const (
maxNameRunes = 64
maxDescription = 1024 // 规范上限(替换旧的 200 截断)
maxCompatibility = 500
maxMetadataEntries = 32
maxAllowedTools = 64
)
// ValidationError 是一条带字段定位的技能校验错误。
type ValidationError struct {
Path string // SKILL.md 路径
Field string // frontmatter 键("name"、"metadata.author"…);整体错误用 "-"
Msg string
}
func (e ValidationError) Error() string {
return fmt.Sprintf("%s: frontmatter %s: %s", e.Path, e.Field, e.Msg)
}
// Frontmatter 是解析+校验后的 SKILL.md 头。
type Frontmatter struct {
Name string
Description string
License string
Compatibility string
Metadata map[string]string
AllowedTools []string
}
// parseFrontmatter 解析 frontmatter 子集。无 frontmatter 时返回 nil(由
// 调用方按「name/description 必填」判错)。data 是完整文件内容。
func parseFrontmatter(path, text string) (*Frontmatter, string, error) {
fm := &Frontmatter{Metadata: map[string]string{}}
lines := strings.Split(text, "\n")
if len(lines) == 0 || strings.TrimSpace(lines[0]) != "---" {
return fm, text, nil
}
end := -1
for i := 1; i < len(lines); i++ {
if strings.TrimSpace(lines[i]) == "---" {
end = i
break
}
}
if end < 0 {
return nil, "", ValidationError{Path: path, Field: "-", Msg: "frontmatter 起始 --- 后找不到结束 ---"}
}
body := strings.Join(lines[end+1:], "\n")
for i := 1; i < end; i++ {
line := lines[i]
trimmed := strings.TrimSpace(line)
if trimmed == "" {
continue
}
// metadata 的缩进映射行在父键循环里处理。
key, value, found := strings.Cut(trimmed, ":")
if !found {
return nil, "", ValidationError{Path: path, Field: "-", Msg: fmt.Sprintf("第 %d 行不是 key: value 形态", i)}
}
key = strings.TrimSpace(key)
if !knownFrontmatterKeys[key] {
return nil, "", ValidationError{Path: path, Field: key, Msg: "未知 frontmatter 键(开放规范白名单:name/description/license/compatibility/metadata/allowed-tools)"}
}
value = strings.TrimSpace(value)
switch key {
case "metadata":
if value != "" {
return nil, "", ValidationError{Path: path, Field: "metadata", Msg: "metadata 必须是缩进映射,不接受行内值"}
}
j := i + 1
for ; j < end; j++ {
sub := lines[j]
if strings.TrimSpace(sub) == "" {
continue
}
if !strings.HasPrefix(sub, " ") && !strings.HasPrefix(sub, "\t") {
break
}
sk, sv, ok := strings.Cut(strings.TrimSpace(sub), ":")
if !ok || strings.TrimSpace(sk) == "" {
return nil, "", ValidationError{Path: path, Field: "metadata", Msg: fmt.Sprintf("第 %d 行不是 key: value 形态", j)}
}
if len(fm.Metadata) >= maxMetadataEntries {
return nil, "", ValidationError{Path: path, Field: "metadata." + strings.TrimSpace(sk), Msg: fmt.Sprintf("metadata 超过 %d 条上限", maxMetadataEntries)}
}
fm.Metadata[strings.TrimSpace(sk)] = strings.TrimSpace(sv)
}
i = j - 1
case "name":
fm.Name = value
case "description":
fm.Description = value
case "license":
fm.License = value
case "compatibility":
fm.Compatibility = value
case "allowed-tools":
if value == "" {
return nil, "", ValidationError{Path: path, Field: "allowed-tools", Msg: "allowed-tools 为空(应为空格分隔的工具名列表)"}
}
for _, t := range strings.Fields(value) {
fm.AllowedTools = append(fm.AllowedTools, t)
}
if len(fm.AllowedTools) > maxAllowedTools {
return nil, "", ValidationError{Path: path, Field: "allowed-tools", Msg: fmt.Sprintf("allowed-tools 超过 %d 条上限", maxAllowedTools)}
}
}
}
return fm, body, nil
}
// validateFrontmatter 按开放规范校验(dirName 是目录名,name 须与之相等)。
func validateFrontmatter(path, dirName string, fm *Frontmatter) error {
if fm.Name == "" {
return ValidationError{Path: path, Field: "name", Msg: "name 必填(开放规范)"}
}
if !validSkillName(fm.Name) {
return ValidationError{Path: path, Field: "name", Msg: fmt.Sprintf("name %q 不是 kebab-case(小写字母数字与单连字符,1-64 字符)", fm.Name)}
}
if fm.Name != dirName {
return ValidationError{Path: path, Field: "name", Msg: fmt.Sprintf("name %q 与目录名 %q 不一致(规范要求同名)", fm.Name, dirName)}
}
if strings.TrimSpace(fm.Description) == "" {
return ValidationError{Path: path, Field: "description", Msg: "description 必填且非空(开放规范)"}
}
if len([]rune(fm.Description)) > maxDescription {
return ValidationError{Path: path, Field: "description", Msg: fmt.Sprintf("description 超过 %d 字符上限", maxDescription)}
}
if len([]rune(fm.Compatibility)) > maxCompatibility {
return ValidationError{Path: path, Field: "compatibility", Msg: fmt.Sprintf("compatibility 超过 %d 字符上限", maxCompatibility)}
}
return nil
}
// validSkillName 是开放规范的 kebab-case:小写字母/数字段,段间单连字符,
// 无首尾/连续连字符,1-64 字符。
func validSkillName(s string) bool {
if s == "" || len(s) > maxNameRunes {
return false
}
if strings.HasPrefix(s, "-") || strings.HasSuffix(s, "-") || strings.Contains(s, "--") {
return false
}
for _, r := range s {
switch {
case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '-':
default:
return false
}
}
return true
}
// ValidateSkillDocument 解析并校验一份 SKILL.md(供组合根以外的消费方
// 复用同一校验器:插件技能装载、种子写入前置检查)。dirName 是技能目录名
// (name 必须与之相等);path 只用于错误定位。
func ValidateSkillDocument(dirName, path string, data []byte) error {
if len(data) > maxBodyBytes {
data = data[:maxBodyBytes]
}
fm, _, err := parseFrontmatter(path, string(data))
if err != nil {
return err
}
return validateFrontmatter(path, dirName, fm)
}