feat(skillpack): SKILL.md-subscription battery
New additive, nil-safe battery for subscribing to skill packages in the Anthropic agent-skills format (SKILL.md manifest + bundled files): - Manifest/ParseManifest: SKILL.md frontmatter+body parse & validation (name/description required, allowed-tools passthrough, kebab/length limits) - Tree/Pack/LoadPack: self-contained file set, order-independent content digest (the pin identity + change signal), bundled-file listing, traversal- safe staging - Source (DirSource, GitSource): Fetch returns tree + resolved ref; git clones to temp, reads subpath into memory, cleans up (self-contained tree) - Subscription + Store + content-addressed PackCache, with Memory defaults - Syncer: Subscribe pins; Check records a PENDING update but never moves the pin; Apply is the only re-pin (supply-chain guard — upstream can't silently change what an agent runs) - Activate: resolved packs -> majordomo agent.Skill (catalog instructions + one skill_use tool) for progressive disclosure; Stage materializes files Third distinct 'skill' concept, deliberately separate from executus/skill (saved-agent noun) and majordomo/skill (eager capability bundle). Mort-side wiring (convars, .skillpack commands, Agent.SkillPacks, allowed-tools shim) is a later, separate step. Full unit + hermetic local-git tests; gofmt/vet clean; race-tested. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,131 @@
|
||||
package skillpack
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Tree is a pack's file set: relative slash-separated path -> file bytes,
|
||||
// including the SKILL.md itself. It is self-contained (no live filesystem
|
||||
// handle) so it can be cached, digested, and staged without worrying about the
|
||||
// lifetime of a clone or temp dir.
|
||||
type Tree map[string][]byte
|
||||
|
||||
// Digest is the content address of the tree: a SHA-256 over every file's path
|
||||
// and bytes, order-independent. Two trees with identical contents produce the
|
||||
// same digest regardless of how they were fetched — this is the pin identity
|
||||
// and the change-detection signal (a git SHA is provenance, but the digest is
|
||||
// what says "the bytes an agent runs changed").
|
||||
func (t Tree) Digest() string {
|
||||
paths := t.Paths()
|
||||
h := sha256.New()
|
||||
for _, p := range paths {
|
||||
fh := sha256.Sum256(t[p])
|
||||
// path \x00 filehash \n — the NUL prevents path/content boundary games.
|
||||
fmt.Fprintf(h, "%s\x00%s\n", p, hex.EncodeToString(fh[:]))
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
|
||||
// Paths returns the tree's file paths, sorted.
|
||||
func (t Tree) Paths() []string {
|
||||
out := make([]string, 0, len(t))
|
||||
for p := range t {
|
||||
out = append(out, p)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// WriteTo materializes the tree under dir (creating it and any parents). It is
|
||||
// how a host stages a pack's files for a sandbox; the host owns mount/read-only
|
||||
// policy. Paths are cleaned and constrained to dir — a tree entry that escapes
|
||||
// (via .. or an absolute path) is rejected rather than written outside dir.
|
||||
func (t Tree) WriteTo(dir string) error {
|
||||
for _, p := range t.Paths() {
|
||||
dest := filepath.Join(dir, filepath.FromSlash(p))
|
||||
if !within(dir, dest) {
|
||||
return fmt.Errorf("skillpack: refusing to stage %q outside %q", p, dir)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(dest, t[p], 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Pack is a fetched, parsed pack: its manifest, its file tree, the tree's
|
||||
// content digest, and the non-manifest ("bundled") file paths a host can stage.
|
||||
type Pack struct {
|
||||
Manifest *Manifest
|
||||
Tree Tree
|
||||
Digest string
|
||||
// Bundled is every tree path except the SKILL.md, sorted — the scripts and
|
||||
// reference files skill_use points the model at.
|
||||
Bundled []string
|
||||
}
|
||||
|
||||
// LoadPack parses a fetched Tree into a Pack: it requires a root SKILL.md,
|
||||
// parses+validates it, computes the digest, and lists the bundled files.
|
||||
func LoadPack(t Tree) (*Pack, error) {
|
||||
raw, ok := t[ManifestName]
|
||||
if !ok {
|
||||
return nil, ErrNoManifest
|
||||
}
|
||||
m, err := ParseManifest(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bundled := make([]string, 0, len(t))
|
||||
for _, p := range t.Paths() {
|
||||
if p != ManifestName {
|
||||
bundled = append(bundled, p)
|
||||
}
|
||||
}
|
||||
return &Pack{Manifest: m, Tree: t, Digest: t.Digest(), Bundled: bundled}, nil
|
||||
}
|
||||
|
||||
// readTree reads an entire fs.FS (rooted at ".") into a Tree, skipping
|
||||
// directories. It is the shared reader for DirSource and GitSource, so both
|
||||
// produce identical self-contained trees.
|
||||
func readTree(fsys fs.FS) (Tree, error) {
|
||||
t := Tree{}
|
||||
err := fs.WalkDir(fsys, ".", func(p string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
b, err := fs.ReadFile(fsys, p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
t[path.Clean(p)] = b
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// within reports whether dest is inside dir (defense against path traversal in
|
||||
// a staged tree).
|
||||
func within(dir, dest string) bool {
|
||||
rel, err := filepath.Rel(dir, dest)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
|
||||
}
|
||||
Reference in New Issue
Block a user