b4fbd6fe9f
Deploy Site / deploy-vercel (push) Has been skipped
Deploy Site / deploy-docs (push) Has been skipped
Build Skills Index / build-index (push) Has been skipped
CI / Deny unrelated histories (push) Has been skipped
CI / Detect affected areas (push) Successful in 27m35s
CI / OSV scan (push) Failing after 4s
CI / Build&Test Docker image (push) Successful in 9s
CI / Supply-chain scan (push) Has been skipped
CI / Lint Docker scripts (push) Failing after 5m13s
CI / Check contributors (push) Failing after 12m8s
CI / Docs Site (push) Failing after 12m8s
CI / TypeScript (push) Failing after 12m8s
CI / Python lints (push) Failing after 12m9s
CI / Python tests (push) Failing after 12m9s
CI / Check uv.lock (push) Failing after 23m22s
CI / CI timing report (push) Has been cancelled
Build Skills Index / trigger-deploy (push) Has been cancelled
CI / All required checks pass (push) Has been cancelled
261 lines
9.9 KiB
Python
261 lines
9.9 KiB
Python
"""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
|
|
|
|
|
|
@pytest.fixture
|
|
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=test@example.com", "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"}
|