mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-30 19:09:28 +00:00
Second, deeper pass over tools/gateway/hermes_cli plus first pass over the trees wave 1 missed (acp, acp_adapter, skills, computer_use, docker, dashboard, conformance, monitoring, secret_sources, hermes_state, providers). Same rubric as wave 1 (AGENTS.md test policy); security, alternation/caching invariants, issue-number regressions, and E2E kept. Real test-quality fixes found and rooted out along the way: - tests/tools/test_command_guards.py made real auxiliary-LLM HTTPS calls (DEFAULT_CONFIG smart-approval leaked in) — pinned approval mode=manual via autouse fixture: 17.4s → 0.4s. - test_model_switch_custom_providers.py / test_user_providers_model_switch.py silently probed live provider catalogs (~2s/test) — stubbed cached_provider_model_ids/provider_model_ids/fetch_api_models. - test_telegram_noise_filter.py: 15-platform copy-paste matrix over shared gateway.run logic → 3 representative platforms (55s → 3.9s). - test_gateway_shutdown.py: stop()'s 5s interrupt-deadline loop spun on MagicMock agents — interrupt.side_effect now clears _running_agents (22s → 1.0s). - test_gateway_inactivity_timeout.py poll-harness timings shrunk 3-5x (24s → 1.1s); test_mcp_stability.py backoff/SIGTERM-grace sleeps patched (15.4s → 2.5s); test_async_delegation.py negative-drain wait 5s → 0.5s. - test_telegram_init_deadline.py: loop-block margin restored to 1.0s with rationale comment — the watchdog-dump assertion needs the loop blocked well past deadline+grace under parallel load (flaked once in the 40-worker verification run at a 0.2s margin). Verification: full hermetic suite via scripts/run_tests.sh — 2,438 files, 21,718 tests passed, 0 failed, 293.9s wall. Suite totals vs original baseline: 46,820 → 19,757 test functions (−57.8%), wall 583.5s → 293.9s (−50%), subprocess CPU 13,564s → 11,623s.
716 lines
33 KiB
Python
716 lines
33 KiB
Python
"""Tests for the tirith security scanning subprocess wrapper."""
|
||
|
||
import io
|
||
import json
|
||
import os
|
||
import subprocess
|
||
import tarfile
|
||
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 = ""
|
||
_tirith_mod._crash_count = 0
|
||
_tirith_mod._circuit_open = False
|
||
yield
|
||
_tirith_mod._resolved_path = None
|
||
_tirith_mod._install_thread = None
|
||
_tirith_mod._install_failure_reason = ""
|
||
_tirith_mod._crash_count = 0
|
||
_tirith_mod._circuit_open = False
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 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_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_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_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_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
|
||
# ---------------------------------------------------------------------------
|
||
|
||
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"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Findings cap + summary cap
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class TestCaps:
|
||
@patch("tools.tirith_security.subprocess.run")
|
||
@patch("tools.tirith_security._load_security_config")
|
||
def test_findings_and_summary_capped(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, "x" * 1000))
|
||
result = check_command_security("cmd")
|
||
assert len(result["findings"]) == 50
|
||
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")
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 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
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Unsupported platform (Windows etc.) — silent fast-path everywhere
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class TestUnsupportedPlatform:
|
||
"""When _detect_target() returns None (no tirith binary for this OS+arch),
|
||
the entire subsystem must stay silent: no PATH probes, no download thread,
|
||
no disk failure marker, no spawn attempts, no CLI banner. Pattern-matching
|
||
guards still cover the gap; tirith content scanning is just absent."""
|
||
|
||
@pytest.mark.parametrize("system, machine, expected", [
|
||
("Linux", "x86_64", True),
|
||
("Windows", "AMD64", False),
|
||
("Linux", "riscv64", False),
|
||
])
|
||
def test_is_platform_supported(self, system, machine, expected):
|
||
with patch("tools.tirith_security.platform.system", return_value=system), \
|
||
patch("tools.tirith_security.platform.machine", return_value=machine):
|
||
assert _tirith_mod.is_platform_supported() is expected
|
||
|
||
|
||
@patch("tools.tirith_security._load_security_config")
|
||
def test_check_command_security_unsupported_allows_silently(self, mock_cfg):
|
||
"""Windows: skip the resolver and spawn entirely — return allow with
|
||
an empty summary so callers can't accidentally surface 'tirith
|
||
unavailable' messaging to the user."""
|
||
mock_cfg.return_value = {"tirith_enabled": True, "tirith_path": "tirith",
|
||
"tirith_timeout": 5, "tirith_fail_open": True}
|
||
with patch("tools.tirith_security.is_platform_supported", return_value=False), \
|
||
patch("tools.tirith_security.subprocess.run") as mock_run, \
|
||
patch("tools.tirith_security._resolve_tirith_path") as mock_resolve:
|
||
result = check_command_security("rm -rf /")
|
||
assert result == {"action": "allow", "findings": [], "summary": ""}
|
||
mock_run.assert_not_called()
|
||
mock_resolve.assert_not_called()
|
||
|
||
@patch("tools.tirith_security._load_security_config")
|
||
def test_explicit_path_still_honored_on_unsupported_platform(self, mock_cfg):
|
||
"""If a user explicitly configured a tirith_path (e.g. they built it
|
||
themselves under WSL), the unsupported-platform short-circuit must
|
||
NOT override that — explicit config wins."""
|
||
mock_cfg.return_value = {"tirith_enabled": True,
|
||
"tirith_path": "/opt/custom/tirith",
|
||
"tirith_timeout": 5, "tirith_fail_open": True}
|
||
_tirith_mod._resolved_path = None
|
||
with patch("tools.tirith_security.is_platform_supported", return_value=False), \
|
||
patch("os.path.isfile", return_value=True), \
|
||
patch("os.access", return_value=True):
|
||
result = _tirith_mod._resolve_tirith_path("/opt/custom/tirith")
|
||
assert result == "/opt/custom/tirith"
|
||
assert _tirith_mod._resolved_path == "/opt/custom/tirith"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 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
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 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_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_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.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
|
||
|
||
|
||
class TestInstallArchiveMemberValidation:
|
||
def _write_archive(self, tmp_path, member: tarfile.TarInfo, data: bytes | None = None):
|
||
archive = tmp_path / "tirith-aarch64-apple-darwin.tar.gz"
|
||
checksums = tmp_path / "checksums.txt"
|
||
with tarfile.open(archive, "w:gz") as tar:
|
||
if data is None:
|
||
tar.addfile(member)
|
||
else:
|
||
tar.addfile(member, io.BytesIO(data))
|
||
checksums.write_text(
|
||
"ignored tirith-aarch64-apple-darwin.tar.gz\n",
|
||
encoding="utf-8",
|
||
)
|
||
return archive, checksums
|
||
|
||
def _download_side_effect(self, archive, checksums):
|
||
def _download(url, dest, timeout=10):
|
||
del timeout
|
||
if url.endswith(".tar.gz"):
|
||
with open(archive, "rb") as src, open(dest, "wb") as dst:
|
||
dst.write(src.read())
|
||
return
|
||
if url.endswith("checksums.txt"):
|
||
with open(checksums, "rb") as src, open(dest, "wb") as dst:
|
||
dst.write(src.read())
|
||
return
|
||
raise AssertionError(f"unexpected download URL: {url}")
|
||
|
||
return _download
|
||
|
||
@patch("tools.tirith_security._verify_checksum", return_value=True)
|
||
@patch("tools.tirith_security.shutil.which", return_value=None)
|
||
@patch("tools.tirith_security._detect_target", return_value="aarch64-apple-darwin")
|
||
def test_install_extracts_regular_tirith_member(self, mock_target, mock_which,
|
||
mock_checksum, tmp_path, monkeypatch):
|
||
"""A valid regular-file tirith member is installed as a plain file."""
|
||
del mock_target, mock_which, mock_checksum
|
||
from tools.tirith_security import _install_tirith
|
||
|
||
payload = b"#!/bin/sh\nexit 0\n"
|
||
member = tarfile.TarInfo("bin/tirith")
|
||
member.mode = 0o755
|
||
member.size = len(payload)
|
||
archive, checksums = self._write_archive(tmp_path, member, payload)
|
||
|
||
hermes_home = tmp_path / "hermes-home"
|
||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||
with patch("tools.tirith_security._download_file",
|
||
side_effect=self._download_side_effect(archive, checksums)):
|
||
path, reason = _install_tirith(log_failures=False)
|
||
|
||
assert reason == ""
|
||
assert path == str(hermes_home / "bin" / "tirith")
|
||
assert os.path.isfile(path)
|
||
assert not os.path.islink(path)
|
||
with open(path, "rb") as f:
|
||
assert f.read() == payload
|
||
|
||
@patch("tools.tirith_security._verify_checksum", return_value=True)
|
||
@patch("tools.tirith_security.shutil.which", return_value=None)
|
||
@patch("tools.tirith_security._detect_target", return_value="aarch64-apple-darwin")
|
||
def test_install_rejects_non_regular_tirith_member(self, mock_target, mock_which,
|
||
mock_checksum, tmp_path, monkeypatch):
|
||
"""Symlink or hardlink tar members must not be installed as tirith."""
|
||
del mock_target, mock_which, mock_checksum
|
||
from tools.tirith_security import _install_tirith
|
||
|
||
member = tarfile.TarInfo("bin/tirith")
|
||
member.type = tarfile.SYMTYPE
|
||
member.linkname = "/bin/sh"
|
||
archive, checksums = self._write_archive(tmp_path, member)
|
||
|
||
hermes_home = tmp_path / "hermes-home"
|
||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||
with patch("tools.tirith_security._download_file",
|
||
side_effect=self._download_side_effect(archive, checksums)):
|
||
path, reason = _install_tirith(log_failures=False)
|
||
|
||
assert path is None
|
||
assert reason == "binary_not_regular_file"
|
||
assert not os.path.lexists(hermes_home / "bin" / "tirith")
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 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_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
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Disk failure marker persistence (P2)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class TestDiskFailureMarker:
|
||
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
|
||
assert not _is_install_failed_on_disk()
|
||
_mark_install_failed("download_failed")
|
||
assert _is_install_failed_on_disk()
|
||
# 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_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
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 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)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Warn-once dedupe (issue: tirith spawn failed spamming on Windows)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class TestSpawnWarningDedup:
|
||
"""When tirith isn't installed yet (background install in flight, or
|
||
install marked failed), every terminal command spammed an identical
|
||
``tirith spawn failed: [WinError 2]`` warning to ``errors.log``. The
|
||
dedupe set in ``_warn_once`` collapses repeats by ``(exc class, errno)``
|
||
while still surfacing the first occurrence so users see the failure.
|
||
"""
|
||
|
||
@patch("tools.tirith_security.subprocess.run")
|
||
@patch("tools.tirith_security._load_security_config")
|
||
def test_repeated_spawn_failure_logs_once(self, mock_cfg, mock_run, caplog):
|
||
mock_cfg.return_value = {
|
||
"tirith_enabled": True, "tirith_path": "tirith",
|
||
"tirith_timeout": 5, "tirith_fail_open": True,
|
||
}
|
||
mock_run.side_effect = FileNotFoundError("[WinError 2]")
|
||
# Fresh dedupe state — clear any keys left by other tests.
|
||
_tirith_mod._reset_spawn_warning_state()
|
||
|
||
with caplog.at_level("WARNING", logger="tools.tirith_security"):
|
||
for i in range(15):
|
||
result = check_command_security("echo hi")
|
||
# Behavior must remain the same on every call —
|
||
# fail-open allow, with the exception captured in summary.
|
||
assert result["action"] == "allow"
|
||
if i < _tirith_mod._CRASH_LIMIT:
|
||
# Before circuit breaker opens, summary has the exception
|
||
assert "unavailable" in result["summary"]
|
||
else:
|
||
# After circuit breaker opens, summary is generic
|
||
assert "circuit breaker" in result["summary"]
|
||
|
||
spawn_warnings = [
|
||
rec for rec in caplog.records
|
||
if "tirith spawn failed" in rec.message
|
||
]
|
||
assert len(spawn_warnings) == 1, (
|
||
f"expected exactly 1 spawn-failed warning across 15 commands, "
|
||
f"got {len(spawn_warnings)}: {[r.message for r in spawn_warnings]}"
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# .app TLD suppression (issue #24461)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
_CFG = {"tirith_enabled": True, "tirith_path": "tirith",
|
||
"tirith_timeout": 5, "tirith_fail_open": True}
|
||
|
||
|
||
class TestAppTldSuppression:
|
||
"""warn verdicts whose only finding is lookalike_tld/.app are downgraded to allow."""
|
||
|
||
@patch("tools.tirith_security.subprocess.run")
|
||
@patch("tools.tirith_security._load_security_config")
|
||
def test_app_only_warn_downgraded_to_allow(self, mock_cfg, mock_run):
|
||
mock_cfg.return_value = _CFG
|
||
findings = [{"rule_id": "lookalike_tld", "value": ".app",
|
||
"message": "Domain uses '.app' TLD which can be confused with file extensions"}]
|
||
mock_run.return_value = _mock_run(2, _json_stdout(findings, ".app TLD warning"))
|
||
result = check_command_security("curl https://example.app")
|
||
assert result["action"] == "allow"
|
||
assert result["findings"] == []
|
||
assert result["summary"] == ""
|
||
|
||
@patch("tools.tirith_security.subprocess.run")
|
||
@patch("tools.tirith_security._load_security_config")
|
||
def test_mixed_findings_preserve_warn(self, mock_cfg, mock_run):
|
||
"""If .app finding is accompanied by another finding, warn is preserved."""
|
||
mock_cfg.return_value = _CFG
|
||
findings = [
|
||
{"rule_id": "lookalike_tld", "value": ".app"},
|
||
{"rule_id": "shortened_url", "severity": "medium"},
|
||
]
|
||
mock_run.return_value = _mock_run(2, _json_stdout(findings, "mixed"))
|
||
result = check_command_security("curl https://bit.ly/test.app")
|
||
assert result["action"] == "warn"
|
||
assert len(result["findings"]) == 2
|
||
|
||
@patch("tools.tirith_security.subprocess.run")
|
||
@patch("tools.tirith_security._load_security_config")
|
||
def test_block_verdict_never_suppressed(self, mock_cfg, mock_run):
|
||
"""block exit code is never downgraded, even if finding looks like .app."""
|
||
mock_cfg.return_value = _CFG
|
||
findings = [{"rule_id": "lookalike_tld", "value": ".app"}]
|
||
mock_run.return_value = _mock_run(1, _json_stdout(findings, "block"))
|
||
result = check_command_security("curl https://example.app")
|
||
assert result["action"] == "block"
|
||
|
||
|
||
class TestIsAppTldFinding:
|
||
"""Unit tests for the _is_app_tld_finding helper."""
|
||
|
||
@pytest.mark.parametrize("finding, expected", [
|
||
({"rule_id": "lookalike_tld", "value": ".APP"}, True), # case-insensitive
|
||
({"rule_id": "lookalike_tld", "message": "Domain uses '.app' TLD"}, True),
|
||
({"rule_id": "shortened_url", "value": ".app"}, False), # wrong rule_id
|
||
({"rule_id": "lookalike_tld", "value": ".zip"}, False), # other TLD
|
||
])
|
||
def test_app_tld_detection(self, finding, expected):
|
||
from tools.tirith_security import _is_app_tld_finding
|
||
assert _is_app_tld_finding(finding) is expected
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# mkdtemp OSError → no_space (disk-full leak prevention)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class TestMkdtempOSErrorNoSpace:
|
||
"""When tempfile.mkdtemp raises OSError (e.g. disk full), _install_tirith
|
||
must return (None, "no_space") instead of propagating the exception.
|
||
This prevents the unbounded retry + temp-dir leak described in #51826.
|
||
"""
|
||
|
||
def test_mkdtemp_oserror_returns_no_space(self):
|
||
from tools.tirith_security import _install_tirith
|
||
|
||
with patch("tools.tirith_security.tempfile.mkdtemp",
|
||
side_effect=OSError(28, "No space left on device")):
|
||
result, reason = _install_tirith(log_failures=False)
|
||
assert result is None
|
||
assert reason == "no_space"
|
||
|
||
def test_mkdtemp_oserror_does_not_leak_tempdir(self):
|
||
"""No temp directory should remain after a mkdtemp failure."""
|
||
import glob
|
||
from tools.tirith_security import _install_tirith
|
||
|
||
before = set(glob.glob("/tmp/tirith-install-*"))
|
||
with patch("tools.tirith_security.tempfile.mkdtemp",
|
||
side_effect=OSError(28, "No space left on device")):
|
||
_install_tirith(log_failures=False)
|
||
after = set(glob.glob("/tmp/tirith-install-*"))
|
||
assert after - before == set()
|