feat: install skill sets from AI Catalog + agentskills discovery indexes

Prototype of the skill-set layering discussed with agentskills.io:

- AI Catalog (/.well-known/ai-catalog.json) entries typed
  application/agent-skills+json point at an agentskills PR #254
  discovery index and represent an installable skill set.
- The optional io.hermes.skill-set extension carries set-level usage
  intent: a suggested load-alias command and a shared instruction
  preamble. Clients that ignore the extension still install the
  correct set.
- tools/skill_set_catalog.py implements the client: $schema gating,
  required sha256 digest verification, skill-md + archive (.tar.gz/.zip)
  artifacts, and #254 archive-safety rules (traversal/absolute-path/
  link rejection, decompression caps).
- hermes skills install-set <url> installs every member through the
  existing quarantine -> scan -> install pipeline, then creates the
  /<name> skill bundle so the whole set loads in one turn.
- scripts/publish_skill_set.py is the publisher-side counterpart:
  builds the static .well-known tree (catalog + index + artifacts)
  from local skill directories with byte-stable archives.
This commit is contained in:
Teknium 2026-08-08 10:14:49 -07:00
parent a6ede70c2a
commit e34a29be56
No known key found for this signature in database
5 changed files with 1373 additions and 1 deletions

View File

@ -793,6 +793,193 @@ def do_install(identifier: str, category: str = "", force: bool = False,
c.print("[dim]Use /reset to start a new session now, or --now to activate immediately (invalidates prompt cache).[/]\n")
def do_install_set(url: str, *, set_name: str = "", force: bool = False,
skip_confirm: bool = False, no_alias: bool = False,
console: Optional[Console] = None) -> None:
"""Install a skill *set* from an AI Catalog or a #254 discovery index.
``url`` may be:
- an origin (``https://example.com``) resolved to
``/.well-known/ai-catalog.json``
- an AI Catalog JSON URL skill-set entries
(``application/agent-skills+json``) are listed / selected
- a #254 ``index.json`` URL — treated as a single anonymous set
Every member flows through the standard fetch -> digest-verify ->
quarantine -> scan -> install pipeline. After installing, a local skill
bundle (load-alias) is created from the entry's ``io.hermes.skill-set``
extension unless ``no_alias`` is set.
"""
from tools.skill_set_catalog import (
SkillSetError, catalog_url_for, discover_skill_sets, fetch_member,
resolve_bare_index, resolve_skill_set,
)
from tools.skills_hub import (
HUB_DIR, HubLockFile, append_audit_log, ensure_hub_dirs,
install_from_quarantine, quarantine_bundle, source_url_for_bundle,
)
from tools.skills_guard import (
format_scan_report, scan_skill_cached, should_allow_install,
)
c = console or _console
ensure_hub_dirs()
# --- Resolve the set -------------------------------------------------
try:
if url.rstrip("/").endswith("index.json"):
resolved = resolve_bare_index(url, name=set_name)
else:
catalog_url = catalog_url_for(url)
c.print(f"\n[bold]Fetching catalog:[/] {catalog_url}")
sets = discover_skill_sets(catalog_url)
if not sets:
c.print("[bold red]Error:[/] catalog has no skill-set entries "
"(type: application/agent-skills+json).\n")
return
chosen = None
if set_name:
wanted = set_name.strip().lower()
for s in sets:
if wanted in (s.name.strip().lower(),
s.command.strip().lower(),
s.identifier.strip().lower()):
chosen = s
break
if chosen is None:
c.print(f"[bold red]Error:[/] no skill set named '{set_name}'. "
f"Available: {', '.join(s.name for s in sets)}\n")
return
elif len(sets) == 1:
chosen = sets[0]
else:
c.print("\n[bold]Skill sets in this catalog:[/]")
for s in sets:
c.print(f" • [cyan]{s.name}[/] — {s.description or '(no description)'}")
c.print("\nRe-run with [bold]--set <name>[/] to pick one.\n")
return
resolved = resolve_skill_set(chosen)
except SkillSetError as exc:
c.print(f"[bold red]Error:[/] {exc}\n")
return
info = resolved.info
c.print(f"\n[bold]Skill set:[/] {info.name}")
if info.description:
c.print(f" {info.description}")
c.print(f" [dim]Index: {info.index_url}[/]")
c.print(f" [bold]{len(resolved.members)}[/] member skill(s): "
f"{', '.join(m.name for m in resolved.members)}")
for note in resolved.skipped:
c.print(f" [yellow]Skipped:[/] {note}")
if not resolved.members:
c.print("[bold red]Error:[/] set has no installable members.\n")
return
if not force and not skip_confirm:
c.print(Panel(
"[bold yellow]You are installing third-party skills at your own risk.[/]\n\n"
"Each member is digest-verified and security-scanned before install,\n"
"but you should review the installed files before use.",
title="Disclaimer", border_style="yellow",
))
c.print(f"[bold]Install all {len(resolved.members)} skills from "
f"'{info.name}'?[/]")
try:
answer = input("Confirm [y/N]: ").strip().lower()
except (EOFError, KeyboardInterrupt):
answer = "n"
if answer not in {"y", "yes"}:
c.print("[dim]Installation cancelled.[/]\n")
return
# --- Install each member through the standard pipeline ----------------
lock = HubLockFile()
installed: List[str] = []
failed: List[str] = []
for member in resolved.members:
c.print(f"\n[bold]── {member.name} ──[/]")
try:
bundle = fetch_member(member, set_info=info)
except SkillSetError as exc:
c.print(f"[bold red]Failed:[/] {exc}")
failed.append(member.name)
continue
if lock.get_installed(bundle.name) and not force:
c.print(f"[yellow]Already installed — skipping.[/] (--force to reinstall)")
installed.append(bundle.name) # still usable by the alias
continue
try:
q_path = quarantine_bundle(bundle)
except ValueError as exc:
c.print(f"[bold red]Blocked:[/] {exc}")
append_audit_log("BLOCKED", bundle.name, bundle.source,
bundle.trust_level, "invalid_path", str(exc))
failed.append(member.name)
continue
result, scan_provenance = scan_skill_cached(
q_path, source=bundle.identifier,
source_url=source_url_for_bundle(bundle),
cache_dir=HUB_DIR / "scan-cache",
)
c.print(format_scan_report(result))
allowed, reason = should_allow_install(result, force=force)
if not allowed:
c.print(f"[bold red]Blocked:[/] {reason}")
shutil.rmtree(q_path, ignore_errors=True)
append_audit_log("BLOCKED", bundle.name, bundle.source,
bundle.trust_level, result.verdict,
f"{len(result.findings)}_findings")
failed.append(member.name)
continue
try:
install_dir = install_from_quarantine(
q_path, bundle.name, "", bundle, result, scan_provenance,
)
except ValueError as exc:
c.print(f"[bold red]Blocked:[/] {exc}")
shutil.rmtree(q_path, ignore_errors=True)
failed.append(member.name)
continue
c.print(f"[bold green]Installed:[/] {install_dir.name}")
installed.append(bundle.name)
# --- Create the load-alias (skill bundle) ------------------------------
c.print()
if failed:
c.print(f"[yellow]{len(failed)} member(s) failed:[/] {', '.join(failed)}")
if not installed:
c.print("[bold red]No skills installed — not creating a bundle alias.[/]\n")
return
if no_alias:
c.print(f"[bold green]Done.[/] Installed {len(installed)} skill(s).\n")
return
alias = info.command or info.name
try:
from agent.skill_bundles import save_bundle
bundle_path = save_bundle(
alias, installed,
description=info.description,
instruction=info.instruction,
overwrite=True,
)
from agent.skill_bundles import _slugify as _bundle_slug
c.print(f"[bold green]Done.[/] Installed {len(installed)} skill(s) and "
f"created the [bold]/{_bundle_slug(alias)}[/] bundle "
f"([dim]{bundle_path}[/]).")
c.print("[dim]Invoke it in chat to load the whole set in one turn. "
"New skills appear next session (or /reset now).[/]\n")
except (ValueError, OSError) as exc:
c.print(f"[yellow]Installed {len(installed)} skill(s), but could not "
f"create the bundle alias: {exc}[/]\n")
def do_inspect(identifier: str, console: Optional[Console] = None) -> None:
"""Preview a skill's SKILL.md content without installing."""
from tools.skills_hub import GitHubAuth, create_source_router
@ -1735,6 +1922,11 @@ def skills_command(args) -> None:
do_install(args.identifier, category=args.category, force=args.force,
skip_confirm=getattr(args, "yes", False),
name_override=getattr(args, "name", "") or "")
elif action == "install-set":
do_install_set(args.url, set_name=getattr(args, "set_name", "") or "",
force=getattr(args, "force", False),
skip_confirm=getattr(args, "yes", False),
no_alias=getattr(args, "no_alias", False))
elif action == "inspect":
do_inspect(args.identifier)
elif action == "list":
@ -1788,7 +1980,7 @@ def skills_command(args) -> None:
return
do_tap(tap_action, repo=repo)
else:
_console.print("Usage: hermes skills [browse|search|install|inspect|list|list-modified|diff|check|update|audit|uninstall|reset|opt-out|opt-in|publish|snapshot|tap]\n")
_console.print("Usage: hermes skills [browse|search|install|install-set|inspect|list|list-modified|diff|check|update|audit|uninstall|reset|opt-out|opt-in|publish|snapshot|tap]\n")
_console.print("Run 'hermes skills <command> --help' for details.\n")

View File

@ -108,6 +108,38 @@ def build_skills_parser(subparsers, *, cmd_skills: Callable) -> None:
help="Skip confirmation prompt (needed in TUI mode)",
)
skills_install_set = skills_subparsers.add_parser(
"install-set",
help="Install a skill set from an AI Catalog or agent-skills discovery index",
description=(
"Install a group of skills published as a set. Accepts an origin "
"(https://example.com — resolved to /.well-known/ai-catalog.json), "
"an AI Catalog JSON URL, or a direct agent-skills index.json URL. "
"Members are digest-verified and security-scanned individually; "
"a /<name> bundle alias is created so the whole set loads in one turn."
),
)
skills_install_set.add_argument(
"url",
help="Origin, AI Catalog URL, or agent-skills index.json URL",
)
skills_install_set.add_argument(
"--set", dest="set_name", default="",
help="Which skill set to install when the catalog has several",
)
skills_install_set.add_argument(
"--no-alias", action="store_true",
help="Install the skills but skip creating the /<name> bundle alias",
)
skills_install_set.add_argument(
"--force", action="store_true",
help="Reinstall members that are already installed",
)
skills_install_set.add_argument(
"--yes", "-y", action="store_true",
help="Skip confirmation prompt",
)
skills_inspect = skills_subparsers.add_parser(
"inspect", help="Preview a skill without installing"
)

View File

@ -0,0 +1,186 @@
#!/usr/bin/env python3
"""Build a publishable skill-set catalog from local skill directories.
Publisher-side companion to ``hermes skills install-set``. Given one or more
skill directories (each containing a ``SKILL.md``), this produces a static
site tree implementing the agentskills #254 discovery index plus an AI
Catalog (https://ai-catalog.io) wrapper entry:
out/
.well-known/
ai-catalog.json # AI Catalog with one skill-set entry
agent-skills/
index.json # #254 discovery index
<single-file-skill>/SKILL.md # type: skill-md
<multi-file-skill>.tar.gz # type: archive
(serve this directory over HTTPS)
Skills with only a SKILL.md are published as ``type: "skill-md"``; skills
with supporting files (scripts/, references/, ...) are packed into a
``.tar.gz`` with SKILL.md at the archive root, per the spec. Every artifact
gets a required ``sha256:`` digest.
Usage:
python scripts/publish_skill_set.py \
--name "Backend Dev" \
--command backend-dev \
--description "Everything for backend feature work." \
--instruction "Prefer TDD. Run the linter before opening a PR." \
--host-name "Example Corp" \
--out ./public \
path/to/skill-a path/to/skill-b ...
Then serve ``./public`` at your origin and install with:
hermes skills install-set https://your-origin.example
"""
from __future__ import annotations
import argparse
import gzip
import hashlib
import io
import json
import sys
import tarfile
from pathlib import Path
INDEX_SCHEMA = "https://schemas.agentskills.io/discovery/0.2.0/schema.json"
SKILL_SET_ENTRY_TYPE = "application/agent-skills+json"
HERMES_SET_EXTENSION = "io.hermes.skill-set"
def _digest(content: bytes) -> str:
return f"sha256:{hashlib.sha256(content).hexdigest()}"
def _frontmatter_field(skill_md: str, field: str) -> str:
"""Cheap YAML frontmatter single-field read (name/description)."""
lines = skill_md.splitlines()
if not lines or lines[0].strip() != "---":
return ""
for line in lines[1:]:
if line.strip() == "---":
break
if line.startswith(f"{field}:"):
return line.split(":", 1)[1].strip().strip("\"'")
return ""
def _pack_tar_gz(skill_dir: Path) -> bytes:
"""Deterministic tar.gz of a skill directory, SKILL.md at archive root."""
buf = io.BytesIO()
# mtime=0 + sorted names -> byte-stable archives, so digests only change
# when content changes (plays nice with #254's digest-based caching).
with gzip.GzipFile(fileobj=buf, mode="wb", mtime=0) as gz:
with tarfile.open(fileobj=gz, mode="w") as tf:
for path in sorted(skill_dir.rglob("*")):
if not path.is_file() or path.is_symlink():
continue
rel = path.relative_to(skill_dir).as_posix()
info = tarfile.TarInfo(name=rel)
data = path.read_bytes()
info.size = len(data)
info.mtime = 0
info.mode = 0o644
tf.addfile(info, io.BytesIO(data))
return buf.getvalue()
def main() -> int:
ap = argparse.ArgumentParser(description=(__doc__ or "").split("\n", 1)[0])
ap.add_argument("skill_dirs", nargs="+", type=Path,
help="Skill directories (each must contain SKILL.md)")
ap.add_argument("--name", required=True, help="Skill set display name")
ap.add_argument("--description", default="", help="Skill set description")
ap.add_argument("--command", default="",
help="Suggested load-alias (io.hermes.skill-set extension)")
ap.add_argument("--instruction", default="",
help="Shared instruction preamble (io.hermes.skill-set extension)")
ap.add_argument("--host-name", default="", help="AI Catalog host displayName")
ap.add_argument("--host-id", default="", help="AI Catalog host identifier (e.g. did:web:...)")
ap.add_argument("--out", type=Path, default=Path("public"), help="Output directory")
args = ap.parse_args()
well_known = args.out / ".well-known"
skills_dir = well_known / "agent-skills"
skills_dir.mkdir(parents=True, exist_ok=True)
entries = []
for skill_dir in args.skill_dirs:
skill_md_path = skill_dir / "SKILL.md"
if not skill_md_path.is_file():
print(f"error: {skill_dir} has no SKILL.md", file=sys.stderr)
return 1
skill_md = skill_md_path.read_text(encoding="utf-8")
name = _frontmatter_field(skill_md, "name") or skill_dir.name
description = _frontmatter_field(skill_md, "description")
extra_files = [p for p in skill_dir.rglob("*")
if p.is_file() and p != skill_md_path]
if extra_files:
artifact = _pack_tar_gz(skill_dir)
(skills_dir / f"{name}.tar.gz").write_bytes(artifact)
entries.append({
"name": name,
"type": "archive",
"description": description,
"url": f"/.well-known/agent-skills/{name}.tar.gz",
"digest": _digest(artifact),
})
print(f" archive {name} ({len(extra_files) + 1} files, "
f"{len(artifact):,} bytes)")
else:
content = skill_md.encode("utf-8")
dest = skills_dir / name
dest.mkdir(parents=True, exist_ok=True)
(dest / "SKILL.md").write_bytes(content)
entries.append({
"name": name,
"type": "skill-md",
"description": description,
"url": f"/.well-known/agent-skills/{name}/SKILL.md",
"digest": _digest(content),
})
print(f" skill-md {name}")
index = {"$schema": INDEX_SCHEMA, "skills": entries}
(skills_dir / "index.json").write_text(
json.dumps(index, indent=2) + "\n", encoding="utf-8")
set_entry = {
"identifier": f"urn:air:{args.host_id or 'example'}:skill-set:"
f"{args.command or args.name.lower().replace(' ', '-')}",
"displayName": args.name,
"description": args.description,
"type": SKILL_SET_ENTRY_TYPE,
"url": "/.well-known/agent-skills/index.json",
}
ext: dict = {}
if args.command:
ext["command"] = args.command
if args.instruction:
ext["instruction"] = args.instruction
if ext:
set_entry["extensions"] = {HERMES_SET_EXTENSION: ext}
catalog = {
"specVersion": "1.0",
"host": {
"displayName": args.host_name or args.name,
**({"identifier": args.host_id} if args.host_id else {}),
},
"entries": [set_entry],
}
(well_known / "ai-catalog.json").write_text(
json.dumps(catalog, indent=2) + "\n", encoding="utf-8")
print(f"\nWrote {args.out}/.well-known/ai-catalog.json "
f"and agent-skills/index.json ({len(entries)} skills).")
print("Serve the output directory at your origin, then:")
print(" hermes skills install-set https://<your-origin>")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@ -0,0 +1,458 @@
"""Tests for tools/skill_set_catalog.py — the #254 + AI Catalog skill-set client.
Unit tests cover schema gating, digest verification, archive safety, and
catalog/index parsing with mocked HTTP. The E2E test at the bottom runs a
real local HTTP server built by scripts/publish_skill_set.py and exercises
discover -> resolve -> fetch against actual bytes on the wire.
"""
import gzip
import hashlib
import io
import json
import tarfile
import zipfile
from unittest.mock import patch
import pytest
from tools.skill_set_catalog import (
ArchiveSafetyError,
DigestError,
SchemaError,
SkillSetError,
SkillSetInfo,
catalog_url_for,
compute_digest,
discover_skill_sets,
fetch_member,
resolve_bare_index,
resolve_skill_set,
verify_digest,
SkillSetMember,
KNOWN_INDEX_SCHEMAS,
HERMES_SET_EXTENSION,
SKILL_SET_ENTRY_TYPE,
)
SCHEMA = next(iter(KNOWN_INDEX_SCHEMAS))
BASE = "https://skills.example.com"
INDEX_URL = f"{BASE}/.well-known/agent-skills/index.json"
CATALOG_URL = f"{BASE}/.well-known/ai-catalog.json"
SKILL_MD = (
"---\nname: code-review\ndescription: Review code.\n---\n\n# Code Review Skill\n"
)
def _tar_gz(files: dict) -> bytes:
buf = io.BytesIO()
with gzip.GzipFile(fileobj=buf, mode="wb", mtime=0) as gz:
with tarfile.open(fileobj=gz, mode="w") as tf:
for name, data in files.items():
raw = data.encode() if isinstance(data, str) else data
info = tarfile.TarInfo(name=name)
info.size = len(raw)
tf.addfile(info, io.BytesIO(raw))
return buf.getvalue()
def _zip(files: dict) -> bytes:
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as zf:
for name, data in files.items():
zf.writestr(name, data)
return buf.getvalue()
def _serve(pages: dict):
"""Patch the module HTTP layer with a URL -> bytes dict."""
def fake_get(url, *, timeout=30):
val = pages.get(url)
if val is None:
return None
return val.encode() if isinstance(val, str) else val
return patch("tools.skill_set_catalog._http_get_bytes", side_effect=fake_get)
# ---------------------------------------------------------------------------
# Digest verification
# ---------------------------------------------------------------------------
class TestDigest:
def test_roundtrip(self):
content = b"hello skills"
verify_digest(content, compute_digest(content)) # no raise
def test_mismatch_rejected(self):
with pytest.raises(DigestError, match="mismatch"):
verify_digest(b"tampered", compute_digest(b"original"))
def test_missing_digest_rejected(self):
with pytest.raises(DigestError, match="missing digest"):
verify_digest(b"x", "")
@pytest.mark.parametrize("bad", [
"sha256:short",
"sha256:" + "G" * 64, # non-hex
"sha256:" + "A" * 64, # uppercase — spec says lowercase
"md5:" + "a" * 32,
"a" * 64, # bare hex without prefix
])
def test_malformed_digest_rejected(self, bad):
with pytest.raises(DigestError, match="malformed"):
verify_digest(b"x", bad)
# ---------------------------------------------------------------------------
# AI Catalog discovery
# ---------------------------------------------------------------------------
def _catalog(entries) -> str:
return json.dumps({
"specVersion": "1.0",
"host": {"displayName": "Example"},
"entries": entries,
})
class TestCatalogDiscovery:
def test_catalog_url_for_origin(self):
assert catalog_url_for("https://x.example") == \
"https://x.example/.well-known/ai-catalog.json"
assert catalog_url_for("https://x.example/custom/cat.json") == \
"https://x.example/custom/cat.json"
def test_finds_skill_set_entries_with_extension(self):
pages = {CATALOG_URL: _catalog([
{
"identifier": "urn:air:example:skill-set:backend",
"displayName": "Backend Dev",
"description": "Backend feature work.",
"type": SKILL_SET_ENTRY_TYPE,
"url": "/.well-known/agent-skills/index.json",
"extensions": {HERMES_SET_EXTENSION: {
"command": "backend-dev",
"instruction": "Prefer TDD.",
}},
},
{"identifier": "urn:air:example:mcp:weather",
"type": "application/mcp-server-card+json",
"url": "https://api.example.com/mcp"},
])}
with _serve(pages):
sets = discover_skill_sets(CATALOG_URL)
assert len(sets) == 1
s = sets[0]
assert s.name == "Backend Dev"
assert s.index_url == INDEX_URL
assert s.command == "backend-dev"
assert s.instruction == "Prefer TDD."
def test_entry_without_extension_still_discovered(self):
pages = {CATALOG_URL: _catalog([
{"displayName": "Plain Set", "type": SKILL_SET_ENTRY_TYPE,
"url": "/.well-known/agent-skills/index.json"},
])}
with _serve(pages):
sets = discover_skill_sets(CATALOG_URL)
assert len(sets) == 1
assert sets[0].command == ""
assert sets[0].instruction == ""
def test_follows_sub_catalog_one_level(self):
sub_url = f"{BASE}/catalogs/eng.json"
pages = {
CATALOG_URL: _catalog([
{"displayName": "Engineering", "type": "application/ai-catalog+json",
"url": "/catalogs/eng.json"},
]),
sub_url: _catalog([
{"displayName": "Backend Dev", "type": SKILL_SET_ENTRY_TYPE,
"url": "/.well-known/agent-skills/index.json"},
]),
}
with _serve(pages):
sets = discover_skill_sets(CATALOG_URL)
assert [s.name for s in sets] == ["Backend Dev"]
assert sets[0].index_url == INDEX_URL
def test_not_a_catalog_raises(self):
with _serve({CATALOG_URL: json.dumps({"skills": []})}):
with pytest.raises(SkillSetError, match="not an AI Catalog"):
discover_skill_sets(CATALOG_URL)
def test_unreachable_catalog_raises(self):
with _serve({}):
with pytest.raises(SkillSetError, match="Could not fetch"):
discover_skill_sets(CATALOG_URL)
# ---------------------------------------------------------------------------
# #254 index resolution
# ---------------------------------------------------------------------------
def _index(skills, schema: "str | None" = SCHEMA) -> str:
payload = {"skills": skills}
if schema is not None:
payload["$schema"] = schema
return json.dumps(payload)
def _info() -> SkillSetInfo:
return SkillSetInfo(name="Test Set", description="", index_url=INDEX_URL)
class TestIndexResolution:
def test_members_parsed_and_urls_resolved(self):
pages = {INDEX_URL: _index([
{"name": "code-review", "type": "skill-md",
"description": "Review code.",
"url": "code-review/SKILL.md", "digest": compute_digest(b"x")},
{"name": "wrangler", "type": "archive",
"description": "Deploy workers.",
"url": "/.well-known/agent-skills/wrangler.tar.gz",
"digest": compute_digest(b"y")},
])}
with _serve(pages):
resolved = resolve_skill_set(_info())
assert [m.name for m in resolved.members] == ["code-review", "wrangler"]
# Relative resolved against index directory; path-absolute against origin.
assert resolved.members[0].url == \
f"{BASE}/.well-known/agent-skills/code-review/SKILL.md"
assert resolved.members[1].url == \
f"{BASE}/.well-known/agent-skills/wrangler.tar.gz"
def test_unknown_schema_refused(self):
pages = {INDEX_URL: _index([], schema="https://example.com/other/1.0.json")}
with _serve(pages):
with pytest.raises(SchemaError, match="Unrecognized index"):
resolve_skill_set(_info())
def test_absent_schema_refused(self):
pages = {INDEX_URL: _index([], schema=None)}
with _serve(pages):
with pytest.raises(SchemaError):
resolve_skill_set(_info())
def test_unrecognized_type_skipped_with_warning(self):
pages = {INDEX_URL: _index([
{"name": "good", "type": "skill-md", "url": "good/SKILL.md",
"digest": compute_digest(b"x")},
{"name": "weird", "type": "oci-image", "url": "weird.oci",
"digest": compute_digest(b"y")},
])}
with _serve(pages):
resolved = resolve_skill_set(_info())
assert [m.name for m in resolved.members] == ["good"]
assert any("weird" in s for s in resolved.skipped)
def test_bare_index_fallback_name(self):
pages = {INDEX_URL: _index([])}
with _serve(pages):
resolved = resolve_bare_index(INDEX_URL)
assert resolved.info.name == "skills.example.com"
# ---------------------------------------------------------------------------
# Member fetching — digest + archive safety
# ---------------------------------------------------------------------------
def _member(name="code-review", mtype="skill-md", url=None, digest=""):
return SkillSetMember(
name=name, description="", type=mtype,
url=url or f"{BASE}/.well-known/agent-skills/{name}/SKILL.md",
digest=digest,
)
class TestFetchMember:
def test_skill_md_happy_path(self):
content = SKILL_MD.encode()
m = _member(digest=compute_digest(content))
with _serve({m.url: content}):
bundle = fetch_member(m, set_info=_info())
assert bundle.name == "code-review"
assert bundle.files == {"SKILL.md": SKILL_MD}
assert bundle.source == "skill-set"
assert bundle.metadata["digest"] == m.digest
def test_tampered_content_rejected(self):
m = _member(digest=compute_digest(SKILL_MD.encode()))
with _serve({m.url: b"---\nname: evil\n---\nrm -rf /"}):
with pytest.raises(DigestError, match="mismatch"):
fetch_member(m, set_info=_info())
def test_archive_happy_path(self):
artifact = _tar_gz({
"SKILL.md": SKILL_MD,
"scripts/deploy.sh": "#!/bin/sh\necho hi\n",
"references/API.md": "# API\n",
})
url = f"{BASE}/.well-known/agent-skills/wrangler.tar.gz"
m = _member("wrangler", "archive", url, compute_digest(artifact))
with _serve({url: artifact}):
bundle = fetch_member(m, set_info=_info())
assert set(bundle.files) == {"SKILL.md", "scripts/deploy.sh",
"references/API.md"}
def test_zip_happy_path(self):
artifact = _zip({"SKILL.md": SKILL_MD, "references/NOTES.md": "notes"})
url = f"{BASE}/.well-known/agent-skills/z.zip"
m = _member("zskill", "archive", url, compute_digest(artifact))
with _serve({url: artifact}):
bundle = fetch_member(m, set_info=_info())
assert set(bundle.files) == {"SKILL.md", "references/NOTES.md"}
def test_archive_without_root_skill_md_rejected(self):
artifact = _tar_gz({"nested/SKILL.md": SKILL_MD})
url = f"{BASE}/.well-known/agent-skills/bad.tar.gz"
m = _member("bad", "archive", url, compute_digest(artifact))
with _serve({url: artifact}):
with pytest.raises(ArchiveSafetyError, match="no SKILL.md at its root"):
fetch_member(m, set_info=_info())
def test_path_traversal_rejected(self):
artifact = _tar_gz({"SKILL.md": SKILL_MD, "../../evil.sh": "boom"})
url = f"{BASE}/.well-known/agent-skills/trav.tar.gz"
m = _member("trav", "archive", url, compute_digest(artifact))
with _serve({url: artifact}):
with pytest.raises(ArchiveSafetyError):
fetch_member(m, set_info=_info())
def test_absolute_path_rejected(self):
artifact = _tar_gz({"SKILL.md": SKILL_MD, "/etc/cron.d/evil": "boom"})
url = f"{BASE}/.well-known/agent-skills/abs.tar.gz"
m = _member("abs", "archive", url, compute_digest(artifact))
with _serve({url: artifact}):
with pytest.raises(ArchiveSafetyError):
fetch_member(m, set_info=_info())
def test_symlink_member_rejected(self):
buf = io.BytesIO()
with gzip.GzipFile(fileobj=buf, mode="wb", mtime=0) as gz:
with tarfile.open(fileobj=gz, mode="w") as tf:
data = SKILL_MD.encode()
info = tarfile.TarInfo(name="SKILL.md")
info.size = len(data)
tf.addfile(info, io.BytesIO(data))
link = tarfile.TarInfo(name="creds")
link.type = tarfile.SYMTYPE
link.linkname = "/home/user/.ssh/id_rsa"
tf.addfile(link)
artifact = buf.getvalue()
url = f"{BASE}/.well-known/agent-skills/lnk.tar.gz"
m = _member("lnk", "archive", url, compute_digest(artifact))
with _serve({url: artifact}):
with pytest.raises(ArchiveSafetyError, match="link member"):
fetch_member(m, set_info=_info())
def test_decompression_bomb_rejected(self):
# 20MB of zeros compresses tiny but exceeds the per-member cap.
artifact = _tar_gz({"SKILL.md": SKILL_MD, "big.bin": b"\0" * (6 * 1024 * 1024)})
url = f"{BASE}/.well-known/agent-skills/bomb.tar.gz"
m = _member("bomb", "archive", url, compute_digest(artifact))
with _serve({url: artifact}):
with pytest.raises(ArchiveSafetyError):
fetch_member(m, set_info=_info())
def test_unsupported_archive_extension_rejected(self):
url = f"{BASE}/.well-known/agent-skills/skill.rar"
m = _member("rarred", "archive", url, compute_digest(b"data"))
with _serve({url: b"data"}):
with pytest.raises(ArchiveSafetyError, match="unsupported archive format"):
fetch_member(m, set_info=_info())
def test_unsafe_skill_name_rejected(self):
m = _member("../escape", digest=compute_digest(b"x"))
with _serve({m.url: b"x"}):
with pytest.raises(ValueError):
fetch_member(m, set_info=_info())
# ---------------------------------------------------------------------------
# E2E: publisher script -> real HTTP server -> full client flow
# ---------------------------------------------------------------------------
class TestEndToEnd:
@pytest.fixture()
def published_site(self, tmp_path):
"""Build a real publisher tree with scripts/publish_skill_set.py."""
import subprocess
import sys as _sys
from pathlib import Path as _P
repo_root = _P(__file__).resolve().parents[2]
script = repo_root / "scripts" / "publish_skill_set.py"
# One single-file skill, one multi-file skill.
s1 = tmp_path / "src" / "code-review"
s1.mkdir(parents=True)
(s1 / "SKILL.md").write_text(SKILL_MD)
s2 = tmp_path / "src" / "deploy-tool"
(s2 / "scripts").mkdir(parents=True)
(s2 / "SKILL.md").write_text(
"---\nname: deploy-tool\ndescription: Deploy things.\n---\n\n# Deploy\n")
(s2 / "scripts" / "run.sh").write_text("#!/bin/sh\necho deploy\n")
out = tmp_path / "public"
subprocess.run(
[_sys.executable, str(script),
"--name", "Backend Dev", "--command", "backend-dev",
"--description", "Backend feature work.",
"--instruction", "Prefer TDD.",
"--out", str(out), str(s1), str(s2)],
check=True, capture_output=True, text=True,
)
return out
def test_full_flow_over_real_http(self, published_site):
import http.server
import threading
handler = type("H", (http.server.SimpleHTTPRequestHandler,), {
"directory": str(published_site),
"log_message": lambda self, *a: None,
})
httpd = http.server.ThreadingHTTPServer(
("127.0.0.1", 0),
lambda *a, **kw: handler(*a, directory=str(published_site), **kw),
)
port = httpd.server_address[1]
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
thread.start()
try:
origin = f"http://127.0.0.1:{port}"
# Bypass the SSRF guard for the loopback test server only.
def local_get(url, *, timeout=30):
import httpx
resp = httpx.get(url, timeout=timeout)
return resp.content if resp.status_code == 200 else None
with patch("tools.skill_set_catalog._http_get_bytes",
side_effect=local_get):
sets = discover_skill_sets(catalog_url_for(origin))
assert len(sets) == 1
info = sets[0]
assert info.name == "Backend Dev"
assert info.command == "backend-dev"
assert info.instruction == "Prefer TDD."
resolved = resolve_skill_set(info)
assert {m.name for m in resolved.members} == \
{"code-review", "deploy-tool"}
types = {m.name: m.type for m in resolved.members}
assert types["code-review"] == "skill-md"
assert types["deploy-tool"] == "archive"
bundles = {m.name: fetch_member(m, set_info=info)
for m in resolved.members}
assert bundles["code-review"].files["SKILL.md"] == SKILL_MD
assert "scripts/run.sh" in bundles["deploy-tool"].files
# Digest verification ran on real bytes for every member.
for b in bundles.values():
assert b.metadata["digest"].startswith("sha256:")
finally:
httpd.shutdown()
httpd.server_close()

504
tools/skill_set_catalog.py Normal file
View File

@ -0,0 +1,504 @@
"""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.
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
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
@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_get_bytes)
# ---------------------------------------------------------------------------
def _http_get_bytes(url: str, *, timeout: int = 30) -> Optional[bytes]:
"""SSRF-guarded GET returning raw bytes, or None on any failure."""
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
return resp.content
def _http_get_json(url: str, *, timeout: int = 20) -> Optional[Any]:
raw = _http_get_bytes(url, timeout=timeout)
if raw is None:
return None
try:
return json.loads(raw.decode("utf-8"))
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) -> 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).
"""
data = _http_get_json(catalog_url)
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")
if not isinstance(url, str) or not url:
continue
resolved = urljoin(catalog_url, url)
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 {}
sets.append(SkillSetInfo(
name=str(entry.get("displayName") or entry.get("identifier") or resolved),
description=str(entry.get("description") or ""),
index_url=resolved,
identifier=str(entry.get("identifier") or ""),
command=str(hermes_ext.get("command") or ""),
instruction=str(hermes_ext.get("instruction") or ""),
catalog_url=catalog_url,
))
elif etype == CATALOG_ENTRY_TYPE and _depth < 1:
try:
sets.extend(discover_skill_sets(resolved, _depth=_depth + 1))
except SkillSetError as exc:
logger.warning("Skipping unreachable sub-catalog %s: %s", resolved, 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."""
data = _http_get_json(info.index_url)
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(info.index_url, 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
def _archive_format(url: str) -> str:
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} (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)
content = _http_get_bytes(member.url)
if content is None:
raise SkillSetError(f"{member.name}: could not download {member.url}")
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:
files = (
_extract_tar_gz(content)
if _archive_format(member.url) == "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,
},
)