hermes-agent/tools/skill_set_catalog.py

624 lines
25 KiB
Python

"""Skill-set catalog client — prototype for the agentskills #254 + AI Catalog layering.
Implements the design discussed with agentskills.io:
1. **AI Catalog** (https://ai-catalog.io) — a typed discovery index served at
``/.well-known/ai-catalog.json``. Entries with
``type: "application/agent-skills+json"`` point at an agent-skills
discovery index and represent an installable *skill set*.
2. **agentskills PR #254 discovery index** — the index the entry points at:
``{"$schema": ..., "skills": [{name, description, type, url, digest}]}``
where ``type`` is ``"skill-md"`` (single file) or ``"archive"``
(``.tar.gz`` / ``.zip`` with SKILL.md at the archive root).
3. **``io.hermes.skill-set`` extension** — optional namespaced metadata on
the AI Catalog entry carrying set-level usage intent::
"extensions": {
"io.hermes.skill-set": {
"command": "backend-dev",
"instruction": "Prefer TDD. Run the linter before opening a PR."
}
}
``command`` is the suggested local load-alias (Hermes creates a skill
bundle so ``/backend-dev`` loads every member in one turn);
``instruction`` is a shared preamble injected above the member skills.
Clients that don't recognize the extension still install the correct
set — they only miss the alias/preamble sugar.
Catalog entries may deliver their artifact via ``url`` OR inline ``data``
(per the AI Catalog spec); both are supported for skill-set entries and
sub-catalogs. Relative URLs are resolved per RFC 3986 against the URL the
referencing document was *actually retrieved from* (i.e. after redirects),
and archive formats are detected from the ``Content-Type`` header first,
falling back to the URL file extension when the header is absent or
generic — both per agentskills #254.
Security posture matches the rest of the skills hub: all HTTP goes through
the SSRF-guarded fetcher, member artifacts are digest-verified (SHA-256,
required by #254), archive extraction rejects path traversal / absolute
paths / symlinks / hardlinks and caps decompressed size, and every fetched
skill flows through the existing quarantine -> scan -> install pipeline.
This module only *fetches and validates*; installation is orchestrated by
``hermes_cli.skills_hub.do_install_set``.
"""
from __future__ import annotations
import hashlib
import io
import json
import logging
import posixpath
import re
import tarfile
import zipfile
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Union
from urllib.parse import urljoin, urlparse
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
#: MIME-ish AI Catalog entry type identifying an agent-skills discovery index.
SKILL_SET_ENTRY_TYPE = "application/agent-skills+json"
#: Nested AI Catalog entry type (sub-catalogs) — followed one level deep.
CATALOG_ENTRY_TYPE = "application/ai-catalog+json"
#: Namespaced AI Catalog extension carrying set-level usage intent.
HERMES_SET_EXTENSION = "io.hermes.skill-set"
#: ``$schema`` URIs this client knows how to process (agentskills #254).
KNOWN_INDEX_SCHEMAS = frozenset({
"https://schemas.agentskills.io/discovery/0.2.0/schema.json",
})
#: Well-known path for AI Catalog discovery.
AI_CATALOG_WELL_KNOWN = "/.well-known/ai-catalog.json"
_DIGEST_RE = re.compile(r"^sha256:([0-9a-f]{64})$")
# Archive safety caps (decompression-bomb guard per #254 "Archive safety").
MAX_ARCHIVE_BYTES = 20 * 1024 * 1024 # compressed artifact cap
MAX_UNPACKED_BYTES = 50 * 1024 * 1024 # total decompressed cap
MAX_MEMBER_BYTES = 5 * 1024 * 1024 # single decompressed file cap
MAX_ARCHIVE_MEMBERS = 500
#: Max skills a single set may enumerate (sanity cap for the prototype).
MAX_SET_MEMBERS = 50
# ---------------------------------------------------------------------------
# Errors
# ---------------------------------------------------------------------------
class SkillSetError(Exception):
"""Base error for skill-set catalog operations."""
class SchemaError(SkillSetError):
"""Unrecognized or missing ``$schema`` — per #254, warn and stop."""
class DigestError(SkillSetError):
"""Artifact digest missing, malformed, or mismatched."""
class ArchiveSafetyError(SkillSetError):
"""Archive violated safety rules (traversal, links, bomb, structure)."""
# ---------------------------------------------------------------------------
# Data models
# ---------------------------------------------------------------------------
@dataclass
class SkillSetInfo:
"""A skill set discovered in an AI Catalog (or a bare #254 index)."""
name: str # display name (catalog displayName or derived)
description: str
index_url: str # resolved URL of the #254 index.json (or a
# synthetic "#inline" marker for data entries)
identifier: str = "" # catalog entry identifier (urn:...), if any
command: str = "" # io.hermes.skill-set suggested alias
instruction: str = "" # io.hermes.skill-set shared preamble
catalog_url: str = "" # catalog this was discovered from, if any
inline_index: Optional[Dict[str, Any]] = None # AI Catalog `data` payload
base_url: str = "" # RFC 3986 base for inline-index member URLs
@dataclass
class SkillSetMember:
"""One skill entry from a #254 discovery index."""
name: str
description: str
type: str # "skill-md" | "archive"
url: str # resolved absolute URL
digest: str # "sha256:<hex>"
@dataclass
class ResolvedSkillSet:
"""A fully parsed set: info + members, ready to fetch."""
info: SkillSetInfo
members: List[SkillSetMember] = field(default_factory=list)
skipped: List[str] = field(default_factory=list) # entries skipped w/ reason
# ---------------------------------------------------------------------------
# HTTP (indirection point — tests monkeypatch _http_fetch)
# ---------------------------------------------------------------------------
@dataclass
class FetchResult:
"""Raw fetch result carrying what URL resolution + format detection need."""
url: str # FINAL URL after redirects (RFC 3986 base for children)
content: bytes
content_type: str # Content-Type header value ("" when absent)
def _http_fetch(url: str, *, timeout: int = 30) -> Optional[FetchResult]:
"""SSRF-guarded GET returning content + final URL + Content-Type.
The final (post-redirect) URL matters: #254 resolves relative member
URLs against the URL the index was *actually retrieved from*, so an
index that redirects to a CDN must have its members resolved against
the CDN location, not the original well-known path.
"""
from tools.skills_hub import _guarded_http_get
resp = _guarded_http_get(url, timeout=timeout)
if resp is None or resp.status_code != 200:
return None
# _guarded_http_get follows redirects manually (follow_redirects=False),
# so the response's request URL IS the final hop.
try:
final_url = str(resp.url) or url
except Exception:
final_url = url
content_type = ""
try:
content_type = resp.headers.get("content-type", "") or ""
except Exception:
pass
return FetchResult(url=final_url, content=resp.content,
content_type=content_type)
def _http_get_bytes(url: str, *, timeout: int = 30) -> Optional[bytes]:
"""Bytes-only convenience wrapper over :func:`_http_fetch`."""
result = _http_fetch(url, timeout=timeout)
return None if result is None else result.content
def _fetch_json(url: str, *, timeout: int = 20) -> Optional[tuple]:
"""Fetch and parse JSON. Returns ``(data, final_url)`` or None."""
result = _http_fetch(url, timeout=timeout)
if result is None:
return None
try:
return json.loads(result.content.decode("utf-8")), result.url
except (json.JSONDecodeError, UnicodeDecodeError):
logger.warning("Skill-set fetch: invalid JSON at %s", url)
return None
# ---------------------------------------------------------------------------
# Digest verification
# ---------------------------------------------------------------------------
def verify_digest(content: bytes, digest: str, *, what: str = "artifact") -> None:
"""Verify ``content`` against a ``sha256:<hex>`` digest string.
Raises :class:`DigestError` on missing/malformed/mismatched digests —
#254 makes the digest required and forbids using unverified content.
"""
if not isinstance(digest, str) or not digest:
raise DigestError(f"{what}: missing digest (required by the discovery spec)")
m = _DIGEST_RE.match(digest.strip())
if not m:
raise DigestError(
f"{what}: malformed digest {digest!r} (expected sha256:<64 lowercase hex>)"
)
actual = hashlib.sha256(content).hexdigest()
if actual != m.group(1):
raise DigestError(
f"{what}: digest mismatch — index says sha256:{m.group(1)}, "
f"downloaded content is sha256:{actual}. Refusing to use it."
)
def compute_digest(content: bytes) -> str:
"""Format a #254 digest string for raw bytes (publisher-side helper)."""
return f"sha256:{hashlib.sha256(content).hexdigest()}"
# ---------------------------------------------------------------------------
# AI Catalog parsing
# ---------------------------------------------------------------------------
def catalog_url_for(origin_or_url: str) -> str:
"""Normalize user input to an AI Catalog URL.
``https://example.com`` -> ``https://example.com/.well-known/ai-catalog.json``;
anything already ending in ``.json`` is returned as-is.
"""
url = origin_or_url.strip().rstrip("/")
if url.endswith(".json"):
return origin_or_url.strip()
return url + AI_CATALOG_WELL_KNOWN
def _entry_extensions(entry: Dict[str, Any]) -> Dict[str, Any]:
ext = entry.get("extensions")
return ext if isinstance(ext, dict) else {}
def discover_skill_sets(catalog_url: str, *, _depth: int = 0,
_inline_catalog: Optional[Dict[str, Any]] = None,
) -> List[SkillSetInfo]:
"""Fetch an AI Catalog and return every skill-set entry in it.
Follows ``application/ai-catalog+json`` sub-catalog entries one level
deep (AI Catalog is nestable; the prototype bounds recursion at 1).
Entries may carry their payload via ``url`` or inline ``data`` — the
AI Catalog spec allows either (exactly one per entry).
"""
if _inline_catalog is not None:
data: Any = _inline_catalog
base_url = catalog_url
else:
fetched = _fetch_json(catalog_url)
if fetched is None:
raise SkillSetError(f"Could not fetch AI Catalog at {catalog_url}")
data, base_url = fetched
if not isinstance(data, dict):
raise SkillSetError(f"Could not fetch AI Catalog at {catalog_url}")
entries = data.get("entries")
if not isinstance(entries, list):
raise SkillSetError(
f"{catalog_url} is not an AI Catalog (no 'entries' array)"
)
sets: List[SkillSetInfo] = []
for entry in entries:
if not isinstance(entry, dict):
continue
etype = entry.get("type")
url = entry.get("url")
inline = entry.get("data")
has_url = isinstance(url, str) and bool(url)
has_data = isinstance(inline, dict)
if not has_url and not has_data:
continue
# RFC 3986: resolve against the URL the catalog was actually
# retrieved from (post-redirect), not the URL we asked for.
resolved = urljoin(base_url, url) if has_url else ""
if etype == SKILL_SET_ENTRY_TYPE:
hermes_ext = _entry_extensions(entry).get(HERMES_SET_EXTENSION)
hermes_ext = hermes_ext if isinstance(hermes_ext, dict) else {}
display = str(
entry.get("displayName") or entry.get("identifier")
or resolved or "inline skill set"
)
sets.append(SkillSetInfo(
name=display,
description=str(entry.get("description") or ""),
index_url=resolved or f"{base_url}#inline",
identifier=str(entry.get("identifier") or ""),
command=str(hermes_ext.get("command") or ""),
instruction=str(hermes_ext.get("instruction") or ""),
catalog_url=catalog_url,
inline_index=inline if has_data and not has_url else None,
base_url=base_url,
))
elif etype == CATALOG_ENTRY_TYPE and _depth < 1:
try:
if has_url:
sets.extend(discover_skill_sets(resolved, _depth=_depth + 1))
else:
# Inline sub-catalog: relative URLs inside it resolve
# against the parent catalog's retrieved location.
sets.extend(discover_skill_sets(
base_url, _depth=_depth + 1, _inline_catalog=inline,
))
except SkillSetError as exc:
logger.warning("Skipping unreadable sub-catalog %s: %s",
resolved or "(inline)", exc)
return sets
# ---------------------------------------------------------------------------
# #254 discovery index parsing
# ---------------------------------------------------------------------------
def resolve_skill_set(info: SkillSetInfo) -> ResolvedSkillSet:
"""Fetch and validate the #254 index a :class:`SkillSetInfo` points at.
Member URLs are resolved per RFC 3986 against the URL the index was
*actually retrieved from* — if the index URL redirects (e.g. to a CDN),
relative member URLs resolve against the CDN location, per #254.
"""
if info.inline_index is not None:
data: Any = info.inline_index
# Inline data was transported inside the catalog document, so its
# relative URLs resolve against the catalog's retrieved location.
member_base = info.base_url or info.catalog_url or info.index_url
else:
fetched = _fetch_json(info.index_url)
if fetched is None:
raise SkillSetError(f"Could not fetch skill index at {info.index_url}")
data, member_base = fetched
if not isinstance(data, dict):
raise SkillSetError(f"Could not fetch skill index at {info.index_url}")
schema = data.get("$schema")
if not isinstance(schema, str) or schema not in KNOWN_INDEX_SCHEMAS:
# #254: "Clients encountering an unrecognized or absent $schema
# should warn the user and should not process the index."
raise SchemaError(
f"Unrecognized index $schema {schema!r} at {info.index_url}. "
f"Known: {', '.join(sorted(KNOWN_INDEX_SCHEMAS))}"
)
raw_skills = data.get("skills")
if not isinstance(raw_skills, list):
raise SkillSetError(f"Index at {info.index_url} has no 'skills' array")
if len(raw_skills) > MAX_SET_MEMBERS:
raise SkillSetError(
f"Index enumerates {len(raw_skills)} skills (cap: {MAX_SET_MEMBERS})"
)
members: List[SkillSetMember] = []
skipped: List[str] = []
for entry in raw_skills:
if not isinstance(entry, dict):
skipped.append("(non-object entry)")
continue
name = entry.get("name")
etype = entry.get("type")
url = entry.get("url")
digest = entry.get("digest")
if not isinstance(name, str) or not name:
skipped.append("(entry without a name)")
continue
if etype not in ("skill-md", "archive"):
# #254: skip entries with an unrecognized type and warn.
skipped.append(f"{name} (unrecognized type {etype!r})")
continue
if not isinstance(url, str) or not url:
skipped.append(f"{name} (missing url)")
continue
members.append(SkillSetMember(
name=name,
description=str(entry.get("description") or ""),
type=etype,
url=urljoin(member_base, url),
digest=str(digest or ""),
))
return ResolvedSkillSet(info=info, members=members, skipped=skipped)
def resolve_bare_index(index_url: str, *, name: str = "") -> ResolvedSkillSet:
"""Treat a raw #254 index URL as a set (no AI Catalog wrapper).
Used when the user hands us an index.json directly. The set name falls
back to the host name; no extension metadata is available on this path.
"""
host = urlparse(index_url).netloc or index_url
info = SkillSetInfo(
name=name or host,
description=f"Skill set from {host}",
index_url=index_url,
)
return resolve_skill_set(info)
# ---------------------------------------------------------------------------
# Member fetching (skill-md + archive) -> SkillBundle
# ---------------------------------------------------------------------------
def _safe_member_path(raw_name: str) -> str:
"""Validate an archive member path per #254 archive-safety rules."""
name = raw_name.replace("\\", "/")
if name.startswith("/") or re.match(r"^[A-Za-z]:", name):
raise ArchiveSafetyError(f"absolute path in archive: {raw_name!r}")
normalized = posixpath.normpath(name)
if normalized.startswith("..") or "/../" in f"/{normalized}/":
raise ArchiveSafetyError(f"path traversal in archive: {raw_name!r}")
if normalized in (".", ""):
raise ArchiveSafetyError(f"empty archive member path: {raw_name!r}")
# Reuse the hub-wide validator for charset / depth constraints.
from tools.skills_hub import _validate_bundle_rel_path
return _validate_bundle_rel_path(normalized)
def _decode_member(raw: bytes) -> Union[str, bytes]:
try:
return raw.decode("utf-8")
except UnicodeDecodeError:
return raw
def _extract_tar_gz(content: bytes) -> Dict[str, Union[str, bytes]]:
files: Dict[str, Union[str, bytes]] = {}
total = 0
try:
tf = tarfile.open(fileobj=io.BytesIO(content), mode="r:gz")
except tarfile.TarError as exc:
raise ArchiveSafetyError(f"invalid .tar.gz archive: {exc}") from exc
with tf:
members = tf.getmembers()
if len(members) > MAX_ARCHIVE_MEMBERS:
raise ArchiveSafetyError(
f"archive has {len(members)} members (cap: {MAX_ARCHIVE_MEMBERS})"
)
for m in members:
if m.issym() or m.islnk():
# #254: reject symlinks/hardlinks outright (resolving them
# safely is not worth it for skill payloads).
raise ArchiveSafetyError(f"link member in archive: {m.name!r}")
if not m.isfile():
continue
safe = _safe_member_path(m.name)
if m.size > MAX_MEMBER_BYTES:
raise ArchiveSafetyError(
f"archive member {safe} is {m.size} bytes (cap: {MAX_MEMBER_BYTES})"
)
total += m.size
if total > MAX_UNPACKED_BYTES:
raise ArchiveSafetyError("archive exceeds decompressed size cap")
fh = tf.extractfile(m)
if fh is None:
continue
raw = fh.read(MAX_MEMBER_BYTES + 1)
if len(raw) > MAX_MEMBER_BYTES:
raise ArchiveSafetyError(f"archive member {safe} exceeded size cap")
files[safe] = _decode_member(raw)
return files
def _extract_zip(content: bytes) -> Dict[str, Union[str, bytes]]:
files: Dict[str, Union[str, bytes]] = {}
total = 0
try:
zf = zipfile.ZipFile(io.BytesIO(content))
except zipfile.BadZipFile as exc:
raise ArchiveSafetyError(f"invalid .zip archive: {exc}") from exc
with zf:
infos = zf.infolist()
if len(infos) > MAX_ARCHIVE_MEMBERS:
raise ArchiveSafetyError(
f"archive has {len(infos)} members (cap: {MAX_ARCHIVE_MEMBERS})"
)
for zinfo in infos:
if zinfo.is_dir():
continue
# Zip symlinks encode the link mode in external_attr's high bits.
if (zinfo.external_attr >> 16) & 0o170000 == 0o120000:
raise ArchiveSafetyError(f"symlink member in archive: {zinfo.filename!r}")
safe = _safe_member_path(zinfo.filename)
if zinfo.file_size > MAX_MEMBER_BYTES:
raise ArchiveSafetyError(
f"archive member {safe} is {zinfo.file_size} bytes "
f"(cap: {MAX_MEMBER_BYTES})"
)
total += zinfo.file_size
if total > MAX_UNPACKED_BYTES:
raise ArchiveSafetyError("archive exceeds decompressed size cap")
with zf.open(zinfo) as fh:
raw = fh.read(MAX_MEMBER_BYTES + 1)
if len(raw) > MAX_MEMBER_BYTES:
raise ArchiveSafetyError(f"archive member {safe} exceeded size cap")
files[safe] = _decode_member(raw)
return files
#: Content-Type values mapped to archive formats (#254: header wins; the
#: URL extension is only a fallback for absent/generic headers).
_ARCHIVE_CONTENT_TYPES = {
"application/gzip": "tar.gz",
"application/x-gzip": "tar.gz",
"application/x-tar+gzip": "tar.gz",
"application/zip": "zip",
"application/x-zip-compressed": "zip",
}
_GENERIC_CONTENT_TYPES = frozenset({
"", "application/octet-stream", "binary/octet-stream",
"application/binary", "text/plain",
})
def _archive_format(url: str, content_type: str = "") -> str:
"""Determine archive format: Content-Type header first, then extension.
Per #254: "Clients should determine the archive format from the
server's Content-Type header, falling back to the URL file extension
if the header is absent or generic."
"""
media_type = content_type.split(";", 1)[0].strip().lower()
if media_type not in _GENERIC_CONTENT_TYPES:
fmt = _ARCHIVE_CONTENT_TYPES.get(media_type)
if fmt:
return fmt
# Specific but unknown media type — fall through to the extension
# rather than hard-failing on an exotic-but-honest server config.
logger.debug("Unrecognized archive Content-Type %r for %s; "
"falling back to URL extension", content_type, url)
path = urlparse(url).path.lower()
if path.endswith((".tar.gz", ".tgz")):
return "tar.gz"
if path.endswith(".zip"):
return "zip"
raise ArchiveSafetyError(
f"unsupported archive format for {url} "
f"(Content-Type {content_type or '(none)'!r}; expected .tar.gz or .zip)"
)
def fetch_member(member: SkillSetMember, *, set_info: SkillSetInfo):
"""Download, digest-verify, and unpack one set member.
Returns a ``tools.skills_hub.SkillBundle`` ready for the standard
quarantine -> scan -> install pipeline.
"""
from tools.skills_hub import SkillBundle, _validate_skill_name
skill_name = _validate_skill_name(member.name)
fetched = _http_fetch(member.url)
if fetched is None:
raise SkillSetError(f"{member.name}: could not download {member.url}")
content = fetched.content
if len(content) > MAX_ARCHIVE_BYTES:
raise ArchiveSafetyError(
f"{member.name}: artifact is {len(content)} bytes (cap: {MAX_ARCHIVE_BYTES})"
)
verify_digest(content, member.digest, what=member.name)
if member.type == "skill-md":
try:
files: Dict[str, Union[str, bytes]] = {
"SKILL.md": content.decode("utf-8")
}
except UnicodeDecodeError as exc:
raise SkillSetError(f"{member.name}: SKILL.md is not valid UTF-8") from exc
else:
# Format detection uses the FINAL (post-redirect) URL for the
# extension fallback, and the response Content-Type first.
fmt = _archive_format(fetched.url, fetched.content_type)
files = _extract_tar_gz(content) if fmt == "tar.gz" else _extract_zip(content)
if "SKILL.md" not in files:
# #254: "The archive must contain SKILL.md at the root."
raise ArchiveSafetyError(
f"{member.name}: archive has no SKILL.md at its root"
)
return SkillBundle(
name=skill_name,
files=files,
source="skill-set",
identifier=f"skill-set:{set_info.index_url}#{skill_name}",
trust_level="community",
metadata={
"set_name": set_info.name,
"index_url": set_info.index_url,
"catalog_url": set_info.catalog_url,
"artifact_url": member.url,
"artifact_type": member.type,
"digest": member.digest,
"source_url": member.url,
},
)