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

149 lines
6.5 KiB
Python

"""Adapter that presents an AuthorizationProvider as a GuardrailProvider.
This lets the existing :class:`~deerflow.guardrails.middleware.GuardrailMiddleware`
enforce :class:`~deerflow.authz.provider.AuthorizationProvider` decisions at
tool-call time — no new middleware class required (see RFC §6.1).
The adapter maps :class:`~deerflow.guardrails.provider.GuardrailRequest`
fields to :class:`~deerflow.authz.provider.AuthzRequest` fields, calls the
authorization provider, and converts the :class:`~deerflow.authz.provider.AuthzDecision`
back to a :class:`~deerflow.guardrails.provider.GuardrailDecision`.
Principal construction delegates to
:func:`~deerflow.authz.principal.build_principal_from_context` so Layer 1
(tool assembly) and Layer 2 (this adapter) share a single identity builder
with consistent ``default_role`` and ``attributes`` semantics.
"""
from __future__ import annotations
from collections.abc import Iterable
from deerflow.authz.principal import build_principal_from_context
from deerflow.authz.provider import AuthorizationProvider, AuthzDecision, AuthzRequest
from deerflow.guardrails.provider import GuardrailDecision, GuardrailReason, GuardrailRequest
class GuardrailAuthorizationAdapter:
"""Adapt an :class:`AuthorizationProvider` to the ``GuardrailProvider`` Protocol.
``resource_type`` and ``action`` default to ``"tool"`` / ``"call"``,
which is correct for the tool-execution path. A different resource/action
pair can be injected if the adapter is reused outside the tool path.
Args:
provider: The authorization provider to delegate decisions to.
default_role: Role used when ``user_role`` is absent or empty in the
runtime context. Must be passed by Phase 1B wiring from
``AuthorizationConfig.default_role``.
resource_type: Resource type for all ``AuthzRequest`` instances.
action: Action for all ``AuthzRequest`` instances.
infrastructure_tools: Framework tool *objects* created by this build
from an already authorized capability set. The exemption binds to
object identity — never to a name or a provenance label — so a
same-named foreign tool, or one self-declaring
``deerflow_tool_source: "builtin"``, is not exempt. Callers pass
the current build's concrete tool objects (e.g. the deferred
setup's generated ``tool_search``), not static config.
"""
name = "authorization"
def __init__(
self,
provider: AuthorizationProvider,
*,
default_role: str = "user",
resource_type: str = "tool",
action: str = "call",
infrastructure_tools: Iterable[object] = (),
) -> None:
self._provider = provider
self._default_role = default_role
self._resource_type = resource_type
self._action = action
self._infrastructure_tools = tuple(infrastructure_tools)
def _infrastructure_decision(self, request: GuardrailRequest) -> GuardrailDecision | None:
"""Allow this build's host-created framework tools without a second decision.
The exemption binds to the concrete tool *object* the host created for
this build, not to a name or a provenance label: a request that enters
the guardrail with ``tool=None`` (an unregistered/dynamic name) is not
exempt and goes to the provider.
"""
if request.tool_identity is None or not any(request.tool_identity is tool for tool in self._infrastructure_tools):
return None
return GuardrailDecision(
allow=True,
reasons=[GuardrailReason(code="authz.infrastructure_tool")],
policy_id="authz:infrastructure",
)
def _to_authz(self, gr: GuardrailRequest) -> AuthzRequest:
"""Map a guardrail request to an authorization request."""
principal = build_principal_from_context(
{
"user_id": gr.user_id,
"user_role": gr.user_role,
"oauth_provider": gr.oauth_provider,
"oauth_id": gr.oauth_id,
"channel_user_id": gr.channel_user_id,
"is_internal": gr.is_internal,
"authz_attributes": gr.authz_attributes,
},
default_role=self._default_role,
)
return AuthzRequest(
principal=principal,
resource=self._resource_type,
action=self._action,
target=gr.tool_name,
context={
"thread_id": gr.thread_id,
"run_id": gr.run_id,
"tool_call_id": gr.tool_call_id,
"tool_input": gr.tool_input,
"is_subagent": gr.is_subagent,
"agent_id": gr.agent_id,
"timestamp": gr.timestamp,
"tool_provenance": gr.tool_provenance,
},
)
@staticmethod
def _to_guardrail(d: AuthzDecision) -> GuardrailDecision:
"""Convert an authorization decision to a guardrail decision."""
return GuardrailDecision(
allow=d.allow,
reasons=[GuardrailReason(code=r.code, message=r.message) for r in d.reasons],
policy_id=d.policy_id,
metadata=d.metadata,
)
def evaluate(self, request: GuardrailRequest) -> GuardrailDecision:
"""Synchronous evaluation: delegate to ``provider.authorize``.
Provider exceptions are intentionally allowed to propagate. The
adapter is consumed by :class:`~deerflow.guardrails.middleware.GuardrailMiddleware`,
whose ``wrap_tool_call`` / ``awrap_tool_call`` already applies
fail-closed semantics based on its ``fail_closed`` parameter
(backed by ``AuthorizationConfig.fail_closed``). Catching exceptions
here would duplicate that logic and risk divergent behavior between
the two layers.
"""
if infrastructure_decision := self._infrastructure_decision(request):
return infrastructure_decision
decision = self._provider.authorize(self._to_authz(request))
return self._to_guardrail(decision)
async def aevaluate(self, request: GuardrailRequest) -> GuardrailDecision:
"""Async evaluation: delegate to ``provider.aauthorize``.
See :meth:`evaluate` for exception-propagation rationale.
"""
if infrastructure_decision := self._infrastructure_decision(request):
return infrastructure_decision
decision = await self._provider.aauthorize(self._to_authz(request))
return self._to_guardrail(decision)