/ internal / skills / validate.go
validate.go
 1  package skills
 2  
 3  import (
 4  	"fmt"
 5  	"regexp"
 6  	"strings"
 7  	"unicode/utf8"
 8  )
 9  
10  var skillNameRegex = regexp.MustCompile(`^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$`)
11  
12  // builtinCommands mirrors agents.BuiltinCommands to avoid an import cycle.
13  // Keep in sync with internal/agents/validate.go.
14  var builtinCommands = map[string]bool{
15  	"quit": true, "exit": true, "help": true, "clear": true,
16  	"sessions": true, "session": true, "model": true, "config": true,
17  	"setup": true, "update": true, "copy": true, "research": true,
18  	"swarm": true, "search": true,
19  }
20  
21  func ValidateSkillName(name string) error {
22  	if name == "" {
23  		return fmt.Errorf("skill name is required")
24  	}
25  	if len(name) > 64 {
26  		return fmt.Errorf("skill name %q exceeds 64 characters", name)
27  	}
28  	if !skillNameRegex.MatchString(name) {
29  		return fmt.Errorf("skill name %q must contain only lowercase letters, numbers, and hyphens (no underscores), and must not start or end with a hyphen", name)
30  	}
31  	if strings.Contains(name, "--") {
32  		return fmt.Errorf("skill name %q must not contain consecutive hyphens", name)
33  	}
34  	if builtinCommands[name] {
35  		return fmt.Errorf("skill name %q conflicts with built-in slash command", name)
36  	}
37  	return nil
38  }
39  
40  // validateFrontmatterName bounds the frontmatter.name field so it can't
41  // smuggle newlines or control chars into LLM-visible contexts (skill
42  // catalog, use_skill output, sticky reinjection). Unlike slugs, frontmatter
43  // name is a free-form display label and may contain uppercase / mixed
44  // case / CJK / spaces; we only reject hostile formatting.
45  func validateFrontmatterName(name string) error {
46  	if name == "" {
47  		return fmt.Errorf("skill name is required in frontmatter")
48  	}
49  	// Count runes, not bytes, so CJK names (3 bytes per character in UTF-8)
50  	// are not wrongly rejected at ~33 characters.
51  	if utf8.RuneCountInString(name) > 100 {
52  		return fmt.Errorf("skill frontmatter name exceeds 100 characters")
53  	}
54  	for _, r := range name {
55  		// Reject ASCII control chars and the DEL character. U+0000–U+001F
56  		// and U+007F can break the "Available Skills" list formatting or
57  		// inject markers into <system-reminder> content.
58  		if r < 0x20 || r == 0x7f {
59  			return fmt.Errorf("skill frontmatter name contains a control character")
60  		}
61  	}
62  	return nil
63  }