# Conflicts: # AGENTS.md # acp_adapter/edit_approval.py # acp_adapter/server.py # agent/agent_init.py # agent/anthropic_adapter.py # agent/anthropic_credentials.py # agent/auxiliary_client.py # agent/azure_identity_adapter.py # agent/bedrock_adapter.py # agent/browser_registry.py # agent/chat_completion_helpers.py # agent/coding_context.py # agent/context_references.py # agent/conversation_loop.py # agent/copilot_acp_client.py # agent/credits_tracker.py # agent/curator.py # agent/curator_backup.py # agent/deadline.py # agent/display.py # agent/errors.py # agent/estop.py # agent/i18n.py # agent/image_gen_registry.py # agent/image_routing.py # agent/learning_graph.py # agent/learning_mutations.py # agent/lsp/servers.py # agent/model_metadata.py # agent/models_dev.py # agent/monitoring/gateway_health_export.py # agent/monitoring/otlp_exporter.py # agent/pet/store.py # agent/process_bootstrap.py # agent/prompt_builder.py # agent/proxy_sources/iron_proxy.py # agent/secret_sources/_cache.py # agent/secret_sources/bitwarden.py # agent/secret_sources/registry.py # agent/shell_hooks.py # agent/skill_bundles.py # agent/skill_commands.py # agent/skill_utils.py # agent/ssl_guard.py # agent/ssl_verify.py # agent/system_prompt.py # agent/terminal_env_registry.py # agent/trace_upload.py # agent/transcription_registry.py # agent/tts_registry.py # agent/verify/environment.py # agent/vertex_adapter.py # agent/video_gen_registry.py # agent/web_search_registry.py # cli.py # cron/jobs.py # cron/scheduler.py # gateway/agent_cache_pressure.py # gateway/cgroup_cleanup.py # gateway/channel_directory.py # gateway/config.py # gateway/control_socket.py # gateway/dead_targets.py # gateway/drain_control.py # gateway/hooks.py # gateway/kanban_watchers.py # gateway/lifecycle_ledger.py # gateway/mirror.py # gateway/pairing.py # gateway/platform_registry.py # gateway/platforms/helpers.py # gateway/platforms/weixin.py # gateway/readiness.py # gateway/restart_loop_guard.py # gateway/rich_sent_store.py # gateway/run.py # gateway/session.py # gateway/shutdown_flush.py # gateway/shutdown_forensics.py # gateway/slash_commands.py # gateway/status.py # gateway/sticker_cache.py # gateway/whatsapp_identity.py # hermes_bootstrap.py # hermes_cli/_early_recovery.py # hermes_cli/_install_repair.py # hermes_cli/_startup_fast.py # hermes_cli/_subprocess_compat.py # hermes_cli/agent_plugins.py # hermes_cli/auth.py # hermes_cli/backup.py # hermes_cli/banner.py # hermes_cli/browser_connect.py # hermes_cli/build_info.py # hermes_cli/cli_agent_setup_mixin.py # hermes_cli/cli_commands_mixin.py # hermes_cli/codex_models.py # hermes_cli/config.py # hermes_cli/config_defaults.py # hermes_cli/config_migrations.py # hermes_cli/container_boot.py # hermes_cli/dashboard_auth/registry.py # hermes_cli/debug.py # hermes_cli/dep_ensure.py # hermes_cli/doctor.py # hermes_cli/doctor_live.py # hermes_cli/dump.py # hermes_cli/env_loader.py # hermes_cli/foreign_sessions.py # hermes_cli/gateway.py # hermes_cli/gateway_windows.py # hermes_cli/gui_uninstall.py # hermes_cli/image_provenance.py # hermes_cli/install_identity.py # hermes_cli/kanban.py # hermes_cli/kanban_db.py # hermes_cli/linux_desktop_entry.py # hermes_cli/local_runtime/binaries.py # hermes_cli/local_runtime/endpoint.py # hermes_cli/local_runtime/growth.py # hermes_cli/local_runtime/supervisor.py # hermes_cli/logs.py # hermes_cli/macos_tcc_anchor.py # hermes_cli/main.py # hermes_cli/memory_setup.py # hermes_cli/model_catalog.py # hermes_cli/models.py # hermes_cli/nous_subscription.py # hermes_cli/npm_engine.py # hermes_cli/plugin_index.py # hermes_cli/plugins.py # hermes_cli/plugins_cmd.py # hermes_cli/profile_distribution.py # hermes_cli/profiles.py # hermes_cli/prompt_size.py # hermes_cli/psutil_android.py # hermes_cli/runtime_repair.py # hermes_cli/security_advisories.py # hermes_cli/security_audit.py # hermes_cli/security_audit_startup.py # hermes_cli/service_manager.py # hermes_cli/session_export_md.py # hermes_cli/setup.py # hermes_cli/skills_hub.py # hermes_cli/slack_cli.py # hermes_cli/status.py # hermes_cli/subcommands/gateway.py # hermes_cli/subcommands/uninstall.py # hermes_cli/tools_config.py # hermes_cli/uninstall.py # hermes_cli/update_cmd.py # hermes_cli/update_contract.py # hermes_cli/update_inventory.py # hermes_cli/update_lock.py # hermes_cli/update_receipt.py # hermes_cli/urllib_security.py # hermes_cli/web_routers/local_models.py # hermes_cli/web_routers/profiles.py # hermes_cli/web_routers/skills.py # hermes_cli/web_server.py # hermes_constants.py # hermes_state.py # plugins/disk-cleanup/__init__.py # plugins/disk-cleanup/disk_cleanup.py # plugins/google_meet/node/registry.py # plugins/google_meet/node/server.py # plugins/google_meet/process_manager.py # plugins/google_meet/realtime/openai_client.py # plugins/hermes-achievements/dashboard/plugin_api.py # plugins/memory/hindsight/__init__.py # plugins/memory/honcho/__init__.py # plugins/memory/honcho/cli.py # plugins/memory/honcho/client.py # plugins/memory/honcho/oauth.py # plugins/memory/honcho/session.py # plugins/memory/mem0/__init__.py # plugins/memory/mem0/_setup.py # plugins/memory/openviking/__init__.py # plugins/memory/retaindb/__init__.py # plugins/memory/supermemory/__init__.py # plugins/platforms/a2a/protocol.py # plugins/platforms/dingtalk/adapter.py # plugins/platforms/discord/adapter.py # plugins/platforms/feishu/adapter.py # plugins/platforms/google_chat/adapter.py # plugins/platforms/matrix/adapter.py # plugins/platforms/photon/adapter.py # plugins/platforms/photon/auth.py # plugins/platforms/photon/cli.py # plugins/platforms/slack/adapter.py # plugins/platforms/teams/adapter.py # plugins/platforms/telegram/adapter.py # plugins/platforms/wecom/callback_adapter.py # plugins/platforms/whatsapp/adapter.py # plugins/teams_pipeline/store.py # plugins/video_gen/fal/__init__.py # plugins/web/ddgs/provider.py # plugins/web/exa/provider.py # plugins/web/firecrawl/provider.py # plugins/web/parallel/provider.py # tests/agent/test_ssl_ca_guard.py # tests/hermes_cli/test_certifi_repair.py # tests/hermes_cli/test_cmd_update.py # tests/hermes_cli/test_cmd_update_apt.py # tests/hermes_cli/test_dashboard_unified_launch.py # tests/hermes_cli/test_dep_ensure.py # tests/hermes_cli/test_doctor.py # tests/hermes_cli/test_doctor_live.py # tests/hermes_cli/test_gui_command.py # tests/hermes_cli/test_kanban_boards.py # tests/hermes_cli/test_kanban_db.py # tests/hermes_cli/test_lazy_refresh_venv_repair.py # tests/hermes_cli/test_memory_setup_provider_arg.py # tests/hermes_cli/test_nous_subscription.py # tests/hermes_cli/test_pip_install_detection.py # tests/hermes_cli/test_profile_export_credentials.py # tests/hermes_cli/test_psutil_android_extract.py # tests/hermes_cli/test_status.py # tests/hermes_cli/test_tui_npm_install.py # tests/hermes_cli/test_update_fleet_restart_pending.py # tests/hermes_cli/test_update_head_moved_gate.py # tests/hermes_cli/test_update_interrupted_recovery.py # tests/hermes_cli/test_web_server.py # tests/hermes_cli/test_web_ui_build.py # tests/test_hermes_logging.py # tests/test_managed_runtime_resolution.py # tests/tools/test_browser_chromium_autoinstall.py # tests/tools/test_browser_chromium_check.py # tests/tools/test_browser_homebrew_paths.py # tests/tools/test_browser_lightpanda.py # tests/tools/test_browser_npx_warmup.py # tests/tools/test_browser_open_timeout.py # tests/tools/test_browser_orphan_reaper.py # tests/tools/test_browser_real_profile.py # tests/tools/test_browser_suspect_recycle.py # tests/tools/test_find_shell.py # tests/tools/test_local_env_blocklist.py # tests/tools/test_macos_protected_search.py # tests/tui_gateway/test_compute_host.py # tools/approval.py # tools/blueprints.py # tools/bot_mode_dm.py # tools/bot_mode_probe.py # tools/bot_relay.py # tools/browser_tool.py # tools/browser_use_cli.py # tools/checkpoint_manager.py # tools/code_execution_tool.py # tools/code_kernel.py # tools/computer_use/cua_backend.py # tools/cronjob_tools.py # tools/discord_tool.py # tools/environments/base.py # tools/environments/daytona.py # tools/environments/local.py # tools/environments/modal.py # tools/environments/vercel_sandbox.py # tools/fal_common.py # tools/file_operations.py # tools/lazy_deps.py # tools/mcp_tool.py # tools/neutts_synth.py # tools/process_registry.py # tools/read_extract.py # tools/registry.py # tools/skill_ledger.py # tools/skill_linter.py # tools/skill_manager_tool.py # tools/skill_usage.py # tools/skills_ast_audit.py # tools/skills_guard.py # tools/skills_hub.py # tools/skills_sync.py # tools/skills_sync_client.py # tools/skills_tool.py # tools/terminal_scope.py # tools/terminal_tool.py # tools/tirith_security.py # tools/transcription_tools.py # tools/tts_tool.py # tools/vision_tools.py # tools/voice_mode.py # tools/wake_word.py # tools/web_result_cache.py # tools/website_policy.py # tools/working_diff.py # tools/write_approval.py # tui_gateway/entry.py # tui_gateway/methods_tools.py # tui_gateway/server.py
547 lines
25 KiB
Python
547 lines
25 KiB
Python
"""Tirith pre-exec security scanning wrapper: runs the tirith binary as a subprocess to scan
|
|
commands for content-level threats (homograph URLs, pipe-to-interpreter, terminal injection).
|
|
The exit code is the verdict source of truth (0 allow, 1 block, 2 warn); JSON stdout only
|
|
enriches findings. Operational failures (spawn error, timeout, unknown exit) respect
|
|
``fail_open``; programming errors propagate. Auto-install: a missing tirith is downloaded from
|
|
GitHub releases to $HERMES_HOME/bin/tirith in a background thread -- SHA-256 always verified,
|
|
cosign provenance when cosign is on PATH."""
|
|
|
|
import hashlib
|
|
import json
|
|
import logging
|
|
import os
|
|
import platform
|
|
import shutil
|
|
import stat
|
|
import subprocess
|
|
import tarfile
|
|
import tempfile
|
|
import threading
|
|
import time
|
|
import urllib.request
|
|
from contextlib import suppress
|
|
|
|
from hermes_constants import get_hermes_home
|
|
|
|
logger = logging.getLogger(__name__)
|
|
_REPO = "sheeki03/tirith"
|
|
# Cosign provenance pinned to the release workflow, not the whole repo.
|
|
_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))}
|
|
|
|
|
|
# --- Module state ---
|
|
# Cached path after first resolution. _INSTALL_FAILED means "tried and failed" (distinct
|
|
# from None = "not yet tried") so a failed install is not retried per command.
|
|
_resolved_path: str | None | bool = None
|
|
_INSTALL_FAILED = False
|
|
_install_failure_reason: str = "" # reason tag when _resolved_path is _INSTALL_FAILED
|
|
|
|
# Circuit breaker: after _CRASH_LIMIT consecutive spawn/execution failures tirith is disabled
|
|
# for the rest of the process so a broken binary can't turn every tool call into a fail-open
|
|
# retry loop. Reset on success. Lock-free on purpose: a racing double-increment only opens the
|
|
# breaker one call early; no corruption or security bypass is possible.
|
|
_CRASH_LIMIT = 3
|
|
# Reset on successful execution (see _record_tirith_crash / check_command_security). Thread safety:
|
|
# _crash_count and _circuit_open are module-level globals mutated without a lock. check_command_security can
|
|
# be called from concurrent agent threads (gateway multi-session). The race is benign — at worst two threads
|
|
# both increment past _CRASH_LIMIT and both set _circuit_open = True, opening the breaker one call early.
|
|
# This intentionally matches the lock-free style of error counters in mcp_tool.py rather than the locked
|
|
# _warn_once pattern, because the worst case is harmless. See #41400.
|
|
_crash_count: int = 0
|
|
_circuit_open: bool = False
|
|
|
|
_install_lock = threading.Lock()
|
|
_install_thread: threading.Thread | None = None
|
|
|
|
# Warn-once: spawn/path warnings sit in the hot path and would otherwise repeat once per
|
|
# terminal command while tirith is unavailable (e.g. install thread still running).
|
|
_warned_messages: set[str] = set()
|
|
_warned_lock = threading.Lock()
|
|
|
|
_MARKER_TTL = 86400 # disk failure marker validity (24h) -- avoids retry across restarts
|
|
|
|
|
|
def _record_tirith_crash() -> None:
|
|
global _crash_count, _circuit_open
|
|
_crash_count += 1
|
|
if _crash_count >= _CRASH_LIMIT:
|
|
_circuit_open = True
|
|
logger.warning("tirith circuit breaker opened after %d consecutive failures; "
|
|
"disabling for the rest of the process", _crash_count)
|
|
|
|
|
|
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 _cached_path() -> str | None:
|
|
"""The path resolved on a previous call, or None if unresolved (None) / failed (_INSTALL_FAILED)."""
|
|
return _resolved_path or None
|
|
|
|
|
|
def _set_resolved(path: str) -> None:
|
|
global _resolved_path, _install_failure_reason
|
|
_resolved_path, _install_failure_reason = path, ""
|
|
|
|
|
|
def _set_failed(reason: str) -> None:
|
|
global _resolved_path, _install_failure_reason
|
|
_resolved_path, _install_failure_reason = _INSTALL_FAILED, reason
|
|
|
|
|
|
# --- Disk failure marker ---
|
|
def _failure_marker_path() -> str:
|
|
return os.path.join(str(get_hermes_home()), ".tirith-install-failed")
|
|
|
|
|
|
def _read_failure_reason() -> str | None:
|
|
"""The marker's reason, or None if absent or older than _MARKER_TTL."""
|
|
try:
|
|
p = _failure_marker_path()
|
|
if (time.time() - os.path.getmtime(p)) >= _MARKER_TTL:
|
|
return None
|
|
with open(p, "r", encoding="utf-8-sig") as f:
|
|
return f.read().strip()
|
|
except OSError:
|
|
return None
|
|
|
|
|
|
def _is_install_failed_on_disk() -> bool:
|
|
"""True if a recent install failure was persisted and is still non-retryable.
|
|
A 'cosign_missing' marker is auto-cleared once cosign appears on PATH."""
|
|
reason = _read_failure_reason()
|
|
if reason == "cosign_missing" and shutil.which("cosign"):
|
|
_clear_install_failed()
|
|
return False
|
|
return reason is not None
|
|
|
|
|
|
def _mark_install_failed(reason: str = ""):
|
|
"""Persist install failure to disk; ``reason`` is a short retryability tag."""
|
|
with suppress(OSError):
|
|
p = _failure_marker_path()
|
|
os.makedirs(os.path.dirname(p), exist_ok=True)
|
|
with open(p, "w", encoding="utf-8") as f:
|
|
f.write(reason)
|
|
|
|
|
|
def _clear_install_failed():
|
|
"""Remove the failure marker and reset warn-once state (so a failure after a reinstall surfaces again)."""
|
|
with _warned_lock:
|
|
_warned_messages.clear()
|
|
with suppress(OSError):
|
|
os.unlink(_failure_marker_path())
|
|
|
|
|
|
def _disk_marker_blocks_install() -> bool:
|
|
"""Apply a still-valid disk marker to module state; True if install must be skipped.
|
|
Keeps the marker's real reason so in-process retry can detect cosign_missing."""
|
|
if (disk_reason := _read_failure_reason()) is None or not _is_install_failed_on_disk():
|
|
return False
|
|
_set_failed(disk_reason)
|
|
return True
|
|
|
|
|
|
# --- Auto-install ---
|
|
def _hermes_bin_dir() -> str:
|
|
"""$HERMES_HOME/bin, created if needed."""
|
|
os.makedirs(d := os.path.join(str(get_hermes_home()), "bin"), exist_ok=True)
|
|
return d
|
|
|
|
|
|
# Rust target triple components. Android (Termux) is ABI-compatible with Linux. Windows is
|
|
# absent on purpose (no tirith build): None = "never available here", pattern guards still run.
|
|
_TARGET_PLATFORMS = {"Darwin": "apple-darwin", "Linux": "unknown-linux-gnu", "Android": "unknown-linux-gnu"}
|
|
_TARGET_ARCHES = {"x86_64": "x86_64", "amd64": "x86_64", "aarch64": "aarch64", "arm64": "aarch64"}
|
|
|
|
|
|
def _detect_target() -> str | None:
|
|
"""Rust target triple for this platform, or None if tirith has no build for it."""
|
|
plat = _TARGET_PLATFORMS.get(platform.system())
|
|
arch = _TARGET_ARCHES.get(platform.machine().lower())
|
|
return f"{arch}-{plat}" if plat and arch else None
|
|
|
|
|
|
def is_platform_supported() -> bool:
|
|
"""True when tirith ships a prebuilt binary for this OS+arch (CLI banner uses this)."""
|
|
return _detect_target() is not None
|
|
|
|
|
|
def _download_file(url: str, dest: str, timeout: int = 10):
|
|
from agent.secret_scope import get_secret
|
|
req = urllib.request.Request(url)
|
|
if token := get_secret("GITHUB_TOKEN"):
|
|
req.add_header("Authorization", f"token {token}")
|
|
with urllib.request.urlopen(req, timeout=timeout) as resp, open(dest, "wb") as f:
|
|
shutil.copyfileobj(resp, f)
|
|
|
|
|
|
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(base_url: str, tmpdir: str, checksums_path: str, log) -> tuple[bool, str]:
|
|
"""Cosign step of the install -> ``(cosign_verified, failure_reason)``. Only an explicit
|
|
cosign rejection aborts; missing/broken cosign or artifacts fall back to SHA-256 only."""
|
|
if not shutil.which("cosign"):
|
|
logger.info("cosign not on PATH — installing tirith with SHA-256 verification only "
|
|
"(install cosign for full supply chain verification)")
|
|
return False, ""
|
|
sig_path, cert_path = os.path.join(tmpdir, "checksums.txt.sig"), os.path.join(tmpdir, "checksums.txt.pem")
|
|
try:
|
|
_download_file(f"{base_url}/checksums.txt.sig", sig_path)
|
|
_download_file(f"{base_url}/checksums.txt.pem", cert_path)
|
|
except Exception as exc:
|
|
logger.info("cosign artifacts unavailable (%s), proceeding with SHA-256 only", exc)
|
|
return False, ""
|
|
verified = _verify_cosign(checksums_path, sig_path, cert_path)
|
|
if verified is False:
|
|
log("tirith install aborted: cosign provenance verification failed")
|
|
return False, "cosign_verification_failed"
|
|
if verified is None:
|
|
logger.info("cosign execution failed, proceeding with SHA-256 only")
|
|
return verified is True, ""
|
|
|
|
|
|
def _verify_checksum(archive_path: str, checksums_path: str, archive_name: str) -> bool:
|
|
"""Verify SHA-256 of the archive against checksums.txt ("<hash> <filename>" lines)."""
|
|
with open(checksums_path, encoding="utf-8-sig") as f:
|
|
parsed = (line.strip().split(" ", 1) for line in f)
|
|
expected = next((h for h, *n in parsed if n == [archive_name]), None)
|
|
if not expected:
|
|
logger.warning("No checksum entry for %s", archive_name)
|
|
return False
|
|
sha = hashlib.sha256()
|
|
with open(archive_path, "rb") as f:
|
|
for chunk in iter(lambda: f.read(8192), b""):
|
|
sha.update(chunk)
|
|
actual = sha.hexdigest()
|
|
if actual != expected:
|
|
logger.warning("Checksum mismatch: expected %s, got %s", expected, actual)
|
|
return actual == expected
|
|
|
|
|
|
def _extract_tirith_binary(tar: tarfile.TarFile, dest_dir: str, log) -> tuple[str | None, str]:
|
|
"""Extract the tirith binary from a release archive into dest_dir -> ``(path, reason)``."""
|
|
for member in tar.getmembers():
|
|
if member.name.rsplit("/", 1)[-1] != "tirith" or ".." in member.name:
|
|
continue
|
|
if not member.isfile():
|
|
log("tirith archive member is not a regular file: %s", member.name)
|
|
return None, "binary_not_regular_file"
|
|
if (src_file := tar.extractfile(member)) is None:
|
|
log("tirith binary could not be read from archive")
|
|
return None, "binary_extract_failed"
|
|
dest_path = os.path.join(dest_dir, "tirith")
|
|
with src_file, open(dest_path, "wb") as out:
|
|
shutil.copyfileobj(src_file, out)
|
|
return dest_path, ""
|
|
log("tirith binary not found in archive")
|
|
return None, "binary_not_in_archive"
|
|
|
|
|
|
def _install_tirith(*, log_failures: bool = True) -> tuple[str | None, str]:
|
|
"""Download and install tirith to $HERMES_HOME/bin/tirith -> ``(installed_path,
|
|
failure_reason)``; the reason ("" on success) is the disk marker's retryability tag."""
|
|
log = logger.warning if log_failures else logger.debug
|
|
if not (target := _detect_target()):
|
|
logger.info("tirith auto-install: unsupported platform %s/%s", platform.system(), platform.machine())
|
|
return None, "unsupported_platform"
|
|
archive_name = f"tirith-{target}.tar.gz"
|
|
base_url = f"https://github.com/{_REPO}/releases/latest/download"
|
|
try:
|
|
tmpdir = tempfile.mkdtemp(prefix="tirith-install-")
|
|
except OSError as exc:
|
|
log("tirith install failed: cannot create temp dir: %s", exc)
|
|
return None, "no_space"
|
|
try:
|
|
archive_path, checksums_path = os.path.join(tmpdir, archive_name), os.path.join(tmpdir, "checksums.txt")
|
|
logger.info("tirith not found — downloading latest release for %s...", target)
|
|
try:
|
|
_download_file(f"{base_url}/{archive_name}", archive_path)
|
|
_download_file(f"{base_url}/checksums.txt", checksums_path)
|
|
except Exception as exc:
|
|
log("tirith download failed: %s", exc)
|
|
return None, "download_failed"
|
|
cosign_verified, reason = _verify_release_provenance(base_url, tmpdir, checksums_path, log)
|
|
if reason:
|
|
return None, reason
|
|
if not _verify_checksum(archive_path, checksums_path, archive_name):
|
|
return None, "checksum_failed"
|
|
with tarfile.open(archive_path, "r:gz") as tar:
|
|
src, reason = _extract_tirith_binary(tar, tmpdir, log)
|
|
if src is None:
|
|
return None, reason
|
|
dest = os.path.join(_hermes_bin_dir(), "tirith")
|
|
try:
|
|
shutil.move(src, dest)
|
|
except OSError:
|
|
# Cross-device move (Docker, NFS): copy2's metadata step can raise PermissionError,
|
|
# so fall back to plain copy + chmod; a partial dest is removed to avoid a
|
|
# non-executable retry loop.
|
|
try:
|
|
shutil.copy(src, dest)
|
|
except OSError:
|
|
with suppress(OSError):
|
|
os.unlink(dest)
|
|
return None, "cross_device_copy_failed"
|
|
os.chmod(dest, os.stat(dest).st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
|
logger.info("tirith installed to %s (%s)", dest, "cosign + SHA-256" if cosign_verified else "SHA-256 only")
|
|
return dest, ""
|
|
finally:
|
|
shutil.rmtree(tmpdir, ignore_errors=True)
|
|
|
|
|
|
# --- Path resolution ---
|
|
def _is_executable(path: str) -> bool:
|
|
return os.path.isfile(path) and os.access(path, os.X_OK)
|
|
|
|
|
|
def _find_local_tirith() -> str | None:
|
|
"""Cheap local lookup for the default "tirith": PATH, then $HERMES_HOME/bin."""
|
|
hermes_bin = os.path.join(_hermes_bin_dir(), "tirith")
|
|
return shutil.which("tirith") or (hermes_bin if _is_executable(hermes_bin) else None)
|
|
|
|
|
|
def _resolve_locally(configured_path: str, *, warn_missing: bool) -> tuple[str | None, bool]:
|
|
"""Network-free resolution -> ``(path, may_install)``: ``path`` set = resolved (module state
|
|
updated); else ``may_install`` False = terminal miss (explicit path missing, cached non-retryable
|
|
failure), True = the disk marker / install step may proceed."""
|
|
global _resolved_path, _install_failure_reason
|
|
expanded = os.path.expanduser(configured_path)
|
|
# An explicit (non-"tirith") path is authoritative: never auto-download a replacement.
|
|
if configured_path != "tirith":
|
|
if found := (expanded if _is_executable(expanded) else shutil.which(expanded)):
|
|
_resolved_path = found
|
|
return found, False
|
|
if warn_missing:
|
|
logger.warning("Configured tirith path %r not found; scanning disabled", configured_path)
|
|
_set_failed("explicit_path_missing")
|
|
return None, False
|
|
# Always re-run the cheap local checks so a manual install is picked up even after a
|
|
# previous network failure (a long-lived gateway recovers without restart).
|
|
if found := _find_local_tirith():
|
|
_set_resolved(found)
|
|
_clear_install_failed()
|
|
return found, False
|
|
# Previous install failed: skip the network retry unless the retryable cosign_missing
|
|
# cause has been resolved in-process.
|
|
if _resolved_path is _INSTALL_FAILED:
|
|
if _install_failure_reason != "cosign_missing" or not shutil.which("cosign"):
|
|
return None, False
|
|
_resolved_path, _install_failure_reason = None, ""
|
|
_clear_install_failed()
|
|
return None, True
|
|
|
|
|
|
def _record_install_result(installed: str | None, reason: str) -> str | None:
|
|
"""Cache an install outcome in module state + disk marker; returns *installed*."""
|
|
if installed:
|
|
_set_resolved(installed)
|
|
_clear_install_failed()
|
|
else:
|
|
_set_failed(reason)
|
|
_mark_install_failed(reason)
|
|
return installed
|
|
|
|
|
|
def _resolve_tirith_path(configured_path: str) -> str:
|
|
"""Resolve the tirith path, auto-installing synchronously if needed (default "tirith": PATH →
|
|
$HERMES_HOME/bin/tirith → install; failures cached in-process and on disk for 24h). On a miss
|
|
the expanded configured path is returned so the spawn fails open via the dedupe'd OSError."""
|
|
if cached := _cached_path():
|
|
return cached
|
|
expanded = os.path.expanduser(configured_path)
|
|
# No tirith build for this platform: cache the verdict; the spawn fails open once, then
|
|
# the fast path above short-circuits.
|
|
if configured_path == "tirith" and not is_platform_supported():
|
|
_set_failed("unsupported_platform")
|
|
return expanded
|
|
found, may_install = _resolve_locally(configured_path, warn_missing=True)
|
|
if found or not may_install:
|
|
return found or expanded
|
|
# A background install is running: don't start a parallel one; fail-open until it finishes.
|
|
if _install_running() or _disk_marker_blocks_install():
|
|
return expanded
|
|
installed = _record_install_result(*_install_tirith())
|
|
return installed or expanded
|
|
|
|
|
|
def _install_running() -> bool:
|
|
return _install_thread is not None and _install_thread.is_alive()
|
|
|
|
|
|
def _background_install(*, log_failures: bool = True):
|
|
"""Background thread target: download and install tirith."""
|
|
with _install_lock:
|
|
if _resolved_path is not None: # another thread resolved meanwhile
|
|
return
|
|
if found := _find_local_tirith(): # may have been installed by another process
|
|
_set_resolved(found)
|
|
return
|
|
_record_install_result(*_install_tirith(log_failures=log_failures))
|
|
|
|
|
|
def ensure_installed(*, log_failures: bool = True):
|
|
"""Resolved path if available now, else None after kicking off a daemon-thread download (local
|
|
checks are synchronous; the download never blocks startup). Safe to call repeatedly."""
|
|
global _install_thread
|
|
cfg = _load_security_config()
|
|
if not cfg["tirith_enabled"]:
|
|
return None
|
|
if cached := _cached_path():
|
|
return cached if _is_executable(cached) else None
|
|
# No tirith build here (e.g. Windows): stay silent -- no PATH probe, no download thread,
|
|
# no disk marker. Pattern-matching guards still run.
|
|
if not is_platform_supported():
|
|
_set_failed("unsupported_platform")
|
|
return None
|
|
found, may_install = _resolve_locally(cfg["tirith_path"], warn_missing=False)
|
|
if found or not may_install or _disk_marker_blocks_install():
|
|
return found
|
|
if not _install_running():
|
|
_install_thread = threading.Thread(target=_background_install, daemon=True,
|
|
kwargs={"log_failures": log_failures})
|
|
_install_thread.start()
|
|
return None # not available yet; commands fail-open until ready
|
|
|
|
|
|
# --- 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)"}
|
|
|
|
|
|
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
|
|
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 for the rest of the
|
|
# process. Without this, a corrupted or missing binary causes every tool call to hit the same spawn
|
|
# failure → fail-open → agent retry loop, hanging the user for 20+ minutes (issue #41400).
|
|
if _circuit_open:
|
|
return _verdict("allow", "tirith disabled (circuit breaker)")
|
|
# No binary for this platform, ever: skip the resolver so we never spawn.
|
|
if 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)")
|
|
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)")
|
|
if action == "allow":
|
|
_crash_count = 0 # successful execution resets the circuit breaker
|
|
# 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")
|
|
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")))
|