mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 13:16:24 +00:00
* fix(sandbox): attest lark broker mode per Pod and quarantine uncertain shell outcomes Why this change: Two fail-open paths in the AIO sandbox lifecycle. First, the lark broker capability probe collapsed "provisioner unreachable" into "broker not configured": one 1.5s timeout cached a negative for 300s, so new sandboxes were created without the broker sidecar while the per-user credential directories were still bind-mounted into the container, and existing Pods kept being reused without their actual mode ever being read. Second, an implicit-shell command that timed out or returned an unknown status only marked the current client object; warm-pool reclaim and rediscovery built a fresh client that re-entered a container whose shell state could not be proven clean, and nothing about that ambiguity survived a Gateway restart. What changed: - The capability cache stores confirmed observations only, keyed per provisioner endpoint/auth identity; an unknown answer (timeout, malformed payload) raises instead of selecting credential placement, while a confirmed broker requirement is retained through outages. One in-flight probe per identity shares success and failure, and a Pod-attested mode contradicting the cache forces one shared refresh. - Execution uses the mode attested for the actual sandbox: SandboxInfo.lark_cli_broker flows from the provisioner's atomic Pod observation into the client, so a probe outage delays drift detection but never denies reuse of an attested runtime, and never trusts an unattested one. - The provisioner reports each Pod's actual broker mode, generation (Pod UID) and shell capacity from one read, rejects lark requests conflicting with its configuration (409/503 carrying a capability-refresh marker the Gateway uses to drop the stale cache entry), rejects conflicting or Terminating Pods on coordination creates while reporting Terminating as data to status/get/list, and answers Pod presence independently of Service readiness. - A persistent quarantine (DEER_FLOW_HOME/sandbox-quarantine, keyed by scope/sandbox/generation) fences containers after uncertain implicit shell outcomes across client rebuilds and restarts; in-process reuse, warm reclaim, discovery, create responses and orphan reconciliation all pass one policy gate, and replacement runs under the existing ownership and teardown fences. A fenced sandbox still tracked after a failed release-recycle is destroyed inline on the next acquire. - Quarantine records retire only after confirmed absence, retrying partial cleanup for the quarantined generation and releasing remembered ports under the same fences; local stop failures now propagate instead of being logged away, and remote destroy errors surface to callers. Problem solved: A provisioner blip could silently strip broker isolation from new lark sandboxes for minutes at a time, and ambiguous shell outcomes let corrupted default shells persist across warm reuse, rediscovery and restarts. Docs updated: README, aio_sandbox and skills AGENTS.md. * docs(changelog): record sandbox attestation and quarantine fix (#6436) * fix(sandbox): preserve active leases and fail closed on unattested broker modes Concurrent uploads could recycle a healthy AIO runtime while another holder was creating or cleaning up a command session. Older provisioner responses could also trigger destructive replacement before failing admission. - Distinguish pending session creation from ambiguous outcomes and reserve idle teardown until execution and upload holders finish scope cleanup. - Reject stale fork lookups and reconcile cancelled acquires without releasing a sandbox still borrowed by another holder. - Default Lark broker mode to unverified and preserve unattested Pods until the provisioner is upgraded. - Recognize Docker 29 absence diagnostics while matching resource names exactly. - Preserve Gateway upload errors and cover admission and lifecycle regressions. * fix(sandbox): scope the unverified-broker refuse path to attested providers The fail-closed raise in _lark_cli_env_from_runtime treated every Sandbox without a lark_cli_broker attribute as unverified, but only AioSandbox carries the attestation — E2B, OpenSandbox, Tenki, and Boxlite have no broker sidecar, no credential mounts, and no provisioner to upgrade, so every lark-cli command on those providers failed with an unactionable error. Declare the tri-state on the Sandbox base class (defaulting to False, the only mode brokerless providers have) so the None fail-closed path stays scoped to AioSandbox's deliberate unverified default. - Add lark_cli_broker: bool | None = False to the Sandbox ABC, mirroring the persistent_shell_sessions tri-state pattern - Add a regression test pinning the non-broker overlay for a provider without the attribute * fix(sandbox): close review gaps in broker attestation and teardown lifecycle Address the latest #6436 review round: - The provisioner's post-build broker-mode 409 now carries the capability-refresh marker, so an incompatible-mode conflict surfaced by idempotent re-create, attestation drift or post-create verification drops the Gateway's cached capability observation instead of repeating the same 409 for the full negative-cache TTL. - AioSandboxProvider.destroy() returns the _destroy_tracked outcome: False when teardown is deferred because an execution/upload holder or a peer teardown is still active, so callers can no longer mistake a deferred destroy for a completed one. - A live replacement under the same logical ID proves the fenced old generations are absent; retire_replaced_generations prunes only those stale records so they cannot strand future creates, while the live generation's fence and unversioned crash fences are never touched. - SandboxInfo.from_dict keeps a missing lark_cli_broker attestation unverified (None) instead of silently rehydrating as attested non-broker. - The restricted-network inspect path shares the strict _is_no_such_network_error parser, closing the substring loophole for context-name collisions and wrong-target errors. * docs(sandbox): condense lease notes to keep the AGENTS.md chain under the size gate The three lease notes added in 26c307f3 pushed the effective guidance chain to 98,312 bytes — 8 over the 98,304 hard limit enforced by agent-guidance (AG002). Fold them into two lines carrying the same rules. --------- Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
1073 lines
42 KiB
Python
1073 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
|
|
|
|
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
|