/ tests / agent / test_shell_hooks_consent.py
test_shell_hooks_consent.py
  1  """Consent-flow tests for the shell-hook allowlist.
  2  
  3  Covers the prompt/non-prompt decision tree: TTY vs non-TTY, and the
  4  three accept-hooks channels (--accept-hooks, HERMES_ACCEPT_HOOKS env,
  5  hooks_auto_accept: config key).
  6  """
  7  
  8  from __future__ import annotations
  9  
 10  import json
 11  from pathlib import Path
 12  from unittest.mock import patch
 13  
 14  import pytest
 15  
 16  from agent import shell_hooks
 17  
 18  
 19  @pytest.fixture(autouse=True)
 20  def _isolated_home(tmp_path, monkeypatch):
 21      monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes_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 _write_hook_script(tmp_path: Path) -> Path:
 29      script = tmp_path / "hook.sh"
 30      script.write_text("#!/usr/bin/env bash\nprintf '{}\\n'\n")
 31      script.chmod(0o755)
 32      return script
 33  
 34  
 35  # ── TTY prompt flow ───────────────────────────────────────────────────────
 36  
 37  
 38  class TestTTYPromptFlow:
 39      def test_first_use_prompts_and_approves(self, tmp_path):
 40          from hermes_cli import plugins
 41  
 42          script = _write_hook_script(tmp_path)
 43          plugins._plugin_manager = plugins.PluginManager()
 44  
 45          with patch("sys.stdin") as mock_stdin, patch("builtins.input", return_value="y"):
 46              mock_stdin.isatty.return_value = True
 47              registered = shell_hooks.register_from_config(
 48                  {"hooks": {"on_session_start": [{"command": str(script)}]}},
 49                  accept_hooks=False,
 50              )
 51          assert len(registered) == 1
 52  
 53          entry = shell_hooks.allowlist_entry_for("on_session_start", str(script))
 54          assert entry is not None
 55          assert entry["event"] == "on_session_start"
 56          assert entry["command"] == str(script)
 57  
 58      def test_first_use_prompts_and_rejects(self, tmp_path):
 59          from hermes_cli import plugins
 60  
 61          script = _write_hook_script(tmp_path)
 62          plugins._plugin_manager = plugins.PluginManager()
 63  
 64          with patch("sys.stdin") as mock_stdin, patch("builtins.input", return_value="n"):
 65              mock_stdin.isatty.return_value = True
 66              registered = shell_hooks.register_from_config(
 67                  {"hooks": {"on_session_start": [{"command": str(script)}]}},
 68                  accept_hooks=False,
 69              )
 70          assert registered == []
 71          assert shell_hooks.allowlist_entry_for(
 72              "on_session_start", str(script),
 73          ) is None
 74  
 75      def test_subsequent_use_does_not_prompt(self, tmp_path):
 76          """After the first approval, re-registration must be silent."""
 77          from hermes_cli import plugins
 78  
 79          script = _write_hook_script(tmp_path)
 80          plugins._plugin_manager = plugins.PluginManager()
 81  
 82          # First call: TTY, approved.
 83          with patch("sys.stdin") as mock_stdin, patch("builtins.input", return_value="y"):
 84              mock_stdin.isatty.return_value = True
 85              shell_hooks.register_from_config(
 86                  {"hooks": {"on_session_start": [{"command": str(script)}]}},
 87                  accept_hooks=False,
 88              )
 89  
 90          # Reset registration set but keep the allowlist on disk.
 91          shell_hooks.reset_for_tests()
 92  
 93          # Second call: TTY, input() must NOT be called.
 94          with patch("sys.stdin") as mock_stdin, patch(
 95              "builtins.input", side_effect=AssertionError("should not prompt"),
 96          ):
 97              mock_stdin.isatty.return_value = True
 98              registered = shell_hooks.register_from_config(
 99                  {"hooks": {"on_session_start": [{"command": str(script)}]}},
100                  accept_hooks=False,
101              )
102          assert len(registered) == 1
103  
104  
105  # ── non-TTY flow ──────────────────────────────────────────────────────────
106  
107  
108  class TestNonTTYFlow:
109      def test_no_tty_no_flag_skips_registration(self, tmp_path):
110          from hermes_cli import plugins
111  
112          script = _write_hook_script(tmp_path)
113          plugins._plugin_manager = plugins.PluginManager()
114  
115          with patch("sys.stdin") as mock_stdin:
116              mock_stdin.isatty.return_value = False
117              registered = shell_hooks.register_from_config(
118                  {"hooks": {"on_session_start": [{"command": str(script)}]}},
119                  accept_hooks=False,
120              )
121          assert registered == []
122  
123      def test_no_tty_with_argument_flag_accepts(self, tmp_path):
124          from hermes_cli import plugins
125  
126          script = _write_hook_script(tmp_path)
127          plugins._plugin_manager = plugins.PluginManager()
128  
129          with patch("sys.stdin") as mock_stdin:
130              mock_stdin.isatty.return_value = False
131              registered = shell_hooks.register_from_config(
132                  {"hooks": {"on_session_start": [{"command": str(script)}]}},
133                  accept_hooks=True,
134              )
135          assert len(registered) == 1
136  
137      def test_no_tty_with_env_accepts(self, tmp_path, monkeypatch):
138          from hermes_cli import plugins
139  
140          script = _write_hook_script(tmp_path)
141          plugins._plugin_manager = plugins.PluginManager()
142          monkeypatch.setenv("HERMES_ACCEPT_HOOKS", "1")
143  
144          with patch("sys.stdin") as mock_stdin:
145              mock_stdin.isatty.return_value = False
146              registered = shell_hooks.register_from_config(
147                  {"hooks": {"on_session_start": [{"command": str(script)}]}},
148                  accept_hooks=False,
149              )
150          assert len(registered) == 1
151  
152      def test_no_tty_with_config_accepts(self, tmp_path):
153          from hermes_cli import plugins
154  
155          script = _write_hook_script(tmp_path)
156          plugins._plugin_manager = plugins.PluginManager()
157  
158          with patch("sys.stdin") as mock_stdin:
159              mock_stdin.isatty.return_value = False
160              registered = shell_hooks.register_from_config(
161                  {
162                      "hooks_auto_accept": True,
163                      "hooks": {"on_session_start": [{"command": str(script)}]},
164                  },
165                  accept_hooks=False,
166              )
167          assert len(registered) == 1
168  
169  
170  # ── Allowlist + revoke + mtime ────────────────────────────────────────────
171  
172  
173  class TestAllowlistOps:
174      def test_mtime_recorded_on_approval(self, tmp_path):
175          script = _write_hook_script(tmp_path)
176          shell_hooks._record_approval("on_session_start", str(script))
177  
178          entry = shell_hooks.allowlist_entry_for(
179              "on_session_start", str(script),
180          )
181          assert entry is not None
182          assert entry["script_mtime_at_approval"] is not None
183          # ISO-8601 Z-suffix
184          assert entry["script_mtime_at_approval"].endswith("Z")
185  
186      def test_revoke_removes_entry(self, tmp_path):
187          script = _write_hook_script(tmp_path)
188          shell_hooks._record_approval("on_session_start", str(script))
189          assert shell_hooks.allowlist_entry_for(
190              "on_session_start", str(script),
191          ) is not None
192  
193          removed = shell_hooks.revoke(str(script))
194          assert removed == 1
195          assert shell_hooks.allowlist_entry_for(
196              "on_session_start", str(script),
197          ) is None
198  
199      def test_revoke_unknown_returns_zero(self, tmp_path):
200          assert shell_hooks.revoke(str(tmp_path / "never-approved.sh")) == 0
201  
202      def test_tilde_path_approval_records_resolvable_mtime(self, tmp_path, monkeypatch):
203          """If the command uses ~ the approval must still find the file."""
204          monkeypatch.setenv("HOME", str(tmp_path))
205          target = tmp_path / "hook.sh"
206          target.write_text("#!/usr/bin/env bash\n")
207          target.chmod(0o755)
208  
209          shell_hooks._record_approval("on_session_start", "~/hook.sh")
210          entry = shell_hooks.allowlist_entry_for(
211              "on_session_start", "~/hook.sh",
212          )
213          assert entry is not None
214          # Must not be None — the tilde was expanded before stat().
215          assert entry["script_mtime_at_approval"] is not None
216  
217      def test_duplicate_approval_replaces_mtime(self, tmp_path):
218          """Re-approving the same pair refreshes the approval timestamp."""
219          script = _write_hook_script(tmp_path)
220          shell_hooks._record_approval("on_session_start", str(script))
221          original_entry = shell_hooks.allowlist_entry_for(
222              "on_session_start", str(script),
223          )
224          assert original_entry is not None
225  
226          # Touch the script to bump its mtime then re-approve.
227          import os
228          import time
229          new_mtime = original_entry.get("script_mtime_at_approval")
230          time.sleep(0.01)
231          os.utime(script, None)  # current time
232  
233          shell_hooks._record_approval("on_session_start", str(script))
234  
235          # Exactly one entry per (event, command).
236          approvals = shell_hooks.load_allowlist().get("approvals", [])
237          matching = [
238              e for e in approvals
239              if e.get("event") == "on_session_start"
240              and e.get("command") == str(script)
241          ]
242          assert len(matching) == 1
243  
244  
245  # ── hooks_auto_accept config parsing ──────────────────────────────────────
246  
247  
248  class TestHooksAutoAcceptParsing:
249      """Regression guard: YAML-string values must not silently auto-accept.
250  
251      ``bool("false")`` is ``True`` in Python, so the old ``return bool(cfg_val)``
252      path treated ``hooks_auto_accept: "false"`` (quoted YAML string) as a
253      truthy opt-in, silently bypassing user consent for every shell hook.
254      """
255  
256      def test_bool_true_accepts(self):
257          assert shell_hooks._resolve_effective_accept(
258              {"hooks_auto_accept": True}, accept_hooks_arg=False,
259          ) is True
260  
261      def test_bool_false_rejects(self):
262          assert shell_hooks._resolve_effective_accept(
263              {"hooks_auto_accept": False}, accept_hooks_arg=False,
264          ) is False
265  
266      def test_string_false_rejects(self):
267          # The bug: bool("false") is True. Must be parsed, not coerced.
268          assert shell_hooks._resolve_effective_accept(
269              {"hooks_auto_accept": "false"}, accept_hooks_arg=False,
270          ) is False
271  
272      def test_string_no_rejects(self):
273          assert shell_hooks._resolve_effective_accept(
274              {"hooks_auto_accept": "no"}, accept_hooks_arg=False,
275          ) is False
276  
277      def test_string_true_accepts(self):
278          assert shell_hooks._resolve_effective_accept(
279              {"hooks_auto_accept": "true"}, accept_hooks_arg=False,
280          ) is True
281  
282      def test_string_true_case_insensitive(self):
283          assert shell_hooks._resolve_effective_accept(
284              {"hooks_auto_accept": "  TRUE  "}, accept_hooks_arg=False,
285          ) is True
286  
287      def test_string_yes_on_one_accept(self):
288          for val in ("yes", "on", "1"):
289              assert shell_hooks._resolve_effective_accept(
290                  {"hooks_auto_accept": val}, accept_hooks_arg=False,
291              ) is True, val
292  
293      def test_missing_key_rejects(self):
294          assert shell_hooks._resolve_effective_accept(
295              {}, accept_hooks_arg=False,
296          ) is False
297  
298      def test_none_rejects(self):
299          assert shell_hooks._resolve_effective_accept(
300              {"hooks_auto_accept": None}, accept_hooks_arg=False,
301          ) is False
302  
303      def test_integer_ignored(self):
304          # Only bool and str are honored; anything else (including 1) is False.
305          assert shell_hooks._resolve_effective_accept(
306              {"hooks_auto_accept": 1}, accept_hooks_arg=False,
307          ) is False
308  
309      def test_cli_arg_overrides_config(self):
310          assert shell_hooks._resolve_effective_accept(
311              {"hooks_auto_accept": "false"}, accept_hooks_arg=True,
312          ) is True
313