mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-11-05 10:10:36 +00:00
* feat(authz): add plugin resource targets, provenance, action and management enforcement
PR1 of the extension-tool-authorization series
(docs/plans/2026-09-24-extension-tool-authorization-spec.md): plugin
authorization plumbing, no new author-facing declaration.
- `authz/plugin_targets.py` is the single encoder/validator for composite
targets (`namespace/part`), with one validating composer behind three
kind-checked constructors; invalid input raises instead of degrading.
- `authz/plugin_authz.py` is the harness decision layer (`plugin_action`,
`plugin_management`, plus the batch filters the page slice will consume):
disabled is a no-op, an explicit deny raises, and provider errors follow
`fail_closed` like the sandbox gate.
- `POST /api/plugins/{ns}/actions/{name}` decides `plugin_action:invoke`
after the action resolves and before the body is streamed.
- `deerflow_extension_api.auth` publishes `require_plugin_management` /
`arequire_plugin_management` with two `app.state` resolver keys; the
Gateway installs both. Contract version 0.2.3 -> 0.2.4.
- `tools/tool_provenance.py` records host-written plugin provenance for
display (assembly descriptor, Layer-2 context later); no decision reads a
label.
- Plugin providers use a `(config signature, loop key)` cache so a
loop-affine provider is never handed to another loop or to a sync caller.
- RBAC gains `plugin_actions` / `plugin_management` aliases;
`config_version` 47 -> 48 across the three mirrors.
Tests: new plugin target/provenance/action/management suites plus a
blocking-IO anchor whose offloads were reverted to prove red->green.
* fix(authz): decide plugin resources from the request's config snapshot
Review round 1 on PR #5842 (willem-bd).
P1 — the decision layer no longer reloads the configuration. The six
`authz/plugin_authz.py` functions take a required `app_config` snapshot (the
same request-scoped config the Gateway used to resolve the provider), and
`resolve_/aresolve_plugin_authorization` return it as a third element, so
`enabled`/`fail_closed` can never come from a second read that drifted from
the one that produced the provider. A snapshot the caller could not read at
all is *unavailable*, not disabled: it fails closed as
`authz.config_unavailable`. The Gateway's `_plugin_app_config` now separates
an absent `config.yaml` (no gate, today's behavior) from a config that exists
but cannot be read or validated (deny) instead of swallowing both through
`safe_app_config`.
P2 — `_validated_decision` requires `type(decision.allow) is bool` on both
the sync `authorize` and async `aauthorize` paths, so a provider returning
`AuthzDecision(allow="false")` is a malformed verdict that follows
`fail_closed` instead of being read as an allow.
Both fixes are covered by regression tests that were verified red against the
pre-fix behavior (P1: 200 vs 403 and resolver True vs False; P2: 12 cases
across fail_closed/fail_open and sync/async), plus tests pinning that an
absent config and `authorization.enabled: false` keep today's behavior.
* fix(authz): treat a request-time config absence as unavailable
Review round 2 on PR #5842 (willem-bd).
P1 — `_plugin_app_config[_async]` no longer maps a missing `config.yaml` to
`None` ("no gate"). A Gateway cannot have started without a readable config
(`app/gateway/app.py` reads it through this same accessor in `lifespan`), so
absence *during a request* means the configuration this process was running on
became unavailable — a hot reload, an atomic replace, a ConfigMap remount. It is
unavailable, not disabled: the error propagates,
`resolve_/aresolve_plugin_authorization` raise
`_PluginAuthorizationUnavailable(fail_closed=True)`, the action dispatcher
returns 403 and the management resolvers answer `False`. The dead
`(None, None, None)` allow path is gone, the resolvers return a non-optional
`AppConfig` as their third element, and callers key "authorization disabled" on
`provider is None` alone. This matches the rule the decision layer already
documented (`authz.config_unavailable`) and the public docs.
P2 — `_deny_reason_code` is total: a `reasons` that is not iterable degrades the
code to `authz.denied` instead of raising `TypeError` outside the guarded
provider path, which turned a denial into an unhandled error. It is deliberately
not folded into `_validated_decision`: `allow` was a real `bool`, so the verdict
is a valid denial, and treating it as malformed would flip it to an allow under
`fail_closed: false`.
Both fixes are covered by regression tests verified red against the pre-fix
behavior (P1: resolver True vs False, async `PermissionError` not raised, route
200 vs 403; P2: `TypeError: 'NoneType' object is not iterable` at
`plugin_authz.py:113`). The action-route tests in
`tests/test_plugin_contributions.py` now install a readable
`authorization.enabled: false` config instead of relying on a config-less
process, and `docs/full-stack-plugins.md` plus the PR1 decision log record the
new rule.
* fix(authz): apply the running policy when the plugin config is unreadable
Follow-up to f03d2ea9 (review round 2 on PR #5842, willem-bd).
The first attempt read the P1 finding as "absence is never disabled" and failed
every config read closed unconditionally. That is too broad for this repo: a
config-less host is deliberately treated as having no policy to apply
(`_get_route_authorization_config` falls back to a disabled config,
`sandbox_authz.safe_app_config` documents the same no-op for "CI runners and
direct-call tests"), and the rule turned the config-less e2e plugin host
(`extension_test_fixtures.bookmark_plugin_gateway`) into a 403 — the three
`bookmark-plugin.spec.ts` cases lost the save action. Reproduced locally:
`POST /api/plugins/community.bookmarks/actions/save` against that fixture host
returned 403 before, 200 after this change.
The rule is now "has this process loaded a configuration?", not "was the last
read successful?":
- No loaded config (`peek_loaded_app_config()`, new read-only accessor on
`deerflow.config.app_config`) and no `config.yaml`: no policy to apply, so the
gate stays a no-op — unchanged for CI runners, direct-call tests and hosts
that mount only the plugins router.
- A loaded config whose file disappears or cannot be read: *unavailable*, never
*disabled*. The resolvers raise `_PluginAuthorizationUnavailable` carrying the
failure flag of the configuration this process runs on (authorization
disabled there -> no gate; enabled -> that policy's `fail_closed`, `True` by
default), so an enabled Gateway answers 403/False in the window instead of
letting protected plugin actions and management routes through.
- A config file that exists but cannot be read or validated right now: fail
closed when nothing is loaded (round 1's rule), otherwise the loaded policy
decides.
`peek_loaded_app_config()` is preferred over a Gateway-side startup snapshot: it
reflects the last successfully loaded config, so a hot-reloaded policy is what
applies during the window, and there is no lifecycle hook to keep in sync.
Evidence: the three no-config/lost-config branches in
`tests/test_plugin_management_guard.py` and
`tests/test_plugin_action_authorization.py`, round 1's unreadable-config tests
unchanged, `test_peek_loaded_app_config_survives_a_missing_file` in
`tests/test_app_config_reload.py`, and the fixture-host probe above. Docs
(`docs/full-stack-plugins.md`, `reference.mdx` en/zh) and the PR1 decision log
state the final rule; the log records the superseded variant.
* fix(authz): keep a denial whose reasons raise while being read
* fix(authz): intersect plugin batch filters with the candidates
The plugin_page/plugin_management batch helpers returned the provider's
`filter_resources` answer verbatim, so a custom provider could hand the
projections a target that was never asked about ("*", another namespace,
a phantom page). Every other filter call site re-projects from its own
candidate list; do the same here, state the intersection on the provider
contract, and pin it with a regression test that fails on the old
pass-through.
* docs(plugins): map a management denial to 403 in the route example
The contributed-route example called `arequire_plugin_management` and let
the documented `PermissionError` escape. The Gateway installs no
`PermissionError` handler, so a route copied from the example answered 500
on a normal policy denial instead of 403. Both example routes now translate
the denial, and the surrounding bullets say why the translation is the
route's job.
* docs(extensions): point the 0.2.4 contract row at the PR that ships it
Every other row in the version-history table links the pull request that
shipped the version; 0.2.4 linked the authorization tracking issue instead.
Both the English and Chinese tables now link this PR.
219 lines
8.5 KiB
Python
219 lines
8.5 KiB
Python
"""Host-recorded tool provenance.
|
|
|
|
Two consumers with different trust needs share this module:
|
|
|
|
* **Display attribution** — which component a tool came from, for assembly
|
|
descriptors, traces and enterprise context. Best effort and consistency
|
|
checked; a provenance label is **never** a trust input.
|
|
* **Exemption identity** — the one host decision that needs certainty (the
|
|
Layer-2 infrastructure-tool exemption) compares the concrete bound tool
|
|
object, not a label. That reference travels on
|
|
``GuardrailRequest.tool_identity``; this module decides nothing.
|
|
|
|
Only two sources write a tag, both host-written at construction time: MCP tools
|
|
(:mod:`deerflow.tools.mcp_metadata`, written by the loader) and plugin tools
|
|
(:func:`tag_plugin_tool`, written by ``deerflow.extensions.plugin_tools``).
|
|
Every other source keeps a heuristic label computed here, and the resolution
|
|
order is part of the contract:
|
|
|
|
``mcp flag`` → ``plugin tag`` → declared ``deerflow_tool_source`` → module
|
|
heuristic → ``builtin`` when the callable exposes no module.
|
|
|
|
Some host-owned sources are labeled ``community`` by that heuristic
|
|
(task-continuity tools, project-shelf tools, config- and middleware-declared
|
|
tools); those labels are display-only and unchanged.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import re
|
|
from collections.abc import Mapping
|
|
from dataclasses import dataclass
|
|
from typing import Any
|
|
|
|
from deerflow.tools.mcp_metadata import get_mcp_source, is_mcp_tool
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
PLUGIN_TOOL_METADATA_KEY = "deerflow_plugin"
|
|
PLUGIN_TOOL_SOURCE_METADATA_KEY = "deerflow_plugin_source"
|
|
DECLARED_TOOL_SOURCE_METADATA_KEY = "deerflow_tool_source"
|
|
|
|
# Mirrors the host plugin-namespace registration rule
|
|
# (deerflow.config.plugin_settings). Independent copy on purpose: this module is
|
|
# a leaf that must not import the extension registry, and
|
|
# tests/test_plugin_targets.py asserts all three copies agree on one sample set.
|
|
_NAMESPACE_PATTERN = r"[a-z][a-z0-9_.-]{0,95}"
|
|
_NAMESPACE_RE = re.compile(_NAMESPACE_PATTERN)
|
|
|
|
# Order of the optional fields in the plain-mapping form handed to enterprise
|
|
# consumers (GuardrailRequest.tool_provenance, AuthzRequest.context).
|
|
_OPTIONAL_CONTEXT_FIELDS = ("namespace", "declaration", "installation", "operation", "mcp_server", "mcp_transport")
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class ToolProvenance:
|
|
"""Where a bound tool came from, for display and enterprise context."""
|
|
|
|
source: str
|
|
"""``"plugin:<namespace>"`` | ``"mcp:<server>"`` | ``"builtin"`` | ``"skill"`` | ``"community"``."""
|
|
|
|
namespace: str | None = None
|
|
declaration: str | None = None
|
|
"""The plugin's raw ``ModelTool.name`` (not the generated bound tool name)."""
|
|
|
|
installation: str | None = None
|
|
"""The contributing ``ExtensionSpec`` source string."""
|
|
|
|
operation: str | None = None
|
|
"""The plugin's declared shared operation, when it declared one."""
|
|
|
|
mcp_server: str | None = None
|
|
mcp_transport: str | None = None
|
|
|
|
|
|
def tag_plugin_tool(
|
|
tool: object,
|
|
*,
|
|
namespace: str,
|
|
declaration: str,
|
|
installation: str,
|
|
operation: str | None = None,
|
|
) -> None:
|
|
"""Record host-side plugin provenance on ``tool`` (mutates in place).
|
|
|
|
Only the host calls this, at plugin-tool construction, with values the
|
|
registry already validated. ``ModelTool`` carries no metadata field, so a
|
|
plugin cannot inject a ``deerflow_*`` tag through its declaration.
|
|
"""
|
|
source: dict[str, Any] = {
|
|
"namespace": namespace,
|
|
"declaration": declaration,
|
|
"installation": installation,
|
|
}
|
|
if operation is not None:
|
|
source["operation"] = operation
|
|
tool.metadata = {
|
|
**(getattr(tool, "metadata", None) or {}),
|
|
PLUGIN_TOOL_METADATA_KEY: True,
|
|
PLUGIN_TOOL_SOURCE_METADATA_KEY: source,
|
|
}
|
|
|
|
|
|
def is_plugin_tool(tool: object) -> bool:
|
|
"""True when ``tool`` carries the plugin tag written by :func:`tag_plugin_tool`."""
|
|
metadata = getattr(tool, "metadata", None)
|
|
return isinstance(metadata, Mapping) and metadata.get(PLUGIN_TOOL_METADATA_KEY) is True
|
|
|
|
|
|
def get_plugin_source(tool: object) -> dict[str, Any] | None:
|
|
"""Return the structurally valid plugin tag, or ``None``.
|
|
|
|
Valid means the flag is set, every required field is a non-empty string, the
|
|
namespace matches the host charset, and ``operation`` is a non-empty string
|
|
when present. The tag's *name consistency* with the tool is checked by
|
|
:func:`resolve_tool_provenance`, which owns the ``tool.name`` comparison.
|
|
"""
|
|
metadata = getattr(tool, "metadata", None)
|
|
if not isinstance(metadata, Mapping) or metadata.get(PLUGIN_TOOL_METADATA_KEY) is not True:
|
|
return None
|
|
source = metadata.get(PLUGIN_TOOL_SOURCE_METADATA_KEY)
|
|
if not isinstance(source, Mapping):
|
|
return None
|
|
namespace = source.get("namespace")
|
|
declaration = source.get("declaration")
|
|
installation = source.get("installation")
|
|
if not all(isinstance(value, str) and value for value in (namespace, declaration, installation)):
|
|
return None
|
|
if not _NAMESPACE_RE.fullmatch(namespace):
|
|
return None
|
|
resolved: dict[str, Any] = {
|
|
"namespace": namespace,
|
|
"declaration": declaration,
|
|
"installation": installation,
|
|
}
|
|
operation = source.get("operation")
|
|
if operation is not None:
|
|
if not isinstance(operation, str) or not operation:
|
|
return None
|
|
resolved["operation"] = operation
|
|
return resolved
|
|
|
|
|
|
def _callable_module(tool: object) -> str:
|
|
callable_object = getattr(tool, "func", None) or getattr(tool, "coroutine", None)
|
|
return getattr(callable_object, "__module__", "") or ""
|
|
|
|
|
|
def resolve_tool_provenance(tool: object | None) -> ToolProvenance | None:
|
|
"""Resolve *tool*'s provenance, or ``None`` when there is no tool at all.
|
|
|
|
Every tool without a tag still resolves to a label, so a label lookup is
|
|
never ``None`` for a real tool.
|
|
"""
|
|
if tool is None:
|
|
return None
|
|
|
|
if is_mcp_tool(tool):
|
|
source = get_mcp_source(tool)
|
|
if source is None:
|
|
return ToolProvenance(source="mcp:unknown")
|
|
return ToolProvenance(
|
|
source=f"mcp:{source['server_name']}",
|
|
mcp_server=source["server_name"],
|
|
mcp_transport=source["transport"],
|
|
)
|
|
|
|
plugin = get_plugin_source(tool)
|
|
if plugin is not None:
|
|
# Local import: plugin_tools tags its tools through this module, so a
|
|
# module-level import here would be a cycle. The consistency check runs
|
|
# only for tagged tools, which are host-built plugin tools.
|
|
from deerflow.extensions.plugin_tools import plugin_tool_name
|
|
|
|
if getattr(tool, "name", None) == plugin_tool_name(plugin["namespace"], plugin["declaration"]):
|
|
return ToolProvenance(
|
|
source=f"plugin:{plugin['namespace']}",
|
|
namespace=plugin["namespace"],
|
|
declaration=plugin["declaration"],
|
|
installation=plugin["installation"],
|
|
operation=plugin.get("operation"),
|
|
)
|
|
# A mis-attributed tag is dropped rather than mislabeling the tool. This
|
|
# is a consistency check, not an authenticity check: code able to forge
|
|
# the metadata can forge the name with it. It exists so a tag that does
|
|
# not describe *this* tool never becomes a label.
|
|
logger.warning(
|
|
"Dropping inconsistent plugin provenance tag on tool %r (declared %s/%s)",
|
|
getattr(tool, "name", tool),
|
|
plugin["namespace"],
|
|
plugin["declaration"],
|
|
)
|
|
|
|
metadata = getattr(tool, "metadata", None)
|
|
if isinstance(metadata, Mapping):
|
|
declared = metadata.get(DECLARED_TOOL_SOURCE_METADATA_KEY)
|
|
if isinstance(declared, str) and declared:
|
|
return ToolProvenance(source=declared)
|
|
|
|
module = _callable_module(tool)
|
|
if module.startswith("deerflow.tools.builtins") or module.startswith("deerflow.agents.memory"):
|
|
return ToolProvenance(source="builtin")
|
|
if "skill" in module:
|
|
return ToolProvenance(source="skill")
|
|
return ToolProvenance(source="community" if module else "builtin")
|
|
|
|
|
|
def tool_provenance_context(tool: object | None) -> dict[str, Any]:
|
|
"""Plain-mapping form of :func:`resolve_tool_provenance` for request contexts."""
|
|
provenance = resolve_tool_provenance(tool)
|
|
if provenance is None:
|
|
return {}
|
|
context: dict[str, Any] = {"source": provenance.source}
|
|
for field in _OPTIONAL_CONTEXT_FIELDS:
|
|
value = getattr(provenance, field)
|
|
if value is not None:
|
|
context[field] = value
|
|
return context
|