mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-14 16:08:41 +00:00
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.
697 lines
27 KiB
Python
697 lines
27 KiB
Python
"""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
|