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.
This commit is contained in:
hataa 2026-08-24 16:32:06 +08:00 committed by GitHub
parent cff8b74ec3
commit cc6a2657e7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 1090 additions and 24 deletions

View File

@ -901,7 +901,7 @@ uv run python -m deerflow.skills.review.cli ../skills/public/data-analysis --for
Tools follow the same philosophy. DeerFlow comes with a core toolset — web search, web fetch, rendered web capture, file operations, bash execution — and supports custom tools via MCP servers and Python functions. Swap anything. Add anything.
Advanced deployments can enable pluggable authorization with `authorization.enabled` in `config.yaml`. A configured `AuthorizationProvider` filters denied tools before they reach the model or deferred-tool catalog, then the same provider is checked again before every business-tool execution through the existing guardrail middleware. Gateway `threads:*` and `runs:*` route permissions are derived from the same provider, while existing owner checks and admin-only management gates remain in force. A generated `tool_search` may bypass the second tool check only when it fronts the current build's already-filtered deferred catalog. The built-in RBAC provider supports per-role `tools` and `routes` allow/deny policies and validates that `default_role` names a configured role; authorization is disabled by default. See `config.example.yaml` and the [authorization RFC](docs/plans/2026-07-10-pluggable-authorization-rfc.md).
Advanced deployments can enable pluggable authorization with `authorization.enabled` in `config.yaml`. A configured `AuthorizationProvider` filters denied tools before they reach the model or deferred-tool catalog, then the same provider is checked again before every business-tool execution through the existing guardrail middleware. Gateway `threads:*` and `runs:*` route permissions are derived from the same provider, while existing owner checks and admin-only management gates remain in force. A generated `tool_search` may bypass the second tool check only when it fronts the current build's already-filtered deferred catalog. Model access follows the same provider: the Gateway `models` list is filtered per principal, `model:use` is enforced on model detail requests and again when the runtime resolves the agent's model, and a denied default model falls back to the first remaining candidate that also passes `model:use`. The built-in RBAC provider supports per-role `tools`, `routes`, `models`, `skills`, and `sandbox` allow/deny policies and validates that `default_role` names a configured role; authorization is disabled by default. See `config.example.yaml` and the [authorization RFC](docs/plans/2026-07-10-pluggable-authorization-rfc.md).
Advanced deployments can also extend the agent runtime itself by declaring zero-argument `AgentMiddleware` classes under `extensions.middlewares` in `config.yaml` or `extensions_config.json`. DeerFlow loads the same configured class list into the lead-agent and subagent pipelines after their built-in runtime middlewares and loop/token guards, but before the terminal-response/safety/clarification tail, so enterprise forks can add domain guardrails, tool-call governance, or observability hooks without patching the built-in middleware builders. Missing packages, invalid classes, and broken modules fail loudly at agent creation. Treat `config.yaml` and `extensions_config.json` as trusted operator-controlled files: middleware paths are code execution, just like custom tool, model, sandbox, guardrail, MCP server, and MCP interceptor declarations. Gateway skill/MCP toggle endpoints preserve this field but do not expose an API write path for `extensions.middlewares`. Per-context parameterization and separate lead-only/subagent-only middleware lists are not supported yet.

View File

@ -46,6 +46,7 @@ from deerflow.config.authorization_config import AuthorizationConfig
if TYPE_CHECKING:
from app.gateway.auth.models import User
from deerflow.config.app_config import AppConfig
P = ParamSpec("P")
T = TypeVar("T")
@ -299,23 +300,121 @@ def resolve_model_authorization(user: User, *, is_internal: bool) -> tuple[Autho
logger.warning("Failed to resolve authorization provider for model routes", exc_info=True)
raise _AuthorizationUnavailable(fail_closed=config.fail_closed)
principal = build_principal_from_context(
_route_authz_context(user, is_internal=is_internal),
default_role=config.default_role,
)
return provider, principal
def _route_authz_context(user: User, *, is_internal: bool) -> dict:
"""Build the shared Principal context dict for a request-scoped user.
Applies the ``INTERNAL_SYSTEM_ROLE None`` pop so internal callers fall
under ``default_role`` (mirrors ``inject_authenticated_user_context``).
Used by ``resolve_model_authorization`` and ``authorize_sandbox_for_request``
so every route-level authorization path builds the identity the same way.
"""
from app.gateway.internal_auth import INTERNAL_SYSTEM_ROLE
user_role = getattr(user, "system_role", None)
if user_role == INTERNAL_SYSTEM_ROLE:
user_role = None
return {
"user_id": str(user.id),
"user_role": user_role,
"oauth_provider": getattr(user, "oauth_provider", None),
"oauth_id": getattr(user, "oauth_id", None),
"is_internal": is_internal,
}
principal = build_principal_from_context(
{
"user_id": str(user.id),
"user_role": user_role,
"oauth_provider": getattr(user, "oauth_provider", None),
"oauth_id": getattr(user, "oauth_id", None),
"is_internal": is_internal,
},
default_role=config.default_role,
)
return provider, principal
def authorize_sandbox_for_request(
user: User,
*,
is_internal: bool,
app_config: AppConfig | None,
) -> None:
"""Check ``sandbox:execute`` for a Gateway request before sandbox acquisition.
Thin wrapper over the harness-level ``authorize_sandbox_execution`` that
builds the Principal from the request-scoped ``user`` the same identity
construction as ``resolve_model_authorization`` (including the
``INTERNAL_SYSTEM_ROLE None`` pop). Raises
:class:`~deerflow.sandbox.exceptions.SandboxAuthorizationError` on deny or
on provider-resolution failure under ``fail_closed``; callers translate
that into skipping the sandbox sync (not an HTTP error, since the primary
operation e.g. file upload can proceed without it).
No-op when ``authorization.enabled`` is false.
"""
from deerflow.authz.sandbox_authz import authorize_sandbox_execution
from deerflow.sandbox.exceptions import SandboxAuthorizationError
config = _get_route_authorization_config()
if config.enabled is not True:
return
context = _route_authz_context(user, is_internal=is_internal)
try:
authorize_sandbox_execution(
context=context,
app_config=app_config,
)
except SandboxAuthorizationError:
raise
except Exception:
# Defense-in-depth: provider resolution and authorize() errors are
# already converted to SandboxAuthorizationError (or allowed under
# fail-open) one layer down inside authorize_sandbox_execution, so this
# normally only catches config-read failures here (e.g. get_config()
# raising in a config-less environment). Those must not 500 the
# upload/artifact route — degrade per fail_closed instead.
logger.warning("Failed to resolve authorization provider for sandbox:execute", exc_info=True)
if config.fail_closed:
raise SandboxAuthorizationError(role=context.get("user_role")) from None
async def try_acquire_sandbox_for_request(
request: Request,
sandbox_provider,
thread_id: str,
*,
user_id: str,
app_config: AppConfig | None,
) -> tuple[object, str | None, bool]:
"""Gate + acquire the thread sandbox for a Gateway sync path.
Single entry point for the uploads/artifacts sandbox-sync paths so the
deny/skip semantics live in one place: runs the ``sandbox:execute`` gate
for the request's user, then acquires the sandbox. Returns
``(sandbox, sandbox_id, denied)``:
- denied role ``(None, None, True)``: acquisition was skipped by policy;
the primary operation (upload / artifact edit) proceeds without the
sandbox copy.
- allowed ``(sandbox, sandbox_id, False)``: ``sandbox`` is the acquired
instance (``sandbox_id`` for later release), or ``sandbox is None`` when
the provider lost it right after acquiring (infrastructure error
callers surface it as 500 / RuntimeError respectively, since that is
not a policy decision).
- ``request is None`` (direct-call tests) and unresolvable users skip the
gate same fail-open semantics as the models routes' anonymous bypass.
"""
from deerflow.sandbox.exceptions import SandboxAuthorizationError
try:
from app.gateway.deps import get_optional_user_from_request
user = await get_optional_user_from_request(request) if request is not None else None
if user is not None:
authorize_sandbox_for_request(user, is_internal=_is_internal_caller(request, user), app_config=app_config)
except SandboxAuthorizationError:
logger.info("Sandbox sync skipped: sandbox execution not permitted for this caller (thread_id=%s)", thread_id)
return None, None, True
sandbox_id = await sandbox_provider.acquire_async(thread_id, user_id=user_id)
return sandbox_provider.get(sandbox_id), sandbox_id, False
async def _authenticate(request: Request) -> AuthContext:

View File

@ -16,10 +16,11 @@ from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import FileResponse, Response
from pydantic import BaseModel, Field
from app.gateway.authz import require_permission
from app.gateway.authz import require_permission, try_acquire_sandbox_for_request
from app.gateway.deps import get_run_manager
from app.gateway.internal_auth import get_trusted_internal_owner_user_id
from app.gateway.path_utils import resolve_thread_virtual_path
from deerflow.authz.sandbox_authz import safe_app_config
from deerflow.config.paths import make_safe_user_id
from deerflow.runtime import ConflictError, ThreadOperationKind
from deerflow.runtime.user_context import get_effective_user_id
@ -498,7 +499,14 @@ async def update_artifact(
body: ArtifactUpdateRequest,
request: Request,
) -> ArtifactUpdateResponse:
"""Update an existing text artifact while the thread has no active run."""
"""Update an existing text artifact while the thread has no active run.
The host-side artifact file is updated first; when the sandbox provider is
not thread-mounted, the new content is also synced into the thread's
sandbox. Under ``authorization.enabled``, a caller denied
``sandbox:execute`` skips that sandbox sync (the host-side update still
completes).
"""
virtual_path = _normalize_editable_artifact_path(path)
raw_owner_user_id = get_trusted_internal_owner_user_id(request)
effective_user_id = make_safe_user_id(raw_owner_user_id) if raw_owner_user_id else get_effective_user_id()
@ -524,9 +532,18 @@ async def update_artifact(
sandbox_provider = get_sandbox_provider()
if not bool(getattr(sandbox_provider, "uses_thread_data_mounts", False)):
sandbox_id = await sandbox_provider.acquire_async(thread_id, user_id=effective_user_id)
sandbox = sandbox_provider.get(sandbox_id)
if sandbox is None:
# Phase 3: enforce sandbox:execute before acquiring — a denied
# role skips the sandbox sync; the host-side artifact update
# still completes (the agent cannot consume the sandbox copy
# anyway when sandbox execution is denied).
sandbox, sandbox_id, sandbox_denied = await try_acquire_sandbox_for_request(
request,
sandbox_provider,
thread_id,
user_id=effective_user_id,
app_config=safe_app_config(),
)
if not sandbox_denied and sandbox is None:
raise RuntimeError("Failed to acquire sandbox for artifact update")
try:

View File

@ -11,7 +11,7 @@ from typing import BinaryIO
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile
from pydantic import BaseModel, Field
from app.gateway.authz import require_permission
from app.gateway.authz import require_permission, try_acquire_sandbox_for_request
from app.gateway.deps import get_config
from deerflow.config.app_config import AppConfig
from deerflow.config.paths import get_paths
@ -305,7 +305,14 @@ async def upload_files(
files: list[UploadFile] = File(...),
config: AppConfig = Depends(get_config),
) -> UploadResponse:
"""Upload multiple files to a thread's uploads directory."""
"""Upload multiple files to a thread's uploads directory.
When the sandbox provider is not thread-mounted, uploaded files are also
synced into the thread's sandbox. Under ``authorization.enabled``, a caller
denied ``sandbox:execute`` skips that sync (the upload itself still
succeeds files stay in the uploads dir; a sandbox-denied agent cannot
consume them anyway).
"""
if not files:
raise HTTPException(status_code=400, detail="No files provided")
@ -333,9 +340,19 @@ async def upload_files(
sync_to_sandbox = not _uses_thread_data_mounts(sandbox_provider)
sandbox = None
if sync_to_sandbox:
sandbox_id = await sandbox_provider.acquire_async(thread_id, user_id=effective_user_id)
sandbox = sandbox_provider.get(sandbox_id)
if sandbox is None:
# Phase 3: enforce sandbox:execute before acquiring — a role denied
# sandbox execution must not trigger sandbox allocation just by
# uploading files. Deny skips the sync; the upload itself still
# succeeds (files stay in the thread uploads dir; the agent cannot
# consume them via sandbox anyway).
sandbox, _sandbox_id, sandbox_denied = await try_acquire_sandbox_for_request(
request,
sandbox_provider,
thread_id,
user_id=effective_user_id,
app_config=config,
)
if not sandbox_denied and sandbox is None:
raise HTTPException(status_code=500, detail="Failed to acquire sandbox")
auto_convert_documents = _auto_convert_documents_enabled(config)
@ -427,7 +444,7 @@ async def upload_files(
# configuration can read the uploaded content.
await run_file_io(_make_uploaded_paths_sandbox_readable, written_paths)
if sync_to_sandbox:
if sync_to_sandbox and sandbox is not None:
for file_path, virtual_path in sandbox_sync_targets:
await run_file_io(_sync_upload_to_sandbox, sandbox, file_path, virtual_path)

View File

@ -67,6 +67,10 @@ Authorization identity plumbing is independent of whether authorization enforcem
Gateway route authorization uses `authz.py::resolve_route_permissions()` as the single provider integration point for both `AuthMiddleware` and decorator-only authentication. When enabled, it evaluates the six registered `threads:*` / `runs:*` permissions as `resource="route"` requests whose targets are the full `resource:action` strings. Decisions use the async provider API and are cached for the request in `AuthContext`; decorators do not call the provider again. Provider resolution or decision errors follow `authorization.fail_closed`, scoped per permission for decision errors. When authorization is disabled, the legacy complete permission set is returned without resolving a provider. Existing `owner_check` enforcement and `require_admin_user()` management gates remain independent and unchanged. Tests: `tests/test_authorization_route_permissions.py`, `tests/test_auth.py`, and `tests/test_auth_middleware.py`.
Model authorization uses `authz.py::resolve_model_authorization()` (same cached-provider, internal-role, and principal-building path as route authorization) as the Gateway integration point for the `models` router: `list_models` filters names through `filter_resources(principal, "model", ...)`, and `get_model` enforces `authorize(resource="model", action="use")` with a deny surfacing as 403; provider errors follow `authorization.fail_closed` (fail-open returns the unfiltered list / proceeds). At runtime, `lead_agent/agent.py::_authorize_model_name` — called from `_make_lead_agent` and from `DeerFlowClient._ensure_agent` — applies the same `model:use` check to the resolved model name. On deny it scans the `filter_resources`-visible names (excluding the denied model), re-verifying each candidate with `authorize("model", "use")` before falling back, because a custom provider may allow `list` while denying `use`; no usable fallback raises under `fail_closed` and keeps the original model under fail-open. The built-in RBAC provider maps this to the per-role `models` policy key. Tests: `tests/test_models_authorization.py`.
Sandbox authorization (`sandbox:execute`) gates every sandbox acquisition before `provider.acquire` — including this middleware's eager path (`before_agent` / `abefore_agent` skip acquisition on deny instead of raising, deferring to the lazy per-tool gate). See the [sandbox module guide](../../sandbox/AGENTS.md) authorization-gate paragraph and `tests/test_sandbox_authorization.py`.
Before changing a later authorization phase, read the [authorization RFC](../../../../../../docs/plans/2026-07-10-pluggable-authorization-rfc.md) and its [implementation notes](../../../../../../docs/plans/2026-07-10-pluggable-authorization-implementation-notes.md). The notes are the cumulative handoff record for merged PR behavior, reviewer feedback, trust-boundary decisions, deferred scope, and required regression coverage.
**Lead-only middlewares** (`build_middlewares`, appended after the base):

View File

@ -6,6 +6,7 @@ from deerflow.authz.principal import build_principal_from_context, normalize_aut
from deerflow.authz.provider import AuthorizationProvider, AuthzDecision, AuthzReason, AuthzRequest, Principal
from deerflow.authz.rbac import RbacAuthorizationProvider
from deerflow.authz.runtime import resolve_authorization_provider
from deerflow.authz.sandbox_authz import authorize_sandbox_execution
from deerflow.authz.tool_filter import apply_tool_authorization
__all__ = [
@ -17,6 +18,7 @@ __all__ = [
"Principal",
"RbacAuthorizationProvider",
"apply_tool_authorization",
"authorize_sandbox_execution",
"build_principal_from_context",
"filter_tools_by_authorization",
"normalize_authz_attributes",

View File

@ -0,0 +1,101 @@
"""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

View File

@ -2,6 +2,7 @@
**Interface**: Abstract `Sandbox` with `execute_command(command, env=None)`, `read_file`, `write_file`, `list_dir`, `glob`, and `grep`. `grep` accepts either one text file or a directory tree. The optional `env` injects per-call environment variables (request-scoped secrets — see Request-Scoped Secrets below); `LocalSandbox` merges it via `subprocess.run(env=...)` and `AioSandbox` routes env-bearing commands through the `bash.exec(env=...)` API on a fresh session.
**Provider Pattern**: `SandboxProvider` with `acquire`, `acquire_async`, `get`, `release` lifecycle. Async agent/tool paths call async sandbox lifecycle hooks so Docker sandbox creation, discovery, cross-process locking, readiness polling, and release stay off the event loop.
**Authorization gate** (`sandbox:execute`, RFC #4063 Phase 3): every sandbox acquisition passes through `authorize_sandbox_execution` (`deerflow/authz/sandbox_authz.py`) - a binary `authorize(principal, "sandbox", "execute", target="*")` check before `provider.acquire`. The gate lives at the single acquisition entry point (`ensure_sandbox_initialized` / `_acquire_sandbox_async` in `tools.py`, and `SandboxMiddleware.before_agent` / `abefore_agent`), so it cannot be bypassed regardless of which sandbox-dependent tool triggers it; the reuse path (sandbox already in state) skips the re-check. Deny raises `SandboxAuthorizationError` (`sandbox/exceptions.py`), which propagates out of tool execution as a friendly error `ToolMessage` ("sandbox execution is not permitted for your role") - the eager path catches it and skips acquisition instead, deferring the deny to the first sandbox-touching tool call so both paths share the same semantics. Provider errors (both `authorize()` and provider resolution) follow `authorization.fail_closed` / `fail_open`; no readable `config.yaml` or `authorization.enabled: false` makes the gate a no-op (`safe_app_config` tolerates missing config). Gateway auxiliary sync paths (uploads/artifacts routers) call `try_acquire_sandbox_for_request` (`app/gateway/authz.py`), which gates via `authorize_sandbox_for_request` and skips the sync on deny - the upload/artifact edit itself still succeeds. Tests: `tests/test_sandbox_authorization.py`.
**Environment policy** (`sandbox/env_policy.py`): `execute_command` no longer inherits the full `os.environ`. `build_sandbox_env()` scrubs secret-looking names (`*KEY*`/`*SECRET*`/`*TOKEN*`/`*PASS*`/`*CREDENTIAL*`) from the inherited environment before layering injected request secrets on top, so platform credentials (e.g. `OPENAI_API_KEY`) never leak into skill subprocesses. Benign vars (`PATH`, `HOME`, `LANG`, `VIRTUAL_ENV`, ...) are preserved.
**Implementations**:
- `LocalSandboxProvider` - Local filesystem execution. `acquire(thread_id)` returns a per-thread `LocalSandbox` (id `local:{thread_id}`) whose `path_mappings` resolve `/mnt/user-data/{workspace,uploads,outputs}` and `/mnt/acp-workspace` to that thread's host directories, so the public `Sandbox` API honours the `/mnt/user-data` contract uniformly with AIO. `acquire()` / `acquire(None)` keeps the legacy generic singleton (id `local`) for callers without a thread context. Per-thread sandboxes are held in an LRU cache (default 256 entries) guarded by a `threading.Lock`. Public, custom, legacy, and managed integration skill mappings point at stable enabled-only projection roots rather than raw skill directories.

View File

@ -111,3 +111,24 @@ class SandboxCapacityExceededError(SandboxError):
self.replicas = replicas
self.retry_after_seconds = retry_after_seconds
self.reason = reason
class SandboxAuthorizationError(SandboxError):
"""Raised when the caller's role is denied sandbox execution.
Phase 3 pluggable authorization: ``authorize("sandbox", "execute")`` is
checked before sandbox acquisition. On deny this error propagates up
through the tool's execution so 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).
"""
def __init__(
self,
message: str = "Sandbox execution is not permitted for your role",
*,
role: str | None = None,
) -> None:
details = {"role": role} if role else None
super().__init__(message, details)
self.role = role

View File

@ -12,8 +12,10 @@ from langgraph.runtime import Runtime
from langgraph.types import Command
from deerflow.agents.thread_state import SandboxStateField, ThreadDataState
from deerflow.authz.sandbox_authz import authorize_sandbox_execution, safe_app_config
from deerflow.runtime.user_context import resolve_runtime_user_id
from deerflow.sandbox import get_sandbox_provider
from deerflow.sandbox.exceptions import SandboxAuthorizationError
from deerflow.sandbox.overwrite import unwrap_sandbox
logger = logging.getLogger(__name__)
@ -76,6 +78,21 @@ class SandboxMiddleware(AgentMiddleware[SandboxMiddlewareState]):
thread_id = (runtime.context or {}).get("thread_id")
if thread_id is None:
return super().before_agent(state, runtime)
# Phase 3: enforce sandbox:execute authorization before acquiring
# (eager path). On deny, skip the eager acquisition instead of
# raising: an exception here is outside any tool call, so it would
# surface as a run-level graph error rather than the RFC §9
# friendly ToolMessage. Skipping defers to the lazy gate inside
# ``ensure_sandbox_initialized``, which denies per-tool with the
# friendly message on the first sandbox-touching tool call.
try:
authorize_sandbox_execution(
context=runtime.context or {},
app_config=safe_app_config(),
)
except SandboxAuthorizationError:
logger.info("Sandbox execution denied for this role; skipping eager sandbox acquisition (thread_id=%s)", thread_id)
return None
sandbox_id = self._acquire_sandbox(thread_id, user_id=resolve_runtime_user_id(runtime))
logger.info(f"Assigned sandbox {sandbox_id} to thread {thread_id}")
return {"sandbox": {"sandbox_id": sandbox_id}}
@ -93,6 +110,19 @@ class SandboxMiddleware(AgentMiddleware[SandboxMiddlewareState]):
thread_id = (runtime.context or {}).get("thread_id")
if thread_id is None:
return await super().abefore_agent(state, runtime)
# Phase 3: enforce sandbox:execute authorization before acquiring
# (eager path, async counterpart of the gate in before_agent). On
# deny, skip the eager acquisition — the lazy gate inside
# ``ensure_sandbox_initialized`` denies per-tool with the RFC §9
# friendly message on the first sandbox-touching tool call.
try:
authorize_sandbox_execution(
context=runtime.context or {},
app_config=safe_app_config(),
)
except SandboxAuthorizationError:
logger.info("Sandbox execution denied for this role; skipping eager sandbox acquisition (thread_id=%s)", thread_id)
return None
sandbox_id = await self._acquire_sandbox_async(thread_id, user_id=resolve_runtime_user_id(runtime))
logger.info(f"Assigned sandbox {sandbox_id} to thread {thread_id}")
return {"sandbox": {"sandbox_id": sandbox_id}}

View File

@ -12,6 +12,7 @@ from pathlib import Path
from langchain.tools import tool
from deerflow.agents.thread_state import ThreadDataState
from deerflow.authz.sandbox_authz import authorize_sandbox_execution, safe_app_config
from deerflow.config import get_app_config
from deerflow.config.paths import VIRTUAL_PATH_PREFIX
from deerflow.constants import DEFAULT_SKILLS_CONTAINER_PATH
@ -1416,6 +1417,15 @@ def ensure_sandbox_initialized(runtime: Runtime | None = None) -> Sandbox:
if thread_id is None:
raise SandboxRuntimeError("Thread ID not available in runtime context")
# Phase 3: enforce sandbox:execute authorization before acquiring. On deny
# a SandboxAuthorizationError propagates up through the tool so the agent's
# tool-error handling returns a friendly message (RFC §9). Skipped on the
# reuse path above (already authorized when first acquired).
authorize_sandbox_execution(
context=runtime.context or {},
app_config=safe_app_config(),
)
provider = get_sandbox_provider()
sandbox_id = provider.acquire(thread_id, user_id=resolve_runtime_user_id(runtime))
@ -1463,6 +1473,13 @@ async def ensure_sandbox_initialized_async(runtime: Runtime | None = None) -> Sa
if thread_id is None:
raise SandboxRuntimeError("Thread ID not available in runtime context")
# Phase 3: enforce sandbox:execute authorization before acquiring (async
# counterpart of the sync gate in ``ensure_sandbox_initialized``).
authorize_sandbox_execution(
context=runtime.context or {},
app_config=safe_app_config(),
)
provider = get_sandbox_provider()
sandbox_id = await provider.acquire_async(thread_id, user_id=resolve_runtime_user_id(runtime))

View File

@ -0,0 +1,696 @@
"""Phase 3 sandbox-level authorization tests.
Covers the ``authorize("sandbox", "execute")`` gate at the sandbox-acquisition
entry point. When denied, a :class:`SandboxAuthorizationError` propagates up
through the tool so the agent's tool-error handling returns a friendly message
(RFC §9), rather than crashing the run.
The gate lives in :func:`authorize_sandbox_execution` (``authz/sandbox_authz.py``)
and is called from:
- ``ensure_sandbox_initialized`` / ``ensure_sandbox_initialized_async`` (lazy path)
- ``SandboxMiddleware.before_agent`` / ``abefore_agent`` (eager path)
"""
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
import pytest
from deerflow.authz.provider import AuthzDecision, AuthzReason
from deerflow.authz.rbac import RbacAuthorizationProvider
from deerflow.authz.sandbox_authz import authorize_sandbox_execution
from deerflow.config.app_config import AppConfig
from deerflow.config.authorization_config import AuthorizationConfig
from deerflow.config.model_config import ModelConfig
from deerflow.config.sandbox_config import SandboxConfig
from deerflow.sandbox.exceptions import SandboxAuthorizationError
# ── Helpers ────────────────────────────────────────────────────────────
def _make_app_config() -> AppConfig:
"""Build a minimal AppConfig for authorization tests."""
return AppConfig(
models=[ModelConfig(name="gpt-4", model="gpt-4", use="langchain_openai:ChatOpenAI")],
sandbox=SandboxConfig(use="deerflow.sandbox.local:LocalSandboxProvider"),
authorization=AuthorizationConfig(),
)
def _context(**overrides):
values = {
"user_id": "user-123",
"user_role": "user",
"oauth_provider": "github",
"oauth_id": "oauth-456",
"is_internal": False,
}
values.update(overrides)
return values
def _enable_authz(app_config: AppConfig, *, fail_closed: bool = True, default_role: str = "user") -> None:
app_config.authorization = AuthorizationConfig(
enabled=True,
fail_closed=fail_closed,
default_role=default_role,
)
# ── authorize_sandbox_execution unit tests ─────────────────────────────
def test_authorize_sandbox_disabled_is_noop():
"""When authorization is disabled, no check is performed (allow)."""
app_config = _make_app_config()
# AuthorizationConfig() defaults to enabled=False.
authorize_sandbox_execution(context=_context(), app_config=app_config) # must not raise
def test_authorize_sandbox_rbac_allow(monkeypatch):
"""Role with sandbox allow → permitted."""
provider = RbacAuthorizationProvider(roles={"user": {"sandbox": {"allow": "*"}}})
app_config = _make_app_config()
_enable_authz(app_config)
monkeypatch.setattr(
"deerflow.authz.sandbox_authz.resolve_authorization_provider",
lambda config: provider,
)
authorize_sandbox_execution(context=_context(), app_config=app_config) # must not raise
def test_authorize_sandbox_rbac_deny(monkeypatch):
"""Role with sandbox allow: [] → SandboxAuthorizationError."""
provider = RbacAuthorizationProvider(roles={"user": {"sandbox": {"allow": []}}})
app_config = _make_app_config()
_enable_authz(app_config)
monkeypatch.setattr(
"deerflow.authz.sandbox_authz.resolve_authorization_provider",
lambda config: provider,
)
with pytest.raises(SandboxAuthorizationError, match="not permitted for your role"):
authorize_sandbox_execution(context=_context(), app_config=app_config)
def test_authorize_sandbox_rbac_deny_via_bool(monkeypatch):
"""Role with sandbox allow: false → denied."""
provider = RbacAuthorizationProvider(roles={"user": {"sandbox": {"allow": False}}})
app_config = _make_app_config()
_enable_authz(app_config)
monkeypatch.setattr(
"deerflow.authz.sandbox_authz.resolve_authorization_provider",
lambda config: provider,
)
with pytest.raises(SandboxAuthorizationError):
authorize_sandbox_execution(context=_context(), app_config=app_config)
def test_authorize_sandbox_no_policy_is_unrestricted(monkeypatch):
"""Role with no sandbox policy → unrestricted (allow)."""
provider = RbacAuthorizationProvider(roles={"user": {"tools": {"allow": "*"}}})
app_config = _make_app_config()
_enable_authz(app_config)
monkeypatch.setattr(
"deerflow.authz.sandbox_authz.resolve_authorization_provider",
lambda config: provider,
)
authorize_sandbox_execution(context=_context(), app_config=app_config) # must not raise
def test_authorize_sandbox_provider_error_fail_closed(monkeypatch):
"""Provider error + fail_closed → SandboxAuthorizationError."""
class _ErrorProvider:
name = "error"
def authorize(self, request):
raise RuntimeError("boom")
async def aauthorize(self, request):
raise RuntimeError("boom")
def filter_resources(self, principal, resource_type, candidates):
raise RuntimeError("boom")
app_config = _make_app_config()
_enable_authz(app_config, fail_closed=True)
monkeypatch.setattr(
"deerflow.authz.sandbox_authz.resolve_authorization_provider",
lambda config: _ErrorProvider(),
)
with pytest.raises(SandboxAuthorizationError):
authorize_sandbox_execution(context=_context(), app_config=app_config)
def test_authorize_sandbox_provider_error_fail_open(monkeypatch):
"""Provider error + fail_open → allow (no raise)."""
class _ErrorProvider:
name = "error"
def authorize(self, request):
raise RuntimeError("boom")
async def aauthorize(self, request):
raise RuntimeError("boom")
def filter_resources(self, principal, resource_type, candidates):
raise RuntimeError("boom")
app_config = _make_app_config()
_enable_authz(app_config, fail_closed=False)
monkeypatch.setattr(
"deerflow.authz.sandbox_authz.resolve_authorization_provider",
lambda config: _ErrorProvider(),
)
authorize_sandbox_execution(context=_context(), app_config=app_config) # must not raise
def test_authorize_sandbox_internal_caller_uses_default_role(monkeypatch):
"""Internal callers (system_role=None) fall under default_role."""
provider = RbacAuthorizationProvider(
roles={
"user": {"sandbox": {"allow": []}},
"admin": {"sandbox": {"allow": "*"}},
}
)
app_config = _make_app_config()
_enable_authz(app_config, default_role="admin")
monkeypatch.setattr(
"deerflow.authz.sandbox_authz.resolve_authorization_provider",
lambda config: provider,
)
# Internal caller with system_role=None → default_role="admin" → allowed.
authorize_sandbox_execution(
context=_context(user_role=None, is_internal=True),
app_config=app_config,
)
def test_authorize_sandbox_denied_error_carries_role(monkeypatch):
"""The SandboxAuthorizationError carries the denied role for diagnostics."""
provider = RbacAuthorizationProvider(roles={"user": {"sandbox": {"allow": []}}})
app_config = _make_app_config()
_enable_authz(app_config)
monkeypatch.setattr(
"deerflow.authz.sandbox_authz.resolve_authorization_provider",
lambda config: provider,
)
with pytest.raises(SandboxAuthorizationError) as exc_info:
authorize_sandbox_execution(context=_context(), app_config=app_config)
assert exc_info.value.role == "user"
# ── Integration: ensure_sandbox_initialized denies on authz reject ──────
def test_ensure_sandbox_initialized_denies_on_authz_reject(monkeypatch):
"""ensure_sandbox_initialized raises SandboxAuthorizationError on deny.
Real-path test: the genuine gate runs inside ensure_sandbox_initialized
before provider.acquire is touched, so a denied role never acquires a
sandbox (and the error propagates as a friendly ToolMessage upstream).
"""
from deerflow.sandbox import tools as sandbox_tools
provider = RbacAuthorizationProvider(roles={"user": {"sandbox": {"allow": []}}})
app_config = _make_app_config()
_enable_authz(app_config)
monkeypatch.setattr(
"deerflow.authz.sandbox_authz.resolve_authorization_provider",
lambda config: provider,
)
monkeypatch.setattr("deerflow.config.get_app_config", lambda: app_config)
runtime = SimpleNamespace(
state={"sandbox": None},
context={"thread_id": "t1", "user_id": "u1", "user_role": "user"},
config=None,
)
# A mock provider whose acquire must never be called.
sandbox_provider = MagicMock()
monkeypatch.setattr(sandbox_tools, "get_sandbox_provider", lambda: sandbox_provider)
with pytest.raises(SandboxAuthorizationError):
sandbox_tools.ensure_sandbox_initialized(runtime)
sandbox_provider.acquire.assert_not_called()
def test_ensure_sandbox_initialized_allows_on_authz_permit(monkeypatch):
"""ensure_sandbox_initialized proceeds to acquire on allow."""
from deerflow.sandbox import tools as sandbox_tools
from deerflow.sandbox.sandbox_provider import (
reset_sandbox_provider,
set_sandbox_provider,
)
provider = RbacAuthorizationProvider(roles={"user": {"sandbox": {"allow": "*"}}})
app_config = _make_app_config()
_enable_authz(app_config)
monkeypatch.setattr(
"deerflow.authz.sandbox_authz.resolve_authorization_provider",
lambda config: provider,
)
monkeypatch.setattr("deerflow.config.get_app_config", lambda: app_config)
acquired = {"called": False}
class _StubProvider:
def acquire(self, thread_id=None, *, user_id=None):
acquired["called"] = True
return "sbx-1"
def get(self, sandbox_id):
return MagicMock() # sandbox instance shape doesn't matter for this test
def release(self, sandbox_id):
pass
def reset(self):
pass
set_sandbox_provider(_StubProvider())
try:
runtime = SimpleNamespace(
state={"sandbox": None},
context={"thread_id": "t1", "user_id": "u1", "user_role": "user"},
config=None,
)
sandbox = sandbox_tools.ensure_sandbox_initialized(runtime)
assert sandbox is not None
assert acquired["called"] is True
finally:
reset_sandbox_provider()
# ── Custom provider: authorize called with correct args ────────────────
def test_authorize_sandbox_calls_provider_with_correct_request(monkeypatch):
"""The provider receives resource='sandbox', action='execute', target='*'."""
class _RecordingProvider:
name = "recording"
def __init__(self):
self.request = None
def authorize(self, request):
self.request = request
return AuthzDecision(allow=True, reasons=[AuthzReason(code="authz.allowed")])
async def aauthorize(self, request):
return self.authorize(request)
def filter_resources(self, principal, resource_type, candidates):
return list(candidates)
provider = _RecordingProvider()
app_config = _make_app_config()
_enable_authz(app_config)
monkeypatch.setattr(
"deerflow.authz.sandbox_authz.resolve_authorization_provider",
lambda config: provider,
)
authorize_sandbox_execution(context=_context(), app_config=app_config)
assert provider.request.resource == "sandbox"
assert provider.request.action == "execute"
assert provider.request.target == "*"
# ── Gateway auxiliary paths: uploads / artifacts sync ──────────────────
# Regression for the multi-path coverage self-check (pr-review checkpoint 14):
# the uploads and artifacts routers acquire the thread sandbox directly (not
# via ensure_sandbox_initialized) to sync files. A role denied sandbox:execute
# must not trigger sandbox allocation there; the primary operation (upload /
# artifact edit) still succeeds with the sandbox sync skipped.
def _make_upload_app(monkeypatch, provider, *, fail_closed: bool = True):
"""Build a FastAPI app with the uploads router and authz enabled."""
from _router_auth_helpers import make_authed_test_app
from fastapi.testclient import TestClient
from app.gateway.routers import uploads as uploads_router
app_config = _make_app_config()
app_config.authorization = AuthorizationConfig(enabled=True, fail_closed=fail_closed, default_role="user")
monkeypatch.setattr(
"app.gateway.authz._get_route_authorization_config",
lambda: app_config.authorization,
)
monkeypatch.setattr(
"deerflow.authz.sandbox_authz.resolve_authorization_provider",
lambda config: provider,
)
app = make_authed_test_app()
app.include_router(uploads_router.router)
app.dependency_overrides[uploads_router.get_config] = lambda: app_config
return TestClient(app)
def test_upload_sandbox_sync_skipped_when_denied(monkeypatch, tmp_path):
"""Denied role: upload succeeds, sandbox.acquire is never called."""
from unittest.mock import AsyncMock, MagicMock
provider = RbacAuthorizationProvider(roles={"user": {"sandbox": {"allow": []}}})
client = _make_upload_app(monkeypatch, provider)
_isolated_uploads_dir(monkeypatch, tmp_path)
sandbox_provider = MagicMock()
sandbox_provider.uses_thread_data_mounts = False
sandbox_provider.acquire_async = AsyncMock(side_effect=AssertionError("must not acquire"))
monkeypatch.setattr("app.gateway.routers.uploads.get_sandbox_provider", lambda: sandbox_provider)
monkeypatch.setattr(
"app.gateway.deps.get_optional_user_from_request",
AsyncMock(return_value=_request_user()),
)
resp = client.post("/api/threads/upload-test/uploads", files={"files": ("a.txt", b"hello")})
assert resp.status_code == 200, resp.text
sandbox_provider.acquire_async.assert_not_called()
def test_upload_sandbox_sync_proceeds_when_allowed(monkeypatch, tmp_path):
"""Allowed role: sandbox.acquire_async is called as before."""
from unittest.mock import AsyncMock, MagicMock
provider = RbacAuthorizationProvider(roles={"user": {"sandbox": {"allow": "*"}}})
client = _make_upload_app(monkeypatch, provider)
_isolated_uploads_dir(monkeypatch, tmp_path)
sandbox_provider = MagicMock()
sandbox_provider.uses_thread_data_mounts = False
sandbox_provider.acquire_async = AsyncMock(return_value="sbx-1")
sandbox_provider.get = MagicMock(return_value=MagicMock())
monkeypatch.setattr("app.gateway.routers.uploads.get_sandbox_provider", lambda: sandbox_provider)
monkeypatch.setattr(
"app.gateway.deps.get_optional_user_from_request",
AsyncMock(return_value=_request_user()),
)
resp = client.post("/api/threads/upload-test/uploads", files={"files": ("a.txt", b"hello")})
assert resp.status_code == 200, resp.text
sandbox_provider.acquire_async.assert_called_once()
def _isolated_uploads_dir(monkeypatch, tmp_path):
"""Redirect thread uploads storage to tmp_path (test-isolation).
Without this the upload route writes into the real global uploads root,
polluting other tests that assert on uploads directory state.
"""
uploads_dir = tmp_path / "uploads"
uploads_dir.mkdir(parents=True)
monkeypatch.setattr("app.gateway.routers.uploads.get_uploads_dir", lambda thread_id, user_id=None: uploads_dir)
monkeypatch.setattr("app.gateway.routers.uploads.ensure_uploads_dir", lambda thread_id, user_id=None: uploads_dir)
return uploads_dir
def _request_user():
from types import SimpleNamespace
return SimpleNamespace(id="user-123", system_role="user", oauth_provider=None, oauth_id=None)
def test_authorize_sandbox_mock_app_config_is_noop():
"""A SimpleNamespace/duck-typed app_config without `authorization` is a no-op.
Mirrors the mock-safe guard in filter_available_skills_by_authorization
(skill_filter.py) regression for the consistency finding willem-bd raised
on apply_skill_authorization in #4541: the sibling guards use getattr so
test doubles that don't carry a real AuthorizationConfig don't blow up.
"""
authorize_sandbox_execution(context=_context(), app_config=SimpleNamespace(sandbox=None)) # must not raise
# ── willem-bd review round 1 regressions ───────────────────────────────
def test_authorize_sandbox_resolution_error_fail_open_allows(monkeypatch):
"""Provider *resolution* error + fail_open → allow (not an inverted deny).
Regression for willem-bd's finding: resolve_authorization_provider ran
outside the try, so a misconfigured provider (ValueError) propagated as a
raw exception and effectively denied under fail_open inverted semantics.
"""
def _boom(_config):
raise ValueError("bad provider class path")
app_config = _make_app_config()
_enable_authz(app_config, fail_closed=False)
monkeypatch.setattr(
"deerflow.authz.sandbox_authz.resolve_authorization_provider",
_boom,
)
authorize_sandbox_execution(context=_context(), app_config=app_config) # must not raise
def test_authorize_sandbox_resolution_error_fail_closed_denies(monkeypatch):
"""Provider *resolution* error + fail_closed → SandboxAuthorizationError."""
def _boom(_config):
raise ValueError("bad provider class path")
app_config = _make_app_config()
_enable_authz(app_config, fail_closed=True)
monkeypatch.setattr(
"deerflow.authz.sandbox_authz.resolve_authorization_provider",
_boom,
)
with pytest.raises(SandboxAuthorizationError):
authorize_sandbox_execution(context=_context(), app_config=app_config)
def test_eager_before_agent_deny_skips_acquisition(monkeypatch):
"""Eager-path deny skips acquisition instead of raising a run-level error.
Regression for willem-bd's finding: an exception from before_agent is
outside any tool call, so it would surface as a graph error rather than
the RFC §9 friendly ToolMessage. On deny the middleware now returns None
(no sandbox assigned); the lazy gate denies per-tool later.
"""
from types import SimpleNamespace as _NS
from deerflow.sandbox.middleware import SandboxMiddleware
provider = RbacAuthorizationProvider(roles={"user": {"sandbox": {"allow": []}}})
app_config = _make_app_config()
_enable_authz(app_config)
monkeypatch.setattr(
"deerflow.authz.sandbox_authz.resolve_authorization_provider",
lambda config: provider,
)
monkeypatch.setattr("deerflow.config.get_app_config", lambda: app_config)
acquired = {"called": False}
def _no_acquire(_thread_id, *, user_id=None):
acquired["called"] = True
return "sbx-1"
middleware = SandboxMiddleware(lazy_init=False)
monkeypatch.setattr(middleware, "_acquire_sandbox", _no_acquire)
runtime = _NS(context={"thread_id": "t1", "user_id": "u1", "user_role": "user"})
result = middleware.before_agent({"sandbox": None}, runtime)
assert result is None # no sandbox assigned
assert acquired["called"] is False # acquire never touched
def test_artifact_sandbox_sync_skipped_when_denied(monkeypatch, tmp_path):
"""Artifacts router deny path: host-side update completes, acquire skipped.
Regression for willem-bd's round-2 nit — the uploads router's deny path
had coverage but this copy didn't; a reverted gate here would pass the
suite unnoticed.
"""
from unittest.mock import AsyncMock, MagicMock
from app.gateway.routers import artifacts as artifacts_router
provider = RbacAuthorizationProvider(roles={"user": {"sandbox": {"allow": []}}})
app_config = _make_app_config()
app_config.authorization = AuthorizationConfig(enabled=True, fail_closed=True, default_role="user")
monkeypatch.setattr(
"app.gateway.authz._get_route_authorization_config",
lambda: app_config.authorization,
)
monkeypatch.setattr(
"deerflow.authz.sandbox_authz.resolve_authorization_provider",
lambda config: provider,
)
sandbox_provider = MagicMock()
sandbox_provider.uses_thread_data_mounts = False
sandbox_provider.acquire_async = AsyncMock(side_effect=AssertionError("must not acquire"))
monkeypatch.setattr(artifacts_router, "get_sandbox_provider", lambda: sandbox_provider)
monkeypatch.setattr(artifacts_router, "resolve_thread_virtual_path", lambda _t, _p, user_id=None: tmp_path / "note.txt")
from contextlib import asynccontextmanager
@asynccontextmanager
async def _allow_write(*_args, **_kwargs):
yield
monkeypatch.setattr(artifacts_router, "reserve_artifact_write", _allow_write)
monkeypatch.setattr(
"app.gateway.deps.get_optional_user_from_request",
AsyncMock(return_value=_request_user()),
)
monkeypatch.setattr("deerflow.config.get_app_config", lambda: app_config)
from _router_auth_helpers import call_unwrapped
artifact_path = tmp_path / "note.txt"
artifact_path.write_bytes(b"before")
request = type("R", (), {})() # simple object; only passed through to mocked helpers
import asyncio
import hashlib
from app.gateway.routers.artifacts import ArtifactUpdateRequest
sha = hashlib.sha256(b"before").hexdigest()
asyncio.run(
call_unwrapped(
artifacts_router.update_artifact,
"t-denied",
"mnt/user-data/outputs/note.txt",
ArtifactUpdateRequest(content="after", expected_sha256=sha),
request,
)
)
sandbox_provider.acquire_async.assert_not_called()
assert artifact_path.read_bytes() == b"after"
def test_authorize_sandbox_no_config_file_is_noop(monkeypatch):
"""No readable config.yaml (CI environments) → the gate is a no-op.
Regression for the CI failure: get_app_config raises FileNotFoundError in
config-less environments, which previously propagated out of the gate and
broke ensure_sandbox_initialized's direct-call tests.
"""
from deerflow.authz import sandbox_authz as mod
def _no_config():
raise FileNotFoundError("config.yaml file not found in the project root")
monkeypatch.setattr("deerflow.config.get_app_config", _no_config)
# safe_app_config imports get_app_config lazily from deerflow.config.
assert mod.safe_app_config() is None
# And the gate itself tolerates app_config=None (same as disabled).
authorize_sandbox_execution(context=_context(), app_config=None) # must not raise
def test_upload_gate_tolerates_request_none(monkeypatch, tmp_path):
"""Direct-call tests pass request=None; the uploads gate must not crash.
Regression for the blocking-io CI failure: get_optional_user_from_request
dereferences request.cookies on the no-state-user path, so request=None
raised AttributeError before the None guard.
"""
import asyncio
from unittest.mock import AsyncMock, MagicMock
from app.gateway.routers import uploads as uploads_router
_isolated_uploads_dir(monkeypatch, tmp_path)
sandbox_provider = MagicMock()
sandbox_provider.uses_thread_data_mounts = False
sandbox_provider.acquire_async = AsyncMock(return_value="sbx-1")
sandbox_provider.get = MagicMock(return_value=MagicMock())
monkeypatch.setattr(uploads_router, "get_sandbox_provider", lambda: sandbox_provider)
# If the request=None guard were missing, this lookup would dereference
# request.cookies and raise AttributeError; with the guard it is never called.
monkeypatch.setattr(
"app.gateway.deps.get_optional_user_from_request",
AsyncMock(side_effect=AttributeError("'NoneType' object has no attribute 'cookies'")),
)
from io import BytesIO
from _router_auth_helpers import call_unwrapped
from fastapi import UploadFile
config = SimpleNamespace(uploads={"max_files": 5, "max_file_size": 10**6})
file = UploadFile(filename="a.txt", file=BytesIO(b"hello"))
# request=None — the None guard must skip the user lookup entirely.
result = asyncio.run(call_unwrapped(uploads_router.upload_files, "t-none", request=None, files=[file], config=config))
assert result.success is True
sandbox_provider.acquire_async.assert_called_once()
def test_ensure_sandbox_initialized_async_denies_on_authz_reject(monkeypatch):
"""Async acquisition path deny: acquire_async never called.
Regression for willem-bd's round-4 finding — the async gate copy is
verbatim-sync, so without this test deleting it would leave the suite green.
"""
from deerflow.sandbox import tools as sandbox_tools
provider = RbacAuthorizationProvider(roles={"user": {"sandbox": {"allow": []}}})
app_config = _make_app_config()
_enable_authz(app_config)
monkeypatch.setattr(
"deerflow.authz.sandbox_authz.resolve_authorization_provider",
lambda config: provider,
)
monkeypatch.setattr("deerflow.config.get_app_config", lambda: app_config)
sandbox_provider = MagicMock()
sandbox_provider.acquire_async = AsyncMock(side_effect=AssertionError("must not acquire"))
monkeypatch.setattr(sandbox_tools, "get_sandbox_provider", lambda: sandbox_provider)
runtime = SimpleNamespace(
state={"sandbox": None},
context={"thread_id": "t1", "user_id": "u1", "user_role": "user"},
config=None,
)
import asyncio
with pytest.raises(SandboxAuthorizationError):
asyncio.run(sandbox_tools.ensure_sandbox_initialized_async(runtime))
sandbox_provider.acquire_async.assert_not_called()
def test_abefore_agent_deny_skips_acquisition(monkeypatch):
"""Async eager path deny: acquisition skipped, no run-level error.
Async counterpart of test_eager_before_agent_deny_skips_acquisition.
"""
from deerflow.sandbox.middleware import SandboxMiddleware
provider = RbacAuthorizationProvider(roles={"user": {"sandbox": {"allow": []}}})
app_config = _make_app_config()
_enable_authz(app_config)
monkeypatch.setattr(
"deerflow.authz.sandbox_authz.resolve_authorization_provider",
lambda config: provider,
)
monkeypatch.setattr("deerflow.config.get_app_config", lambda: app_config)
acquired = {"called": False}
async def _no_acquire(_thread_id, *, user_id=None):
acquired["called"] = True
return "sbx-1"
middleware = SandboxMiddleware(lazy_init=False)
monkeypatch.setattr(middleware, "_acquire_sandbox_async", _no_acquire)
runtime = SimpleNamespace(context={"thread_id": "t1", "user_id": "u1", "user_role": "user"})
import asyncio
result = asyncio.run(middleware.abefore_agent({"sandbox": None}, runtime))
assert result is None # no sandbox assigned
assert acquired["called"] is False # acquire_async never touched

View File

@ -2484,14 +2484,21 @@ run_ownership:
# tools: {allow: "*"}
# routes: {allow: "*"}
# models: {allow: "*"}
# sandbox: {allow: "*"}
# user:
# tools: {allow: "*", deny: ["update_agent"]}
# routes: {allow: "*"}
# models: {allow: "*"}
# sandbox: {allow: "*"}
# guest:
# tools: {allow: ["web_search", "read_file"]}
# # web-only role: sandbox-dependent tools (read_file, bash, glob,
# # grep, write_file, ...) are omitted — with sandbox:execute denied
# # they could only ever return the deny error, so allow just the
# # non-sandbox tools.
# tools: {allow: ["web_search"]}
# routes: {allow: ["threads:read", "runs:read"]}
# models: {allow: ["gpt-4o-mini"]}
# sandbox: {allow: false} # deny sandbox execution
authorization:
enabled: false

View File

@ -349,6 +349,60 @@ Phase 1 最低验证要求:
- **延期:** Skills、Sandbox 权限Phase 3 后续 PR前端 effective-permissions 展示;
management route 的 provider 迁移。
### 2026-08-02 — Phase 3 / Sandbox authorization (execute)
- **背景:** Phase 3 Models 合并后sandbox 仍只由 config presence
`feat.sandbox is not False`)控制,任何已认证用户都能获得完整 sandbox 执行
bash、文件 I/O。RFC §9 要求 `SandboxMiddleware gates on
authorize("sandbox","execute")`deny 时返回友好错误消息而非崩溃。
- **决策gate 位置):** 与 Models/Skills 不同sandbox 不是具名资源,而是一个
**执行环境** —— 多个工具bash、read_file、write_file、glob、grep 等)都依赖它,
全部经过 `ensure_sandbox_initialized` / `ensure_sandbox_initialized_async`
sandbox/tools.py。选择在 **sandbox 获取的唯一入口** gate而非在 middleware 里
维护"sandbox 工具名集合"Shotgun Surgery每加一个 sandbox 工具都要改 middleware
具体在两个 acquire 点之前调用共享的 `authorize_sandbox_execution` helper
- lazy 路径:`ensure_sandbox_initialized` + async覆盖所有 sandbox 工具)
- eager 路径:`SandboxMiddleware.before_agent` / `abefore_agent``lazy_init=False`
- **决策(授权语义):** `authorize("sandbox", "execute", target="*")` —— **二元判断**
"这个角色能否用 sandbox"target 用 `"*"` 表示"sandbox 资源整体"。RBAC
`allow: ["*"]` / `allow: true` 允许,`allow: []` / `allow: false` 拒绝。
- **决策deny 行为):** 新增 `SandboxAuthorizationError(SandboxError)`deny 时抛出,
沿工具执行链传播 → agent 的 tool-error 处理转成友好 `ToolMessage`
"sandbox execution is not permitted for your role"),符合 RFC §9 的"not a crash"。
- **否决方案:** 不在 `SandboxMiddleware.wrap_tool_call` 里 per-tool gate —— middleware
无法区分哪些工具需要 sandbox要么误伤非 sandbox 工具,要么维护硬编码工具名集合。
不为 sandbox 引入独立的 `SandboxMiddleware` 构造参数接收 provider —— lazy 路径不经过
middlewaregate 放在工具侧的 `ensure_sandbox_initialized` 才是 single source of truth。
- **决策Gateway 辅助同步路径):** 多路径覆盖自审pr-review 检查点 14发现 4 个
绕过 `ensure_sandbox_initialized` 的直接 acquireuploads.py上传文件同步进 sandbox
artifacts.pyartifact 编辑后同步、feishu.py / dingtalk.pyIM 下载文件同步)。
- uploads / artifactsGateway 路由,身份齐全):加共享 helper `try_acquire_sandbox_for_request`(内部经 `authorize_sandbox_for_request` gate
gateway/authz.py`request.state.user` 构造 Principal含 INTERNAL_SYSTEM_ROLE pop
deny 时**跳过 sandbox 同步**(上传/artifact 编辑本身仍成功——deny 的 role 反正无法
通过 sandbox 消费这些文件provider 解析失败按 `fail_closed` 降级,不让 route 500。
- feishu / dingtalkchannel worker 路径):**本 PR 不 gate**。理由channel 文件下载路径
无法拿到完整授权身份owner-user 解析依赖 run 启动时的 `inject_authenticated_user_context`
文件下载时不可得);且 deny 时同步的文件无法被 agent 消费,仅浪费一次幂等 acquire。
留作 follow-up若维护者要求可从 channel worker 的 run context 传递身份)。
- **兼容性:** `authorization.enabled: false``authorize_sandbox_execution` 是 no-op
直接返回。RBAC provider 的 `_RESOURCE_POLICY_KEYS` 已包含 `"sandbox": "sandbox"`
`provider.py` 已声明 `"sandbox"` 为有效 resource无需 schema 变更。对 test mock
SimpleNamespace app_config安全使用 `getattr` + `is not True` 防御。
- **证据:** `tests/test_sandbox_authorization.py`23 tests覆盖 disabled/RBAC allow/deny/
deny-via-bool/no-policy-unrestricted/provider-error-fail-closed-fail-open/
internal-caller/default-role 场景deny 错误携带 roleprovider 收到正确的
resource/action/target`ensure_sandbox_initialized` sync+async 的 deny不 acquire
+ allowacquire集成场景eager 路径(`before_agent` + `abefore_agent`deny 跳过
acquire 而非 run 级报错provider 解析错误的 fail-closed/fail-open含 fail-open 语义
反转回归uploads/artifacts 路由 denyacquire 不被调用、主操作仍成功)+ allow
acquire 被调用集成场景request=None 容忍(直调测试路径);无 config.yaml 时
gate no-opCI 环境mock app_configSimpleNamespace防御回归既有 `test_sandbox_middleware.py`22 tests
`test_artifacts_router.py``test_uploads_manager.py` 全部通过
authorization 禁用时 gate 是 no-op不破坏现有行为
- **延期:** Phase 3Models/Skills/Sandbox三资源类型完成Phase 4 前端
effective-permissions 展示management route 的 provider 迁移;
feishu/dingtalk 文件同步路径的 sandbox gate身份传递机制待定
### 新记录模板
```markdown