deer-flow/backend/tests/test_layer2_tool_provenance.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

145 lines
5.9 KiB
Python

"""Layer-2 tool provenance forwarding and the identity-bound infrastructure exemption.
Covers (spec: PR 2, §6.5):
- ``GuardrailMiddleware._build_request`` resolves host provenance from the
bound tool object and forwards it into ``AuthzRequest.context``;
- the authorization adapter's infrastructure exemption binds to the concrete
host-created tool *object* — never to a name or a provenance label;
- a request entering the guardrail with ``tool=None`` goes to the provider.
"""
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import MagicMock
from langchain_core.tools import StructuredTool
from deerflow.authz.adapter import GuardrailAuthorizationAdapter
from deerflow.authz.provider import AuthzDecision, AuthzReason, Principal
from deerflow.guardrails.middleware import GuardrailMiddleware
from deerflow.tools.tool_provenance import DECLARED_TOOL_SOURCE_METADATA_KEY, tag_plugin_tool
def _tool(name: str) -> StructuredTool:
return StructuredTool.from_function(lambda: name, name=name, description=name)
def _build_guardrail_request(tool, *, tool_name: str):
middleware = GuardrailMiddleware(MagicMock(), fail_closed=True)
request = MagicMock()
request.tool_call = {"name": tool_name, "args": {}, "id": "call-1"}
request.tool = tool
request.runtime = SimpleNamespace(context={"user_role": "user"})
return middleware._build_request(request, {"user_role": "user"})
class _CapturingProvider:
name = "capturing"
def __init__(self, allow: bool = True) -> None:
self.allow = allow
self.requests = []
def authorize(self, request):
self.requests.append(request)
return AuthzDecision(
allow=self.allow,
reasons=[] if self.allow else [AuthzReason(code="test.deny", message="denied")],
)
async def aauthorize(self, request):
return self.authorize(request)
def filter_resources(self, principal: Principal, resource_type: str, candidates: list[str]) -> list[str]:
return list(candidates)
class TestProvenanceForwarding:
def test_plugin_tool_provenance_reaches_the_authz_context(self):
from deerflow.extensions.plugin_tools import plugin_tool_name
tool = _tool(plugin_tool_name("acme.search", "web_query"))
tag_plugin_tool(tool, namespace="acme.search", declaration="web_query", installation="acme:1.0.0", operation="query")
gr = _build_guardrail_request(tool, tool_name=tool.name)
assert gr.tool_identity is tool
assert gr.tool_provenance["source"] == "plugin:acme.search"
assert gr.tool_provenance["namespace"] == "acme.search"
assert gr.tool_provenance["declaration"] == "web_query"
assert gr.tool_provenance["installation"] == "acme:1.0.0"
assert gr.tool_provenance["operation"] == "query"
provider = _CapturingProvider()
GuardrailAuthorizationAdapter(provider).evaluate(gr)
forwarded = provider.requests[0].context["tool_provenance"]
assert forwarded["source"] == "plugin:acme.search"
assert forwarded["namespace"] == "acme.search"
# The opaque identity never leaves the guardrail layer.
assert "tool_identity" not in provider.requests[0].context
def test_mis_attributed_plugin_tag_is_dropped(self):
tool = _tool("totally_different_name")
tag_plugin_tool(tool, namespace="acme.search", declaration="web_query", installation="acme:1.0.0")
gr = _build_guardrail_request(tool, tool_name=tool.name)
assert gr.tool_provenance.get("source") != "plugin:acme.search"
assert "namespace" not in gr.tool_provenance
def test_missing_tool_yields_empty_provenance_and_no_identity(self):
gr = _build_guardrail_request(None, tool_name="anything")
assert gr.tool_provenance == {}
assert gr.tool_identity is None
class TestIdentityBoundExemption:
def test_exemption_applies_to_the_host_created_object(self):
host_tool = _tool("tool_search")
provider = _CapturingProvider()
adapter = GuardrailAuthorizationAdapter(provider, infrastructure_tools=[host_tool])
decision = adapter.evaluate(_build_guardrail_request(host_tool, tool_name="tool_search"))
assert decision.allow is True
assert decision.policy_id == "authz:infrastructure"
assert provider.requests == [] # no provider call for the exempt tool
def test_same_named_foreign_tool_is_not_exempt(self):
host_tool = _tool("tool_search")
foreign_tool = _tool("tool_search") # same name, different object
provider = _CapturingProvider(allow=False)
adapter = GuardrailAuthorizationAdapter(provider, infrastructure_tools=[host_tool])
decision = adapter.evaluate(_build_guardrail_request(foreign_tool, tool_name="tool_search"))
assert decision.allow is False
assert len(provider.requests) == 1
def test_builtin_label_does_not_buy_the_exemption(self):
host_tool = _tool("tool_search")
impostor = _tool("tool_search")
impostor.metadata = {DECLARED_TOOL_SOURCE_METADATA_KEY: "builtin"}
provider = _CapturingProvider(allow=False)
adapter = GuardrailAuthorizationAdapter(provider, infrastructure_tools=[host_tool])
decision = adapter.evaluate(_build_guardrail_request(impostor, tool_name="tool_search"))
assert decision.allow is False
assert len(provider.requests) == 1
# The label still flows as display context — it just decides nothing.
assert provider.requests[0].context["tool_provenance"]["source"] == "builtin"
def test_tool_none_request_goes_to_the_provider(self):
host_tool = _tool("tool_search")
provider = _CapturingProvider(allow=False)
adapter = GuardrailAuthorizationAdapter(provider, infrastructure_tools=[host_tool])
decision = adapter.evaluate(_build_guardrail_request(None, tool_name="tool_search"))
assert decision.allow is False
assert len(provider.requests) == 1