Ctrl K
ComunidadGuías…

butler/tools/terminal_approval.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""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