Files
hermes-agent/tests/agent/test_skill_inline_shell_trust.py
Austin Pickett ccff4df98c fix(skills): trust-scope inline-shell auto-exec to non-community hub skills
Inline `!`cmd`` expansion in SKILL.md bodies ran for every skill whenever the
global skills.inline_shell flag was on — a community hub skill and a builtin
were expanded identically. The hub's install gate (INSTALL_POLICY) blocks a
community install on the caution/dangerous verdicts the inline-shell DSL scans
as, but --force or a pre-scanner install puts that skill on disk anyway, and
the next view/slash/preload would auto-execute it on the host.

Gate expansion at the single preprocess_skill_content choke point (all three
surfaces route through it): community-trust hub lock entries never expand;
trusted/builtin hub entries and anything without a hub lock entry (bundled-
synced, user-created, project/external) keep the flag's contract. Lock read
failures skip the gate, like every other provenance consumer.

Fixes #63307
2026-10-06 11:20:43 -04:00

80 lines
3.4 KiB
Python

"""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