diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index 480848b675..1ec60f7623 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -528,6 +528,7 @@ class BasePlatformAdapter(ABC): animation_url: str, caption: Optional[str] = None, reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, ) -> SendResult: """ Send an animated GIF natively via the platform API. @@ -1129,6 +1130,27 @@ class BasePlatformAdapter(ABC): if split_at < 1: split_at = headroom + # Avoid splitting inside an inline code span (`...`). + # If the text before split_at has an odd number of unescaped + # backticks, the split falls inside inline code — the resulting + # chunk would have an unpaired backtick and any special characters + # (like parentheses) inside the broken span would be unescaped, + # causing MarkdownV2 parse errors on Telegram. + candidate = remaining[:split_at] + backtick_count = candidate.count("`") - candidate.count("\\`") + if backtick_count % 2 == 1: + # Find the last unescaped backtick and split before it + last_bt = candidate.rfind("`") + while last_bt > 0 and candidate[last_bt - 1] == "\\": + last_bt = candidate.rfind("`", 0, last_bt) + if last_bt > 0: + # Try to find a space or newline just before the backtick + safe_split = candidate.rfind(" ", 0, last_bt) + nl_split = candidate.rfind("\n", 0, last_bt) + safe_split = max(safe_split, nl_split) + if safe_split > headroom // 4: + split_at = safe_split + chunk_body = remaining[:split_at] remaining = remaining[split_at:].lstrip() diff --git a/tests/tools/test_tirith_security.py b/tests/tools/test_tirith_security.py index 67f3fc7f98..10a92e9b94 100644 --- a/tests/tools/test_tirith_security.py +++ b/tests/tools/test_tirith_security.py @@ -522,50 +522,59 @@ class TestCosignVerification: assert path is None assert reason == "cosign_verification_failed" + @patch("tools.tirith_security.tarfile.open") + @patch("tools.tirith_security._verify_checksum", return_value=True) @patch("tools.tirith_security.shutil.which", return_value=None) @patch("tools.tirith_security._download_file") @patch("tools.tirith_security._detect_target", return_value="aarch64-apple-darwin") - def test_install_aborts_when_cosign_missing(self, mock_target, mock_dl, - mock_which): - """_install_tirith returns cosign_missing when cosign is not on PATH.""" + 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 == "cosign_missing" - - @patch("tools.tirith_security.logger.debug") - @patch("tools.tirith_security.logger.warning") - @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_quiet_mode_downgrades_cosign_missing_log(self, mock_target, mock_dl, - mock_which, mock_warning, - mock_debug): - """Startup prefetch should not surface cosign-missing as a warning.""" - from tools.tirith_security import _install_tirith - path, reason = _install_tirith(log_failures=False) - assert path is None - assert reason == "cosign_missing" - mock_warning.assert_not_called() - mock_debug.assert_called() + assert reason == "binary_not_in_archive" + assert mock_checksum.called # SHA-256 verification ran + @patch("tools.tirith_security.tarfile.open") + @patch("tools.tirith_security._verify_checksum", return_value=True) @patch("tools.tirith_security._verify_cosign", return_value=None) @patch("tools.tirith_security.shutil.which", return_value="/usr/local/bin/cosign") @patch("tools.tirith_security._download_file") @patch("tools.tirith_security._detect_target", return_value="aarch64-apple-darwin") - def test_install_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.""" + def test_install_proceeds_when_cosign_exec_fails(self, mock_target, mock_dl, + mock_which, mock_cosign, + mock_checksum, mock_tarfile): + """_install_tirith falls back to SHA-256 when cosign exists but fails to execute.""" from tools.tirith_security import _install_tirith + mock_tar = MagicMock() + mock_tar.__enter__ = MagicMock(return_value=mock_tar) + mock_tar.__exit__ = MagicMock(return_value=False) + mock_tar.getmembers.return_value = [] + mock_tarfile.return_value = mock_tar + path, reason = _install_tirith() assert path is None - assert reason == "cosign_exec_failed" + assert reason == "binary_not_in_archive" # got past cosign + assert mock_checksum.called + @patch("tools.tirith_security.tarfile.open") + @patch("tools.tirith_security._verify_checksum", return_value=True) + @patch("tools.tirith_security.shutil.which", return_value="/usr/local/bin/cosign") @patch("tools.tirith_security._download_file") @patch("tools.tirith_security._detect_target", return_value="aarch64-apple-darwin") - def test_install_aborts_when_cosign_artifacts_missing(self, mock_target, - mock_dl): - """_install_tirith returns None when .sig/.pem downloads fail (404).""" + def test_install_proceeds_when_cosign_artifacts_missing(self, mock_target, + mock_dl, mock_which, + mock_checksum, mock_tarfile): + """_install_tirith proceeds with SHA-256 when .sig/.pem downloads fail.""" from tools.tirith_security import _install_tirith import urllib.request @@ -574,10 +583,16 @@ class TestCosignVerification: raise urllib.request.URLError("404 Not Found") mock_dl.side_effect = _dl_side_effect + mock_tar = MagicMock() + mock_tar.__enter__ = MagicMock(return_value=mock_tar) + mock_tar.__exit__ = MagicMock(return_value=False) + mock_tar.getmembers.return_value = [] + mock_tarfile.return_value = mock_tar path, reason = _install_tirith() assert path is None - assert reason == "cosign_artifacts_unavailable" + assert reason == "binary_not_in_archive" # got past cosign + assert mock_checksum.called @patch("tools.tirith_security.tarfile.open") @patch("tools.tirith_security._verify_checksum", return_value=True) diff --git a/tools/tirith_security.py b/tools/tirith_security.py index fd134b5d29..2ce5e60669 100644 --- a/tools/tirith_security.py +++ b/tools/tirith_security.py @@ -12,9 +12,12 @@ the fail_open config setting. Programming errors propagate. Auto-install: if tirith is not found on PATH or at the configured path, it is automatically downloaded from GitHub releases to $HERMES_HOME/bin/tirith. -The download verifies SHA-256 checksums and cosign provenance (when cosign -is available). Installation runs in a background thread so startup never -blocks. +The download always verifies SHA-256 checksums. When cosign is available on +PATH, provenance verification (GitHub Actions workflow signature) is also +performed. If cosign is not installed, the download proceeds with SHA-256 +verification only — still secure via HTTPS + checksum, just without supply +chain provenance proof. Installation runs in a background thread so startup +never blocks. """ import hashlib @@ -314,34 +317,34 @@ def _install_tirith(*, log_failures: bool = True) -> tuple[str | None, str]: log("tirith download failed: %s", exc) return None, "download_failed" - # Cosign provenance verification is mandatory for auto-install. - # SHA-256 alone only proves self-consistency (both files come from the - # same endpoint), not provenance. Without cosign we cannot verify the - # release was produced by the expected GitHub Actions workflow. - 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: - log("tirith install skipped: cosign artifacts unavailable (%s). " - "Install tirith manually or install cosign for auto-install.", exc) - return None, "cosign_artifacts_unavailable" - - # Check cosign availability before attempting verification so we can - # distinguish "not installed" (retryable) from "installed but broken." - if not shutil.which("cosign"): - log("tirith install skipped: cosign not found on PATH. " - "Install cosign for auto-install, or install tirith manually.") - return None, "cosign_missing" - - cosign_result = _verify_cosign(checksums_path, sig_path, cert_path) - if cosign_result is not True: - # False = verification rejected, None = execution failure (timeout/OSError) - if cosign_result is None: - log("tirith install aborted: cosign execution failed") - return None, "cosign_exec_failed" + # Cosign provenance verification — preferred but not mandatory. + # When cosign is available, we verify that the release was produced + # by the expected GitHub Actions workflow (full supply chain proof). + # Without cosign, SHA-256 checksum + HTTPS still provides integrity + # and transport-level authenticity. + cosign_verified = False + if shutil.which("cosign"): + 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) else: - log("tirith install aborted: cosign provenance verification failed") - return None, "cosign_verification_failed" + cosign_result = _verify_cosign(checksums_path, sig_path, cert_path) + if cosign_result is True: + cosign_verified = True + elif cosign_result is False: + # Verification explicitly rejected — abort, the release + # may have been tampered with. + log("tirith install aborted: cosign provenance verification failed") + return None, "cosign_verification_failed" + else: + # None = execution failure (timeout/OSError) — proceed + # with SHA-256 only since cosign itself is broken. + logger.info("cosign execution failed, proceeding with SHA-256 only") + else: + logger.info("cosign not on PATH — installing tirith with SHA-256 verification only " + "(install cosign for full supply chain verification)") if not _verify_checksum(archive_path, checksums_path, archive_name): return None, "checksum_failed" @@ -364,7 +367,8 @@ def _install_tirith(*, log_failures: bool = True) -> tuple[str | None, str]: shutil.move(src, dest) os.chmod(dest, os.stat(dest).st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) - logger.info("tirith installed to %s", dest) + verification = "cosign + SHA-256" if cosign_verified else "SHA-256 only" + logger.info("tirith installed to %s (%s)", dest, verification) return dest, "" finally: