Files
hermes-agent/tests/tools/test_tirith_security.py
sheeki003 375ce8a881 feat(security): add tirith pre-exec command scanning
Integrate tirith as a pre-execution security scanner that detects
homograph URLs, pipe-to-interpreter patterns, terminal injection,
zero-width Unicode, and environment variable manipulation — threats
the existing 50-pattern dangerous command detector doesn't cover.

Architecture: gather-then-decide — both tirith and the dangerous
command detector run before any approval prompt, preventing gateway
force=True replay from bypassing one check when only the other was
shown to the user.

New files:
- tools/tirith_security.py: subprocess wrapper with auto-installer,
  mandatory cosign provenance verification, non-blocking background
  download, disk-persistent failure markers with retryable-cause
  tracking (cosign_missing auto-clears when cosign appears on PATH)
- tests/tools/test_tirith_security.py: 62 tests covering exit code
  mapping, fail_open, cosign verification, background install,
  HERMES_HOME isolation, and failure recovery
- tests/tools/test_command_guards.py: 21 integration tests for the
  combined guard orchestration

Modified files:
- tools/approval.py: add check_all_command_guards() orchestrator,
  add allow_permanent parameter to prompt_dangerous_approval()
- tools/terminal_tool.py: replace _check_dangerous_command with
  consolidated check_all_command_guards
- cli.py: update _approval_callback for allow_permanent kwarg,
  call ensure_installed() at startup
- gateway/run.py: iterate pattern_keys list on replay approval,
  call ensure_installed() at startup
- hermes_cli/config.py: add security config defaults, split
  commented sections for independent fallback
- cli-config.yaml.example: document tirith security config
2026-03-14 00:11:27 -07:00

959 lines
47 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
# ---------------------------------------------------------------------------
# Failed download caches the miss (Finding #1)
# ---------------------------------------------------------------------------
class TestFailedDownloadCaching:
@patch("tools.tirith_security._mark_install_failed")
@patch("tools.tirith_security._is_install_failed_on_disk", return_value=False)
@patch("tools.tirith_security._install_tirith", return_value=(None, "download_failed"))
@patch("tools.tirith_security.shutil.which", return_value=None)
def test_failed_install_cached_no_retry(self, mock_which, mock_install,
mock_disk_check, mock_mark):
"""After a failed download, subsequent resolves must not retry."""
from tools.tirith_security import _resolve_tirith_path, _INSTALL_FAILED
_tirith_mod._resolved_path = None
# First call: tries install, fails
_resolve_tirith_path("tirith")
assert mock_install.call_count == 1
assert _tirith_mod._resolved_path is _INSTALL_FAILED
mock_mark.assert_called_once_with("download_failed") # reason persisted
# Second call: hits the cache, does NOT call _install_tirith again
_resolve_tirith_path("tirith")
assert mock_install.call_count == 1 # still 1, not 2
_tirith_mod._resolved_path = None
@patch("tools.tirith_security._mark_install_failed")
@patch("tools.tirith_security._is_install_failed_on_disk", return_value=False)
@patch("tools.tirith_security._install_tirith", return_value=(None, "download_failed"))
@patch("tools.tirith_security.shutil.which", return_value=None)
@patch("tools.tirith_security.subprocess.run")
@patch("tools.tirith_security._load_security_config")
def test_failed_install_scan_uses_fail_open(self, mock_cfg, mock_run,
mock_which, mock_install,
mock_disk_check, mock_mark):
"""After cached miss, check_command_security hits OSError → fail_open."""
_tirith_mod._resolved_path = None
mock_cfg.return_value = {"tirith_enabled": True, "tirith_path": "tirith",
"tirith_timeout": 5, "tirith_fail_open": True}
mock_run.side_effect = FileNotFoundError("No such file: tirith")
# First command triggers install attempt + cached miss + scan
result = check_command_security("echo hello")
assert result["action"] == "allow"
assert mock_install.call_count == 1
# Second command: no install retry, just hits OSError → allow
result = check_command_security("echo world")
assert result["action"] == "allow"
assert mock_install.call_count == 1 # still 1
_tirith_mod._resolved_path = None
# ---------------------------------------------------------------------------
# Explicit path must not auto-download (Finding #2)
# ---------------------------------------------------------------------------
class TestExplicitPathNoAutoDownload:
@patch("tools.tirith_security._install_tirith")
@patch("tools.tirith_security.shutil.which", return_value=None)
def test_explicit_path_missing_no_download(self, mock_which, mock_install):
"""An explicit tirith_path that doesn't exist must NOT trigger download."""
from tools.tirith_security import _resolve_tirith_path, _INSTALL_FAILED
_tirith_mod._resolved_path = None
result = _resolve_tirith_path("/opt/custom/tirith")
# Should cache failure, not call _install_tirith
mock_install.assert_not_called()
assert _tirith_mod._resolved_path is _INSTALL_FAILED
assert "/opt/custom/tirith" in result
_tirith_mod._resolved_path = None
@patch("tools.tirith_security._install_tirith")
@patch("tools.tirith_security.shutil.which", return_value=None)
def test_tilde_explicit_path_missing_no_download(self, mock_which, mock_install):
"""An explicit ~/path that doesn't exist must NOT trigger download."""
from tools.tirith_security import _resolve_tirith_path, _INSTALL_FAILED
_tirith_mod._resolved_path = None
result = _resolve_tirith_path("~/bin/tirith")
mock_install.assert_not_called()
assert _tirith_mod._resolved_path is _INSTALL_FAILED
assert "~" not in result # tilde still expanded
_tirith_mod._resolved_path = None
@patch("tools.tirith_security._mark_install_failed")
@patch("tools.tirith_security._is_install_failed_on_disk", return_value=False)
@patch("tools.tirith_security._install_tirith", return_value=("/auto/tirith", ""))
@patch("tools.tirith_security.shutil.which", return_value=None)
def test_default_path_does_auto_download(self, mock_which, mock_install,
mock_disk_check, mock_mark):
"""The default bare 'tirith' SHOULD trigger auto-download."""
from tools.tirith_security import _resolve_tirith_path
_tirith_mod._resolved_path = None
result = _resolve_tirith_path("tirith")
mock_install.assert_called_once()
assert result == "/auto/tirith"
_tirith_mod._resolved_path = None
# ---------------------------------------------------------------------------
# Cosign provenance verification (P1)
# ---------------------------------------------------------------------------
class TestCosignVerification:
@patch("tools.tirith_security.subprocess.run")
@patch("tools.tirith_security.shutil.which", return_value="/usr/bin/cosign")
def test_cosign_pass(self, mock_which, mock_run):
"""cosign verify-blob exits 0 → returns True."""
from tools.tirith_security import _verify_cosign
mock_run.return_value = _mock_run(0, "Verified OK")
result = _verify_cosign("/tmp/checksums.txt", "/tmp/checksums.txt.sig",
"/tmp/checksums.txt.pem")
assert result is True
mock_run.assert_called_once()
args = mock_run.call_args[0][0]
assert "verify-blob" in args
assert "--certificate-identity-regexp" in args
@patch("tools.tirith_security.subprocess.run")
@patch("tools.tirith_security.shutil.which", return_value="/usr/bin/cosign")
def test_cosign_identity_pinned_to_release_workflow(self, mock_which, mock_run):
"""Identity regexp must pin to the release workflow, not the whole repo."""
from tools.tirith_security import _verify_cosign
mock_run.return_value = _mock_run(0, "Verified OK")
_verify_cosign("/tmp/checksums.txt", "/tmp/sig", "/tmp/cert")
args = mock_run.call_args[0][0]
# Find the value after --certificate-identity-regexp
idx = args.index("--certificate-identity-regexp")
identity = args[idx + 1]
# The identity contains regex-escaped dots
assert "workflows/release" in identity
assert "refs/tags/v" in identity
@patch("tools.tirith_security.subprocess.run")
@patch("tools.tirith_security.shutil.which", return_value="/usr/bin/cosign")
def test_cosign_fail_aborts(self, mock_which, mock_run):
"""cosign verify-blob exits non-zero → returns False (abort install)."""
from tools.tirith_security import _verify_cosign
mock_run.return_value = _mock_run(1, "", "signature mismatch")
result = _verify_cosign("/tmp/checksums.txt", "/tmp/checksums.txt.sig",
"/tmp/checksums.txt.pem")
assert result is False
@patch("tools.tirith_security.shutil.which", return_value=None)
def test_cosign_not_found_returns_none(self, mock_which):
"""cosign not on PATH → returns None (proceed with SHA-256 only)."""
from tools.tirith_security import _verify_cosign
result = _verify_cosign("/tmp/checksums.txt", "/tmp/checksums.txt.sig",
"/tmp/checksums.txt.pem")
assert result is None
@patch("tools.tirith_security.subprocess.run",
side_effect=subprocess.TimeoutExpired("cosign", 15))
@patch("tools.tirith_security.shutil.which", return_value="/usr/bin/cosign")
def test_cosign_timeout_returns_none(self, mock_which, mock_run):
"""cosign times out → returns None (proceed with SHA-256 only)."""
from tools.tirith_security import _verify_cosign
result = _verify_cosign("/tmp/checksums.txt", "/tmp/checksums.txt.sig",
"/tmp/checksums.txt.pem")
assert result is None
@patch("tools.tirith_security.subprocess.run",
side_effect=OSError("exec format error"))
@patch("tools.tirith_security.shutil.which", return_value="/usr/bin/cosign")
def test_cosign_os_error_returns_none(self, mock_which, mock_run):
"""cosign OSError → returns None (proceed with SHA-256 only)."""
from tools.tirith_security import _verify_cosign
result = _verify_cosign("/tmp/checksums.txt", "/tmp/checksums.txt.sig",
"/tmp/checksums.txt.pem")
assert result is None
@patch("tools.tirith_security._verify_cosign", return_value=False)
@patch("tools.tirith_security.shutil.which", return_value="/usr/local/bin/cosign")
@patch("tools.tirith_security._download_file")
@patch("tools.tirith_security._detect_target", return_value="aarch64-apple-darwin")
def test_install_aborts_on_cosign_rejection(self, mock_target, mock_dl,
mock_which, mock_cosign):
"""_install_tirith returns None when cosign rejects the signature."""
from tools.tirith_security import _install_tirith
path, reason = _install_tirith()
assert path is None
assert reason == "cosign_verification_failed"
@patch("tools.tirith_security.shutil.which", return_value=None)
@patch("tools.tirith_security._download_file")
@patch("tools.tirith_security._detect_target", return_value="aarch64-apple-darwin")
def test_install_aborts_when_cosign_missing(self, mock_target, mock_dl,
mock_which):
"""_install_tirith returns cosign_missing when cosign is not on PATH."""
from tools.tirith_security import _install_tirith
path, reason = _install_tirith()
assert path is None
assert reason == "cosign_missing"
@patch("tools.tirith_security._verify_cosign", return_value=None)
@patch("tools.tirith_security.shutil.which", return_value="/usr/local/bin/cosign")
@patch("tools.tirith_security._download_file")
@patch("tools.tirith_security._detect_target", return_value="aarch64-apple-darwin")
def test_install_aborts_when_cosign_exec_fails(self, mock_target, mock_dl,
mock_which, mock_cosign):
"""_install_tirith returns cosign_exec_failed when cosign exists but fails."""
from tools.tirith_security import _install_tirith
path, reason = _install_tirith()
assert path is None
assert reason == "cosign_exec_failed"
@patch("tools.tirith_security._download_file")
@patch("tools.tirith_security._detect_target", return_value="aarch64-apple-darwin")
def test_install_aborts_when_cosign_artifacts_missing(self, mock_target,
mock_dl):
"""_install_tirith returns None when .sig/.pem downloads fail (404)."""
from tools.tirith_security import _install_tirith
import urllib.request
def _dl_side_effect(url, dest, timeout=10):
if url.endswith(".sig") or url.endswith(".pem"):
raise urllib.request.URLError("404 Not Found")
mock_dl.side_effect = _dl_side_effect
path, reason = _install_tirith()
assert path is None
assert reason == "cosign_artifacts_unavailable"
@patch("tools.tirith_security.tarfile.open")
@patch("tools.tirith_security._verify_checksum", return_value=True)
@patch("tools.tirith_security._verify_cosign", return_value=True)
@patch("tools.tirith_security.shutil.which", return_value="/usr/local/bin/cosign")
@patch("tools.tirith_security._download_file")
@patch("tools.tirith_security._detect_target", return_value="aarch64-apple-darwin")
def test_install_proceeds_when_cosign_passes(self, mock_target, mock_dl,
mock_which, mock_cosign,
mock_checksum, mock_tarfile):
"""_install_tirith proceeds only when cosign explicitly passes (True)."""
from tools.tirith_security import _install_tirith
# Mock tarfile — empty archive means "binary not found" return
mock_tar = MagicMock()
mock_tar.__enter__ = MagicMock(return_value=mock_tar)
mock_tar.__exit__ = MagicMock(return_value=False)
mock_tar.getmembers.return_value = []
mock_tarfile.return_value = mock_tar
path, reason = _install_tirith()
assert path is None # no binary in mock archive, but got past cosign
assert reason == "binary_not_in_archive"
assert mock_checksum.called # reached SHA-256 step
assert mock_cosign.called # cosign was invoked
# ---------------------------------------------------------------------------
# Background install / non-blocking startup (P2)
# ---------------------------------------------------------------------------
class TestBackgroundInstall:
def test_ensure_installed_non_blocking(self):
"""ensure_installed must return immediately when download needed."""
_tirith_mod._resolved_path = None
with patch("tools.tirith_security._load_security_config",
return_value={"tirith_enabled": True, "tirith_path": "tirith",
"tirith_timeout": 5, "tirith_fail_open": True}), \
patch("tools.tirith_security.shutil.which", return_value=None), \
patch("tools.tirith_security._hermes_bin_dir", return_value="/nonexistent"), \
patch("tools.tirith_security._is_install_failed_on_disk", return_value=False), \
patch("tools.tirith_security.threading.Thread") as MockThread:
mock_thread = MagicMock()
mock_thread.is_alive.return_value = False
MockThread.return_value = mock_thread
result = ensure_installed()
assert result is None # not available yet
MockThread.assert_called_once()
mock_thread.start.assert_called_once()
_tirith_mod._resolved_path = None
def test_ensure_installed_skips_on_disk_marker(self):
"""ensure_installed skips network attempt when disk marker exists."""
_tirith_mod._resolved_path = None
with patch("tools.tirith_security._load_security_config",
return_value={"tirith_enabled": True, "tirith_path": "tirith",
"tirith_timeout": 5, "tirith_fail_open": True}), \
patch("tools.tirith_security.shutil.which", return_value=None), \
patch("tools.tirith_security._hermes_bin_dir", return_value="/nonexistent"), \
patch("tools.tirith_security._read_failure_reason", return_value="download_failed"), \
patch("tools.tirith_security._is_install_failed_on_disk", return_value=True):
result = ensure_installed()
assert result is None
assert _tirith_mod._resolved_path is _tirith_mod._INSTALL_FAILED
assert _tirith_mod._install_failure_reason == "download_failed"
_tirith_mod._resolved_path = None
def test_resolve_returns_default_when_thread_alive(self):
"""_resolve_tirith_path returns default while background thread runs."""
from tools.tirith_security import _resolve_tirith_path
_tirith_mod._resolved_path = None
mock_thread = MagicMock()
mock_thread.is_alive.return_value = True
_tirith_mod._install_thread = mock_thread
with patch("tools.tirith_security.shutil.which", return_value=None), \
patch("tools.tirith_security._hermes_bin_dir", return_value="/nonexistent"):
result = _resolve_tirith_path("tirith")
assert result == "tirith" # returns configured default, doesn't block
_tirith_mod._install_thread = None
_tirith_mod._resolved_path = None
def test_resolve_picks_up_background_result(self):
"""After background thread finishes, _resolve_tirith_path uses cached path."""
from tools.tirith_security import _resolve_tirith_path
# Simulate background thread having completed and set the path
_tirith_mod._resolved_path = "/usr/local/bin/tirith"
result = _resolve_tirith_path("tirith")
assert result == "/usr/local/bin/tirith"
_tirith_mod._resolved_path = None
# ---------------------------------------------------------------------------
# Disk failure marker persistence (P2)
# ---------------------------------------------------------------------------
class TestDiskFailureMarker:
def test_mark_and_check(self):
"""Writing then reading the marker should work."""
import tempfile
tmpdir = tempfile.mkdtemp()
marker = os.path.join(tmpdir, ".tirith-install-failed")
with patch("tools.tirith_security._failure_marker_path", return_value=marker):
from tools.tirith_security import (
_mark_install_failed, _is_install_failed_on_disk, _clear_install_failed,
)
assert not _is_install_failed_on_disk()
_mark_install_failed("download_failed")
assert _is_install_failed_on_disk()
_clear_install_failed()
assert not _is_install_failed_on_disk()
def test_expired_marker_ignored(self):
"""Marker older than TTL should be ignored."""
import tempfile
tmpdir = tempfile.mkdtemp()
marker = os.path.join(tmpdir, ".tirith-install-failed")
with patch("tools.tirith_security._failure_marker_path", return_value=marker):
from tools.tirith_security import _mark_install_failed, _is_install_failed_on_disk
_mark_install_failed("download_failed")
# Backdate the file past 24h TTL
old_time = time.time() - 90000 # 25 hours ago
os.utime(marker, (old_time, old_time))
assert not _is_install_failed_on_disk()
def test_cosign_missing_marker_clears_when_cosign_appears(self):
"""Marker with 'cosign_missing' reason clears if cosign is now on PATH."""
import tempfile
tmpdir = tempfile.mkdtemp()
marker = os.path.join(tmpdir, ".tirith-install-failed")
with patch("tools.tirith_security._failure_marker_path", return_value=marker):
from tools.tirith_security import _mark_install_failed, _is_install_failed_on_disk
_mark_install_failed("cosign_missing")
assert _is_install_failed_on_disk() # cosign still absent
# Now cosign appears on PATH
with patch("tools.tirith_security.shutil.which", return_value="/usr/local/bin/cosign"):
assert not _is_install_failed_on_disk()
# Marker file should have been removed
assert not os.path.exists(marker)
def test_cosign_missing_marker_stays_when_cosign_still_absent(self):
"""Marker with 'cosign_missing' reason stays if cosign is still missing."""
import tempfile
tmpdir = tempfile.mkdtemp()
marker = os.path.join(tmpdir, ".tirith-install-failed")
with patch("tools.tirith_security._failure_marker_path", return_value=marker):
from tools.tirith_security import _mark_install_failed, _is_install_failed_on_disk
_mark_install_failed("cosign_missing")
with patch("tools.tirith_security.shutil.which", return_value=None):
assert _is_install_failed_on_disk()
def test_non_cosign_marker_not_affected_by_cosign_presence(self):
"""Markers with other reasons are NOT cleared by cosign appearing."""
import tempfile
tmpdir = tempfile.mkdtemp()
marker = os.path.join(tmpdir, ".tirith-install-failed")
with patch("tools.tirith_security._failure_marker_path", return_value=marker):
from tools.tirith_security import _mark_install_failed, _is_install_failed_on_disk
_mark_install_failed("download_failed")
with patch("tools.tirith_security.shutil.which", return_value="/usr/local/bin/cosign"):
assert _is_install_failed_on_disk() # still failed
@patch("tools.tirith_security._mark_install_failed")
@patch("tools.tirith_security._is_install_failed_on_disk", return_value=False)
@patch("tools.tirith_security._install_tirith", return_value=(None, "cosign_missing"))
@patch("tools.tirith_security.shutil.which", return_value=None)
def test_sync_resolve_persists_failure(self, mock_which, mock_install,
mock_disk_check, mock_mark):
"""Synchronous _resolve_tirith_path persists failure to disk."""
from tools.tirith_security import _resolve_tirith_path
_tirith_mod._resolved_path = None
_resolve_tirith_path("tirith")
mock_mark.assert_called_once_with("cosign_missing")
_tirith_mod._resolved_path = None
@patch("tools.tirith_security._clear_install_failed")
@patch("tools.tirith_security._is_install_failed_on_disk", return_value=False)
@patch("tools.tirith_security._install_tirith", return_value=("/installed/tirith", ""))
@patch("tools.tirith_security.shutil.which", return_value=None)
def test_sync_resolve_clears_marker_on_success(self, mock_which, mock_install,
mock_disk_check, mock_clear):
"""Successful install clears the disk failure marker."""
from tools.tirith_security import _resolve_tirith_path
_tirith_mod._resolved_path = None
result = _resolve_tirith_path("tirith")
assert result == "/installed/tirith"
mock_clear.assert_called_once()
_tirith_mod._resolved_path = None
def test_sync_resolve_skips_install_on_disk_marker(self):
"""_resolve_tirith_path skips download when disk marker is recent."""
from tools.tirith_security import _resolve_tirith_path, _INSTALL_FAILED
_tirith_mod._resolved_path = None
with patch("tools.tirith_security.shutil.which", return_value=None), \
patch("tools.tirith_security._hermes_bin_dir", return_value="/nonexistent"), \
patch("tools.tirith_security._read_failure_reason", return_value="download_failed"), \
patch("tools.tirith_security._is_install_failed_on_disk", return_value=True), \
patch("tools.tirith_security._install_tirith") as mock_install:
_resolve_tirith_path("tirith")
mock_install.assert_not_called()
assert _tirith_mod._resolved_path is _INSTALL_FAILED
assert _tirith_mod._install_failure_reason == "download_failed"
_tirith_mod._resolved_path = None
def test_install_failed_still_checks_local_paths(self):
"""After _INSTALL_FAILED, a manual install on PATH is picked up."""
from tools.tirith_security import _resolve_tirith_path, _INSTALL_FAILED
_tirith_mod._resolved_path = _INSTALL_FAILED
with patch("tools.tirith_security.shutil.which", return_value="/usr/local/bin/tirith"), \
patch("tools.tirith_security._clear_install_failed") as mock_clear:
result = _resolve_tirith_path("tirith")
assert result == "/usr/local/bin/tirith"
assert _tirith_mod._resolved_path == "/usr/local/bin/tirith"
mock_clear.assert_called_once()
_tirith_mod._resolved_path = None
def test_install_failed_recovers_from_hermes_bin(self):
"""After _INSTALL_FAILED, manual install in HERMES_HOME/bin is picked up."""
from tools.tirith_security import _resolve_tirith_path, _INSTALL_FAILED
import tempfile
tmpdir = tempfile.mkdtemp()
hermes_bin = os.path.join(tmpdir, "tirith")
# Create a fake executable
with open(hermes_bin, "w") as f:
f.write("#!/bin/sh\n")
os.chmod(hermes_bin, 0o755)
_tirith_mod._resolved_path = _INSTALL_FAILED
with patch("tools.tirith_security.shutil.which", return_value=None), \
patch("tools.tirith_security._hermes_bin_dir", return_value=tmpdir), \
patch("tools.tirith_security._clear_install_failed") as mock_clear:
result = _resolve_tirith_path("tirith")
assert result == hermes_bin
assert _tirith_mod._resolved_path == hermes_bin
mock_clear.assert_called_once()
_tirith_mod._resolved_path = None
def test_install_failed_skips_network_when_local_absent(self):
"""After _INSTALL_FAILED, if local checks fail, network is NOT retried."""
from tools.tirith_security import _resolve_tirith_path, _INSTALL_FAILED
_tirith_mod._resolved_path = _INSTALL_FAILED
with patch("tools.tirith_security.shutil.which", return_value=None), \
patch("tools.tirith_security._hermes_bin_dir", return_value="/nonexistent"), \
patch("tools.tirith_security._install_tirith") as mock_install:
result = _resolve_tirith_path("tirith")
assert result == "tirith" # fallback to configured path
mock_install.assert_not_called()
_tirith_mod._resolved_path = None
def test_cosign_missing_disk_marker_allows_retry(self):
"""Disk marker with cosign_missing reason allows retry when cosign appears."""
from tools.tirith_security import _resolve_tirith_path, _INSTALL_FAILED
_tirith_mod._resolved_path = None
# _is_install_failed_on_disk sees "cosign_missing" + cosign on PATH → returns False
with patch("tools.tirith_security.shutil.which", return_value=None), \
patch("tools.tirith_security._hermes_bin_dir", return_value="/nonexistent"), \
patch("tools.tirith_security._is_install_failed_on_disk", return_value=False), \
patch("tools.tirith_security._install_tirith", return_value=("/new/tirith", "")) as mock_install, \
patch("tools.tirith_security._clear_install_failed"):
result = _resolve_tirith_path("tirith")
mock_install.assert_called_once() # network retry happened
assert result == "/new/tirith"
_tirith_mod._resolved_path = None
def test_in_memory_cosign_missing_retries_when_cosign_appears(self):
"""In-memory _INSTALL_FAILED with cosign_missing retries when cosign appears."""
from tools.tirith_security import _resolve_tirith_path, _INSTALL_FAILED
_tirith_mod._resolved_path = _INSTALL_FAILED
_tirith_mod._install_failure_reason = "cosign_missing"
def _which_side_effect(name):
if name == "tirith":
return None # tirith not on PATH
if name == "cosign":
return "/usr/local/bin/cosign" # cosign now available
return None
with patch("tools.tirith_security.shutil.which", side_effect=_which_side_effect), \
patch("tools.tirith_security._hermes_bin_dir", return_value="/nonexistent"), \
patch("tools.tirith_security._is_install_failed_on_disk", return_value=False), \
patch("tools.tirith_security._install_tirith", return_value=("/new/tirith", "")) as mock_install, \
patch("tools.tirith_security._clear_install_failed"):
result = _resolve_tirith_path("tirith")
mock_install.assert_called_once() # network retry happened
assert result == "/new/tirith"
_tirith_mod._resolved_path = None
def test_in_memory_cosign_exec_failed_not_retried(self):
"""In-memory _INSTALL_FAILED with cosign_exec_failed is NOT retried."""
from tools.tirith_security import _resolve_tirith_path, _INSTALL_FAILED
_tirith_mod._resolved_path = _INSTALL_FAILED
_tirith_mod._install_failure_reason = "cosign_exec_failed"
with patch("tools.tirith_security.shutil.which", return_value=None), \
patch("tools.tirith_security._hermes_bin_dir", return_value="/nonexistent"), \
patch("tools.tirith_security._install_tirith") as mock_install:
result = _resolve_tirith_path("tirith")
assert result == "tirith" # fallback
mock_install.assert_not_called()
_tirith_mod._resolved_path = None
def test_in_memory_cosign_missing_stays_when_cosign_still_absent(self):
"""In-memory cosign_missing is NOT retried when cosign is still absent."""
from tools.tirith_security import _resolve_tirith_path, _INSTALL_FAILED
_tirith_mod._resolved_path = _INSTALL_FAILED
_tirith_mod._install_failure_reason = "cosign_missing"
with patch("tools.tirith_security.shutil.which", return_value=None), \
patch("tools.tirith_security._hermes_bin_dir", return_value="/nonexistent"), \
patch("tools.tirith_security._install_tirith") as mock_install:
result = _resolve_tirith_path("tirith")
assert result == "tirith" # fallback
mock_install.assert_not_called()
_tirith_mod._resolved_path = None
def test_disk_marker_reason_preserved_in_memory(self):
"""Disk marker reason is loaded into _install_failure_reason, not a generic tag."""
from tools.tirith_security import _resolve_tirith_path, _INSTALL_FAILED
_tirith_mod._resolved_path = None
# First call: disk marker with cosign_missing is active, cosign still absent
with patch("tools.tirith_security.shutil.which", return_value=None), \
patch("tools.tirith_security._hermes_bin_dir", return_value="/nonexistent"), \
patch("tools.tirith_security._read_failure_reason", return_value="cosign_missing"), \
patch("tools.tirith_security._is_install_failed_on_disk", return_value=True):
_resolve_tirith_path("tirith")
assert _tirith_mod._resolved_path is _INSTALL_FAILED
assert _tirith_mod._install_failure_reason == "cosign_missing"
# Second call: cosign now on PATH → in-memory retry fires
def _which_side_effect(name):
if name == "tirith":
return None
if name == "cosign":
return "/usr/local/bin/cosign"
return None
with patch("tools.tirith_security.shutil.which", side_effect=_which_side_effect), \
patch("tools.tirith_security._hermes_bin_dir", return_value="/nonexistent"), \
patch("tools.tirith_security._is_install_failed_on_disk", return_value=False), \
patch("tools.tirith_security._install_tirith", return_value=("/new/tirith", "")) as mock_install, \
patch("tools.tirith_security._clear_install_failed"):
result = _resolve_tirith_path("tirith")
mock_install.assert_called_once()
assert result == "/new/tirith"
_tirith_mod._resolved_path = None
# ---------------------------------------------------------------------------
# HERMES_HOME isolation
# ---------------------------------------------------------------------------
class TestHermesHomeIsolation:
def test_hermes_bin_dir_respects_hermes_home(self):
"""_hermes_bin_dir must use HERMES_HOME, not hardcoded ~/.hermes."""
from tools.tirith_security import _hermes_bin_dir
import tempfile
tmpdir = tempfile.mkdtemp()
with patch.dict(os.environ, {"HERMES_HOME": tmpdir}):
result = _hermes_bin_dir()
assert result == os.path.join(tmpdir, "bin")
assert os.path.isdir(result)
def test_failure_marker_respects_hermes_home(self):
"""_failure_marker_path must use HERMES_HOME, not hardcoded ~/.hermes."""
from tools.tirith_security import _failure_marker_path
with patch.dict(os.environ, {"HERMES_HOME": "/custom/hermes"}):
result = _failure_marker_path()
assert result == "/custom/hermes/.tirith-install-failed"
def test_conftest_isolation_prevents_real_home_writes(self):
"""The conftest autouse fixture sets HERMES_HOME; verify it's active."""
hermes_home = os.getenv("HERMES_HOME")
assert hermes_home is not None, "HERMES_HOME should be set by conftest"
assert "hermes_test" in hermes_home, "Should point to test temp dir"
def test_get_hermes_home_fallback(self):
"""Without HERMES_HOME set, falls back to ~/.hermes."""
from tools.tirith_security import _get_hermes_home
with patch.dict(os.environ, {}, clear=True):
# Remove HERMES_HOME entirely
os.environ.pop("HERMES_HOME", None)
result = _get_hermes_home()
assert result == os.path.join(os.path.expanduser("~"), ".hermes")