Integrate tirith as a pre-execution security scanner that detects homograph URLs, pipe-to-interpreter patterns, terminal injection, zero-width Unicode, and environment variable manipulation — threats the existing 50-pattern dangerous command detector doesn't cover. Architecture: gather-then-decide — both tirith and the dangerous command detector run before any approval prompt, preventing gateway force=True replay from bypassing one check when only the other was shown to the user. New files: - tools/tirith_security.py: subprocess wrapper with auto-installer, mandatory cosign provenance verification, non-blocking background download, disk-persistent failure markers with retryable-cause tracking (cosign_missing auto-clears when cosign appears on PATH) - tests/tools/test_tirith_security.py: 62 tests covering exit code mapping, fail_open, cosign verification, background install, HERMES_HOME isolation, and failure recovery - tests/tools/test_command_guards.py: 21 integration tests for the combined guard orchestration Modified files: - tools/approval.py: add check_all_command_guards() orchestrator, add allow_permanent parameter to prompt_dangerous_approval() - tools/terminal_tool.py: replace _check_dangerous_command with consolidated check_all_command_guards - cli.py: update _approval_callback for allow_permanent kwarg, call ensure_installed() at startup - gateway/run.py: iterate pattern_keys list on replay approval, call ensure_installed() at startup - hermes_cli/config.py: add security config defaults, split commented sections for independent fallback - cli-config.yaml.example: document tirith security config
959 lines
47 KiB
Python
959 lines
47 KiB
Python
"""Tests for the tirith security scanning subprocess wrapper."""
|
||
|
||
import json
|
||
import os
|
||
import subprocess
|
||
import time
|
||
from unittest.mock import MagicMock, patch
|
||
|
||
import pytest
|
||
|
||
import tools.tirith_security as _tirith_mod
|
||
from tools.tirith_security import check_command_security, ensure_installed
|
||
|
||
|
||
@pytest.fixture(autouse=True)
|
||
def _reset_resolved_path():
|
||
"""Pre-set cached path to skip auto-install in scan tests.
|
||
|
||
Tests that specifically test ensure_installed / resolve behavior
|
||
reset this to None themselves.
|
||
"""
|
||
_tirith_mod._resolved_path = "tirith"
|
||
_tirith_mod._install_thread = None
|
||
_tirith_mod._install_failure_reason = ""
|
||
yield
|
||
_tirith_mod._resolved_path = None
|
||
_tirith_mod._install_thread = None
|
||
_tirith_mod._install_failure_reason = ""
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Helpers
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _mock_run(returncode=0, stdout="", stderr=""):
|
||
"""Build a mock subprocess.CompletedProcess."""
|
||
cp = MagicMock(spec=subprocess.CompletedProcess)
|
||
cp.returncode = returncode
|
||
cp.stdout = stdout
|
||
cp.stderr = stderr
|
||
return cp
|
||
|
||
|
||
def _json_stdout(findings=None, summary=""):
|
||
return json.dumps({"findings": findings or [], "summary": summary})
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Exit code → action mapping
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class TestExitCodeMapping:
|
||
@patch("tools.tirith_security.subprocess.run")
|
||
@patch("tools.tirith_security._load_security_config")
|
||
def test_exit_0_allow(self, mock_cfg, mock_run):
|
||
mock_cfg.return_value = {"tirith_enabled": True, "tirith_path": "tirith",
|
||
"tirith_timeout": 5, "tirith_fail_open": True}
|
||
mock_run.return_value = _mock_run(0, _json_stdout())
|
||
result = check_command_security("echo hello")
|
||
assert result["action"] == "allow"
|
||
assert result["findings"] == []
|
||
|
||
@patch("tools.tirith_security.subprocess.run")
|
||
@patch("tools.tirith_security._load_security_config")
|
||
def test_exit_1_block_with_findings(self, mock_cfg, mock_run):
|
||
mock_cfg.return_value = {"tirith_enabled": True, "tirith_path": "tirith",
|
||
"tirith_timeout": 5, "tirith_fail_open": True}
|
||
findings = [{"rule_id": "homograph_url", "severity": "high"}]
|
||
mock_run.return_value = _mock_run(1, _json_stdout(findings, "homograph detected"))
|
||
result = check_command_security("curl http://gооgle.com")
|
||
assert result["action"] == "block"
|
||
assert len(result["findings"]) == 1
|
||
assert result["summary"] == "homograph detected"
|
||
|
||
@patch("tools.tirith_security.subprocess.run")
|
||
@patch("tools.tirith_security._load_security_config")
|
||
def test_exit_2_warn_with_findings(self, mock_cfg, mock_run):
|
||
mock_cfg.return_value = {"tirith_enabled": True, "tirith_path": "tirith",
|
||
"tirith_timeout": 5, "tirith_fail_open": True}
|
||
findings = [{"rule_id": "shortened_url", "severity": "medium"}]
|
||
mock_run.return_value = _mock_run(2, _json_stdout(findings, "shortened URL"))
|
||
result = check_command_security("curl https://bit.ly/abc")
|
||
assert result["action"] == "warn"
|
||
assert len(result["findings"]) == 1
|
||
assert result["summary"] == "shortened URL"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# JSON parse failure (exit code still wins)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class TestJsonParseFailure:
|
||
@patch("tools.tirith_security.subprocess.run")
|
||
@patch("tools.tirith_security._load_security_config")
|
||
def test_exit_1_invalid_json_still_blocks(self, mock_cfg, mock_run):
|
||
mock_cfg.return_value = {"tirith_enabled": True, "tirith_path": "tirith",
|
||
"tirith_timeout": 5, "tirith_fail_open": True}
|
||
mock_run.return_value = _mock_run(1, "NOT JSON")
|
||
result = check_command_security("bad command")
|
||
assert result["action"] == "block"
|
||
assert "details unavailable" in result["summary"]
|
||
|
||
@patch("tools.tirith_security.subprocess.run")
|
||
@patch("tools.tirith_security._load_security_config")
|
||
def test_exit_2_invalid_json_still_warns(self, mock_cfg, mock_run):
|
||
mock_cfg.return_value = {"tirith_enabled": True, "tirith_path": "tirith",
|
||
"tirith_timeout": 5, "tirith_fail_open": True}
|
||
mock_run.return_value = _mock_run(2, "{broken")
|
||
result = check_command_security("suspicious command")
|
||
assert result["action"] == "warn"
|
||
assert "details unavailable" in result["summary"]
|
||
|
||
@patch("tools.tirith_security.subprocess.run")
|
||
@patch("tools.tirith_security._load_security_config")
|
||
def test_exit_0_invalid_json_allows(self, mock_cfg, mock_run):
|
||
mock_cfg.return_value = {"tirith_enabled": True, "tirith_path": "tirith",
|
||
"tirith_timeout": 5, "tirith_fail_open": True}
|
||
mock_run.return_value = _mock_run(0, "NOT JSON")
|
||
result = check_command_security("safe command")
|
||
assert result["action"] == "allow"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Operational failures + fail_open
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class TestOSErrorFailOpen:
|
||
@patch("tools.tirith_security.subprocess.run")
|
||
@patch("tools.tirith_security._load_security_config")
|
||
def test_file_not_found_fail_open(self, mock_cfg, mock_run):
|
||
mock_cfg.return_value = {"tirith_enabled": True, "tirith_path": "tirith",
|
||
"tirith_timeout": 5, "tirith_fail_open": True}
|
||
mock_run.side_effect = FileNotFoundError("No such file: tirith")
|
||
result = check_command_security("echo hi")
|
||
assert result["action"] == "allow"
|
||
assert "unavailable" in result["summary"]
|
||
|
||
@patch("tools.tirith_security.subprocess.run")
|
||
@patch("tools.tirith_security._load_security_config")
|
||
def test_permission_error_fail_open(self, mock_cfg, mock_run):
|
||
mock_cfg.return_value = {"tirith_enabled": True, "tirith_path": "tirith",
|
||
"tirith_timeout": 5, "tirith_fail_open": True}
|
||
mock_run.side_effect = PermissionError("Permission denied")
|
||
result = check_command_security("echo hi")
|
||
assert result["action"] == "allow"
|
||
assert "unavailable" in result["summary"]
|
||
|
||
@patch("tools.tirith_security.subprocess.run")
|
||
@patch("tools.tirith_security._load_security_config")
|
||
def test_os_error_fail_closed(self, mock_cfg, mock_run):
|
||
mock_cfg.return_value = {"tirith_enabled": True, "tirith_path": "tirith",
|
||
"tirith_timeout": 5, "tirith_fail_open": False}
|
||
mock_run.side_effect = FileNotFoundError("No such file: tirith")
|
||
result = check_command_security("echo hi")
|
||
assert result["action"] == "block"
|
||
assert "fail-closed" in result["summary"]
|
||
|
||
|
||
class TestTimeoutFailOpen:
|
||
@patch("tools.tirith_security.subprocess.run")
|
||
@patch("tools.tirith_security._load_security_config")
|
||
def test_timeout_fail_open(self, mock_cfg, mock_run):
|
||
mock_cfg.return_value = {"tirith_enabled": True, "tirith_path": "tirith",
|
||
"tirith_timeout": 5, "tirith_fail_open": True}
|
||
mock_run.side_effect = subprocess.TimeoutExpired(cmd="tirith", timeout=5)
|
||
result = check_command_security("slow command")
|
||
assert result["action"] == "allow"
|
||
assert "timed out" in result["summary"]
|
||
|
||
@patch("tools.tirith_security.subprocess.run")
|
||
@patch("tools.tirith_security._load_security_config")
|
||
def test_timeout_fail_closed(self, mock_cfg, mock_run):
|
||
mock_cfg.return_value = {"tirith_enabled": True, "tirith_path": "tirith",
|
||
"tirith_timeout": 5, "tirith_fail_open": False}
|
||
mock_run.side_effect = subprocess.TimeoutExpired(cmd="tirith", timeout=5)
|
||
result = check_command_security("slow command")
|
||
assert result["action"] == "block"
|
||
assert "fail-closed" in result["summary"]
|
||
|
||
|
||
class TestUnknownExitCode:
|
||
@patch("tools.tirith_security.subprocess.run")
|
||
@patch("tools.tirith_security._load_security_config")
|
||
def test_unknown_exit_code_fail_open(self, mock_cfg, mock_run):
|
||
mock_cfg.return_value = {"tirith_enabled": True, "tirith_path": "tirith",
|
||
"tirith_timeout": 5, "tirith_fail_open": True}
|
||
mock_run.return_value = _mock_run(99, "")
|
||
result = check_command_security("cmd")
|
||
assert result["action"] == "allow"
|
||
assert "exit code 99" in result["summary"]
|
||
|
||
@patch("tools.tirith_security.subprocess.run")
|
||
@patch("tools.tirith_security._load_security_config")
|
||
def test_unknown_exit_code_fail_closed(self, mock_cfg, mock_run):
|
||
mock_cfg.return_value = {"tirith_enabled": True, "tirith_path": "tirith",
|
||
"tirith_timeout": 5, "tirith_fail_open": False}
|
||
mock_run.return_value = _mock_run(99, "")
|
||
result = check_command_security("cmd")
|
||
assert result["action"] == "block"
|
||
assert "exit code 99" in result["summary"]
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Disabled + path expansion
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class TestDisabled:
|
||
@patch("tools.tirith_security._load_security_config")
|
||
def test_disabled_returns_allow(self, mock_cfg):
|
||
mock_cfg.return_value = {"tirith_enabled": False, "tirith_path": "tirith",
|
||
"tirith_timeout": 5, "tirith_fail_open": True}
|
||
result = check_command_security("rm -rf /")
|
||
assert result["action"] == "allow"
|
||
|
||
|
||
class TestPathExpansion:
|
||
def test_tilde_expanded_in_resolve(self):
|
||
"""_resolve_tirith_path should expand ~ in configured path."""
|
||
from tools.tirith_security import _resolve_tirith_path
|
||
_tirith_mod._resolved_path = None
|
||
# Explicit path — won't auto-download, just expands and caches miss
|
||
result = _resolve_tirith_path("~/bin/tirith")
|
||
assert "~" not in result, "tilde should be expanded"
|
||
_tirith_mod._resolved_path = None
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Findings cap + summary cap
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class TestCaps:
|
||
@patch("tools.tirith_security.subprocess.run")
|
||
@patch("tools.tirith_security._load_security_config")
|
||
def test_findings_capped_at_50(self, mock_cfg, mock_run):
|
||
mock_cfg.return_value = {"tirith_enabled": True, "tirith_path": "tirith",
|
||
"tirith_timeout": 5, "tirith_fail_open": True}
|
||
findings = [{"rule_id": f"rule_{i}"} for i in range(100)]
|
||
mock_run.return_value = _mock_run(2, _json_stdout(findings, "many findings"))
|
||
result = check_command_security("cmd")
|
||
assert len(result["findings"]) == 50
|
||
|
||
@patch("tools.tirith_security.subprocess.run")
|
||
@patch("tools.tirith_security._load_security_config")
|
||
def test_summary_capped_at_500(self, mock_cfg, mock_run):
|
||
mock_cfg.return_value = {"tirith_enabled": True, "tirith_path": "tirith",
|
||
"tirith_timeout": 5, "tirith_fail_open": True}
|
||
long_summary = "x" * 1000
|
||
mock_run.return_value = _mock_run(2, _json_stdout([], long_summary))
|
||
result = check_command_security("cmd")
|
||
assert len(result["summary"]) == 500
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Programming errors propagate
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class TestProgrammingErrors:
|
||
@patch("tools.tirith_security.subprocess.run")
|
||
@patch("tools.tirith_security._load_security_config")
|
||
def test_attribute_error_propagates(self, mock_cfg, mock_run):
|
||
mock_cfg.return_value = {"tirith_enabled": True, "tirith_path": "tirith",
|
||
"tirith_timeout": 5, "tirith_fail_open": True}
|
||
mock_run.side_effect = AttributeError("unexpected bug")
|
||
with pytest.raises(AttributeError):
|
||
check_command_security("cmd")
|
||
|
||
@patch("tools.tirith_security.subprocess.run")
|
||
@patch("tools.tirith_security._load_security_config")
|
||
def test_type_error_propagates(self, mock_cfg, mock_run):
|
||
mock_cfg.return_value = {"tirith_enabled": True, "tirith_path": "tirith",
|
||
"tirith_timeout": 5, "tirith_fail_open": True}
|
||
mock_run.side_effect = TypeError("unexpected bug")
|
||
with pytest.raises(TypeError):
|
||
check_command_security("cmd")
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# ensure_installed
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class TestEnsureInstalled:
|
||
@patch("tools.tirith_security._load_security_config")
|
||
def test_disabled_returns_none(self, mock_cfg):
|
||
mock_cfg.return_value = {"tirith_enabled": False, "tirith_path": "tirith",
|
||
"tirith_timeout": 5, "tirith_fail_open": True}
|
||
_tirith_mod._resolved_path = None
|
||
assert ensure_installed() is None
|
||
|
||
@patch("tools.tirith_security.shutil.which", return_value="/usr/local/bin/tirith")
|
||
@patch("tools.tirith_security._load_security_config")
|
||
def test_found_on_path_returns_immediately(self, mock_cfg, mock_which):
|
||
mock_cfg.return_value = {"tirith_enabled": True, "tirith_path": "tirith",
|
||
"tirith_timeout": 5, "tirith_fail_open": True}
|
||
_tirith_mod._resolved_path = None
|
||
with patch("os.path.isfile", return_value=True), \
|
||
patch("os.access", return_value=True):
|
||
result = ensure_installed()
|
||
assert result == "/usr/local/bin/tirith"
|
||
_tirith_mod._resolved_path = None
|
||
|
||
@patch("tools.tirith_security._load_security_config")
|
||
def test_not_found_returns_none(self, mock_cfg):
|
||
mock_cfg.return_value = {"tirith_enabled": True, "tirith_path": "tirith",
|
||
"tirith_timeout": 5, "tirith_fail_open": True}
|
||
_tirith_mod._resolved_path = None
|
||
with patch("tools.tirith_security.shutil.which", return_value=None), \
|
||
patch("tools.tirith_security._hermes_bin_dir", return_value="/nonexistent"), \
|
||
patch("tools.tirith_security._is_install_failed_on_disk", return_value=False), \
|
||
patch("tools.tirith_security.threading.Thread") as MockThread:
|
||
mock_thread = MagicMock()
|
||
MockThread.return_value = mock_thread
|
||
result = ensure_installed()
|
||
assert result is None
|
||
# Should have launched background thread
|
||
mock_thread.start.assert_called_once()
|
||
_tirith_mod._resolved_path = None
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Failed download caches the miss (Finding #1)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class TestFailedDownloadCaching:
|
||
@patch("tools.tirith_security._mark_install_failed")
|
||
@patch("tools.tirith_security._is_install_failed_on_disk", return_value=False)
|
||
@patch("tools.tirith_security._install_tirith", return_value=(None, "download_failed"))
|
||
@patch("tools.tirith_security.shutil.which", return_value=None)
|
||
def test_failed_install_cached_no_retry(self, mock_which, mock_install,
|
||
mock_disk_check, mock_mark):
|
||
"""After a failed download, subsequent resolves must not retry."""
|
||
from tools.tirith_security import _resolve_tirith_path, _INSTALL_FAILED
|
||
_tirith_mod._resolved_path = None
|
||
|
||
# First call: tries install, fails
|
||
_resolve_tirith_path("tirith")
|
||
assert mock_install.call_count == 1
|
||
assert _tirith_mod._resolved_path is _INSTALL_FAILED
|
||
mock_mark.assert_called_once_with("download_failed") # reason persisted
|
||
|
||
# Second call: hits the cache, does NOT call _install_tirith again
|
||
_resolve_tirith_path("tirith")
|
||
assert mock_install.call_count == 1 # still 1, not 2
|
||
|
||
_tirith_mod._resolved_path = None
|
||
|
||
@patch("tools.tirith_security._mark_install_failed")
|
||
@patch("tools.tirith_security._is_install_failed_on_disk", return_value=False)
|
||
@patch("tools.tirith_security._install_tirith", return_value=(None, "download_failed"))
|
||
@patch("tools.tirith_security.shutil.which", return_value=None)
|
||
@patch("tools.tirith_security.subprocess.run")
|
||
@patch("tools.tirith_security._load_security_config")
|
||
def test_failed_install_scan_uses_fail_open(self, mock_cfg, mock_run,
|
||
mock_which, mock_install,
|
||
mock_disk_check, mock_mark):
|
||
"""After cached miss, check_command_security hits OSError → fail_open."""
|
||
_tirith_mod._resolved_path = None
|
||
mock_cfg.return_value = {"tirith_enabled": True, "tirith_path": "tirith",
|
||
"tirith_timeout": 5, "tirith_fail_open": True}
|
||
mock_run.side_effect = FileNotFoundError("No such file: tirith")
|
||
# First command triggers install attempt + cached miss + scan
|
||
result = check_command_security("echo hello")
|
||
assert result["action"] == "allow"
|
||
assert mock_install.call_count == 1
|
||
|
||
# Second command: no install retry, just hits OSError → allow
|
||
result = check_command_security("echo world")
|
||
assert result["action"] == "allow"
|
||
assert mock_install.call_count == 1 # still 1
|
||
|
||
_tirith_mod._resolved_path = None
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Explicit path must not auto-download (Finding #2)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class TestExplicitPathNoAutoDownload:
|
||
@patch("tools.tirith_security._install_tirith")
|
||
@patch("tools.tirith_security.shutil.which", return_value=None)
|
||
def test_explicit_path_missing_no_download(self, mock_which, mock_install):
|
||
"""An explicit tirith_path that doesn't exist must NOT trigger download."""
|
||
from tools.tirith_security import _resolve_tirith_path, _INSTALL_FAILED
|
||
_tirith_mod._resolved_path = None
|
||
|
||
result = _resolve_tirith_path("/opt/custom/tirith")
|
||
# Should cache failure, not call _install_tirith
|
||
mock_install.assert_not_called()
|
||
assert _tirith_mod._resolved_path is _INSTALL_FAILED
|
||
assert "/opt/custom/tirith" in result
|
||
|
||
_tirith_mod._resolved_path = None
|
||
|
||
@patch("tools.tirith_security._install_tirith")
|
||
@patch("tools.tirith_security.shutil.which", return_value=None)
|
||
def test_tilde_explicit_path_missing_no_download(self, mock_which, mock_install):
|
||
"""An explicit ~/path that doesn't exist must NOT trigger download."""
|
||
from tools.tirith_security import _resolve_tirith_path, _INSTALL_FAILED
|
||
_tirith_mod._resolved_path = None
|
||
|
||
result = _resolve_tirith_path("~/bin/tirith")
|
||
mock_install.assert_not_called()
|
||
assert _tirith_mod._resolved_path is _INSTALL_FAILED
|
||
assert "~" not in result # tilde still expanded
|
||
|
||
_tirith_mod._resolved_path = None
|
||
|
||
@patch("tools.tirith_security._mark_install_failed")
|
||
@patch("tools.tirith_security._is_install_failed_on_disk", return_value=False)
|
||
@patch("tools.tirith_security._install_tirith", return_value=("/auto/tirith", ""))
|
||
@patch("tools.tirith_security.shutil.which", return_value=None)
|
||
def test_default_path_does_auto_download(self, mock_which, mock_install,
|
||
mock_disk_check, mock_mark):
|
||
"""The default bare 'tirith' SHOULD trigger auto-download."""
|
||
from tools.tirith_security import _resolve_tirith_path
|
||
_tirith_mod._resolved_path = None
|
||
|
||
result = _resolve_tirith_path("tirith")
|
||
mock_install.assert_called_once()
|
||
assert result == "/auto/tirith"
|
||
|
||
_tirith_mod._resolved_path = None
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Cosign provenance verification (P1)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class TestCosignVerification:
|
||
@patch("tools.tirith_security.subprocess.run")
|
||
@patch("tools.tirith_security.shutil.which", return_value="/usr/bin/cosign")
|
||
def test_cosign_pass(self, mock_which, mock_run):
|
||
"""cosign verify-blob exits 0 → returns True."""
|
||
from tools.tirith_security import _verify_cosign
|
||
mock_run.return_value = _mock_run(0, "Verified OK")
|
||
result = _verify_cosign("/tmp/checksums.txt", "/tmp/checksums.txt.sig",
|
||
"/tmp/checksums.txt.pem")
|
||
assert result is True
|
||
mock_run.assert_called_once()
|
||
args = mock_run.call_args[0][0]
|
||
assert "verify-blob" in args
|
||
assert "--certificate-identity-regexp" in args
|
||
|
||
@patch("tools.tirith_security.subprocess.run")
|
||
@patch("tools.tirith_security.shutil.which", return_value="/usr/bin/cosign")
|
||
def test_cosign_identity_pinned_to_release_workflow(self, mock_which, mock_run):
|
||
"""Identity regexp must pin to the release workflow, not the whole repo."""
|
||
from tools.tirith_security import _verify_cosign
|
||
mock_run.return_value = _mock_run(0, "Verified OK")
|
||
_verify_cosign("/tmp/checksums.txt", "/tmp/sig", "/tmp/cert")
|
||
args = mock_run.call_args[0][0]
|
||
# Find the value after --certificate-identity-regexp
|
||
idx = args.index("--certificate-identity-regexp")
|
||
identity = args[idx + 1]
|
||
# The identity contains regex-escaped dots
|
||
assert "workflows/release" in identity
|
||
assert "refs/tags/v" in identity
|
||
|
||
@patch("tools.tirith_security.subprocess.run")
|
||
@patch("tools.tirith_security.shutil.which", return_value="/usr/bin/cosign")
|
||
def test_cosign_fail_aborts(self, mock_which, mock_run):
|
||
"""cosign verify-blob exits non-zero → returns False (abort install)."""
|
||
from tools.tirith_security import _verify_cosign
|
||
mock_run.return_value = _mock_run(1, "", "signature mismatch")
|
||
result = _verify_cosign("/tmp/checksums.txt", "/tmp/checksums.txt.sig",
|
||
"/tmp/checksums.txt.pem")
|
||
assert result is False
|
||
|
||
@patch("tools.tirith_security.shutil.which", return_value=None)
|
||
def test_cosign_not_found_returns_none(self, mock_which):
|
||
"""cosign not on PATH → returns None (proceed with SHA-256 only)."""
|
||
from tools.tirith_security import _verify_cosign
|
||
result = _verify_cosign("/tmp/checksums.txt", "/tmp/checksums.txt.sig",
|
||
"/tmp/checksums.txt.pem")
|
||
assert result is None
|
||
|
||
@patch("tools.tirith_security.subprocess.run",
|
||
side_effect=subprocess.TimeoutExpired("cosign", 15))
|
||
@patch("tools.tirith_security.shutil.which", return_value="/usr/bin/cosign")
|
||
def test_cosign_timeout_returns_none(self, mock_which, mock_run):
|
||
"""cosign times out → returns None (proceed with SHA-256 only)."""
|
||
from tools.tirith_security import _verify_cosign
|
||
result = _verify_cosign("/tmp/checksums.txt", "/tmp/checksums.txt.sig",
|
||
"/tmp/checksums.txt.pem")
|
||
assert result is None
|
||
|
||
@patch("tools.tirith_security.subprocess.run",
|
||
side_effect=OSError("exec format error"))
|
||
@patch("tools.tirith_security.shutil.which", return_value="/usr/bin/cosign")
|
||
def test_cosign_os_error_returns_none(self, mock_which, mock_run):
|
||
"""cosign OSError → returns None (proceed with SHA-256 only)."""
|
||
from tools.tirith_security import _verify_cosign
|
||
result = _verify_cosign("/tmp/checksums.txt", "/tmp/checksums.txt.sig",
|
||
"/tmp/checksums.txt.pem")
|
||
assert result is None
|
||
|
||
@patch("tools.tirith_security._verify_cosign", return_value=False)
|
||
@patch("tools.tirith_security.shutil.which", return_value="/usr/local/bin/cosign")
|
||
@patch("tools.tirith_security._download_file")
|
||
@patch("tools.tirith_security._detect_target", return_value="aarch64-apple-darwin")
|
||
def test_install_aborts_on_cosign_rejection(self, mock_target, mock_dl,
|
||
mock_which, mock_cosign):
|
||
"""_install_tirith returns None when cosign rejects the signature."""
|
||
from tools.tirith_security import _install_tirith
|
||
path, reason = _install_tirith()
|
||
assert path is None
|
||
assert reason == "cosign_verification_failed"
|
||
|
||
@patch("tools.tirith_security.shutil.which", return_value=None)
|
||
@patch("tools.tirith_security._download_file")
|
||
@patch("tools.tirith_security._detect_target", return_value="aarch64-apple-darwin")
|
||
def test_install_aborts_when_cosign_missing(self, mock_target, mock_dl,
|
||
mock_which):
|
||
"""_install_tirith returns cosign_missing when cosign is not on PATH."""
|
||
from tools.tirith_security import _install_tirith
|
||
path, reason = _install_tirith()
|
||
assert path is None
|
||
assert reason == "cosign_missing"
|
||
|
||
@patch("tools.tirith_security._verify_cosign", return_value=None)
|
||
@patch("tools.tirith_security.shutil.which", return_value="/usr/local/bin/cosign")
|
||
@patch("tools.tirith_security._download_file")
|
||
@patch("tools.tirith_security._detect_target", return_value="aarch64-apple-darwin")
|
||
def test_install_aborts_when_cosign_exec_fails(self, mock_target, mock_dl,
|
||
mock_which, mock_cosign):
|
||
"""_install_tirith returns cosign_exec_failed when cosign exists but fails."""
|
||
from tools.tirith_security import _install_tirith
|
||
path, reason = _install_tirith()
|
||
assert path is None
|
||
assert reason == "cosign_exec_failed"
|
||
|
||
@patch("tools.tirith_security._download_file")
|
||
@patch("tools.tirith_security._detect_target", return_value="aarch64-apple-darwin")
|
||
def test_install_aborts_when_cosign_artifacts_missing(self, mock_target,
|
||
mock_dl):
|
||
"""_install_tirith returns None when .sig/.pem downloads fail (404)."""
|
||
from tools.tirith_security import _install_tirith
|
||
import urllib.request
|
||
|
||
def _dl_side_effect(url, dest, timeout=10):
|
||
if url.endswith(".sig") or url.endswith(".pem"):
|
||
raise urllib.request.URLError("404 Not Found")
|
||
|
||
mock_dl.side_effect = _dl_side_effect
|
||
|
||
path, reason = _install_tirith()
|
||
assert path is None
|
||
assert reason == "cosign_artifacts_unavailable"
|
||
|
||
@patch("tools.tirith_security.tarfile.open")
|
||
@patch("tools.tirith_security._verify_checksum", return_value=True)
|
||
@patch("tools.tirith_security._verify_cosign", return_value=True)
|
||
@patch("tools.tirith_security.shutil.which", return_value="/usr/local/bin/cosign")
|
||
@patch("tools.tirith_security._download_file")
|
||
@patch("tools.tirith_security._detect_target", return_value="aarch64-apple-darwin")
|
||
def test_install_proceeds_when_cosign_passes(self, mock_target, mock_dl,
|
||
mock_which, mock_cosign,
|
||
mock_checksum, mock_tarfile):
|
||
"""_install_tirith proceeds only when cosign explicitly passes (True)."""
|
||
from tools.tirith_security import _install_tirith
|
||
# Mock tarfile — empty archive means "binary not found" return
|
||
mock_tar = MagicMock()
|
||
mock_tar.__enter__ = MagicMock(return_value=mock_tar)
|
||
mock_tar.__exit__ = MagicMock(return_value=False)
|
||
mock_tar.getmembers.return_value = []
|
||
mock_tarfile.return_value = mock_tar
|
||
|
||
path, reason = _install_tirith()
|
||
assert path is None # no binary in mock archive, but got past cosign
|
||
assert reason == "binary_not_in_archive"
|
||
assert mock_checksum.called # reached SHA-256 step
|
||
assert mock_cosign.called # cosign was invoked
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Background install / non-blocking startup (P2)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class TestBackgroundInstall:
|
||
def test_ensure_installed_non_blocking(self):
|
||
"""ensure_installed must return immediately when download needed."""
|
||
_tirith_mod._resolved_path = None
|
||
|
||
with patch("tools.tirith_security._load_security_config",
|
||
return_value={"tirith_enabled": True, "tirith_path": "tirith",
|
||
"tirith_timeout": 5, "tirith_fail_open": True}), \
|
||
patch("tools.tirith_security.shutil.which", return_value=None), \
|
||
patch("tools.tirith_security._hermes_bin_dir", return_value="/nonexistent"), \
|
||
patch("tools.tirith_security._is_install_failed_on_disk", return_value=False), \
|
||
patch("tools.tirith_security.threading.Thread") as MockThread:
|
||
mock_thread = MagicMock()
|
||
mock_thread.is_alive.return_value = False
|
||
MockThread.return_value = mock_thread
|
||
|
||
result = ensure_installed()
|
||
assert result is None # not available yet
|
||
MockThread.assert_called_once()
|
||
mock_thread.start.assert_called_once()
|
||
|
||
_tirith_mod._resolved_path = None
|
||
|
||
def test_ensure_installed_skips_on_disk_marker(self):
|
||
"""ensure_installed skips network attempt when disk marker exists."""
|
||
_tirith_mod._resolved_path = None
|
||
|
||
with patch("tools.tirith_security._load_security_config",
|
||
return_value={"tirith_enabled": True, "tirith_path": "tirith",
|
||
"tirith_timeout": 5, "tirith_fail_open": True}), \
|
||
patch("tools.tirith_security.shutil.which", return_value=None), \
|
||
patch("tools.tirith_security._hermes_bin_dir", return_value="/nonexistent"), \
|
||
patch("tools.tirith_security._read_failure_reason", return_value="download_failed"), \
|
||
patch("tools.tirith_security._is_install_failed_on_disk", return_value=True):
|
||
|
||
result = ensure_installed()
|
||
assert result is None
|
||
assert _tirith_mod._resolved_path is _tirith_mod._INSTALL_FAILED
|
||
assert _tirith_mod._install_failure_reason == "download_failed"
|
||
|
||
_tirith_mod._resolved_path = None
|
||
|
||
def test_resolve_returns_default_when_thread_alive(self):
|
||
"""_resolve_tirith_path returns default while background thread runs."""
|
||
from tools.tirith_security import _resolve_tirith_path
|
||
_tirith_mod._resolved_path = None
|
||
mock_thread = MagicMock()
|
||
mock_thread.is_alive.return_value = True
|
||
_tirith_mod._install_thread = mock_thread
|
||
|
||
with patch("tools.tirith_security.shutil.which", return_value=None), \
|
||
patch("tools.tirith_security._hermes_bin_dir", return_value="/nonexistent"):
|
||
result = _resolve_tirith_path("tirith")
|
||
assert result == "tirith" # returns configured default, doesn't block
|
||
|
||
_tirith_mod._install_thread = None
|
||
_tirith_mod._resolved_path = None
|
||
|
||
def test_resolve_picks_up_background_result(self):
|
||
"""After background thread finishes, _resolve_tirith_path uses cached path."""
|
||
from tools.tirith_security import _resolve_tirith_path
|
||
# Simulate background thread having completed and set the path
|
||
_tirith_mod._resolved_path = "/usr/local/bin/tirith"
|
||
|
||
result = _resolve_tirith_path("tirith")
|
||
assert result == "/usr/local/bin/tirith"
|
||
|
||
_tirith_mod._resolved_path = None
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Disk failure marker persistence (P2)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class TestDiskFailureMarker:
|
||
def test_mark_and_check(self):
|
||
"""Writing then reading the marker should work."""
|
||
import tempfile
|
||
tmpdir = tempfile.mkdtemp()
|
||
marker = os.path.join(tmpdir, ".tirith-install-failed")
|
||
with patch("tools.tirith_security._failure_marker_path", return_value=marker):
|
||
from tools.tirith_security import (
|
||
_mark_install_failed, _is_install_failed_on_disk, _clear_install_failed,
|
||
)
|
||
assert not _is_install_failed_on_disk()
|
||
_mark_install_failed("download_failed")
|
||
assert _is_install_failed_on_disk()
|
||
_clear_install_failed()
|
||
assert not _is_install_failed_on_disk()
|
||
|
||
def test_expired_marker_ignored(self):
|
||
"""Marker older than TTL should be ignored."""
|
||
import tempfile
|
||
tmpdir = tempfile.mkdtemp()
|
||
marker = os.path.join(tmpdir, ".tirith-install-failed")
|
||
with patch("tools.tirith_security._failure_marker_path", return_value=marker):
|
||
from tools.tirith_security import _mark_install_failed, _is_install_failed_on_disk
|
||
_mark_install_failed("download_failed")
|
||
# Backdate the file past 24h TTL
|
||
old_time = time.time() - 90000 # 25 hours ago
|
||
os.utime(marker, (old_time, old_time))
|
||
assert not _is_install_failed_on_disk()
|
||
|
||
def test_cosign_missing_marker_clears_when_cosign_appears(self):
|
||
"""Marker with 'cosign_missing' reason clears if cosign is now on PATH."""
|
||
import tempfile
|
||
tmpdir = tempfile.mkdtemp()
|
||
marker = os.path.join(tmpdir, ".tirith-install-failed")
|
||
with patch("tools.tirith_security._failure_marker_path", return_value=marker):
|
||
from tools.tirith_security import _mark_install_failed, _is_install_failed_on_disk
|
||
_mark_install_failed("cosign_missing")
|
||
assert _is_install_failed_on_disk() # cosign still absent
|
||
|
||
# Now cosign appears on PATH
|
||
with patch("tools.tirith_security.shutil.which", return_value="/usr/local/bin/cosign"):
|
||
assert not _is_install_failed_on_disk()
|
||
# Marker file should have been removed
|
||
assert not os.path.exists(marker)
|
||
|
||
def test_cosign_missing_marker_stays_when_cosign_still_absent(self):
|
||
"""Marker with 'cosign_missing' reason stays if cosign is still missing."""
|
||
import tempfile
|
||
tmpdir = tempfile.mkdtemp()
|
||
marker = os.path.join(tmpdir, ".tirith-install-failed")
|
||
with patch("tools.tirith_security._failure_marker_path", return_value=marker):
|
||
from tools.tirith_security import _mark_install_failed, _is_install_failed_on_disk
|
||
_mark_install_failed("cosign_missing")
|
||
with patch("tools.tirith_security.shutil.which", return_value=None):
|
||
assert _is_install_failed_on_disk()
|
||
|
||
def test_non_cosign_marker_not_affected_by_cosign_presence(self):
|
||
"""Markers with other reasons are NOT cleared by cosign appearing."""
|
||
import tempfile
|
||
tmpdir = tempfile.mkdtemp()
|
||
marker = os.path.join(tmpdir, ".tirith-install-failed")
|
||
with patch("tools.tirith_security._failure_marker_path", return_value=marker):
|
||
from tools.tirith_security import _mark_install_failed, _is_install_failed_on_disk
|
||
_mark_install_failed("download_failed")
|
||
with patch("tools.tirith_security.shutil.which", return_value="/usr/local/bin/cosign"):
|
||
assert _is_install_failed_on_disk() # still failed
|
||
|
||
@patch("tools.tirith_security._mark_install_failed")
|
||
@patch("tools.tirith_security._is_install_failed_on_disk", return_value=False)
|
||
@patch("tools.tirith_security._install_tirith", return_value=(None, "cosign_missing"))
|
||
@patch("tools.tirith_security.shutil.which", return_value=None)
|
||
def test_sync_resolve_persists_failure(self, mock_which, mock_install,
|
||
mock_disk_check, mock_mark):
|
||
"""Synchronous _resolve_tirith_path persists failure to disk."""
|
||
from tools.tirith_security import _resolve_tirith_path
|
||
_tirith_mod._resolved_path = None
|
||
|
||
_resolve_tirith_path("tirith")
|
||
mock_mark.assert_called_once_with("cosign_missing")
|
||
|
||
_tirith_mod._resolved_path = None
|
||
|
||
@patch("tools.tirith_security._clear_install_failed")
|
||
@patch("tools.tirith_security._is_install_failed_on_disk", return_value=False)
|
||
@patch("tools.tirith_security._install_tirith", return_value=("/installed/tirith", ""))
|
||
@patch("tools.tirith_security.shutil.which", return_value=None)
|
||
def test_sync_resolve_clears_marker_on_success(self, mock_which, mock_install,
|
||
mock_disk_check, mock_clear):
|
||
"""Successful install clears the disk failure marker."""
|
||
from tools.tirith_security import _resolve_tirith_path
|
||
_tirith_mod._resolved_path = None
|
||
|
||
result = _resolve_tirith_path("tirith")
|
||
assert result == "/installed/tirith"
|
||
mock_clear.assert_called_once()
|
||
|
||
_tirith_mod._resolved_path = None
|
||
|
||
def test_sync_resolve_skips_install_on_disk_marker(self):
|
||
"""_resolve_tirith_path skips download when disk marker is recent."""
|
||
from tools.tirith_security import _resolve_tirith_path, _INSTALL_FAILED
|
||
_tirith_mod._resolved_path = None
|
||
|
||
with patch("tools.tirith_security.shutil.which", return_value=None), \
|
||
patch("tools.tirith_security._hermes_bin_dir", return_value="/nonexistent"), \
|
||
patch("tools.tirith_security._read_failure_reason", return_value="download_failed"), \
|
||
patch("tools.tirith_security._is_install_failed_on_disk", return_value=True), \
|
||
patch("tools.tirith_security._install_tirith") as mock_install:
|
||
_resolve_tirith_path("tirith")
|
||
mock_install.assert_not_called()
|
||
assert _tirith_mod._resolved_path is _INSTALL_FAILED
|
||
assert _tirith_mod._install_failure_reason == "download_failed"
|
||
|
||
_tirith_mod._resolved_path = None
|
||
|
||
def test_install_failed_still_checks_local_paths(self):
|
||
"""After _INSTALL_FAILED, a manual install on PATH is picked up."""
|
||
from tools.tirith_security import _resolve_tirith_path, _INSTALL_FAILED
|
||
_tirith_mod._resolved_path = _INSTALL_FAILED
|
||
|
||
with patch("tools.tirith_security.shutil.which", return_value="/usr/local/bin/tirith"), \
|
||
patch("tools.tirith_security._clear_install_failed") as mock_clear:
|
||
result = _resolve_tirith_path("tirith")
|
||
assert result == "/usr/local/bin/tirith"
|
||
assert _tirith_mod._resolved_path == "/usr/local/bin/tirith"
|
||
mock_clear.assert_called_once()
|
||
|
||
_tirith_mod._resolved_path = None
|
||
|
||
def test_install_failed_recovers_from_hermes_bin(self):
|
||
"""After _INSTALL_FAILED, manual install in HERMES_HOME/bin is picked up."""
|
||
from tools.tirith_security import _resolve_tirith_path, _INSTALL_FAILED
|
||
import tempfile
|
||
tmpdir = tempfile.mkdtemp()
|
||
hermes_bin = os.path.join(tmpdir, "tirith")
|
||
# Create a fake executable
|
||
with open(hermes_bin, "w") as f:
|
||
f.write("#!/bin/sh\n")
|
||
os.chmod(hermes_bin, 0o755)
|
||
|
||
_tirith_mod._resolved_path = _INSTALL_FAILED
|
||
|
||
with patch("tools.tirith_security.shutil.which", return_value=None), \
|
||
patch("tools.tirith_security._hermes_bin_dir", return_value=tmpdir), \
|
||
patch("tools.tirith_security._clear_install_failed") as mock_clear:
|
||
result = _resolve_tirith_path("tirith")
|
||
assert result == hermes_bin
|
||
assert _tirith_mod._resolved_path == hermes_bin
|
||
mock_clear.assert_called_once()
|
||
|
||
_tirith_mod._resolved_path = None
|
||
|
||
def test_install_failed_skips_network_when_local_absent(self):
|
||
"""After _INSTALL_FAILED, if local checks fail, network is NOT retried."""
|
||
from tools.tirith_security import _resolve_tirith_path, _INSTALL_FAILED
|
||
_tirith_mod._resolved_path = _INSTALL_FAILED
|
||
|
||
with patch("tools.tirith_security.shutil.which", return_value=None), \
|
||
patch("tools.tirith_security._hermes_bin_dir", return_value="/nonexistent"), \
|
||
patch("tools.tirith_security._install_tirith") as mock_install:
|
||
result = _resolve_tirith_path("tirith")
|
||
assert result == "tirith" # fallback to configured path
|
||
mock_install.assert_not_called()
|
||
|
||
_tirith_mod._resolved_path = None
|
||
|
||
def test_cosign_missing_disk_marker_allows_retry(self):
|
||
"""Disk marker with cosign_missing reason allows retry when cosign appears."""
|
||
from tools.tirith_security import _resolve_tirith_path, _INSTALL_FAILED
|
||
_tirith_mod._resolved_path = None
|
||
|
||
# _is_install_failed_on_disk sees "cosign_missing" + cosign on PATH → returns False
|
||
with patch("tools.tirith_security.shutil.which", return_value=None), \
|
||
patch("tools.tirith_security._hermes_bin_dir", return_value="/nonexistent"), \
|
||
patch("tools.tirith_security._is_install_failed_on_disk", return_value=False), \
|
||
patch("tools.tirith_security._install_tirith", return_value=("/new/tirith", "")) as mock_install, \
|
||
patch("tools.tirith_security._clear_install_failed"):
|
||
result = _resolve_tirith_path("tirith")
|
||
mock_install.assert_called_once() # network retry happened
|
||
assert result == "/new/tirith"
|
||
|
||
_tirith_mod._resolved_path = None
|
||
|
||
def test_in_memory_cosign_missing_retries_when_cosign_appears(self):
|
||
"""In-memory _INSTALL_FAILED with cosign_missing retries when cosign appears."""
|
||
from tools.tirith_security import _resolve_tirith_path, _INSTALL_FAILED
|
||
_tirith_mod._resolved_path = _INSTALL_FAILED
|
||
_tirith_mod._install_failure_reason = "cosign_missing"
|
||
|
||
def _which_side_effect(name):
|
||
if name == "tirith":
|
||
return None # tirith not on PATH
|
||
if name == "cosign":
|
||
return "/usr/local/bin/cosign" # cosign now available
|
||
return None
|
||
|
||
with patch("tools.tirith_security.shutil.which", side_effect=_which_side_effect), \
|
||
patch("tools.tirith_security._hermes_bin_dir", return_value="/nonexistent"), \
|
||
patch("tools.tirith_security._is_install_failed_on_disk", return_value=False), \
|
||
patch("tools.tirith_security._install_tirith", return_value=("/new/tirith", "")) as mock_install, \
|
||
patch("tools.tirith_security._clear_install_failed"):
|
||
result = _resolve_tirith_path("tirith")
|
||
mock_install.assert_called_once() # network retry happened
|
||
assert result == "/new/tirith"
|
||
|
||
_tirith_mod._resolved_path = None
|
||
|
||
def test_in_memory_cosign_exec_failed_not_retried(self):
|
||
"""In-memory _INSTALL_FAILED with cosign_exec_failed is NOT retried."""
|
||
from tools.tirith_security import _resolve_tirith_path, _INSTALL_FAILED
|
||
_tirith_mod._resolved_path = _INSTALL_FAILED
|
||
_tirith_mod._install_failure_reason = "cosign_exec_failed"
|
||
|
||
with patch("tools.tirith_security.shutil.which", return_value=None), \
|
||
patch("tools.tirith_security._hermes_bin_dir", return_value="/nonexistent"), \
|
||
patch("tools.tirith_security._install_tirith") as mock_install:
|
||
result = _resolve_tirith_path("tirith")
|
||
assert result == "tirith" # fallback
|
||
mock_install.assert_not_called()
|
||
|
||
_tirith_mod._resolved_path = None
|
||
|
||
def test_in_memory_cosign_missing_stays_when_cosign_still_absent(self):
|
||
"""In-memory cosign_missing is NOT retried when cosign is still absent."""
|
||
from tools.tirith_security import _resolve_tirith_path, _INSTALL_FAILED
|
||
_tirith_mod._resolved_path = _INSTALL_FAILED
|
||
_tirith_mod._install_failure_reason = "cosign_missing"
|
||
|
||
with patch("tools.tirith_security.shutil.which", return_value=None), \
|
||
patch("tools.tirith_security._hermes_bin_dir", return_value="/nonexistent"), \
|
||
patch("tools.tirith_security._install_tirith") as mock_install:
|
||
result = _resolve_tirith_path("tirith")
|
||
assert result == "tirith" # fallback
|
||
mock_install.assert_not_called()
|
||
|
||
_tirith_mod._resolved_path = None
|
||
|
||
def test_disk_marker_reason_preserved_in_memory(self):
|
||
"""Disk marker reason is loaded into _install_failure_reason, not a generic tag."""
|
||
from tools.tirith_security import _resolve_tirith_path, _INSTALL_FAILED
|
||
_tirith_mod._resolved_path = None
|
||
|
||
# First call: disk marker with cosign_missing is active, cosign still absent
|
||
with patch("tools.tirith_security.shutil.which", return_value=None), \
|
||
patch("tools.tirith_security._hermes_bin_dir", return_value="/nonexistent"), \
|
||
patch("tools.tirith_security._read_failure_reason", return_value="cosign_missing"), \
|
||
patch("tools.tirith_security._is_install_failed_on_disk", return_value=True):
|
||
_resolve_tirith_path("tirith")
|
||
assert _tirith_mod._resolved_path is _INSTALL_FAILED
|
||
assert _tirith_mod._install_failure_reason == "cosign_missing"
|
||
|
||
# Second call: cosign now on PATH → in-memory retry fires
|
||
def _which_side_effect(name):
|
||
if name == "tirith":
|
||
return None
|
||
if name == "cosign":
|
||
return "/usr/local/bin/cosign"
|
||
return None
|
||
|
||
with patch("tools.tirith_security.shutil.which", side_effect=_which_side_effect), \
|
||
patch("tools.tirith_security._hermes_bin_dir", return_value="/nonexistent"), \
|
||
patch("tools.tirith_security._is_install_failed_on_disk", return_value=False), \
|
||
patch("tools.tirith_security._install_tirith", return_value=("/new/tirith", "")) as mock_install, \
|
||
patch("tools.tirith_security._clear_install_failed"):
|
||
result = _resolve_tirith_path("tirith")
|
||
mock_install.assert_called_once()
|
||
assert result == "/new/tirith"
|
||
|
||
_tirith_mod._resolved_path = None
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# HERMES_HOME isolation
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class TestHermesHomeIsolation:
|
||
def test_hermes_bin_dir_respects_hermes_home(self):
|
||
"""_hermes_bin_dir must use HERMES_HOME, not hardcoded ~/.hermes."""
|
||
from tools.tirith_security import _hermes_bin_dir
|
||
import tempfile
|
||
tmpdir = tempfile.mkdtemp()
|
||
with patch.dict(os.environ, {"HERMES_HOME": tmpdir}):
|
||
result = _hermes_bin_dir()
|
||
assert result == os.path.join(tmpdir, "bin")
|
||
assert os.path.isdir(result)
|
||
|
||
def test_failure_marker_respects_hermes_home(self):
|
||
"""_failure_marker_path must use HERMES_HOME, not hardcoded ~/.hermes."""
|
||
from tools.tirith_security import _failure_marker_path
|
||
with patch.dict(os.environ, {"HERMES_HOME": "/custom/hermes"}):
|
||
result = _failure_marker_path()
|
||
assert result == "/custom/hermes/.tirith-install-failed"
|
||
|
||
def test_conftest_isolation_prevents_real_home_writes(self):
|
||
"""The conftest autouse fixture sets HERMES_HOME; verify it's active."""
|
||
hermes_home = os.getenv("HERMES_HOME")
|
||
assert hermes_home is not None, "HERMES_HOME should be set by conftest"
|
||
assert "hermes_test" in hermes_home, "Should point to test temp dir"
|
||
|
||
def test_get_hermes_home_fallback(self):
|
||
"""Without HERMES_HOME set, falls back to ~/.hermes."""
|
||
from tools.tirith_security import _get_hermes_home
|
||
with patch.dict(os.environ, {}, clear=True):
|
||
# Remove HERMES_HOME entirely
|
||
os.environ.pop("HERMES_HOME", None)
|
||
result = _get_hermes_home()
|
||
assert result == os.path.join(os.path.expanduser("~"), ".hermes")
|