/ tests / hermes_cli / test_hooks_cli.py
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