Documentation
¶
Overview ¶
Package skills validates skill uploads and normalizes them into the canonical archive form the registry stores: a zip whose single top-level entry is the skill directory, with SKILL.md at its root. Both the /v1/skills upload forms (loose path-qualified files, or one zip archive) and the slice-3 operator import funnel through here, so the validation rules — the skills-guide's published constraints on name/description/size — cannot drift between entry points. Every error returned by this package is caused by the upload's content and is safe to echo to the client as a 400.
Index ¶
- Constants
- Variables
- func BlobKey(skillID, version string) string
- func Digest(data []byte) string
- func IsZip(data []byte) bool
- func ParseSentinel(data []byte) ([]markerEntry, bool)
- func ReadArchive(r io.Reader, wantSHA256 string) ([]byte, error)
- func Sentinel(rs []Resolved) []byte
- func SentinelMatches(ctx context.Context, read func(context.Context, string) ([]byte, error), ...) bool
- func TargetDir(name, skillID string) string
- type Bundle
- type File
- type Resolved
Constants ¶
const ( // MaxTotalBytes caps a skill's total uncompressed content (the // skills-guide's published 30 MB bundle limit). MaxTotalBytes = 30 << 20 // MaxMembers caps the file count, matching the reference worker's own // extraction guard (anthropic-sdk-go tools/agenttoolset). MaxMembers = 10000 )
const ArchiveDigestHeader = "x-skill-archive-sha256"
ArchiveDigestHeader carries Digest(archive) on the /content download response so the BYOC worker — which never reads the database — can verify what it downloaded. It lives beside BlobKey for the same reason: one definition shared by the API that sends it and the worker that reads it. Additive and ignored by reference clients (the SDK treats the body as opaque bytes).
const ExtractMaxBytes = 1 << 30
ExtractMaxBytes caps a skill archive's total decompressed content at extraction time, matching the reference worker's guard (anthropic-sdk-go tools/agenttoolset/skillarchive.go: 1 GiB). Far above the 30 MB upload cap on purpose — extraction guards protect the sandbox even if a stored object did not come through this platform's upload validation.
const MaxArchiveBytes = 64 << 20
MaxArchiveBytes caps how many *compressed* bytes a materializer reads from an object-store stream before handing the archive to Extract. It is set well above the platform's upload limit (MaxTotalBytes, 30 MB) but far below ExtractMaxBytes: a canonical zip is built from at most MaxTotalBytes of validated content, so its compressed form fits comfortably here, while a stored object larger than this is malformed or hostile and is refused before it can consume memory — Extract's own decompressed cap then guards what a valid archive expands to. Capping the *read* at a realistic archive size (not the gigabyte decompressed ceiling) is what keeps refusing a hostile object from ever needing a gigabyte-scale allocation.
const SentinelName = ".materialized"
SentinelName is the marker file written under {workdir}/skills/ after a successful materialization pass, recording the resolved {skill_id: version} set so re-entrant sandbox provisioning skips rewriting unchanged skills.
const SentinelVersion = 2
SentinelVersion is the marker's integrity generation — what a successful materialization the marker records was actually guaranteed to have done. Version 2 means "every recorded archive was verified against the digest the registry holds for it, where one was recorded" (#155); version 1 was the bare, unversioned JSON array written before any digest existed, and is no longer accepted. Bumping this is how a marker written under a weaker guarantee is stopped from satisfying a stronger one: it costs exactly one re-materialization pass per live sandbox at upgrade — which is where the stronger guarantee gets applied — and nothing at steady state. Recording the digests themselves would be the alternative, and is not viable: the BYOC worker learns a digest only from the download response, i.e. after the skip decision, so it would have to spend a wire round trip per skill per pass to answer a question this constant answers for free.
Variables ¶
var ErrDigestMismatch = errors.New("skill archive does not match its recorded sha256")
ErrDigestMismatch reports an archive whose bytes do not hash to the digest recorded for that skill version at upload — storage bit-rot, truncation, or a substituted object between upload and materialization.
Functions ¶
func BlobKey ¶
BlobKey is the object-store key for a skill version's archive — the one layout the API's upload/download, the importer, and the executor's materialization all share.
func Digest ¶
Digest is the archive digest this platform records and verifies: the lowercase-hex sha256 of the stored bytes.
func IsZip ¶
IsZip reports whether data begins with the zip local-file-header magic. The API uses it to pick the upload form when exactly one file part arrives; magic-byte detection is an inference recorded in docs/DIVERGENCES.md.
func ParseSentinel ¶
ParseSentinel decodes a marker file; ok is false for bytes that are not a marker of the current integrity generation — unreadable, or written under an older one (including the unversioned array form) — which a caller treats as "materialize", so the current generation's guarantees are applied on that pass. A marker from a *newer* generation is likewise not accepted: a downgraded binary must re-materialize rather than trust a claim it cannot evaluate.
func ReadArchive ¶
ReadArchive reads a skill archive from an object-store stream, refusing more than MaxArchiveBytes so a hostile or corrupt object cannot exhaust memory, then verifies it against the digest recorded for that version. The store's reported length is deliberately NOT used to pre-size the buffer: it is untrusted, and a large hint would let a tiny hostile stream provoke a huge eager allocation. The cap is enforced on bytes actually read.
Verification lives here, in the one function both materialization halves call between fetching an archive and extracting it, so a caller cannot read an archive and forget to check it. wantSHA256 empty means no digest was recorded for this version — a row predating the sha256 column, or (on the wire half) a control plane that sends no digest header — and the archive is read unverified; callers log that. Anything else must match, case-insensitively: our own digests are lowercase by construction, but rejecting another implementation's uppercase hex would be a gratuitous failure. A malformed expectation needs no format check of its own — it can never equal a real digest, so it fails closed.
func Sentinel ¶
Sentinel canonically encodes the materialized set for the marker file: the integrity generation plus sorted {skill_id, version} entries, so equal sets always produce equal bytes.
func SentinelMatches ¶
func SentinelMatches(ctx context.Context, read func(context.Context, string) ([]byte, error), workdir string, data []byte, rs []Resolved) bool
SentinelMatches reports whether the marker proves the resolved set rs is already fully materialized. Against an agent-writable marker it holds these:
- the probe directory is rs[i].Dir (trusted metadata), never the marker, so a rewritten marker cannot redirect the probe at a decoy directory;
- the marker's {id, version} set must be an EXACT bijection with rs (same length, each rs id present exactly once with its version), so a forged, duplicated, or zero-value entry cannot mask a skill that is absent from its directory;
- every resolved directory must still hold its SKILL.md (canonical archives place one at the root), so a deleted tree self-heals next pass.
It is NOT a soundness proof against a fully hostile agent: the SKILL.md probe tests presence, not content, so an agent that both forges the marker's version and leaves an older version's files in place (the landing directory is the skill name, shared across a skill's versions) can suppress an upgrade — the same tampering class as an in-place content edit, and equally beyond a presence probe. Both residuals are recorded in docs/DIVERGENCES.md; closing them would mean abandoning the skip and re-extracting every pass, as the reference does. read is the sandbox's ReadFile, passed as a function so this package needs no sandbox dependency. rs must be deduplicated by id.
Types ¶
type Bundle ¶
Bundle is a validated upload: the SKILL.md frontmatter extraction plus the canonical archive the registry stores and later materializes into sandboxes. SHA256 is Digest(Zip), recorded beside the metadata so materialization can prove the object it reads back is the archive that was validated here.
type File ¶
File is one uploaded file: a slash-separated path as sent by the client ("financial-skill/SKILL.md") and its content.
func Extract ¶
Extract opens a stored skill archive (the canonical zip the registry serves) and returns its files with slash-relative paths, the single top-level wrapper directory stripped — the reference worker's extraction semantics with its guards: escape ("slip") refusal and member/byte caps. Only zip is accepted: this platform stores and serves canonical zips, so the reference's tar fallback would be dead code here.
type Resolved ¶
type Resolved struct {
ID string
Version string
Dir string
// SHA256 is the archive digest recorded for this version, from the same
// trusted metadata that supplies Dir — empty when the source records none.
// The executor fills it from the version row it already reads; the BYOC
// worker leaves it empty because the SDK's version object carries no
// checksum field, and learns the digest from the download response instead.
SHA256 string
}
Resolved is a skill the caller intends to materialize: its id, the concrete version resolved at use time, and the directory it lands in. Dir is TargetDir of the version's name derived from TRUSTED metadata (the DB row or the version object), never from the marker — so the skip's presence probe cannot be redirected by an agent that rewrites the marker.