Files
executus/skillpack/subscription.go
T
steve 9bb5d143f7
executus CI / test (pull_request) Successful in 3m30s
fix(skillpack): address review — symlink read, git arg-injection, dup-subscribe, nil panics
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>
2026-07-04 20:41:44 -04:00

66 lines
2.5 KiB
Go

package skillpack
import "time"
// Subscription is a host's persisted "I track this pack, pinned here" record. It
// is metadata only — the pinned pack's bytes live in a PackCache keyed by
// PinnedDigest. A subscription is only ever advanced to new content by an
// explicit Apply (see Syncer): a sync records a PendingDigest, it never moves
// the pin. That is the supply-chain guard — a compromised or careless upstream
// cannot change what an agent runs without a human re-pin.
type Subscription struct {
// ID is a stable host-assigned identifier.
ID string
// Name is the pack's manifest name (unique per host); what an agent lists in
// its SkillPacks and what skill_use receives.
Name string
// Description is the pinned manifest's description, cached so the catalog
// renders without opening the PackCache.
Description string
// Source coordinates.
SourceKind string // "dir" | "git"
SourceURL string // dir path or git URL
Subpath string // git subpath, if any
// TrackRef is the git commit-ish the host follows (branch/tag/SHA); empty =
// default branch. Sync fetches THIS; the pin only moves on Apply.
TrackRef string
// Pinned* describe the currently-active content.
PinnedDigest string // content digest = PackCache key + change signal
PinnedSourceRef string // source's resolved ref (git SHA) — provenance
PinnedAt time.Time
PinnedBy string
// Pending* describe an update a sync found but has NOT applied. Empty
// PendingDigest = no pending update. A pending digest equal to the pinned
// one is impossible by construction (Syncer clears it).
PendingDigest string
PendingSourceRef string
PendingAt time.Time
// Enabled lets a host keep a subscription but deactivate it without
// deleting the pin/history.
Enabled bool
}
// HasPending reports whether a sync found an unapplied update.
func (s *Subscription) HasPending() bool {
return s.PendingDigest != "" && s.PendingDigest != s.PinnedDigest
}
// pinTo advances the active pin to a fetched pack and clears any pending state.
// 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) {
s.Description = p.Manifest.Description
s.PinnedDigest = p.Digest
s.PinnedSourceRef = sourceRef
s.PinnedAt = now
s.PinnedBy = by
s.PendingDigest = ""
s.PendingSourceRef = ""
s.PendingAt = time.Time{}
}