diff --git a/hermes_cli/skills_hub.py b/hermes_cli/skills_hub.py index 5d39b5202f4..5598e6d2b6b 100644 --- a/hermes_cli/skills_hub.py +++ b/hermes_cli/skills_hub.py @@ -550,7 +550,14 @@ def do_install(identifier: str, category: str = "", force: bool = False, # Scan c.print("[bold]Running security scan...[/]") - scan_source = getattr(bundle, "identifier", "") or getattr(meta, "identifier", "") or identifier + if bundle.source == "official": + scan_source = "official" + else: + scan_source = ( + getattr(bundle, "identifier", "") + or getattr(meta, "identifier", "") + or identifier + ) result = scan_skill(q_path, source=scan_source) c.print(format_scan_report(result)) diff --git a/tests/hermes_cli/test_skills_hub.py b/tests/hermes_cli/test_skills_hub.py index 1eca264b12c..7b262a75a76 100644 --- a/tests/hermes_cli/test_skills_hub.py +++ b/tests/hermes_cli/test_skills_hub.py @@ -286,7 +286,6 @@ def test_do_install_scans_with_resolved_identifier(monkeypatch, tmp_path, hub_en "trust_level": "trusted", "metadata": {}, })() - q_path = tmp_path / "skills" / ".hub" / "quarantine" / "frontend-design" q_path.mkdir(parents=True) (q_path / "SKILL.md").write_text("# Frontend Design") @@ -318,6 +317,60 @@ def test_do_install_scans_with_resolved_identifier(monkeypatch, tmp_path, hub_en assert scanned["source"] == canonical_identifier +def test_do_install_scans_official_bundles_with_source_provenance( + monkeypatch, tmp_path, hub_env +): + import tools.skills_guard as guard + import tools.skills_hub as hub + + class _OfficialSource: + def inspect(self, identifier): + return type("Meta", (), { + "extra": {}, + "identifier": "official/agent/prunus-gaia", + })() + + def fetch(self, identifier): + return type("Bundle", (), { + "name": "prunus-gaia", + "files": {"SKILL.md": "# Prunus Gaia"}, + "source": "official", + "identifier": "official/agent/prunus-gaia", + "trust_level": "builtin", + "metadata": {}, + })() + + q_path = tmp_path / "skills" / ".hub" / "quarantine" / "prunus-gaia" + q_path.mkdir(parents=True) + (q_path / "SKILL.md").write_text("# Prunus Gaia") + + scanned = {} + + def _scan_skill(skill_path, source="community"): + scanned["source"] = source + return guard.ScanResult( + skill_name="prunus-gaia", + source=source, + trust_level="builtin", + verdict="safe", + ) + + monkeypatch.setattr(hub, "ensure_hub_dirs", lambda: None) + monkeypatch.setattr(hub, "create_source_router", lambda auth: [_OfficialSource()]) + monkeypatch.setattr(hub, "quarantine_bundle", lambda bundle: q_path) + monkeypatch.setattr(hub, "HubLockFile", lambda: type("Lock", (), {"get_installed": lambda self, name: None})()) + monkeypatch.setattr(guard, "scan_skill", _scan_skill) + monkeypatch.setattr(guard, "format_scan_report", lambda result: "scan ok") + monkeypatch.setattr(guard, "should_allow_install", lambda result, force=False: (False, "stop after scan")) + + sink = StringIO() + console = Console(file=sink, force_terminal=False, color_system=None) + + do_install("official/agent/prunus-gaia", console=console, skip_confirm=True) + + assert scanned["source"] == "official" + + # --------------------------------------------------------------------------- # UrlSource-specific install paths: --name override, interactive prompts, # non-interactive error, existing-category scan. diff --git a/tests/tools/test_skills_guard.py b/tests/tools/test_skills_guard.py index e2cc1c84e79..2ac1af808bd 100644 --- a/tests/tools/test_skills_guard.py +++ b/tests/tools/test_skills_guard.py @@ -46,15 +46,23 @@ from tools.skills_guard import ( class TestResolveTrustLevel: - def test_official_sources_resolve_to_builtin(self): + def test_official_source_provenance_resolves_to_builtin(self): assert _resolve_trust_level("official") == "builtin" - assert _resolve_trust_level("official/email/agentmail") == "builtin" def test_trusted_repos(self): assert _resolve_trust_level("openai/skills") == "trusted" assert _resolve_trust_level("anthropics/skills") == "trusted" assert _resolve_trust_level("openai/skills/some-skill") == "trusted" + def test_trusted_repo_sibling_prefixes_are_not_trusted(self): + assert _resolve_trust_level("openai/skills-evil") == "community" + assert _resolve_trust_level("anthropics/skills-foo/frontend-design") == "community" + assert _resolve_trust_level("huggingface/skills-bar/some-skill") == "community" + + def test_official_github_namespace_does_not_resolve_to_builtin(self): + assert _resolve_trust_level("official/attacker-skill") == "community" + assert _resolve_trust_level("official/agent/evil-skill") == "community" + def test_skills_sh_wrapped_trusted_repos(self): assert _resolve_trust_level("skills-sh/openai/skills/skill-creator") == "trusted" assert _resolve_trust_level("skills-sh/anthropics/skills/frontend-design") == "trusted" diff --git a/tools/skills_guard.py b/tools/skills_guard.py index 28d29daa5c6..f1bced5dd5f 100644 --- a/tools/skills_guard.py +++ b/tools/skills_guard.py @@ -917,12 +917,14 @@ def _resolve_trust_level(source: str) -> str: # Agent-created skills get their own permissive trust level if normalized_source == "agent-created": return "agent-created" - # Official optional skills shipped with the repo - if normalized_source.startswith("official/") or normalized_source == "official": + # Official optional skills must be identified by source provenance, not by + # user-controlled GitHub identifiers such as "official/". + if normalized_source == "official": return "builtin" - # Check if source matches any trusted repo + # Check if source matches any trusted repo exactly, or a skill path inside + # that repo. Do not trust sibling repositories that merely share a prefix. for trusted in TRUSTED_REPOS: - if normalized_source.startswith(trusted) or normalized_source == trusted: + if normalized_source == trusted or normalized_source.startswith(f"{trusted}/"): return "trusted" return "community"