/ tests / tools / test_tirith_security.py
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