fix(skillpack): address review — symlink read, git arg-injection, dup-subscribe, nil panics
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>
This commit is contained in:
2026-07-04 20:41:44 -04:00
parent bf0b67f9af
commit 9bb5d143f7
10 changed files with 150 additions and 24 deletions
+20 -6
View File
@@ -2,6 +2,7 @@ package skillpack
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"sort" "sort"
"strings" "strings"
@@ -40,10 +41,15 @@ func Resolve(ctx context.Context, cache PackCache, subs []Subscription) ([]*Pack
// pack (name + description) plus how to load one. This is the whole prompt cost // pack (name + description) plus how to load one. This is the whole prompt cost
// of a subscription — the bodies stay out until skill_use is called. // of a subscription — the bodies stay out until skill_use is called.
func Catalog(packs []*Pack) string { func Catalog(packs []*Pack) string {
if len(packs) == 0 { sorted := make([]*Pack, 0, len(packs))
for _, p := range packs {
if p != nil && p.Manifest != nil {
sorted = append(sorted, p)
}
}
if len(sorted) == 0 {
return "" return ""
} }
sorted := append([]*Pack(nil), packs...)
sort.Slice(sorted, func(i, j int) bool { return sorted[i].Manifest.Name < sorted[j].Manifest.Name }) sort.Slice(sorted, func(i, j int) bool { return sorted[i].Manifest.Name < sorted[j].Manifest.Name })
var b strings.Builder var b strings.Builder
@@ -71,12 +77,14 @@ type skillUseArgs struct {
// where to read scripts/references with its file tools. Leave it empty when the // where to read scripts/references with its file tools. Leave it empty when the
// host has no staging. // host has no staging.
func Activate(packs []*Pack, stagedDir string) mdagent.Skill { func Activate(packs []*Pack, stagedDir string) mdagent.Skill {
if len(packs) == 0 {
return nil
}
byName := make(map[string]*Pack, len(packs)) byName := make(map[string]*Pack, len(packs))
for _, p := range packs { for _, p := range packs {
byName[p.Manifest.Name] = p if p != nil && p.Manifest != nil {
byName[p.Manifest.Name] = p
}
}
if len(byName) == 0 {
return nil
} }
tool := llm.DefineTool("skill_use", tool := llm.DefineTool("skill_use",
@@ -99,6 +107,9 @@ func Activate(packs []*Pack, stagedDir string) mdagent.Skill {
// renderPackBody is what skill_use returns: the pack's instructions plus a // renderPackBody is what skill_use returns: the pack's instructions plus a
// pointer to its bundled files (with the staged path when known). // pointer to its bundled files (with the staged path when known).
func renderPackBody(p *Pack, stagedDir string) string { func renderPackBody(p *Pack, stagedDir string) string {
if p == nil || p.Manifest == nil {
return "Error: invalid skill pack."
}
var b strings.Builder var b strings.Builder
fmt.Fprintf(&b, "# Skill: %s\n\n%s\n", p.Manifest.Name, p.Manifest.Body) fmt.Fprintf(&b, "# Skill: %s\n\n%s\n", p.Manifest.Name, p.Manifest.Body)
if len(p.Bundled) > 0 { if len(p.Bundled) > 0 {
@@ -118,6 +129,9 @@ func renderPackBody(p *Pack, stagedDir string) string {
// mount them (read-only is the host's concern) into a sandbox the agent's file // mount them (read-only is the host's concern) into a sandbox the agent's file
// tools can read. Returns the pack's staged directory. // tools can read. Returns the pack's staged directory.
func Stage(p *Pack, baseDir string) (string, error) { func Stage(p *Pack, baseDir string) (string, error) {
if p == nil || p.Manifest == nil {
return "", errors.New("skillpack: Stage requires a non-nil pack")
}
dir := baseDir + "/" + p.Manifest.Name dir := baseDir + "/" + p.Manifest.Name
if err := p.Tree.WriteTo(dir); err != nil { if err := p.Tree.WriteTo(dir); err != nil {
return "", err return "", err
+19
View File
@@ -86,6 +86,25 @@ func TestActivate_Empty(t *testing.T) {
} }
} }
func TestNilPackElementsAreSafe(t *testing.T) {
packs := []*Pack{nil, mustPack(t, "real", "b", nil), {Manifest: nil}}
// Neither Catalog nor Activate may panic on nil / malformed elements.
if got := Catalog(packs); !strings.Contains(got, "real") {
t.Errorf("catalog should include the valid pack and skip nils: %q", got)
}
sk := Activate(packs, "")
if sk == nil {
t.Fatal("a valid pack among nils should still activate")
}
if _, ok := sk.Tools().Get("skill_use"); !ok {
t.Error("skill_use missing")
}
// All-nil activates to nothing rather than panicking.
if Activate([]*Pack{nil, {Manifest: nil}}, "") != nil {
t.Error("only-nil packs should activate to nil")
}
}
func TestResolveFromCache(t *testing.T) { func TestResolveFromCache(t *testing.T) {
ctx := context.Background() ctx := context.Background()
cache := NewMemoryPackCache() cache := NewMemoryPackCache()
+18 -2
View File
@@ -112,6 +112,10 @@ func (m *Manifest) Validate() error {
// Leading blank lines/BOM are tolerated. A missing or unterminated block is an // 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. // error — a SKILL.md without frontmatter has no name/description to catalog.
func splitFrontmatter(raw []byte) (front, body []byte, err error) { 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 := bufio.NewScanner(bytes.NewReader(raw))
s.Buffer(make([]byte, 0, 64*1024), maxBodyBytes+64*1024) s.Buffer(make([]byte, 0, 64*1024), maxBodyBytes+64*1024)
@@ -180,13 +184,25 @@ func trimAll(in []string) []string {
return out 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 { func isKebab(s string) bool {
if s == "" || s[0] == '-' || s[len(s)-1] == '-' {
return false
}
prevHyphen := false
for _, r := range s { for _, r := range s {
switch { switch {
case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '-': case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
prevHyphen = false
case r == '-':
if prevHyphen {
return false
}
prevHyphen = true
default: default:
return false return false
} }
} }
return s != "" return true
} }
+14
View File
@@ -61,6 +61,9 @@ func TestParseManifest_Errors(t *testing.T) {
"missing desc": "---\nname: x\n---\nb\n", "missing desc": "---\nname: x\n---\nb\n",
"bad name uppercase": "---\nname: PdfProcessing\ndescription: d\n---\nb\n", "bad name uppercase": "---\nname: PdfProcessing\ndescription: d\n---\nb\n",
"bad name space": "---\nname: pdf processing\ndescription: d\n---\nb\n", "bad name space": "---\nname: pdf processing\ndescription: d\n---\nb\n",
"bad name leading -": "---\nname: -pdf\ndescription: d\n---\nb\n",
"bad name trailing-": "---\nname: pdf-\ndescription: d\n---\nb\n",
"bad name double -": "---\nname: pdf--tools\ndescription: d\n---\nb\n",
"bad yaml": "---\nname: [unclosed\n---\nb\n", "bad yaml": "---\nname: [unclosed\n---\nb\n",
} }
for label, in := range cases { for label, in := range cases {
@@ -80,3 +83,14 @@ func TestParseManifest_LeadingBlanksAndCRLF(t *testing.T) {
t.Errorf("got name=%q body=%q", m.Name, m.Body) t.Errorf("got name=%q body=%q", m.Name, m.Body)
} }
} }
func TestParseManifest_BOM(t *testing.T) {
in := append([]byte{0xEF, 0xBB, 0xBF}, []byte("---\nname: bom-ok\ndescription: d\n---\nbody\n")...)
m, err := ParseManifest(in)
if err != nil {
t.Fatalf("BOM-prefixed SKILL.md should parse: %v", err)
}
if m.Name != "bom-ok" {
t.Errorf("name = %q", m.Name)
}
}
+8
View File
@@ -107,6 +107,14 @@ func readTree(fsys fs.FS) (Tree, error) {
if d.IsDir() { if d.IsDir() {
return nil return nil
} }
// Skip symlinks. A pack must be self-contained; os.DirFS + ReadFile
// follows symlinks, so a malicious pack with `SKILL.md -> /etc/passwd`
// or `scripts/x -> ../../.ssh/id_rsa` would otherwise read host files
// into the tree. WalkDir yields a symlink-to-dir as a non-dir entry
// carrying ModeSymlink, so this one check covers file and dir symlinks.
if d.Type()&fs.ModeSymlink != 0 {
return nil
}
b, err := fs.ReadFile(fsys, p) b, err := fs.ReadFile(fsys, p)
if err != nil { if err != nil {
return err return err
+25
View File
@@ -68,6 +68,31 @@ func TestTreeWriteTo(t *testing.T) {
} }
} }
func TestReadTree_SkipsSymlinks(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, ManifestName), []byte(goodManifest), 0o644); err != nil {
t.Fatal(err)
}
// A malicious pack pointing at a host file must NOT be read into the tree.
secret := filepath.Join(t.TempDir(), "secret")
if err := os.WriteFile(secret, []byte("TOPSECRET"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.Symlink(secret, filepath.Join(dir, "leak")); err != nil {
t.Skipf("symlink unsupported: %v", err)
}
tree, err := readTree(os.DirFS(dir))
if err != nil {
t.Fatal(err)
}
if _, ok := tree["leak"]; ok {
t.Fatal("symlink was followed into the tree — arbitrary host file read")
}
if _, ok := tree[ManifestName]; !ok {
t.Fatal("real file should still be read")
}
}
func TestTreeWriteTo_RejectsTraversal(t *testing.T) { func TestTreeWriteTo_RejectsTraversal(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
evil := Tree{"../escape.txt": []byte("nope")} evil := Tree{"../escape.txt": []byte("nope")}
+18 -2
View File
@@ -88,17 +88,33 @@ func (g GitSource) run(ctx context.Context, dir string, args ...string) ([]byte,
} }
func (g GitSource) Fetch(ctx context.Context, ref string) (Tree, string, error) { func (g GitSource) Fetch(ctx context.Context, ref string) (Tree, string, error) {
// Argument-injection guard: a URL or ref beginning with "-" would be parsed
// by git as an option (e.g. --upload-pack=…), not a value. Reject it rather
// than rely solely on the "--" separator, which checkout does not honor for
// a rev. Hosts should also allow-list sources, but this is defense-in-depth
// for a library.
if strings.HasPrefix(g.URL, "-") {
return nil, "", fmt.Errorf("skillpack: git url must not start with '-': %q", g.URL)
}
if strings.HasPrefix(ref, "-") {
return nil, "", fmt.Errorf("skillpack: git ref must not start with '-': %q", ref)
}
tmp, err := os.MkdirTemp("", "skillpack-git-*") tmp, err := os.MkdirTemp("", "skillpack-git-*")
if err != nil { if err != nil {
return nil, "", err return nil, "", err
} }
defer os.RemoveAll(tmp) defer os.RemoveAll(tmp)
if _, err := g.run(ctx, "", "clone", "--quiet", g.URL, tmp); err != nil { // --filter=blob:none: a blobless partial clone gets the ref graph cheaply
// and fetches only the blobs the checkout needs — much less than the full
// history, while still supporting an arbitrary commit-ish ref. "--" ends
// option parsing before the URL.
if _, err := g.run(ctx, "", "clone", "--quiet", "--filter=blob:none", "--", g.URL, tmp); err != nil {
return nil, "", err return nil, "", err
} }
if ref != "" { if ref != "" {
if _, err := g.run(ctx, tmp, "checkout", "--quiet", ref); err != nil { if _, err := g.run(ctx, tmp, "checkout", "--quiet", "--detach", ref); err != nil {
return nil, "", err return nil, "", err
} }
} }
+3 -2
View File
@@ -50,9 +50,10 @@ func (s *Subscription) HasPending() bool {
} }
// pinTo advances the active pin to a fetched pack and clears any pending state. // pinTo advances the active pin to a fetched pack and clears any pending state.
// Used by initial pin and by Apply. // Used by initial pin and by Apply. It does NOT set Name: a subscription's name
// is its stable host handle, fixed at Subscribe time — letting an upstream pack
// rename move it would silently collide with another subscription on Apply.
func (s *Subscription) pinTo(p *Pack, sourceRef, by string, now time.Time) { func (s *Subscription) pinTo(p *Pack, sourceRef, by string, now time.Time) {
s.Name = p.Manifest.Name
s.Description = p.Manifest.Description s.Description = p.Manifest.Description
s.PinnedDigest = p.Digest s.PinnedDigest = p.Digest
s.PinnedSourceRef = sourceRef s.PinnedSourceRef = sourceRef
+20 -8
View File
@@ -2,6 +2,7 @@ package skillpack
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"time" "time"
@@ -14,7 +15,7 @@ import (
// — the only call that changes the bytes an agent runs is Apply, always with an // — the only call that changes the bytes an agent runs is Apply, always with an
// actor recorded. // actor recorded.
type Syncer struct { type Syncer struct {
Store PackCache // content store for pinned trees Cache PackCache // content store for pinned trees
Subs Store // subscription metadata store Subs Store // subscription metadata store
// SourceFor builds the Source for a stored subscription. A host overrides // SourceFor builds the Source for a stored subscription. A host overrides
@@ -73,7 +74,7 @@ func (y *Syncer) fetchPack(ctx context.Context, src Source, ref string) (*Pack,
if err != nil { if err != nil {
return nil, "", err return nil, "", err
} }
if err := y.Store.Put(ctx, pack.Digest, pack.Tree); err != nil { if err := y.Cache.Put(ctx, pack.Digest, pack.Tree); err != nil {
return nil, "", err return nil, "", err
} }
return pack, sourceRef, nil return pack, sourceRef, nil
@@ -87,21 +88,32 @@ func (y *Syncer) Subscribe(ctx context.Context, src Source, trackRef, by string)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if existing, err := y.Subs.GetByName(ctx, pack.Manifest.Name); err == nil { existing, err := y.Subs.GetByName(ctx, pack.Manifest.Name)
if err == nil {
return nil, fmt.Errorf("skillpack: already subscribed to %q (id %s)", pack.Manifest.Name, existing.ID) return nil, fmt.Errorf("skillpack: already subscribed to %q (id %s)", pack.Manifest.Name, existing.ID)
} }
if !errors.Is(err, ErrNotFound) {
// A transient store error must NOT fall through to creating a row — that
// would produce a duplicate subscription the uniqueness check missed.
return nil, fmt.Errorf("skillpack: checking for existing subscription %q: %w", pack.Manifest.Name, err)
}
sub := &Subscription{ sub := &Subscription{
ID: y.newID(), ID: y.newID(),
Name: pack.Manifest.Name,
SourceKind: src.Kind(), SourceKind: src.Kind(),
SourceURL: src.String(), SourceURL: src.String(),
TrackRef: trackRef, TrackRef: trackRef,
Enabled: true, Enabled: true,
} }
// Store the raw URL/subpath (String() may combine them for display). // Store the raw URL + subpath separately (String() may combine them for
if gs, ok := src.(GitSource); ok { // display). GitSource methods have value receivers, so a caller may pass
sub.SourceURL = gs.URL // either GitSource or *GitSource — handle both.
sub.Subpath = gs.Subpath switch gs := src.(type) {
case GitSource:
sub.SourceURL, sub.Subpath = gs.URL, gs.Subpath
case *GitSource:
sub.SourceURL, sub.Subpath = gs.URL, gs.Subpath
} }
sub.pinTo(pack, sourceRef, by, y.now()) sub.pinTo(pack, sourceRef, by, y.now())
if err := y.Subs.Save(ctx, sub); err != nil { if err := y.Subs.Save(ctx, sub); err != nil {
@@ -175,7 +187,7 @@ func (y *Syncer) Apply(ctx context.Context, id, by string) (*Subscription, error
if !sub.HasPending() { if !sub.HasPending() {
return nil, fmt.Errorf("skillpack: %q has no pending update to apply", sub.Name) return nil, fmt.Errorf("skillpack: %q has no pending update to apply", sub.Name)
} }
tree, err := y.Store.Get(ctx, sub.PendingDigest) tree, err := y.Cache.Get(ctx, sub.PendingDigest)
if err != nil { if err != nil {
return nil, fmt.Errorf("skillpack: pending tree for %q missing from cache: %w", sub.Name, err) return nil, fmt.Errorf("skillpack: pending tree for %q missing from cache: %w", sub.Name, err)
} }
+5 -4
View File
@@ -2,6 +2,7 @@ package skillpack
import ( import (
"context" "context"
"fmt"
"testing" "testing"
"time" "time"
) )
@@ -27,10 +28,10 @@ func packTree(name, body string) Tree {
func newTestSyncer(src *fakeSource) *Syncer { func newTestSyncer(src *fakeSource) *Syncer {
n := 0 n := 0
return &Syncer{ return &Syncer{
Store: NewMemoryPackCache(), Cache: NewMemoryPackCache(),
Subs: NewMemory(), Subs: NewMemory(),
Now: func() time.Time { return time.Unix(1000, 0) }, Now: func() time.Time { return time.Unix(1000, 0) },
NewID: func() string { n++; return "id-1" }, NewID: func() string { n++; return fmt.Sprintf("id-%d", n) },
SourceFor: func(*Subscription) (Source, error) { return src, nil }, SourceFor: func(*Subscription) (Source, error) { return src, nil },
} }
} }
@@ -51,7 +52,7 @@ func TestSubscribeAndPin(t *testing.T) {
t.Fatal("fresh subscription should have no pending update") t.Fatal("fresh subscription should have no pending update")
} }
// pinned tree is cached under its digest // pinned tree is cached under its digest
if _, err := y.Store.Get(ctx, sub.PinnedDigest); err != nil { if _, err := y.Cache.Get(ctx, sub.PinnedDigest); err != nil {
t.Fatalf("pinned tree not cached: %v", err) t.Fatalf("pinned tree not cached: %v", err)
} }
} }
@@ -93,7 +94,7 @@ func TestCheck_RecordsPendingButDoesNotMovePin(t *testing.T) {
t.Errorf("pending ref = %q", updated.PendingSourceRef) t.Errorf("pending ref = %q", updated.PendingSourceRef)
} }
// the pending tree is cached, ready for Apply // the pending tree is cached, ready for Apply
if _, err := y.Store.Get(ctx, updated.PendingDigest); err != nil { if _, err := y.Cache.Get(ctx, updated.PendingDigest); err != nil {
t.Fatalf("pending tree not cached: %v", err) t.Fatalf("pending tree not cached: %v", err)
} }
} }