Files
hermes-agent/tests/tools/test_tirith_security.py
Teknium e3f9894caf fix: send_animation metadata, MarkdownV2 inline code splitting, tirith cosign-free install (#1626)
* 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).
2026-03-16 23:39:41 -07:00

1007 lines
50 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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")