* fix: Anthropic OAuth compatibility — Claude Code identity fingerprinting
Anthropic routes OAuth/subscription requests based on Claude Code's
identity markers. Without them, requests get intermittent 500 errors
(~25% failure rate observed). This matches what pi-ai (clawdbot) and
OpenCode both implement for OAuth compatibility.
Changes (OAuth tokens only — API key users unaffected):
1. Headers: user-agent 'claude-cli/2.1.2 (external, cli)' + x-app 'cli'
2. System prompt: prepend 'You are Claude Code, Anthropic's official CLI'
3. System prompt sanitization: replace Hermes/Nous references
4. Tool names: prefix with 'mcp_' (Claude Code convention for non-native tools)
5. Tool name stripping: remove 'mcp_' prefix from response tool calls
Before: 9/12 OK, 1 hard fail, 4 needed retries (~25% error rate)
After: 16/16 OK, 0 failures, 0 retries (0% error rate)
* fix: three gateway issues from user error logs
1. send_animation missing metadata kwarg (base.py)
- Base class send_animation lacked the metadata parameter that the
call site in base.py line 917 passes. Telegram's override accepted
it, but any platform without an override (Discord, Slack, etc.)
hit TypeError. Added metadata to base class signature.
2. MarkdownV2 split-inside-inline-code (base.py truncate_message)
- truncate_message could split at a space inside an inline code span
(e.g. `function(arg1, arg2)`), leaving an unpaired backtick and
unescaped parentheses in the chunk. Telegram rejects with
'character ( is reserved'. Added inline code awareness to the
split-point finder — detects odd backtick counts and moves the
split before the code span.
3. tirith auto-install without cosign (tirith_security.py)
- Previously required cosign on PATH for auto-install, blocking
install entirely with a warning if missing. Now proceeds with
SHA-256 checksum verification only when cosign is unavailable.
Cosign is still used for full supply chain verification when
present. If cosign IS present but verification explicitly fails,
install is still aborted (tampered release).
1007 lines
50 KiB
Python
1007 lines
50 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
|
||
|
||
@patch("tools.tirith_security._load_security_config")
|
||
def test_startup_prefetch_can_suppress_install_failure_logs(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(log_failures=False)
|
||
assert result is None
|
||
assert MockThread.call_args.kwargs["kwargs"] == {"log_failures": False}
|
||
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.tarfile.open")
|
||
@patch("tools.tirith_security._verify_checksum", return_value=True)
|
||
@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_proceeds_without_cosign(self, mock_target, mock_dl,
|
||
mock_which, mock_checksum,
|
||
mock_tarfile):
|
||
"""_install_tirith proceeds with SHA-256 only when cosign is not on PATH."""
|
||
from tools.tirith_security import _install_tirith
|
||
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()
|
||
# Reaches extraction (no binary in mock archive), but got past cosign
|
||
assert path is None
|
||
assert reason == "binary_not_in_archive"
|
||
assert mock_checksum.called # SHA-256 verification ran
|
||
|
||
@patch("tools.tirith_security.tarfile.open")
|
||
@patch("tools.tirith_security._verify_checksum", return_value=True)
|
||
@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_proceeds_when_cosign_exec_fails(self, mock_target, mock_dl,
|
||
mock_which, mock_cosign,
|
||
mock_checksum, mock_tarfile):
|
||
"""_install_tirith falls back to SHA-256 when cosign exists but fails to execute."""
|
||
from tools.tirith_security import _install_tirith
|
||
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
|
||
assert reason == "binary_not_in_archive" # got past cosign
|
||
assert mock_checksum.called
|
||
|
||
@patch("tools.tirith_security.tarfile.open")
|
||
@patch("tools.tirith_security._verify_checksum", 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_artifacts_missing(self, mock_target,
|
||
mock_dl, mock_which,
|
||
mock_checksum, mock_tarfile):
|
||
"""_install_tirith proceeds with SHA-256 when .sig/.pem downloads fail."""
|
||
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
|
||
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
|
||
assert reason == "binary_not_in_archive" # got past cosign
|
||
assert mock_checksum.called
|
||
|
||
@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")
|