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}