test_tirith_security.py
1 """Tests for the tirith security scanning subprocess wrapper.""" 2 3 import json 4 import os 5 import subprocess 6 import time 7 from unittest.mock import MagicMock, patch 8 9 import pytest 10 11 import tools.tirith_security as _tirith_mod 12 from tools.tirith_security import check_command_security, ensure_installed 13 14 15 @pytest.fixture(autouse=True) 16 def _reset_resolved_path(): 17 """Pre-set cached path to skip auto-install in scan tests. 18 19 Tests that specifically test ensure_installed / resolve behavior 20 reset this to None themselves. 21 """ 22 _tirith_mod._resolved_path = "tirith" 23 _tirith_mod._install_thread = None 24 _tirith_mod._install_failure_reason = "" 25 yield 26 _tirith_mod._resolved_path = None 27 _tirith_mod._install_thread = None 28 _tirith_mod._install_failure_reason = "" 29 30 31 # --------------------------------------------------------------------------- 32 # Helpers 33 # --------------------------------------------------------------------------- 34 35 def _mock_run(returncode=0, stdout="", stderr=""): 36 """Build a mock subprocess.CompletedProcess.""" 37 cp = MagicMock(spec=subprocess.CompletedProcess) 38 cp.returncode = returncode 39 cp.stdout = stdout 40 cp.stderr = stderr 41 return cp 42 43 44 def _json_stdout(findings=None, summary=""): 45 return json.dumps({"findings": findings or [], "summary": summary}) 46 47 48 # --------------------------------------------------------------------------- 49 # Exit code → action mapping 50 # --------------------------------------------------------------------------- 51 52 class TestExitCodeMapping: 53 @patch("tools.tirith_security.subprocess.run") 54 @patch("tools.tirith_security._load_security_config") 55 def test_exit_0_allow(self, mock_cfg, mock_run): 56 mock_cfg.return_value = {"tirith_enabled": True, "tirith_path": "tirith", 57 "tirith_timeout": 5, "tirith_fail_open": True} 58 mock_run.return_value = _mock_run(0, _json_stdout()) 59 result = check_command_security("echo hello") 60 assert result["action"] == "allow" 61 assert result["findings"] == [] 62 63 @patch("tools.tirith_security.subprocess.run") 64 @patch("tools.tirith_security._load_security_config") 65 def test_exit_1_block_with_findings(self, mock_cfg, mock_run): 66 mock_cfg.return_value = {"tirith_enabled": True, "tirith_path": "tirith", 67 "tirith_timeout": 5, "tirith_fail_open": True} 68 findings = [{"rule_id": "homograph_url", "severity": "high"}] 69 mock_run.return_value = _mock_run(1, _json_stdout(findings, "homograph detected")) 70 result = check_command_security("curl http://gооgle.com") 71 assert result["action"] == "block" 72 assert len(result["findings"]) == 1 73 assert result["summary"] == "homograph detected" 74 75 @patch("tools.tirith_security.subprocess.run") 76 @patch("tools.tirith_security._load_security_config") 77 def test_exit_2_warn_with_findings(self, mock_cfg, mock_run): 78 mock_cfg.return_value = {"tirith_enabled": True, "tirith_path": "tirith", 79 "tirith_timeout": 5, "tirith_fail_open": True} 80 findings = [{"rule_id": "shortened_url", "severity": "medium"}] 81 mock_run.return_value = _mock_run(2, _json_stdout(findings, "shortened URL")) 82 result = check_command_security("curl https://bit.ly/abc") 83 assert result["action"] == "warn" 84 assert len(result["findings"]) == 1 85 assert result["summary"] == "shortened URL" 86 87 88 # --------------------------------------------------------------------------- 89 # JSON parse failure (exit code still wins) 90 # --------------------------------------------------------------------------- 91 92 class TestJsonParseFailure: 93 @patch("tools.tirith_security.subprocess.run") 94 @patch("tools.tirith_security._load_security_config") 95 def test_exit_1_invalid_json_still_blocks(self, mock_cfg, mock_run): 96 mock_cfg.return_value = {"tirith_enabled": True, "tirith_path": "tirith", 97 "tirith_timeout": 5, "tirith_fail_open": True} 98 mock_run.return_value = _mock_run(1, "NOT JSON") 99 result = check_command_security("bad command") 100 assert result["action"] == "block" 101 assert "details unavailable" in result["summary"] 102 103 @patch("tools.tirith_security.subprocess.run") 104 @patch("tools.tirith_security._load_security_config") 105 def test_exit_2_invalid_json_still_warns(self, mock_cfg, mock_run): 106 mock_cfg.return_value = {"tirith_enabled": True, "tirith_path": "tirith", 107 "tirith_timeout": 5, "tirith_fail_open": True} 108 mock_run.return_value = _mock_run(2, "{broken") 109 result = check_command_security("suspicious command") 110 assert result["action"] == "warn" 111 assert "details unavailable" in result["summary"] 112 113 @patch("tools.tirith_security.subprocess.run") 114 @patch("tools.tirith_security._load_security_config") 115 def test_exit_0_invalid_json_allows(self, mock_cfg, mock_run): 116 mock_cfg.return_value = {"tirith_enabled": True, "tirith_path": "tirith", 117 "tirith_timeout": 5, "tirith_fail_open": True} 118 mock_run.return_value = _mock_run(0, "NOT JSON") 119 result = check_command_security("safe command") 120 assert result["action"] == "allow" 121 122 123 # --------------------------------------------------------------------------- 124 # Operational failures + fail_open 125 # --------------------------------------------------------------------------- 126 127 class TestOSErrorFailOpen: 128 @patch("tools.tirith_security.subprocess.run") 129 @patch("tools.tirith_security._load_security_config") 130 def test_file_not_found_fail_open(self, mock_cfg, mock_run): 131 mock_cfg.return_value = {"tirith_enabled": True, "tirith_path": "tirith", 132 "tirith_timeout": 5, "tirith_fail_open": True} 133 mock_run.side_effect = FileNotFoundError("No such file: tirith") 134 result = check_command_security("echo hi") 135 assert result["action"] == "allow" 136 assert "unavailable" in result["summary"] 137 138 @patch("tools.tirith_security.subprocess.run") 139 @patch("tools.tirith_security._load_security_config") 140 def test_permission_error_fail_open(self, mock_cfg, mock_run): 141 mock_cfg.return_value = {"tirith_enabled": True, "tirith_path": "tirith", 142 "tirith_timeout": 5, "tirith_fail_open": True} 143 mock_run.side_effect = PermissionError("Permission denied") 144 result = check_command_security("echo hi") 145 assert result["action"] == "allow" 146 assert "unavailable" in result["summary"] 147 148 @patch("tools.tirith_security.subprocess.run") 149 @patch("tools.tirith_security._load_security_config") 150 def test_os_error_fail_closed(self, mock_cfg, mock_run): 151 mock_cfg.return_value = {"tirith_enabled": True, "tirith_path": "tirith", 152 "tirith_timeout": 5, "tirith_fail_open": False} 153 mock_run.side_effect = FileNotFoundError("No such file: tirith") 154 result = check_command_security("echo hi") 155 assert result["action"] == "block" 156 assert "fail-closed" in result["summary"] 157 158 159 class TestTimeoutFailOpen: 160 @patch("tools.tirith_security.subprocess.run") 161 @patch("tools.tirith_security._load_security_config") 162 def test_timeout_fail_open(self, mock_cfg, mock_run): 163 mock_cfg.return_value = {"tirith_enabled": True, "tirith_path": "tirith", 164 "tirith_timeout": 5, "tirith_fail_open": True} 165 mock_run.side_effect = subprocess.TimeoutExpired(cmd="tirith", timeout=5) 166 result = check_command_security("slow command") 167 assert result["action"] == "allow" 168 assert "timed out" in result["summary"] 169 170 @patch("tools.tirith_security.subprocess.run") 171 @patch("tools.tirith_security._load_security_config") 172 def test_timeout_fail_closed(self, mock_cfg, mock_run): 173 mock_cfg.return_value = {"tirith_enabled": True, "tirith_path": "tirith", 174 "tirith_timeout": 5, "tirith_fail_open": False} 175 mock_run.side_effect = subprocess.TimeoutExpired(cmd="tirith", timeout=5) 176 result = check_command_security("slow command") 177 assert result["action"] == "block" 178 assert "fail-closed" in result["summary"] 179 180 181 class TestUnknownExitCode: 182 @patch("tools.tirith_security.subprocess.run") 183 @patch("tools.tirith_security._load_security_config") 184 def test_unknown_exit_code_fail_open(self, mock_cfg, mock_run): 185 mock_cfg.return_value = {"tirith_enabled": True, "tirith_path": "tirith", 186 "tirith_timeout": 5, "tirith_fail_open": True} 187 mock_run.return_value = _mock_run(99, "") 188 result = check_command_security("cmd") 189 assert result["action"] == "allow" 190 assert "exit code 99" in result["summary"] 191 192 @patch("tools.tirith_security.subprocess.run") 193 @patch("tools.tirith_security._load_security_config") 194 def test_unknown_exit_code_fail_closed(self, mock_cfg, mock_run): 195 mock_cfg.return_value = {"tirith_enabled": True, "tirith_path": "tirith", 196 "tirith_timeout": 5, "tirith_fail_open": False} 197 mock_run.return_value = _mock_run(99, "") 198 result = check_command_security("cmd") 199 assert result["action"] == "block" 200 assert "exit code 99" in result["summary"] 201 202 203 # --------------------------------------------------------------------------- 204 # Disabled + path expansion 205 # --------------------------------------------------------------------------- 206 207 class TestDisabled: 208 @patch("tools.tirith_security._load_security_config") 209 def test_disabled_returns_allow(self, mock_cfg): 210 mock_cfg.return_value = {"tirith_enabled": False, "tirith_path": "tirith", 211 "tirith_timeout": 5, "tirith_fail_open": True} 212 result = check_command_security("rm -rf /") 213 assert result["action"] == "allow" 214 215 216 class TestPathExpansion: 217 def test_tilde_expanded_in_resolve(self): 218 """_resolve_tirith_path should expand ~ in configured path.""" 219 from tools.tirith_security import _resolve_tirith_path 220 _tirith_mod._resolved_path = None 221 # Explicit path — won't auto-download, just expands and caches miss 222 result = _resolve_tirith_path("~/bin/tirith") 223 assert "~" not in result, "tilde should be expanded" 224 _tirith_mod._resolved_path = None 225 226 227 # --------------------------------------------------------------------------- 228 # Findings cap + summary cap 229 # --------------------------------------------------------------------------- 230 231 class TestCaps: 232 @patch("tools.tirith_security.subprocess.run") 233 @patch("tools.tirith_security._load_security_config") 234 def test_findings_capped_at_50(self, mock_cfg, mock_run): 235 mock_cfg.return_value = {"tirith_enabled": True, "tirith_path": "tirith", 236 "tirith_timeout": 5, "tirith_fail_open": True} 237 findings = [{"rule_id": f"rule_{i}"} for i in range(100)] 238 mock_run.return_value = _mock_run(2, _json_stdout(findings, "many findings")) 239 result = check_command_security("cmd") 240 assert len(result["findings"]) == 50 241 242 @patch("tools.tirith_security.subprocess.run") 243 @patch("tools.tirith_security._load_security_config") 244 def test_summary_capped_at_500(self, mock_cfg, mock_run): 245 mock_cfg.return_value = {"tirith_enabled": True, "tirith_path": "tirith", 246 "tirith_timeout": 5, "tirith_fail_open": True} 247 long_summary = "x" * 1000 248 mock_run.return_value = _mock_run(2, _json_stdout([], long_summary)) 249 result = check_command_security("cmd") 250 assert len(result["summary"]) == 500 251 252 253 # --------------------------------------------------------------------------- 254 # Programming errors propagate 255 # --------------------------------------------------------------------------- 256 257 class TestProgrammingErrors: 258 @patch("tools.tirith_security.subprocess.run") 259 @patch("tools.tirith_security._load_security_config") 260 def test_attribute_error_propagates(self, mock_cfg, mock_run): 261 mock_cfg.return_value = {"tirith_enabled": True, "tirith_path": "tirith", 262 "tirith_timeout": 5, "tirith_fail_open": True} 263 mock_run.side_effect = AttributeError("unexpected bug") 264 with pytest.raises(AttributeError): 265 check_command_security("cmd") 266 267 @patch("tools.tirith_security.subprocess.run") 268 @patch("tools.tirith_security._load_security_config") 269 def test_type_error_propagates(self, mock_cfg, mock_run): 270 mock_cfg.return_value = {"tirith_enabled": True, "tirith_path": "tirith", 271 "tirith_timeout": 5, "tirith_fail_open": True} 272 mock_run.side_effect = TypeError("unexpected bug") 273 with pytest.raises(TypeError): 274 check_command_security("cmd") 275 276 277 # --------------------------------------------------------------------------- 278 # ensure_installed 279 # --------------------------------------------------------------------------- 280 281 class TestEnsureInstalled: 282 @patch("tools.tirith_security._load_security_config") 283 def test_disabled_returns_none(self, mock_cfg): 284 mock_cfg.return_value = {"tirith_enabled": False, "tirith_path": "tirith", 285 "tirith_timeout": 5, "tirith_fail_open": True} 286 _tirith_mod._resolved_path = None 287 assert ensure_installed() is None 288 289 @patch("tools.tirith_security.shutil.which", return_value="/usr/local/bin/tirith") 290 @patch("tools.tirith_security._load_security_config") 291 def test_found_on_path_returns_immediately(self, mock_cfg, mock_which): 292 mock_cfg.return_value = {"tirith_enabled": True, "tirith_path": "tirith", 293 "tirith_timeout": 5, "tirith_fail_open": True} 294 _tirith_mod._resolved_path = None 295 with patch("os.path.isfile", return_value=True), \ 296 patch("os.access", return_value=True): 297 result = ensure_installed() 298 assert result == "/usr/local/bin/tirith" 299 _tirith_mod._resolved_path = None 300 301 @patch("tools.tirith_security._load_security_config") 302 def test_not_found_returns_none(self, mock_cfg): 303 mock_cfg.return_value = {"tirith_enabled": True, "tirith_path": "tirith", 304 "tirith_timeout": 5, "tirith_fail_open": True} 305 _tirith_mod._resolved_path = None 306 with patch("tools.tirith_security.shutil.which", return_value=None), \ 307 patch("tools.tirith_security._hermes_bin_dir", return_value="/nonexistent"), \ 308 patch("tools.tirith_security._is_install_failed_on_disk", return_value=False), \ 309 patch("tools.tirith_security.threading.Thread") as MockThread: 310 mock_thread = MagicMock() 311 MockThread.return_value = mock_thread 312 result = ensure_installed() 313 assert result is None 314 # Should have launched background thread 315 mock_thread.start.assert_called_once() 316 _tirith_mod._resolved_path = None 317 318 @patch("tools.tirith_security._load_security_config") 319 def test_startup_prefetch_can_suppress_install_failure_logs(self, mock_cfg): 320 mock_cfg.return_value = {"tirith_enabled": True, "tirith_path": "tirith", 321 "tirith_timeout": 5, "tirith_fail_open": True} 322 _tirith_mod._resolved_path = None 323 with patch("tools.tirith_security.shutil.which", return_value=None), \ 324 patch("tools.tirith_security._hermes_bin_dir", return_value="/nonexistent"), \ 325 patch("tools.tirith_security._is_install_failed_on_disk", return_value=False), \ 326 patch("tools.tirith_security.threading.Thread") as MockThread: 327 mock_thread = MagicMock() 328 MockThread.return_value = mock_thread 329 result = ensure_installed(log_failures=False) 330 assert result is None 331 assert MockThread.call_args.kwargs["kwargs"] == {"log_failures": False} 332 mock_thread.start.assert_called_once() 333 _tirith_mod._resolved_path = None 334 335 336 # --------------------------------------------------------------------------- 337 # Failed download caches the miss (Finding #1) 338 # --------------------------------------------------------------------------- 339 340 class TestFailedDownloadCaching: 341 @patch("tools.tirith_security._mark_install_failed") 342 @patch("tools.tirith_security._is_install_failed_on_disk", return_value=False) 343 @patch("tools.tirith_security._install_tirith", return_value=(None, "download_failed")) 344 @patch("tools.tirith_security.shutil.which", return_value=None) 345 def test_failed_install_cached_no_retry(self, mock_which, mock_install, 346 mock_disk_check, mock_mark): 347 """After a failed download, subsequent resolves must not retry.""" 348 from tools.tirith_security import _resolve_tirith_path, _INSTALL_FAILED 349 _tirith_mod._resolved_path = None 350 351 # First call: tries install, fails 352 _resolve_tirith_path("tirith") 353 assert mock_install.call_count == 1 354 assert _tirith_mod._resolved_path is _INSTALL_FAILED 355 mock_mark.assert_called_once_with("download_failed") # reason persisted 356 357 # Second call: hits the cache, does NOT call _install_tirith again 358 _resolve_tirith_path("tirith") 359 assert mock_install.call_count == 1 # still 1, not 2 360 361 _tirith_mod._resolved_path = None 362 363 @patch("tools.tirith_security._mark_install_failed") 364 @patch("tools.tirith_security._is_install_failed_on_disk", return_value=False) 365 @patch("tools.tirith_security._install_tirith", return_value=(None, "download_failed")) 366 @patch("tools.tirith_security.shutil.which", return_value=None) 367 @patch("tools.tirith_security.subprocess.run") 368 @patch("tools.tirith_security._load_security_config") 369 def test_failed_install_scan_uses_fail_open(self, mock_cfg, mock_run, 370 mock_which, mock_install, 371 mock_disk_check, mock_mark): 372 """After cached miss, check_command_security hits OSError → fail_open.""" 373 _tirith_mod._resolved_path = None 374 mock_cfg.return_value = {"tirith_enabled": True, "tirith_path": "tirith", 375 "tirith_timeout": 5, "tirith_fail_open": True} 376 mock_run.side_effect = FileNotFoundError("No such file: tirith") 377 # First command triggers install attempt + cached miss + scan 378 result = check_command_security("echo hello") 379 assert result["action"] == "allow" 380 assert mock_install.call_count == 1 381 382 # Second command: no install retry, just hits OSError → allow 383 result = check_command_security("echo world") 384 assert result["action"] == "allow" 385 assert mock_install.call_count == 1 # still 1 386 387 _tirith_mod._resolved_path = None 388 389 390 # --------------------------------------------------------------------------- 391 # Explicit path must not auto-download (Finding #2) 392 # --------------------------------------------------------------------------- 393 394 class TestExplicitPathNoAutoDownload: 395 @patch("tools.tirith_security._install_tirith") 396 @patch("tools.tirith_security.shutil.which", return_value=None) 397 def test_explicit_path_missing_no_download(self, mock_which, mock_install): 398 """An explicit tirith_path that doesn't exist must NOT trigger download.""" 399 from tools.tirith_security import _resolve_tirith_path, _INSTALL_FAILED 400 _tirith_mod._resolved_path = None 401 402 result = _resolve_tirith_path("/opt/custom/tirith") 403 # Should cache failure, not call _install_tirith 404 mock_install.assert_not_called() 405 assert _tirith_mod._resolved_path is _INSTALL_FAILED 406 assert "/opt/custom/tirith" in result 407 408 _tirith_mod._resolved_path = None 409 410 @patch("tools.tirith_security._install_tirith") 411 @patch("tools.tirith_security.shutil.which", return_value=None) 412 def test_tilde_explicit_path_missing_no_download(self, mock_which, mock_install): 413 """An explicit ~/path that doesn't exist must NOT trigger download.""" 414 from tools.tirith_security import _resolve_tirith_path, _INSTALL_FAILED 415 _tirith_mod._resolved_path = None 416 417 result = _resolve_tirith_path("~/bin/tirith") 418 mock_install.assert_not_called() 419 assert _tirith_mod._resolved_path is _INSTALL_FAILED 420 assert "~" not in result # tilde still expanded 421 422 _tirith_mod._resolved_path = None 423 424 @patch("tools.tirith_security._mark_install_failed") 425 @patch("tools.tirith_security._is_install_failed_on_disk", return_value=False) 426 @patch("tools.tirith_security._install_tirith", return_value=("/auto/tirith", "")) 427 @patch("tools.tirith_security.shutil.which", return_value=None) 428 def test_default_path_does_auto_download(self, mock_which, mock_install, 429 mock_disk_check, mock_mark): 430 """The default bare 'tirith' SHOULD trigger auto-download.""" 431 from tools.tirith_security import _resolve_tirith_path 432 _tirith_mod._resolved_path = None 433 434 result = _resolve_tirith_path("tirith") 435 mock_install.assert_called_once() 436 assert result == "/auto/tirith" 437 438 _tirith_mod._resolved_path = None 439 440 441 # --------------------------------------------------------------------------- 442 # Cosign provenance verification (P1) 443 # --------------------------------------------------------------------------- 444 445 class TestCosignVerification: 446 @patch("tools.tirith_security.subprocess.run") 447 @patch("tools.tirith_security.shutil.which", return_value="/usr/bin/cosign") 448 def test_cosign_pass(self, mock_which, mock_run): 449 """cosign verify-blob exits 0 → returns True.""" 450 from tools.tirith_security import _verify_cosign 451 mock_run.return_value = _mock_run(0, "Verified OK") 452 result = _verify_cosign("/tmp/checksums.txt", "/tmp/checksums.txt.sig", 453 "/tmp/checksums.txt.pem") 454 assert result is True 455 mock_run.assert_called_once() 456 args = mock_run.call_args[0][0] 457 assert "verify-blob" in args 458 assert "--certificate-identity-regexp" in args 459 460 @patch("tools.tirith_security.subprocess.run") 461 @patch("tools.tirith_security.shutil.which", return_value="/usr/bin/cosign") 462 def test_cosign_identity_pinned_to_release_workflow(self, mock_which, mock_run): 463 """Identity regexp must pin to the release workflow, not the whole repo.""" 464 from tools.tirith_security import _verify_cosign 465 mock_run.return_value = _mock_run(0, "Verified OK") 466 _verify_cosign("/tmp/checksums.txt", "/tmp/sig", "/tmp/cert") 467 args = mock_run.call_args[0][0] 468 # Find the value after --certificate-identity-regexp 469 idx = args.index("--certificate-identity-regexp") 470 identity = args[idx + 1] 471 # The identity contains regex-escaped dots 472 assert "workflows/release" in identity 473 assert "refs/tags/v" in identity 474 475 @patch("tools.tirith_security.subprocess.run") 476 @patch("tools.tirith_security.shutil.which", return_value="/usr/bin/cosign") 477 def test_cosign_fail_aborts(self, mock_which, mock_run): 478 """cosign verify-blob exits non-zero → returns False (abort install).""" 479 from tools.tirith_security import _verify_cosign 480 mock_run.return_value = _mock_run(1, "", "signature mismatch") 481 result = _verify_cosign("/tmp/checksums.txt", "/tmp/checksums.txt.sig", 482 "/tmp/checksums.txt.pem") 483 assert result is False 484 485 @patch("tools.tirith_security.shutil.which", return_value=None) 486 def test_cosign_not_found_returns_none(self, mock_which): 487 """cosign not on PATH → returns None (proceed with SHA-256 only).""" 488 from tools.tirith_security import _verify_cosign 489 result = _verify_cosign("/tmp/checksums.txt", "/tmp/checksums.txt.sig", 490 "/tmp/checksums.txt.pem") 491 assert result is None 492 493 @patch("tools.tirith_security.subprocess.run", 494 side_effect=subprocess.TimeoutExpired("cosign", 15)) 495 @patch("tools.tirith_security.shutil.which", return_value="/usr/bin/cosign") 496 def test_cosign_timeout_returns_none(self, mock_which, mock_run): 497 """cosign times out → returns None (proceed with SHA-256 only).""" 498 from tools.tirith_security import _verify_cosign 499 result = _verify_cosign("/tmp/checksums.txt", "/tmp/checksums.txt.sig", 500 "/tmp/checksums.txt.pem") 501 assert result is None 502 503 @patch("tools.tirith_security.subprocess.run", 504 side_effect=OSError("exec format error")) 505 @patch("tools.tirith_security.shutil.which", return_value="/usr/bin/cosign") 506 def test_cosign_os_error_returns_none(self, mock_which, mock_run): 507 """cosign OSError → returns None (proceed with SHA-256 only).""" 508 from tools.tirith_security import _verify_cosign 509 result = _verify_cosign("/tmp/checksums.txt", "/tmp/checksums.txt.sig", 510 "/tmp/checksums.txt.pem") 511 assert result is None 512 513 @patch("tools.tirith_security._verify_cosign", return_value=False) 514 @patch("tools.tirith_security.shutil.which", return_value="/usr/local/bin/cosign") 515 @patch("tools.tirith_security._download_file") 516 @patch("tools.tirith_security._detect_target", return_value="aarch64-apple-darwin") 517 def test_install_aborts_on_cosign_rejection(self, mock_target, mock_dl, 518 mock_which, mock_cosign): 519 """_install_tirith returns None when cosign rejects the signature.""" 520 from tools.tirith_security import _install_tirith 521 path, reason = _install_tirith() 522 assert path is None 523 assert reason == "cosign_verification_failed" 524 525 @patch("tools.tirith_security.tarfile.open") 526 @patch("tools.tirith_security._verify_checksum", return_value=True) 527 @patch("tools.tirith_security.shutil.which", return_value=None) 528 @patch("tools.tirith_security._download_file") 529 @patch("tools.tirith_security._detect_target", return_value="aarch64-apple-darwin") 530 def test_install_proceeds_without_cosign(self, mock_target, mock_dl, 531 mock_which, mock_checksum, 532 mock_tarfile): 533 """_install_tirith proceeds with SHA-256 only when cosign is not on PATH.""" 534 from tools.tirith_security import _install_tirith 535 mock_tar = MagicMock() 536 mock_tar.__enter__ = MagicMock(return_value=mock_tar) 537 mock_tar.__exit__ = MagicMock(return_value=False) 538 mock_tar.getmembers.return_value = [] 539 mock_tarfile.return_value = mock_tar 540 541 path, reason = _install_tirith() 542 # Reaches extraction (no binary in mock archive), but got past cosign 543 assert path is None 544 assert reason == "binary_not_in_archive" 545 assert mock_checksum.called # SHA-256 verification ran 546 547 @patch("tools.tirith_security.tarfile.open") 548 @patch("tools.tirith_security._verify_checksum", return_value=True) 549 @patch("tools.tirith_security._verify_cosign", return_value=None) 550 @patch("tools.tirith_security.shutil.which", return_value="/usr/local/bin/cosign") 551 @patch("tools.tirith_security._download_file") 552 @patch("tools.tirith_security._detect_target", return_value="aarch64-apple-darwin") 553 def test_install_proceeds_when_cosign_exec_fails(self, mock_target, mock_dl, 554 mock_which, mock_cosign, 555 mock_checksum, mock_tarfile): 556 """_install_tirith falls back to SHA-256 when cosign exists but fails to execute.""" 557 from tools.tirith_security import _install_tirith 558 mock_tar = MagicMock() 559 mock_tar.__enter__ = MagicMock(return_value=mock_tar) 560 mock_tar.__exit__ = MagicMock(return_value=False) 561 mock_tar.getmembers.return_value = [] 562 mock_tarfile.return_value = mock_tar 563 564 path, reason = _install_tirith() 565 assert path is None 566 assert reason == "binary_not_in_archive" # got past cosign 567 assert mock_checksum.called 568 569 @patch("tools.tirith_security.tarfile.open") 570 @patch("tools.tirith_security._verify_checksum", return_value=True) 571 @patch("tools.tirith_security.shutil.which", return_value="/usr/local/bin/cosign") 572 @patch("tools.tirith_security._download_file") 573 @patch("tools.tirith_security._detect_target", return_value="aarch64-apple-darwin") 574 def test_install_proceeds_when_cosign_artifacts_missing(self, mock_target, 575 mock_dl, mock_which, 576 mock_checksum, mock_tarfile): 577 """_install_tirith proceeds with SHA-256 when .sig/.pem downloads fail.""" 578 from tools.tirith_security import _install_tirith 579 import urllib.request 580 581 def _dl_side_effect(url, dest, timeout=10): 582 if url.endswith(".sig") or url.endswith(".pem"): 583 raise urllib.request.URLError("404 Not Found") 584 585 mock_dl.side_effect = _dl_side_effect 586 mock_tar = MagicMock() 587 mock_tar.__enter__ = MagicMock(return_value=mock_tar) 588 mock_tar.__exit__ = MagicMock(return_value=False) 589 mock_tar.getmembers.return_value = [] 590 mock_tarfile.return_value = mock_tar 591 592 path, reason = _install_tirith() 593 assert path is None 594 assert reason == "binary_not_in_archive" # got past cosign 595 assert mock_checksum.called 596 597 @patch("tools.tirith_security.tarfile.open") 598 @patch("tools.tirith_security._verify_checksum", return_value=True) 599 @patch("tools.tirith_security._verify_cosign", return_value=True) 600 @patch("tools.tirith_security.shutil.which", return_value="/usr/local/bin/cosign") 601 @patch("tools.tirith_security._download_file") 602 @patch("tools.tirith_security._detect_target", return_value="aarch64-apple-darwin") 603 def test_install_proceeds_when_cosign_passes(self, mock_target, mock_dl, 604 mock_which, mock_cosign, 605 mock_checksum, mock_tarfile): 606 """_install_tirith proceeds only when cosign explicitly passes (True).""" 607 from tools.tirith_security import _install_tirith 608 # Mock tarfile — empty archive means "binary not found" return 609 mock_tar = MagicMock() 610 mock_tar.__enter__ = MagicMock(return_value=mock_tar) 611 mock_tar.__exit__ = MagicMock(return_value=False) 612 mock_tar.getmembers.return_value = [] 613 mock_tarfile.return_value = mock_tar 614 615 path, reason = _install_tirith() 616 assert path is None # no binary in mock archive, but got past cosign 617 assert reason == "binary_not_in_archive" 618 assert mock_checksum.called # reached SHA-256 step 619 assert mock_cosign.called # cosign was invoked 620 621 622 # --------------------------------------------------------------------------- 623 # Background install / non-blocking startup (P2) 624 # --------------------------------------------------------------------------- 625 626 class TestBackgroundInstall: 627 def test_ensure_installed_non_blocking(self): 628 """ensure_installed must return immediately when download needed.""" 629 _tirith_mod._resolved_path = None 630 631 with patch("tools.tirith_security._load_security_config", 632 return_value={"tirith_enabled": True, "tirith_path": "tirith", 633 "tirith_timeout": 5, "tirith_fail_open": True}), \ 634 patch("tools.tirith_security.shutil.which", return_value=None), \ 635 patch("tools.tirith_security._hermes_bin_dir", return_value="/nonexistent"), \ 636 patch("tools.tirith_security._is_install_failed_on_disk", return_value=False), \ 637 patch("tools.tirith_security.threading.Thread") as MockThread: 638 mock_thread = MagicMock() 639 mock_thread.is_alive.return_value = False 640 MockThread.return_value = mock_thread 641 642 result = ensure_installed() 643 assert result is None # not available yet 644 MockThread.assert_called_once() 645 mock_thread.start.assert_called_once() 646 647 _tirith_mod._resolved_path = None 648 649 def test_ensure_installed_skips_on_disk_marker(self): 650 """ensure_installed skips network attempt when disk marker exists.""" 651 _tirith_mod._resolved_path = None 652 653 with patch("tools.tirith_security._load_security_config", 654 return_value={"tirith_enabled": True, "tirith_path": "tirith", 655 "tirith_timeout": 5, "tirith_fail_open": True}), \ 656 patch("tools.tirith_security.shutil.which", return_value=None), \ 657 patch("tools.tirith_security._hermes_bin_dir", return_value="/nonexistent"), \ 658 patch("tools.tirith_security._read_failure_reason", return_value="download_failed"), \ 659 patch("tools.tirith_security._is_install_failed_on_disk", return_value=True): 660 661 result = ensure_installed() 662 assert result is None 663 assert _tirith_mod._resolved_path is _tirith_mod._INSTALL_FAILED 664 assert _tirith_mod._install_failure_reason == "download_failed" 665 666 _tirith_mod._resolved_path = None 667 668 def test_resolve_returns_default_when_thread_alive(self): 669 """_resolve_tirith_path returns default while background thread runs.""" 670 from tools.tirith_security import _resolve_tirith_path 671 _tirith_mod._resolved_path = None 672 mock_thread = MagicMock() 673 mock_thread.is_alive.return_value = True 674 _tirith_mod._install_thread = mock_thread 675 676 with patch("tools.tirith_security.shutil.which", return_value=None), \ 677 patch("tools.tirith_security._hermes_bin_dir", return_value="/nonexistent"): 678 result = _resolve_tirith_path("tirith") 679 assert result == "tirith" # returns configured default, doesn't block 680 681 _tirith_mod._install_thread = None 682 _tirith_mod._resolved_path = None 683 684 def test_resolve_picks_up_background_result(self): 685 """After background thread finishes, _resolve_tirith_path uses cached path.""" 686 from tools.tirith_security import _resolve_tirith_path 687 # Simulate background thread having completed and set the path 688 _tirith_mod._resolved_path = "/usr/local/bin/tirith" 689 690 result = _resolve_tirith_path("tirith") 691 assert result == "/usr/local/bin/tirith" 692 693 _tirith_mod._resolved_path = None 694 695 696 # --------------------------------------------------------------------------- 697 # Disk failure marker persistence (P2) 698 # --------------------------------------------------------------------------- 699 700 class TestDiskFailureMarker: 701 def test_mark_and_check(self): 702 """Writing then reading the marker should work.""" 703 import tempfile 704 tmpdir = tempfile.mkdtemp() 705 marker = os.path.join(tmpdir, ".tirith-install-failed") 706 with patch("tools.tirith_security._failure_marker_path", return_value=marker): 707 from tools.tirith_security import ( 708 _mark_install_failed, _is_install_failed_on_disk, _clear_install_failed, 709 ) 710 assert not _is_install_failed_on_disk() 711 _mark_install_failed("download_failed") 712 assert _is_install_failed_on_disk() 713 _clear_install_failed() 714 assert not _is_install_failed_on_disk() 715 716 def test_expired_marker_ignored(self): 717 """Marker older than TTL should be ignored.""" 718 import tempfile 719 tmpdir = tempfile.mkdtemp() 720 marker = os.path.join(tmpdir, ".tirith-install-failed") 721 with patch("tools.tirith_security._failure_marker_path", return_value=marker): 722 from tools.tirith_security import _mark_install_failed, _is_install_failed_on_disk 723 _mark_install_failed("download_failed") 724 # Backdate the file past 24h TTL 725 old_time = time.time() - 90000 # 25 hours ago 726 os.utime(marker, (old_time, old_time)) 727 assert not _is_install_failed_on_disk() 728 729 def test_cosign_missing_marker_clears_when_cosign_appears(self): 730 """Marker with 'cosign_missing' reason clears if cosign is now on PATH.""" 731 import tempfile 732 tmpdir = tempfile.mkdtemp() 733 marker = os.path.join(tmpdir, ".tirith-install-failed") 734 with patch("tools.tirith_security._failure_marker_path", return_value=marker): 735 from tools.tirith_security import _mark_install_failed, _is_install_failed_on_disk 736 _mark_install_failed("cosign_missing") 737 assert _is_install_failed_on_disk() # cosign still absent 738 739 # Now cosign appears on PATH 740 with patch("tools.tirith_security.shutil.which", return_value="/usr/local/bin/cosign"): 741 assert not _is_install_failed_on_disk() 742 # Marker file should have been removed 743 assert not os.path.exists(marker) 744 745 def test_cosign_missing_marker_stays_when_cosign_still_absent(self): 746 """Marker with 'cosign_missing' reason stays if cosign is still missing.""" 747 import tempfile 748 tmpdir = tempfile.mkdtemp() 749 marker = os.path.join(tmpdir, ".tirith-install-failed") 750 with patch("tools.tirith_security._failure_marker_path", return_value=marker): 751 from tools.tirith_security import _mark_install_failed, _is_install_failed_on_disk 752 _mark_install_failed("cosign_missing") 753 with patch("tools.tirith_security.shutil.which", return_value=None): 754 assert _is_install_failed_on_disk() 755 756 def test_non_cosign_marker_not_affected_by_cosign_presence(self): 757 """Markers with other reasons are NOT cleared by cosign appearing.""" 758 import tempfile 759 tmpdir = tempfile.mkdtemp() 760 marker = os.path.join(tmpdir, ".tirith-install-failed") 761 with patch("tools.tirith_security._failure_marker_path", return_value=marker): 762 from tools.tirith_security import _mark_install_failed, _is_install_failed_on_disk 763 _mark_install_failed("download_failed") 764 with patch("tools.tirith_security.shutil.which", return_value="/usr/local/bin/cosign"): 765 assert _is_install_failed_on_disk() # still failed 766 767 @patch("tools.tirith_security._mark_install_failed") 768 @patch("tools.tirith_security._is_install_failed_on_disk", return_value=False) 769 @patch("tools.tirith_security._install_tirith", return_value=(None, "cosign_missing")) 770 @patch("tools.tirith_security.shutil.which", return_value=None) 771 def test_sync_resolve_persists_failure(self, mock_which, mock_install, 772 mock_disk_check, mock_mark): 773 """Synchronous _resolve_tirith_path persists failure to disk.""" 774 from tools.tirith_security import _resolve_tirith_path 775 _tirith_mod._resolved_path = None 776 777 _resolve_tirith_path("tirith") 778 mock_mark.assert_called_once_with("cosign_missing") 779 780 _tirith_mod._resolved_path = None 781 782 @patch("tools.tirith_security._clear_install_failed") 783 @patch("tools.tirith_security._is_install_failed_on_disk", return_value=False) 784 @patch("tools.tirith_security._install_tirith", return_value=("/installed/tirith", "")) 785 @patch("tools.tirith_security.shutil.which", return_value=None) 786 def test_sync_resolve_clears_marker_on_success(self, mock_which, mock_install, 787 mock_disk_check, mock_clear): 788 """Successful install clears the disk failure marker.""" 789 from tools.tirith_security import _resolve_tirith_path 790 _tirith_mod._resolved_path = None 791 792 result = _resolve_tirith_path("tirith") 793 assert result == "/installed/tirith" 794 mock_clear.assert_called_once() 795 796 _tirith_mod._resolved_path = None 797 798 def test_sync_resolve_skips_install_on_disk_marker(self): 799 """_resolve_tirith_path skips download when disk marker is recent.""" 800 from tools.tirith_security import _resolve_tirith_path, _INSTALL_FAILED 801 _tirith_mod._resolved_path = None 802 803 with patch("tools.tirith_security.shutil.which", return_value=None), \ 804 patch("tools.tirith_security._hermes_bin_dir", return_value="/nonexistent"), \ 805 patch("tools.tirith_security._read_failure_reason", return_value="download_failed"), \ 806 patch("tools.tirith_security._is_install_failed_on_disk", return_value=True), \ 807 patch("tools.tirith_security._install_tirith") as mock_install: 808 _resolve_tirith_path("tirith") 809 mock_install.assert_not_called() 810 assert _tirith_mod._resolved_path is _INSTALL_FAILED 811 assert _tirith_mod._install_failure_reason == "download_failed" 812 813 _tirith_mod._resolved_path = None 814 815 def test_install_failed_still_checks_local_paths(self): 816 """After _INSTALL_FAILED, a manual install on PATH is picked up.""" 817 from tools.tirith_security import _resolve_tirith_path, _INSTALL_FAILED 818 _tirith_mod._resolved_path = _INSTALL_FAILED 819 820 with patch("tools.tirith_security.shutil.which", return_value="/usr/local/bin/tirith"), \ 821 patch("tools.tirith_security._clear_install_failed") as mock_clear: 822 result = _resolve_tirith_path("tirith") 823 assert result == "/usr/local/bin/tirith" 824 assert _tirith_mod._resolved_path == "/usr/local/bin/tirith" 825 mock_clear.assert_called_once() 826 827 _tirith_mod._resolved_path = None 828 829 def test_install_failed_recovers_from_hermes_bin(self): 830 """After _INSTALL_FAILED, manual install in HERMES_HOME/bin is picked up.""" 831 from tools.tirith_security import _resolve_tirith_path, _INSTALL_FAILED 832 import tempfile 833 tmpdir = tempfile.mkdtemp() 834 hermes_bin = os.path.join(tmpdir, "tirith") 835 # Create a fake executable 836 with open(hermes_bin, "w") as f: 837 f.write("#!/bin/sh\n") 838 os.chmod(hermes_bin, 0o755) 839 840 _tirith_mod._resolved_path = _INSTALL_FAILED 841 842 with patch("tools.tirith_security.shutil.which", return_value=None), \ 843 patch("tools.tirith_security._hermes_bin_dir", return_value=tmpdir), \ 844 patch("tools.tirith_security._clear_install_failed") as mock_clear: 845 result = _resolve_tirith_path("tirith") 846 assert result == hermes_bin 847 assert _tirith_mod._resolved_path == hermes_bin 848 mock_clear.assert_called_once() 849 850 _tirith_mod._resolved_path = None 851 852 def test_install_failed_skips_network_when_local_absent(self): 853 """After _INSTALL_FAILED, if local checks fail, network is NOT retried.""" 854 from tools.tirith_security import _resolve_tirith_path, _INSTALL_FAILED 855 _tirith_mod._resolved_path = _INSTALL_FAILED 856 857 with patch("tools.tirith_security.shutil.which", return_value=None), \ 858 patch("tools.tirith_security._hermes_bin_dir", return_value="/nonexistent"), \ 859 patch("tools.tirith_security._install_tirith") as mock_install: 860 result = _resolve_tirith_path("tirith") 861 assert result == "tirith" # fallback to configured path 862 mock_install.assert_not_called() 863 864 _tirith_mod._resolved_path = None 865 866 def test_cosign_missing_disk_marker_allows_retry(self): 867 """Disk marker with cosign_missing reason allows retry when cosign appears.""" 868 from tools.tirith_security import _resolve_tirith_path, _INSTALL_FAILED 869 _tirith_mod._resolved_path = None 870 871 # _is_install_failed_on_disk sees "cosign_missing" + cosign on PATH → returns False 872 with patch("tools.tirith_security.shutil.which", return_value=None), \ 873 patch("tools.tirith_security._hermes_bin_dir", return_value="/nonexistent"), \ 874 patch("tools.tirith_security._is_install_failed_on_disk", return_value=False), \ 875 patch("tools.tirith_security._install_tirith", return_value=("/new/tirith", "")) as mock_install, \ 876 patch("tools.tirith_security._clear_install_failed"): 877 result = _resolve_tirith_path("tirith") 878 mock_install.assert_called_once() # network retry happened 879 assert result == "/new/tirith" 880 881 _tirith_mod._resolved_path = None 882 883 def test_in_memory_cosign_missing_retries_when_cosign_appears(self): 884 """In-memory _INSTALL_FAILED with cosign_missing retries when cosign appears.""" 885 from tools.tirith_security import _resolve_tirith_path, _INSTALL_FAILED 886 _tirith_mod._resolved_path = _INSTALL_FAILED 887 _tirith_mod._install_failure_reason = "cosign_missing" 888 889 def _which_side_effect(name): 890 if name == "tirith": 891 return None # tirith not on PATH 892 if name == "cosign": 893 return "/usr/local/bin/cosign" # cosign now available 894 return None 895 896 with patch("tools.tirith_security.shutil.which", side_effect=_which_side_effect), \ 897 patch("tools.tirith_security._hermes_bin_dir", return_value="/nonexistent"), \ 898 patch("tools.tirith_security._is_install_failed_on_disk", return_value=False), \ 899 patch("tools.tirith_security._install_tirith", return_value=("/new/tirith", "")) as mock_install, \ 900 patch("tools.tirith_security._clear_install_failed"): 901 result = _resolve_tirith_path("tirith") 902 mock_install.assert_called_once() # network retry happened 903 assert result == "/new/tirith" 904 905 _tirith_mod._resolved_path = None 906 907 def test_in_memory_cosign_exec_failed_not_retried(self): 908 """In-memory _INSTALL_FAILED with cosign_exec_failed is NOT retried.""" 909 from tools.tirith_security import _resolve_tirith_path, _INSTALL_FAILED 910 _tirith_mod._resolved_path = _INSTALL_FAILED 911 _tirith_mod._install_failure_reason = "cosign_exec_failed" 912 913 with patch("tools.tirith_security.shutil.which", return_value=None), \ 914 patch("tools.tirith_security._hermes_bin_dir", return_value="/nonexistent"), \ 915 patch("tools.tirith_security._install_tirith") as mock_install: 916 result = _resolve_tirith_path("tirith") 917 assert result == "tirith" # fallback 918 mock_install.assert_not_called() 919 920 _tirith_mod._resolved_path = None 921 922 def test_in_memory_cosign_missing_stays_when_cosign_still_absent(self): 923 """In-memory cosign_missing is NOT retried when cosign is still absent.""" 924 from tools.tirith_security import _resolve_tirith_path, _INSTALL_FAILED 925 _tirith_mod._resolved_path = _INSTALL_FAILED 926 _tirith_mod._install_failure_reason = "cosign_missing" 927 928 with patch("tools.tirith_security.shutil.which", return_value=None), \ 929 patch("tools.tirith_security._hermes_bin_dir", return_value="/nonexistent"), \ 930 patch("tools.tirith_security._install_tirith") as mock_install: 931 result = _resolve_tirith_path("tirith") 932 assert result == "tirith" # fallback 933 mock_install.assert_not_called() 934 935 _tirith_mod._resolved_path = None 936 937 def test_disk_marker_reason_preserved_in_memory(self): 938 """Disk marker reason is loaded into _install_failure_reason, not a generic tag.""" 939 from tools.tirith_security import _resolve_tirith_path, _INSTALL_FAILED 940 _tirith_mod._resolved_path = None 941 942 # First call: disk marker with cosign_missing is active, cosign still absent 943 with patch("tools.tirith_security.shutil.which", return_value=None), \ 944 patch("tools.tirith_security._hermes_bin_dir", return_value="/nonexistent"), \ 945 patch("tools.tirith_security._read_failure_reason", return_value="cosign_missing"), \ 946 patch("tools.tirith_security._is_install_failed_on_disk", return_value=True): 947 _resolve_tirith_path("tirith") 948 assert _tirith_mod._resolved_path is _INSTALL_FAILED 949 assert _tirith_mod._install_failure_reason == "cosign_missing" 950 951 # Second call: cosign now on PATH → in-memory retry fires 952 def _which_side_effect(name): 953 if name == "tirith": 954 return None 955 if name == "cosign": 956 return "/usr/local/bin/cosign" 957 return None 958 959 with patch("tools.tirith_security.shutil.which", side_effect=_which_side_effect), \ 960 patch("tools.tirith_security._hermes_bin_dir", return_value="/nonexistent"), \ 961 patch("tools.tirith_security._is_install_failed_on_disk", return_value=False), \ 962 patch("tools.tirith_security._install_tirith", return_value=("/new/tirith", "")) as mock_install, \ 963 patch("tools.tirith_security._clear_install_failed"): 964 result = _resolve_tirith_path("tirith") 965 mock_install.assert_called_once() 966 assert result == "/new/tirith" 967 968 _tirith_mod._resolved_path = None 969 970 971 # --------------------------------------------------------------------------- 972 # HERMES_HOME isolation 973 # --------------------------------------------------------------------------- 974 975 class TestHermesHomeIsolation: 976 def test_hermes_bin_dir_respects_hermes_home(self): 977 """_hermes_bin_dir must use HERMES_HOME, not hardcoded ~/.hermes.""" 978 from tools.tirith_security import _hermes_bin_dir 979 import tempfile 980 tmpdir = tempfile.mkdtemp() 981 with patch.dict(os.environ, {"HERMES_HOME": tmpdir}): 982 result = _hermes_bin_dir() 983 assert result == os.path.join(tmpdir, "bin") 984 assert os.path.isdir(result) 985 986 def test_failure_marker_respects_hermes_home(self): 987 """_failure_marker_path must use HERMES_HOME, not hardcoded ~/.hermes.""" 988 from tools.tirith_security import _failure_marker_path 989 with patch.dict(os.environ, {"HERMES_HOME": "/custom/hermes"}): 990 result = _failure_marker_path() 991 assert result == "/custom/hermes/.tirith-install-failed" 992 993 def test_conftest_isolation_prevents_real_home_writes(self): 994 """The conftest autouse fixture sets HERMES_HOME; verify it's active.""" 995 hermes_home = os.getenv("HERMES_HOME") 996 assert hermes_home is not None, "HERMES_HOME should be set by conftest" 997 assert "hermes_test" in hermes_home, "Should point to test temp dir" 998 999 def test_get_hermes_home_fallback(self): 1000 """Without HERMES_HOME set, falls back to the active OS home.""" 1001 from tools.tirith_security import _get_hermes_home 1002 with patch.dict(os.environ, {}, clear=True): 1003 # Remove HERMES_HOME entirely. With HOME also absent, expanduser 1004 # falls back to the account database; compute expected under the 1005 # same environment instead of after patch.dict restores HOME. 1006 os.environ.pop("HERMES_HOME", None) 1007 expected = os.path.join(os.path.expanduser("~"), ".hermes") 1008 result = _get_hermes_home() 1009 assert result == expected