* fix(security): the first tirith scan no longer waits on its download
A process that never ran the CLI startup install (desktop serve, the
gateway, a newly routed profile) claimed the install on its first scan and
ran pm.ensure("tirith") inline. The first terminal call then waited on the
PM worker, the runtime toolchain and the tirith download before it ran.
The cold scan now starts the same background install as startup and scans
with the default path, so the call fails open at once like any scan during
a startup download.
* fix(security): a scan during the tirith download never trips the breaker
The previous commit let a cold scan run while the background install was
still downloading. That scan spawned the bare default "tirith", got
FileNotFoundError and counted it as a crash. Three terminal calls during
the download opened the circuit breaker, and every scan then returned
allow unscanned for 300 s, even after the download finished. With
tirith_fail_open: false the first three commands were blocked and the
breaker then let commands run unscanned.
A scan that finds the default binary missing while this home's install
thread is alive no longer spawns or counts a crash. Fail-open allows the
command at once ("tirith installing"). Fail-closed waits for the install,
as the inline install did before, and then scans with the real binary.
So only the fail-open case skips the scan during a download; the earlier
commit's "fails open at once" holds only for fail_open.
The PM consumer test now joins the background install before it reads
the recorded download requests.
396 lines
18 KiB
Python
396 lines
18 KiB
Python
"""Tirith pre-exec scanning. PM owns its optional pinned binary; exit codes
|
||
remain the verdict authority and operational failures obey fail_open."""
|
||
|
||
import json
|
||
import logging
|
||
import os
|
||
import shutil
|
||
import subprocess
|
||
import threading
|
||
import time
|
||
from contextvars import copy_context
|
||
from pathlib import Path
|
||
|
||
from hermes_constants import hermes_home_key
|
||
|
||
logger = logging.getLogger(__name__)
|
||
_REPO = "sheeki03/tirith"
|
||
# Only the release workflow may attest checksums, not arbitrary repo workflows.
|
||
_COSIGN_IDENTITY_REGEXP = f"^https://github.com/{_REPO}/\\.github/workflows/release\\.yml@refs/tags/v"
|
||
_COSIGN_ISSUER = "https://token.actions.githubusercontent.com"
|
||
|
||
# --- Config helpers ---
|
||
def _env_bool(key: str, default: bool) -> bool:
|
||
val = os.getenv(key)
|
||
return default if val is None else val.lower() in {"1", "true", "yes"}
|
||
|
||
|
||
def _env_int(key: str, default: int) -> int:
|
||
try:
|
||
return int(os.environ[key])
|
||
except (KeyError, ValueError):
|
||
return default
|
||
|
||
|
||
def _load_security_config() -> dict:
|
||
"""Security settings from config.yaml, with env var overrides."""
|
||
try:
|
||
from hermes_cli.config import load_config_readonly
|
||
cfg = load_config_readonly().get("security", {}) or {}
|
||
except Exception:
|
||
cfg = {}
|
||
return {
|
||
"tirith_enabled": _env_bool("TIRITH_ENABLED", cfg.get("tirith_enabled", True)),
|
||
"tirith_path": os.getenv("TIRITH_BIN", cfg.get("tirith_path", "tirith")),
|
||
"tirith_timeout": _env_int("TIRITH_TIMEOUT", cfg.get("tirith_timeout", 5)),
|
||
"tirith_fail_open": _env_bool("TIRITH_FAIL_OPEN", cfg.get("tirith_fail_open", True))}
|
||
|
||
|
||
# Circuit breaker: after _CRASH_LIMIT consecutive spawn/execution failures tirith is disabled so a broken
|
||
# binary can't turn every tool call into a fail-open retry loop (#41400). The breaker HALF-OPENS after
|
||
# _CIRCUIT_RETRY_S: one caller re-probes tirith for real, and any completed scan (exit 0/1/2 — allow/block/warn
|
||
# all prove the binary is healthy) closes it, while a failed probe re-arms the timer. Without the TTL this was
|
||
# a one-way latch: once open, the reset branch below was unreachable for the rest of the process.
|
||
# Thread safety: crash counting stays lock-free — a racing double-increment only opens the breaker one call
|
||
# early, which is harmless, and matches the mcp_tool.py error counters rather than the locked _warn_once
|
||
# pattern. _breaker_lock guards ONLY the half-open claim (TTL check + timestamp re-arm, nanoseconds); it is
|
||
# never held across the subprocess probe, so it cannot reintroduce the #41400 hang. Claiming re-arms
|
||
# _circuit_open_at first, so concurrent callers see a fresh TTL and stay fail-open: one probe per TTL window.
|
||
_CRASH_LIMIT = 3
|
||
_CIRCUIT_RETRY_S = 300 # half-open probe interval (seconds)
|
||
_crash_count: int = 0
|
||
_circuit_open: bool = False
|
||
_circuit_open_at: float = 0.0
|
||
_breaker_lock = threading.Lock()
|
||
|
||
# Warn-once: spawn/path warnings sit in the hot path and would otherwise repeat once per
|
||
# terminal command while tirith is unavailable.
|
||
_warned_messages: set[str] = set()
|
||
_warned_lock = threading.Lock()
|
||
|
||
def _record_tirith_crash() -> None:
|
||
global _crash_count, _circuit_open, _circuit_open_at
|
||
_crash_count += 1
|
||
if _crash_count >= _CRASH_LIMIT:
|
||
_circuit_open, _circuit_open_at = True, time.monotonic()
|
||
logger.warning("tirith circuit breaker opened after %d consecutive failures; "
|
||
"disabling for %ds", _crash_count, _CIRCUIT_RETRY_S)
|
||
|
||
|
||
def _warn_once(key: str, message: str, *args) -> None:
|
||
"""``logger.warning`` at most once per ``key`` for the process lifetime."""
|
||
with _warned_lock:
|
||
if key in _warned_messages:
|
||
return
|
||
_warned_messages.add(key)
|
||
logger.warning(message, *args)
|
||
|
||
|
||
def _verify_cosign(checksums_path: str, sig_path: str, cert_path: str) -> bool | None:
|
||
"""Cosign provenance of checksums.txt: True verified, False rejected, None if cosign absent/failed."""
|
||
if not (cosign := shutil.which("cosign")):
|
||
logger.info("cosign not found on PATH")
|
||
return None
|
||
try:
|
||
result = subprocess.run(
|
||
[cosign, "verify-blob", "--certificate", cert_path, "--signature", sig_path,
|
||
"--certificate-identity-regexp", _COSIGN_IDENTITY_REGEXP,
|
||
"--certificate-oidc-issuer", _COSIGN_ISSUER, checksums_path],
|
||
capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=15, stdin=subprocess.DEVNULL)
|
||
except (OSError, subprocess.TimeoutExpired) as exc:
|
||
logger.warning("cosign execution failed: %s", exc)
|
||
return None
|
||
if result.returncode:
|
||
logger.warning("cosign verification failed (exit %d): %s", result.returncode, result.stderr.strip())
|
||
return False
|
||
logger.info("cosign provenance verification passed")
|
||
return True
|
||
|
||
|
||
def verify_release_provenance(directory: Path, log) -> tuple[bool, str]:
|
||
"""Verify PM-acquired checksum provenance; this function never downloads.
|
||
|
||
Missing/broken cosign is optional; an explicit rejection is fatal.
|
||
"""
|
||
if not shutil.which("cosign"):
|
||
logger.info("cosign not on PATH — installing tirith with SHA-256 verification only")
|
||
return False, ""
|
||
checksums = directory / "checksums.txt"
|
||
signature, certificate = directory / "checksums.txt.sig", directory / "checksums.txt.pem"
|
||
if not signature.is_file() or not certificate.is_file():
|
||
logger.info("cosign artifacts unavailable, proceeding with SHA-256 only")
|
||
return False, ""
|
||
verified = _verify_cosign(str(checksums), str(signature), str(certificate))
|
||
if verified is False:
|
||
log("tirith install aborted: cosign provenance verification failed")
|
||
return False, "cosign_verification_failed"
|
||
return verified is True, ""
|
||
|
||
|
||
# One non-blocking startup attempt per routed home. Durable selection, failure
|
||
# recovery, download locks and publication belong to PM, not disk markers here.
|
||
_install_lock = threading.Lock()
|
||
_install_threads: dict[str, threading.Thread] = {}
|
||
_install_attempted: set[str] = set()
|
||
|
||
|
||
def _claim_install_attempt() -> bool:
|
||
"""Share the one-attempt budget between cold scans and startup threads."""
|
||
with _install_lock:
|
||
home = hermes_home_key()
|
||
if home in _install_attempted:
|
||
return False
|
||
_install_attempted.add(home)
|
||
return True
|
||
|
||
|
||
def _install_in_flight() -> threading.Thread | None:
|
||
thread = _install_threads.get(hermes_home_key())
|
||
return thread if thread is not None and thread.is_alive() else None
|
||
|
||
|
||
def is_platform_supported() -> bool:
|
||
"""Whether PM has a managed Tirith build for this host."""
|
||
import pm
|
||
|
||
try:
|
||
return pm.get_package("tirith").missing_reason(pm.current_target()) is None
|
||
except RuntimeError:
|
||
return False
|
||
|
||
|
||
def _local_tirith(configured_path: str) -> str | None:
|
||
expanded = os.path.expanduser(configured_path)
|
||
if configured_path != "tirith":
|
||
return (expanded if os.path.isfile(expanded) and os.access(expanded, os.X_OK)
|
||
else shutil.which(expanded))
|
||
if external := shutil.which("tirith"):
|
||
return external
|
||
import pm
|
||
|
||
selected = pm.installed_package("tirith")
|
||
return str(selected.binary) if selected and selected.binary else None
|
||
|
||
|
||
def _resolve_tirith_path(configured_path: str) -> str:
|
||
"""Resolve for a scan; do not wait on a startup download already in flight."""
|
||
if found := _local_tirith(configured_path):
|
||
return found
|
||
if configured_path == "tirith":
|
||
import pm
|
||
|
||
if pm.lazy_installs_allowed():
|
||
_start_background_install(log_failures=True)
|
||
return os.path.expanduser(configured_path)
|
||
|
||
|
||
def _background_install(*, log_failures: bool) -> None:
|
||
import pm
|
||
|
||
try:
|
||
pm.ensure("tirith")
|
||
except Exception as exc:
|
||
log = logger.warning if log_failures else logger.debug
|
||
log("tirith install failed: %s", exc)
|
||
|
||
|
||
def _start_background_install(*, log_failures: bool) -> None:
|
||
if _claim_install_attempt():
|
||
context = copy_context()
|
||
thread = threading.Thread(
|
||
target=context.run, args=(_background_install,),
|
||
kwargs={"log_failures": log_failures}, daemon=True,
|
||
)
|
||
_install_threads[hermes_home_key()] = thread
|
||
thread.start()
|
||
|
||
|
||
def ensure_installed(*, log_failures: bool = True, explicit: bool = False):
|
||
"""Opt-in startup is non-blocking. Explicit setup waits and reports errors.
|
||
|
||
Explicit executable configuration remains authoritative, including a miss.
|
||
Lazy refusal never starts a thread; already installed tools remain usable.
|
||
"""
|
||
import pm
|
||
|
||
cfg = _load_security_config()
|
||
if not cfg["tirith_enabled"]:
|
||
return None
|
||
configured = cfg["tirith_path"]
|
||
if configured != "tirith":
|
||
return _local_tirith(configured)
|
||
if explicit:
|
||
pm.ensure("tirith", explicit=True)
|
||
selected = pm.installed_package("tirith")
|
||
return str(selected.binary) if selected and selected.binary else None
|
||
if found := _local_tirith(configured):
|
||
return found
|
||
if not is_platform_supported() or not pm.lazy_installs_allowed():
|
||
return None
|
||
_start_background_install(log_failures=log_failures)
|
||
return None
|
||
|
||
|
||
def missing_is_expected() -> bool:
|
||
"""Whether an unresolved default tirith is by design rather than a fault.
|
||
|
||
The first launch after a PM install starts the download in the background,
|
||
and a lazy-install policy refusal is the operator's choice; neither is
|
||
actionable. A missing explicit ``tirith_path`` always is.
|
||
"""
|
||
import pm
|
||
|
||
configured = _load_security_config()["tirith_path"]
|
||
if configured != "tirith":
|
||
return False
|
||
if _install_in_flight():
|
||
return True
|
||
return _local_tirith(configured) is not None or not pm.lazy_installs_allowed()
|
||
|
||
|
||
# --- Main API ---
|
||
_MAX_FINDINGS = 50
|
||
_MAX_SUMMARY_LEN = 500
|
||
_EXIT_ACTIONS = {0: "allow", 1: "block", 2: "warn"}
|
||
# Summary when tirith's JSON is unparseable and only the exit code is known.
|
||
_NO_DETAILS_SUMMARY = {
|
||
"block": "security issue detected (details unavailable)",
|
||
"warn": "security warning detected (details unavailable)"}
|
||
_VARIATION_SELECTOR_16 = "\ufe0f"
|
||
# Code points that carry the Unicode ``Emoji`` property and take VS16 for emoji presentation: the
|
||
# Miscellaneous Symbols / Dingbats blocks, the SMP emoji planes, and the BMP singletons outside them
|
||
# (©️ ®️ ‼️ ⁉️ ™️ ℹ️ arrows, ⌚ ⌨️ ⏏️ media keys, Ⓜ️ ▪️ ▶️ ◀️ ◻️ ⤴️ ⬅️ ⬛ ⭐ ⭕ 〰️ 〽️ ㊗️ ㊙️).
|
||
# Digits, ``#`` and ``*`` also carry the property (keycap bases) but are deliberately absent: VS16
|
||
# after a letter or digit is exactly the steganography signal the rule exists for.
|
||
_EMOJI_PRESENTATION_BASE_RANGES = (
|
||
(0x00A9, 0x00A9), (0x00AE, 0x00AE), (0x203C, 0x203C), (0x2049, 0x2049), (0x2122, 0x2122),
|
||
(0x2139, 0x2139), (0x2194, 0x2199), (0x21A9, 0x21AA), (0x231A, 0x231B), (0x2328, 0x2328),
|
||
(0x23CF, 0x23CF), (0x23E9, 0x23F3), (0x23F8, 0x23FA), (0x24C2, 0x24C2), (0x25AA, 0x25AB),
|
||
(0x25B6, 0x25B6), (0x25C0, 0x25C0), (0x25FB, 0x25FE), (0x2600, 0x27BF), (0x2934, 0x2935),
|
||
(0x2B05, 0x2B07), (0x2B1B, 0x2B1C), (0x2B50, 0x2B50), (0x2B55, 0x2B55), (0x3030, 0x3030),
|
||
(0x303D, 0x303D), (0x3297, 0x3297), (0x3299, 0x3299), (0x1F000, 0x1FAFF))
|
||
|
||
|
||
def _verdict(action: str, summary: str = "", findings: list | None = None) -> dict:
|
||
return {"action": action, "findings": [] if findings is None else findings, "summary": summary}
|
||
|
||
|
||
def _fail(fail_open: bool, open_summary: str, closed_summary: str) -> dict:
|
||
return _verdict("allow", open_summary) if fail_open else _verdict("block", closed_summary)
|
||
|
||
|
||
def _crash(fail_open: bool, open_summary: str, closed_summary: str) -> dict:
|
||
"""An operational failure: count it toward the circuit breaker, then fail open/closed."""
|
||
_record_tirith_crash()
|
||
return _fail(fail_open, open_summary, closed_summary)
|
||
|
||
|
||
def check_command_security(command: str) -> dict:
|
||
"""Run the tirith scan on a command -> ``{"action": allow|warn|block, "findings", "summary"}``.
|
||
Exit code determines the action; JSON enriches. Spawn failures/timeouts respect fail_open."""
|
||
global _crash_count, _circuit_open, _circuit_open_at
|
||
cfg = _load_security_config()
|
||
if not cfg["tirith_enabled"]:
|
||
return _verdict("allow")
|
||
# Circuit breaker: if tirith has crashed _CRASH_LIMIT times in a row, stop trying and fail open (issue
|
||
# #41400). After _CIRCUIT_RETRY_S the breaker half-opens: exactly one caller claims the probe slot —
|
||
# claiming re-arms _circuit_open_at under _breaker_lock, so concurrent callers see a fresh TTL and stay
|
||
# fail-open — and falls through to a real scan below.
|
||
if _circuit_open:
|
||
with _breaker_lock:
|
||
if _circuit_open and time.monotonic() - _circuit_open_at < _CIRCUIT_RETRY_S:
|
||
return _verdict("allow", "tirith disabled (circuit breaker)")
|
||
if _circuit_open: # TTL expired: claim the single-flight probe slot for this window
|
||
_circuit_open_at = time.monotonic()
|
||
logger.info("tirith circuit breaker half-open: probing after %ds", _CIRCUIT_RETRY_S)
|
||
# No binary for this platform, ever: skip the resolver so we never spawn.
|
||
if cfg["tirith_path"] == "tirith" and not is_platform_supported():
|
||
return _verdict("allow")
|
||
tirith_path = _resolve_tirith_path(cfg["tirith_path"])
|
||
timeout, fail_open = cfg["tirith_timeout"], cfg["tirith_fail_open"]
|
||
if tirith_path is None:
|
||
_warn_once("tirith_path_none", "tirith path resolved to None; scanning disabled")
|
||
return _fail(fail_open, "tirith path unavailable", "tirith path unavailable (fail-closed)")
|
||
if tirith_path == "tirith" and (install := _install_in_flight()):
|
||
if fail_open:
|
||
return _verdict("allow", "tirith installing")
|
||
install.join()
|
||
tirith_path = _resolve_tirith_path(cfg["tirith_path"])
|
||
try:
|
||
result = subprocess.run(
|
||
[tirith_path, "check", "--json", "--non-interactive", "--shell", "posix", "--", command],
|
||
capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=timeout,
|
||
stdin=subprocess.DEVNULL)
|
||
except OSError as exc:
|
||
# FileNotFoundError / PermissionError / exec format error: dedupe by (class, errno)
|
||
# so each failure mode surfaces once, not per command.
|
||
_warn_once(f"tirith_spawn_failed:{type(exc).__name__}:{getattr(exc, 'errno', '')}",
|
||
"tirith spawn failed: %s", exc)
|
||
return _crash(fail_open, f"tirith unavailable: {exc}", f"tirith spawn failed (fail-closed): {exc}")
|
||
except subprocess.TimeoutExpired:
|
||
_warn_once(f"tirith_timeout:{timeout}", "tirith timed out after %ds", timeout)
|
||
return _crash(fail_open, f"tirith timed out ({timeout}s)", "tirith timed out (fail-closed)")
|
||
exit_code = result.returncode
|
||
if (action := _EXIT_ACTIONS.get(exit_code)) is None:
|
||
# Unknown exit code (includes signal-killed, e.g. -11): respect fail_open.
|
||
logger.warning("tirith returned unexpected exit code %d", exit_code)
|
||
return _crash(fail_open, f"tirith exit code {exit_code} (fail-open)",
|
||
f"tirith exit code {exit_code} (fail-closed)")
|
||
# Any completed scan (allow/block/warn) proves the binary is healthy: clear the streak and close the
|
||
# breaker. This is the half-open probe's recovery path, and it also fixes the streak never resetting on
|
||
# block/warn verdicts.
|
||
_crash_count = 0
|
||
if _circuit_open:
|
||
_circuit_open, _circuit_open_at = False, 0.0
|
||
logger.info("tirith circuit breaker closed after successful probe")
|
||
# JSON enriches findings/summary; a parse failure never changes the verdict.
|
||
findings, summary = [], ""
|
||
try:
|
||
data = json.loads(result.stdout) if result.stdout.strip() else {}
|
||
findings = data.get("findings", [])[:_MAX_FINDINGS]
|
||
summary = (data.get("summary", "") or "")[:_MAX_SUMMARY_LEN]
|
||
except (json.JSONDecodeError, AttributeError):
|
||
logger.debug("tirith JSON parse failed, using exit code only")
|
||
summary = _NO_DETAILS_SUMMARY.get(action, "")
|
||
# .app is a legitimate gTLD: a warn consisting solely of lookalike_tld findings for .app is a
|
||
# known false positive and is downgraded to allow. Any other finding keeps the warn.
|
||
if action == "warn" and findings and all(_is_app_tld_finding(f) for f in findings):
|
||
return _verdict("allow")
|
||
# VS16 follows ordinary emoji-capable code points in standard emoji-presentation sequences.
|
||
# Preserve warnings for every other selector, including VS16 after text, because those can
|
||
# carry the steganographic payload that Tirith is intended to detect.
|
||
if action == "warn" and findings and all(_is_emoji_variation_selector_finding(f) for f in findings) \
|
||
and _has_only_emoji_presentation_selectors(command):
|
||
return _verdict("allow")
|
||
return _verdict(action, summary, findings)
|
||
|
||
|
||
def _is_app_tld_finding(finding: dict) -> bool:
|
||
"""True if this finding is a lookalike_tld warning for the .app TLD only."""
|
||
if not isinstance(finding, dict) or finding.get("rule_id") != "lookalike_tld":
|
||
return False
|
||
return any(
|
||
val is not None and ".app" in str(val).lower()
|
||
for val in (finding.get(k) for k in ("value", "tld", "detail", "description", "message")))
|
||
|
||
|
||
def _is_emoji_variation_selector_finding(finding: dict) -> bool:
|
||
"""True only for the Tirith rule that reports variation selectors."""
|
||
return isinstance(finding, dict) and finding.get("rule_id") == "variation_selector"
|
||
|
||
|
||
def _has_only_emoji_presentation_selectors(command: str) -> bool:
|
||
"""Whether every variation selector is VS16 immediately after an emoji-capable base."""
|
||
selectors = ("\ufe00", "\U000e0100")
|
||
saw_selector = False
|
||
for idx, char in enumerate(command):
|
||
if not selectors[0] <= char <= "\ufe0f" and not selectors[1] <= char <= "\U000e01ef":
|
||
continue
|
||
saw_selector = True
|
||
if char != _VARIATION_SELECTOR_16 or idx == 0:
|
||
return False
|
||
base = ord(command[idx - 1])
|
||
if not any(start <= base <= end for start, end in _EMOJI_PRESENTATION_BASE_RANGES):
|
||
return False
|
||
return saw_selector
|