diff --git a/hermes_cli/skills_hub.py b/hermes_cli/skills_hub.py index 2d0055dae..32a0bab1b 100644 --- a/hermes_cli/skills_hub.py +++ b/hermes_cli/skills_hub.py @@ -57,8 +57,9 @@ def _resolve_short_name(name: str, sources, console: Console) -> str: table.add_column("Trust", style="dim") table.add_column("Identifier", style="bold cyan") for r in exact: - trust_style = {"trusted": "green", "community": "yellow"}.get(r.trust_level, "dim") - table.add_row(r.source, f"[{trust_style}]{r.trust_level}[/]", r.identifier) + trust_style = {"builtin": "bright_cyan", "trusted": "green", "community": "yellow"}.get(r.trust_level, "dim") + trust_label = "official" if r.source == "official" else r.trust_level + table.add_row(r.source, f"[{trust_style}]{trust_label}[/]", r.identifier) c.print(table) c.print("[bold]Use the full identifier to install a specific one.[/]\n") return "" @@ -124,6 +125,9 @@ def do_browse(page: int = 1, page_size: int = 20, source: str = "all", GitHubAuth, create_source_router, OptionalSkillSource, SkillMeta, ) + # Clamp page_size to safe range + page_size = max(1, min(page_size, 100)) + c = console or _console auth = GitHubAuth() @@ -375,13 +379,14 @@ def do_inspect(identifier: str, console: Optional[Console] = None) -> None: break c.print() - trust_style = {"trusted": "green", "community": "yellow"}.get(meta.trust_level, "dim") + trust_style = {"builtin": "bright_cyan", "trusted": "green", "community": "yellow"}.get(meta.trust_level, "dim") + trust_label = "official" if meta.source == "official" else meta.trust_level info_lines = [ f"[bold]Name:[/] {meta.name}", f"[bold]Description:[/] {meta.description}", f"[bold]Source:[/] {meta.source}", - f"[bold]Trust:[/] [{trust_style}]{meta.trust_level}[/]", + f"[bold]Trust:[/] [{trust_style}]{trust_label}[/]", f"[bold]Identifier:[/] {meta.identifier}", ] if meta.tags: diff --git a/optional-skills/DESCRIPTION.md b/optional-skills/DESCRIPTION.md index 3e3b96610..4f0675311 100644 --- a/optional-skills/DESCRIPTION.md +++ b/optional-skills/DESCRIPTION.md @@ -6,8 +6,10 @@ These skills ship with the hermes-agent repository but are not copied to `~/.hermes/skills/` during setup. They are discoverable via the Skills Hub: ```bash -hermes skills search # finds optional skills labeled "official" -hermes skills install # copies to ~/.hermes/skills/ and activates +hermes skills browse # browse all skills, official shown first +hermes skills browse --source official # browse only official optional skills +hermes skills search # finds optional skills labeled "official" +hermes skills install # copies to ~/.hermes/skills/ and activates ``` ## Why optional? diff --git a/tools/skills_hub.py b/tools/skills_hub.py index 27b233525..b4e66746e 100644 --- a/tools/skills_hub.py +++ b/tools/skills_hub.py @@ -63,7 +63,7 @@ class SkillMeta: """Minimal metadata returned by search results.""" name: str description: str - source: str # "github", "clawhub", "claude-marketplace", "lobehub" + source: str # "official", "github", "clawhub", "claude-marketplace", "lobehub" identifier: str # source-specific ID (e.g. "openai/skills/skill-creator") trust_level: str # "builtin" | "trusted" | "community" repo: Optional[str] = None @@ -987,12 +987,22 @@ class OptionalSkillSource(SkillSource): rel = identifier.split("/", 1)[-1] if identifier.startswith("official/") else identifier skill_dir = self._optional_dir / rel - if not skill_dir.is_dir(): + # Guard against path traversal (e.g. "official/../../etc") + try: + resolved = skill_dir.resolve() + if not str(resolved).startswith(str(self._optional_dir.resolve())): + return None + except (OSError, ValueError): + return None + + if not resolved.is_dir(): # Try searching by skill name only (last segment) skill_name = rel.rsplit("/", 1)[-1] skill_dir = self._find_skill_dir(skill_name) if not skill_dir: return None + else: + skill_dir = resolved files: Dict[str, str] = {} for f in skill_dir.rglob("*"):