9bb5d143f7
executus CI / test (pull_request) Successful in 3m30s
Real issues from the PR review: - security: readTree now skips symlinks (a pack with SKILL.md -> /etc/passwd or scripts/x -> ../../.ssh/id_rsa could read host files); covers file and dir symlinks, incl. within a git subpath - security: GitSource rejects url/ref beginning with '-' (git arg injection) and clones with '--' separator; --filter=blob:none (blobless partial clone) instead of full-history clone - correctness: Subscribe no longer swallows a non-ErrNotFound store error from GetByName (would create a duplicate subscription); handles *GitSource as well as GitSource in the URL/subpath extraction - correctness: pinTo no longer renames a subscription, so Apply can't silently collide two subscriptions when an upstream pack changes its name - validation: isKebab rejects leading/trailing/consecutive hyphens; BOM- prefixed SKILL.md now parses (matches the doc comment) - robustness: Catalog/Activate/renderPackBody/Stage guard nil/malformed packs - test cleanup: Syncer.Store field renamed Cache (collided with the Store interface); test NewID returns distinct ids - tests: symlink-skip, BOM, strict-kebab, nil-pack-safety Deferred (advisory perf, documented): PackCache stores raw trees so activation re-parses; CheckAll is serial. Both fine at expected scale. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
209 lines
6.7 KiB
Go
209 lines
6.7 KiB
Go
package skillpack
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
// ManifestName is the required filename at a pack's root.
|
|
const ManifestName = "SKILL.md"
|
|
|
|
// Limits on manifest fields, matching the Anthropic agent-skills constraints so
|
|
// packs authored against that ecosystem validate here unchanged.
|
|
const (
|
|
maxNameLen = 64
|
|
maxDescriptionLen = 1024
|
|
maxBodyBytes = 1 << 20 // 1 MiB of instruction text is already excessive
|
|
)
|
|
|
|
// Manifest is a parsed SKILL.md: YAML frontmatter plus the markdown body. Only
|
|
// Name and Description are required; everything else is optional and passes
|
|
// through so a host can honor it (or ignore it) without this package growing a
|
|
// policy opinion.
|
|
type Manifest struct {
|
|
// Name is the pack's stable identifier (kebab-case, unique within a host's
|
|
// subscriptions). It is what the model passes to skill_use.
|
|
Name string
|
|
// Description is the one-liner shown in the catalog — the ONLY text loaded
|
|
// into the prompt up front, so it must convey when to reach for the skill.
|
|
Description string
|
|
// License is an optional SPDX-ish tag, informational only.
|
|
License string
|
|
// AllowedTools is the pack author's declared tool allow-list. It is advisory
|
|
// here: a host MAY intersect it with the agent's real toolset, but it can
|
|
// only ever NARROW, never grant (see the host wiring, not this package).
|
|
AllowedTools []string
|
|
// Metadata is arbitrary passthrough frontmatter (e.g. version) the host may
|
|
// use; this package does not interpret it.
|
|
Metadata map[string]string
|
|
// Body is the markdown instruction text after the frontmatter — the payload
|
|
// skill_use returns on demand.
|
|
Body string
|
|
}
|
|
|
|
// ParseManifest parses a SKILL.md byte slice into a validated Manifest. The
|
|
// input must begin with a `---` YAML frontmatter block; the remainder is the
|
|
// body. It returns a descriptive error on malformed frontmatter or a field that
|
|
// violates the limits, so a bad pack fails loudly at subscribe/sync time rather
|
|
// than silently activating.
|
|
func ParseManifest(raw []byte) (*Manifest, error) {
|
|
front, body, err := splitFrontmatter(raw)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Decode into a permissive intermediate: SKILL.md uses hyphenated keys
|
|
// (allowed-tools) and lets metadata values be scalars of any type.
|
|
var fm struct {
|
|
Name string `yaml:"name"`
|
|
Description string `yaml:"description"`
|
|
License string `yaml:"license"`
|
|
AllowedTools stringList `yaml:"allowed-tools"`
|
|
Metadata map[string]any `yaml:"metadata"`
|
|
}
|
|
if err := yaml.Unmarshal(front, &fm); err != nil {
|
|
return nil, fmt.Errorf("skillpack: invalid SKILL.md frontmatter: %w", err)
|
|
}
|
|
|
|
m := &Manifest{
|
|
Name: strings.TrimSpace(fm.Name),
|
|
Description: strings.TrimSpace(fm.Description),
|
|
License: strings.TrimSpace(fm.License),
|
|
AllowedTools: []string(fm.AllowedTools),
|
|
Body: strings.TrimSpace(string(body)),
|
|
}
|
|
if len(fm.Metadata) > 0 {
|
|
m.Metadata = make(map[string]string, len(fm.Metadata))
|
|
for k, v := range fm.Metadata {
|
|
m.Metadata[k] = fmt.Sprintf("%v", v)
|
|
}
|
|
}
|
|
|
|
if err := m.Validate(); err != nil {
|
|
return nil, err
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
// Validate reports the first field that violates the manifest contract.
|
|
func (m *Manifest) Validate() error {
|
|
switch {
|
|
case m.Name == "":
|
|
return fmt.Errorf("skillpack: SKILL.md missing required 'name'")
|
|
case len(m.Name) > maxNameLen:
|
|
return fmt.Errorf("skillpack: name %q exceeds %d chars", m.Name, maxNameLen)
|
|
case !isKebab(m.Name):
|
|
return fmt.Errorf("skillpack: name %q must be lowercase kebab-case (a-z, 0-9, -)", m.Name)
|
|
case m.Description == "":
|
|
return fmt.Errorf("skillpack: SKILL.md missing required 'description'")
|
|
case len(m.Description) > maxDescriptionLen:
|
|
return fmt.Errorf("skillpack: description exceeds %d chars", maxDescriptionLen)
|
|
case len(m.Body) > maxBodyBytes:
|
|
return fmt.Errorf("skillpack: body exceeds %d bytes", maxBodyBytes)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// splitFrontmatter separates a leading `---`-delimited YAML block from the body.
|
|
// Leading blank lines/BOM are tolerated. A missing or unterminated block is an
|
|
// error — a SKILL.md without frontmatter has no name/description to catalog.
|
|
func splitFrontmatter(raw []byte) (front, body []byte, err error) {
|
|
// Strip a leading UTF-8 BOM: editors on some platforms prepend one, and
|
|
// bytes.TrimSpace (used below) does not remove it, so a BOM would otherwise
|
|
// make the first "---" fence unrecognizable.
|
|
raw = bytes.TrimPrefix(raw, []byte{0xEF, 0xBB, 0xBF})
|
|
s := bufio.NewScanner(bytes.NewReader(raw))
|
|
s.Buffer(make([]byte, 0, 64*1024), maxBodyBytes+64*1024)
|
|
|
|
var frontLines [][]byte
|
|
var bodyLines [][]byte
|
|
state := 0 // 0=before open fence, 1=in frontmatter, 2=in body
|
|
sawOpen := false
|
|
for s.Scan() {
|
|
line := s.Bytes()
|
|
trimmed := bytes.TrimRight(line, "\r")
|
|
switch state {
|
|
case 0:
|
|
if len(bytes.TrimSpace(trimmed)) == 0 {
|
|
continue // skip leading blanks
|
|
}
|
|
if string(bytes.TrimSpace(trimmed)) != "---" {
|
|
return nil, nil, fmt.Errorf("skillpack: SKILL.md must start with a '---' frontmatter block")
|
|
}
|
|
sawOpen = true
|
|
state = 1
|
|
case 1:
|
|
if string(bytes.TrimSpace(trimmed)) == "---" {
|
|
state = 2
|
|
continue
|
|
}
|
|
frontLines = append(frontLines, append([]byte(nil), trimmed...))
|
|
case 2:
|
|
bodyLines = append(bodyLines, append([]byte(nil), trimmed...))
|
|
}
|
|
}
|
|
if err := s.Err(); err != nil {
|
|
return nil, nil, fmt.Errorf("skillpack: reading SKILL.md: %w", err)
|
|
}
|
|
if !sawOpen || state != 2 {
|
|
return nil, nil, fmt.Errorf("skillpack: SKILL.md frontmatter block is not terminated by a closing '---'")
|
|
}
|
|
return bytes.Join(frontLines, []byte("\n")), bytes.Join(bodyLines, []byte("\n")), nil
|
|
}
|
|
|
|
// stringList decodes either a YAML sequence or a comma-separated scalar into a
|
|
// []string, so `allowed-tools: [Read, Bash]` and `allowed-tools: "Read, Bash"`
|
|
// both work.
|
|
type stringList []string
|
|
|
|
func (l *stringList) UnmarshalYAML(node *yaml.Node) error {
|
|
var seq []string
|
|
if err := node.Decode(&seq); err == nil {
|
|
*l = trimAll(seq)
|
|
return nil
|
|
}
|
|
var scalar string
|
|
if err := node.Decode(&scalar); err != nil {
|
|
return err
|
|
}
|
|
*l = trimAll(strings.Split(scalar, ","))
|
|
return nil
|
|
}
|
|
|
|
func trimAll(in []string) []string {
|
|
out := in[:0]
|
|
for _, s := range in {
|
|
if t := strings.TrimSpace(s); t != "" {
|
|
out = append(out, t)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// isKebab reports whether s is strict lowercase kebab-case: [a-z0-9] segments
|
|
// joined by single hyphens, with no leading, trailing, or consecutive hyphens.
|
|
func isKebab(s string) bool {
|
|
if s == "" || s[0] == '-' || s[len(s)-1] == '-' {
|
|
return false
|
|
}
|
|
prevHyphen := false
|
|
for _, r := range s {
|
|
switch {
|
|
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
|
|
prevHyphen = false
|
|
case r == '-':
|
|
if prevHyphen {
|
|
return false
|
|
}
|
|
prevHyphen = true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|