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

381 lines
15 KiB
Python

"""Isolating extension middleware failures from the user's run.
Extension middlewares execute inside LangChain's call chain, so an unhandled
exception would abort the user's run. Every contributed middleware is wrapped
so an observation failure degrades to a diagnostic and the call passes through.
The downstream handler is tracked so isolation recovery never adds another
model request or tool side effect: pre-handler extension failures invoke it
once, post-handler failures return its captured result, and handler failures
remain owned by the graph's error policy.
The wrapper must mirror the inner middleware's full interface, not just the
four wrap-call hooks: LangChain discovers capabilities by inspecting the
wrapper — hook participation via class-level identity checks
(`m.__class__.before_model is not AgentMiddleware.before_model`), tools,
state_schema and transformers via instance attributes. Lifecycle mirroring is
exact in both directions. LangChain deliberately treats each sync/async wrap
pair as one capability and wires both execution paths when either side exists,
so the wrapper supplies a silent pass-through counterpart when the inner
implements only one side; otherwise the base class raises
``NotImplementedError`` before isolation can fail open.
All first-version contributions are observational, hence fail-open. A future
intercepting (decision-making) contribution would need to fail closed and must
opt out of this wrapper explicitly.
"""
from __future__ import annotations
import logging
import re
import threading
from collections.abc import Awaitable, Callable
from types import TracebackType
from typing import Any
from langchain.agents.middleware import AgentMiddleware
from langgraph.errors import GraphBubbleUp
from deerflow.extensions.loader import Diagnostic
logger = logging.getLogger(__name__)
_UNSAFE_GRAPH_NAME = re.compile(r"[^A-Za-z0-9_.-]+")
def graph_safe_middleware_name(value: str) -> str:
"""Normalize a middleware identity for LangGraph node names."""
return _UNSAFE_GRAPH_NAME.sub("_", value)
_WRAP_HOOKS = ("wrap_model_call", "awrap_model_call", "wrap_tool_call", "awrap_tool_call")
_WRAP_HOOK_PAIRS = (
("wrap_model_call", "awrap_model_call"),
("wrap_tool_call", "awrap_tool_call"),
)
_LIFECYCLE_HOOKS = (
"before_agent",
"abefore_agent",
"before_model",
"abefore_model",
"after_model",
"aafter_model",
"after_agent",
"aafter_agent",
)
def _implemented_hooks(inner: AgentMiddleware) -> frozenset[str]:
"""The hooks ``inner`` actually overrides, by LangChain's own class-level
identity check — instance-level attributes are invisible to the factory,
so they are invisible here too."""
return frozenset(hook for hook in (*_WRAP_HOOKS, *_LIFECYCLE_HOOKS) if getattr(type(inner), hook, None) is not getattr(AgentMiddleware, hook, None))
def _make_sync_wrap_delegate(hook: str):
def delegate(self: IsolatedMiddleware, request: Any, handler: Callable[[Any], Any]) -> Any:
return self._invoke_sync(hook, getattr(self._inner, hook), request, handler)
return delegate
def _make_async_wrap_delegate(hook: str):
async def delegate(self: IsolatedMiddleware, request: Any, handler: Callable[[Any], Awaitable[Any]]) -> Any:
return await self._invoke_async(hook, getattr(self._inner, hook), request, handler)
return delegate
def _make_sync_wrap_passthrough():
def delegate(self: IsolatedMiddleware, request: Any, handler: Callable[[Any], Any]) -> Any:
return handler(request)
return delegate
def _make_async_wrap_passthrough():
async def delegate(self: IsolatedMiddleware, request: Any, handler: Callable[[Any], Awaitable[Any]]) -> Any:
return await handler(request)
return delegate
def _make_sync_lifecycle_delegate(hook: str):
def delegate(self: IsolatedMiddleware, state: Any, runtime: Any) -> Any:
return self._invoke_lifecycle_sync(hook, state, runtime)
return delegate
def _make_async_lifecycle_delegate(hook: str):
async def delegate(self: IsolatedMiddleware, state: Any, runtime: Any) -> Any:
return await self._invoke_lifecycle_async(hook, state, runtime)
return delegate
# Async variants are named explicitly: startswith("a") would also catch the
# sync after_model/after_agent.
_ASYNC_HOOKS = frozenset(hook for hook in (*_WRAP_HOOKS, *_LIFECYCLE_HOOKS) if hook[1:].startswith(("wrap", "before", "after")))
def _delegate_for(hook: str):
if hook in _WRAP_HOOKS:
return _make_async_wrap_delegate(hook) if hook in _ASYNC_HOOKS else _make_sync_wrap_delegate(hook)
return _make_async_lifecycle_delegate(hook) if hook in _ASYNC_HOOKS else _make_sync_lifecycle_delegate(hook)
_subclass_cache: dict[frozenset[str], type[IsolatedMiddleware]] = {}
_subclass_cache_lock = threading.Lock()
def _wrapper_subclass(hooks: frozenset[str]) -> type[IsolatedMiddleware]:
"""A cached IsolatedMiddleware subclass defining ``hooks`` and required
wrap-hook pass-through counterparts.
Per hook set, not per middleware: every inner middleware with the same
implemented-hook combination shares one subclass.
"""
with _subclass_cache_lock:
subclass = _subclass_cache.get(hooks)
if subclass is None:
namespace = {hook: _delegate_for(hook) for hook in hooks}
for sync_hook, async_hook in _WRAP_HOOK_PAIRS:
if sync_hook in hooks and async_hook not in hooks:
namespace[async_hook] = _make_async_wrap_passthrough()
elif async_hook in hooks and sync_hook not in hooks:
namespace[sync_hook] = _make_sync_wrap_passthrough()
subclass = type(IsolatedMiddleware.__name__, (IsolatedMiddleware,), namespace)
_subclass_cache[hooks] = subclass
return subclass
class IsolatedMiddleware(AgentMiddleware):
"""Wrap one extension middleware so its failures cannot break the run.
Instantiation returns a cached subclass that defines exactly the hooks the
inner middleware implements, so LangChain's class-level capability checks
see the same interface on the wrapper as on the inner middleware itself.
"""
def __new__(cls, inner: AgentMiddleware, source: str, on_error: Callable[[Diagnostic], None], *, name: str | None = None):
if cls is IsolatedMiddleware:
cls = _wrapper_subclass(_implemented_hooks(inner))
return super().__new__(cls)
def __init__(
self,
inner: AgentMiddleware,
source: str,
on_error: Callable[[Diagnostic], None],
*,
name: str | None = None,
) -> None:
super().__init__()
self._inner = inner
self._source = source
self._on_error = on_error
if name is None:
inner_name = getattr(inner, "name", type(inner).__name__)
name = f"extension:{source}:{inner_name}"
self._name = graph_safe_middleware_name(name)
# Mirror the declared-contribution attributes LangChain reads off the
# middleware instance (factory.py: m.tools, m.state_schema,
# m.transformers). state_schema is a class attribute on the base but
# must be per-instance here: cached subclasses are shared across
# middlewares whose schemas differ.
self.tools = getattr(inner, "tools", [])
self.transformers = getattr(inner, "transformers", ())
self.state_schema = getattr(inner, "state_schema", AgentMiddleware.state_schema)
def __copy__(self) -> IsolatedMiddleware:
"""State-preserving shallow copy; never rebuilds from ``inner``.
``normalize_middleware_state_schemas`` (delta mode) and the
authorization declaration view both copy middleware instances. A
rebuild-based copy would re-run ``__init__`` and re-mirror
``inner.tools``, silently restoring declarations Layer 1 narrowed away
on this build; the default copy protocol would call the argument-
requiring ``__new__`` above and fail. Bypassing both keeps the
narrowed ``tools``, transformers, and adapted ``state_schema`` through
the whole copy chain.
"""
clone = object.__new__(type(self))
clone.__dict__.update(self.__dict__)
return clone
@property
def name(self) -> str:
"""Stable graph and trace identity for this isolated contribution."""
return self._name
@property
def inner(self) -> AgentMiddleware:
"""The wrapped middleware. Used by ordering checks and tests."""
return self._inner
@property
def source(self) -> str:
"""Extension this middleware came from. Read by the provenance map."""
return self._source
def _report(self, hook: str, exc: Exception) -> None:
message = f"{type(self._inner).__name__}.{hook} failed and was skipped: {exc}"
logger.exception("Extension %s: %s", self._source, message)
try:
self._on_error(Diagnostic.error(self._source, message))
except Exception: # pragma: no cover - reporting must never raise
logger.exception("Extension %s: diagnostic reporting failed", self._source)
def _invoke_sync(
self,
hook: str,
inner_hook: Callable[[Any, Callable[[Any], Any]], Any],
request: Any,
handler: Callable[[Any], Any],
) -> Any:
handler_called = False
handler_succeeded = False
handler_result: Any = None
handler_error: BaseException | None = None
handler_error_traceback: TracebackType | None = None
duplicate_call_error: RuntimeError | None = None
def tracked_handler(inner_request: Any) -> Any:
nonlocal handler_called, duplicate_call_error
nonlocal handler_error, handler_error_traceback
nonlocal handler_result, handler_succeeded
if handler_called:
duplicate_call_error = RuntimeError(f"{type(self._inner).__name__}.{hook} called the downstream handler more than once")
raise duplicate_call_error
handler_called = True
handler_error = None
handler_error_traceback = None
handler_succeeded = False
try:
# The first contract slice is observational: a contributed
# wrapper may inspect the request but cannot substitute a new
# one after the host's policy/authorization layers have run.
handler_result = handler(request)
except BaseException as exc:
handler_error = exc
handler_error_traceback = exc.__traceback__
raise
else:
handler_succeeded = True
return handler_result
try:
inner_hook(request, tracked_handler)
if handler_error is not None:
raise handler_error.with_traceback(handler_error_traceback)
if duplicate_call_error is not None:
raise duplicate_call_error
if not handler_called:
raise RuntimeError(f"{type(self._inner).__name__}.{hook} did not call the downstream handler")
return handler_result
except GraphBubbleUp as exc:
if handler_error is not None:
if handler_error is exc:
raise
raise handler_error.with_traceback(handler_error_traceback) from None
if handler_succeeded:
self._report(hook, duplicate_call_error or exc)
return handler_result
raise
except Exception as exc:
if handler_error is not None:
if handler_error is exc:
raise
raise handler_error.with_traceback(handler_error_traceback) from None
self._report(hook, exc)
if handler_succeeded:
return handler_result
return handler(request)
async def _invoke_async(
self,
hook: str,
inner_hook: Callable[
[Any, Callable[[Any], Awaitable[Any]]],
Awaitable[Any],
],
request: Any,
handler: Callable[[Any], Awaitable[Any]],
) -> Any:
handler_called = False
handler_succeeded = False
handler_result: Any = None
handler_error: BaseException | None = None
handler_error_traceback: TracebackType | None = None
duplicate_call_error: RuntimeError | None = None
async def tracked_handler(inner_request: Any) -> Any:
nonlocal handler_called, duplicate_call_error
nonlocal handler_error, handler_error_traceback
nonlocal handler_result, handler_succeeded
if handler_called:
duplicate_call_error = RuntimeError(f"{type(self._inner).__name__}.{hook} called the downstream handler more than once")
raise duplicate_call_error
handler_called = True
handler_error = None
handler_error_traceback = None
handler_succeeded = False
try:
handler_result = await handler(request)
except BaseException as exc:
handler_error = exc
handler_error_traceback = exc.__traceback__
raise
else:
handler_succeeded = True
return handler_result
try:
await inner_hook(request, tracked_handler)
if handler_error is not None:
raise handler_error.with_traceback(handler_error_traceback)
if duplicate_call_error is not None:
raise duplicate_call_error
if not handler_called:
raise RuntimeError(f"{type(self._inner).__name__}.{hook} did not call the downstream handler")
return handler_result
except GraphBubbleUp as exc:
if handler_error is not None:
if handler_error is exc:
raise
raise handler_error.with_traceback(handler_error_traceback) from None
if handler_succeeded:
self._report(hook, duplicate_call_error or exc)
return handler_result
raise
except Exception as exc:
if handler_error is not None:
if handler_error is exc:
raise
raise handler_error.with_traceback(handler_error_traceback) from None
self._report(hook, exc)
if handler_succeeded:
return handler_result
return await handler(request)
def _invoke_lifecycle_sync(self, hook: str, state: Any, runtime: Any) -> Any:
"""Lifecycle hooks have no handler to fall through to: the fail-open
degradation for a failed observation is applying no state update."""
try:
return getattr(self._inner, hook)(state, runtime)
except GraphBubbleUp:
raise
except Exception as exc:
self._report(hook, exc)
return None
async def _invoke_lifecycle_async(self, hook: str, state: Any, runtime: Any) -> Any:
try:
return await getattr(self._inner, hook)(state, runtime)
except GraphBubbleUp:
raise
except Exception as exc:
self._report(hook, exc)
return None