feat(authz): enforce skill authorization at assembly and slash-activation (#4063 Phase 3) - #4541
feat(authz): enforce skill authorization at assembly and slash-activation (#4063 Phase 3)#4541hata33 wants to merge 1 commit into
Conversation
willem-bd
left a comment
There was a problem hiding this comment.
Reviewed Phase 3 skill authorization. Layer 1 filtering is wired into both assembly paths (lead agent + subagent _load_skills) and the Layer 2 reuse of _available_skills for slash-activation gating is the right call.
One concrete fail-open bypass worth addressing: when available_skills=None (no agent-level allowlist) and _all_configured_skill_names raises (any storage I/O error is swallowed at line 99), filter_available_skills_by_authorization returns None. That None then propagates to SkillActivationMiddleware (which skips its reference.name not in self._available_skills check when the set is None) and to the subagent _load_skills (else: return all_skills), so every installed skill becomes activatable/loadable even with fail_closed=true. The fail-closed guarantee is enforced for provider errors inside filter_resources but not for candidate-set resolution errors.
Minor: apply_skill_authorization is re-exported in authz/__init__.py __all__ and exercised by tests, but is not called from any production path in this PR (the integrations use filter_available_skills_by_authorization directly).
| candidates = _all_configured_skill_names(app_config) | ||
|
|
||
| if not candidates: | ||
| return available_skills |
There was a problem hiding this comment.
Fail-open bypass when candidate-set resolution errors. When available_skills=None (no agent-level allowlist) and authorization is enabled with fail_closed=True, an exception in _all_configured_skill_names is swallowed at line 99 (except Exception: return []), so this early-exit returns available_skills unchanged — i.e. None. That None then propagates:
- Lead agent:
SkillActivationMiddlewareis constructed withavailable_skills=None, so its gateif self._available_skills is not None and reference.name not in self._available_skills(skill_activation_middleware.py:151) is skipped — every installed skill can be slash-activated. - Subagent:
_load_skillshitsif allowed is not None: ... else: return all_skills(executor.py:619-621) and returns the full unfiltered set.
This contradicts the fail-closed contract the sibling filter_resources_by_authorization enforces (its docstring: "provider errors and malformed filter results deny every resource when fail_closed is true"). Provider exceptions raised inside filter_resources are handled correctly at line 78, but candidate-set resolution errors bypass it. Consider returning set() here when authz_config.fail_closed is true (or propagating the resolution error instead of swallowing it at line 99).
|
@hata33 please take a look at the recent review comments. |
79df486 to
c8237f8
Compare
|
Thanks @willem-bd — addressed both points. Rebased onto latest Fail-open bypass — fixed. The root cause was
The Added 3 regression tests:
Mutation-tested: reverting only the
|
willem-bd
left a comment
There was a problem hiding this comment.
Follow-up at HEAD c8237f89. The fail-open bypass I flagged earlier at skill_filter.py:83 is properly resolved in this revision — _all_configured_skill_names now raises and the caller returns set() under fail_closed (lines 99-101), with explicit regression tests (test_filter_available_skills_candidate_resolution_error_fail_closed/open). One new gap below: the embedded DeerFlowClient path is missing the Layer 1 skill filter, even though PR #4540 wired model authz into the same function for the same bypass reason.
| # still constrains via filter_resources in that case. | ||
| from deerflow.authz.skill_filter import filter_available_skills_by_authorization | ||
|
|
||
| available_skills = filter_available_skills_by_authorization( |
There was a problem hiding this comment.
Embedded DeerFlowClient path is missing the equivalent filter. DeerFlowClient._ensure_agent (not in this PR's diff) applies model authz (_authorize_model_name at client.py:302) and tool authz (apply_tool_authorization at client.py:327), but self._available_skills — set directly from the constructor's available_skills argument — is passed unfiltered to build_middlewares(..., available_skills=self._available_skills, ...) (client.py:357) and to the skills_list intersection (client.py:312-313), without ever going through filter_available_skills_by_authorization. The model-authz comment at client.py:293-299 calls out this exact bypass pattern ("the role-scoped model policy cannot be bypassed by constructing the agent through DeerFlowClient") — the same risk applies to skills but isn't wired up here.
With authorization.enabled: true, a caller building the agent via DeerFlowClient(available_skills={"denied-skill"}) (or available_skills=None for "all skills") would bypass the role's skills policy: SkillActivationMiddleware (constructed with the unfiltered set) would permit slash-activation of any skill in the set, and the catalog/prompt would advertise skills the role is denied. Mirroring this line in _ensure_agent — e.g. self._available_skills = filter_available_skills_by_authorization(self._available_skills, context=cfg, app_config=self._app_config) right after the _authorize_model_name call — closes it and keeps the two assembly paths symmetric.
c8237f8 to
12db2fb
Compare
|
Good catch — this is the exact symmetric gap to the model-authz follow-up in #4540. Pushed in Embedded path — wired. Added 2 regression tests in
Mutation-tested: reverting only the On the route-layer symmetry (preempting the question): Skills intentionally have no Gateway route-level authorization (decision log, implementation-notes.md: "技能没有 Gateway route... 不为技能新增独立 Gateway route, per §12 Q6"). |
| """ | ||
| from deerflow.skills.storage import get_or_new_skill_storage | ||
|
|
||
| storage = get_or_new_skill_storage(app_config=app_config) |
There was a problem hiding this comment.
Per-user custom skills are silently dropped when available_skills=None and authz is enabled. _all_configured_skill_names resolves the candidate universe via get_or_new_skill_storage (process-global storage), but per-user custom skills live in UserScopedSkillStorage under {base}/users/{user_id}/skills/custom/ (see storage/__init__.py). So when the agent has no allowlist and authorization.enabled=true, filter_available_skills_by_authorization returns a non-None set of global names only; the provider never sees per-user custom skill names as candidates — even a skills: {allow: "*"} wildcard cannot reach them because they were never in candidates. SkillActivationMiddleware._resolve_activation then blocks them at line 151 (reference.name not in self._available_skills): the middleware resolves skills from user-scoped storage (_storage() -> get_or_new_user_skill_storage(self._user_id, ...) at line 112) but checks membership against the global-derived set, so a per-user custom skill that loads fine is rejected with "not available for this agent." Pre-PR, available_skills=None reached the middleware as None and the line-151 check was skipped, so those skills were activatable. Same gap in subagents/executor.py::_load_skills, where this is also redundant I/O — all_skills is already loaded from user-scoped storage a few lines above the filter call, yet the filter re-loads from global storage to build candidates. Net effect: enabling authorization disables per-user custom skill slash-activation (and subagent loading) regardless of role policy. Consider threading user_id into the resolver (or, in the executor, deriving candidates from the already-loaded all_skills) so the provider filters the same universe the activation path loads.
12db2fb to
15d462b
Compare
|
Excellent catch — this was a real correctness bug, not just a scoping nit. Pushed in Root cause confirmed. Fix. Threaded
This also removes the redundant I/O in the executor path you noted: Added 2 regression tests:
Mutation-tested: reverting only |
15d462b to
cb96d77
Compare
willem-bd
left a comment
There was a problem hiding this comment.
Round 3 review at cb96d77e. All three prior findings are properly resolved in this revision:
- Fail-open bypass on candidate-set resolution errors — fixed (
_all_configured_skill_namesnow raises; caller returnsset()underfail_closed). - Embedded
DeerFlowClient._ensure_agentpath — wired up withfilter_available_skills_by_authorization. - Per-user custom skills dropped —
user_idis now threaded into_all_configured_skill_namessoUserScopedSkillStorageis the candidate universe.
Two new suggestion-level findings below (no blockers).
| provider instance (for passing to Layer 2 middleware wiring, or | ||
| ``None`` when authorization is disabled). | ||
| """ | ||
| authz_config = app_config.authorization |
There was a problem hiding this comment.
apply_skill_authorization skips the mock-safe guard its sibling has — and has no callers or tests in this PR. Line 157 accesses app_config.authorization directly, while filter_available_skills_by_authorization (line 56) deliberately uses getattr(app_config, "authorization", None) + getattr(authz_config, "enabled", None) is not True so SimpleNamespace/Mock app_config objects in tests don't raise AttributeError (the PR description calls this pattern out as intentional: "Test-mock safe via getattr + is not True guards"). This function doesn't follow it.
It's also exported in authz/__init__.py __all__ but none of the three integration sites in this PR call it — lead agent, DeerFlowClient._ensure_agent, and subagent _load_skills all use filter_available_skills_by_authorization directly — and test_skills_authorization.py has no test for it (every test targets filter_available_skills_by_authorization or filter_resources_by_authorization). If it's intended as a public API for a later phase, adding the getattr guard + a test would keep it consistent with its sibling; if not, dropping it until a caller exists avoids shipping untested surface area (it's easy to assume it's covered because it sits next to the heavily-tested filter_available_skills_by_authorization).
| "is_internal": self.is_internal, | ||
| "authz_attributes": self.authz_attributes, | ||
| } | ||
| allowed = filter_available_skills_by_authorization( |
There was a problem hiding this comment.
Skills are loaded from disk twice when config.skills is None and authz is enabled. Line 626 already calls storage.load_skills(enabled_only=True) from UserScopedSkillStorage into all_skills. This call then enters the available_skills is None branch in filter_available_skills_by_authorization (skill_filter.py:94), which calls _all_configured_skill_names(user_id=self.user_id) — and that resolves the same UserScopedSkillStorage and calls storage.load_skills(enabled_only=True) again. Same storage instance, same flag, same user.
The executor is the one call site that already holds the full loaded list in all_skills, so it could pass [s.name for s in all_skills] as pre-resolved candidates (or filter_available_skills_by_authorization could accept a candidate-set override) and skip the second load_skills. The lead-agent and DeerFlowClient paths don't have this issue because they operate on a name set from the start.
Side note: the comment at line 644 ("so denied skills are never loaded from disk") slightly overstates the effect — by the time the filter runs, all_skills is already loaded; the filter only prunes the returned list, it doesn't avoid the disk read. Consider rewording to match what actually happens (e.g. "so denied skills never reach the returned list / are never turned into tools").
cb96d77 to
593aeec
Compare
|
Both suggestions addressed in
Executor double |
593aeec to
3a1f4bd
Compare
|
@WillemJiang @willem-bd friendly ping — this PR is ready for review/merge:
Would appreciate a review or approval when you have a moment. Happy to make any further changes if needed. |
zhfeng
left a comment
There was a problem hiding this comment.
[P2] Preserve the agent skill allowlist when filtering provider results (backend/packages/harness/deerflow/authz/skill_filter.py:107)
filter_available_skills_by_authorization() returns set(allowed) directly. Although the provider contract says results must be a subset of candidates, this boundary only validates list[str]. A buggy custom provider can therefore return extra skill names, expanding an explicit agent-level allowlist and making those skills discoverable or slash-activatable.
The generic filter_resources_by_authorization() is defensive because it filters the original resources. This helper should likewise intersect the provider result with candidates, preserving the original candidate boundary, for example:
allowed_names = set(allowed)
return {name for name in candidates if name in allowed_names}Please add a regression test using a provider that returns ["allowed-skill", "injected-skill"] and verify that the injected name is excluded.
3a1f4bd to
548b2ce
Compare
|
@zhfeng good point — fixed in
Added |
…tion (bytedance#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.
548b2ce to
ac463cf
Compare
|
Rebased onto latest Conflicts resolved (3 files, all from the sandbox PR #4911):
Verification: The dangling reference in |
Part of #4063
Why
Phase 2A (#4439) connected the
AuthorizationProviderto Gateway HTTP route permissions, but skill access remained controlled only by the agent-config allowlist (config.skills) — no role policy could deny a skill. This PR addsskill:activateenforcement so RBAC policies likeskills: {allow: ["data-analysis"]}actually take effect. It is the second of three Phase 3 resource-type PRs (Models → Skills → Sandbox).What changed
Layer 1 (assembly-time, mirrors Phase 1B):
filter_available_skills_by_authorization()inskill_filter.py— filters the skill-name allowlist (set[str] | None) by the provider's"skill"policy. In the lead agent, runs after_available_skill_names()so the catalog (describe_skill) andSkillActivationMiddlewareshare one filtered set. In the subagent executor_load_skills(), filtersconfig.skillsbefore any disk I/O.filter_resources_by_authorization()inenforcement.py— generic version offilter_tools_by_authorizationfor any resource with anameattribute (skills, models, etc.).Layer 2 (slash-activation):
SkillActivationMiddleware._resolve_activationalready checksreference.name not in self._available_skills. Since Layer 1 filters the allowlist at assembly time, denied skills cannot be slash-activated — the existing check enforces the policy for free.authorization.enabled: falseis a complete no-op.available_skills=None(no agent-level allowlist) + enabled authorization resolves all skill names from config and filters them. The RBAC provider already maps"skill" → "skills"(_RESOURCE_POLICY_KEYS), so no schema change is needed. Test-mock safe viagetattr+is not Trueguards (same pattern as Phase 1B).Authorization RFC continuity confirmation
docs/plans/2026-07-10-pluggable-authorization-rfc.md.docs/plans/2026-07-10-pluggable-authorization-implementation-notes.md.Surface area
authorization.enabled: true(opt-in). Defaultfalse= all skills available, identical to today.Validation
Related