mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 21:26:39 +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.
205 lines
7.3 KiB
Python
205 lines
7.3 KiB
Python
"""Tool provenance: display attribution only, with a fixed precedence.
|
|
|
|
The plugin tag is written by the host at construction; every other source keeps
|
|
the heuristic label it had. Nothing here is a trust input — the one decision
|
|
that needs certainty (the Layer-2 infrastructure exemption) compares object
|
|
identity, not a label.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
|
|
import pytest
|
|
from langchain_core.tools import Tool
|
|
|
|
from deerflow.agents.assembly_descriptor import describe_tool
|
|
from deerflow.extensions.plugin_tools import plugin_tool_name
|
|
from deerflow.tools.mcp_metadata import MCP_TOOL_METADATA_KEY, MCP_TOOL_SOURCE_METADATA_KEY
|
|
from deerflow.tools.tool_provenance import (
|
|
PLUGIN_TOOL_METADATA_KEY,
|
|
PLUGIN_TOOL_SOURCE_METADATA_KEY,
|
|
get_plugin_source,
|
|
is_plugin_tool,
|
|
resolve_tool_provenance,
|
|
tag_plugin_tool,
|
|
tool_provenance_context,
|
|
)
|
|
|
|
NAMESPACE = "acme.reports"
|
|
INSTALLATION = "acme.plugin:install"
|
|
|
|
|
|
def _tool(module: str = "some.vendor.tools", name: str = "gadget") -> Tool:
|
|
def func(**_kwargs):
|
|
return "ok"
|
|
|
|
func.__module__ = module
|
|
return Tool(name=name, description="A tool", func=func)
|
|
|
|
|
|
def _plugin_tool(namespace: str = NAMESPACE, declaration: str = "export", *, installation: str = INSTALLATION, operation: str | None = None) -> Tool:
|
|
tool = _tool(name=plugin_tool_name(namespace, declaration))
|
|
tag_plugin_tool(tool, namespace=namespace, declaration=declaration, installation=installation, operation=operation)
|
|
return tool
|
|
|
|
|
|
# --- Tag round trip ------------------------------------------------------------
|
|
|
|
|
|
def test_plugin_tag_round_trips_into_provenance_and_context():
|
|
tool = _plugin_tool()
|
|
|
|
assert is_plugin_tool(tool) is True
|
|
provenance = resolve_tool_provenance(tool)
|
|
assert provenance is not None
|
|
assert provenance.source == f"plugin:{NAMESPACE}"
|
|
assert provenance.namespace == NAMESPACE
|
|
assert provenance.declaration == "export"
|
|
assert provenance.installation == INSTALLATION
|
|
assert provenance.operation is None
|
|
assert tool_provenance_context(tool) == {
|
|
"source": f"plugin:{NAMESPACE}",
|
|
"namespace": NAMESPACE,
|
|
"declaration": "export",
|
|
"installation": INSTALLATION,
|
|
}
|
|
|
|
|
|
def test_declared_shared_operation_is_carried_through():
|
|
tool = _plugin_tool(operation="acme.reports.export")
|
|
|
|
provenance = resolve_tool_provenance(tool)
|
|
assert provenance is not None and provenance.operation == "acme.reports.export"
|
|
assert tool_provenance_context(tool)["operation"] == "acme.reports.export"
|
|
|
|
|
|
def test_plugin_tag_is_read_by_the_assembly_descriptor():
|
|
"""The descriptor identity and the execution-time label cannot disagree."""
|
|
assert describe_tool(_plugin_tool()).source == f"plugin:{NAMESPACE}"
|
|
|
|
|
|
def test_tagging_preserves_unrelated_metadata():
|
|
tool = _tool()
|
|
tool.metadata = {"kept": "value"}
|
|
|
|
tag_plugin_tool(tool, namespace=NAMESPACE, declaration="export", installation=INSTALLATION)
|
|
|
|
assert tool.metadata["kept"] == "value"
|
|
assert tool.metadata[PLUGIN_TOOL_METADATA_KEY] is True
|
|
assert tool.metadata[PLUGIN_TOOL_SOURCE_METADATA_KEY]["declaration"] == "export"
|
|
|
|
|
|
# --- Consistency and precedence ------------------------------------------------
|
|
|
|
|
|
def test_mis_attributed_tag_is_dropped_with_a_warning(caplog: pytest.LogCaptureFixture):
|
|
"""A tag whose name is not this plugin's generated name never becomes a label."""
|
|
tool = _tool(name="gadget")
|
|
tag_plugin_tool(tool, namespace=NAMESPACE, declaration="export", installation=INSTALLATION)
|
|
|
|
with caplog.at_level(logging.WARNING):
|
|
provenance = resolve_tool_provenance(tool)
|
|
|
|
assert provenance is not None
|
|
assert provenance.source == "community" # the heuristic the tool would have had
|
|
assert provenance.namespace is None
|
|
assert "inconsistent plugin provenance tag" in caplog.text
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"source",
|
|
[
|
|
{"namespace": "BAD NAMESPACE", "declaration": "export", "installation": INSTALLATION},
|
|
{"namespace": NAMESPACE, "declaration": "", "installation": INSTALLATION},
|
|
{"namespace": NAMESPACE, "declaration": "export", "installation": None},
|
|
{"namespace": NAMESPACE, "declaration": "export", "installation": INSTALLATION, "operation": ""},
|
|
"not-a-mapping",
|
|
],
|
|
)
|
|
def test_structurally_invalid_tags_are_not_plugin_provenance(source):
|
|
tool = _plugin_tool()
|
|
tool.metadata = {**(tool.metadata or {}), PLUGIN_TOOL_SOURCE_METADATA_KEY: source}
|
|
|
|
assert is_plugin_tool(tool) is True
|
|
assert get_plugin_source(tool) is None
|
|
provenance = resolve_tool_provenance(tool)
|
|
assert provenance is not None and provenance.source == "community"
|
|
|
|
|
|
def test_mcp_flag_wins_over_a_plugin_tag():
|
|
"""MCP tools are tagged first in the documented precedence."""
|
|
tool = _plugin_tool()
|
|
tool.metadata = {
|
|
**(tool.metadata or {}),
|
|
MCP_TOOL_METADATA_KEY: True,
|
|
MCP_TOOL_SOURCE_METADATA_KEY: {"server_name": "filesystem", "transport": "stdio"},
|
|
}
|
|
|
|
provenance = resolve_tool_provenance(tool)
|
|
assert provenance is not None
|
|
assert provenance.source == "mcp:filesystem"
|
|
assert provenance.mcp_server == "filesystem"
|
|
assert provenance.mcp_transport == "stdio"
|
|
assert provenance.namespace is None
|
|
|
|
|
|
def test_an_mcp_flag_without_source_details_stays_labelled():
|
|
tool = _tool()
|
|
tool.metadata = {MCP_TOOL_METADATA_KEY: True}
|
|
|
|
provenance = resolve_tool_provenance(tool)
|
|
assert provenance is not None and provenance.source == "mcp:unknown"
|
|
|
|
|
|
def test_declared_tool_source_is_honoured_for_an_untagged_tool():
|
|
"""Out-of-repo tool builders keep their declaration-controlled display label."""
|
|
tool = _tool()
|
|
tool.metadata = {"deerflow_tool_source": "custom-vendor"}
|
|
|
|
provenance = resolve_tool_provenance(tool)
|
|
assert provenance is not None and provenance.source == "custom-vendor"
|
|
|
|
|
|
def test_plugin_tag_is_checked_before_a_declared_source():
|
|
tool = _plugin_tool()
|
|
tool.metadata = {**(tool.metadata or {}), "deerflow_tool_source": "custom-vendor"}
|
|
|
|
provenance = resolve_tool_provenance(tool)
|
|
assert provenance is not None and provenance.source == f"plugin:{NAMESPACE}"
|
|
|
|
|
|
# --- Heuristic fallbacks (unchanged display labels) -----------------------------
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"module,expected",
|
|
[
|
|
("deerflow.tools.builtins.web_search", "builtin"),
|
|
("deerflow.agents.memory.manager", "builtin"),
|
|
("deerflow.skills.loader", "skill"),
|
|
("some.vendor.tools", "community"),
|
|
("", "builtin"), # no usable __module__ on the callable
|
|
],
|
|
)
|
|
def test_module_heuristic_labels_are_unchanged(module: str, expected: str):
|
|
provenance = resolve_tool_provenance(_tool(module=module))
|
|
assert provenance is not None and provenance.source == expected
|
|
|
|
|
|
def test_every_real_tool_resolves_to_a_label():
|
|
"""An untagged source can never turn a label lookup into ``None``."""
|
|
for tool in (_tool(), _tool(module=""), _plugin_tool()):
|
|
provenance = resolve_tool_provenance(tool)
|
|
assert provenance is not None and provenance.source
|
|
|
|
|
|
def test_missing_tool_resolves_to_nothing():
|
|
assert resolve_tool_provenance(None) is None
|
|
assert tool_provenance_context(None) == {}
|
|
|
|
|
|
def test_context_form_omits_absent_optional_fields():
|
|
context = tool_provenance_context(_tool(module="deerflow.tools.builtins.web_search"))
|
|
assert context == {"source": "builtin"}
|