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