mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-11 14:38:38 +00:00
* fix(gateway): confine artifact PUT to /mnt/user-data/outputs after path resolution
The outputs-only guard on PUT /api/threads/{id}/artifacts/{path} was a
string-prefix check on the raw path. A percent-encoded `..`
(`outputs/%2e%2e/uploads/x.txt`) survives nginx's variable proxy_pass
untouched, is decoded by Starlette, passes the prefix check, and the
resolver only confines the result to `user-data/` -- so an owner could
overwrite a sibling upload or workspace file in their own thread.
Collapse dot segments before the prefix check, and re-check the resolved
host path against the resolved outputs root so a symlink planted inside
`outputs/` cannot redirect the write either. The normalized virtual path
is what the response echoes and what non-mounted sandboxes receive.
* refactor(gateway): share the outputs-confinement rule with channel attachments
Review follow-up on #5321: the "only under /mnt/user-data/outputs" rule was
implemented independently by the artifact editor and by IM-channel
attachment delivery, and the two copies had already drifted.
Move it into app/gateway/path_utils.py as normalize_outputs_virtual_path
(collapse `..` before the prefix check) and resolve_outputs_confined_path
(re-check the resolved host path against the resolved outputs root, which
also catches a symlink planted inside outputs/). PUT /artifacts and
ChannelManager._resolve_attachments both call the helper; artifact_archive
keeps its stricter ZIP-member rules layered on top.
Tests that previously stubbed resolve_thread_virtual_path for the editor now
stub resolve_outputs_confined_path, and the channel attachment tests patch
path_utils.get_paths, which the helper binds at import like the other
consumers. The confinement itself is pinned by tests/test_gateway_path_utils.py.
1020 lines
40 KiB
Python
1020 lines
40 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 _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
|
|
|
|
from deerflow.agents.middlewares.read_before_write_middleware import ReadBeforeWriteMiddleware
|
|
|
|
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()
|
|
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
|