Ctrl K
ComunidadGuías…

butler/tools/terminal_sandbox.py

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

Archivo original ↗Preguntar a KiBot
"""OS-level terminal sandbox (Linux bubblewrap; Cursor/CC-aligned subset)."""

from __future__ import annotations

import json
import logging
import os
import re
import shutil
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, cast

from butler.utilities.env_parse import env_truthy
from butler.tools.butlerignore import credential_mask_paths
from butler.defaults.env_defaults import (
    SANDBOX_CREDENTIAL_ENV_DEFAULT,
    TERMINAL_SANDBOX_DEFAULT,
    TERMINAL_SANDBOX_NETWORK_ALLOWLIST_DEFAULT,
    TERMINAL_SANDBOX_FAIL_UNAVAILABLE_DEFAULT,
)

logger = logging.getLogger(__name__)

_BWRAP_CANDIDATES = ("bwrap", "bubblewrap")
_SANDBOX_FAILURE_MARKERS = (
    (re.compile(r"(?i)network|unshare-net|operation not permitted.*socket"), "network"),
    (re.compile(r"(?i)read-only file system|permission denied"), "filesystem"),
    (re.compile(r"(?i)no such file or directory"), "filesystem"),
)

_DEFAULT_CREDENTIAL_ENV_DENY = (
    "GITHUB_TOKEN",
    "GITLAB_TOKEN",
    "NPM_TOKEN",
    "AWS_ACCESS_KEY_ID",
    "AWS_SECRET_ACCESS_KEY",
    "OPENAI_API_KEY",
    "ANTHROPIC_API_KEY",
)


@dataclass(frozen=True)
class NetworkPolicy:
    default: str = "deny"
    allow: tuple[str, ...] = ()
    deny: tuple[str, ...] = ()


@dataclass(frozen=True)
class TerminalSandboxConfig:
    enabled: bool = False
    sandbox_type: str = "workspace_readwrite"
    additional_readwrite_paths: tuple[str, ...] = ()
    additional_readonly_paths: tuple[str, ...] = ()
    disable_tmp_write: bool = False
    network: NetworkPolicy = field(default_factory=NetworkPolicy)
    fail_if_unavailable: bool = False
    credential_env_deny: tuple[str, ...] = _DEFAULT_CREDENTIAL_ENV_DENY


@dataclass(frozen=True)
class SandboxFailure:
    constraint: str
    code: str
    message: str

    def escalate_hint(self, command: str) -> str:
        short = (command or "").strip()[:120]
        return (
            f"沙箱约束「{self.constraint}」阻止了命令执行。"
            f" Owner 可发「/批准沙箱外 {short}」后在 5 分钟内无沙箱重试。"
        )


def terminal_sandbox_enabled() -> bool:
    return bool(env_truthy("BUTLER_TERMINAL_SANDBOX", default=TERMINAL_SANDBOX_DEFAULT))


def sandbox_network_allowlist_mode() -> bool:
    """When true and sandbox.json has network allow entries, skip full net unshare."""
    return bool(env_truthy("BUTLER_TERMINAL_SANDBOX_NETWORK_ALLOWLIST", default=TERMINAL_SANDBOX_NETWORK_ALLOWLIST_DEFAULT))


def sandbox_fail_if_unavailable() -> bool:
    return bool(env_truthy("BUTLER_TERMINAL_SANDBOX_FAIL_UNAVAILABLE", default=TERMINAL_SANDBOX_FAIL_UNAVAILABLE_DEFAULT))


def bubblewrap_path() -> str | None:
    for name in _BWRAP_CANDIDATES:
        found = shutil.which(name)
        if found:
            return found
    return None


def sandbox_runtime_available() -> bool:
    return bubblewrap_path() is not None


def _butler_home() -> Path:
    from butler.configuration.settings import get_butler_home

    return cast(Path, get_butler_home())


def _read_json_file(path: Path) -> dict[str, Any]:
    if not path.is_file():
        return {}
    try:
        data = json.loads(path.read_text(encoding="utf-8"))
    except (OSError, json.JSONDecodeError) as exc:
        logger.warning("invalid sandbox config %s: %s", path, exc)
        return {}
    return data if isinstance(data, dict) else {}


def _merge_paths(*groups: list[str]) -> tuple[str, ...]:
    seen: set[str] = set()
    out: list[str] = []
    for group in groups:
        for raw in group:
            text = str(raw or "").strip()
            if not text or text in seen:
                continue
            seen.add(text)
            out.append(text)
    return tuple(out)


def _parse_network_policy(raw: dict[str, Any] | None) -> NetworkPolicy:
    if not isinstance(raw, dict):
        return NetworkPolicy()
    default = str(raw.get("default") or "deny").strip().lower()
    if default not in ("allow", "deny"):
        default = "deny"
    allow = tuple(str(x).strip() for x in (raw.get("allow") or []) if str(x).strip())
    deny = tuple(str(x).strip() for x in (raw.get("deny") or []) if str(x).strip())
    return NetworkPolicy(default=default, allow=allow, deny=deny)


def _resolve_config_path(path_text: str, *, workspace: Path, user_home: Path) -> str | None:
    raw = str(path_text or "").strip()
    if not raw:
        return None
    if raw.startswith("~/"):
        return str((user_home / raw[2:]).resolve(strict=False))
    if raw.startswith("./") or raw == ".":
        rel = raw[2:] if raw.startswith("./") else ""
        return str((workspace / rel).resolve(strict=False))
    if raw.startswith("/"):
        return str(Path(raw).resolve(strict=False))
    return str((workspace / raw).resolve(strict=False))


def load_terminal_sandbox_config(workspace: Path) -> TerminalSandboxConfig:
    """Merge user + repo ``sandbox.json`` (repo wins); env gates ``enabled``."""
    ws = workspace.resolve(strict=False)
    user_cfg = _read_json_file(_butler_home() / "sandbox.json")
    repo_cfg = _read_json_file(ws / ".butler" / "sandbox.json")

    sandbox_type = str(repo_cfg.get("type") or user_cfg.get("type") or "workspace_readwrite")
    if sandbox_type not in ("workspace_readwrite", "workspace_readonly", "insecure_none"):
        sandbox_type = "workspace_readwrite"

    rw_paths = _merge_paths(
        [str(x) for x in user_cfg.get("additionalReadwritePaths") or []],
        [str(x) for x in repo_cfg.get("additionalReadwritePaths") or []],
    )
    ro_paths = _merge_paths(
        [str(x) for x in user_cfg.get("additionalReadonlyPaths") or []],
        [str(x) for x in repo_cfg.get("additionalReadonlyPaths") or []],
    )

    user_home = Path.home()
    resolved_rw = tuple(
        p
        for p in (
            _resolve_config_path(item, workspace=ws, user_home=user_home) for item in rw_paths
        )
        if p
    )
    resolved_ro = tuple(
        p
        for p in (
            _resolve_config_path(item, workspace=ws, user_home=user_home) for item in ro_paths
        )
        if p
    )

    disable_tmp = bool(user_cfg.get("disableTmpWrite")) or bool(repo_cfg.get("disableTmpWrite"))
    network = _merge_network_policy(
        _parse_network_policy(user_cfg.get("networkPolicy")),
        _parse_network_policy(repo_cfg.get("networkPolicy")),
    )

    env_deny = os.getenv("BUTLER_SANDBOX_CREDENTIAL_ENV", SANDBOX_CREDENTIAL_ENV_DEFAULT).strip()
    credential_env = (
        tuple(x.strip() for x in env_deny.split(",") if x.strip())
        if env_deny
        else _DEFAULT_CREDENTIAL_ENV_DENY
    )

    return TerminalSandboxConfig(
        enabled=terminal_sandbox_enabled() and sandbox_type != "insecure_none",
        sandbox_type=sandbox_type,
        additional_readwrite_paths=resolved_rw,
        additional_readonly_paths=resolved_ro,
        disable_tmp_write=disable_tmp,
        network=network,
        fail_if_unavailable=sandbox_fail_if_unavailable(),
        credential_env_deny=credential_env,
    )


def _merge_network_policy(user: NetworkPolicy, repo: NetworkPolicy) -> NetworkPolicy:
    default = "deny" if "deny" in (user.default, repo.default) else "allow"
    allow = _merge_paths(list(user.allow), list(repo.allow))
    deny = _merge_paths(list(user.deny), list(repo.deny))
    return NetworkPolicy(default=default, allow=allow, deny=deny)


def should_run_sandboxed(
    config: TerminalSandboxConfig,
    *,
    unsandboxed_approved: bool = False,
) -> bool:
    if unsandboxed_approved:
        return False
    if not config.enabled:
        return False
    if config.sandbox_type == "insecure_none":
        return False
    return True


def enrich_subprocess_env(base_env: dict[str, str], *, sandboxed: bool) -> dict[str, str]:
    env = dict(base_env)
    if sandboxed:
        env["BUTLER_SANDBOX"] = "1"
        try:
            env["BUTLER_ORIG_UID"] = str(os.getuid())
            env["BUTLER_ORIG_GID"] = str(os.getgid())
        except AttributeError:
            pass
    return env


def scrub_credential_env(env: dict[str, str], config: TerminalSandboxConfig) -> dict[str, str]:
    cleaned = dict(env)
    for name in config.credential_env_deny:
        cleaned.pop(name, None)
    return cleaned


def wrap_argv_with_bubblewrap(
    argv: list[str],
    *,
    workspace: Path,
    config: TerminalSandboxConfig,
) -> list[str]:
    """Wrap ``argv`` with bubblewrap; raises ``RuntimeError`` when bwrap missing."""
    bwrap = bubblewrap_path()
    if not bwrap:
        raise RuntimeError("bubblewrap (bwrap) not found on PATH")

    ws = str(workspace.resolve(strict=False))
    cmd: list[str] = [
        bwrap,
        "--die-with-parent",
        "--unshare-pid",
        "--ro-bind",
        "/",
        "/",
    ]

    if config.sandbox_type == "workspace_readwrite":
        cmd.extend(["--bind", ws, ws])
    else:
        cmd.extend(["--ro-bind", ws, ws])

    if not config.disable_tmp_write:
        cmd.extend(["--bind", "/tmp", "/tmp"])

    for extra in config.additional_readwrite_paths:
        if extra and extra != ws:
            cmd.extend(["--bind", extra, extra])
    for extra in config.additional_readonly_paths:
        if extra and extra not in config.additional_readwrite_paths:
            cmd.extend(["--ro-bind", extra, extra])

    for masked in credential_mask_paths():
        cmd.extend(["--tmpfs", masked])

    cmd.extend(["--dev", "/dev", "--proc", "/proc", "--chdir", ws])

    net_deny = config.network.default == "deny"
    has_allow = bool(config.network.allow)
    if net_deny and not (has_allow and sandbox_network_allowlist_mode()):
        cmd.append("--unshare-net")
    elif net_deny and has_allow and sandbox_network_allowlist_mode():
        logger.info(
            "sandbox network allowlist mode: skipping --unshare-net (hosts=%s)",
            ",".join(config.network.allow[:5]),
        )

    cmd.extend(["--setenv", "BUTLER_SANDBOX", "1"])
    cmd.append("--")
    cmd.extend(argv)
    return cmd


def classify_sandbox_failure(
    *,
    exit_code: int | None,
    stdout: str,
    stderr: str,
    sandboxed: bool,
) -> SandboxFailure | None:
    if not sandboxed:
        return None
    if exit_code in (0, None):
        return None
    blob = f"{stdout}\n{stderr}"
    constraint = "unknown"
    for pattern, name in _SANDBOX_FAILURE_MARKERS:
        if pattern.search(blob):
            constraint = name
            break
    code = f"SANDBOX_{constraint.upper()}_DENIED"
    snippet = (stderr or stdout or "").strip().splitlines()
    detail = snippet[-1][:200] if snippet else f"exit_code={exit_code}"
    return SandboxFailure(
        constraint=constraint,
        code=code,
        message=detail,
    )


def format_sandbox_error_payload(
    failure: SandboxFailure,
    *,
    command: str,
    exit_code: int | None,
    output: str,
) -> dict[str, Any]:
    from butler.core.approval_cards import format_terminal_sandbox_card

    card = format_terminal_sandbox_card(command, constraint=failure.constraint)
    return {
        "ok": False,
        "error": failure.message,
        "code": failure.code,
        "sandbox_constraint": failure.constraint,
        "escalate_hint": card,
        "exit_code": exit_code,
        "output": output,
        "sandboxed": True,
    }


__all__ = [
    "NetworkPolicy",
    "SandboxFailure",
    "TerminalSandboxConfig",
    "bubblewrap_path",
    "classify_sandbox_failure",
    "enrich_subprocess_env",
    "format_sandbox_error_payload",
    "load_terminal_sandbox_config",
    "sandbox_fail_if_unavailable",
    "sandbox_network_allowlist_mode",
    "sandbox_runtime_available",
    "scrub_credential_env",
    "should_run_sandboxed",
    "terminal_sandbox_enabled",
    "wrap_argv_with_bubblewrap",
]