hataa cc6a2657e7
feat(authz): enforce sandbox:execute authorization at sandbox acquisition (#4063 Phase 3) (#4911)
Sandbox is an execution environment, not a named resource: multiple tools
(bash, read_file, write_file, glob, grep, ...) depend on it, all funneled
through ensure_sandbox_initialized / ensure_sandbox_initialized_async. Gate
the single acquisition entry point (single source of truth) instead of
maintaining a sandbox-tool-name set in middleware:

- authorize_sandbox_execution helper (authz/sandbox_authz.py) checks
  authorize("sandbox", "execute", target="*") — a binary judgment
  (can this role use the sandbox at all); RBAC allow:"*"/true permits,
  allow:[]/false denies.
- lazy path: ensure_sandbox_initialized (+ async) calls the gate before
  provider.acquire.
- eager path: SandboxMiddleware.before_agent / abefore_agent call the gate
  before _acquire_sandbox.
- deny raises SandboxAuthorizationError (SandboxError subclass) which
  propagates through tool execution as a friendly ToolMessage (RFC §9:
  'not a crash').
- authorization.enabled: false is a no-op everywhere; provider errors
  follow fail_closed (deny) / fail_open (allow).

12 tests in tests/test_sandbox_authorization.py cover disabled/allow/deny/
deny-via-bool/no-policy-unrestricted/provider-error-fail-closed/open/
internal-caller + ensure_sandbox_initialized deny (never acquires) and
allow (acquires) integration paths.
2026-08-24 16:32:06 +08:00

102 lines
4.6 KiB
Python

"""Sandbox execution authorization gate.
Checks ``authorize("sandbox", "execute")`` before sandbox acquisition so a
role-scoped policy can deny sandbox execution entirely. On deny, a
:class:`~deerflow.sandbox.exceptions.SandboxAuthorizationError` propagates up
through the tool's execution; the agent's tool-error handling converts it to a
friendly ``ToolMessage`` ("sandbox not permitted for your role") rather than
crashing the run (RFC §9).
Mirrors the Principal/provider pattern of ``apply_tool_authorization``
(``tool_filter.py``) and ``_authorize_model_name`` (``lead_agent/agent.py``)
so the sandbox path shares one identity source with the tool and model paths.
"""
from __future__ import annotations
import logging
from collections.abc import Mapping
from typing import Any
from deerflow.authz.principal import build_principal_from_context
from deerflow.authz.provider import AuthzDecision, AuthzRequest
from deerflow.authz.runtime import resolve_authorization_provider
from deerflow.config.app_config import AppConfig
from deerflow.sandbox.exceptions import SandboxAuthorizationError
logger = logging.getLogger(__name__)
def safe_app_config() -> AppConfig | None:
"""Load the global AppConfig, returning None when unavailable.
Authorization can only be enabled via config, so no readable config ⇒ the
sandbox gate is a no-op (``authorize_sandbox_execution`` treats a ``None``
app_config the same as ``authorization.enabled: false``). This keeps the
gate safe in environments without a ``config.yaml`` (e.g. CI runners and
direct-call tests) where ``get_app_config`` would raise ``FileNotFoundError``.
"""
try:
from deerflow.config import get_app_config
return get_app_config()
except Exception:
logger.debug("App config unavailable; sandbox:execute gate is a no-op", exc_info=True)
return None
# Sandbox is a single shared resource (the execution environment), not a named
# catalog like tools/models/skills. The target is therefore a sentinel "*" that
# means "the sandbox as a whole"; RBAC ``allow: ["*"]`` / ``allow: true`` permits
# it, ``allow: []`` / ``allow: false`` denies it.
_SANDBOX_TARGET = "*"
def authorize_sandbox_execution(*, context: Mapping[str, Any], app_config: AppConfig | None) -> None:
"""Check ``authorize("sandbox", "execute")`` before sandbox acquisition.
``app_config=None`` (unreadable config) is treated the same as
``authorization.enabled: false`` — a no-op. On deny (or provider error
with ``fail_closed``), raises :class:`SandboxAuthorizationError`; on
provider error with fail-open, returns silently (legacy allow behavior).
"""
# Guard against Mock/SimpleNamespace app_config objects in tests that
# don't carry a real AuthorizationConfig. getattr avoids AttributeError
# and the ``is not True`` identity check avoids truthy Mock attributes
# (mirrors filter_available_skills_by_authorization in skill_filter.py).
authz_config = getattr(app_config, "authorization", None)
if authz_config is None or getattr(authz_config, "enabled", None) is not True:
return
# Provider *resolution* failures follow the same fail_closed/fail_open
# decision as authorize() errors — a raw ValueError here would otherwise
# effectively deny under fail_open (inverted semantics).
try:
provider = resolve_authorization_provider(authz_config)
except Exception:
logger.warning("Failed to resolve authorization provider for sandbox:execute", exc_info=True)
if authz_config.fail_closed:
raise SandboxAuthorizationError() from None
# fail-open: allow sandbox acquisition despite the resolution error.
return
if provider is None:
return
principal = build_principal_from_context(context, default_role=authz_config.default_role)
try:
decision = provider.authorize(AuthzRequest(principal=principal, resource="sandbox", action="execute", target=_SANDBOX_TARGET))
if not isinstance(decision, AuthzDecision):
raise TypeError("AuthorizationProvider.authorize must return AuthzDecision")
if decision.allow:
return
# Explicit deny → block sandbox acquisition with a friendly error.
raise SandboxAuthorizationError(role=principal.role)
except SandboxAuthorizationError:
raise
except Exception:
logger.warning("Authorization provider failed while checking sandbox:execute", exc_info=True)
if authz_config.fail_closed:
raise SandboxAuthorizationError(role=principal.role)
# fail-open: allow sandbox acquisition despite the provider error.
return