GitHub skill install missing bundle files fix
NousResearch/hermes-agent · cluster 35125 · skills
Data as of
2 open items. Fix merged in #63051. Check it covers your case.
The short answer
- Known?
- Yes. 7 related items grouped here, 0 issues already closed.
- Fix event
- A fix was merged to main in PR #63051. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
unresolved. That is separate from the fix event. - Still open
- 2 issues and 0 PRs.
Next step: read #63051, then check whether your report is one of the 2 still open.
Read #63051Pick up a Lab taskA bug where installing skills from GitHub URLs only downloaded SKILL.md while omitting reference files, templates, and scripts. Multiple fixes address complete bundle downloading and scan provenance for GitHub-sourced skills.
Open issues2
Open PRs0
Fixes on record
-
#63051 fix(skills): install complete bundles with bound scan provenance
files:
hermes_cli/skills_hub.py,tests/tools/test_skill_bundle_provenance.py,tools/skills_guard.py,tools/skills_hub.py,website/docs/guides/work-with-skills.md,website/docs/reference/cli-commands.md,website/docs/user-guide/features/skills.md,website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/guides/work-with-skills.md,website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/cli-commands.md,website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/skills.mdShow the diff
-
hermes_cli/skills_hub.py@@ -516,7 +516,7 @@ def do_install(identifier: str, category: str = "", force: bool = False, GitHubAuth, create_source_router, ensure_hub_dirs, quarantine_bundle, install_from_quarantine, HubLockFile, ) - from tools.skills_guard import scan_skill, should_allow_install, format_scan_report + from tools.skills_guard import scan_skill_cached, should_allow_install, format_scan_report c = console or _console ensure_hub_dirs() @@ -648,8 +648,24 @@ def do_install(identifier: str, category: str = "", force: bool = False, or getattr(meta, "identifier", "") or identifier ) - result = scan_skill(q_path, source=scan_source) + from tools.skills_hub import HUB_DIR, source_url_for_bundle + result, scan_provenance = scan_skill_cached( + q_path, + source=scan_source, + source_url=source_url_for_bundle(bundle), + cache_dir=HUB_DIR / "scan-cache", + ) c.print(format_scan_report(result)) + freshness = "fresh" if scan_provenance["fresh"] else "cached" + c.print( + f"[dim]Scan provenance: {freshness}; scanner " + f"{scan_provenance['scanner_version']}; hash {scan_provenance['bundle_hash']}[/]" + ) + rules = ", ".join(scan_provenance["rules"]) or "none" + c.print( + f"[dim]Source: {scan_provenance['source_url']}; scanned " + f"{scan_provenance['scanned_at']}; rules: {rules}[/]" + ) # Check install policy allowed, reason = should_allow_install(result, force=force) -
tests/tools/test_skill_bundle_provenance.py@@ -0,0 +1,260 @@ +"""Multi-file third-party skill bundles and scanner provenance (#60598).""" + +import json +import subprocess +import threading +from functools import partial +from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer +from io import StringIO +from pathlib import Path + +import pytest +from rich.console import Console + +from tools.skills_guard import SCANNER_VERSION, scan_skill_cached +from tools.skills_hub import GitHubAuth, GitHubSource, HubLockFile, SkillBundle, UrlSource + + +SKILL_MD = """--- +name: demo-bundle +description: A multi-file test skill. +--- +# Demo +Read [the guide](references/guide.md#usage), use `templates/report.md?raw=1`, and run +`scripts/run.py`. See `examples/endpoint-inventory.md`. The repository also +contains assets/logo.png. +""" + + +class _QuietHandler(SimpleHTTPRequestHandler): + def log_message(self, *_args): + pass + + [redacted:email] +def served_repo(tmp_path): + repo = tmp_path / "upstream" + repo.mkdir() + (repo / "SKILL.md").write_text(SKILL_MD) + for rel, content in { + "references/guide.md": "safe guide\n", + "templates/report.md": "report\n", + "scripts/run.py": "print('ok')\n", + "assets/logo.png": b"\x89PNG\r\n\x1a\n\x00\xff", + "examples/endpoint-inventory.md": "example\n", + "examples/not-installed.md": "must not be copied\n", + "README.md": "must not be copied\n", + }.items(): + path = repo / rel + path.parent.mkdir(parents=True, exist_ok=True) + if isinstance(content, bytes): + path.write_bytes(content) + else: + path.write_text(content) + subprocess.run(["git", "init", "-q"], cwd=repo, check=True) + subprocess.run(["git", "add", "."], cwd=repo, check=True) + subprocess.run( + ["git", "-c", "user.name=Test", "-c", "user.email=[redacted:email]", "commit", "-qm", "fixture"], + cwd=repo, + check=True, + ) + + server = ThreadingHTTPServer( + ("127.0.0.1", 0), partial(_QuietHandler, directory=str(repo)) + ) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + yield repo, f"http://127.0.0.1:{server.server_port}/SKILL.md" + finally: + server.shutdown() + thread.join() + + +def test_url_source_fetches_only_referenced_allowed_support_directories(served_repo, monkeypatch): + _repo, url = served_repo + monkeypatch.setattr("tools.skills_hub.is_safe_url", lambda _url: True) + monkeypatch.setattr("tools.skills_hub.check_website_access", lambda _url: None) + + bundle = UrlSource().fetch(url) + + assert bundle is not None + assert set(bundle.files) == { + "SKILL.md", + "references/guide.md", + "templates/report.md", + "scripts/run.py", + "assets/logo.png", + "examples/endpoint-inventory.md", + } + assert bundle.files["assets/logo.png"] == b"\x89PNG\r\n\x1a\n\x00\xff" + assert "examples/not-installed.md" not in bundle.files + assert bundle.metadata["source_url"] == url + + +def test_url_source_rejects_traversal_reference(monkeypatch): + source = UrlSource() + skill = "---\nname: bad\ndescription: bad\n---\n[bad](references/../../secret.txt)\n" + monkeypatch.setattr(source, "_fetch_text", lambda _url: skill) + + assert source.fetch("https://example.com/bad/SKILL.md") is None + + +def test_github_source_rejects_symlink_in_referenced_directory(monkeypatch): + source = GitHubSource(GitHubAuth()) + monkeypatch.setattr(source, "_fetch_file_content", lambda _repo, path: SKILL_MD if path.endswith("SKILL.md") else "x") + source._tree_cache["owner/repo"] = ( + "main", + [ + {"path": "skill/SKILL.md", "type": "blob", "mode": "100644"}, + {"path": "skill/references/guide.md", "type": "blob", "mode": "120000"}, + ], + ) + + assert source.fetch("owner/repo/skill") is None + + +def test_github_source_fetches_only_exact_references_and_records_tree_revision(monkeypatch): + source = GitHubSource(GitHubAuth()) + skill = "---\nname: demo\ndescription: demo\n---\n[guide](references/guide.md)\n" + fetched = [] + monkeypatch.setattr( + source, + "_fetch_file_content", + lambda _repo, path: skill if path.endswith("SKILL.md") else None, + ) + + def _fetch_bytes(_repo, path): + fetched.append(path) + return b"guide" + + monkeypatch.setattr(source, "_fetch_file_bytes", _fetch_bytes, raising=False) + source._tree_cache["owner/repo"] = ( + "develop", + [ + {"path": "skill/SKILL.md", "type": "blob", "mode": "100644"}, + {"path": "skill/references/guide.md", "type": "blob", "mode": "100644"}, + {"path": "skill/references/unreferenced.md", "type": "blob", "mode": "100644"}, + ], + ) + source._tree_revisions = {"owner/repo": "deadbeef"} + + bundle = source.fetch("owner/repo/skill") + + assert bundle is not None + assert fetched == ["skill/references/guide.md"] + assert bundle.files["references/guide.md"] == b"guide" + assert bundle.metadata["source_url"] == "https://github.com/owner/repo/tree/deadbeef/skill" + assert bundle.metadata["source_revision"] == "deadbeef" + + +def test_scan_cache_records_full_provenance_and_hash_change_forces_rescan(tmp_path): + skill = tmp_path / "skill" + skill.mkdir() + (skill / "SKILL.md").write_text("---\nname: skill\ndescription: test\n---\n# safe\n") + cache = tmp_path / "scan-cache" + + first, first_provenance = scan_skill_cached( + skill, source="owner/repo/skill", source_url="https://github.com/owner/repo", cache_dir=cache + ) + second, second_provenance = scan_skill_cached( + skill, source="owner/repo/skill", source_url="https://github.com/owner/repo", cache_dir=cache + ) + (skill / "SKILL.md").write_text("---\nname: skill\ndescription: changed\n---\n# safe\n") + third, third_provenance = scan_skill_cached( + skill, source="owner/repo/skill", source_url="https://github.com/owner/repo", cache_dir=cache + ) + + assert first.verdict == second.verdict == third.verdict == "safe" + assert first_provenance["fresh"] is True + assert second_provenance["fresh"] is False + assert third_provenance["fresh"] is True + assert first_provenance["bundle_hash"].startswith("sha256:") + assert len(first_provenance["bundle_hash"].split(":", 1)[1]) == 64 + assert third_provenance["bundle_hash"] != first_provenance["bundle_hash"] + assert first_provenance["scanner_version"] == SCANNER_VERSION + assert first_provenance["source_url"] == "https://github.com/owner/repo" + assert isinstance(first_provenance["findings"], list) + assert isinstance(first_provenance["rules"], list) + assert first_provenance["scanned_at"] + + +def test_scan_cache_never_reuses_provenance_across_sources(tmp_path): + skill = tmp_path / "skill" + skill.mkdir() + (skill / "SKILL.md").write_text("---\nname: skill\ndescription: test\n---\n") + cache = tmp_path / "scan-cache" + + _first, first = scan_skill_cached( + skill, source="community", source_url="https://one.example/SKILL.md", cache_dir=cache + ) + _second, second = scan_skill_cached( + skill, source="community", source_url="https://two.example/SKILL.md", cache_dir=cache + ) + + assert first["fresh"] is True + assert second["fresh"] is True + assert second["source_url"] == "https://two.example/SKILL.md" + + +def test_lock_file_persists_scan_provenance(tmp_path): + lock = HubLockFile(tmp_path / "lock.json") + provenance = { + "source_url": "https://example.com/SKILL.md", + "bundle_hash": "sha256:" + "a" * 64, + "scanner_version": SCANNER_VERSION, + "findings": [], + "rules": [], + "scanned_at": "2026-07-09T00:00:00+00:00", + "fresh": True, + } + lock.record_install( + name="demo", source="url", identifier="https://example.com/SKILL.md", + trust_level="community", scan_verdict="safe", skill_hash="sha256:legacy", + install_path="demo", files=["SKILL.md"], scan_provenance=provenance, + ) + + assert lock.get_installed("demo")["scan_provenance"] == provenance + + +def test_real_temp_repo_and_home_install_e2e(served_repo, monkeypatch, tmp_path): + from hermes_cli.skills_hub import do_install + import tools.skills_hub as hub + + _repo, url = served_repo + home = tmp_path / "home" + monkeypatch.setenv("HERMES_HOME", str(home)) + monkeypatch.setattr("tools.skills_hub.is_safe_url", lambda _url: True) + monkeypatch.setattr("tools.skills_hub.check_website_access", lambda _url: None) + monkeypatch.setattr(hub, "create_source_router", lambda auth=None: [UrlSource()]) + + sink = StringIO() + do_install(url, console=Console(file=sink, force_terminal=False), skip_confirm=True) + + installed = home / "skills" / "demo-bundle" + assert (installed / "references" / "guide.md").read_text() == "safe guide\n" + assert (installed / "templates" / "report.md").is_file() + assert (installed / "scripts" / "run.py").is_file() + assert (installed / "examples" / "endpoint-inventory.md").is_file() + assert not (installed / "examples" / "not-installed.md").exists() + assert (installed / "assets" / "logo.png").read_bytes() == b"\x89PNG\r\n\x1a\n\x00\xff" + entry = json.loads((home / "skills" / ".hub" / "lock.json").read_text())["installed"]["demo-bundle"] + assert entry["scan_provenance"]["source_url"] == url + assert entry["scan_provenance"]["fresh"] is True + assert "Scan provenance: fresh" in sink.getvalue() + + +def test_bundled_optional_source_still_includes_support_files(tmp_path, monkeypatch): + from tools.skills_hub import OptionalSkillSource + + root = tmp_path / "optional-skills" + skill = root / "category" / "official-demo" + (skill / "references").mkdir(parents=True) + (skill / "SKILL.md").write_text("---\nname: official-demo\ndescription: demo\n---\n") + (skill / "references" / "all.md").write_text("all") + source = OptionalSkillSource() + source._optional_dir = root + + bundle = source.fetch("official/category/official-demo") + assert bundle is not None + assert set(bundle.files) == {"SKILL.md", "references/all.md"} -
tools/skills_guard.py@@ -25,12 +25,16 @@ import re import fnmatch import hashlib +import json from dataclasses import dataclass, field from datetime import datetime, timezone from pathlib import Path from typing import List, Tuple +SCANNER_VERSION = "skills-guard-v1" + + # --------------------------------------------------------------------------- @@ -87,6 +91,7 @@ class ScanResult: findings: List[Finding] = field(default_factory=list) scanned_at: str = "" summary: str = "" + scan_provenance: dict = field(default_factory=dict) # --------------------------------------------------------------------------- @@ -683,6 +688,81 @@ def scan_skill(skill_path: Path, source: str = "community") -> ScanResult: ) +def _content_digest(skill_path: Path) -> str: + """Canonical SHA-256 over relative paths and exact file bytes.""" + h = hashlib.sha256() + if skill_path.is_dir(): + for file_path in sorted(skill_path.rglob("*")): + if file_path.is_file(): + rel = file_path.relative_to(skill_path).as_posix() + h.update(rel.encode("utf-8") + b"\x00") + h.update(file_path.read_bytes()) + else: + h.update(skill_path.read_bytes()) + return h.hexdigest() + + +def full_content_hash(skill_path: Path) -> str: + """Full canonical digest used to bind scanner attestations.""" + return f"sha256:{_content_digest(skill_path)}" + + +def _finding_dict(finding: Finding) -> dict: + return {key: getattr(finding, key) for key in ( + "pattern_id", "severity", "category", "file", "line", "match", "description" + )} + + +def scan_skill_cached( + skill_path: Path, + source: str = "community", + *, + source_url: str = "", + cache_dir: Path | None = None, +) -> Tuple[ScanResult, dict]: + """Return a scan plus attestation, caching only exact current content.""" + bundle_hash = full_content_hash(skill_path) + cache_root = cache_dir or skill_path.parent / ".scan-cache" + source_identity = hashlib.sha256(f"{source}\0{source_url}".encode("utf-8")).hexdigest()[:16] + cache_file = cache_root / f"{bundle_hash.split(':', 1)[1]}-{source_identity}.json" + try: + cached = json.loads(cache_file.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + cached = None + if (isinstance(cached, dict) + and cached.get("bundle_hash") == bundle_hash + and cached.get("scanner_version") == SCANNER_VERSION + and cached.get("source") == source + and cached.get("source_url") == source_url): + result = ScanResult( + skill_name=skill_path.name, source=source, + trust_level=cached["trust_level"], verdict=cached["verdict"], + findings=[Finding(**item) for item in cached.get("findings", [])], + scanned_at=cached["scanned_at"], summary=cached.get("summary", ""), + ) + provenance = dict(cached) + provenance["fresh"] = False + result.scan_provenance = provenance + return result, provenance + + result = scan_skill(skill_path, source=source) + findings = [_finding_dict(item) for item in result.findings] + provenance = { + "source": source, "source_url": source_url, "bundle_hash": bundle_hash, + "scanner_version": SCANNER_VERSION, "verdict": result.verdict, + "trust_level": result.trust_level, "findings": findings, + "rules": sorted({item["pattern_id"] for item in findings}), + "scanned_at": result.scanned_at, "summary": result.summary, "fresh": True, + } + try: + cache_root.mkdir(parents=True, exist_ok=True) + cache_file.write_text(json.dumps(provenance, indent=2) + "\n", encoding="utf-8") + except OSError: + pass + result.scan_provenance = provenance + return result, provenance + + def should_allow_install(result: ScanResult, force: bool = False) -> Tuple[bool, str]: """ Determine whether a skill should be installed based on scan result and trust. @@ -774,20 +854,7 @@ def content_hash(skill_path: Path) -> str: one on an in-memory bundle), so any change to the hash shape MUST land in both places at once. """ - h = hashlib.sha256() - if skill_path.is_dir(): - for f in sorted(skill_path.rglob("*")): - if f.is_file(): - try: - rel = f.relative_to(skill_path).as_posix() - h.update(rel.encode("utf-8")) - h.update(b"\x00") - h.update(f.read_bytes()) - except OSError: - continue - elif skill_path.is_file(): - h.update(skill_path.read_bytes()) - return f"sha256:{h.hexdigest()[:16]}" + return f"sha256:{_content_digest(skill_path)[:16]}" # --------------------------------------------------------------------------- -
tools/skills_hub.py@@ -29,7 +29,7 @@ from hermes_cli._subprocess_compat import windows_hide_flags from agent.skill_utils import is_excluded_skill_path from typing import Any, Dict, List, Optional, Tuple, Union -from urllib.parse import urljoin, urlparse, urlunparse +from urllib.parse import unquote, urljoin, urlparse, urlsplit, urlunparse import httpx import yaml @@ -152,6 +152,46 @@ class SkillBundle: metadata: Dict[str, Any] = field(default_factory=dict) +_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)`\"'<>]*/)?\.\.(?:/|$)" +) + + +def _referenced_support_paths(skill_md: str) -> Optional[set[str]]: + """Extract safe referenced paths; return None on a traversal attempt.""" + 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): + raw = unquote(urlsplit(match.group(1).rstrip(".,;:")).path) + try: + safe = _validate_bundle_rel_path(raw) + except ValueError: + return None + if safe.split("/", 1)[0] in _ALLOWED_SUPPORT_DIRS: + paths.add(safe) + return paths + + +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 + + 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): @@ -535,6 +575,7 @@ def __init__(self, auth: GitHubAuth, extra_taps: Optional[List[Dict]] = None): # Per-instance cache: repo -> (default_branch, tree_entries) # Survives within a single search/install flow, avoiding redundant API calls. self._tree_cache: Dict[str, Tuple[str, List[dict]]] = {} + self._tree_revisions: Dict[str, str] = {} # Per-repo cache of the optional skills.sh.json grouping sidecar, # mapping skill_name -> human-readable grouping title. ``None`` means # "fetched, no sidecar"; a missing key means "not fetched yet". @@ -601,10 +642,41 @@ def fetch(self, identifier: str) -> Optional[SkillBundle]: repo = f"{parts[0]}/{parts[1]}" skill_path = parts[2] - files = self._download_directory(repo, skill_path) - if not files or "SKILL.md" not in files: + skill_md = self._fetch_file_content(repo, f"{skill_path.rstrip('/')}/SKILL.md") + if skill_md is None: + return None + referenced = _referenced_support_paths(skill_md) + if referenced is None: return None + files: Dict[str, Union[str, bytes]] = {"SKILL.md": skill_md} + tree = self._get_repo_tree(repo) + if tree is not None: + branch, entries = tree + prefix = f"{skill_path.rstrip('/')}/" + entries_by_path = {item.get("path", ""): item for item in entries} + for rel_path in sorted(referenced): + item_path = f"{prefix}{rel_path}" + item = entries_by_path.get(item_path) + if item is None: + logger.warning("Referenced skill support file is missing: %s", item_path) + return None + if item.get("type") != "blob" or item.get("mode") == "120000": + logger.warning("Rejected non-regular file in skill bundle: %s", item_path) + return None + content = self._fetch_file_bytes(repo, item_path) + if content is None: + return None + files[rel_path] = content + revision = self._tree_revisions.get(repo) or branch + else: + for rel_path in referenced: + content = self._fetch_file_bytes(repo, f"{skill_path.rstrip('/')}/{rel_path}") + if content is None: + return None + files[rel_path] = content + revision = "" + skill_name = skill_path.rstrip("/").split("/")[-1] trust = self.trust_level_for(identifier) @@ -614,6 +686,13 @@ def fetch(self, identifier: str) -> Optional[SkillBundle]: source="github", identifier=identifier, trust_level=trust, + metadata={ + "source_url": ( + f"https://github.com/{repo}/tree/{revision}/{skill_path}" + if revision else f"https://github.com/{repo}/{skill_path}" + ), + "source_revision": revision, + }, ) def inspect(self, identifier: str) -> Optional[SkillMeta]: @@ -752,6 +831,9 @@ def _get_repo_tree(self, repo: str) -> Optional[Tuple[str, List[dict]]]: return None entries = tree_data.get("tree", []) + revision = tree_data.get("sha") + if isinstance(revision, str) and revision: + self._tree_revisions[repo] = revision self._tree_cache[repo] = (default_branch, entries) return (default_branch, entries) @@ -968,14 +1050,24 @@ def _find_skill_in_repo_tree(self, repo: str, skill_name: str) -> Optional[str]: return None def _fetch_file_content(self, repo: str, path: str) -> Optional[str]: - """Fetch a single file's content from GitHub.""" + """Fetch a single text file from GitHub.""" + content = self._fetch_file_bytes(repo, path) + if content is None: + return None + try: + return content.decode("utf-8") + except UnicodeDecodeError: + return None + + def _fetch_file_bytes(self, repo: str, path: str) -> Optional[bytes]: + """Fetch exact file bytes from GitHub without text decoding.""" url = f"https://api.github.com/repos/{repo}/contents/{path}" resp = self._github_get( url, headers={**self.auth.get_headers(), "Accept": "application/vnd.github.v3.raw"}, ) if resp is not None and resp.status_code == 200: - return resp.text + return resp.content return None def _get_skillsh_groupings(self, repo: str) -> Optional[Dict[str, str]]: @@ -1318,12 +1410,12 @@ def _wrap_identifier(base_url: str, skill_name: str) -> str: # --------------------------------------------------------------------------- class UrlSource(SkillSource): - """Fetch a single-file SKILL.md skill directly from an HTTP(S) URL. + """Fetch SKILL.md plus explicitly referenced, allowlisted support files. The identifier IS the URL (e.g. ``https://example.com/path/SKILL.md``). - Only single-file skills are supported — multi-file skills with - ``references/`` or ``scripts/`` subfolders need a manifest we can't - discover from a bare URL. + Bare URLs cannot safely enumerate a repository, so only exact references + below references/templates/scripts/assets are fetched. Other repository + files are never copied. The skill name is read from the ``name:`` field in the SKILL.md YAML frontmatter (with a URL-slug fallback). Trust level is always @@ -1402,6 +1494,19 @@ def fetch(self, identifier: str) -> Optional[SkillBundle]: fm = GitHubSource._parse_frontmatter_quick(text) name = self._resolve_skill_name(fm, url) + referenced = _referenced_support_paths(text) + if referenced is None: + return None + files: Dict[str, Union[str, bytes]] = {"SKILL.md": text} + base_url = url.rsplit("/", 1)[0] + "/" + for rel_path in sorted(referenced): + support_url = urljoin(base_url, rel_path) + if urlparse(support_url).netloc != urlparse(url).netloc: + return None + content = self._fetch_bytes(support_url) + if content is None: + return None + files[rel_path] = content # When auto-resolution fails, return a bundle with an empty name and # ``awaiting_name=True`` in metadata. The install flow (``do_install``) @@ -1418,11 +1523,11 @@ def fetch(self, identifier: str) -> Optional[SkillBundle]: return SkillBundle( name=skill_name, - files={"SKILL.md": text}, + files=files, source="url", identifier=url, trust_level="community", - metadata={"url": url, "awaiting_name": not skill_name}, + metadata={"url": url, "source_url": url, "awaiting_name": not skill_name}, ) @staticmethod @@ -1432,6 +1537,13 @@ def _fetch_text(url: str) -> Optional[str]: return resp.text return None + @staticmethod + def _fetch_bytes(url: str) -> Optional[bytes]: + resp = _guarded_http_get(url, timeout=20) + if resp is not None and resp.status_code == 200: + return resp.content + return None + # Skill names must look like identifiers: lowercase letters/digits with # optional hyphens/underscores. Blocks dangerous (``../evil``) AND useless # (``SKILL``, ``README``, empty) candidates before they hit the disk. @@ -3304,6 +3416,7 @@ def record_install( install_path: str, files: List[str], metadata: Optional[Dict[str, Any]] = None, + scan_provenance: Optional[Dict[str, Any]] = None, ) -> None: # Validate both the skill name and the install path SHAPE before # writing into lock.json. A poisoned lock entry is the precondition @@ -3321,6 +3434,7 @@ def record_install( "install_path": safe_install_path, "files": files, "metadata": metadata or {}, + "scan_provenance": scan_provenance or {}, "installed_at": datetime.now(timezone.utc).isoformat(), "updated_at": datetime.now(timezone.utc).isoformat(), } @@ -3461,6 +3575,7 @@ def install_from_quarantine( category: str, bundle: SkillBundle, scan_result: ScanResult, + scan_provenance: Optional[Dict[str, Any]] = None, ) -> Path: """Move a scanned skill from quarantine into the skills directory.""" safe_skill_name = _validate_skill_name(skill_name) @@ -3529,6 +3644,7 @@ def install_from_quarantine( install_path=str(install_dir.relative_to(_skills_dir())), files=list(bundle.files.keys()), metadata=bundle.metadata, + scan_provenance=scan_provenance or getattr(scan_result, "scan_provenance", None), ) append_audit_log( -
website/docs/guides/work-with-skills.md@@ -95,7 +95,7 @@ hermes skills install official/research/arxiv # Install from the hub in a chat session /skills install official/creative/songwriting-and-ai-music -# Install a single-file SKILL.md directly from any HTTP(S) URL +# Install SKILL.md and its referenced support files from an HTTP(S) URL hermes skills install https://sharethis.chat/SKILL.md /skills install https://example.com/SKILL.md --name my-skill ``` -
website/docs/reference/cli-commands.md@@ -1065,7 +1065,7 @@ hermes skills inspect official/security/1password hermes skills inspect skills-sh/vercel-labs/json-render/json-render-react hermes skills install official/migration/openclaw-migration hermes skills install skills-sh/anthropics/skills/pdf --force -hermes skills install https://sharethis.chat/SKILL.md # Direct URL (single-file SKILL.md) +hermes skills install https://sharethis.chat/SKILL.md # Direct URL (+ referenced support files) hermes skills install https://example.com/SKILL.md --name my-skill # Override name when frontmatter has none hermes skills check hermes skills update @@ -1083,7 +1083,7 @@ Notes: - `--source skills-sh` searches the public `skills.sh` directory. - `--source well-known` lets you point Hermes at a site exposing `/.well-known/skills/index.json`. - `--source browse-sh` searches [browse.sh](https://browse.sh)'s catalog of 200+ site-specific browser-automation skills. Identifiers look like `browse-sh/airbnb.com/search-listings-ddgioa`. -- Passing an `http(s)://…/*.md` URL installs a single-file SKILL.md directly. When frontmatter has no `name:` and the URL slug isn't a valid identifier, an interactive terminal prompts for a name; non-interactive surfaces (`/skills install` inside the TUI, gateway platforms) require `--name <x>` instead. +- Passing an `http(s)://…/*.md` URL installs `SKILL.md` plus explicitly referenced files under `references/`, `templates/`, `scripts/`, `assets/`, and `examples/`. When frontmatter has no `name:` and the URL slug isn't a valid identifier, an interactive terminal prompts for a name; non-interactive surfaces (`/skills install` inside the TUI, gateway platforms) require `--name <x>` instead. ## `hermes bundles` -
website/docs/user-guide/features/skills.md@@ -290,6 +290,7 @@ See [Skill Settings](/user-guide/configuration#skill-settings) and [Creating Ski │ │ ├── references/ # Additional docs │ │ ├── templates/ # Output formats │ │ ├── scripts/ # Helper scripts callable from the skill +│ │ ├── examples/ # Referenced example outputs │ │ └── assets/ # Supplementary files │ └── vllm/ │ └── SKILL.md @@ -304,6 +305,13 @@ See [Skill Settings](/user-guide/configuration#skill-settings) and [Creating Ski └── .bundled_manifest # Tracks seeded bundled skills ``` +Third-party URL and GitHub installs include `SKILL.md` plus the exact local +files it references under `references/`, `templates/`, `scripts/`, `assets/`, +and `examples/`. Unreferenced repository files are not copied. Hermes scans the +complete quarantined bundle and records the source URL, exact content hash, +scanner version, findings, timestamp, and fresh-or-cached status in +`skills/.hub/lock.json`. + ## External Skill Directories If you maintain skills outside of Hermes — for example, a shared `~/.agents/skills/` directory used by multiple AI tools — you can tell Hermes to scan those directories too. @@ -517,7 +525,7 @@ hermes skills install openai/skills/k8s # Install with security scan hermes skills install official/security/1password hermes skills install skills-sh/vercel-labs/json-render/json-render-react --force hermes skills install well-known:https://mintlify.com/docs/.well-known/skills/mintlify -hermes skills install https://sharethis.chat/SKILL.md # Direct URL (single-file SKILL.md) +hermes skills install https://sharethis.chat/SKILL.md # Direct URL (+ referenced support files) hermes skills install https://example.com/SKILL.md --name my-skill # Override name when frontmatter has none hermes skills list --source hub # List hub-installed skills hermes skills check # Check installed hub skills for upstream updates @@ -538,7 +546,7 @@ hermes skills tap add myorg/skills-repo # Add a custom GitHub source | `official` | `official/security/1password` | Optional skills shipped with Hermes. | | `skills-sh` | `skills-sh/vercel-labs/agent-skills/vercel-react-best-practices` | Searchable via `hermes skills search <query> --source skills-sh`. Hermes resolves alias-style skills when the skills.sh slug differs from the repo folder. | | `well-known` | `well-known:https://mintlify.com/docs/.well-known/skills/mintlify` | Skills served directly from `/.well-known/skills/index.json` on a website. Search using the site or docs URL. | -| `url` | `https://sharethis.chat/SKILL.md` | Direct HTTP(S) URL to a single-file `SKILL.md`. Name resolution: frontmatter → URL slug → interactive prompt → `--name` flag. | +| `url` | `https://sharethis.chat/SKILL.md` | Direct HTTP(S) URL to `SKILL.md` plus explicitly referenced support files. Name resolution: frontmatter → URL slug → interactive prompt → `--name` flag. | | `github` | `openai/skills/k8s` | Direct GitHub repo/path installs and custom taps. | | `clawhub`, `lobehub`, `browse-sh` | Source-specific identifiers | Community or marketplace integrations. | @@ -670,11 +678,11 @@ Identifiers use the form `browse-sh/<hostname>/<task-id>` and match the slug exp #### 9. Direct URL (`url`) -Install a single-file `SKILL.md` directly from any HTTP(S) URL — useful when an author hosts a skill on their own site (no hub listing, no GitHub path to type). Hermes fetches the URL, parses the YAML frontmatter, security-scans it, and installs. +Install `SKILL.md` directly from any HTTP(S) URL — useful when an author hosts a skill on their own site (no hub listing, no GitHub path to type). Hermes also fetches explicitly referenced files under `references/`, `templates/`, `scripts/`, `assets/`, and `examples/`, then scans and installs the complete bundle. - Hermes source id: `url` - Identifier: the URL itself (no prefix needed) -- Scope: **single-file `SKILL.md`** only. Multi-file skills with `references/` or `scripts/` need a manifest and should be published via one of the other sources above. +- Scope: `SKILL.md` plus exact referenced support files in the allowlisted directories. Hermes does not enumerate or copy unrelated files from the host. ```bash hermes skills install https://sharethis.chat/SKILL.md -
website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/guides/work-with-skills.md@@ -95,7 +95,7 @@ hermes skills install official/research/arxiv # 在聊天会话中从 Hub 安装 /skills install official/creative/songwriting-and-ai-music -# 直接从任意 HTTP(S) URL 安装单文件 SKILL.md +# 从 HTTP(S) URL 安装 SKILL.md 及其引用的支持文件 hermes skills install https://sharethis.chat/SKILL.md /skills install https://example.com/SKILL.md --name my-skill ``` -
website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/cli-commands.md@@ -820,7 +820,7 @@ hermes skills inspect official/security/1password hermes skills inspect skills-sh/vercel-labs/json-render/json-render-react hermes skills install official/migration/openclaw-migration hermes skills install skills-sh/anthropics/skills/pdf --force -hermes skills install https://sharethis.chat/SKILL.md # 直接 URL(单文件 SKILL.md) +hermes skills install https://sharethis.chat/SKILL.md # 直接 URL(含引用的支持文件) hermes skills install https://example.com/SKILL.md --name my-skill # frontmatter 无名称时覆盖名称 hermes skills check hermes skills update @@ -835,7 +835,7 @@ hermes skills reset google-workspace --restore --yes - `--source skills-sh` 搜索公共 `skills.sh` 目录。 - `--source well-known` 允许你将 Hermes 指向暴露 `/.well-known/skills/index.json` 的站点。 - `--source browse-sh` 搜索 [browse.sh](https://browse.sh) 包含 200+ 站点特定浏览器自动化 skill 的目录。标识符形如 `browse-sh/airbnb.com/search-listings-ddgioa`。 -- 传入 `http(s)://…/*.md` URL 可直接安装单文件 SKILL.md。当 frontmatter 没有 `name:` 且 URL slug 不是有效标识符时,交互式终端会提示输入名称;非交互式界面(TUI 内的 `/skills install`、gateway 平台)需要改用 `--name <x>`。 +- 传入 `http(s)://…/*.md` URL 可安装 `SKILL.md`,以及其中明确引用且位于 `references/`、`templates/`、`scripts/`、`assets/` 和 `examples/` 下的文件。当 frontmatter 没有 `name:` 且 URL slug 不是有效标识符时,交互式终端会提示输入名称;非交互式界面(TUI 内的 `/skills install`、gateway 平台)需要改用 `--name <x>`。 ## `hermes bundles` -
website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/skills.md@@ -204,6 +204,7 @@ metadata: │ │ ├── references/ # Additional docs │ │ ├── templates/ # Output formats │ │ ├── scripts/ # Helper scripts callable from the skill +│ │ ├── examples/ # Referenced example outputs │ │ └── assets/ # Supplementary files │ └── vllm/ │ └── SKILL.md @@ -218,6 +219,8 @@ metadata: └── .bundled_manifest # Tracks seeded bundled skills ``` +通过第三方 URL 或 GitHub 安装时,Hermes 会安装 `SKILL.md`,以及其中明确引用且位于 `references/`、`templates/`、`scripts/`、`assets/` 和 `examples/` 下的文件。未引用的仓库文件不会被复制。Hermes 会扫描完整的隔离捆绑包,并在 `skills/.hub/lock.json` 中记录来源 URL、精确内容哈希、扫描器版本、发现项、时间戳,以及本次结果是新扫描还是缓存复用。 + ## 外部 Skill 目录 如果你在 Hermes 之外维护 skills——例如,供多个 AI 工具使用的共享 `~/.agents/skills/` 目录——你可以告诉 Hermes 也扫描这些目录。 @@ -388,7 +391,7 @@ hermes skills install openai/skills/k8s # Install with security scan hermes skills install official/security/1password hermes skills install skills-sh/vercel-labs/json-render/json-render-react --force hermes skills install well-known:https://mintlify.com/docs/.well-known/skills/mintlify -hermes skills install https://sharethis.chat/SKILL.md # Direct URL (single-file SKILL.md) +hermes skills install https://sharethis.chat/SKILL.md # 直接 URL(含引用的支持文件) hermes skills install https://example.com/SKILL.md --name my-skill # Override name when frontmatter has none hermes skills list --source hub # List hub-installed skills hermes skills check # Check installed hub skills for upstream updates @@ -409,7 +412,7 @@ hermes skills tap add myorg/skills-repo # Add a custom GitHub source | `official` | `official/security/1password` | Hermes 随附的可选 skills。 | | `skills-sh` | `skills-sh/vercel-labs/agent-skills/vercel-react-best-practices` | 可通过 `hermes skills search <query> --source skills-sh` 搜索。当 skills.sh slug 与仓库文件夹不同时,Hermes 会解析别名式 skills。 | | `well-known` | `well-known:https://mintlify.com/docs/.well-known/skills/mintlify` | 直接从网站的 `/.well-known/skills/index.json` 提供的 skills。使用站点或文档 URL 搜索。 | -| `url` | `https://sharethis.chat/SKILL.md` | 指向单文件 `SKILL.md` 的直接 HTTP(S) URL。名称解析顺序:frontmatter → URL slug → 交互式提示 → `--name` 标志。 | +| `url` | `https://sharethis.chat/SKILL.md` | 指向 `SKILL.md` 及其明确引用的支持文件的直接 HTTP(S) URL。名称解析顺序:frontmatter → URL slug → 交互式提示 → `--name` 标志。 | | `github` | `openai/skills/k8s` | 直接从 GitHub 仓库/路径安装以及基于 GitHub 的自定义 tap。 | | `clawhub`、`lobehub`、`browse-sh`、`claude-marketplace` | 来源特定标识符 | 社区或市场集成。 | @@ -523,11 +526,11 @@ hermes skills install browse-sh/airbnb.com/search-listings-ddgioa #### 9. 直接 URL(`url`) -直接从任何 HTTP(S) URL 安装单文件 `SKILL.md`——当作者在自己的站点上托管 skill 时非常有用(无 hub 列表,无需输入 GitHub 路径)。Hermes 获取 URL,解析 YAML frontmatter,进行安全扫描并安装。 +直接从任何 HTTP(S) URL 安装 `SKILL.md`——当作者在自己的站点上托管 skill 时非常有用(无 hub 列表,无需输入 GitHub 路径)。Hermes 还会获取其中明确引用且位于 `references/`、`templates/`、`scripts/`、`assets/` 和 `examples/` 下的文件,然后扫描并安装完整捆绑包。 - Hermes 来源 id:`url` - 标识符:URL 本身(无需前缀) -- 范围:**仅限单文件 `SKILL.md`**。包含 `references/` 或 `scripts/` 的多文件 skills 需要清单,应通过上述其他来源之一发布。 +- 范围:`SKILL.md` 加上允许目录中明确引用的支持文件。Hermes 不会枚举或复制托管站点上的其他文件。 ```bash hermes skills install https://sharethis.chat/SKILL.md
-
4 PRs closed without merging
From the close comments: merged via another change 1 · stale or abandoned 1 · other 2
-
#35173 fix: install full bundles from raw GitHub skill URLs · merged via another change →
51382ac,#63051 -
#35175 fix(skills): preserve support files for GitHub URL installs · other →
51382ac - #35177 fix(skills): route raw.githubusercontent.com URLs to GitHubSource for directory download · stale or abandoned
-
#61771 fix(skills): install complete bundles with scan provenance · other →
#63051
How this cluster was grouped
-
Link evidence (
partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text. - Members outside that structural core may belong to a different underlying problem.
-
Pairwise link density
0.26: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the LabAt a glance
- Subsystem
- skills
- Merged PRs
- #63051
- Closed issues
- 0
- Attempts
- 4 PRs closed without merging
- Fix link checked
- 12 Jul 2026