"""Terminal exec approval binding (OpenClaw exec-approvals subset).""" from __future__ import annotations import hashlib import json import logging import os import time from pathlib import Path from butler.configuration.settings import get_butler_home from butler.contracts.approval_registry import get_approval_store from butler.core.approval_cards import format_terminal_exec_card from butler.defaults.env_defaults import TERMINAL_REQUIRE_APPROVAL_DEFAULT from butler.permissions.approvals import _load, _purge_once from butler.tools.terminal_approval_ops import ( canonicalize_command_safe, read_approval_record_safe, try_auto_review_terminal_safe, ) logger = logging.getLogger(__name__) _TTL_SEC = 300.0 _GLOBAL_TERMINAL_SESSION_KEY = "__terminal_global__" def _resolve_session_key(session_key: str) -> str: sk = str(session_key or "").strip() return sk or _GLOBAL_TERMINAL_SESSION_KEY def _legacy_approval_path(fp: str) -> Path: return Path(get_butler_home()) / "exec_approvals" / f"{fp}.json" def approval_required() -> bool: return os.getenv("BUTLER_TERMINAL_REQUIRE_APPROVAL", str(int(TERMINAL_REQUIRE_APPROVAL_DEFAULT))).strip().lower() in ( "1", "true", "yes", "on", ) def argv_fingerprint(command: str, *, cwd: str = "") -> str: canonical = canonicalize_command_safe(command) payload = json.dumps( {"command": canonical, "cwd": (cwd or "").strip()}, sort_keys=True, ensure_ascii=False, ) return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:32] def _migrate_legacy_exec_approval( command: str, *, cwd: str, session_key: str, fp: str, ) -> bool: """Import one-shot legacy ``exec_approvals/{fp}.json`` into approvals.json.""" from butler.contracts.approval_store_impl import grant_terminal_exec_once path = _legacy_approval_path(fp) if not path.is_file(): return False data = read_approval_record_safe(path) try: if data is None: path.unlink(missing_ok=True) return False expires = float(data.get("expires_at") or 0) if time.time() > expires: path.unlink(missing_ok=True) return False if str(data.get("command") or "").strip() != command.strip(): return False sk = _resolve_session_key(str(data.get("session_key") or session_key)) grant_terminal_exec_once( sk, fingerprint=fp, command=command, ttl_sec=max(1.0, expires - time.time()), unsandboxed=bool(data.get("unsandboxed")), ) path.unlink(missing_ok=True) return True except OSError as exc: logger.debug("legacy exec_approval migrate failed: %s", exc) return False def store_approval( command: str, *, cwd: str = "", session_key: str = "", ttl_sec: float | None = None, unsandboxed: bool = False, ) -> str: from butler.contracts.approval_store_impl import grant_terminal_exec_once fp = argv_fingerprint(command, cwd=cwd) sk = _resolve_session_key(session_key) grant_terminal_exec_once( sk, fingerprint=fp, command=command, ttl_sec=ttl_sec, unsandboxed=unsandboxed, ) return fp def check_approval( command: str, *, cwd: str = "", session_key: str = "", ) -> str | None: """Return error message if not approved; None if ok or approval not required.""" if not approval_required(): return None review = try_auto_review_terminal_safe(command) if review is not None and review.allowed and not review.skipped: store_approval(command, cwd=cwd, session_key=session_key, ttl_sec=300.0) return None fp = argv_fingerprint(command, cwd=cwd) sk = _resolve_session_key(session_key) _migrate_legacy_exec_approval(command, cwd=cwd, session_key=sk, fp=fp) store = get_approval_store() if store is not None and store.is_approved( sk, permission="terminal_exec", tool="terminal", pattern=fp, ): return None return str( format_terminal_exec_card( command, reason="终端命令需 Owner 批准后再执行", ) ) def approval_allows_unsandboxed( command: str, *, cwd: str = "", session_key: str = "", ) -> bool: """True when a valid approval record explicitly allows running outside sandbox.""" fp = argv_fingerprint(command, cwd=cwd) sk = _resolve_session_key(session_key) _migrate_legacy_exec_approval(command, cwd=cwd, session_key=sk, fp=fp) data = _load(sk) now = time.time() for row in _purge_once(data.get("once") or []): if not isinstance(row, dict): continue if str(row.get("permission") or "") != "terminal_exec": continue if str(row.get("pattern") or "") != fp and str(row.get("fingerprint") or "") != fp: continue if not bool(row.get("unsandboxed")): continue if str(row.get("command") or "").strip() != command.strip(): continue if float(row.get("expires_at") or 0) <= now: continue return True return False def parse_approve_command(text: str) -> str | None: """Extract command from `/批准执行 ...` or `/approve-exec ...`.""" raw = (text or "").strip() for prefix in ("/批准执行", "/approve-exec", "/approve_exec"): if raw.lower().startswith(prefix.lower()): rest = raw[len(prefix) :].strip() return rest or None return None def parse_approve_unsandboxed_command(text: str) -> str | None: """Extract command from ``/批准沙箱外`` or ``/approve-unsandboxed``.""" raw = (text or "").strip() for prefix in ("/批准沙箱外", "/approve-unsandboxed", "/approve_unsandboxed"): if raw.lower().startswith(prefix.lower()): rest = raw[len(prefix) :].strip() return rest or None return None