""" SafeExecutor — PR3. Execute allowlisted shell scripts via asyncio.create_subprocess_exec (not shell=True). Security: path confinement to REPO_ROOT (realpath), strict env allowlist, stdin=DEVNULL, output cap, timeout clamp, non-root warning. """ from __future__ import annotations import asyncio import logging import os import time from pathlib import Path from typing import Any, Dict, List, Optional logger = logging.getLogger(__name__) # ── Config ──────────────────────────────────────────────────────────────────── def _get_repo_root() -> Path: """SOFIIA_REPO_ROOT env or auto-detect: app/ -> sofiia-console/ -> services/ -> repo.""" env = os.getenv("SOFIIA_REPO_ROOT", "").strip() if env: return Path(env).resolve() return Path(__file__).resolve().parent.parent.parent.parent _REPO_ROOT = _get_repo_root() # Allowlisted scripts (relative to REPO_ROOT) _SCRIPT_ALLOWLIST: frozenset = frozenset({ "ops/preflight_sofiia_console.sh", "ops/redis_idempotency_smoke.sh", "ops/generate_release_evidence.sh", }) # Env keys allowed to be passed from action_json _ENV_KEY_ALLOWLIST: frozenset = frozenset({ "STRICT", "SOFIIA_URL", "BFF_A", "BFF_B", "NODE_ID", "AGENT_ID", }) _TIMEOUT_MAX_S: int = 300 _OUTPUT_CAP_BYTES: int = 8 * 1024 # 8 KB # ── Public API ──────────────────────────────────────────────────────────────── class ScriptNotAllowedError(ValueError): pass def _validate_script_path(script: str) -> Path: """ Validate that script is in the allowlist and resides under REPO_ROOT. Returns resolved absolute Path. Raises ScriptNotAllowedError on any violation. """ if not script or not script.strip(): raise ScriptNotAllowedError("Empty script path") # Reject absolute paths and traversal immediately (before resolve) s = script.strip() if s.startswith("/"): raise ScriptNotAllowedError(f"Absolute paths not allowed: {s!r}") if ".." in Path(s).parts: raise ScriptNotAllowedError(f"Path traversal not allowed: {s!r}") # Exact allowlist check (on normalized relative form) normalized = s.replace("\\", "/") if normalized not in _SCRIPT_ALLOWLIST: raise ScriptNotAllowedError(f"Script not in allowlist: {normalized!r}") resolved = (_REPO_ROOT / normalized).resolve() # Confinement: must be under REPO_ROOT try: resolved.relative_to(_REPO_ROOT) except ValueError: raise ScriptNotAllowedError(f"Script escaped REPO_ROOT: {resolved}") return resolved def _filter_env(raw_env: Optional[Dict[str, Any]]) -> tuple[Dict[str, str], List[str]]: """Return (filtered_env, list_of_dropped_keys).""" if not raw_env: return {}, [] filtered: Dict[str, str] = {} dropped: List[str] = [] for k, v in raw_env.items(): if k in _ENV_KEY_ALLOWLIST: filtered[k] = str(v) else: dropped.append(k) return filtered, dropped def _cap_bytes(data: bytes, cap: int = _OUTPUT_CAP_BYTES) -> str: if not data: return "" text = data.decode("utf-8", errors="replace") if len(data) > cap: tail = text[-(cap // 2):] return f"[...truncated...]\n{tail}" return text async def run_script( script: str, env: Optional[Dict[str, Any]] = None, timeout_s: int = 120, ) -> Dict[str, Any]: """ Execute allowlisted script. Returns: {ok, exit_code, stdout_tail, stderr_tail, duration_ms, timeout, warning?} """ try: resolved = _validate_script_path(script) except ScriptNotAllowedError as e: return {"ok": False, "exit_code": None, "error": str(e), "stdout_tail": "", "stderr_tail": "", "duration_ms": 0, "timeout": False} # Build env: inherit minimal set, add allowed overrides base_env: Dict[str, str] = { k: v for k, v in os.environ.items() if k in {"PATH", "HOME", "LANG", "LC_ALL", "USER", "LOGNAME", "SOFIIA_DATA_DIR", "SOFIIA_REDIS_URL"} } filtered_env, dropped_keys = _filter_env(env) if dropped_keys: logger.warning("safe_executor: dropped non-allowlisted env keys: %s", dropped_keys) base_env.update(filtered_env) # Clamp timeout effective_timeout = max(1, min(int(timeout_s), _TIMEOUT_MAX_S)) # Non-root check warning: Optional[str] = None try: if os.getuid() == 0: warning = "running_as_root" logger.warning("safe_executor: running as root, step status will be warn") except AttributeError: pass # Windows / no getuid started = time.monotonic() timed_out = False exit_code: Optional[int] = None stdout_bytes = b"" stderr_bytes = b"" try: proc = await asyncio.create_subprocess_exec( "/bin/bash", str(resolved), cwd=str(_REPO_ROOT), env=base_env, stdin=asyncio.subprocess.DEVNULL, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, ) try: stdout_bytes, stderr_bytes = await asyncio.wait_for( proc.communicate(), timeout=float(effective_timeout) ) exit_code = proc.returncode except asyncio.TimeoutError: timed_out = True try: proc.kill() except Exception: pass try: await asyncio.wait_for(proc.wait(), timeout=5.0) except Exception: pass exit_code = None except Exception as e: logger.error("safe_executor: failed to start %s: %s", script, e) duration_ms = int((time.monotonic() - started) * 1000) return { "ok": False, "exit_code": None, "error": str(e)[:200], "stdout_tail": "", "stderr_tail": "", "duration_ms": duration_ms, "timeout": False, } duration_ms = int((time.monotonic() - started) * 1000) ok = (not timed_out) and exit_code == 0 result: Dict[str, Any] = { "ok": ok, "exit_code": exit_code, "stdout_tail": _cap_bytes(stdout_bytes), "stderr_tail": _cap_bytes(stderr_bytes), "duration_ms": duration_ms, "timeout": timed_out, "dropped_env_keys": dropped_keys or None, } if warning: result["warning"] = warning return result