mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-11-09 04:20:34 +00:00
* refactor(agent): ground runtime decisions in structured evidence Use producer status and structured errors for tool recovery, preserve memory context without lexical confirmation, and remove predetermined receipt and skill conclusions. Require explicit MCP discovery and authenticated schedule trial actions. Preserve authorization, ownership, isolation, and bounded execution; update documentation and regression coverage. * test(sandbox): expect read_file initialization error callback * fix(agent): address evidence review follow-ups * fix(agent): preserve retry budgets for OpenAI transport errors * fix(agent): expose tool execution status in model requests Project validated execution metadata and exact tool text into request-only JSON so status survives provider serialization. Preserve stored results and receipt hashes, cover SDK and graph boundaries, and migrate replay input hashes without changing recorded responses. --------- Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
1086 lines
42 KiB
Python
1086 lines
42 KiB
Python
"""Phase 3 sandbox-level authorization tests.
|
|
|
|
Covers the ``authorize("sandbox", "execute")`` gate at the sandbox-use 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
|
|
|
|
import asyncio
|
|
import sys
|
|
from types import ModuleType, 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, AuthorizationProviderConfig
|
|
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_rechecks_authz_for_reused_sandbox(monkeypatch):
|
|
"""A persisted sandbox id must not outlive a revoked execute grant."""
|
|
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.get.return_value = MagicMock()
|
|
monkeypatch.setattr(sandbox_tools, "get_sandbox_provider", lambda: sandbox_provider)
|
|
runtime = SimpleNamespace(
|
|
state={"sandbox": {"sandbox_id": "sbx-existing"}},
|
|
context={"thread_id": "t1", "user_id": "u1", "user_role": "user"},
|
|
config=None,
|
|
)
|
|
|
|
with pytest.raises(SandboxAuthorizationError):
|
|
sandbox_tools.ensure_sandbox_initialized(runtime)
|
|
sandbox_provider.get.assert_not_called()
|
|
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 test_gateway_missing_client_keeps_infrastructure_error_lease():
|
|
from app.gateway.authz import try_acquire_sandbox_for_request
|
|
from deerflow.sandbox.lease import discard_sandbox_lease_manager
|
|
|
|
provider = MagicMock()
|
|
provider.acquire_async = AsyncMock(return_value="lost-after-acquire")
|
|
provider.get.return_value = None
|
|
|
|
async def go():
|
|
lease = await try_acquire_sandbox_for_request(None, provider, "thread-1", user_id="user-1", app_config=None)
|
|
assert lease.sandbox is None
|
|
assert lease.sandbox_id == "lost-after-acquire"
|
|
assert lease.denied is False
|
|
assert lease.owner_id is None
|
|
assert lease.provider is provider
|
|
await lease.release()
|
|
await lease.release()
|
|
|
|
try:
|
|
asyncio.run(go())
|
|
provider.release.assert_called_once_with("lost-after-acquire")
|
|
finally:
|
|
discard_sandbox_lease_manager(provider)
|
|
|
|
|
|
@pytest.mark.parametrize("failure", ["acquire", "lookup", "rollback"])
|
|
def test_gateway_client_lease_preserves_provider_errors(failure):
|
|
from app.gateway.authz import try_acquire_sandbox_for_request
|
|
from deerflow.sandbox.lease import discard_sandbox_lease_manager
|
|
|
|
error = RuntimeError(f"{failure} failed")
|
|
provider = MagicMock()
|
|
provider.acquire_async = AsyncMock(return_value="lost-after-acquire")
|
|
provider.get.return_value = None
|
|
if failure == "acquire":
|
|
provider.acquire_async.side_effect = error
|
|
elif failure == "lookup":
|
|
provider.get.side_effect = error
|
|
else:
|
|
provider.release.side_effect = error
|
|
|
|
try:
|
|
with pytest.raises(RuntimeError) as exc_info:
|
|
asyncio.run(try_acquire_sandbox_for_request(None, provider, "thread-1", user_id="user-1", app_config=None))
|
|
assert exc_info.value is error
|
|
if failure == "acquire":
|
|
provider.release.assert_not_called()
|
|
else:
|
|
provider.release.assert_called_once_with("lost-after-acquire")
|
|
finally:
|
|
discard_sandbox_lease_manager(provider)
|
|
|
|
|
|
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_outputs_confined_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_ensure_sandbox_initialized_async_rechecks_authz_for_reused_sandbox(monkeypatch):
|
|
"""Async tool calls also re-check a revoked grant before sandbox reuse."""
|
|
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.get.return_value = MagicMock()
|
|
sandbox_provider.acquire_async = AsyncMock(return_value="sbx-new")
|
|
monkeypatch.setattr(sandbox_tools, "get_sandbox_provider", lambda: sandbox_provider)
|
|
runtime = SimpleNamespace(
|
|
state={"sandbox": {"sandbox_id": "sbx-existing"}},
|
|
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.get.assert_not_called()
|
|
sandbox_provider.acquire_async.assert_not_called()
|
|
|
|
|
|
def test_async_provider_is_constructed_on_running_event_loop(monkeypatch):
|
|
"""A loop-affine custom provider must not be constructed in a worker."""
|
|
from deerflow.authz.sandbox_authz import authorize_sandbox_execution_async
|
|
|
|
provider_module = ModuleType("loop_affine_authz_test_provider")
|
|
constructed_on = []
|
|
|
|
class LoopAffineProvider:
|
|
name = "loop-affine"
|
|
|
|
def __init__(self):
|
|
self.loop = asyncio.get_running_loop()
|
|
constructed_on.append(self.loop)
|
|
|
|
def authorize(self, request):
|
|
return AuthzDecision(allow=True)
|
|
|
|
async def aauthorize(self, request):
|
|
assert asyncio.get_running_loop() is self.loop
|
|
return AuthzDecision(allow=True)
|
|
|
|
def filter_resources(self, principal, resource_type, candidates):
|
|
return list(candidates)
|
|
|
|
provider_module.LoopAffineProvider = LoopAffineProvider
|
|
monkeypatch.setitem(sys.modules, provider_module.__name__, provider_module)
|
|
|
|
app_config = _make_app_config()
|
|
app_config.authorization = AuthorizationConfig(
|
|
enabled=True,
|
|
fail_closed=True,
|
|
default_role="user",
|
|
provider=AuthorizationProviderConfig(use=f"{provider_module.__name__}:LoopAffineProvider"),
|
|
)
|
|
|
|
async def _run() -> None:
|
|
running_loop = asyncio.get_running_loop()
|
|
await authorize_sandbox_execution_async(context=_context(), app_config=app_config)
|
|
assert constructed_on == [running_loop]
|
|
|
|
asyncio.run(_run())
|
|
|
|
|
|
def test_async_sandbox_tool_authorizes_once_via_async_provider(monkeypatch):
|
|
"""One async tool invocation must make one async authorization decision."""
|
|
from deerflow.sandbox import tools as sandbox_tools
|
|
|
|
provider = MagicMock()
|
|
provider.authorize.return_value = AuthzDecision(allow=True)
|
|
provider.aauthorize = AsyncMock(return_value=AuthzDecision(allow=True))
|
|
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 = MagicMock()
|
|
sandbox.list_dir.return_value = []
|
|
sandbox_provider = MagicMock()
|
|
sandbox_provider.get.return_value = sandbox
|
|
monkeypatch.setattr(sandbox_tools, "get_sandbox_provider", lambda: sandbox_provider)
|
|
monkeypatch.setattr(sandbox_tools, "is_local_sandbox", lambda runtime: False)
|
|
runtime = SimpleNamespace(
|
|
state={"sandbox": {"sandbox_id": "sbx-existing"}},
|
|
context={"thread_id": "t1", "user_id": "u1", "user_role": "user"},
|
|
config=None,
|
|
)
|
|
|
|
import asyncio
|
|
|
|
result = asyncio.run(
|
|
sandbox_tools.ls_tool.coroutine(
|
|
runtime=runtime,
|
|
description="list workspace",
|
|
path="/mnt/user-data/workspace",
|
|
)
|
|
)
|
|
|
|
assert result == "(empty)"
|
|
provider.aauthorize.assert_awaited_once()
|
|
provider.authorize.assert_not_called()
|
|
|
|
|
|
def _composed_file_request(name, args, runtime, messages=()):
|
|
from langgraph.prebuilt.tool_node import ToolCallRequest
|
|
|
|
return ToolCallRequest(
|
|
tool_call={"name": name, "args": args, "id": f"call-{name}"},
|
|
tool=None,
|
|
state={"messages": list(messages)},
|
|
runtime=runtime,
|
|
)
|
|
|
|
|
|
def _install_composed_file_authz(monkeypatch, *, allow=True):
|
|
from deerflow.sandbox import tools as sandbox_tools
|
|
|
|
provider = MagicMock()
|
|
provider.authorize.return_value = AuthzDecision(allow=allow)
|
|
provider.aauthorize = AsyncMock(return_value=AuthzDecision(allow=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 = MagicMock()
|
|
sandbox.id = "sbx-existing"
|
|
sandbox_provider = MagicMock()
|
|
sandbox_provider.get.return_value = sandbox
|
|
monkeypatch.setattr(sandbox_tools, "get_sandbox_provider", lambda: sandbox_provider)
|
|
monkeypatch.setattr(sandbox_tools, "is_local_sandbox", lambda runtime: False)
|
|
runtime = SimpleNamespace(
|
|
state={"sandbox": {"sandbox_id": "sbx-existing"}},
|
|
context={"thread_id": "t1", "user_id": "u1", "user_role": "user"},
|
|
config=None,
|
|
)
|
|
return provider, sandbox, runtime
|
|
|
|
|
|
@pytest.mark.parametrize("tool_name", ["read_file", "write_file", "str_replace"])
|
|
def test_composed_sync_file_tool_authorizes_once(monkeypatch, tool_name):
|
|
"""Read-before-write re-entry shares one synchronous provider decision."""
|
|
from langchain_core.messages import ToolMessage
|
|
|
|
from deerflow.agents.middlewares.read_before_write_middleware import ReadBeforeWriteMiddleware
|
|
from deerflow.sandbox import tools as sandbox_tools
|
|
|
|
provider, sandbox, runtime = _install_composed_file_authz(monkeypatch)
|
|
path = "/mnt/user-data/outputs/report.md"
|
|
middleware = ReadBeforeWriteMiddleware()
|
|
messages = ()
|
|
|
|
if tool_name == "read_file":
|
|
sandbox.read_file.return_value = "v1"
|
|
args = {"description": "read report", "path": path}
|
|
|
|
def handler(_request):
|
|
content = sandbox_tools.read_file_tool.func(runtime, args["description"], path)
|
|
return ToolMessage(content=content, tool_call_id="call-read_file", name="read_file")
|
|
|
|
elif tool_name == "write_file":
|
|
sandbox.read_file.side_effect = FileNotFoundError(path)
|
|
args = {"description": "write report", "path": path, "content": "v1"}
|
|
|
|
def handler(_request):
|
|
content = sandbox_tools.write_file_tool.func(runtime, args["description"], path, args["content"])
|
|
return ToolMessage(content=content, tool_call_id="call-write_file", name="write_file")
|
|
|
|
else:
|
|
import hashlib
|
|
|
|
sandbox.read_file.return_value = "v1"
|
|
args = {"description": "edit report", "path": path, "old_str": "v1", "new_str": "v2"}
|
|
read_mark = ToolMessage(content="v1", tool_call_id="prior-read", name="read_file")
|
|
read_mark.additional_kwargs["deerflow_read_mark"] = {
|
|
"path": path,
|
|
"hash": hashlib.sha256(b"v1").hexdigest(),
|
|
}
|
|
messages = (read_mark,)
|
|
|
|
def handler(_request):
|
|
content = sandbox_tools.str_replace_tool.func(runtime, args["description"], path, args["old_str"], args["new_str"])
|
|
return ToolMessage(content=content, tool_call_id="call-str_replace", name="str_replace")
|
|
|
|
result = middleware.wrap_tool_call(_composed_file_request(tool_name, args, runtime, messages), handler)
|
|
|
|
assert result.status != "error"
|
|
provider.authorize.assert_called_once()
|
|
provider.aauthorize.assert_not_called()
|
|
|
|
|
|
@pytest.mark.parametrize("tool_name", ["read_file", "write_file", "str_replace"])
|
|
def test_composed_async_file_tool_authorizes_once(monkeypatch, tool_name):
|
|
"""Async gate, tool body, and read-mark worker share one async decision."""
|
|
import asyncio
|
|
|
|
from langchain_core.messages import ToolMessage
|
|
|
|
from deerflow.agents.middlewares.read_before_write_middleware import ReadBeforeWriteMiddleware
|
|
from deerflow.sandbox import tools as sandbox_tools
|
|
|
|
provider, sandbox, runtime = _install_composed_file_authz(monkeypatch)
|
|
path = "/mnt/user-data/outputs/report.md"
|
|
middleware = ReadBeforeWriteMiddleware()
|
|
messages = ()
|
|
|
|
if tool_name == "read_file":
|
|
sandbox.read_file.return_value = "v1"
|
|
args = {"description": "read report", "path": path}
|
|
|
|
async def handler(_request):
|
|
content = await sandbox_tools.read_file_tool.coroutine(runtime, args["description"], path)
|
|
return ToolMessage(content=content, tool_call_id="call-read_file", name="read_file")
|
|
|
|
elif tool_name == "write_file":
|
|
sandbox.read_file.side_effect = FileNotFoundError(path)
|
|
args = {"description": "write report", "path": path, "content": "v1"}
|
|
|
|
async def handler(_request):
|
|
content = await sandbox_tools.write_file_tool.coroutine(runtime, args["description"], path, args["content"])
|
|
return ToolMessage(content=content, tool_call_id="call-write_file", name="write_file")
|
|
|
|
else:
|
|
import hashlib
|
|
|
|
sandbox.read_file.return_value = "v1"
|
|
args = {"description": "edit report", "path": path, "old_str": "v1", "new_str": "v2"}
|
|
read_mark = ToolMessage(content="v1", tool_call_id="prior-read", name="read_file")
|
|
read_mark.additional_kwargs["deerflow_read_mark"] = {
|
|
"path": path,
|
|
"hash": hashlib.sha256(b"v1").hexdigest(),
|
|
}
|
|
messages = (read_mark,)
|
|
|
|
async def handler(_request):
|
|
content = await sandbox_tools.str_replace_tool.coroutine(runtime, args["description"], path, args["old_str"], args["new_str"])
|
|
return ToolMessage(content=content, tool_call_id="call-str_replace", name="str_replace")
|
|
|
|
result = asyncio.run(middleware.awrap_tool_call(_composed_file_request(tool_name, args, runtime, messages), handler))
|
|
|
|
assert result.status != "error"
|
|
provider.aauthorize.assert_awaited_once()
|
|
provider.authorize.assert_not_called()
|
|
|
|
|
|
@pytest.mark.parametrize("is_async", [False, True])
|
|
def test_composed_write_authorization_deny_is_not_swallowed(monkeypatch, is_async):
|
|
"""The gate's generic fail-open path must never turn an authz deny into allow."""
|
|
import asyncio
|
|
|
|
import deerflow.agents.middlewares.read_before_write_middleware as read_before_write_module
|
|
from deerflow.agents.middlewares.read_before_write_middleware import ReadBeforeWriteMiddleware
|
|
from deerflow.sandbox.exceptions import SandboxAuthorizationError
|
|
|
|
stamped_exceptions = []
|
|
original_stamp_exception_meta = read_before_write_module.stamp_exception_meta
|
|
|
|
def record_stamped_exception(message, exc_info):
|
|
stamped_exceptions.append(exc_info)
|
|
return original_stamp_exception_meta(message, exc_info)
|
|
|
|
monkeypatch.setattr(read_before_write_module, "stamp_exception_meta", record_stamped_exception)
|
|
|
|
provider, sandbox, runtime = _install_composed_file_authz(monkeypatch, allow=False)
|
|
path = "/mnt/user-data/outputs/report.md"
|
|
args = {"description": "write report", "path": path, "content": "v1"}
|
|
request = _composed_file_request("write_file", args, runtime)
|
|
middleware = ReadBeforeWriteMiddleware()
|
|
handler = MagicMock(side_effect=AssertionError("denied handler must not run"))
|
|
|
|
if is_async:
|
|
|
|
async def async_handler(_request):
|
|
handler(_request)
|
|
|
|
result = asyncio.run(middleware.awrap_tool_call(request, async_handler))
|
|
provider.aauthorize.assert_awaited_once()
|
|
provider.authorize.assert_not_called()
|
|
else:
|
|
result = middleware.wrap_tool_call(request, handler)
|
|
provider.authorize.assert_called_once()
|
|
provider.aauthorize.assert_not_called()
|
|
|
|
assert result.status == "error"
|
|
assert "not permitted" in str(result.content).lower()
|
|
assert len(stamped_exceptions) == 1
|
|
assert isinstance(stamped_exceptions[0], SandboxAuthorizationError)
|
|
handler.assert_not_called()
|
|
sandbox.read_file.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
|