Files
hermes-agent/agent/skill_preprocessing.py
Teknium 587f638e52 fix(skills): keep inline shell off for nested, cross-profile and unreadable-lock community skills
The inline-shell trust gate matched only the exact install dir from the
active profile's hub lock, and a lock read failure fell through to
expansion. So a nested SKILL.md inside a --force community bundle, the
same skill viewed by another profile through skills.external_dirs, and
any skill under a truncated or wrong-shape lock all ran their !`cmd`
snippets on view. Match descendants of community install dirs, also read
the lock that owns the skills tree the dir lives in, and fail closed when
an existing lock cannot be read.
2026-10-09 18:18:41 -07:00

157 lines
7.3 KiB
Python

"""Shared SKILL.md preprocessing helpers: ``${HERMES_*}`` template tokens and
inline ``!`cmd``` shell expansion."""
import json
import logging
import re
import subprocess
from pathlib import Path
from agent.compression_marker import elide
from hermes_cli._subprocess_compat import IS_WINDOWS, windows_hide_flags
logger = logging.getLogger(__name__)
# ${HERMES_SKILL_DIR} / ${HERMES_SESSION_ID} tokens. Unresolvable ones (e.g. no
# session) are left as-is so the author can spot them.
_SKILL_TEMPLATE_RE = re.compile(r"\$\{(HERMES_SKILL_DIR|HERMES_SESSION_ID)\}")
# Inline shell snippets like !`date +%Y-%m-%d` — single-line only.
_INLINE_SHELL_RE = re.compile(r"!`([^`\n]+)`")
# Cap inline-shell output so a runaway command can't blow out the context.
_INLINE_SHELL_MAX_OUTPUT = 4000
def load_skills_config() -> dict:
"""Load the ``skills`` section of config.yaml (best-effort)."""
try:
from hermes_cli.config import load_config_readonly
skills_cfg = (load_config_readonly() or {}).get("skills")
if isinstance(skills_cfg, dict):
return skills_cfg
except Exception:
logger.debug("Could not read skills config", exc_info=True)
return {}
def substitute_template_vars(content: str, skill_dir: Path | None, session_id: str | None) -> str:
"""Replace ${HERMES_SKILL_DIR} / ${HERMES_SESSION_ID}; tokens without a value stay in place."""
if not content:
return content
values = {
"HERMES_SKILL_DIR": str(skill_dir) if skill_dir else None,
"HERMES_SESSION_ID": str(session_id) if session_id else None,
}
return _SKILL_TEMPLATE_RE.sub(lambda m: values[m.group(1)] or m.group(0), content)
def run_inline_shell(command: str, cwd: Path | None, timeout: int) -> str:
"""Run one inline-shell snippet and return its stdout (trimmed; stderr when
stdout is empty). Failures return an ``[inline-shell ...]`` marker instead
of raising, so one bad snippet can't wreck the whole skill message."""
_popen_kwargs = {"creationflags": windows_hide_flags()} if IS_WINDOWS else {}
from agent.delegation_context import delegated_child_subprocess_env
try:
bash = "bash"
if IS_WINDOWS:
# CreateProcess searches System32 before PATH and may pick WSL's
# launcher. Reuse the terminal's native Git Bash resolution.
from tools.environments.local import _find_bash
bash = _find_bash()
completed = subprocess.run(
[bash, "-c", command],
cwd=str(cwd) if cwd else None,
capture_output=True,
text=True, encoding='utf-8', errors='replace',
timeout=max(1, int(timeout)),
check=False,
stdin=subprocess.DEVNULL,
env=delegated_child_subprocess_env(),
**_popen_kwargs,
)
except subprocess.TimeoutExpired:
return f"[inline-shell timeout after {timeout}s: {command}]"
except FileNotFoundError:
return "[inline-shell error: bash not found]"
except Exception as exc:
# tests/conftest.py's live-system guard may block the os.kill that
# subprocess.run uses to clean up a timed-out shell; report the timeout.
if isinstance(exc, RuntimeError) and "live-system guard: blocked os.kill" in str(exc):
return f"[inline-shell timeout after {timeout}s: {command}]"
return f"[inline-shell error: {exc}]"
output = (completed.stdout or "").rstrip("\n") or (completed.stderr or "").rstrip("\n")
if completed.returncode != 0 and not output:
# rc!=0 with no output at all is indistinguishable from a legit empty result; it is the
# "interpreter never ran the command" signature (WSL stub without a distro) — say so.
return f"[inline-shell exit {completed.returncode} with no output: {command}]"
return elide(output, _INLINE_SHELL_MAX_OUTPUT)
def expand_inline_shell(content: str, skill_dir: Path | None, timeout: int) -> str:
"""Replace every !`cmd` snippet with its stdout, run with the skill dir as CWD."""
if "!`" not in content:
return content
def _replace(match: re.Match) -> str:
cmd = match.group(1).strip()
return run_inline_shell(cmd, skill_dir, timeout) if cmd else ""
return _INLINE_SHELL_RE.sub(_replace, content)
def _is_community_hub_skill(skill_dir: Path | None) -> bool:
"""Whether *skill_dir* is a hub-installed skill the scan gate classifies as community trust.
The hub's INSTALL_POLICY blocks a community install on a caution/dangerous verdict — but
``--force`` (or a pre-scanner install) puts that skill on disk anyway, and the inline-shell
DSL scans as high severity, so auto-executing it on view would re-arm exactly what the
gate refused (#63307). Provenance is the hub lock entry (trusted/builtin entries expand;
anything without one — bundled-synced, user-created, project/external — keeps the flag's
contract). It covers every SKILL.md nested inside a community install and is read from
the lock that owns the skills tree the dir lives in, not only the active profile's
(``external_dirs`` can expose another profile's installs). A lock that exists but cannot
be read fails closed for skills in its tree; no lock means no hub installs.
"""
if skill_dir is None:
return False
try:
from tools.skills_hub import HubLockFile
from tools.skills_tool import _skills_dir
target = skill_dir.resolve()
locks = {HubLockFile().path.resolve(): _skills_dir().resolve()}
for root in target.parents:
if root.name == "skills" and (root / ".hub" / "lock.json").is_file():
locks.setdefault((root / ".hub" / "lock.json").resolve(), root)
for lock_path, root in locks.items():
if not (target.is_relative_to(root) and lock_path.is_file()):
continue
try:
installed = json.loads(lock_path.read_text(encoding="utf-8-sig"))["installed"]
entries = [e for e in installed.values() if isinstance(e, dict)]
except (OSError, ValueError, KeyError, TypeError, AttributeError):
logger.debug("Unreadable hub lock %s; inline shell stays off for its tree", lock_path, exc_info=True)
return True
for entry in entries:
rel = str(entry.get("install_path") or "")
if entry.get("trust_level") == "community" and rel and target.is_relative_to((root / rel).resolve()):
return True
return False
except Exception:
logger.debug("Could not resolve hub provenance for inline-shell trust scoping", exc_info=True)
return True
def preprocess_skill_content(
content: str,
skill_dir: Path | None,
session_id: str | None = None,
skills_cfg: dict | None = None,
) -> str:
"""Apply configured SKILL.md template and inline-shell preprocessing."""
if not content:
return content
cfg = skills_cfg if isinstance(skills_cfg, dict) else load_skills_config()
if cfg.get("template_vars", True):
content = substitute_template_vars(content, skill_dir, session_id)
if cfg.get("inline_shell", False) and not _is_community_hub_skill(skill_dir):
content = expand_inline_shell(content, skill_dir, int(cfg.get("inline_shell_timeout", 10) or 10))
return content