Files
hermes-agent/tools/skills_tool_plugin.py
ethernet 92686159d1 fix(pm): integrate audited runtime and lifecycle repairs
Prepare dependency generations before selecting them. Keep shipped tool
bytes separate from writable additions, and store facts beside their entries.
Validate proposed plugin sets before config publication. Restore the previous
config if the facts write fails.

Consolidate duplicate updater, backup, setup, and voice helpers. Repair
launcher selection, dependency consumers, download ownership, update feeds,
and native Windows process and file handling.

Verification: 206 changed/prior-failing Python files reported 4630 passed,
one failed, and 330 skipped. Fix the remaining Hindsight fixture boundary.
The final targeted rerun reported 234 passed and two skipped. The store
review regression batch reported 83 passed and one skipped. Desktop
TypeScript checks, 56 selected Electron tests, 24 release tests, and the
removed-import/compatibility guards passed.

This is an integration checkpoint, not full audit acceptance. The complete
Python suite has not run on this fixed tree. Crash-atomic plugin publication,
generation cleanup, receipt correlation, and packaged lifecycle acceptance
remain open in docs/pm-audit-status.md.
2026-09-05 22:36:48 -04:00

173 lines
8.3 KiB
Python

"""Plugin-provided skill serving for ``skill_view`` (``plugin:skill`` names) plus the JSON /
file-serving helpers shared with the local-skill path. Helpers tests patch on the origin module
(``_is_skill_disabled``, ``_parse_frontmatter``, ``skill_matches_platform``) resolve lazily."""
import json
import logging
from contextlib import suppress
from pathlib import Path
from typing import Any, Dict, List
from tools.skills_tool_setup import SkillReadinessStatus
logger = logging.getLogger("tools.skills_tool")
MAX_NAME_LENGTH = 64 # Anthropic-recommended progressive-disclosure limits
MAX_DESCRIPTION_LENGTH = 1024
_INJECTION_PATTERNS: list = [ # shared by local-skill and plugin-skill paths
"ignore previous instructions", "ignore all previous", "you are now",
"disregard your", "forget your instructions", "new instructions:",
"system prompt:", "<system>", "]]>"]
_SUPPORT_DIRS = ("references", "templates", "assets", "scripts")
_SKILL_FILE_EXTS = {".md", ".py", ".yaml", ".yml", ".json", ".tex", ".sh"}
def _json(payload: dict) -> str:
return json.dumps(payload, ensure_ascii=False)
def _fail(error: str, **extra) -> str:
return _json({"success": False, "error": error, **extra})
def _read_skill_text(path: Path) -> str:
"""utf-8-sig + errors="replace": user-authored SKILL.md may carry a Notepad BOM or stray
bytes; pinning UTF-8 keeps skill_view deterministic across host locales."""
return path.read_text(encoding="utf-8-sig", errors="replace")
def _truncate_description(description: str) -> str:
if len(description) <= MAX_DESCRIPTION_LENGTH:
return description
return description[: MAX_DESCRIPTION_LENGTH - 3] + "..."
def _safe_frontmatter(path: Path | None = None, *, content: str | None = None) -> Dict[str, Any]:
"""Frontmatter of *path* (or of *content*), ``{}`` on any read/parse failure.
Parses via ``tools.skills_tool._parse_frontmatter`` so test patches are honored."""
from tools import skills_tool as _st
with suppress(Exception):
return _st._parse_frontmatter(_read_skill_text(path) if content is None else content)[0]
return {}
def _available_skill_files(skill_dir: Path) -> Dict[str, List[str]]:
"""Non-SKILL.md files grouped by support dir (+ "other" for known source extensions
elsewhere); empty groups dropped."""
groups: Dict[str, List[str]] = {}
for f in skill_dir.rglob("*"):
if not f.is_file() or f.name == "SKILL.md":
continue
rel = f.relative_to(skill_dir).as_posix()
top = rel.split("/", 1)[0] if "/" in rel else None
if top in _SUPPORT_DIRS or f.suffix in _SKILL_FILE_EXTS:
groups.setdefault(top if top in _SUPPORT_DIRS else "other", []).append(rel)
return {k: groups[k] for k in (*_SUPPORT_DIRS, "other") if k in groups}
def _serve_skill_file(
skill_root: Path, file_path: str, label: str, *, hint: str | None = None,
list_available: bool = False, read_error_prefix: bool = False, mark_read: bool = False) -> str:
"""Serve one linked file from a skill directory as a skill_view JSON result. ``hint``
decorates traversal/containment errors; ``list_available`` adds the available-files listing
on not-found (local); ``read_error_prefix`` wraps non-decode read errors (plugin) instead
of propagating to the caller's handler."""
from tools.path_security import has_traversal_component, validate_within_dir
extra = {"hint": hint} if hint else {}
if has_traversal_component(file_path):
return _fail("Path traversal ('..') is not allowed.", **extra)
target = skill_root / file_path
if path_error := validate_within_dir(target, skill_root):
return _fail(path_error, **extra)
# is_file(), not exists(): a bare directory must take the not-found branch.
if not target.is_file():
listing = {} if not list_available else {
"available_files": _available_skill_files(skill_root),
"hint": "Use one of the available file paths listed above"}
return _fail(f"File '{file_path}' not found in skill '{label}'.", **listing)
try:
content = _read_skill_text(target)
except UnicodeDecodeError:
return _json({
"success": True, "name": label, "file": file_path, "is_binary": True,
"content": f"[Binary file: {target.name}, size: {target.stat().st_size} bytes]"})
except Exception as exc:
if not read_error_prefix:
raise
return _fail(f"Failed to read '{file_path}': {exc}")
if mark_read:
_mark_background_review_read(target)
return _json({ # _source_path: internal, feeds the repeat-view dedup fingerprint
"success": True, "name": label, "file": file_path, "content": content,
"file_type": target.suffix, "_source_path": str(target)})
def _mark_background_review_read(path: Path) -> None:
try:
from tools.skill_manager_guards import mark_background_review_skill_read
mark_background_review_skill_read(path)
except Exception:
logger.debug("Could not record background-review skill read for %s", path, exc_info=True)
def _preprocess_skill(content: str, skill_dir, session_id, debug_msg: str, *args) -> str:
"""Apply the configured SKILL.md preprocessing; on failure log and serve raw."""
try:
from agent.skill_preprocessing import preprocess_skill_content
return preprocess_skill_content(content, skill_dir, session_id=session_id)
except Exception:
logger.debug(debug_msg, *args, exc_info=True)
return content
def _serve_plugin_skill(
skill_md: Path, namespace: str, bare: str, file_path: str | None = None, *,
preprocess: bool = True, session_id: str | None = None) -> str:
"""Read a plugin-provided skill, apply guards, return JSON."""
from hermes_cli.plugins import _get_disabled_plugins, get_plugin_manager
from tools import skills_tool as _st
if namespace in _get_disabled_plugins():
return _fail(f"Plugin '{namespace}' is disabled. Re-enable with: hermes plugins enable {namespace}")
qualified_name = f"{namespace}:{bare}"
try:
content = _read_skill_text(skill_md)
except Exception as e:
return _fail(f"Failed to read skill '{qualified_name}': {e}")
parsed_frontmatter = _safe_frontmatter(content=content)
if _st._is_skill_disabled(qualified_name):
return _fail(f"Skill '{qualified_name}' is disabled.")
if not _st.skill_matches_platform(parsed_frontmatter):
return _fail(f"Skill '{qualified_name}' is not supported on this platform.",
readiness_status=SkillReadinessStatus.UNSUPPORTED.value)
if file_path:
return _serve_skill_file(skill_md.parent, file_path, qualified_name, read_error_prefix=True)
if any(p in content.lower() for p in _INJECTION_PATTERNS):
logger.warning(
"Plugin skill '%s:%s' contains patterns that may indicate prompt injection", namespace, bare)
banner = ""
with suppress(Exception): # bundle-context banner: sibling skills of the same plugin
siblings = [s for s in get_plugin_manager().list_plugin_skills(namespace) if s != bare]
banner = f"[Bundle context: This skill is part of the '{namespace}' plugin." + (
f"\nSibling skills: {', '.join(siblings)}.\nUse qualified form to invoke siblings "
f"(e.g. {namespace}:{siblings[0]})." if siblings else "") + "]\n\n"
rendered_content = content if not preprocess else _preprocess_skill(
content, skill_md.parent, session_id, "Could not preprocess plugin skill %s:%s",
namespace, bare)
return _json({
"success": True, "name": qualified_name, "content": banner + rendered_content,
"description": _truncate_description(str(parsed_frontmatter.get("description", ""))),
"linked_files": _plugin_skill_linked_files(skill_md.parent),
"readiness_status": SkillReadinessStatus.AVAILABLE.value})
def _plugin_skill_linked_files(skill_root: Path) -> Dict[str, List[str]] | None:
from tools.path_security import validate_within_dir
linked: Dict[str, List[str]] = {}
for category in _SUPPORT_DIRS:
files = [
str(path.relative_to(skill_root)) for path in sorted((skill_root / category).rglob("*"))
if path.is_file() and validate_within_dir(path, skill_root) is None]
if files:
linked[category] = files
return linked or None