# Conflicts: # hermes_cli/env_loader.py # hermes_cli/urllib_security.py # tools/terminal_scope.py
341 lines
15 KiB
Python
341 lines
15 KiB
Python
"""Skills Hub data models, path validators, and SKILL.md helpers. Leaf module (no imports from
|
|
tools.skills_hub) so every source adapter module can import it at top level without cycles."""
|
|
|
|
import json
|
|
import logging
|
|
import re
|
|
from abc import ABC, abstractmethod
|
|
from dataclasses import dataclass, field
|
|
from pathlib import PurePosixPath
|
|
from typing import Any, Callable, Dict, Iterable, List, Optional, Union
|
|
from urllib.parse import unquote, urlsplit
|
|
|
|
import httpx
|
|
import hermes_yaml as yaml
|
|
|
|
logger = logging.getLogger("tools.skills_hub")
|
|
|
|
|
|
def hub():
|
|
"""``tools.skills_hub`` resolved at call time: its cache / HTTP / index helpers are the test-patch targets
|
|
(``patch("tools.skills_hub._read_index_cache")`` ...), so adapters look them up on every call, not at import."""
|
|
import tools.skills_hub as mod
|
|
return mod
|
|
|
|
|
|
@dataclass
|
|
class SkillMeta:
|
|
"""Minimal metadata returned by search results."""
|
|
name: str
|
|
description: str
|
|
source: str # "official", "github", "clawhub", "lobehub"
|
|
identifier: str # source-specific ID (e.g. "openai/skills/skill-creator")
|
|
trust_level: str # "builtin" | "trusted" | "community"
|
|
repo: Optional[str] = None
|
|
path: Optional[str] = None
|
|
tags: List[str] = field(default_factory=list)
|
|
extra: Dict[str, Any] = field(default_factory=dict)
|
|
|
|
|
|
@dataclass
|
|
class SkillBundle:
|
|
"""A downloaded skill ready for quarantine/scanning/installation."""
|
|
name: str
|
|
files: Dict[str, Union[str, bytes]] # relative_path -> file content
|
|
source: str
|
|
identifier: str
|
|
trust_level: str
|
|
metadata: Dict[str, Any] = field(default_factory=dict)
|
|
|
|
|
|
def _skill_meta_to_dict(meta: SkillMeta) -> dict:
|
|
return dict(vars(meta))
|
|
|
|
|
|
def _cached_metas(key: str) -> Optional[List[SkillMeta]]:
|
|
"""SkillMeta list from the shared index cache, or None on miss/expiry."""
|
|
cached = hub()._read_index_cache(key)
|
|
return None if cached is None else [SkillMeta(**item) for item in cached]
|
|
|
|
|
|
def _cache_metas(key: str, metas: List[SkillMeta]) -> None:
|
|
hub()._write_index_cache(key, [_skill_meta_to_dict(m) for m in metas])
|
|
|
|
|
|
def _memo_json(key: str, compute: Callable[[], Any], valid: Callable[[Any], bool] = lambda c: c is not None) -> Any:
|
|
"""Shared-index-cache memo: a cached value passing ``valid`` is returned as-is; otherwise ``compute()``
|
|
runs and a non-None result is written back."""
|
|
cached = hub()._read_index_cache(key)
|
|
if valid(cached):
|
|
return cached
|
|
data = compute()
|
|
if data is not None:
|
|
hub()._write_index_cache(key, data)
|
|
return data
|
|
|
|
|
|
def _get_json(url: str, *, timeout: int = 20, **kwargs) -> Optional[Any]:
|
|
"""Plain (unguarded) GET + JSON decode; None on non-200 or transport/decode error."""
|
|
try:
|
|
resp = hub()._skills_hub_http_get(url, timeout=timeout, **kwargs)
|
|
return resp.json() if resp.status_code == 200 else None
|
|
except (httpx.HTTPError, json.JSONDecodeError):
|
|
return None
|
|
|
|
|
|
def _get_text(url: str, *, timeout: int = 20, **kwargs) -> Optional[str]:
|
|
"""Plain (unguarded) GET; body text on 200, None on any other status or transport error."""
|
|
try:
|
|
resp = hub()._skills_hub_http_get(url, timeout=timeout, **kwargs)
|
|
except httpx.HTTPError:
|
|
return None
|
|
return resp.text if resp.status_code == 200 else None
|
|
|
|
|
|
def _matches_query(query_lower: str, *fields: Any) -> bool:
|
|
"""Case-insensitive substring match against joined fields (lists space-joined; empty query matches all)."""
|
|
parts = [" ".join(str(t) for t in f) if isinstance(f, list) else str(f) for f in fields]
|
|
return query_lower in " ".join(parts).lower()
|
|
|
|
|
|
def _first_matching(query_lower: str, items: Iterable[Any], fields_of: Callable[[Any], tuple],
|
|
to_meta: Callable[[Any], Optional[SkillMeta]], limit: int) -> List[SkillMeta]:
|
|
"""Substring-search ``items`` in order, converting hits with ``to_meta`` until ``limit``."""
|
|
results: List[SkillMeta] = []
|
|
for item in items:
|
|
if _matches_query(query_lower, *fields_of(item)) and (meta := to_meta(item)):
|
|
results.append(meta)
|
|
if len(results) >= limit:
|
|
break
|
|
return results
|
|
|
|
|
|
TRUST_RANK = {"builtin": 2, "trusted": 1, "community": 0}
|
|
|
|
|
|
def _dedupe_by_trust(results: Iterable[SkillMeta]) -> List[SkillMeta]:
|
|
"""Dedupe by identifier, keeping the higher-trust copy (first wins on ties). identifier is unique per
|
|
skill; name is not — two taps can publish same-named skills, and browse-sh reuses task names across sites."""
|
|
seen: Dict[str, SkillMeta] = {}
|
|
for r in results:
|
|
kept = seen.get(r.identifier)
|
|
if kept is None or TRUST_RANK.get(r.trust_level, 0) > TRUST_RANK.get(kept.trust_level, 0):
|
|
seen[r.identifier] = r
|
|
return list(seen.values())
|
|
|
|
|
|
class SkillSource(ABC):
|
|
"""Abstract base for all skill registry adapters. ``SOURCE_ID`` is the unique source id (e.g. 'github',
|
|
'clawhub'); ``TRUST_LEVEL`` the trust every identifier gets unless ``trust_level_for`` is overridden."""
|
|
|
|
SOURCE_ID: str = ""
|
|
TRUST_LEVEL: str = "community"
|
|
# Consecutive failed fetches of one catalog page/shard before a walk gives up as partial.
|
|
CATALOG_PAGE_RETRIES = 5
|
|
|
|
@abstractmethod
|
|
def search(self, query: str, limit: int = 10) -> List[SkillMeta]:
|
|
"""Search for skills matching a query string."""
|
|
|
|
@abstractmethod
|
|
def fetch(self, identifier: str) -> Optional[SkillBundle]:
|
|
"""Download a skill bundle by identifier."""
|
|
|
|
@abstractmethod
|
|
def inspect(self, identifier: str) -> Optional[SkillMeta]:
|
|
"""Fetch metadata for a skill without downloading all files."""
|
|
|
|
def source_id(self) -> str:
|
|
return self.SOURCE_ID
|
|
|
|
def trust_level_for(self, identifier: str) -> str:
|
|
return self.TRUST_LEVEL
|
|
|
|
def current_revision(self, identifier: str) -> str:
|
|
"""Upstream revision the skill would be fetched at; "" when the registry has no cheap
|
|
revision probe, which keeps update checks on the full-fetch path."""
|
|
return ""
|
|
|
|
|
|
class GuardedFetchMixin:
|
|
"""SSRF/policy-guarded GETs, routed through ``tools.skills_hub`` (test-patchable)."""
|
|
|
|
@staticmethod
|
|
def _fetch_text(url: str) -> Optional[str]:
|
|
resp = hub()._guarded_http_get(url, timeout=20)
|
|
return resp.text if resp is not None and resp.status_code == 200 else None
|
|
|
|
@staticmethod
|
|
def _fetch_bytes(url: str) -> Optional[bytes]:
|
|
resp = hub()._guarded_http_get(url, timeout=20)
|
|
return resp.content if resp is not None and resp.status_code == 200 else None
|
|
|
|
|
|
# --- SKILL.md frontmatter ---------------------------------------------------
|
|
def _parse_frontmatter(content: str) -> dict:
|
|
"""Parse YAML frontmatter from SKILL.md content ({} when absent/invalid)."""
|
|
content = content.lstrip("\ufeff") # tolerate UTF-8 BOM (Windows editors)
|
|
match = re.search(r'\n---\s*\n', content[3:]) if content.startswith("---") else None
|
|
if not match:
|
|
return {}
|
|
try:
|
|
parsed = yaml.safe_load(content[3:match.start() + 3])
|
|
return parsed if isinstance(parsed, dict) else {}
|
|
except yaml.YAMLError:
|
|
return {}
|
|
|
|
|
|
def _hermes_tags(fm: dict) -> Any:
|
|
"""``metadata.hermes.tags`` from parsed frontmatter, or ``[]`` (unvalidated type)."""
|
|
metadata = fm.get("metadata", {})
|
|
hermes_meta = metadata.get("hermes", {}) if isinstance(metadata, dict) else None
|
|
return hermes_meta.get("tags", []) if isinstance(hermes_meta, dict) else []
|
|
|
|
|
|
def source_url_for_bundle(bundle: SkillBundle) -> str:
|
|
"""Best available human-facing immutable-source provenance URL."""
|
|
explicit = bundle.metadata.get("source_url") or bundle.metadata.get("url")
|
|
if explicit:
|
|
return str(explicit)
|
|
if bundle.source == "github":
|
|
parts = bundle.identifier.split("/", 2)
|
|
if len(parts) >= 2:
|
|
suffix = f"/tree/main/{parts[2]}" if len(parts) == 3 else ""
|
|
return f"https://github.com/{parts[0]}/{parts[1]}{suffix}"
|
|
return bundle.identifier
|
|
|
|
|
|
# --- Bundle path validation -------------------------------------------------
|
|
def _normalize_bundle_path(path_value: str, *, field_name: str, allow_nested: bool) -> str:
|
|
"""Normalize and validate bundle-controlled paths before touching disk."""
|
|
if not isinstance(path_value, str):
|
|
raise ValueError(f"Unsafe {field_name}: expected a string")
|
|
raw = path_value.strip()
|
|
if not raw:
|
|
raise ValueError(f"Unsafe {field_name}: empty path")
|
|
normalized = raw.replace("\\", "/")
|
|
path = PurePosixPath(normalized)
|
|
parts = [part for part in path.parts if part not in {"", "."}]
|
|
# A colon in any component is rejected: on Windows it marks a drive (``C:foo``) or an NTFS Alternate Data
|
|
# Stream (``file.py:payload`` writes scanner-invisible bytes); ``/`` is the only legal separator once normalized.
|
|
if (normalized.startswith("/") or path.is_absolute() or not parts or any(part == ".." for part in parts)
|
|
or any(":" in part for part in parts) or (not allow_nested and len(parts) != 1)):
|
|
raise ValueError(f"Unsafe {field_name}: {path_value}")
|
|
return "/".join(parts)
|
|
|
|
|
|
def _validate_skill_name(name: str) -> str:
|
|
return _normalize_bundle_path(name, field_name="skill name", allow_nested=False)
|
|
|
|
|
|
def _validate_install_parent_path(category: str) -> str:
|
|
return _normalize_bundle_path(category, field_name="install parent path", allow_nested=True)
|
|
|
|
|
|
def _validate_bundle_rel_path(rel_path: str) -> str:
|
|
return _normalize_bundle_path(rel_path, field_name="bundle file path", allow_nested=True)
|
|
|
|
|
|
def _normalize_lock_install_path(install_path: str, skill_name: str) -> str:
|
|
"""Validate a lock-file ``install_path`` (the ``uninstall_skill`` rmtree target).
|
|
|
|
Must be relative, traversal-free, and end with ``<skill_name>`` — nested official skills legitimately
|
|
live at ``mlops/training/<skill_name>``; an empty/``"."``/absolute/mismatched entry could point rmtree
|
|
at the whole ``skills/`` tree or outside it.
|
|
"""
|
|
safe_skill_name = _validate_skill_name(skill_name)
|
|
normalized = _normalize_bundle_path(install_path, field_name="install path", allow_nested=True)
|
|
if normalized.split("/")[-1] != safe_skill_name:
|
|
raise ValueError(f"Unsafe install path: {install_path}")
|
|
return normalized
|
|
|
|
|
|
# --- Referenced support-file extraction from SKILL.md -----------------------
|
|
_ALLOWED_SUPPORT_DIRS = frozenset({"references", "templates", "scripts", "assets", "examples"})
|
|
_LOCAL_LINK_RE = re.compile(
|
|
r"(?:\]\(|`|(?:^|[\s\"']))((?:references|templates|scripts|assets|examples)/[^\s)`\"'<>]+)", re.MULTILINE)
|
|
_SUSPICIOUS_LOCAL_REF_RE = re.compile(
|
|
r"(?:references|templates|scripts|assets|examples)/(?:[^\s)`\"'<>]*/)?\.\.(?:/|$)")
|
|
_VALUELESS_QUERY_FLAG_RE = re.compile(r"(?:[A-Za-z0-9_~-]|%[0-9A-Fa-f]{2})+\Z")
|
|
# Same-directory links (``](./FILE.ext)`` / ``](FILE.ext)``): siblings of SKILL.md the document links
|
|
# explicitly (e.g. ./CONTEXT-FORMAT.md). Dropping them made the install "succeed" with unresolved links.
|
|
# The extension requirement keeps prose words out; support-dir links stay on _LOCAL_LINK_RE.
|
|
# Skills legitimately ship supporting docs next to SKILL.md instead of under a support directory (e.g.
|
|
# mattpocock/skills' domain-modeling links ./CONTEXT-FORMAT.md); dropping them made the install "succeed"
|
|
# while the bundle came out with unresolved links (#96310).
|
|
_SAMEDIR_LINK_RE = re.compile(r"\]\(([^)\s\"'<>]+)")
|
|
_SAMEDIR_NAME_RE = re.compile(r"^(?:\./)?[A-Za-z0-9][A-Za-z0-9._-]*$")
|
|
|
|
|
|
def _query_is_concrete(query: str) -> bool:
|
|
"""Whether a URL query is real URL syntax rather than glob prose.
|
|
|
|
A non-empty ``key=value`` part is always concrete. Valueless flags are accepted only when RFC 3986
|
|
unreserved-token shaped (percent escapes ok); ``.``, brackets and extra ``?`` are excluded because
|
|
``?x.md`` / ``?.md`` is indistinguishable from a single-char glob finishing a filename in prose.
|
|
"""
|
|
return all(("=" in part and bool(part.split("=", 1)[0])) or bool(_VALUELESS_QUERY_FLAG_RE.fullmatch(part))
|
|
for part in query.split("&"))
|
|
|
|
|
|
def _referenced_support_paths(skill_md: str) -> Optional[set[str]]:
|
|
"""Extract safe referenced paths; return None on a traversal attempt (fail closed)."""
|
|
normalized = skill_md.replace("\\", "/")
|
|
if _SUSPICIOUS_LOCAL_REF_RE.search(normalized):
|
|
return None
|
|
paths: set[str] = set()
|
|
for match in _LOCAL_LINK_RE.finditer(normalized):
|
|
candidate = match.group(1).rstrip(".,;:")
|
|
parsed = urlsplit(candidate)
|
|
raw = unquote(parsed.path)
|
|
if (candidate.endswith("?") or any(char in raw for char in "*?[]")
|
|
or (parsed.query and not _query_is_concrete(parsed.query))):
|
|
continue
|
|
try:
|
|
safe = _validate_bundle_rel_path(raw)
|
|
except ValueError:
|
|
return None
|
|
if safe.split("/", 1)[0] in _ALLOWED_SUPPORT_DIRS:
|
|
# Prose placeholders (``references/type-<name>.md``, truncated at ``<`` to
|
|
# ``references/type-``) are instructions, not files: a basename ending in a
|
|
# separator is skipped. No extension requirement — ``references/LICENSE`` is legitimate.
|
|
base = safe.rsplit("/", 1)[-1]
|
|
if re.search(r"[*?<>]", safe) or not re.search(r"[A-Za-z0-9]$", base):
|
|
continue
|
|
paths.add(safe)
|
|
for match in _SAMEDIR_LINK_RE.finditer(normalized):
|
|
raw = match.group(1).rstrip(".,;:")
|
|
# Canonicalize like the support-dir branch (drop query/fragment, percent-decode), strip leading ``./``.
|
|
name = unquote(urlsplit(raw).path)
|
|
name = name[2:] if name.startswith("./") else name
|
|
# External URLs, anchors, mailto and site-absolute targets are not same-directory file links.
|
|
if not name or "://" in raw or raw.startswith(("mailto:", "#", "/")):
|
|
continue
|
|
if name.startswith(".."):
|
|
# A repo-relative link to a doc outside the skill directory (``../../tools/REGISTRY.md``
|
|
# in a multi-skill repo) is prose, never a bundle path: nothing is fetched or written for
|
|
# it, so refusing the whole bundle protected nothing and made every skill that links a
|
|
# sibling doc uninstallable with a misleading "files no longer exist upstream" (#115171).
|
|
# The link is left dangling in the installed copy, like an absent support file.
|
|
logger.warning("SKILL.md links outside the skill directory; installing without it: %s", raw)
|
|
continue
|
|
# Only unambiguous file links: an extension, no internal slash, never SKILL.md itself (casefolded —
|
|
# a ``skill.md`` entry would collide with the bundle root on macOS/Windows; skipped, not merged).
|
|
if ("/" in name or name.casefold() == "skill.md" or "." not in name.lstrip(".")
|
|
or not _SAMEDIR_NAME_RE.match(name)):
|
|
continue
|
|
try:
|
|
safe = _validate_bundle_rel_path(name)
|
|
except ValueError:
|
|
return None
|
|
paths.add(safe)
|
|
# Case-folded collisions among accepted same-dir names (``A.md`` + ``a.md``)
|
|
# would collide on install — drop the pair rather than guess.
|
|
folded: dict[str, list[str]] = {}
|
|
for p in sorted(paths):
|
|
folded.setdefault(p.casefold(), []).append(p)
|
|
for group in folded.values():
|
|
if len(group) > 1:
|
|
paths.difference_update(group)
|
|
return paths
|