deer-flow/backend/tests/test_tool_provenance.py
Zeren Wang 82671596f3
feat(authz): add plugin resource targets, provenance, action and management enforcement (#5842)
* 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.
2026-09-25 14:46:12 +08:00

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"}