Validate skills against the open Agent Skills spec (N2 WP4)
ci / vet-and-test (pull_request) Successful in 1m1s
ci / docker-checkout-hostgateway (pull_request) Successful in 1s

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)
This commit is contained in:
2026-10-07 13:51:10 +00:00
parent 77e7f97827
commit e1fac5864d
6 changed files with 360 additions and 61 deletions
+2 -2
View File
@@ -260,7 +260,7 @@ func TestBackendSkillsIndexAndLoader(t *testing.T) {
if err := os.MkdirAll(skillDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("---\ndescription: 生成文档交付物\n---\n\n配方正文"), 0o644); err != nil {
if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("---\nname: docx-author\ndescription: 生成文档交付物\n---\n\n配方正文"), 0o644); err != nil {
t.Fatal(err)
}
stream, c := captureStream(
@@ -347,7 +347,7 @@ func TestBackendWorkspaceSkillsRootAutoMerged(t *testing.T) {
if err := os.MkdirAll(wsSkill, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(wsSkill, "SKILL.md"), []byte("---\ndescription: 工作区技能\n---\n正文"), 0o644); err != nil {
if err := os.WriteFile(filepath.Join(wsSkill, "SKILL.md"), []byte("---\nname: ws-skill\ndescription: 工作区技能\n---\n正文"), 0o644); err != nil {
t.Fatal(err)
}
res, err := b.Execute(context.Background(), exec.Request{
+1 -1
View File
@@ -61,7 +61,7 @@ func TestPiAliasesFilterAndSkillCommand(t *testing.T) {
if err := os.MkdirAll(skillDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("be thorough"), 0o644); err != nil {
if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("---\nname: review\ndescription: 评审技能\n---\n\nbe thorough"), 0o644); err != nil {
t.Fatal(err)
}
store := skills.NewStore(dir)
+184
View File
@@ -0,0 +1,184 @@
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)
}
+110
View File
@@ -0,0 +1,110 @@
package skills
import (
"strings"
"testing"
)
// ---- N2 WP4:开放规范校验矩阵 ----
func TestParseFrontmatterAllFields(t *testing.T) {
text := "---\n" +
"name: docx-author\n" +
"description: 生成 Word\n" +
"license: Apache-2.0\n" +
"compatibility: 需要 python-docx\n" +
"metadata:\n" +
" author: 交付组\n" +
" version: \"1.0\"\n" +
"allowed-tools: read_file write_file\n" +
"---\n\n正文"
fm, body, err := parseFrontmatter("x/SKILL.md", text)
if err != nil {
t.Fatal(err)
}
if fm.Name != "docx-author" || fm.Description != "生成 Word" || fm.License != "Apache-2.0" ||
fm.Compatibility != "需要 python-docx" || body == "" || !strings.Contains(body, "正文") {
t.Fatalf("%+v body=%q", fm, body)
}
if fm.Metadata["author"] != "交付组" || fm.Metadata["version"] != "\"1.0\"" {
t.Fatalf("metadata: %+v", fm.Metadata)
}
if len(fm.AllowedTools) != 2 || fm.AllowedTools[0] != "read_file" || fm.AllowedTools[1] != "write_file" {
t.Fatalf("allowed-tools: %+v", fm.AllowedTools)
}
}
func TestValidateFrontmatterMatrix(t *testing.T) {
mk := func(mutate func(*Frontmatter)) *Frontmatter {
fm := &Frontmatter{Name: "good-name", Description: "d", Metadata: map[string]string{}}
mutate(fm)
return fm
}
cases := []struct {
name string
fm *Frontmatter
dir string
wantKey string
}{
{"缺 name", mk(func(f *Frontmatter) { f.Name = "" }), "good-name", "name"},
{"name 大写", mk(func(f *Frontmatter) { f.Name = "Bad-Name" }), "good-name", "name"},
{"name 下划线", mk(func(f *Frontmatter) { f.Name = "bad_name" }), "good-name", "name"},
{"name 连续连字符", mk(func(f *Frontmatter) { f.Name = "bad--name" }), "good-name", "name"},
{"name 尾连字符", mk(func(f *Frontmatter) { f.Name = "bad-" }), "good-name", "name"},
{"name≠目录", mk(func(f *Frontmatter) {}), "other-dir", "name"},
{"缺 description", mk(func(f *Frontmatter) { f.Description = " " }), "good-name", "description"},
{"description 超长", mk(func(f *Frontmatter) { f.Description = strings.Repeat("长", 1025) }), "good-name", "description"},
{"compatibility 超长", mk(func(f *Frontmatter) { f.Compatibility = strings.Repeat("长", 501) }), "good-name", "compatibility"},
}
for _, tc := range cases {
err := validateFrontmatter("p/SKILL.md", tc.dir, tc.fm)
ve, ok := err.(ValidationError)
if err == nil || !ok || ve.Field != tc.wantKey {
t.Fatalf("%s: 期望 %s 字段错误,得到 %v", tc.name, tc.wantKey, err)
}
if !strings.Contains(ve.Error(), "p/SKILL.md") {
t.Fatalf("%s: 错误必须带路径:%v", tc.name, ve)
}
}
// 合法形态通过。
if err := validateFrontmatter("p/SKILL.md", "good-name", mk(func(f *Frontmatter) {})); err != nil {
t.Fatalf("合法形态被误拒:%v", err)
}
}
func TestParseFrontmatterRejectsBadShapes(t *testing.T) {
cases := map[string]string{
"未知键": "---\nname: a\ndescription: d\nbogus: x\n---\n",
"非 kv 行": "---\nname: a\n这是垃圾\n---\n",
"metadata 行内值": "---\nname: a\ndescription: d\nmetadata: {a: b}\n---\n",
"allowed-tools 空": "---\nname: a\ndescription: d\nallowed-tools:\n---\n",
"无结束围栏": "---\nname: a\n",
}
for name, text := range cases {
if _, _, err := parseFrontmatter("p/SKILL.md", text); err == nil {
t.Fatalf("%s:应拒绝", name)
} else if ve, ok := err.(ValidationError); !ok || ve.Msg == "" {
t.Fatalf("%s:错误必须带定位:%v", name, err)
}
}
}
func TestStoreDiagnosticsSurface(t *testing.T) {
root := t.TempDir()
writeSkill(t, root, "good", "x")
writeRawSkill(t, root, "bad-skill", "---\nname: bad-skill\n---\n缺 description")
store := NewStore(root)
list := store.List()
if len(list) != 1 {
t.Fatalf("good only: %d", len(list))
}
diags := store.Diagnostics()
if len(diags) != 1 || diags[0].Field != "description" {
t.Fatalf("diagnostics: %+v", diags)
}
// Load 同名返回诊断原文(含路径与字段)。
_, err := store.Load("bad-skill")
if err == nil || !strings.Contains(err.Error(), "description") {
t.Fatalf("load error: %v", err)
}
}
+44 -49
View File
@@ -1,8 +1,10 @@
// Package skills 实现 SKILL.md 技能的加载与按需展开。
//
// 目录约定:<root>/<skill-name>/SKILL.md,正文前可选 frontmatter
//(--- 包围,行前缀 description:,不引入 YAML 依赖)。
// 目录名是技能名的权威来源(避免目录与 frontmatter 漂移)。
// 目录约定:<root>/<skill-name>/SKILL.md,frontmatter 按开放规范校验
//(agentskills.io;name 必填且与目录同名、description 必填;已知键白名单;
// 不引入 YAML 依赖,用 frontmatter.go 的严格子集解析)。
// 校验失败不静默:记入 Store.Diagnostics(),Load 同名技能 fail-closed
// 带字段定位错误。
//
// 信任边界:技能正文进入模型上下文,属提示注入面——只加载组合根显式
// 配置的根目录与工作区 .agent/skills/;正文与索引总量受限。
@@ -22,7 +24,6 @@ import (
const (
maxSkillsPerRoot = 100
maxBodyBytes = 64 << 10
maxDescription = 200
)
// Skill 是一个可加载技能。
@@ -31,12 +32,15 @@ type Skill struct {
Description string
Body string
Source string
// Frontmatter 是校验后的头(License/Compatibility/Metadata/AllowedTools)。
Frontmatter Frontmatter
}
// Store 扫描若干根目录并按名加载技能。
type Store struct {
roots []string
cache map[string]Skill
roots []string
cache map[string]Skill
diagnostics []ValidationError
}
func NewStore(roots ...string) *Store {
@@ -49,6 +53,14 @@ func NewStore(roots ...string) *Store {
return &Store{roots: clean, cache: nil}
}
// Diagnostics 返回最近一次 List 的校验诊断(不合规技能的逐条原因)。
// 加载器不因单技能坏而中断索引,但原因必须可查(开放规范:错误上浮)。
func (s *Store) Diagnostics() []ValidationError {
out := make([]ValidationError, len(s.diagnostics))
copy(out, s.diagnostics)
return out
}
// Roots 返回配置的根目录(只读副本)。
func (s *Store) Roots() []string {
out := make([]string, len(s.roots))
@@ -58,6 +70,7 @@ func (s *Store) Roots() []string {
// List 扫描全部根目录(同名技能先配置者优先),返回索引。
func (s *Store) List() []Skill {
s.diagnostics = nil
cache := map[string]Skill{}
var out []Skill
for _, root := range s.roots {
@@ -77,7 +90,13 @@ func (s *Store) List() []Skill {
}
skill, err := parse(e.Name(), path, data)
if err != nil {
continue // 单个坏技能跳过,不拖垮整个索引
// 单个坏技能跳过(不拖垮索引),但原因记入诊断(错误上浮)。
if ve, ok := err.(ValidationError); ok {
s.diagnostics = append(s.diagnostics, ve)
} else {
s.diagnostics = append(s.diagnostics, ValidationError{Path: path, Field: "-", Msg: err.Error()})
}
continue
}
if _, dup := cache[skill.Name]; dup {
continue
@@ -91,7 +110,8 @@ func (s *Store) List() []Skill {
return out
}
// Load 按名加载技能正文(fail-closed:未知名返回错误,不猜测近似名)。
// Load 按名加载技能正文(fail-closed:未知名返回错误,不猜测近似名;
// 同名但校验失败时返回带字段定位的校验错误而非笼统 unknown)。
func (s *Store) Load(name string) (Skill, error) {
if s.cache == nil {
s.List()
@@ -104,6 +124,11 @@ func (s *Store) Load(name string) (Skill, error) {
if skill, ok := s.cache[name]; ok {
return skill, nil
}
for _, d := range s.diagnostics {
if strings.Contains(d.Path, string(filepath.Separator)+name+string(filepath.Separator)) {
return Skill{}, d
}
}
return Skill{}, fmt.Errorf("unknown skill %q (not installed in any configured root)", name)
}
@@ -136,53 +161,23 @@ func (s *Store) ExpandCommands(text string) (string, error) {
return b.String(), nil
}
// parse 解析一份 SKILL.md:目录名权威,frontmatter 可选。
// parse 解析并校验一份 SKILL.md(开放规范:name/description 必填、
// name 与目录同名、已知键白名单、错误带字段定位)。
func parse(dirName, path string, data []byte) (Skill, error) {
if !validSkillName(dirName) {
return Skill{}, fmt.Errorf("invalid skill dir name %q", dirName)
}
if len(data) > maxBodyBytes {
data = data[:maxBodyBytes]
}
text := string(data)
desc := ""
body := text
lines := strings.Split(text, "\n")
if len(lines) > 0 && strings.TrimSpace(lines[0]) == "---" {
end := -1
for i := 1; i < len(lines); i++ {
if strings.TrimSpace(lines[i]) == "---" {
end = i
break
}
}
if end > 0 {
for _, line := range lines[1:end] {
if v, ok := strings.CutPrefix(strings.TrimSpace(line), "description:"); ok {
desc = strings.TrimSpace(v)
}
}
body = strings.Join(lines[end+1:], "\n")
}
fm, body, err := parseFrontmatter(path, string(data))
if err != nil {
return Skill{}, err
}
if len(desc) > maxDescription {
desc = desc[:maxDescription] + "…"
if err := validateFrontmatter(path, dirName, fm); err != nil {
return Skill{}, err
}
return Skill{Name: dirName, Description: desc, Body: body, Source: path}, nil
}
func validSkillName(s string) bool {
if s == "" || len(s) > 64 {
return false
}
for _, r := range s {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_':
default:
return false
}
}
return true
return Skill{
Name: fm.Name, Description: fm.Description, Body: body,
Source: path, Frontmatter: *fm,
}, nil
}
// ---- load_skill 工具 ----
+19 -9
View File
@@ -11,46 +11,56 @@ import (
"agent-native/runtime/tools"
)
// writeSkill 写一份符合开放规范的 SKILL.md(name/description 齐备)。
func writeSkill(t *testing.T, root, name, body string) {
t.Helper()
writeRawSkill(t, root, name, "---\nname: "+name+"\ndescription: 测试技能 "+name+"\n---\n\n"+body)
}
func writeRawSkill(t *testing.T, root, name, text string) {
t.Helper()
dir := filepath.Join(root, name)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(body), 0o644); err != nil {
if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(text), 0o644); err != nil {
t.Fatal(err)
}
}
func TestStoreListAndLoad(t *testing.T) {
root := t.TempDir()
writeSkill(t, root, "docx-author", "---\ndescription: 生成 docx 交付物\n---\n\n## 步骤\n1. 用 run_python")
writeSkill(t, root, "docx-author", "## 步骤\n1. 用 run_python")
writeSkill(t, root, "xlsx-author", "没有 frontmatter 的技能正文")
writeSkill(t, root, "bad name", "目录名不合法") //nolint
// 无 frontmatter(缺 name/description):开放规范下不合规,进诊断不进索引。
writeRawSkill(t, root, "nofm", "没有任何头部")
store := NewStore(root)
list := store.List()
if len(list) != 2 {
t.Fatalf("want 2 valid skills (bad dir skipped), got %d: %+v", len(list), list)
t.Fatalf("want 2 valid skills (bad dir + nofm skipped), got %d: %+v", len(list), list)
}
byName := map[string]Skill{}
for _, s := range list {
byName[s.Name] = s
}
if byName["docx-author"].Description != "生成 docx 交付物" {
if byName["docx-author"].Description != "测试技能 docx-author" {
t.Fatalf("frontmatter description wrong: %+v", byName["docx-author"])
}
if !strings.Contains(byName["docx-author"].Body, "run_python") {
t.Fatalf("body must exclude frontmatter, got %q", byName["docx-author"].Body)
}
if byName["xlsx-author"].Description != "" || !strings.Contains(byName["xlsx-author"].Body, "没有 frontmatter") {
t.Fatalf("no-frontmatter skill wrong: %+v", byName["xlsx-author"])
}
skill, err := store.Load("docx-author")
if err != nil || !strings.Contains(skill.Body, "步骤") {
t.Fatalf("load failed: %v %+v", err, skill)
}
// 同名不合规:Load 返回带字段定位的校验错误(不是笼统 unknown)。
_, err = store.Load("nofm")
if err == nil || !strings.Contains(err.Error(), "name") {
t.Fatalf("invalid skill must surface its validation error, got %v", err)
}
_, err = store.Load("nope")
if err == nil || !strings.Contains(err.Error(), "unknown skill") {
t.Fatalf("unknown skill must fail-closed, got %v", err)
@@ -68,14 +78,14 @@ func TestStoreRootPriorityAndMissingRoot(t *testing.T) {
t.Fatalf("want 2 skills, got %d", len(list))
}
skill, _ := store.Load("dup")
if skill.Body != "from r1" {
if !strings.Contains(skill.Body, "from r1") {
t.Fatalf("earlier root must win, got %q", skill.Body)
}
}
func TestRegisterLoaderTool(t *testing.T) {
root := t.TempDir()
writeSkill(t, root, "docx-author", "---\ndescription: d\n---\n正文指令")
writeSkill(t, root, "docx-author", "正文指令")
reg := tools.NewRegistry(t.TempDir())
RegisterLoader(reg, NewStore(root))
var found bool