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

2open issues
0open PRs
7items grouped
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.

A 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

  • Issue#35125hermes skills install URL source only downloads SKILL.md, misses reference files (references/, templates/, scripts/)
  • Issue#60598Bug: community skill scanner verdict may be stale and third-party installs only copy SKILL.md

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #63051 fix(skills): install complete bundles with bound scan provenance

    merged to main

    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.md

    Show 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.00 means 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 Lab

At a glance

Subsystem
skills
Merged PRs
#63051
Closed issues
0
Attempts
4 PRs closed without merging
Fix link checked
12 Jul 2026

For your agent

The same facts, typed.