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 -8
View File
@@ -2,6 +2,7 @@ package skillpack
import (
"context"
"errors"
"fmt"
"time"
@@ -14,7 +15,7 @@ import (
// — the only call that changes the bytes an agent runs is Apply, always with an
// actor recorded.
type Syncer struct {
Store PackCache // content store for pinned trees
Cache PackCache // content store for pinned trees
Subs Store // subscription metadata store
// 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 {
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 pack, sourceRef, nil
@@ -87,21 +88,32 @@ func (y *Syncer) Subscribe(ctx context.Context, src Source, trackRef, by string)
if err != nil {
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)
}
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{
ID: y.newID(),
Name: pack.Manifest.Name,
SourceKind: src.Kind(),
SourceURL: src.String(),
TrackRef: trackRef,
Enabled: true,
}
// Store the raw URL/subpath (String() may combine them for display).
if gs, ok := src.(GitSource); ok {
sub.SourceURL = gs.URL
sub.Subpath = gs.Subpath
// Store the raw URL + subpath separately (String() may combine them for
// display). GitSource methods have value receivers, so a caller may pass
// either GitSource or *GitSource — handle both.
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())
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() {
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 {
return nil, fmt.Errorf("skillpack: pending tree for %q missing from cache: %w", sub.Name, err)
}