deer-flow/backend/tests/blocking_io/test_declared_tool_decision_offloop.py
Zeren Wang d3e8e78f3f
feat(authz): middleware-declared tool path Layer 1 now covers tools declared by middlewares (#6105)
* feat(authz): extension tool authorization PR2 — middleware-declared tool path

Layer 1 now covers tools declared by middlewares, which LangChain merges
into the bound tool set after the host's explicit-list filter:

- new agents/middlewares/tool_declarations.py: record the ordinary pass's
  verdict, collect declarations from the built stack, decide only the
  delta seeded by the first pass (a name denied for this build stays
  denied), narrow the stack build-locally on independent state-preserving
  copies (direct __dict__ write, refused up front for tools data
  descriptors, never mutating caller-owned instances), and verify the
  exact bound stack after the final normalization copy.
- IsolatedMiddleware ships a state-preserving __copy__ so narrowing
  survives full/delta normalization and copies of copies.
- TodoMiddleware degrades when write_todos is denied: no system-prompt
  injection, no completion/context-loss reminders, no jump_to.
- Layer 2 forwards host-resolved tool provenance into AuthzRequest.context
  and binds the infrastructure exemption to the concrete host-created
  tool object instead of a name set (tool=None goes to the provider).
- Wired into all four assembly paths (lead x2, native subagent with the
  decision offloaded via asyncio.to_thread, DeerFlowClient).

Tests: new test_middleware_declared_tools.py (35) and
test_layer2_tool_provenance.py (7); blocking-IO anchor pinning the
subagent offload (teeth verified red->green); placement-guarantee and
enforcement suites extended; executor test doubles converted for the
async _create_agent. Behavior change called out in CHANGELOG: with
authorization enabled and a tools policy that excludes write_todos,
plan-mode builds no longer bind it (built-in RBAC default unchanged).

* fix(authz): close residual gaps from review — narrow non-collectable declarations, dedupe candidates, warn on skipped pass

- apply_declared_tool_view now removes non-BaseTool declarations from the
  bound view when authorization is enabled (they would otherwise bind via
  LangChain's create_tool auto-conversion with no provider decision);
  verify_declared_tool_view fails loudly if one survives normalization
- decide_declared_tools dedupes never-submitted names so the provider sees
  each candidate once
- subagent executor logs a warning when the provider is set but the seeded
  Layer-1 state is missing, instead of skipping the declaration pass silently
- docs: middleware-declared tools must be BaseTool instances with usable names

* fix(authz): fail loudly on non-list/tuple middleware tools declarations

_iter_declared warned and returned () for a 'tools' attribute that is not
a list/tuple, but LangChain's factory iterates the attribute as-is — so a
set/generator/dict_values declaration bound with no Layer-1 decision.
Raise DeclaredToolViewError instead, matching the module's fail-loud
standard for every other un-narrowable shape. Disabled path remains a
strict no-op. Docs updated to match.
2026-10-01 18:26:58 +08:00

226 lines
8.8 KiB
Python

"""Regression: the subagent middleware-declared-tool decision runs off the event loop.
``SubagentExecutor._create_agent`` is awaited by ``_aexecute`` on the shared
event loop, and its authorization declaration pass calls the provider's
``filter_resources`` — which a custom provider may implement with external
policy-service IO. The decision must stay behind ``asyncio.to_thread``; if it
is flattened back to a plain call, the blocking-probe provider below trips the
strict Blockbuster gate (this directory's conftest). The meta-check at the
bottom proves the probe has teeth by calling the decision inline on the loop.
"""
from __future__ import annotations
import importlib
import sys
import threading
from types import ModuleType, SimpleNamespace
from unittest.mock import MagicMock
import pytest
# Same cycle-breaking parent mocks as tests/test_subagent_executor.py: the real
# executor must be imported behind them (conftest.py keeps a mock in
# sys.modules["deerflow.subagents.executor"] for collection).
_MOCKED_MODULE_NAMES = [
"deerflow.agents",
"deerflow.agents.thread_state",
"deerflow.agents.middlewares",
"deerflow.agents.middlewares.thread_data_middleware",
"deerflow.sandbox",
"deerflow.sandbox.middleware",
"deerflow.sandbox.security",
"deerflow.models",
"deerflow.skills.storage",
]
def _import_real_executor():
"""Import the real SubagentExecutor at module scope (imports do one-time IO
that must not run inside a gated test item), then restore sys.modules."""
original_modules = {name: sys.modules.get(name) for name in _MOCKED_MODULE_NAMES}
original_executor = sys.modules.get("deerflow.subagents.executor")
# Preload the real leafs the executor imports at runtime so no import IO
# happens inside the gated test either.
tool_declarations_module = importlib.import_module("deerflow.agents.middlewares.tool_declarations")
audit_context_module = importlib.import_module("deerflow.agents.middlewares.audit_context")
sys.modules.pop("deerflow.subagents.executor", None)
subagents_pkg = sys.modules.get("deerflow.subagents")
had_executor_attr = subagents_pkg is not None and hasattr(subagents_pkg, "executor")
if had_executor_attr:
delattr(subagents_pkg, "executor")
for name in _MOCKED_MODULE_NAMES:
sys.modules[name] = MagicMock()
sys.modules["deerflow.agents.middlewares.tool_declarations"] = tool_declarations_module
sys.modules["deerflow.agents.middlewares.audit_context"] = audit_context_module
try:
module = importlib.import_module("deerflow.subagents.executor")
finally:
for name, original in original_modules.items():
if original is None:
sys.modules.pop(name, None)
else:
sys.modules[name] = original
if original_executor is not None:
sys.modules["deerflow.subagents.executor"] = original_executor
return module
executor_module = _import_real_executor()
SubagentExecutor = executor_module.SubagentExecutor
SubagentConfig = importlib.import_module("deerflow.subagents.config").SubagentConfig
LayerOneOutcome = importlib.import_module("deerflow.agents.middlewares.tool_declarations").LayerOneOutcome
tool_declarations = sys.modules["deerflow.agents.middlewares.tool_declarations"]
pytestmark = pytest.mark.asyncio
def _leaf_module(name: str, **attrs):
module = ModuleType(name)
for key, value in attrs.items():
setattr(module, key, value)
return module
def _app_config():
return SimpleNamespace(
models=[SimpleNamespace(name="default-model")],
authorization=SimpleNamespace(enabled=True, fail_closed=True, default_role="user"),
)
def _blocking_probe_provider(probe_file, observed_threads: list):
"""An AuthorizationProvider whose filter_resources performs real blocking file IO."""
from deerflow.authz.provider import AuthzDecision
class _Provider:
name = "blocking-probe"
def authorize(self, request):
return AuthzDecision(allow=True)
async def aauthorize(self, request):
return self.authorize(request)
def filter_resources(self, principal, resource_type, candidates):
observed_threads.append(threading.current_thread())
body = probe_file.read_text(encoding="utf-8") # trips the gate on the loop
return [candidate for candidate in candidates if candidate in body]
return _Provider()
def _executor_with_declaration(monkeypatch, declaring):
app_config = _app_config()
captured: dict = {}
def fake_build_subagent_runtime_middlewares(**kwargs):
return [declaring]
def fake_create_agent(**kwargs):
captured["agent"] = kwargs
return MagicMock()
monkeypatch.setattr(executor_module, "create_chat_model", lambda **kwargs: MagicMock())
monkeypatch.setattr(executor_module, "create_agent", fake_create_agent)
monkeypatch.setitem(
sys.modules,
"deerflow.agents.middlewares.tool_error_handling_middleware",
_leaf_module(
"deerflow.agents.middlewares.tool_error_handling_middleware",
build_subagent_runtime_middlewares=fake_build_subagent_runtime_middlewares,
),
)
# Phase 3's skill-authorization resolution requires a real
# AuthorizationConfig; the SimpleNamespace app_config above is scoped to
# the declaration pass, which uses the explicitly-set provider.
monkeypatch.setattr(SubagentExecutor, "_resolve_skill_authorization", lambda self: None)
executor = SubagentExecutor(
config=SubagentConfig(name="researcher", description="d", system_prompt="p"),
tools=[],
app_config=app_config,
parent_model="parent-model",
)
executor.model_name = "test-model"
return executor, captured
def _tool(name: str):
from langchain_core.tools import StructuredTool
return StructuredTool.from_function(lambda: name, name=name, description=name)
async def test_declared_tool_decision_runs_off_loop(monkeypatch, tmp_path):
"""The seeded declaration decision must not execute provider IO on the loop."""
from langchain.agents.middleware import AgentMiddleware
probe = tmp_path / "policy.txt"
probe.write_text("allowed_decl", encoding="utf-8")
observed_threads: list = []
class _DeclaringMiddleware(AgentMiddleware):
def __init__(self, tools):
super().__init__()
self.tools = tools
declaring = _DeclaringMiddleware([_tool("allowed_decl"), _tool("denied_decl")])
executor, captured = _executor_with_declaration(monkeypatch, declaring)
executor._authz_provider = _blocking_probe_provider(probe, observed_threads)
executor._authz_context = {"user_role": "user"}
executor._layer_one_outcome = LayerOneOutcome(submitted=frozenset({"regular"}), allowed=frozenset({"regular"}))
await executor._create_agent()
assert observed_threads, "the provider was never consulted — the anchor is vacuous"
assert all(thread is not threading.current_thread() for thread in observed_threads), "filter_resources ran on the event-loop thread"
(bound,) = captured["agent"]["middleware"]
assert [tool.name for tool in bound.tools] == ["allowed_decl"]
async def test_inline_decision_trips_the_gate(tmp_path):
"""Meta-check: the same decision called inline (no offload) really does hit
the gate — BlockingError fires inside the probe — so the anchor above is
not vacuously green. ``filter_tools_by_authorization``'s fail-closed
handler then swallows that error into a denial, which is exactly why the
anchor's teeth are the thread identity assertion, not the exception."""
from blockbuster import BlockingError
probe = tmp_path / "policy.txt"
probe.write_text("allowed_decl", encoding="utf-8")
caught: list = []
class _Provider:
name = "blocking-probe"
def authorize(self, request):
from deerflow.authz.provider import AuthzDecision
return AuthzDecision(allow=True)
async def aauthorize(self, request):
return self.authorize(request)
def filter_resources(self, principal, resource_type, candidates):
try:
body = probe.read_text(encoding="utf-8")
except BlockingError as exc:
caught.append(exc)
raise
return [candidate for candidate in candidates if candidate in body]
authorized = tool_declarations.decide_declared_tools(
[_tool("allowed_decl")],
outcome=LayerOneOutcome(submitted=frozenset(), allowed=frozenset()),
context={},
app_config=_app_config(),
authorization_provider=_Provider(),
)
assert len(caught) == 1 # the gate fired through the deerflow decision frame
assert authorized == frozenset() # … and fail_closed converted it to a denial