diff --git a/agent/skill_preprocessing.py b/agent/skill_preprocessing.py index 2e3f66b0f2..e07aa87b3f 100644 --- a/agent/skill_preprocessing.py +++ b/agent/skill_preprocessing.py @@ -95,6 +95,39 @@ def expand_inline_shell(content: str, skill_dir: Path | None, timeout: int) -> s 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). A lock read failure skips the gate, like every other provenance consumer. + """ + if skill_dir is None: + return False + try: + from tools.skills_tool import _skills_dir + from tools.skills_hub import HubLockFile + installed = HubLockFile().load().get("installed") or {} + for entry in installed.values(): + if not (isinstance(entry, dict) and entry.get("trust_level") == "community"): + continue + rel = str(entry.get("install_path") or "") + if not rel: + continue + try: + if skill_dir.resolve() == (_skills_dir() / rel).resolve(): + return True + except (OSError, ValueError): + continue + return False + except Exception: + logger.debug("Could not read hub lock for inline-shell trust scoping", exc_info=True) + return False + + def preprocess_skill_content( content: str, skill_dir: Path | None, @@ -107,6 +140,6 @@ def preprocess_skill_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): + 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 diff --git a/hermes_cli/config_defaults.py b/hermes_cli/config_defaults.py index 94baa87e36..fa682523a1 100644 --- a/hermes_cli/config_defaults.py +++ b/hermes_cli/config_defaults.py @@ -1465,7 +1465,7 @@ DEFAULT_CONFIG = { # Substitute ${HERMES_SKILL_DIR} / ${HERMES_SESSION_ID} in SKILL.md content. "template_vars": True, # Pre-execute !`cmd` snippets in SKILL.md, inlining stdout (dates, git state...). Off: - # skill-author content would run on the host unapproved — trusted sources only. + # host-unapproved skill-author code; community hub installs never auto-execute (#63307). "inline_shell": False, "inline_shell_timeout": 10, # seconds per !`cmd` snippet # Security-scan skills the agent writes via skill_manage. Off: the agent can run the same diff --git a/tests/agent/test_skill_inline_shell_trust.py b/tests/agent/test_skill_inline_shell_trust.py new file mode 100644 index 0000000000..c84381db58 --- /dev/null +++ b/tests/agent/test_skill_inline_shell_trust.py @@ -0,0 +1,79 @@ +"""Inline-shell expansion is trust-scoped (#63307): community hub installs never +auto-execute `` !`cmd` `` snippets, mirroring the hub scan's INSTALL_POLICY trust +gate — a ``--force`` or pre-scanner community install must not re-arm them.""" +import json +from unittest.mock import patch + +from agent.skill_preprocessing import preprocess_skill_content + +CFG = {"inline_shell": True, "inline_shell_timeout": 5} +SNIPPET = "Dynamic: !`printf SENTINEL`" + + +def _make_skill(skills_root, rel, body=SNIPPET): + skill_dir = skills_root / rel + skill_dir.mkdir(parents=True, exist_ok=True) + name = rel.rsplit("/", 1)[-1] + (skill_dir / "SKILL.md").write_text( + f"---\nname: {name}\ndescription: Description for {name}.\n---\n\n{body}\n", + encoding="utf-8", + ) + return skill_dir + + +def _record_hub_install(install_path: str, trust_level: str): + """Write a hub lock entry the way ``install_from_quarantine`` does.""" + from tools.skills_hub import HubLockFile + + HubLockFile().record_install( + name=install_path.rsplit("/", 1)[-1], source="github", identifier=f"o/r/{install_path}", + trust_level=trust_level, scan_verdict="caution", skill_hash="sha256:0", + install_path=install_path, files=["SKILL.md"]) + + +def test_community_hub_install_does_not_expand(tmp_path): + """A community-trust hub lock entry means the snippet must NOT run on view.""" + skill_dir = _make_skill(tmp_path, "mlops/hubskill") + _record_hub_install("mlops/hubskill", "community") + with patch("tools.skills_tool.SKILLS_DIR", tmp_path): + rendered = preprocess_skill_content(SNIPPET, skill_dir, skills_cfg=dict(CFG)) + assert rendered == SNIPPET # raw DSL preserved — nothing executed + + +def test_trusted_hub_install_still_expands(tmp_path): + """Trusted/builtin hub installs keep the operator's opt-in contract.""" + skill_dir = _make_skill(tmp_path, "mlops/hubskill") + _record_hub_install("mlops/hubskill", "trusted") + with patch("tools.skills_tool.SKILLS_DIR", tmp_path): + rendered = preprocess_skill_content(SNIPPET, skill_dir, skills_cfg=dict(CFG)) + assert rendered == "Dynamic: SENTINEL" + + +def test_local_skill_without_hub_entry_still_expands(tmp_path): + """Bundled-synced and user-created skills carry no lock entry and keep expanding.""" + skill_dir = _make_skill(tmp_path, "mlops/my-own") + with patch("tools.skills_tool.SKILLS_DIR", tmp_path): + rendered = preprocess_skill_content(SNIPPET, skill_dir, skills_cfg=dict(CFG)) + assert rendered == "Dynamic: SENTINEL" + + +def test_slash_invocation_leaves_community_snippet_raw(tmp_path): + """The slash/bundle surface routes through the same gate: a community hub + skill's message shows the raw snippet, never the executed output.""" + from agent.skill_commands import build_skill_invocation_message, scan_skill_commands + + _make_skill(tmp_path, "dyn-comm") + _record_hub_install("dyn-comm", "community") + with ( + patch("tools.skills_tool.SKILLS_DIR", tmp_path), + patch("agent.skill_commands._load_skills_config", + return_value={"template_vars": True, "inline_shell": True, + "inline_shell_timeout": 5}), + ): + scan_skill_commands() + msg = build_skill_invocation_message("/dyn-comm") + + assert msg is not None + # Executed output would be "Dynamic: SENTINEL"; the gate leaves the raw DSL. + assert "Dynamic: SENTINEL" not in msg + assert "!`printf SENTINEL`" in msg diff --git a/website/docs/developer-guide/creating-skills.md b/website/docs/developer-guide/creating-skills.md index 51219596e4..e03182ddf8 100644 --- a/website/docs/developer-guide/creating-skills.md +++ b/website/docs/developer-guide/creating-skills.md @@ -320,6 +320,8 @@ skills: Snippets run with the skill directory as their working directory, and output is capped at 4000 characters. Failures (timeouts, non-zero exits) show up as a short `[inline-shell error: ...]` marker instead of breaking the whole skill. +Trust scoping: community hub installs never auto-execute snippets, even with the flag on — the hub's install scan blocks a community skill carrying this DSL on a caution/dangerous verdict, and a `--force` install (or one from before the scanner caught the pattern) must not re-arm what the gate refused. Bundled, trusted, and your own skills expand as before. + ### Test It Run the skill and verify the agent follows the instructions correctly: