test_hooks_cli.py
1 """Tests for the ``hermes hooks`` CLI subcommand.""" 2 3 from __future__ import annotations 4 5 import io 6 import json 7 import sys 8 from contextlib import redirect_stdout 9 from pathlib import Path 10 from types import SimpleNamespace 11 from unittest.mock import patch 12 13 import pytest 14 15 from agent import shell_hooks 16 from hermes_cli import hooks as hooks_cli 17 18 19 @pytest.fixture(autouse=True) 20 def _isolated_home(tmp_path, monkeypatch): 21 monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home")) 22 monkeypatch.delenv("HERMES_ACCEPT_HOOKS", raising=False) 23 shell_hooks.reset_for_tests() 24 yield 25 shell_hooks.reset_for_tests() 26 27 28 def _hook_script(tmp_path: Path, body: str, name: str = "hook.sh") -> Path: 29 p = tmp_path / name 30 p.write_text(body) 31 p.chmod(0o755) 32 return p 33 34 35 def _run(sub_args: SimpleNamespace) -> str: 36 """Capture stdout for a hooks_command invocation.""" 37 buf = io.StringIO() 38 with redirect_stdout(buf): 39 hooks_cli.hooks_command(sub_args) 40 return buf.getvalue() 41 42 43 # ── list ────────────────────────────────────────────────────────────────── 44 45 46 class TestHooksList: 47 def test_empty_config(self, tmp_path): 48 with patch("hermes_cli.config.load_config", return_value={}): 49 out = _run(SimpleNamespace(hooks_action="list")) 50 assert "No shell hooks configured" in out 51 52 def test_shows_configured_and_consent_status(self, tmp_path): 53 script = _hook_script( 54 tmp_path, "#!/usr/bin/env bash\nprintf '{}\\n'\n", 55 ) 56 cfg = { 57 "hooks": { 58 "pre_tool_call": [ 59 {"matcher": "terminal", "command": str(script), "timeout": 30}, 60 ], 61 "on_session_start": [ 62 {"command": str(script)}, 63 ], 64 } 65 } 66 67 # Approve one of the two so we can see both states in the output 68 shell_hooks._record_approval("pre_tool_call", str(script)) 69 70 with patch("hermes_cli.config.load_config", return_value=cfg): 71 out = _run(SimpleNamespace(hooks_action="list")) 72 73 assert "[pre_tool_call]" in out 74 assert "[on_session_start]" in out 75 assert "✓ allowed" in out 76 assert "✗ not allowlisted" in out 77 assert str(script) in out 78 79 80 # ── test ────────────────────────────────────────────────────────────────── 81 82 83 class TestHooksTest: 84 def test_synthetic_payload_matches_production_shape(self, tmp_path): 85 """`hermes hooks test` must feed the script stdin in the same 86 shape invoke_hook() would at runtime. Prior to this fix, 87 run_once bypassed _serialize_payload and the two paths diverged — 88 scripts tested with `hermes hooks test` saw different top-level 89 keys than at runtime, silently breaking in production.""" 90 capture = tmp_path / "captured.json" 91 script = _hook_script( 92 tmp_path, 93 f"#!/usr/bin/env bash\ncat - > {capture}\nprintf '{{}}\\n'\n", 94 ) 95 cfg = {"hooks": {"subagent_stop": [{"command": str(script)}]}} 96 with patch("hermes_cli.config.load_config", return_value=cfg): 97 _run(SimpleNamespace( 98 hooks_action="test", event="subagent_stop", 99 for_tool=None, payload_file=None, 100 )) 101 102 seen = json.loads(capture.read_text()) 103 # Same top-level keys _serialize_payload produces at runtime 104 assert set(seen.keys()) == { 105 "hook_event_name", "tool_name", "tool_input", 106 "session_id", "cwd", "extra", 107 } 108 # parent_session_id was routed to top-level session_id (matches runtime) 109 assert seen["session_id"] == "parent-sess" 110 assert "parent_session_id" not in seen["extra"] 111 # subagent_stop has no tool, so tool_name / tool_input are null 112 assert seen["tool_name"] is None 113 assert seen["tool_input"] is None 114 115 def test_fires_real_subprocess_and_parses_block(self, tmp_path): 116 block_script = _hook_script( 117 tmp_path, 118 "#!/usr/bin/env bash\n" 119 'printf \'{"decision": "block", "reason": "nope"}\\n\'\n', 120 name="block.sh", 121 ) 122 cfg = { 123 "hooks": { 124 "pre_tool_call": [ 125 {"matcher": "terminal", "command": str(block_script)}, 126 ], 127 }, 128 } 129 with patch("hermes_cli.config.load_config", return_value=cfg): 130 out = _run(SimpleNamespace( 131 hooks_action="test", event="pre_tool_call", 132 for_tool="terminal", payload_file=None, 133 )) 134 135 # Parsed block appears in output 136 assert '"action": "block"' in out 137 assert '"message": "nope"' in out 138 139 def test_for_tool_matcher_filters(self, tmp_path): 140 script = _hook_script(tmp_path, "#!/usr/bin/env bash\nprintf '{}\\n'\n") 141 cfg = { 142 "hooks": { 143 "pre_tool_call": [ 144 {"matcher": "terminal", "command": str(script)}, 145 ], 146 } 147 } 148 with patch("hermes_cli.config.load_config", return_value=cfg): 149 out = _run(SimpleNamespace( 150 hooks_action="test", event="pre_tool_call", 151 for_tool="web_search", payload_file=None, 152 )) 153 assert "No shell hooks" in out 154 155 def test_unknown_event(self): 156 with patch("hermes_cli.config.load_config", return_value={}): 157 out = _run(SimpleNamespace( 158 hooks_action="test", event="bogus_event", 159 for_tool=None, payload_file=None, 160 )) 161 assert "Unknown event" in out 162 163 164 # ── revoke ──────────────────────────────────────────────────────────────── 165 166 167 class TestHooksRevoke: 168 def test_revoke_removes_entry(self, tmp_path): 169 script = _hook_script(tmp_path, "#!/usr/bin/env bash\n") 170 shell_hooks._record_approval("on_session_start", str(script)) 171 172 out = _run(SimpleNamespace(hooks_action="revoke", command=str(script))) 173 assert "Removed 1" in out 174 assert shell_hooks.allowlist_entry_for( 175 "on_session_start", str(script), 176 ) is None 177 178 def test_revoke_unknown(self, tmp_path): 179 out = _run(SimpleNamespace( 180 hooks_action="revoke", command=str(tmp_path / "never.sh"), 181 )) 182 assert "No allowlist entry" in out 183 184 185 # ── doctor ──────────────────────────────────────────────────────────────── 186 187 188 class TestHooksDoctor: 189 def test_flags_missing_exec_bit(self, tmp_path): 190 script = tmp_path / "hook.sh" 191 script.write_text("#!/usr/bin/env bash\nprintf '{}\\n'\n") 192 # No chmod — intentionally not executable 193 cfg = {"hooks": {"on_session_start": [{"command": str(script)}]}} 194 with patch("hermes_cli.config.load_config", return_value=cfg): 195 out = _run(SimpleNamespace(hooks_action="doctor")) 196 assert "not executable" in out.lower() 197 198 def test_flags_unallowlisted(self, tmp_path): 199 script = _hook_script(tmp_path, "#!/usr/bin/env bash\nprintf '{}\\n'\n") 200 cfg = {"hooks": {"on_session_start": [{"command": str(script)}]}} 201 with patch("hermes_cli.config.load_config", return_value=cfg): 202 out = _run(SimpleNamespace(hooks_action="doctor")) 203 assert "not allowlisted" in out.lower() 204 205 def test_flags_invalid_json(self, tmp_path): 206 script = _hook_script( 207 tmp_path, 208 "#!/usr/bin/env bash\necho 'not json!'\n", 209 ) 210 shell_hooks._record_approval("on_session_start", str(script)) 211 cfg = {"hooks": {"on_session_start": [{"command": str(script)}]}} 212 with patch("hermes_cli.config.load_config", return_value=cfg): 213 out = _run(SimpleNamespace(hooks_action="doctor")) 214 assert "not valid JSON" in out 215 216 def test_flags_mtime_drift(self, tmp_path, monkeypatch): 217 """Allowlist with older mtime than current -> drift warning.""" 218 script = _hook_script(tmp_path, "#!/usr/bin/env bash\nprintf '{}\\n'\n") 219 220 # Manually stash an allowlist entry with an old mtime 221 from agent.shell_hooks import allowlist_path 222 allowlist_path().parent.mkdir(parents=True, exist_ok=True) 223 allowlist_path().write_text(json.dumps({ 224 "approvals": [ 225 { 226 "event": "on_session_start", 227 "command": str(script), 228 "approved_at": "2000-01-01T00:00:00Z", 229 "script_mtime_at_approval": "2000-01-01T00:00:00Z", 230 } 231 ] 232 })) 233 234 cfg = {"hooks": {"on_session_start": [{"command": str(script)}]}} 235 with patch("hermes_cli.config.load_config", return_value=cfg): 236 out = _run(SimpleNamespace(hooks_action="doctor")) 237 assert "modified since approval" in out 238 239 def test_clean_script_runs(self, tmp_path): 240 script = _hook_script(tmp_path, "#!/usr/bin/env bash\nprintf '{}\\n'\n") 241 shell_hooks._record_approval("on_session_start", str(script)) 242 cfg = {"hooks": {"on_session_start": [{"command": str(script)}]}} 243 with patch("hermes_cli.config.load_config", return_value=cfg): 244 out = _run(SimpleNamespace(hooks_action="doctor")) 245 assert "All shell hooks look healthy" in out 246 247 def test_unallowlisted_script_is_not_executed(self, tmp_path): 248 """Regression for M4: `hermes hooks doctor` used to run every 249 listed script against a synthetic payload as part of its JSON 250 smoke test, which contradicted the documented workflow of 251 "spot newly-added hooks *before they register*". An un-allowlisted 252 script must not be executed during `doctor`.""" 253 sentinel = tmp_path / "executed" 254 # Script would touch the sentinel if executed; we assert it wasn't. 255 script = _hook_script( 256 tmp_path, 257 f"#!/usr/bin/env bash\ntouch {sentinel}\nprintf '{{}}\\n'\n", 258 ) 259 cfg = {"hooks": {"on_session_start": [{"command": str(script)}]}} 260 with patch("hermes_cli.config.load_config", return_value=cfg): 261 out = _run(SimpleNamespace(hooks_action="doctor")) 262 263 assert not sentinel.exists(), ( 264 "doctor executed an un-allowlisted script — " 265 "M4 gate regressed" 266 ) 267 assert "not allowlisted" in out.lower() 268 assert "skipped JSON smoke test" in out