/ tools / tirith_security.py
tirith_security.py
  1  """Tirith pre-exec security scanning wrapper.
  2  
  3  Runs the tirith binary as a subprocess to scan commands for content-level
  4  threats (homograph URLs, pipe-to-interpreter, terminal injection, etc.).
  5  
  6  Exit code is the verdict source of truth:
  7    0 = allow, 1 = block, 2 = warn
  8  
  9  JSON stdout enriches findings/summary but never overrides the verdict.
 10  Operational failures (spawn error, timeout, unknown exit code) respect
 11  the fail_open config setting. Programming errors propagate.
 12  
 13  Auto-install: if tirith is not found on PATH or at the configured path,
 14  it is automatically downloaded from GitHub releases to $HERMES_HOME/bin/tirith.
 15  The download always verifies SHA-256 checksums.  When cosign is available on
 16  PATH, provenance verification (GitHub Actions workflow signature) is also
 17  performed.  If cosign is not installed, the download proceeds with SHA-256
 18  verification only — still secure via HTTPS + checksum, just without supply
 19  chain provenance proof.  Installation runs in a background thread so startup
 20  never blocks.
 21  """
 22  
 23  import hashlib
 24  import json
 25  import logging
 26  import os
 27  import platform
 28  import shutil
 29  import stat
 30  import subprocess
 31  import tarfile
 32  import tempfile
 33  import threading
 34  import time
 35  import urllib.request
 36  
 37  from hermes_constants import get_hermes_home
 38  
 39  logger = logging.getLogger(__name__)
 40  
 41  _REPO = "sheeki03/tirith"
 42  
 43  # Cosign provenance verification — pinned to the specific release workflow
 44  _COSIGN_IDENTITY_REGEXP = f"^https://github.com/{_REPO}/\\.github/workflows/release\\.yml@refs/tags/v"
 45  _COSIGN_ISSUER = "https://token.actions.githubusercontent.com"
 46  
 47  # ---------------------------------------------------------------------------
 48  # Config helpers
 49  # ---------------------------------------------------------------------------
 50  
 51  def _env_bool(key: str, default: bool) -> bool:
 52      val = os.getenv(key)
 53      if val is None:
 54          return default
 55      return val.lower() in ("1", "true", "yes")
 56  
 57  
 58  def _env_int(key: str, default: int) -> int:
 59      val = os.getenv(key)
 60      if val is None:
 61          return default
 62      try:
 63          return int(val)
 64      except ValueError:
 65          return default
 66  
 67  
 68  def _load_security_config() -> dict:
 69      """Load security settings from config.yaml, with env var overrides."""
 70      defaults = {
 71          "tirith_enabled": True,
 72          "tirith_path": "tirith",
 73          "tirith_timeout": 5,
 74          "tirith_fail_open": True,
 75      }
 76      try:
 77          from hermes_cli.config import load_config
 78          cfg = load_config().get("security", {}) or {}
 79      except Exception:
 80          cfg = {}
 81  
 82      return {
 83          "tirith_enabled": _env_bool("TIRITH_ENABLED", cfg.get("tirith_enabled", defaults["tirith_enabled"])),
 84          "tirith_path": os.getenv("TIRITH_BIN", cfg.get("tirith_path", defaults["tirith_path"])),
 85          "tirith_timeout": _env_int("TIRITH_TIMEOUT", cfg.get("tirith_timeout", defaults["tirith_timeout"])),
 86          "tirith_fail_open": _env_bool("TIRITH_FAIL_OPEN", cfg.get("tirith_fail_open", defaults["tirith_fail_open"])),
 87      }
 88  
 89  
 90  # ---------------------------------------------------------------------------
 91  # Auto-install
 92  # ---------------------------------------------------------------------------
 93  
 94  # Cached path after first resolution (avoids repeated shutil.which per command).
 95  # _INSTALL_FAILED means "we tried and failed" — prevents retry on every command.
 96  _resolved_path: str | None | bool = None
 97  _INSTALL_FAILED = False  # sentinel: distinct from "not yet tried"
 98  _install_failure_reason: str = ""  # reason tag when _resolved_path is _INSTALL_FAILED
 99  
100  # Background install thread coordination
101  _install_lock = threading.Lock()
102  _install_thread: threading.Thread | None = None
103  
104  # Disk-persistent failure marker — avoids retry across process restarts
105  _MARKER_TTL = 86400  # 24 hours
106  
107  
108  def _get_hermes_home() -> str:
109      """Return the Hermes home directory, respecting HERMES_HOME env var."""
110      return str(get_hermes_home())
111  
112  
113  def _failure_marker_path() -> str:
114      """Return the path to the install-failure marker file."""
115      return os.path.join(_get_hermes_home(), ".tirith-install-failed")
116  
117  
118  def _read_failure_reason() -> str | None:
119      """Read the failure reason from the disk marker.
120  
121      Returns the reason string, or None if the marker doesn't exist or is
122      older than _MARKER_TTL.
123      """
124      try:
125          p = _failure_marker_path()
126          mtime = os.path.getmtime(p)
127          if (time.time() - mtime) >= _MARKER_TTL:
128              return None
129          with open(p, "r") as f:
130              return f.read().strip()
131      except OSError:
132          return None
133  
134  
135  def _is_install_failed_on_disk() -> bool:
136      """Check if a recent install failure was persisted to disk.
137  
138      Returns False (allowing retry) when:
139      - No marker exists
140      - Marker is older than _MARKER_TTL (24h)
141      - Marker reason is 'cosign_missing' and cosign is now on PATH
142      """
143      reason = _read_failure_reason()
144      if reason is None:
145          return False
146      if reason == "cosign_missing" and shutil.which("cosign"):
147          _clear_install_failed()
148          return False
149      return True
150  
151  
152  def _mark_install_failed(reason: str = ""):
153      """Persist install failure to disk to avoid retry on next process.
154  
155      Args:
156          reason: Short tag identifying the failure cause. Use "cosign_missing"
157                  when cosign is not on PATH so the marker can be auto-cleared
158                  once cosign becomes available.
159      """
160      try:
161          p = _failure_marker_path()
162          os.makedirs(os.path.dirname(p), exist_ok=True)
163          with open(p, "w") as f:
164              f.write(reason)
165      except OSError:
166          pass
167  
168  
169  def _clear_install_failed():
170      """Remove the failure marker after successful install."""
171      try:
172          os.unlink(_failure_marker_path())
173      except OSError:
174          pass
175  
176  
177  def _hermes_bin_dir() -> str:
178      """Return $HERMES_HOME/bin, creating it if needed."""
179      d = os.path.join(_get_hermes_home(), "bin")
180      os.makedirs(d, exist_ok=True)
181      return d
182  
183  
184  def _detect_target() -> str | None:
185      """Return the Rust target triple for the current platform, or None."""
186      system = platform.system()
187      machine = platform.machine().lower()
188  
189      # Android (Termux) is ABI-compatible with Linux — reuse Linux binaries.
190      if system == "Darwin":
191          plat = "apple-darwin"
192      elif system in ("Linux", "Android"):
193          plat = "unknown-linux-gnu"
194      else:
195          return None
196  
197      if machine in ("x86_64", "amd64"):
198          arch = "x86_64"
199      elif machine in ("aarch64", "arm64"):
200          arch = "aarch64"
201      else:
202          return None
203  
204      return f"{arch}-{plat}"
205  
206  
207  def _download_file(url: str, dest: str, timeout: int = 10):
208      """Download a URL to a local file."""
209      req = urllib.request.Request(url)
210      token = os.getenv("GITHUB_TOKEN")
211      if token:
212          req.add_header("Authorization", f"token {token}")
213      with urllib.request.urlopen(req, timeout=timeout) as resp, open(dest, "wb") as f:
214          shutil.copyfileobj(resp, f)
215  
216  
217  def _verify_cosign(checksums_path: str, sig_path: str, cert_path: str) -> bool | None:
218      """Verify cosign provenance signature on checksums.txt.
219  
220      Returns:
221          True  — cosign verified successfully
222          False — cosign found but verification failed
223          None  — cosign not available (not on PATH, or execution failed)
224  
225      The caller treats both False and None as "abort auto-install" — only
226      True allows the install to proceed.
227      """
228      cosign = shutil.which("cosign")
229      if not cosign:
230          logger.info("cosign not found on PATH")
231          return None
232  
233      try:
234          result = subprocess.run(
235              [cosign, "verify-blob",
236               "--certificate", cert_path,
237               "--signature", sig_path,
238               "--certificate-identity-regexp", _COSIGN_IDENTITY_REGEXP,
239               "--certificate-oidc-issuer", _COSIGN_ISSUER,
240               checksums_path],
241              capture_output=True,
242              text=True,
243              timeout=15,
244          )
245          if result.returncode == 0:
246              logger.info("cosign provenance verification passed")
247              return True
248          else:
249              logger.warning("cosign verification failed (exit %d): %s",
250                            result.returncode, result.stderr.strip())
251              return False
252      except (OSError, subprocess.TimeoutExpired) as exc:
253          logger.warning("cosign execution failed: %s", exc)
254          return None
255  
256  
257  def _verify_checksum(archive_path: str, checksums_path: str, archive_name: str) -> bool:
258      """Verify SHA-256 of the archive against checksums.txt."""
259      expected = None
260      with open(checksums_path) as f:
261          for line in f:
262              # Format: "<hash>  <filename>"
263              parts = line.strip().split("  ", 1)
264              if len(parts) == 2 and parts[1] == archive_name:
265                  expected = parts[0]
266                  break
267      if not expected:
268          logger.warning("No checksum entry for %s", archive_name)
269          return False
270  
271      sha = hashlib.sha256()
272      with open(archive_path, "rb") as f:
273          for chunk in iter(lambda: f.read(8192), b""):
274              sha.update(chunk)
275      actual = sha.hexdigest()
276      if actual != expected:
277          logger.warning("Checksum mismatch: expected %s, got %s", expected, actual)
278          return False
279      return True
280  
281  
282  def _install_tirith(*, log_failures: bool = True) -> tuple[str | None, str]:
283      """Download and install tirith to $HERMES_HOME/bin/tirith.
284  
285      Verifies provenance via cosign and SHA-256 checksum.
286      Returns (installed_path, failure_reason).  On success failure_reason is "".
287      failure_reason is a short tag used by the disk marker to decide if the
288      failure is retryable (e.g. "cosign_missing" clears when cosign appears).
289      """
290      log = logger.warning if log_failures else logger.debug
291  
292      target = _detect_target()
293      if not target:
294          logger.info("tirith auto-install: unsupported platform %s/%s",
295                       platform.system(), platform.machine())
296          return None, "unsupported_platform"
297  
298      archive_name = f"tirith-{target}.tar.gz"
299      base_url = f"https://github.com/{_REPO}/releases/latest/download"
300  
301      tmpdir = tempfile.mkdtemp(prefix="tirith-install-")
302      try:
303          archive_path = os.path.join(tmpdir, archive_name)
304          checksums_path = os.path.join(tmpdir, "checksums.txt")
305          sig_path = os.path.join(tmpdir, "checksums.txt.sig")
306          cert_path = os.path.join(tmpdir, "checksums.txt.pem")
307  
308          logger.info("tirith not found — downloading latest release for %s...", target)
309  
310          try:
311              _download_file(f"{base_url}/{archive_name}", archive_path)
312              _download_file(f"{base_url}/checksums.txt", checksums_path)
313          except Exception as exc:
314              log("tirith download failed: %s", exc)
315              return None, "download_failed"
316  
317          # Cosign provenance verification — preferred but not mandatory.
318          # When cosign is available, we verify that the release was produced
319          # by the expected GitHub Actions workflow (full supply chain proof).
320          # Without cosign, SHA-256 checksum + HTTPS still provides integrity
321          # and transport-level authenticity.
322          cosign_verified = False
323          if shutil.which("cosign"):
324              try:
325                  _download_file(f"{base_url}/checksums.txt.sig", sig_path)
326                  _download_file(f"{base_url}/checksums.txt.pem", cert_path)
327              except Exception as exc:
328                  logger.info("cosign artifacts unavailable (%s), proceeding with SHA-256 only", exc)
329              else:
330                  cosign_result = _verify_cosign(checksums_path, sig_path, cert_path)
331                  if cosign_result is True:
332                      cosign_verified = True
333                  elif cosign_result is False:
334                      # Verification explicitly rejected — abort, the release
335                      # may have been tampered with.
336                      log("tirith install aborted: cosign provenance verification failed")
337                      return None, "cosign_verification_failed"
338                  else:
339                      # None = execution failure (timeout/OSError) — proceed
340                      # with SHA-256 only since cosign itself is broken.
341                      logger.info("cosign execution failed, proceeding with SHA-256 only")
342          else:
343              logger.info("cosign not on PATH — installing tirith with SHA-256 verification only "
344                          "(install cosign for full supply chain verification)")
345  
346          if not _verify_checksum(archive_path, checksums_path, archive_name):
347              return None, "checksum_failed"
348  
349          with tarfile.open(archive_path, "r:gz") as tar:
350              # Extract only the tirith binary (safety: reject paths with ..)
351              for member in tar.getmembers():
352                  if member.name == "tirith" or member.name.endswith("/tirith"):
353                      if ".." in member.name:
354                          continue
355                      member.name = "tirith"
356                      tar.extract(member, tmpdir)
357                      break
358              else:
359                  log("tirith binary not found in archive")
360                  return None, "binary_not_in_archive"
361  
362          src = os.path.join(tmpdir, "tirith")
363          dest = os.path.join(_hermes_bin_dir(), "tirith")
364          try:
365              shutil.move(src, dest)
366          except OSError:
367              # Cross-device move (common in Docker, NFS): shutil.move() falls
368              # back to copy2 + unlink, but copy2's metadata step can raise
369              # PermissionError.  Use plain copy + manual chmod instead.
370              try:
371                  shutil.copy(src, dest)
372              except OSError:
373                  # Clean up partial dest to prevent a non-executable retry loop
374                  try:
375                      os.unlink(dest)
376                  except OSError:
377                      pass
378                  return None, "cross_device_copy_failed"
379          os.chmod(dest, os.stat(dest).st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
380  
381          verification = "cosign + SHA-256" if cosign_verified else "SHA-256 only"
382          logger.info("tirith installed to %s (%s)", dest, verification)
383          return dest, ""
384  
385      finally:
386          shutil.rmtree(tmpdir, ignore_errors=True)
387  
388  
389  def _is_explicit_path(configured_path: str) -> bool:
390      """Return True if the user explicitly configured a non-default tirith path."""
391      return configured_path != "tirith"
392  
393  
394  def _resolve_tirith_path(configured_path: str) -> str:
395      """Resolve the tirith binary path, auto-installing if necessary.
396  
397      If the user explicitly set a path (anything other than the bare "tirith"
398      default), that path is authoritative — we never fall through to
399      auto-download a different binary.
400  
401      For the default "tirith":
402      1. PATH lookup via shutil.which
403      2. $HERMES_HOME/bin/tirith (previously auto-installed)
404      3. Auto-install from GitHub releases → $HERMES_HOME/bin/tirith
405  
406      Failed installs are cached for the process lifetime (and persisted to
407      disk for 24h) to avoid repeated network attempts.
408      """
409      global _resolved_path, _install_failure_reason
410  
411      # Fast path: successfully resolved on a previous call.
412      if _resolved_path is not None and _resolved_path is not _INSTALL_FAILED:
413          return _resolved_path
414  
415      expanded = os.path.expanduser(configured_path)
416      explicit = _is_explicit_path(configured_path)
417      install_failed = _resolved_path is _INSTALL_FAILED
418  
419      # Explicit path: check it and stop. Never auto-download a replacement.
420      if explicit:
421          if os.path.isfile(expanded) and os.access(expanded, os.X_OK):
422              _resolved_path = expanded
423              return expanded
424          # Also try shutil.which in case it's a bare name on PATH
425          found = shutil.which(expanded)
426          if found:
427              _resolved_path = found
428              return found
429          logger.warning("Configured tirith path %r not found; scanning disabled", configured_path)
430          _resolved_path = _INSTALL_FAILED
431          _install_failure_reason = "explicit_path_missing"
432          return expanded
433  
434      # Default "tirith" — always re-run cheap local checks so a manual
435      # install is picked up even after a previous network failure (P2 fix:
436      # long-lived gateway/CLI recovers without restart).
437      found = shutil.which("tirith")
438      if found:
439          _resolved_path = found
440          _install_failure_reason = ""
441          _clear_install_failed()
442          return found
443  
444      hermes_bin = os.path.join(_hermes_bin_dir(), "tirith")
445      if os.path.isfile(hermes_bin) and os.access(hermes_bin, os.X_OK):
446          _resolved_path = hermes_bin
447          _install_failure_reason = ""
448          _clear_install_failed()
449          return hermes_bin
450  
451      # Local checks failed.  If a previous install attempt already failed,
452      # skip the network retry — UNLESS the failure was "cosign_missing" and
453      # cosign is now available (retryable cause resolved in-process).
454      if install_failed:
455          if _install_failure_reason == "cosign_missing" and shutil.which("cosign"):
456              # Retryable cause resolved — clear sentinel and fall through to retry
457              _resolved_path = None
458              _install_failure_reason = ""
459              _clear_install_failed()
460              install_failed = False
461          else:
462              return expanded
463  
464      # If a background install thread is running, don't start a parallel one —
465      # return the configured path; the OSError handler in check_command_security
466      # will apply fail_open until the thread finishes.
467      if _install_thread is not None and _install_thread.is_alive():
468          return expanded
469  
470      # Check disk failure marker before attempting network download.
471      # Preserve the marker's real reason so in-memory retry logic can
472      # detect retryable causes (e.g. cosign_missing) without restart.
473      disk_reason = _read_failure_reason()
474      if disk_reason is not None and _is_install_failed_on_disk():
475          _resolved_path = _INSTALL_FAILED
476          _install_failure_reason = disk_reason
477          return expanded
478  
479      installed, reason = _install_tirith()
480      if installed:
481          _resolved_path = installed
482          _install_failure_reason = ""
483          _clear_install_failed()
484          return installed
485  
486      # Install failed — cache the miss and persist reason to disk
487      _resolved_path = _INSTALL_FAILED
488      _install_failure_reason = reason
489      _mark_install_failed(reason)
490      return expanded
491  
492  
493  def _background_install(*, log_failures: bool = True):
494      """Background thread target: download and install tirith."""
495      global _resolved_path, _install_failure_reason
496      with _install_lock:
497          # Double-check after acquiring lock (another thread may have resolved)
498          if _resolved_path is not None:
499              return
500  
501          # Re-check local paths (may have been installed by another process)
502          found = shutil.which("tirith")
503          if found:
504              _resolved_path = found
505              _install_failure_reason = ""
506              return
507  
508          hermes_bin = os.path.join(_hermes_bin_dir(), "tirith")
509          if os.path.isfile(hermes_bin) and os.access(hermes_bin, os.X_OK):
510              _resolved_path = hermes_bin
511              _install_failure_reason = ""
512              return
513  
514          installed, reason = _install_tirith(log_failures=log_failures)
515          if installed:
516              _resolved_path = installed
517              _install_failure_reason = ""
518              _clear_install_failed()
519          else:
520              _resolved_path = _INSTALL_FAILED
521              _install_failure_reason = reason
522              _mark_install_failed(reason)
523  
524  
525  def ensure_installed(*, log_failures: bool = True):
526      """Ensure tirith is available, downloading in background if needed.
527  
528      Quick PATH/local checks are synchronous; network download runs in a
529      daemon thread so startup never blocks. Safe to call multiple times.
530      Returns the resolved path immediately if available, or None.
531      """
532      global _resolved_path, _install_thread, _install_failure_reason
533  
534      cfg = _load_security_config()
535      if not cfg["tirith_enabled"]:
536          return None
537  
538      # Already resolved from a previous call
539      if _resolved_path is not None and _resolved_path is not _INSTALL_FAILED:
540          path = _resolved_path
541          if os.path.isfile(path) and os.access(path, os.X_OK):
542              return path
543          return None
544  
545      configured_path = cfg["tirith_path"]
546      explicit = _is_explicit_path(configured_path)
547      expanded = os.path.expanduser(configured_path)
548  
549      # Explicit path: synchronous check only, no download
550      if explicit:
551          if os.path.isfile(expanded) and os.access(expanded, os.X_OK):
552              _resolved_path = expanded
553              return expanded
554          found = shutil.which(expanded)
555          if found:
556              _resolved_path = found
557              return found
558          _resolved_path = _INSTALL_FAILED
559          _install_failure_reason = "explicit_path_missing"
560          return None
561  
562      # Default "tirith" — quick local checks first (no network)
563      found = shutil.which("tirith")
564      if found:
565          _resolved_path = found
566          _install_failure_reason = ""
567          _clear_install_failed()
568          return found
569  
570      hermes_bin = os.path.join(_hermes_bin_dir(), "tirith")
571      if os.path.isfile(hermes_bin) and os.access(hermes_bin, os.X_OK):
572          _resolved_path = hermes_bin
573          _install_failure_reason = ""
574          _clear_install_failed()
575          return hermes_bin
576  
577      # If previously failed in-memory, check if the cause is now resolved
578      if _resolved_path is _INSTALL_FAILED:
579          if _install_failure_reason == "cosign_missing" and shutil.which("cosign"):
580              _resolved_path = None
581              _install_failure_reason = ""
582              _clear_install_failed()
583          else:
584              return None
585  
586      # Check disk failure marker (skip network attempt for 24h, unless
587      # the cosign_missing reason was resolved — handled by _is_install_failed_on_disk).
588      # Preserve the marker's real reason for in-memory retry logic.
589      disk_reason = _read_failure_reason()
590      if disk_reason is not None and _is_install_failed_on_disk():
591          _resolved_path = _INSTALL_FAILED
592          _install_failure_reason = disk_reason
593          return None
594  
595      # Need to download — launch background thread so startup doesn't block
596      if _install_thread is None or not _install_thread.is_alive():
597          _install_thread = threading.Thread(
598              target=_background_install,
599              kwargs={"log_failures": log_failures},
600              daemon=True,
601          )
602          _install_thread.start()
603  
604      return None  # Not available yet; commands will fail-open until ready
605  
606  
607  # ---------------------------------------------------------------------------
608  # Main API
609  # ---------------------------------------------------------------------------
610  
611  _MAX_FINDINGS = 50
612  _MAX_SUMMARY_LEN = 500
613  
614  
615  def check_command_security(command: str) -> dict:
616      """Run tirith security scan on a command.
617  
618      Exit code determines action (0=allow, 1=block, 2=warn). JSON enriches
619      findings/summary. Spawn failures and timeouts respect fail_open config.
620      Programming errors propagate.
621  
622      Returns:
623          {"action": "allow"|"warn"|"block", "findings": [...], "summary": str}
624      """
625      cfg = _load_security_config()
626  
627      if not cfg["tirith_enabled"]:
628          return {"action": "allow", "findings": [], "summary": ""}
629  
630      tirith_path = _resolve_tirith_path(cfg["tirith_path"])
631      timeout = cfg["tirith_timeout"]
632      fail_open = cfg["tirith_fail_open"]
633  
634      if tirith_path is None:
635          logger.warning("tirith path resolved to None; scanning disabled")
636          if fail_open:
637              return {"action": "allow", "findings": [], "summary": "tirith path unavailable"}
638          return {"action": "block", "findings": [], "summary": "tirith path unavailable (fail-closed)"}
639  
640      try:
641          result = subprocess.run(
642              [tirith_path, "check", "--json", "--non-interactive",
643               "--shell", "posix", "--", command],
644              capture_output=True,
645              text=True,
646              timeout=timeout,
647          )
648      except OSError as exc:
649          # Covers FileNotFoundError, PermissionError, exec format error
650          logger.warning("tirith spawn failed: %s", exc)
651          if fail_open:
652              return {"action": "allow", "findings": [], "summary": f"tirith unavailable: {exc}"}
653          return {"action": "block", "findings": [], "summary": f"tirith spawn failed (fail-closed): {exc}"}
654      except subprocess.TimeoutExpired:
655          logger.warning("tirith timed out after %ds", timeout)
656          if fail_open:
657              return {"action": "allow", "findings": [], "summary": f"tirith timed out ({timeout}s)"}
658          return {"action": "block", "findings": [], "summary": "tirith timed out (fail-closed)"}
659  
660      # Map exit code to action
661      exit_code = result.returncode
662      if exit_code == 0:
663          action = "allow"
664      elif exit_code == 1:
665          action = "block"
666      elif exit_code == 2:
667          action = "warn"
668      else:
669          # Unknown exit code — respect fail_open
670          logger.warning("tirith returned unexpected exit code %d", exit_code)
671          if fail_open:
672              return {"action": "allow", "findings": [], "summary": f"tirith exit code {exit_code} (fail-open)"}
673          return {"action": "block", "findings": [], "summary": f"tirith exit code {exit_code} (fail-closed)"}
674  
675      # Parse JSON for enrichment (never overrides the exit code verdict)
676      findings = []
677      summary = ""
678      try:
679          data = json.loads(result.stdout) if result.stdout.strip() else {}
680          raw_findings = data.get("findings", [])
681          findings = raw_findings[:_MAX_FINDINGS]
682          summary = (data.get("summary", "") or "")[:_MAX_SUMMARY_LEN]
683      except (json.JSONDecodeError, AttributeError):
684          # JSON parse failure degrades findings/summary, not the verdict
685          logger.debug("tirith JSON parse failed, using exit code only")
686          if action == "block":
687              summary = "security issue detected (details unavailable)"
688          elif action == "warn":
689              summary = "security warning detected (details unavailable)"
690  
691      return {"action": action, "findings": findings, "summary": summary}