mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 13:16:24 +00:00
* feat(authz): enforce skill authorization at assembly and slash-activation (#4063 Phase 3) Phase 3 / Skills — the second of three resource-type PRs (Models, Skills, Sandbox). The RBAC provider already maps "skill" → config key "skills" (rbac.py _RESOURCE_POLICY_KEYS), so no schema change is needed. Layer 1 (assembly-time, mirrors Phase 1B): - filter_available_skills_by_authorization() in skill_filter.py filters the skill-name allowlist (set[str] | None) by the provider's "skill" policy. In lead agent, runs after _available_skill_names() so the catalog (describe_skill) and SkillActivationMiddleware share one filtered set. In subagent executor _load_skills(), filters config.skills before disk load. - filter_resources_by_authorization() in enforcement.py — generic version of filter_tools_by_authorization for any resource with a name attribute. Layer 2 (slash-activation): - No new middleware code needed. SkillActivationMiddleware._resolve_activation already checks "reference.name not in self._available_skills". Since Layer 1 filters the allowlist, denied skills cannot be slash-activated. authorization.enabled: false is a complete no-op. available_skills=None (no agent allowlist) + enabled: resolves all skill names from config and filters. 12 new tests + 243 existing authz/skills tests pass. * test(subagents): wait on the chained future instead of polling done() in caller-loop teardown test * feat(authz): enforce action-scoped skill:activate at runtime and reuse the catalog loader for filter candidates * feat(authz): arm skill:activate on the subagent chain, unify the client skill-surface user, and record the Layer 2 reversal in the design notes * chore(authz): align the subagent filter's fallback user with the chain's bucket convention * feat(authz): gate autonomous skill loading on skill:activate, close the bootstrap assembly leak, and reuse one provider per subagent assembly Review round 4 (willem-bd, CHANGES_REQUESTED): - [P1] Autonomous load path: describe_skill filters its match set on the action-scoped skill:activate decision (all-denied is indistinguishable from "no match"), and ToolErrorHandlingMiddleware stamps skill_context entries only when the action allows it — a denied SKILL.md read gets a skill_context_denied marker instead, so durable context, allowed-tools policy, and autonomous secret bindings never activate a denied skill. File visibility itself stays Layer 1 (sandbox-projection semantics). - [P1] Bootstrap assembly: build_middlewares/apply_prompt_template now receive the authorization-filtered set instead of the raw _BOOTSTRAP_SKILL_NAMES, and an empty skill_names index is preserved when deferred discovery is on rather than round-tripping through None into the legacy full-metadata prompt that re-advertises a denied skill (deferred-off keeps the legacy rendering; the filtered empty allowlist blanks it on denial). - [P2] SubagentExecutor resolves ResolvedSkillAuthorization once per assembly (_resolve_skill_authorization, memoized per executor) and shares it across the Layer 1 filter (authorization=), the activation middleware, and both new gates — uncached provider factories can no longer hand the layers different instances/policy snapshots. All gates share one helper (skill_filter.skill_activation_allowed) so slash, describe, and skill-file-load cannot drift; provider errors keep the configured fail-closed/fail-open policy. skill_authorization is threaded through _build_runtime_middlewares into both runtime builders. Tests: describe gating (deny/fail-closed/fail-open/disabled), read stamping (denied marker vs entry) + extract_skills skip without warning, bootstrap three-way param (allow/deny x deferred on/off), lead + subagent chain wiring for the stamp gate, and the distinct-instance provider factory repro. Each fix is mutation-verified (reverting it fails its test). Full A/B suite run: no new failures vs HEAD. * feat(authz): re-authorize persisted skill_context before reuse and route async paths through aauthorize Review round 5 (willem-bd): - [P1] Persisted entries: skill_context survives across runs, so both consumers now re-check the action-scoped skill:activate decision before reuse. SkillToolPolicyMiddleware._active_skills_for_paths skips a denied skill (same skip as a disabled/unallowlisted one; all-denied keeps the existing fail-closed-to-builtins semantics) and _in_context_secret_sources binds no secrets for a denied entry. skill_authorization is threaded into SkillToolPolicyMiddleware on both chains. - [P2] Async provider API: skill_activation_allowed_async mirrors the sync helper through await provider.aauthorize() (same pattern as authorize_sandbox_execution_async). Async hooks precompute an activation_decisions map on the event loop (slash target + persisted entry names, one batch of awaits) and hand it to the worker-thread handler, which consults the map instead of the sync API: SkillActivationMiddleware.awrap_model_call (activation + secret binding), ToolErrorHandlingMiddleware.awrap_tool_call (_amaybe_stamp awaits the decision before stamping), and SkillToolPolicyMiddleware's awrap_model_call/awrap_tool_call (entry names + slash source name). describe_skill becomes a StructuredTool with both a sync function (invoke -> authorize) and a coroutine (ainvoke -> aauthorize), so async agent execution never routes a loop-affine provider through the sync API while sync callers keep their semantics. Tests: allow-read-then-deny-next-run regressions for secret binding and allowed-tools (partial deny does not poison, all-denied fails closed to builtins), and an _AsyncOnlyProvider (sync authorize always fails, aauthorize works) driving four async-path assertions (activation, stamp allow/deny, describe, tool policy). All six fixes are mutation-verified; full A/B suite run against HEAD shows no new failures. * fix(authz): authorize skill activation by declared Skill.name, not directory basename ShenAC-SAC's P2: bundled skills may declare a name that differs from their directory (skills/public/vercel-deploy-claimable declares name: vercel-deploy), but the runtime gates derived authorization targets from the container path. - New deerflow/skills/container_registry.py: build_container_path_registry() + canonical_skill_name() — the one path-to-Skill.name resolution shared by the activation, tool-policy, and read-stamping middlewares. - ToolErrorHandlingMiddleware resolves the read path through the (user-scoped, via new user_id kwarg threaded from both runtime builders) registry before the skill:activate decision, sync and async (registry lookup in a worker thread, aauthorize() on the loop). RBAC allow by the declared name now activates; allow by the directory name does not. - SkillToolPolicyMiddleware async hooks canonicalize the policy paths first (three-phase: thread registry -> loop aauthorize -> thread filter reusing the registry; the cached per-model-step decision still short-circuits), so the decision map is keyed by the names _active_skills_for_paths checks — no more map miss falling back to the sync provider API from the thread. _entry_names removed: persisted entry["name"] is path-derived by construction; entries are resolved by path instead. - SkillActivationMiddleware collects slash names (already canonical) plus persisted entry *paths*, canonicalized off-loop via _canonical_names_for_paths(). Regressions (test_skills_authorization.py 45->50): sync stamp target, RBAC allow declared-name/deny directory-name both directions, async stamp via _AsyncOnlyProvider, composed async slash -> tool-policy keeps the skill's tools with zero sync fallback, persisted-entry secret binding survives the name mismatch. All three mutations (stamp target, policy map key, entry canonicalization) fail their tests. * fix(authz): close the sync-fallback class on skill activation paths willem-bd's two P2s on the canonical-name fix, plus a fourth instance of the same class found while sweeping the membership: - SkillToolPolicyMiddleware: a transient prepass registry failure is now preserved (_REGISTRY_LOAD_FAILED marker) — the worker-side filter applies its own fail-closed treatment instead of silently retrying storage, which recovered skills absent from the decision map and fell back to the synchronous provider API from the thread (fail-open retaining a denied skill's allowed-tools). - SkillActivationMiddleware: an empty entry-path list no longer pays a full skill-tree scan on ordinary authorization-enabled async model steps (previously a passive path with zero registry I/O). - 4th instance (reproduced with the PR fixtures, then fixed): the entry prepass failure on the secret-binding path let the consumer-side _load_skill_registry_by_path silently recover and resolve entry names outside the decision map — the same sync fallback, fail-open binding the aauthorize-denied skill's declared secret. The fix goes one step further than failure propagation: the prepass hands down the registry *snapshot* the decision map was keyed by, and entry sources resolve against that snapshot, so a map miss is impossible by construction (this also closes the narrower window where both loads succeed but disagree because storage changed mid-step). The slash source is deliberately unaffected: it does not consult the decision map (validated once at activation) and keeps its fresh per-call load. - Class membership swept: skill_activation_allowed() sync call sites are exactly four (stamping sync path and describe's sync implementation are legitimately sync); the only two async-reachable fallback points (the _activation_allowed helpers) are both construction-guarded now. Structural hardening (typed async-batch decision object where a map miss is fail-closed by construction) recorded in the plan notes as a follow-up, anchored at those two helpers. - _RegistryArg type alias replaces the degenerate dict|object|None union in the policy middleware signatures. Tests (test_skills_authorization.py 50->54): transient prepass failure -> builtins-only with exactly one storage attempt and zero provider calls; ordinary async step -> zero registry scans; two-call scenario -> entry binding zeroed while the slash binding survives; entries resolve against the prepass snapshot under a mid-step rename. Mutations verified red: marker reverted to None, over-broad slash kill, snapshot bypass. * fix(authz): correct secret-binding reference points and gate the rendered skill reminder Review round 9 (willem-bd) plus construction-audit follow-ups on the same class — every async divergence between two loads of the same data now has an explicit, pinned reference point: - Slash secret source resolves from a FRESH post-activation registry: the prepass snapshot is taken before the aauthorize awaits, so binding the slash source from it would activate NEW_KEY content while injecting OLD_KEY when a skill's declared secrets change in that window. The 'one scan per step' bonus that motivated sharing the snapshot is withdrawn — consistency wins. The two secret sources resolve independently: a transient fresh-load failure zeroes only the slash binding; entries keep binding from their own snapshot. - Same-skill dominance: when a skill is both the slash source (fresh) and a persisted entry (snapshot), only the activation-era declaration binds — unioning both eras injected a secret the skill no longer declares. Aligns with the tool-policy rule (slash activation dominates for the run); different-skill entries still stack additively. - The rendered 'Active skills' reminder (DurableContextMiddleware) no longer advertises skills the provider denies — surface consistency with describe_skill. Publication/consumption split: the activation middleware publishes per-step path-keyed skill:activate decisions into the run context (async: reused from the prepass snapshot, zero extra I/O; sync: one scan + authorize() only when entries exist — passive steps stay scan-free), the renderer consumes them and filters its render copy (state untouched; unpublished = permissive, matching the other run-context carriers). New carrier key listed in REDACTED_CONTEXT_KEYS with a redaction regression. - Wiring regressions on both chains for everything added above (user_id to the stamp gate, skill_authorization + token pairing + activation- before-durable ordering to the durable renderer): each mutation verified red (M4-M13 across this change set). - Tests 54->65: slash post-activation lookup, source independence, dominance, sync-chain sync-API path, render filter (async/sync/ disabled with load-count assertions), chain wiring x2, redaction listing. Plan notes record rounds 9-2..6 including the reference-point post-mortem and the audit axes exhausted. * fix(authz): anchor same-skill dominance on the slash identity, not binding results Review round 10 (willem-bd) plus a self-audit extension the new checklist items caught in the first fix itself: - R10 (P2): the same-skill exclusion was derived from successfully BOUND slash sources. When the prepass snapshots required-secrets [OLD_KEY] and the operator strips the declaration during the await window, the slash source binds nothing (required_secrets gate) — the exclusion set stayed empty and the stale entry view kept injecting OLD_KEY for a skill that now declares no secrets. The exclusion now derives from the AUTHENTICATED slash activation identity (run-context source path, owner-token checked): the identity's declared name resolves from the fresh registry first, then the entry snapshot, and joins the exclusion set regardless of binding success. - Self-audit (value->gate->downstream on the fix itself): the declared name can also change mid-run (same path, foo->bar). A name-anchored exclusion then holds only the new name while the snapshot entry resolves the same path to the old one — both eras bound again. The PATH is the stable identity shared by the activation and every earlier read: entries at the slash identity's path are now excluded before registry resolution (exclude_paths), independent of what either version calls the skill; the name-based set remains for cross-path same-name shadowing. Tests 65->67: no-secrets-era dominance (ACTIVE secrets None under the OLD_KEY-to-empty interleaving), path-anchored dominance across a mid-run rename (only the activation-era key binds). Mutations verified red: identity-derived exclusion disabled (M14), path exclusion disabled (M16). * test(authz): pass explicit config to lead middleware wiring test --------- Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
475 lines
20 KiB
Python
475 lines
20 KiB
Python
"""Tests for Phase 1B tool authorization enforcement."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
import pytest
|
|
from langchain_core.tools import StructuredTool
|
|
|
|
from deerflow.agents.lead_agent import agent as lead_agent_module
|
|
from deerflow.agents.middlewares.tool_error_handling_middleware import (
|
|
build_lead_runtime_middlewares,
|
|
build_subagent_runtime_middlewares,
|
|
)
|
|
from deerflow.authz.adapter import GuardrailAuthorizationAdapter
|
|
from deerflow.authz.enforcement import filter_tools_by_authorization
|
|
from deerflow.authz.provider import AuthzDecision, AuthzReason, Principal
|
|
from deerflow.authz.rbac import RbacAuthorizationProvider
|
|
from deerflow.config.app_config import AppConfig
|
|
from deerflow.config.authorization_config import AuthorizationConfig, AuthorizationProviderConfig
|
|
from deerflow.config.guardrails_config import GuardrailProviderConfig, GuardrailsConfig
|
|
from deerflow.config.model_config import ModelConfig
|
|
from deerflow.config.sandbox_config import SandboxConfig
|
|
from deerflow.guardrails.middleware import GuardrailMiddleware
|
|
from deerflow.tools.builtins.tool_search import assemble_deferred_tools
|
|
from deerflow.tools.mcp_metadata import tag_mcp_tool
|
|
|
|
|
|
def _tool(name: str) -> StructuredTool:
|
|
return StructuredTool.from_function(lambda: name, name=name, description=name)
|
|
|
|
|
|
class _FilterProvider:
|
|
name = "filter"
|
|
|
|
def __init__(self, allowed: list[str]) -> None:
|
|
self.allowed = allowed
|
|
self.calls: list[tuple[Principal, str, list[str]]] = []
|
|
|
|
def authorize(self, request):
|
|
return AuthzDecision(allow=True)
|
|
|
|
async def aauthorize(self, request):
|
|
return self.authorize(request)
|
|
|
|
def filter_resources(self, principal: Principal, resource_type: str, candidates: list[str]) -> list[str]:
|
|
self.calls.append((principal, resource_type, candidates))
|
|
return [candidate for candidate in candidates if candidate in self.allowed]
|
|
|
|
|
|
class _ExplodingFilterProvider(_FilterProvider):
|
|
def __init__(self) -> None:
|
|
super().__init__([])
|
|
|
|
def filter_resources(self, principal: Principal, resource_type: str, candidates: list[str]) -> list[str]:
|
|
raise RuntimeError("provider failed")
|
|
|
|
|
|
def _app_config(
|
|
*,
|
|
authorization: AuthorizationConfig,
|
|
guardrails: GuardrailsConfig | None = None,
|
|
models: list[ModelConfig] | None = None,
|
|
) -> AppConfig:
|
|
return AppConfig(
|
|
models=models or [],
|
|
sandbox=SandboxConfig(use="test"),
|
|
authorization=authorization,
|
|
guardrails=guardrails or GuardrailsConfig(),
|
|
)
|
|
|
|
|
|
class TestAuthorizationToolFilter:
|
|
def test_keeps_only_provider_allowed_tools_and_preserves_input_order(self):
|
|
provider = _FilterProvider(["web_search", "read_file"])
|
|
tools = [_tool("bash"), _tool("web_search"), _tool("read_file")]
|
|
|
|
filtered = filter_tools_by_authorization(
|
|
tools,
|
|
provider=provider,
|
|
principal=Principal(role="user"),
|
|
fail_closed=True,
|
|
)
|
|
|
|
assert [tool.name for tool in filtered] == ["web_search", "read_file"]
|
|
assert provider.calls == [(Principal(role="user"), "tool", ["bash", "web_search", "read_file"])]
|
|
|
|
def test_provider_error_fails_closed_to_an_empty_tool_set(self):
|
|
tools = [_tool("bash"), _tool("web_search")]
|
|
|
|
filtered = filter_tools_by_authorization(
|
|
tools,
|
|
provider=_ExplodingFilterProvider(),
|
|
principal=Principal(role="user"),
|
|
fail_closed=True,
|
|
)
|
|
|
|
assert filtered == []
|
|
|
|
def test_provider_error_fails_open_only_when_configured(self):
|
|
tools = [_tool("bash"), _tool("web_search")]
|
|
|
|
filtered = filter_tools_by_authorization(
|
|
tools,
|
|
provider=_ExplodingFilterProvider(),
|
|
principal=Principal(role="user"),
|
|
fail_closed=False,
|
|
)
|
|
|
|
assert filtered == tools
|
|
|
|
@pytest.mark.parametrize("invalid_result", ["bash", ("bash",), ["bash", 1]])
|
|
def test_invalid_provider_result_fails_closed(self, invalid_result):
|
|
class _InvalidResultProvider(_FilterProvider):
|
|
def filter_resources(self, principal, resource_type, candidates):
|
|
return invalid_result
|
|
|
|
filtered = filter_tools_by_authorization(
|
|
[_tool("bash")],
|
|
provider=_InvalidResultProvider([]),
|
|
principal=Principal(role="user"),
|
|
fail_closed=True,
|
|
)
|
|
|
|
assert filtered == []
|
|
|
|
def test_provider_cannot_add_tools_outside_the_candidate_set(self):
|
|
class _InjectingProvider(_FilterProvider):
|
|
def filter_resources(self, principal, resource_type, candidates):
|
|
return [*candidates, "injected_tool"]
|
|
|
|
filtered = filter_tools_by_authorization(
|
|
[_tool("bash")],
|
|
provider=_InjectingProvider([]),
|
|
principal=Principal(role="user"),
|
|
fail_closed=True,
|
|
)
|
|
|
|
assert [tool.name for tool in filtered] == ["bash"]
|
|
|
|
|
|
class TestAuthorizationGuardrailWiring:
|
|
def test_deferred_tool_search_bypasses_layer_two_for_filtered_catalog(self):
|
|
provider = RbacAuthorizationProvider(roles={"guest": {"tools": {"allow": ["mcp_allowed"]}}})
|
|
filtered_tools = filter_tools_by_authorization(
|
|
[tag_mcp_tool(_tool("mcp_allowed"))],
|
|
provider=provider,
|
|
principal=Principal(role="guest"),
|
|
fail_closed=True,
|
|
)
|
|
_final_tools, deferred_setup = assemble_deferred_tools(filtered_tools, enabled=True)
|
|
config = _app_config(
|
|
authorization=AuthorizationConfig(
|
|
enabled=True,
|
|
default_role="guest",
|
|
provider=AuthorizationProviderConfig(use="unused:Provider"),
|
|
)
|
|
)
|
|
|
|
middlewares = build_lead_runtime_middlewares(
|
|
app_config=config,
|
|
authorization_provider=provider,
|
|
deferred_setup=deferred_setup,
|
|
)
|
|
authorization_middleware = next(middleware for middleware in middlewares if isinstance(middleware, GuardrailMiddleware) and isinstance(middleware.provider, GuardrailAuthorizationAdapter))
|
|
request = MagicMock()
|
|
request.tool_call = {
|
|
"name": "tool_search",
|
|
"args": {"query": "mcp_allowed"},
|
|
"id": "call-search",
|
|
}
|
|
request.runtime = SimpleNamespace(context={"user_role": "guest"})
|
|
expected = MagicMock()
|
|
handler = MagicMock(return_value=expected)
|
|
|
|
result = authorization_middleware.wrap_tool_call(request, handler)
|
|
|
|
assert result is expected
|
|
handler.assert_called_once_with(request)
|
|
|
|
def test_tool_search_without_deferred_catalog_is_not_exempt(self):
|
|
provider = RbacAuthorizationProvider(roles={"guest": {"tools": {"allow": ["web_search"]}}})
|
|
config = _app_config(
|
|
authorization=AuthorizationConfig(
|
|
enabled=True,
|
|
default_role="guest",
|
|
provider=AuthorizationProviderConfig(use="unused:Provider"),
|
|
)
|
|
)
|
|
middlewares = build_lead_runtime_middlewares(
|
|
app_config=config,
|
|
authorization_provider=provider,
|
|
)
|
|
authorization_middleware = next(middleware for middleware in middlewares if isinstance(middleware, GuardrailMiddleware) and isinstance(middleware.provider, GuardrailAuthorizationAdapter))
|
|
request = MagicMock()
|
|
request.tool_call = {"name": "tool_search", "args": {}, "id": "call-search"}
|
|
request.runtime = SimpleNamespace(context={"user_role": "guest"})
|
|
handler = MagicMock()
|
|
|
|
result = authorization_middleware.wrap_tool_call(request, handler)
|
|
|
|
assert result.status == "error"
|
|
handler.assert_not_called()
|
|
|
|
def test_subagent_deferred_tool_search_bypasses_layer_two_async(self):
|
|
provider = RbacAuthorizationProvider(roles={"guest": {"tools": {"allow": ["mcp_allowed"]}}})
|
|
filtered_tools = filter_tools_by_authorization(
|
|
[tag_mcp_tool(_tool("mcp_allowed"))],
|
|
provider=provider,
|
|
principal=Principal(role="guest"),
|
|
fail_closed=True,
|
|
)
|
|
_final_tools, deferred_setup = assemble_deferred_tools(filtered_tools, enabled=True)
|
|
config = _app_config(
|
|
authorization=AuthorizationConfig(
|
|
enabled=True,
|
|
default_role="guest",
|
|
provider=AuthorizationProviderConfig(use="unused:Provider"),
|
|
)
|
|
)
|
|
middlewares = build_subagent_runtime_middlewares(
|
|
app_config=config,
|
|
authorization_provider=provider,
|
|
deferred_setup=deferred_setup,
|
|
)
|
|
authorization_middleware = next(middleware for middleware in middlewares if isinstance(middleware, GuardrailMiddleware) and isinstance(middleware.provider, GuardrailAuthorizationAdapter))
|
|
request = MagicMock()
|
|
request.tool_call = {
|
|
"name": "tool_search",
|
|
"args": {"query": "mcp_allowed"},
|
|
"id": "call-search",
|
|
}
|
|
request.runtime = SimpleNamespace(context={"user_role": "guest"})
|
|
expected = MagicMock()
|
|
handler = AsyncMock(return_value=expected)
|
|
|
|
result = asyncio.run(authorization_middleware.awrap_tool_call(request, handler))
|
|
|
|
assert result is expected
|
|
handler.assert_awaited_once_with(request)
|
|
|
|
def test_authorization_wires_adapter_with_the_build_provider_instance(self):
|
|
provider = _FilterProvider(["bash"])
|
|
config = _app_config(
|
|
authorization=AuthorizationConfig(
|
|
enabled=True,
|
|
provider=AuthorizationProviderConfig(use="deerflow.authz.rbac:RbacAuthorizationProvider", config={"roles": {"user": {}}}),
|
|
)
|
|
)
|
|
|
|
middlewares = build_lead_runtime_middlewares(app_config=config, authorization_provider=provider)
|
|
authorization_middleware = next(middleware for middleware in middlewares if isinstance(middleware, GuardrailMiddleware) and isinstance(middleware.provider, GuardrailAuthorizationAdapter))
|
|
|
|
assert authorization_middleware.provider._provider is provider
|
|
assert authorization_middleware.fail_closed is True
|
|
|
|
def test_authorization_and_explicit_guardrail_both_run(self):
|
|
provider = _FilterProvider(["bash"])
|
|
config = _app_config(
|
|
authorization=AuthorizationConfig(
|
|
enabled=True,
|
|
provider=AuthorizationProviderConfig(use="deerflow.authz.rbac:RbacAuthorizationProvider", config={"roles": {"user": {}}}),
|
|
),
|
|
guardrails=GuardrailsConfig(
|
|
enabled=True,
|
|
provider=GuardrailProviderConfig(
|
|
use="deerflow.guardrails.builtin:AllowlistProvider",
|
|
config={"allowed_tools": ["bash"]},
|
|
),
|
|
),
|
|
)
|
|
|
|
middlewares = build_lead_runtime_middlewares(app_config=config, authorization_provider=provider)
|
|
guardrails = [middleware for middleware in middlewares if isinstance(middleware, GuardrailMiddleware)]
|
|
|
|
assert len(guardrails) == 2
|
|
assert isinstance(guardrails[0].provider, GuardrailAuthorizationAdapter)
|
|
assert guardrails[0].provider._provider is provider
|
|
assert type(guardrails[1].provider).__name__ == "AllowlistProvider"
|
|
|
|
def test_wired_authorization_middleware_denies_execution_with_runtime_principal(self):
|
|
class _DenyingProvider(_FilterProvider):
|
|
def __init__(self):
|
|
super().__init__(["bash"])
|
|
self.requests = []
|
|
|
|
def authorize(self, request):
|
|
self.requests.append(request)
|
|
return AuthzDecision(
|
|
allow=False,
|
|
reasons=[AuthzReason(code="authz.denied", message="blocked")],
|
|
)
|
|
|
|
provider = _DenyingProvider()
|
|
config = _app_config(
|
|
authorization=AuthorizationConfig(
|
|
enabled=True,
|
|
provider=AuthorizationProviderConfig(use="unused:Provider"),
|
|
)
|
|
)
|
|
middlewares = build_lead_runtime_middlewares(
|
|
app_config=config,
|
|
authorization_provider=provider,
|
|
)
|
|
authorization_middleware = next(middleware for middleware in middlewares if isinstance(middleware, GuardrailMiddleware) and isinstance(middleware.provider, GuardrailAuthorizationAdapter))
|
|
request = MagicMock()
|
|
request.tool_call = {"name": "bash", "args": {"command": "whoami"}, "id": "call-1"}
|
|
request.runtime = SimpleNamespace(
|
|
context={
|
|
"user_id": "u1",
|
|
"user_role": "guest",
|
|
"thread_id": "t1",
|
|
"run_id": "r1",
|
|
}
|
|
)
|
|
handler = MagicMock()
|
|
|
|
result = authorization_middleware.wrap_tool_call(request, handler)
|
|
|
|
handler.assert_not_called()
|
|
assert result.status == "error"
|
|
assert "authz.denied" in result.content
|
|
assert provider.requests[0].principal == Principal(user_id="u1", role="guest")
|
|
assert provider.requests[0].target == "bash"
|
|
assert provider.requests[0].context["run_id"] == "r1"
|
|
|
|
|
|
@pytest.mark.parametrize("is_bootstrap", [False, True])
|
|
def test_lead_agent_filters_all_model_visible_tools_and_reuses_provider(monkeypatch, is_bootstrap):
|
|
"""Layer 1 covers late framework tools and Layer 2 receives its provider."""
|
|
config = _app_config(
|
|
authorization=AuthorizationConfig(
|
|
enabled=True,
|
|
provider=AuthorizationProviderConfig(
|
|
use="deerflow.authz.rbac:RbacAuthorizationProvider",
|
|
config={"roles": {"user": {"tools": {"allow": ["safe_tool", "history_read"]}}}},
|
|
),
|
|
),
|
|
models=[
|
|
ModelConfig(
|
|
name="test-model",
|
|
display_name="Test model",
|
|
use="langchain_openai:ChatOpenAI",
|
|
model="test-model",
|
|
)
|
|
],
|
|
)
|
|
config.skills.deferred_discovery = True
|
|
config.task_continuity.enabled = True
|
|
|
|
monkeypatch.setattr(lead_agent_module, "_resolve_model_name", lambda *args, **kwargs: "test-model")
|
|
monkeypatch.setattr(lead_agent_module, "create_chat_model", lambda **kwargs: object())
|
|
monkeypatch.setattr(lead_agent_module, "create_agent", lambda **kwargs: kwargs)
|
|
monkeypatch.setattr(lead_agent_module, "apply_prompt_template", lambda **kwargs: "prompt")
|
|
monkeypatch.setattr(lead_agent_module, "build_tracing_callbacks", lambda: [])
|
|
monkeypatch.setattr(lead_agent_module, "_load_enabled_available_skills", lambda *args, **kwargs: [])
|
|
monkeypatch.setattr(
|
|
lead_agent_module,
|
|
"build_skill_search_setup",
|
|
lambda *args, **kwargs: SimpleNamespace(
|
|
describe_skill_tool=_tool("describe_skill"),
|
|
skill_names=frozenset({"example"}),
|
|
),
|
|
raising=False,
|
|
)
|
|
monkeypatch.setattr("deerflow.skills.describe.build_skill_search_setup", lead_agent_module.build_skill_search_setup)
|
|
monkeypatch.setattr("deerflow.tools.get_available_tools", lambda **kwargs: [_tool("safe_tool"), _tool("denied_tool")])
|
|
monkeypatch.setattr(lead_agent_module, "should_use_memory_tools", lambda memory_config: True)
|
|
monkeypatch.setattr(
|
|
lead_agent_module,
|
|
"_append_memory_tools_without_name_conflicts",
|
|
lambda tools: tools.append(_tool("memory_search")),
|
|
)
|
|
|
|
captured: dict[str, object] = {}
|
|
|
|
def _capture_middlewares(*args, **kwargs):
|
|
captured.update(kwargs)
|
|
return []
|
|
|
|
monkeypatch.setattr(lead_agent_module, "build_middlewares", _capture_middlewares)
|
|
|
|
runtime_context = {"user_role": "user"}
|
|
if is_bootstrap:
|
|
runtime_context["is_bootstrap"] = True
|
|
result = lead_agent_module._make_lead_agent({"context": runtime_context}, app_config=config)
|
|
|
|
assert [tool.name for tool in result["tools"]] == ["safe_tool", "history_read"]
|
|
assert captured["authorization_provider"] is not None
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"skills_allow,deferred,expected_names",
|
|
[
|
|
(["bootstrap"], True, frozenset({"bootstrap"})),
|
|
([], True, frozenset()),
|
|
([], False, None),
|
|
],
|
|
)
|
|
def test_bootstrap_assembly_uses_filtered_skill_set(monkeypatch, skills_allow, deferred, expected_names):
|
|
"""[P1 regression] The bootstrap branch must feed the authorization-filtered
|
|
set to ``build_middlewares`` and ``apply_prompt_template`` — not the raw
|
|
``_BOOTSTRAP_SKILL_NAMES`` — and must preserve an empty ``skill_names``
|
|
index instead of converting it to ``None`` (a None falls through to the
|
|
legacy full-metadata prompt, which reloads every enabled skill from storage
|
|
and re-advertises the policy-denied one; a raw {"bootstrap"} set re-admits
|
|
it to slash activation)."""
|
|
config = _app_config(
|
|
authorization=AuthorizationConfig(
|
|
enabled=True,
|
|
provider=AuthorizationProviderConfig(
|
|
use="deerflow.authz.rbac:RbacAuthorizationProvider",
|
|
config={"roles": {"user": {"skills": {"allow": skills_allow}}}},
|
|
),
|
|
),
|
|
models=[
|
|
ModelConfig(
|
|
name="test-model",
|
|
display_name="Test model",
|
|
use="langchain_openai:ChatOpenAI",
|
|
model="test-model",
|
|
)
|
|
],
|
|
)
|
|
config.skills.deferred_discovery = deferred
|
|
|
|
monkeypatch.setattr(lead_agent_module, "_resolve_model_name", lambda *args, **kwargs: "test-model")
|
|
monkeypatch.setattr(lead_agent_module, "create_chat_model", lambda **kwargs: object())
|
|
monkeypatch.setattr(lead_agent_module, "create_agent", lambda **kwargs: kwargs)
|
|
monkeypatch.setattr(lead_agent_module, "build_tracing_callbacks", lambda: [])
|
|
# Emulate the real loader's allowlist filtering so the allowed case keeps
|
|
# the bootstrap skill and the denied case drops it.
|
|
monkeypatch.setattr(
|
|
lead_agent_module,
|
|
"_load_enabled_available_skills",
|
|
lambda available_skills, **kwargs: [s for s in (SimpleNamespace(name="bootstrap"), SimpleNamespace(name="other")) if available_skills is None or s.name in available_skills],
|
|
)
|
|
# Emulate the real setup mapping (bootstrap_skills -> skill_names) without
|
|
# depending on SkillCatalog internals; the real mapping has its own tests.
|
|
monkeypatch.setattr(
|
|
lead_agent_module,
|
|
"build_skill_search_setup",
|
|
lambda skills, *, enabled, container_base_path, skill_authorization=None: SimpleNamespace(
|
|
describe_skill_tool=None,
|
|
skill_names=frozenset(s.name for s in skills),
|
|
),
|
|
raising=False,
|
|
)
|
|
monkeypatch.setattr("deerflow.tools.get_available_tools", lambda **kwargs: [])
|
|
monkeypatch.setattr(lead_agent_module, "should_use_memory_tools", lambda memory_config: False)
|
|
|
|
captured: dict[str, dict] = {}
|
|
|
|
def _capture_middlewares(*args, **kwargs):
|
|
captured.setdefault("middlewares", kwargs)
|
|
return []
|
|
|
|
def _capture_prompt(**kwargs):
|
|
captured.setdefault("prompt", kwargs)
|
|
return "prompt"
|
|
|
|
monkeypatch.setattr(lead_agent_module, "build_middlewares", _capture_middlewares)
|
|
monkeypatch.setattr(lead_agent_module, "apply_prompt_template", _capture_prompt)
|
|
|
|
lead_agent_module._make_lead_agent({"context": {"is_bootstrap": True, "user_role": "user"}}, app_config=config)
|
|
|
|
expected_available = {"bootstrap"} if "bootstrap" in skills_allow else set()
|
|
assert captured["middlewares"]["available_skills"] == expected_available
|
|
assert captured["prompt"]["available_skills"] == expected_available
|
|
# The empty index must survive as a set, never round-trip through None.
|
|
assert captured["prompt"]["skill_names"] == expected_names
|
|
assert captured["prompt"]["skill_names"] is not None or expected_names is None
|